Record the decoded ID token claims on upstream auth sessions

This commit is contained in:
Quentin Gliech
2025-06-27 14:56:21 +02:00
parent 26c08f9215
commit 5b7bf232d6
11 changed files with 129 additions and 45 deletions

View File

@@ -19,6 +19,7 @@ pub enum UpstreamOAuthAuthorizationSessionState {
completed_at: DateTime<Utc>,
link_id: Ulid,
id_token: Option<String>,
id_token_claims: Option<serde_json::Value>,
extra_callback_parameters: Option<serde_json::Value>,
userinfo: Option<serde_json::Value>,
},
@@ -27,6 +28,7 @@ pub enum UpstreamOAuthAuthorizationSessionState {
consumed_at: DateTime<Utc>,
link_id: Ulid,
id_token: Option<String>,
id_token_claims: Option<serde_json::Value>,
extra_callback_parameters: Option<serde_json::Value>,
userinfo: Option<serde_json::Value>,
},
@@ -35,6 +37,7 @@ pub enum UpstreamOAuthAuthorizationSessionState {
consumed_at: Option<DateTime<Utc>>,
unlinked_at: DateTime<Utc>,
id_token: Option<String>,
id_token_claims: Option<serde_json::Value>,
},
}
@@ -52,6 +55,7 @@ impl UpstreamOAuthAuthorizationSessionState {
completed_at: DateTime<Utc>,
link: &UpstreamOAuthLink,
id_token: Option<String>,
id_token_claims: Option<serde_json::Value>,
extra_callback_parameters: Option<serde_json::Value>,
userinfo: Option<serde_json::Value>,
) -> Result<Self, InvalidTransitionError> {
@@ -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<Utc>,
link: &UpstreamOAuthLink,
id_token: Option<String>,
id_token_claims: Option<serde_json::Value>,
extra_callback_parameters: Option<serde_json::Value>,
userinfo: Option<serde_json::Value>,
) -> Result<Self, InvalidTransitionError> {
@@ -284,6 +315,7 @@ impl UpstreamOAuthAuthorizationSession {
completed_at,
link,
id_token,
id_token_claims,
extra_callback_parameters,
userinfo,
)?;

View File

@@ -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();

View File

@@ -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<String, Value> 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,
)

View File

@@ -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,
)

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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;

View File

@@ -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

View File

@@ -40,6 +40,7 @@ struct SessionLookup {
code_challenge_verifier: Option<String>,
nonce: Option<String>,
id_token: Option<String>,
id_token_claims: Option<serde_json::Value>,
userinfo: Option<serde_json::Value>,
created_at: DateTime<Utc>,
completed_at: Option<DateTime<Utc>>,
@@ -56,18 +57,20 @@ impl TryFrom<SessionLookup> 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<SessionLookup> 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<SessionLookup> 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<String>,
id_token_claims: Option<serde_json::Value>,
extra_callback_parameters: Option<serde_json::Value>,
userinfo: Option<serde_json::Value>,
) -> Result<UpstreamOAuthAuthorizationSession, Self::Error> {
@@ -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,
)

View File

@@ -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<String>,
id_token_claims: Option<serde_json::Value>,
extra_callback_parameters: Option<serde_json::Value>,
userinfo: Option<serde_json::Value>,
) -> Result<UpstreamOAuthAuthorizationSession, Self::Error>;
@@ -131,6 +136,7 @@ repository_impl!(UpstreamOAuthSessionRepository:
upstream_oauth_authorization_session: UpstreamOAuthAuthorizationSession,
upstream_oauth_link: &UpstreamOAuthLink,
id_token: Option<String>,
id_token_claims: Option<serde_json::Value>,
extra_callback_parameters: Option<serde_json::Value>,
userinfo: Option<serde_json::Value>,
) -> Result<UpstreamOAuthAuthorizationSession, Self::Error>;