diff --git a/Cargo.lock b/Cargo.lock index d843da27..3d84cb1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -581,6 +581,22 @@ dependencies = [ "syn 2.0.77", ] +[[package]] +name = "biscuit" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e28fc7c56c61743a01d0d1b73e4fed68b8a4f032ea3a2d4bb8c6520a33fc05a" +dependencies = [ + "chrono", + "data-encoding", + "num-bigint", + "num-traits", + "once_cell", + "ring 0.17.8", + "serde", + "serde_json", +] + [[package]] name = "bit-set" version = "0.5.3" @@ -1043,6 +1059,7 @@ dependencies = [ "arc-swap", "base64 0.22.1", "bincode", + "biscuit", "chrono", "dashmap", "decancer", @@ -1067,6 +1084,8 @@ dependencies = [ "opentelemetry-otlp", "opentelemetry-semantic-conventions", "opentelemetry_sdk", + "p256", + "p384", "parking_lot", "pem", "privdrop", @@ -1078,6 +1097,7 @@ dependencies = [ "regex", "reqwest 0.12.7", "ring 0.17.8", + "rsa", "rustls 0.23.13", "rustls-pemfile 2.1.3", "rustls-pki-types", @@ -5838,6 +5858,7 @@ version = "1.0.128" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8" dependencies = [ + "indexmap 2.5.0", "itoa", "memchr", "ryu", @@ -6418,6 +6439,7 @@ dependencies = [ "ahash 0.8.11", "async-trait", "base64 0.22.1", + "biscuit", "bytes", "chrono", "common", diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index c3c9289d..ed0f3d21 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -62,6 +62,10 @@ xxhash-rust = { version = "0.8.5", features = ["xxh3"] } psl = "2" dashmap = "6.0" aes-gcm-siv = "0.11.1" +biscuit = "0.7.0" +rsa = "0.9.2" +p256 = { version = "0.13", features = ["ecdh"] } +p384 = { version = "0.13", features = ["ecdh"] } [target.'cfg(unix)'.dependencies] privdrop = "0.5.3" diff --git a/crates/common/src/auth/oauth/config.rs b/crates/common/src/auth/oauth/config.rs new file mode 100644 index 00000000..a283d458 --- /dev/null +++ b/crates/common/src/auth/oauth/config.rs @@ -0,0 +1,330 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::time::Duration; + +use biscuit::{ + jwa::{Algorithm, SignatureAlgorithm}, + jwk::{ + AlgorithmParameters, CommonParameters, EllipticCurve, EllipticCurveKeyParameters, + EllipticCurveKeyType, JWKSet, OctetKeyParameters, OctetKeyType, PublicKeyUse, + RSAKeyParameters, RSAKeyType, JWK, + }, + jws::Secret, +}; +use ring::signature::{self, KeyPair}; +use rsa::{pkcs1::DecodeRsaPublicKey, traits::PublicKeyParts, RsaPublicKey}; +use store::rand::{distributions::Alphanumeric, thread_rng, Rng}; +use utils::config::Config; +use x509_parser::num_bigint::BigUint; + +use crate::{ + config::{build_ecdsa_pem, build_rsa_keypair}, + manager::webadmin::Resource, +}; + +#[derive(Clone)] +pub struct OAuthConfig { + pub oauth_key: String, + pub oauth_expiry_user_code: u64, + pub oauth_expiry_auth_code: u64, + pub oauth_expiry_token: u64, + pub oauth_expiry_refresh_token: u64, + pub oauth_expiry_refresh_token_renew: u64, + pub oauth_max_auth_attempts: u32, + + pub oidc_expiry_id_token: u64, + pub oidc_signing_secret: Secret, + pub oidc_signature_algorithm: SignatureAlgorithm, + pub oidc_jwks: Resource>, +} + +impl OAuthConfig { + pub fn parse(config: &mut Config) -> Self { + let oidc_signature_algorithm = match config.value("oauth.oidc.signature-algorithm") { + Some(alg) => match alg.to_uppercase().as_str() { + "HS256" => SignatureAlgorithm::HS256, + "HS384" => SignatureAlgorithm::HS384, + "HS512" => SignatureAlgorithm::HS512, + + "RS256" => SignatureAlgorithm::RS256, + "RS384" => SignatureAlgorithm::RS384, + "RS512" => SignatureAlgorithm::RS512, + + "ES256" => SignatureAlgorithm::ES256, + "ES384" => SignatureAlgorithm::ES384, + + "PS256" => SignatureAlgorithm::PS256, + "PS384" => SignatureAlgorithm::PS384, + "PS512" => SignatureAlgorithm::PS512, + _ => { + config.new_parse_error( + "oauth.oidc.signature-algorithm", + format!("Invalid OIDC signature algorithm: {}", alg), + ); + SignatureAlgorithm::HS256 + } + }, + None => SignatureAlgorithm::HS256, + }; + + let rand_key = thread_rng() + .sample_iter(Alphanumeric) + .take(64) + .map(char::from) + .collect::() + .into_bytes(); + + let (oidc_signing_secret, algorithm) = match oidc_signature_algorithm { + SignatureAlgorithm::None + | SignatureAlgorithm::HS256 + | SignatureAlgorithm::HS384 + | SignatureAlgorithm::HS512 => { + let key = config + .value("oauth.oidc.signature-key") + .map(|s| s.to_string().into_bytes()) + .unwrap_or(rand_key); + + ( + Secret::Bytes(key.clone()), + AlgorithmParameters::OctetKey(OctetKeyParameters { + key_type: OctetKeyType::Octet, + value: key, + }), + ) + } + SignatureAlgorithm::RS256 + | SignatureAlgorithm::RS384 + | SignatureAlgorithm::RS512 + | SignatureAlgorithm::PS256 + | SignatureAlgorithm::PS384 + | SignatureAlgorithm::PS512 => parse_rsa_key(config).unwrap_or_else(|| { + ( + Secret::Bytes(rand_key.clone()), + AlgorithmParameters::OctetKey(OctetKeyParameters { + key_type: OctetKeyType::Octet, + value: rand_key, + }), + ) + }), + SignatureAlgorithm::ES256 | SignatureAlgorithm::ES384 | SignatureAlgorithm::ES512 => { + parse_ecdsa_key(config, oidc_signature_algorithm).unwrap_or_else(|| { + ( + Secret::Bytes(rand_key.clone()), + AlgorithmParameters::OctetKey(OctetKeyParameters { + key_type: OctetKeyType::Octet, + value: rand_key, + }), + ) + }) + } + }; + + let oidc_jwks = Resource { + content_type: "application/json".into(), + contents: serde_json::to_string(&JWKSet { + keys: vec![JWK { + common: CommonParameters { + public_key_use: PublicKeyUse::Signature.into(), + algorithm: Algorithm::Signature(oidc_signature_algorithm).into(), + key_id: "default".to_string().into(), + ..Default::default() + }, + algorithm, + additional: (), + }], + }) + .unwrap_or_default() + .into_bytes(), + }; + + OAuthConfig { + oauth_key: config + .value("oauth.key") + .map(|s| s.to_string()) + .unwrap_or_else(|| { + thread_rng() + .sample_iter(Alphanumeric) + .take(64) + .map(char::from) + .collect::() + }), + oauth_expiry_user_code: config + .property_or_default::("oauth.expiry.user-code", "30m") + .unwrap_or_else(|| Duration::from_secs(30 * 60)) + .as_secs(), + oauth_expiry_auth_code: config + .property_or_default::("oauth.expiry.auth-code", "10m") + .unwrap_or_else(|| Duration::from_secs(10 * 60)) + .as_secs(), + oauth_expiry_token: config + .property_or_default::("oauth.expiry.token", "1h") + .unwrap_or_else(|| Duration::from_secs(60 * 60)) + .as_secs(), + oauth_expiry_refresh_token: config + .property_or_default::("oauth.expiry.refresh-token", "30d") + .unwrap_or_else(|| Duration::from_secs(30 * 24 * 60 * 60)) + .as_secs(), + oauth_expiry_refresh_token_renew: config + .property_or_default::("oauth.expiry.refresh-token-renew", "4d") + .unwrap_or_else(|| Duration::from_secs(4 * 24 * 60 * 60)) + .as_secs(), + oauth_max_auth_attempts: config + .property_or_default("oauth.auth.max-attempts", "3") + .unwrap_or(10), + oidc_expiry_id_token: config + .property_or_default::("oauth.oidc.expiry.id-token", "15m") + .unwrap_or_else(|| Duration::from_secs(15 * 60)) + .as_secs(), + oidc_signing_secret, + oidc_signature_algorithm, + oidc_jwks, + } + } +} + +impl Default for OAuthConfig { + fn default() -> Self { + Self { + oauth_key: Default::default(), + oauth_expiry_user_code: Default::default(), + oauth_expiry_auth_code: Default::default(), + oauth_expiry_token: Default::default(), + oauth_expiry_refresh_token: Default::default(), + oauth_expiry_refresh_token_renew: Default::default(), + oauth_max_auth_attempts: Default::default(), + oidc_expiry_id_token: Default::default(), + oidc_signing_secret: Secret::Bytes("secret".to_string().into_bytes()), + oidc_signature_algorithm: SignatureAlgorithm::HS256, + oidc_jwks: Resource { + content_type: "application/json".into(), + contents: serde_json::to_string(&JWKSet::<()> { keys: vec![] }) + .unwrap_or_default() + .into_bytes(), + }, + } + } +} + +fn parse_rsa_key(config: &mut Config) -> Option<(Secret, AlgorithmParameters)> { + let rsa_key_pair = match build_rsa_keypair(config.value_require("oauth.oidc.signature-key")?) { + Ok(key) => key, + Err(err) => { + config.new_build_error( + "oauth.oidc.signature-key", + format!("Failed to build RSA key: {}", err), + ); + return None; + } + }; + + let rsa_public_key = match RsaPublicKey::from_pkcs1_der(rsa_key_pair.public_key().as_ref()) { + Ok(key) => key, + Err(err) => { + config.new_build_error( + "oauth.oidc.signature-key", + format!("Failed to obtain RSA public key: {}", err), + ); + return None; + } + }; + + let rsa_key_params = RSAKeyParameters { + key_type: RSAKeyType::RSA, + n: BigUint::from_bytes_be(&rsa_public_key.n().to_bytes_be()), + e: BigUint::from_bytes_be(&rsa_public_key.e().to_bytes_be()), + ..Default::default() + }; + + ( + Secret::RsaKeyPair(rsa_key_pair.into()), + AlgorithmParameters::RSA(rsa_key_params), + ) + .into() +} + +fn parse_ecdsa_key( + config: &mut Config, + oidc_signature_algorithm: SignatureAlgorithm, +) -> Option<(Secret, AlgorithmParameters)> { + let (alg, curve) = match oidc_signature_algorithm { + SignatureAlgorithm::ES256 => ( + &signature::ECDSA_P256_SHA256_FIXED_SIGNING, + EllipticCurve::P256, + ), + SignatureAlgorithm::ES384 => ( + &signature::ECDSA_P384_SHA384_FIXED_SIGNING, + EllipticCurve::P384, + ), + _ => unreachable!(), + }; + + let ecdsa_key_pair = + match build_ecdsa_pem(alg, config.value_require("oauth.oidc.signature-key")?) { + Ok(key) => key, + Err(err) => { + config.new_build_error( + "oauth.oidc.signature-key", + format!("Failed to build ECDSA key: {}", err), + ); + return None; + } + }; + + let ecdsa_public_key = ecdsa_key_pair.public_key().as_ref(); + + let (x, y) = match oidc_signature_algorithm { + SignatureAlgorithm::ES256 => { + let points = match p256::EncodedPoint::from_bytes(ecdsa_public_key) { + Ok(points) => points, + Err(err) => { + config.new_build_error( + "oauth.oidc.signature-key", + format!("Failed to parse ECDSA key: {}", err), + ); + return None; + } + }; + + ( + points.x().map(|x| x.to_vec()).unwrap_or_default(), + points.y().map(|y| y.to_vec()).unwrap_or_default(), + ) + } + SignatureAlgorithm::ES384 => { + let points = match p384::EncodedPoint::from_bytes(ecdsa_public_key) { + Ok(points) => points, + Err(err) => { + config.new_build_error( + "oauth.oidc.signature-key", + format!("Failed to parse ECDSA key: {}", err), + ); + return None; + } + }; + + ( + points.x().map(|x| x.to_vec()).unwrap_or_default(), + points.y().map(|y| y.to_vec()).unwrap_or_default(), + ) + } + _ => unreachable!(), + }; + + let ecdsa_key_params = EllipticCurveKeyParameters { + key_type: EllipticCurveKeyType::EC, + curve, + x, + y, + d: None, + }; + + ( + Secret::EcdsaKeyPair(ecdsa_key_pair.into()), + AlgorithmParameters::EllipticCurve(ecdsa_key_params), + ) + .into() +} diff --git a/crates/common/src/auth/oauth/mod.rs b/crates/common/src/auth/oauth/mod.rs index 20078868..680b6e68 100644 --- a/crates/common/src/auth/oauth/mod.rs +++ b/crates/common/src/auth/oauth/mod.rs @@ -4,8 +4,10 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ +pub mod config; pub mod crypto; pub mod introspect; +pub mod oidc; pub mod token; pub const DEVICE_CODE_LEN: usize = 40; diff --git a/crates/common/src/auth/oauth/oidc.rs b/crates/common/src/auth/oauth/oidc.rs new file mode 100644 index 00000000..16a9192b --- /dev/null +++ b/crates/common/src/auth/oauth/oidc.rs @@ -0,0 +1,153 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::fmt; + +use biscuit::{jws::RegisteredHeader, ClaimsSet, RegisteredClaims, SingleOrMultiple, JWT}; +use serde::{ + de::{self, Visitor}, + Deserialize, Deserializer, Serialize, +}; +use store::write::now; + +use crate::Server; + +#[derive(Debug, Default, Clone, Eq, PartialEq, Deserialize, Serialize)] +pub struct Userinfo { + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub sub: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub given_name: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub family_name: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub middle_name: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub nickname: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub preferred_username: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub profile: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub picture: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub website: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub email: Option, + + #[serde(default, deserialize_with = "any_bool")] + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub email_verified: bool, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub zoneinfo: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub locale: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_at: Option, +} + +impl Server { + pub fn issue_id_token( + &self, + subject: impl Into, + issuer: impl Into, + audience: impl Into, + ) -> trc::Result { + let now = now() as i64; + + JWT::new_decoded( + From::from(RegisteredHeader { + algorithm: self.core.oauth.oidc_signature_algorithm, + key_id: Some("default".into()), + ..Default::default() + }), + ClaimsSet::<()> { + registered: RegisteredClaims { + issuer: Some(issuer.into()), + subject: Some(subject.into()), + audience: Some(SingleOrMultiple::Single(audience.into())), + not_before: Some(now.into()), + issued_at: Some(now.into()), + expiry: Some((now + self.core.oauth.oidc_expiry_id_token as i64).into()), + ..Default::default() + }, + private: (), + }, + ) + .into_encoded(&self.core.oauth.oidc_signing_secret) + .map(|token| token.unwrap_encoded().to_string()) + .map_err(|err| { + trc::AuthEvent::Error + .into_err() + .reason(err) + .details("Failed to encode ID token") + }) + } +} + +fn any_bool<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + struct AnyBoolVisitor; + + impl<'de> Visitor<'de> for AnyBoolVisitor { + type Value = bool; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a boolean value") + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + match value { + "true" => Ok(true), + "false" => Ok(false), + _ => Err(E::custom(format!("Unknown boolean: {value}"))), + } + } + + fn visit_bool(self, value: bool) -> Result + where + E: de::Error, + { + Ok(value) + } + } + + deserializer.deserialize_any(AnyBoolVisitor) +} diff --git a/crates/common/src/auth/oauth/token.rs b/crates/common/src/auth/oauth/token.rs index ec6cb946..410487ea 100644 --- a/crates/common/src/auth/oauth/token.rs +++ b/crates/common/src/auth/oauth/token.rs @@ -55,7 +55,7 @@ impl Server { String::new() }; - let key = &self.core.jmap.oauth_key; + let key = &self.core.oauth.oauth_key; let context = format!( "{} {} {} {}", grant_type.as_str(), @@ -165,7 +165,7 @@ impl Server { }; // Build context - let key = self.core.jmap.oauth_key.clone(); + let key = self.core.oauth.oauth_key.clone(); let context = format!( "{} {} {} {}", grant_type.as_str(), diff --git a/crates/common/src/config/jmap/settings.rs b/crates/common/src/config/jmap/settings.rs index 700a5464..78f4c4a0 100644 --- a/crates/common/src/config/jmap/settings.rs +++ b/crates/common/src/config/jmap/settings.rs @@ -9,7 +9,6 @@ use std::{str::FromStr, time::Duration}; use jmap_proto::request::capability::BaseCapabilities; use mail_parser::HeaderName; use nlp::language::Language; -use store::rand::{distributions::Alphanumeric, thread_rng, Rng}; use utils::config::{cron::SimpleCron, utils::ParseValue, Config, Rate}; #[derive(Default, Clone)] @@ -63,13 +62,6 @@ pub struct JmapConfig { pub web_socket_timeout: Duration, pub web_socket_heartbeat: Duration, - pub oauth_key: String, - pub oauth_expiry_user_code: u64, - pub oauth_expiry_auth_code: u64, - pub oauth_expiry_token: u64, - pub oauth_expiry_refresh_token: u64, - pub oauth_expiry_refresh_token_renew: u64, - pub oauth_max_auth_attempts: u32, pub fallback_admin: Option<(String, String)>, pub master_user: Option<(String, String)>, @@ -321,39 +313,6 @@ impl JmapConfig { rate_anonymous: config .property_or_default::>("jmap.rate-limit.anonymous", "100/1m") .unwrap_or_default(), - oauth_key: config - .value("oauth.key") - .map(|s| s.to_string()) - .unwrap_or_else(|| { - thread_rng() - .sample_iter(Alphanumeric) - .take(64) - .map(char::from) - .collect::() - }), - oauth_expiry_user_code: config - .property_or_default::("oauth.expiry.user-code", "30m") - .unwrap_or_else(|| Duration::from_secs(30 * 60)) - .as_secs(), - oauth_expiry_auth_code: config - .property_or_default::("oauth.expiry.auth-code", "10m") - .unwrap_or_else(|| Duration::from_secs(10 * 60)) - .as_secs(), - oauth_expiry_token: config - .property_or_default::("oauth.expiry.token", "1h") - .unwrap_or_else(|| Duration::from_secs(60 * 60)) - .as_secs(), - oauth_expiry_refresh_token: config - .property_or_default::("oauth.expiry.refresh-token", "30d") - .unwrap_or_else(|| Duration::from_secs(30 * 24 * 60 * 60)) - .as_secs(), - oauth_expiry_refresh_token_renew: config - .property_or_default::("oauth.expiry.refresh-token-renew", "4d") - .unwrap_or_else(|| Duration::from_secs(4 * 24 * 60 * 60)) - .as_secs(), - oauth_max_auth_attempts: config - .property_or_default("oauth.auth.max-attempts", "3") - .unwrap_or(10), event_source_throttle: config .property_or_default("jmap.event-source.throttle", "1s") .unwrap_or_else(|| Duration::from_secs(1)), diff --git a/crates/common/src/config/mod.rs b/crates/common/src/config/mod.rs index 79b7c059..443f5253 100644 --- a/crates/common/src/config/mod.rs +++ b/crates/common/src/config/mod.rs @@ -8,12 +8,14 @@ use std::sync::Arc; use arc_swap::ArcSwap; use directory::{Directories, Directory}; +use ring::signature::{EcdsaKeyPair, RsaKeyPair}; use store::{BlobBackend, BlobStore, FtsStore, LookupStore, Store, Stores}; use telemetry::Metrics; use utils::config::Config; use crate::{ - expr::*, listener::tls::AcmeProviders, manager::config::ConfigManager, Core, Network, Security, + auth::oauth::config::OAuthConfig, expr::*, listener::tls::AcmeProviders, + manager::config::ConfigManager, Core, Network, Security, }; use self::{ @@ -163,6 +165,7 @@ impl Core { smtp: SmtpConfig::parse(config).await, jmap: JmapConfig::parse(config), imap: ImapConfig::parse(config), + oauth: OAuthConfig::parse(config), acme: AcmeProviders::parse(config), metrics: Metrics::parse(config), storage: Storage { @@ -186,3 +189,36 @@ impl Core { ArcSwap::from_pointee(self) } } + +pub fn build_rsa_keypair(pem: &str) -> Result { + match rustls_pemfile::read_one(&mut pem.as_bytes()) { + Ok(Some(rustls_pemfile::Item::Pkcs1Key(key))) => { + RsaKeyPair::from_der(key.secret_pkcs1_der()) + .map_err(|err| format!("Failed to parse PKCS1 RSA key: {err}")) + } + Ok(Some(rustls_pemfile::Item::Pkcs8Key(key))) => { + RsaKeyPair::from_pkcs8(key.secret_pkcs8_der()) + .map_err(|err| format!("Failed to parse PKCS8 RSA key: {err}")) + } + Err(err) => Err(format!("Failed to read PEM: {err}")), + Ok(Some(key)) => Err(format!("Unsupported key type: {key:?}")), + Ok(None) => Err("No RSA key found in PEM".to_string()), + } +} + +pub fn build_ecdsa_pem( + alg: &'static ring::signature::EcdsaSigningAlgorithm, + pem: &str, +) -> Result { + match rustls_pemfile::read_one(&mut pem.as_bytes()) { + Ok(Some(rustls_pemfile::Item::Pkcs8Key(key))) => EcdsaKeyPair::from_pkcs8( + alg, + key.secret_pkcs8_der(), + &ring::rand::SystemRandom::new(), + ) + .map_err(|err| format!("Failed to parse PKCS8 ECDSA key: {err}")), + Err(err) => Err(format!("Failed to read PEM: {err}")), + Ok(Some(key)) => Err(format!("Unsupported key type: {key:?}")), + Ok(None) => Err("No ECDSA key found in PEM".to_string()), + } +} diff --git a/crates/common/src/enterprise/license.rs b/crates/common/src/enterprise/license.rs index a0b96303..718af5ab 100644 --- a/crates/common/src/enterprise/license.rs +++ b/crates/common/src/enterprise/license.rs @@ -47,7 +47,7 @@ pub struct LicenseKey { #[derive(Debug)] pub enum LicenseError { Expired, - HostnameMismatch { issued_to: String, current: String }, + DomainMismatch { issued_to: String, current: String }, Parse, Validation, Decode, @@ -175,10 +175,12 @@ impl LicenseKey { } pub fn into_validated_key(self, hostname: impl AsRef) -> Result { - if self.hostname != hostname.as_ref() { - Err(LicenseError::HostnameMismatch { - issued_to: self.hostname.clone(), - current: hostname.as_ref().to_string(), + let local_domain = psl::domain_str(hostname.as_ref()).unwrap_or("invalid-hostname"); + let license_domain = psl::domain_str(&self.hostname).expect("Invalid license hostname"); + if local_domain != license_domain { + Err(LicenseError::DomainMismatch { + issued_to: license_domain.to_string(), + current: local_domain.to_string(), }) } else { Ok(self) @@ -213,11 +215,10 @@ impl Display for LicenseError { LicenseError::Validation => write!(f, "Failed to validate license key"), LicenseError::Decode => write!(f, "Failed to decode license key"), LicenseError::InvalidParameters => write!(f, "Invalid license key parameters"), - LicenseError::HostnameMismatch { issued_to, current } => { + LicenseError::DomainMismatch { issued_to, current } => { write!( f, - "License issued to {} does not match {}", - issued_to, current + "License issued to domain {issued_to:?} does not match {current:?}", ) } } diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index cda2f96f..95c83fb1 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -13,7 +13,7 @@ use std::{ use ahash::{AHashMap, AHashSet, RandomState}; use arc_swap::ArcSwap; -use auth::{roles::RolePermissions, AccessToken}; +use auth::{oauth::config::OAuthConfig, roles::RolePermissions, AccessToken}; use config::{ imap::ImapConfig, jmap::settings::JmapConfig, @@ -202,6 +202,7 @@ pub struct Core { pub sieve: Scripting, pub network: Network, pub acme: AcmeProviders, + pub oauth: OAuthConfig, pub smtp: SmtpConfig, pub jmap: JmapConfig, pub imap: ImapConfig, diff --git a/crates/jmap/src/api/http.rs b/crates/jmap/src/api/http.rs index 9de2e2ab..8a147a79 100644 --- a/crates/jmap/src/api/http.rs +++ b/crates/jmap/src/api/http.rs @@ -37,7 +37,7 @@ use crate::{ api::management::enterprise::telemetry::TelemetryApi, auth::{ authenticate::{Authenticator, HttpHeaders}, - oauth::{auth::OAuthApiHandler, token::TokenHandler, FormData, OAuthMetadata}, + oauth::{auth::OAuthApiHandler, openid::OpenIdHandler, token::TokenHandler, FormData}, rate_limit::RateLimiter, }, blob::{download::BlobDownload, upload::BlobUpload, DownloadResponse, UploadResponse}, @@ -217,19 +217,22 @@ impl ParseHttp for Server { let (_in_flight, access_token) = self.authenticate_headers(&req, &session).await?; - return Ok(self + return self .handle_session_resource(ctx.resolve_response_url(self).await, access_token) - .await? - .into_http_response()); + .await + .map(|s| s.into_http_response()); } ("oauth-authorization-server", &Method::GET) => { // Limit anonymous requests self.is_anonymous_allowed(&session.remote_ip).await?; - return Ok(JsonResponse::new(OAuthMetadata::new( - ctx.resolve_response_url(self).await, - )) - .into_http_response()); + return self.handle_oauth_metadata(req, session).await; + } + ("openid-configuration", &Method::GET) => { + // Limit anonymous requests + self.is_anonymous_allowed(&session.remote_ip).await?; + + return self.handle_oidc_metadata(req, session).await; } ("acme-challenge", &Method::GET) if self.has_acme_http_providers() => { if let Some(token) = path.next() { @@ -273,17 +276,12 @@ impl ParseHttp for Server { ("device", &Method::POST) => { self.is_anonymous_allowed(&session.remote_ip).await?; - let url = ctx.resolve_response_url(self).await; - return self - .handle_device_auth(&mut req, url, session.session_id) - .await; + return self.handle_device_auth(&mut req, session).await; } ("token", &Method::POST) => { self.is_anonymous_allowed(&session.remote_ip).await?; - return self - .handle_token_request(&mut req, session.session_id) - .await; + return self.handle_token_request(&mut req, session).await; } ("introspect", &Method::POST) => { // Authenticate request @@ -294,6 +292,19 @@ impl ParseHttp for Server { .handle_token_introspect(&mut req, &access_token, session.session_id) .await; } + ("userinfo", &Method::GET) => { + // Authenticate request + let (_in_flight, access_token) = + self.authenticate_headers(&req, &session).await?; + + return self.handle_userinfo_request(&access_token).await; + } + ("jwks.json", &Method::GET) => { + // Limit anonymous requests + self.is_anonymous_allowed(&session.remote_ip).await?; + + return Ok(self.core.oauth.oidc_jwks.clone().into_http_response()); + } (_, &Method::OPTIONS) => { return Ok(StatusCode::NO_CONTENT.into_http_response()); } @@ -655,17 +666,17 @@ impl SessionManager for JmapSessionManager { } } -struct HttpContext<'x> { - session: &'x HttpSessionData, - req: &'x HttpRequest, +pub struct HttpContext<'x> { + pub session: &'x HttpSessionData, + pub req: &'x HttpRequest, } impl<'x> HttpContext<'x> { - fn new(session: &'x HttpSessionData, req: &'x HttpRequest) -> Self { + pub fn new(session: &'x HttpSessionData, req: &'x HttpRequest) -> Self { Self { session, req } } - async fn resolve_response_url(&self, server: &Server) -> String { + pub async fn resolve_response_url(&self, server: &Server) -> String { server .eval_if( &server.core.network.http_response_url, @@ -683,7 +694,7 @@ impl<'x> HttpContext<'x> { }) } - async fn has_endpoint_access(&self, server: &Server) -> StatusCode { + pub async fn has_endpoint_access(&self, server: &Server) -> StatusCode { server .eval_if( &server.core.network.http_allowed_endpoint, diff --git a/crates/jmap/src/auth/oauth/auth.rs b/crates/jmap/src/auth/oauth/auth.rs index 9cb93a3f..44179cff 100644 --- a/crates/jmap/src/auth/oauth/auth.rs +++ b/crates/jmap/src/auth/oauth/auth.rs @@ -14,6 +14,7 @@ use common::{ Server, }; use rand::distributions::Standard; +use serde::Deserialize; use serde_json::json; use std::future::Future; use store::{ @@ -23,12 +24,27 @@ use store::{ }; use crate::{ - api::{http::ToHttpResponse, HttpRequest, HttpResponse, JsonResponse}, + api::{ + http::{HttpContext, HttpSessionData, ToHttpResponse}, + HttpRequest, HttpResponse, JsonResponse, + }, auth::oauth::OAuthStatus, }; use super::{DeviceAuthResponse, FormData, OAuthCode, OAuthCodeRequest, MAX_POST_LEN}; +#[derive(Debug, serde::Serialize, Deserialize)] +pub struct OAuthMetadata { + pub issuer: String, + pub token_endpoint: String, + pub authorization_endpoint: String, + pub device_authorization_endpoint: String, + pub introspection_endpoint: String, + pub grant_types_supported: Vec, + pub response_types_supported: Vec, + pub scopes_supported: Vec, +} + pub trait OAuthApiHandler: Sync + Send { fn handle_oauth_api_request( &self, @@ -39,8 +55,13 @@ pub trait OAuthApiHandler: Sync + Send { fn handle_device_auth( &self, req: &mut HttpRequest, - base_url: impl AsRef + Send, - session_id: u64, + session: HttpSessionData, + ) -> impl Future> + Send; + + fn handle_oauth_metadata( + &self, + req: HttpRequest, + session: HttpSessionData, ) -> impl Future> + Send; } @@ -98,7 +119,7 @@ impl OAuthApiHandler for Server { .key_set( format!("oauth:{client_code}").into_bytes(), value, - self.core.jmap.oauth_expiry_auth_code.into(), + self.core.oauth.oauth_expiry_auth_code.into(), ) .await?; @@ -146,7 +167,7 @@ impl OAuthApiHandler for Server { .key_set( format!("oauth:{device_code}").into_bytes(), auth_code.serialize(), - self.core.jmap.oauth_expiry_auth_code.into(), + self.core.oauth.oauth_expiry_auth_code.into(), ) .await?; } @@ -164,11 +185,10 @@ impl OAuthApiHandler for Server { async fn handle_device_auth( &self, req: &mut HttpRequest, - base_url: impl AsRef, - session_id: u64, + session: HttpSessionData, ) -> trc::Result { // Parse form - let client_id = FormData::from_request(req, MAX_POST_LEN, session_id) + let client_id = FormData::from_request(req, MAX_POST_LEN, session.session_id) .await? .remove("client_id") .filter(|client_id| client_id.len() < CLIENT_ID_MAX_LEN) @@ -215,7 +235,7 @@ impl OAuthApiHandler for Server { .key_set( format!("oauth:{device_code}").into_bytes(), oauth_code.clone(), - self.core.jmap.oauth_expiry_user_code.into(), + self.core.oauth.oauth_expiry_user_code.into(), ) .await?; @@ -226,20 +246,53 @@ impl OAuthApiHandler for Server { .key_set( format!("oauth:{user_code}").into_bytes(), oauth_code, - self.core.jmap.oauth_expiry_user_code.into(), + self.core.oauth.oauth_expiry_user_code.into(), ) .await?; // Build response - let base_url = base_url.as_ref(); + let base_url = HttpContext::new(&session, req) + .resolve_response_url(self) + .await; Ok(JsonResponse::new(DeviceAuthResponse { - verification_uri: format!("{}/authorize", base_url), - verification_uri_complete: format!("{}/authorize/?code={}", base_url, user_code), + verification_uri: format!("{base_url}/authorize"), + verification_uri_complete: format!("{base_url}/authorize/?code={user_code}"), device_code, user_code, - expires_in: self.core.jmap.oauth_expiry_user_code, + expires_in: self.core.oauth.oauth_expiry_user_code, interval: 5, }) .into_http_response()) } + + async fn handle_oauth_metadata( + &self, + req: HttpRequest, + session: HttpSessionData, + ) -> trc::Result { + let base_url = HttpContext::new(&session, &req) + .resolve_response_url(self) + .await; + + Ok(JsonResponse::new(OAuthMetadata { + authorization_endpoint: format!("{base_url}/authorize/code",), + token_endpoint: format!("{base_url}/auth/token"), + grant_types_supported: vec![ + "authorization_code".to_string(), + "implicit".to_string(), + "urn:ietf:params:oauth:grant-type:device_code".to_string(), + ], + device_authorization_endpoint: format!("{base_url}/auth/device"), + response_types_supported: vec![ + "code".to_string(), + "id_token".to_string(), + "code token".to_string(), + "id_token token".to_string(), + ], + scopes_supported: vec!["openid".to_string(), "offline_access".to_string()], + introspection_endpoint: format!("{base_url}/auth/introspect"), + issuer: base_url, + }) + .into_http_response()) + } } diff --git a/crates/jmap/src/auth/oauth/mod.rs b/crates/jmap/src/auth/oauth/mod.rs index eaa8c0c1..14d1caa9 100644 --- a/crates/jmap/src/auth/oauth/mod.rs +++ b/crates/jmap/src/auth/oauth/mod.rs @@ -11,6 +11,7 @@ use utils::map::vec_map::VecMap; use crate::api::{http::fetch_body, HttpRequest}; pub mod auth; +pub mod openid; pub mod token; #[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -110,6 +111,8 @@ pub struct OAuthResponse { pub refresh_token: Option, #[serde(skip_serializing_if = "Option::is_none")] pub scope: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub id_token: Option, } #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -136,38 +139,6 @@ pub enum ErrorType { ExpiredToken, } -#[derive(Debug, Serialize, Deserialize)] -pub struct OAuthMetadata { - pub issuer: String, - pub token_endpoint: String, - pub authorization_endpoint: String, - pub device_authorization_endpoint: String, - pub introspection_endpoint: String, - pub grant_types_supported: Vec, - pub response_types_supported: Vec, - pub scopes_supported: Vec, -} - -impl OAuthMetadata { - pub fn new(base_url: impl AsRef) -> Self { - let base_url = base_url.as_ref(); - OAuthMetadata { - issuer: base_url.into(), - authorization_endpoint: format!("{base_url}/authorize/code",), - token_endpoint: format!("{base_url}/auth/token"), - grant_types_supported: vec![ - "authorization_code".to_string(), - "implicit".to_string(), - "urn:ietf:params:oauth:grant-type:device_code".to_string(), - ], - device_authorization_endpoint: format!("{base_url}/auth/device"), - response_types_supported: vec!["code".to_string(), "code token".to_string()], - scopes_supported: vec!["offline_access".to_string()], - introspection_endpoint: format!("{base_url}/auth/introspect"), - } - } -} - #[derive(Debug, Serialize, Deserialize)] #[serde(tag = "type")] pub enum OAuthCodeRequest { diff --git a/crates/jmap/src/auth/oauth/openid.rs b/crates/jmap/src/auth/oauth/openid.rs new file mode 100644 index 00000000..47825c6c --- /dev/null +++ b/crates/jmap/src/auth/oauth/openid.rs @@ -0,0 +1,116 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::future::Future; + +use common::{ + auth::{oauth::oidc::Userinfo, AccessToken}, + Server, +}; +use serde::{Deserialize, Serialize}; + +use crate::api::{ + http::{HttpContext, HttpSessionData, ToHttpResponse}, + HttpRequest, HttpResponse, JsonResponse, +}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct OpenIdMetadata { + pub issuer: String, + pub authorization_endpoint: String, + pub token_endpoint: String, + pub userinfo_endpoint: String, + pub jwks_uri: String, + pub registration_endpoint: String, + pub scopes_supported: Vec, + pub response_types_supported: Vec, + pub subject_types_supported: Vec, + pub grant_types_supported: Vec, + pub id_token_signing_alg_values_supported: Vec, + pub claims_supported: Vec, +} + +pub trait OpenIdHandler: Sync + Send { + fn handle_userinfo_request( + &self, + access_token: &AccessToken, + ) -> impl Future> + Send; + + fn handle_oidc_metadata( + &self, + req: HttpRequest, + session: HttpSessionData, + ) -> impl Future> + Send; +} + +impl OpenIdHandler for Server { + async fn handle_userinfo_request( + &self, + access_token: &AccessToken, + ) -> trc::Result { + Ok(JsonResponse::new(Userinfo { + sub: Some(access_token.primary_id.to_string()), + name: access_token.description.clone(), + preferred_username: Some(access_token.name.clone()), + email: access_token.emails.first().cloned(), + email_verified: !access_token.emails.is_empty(), + ..Default::default() + }) + .into_http_response()) + } + + async fn handle_oidc_metadata( + &self, + req: HttpRequest, + session: HttpSessionData, + ) -> trc::Result { + let base_url = HttpContext::new(&session, &req) + .resolve_response_url(self) + .await; + + Ok(JsonResponse::new(OpenIdMetadata { + authorization_endpoint: format!("{base_url}/authorize/code",), + token_endpoint: format!("{base_url}/auth/token"), + userinfo_endpoint: format!("{base_url}/auth/userinfo"), + jwks_uri: format!("{base_url}/auth/jwks.json"), + registration_endpoint: format!("{base_url}/auth/register"), + response_types_supported: vec![ + "code".to_string(), + "id_token".to_string(), + "id_token token".to_string(), + ], + grant_types_supported: vec![ + "authorization_code".to_string(), + "implicit".to_string(), + "urn:ietf:params:oauth:grant-type:device_code".to_string(), + ], + scopes_supported: vec!["openid".to_string(), "offline_access".to_string()], + subject_types_supported: vec!["public".to_string()], + id_token_signing_alg_values_supported: vec![ + "RS256".to_string(), + "RS384".to_string(), + "RS512".to_string(), + "ES256".to_string(), + "ES384".to_string(), + "PS256".to_string(), + "PS384".to_string(), + "PS512".to_string(), + "HS256".to_string(), + "HS384".to_string(), + "HS512".to_string(), + ], + claims_supported: vec![ + "sub".to_string(), + "name".to_string(), + "preferred_username".to_string(), + "email".to_string(), + "email_verified".to_string(), + ], + issuer: base_url, + }) + .into_http_response()) + } +} diff --git a/crates/jmap/src/auth/oauth/token.rs b/crates/jmap/src/auth/oauth/token.rs index 49046746..677028b9 100644 --- a/crates/jmap/src/auth/oauth/token.rs +++ b/crates/jmap/src/auth/oauth/token.rs @@ -12,7 +12,10 @@ use hyper::StatusCode; use std::future::Future; use store::write::Bincode; -use crate::api::{http::ToHttpResponse, HttpRequest, HttpResponse, JsonResponse}; +use crate::api::{ + http::{HttpContext, HttpSessionData, ToHttpResponse}, + HttpRequest, HttpResponse, JsonResponse, +}; use super::{ ErrorType, FormData, OAuthCode, OAuthResponse, OAuthStatus, TokenResponse, MAX_POST_LEN, @@ -22,7 +25,7 @@ pub trait TokenHandler: Sync + Send { fn handle_token_request( &self, req: &mut HttpRequest, - session_id: u64, + session: HttpSessionData, ) -> impl Future> + Send; fn handle_token_introspect( @@ -36,6 +39,7 @@ pub trait TokenHandler: Sync + Send { &self, account_id: u32, client_id: &str, + issuer: String, with_refresh_token: bool, ) -> impl Future> + Send; } @@ -45,14 +49,18 @@ impl TokenHandler for Server { async fn handle_token_request( &self, req: &mut HttpRequest, - session_id: u64, + session: HttpSessionData, ) -> trc::Result { // Parse form - let params = FormData::from_request(req, MAX_POST_LEN, session_id).await?; + let params = FormData::from_request(req, MAX_POST_LEN, session.session_id).await?; let grant_type = params.get("grant_type").unwrap_or_default(); let mut response = TokenResponse::error(ErrorType::InvalidGrant); + let issuer = HttpContext::new(&session, req) + .resolve_response_url(self) + .await; + if grant_type.eq_ignore_ascii_case("authorization_code") { response = if let (Some(code), Some(client_id), Some(redirect_uri)) = ( params.get("code"), @@ -80,7 +88,7 @@ impl TokenHandler for Server { .await?; // Issue token - self.issue_token(oauth.account_id, &oauth.client_id, true) + self.issue_token(oauth.account_id, &oauth.client_id, issuer, true) .await .map(TokenResponse::Granted) .map_err(|err| { @@ -126,7 +134,7 @@ impl TokenHandler for Server { .await?; // Issue token - self.issue_token(oauth.account_id, &oauth.client_id, true) + self.issue_token(oauth.account_id, &oauth.client_id, issuer, true) .await .map(TokenResponse::Granted) .map_err(|err| { @@ -156,8 +164,9 @@ impl TokenHandler for Server { .issue_token( token_info.account_id, &token_info.client_id, + issuer, token_info.expires_in - <= self.core.jmap.oauth_expiry_refresh_token_renew, + <= self.core.oauth.oauth_expiry_refresh_token_renew, ) .await .map(TokenResponse::Granted) @@ -171,7 +180,7 @@ impl TokenHandler for Server { trc::error!(err .caused_by(trc::location!()) .details("Failed to validate refresh token") - .span_id(session_id)); + .span_id(session.session_id)); TokenResponse::error(ErrorType::InvalidGrant) } }; @@ -216,6 +225,7 @@ impl TokenHandler for Server { &self, account_id: u32, client_id: &str, + issuer: String, with_refresh_token: bool, ) -> trc::Result { Ok(OAuthResponse { @@ -224,23 +234,30 @@ impl TokenHandler for Server { GrantType::AccessToken, account_id, client_id, - self.core.jmap.oauth_expiry_token, + self.core.oauth.oauth_expiry_token, ) .await?, token_type: "bearer".to_string(), - expires_in: self.core.jmap.oauth_expiry_token, + expires_in: self.core.oauth.oauth_expiry_token, refresh_token: if with_refresh_token { self.encode_access_token( GrantType::RefreshToken, account_id, client_id, - self.core.jmap.oauth_expiry_refresh_token, + self.core.oauth.oauth_expiry_refresh_token, ) .await? .into() } else { None }, + id_token: match self.issue_id_token(account_id.to_string(), issuer, client_id) { + Ok(id_token) => Some(id_token), + Err(err) => { + trc::error!(err); + None + } + }, scope: None, }) } diff --git a/tests/Cargo.toml b/tests/Cargo.toml index 1c2a085d..72a86abe 100644 --- a/tests/Cargo.toml +++ b/tests/Cargo.toml @@ -61,6 +61,7 @@ num_cpus = "1.15.0" async-trait = "0.1.68" chrono = "0.4" ring = { version = "0.17" } +biscuit = "0.7.0" [target.'cfg(not(target_env = "msvc"))'.dependencies] jemallocator = "0.5.0" diff --git a/tests/src/jmap/auth_oauth.rs b/tests/src/jmap/auth_oauth.rs index 7b74be57..dafe5e4e 100644 --- a/tests/src/jmap/auth_oauth.rs +++ b/tests/src/jmap/auth_oauth.rs @@ -7,11 +7,13 @@ use std::time::{Duration, Instant}; use base64::{engine::general_purpose, Engine}; +use biscuit::{jwk::JWKSet, SingleOrMultiple, JWT}; use bytes::Bytes; use common::auth::oauth::introspect::OAuthIntrospect; use imap_proto::ResponseType; use jmap::auth::oauth::{ - DeviceAuthResponse, ErrorType, OAuthCodeRequest, OAuthMetadata, TokenResponse, + auth::OAuthMetadata, openid::OpenIdMetadata, DeviceAuthResponse, ErrorType, OAuthCodeRequest, + TokenResponse, }; use jmap_client::{ client::{Client, Credentials}, @@ -47,20 +49,18 @@ pub async fn test(params: &mut JMAPTest) { // Create test account let server = params.server.clone(); - let john_id = Id::from( - server - .core - .storage - .data - .create_test_user( - "jdoe@example.com", - "12345", - "John Doe", - &["jdoe@example.com"], - ) - .await, - ) - .to_string(); + let john_int_id = server + .core + .storage + .data + .create_test_user( + "jdoe@example.com", + "12345", + "John Doe", + &["jdoe@example.com"], + ) + .await; + let john_id = Id::from(john_int_id).to_string(); // Build API let api = ManagementApi::new(8899, "jdoe@example.com", "12345"); @@ -68,7 +68,13 @@ pub async fn test(params: &mut JMAPTest) { // Obtain OAuth metadata let metadata: OAuthMetadata = get("https://127.0.0.1:8899/.well-known/oauth-authorization-server").await; - //println!("OAuth metadata: {:#?}", metadata); + let oidc_metadata: OpenIdMetadata = + get("https://127.0.0.1:8899/.well-known/openid-configuration").await; + let jwk_set: JWKSet<()> = get(&oidc_metadata.jwks_uri).await; + + /*println!("OAuth metadata: {:#?}", metadata); + println!("OpenID metadata: {:#?}", oidc_metadata); + println!("JWKSet: {:#?}", jwk_set);*/ // ------------------------ // Authorization code flow @@ -114,8 +120,8 @@ pub async fn test(params: &mut JMAPTest) { // Obtain token token_params.insert("redirect_uri".to_string(), "https://localhost".to_string()); - let (token, refresh_token, _) = - unwrap_token_response(post(&metadata.token_endpoint, &token_params).await); + let (token, refresh_token, id_token) = + unwrap_oidc_token_response(post(&metadata.token_endpoint, &token_params).await); // Connect to account using token and attempt to search let john_client = Client::new() @@ -132,6 +138,18 @@ pub async fn test(params: &mut JMAPTest) { .ids() .is_empty()); + // Verify ID token using the JWK set + let id_token = JWT::<(), biscuit::Empty>::new_encoded(&id_token) + .decode_with_jwks(&jwk_set, None) + .unwrap(); + let claims = &id_token.payload().unwrap().registered; + assert_eq!(claims.issuer, Some(oidc_metadata.issuer)); + assert_eq!(claims.subject, Some(john_int_id.to_string())); + assert_eq!( + claims.audience, + Some(SingleOrMultiple::Single("OAuthyMcOAuthFace".to_string())) + ); + // Introspect token let access_introspect: OAuthIntrospect = post_with_auth::( &metadata.introspection_endpoint, @@ -441,3 +459,17 @@ fn unwrap_token_response(response: TokenResponse) -> (String, Option, u6 TokenResponse::Error { error } => panic!("Expected granted, got {:?}", error), } } + +fn unwrap_oidc_token_response(response: TokenResponse) -> (String, Option, String) { + match response { + TokenResponse::Granted(granted) => { + assert_eq!(granted.token_type, "bearer"); + ( + granted.access_token, + granted.refresh_token, + granted.id_token.unwrap(), + ) + } + TokenResponse::Error { error } => panic!("Expected granted, got {:?}", error), + } +} diff --git a/tests/src/jmap/mod.rs b/tests/src/jmap/mod.rs index ee06ef75..8634f3e7 100644 --- a/tests/src/jmap/mod.rs +++ b/tests/src/jmap/mod.rs @@ -289,6 +289,47 @@ token = "1s" refresh-token = "3s" refresh-token-renew = "2s" +[oauth.oidc] +signature-key = '''-----BEGIN PRIVATE KEY----- +MIIEuwIBADANBgkqhkiG9w0BAQEFAASCBKUwggShAgEAAoIBAQDMXJI1bL3z8gaF +Ze/6493VjL+jHkFMP2Pc7fLwRF1fhkuIdYTp69LabzrSEJCRCz0UI2NHqPOgtOta ++zRHKAMr7c7Z6uKO0K+aXiQYHw4Y70uSG8CnmNl7kb4OM/CAcoO6fePmvBsyESfn +TmkJ5bfHEZQFDQEAoDlDjtjxuwYsAQQVQXuAydi8j8pyTWKAJ1RDgnUT+HbOub7j +JrQ7sPe6MPCjXv5N76v9RMHKktfYwRNMlkLkxImQU55+vlvghNztgFlIlJDFfNiy +UQPV5FTEZJli9BzMoj1JQK3sZyV8WV0W1zN41QQ+glAAC6+K7iTDPRMINBSwbHyn +6Lb9Q6U7AgMBAAECggEAB93qZ5xrhYgEFeoyKO4mUdGsu4qZyJB0zNeWGgdaXCfZ +zC4l8zFM+R6osix0EY6lXRtC95+6h9hfFQNa5FWseupDzmIQiEnim1EowjWef87l +Eayi0nDRB8TjqZKjR/aLOUhzrPlXHKrKEUk/RDkacCiDklwz9S0LIfLOSXlByBDM +/n/eczfX2gUATexMHSeIXs8vN2jpuiVv0r+FPXcRvqdzDZnYSzS8BJ9k6RYXVQ4o +NzCbfqgFIpVryB7nHgSTrNX9G7299If8/dXmesXWSFEJvvDSSpcBoINKbfgSlrxd +6ubjiotcEIBUSlbaanRrydwShhLHnXyupNAb7tlvyQKBgQDsIipSK4+H9FGl1rAk +Gg9DLJ7P/94sidhoq1KYnj/CxwGLoRq22khZEUYZkSvYXDu1Qkj9Avi3TRhw8uol +l2SK1VylL5FQvTLKhWB7b2hjrUd5llMRgS3/NIdLhOgDMB7w3UxJnCA/df/Rj+dM +WhkyS1f0x3t7XPLwWGurW0nJcwKBgQDdjhrNfabrK7OQvDpAvNJizuwZK9WUL7CD +rR0V0MpDGYW12BTEOY6tUK6XZgiRitAXf4EkEI6R0Q0bFzwDDLrg7TvGdTuzNeg/ +8vm8IlRlOkrdihtHZI4uRB7Ytmz24vzywEBE0p6enA7v4oniscUks/KKmDGr0V90 +yT9gIVrjGQKBgQCjnWC5otlHGLDiOgm+WhgtMWOxN9dYAQNkMyF+Alinu4CEoVKD +VGhA3sk1ufMpbW8pvw4X0dFIITFIQeift3DBCemxw23rBc2FqjkaDi3EszINO22/ +eUTHyjvcxfCFFPi7aHsNnhJyJm7lY9Kegudmg/Ij93zGE7d5darVBuHvpQKBgBBY +YovUgFMLR1UfPeD2zUKy52I4BKrJFemxBNtOKw3mPSIcTfPoFymcMTVENs+eARoq +svlZK1uAo8ni3e+Pqd3cQrOyhHQFPxwwrdH+amGJemp7vOV4erDZH7l3Q/S27Fhw +bI1nSIKFGukBupB58wRxLiyha9C0QqmYC0/pRg5JAn8Rbj5tP26oVCXjZEfWJL8J +axxSxsGA4Vol6i6LYnVgZG+1ez2rP8vUORo1lRzmdeP4o1BSJf9TPwXkuppE5J+t +UZVKtYGlEn1RqwGNd8I9TiWvU84rcY9nsxlDR86xwKRWFvYqVOiGYtzRyewYRdjU +rTs9aqB3v1+OVxGxR6Na +-----END PRIVATE KEY----- +''' +signature-algorithm = "RS256" + +[oauth.oidc-ignore] +signature-key = '''-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQggybcqc86ulFFiOon +WiYrLO4z8/kmkqvA7wGElBok9IqhRANCAAQxZK68FnQtHC0eyh8CA05xRIvxhVHn +0ymka6XBh9aFtW4wfeoKhTkSKjHc/zjh9Rr2dr3kvmYe80fMGhW4ycGA +-----END PRIVATE KEY----- +''' +signature-algorithm = "ES256" + [session.extensions] expn = true vrfy = true