Support Sign in with Apple
This commit is contained in:
6
Cargo.lock
generated
6
Cargo.lock
generated
@@ -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",
|
||||
|
||||
35
Cargo.toml
35
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"
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<TokenAuthMethod> 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<SignInWithApple>,
|
||||
|
||||
/// The JWS algorithm to use when authenticating the client with the
|
||||
/// provider
|
||||
///
|
||||
|
||||
@@ -42,6 +42,7 @@ pub use self::{
|
||||
UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderDiscoveryMode,
|
||||
UpstreamOAuthProviderImportAction, UpstreamOAuthProviderImportPreference,
|
||||
UpstreamOAuthProviderPkceMode, UpstreamOAuthProviderSubjectPreference,
|
||||
UpstreamOAuthProviderTokenAuthMethod,
|
||||
},
|
||||
user_agent::{DeviceType, UserAgent},
|
||||
users::{
|
||||
|
||||
@@ -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},
|
||||
};
|
||||
|
||||
@@ -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<Self, Self::Err> {
|
||||
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<Url>,
|
||||
pub authorization_endpoint_override: Option<Url>,
|
||||
pub token_endpoint_override: Option<Url>,
|
||||
pub scope: Scope,
|
||||
pub token_endpoint_override: Option<Url>,
|
||||
pub client_id: String,
|
||||
pub encrypted_client_secret: Option<String>,
|
||||
pub token_endpoint_signing_alg: Option<JsonWebSignatureAlg>,
|
||||
pub token_endpoint_auth_method: OAuthClientAuthenticationMethod,
|
||||
pub token_endpoint_auth_method: TokenAuthMethod,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub disabled_at: Option<DateTime<Utc>>,
|
||||
pub claims_imports: ClaimsImports,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<p256::NistP256>,
|
||||
|
||||
/// 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<T: Serialize>(
|
||||
&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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": [
|
||||
|
||||
Reference in New Issue
Block a user