From 2085f1097f6ce39e0a83f6b1dcc157ddaef28260 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Fri, 27 Mar 2026 15:03:32 +0100 Subject: [PATCH 01/46] Rename the `WithCaptcha` template context field to `captcha_config` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The field name is now more descriptive of what it actually is – the configuration of the CAPTCHA provider – and aligns with the key used when passing it to the client-side React form. --- crates/templates/src/context/captcha.rs | 5 +++-- templates/components/captcha.html | 22 +++++++++++----------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/crates/templates/src/context/captcha.rs b/crates/templates/src/context/captcha.rs index f9d8723b4..f2139e2bd 100644 --- a/crates/templates/src/context/captcha.rs +++ b/crates/templates/src/context/captcha.rs @@ -1,3 +1,4 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // @@ -42,7 +43,7 @@ impl Object for CaptchaConfig { /// Context with an optional CAPTCHA configuration in it #[derive(Serialize)] pub struct WithCaptcha { - captcha: Option, + captcha_config: Option, #[serde(flatten)] inner: T, @@ -52,7 +53,7 @@ impl WithCaptcha { #[must_use] pub(crate) fn new(captcha: Option, inner: T) -> Self { Self { - captcha: captcha.map(|captcha| Value::from_object(CaptchaConfig(captcha))), + captcha_config: captcha.map(|captcha| Value::from_object(CaptchaConfig(captcha))), inner, } } diff --git a/templates/components/captcha.html b/templates/components/captcha.html index 42b5e12b2..45fbf9bcb 100644 --- a/templates/components/captcha.html +++ b/templates/components/captcha.html @@ -7,19 +7,19 @@ Please see LICENSE files in the repository root for full details. -#} {% macro form(class="") -%} - {%- if captcha|default(False) -%} + {%- if captcha_config|default(False) -%} - {%- if captcha.service == "recaptcha_v2" -%} -
- {%- elif captcha.service == "cloudflare_turnstile" -%} -
- {%- elif captcha.service == "hcaptcha" -%} -
+ {%- if captcha_config.service == "recaptcha_v2" -%} +
+ {%- elif captcha_config.service == "cloudflare_turnstile" -%} +
+ {%- elif captcha_config.service == "hcaptcha" -%} +
{%- else -%} {{ throw(message="Invalid captcha service setup") }} {%- endif %} @@ -27,12 +27,12 @@ Please see LICENSE files in the repository root for full details. {% endmacro %} {% macro head() -%} - {%- if captcha|default(False) -%} - {%- if captcha.service == "recaptcha_v2" -%} + {%- if captcha_config|default(False) -%} + {%- if captcha_config.service == "recaptcha_v2" -%} - {%- elif captcha.service == "cloudflare_turnstile" -%} + {%- elif captcha_config.service == "cloudflare_turnstile" -%} - {%- elif captcha.service == "hcaptcha" -%} + {%- elif captcha_config.service == "hcaptcha" -%} {%- else -%} {{ throw(message="Invalid captcha service setup") }} From 182e08d0cb13d892866ae6dd612cf712f54b4172 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Mon, 10 Aug 2026 16:01:29 +0200 Subject: [PATCH 02/46] Add a React component to render CAPTCHA widgets Adds a reusable Captcha React component along with the type definitions for the supported providers (hCaptcha, Cloudflare Turnstile, Google reCAPTCHA). The provider SDKs are lazy-loaded by the component itself, suspending the tree while they load, and evicted on failure so a retry can re-attempt the load. The widgets report solved/expired state through an `onValidChange` callback. --- frontend/locales/en.json | 6 +- frontend/package.json | 3 + frontend/src/components/Captcha.stories.tsx | 86 +++++ frontend/src/components/Captcha.tsx | 390 ++++++++++++++++++++ pnpm-lock.yaml | 24 ++ 5 files changed, 508 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/Captcha.stories.tsx create mode 100644 frontend/src/components/Captcha.tsx diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 163acb2d3..565464b7e 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -12,7 +12,8 @@ "save": "Save", "save_and_continue": "Save and continue", "sign_out": "Sign out", - "start_over": "Start over" + "start_over": "Start over", + "try_again": "Try again" }, "branding": { "privacy_policy": { @@ -88,6 +89,9 @@ }, "view_all_button": "View all" }, + "captcha": { + "load_failed": "The CAPTCHA failed to load. Check your connection and try again." + }, "compat_session_detail": { "client_details_title": "Client info", "name": "Name" diff --git a/frontend/package.json b/frontend/package.json index 33d7f10e7..a68311323 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -47,6 +47,7 @@ "@graphql-codegen/client-preset": "^6.0.1", "@graphql-codegen/typescript-msw": "^4.0.1", "@graphql-typed-document-node/core": "^3.2.0", + "@hcaptcha/types": "^1.1.0", "@storybook/addon-docs": "^10.4.6", "@storybook/react-vite": "^10.4.6", "@tailwindcss/vite": "^4.3.2", @@ -56,6 +57,8 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", + "@types/cloudflare-turnstile": "^0.2.2", + "@types/grecaptcha": "^3.0.9", "@types/node": "^26.1.0", "@types/react": "19.2.17", "@types/react-dom": "19.2.3", diff --git a/frontend/src/components/Captcha.stories.tsx b/frontend/src/components/Captcha.stories.tsx new file mode 100644 index 000000000..75285820a --- /dev/null +++ b/frontend/src/components/Captcha.stories.tsx @@ -0,0 +1,86 @@ +// 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. + +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { + CaptchaLoadError, + CaptchaPlaceholder, + CaptchaSection, + captchaPlaceholderHeight, +} from "./Captcha"; + +const meta = { + component: CaptchaSection, + title: "ui/Captcha", +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// The stories below render a real widget, which loads the provider's SDK from +// its own CDN. They're kept out of the autodocs page, which would otherwise +// pull in all three SDKs at once on every visit. +const liveOnly = ["!autodocs"]; + +export const Placeholder: Story = { + name: "Loading placeholder", + render: () => ( + + ), +}; + +export const LoadError: Story = { + name: "Failed to load", + render: () => {}} />, +}; + +export const NotConfigured: Story = { + name: "No CAPTCHA configured", + args: { config: null }, +}; + +export const TurnstilePasses: Story = { + name: "Cloudflare Turnstile (pass)", + tags: liveOnly, + args: { + config: { + service: "cloudflare_turnstile", + site_key: "1x00000000000000000000AA", + }, + }, +}; + +export const TurnstileFails: Story = { + name: "Cloudflare Turnstile (fail)", + tags: liveOnly, + args: { + config: { + service: "cloudflare_turnstile", + site_key: "2x00000000000000000000AB", + }, + }, +}; + +export const ReCaptcha: Story = { + name: "Google ReCaptcha", + tags: liveOnly, + args: { + config: { + service: "recaptcha_v2", + site_key: "6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI", + }, + }, +}; + +export const HCaptcha: Story = { + name: "hCaptcha", + tags: liveOnly, + args: { + config: { + service: "hcaptcha", + site_key: "10000000-ffff-ffff-ffff-000000000001", + }, + }, +}; diff --git a/frontend/src/components/Captcha.tsx b/frontend/src/components/Captcha.tsx new file mode 100644 index 000000000..acef6c73d --- /dev/null +++ b/frontend/src/components/Captcha.tsx @@ -0,0 +1,390 @@ +// 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. + +/// +/// +/// + +import { Button } from "@vector-im/compound-web"; +import { + Component, + Fragment, + type ReactNode, + Suspense, + use, + useCallback, + useEffect, + useRef, +} from "react"; +import { useTranslation } from "react-i18next"; + +export type CaptchaService = + | "recaptcha_v2" + | "cloudflare_turnstile" + | "hcaptcha"; + +export type CaptchaConfig = { + service: CaptchaService; + site_key: string; +}; + +// Cache of script URLs to their load promises, so each script is only loaded +// once even if multiple components request it. +const scripts = new Map>(); + +// Counter used to generate unique global callback names for each script. +let scriptCounter = 0; + +// Some providers fail silently: the script loads but its onload callback never +// fires. Reject rather than suspend forever. +const SETTLE_TIMEOUT = 20_000; + +/** + * Load an external script exactly once, returning a promise that resolves when + * the script's onload callback fires. + * + * How it works: + * 1. On the first call for a given URL, it creates a ` - {%- elif captcha_config.service == "cloudflare_turnstile" -%} - - {%- elif captcha_config.service == "hcaptcha" -%} - - {%- else -%} - {{ throw(message="Invalid captcha service setup") }} - {%- endif %} - {%- endif -%} -{%- endmacro %} diff --git a/templates/pages/register/password.html b/templates/pages/register/password.html index f1a77efad..08e71fe1e 100644 --- a/templates/pages/register/password.html +++ b/templates/pages/register/password.html @@ -1,4 +1,5 @@ {# +Copyright 2025, 2026 Element Creations Ltd. Copyright 2024, 2025 New Vector Ltd. Copyright 2021-2024 The Matrix.org Foundation C.I.C. @@ -8,6 +9,8 @@ Please see LICENSE files in the repository root for full details. {% extends "base.html" %} +{% import "components/captcha.html" as captcha %} + {% block content %}
diff --git a/translations/en.json b/translations/en.json index 320fed5e0..c0b1ecad8 100644 --- a/translations/en.json +++ b/translations/en.json @@ -10,7 +10,7 @@ }, "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/reauth.html:32:28-48, pages/recovery/start.html:38:26-46, pages/register/password.html:80: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": { @@ -79,7 +79,7 @@ }, "email_address": "Email address", "@email_address": { - "context": "pages/recovery/start.html:34:33-58, pages/register/password.html:40:35-60, pages/upstream_oauth2/do_register.html:115:37-62" + "context": "pages/recovery/start.html:34:33-58, pages/register/password.html:43:35-60, pages/upstream_oauth2/do_register.html:115:37-62" }, "loading": "Loading…", "@loading": { @@ -91,15 +91,15 @@ }, "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/reauth.html:28:35-55, pages/register/password.html:48:33-53" }, "password_confirm": "Confirm password", "@password_confirm": { - "context": "pages/register/password.html:49:33-61" + "context": "pages/register/password.html:52:33-61" }, "username": "Username", "@username": { - "context": "pages/login.html:51:39-59, pages/register/index.html:30:35-55, pages/register/password.html:34:33-53, pages/upstream_oauth2/do_register.html:101:35-55, pages/upstream_oauth2/do_register.html:107:39-59" + "context": "pages/login.html:51:39-59, pages/register/index.html:30:35-55, pages/register/password.html:37:33-53, pages/upstream_oauth2/do_register.html:101:35-55, pages/upstream_oauth2/do_register.html:107:39-59" } }, "error": { @@ -149,7 +149,7 @@ "captcha": { "noscript": "This form is protected by a CAPTCHA and requires JavaScript to be enabled to submit it. Please enable JavaScript in your browser and reload this page.", "@noscript": { - "context": "components/captcha.html:13:11-36" + "context": "components/captcha.html:14:11-36" } }, "change_password": { @@ -660,7 +660,7 @@ "register": { "call_to_login": "Already have an account?", "@call_to_login": { - "context": "pages/register/index.html:63:35-66, pages/register/password.html:80:33-64", + "context": "pages/register/index.html:63:35-66, pages/register/password.html:83:33-64", "description": "Displayed on the registration page to suggest to log in instead" }, "continue_with_email": "Continue with email address", @@ -678,12 +678,12 @@ }, "heading": "Create an account", "@heading": { - "context": "pages/register/index.html:21:29-69, pages/register/password.html:18:27-67" + "context": "pages/register/index.html:21:29-69, pages/register/password.html:21:27-67" } }, "terms_of_service": "I agree to the Terms and Conditions", "@terms_of_service": { - "context": "pages/register/password.html:54:35-95, pages/upstream_oauth2/do_register.html:180:35-95" + "context": "pages/register/password.html:57:35-95, pages/upstream_oauth2/do_register.html:180:35-95" } }, "registration_token": { From 0bd67a9f9a5aa8b133e7f438d54341c37af68ceb Mon Sep 17 00:00:00 2001 From: "Kai A. Hiller" Date: Thu, 6 Aug 2026 15:10:30 +0200 Subject: [PATCH 05/46] Add unittest for database connection via pgpass --- Cargo.lock | 1 + crates/cli/Cargo.toml | 3 +++ crates/cli/src/util.rs | 57 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index a07fe38a7..a34dda170 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3293,6 +3293,7 @@ dependencies = [ "serde_yaml", "sqlx", "syn2mas", + "tempfile", "tokio", "tokio-util", "tower", diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 5341a15f9..fae239425 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -91,6 +91,9 @@ mas-tower.workspace = true syn2mas.workspace = true +[dev-dependencies] +tempfile = "3" + [build-dependencies] anyhow.workspace = true vergen-gitcl.workspace = true diff --git a/crates/cli/src/util.rs b/crates/cli/src/util.rs index c9832ef79..5172ae1ed 100644 --- a/crates/cli/src/util.rs +++ b/crates/cli/src/util.rs @@ -627,4 +627,61 @@ mod tests { let manager = password_manager_from_config(&config).await; assert!(manager.is_err()); } + + /// RAII guard that removes an environment variable when dropped, + /// ensuring cleanup even if the test panics. + struct EnvVarGuard(&'static str); + + impl EnvVarGuard { + fn set(key: &'static str, value: impl AsRef) -> Self { + // SAFETY: single-threaded tokio test runtime; no other thread + // is reading or writing the environment concurrently. + #[expect(unsafe_code)] + unsafe { + std::env::set_var(key, value); + } + Self(key) + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + // SAFETY: single-threaded tokio test runtime; no other thread + // is reading or writing the environment concurrently. + #[expect(unsafe_code)] + unsafe { + std::env::remove_var(self.0); + } + } + } + + #[tokio::test] + async fn test_database_connection_with_pgpass() { + // Write a temporary pgpass file and point PGPASSFILE at it + let pgpass_content = "*:*:*:testuser:testpassword\n"; + let pgpass_file = tempfile::NamedTempFile::new().expect("failed to create temp file"); + tokio::fs::write(pgpass_file.path(), pgpass_content) + .await + .expect("failed to write pgpass file"); + + // Set PGPASSFILE for sqlx to pick up the password from the pgpass file + let _guard = EnvVarGuard::set("PGPASSFILE", pgpass_file.path()); + + let config = serde_json::from_value(serde_json::json!({ + "uri": "postgresql://testuser@localhost/test" + })) + .unwrap(); + + let opts = DatabaseConnectOptions { + log_slow_statements: false, + }; + + let result = database_connect_options_from_config(&config, &opts).await; + assert!(result.is_ok()); + let debug = format!("{:?}", result.unwrap()); + assert!( + debug.contains("testpassword"), + "pgpass password was not resolved: {debug}" + ); + } } From 35be69bf15fdb9f64329c4de4c9ccdb66c7fa118 Mon Sep 17 00:00:00 2001 From: defaultdino Date: Wed, 19 Aug 2026 14:30:51 +0200 Subject: [PATCH 06/46] implement hard shutdown and exit for task timeout --- crates/cli/src/lifecycle.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/cli/src/lifecycle.rs b/crates/cli/src/lifecycle.rs index 3da49f9d1..0f551c0bf 100644 --- a/crates/cli/src/lifecycle.rs +++ b/crates/cli/src/lifecycle.rs @@ -217,11 +217,14 @@ impl LifecycleManager { self.hard_shutdown_token().cancel(); - // TODO: we may want to have a time out on the task tracker, in case we have - // really stuck tasks on it - self.task_tracker().wait().await; - - tracing::info!("All tasks are done, exitting"); + tokio::select! { + () = self.task_tracker().wait() => { + tracing::info!("All tasks are done, exiting"); + }, + () = tokio::time::sleep(self.timeout) => { + tracing::warn!("Hard shutdown timeout reached with tasks still running, exiting anyway"); + }, + } if likely_crashed { ExitCode::FAILURE From 1a086b543df5afb68b5739d09adfdd296fce7579 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20R=C3=B6ttsches?= Date: Fri, 21 Aug 2026 23:14:37 +0300 Subject: [PATCH 07/46] Format log timestamps in the local timezone Replace the UTC-only SystemTime timer in EventFormatter with chrono's ChronoLocal, which resolves the zone from TZ, then /etc/localtime, then /usr/share/zoneinfo, and falls back to UTC when none are available. The published image is distroless and ships none of them, so the default output is unchanged and operators opt in by mounting a zone file; the only difference for existing deployments is the offset suffix, Z -> +00:00. --- Cargo.lock | 1 + Cargo.toml | 2 +- crates/context/src/fmt.rs | 9 +++++++-- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 808440bb5..65308a147 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6804,6 +6804,7 @@ version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ + "chrono", "matchers", "nu-ansi-term", "once_cell", diff --git a/Cargo.toml b/Cargo.toml index 8a80b5f36..4bab24fa5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -688,7 +688,7 @@ features = ["cors", "fs", "add-extension", "set-header"] version = "0.1.41" [workspace.dependencies.tracing-subscriber] version = "0.3.23" -features = ["env-filter"] +features = ["env-filter", "chrono"] [workspace.dependencies.tracing-appender] version = "0.2.5" diff --git a/crates/context/src/fmt.rs b/crates/context/src/fmt.rs index 0b6edb130..3be0ed3b2 100644 --- a/crates/context/src/fmt.rs +++ b/crates/context/src/fmt.rs @@ -4,6 +4,8 @@ // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // Please see LICENSE files in the repository root for full details. +use std::sync::LazyLock; + use console::{Color, Style}; use opentelemetry::{TraceId, trace::TraceContextExt as _}; use tracing::{Level, Subscriber}; @@ -12,13 +14,16 @@ use tracing_subscriber::{ fmt::{ FormatEvent, FormatFields, format::{DefaultFields, Writer}, - time::{FormatTime, SystemTime}, + time::{ChronoLocal, FormatTime}, }, registry::LookupSpan, }; use crate::LogContext; +static TIMER: LazyLock = + LazyLock::new(|| ChronoLocal::new("%Y-%m-%dT%H:%M:%S%.6f%:z".to_owned())); + /// An event formatter usable by the [`tracing_subscriber`] crate, which /// includes the log context and the OTEL trace ID. #[derive(Debug, Default)] @@ -98,7 +103,7 @@ where let ansi = writer.has_ansi_escapes(); let metadata = event.metadata(); - SystemTime.format_time(&mut writer)?; + TIMER.format_time(&mut writer)?; let level = FmtLevel::new(metadata.level(), ansi); write!(&mut writer, " {level} ")?; From 2f160c03ee901d9457e86b2c0216d2a20c6de1af Mon Sep 17 00:00:00 2001 From: Paul Chobert Date: Tue, 1 Sep 2026 11:14:55 +0200 Subject: [PATCH 08/46] Add test asserting M_APPSERVICE_LOGIN_UNSUPPORTED on appservice login Matrix 1.17 requires servers that don't support the legacy authentication API to reject `m.login.application_service` on `/login` with a 400 and the `M_APPSERVICE_LOGIN_UNSUPPORTED` error code. The test fails until the errcode is implemented. --- crates/handlers/src/compat/login.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index efa8358ae..418e1a31c 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -1686,6 +1686,27 @@ mod tests { "###); } + /// Test the response of an `m.login.application_service` login. + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_appservice_login_unsupported(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + + let request = Request::post("/_matrix/client/v3/login").json(serde_json::json!({ + "type": "m.login.application_service", + })); + + let response = state.request(request).await; + response.assert_status(StatusCode::BAD_REQUEST); + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r###" + { + "errcode": "M_APPSERVICE_LOGIN_UNSUPPORTED", + "error": "Application services can't log in through the legacy authentication API on this server" + } + "###); + } + /// Test `m.login.token` login flow. #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] async fn test_login_token_login(pool: PgPool) { From 765fa97ffe46116246eaf719a345114a9e6ddf27 Mon Sep 17 00:00:00 2001 From: Paul Chobert Date: Tue, 1 Sep 2026 11:17:59 +0200 Subject: [PATCH 09/46] Return M_APPSERVICE_LOGIN_UNSUPPORTED for m.login.application_service Matrix 1.17 requires servers that don't support the legacy authentication API to reject `m.login.application_service` on `/login` with a 400 and the `M_APPSERVICE_LOGIN_UNSUPPORTED` error code. Previously this login type fell through the generic unsupported credentials path and returned `M_UNKNOWN`, which appservices written against Matrix 1.17 can't distinguish from an unrelated failure. --- crates/handlers/src/compat/login.rs | 16 ++++++++++++++++ docs/as-login.md | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 418e1a31c..745fad14d 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -155,6 +155,9 @@ pub enum Credentials { #[serde(rename = "m.login.token")] Token { token: String }, + #[serde(rename = "m.login.application_service")] + ApplicationService, + #[serde(other)] Unsupported, } @@ -164,6 +167,7 @@ impl Credentials { match self { Self::Password { .. } => "m.login.password", Self::Token { .. } => "m.login.token", + Self::ApplicationService => "m.login.application_service", Self::Unsupported => "unsupported", } } @@ -199,6 +203,9 @@ pub enum RouteError { #[error("unsupported login method")] Unsupported, + #[error("appservice login not supported")] + AppserviceLoginUnsupported, + #[error("unsupported identifier type")] UnsupportedIdentifier, @@ -266,6 +273,11 @@ impl IntoResponse for RouteError { error: "Invalid login type", status: StatusCode::BAD_REQUEST, }, + Self::AppserviceLoginUnsupported => MatrixError { + errcode: "M_APPSERVICE_LOGIN_UNSUPPORTED", + error: "Application services can't log in through the legacy authentication API on this server", + status: StatusCode::BAD_REQUEST, + }, Self::UnsupportedIdentifier => MatrixError { errcode: "M_UNKNOWN", error: "Unsupported login identifier", @@ -394,6 +406,10 @@ pub(crate) async fn post( .await? } + (_, Credentials::ApplicationService) => { + return Err(RouteError::AppserviceLoginUnsupported); + } + _ => { return Err(RouteError::Unsupported); } diff --git a/docs/as-login.md b/docs/as-login.md index 0eed4f9e3..f3b61c257 100644 --- a/docs/as-login.md +++ b/docs/as-login.md @@ -1,7 +1,7 @@ # About Application Services login Encrypted Application Services/Bridges currently leverage the `m.login.application_service` login type to create devices for users. -This API is *not* available in the Matrix Authentication Service. +This API is *not* available in the Matrix Authentication Service: as per [Matrix 1.19](https://spec.matrix.org/v1.19/application-service-api/#registration), calling `/login` with this login type returns a 400 HTTP status code with an `M_APPSERVICE_LOGIN_UNSUPPORTED` error code. We're working on a solution to support this use case, but in the meantime, this means **encrypted bridges will not work with the Matrix Authentication Service.** A workaround is to disable E2EE support in your bridge setup. From 9e1d3b94b1ed580f3b1511cee1c220925282571b Mon Sep 17 00:00:00 2001 From: Paul Chobert Date: Thu, 3 Sep 2026 12:05:30 +0200 Subject: [PATCH 10/46] Update crates/handlers/src/compat/login.rs Co-authored-by: Quentin Gliech --- crates/handlers/src/compat/login.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 745fad14d..f30b1bab4 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -1710,6 +1710,10 @@ mod tests { let request = Request::post("/_matrix/client/v3/login").json(serde_json::json!({ "type": "m.login.application_service", + "identifier": { + "type": "m.id.user", + "user": "_irc_example", + }, })); let response = state.request(request).await; From db25101de43b761ec0ba4f3b4f47fef93e4f3823 Mon Sep 17 00:00:00 2001 From: Paul Chobert Date: Thu, 3 Sep 2026 16:09:44 +0200 Subject: [PATCH 11/46] Fix flaky fail_verify_id_token_wrong_auth_time test The oidc-client integration tests read the real system clock through a `now()` helper. The ID token builder generates a fresh RSA key before stamping `iat`, while each test captured its own `now` earlier for verification. Under coverage instrumentation on a loaded CI runner, the two key generations in `fail_verify_id_token_wrong_auth_time` took more than the 5 minute `iat` leeway, so verification failed on `iat` before ever reaching the `auth_time` check the test asserts on. Make the helper return a frozen `MockClock` timestamp instead. Every token and verification in the binary now shares one instant, so setup duration can no longer affect the outcome, and the clippy opt-out for `Utc::now()` goes away. --- Cargo.lock | 1 + crates/oidc-client/Cargo.toml | 2 ++ crates/oidc-client/tests/it/main.rs | 9 +++++++-- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 330f6f332..58e481e25 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3673,6 +3673,7 @@ dependencies = [ "http", "http-body-util", "language-tags", + "mas-data-model", "mas-http", "mas-iana", "mas-jose", diff --git a/crates/oidc-client/Cargo.toml b/crates/oidc-client/Cargo.toml index 3fffd584c..ec1b1eb89 100644 --- a/crates/oidc-client/Cargo.toml +++ b/crates/oidc-client/Cargo.toml @@ -51,3 +51,5 @@ rand_chacha.workspace = true rustls.workspace = true tokio.workspace = true wiremock.workspace = true + +mas-data-model.workspace = true diff --git a/crates/oidc-client/tests/it/main.rs b/crates/oidc-client/tests/it/main.rs index f05340467..28960e9d5 100644 --- a/crates/oidc-client/tests/it/main.rs +++ b/crates/oidc-client/tests/it/main.rs @@ -7,6 +7,7 @@ use std::collections::HashMap; use chrono::{DateTime, Duration, Utc}; +use mas_data_model::{Clock, clock::MockClock}; use mas_iana::{jose::JsonWebSignatureAlg, oauth::OAuthClientAuthenticationMethod}; use mas_jose::{ claims::{self, hash_token}, @@ -37,9 +38,13 @@ const REFRESH_TOKEN: &str = "RefreshToken1"; const SUBJECT_IDENTIFIER: &str = "SubjectID"; const ID_TOKEN_SIGNING_ALG: JsonWebSignatureAlg = JsonWebSignatureAlg::Rs256; +/// The current time, as seen by every test in this crate. +/// +/// Tests never read the system clock: they use a [`MockClock`] frozen at a +/// fixed instant, so token timestamps and verification always agree no matter +/// how long the setup in between takes. fn now() -> DateTime { - #[expect(clippy::disallowed_methods)] - Utc::now() + MockClock::default().now() } async fn init_test() -> (reqwest::Client, MockServer, Url) { From 21aaa65b34512a584fc9cae9cae53e40cccb8132 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 12:48:24 +0000 Subject: [PATCH 12/46] build(deps): bump http-body-util from 0.1.3 to 0.1.5 Bumps [http-body-util](https://github.com/hyperium/http-body) from 0.1.3 to 0.1.5. - [Release notes](https://github.com/hyperium/http-body/releases) - [Commits](https://github.com/hyperium/http-body/compare/http-body-util-v0.1.3...http-body-util-v0.1.5) --- updated-dependencies: - dependency-name: http-body-util dependency-version: 0.1.5 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 58e481e25..ac341d0d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2304,9 +2304,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", From aeae198d0effc1e2acb325b97b2c5e2d540c8305 Mon Sep 17 00:00:00 2001 From: Noah Gao Date: Sun, 6 Sep 2026 02:57:35 +0800 Subject: [PATCH 13/46] docs: fix manage command positional arguments --- docs/reference/cli/manage.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/reference/cli/manage.md b/docs/reference/cli/manage.md index 52b5d3598..0117a9638 100644 --- a/docs/reference/cli/manage.md +++ b/docs/reference/cli/manage.md @@ -64,12 +64,15 @@ $ mas-cli manage set-password --ignore-complexity Issue a compatibility token for a user. +Arguments: +- ``: User for which to issue the token. +- `[device_id]`: Device ID to set in the token. If not specified, a random device ID will be generated. + Options: -- `--device-id `: Device ID to set in the token. If not specified, a random device ID will be generated. - `--yes-i-want-to-grant-synapse-admin-privileges`: Whether the token should be given admin privileges. ``` -$ mas-cli manage issue-compatibility-token --device-id --yes-i-want-to-grant-synapse-admin-privileges +$ mas-cli manage issue-compatibility-token --yes-i-want-to-grant-synapse-admin-privileges ``` ## `manage issue-user-registration-token` @@ -131,8 +134,10 @@ $ mas-cli manage unlock-user --reactivate Register a user. This will interactively prompt for the user's attributes unless the `--yes` flag is set. It bypasses any policy check on the password, email, etc. +Arguments: +- `[username]`: Username to register. Required when using `--yes`. + Options: -- `--username `: Username to register. - `--password `: Password to set. - `--email `: Email to add. Can be specified multiple times. - `--upstream-provider-mapping `: Upstream OAuth 2.0 provider mapping. Can be specified multiple times. @@ -144,4 +149,5 @@ Options: ``` $ mas-cli manage register-user +$ mas-cli manage register-user --yes ``` From dbbb93915efce7edc5771aa7d26e39485fe7feb3 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Mon, 7 Sep 2026 15:50:18 +0200 Subject: [PATCH 14/46] Declare the Node.js version in `devEngines.runtime` `pnpm/setup` reads the runtime version from `package.json` and ignores `.node-version`. With `onFail: download`, pnpm records Node 24.15.0 in the lockfile and downloads it on every install unless `--no-runtime` is passed. The Dockerfile and the docs build pass it, since they already run a pinned Node. `.node-version` stays for the Cloudflare Pages build, which reads it to pick the Node that bootstraps corepack. --- Dockerfile | 6 +- docs/development/contributing.md | 2 +- docs/setup/installation.md | 2 +- misc/build-docs.sh | 7 +- package.json | 7 ++ pnpm-lock.yaml | 126 +++++++++++++++++++++++++++++++ 6 files changed, 144 insertions(+), 6 deletions(-) diff --git a/Dockerfile b/Dockerfile index 899ef0710..0a644d6ed 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,7 @@ ARG DEBIAN_VERSION=13 ARG DEBIAN_VERSION_NAME=trixie ARG RUSTUP_VERSION=1.29.0 -# Keep in sync with .node-version +# Keep in sync with .node-version and devEngines.runtime in package.json ARG NODEJS_VERSION=24.15.0 # Keep in sync with .github/actions/build-policies/action.yml and policies/Makefile ARG OPA_VERSION=1.13.1 @@ -38,8 +38,10 @@ COPY ./package.json ./pnpm-workspace.yaml ./pnpm-lock.yaml /app/ COPY ./frontend/package.json /app/frontend/ # Network access: to fetch dependencies +# The base image already provides the Node.js version pinned in +# `devEngines.runtime`, so skip pnpm's own runtime download. RUN --network=default \ - pnpm install --frozen-lockfile + pnpm install --frozen-lockfile --no-runtime COPY ./frontend/ /app/frontend/ COPY ./templates/ /app/templates/ diff --git a/docs/development/contributing.md b/docs/development/contributing.md index e8a32ccbf..a9079b17e 100644 --- a/docs/development/contributing.md +++ b/docs/development/contributing.md @@ -20,7 +20,7 @@ There are two main ways to contribute to MAS: To get MAS running locally from source you will need to: - [Install Rust and Cargo](https://www.rust-lang.org/learn/get-started). The exact version is pinned in `rust-toolchain.toml`; rustup installs it automatically when you run cargo in the repo. -- [Install Node.js](https://nodejs.org/). We recommend using the latest LTS version of Node.js. The frontend uses pnpm, which is installed automatically via corepack — see below. +- [Install Node.js](https://nodejs.org/). Any recent version works to bootstrap: the exact version is pinned in `devEngines.runtime` in `package.json`, and pnpm downloads it on install. The frontend uses pnpm, which is installed automatically via corepack — see below. - [Install Open Policy Agent](https://www.openpolicyagent.org/docs#1-download-opa) ## 4. Get the source diff --git a/docs/setup/installation.md b/docs/setup/installation.md index bcdb7fbd4..9d92a24ee 100644 --- a/docs/setup/installation.md +++ b/docs/setup/installation.md @@ -64,7 +64,7 @@ The image can also be built from the source: Building from the source requires: - The [Rust toolchain](https://www.rust-lang.org/learn/get-started) pinned by `rust-toolchain.toml` (installed automatically by rustup) -- [Node.js (24 and later)](https://nodejs.org/en/), with [corepack](https://nodejs.org/api/corepack.html) enabled so pnpm@11 is provisioned automatically +- [Node.js](https://nodejs.org/en/), with [corepack](https://nodejs.org/api/corepack.html) enabled so pnpm@11 is provisioned automatically; pnpm then downloads the Node.js version pinned in `package.json` - the [Open Policy Agent](https://www.openpolicyagent.org/docs/latest/#running-opa) binary (or alternatively, Docker) 1. Get the source diff --git a/misc/build-docs.sh b/misc/build-docs.sh index da0bed402..ee9c08a6f 100644 --- a/misc/build-docs.sh +++ b/misc/build-docs.sh @@ -30,7 +30,8 @@ if [ "${CF_PAGES:-""}" = "1" ]; then MDBOOK_URL="https://github.com/rust-lang/mdBook/releases/download/v${MDBOOK_VERSION}/mdbook-v${MDBOOK_VERSION}-$(uname -m)-unknown-linux-gnu.tar.gz" curl --proto '=https' --tlsv1.2 -sSfL "${MDBOOK_URL}" | tar -C "$HOME/.cargo/bin" -xzv - # Enable pnpm via corepack (Node and corepack are pre-installed on Cloudflare Pages) + # Enable pnpm via corepack (Node and corepack are pre-installed on Cloudflare + # Pages, which picks the Node version from .node-version) corepack enable fi @@ -56,5 +57,7 @@ rm -rf target/book/rustdoc mv target/doc target/book/rustdoc # Build the frontend storybook -pnpm install --frozen-lockfile +# Node is already provided by Cloudflare Pages / the CI runner, so skip pnpm's +# own runtime download. +pnpm install --frozen-lockfile --no-runtime pnpm --filter mas-frontend exec storybook build -o ../target/book/storybook diff --git a/package.json b/package.json index 563c22fa7..ef74b844d 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,13 @@ "name": "matrix-authentication-service", "private": true, "packageManager": "pnpm@11.3.0+sha512.2c403d6594527287672b1f7056343a1f7c3634036a67ffabfcc2b3d7595d843768f8787148d1b57cf7956c90606bbd192857c363af19e96d2d0ec9ec5741d215", + "devEngines": { + "runtime": { + "name": "node", + "version": "24.15.0", + "onFail": "download" + } + }, "devDependencies": { "@localazy/cli": "^2.0.11", "semver": "^7.8.5" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5f0027294..72b201e08 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@localazy/cli': specifier: ^2.0.11 version: 2.0.11 + node: + specifier: runtime:24.15.0 + version: runtime:24.15.0 semver: specifier: ^7.8.5 version: 7.8.5 @@ -3953,6 +3956,127 @@ packages: resolution: {integrity: sha512-MRlTqhAfoMx/4mhEbPo3Hi02g9LJZaJkka69V6h67Cb1gjrAG0jsTE4CZX1eptNx+VCAwJmfpnDIF4P0Nh1A7A==} engines: {node: '>=18'} + node@runtime:24.15.0: + resolution: + type: variations + variants: + - resolution: + archive: tarball + bin: + node: bin/node + integrity: sha256-3UvHfctfTJoslkNzm7WTUAzLCUE+xCyqD47w5e8RYJU= + type: binary + url: https://nodejs.org/download/release/v24.15.0/node-v24.15.0-aix-ppc64.tar.gz + targets: + - cpu: ppc64 + os: aix + - resolution: + archive: tarball + bin: + node: bin/node + integrity: sha256-NyMxuWl3mrXRW5SYhPxur4jVr+h73ouogdZAC5EA/8Q= + type: binary + url: https://nodejs.org/download/release/v24.15.0/node-v24.15.0-darwin-arm64.tar.gz + targets: + - cpu: arm64 + os: darwin + - resolution: + archive: tarball + bin: + node: bin/node + integrity: sha256-/9XuKTRnkn8+5zGlU+uI/R9Iz3TuvC10prq+SvIoZzs= + type: binary + url: https://nodejs.org/download/release/v24.15.0/node-v24.15.0-darwin-x64.tar.gz + targets: + - cpu: x64 + os: darwin + - resolution: + archive: tarball + bin: + node: bin/node + integrity: sha256-c6/CNNVYwkkZh19RwtHqACoq2k6m+DYBo4OGn++mTu0= + type: binary + url: https://nodejs.org/download/release/v24.15.0/node-v24.15.0-linux-arm64.tar.gz + targets: + - cpu: arm64 + os: linux + - resolution: + archive: tarball + bin: + node: bin/node + integrity: sha256-sfiJAKSxY2XqulYmkwT8Edp1gdvwNVLUSV+azh/AX20= + type: binary + url: https://nodejs.org/download/release/v24.15.0/node-v24.15.0-linux-ppc64le.tar.gz + targets: + - cpu: ppc64le + os: linux + - resolution: + archive: tarball + bin: + node: bin/node + integrity: sha256-+YVFVDnVL+m43mqPbQe/7MxzbepSfofqyv5ajXUWo4A= + type: binary + url: https://nodejs.org/download/release/v24.15.0/node-v24.15.0-linux-s390x.tar.gz + targets: + - cpu: s390x + os: linux + - resolution: + archive: tarball + bin: + node: bin/node + integrity: sha256-RINoctmuxJ8ea1KpqSKHLbmisC0jWmFqVoG2qF/sjYk= + type: binary + url: https://nodejs.org/download/release/v24.15.0/node-v24.15.0-linux-x64.tar.gz + targets: + - cpu: x64 + os: linux + - resolution: + archive: zip + bin: + node: node.exe + integrity: sha256-yet0Au2ibiun5EtnJ/yFqN5WxQlbH3Hr0wYokiEaoRY= + prefix: node-v24.15.0-win-arm64 + type: binary + url: https://nodejs.org/download/release/v24.15.0/node-v24.15.0-win-arm64.zip + targets: + - cpu: arm64 + os: win32 + - resolution: + archive: zip + bin: + node: node.exe + integrity: sha256-zFFJ6r1Td5zh573FQBZDYi0MfmgAreGJKKdn6UC7DmI= + prefix: node-v24.15.0-win-x64 + type: binary + url: https://nodejs.org/download/release/v24.15.0/node-v24.15.0-win-x64.zip + targets: + - cpu: x64 + os: win32 + - resolution: + archive: tarball + bin: + node: bin/node + integrity: sha256-MemKqWCgZ9qR7f/V2TvEZle10qgClhLDWfXyrABgFSo= + type: binary + url: https://unofficial-builds.nodejs.org/download/release/v24.15.0/node-v24.15.0-linux-arm64-musl.tar.gz + targets: + - cpu: arm64 + os: linux + libc: musl + - resolution: + archive: tarball + bin: + node: bin/node + integrity: sha256-9Vr1vUicU0exE8pllMrgClSzC6V6xYdTJDEb/G9HYuM= + type: binary + url: https://unofficial-builds.nodejs.org/download/release/v24.15.0/node-v24.15.0-linux-x64-musl.tar.gz + targets: + - cpu: x64 + os: linux + libc: musl + version: 24.15.0 + hasBin: true + normalize-path@2.1.1: resolution: {integrity: sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w==} engines: {node: '>=0.10.0'} @@ -8387,6 +8511,8 @@ snapshots: node-releases@2.0.52: {} + node@runtime:24.15.0: {} + normalize-path@2.1.1: dependencies: remove-trailing-separator: 1.1.0 From 1826034167314a147c8ca392527d0091675f5764 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Mon, 7 Sep 2026 15:51:25 +0200 Subject: [PATCH 15/46] Replace `pnpm/action-setup` and `actions/setup-node` with `pnpm/setup` One pinned step now installs pnpm, Node.js and the dependencies. `require-lockfile: true` keeps the `--frozen-lockfile` semantics, `cache: true` keeps the pnpm store cache. --- .github/actions/build-frontend/action.yml | 18 +++------ .github/workflows/ci.yaml | 42 ++++++-------------- .github/workflows/docs.yaml | 11 ++--- .github/workflows/release-branch.yaml | 28 ++++--------- .github/workflows/release-bump.yaml | 14 ++----- .github/workflows/translations-download.yaml | 15 +++---- .github/workflows/translations-upload.yaml | 15 +++---- 7 files changed, 44 insertions(+), 99 deletions(-) diff --git a/.github/actions/build-frontend/action.yml b/.github/actions/build-frontend/action.yml index dd8a30703..3118e4f22 100644 --- a/.github/actions/build-frontend/action.yml +++ b/.github/actions/build-frontend/action.yml @@ -1,26 +1,20 @@ +# Copyright 2025, 2026 Element Creations Ltd. # Copyright 2025 New Vector Ltd. # # SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial # Please see LICENSE files in the repository root for full details. name: Build the frontend assets -description: Installs Node.js and builds the frontend assets from the frontend directory +description: Installs pnpm, Node.js and the dependencies, then builds the frontend assets runs: using: composite steps: - - name: Install pnpm - uses: pnpm/action-setup@v6.0.5 - - - name: Install Node - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + - name: Install pnpm, Node.js and the dependencies + uses: pnpm/setup@703c52620218391530e48b9e8870d5c0082e1b9b # v2.1.0 with: - node-version-file: .node-version - cache: "pnpm" - - - name: Install dependencies - run: pnpm install --frozen-lockfile - shell: sh + cache: true + require-lockfile: true - name: Build the frontend assets run: pnpm --filter mas-frontend run build diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7cf0db1d0..4b6eff083 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -68,17 +68,11 @@ jobs: with: persist-credentials: false - - name: Install pnpm - uses: pnpm/action-setup@v6.0.9 - - - name: Install Node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - name: Install pnpm, Node.js and the dependencies + uses: pnpm/setup@703c52620218391530e48b9e8870d5c0082e1b9b # v2.1.0 with: - node-version-file: .node-version - cache: "pnpm" - - - name: Install dependencies - run: pnpm install --frozen-lockfile + cache: true + require-lockfile: true - name: Lint run: pnpm --filter mas-frontend run lint @@ -96,17 +90,11 @@ jobs: with: persist-credentials: false - - name: Install pnpm - uses: pnpm/action-setup@v6.0.9 - - - name: Install Node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - name: Install pnpm, Node.js and the dependencies + uses: pnpm/setup@703c52620218391530e48b9e8870d5c0082e1b9b # v2.1.0 with: - node-version-file: .node-version - cache: "pnpm" - - - name: Install dependencies - run: pnpm install --frozen-lockfile + cache: true + require-lockfile: true - name: Test run: pnpm --filter mas-frontend test @@ -124,17 +112,11 @@ jobs: with: persist-credentials: false - - name: Install pnpm - uses: pnpm/action-setup@v6.0.9 - - - name: Install Node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - name: Install pnpm, Node.js and the dependencies + uses: pnpm/setup@703c52620218391530e48b9e8870d5c0082e1b9b # v2.1.0 with: - node-version-file: .node-version - cache: "pnpm" - - - name: Install dependencies - run: pnpm install --frozen-lockfile + cache: true + require-lockfile: true - name: Check for unused dependencies run: pnpm --filter mas-frontend run knip diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index bf01b490a..19e3d7d67 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -41,14 +41,11 @@ jobs: with: tool: mdbook - - name: Install pnpm - uses: pnpm/action-setup@v6.0.9 - - - name: Install Node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - name: Install pnpm, Node.js and the dependencies + uses: pnpm/setup@703c52620218391530e48b9e8870d5c0082e1b9b # v2.1.0 with: - node-version-file: .node-version - cache: "pnpm" + cache: true + require-lockfile: true - name: Build the documentation run: sh misc/build-docs.sh diff --git a/.github/workflows/release-branch.yaml b/.github/workflows/release-branch.yaml index 637acc053..108617952 100644 --- a/.github/workflows/release-branch.yaml +++ b/.github/workflows/release-branch.yaml @@ -42,17 +42,11 @@ jobs: - name: Install Rust toolchain run: rustup toolchain install - - name: Install pnpm - uses: pnpm/action-setup@v6.0.9 - - - name: Install Node - uses: actions/setup-node@v7.0.0 + - name: Install pnpm, Node.js and the dependencies + uses: pnpm/setup@703c52620218391530e48b9e8870d5c0082e1b9b # v2.1.0 with: - node-version-file: .node-version - cache: "pnpm" - - - name: Install dependencies - run: pnpm install --frozen-lockfile + cache: true + require-lockfile: true - name: Compute the new minor RC id: next @@ -80,17 +74,11 @@ jobs: with: persist-credentials: false - - name: Install pnpm - uses: pnpm/action-setup@v6.0.9 - - - name: Install Node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - name: Install pnpm, Node.js and the dependencies + uses: pnpm/setup@703c52620218391530e48b9e8870d5c0082e1b9b # v2.1.0 with: - node-version-file: .node-version - cache: "pnpm" - - - name: Install dependencies - run: pnpm install --frozen-lockfile + cache: true + require-lockfile: true - name: Create a new branch in Localazy run: pnpm exec localazy branch -w "$LOCALAZY_WRITE_KEY" create main "$BRANCH" diff --git a/.github/workflows/release-bump.yaml b/.github/workflows/release-bump.yaml index ae095b1c5..b1b2c4d3e 100644 --- a/.github/workflows/release-bump.yaml +++ b/.github/workflows/release-bump.yaml @@ -41,17 +41,11 @@ jobs: - name: Install Rust toolchain run: rustup toolchain install - - name: Install pnpm - uses: pnpm/action-setup@v6.0.9 - - - name: Install Node - uses: actions/setup-node@v7.0.0 + - name: Install pnpm, Node.js and the dependencies + uses: pnpm/setup@703c52620218391530e48b9e8870d5c0082e1b9b # v2.1.0 with: - node-version-file: .node-version - cache: "pnpm" - - - name: Install dependencies - run: pnpm install --frozen-lockfile + cache: true + require-lockfile: true - name: Extract the current version id: current diff --git a/.github/workflows/translations-download.yaml b/.github/workflows/translations-download.yaml index 957781591..f106b4c47 100644 --- a/.github/workflows/translations-download.yaml +++ b/.github/workflows/translations-download.yaml @@ -1,3 +1,4 @@ +# Copyright 2025, 2026 Element Creations Ltd. # Copyright 2025 New Vector Ltd. # # SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial @@ -23,17 +24,11 @@ jobs: with: persist-credentials: false - - name: Install pnpm - uses: pnpm/action-setup@v6.0.9 - - - name: Install Node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - name: Install pnpm, Node.js and the dependencies + uses: pnpm/setup@703c52620218391530e48b9e8870d5c0082e1b9b # v2.1.0 with: - node-version-file: .node-version - cache: "pnpm" - - - name: Install dependencies - run: pnpm install --frozen-lockfile + cache: true + require-lockfile: true - name: Compute the Localazy branch name id: branch diff --git a/.github/workflows/translations-upload.yaml b/.github/workflows/translations-upload.yaml index d81b04074..08d8d8974 100644 --- a/.github/workflows/translations-upload.yaml +++ b/.github/workflows/translations-upload.yaml @@ -1,3 +1,4 @@ +# Copyright 2025, 2026 Element Creations Ltd. # Copyright 2025 New Vector Ltd. # # SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial @@ -22,17 +23,11 @@ jobs: with: persist-credentials: false - - name: Install pnpm - uses: pnpm/action-setup@v6.0.9 - - - name: Install Node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - name: Install pnpm, Node.js and the dependencies + uses: pnpm/setup@703c52620218391530e48b9e8870d5c0082e1b9b # v2.1.0 with: - node-version-file: .node-version - cache: "pnpm" - - - name: Install dependencies - run: pnpm install --frozen-lockfile + cache: true + require-lockfile: true - name: Compute the Localazy branch name id: branch From a5ead762401c99ab16f17e34b2e8621f874fda5a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:54:17 +0000 Subject: [PATCH 16/46] build(deps): bump rust-toolchain from 1.96.0 to 1.98.1 Bumps [rust-toolchain](https://github.com/rust-lang/rust) from 1.96.0 to 1.98.1. - [Release notes](https://github.com/rust-lang/rust/releases) - [Changelog](https://github.com/rust-lang/rust/blob/main/RELEASES.md) - [Commits](https://github.com/rust-lang/rust/compare/1.96.0...1.98.1) --- updated-dependencies: - dependency-name: rust-toolchain dependency-version: 1.98.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- rust-toolchain.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust-toolchain.toml b/rust-toolchain.toml index c0b6afb91..44d11ea41 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -4,7 +4,7 @@ # Please see LICENSE files in the repository root for full details. [toolchain] -channel = "1.96.0" +channel = "1.98.1" profile = "minimal" # No rustfmt: .rustfmt.toml uses nightly-only options, so formatting runs as `cargo +nightly fmt`. # No cross-compilation targets: the Dockerfile and build.yaml add the ones they need. From 98af240ce31ffdcccd16d6b79d12d7ee8add30ff Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:25:07 +0000 Subject: [PATCH 17/46] Translations updates --- frontend/.storybook/locales.ts | 82 +++++++++++++++++----------------- 1 file changed, 41 insertions(+), 41 deletions(-) diff --git a/frontend/.storybook/locales.ts b/frontend/.storybook/locales.ts index deda65899..4b0ee9189 100644 --- a/frontend/.storybook/locales.ts +++ b/frontend/.storybook/locales.ts @@ -27,7 +27,7 @@ export type LocalazyMetadata = { }; const localazyMetadata: LocalazyMetadata = { - projectUrl: "https://localazy.com/p/matrix-authentication-service!v1.24", + projectUrl: "https://localazy.com/p/matrix-authentication-service", baseLocale: "en", languages: [ { @@ -217,26 +217,26 @@ const localazyMetadata: LocalazyMetadata = { file: "frontend.json", path: "", cdnFiles: { - "cs": "https://delivery.localazy.com/_a6406816515547716152c4539eac/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/cs/frontend.json", - "da": "https://delivery.localazy.com/_a6406816515547716152c4539eac/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/da/frontend.json", - "de": "https://delivery.localazy.com/_a6406816515547716152c4539eac/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/de/frontend.json", - "en": "https://delivery.localazy.com/_a6406816515547716152c4539eac/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/en/frontend.json", - "et": "https://delivery.localazy.com/_a6406816515547716152c4539eac/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/et/frontend.json", - "fi": "https://delivery.localazy.com/_a6406816515547716152c4539eac/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/fi/frontend.json", - "fr": "https://delivery.localazy.com/_a6406816515547716152c4539eac/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/fr/frontend.json", - "hu": "https://delivery.localazy.com/_a6406816515547716152c4539eac/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/hu/frontend.json", - "nb_NO": "https://delivery.localazy.com/_a6406816515547716152c4539eac/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/nb-NO/frontend.json", - "nl": "https://delivery.localazy.com/_a6406816515547716152c4539eac/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/nl/frontend.json", - "pl": "https://delivery.localazy.com/_a6406816515547716152c4539eac/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pl/frontend.json", - "pt": "https://delivery.localazy.com/_a6406816515547716152c4539eac/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pt/frontend.json", - "pt_BR": "https://delivery.localazy.com/_a6406816515547716152c4539eac/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pt-BR/frontend.json", - "ru": "https://delivery.localazy.com/_a6406816515547716152c4539eac/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/ru/frontend.json", - "sk": "https://delivery.localazy.com/_a6406816515547716152c4539eac/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/sk/frontend.json", - "sv": "https://delivery.localazy.com/_a6406816515547716152c4539eac/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/sv/frontend.json", - "tr": "https://delivery.localazy.com/_a6406816515547716152c4539eac/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/tr/frontend.json", - "uk": "https://delivery.localazy.com/_a6406816515547716152c4539eac/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/uk/frontend.json", - "uz": "https://delivery.localazy.com/_a6406816515547716152c4539eac/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/uz/frontend.json", - "zh#Hans": "https://delivery.localazy.com/_a6406816515547716152c4539eac/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/zh-Hans/frontend.json" + "cs": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/cs/frontend.json", + "da": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/da/frontend.json", + "de": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/de/frontend.json", + "en": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/en/frontend.json", + "et": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/et/frontend.json", + "fi": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/fi/frontend.json", + "fr": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/fr/frontend.json", + "hu": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/hu/frontend.json", + "nb_NO": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/nb-NO/frontend.json", + "nl": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/nl/frontend.json", + "pl": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pl/frontend.json", + "pt": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pt/frontend.json", + "pt_BR": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pt-BR/frontend.json", + "ru": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/ru/frontend.json", + "sk": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/sk/frontend.json", + "sv": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/sv/frontend.json", + "tr": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/tr/frontend.json", + "uk": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/uk/frontend.json", + "uz": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/uz/frontend.json", + "zh#Hans": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/zh-Hans/frontend.json" } }, { @@ -244,26 +244,26 @@ const localazyMetadata: LocalazyMetadata = { file: "file.json", path: "", cdnFiles: { - "cs": "https://delivery.localazy.com/_a6406816515547716152c4539eac/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/cs/file.json", - "da": "https://delivery.localazy.com/_a6406816515547716152c4539eac/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/da/file.json", - "de": "https://delivery.localazy.com/_a6406816515547716152c4539eac/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/de/file.json", - "en": "https://delivery.localazy.com/_a6406816515547716152c4539eac/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/en/file.json", - "et": "https://delivery.localazy.com/_a6406816515547716152c4539eac/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/et/file.json", - "fi": "https://delivery.localazy.com/_a6406816515547716152c4539eac/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/fi/file.json", - "fr": "https://delivery.localazy.com/_a6406816515547716152c4539eac/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/fr/file.json", - "hu": "https://delivery.localazy.com/_a6406816515547716152c4539eac/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/hu/file.json", - "nb_NO": "https://delivery.localazy.com/_a6406816515547716152c4539eac/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/nb-NO/file.json", - "nl": "https://delivery.localazy.com/_a6406816515547716152c4539eac/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/nl/file.json", - "pl": "https://delivery.localazy.com/_a6406816515547716152c4539eac/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pl/file.json", - "pt": "https://delivery.localazy.com/_a6406816515547716152c4539eac/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pt/file.json", - "pt_BR": "https://delivery.localazy.com/_a6406816515547716152c4539eac/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pt-BR/file.json", - "ru": "https://delivery.localazy.com/_a6406816515547716152c4539eac/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/ru/file.json", - "sk": "https://delivery.localazy.com/_a6406816515547716152c4539eac/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/sk/file.json", - "sv": "https://delivery.localazy.com/_a6406816515547716152c4539eac/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/sv/file.json", - "tr": "https://delivery.localazy.com/_a6406816515547716152c4539eac/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/tr/file.json", - "uk": "https://delivery.localazy.com/_a6406816515547716152c4539eac/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/uk/file.json", - "uz": "https://delivery.localazy.com/_a6406816515547716152c4539eac/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/uz/file.json", - "zh#Hans": "https://delivery.localazy.com/_a6406816515547716152c4539eac/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/zh-Hans/file.json" + "cs": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/cs/file.json", + "da": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/da/file.json", + "de": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/de/file.json", + "en": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/en/file.json", + "et": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/et/file.json", + "fi": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/fi/file.json", + "fr": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/fr/file.json", + "hu": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/hu/file.json", + "nb_NO": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/nb-NO/file.json", + "nl": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/nl/file.json", + "pl": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pl/file.json", + "pt": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pt/file.json", + "pt_BR": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pt-BR/file.json", + "ru": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/ru/file.json", + "sk": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/sk/file.json", + "sv": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/sv/file.json", + "tr": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/tr/file.json", + "uk": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/uk/file.json", + "uz": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/uz/file.json", + "zh#Hans": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/zh-Hans/file.json" } } ] From 258de7e8d2a35061d873ebd5fee8d8770c13c14a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:51:34 +0000 Subject: [PATCH 18/46] 1.25.0-rc.0 --- Cargo.lock | 56 +++++++++++++++++++++++++------------------------- Cargo.toml | 60 +++++++++++++++++++++++++++--------------------------- 2 files changed, 58 insertions(+), 58 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fea904a2d..310211593 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3198,7 +3198,7 @@ dependencies = [ [[package]] name = "mas-axum-utils" -version = "1.24.0" +version = "1.25.0-rc.0" dependencies = [ "anyhow", "axum", @@ -3233,7 +3233,7 @@ dependencies = [ [[package]] name = "mas-cli" -version = "1.24.0" +version = "1.25.0-rc.0" dependencies = [ "anyhow", "axum", @@ -3309,7 +3309,7 @@ dependencies = [ [[package]] name = "mas-config" -version = "1.24.0" +version = "1.25.0-rc.0" dependencies = [ "anyhow", "camino", @@ -3340,7 +3340,7 @@ dependencies = [ [[package]] name = "mas-context" -version = "1.24.0" +version = "1.25.0-rc.0" dependencies = [ "console", "opentelemetry", @@ -3357,7 +3357,7 @@ dependencies = [ [[package]] name = "mas-data-model" -version = "1.24.0" +version = "1.25.0-rc.0" dependencies = [ "base64ct", "chrono", @@ -3379,7 +3379,7 @@ dependencies = [ [[package]] name = "mas-email" -version = "1.24.0" +version = "1.25.0-rc.0" dependencies = [ "async-trait", "lettre", @@ -3390,7 +3390,7 @@ dependencies = [ [[package]] name = "mas-handlers" -version = "1.24.0" +version = "1.25.0-rc.0" dependencies = [ "aide", "anyhow", @@ -3473,7 +3473,7 @@ dependencies = [ [[package]] name = "mas-http" -version = "1.24.0" +version = "1.25.0-rc.0" dependencies = [ "futures-util", "headers", @@ -3493,7 +3493,7 @@ dependencies = [ [[package]] name = "mas-i18n" -version = "1.24.0" +version = "1.25.0-rc.0" dependencies = [ "camino", "icu_datetime", @@ -3513,7 +3513,7 @@ dependencies = [ [[package]] name = "mas-i18n-scan" -version = "1.24.0" +version = "1.25.0-rc.0" dependencies = [ "camino", "clap", @@ -3527,7 +3527,7 @@ dependencies = [ [[package]] name = "mas-iana" -version = "1.24.0" +version = "1.25.0-rc.0" dependencies = [ "schemars 1.2.1", "serde", @@ -3535,7 +3535,7 @@ dependencies = [ [[package]] name = "mas-iana-codegen" -version = "1.24.0" +version = "1.25.0-rc.0" dependencies = [ "anyhow", "async-trait", @@ -3552,7 +3552,7 @@ dependencies = [ [[package]] name = "mas-jose" -version = "1.24.0" +version = "1.25.0-rc.0" dependencies = [ "base64ct", "chrono", @@ -3582,7 +3582,7 @@ dependencies = [ [[package]] name = "mas-keystore" -version = "1.24.0" +version = "1.25.0-rc.0" dependencies = [ "aead", "base64ct", @@ -3610,7 +3610,7 @@ dependencies = [ [[package]] name = "mas-listener" -version = "1.24.0" +version = "1.25.0-rc.0" dependencies = [ "anyhow", "bytes", @@ -3634,7 +3634,7 @@ dependencies = [ [[package]] name = "mas-matrix" -version = "1.24.0" +version = "1.25.0-rc.0" dependencies = [ "anyhow", "async-trait", @@ -3644,7 +3644,7 @@ dependencies = [ [[package]] name = "mas-matrix-synapse" -version = "1.24.0" +version = "1.25.0-rc.0" dependencies = [ "anyhow", "async-trait", @@ -3661,7 +3661,7 @@ dependencies = [ [[package]] name = "mas-oidc-client" -version = "1.24.0" +version = "1.25.0-rc.0" dependencies = [ "assert_matches", "async-trait", @@ -3698,7 +3698,7 @@ dependencies = [ [[package]] name = "mas-policy" -version = "1.24.0" +version = "1.25.0-rc.0" dependencies = [ "anyhow", "arc-swap", @@ -3715,7 +3715,7 @@ dependencies = [ [[package]] name = "mas-router" -version = "1.24.0" +version = "1.25.0-rc.0" dependencies = [ "axum", "serde", @@ -3726,7 +3726,7 @@ dependencies = [ [[package]] name = "mas-spa" -version = "1.24.0" +version = "1.25.0-rc.0" dependencies = [ "camino", "serde", @@ -3735,7 +3735,7 @@ dependencies = [ [[package]] name = "mas-storage" -version = "1.24.0" +version = "1.25.0-rc.0" dependencies = [ "async-trait", "chrono", @@ -3757,7 +3757,7 @@ dependencies = [ [[package]] name = "mas-storage-pg" -version = "1.24.0" +version = "1.25.0-rc.0" dependencies = [ "async-trait", "chrono", @@ -3789,7 +3789,7 @@ dependencies = [ [[package]] name = "mas-tasks" -version = "1.24.0" +version = "1.25.0-rc.0" dependencies = [ "anyhow", "async-trait", @@ -3821,7 +3821,7 @@ dependencies = [ [[package]] name = "mas-templates" -version = "1.24.0" +version = "1.25.0-rc.0" dependencies = [ "anyhow", "arc-swap", @@ -3853,7 +3853,7 @@ dependencies = [ [[package]] name = "mas-tower" -version = "1.24.0" +version = "1.25.0-rc.0" dependencies = [ "http", "opentelemetry", @@ -4105,7 +4105,7 @@ dependencies = [ [[package]] name = "oauth2-types" -version = "1.24.0" +version = "1.25.0-rc.0" dependencies = [ "assert_matches", "base64ct", @@ -6281,7 +6281,7 @@ dependencies = [ [[package]] name = "syn2mas" -version = "1.24.0" +version = "1.25.0-rc.0" dependencies = [ "anyhow", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index 1cec30dea..689b90bdc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ members = ["crates/*"] resolver = "2" # Updated in the CI with a `sed` command -package.version = "1.24.0" +package.version = "1.25.0-rc.0" package.license = "AGPL-3.0-only OR LicenseRef-Element-Commercial" package.authors = ["Element Backend Team"] package.edition = "2024" @@ -42,35 +42,35 @@ broken_intra_doc_links = "deny" [workspace.dependencies] # Workspace crates -mas-axum-utils = { path = "./crates/axum-utils/", version = "=1.24.0" } -mas-cli = { path = "./crates/cli/", version = "=1.24.0" } -mas-config = { path = "./crates/config/", version = "=1.24.0" } -mas-context = { path = "./crates/context/", version = "=1.24.0" } -mas-data-model = { path = "./crates/data-model/", version = "=1.24.0" } -mas-email = { path = "./crates/email/", version = "=1.24.0" } -mas-graphql = { path = "./crates/graphql/", version = "=1.24.0" } -mas-handlers = { path = "./crates/handlers/", version = "=1.24.0" } -mas-http = { path = "./crates/http/", version = "=1.24.0" } -mas-i18n = { path = "./crates/i18n/", version = "=1.24.0" } -mas-i18n-scan = { path = "./crates/i18n-scan/", version = "=1.24.0" } -mas-iana = { path = "./crates/iana/", version = "=1.24.0" } -mas-iana-codegen = { path = "./crates/iana-codegen/", version = "=1.24.0" } -mas-jose = { path = "./crates/jose/", version = "=1.24.0" } -mas-keystore = { path = "./crates/keystore/", version = "=1.24.0" } -mas-listener = { path = "./crates/listener/", version = "=1.24.0" } -mas-matrix = { path = "./crates/matrix/", version = "=1.24.0" } -mas-matrix-synapse = { path = "./crates/matrix-synapse/", version = "=1.24.0" } -mas-oidc-client = { path = "./crates/oidc-client/", version = "=1.24.0" } -mas-policy = { path = "./crates/policy/", version = "=1.24.0" } -mas-router = { path = "./crates/router/", version = "=1.24.0" } -mas-spa = { path = "./crates/spa/", version = "=1.24.0" } -mas-storage = { path = "./crates/storage/", version = "=1.24.0" } -mas-storage-pg = { path = "./crates/storage-pg/", version = "=1.24.0" } -mas-tasks = { path = "./crates/tasks/", version = "=1.24.0" } -mas-templates = { path = "./crates/templates/", version = "=1.24.0" } -mas-tower = { path = "./crates/tower/", version = "=1.24.0" } -oauth2-types = { path = "./crates/oauth2-types/", version = "=1.24.0" } -syn2mas = { path = "./crates/syn2mas", version = "=1.24.0" } +mas-axum-utils = { path = "./crates/axum-utils/", version = "=1.25.0-rc.0" } +mas-cli = { path = "./crates/cli/", version = "=1.25.0-rc.0" } +mas-config = { path = "./crates/config/", version = "=1.25.0-rc.0" } +mas-context = { path = "./crates/context/", version = "=1.25.0-rc.0" } +mas-data-model = { path = "./crates/data-model/", version = "=1.25.0-rc.0" } +mas-email = { path = "./crates/email/", version = "=1.25.0-rc.0" } +mas-graphql = { path = "./crates/graphql/", version = "=1.25.0-rc.0" } +mas-handlers = { path = "./crates/handlers/", version = "=1.25.0-rc.0" } +mas-http = { path = "./crates/http/", version = "=1.25.0-rc.0" } +mas-i18n = { path = "./crates/i18n/", version = "=1.25.0-rc.0" } +mas-i18n-scan = { path = "./crates/i18n-scan/", version = "=1.25.0-rc.0" } +mas-iana = { path = "./crates/iana/", version = "=1.25.0-rc.0" } +mas-iana-codegen = { path = "./crates/iana-codegen/", version = "=1.25.0-rc.0" } +mas-jose = { path = "./crates/jose/", version = "=1.25.0-rc.0" } +mas-keystore = { path = "./crates/keystore/", version = "=1.25.0-rc.0" } +mas-listener = { path = "./crates/listener/", version = "=1.25.0-rc.0" } +mas-matrix = { path = "./crates/matrix/", version = "=1.25.0-rc.0" } +mas-matrix-synapse = { path = "./crates/matrix-synapse/", version = "=1.25.0-rc.0" } +mas-oidc-client = { path = "./crates/oidc-client/", version = "=1.25.0-rc.0" } +mas-policy = { path = "./crates/policy/", version = "=1.25.0-rc.0" } +mas-router = { path = "./crates/router/", version = "=1.25.0-rc.0" } +mas-spa = { path = "./crates/spa/", version = "=1.25.0-rc.0" } +mas-storage = { path = "./crates/storage/", version = "=1.25.0-rc.0" } +mas-storage-pg = { path = "./crates/storage-pg/", version = "=1.25.0-rc.0" } +mas-tasks = { path = "./crates/tasks/", version = "=1.25.0-rc.0" } +mas-templates = { path = "./crates/templates/", version = "=1.25.0-rc.0" } +mas-tower = { path = "./crates/tower/", version = "=1.25.0-rc.0" } +oauth2-types = { path = "./crates/oauth2-types/", version = "=1.25.0-rc.0" } +syn2mas = { path = "./crates/syn2mas", version = "=1.25.0-rc.0" } # OpenAPI schema generation and validation [workspace.dependencies.aide] From 2762927a474a2a18937060ad7e3be5ca4efd16a4 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 10 Sep 2026 15:44:27 +0200 Subject: [PATCH 19/46] Fix the lints introduced by clippy 1.98 The new `unused_async_trait_impl` lint flags `async fn` trait impls without any `.await`. The `FromRequestParts` impls that only read from the request parts now return `std::future::ready(...)` directly. The GraphQL module gets a module-wide allow: async-graphql's `#[Object]` refuses non-async resolvers and its `Enum` derive emits async trait impls of its own. Also drop the `?` on two `return Err(...)?` statements in the introspection handler, flagged by `needless_return_with_question_mark`. --- crates/axum-utils/src/cookies.rs | 10 ++++++--- crates/cli/src/app_state.rs | 24 +++++++++++---------- crates/handlers/src/bin/api-schema.rs | 7 +++--- crates/handlers/src/graphql/mod.rs | 7 +++++- crates/handlers/src/oauth2/introspection.rs | 4 ++-- crates/handlers/src/preferred_language.rs | 10 ++++++--- crates/handlers/src/rate_limit.rs | 18 +++++++++------- crates/handlers/src/test_utils.rs | 21 ++++++++++-------- 8 files changed, 61 insertions(+), 40 deletions(-) diff --git a/crates/axum-utils/src/cookies.rs b/crates/axum-utils/src/cookies.rs index 97f1db830..07b1a9526 100644 --- a/crates/axum-utils/src/cookies.rs +++ b/crates/axum-utils/src/cookies.rs @@ -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. // @@ -6,7 +7,7 @@ //! Private (encrypted) cookie jar, based on axum-extra's cookie jar -use std::convert::Infallible; +use std::{convert::Infallible, future::ready}; use axum::{ extract::{FromRef, FromRequestParts}, @@ -71,9 +72,12 @@ where { type Rejection = Infallible; - async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + fn from_request_parts( + parts: &mut Parts, + state: &S, + ) -> impl Future> + Send { let cookie_manager = CookieManager::from_ref(state); - Ok(cookie_manager.cookie_jar_from_headers(&parts.headers)) + ready(Ok(cookie_manager.cookie_jar_from_headers(&parts.headers))) } } diff --git a/crates/cli/src/app_state.rs b/crates/cli/src/app_state.rs index 25a295d0c..6a9b455fa 100644 --- a/crates/cli/src/app_state.rs +++ b/crates/cli/src/app_state.rs @@ -1,11 +1,11 @@ -// Copyright 2026 Element Creations Ltd. +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-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. -use std::{convert::Infallible, net::IpAddr, sync::Arc}; +use std::{convert::Infallible, future::ready, net::IpAddr, sync::Arc}; use axum::extract::{FromRef, FromRequestParts, State}; use ipnetwork::IpNetwork; @@ -224,28 +224,30 @@ impl FromRef for AppVersion { impl FromRequestParts for BoxClock { type Rejection = Infallible; - async fn from_request_parts( + fn from_request_parts( _parts: &mut axum::http::request::Parts, _state: &AppState, - ) -> Result { + ) -> impl Future> + Send { let clock = SystemClock::default(); - Ok(Box::new(clock)) + let clock: Self = Box::new(clock); + ready(Ok(clock)) } } impl FromRequestParts for BoxRng { type Rejection = Infallible; - async fn from_request_parts( + fn from_request_parts( _parts: &mut axum::http::request::Parts, _state: &AppState, - ) -> Result { + ) -> impl Future> + Send { // This rng is used to source the local rng #[expect(clippy::disallowed_methods)] let rng = rand::thread_rng(); let rng = rand_chacha::ChaChaRng::from_rng(rng).expect("Failed to seed RNG"); - Ok(Box::new(rng)) + let rng: Self = Box::new(rng); + ready(Ok(rng)) } } @@ -264,11 +266,11 @@ impl FromRequestParts for Policy { impl FromRequestParts for ActivityTracker { type Rejection = Infallible; - async fn from_request_parts( + fn from_request_parts( _parts: &mut axum::http::request::Parts, state: &AppState, - ) -> Result { - Ok(state.activity_tracker.clone()) + ) -> impl Future> + Send { + ready(Ok(state.activity_tracker.clone())) } } diff --git a/crates/handlers/src/bin/api-schema.rs b/crates/handlers/src/bin/api-schema.rs index 1b73c05c3..20f56cd96 100644 --- a/crates/handlers/src/bin/api-schema.rs +++ b/crates/handlers/src/bin/api-schema.rs @@ -1,3 +1,4 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // @@ -29,11 +30,11 @@ macro_rules! impl_from_request_parts { impl axum::extract::FromRequestParts for $type { type Rejection = std::convert::Infallible; - async fn from_request_parts( + fn from_request_parts( _parts: &mut axum::http::request::Parts, _state: &DummyState, - ) -> Result { - unimplemented!("This is a dummy state, it should never be used") + ) -> impl Future> + Send { + async { unimplemented!("This is a dummy state, it should never be used") } } } }; diff --git a/crates/handlers/src/graphql/mod.rs b/crates/handlers/src/graphql/mod.rs index 4d4479fd8..b541500c3 100644 --- a/crates/handlers/src/graphql/mod.rs +++ b/crates/handlers/src/graphql/mod.rs @@ -5,7 +5,12 @@ // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // Please see LICENSE files in the repository root for full details. -#![allow(clippy::module_name_repetitions)] +#![allow( + clippy::module_name_repetitions, + // async-graphql's `#[Object]` requires resolvers to be `async fn`, and its + // `Enum` derive emits async trait impls without awaits + clippy::unused_async_trait_impl, +)] use std::{net::IpAddr, ops::Deref, sync::Arc}; diff --git a/crates/handlers/src/oauth2/introspection.rs b/crates/handlers/src/oauth2/introspection.rs index 04ff851e6..12772c3a4 100644 --- a/crates/handlers/src/oauth2/introspection.rs +++ b/crates/handlers/src/oauth2/introspection.rs @@ -515,7 +515,7 @@ pub(crate) async fn post( .ok_or(RouteError::CantLoadUser(session.user_id))?; if !user.is_valid() { - return Err(RouteError::InvalidUser(user.id))?; + return Err(RouteError::InvalidUser(user.id)); } // Grant the synapse admin scope if the session has the admin flag set. @@ -599,7 +599,7 @@ pub(crate) async fn post( .ok_or(RouteError::CantLoadUser(session.user_id))?; if !user.is_valid() { - return Err(RouteError::InvalidUser(user.id))?; + return Err(RouteError::InvalidUser(user.id)); } // Grant the synapse admin scope if the session has the admin flag set. diff --git a/crates/handlers/src/preferred_language.rs b/crates/handlers/src/preferred_language.rs index 8ea38c990..9663da61c 100644 --- a/crates/handlers/src/preferred_language.rs +++ b/crates/handlers/src/preferred_language.rs @@ -1,10 +1,11 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 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. -use std::{convert::Infallible, sync::Arc}; +use std::{convert::Infallible, future::ready, sync::Arc}; use axum::{ extract::{FromRef, FromRequestParts}, @@ -23,7 +24,10 @@ where { type Rejection = Infallible; - async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + fn from_request_parts( + parts: &mut Parts, + state: &S, + ) -> impl Future> + Send { let translator: Arc = FromRef::from_ref(state); let accept_language = parts.headers.typed_get::(); @@ -45,6 +49,6 @@ where let locale = translator.choose_locale(iter); - Ok(PreferredLanguage(locale)) + ready(Ok(PreferredLanguage(locale))) } } diff --git a/crates/handlers/src/rate_limit.rs b/crates/handlers/src/rate_limit.rs index a405589a4..07acc1bc3 100644 --- a/crates/handlers/src/rate_limit.rs +++ b/crates/handlers/src/rate_limit.rs @@ -1,11 +1,11 @@ -// Copyright 2026 Element Creations Ltd. +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2024, 2025 New Vector Ltd. // Copyright 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. -use std::{convert::Infallible, net::IpAddr, sync::Arc, time::Duration}; +use std::{convert::Infallible, future::ready, net::IpAddr, sync::Arc, time::Duration}; use axum::extract::FromRequestParts; use governor::{RateLimiter, clock::QuantaClock, state::keyed::DashMapStateStore}; @@ -82,22 +82,24 @@ impl RequesterFingerprint { impl FromRequestParts for RequesterFingerprint { type Rejection = Infallible; - async fn from_request_parts( + fn from_request_parts( parts: &mut axum::http::request::Parts, _state: &S, - ) -> Result { + ) -> impl Future> + Send { let ip = parts.extensions.get::().and_then(|ip| ip.0); - if let Some(ip) = ip { - Ok(Self::new(ip)) + let fingerprint = if let Some(ip) = ip { + Self::new(ip) } else { // If we can't infer the IP address, we'll just use an empty fingerprint and // warn about it tracing::warn!( "Could not infer client IP address for an operation which rate-limits based on IP addresses" ); - Ok(Self::EMPTY) - } + Self::EMPTY + }; + + ready(Ok(fingerprint)) } } diff --git a/crates/handlers/src/test_utils.rs b/crates/handlers/src/test_utils.rs index b08f8086e..56df8d652 100644 --- a/crates/handlers/src/test_utils.rs +++ b/crates/handlers/src/test_utils.rs @@ -7,6 +7,7 @@ use std::{ convert::Infallible, + future::ready, net::IpAddr, sync::{Arc, Mutex, RwLock}, task::{Context, Poll}, @@ -655,35 +656,37 @@ impl FromRef for AppVersion { impl FromRequestParts for ActivityTracker { type Rejection = Infallible; - async fn from_request_parts( + fn from_request_parts( _parts: &mut axum::http::request::Parts, state: &TestState, - ) -> Result { - Ok(state.activity_tracker.clone()) + ) -> impl Future> + Send { + ready(Ok(state.activity_tracker.clone())) } } impl FromRequestParts for BoxClock { type Rejection = Infallible; - async fn from_request_parts( + fn from_request_parts( _parts: &mut axum::http::request::Parts, state: &TestState, - ) -> Result { - Ok(Box::new(state.clock.clone())) + ) -> impl Future> + Send { + let clock: Self = Box::new(state.clock.clone()); + ready(Ok(clock)) } } impl FromRequestParts for BoxRng { type Rejection = Infallible; - async fn from_request_parts( + fn from_request_parts( _parts: &mut axum::http::request::Parts, state: &TestState, - ) -> Result { + ) -> impl Future> + Send { let mut parent_rng = state.rng.lock().expect("Failed to lock RNG"); let rng = ChaChaRng::from_rng(&mut *parent_rng).expect("Failed to seed RNG"); - Ok(Box::new(rng)) + let rng: Self = Box::new(rng); + ready(Ok(rng)) } } From 357f83c96a85f771ea70fcd301f568d74f2fda7d Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Fri, 11 Sep 2026 12:43:18 +0100 Subject: [PATCH 20/46] Rate limit device code link attempts Add a dedicated `device_code_link` rate limiter for device authorization user code verification. This is to help mitigate the User Code Brute Forcing attack as described by [RFC 8628 section 5.1](https://datatracker.ietf.org/doc/html/rfc8628#section-5.1) Proposed default is 1 per minute and burst of 10. This can be overridden by the server admin. --- crates/config/src/sections/rate_limiting.rs | 19 ++ crates/handlers/src/oauth2/device/link.rs | 193 +++++++++++++++++++- crates/handlers/src/rate_limit.rs | 44 +++++ docs/config.schema.json | 12 ++ docs/reference/configuration.md | 8 + templates/pages/device_link.html | 8 + translations/en.json | 6 +- 7 files changed, 285 insertions(+), 5 deletions(-) diff --git a/crates/config/src/sections/rate_limiting.rs b/crates/config/src/sections/rate_limiting.rs index 0b7c95dbc..bcefa4093 100644 --- a/crates/config/src/sections/rate_limiting.rs +++ b/crates/config/src/sections/rate_limiting.rs @@ -31,6 +31,13 @@ pub struct RateLimitingConfig { /// Email authentication-specific rate limits #[serde(default)] pub email_authentication: EmailauthenticationRateLimitingConfig, + + /// Controls how many user code verification attempts are permitted + /// based on source IP address, when linking a device through the + /// Device Authorization Grant. + /// This can protect against brute-forcing the user code. + #[serde(default = "default_device_code_link")] + pub device_code_link: RateLimiterConfiguration, } #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] @@ -174,6 +181,10 @@ impl ConfigurationSection for RateLimitingConfig { return Err(error_on_nested_field(error, "login", "per_account").into()); } + if let Some(error) = error_on_limiter(&self.device_code_link) { + return Err(error_on_field(error, "device_code_link").into()); + } + Ok(()) } } @@ -257,6 +268,13 @@ fn default_email_authentication_attempt_per_session() -> RateLimiterConfiguratio } } +fn default_device_code_link() -> RateLimiterConfiguration { + RateLimiterConfiguration { + burst: NonZeroU32::new(10).unwrap(), + per_second: 1.0 / 60.0, + } +} + impl Default for RateLimitingConfig { fn default() -> Self { RateLimitingConfig { @@ -264,6 +282,7 @@ impl Default for RateLimitingConfig { registration: default_registration(), account_recovery: AccountRecoveryRateLimitingConfig::default(), email_authentication: EmailauthenticationRateLimitingConfig::default(), + device_code_link: default_device_code_link(), } } } diff --git a/crates/handlers/src/oauth2/device/link.rs b/crates/handlers/src/oauth2/device/link.rs index 38f55ac12..b568f0a49 100644 --- a/crates/handlers/src/oauth2/device/link.rs +++ b/crates/handlers/src/oauth2/device/link.rs @@ -5,6 +5,8 @@ // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // Please see LICENSE files in the repository root for full details. +use std::sync::LazyLock; + use axum::{ Form, extract::State, @@ -21,11 +23,22 @@ use mas_i18n::DataLocale; use mas_router::UrlBuilder; use mas_storage::BoxRepository; use mas_templates::{ - DeviceLinkContext, DeviceLinkFormField, FieldError, FormState, TemplateContext, Templates, + DeviceLinkContext, DeviceLinkFormField, FieldError, FormError, FormState, TemplateContext, + Templates, }; +use opentelemetry::{Key, KeyValue, metrics::Counter}; use serde::{Deserialize, Serialize}; -use crate::{PreferredLanguage, SiteConfig}; +use crate::{Limiter, METER, PreferredLanguage, RequesterFingerprint, SiteConfig}; + +static USER_CODE_ATTEMPT_COUNTER: LazyLock> = LazyLock::new(|| { + METER + .u64_counter("mas.oauth2.device_code_link_attempt") + .with_description("Number of user codes submitted on the device link page") + .with_unit("{attempt}") + .build() +}); +const RESULT: Key = Key::from_static_str("result"); #[derive(Serialize, Deserialize)] pub struct Params { @@ -42,6 +55,8 @@ pub(crate) async fn get( State(templates): State, State(url_builder): State, State(site_config): State, + State(limiter): State, + requester: RequesterFingerprint, cookie_jar: CookieJar, Query(mut query): Query, ) -> Result { @@ -63,6 +78,8 @@ pub(crate) async fn get( &locale, &templates, &url_builder, + &limiter, + requester, cookie_jar, query, ) @@ -78,6 +95,8 @@ pub(crate) async fn post( State(templates): State, State(url_builder): State, State(site_config): State, + State(limiter): State, + requester: RequesterFingerprint, cookie_jar: CookieJar, Form(form): Form>, ) -> Result { @@ -96,12 +115,15 @@ pub(crate) async fn post( &locale, &templates, &url_builder, + &limiter, + requester, cookie_jar, form, ) .await } +#[expect(clippy::too_many_arguments)] async fn handle_code( rng: &mut BoxRng, clock: &BoxClock, @@ -109,6 +131,8 @@ async fn handle_code( locale: &DataLocale, templates: &Templates, url_builder: &UrlBuilder, + limiter: &Limiter, + requester: RequesterFingerprint, cookie_jar: CookieJar, params: Params, ) -> Result { @@ -116,6 +140,25 @@ async fn handle_code( // If we have a code, find it in the database if let Some(code) = ¶ms.code { + // Rate-limit how many user codes a single requester can try, to make + // brute-forcing the user code impractical. RFC 8628 section 5.1. + // This is checked before looking the code up, so that guesses beyond the + // allowance are never evaluated. + if let Err(e) = limiter.check_device_code_link(requester) { + tracing::warn!(error = &e as &dyn std::error::Error, "ratelimit exceeded"); + USER_CODE_ATTEMPT_COUNTER.add(1, &[KeyValue::new(RESULT, "rate_limited")]); + + let (csrf_token, cookie_jar) = cookie_jar.csrf_token(clock, rng); + let ctx = DeviceLinkContext::new() + .with_form_state(form_state.with_error_on_form(FormError::RateLimitExceeded)) + .with_csrf(csrf_token.form_value()) + .with_language(*locale); + + let content = templates.render_device_link(&ctx)?; + + return Ok((cookie_jar, Html(content)).into_response()); + } + let code = code.to_uppercase(); let grant = repo .oauth2_device_code_grant() @@ -128,12 +171,14 @@ async fn handle_code( if let Some(grant) = grant { // This is a valid code, redirect to the consent page // This will in turn redirect to the login page if the user is not logged in + USER_CODE_ATTEMPT_COUNTER.add(1, &[KeyValue::new(RESULT, "success")]); let destination = url_builder.redirect(&mas_router::DeviceCodeConsent::new(grant.id)); return Ok((cookie_jar, destination).into_response()); } // The code isn't valid, set an error on the form + USER_CODE_ATTEMPT_COUNTER.add(1, &[KeyValue::new(RESULT, "invalid")]); form_state = form_state.with_error_on_field(DeviceLinkFormField::Code, FieldError::Invalid); } @@ -149,3 +194,147 @@ async fn handle_code( Ok((cookie_jar, Html(content)).into_response()) } + +#[cfg(test)] +mod tests { + use std::net::{IpAddr, Ipv4Addr}; + + use hyper::{Request, StatusCode, header::CONTENT_TYPE}; + use mas_router::{Route, SimpleRoute}; + use oauth2_types::{ + registration::ClientRegistrationResponse, requests::DeviceAuthorizationResponse, + }; + use sqlx::PgPool; + + use crate::test_utils::{CookieHelper, RequestBuilderExt, ResponseExt, TestState, setup}; + + const ALICE: IpAddr = IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)); + const BOB: IpAddr = IpAddr::V4(Ipv4Addr::new(4, 3, 2, 1)); + + /// Start a device authorization grant and return its user code + async fn get_user_code(state: &TestState) -> String { + let request = + Request::post(mas_router::OAuth2RegistrationEndpoint::PATH).json(serde_json::json!({ + "client_uri": "https://example.com/", + "token_endpoint_auth_method": "none", + "grant_types": ["urn:ietf:params:oauth:grant-type:device_code"], + "response_types": [], + })); + + let response = state.request(request).await; + response.assert_status(StatusCode::CREATED); + let response: ClientRegistrationResponse = response.json(); + + let request = Request::post(mas_router::OAuth2DeviceAuthorizationEndpoint::PATH).form( + serde_json::json!({ + "client_id": response.client_id, + "scope": "openid", + }), + ); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let response: DeviceAuthorizationResponse = response.json(); + + response.user_code + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_link_rate_limit(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let cookies = CookieHelper::new(); + + // Render the link page to get a CSRF token. This shouldn't consume any of the + // rate limit allowance, as no code is submitted + let request = Request::get( + mas_router::DeviceCodeLink::default() + .path_and_query() + .as_ref(), + ) + .client_ip(ALICE) + .empty(); + let request = cookies.with_cookies(request); + let response = state.request(request).await; + cookies.save_cookies(&response); + response.assert_status(StatusCode::OK); + response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); + let csrf_token = response + .body() + .split("name=\"csrf\" value=\"") + .nth(1) + .unwrap() + .split('\"') + .next() + .unwrap() + .to_owned(); + + let request = Request::post(mas_router::DeviceCodeLink::route()) + .client_ip(ALICE) + .form(serde_json::json!({ + "csrf": csrf_token, + "code": "AAAAAA", + })); + let request = cookies.with_cookies(request); + + // The default burst allowance is 10 attempts, which should all be told that the + // code is invalid + for _ in 0..10 { + let response = state.request(request.clone()).await; + response.assert_status(StatusCode::OK); + let body = response.body(); + assert!(body.contains(r#"data-error-kind="invalid""#)); + assert!(!body.contains("too many requests")); + } + + // The next attempt should be rate-limited + let response = state.request(request.clone()).await; + response.assert_status(StatusCode::OK); + let body = response.body(); + assert!(!body.contains(r#"data-error-kind="invalid""#)); + assert!(body.contains("too many requests")); + + // A valid code is refused too: the allowance is checked before the code is + // looked up, so that guesses beyond it are never evaluated + let user_code = get_user_code(&state).await; + let request = Request::get( + mas_router::DeviceCodeLink::with_code(user_code.clone()) + .path_and_query() + .as_ref(), + ) + .client_ip(ALICE) + .empty(); + let request = cookies.with_cookies(request); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + assert!(response.body().contains("too many requests")); + + // Another requester is unaffected and can still use that code + let request = Request::get( + mas_router::DeviceCodeLink::with_code(user_code) + .path_and_query() + .as_ref(), + ) + .client_ip(BOB) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::SEE_OTHER); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_link_valid_code(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let user_code = get_user_code(&state).await; + + // A valid code redirects to the consent page + let request = Request::get( + mas_router::DeviceCodeLink::with_code(user_code) + .path_and_query() + .as_ref(), + ) + .client_ip(ALICE) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::SEE_OTHER); + } +} diff --git a/crates/handlers/src/rate_limit.rs b/crates/handlers/src/rate_limit.rs index 07acc1bc3..246e2d5d4 100644 --- a/crates/handlers/src/rate_limit.rs +++ b/crates/handlers/src/rate_limit.rs @@ -51,6 +51,12 @@ pub enum EmailAuthenticationLimitedError { Email(String), } +#[derive(Debug, Clone, thiserror::Error)] +pub enum DeviceCodeLinkLimitedError { + #[error("Too many device code link attempts for requester {0}")] + Requester(RequesterFingerprint), +} + /// Key used to rate limit requests per requester #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct RequesterFingerprint { @@ -122,6 +128,7 @@ struct LimiterInner { email_authentication_per_email: KeyedRateLimiter, email_authentication_emails_per_session: KeyedRateLimiter, email_authentication_attempt_per_session: KeyedRateLimiter, + device_code_link_per_requester: KeyedRateLimiter, } impl LimiterInner { @@ -148,6 +155,7 @@ impl LimiterInner { email_authentication_attempt_per_session: RateLimiter::keyed( config.email_authentication.attempt_per_session.to_quota()?, ), + device_code_link_per_requester: RateLimiter::keyed(config.device_code_link.to_quota()?), }) } } @@ -193,6 +201,7 @@ impl Limiter { this.inner .email_authentication_attempt_per_session .retain_recent(); + this.inner.device_code_link_per_requester.retain_recent(); interval.tick().await; } @@ -325,6 +334,24 @@ impl Limiter { .check_key(&authentication.id) .map_err(|_| EmailAuthenticationLimitedError::Authentication(authentication.id)) } + + /// Check if a user code can be submitted on the device link page + /// + /// This protects against brute-forcing the user code of a device code + /// grant, as described in RFC 8628 section 5.1. + /// + /// # Errors + /// + /// Returns an error if the operation is rate limited. + pub fn check_device_code_link( + &self, + requester: RequesterFingerprint, + ) -> Result<(), DeviceCodeLinkLimitedError> { + self.inner + .device_code_link_per_requester + .check_key(&requester) + .map_err(|_| DeviceCodeLinkLimitedError::Requester(requester)) + } } #[cfg(test)] @@ -401,4 +428,21 @@ mod tests { // The other account isn't rate-limited assert!(limiter.check_password(requesters[603], &bob).is_ok()); } + + #[test] + fn test_device_code_link_limiter() { + let limiter = Limiter::new(&RateLimitingConfig::default()).unwrap(); + + let alice = RequesterFingerprint::new([1, 2, 3, 4].into()); + let bob = RequesterFingerprint::new([4, 3, 2, 1].into()); + + // The default burst allowance is 10 attempts per requester + for _ in 0..10 { + assert!(limiter.check_device_code_link(alice).is_ok()); + } + assert!(limiter.check_device_code_link(alice).is_err()); + + // Another requester is unaffected + assert!(limiter.check_device_code_link(bob).is_ok()); + } } diff --git a/docs/config.schema.json b/docs/config.schema.json index a6b6560bf..cbe8f67c8 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -2021,6 +2021,18 @@ "$ref": "#/definitions/EmailauthenticationRateLimitingConfig" } ] + }, + "device_code_link": { + "description": "Controls how many user code verification attempts are permitted\nbased on source IP address, when linking a device through the\nDevice Authorization Grant.\nThis can protect against brute-forcing the user code.", + "default": { + "burst": 10, + "per_second": 0.016666666666666666 + }, + "allOf": [ + { + "$ref": "#/definitions/RateLimiterConfiguration" + } + ] } } }, diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 3600ff226..e9f07ae34 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -640,6 +640,14 @@ rate_limiting: attempt_per_session: burst: 10 per_second: 0.016666 + + # Limits how many user codes can be submitted on the device link page, + # based on source IP address. + # This protects against brute-forcing the user code of the + # Device Authorization Grant. + device_code_link: + burst: 10 + per_second: 0.016666 ``` ## `telemetry` diff --git a/templates/pages/device_link.html b/templates/pages/device_link.html index aaf018b1c..b78e8a439 100644 --- a/templates/pages/device_link.html +++ b/templates/pages/device_link.html @@ -22,6 +22,14 @@ Please see LICENSE files in the repository root for full details.
+ {% if form_state.errors is not empty %} + {% for error in form_state.errors %} +
+ {{ errors.form_error_message(error=error) }} +
+ {% endfor %} + {% endif %} + {% call(f) field.field(label=_("mas.device_code_link.verification_code"), name="code", class="mb-4 self-center", form_state=form_state) %}
diff --git a/translations/en.json b/translations/en.json index e7df4b636..7efcc3739 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:53:33-51, pages/policy_violation.html:70:15-33, pages/reauth.html:37:13-31" }, "continue": "Continue", "@continue": { - "context": "form_post.html:26: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:80: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:26:28-48, pages/consent.html:71:28-48, pages/device_link.html:50: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:80: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": { @@ -234,7 +234,7 @@ }, "verification_code": "Verification code", "@verification_code": { - "context": "pages/device_link.html:26:35-78", + "context": "pages/device_link.html:34:35-78", "description": "On the device link page, label of the text field where the user enters the verification code shown on the other device." } }, From 2fd58b9f41c00388d1a5e746448aa8912131cb93 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Mon, 14 Sep 2026 16:07:54 +0100 Subject: [PATCH 21/46] Improve usability of user code during device code flow Use Crockford Base32 alphabet with O/0 and I/L/1 folding. Also help the browser to prompt for correct input. The current alphabet gave 29.7 bits of entropy (it was random 0-9a-zA-Z which was then mapped to 0-9A-Z) whereas the new encoding is 30 bits. --- crates/data-model/src/lib.rs | 1 + .../src/oauth2/device_code_grant.rs | 137 +++++++ crates/data-model/src/oauth2/mod.rs | 4 +- .../handlers/src/oauth2/device/authorize.rs | 25 +- crates/handlers/src/oauth2/device/link.rs | 357 +++++++++++++++++- crates/templates/src/context.rs | 6 +- templates/pages/device_link.html | 4 + 7 files changed, 516 insertions(+), 18 deletions(-) diff --git a/crates/data-model/src/lib.rs b/crates/data-model/src/lib.rs index 75ad1673d..9e713cb58 100644 --- a/crates/data-model/src/lib.rs +++ b/crates/data-model/src/lib.rs @@ -37,6 +37,7 @@ pub use self::{ oauth2::{ AuthorizationCode, AuthorizationGrant, AuthorizationGrantStage, Client, DeviceCodeGrant, DeviceCodeGrantState, InvalidRedirectUriError, JwksOrJwksUri, Pkce, Session, SessionState, + generate_user_code, normalize_user_code, }, policy_data::PolicyData, site_config::{ diff --git a/crates/data-model/src/oauth2/device_code_grant.rs b/crates/data-model/src/oauth2/device_code_grant.rs index d35e42470..8b5223a3b 100644 --- a/crates/data-model/src/oauth2/device_code_grant.rs +++ b/crates/data-model/src/oauth2/device_code_grant.rs @@ -8,11 +8,70 @@ use std::net::IpAddr; use chrono::{DateTime, Utc}; use oauth2_types::scope::Scope; +use rand::{Rng, RngCore}; use serde::Serialize; use ulid::Ulid; use crate::{BrowserSession, InvalidTransitionError, Session}; +/// The [Crockford Base32] symbol set, used for device grant user codes. +/// +/// `I`, `L` and `O` are excluded because they are easily confused with `1` and +/// `0`; `U` is excluded to reduce the chance of a code spelling something +/// obscene. +/// +/// [Crockford Base32]: https://www.crockford.com/base32.html +const USER_CODE_ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + +/// The number of symbols in a generated user code. +/// +/// The alphabet has 32 symbols, so each one carries exactly 5 bits: a +/// six-symbol code is 30 bits of entropy. +const USER_CODE_LENGTH: usize = 6; + +/// Generate a random user code for a device code grant. +pub fn generate_user_code(rng: &mut R) -> String { + (0..USER_CODE_LENGTH) + .map(|_| char::from(USER_CODE_ALPHABET[rng.gen_range(0..USER_CODE_ALPHABET.len())])) + .collect() +} + +/// Apply the [Crockford Base32] decode mapping to a user code as typed by a +/// user. +/// +/// On top of uppercasing, this folds `O` onto `0` and `I` and `L` onto `1`, +/// repairing a user who read a `0` as an `O` or a `1` as an `I`. +/// +/// Note that Crockford only defines those two foldings: it keeps both members +/// of `2`/`Z`, `5`/`S`, `6`/`G`, `8`/`B` and `9`/`Q`, so those stay ambiguous +/// and are deliberately left alone here. +/// +/// `U` is not in the alphabet but has no mapping defined for it either, so it +/// is passed through unchanged. +/// +/// Crockford also allows hyphens as readability separators and ignores them +/// when decoding, but we never put any into the codes we hand out, so there is +/// nothing for a user to optionally include and nothing to strip here. +/// Whitespace and punctuation are likewise left alone, and so will simply fail +/// to match. +/// +/// This mapping is lossy against the alphanumeric alphabet user codes used to +/// be drawn from, so callers must look a code up as plainly uppercased +/// *before* falling back to this — see the lookup in the device link handler. +/// +/// [Crockford Base32]: https://www.crockford.com/base32.html +#[must_use] +pub fn normalize_user_code(code: &str) -> String { + code.to_uppercase() + .chars() + .map(|c| match c { + 'O' => '0', + 'I' | 'L' => '1', + c => c, + }) + .collect() +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case", tag = "state")] pub enum DeviceCodeGrantState { @@ -267,3 +326,81 @@ impl DeviceCodeGrant { }) } } + +#[cfg(test)] +mod tests { + use rand::SeedableRng; + use rand_chacha::ChaCha8Rng; + + use super::*; + + #[test] + fn test_generate_user_code_uses_the_crockford_alphabet() { + let mut rng = ChaCha8Rng::seed_from_u64(42); + + for _ in 0..1000 { + let code = generate_user_code(&mut rng); + + assert_eq!(code.chars().count(), USER_CODE_LENGTH); + for c in code.chars() { + assert!( + USER_CODE_ALPHABET.contains(&u8::try_from(c).unwrap()), + "generated code {code:?} has {c:?}, which is not in the alphabet" + ); + // The whole point of the alphabet: these are the characters + // which get confused for `0` and `1`, plus the one Crockford + // drops to avoid obscenities. + assert!( + !matches!(c, 'I' | 'L' | 'O' | 'U'), + "generated code {code:?} has an excluded character {c:?}" + ); + } + } + } + + /// Normalising a freshly generated code must be a no-op, otherwise the + /// codes we hand out would not match themselves when typed back in + /// correctly. This holds by construction — the alphabet is uppercase and + /// contains none of `I`, `L` or `O` — and this test is what keeps + /// [`generate_user_code`] and [`normalize_user_code`] in step if either is + /// edited later. + #[test] + fn test_generated_user_codes_normalize_to_themselves() { + let mut rng = ChaCha8Rng::seed_from_u64(42); + + for _ in 0..1000 { + let code = generate_user_code(&mut rng); + assert_eq!(normalize_user_code(&code), code); + } + } + + #[test] + fn test_normalize_user_code_applies_the_decode_mapping() { + assert_eq!(normalize_user_code("01ILO"), "01110"); + assert_eq!(normalize_user_code("hello"), "HE110"); + // Crockford defines no mapping for these, so both members of each pair + // stay ambiguous and must be left as they are. + assert_eq!(normalize_user_code("8bs5"), "8BS5"); + assert_eq!(normalize_user_code("2z6g9q"), "2Z6G9Q"); + // `U` is not in the alphabet but has no mapping either. + assert_eq!(normalize_user_code("u"), "U"); + } + + /// We never put separators into the codes we hand out, so there is nothing + /// for a user to optionally include and nothing to strip. Anything which + /// isn't part of the code is passed through, and will simply fail to match. + #[test] + fn test_normalize_user_code_does_not_strip_separators() { + assert_eq!(normalize_user_code("abc-def"), "ABC-DEF"); + assert_eq!(normalize_user_code(" abcdef "), " ABCDEF "); + assert_eq!(normalize_user_code("---"), "---"); + } + + #[test] + fn test_normalize_user_code_is_idempotent() { + for input in ["01ILO", "hello", "abc-def", " 8b s5 ", "xil9oz", ""] { + let once = normalize_user_code(input); + assert_eq!(normalize_user_code(&once), once); + } + } +} diff --git a/crates/data-model/src/oauth2/mod.rs b/crates/data-model/src/oauth2/mod.rs index 798e07e54..3ad92504a 100644 --- a/crates/data-model/src/oauth2/mod.rs +++ b/crates/data-model/src/oauth2/mod.rs @@ -12,6 +12,8 @@ mod session; pub use self::{ authorization_grant::{AuthorizationCode, AuthorizationGrant, AuthorizationGrantStage, Pkce}, client::{Client, InvalidRedirectUriError, JwksOrJwksUri}, - device_code_grant::{DeviceCodeGrant, DeviceCodeGrantState}, + device_code_grant::{ + DeviceCodeGrant, DeviceCodeGrantState, generate_user_code, normalize_user_code, + }, session::{Session, SessionState}, }; diff --git a/crates/handlers/src/oauth2/device/authorize.rs b/crates/handlers/src/oauth2/device/authorize.rs index 3fb711192..827b4f3d5 100644 --- a/crates/handlers/src/oauth2/device/authorize.rs +++ b/crates/handlers/src/oauth2/device/authorize.rs @@ -14,7 +14,7 @@ use mas_axum_utils::{ client_authorization::{ClientAuthorization, CredentialsVerificationError}, record_error, }; -use mas_data_model::{BoxClock, BoxRng}; +use mas_data_model::{BoxClock, BoxRng, generate_user_code}; use mas_keystore::Encrypter; use mas_router::UrlBuilder; use mas_storage::{BoxRepository, oauth2::OAuth2DeviceCodeGrantParams}; @@ -160,7 +160,7 @@ pub(crate) async fn post( let ip_address = activity_tracker.ip(); let device_code = Alphanumeric.sample_string(&mut rng, 32); - let user_code = Alphanumeric.sample_string(&mut rng, 6).to_uppercase(); + let user_code = generate_user_code(&mut rng); let device_code = repo .oauth2_device_code_grant() @@ -216,6 +216,23 @@ mod tests { use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup, test_site_config}; + /// The user code is drawn from the Crockford Base32 alphabet, which + /// excludes the characters which get read as `0` and `1` (and the `U` + /// which Crockford drops to avoid obscenities). + fn assert_user_code_is_well_formed(user_code: &str) { + assert_eq!(user_code.len(), 6); + for c in user_code.chars() { + assert!( + c.is_ascii_digit() || c.is_ascii_uppercase(), + "user code {user_code:?} has {c:?}, which is not an uppercase alphanumeric" + ); + assert!( + !matches!(c, 'I' | 'L' | 'O' | 'U'), + "user code {user_code:?} has an excluded character {c:?}" + ); + } + } + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] async fn test_device_code_request(pool: PgPool) { setup(); @@ -248,7 +265,7 @@ mod tests { let response: DeviceAuthorizationResponse = response.json(); assert_eq!(response.device_code.len(), 32); - assert_eq!(response.user_code.len(), 6); + assert_user_code_is_well_formed(&response.user_code); assert!(response.verification_uri_complete.is_some()); } @@ -292,7 +309,7 @@ mod tests { let response: DeviceAuthorizationResponse = response.json(); assert_eq!(response.device_code.len(), 32); - assert_eq!(response.user_code.len(), 6); + assert_user_code_is_well_formed(&response.user_code); assert!(response.verification_uri_complete.is_none()); } diff --git a/crates/handlers/src/oauth2/device/link.rs b/crates/handlers/src/oauth2/device/link.rs index 38f55ac12..8c073e6ce 100644 --- a/crates/handlers/src/oauth2/device/link.rs +++ b/crates/handlers/src/oauth2/device/link.rs @@ -16,10 +16,10 @@ use mas_axum_utils::{ cookies::CookieJar, csrf::{CsrfExt, ProtectedForm}, }; -use mas_data_model::{BoxClock, BoxRng}; +use mas_data_model::{BoxClock, BoxRng, DeviceCodeGrant, normalize_user_code}; use mas_i18n::DataLocale; use mas_router::UrlBuilder; -use mas_storage::BoxRepository; +use mas_storage::{BoxRepository, RepositoryError}; use mas_templates::{ DeviceLinkContext, DeviceLinkFormField, FieldError, FormState, TemplateContext, Templates, }; @@ -102,6 +102,27 @@ pub(crate) async fn post( .await } +/// Find a device code grant by its user code, ignoring any grant which is no +/// longer usable. +/// +/// The liveness checks belong here rather than at the call site because +/// [`handle_code`] tries more than one candidate code: a grant which is +/// expired or has already been used sits at a real row, and must not stop the +/// caller from trying its next candidate. +async fn find_usable_grant( + repo: &mut BoxRepository, + clock: &BoxClock, + user_code: &str, +) -> Result, RepositoryError> { + Ok(repo + .oauth2_device_code_grant() + .find_by_user_code(user_code) + .await? + // XXX: We should have different error messages for already exchanged and expired + .filter(|grant| grant.is_pending()) + .filter(|grant| grant.expires_at > clock.now())) +} + async fn handle_code( rng: &mut BoxRng, clock: &BoxClock, @@ -116,14 +137,27 @@ async fn handle_code( // If we have a code, find it in the database if let Some(code) = ¶ms.code { - let code = code.to_uppercase(); - let grant = repo - .oauth2_device_code_grant() - .find_by_user_code(&code) - .await? - // XXX: We should have different error messages for already exchanged and expired - .filter(|grant| grant.is_pending()) - .filter(|grant| grant.expires_at > clock.now()); + // Look the code up as it was typed first, so that codes issued before + // the Crockford alphabet was adopted — which may contain a literal + // `I`, `L` or `O` — still resolve. Only then apply the decode mapping, + // which repairs a user who read a `0` as an `O` or a `1` as an `I`. + // + // Doing it in this order means the fallback for codes issued by an + // older version and the repair of a misread code are the same + // mechanism, so there is no transitional code to remove later. + let uppercased = code.to_uppercase(); + let normalized = normalize_user_code(code); + + // Note that each candidate is checked for usability as part of its own + // lookup. A grant which is expired or already used still occupies a + // row, so testing liveness only after picking a candidate would let a + // dead grant sitting at the code as typed swallow the lookup and block + // the fallback below. + let mut grant = find_usable_grant(&mut repo, clock, &uppercased).await?; + + if grant.is_none() && normalized != uppercased { + grant = find_usable_grant(&mut repo, clock, &normalized).await?; + } if let Some(grant) = grant { // This is a valid code, redirect to the consent page @@ -149,3 +183,306 @@ async fn handle_code( Ok((cookie_jar, Html(content)).into_response()) } + +#[cfg(test)] +mod tests { + use chrono::Duration; + use hyper::{Request, StatusCode, header::LOCATION}; + use mas_data_model::Client; + use mas_router::{Route, SimpleRoute}; + use mas_storage::oauth2::OAuth2DeviceCodeGrantParams; + use oauth2_types::{registration::ClientRegistrationResponse, scope::OPENID}; + use sqlx::PgPool; + + use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + + /// Register a client which is allowed to use the device code grant. + async fn device_client(state: &TestState) -> Client { + let request = + Request::post(mas_router::OAuth2RegistrationEndpoint::PATH).json(serde_json::json!({ + "client_uri": "https://example.com/", + "token_endpoint_auth_method": "none", + "grant_types": ["urn:ietf:params:oauth:grant-type:device_code"], + "response_types": [], + })); + let response = state.request(request).await; + response.assert_status(StatusCode::CREATED); + let response: 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 + } + + /// Create a pending device code grant with an exact `user_code`, so that + /// tests can control the format of the code rather than taking whatever + /// the generator produces. + async fn grant_with_user_code(state: &TestState, client: &Client, user_code: &str) { + grant_with_user_code_expiring_in( + state, + client, + user_code, + Duration::try_minutes(20).unwrap(), + ) + .await; + } + + /// As [`grant_with_user_code`], but with an explicit lifetime. A negative + /// `expires_in` gives a grant which is already expired, since the store + /// records `expires_at` as `now + expires_in`. + async fn grant_with_user_code_expiring_in( + state: &TestState, + client: &Client, + user_code: &str, + expires_in: Duration, + ) { + let mut repo = state.repository().await.unwrap(); + repo.oauth2_device_code_grant() + .add( + &mut state.rng(), + &state.clock, + OAuth2DeviceCodeGrantParams { + client, + scope: [OPENID].into_iter().collect(), + // `device_code` is unique too, and some of these tests + // create more than one grant + device_code: format!("devicecode-{user_code}"), + user_code: user_code.to_owned(), + expires_in, + ip_address: None, + user_agent: None, + }, + ) + .await + .unwrap(); + repo.save().await.unwrap(); + } + + /// Submit a code to the link endpoint, returning whether it resolved to a + /// grant. The `GET` form of the endpoint takes the code as a query + /// parameter and runs the same lookup as the form `POST`, without needing + /// a CSRF token. + async fn submit_code(state: &TestState, code: &str) -> bool { + let uri = mas_router::DeviceCodeLink::with_code(code.to_owned()).path_and_query(); + let response = state.request(Request::get(&*uri).empty()).await; + + match response.status() { + StatusCode::SEE_OTHER => { + let location = response.headers().get(LOCATION).unwrap().to_str().unwrap(); + assert!( + location.contains("/device/"), + "expected a redirect to the consent page, got {location:?}" + ); + true + } + // The form is re-rendered with an error on the field + StatusCode::OK => { + assert!(response.body().contains("mfa-code-input")); + false + } + status => panic!("unexpected status {status}"), + } + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_link_valid_code(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let client = device_client(&state).await; + + grant_with_user_code(&state, &client, "D0WK7B").await; + + assert!(submit_code(&state, "D0WK7B").await); + } + + /// A user who mistypes the code in the ways the Crockford decode mapping + /// is defined to repair should still get through. + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_link_applies_the_decode_mapping(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let client = device_client(&state).await; + + // Contains both a `0` and a `1`, the two characters with look-alikes + grant_with_user_code(&state, &client, "D0WK1B").await; + + // Typed verbatim + assert!(submit_code(&state, "D0WK1B").await); + // Lowercased + assert!(submit_code(&state, "d0wk1b").await); + // `0` read as `O`, and `1` read as `I` or `L` + assert!(submit_code(&state, "DOWK1B").await); + assert!(submit_code(&state, "D0WKIB").await); + assert!(submit_code(&state, "D0WKLB").await); + assert!(submit_code(&state, "dowklb").await); + } + + /// The codes we hand out contain no separators, so there is nothing for a + /// user to optionally include and nothing for the endpoint to strip. This + /// matches what it did before the Crockford alphabet was adopted. + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_link_does_not_tolerate_separators(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let client = device_client(&state).await; + + grant_with_user_code(&state, &client, "D0WK1B").await; + + assert!(!submit_code(&state, "D0W-K1B").await); + assert!(!submit_code(&state, "D0W K1B").await); + assert!(!submit_code(&state, " D0WK1B ").await); + } + + /// User codes issued before the Crockford alphabet was adopted may contain + /// a literal `I`, `L` or `O`, which the decode mapping would rewrite into + /// something that no longer matches the stored row. The handler looks the + /// code up as typed before folding it, which is what keeps those working + /// across an upgrade. + /// + /// This is a regression test for that ordering: it fails if the handler is + /// ever reduced to a single, folded lookup. + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_link_legacy_code_still_resolves(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let client = device_client(&state).await; + + // Every character the decode mapping is lossy over, plus the `U` which + // is excluded from the alphabet but has no mapping defined for it + grant_with_user_code(&state, &client, "XIL9OZ").await; + grant_with_user_code(&state, &client, "QUIRKY").await; + + assert!(submit_code(&state, "XIL9OZ").await); + assert!(submit_code(&state, "xil9oz").await); + assert!(submit_code(&state, "QUIRKY").await); + } + + /// A legacy code and a new-format code which fold onto each other can be + /// pending at the same time during an upgrade. Each must resolve to its + /// own grant rather than shadowing the other. + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_link_legacy_and_new_codes_do_not_collide(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let client = device_client(&state).await; + + grant_with_user_code(&state, &client, "DOWK7B").await; + grant_with_user_code(&state, &client, "D0WK7B").await; + + let mut repo = state.repository().await.unwrap(); + let legacy = repo + .oauth2_device_code_grant() + .find_by_user_code("DOWK7B") + .await + .unwrap() + .unwrap(); + let current = repo + .oauth2_device_code_grant() + .find_by_user_code("D0WK7B") + .await + .unwrap() + .unwrap(); + repo.save().await.unwrap(); + assert_ne!(legacy.id, current.id); + + // Each code must reach its own grant: the exact match is tried first, + // so the fold never steals a lookup from the legacy grant. + let uri = mas_router::DeviceCodeLink::with_code("DOWK7B".to_owned()).path_and_query(); + let response = state.request(Request::get(&*uri).empty()).await; + response.assert_status(StatusCode::SEE_OTHER); + let location = response.headers().get(LOCATION).unwrap().to_str().unwrap(); + assert!(location.contains(&legacy.id.to_string())); + + let uri = mas_router::DeviceCodeLink::with_code("D0WK7B".to_owned()).path_and_query(); + let response = state.request(Request::get(&*uri).empty()).await; + response.assert_status(StatusCode::SEE_OTHER); + let location = response.headers().get(LOCATION).unwrap().to_str().unwrap(); + assert!(location.contains(¤t.id.to_string())); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_link_invalid_code(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let client = device_client(&state).await; + + grant_with_user_code(&state, &client, "D0WK7B").await; + + assert!(!submit_code(&state, "ZZZZZZ").await); + assert!(!submit_code(&state, "").await); + // Close, but not a code we issued + assert!(!submit_code(&state, "D0WK7C").await); + } + + /// A dead grant sitting at the code exactly as typed must not stop the + /// folded lookup from finding a live one. + /// + /// This is the misread case the two-step lookup exists for: the user holds + /// a live `D0WK1B`, reads the `0` as an `O`, and types `DOWK1B` — which + /// happens to be an expired grant left over from before the upgrade. If + /// liveness is only checked after choosing a candidate, that expired row + /// comes back as `Some`, suppresses the fallback, and is only then + /// filtered away, so the live grant is never reached. + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_link_expired_grant_does_not_shadow_the_folded_lookup(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let client = device_client(&state).await; + + // Already expired, and sits exactly where the typed code lands + grant_with_user_code_expiring_in( + &state, + &client, + "DOWK1B", + Duration::try_minutes(-20).unwrap(), + ) + .await; + // Live, and is what the user is actually trying to reach + grant_with_user_code(&state, &client, "D0WK1B").await; + + let mut repo = state.repository().await.unwrap(); + let live = repo + .oauth2_device_code_grant() + .find_by_user_code("D0WK1B") + .await + .unwrap() + .unwrap(); + repo.save().await.unwrap(); + + let uri = mas_router::DeviceCodeLink::with_code("DOWK1B".to_owned()).path_and_query(); + let response = state.request(Request::get(&*uri).empty()).await; + response.assert_status(StatusCode::SEE_OTHER); + let location = response.headers().get(LOCATION).unwrap().to_str().unwrap(); + assert!( + location.contains(&live.id.to_string()), + "expected the live grant {}, got a redirect to {location:?}", + live.id + ); + } + + /// The decode mapping runs one way only: `O` is read as `0`, never the + /// reverse. So a legacy code containing a literal `O` is reachable by + /// typing that `O`, but not by typing a `0` in its place. + /// + /// This is a limitation rather than a goal — it matches what the endpoint + /// did before this alphabet was adopted, and only applies to codes issued + /// by an older version, which expire within 20 minutes of an upgrade. It + /// is asserted here so the behaviour is documented rather than incidental. + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_link_decode_mapping_is_one_directional(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let client = device_client(&state).await; + + grant_with_user_code(&state, &client, "DOWK7B").await; + + assert!(submit_code(&state, "DOWK7B").await); + assert!(!submit_code(&state, "D0WK7B").await); + } +} diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index 090ba4333..6148cc165 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -857,7 +857,7 @@ impl TemplateContext for PolicyViolationContext { state: mas_data_model::DeviceCodeGrantState::Pending, client_id: client.id, scope: [OPENID].into_iter().collect(), - user_code: Alphanumeric.sample_string(rng, 6).to_uppercase(), + user_code: mas_data_model::generate_user_code(rng), device_code: Alphanumeric.sample_string(rng, 32), created_at: now - Duration::try_minutes(5).unwrap(), expires_at: now + Duration::try_minutes(25).unwrap(), @@ -876,7 +876,7 @@ impl TemplateContext for PolicyViolationContext { state: mas_data_model::DeviceCodeGrantState::Pending, client_id: client.id, scope: [OPENID].into_iter().collect(), - user_code: Alphanumeric.sample_string(rng, 6).to_uppercase(), + user_code: mas_data_model::generate_user_code(rng), device_code: Alphanumeric.sample_string(rng, 32), created_at: now - Duration::try_minutes(5).unwrap(), expires_at: now + Duration::try_minutes(25).unwrap(), @@ -1918,7 +1918,7 @@ impl TemplateContext for DeviceConsentContext { state: mas_data_model::DeviceCodeGrantState::Pending, client_id: client.id, scope: [OPENID].into_iter().collect(), - user_code: Alphanumeric.sample_string(rng, 6).to_uppercase(), + user_code: mas_data_model::generate_user_code(rng), device_code: Alphanumeric.sample_string(rng, 32), created_at: now - Duration::try_minutes(5).unwrap(), expires_at: now + Duration::try_minutes(25).unwrap(), diff --git a/templates/pages/device_link.html b/templates/pages/device_link.html index aaf018b1c..b74a6e9b8 100644 --- a/templates/pages/device_link.html +++ b/templates/pages/device_link.html @@ -30,6 +30,10 @@ Please see LICENSE files in the repository root for full details. type="text" minlength="0" maxlength="6" + autocapitalize="characters" + autocorrect="off" + autocomplete="off" + spellcheck="false" class="cpd-mfa-control uppercase" required> From e63950f8e0046ce05f6d90548e4ffbe918b5b935 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Mon, 14 Sep 2026 17:08:53 +0100 Subject: [PATCH 22/46] Update schemas --- translations/en.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/translations/en.json b/translations/en.json index e7df4b636..d977100b7 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:49:33-51, pages/policy_violation.html:70:15-33, pages/reauth.html:37:13-31" }, "continue": "Continue", "@continue": { - "context": "form_post.html:26: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:80: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:26:28-48, pages/consent.html:71:28-48, pages/device_link.html:46: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:80: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": { From f99bf44090e7803f8c1b83915ebccb5f05267aee Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Tue, 15 Sep 2026 14:48:55 +0200 Subject: [PATCH 23/46] Update rustls to 0.23.45 to fix RUSTSEC-2026-0285 --- Cargo.lock | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 310211593..bc4468a78 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -537,9 +537,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.16.2" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" dependencies = [ "aws-lc-sys", "zeroize", @@ -547,14 +547,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.39.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -1747,7 +1748,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5289,14 +5290,14 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] name = "rustls" -version = "0.23.35" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "aws-lc-rs", "log", @@ -5347,7 +5348,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5358,9 +5359,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", @@ -6373,7 +6374,7 @@ dependencies = [ "getrandom 0.3.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] From 179f1f9b1d09711852f35f9b819a37cb13b150ab Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Tue, 15 Sep 2026 10:29:25 +0200 Subject: [PATCH 24/46] Extract the password complexity feedback out of `PasswordCreationDoubleInput` The strength meter, the improvement hints and the too-weak error move to a `PasswordComplexityFeedback` component backed by a `usePasswordComplexity` hook, so that forms which lay out their password fields differently can reuse them. `PasswordCreationDoubleInput` takes the minimum complexity as a plain number instead of a `SiteConfig` fragment; its two callers already query `siteConfig`, so they pass the field directly. --- .../components/PasswordComplexityFeedback.tsx | 49 ++++++++++ .../PasswordCreationDoubleInput.tsx | 93 +++---------------- frontend/src/gql/gql.ts | 18 ++-- frontend/src/gql/graphql.ts | 34 ++----- frontend/src/routes/password.change.index.tsx | 5 +- .../src/routes/password.recovery.index.tsx | 5 +- frontend/src/utils/usePasswordComplexity.ts | 46 +++++++++ 7 files changed, 126 insertions(+), 124 deletions(-) create mode 100644 frontend/src/components/PasswordComplexityFeedback.tsx create mode 100644 frontend/src/utils/usePasswordComplexity.ts diff --git a/frontend/src/components/PasswordComplexityFeedback.tsx b/frontend/src/components/PasswordComplexityFeedback.tsx new file mode 100644 index 000000000..e357ef73c --- /dev/null +++ b/frontend/src/components/PasswordComplexityFeedback.tsx @@ -0,0 +1,49 @@ +// Copyright 2026 Element Creations Ltd. +// Copyright 2024, 2025 New Vector Ltd. +// Copyright 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. + +import { Form, Progress } from "@vector-im/compound-web"; +import { useTranslation } from "react-i18next"; + +import { usePasswordComplexity } from "../utils/usePasswordComplexity"; + +const TINTS = ["red", "red", "orange", "lime", "green"] as const; + +/** + * Strength meter, improvement hints and too-weak error for a new password. + * Renders as a set of `Form.Field` children, so it must be used inside one. + */ +const PasswordComplexityFeedback: React.FC<{ + password: string; + minimumPasswordComplexity: number; +}> = ({ password, minimumPasswordComplexity }) => { + const { t } = useTranslation(); + const complexity = usePasswordComplexity(password); + + return ( + <> + complexity.scoreText} + tint={password === "" ? undefined : TINTS[complexity.score]} + max={4} + value={complexity.score} + /> + + {complexity.improvementsText.map((suggestion) => ( + {suggestion} + ))} + + {complexity.score < minimumPasswordComplexity && ( + true}> + {t("frontend.password_strength.too_weak")} + + )} + + ); +}; + +export default PasswordComplexityFeedback; diff --git a/frontend/src/components/PasswordCreationDoubleInput.tsx b/frontend/src/components/PasswordCreationDoubleInput.tsx index 6724463c5..ea77a078a 100644 --- a/frontend/src/components/PasswordCreationDoubleInput.tsx +++ b/frontend/src/components/PasswordCreationDoubleInput.tsx @@ -1,84 +1,27 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2024, 2025 New Vector Ltd. // Copyright 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. -import { Form, Progress } from "@vector-im/compound-web"; -import { useDeferredValue, useEffect, useRef, useState } from "react"; +import { Form } from "@vector-im/compound-web"; +import { useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { type FragmentType, graphql, useFragment } from "../gql"; -import type { PasswordComplexity } from "../utils/password_complexity"; - -const CONFIG_FRAGMENT = graphql(/* GraphQL */ ` - fragment PasswordCreationDoubleInput_siteConfig on SiteConfig { - id - minimumPasswordComplexity - } -`); - -// This will load the password complexity module lazily, -// so that it doesn't block the initial render and can be code-split -const loadPromise = import("../utils/password_complexity").then( - ({ estimatePasswordComplexity }) => estimatePasswordComplexity, -); - -const usePasswordComplexity = (password: string): PasswordComplexity => { - const { t } = useTranslation(); - const [result, setResult] = useState({ - score: 0, - scoreText: t("frontend.password_strength.placeholder"), - improvementsText: [], - }); - const deferredPassword = useDeferredValue(password); - - useEffect(() => { - if (deferredPassword === "") { - setResult({ - score: 0, - scoreText: t("frontend.password_strength.placeholder"), - improvementsText: [], - }); - } else { - loadPromise - .then((estimatePasswordComplexity) => - estimatePasswordComplexity(deferredPassword, t), - ) - .then((response) => setResult(response)); - } - }, [deferredPassword, t]); - - return result; -}; +import PasswordComplexityFeedback from "./PasswordComplexityFeedback"; export default function PasswordCreationDoubleInput({ - siteConfig, - forceShowNewPasswordInvalid, + minimumPasswordComplexity, + forceShowNewPasswordInvalid = false, }: { - siteConfig: FragmentType; - forceShowNewPasswordInvalid: boolean; + minimumPasswordComplexity: number; + forceShowNewPasswordInvalid?: boolean; }): React.ReactElement { const { t } = useTranslation(); - const { minimumPasswordComplexity } = useFragment( - CONFIG_FRAGMENT, - siteConfig, - ); - - const newPasswordRef = useRef(null); const newPasswordAgainRef = useRef(null); const [newPassword, setNewPassword] = useState(""); - const passwordComplexity = usePasswordComplexity(newPassword); - let passwordStrengthTint: "red" | "orange" | "lime" | "green" | undefined; - if (newPassword === "") { - passwordStrengthTint = undefined; - } else { - passwordStrengthTint = (["red", "red", "orange", "lime", "green"] as const)[ - passwordComplexity.score - ]; - } - return ( <> @@ -89,7 +32,6 @@ export default function PasswordCreationDoubleInput({ newPasswordAgainRef.current?.value && newPasswordAgainRef.current?.reportValidity() @@ -97,24 +39,11 @@ export default function PasswordCreationDoubleInput({ onChange={(e) => setNewPassword(e.target.value)} /> - passwordComplexity.scoreText} - tint={passwordStrengthTint} - max={4} - value={passwordComplexity.score} + - {passwordComplexity.improvementsText.map((suggestion) => ( - {suggestion} - ))} - - {passwordComplexity.score < minimumPasswordComplexity && ( - true}> - {t("frontend.password_strength.too_weak")} - - )} - {t("frontend.errors.field_required")} diff --git a/frontend/src/gql/gql.ts b/frontend/src/gql/gql.ts index 72047364f..eca477e5d 100644 --- a/frontend/src/gql/gql.ts +++ b/frontend/src/gql/gql.ts @@ -25,7 +25,6 @@ type Documents = { "\n fragment Footer_siteConfig on SiteConfig {\n id\n imprint\n tosUri\n policyUri\n }\n": typeof types.Footer_SiteConfigFragmentDoc, "\n query Footer {\n siteConfig {\n id\n ...Footer_siteConfig\n }\n }\n": typeof types.FooterDocument, "\n fragment OAuth2Session_session on Oauth2Session {\n id\n scope\n createdAt\n finishedAt\n lastActiveIp\n lastActiveAt\n humanName\n\n ...EndOAuth2SessionButton_session\n\n userAgent {\n name\n model\n os\n deviceType\n }\n\n client {\n id\n clientId\n clientName\n applicationType\n logoUri\n }\n }\n": typeof types.OAuth2Session_SessionFragmentDoc, - "\n fragment PasswordCreationDoubleInput_siteConfig on SiteConfig {\n id\n minimumPasswordComplexity\n }\n": typeof types.PasswordCreationDoubleInput_SiteConfigFragmentDoc, "\n fragment EndBrowserSessionButton_session on BrowserSession {\n id\n userAgent {\n name\n os\n model\n deviceType\n }\n }\n": typeof types.EndBrowserSessionButton_SessionFragmentDoc, "\n mutation EndBrowserSession($id: ID!) {\n endBrowserSession(input: { browserSessionId: $id }) {\n status\n browserSession {\n id\n }\n }\n }\n": typeof types.EndBrowserSessionDocument, "\n fragment EndCompatSessionButton_session on CompatSession {\n id\n userAgent {\n name\n os\n model\n deviceType\n }\n ssoLogin {\n id\n redirectUri\n }\n }\n": typeof types.EndCompatSessionButton_SessionFragmentDoc, @@ -62,11 +61,11 @@ type Documents = { "\n mutation DoVerifyEmail($id: ID!, $code: String!) {\n completeEmailAuthentication(input: { id: $id, code: $code }) {\n status\n }\n }\n": typeof types.DoVerifyEmailDocument, "\n mutation ResendEmailAuthenticationCode($id: ID!, $language: String!) {\n resendEmailAuthenticationCode(input: { id: $id, language: $language }) {\n status\n }\n }\n": typeof types.ResendEmailAuthenticationCodeDocument, "\n mutation ChangePassword(\n $userId: ID!\n $oldPassword: String!\n $newPassword: String!\n ) {\n setPassword(\n input: {\n userId: $userId\n currentPassword: $oldPassword\n newPassword: $newPassword\n }\n ) {\n status\n }\n }\n": typeof types.ChangePasswordDocument, - "\n query PasswordChange {\n viewer {\n __typename\n ... on Node {\n id\n }\n }\n\n siteConfig {\n ...PasswordCreationDoubleInput_siteConfig\n }\n }\n": typeof types.PasswordChangeDocument, + "\n query PasswordChange {\n viewer {\n __typename\n ... on Node {\n id\n }\n }\n\n siteConfig {\n minimumPasswordComplexity\n }\n }\n": typeof types.PasswordChangeDocument, "\n mutation RecoverPassword($ticket: String!, $newPassword: String!) {\n setPasswordByRecovery(\n input: { ticket: $ticket, newPassword: $newPassword }\n ) {\n status\n }\n }\n": typeof types.RecoverPasswordDocument, "\n mutation ResendRecoveryEmail($ticket: String!) {\n resendRecoveryEmail(input: { ticket: $ticket }) {\n status\n progressUrl\n }\n }\n": typeof types.ResendRecoveryEmailDocument, "\n fragment RecoverPassword_userRecoveryTicket on UserRecoveryTicket {\n username\n email\n }\n": typeof types.RecoverPassword_UserRecoveryTicketFragmentDoc, - "\n fragment RecoverPassword_siteConfig on SiteConfig {\n ...PasswordCreationDoubleInput_siteConfig\n }\n": typeof types.RecoverPassword_SiteConfigFragmentDoc, + "\n fragment RecoverPassword_siteConfig on SiteConfig {\n minimumPasswordComplexity\n }\n": typeof types.RecoverPassword_SiteConfigFragmentDoc, "\n query PasswordRecovery($ticket: String!) {\n siteConfig {\n ...RecoverPassword_siteConfig\n }\n\n userRecoveryTicket(ticket: $ticket) {\n status\n ...RecoverPassword_userRecoveryTicket\n }\n }\n": typeof types.PasswordRecoveryDocument, "\n mutation AllowCrossSigningReset($userId: ID!) {\n allowUserCrossSigningReset(input: { userId: $userId }) {\n user {\n id\n }\n }\n }\n": typeof types.AllowCrossSigningResetDocument, "\n query SessionDetail($id: ID!) {\n viewerSession {\n ... on Node {\n id\n }\n }\n\n node(id: $id) {\n __typename\n id\n ...CompatSession_detail\n ...OAuth2Session_detail\n ...BrowserSession_detail\n }\n }\n": typeof types.SessionDetailDocument, @@ -82,7 +81,6 @@ const documents: Documents = { "\n fragment Footer_siteConfig on SiteConfig {\n id\n imprint\n tosUri\n policyUri\n }\n": types.Footer_SiteConfigFragmentDoc, "\n query Footer {\n siteConfig {\n id\n ...Footer_siteConfig\n }\n }\n": types.FooterDocument, "\n fragment OAuth2Session_session on Oauth2Session {\n id\n scope\n createdAt\n finishedAt\n lastActiveIp\n lastActiveAt\n humanName\n\n ...EndOAuth2SessionButton_session\n\n userAgent {\n name\n model\n os\n deviceType\n }\n\n client {\n id\n clientId\n clientName\n applicationType\n logoUri\n }\n }\n": types.OAuth2Session_SessionFragmentDoc, - "\n fragment PasswordCreationDoubleInput_siteConfig on SiteConfig {\n id\n minimumPasswordComplexity\n }\n": types.PasswordCreationDoubleInput_SiteConfigFragmentDoc, "\n fragment EndBrowserSessionButton_session on BrowserSession {\n id\n userAgent {\n name\n os\n model\n deviceType\n }\n }\n": types.EndBrowserSessionButton_SessionFragmentDoc, "\n mutation EndBrowserSession($id: ID!) {\n endBrowserSession(input: { browserSessionId: $id }) {\n status\n browserSession {\n id\n }\n }\n }\n": types.EndBrowserSessionDocument, "\n fragment EndCompatSessionButton_session on CompatSession {\n id\n userAgent {\n name\n os\n model\n deviceType\n }\n ssoLogin {\n id\n redirectUri\n }\n }\n": types.EndCompatSessionButton_SessionFragmentDoc, @@ -119,11 +117,11 @@ const documents: Documents = { "\n mutation DoVerifyEmail($id: ID!, $code: String!) {\n completeEmailAuthentication(input: { id: $id, code: $code }) {\n status\n }\n }\n": types.DoVerifyEmailDocument, "\n mutation ResendEmailAuthenticationCode($id: ID!, $language: String!) {\n resendEmailAuthenticationCode(input: { id: $id, language: $language }) {\n status\n }\n }\n": types.ResendEmailAuthenticationCodeDocument, "\n mutation ChangePassword(\n $userId: ID!\n $oldPassword: String!\n $newPassword: String!\n ) {\n setPassword(\n input: {\n userId: $userId\n currentPassword: $oldPassword\n newPassword: $newPassword\n }\n ) {\n status\n }\n }\n": types.ChangePasswordDocument, - "\n query PasswordChange {\n viewer {\n __typename\n ... on Node {\n id\n }\n }\n\n siteConfig {\n ...PasswordCreationDoubleInput_siteConfig\n }\n }\n": types.PasswordChangeDocument, + "\n query PasswordChange {\n viewer {\n __typename\n ... on Node {\n id\n }\n }\n\n siteConfig {\n minimumPasswordComplexity\n }\n }\n": types.PasswordChangeDocument, "\n mutation RecoverPassword($ticket: String!, $newPassword: String!) {\n setPasswordByRecovery(\n input: { ticket: $ticket, newPassword: $newPassword }\n ) {\n status\n }\n }\n": types.RecoverPasswordDocument, "\n mutation ResendRecoveryEmail($ticket: String!) {\n resendRecoveryEmail(input: { ticket: $ticket }) {\n status\n progressUrl\n }\n }\n": types.ResendRecoveryEmailDocument, "\n fragment RecoverPassword_userRecoveryTicket on UserRecoveryTicket {\n username\n email\n }\n": types.RecoverPassword_UserRecoveryTicketFragmentDoc, - "\n fragment RecoverPassword_siteConfig on SiteConfig {\n ...PasswordCreationDoubleInput_siteConfig\n }\n": types.RecoverPassword_SiteConfigFragmentDoc, + "\n fragment RecoverPassword_siteConfig on SiteConfig {\n minimumPasswordComplexity\n }\n": types.RecoverPassword_SiteConfigFragmentDoc, "\n query PasswordRecovery($ticket: String!) {\n siteConfig {\n ...RecoverPassword_siteConfig\n }\n\n userRecoveryTicket(ticket: $ticket) {\n status\n ...RecoverPassword_userRecoveryTicket\n }\n }\n": types.PasswordRecoveryDocument, "\n mutation AllowCrossSigningReset($userId: ID!) {\n allowUserCrossSigningReset(input: { userId: $userId }) {\n user {\n id\n }\n }\n }\n": types.AllowCrossSigningResetDocument, "\n query SessionDetail($id: ID!) {\n viewerSession {\n ... on Node {\n id\n }\n }\n\n node(id: $id) {\n __typename\n id\n ...CompatSession_detail\n ...OAuth2Session_detail\n ...BrowserSession_detail\n }\n }\n": types.SessionDetailDocument, @@ -169,10 +167,6 @@ export function graphql(source: "\n query Footer {\n siteConfig {\n id\ * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql(source: "\n fragment OAuth2Session_session on Oauth2Session {\n id\n scope\n createdAt\n finishedAt\n lastActiveIp\n lastActiveAt\n humanName\n\n ...EndOAuth2SessionButton_session\n\n userAgent {\n name\n model\n os\n deviceType\n }\n\n client {\n id\n clientId\n clientName\n applicationType\n logoUri\n }\n }\n"): typeof import('./graphql').OAuth2Session_SessionFragmentDoc; -/** - * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. - */ -export function graphql(source: "\n fragment PasswordCreationDoubleInput_siteConfig on SiteConfig {\n id\n minimumPasswordComplexity\n }\n"): typeof import('./graphql').PasswordCreationDoubleInput_SiteConfigFragmentDoc; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -320,7 +314,7 @@ export function graphql(source: "\n mutation ChangePassword(\n $userId: ID!\ /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "\n query PasswordChange {\n viewer {\n __typename\n ... on Node {\n id\n }\n }\n\n siteConfig {\n ...PasswordCreationDoubleInput_siteConfig\n }\n }\n"): typeof import('./graphql').PasswordChangeDocument; +export function graphql(source: "\n query PasswordChange {\n viewer {\n __typename\n ... on Node {\n id\n }\n }\n\n siteConfig {\n minimumPasswordComplexity\n }\n }\n"): typeof import('./graphql').PasswordChangeDocument; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -336,7 +330,7 @@ export function graphql(source: "\n fragment RecoverPassword_userRecoveryTicket /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "\n fragment RecoverPassword_siteConfig on SiteConfig {\n ...PasswordCreationDoubleInput_siteConfig\n }\n"): typeof import('./graphql').RecoverPassword_SiteConfigFragmentDoc; +export function graphql(source: "\n fragment RecoverPassword_siteConfig on SiteConfig {\n minimumPasswordComplexity\n }\n"): typeof import('./graphql').RecoverPassword_SiteConfigFragmentDoc; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/frontend/src/gql/graphql.ts b/frontend/src/gql/graphql.ts index 3d6bdb953..63278096d 100644 --- a/frontend/src/gql/graphql.ts +++ b/frontend/src/gql/graphql.ts @@ -224,8 +224,6 @@ export type OAuth2Session_SessionFragment = ( & { ' $fragmentRefs'?: { 'EndOAuth2SessionButton_SessionFragment': EndOAuth2SessionButton_SessionFragment } } ) & { ' $fragmentName'?: 'OAuth2Session_SessionFragment' }; -export type PasswordCreationDoubleInput_SiteConfigFragment = { id: string, minimumPasswordComplexity: number } & { ' $fragmentName'?: 'PasswordCreationDoubleInput_SiteConfigFragment' }; - export type EndBrowserSessionButton_SessionFragment = { id: string, userAgent: { name: string | null, os: string | null, model: string | null, deviceType: DeviceType } | null } & { ' $fragmentName'?: 'EndBrowserSessionButton_SessionFragment' }; export type EndBrowserSessionMutationVariables = Exact<{ @@ -488,7 +486,7 @@ export type PasswordChangeQueryVariables = Exact<{ [key: string]: never; }>; export type PasswordChangeQuery = { viewer: | { __typename: 'Anonymous', id: string } | { __typename: 'User', id: string } - , siteConfig: { ' $fragmentRefs'?: { 'PasswordCreationDoubleInput_SiteConfigFragment': PasswordCreationDoubleInput_SiteConfigFragment } } }; + , siteConfig: { minimumPasswordComplexity: number } }; export type RecoverPasswordMutationVariables = Exact<{ ticket: string; @@ -507,7 +505,7 @@ export type ResendRecoveryEmailMutation = { resendRecoveryEmail: { status: Resen export type RecoverPassword_UserRecoveryTicketFragment = { username: string, email: string } & { ' $fragmentName'?: 'RecoverPassword_UserRecoveryTicketFragment' }; -export type RecoverPassword_SiteConfigFragment = { ' $fragmentRefs'?: { 'PasswordCreationDoubleInput_SiteConfigFragment': PasswordCreationDoubleInput_SiteConfigFragment } } & { ' $fragmentName'?: 'RecoverPassword_SiteConfigFragment' }; +export type RecoverPassword_SiteConfigFragment = { minimumPasswordComplexity: number } & { ' $fragmentName'?: 'RecoverPassword_SiteConfigFragment' }; export type PasswordRecoveryQueryVariables = Exact<{ ticket: string; @@ -914,20 +912,11 @@ export const RecoverPassword_UserRecoveryTicketFragmentDoc = new TypedDocumentSt email } `, {"fragmentName":"RecoverPassword_userRecoveryTicket"}) as unknown as TypedDocumentString; -export const PasswordCreationDoubleInput_SiteConfigFragmentDoc = new TypedDocumentString(` - fragment PasswordCreationDoubleInput_siteConfig on SiteConfig { - id - minimumPasswordComplexity -} - `, {"fragmentName":"PasswordCreationDoubleInput_siteConfig"}) as unknown as TypedDocumentString; export const RecoverPassword_SiteConfigFragmentDoc = new TypedDocumentString(` fragment RecoverPassword_siteConfig on SiteConfig { - ...PasswordCreationDoubleInput_siteConfig -} - fragment PasswordCreationDoubleInput_siteConfig on SiteConfig { - id minimumPasswordComplexity -}`, {"fragmentName":"RecoverPassword_siteConfig"}) as unknown as TypedDocumentString; +} + `, {"fragmentName":"RecoverPassword_siteConfig"}) as unknown as TypedDocumentString; export const DeactivateUserDocument = new TypedDocumentString(` mutation DeactivateUser($hsErase: Boolean!, $password: String) { deactivateUser(input: {hsErase: $hsErase, password: $password}) { @@ -1404,13 +1393,10 @@ export const PasswordChangeDocument = new TypedDocumentString(` } } siteConfig { - ...PasswordCreationDoubleInput_siteConfig + minimumPasswordComplexity } } - fragment PasswordCreationDoubleInput_siteConfig on SiteConfig { - id - minimumPasswordComplexity -}`) as unknown as TypedDocumentString; + `) as unknown as TypedDocumentString; export const RecoverPasswordDocument = new TypedDocumentString(` mutation RecoverPassword($ticket: String!, $newPassword: String!) { setPasswordByRecovery(input: {ticket: $ticket, newPassword: $newPassword}) { @@ -1436,16 +1422,12 @@ export const PasswordRecoveryDocument = new TypedDocumentString(` ...RecoverPassword_userRecoveryTicket } } - fragment PasswordCreationDoubleInput_siteConfig on SiteConfig { - id - minimumPasswordComplexity -} -fragment RecoverPassword_userRecoveryTicket on UserRecoveryTicket { + fragment RecoverPassword_userRecoveryTicket on UserRecoveryTicket { username email } fragment RecoverPassword_siteConfig on SiteConfig { - ...PasswordCreationDoubleInput_siteConfig + minimumPasswordComplexity }`) as unknown as TypedDocumentString; export const AllowCrossSigningResetDocument = new TypedDocumentString(` mutation AllowCrossSigningReset($userId: ID!) { diff --git a/frontend/src/routes/password.change.index.tsx b/frontend/src/routes/password.change.index.tsx index 39a6dce80..573c93c74 100644 --- a/frontend/src/routes/password.change.index.tsx +++ b/frontend/src/routes/password.change.index.tsx @@ -1,3 +1,4 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // @@ -52,7 +53,7 @@ const QUERY = graphql(/* GraphQL */ ` } siteConfig { - ...PasswordCreationDoubleInput_siteConfig + minimumPasswordComplexity } } `); @@ -183,7 +184,7 @@ function ChangePassword(): React.ReactNode { estimatePasswordComplexity, +); + +/** Score the given password, off the critical path and off the main thread. */ +export const usePasswordComplexity = (password: string): PasswordComplexity => { + const { t } = useTranslation(); + const [result, setResult] = useState({ + score: 0, + scoreText: t("frontend.password_strength.placeholder"), + improvementsText: [], + }); + const deferredPassword = useDeferredValue(password); + + useEffect(() => { + if (deferredPassword === "") { + setResult({ + score: 0, + scoreText: t("frontend.password_strength.placeholder"), + improvementsText: [], + }); + } else { + loadPromise + .then((estimatePasswordComplexity) => + estimatePasswordComplexity(deferredPassword, t), + ) + .then((response) => setResult(response)); + } + }, [deferredPassword, t]); + + return result; +}; From 05d2f37423874728912bdb9ec35f9ea543e52d8a Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Mon, 10 Aug 2026 19:31:33 +0200 Subject: [PATCH 25/46] Add a shared boot helper for React islands The helper parses the mount node's dataset, waits for the i18n setup before the first render, and swaps the page to a server-rendered error notice when booting fails. This fixes the account app staying on its loading screen forever when fetching the locale data fails, and gives upcoming server-rendered pages with React islands a common entry point. Waiting on the setup requires `setupI18n` to return the initialisation promise, so it now does. --- frontend/src/entrypoints/main.tsx | 48 ++++++++----------- frontend/src/entrypoints/shared.css | 6 ++- frontend/src/i18n.ts | 4 +- frontend/src/styles/critical-notice.css | 18 ++++++++ frontend/src/utils/mountIsland.tsx | 61 +++++++++++++++++++++++++ templates/app.html | 4 ++ translations/en.json | 4 ++ 7 files changed, 112 insertions(+), 33 deletions(-) create mode 100644 frontend/src/styles/critical-notice.css create mode 100644 frontend/src/utils/mountIsland.tsx diff --git a/frontend/src/entrypoints/main.tsx b/frontend/src/entrypoints/main.tsx index 0635b09ab..d18e40477 100644 --- a/frontend/src/entrypoints/main.tsx +++ b/frontend/src/entrypoints/main.tsx @@ -7,22 +7,16 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { RouterProvider } from "@tanstack/react-router"; -import { TooltipProvider } from "@vector-im/compound-web"; -import { StrictMode, Suspense } from "react"; -import { createRoot } from "react-dom/client"; -import { I18nextProvider } from "react-i18next"; +import { Suspense } from "react"; import * as v from "valibot"; -import ErrorBoundary from "../components/ErrorBoundary"; import LoadingScreen from "../components/LoadingScreen"; import { setGraphqlEndpoint } from "../graphql"; -import i18n, { setupI18n } from "../i18n"; import { makeRouter } from "../router"; +import { mountIsland } from "../utils/mountIsland"; import "./vendor.css"; import "./shared.css"; -setupI18n(); - -const configSchema = v.object({ +const schema = v.object({ root: v.optional(v.string(), "/"), graphqlEndpoint: v.optional(v.string(), "/graphql"), }); @@ -35,25 +29,19 @@ const queryClient = new QueryClient({ }, }); -const rootElement = document.getElementById("root"); -if (!rootElement) throw new Error("#root element not found"); +void mountIsland({ + id: "root", + schema, + render: (config) => { + setGraphqlEndpoint(config.graphqlEndpoint); -const config = v.parse(configSchema, rootElement.dataset); -setGraphqlEndpoint(config.graphqlEndpoint); -const router = makeRouter(config.root, queryClient); - -createRoot(rootElement).render( - - - - - }> - - - - - - - - , -); + return ( + + {/* The router lazy-loads its routes, so it needs a boundary of its own */} + }> + + + + ); + }, +}); diff --git a/frontend/src/entrypoints/shared.css b/frontend/src/entrypoints/shared.css index bbf532163..caa56aead 100644 --- a/frontend/src/entrypoints/shared.css +++ b/frontend/src/entrypoints/shared.css @@ -1,4 +1,5 @@ -/* Copyright 2024, 2025 New Vector Ltd. +/* Copyright 2025, 2026 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. * Copyright 2024 The Matrix.org Foundation C.I.C. * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial @@ -7,6 +8,9 @@ @import "tailwindcss"; +/* Both the SSR templates and the SPA shell need this one */ +@import "../styles/critical-notice.css"; + /* The SSR templates live outside the frontend root, so they aren't picked up by * Tailwind's automatic content detection. */ @source "../../../templates/**/*.html"; diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 843e89463..bbae11885 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -67,8 +67,8 @@ const Backend = { }, } satisfies BackendModule; -export const setupI18n = () => { - i18n +export const setupI18n = async () => { + await i18n .use(Backend) .use(LanguageDetector) .use(initReactI18next) diff --git a/frontend/src/styles/critical-notice.css b/frontend/src/styles/critical-notice.css new file mode 100644 index 000000000..542c4a151 --- /dev/null +++ b/frontend/src/styles/critical-notice.css @@ -0,0 +1,18 @@ +/* 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. + */ + +/* Used by the SSR templates and by the React island boot-failure notices, so it + * has to be in a stylesheet both the templates and the SPA bundle pull in. */ +.critical-notice { + color: var(--cpd-color-text-critical-primary); + font: var(--cpd-font-body-md-semibold); + letter-spacing: var(--cpd-font-letter-spacing-body-md); + background-color: var(--cpd-color-bg-critical-subtle); + border: 1px solid var(--cpd-color-border-critical-subtle); + border-radius: var(--cpd-space-2x); + text-align: justify; + padding: var(--cpd-space-4x); +} diff --git a/frontend/src/utils/mountIsland.tsx b/frontend/src/utils/mountIsland.tsx new file mode 100644 index 000000000..d8ae8b26f --- /dev/null +++ b/frontend/src/utils/mountIsland.tsx @@ -0,0 +1,61 @@ +// 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. + +import { TooltipProvider } from "@vector-im/compound-web"; +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { I18nextProvider } from "react-i18next"; +import * as v from "valibot"; +import ErrorBoundary from "../components/ErrorBoundary"; +import i18n, { setupI18n } from "../i18n"; + +/** + * Boot a React island rendered into a server-rendered page. + * + * The mount element's `data-*` attributes are parsed with `schema`, the locale + * data is loaded, then the tree returned by `render` is mounted inside the + * shared provider stack. Anything going wrong before the first render leaves + * the page with the template's own `#-error` notice instead of a + * half-mounted island. + * + * `render` is called exactly once, after the dataset is parsed and before + * anything is mounted, so setup which needs the parsed data, such as + * configuring the GraphQL endpoint, can happen at the top of it. + */ +export const mountIsland = async ({ + id, + schema, + render, +}: { + /** Id of the mount element; its `#-error` sibling is the fallback */ + id: string; + schema: v.GenericSchema; + render: (data: T) => React.ReactNode; +}): Promise => { + const el = document.getElementById(id); + + try { + if (!el) throw new Error(`#${id} element not found`); + + const data = v.parse(schema, el.dataset); + + // Rendering before the locale data is in would flash the raw keys + await setupI18n(); + + createRoot(el).render( + + + + {render(data)} + + + , + ); + } catch (error) { + console.error(`Failed to mount #${id}`, error); + if (el) el.hidden = true; + document.getElementById(`${id}-error`)?.removeAttribute("hidden"); + } +}; diff --git a/templates/app.html b/templates/app.html index 0763b8d12..d5aced6d9 100644 --- a/templates/app.html +++ b/templates/app.html @@ -23,5 +23,9 @@ Please see LICENSE files in the repository root for full details.
+ + diff --git a/translations/en.json b/translations/en.json index 7efcc3739..cc0383647 100644 --- a/translations/en.json +++ b/translations/en.json @@ -450,6 +450,10 @@ "context": "pages/sso.html:28:11-109" } }, + "loading_failed": "Something went wrong while loading the page. Try reloading it.", + "@loading_failed": { + "context": "app.html:28:9-32" + }, "login": { "call_to_register": "Don't have an account yet?", "@call_to_register": { From 5dbef45e9fe610e2e573818d17a8ad0d01c01b6c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:59:01 +0000 Subject: [PATCH 26/46] Translations updates --- frontend/.storybook/locales.ts | 82 +++++++++++++++++----------------- 1 file changed, 41 insertions(+), 41 deletions(-) diff --git a/frontend/.storybook/locales.ts b/frontend/.storybook/locales.ts index 4b0ee9189..2c5182dec 100644 --- a/frontend/.storybook/locales.ts +++ b/frontend/.storybook/locales.ts @@ -27,7 +27,7 @@ export type LocalazyMetadata = { }; const localazyMetadata: LocalazyMetadata = { - projectUrl: "https://localazy.com/p/matrix-authentication-service", + projectUrl: "https://localazy.com/p/matrix-authentication-service!v1.25", baseLocale: "en", languages: [ { @@ -217,26 +217,26 @@ const localazyMetadata: LocalazyMetadata = { file: "frontend.json", path: "", cdnFiles: { - "cs": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/cs/frontend.json", - "da": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/da/frontend.json", - "de": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/de/frontend.json", - "en": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/en/frontend.json", - "et": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/et/frontend.json", - "fi": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/fi/frontend.json", - "fr": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/fr/frontend.json", - "hu": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/hu/frontend.json", - "nb_NO": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/nb-NO/frontend.json", - "nl": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/nl/frontend.json", - "pl": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pl/frontend.json", - "pt": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pt/frontend.json", - "pt_BR": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pt-BR/frontend.json", - "ru": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/ru/frontend.json", - "sk": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/sk/frontend.json", - "sv": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/sv/frontend.json", - "tr": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/tr/frontend.json", - "uk": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/uk/frontend.json", - "uz": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/uz/frontend.json", - "zh#Hans": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/zh-Hans/frontend.json" + "cs": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/cs/frontend.json", + "da": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/da/frontend.json", + "de": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/de/frontend.json", + "en": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/en/frontend.json", + "et": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/et/frontend.json", + "fi": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/fi/frontend.json", + "fr": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/fr/frontend.json", + "hu": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/hu/frontend.json", + "nb_NO": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/nb-NO/frontend.json", + "nl": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/nl/frontend.json", + "pl": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pl/frontend.json", + "pt": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pt/frontend.json", + "pt_BR": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pt-BR/frontend.json", + "ru": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/ru/frontend.json", + "sk": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/sk/frontend.json", + "sv": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/sv/frontend.json", + "tr": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/tr/frontend.json", + "uk": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/uk/frontend.json", + "uz": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/uz/frontend.json", + "zh#Hans": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/zh-Hans/frontend.json" } }, { @@ -244,26 +244,26 @@ const localazyMetadata: LocalazyMetadata = { file: "file.json", path: "", cdnFiles: { - "cs": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/cs/file.json", - "da": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/da/file.json", - "de": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/de/file.json", - "en": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/en/file.json", - "et": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/et/file.json", - "fi": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/fi/file.json", - "fr": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/fr/file.json", - "hu": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/hu/file.json", - "nb_NO": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/nb-NO/file.json", - "nl": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/nl/file.json", - "pl": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pl/file.json", - "pt": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pt/file.json", - "pt_BR": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pt-BR/file.json", - "ru": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/ru/file.json", - "sk": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/sk/file.json", - "sv": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/sv/file.json", - "tr": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/tr/file.json", - "uk": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/uk/file.json", - "uz": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/uz/file.json", - "zh#Hans": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/zh-Hans/file.json" + "cs": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/cs/file.json", + "da": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/da/file.json", + "de": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/de/file.json", + "en": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/en/file.json", + "et": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/et/file.json", + "fi": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/fi/file.json", + "fr": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/fr/file.json", + "hu": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/hu/file.json", + "nb_NO": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/nb-NO/file.json", + "nl": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/nl/file.json", + "pl": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pl/file.json", + "pt": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pt/file.json", + "pt_BR": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pt-BR/file.json", + "ru": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/ru/file.json", + "sk": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/sk/file.json", + "sv": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/sv/file.json", + "tr": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/tr/file.json", + "uk": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/uk/file.json", + "uz": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/uz/file.json", + "zh#Hans": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/zh-Hans/file.json" } } ] From 49ea27f8c700d3d3145f6b00dc12c08792183e5e Mon Sep 17 00:00:00 2001 From: Tonkku Date: Thu, 17 Sep 2026 09:57:00 +0000 Subject: [PATCH 27/46] Add MSC4198 `login_hint_types_supported` discovery metadata --- crates/handlers/src/oauth2/discovery.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/handlers/src/oauth2/discovery.rs b/crates/handlers/src/oauth2/discovery.rs index 4530ea820..807ff327e 100644 --- a/crates/handlers/src/oauth2/discovery.rs +++ b/crates/handlers/src/oauth2/discovery.rs @@ -32,6 +32,10 @@ struct DiscoveryResponse { // As per MSC4191 account_management_uri: url::Url, account_management_actions_supported: Vec, + + // As per MSC4198 + #[serde(rename = "org.matrix.msc4198.login_hint_types_supported")] + login_hint_types_supported: Vec, } #[tracing::instrument(name = "handlers.oauth2.discovery.get", skip_all)] @@ -202,6 +206,7 @@ pub(crate) async fn get( "org.matrix.session_view".to_owned(), "org.matrix.session_end".to_owned(), ], + login_hint_types_supported: vec!["mxid".to_owned()], }) } From ba3e869566757b3f9318b51f365b4cf6dad8fec0 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Fri, 18 Sep 2026 15:41:14 +0100 Subject: [PATCH 28/46] Rest of merge --- translations/en.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/translations/en.json b/translations/en.json index 82df6e72a..0f22b2507 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:53: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:57:33-51, pages/policy_violation.html:70:15-33, pages/reauth.html:37:13-31" }, "continue": "Continue", "@continue": { - "context": "form_post.html:26:28-48, pages/consent.html:71:28-48, pages/device_link.html:50: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:80: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:26:28-48, pages/consent.html:71:28-48, pages/device_link.html:54: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:80: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": { @@ -851,4 +851,4 @@ } } } -} +} \ No newline at end of file From ada65be990a778f362119ffadd003307ec0edd28 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Fri, 18 Sep 2026 16:06:21 +0100 Subject: [PATCH 29/46] Doc clarifications --- .../data-model/src/oauth2/device_code_grant.rs | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/crates/data-model/src/oauth2/device_code_grant.rs b/crates/data-model/src/oauth2/device_code_grant.rs index 8b5223a3b..6fb62a940 100644 --- a/crates/data-model/src/oauth2/device_code_grant.rs +++ b/crates/data-model/src/oauth2/device_code_grant.rs @@ -42,22 +42,16 @@ pub fn generate_user_code(rng: &mut R) -> String { /// On top of uppercasing, this folds `O` onto `0` and `I` and `L` onto `1`, /// repairing a user who read a `0` as an `O` or a `1` as an `I`. /// -/// Note that Crockford only defines those two foldings: it keeps both members -/// of `2`/`Z`, `5`/`S`, `6`/`G`, `8`/`B` and `9`/`Q`, so those stay ambiguous -/// and are deliberately left alone here. -/// /// `U` is not in the alphabet but has no mapping defined for it either, so it /// is passed through unchanged. /// -/// Crockford also allows hyphens as readability separators and ignores them -/// when decoding, but we never put any into the codes we hand out, so there is -/// nothing for a user to optionally include and nothing to strip here. -/// Whitespace and punctuation are likewise left alone, and so will simply fail -/// to match. +/// Crockford ignores hyphens when decoding, but we issue none, so nothing is +/// stripped here: hyphens, whitespace and punctuation all just fail to match. /// -/// This mapping is lossy against the alphanumeric alphabet user codes used to -/// be drawn from, so callers must look a code up as plainly uppercased -/// *before* falling back to this — see the lookup in the device link handler. +/// The mapping is lossy, so a caller which looks up both the code as the user +/// typed and its normalized form should try the code as typed first: a legacy +/// code containing `I`, `L` or `O` is otherwise shadowed by any live grant at +/// the code it folds onto. See the lookup in the device link handler. /// /// [Crockford Base32]: https://www.crockford.com/base32.html #[must_use] From b6c6e974614acdbbfe7d15e30c285ba7417003c2 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Tue, 11 Aug 2026 15:03:24 +0200 Subject: [PATCH 30/46] Extract a helper to start an upstream OAuth 2.0 authorization flow The registration page is about to start these flows from a form POST, so minting the session, the cookie and the authorization URL has to be callable from outside the `/upstream/authorize/:id` handler. --- .../handlers/src/upstream_oauth2/authorize.rs | 109 +++++++++++++----- 1 file changed, 80 insertions(+), 29 deletions(-) diff --git a/crates/handlers/src/upstream_oauth2/authorize.rs b/crates/handlers/src/upstream_oauth2/authorize.rs index 5807f5430..ea5a35053 100644 --- a/crates/handlers/src/upstream_oauth2/authorize.rs +++ b/crates/handlers/src/upstream_oauth2/authorize.rs @@ -26,6 +26,7 @@ use mas_storage::{ use minijinja::context; use thiserror::Error; use ulid::Ulid; +use url::Url; use super::{UpstreamSessionsCookie, cache::LazyProviderInfos, template::environment}; use crate::{ @@ -42,9 +43,8 @@ pub(crate) enum RouteError { Internal(Box), } -impl_from_error_for_route!(mas_oidc_client::error::DiscoveryError); -impl_from_error_for_route!(mas_oidc_client::error::AuthorizationError); impl_from_error_for_route!(mas_storage::RepositoryError); +impl_from_error_for_route!(StartAuthorizationError); impl IntoResponse for RouteError { fn into_response(self) -> axum::response::Response { @@ -57,22 +57,41 @@ impl IntoResponse for RouteError { } } +/// Errors which can happen while starting an upstream OAuth 2.0 authorization +/// flow. They are all internal errors, as the caller is responsible for +/// checking that the provider exists and is enabled. +#[derive(Debug, Error)] +pub(crate) enum StartAuthorizationError { + #[error(transparent)] + Discovery(#[from] mas_oidc_client::error::DiscoveryError), + + #[error(transparent)] + Authorization(#[from] mas_oidc_client::error::AuthorizationError), + + #[error(transparent)] + Repository(#[from] mas_storage::RepositoryError), +} + +/// Start an upstream OAuth 2.0 authorization flow with the given provider, and +/// return the authorization URL to redirect the browser to. +/// +/// The caller is responsible for committing the repository. #[tracing::instrument( - name = "handlers.upstream_oauth2.authorize.get", - fields(upstream_oauth_provider.id = %provider_id), + name = "handlers.upstream_oauth2.authorize.start", + fields(upstream_oauth_provider.id = %provider.id), skip_all, )] -pub(crate) async fn get( - mut rng: BoxRng, - clock: BoxClock, - State(metadata_cache): State, - mut repo: BoxRepository, - State(url_builder): State, - State(http_client): State, +pub(crate) async fn start_authorization( + rng: &mut BoxRng, + clock: &BoxClock, + metadata_cache: &MetadataCache, + http_client: &reqwest::Client, + url_builder: &UrlBuilder, + repo: &mut BoxRepository, cookie_jar: CookieJar, - Path(provider_id): Path, - Query(query): Query, -) -> Result { + provider: &UpstreamOAuthProvider, + post_auth_action: Option, +) -> Result<(CookieJar, Url), StartAuthorizationError> { // Load the session info from the cookie jar. We use this to know whether // the browser recently signed out, which we expose to the // `additional_authorization_parameters` templates as `logged_out` so that @@ -83,17 +102,10 @@ pub(crate) async fn get( .logged_out_at() .is_some_and(|t| clock.now().signed_duration_since(t) < Duration::minutes(5)); - let provider = repo - .upstream_oauth_provider() - .lookup(provider_id) - .await? - .filter(UpstreamOAuthProvider::enabled) - .ok_or(RouteError::ProviderNotFound)?; - // First, discover the provider // This is done lazyly according to provider.discovery_mode and the various // endpoint overrides - let mut lazy_metadata = LazyProviderInfos::new(&metadata_cache, &provider, &http_client); + let mut lazy_metadata = LazyProviderInfos::new(metadata_cache, provider, http_client); lazy_metadata.maybe_discover().await?; let redirect_uri = url_builder.upstream_oauth_callback(provider.id); @@ -114,7 +126,7 @@ pub(crate) async fn get( // `forward_login_hint` shortcut working for deployments that haven't // re-run config sync yet. let downstream_grant = - if let Some(PostAuthAction::ContinueAuthorizationGrant { id }) = &query.post_auth_action { + if let Some(PostAuthAction::ContinueAuthorizationGrant { id }) = &post_auth_action { repo.oauth2_authorization_grant().lookup(*id).await? } else { None @@ -152,7 +164,7 @@ pub(crate) async fn get( let (mut url, data) = mas_oidc_client::requests::authorization_code::build_authorization_url( lazy_metadata.authorization_endpoint().await?.clone(), data, - &mut rng, + rng, )?; // Render the templated `additional_authorization_parameters` and @@ -172,9 +184,9 @@ pub(crate) async fn get( let session = repo .upstream_oauth_session() .add( - &mut rng, - &clock, - &provider, + rng, + clock, + provider, data.state.clone(), data.code_challenge_verifier, data.nonce, @@ -182,8 +194,47 @@ pub(crate) async fn get( .await?; let cookie_jar = UpstreamSessionsCookie::load(&cookie_jar) - .add(session.id, provider.id, data.state, query.post_auth_action) - .save(cookie_jar, &clock); + .add(session.id, provider.id, data.state, post_auth_action) + .save(cookie_jar, clock); + + Ok((cookie_jar, url)) +} + +#[tracing::instrument( + name = "handlers.upstream_oauth2.authorize.get", + fields(upstream_oauth_provider.id = %provider_id), + skip_all, +)] +pub(crate) async fn get( + mut rng: BoxRng, + clock: BoxClock, + State(metadata_cache): State, + mut repo: BoxRepository, + State(url_builder): State, + State(http_client): State, + cookie_jar: CookieJar, + Path(provider_id): Path, + Query(query): Query, +) -> Result { + let provider = repo + .upstream_oauth_provider() + .lookup(provider_id) + .await? + .filter(UpstreamOAuthProvider::enabled) + .ok_or(RouteError::ProviderNotFound)?; + + let (cookie_jar, url) = start_authorization( + &mut rng, + &clock, + &metadata_cache, + &http_client, + &url_builder, + &mut repo, + cookie_jar, + &provider, + query.post_auth_action, + ) + .await?; repo.save().await?; From 877e279fbffd87d5b74a3d33a9ae48cc3609ff74 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Tue, 11 Aug 2026 15:11:11 +0200 Subject: [PATCH 31/46] Carry the username typed on the registration page through the upstream OAuth 2.0 flow It is stashed in the upstream sessions cookie and used as the localpart when the user comes back, unless the provider forces or requires its own. --- .../handlers/src/upstream_oauth2/authorize.rs | 10 +- crates/handlers/src/upstream_oauth2/cookie.rs | 53 ++- crates/handlers/src/upstream_oauth2/link.rs | 354 ++++++++++++++++-- 3 files changed, 378 insertions(+), 39 deletions(-) diff --git a/crates/handlers/src/upstream_oauth2/authorize.rs b/crates/handlers/src/upstream_oauth2/authorize.rs index ea5a35053..364ba1cbb 100644 --- a/crates/handlers/src/upstream_oauth2/authorize.rs +++ b/crates/handlers/src/upstream_oauth2/authorize.rs @@ -91,6 +91,7 @@ pub(crate) async fn start_authorization( cookie_jar: CookieJar, provider: &UpstreamOAuthProvider, post_auth_action: Option, + username: Option, ) -> Result<(CookieJar, Url), StartAuthorizationError> { // Load the session info from the cookie jar. We use this to know whether // the browser recently signed out, which we expose to the @@ -194,7 +195,13 @@ pub(crate) async fn start_authorization( .await?; let cookie_jar = UpstreamSessionsCookie::load(&cookie_jar) - .add(session.id, provider.id, data.state, post_auth_action) + .add( + session.id, + provider.id, + data.state, + post_auth_action, + username, + ) .save(cookie_jar, clock); Ok((cookie_jar, url)) @@ -233,6 +240,7 @@ pub(crate) async fn get( cookie_jar, &provider, query.post_auth_action, + None, ) .await?; diff --git a/crates/handlers/src/upstream_oauth2/cookie.rs b/crates/handlers/src/upstream_oauth2/cookie.rs index cc5ae2943..abf18258c 100644 --- a/crates/handlers/src/upstream_oauth2/cookie.rs +++ b/crates/handlers/src/upstream_oauth2/cookie.rs @@ -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. // @@ -27,6 +28,11 @@ pub struct Payload { state: String, link: Option, post_auth_action: Option, + + /// The username typed on the registration page, when the flow started + /// there with the field filled in + #[serde(skip_serializing_if = "Option::is_none")] + username: Option, } impl Payload { @@ -87,6 +93,7 @@ impl UpstreamSessions { provider: Ulid, state: String, post_auth_action: Option, + username: Option, ) -> Self { self.0.push(Payload { session, @@ -94,6 +101,7 @@ impl UpstreamSessions { state, link: None, post_auth_action, + username, }); self } @@ -131,13 +139,19 @@ impl UpstreamSessions { pub fn lookup_link( &self, link_id: Ulid, - ) -> Result<(Ulid, Option<&PostAuthAction>), UpstreamSessionNotFound> { + ) -> Result<(Ulid, Option<&PostAuthAction>, Option<&str>), UpstreamSessionNotFound> { self.0 .iter() .filter(|p| p.link == Some(link_id)) // Find the session with the highest ID, aka. the most recent one .reduce(|a, b| if a.session > b.session { a } else { b }) - .map(|p| (p.session, p.post_auth_action.as_ref())) + .map(|p| { + ( + p.session, + p.post_auth_action.as_ref(), + p.username.as_deref(), + ) + }) .ok_or(UpstreamSessionNotFound) } @@ -178,13 +192,19 @@ mod tests { let first_session = Ulid::from_datetime_with_rng(now, &mut rng); let first_state = "first-state"; - let sessions = sessions.add(first_session, provider_a, first_state.into(), None); + let sessions = sessions.add(first_session, provider_a, first_state.into(), None, None); let now = now + Duration::microseconds(5 * 60 * 1000 * 1000); let second_session = Ulid::from_datetime_with_rng(now, &mut rng); let second_state = "second-state"; - let sessions = sessions.add(second_session, provider_b, second_state.into(), None); + let sessions = sessions.add( + second_session, + provider_b, + second_state.into(), + None, + Some("john".to_owned()), + ); let sessions = sessions.expire(now); assert_eq!( @@ -216,11 +236,32 @@ mod tests { // Now the session can't be found with its state assert!(sessions.find_session(provider_b, second_state).is_err()); - // But it can be looked up by its link - assert_eq!(sessions.lookup_link(second_link).unwrap().0, second_session); + // But it can be looked up by its link, along with the username it carries + let (session, _post_auth_action, username) = sessions.lookup_link(second_link).unwrap(); + assert_eq!(session, second_session); + assert_eq!(username, Some("john")); + // And it can be consumed let sessions = sessions.consume_link(second_link).unwrap(); // But only once assert!(sessions.consume_link(second_link).is_err()); } + + /// A cookie payload written before the username field existed still + /// deserializes, and carries no username + #[test] + fn test_payload_without_username() { + let sessions: UpstreamSessions = serde_json::from_value(serde_json::json!([{ + "session": "01FSHN9AG0AJ6AC5HQ9X6H4RP4", + "provider": "01FSHN9AG0MZAA6S4AF7CTV32E", + "state": "state", + "link": "01FSHN9AG09NMZYX8MFVH74RP4", + "post_auth_action": null, + }])) + .expect("payload without a username should deserialize"); + + let link = "01FSHN9AG09NMZYX8MFVH74RP4".parse().unwrap(); + let (_session, _post_auth_action, username) = sessions.lookup_link(link).unwrap(); + assert_eq!(username, None); + } } diff --git a/crates/handlers/src/upstream_oauth2/link.rs b/crates/handlers/src/upstream_oauth2/link.rs index 51de8ac5a..f2486c5a9 100644 --- a/crates/handlers/src/upstream_oauth2/link.rs +++ b/crates/handlers/src/upstream_oauth2/link.rs @@ -245,10 +245,14 @@ pub(crate) async fn get( ) -> Result { let user_agent = user_agent.map(|ua| ua.as_str().to_owned()); let sessions_cookie = UpstreamSessionsCookie::load(&cookie_jar); - let (session_id, post_auth_action) = sessions_cookie + let (session_id, post_auth_action, username) = sessions_cookie .lookup_link(link_id) .map_err(|_| RouteError::MissingCookie)?; + // Owned copy of the username the user typed on the registration page before + // starting the flow, if any, so we can consume the cookie later. + let carried_username = username.map(ToOwned::to_owned); + let link = repo .upstream_oauth_link() .lookup(link_id) @@ -454,34 +458,47 @@ pub(crate) async fn get( )? }; + let forced_or_required = provider.claims_imports.localpart.is_forced_or_required(); + + // What the user typed on the registration page beats the claim rendered + // from the upstream response, even when that claim is ignored. A provider + // which forces or requires the localpart never lets the user pick one. + let carried_username = if forced_or_required { + None + } else { + carried_username + }; + // We do a bunch of checks for the localpart. Instead of using nested ifs all // the way, we use a labelled block, and use `break` for 'exiting' early when // needed let localpart = 'localpart: { - if provider.claims_imports.localpart.ignore() { + let localpart = if let Some(username) = carried_username { + username + } else if provider.claims_imports.localpart.ignore() { break 'localpart None; - } + } else { + let template = provider + .claims_imports + .localpart + .template + .as_deref() + .unwrap_or(DEFAULT_LOCALPART_TEMPLATE); - let template = provider - .claims_imports - .localpart - .template - .as_deref() - .unwrap_or(DEFAULT_LOCALPART_TEMPLATE); + let Some(localpart) = render_attribute_template( + &env, + template, + &context, + provider.claims_imports.localpart.is_required(), + )? + else { + break 'localpart None; + }; - let Some(localpart) = render_attribute_template( - &env, - template, - &context, - provider.claims_imports.localpart.is_required(), - )? - else { - break 'localpart None; + localpart }; - let forced_or_required = provider.claims_imports.localpart.is_forced_or_required(); - - // We got a localpart from the template. We need to check if it's + // We got a localpart candidate. We need to check if it's // available, and if it's not apply the conflict resolution setup in // the config let maybe_existing_user = repo.user().find_by_username(&localpart).await?; @@ -491,7 +508,7 @@ pub(crate) async fn get( upstream_oauth_provider.id = %provider.id, upstream_oauth_link.id = %link.id, user.id = %existing_user.id, - "Upstream provider returned a localpart {localpart:?} which is already used by another user. As the username is just a suggestion, it was ignored." + "Localpart {localpart:?} is already used by another user. As the username is just a suggestion, it was ignored." ); break 'localpart None; } @@ -714,7 +731,7 @@ pub(crate) async fn get( tracing::warn!( upstream_oauth_provider.id = %provider.id, upstream_oauth_link.id = %link.id, - "Upstream provider returned a localpart {localpart:?} which was denied by the policy ({res}). As the username is just a suggestion, it was ignored." + "Localpart {localpart:?} was denied by the policy ({res}). As the username is just a suggestion, it was ignored." ); break 'localpart None; } @@ -748,7 +765,7 @@ pub(crate) async fn get( tracing::warn!( upstream_oauth_provider.id = %provider.id, upstream_oauth_link.id = %link.id, - "Upstream provider returned a localpart {localpart:?} which isn't available on the homeserver. As the username is just a suggestion, it was ignored." + "Localpart {localpart:?} isn't available on the homeserver. As the username is just a suggestion, it was ignored." ); break 'localpart None; } @@ -869,7 +886,9 @@ pub(crate) async fn post( let form = cookie_jar.verify_form(&clock, form)?; let sessions_cookie = UpstreamSessionsCookie::load(&cookie_jar); - let (session_id, post_auth_action) = sessions_cookie + // The carried username doesn't matter here: the username the user picked, + // if they were allowed to pick one, comes from the form + let (session_id, post_auth_action, _username) = sessions_cookie .lookup_link(link_id) .map_err(|_| RouteError::MissingCookie)?; @@ -1256,7 +1275,7 @@ async fn prepare_user_registration( #[cfg(test)] mod tests { - use hyper::{Request, StatusCode, header::CONTENT_TYPE}; + use hyper::{Request, Response, StatusCode, header::CONTENT_TYPE}; use mas_data_model::{ UpstreamOAuthAuthorizationSession, UpstreamOAuthLink, UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderImportPreference, UpstreamOAuthProviderLocalpartPreference, @@ -1399,7 +1418,7 @@ mod tests { let cookie_jar = state.cookie_jar(); let upstream_sessions = UpstreamSessionsCookie::default() - .add(session.id, provider.id, "state".to_owned(), None) + .add(session.id, provider.id, "state".to_owned(), None, None) .add_link_to_session(session.id, link.id) .unwrap(); let cookie_jar = upstream_sessions.save(cookie_jar, &state.clock); @@ -1597,7 +1616,7 @@ mod tests { let cookie_jar = state.cookie_jar(); let upstream_sessions = UpstreamSessionsCookie::default() - .add(session.id, provider.id, "state".to_owned(), None) + .add(session.id, provider.id, "state".to_owned(), None, None) .add_link_to_session(session.id, link.id) .unwrap(); let cookie_jar = upstream_sessions.save(cookie_jar, &state.clock); @@ -1731,7 +1750,7 @@ mod tests { let cookie_jar = state.cookie_jar(); let upstream_sessions = UpstreamSessionsCookie::default() - .add(session.id, provider.id, "state".to_owned(), None) + .add(session.id, provider.id, "state".to_owned(), None, None) .add_link_to_session(session.id, link.id) .unwrap(); let cookie_jar = upstream_sessions.save(cookie_jar, &state.clock); @@ -1855,7 +1874,7 @@ mod tests { let cookie_jar = state.cookie_jar(); let upstream_sessions = UpstreamSessionsCookie::default() - .add(session.id, provider.id, "state".to_owned(), None) + .add(session.id, provider.id, "state".to_owned(), None, None) .add_link_to_session(session.id, link.id) .unwrap(); let cookie_jar = upstream_sessions.save(cookie_jar, &state.clock); @@ -2045,7 +2064,7 @@ mod tests { let cookie_jar = state.cookie_jar(); let upstream_sessions = UpstreamSessionsCookie::default() - .add(session.id, provider.id, "state".to_owned(), None) + .add(session.id, provider.id, "state".to_owned(), None, None) .add_link_to_session(session.id, link.id) .unwrap(); let cookie_jar = upstream_sessions.save(cookie_jar, &state.clock); @@ -2176,7 +2195,7 @@ mod tests { let cookie_jar = state.cookie_jar(); let upstream_sessions = UpstreamSessionsCookie::default() - .add(session.id, provider.id, "state".to_owned(), None) + .add(session.id, provider.id, "state".to_owned(), None, None) .add_link_to_session(session.id, link.id) .unwrap(); let cookie_jar = upstream_sessions.save(cookie_jar, &state.clock); @@ -2314,7 +2333,7 @@ mod tests { let cookie_jar = state.cookie_jar(); let upstream_sessions = UpstreamSessionsCookie::default() - .add(session.id, provider.id, "state".to_owned(), None) + .add(session.id, provider.id, "state".to_owned(), None, None) .add_link_to_session(session.id, link.id) .unwrap(); let cookie_jar = upstream_sessions.save(cookie_jar, &state.clock); @@ -2357,4 +2376,275 @@ mod tests { assert!(old_link_result.is_some(), "Old link should still exist"); assert_eq!(old_link_result.unwrap().user_id, Some(user.id)); } + + /// Provision a provider with the given claims imports, along with a + /// completed upstream authorization session and its link, and load the + /// upstream sessions cookie carrying the given username in the given cookie + /// jar + async fn carried_username_setup( + state: &TestState, + cookies: &CookieHelper, + localpart: mas_data_model::UpstreamOAuthProviderImportAction, + carried_username: &str, + skip_confirmation: bool, + ) -> UpstreamOAuthLink { + let mut rng = state.rng(); + + let claims_imports = UpstreamOAuthProviderClaimsImports { + localpart: UpstreamOAuthProviderLocalpartPreference { + action: localpart, + template: None, + on_conflict: mas_data_model::UpstreamOAuthProviderOnConflict::default(), + }, + skip_confirmation, + ..UpstreamOAuthProviderClaimsImports::default() + }; + + let id_token_claims = serde_json::json!({ + "preferred_username": "john", + }); + let id_token = sign_token(&mut rng, &state.key_store, id_token_claims.clone()).unwrap(); + + let mut repo = state.repository().await.unwrap(); + let provider = repo + .upstream_oauth_provider() + .add( + &mut rng, + &state.clock, + UpstreamOAuthProviderParams { + issuer: Some("https://example.com/".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, + client_id: "client".to_owned(), + encrypted_client_secret: None, + claims_imports, + authorization_endpoint_override: None, + token_endpoint_override: None, + userinfo_endpoint_override: None, + fetch_userinfo: false, + userinfo_signed_response_alg: 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: + mas_data_model::UpstreamOAuthProviderOnBackchannelLogout::DoNothing, + registration_token_required: false, + }, + ) + .await + .unwrap(); + + let (link, session) = add_linked_upstream_session( + &mut rng, + &state.clock, + &mut repo, + &provider, + "subject", + &id_token.into_string(), + id_token_claims, + ) + .await + .unwrap(); + + repo.save().await.unwrap(); + + let upstream_sessions = UpstreamSessionsCookie::default() + .add( + session.id, + provider.id, + "state".to_owned(), + None, + Some(carried_username.to_owned()), + ) + .add_link_to_session(session.id, link.id) + .unwrap(); + cookies.import(upstream_sessions.save(state.cookie_jar(), &state.clock)); + + link + } + + /// A username carried through the flow prefills the registration form, and + /// takes precedence over the username suggested by the provider + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_register_with_carried_username(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let cookies = CookieHelper::new(); + + let link = carried_username_setup( + &state, + &cookies, + mas_data_model::UpstreamOAuthProviderImportAction::Suggest, + "alice", + false, + ) + .await; + + let request = Request::get(&*mas_router::UpstreamOAuth2Link::new(link.id).path()).empty(); + let response = state.request(cookies.with_cookies(request)).await; + response.assert_status(StatusCode::OK); + assert!(response.body().contains(r#"value="alice""#)); + assert!(!response.body().contains(r#"value="john""#)); + } + + /// A carried username prefills the form even when the provider is + /// configured to ignore the localpart claim: it isn't a claim, it is what + /// the user typed + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_register_with_carried_username_and_ignored_claim(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let cookies = CookieHelper::new(); + + let link = carried_username_setup( + &state, + &cookies, + mas_data_model::UpstreamOAuthProviderImportAction::Ignore, + "alice", + false, + ) + .await; + + let request = Request::get(&*mas_router::UpstreamOAuth2Link::new(link.id).path()).empty(); + let response = state.request(cookies.with_cookies(request)).await; + response.assert_status(StatusCode::OK); + assert!(response.body().contains(r#"value="alice""#)); + } + + /// Providers which force the localpart ignore the carried username + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_carried_username_ignored_when_localpart_forced(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let cookies = CookieHelper::new(); + + let link = carried_username_setup( + &state, + &cookies, + mas_data_model::UpstreamOAuthProviderImportAction::Force, + "alice", + false, + ) + .await; + + let request = Request::get(&*mas_router::UpstreamOAuth2Link::new(link.id).path()).empty(); + let response = state.request(cookies.with_cookies(request)).await; + response.assert_status(StatusCode::OK); + // The localpart is enforced by the provider, and displayed as a full MXID + assert!(response.body().contains(r#"value="@john:"#)); + assert!(!response.body().contains("alice")); + } + + /// A carried username which is already taken is dropped, and the user gets + /// to pick another one + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_carried_username_dropped_when_taken(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let cookies = CookieHelper::new(); + + let link = carried_username_setup( + &state, + &cookies, + mas_data_model::UpstreamOAuthProviderImportAction::Suggest, + "alice", + false, + ) + .await; + + let mut rng = state.rng(); + let mut repo = state.repository().await.unwrap(); + repo.user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + repo.save().await.unwrap(); + + let request = Request::get(&*mas_router::UpstreamOAuth2Link::new(link.id).path()).empty(); + let response = state.request(cookies.with_cookies(request)).await; + response.assert_status(StatusCode::OK); + assert!(!response.body().contains(r#"value="alice""#)); + assert!(!response.body().contains(r#"value="john""#)); + } + + /// Look up the user registration the response redirects to + async fn lookup_registration( + state: &TestState, + response: &Response, + ) -> UserRegistration { + let location = response.headers().get(hyper::header::LOCATION).unwrap(); + // The location is /register/steps/{id}/finish + let registration_id: Ulid = str::from_utf8(location.as_bytes()) + .unwrap() + .rsplit('/') + .nth(1) + .expect("Location to have two slashes") + .parse() + .expect("last segment of location to be a ULID"); + + let mut repo = state.repository().await.unwrap(); + repo.user_registration() + .lookup(registration_id) + .await + .unwrap() + .expect("user registration exists") + } + + /// A provider which skips the confirmation registers under the carried + /// username, without showing the form + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_carried_username_with_skip_confirmation(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let cookies = CookieHelper::new(); + + let link = carried_username_setup( + &state, + &cookies, + mas_data_model::UpstreamOAuthProviderImportAction::Suggest, + "alice", + true, + ) + .await; + + let request = Request::get(&*mas_router::UpstreamOAuth2Link::new(link.id).path()).empty(); + let response = state.request(cookies.with_cookies(request)).await; + response.assert_status(StatusCode::SEE_OTHER); + + let registration = lookup_registration(&state, &response).await; + assert_eq!(registration.username, "alice"); + } + + /// The carried username is what makes the registration possible when the + /// provider ignores the localpart claim and skips the confirmation + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_carried_username_with_skip_confirmation_and_ignored_claim(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let cookies = CookieHelper::new(); + + let link = carried_username_setup( + &state, + &cookies, + mas_data_model::UpstreamOAuthProviderImportAction::Ignore, + "alice", + true, + ) + .await; + + let request = Request::get(&*mas_router::UpstreamOAuth2Link::new(link.id).path()).empty(); + let response = state.request(cookies.with_cookies(request)).await; + response.assert_status(StatusCode::SEE_OTHER); + + let registration = lookup_registration(&state, &response).await; + assert_eq!(registration.username, "alice"); + } } From 474e67b5a6f097aedf783b2a77f6536259b6eec4 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Tue, 11 Aug 2026 15:22:15 +0200 Subject: [PATCH 32/46] Start upstream OAuth 2.0 flows with a form POST from the registration page A POST is what lets the username typed on the page travel with the request. It also lets the whole registration form become a single component later on. --- crates/handlers/src/lib.rs | 2 +- crates/handlers/src/views/register/mod.rs | 440 +++++++++++++++++++++- crates/router/src/endpoints.rs | 9 +- templates/pages/register/index.html | 12 +- translations/en.json | 14 +- 5 files changed, 455 insertions(+), 22 deletions(-) diff --git a/crates/handlers/src/lib.rs b/crates/handlers/src/lib.rs index 66ad96530..1190c0edc 100644 --- a/crates/handlers/src/lib.rs +++ b/crates/handlers/src/lib.rs @@ -393,7 +393,7 @@ where .route(mas_router::Logout::route(), post(self::views::logout::post)) .route( mas_router::Register::route(), - get(self::views::register::get), + get(self::views::register::get).post(self::views::register::post), ) .route( mas_router::PasswordRegister::route(), diff --git a/crates/handlers/src/views/register/mod.rs b/crates/handlers/src/views/register/mod.rs index 6a51852ae..54dd69298 100644 --- a/crates/handlers/src/views/register/mod.rs +++ b/crates/handlers/src/views/register/mod.rs @@ -1,21 +1,34 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2024, 2025 New Vector Ltd. // // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // Please see LICENSE files in the repository root for full details. use axum::{ + Form, extract::State, - response::{Html, IntoResponse, Response}, + response::{Html, IntoResponse, Redirect, Response}, }; use axum_extra::extract::Query; -use mas_axum_utils::{InternalError, SessionInfoExt, cookies::CookieJar, csrf::CsrfExt as _}; -use mas_data_model::{BoxClock, BoxRng, SiteConfig}; -use mas_router::{PasswordRegister, UpstreamOAuth2Authorize, UrlBuilder}; -use mas_storage::BoxRepository; +use hyper::StatusCode; +use mas_axum_utils::{ + GenericError, InternalError, SessionInfoExt, + cookies::CookieJar, + csrf::{CsrfExt as _, ProtectedForm}, +}; +use mas_data_model::{BoxClock, BoxRng, SiteConfig, UpstreamOAuthProvider}; +use mas_router::{PasswordRegister, Register, UpstreamOAuth2Authorize, UrlBuilder}; +use mas_storage::{BoxRepository, upstream_oauth2::UpstreamOAuthProviderRepository}; use mas_templates::{RegisterContext, TemplateContext, Templates}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use ulid::Ulid; use super::shared::OptionalPostAuthAction; -use crate::{BoundActivityTracker, PreferredLanguage}; +use crate::{ + BoundActivityTracker, MetadataCache, PreferredLanguage, impl_from_error_for_route, + upstream_oauth2::authorize::start_authorization, +}; mod cookie; pub(crate) mod password; @@ -23,6 +36,29 @@ pub(crate) mod steps; pub use self::cookie::UserRegistrationSessions as UserRegistrationSessionsCookie; +#[derive(Debug, Error)] +pub(crate) enum RouteError { + #[error("Provider not found")] + ProviderNotFound, + + #[error(transparent)] + Internal(Box), +} + +impl_from_error_for_route!(mas_storage::RepositoryError); +impl_from_error_for_route!(crate::upstream_oauth2::authorize::StartAuthorizationError); + +impl IntoResponse for RouteError { + fn into_response(self) -> Response { + match self { + e @ Self::ProviderNotFound => { + GenericError::new(StatusCode::NOT_FOUND, e).into_response() + } + Self::Internal(e) => InternalError::new(e).into_response(), + } + } +} + #[tracing::instrument(name = "handlers.views.register.get", skip_all)] pub(crate) async fn get( mut rng: BoxRng, @@ -93,3 +129,395 @@ pub(crate) async fn get( Ok((cookie_jar, Html(content)).into_response()) } + +/// A localpart longer than this can never become an MXID (`@` + localpart + +/// `:` + server name is capped at 255 bytes), so there is no point carrying it +/// any further. The real limit is enforced by the register policy. +const MAX_USERNAME_LENGTH: usize = 255; + +#[derive(Debug, Deserialize, Serialize)] +pub(crate) struct RegisterForm { + #[serde(default)] + username: String, + + /// Which upstream provider the user chose, if any: each provider has its + /// own submit button + #[serde(default)] + provider: Option, +} + +#[tracing::instrument(name = "handlers.views.register.post", skip_all)] +pub(crate) async fn post( + mut rng: BoxRng, + clock: BoxClock, + State(metadata_cache): State, + State(url_builder): State, + State(http_client): State, + mut repo: BoxRepository, + Query(query): Query, + cookie_jar: CookieJar, + Form(form): Form>, +) -> Result { + let post_auth_action = query.post_auth_action; + + let Ok(form) = cookie_jar.verify_form(&clock, form) else { + // The CSRF token expires after an hour, which a registration page left + // open in a tab will outlive. + tracing::debug!("Invalid CSRF token on the registration form, redirecting to a fresh one"); + let destination = Register::from(post_auth_action); + return Ok((cookie_jar, url_builder.redirect(&destination)).into_response()); + }; + + let username = form.username.trim(); + let username = if username.len() <= MAX_USERNAME_LENGTH { + username + } else { + "" + }; + + // Carry the username along so we can prefill it if the user gets to pick + // one when they come back + if let Some(provider_id) = form.provider { + let provider_id: Ulid = provider_id + .parse() + .map_err(|_| RouteError::ProviderNotFound)?; + + let provider = repo + .upstream_oauth_provider() + .lookup(provider_id) + .await? + .filter(UpstreamOAuthProvider::enabled) + .ok_or(RouteError::ProviderNotFound)?; + + let carried_username = (!username.is_empty()).then(|| username.to_owned()); + + let (cookie_jar, url) = start_authorization( + &mut rng, + &clock, + &metadata_cache, + &http_client, + &url_builder, + &mut repo, + cookie_jar, + &provider, + post_auth_action, + carried_username, + ) + .await?; + + repo.save().await?; + + return Ok((cookie_jar, Redirect::to(url.as_str())).into_response()); + } + + // The query string, rather than a cookie, so the page can be reloaded or + // bookmarked + let mut destination = PasswordRegister::from(post_auth_action); + if !username.is_empty() { + destination = destination.with_username(username.to_owned()); + } + + Ok((cookie_jar, url_builder.redirect(&destination)).into_response()) +} + +#[cfg(test)] +mod tests { + use hyper::{Request, StatusCode, header::LOCATION}; + use mas_data_model::{ + Clock, UlidExt, UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderDiscoveryMode, + UpstreamOAuthProviderOnBackchannelLogout, UpstreamOAuthProviderPkceMode, + UpstreamOAuthProviderTokenAuthMethod, + }; + use mas_iana::jose::JsonWebSignatureAlg; + use mas_storage::{ + RepositoryAccess, + upstream_oauth2::{UpstreamOAuthProviderParams, UpstreamOAuthSessionRepository}, + }; + use oauth2_types::scope::{OPENID, Scope}; + use sqlx::PgPool; + use ulid::Ulid; + + use super::MAX_USERNAME_LENGTH; + use crate::test_utils::{CookieHelper, RequestBuilderExt, ResponseExt, TestState, setup}; + + /// Provision an upstream provider which needs no network access to start an + /// authorization flow: discovery is disabled and the authorization endpoint + /// is set explicitly + async fn provider(state: &TestState) -> Ulid { + let mut rng = state.rng(); + let mut repo = state.repository().await.unwrap(); + let provider = repo + .upstream_oauth_provider() + .add( + &mut rng, + &state.clock, + UpstreamOAuthProviderParams { + issuer: Some("https://upstream.example.com/".to_owned()), + human_name: Some("Upstream 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, + client_id: "client".to_owned(), + encrypted_client_secret: None, + claims_imports: UpstreamOAuthProviderClaimsImports::default(), + authorization_endpoint_override: Some( + "https://upstream.example.com/authorize".parse().unwrap(), + ), + token_endpoint_override: None, + userinfo_endpoint_override: None, + fetch_userinfo: false, + userinfo_signed_response_alg: None, + jwks_uri_override: None, + discovery_mode: UpstreamOAuthProviderDiscoveryMode::Disabled, + pkce_mode: UpstreamOAuthProviderPkceMode::Disabled, + response_mode: None, + additional_authorization_parameters: Vec::new(), + forward_login_hint: false, + ui_order: 0, + on_backchannel_logout: UpstreamOAuthProviderOnBackchannelLogout::DoNothing, + registration_token_required: false, + }, + ) + .await + .unwrap(); + repo.save().await.unwrap(); + provider.id + } + + /// Render the registration page, saving its cookies and returning its CSRF + /// token and body + async fn render_page(state: &TestState, cookies: &CookieHelper) -> (String, String) { + let request = cookies.with_cookies(Request::get("/register").empty()); + let response = state.request(request).await; + cookies.save_cookies(&response); + response.assert_status(StatusCode::OK); + + let csrf_token = response + .body() + .split("name=\"csrf\" value=\"") + .nth(1) + .expect("the page should have a CSRF token") + .split('\"') + .next() + .unwrap() + .to_owned(); + + (csrf_token, response.body().clone()) + } + + /// Decode the upstream sessions cookie set by the given response, if any + fn upstream_sessions( + state: &TestState, + response: &hyper::Response, + ) -> Option { + let cookies = CookieHelper::new(); + cookies.save_cookies(response); + let request = cookies.with_cookies(Request::get("/").empty()); + state + .cookie_manager + .cookie_jar_from_headers(request.headers()) + .load("upstream-oauth2-sessions") + .expect("the upstream sessions cookie should decode") + } + + /// Submitting the form with a provider starts an upstream authorization + /// flow, carrying the username and the post-auth action along in the cookie + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_post_with_provider(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let cookies = CookieHelper::new(); + + let provider_id = provider(&state).await; + let (csrf_token, body) = render_page(&state, &cookies).await; + + // The provider is rendered as a submit button of the form + assert!(body.contains(&format!(r#"name="provider" value="{provider_id}""#))); + + let grant = Ulid::from_datetime_with_rng(state.clock.now(), &mut state.rng()); + let request = cookies.with_cookies( + Request::post(format!( + "/register?kind=continue_authorization_grant&id={grant}" + )) + .form(serde_json::json!({ + "csrf": csrf_token, + "username": "alice", + "provider": provider_id.to_string(), + })), + ); + let response = state.request(request).await; + response.assert_status(StatusCode::SEE_OTHER); + + let location = response.headers().get(LOCATION).unwrap().to_str().unwrap(); + assert!( + location.starts_with("https://upstream.example.com/authorize?"), + "unexpected location: {location}" + ); + + let sessions = upstream_sessions(&state, &response) + .expect("the upstream sessions cookie should be set"); + assert_eq!(sessions[0]["username"], "alice"); + assert_eq!( + sessions[0]["post_auth_action"], + serde_json::json!({ + "kind": "continue_authorization_grant", + "id": grant.to_string(), + }) + ); + + // And we recorded the session it points to + let session_id: Ulid = sessions[0]["session"].as_str().unwrap().parse().unwrap(); + let mut repo = state.repository().await.unwrap(); + let session = repo + .upstream_oauth_session() + .lookup(session_id) + .await + .unwrap() + .expect("the upstream authorization session should exist"); + assert_eq!(session.provider_id, provider_id); + } + + /// Submitting the form without a provider redirects to the password + /// registration page, keeping the username and the post-auth action + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_post_without_provider(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let cookies = CookieHelper::new(); + + provider(&state).await; + let (csrf_token, _body) = render_page(&state, &cookies).await; + + let request = cookies.with_cookies(Request::post("/register").form(serde_json::json!({ + "csrf": csrf_token, + "username": "alice", + }))); + let response = state.request(request).await; + response.assert_status(StatusCode::SEE_OTHER); + response.assert_header_value(LOCATION, "/register/password?username=alice"); + + // The post-auth action travels in the page's own query string + let request = cookies.with_cookies(Request::post("/register?kind=change_password").form( + serde_json::json!({ + "csrf": csrf_token, + "username": "alice", + }), + )); + let response = state.request(request).await; + response.assert_status(StatusCode::SEE_OTHER); + response.assert_header_value( + LOCATION, + "/register/password?username=alice&kind=change_password", + ); + + // Including the actions which carry an ID + let grant = Ulid::from_datetime_with_rng(state.clock.now(), &mut state.rng()); + let request = cookies.with_cookies( + Request::post(format!( + "/register?kind=continue_authorization_grant&id={grant}" + )) + .form(serde_json::json!({ + "csrf": csrf_token, + "username": "alice", + })), + ); + let response = state.request(request).await; + response.assert_status(StatusCode::SEE_OTHER); + response.assert_header_value( + LOCATION, + &format!( + "/register/password?username=alice&kind=continue_authorization_grant&id={grant}" + ), + ); + + // An empty username is not carried over + let request = cookies.with_cookies(Request::post("/register").form(serde_json::json!({ + "csrf": csrf_token, + "username": " ", + }))); + let response = state.request(request).await; + response.assert_status(StatusCode::SEE_OTHER); + response.assert_header_value(LOCATION, "/register/password"); + } + + /// A form submitted with an invalid CSRF token starts nothing and sends the + /// user back to a freshly rendered page + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_post_invalid_csrf(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let cookies = CookieHelper::new(); + + let provider_id = provider(&state).await; + let (csrf_token, _body) = render_page(&state, &cookies).await; + + let request = cookies.with_cookies(Request::post("/register").form(serde_json::json!({ + "csrf": format!("{csrf_token}invalid"), + "username": "alice", + "provider": provider_id.to_string(), + }))); + let response = state.request(request).await; + response.assert_status(StatusCode::SEE_OTHER); + response.assert_header_value(LOCATION, "/register"); + assert!(upstream_sessions(&state, &response).is_none()); + } + + /// A username too long to ever become an MXID is dropped instead of being + /// carried in the cookie + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_post_with_overlong_username(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let cookies = CookieHelper::new(); + + let provider_id = provider(&state).await; + let (csrf_token, _body) = render_page(&state, &cookies).await; + + let username = "a".repeat(MAX_USERNAME_LENGTH + 1); + let request = cookies.with_cookies(Request::post("/register").form(serde_json::json!({ + "csrf": csrf_token, + "username": username, + "provider": provider_id.to_string(), + }))); + let response = state.request(request).await; + response.assert_status(StatusCode::SEE_OTHER); + + let sessions = upstream_sessions(&state, &response) + .expect("the upstream sessions cookie should be set"); + assert!(sessions[0].get("username").is_none()); + + // And it isn't put in the redirect to the password registration page either + let request = cookies.with_cookies(Request::post("/register").form(serde_json::json!({ + "csrf": csrf_token, + "username": username, + }))); + let response = state.request(request).await; + response.assert_status(StatusCode::SEE_OTHER); + response.assert_header_value(LOCATION, "/register/password"); + } + + /// Submitting a provider which doesn't exist gives a 404 + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_post_with_unknown_provider(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let cookies = CookieHelper::new(); + + provider(&state).await; + let (csrf_token, _body) = render_page(&state, &cookies).await; + + let unknown = Ulid::from_datetime_with_rng(state.clock.now(), &mut state.rng()); + for provider in [unknown.to_string(), "not-a-ulid".to_owned()] { + let request = + cookies.with_cookies(Request::post("/register").form(serde_json::json!({ + "csrf": csrf_token, + "username": "alice", + "provider": provider, + }))); + let response = state.request(request).await; + response.assert_status(StatusCode::NOT_FOUND); + } + } +} diff --git a/crates/router/src/endpoints.rs b/crates/router/src/endpoints.rs index 3e60de10c..183ead4fa 100644 --- a/crates/router/src/endpoints.rs +++ b/crates/router/src/endpoints.rs @@ -271,7 +271,7 @@ impl SimpleRoute for Logout { const PATH: &'static str = "/logout"; } -/// `POST /register` +/// `GET|POST /register` #[derive(Default, Debug, Clone)] pub struct Register { post_auth_action: Option, @@ -347,6 +347,13 @@ impl PasswordRegister { self } + /// Prefill the form with the given username + #[must_use] + pub fn with_username(mut self, username: String) -> Self { + self.username = Some(username); + self + } + #[must_use] pub fn and_continue_grant(mut self, data: Ulid) -> Self { self.post_auth_action = Some(PostAuthAction::continue_grant(data)); diff --git a/templates/pages/register/index.html b/templates/pages/register/index.html index dc9fd61ec..b06da8429 100644 --- a/templates/pages/register/index.html +++ b/templates/pages/register/index.html @@ -1,4 +1,5 @@ {# +Copyright 2025, 2026 Element Creations Ltd. Copyright 2024, 2025 New Vector Ltd. Copyright 2021-2024 The Matrix.org Foundation C.I.C. @@ -11,7 +12,7 @@ Please see LICENSE files in the repository root for full details. {% from "components/idp_brand.html" import logo %} {% block content %} - +
{{ icon.user_profile_solid() }} @@ -36,9 +37,7 @@ Please see LICENSE files in the repository root for full details. {% endif %}
- {% for key, value in next["params"] | default({}) | items %} - - {% endfor %} + {% if features.password_registration %} {% if features.password_registration_email_required %} @@ -49,13 +48,12 @@ Please see LICENSE files in the repository root for full details. {% endif %} {% if providers %} - {% set params = next["params"] | default({}) | to_params(prefix="?") %} {% for provider in providers %} {% set name = provider.human_name or (provider.issuer | simplify_url(keep_path=True)) or provider.id %} - + {% endfor %} {% endif %} diff --git a/translations/en.json b/translations/en.json index 0f22b2507..5e3363cbb 100644 --- a/translations/en.json +++ b/translations/en.json @@ -99,7 +99,7 @@ }, "username": "Username", "@username": { - "context": "pages/login.html:51:39-59, pages/register/index.html:30:35-55, pages/register/password.html:37:33-53, pages/upstream_oauth2/do_register.html:101:35-55, pages/upstream_oauth2/do_register.html:107:39-59" + "context": "pages/login.html:51:39-59, pages/register/index.html:31:35-55, pages/register/password.html:37:33-53, pages/upstream_oauth2/do_register.html:101:35-55, pages/upstream_oauth2/do_register.html:107:39-59" } }, "error": { @@ -461,7 +461,7 @@ }, "continue_with_provider": "Continue with %(provider)s", "@continue_with_provider": { - "context": "pages/login.html:81:15-67, pages/register/index.html:57:15-67", + "context": "pages/login.html:81:15-67, pages/register/index.html:55:15-67", "description": "Button to log in with an upstream provider" }, "description": "Please sign in to continue:", @@ -664,25 +664,25 @@ "register": { "call_to_login": "Already have an account?", "@call_to_login": { - "context": "pages/register/index.html:63:35-66, pages/register/password.html:83:33-64", + "context": "pages/register/index.html:61:35-66, pages/register/password.html:83:33-64", "description": "Displayed on the registration page to suggest to log in instead" }, "continue_with_email": "Continue with email address", "@continue_with_email": { - "context": "pages/register/index.html:45:32-69" + "context": "pages/register/index.html:44:32-69" }, "continue_with_password": "Continue with password", "@continue_with_password": { - "context": "pages/register/index.html:47:32-72" + "context": "pages/register/index.html:46:32-72" }, "create_account": { "description": "Choose a username to continue.", "@description": { - "context": "pages/register/index.html:24:29-73" + "context": "pages/register/index.html:25:29-73" }, "heading": "Create an account", "@heading": { - "context": "pages/register/index.html:21:29-69, pages/register/password.html:21:27-67" + "context": "pages/register/index.html:22:29-69, pages/register/password.html:21:27-67" } }, "terms_of_service": "I agree to the Terms and Conditions", From 8a8266d4093c5aa57e711133c225d2dac4aef65c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:07:45 +0000 Subject: [PATCH 33/46] Translations updates --- frontend/.storybook/locales.ts | 82 +++++++++++++++++----------------- 1 file changed, 41 insertions(+), 41 deletions(-) diff --git a/frontend/.storybook/locales.ts b/frontend/.storybook/locales.ts index 4b0ee9189..2c5182dec 100644 --- a/frontend/.storybook/locales.ts +++ b/frontend/.storybook/locales.ts @@ -27,7 +27,7 @@ export type LocalazyMetadata = { }; const localazyMetadata: LocalazyMetadata = { - projectUrl: "https://localazy.com/p/matrix-authentication-service", + projectUrl: "https://localazy.com/p/matrix-authentication-service!v1.25", baseLocale: "en", languages: [ { @@ -217,26 +217,26 @@ const localazyMetadata: LocalazyMetadata = { file: "frontend.json", path: "", cdnFiles: { - "cs": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/cs/frontend.json", - "da": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/da/frontend.json", - "de": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/de/frontend.json", - "en": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/en/frontend.json", - "et": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/et/frontend.json", - "fi": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/fi/frontend.json", - "fr": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/fr/frontend.json", - "hu": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/hu/frontend.json", - "nb_NO": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/nb-NO/frontend.json", - "nl": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/nl/frontend.json", - "pl": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pl/frontend.json", - "pt": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pt/frontend.json", - "pt_BR": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pt-BR/frontend.json", - "ru": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/ru/frontend.json", - "sk": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/sk/frontend.json", - "sv": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/sv/frontend.json", - "tr": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/tr/frontend.json", - "uk": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/uk/frontend.json", - "uz": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/uz/frontend.json", - "zh#Hans": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/zh-Hans/frontend.json" + "cs": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/cs/frontend.json", + "da": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/da/frontend.json", + "de": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/de/frontend.json", + "en": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/en/frontend.json", + "et": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/et/frontend.json", + "fi": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/fi/frontend.json", + "fr": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/fr/frontend.json", + "hu": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/hu/frontend.json", + "nb_NO": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/nb-NO/frontend.json", + "nl": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/nl/frontend.json", + "pl": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pl/frontend.json", + "pt": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pt/frontend.json", + "pt_BR": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pt-BR/frontend.json", + "ru": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/ru/frontend.json", + "sk": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/sk/frontend.json", + "sv": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/sv/frontend.json", + "tr": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/tr/frontend.json", + "uk": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/uk/frontend.json", + "uz": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/uz/frontend.json", + "zh#Hans": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/zh-Hans/frontend.json" } }, { @@ -244,26 +244,26 @@ const localazyMetadata: LocalazyMetadata = { file: "file.json", path: "", cdnFiles: { - "cs": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/cs/file.json", - "da": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/da/file.json", - "de": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/de/file.json", - "en": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/en/file.json", - "et": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/et/file.json", - "fi": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/fi/file.json", - "fr": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/fr/file.json", - "hu": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/hu/file.json", - "nb_NO": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/nb-NO/file.json", - "nl": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/nl/file.json", - "pl": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pl/file.json", - "pt": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pt/file.json", - "pt_BR": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pt-BR/file.json", - "ru": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/ru/file.json", - "sk": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/sk/file.json", - "sv": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/sv/file.json", - "tr": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/tr/file.json", - "uk": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/uk/file.json", - "uz": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/uz/file.json", - "zh#Hans": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/zh-Hans/file.json" + "cs": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/cs/file.json", + "da": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/da/file.json", + "de": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/de/file.json", + "en": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/en/file.json", + "et": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/et/file.json", + "fi": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/fi/file.json", + "fr": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/fr/file.json", + "hu": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/hu/file.json", + "nb_NO": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/nb-NO/file.json", + "nl": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/nl/file.json", + "pl": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pl/file.json", + "pt": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pt/file.json", + "pt_BR": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pt-BR/file.json", + "ru": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/ru/file.json", + "sk": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/sk/file.json", + "sv": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/sv/file.json", + "tr": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/tr/file.json", + "uk": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/uk/file.json", + "uz": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/uz/file.json", + "zh#Hans": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/zh-Hans/file.json" } } ] From 57bb1b0512c8d626d232317880d92062fb829d30 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:50:40 +0000 Subject: [PATCH 34/46] 1.25.0 --- Cargo.lock | 56 +++++++++++++++++++++++++------------------------- Cargo.toml | 60 +++++++++++++++++++++++++++--------------------------- 2 files changed, 58 insertions(+), 58 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 310211593..4739534be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3198,7 +3198,7 @@ dependencies = [ [[package]] name = "mas-axum-utils" -version = "1.25.0-rc.0" +version = "1.25.0" dependencies = [ "anyhow", "axum", @@ -3233,7 +3233,7 @@ dependencies = [ [[package]] name = "mas-cli" -version = "1.25.0-rc.0" +version = "1.25.0" dependencies = [ "anyhow", "axum", @@ -3309,7 +3309,7 @@ dependencies = [ [[package]] name = "mas-config" -version = "1.25.0-rc.0" +version = "1.25.0" dependencies = [ "anyhow", "camino", @@ -3340,7 +3340,7 @@ dependencies = [ [[package]] name = "mas-context" -version = "1.25.0-rc.0" +version = "1.25.0" dependencies = [ "console", "opentelemetry", @@ -3357,7 +3357,7 @@ dependencies = [ [[package]] name = "mas-data-model" -version = "1.25.0-rc.0" +version = "1.25.0" dependencies = [ "base64ct", "chrono", @@ -3379,7 +3379,7 @@ dependencies = [ [[package]] name = "mas-email" -version = "1.25.0-rc.0" +version = "1.25.0" dependencies = [ "async-trait", "lettre", @@ -3390,7 +3390,7 @@ dependencies = [ [[package]] name = "mas-handlers" -version = "1.25.0-rc.0" +version = "1.25.0" dependencies = [ "aide", "anyhow", @@ -3473,7 +3473,7 @@ dependencies = [ [[package]] name = "mas-http" -version = "1.25.0-rc.0" +version = "1.25.0" dependencies = [ "futures-util", "headers", @@ -3493,7 +3493,7 @@ dependencies = [ [[package]] name = "mas-i18n" -version = "1.25.0-rc.0" +version = "1.25.0" dependencies = [ "camino", "icu_datetime", @@ -3513,7 +3513,7 @@ dependencies = [ [[package]] name = "mas-i18n-scan" -version = "1.25.0-rc.0" +version = "1.25.0" dependencies = [ "camino", "clap", @@ -3527,7 +3527,7 @@ dependencies = [ [[package]] name = "mas-iana" -version = "1.25.0-rc.0" +version = "1.25.0" dependencies = [ "schemars 1.2.1", "serde", @@ -3535,7 +3535,7 @@ dependencies = [ [[package]] name = "mas-iana-codegen" -version = "1.25.0-rc.0" +version = "1.25.0" dependencies = [ "anyhow", "async-trait", @@ -3552,7 +3552,7 @@ dependencies = [ [[package]] name = "mas-jose" -version = "1.25.0-rc.0" +version = "1.25.0" dependencies = [ "base64ct", "chrono", @@ -3582,7 +3582,7 @@ dependencies = [ [[package]] name = "mas-keystore" -version = "1.25.0-rc.0" +version = "1.25.0" dependencies = [ "aead", "base64ct", @@ -3610,7 +3610,7 @@ dependencies = [ [[package]] name = "mas-listener" -version = "1.25.0-rc.0" +version = "1.25.0" dependencies = [ "anyhow", "bytes", @@ -3634,7 +3634,7 @@ dependencies = [ [[package]] name = "mas-matrix" -version = "1.25.0-rc.0" +version = "1.25.0" dependencies = [ "anyhow", "async-trait", @@ -3644,7 +3644,7 @@ dependencies = [ [[package]] name = "mas-matrix-synapse" -version = "1.25.0-rc.0" +version = "1.25.0" dependencies = [ "anyhow", "async-trait", @@ -3661,7 +3661,7 @@ dependencies = [ [[package]] name = "mas-oidc-client" -version = "1.25.0-rc.0" +version = "1.25.0" dependencies = [ "assert_matches", "async-trait", @@ -3698,7 +3698,7 @@ dependencies = [ [[package]] name = "mas-policy" -version = "1.25.0-rc.0" +version = "1.25.0" dependencies = [ "anyhow", "arc-swap", @@ -3715,7 +3715,7 @@ dependencies = [ [[package]] name = "mas-router" -version = "1.25.0-rc.0" +version = "1.25.0" dependencies = [ "axum", "serde", @@ -3726,7 +3726,7 @@ dependencies = [ [[package]] name = "mas-spa" -version = "1.25.0-rc.0" +version = "1.25.0" dependencies = [ "camino", "serde", @@ -3735,7 +3735,7 @@ dependencies = [ [[package]] name = "mas-storage" -version = "1.25.0-rc.0" +version = "1.25.0" dependencies = [ "async-trait", "chrono", @@ -3757,7 +3757,7 @@ dependencies = [ [[package]] name = "mas-storage-pg" -version = "1.25.0-rc.0" +version = "1.25.0" dependencies = [ "async-trait", "chrono", @@ -3789,7 +3789,7 @@ dependencies = [ [[package]] name = "mas-tasks" -version = "1.25.0-rc.0" +version = "1.25.0" dependencies = [ "anyhow", "async-trait", @@ -3821,7 +3821,7 @@ dependencies = [ [[package]] name = "mas-templates" -version = "1.25.0-rc.0" +version = "1.25.0" dependencies = [ "anyhow", "arc-swap", @@ -3853,7 +3853,7 @@ dependencies = [ [[package]] name = "mas-tower" -version = "1.25.0-rc.0" +version = "1.25.0" dependencies = [ "http", "opentelemetry", @@ -4105,7 +4105,7 @@ dependencies = [ [[package]] name = "oauth2-types" -version = "1.25.0-rc.0" +version = "1.25.0" dependencies = [ "assert_matches", "base64ct", @@ -6281,7 +6281,7 @@ dependencies = [ [[package]] name = "syn2mas" -version = "1.25.0-rc.0" +version = "1.25.0" dependencies = [ "anyhow", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index 689b90bdc..421bfd42e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ members = ["crates/*"] resolver = "2" # Updated in the CI with a `sed` command -package.version = "1.25.0-rc.0" +package.version = "1.25.0" package.license = "AGPL-3.0-only OR LicenseRef-Element-Commercial" package.authors = ["Element Backend Team"] package.edition = "2024" @@ -42,35 +42,35 @@ broken_intra_doc_links = "deny" [workspace.dependencies] # Workspace crates -mas-axum-utils = { path = "./crates/axum-utils/", version = "=1.25.0-rc.0" } -mas-cli = { path = "./crates/cli/", version = "=1.25.0-rc.0" } -mas-config = { path = "./crates/config/", version = "=1.25.0-rc.0" } -mas-context = { path = "./crates/context/", version = "=1.25.0-rc.0" } -mas-data-model = { path = "./crates/data-model/", version = "=1.25.0-rc.0" } -mas-email = { path = "./crates/email/", version = "=1.25.0-rc.0" } -mas-graphql = { path = "./crates/graphql/", version = "=1.25.0-rc.0" } -mas-handlers = { path = "./crates/handlers/", version = "=1.25.0-rc.0" } -mas-http = { path = "./crates/http/", version = "=1.25.0-rc.0" } -mas-i18n = { path = "./crates/i18n/", version = "=1.25.0-rc.0" } -mas-i18n-scan = { path = "./crates/i18n-scan/", version = "=1.25.0-rc.0" } -mas-iana = { path = "./crates/iana/", version = "=1.25.0-rc.0" } -mas-iana-codegen = { path = "./crates/iana-codegen/", version = "=1.25.0-rc.0" } -mas-jose = { path = "./crates/jose/", version = "=1.25.0-rc.0" } -mas-keystore = { path = "./crates/keystore/", version = "=1.25.0-rc.0" } -mas-listener = { path = "./crates/listener/", version = "=1.25.0-rc.0" } -mas-matrix = { path = "./crates/matrix/", version = "=1.25.0-rc.0" } -mas-matrix-synapse = { path = "./crates/matrix-synapse/", version = "=1.25.0-rc.0" } -mas-oidc-client = { path = "./crates/oidc-client/", version = "=1.25.0-rc.0" } -mas-policy = { path = "./crates/policy/", version = "=1.25.0-rc.0" } -mas-router = { path = "./crates/router/", version = "=1.25.0-rc.0" } -mas-spa = { path = "./crates/spa/", version = "=1.25.0-rc.0" } -mas-storage = { path = "./crates/storage/", version = "=1.25.0-rc.0" } -mas-storage-pg = { path = "./crates/storage-pg/", version = "=1.25.0-rc.0" } -mas-tasks = { path = "./crates/tasks/", version = "=1.25.0-rc.0" } -mas-templates = { path = "./crates/templates/", version = "=1.25.0-rc.0" } -mas-tower = { path = "./crates/tower/", version = "=1.25.0-rc.0" } -oauth2-types = { path = "./crates/oauth2-types/", version = "=1.25.0-rc.0" } -syn2mas = { path = "./crates/syn2mas", version = "=1.25.0-rc.0" } +mas-axum-utils = { path = "./crates/axum-utils/", version = "=1.25.0" } +mas-cli = { path = "./crates/cli/", version = "=1.25.0" } +mas-config = { path = "./crates/config/", version = "=1.25.0" } +mas-context = { path = "./crates/context/", version = "=1.25.0" } +mas-data-model = { path = "./crates/data-model/", version = "=1.25.0" } +mas-email = { path = "./crates/email/", version = "=1.25.0" } +mas-graphql = { path = "./crates/graphql/", version = "=1.25.0" } +mas-handlers = { path = "./crates/handlers/", version = "=1.25.0" } +mas-http = { path = "./crates/http/", version = "=1.25.0" } +mas-i18n = { path = "./crates/i18n/", version = "=1.25.0" } +mas-i18n-scan = { path = "./crates/i18n-scan/", version = "=1.25.0" } +mas-iana = { path = "./crates/iana/", version = "=1.25.0" } +mas-iana-codegen = { path = "./crates/iana-codegen/", version = "=1.25.0" } +mas-jose = { path = "./crates/jose/", version = "=1.25.0" } +mas-keystore = { path = "./crates/keystore/", version = "=1.25.0" } +mas-listener = { path = "./crates/listener/", version = "=1.25.0" } +mas-matrix = { path = "./crates/matrix/", version = "=1.25.0" } +mas-matrix-synapse = { path = "./crates/matrix-synapse/", version = "=1.25.0" } +mas-oidc-client = { path = "./crates/oidc-client/", version = "=1.25.0" } +mas-policy = { path = "./crates/policy/", version = "=1.25.0" } +mas-router = { path = "./crates/router/", version = "=1.25.0" } +mas-spa = { path = "./crates/spa/", version = "=1.25.0" } +mas-storage = { path = "./crates/storage/", version = "=1.25.0" } +mas-storage-pg = { path = "./crates/storage-pg/", version = "=1.25.0" } +mas-tasks = { path = "./crates/tasks/", version = "=1.25.0" } +mas-templates = { path = "./crates/templates/", version = "=1.25.0" } +mas-tower = { path = "./crates/tower/", version = "=1.25.0" } +oauth2-types = { path = "./crates/oauth2-types/", version = "=1.25.0" } +syn2mas = { path = "./crates/syn2mas", version = "=1.25.0" } # OpenAPI schema generation and validation [workspace.dependencies.aide] From 1296cb544094d59391999206f6eab8e15bac1f66 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 21 Sep 2026 11:16:05 -0500 Subject: [PATCH 35/46] Link to CI from @ElementBot --- docs/development/releasing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/development/releasing.md b/docs/development/releasing.md index 1c8e8ad89..6e7033efb 100644 --- a/docs/development/releasing.md +++ b/docs/development/releasing.md @@ -119,5 +119,5 @@ At this point, the releaser should check the changelog and ensure the "Set as pr [`release-bump` workflow]: https://github.com/element-hq/matrix-authentication-service/actions/workflows/release-bump.yaml [`build` workflow]: https://github.com/element-hq/matrix-authentication-service/actions/workflows/build.yaml [translation download PR]: https://github.com/element-hq/matrix-authentication-service/pulls?q=is%3Apr+label%3AA-I18n -[CI to churn]: https://github.com/element-hq/matrix-authentication-service/actions/workflows/build.yaml?query=event%3Apush+actor%3Amatrixbot +[CI to churn]: https://github.com/element-hq/matrix-authentication-service/actions/workflows/build.yaml?query=event%3Apush+actor%3Aelementbot [draft release to appear]: https://github.com/element-hq/matrix-authentication-service/releases From 8898c7ba799ddf20fbd19f68518584714fd3f841 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 21 Sep 2026 11:16:44 -0500 Subject: [PATCH 36/46] Link [`translations-download` workflow] --- docs/development/releasing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/development/releasing.md b/docs/development/releasing.md index 6e7033efb..e1b38aeb6 100644 --- a/docs/development/releasing.md +++ b/docs/development/releasing.md @@ -13,7 +13,7 @@ MAS follows the same release cadence as Synapse, meaning usually one full releas 1. Check the "Set as pre-release" checkbox, and publish the release. 1. Delete the N-2 release branch on [Localazy](https://localazy.com/console/branching), meaning that once the 0.16 release cycle begins, the 0.14 release branch will be deleted. - Create new release candidates if needed: - 1. Run the `translations-download` workflow on the release branch. + 1. Run the [`translations-download` workflow] on the release branch. 1. Wait for the [translation download PR] to be automatically merged. 1. Run the [`release-bump` workflow] on the release branch, with the `rc` input **checked**. 1. Wait for [CI to churn] and the [draft release to appear]. This takes about 30 minutes. From a6de208d83bfaaf06104fa62f3503e066b05e0ce Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Mon, 21 Sep 2026 22:08:41 +0200 Subject: [PATCH 37/46] Render the templates with a CAPTCHA config in the template checks --- crates/templates/src/context/captcha.rs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/templates/src/context/captcha.rs b/crates/templates/src/context/captcha.rs index f2139e2bd..76e692548 100644 --- a/crates/templates/src/context/captcha.rs +++ b/crates/templates/src/context/captcha.rs @@ -68,9 +68,24 @@ impl TemplateContext for WithCaptcha { where Self: Sized, { - T::sample(now, rng, locales) + let config = mas_data_model::CaptchaConfig { + service: mas_data_model::CaptchaService::RecaptchaV2, + site_key: "site-key".to_owned(), + secret_key: "secret-key".to_owned(), + }; + + [("none", None), ("some", Some(config))] .into_iter() - .map(|(k, inner)| (k, Self::new(None, inner))) + .flat_map(|(label, config)| { + T::sample(now, rng, locales) + .into_iter() + .map(move |(k, inner)| { + ( + k.with_appended("captcha", label.to_owned()), + Self::new(config.clone(), inner), + ) + }) + }) .collect() } } From dfda70dd8ea95e07487d3f2ac2b8d205fcc87b9a Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Mon, 21 Sep 2026 22:09:46 +0200 Subject: [PATCH 38/46] Fix the CAPTCHA component failing to render because of an undefined translation function Macros close over the context of the template that imports them, at import time. Importing the CAPTCHA component from a child template ran before `base.html` defined `_`, so the noscript translation inside the macro failed with an undefined-not-callable error. Import it from `base.html` like every other component. --- templates/base.html | 1 + templates/pages/register/password.html | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/templates/base.html b/templates/base.html index 2113a34fd..f7ed20bb6 100644 --- a/templates/base.html +++ b/templates/base.html @@ -16,6 +16,7 @@ Please see LICENSE files in the repository root for full details. {% import "components/errors.html" as errors %} {% import "components/icon.html" as icon %} {% import "components/scope.html" as scope %} +{% import "components/captcha.html" as captcha %} diff --git a/templates/pages/register/password.html b/templates/pages/register/password.html index 08e71fe1e..d181d5428 100644 --- a/templates/pages/register/password.html +++ b/templates/pages/register/password.html @@ -9,8 +9,6 @@ Please see LICENSE files in the repository root for full details. {% extends "base.html" %} -{% import "components/captcha.html" as captcha %} - {% block content %}
From c565685989cafcb3a60e661a7c27fef65b5bff9e Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Mon, 21 Sep 2026 22:33:53 +0200 Subject: [PATCH 39/46] Update translations by running `sh misc/update.sh` --- translations/en.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/translations/en.json b/translations/en.json index e7df4b636..75cf3fbe7 100644 --- a/translations/en.json +++ b/translations/en.json @@ -10,7 +10,7 @@ }, "continue": "Continue", "@continue": { - "context": "form_post.html:26: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:80: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:26: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:78: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": { @@ -41,7 +41,7 @@ }, "name": "matrix-authentication-service", "@name": { - "context": "app.html:18:14-27, base.html:24:31-44", + "context": "app.html:18:14-27, base.html:25:31-44", "description": "Name of the application" }, "technical_description": "OpenID Connect discovery document: %(discovery_url)s", @@ -79,7 +79,7 @@ }, "email_address": "Email address", "@email_address": { - "context": "pages/recovery/start.html:34:33-58, pages/register/password.html:43:35-60, pages/upstream_oauth2/do_register.html:115:37-62" + "context": "pages/recovery/start.html:34:33-58, pages/register/password.html:41:35-60, pages/upstream_oauth2/do_register.html:115:37-62" }, "loading": "Loading…", "@loading": { @@ -91,15 +91,15 @@ }, "password": "Password", "@password": { - "context": "pages/login.html:56:37-57, pages/reauth.html:28:35-55, pages/register/password.html:48:33-53" + "context": "pages/login.html:56:37-57, pages/reauth.html:28:35-55, pages/register/password.html:46:33-53" }, "password_confirm": "Confirm password", "@password_confirm": { - "context": "pages/register/password.html:52:33-61" + "context": "pages/register/password.html:50:33-61" }, "username": "Username", "@username": { - "context": "pages/login.html:51:39-59, pages/register/index.html:30:35-55, pages/register/password.html:37:33-53, pages/upstream_oauth2/do_register.html:101:35-55, pages/upstream_oauth2/do_register.html:107:39-59" + "context": "pages/login.html:51:39-59, pages/register/index.html:30:35-55, pages/register/password.html:35:33-53, pages/upstream_oauth2/do_register.html:101:35-55, pages/upstream_oauth2/do_register.html:107:39-59" } }, "error": { @@ -660,7 +660,7 @@ "register": { "call_to_login": "Already have an account?", "@call_to_login": { - "context": "pages/register/index.html:63:35-66, pages/register/password.html:83:33-64", + "context": "pages/register/index.html:63:35-66, pages/register/password.html:81:33-64", "description": "Displayed on the registration page to suggest to log in instead" }, "continue_with_email": "Continue with email address", @@ -678,12 +678,12 @@ }, "heading": "Create an account", "@heading": { - "context": "pages/register/index.html:21:29-69, pages/register/password.html:21:27-67" + "context": "pages/register/index.html:21:29-69, pages/register/password.html:19:27-67" } }, "terms_of_service": "I agree to the Terms and Conditions", "@terms_of_service": { - "context": "pages/register/password.html:57:35-95, pages/upstream_oauth2/do_register.html:180:35-95" + "context": "pages/register/password.html:55:35-95, pages/upstream_oauth2/do_register.html:180:35-95" } }, "registration_token": { From 97945cd9e7869bc5cdc4322bfc26cef552f11157 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:56:31 +0000 Subject: [PATCH 40/46] 1.25.1 --- Cargo.lock | 56 +++++++++++++++++++++++++------------------------- Cargo.toml | 60 +++++++++++++++++++++++++++--------------------------- 2 files changed, 58 insertions(+), 58 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4739534be..75940b7e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3198,7 +3198,7 @@ dependencies = [ [[package]] name = "mas-axum-utils" -version = "1.25.0" +version = "1.25.1" dependencies = [ "anyhow", "axum", @@ -3233,7 +3233,7 @@ dependencies = [ [[package]] name = "mas-cli" -version = "1.25.0" +version = "1.25.1" dependencies = [ "anyhow", "axum", @@ -3309,7 +3309,7 @@ dependencies = [ [[package]] name = "mas-config" -version = "1.25.0" +version = "1.25.1" dependencies = [ "anyhow", "camino", @@ -3340,7 +3340,7 @@ dependencies = [ [[package]] name = "mas-context" -version = "1.25.0" +version = "1.25.1" dependencies = [ "console", "opentelemetry", @@ -3357,7 +3357,7 @@ dependencies = [ [[package]] name = "mas-data-model" -version = "1.25.0" +version = "1.25.1" dependencies = [ "base64ct", "chrono", @@ -3379,7 +3379,7 @@ dependencies = [ [[package]] name = "mas-email" -version = "1.25.0" +version = "1.25.1" dependencies = [ "async-trait", "lettre", @@ -3390,7 +3390,7 @@ dependencies = [ [[package]] name = "mas-handlers" -version = "1.25.0" +version = "1.25.1" dependencies = [ "aide", "anyhow", @@ -3473,7 +3473,7 @@ dependencies = [ [[package]] name = "mas-http" -version = "1.25.0" +version = "1.25.1" dependencies = [ "futures-util", "headers", @@ -3493,7 +3493,7 @@ dependencies = [ [[package]] name = "mas-i18n" -version = "1.25.0" +version = "1.25.1" dependencies = [ "camino", "icu_datetime", @@ -3513,7 +3513,7 @@ dependencies = [ [[package]] name = "mas-i18n-scan" -version = "1.25.0" +version = "1.25.1" dependencies = [ "camino", "clap", @@ -3527,7 +3527,7 @@ dependencies = [ [[package]] name = "mas-iana" -version = "1.25.0" +version = "1.25.1" dependencies = [ "schemars 1.2.1", "serde", @@ -3535,7 +3535,7 @@ dependencies = [ [[package]] name = "mas-iana-codegen" -version = "1.25.0" +version = "1.25.1" dependencies = [ "anyhow", "async-trait", @@ -3552,7 +3552,7 @@ dependencies = [ [[package]] name = "mas-jose" -version = "1.25.0" +version = "1.25.1" dependencies = [ "base64ct", "chrono", @@ -3582,7 +3582,7 @@ dependencies = [ [[package]] name = "mas-keystore" -version = "1.25.0" +version = "1.25.1" dependencies = [ "aead", "base64ct", @@ -3610,7 +3610,7 @@ dependencies = [ [[package]] name = "mas-listener" -version = "1.25.0" +version = "1.25.1" dependencies = [ "anyhow", "bytes", @@ -3634,7 +3634,7 @@ dependencies = [ [[package]] name = "mas-matrix" -version = "1.25.0" +version = "1.25.1" dependencies = [ "anyhow", "async-trait", @@ -3644,7 +3644,7 @@ dependencies = [ [[package]] name = "mas-matrix-synapse" -version = "1.25.0" +version = "1.25.1" dependencies = [ "anyhow", "async-trait", @@ -3661,7 +3661,7 @@ dependencies = [ [[package]] name = "mas-oidc-client" -version = "1.25.0" +version = "1.25.1" dependencies = [ "assert_matches", "async-trait", @@ -3698,7 +3698,7 @@ dependencies = [ [[package]] name = "mas-policy" -version = "1.25.0" +version = "1.25.1" dependencies = [ "anyhow", "arc-swap", @@ -3715,7 +3715,7 @@ dependencies = [ [[package]] name = "mas-router" -version = "1.25.0" +version = "1.25.1" dependencies = [ "axum", "serde", @@ -3726,7 +3726,7 @@ dependencies = [ [[package]] name = "mas-spa" -version = "1.25.0" +version = "1.25.1" dependencies = [ "camino", "serde", @@ -3735,7 +3735,7 @@ dependencies = [ [[package]] name = "mas-storage" -version = "1.25.0" +version = "1.25.1" dependencies = [ "async-trait", "chrono", @@ -3757,7 +3757,7 @@ dependencies = [ [[package]] name = "mas-storage-pg" -version = "1.25.0" +version = "1.25.1" dependencies = [ "async-trait", "chrono", @@ -3789,7 +3789,7 @@ dependencies = [ [[package]] name = "mas-tasks" -version = "1.25.0" +version = "1.25.1" dependencies = [ "anyhow", "async-trait", @@ -3821,7 +3821,7 @@ dependencies = [ [[package]] name = "mas-templates" -version = "1.25.0" +version = "1.25.1" dependencies = [ "anyhow", "arc-swap", @@ -3853,7 +3853,7 @@ dependencies = [ [[package]] name = "mas-tower" -version = "1.25.0" +version = "1.25.1" dependencies = [ "http", "opentelemetry", @@ -4105,7 +4105,7 @@ dependencies = [ [[package]] name = "oauth2-types" -version = "1.25.0" +version = "1.25.1" dependencies = [ "assert_matches", "base64ct", @@ -6281,7 +6281,7 @@ dependencies = [ [[package]] name = "syn2mas" -version = "1.25.0" +version = "1.25.1" dependencies = [ "anyhow", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index 421bfd42e..d83162ca4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ members = ["crates/*"] resolver = "2" # Updated in the CI with a `sed` command -package.version = "1.25.0" +package.version = "1.25.1" package.license = "AGPL-3.0-only OR LicenseRef-Element-Commercial" package.authors = ["Element Backend Team"] package.edition = "2024" @@ -42,35 +42,35 @@ broken_intra_doc_links = "deny" [workspace.dependencies] # Workspace crates -mas-axum-utils = { path = "./crates/axum-utils/", version = "=1.25.0" } -mas-cli = { path = "./crates/cli/", version = "=1.25.0" } -mas-config = { path = "./crates/config/", version = "=1.25.0" } -mas-context = { path = "./crates/context/", version = "=1.25.0" } -mas-data-model = { path = "./crates/data-model/", version = "=1.25.0" } -mas-email = { path = "./crates/email/", version = "=1.25.0" } -mas-graphql = { path = "./crates/graphql/", version = "=1.25.0" } -mas-handlers = { path = "./crates/handlers/", version = "=1.25.0" } -mas-http = { path = "./crates/http/", version = "=1.25.0" } -mas-i18n = { path = "./crates/i18n/", version = "=1.25.0" } -mas-i18n-scan = { path = "./crates/i18n-scan/", version = "=1.25.0" } -mas-iana = { path = "./crates/iana/", version = "=1.25.0" } -mas-iana-codegen = { path = "./crates/iana-codegen/", version = "=1.25.0" } -mas-jose = { path = "./crates/jose/", version = "=1.25.0" } -mas-keystore = { path = "./crates/keystore/", version = "=1.25.0" } -mas-listener = { path = "./crates/listener/", version = "=1.25.0" } -mas-matrix = { path = "./crates/matrix/", version = "=1.25.0" } -mas-matrix-synapse = { path = "./crates/matrix-synapse/", version = "=1.25.0" } -mas-oidc-client = { path = "./crates/oidc-client/", version = "=1.25.0" } -mas-policy = { path = "./crates/policy/", version = "=1.25.0" } -mas-router = { path = "./crates/router/", version = "=1.25.0" } -mas-spa = { path = "./crates/spa/", version = "=1.25.0" } -mas-storage = { path = "./crates/storage/", version = "=1.25.0" } -mas-storage-pg = { path = "./crates/storage-pg/", version = "=1.25.0" } -mas-tasks = { path = "./crates/tasks/", version = "=1.25.0" } -mas-templates = { path = "./crates/templates/", version = "=1.25.0" } -mas-tower = { path = "./crates/tower/", version = "=1.25.0" } -oauth2-types = { path = "./crates/oauth2-types/", version = "=1.25.0" } -syn2mas = { path = "./crates/syn2mas", version = "=1.25.0" } +mas-axum-utils = { path = "./crates/axum-utils/", version = "=1.25.1" } +mas-cli = { path = "./crates/cli/", version = "=1.25.1" } +mas-config = { path = "./crates/config/", version = "=1.25.1" } +mas-context = { path = "./crates/context/", version = "=1.25.1" } +mas-data-model = { path = "./crates/data-model/", version = "=1.25.1" } +mas-email = { path = "./crates/email/", version = "=1.25.1" } +mas-graphql = { path = "./crates/graphql/", version = "=1.25.1" } +mas-handlers = { path = "./crates/handlers/", version = "=1.25.1" } +mas-http = { path = "./crates/http/", version = "=1.25.1" } +mas-i18n = { path = "./crates/i18n/", version = "=1.25.1" } +mas-i18n-scan = { path = "./crates/i18n-scan/", version = "=1.25.1" } +mas-iana = { path = "./crates/iana/", version = "=1.25.1" } +mas-iana-codegen = { path = "./crates/iana-codegen/", version = "=1.25.1" } +mas-jose = { path = "./crates/jose/", version = "=1.25.1" } +mas-keystore = { path = "./crates/keystore/", version = "=1.25.1" } +mas-listener = { path = "./crates/listener/", version = "=1.25.1" } +mas-matrix = { path = "./crates/matrix/", version = "=1.25.1" } +mas-matrix-synapse = { path = "./crates/matrix-synapse/", version = "=1.25.1" } +mas-oidc-client = { path = "./crates/oidc-client/", version = "=1.25.1" } +mas-policy = { path = "./crates/policy/", version = "=1.25.1" } +mas-router = { path = "./crates/router/", version = "=1.25.1" } +mas-spa = { path = "./crates/spa/", version = "=1.25.1" } +mas-storage = { path = "./crates/storage/", version = "=1.25.1" } +mas-storage-pg = { path = "./crates/storage-pg/", version = "=1.25.1" } +mas-tasks = { path = "./crates/tasks/", version = "=1.25.1" } +mas-templates = { path = "./crates/templates/", version = "=1.25.1" } +mas-tower = { path = "./crates/tower/", version = "=1.25.1" } +oauth2-types = { path = "./crates/oauth2-types/", version = "=1.25.1" } +syn2mas = { path = "./crates/syn2mas", version = "=1.25.1" } # OpenAPI schema generation and validation [workspace.dependencies.aide] From 52f9a11d7819d702696eb0bde998df6a4e1e6c5b Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Tue, 22 Sep 2026 15:36:08 +0200 Subject: [PATCH 41/46] Show the reason on the policy violation page for admin scopes and disallowed clients Violations from the `authorization_grant` policy for `urn:synapse:admin:*` and `urn:mas:admin` now carry the `admin-scope-not-allowed` code, and `client-not-allowed` is reserved for policies which reject the client itself (used by the Pro policies). The policy violation pages render a specific reason for each code under a shared headline, instead of the generic explanation. Fixes #5995 --- crates/policy/src/model.rs | 9 ++++ crates/templates/src/context.rs | 52 ++++++++++++++++++- .../authorization_grant.rego | 13 +++++ .../authorization_grant_test.rego | 27 ++++++++++ .../pages/compat_login_policy_violation.html | 20 ++++--- templates/pages/policy_violation.html | 27 ++++++---- translations/en.json | 44 ++++++++++++---- 7 files changed, 163 insertions(+), 29 deletions(-) diff --git a/crates/policy/src/model.rs b/crates/policy/src/model.rs index 83ceb08b9..e408c63c3 100644 --- a/crates/policy/src/model.rs +++ b/crates/policy/src/model.rs @@ -1,3 +1,4 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // @@ -51,6 +52,12 @@ pub enum ViolationVariant { /// The email address is banned. EmailBanned, + /// An admin scope was requested but is not allowed. + AdminScopeNotAllowed, + + /// The client is not allowed by the policy. + ClientNotAllowed, + /// The user has reached their session limit. TooManySessions { /// How many devices need to be removed to make room for the new session @@ -73,6 +80,8 @@ impl ViolationVariant { Self::EmailDomainBanned => "email-domain-banned", Self::EmailNotAllowed => "email-not-allowed", Self::EmailBanned => "email-banned", + Self::AdminScopeNotAllowed => "admin-scope-not-allowed", + Self::ClientNotAllowed => "client-not-allowed", Self::TooManySessions { .. } => "too-many-sessions", } } diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index 6148cc165..937a2b66e 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -842,7 +842,7 @@ impl TemplateContext for PolicyViolationContext { let authorization_grant_invalid_scope = PolicyViolationContext::for_authorization_grant( - grant, + grant.clone(), client.clone(), vec![Violation { msg: "scope 'foo' not allowed".to_owned(), @@ -851,6 +851,30 @@ impl TemplateContext for PolicyViolationContext { variant: None, }], ); + let authorization_grant_admin_scope = + PolicyViolationContext::for_authorization_grant( + grant.clone(), + client.clone(), + vec![Violation { + msg: "scope 'urn:mas:admin' requires admin privileges".to_owned(), + redirect_uri: None, + field: None, + variant: Some(ViolationVariant::AdminScopeNotAllowed), + }], + ); + + let authorization_grant_client_not_allowed = + PolicyViolationContext::for_authorization_grant( + grant, + client.clone(), + vec![Violation { + msg: "This client is not allowed to login".to_owned(), + redirect_uri: None, + field: None, + variant: Some(ViolationVariant::ClientNotAllowed), + }], + ); + let device_code_grant = PolicyViolationContext::for_device_code_grant( DeviceCodeGrant { id: Ulid::from_datetime_with_rng(now, rng), @@ -898,6 +922,8 @@ impl TemplateContext for PolicyViolationContext { [ authorization_grant, authorization_grant_invalid_scope, + authorization_grant_admin_scope, + authorization_grant_client_not_allowed, device_code_grant, device_code_grant_invalid_scope, ] @@ -976,6 +1002,30 @@ impl TemplateContext for CompatLoginPolicyViolationContext { variant: Some(ViolationVariant::TooManySessions { need_to_remove: 1 }), }], }, + CompatLoginPolicyViolationContext { + violations: vec![Violation { + msg: "This client is not allowed to login".to_owned(), + redirect_uri: None, + field: None, + variant: Some(ViolationVariant::ClientNotAllowed), + }], + }, + CompatLoginPolicyViolationContext { + violations: vec![ + Violation { + msg: "scope 'foo' not allowed".to_owned(), + redirect_uri: None, + field: None, + variant: None, + }, + Violation { + msg: "user has too many active sessions".to_owned(), + redirect_uri: None, + field: None, + variant: Some(ViolationVariant::TooManySessions { need_to_remove: 1 }), + }, + ], + }, ]) } } diff --git a/policies/authorization_grant/authorization_grant.rego b/policies/authorization_grant/authorization_grant.rego index f02fdaac4..a4d1e0708 100644 --- a/policies/authorization_grant/authorization_grant.rego +++ b/policies/authorization_grant/authorization_grant.rego @@ -1,3 +1,4 @@ +# Copyright 2025, 2026 Element Creations Ltd. # Copyright 2025 New Vector Ltd. # # SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial @@ -88,6 +89,10 @@ allowed_scope("urn:matrix:org.matrix.msc2967.client:api:*") if { interactive_grant_type(input.grant_type) } +admin_scope("urn:synapse:admin:*") := true + +admin_scope("urn:mas:admin") := true + uses_unstable_scopes if { scope_list := split(input.scope, " ") count({scope | some scope in scope_list; startswith(scope, "urn:matrix:org.matrix.msc2967.client:")}) > 0 @@ -122,10 +127,18 @@ has_cs_api_scope if { # entrypoint: true violation contains {"msg": msg} if { some scope in split(input.scope, " ") + not admin_scope(scope) not allowed_scope(scope) msg := sprintf("scope '%s' not allowed", [scope]) } +violation contains {"code": "admin-scope-not-allowed", "msg": msg} if { + some scope in split(input.scope, " ") + admin_scope(scope) + not allowed_scope(scope) + msg := sprintf("scope '%s' requires admin privileges", [scope]) +} + violation contains {"msg": "only one device scope is allowed at a time"} if { scope_list := split(input.scope, " ") count({scope | some scope in scope_list; startswith(scope, "urn:matrix:org.matrix.msc2967.client:device:")}) > 1 diff --git a/policies/authorization_grant/authorization_grant_test.rego b/policies/authorization_grant/authorization_grant_test.rego index 5186e6c3e..093cc9510 100644 --- a/policies/authorization_grant/authorization_grant_test.rego +++ b/policies/authorization_grant/authorization_grant_test.rego @@ -1,3 +1,4 @@ +# Copyright 2025, 2026 Element Creations Ltd. # Copyright 2025 New Vector Ltd. # # SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial @@ -223,6 +224,32 @@ test_mas_scopes if { with input.scope as "urn:mas:admin" } +test_synapse_admin_scope_violation if { + violations := authorization_grant.violation with input.user as user + with input.client as client + with data.admin_users as [] + with input.grant_type as "authorization_code" + with input.scope as "urn:synapse:admin:*" + + violations == {{ + "code": "admin-scope-not-allowed", + "msg": "scope 'urn:synapse:admin:*' requires admin privileges", + }} +} + +test_mas_admin_scope_violation if { + violations := authorization_grant.violation with input.user as user + with input.client as client + with data.admin_users as [] + with input.grant_type as "authorization_code" + with input.scope as "urn:mas:admin" + + violations == {{ + "code": "admin-scope-not-allowed", + "msg": "scope 'urn:mas:admin' requires admin privileges", + }} +} + # Helper utility to extract the number of sessions that they `need_to_remove`, returns 0 # if the `too-many-sessions` violation is not found need_to_remove_sessions(violations) := need if { diff --git a/templates/pages/compat_login_policy_violation.html b/templates/pages/compat_login_policy_violation.html index d6b7372dc..2796475f5 100644 --- a/templates/pages/compat_login_policy_violation.html +++ b/templates/pages/compat_login_policy_violation.html @@ -1,4 +1,5 @@ {# +Copyright 2025, 2026 Element Creations Ltd. Copyright 2025 New Vector Ltd. SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial @@ -7,8 +8,8 @@ Please see LICENSE files in the repository root for full details. {% extends "base.html" %} -{% set is_too_many_sessions = violations | length == 1 and violations[0].code | default("") == "too-many-sessions" %} -{% set num_sessions_need_to_remove = violations[0].need_to_remove if is_too_many_sessions else none %} +{% set violation = violations | selectattr("code", "defined") | first | default({}) %} +{% set code = violation.code | default("") %} {% block content %}
@@ -16,15 +17,20 @@ Please see LICENSE files in the repository root for full details. {{ icon.error_solid() }}
- {% if is_too_many_sessions %} + {% if code == "too-many-sessions" %}

{{ _("mas.policy_violation.too_many_sessions.heading") }}

-

{{ _("mas.policy_violation.too_many_sessions.description", num_devices_to_remove=num_sessions_need_to_remove) }}

+

{{ _("mas.policy_violation.too_many_sessions.description", num_devices_to_remove=violation.need_to_remove) }}

{% else %}
-

{{ _("mas.policy_violation.heading") }}

-

{{ _("mas.policy_violation.description") }}

+ {% if code == "client-not-allowed" %} +

{{ _("mas.policy_violation.client_not_allowed.heading") }}

+

{{ _("mas.policy_violation.client_not_allowed.description") }}

+ {% else %} +

{{ _("mas.policy_violation.heading") }}

+

{{ _("mas.policy_violation.description") }}

+ {% endif %}
{% endif %}
@@ -38,7 +44,7 @@ Please see LICENSE files in the repository root for full details. {{ logout.button(text=_("action.sign_out"), csrf_token=csrf_token, post_logout_action=action, as_link=True) }}
- {% if is_too_many_sessions %} + {% if code == "too-many-sessions" %} {{ button.link(text=_("mas.policy_violation.too_many_sessions.manage_devices"), href="/account/sessions") }} {% endif %} diff --git a/templates/pages/policy_violation.html b/templates/pages/policy_violation.html index d44593c85..b2eff9fd6 100644 --- a/templates/pages/policy_violation.html +++ b/templates/pages/policy_violation.html @@ -1,4 +1,5 @@ {# +Copyright 2025, 2026 Element Creations Ltd. Copyright 2024, 2025 New Vector Ltd. Copyright 2022-2024 The Matrix.org Foundation C.I.C. @@ -8,8 +9,8 @@ Please see LICENSE files in the repository root for full details. {% extends "base.html" %} -{% set is_too_many_sessions = violations | length == 1 and violations[0].code | default("") == "too-many-sessions" %} -{% set num_sessions_need_to_remove = violations[0].need_to_remove if is_too_many_sessions else none %} +{% set violation = violations | selectattr("code", "defined") | first | default({}) %} +{% set code = violation.code | default("") %} {% block content %} {% set client_display_name = client.client_name or client.client_id %} @@ -19,17 +20,21 @@ Please see LICENSE files in the repository root for full details. {{ icon.error_solid() }}
- {% if is_too_many_sessions %} -
+
+ {% if code == "too-many-sessions" %}

{{ _("mas.policy_violation.too_many_sessions.heading") }}

-

{{ _("mas.policy_violation.too_many_sessions.description", num_devices_to_remove=num_sessions_need_to_remove) }}

-
- {% else %} -
+

{{ _("mas.policy_violation.too_many_sessions.description", num_devices_to_remove=violation.need_to_remove) }}

+ {% elif code == "admin-scope-not-allowed" %} +

{{ _("mas.policy_violation.admin_scope_not_allowed.heading") }}

+

{{ _("mas.policy_violation.admin_scope_not_allowed.description") }}

+ {% elif code == "client-not-allowed" %} +

{{ _("mas.policy_violation.client_not_allowed.heading") }}

+

{{ _("mas.policy_violation.client_not_allowed.description") }}

+ {% else %}

{{ _("mas.policy_violation.heading") }}

{{ _("mas.policy_violation.description") }}

-
- {% endif %} + {% endif %} +
@@ -60,7 +65,7 @@ Please see LICENSE files in the repository root for full details.
- {% if is_too_many_sessions %} + {% if code == "too-many-sessions" %} {{ button.link(text=_("mas.policy_violation.too_many_sessions.manage_devices"), href="/account/sessions") }} {% endif %} diff --git a/translations/en.json b/translations/en.json index b68e4e84d..4edbea8b0 100644 --- a/translations/en.json +++ b/translations/en.json @@ -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:57: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:57:33-51, pages/policy_violation.html:75:15-33, pages/reauth.html:37:13-31" }, "continue": "Continue", "@continue": { @@ -22,7 +22,7 @@ }, "sign_out": "Sign out", "@sign_out": { - "context": "pages/account/logged_out.html:23:28-48, pages/compat_login_policy_violation.html:38:28-48, pages/index.html:28:28-48, pages/policy_violation.html:59:28-48, pages/upstream_oauth2/link_mismatch.html:24:24-44, pages/upstream_oauth2/suggest_link.html:32:26-46" + "context": "pages/account/logged_out.html:23:28-48, pages/compat_login_policy_violation.html:44:28-48, pages/index.html:28:28-48, pages/policy_violation.html:64:28-48, pages/upstream_oauth2/link_mismatch.html:24:24-44, pages/upstream_oauth2/suggest_link.html:32:26-46" }, "skip": "Skip", "@skip": { @@ -531,32 +531,56 @@ "description": "Separator between the login methods" }, "policy_violation": { - "description": "This might be because of the client which authored the request, the currently logged in user, or the request itself.", + "admin_scope_not_allowed": { + "description": "This application requires administrator privileges. Sign in with an administrator account to continue.", + "@description": { + "context": "pages/policy_violation.html:29:27-88", + "description": "Displayed when the client requested an admin scope and the user is not an administrator" + }, + "heading": "Administrator access required", + "@heading": { + "context": "pages/policy_violation.html:28:29-86", + "description": "Displayed when the client requested an admin scope and the user is not an administrator" + } + }, + "client_not_allowed": { + "description": "This application is not allowed to sign in to this service.", + "@description": { + "context": "pages/compat_login_policy_violation.html:29:29-85, pages/policy_violation.html:32:27-83", + "description": "Displayed when the policy does not allow the client" + }, + "heading": "Application not allowed", + "@heading": { + "context": "pages/compat_login_policy_violation.html:28:31-83, pages/policy_violation.html:31:29-81", + "description": "Displayed when the policy does not allow the client" + } + }, + "description": "Your request has been denied by the policy of this service. This might be because of the application, your account, or the request itself.", "@description": { - "context": "pages/compat_login_policy_violation.html:27:27-64, pages/policy_violation.html:30:27-64", + "context": "pages/compat_login_policy_violation.html:32:29-66, pages/policy_violation.html:35:27-64", "description": "Displayed when an authorization request is denied by the policy" }, - "heading": "The authorization request was denied by the policy enforced by this service", + "heading": "Request denied", "@heading": { - "context": "pages/compat_login_policy_violation.html:26:29-62, pages/policy_violation.html:29:29-62", + "context": "pages/compat_login_policy_violation.html:31:31-64, pages/policy_violation.html:34:29-62", "description": "Displayed when an authorization request is denied by the policy" }, "logged_as": "Logged as %(username)s", "@logged_as": { - "context": "pages/compat_login_policy_violation.html:35:11-86, pages/policy_violation.html:56:11-86" + "context": "pages/compat_login_policy_violation.html:41:11-86, pages/policy_violation.html:61:11-86" }, "too_many_sessions": { "description": "Your account is already signed in on the maximum number of devices. To continue, remove %(num_devices_to_remove)s of your existing devices and sign in again.", "@description": { - "context": "pages/compat_login_policy_violation.html:22:27-133, pages/policy_violation.html:25:27-133" + "context": "pages/compat_login_policy_violation.html:23:27-130, pages/policy_violation.html:26:27-130" }, "heading": "Device limit reached", "@heading": { - "context": "pages/compat_login_policy_violation.html:21:29-80, pages/policy_violation.html:24:29-80" + "context": "pages/compat_login_policy_violation.html:22:29-80, pages/policy_violation.html:25:29-80" }, "manage_devices": "Manage devices", "@manage_devices": { - "context": "pages/compat_login_policy_violation.html:42:26-84, pages/policy_violation.html:64:28-86" + "context": "pages/compat_login_policy_violation.html:48:26-84, pages/policy_violation.html:69:28-86" } } }, From b67a480af87579971977b44af0212e59984d24fc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:00:00 +0000 Subject: [PATCH 42/46] Translations updates --- frontend/.storybook/locales.ts | 82 +++++++++++++++++----------------- frontend/locales/fi.json | 6 +-- translations/fi.json | 8 ++-- translations/pl.json | 1 + translations/uk.json | 1 + 5 files changed, 50 insertions(+), 48 deletions(-) diff --git a/frontend/.storybook/locales.ts b/frontend/.storybook/locales.ts index 2c5182dec..4b0ee9189 100644 --- a/frontend/.storybook/locales.ts +++ b/frontend/.storybook/locales.ts @@ -27,7 +27,7 @@ export type LocalazyMetadata = { }; const localazyMetadata: LocalazyMetadata = { - projectUrl: "https://localazy.com/p/matrix-authentication-service!v1.25", + projectUrl: "https://localazy.com/p/matrix-authentication-service", baseLocale: "en", languages: [ { @@ -217,26 +217,26 @@ const localazyMetadata: LocalazyMetadata = { file: "frontend.json", path: "", cdnFiles: { - "cs": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/cs/frontend.json", - "da": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/da/frontend.json", - "de": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/de/frontend.json", - "en": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/en/frontend.json", - "et": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/et/frontend.json", - "fi": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/fi/frontend.json", - "fr": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/fr/frontend.json", - "hu": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/hu/frontend.json", - "nb_NO": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/nb-NO/frontend.json", - "nl": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/nl/frontend.json", - "pl": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pl/frontend.json", - "pt": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pt/frontend.json", - "pt_BR": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pt-BR/frontend.json", - "ru": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/ru/frontend.json", - "sk": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/sk/frontend.json", - "sv": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/sv/frontend.json", - "tr": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/tr/frontend.json", - "uk": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/uk/frontend.json", - "uz": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/uz/frontend.json", - "zh#Hans": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/zh-Hans/frontend.json" + "cs": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/cs/frontend.json", + "da": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/da/frontend.json", + "de": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/de/frontend.json", + "en": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/en/frontend.json", + "et": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/et/frontend.json", + "fi": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/fi/frontend.json", + "fr": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/fr/frontend.json", + "hu": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/hu/frontend.json", + "nb_NO": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/nb-NO/frontend.json", + "nl": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/nl/frontend.json", + "pl": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pl/frontend.json", + "pt": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pt/frontend.json", + "pt_BR": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pt-BR/frontend.json", + "ru": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/ru/frontend.json", + "sk": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/sk/frontend.json", + "sv": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/sv/frontend.json", + "tr": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/tr/frontend.json", + "uk": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/uk/frontend.json", + "uz": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/uz/frontend.json", + "zh#Hans": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/zh-Hans/frontend.json" } }, { @@ -244,26 +244,26 @@ const localazyMetadata: LocalazyMetadata = { file: "file.json", path: "", cdnFiles: { - "cs": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/cs/file.json", - "da": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/da/file.json", - "de": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/de/file.json", - "en": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/en/file.json", - "et": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/et/file.json", - "fi": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/fi/file.json", - "fr": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/fr/file.json", - "hu": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/hu/file.json", - "nb_NO": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/nb-NO/file.json", - "nl": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/nl/file.json", - "pl": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pl/file.json", - "pt": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pt/file.json", - "pt_BR": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pt-BR/file.json", - "ru": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/ru/file.json", - "sk": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/sk/file.json", - "sv": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/sv/file.json", - "tr": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/tr/file.json", - "uk": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/uk/file.json", - "uz": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/uz/file.json", - "zh#Hans": "https://delivery.localazy.com/_a6390520325885548536f083501d/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/zh-Hans/file.json" + "cs": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/cs/file.json", + "da": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/da/file.json", + "de": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/de/file.json", + "en": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/en/file.json", + "et": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/et/file.json", + "fi": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/fi/file.json", + "fr": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/fr/file.json", + "hu": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/hu/file.json", + "nb_NO": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/nb-NO/file.json", + "nl": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/nl/file.json", + "pl": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pl/file.json", + "pt": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pt/file.json", + "pt_BR": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pt-BR/file.json", + "ru": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/ru/file.json", + "sk": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/sk/file.json", + "sv": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/sv/file.json", + "tr": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/tr/file.json", + "uk": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/uk/file.json", + "uz": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/uz/file.json", + "zh#Hans": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/zh-Hans/file.json" } } ] diff --git a/frontend/locales/fi.json b/frontend/locales/fi.json index 1e94f155f..9b38c3666 100644 --- a/frontend/locales/fi.json +++ b/frontend/locales/fi.json @@ -145,14 +145,14 @@ "current_password_label": "Nykyinen salasana", "failure": { "description": { - "account_locked": "Tilisi on lukittu eikä sitä voi palauttaa tällä hetkellä. Jos et odottanut tätä, ota yhteyttä palvelimen ylläpitäjään.", + "account_locked": "Tilisi on lukittu eikä sitä voi palauttaa tällä hetkellä. Jos et odottanut tätä, ota yhteyttä palveluntarjoajaasi.", "expired_recovery_ticket": "Palautuslinkki on vanhentunut. Aloita tilin palautusprosessi uudelleen alusta.", "invalid_new_password": "Valitsemasi uusi salasana on virheellinen; se ei ehkä vastaa määritettyä suojauskäytäntöä.", "no_current_password": "Sinulla ei ole nykyistä salasanaa.", "no_such_recovery_ticket": "Palautuslinkki on virheellinen. Jos kopioit linkin palautussähköpostista, tarkista, että kopioit koko linkin.", "password_changes_disabled": "Salasanan vaihtaminen on poistettu käytöstä.", "recovery_ticket_already_used": "Palautuslinkki on jo käytetty. Sitä ei voi käyttää uudelleen.", - "unspecified": "Tämä saattaa olla väliaikainen ongelma, joten yritä myöhemmin uudelleen. Jos ongelma jatkuu, ota yhteyttä palvelimen ylläpitäjään.", + "unspecified": "Tämä saattaa olla väliaikainen ongelma, joten yritä myöhemmin uudelleen. Jos ongelma jatkuu, ota yhteyttä palveluntarjoajaasi.", "wrong_password": "Nykyiseksi salasanaksi antamasi salasana on virheellinen. Yritä uudelleen." }, "title": "Salasanan vaihtaminen epäonnistui" @@ -239,7 +239,7 @@ "positive_1": "Tilitietosi, yhteystiedot, asetukset ja keskustelulista säilytetään" }, "failure": { - "description": "Tämä saattaa olla väliaikainen ongelma, joten yritä myöhemmin uudelleen. Jos ongelma jatkuu, ota yhteyttä palvelimen ylläpitäjään.", + "description": "Tämä saattaa olla väliaikainen ongelma, joten yritä myöhemmin uudelleen. Jos ongelma jatkuu, ota yhteyttä palveluntarjoajaasi.", "heading": "Digitaalisen identiteetin nollauksen salliminen epäonnistui" }, "finish_reset": "Viimeistele nollaus", diff --git a/translations/fi.json b/translations/fi.json index 4120e8a4e..491279bda 100644 --- a/translations/fi.json +++ b/translations/fi.json @@ -39,11 +39,11 @@ "mas": { "account": { "deactivated": { - "description": "Tämä tili (%(mxid)s) on poistettu. Jos tämä on virhe, ota yhteyttä palvelimen ylläpitäjään.", + "description": "Tämä tili (%(mxid)s) on poistettu. Jos et odottanut tätä, ota yhteyttä palveluntarjoajaasi.", "heading": "Tili poistettu" }, "locked": { - "description": "Tämä tili (%(mxid)s) on lukittu. Jos tämä on virhe, ota yhteyttä palvelimen ylläpitäjään.", + "description": "Tämä tili (%(mxid)s) on lukittu. Jos et odottanut tätä, ota yhteyttä palveluntarjoajaasi.", "heading": "Tili lukittu" }, "logged_out": { @@ -113,7 +113,7 @@ "emails": { "greeting": "Hei %(username)s,", "recovery": { - "click_button": "Luo uusi salasana napsauttamalla alla olevaa painiketta:", + "click_button": "Luo uusi salasana valitsemalla alla oleva painike:", "copy_link": "Luo uusi salasana kopioimalla seuraava linkki ja liittämällä se selaimeen:", "create_new_password": "Luo uusi salasana", "fallback": "Eikö painike toimi sinulle?", @@ -226,7 +226,7 @@ "terms_of_service": "Hyväksyn Käyttöehdot" }, "registration_token": { - "description": "Anna kotipalvelimen ylläpitäjän antama rekisteröitymispoletti.", + "description": "Anna palveluntarjoajasi antama rekisteröitymispoletti.", "field": "Rekisteröitymispoletti", "headline": "Rekisteröitymispoletti" }, diff --git a/translations/pl.json b/translations/pl.json index f46a12dc7..11d1c3522 100644 --- a/translations/pl.json +++ b/translations/pl.json @@ -149,6 +149,7 @@ "legacy_consent": { "this_will_setup": "Spowoduje to skonfigurowanie %(client_name)s z kontem %(server_name)s." }, + "loading_failed": "Coś poszło nie tak podczas ładowania strony. Spróbuj ją odświeżyć.", "login": { "call_to_register": "Nie masz jeszcze konta?", "continue_with_provider": "Kontynuuj z %(provider)s", diff --git a/translations/uk.json b/translations/uk.json index aba940519..665a77728 100644 --- a/translations/uk.json +++ b/translations/uk.json @@ -149,6 +149,7 @@ "legacy_consent": { "this_will_setup": "Це налаштує %(client_name)s з вашим обліковим записом %(server_name)s." }, + "loading_failed": "Під час завантаження сторінки сталася помилка. Спробуйте перезавантажити її.", "login": { "call_to_register": "У вас ще немає облікового запису?", "continue_with_provider": "Продовжити з %(provider)s", From 263a24addd6290d181a3952f75f1e7b76f041312 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:33:54 +0000 Subject: [PATCH 43/46] 1.26.0-rc.0 --- Cargo.lock | 56 +++++++++++++++++++++++++------------------------- Cargo.toml | 60 +++++++++++++++++++++++++++--------------------------- 2 files changed, 58 insertions(+), 58 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 87f08a296..3a7cb9463 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3199,7 +3199,7 @@ dependencies = [ [[package]] name = "mas-axum-utils" -version = "1.25.1" +version = "1.26.0-rc.0" dependencies = [ "anyhow", "axum", @@ -3234,7 +3234,7 @@ dependencies = [ [[package]] name = "mas-cli" -version = "1.25.1" +version = "1.26.0-rc.0" dependencies = [ "anyhow", "axum", @@ -3310,7 +3310,7 @@ dependencies = [ [[package]] name = "mas-config" -version = "1.25.1" +version = "1.26.0-rc.0" dependencies = [ "anyhow", "camino", @@ -3341,7 +3341,7 @@ dependencies = [ [[package]] name = "mas-context" -version = "1.25.1" +version = "1.26.0-rc.0" dependencies = [ "console", "opentelemetry", @@ -3358,7 +3358,7 @@ dependencies = [ [[package]] name = "mas-data-model" -version = "1.25.1" +version = "1.26.0-rc.0" dependencies = [ "base64ct", "chrono", @@ -3380,7 +3380,7 @@ dependencies = [ [[package]] name = "mas-email" -version = "1.25.1" +version = "1.26.0-rc.0" dependencies = [ "async-trait", "lettre", @@ -3391,7 +3391,7 @@ dependencies = [ [[package]] name = "mas-handlers" -version = "1.25.1" +version = "1.26.0-rc.0" dependencies = [ "aide", "anyhow", @@ -3474,7 +3474,7 @@ dependencies = [ [[package]] name = "mas-http" -version = "1.25.1" +version = "1.26.0-rc.0" dependencies = [ "futures-util", "headers", @@ -3494,7 +3494,7 @@ dependencies = [ [[package]] name = "mas-i18n" -version = "1.25.1" +version = "1.26.0-rc.0" dependencies = [ "camino", "icu_datetime", @@ -3514,7 +3514,7 @@ dependencies = [ [[package]] name = "mas-i18n-scan" -version = "1.25.1" +version = "1.26.0-rc.0" dependencies = [ "camino", "clap", @@ -3528,7 +3528,7 @@ dependencies = [ [[package]] name = "mas-iana" -version = "1.25.1" +version = "1.26.0-rc.0" dependencies = [ "schemars 1.2.1", "serde", @@ -3536,7 +3536,7 @@ dependencies = [ [[package]] name = "mas-iana-codegen" -version = "1.25.1" +version = "1.26.0-rc.0" dependencies = [ "anyhow", "async-trait", @@ -3553,7 +3553,7 @@ dependencies = [ [[package]] name = "mas-jose" -version = "1.25.1" +version = "1.26.0-rc.0" dependencies = [ "base64ct", "chrono", @@ -3583,7 +3583,7 @@ dependencies = [ [[package]] name = "mas-keystore" -version = "1.25.1" +version = "1.26.0-rc.0" dependencies = [ "aead", "base64ct", @@ -3611,7 +3611,7 @@ dependencies = [ [[package]] name = "mas-listener" -version = "1.25.1" +version = "1.26.0-rc.0" dependencies = [ "anyhow", "bytes", @@ -3635,7 +3635,7 @@ dependencies = [ [[package]] name = "mas-matrix" -version = "1.25.1" +version = "1.26.0-rc.0" dependencies = [ "anyhow", "async-trait", @@ -3645,7 +3645,7 @@ dependencies = [ [[package]] name = "mas-matrix-synapse" -version = "1.25.1" +version = "1.26.0-rc.0" dependencies = [ "anyhow", "async-trait", @@ -3662,7 +3662,7 @@ dependencies = [ [[package]] name = "mas-oidc-client" -version = "1.25.1" +version = "1.26.0-rc.0" dependencies = [ "assert_matches", "async-trait", @@ -3699,7 +3699,7 @@ dependencies = [ [[package]] name = "mas-policy" -version = "1.25.1" +version = "1.26.0-rc.0" dependencies = [ "anyhow", "arc-swap", @@ -3716,7 +3716,7 @@ dependencies = [ [[package]] name = "mas-router" -version = "1.25.1" +version = "1.26.0-rc.0" dependencies = [ "axum", "serde", @@ -3727,7 +3727,7 @@ dependencies = [ [[package]] name = "mas-spa" -version = "1.25.1" +version = "1.26.0-rc.0" dependencies = [ "camino", "serde", @@ -3736,7 +3736,7 @@ dependencies = [ [[package]] name = "mas-storage" -version = "1.25.1" +version = "1.26.0-rc.0" dependencies = [ "async-trait", "chrono", @@ -3758,7 +3758,7 @@ dependencies = [ [[package]] name = "mas-storage-pg" -version = "1.25.1" +version = "1.26.0-rc.0" dependencies = [ "async-trait", "chrono", @@ -3790,7 +3790,7 @@ dependencies = [ [[package]] name = "mas-tasks" -version = "1.25.1" +version = "1.26.0-rc.0" dependencies = [ "anyhow", "async-trait", @@ -3822,7 +3822,7 @@ dependencies = [ [[package]] name = "mas-templates" -version = "1.25.1" +version = "1.26.0-rc.0" dependencies = [ "anyhow", "arc-swap", @@ -3854,7 +3854,7 @@ dependencies = [ [[package]] name = "mas-tower" -version = "1.25.1" +version = "1.26.0-rc.0" dependencies = [ "http", "opentelemetry", @@ -4106,7 +4106,7 @@ dependencies = [ [[package]] name = "oauth2-types" -version = "1.25.1" +version = "1.26.0-rc.0" dependencies = [ "assert_matches", "base64ct", @@ -6282,7 +6282,7 @@ dependencies = [ [[package]] name = "syn2mas" -version = "1.25.1" +version = "1.26.0-rc.0" dependencies = [ "anyhow", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index d83162ca4..47020c0a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ members = ["crates/*"] resolver = "2" # Updated in the CI with a `sed` command -package.version = "1.25.1" +package.version = "1.26.0-rc.0" package.license = "AGPL-3.0-only OR LicenseRef-Element-Commercial" package.authors = ["Element Backend Team"] package.edition = "2024" @@ -42,35 +42,35 @@ broken_intra_doc_links = "deny" [workspace.dependencies] # Workspace crates -mas-axum-utils = { path = "./crates/axum-utils/", version = "=1.25.1" } -mas-cli = { path = "./crates/cli/", version = "=1.25.1" } -mas-config = { path = "./crates/config/", version = "=1.25.1" } -mas-context = { path = "./crates/context/", version = "=1.25.1" } -mas-data-model = { path = "./crates/data-model/", version = "=1.25.1" } -mas-email = { path = "./crates/email/", version = "=1.25.1" } -mas-graphql = { path = "./crates/graphql/", version = "=1.25.1" } -mas-handlers = { path = "./crates/handlers/", version = "=1.25.1" } -mas-http = { path = "./crates/http/", version = "=1.25.1" } -mas-i18n = { path = "./crates/i18n/", version = "=1.25.1" } -mas-i18n-scan = { path = "./crates/i18n-scan/", version = "=1.25.1" } -mas-iana = { path = "./crates/iana/", version = "=1.25.1" } -mas-iana-codegen = { path = "./crates/iana-codegen/", version = "=1.25.1" } -mas-jose = { path = "./crates/jose/", version = "=1.25.1" } -mas-keystore = { path = "./crates/keystore/", version = "=1.25.1" } -mas-listener = { path = "./crates/listener/", version = "=1.25.1" } -mas-matrix = { path = "./crates/matrix/", version = "=1.25.1" } -mas-matrix-synapse = { path = "./crates/matrix-synapse/", version = "=1.25.1" } -mas-oidc-client = { path = "./crates/oidc-client/", version = "=1.25.1" } -mas-policy = { path = "./crates/policy/", version = "=1.25.1" } -mas-router = { path = "./crates/router/", version = "=1.25.1" } -mas-spa = { path = "./crates/spa/", version = "=1.25.1" } -mas-storage = { path = "./crates/storage/", version = "=1.25.1" } -mas-storage-pg = { path = "./crates/storage-pg/", version = "=1.25.1" } -mas-tasks = { path = "./crates/tasks/", version = "=1.25.1" } -mas-templates = { path = "./crates/templates/", version = "=1.25.1" } -mas-tower = { path = "./crates/tower/", version = "=1.25.1" } -oauth2-types = { path = "./crates/oauth2-types/", version = "=1.25.1" } -syn2mas = { path = "./crates/syn2mas", version = "=1.25.1" } +mas-axum-utils = { path = "./crates/axum-utils/", version = "=1.26.0-rc.0" } +mas-cli = { path = "./crates/cli/", version = "=1.26.0-rc.0" } +mas-config = { path = "./crates/config/", version = "=1.26.0-rc.0" } +mas-context = { path = "./crates/context/", version = "=1.26.0-rc.0" } +mas-data-model = { path = "./crates/data-model/", version = "=1.26.0-rc.0" } +mas-email = { path = "./crates/email/", version = "=1.26.0-rc.0" } +mas-graphql = { path = "./crates/graphql/", version = "=1.26.0-rc.0" } +mas-handlers = { path = "./crates/handlers/", version = "=1.26.0-rc.0" } +mas-http = { path = "./crates/http/", version = "=1.26.0-rc.0" } +mas-i18n = { path = "./crates/i18n/", version = "=1.26.0-rc.0" } +mas-i18n-scan = { path = "./crates/i18n-scan/", version = "=1.26.0-rc.0" } +mas-iana = { path = "./crates/iana/", version = "=1.26.0-rc.0" } +mas-iana-codegen = { path = "./crates/iana-codegen/", version = "=1.26.0-rc.0" } +mas-jose = { path = "./crates/jose/", version = "=1.26.0-rc.0" } +mas-keystore = { path = "./crates/keystore/", version = "=1.26.0-rc.0" } +mas-listener = { path = "./crates/listener/", version = "=1.26.0-rc.0" } +mas-matrix = { path = "./crates/matrix/", version = "=1.26.0-rc.0" } +mas-matrix-synapse = { path = "./crates/matrix-synapse/", version = "=1.26.0-rc.0" } +mas-oidc-client = { path = "./crates/oidc-client/", version = "=1.26.0-rc.0" } +mas-policy = { path = "./crates/policy/", version = "=1.26.0-rc.0" } +mas-router = { path = "./crates/router/", version = "=1.26.0-rc.0" } +mas-spa = { path = "./crates/spa/", version = "=1.26.0-rc.0" } +mas-storage = { path = "./crates/storage/", version = "=1.26.0-rc.0" } +mas-storage-pg = { path = "./crates/storage-pg/", version = "=1.26.0-rc.0" } +mas-tasks = { path = "./crates/tasks/", version = "=1.26.0-rc.0" } +mas-templates = { path = "./crates/templates/", version = "=1.26.0-rc.0" } +mas-tower = { path = "./crates/tower/", version = "=1.26.0-rc.0" } +oauth2-types = { path = "./crates/oauth2-types/", version = "=1.26.0-rc.0" } +syn2mas = { path = "./crates/syn2mas", version = "=1.26.0-rc.0" } # OpenAPI schema generation and validation [workspace.dependencies.aide] From 5dd989212a6e4f2a10322de94b783daae6700d7b Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Fri, 25 Sep 2026 17:16:07 +0200 Subject: [PATCH 44/46] Tighten the comments on carrying the username through the upstream flow --- crates/handlers/src/upstream_oauth2/link.rs | 4 +--- crates/handlers/src/views/register/mod.rs | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/crates/handlers/src/upstream_oauth2/link.rs b/crates/handlers/src/upstream_oauth2/link.rs index f2486c5a9..f5128f37c 100644 --- a/crates/handlers/src/upstream_oauth2/link.rs +++ b/crates/handlers/src/upstream_oauth2/link.rs @@ -460,10 +460,8 @@ pub(crate) async fn get( let forced_or_required = provider.claims_imports.localpart.is_forced_or_required(); - // What the user typed on the registration page beats the claim rendered - // from the upstream response, even when that claim is ignored. A provider - // which forces or requires the localpart never lets the user pick one. let carried_username = if forced_or_required { + // The provider picks the localpart, not the user None } else { carried_username diff --git a/crates/handlers/src/views/register/mod.rs b/crates/handlers/src/views/register/mod.rs index 54dd69298..5c025cd0e 100644 --- a/crates/handlers/src/views/register/mod.rs +++ b/crates/handlers/src/views/register/mod.rs @@ -161,13 +161,13 @@ pub(crate) async fn post( let post_auth_action = query.post_auth_action; let Ok(form) = cookie_jar.verify_form(&clock, form) else { - // The CSRF token expires after an hour, which a registration page left - // open in a tab will outlive. + // An invalid token most likely comes from a page left open past the CSRF token lifetime tracing::debug!("Invalid CSRF token on the registration form, redirecting to a fresh one"); let destination = Register::from(post_auth_action); return Ok((cookie_jar, url_builder.redirect(&destination)).into_response()); }; + // The username is carried in a cookie, so its size has to be bounded let username = form.username.trim(); let username = if username.len() <= MAX_USERNAME_LENGTH { username From a3ea612459873f8f0bbac96b5b516863a0134114 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:11:53 +0000 Subject: [PATCH 45/46] Translations updates --- frontend/.storybook/locales.ts | 82 +++++++++++++++++----------------- translations/uk.json | 12 ++++- 2 files changed, 51 insertions(+), 43 deletions(-) diff --git a/frontend/.storybook/locales.ts b/frontend/.storybook/locales.ts index 4b0ee9189..ac0c50506 100644 --- a/frontend/.storybook/locales.ts +++ b/frontend/.storybook/locales.ts @@ -27,7 +27,7 @@ export type LocalazyMetadata = { }; const localazyMetadata: LocalazyMetadata = { - projectUrl: "https://localazy.com/p/matrix-authentication-service", + projectUrl: "https://localazy.com/p/matrix-authentication-service!v1.26", baseLocale: "en", languages: [ { @@ -217,26 +217,26 @@ const localazyMetadata: LocalazyMetadata = { file: "frontend.json", path: "", cdnFiles: { - "cs": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/cs/frontend.json", - "da": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/da/frontend.json", - "de": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/de/frontend.json", - "en": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/en/frontend.json", - "et": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/et/frontend.json", - "fi": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/fi/frontend.json", - "fr": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/fr/frontend.json", - "hu": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/hu/frontend.json", - "nb_NO": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/nb-NO/frontend.json", - "nl": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/nl/frontend.json", - "pl": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pl/frontend.json", - "pt": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pt/frontend.json", - "pt_BR": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pt-BR/frontend.json", - "ru": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/ru/frontend.json", - "sk": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/sk/frontend.json", - "sv": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/sv/frontend.json", - "tr": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/tr/frontend.json", - "uk": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/uk/frontend.json", - "uz": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/uz/frontend.json", - "zh#Hans": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/zh-Hans/frontend.json" + "cs": "https://delivery.localazy.com/_a63743477916861340519768e7e3/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/cs/frontend.json", + "da": "https://delivery.localazy.com/_a63743477916861340519768e7e3/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/da/frontend.json", + "de": "https://delivery.localazy.com/_a63743477916861340519768e7e3/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/de/frontend.json", + "en": "https://delivery.localazy.com/_a63743477916861340519768e7e3/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/en/frontend.json", + "et": "https://delivery.localazy.com/_a63743477916861340519768e7e3/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/et/frontend.json", + "fi": "https://delivery.localazy.com/_a63743477916861340519768e7e3/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/fi/frontend.json", + "fr": "https://delivery.localazy.com/_a63743477916861340519768e7e3/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/fr/frontend.json", + "hu": "https://delivery.localazy.com/_a63743477916861340519768e7e3/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/hu/frontend.json", + "nb_NO": "https://delivery.localazy.com/_a63743477916861340519768e7e3/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/nb-NO/frontend.json", + "nl": "https://delivery.localazy.com/_a63743477916861340519768e7e3/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/nl/frontend.json", + "pl": "https://delivery.localazy.com/_a63743477916861340519768e7e3/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pl/frontend.json", + "pt": "https://delivery.localazy.com/_a63743477916861340519768e7e3/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pt/frontend.json", + "pt_BR": "https://delivery.localazy.com/_a63743477916861340519768e7e3/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pt-BR/frontend.json", + "ru": "https://delivery.localazy.com/_a63743477916861340519768e7e3/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/ru/frontend.json", + "sk": "https://delivery.localazy.com/_a63743477916861340519768e7e3/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/sk/frontend.json", + "sv": "https://delivery.localazy.com/_a63743477916861340519768e7e3/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/sv/frontend.json", + "tr": "https://delivery.localazy.com/_a63743477916861340519768e7e3/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/tr/frontend.json", + "uk": "https://delivery.localazy.com/_a63743477916861340519768e7e3/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/uk/frontend.json", + "uz": "https://delivery.localazy.com/_a63743477916861340519768e7e3/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/uz/frontend.json", + "zh#Hans": "https://delivery.localazy.com/_a63743477916861340519768e7e3/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/zh-Hans/frontend.json" } }, { @@ -244,26 +244,26 @@ const localazyMetadata: LocalazyMetadata = { file: "file.json", path: "", cdnFiles: { - "cs": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/cs/file.json", - "da": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/da/file.json", - "de": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/de/file.json", - "en": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/en/file.json", - "et": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/et/file.json", - "fi": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/fi/file.json", - "fr": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/fr/file.json", - "hu": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/hu/file.json", - "nb_NO": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/nb-NO/file.json", - "nl": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/nl/file.json", - "pl": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pl/file.json", - "pt": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pt/file.json", - "pt_BR": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pt-BR/file.json", - "ru": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/ru/file.json", - "sk": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/sk/file.json", - "sv": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/sv/file.json", - "tr": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/tr/file.json", - "uk": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/uk/file.json", - "uz": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/uz/file.json", - "zh#Hans": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/zh-Hans/file.json" + "cs": "https://delivery.localazy.com/_a63743477916861340519768e7e3/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/cs/file.json", + "da": "https://delivery.localazy.com/_a63743477916861340519768e7e3/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/da/file.json", + "de": "https://delivery.localazy.com/_a63743477916861340519768e7e3/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/de/file.json", + "en": "https://delivery.localazy.com/_a63743477916861340519768e7e3/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/en/file.json", + "et": "https://delivery.localazy.com/_a63743477916861340519768e7e3/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/et/file.json", + "fi": "https://delivery.localazy.com/_a63743477916861340519768e7e3/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/fi/file.json", + "fr": "https://delivery.localazy.com/_a63743477916861340519768e7e3/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/fr/file.json", + "hu": "https://delivery.localazy.com/_a63743477916861340519768e7e3/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/hu/file.json", + "nb_NO": "https://delivery.localazy.com/_a63743477916861340519768e7e3/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/nb-NO/file.json", + "nl": "https://delivery.localazy.com/_a63743477916861340519768e7e3/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/nl/file.json", + "pl": "https://delivery.localazy.com/_a63743477916861340519768e7e3/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pl/file.json", + "pt": "https://delivery.localazy.com/_a63743477916861340519768e7e3/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pt/file.json", + "pt_BR": "https://delivery.localazy.com/_a63743477916861340519768e7e3/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pt-BR/file.json", + "ru": "https://delivery.localazy.com/_a63743477916861340519768e7e3/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/ru/file.json", + "sk": "https://delivery.localazy.com/_a63743477916861340519768e7e3/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/sk/file.json", + "sv": "https://delivery.localazy.com/_a63743477916861340519768e7e3/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/sv/file.json", + "tr": "https://delivery.localazy.com/_a63743477916861340519768e7e3/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/tr/file.json", + "uk": "https://delivery.localazy.com/_a63743477916861340519768e7e3/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/uk/file.json", + "uz": "https://delivery.localazy.com/_a63743477916861340519768e7e3/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/uz/file.json", + "zh#Hans": "https://delivery.localazy.com/_a63743477916861340519768e7e3/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/zh-Hans/file.json" } } ] diff --git a/translations/uk.json b/translations/uk.json index 665a77728..6a3ca3879 100644 --- a/translations/uk.json +++ b/translations/uk.json @@ -175,8 +175,16 @@ "not_you": "Не %(username)s?", "or_separator": "або", "policy_violation": { - "description": "Це може бути пов'язано з клієнтом, який створив запит, користувачем, який зараз ввійшов у систему, або самим запитом.", - "heading": "Запит на авторизацію відхилений політикою, застосованою цим сервісом", + "admin_scope_not_allowed": { + "description": "Цей застосунок вимагає прав адміністратора. Увійдіть за допомогою облікового запису адміністратора, щоб продовжити.", + "heading": "Потрібен доступ адміністратора" + }, + "client_not_allowed": { + "description": "Цьому застосунку не дозволено входити в цю службу.", + "heading": "Застосунок не дозволений" + }, + "description": "Ваш запит було відхилено згідно з політикою цієї служби. Причиною цього може бути застосунок, ваш обліковий запис або сам запит.", + "heading": "Запит відхилено", "logged_as": "Зареєстровано як %(username)s ", "too_many_sessions": { "description": "Ви увійшли на максимальній кількості пристроїв під цим обліковим записом. Щоб продовжити, видаліть %(num_devices_to_remove)s пристроїв і спробуйте увійти знову.", From f36b2766821b3b8f4a92e99dd7994f93dafe07f7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:09:41 +0000 Subject: [PATCH 46/46] 1.26.0 --- Cargo.lock | 56 +++++++++++++++++++++++++------------------------- Cargo.toml | 60 +++++++++++++++++++++++++++--------------------------- 2 files changed, 58 insertions(+), 58 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3a7cb9463..c2cdc240b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3199,7 +3199,7 @@ dependencies = [ [[package]] name = "mas-axum-utils" -version = "1.26.0-rc.0" +version = "1.26.0" dependencies = [ "anyhow", "axum", @@ -3234,7 +3234,7 @@ dependencies = [ [[package]] name = "mas-cli" -version = "1.26.0-rc.0" +version = "1.26.0" dependencies = [ "anyhow", "axum", @@ -3310,7 +3310,7 @@ dependencies = [ [[package]] name = "mas-config" -version = "1.26.0-rc.0" +version = "1.26.0" dependencies = [ "anyhow", "camino", @@ -3341,7 +3341,7 @@ dependencies = [ [[package]] name = "mas-context" -version = "1.26.0-rc.0" +version = "1.26.0" dependencies = [ "console", "opentelemetry", @@ -3358,7 +3358,7 @@ dependencies = [ [[package]] name = "mas-data-model" -version = "1.26.0-rc.0" +version = "1.26.0" dependencies = [ "base64ct", "chrono", @@ -3380,7 +3380,7 @@ dependencies = [ [[package]] name = "mas-email" -version = "1.26.0-rc.0" +version = "1.26.0" dependencies = [ "async-trait", "lettre", @@ -3391,7 +3391,7 @@ dependencies = [ [[package]] name = "mas-handlers" -version = "1.26.0-rc.0" +version = "1.26.0" dependencies = [ "aide", "anyhow", @@ -3474,7 +3474,7 @@ dependencies = [ [[package]] name = "mas-http" -version = "1.26.0-rc.0" +version = "1.26.0" dependencies = [ "futures-util", "headers", @@ -3494,7 +3494,7 @@ dependencies = [ [[package]] name = "mas-i18n" -version = "1.26.0-rc.0" +version = "1.26.0" dependencies = [ "camino", "icu_datetime", @@ -3514,7 +3514,7 @@ dependencies = [ [[package]] name = "mas-i18n-scan" -version = "1.26.0-rc.0" +version = "1.26.0" dependencies = [ "camino", "clap", @@ -3528,7 +3528,7 @@ dependencies = [ [[package]] name = "mas-iana" -version = "1.26.0-rc.0" +version = "1.26.0" dependencies = [ "schemars 1.2.1", "serde", @@ -3536,7 +3536,7 @@ dependencies = [ [[package]] name = "mas-iana-codegen" -version = "1.26.0-rc.0" +version = "1.26.0" dependencies = [ "anyhow", "async-trait", @@ -3553,7 +3553,7 @@ dependencies = [ [[package]] name = "mas-jose" -version = "1.26.0-rc.0" +version = "1.26.0" dependencies = [ "base64ct", "chrono", @@ -3583,7 +3583,7 @@ dependencies = [ [[package]] name = "mas-keystore" -version = "1.26.0-rc.0" +version = "1.26.0" dependencies = [ "aead", "base64ct", @@ -3611,7 +3611,7 @@ dependencies = [ [[package]] name = "mas-listener" -version = "1.26.0-rc.0" +version = "1.26.0" dependencies = [ "anyhow", "bytes", @@ -3635,7 +3635,7 @@ dependencies = [ [[package]] name = "mas-matrix" -version = "1.26.0-rc.0" +version = "1.26.0" dependencies = [ "anyhow", "async-trait", @@ -3645,7 +3645,7 @@ dependencies = [ [[package]] name = "mas-matrix-synapse" -version = "1.26.0-rc.0" +version = "1.26.0" dependencies = [ "anyhow", "async-trait", @@ -3662,7 +3662,7 @@ dependencies = [ [[package]] name = "mas-oidc-client" -version = "1.26.0-rc.0" +version = "1.26.0" dependencies = [ "assert_matches", "async-trait", @@ -3699,7 +3699,7 @@ dependencies = [ [[package]] name = "mas-policy" -version = "1.26.0-rc.0" +version = "1.26.0" dependencies = [ "anyhow", "arc-swap", @@ -3716,7 +3716,7 @@ dependencies = [ [[package]] name = "mas-router" -version = "1.26.0-rc.0" +version = "1.26.0" dependencies = [ "axum", "serde", @@ -3727,7 +3727,7 @@ dependencies = [ [[package]] name = "mas-spa" -version = "1.26.0-rc.0" +version = "1.26.0" dependencies = [ "camino", "serde", @@ -3736,7 +3736,7 @@ dependencies = [ [[package]] name = "mas-storage" -version = "1.26.0-rc.0" +version = "1.26.0" dependencies = [ "async-trait", "chrono", @@ -3758,7 +3758,7 @@ dependencies = [ [[package]] name = "mas-storage-pg" -version = "1.26.0-rc.0" +version = "1.26.0" dependencies = [ "async-trait", "chrono", @@ -3790,7 +3790,7 @@ dependencies = [ [[package]] name = "mas-tasks" -version = "1.26.0-rc.0" +version = "1.26.0" dependencies = [ "anyhow", "async-trait", @@ -3822,7 +3822,7 @@ dependencies = [ [[package]] name = "mas-templates" -version = "1.26.0-rc.0" +version = "1.26.0" dependencies = [ "anyhow", "arc-swap", @@ -3854,7 +3854,7 @@ dependencies = [ [[package]] name = "mas-tower" -version = "1.26.0-rc.0" +version = "1.26.0" dependencies = [ "http", "opentelemetry", @@ -4106,7 +4106,7 @@ dependencies = [ [[package]] name = "oauth2-types" -version = "1.26.0-rc.0" +version = "1.26.0" dependencies = [ "assert_matches", "base64ct", @@ -6282,7 +6282,7 @@ dependencies = [ [[package]] name = "syn2mas" -version = "1.26.0-rc.0" +version = "1.26.0" dependencies = [ "anyhow", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index 47020c0a8..f9848a7ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ members = ["crates/*"] resolver = "2" # Updated in the CI with a `sed` command -package.version = "1.26.0-rc.0" +package.version = "1.26.0" package.license = "AGPL-3.0-only OR LicenseRef-Element-Commercial" package.authors = ["Element Backend Team"] package.edition = "2024" @@ -42,35 +42,35 @@ broken_intra_doc_links = "deny" [workspace.dependencies] # Workspace crates -mas-axum-utils = { path = "./crates/axum-utils/", version = "=1.26.0-rc.0" } -mas-cli = { path = "./crates/cli/", version = "=1.26.0-rc.0" } -mas-config = { path = "./crates/config/", version = "=1.26.0-rc.0" } -mas-context = { path = "./crates/context/", version = "=1.26.0-rc.0" } -mas-data-model = { path = "./crates/data-model/", version = "=1.26.0-rc.0" } -mas-email = { path = "./crates/email/", version = "=1.26.0-rc.0" } -mas-graphql = { path = "./crates/graphql/", version = "=1.26.0-rc.0" } -mas-handlers = { path = "./crates/handlers/", version = "=1.26.0-rc.0" } -mas-http = { path = "./crates/http/", version = "=1.26.0-rc.0" } -mas-i18n = { path = "./crates/i18n/", version = "=1.26.0-rc.0" } -mas-i18n-scan = { path = "./crates/i18n-scan/", version = "=1.26.0-rc.0" } -mas-iana = { path = "./crates/iana/", version = "=1.26.0-rc.0" } -mas-iana-codegen = { path = "./crates/iana-codegen/", version = "=1.26.0-rc.0" } -mas-jose = { path = "./crates/jose/", version = "=1.26.0-rc.0" } -mas-keystore = { path = "./crates/keystore/", version = "=1.26.0-rc.0" } -mas-listener = { path = "./crates/listener/", version = "=1.26.0-rc.0" } -mas-matrix = { path = "./crates/matrix/", version = "=1.26.0-rc.0" } -mas-matrix-synapse = { path = "./crates/matrix-synapse/", version = "=1.26.0-rc.0" } -mas-oidc-client = { path = "./crates/oidc-client/", version = "=1.26.0-rc.0" } -mas-policy = { path = "./crates/policy/", version = "=1.26.0-rc.0" } -mas-router = { path = "./crates/router/", version = "=1.26.0-rc.0" } -mas-spa = { path = "./crates/spa/", version = "=1.26.0-rc.0" } -mas-storage = { path = "./crates/storage/", version = "=1.26.0-rc.0" } -mas-storage-pg = { path = "./crates/storage-pg/", version = "=1.26.0-rc.0" } -mas-tasks = { path = "./crates/tasks/", version = "=1.26.0-rc.0" } -mas-templates = { path = "./crates/templates/", version = "=1.26.0-rc.0" } -mas-tower = { path = "./crates/tower/", version = "=1.26.0-rc.0" } -oauth2-types = { path = "./crates/oauth2-types/", version = "=1.26.0-rc.0" } -syn2mas = { path = "./crates/syn2mas", version = "=1.26.0-rc.0" } +mas-axum-utils = { path = "./crates/axum-utils/", version = "=1.26.0" } +mas-cli = { path = "./crates/cli/", version = "=1.26.0" } +mas-config = { path = "./crates/config/", version = "=1.26.0" } +mas-context = { path = "./crates/context/", version = "=1.26.0" } +mas-data-model = { path = "./crates/data-model/", version = "=1.26.0" } +mas-email = { path = "./crates/email/", version = "=1.26.0" } +mas-graphql = { path = "./crates/graphql/", version = "=1.26.0" } +mas-handlers = { path = "./crates/handlers/", version = "=1.26.0" } +mas-http = { path = "./crates/http/", version = "=1.26.0" } +mas-i18n = { path = "./crates/i18n/", version = "=1.26.0" } +mas-i18n-scan = { path = "./crates/i18n-scan/", version = "=1.26.0" } +mas-iana = { path = "./crates/iana/", version = "=1.26.0" } +mas-iana-codegen = { path = "./crates/iana-codegen/", version = "=1.26.0" } +mas-jose = { path = "./crates/jose/", version = "=1.26.0" } +mas-keystore = { path = "./crates/keystore/", version = "=1.26.0" } +mas-listener = { path = "./crates/listener/", version = "=1.26.0" } +mas-matrix = { path = "./crates/matrix/", version = "=1.26.0" } +mas-matrix-synapse = { path = "./crates/matrix-synapse/", version = "=1.26.0" } +mas-oidc-client = { path = "./crates/oidc-client/", version = "=1.26.0" } +mas-policy = { path = "./crates/policy/", version = "=1.26.0" } +mas-router = { path = "./crates/router/", version = "=1.26.0" } +mas-spa = { path = "./crates/spa/", version = "=1.26.0" } +mas-storage = { path = "./crates/storage/", version = "=1.26.0" } +mas-storage-pg = { path = "./crates/storage-pg/", version = "=1.26.0" } +mas-tasks = { path = "./crates/tasks/", version = "=1.26.0" } +mas-templates = { path = "./crates/templates/", version = "=1.26.0" } +mas-tower = { path = "./crates/tower/", version = "=1.26.0" } +oauth2-types = { path = "./crates/oauth2-types/", version = "=1.26.0" } +syn2mas = { path = "./crates/syn2mas", version = "=1.26.0" } # OpenAPI schema generation and validation [workspace.dependencies.aide]