Skip to content

Commit c7d16c6

Browse files
committed
Support Sign in with Apple
1 parent 805a8cc commit c7d16c6

File tree

20 files changed

+374
-74
lines changed

20 files changed

+374
-74
lines changed

Cargo.lock

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,11 @@ features = ["serde", "clock"]
102102
version = "4.5.21"
103103
features = ["derive"]
104104

105+
# Elliptic curve cryptography
106+
[workspace.dependencies.elliptic-curve]
107+
version = "0.13.8"
108+
features = ["std", "pem", "sec1"]
109+
105110
# Configuration loading
106111
[workspace.dependencies.figment]
107112
version = "0.10.19"
@@ -188,6 +193,36 @@ features = ["pycompat"]
188193
[workspace.dependencies.nonzero_ext]
189194
version = "0.3.0"
190195

196+
# K256 elliptic curve
197+
[workspace.dependencies.k256]
198+
version = "0.13.4"
199+
features = ["std"]
200+
201+
# P256 elliptic curve
202+
[workspace.dependencies.p256]
203+
version = "0.13.2"
204+
features = ["std"]
205+
206+
# P384 elliptic curve
207+
[workspace.dependencies.p384]
208+
version = "0.13.0"
209+
features = ["std"]
210+
211+
# PEM file decoding
212+
[workspace.dependencies.pem-rfc7468]
213+
version = "0.7.0"
214+
features = ["std"]
215+
216+
# PKCS#1 encoding
217+
[workspace.dependencies.pkcs1]
218+
version = "0.7.5"
219+
features = ["std"]
220+
221+
# PKCS#8 encoding
222+
[workspace.dependencies.pkcs8]
223+
version = "0.10.2"
224+
features = ["std", "pkcs5", "encryption"]
225+
191226
# Random values
192227
[workspace.dependencies.rand]
193228
version = "0.8.5"

crates/cli/src/sync.rs

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -187,11 +187,17 @@ pub async fn config_sync(
187187
continue;
188188
}
189189

190-
let encrypted_client_secret = provider
191-
.client_secret
192-
.as_deref()
193-
.map(|client_secret| encrypter.encrypt_to_string(client_secret.as_bytes()))
194-
.transpose()?;
190+
let encrypted_client_secret =
191+
if let Some(client_secret) = provider.client_secret.as_deref() {
192+
Some(encrypter.encrypt_to_string(client_secret.as_bytes())?)
193+
} else if let Some(siwa) = provider.sign_in_with_apple.as_ref() {
194+
// For SIWA, we JSON-encode the config and encrypt it, reusing the client_secret
195+
// field in the database
196+
let encoded = serde_json::to_vec(siwa)?;
197+
Some(encrypter.encrypt_to_string(&encoded)?)
198+
} else {
199+
None
200+
};
195201

196202
let discovery_mode = match provider.discovery_mode {
197203
mas_config::UpstreamOAuth2DiscoveryMode::Oidc => {
@@ -205,6 +211,27 @@ pub async fn config_sync(
205211
}
206212
};
207213

214+
let token_endpoint_auth_method = match provider.token_endpoint_auth_method {
215+
mas_config::UpstreamOAuth2TokenAuthMethod::None => {
216+
mas_data_model::UpstreamOAuthProviderTokenAuthMethod::None
217+
}
218+
mas_config::UpstreamOAuth2TokenAuthMethod::ClientSecretBasic => {
219+
mas_data_model::UpstreamOAuthProviderTokenAuthMethod::ClientSecretBasic
220+
}
221+
mas_config::UpstreamOAuth2TokenAuthMethod::ClientSecretPost => {
222+
mas_data_model::UpstreamOAuthProviderTokenAuthMethod::ClientSecretPost
223+
}
224+
mas_config::UpstreamOAuth2TokenAuthMethod::ClientSecretJwt => {
225+
mas_data_model::UpstreamOAuthProviderTokenAuthMethod::ClientSecretJwt
226+
}
227+
mas_config::UpstreamOAuth2TokenAuthMethod::PrivateKeyJwt => {
228+
mas_data_model::UpstreamOAuthProviderTokenAuthMethod::PrivateKeyJwt
229+
}
230+
mas_config::UpstreamOAuth2TokenAuthMethod::SignInWithApple => {
231+
mas_data_model::UpstreamOAuthProviderTokenAuthMethod::SignInWithApple
232+
}
233+
};
234+
208235
if discovery_mode.is_disabled() {
209236
if provider.authorization_endpoint.is_none() {
210237
error!("Provider has discovery disabled but no authorization endpoint set");
@@ -240,7 +267,7 @@ pub async fn config_sync(
240267
human_name: provider.human_name,
241268
brand_name: provider.brand_name,
242269
scope: provider.scope.parse()?,
243-
token_endpoint_auth_method: provider.token_endpoint_auth_method.into(),
270+
token_endpoint_auth_method,
244271
token_endpoint_signing_alg: provider
245272
.token_endpoint_auth_signing_alg
246273
.clone(),

crates/config/src/sections/mod.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@ pub use self::{
5151
ClaimsImports as UpstreamOAuth2ClaimsImports, DiscoveryMode as UpstreamOAuth2DiscoveryMode,
5252
EmailImportPreference as UpstreamOAuth2EmailImportPreference,
5353
ImportAction as UpstreamOAuth2ImportAction, PkceMethod as UpstreamOAuth2PkceMethod,
54-
SetEmailVerification as UpstreamOAuth2SetEmailVerification, UpstreamOAuth2Config,
54+
SetEmailVerification as UpstreamOAuth2SetEmailVerification,
55+
TokenAuthMethod as UpstreamOAuth2TokenAuthMethod, UpstreamOAuth2Config,
5556
},
5657
};
5758
use crate::util::ConfigurationSection;

crates/config/src/sections/upstream_oauth2.rs

Lines changed: 40 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
use std::collections::BTreeMap;
88

9-
use mas_iana::{jose::JsonWebSignatureAlg, oauth::OAuthClientAuthenticationMethod};
9+
use mas_iana::jose::JsonWebSignatureAlg;
1010
use schemars::JsonSchema;
1111
use serde::{de::Error, Deserialize, Serialize};
1212
use serde_with::skip_serializing_none;
@@ -48,7 +48,9 @@ impl ConfigurationSection for UpstreamOAuth2Config {
4848
};
4949

5050
match provider.token_endpoint_auth_method {
51-
TokenAuthMethod::None | TokenAuthMethod::PrivateKeyJwt => {
51+
TokenAuthMethod::None
52+
| TokenAuthMethod::PrivateKeyJwt
53+
| TokenAuthMethod::SignInWithApple => {
5254
if provider.client_secret.is_some() {
5355
return annotate(figment::Error::custom("Unexpected field `client_secret` for the selected authentication method"));
5456
}
@@ -65,7 +67,8 @@ impl ConfigurationSection for UpstreamOAuth2Config {
6567
match provider.token_endpoint_auth_method {
6668
TokenAuthMethod::None
6769
| TokenAuthMethod::ClientSecretBasic
68-
| TokenAuthMethod::ClientSecretPost => {
70+
| TokenAuthMethod::ClientSecretPost
71+
| TokenAuthMethod::SignInWithApple => {
6972
if provider.token_endpoint_auth_signing_alg.is_some() {
7073
return annotate(figment::Error::custom(
7174
"Unexpected field `token_endpoint_auth_signing_alg` for the selected authentication method",
@@ -80,6 +83,22 @@ impl ConfigurationSection for UpstreamOAuth2Config {
8083
}
8184
}
8285
}
86+
87+
match provider.token_endpoint_auth_method {
88+
TokenAuthMethod::SignInWithApple => {
89+
if provider.sign_in_with_apple.is_none() {
90+
return annotate(figment::Error::missing_field("sign_in_with_apple"));
91+
}
92+
}
93+
94+
_ => {
95+
if provider.sign_in_with_apple.is_some() {
96+
return annotate(figment::Error::custom(
97+
"Unexpected field `sign_in_with_apple` for the selected authentication method",
98+
));
99+
}
100+
}
101+
}
83102
}
84103

85104
Ok(())
@@ -108,20 +127,9 @@ pub enum TokenAuthMethod {
108127
/// `private_key_jwt`: a `client_assertion` sent in the request body and
109128
/// signed by an asymmetric key
110129
PrivateKeyJwt,
111-
}
112130

113-
impl From<TokenAuthMethod> for OAuthClientAuthenticationMethod {
114-
fn from(method: TokenAuthMethod) -> Self {
115-
match method {
116-
TokenAuthMethod::None => OAuthClientAuthenticationMethod::None,
117-
TokenAuthMethod::ClientSecretBasic => {
118-
OAuthClientAuthenticationMethod::ClientSecretBasic
119-
}
120-
TokenAuthMethod::ClientSecretPost => OAuthClientAuthenticationMethod::ClientSecretPost,
121-
TokenAuthMethod::ClientSecretJwt => OAuthClientAuthenticationMethod::ClientSecretJwt,
122-
TokenAuthMethod::PrivateKeyJwt => OAuthClientAuthenticationMethod::PrivateKeyJwt,
123-
}
124-
}
131+
/// `sign_in_with_apple`: a special method for Signin with Apple
132+
SignInWithApple,
125133
}
126134

127135
/// How to handle a claim
@@ -343,6 +351,18 @@ fn is_default_true(value: &bool) -> bool {
343351
*value
344352
}
345353

354+
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
355+
pub struct SignInWithApple {
356+
/// The private key used to sign the `id_token`
357+
pub private_key: String,
358+
359+
/// The Team ID of the Apple Developer Portal
360+
pub team_id: String,
361+
362+
/// The key ID of the Apple Developer Portal
363+
pub key_id: String,
364+
}
365+
346366
#[skip_serializing_none]
347367
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
348368
pub struct Provider {
@@ -394,6 +414,10 @@ pub struct Provider {
394414
/// The method to authenticate the client with the provider
395415
pub token_endpoint_auth_method: TokenAuthMethod,
396416

417+
/// Additional parameters for the `sign_in_with_apple` method
418+
#[serde(skip_serializing_if = "Option::is_none")]
419+
pub sign_in_with_apple: Option<SignInWithApple>,
420+
397421
/// The JWS algorithm to use when authenticating the client with the
398422
/// provider
399423
///

crates/data-model/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ pub use self::{
4242
UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderDiscoveryMode,
4343
UpstreamOAuthProviderImportAction, UpstreamOAuthProviderImportPreference,
4444
UpstreamOAuthProviderPkceMode, UpstreamOAuthProviderSubjectPreference,
45+
UpstreamOAuthProviderTokenAuthMethod,
4546
},
4647
user_agent::{DeviceType, UserAgent},
4748
users::{

crates/data-model/src/upstream_oauth2/mod.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ pub use self::{
1717
ImportPreference as UpstreamOAuthProviderImportPreference,
1818
PkceMode as UpstreamOAuthProviderPkceMode,
1919
SetEmailVerification as UpsreamOAuthProviderSetEmailVerification,
20-
SubjectPreference as UpstreamOAuthProviderSubjectPreference, UpstreamOAuthProvider,
20+
SubjectPreference as UpstreamOAuthProviderSubjectPreference,
21+
TokenAuthMethod as UpstreamOAuthProviderTokenAuthMethod, UpstreamOAuthProvider,
2122
},
2223
session::{UpstreamOAuthAuthorizationSession, UpstreamOAuthAuthorizationSessionState},
2324
};

crates/data-model/src/upstream_oauth2/provider.rs

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
// Please see LICENSE in the repository root for full details.
66

77
use chrono::{DateTime, Utc};
8-
use mas_iana::{jose::JsonWebSignatureAlg, oauth::OAuthClientAuthenticationMethod};
8+
use mas_iana::jose::JsonWebSignatureAlg;
99
use oauth2_types::scope::Scope;
1010
use serde::{Deserialize, Serialize};
1111
use thiserror::Error;
@@ -116,6 +116,57 @@ impl std::fmt::Display for PkceMode {
116116
}
117117
}
118118

119+
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
120+
#[serde(rename_all = "snake_case")]
121+
pub enum TokenAuthMethod {
122+
None,
123+
ClientSecretBasic,
124+
ClientSecretPost,
125+
ClientSecretJwt,
126+
PrivateKeyJwt,
127+
SignInWithApple,
128+
}
129+
130+
impl TokenAuthMethod {
131+
#[must_use]
132+
pub fn as_str(self) -> &'static str {
133+
match self {
134+
Self::None => "none",
135+
Self::ClientSecretBasic => "client_secret_basic",
136+
Self::ClientSecretPost => "client_secret_post",
137+
Self::ClientSecretJwt => "client_secret_jwt",
138+
Self::PrivateKeyJwt => "private_key_jwt",
139+
Self::SignInWithApple => "sign_in_with_apple",
140+
}
141+
}
142+
}
143+
144+
impl std::fmt::Display for TokenAuthMethod {
145+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146+
f.write_str(self.as_str())
147+
}
148+
}
149+
150+
impl std::str::FromStr for TokenAuthMethod {
151+
type Err = InvalidUpstreamOAuth2TokenAuthMethod;
152+
153+
fn from_str(s: &str) -> Result<Self, Self::Err> {
154+
match s {
155+
"none" => Ok(Self::None),
156+
"client_secret_post" => Ok(Self::ClientSecretPost),
157+
"client_secret_basic" => Ok(Self::ClientSecretBasic),
158+
"client_secret_jwt" => Ok(Self::ClientSecretJwt),
159+
"private_key_jwt" => Ok(Self::PrivateKeyJwt),
160+
"sign_in_with_apple" => Ok(Self::SignInWithApple),
161+
s => Err(InvalidUpstreamOAuth2TokenAuthMethod(s.to_owned())),
162+
}
163+
}
164+
}
165+
166+
#[derive(Debug, Clone, Error)]
167+
#[error("Invalid upstream OAuth 2.0 token auth method: {0}")]
168+
pub struct InvalidUpstreamOAuth2TokenAuthMethod(String);
169+
119170
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
120171
pub struct UpstreamOAuthProvider {
121172
pub id: Ulid,
@@ -126,12 +177,12 @@ pub struct UpstreamOAuthProvider {
126177
pub pkce_mode: PkceMode,
127178
pub jwks_uri_override: Option<Url>,
128179
pub authorization_endpoint_override: Option<Url>,
129-
pub token_endpoint_override: Option<Url>,
130180
pub scope: Scope,
181+
pub token_endpoint_override: Option<Url>,
131182
pub client_id: String,
132183
pub encrypted_client_secret: Option<String>,
133184
pub token_endpoint_signing_alg: Option<JsonWebSignatureAlg>,
134-
pub token_endpoint_auth_method: OAuthClientAuthenticationMethod,
185+
pub token_endpoint_auth_method: TokenAuthMethod,
135186
pub created_at: DateTime<Utc>,
136187
pub disabled_at: Option<DateTime<Utc>>,
137188
pub claims_imports: ClaimsImports,

crates/handlers/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,10 @@ zeroize = "1.8.1"
7171
base64ct = "1.6.0"
7272
camino.workspace = true
7373
chrono.workspace = true
74+
elliptic-curve.workspace = true
7475
governor.workspace = true
7576
indexmap = "2.6.0"
77+
pkcs8.workspace = true
7678
psl = "2.1.56"
7779
time = "0.3.36"
7880
url.workspace = true

crates/handlers/src/upstream_oauth2/cache.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -274,8 +274,9 @@ mod tests {
274274
// XXX: sadly, we can't test HTTPS requests with wiremock, so we can only test
275275
// 'insecure' discovery
276276

277-
use mas_data_model::UpstreamOAuthProviderClaimsImports;
278-
use mas_iana::oauth::OAuthClientAuthenticationMethod;
277+
use mas_data_model::{
278+
UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderTokenAuthMethod,
279+
};
279280
use mas_storage::{clock::MockClock, Clock};
280281
use oauth2_types::scope::{Scope, OPENID};
281282
use ulid::Ulid;
@@ -393,7 +394,7 @@ mod tests {
393394
client_id: "client_id".to_owned(),
394395
encrypted_client_secret: None,
395396
token_endpoint_signing_alg: None,
396-
token_endpoint_auth_method: OAuthClientAuthenticationMethod::None,
397+
token_endpoint_auth_method: UpstreamOAuthProviderTokenAuthMethod::None,
397398
created_at: clock.now(),
398399
disabled_at: None,
399400
claims_imports: UpstreamOAuthProviderClaimsImports::default(),

0 commit comments

Comments
 (0)