From dddd9fe9986788a88b5cfe8d7f81d8172106796d Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Fri, 15 Nov 2024 17:04:13 +0100 Subject: [PATCH] Support Sign in with Apple --- Cargo.lock | 6 ++ Cargo.toml | 35 ++++++++ crates/cli/src/sync.rs | 39 +++++++-- crates/config/src/sections/mod.rs | 3 +- crates/config/src/sections/upstream_oauth2.rs | 56 +++++++++---- crates/data-model/src/lib.rs | 1 + crates/data-model/src/upstream_oauth2/mod.rs | 3 +- .../src/upstream_oauth2/provider.rs | 57 ++++++++++++- crates/handlers/Cargo.toml | 2 + crates/handlers/src/upstream_oauth2/cache.rs | 7 +- crates/handlers/src/upstream_oauth2/link.rs | 5 +- crates/handlers/src/upstream_oauth2/mod.rs | 84 ++++++++++++++----- crates/handlers/src/views/login.rs | 9 +- crates/jose/Cargo.toml | 2 +- crates/keystore/Cargo.toml | 14 ++-- crates/oidc-client/Cargo.toml | 4 + .../src/types/client_credentials.rs | 68 ++++++++++++++- crates/storage-pg/src/upstream_oauth2/mod.rs | 10 +-- .../storage/src/upstream_oauth2/provider.rs | 6 +- docs/config.schema.json | 37 ++++++++ 20 files changed, 374 insertions(+), 74 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 76127f916..e29c28f3d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3346,6 +3346,7 @@ dependencies = [ "camino", "chrono", "cookie_store", + "elliptic-curve", "futures-util", "governor", "headers", @@ -3377,6 +3378,7 @@ dependencies = [ "opentelemetry", "opentelemetry-semantic-conventions", "pbkdf2", + "pkcs8", "psl", "rand", "rand_chacha", @@ -3612,6 +3614,7 @@ dependencies = [ "base64ct", "bitflags 2.6.0", "chrono", + "elliptic-curve", "form_urlencoded", "headers", "http", @@ -3623,6 +3626,9 @@ dependencies = [ "mas-keystore", "mime", "oauth2-types", + "p256", + "pem-rfc7468", + "pkcs8", "rand", "rand_chacha", "reqwest", diff --git a/Cargo.toml b/Cargo.toml index 27a22f911..fd447eb28 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -102,6 +102,11 @@ features = ["serde", "clock"] version = "4.5.21" features = ["derive"] +# Elliptic curve cryptography +[workspace.dependencies.elliptic-curve] +version = "0.13.8" +features = ["std", "pem", "sec1"] + # Configuration loading [workspace.dependencies.figment] version = "0.10.19" @@ -188,6 +193,36 @@ features = ["pycompat"] [workspace.dependencies.nonzero_ext] version = "0.3.0" +# K256 elliptic curve +[workspace.dependencies.k256] +version = "0.13.4" +features = ["std"] + +# P256 elliptic curve +[workspace.dependencies.p256] +version = "0.13.2" +features = ["std"] + +# P384 elliptic curve +[workspace.dependencies.p384] +version = "0.13.0" +features = ["std"] + +# PEM file decoding +[workspace.dependencies.pem-rfc7468] +version = "0.7.0" +features = ["std"] + +# PKCS#1 encoding +[workspace.dependencies.pkcs1] +version = "0.7.5" +features = ["std"] + +# PKCS#8 encoding +[workspace.dependencies.pkcs8] +version = "0.10.2" +features = ["std", "pkcs5", "encryption"] + # Random values [workspace.dependencies.rand] version = "0.8.5" diff --git a/crates/cli/src/sync.rs b/crates/cli/src/sync.rs index 0a0e01ddc..ad73427ad 100644 --- a/crates/cli/src/sync.rs +++ b/crates/cli/src/sync.rs @@ -187,11 +187,17 @@ pub async fn config_sync( continue; } - let encrypted_client_secret = provider - .client_secret - .as_deref() - .map(|client_secret| encrypter.encrypt_to_string(client_secret.as_bytes())) - .transpose()?; + let encrypted_client_secret = + if let Some(client_secret) = provider.client_secret.as_deref() { + Some(encrypter.encrypt_to_string(client_secret.as_bytes())?) + } else if let Some(siwa) = provider.sign_in_with_apple.as_ref() { + // For SIWA, we JSON-encode the config and encrypt it, reusing the client_secret + // field in the database + let encoded = serde_json::to_vec(siwa)?; + Some(encrypter.encrypt_to_string(&encoded)?) + } else { + None + }; let discovery_mode = match provider.discovery_mode { mas_config::UpstreamOAuth2DiscoveryMode::Oidc => { @@ -205,6 +211,27 @@ pub async fn config_sync( } }; + let token_endpoint_auth_method = match provider.token_endpoint_auth_method { + mas_config::UpstreamOAuth2TokenAuthMethod::None => { + mas_data_model::UpstreamOAuthProviderTokenAuthMethod::None + } + mas_config::UpstreamOAuth2TokenAuthMethod::ClientSecretBasic => { + mas_data_model::UpstreamOAuthProviderTokenAuthMethod::ClientSecretBasic + } + mas_config::UpstreamOAuth2TokenAuthMethod::ClientSecretPost => { + mas_data_model::UpstreamOAuthProviderTokenAuthMethod::ClientSecretPost + } + mas_config::UpstreamOAuth2TokenAuthMethod::ClientSecretJwt => { + mas_data_model::UpstreamOAuthProviderTokenAuthMethod::ClientSecretJwt + } + mas_config::UpstreamOAuth2TokenAuthMethod::PrivateKeyJwt => { + mas_data_model::UpstreamOAuthProviderTokenAuthMethod::PrivateKeyJwt + } + mas_config::UpstreamOAuth2TokenAuthMethod::SignInWithApple => { + mas_data_model::UpstreamOAuthProviderTokenAuthMethod::SignInWithApple + } + }; + if discovery_mode.is_disabled() { if provider.authorization_endpoint.is_none() { error!("Provider has discovery disabled but no authorization endpoint set"); @@ -240,7 +267,7 @@ pub async fn config_sync( human_name: provider.human_name, brand_name: provider.brand_name, scope: provider.scope.parse()?, - token_endpoint_auth_method: provider.token_endpoint_auth_method.into(), + token_endpoint_auth_method, token_endpoint_signing_alg: provider .token_endpoint_auth_signing_alg .clone(), diff --git a/crates/config/src/sections/mod.rs b/crates/config/src/sections/mod.rs index b21957aac..48d3234cc 100644 --- a/crates/config/src/sections/mod.rs +++ b/crates/config/src/sections/mod.rs @@ -51,7 +51,8 @@ pub use self::{ ClaimsImports as UpstreamOAuth2ClaimsImports, DiscoveryMode as UpstreamOAuth2DiscoveryMode, EmailImportPreference as UpstreamOAuth2EmailImportPreference, ImportAction as UpstreamOAuth2ImportAction, PkceMethod as UpstreamOAuth2PkceMethod, - SetEmailVerification as UpstreamOAuth2SetEmailVerification, UpstreamOAuth2Config, + SetEmailVerification as UpstreamOAuth2SetEmailVerification, + TokenAuthMethod as UpstreamOAuth2TokenAuthMethod, UpstreamOAuth2Config, }, }; use crate::util::ConfigurationSection; diff --git a/crates/config/src/sections/upstream_oauth2.rs b/crates/config/src/sections/upstream_oauth2.rs index 4742b2775..d2b5db2f8 100644 --- a/crates/config/src/sections/upstream_oauth2.rs +++ b/crates/config/src/sections/upstream_oauth2.rs @@ -6,7 +6,7 @@ use std::collections::BTreeMap; -use mas_iana::{jose::JsonWebSignatureAlg, oauth::OAuthClientAuthenticationMethod}; +use mas_iana::jose::JsonWebSignatureAlg; use schemars::JsonSchema; use serde::{de::Error, Deserialize, Serialize}; use serde_with::skip_serializing_none; @@ -48,7 +48,9 @@ impl ConfigurationSection for UpstreamOAuth2Config { }; match provider.token_endpoint_auth_method { - TokenAuthMethod::None | TokenAuthMethod::PrivateKeyJwt => { + TokenAuthMethod::None + | TokenAuthMethod::PrivateKeyJwt + | TokenAuthMethod::SignInWithApple => { if provider.client_secret.is_some() { return annotate(figment::Error::custom("Unexpected field `client_secret` for the selected authentication method")); } @@ -65,7 +67,8 @@ impl ConfigurationSection for UpstreamOAuth2Config { match provider.token_endpoint_auth_method { TokenAuthMethod::None | TokenAuthMethod::ClientSecretBasic - | TokenAuthMethod::ClientSecretPost => { + | TokenAuthMethod::ClientSecretPost + | TokenAuthMethod::SignInWithApple => { if provider.token_endpoint_auth_signing_alg.is_some() { return annotate(figment::Error::custom( "Unexpected field `token_endpoint_auth_signing_alg` for the selected authentication method", @@ -80,6 +83,22 @@ impl ConfigurationSection for UpstreamOAuth2Config { } } } + + match provider.token_endpoint_auth_method { + TokenAuthMethod::SignInWithApple => { + if provider.sign_in_with_apple.is_none() { + return annotate(figment::Error::missing_field("sign_in_with_apple")); + } + } + + _ => { + if provider.sign_in_with_apple.is_some() { + return annotate(figment::Error::custom( + "Unexpected field `sign_in_with_apple` for the selected authentication method", + )); + } + } + } } Ok(()) @@ -108,20 +127,9 @@ pub enum TokenAuthMethod { /// `private_key_jwt`: a `client_assertion` sent in the request body and /// signed by an asymmetric key PrivateKeyJwt, -} -impl From for OAuthClientAuthenticationMethod { - fn from(method: TokenAuthMethod) -> Self { - match method { - TokenAuthMethod::None => OAuthClientAuthenticationMethod::None, - TokenAuthMethod::ClientSecretBasic => { - OAuthClientAuthenticationMethod::ClientSecretBasic - } - TokenAuthMethod::ClientSecretPost => OAuthClientAuthenticationMethod::ClientSecretPost, - TokenAuthMethod::ClientSecretJwt => OAuthClientAuthenticationMethod::ClientSecretJwt, - TokenAuthMethod::PrivateKeyJwt => OAuthClientAuthenticationMethod::PrivateKeyJwt, - } - } + /// `sign_in_with_apple`: a special method for Signin with Apple + SignInWithApple, } /// How to handle a claim @@ -343,6 +351,18 @@ fn is_default_true(value: &bool) -> bool { *value } +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct SignInWithApple { + /// The private key used to sign the `id_token` + pub private_key: String, + + /// The Team ID of the Apple Developer Portal + pub team_id: String, + + /// The key ID of the Apple Developer Portal + pub key_id: String, +} + #[skip_serializing_none] #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct Provider { @@ -394,6 +414,10 @@ pub struct Provider { /// The method to authenticate the client with the provider pub token_endpoint_auth_method: TokenAuthMethod, + /// Additional parameters for the `sign_in_with_apple` method + #[serde(skip_serializing_if = "Option::is_none")] + pub sign_in_with_apple: Option, + /// The JWS algorithm to use when authenticating the client with the /// provider /// diff --git a/crates/data-model/src/lib.rs b/crates/data-model/src/lib.rs index c0b39792a..05c10b4ba 100644 --- a/crates/data-model/src/lib.rs +++ b/crates/data-model/src/lib.rs @@ -42,6 +42,7 @@ pub use self::{ UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderDiscoveryMode, UpstreamOAuthProviderImportAction, UpstreamOAuthProviderImportPreference, UpstreamOAuthProviderPkceMode, UpstreamOAuthProviderSubjectPreference, + UpstreamOAuthProviderTokenAuthMethod, }, user_agent::{DeviceType, UserAgent}, users::{ diff --git a/crates/data-model/src/upstream_oauth2/mod.rs b/crates/data-model/src/upstream_oauth2/mod.rs index cfa21ea1a..3a3ed014a 100644 --- a/crates/data-model/src/upstream_oauth2/mod.rs +++ b/crates/data-model/src/upstream_oauth2/mod.rs @@ -17,7 +17,8 @@ pub use self::{ ImportPreference as UpstreamOAuthProviderImportPreference, PkceMode as UpstreamOAuthProviderPkceMode, SetEmailVerification as UpsreamOAuthProviderSetEmailVerification, - SubjectPreference as UpstreamOAuthProviderSubjectPreference, UpstreamOAuthProvider, + SubjectPreference as UpstreamOAuthProviderSubjectPreference, + TokenAuthMethod as UpstreamOAuthProviderTokenAuthMethod, UpstreamOAuthProvider, }, session::{UpstreamOAuthAuthorizationSession, UpstreamOAuthAuthorizationSessionState}, }; diff --git a/crates/data-model/src/upstream_oauth2/provider.rs b/crates/data-model/src/upstream_oauth2/provider.rs index 0cb976a73..7dd65eea5 100644 --- a/crates/data-model/src/upstream_oauth2/provider.rs +++ b/crates/data-model/src/upstream_oauth2/provider.rs @@ -5,7 +5,7 @@ // Please see LICENSE in the repository root for full details. use chrono::{DateTime, Utc}; -use mas_iana::{jose::JsonWebSignatureAlg, oauth::OAuthClientAuthenticationMethod}; +use mas_iana::jose::JsonWebSignatureAlg; use oauth2_types::scope::Scope; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -116,6 +116,57 @@ impl std::fmt::Display for PkceMode { } } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum TokenAuthMethod { + None, + ClientSecretBasic, + ClientSecretPost, + ClientSecretJwt, + PrivateKeyJwt, + SignInWithApple, +} + +impl TokenAuthMethod { + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::None => "none", + Self::ClientSecretBasic => "client_secret_basic", + Self::ClientSecretPost => "client_secret_post", + Self::ClientSecretJwt => "client_secret_jwt", + Self::PrivateKeyJwt => "private_key_jwt", + Self::SignInWithApple => "sign_in_with_apple", + } + } +} + +impl std::fmt::Display for TokenAuthMethod { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +impl std::str::FromStr for TokenAuthMethod { + type Err = InvalidUpstreamOAuth2TokenAuthMethod; + + fn from_str(s: &str) -> Result { + match s { + "none" => Ok(Self::None), + "client_secret_post" => Ok(Self::ClientSecretPost), + "client_secret_basic" => Ok(Self::ClientSecretBasic), + "client_secret_jwt" => Ok(Self::ClientSecretJwt), + "private_key_jwt" => Ok(Self::PrivateKeyJwt), + "sign_in_with_apple" => Ok(Self::SignInWithApple), + s => Err(InvalidUpstreamOAuth2TokenAuthMethod(s.to_owned())), + } + } +} + +#[derive(Debug, Clone, Error)] +#[error("Invalid upstream OAuth 2.0 token auth method: {0}")] +pub struct InvalidUpstreamOAuth2TokenAuthMethod(String); + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct UpstreamOAuthProvider { pub id: Ulid, @@ -126,12 +177,12 @@ pub struct UpstreamOAuthProvider { pub pkce_mode: PkceMode, pub jwks_uri_override: Option, pub authorization_endpoint_override: Option, - pub token_endpoint_override: Option, pub scope: Scope, + pub token_endpoint_override: Option, pub client_id: String, pub encrypted_client_secret: Option, pub token_endpoint_signing_alg: Option, - pub token_endpoint_auth_method: OAuthClientAuthenticationMethod, + pub token_endpoint_auth_method: TokenAuthMethod, pub created_at: DateTime, pub disabled_at: Option>, pub claims_imports: ClaimsImports, diff --git a/crates/handlers/Cargo.toml b/crates/handlers/Cargo.toml index b23743d26..3eecfff1c 100644 --- a/crates/handlers/Cargo.toml +++ b/crates/handlers/Cargo.toml @@ -71,8 +71,10 @@ zeroize = "1.8.1" base64ct = "1.6.0" camino.workspace = true chrono.workspace = true +elliptic-curve.workspace = true governor.workspace = true indexmap = "2.6.0" +pkcs8.workspace = true psl = "2.1.56" time = "0.3.36" url.workspace = true diff --git a/crates/handlers/src/upstream_oauth2/cache.rs b/crates/handlers/src/upstream_oauth2/cache.rs index c2f307e14..4d7d0ac72 100644 --- a/crates/handlers/src/upstream_oauth2/cache.rs +++ b/crates/handlers/src/upstream_oauth2/cache.rs @@ -274,8 +274,9 @@ mod tests { // XXX: sadly, we can't test HTTPS requests with wiremock, so we can only test // 'insecure' discovery - use mas_data_model::UpstreamOAuthProviderClaimsImports; - use mas_iana::oauth::OAuthClientAuthenticationMethod; + use mas_data_model::{ + UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderTokenAuthMethod, + }; use mas_storage::{clock::MockClock, Clock}; use oauth2_types::scope::{Scope, OPENID}; use ulid::Ulid; @@ -393,7 +394,7 @@ mod tests { client_id: "client_id".to_owned(), encrypted_client_secret: None, token_endpoint_signing_alg: None, - token_endpoint_auth_method: OAuthClientAuthenticationMethod::None, + token_endpoint_auth_method: UpstreamOAuthProviderTokenAuthMethod::None, created_at: clock.now(), disabled_at: None, claims_imports: UpstreamOAuthProviderClaimsImports::default(), diff --git a/crates/handlers/src/upstream_oauth2/link.rs b/crates/handlers/src/upstream_oauth2/link.rs index 3f7407e72..6d666d9c2 100644 --- a/crates/handlers/src/upstream_oauth2/link.rs +++ b/crates/handlers/src/upstream_oauth2/link.rs @@ -843,8 +843,9 @@ mod tests { use hyper::{header::CONTENT_TYPE, Request, StatusCode}; use mas_data_model::{ UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderImportPreference, + UpstreamOAuthProviderTokenAuthMethod, }; - use mas_iana::{jose::JsonWebSignatureAlg, oauth::OAuthClientAuthenticationMethod}; + use mas_iana::jose::JsonWebSignatureAlg; use mas_jose::jwt::{JsonWebSignatureHeader, Jwt}; use mas_router::Route; use mas_storage::upstream_oauth2::UpstreamOAuthProviderParams; @@ -906,7 +907,7 @@ mod tests { human_name: Some("Example Ltd.".to_owned()), brand_name: None, scope: Scope::from_iter([OPENID]), - token_endpoint_auth_method: OAuthClientAuthenticationMethod::None, + token_endpoint_auth_method: UpstreamOAuthProviderTokenAuthMethod::None, token_endpoint_signing_alg: None, client_id: "client".to_owned(), encrypted_client_secret: None, diff --git a/crates/handlers/src/upstream_oauth2/mod.rs b/crates/handlers/src/upstream_oauth2/mod.rs index 19ca0dc48..758202dfc 100644 --- a/crates/handlers/src/upstream_oauth2/mod.rs +++ b/crates/handlers/src/upstream_oauth2/mod.rs @@ -6,10 +6,12 @@ use std::string::FromUtf8Error; -use mas_data_model::UpstreamOAuthProvider; -use mas_iana::{jose::JsonWebSignatureAlg, oauth::OAuthClientAuthenticationMethod}; +use mas_data_model::{UpstreamOAuthProvider, UpstreamOAuthProviderTokenAuthMethod}; +use mas_iana::jose::JsonWebSignatureAlg; use mas_keystore::{DecryptError, Encrypter, Keystore}; use mas_oidc_client::types::client_credentials::ClientCredentials; +use pkcs8::DecodePrivateKey; +use serde::Deserialize; use thiserror::Error; use url::Url; @@ -39,6 +41,25 @@ enum ProviderCredentialsError { #[from] inner: FromUtf8Error, }, + + #[error("Invalid JSON in client secret")] + InvalidClientSecretJson { + #[from] + inner: serde_json::Error, + }, + + #[error("Could not parse PEM encoded private key")] + InvalidPrivateKey { + #[from] + inner: pkcs8::Error, + }, +} + +#[derive(Debug, Deserialize)] +pub struct SignInWithApple { + pub private_key: String, + pub team_id: String, + pub key_id: String, } fn client_credentials_for_provider( @@ -61,28 +82,38 @@ fn client_credentials_for_provider( .transpose()?; let client_credentials = match provider.token_endpoint_auth_method { - OAuthClientAuthenticationMethod::None => ClientCredentials::None { client_id }, - OAuthClientAuthenticationMethod::ClientSecretPost => ClientCredentials::ClientSecretPost { - client_id, - client_secret: client_secret.ok_or(ProviderCredentialsError::MissingClientSecret)?, - }, - OAuthClientAuthenticationMethod::ClientSecretBasic => { + UpstreamOAuthProviderTokenAuthMethod::None => ClientCredentials::None { client_id }, + + UpstreamOAuthProviderTokenAuthMethod::ClientSecretPost => { + ClientCredentials::ClientSecretPost { + client_id, + client_secret: client_secret + .ok_or(ProviderCredentialsError::MissingClientSecret)?, + } + } + + UpstreamOAuthProviderTokenAuthMethod::ClientSecretBasic => { ClientCredentials::ClientSecretBasic { client_id, client_secret: client_secret .ok_or(ProviderCredentialsError::MissingClientSecret)?, } } - OAuthClientAuthenticationMethod::ClientSecretJwt => ClientCredentials::ClientSecretJwt { - client_id, - client_secret: client_secret.ok_or(ProviderCredentialsError::MissingClientSecret)?, - signing_algorithm: provider - .token_endpoint_signing_alg - .clone() - .unwrap_or(JsonWebSignatureAlg::Rs256), - token_endpoint: token_endpoint.clone(), - }, - OAuthClientAuthenticationMethod::PrivateKeyJwt => ClientCredentials::PrivateKeyJwt { + + UpstreamOAuthProviderTokenAuthMethod::ClientSecretJwt => { + ClientCredentials::ClientSecretJwt { + client_id, + client_secret: client_secret + .ok_or(ProviderCredentialsError::MissingClientSecret)?, + signing_algorithm: provider + .token_endpoint_signing_alg + .clone() + .unwrap_or(JsonWebSignatureAlg::Rs256), + token_endpoint: token_endpoint.clone(), + } + } + + UpstreamOAuthProviderTokenAuthMethod::PrivateKeyJwt => ClientCredentials::PrivateKeyJwt { client_id, keystore: keystore.clone(), signing_algorithm: provider @@ -91,8 +122,21 @@ fn client_credentials_for_provider( .unwrap_or(JsonWebSignatureAlg::Rs256), token_endpoint: token_endpoint.clone(), }, - // XXX: The database should never have an unsupported method in it - _ => unreachable!(), + + UpstreamOAuthProviderTokenAuthMethod::SignInWithApple => { + let params = client_secret.ok_or(ProviderCredentialsError::MissingClientSecret)?; + let params: SignInWithApple = serde_json::from_str(¶ms)?; + + let key = elliptic_curve::SecretKey::from_pkcs8_pem(¶ms.private_key)?; + + ClientCredentials::SignInWithApple { + client_id, + audience: provider.issuer.clone(), + key, + key_id: params.key_id, + team_id: params.team_id, + } + } }; Ok(client_credentials) diff --git a/crates/handlers/src/views/login.rs b/crates/handlers/src/views/login.rs index d2fba535d..4179bb06c 100644 --- a/crates/handlers/src/views/login.rs +++ b/crates/handlers/src/views/login.rs @@ -343,8 +343,9 @@ mod test { header::{CONTENT_TYPE, LOCATION}, Request, StatusCode, }; - use mas_data_model::UpstreamOAuthProviderClaimsImports; - use mas_iana::oauth::OAuthClientAuthenticationMethod; + use mas_data_model::{ + UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderTokenAuthMethod, + }; use mas_router::Route; use mas_storage::{ upstream_oauth2::{UpstreamOAuthProviderParams, UpstreamOAuthProviderRepository}, @@ -400,7 +401,7 @@ mod test { human_name: Some("First Ltd.".to_owned()), brand_name: None, scope: [OPENID].into_iter().collect(), - token_endpoint_auth_method: OAuthClientAuthenticationMethod::None, + token_endpoint_auth_method: UpstreamOAuthProviderTokenAuthMethod::None, token_endpoint_signing_alg: None, client_id: "client".to_owned(), encrypted_client_secret: None, @@ -435,7 +436,7 @@ mod test { human_name: None, brand_name: None, scope: [OPENID].into_iter().collect(), - token_endpoint_auth_method: OAuthClientAuthenticationMethod::None, + token_endpoint_auth_method: UpstreamOAuthProviderTokenAuthMethod::None, token_endpoint_signing_alg: None, client_id: "client".to_owned(), encrypted_client_secret: None, diff --git a/crates/jose/Cargo.toml b/crates/jose/Cargo.toml index 4a9bf6384..0127e0e20 100644 --- a/crates/jose/Cargo.toml +++ b/crates/jose/Cargo.toml @@ -16,7 +16,7 @@ base64ct = { version = "1.6.0", features = ["std"] } chrono.workspace = true digest = "0.10.7" ecdsa = { version = "0.16.9", features = ["signing", "verifying"] } -elliptic-curve = "0.13.8" +elliptic-curve.workspace = true generic-array = "0.14.7" hmac = "0.12.1" k256 = { version = "0.13.4", features = ["ecdsa"] } diff --git a/crates/keystore/Cargo.toml b/crates/keystore/Cargo.toml index a64bb6853..3d2a914d3 100644 --- a/crates/keystore/Cargo.toml +++ b/crates/keystore/Cargo.toml @@ -16,13 +16,13 @@ aead = { version = "0.5.2", features = ["std"] } const-oid = { version = "0.9.6", features = ["std"] } der = { version = "0.7.9", features = ["std"] } ecdsa = { version = "0.16.9", features = ["std"] } -elliptic-curve = { version = "0.13.8", features = ["std", "pem", "sec1"] } -k256 = { version = "0.13.4", features = ["std"] } -p256 = { version = "0.13.2", features = ["std"] } -p384 = { version = "0.13.0", features = ["std"] } -pem-rfc7468 = { version = "0.7.0", features = ["std"] } -pkcs1 = { version = "0.7.5", features = ["std"] } -pkcs8 = { version = "0.10.2", features = ["std", "pkcs5", "encryption"] } +elliptic-curve.workspace = true +k256.workspace = true +p256.workspace = true +p384.workspace = true +pem-rfc7468.workspace = true +pkcs1.workspace = true +pkcs8.workspace = true rand.workspace = true rsa = { version = "0.9.6", features = ["std", "pem"] } sec1 = { version = "0.7.3", features = ["std"] } diff --git a/crates/oidc-client/Cargo.toml b/crates/oidc-client/Cargo.toml index 224c1b9eb..fd656525c 100644 --- a/crates/oidc-client/Cargo.toml +++ b/crates/oidc-client/Cargo.toml @@ -15,11 +15,15 @@ workspace = true async-trait.workspace = true base64ct = { version = "1.6.0", features = ["std"] } chrono.workspace = true +elliptic-curve.workspace = true form_urlencoded = "1.2.1" headers.workspace = true http.workspace = true language-tags = "0.3.2" mime = "0.3.17" +pem-rfc7468.workspace = true +pkcs8.workspace = true +p256.workspace = true rand.workspace = true reqwest.workspace = true serde.workspace = true diff --git a/crates/oidc-client/src/types/client_credentials.rs b/crates/oidc-client/src/types/client_credentials.rs index b7095b576..b0378d6cc 100644 --- a/crates/oidc-client/src/types/client_credentials.rs +++ b/crates/oidc-client/src/types/client_credentials.rs @@ -14,7 +14,7 @@ use mas_iana::{jose::JsonWebSignatureAlg, oauth::OAuthClientAuthenticationMethod use mas_jose::{ claims::{self, ClaimError}, constraints::Constrainable, - jwa::SymmetricKey, + jwa::{AsymmetricSigningKey, SymmetricKey}, jwt::{JsonWebSignatureHeader, Jwt}, }; use mas_keystore::Keystore; @@ -97,6 +97,24 @@ pub enum ClientCredentials { /// The URL of the issuer's Token endpoint. token_endpoint: Url, }, + + /// The client authenticates like Sign in with Apple wants + SignInWithApple { + /// The unique ID for the client. + client_id: String, + + /// The audience to use. Usually `https://appleid.apple.com` + audience: String, + + /// The ECDSA key used to sign + key: elliptic_curve::SecretKey, + + /// The key ID + key_id: String, + + /// The Apple Team ID + team_id: String, + }, } impl ClientCredentials { @@ -108,12 +126,14 @@ impl ClientCredentials { | ClientCredentials::ClientSecretBasic { client_id, .. } | ClientCredentials::ClientSecretPost { client_id, .. } | ClientCredentials::ClientSecretJwt { client_id, .. } - | ClientCredentials::PrivateKeyJwt { client_id, .. } => client_id, + | ClientCredentials::PrivateKeyJwt { client_id, .. } + | ClientCredentials::SignInWithApple { client_id, .. } => client_id, } } /// Apply these [`ClientCredentials`] to the given request with the given /// form. + #[allow(clippy::too_many_lines)] pub(crate) fn authenticated_form( &self, request: reqwest::RequestBuilder, @@ -217,6 +237,39 @@ impl ClientCredentials { client_assertion_type: Some(JwtBearerClientAssertionType), }) } + + ClientCredentials::SignInWithApple { + client_id, + audience, + key, + key_id, + team_id, + } => { + // SIWA expects a signed JWT as client secret + // https://developer.apple.com/documentation/accountorganizationaldatasharing/creating-a-client-secret + let signer = AsymmetricSigningKey::es256(key.clone()); + + let mut claims = HashMap::new(); + + claims::ISS.insert(&mut claims, team_id)?; + claims::SUB.insert(&mut claims, client_id)?; + claims::AUD.insert(&mut claims, audience.clone())?; + claims::IAT.insert(&mut claims, now)?; + claims::EXP.insert(&mut claims, now + Duration::microseconds(60 * 1000 * 1000))?; + + let header = + JsonWebSignatureHeader::new(JsonWebSignatureAlg::Es256).with_kid(key_id); + + let client_secret = Jwt::sign(header, claims, &signer)?; + + request.form(&RequestWithClientCredentials { + body: form, + client_id, + client_secret: Some(client_secret.as_str()), + client_assertion: None, + client_assertion_type: None, + }) + } }; Ok(request) @@ -260,6 +313,17 @@ impl fmt::Debug for ClientCredentials { .field("signing_algorithm", signing_algorithm) .field("token_endpoint", token_endpoint) .finish_non_exhaustive(), + Self::SignInWithApple { + client_id, + key_id, + team_id, + .. + } => f + .debug_struct("SignInWithApple") + .field("client_id", client_id) + .field("key_id", key_id) + .field("team_id", team_id) + .finish_non_exhaustive(), } } } diff --git a/crates/storage-pg/src/upstream_oauth2/mod.rs b/crates/storage-pg/src/upstream_oauth2/mod.rs index c9ca8afdc..97f3e3e5e 100644 --- a/crates/storage-pg/src/upstream_oauth2/mod.rs +++ b/crates/storage-pg/src/upstream_oauth2/mod.rs @@ -19,7 +19,9 @@ pub use self::{ #[cfg(test)] mod tests { use chrono::Duration; - use mas_data_model::UpstreamOAuthProviderClaimsImports; + use mas_data_model::{ + UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderTokenAuthMethod, + }; use mas_storage::{ clock::MockClock, upstream_oauth2::{ @@ -57,8 +59,7 @@ mod tests { human_name: None, brand_name: None, scope: Scope::from_iter([OPENID]), - token_endpoint_auth_method: - mas_iana::oauth::OAuthClientAuthenticationMethod::None, + token_endpoint_auth_method: UpstreamOAuthProviderTokenAuthMethod::None, token_endpoint_signing_alg: None, client_id: "client-id".to_owned(), encrypted_client_secret: None, @@ -299,8 +300,7 @@ mod tests { human_name: None, brand_name: None, scope: scope.clone(), - token_endpoint_auth_method: - mas_iana::oauth::OAuthClientAuthenticationMethod::None, + token_endpoint_auth_method: UpstreamOAuthProviderTokenAuthMethod::None, token_endpoint_signing_alg: None, client_id, encrypted_client_secret: None, diff --git a/crates/storage/src/upstream_oauth2/provider.rs b/crates/storage/src/upstream_oauth2/provider.rs index 7d7d4dbb0..315293f6a 100644 --- a/crates/storage/src/upstream_oauth2/provider.rs +++ b/crates/storage/src/upstream_oauth2/provider.rs @@ -9,9 +9,9 @@ use std::marker::PhantomData; use async_trait::async_trait; use mas_data_model::{ UpstreamOAuthProvider, UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderDiscoveryMode, - UpstreamOAuthProviderPkceMode, + UpstreamOAuthProviderPkceMode, UpstreamOAuthProviderTokenAuthMethod, }; -use mas_iana::{jose::JsonWebSignatureAlg, oauth::OAuthClientAuthenticationMethod}; +use mas_iana::jose::JsonWebSignatureAlg; use oauth2_types::scope::Scope; use rand_core::RngCore; use ulid::Ulid; @@ -35,7 +35,7 @@ pub struct UpstreamOAuthProviderParams { pub scope: Scope, /// The token endpoint authentication method - pub token_endpoint_auth_method: OAuthClientAuthenticationMethod, + pub token_endpoint_auth_method: UpstreamOAuthProviderTokenAuthMethod, /// The JWT signing algorithm to use when then `client_secret_jwt` or /// `private_key_jwt` authentication methods are used diff --git a/docs/config.schema.json b/docs/config.schema.json index 701f44013..c3a2654f7 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -1859,6 +1859,14 @@ } ] }, + "sign_in_with_apple": { + "description": "Additional parameters for the `sign_in_with_apple` method", + "allOf": [ + { + "$ref": "#/definitions/SignInWithApple" + } + ] + }, "token_endpoint_auth_signing_alg": { "description": "The JWS algorithm to use when authenticating the client with the provider\n\nUsed by the `client_secret_jwt` and `private_key_jwt` methods", "allOf": [ @@ -1956,9 +1964,38 @@ "enum": [ "private_key_jwt" ] + }, + { + "description": "`sign_in_with_apple`: a special method for Signin with Apple", + "type": "string", + "enum": [ + "sign_in_with_apple" + ] } ] }, + "SignInWithApple": { + "type": "object", + "required": [ + "key_id", + "private_key", + "team_id" + ], + "properties": { + "private_key": { + "description": "The private key used to sign the `id_token`", + "type": "string" + }, + "team_id": { + "description": "The Team ID of the Apple Developer Portal", + "type": "string" + }, + "key_id": { + "description": "The key ID of the Apple Developer Portal", + "type": "string" + } + } + }, "DiscoveryMode": { "description": "How to discover the provider's configuration", "oneOf": [