Include the browser session ID as sid in issued id_tokens

Persist resolved target user and user session on authorization grants
This commit is contained in:
Quentin Gliech
2026-07-29 18:36:41 +02:00
parent 7abe147ba0
commit 23b0d85589
14 changed files with 128 additions and 18 deletions
@@ -1,3 +1,4 @@
// Copyright 2025, 2026 Element Creations Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2021-2024 The Matrix.org Foundation C.I.C.
//
@@ -164,6 +165,15 @@ pub struct AuthorizationGrant {
/// Raw query parameters from the downstream authorization request, used
/// to template the parameters forwarded to the upstream provider.
pub raw_parameters: BTreeMap<String, String>,
/// The user resolved from a valid `id_token_hint`, if one was given and
/// verified at authorize time. The raw hint itself stays in
/// [`Self::raw_parameters`]; this is the resolved outcome.
pub target_user_id: Option<Ulid>,
/// The browser session resolved from the `sid` claim of a valid
/// `id_token_hint`, if present and still existing at authorize time. May
/// dangle after the session is reaped by retention;
/// [`Self::target_user_id`] is the stable anchor.
pub target_user_session_id: Option<Ulid>,
}
impl std::ops::Deref for AuthorizationGrant {
@@ -243,6 +253,8 @@ impl AuthorizationGrant {
login_hint: Some(String::from("mxid:@example-user:example.com")),
locale: Some(String::from("fr")),
raw_parameters: BTreeMap::new(),
target_user_id: None,
target_user_session_id: None,
}
}
}
@@ -267,6 +267,8 @@ pub(crate) async fn get(
params.auth.login_hint,
Some(locale.to_string()),
raw_parameters,
None,
None,
)
.await?;
let continue_grant = PostAuthAction::continue_grant(grant.id);
+2
View File
@@ -1,3 +1,4 @@
// Copyright 2025, 2026 Element Creations Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2021-2024 The Matrix.org Foundation C.I.C.
//
@@ -126,6 +127,7 @@ pub(crate) async fn get(
let claims_supported = Some(vec![
"iss".to_owned(),
"sub".to_owned(),
"sid".to_owned(),
"aud".to_owned(),
"iat".to_owned(),
"exp".to_owned(),
+2
View File
@@ -1,3 +1,4 @@
// Copyright 2025, 2026 Element Creations Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2021-2024 The Matrix.org Foundation C.I.C.
//
@@ -59,6 +60,7 @@ pub(crate) fn generate_id_token(
let now = clock.now();
claims::ISS.insert(&mut claims, url_builder.oidc_issuer().to_string())?;
claims::SUB.insert(&mut claims, &browser_session.user.sub)?;
claims::SID.insert(&mut claims, browser_session.id.to_string())?;
claims::AUD.insert(&mut claims, client.client_id.clone())?;
claims::IAT.insert(&mut claims, now)?;
claims::EXP.insert(&mut claims, now + Duration::try_hours(1).unwrap())?;
+18 -1
View File
@@ -1122,6 +1122,8 @@ mod tests {
None,
None,
std::collections::BTreeMap::new(),
None,
None,
)
.await
.unwrap();
@@ -1147,11 +1149,24 @@ mod tests {
let response = state.request(request).await;
response.assert_status(StatusCode::OK);
let AccessTokenResponse { access_token, .. } = response.json();
let AccessTokenResponse {
access_token,
id_token,
..
} = response.json();
// Check that the token is valid
assert!(state.is_access_token_valid(&access_token).await);
// We asked for the openid scope, so we should have an ID token, and it
// should carry a `sid` claim equal to the browser session ID.
let id_token = id_token.expect("an ID token should be present for the openid scope");
let id_token: mas_jose::jwt::Jwt<std::collections::HashMap<String, serde_json::Value>> =
id_token.as_str().try_into().unwrap();
let (_, mut claims) = id_token.into_parts();
let sid = mas_jose::claims::SID.extract_required(&mut claims).unwrap();
assert_eq!(sid, browser_session.id.to_string());
// Exchange it again, this it should fail
let request =
Request::post(mas_router::OAuth2TokenEndpoint::PATH).form(serde_json::json!({
@@ -1211,6 +1226,8 @@ mod tests {
None,
None,
std::collections::BTreeMap::new(),
None,
None,
)
.await
.unwrap();
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT oauth2_authorization_grant_id\n , created_at\n , cancelled_at\n , fulfilled_at\n , exchanged_at\n , scope\n , state\n , redirect_uri\n , response_mode\n , nonce\n , oauth2_client_id\n , authorization_code\n , response_type_code\n , response_type_id_token\n , code_challenge\n , code_challenge_method\n , login_hint\n , locale\n , raw_parameters AS \"raw_parameters: Json<BTreeMap<String, String>>\"\n , user_session_id\n , oauth2_session_id\n FROM\n oauth2_authorization_grants\n\n WHERE oauth2_authorization_grant_id = $1\n ",
"query": "\n SELECT oauth2_authorization_grant_id\n , created_at\n , cancelled_at\n , fulfilled_at\n , exchanged_at\n , scope\n , state\n , redirect_uri\n , response_mode\n , nonce\n , oauth2_client_id\n , authorization_code\n , response_type_code\n , response_type_id_token\n , code_challenge\n , code_challenge_method\n , login_hint\n , locale\n , raw_parameters AS \"raw_parameters: Json<BTreeMap<String, String>>\"\n , target_user_id\n , target_user_session_id\n , user_session_id\n , oauth2_session_id\n FROM\n oauth2_authorization_grants\n\n WHERE oauth2_authorization_grant_id = $1\n ",
"describe": {
"columns": [
{
@@ -100,11 +100,21 @@
},
{
"ordinal": 19,
"name": "user_session_id",
"name": "target_user_id",
"type_info": "Uuid"
},
{
"ordinal": 20,
"name": "target_user_session_id",
"type_info": "Uuid"
},
{
"ordinal": 21,
"name": "user_session_id",
"type_info": "Uuid"
},
{
"ordinal": 22,
"name": "oauth2_session_id",
"type_info": "Uuid"
}
@@ -135,8 +145,10 @@
true,
true,
true,
true,
true,
true
]
},
"hash": "008ef8a2092fd2424ab6215bcad376b72f26b723dc029624396bb2030e53907c"
"hash": "4040e4d3cecfb2b25e0c3d141df525da902344c79b50e8dd205b1fffe58665d9"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO oauth2_authorization_grants (\n oauth2_authorization_grant_id,\n oauth2_client_id,\n redirect_uri,\n scope,\n state,\n nonce,\n response_mode,\n code_challenge,\n code_challenge_method,\n response_type_code,\n response_type_id_token,\n authorization_code,\n login_hint,\n locale,\n raw_parameters,\n created_at\n )\n VALUES\n ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)\n ",
"query": "\n INSERT INTO oauth2_authorization_grants (\n oauth2_authorization_grant_id,\n oauth2_client_id,\n redirect_uri,\n scope,\n state,\n nonce,\n response_mode,\n code_challenge,\n code_challenge_method,\n response_type_code,\n response_type_id_token,\n authorization_code,\n login_hint,\n locale,\n raw_parameters,\n target_user_id,\n target_user_session_id,\n created_at\n )\n VALUES\n ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)\n ",
"describe": {
"columns": [],
"parameters": {
@@ -20,10 +20,12 @@
"Text",
"Text",
"Jsonb",
"Uuid",
"Uuid",
"Timestamptz"
]
},
"nullable": []
},
"hash": "041c4ddff9b40ff5ba16c9aa1dd9c721998de6e5798e4423df9063519ee5ac4d"
"hash": "4f152ad116f636d513373e07ecddb9a2470a8f1c37401e27192bcf9927b12979"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT oauth2_authorization_grant_id\n , created_at\n , cancelled_at\n , fulfilled_at\n , exchanged_at\n , scope\n , state\n , redirect_uri\n , response_mode\n , nonce\n , oauth2_client_id\n , authorization_code\n , response_type_code\n , response_type_id_token\n , code_challenge\n , code_challenge_method\n , login_hint\n , locale\n , raw_parameters AS \"raw_parameters: Json<BTreeMap<String, String>>\"\n , user_session_id\n , oauth2_session_id\n FROM\n oauth2_authorization_grants\n\n WHERE authorization_code = $1\n ",
"query": "\n SELECT oauth2_authorization_grant_id\n , created_at\n , cancelled_at\n , fulfilled_at\n , exchanged_at\n , scope\n , state\n , redirect_uri\n , response_mode\n , nonce\n , oauth2_client_id\n , authorization_code\n , response_type_code\n , response_type_id_token\n , code_challenge\n , code_challenge_method\n , login_hint\n , locale\n , raw_parameters AS \"raw_parameters: Json<BTreeMap<String, String>>\"\n , target_user_id\n , target_user_session_id\n , user_session_id\n , oauth2_session_id\n FROM\n oauth2_authorization_grants\n\n WHERE authorization_code = $1\n ",
"describe": {
"columns": [
{
@@ -100,11 +100,21 @@
},
{
"ordinal": 19,
"name": "user_session_id",
"name": "target_user_id",
"type_info": "Uuid"
},
{
"ordinal": 20,
"name": "target_user_session_id",
"type_info": "Uuid"
},
{
"ordinal": 21,
"name": "user_session_id",
"type_info": "Uuid"
},
{
"ordinal": 22,
"name": "oauth2_session_id",
"type_info": "Uuid"
}
@@ -135,8 +145,10 @@
true,
true,
true,
true,
true,
true
]
},
"hash": "2921f8cb19dedfc5524298ba4f42580244a4e5611d7aa8ade990bd5b2230f410"
"hash": "65e79b677464a0007cd8afe678953bbea9c52d1432815d3ff47f934d292cecf0"
}
@@ -0,0 +1,13 @@
-- Copyright 2026 Element Creations Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
-- Please see LICENSE files in the repository root for full details.
-- Records the resolved outcome of verifying a hint on the authorization
-- request: the target user and a user session.
ALTER TABLE oauth2_authorization_grants
ADD COLUMN target_user_id UUID
REFERENCES users (user_id),
ADD COLUMN target_user_session_id UUID
REFERENCES user_sessions (user_session_id)
ON DELETE SET NULL;
@@ -0,0 +1,9 @@
-- no-transaction
-- Copyright 2026 Element Creations Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
-- Please see LICENSE files in the repository root for full details.
CREATE INDEX CONCURRENTLY IF NOT EXISTS
oauth2_authorization_grants_target_session_fk
ON oauth2_authorization_grants (target_user_session_id);
@@ -11,7 +11,7 @@ use async_trait::async_trait;
use chrono::{DateTime, Utc};
use mas_data_model::{
AuthorizationCode, AuthorizationGrant, AuthorizationGrantStage, BrowserSession, Client, Clock,
Pkce, Session, UlidExt as _,
Pkce, Session, UlidExt as _, User,
};
use mas_iana::oauth::PkceCodeChallengeMethod;
use mas_storage::oauth2::OAuth2AuthorizationGrantRepository;
@@ -57,6 +57,8 @@ struct GrantLookup {
login_hint: Option<String>,
locale: Option<String>,
raw_parameters: Option<Json<BTreeMap<String, String>>>,
target_user_id: Option<Uuid>,
target_user_session_id: Option<Uuid>,
oauth2_client_id: Uuid,
user_session_id: Option<Uuid>,
oauth2_session_id: Option<Uuid>,
@@ -179,6 +181,8 @@ impl TryFrom<GrantLookup> for AuthorizationGrant {
login_hint: value.login_hint,
locale: value.locale,
raw_parameters: value.raw_parameters.map(|Json(x)| x).unwrap_or_default(),
target_user_id: value.target_user_id.map(Into::into),
target_user_session_id: value.target_user_session_id.map(Into::into),
})
}
}
@@ -213,6 +217,8 @@ impl OAuth2AuthorizationGrantRepository for PgOAuth2AuthorizationGrantRepository
login_hint: Option<String>,
locale: Option<String>,
raw_parameters: BTreeMap<String, String>,
target_user: Option<&User>,
target_user_session: Option<&BrowserSession>,
) -> Result<AuthorizationGrant, Self::Error> {
let code_challenge = code
.as_ref()
@@ -223,6 +229,8 @@ impl OAuth2AuthorizationGrantRepository for PgOAuth2AuthorizationGrantRepository
.and_then(|c| c.pkce.as_ref())
.map(|p| p.challenge_method.to_string());
let code_str = code.as_ref().map(|c| &c.code);
let target_user_id = target_user.map(|u| u.id);
let target_user_session_id = target_user_session.map(|s| s.id);
let created_at = clock.now();
let id = Ulid::from_datetime_with_rng(created_at, rng);
@@ -246,10 +254,12 @@ impl OAuth2AuthorizationGrantRepository for PgOAuth2AuthorizationGrantRepository
login_hint,
locale,
raw_parameters,
target_user_id,
target_user_session_id,
created_at
)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
"#,
Uuid::from(id),
Uuid::from(client.id),
@@ -266,6 +276,8 @@ impl OAuth2AuthorizationGrantRepository for PgOAuth2AuthorizationGrantRepository
login_hint,
locale,
Json(&raw_parameters) as _,
target_user_id.map(Uuid::from),
target_user_session_id.map(Uuid::from),
created_at,
)
.traced()
@@ -287,6 +299,8 @@ impl OAuth2AuthorizationGrantRepository for PgOAuth2AuthorizationGrantRepository
login_hint,
locale,
raw_parameters,
target_user_id,
target_user_session_id,
})
}
@@ -322,6 +336,8 @@ impl OAuth2AuthorizationGrantRepository for PgOAuth2AuthorizationGrantRepository
, login_hint
, locale
, raw_parameters AS "raw_parameters: Json<BTreeMap<String, String>>"
, target_user_id
, target_user_session_id
, user_session_id
, oauth2_session_id
FROM
@@ -374,6 +390,8 @@ impl OAuth2AuthorizationGrantRepository for PgOAuth2AuthorizationGrantRepository
, login_hint
, locale
, raw_parameters AS "raw_parameters: Json<BTreeMap<String, String>>"
, target_user_id
, target_user_session_id
, user_session_id
, oauth2_session_id
FROM
+2
View File
@@ -148,6 +148,8 @@ mod tests {
None,
None,
raw_parameters.clone(),
None,
None,
)
.await
.unwrap();
@@ -9,7 +9,7 @@ use std::collections::BTreeMap;
use async_trait::async_trait;
use mas_data_model::{
AuthorizationCode, AuthorizationGrant, BrowserSession, Client, Clock, Session,
AuthorizationCode, AuthorizationGrant, BrowserSession, Client, Clock, Session, User,
};
use oauth2_types::{requests::ResponseMode, scope::Scope};
use rand_core::RngCore;
@@ -49,6 +49,9 @@ pub trait OAuth2AuthorizationGrantRepository: Send + Sync {
/// * `raw_parameters`: The raw query parameters of the authorization
/// request, used to template the parameters forwarded to the upstream
/// provider
/// * `target_user`: The user resolved from a valid hint, if any
/// * `target_user_session`: The browser session resolved from the `sid`
/// claim of a valid hint, if any
///
/// # Errors
///
@@ -69,6 +72,8 @@ pub trait OAuth2AuthorizationGrantRepository: Send + Sync {
login_hint: Option<String>,
locale: Option<String>,
raw_parameters: BTreeMap<String, String>,
target_user: Option<&User>,
target_user_session: Option<&BrowserSession>,
) -> Result<AuthorizationGrant, Self::Error>;
/// Lookup an authorization grant by its ID
@@ -180,6 +185,8 @@ repository_impl!(OAuth2AuthorizationGrantRepository:
login_hint: Option<String>,
locale: Option<String>,
raw_parameters: BTreeMap<String, String>,
target_user: Option<&User>,
target_user_session: Option<&BrowserSession>,
) -> Result<AuthorizationGrant, Self::Error>;
async fn lookup(&mut self, id: Ulid) -> Result<Option<AuthorizationGrant>, Self::Error>;
+6 -6
View File
@@ -788,9 +788,9 @@ impl ConsentContext {
#[serde(tag = "grant_type")]
enum PolicyViolationGrant {
#[serde(rename = "authorization_code")]
Authorization(AuthorizationGrant),
Authorization(Box<AuthorizationGrant>),
#[serde(rename = "urn:ietf:params:oauth:grant-type:device_code")]
DeviceCode(DeviceCodeGrant),
DeviceCode(Box<DeviceCodeGrant>),
}
/// Context used by the `policy_violation.html` template
@@ -853,14 +853,14 @@ impl PolicyViolationContext {
/// Constructs a context for the policy violation page for an authorization
/// grant
#[must_use]
pub const fn for_authorization_grant(
pub fn for_authorization_grant(
grant: AuthorizationGrant,
client: Client,
violations: Vec<Violation>,
) -> Self {
let action = PostAuthAction::continue_grant(grant.id);
Self {
grant: PolicyViolationGrant::Authorization(grant),
grant: PolicyViolationGrant::Authorization(Box::new(grant)),
client,
action,
violations,
@@ -870,14 +870,14 @@ impl PolicyViolationContext {
/// Constructs a context for the policy violation page for a device code
/// grant
#[must_use]
pub const fn for_device_code_grant(
pub fn for_device_code_grant(
grant: DeviceCodeGrant,
client: Client,
violations: Vec<Violation>,
) -> Self {
let action = PostAuthAction::continue_device_code_grant(grant.id);
Self {
grant: PolicyViolationGrant::DeviceCode(grant),
grant: PolicyViolationGrant::DeviceCode(Box::new(grant)),
client,
action,
violations,