Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

35 changes: 35 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
39 changes: 33 additions & 6 deletions crates/cli/src/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand All @@ -205,6 +211,27 @@ pub async fn config_sync(
}
};

let token_endpoint_auth_method = match provider.token_endpoint_auth_method {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe this would be better suited as an .into() implementation if that's not impossible due to the crates being different

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The crates where both structures are defined don't depend on each other, which is why we can't implement it

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");
Expand Down Expand Up @@ -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(),
Expand Down
3 changes: 2 additions & 1 deletion crates/config/src/sections/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
56 changes: 40 additions & 16 deletions crates/config/src/sections/upstream_oauth2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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"));
}
Expand All @@ -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",
Expand All @@ -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(())
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
///
Expand Down
1 change: 1 addition & 0 deletions crates/data-model/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ pub use self::{
UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderDiscoveryMode,
UpstreamOAuthProviderImportAction, UpstreamOAuthProviderImportPreference,
UpstreamOAuthProviderPkceMode, UpstreamOAuthProviderSubjectPreference,
UpstreamOAuthProviderTokenAuthMethod,
},
user_agent::{DeviceType, UserAgent},
users::{
Expand Down
3 changes: 2 additions & 1 deletion crates/data-model/src/upstream_oauth2/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
};
57 changes: 54 additions & 3 deletions crates/data-model/src/upstream_oauth2/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions crates/handlers/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions crates/handlers/src/upstream_oauth2/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(),
Expand Down
Loading