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 diff --git a/Cargo.lock b/Cargo.lock index 330f6f332..c2cdc240b 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]] @@ -2304,9 +2305,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", @@ -3198,7 +3199,7 @@ dependencies = [ [[package]] name = "mas-axum-utils" -version = "1.24.0" +version = "1.26.0" dependencies = [ "anyhow", "axum", @@ -3233,7 +3234,7 @@ dependencies = [ [[package]] name = "mas-cli" -version = "1.24.0" +version = "1.26.0" dependencies = [ "anyhow", "axum", @@ -3293,6 +3294,7 @@ dependencies = [ "serde_yaml", "sqlx", "syn2mas", + "tempfile", "tokio", "tokio-util", "tower", @@ -3308,7 +3310,7 @@ dependencies = [ [[package]] name = "mas-config" -version = "1.24.0" +version = "1.26.0" dependencies = [ "anyhow", "camino", @@ -3339,7 +3341,7 @@ dependencies = [ [[package]] name = "mas-context" -version = "1.24.0" +version = "1.26.0" dependencies = [ "console", "opentelemetry", @@ -3356,7 +3358,7 @@ dependencies = [ [[package]] name = "mas-data-model" -version = "1.24.0" +version = "1.26.0" dependencies = [ "base64ct", "chrono", @@ -3378,7 +3380,7 @@ dependencies = [ [[package]] name = "mas-email" -version = "1.24.0" +version = "1.26.0" dependencies = [ "async-trait", "lettre", @@ -3389,7 +3391,7 @@ dependencies = [ [[package]] name = "mas-handlers" -version = "1.24.0" +version = "1.26.0" dependencies = [ "aide", "anyhow", @@ -3472,7 +3474,7 @@ dependencies = [ [[package]] name = "mas-http" -version = "1.24.0" +version = "1.26.0" dependencies = [ "futures-util", "headers", @@ -3492,7 +3494,7 @@ dependencies = [ [[package]] name = "mas-i18n" -version = "1.24.0" +version = "1.26.0" dependencies = [ "camino", "icu_datetime", @@ -3512,7 +3514,7 @@ dependencies = [ [[package]] name = "mas-i18n-scan" -version = "1.24.0" +version = "1.26.0" dependencies = [ "camino", "clap", @@ -3526,7 +3528,7 @@ dependencies = [ [[package]] name = "mas-iana" -version = "1.24.0" +version = "1.26.0" dependencies = [ "schemars 1.2.1", "serde", @@ -3534,7 +3536,7 @@ dependencies = [ [[package]] name = "mas-iana-codegen" -version = "1.24.0" +version = "1.26.0" dependencies = [ "anyhow", "async-trait", @@ -3551,7 +3553,7 @@ dependencies = [ [[package]] name = "mas-jose" -version = "1.24.0" +version = "1.26.0" dependencies = [ "base64ct", "chrono", @@ -3581,7 +3583,7 @@ dependencies = [ [[package]] name = "mas-keystore" -version = "1.24.0" +version = "1.26.0" dependencies = [ "aead", "base64ct", @@ -3609,7 +3611,7 @@ dependencies = [ [[package]] name = "mas-listener" -version = "1.24.0" +version = "1.26.0" dependencies = [ "anyhow", "bytes", @@ -3633,7 +3635,7 @@ dependencies = [ [[package]] name = "mas-matrix" -version = "1.24.0" +version = "1.26.0" dependencies = [ "anyhow", "async-trait", @@ -3643,7 +3645,7 @@ dependencies = [ [[package]] name = "mas-matrix-synapse" -version = "1.24.0" +version = "1.26.0" dependencies = [ "anyhow", "async-trait", @@ -3660,7 +3662,7 @@ dependencies = [ [[package]] name = "mas-oidc-client" -version = "1.24.0" +version = "1.26.0" dependencies = [ "assert_matches", "async-trait", @@ -3673,6 +3675,7 @@ dependencies = [ "http", "http-body-util", "language-tags", + "mas-data-model", "mas-http", "mas-iana", "mas-jose", @@ -3696,7 +3699,7 @@ dependencies = [ [[package]] name = "mas-policy" -version = "1.24.0" +version = "1.26.0" dependencies = [ "anyhow", "arc-swap", @@ -3713,7 +3716,7 @@ dependencies = [ [[package]] name = "mas-router" -version = "1.24.0" +version = "1.26.0" dependencies = [ "axum", "serde", @@ -3724,7 +3727,7 @@ dependencies = [ [[package]] name = "mas-spa" -version = "1.24.0" +version = "1.26.0" dependencies = [ "camino", "serde", @@ -3733,7 +3736,7 @@ dependencies = [ [[package]] name = "mas-storage" -version = "1.24.0" +version = "1.26.0" dependencies = [ "async-trait", "chrono", @@ -3755,7 +3758,7 @@ dependencies = [ [[package]] name = "mas-storage-pg" -version = "1.24.0" +version = "1.26.0" dependencies = [ "async-trait", "chrono", @@ -3787,7 +3790,7 @@ dependencies = [ [[package]] name = "mas-tasks" -version = "1.24.0" +version = "1.26.0" dependencies = [ "anyhow", "async-trait", @@ -3819,7 +3822,7 @@ dependencies = [ [[package]] name = "mas-templates" -version = "1.24.0" +version = "1.26.0" dependencies = [ "anyhow", "arc-swap", @@ -3851,7 +3854,7 @@ dependencies = [ [[package]] name = "mas-tower" -version = "1.24.0" +version = "1.26.0" dependencies = [ "http", "opentelemetry", @@ -4103,7 +4106,7 @@ dependencies = [ [[package]] name = "oauth2-types" -version = "1.24.0" +version = "1.26.0" dependencies = [ "assert_matches", "base64ct", @@ -5287,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", @@ -5345,7 +5348,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5356,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", @@ -6279,7 +6282,7 @@ dependencies = [ [[package]] name = "syn2mas" -version = "1.24.0" +version = "1.26.0" dependencies = [ "anyhow", "arc-swap", @@ -6371,7 +6374,7 @@ dependencies = [ "getrandom 0.3.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6804,6 +6807,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 cb65a787b..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.24.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.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.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] @@ -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/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/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/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/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/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 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}" + ); + } } 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/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} ")?; 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..6fb62a940 100644 --- a/crates/data-model/src/oauth2/device_code_grant.rs +++ b/crates/data-model/src/oauth2/device_code_grant.rs @@ -8,11 +8,64 @@ 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`. +/// +/// `U` is not in the alphabet but has no mapping defined for it either, so it +/// is passed through unchanged. +/// +/// Crockford ignores hyphens when decoding, but we issue none, so nothing is +/// stripped here: hyphens, whitespace and punctuation all just fail to match. +/// +/// 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] +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 +320,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/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/compat/login.rs b/crates/handlers/src/compat/login.rs index efa8358ae..f30b1bab4 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); } @@ -1686,6 +1702,31 @@ 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", + "identifier": { + "type": "m.id.user", + "user": "_irc_example", + }, + })); + + 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) { 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/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/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..fe54f901a 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, @@ -16,16 +18,27 @@ 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, + 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,36 @@ pub(crate) async fn post( &locale, &templates, &url_builder, + &limiter, + requester, cookie_jar, form, ) .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())) +} + +#[expect(clippy::too_many_arguments)] async fn handle_code( rng: &mut BoxRng, clock: &BoxClock, @@ -109,6 +152,8 @@ async fn handle_code( locale: &DataLocale, templates: &Templates, url_builder: &UrlBuilder, + limiter: &Limiter, + requester: RequesterFingerprint, cookie_jar: CookieJar, params: Params, ) -> Result { @@ -116,24 +161,59 @@ 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()); + // 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. The two candidate lookups below are one + // attempt between them, not one each. + 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()); + } + + // 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 // 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 +229,424 @@ async fn handle_code( Ok((cookie_jar, Html(content)).into_response()) } + +#[cfg(test)] +mod tests { + use std::net::{IpAddr, Ipv4Addr}; + + use chrono::Duration; + use hyper::{ + Request, StatusCode, + header::{CONTENT_TYPE, LOCATION}, + }; + use mas_data_model::Client; + use mas_router::{Route, SimpleRoute}; + use mas_storage::oauth2::OAuth2DeviceCodeGrantParams; + use oauth2_types::{ + registration::ClientRegistrationResponse, requests::DeviceAuthorizationResponse, + scope::OPENID, + }; + 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)); + + /// 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 + } + + /// Start a device authorization grant and return its user code + async fn get_user_code(state: &TestState) -> String { + let client = device_client(state).await; + + let request = Request::post(mas_router::OAuth2DeviceAuthorizationEndpoint::PATH).form( + serde_json::json!({ + "client_id": client.client_id, + "scope": "openid", + }), + ); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let response: DeviceAuthorizationResponse = response.json(); + + response.user_code + } + + /// 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_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); + } + + /// 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/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()], }) } 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..246e2d5d4 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}; @@ -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 { @@ -82,22 +88,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)) } } @@ -120,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 { @@ -146,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()?), }) } } @@ -191,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; } @@ -323,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)] @@ -399,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/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)) } } diff --git a/crates/handlers/src/upstream_oauth2/authorize.rs b/crates/handlers/src/upstream_oauth2/authorize.rs index 5807f5430..364ba1cbb 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,42 @@ 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, + 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 // `additional_authorization_parameters` templates as `logged_out` so that @@ -83,17 +103,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 +127,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 +165,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 +185,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 +195,54 @@ 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, + username, + ) + .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, + None, + ) + .await?; repo.save().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..f5128f37c 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,45 @@ pub(crate) async fn get( )? }; + let forced_or_required = provider.claims_imports.localpart.is_forced_or_required(); + + let carried_username = if forced_or_required { + // The provider picks the localpart, not the user + 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 +506,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 +729,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 +763,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 +884,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 +1273,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 +1416,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 +1614,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 +1748,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 +1872,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 +2062,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 +2193,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 +2331,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 +2374,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"); + } } diff --git a/crates/handlers/src/views/register/mod.rs b/crates/handlers/src/views/register/mod.rs index 6a51852ae..5c025cd0e 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 { + // 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 + } 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/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) { 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/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/crates/templates/src/context.rs b/crates/templates/src/context.rs index 090ba4333..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,13 +851,37 @@ 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), 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 +900,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(), @@ -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 }), + }, + ], + }, ]) } } @@ -1918,7 +1968,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/crates/templates/src/context/captcha.rs b/crates/templates/src/context/captcha.rs index f9d8723b4..76e692548 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, } } @@ -67,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() } } 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/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/development/releasing.md b/docs/development/releasing.md index 1c8e8ad89..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. @@ -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 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 ``` 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/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/frontend/.storybook/locales.ts b/frontend/.storybook/locales.ts index deda65899..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!v1.24", + 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/_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/_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/_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/_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/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/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..968463703 --- /dev/null +++ b/frontend/src/components/Captcha.stories.tsx @@ -0,0 +1,82 @@ +// 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 { Suspense } from "react"; +import { Captcha, CaptchaPlaceholder } from "./Captcha"; + +const meta = { + component: Captcha, + title: "ui/Captcha", + decorators: [ + (Story) => ( + }> + + + ), + ], +} 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 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..c6cf68054 --- /dev/null +++ b/frontend/src/components/Captcha.tsx @@ -0,0 +1,309 @@ +// 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 { forwardRef, memo, use, useImperativeHandle, useRef } from "react"; + +// Cache of script URLs to their load promises, so each script is only loaded +// once even if multiple components request it. +const scripts: Record> = {}; + +// Counter used to generate unique global callback names for each script. +let i = 0; + +/** + * 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.service == "cloudflare_turnstile" -%} - - {%- elif captcha.service == "hcaptcha" -%} - - {%- else -%} - {{ throw(message="Invalid captcha service setup") }} - {%- endif %} - {%- endif -%} -{%- endmacro %} 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/device_link.html b/templates/pages/device_link.html index aaf018b1c..ba0d597bc 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) %}
@@ -30,6 +38,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> 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/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/templates/pages/register/password.html b/templates/pages/register/password.html index f1a77efad..d181d5428 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. diff --git a/translations/en.json b/translations/en.json index ddc348533..fc05d15d5 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:57:33-51, pages/policy_violation.html:75: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: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: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: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": { @@ -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": { @@ -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:40: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:45: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:49: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: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:31: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": { @@ -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": { @@ -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." } }, @@ -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": { @@ -457,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:", @@ -527,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" } } }, @@ -660,30 +688,30 @@ "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:61: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", "@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:18:27-67" + "context": "pages/register/index.html:22: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:54: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": { 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..6a3ca3879 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", @@ -174,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 пристроїв і спробуйте увійти знову.",