Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
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
11 changes: 11 additions & 0 deletions crates/config/src/sections/upstream_oauth2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,14 @@ pub struct SignInWithApple {
pub key_id: String,
}

fn default_scope() -> String {
"openid".to_owned()
}

fn is_default_scope(scope: &str) -> bool {
scope == default_scope()
}

/// Configuration for one upstream OAuth 2 provider.
#[skip_serializing_none]
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
Expand Down Expand Up @@ -495,6 +503,9 @@ pub struct Provider {
pub id_token_signed_response_alg: JsonWebSignatureAlg,

/// The scopes to request from the provider
///
/// Defaults to `openid`.
#[serde(default = "default_scope", skip_serializing_if = "is_default_scope")]
pub scope: String,

/// How to discover the provider's configuration
Expand Down
2 changes: 1 addition & 1 deletion crates/data-model/src/upstream_oauth2/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ pub struct UpstreamOAuthAuthorizationSession {
pub provider_id: Ulid,
pub state_str: String,
pub code_challenge_verifier: Option<String>,
pub nonce: String,
pub nonce: Option<String>,
pub created_at: DateTime<Utc>,
}

Expand Down
9 changes: 1 addition & 8 deletions crates/handlers/src/admin/v1/upstream_oauth_links/delete.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,14 +108,7 @@ mod tests {
// Pretend it was linked by an authorization session
let session = repo
.upstream_oauth_session()
.add(
&mut rng,
&state.clock,
&provider,
String::new(),
None,
String::new(),
)
.add(&mut rng, &state.clock, &provider, String::new(), None, None)
.await
.unwrap();

Expand Down
10 changes: 6 additions & 4 deletions crates/handlers/src/upstream_oauth2/callback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -356,10 +356,12 @@ pub(crate) async fn handler(
)
.map_err(mas_oidc_client::error::IdTokenError::from)?;

// Nonce must match.
mas_jose::claims::NONCE
.extract_required_with_options(&mut claims, session.nonce.as_str())
.map_err(mas_oidc_client::error::IdTokenError::from)?;
// Nonce must match if present.
if let Some(nonce) = session.nonce.as_deref() {
mas_jose::claims::NONCE
.extract_required_with_options(&mut claims, nonce)
.map_err(mas_oidc_client::error::IdTokenError::from)?;
}

context = context.with_id_token_claims(claims);
}
Expand Down
2 changes: 1 addition & 1 deletion crates/handlers/src/upstream_oauth2/link.rs
Original file line number Diff line number Diff line change
Expand Up @@ -998,7 +998,7 @@ mod tests {
&provider,
"state".to_owned(),
None,
"nonce".to_owned(),
None,
)
.await
.unwrap();
Expand Down
24 changes: 14 additions & 10 deletions crates/oidc-client/src/requests/authorization_code.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ pub struct AuthorizationValidationData {
pub state: String,

/// A string to mitigate replay attacks.
pub nonce: String,
pub nonce: Option<String>,

/// The URI where the end-user will be redirected after authorization.
pub redirect_uri: Url,
Expand All @@ -216,7 +216,7 @@ fn build_authorization_request(
) -> Result<(FullAuthorizationRequest, AuthorizationValidationData), AuthorizationError> {
let AuthorizationRequestData {
client_id,
mut scope,
scope,
redirect_uri,
code_challenge_methods_supported,
display,
Expand All @@ -229,9 +229,13 @@ fn build_authorization_request(
response_mode,
} = authorization_data;

let is_openid = scope.contains(&OPENID);

// Generate a random CSRF "state" token and a nonce.
let state = Alphanumeric.sample_string(rng, 16);
let nonce = Alphanumeric.sample_string(rng, 16);

// Generate a random nonce if we're in 'OpenID Connect' mode
let nonce = is_openid.then(|| Alphanumeric.sample_string(rng, 16));

// Use PKCE, whenever possible.
let (pkce, code_challenge_verifier) = if code_challenge_methods_supported
Expand All @@ -255,8 +259,6 @@ fn build_authorization_request(
(None, None)
};

scope.insert(OPENID);

let auth_request = FullAuthorizationRequest {
inner: AuthorizationRequest {
response_type: OAuthAuthorizationEndpointResponseType::Code.into(),
Expand All @@ -265,7 +267,7 @@ fn build_authorization_request(
scope,
state: Some(state.clone()),
response_mode,
nonce: Some(nonce.clone()),
nonce: nonce.clone(),
display,
prompt,
max_age,
Expand Down Expand Up @@ -442,10 +444,12 @@ pub async fn access_token_with_authorization_code(
.extract_optional_with_options(&mut claims, TokenHash::new(signing_alg, &code))
.map_err(IdTokenError::from)?;

// Nonce must match.
claims::NONCE
.extract_required_with_options(&mut claims, validation_data.nonce.as_str())
.map_err(IdTokenError::from)?;
// Nonce must match if we have one.
if let Some(nonce) = validation_data.nonce.as_deref() {
claims::NONCE
.extract_required_with_options(&mut claims, nonce)
.map_err(IdTokenError::from)?;
}

Some(id_token.into_owned())
} else {
Expand Down
6 changes: 3 additions & 3 deletions crates/oidc-client/tests/it/requests/authorization_code.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ async fn pass_access_token_with_authorization_code() {
let redirect_uri = Url::parse(REDIRECT_URI).unwrap();
let validation_data = AuthorizationValidationData {
state: "some_state".to_owned(),
nonce: NONCE.to_owned(),
nonce: Some(NONCE.to_owned()),
redirect_uri,
code_challenge_verifier: Some(CODE_VERIFIER.to_owned()),
};
Expand Down Expand Up @@ -244,7 +244,7 @@ async fn fail_access_token_with_authorization_code_wrong_nonce() {
let redirect_uri = Url::parse(REDIRECT_URI).unwrap();
let validation_data = AuthorizationValidationData {
state: "some_state".to_owned(),
nonce: "wrong_nonce".to_owned(),
nonce: Some("wrong_nonce".to_owned()),
redirect_uri,
code_challenge_verifier: Some(CODE_VERIFIER.to_owned()),
};
Expand Down Expand Up @@ -306,7 +306,7 @@ async fn fail_access_token_with_authorization_code_no_id_token() {
let nonce = "some_nonce".to_owned();
let validation_data = AuthorizationValidationData {
state: "some_state".to_owned(),
nonce: nonce.clone(),
nonce: Some(nonce.clone()),
redirect_uri,
code_challenge_verifier: Some(CODE_VERIFIER.to_owned()),
};
Expand Down

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

Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.

-- Make the nonce column optional on the upstream oauth sessions
ALTER TABLE "upstream_oauth_authorization_sessions"
ALTER COLUMN "nonce" DROP NOT NULL;
2 changes: 1 addition & 1 deletion crates/storage-pg/src/upstream_oauth2/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ mod tests {
&provider,
"some-state".to_owned(),
None,
"some-nonce".to_owned(),
Some("some-nonce".to_owned()),
)
.await
.unwrap();
Expand Down
4 changes: 2 additions & 2 deletions crates/storage-pg/src/upstream_oauth2/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ struct SessionLookup {
upstream_oauth_link_id: Option<Uuid>,
state: String,
code_challenge_verifier: Option<String>,
nonce: String,
nonce: Option<String>,
id_token: Option<String>,
userinfo: Option<serde_json::Value>,
created_at: DateTime<Utc>,
Expand Down Expand Up @@ -191,7 +191,7 @@ impl UpstreamOAuthSessionRepository for PgUpstreamOAuthSessionRepository<'_> {
upstream_oauth_provider: &UpstreamOAuthProvider,
state_str: String,
code_challenge_verifier: Option<String>,
nonce: String,
nonce: Option<String>,
) -> Result<UpstreamOAuthAuthorizationSession, Self::Error> {
let created_at = clock.now();
let id = Ulid::from_datetime_with_source(created_at.into(), rng);
Expand Down
6 changes: 3 additions & 3 deletions crates/storage/src/upstream_oauth2/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ pub trait UpstreamOAuthSessionRepository: Send + Sync {
/// upstream OAuth provider
/// * `code_challenge_verifier`: the code challenge verifier used in this
/// session, if PKCE is being used
/// * `nonce`: the `nonce` used in this session
/// * `nonce`: the `nonce` used in this session if in OIDC mode
///
/// # Errors
///
Expand All @@ -60,7 +60,7 @@ pub trait UpstreamOAuthSessionRepository: Send + Sync {
upstream_oauth_provider: &UpstreamOAuthProvider,
state: String,
code_challenge_verifier: Option<String>,
nonce: String,
nonce: Option<String>,
) -> Result<UpstreamOAuthAuthorizationSession, Self::Error>;

/// Mark a session as completed and associate the given link
Expand Down Expand Up @@ -122,7 +122,7 @@ repository_impl!(UpstreamOAuthSessionRepository:
upstream_oauth_provider: &UpstreamOAuthProvider,
state: String,
code_challenge_verifier: Option<String>,
nonce: String,
nonce: Option<String>,
) -> Result<UpstreamOAuthAuthorizationSession, Self::Error>;

async fn complete_with_link(
Expand Down
3 changes: 1 addition & 2 deletions docs/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1974,7 +1974,6 @@
"required": [
"client_id",
"id",
"scope",
"token_endpoint_auth_method"
],
"properties": {
Expand Down Expand Up @@ -2044,7 +2043,7 @@
]
},
"scope": {
"description": "The scopes to request from the provider",
"description": "The scopes to request from the provider\n\nDefaults to `openid`.",
"type": "string"
},
"discovery_mode": {
Expand Down
Loading