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) <noreply@anthropic.com>
This commit is contained in:
Quentin Gliech
2026-07-29 18:36:42 +02:00
co-authored by Claude Opus 4.8
parent 1b248e3e10
commit a87b3f0e5e
6 changed files with 1167 additions and 75 deletions
File diff suppressed because it is too large Load Diff
+62
View File
@@ -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<LoginFormField>,
next: Option<PostAuthContext>,
}
impl TemplateContext for WelcomeBackContext {
fn sample<R: Rng>(
_now: chrono::DateTime<Utc>,
_rng: &mut R,
_locales: &[DataLocale],
) -> BTreeMap<SampleIdentifier, Self>
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<LoginFormField>) -> 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")]
+6 -2
View File
@@ -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<WithCsrf<LoginContext>>) { "pages/login.html" }
/// Render the streamlined "welcome back" re-authentication page
pub fn render_welcome_back(WithLanguage<WithCsrf<WelcomeBackContext>>) { "pages/login/welcome_back.html" }
/// Render the registration page
pub fn render_register(WithLanguage<WithCsrf<RegisterContext>>) { "pages/register/index.html" }
+67
View File
@@ -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 %}
<header class="page-heading">
<div class="icon">
{{ icon.user_profile_solid() }}
</div>
<div class="header">
<h1 class="title">{{ _("mas.login.welcome_back.headline") }}</h1>
<p class="text">{{ _("mas.login.welcome_back.description") }}</p>
</div>
</header>
<main class="flex flex-col gap-6">
{% set initial -%}
{%- if matrix_user.display_name -%}{{- matrix_user.display_name[0] | upper -}}{%- else -%}{{- matrix_user.mxid[1] | upper -}}{%- endif -%}
{%- endset %}
<section class="flex items-center p-4 gap-4 border border-[var(--cpd-color-gray-400)] rounded-xl">
<div class="avatar-placeholder" data-color="{{ matrix_user.mxid | id_color_hash }}">{{ initial }}</div>
<div class="flex flex-col">
<div class="text-primary cpd-text-body-lg-semibold">{{ matrix_user.display_name or username }}</div>
<div class="text-secondary cpd-text-body-md-regular">{{ matrix_user.mxid }}</div>
</div>
</section>
<form method="POST" class="cpd-form-root">
<input type="hidden" name="csrf" value="{{ csrf_token }}" />
<input type="hidden" name="username" value="{{ username }}" />
{% if form.errors is not empty %}
{% for error in form.errors %}
<div class="text-critical font-medium">
{{ errors.form_error_message(error=error) }}
</div>
{% endfor %}
{% endif %}
{% call(f) field.field(label=_("common.password"), name="password", form_state=form) %}
<input {{ field.attributes(f) }} class="cpd-text-control" type="password" autocomplete="current-password" required autofocus />
{% endcall %}
{% if features.account_recovery %}
{{ button.link_text(text=_("mas.login.forgot_password"), href="/recover", class="self-center") }}
{% endif %}
{{ button.button(text=_("action.continue")) }}
</form>
{% 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 %}
</main>
{% endblock content %}
-54
View File
@@ -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 %}
<header class="page-heading">
<div class="icon">
{{ icon.lock() }}
</div>
<div class="header">
<h1 class="title">Hi {{ current_session.user.username }}</h1>
<p class="text">To continue, please verify it's you:</p>
</div>
</header>
<main class="flex flex-col gap-6">
<form method="POST" class="cpd-form-root">
<input type="hidden" name="csrf" value="{{ csrf_token }}" />
{# TODO: errors #}
{% call(f) field.field(label=_("common.password"), name="password", form_state=form) %}
<input {{ field.attributes(f) }} class="cpd-text-control" type="password" autocomplete="password" required />
{% endcall %}
{{ button.button(text=_("action.continue")) }}
</form>
{% 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 %}
<div class="flex gap-1 justify-center items-center">
<p class="cpd-text-secondary cpd-text-body-md-regular">
Not {{ current_session.user.username }}?
</p>
{% 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) }}
</div>
</main>
{% endblock content %}
+14 -4
View File
@@ -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": {