feat(oidc_provider) use askama templates

Implements a custom OidcResponse with CSP headers and oxide-auth processing
compatibility.
This commit is contained in:
lafleur 2025-05-09 11:17:50 +02:00 committed by nexy7574
parent 3417ac2487
commit fa9b8869b6
No known key found for this signature in database
GPG key ID: 0FA334385D0B689F
18 changed files with 621 additions and 225 deletions

View file

@ -104,6 +104,8 @@ features = [
"tokio", "tokio",
"tracing", "tracing",
"query", "query",
# Needed for debug_handler.
#"macros",
] ]
[workspace.dependencies.axum-extra] [workspace.dependencies.axum-extra]

View file

@ -92,6 +92,7 @@ tokio.workspace = true
tracing.workspace = true tracing.workspace = true
oxide-auth.workspace = true oxide-auth.workspace = true
oxide-auth-axum.workspace = true oxide-auth-axum.workspace = true
conduwuit-web.workspace = true
percent-encoding.workspace = true percent-encoding.workspace = true
[lints] [lints]

View file

@ -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_axum::{OAuthResponse, OAuthRequest};
use oxide_auth::{ use oxide_auth::{
endpoint::{OwnerConsent, Solicitation}, endpoint::{OwnerConsent, Solicitation},
frontends::simple::endpoint::FnSolicitor, frontends::simple::endpoint::FnSolicitor,
primitives::registrar::PreGrant,
}; };
use axum::extract::{Query, State}; use axum::extract::{Query, State};
use serde_html_form;
use conduwuit::{Result, err}; use conduwuit::{Result, err};
use reqwest::Url;
use percent_encoding::percent_decode_str; 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` /// # `GET /_matrix/client/unstable/org.matrix.msc2964/authorize`
/// ///
/// Authenticate a user and device, and solicit the user's consent. /// 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]. /// access to stage two, [authorize_consent].
pub(crate) async fn authorize( pub(crate) async fn authorize(
State(services): State<crate::State>, State(services): State<crate::State>,
Query(query): Query<OAuthQuery>, Query(query): Query<AuthorizationQuery>,
oauth: OAuthRequest, oauth: OidcRequest,
) -> Result<OAuthResponse> { ) -> Result<OidcResponse> {
tracing::trace!("processing OAuth request: {query:?}"); tracing::trace!("processing OAuth request: {query:?}");
// Enforce MSC2964's restrictions on OAuth2 flow. // Enforce MSC2964's restrictions on OAuth2 flow.
let Ok(scope) = percent_decode_str(&query.scope).decode_utf8() else { let Ok(scope) = percent_decode_str(&query.scope).decode_utf8() else {
@ -60,16 +44,13 @@ pub(crate) async fn authorize(
.client .client
.as_ref() .as_ref()
.map(|s| s.domain().expect("well-known client should be a domain")); .map(|s| s.domain().expect("well-known client should be a domain"));
let login_redirect = OAuthResponse::default() let hostname = hostname.unwrap_or("Continuwuity");
.body(&login_form(hostname, &query))
.content_type("text/html")
.expect("should set Content-Type on OAuth response");
match oauth.authorization_header() { match oauth.authorization_header() {
| None => { | None => {
return Ok(login_redirect); return Ok(oidc_login_form(hostname, &query));
}, },
| Some(token) => if services.users.find_from_token(token).await.is_err() { | 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 ? // TODO register the device ID ?
@ -77,18 +58,7 @@ pub(crate) async fn authorize(
services services
.oidc .oidc
.endpoint() .endpoint()
.with_solicitor(FnSolicitor(move |_: &mut _, solicitation: Solicitation<'_>| .with_solicitor(oidc_consent_form(hostname, &query))
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")
)
))
.authorization_flow() .authorization_flow()
.execute(oauth) .execute(oauth)
.map_err(|err| err!("authorization failed: {err:?}")) .map_err(|err| err!("authorization failed: {err:?}"))
@ -116,99 +86,15 @@ pub(crate) async fn authorize_consent(
services services
.oidc .oidc
.endpoint() .endpoint()
.with_solicitor(FnSolicitor(move |_: &mut _, solicitation: Solicitation<'_>| .with_solicitor(
match allowed { FnSolicitor(move |_: &mut _, solicitation: Solicitation<'_>|
| false => OwnerConsent::Denied, match allowed {
| true => OwnerConsent::Authorized(solicitation.pre_grant().client_id.clone()) | false => OwnerConsent::Denied,
} | true => OwnerConsent::Authorized(solicitation.pre_grant().client_id.clone())
)) }
)
)
.authorization_flow() .authorization_flow()
.execute(oauth) .execute(oauth)
.map_err(|err| err!(Request(Unknown("consent request failed: {err:?}")))) .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#"
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
<center>
<h1>{hostname} login</h1>
<form action="/_matrix/client/unstable/org.matrix.msc2964/login" method="post">
<input type="text" name="username" placeholder="Username" required>
<input type="password" name="password" placeholder="Password" required>
<input type="hidden" name="client_id" value="{client_id}">
<input type="hidden" name="redirect_uri" value="{redirect_uri}">
<input type="hidden" name="scope" value="{scope}">
<input type="hidden" name="state" value="{state}">
<input type="hidden" name="code_challenge" value="{code_challenge}">
<input type="hidden" name="code_challenge_method" value="{code_challenge_method}">
<input type="hidden" name="response_type" value="{response_type}">
<input type="hidden" name="response_mode" value="{response_mode}">
<button type="submit">Login</button>
</form>
</center>
</body>
</html>
"#
)
}
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#"
<html>
<head>
<meta charset='utf-8'>
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
</head>
<body>
<center>
<h1>{hostname} login</h1>
'{client_id}' (at {redirect_uri}) is requesting permission for '{scope}'
<form method="post">
<input type="submit" value="Accept" formaction="{route}?{args}&allow=true">
<input type="submit" value="Deny" formaction="{route}?{args}&deny=true">
</form>
</center>
</body>
</html>
"#,
)
}

View file

@ -1,57 +1,20 @@
use super::authorize::consent_page_html; use conduwuit_web::oidc::{
use oxide_auth_axum::{OAuthRequest, OAuthResponse}; oidc_consent_form,
use oxide_auth::{ AuthorizationQuery,
endpoint::{OwnerConsent, Solicitation}, LoginError,
frontends::simple::endpoint::FnSolicitor, LoginQuery,
OidcRequest,
OidcResponse,
}; };
use axum::extract::{Form, FromRequest, State}; use axum::extract::State;
use conduwuit::{ use conduwuit::{
Result, Result,
err, err,
utils::hash::verify_password, utils::hash::verify_password,
}; };
use ruma::user_id::UserId; 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<LoginForm> 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` /// # `POST /_matrix/client/unstable/org.matrix.msc2964/login`
/// ///
/// Display a login UI to the user and return an authorization code on success. /// Display a login UI to the user and return an authorization code on success.
@ -60,14 +23,17 @@ impl From<LoginForm> for String {
/// [super::authorize::authorize_consent]. /// [super::authorize::authorize_consent].
pub(crate) async fn oidc_login( pub(crate) async fn oidc_login(
State(services): State<crate::State>, State(services): State<crate::State>,
Form(login_query): Form<LoginForm>, request: OidcRequest,
) -> Result<OAuthResponse> { ) -> Result<OidcResponse> {
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. // Only accept local usernames. Mostly to simplify things at first.
let user_id = UserId::parse_with_server_name( let user_id = UserId::parse_with_server_name(
login_query.username.clone(), query.username.clone(),
&services.config.server_name &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 { if ! services.users.exists(&user_id).await {
return Err(err!(Request(Unknown("unknown username")))); return Err(err!(Request(Unknown("unknown username"))));
@ -82,49 +48,26 @@ pub(crate) async fn oidc_login(
if valid_hash.is_empty() { if valid_hash.is_empty() {
return Err(err!(Request(UserDeactivated("the user's hash was not found")))); 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")))); 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<axum::body::Body> = 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 let hostname = services
.config .config
.well_known .well_known
.client .client
.as_ref() .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 services
.oidc .oidc
.endpoint() .endpoint()
.with_solicitor(FnSolicitor(move |_: &mut _, solicitation: Solicitation<'_>| .with_solicitor(oidc_consent_form(hostname, &authorization_query))
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")
)
))
.authorization_flow() .authorization_flow()
.execute(oauth) .execute(request)
.map_err(|err| err!(Request(Unknown("authorization failed: {err:?}")))) .map_err(|err| err!(Request(Unknown("authorization failed: {err:?}"))))
} }

View file

@ -1,12 +1,14 @@
/// OIDC /// OIDC
/// ///
/// Stands for OpenID Connect, and is an authentication scheme relying on OAuth2. /// Stands for OpenID Connect, and is an authentication scheme relying on OAuth2.
/// The MSC2964 Matrix Spec Proposal describes an authentication process based /// The [MSC2964] Matrix Spec Proposal describes an authentication process based
/// on OIDC with restrictions. See the [sample flow] for details on what's expected. /// 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 /// This module implements the needed endpoints. It relies on the [oxide-auth]
/// the [oxide-auth] crate. /// 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 /// [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 /// [sample flow]: https://github.com/sandhose/matrix-spec-proposals/blob/msc/sandhose/oauth2-profile/proposals/2964-oauth2-profile.md#sample-flow

View file

@ -51,7 +51,7 @@ pub(crate) struct ClientResponse {
/// # `GET /_matrix/client/unstable/org.matrix.msc2964/device/register` /// # `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. /// will have the right to submit [super::authorize::authorize] requests.
/// ///
/// [MSC2966]: https://github.com/matrix-org/matrix-spec-proposals/pull/2966 /// [MSC2966]: https://github.com/matrix-org/matrix-spec-proposals/pull/2966

View file

@ -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_axum::{OAuthResponse, OAuthRequest};
use oxide_auth::endpoint::QueryParameter; use oxide_auth::endpoint::QueryParameter;
use axum::{ use axum::{

View file

@ -3,8 +3,7 @@
/// Provides the registrar, authorizer and issuer needed by [api::client::oidc]. /// Provides the registrar, authorizer and issuer needed by [api::client::oidc].
/// The whole OAuth2 flow is taken care of by [oxide-auth]. /// The whole OAuth2 flow is taken care of by [oxide-auth].
/// ///
/// TODO At the moment this service provides no method to dynamically add a /// TODO this service would need a dedicated space in the database.
/// client. That would need a dedicated space in the database.
/// ///
/// [oxide-auth]: https://docs.rs/oxide-auth /// [oxide-auth]: https://docs.rs/oxide-auth
@ -90,7 +89,6 @@ impl Service {
solicitor: Vacant, solicitor: Vacant,
// Scope configured later. // Scope configured later.
scopes: Vacant, scopes: Vacant,
// `rocket::Response` is `Default`, so we don't need more configuration.
response: Vacant, response: Vacant,
} }
} }

View file

@ -22,6 +22,8 @@ crate-type = [
[dependencies] [dependencies]
conduwuit-build-metadata.workspace = true conduwuit-build-metadata.workspace = true
conduwuit-service.workspace = true conduwuit-service.workspace = true
conduwuit-core.workspace = true
async-trait.workspace = true
askama = "0.14.0" askama = "0.14.0"
@ -30,6 +32,11 @@ futures.workspace = true
tracing.workspace = true tracing.workspace = true
rand.workspace = true rand.workspace = true
thiserror.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] [lints]
workspace = true workspace = true

View file

@ -8,6 +8,7 @@ use axum::{
}; };
use conduwuit_build_metadata::{GIT_REMOTE_COMMIT_URL, GIT_REMOTE_WEB_URL, version_tag}; use conduwuit_build_metadata::{GIT_REMOTE_COMMIT_URL, GIT_REMOTE_WEB_URL, version_tag};
use conduwuit_service::state; use conduwuit_service::state;
pub mod oidc;
pub fn build() -> Router<state::State> { pub fn build() -> Router<state::State> {
let router = Router::<state::State>::new(); let router = Router::<state::State>::new();

56
src/web/oidc.rs Normal file
View file

@ -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()
}

43
src/web/oidc/authorize.rs Normal file
View file

@ -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<LoginQuery> 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,
}
}
}

54
src/web/oidc/consent.rs Normal file
View file

@ -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::<u64>().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")
}

132
src/web/oidc/login.rs Normal file
View file

@ -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<OidcRequest> for LoginQuery {
type Error = LoginError;
fn try_from(value: OidcRequest) -> Result<Self, LoginError> {
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::<u64>().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")
}

116
src/web/oidc/request.rs Normal file
View file

@ -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<String>,
pub(crate) query: Option<NormalizedParameter>,
pub(crate) body: Option<NormalizedParameter>,
}
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<Cow<'_, dyn QueryParameter + 'static>, Self::Error> {
self.query
.as_ref()
.map(|q| Cow::Borrowed(q as &dyn QueryParameter))
.ok_or(WebError::Query)
}
fn urlbody(&mut self) -> Result<Cow<'_, dyn QueryParameter + 'static>, Self::Error> {
self.body
.as_ref()
.map(|b| Cow::Borrowed(b as &dyn QueryParameter))
.ok_or(WebError::Body)
}
fn authheader(&mut self) -> Result<Option<Cow<'_, str>>, Self::Error> {
Ok(self.auth.as_deref().map(Cow::Borrowed))
}
}
#[async_trait]
impl<S> FromRequest<S> for OidcRequest
where
S: Send + Sync,
{
type Rejection = WebError;
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
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<NormalizedParameter>| q.0);
let req = Request::from_parts(parts, body);
let body = Form::from_request(req, state)
.await
.ok()
.map(|b: Form<NormalizedParameter>| 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 })
}
}

127
src/web/oidc/response.rs Normal file
View file

@ -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<Url>,
pub(crate) www_authenticate: Option<String>,
pub(crate) body: Option<OAuthRequestBody>,
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<Self, WebError> {
let mut result = OidcResponse::default();
result.body_text(body)?;
Ok(result)
}
}
impl IntoResponse for OidcResponse {
fn into_response(self) -> Response<Body> {
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<OidcRequest> for OidcResponse {
fn check_consent(
&mut self,
request: &mut OidcRequest,
_: Solicitation<'_>,
) -> OwnerConsent<<OidcRequest as WebRequest>::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(())
}
}

View file

@ -0,0 +1,14 @@
{% extends "_layout.html.j2" %}
{%- block content -%}
<div class="panel">
<h1 class-"project-name">{{ hostname }}</h1>
<p>
'{{ client_id }}' (at {{ redirect_uri }}) is requesting permission for '{{ scope }}'
</p>
<form method="post">
<input type="submit" value="Accept" formaction="{{ route }}?client_id={{ client_id }}&redirect_uri={{ redirect_uri }}&scope={{scope }}&state={{ state }}&code_challenge={{ code_challenge }}&code_challenge_method={{ code_challenge_method }}&response_type={{ response_type }}&response_mode={{ response_mode }}&allow=true">
<input type="submit" value="Deny" formaction="{{ route }}?client_id={{ client_id }}&redirect_uri={{ redirect_uri }}&scope={{scope }}&state={{ state }}&code_challenge={{ code_challenge }}&code_challenge_method={{ code_challenge_method }}&response_type={{ response_type }}&response_mode={{ response_mode }}&deny=true">
</form>
</div>
{%- endblock content -%}

View file

@ -0,0 +1,20 @@
{% extends "_layout.html.j2" %}
{%- block content -%}
<div class="panel">
<h1 class-"project-name">{{ hostname }}</h1>
<form action="{{ route }}" method="post">
<input type="text" name="username" placeholder="Username" required>
<input type="password" name="password" placeholder="Password" required>
<input type="hidden" name="client_id" value="{{ client_id }}">
<input type="hidden" name="redirect_uri" value="{{ redirect_uri }}">
<input type="hidden" name="scope" value="{{ scope }}">
<input type="hidden" name="state" value="{{ state }}">
<input type="hidden" name="code_challenge" value="{{ code_challenge }}">
<input type="hidden" name="code_challenge_method" value="{{ code_challenge_method }}">
<input type="hidden" name="response_type" value="{{ response_type }}">
<input type="hidden" name="response_mode" value="{{ response_mode }}">
<button type="submit">Login</button>
</form>
</div>
{%- endblock content -%}