From fa9b8869b670269e1574c93802e18563da8d3fa9 Mon Sep 17 00:00:00 2001 From: lafleur Date: Fri, 9 May 2025 11:17:50 +0200 Subject: [PATCH] feat(oidc_provider) use askama templates Implements a custom OidcResponse with CSP headers and oxide-auth processing compatibility. --- Cargo.toml | 2 + src/api/Cargo.toml | 1 + src/api/client/oidc/authorize.rs | 146 ++++-------------------------- src/api/client/oidc/login.rs | 105 +++++---------------- src/api/client/oidc/mod.rs | 10 +- src/api/client/oidc/register.rs | 2 +- src/api/client/oidc/token.rs | 6 -- src/service/oidc/mod.rs | 4 +- src/web/Cargo.toml | 7 ++ src/web/mod.rs | 1 + src/web/oidc.rs | 56 ++++++++++++ src/web/oidc/authorize.rs | 43 +++++++++ src/web/oidc/consent.rs | 54 +++++++++++ src/web/oidc/login.rs | 132 +++++++++++++++++++++++++++ src/web/oidc/request.rs | 116 ++++++++++++++++++++++++ src/web/oidc/response.rs | 127 ++++++++++++++++++++++++++ src/web/templates/consent.html.j2 | 14 +++ src/web/templates/login.html.j2 | 20 ++++ 18 files changed, 621 insertions(+), 225 deletions(-) create mode 100644 src/web/oidc.rs create mode 100644 src/web/oidc/authorize.rs create mode 100644 src/web/oidc/consent.rs create mode 100644 src/web/oidc/login.rs create mode 100644 src/web/oidc/request.rs create mode 100644 src/web/oidc/response.rs create mode 100644 src/web/templates/consent.html.j2 create mode 100644 src/web/templates/login.html.j2 diff --git a/Cargo.toml b/Cargo.toml index 3a5cc884..b213df30 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -104,6 +104,8 @@ features = [ "tokio", "tracing", "query", + # Needed for debug_handler. + #"macros", ] [workspace.dependencies.axum-extra] diff --git a/src/api/Cargo.toml b/src/api/Cargo.toml index b9a61901..3ddb0f0d 100644 --- a/src/api/Cargo.toml +++ b/src/api/Cargo.toml @@ -92,6 +92,7 @@ tokio.workspace = true tracing.workspace = true oxide-auth.workspace = true oxide-auth-axum.workspace = true +conduwuit-web.workspace = true percent-encoding.workspace = true [lints] diff --git a/src/api/client/oidc/authorize.rs b/src/api/client/oidc/authorize.rs index 2d5adef6..12a1ab79 100644 --- a/src/api/client/oidc/authorize.rs +++ b/src/api/client/oidc/authorize.rs @@ -1,29 +1,13 @@ +use conduwuit_web::oidc::{oidc_consent_form, oidc_login_form, AuthorizationQuery, OidcRequest, OidcResponse}; use oxide_auth_axum::{OAuthResponse, OAuthRequest}; use oxide_auth::{ endpoint::{OwnerConsent, Solicitation}, frontends::simple::endpoint::FnSolicitor, - primitives::registrar::PreGrant, }; use axum::extract::{Query, State}; -use serde_html_form; use conduwuit::{Result, err}; -use reqwest::Url; use percent_encoding::percent_decode_str; - -/// The set of query parameters a client needs to get authorization. -#[derive(serde::Deserialize, Debug)] -pub(crate) struct OAuthQuery { - client_id: String, - redirect_uri: Url, - scope: String, - state: String, - code_challenge: String, - code_challenge_method: String, - response_type: String, - response_mode: String, -} - /// # `GET /_matrix/client/unstable/org.matrix.msc2964/authorize` /// /// Authenticate a user and device, and solicit the user's consent. @@ -34,9 +18,9 @@ pub(crate) struct OAuthQuery { /// access to stage two, [authorize_consent]. pub(crate) async fn authorize( State(services): State, - Query(query): Query, - oauth: OAuthRequest, -) -> Result { + Query(query): Query, + oauth: OidcRequest, +) -> Result { tracing::trace!("processing OAuth request: {query:?}"); // Enforce MSC2964's restrictions on OAuth2 flow. let Ok(scope) = percent_decode_str(&query.scope).decode_utf8() else { @@ -60,16 +44,13 @@ pub(crate) async fn authorize( .client .as_ref() .map(|s| s.domain().expect("well-known client should be a domain")); - let login_redirect = OAuthResponse::default() - .body(&login_form(hostname, &query)) - .content_type("text/html") - .expect("should set Content-Type on OAuth response"); + let hostname = hostname.unwrap_or("Continuwuity"); match oauth.authorization_header() { | None => { - return Ok(login_redirect); + return Ok(oidc_login_form(hostname, &query)); }, | Some(token) => if services.users.find_from_token(token).await.is_err() { - return Ok(login_redirect); + return Ok(oidc_login_form(hostname, &query)); } } // TODO register the device ID ? @@ -77,18 +58,7 @@ pub(crate) async fn authorize( services .oidc .endpoint() - .with_solicitor(FnSolicitor(move |_: &mut _, solicitation: Solicitation<'_>| - OwnerConsent::InProgress( - OAuthResponse::default() - .body(&consent_page_html( - "/_matrix/client/unstable/org.matrix.msc2964/authorize", - solicitation, - hostname.unwrap_or("conduwuit"), - )) - .content_type("text/html") - .expect("set content type on consent form") - ) - )) + .with_solicitor(oidc_consent_form(hostname, &query)) .authorization_flow() .execute(oauth) .map_err(|err| err!("authorization failed: {err:?}")) @@ -116,99 +86,15 @@ pub(crate) async fn authorize_consent( services .oidc .endpoint() - .with_solicitor(FnSolicitor(move |_: &mut _, solicitation: Solicitation<'_>| - match allowed { - | false => OwnerConsent::Denied, - | true => OwnerConsent::Authorized(solicitation.pre_grant().client_id.clone()) - } - )) + .with_solicitor( + FnSolicitor(move |_: &mut _, solicitation: Solicitation<'_>| + match allowed { + | false => OwnerConsent::Denied, + | true => OwnerConsent::Authorized(solicitation.pre_grant().client_id.clone()) + } + ) + ) .authorization_flow() .execute(oauth) .map_err(|err| err!(Request(Unknown("consent request failed: {err:?}")))) } - -fn login_form( - hostname: Option<&str>, - OAuthQuery { - client_id, - redirect_uri, - scope, - state, - code_challenge, - code_challenge_method, - response_type, - response_mode, - }: &OAuthQuery, -) -> String { - let hostname = hostname.unwrap_or(""); - - format!( - r#" - - - - - - - -
-

{hostname} login

-
- - - - - - - - - - - -
-
- - - "# - ) -} - -pub(crate) fn consent_page_html( - route: &str, - solicitation: Solicitation<'_>, - hostname: &str, -) -> String { - let state = solicitation.state(); - let grant = solicitation.pre_grant(); - let PreGrant { client_id, redirect_uri, scope } = grant; - let mut args = vec![ - ("response_type", "code"), - ("client_id", client_id.as_str()), - ("redirect_uri", redirect_uri.as_str()), - ]; - if let Some(state) = state { - args.push(("state", state)); - } - let args = serde_html_form::to_string(args).unwrap(); - - format!( - r#" - - - - - - -
-

{hostname} login

- '{client_id}' (at {redirect_uri}) is requesting permission for '{scope}' -
- - -
-
- - - "#, - ) -} diff --git a/src/api/client/oidc/login.rs b/src/api/client/oidc/login.rs index 5fa7dd81..f5f8308f 100644 --- a/src/api/client/oidc/login.rs +++ b/src/api/client/oidc/login.rs @@ -1,57 +1,20 @@ -use super::authorize::consent_page_html; -use oxide_auth_axum::{OAuthRequest, OAuthResponse}; -use oxide_auth::{ - endpoint::{OwnerConsent, Solicitation}, - frontends::simple::endpoint::FnSolicitor, +use conduwuit_web::oidc::{ + oidc_consent_form, + AuthorizationQuery, + LoginError, + LoginQuery, + OidcRequest, + OidcResponse, }; -use axum::extract::{Form, FromRequest, State}; +use axum::extract::State; use conduwuit::{ Result, err, utils::hash::verify_password, }; use ruma::user_id::UserId; -use reqwest::Url; -use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC}; - -/// The set of query parameters a client needs to get authorization. -#[derive(serde::Deserialize, Debug)] -pub(crate) struct LoginForm { - username: String, - password: String, - client_id: String, - redirect_uri: Url, - scope: String, - state: String, - code_challenge: String, - code_challenge_method: String, - response_type: String, - response_mode: String, -} - -impl From for String { - /// Turn the OAuth parameters from a Form into a GET query, suitable for - /// then turning it into oxide-auth's OAuthRequest. Strips the unneeded - /// username and password. - fn from(value: LoginForm) -> Self { - let encode = |text: &str| -> String { - utf8_percent_encode(text, NON_ALPHANUMERIC).to_string() - }; - - format!( - "?client_id={}&redirect_uri={}&scope={}&state={}&code_challenge={}&code_challenge_method={}&response_type={}&response_mode={}", - encode(&value.client_id), - encode(&value.redirect_uri.to_string()), - encode(&value.scope), - encode(&value.state), - encode(&value.code_challenge), - encode(&value.code_challenge_method), - encode(&value.response_type), - encode(&value.response_mode), - ) - } -} +//#[axum::debug_handler] /// # `POST /_matrix/client/unstable/org.matrix.msc2964/login` /// /// Display a login UI to the user and return an authorization code on success. @@ -60,14 +23,17 @@ impl From for String { /// [super::authorize::authorize_consent]. pub(crate) async fn oidc_login( State(services): State, - Form(login_query): Form, -) -> Result { + request: OidcRequest, +) -> Result { + let query: LoginQuery = request.clone().try_into().map_err(|LoginError(err)| + err!(Request(InvalidParam("Cannot process login form. {err}"))) + )?; // Only accept local usernames. Mostly to simplify things at first. let user_id = UserId::parse_with_server_name( - login_query.username.clone(), + query.username.clone(), &services.config.server_name ) - .map_err(|e| err!(Request(InvalidUsername(warn!("Username is invalid: {e}")))))?; + .map_err(|e| err!(Request(InvalidUsername("Username is invalid: {e}"))))?; if ! services.users.exists(&user_id).await { return Err(err!(Request(Unknown("unknown username")))); @@ -82,49 +48,26 @@ pub(crate) async fn oidc_login( if valid_hash.is_empty() { return Err(err!(Request(UserDeactivated("the user's hash was not found")))); } - if let Err(_) = verify_password(&login_query.password, &valid_hash) { + if let Err(_) = verify_password(&query.password, &valid_hash) { return Err(err!(Request(InvalidParam("password does not match")))); } + tracing::info!("{user_id:?} passed, forwarding to consent page"); - // Build up a GET query and parse it as an OAuthRequest. - let login_query: String = login_query.into(); - let login_url = http::Uri::builder() - .scheme("https") - .authority(services.config.server_name.as_str()) - .path_and_query(login_query) - .build() - .expect("should be parseable"); - let req: http::Request = http::Request::builder() - .method("GET") - .uri(&login_url) - .body(axum::body::Body::empty()) - .expect("login form OAuth parameters parseable as a query"); - let oauth = OAuthRequest::from_request(req, &"") - .await - .expect("request parseable as an OAuth query"); let hostname = services .config .well_known .client .as_ref() - .map(|s| s.domain().expect("well-known client should be a domain")); + .map(|s| s.domain().expect("well-known client should have a domain")); + + let hostname = hostname.unwrap_or("Continuwuity"); + let authorization_query: AuthorizationQuery = query.into(); services .oidc .endpoint() - .with_solicitor(FnSolicitor(move |_: &mut _, solicitation: Solicitation<'_>| - OwnerConsent::InProgress( - OAuthResponse::default() - .body(&consent_page_html( - "/_matrix/client/unstable/org.matrix.msc2964/authorize", - solicitation, - hostname.unwrap_or("conduwuit"), - )) - .content_type("text/html") - .expect("set content type on consent form") - ) - )) + .with_solicitor(oidc_consent_form(hostname, &authorization_query)) .authorization_flow() - .execute(oauth) + .execute(request) .map_err(|err| err!(Request(Unknown("authorization failed: {err:?}")))) } diff --git a/src/api/client/oidc/mod.rs b/src/api/client/oidc/mod.rs index c7bd52e7..3303f9a2 100644 --- a/src/api/client/oidc/mod.rs +++ b/src/api/client/oidc/mod.rs @@ -1,12 +1,14 @@ /// OIDC /// /// Stands for OpenID Connect, and is an authentication scheme relying on OAuth2. -/// The MSC2964 Matrix Spec Proposal describes an authentication process based -/// on OIDC with restrictions. See the [sample flow] for details on what's expected. +/// The [MSC2964] Matrix Spec Proposal describes an authentication process based +/// on the OIDC flow, with restrictions. See the [sample flow] for details on +/// what's expected. /// -/// This module implements the needed endpoints. It relies on [`service::oidc`] and -/// the [oxide-auth] crate. +/// This module implements the needed endpoints. It relies on the [oxide-auth] +/// crate, and the [`service::oidc`] and [`web::oidc`] modules. /// +/// [MSC2964]: https://github.com/matrix-org/matrix-spec-proposals/pull/2964 /// [oxide-auth]: https://docs.rs/oxide-auth /// [sample flow]: https://github.com/sandhose/matrix-spec-proposals/blob/msc/sandhose/oauth2-profile/proposals/2964-oauth2-profile.md#sample-flow diff --git a/src/api/client/oidc/register.rs b/src/api/client/oidc/register.rs index 556778c2..7848b3e1 100644 --- a/src/api/client/oidc/register.rs +++ b/src/api/client/oidc/register.rs @@ -51,7 +51,7 @@ pub(crate) struct ClientResponse { /// # `GET /_matrix/client/unstable/org.matrix.msc2964/device/register` /// -/// Register a client, as specified in [MSC2966]. This client, "device" in Oidc parlance, +/// Register a client, as specified in [MSC2966]. This client, "device" in OIDC parlance, /// will have the right to submit [super::authorize::authorize] requests. /// /// [MSC2966]: https://github.com/matrix-org/matrix-spec-proposals/pull/2966 diff --git a/src/api/client/oidc/token.rs b/src/api/client/oidc/token.rs index 3d3f19db..543ac9bb 100644 --- a/src/api/client/oidc/token.rs +++ b/src/api/client/oidc/token.rs @@ -1,9 +1,3 @@ -/// Implementation of [MSC2964]'s OAuth2 restricted flow using the [oxide-auth] -/// crate. See the MSC for restrictions that apply to this flow. -/// -/// [MSC2965]: https://github.com/matrix-org/matrix-spec-proposals/pull/2965 -/// [oxide-auth]: https://docs.rs/oxide-auth - use oxide_auth_axum::{OAuthResponse, OAuthRequest}; use oxide_auth::endpoint::QueryParameter; use axum::{ diff --git a/src/service/oidc/mod.rs b/src/service/oidc/mod.rs index ebc2118d..b2eb99d9 100644 --- a/src/service/oidc/mod.rs +++ b/src/service/oidc/mod.rs @@ -3,8 +3,7 @@ /// Provides the registrar, authorizer and issuer needed by [api::client::oidc]. /// The whole OAuth2 flow is taken care of by [oxide-auth]. /// -/// TODO At the moment this service provides no method to dynamically add a -/// client. That would need a dedicated space in the database. +/// TODO this service would need a dedicated space in the database. /// /// [oxide-auth]: https://docs.rs/oxide-auth @@ -90,7 +89,6 @@ impl Service { solicitor: Vacant, // Scope configured later. scopes: Vacant, - // `rocket::Response` is `Default`, so we don't need more configuration. response: Vacant, } } diff --git a/src/web/Cargo.toml b/src/web/Cargo.toml index 5c2dbebb..2076a990 100644 --- a/src/web/Cargo.toml +++ b/src/web/Cargo.toml @@ -22,6 +22,8 @@ crate-type = [ [dependencies] conduwuit-build-metadata.workspace = true conduwuit-service.workspace = true +conduwuit-core.workspace = true +async-trait.workspace = true askama = "0.14.0" @@ -30,6 +32,11 @@ futures.workspace = true tracing.workspace = true rand.workspace = true thiserror.workspace = true +serde.workspace = true +url.workspace = true +percent-encoding.workspace = true +oxide-auth.workspace = true +oxide-auth-axum.workspace = true [lints] workspace = true diff --git a/src/web/mod.rs b/src/web/mod.rs index 9c6a5d83..ab3d45be 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -8,6 +8,7 @@ use axum::{ }; use conduwuit_build_metadata::{GIT_REMOTE_COMMIT_URL, GIT_REMOTE_WEB_URL, version_tag}; use conduwuit_service::state; +pub mod oidc; pub fn build() -> Router { let router = Router::::new(); diff --git a/src/web/oidc.rs b/src/web/oidc.rs new file mode 100644 index 00000000..97d0099c --- /dev/null +++ b/src/web/oidc.rs @@ -0,0 +1,56 @@ +use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC}; +use askama::Template; +// Imports needed by askama templates. +use crate::{ + VERSION_EXTRA, GIT_REMOTE_WEB_URL, GIT_REMOTE_COMMIT_URL, +}; + +mod authorize; +mod consent; +mod login; +mod response; +mod request; +pub use authorize::AuthorizationQuery; +pub use consent::oidc_consent_form; +pub use login::{LoginQuery, LoginError, oidc_login_form}; +pub use request::OidcRequest; +pub use response::OidcResponse; + +/// The parameters for the OIDC login page template. +#[derive(Template)] +#[template(path = "login.html.j2")] +pub(crate) struct LoginPageTemplate<'a> { + nonce: &'a str, + hostname: &'a str, + route: &'a str, + client_id: &'a str, + redirect_uri: &'a str, + scope: &'a str, + state: &'a str, + code_challenge: &'a str, + code_challenge_method: &'a str, + response_type: &'a str, + response_mode: &'a str, +} + + +/// The parameters for the OIDC consent page template. +#[derive(Template)] +#[template(path = "consent.html.j2")] +pub(crate) struct ConsentPageTemplate<'a> { + nonce: &'a str, + hostname: &'a str, + route: &'a str, + client_id: &'a str, + redirect_uri: &'a str, + scope: &'a str, + state: &'a str, + code_challenge: &'a str, + code_challenge_method: &'a str, + response_type: &'a str, + response_mode: &'a str, +} + +pub(crate) fn encode(text: &str) -> String { + utf8_percent_encode(text, NON_ALPHANUMERIC).to_string() +} diff --git a/src/web/oidc/authorize.rs b/src/web/oidc/authorize.rs new file mode 100644 index 00000000..b0c47dae --- /dev/null +++ b/src/web/oidc/authorize.rs @@ -0,0 +1,43 @@ +use url::Url; +use super::LoginQuery; + +/// The set of parameters required for an OIDC authorization request. +#[derive(serde::Deserialize, Debug)] +pub struct AuthorizationQuery { + pub client_id: String, + pub redirect_uri: Url, + pub scope: String, + pub state: String, + pub code_challenge: String, + pub code_challenge_method: String, + pub response_type: String, + pub response_mode: String, +} + +impl From for AuthorizationQuery { + fn from(value: LoginQuery) -> Self { + let LoginQuery { + client_id, + redirect_uri, + scope, + state, + code_challenge, + code_challenge_method, + response_type, + response_mode, + .. + } = value; + + AuthorizationQuery { + client_id, + redirect_uri, + scope, + state, + code_challenge, + code_challenge_method, + response_type, + response_mode, + } + } +} + diff --git a/src/web/oidc/consent.rs b/src/web/oidc/consent.rs new file mode 100644 index 00000000..231f36e7 --- /dev/null +++ b/src/web/oidc/consent.rs @@ -0,0 +1,54 @@ +use super::{ + encode, + ConsentPageTemplate, + AuthorizationQuery, + OidcResponse, +}; +use askama::Template; +use oxide_auth::frontends::simple::request::{Body, Status}; + +/// A web consent solicitor form for the OIDC authentication flow. +/// +/// Asks the resource owner for their consent to let a client access their data +/// on this server. +pub fn oidc_consent_form( + hostname: &str, + query: &AuthorizationQuery, +) -> OidcResponse { + // The target request route. + let route = "/_matrix/client/unstable/org.matrix.msc2964/authorize"; + let nonce = rand::random::().to_string(); + let body = Some(Body::Text(consent_page(hostname, query, route, &nonce))); + + OidcResponse { + status: Status::Ok, + location: None, + www_authenticate: None, + body, + nonce, + } +} + +/// Render the html contents of the user consent page. +fn consent_page( + hostname: &str, + query: &AuthorizationQuery, + route: &str, + nonce: &str, +) -> String { + let template = ConsentPageTemplate { + nonce, + hostname, + route, + client_id: &encode(query.client_id.as_str()), + redirect_uri: &encode(query.redirect_uri.as_str()), + scope: &encode(query.scope.as_str()), + state: &encode(query.state.as_str()), + code_challenge: &encode(query.code_challenge.as_str()), + code_challenge_method: &encode(query.code_challenge_method.as_str()), + response_type: &encode(query.response_type.as_str()), + response_mode: &encode(query.response_mode.as_str()), + }; + + template.render().expect("consent page render") +} diff --git a/src/web/oidc/login.rs b/src/web/oidc/login.rs new file mode 100644 index 00000000..0e1f18ad --- /dev/null +++ b/src/web/oidc/login.rs @@ -0,0 +1,132 @@ +use super::{ + AuthorizationQuery, + LoginPageTemplate, + OidcRequest, + OidcResponse, +}; +use askama::Template; +use oxide_auth::{ + endpoint::QueryParameter, + frontends::simple::request::{Body, Status}, +}; +use url::Url; +use std::str::FromStr; + + +/// The set of query parameters a client needs to get authorization. +#[derive(serde::Deserialize, Debug, Clone)] +pub struct LoginQuery { + pub username: String, + pub password: String, + pub client_id: String, + pub redirect_uri: Url, + pub scope: String, + pub state: String, + pub code_challenge: String, + pub code_challenge_method: String, + pub response_type: String, + pub response_mode: String, +} + +#[derive(Debug)] +pub struct LoginError(pub String); + +impl TryFrom for LoginQuery { + type Error = LoginError; + + fn try_from(value: OidcRequest) -> Result { + let body = value.body().expect("body in OidcRequest"); + + let Some(username) = body.unique_value("username") else { + return Err(LoginError("missing field: username".to_string())); + }; + let Some(password) = body.unique_value("password") else { + return Err(LoginError("missing field: password".to_string())); + }; + let Some(client_id) = body.unique_value("client_id") else { + return Err(LoginError("missing field: client_id".to_string())); + }; + let Some(redirect_uri) = body.unique_value("redirect_uri") else { + return Err(LoginError("missing field: redirect_uri".to_string())); + }; + let Some(scope) = body.unique_value("scope") else { + return Err(LoginError("missing field: scope".to_string())); + }; + let Some(state) = body.unique_value("state") else { + return Err(LoginError("missing field: state".to_string())); + }; + let Some(code_challenge) = body.unique_value("code_challenge") else { + return Err(LoginError("missing field: code_challenge".to_string())); + }; + let Some(code_challenge_method) = body.unique_value("code_challenge_method") else { + return Err(LoginError("missing field: code_challenge_method".to_string())); + }; + let Some(response_type) = body.unique_value("response_type") else { + return Err(LoginError("missing field: response_type".to_string())); + }; + let Some(response_mode) = body.unique_value("response_mode") else { + return Err(LoginError("missing field: response_mode".to_string())); + }; + let Ok(redirect_uri) = Url::from_str(&redirect_uri) else { + return Err(LoginError("invalid field: redirect_uri".to_string())); + }; + + Ok(LoginQuery { + username: username.to_string(), + password: password.to_string(), + client_id: client_id.to_string(), + redirect_uri, + scope: scope.to_string(), + state: state.to_string(), + code_challenge: code_challenge.to_string(), + code_challenge_method: code_challenge_method.to_string(), + response_type: response_type.to_string(), + response_mode: response_mode.to_string(), + }) + } +} + +/// A web login form for the OIDC authentication flow. +/// +/// The returned `OidcResponse` handles CSP headers to allow that form. +pub fn oidc_login_form( + hostname: &str, + query: &AuthorizationQuery, +) -> OidcResponse { + // The target request route. + let route = "/_matrix/client/unstable/org.matrix.msc2964/login"; + let nonce = rand::random::().to_string(); + let body = Some(Body::Text(login_page(hostname, query, route, &nonce))); + + OidcResponse { + status: Status::Ok, + location: None, + www_authenticate: None, + body, + nonce, + } +} + +/// Render the html contents of the login page. +fn login_page( + hostname: &str, + query: &AuthorizationQuery, + route: &str, + nonce: &str, +) -> String { + let template = LoginPageTemplate { + nonce, + hostname, + route, + client_id: query.client_id.as_str(), + redirect_uri: query.redirect_uri.as_str(), + scope: query.scope.as_str(), + state: query.state.as_str(), + code_challenge: query.code_challenge.as_str(), + code_challenge_method: query.code_challenge_method.as_str(), + response_type: query.response_type.as_str(), + response_mode: query.response_mode.as_str(), + }; + + template.render().expect("login template render") +} diff --git a/src/web/oidc/request.rs b/src/web/oidc/request.rs new file mode 100644 index 00000000..2417b3bf --- /dev/null +++ b/src/web/oidc/request.rs @@ -0,0 +1,116 @@ +use super::OidcResponse; +use oxide_auth::endpoint::{NormalizedParameter, QueryParameter, WebRequest}; +use oxide_auth_axum::WebError; +use async_trait::async_trait; +use axum::{ + extract::{Form, FromRequest, FromRequestParts, Query, Request}, + http::header, +}; +use std::borrow::Cow; + +/// An OIDC authentication request. +/// +/// Expected to receive GET and POST requests to the `authorize` endpoint, or +/// POST requests to the `login` endpoint. +/// +/// Mostly adapted from the OAuthRequest struct in the [oxide-auth-axum] crate. +/// [oxide-auth-axum]: https://docs.rs/oxide-auth-axum +#[derive(Clone, Debug)] +pub struct OidcRequest { + pub(crate) auth: Option, + pub(crate) query: Option, + pub(crate) body: Option, +} + +impl OidcRequest { + /// Fetch the authorization header from the request + pub fn authorization_header(&self) -> Option<&str> { + self.auth.as_deref() + } + + /// Fetch the query for this request + pub fn query(&self) -> Option<&NormalizedParameter> { + self.query.as_ref() + } + + /// Fetch the query mutably + pub fn query_mut(&mut self) -> Option<&mut NormalizedParameter> { + self.query.as_mut() + } + + /// Fetch the body of the request + pub fn body(&self) -> Option<&NormalizedParameter> { + self.body.as_ref() + } +} + +impl WebRequest for OidcRequest { + type Error = WebError; + type Response = OidcResponse; + + fn query(&mut self) -> Result, Self::Error> { + self.query + .as_ref() + .map(|q| Cow::Borrowed(q as &dyn QueryParameter)) + .ok_or(WebError::Query) + } + + fn urlbody(&mut self) -> Result, Self::Error> { + self.body + .as_ref() + .map(|b| Cow::Borrowed(b as &dyn QueryParameter)) + .ok_or(WebError::Body) + } + + fn authheader(&mut self) -> Result>, Self::Error> { + Ok(self.auth.as_deref().map(Cow::Borrowed)) + } +} + +#[async_trait] +impl FromRequest for OidcRequest +where + S: Send + Sync, +{ + type Rejection = WebError; + + async fn from_request(req: Request, state: &S) -> Result { + let mut all_auth = req.headers().get_all(header::AUTHORIZATION).iter(); + let optional = all_auth.next(); + + let auth = if all_auth.next().is_some() { + return Err(WebError::Authorization); + } else { + optional.and_then(|hv| hv.to_str().ok().map(str::to_owned)) + }; + + let (mut parts, body) = req.into_parts(); + let query = Query::from_request_parts(&mut parts, state) + .await + .ok() + .map(|q: Query| q.0); + + let req = Request::from_parts(parts, body); + let body = Form::from_request(req, state) + .await + .ok() + .map(|b: Form| b.0); + + // If the query is empty and the body has a request, copy it over + // because login forms are POST requests but OAuth flow expects + // arguments in query. + let query = match query { + | None => body.clone(), + | Some(params) => { + //if params == NormalizedParameter::new() { + if params.unique_value("client_id").is_none() { + body.clone() + } else { + Some(params) + } + }, + }; + + Ok(Self { auth, query, body }) + } +} diff --git a/src/web/oidc/response.rs b/src/web/oidc/response.rs new file mode 100644 index 00000000..08a44e65 --- /dev/null +++ b/src/web/oidc/response.rs @@ -0,0 +1,127 @@ +use super::{OidcRequest, oidc_consent_form}; + +use crate::oidc::LoginQuery; +use oxide_auth::{ + endpoint::{OwnerConsent, OwnerSolicitor, Solicitation, WebRequest, WebResponse}, + frontends::simple::request::{Body as OAuthRequestBody, Status}, +}; +use oxide_auth_axum::WebError; +use axum::{ + body::Body, + http::{Response, header}, + response::IntoResponse, +}; +use url::Url; + +/// A Web response that can be processed by the OIDC authentication flow before +/// being sent over. +#[derive(Default, Clone, Debug)] +pub struct OidcResponse { + pub(crate) status: Status, + pub(crate) location: Option, + pub(crate) www_authenticate: Option, + pub(crate) body: Option, + pub(crate) nonce: String, +} + +impl OidcResponse { + /// Instanciate from a response body. Used to send login or consent forms. + pub fn from_body(body: &str) -> Result { + let mut result = OidcResponse::default(); + result.body_text(body)?; + + Ok(result) + } +} + +impl IntoResponse for OidcResponse { + fn into_response(self) -> Response { + let body = self.body.expect("body").as_str().to_string(); + let response = Response::builder() + .header(header::CONTENT_TYPE, "text/html") + .header( + header::CONTENT_SECURITY_POLICY, + format!("default-src 'nonce-{}'; form-action https://eon.presentmatter.one/;", self.nonce) + ) + .body(body.into()) + .unwrap(); + + response + } +} + +impl OwnerSolicitor for OidcResponse { + fn check_consent( + &mut self, + request: &mut OidcRequest, + _: Solicitation<'_>, + ) -> OwnerConsent<::Response> { + //let hostname = self.location.map(|l| l.as_str()).unwrap_or("Continuwuity"); + let hostname = "Continuwuity"; + /* + let hostname = request + .query() + .expect("query in OAuth request") + .unique_value("hostname") + .expect("hostname in OAuth request") + .as_str(); + */ + let query: LoginQuery = request + .clone() + .try_into() + .expect("login query from OidcRequest"); + + OwnerConsent::InProgress(oidc_consent_form( + hostname, + &query.into(), + )) + } +} + +impl WebResponse for OidcResponse { + type Error = WebError; + + fn ok(&mut self) -> Result<(), Self::Error> { + self.status = Status::Ok; + self.location = None; + self.www_authenticate = None; + + Ok(()) + } + + /// A response which will redirect the user-agent to which the response is issued. + fn redirect(&mut self, url: Url) -> Result<(), Self::Error> { + self.status = Status::Redirect; + self.location = Some(url); + self.www_authenticate = None; + Ok(()) + } + + /// Set the response status to 400. + fn client_error(&mut self) -> Result<(), Self::Error> { + self.status = Status::BadRequest; + self.location = None; + self.www_authenticate = None; + Ok(()) + } + + /// Set the response status to 401 and add a `WWW-Authenticate` header. + fn unauthorized(&mut self, header_value: &str) -> Result<(), Self::Error> { + self.status = Status::Unauthorized; + self.location = None; + self.www_authenticate = Some(header_value.to_owned()); + Ok(()) + } + + /// A pure text response with no special media type set. + fn body_text(&mut self, text: &str) -> Result<(), Self::Error> { + self.body = Some(OAuthRequestBody::Text(text.to_owned())); + Ok(()) + } + + /// Json repsonse data, with media type `aplication/json. + fn body_json(&mut self, data: &str) -> Result<(), Self::Error> { + self.body = Some(OAuthRequestBody::Json(data.to_owned())); + Ok(()) + } +} diff --git a/src/web/templates/consent.html.j2 b/src/web/templates/consent.html.j2 new file mode 100644 index 00000000..09501a74 --- /dev/null +++ b/src/web/templates/consent.html.j2 @@ -0,0 +1,14 @@ +{% extends "_layout.html.j2" %} +{%- block content -%} +
+

{{ hostname }}

+

+ '{{ client_id }}' (at {{ redirect_uri }}) is requesting permission for '{{ scope }}' +

+
+ + +
+
+{%- endblock content -%} + diff --git a/src/web/templates/login.html.j2 b/src/web/templates/login.html.j2 new file mode 100644 index 00000000..1748b037 --- /dev/null +++ b/src/web/templates/login.html.j2 @@ -0,0 +1,20 @@ +{% extends "_layout.html.j2" %} +{%- block content -%} +
+

{{ hostname }}

+
+ + + + + + + + + + + +
+
+{%- endblock content -%} +