From a87b3f0e5e187ca7285ac32ddfee1bc18f2e1601 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Wed, 24 Jun 2026 18:22:25 +0200 Subject: [PATCH] Add a welcome-back mode to /login for trusted id_token_hint targets When /login continues an authorization grant carrying a trusted target_user_id (the resolved outcome of a verified id_token_hint), the logged-out branch now streamlines re-authentication: - if the target's last authentication was via an upstream provider, auto-redirect to that provider's authorize endpoint; - otherwise render a dedicated "welcome back" template naming the target user with a password field, posting to the existing /login POST. Untrusted login_hint keeps today's generic pre-fill behaviour. Also deletes the orphaned templates/pages/reauth.html. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/handlers/src/views/login.rs | 1033 ++++++++++++++++++++++- crates/templates/src/context.rs | 62 ++ crates/templates/src/lib.rs | 8 +- templates/pages/login/welcome_back.html | 67 ++ templates/pages/reauth.html | 54 -- translations/en.json | 18 +- 6 files changed, 1167 insertions(+), 75 deletions(-) create mode 100644 templates/pages/login/welcome_back.html delete mode 100644 templates/pages/reauth.html diff --git a/crates/handlers/src/views/login.rs b/crates/handlers/src/views/login.rs index 98fe1a88e..9ba62fee3 100644 --- a/crates/handlers/src/views/login.rs +++ b/crates/handlers/src/views/login.rs @@ -18,18 +18,19 @@ use mas_axum_utils::{ cookies::CookieJar, csrf::{CsrfExt, ProtectedForm}, }; -use mas_data_model::{BoxClock, BoxRng, Clock}; +use mas_data_model::{AuthenticationMethod, BoxClock, BoxRng, Clock}; use mas_i18n::DataLocale; use mas_matrix::HomeserverConnection; -use mas_router::{UpstreamOAuth2Authorize, UrlBuilder}; +use mas_router::{PostAuthAction, UpstreamOAuth2Authorize, UrlBuilder}; use mas_storage::{ BoxRepository, RepositoryAccess, - upstream_oauth2::UpstreamOAuthProviderRepository, + oauth2::OAuth2AuthorizationGrantRepository, + upstream_oauth2::{UpstreamOAuthProviderRepository, UpstreamOAuthSessionRepository}, user::{BrowserSessionRepository, UserPasswordRepository, UserRepository}, }; use mas_templates::{ FieldError, FormError, FormState, LoginContext, LoginFormField, TemplateContext, Templates, - ToFormState, + ToFormState, WelcomeBackContext, }; use opentelemetry::{Key, KeyValue, metrics::Counter}; use rand::Rng; @@ -105,6 +106,30 @@ pub(crate) async fn get( return Ok((cookie_jar, reply).into_response()); } + // If we're continuing an authorization grant that carries a trusted target + // (the resolved outcome of a verified `id_token_hint`), streamline the + // re-authentication: either auto-redirect to the upstream provider the + // target last used, or render a "welcome back" page pre-filled with the + // target's username. `maybe_welcome_back` hands the cookie jar back + // untouched when it doesn't handle the request. + let (cookie_jar, welcome_back_form_state) = match maybe_welcome_back( + &mut rng, + &clock, + locale.clone(), + &url_builder, + &templates, + &mut repo, + &site_config, + &homeserver, + cookie_jar, + &query, + ) + .await? + { + Ok(response) => return Ok(response), + Err((cookie_jar, form_state)) => (cookie_jar, form_state), + }; + let providers = repo.upstream_oauth_provider().all_enabled().await?; // If password-based login is disabled, and there is only one upstream provider, @@ -124,7 +149,7 @@ pub(crate) async fn get( render( locale, cookie_jar, - FormState::default(), + welcome_back_form_state, query, &mut repo, &clock, @@ -178,7 +203,7 @@ pub(crate) async fn post( if !form_state.is_valid() { tracing::warn!("Invalid login form: {form_state:?}"); PASSWORD_LOGIN_COUNTER.add(1, &[KeyValue::new(RESULT, "error")]); - return render( + return render_credential_failure( locale, cookie_jar, form_state, @@ -205,7 +230,7 @@ pub(crate) async fn post( tracing::warn!(username, "User not found"); let form_state = form_state.with_error_on_form(FormError::InvalidCredentials); PASSWORD_LOGIN_COUNTER.add(1, &[KeyValue::new(RESULT, "error")]); - return render( + return render_credential_failure( locale, cookie_jar, form_state, @@ -226,7 +251,7 @@ pub(crate) async fn post( tracing::warn!(error = &e as &dyn std::error::Error, "ratelimit exceeded"); let form_state = form_state.with_error_on_form(FormError::RateLimitExceeded); PASSWORD_LOGIN_COUNTER.add(1, &[KeyValue::new(RESULT, "error")]); - return render( + return render_credential_failure( locale, cookie_jar, form_state, @@ -249,7 +274,7 @@ pub(crate) async fn post( tracing::warn!(username, "No password for user"); let form_state = form_state.with_error_on_form(FormError::InvalidCredentials); PASSWORD_LOGIN_COUNTER.add(1, &[KeyValue::new(RESULT, "error")]); - return render( + return render_credential_failure( locale, cookie_jar, form_state, @@ -295,7 +320,7 @@ pub(crate) async fn post( tracing::warn!(username, "Failed to verify/upgrade password for user"); let form_state = form_state.with_error_on_form(FormError::InvalidCredentials); PASSWORD_LOGIN_COUNTER.add(1, &[KeyValue::new(RESULT, "mismatch")]); - return render( + return render_credential_failure( locale, cookie_jar, form_state, @@ -400,6 +425,283 @@ async fn get_user_by_email_or_by_username( Ok(user) } +/// How a trusted welcome-back target last authenticated, recovered from the +/// grant's `target_user_session_id` (the `BrowserSession` the `id_token_hint`'s +/// `sid` claim pointed at). +enum WelcomeBackMethod { + /// The most-recent authentication on that session was a password login. + Password, + /// The most-recent authentication on that session was through this + /// upstream provider, which is still enabled. + Upstream { provider_id: ulid::Ulid }, + /// The method can't be confirmed: a stale/absent session, no + /// authentication recorded, or a since-disabled provider (whose + /// auto-redirect would dead-end at `ProviderNotFound`). + Unknown, +} + +/// A trusted welcome-back target: the user resolved from a verified +/// `id_token_hint`, how they last authenticated, and the grant being +/// continued. +struct WelcomeBackTarget { + user: mas_data_model::User, + method: WelcomeBackMethod, + grant_id: ulid::Ulid, +} + +/// Resolve the trusted welcome-back target of the grant this `/login` request +/// continues, if any. +/// +/// Returns `None` when there is nothing to streamline: the request doesn't +/// continue an authorization grant, the grant carries no *trusted* +/// `target_user_id` (an untrusted `login_hint` never resolves one), or the +/// target user vanished since authorize time. +/// +/// This is the single resolution shared by the GET ([`maybe_welcome_back`]) +/// and POST ([`render_credential_failure`]) paths so they stay in lockstep. +async fn welcome_back_target( + repo: &mut BoxRepository, + query: &OptionalPostAuthAction, +) -> Result, InternalError> { + // Only authorization grants carry a resolved target. + let Some(PostAuthAction::ContinueAuthorizationGrant { id }) = query.post_auth_action else { + return Ok(None); + }; + + let Some(grant) = repo.oauth2_authorization_grant().lookup(id).await? else { + return Ok(None); + }; + + // Only act on a *trusted* target (`id_token_hint`); an untrusted `login_hint` + // has no resolved `target_user_id`. + let Some(target_user_id) = grant.target_user_id else { + return Ok(None); + }; + + let Some(user) = repo.user().lookup(target_user_id).await? else { + // The target user vanished since authorize time. + return Ok(None); + }; + + let method = welcome_back_method(repo, &grant).await?; + + Ok(Some(WelcomeBackTarget { + user, + method, + grant_id: id, + })) +} + +/// Recover how the trusted target last authenticated, following the grant's +/// `target_user_session_id` to its most-recent [`Authentication`]. +/// +/// [`Authentication`]: mas_data_model::Authentication +async fn welcome_back_method( + repo: &mut BoxRepository, + grant: &mas_data_model::AuthorizationGrant, +) -> Result { + let Some(session_id) = grant.target_user_session_id else { + return Ok(WelcomeBackMethod::Unknown); + }; + let Some(session) = repo.browser_session().lookup(session_id).await? else { + return Ok(WelcomeBackMethod::Unknown); + }; + let Some(authentication) = repo + .browser_session() + .get_last_authentication(&session) + .await? + else { + return Ok(WelcomeBackMethod::Unknown); + }; + + let upstream_oauth2_session_id = match authentication.authentication_method { + AuthenticationMethod::Password { .. } => return Ok(WelcomeBackMethod::Password), + AuthenticationMethod::UpstreamOAuth2 { + upstream_oauth2_session_id, + } => upstream_oauth2_session_id, + AuthenticationMethod::Unknown => return Ok(WelcomeBackMethod::Unknown), + }; + + let Some(upstream_session) = repo + .upstream_oauth_session() + .lookup(upstream_oauth2_session_id) + .await? + else { + return Ok(WelcomeBackMethod::Unknown); + }; + // Only auto-redirect to a provider that is currently enabled; a + // since-disabled provider would make the redirect hit `ProviderNotFound`. + let Some(provider) = repo + .upstream_oauth_provider() + .lookup(upstream_session.provider_id) + .await? + else { + return Ok(WelcomeBackMethod::Unknown); + }; + if !provider.enabled() { + return Ok(WelcomeBackMethod::Unknown); + } + + Ok(WelcomeBackMethod::Upstream { + provider_id: provider.id, + }) +} + +/// Decide whether `/login` should render the dedicated welcome-back *password* +/// page, and for whom: a trusted target whose most-recent authentication is +/// *confirmed* to be a password login, on an instance with password login +/// enabled (the page posts to the `/login` password handler, which 405s when +/// it is disabled). +async fn welcome_back_password_target( + repo: &mut BoxRepository, + site_config: &SiteConfig, + query: &OptionalPostAuthAction, +) -> Result, InternalError> { + if !site_config.password_login_enabled { + return Ok(None); + } + + Ok(welcome_back_target(repo, query) + .await? + .filter(|target| matches!(target.method, WelcomeBackMethod::Password)) + .map(|target| target.user)) +} + +/// Build the [`WelcomeBackContext`] (including the consent-style user card) for +/// the given target `user` and `form_state`, and render the welcome-back +/// password page. +#[expect(clippy::too_many_arguments)] +async fn render_welcome_back( + rng: impl Rng, + clock: &impl Clock, + locale: DataLocale, + templates: &Templates, + repo: &mut BoxRepository, + homeserver: &dyn HomeserverConnection, + cookie_jar: CookieJar, + query: &OptionalPostAuthAction, + user: &mas_data_model::User, + form_state: FormState, +) -> Result { + let (csrf_token, cookie_jar) = cookie_jar.csrf_token(clock, rng); + + // Build the consent-style user card for the target, mirroring `consent.rs`. + let matrix_user = crate::best_effort_matrix_user(homeserver, &user.username).await; + + let ctx = + WelcomeBackContext::new(user.username.clone(), matrix_user).with_form_state(form_state); + let next = query + .load_context(repo) + .await + .map_err(InternalError::from_anyhow)?; + let ctx = if let Some(next) = next { + ctx.with_post_action(next) + } else { + ctx + }; + let ctx = ctx.with_csrf(csrf_token.form_value()).with_language(locale); + + let content = templates.render_welcome_back(&ctx)?; + Ok((cookie_jar, Html(content)).into_response()) +} + +/// Handle the "welcome back" mode of `/login`. +/// +/// When `/login` continues a `ContinueAuthorizationGrant` whose grant carries a +/// trusted `target_user_id` (the resolved outcome of a verified +/// `id_token_hint`), streamline re-authentication for that user: +/// +/// * if the target's most-recent authentication was via an upstream provider +/// (read off the `BrowserSession` the `sid` claim pointed at) that is still +/// enabled, auto-redirect to that provider's authorize endpoint — mirroring +/// the password-disabled-single-provider shortcut in [`get`]; otherwise +/// * if the target's most-recent authentication was *confirmed* to be a +/// password login and the instance has password login enabled, render the +/// dedicated "welcome back" template (via [`render_welcome_back`]), posting +/// to the existing `/login` POST. +/// +/// Anything else — no grant, no `target_user_id`, a vanished target, a +/// stale/unknown `sid` (so the most-recent method can't be confirmed), a +/// since-disabled upstream provider, or a password target on a +/// password-login-disabled instance — falls through to the caller's normal +/// `/login` handling. In that case the cookie jar and a form state (pre-filled +/// with the target's username when we know it, so the generic page renders both +/// password and upstream buttons for the right account) are handed back as +/// `Err((cookie_jar, form_state))`. +// Mirrors the argument list of the surrounding `/login` handler; threading a +// struct here would be more ceremony than it's worth. +#[expect(clippy::too_many_arguments)] +async fn maybe_welcome_back( + rng: &mut (impl Rng + Send), + clock: &impl Clock, + locale: DataLocale, + url_builder: &UrlBuilder, + templates: &Templates, + repo: &mut BoxRepository, + site_config: &SiteConfig, + homeserver: &dyn HomeserverConnection, + cookie_jar: CookieJar, + query: &OptionalPostAuthAction, +) -> Result)>, InternalError> { + let Some(WelcomeBackTarget { + user, + method, + grant_id, + }) = welcome_back_target(repo, query).await? + else { + return Ok(Err((cookie_jar, FormState::default()))); + }; + + // From here on we know the target user, so any fall-through should pre-fill + // the generic login page with their username. + let fallback_form_state = || { + let mut form_state = FormState::::default(); + form_state.set_value(LoginFormField::Username, Some(user.username.clone())); + form_state + }; + + match method { + // The `sid` claim resolved to a still-existing session whose + // most-recent authentication was via an upstream provider that is + // still enabled: auto-redirect to that provider — mirroring the + // password-disabled single-provider shortcut in `get`. + WelcomeBackMethod::Upstream { provider_id } => { + let destination = UpstreamOAuth2Authorize::new(provider_id) + .and_then(PostAuthAction::ContinueAuthorizationGrant { id: grant_id }); + Ok(Ok( + (cookie_jar, url_builder.redirect(&destination)).into_response() + )) + } + + // Confirmed password last-authentication on a password-login-enabled + // instance: render the dedicated welcome-back password page. + WelcomeBackMethod::Password if site_config.password_login_enabled => { + let response = render_welcome_back( + rng, + clock, + locale, + templates, + repo, + homeserver, + cookie_jar, + query, + &user, + FormState::default(), + ) + .await?; + Ok(Ok(response)) + } + + // A stale/absent session, an unknown method, or a + // password-login-disabled instance: fall through to the generic login + // page (which renders both password and upstream buttons per the + // account config) rather than an unusable password-only form. + WelcomeBackMethod::Password | WelcomeBackMethod::Unknown => { + Ok(Err((cookie_jar, fallback_form_state()))) + } + } +} + fn handle_login_hint( mut ctx: LoginContext, query_login_hint: &QueryLoginHint, @@ -460,6 +762,52 @@ async fn render( Ok((cookie_jar, Html(content)).into_response()) } +/// Re-render the appropriate page after a credential failure in the POST +/// handler. +/// +/// When the request is a welcome-back *password* page (per the shared +/// [`welcome_back_password_target`] predicate), re-render that page with the +/// error so the End-User stays on the welcome-back flow. Otherwise fall back to +/// the generic login page via [`render`], preserving the previous behavior for +/// non-welcome-back requests. +#[expect(clippy::too_many_arguments)] +async fn render_credential_failure( + locale: DataLocale, + cookie_jar: CookieJar, + form_state: FormState, + query: OptionalPostAuthAction, + repo: &mut BoxRepository, + clock: &impl Clock, + rng: &mut (impl Rng + Send), + templates: &Templates, + homeserver: &dyn HomeserverConnection, + site_config: &SiteConfig, + query_login_hint: QueryLoginHint, +) -> Result { + let maybe_user = welcome_back_password_target(repo, site_config, &query).await?; + if let Some(user) = maybe_user { + return render_welcome_back( + rng, clock, locale, templates, repo, homeserver, cookie_jar, &query, &user, form_state, + ) + .await; + } + + render( + locale, + cookie_jar, + form_state, + query, + repo, + clock, + rng, + templates, + homeserver, + site_config, + query_login_hint, + ) + .await +} + #[cfg(test)] mod test { use hyper::{ @@ -467,17 +815,25 @@ mod test { header::{CONTENT_TYPE, LOCATION, X_FRAME_OPTIONS}, }; use mas_data_model::{ - UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderOnBackchannelLogout, - UpstreamOAuthProviderTokenAuthMethod, + AuthorizationCode, UpstreamOAuthProviderClaimsImports, + UpstreamOAuthProviderOnBackchannelLogout, UpstreamOAuthProviderTokenAuthMethod, }; use mas_iana::jose::JsonWebSignatureAlg; - use mas_router::Route; + use mas_router::{PostAuthAction, Route, SimpleRoute, UpstreamOAuth2Authorize}; use mas_storage::{ RepositoryAccess, - upstream_oauth2::{UpstreamOAuthProviderParams, UpstreamOAuthProviderRepository}, + oauth2::{OAuth2AuthorizationGrantRepository, OAuth2ClientRepository}, + upstream_oauth2::{ + UpstreamOAuthLinkRepository, UpstreamOAuthProviderParams, + UpstreamOAuthProviderRepository, UpstreamOAuthSessionRepository, + }, + user::{BrowserSessionRepository, UserPasswordRepository, UserRepository}, }; use mas_templates::escape_html; - use oauth2_types::scope::OPENID; + use oauth2_types::{ + requests::ResponseMode, + scope::{OPENID, Scope}, + }; use sqlx::PgPool; use zeroize::Zeroizing; @@ -568,6 +924,653 @@ mod test { browser_session } + /// Register an `OAuth2` client and return its id, so we can attach an + /// authorization grant to it. + async fn provision_client(state: &TestState) -> mas_data_model::Client { + 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(); + + let mut repo = state.repository().await.unwrap(); + let client = repo + .oauth2_client() + .find_by_client_id(&response.client_id) + .await + .unwrap() + .unwrap(); + repo.save().await.unwrap(); + client + } + + /// Add an authorization grant with optional trusted target. + async fn add_grant( + state: &TestState, + repo: &mut mas_storage::BoxRepository, + client: &mas_data_model::Client, + login_hint: Option, + target_user: Option<&mas_data_model::User>, + target_user_session: Option<&mas_data_model::BrowserSession>, + ) -> mas_data_model::AuthorizationGrant { + repo.oauth2_authorization_grant() + .add( + &mut state.rng(), + &state.clock, + client, + "https://example.com/redirect".parse().unwrap(), + Scope::from_iter([OPENID]), + Some(AuthorizationCode { + code: "thisisaverysecurecode".to_owned(), + pkce: None, + }), + Some("state".to_owned()), + Some("nonce".to_owned()), + ResponseMode::Query, + false, + login_hint, + None, + std::collections::BTreeMap::new(), + target_user, + target_user_session, + ) + .await + .unwrap() + } + + fn provider_params(issuer: &str) -> UpstreamOAuthProviderParams { + UpstreamOAuthProviderParams { + issuer: Some(issuer.to_owned()), + human_name: Some("Example Ltd.".to_owned()), + brand_name: None, + scope: Scope::from_iter([OPENID]), + token_endpoint_auth_method: UpstreamOAuthProviderTokenAuthMethod::None, + token_endpoint_signing_alg: None, + id_token_signed_response_alg: JsonWebSignatureAlg::Rs256, + fetch_userinfo: false, + userinfo_signed_response_alg: None, + client_id: "client".to_owned(), + encrypted_client_secret: None, + claims_imports: UpstreamOAuthProviderClaimsImports::default(), + authorization_endpoint_override: None, + token_endpoint_override: None, + userinfo_endpoint_override: None, + jwks_uri_override: None, + discovery_mode: mas_data_model::UpstreamOAuthProviderDiscoveryMode::Oidc, + pkce_mode: mas_data_model::UpstreamOAuthProviderPkceMode::Auto, + response_mode: None, + additional_authorization_parameters: Vec::new(), + forward_login_hint: false, + ui_order: 0, + on_backchannel_logout: UpstreamOAuthProviderOnBackchannelLogout::DoNothing, + registration_token_required: false, + } + } + + /// A trusted target whose last authentication was a password login should + /// render the dedicated "welcome back" page, naming the target and + /// pre-filling their username. + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_welcome_back_password_target(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + + // Provision a user "alice" with a password, and a browser session + // authenticated by that password. + let user = user_with_password(&state, "alice", "hunter2").await; + let mut repo = state.repository().await.unwrap(); + let user_password = repo.user_password().active(&user).await.unwrap().unwrap(); + let browser_session = repo + .browser_session() + .add(&mut state.rng(), &state.clock, &user, None) + .await + .unwrap(); + repo.browser_session() + .authenticate_with_password( + &mut state.rng(), + &state.clock, + &browser_session, + &user_password, + ) + .await + .unwrap(); + + let client = provision_client(&state).await; + let grant = add_grant( + &state, + &mut repo, + &client, + None, + Some(&user), + Some(&browser_session), + ) + .await; + repo.save().await.unwrap(); + + // GET /login continuing the grant, with no active session cookie. + let url = mas_router::Login::and_continue_grant(grant.id).path_and_query(); + let response = state.request(Request::get(&*url).empty()).await; + response.assert_status(StatusCode::OK); + response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); + + let body = response.body(); + // The welcome-back page shows the confirmation headline... + assert!( + body.contains("Confirm it's you"), + "Expected welcome-back headline, body: {body}" + ); + // ...and the consent-style user card showing the target's mxid. + assert!( + body.contains("@alice:example.com"), + "Expected the user card to show the mxid, body: {body}" + ); + // ...and pre-fills the username in a hidden field, distinct from the + // generic login form which has a visible, empty username input. + assert!( + body.contains(r#" untrusted. + let grant = add_grant( + &state, + &mut repo, + &client, + Some("mxid:@alice:example.com".to_owned()), + None, + None, + ) + .await; + repo.save().await.unwrap(); + drop(user); + + // GET /login continuing the grant, passing the login_hint on the URL. + let url = mas_router::Login::and_continue_grant(grant.id) + .with_login_hint("mxid:@alice:example.com".to_owned()) + .path_and_query(); + let response = state.request(Request::get(&*url).empty()).await; + response.assert_status(StatusCode::OK); + response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); + + let body = response.body(); + // Generic login form with the username pre-filled... + assert!( + body.contains(r#"value="alice""#), + "Expected username pre-filled, body: {body}" + ); + // ...and definitely NOT the welcome-back page. + assert!( + !body.contains("Confirm it's you"), + "Should not render the welcome-back page for an untrusted hint" + ); + } + + /// On a password-login-disabled instance, a trusted *password* target must + /// NOT render the welcome-back password page (whose POST would 405); it + /// should fall through to the generic login page instead. + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_welcome_back_password_target_password_disabled(pool: PgPool) { + setup(); + let state = TestState::from_pool_with_site_config( + pool, + SiteConfig { + password_login_enabled: false, + ..test_site_config() + }, + ) + .await + .unwrap(); + + // Provision a user "alice" and a browser session whose most-recent + // authentication is a password login. The password manager is disabled + // in this site config, so insert the password row with a dummy hash + // directly rather than going through `PasswordManager::hash`. + let mut repo = state.repository().await.unwrap(); + let user = repo + .user() + .add(&mut state.rng(), &state.clock, "alice".to_owned()) + .await + .unwrap(); + let user_password = repo + .user_password() + .add( + &mut state.rng(), + &state.clock, + &user, + 1, + "$argon2id$dummy".to_owned(), + None, + ) + .await + .unwrap(); + let browser_session = repo + .browser_session() + .add(&mut state.rng(), &state.clock, &user, None) + .await + .unwrap(); + repo.browser_session() + .authenticate_with_password( + &mut state.rng(), + &state.clock, + &browser_session, + &user_password, + ) + .await + .unwrap(); + + let client = provision_client(&state).await; + let grant = add_grant( + &state, + &mut repo, + &client, + None, + Some(&user), + Some(&browser_session), + ) + .await; + repo.save().await.unwrap(); + + let url = mas_router::Login::and_continue_grant(grant.id).path_and_query(); + let response = state.request(Request::get(&*url).empty()).await; + response.assert_status(StatusCode::OK); + response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); + + let body = response.body(); + // It must NOT be the welcome-back password page. + assert!( + !body.contains("Confirm it's you"), + "Should not render the welcome-back password page when password login is disabled, body: {body}" + ); + // With no upstream providers configured, the generic login page shows + // the "no login methods" message. + assert!( + body.contains("No login methods available"), + "Expected the generic login page, body: {body}" + ); + } + + /// A trusted target whose `target_user_session_id` no longer resolves to a + /// session (stale/reaped `sid`) can't have its most-recent method + /// confirmed, so we must fall through to the generic login page pre-filled + /// with the target's username — NOT the (upstream-button-less) welcome-back + /// password page. + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_welcome_back_stale_session_generic_login(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + + // Provision a target user and a real browser session, attach the grant + // to it, then delete the session row. The grant's + // `target_user_session_id` foreign key is `ON DELETE SET NULL`, so the + // stored grant ends up pointing at no session — exactly the reaped/stale + // `sid` case, where the most-recent method can't be confirmed. + let user = user_with_password(&state, "alice", "hunter2").await; + let mut repo = state.repository().await.unwrap(); + let browser_session = repo + .browser_session() + .add(&mut state.rng(), &state.clock, &user, None) + .await + .unwrap(); + + let client = provision_client(&state).await; + let grant = add_grant( + &state, + &mut repo, + &client, + None, + Some(&user), + Some(&browser_session), + ) + .await; + repo.save().await.unwrap(); + + // Reap the session: the FK nulls the grant's `target_user_session_id`. + sqlx::query("DELETE FROM user_sessions WHERE user_session_id = $1") + .bind(sqlx::types::Uuid::from(browser_session.id)) + .execute(&state.repository_factory.pool()) + .await + .unwrap(); + + let url = mas_router::Login::and_continue_grant(grant.id).path_and_query(); + let response = state.request(Request::get(&*url).empty()).await; + response.assert_status(StatusCode::OK); + response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); + + let body = response.body(); + // Generic login page, pre-filled with the target's username... + assert!( + body.contains(r#"value="alice""#), + "Expected username pre-filled on the generic login page, body: {body}" + ); + // ...NOT the welcome-back password page. + assert!( + !body.contains("Confirm it's you"), + "Should not render the welcome-back page for a stale/unknown sid, body: {body}" + ); + } + + /// A trusted *upstream* target whose provider has since been disabled must + /// NOT auto-redirect to it (that would dead-end at `ProviderNotFound`); it + /// should fall through to the generic login page instead. + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_welcome_back_upstream_target_provider_disabled(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let mut rng = state.rng(); + + let mut repo = state.repository().await.unwrap(); + + let user = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + + let provider = repo + .upstream_oauth_provider() + .add( + &mut rng, + &state.clock, + provider_params("https://upstream.example/"), + ) + .await + .unwrap(); + + let upstream_session = repo + .upstream_oauth_session() + .add( + &mut rng, + &state.clock, + &provider, + "state".to_owned(), + None, + None, + ) + .await + .unwrap(); + let link = repo + .upstream_oauth_link() + .add( + &mut rng, + &state.clock, + &provider, + "subject".to_owned(), + None, + ) + .await + .unwrap(); + let upstream_session = repo + .upstream_oauth_session() + .complete_with_link( + &state.clock, + upstream_session, + &link, + None, + None, + None, + None, + ) + .await + .unwrap(); + + let browser_session = repo + .browser_session() + .add(&mut rng, &state.clock, &user, None) + .await + .unwrap(); + repo.browser_session() + .authenticate_with_upstream(&mut rng, &state.clock, &browser_session, &upstream_session) + .await + .unwrap(); + + // Disable the provider after the fact. + repo.upstream_oauth_provider() + .disable(&state.clock, provider) + .await + .unwrap(); + + let client = provision_client(&state).await; + let grant = add_grant( + &state, + &mut repo, + &client, + None, + Some(&user), + Some(&browser_session), + ) + .await; + repo.save().await.unwrap(); + + let url = mas_router::Login::and_continue_grant(grant.id).path_and_query(); + let response = state.request(Request::get(&*url).empty()).await; + // It must NOT redirect to the disabled provider. + response.assert_status(StatusCode::OK); + response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); + + let body = response.body(); + // Falls through to the generic login page pre-filled with the username. + assert!( + body.contains(r#"value="alice""#), + "Expected username pre-filled on the generic login page, body: {body}" + ); + assert!( + !body.contains("Confirm it's you"), + "Should not render the welcome-back page, body: {body}" + ); + } + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] async fn test_password_disabled(pool: PgPool) { setup(); diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index b1ad2c38b..63c848d02 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -616,6 +616,68 @@ impl LoginContext { } } +/// Context used by the `login/welcome_back.html` template +/// +/// Rendered for the streamlined re-authentication of a known, trusted target +/// user (resolved from an `id_token_hint`) when their last authentication was +/// password-based. +#[derive(Serialize)] +pub struct WelcomeBackContext { + username: String, + matrix_user: MatrixUser, + form: FormState, + next: Option, +} + +impl TemplateContext for WelcomeBackContext { + fn sample( + _now: chrono::DateTime, + _rng: &mut R, + _locales: &[DataLocale], + ) -> BTreeMap + where + Self: Sized, + { + sample_list(vec![WelcomeBackContext { + username: "alice".to_owned(), + matrix_user: MatrixUser { + mxid: "@alice:example.com".to_owned(), + display_name: Some("Alice".to_owned()), + }, + form: FormState::default(), + next: None, + }]) + } +} + +impl WelcomeBackContext { + /// Create a new context for the given target username and Matrix user + #[must_use] + pub fn new(username: String, matrix_user: MatrixUser) -> Self { + Self { + username, + matrix_user, + form: FormState::default(), + next: None, + } + } + + /// Set the form state + #[must_use] + pub fn with_form_state(self, form: FormState) -> Self { + Self { form, ..self } + } + + /// Add a post authentication action to the context + #[must_use] + pub fn with_post_action(self, context: PostAuthContext) -> Self { + Self { + next: Some(context), + ..self + } + } +} + /// Fields of the registration form #[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, PartialEq, Eq)] #[serde(rename_all = "snake_case")] diff --git a/crates/templates/src/lib.rs b/crates/templates/src/lib.rs index 18eaa74e4..dda5b09f8 100644 --- a/crates/templates/src/lib.rs +++ b/crates/templates/src/lib.rs @@ -1,4 +1,4 @@ -// Copyright 2026 Element Creations Ltd. +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // @@ -51,7 +51,8 @@ pub use self::{ RegisterStepsRegistrationTokenFormField, RegisterStepsVerifyEmailContext, RegisterStepsVerifyEmailFormField, SiteBranding, SiteConfigExt, SiteFeatures, TemplateContext, UpstreamExistingLinkContext, UpstreamRegister, UpstreamRegisterFormField, - UpstreamSuggestLink, WithCaptcha, WithCsrf, WithLanguage, WithOptionalSession, WithSession, + UpstreamSuggestLink, WelcomeBackContext, WithCaptcha, WithCsrf, WithLanguage, + WithOptionalSession, WithSession, }, forms::{FieldError, FormError, FormField, FormState, ToFormState}, }; @@ -371,6 +372,9 @@ register_templates! { /// Render the login page pub fn render_login(WithLanguage>) { "pages/login.html" } + /// Render the streamlined "welcome back" re-authentication page + pub fn render_welcome_back(WithLanguage>) { "pages/login/welcome_back.html" } + /// Render the registration page pub fn render_register(WithLanguage>) { "pages/register/index.html" } diff --git a/templates/pages/login/welcome_back.html b/templates/pages/login/welcome_back.html new file mode 100644 index 000000000..599b0c64e --- /dev/null +++ b/templates/pages/login/welcome_back.html @@ -0,0 +1,67 @@ +{# +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 %} +
+
+ {{ icon.user_profile_solid() }} +
+ +
+

{{ _("mas.login.welcome_back.headline") }}

+

{{ _("mas.login.welcome_back.description") }}

+
+
+ +
+ {% set initial -%} + {%- if matrix_user.display_name -%}{{- matrix_user.display_name[0] | upper -}}{%- else -%}{{- matrix_user.mxid[1] | upper -}}{%- endif -%} + {%- endset %} +
+
{{ initial }}
+
+
{{ matrix_user.display_name or username }}
+
{{ matrix_user.mxid }}
+
+
+ +
+ + + + {% if form.errors is not empty %} + {% for error in form.errors %} +
+ {{ errors.form_error_message(error=error) }} +
+ {% endfor %} + {% endif %} + + {% call(f) field.field(label=_("common.password"), name="password", form_state=form) %} + + {% endcall %} + + {% if features.account_recovery %} + {{ button.link_text(text=_("mas.login.forgot_password"), href="/recover", class="self-center") }} + {% endif %} + + {{ button.button(text=_("action.continue")) }} +
+ + {% if next and next.kind == "continue_authorization_grant" %} + {{ back_to_client.link( + text=_("action.cancel"), + destructive=True, + uri=next.grant.redirect_uri, + mode=next.grant.response_mode, + params=dict(error="access_denied", state=next.grant.state) + ) }} + {% endif %} +
+{% endblock content %} diff --git a/templates/pages/reauth.html b/templates/pages/reauth.html deleted file mode 100644 index 883a8f653..000000000 --- a/templates/pages/reauth.html +++ /dev/null @@ -1,54 +0,0 @@ -{# -Copyright 2024, 2025 New Vector Ltd. -Copyright 2021-2024 The Matrix.org Foundation C.I.C. - -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 %} -
-
- {{ icon.lock() }} -
- -
-

Hi {{ current_session.user.username }}

-

To continue, please verify it's you:

-
-
- -
-
- - {# TODO: errors #} - - {% call(f) field.field(label=_("common.password"), name="password", form_state=form) %} - - {% endcall %} - - {{ button.button(text=_("action.continue")) }} -
- - {% if next and next.kind == "continue_authorization_grant" %} - {{ back_to_client.link( - text=_("action.cancel"), - destructive=True, - uri=next.grant.redirect_uri, - mode=next.grant.response_mode, - params=dict(error="access_denied", state=next.grant.state) - ) }} - {% endif %} - -
-

- Not {{ current_session.user.username }}? -

- - {% set post_logout_action = next["params"] | default({}) %} - {{ logout.button(text="Sign out", csrf_token=csrf_token, post_logout_action=post_logout_action, as_link=true) }} -
-
-{% endblock content %} diff --git a/translations/en.json b/translations/en.json index e09768a7e..e0ce33465 100644 --- a/translations/en.json +++ b/translations/en.json @@ -6,11 +6,11 @@ }, "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/policy_violation.html:70:15-33, pages/reauth.html:37:13-31" + "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" }, "continue": "Continue", "@continue": { - "context": "form_post.html:25:28-48, pages/consent.html:71:28-48, pages/device_link.html:42:28-48, pages/login.html:68:30-50, pages/reauth.html:32:28-48, pages/recovery/start.html:38:26-46, pages/register/password.html:77:26-46, pages/register/steps/display_name.html:43:28-48, pages/register/steps/registration_token.html:41:28-48, pages/register/steps/verify_email.html:51:26-46, pages/sso.html:52:28-48" + "context": "form_post.html:25:28-48, pages/consent.html:71:28-48, pages/device_link.html:42:28-48, pages/login.html:68:30-50, pages/login/welcome_back.html:54:28-48, pages/recovery/start.html:38:26-46, pages/register/password.html:77:26-46, pages/register/steps/display_name.html:43:28-48, pages/register/steps/registration_token.html:41:28-48, pages/register/steps/verify_email.html:51:26-46, pages/sso.html:52:28-48" }, "create_account": "Create Account", "@create_account": { @@ -91,7 +91,7 @@ }, "password": "Password", "@password": { - "context": "pages/login.html:56:37-57, pages/reauth.html:28:35-55, pages/register/password.html:45:33-53" + "context": "pages/login.html:56:37-57, pages/login/welcome_back.html:46:35-55, pages/register/password.html:45:33-53" }, "password_confirm": "Confirm password", "@password_confirm": { @@ -466,7 +466,7 @@ }, "forgot_password": "Forgot password?", "@forgot_password": { - "context": "pages/login.html:61:35-65", + "context": "pages/login.html:61:35-65, pages/login/welcome_back.html:51:33-63", "description": "On the login page, link to the account recovery process" }, "headline": "Sign in", @@ -490,6 +490,16 @@ "username_or_email": "Username or Email", "@username_or_email": { "context": "pages/login.html:47:39-71" + }, + "welcome_back": { + "description": "Re-enter your password to continue:", + "@description": { + "context": "pages/login/welcome_back.html:18:25-64" + }, + "headline": "Confirm it's you", + "@headline": { + "context": "pages/login/welcome_back.html:17:27-63" + } } }, "navbar": {