diff --git a/Cargo.lock b/Cargo.lock index 2da23a7a..6b45da79 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -436,9 +436,9 @@ dependencies = [ [[package]] name = "backtrace" -version = "0.3.74" +version = "0.3.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" +checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" dependencies = [ "addr2line", "cfg-if", @@ -866,6 +866,7 @@ dependencies = [ "bytes", "conduwuit_core", "conduwuit_service", + "conduwuit_web", "const-str", "futures", "hmac", @@ -1060,13 +1061,20 @@ name = "conduwuit_web" version = "0.5.0-rc.5" dependencies = [ "askama", + "async-trait", "axum", "conduwuit_build_metadata", + "conduwuit_core", "conduwuit_service", "futures", + "oxide-auth", + "oxide-auth-axum", + "percent-encoding", "rand 0.8.5", + "serde", "thiserror 2.0.12", "tracing", + "url", ] [[package]] @@ -2122,7 +2130,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", + "webpki-roots 0.26.11", ] [[package]] @@ -2183,21 +2191,22 @@ dependencies = [ [[package]] name = "icu_collections" -version = "1.5.0" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" dependencies = [ "displaydoc", + "potential_utf", "yoke", "zerofrom", "zerovec", ] [[package]] -name = "icu_locid" -version = "1.5.0" +name = "icu_locale_core" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" dependencies = [ "displaydoc", "litemap", @@ -2206,31 +2215,11 @@ dependencies = [ "zerovec", ] -[[package]] -name = "icu_locid_transform" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" -dependencies = [ - "displaydoc", - "icu_locid", - "icu_locid_transform_data", - "icu_provider", - "tinystr", - "zerovec", -] - -[[package]] -name = "icu_locid_transform_data" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7515e6d781098bf9f7205ab3fc7e9709d34554ae0b21ddbcb5febfa4bc7df11d" - [[package]] name = "icu_normalizer" -version = "1.5.0" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" dependencies = [ "displaydoc", "icu_collections", @@ -2238,67 +2227,54 @@ dependencies = [ "icu_properties", "icu_provider", "smallvec", - "utf16_iter", - "utf8_iter", - "write16", "zerovec", ] [[package]] name = "icu_normalizer_data" -version = "1.5.1" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e8338228bdc8ab83303f16b797e177953730f601a96c25d10cb3ab0daa0cb7" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" [[package]] name = "icu_properties" -version = "1.5.1" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +checksum = "2549ca8c7241c82f59c80ba2a6f415d931c5b58d24fb8412caa1a1f02c49139a" dependencies = [ "displaydoc", "icu_collections", - "icu_locid_transform", + "icu_locale_core", "icu_properties_data", "icu_provider", - "tinystr", + "potential_utf", + "zerotrie", "zerovec", ] [[package]] name = "icu_properties_data" -version = "1.5.1" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85fb8799753b75aee8d2a21d7c14d9f38921b54b3dbda10f5a3c7a7b82dba5e2" +checksum = "8197e866e47b68f8f7d95249e172903bec06004b18b2937f1095d40a0c57de04" [[package]] name = "icu_provider" -version = "1.5.0" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" dependencies = [ "displaydoc", - "icu_locid", - "icu_provider_macros", + "icu_locale_core", "stable_deref_trait", "tinystr", "writeable", "yoke", "zerofrom", + "zerotrie", "zerovec", ] -[[package]] -name = "icu_provider_macros" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "idna" version = "1.0.3" @@ -2312,9 +2288,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" dependencies = [ "icu_normalizer", "icu_properties", @@ -2624,9 +2600,9 @@ checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "litemap" -version = "0.7.5" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" [[package]] name = "lock_api" @@ -3340,6 +3316,15 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e" +[[package]] +name = "potential_utf" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -3783,7 +3768,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots", + "webpki-roots 0.26.11", "windows-registry", ] @@ -4149,11 +4134,12 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "917ce264624a4b4db1c364dcc35bfca9ded014d0a958cd47ad3e960e988ea51c" +checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" dependencies = [ "web-time 1.1.0", + "zeroize", ] [[package]] @@ -4282,7 +4268,7 @@ dependencies = [ "sentry-tracing", "tokio", "ureq", - "webpki-roots", + "webpki-roots 0.26.11", ] [[package]] @@ -4931,9 +4917,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.7.6" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" dependencies = [ "displaydoc", "zerovec", @@ -5366,7 +5352,7 @@ dependencies = [ "rustls", "rustls-pki-types", "url", - "webpki-roots", + "webpki-roots 0.26.11", ] [[package]] @@ -5393,12 +5379,6 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" -[[package]] -name = "utf16_iter" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -5589,9 +5569,18 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "0.26.10" +version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37493cadf42a2a939ed404698ded7fb378bf301b5011f973361779a3a74f8c93" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.0", +] + +[[package]] +name = "webpki-roots" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2853738d1cc4f2da3a225c18ec6c3721abb31961096e9dbf5ab35fa88b19cfdb" dependencies = [ "rustls-pki-types", ] @@ -6005,9 +5994,9 @@ checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" [[package]] name = "winnow" -version = "0.7.9" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9fb597c990f03753e08d3c29efbfcf2019a003b4bf4ba19225c158e1549f0f3" +checksum = "c06928c8748d81b05c9be96aad92e1b6ff01833332f281e8cfca3be4b35fc9ec" dependencies = [ "memchr", ] @@ -6031,17 +6020,11 @@ dependencies = [ "bitflags 2.9.0", ] -[[package]] -name = "write16" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" - [[package]] name = "writeable" -version = "0.5.5" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" [[package]] name = "xml5ever" @@ -6062,9 +6045,9 @@ checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" [[package]] name = "yoke" -version = "0.7.5" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" dependencies = [ "serde", "stable_deref_trait", @@ -6074,9 +6057,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.7.5" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" dependencies = [ "proc-macro2", "quote", @@ -6132,10 +6115,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" [[package]] -name = "zerovec" -version = "0.10.4" +name = "zerotrie" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428" dependencies = [ "yoke", "zerofrom", @@ -6144,9 +6138,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.10.3" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 76150764..7750253c 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 -%} +