mirror of
https://github.com/element-hq/matrix-authentication-service.git
synced 2026-08-14 09:09:47 +00:00
Add the account-mismatch interstitial for id_token_hint/login_hint
When continuing an authorization grant whose requested identity (a verified id_token_hint target or an untrusted login_hint) does not match the active session, divert to a new grant-scoped /account-selection screen rather than straight to consent. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
a87b3f0e5e
commit
ccdd64bf3a
@@ -479,6 +479,11 @@ where
|
||||
get(self::oauth2::authorization::consent::get)
|
||||
.post(self::oauth2::authorization::consent::post),
|
||||
)
|
||||
.route(
|
||||
mas_router::SelectAccount::route(),
|
||||
get(self::oauth2::authorization::select_account::get)
|
||||
.post(self::oauth2::authorization::select_account::post),
|
||||
)
|
||||
.route(
|
||||
mas_router::CompatLoginSsoComplete::route(),
|
||||
get(self::compat::login_sso_complete::get).post(self::compat::login_sso_complete::post),
|
||||
|
||||
@@ -145,6 +145,24 @@ pub(crate) async fn get(
|
||||
.record_browser_session(&clock, &session)
|
||||
.await;
|
||||
|
||||
// Trusted-mismatch guard: for a grant with a verified `id_token_hint`
|
||||
// target, OIDC Core forbids returning a token for a different user. If the
|
||||
// active session is for someone else, divert to the account-selection
|
||||
// screen rather than rendering consent. We only enforce the *trusted* case;
|
||||
// an untrusted `login_hint` stays advisory (enforcing it here would loop
|
||||
// consent → select-account → "continue" → consent → …).
|
||||
if grant
|
||||
.target_user_id
|
||||
.is_some_and(|target| target != session.user.id)
|
||||
{
|
||||
repo.save().await?;
|
||||
return Ok((
|
||||
cookie_jar,
|
||||
url_builder.redirect(&mas_router::SelectAccount::continue_grant(grant_id)),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
|
||||
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
|
||||
|
||||
let session_counts = count_user_sessions_for_limiting(&mut repo, &session.user).await?;
|
||||
@@ -250,6 +268,21 @@ pub(crate) async fn post(
|
||||
.record_browser_session(&clock, &browser_session)
|
||||
.await;
|
||||
|
||||
// Trusted-mismatch guard (see the GET handler): never fulfil a grant with a
|
||||
// verified `id_token_hint` target as a different user. Divert to the
|
||||
// account-selection screen instead. Untrusted `login_hint` stays advisory.
|
||||
if grant
|
||||
.target_user_id
|
||||
.is_some_and(|target| target != browser_session.user.id)
|
||||
{
|
||||
repo.save().await?;
|
||||
return Ok((
|
||||
cookie_jar,
|
||||
url_builder.redirect(&mas_router::SelectAccount::continue_grant(grant_id)),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
|
||||
let client = repo
|
||||
.oauth2_client()
|
||||
.lookup(grant.client_id)
|
||||
|
||||
@@ -8,17 +8,70 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use mas_data_model::{BrowserSession, Clock, User};
|
||||
use mas_data_model::{AuthorizationGrant, BrowserSession, Clock, User};
|
||||
use mas_jose::{
|
||||
claims::{self, TimeOptions},
|
||||
jwt::Jwt,
|
||||
};
|
||||
use mas_keystore::Keystore;
|
||||
use mas_router::UrlBuilder;
|
||||
use mas_storage::{BoxRepository, RepositoryAccess, RepositoryError};
|
||||
use mas_storage::{BoxRepository, RepositoryAccess, RepositoryError, user::UserEmailRepository};
|
||||
use serde_json::Value;
|
||||
use ulid::Ulid;
|
||||
|
||||
use crate::views::shared::{LoginHint, parse_login_hint};
|
||||
|
||||
/// Decide whether the active browser session matches the identity the client
|
||||
/// requested on the grant.
|
||||
///
|
||||
/// The match key is the **user**, not the session: a stale `sid` whose user is
|
||||
/// still logged in is a match. The trust ladder mirrors §4 of the design:
|
||||
///
|
||||
/// * a verified `id_token_hint` target (`grant.target_user_id`) is **trusted**
|
||||
/// and compared by user id;
|
||||
/// * otherwise an untrusted `grant.login_hint` is parsed and compared against
|
||||
/// the *current* session's own data only (we never look up the hinted
|
||||
/// account): an `mxid:` hint matches on localpart + homeserver, a bare email
|
||||
/// matches iff the current user owns that email;
|
||||
/// * an unparseable or absent hint is treated as no constraint (a match).
|
||||
///
|
||||
/// Returns `Ok(true)` when there is no mismatch (proceed to consent),
|
||||
/// `Ok(false)` when the active session is a different account than requested.
|
||||
pub(crate) async fn session_matches_requested_identity(
|
||||
repo: &mut BoxRepository,
|
||||
homeserver: &str,
|
||||
grant: &AuthorizationGrant,
|
||||
session: &BrowserSession,
|
||||
) -> Result<bool, RepositoryError> {
|
||||
// Trusted target wins: compare by user id.
|
||||
if let Some(target) = grant.target_user_id {
|
||||
return Ok(session.user.id == target);
|
||||
}
|
||||
|
||||
// Otherwise fall back to the untrusted `login_hint`, compared only against
|
||||
// the current session's own data.
|
||||
let Some(login_hint) = &grant.login_hint else {
|
||||
return Ok(true);
|
||||
};
|
||||
|
||||
match parse_login_hint(login_hint, homeserver) {
|
||||
// `parse_login_hint` only resolves mxids on our own homeserver, so
|
||||
// comparing localparts is enough.
|
||||
LoginHint::Mxid(mxid) => Ok(mxid.localpart() == session.user.username),
|
||||
LoginHint::Email(email) => {
|
||||
// Look up the email on the *current* user only — their own data.
|
||||
let found = repo
|
||||
.user_email()
|
||||
.find(&session.user, email.as_ref())
|
||||
.await?;
|
||||
Ok(found.is_some())
|
||||
}
|
||||
// Unparseable hint (including an mxid on another homeserver): no
|
||||
// constraint we can act on, treat as a match.
|
||||
LoginHint::None => Ok(true),
|
||||
}
|
||||
}
|
||||
|
||||
/// The resolved outcome of verifying an `id_token_hint`.
|
||||
pub(crate) struct ResolvedHint {
|
||||
/// The user identified by the token's `sub` claim.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
|
||||
use axum::{
|
||||
extract::State,
|
||||
@@ -16,6 +16,7 @@ use hyper::StatusCode;
|
||||
use mas_axum_utils::{GenericError, InternalError, SessionInfoExt, cookies::CookieJar};
|
||||
use mas_data_model::{AuthorizationCode, BoxClock, BoxRng, Pkce};
|
||||
use mas_keystore::Keystore;
|
||||
use mas_matrix::HomeserverConnection;
|
||||
use mas_router::{PostAuthAction, UrlBuilder};
|
||||
use mas_storage::{
|
||||
BoxRepository,
|
||||
@@ -38,6 +39,7 @@ use crate::{BoundActivityTracker, PreferredLanguage, impl_from_error_for_route};
|
||||
mod callback;
|
||||
pub(crate) mod consent;
|
||||
mod id_token_hint;
|
||||
pub(crate) mod select_account;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum RouteError {
|
||||
@@ -122,6 +124,7 @@ pub(crate) async fn get(
|
||||
State(templates): State<Templates>,
|
||||
State(url_builder): State<UrlBuilder>,
|
||||
State(key_store): State<Keystore>,
|
||||
State(homeserver): State<Arc<dyn HomeserverConnection>>,
|
||||
activity_tracker: BoundActivityTracker,
|
||||
mut repo: BoxRepository,
|
||||
cookie_jar: CookieJar,
|
||||
@@ -319,14 +322,34 @@ pub(crate) async fn get(
|
||||
|
||||
Some(user_session) => {
|
||||
// TODO: better support for prompt=create when we have a session
|
||||
repo.save().await?;
|
||||
|
||||
activity_tracker
|
||||
.record_browser_session(&clock, &user_session)
|
||||
.await;
|
||||
url_builder
|
||||
.redirect(&mas_router::Consent(grant.id))
|
||||
.into_response()
|
||||
|
||||
// If the client requested a specific identity (via a verified
|
||||
// `id_token_hint` target or an untrusted `login_hint`) that
|
||||
// doesn't match the active session, divert to the
|
||||
// account-mismatch interstitial. Consent re-enforces this
|
||||
// authoritatively; this branch is only early UX routing.
|
||||
let matches = id_token_hint::session_matches_requested_identity(
|
||||
&mut repo,
|
||||
homeserver.homeserver(),
|
||||
&grant,
|
||||
&user_session,
|
||||
)
|
||||
.await?;
|
||||
|
||||
repo.save().await?;
|
||||
|
||||
if matches {
|
||||
url_builder
|
||||
.redirect(&mas_router::Consent(grant.id))
|
||||
.into_response()
|
||||
} else {
|
||||
url_builder
|
||||
.redirect(&mas_router::SelectAccount::continue_grant(grant.id))
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,837 @@
|
||||
// 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.
|
||||
|
||||
//! The account-mismatch interstitial.
|
||||
//!
|
||||
//! Shown while continuing an authorization grant whose requested identity (a
|
||||
//! verified `id_token_hint` target, or an untrusted `login_hint`) doesn't match
|
||||
//! the active browser session. It is always scoped to a grant — never reachable
|
||||
//! as a standalone endpoint — which keeps it from being a generic phishing
|
||||
//! surface.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
extract::{Form, State},
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
use axum_extra::extract::Query;
|
||||
use hyper::StatusCode;
|
||||
use mas_axum_utils::{
|
||||
GenericError, InternalError, SessionInfoExt,
|
||||
cookies::CookieJar,
|
||||
csrf::{CsrfExt, ProtectedForm},
|
||||
};
|
||||
use mas_data_model::{AuthorizationGrantStage, BoxClock, BoxRng};
|
||||
use mas_matrix::HomeserverConnection;
|
||||
use mas_router::{PostAuthAction, UrlBuilder};
|
||||
use mas_storage::{
|
||||
BoxRepository, oauth2::OAuth2AuthorizationGrantRepository, user::BrowserSessionRepository,
|
||||
};
|
||||
use mas_templates::{SelectAccountContext, TemplateContext, Templates};
|
||||
use oauth2_types::errors::{ClientError, ClientErrorCode};
|
||||
use serde::Deserialize;
|
||||
use thiserror::Error;
|
||||
use ulid::Ulid;
|
||||
|
||||
use super::{callback::CallbackDestination, id_token_hint::session_matches_requested_identity};
|
||||
use crate::{BoundActivityTracker, PreferredLanguage, impl_from_error_for_route};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum RouteError {
|
||||
#[error(transparent)]
|
||||
Internal(Box<dyn std::error::Error + Send + Sync>),
|
||||
|
||||
#[error(transparent)]
|
||||
Csrf(#[from] mas_axum_utils::csrf::CsrfError),
|
||||
|
||||
#[error("Authorization grant not found")]
|
||||
GrantNotFound,
|
||||
|
||||
#[error("Authorization grant {0} already used")]
|
||||
GrantNotPending(Ulid),
|
||||
}
|
||||
|
||||
impl_from_error_for_route!(mas_templates::TemplateError);
|
||||
impl_from_error_for_route!(mas_storage::RepositoryError);
|
||||
impl_from_error_for_route!(super::callback::IntoCallbackDestinationError);
|
||||
impl_from_error_for_route!(super::callback::CallbackDestinationError);
|
||||
|
||||
impl IntoResponse for RouteError {
|
||||
fn into_response(self) -> Response {
|
||||
match self {
|
||||
Self::Internal(e) => InternalError::new(e).into_response(),
|
||||
e @ Self::GrantNotFound => GenericError::new(StatusCode::NOT_FOUND, e).into_response(),
|
||||
e @ Self::GrantNotPending(_) => {
|
||||
GenericError::new(StatusCode::CONFLICT, e).into_response()
|
||||
}
|
||||
e @ Self::Csrf(_) => GenericError::new(StatusCode::BAD_REQUEST, e).into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the requested-identity display string and trust flag for the grant.
|
||||
///
|
||||
/// For a trusted target we look up the resolved user (allowed — the hint was
|
||||
/// cryptographically verified) and show its mxid. For an untrusted `login_hint`
|
||||
/// we echo the client-supplied string verbatim, never looking it up.
|
||||
async fn requested_identity(
|
||||
repo: &mut BoxRepository,
|
||||
homeserver: &dyn HomeserverConnection,
|
||||
grant: &mas_data_model::AuthorizationGrant,
|
||||
) -> Result<Option<(String, bool)>, RouteError> {
|
||||
if let Some(target) = grant.target_user_id {
|
||||
let user = repo.user().lookup(target).await?;
|
||||
return Ok(user.map(|user| (homeserver.mxid(&user.username), true)));
|
||||
}
|
||||
|
||||
Ok(grant
|
||||
.login_hint
|
||||
.clone()
|
||||
.map(|hint| (login_hint_display(&hint), false)))
|
||||
}
|
||||
|
||||
/// Turn a raw `login_hint` into something friendlier to echo, while still being
|
||||
/// purely the client's own input (no MAS-side lookup).
|
||||
fn login_hint_display(hint: &str) -> String {
|
||||
hint.strip_prefix("mxid:").unwrap_or(hint).to_owned()
|
||||
}
|
||||
|
||||
#[tracing::instrument(
|
||||
name = "handlers.oauth2.authorization.select_account.get",
|
||||
fields(grant.id = tracing::field::Empty),
|
||||
skip_all,
|
||||
)]
|
||||
pub(crate) async fn get(
|
||||
mut rng: BoxRng,
|
||||
clock: BoxClock,
|
||||
PreferredLanguage(locale): PreferredLanguage,
|
||||
State(templates): State<Templates>,
|
||||
State(url_builder): State<UrlBuilder>,
|
||||
State(homeserver): State<Arc<dyn HomeserverConnection>>,
|
||||
activity_tracker: BoundActivityTracker,
|
||||
mut repo: BoxRepository,
|
||||
cookie_jar: CookieJar,
|
||||
Query(params): Query<mas_router::SelectAccount>,
|
||||
) -> Result<Response, RouteError> {
|
||||
let action = params.post_auth_action();
|
||||
let PostAuthAction::ContinueAuthorizationGrant { id: grant_id } = *action else {
|
||||
// Other flows (device-code, manage-account) don't populate a requested
|
||||
// identity yet, so the account-selection screen is a no-op passthrough
|
||||
// for them; just continue the flow.
|
||||
return Ok((cookie_jar, action.go_next(&url_builder)).into_response());
|
||||
};
|
||||
tracing::Span::current().record("grant.id", tracing::field::display(grant_id));
|
||||
|
||||
let (session_info, cookie_jar) = cookie_jar.session_info();
|
||||
let maybe_session = session_info.load_active_session(&mut repo).await?;
|
||||
|
||||
let grant = repo
|
||||
.oauth2_authorization_grant()
|
||||
.lookup(grant_id)
|
||||
.await?
|
||||
.ok_or(RouteError::GrantNotFound)?;
|
||||
|
||||
if !matches!(grant.stage, AuthorizationGrantStage::Pending) {
|
||||
return Err(RouteError::GrantNotPending(grant.id));
|
||||
}
|
||||
|
||||
// No active session: nothing to mismatch against, send them to login
|
||||
// continuing the grant.
|
||||
let Some(session) = maybe_session else {
|
||||
let login = mas_router::Login::and_continue_grant(grant_id);
|
||||
return Ok((cookie_jar, url_builder.redirect(&login)).into_response());
|
||||
};
|
||||
|
||||
activity_tracker
|
||||
.record_browser_session(&clock, &session)
|
||||
.await;
|
||||
|
||||
// Defensive: if the session actually matches (or there is no constraint),
|
||||
// don't show the interstitial — go straight to consent. Entry routing
|
||||
// should already prevent landing here on a match.
|
||||
if session_matches_requested_identity(&mut repo, homeserver.homeserver(), &grant, &session)
|
||||
.await?
|
||||
{
|
||||
repo.save().await?;
|
||||
return Ok((
|
||||
cookie_jar,
|
||||
url_builder.redirect(&mas_router::Consent(grant_id)),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
|
||||
let Some((requested, trusted)) = requested_identity(&mut repo, &*homeserver, &grant).await?
|
||||
else {
|
||||
// Only reachable when a trusted target vanished since authorize time
|
||||
// (an absent hint counted as a match above). Per OIDC Core the grant
|
||||
// can never be fulfilled as a different user, so return
|
||||
// `login_required` to the client rather than redirecting to consent,
|
||||
// which would divert straight back here.
|
||||
let callback_destination = CallbackDestination::try_from(&grant)?;
|
||||
repo.save().await?;
|
||||
let response = callback_destination.go(
|
||||
&templates,
|
||||
&locale,
|
||||
ClientError::from(ClientErrorCode::LoginRequired),
|
||||
)?;
|
||||
return Ok((cookie_jar, response).into_response());
|
||||
};
|
||||
|
||||
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
|
||||
let current_username = session.user.username.clone();
|
||||
|
||||
repo.save().await?;
|
||||
|
||||
let ctx = SelectAccountContext::new(requested, trusted, current_username)
|
||||
.with_csrf(csrf_token.form_value())
|
||||
.with_language(locale);
|
||||
|
||||
let content = templates.render_select_account(&ctx)?;
|
||||
|
||||
Ok((cookie_jar, Html(content)).into_response())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "snake_case", tag = "action")]
|
||||
pub(crate) enum FormAction {
|
||||
/// Sign in as the requested account (sign out the current one).
|
||||
Switch,
|
||||
/// Continue as the current account (untrusted hint only).
|
||||
Continue,
|
||||
/// Abandon the flow and return an error to the client.
|
||||
Cancel,
|
||||
}
|
||||
|
||||
#[tracing::instrument(
|
||||
name = "handlers.oauth2.authorization.select_account.post",
|
||||
fields(grant.id = tracing::field::Empty),
|
||||
skip_all,
|
||||
)]
|
||||
pub(crate) async fn post(
|
||||
clock: BoxClock,
|
||||
PreferredLanguage(locale): PreferredLanguage,
|
||||
State(templates): State<Templates>,
|
||||
State(url_builder): State<UrlBuilder>,
|
||||
activity_tracker: BoundActivityTracker,
|
||||
mut repo: BoxRepository,
|
||||
cookie_jar: CookieJar,
|
||||
Query(params): Query<mas_router::SelectAccount>,
|
||||
Form(form): Form<ProtectedForm<FormAction>>,
|
||||
) -> Result<Response, RouteError> {
|
||||
let action = params.post_auth_action();
|
||||
let PostAuthAction::ContinueAuthorizationGrant { id: grant_id } = *action else {
|
||||
// Other flows (device-code, manage-account) don't populate a requested
|
||||
// identity yet, so the account-selection screen is a no-op passthrough
|
||||
// for them; just continue the flow.
|
||||
return Ok((cookie_jar, action.go_next(&url_builder)).into_response());
|
||||
};
|
||||
tracing::Span::current().record("grant.id", tracing::field::display(grant_id));
|
||||
|
||||
let form = cookie_jar.verify_form(&clock, form)?;
|
||||
|
||||
let (session_info, cookie_jar) = cookie_jar.session_info();
|
||||
|
||||
let grant = repo
|
||||
.oauth2_authorization_grant()
|
||||
.lookup(grant_id)
|
||||
.await?
|
||||
.ok_or(RouteError::GrantNotFound)?;
|
||||
|
||||
if !matches!(grant.stage, AuthorizationGrantStage::Pending) {
|
||||
return Err(RouteError::GrantNotPending(grant.id));
|
||||
}
|
||||
|
||||
match form {
|
||||
FormAction::Continue => {
|
||||
// "Continue as current" only exists for an untrusted hint. For a
|
||||
// trusted target (a verified `id_token_hint`) OIDC Core forbids
|
||||
// returning a token for a different user, and the screen never
|
||||
// offers this action — so a crafted POST must not be allowed to
|
||||
// complete the grant as the current account. Divert back to the
|
||||
// account-selection screen instead of proceeding to consent.
|
||||
if grant.target_user_id.is_some() {
|
||||
repo.save().await?;
|
||||
return Ok((
|
||||
cookie_jar,
|
||||
url_builder.redirect(&mas_router::SelectAccount::new(action.clone())),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
|
||||
// Untrusted hint only: the user explicitly chooses to keep their
|
||||
// current account. Proceed to consent (it re-enforces the match).
|
||||
repo.save().await?;
|
||||
Ok((
|
||||
cookie_jar,
|
||||
url_builder.redirect(&mas_router::Consent(grant_id)),
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
FormAction::Switch => {
|
||||
// Sign out the current session (mirroring logout), then send the
|
||||
// user to login continuing the grant. Slice 1's welcome-back handles
|
||||
// a trusted target; a generic pre-fill handles the untrusted case,
|
||||
// for which we carry the original `login_hint` along.
|
||||
if let Some(session_id) = session_info.current_session_id() {
|
||||
let maybe_session = repo.browser_session().lookup(session_id).await?;
|
||||
if let Some(session) = maybe_session
|
||||
&& session.finished_at.is_none()
|
||||
{
|
||||
activity_tracker
|
||||
.record_browser_session(&clock, &session)
|
||||
.await;
|
||||
repo.browser_session().finish(&clock, session).await?;
|
||||
}
|
||||
}
|
||||
|
||||
repo.save().await?;
|
||||
|
||||
let cookie_jar =
|
||||
cookie_jar.update_session_info(&session_info.mark_session_ended(clock.now()));
|
||||
|
||||
let mut login = mas_router::Login::and_continue_grant(grant_id);
|
||||
// Only the untrusted case needs the hint forwarded; for a trusted
|
||||
// target the resolved identity is already on the grant.
|
||||
if grant.target_user_id.is_none()
|
||||
&& let Some(login_hint) = grant.login_hint
|
||||
{
|
||||
login = login.with_login_hint(login_hint);
|
||||
}
|
||||
|
||||
Ok((cookie_jar, url_builder.redirect(&login)).into_response())
|
||||
}
|
||||
|
||||
FormAction::Cancel => {
|
||||
// Bail out: return an `access_denied` error to the client.
|
||||
let callback_destination = CallbackDestination::try_from(&grant)?;
|
||||
repo.save().await?;
|
||||
let response = callback_destination.go(
|
||||
&templates,
|
||||
&locale,
|
||||
ClientError::from(ClientErrorCode::AccessDenied),
|
||||
)?;
|
||||
Ok((cookie_jar, response).into_response())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::Duration;
|
||||
use hyper::{
|
||||
Request, StatusCode,
|
||||
header::{CONTENT_TYPE, LOCATION},
|
||||
};
|
||||
use mas_data_model::Clock;
|
||||
use mas_iana::jose::JsonWebSignatureAlg;
|
||||
use mas_jose::{
|
||||
claims,
|
||||
jwt::{JsonWebSignatureHeader, Jwt},
|
||||
};
|
||||
use mas_router::SimpleRoute;
|
||||
use mas_storage::user::{UserPasswordRepository, UserRepository};
|
||||
use serde_json::Value;
|
||||
use sqlx::PgPool;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::test_utils::{CookieHelper, RequestBuilderExt, ResponseExt, TestState, setup};
|
||||
|
||||
/// Register a public `OAuth2` client we can run an authorization flow
|
||||
/// against.
|
||||
async fn provision_client(state: &TestState) -> String {
|
||||
let request =
|
||||
Request::post(mas_router::OAuth2RegistrationEndpoint::PATH).json(serde_json::json!({
|
||||
"client_uri": "https://example.com/",
|
||||
"redirect_uris": ["https://example.com/redirect"],
|
||||
"response_types": ["code"],
|
||||
"grant_types": ["authorization_code"],
|
||||
"token_endpoint_auth_method": "none",
|
||||
}));
|
||||
let response = state.request(request).await;
|
||||
response.assert_status(StatusCode::CREATED);
|
||||
let response: oauth2_types::registration::ClientRegistrationResponse = response.json();
|
||||
response.client_id
|
||||
}
|
||||
|
||||
/// Provision a user with a password.
|
||||
async fn user_with_password(state: &TestState, username: &str) -> mas_data_model::User {
|
||||
let mut rng = state.rng();
|
||||
let mut repo = state.repository().await.unwrap();
|
||||
let user = repo
|
||||
.user()
|
||||
.add(&mut rng, &state.clock, username.to_owned())
|
||||
.await
|
||||
.unwrap();
|
||||
let (version, hash) = state
|
||||
.password_manager
|
||||
.hash(&mut rng, Zeroizing::new("hunter2".to_owned()))
|
||||
.await
|
||||
.unwrap();
|
||||
repo.user_password()
|
||||
.add(&mut rng, &state.clock, &user, version, hash, None)
|
||||
.await
|
||||
.unwrap();
|
||||
repo.save().await.unwrap();
|
||||
user
|
||||
}
|
||||
|
||||
/// Log in as the given user via `POST /login`, returning a cookie helper
|
||||
/// holding the active session cookie.
|
||||
async fn login(state: &TestState, username: &str) -> CookieHelper {
|
||||
let cookies = CookieHelper::new();
|
||||
|
||||
let request = cookies.with_cookies(Request::get("/login").empty());
|
||||
let response = state.request(request).await;
|
||||
cookies.save_cookies(&response);
|
||||
let csrf_token = response
|
||||
.body()
|
||||
.split("name=\"csrf\" value=\"")
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.split('\"')
|
||||
.next()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
|
||||
let request = cookies.with_cookies(Request::post("/login").form(serde_json::json!({
|
||||
"csrf": csrf_token,
|
||||
"username": username,
|
||||
"password": "hunter2",
|
||||
})));
|
||||
let response = state.request(request).await;
|
||||
cookies.save_cookies(&response);
|
||||
response.assert_status(StatusCode::SEE_OTHER);
|
||||
|
||||
cookies
|
||||
}
|
||||
|
||||
/// Mint a MAS-signed ID-token-shaped JWT for the given subject, with an
|
||||
/// already-expired `exp` (a realistic stale `id_token_hint`).
|
||||
fn mint_hint(state: &TestState, sub: &str) -> String {
|
||||
let issuer = state.url_builder.oidc_issuer().to_string();
|
||||
let mut payload: HashMap<String, Value> = HashMap::new();
|
||||
claims::ISS.insert(&mut payload, issuer).unwrap();
|
||||
claims::SUB.insert(&mut payload, sub.to_owned()).unwrap();
|
||||
claims::IAT
|
||||
.insert(&mut payload, state.clock.now() - Duration::hours(2))
|
||||
.unwrap();
|
||||
claims::EXP
|
||||
.insert(&mut payload, state.clock.now() - Duration::hours(1))
|
||||
.unwrap();
|
||||
|
||||
let key = state
|
||||
.key_store
|
||||
.signing_key_for_algorithm(&JsonWebSignatureAlg::Rs256)
|
||||
.unwrap();
|
||||
let signer = key
|
||||
.params()
|
||||
.signing_key_for_alg(&JsonWebSignatureAlg::Rs256)
|
||||
.unwrap();
|
||||
let header = JsonWebSignatureHeader::new(JsonWebSignatureAlg::Rs256);
|
||||
let jwt: Jwt<'static, _> =
|
||||
Jwt::sign_with_rng(&mut state.rng(), header, payload, &signer).unwrap();
|
||||
jwt.into_string()
|
||||
}
|
||||
|
||||
fn authorize_url(client_id: &str, extra: &str) -> String {
|
||||
format!(
|
||||
"{}?response_type=code&client_id={client_id}&redirect_uri=https://example.com/redirect&scope=openid&state=somestate&{extra}",
|
||||
mas_router::OAuth2AuthorizationEndpoint::PATH,
|
||||
)
|
||||
}
|
||||
|
||||
/// Find the grant id the authorize handler redirected to, regardless of
|
||||
/// whether it landed on consent or the account-selection screen.
|
||||
fn redirect_path(response: &hyper::Response<String>) -> String {
|
||||
response
|
||||
.headers()
|
||||
.get(LOCATION)
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
/// Logged in as bob, a trusted hint for alice → account-selection screen
|
||||
/// naming alice, with NO "continue as bob" option.
|
||||
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
|
||||
async fn test_trusted_mismatch_no_continue(pool: PgPool) {
|
||||
setup();
|
||||
let state = TestState::from_pool(pool).await.unwrap();
|
||||
|
||||
let alice = user_with_password(&state, "alice").await;
|
||||
user_with_password(&state, "bob").await;
|
||||
let client_id = provision_client(&state).await;
|
||||
let cookies = login(&state, "bob").await;
|
||||
|
||||
let hint = mint_hint(&state, &alice.sub);
|
||||
let url = authorize_url(&client_id, &format!("id_token_hint={hint}"));
|
||||
let request = cookies.with_cookies(Request::get(&url).empty());
|
||||
let response = state.request(request).await;
|
||||
response.assert_status(StatusCode::SEE_OTHER);
|
||||
|
||||
let location = redirect_path(&response);
|
||||
assert!(
|
||||
location.contains("/account-selection"),
|
||||
"expected account-selection, got {location}"
|
||||
);
|
||||
|
||||
// Follow the redirect to render the interstitial.
|
||||
let request = cookies.with_cookies(Request::get(&location).empty());
|
||||
let response = state.request(request).await;
|
||||
response.assert_status(StatusCode::OK);
|
||||
response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8");
|
||||
let body = response.body();
|
||||
assert!(
|
||||
body.contains("@alice:"),
|
||||
"should name the trusted target alice, body: {body}"
|
||||
);
|
||||
assert!(
|
||||
!body.contains("Continue as bob"),
|
||||
"trusted mismatch must not offer continue-as-current, body: {body}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Logged in as bob, an untrusted `login_hint` for alice →
|
||||
/// account-selection screen WITH a "continue as bob" option; choosing to
|
||||
/// continue redirects to consent.
|
||||
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
|
||||
async fn test_untrusted_mismatch_continue(pool: PgPool) {
|
||||
setup();
|
||||
let state = TestState::from_pool(pool).await.unwrap();
|
||||
|
||||
user_with_password(&state, "bob").await;
|
||||
let client_id = provision_client(&state).await;
|
||||
let cookies = login(&state, "bob").await;
|
||||
|
||||
let url = authorize_url(&client_id, "login_hint=mxid%3A%40alice%3Aexample.com");
|
||||
let request = cookies.with_cookies(Request::get(&url).empty());
|
||||
let response = state.request(request).await;
|
||||
response.assert_status(StatusCode::SEE_OTHER);
|
||||
|
||||
let location = redirect_path(&response);
|
||||
assert!(
|
||||
location.contains("/account-selection"),
|
||||
"expected account-selection, got {location}"
|
||||
);
|
||||
|
||||
let request = cookies.with_cookies(Request::get(&location).empty());
|
||||
let response = state.request(request).await;
|
||||
response.assert_status(StatusCode::OK);
|
||||
let body = response.body();
|
||||
assert!(
|
||||
body.contains("Continue as bob"),
|
||||
"untrusted mismatch must offer continue-as-current, body: {body}"
|
||||
);
|
||||
// Echoes the client-supplied hint.
|
||||
assert!(
|
||||
body.contains("@alice:example.com"),
|
||||
"should echo the login_hint, body: {body}"
|
||||
);
|
||||
|
||||
// Extract CSRF and POST "continue".
|
||||
let csrf_token = body
|
||||
.split("name=\"csrf\" value=\"")
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.split('\"')
|
||||
.next()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
let request = cookies.with_cookies(Request::post(&location).form(serde_json::json!({
|
||||
"csrf": csrf_token,
|
||||
"action": "continue",
|
||||
})));
|
||||
let response = state.request(request).await;
|
||||
response.assert_status(StatusCode::SEE_OTHER);
|
||||
assert!(
|
||||
redirect_path(&response).contains("/consent/"),
|
||||
"continue-as-current should land on consent"
|
||||
);
|
||||
}
|
||||
|
||||
/// Logged in as alice, a trusted hint for alice (a match) → straight to
|
||||
/// consent, no interstitial.
|
||||
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
|
||||
async fn test_match_goes_to_consent(pool: PgPool) {
|
||||
setup();
|
||||
let state = TestState::from_pool(pool).await.unwrap();
|
||||
|
||||
let alice = user_with_password(&state, "alice").await;
|
||||
let client_id = provision_client(&state).await;
|
||||
let cookies = login(&state, "alice").await;
|
||||
|
||||
let hint = mint_hint(&state, &alice.sub);
|
||||
let url = authorize_url(&client_id, &format!("id_token_hint={hint}"));
|
||||
let request = cookies.with_cookies(Request::get(&url).empty());
|
||||
let response = state.request(request).await;
|
||||
response.assert_status(StatusCode::SEE_OTHER);
|
||||
assert!(
|
||||
redirect_path(&response).contains("/consent/"),
|
||||
"a matching session should skip straight to consent"
|
||||
);
|
||||
}
|
||||
|
||||
/// "Sign in as target" POST clears the session cookie and redirects to
|
||||
/// /login continuing the grant.
|
||||
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
|
||||
async fn test_switch_clears_session(pool: PgPool) {
|
||||
setup();
|
||||
let state = TestState::from_pool(pool).await.unwrap();
|
||||
|
||||
let alice = user_with_password(&state, "alice").await;
|
||||
user_with_password(&state, "bob").await;
|
||||
let client_id = provision_client(&state).await;
|
||||
let cookies = login(&state, "bob").await;
|
||||
|
||||
let hint = mint_hint(&state, &alice.sub);
|
||||
let url = authorize_url(&client_id, &format!("id_token_hint={hint}"));
|
||||
let request = cookies.with_cookies(Request::get(&url).empty());
|
||||
let response = state.request(request).await;
|
||||
let location = redirect_path(&response);
|
||||
|
||||
let request = cookies.with_cookies(Request::get(&location).empty());
|
||||
let response = state.request(request).await;
|
||||
let body = response.body();
|
||||
let csrf_token = body
|
||||
.split("name=\"csrf\" value=\"")
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.split('\"')
|
||||
.next()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
|
||||
let request = cookies.with_cookies(Request::post(&location).form(serde_json::json!({
|
||||
"csrf": csrf_token,
|
||||
"action": "switch",
|
||||
})));
|
||||
let response = state.request(request).await;
|
||||
response.assert_status(StatusCode::SEE_OTHER);
|
||||
|
||||
// It should redirect to /login continuing the grant...
|
||||
let dest = redirect_path(&response);
|
||||
assert!(dest.starts_with("/login"), "expected /login, got {dest}");
|
||||
|
||||
// ...and forget the session cookie (Set-Cookie with an emptied/expired
|
||||
// session jar).
|
||||
let set_cookie = response
|
||||
.headers()
|
||||
.get_all(hyper::header::SET_COOKIE)
|
||||
.iter()
|
||||
.map(|v| v.to_str().unwrap())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(
|
||||
set_cookie.contains("session"),
|
||||
"the switch response should rewrite the session cookie, got: {set_cookie}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Extract the grant id from the `id` query parameter of an
|
||||
/// `/account-selection?kind=...&id=<ULID>` location.
|
||||
fn grant_id_from_selection(location: &str) -> &str {
|
||||
let query = location
|
||||
.split_once('?')
|
||||
.expect("expected an account-selection location with a query")
|
||||
.1;
|
||||
query
|
||||
.split('&')
|
||||
.find_map(|pair| pair.strip_prefix("id="))
|
||||
.expect("expected an `id` query parameter")
|
||||
}
|
||||
|
||||
/// Trusted mismatch: hitting `/consent/{grant_id}` directly while logged in
|
||||
/// as the wrong user (session=bob, target=alice) must NOT render consent —
|
||||
/// it diverts back to the account-selection screen.
|
||||
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
|
||||
async fn test_consent_trusted_mismatch_diverts(pool: PgPool) {
|
||||
setup();
|
||||
let state = TestState::from_pool(pool).await.unwrap();
|
||||
|
||||
let alice = user_with_password(&state, "alice").await;
|
||||
user_with_password(&state, "bob").await;
|
||||
let client_id = provision_client(&state).await;
|
||||
let cookies = login(&state, "bob").await;
|
||||
|
||||
let hint = mint_hint(&state, &alice.sub);
|
||||
let url = authorize_url(&client_id, &format!("id_token_hint={hint}"));
|
||||
let request = cookies.with_cookies(Request::get(&url).empty());
|
||||
let response = state.request(request).await;
|
||||
let location = redirect_path(&response);
|
||||
let grant_id = grant_id_from_selection(&location);
|
||||
|
||||
// Hit consent directly, bypassing the interstitial.
|
||||
let consent = format!("/consent/{grant_id}");
|
||||
let request = cookies.with_cookies(Request::get(&consent).empty());
|
||||
let response = state.request(request).await;
|
||||
response.assert_status(StatusCode::SEE_OTHER);
|
||||
let dest = redirect_path(&response);
|
||||
assert!(
|
||||
dest.contains("/account-selection"),
|
||||
"trusted mismatch at consent must divert to account-selection, got {dest}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Trusted grant + POST `action=continue` to the account-selection screen
|
||||
/// must be rejected: it must NOT reach consent (the screen never offers
|
||||
/// this action for a trusted target).
|
||||
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
|
||||
async fn test_trusted_continue_rejected(pool: PgPool) {
|
||||
setup();
|
||||
let state = TestState::from_pool(pool).await.unwrap();
|
||||
|
||||
let alice = user_with_password(&state, "alice").await;
|
||||
user_with_password(&state, "bob").await;
|
||||
let client_id = provision_client(&state).await;
|
||||
let cookies = login(&state, "bob").await;
|
||||
|
||||
let hint = mint_hint(&state, &alice.sub);
|
||||
let url = authorize_url(&client_id, &format!("id_token_hint={hint}"));
|
||||
let request = cookies.with_cookies(Request::get(&url).empty());
|
||||
let response = state.request(request).await;
|
||||
let location = redirect_path(&response);
|
||||
|
||||
// Render the interstitial to grab a valid CSRF token.
|
||||
let request = cookies.with_cookies(Request::get(&location).empty());
|
||||
let response = state.request(request).await;
|
||||
let body = response.body();
|
||||
let csrf_token = body
|
||||
.split("name=\"csrf\" value=\"")
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.split('\"')
|
||||
.next()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
|
||||
// A crafted `continue` POST with a valid CSRF token must not complete
|
||||
// the trusted grant.
|
||||
let request = cookies.with_cookies(Request::post(&location).form(serde_json::json!({
|
||||
"csrf": csrf_token,
|
||||
"action": "continue",
|
||||
})));
|
||||
let response = state.request(request).await;
|
||||
response.assert_status(StatusCode::SEE_OTHER);
|
||||
let dest = redirect_path(&response);
|
||||
assert!(
|
||||
!dest.contains("/consent/"),
|
||||
"a trusted `continue` must not reach consent, got {dest}"
|
||||
);
|
||||
assert!(
|
||||
dest.contains("/account-selection"),
|
||||
"a trusted `continue` should be sent back to account-selection, got {dest}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Untrusted mismatch (`login_hint` only) at consent must still proceed —
|
||||
/// it must NOT be diverted (guards against an interstitial ↔ consent
|
||||
/// loop).
|
||||
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
|
||||
async fn test_consent_untrusted_mismatch_proceeds(pool: PgPool) {
|
||||
setup();
|
||||
let state = TestState::from_pool(pool).await.unwrap();
|
||||
|
||||
user_with_password(&state, "bob").await;
|
||||
let client_id = provision_client(&state).await;
|
||||
let cookies = login(&state, "bob").await;
|
||||
|
||||
let url = authorize_url(&client_id, "login_hint=mxid%3A%40alice%3Aexample.com");
|
||||
let request = cookies.with_cookies(Request::get(&url).empty());
|
||||
let response = state.request(request).await;
|
||||
let location = redirect_path(&response);
|
||||
let grant_id = grant_id_from_selection(&location);
|
||||
|
||||
let consent = format!("/consent/{grant_id}");
|
||||
let request = cookies.with_cookies(Request::get(&consent).empty());
|
||||
let response = state.request(request).await;
|
||||
// Renders consent (200) rather than diverting back to account-selection.
|
||||
response.assert_status(StatusCode::OK);
|
||||
let dest = response.headers().get(LOCATION);
|
||||
assert!(
|
||||
dest.is_none(),
|
||||
"untrusted mismatch at consent must not be diverted"
|
||||
);
|
||||
}
|
||||
|
||||
/// Match (session=alice, target=alice) at consent proceeds as before:
|
||||
/// the authorize handler sends a matching session straight to consent.
|
||||
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
|
||||
async fn test_consent_match_proceeds(pool: PgPool) {
|
||||
setup();
|
||||
let state = TestState::from_pool(pool).await.unwrap();
|
||||
|
||||
let alice = user_with_password(&state, "alice").await;
|
||||
let client_id = provision_client(&state).await;
|
||||
let cookies = login(&state, "alice").await;
|
||||
|
||||
let hint = mint_hint(&state, &alice.sub);
|
||||
let url = authorize_url(&client_id, &format!("id_token_hint={hint}"));
|
||||
let request = cookies.with_cookies(Request::get(&url).empty());
|
||||
let response = state.request(request).await;
|
||||
let location = redirect_path(&response);
|
||||
assert!(
|
||||
location.contains("/consent/"),
|
||||
"a matching session should land on consent, got {location}"
|
||||
);
|
||||
|
||||
let request = cookies.with_cookies(Request::get(&location).empty());
|
||||
let response = state.request(request).await;
|
||||
response.assert_status(StatusCode::OK);
|
||||
let dest = response.headers().get(LOCATION);
|
||||
assert!(dest.is_none(), "a matching session must render consent");
|
||||
}
|
||||
|
||||
/// "Cancel" POST returns an `access_denied` error to the client callback.
|
||||
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
|
||||
async fn test_cancel_access_denied(pool: PgPool) {
|
||||
setup();
|
||||
let state = TestState::from_pool(pool).await.unwrap();
|
||||
|
||||
let alice = user_with_password(&state, "alice").await;
|
||||
user_with_password(&state, "bob").await;
|
||||
let client_id = provision_client(&state).await;
|
||||
let cookies = login(&state, "bob").await;
|
||||
|
||||
let hint = mint_hint(&state, &alice.sub);
|
||||
let url = authorize_url(&client_id, &format!("id_token_hint={hint}"));
|
||||
let request = cookies.with_cookies(Request::get(&url).empty());
|
||||
let response = state.request(request).await;
|
||||
let location = redirect_path(&response);
|
||||
|
||||
let request = cookies.with_cookies(Request::get(&location).empty());
|
||||
let response = state.request(request).await;
|
||||
let body = response.body();
|
||||
let csrf_token = body
|
||||
.split("name=\"csrf\" value=\"")
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.split('\"')
|
||||
.next()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
|
||||
let request = cookies.with_cookies(Request::post(&location).form(serde_json::json!({
|
||||
"csrf": csrf_token,
|
||||
"action": "cancel",
|
||||
})));
|
||||
let response = state.request(request).await;
|
||||
response.assert_status(StatusCode::SEE_OTHER);
|
||||
let dest = redirect_path(&response);
|
||||
assert!(
|
||||
dest.starts_with("https://example.com/redirect")
|
||||
&& dest.contains("error=access_denied"),
|
||||
"cancel should redirect to the client callback with access_denied, got {dest}"
|
||||
);
|
||||
assert!(
|
||||
dest.contains("state=somestate"),
|
||||
"cancel callback should carry the grant state, got {dest}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
//
|
||||
@@ -123,28 +124,34 @@ pub(crate) struct QueryLoginHint {
|
||||
}
|
||||
|
||||
impl QueryLoginHint {
|
||||
/// Parse a `login_hint`
|
||||
///
|
||||
/// Returns `LoginHint::MXID` for valid mxid 'mxid:@john.doe:example.com'
|
||||
///
|
||||
/// Returns `LoginHint::Email` for valid email 'john.doe@example.com'
|
||||
///
|
||||
/// Otherwise returns `LoginHint::None`
|
||||
/// Parse the `login_hint` query parameter, if any
|
||||
pub fn parse_login_hint(&self, homeserver: &str) -> LoginHint<'_> {
|
||||
let Some(login_hint) = &self.login_hint else {
|
||||
return LoginHint::None;
|
||||
};
|
||||
|
||||
if let Some(value) = login_hint.strip_prefix("mxid:")
|
||||
&& let Ok(mxid) = <&UserId>::try_from(value)
|
||||
&& mxid.server_name() == homeserver
|
||||
{
|
||||
LoginHint::Mxid(mxid)
|
||||
} else if let Ok(email) = lettre::Address::from_str(login_hint) {
|
||||
LoginHint::Email(email)
|
||||
} else {
|
||||
LoginHint::None
|
||||
}
|
||||
parse_login_hint(login_hint, homeserver)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a `login_hint`
|
||||
///
|
||||
/// Returns `LoginHint::Mxid` for a valid mxid on this homeserver
|
||||
/// ('mxid:@john.doe:example.com')
|
||||
///
|
||||
/// Returns `LoginHint::Email` for a valid email ('john.doe@example.com')
|
||||
///
|
||||
/// Otherwise returns `LoginHint::None`
|
||||
pub(crate) fn parse_login_hint<'a>(login_hint: &'a str, homeserver: &str) -> LoginHint<'a> {
|
||||
if let Some(value) = login_hint.strip_prefix("mxid:")
|
||||
&& let Ok(mxid) = <&UserId>::try_from(value)
|
||||
&& mxid.server_name() == homeserver
|
||||
{
|
||||
LoginHint::Mxid(mxid)
|
||||
} else if let Ok(email) = lettre::Address::from_str(login_hint) {
|
||||
LoginHint::Email(email)
|
||||
} else {
|
||||
LoginHint::None
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Copyright 2025, 2026 Element Creations Ltd.
|
||||
// Copyright 2024, 2025 New Vector Ltd.
|
||||
// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
|
||||
//
|
||||
@@ -588,6 +589,52 @@ impl Route for Consent {
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET|POST /account-selection`
|
||||
///
|
||||
/// The account-mismatch interstitial shown while continuing a post-auth action
|
||||
/// (currently only an authorization grant) whose requested identity (a verified
|
||||
/// `id_token_hint` target, or an untrusted `login_hint`) doesn't match the
|
||||
/// active session. The action it should continue is carried as a query
|
||||
/// parameter, mirroring the `Login`/`Register` routes.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SelectAccount {
|
||||
#[serde(flatten)]
|
||||
post_auth_action: PostAuthAction,
|
||||
}
|
||||
|
||||
impl SelectAccount {
|
||||
/// Build a `SelectAccount` route for the given post-auth action.
|
||||
#[must_use]
|
||||
pub const fn new(action: PostAuthAction) -> Self {
|
||||
Self {
|
||||
post_auth_action: action,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a `SelectAccount` route continuing the given authorization grant.
|
||||
#[must_use]
|
||||
pub const fn continue_grant(id: Ulid) -> Self {
|
||||
Self::new(PostAuthAction::continue_grant(id))
|
||||
}
|
||||
|
||||
/// Get the post-auth action this route should continue.
|
||||
#[must_use]
|
||||
pub fn post_auth_action(&self) -> &PostAuthAction {
|
||||
&self.post_auth_action
|
||||
}
|
||||
}
|
||||
|
||||
impl Route for SelectAccount {
|
||||
type Query = PostAuthAction;
|
||||
fn route() -> &'static str {
|
||||
"/account-selection"
|
||||
}
|
||||
|
||||
fn query(&self) -> Option<&Self::Query> {
|
||||
Some(&self.post_auth_action)
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET|POST /_matrix/client/v3/login`
|
||||
pub struct CompatLogin;
|
||||
|
||||
|
||||
@@ -678,6 +678,58 @@ impl WelcomeBackContext {
|
||||
}
|
||||
}
|
||||
|
||||
/// Context used by the `pages/account_selection.html` template
|
||||
///
|
||||
/// Rendered when continuing an authorization grant whose requested identity
|
||||
/// doesn't match the active session: a verified `id_token_hint` target
|
||||
/// (`trusted = true`, the resolved username) or an untrusted `login_hint`
|
||||
/// (`trusted = false`, the raw client-supplied string echoed back).
|
||||
///
|
||||
/// For a trusted mismatch the only options are "sign in as the requested user"
|
||||
/// or "cancel" — per OIDC Core, MAS must not return a token for a different
|
||||
/// user. An untrusted mismatch additionally offers "continue as the current
|
||||
/// user", since `login_hint` is purely advisory.
|
||||
#[derive(Serialize)]
|
||||
pub struct SelectAccountContext {
|
||||
/// The requested identity, as a display string. For a trusted target this
|
||||
/// is the resolved username; for an untrusted hint it is the raw
|
||||
/// client-supplied `login_hint` string echoed verbatim.
|
||||
requested: String,
|
||||
/// Whether the requested identity came from a verified `id_token_hint`.
|
||||
trusted: bool,
|
||||
/// The username of the currently-active session.
|
||||
current_username: String,
|
||||
}
|
||||
|
||||
impl TemplateContext for SelectAccountContext {
|
||||
fn sample<R: Rng>(
|
||||
_now: chrono::DateTime<Utc>,
|
||||
_rng: &mut R,
|
||||
_locales: &[DataLocale],
|
||||
) -> BTreeMap<SampleIdentifier, Self>
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
sample_list(vec![SelectAccountContext {
|
||||
requested: "@alice:example.com".to_owned(),
|
||||
trusted: true,
|
||||
current_username: "bob".to_owned(),
|
||||
}])
|
||||
}
|
||||
}
|
||||
|
||||
impl SelectAccountContext {
|
||||
/// Construct a context for the account-mismatch interstitial.
|
||||
#[must_use]
|
||||
pub fn new(requested: String, trusted: bool, current_username: String) -> Self {
|
||||
Self {
|
||||
requested,
|
||||
trusted,
|
||||
current_username,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fields of the registration form
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
|
||||
@@ -49,10 +49,10 @@ pub use self::{
|
||||
RegisterStepsDisplayNameContext, RegisterStepsDisplayNameFormField,
|
||||
RegisterStepsEmailInUseContext, RegisterStepsRegistrationTokenContext,
|
||||
RegisterStepsRegistrationTokenFormField, RegisterStepsVerifyEmailContext,
|
||||
RegisterStepsVerifyEmailFormField, SiteBranding, SiteConfigExt, SiteFeatures,
|
||||
TemplateContext, UpstreamExistingLinkContext, UpstreamRegister, UpstreamRegisterFormField,
|
||||
UpstreamSuggestLink, WelcomeBackContext, WithCaptcha, WithCsrf, WithLanguage,
|
||||
WithOptionalSession, WithSession,
|
||||
RegisterStepsVerifyEmailFormField, SelectAccountContext, SiteBranding, SiteConfigExt,
|
||||
SiteFeatures, TemplateContext, UpstreamExistingLinkContext, UpstreamRegister,
|
||||
UpstreamRegisterFormField, UpstreamSuggestLink, WelcomeBackContext, WithCaptcha, WithCsrf,
|
||||
WithLanguage, WithOptionalSession, WithSession,
|
||||
},
|
||||
forms::{FieldError, FormError, FormField, FormState, ToFormState},
|
||||
};
|
||||
@@ -375,6 +375,9 @@ register_templates! {
|
||||
/// Render the streamlined "welcome back" re-authentication page
|
||||
pub fn render_welcome_back(WithLanguage<WithCsrf<WelcomeBackContext>>) { "pages/login/welcome_back.html" }
|
||||
|
||||
/// Render the account-mismatch interstitial
|
||||
pub fn render_select_account(WithLanguage<WithCsrf<SelectAccountContext>>) { "pages/account_selection.html" }
|
||||
|
||||
/// Render the registration page
|
||||
pub fn render_register(WithLanguage<WithCsrf<RegisterContext>>) { "pages/register/index.html" }
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
{#
|
||||
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.
|
||||
-#}
|
||||
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<header class="page-heading">
|
||||
<div class="icon">
|
||||
{{ icon.user_profile_solid() }}
|
||||
</div>
|
||||
|
||||
<div class="header">
|
||||
<h1 class="title">{{ _("mas.select_account.headline") }}</h1>
|
||||
<p class="text">
|
||||
{{ _("mas.select_account.description", current=current_username, requested=requested) }}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="flex flex-col gap-6">
|
||||
<form method="POST" class="cpd-form-root flex flex-col gap-4">
|
||||
<input type="hidden" name="csrf" value="{{ csrf_token }}" />
|
||||
|
||||
<button class="cpd-button" data-kind="primary" data-size="lg" type="submit" name="action" value="switch">
|
||||
{{ _("mas.select_account.sign_in_as", username=requested) }}
|
||||
</button>
|
||||
|
||||
{% if not trusted %}
|
||||
<button class="cpd-button" data-kind="secondary" data-size="lg" type="submit" name="action" value="continue">
|
||||
{{ _("mas.select_account.continue_as", username=current_username) }}
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
<button class="cpd-button destructive" data-kind="tertiary" data-size="lg" type="submit" name="action" value="cancel">
|
||||
{{ _("action.cancel") }}
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
{% endblock content %}
|
||||
+19
-1
@@ -6,7 +6,7 @@
|
||||
},
|
||||
"cancel": "Cancel",
|
||||
"@cancel": {
|
||||
"context": "pages/consent.html:81:11-29, pages/device_consent.html:179:13-31, pages/device_link.html:45:33-51, pages/login/welcome_back.html:59:13-31, pages/policy_violation.html:70:15-33"
|
||||
"context": "pages/account_selection.html:39:11-29, pages/consent.html:81:11-29, pages/device_consent.html:179:13-31, pages/device_link.html:45:33-51, pages/login/welcome_back.html:59:13-31, pages/policy_violation.html:70:15-33"
|
||||
},
|
||||
"continue": "Continue",
|
||||
"@continue": {
|
||||
@@ -746,6 +746,24 @@
|
||||
"description": "Displayed when the 'openid' scope is requested"
|
||||
}
|
||||
},
|
||||
"select_account": {
|
||||
"continue_as": "Continue as %(username)s",
|
||||
"@continue_as": {
|
||||
"context": "pages/account_selection.html:34:13-75"
|
||||
},
|
||||
"description": "You are signed in as %(current)s, but the application requested %(requested)s.",
|
||||
"@description": {
|
||||
"context": "pages/account_selection.html:19:11-93"
|
||||
},
|
||||
"headline": "Choose an account",
|
||||
"@headline": {
|
||||
"context": "pages/account_selection.html:17:27-59"
|
||||
},
|
||||
"sign_in_as": "Sign in as %(username)s",
|
||||
"@sign_in_as": {
|
||||
"context": "pages/account_selection.html:29:11-65"
|
||||
}
|
||||
},
|
||||
"upstream_oauth2": {
|
||||
"link_mismatch": {
|
||||
"heading": "This upstream account is already linked to another account.",
|
||||
|
||||
Reference in New Issue
Block a user