diff --git a/crates/data-model/src/upstream_oauth2/session.rs b/crates/data-model/src/upstream_oauth2/session.rs index e1d0695fc..e7dad7132 100644 --- a/crates/data-model/src/upstream_oauth2/session.rs +++ b/crates/data-model/src/upstream_oauth2/session.rs @@ -19,6 +19,7 @@ pub enum UpstreamOAuthAuthorizationSessionState { completed_at: DateTime, link_id: Ulid, id_token: Option, + id_token_claims: Option, extra_callback_parameters: Option, userinfo: Option, }, @@ -27,6 +28,7 @@ pub enum UpstreamOAuthAuthorizationSessionState { consumed_at: DateTime, link_id: Ulid, id_token: Option, + id_token_claims: Option, extra_callback_parameters: Option, userinfo: Option, }, @@ -35,6 +37,7 @@ pub enum UpstreamOAuthAuthorizationSessionState { consumed_at: Option>, unlinked_at: DateTime, id_token: Option, + id_token_claims: Option, }, } @@ -52,6 +55,7 @@ impl UpstreamOAuthAuthorizationSessionState { completed_at: DateTime, link: &UpstreamOAuthLink, id_token: Option, + id_token_claims: Option, extra_callback_parameters: Option, userinfo: Option, ) -> Result { @@ -60,6 +64,7 @@ impl UpstreamOAuthAuthorizationSessionState { completed_at, link_id: link.id, id_token, + id_token_claims, extra_callback_parameters, userinfo, }), @@ -83,6 +88,7 @@ impl UpstreamOAuthAuthorizationSessionState { completed_at, link_id, id_token, + id_token_claims, extra_callback_parameters, userinfo, } => Ok(Self::Consumed { @@ -90,6 +96,7 @@ impl UpstreamOAuthAuthorizationSessionState { link_id, consumed_at, id_token, + id_token_claims, extra_callback_parameters, userinfo, }), @@ -146,6 +153,29 @@ impl UpstreamOAuthAuthorizationSessionState { } } + /// Get the ID token claims for the upstream OAuth 2.0 authorization + /// session. + /// + /// Returns `None` if the upstream OAuth 2.0 authorization session state is + /// not [`Pending`]. + /// + /// [`Pending`]: UpstreamOAuthAuthorizationSessionState::Pending + #[must_use] + pub fn id_token_claims(&self) -> Option<&serde_json::Value> { + match self { + Self::Pending => None, + Self::Completed { + id_token_claims, .. + } + | Self::Consumed { + id_token_claims, .. + } + | Self::Unlinked { + id_token_claims, .. + } => id_token_claims.as_ref(), + } + } + /// Get the extra query parameters that were sent to the upstream provider. /// /// Returns `None` if the upstream OAuth 2.0 authorization session state is @@ -277,6 +307,7 @@ impl UpstreamOAuthAuthorizationSession { completed_at: DateTime, link: &UpstreamOAuthLink, id_token: Option, + id_token_claims: Option, extra_callback_parameters: Option, userinfo: Option, ) -> Result { @@ -284,6 +315,7 @@ impl UpstreamOAuthAuthorizationSession { completed_at, link, id_token, + id_token_claims, extra_callback_parameters, userinfo, )?; diff --git a/crates/handlers/src/admin/v1/upstream_oauth_links/delete.rs b/crates/handlers/src/admin/v1/upstream_oauth_links/delete.rs index 3c7905a4c..3e87109b5 100644 --- a/crates/handlers/src/admin/v1/upstream_oauth_links/delete.rs +++ b/crates/handlers/src/admin/v1/upstream_oauth_links/delete.rs @@ -126,7 +126,7 @@ mod tests { let session = repo .upstream_oauth_session() - .complete_with_link(&state.clock, session, &link, None, None, None) + .complete_with_link(&state.clock, session, &link, None, None, None, None) .await .unwrap(); diff --git a/crates/handlers/src/upstream_oauth2/callback.rs b/crates/handlers/src/upstream_oauth2/callback.rs index e6b207490..e368c9b72 100644 --- a/crates/handlers/src/upstream_oauth2/callback.rs +++ b/crates/handlers/src/upstream_oauth2/callback.rs @@ -312,6 +312,7 @@ pub(crate) async fn handler( .await?; let mut jwks = None; + let mut id_token_claims = None; let mut context = AttributeMappingContext::new(); if let Some(id_token) = token_response.id_token.as_ref() { @@ -337,6 +338,14 @@ pub(crate) async fn handler( let (_headers, mut claims) = id_token.into_parts(); + // Save a copy of the claims for later; the claims extract methods + // remove them from the map, and we want to store the original claims. + // We anyway need this to be a serde_json::Value + id_token_claims = Some( + serde_json::to_value(&claims) + .expect("serializing a HashMap into a Value should never fail"), + ); + // Access token hash must match. mas_jose::claims::AT_HASH .extract_optional_with_options( @@ -472,6 +481,7 @@ pub(crate) async fn handler( session, &link, token_response.id_token, + id_token_claims, params.extra_callback_parameters, userinfo, ) diff --git a/crates/handlers/src/upstream_oauth2/link.rs b/crates/handlers/src/upstream_oauth2/link.rs index 610fdf3e0..feb9e9074 100644 --- a/crates/handlers/src/upstream_oauth2/link.rs +++ b/crates/handlers/src/upstream_oauth2/link.rs @@ -934,7 +934,7 @@ mod tests { ..UpstreamOAuthProviderClaimsImports::default() }; - let id_token = serde_json::json!({ + let id_token_claims = serde_json::json!({ "preferred_username": "john", "email": "john@example.com", "email_verified": true, @@ -953,7 +953,8 @@ mod tests { .signing_key_for_alg(&JsonWebSignatureAlg::Rs256) .unwrap(); let header = JsonWebSignatureHeader::new(JsonWebSignatureAlg::Rs256); - let id_token = Jwt::sign_with_rng(&mut rng, header, id_token, &signer).unwrap(); + let id_token = + Jwt::sign_with_rng(&mut rng, header, id_token_claims.clone(), &signer).unwrap(); // Provision a provider and a link let mut repo = state.repository().await.unwrap(); @@ -1022,6 +1023,7 @@ mod tests { session, &link, Some(id_token.into_string()), + Some(id_token_claims), None, None, ) diff --git a/crates/storage-pg/.sqlx/query-5f5245ace61b896f92be78ab4fef701b37c9e3c2f4a332f418b9fb2625a0fe3f.json b/crates/storage-pg/.sqlx/query-5f5245ace61b896f92be78ab4fef701b37c9e3c2f4a332f418b9fb2625a0fe3f.json deleted file mode 100644 index c33da04d8..000000000 --- a/crates/storage-pg/.sqlx/query-5f5245ace61b896f92be78ab4fef701b37c9e3c2f4a332f418b9fb2625a0fe3f.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE upstream_oauth_authorization_sessions\n SET upstream_oauth_link_id = $1,\n completed_at = $2,\n id_token = $3,\n extra_callback_parameters = $4,\n userinfo = $5\n WHERE upstream_oauth_authorization_session_id = $6\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Timestamptz", - "Text", - "Jsonb", - "Jsonb", - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "5f5245ace61b896f92be78ab4fef701b37c9e3c2f4a332f418b9fb2625a0fe3f" -} diff --git a/crates/storage-pg/.sqlx/query-37a124678323380357fa9d1375fd125fb35476ac3008e5adbd04a761d5edcd42.json b/crates/storage-pg/.sqlx/query-e62d043f86e7232e6e9433631f8273e7ed0770c81071cf1f17516d3a45881ae9.json similarity index 77% rename from crates/storage-pg/.sqlx/query-37a124678323380357fa9d1375fd125fb35476ac3008e5adbd04a761d5edcd42.json rename to crates/storage-pg/.sqlx/query-e62d043f86e7232e6e9433631f8273e7ed0770c81071cf1f17516d3a45881ae9.json index 0e28ac022..c3c2e2507 100644 --- a/crates/storage-pg/.sqlx/query-37a124678323380357fa9d1375fd125fb35476ac3008e5adbd04a761d5edcd42.json +++ b/crates/storage-pg/.sqlx/query-e62d043f86e7232e6e9433631f8273e7ed0770c81071cf1f17516d3a45881ae9.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n upstream_oauth_authorization_session_id,\n upstream_oauth_provider_id,\n upstream_oauth_link_id,\n state,\n code_challenge_verifier,\n nonce,\n id_token,\n extra_callback_parameters,\n userinfo,\n created_at,\n completed_at,\n consumed_at,\n unlinked_at\n FROM upstream_oauth_authorization_sessions\n WHERE upstream_oauth_authorization_session_id = $1\n ", + "query": "\n SELECT\n upstream_oauth_authorization_session_id,\n upstream_oauth_provider_id,\n upstream_oauth_link_id,\n state,\n code_challenge_verifier,\n nonce,\n id_token,\n id_token_claims,\n extra_callback_parameters,\n userinfo,\n created_at,\n completed_at,\n consumed_at,\n unlinked_at\n FROM upstream_oauth_authorization_sessions\n WHERE upstream_oauth_authorization_session_id = $1\n ", "describe": { "columns": [ { @@ -40,31 +40,36 @@ }, { "ordinal": 7, - "name": "extra_callback_parameters", + "name": "id_token_claims", "type_info": "Jsonb" }, { "ordinal": 8, - "name": "userinfo", + "name": "extra_callback_parameters", "type_info": "Jsonb" }, { "ordinal": 9, + "name": "userinfo", + "type_info": "Jsonb" + }, + { + "ordinal": 10, "name": "created_at", "type_info": "Timestamptz" }, { - "ordinal": 10, + "ordinal": 11, "name": "completed_at", "type_info": "Timestamptz" }, { - "ordinal": 11, + "ordinal": 12, "name": "consumed_at", "type_info": "Timestamptz" }, { - "ordinal": 12, + "ordinal": 13, "name": "unlinked_at", "type_info": "Timestamptz" } @@ -84,11 +89,12 @@ true, true, true, + true, false, true, true, true ] }, - "hash": "37a124678323380357fa9d1375fd125fb35476ac3008e5adbd04a761d5edcd42" + "hash": "e62d043f86e7232e6e9433631f8273e7ed0770c81071cf1f17516d3a45881ae9" } diff --git a/crates/storage-pg/.sqlx/query-fd8f3e7ff02d4d1f465aad32edcb06a842cabc787279ba7d690f69b59ad3eb50.json b/crates/storage-pg/.sqlx/query-fd8f3e7ff02d4d1f465aad32edcb06a842cabc787279ba7d690f69b59ad3eb50.json new file mode 100644 index 000000000..072e6f57b --- /dev/null +++ b/crates/storage-pg/.sqlx/query-fd8f3e7ff02d4d1f465aad32edcb06a842cabc787279ba7d690f69b59ad3eb50.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE upstream_oauth_authorization_sessions\n SET upstream_oauth_link_id = $1\n , completed_at = $2\n , id_token = $3\n , id_token_claims = $4\n , extra_callback_parameters = $5\n , userinfo = $6\n WHERE upstream_oauth_authorization_session_id = $7\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Timestamptz", + "Text", + "Jsonb", + "Jsonb", + "Jsonb", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "fd8f3e7ff02d4d1f465aad32edcb06a842cabc787279ba7d690f69b59ad3eb50" +} diff --git a/crates/storage-pg/migrations/20250602212102_upstream_oauth2_id_token_claims.sql b/crates/storage-pg/migrations/20250602212102_upstream_oauth2_id_token_claims.sql new file mode 100644 index 000000000..6cb78a4c2 --- /dev/null +++ b/crates/storage-pg/migrations/20250602212102_upstream_oauth2_id_token_claims.sql @@ -0,0 +1,8 @@ +-- Copyright 2025 New Vector Ltd. +-- +-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +-- Please see LICENSE in the repository root for full details. + +-- This is the decoded claims from the ID token stored as JSONB +ALTER TABLE upstream_oauth_authorization_sessions + ADD COLUMN id_token_claims JSONB; diff --git a/crates/storage-pg/src/upstream_oauth2/mod.rs b/crates/storage-pg/src/upstream_oauth2/mod.rs index 23f73481c..b77e0631d 100644 --- a/crates/storage-pg/src/upstream_oauth2/mod.rs +++ b/crates/storage-pg/src/upstream_oauth2/mod.rs @@ -152,7 +152,7 @@ mod tests { let session = repo .upstream_oauth_session() - .complete_with_link(&clock, session, &link, None, None, None) + .complete_with_link(&clock, session, &link, None, None, None, None) .await .unwrap(); // Reload the session diff --git a/crates/storage-pg/src/upstream_oauth2/session.rs b/crates/storage-pg/src/upstream_oauth2/session.rs index e1dc6deb1..7946d63f0 100644 --- a/crates/storage-pg/src/upstream_oauth2/session.rs +++ b/crates/storage-pg/src/upstream_oauth2/session.rs @@ -40,6 +40,7 @@ struct SessionLookup { code_challenge_verifier: Option, nonce: Option, id_token: Option, + id_token_claims: Option, userinfo: Option, created_at: DateTime, completed_at: Option>, @@ -56,18 +57,20 @@ impl TryFrom for UpstreamOAuthAuthorizationSession { let state = match ( value.upstream_oauth_link_id, value.id_token, + value.id_token_claims, value.extra_callback_parameters, value.userinfo, value.completed_at, value.consumed_at, value.unlinked_at, ) { - (None, None, None, None, None, None, None) => { + (None, None, None, None, None, None, None, None) => { UpstreamOAuthAuthorizationSessionState::Pending } ( Some(link_id), id_token, + id_token_claims, extra_callback_parameters, userinfo, Some(completed_at), @@ -77,12 +80,14 @@ impl TryFrom for UpstreamOAuthAuthorizationSession { completed_at, link_id: link_id.into(), id_token, + id_token_claims, extra_callback_parameters, userinfo, }, ( Some(link_id), id_token, + id_token_claims, extra_callback_parameters, userinfo, Some(completed_at), @@ -92,18 +97,27 @@ impl TryFrom for UpstreamOAuthAuthorizationSession { completed_at, link_id: link_id.into(), id_token, + id_token_claims, extra_callback_parameters, userinfo, consumed_at, }, - (_, id_token, _, _, Some(completed_at), consumed_at, Some(unlinked_at)) => { - UpstreamOAuthAuthorizationSessionState::Unlinked { - completed_at, - id_token, - consumed_at, - unlinked_at, - } - } + ( + _, + id_token, + id_token_claims, + _, + _, + Some(completed_at), + consumed_at, + Some(unlinked_at), + ) => UpstreamOAuthAuthorizationSessionState::Unlinked { + completed_at, + id_token, + id_token_claims, + consumed_at, + unlinked_at, + }, _ => { return Err(DatabaseInconsistencyError::on( "upstream_oauth_authorization_sessions", @@ -152,6 +166,7 @@ impl UpstreamOAuthSessionRepository for PgUpstreamOAuthSessionRepository<'_> { code_challenge_verifier, nonce, id_token, + id_token_claims, extra_callback_parameters, userinfo, created_at, @@ -253,6 +268,7 @@ impl UpstreamOAuthSessionRepository for PgUpstreamOAuthSessionRepository<'_> { upstream_oauth_authorization_session: UpstreamOAuthAuthorizationSession, upstream_oauth_link: &UpstreamOAuthLink, id_token: Option, + id_token_claims: Option, extra_callback_parameters: Option, userinfo: Option, ) -> Result { @@ -261,16 +277,18 @@ impl UpstreamOAuthSessionRepository for PgUpstreamOAuthSessionRepository<'_> { sqlx::query!( r#" UPDATE upstream_oauth_authorization_sessions - SET upstream_oauth_link_id = $1, - completed_at = $2, - id_token = $3, - extra_callback_parameters = $4, - userinfo = $5 - WHERE upstream_oauth_authorization_session_id = $6 + SET upstream_oauth_link_id = $1 + , completed_at = $2 + , id_token = $3 + , id_token_claims = $4 + , extra_callback_parameters = $5 + , userinfo = $6 + WHERE upstream_oauth_authorization_session_id = $7 "#, Uuid::from(upstream_oauth_link.id), completed_at, id_token, + id_token_claims, extra_callback_parameters, userinfo, Uuid::from(upstream_oauth_authorization_session.id), @@ -284,6 +302,7 @@ impl UpstreamOAuthSessionRepository for PgUpstreamOAuthSessionRepository<'_> { completed_at, upstream_oauth_link, id_token, + id_token_claims, extra_callback_parameters, userinfo, ) diff --git a/crates/storage/src/upstream_oauth2/session.rs b/crates/storage/src/upstream_oauth2/session.rs index 6aadb673f..273370794 100644 --- a/crates/storage/src/upstream_oauth2/session.rs +++ b/crates/storage/src/upstream_oauth2/session.rs @@ -74,18 +74,23 @@ pub trait UpstreamOAuthSessionRepository: Send + Sync { /// * `upstream_oauth_link`: the link to associate with the session /// * `id_token`: the ID token returned by the upstream OAuth provider, if /// present + /// * `id_token_claims`: the claims contained in the ID token, if present /// * `extra_callback_parameters`: the extra query parameters returned in /// the callback, if any + /// * `userinfo`: the user info returned by the upstream OAuth provider, if + /// requested /// /// # Errors /// /// Returns [`Self::Error`] if the underlying repository fails + #[expect(clippy::too_many_arguments)] async fn complete_with_link( &mut self, clock: &dyn Clock, upstream_oauth_authorization_session: UpstreamOAuthAuthorizationSession, upstream_oauth_link: &UpstreamOAuthLink, id_token: Option, + id_token_claims: Option, extra_callback_parameters: Option, userinfo: Option, ) -> Result; @@ -131,6 +136,7 @@ repository_impl!(UpstreamOAuthSessionRepository: upstream_oauth_authorization_session: UpstreamOAuthAuthorizationSession, upstream_oauth_link: &UpstreamOAuthLink, id_token: Option, + id_token_claims: Option, extra_callback_parameters: Option, userinfo: Option, ) -> Result;