diff --git a/CHANGELOG.md b/CHANGELOG.md index b278cdd7..7d129d57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to this project will be documented in this file. This projec If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions. ## Added +- OAuth Profile for Open Public Clients ([draft-ietf-mailmaint-oauth-public](https://datatracker.ietf.org/doc/draft-ietf-mailmaint-oauth-public/)) ## Changed diff --git a/crates/common/src/auth/oauth/client_id.rs b/crates/common/src/auth/oauth/client_id.rs new file mode 100644 index 00000000..525fab3a --- /dev/null +++ b/crates/common/src/auth/oauth/client_id.rs @@ -0,0 +1,225 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{ + SCOPE_CALENDARS, SCOPE_CONTACTS, SCOPE_MAIL, SCOPE_OFFLINE_ACCESS, SCOPE_OPENID, + crypto::SymmetricEncrypt, +}; +use base64::{Engine, engine::general_purpose}; +use store::blake3; +use utils::codec::leb128::{Leb128Iterator, Leb128Vec}; + +const CLIENT_ID_HEADER: &str = "swc1."; +const CLIENT_ID_KEY_CONTEXT: &str = "stalwart-oauth-client-id-sw1"; +const CLIENT_ID_VERSION: u8 = 1; + +const SCOPE_BITS: &[&str] = &[ + SCOPE_OPENID, + SCOPE_OFFLINE_ACCESS, + SCOPE_MAIL, + SCOPE_CONTACTS, + SCOPE_CALENDARS, +]; + +pub fn scopes_to_mask(scope: &str) -> u64 { + let mut mask = 0u64; + for scope in scope.split_ascii_whitespace() { + if let Some(bit) = SCOPE_BITS.iter().position(|known| *known == scope) { + mask |= 1 << bit; + } + } + mask +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ClientMeta { + pub redirect_uris: Vec, + pub scope_mask: u64, + pub client_name: Option, +} + +pub fn encode_client_id(key: &[u8], meta: &ClientMeta) -> Result { + let client_name = meta.client_name.as_deref().unwrap_or_default(); + + let mut payload = Vec::with_capacity( + 24 + meta + .redirect_uris + .iter() + .map(|u| u.len() + 2) + .sum::() + + client_name.len(), + ); + payload.push(CLIENT_ID_VERSION); + payload.push_leb128(meta.redirect_uris.len()); + for uri in &meta.redirect_uris { + payload.push_leb128(uri.len()); + payload.extend_from_slice(uri.as_bytes()); + } + payload.push_leb128(meta.scope_mask); + payload.push_leb128(client_name.len()); + payload.extend_from_slice(client_name.as_bytes()); + + let digest = blake3::hash(&payload); + let nonce = &digest.as_bytes()[..SymmetricEncrypt::NONCE_LEN]; + let ciphertext = + SymmetricEncrypt::new(key, CLIENT_ID_KEY_CONTEXT).encrypt_with_aad(&payload, nonce, &[])?; + + let mut body = Vec::with_capacity(nonce.len() + ciphertext.len()); + body.extend_from_slice(nonce); + body.extend_from_slice(&ciphertext); + + let mut out = String::with_capacity(CLIENT_ID_HEADER.len() + body.len().div_ceil(3) * 4); + out.push_str(CLIENT_ID_HEADER); + general_purpose::URL_SAFE_NO_PAD.encode_string(&body, &mut out); + + Ok(out) +} + +pub fn decode_client_id(key: &[u8], client_id: &str) -> Option { + let body = general_purpose::URL_SAFE_NO_PAD + .decode(client_id.strip_prefix(CLIENT_ID_HEADER)?.as_bytes()) + .ok()?; + if body.len() < SymmetricEncrypt::NONCE_LEN + SymmetricEncrypt::ENCRYPT_TAG_LEN { + return None; + } + let (nonce, ciphertext) = body.split_at(SymmetricEncrypt::NONCE_LEN); + let payload = SymmetricEncrypt::new(key, CLIENT_ID_KEY_CONTEXT) + .decrypt_with_aad(ciphertext, nonce, &[]) + .ok()?; + + let mut bytes = payload.iter(); + if bytes.next().copied()? != CLIENT_ID_VERSION { + return None; + } + + let uri_count: usize = bytes.next_leb128()?; + if uri_count > u8::MAX as usize { + return None; + } + let mut redirect_uris = Vec::with_capacity(uri_count); + for _ in 0..uri_count { + redirect_uris.push(take_string(&mut bytes)?); + } + let scope_mask: u64 = bytes.next_leb128()?; + let client_name = take_string(&mut bytes)?; + + Some(ClientMeta { + redirect_uris, + scope_mask, + client_name: (!client_name.is_empty()).then_some(client_name), + }) +} + +fn take_string(bytes: &mut std::slice::Iter<'_, u8>) -> Option { + let len: usize = bytes.next_leb128()?; + let slice = bytes.as_slice(); + if slice.len() < len { + return None; + } + let value = String::from_utf8(slice[..len].to_vec()).ok()?; + if len > 0 { + bytes.nth(len - 1)?; + } + Some(value) +} + +#[cfg(test)] +mod tests { + use super::*; + + const KEY: &[u8] = b"a-test-encryption-key-of-some-length"; + + fn sample() -> ClientMeta { + ClientMeta { + redirect_uris: vec![ + "http://127.0.0.1/cb".to_string(), + "com.example.app:/oauth".to_string(), + ], + scope_mask: scopes_to_mask(&format!("{SCOPE_OFFLINE_ACCESS} {SCOPE_MAIL}")), + client_name: Some("Example Client".to_string()), + } + } + + #[test] + fn round_trip_preserves_all_fields() { + for meta in [ + sample(), + ClientMeta { + redirect_uris: vec!["http://[::1]/".to_string()], + scope_mask: 0, + client_name: None, + }, + ClientMeta::default(), + ] { + let client_id = encode_client_id(KEY, &meta).unwrap(); + assert!(client_id.starts_with(CLIENT_ID_HEADER)); + assert_eq!(decode_client_id(KEY, &client_id), Some(meta)); + } + } + + #[test] + fn scope_mask_is_order_independent_and_drops_unknown() { + assert_eq!( + scopes_to_mask(&format!("{SCOPE_MAIL} {SCOPE_OFFLINE_ACCESS}")), + scopes_to_mask(&format!("{SCOPE_OFFLINE_ACCESS} {SCOPE_MAIL}")) + ); + assert_eq!( + scopes_to_mask(&format!("{SCOPE_MAIL} custom:unknown")), + scopes_to_mask(SCOPE_MAIL) + ); + assert_eq!(scopes_to_mask("totally unknown"), 0); + } + + #[test] + fn identical_input_is_deterministic() { + let meta = sample(); + assert_eq!( + encode_client_id(KEY, &meta).unwrap(), + encode_client_id(KEY, &meta).unwrap() + ); + } + + #[test] + fn wrong_key_is_rejected() { + let client_id = encode_client_id(KEY, &sample()).unwrap(); + assert_eq!( + decode_client_id(b"a-completely-different-key-value!", &client_id), + None + ); + } + + #[test] + fn tampering_is_rejected() { + let client_id = encode_client_id(KEY, &sample()).unwrap(); + let (header, body_b64) = client_id.split_at(CLIENT_ID_HEADER.len()); + let mut body = general_purpose::URL_SAFE_NO_PAD.decode(body_b64).unwrap(); + for idx in 0..body.len() { + let mut tampered = body.clone(); + tampered[idx] ^= 0x01; + let forged = format!( + "{header}{}", + general_purpose::URL_SAFE_NO_PAD.encode(&tampered) + ); + assert_eq!(decode_client_id(KEY, &forged), None, "byte {idx}"); + } + body[0] ^= 0x00; + assert!(decode_client_id(KEY, &client_id).is_some()); + } + + #[test] + fn malformed_input_never_panics() { + for case in [ + "", + "swc1.", + "swc1.!!!", + "swc1.AAAA", + "wrong.AAAA", + "swc1.AAAAAAAAAAAAAAAAAAAAAAAAAAAA", + ] { + assert_eq!(decode_client_id(KEY, case), None, "{case:?}"); + } + } +} diff --git a/crates/common/src/auth/oauth/mod.rs b/crates/common/src/auth/oauth/mod.rs index 55e5bfa4..2beb8192 100644 --- a/crates/common/src/auth/oauth/mod.rs +++ b/crates/common/src/auth/oauth/mod.rs @@ -4,6 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ +pub mod client_id; pub mod config; pub mod crypto; pub mod introspect; @@ -14,10 +15,24 @@ pub mod token; pub const DEVICE_CODE_LEN: usize = 40; pub const USER_CODE_LEN: usize = 8; pub const RANDOM_CODE_LEN: usize = 32; -pub const CLIENT_ID_MAX_LEN: usize = 100; +pub const CLIENT_ID_MAX_LEN: usize = 2048; pub const USER_CODE_ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // No 0, O, I, 1 +pub const SCOPE_OPENID: &str = "openid"; +pub const SCOPE_OFFLINE_ACCESS: &str = "offline_access"; +pub const SCOPE_MAIL: &str = "urn:ietf:params:oauth:scope:mail"; +pub const SCOPE_CONTACTS: &str = "urn:ietf:params:oauth:scope:contacts"; +pub const SCOPE_CALENDARS: &str = "urn:ietf:params:oauth:scope:calendars"; + +pub const SUPPORTED_SCOPES: &[&str] = &[ + SCOPE_OPENID, + SCOPE_OFFLINE_ACCESS, + SCOPE_MAIL, + SCOPE_CONTACTS, + SCOPE_CALENDARS, +]; + #[derive(Debug, Clone, Copy, Eq, PartialEq)] pub enum GrantType { AccessToken, diff --git a/crates/common/src/auth/oauth/registration.rs b/crates/common/src/auth/oauth/registration.rs index 4ad6becc..60e02532 100644 --- a/crates/common/src/auth/oauth/registration.rs +++ b/crates/common/src/auth/oauth/registration.rs @@ -12,6 +12,10 @@ use std::collections::HashMap; pub struct ClientRegistrationRequest { pub redirect_uris: Vec, + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub scope: Option, + #[serde(default)] #[serde(skip_serializing_if = "Vec::is_empty")] pub response_types: Vec, @@ -170,7 +174,7 @@ pub enum SubjectType { Public, } -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum TokenEndpointAuthMethod { ClientSecretPost, @@ -179,3 +183,86 @@ pub enum TokenEndpointAuthMethod { PrivateKeyJwt, None, } + +#[derive(Serialize, Debug)] +pub struct ClientRegistrationError { + pub error: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub error_description: Option<&'static str>, +} + +impl ClientRegistrationError { + pub fn invalid_redirect_uri(description: &'static str) -> Self { + ClientRegistrationError { + error: "invalid_redirect_uri", + error_description: Some(description), + } + } + + pub fn invalid_client_metadata(description: &'static str) -> Self { + ClientRegistrationError { + error: "invalid_client_metadata", + error_description: Some(description), + } + } +} + +pub fn validate_redirect_uri(uri: &str) -> Result<(), ClientRegistrationError> { + if uri.contains('#') { + return Err(ClientRegistrationError::invalid_redirect_uri( + "Redirect URI must not contain a fragment.", + )); + } + if uri.contains("..") { + return Err(ClientRegistrationError::invalid_redirect_uri( + "Redirect URI must not contain consecutive dots.", + )); + } + if uri.starts_with("http://127.0.0.1/") || uri.starts_with("http://[::1]/") { + return Ok(()); + } + if let Some((scheme, _)) = uri.split_once(':') + && scheme.contains('.') + && scheme + .as_bytes() + .first() + .is_some_and(u8::is_ascii_alphabetic) + && scheme + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'+')) + { + return Ok(()); + } + + Err(ClientRegistrationError::invalid_redirect_uri( + "Redirect URI must be a loopback (http://127.0.0.1/, http://[::1]/) or private-use scheme URI.", + )) +} + +pub fn validate_grant_metadata( + request: &ClientRegistrationRequest, +) -> Result<(), ClientRegistrationError> { + if !request.response_types.is_empty() && !request.response_types.iter().any(|t| t == "code") { + return Err(ClientRegistrationError::invalid_client_metadata( + "response_types must include \"code\".", + )); + } + if !request.grant_types.is_empty() { + if !request + .grant_types + .iter() + .any(|t| t == "authorization_code") + { + return Err(ClientRegistrationError::invalid_client_metadata( + "grant_types must include \"authorization_code\".", + )); + } + if !request.grant_types.iter().any(|t| t == "refresh_token") { + return Err(ClientRegistrationError::invalid_client_metadata( + "grant_types must include \"refresh_token\".", + )); + } + } + + Ok(()) +} diff --git a/crates/http/src/api/mod.rs b/crates/http/src/api/mod.rs index c38a8424..a0b167f5 100644 --- a/crates/http/src/api/mod.rs +++ b/crates/http/src/api/mod.rs @@ -328,7 +328,10 @@ impl UnauthorizedResponse for HttpResponse { fn unauthorized(include_realms: bool) -> Self { (if include_realms { HttpResponse::new(StatusCode::UNAUTHORIZED) - .with_header(header::WWW_AUTHENTICATE, "Bearer realm=\"Stalwart Server\"") + .with_header( + header::WWW_AUTHENTICATE, + "Bearer realm=\"Stalwart Server\", resource_metadata=\"/.well-known/oauth-protected-resource\"", + ) .with_header(header::WWW_AUTHENTICATE, "Basic realm=\"Stalwart Server\"") } else { HttpResponse::new(StatusCode::UNAUTHORIZED) diff --git a/crates/http/src/auth/oauth/auth.rs b/crates/http/src/auth/oauth/auth.rs index ca448e1c..a196ef01 100644 --- a/crates/http/src/auth/oauth/auth.rs +++ b/crates/http/src/auth/oauth/auth.rs @@ -5,12 +5,18 @@ */ use super::{DeviceAuthResponse, FormData, MAX_POST_LEN, OAuthCode, PkceCodeChallenge}; -use crate::auth::oauth::{OAuthStatus, openid::OpenIdHandler}; +use crate::auth::oauth::{ + OAuthStatus, openid::OpenIdHandler, registration::ClientRegistrationHandler, +}; use common::{ KV_OAUTH, Server, auth::{ AuthRequest, - oauth::{CLIENT_ID_MAX_LEN, DEVICE_CODE_LEN, USER_CODE_ALPHABET, USER_CODE_LEN}, + oauth::{ + CLIENT_ID_MAX_LEN, DEVICE_CODE_LEN, SUPPORTED_SCOPES, USER_CODE_ALPHABET, + USER_CODE_LEN, + client_id::{decode_client_id, scopes_to_mask}, + }, }, }; use directory::Credentials; @@ -32,6 +38,14 @@ use store::{ use trc::AddContext; use utils::DomainPart; +#[derive(Debug, serde::Serialize)] +pub struct ProtectedResourceMetadata { + pub resource: String, + pub authorization_servers: [String; 1], + pub scopes_supported: &'static [&'static str], + pub bearer_methods_supported: &'static [&'static str], +} + #[derive(Debug, serde::Serialize)] pub struct OAuthMetadata { pub issuer: String, @@ -43,7 +57,9 @@ pub struct OAuthMetadata { pub grant_types_supported: &'static [&'static str], pub response_types_supported: &'static [&'static str], pub scopes_supported: &'static [&'static str], + pub token_endpoint_auth_methods_supported: &'static [&'static str], pub code_challenge_methods_supported: &'static [&'static str], + pub authorization_response_iss_parameter_supported: bool, } pub trait OAuthApiHandler: Sync + Send { @@ -66,6 +82,10 @@ pub trait OAuthApiHandler: Sync + Send { ) -> impl Future> + Send; fn handle_oauth_metadata(&self) -> impl Future> + Send; + + fn handle_oauth_protected_resource( + &self, + ) -> impl Future> + Send; } #[derive(Debug, serde::Serialize, serde::Deserialize)] @@ -92,6 +112,8 @@ pub enum LoginRequest { code_challenge_method: Option, #[serde(default)] state: Option, + #[serde(default)] + resource: Vec, }, #[serde(rename_all = "camelCase")] AuthDevice { @@ -108,7 +130,7 @@ pub enum LoginRequest { #[serde(tag = "type")] #[serde(rename_all = "camelCase")] pub enum LoginResponse { - Authenticated { client_code: String }, + Authenticated { client_code: String, iss: String }, Verified, MfaRequired, Failure, @@ -152,8 +174,10 @@ impl OAuthApiHandler for Server { client_id, redirect_uri, nonce, + scope, code_challenge, code_challenge_method, + resource, .. } => { // Validate clientId @@ -173,13 +197,48 @@ impl OAuthApiHandler for Server { } } + // Resolve the client and validate the redirect URI against the registration. + // Stateless client ids are self-describing; otherwise fall back to the registry. + let redirect_uri = redirect_uri.ok_or_else(|| { + trc::AuthEvent::Error + .into_err() + .details("A redirect URI is required.") + })?; + let stateless_client = + decode_client_id(self.core.oauth.oauth_key.as_bytes(), &client_id); + let granted_scope = match &stateless_client { + Some(meta) => { + if !meta + .redirect_uris + .iter() + .any(|uri| redirect_uri_matches(uri, &redirect_uri)) + { + return Err(trc::AuthEvent::Error + .into_err() + .details("Redirect URI does not match the client registration.")); + } + grant_scope(scope.as_deref(), meta.scope_mask) + } + None => grant_scope(scope.as_deref(), u64::MAX), + }; + + // Validate Resource Indicators (RFC 8707) + for resource in &resource { + if !is_known_resource(&self.core.network.http.url_https, resource) { + return Err(trc::AuthEvent::Error + .into_err() + .details("Unknown resource indicator.")); + } + } + // Parse and validate PKCE challenge (RFC 7636). let pkce_challenge = match code_challenge { Some(challenge) => { - // Default to "plain" when the method is omitted, per RFC 7636 4.3. match code_challenge_method.as_deref().unwrap_or("plain") { "S256" => PkceCodeChallenge::S256(challenge), - "plain" => PkceCodeChallenge::Plain(challenge), + "plain" if stateless_client.is_none() => { + PkceCodeChallenge::Plain(challenge) + } _ => { return Err(trc::AuthEvent::Error .into_err() @@ -187,7 +246,14 @@ impl OAuthApiHandler for Server { } } } - None => PkceCodeChallenge::None, + None => { + if stateless_client.is_some() { + return Err(trc::AuthEvent::Error + .into_err() + .details("A PKCE code_challenge with the S256 method is required.")); + } + PkceCodeChallenge::None + } }; // Authenticate @@ -204,6 +270,22 @@ impl OAuthApiHandler for Server { .await { Ok(access_token) => { + // Registry-backed clients are validated once the account is known + if stateless_client.is_none() + && self + .validate_client_registration( + &client_id, + Some(redirect_uri.as_str()), + access_token.account_id(), + ) + .await? + .is_some() + { + return Err(trc::AuthEvent::Error + .into_err() + .details("Invalid client registration.")); + } + // Generate client code let client_code = rng() .sample_iter(Alphanumeric) @@ -217,8 +299,10 @@ impl OAuthApiHandler for Server { account_id: access_token.account_id(), client_id, nonce, - params: redirect_uri.unwrap_or_default(), + params: redirect_uri, code_challenge: pkce_challenge, + scope: granted_scope, + resources: resource, }) .untrusted() .serialize() @@ -232,7 +316,10 @@ impl OAuthApiHandler for Server { ) .await?; - LoginResponse::Authenticated { client_code } + LoginResponse::Authenticated { + client_code, + iss: self.core.network.http.url_https.clone(), + } } Err(err) => match *err.as_ref() { trc::EventType::Auth(trc::AuthEvent::MfaRequired) => { @@ -294,6 +381,12 @@ impl OAuthApiHandler for Server { nonce: oauth.nonce.as_ref().map(|s| s.to_string()), params: Default::default(), code_challenge: PkceCodeChallenge::None, + scope: oauth.scope.as_ref().map(|s| s.to_string()), + resources: oauth + .resources + .iter() + .map(|s| s.to_string()) + .collect(), }; // Delete issued user code @@ -365,6 +458,9 @@ impl OAuthApiHandler for Server { .details("Client ID is missing.") })?; let nonce = form_data.remove("nonce"); + let scope = form_data + .remove("scope") + .and_then(|scope| grant_scope(Some(&scope), u64::MAX)); // Generate device code let device_code = rng() @@ -395,6 +491,8 @@ impl OAuthApiHandler for Server { nonce, params: device_code.clone(), code_challenge: PkceCodeChallenge::None, + scope, + resources: Vec::new(), }) .untrusted() .serialize() @@ -441,22 +539,76 @@ impl OAuthApiHandler for Server { registration_endpoint: format!("{base_url}/auth/register"), grant_types_supported: &[ "authorization_code", - "implicit", + "refresh_token", "urn:ietf:params:oauth:grant-type:device_code", ], - response_types_supported: &["code", "id_token", "code token", "id_token token"], - scopes_supported: &[ - "openid", - "offline_access", - "urn:ietf:params:jmap:core", - "urn:ietf:params:jmap:mail", - "urn:ietf:params:jmap:submission", - "urn:ietf:params:jmap:vacationresponse", + response_types_supported: &["code"], + scopes_supported: SUPPORTED_SCOPES, + token_endpoint_auth_methods_supported: &[ + "none", + "client_secret_post", + "client_secret_basic", ], code_challenge_methods_supported: &["S256"], + authorization_response_iss_parameter_supported: true, issuer: base_url.to_string(), }) .into_http_response() .with_cors_unrestricted()) } + + async fn handle_oauth_protected_resource(&self) -> trc::Result { + let base_url = &self.core.network.http.url_https; + + Ok(JsonResponse::new(ProtectedResourceMetadata { + resource: base_url.to_string(), + authorization_servers: [base_url.to_string()], + scopes_supported: SUPPORTED_SCOPES, + bearer_methods_supported: &["header"], + }) + .into_http_response() + .with_cors_unrestricted()) + } +} + +fn redirect_uri_matches(registered: &str, presented: &str) -> bool { + registered == presented || loopback_redirect_matches(registered, presented) +} + +fn loopback_redirect_matches(registered: &str, presented: &str) -> bool { + for host in ["http://127.0.0.1", "http://[::1]"] { + if let (Some(reg_path), Some(pres_rest)) = + (registered.strip_prefix(host), presented.strip_prefix(host)) + && let Some(after_port) = pres_rest.strip_prefix(':') + && let Some(slash) = after_port.find('/') + { + let (port, pres_path) = after_port.split_at(slash); + if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) && pres_path == reg_path + { + return true; + } + } + } + false +} + +fn grant_scope(requested: Option<&str>, registered_mask: u64) -> Option { + let mut granted = String::new(); + for scope in requested.unwrap_or_default().split_ascii_whitespace() { + let bit = scopes_to_mask(scope); + if bit != 0 && registered_mask & bit == bit { + if !granted.is_empty() { + granted.push(' '); + } + granted.push_str(scope); + } + } + + (!granted.is_empty()).then_some(granted) +} + +fn is_known_resource(base_url: &str, uri: &str) -> bool { + let base = base_url.trim_end_matches('/'); + uri.strip_prefix(base) + .is_some_and(|rest| rest.is_empty() || rest.starts_with('/')) } diff --git a/crates/http/src/auth/oauth/mod.rs b/crates/http/src/auth/oauth/mod.rs index e0b80ee2..d1b91c1b 100644 --- a/crates/http/src/auth/oauth/mod.rs +++ b/crates/http/src/auth/oauth/mod.rs @@ -54,6 +54,8 @@ pub struct OAuthCode { pub nonce: Option, pub params: String, pub code_challenge: PkceCodeChallenge, + pub scope: Option, + pub resources: Vec, } #[derive( diff --git a/crates/http/src/auth/oauth/openid.rs b/crates/http/src/auth/oauth/openid.rs index f8a32e53..3407f51e 100644 --- a/crates/http/src/auth/oauth/openid.rs +++ b/crates/http/src/auth/oauth/openid.rs @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use common::{Server, auth::oauth::oidc::Userinfo}; +use common::{Server, auth::oauth::SUPPORTED_SCOPES, auth::oauth::oidc::Userinfo}; use http_proto::*; use serde::Serialize; use std::future::Future; @@ -22,9 +22,11 @@ pub struct OpenIdMetadata { pub response_types_supported: &'static [&'static str], pub subject_types_supported: &'static [&'static str], pub grant_types_supported: &'static [&'static str], + pub token_endpoint_auth_methods_supported: &'static [&'static str], pub id_token_signing_alg_values_supported: &'static [&'static str], pub claims_supported: &'static [&'static str], pub code_challenge_methods_supported: &'static [&'static str], + pub authorization_response_iss_parameter_supported: bool, } pub trait OpenIdHandler: Sync + Send { @@ -77,14 +79,19 @@ impl OpenIdHandler for Server { jwks_uri: format!("{base_url}/auth/jwks.json"), registration_endpoint: format!("{base_url}/auth/register"), device_authorization_endpoint: format!("{base_url}/auth/device"), - response_types_supported: &["code", "id_token", "id_token token"], + response_types_supported: &["code"], grant_types_supported: &[ "authorization_code", - "implicit", + "refresh_token", "urn:ietf:params:oauth:grant-type:device_code", ], - scopes_supported: &["openid", "offline_access"], + scopes_supported: SUPPORTED_SCOPES, subject_types_supported: &["public"], + token_endpoint_auth_methods_supported: &[ + "none", + "client_secret_post", + "client_secret_basic", + ], id_token_signing_alg_values_supported: &[ "RS256", "RS384", "RS512", "ES256", "ES384", "PS256", "PS384", "PS512", "HS256", "HS384", "HS512", @@ -97,6 +104,7 @@ impl OpenIdHandler for Server { "email_verified", ], code_challenge_methods_supported: &["S256"], + authorization_response_iss_parameter_supported: true, issuer: base_url.to_string(), }) .into_http_response() diff --git a/crates/http/src/auth/oauth/registration.rs b/crates/http/src/auth/oauth/registration.rs index 9a64a83b..b430f464 100644 --- a/crates/http/src/auth/oauth/registration.rs +++ b/crates/http/src/auth/oauth/registration.rs @@ -10,10 +10,17 @@ use common::{ Server, auth::{ BuildAccessToken, - oauth::registration::{ClientRegistrationRequest, ClientRegistrationResponse}, + oauth::{ + client_id::{ClientMeta, decode_client_id, encode_client_id, scopes_to_mask}, + registration::{ + ClientRegistrationError, ClientRegistrationRequest, ClientRegistrationResponse, + TokenEndpointAuthMethod, validate_grant_metadata, validate_redirect_uri, + }, + }, }, }; use http_proto::{request::fetch_body, *}; +use hyper::StatusCode; use registry::schema::{ enums::Permission, prelude::{ObjectType, Property}, @@ -23,6 +30,7 @@ use std::future::Future; use store::{ rand::{Rng, distr::Alphanumeric, rng}, registry::write::{RegistryWrite, RegistryWriteResult}, + write::now, }; use trc::{AddContext, AuthEvent}; use types::id::Id; @@ -47,19 +55,6 @@ impl ClientRegistrationHandler for Server { req: &mut HttpRequest, session: HttpSessionData, ) -> trc::Result { - let tenant_id = if !self.core.oauth.allow_anonymous_client_registration { - // Authenticate request - let (_, access_token) = self.authenticate_headers(req, &session).await?; - - // Validate permissions - access_token.enforce_permission(Permission::OAuthClientRegistration)?; - access_token.tenant_id() - } else { - self.is_http_anonymous_request_allowed(session.remote_ip) - .await?; - None - }; - // Parse request let body = fetch_body(req, 20 * 1024, session.session_id).await; let request = serde_json::from_slice::( @@ -69,6 +64,78 @@ impl ClientRegistrationHandler for Server { trc::EventType::Resource(trc::ResourceEvent::BadParameters).from_json_error(err) })?; + // Validate redirect URIs and grant metadata (RFC 7591 + OAuth Public Clients profile) + if request.redirect_uris.is_empty() { + return Ok(registration_error( + ClientRegistrationError::invalid_redirect_uri( + "At least one redirect URI is required.", + ), + )); + } + for uri in &request.redirect_uris { + if let Err(err) = validate_redirect_uri(uri) { + return Ok(registration_error(err)); + } + } + if let Err(err) = validate_grant_metadata(&request) { + return Ok(registration_error(err)); + } + + let is_public = matches!( + request.token_endpoint_auth_method, + None | Some(TokenEndpointAuthMethod::None) + ); + + if is_public { + // Public client: issue a stateless, self-describing client id with no database write + if self.core.oauth.allow_anonymous_client_registration { + self.is_http_anonymous_request_allowed(session.remote_ip) + .await?; + } else { + let (_, access_token) = self.authenticate_headers(req, &session).await?; + access_token.enforce_permission(Permission::OAuthClientRegistration)?; + } + + let client_id = encode_client_id( + self.core.oauth.oauth_key.as_bytes(), + &ClientMeta { + redirect_uris: request.redirect_uris.clone(), + scope_mask: scopes_to_mask(request.scope.as_deref().unwrap_or_default()), + client_name: request.client_name.clone(), + }, + ) + .map_err(|err| { + trc::AuthEvent::Error + .into_err() + .details("Failed to encode client id.") + .reason(err) + .caused_by(trc::location!()) + })?; + + trc::event!( + Auth(AuthEvent::ClientRegistration), + Id = client_id.clone(), + RemoteIp = session.remote_ip + ); + + return Ok(JsonResponse::with_status( + StatusCode::CREATED, + ClientRegistrationResponse { + client_id_issued_at: Some(now()), + client_id, + request, + ..Default::default() + }, + ) + .no_cache() + .into_http_response()); + } + + // Confidential client: authenticate and persist the registration + let (_, access_token) = self.authenticate_headers(req, &session).await?; + access_token.enforce_permission(Permission::OAuthClientRegistration)?; + let tenant_id = access_token.tenant_id(); + // Generate client ID let client_id = rng() .sample_iter(Alphanumeric) @@ -107,11 +174,14 @@ impl ClientRegistrationHandler for Server { RemoteIp = session.remote_ip ); - Ok(JsonResponse::new(ClientRegistrationResponse { - client_id, - request, - ..Default::default() - }) + Ok(JsonResponse::with_status( + StatusCode::CREATED, + ClientRegistrationResponse { + client_id, + request, + ..Default::default() + }, + ) .no_cache() .into_http_response()) } @@ -122,6 +192,10 @@ impl ClientRegistrationHandler for Server { redirect_uri: Option<&str>, account_id: u32, ) -> trc::Result> { + // Stateless client ids are self-describing and validated at the authorization endpoint + if decode_client_id(self.core.oauth.oauth_key.as_bytes(), client_id).is_some() { + return Ok(None); + } if !self.core.oauth.require_client_authentication { return Ok(None); } @@ -180,3 +254,9 @@ impl ClientRegistrationHandler for Server { })) } } + +fn registration_error(error: ClientRegistrationError) -> HttpResponse { + JsonResponse::with_status(StatusCode::BAD_REQUEST, error) + .no_cache() + .into_http_response() +} diff --git a/crates/http/src/auth/oauth/token.rs b/crates/http/src/auth/oauth/token.rs index 22b240c5..805d8126 100644 --- a/crates/http/src/auth/oauth/token.rs +++ b/crates/http/src/auth/oauth/token.rs @@ -40,12 +40,14 @@ pub trait TokenHandler: Sync + Send { session_id: u64, ) -> impl Future> + Send; + #[allow(clippy::too_many_arguments)] fn issue_token( &self, account_id: u32, client_id: &str, issuer: String, nonce: Option, + scope: Option, with_refresh_token: bool, with_id_token: bool, ) -> impl Future> + Send; @@ -115,6 +117,7 @@ impl TokenHandler for Server { &oauth.client_id, issuer, oauth.nonce.as_ref().map(|s| s.as_str().into()), + oauth.scope.as_ref().map(|s| s.as_str().into()), true, true, ) @@ -183,6 +186,7 @@ impl TokenHandler for Server { &oauth.client_id, issuer, oauth.nonce.as_ref().map(|s| s.as_str().into()), + oauth.scope.as_ref().map(|s| s.as_str().into()), true, true, ) @@ -218,6 +222,7 @@ impl TokenHandler for Server { "", issuer, None, + None, token_info.expires_in <= self.core.oauth.oauth_expiry_refresh_token_renew, false, @@ -282,6 +287,7 @@ impl TokenHandler for Server { client_id: &str, issuer: String, nonce: Option, + scope: Option, with_refresh_token: bool, with_id_token: bool, ) -> trc::Result { @@ -341,7 +347,7 @@ impl TokenHandler for Server { } else { None }, - scope: None, + scope, }) } } diff --git a/crates/http/src/request.rs b/crates/http/src/request.rs index 12e8b51f..0d14d913 100644 --- a/crates/http/src/request.rs +++ b/crates/http/src/request.rs @@ -274,6 +274,13 @@ impl ParseHttp for Server { return self.handle_oauth_metadata().await; } + ("oauth-protected-resource", &Method::GET) => { + // Limit anonymous requests + self.is_http_anonymous_request_allowed(session.remote_ip) + .await?; + + return self.handle_oauth_protected_resource().await; + } ("openid-configuration", &Method::GET) => { // Limit anonymous requests self.is_http_anonymous_request_allowed(session.remote_ip) @@ -473,9 +480,7 @@ impl ParseHttp for Server { .await?; let path_email = path - .map(|segment| { - percent_decode_str(segment).decode_utf8_lossy().into_owned() - }) + .map(|segment| percent_decode_str(segment).decode_utf8_lossy().into_owned()) .find(|segment| segment.contains('@')); return self diff --git a/crates/registry/src/schema/structs_impl.rs b/crates/registry/src/schema/structs_impl.rs index c22ad6cc..4aa7887b 100644 --- a/crates/registry/src/schema/structs_impl.rs +++ b/crates/registry/src/schema/structs_impl.rs @@ -30125,7 +30125,7 @@ impl Default for OidcProvider { fn default() -> Self { Self { auth_code_max_attempts: 3u64, - anonymous_client_registration: false, + anonymous_client_registration: true, require_client_registration: false, auth_code_expiry: Duration::from_millis(600000), refresh_token_expiry: Duration::from_millis(2592000000), diff --git a/resources/html-templates/login.html b/resources/html-templates/login.html index 234b3dde..c5cb3ecb 100644 --- a/resources/html-templates/login.html +++ b/resources/html-templates/login.html @@ -334,9 +334,19 @@ state: params.get('state'), nonce: params.get('nonce'), code_challenge: params.get('code_challenge'), - code_challenge_method: params.get('code_challenge_method') + code_challenge_method: params.get('code_challenge_method'), + resource: params.getAll('resource') }; + // This page is the OAuth authorization screen used by apps + if (!isDevice && !oauth.redirect_uri) { + hide($('login-form')); + setText($('title'), 'Sign in with an app'); + setText($('subtitle'), 'This page is opened automatically by your mail, calendar or contacts app to authorize access to your account.'); + showError('This sign-in page cannot be used directly. Please start the sign-in from your app instead. (The required "redirect_uri" parameter is missing.)'); + return; + } + // Prefill username from login_hint var loginHint = params.get('login_hint'); if (loginHint) $('username').value = loginHint; @@ -420,11 +430,12 @@ if (oauth.nonce) r.nonce = oauth.nonce; if (oauth.code_challenge) r.codeChallenge = oauth.code_challenge; if (oauth.code_challenge_method) r.codeChallengeMethod = oauth.code_challenge_method; + if (oauth.resource && oauth.resource.length) r.resource = oauth.resource; if (otpValue) r.mfaToken = otpValue; return r; } - function performRedirect(clientCode) { + function performRedirect(clientCode, issuer) { // Build redirect_uri?code=&state= var target; try { @@ -437,6 +448,7 @@ } target.searchParams.set('code', clientCode); if (oauth.state) target.searchParams.set('state', oauth.state); + target.searchParams.set('iss', issuer || url.origin); window.location.assign(target.toString()); } @@ -453,7 +465,7 @@ showError('Temporary server failure. If the problem persists, contact your administrator.'); return; } - performRedirect(resp.client_code); + performRedirect(resp.client_code, resp.iss); return; case 'verified': hide($('login-form')); diff --git a/resources/html-templates/login.html.min b/resources/html-templates/login.html.min index d594e423..3be8982b 100644 --- a/resources/html-templates/login.html.min +++ b/resources/html-templates/login.html.min @@ -1 +1 @@ - Sign in

Sign in

Enter your credentials to continue

\ No newline at end of file + Sign in

Sign in

Enter your credentials to continue

\ No newline at end of file diff --git a/resources/schema/schema.json.gz b/resources/schema/schema.json.gz index ffa38c3d..473a2fa5 100644 Binary files a/resources/schema/schema.json.gz and b/resources/schema/schema.json.gz differ diff --git a/resources/schema/schema.json.sha256 b/resources/schema/schema.json.sha256 index a33c822f..0d3b6f98 100644 --- a/resources/schema/schema.json.sha256 +++ b/resources/schema/schema.json.sha256 @@ -1 +1 @@ --bCNRlw73NjcmJBDrWwU7hhuRX8FL4IZLWsQ_-iZh0o \ No newline at end of file +fleWj8pl8amEMbzM8N8ku4pLHlm4yYln_xEcXSCADBw \ No newline at end of file diff --git a/tests/src/system/mod.rs b/tests/src/system/mod.rs index 037f165f..260dfad7 100644 --- a/tests/src/system/mod.rs +++ b/tests/src/system/mod.rs @@ -58,10 +58,10 @@ pub async fn system_tests() { .await; test.insert_account(admin); - directory::test(&test).await; - authentication::test(&test).await; + /*directory::test(&test).await; + authentication::test(&test).await;*/ oidc::test(&mut test).await; - authorization::test(&mut test).await; + /*authorization::test(&mut test).await; tenant::test(&mut test).await; security::test(&mut test).await; quota::test(&mut test).await; @@ -70,7 +70,7 @@ pub async fn system_tests() { crypto::test(&mut test).await; antispam::test(&mut test).await; archiving::test(&mut test).await; - task::test(&mut test).await; + task::test(&mut test).await;*/ if test.is_reset() { test.temp_dir.delete(); diff --git a/tests/src/system/oidc.rs b/tests/src/system/oidc.rs index 7403de70..e229faf7 100644 --- a/tests/src/system/oidc.rs +++ b/tests/src/system/oidc.rs @@ -48,9 +48,23 @@ pub struct OAuthMetadata { pub grant_types_supported: Vec, pub response_types_supported: Vec, pub scopes_supported: Vec, + pub token_endpoint_auth_methods_supported: Vec, pub code_challenge_methods_supported: Vec, + pub authorization_response_iss_parameter_supported: bool, } +#[derive(Debug, serde::Deserialize)] +pub struct ProtectedResourceMetadata { + pub resource: String, + pub authorization_servers: Vec, + pub scopes_supported: Vec, + pub bearer_methods_supported: Vec, +} + +const PKCE_VERIFIER: &str = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; +const PKCE_CHALLENGE: &str = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"; +const PROFILE_SCOPE: &str = "urn:ietf:params:oauth:scope:mail offline_access"; + #[derive(Debug, serde::Deserialize)] pub struct OpenIdMetadata { pub issuer: String, @@ -64,9 +78,11 @@ pub struct OpenIdMetadata { pub response_types_supported: Vec, pub subject_types_supported: Vec, pub grant_types_supported: Vec, + pub token_endpoint_auth_methods_supported: Vec, pub id_token_signing_alg_values_supported: Vec, pub claims_supported: Vec, pub code_challenge_methods_supported: Vec, + pub authorization_response_iss_parameter_supported: bool, } pub async fn test(test: &mut TestServer) { @@ -131,18 +147,142 @@ pub async fn test(test: &mut TestServer) { get("https://127.0.0.1:8899/.well-known/openid-configuration").await; let jwk_set: JWKSet<()> = get(&oidc_metadata.jwks_uri).await; - // Register client + // OAuth Public Clients profile: the authorization server metadata must advertise the + // mandatory properties (RFC 8414 + draft-ietf-mailmaint-oauth-public). + assert!( + metadata + .grant_types_supported + .iter() + .any(|g| g == "authorization_code") + ); + assert!( + metadata + .grant_types_supported + .iter() + .any(|g| g == "refresh_token") + ); + assert!( + metadata + .response_types_supported + .iter() + .any(|r| r == "code") + ); + assert!( + metadata + .token_endpoint_auth_methods_supported + .iter() + .any(|m| m == "none") + ); + assert!( + metadata + .code_challenge_methods_supported + .iter() + .any(|m| m == "S256") + ); + assert!(metadata.authorization_response_iss_parameter_supported); + for scope in [ + "urn:ietf:params:oauth:scope:mail", + "urn:ietf:params:oauth:scope:contacts", + "urn:ietf:params:oauth:scope:calendars", + "offline_access", + ] { + assert!( + metadata.scopes_supported.iter().any(|s| s == scope), + "missing scope {scope}" + ); + } + assert!( + oidc_metadata + .grant_types_supported + .iter() + .any(|g| g == "refresh_token") + ); + assert!( + oidc_metadata + .token_endpoint_auth_methods_supported + .iter() + .any(|m| m == "none") + ); + assert!(oidc_metadata.authorization_response_iss_parameter_supported); + + // Protected Resource Metadata (RFC 9728) + let resource_metadata: ProtectedResourceMetadata = + get("https://127.0.0.1:8899/.well-known/oauth-protected-resource").await; + assert_eq!( + resource_metadata.authorization_servers, + vec![metadata.issuer.clone()] + ); + assert!( + resource_metadata + .bearer_methods_supported + .iter() + .any(|m| m == "header") + ); + assert!(!resource_metadata.resource.is_empty()); + + // Dynamic Client Registration: invalid redirect URIs are rejected (RFC 7591 §3.2.2) + for bad_uri in [ + "https://example.com/cb", + "http://127.0.0.1/cb#frag", + "http://127.0.0.1/../cb", + ] { + let (status, body) = post_json_raw( + &metadata.registration_endpoint, + &ClientRegistrationRequest { + redirect_uris: vec![bad_uri.to_string()], + ..Default::default() + }, + ) + .await; + assert_eq!(status, 400, "expected rejection for {bad_uri}: {body}"); + assert_eq!(body["error"], "invalid_redirect_uri", "for {bad_uri}"); + } + + // A loopback redirect URI is accepted and registration returns 201 Created + let (status, _) = post_json_raw( + &metadata.registration_endpoint, + &ClientRegistrationRequest { + redirect_uris: vec!["http://127.0.0.1/cb".to_string()], + scope: Some(PROFILE_SCOPE.to_string()), + ..Default::default() + }, + ) + .await; + assert_eq!(status, 201, "registration should return 201 Created"); + + // Register the client used for the flow with a private-use scheme redirect URI let registration: ClientRegistrationResponse = post_json( &metadata.registration_endpoint, None, &ClientRegistrationRequest { - redirect_uris: vec!["https://localhost".to_string()], + redirect_uris: vec!["com.example.app:/cb".to_string()], + scope: Some(PROFILE_SCOPE.to_string()), ..Default::default() }, ) .await; let client_id = registration.client_id; + // Public client ids are stateless (self-describing) and issued deterministically + assert!( + client_id.starts_with("swc1."), + "expected stateless client id, got {client_id}" + ); + let registration2: ClientRegistrationResponse = post_json( + &metadata.registration_endpoint, + None, + &ClientRegistrationRequest { + redirect_uris: vec!["com.example.app:/cb".to_string()], + scope: Some(PROFILE_SCOPE.to_string()), + ..Default::default() + }, + ) + .await; + assert_eq!( + registration2.client_id, client_id, + "identical registration must be deterministic" + ); + /*println!("OAuth metadata: {:#?}", metadata); println!("OpenID metadata: {:#?}", oidc_metadata); println!("JWKSet: {:#?}", jwk_set);*/ @@ -151,7 +291,48 @@ pub async fn test(test: &mut TestServer) { // Authorization code flow // ------------------------ - // Authenticate with the correct password + // A redirect URI that does not match the client registration must be rejected + // and the authorization server must not issue a code (OAuth Public Clients §3.4) + let (status, _) = post_login_raw(&LoginRequest::AuthCode { + account_name: "user@example.org".to_string(), + account_secret: "this is a very strong password".to_string(), + mfa_token: None, + client_id: client_id.to_string(), + redirect_uri: "com.example.app:/evil".to_string().into(), + nonce: None, + scope: Some(PROFILE_SCOPE.to_string()), + code_challenge: Some(PKCE_CHALLENGE.to_string()), + code_challenge_method: Some("S256".to_string()), + state: None, + resource: vec![], + }) + .await; + assert_ne!( + status, 200, + "mismatched redirect URI must not be authorized" + ); + + // An unknown resource indicator must be rejected (RFC 8707) + let (status, _) = post_login_raw(&LoginRequest::AuthCode { + account_name: "user@example.org".to_string(), + account_secret: "this is a very strong password".to_string(), + mfa_token: None, + client_id: client_id.to_string(), + redirect_uri: "com.example.app:/cb".to_string().into(), + nonce: None, + scope: Some(PROFILE_SCOPE.to_string()), + code_challenge: Some(PKCE_CHALLENGE.to_string()), + code_challenge_method: Some("S256".to_string()), + state: None, + resource: vec!["https://evil.example.com/jmap".to_string()], + }) + .await; + assert_ne!( + status, 200, + "unknown resource indicator must not be authorized" + ); + + // Authenticate with the correct password, PKCE (S256), scope and a valid resource indicator let response = http .post::( "/api/auth", @@ -160,23 +341,35 @@ pub async fn test(test: &mut TestServer) { account_secret: "this is a very strong password".to_string(), mfa_token: None, client_id: client_id.to_string(), - redirect_uri: "https://localhost".to_string().into(), + redirect_uri: "com.example.app:/cb".to_string().into(), nonce: "abc1234".to_string().into(), - scope: None, - code_challenge: None, - code_challenge_method: None, + scope: Some(PROFILE_SCOPE.to_string()), + code_challenge: Some(PKCE_CHALLENGE.to_string()), + code_challenge_method: Some("S256".to_string()), state: None, + resource: vec!["https://127.0.0.1:8899/jmap/session".to_string()], }, ) .await .unwrap(); + // The issuer returned in the authorization response must match the metadata issuer (RFC 9207) + if let LoginResponse::Authenticated { iss, .. } = &response { + assert_eq!(iss, &metadata.issuer); + } else { + panic!("Expected an authenticated response, got {response:?}"); + } + // Both client_id and redirect_uri have to match let mut token_params = AHashMap::from_iter([ ("client_id".to_string(), "invalid_client".to_string()), - ("redirect_uri".to_string(), "https://localhost".to_string()), + ( + "redirect_uri".to_string(), + "com.example.app:/cb".to_string(), + ), ("grant_type".to_string(), "authorization_code".to_string()), ("code".to_string(), response.unwrap_code()), + ("code_verifier".to_string(), PKCE_VERIFIER.to_string()), ]); assert_eq!( post::(&metadata.token_endpoint, &token_params).await, @@ -187,7 +380,7 @@ pub async fn test(test: &mut TestServer) { token_params.insert("client_id".to_string(), client_id.to_string()); token_params.insert( "redirect_uri".to_string(), - "https://some-other.url".to_string(), + "com.example.app:/other".to_string(), ); assert_eq!( post::(&metadata.token_endpoint, &token_params).await, @@ -196,10 +389,29 @@ pub async fn test(test: &mut TestServer) { } ); - // Obtain token - token_params.insert("redirect_uri".to_string(), "https://localhost".to_string()); - let (token, refresh_token, id_token) = - unwrap_oidc_token_response(post(&metadata.token_endpoint, &token_params).await); + // A missing or invalid PKCE verifier must be rejected (RFC 7636) + token_params.insert( + "redirect_uri".to_string(), + "com.example.app:/cb".to_string(), + ); + token_params.insert( + "code_verifier".to_string(), + "the-wrong-verifier".to_string(), + ); + assert_eq!( + post::(&metadata.token_endpoint, &token_params).await, + TokenResponse::Error { + error: ErrorType::InvalidGrant + } + ); + + // Obtain token and verify the granted scope is echoed back + token_params.insert("code_verifier".to_string(), PKCE_VERIFIER.to_string()); + let granted = post::(&metadata.token_endpoint, &token_params).await; + if let TokenResponse::Granted(response) = &granted { + assert_eq!(response.scope.as_deref(), Some(PROFILE_SCOPE)); + } + let (token, refresh_token, id_token) = unwrap_oidc_token_response(granted); // Connect to account using token and attempt to search let john_client = Client::new() @@ -522,6 +734,27 @@ async fn post_json( .unwrap() } +async fn post_json_raw(url: &str, body: &impl Serialize) -> (u16, serde_json::Value) { + let response = reqwest::Client::builder() + .timeout(Duration::from_millis(500)) + .danger_accept_invalid_certs(true) + .build() + .unwrap_or_default() + .post(url) + .body(serde_json::to_string(body).unwrap().into_bytes()) + .send() + .await + .unwrap(); + let status = response.status().as_u16(); + let value = + serde_json::from_slice(&response.bytes().await.unwrap()).unwrap_or(serde_json::Value::Null); + (status, value) +} + +async fn post_login_raw(body: &impl Serialize) -> (u16, serde_json::Value) { + post_json_raw("https://127.0.0.1:8899/api/auth", body).await +} + async fn post(url: &str, params: &AHashMap) -> T { post_with_auth(url, None, params).await } @@ -603,7 +836,7 @@ pub trait LoginResponseTest { impl LoginResponseTest for LoginResponse { fn unwrap_code(self) -> String { match self { - LoginResponse::Authenticated { client_code } => client_code, + LoginResponse::Authenticated { client_code, .. } => client_code, _ => panic!("Expected auth code response, got {:?}", self), } }