Merge branch 'main' into rei/reapply_5297

This commit is contained in:
Quentin Gliech
2025-12-10 22:09:56 +01:00
committed by GitHub
73 changed files with 3444 additions and 1077 deletions

View File

@@ -46,7 +46,7 @@ jobs:
steps: steps:
- name: Checkout the code - name: Checkout the code
uses: actions/checkout@v5 uses: actions/checkout@v6
with: with:
# Need a full clone so that `git describe` reports the right version # Need a full clone so that `git describe` reports the right version
fetch-depth: 0 fetch-depth: 0
@@ -67,7 +67,7 @@ jobs:
steps: steps:
- name: Checkout the code - name: Checkout the code
uses: actions/checkout@v5 uses: actions/checkout@v6
- uses: ./.github/actions/build-frontend - uses: ./.github/actions/build-frontend
- uses: ./.github/actions/build-policies - uses: ./.github/actions/build-policies
@@ -112,7 +112,7 @@ jobs:
steps: steps:
- name: Checkout the code - name: Checkout the code
uses: actions/checkout@v5 uses: actions/checkout@v6
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
@@ -226,7 +226,7 @@ jobs:
steps: steps:
- name: Docker meta - name: Docker meta
id: meta id: meta
uses: docker/metadata-action@v5.9.0 uses: docker/metadata-action@v5.10.0
with: with:
images: "${{ env.IMAGE }}" images: "${{ env.IMAGE }}"
bake-target: docker-metadata-action bake-target: docker-metadata-action
@@ -242,7 +242,7 @@ jobs:
- name: Docker meta (debug variant) - name: Docker meta (debug variant)
id: meta-debug id: meta-debug
uses: docker/metadata-action@v5.9.0 uses: docker/metadata-action@v5.10.0
with: with:
images: "${{ env.IMAGE }}" images: "${{ env.IMAGE }}"
bake-target: docker-metadata-action-debug bake-target: docker-metadata-action-debug
@@ -276,7 +276,7 @@ jobs:
- name: Build and push - name: Build and push
id: bake id: bake
uses: docker/bake-action@v6.9.0 uses: docker/bake-action@v6.10.0
with: with:
files: | files: |
./docker-bake.hcl ./docker-bake.hcl
@@ -327,7 +327,7 @@ jobs:
merge-multiple: true merge-multiple: true
- name: Prepare a release - name: Prepare a release
uses: softprops/action-gh-release@v2.4.2 uses: softprops/action-gh-release@v2.5.0
with: with:
generate_release_notes: true generate_release_notes: true
body: | body: |
@@ -376,7 +376,7 @@ jobs:
steps: steps:
- name: Checkout the code - name: Checkout the code
uses: actions/checkout@v5 uses: actions/checkout@v6
with: with:
sparse-checkout: | sparse-checkout: |
.github/scripts .github/scripts
@@ -396,7 +396,7 @@ jobs:
await script({ core, github, context }); await script({ core, github, context });
- name: Update unstable release - name: Update unstable release
uses: softprops/action-gh-release@v2.4.2 uses: softprops/action-gh-release@v2.5.0
with: with:
name: "Unstable build" name: "Unstable build"
tag_name: unstable tag_name: unstable
@@ -454,7 +454,7 @@ jobs:
steps: steps:
- name: Checkout the code - name: Checkout the code
uses: actions/checkout@v5 uses: actions/checkout@v6
with: with:
sparse-checkout: | sparse-checkout: |
.github/scripts .github/scripts

View File

@@ -34,7 +34,7 @@ jobs:
steps: steps:
- name: Checkout the code - name: Checkout the code
uses: actions/checkout@v5 uses: actions/checkout@v6
- uses: ./.github/actions/build-policies - uses: ./.github/actions/build-policies
@@ -61,10 +61,10 @@ jobs:
steps: steps:
- name: Checkout the code - name: Checkout the code
uses: actions/checkout@v5 uses: actions/checkout@v6
- name: Install Node - name: Install Node
uses: actions/setup-node@v6.0.0 uses: actions/setup-node@v6.1.0
with: with:
node-version: 24 node-version: 24
@@ -85,10 +85,10 @@ jobs:
steps: steps:
- name: Checkout the code - name: Checkout the code
uses: actions/checkout@v5 uses: actions/checkout@v6
- name: Install Node - name: Install Node
uses: actions/setup-node@v6.0.0 uses: actions/setup-node@v6.1.0
with: with:
node-version: 24 node-version: 24
@@ -109,10 +109,10 @@ jobs:
steps: steps:
- name: Checkout the code - name: Checkout the code
uses: actions/checkout@v5 uses: actions/checkout@v6
- name: Install Node - name: Install Node
uses: actions/setup-node@v6.0.0 uses: actions/setup-node@v6.1.0
with: with:
node-version: 24 node-version: 24
@@ -133,7 +133,7 @@ jobs:
steps: steps:
- name: Checkout the code - name: Checkout the code
uses: actions/checkout@v5 uses: actions/checkout@v6
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@nightly uses: dtolnay/rust-toolchain@nightly
@@ -156,10 +156,10 @@ jobs:
steps: steps:
- name: Checkout the code - name: Checkout the code
uses: actions/checkout@v5 uses: actions/checkout@v6
- name: Run `cargo-deny` - name: Run `cargo-deny`
uses: EmbarkStudios/cargo-deny-action@v2.0.13 uses: EmbarkStudios/cargo-deny-action@v2.0.14
with: with:
rust-version: stable rust-version: stable
@@ -172,7 +172,7 @@ jobs:
steps: steps:
- name: Checkout the code - name: Checkout the code
uses: actions/checkout@v5 uses: actions/checkout@v6
- name: Install Rust toolchain - name: Install Rust toolchain
run: | run: |
@@ -213,7 +213,7 @@ jobs:
steps: steps:
- name: Checkout the code - name: Checkout the code
uses: actions/checkout@v5 uses: actions/checkout@v6
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@1.89.0 uses: dtolnay/rust-toolchain@1.89.0
@@ -238,7 +238,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v5 uses: actions/checkout@v6
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
@@ -291,7 +291,7 @@ jobs:
steps: steps:
- name: Checkout the code - name: Checkout the code
uses: actions/checkout@v5 uses: actions/checkout@v6
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable

View File

@@ -29,7 +29,7 @@ jobs:
steps: steps:
- name: Checkout the code - name: Checkout the code
uses: actions/checkout@v5 uses: actions/checkout@v6
- uses: ./.github/actions/build-policies - uses: ./.github/actions/build-policies
@@ -54,7 +54,7 @@ jobs:
steps: steps:
- name: Checkout the code - name: Checkout the code
uses: actions/checkout@v5 uses: actions/checkout@v6
- uses: ./.github/actions/build-frontend - uses: ./.github/actions/build-frontend
env: env:
@@ -99,7 +99,7 @@ jobs:
steps: steps:
- name: Checkout the code - name: Checkout the code
uses: actions/checkout@v5 uses: actions/checkout@v6
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable

View File

@@ -25,7 +25,7 @@ jobs:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- name: Checkout the code - name: Checkout the code
uses: actions/checkout@v5 uses: actions/checkout@v6
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
@@ -39,7 +39,7 @@ jobs:
tool: mdbook tool: mdbook
- name: Install Node - name: Install Node
uses: actions/setup-node@v6.0.0 uses: actions/setup-node@v6.1.0
with: with:
node-version: 24 node-version: 24

View File

@@ -24,7 +24,7 @@ jobs:
steps: steps:
- name: Checkout the code - name: Checkout the code
uses: actions/checkout@v5 uses: actions/checkout@v6
with: with:
sparse-checkout: | sparse-checkout: |
.github/scripts .github/scripts

View File

@@ -34,7 +34,7 @@ jobs:
run: exit 1 run: exit 1
- name: Checkout the code - name: Checkout the code
uses: actions/checkout@v5 uses: actions/checkout@v6
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
@@ -61,10 +61,10 @@ jobs:
steps: steps:
- name: Checkout the code - name: Checkout the code
uses: actions/checkout@v5 uses: actions/checkout@v6
- name: Install Node - name: Install Node
uses: actions/setup-node@v6.0.0 uses: actions/setup-node@v6.1.0
with: with:
node-version: 24 node-version: 24
@@ -106,7 +106,7 @@ jobs:
needs: [tag, compute-version, localazy] needs: [tag, compute-version, localazy]
steps: steps:
- name: Checkout the code - name: Checkout the code
uses: actions/checkout@v5 uses: actions/checkout@v6
with: with:
sparse-checkout: | sparse-checkout: |
.github/scripts .github/scripts

View File

@@ -33,7 +33,7 @@ jobs:
run: exit 1 run: exit 1
- name: Checkout the code - name: Checkout the code
uses: actions/checkout@v5 uses: actions/checkout@v6
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
@@ -76,7 +76,7 @@ jobs:
needs: [tag, compute-version] needs: [tag, compute-version]
steps: steps:
- name: Checkout the code - name: Checkout the code
uses: actions/checkout@v5 uses: actions/checkout@v6
with: with:
sparse-checkout: | sparse-checkout: |
.github/scripts .github/scripts

View File

@@ -30,7 +30,7 @@ jobs:
steps: steps:
- name: Checkout the code - name: Checkout the code
uses: actions/checkout@v5 uses: actions/checkout@v6
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable

View File

@@ -19,10 +19,10 @@ jobs:
run: exit 1 run: exit 1
- name: Checkout the code - name: Checkout the code
uses: actions/checkout@v5 uses: actions/checkout@v6
- name: Install Node - name: Install Node
uses: actions/setup-node@v6.0.0 uses: actions/setup-node@v6.1.0
with: with:
node-version: 24 node-version: 24
@@ -42,7 +42,7 @@ jobs:
- name: Create Pull Request - name: Create Pull Request
id: cpr id: cpr
uses: peter-evans/create-pull-request@v7.0.8 uses: peter-evans/create-pull-request@v8.0.0
with: with:
sign-commits: true sign-commits: true
token: ${{ secrets.BOT_GITHUB_TOKEN }} token: ${{ secrets.BOT_GITHUB_TOKEN }}

View File

@@ -18,10 +18,10 @@ jobs:
steps: steps:
- name: Checkout the code - name: Checkout the code
uses: actions/checkout@v5 uses: actions/checkout@v6
- name: Install Node - name: Install Node
uses: actions/setup-node@v6.0.0 uses: actions/setup-node@v6.1.0
with: with:
node-version: 24 node-version: 24

101
Cargo.lock generated
View File

@@ -1089,9 +1089,9 @@ checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]] [[package]]
name = "convert_case" name = "convert_case"
version = "0.8.0" version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" checksum = "db05ffb6856bf0ecdf6367558a76a0e8a77b1713044eb92845c692100ed50190"
dependencies = [ dependencies = [
"unicode-segmentation", "unicode-segmentation",
] ]
@@ -3097,7 +3097,7 @@ dependencies = [
[[package]] [[package]]
name = "mas-axum-utils" name = "mas-axum-utils"
version = "1.7.0" version = "1.8.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@@ -3131,7 +3131,7 @@ dependencies = [
[[package]] [[package]]
name = "mas-cli" name = "mas-cli"
version = "1.7.0" version = "1.8.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@@ -3204,7 +3204,7 @@ dependencies = [
[[package]] [[package]]
name = "mas-config" name = "mas-config"
version = "1.7.0" version = "1.8.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"camino", "camino",
@@ -3236,7 +3236,7 @@ dependencies = [
[[package]] [[package]]
name = "mas-context" name = "mas-context"
version = "1.7.0" version = "1.8.0"
dependencies = [ dependencies = [
"console", "console",
"opentelemetry", "opentelemetry",
@@ -3252,7 +3252,7 @@ dependencies = [
[[package]] [[package]]
name = "mas-data-model" name = "mas-data-model"
version = "1.7.0" version = "1.8.0"
dependencies = [ dependencies = [
"base64ct", "base64ct",
"chrono", "chrono",
@@ -3275,7 +3275,7 @@ dependencies = [
[[package]] [[package]]
name = "mas-email" name = "mas-email"
version = "1.7.0" version = "1.8.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"lettre", "lettre",
@@ -3286,7 +3286,7 @@ dependencies = [
[[package]] [[package]]
name = "mas-handlers" name = "mas-handlers"
version = "1.7.0" version = "1.8.0"
dependencies = [ dependencies = [
"aide", "aide",
"anyhow", "anyhow",
@@ -3366,7 +3366,7 @@ dependencies = [
[[package]] [[package]]
name = "mas-http" name = "mas-http"
version = "1.7.0" version = "1.8.0"
dependencies = [ dependencies = [
"futures-util", "futures-util",
"headers", "headers",
@@ -3387,7 +3387,7 @@ dependencies = [
[[package]] [[package]]
name = "mas-i18n" name = "mas-i18n"
version = "1.7.0" version = "1.8.0"
dependencies = [ dependencies = [
"camino", "camino",
"icu_calendar", "icu_calendar",
@@ -3409,7 +3409,7 @@ dependencies = [
[[package]] [[package]]
name = "mas-i18n-scan" name = "mas-i18n-scan"
version = "1.7.0" version = "1.8.0"
dependencies = [ dependencies = [
"camino", "camino",
"clap", "clap",
@@ -3423,7 +3423,7 @@ dependencies = [
[[package]] [[package]]
name = "mas-iana" name = "mas-iana"
version = "1.7.0" version = "1.8.0"
dependencies = [ dependencies = [
"schemars 0.9.0", "schemars 0.9.0",
"serde", "serde",
@@ -3431,7 +3431,7 @@ dependencies = [
[[package]] [[package]]
name = "mas-iana-codegen" name = "mas-iana-codegen"
version = "1.7.0" version = "1.8.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@@ -3448,7 +3448,7 @@ dependencies = [
[[package]] [[package]]
name = "mas-jose" name = "mas-jose"
version = "1.7.0" version = "1.8.0"
dependencies = [ dependencies = [
"base64ct", "base64ct",
"chrono", "chrono",
@@ -3478,7 +3478,7 @@ dependencies = [
[[package]] [[package]]
name = "mas-keystore" name = "mas-keystore"
version = "1.7.0" version = "1.8.0"
dependencies = [ dependencies = [
"aead", "aead",
"base64ct", "base64ct",
@@ -3506,7 +3506,7 @@ dependencies = [
[[package]] [[package]]
name = "mas-listener" name = "mas-listener"
version = "1.7.0" version = "1.8.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@@ -3531,7 +3531,7 @@ dependencies = [
[[package]] [[package]]
name = "mas-matrix" name = "mas-matrix"
version = "1.7.0" version = "1.8.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@@ -3541,7 +3541,7 @@ dependencies = [
[[package]] [[package]]
name = "mas-matrix-synapse" name = "mas-matrix-synapse"
version = "1.7.0" version = "1.8.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@@ -3558,7 +3558,7 @@ dependencies = [
[[package]] [[package]]
name = "mas-oidc-client" name = "mas-oidc-client"
version = "1.7.0" version = "1.8.0"
dependencies = [ dependencies = [
"assert_matches", "assert_matches",
"async-trait", "async-trait",
@@ -3594,7 +3594,7 @@ dependencies = [
[[package]] [[package]]
name = "mas-policy" name = "mas-policy"
version = "1.7.0" version = "1.8.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"arc-swap", "arc-swap",
@@ -3611,7 +3611,7 @@ dependencies = [
[[package]] [[package]]
name = "mas-router" name = "mas-router"
version = "1.7.0" version = "1.8.0"
dependencies = [ dependencies = [
"axum", "axum",
"serde", "serde",
@@ -3622,7 +3622,7 @@ dependencies = [
[[package]] [[package]]
name = "mas-spa" name = "mas-spa"
version = "1.7.0" version = "1.8.0"
dependencies = [ dependencies = [
"camino", "camino",
"serde", "serde",
@@ -3631,7 +3631,7 @@ dependencies = [
[[package]] [[package]]
name = "mas-storage" name = "mas-storage"
version = "1.7.0" version = "1.8.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"chrono", "chrono",
@@ -3653,10 +3653,11 @@ dependencies = [
[[package]] [[package]]
name = "mas-storage-pg" name = "mas-storage-pg"
version = "1.7.0" version = "1.8.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"chrono", "chrono",
"crc",
"futures-util", "futures-util",
"mas-data-model", "mas-data-model",
"mas-iana", "mas-iana",
@@ -3673,6 +3674,7 @@ dependencies = [
"sha2", "sha2",
"sqlx", "sqlx",
"thiserror 2.0.17", "thiserror 2.0.17",
"tokio",
"tracing", "tracing",
"ulid", "ulid",
"url", "url",
@@ -3681,7 +3683,7 @@ dependencies = [
[[package]] [[package]]
name = "mas-tasks" name = "mas-tasks"
version = "1.7.0" version = "1.8.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@@ -3713,7 +3715,7 @@ dependencies = [
[[package]] [[package]]
name = "mas-templates" name = "mas-templates"
version = "1.7.0" version = "1.8.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"arc-swap", "arc-swap",
@@ -3723,6 +3725,7 @@ dependencies = [
"mas-data-model", "mas-data-model",
"mas-i18n", "mas-i18n",
"mas-iana", "mas-iana",
"mas-policy",
"mas-router", "mas-router",
"mas-spa", "mas-spa",
"minijinja", "minijinja",
@@ -3744,7 +3747,7 @@ dependencies = [
[[package]] [[package]]
name = "mas-tower" name = "mas-tower"
version = "1.7.0" version = "1.8.0"
dependencies = [ dependencies = [
"http", "http",
"opentelemetry", "opentelemetry",
@@ -4014,7 +4017,7 @@ dependencies = [
[[package]] [[package]]
name = "oauth2-types" name = "oauth2-types"
version = "1.7.0" version = "1.8.0"
dependencies = [ dependencies = [
"assert_matches", "assert_matches",
"base64ct", "base64ct",
@@ -5383,9 +5386,9 @@ checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
[[package]] [[package]]
name = "sentry" name = "sentry"
version = "0.45.0" version = "0.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48b85e25e8a1fc13928885e8bf13abe8a09e15c46993aed05d6405f7755d6e20" checksum = "d9794f69ad475e76c057e326175d3088509649e3aed98473106b9fe94ba59424"
dependencies = [ dependencies = [
"httpdate", "httpdate",
"reqwest", "reqwest",
@@ -5400,9 +5403,9 @@ dependencies = [
[[package]] [[package]]
name = "sentry-backtrace" name = "sentry-backtrace"
version = "0.45.0" version = "0.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3253a495ab536f6de1746a58d5d7824b77d75e08e1a4b8ca6fb356839077ae0" checksum = "e81137ad53b8592bd0935459ad74c0376053c40084aa170451e74eeea8dbc6c3"
dependencies = [ dependencies = [
"backtrace", "backtrace",
"regex", "regex",
@@ -5411,9 +5414,9 @@ dependencies = [
[[package]] [[package]]
name = "sentry-contexts" name = "sentry-contexts"
version = "0.45.0" version = "0.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "027f81a728836e66b88c07666a10f5ed5a35e2695b04eb7aa0fcbed93f814900" checksum = "cfb403c66cc2651a01b9bacda2e7c22cd51f7e8f56f206aa4310147eb3259282"
dependencies = [ dependencies = [
"hostname", "hostname",
"libc", "libc",
@@ -5425,9 +5428,9 @@ dependencies = [
[[package]] [[package]]
name = "sentry-core" name = "sentry-core"
version = "0.45.0" version = "0.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3b6729c8e71ac968edbe9bf2dd4109c162e552b52bacd2b07e24ede1aba84a5" checksum = "cfc409727ae90765ca8ea76fe6c949d6f159a11d02e130b357fa652ee9efcada"
dependencies = [ dependencies = [
"rand 0.9.2", "rand 0.9.2",
"sentry-types", "sentry-types",
@@ -5438,9 +5441,9 @@ dependencies = [
[[package]] [[package]]
name = "sentry-panic" name = "sentry-panic"
version = "0.45.0" version = "0.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ac0471f04f8f97af0c17eeca2c516e23faa1c0271a55bc64371d9ce488c2d40" checksum = "3df79f4e1e72b2a8b75a0ebf49e78709ceb9b3f0b451f13adc92a0361b0aaabe"
dependencies = [ dependencies = [
"sentry-backtrace", "sentry-backtrace",
"sentry-core", "sentry-core",
@@ -5448,9 +5451,9 @@ dependencies = [
[[package]] [[package]]
name = "sentry-tower" name = "sentry-tower"
version = "0.45.0" version = "0.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "417bd48071863a65ca5f33d15af9aabd49a5cee7f97415d3f08ce8c90ed2c531" checksum = "7eec9885bceb8ba374858d015bb6fa39dbb341d94ca088bc8f13bee2e64e2c68"
dependencies = [ dependencies = [
"axum", "axum",
"http", "http",
@@ -5463,9 +5466,9 @@ dependencies = [
[[package]] [[package]]
name = "sentry-tracing" name = "sentry-tracing"
version = "0.45.0" version = "0.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "428f780866a613142dcc81b7f8551ae4d1c056f4df22b6d7ddd9154a9974eb03" checksum = "ff2046f527fd4b75e0b6ab3bd656c67dce42072f828dc4d03c206d15dca74a93"
dependencies = [ dependencies = [
"bitflags", "bitflags",
"sentry-backtrace", "sentry-backtrace",
@@ -5476,9 +5479,9 @@ dependencies = [
[[package]] [[package]]
name = "sentry-types" name = "sentry-types"
version = "0.45.0" version = "0.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c19d1d1967b55659c358886d0f1aa3076488d445f84c7d727d384c675adaec1" checksum = "c7b9b4e4c03a4d3643c18c78b8aa91d2cbee5da047d2fa0ca4bb29bc67e6c55c"
dependencies = [ dependencies = [
"debugid", "debugid",
"hex", "hex",
@@ -6085,7 +6088,7 @@ dependencies = [
[[package]] [[package]]
name = "syn2mas" name = "syn2mas"
version = "1.7.0" version = "1.8.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"arc-swap", "arc-swap",
@@ -6508,12 +6511,12 @@ dependencies = [
[[package]] [[package]]
name = "tracing-appender" name = "tracing-appender"
version = "0.2.3" version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3566e8ce28cc0a3fe42519fc80e6b4c943cc4c8cef275620eb8dac2d3d4e06cf" checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf"
dependencies = [ dependencies = [
"crossbeam-channel", "crossbeam-channel",
"thiserror 1.0.69", "thiserror 2.0.17",
"time", "time",
"tracing-subscriber", "tracing-subscriber",
] ]

View File

@@ -9,7 +9,7 @@ members = ["crates/*"]
resolver = "2" resolver = "2"
# Updated in the CI with a `sed` command # Updated in the CI with a `sed` command
package.version = "1.7.0" package.version = "1.8.0"
package.license = "AGPL-3.0-only OR LicenseRef-Element-Commercial" package.license = "AGPL-3.0-only OR LicenseRef-Element-Commercial"
package.authors = ["Element Backend Team"] package.authors = ["Element Backend Team"]
package.edition = "2024" package.edition = "2024"
@@ -34,35 +34,35 @@ broken_intra_doc_links = "deny"
[workspace.dependencies] [workspace.dependencies]
# Workspace crates # Workspace crates
mas-axum-utils = { path = "./crates/axum-utils/", version = "=1.7.0" } mas-axum-utils = { path = "./crates/axum-utils/", version = "=1.8.0" }
mas-cli = { path = "./crates/cli/", version = "=1.7.0" } mas-cli = { path = "./crates/cli/", version = "=1.8.0" }
mas-config = { path = "./crates/config/", version = "=1.7.0" } mas-config = { path = "./crates/config/", version = "=1.8.0" }
mas-context = { path = "./crates/context/", version = "=1.7.0" } mas-context = { path = "./crates/context/", version = "=1.8.0" }
mas-data-model = { path = "./crates/data-model/", version = "=1.7.0" } mas-data-model = { path = "./crates/data-model/", version = "=1.8.0" }
mas-email = { path = "./crates/email/", version = "=1.7.0" } mas-email = { path = "./crates/email/", version = "=1.8.0" }
mas-graphql = { path = "./crates/graphql/", version = "=1.7.0" } mas-graphql = { path = "./crates/graphql/", version = "=1.8.0" }
mas-handlers = { path = "./crates/handlers/", version = "=1.7.0" } mas-handlers = { path = "./crates/handlers/", version = "=1.8.0" }
mas-http = { path = "./crates/http/", version = "=1.7.0" } mas-http = { path = "./crates/http/", version = "=1.8.0" }
mas-i18n = { path = "./crates/i18n/", version = "=1.7.0" } mas-i18n = { path = "./crates/i18n/", version = "=1.8.0" }
mas-i18n-scan = { path = "./crates/i18n-scan/", version = "=1.7.0" } mas-i18n-scan = { path = "./crates/i18n-scan/", version = "=1.8.0" }
mas-iana = { path = "./crates/iana/", version = "=1.7.0" } mas-iana = { path = "./crates/iana/", version = "=1.8.0" }
mas-iana-codegen = { path = "./crates/iana-codegen/", version = "=1.7.0" } mas-iana-codegen = { path = "./crates/iana-codegen/", version = "=1.8.0" }
mas-jose = { path = "./crates/jose/", version = "=1.7.0" } mas-jose = { path = "./crates/jose/", version = "=1.8.0" }
mas-keystore = { path = "./crates/keystore/", version = "=1.7.0" } mas-keystore = { path = "./crates/keystore/", version = "=1.8.0" }
mas-listener = { path = "./crates/listener/", version = "=1.7.0" } mas-listener = { path = "./crates/listener/", version = "=1.8.0" }
mas-matrix = { path = "./crates/matrix/", version = "=1.7.0" } mas-matrix = { path = "./crates/matrix/", version = "=1.8.0" }
mas-matrix-synapse = { path = "./crates/matrix-synapse/", version = "=1.7.0" } mas-matrix-synapse = { path = "./crates/matrix-synapse/", version = "=1.8.0" }
mas-oidc-client = { path = "./crates/oidc-client/", version = "=1.7.0" } mas-oidc-client = { path = "./crates/oidc-client/", version = "=1.8.0" }
mas-policy = { path = "./crates/policy/", version = "=1.7.0" } mas-policy = { path = "./crates/policy/", version = "=1.8.0" }
mas-router = { path = "./crates/router/", version = "=1.7.0" } mas-router = { path = "./crates/router/", version = "=1.8.0" }
mas-spa = { path = "./crates/spa/", version = "=1.7.0" } mas-spa = { path = "./crates/spa/", version = "=1.8.0" }
mas-storage = { path = "./crates/storage/", version = "=1.7.0" } mas-storage = { path = "./crates/storage/", version = "=1.8.0" }
mas-storage-pg = { path = "./crates/storage-pg/", version = "=1.7.0" } mas-storage-pg = { path = "./crates/storage-pg/", version = "=1.8.0" }
mas-tasks = { path = "./crates/tasks/", version = "=1.7.0" } mas-tasks = { path = "./crates/tasks/", version = "=1.8.0" }
mas-templates = { path = "./crates/templates/", version = "=1.7.0" } mas-templates = { path = "./crates/templates/", version = "=1.8.0" }
mas-tower = { path = "./crates/tower/", version = "=1.7.0" } mas-tower = { path = "./crates/tower/", version = "=1.8.0" }
oauth2-types = { path = "./crates/oauth2-types/", version = "=1.7.0" } oauth2-types = { path = "./crates/oauth2-types/", version = "=1.8.0" }
syn2mas = { path = "./crates/syn2mas", version = "=1.7.0" } syn2mas = { path = "./crates/syn2mas", version = "=1.8.0" }
# OpenAPI schema generation and validation # OpenAPI schema generation and validation
[workspace.dependencies.aide] [workspace.dependencies.aide]
@@ -177,7 +177,7 @@ features = ["std"]
# Utility for converting between different cases # Utility for converting between different cases
[workspace.dependencies.convert_case] [workspace.dependencies.convert_case]
version = "0.8.0" version = "0.9.0"
# CRC calculation # CRC calculation
[workspace.dependencies.crc] [workspace.dependencies.crc]
@@ -567,18 +567,18 @@ features = [
# Sentry error tracking # Sentry error tracking
[workspace.dependencies.sentry] [workspace.dependencies.sentry]
version = "0.45.0" version = "0.46.0"
default-features = false default-features = false
features = ["backtrace", "contexts", "panic", "tower", "reqwest"] features = ["backtrace", "contexts", "panic", "tower", "reqwest"]
# Sentry tower layer # Sentry tower layer
[workspace.dependencies.sentry-tower] [workspace.dependencies.sentry-tower]
version = "0.45.0" version = "0.46.0"
features = ["http", "axum-matched-path"] features = ["http", "axum-matched-path"]
# Sentry tracing integration # Sentry tracing integration
[workspace.dependencies.sentry-tracing] [workspace.dependencies.sentry-tracing]
version = "0.45.0" version = "0.46.0"
# Serialization and deserialization # Serialization and deserialization
[workspace.dependencies.serde] [workspace.dependencies.serde]
@@ -688,7 +688,7 @@ version = "0.1.41"
version = "0.3.20" version = "0.3.20"
features = ["env-filter"] features = ["env-filter"]
[workspace.dependencies.tracing-appender] [workspace.dependencies.tracing-appender]
version = "0.2.3" version = "0.2.4"
# URL manipulation # URL manipulation
[workspace.dependencies.url] [workspace.dependencies.url]

View File

@@ -12,10 +12,9 @@ use clap::Parser;
use figment::Figment; use figment::Figment;
use mas_config::{ConfigurationSection, RootConfig, SyncConfig}; use mas_config::{ConfigurationSection, RootConfig, SyncConfig};
use mas_data_model::{Clock as _, SystemClock}; use mas_data_model::{Clock as _, SystemClock};
use mas_storage_pg::MIGRATOR;
use rand::SeedableRng; use rand::SeedableRng;
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
use tracing::{Instrument, info, info_span}; use tracing::{info, info_span};
use crate::util::database_connection_from_config; use crate::util::database_connection_from_config;
@@ -129,9 +128,7 @@ impl Options {
// Grab a connection to the database // Grab a connection to the database
let mut conn = database_connection_from_config(&config.database).await?; let mut conn = database_connection_from_config(&config.database).await?;
MIGRATOR mas_storage_pg::migrate(&mut conn)
.run(&mut conn)
.instrument(info_span!("db.migrate"))
.await .await
.context("could not run migrations")?; .context("could not run migrations")?;

View File

@@ -10,8 +10,7 @@ use anyhow::Context;
use clap::Parser; use clap::Parser;
use figment::Figment; use figment::Figment;
use mas_config::{ConfigurationSectionExt, DatabaseConfig}; use mas_config::{ConfigurationSectionExt, DatabaseConfig};
use mas_storage_pg::MIGRATOR; use tracing::info_span;
use tracing::{Instrument, info_span};
use crate::util::database_connection_from_config; use crate::util::database_connection_from_config;
@@ -35,9 +34,7 @@ impl Options {
let mut conn = database_connection_from_config(&config).await?; let mut conn = database_connection_from_config(&config).await?;
// Run pending migrations // Run pending migrations
MIGRATOR mas_storage_pg::migrate(&mut conn)
.run(&mut conn)
.instrument(info_span!("db.migrate"))
.await .await
.context("could not run migrations")?; .context("could not run migrations")?;

View File

@@ -4,7 +4,7 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details. // Please see LICENSE files in the repository root for full details.
use std::{collections::BTreeSet, process::ExitCode, sync::Arc, time::Duration}; use std::{process::ExitCode, sync::Arc, time::Duration};
use anyhow::Context; use anyhow::Context;
use clap::Parser; use clap::Parser;
@@ -18,9 +18,8 @@ use mas_data_model::SystemClock;
use mas_handlers::{ActivityTracker, CookieManager, Limiter, MetadataCache}; use mas_handlers::{ActivityTracker, CookieManager, Limiter, MetadataCache};
use mas_listener::server::Server; use mas_listener::server::Server;
use mas_router::UrlBuilder; use mas_router::UrlBuilder;
use mas_storage_pg::{MIGRATOR, PgRepositoryFactory}; use mas_storage_pg::PgRepositoryFactory;
use sqlx::migrate::Migrate; use tracing::{info, info_span, warn};
use tracing::{Instrument, info, info_span, warn};
use crate::{ use crate::{
app_state::AppState, app_state::AppState,
@@ -73,24 +72,20 @@ impl Options {
let pool = database_pool_from_config(&config.database).await?; let pool = database_pool_from_config(&config.database).await?;
if self.no_migrate { if self.no_migrate {
// Check that we applied all the migrations
let mut conn = pool.acquire().await?; let mut conn = pool.acquire().await?;
let applied = conn.list_applied_migrations().await?; let pending_migrations = mas_storage_pg::pending_migrations(&mut conn).await?;
let applied: BTreeSet<_> = applied.into_iter().map(|m| m.version).collect(); if !pending_migrations.is_empty() {
let has_missing_migrations = MIGRATOR.iter().any(|m| !applied.contains(&m.version));
if has_missing_migrations {
// Refuse to start if there are pending migrations // Refuse to start if there are pending migrations
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
"The server is running with `--no-migrate` but there are pending. Please run them first with `mas-cli database migrate`, or omit the `--no-migrate` flag to apply them automatically on startup." "The server is running with `--no-migrate` but there are pending migrations. Please run them first with `mas-cli database migrate`, or omit the `--no-migrate` flag to apply them automatically on startup."
)); ));
} }
} else { } else {
info!("Running pending database migrations"); info!("Running pending database migrations");
MIGRATOR let mut conn = pool.acquire().await?;
.run(&pool) mas_storage_pg::migrate(&mut conn)
.instrument(info_span!("db.migrate"))
.await .await
.context("could not run database migrations")?; .context("could not run migrations")?;
} }
let encrypter = config.secrets.encrypter().await?; let encrypter = config.secrets.encrypter().await?;

View File

@@ -14,13 +14,12 @@ use mas_config::{
UpstreamOAuth2Config, UpstreamOAuth2Config,
}; };
use mas_data_model::SystemClock; use mas_data_model::SystemClock;
use mas_storage_pg::MIGRATOR;
use rand::thread_rng; use rand::thread_rng;
use sqlx::{Connection, Either, PgConnection, postgres::PgConnectOptions, types::Uuid}; use sqlx::{Connection, Either, PgConnection, postgres::PgConnectOptions, types::Uuid};
use syn2mas::{ use syn2mas::{
LockedMasDatabase, MasWriter, Progress, ProgressStage, SynapseReader, synapse_config, LockedMasDatabase, MasWriter, Progress, ProgressStage, SynapseReader, synapse_config,
}; };
use tracing::{Instrument, error, info, info_span}; use tracing::{Instrument, error, info};
use crate::util::{DatabaseConnectOptions, database_connection_from_config_with_options}; use crate::util::{DatabaseConnectOptions, database_connection_from_config_with_options};
@@ -122,9 +121,7 @@ impl Options {
) )
.await?; .await?;
MIGRATOR mas_storage_pg::migrate(&mut mas_connection)
.run(&mut mas_connection)
.instrument(info_span!("db.migrate"))
.await .await
.context("could not run migrations")?; .context("could not run migrations")?;

View File

@@ -45,6 +45,12 @@ fn map_import_on_conflict(
mas_config::UpstreamOAuth2OnConflict::Add => { mas_config::UpstreamOAuth2OnConflict::Add => {
mas_data_model::UpstreamOAuthProviderOnConflict::Add mas_data_model::UpstreamOAuthProviderOnConflict::Add
} }
mas_config::UpstreamOAuth2OnConflict::Replace => {
mas_data_model::UpstreamOAuthProviderOnConflict::Replace
}
mas_config::UpstreamOAuth2OnConflict::Set => {
mas_data_model::UpstreamOAuthProviderOnConflict::Set
}
mas_config::UpstreamOAuth2OnConflict::Fail => { mas_config::UpstreamOAuth2OnConflict::Fail => {
mas_data_model::UpstreamOAuthProviderOnConflict::Fail mas_data_model::UpstreamOAuthProviderOnConflict::Fail
} }
@@ -58,6 +64,7 @@ fn map_claims_imports(
subject: mas_data_model::UpstreamOAuthProviderSubjectPreference { subject: mas_data_model::UpstreamOAuthProviderSubjectPreference {
template: config.subject.template.clone(), template: config.subject.template.clone(),
}, },
skip_confirmation: config.skip_confirmation,
localpart: mas_data_model::UpstreamOAuthProviderLocalpartPreference { localpart: mas_data_model::UpstreamOAuthProviderLocalpartPreference {
action: map_import_action(config.localpart.action), action: map_import_action(config.localpart.action),
template: config.localpart.template.clone(), template: config.localpart.template.clone(),

View File

@@ -145,6 +145,7 @@ pub async fn policy_factory_from_config(
register: config.register_entrypoint.clone(), register: config.register_entrypoint.clone(),
client_registration: config.client_registration_entrypoint.clone(), client_registration: config.client_registration_entrypoint.clone(),
authorization_grant: config.authorization_grant_entrypoint.clone(), authorization_grant: config.authorization_grant_entrypoint.clone(),
compat_login: config.compat_login_entrypoint.clone(),
email: config.email_entrypoint.clone(), email: config.email_entrypoint.clone(),
}; };

View File

@@ -62,6 +62,14 @@ fn is_default_password_entrypoint(value: &String) -> bool {
*value == default_password_entrypoint() *value == default_password_entrypoint()
} }
fn default_compat_login_entrypoint() -> String {
"compat_login/violation".to_owned()
}
fn is_default_compat_login_entrypoint(value: &String) -> bool {
*value == default_compat_login_entrypoint()
}
fn default_email_entrypoint() -> String { fn default_email_entrypoint() -> String {
"email/violation".to_owned() "email/violation".to_owned()
} }
@@ -111,6 +119,13 @@ pub struct PolicyConfig {
)] )]
pub authorization_grant_entrypoint: String, pub authorization_grant_entrypoint: String,
/// Entrypoint to use when evaluating compatibility logins
#[serde(
default = "default_compat_login_entrypoint",
skip_serializing_if = "is_default_compat_login_entrypoint"
)]
pub compat_login_entrypoint: String,
/// Entrypoint to use when changing password /// Entrypoint to use when changing password
#[serde( #[serde(
default = "default_password_entrypoint", default = "default_password_entrypoint",
@@ -137,6 +152,7 @@ impl Default for PolicyConfig {
client_registration_entrypoint: default_client_registration_entrypoint(), client_registration_entrypoint: default_client_registration_entrypoint(),
register_entrypoint: default_register_entrypoint(), register_entrypoint: default_register_entrypoint(),
authorization_grant_entrypoint: default_authorization_grant_entrypoint(), authorization_grant_entrypoint: default_authorization_grant_entrypoint(),
compat_login_entrypoint: default_compat_login_entrypoint(),
password_entrypoint: default_password_entrypoint(), password_entrypoint: default_password_entrypoint(),
email_entrypoint: default_email_entrypoint(), email_entrypoint: default_email_entrypoint(),
data: default_data(), data: default_data(),

View File

@@ -118,16 +118,36 @@ impl ConfigurationSection for UpstreamOAuth2Config {
} }
} }
if provider.claims_imports.skip_confirmation {
if provider.claims_imports.localpart.action != ImportAction::Require {
return Err(annotate(figment::Error::custom(
"The field `action` must be `require` when `skip_confirmation` is set to `true`",
)).with_path("claims_imports.localpart").into());
}
if provider.claims_imports.email.action == ImportAction::Suggest {
return Err(annotate(figment::Error::custom(
"The field `action` must not be `suggest` when `skip_confirmation` is set to `true`",
)).with_path("claims_imports.email").into());
}
if provider.claims_imports.displayname.action == ImportAction::Suggest {
return Err(annotate(figment::Error::custom(
"The field `action` must not be `suggest` when `skip_confirmation` is set to `true`",
)).with_path("claims_imports.displayname").into());
}
}
if matches!( if matches!(
provider.claims_imports.localpart.on_conflict, provider.claims_imports.localpart.on_conflict,
OnConflict::Add OnConflict::Add | OnConflict::Replace | OnConflict::Set
) && !matches!( ) && !matches!(
provider.claims_imports.localpart.action, provider.claims_imports.localpart.action,
ImportAction::Force | ImportAction::Require ImportAction::Force | ImportAction::Require
) { ) {
return Err(annotate(figment::Error::custom( return Err(annotate(figment::Error::custom(
"The field `action` must be either `force` or `require` when `on_conflict` is set to `add`", "The field `action` must be either `force` or `require` when `on_conflict` is set to `add`, `replace` or `set`",
)).into()); )).with_path("claims_imports.localpart").into());
} }
} }
@@ -206,13 +226,20 @@ impl ImportAction {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum OnConflict { pub enum OnConflict {
/// Fails the sso login on conflict /// Fails the upstream OAuth 2.0 login on conflict
#[default] #[default]
Fail, Fail,
/// Adds the oauth identity link, regardless of whether there is an existing /// Adds the upstream OAuth 2.0 identity link, regardless of whether there
/// link or not /// is an existing link or not
Add, Add,
/// Replace any existing upstream OAuth 2.0 identity link
Replace,
/// Adds the upstream OAuth 2.0 identity link *only* if there is no existing
/// link for this provider on the matching user
Set,
} }
impl OnConflict { impl OnConflict {
@@ -326,6 +353,13 @@ pub struct ClaimsImports {
#[serde(default, skip_serializing_if = "SubjectImportPreference::is_default")] #[serde(default, skip_serializing_if = "SubjectImportPreference::is_default")]
pub subject: SubjectImportPreference, pub subject: SubjectImportPreference,
/// Whether to skip the interactive screen prompting the user to confirm the
/// attributes that are being imported. This requires `localpart.action` to
/// be `require` and other attribute actions to be either `ignore`, `force`
/// or `require`
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub skip_confirmation: bool,
/// Import the localpart of the MXID /// Import the localpart of the MXID
#[serde(default, skip_serializing_if = "LocalpartImportPreference::is_default")] #[serde(default, skip_serializing_if = "LocalpartImportPreference::is_default")]
pub localpart: LocalpartImportPreference, pub localpart: LocalpartImportPreference,
@@ -337,8 +371,7 @@ pub struct ClaimsImports {
)] )]
pub displayname: DisplaynameImportPreference, pub displayname: DisplaynameImportPreference,
/// Import the email address of the user based on the `email` and /// Import the email address of the user
/// `email_verified` claims
#[serde(default, skip_serializing_if = "EmailImportPreference::is_default")] #[serde(default, skip_serializing_if = "EmailImportPreference::is_default")]
pub email: EmailImportPreference, pub email: EmailImportPreference,
@@ -354,8 +387,10 @@ impl ClaimsImports {
const fn is_default(&self) -> bool { const fn is_default(&self) -> bool {
self.subject.is_default() self.subject.is_default()
&& self.localpart.is_default() && self.localpart.is_default()
&& !self.skip_confirmation
&& self.displayname.is_default() && self.displayname.is_default()
&& self.email.is_default() && self.email.is_default()
&& self.account_name.is_default()
} }
} }

View File

@@ -56,8 +56,8 @@ pub use self::{
}, },
user_agent::{DeviceType, UserAgent}, user_agent::{DeviceType, UserAgent},
users::{ users::{
Authentication, AuthenticationMethod, BrowserSession, Password, User, UserEmail, Authentication, AuthenticationMethod, BrowserSession, MatrixUser, Password, User,
UserEmailAuthentication, UserEmailAuthenticationCode, UserRecoverySession, UserEmail, UserEmailAuthentication, UserEmailAuthenticationCode, UserRecoverySession,
UserRecoveryTicket, UserRegistration, UserRegistrationPassword, UserRegistrationToken, UserRecoveryTicket, UserRegistration, UserRegistrationPassword, UserRegistrationToken,
}, },
utils::{BoxClock, BoxRng}, utils::{BoxClock, BoxRng},

View File

@@ -312,6 +312,9 @@ pub struct ClaimsImports {
#[serde(default)] #[serde(default)]
pub subject: SubjectPreference, pub subject: SubjectPreference,
#[serde(default)]
pub skip_confirmation: bool,
#[serde(default)] #[serde(default)]
pub localpart: LocalpartPreference, pub localpart: LocalpartPreference,
@@ -415,11 +418,18 @@ impl ImportAction {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum OnConflict { pub enum OnConflict {
/// Fails the upstream OAuth 2.0 login /// Fails the upstream OAuth 2.0 login on conflict
#[default] #[default]
Fail, Fail,
/// Adds the upstream account link, regardless of whether there is an /// Adds the upstream OAuth 2.0 identity link, regardless of whether there
/// existing link or not /// is an existing link or not
Add, Add,
/// Replace any existing upstream OAuth 2.0 identity link
Replace,
/// Adds the upstream OAuth 2.0 identity link *only* if there is no existing
/// link for this provider on the matching user
Set,
} }

View File

@@ -12,6 +12,12 @@ use serde::Serialize;
use ulid::Ulid; use ulid::Ulid;
use url::Url; use url::Url;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct MatrixUser {
pub mxid: String,
pub display_name: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct User { pub struct User {
pub id: Ulid, pub id: Ulid,

View File

@@ -16,6 +16,7 @@ use mas_data_model::{
User, User,
}; };
use mas_matrix::HomeserverConnection; use mas_matrix::HomeserverConnection;
use mas_policy::{Policy, Requester, ViolationCode, model::CompatLogin};
use mas_storage::{ use mas_storage::{
BoxRepository, BoxRepositoryFactory, RepositoryAccess, BoxRepository, BoxRepositoryFactory, RepositoryAccess,
compat::{ compat::{
@@ -37,6 +38,7 @@ use crate::{
BoundActivityTracker, Limiter, METER, RequesterFingerprint, impl_from_error_for_route, BoundActivityTracker, Limiter, METER, RequesterFingerprint, impl_from_error_for_route,
passwords::{PasswordManager, PasswordVerificationResult}, passwords::{PasswordManager, PasswordVerificationResult},
rate_limit::PasswordCheckLimitedError, rate_limit::PasswordCheckLimitedError,
session::count_user_sessions_for_limiting,
}; };
static LOGIN_COUNTER: LazyLock<Counter<u64>> = LazyLock::new(|| { static LOGIN_COUNTER: LazyLock<Counter<u64>> = LazyLock::new(|| {
@@ -213,9 +215,16 @@ pub enum RouteError {
#[error("failed to provision device")] #[error("failed to provision device")]
ProvisionDeviceFailed(#[source] anyhow::Error), ProvisionDeviceFailed(#[source] anyhow::Error),
#[error("login rejected by policy")]
PolicyRejected,
#[error("login rejected by policy (hard session limit reached)")]
PolicyHardSessionLimitReached,
} }
impl_from_error_for_route!(mas_storage::RepositoryError); impl_from_error_for_route!(mas_storage::RepositoryError);
impl_from_error_for_route!(mas_policy::EvaluationError);
impl From<anyhow::Error> for RouteError { impl From<anyhow::Error> for RouteError {
fn from(err: anyhow::Error) -> Self { fn from(err: anyhow::Error) -> Self {
@@ -274,6 +283,16 @@ impl IntoResponse for RouteError {
error: "User account has been locked", error: "User account has been locked",
status: StatusCode::UNAUTHORIZED, status: StatusCode::UNAUTHORIZED,
}, },
Self::PolicyRejected => MatrixError {
errcode: "M_FORBIDDEN",
error: "Login denied by the policy enforced by this service",
status: StatusCode::FORBIDDEN,
},
Self::PolicyHardSessionLimitReached => MatrixError {
errcode: "M_FORBIDDEN",
error: "You have reached your hard device limit. Please visit your account page to sign some out.",
status: StatusCode::FORBIDDEN,
},
}; };
(sentry_event_id, response).into_response() (sentry_event_id, response).into_response()
@@ -290,6 +309,7 @@ pub(crate) async fn post(
State(homeserver): State<Arc<dyn HomeserverConnection>>, State(homeserver): State<Arc<dyn HomeserverConnection>>,
State(site_config): State<SiteConfig>, State(site_config): State<SiteConfig>,
State(limiter): State<Limiter>, State(limiter): State<Limiter>,
mut policy: Policy,
requester: RequesterFingerprint, requester: RequesterFingerprint,
user_agent: Option<TypedHeader<headers::UserAgent>>, user_agent: Option<TypedHeader<headers::UserAgent>>,
MatrixJsonBody(input): MatrixJsonBody<RequestBody>, MatrixJsonBody(input): MatrixJsonBody<RequestBody>,
@@ -329,6 +349,11 @@ pub(crate) async fn post(
&limiter, &limiter,
requester, requester,
&mut repo, &mut repo,
&mut policy,
Requester {
ip_address: activity_tracker.ip(),
user_agent: user_agent.clone(),
},
username, username,
password, password,
input.device_id, // TODO check for validity input.device_id, // TODO check for validity
@@ -342,6 +367,11 @@ pub(crate) async fn post(
&mut rng, &mut rng,
&clock, &clock,
&mut repo, &mut repo,
&mut policy,
Requester {
ip_address: activity_tracker.ip(),
user_agent: user_agent.clone(),
},
&token, &token,
input.device_id, input.device_id,
input.initial_device_display_name, input.initial_device_display_name,
@@ -459,6 +489,8 @@ async fn token_login(
rng: &mut (dyn RngCore + Send), rng: &mut (dyn RngCore + Send),
clock: &dyn Clock, clock: &dyn Clock,
repo: &mut BoxRepository, repo: &mut BoxRepository,
policy: &mut Policy,
requester: Requester,
token: &str, token: &str,
requested_device_id: Option<String>, requested_device_id: Option<String>,
initial_device_display_name: Option<String>, initial_device_display_name: Option<String>,
@@ -544,10 +576,38 @@ async fn token_login(
Device::generate(rng) Device::generate(rng)
}; };
repo.app_session() let session_replaced = repo
.app_session()
.finish_sessions_to_replace_device(clock, &browser_session.user, &device) .finish_sessions_to_replace_device(clock, &browser_session.user, &device)
.await?; .await?;
let session_counts = count_user_sessions_for_limiting(repo, &browser_session.user).await?;
let res = policy
.evaluate_compat_login(mas_policy::CompatLoginInput {
user: &browser_session.user,
login: CompatLogin::Token,
session_replaced,
session_counts,
requester,
})
.await?;
if !res.valid() {
// If the only violation is that we have too many sessions, then handle that
// separately.
// In the future, we intend to evict some sessions automatically instead. We
// don't trigger this if there was some other violation anyway, since that means
// that removing a session wouldn't actually unblock the login.
if res.violations.len() == 1 {
let violation = &res.violations[0];
if violation.code == Some(ViolationCode::TooManySessions) {
// The only violation is having reached the session limit.
return Err(RouteError::PolicyHardSessionLimitReached);
}
}
return Err(RouteError::PolicyRejected);
}
// We first create the session in the database, commit the transaction, then // We first create the session in the database, commit the transaction, then
// create it on the homeserver, scheduling a device sync job afterwards to // create it on the homeserver, scheduling a device sync job afterwards to
// make sure we don't end up in an inconsistent state. // make sure we don't end up in an inconsistent state.
@@ -578,6 +638,8 @@ async fn user_password_login(
limiter: &Limiter, limiter: &Limiter,
requester: RequesterFingerprint, requester: RequesterFingerprint,
repo: &mut BoxRepository, repo: &mut BoxRepository,
policy: &mut Policy,
policy_requester: Requester,
username: &str, username: &str,
password: String, password: String,
requested_device_id: Option<String>, requested_device_id: Option<String>,
@@ -647,10 +709,38 @@ async fn user_password_login(
Device::generate(&mut rng) Device::generate(&mut rng)
}; };
repo.app_session() let session_replaced = repo
.app_session()
.finish_sessions_to_replace_device(clock, &user, &device) .finish_sessions_to_replace_device(clock, &user, &device)
.await?; .await?;
let session_counts = count_user_sessions_for_limiting(repo, &user).await?;
let res = policy
.evaluate_compat_login(mas_policy::CompatLoginInput {
user: &user,
login: CompatLogin::Password,
session_replaced,
session_counts,
requester: policy_requester,
})
.await?;
if !res.valid() {
// If the only violation is that we have too many sessions, then handle that
// separately.
// In the future, we intend to evict some sessions automatically instead. We
// don't trigger this if there was some other violation anyway, since that means
// that removing a session wouldn't actually unblock the login.
if res.violations.len() == 1 {
let violation = &res.violations[0];
if violation.code == Some(ViolationCode::TooManySessions) {
// The only violation is having reached the session limit.
return Err(RouteError::PolicyHardSessionLimitReached);
}
}
return Err(RouteError::PolicyRejected);
}
let session = repo let session = repo
.compat_session() .compat_session()
.add( .add(

View File

@@ -4,30 +4,35 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details. // Please see LICENSE files in the repository root for full details.
use std::collections::HashMap; use std::{collections::HashMap, sync::Arc};
use anyhow::Context; use anyhow::Context;
use axum::{ use axum::{
extract::{Form, Path, State}, extract::{Form, Path, State},
response::{Html, IntoResponse, Redirect, Response}, response::{Html, IntoResponse, Redirect, Response},
}; };
use axum_extra::extract::Query; use axum_extra::{TypedHeader, extract::Query};
use chrono::Duration; use chrono::Duration;
use hyper::StatusCode;
use mas_axum_utils::{ use mas_axum_utils::{
InternalError, InternalError,
cookies::CookieJar, cookies::CookieJar,
csrf::{CsrfExt, ProtectedForm}, csrf::{CsrfExt, ProtectedForm},
}; };
use mas_data_model::{BoxClock, BoxRng, Clock}; use mas_data_model::{BoxClock, BoxRng, Clock, MatrixUser};
use mas_matrix::HomeserverConnection;
use mas_policy::{Policy, model::CompatLogin};
use mas_router::{CompatLoginSsoAction, UrlBuilder}; use mas_router::{CompatLoginSsoAction, UrlBuilder};
use mas_storage::{BoxRepository, RepositoryAccess, compat::CompatSsoLoginRepository}; use mas_storage::{BoxRepository, RepositoryAccess, compat::CompatSsoLoginRepository};
use mas_templates::{CompatSsoContext, ErrorContext, TemplateContext, Templates}; use mas_templates::{
CompatLoginPolicyViolationContext, CompatSsoContext, ErrorContext, TemplateContext, Templates,
};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use ulid::Ulid; use ulid::Ulid;
use crate::{ use crate::{
PreferredLanguage, BoundActivityTracker, PreferredLanguage,
session::{SessionOrFallback, load_session_or_fallback}, session::{SessionOrFallback, count_user_sessions_for_limiting, load_session_or_fallback},
}; };
#[derive(Serialize)] #[derive(Serialize)]
@@ -56,10 +61,16 @@ pub async fn get(
mut repo: BoxRepository, mut repo: BoxRepository,
State(templates): State<Templates>, State(templates): State<Templates>,
State(url_builder): State<UrlBuilder>, State(url_builder): State<UrlBuilder>,
State(homeserver): State<Arc<dyn HomeserverConnection>>,
mut policy: Policy,
activity_tracker: BoundActivityTracker,
user_agent: Option<TypedHeader<headers::UserAgent>>,
cookie_jar: CookieJar, cookie_jar: CookieJar,
Path(id): Path<Ulid>, Path(id): Path<Ulid>,
Query(params): Query<Params>, Query(params): Query<Params>,
) -> Result<Response, InternalError> { ) -> Result<Response, InternalError> {
let user_agent = user_agent.map(|ua| ua.to_string());
let (cookie_jar, maybe_session) = match load_session_or_fallback( let (cookie_jar, maybe_session) = match load_session_or_fallback(
cookie_jar, &clock, &mut rng, &templates, &locale, &mut repo, cookie_jar, &clock, &mut rng, &templates, &locale, &mut repo,
) )
@@ -107,7 +118,69 @@ pub async fn get(
return Ok((cookie_jar, Html(content)).into_response()); return Ok((cookie_jar, Html(content)).into_response());
} }
let ctx = CompatSsoContext::new(login) let session_counts = count_user_sessions_for_limiting(&mut repo, &session.user).await?;
// We can close the repository early, we don't need it at this point
repo.save().await?;
let res = policy
.evaluate_compat_login(mas_policy::CompatLoginInput {
user: &session.user,
login: CompatLogin::Sso {
redirect_uri: login.redirect_uri.to_string(),
},
// We don't know if there's going to be a replacement until we received the device ID,
// which happens too late.
session_replaced: false,
session_counts,
requester: mas_policy::Requester {
ip_address: activity_tracker.ip(),
user_agent,
},
})
.await?;
if !res.valid() {
let ctx = CompatLoginPolicyViolationContext::for_violations(res.violations)
.with_session(session)
.with_csrf(csrf_token.form_value())
.with_language(locale);
let content = templates.render_compat_login_policy_violation(&ctx)?;
return Ok((StatusCode::FORBIDDEN, cookie_jar, Html(content)).into_response());
}
// Fetch informations about the user. This is purely cosmetic, so we let it
// fail and put a 1s timeout to it in case we fail to query it
// XXX: we're likely to need this in other places
let localpart = &session.user.username;
let display_name = match tokio::time::timeout(
std::time::Duration::from_secs(1),
homeserver.query_user(localpart),
)
.await
{
Ok(Ok(user)) => user.displayname,
Ok(Err(err)) => {
tracing::warn!(
error = &*err as &dyn std::error::Error,
localpart,
"Failed to query user"
);
None
}
Err(_) => {
tracing::warn!(localpart, "Timed out while querying user");
None
}
};
let matrix_user = MatrixUser {
mxid: homeserver.mxid(localpart),
display_name,
};
let ctx = CompatSsoContext::new(login, matrix_user)
.with_session(session) .with_session(session)
.with_csrf(csrf_token.form_value()) .with_csrf(csrf_token.form_value())
.with_language(locale); .with_language(locale);
@@ -129,11 +202,16 @@ pub async fn post(
PreferredLanguage(locale): PreferredLanguage, PreferredLanguage(locale): PreferredLanguage,
State(templates): State<Templates>, State(templates): State<Templates>,
State(url_builder): State<UrlBuilder>, State(url_builder): State<UrlBuilder>,
mut policy: Policy,
activity_tracker: BoundActivityTracker,
user_agent: Option<TypedHeader<headers::UserAgent>>,
cookie_jar: CookieJar, cookie_jar: CookieJar,
Path(id): Path<Ulid>, Path(id): Path<Ulid>,
Query(params): Query<Params>, Query(params): Query<Params>,
Form(form): Form<ProtectedForm<()>>, Form(form): Form<ProtectedForm<()>>,
) -> Result<Response, InternalError> { ) -> Result<Response, InternalError> {
let user_agent = user_agent.map(|ua| ua.to_string());
let (cookie_jar, maybe_session) = match load_session_or_fallback( let (cookie_jar, maybe_session) = match load_session_or_fallback(
cookie_jar, &clock, &mut rng, &templates, &locale, &mut repo, cookie_jar, &clock, &mut rng, &templates, &locale, &mut repo,
) )
@@ -200,6 +278,37 @@ pub async fn post(
redirect_uri redirect_uri
}; };
let session_counts = count_user_sessions_for_limiting(&mut repo, &session.user).await?;
let res = policy
.evaluate_compat_login(mas_policy::CompatLoginInput {
user: &session.user,
login: CompatLogin::Sso {
redirect_uri: login.redirect_uri.to_string(),
},
session_counts,
// We don't know if there's going to be a replacement until we received the device ID,
// which happens too late.
session_replaced: false,
requester: mas_policy::Requester {
ip_address: activity_tracker.ip(),
user_agent,
},
})
.await?;
if !res.valid() {
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
let ctx = CompatLoginPolicyViolationContext::for_violations(res.violations)
.with_session(session)
.with_csrf(csrf_token.form_value())
.with_language(locale);
let content = templates.render_compat_login_policy_violation(&ctx)?;
return Ok((StatusCode::FORBIDDEN, cookie_jar, Html(content)).into_response());
}
// Note that if the login is not Pending, // Note that if the login is not Pending,
// this fails and aborts the transaction. // this fails and aborts the transaction.
repo.compat_sso_login() repo.compat_sso_login()

View File

@@ -272,6 +272,7 @@ where
BoxRepository: FromRequestParts<S>, BoxRepository: FromRequestParts<S>,
BoxClock: FromRequestParts<S>, BoxClock: FromRequestParts<S>,
BoxRng: FromRequestParts<S>, BoxRng: FromRequestParts<S>,
Policy: FromRequestParts<S>,
{ {
// A sub-router for human-facing routes with error handling // A sub-router for human-facing routes with error handling
let human_router = Router::new() let human_router = Router::new()

View File

@@ -4,6 +4,8 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details. // Please see LICENSE files in the repository root for full details.
use std::{sync::Arc, time::Duration};
use axum::{ use axum::{
extract::{Form, Path, State}, extract::{Form, Path, State},
response::{Html, IntoResponse, Response}, response::{Html, IntoResponse, Response},
@@ -15,8 +17,9 @@ use mas_axum_utils::{
cookies::CookieJar, cookies::CookieJar,
csrf::{CsrfExt, ProtectedForm}, csrf::{CsrfExt, ProtectedForm},
}; };
use mas_data_model::{AuthorizationGrantStage, BoxClock, BoxRng}; use mas_data_model::{AuthorizationGrantStage, BoxClock, BoxRng, MatrixUser};
use mas_keystore::Keystore; use mas_keystore::Keystore;
use mas_matrix::HomeserverConnection;
use mas_policy::Policy; use mas_policy::Policy;
use mas_router::{PostAuthAction, UrlBuilder}; use mas_router::{PostAuthAction, UrlBuilder};
use mas_storage::{ use mas_storage::{
@@ -87,6 +90,7 @@ pub(crate) async fn get(
PreferredLanguage(locale): PreferredLanguage, PreferredLanguage(locale): PreferredLanguage,
State(templates): State<Templates>, State(templates): State<Templates>,
State(url_builder): State<UrlBuilder>, State(url_builder): State<UrlBuilder>,
State(homeserver): State<Arc<dyn HomeserverConnection>>,
mut policy: Policy, mut policy: Policy,
mut repo: BoxRepository, mut repo: BoxRepository,
activity_tracker: BoundActivityTracker, activity_tracker: BoundActivityTracker,
@@ -138,6 +142,9 @@ pub(crate) async fn get(
let session_counts = count_user_sessions_for_limiting(&mut repo, &session.user).await?; let session_counts = count_user_sessions_for_limiting(&mut repo, &session.user).await?;
// We can close the repository early, we don't need it at this point
repo.save().await?;
let res = policy let res = policy
.evaluate_authorization_grant(mas_policy::AuthorizationGrantInput { .evaluate_authorization_grant(mas_policy::AuthorizationGrantInput {
user: Some(&session.user), user: Some(&session.user),
@@ -162,7 +169,37 @@ pub(crate) async fn get(
return Ok((cookie_jar, Html(content)).into_response()); return Ok((cookie_jar, Html(content)).into_response());
} }
let ctx = ConsentContext::new(grant, client) // Fetch informations about the user. This is purely cosmetic, so we let it
// fail and put a 1s timeout to it in case we fail to query it
// XXX: we're likely to need this in other places
let localpart = &session.user.username;
let display_name = match tokio::time::timeout(
Duration::from_secs(1),
homeserver.query_user(localpart),
)
.await
{
Ok(Ok(user)) => user.displayname,
Ok(Err(err)) => {
tracing::warn!(
error = &*err as &dyn std::error::Error,
localpart,
"Failed to query user"
);
None
}
Err(_) => {
tracing::warn!(localpart, "Timed out while querying user");
None
}
};
let matrix_user = MatrixUser {
mxid: homeserver.mxid(localpart),
display_name,
};
let ctx = ConsentContext::new(grant, client, matrix_user)
.with_session(session) .with_session(session)
.with_csrf(csrf_token.form_value()) .with_csrf(csrf_token.form_value())
.with_language(locale); .with_language(locale);

View File

@@ -4,6 +4,8 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details. // Please see LICENSE files in the repository root for full details.
use std::{sync::Arc, time::Duration};
use anyhow::Context; use anyhow::Context;
use axum::{ use axum::{
Form, Form,
@@ -16,7 +18,8 @@ use mas_axum_utils::{
cookies::CookieJar, cookies::CookieJar,
csrf::{CsrfExt, ProtectedForm}, csrf::{CsrfExt, ProtectedForm},
}; };
use mas_data_model::{BoxClock, BoxRng}; use mas_data_model::{BoxClock, BoxRng, MatrixUser};
use mas_matrix::HomeserverConnection;
use mas_policy::Policy; use mas_policy::Policy;
use mas_router::UrlBuilder; use mas_router::UrlBuilder;
use mas_storage::BoxRepository; use mas_storage::BoxRepository;
@@ -49,6 +52,7 @@ pub(crate) async fn get(
PreferredLanguage(locale): PreferredLanguage, PreferredLanguage(locale): PreferredLanguage,
State(templates): State<Templates>, State(templates): State<Templates>,
State(url_builder): State<UrlBuilder>, State(url_builder): State<UrlBuilder>,
State(homeserver): State<Arc<dyn HomeserverConnection>>,
mut repo: BoxRepository, mut repo: BoxRepository,
mut policy: Policy, mut policy: Policy,
activity_tracker: BoundActivityTracker, activity_tracker: BoundActivityTracker,
@@ -105,6 +109,9 @@ pub(crate) async fn get(
let session_counts = count_user_sessions_for_limiting(&mut repo, &session.user).await?; let session_counts = count_user_sessions_for_limiting(&mut repo, &session.user).await?;
// We can close the repository early, we don't need it at this point
repo.save().await?;
// Evaluate the policy // Evaluate the policy
let res = policy let res = policy
.evaluate_authorization_grant(mas_policy::AuthorizationGrantInput { .evaluate_authorization_grant(mas_policy::AuthorizationGrantInput {
@@ -133,7 +140,37 @@ pub(crate) async fn get(
return Ok((cookie_jar, Html(content)).into_response()); return Ok((cookie_jar, Html(content)).into_response());
} }
let ctx = DeviceConsentContext::new(grant, client) // Fetch informations about the user. This is purely cosmetic, so we let it
// fail and put a 1s timeout to it in case we fail to query it
// XXX: we're likely to need this in other places
let localpart = &session.user.username;
let display_name = match tokio::time::timeout(
Duration::from_secs(1),
homeserver.query_user(localpart),
)
.await
{
Ok(Ok(user)) => user.displayname,
Ok(Err(err)) => {
tracing::warn!(
error = &*err as &dyn std::error::Error,
localpart,
"Failed to query user"
);
None
}
Err(_) => {
tracing::warn!(localpart, "Timed out while querying user");
None
}
};
let matrix_user = MatrixUser {
mxid: homeserver.mxid(localpart),
display_name,
};
let ctx = DeviceConsentContext::new(grant, client, matrix_user)
.with_session(session) .with_session(session)
.with_csrf(csrf_token.form_value()) .with_csrf(csrf_token.form_value())
.with_language(locale); .with_language(locale);
@@ -153,6 +190,7 @@ pub(crate) async fn post(
PreferredLanguage(locale): PreferredLanguage, PreferredLanguage(locale): PreferredLanguage,
State(templates): State<Templates>, State(templates): State<Templates>,
State(url_builder): State<UrlBuilder>, State(url_builder): State<UrlBuilder>,
State(homeserver): State<Arc<dyn HomeserverConnection>>,
mut repo: BoxRepository, mut repo: BoxRepository,
mut policy: Policy, mut policy: Policy,
activity_tracker: BoundActivityTracker, activity_tracker: BoundActivityTracker,
@@ -265,7 +303,37 @@ pub(crate) async fn post(
repo.save().await?; repo.save().await?;
let ctx = DeviceConsentContext::new(grant, client) // Fetch informations about the user. This is purely cosmetic, so we let it
// fail and put a 1s timeout to it in case we fail to query it
// XXX: we're likely to need this in other places
let localpart = &session.user.username;
let display_name = match tokio::time::timeout(
Duration::from_secs(1),
homeserver.query_user(localpart),
)
.await
{
Ok(Ok(user)) => user.displayname,
Ok(Err(err)) => {
tracing::warn!(
error = &*err as &dyn std::error::Error,
localpart,
"Failed to query user"
);
None
}
Err(_) => {
tracing::warn!(localpart, "Timed out while querying user");
None
}
};
let matrix_user = MatrixUser {
mxid: homeserver.mxid(localpart),
display_name,
};
let ctx = DeviceConsentContext::new(grant, client, matrix_user)
.with_session(session) .with_session(session)
.with_csrf(csrf_token.form_value()) .with_csrf(csrf_token.form_value())
.with_language(locale); .with_language(locale);

View File

@@ -82,6 +82,7 @@ pub(crate) async fn policy_factory(
register: "register/violation".to_owned(), register: "register/violation".to_owned(),
client_registration: "client_registration/violation".to_owned(), client_registration: "client_registration/violation".to_owned(),
authorization_grant: "authorization_grant/violation".to_owned(), authorization_grant: "authorization_grant/violation".to_owned(),
compat_login: "compat_login/violation".to_owned(),
email: "email/violation".to_owned(), email: "email/violation".to_owned(),
}; };

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,7 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use mas_policy::model::{ use mas_policy::model::{
AuthorizationGrantInput, ClientRegistrationInput, EmailInput, RegisterInput, AuthorizationGrantInput, ClientRegistrationInput, CompatLoginInput, EmailInput, RegisterInput,
}; };
use schemars::{JsonSchema, generate::SchemaSettings}; use schemars::{JsonSchema, generate::SchemaSettings};
@@ -42,5 +42,6 @@ fn main() {
write_schema::<RegisterInput>(output_root, "register_input.json"); write_schema::<RegisterInput>(output_root, "register_input.json");
write_schema::<ClientRegistrationInput>(output_root, "client_registration_input.json"); write_schema::<ClientRegistrationInput>(output_root, "client_registration_input.json");
write_schema::<AuthorizationGrantInput>(output_root, "authorization_grant_input.json"); write_schema::<AuthorizationGrantInput>(output_root, "authorization_grant_input.json");
write_schema::<CompatLoginInput>(output_root, "compat_login_input.json");
write_schema::<EmailInput>(output_root, "email_input.json"); write_schema::<EmailInput>(output_root, "email_input.json");
} }

View File

@@ -19,8 +19,9 @@ use thiserror::Error;
use tokio::io::{AsyncRead, AsyncReadExt}; use tokio::io::{AsyncRead, AsyncReadExt};
pub use self::model::{ pub use self::model::{
AuthorizationGrantInput, ClientRegistrationInput, Code as ViolationCode, EmailInput, AuthorizationGrantInput, ClientRegistrationInput, Code as ViolationCode, CompatLoginInput,
EvaluationResult, GrantType, RegisterInput, RegistrationMethod, Requester, Violation, EmailInput, EvaluationResult, GrantType, RegisterInput, RegistrationMethod, Requester,
Violation,
}; };
#[derive(Debug, Error)] #[derive(Debug, Error)]
@@ -72,15 +73,17 @@ pub struct Entrypoints {
pub register: String, pub register: String,
pub client_registration: String, pub client_registration: String,
pub authorization_grant: String, pub authorization_grant: String,
pub compat_login: String,
pub email: String, pub email: String,
} }
impl Entrypoints { impl Entrypoints {
fn all(&self) -> [&str; 4] { fn all(&self) -> [&str; 5] {
[ [
self.register.as_str(), self.register.as_str(),
self.client_registration.as_str(), self.client_registration.as_str(),
self.authorization_grant.as_str(), self.authorization_grant.as_str(),
self.compat_login.as_str(),
self.email.as_str(), self.email.as_str(),
] ]
} }
@@ -459,6 +462,30 @@ impl Policy {
Ok(res) Ok(res)
} }
/// Evaluate the `compat_login` entrypoint.
///
/// # Errors
///
/// Returns an error if the policy engine fails to evaluate the entrypoint.
#[tracing::instrument(
name = "policy.evaluate.compat_login",
skip_all,
fields(
%input.user.id,
),
)]
pub async fn evaluate_compat_login(
&mut self,
input: CompatLoginInput<'_>,
) -> Result<EvaluationResult, EvaluationError> {
let [res]: [EvaluationResult; 1] = self
.instance
.evaluate(&mut self.store, &self.entrypoints.compat_login, &input)
.await?;
Ok(res)
}
} }
#[cfg(test)] #[cfg(test)]
@@ -468,6 +495,16 @@ mod tests {
use super::*; use super::*;
fn make_entrypoints() -> Entrypoints {
Entrypoints {
register: "register/violation".to_owned(),
client_registration: "client_registration/violation".to_owned(),
authorization_grant: "authorization_grant/violation".to_owned(),
compat_login: "compat_login/violation".to_owned(),
email: "email/violation".to_owned(),
}
}
#[tokio::test] #[tokio::test]
async fn test_register() { async fn test_register() {
let data = Data::new("example.com".to_owned(), None).with_rest(serde_json::json!({ let data = Data::new("example.com".to_owned(), None).with_rest(serde_json::json!({
@@ -484,14 +521,9 @@ mod tests {
let file = tokio::fs::File::open(path).await.unwrap(); let file = tokio::fs::File::open(path).await.unwrap();
let entrypoints = Entrypoints { let factory = PolicyFactory::load(file, data, make_entrypoints())
register: "register/violation".to_owned(), .await
client_registration: "client_registration/violation".to_owned(), .unwrap();
authorization_grant: "authorization_grant/violation".to_owned(),
email: "email/violation".to_owned(),
};
let factory = PolicyFactory::load(file, data, entrypoints).await.unwrap();
let mut policy = factory.instantiate().await.unwrap(); let mut policy = factory.instantiate().await.unwrap();
@@ -551,14 +583,9 @@ mod tests {
let file = tokio::fs::File::open(path).await.unwrap(); let file = tokio::fs::File::open(path).await.unwrap();
let entrypoints = Entrypoints { let factory = PolicyFactory::load(file, data, make_entrypoints())
register: "register/violation".to_owned(), .await
client_registration: "client_registration/violation".to_owned(), .unwrap();
authorization_grant: "authorization_grant/violation".to_owned(),
email: "email/violation".to_owned(),
};
let factory = PolicyFactory::load(file, data, entrypoints).await.unwrap();
let mut policy = factory.instantiate().await.unwrap(); let mut policy = factory.instantiate().await.unwrap();
@@ -620,14 +647,9 @@ mod tests {
let file = tokio::fs::File::open(path).await.unwrap(); let file = tokio::fs::File::open(path).await.unwrap();
let entrypoints = Entrypoints { let factory = PolicyFactory::load(file, data, make_entrypoints())
register: "register/violation".to_owned(), .await
client_registration: "client_registration/violation".to_owned(), .unwrap();
authorization_grant: "authorization_grant/violation".to_owned(),
email: "email/violation".to_owned(),
};
let factory = PolicyFactory::load(file, data, entrypoints).await.unwrap();
// That is around 1 MB of JSON data. Each element is a 5-digit string, so 8 // That is around 1 MB of JSON data. Each element is a 5-digit string, so 8
// characters including the quotes and a comma. // characters including the quotes and a comma.

View File

@@ -17,7 +17,7 @@ use schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// A well-known policy code. /// A well-known policy code.
#[derive(Deserialize, Debug, Clone, Copy, JsonSchema)] #[derive(Serialize, Deserialize, Debug, Clone, Copy, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub enum Code { pub enum Code {
/// The username is too short. /// The username is too short.
@@ -75,7 +75,7 @@ impl Code {
} }
/// A single violation of a policy. /// A single violation of a policy.
#[derive(Deserialize, Debug, JsonSchema)] #[derive(Serialize, Deserialize, Debug, JsonSchema)]
pub struct Violation { pub struct Violation {
pub msg: String, pub msg: String,
pub redirect_uri: Option<String>, pub redirect_uri: Option<String>,
@@ -187,6 +187,42 @@ pub struct AuthorizationGrantInput<'a> {
pub requester: Requester, pub requester: Requester,
} }
/// Input for the compatibility login policy.
#[derive(Serialize, Debug, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub struct CompatLoginInput<'a> {
#[schemars(with = "std::collections::HashMap<String, serde_json::Value>")]
pub user: &'a User,
/// How many sessions the user has.
pub session_counts: SessionCounts,
/// Whether a session will be replaced by this login
pub session_replaced: bool,
/// What type of login is being performed.
/// This also determines whether the login is interactive.
pub login: CompatLogin,
pub requester: Requester,
}
#[derive(Serialize, Debug, JsonSchema)]
#[serde(tag = "type")]
pub enum CompatLogin {
/// Used as the interactive part of SSO login.
#[serde(rename = "m.login.sso")]
Sso { redirect_uri: String },
/// Used as the final (non-interactive) stage of SSO login.
#[serde(rename = "m.login.token")]
Token,
/// Non-interactive password-over-the-API login.
#[serde(rename = "m.login.password")]
Password,
}
/// Information about how many sessions the user has /// Information about how many sessions the user has
#[derive(Serialize, Debug, JsonSchema)] #[derive(Serialize, Debug, JsonSchema)]
pub struct SessionCounts { pub struct SessionCounts {

View File

@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT current_database() as \"current_database!\"",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "current_database!",
"type_info": "Name"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "2f66991d7b9ba58f011d9aef0eb6a38f3b244c2f46444c0ab345de7feff54aba"
}

View File

@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT EXISTS (\n SELECT 1\n FROM information_schema.tables\n WHERE table_name = '_sqlx_migrations'\n ) AS \"exists!\"\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists!",
"type_info": "Bool"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "fbf926f630df5d588df4f1c9c0dc0f594332be5829d5d7c6b66183ac25b3d166"
}

View File

@@ -19,6 +19,7 @@ workspace = true
[dependencies] [dependencies]
async-trait.workspace = true async-trait.workspace = true
chrono.workspace = true chrono.workspace = true
crc.workspace = true
futures-util.workspace = true futures-util.workspace = true
opentelemetry-semantic-conventions.workspace = true opentelemetry-semantic-conventions.workspace = true
opentelemetry.workspace = true opentelemetry.workspace = true
@@ -31,6 +32,7 @@ sha2.workspace = true
sqlx.workspace = true sqlx.workspace = true
thiserror.workspace = true thiserror.workspace = true
tracing.workspace = true tracing.workspace = true
tokio.workspace = true
ulid.workspace = true ulid.workspace = true
url.workspace = true url.workspace = true
uuid.workspace = true uuid.workspace = true

View File

@@ -487,14 +487,15 @@ impl AppSessionRepository for PgAppSessionRepository<'_> {
clock: &dyn Clock, clock: &dyn Clock,
user: &User, user: &User,
device: &Device, device: &Device,
) -> Result<(), Self::Error> { ) -> Result<bool, Self::Error> {
let mut affected = false;
// TODO need to invoke this from all the oauth2 login sites // TODO need to invoke this from all the oauth2 login sites
let span = tracing::info_span!( let span = tracing::info_span!(
"db.app_session.finish_sessions_to_replace_device.compat_sessions", "db.app_session.finish_sessions_to_replace_device.compat_sessions",
{ DB_QUERY_TEXT } = tracing::field::Empty, { DB_QUERY_TEXT } = tracing::field::Empty,
); );
let finished_at = clock.now(); let finished_at = clock.now();
sqlx::query!( let compat_affected = sqlx::query!(
" "
UPDATE compat_sessions SET finished_at = $3 WHERE user_id = $1 AND device_id = $2 AND finished_at IS NULL UPDATE compat_sessions SET finished_at = $3 WHERE user_id = $1 AND device_id = $2 AND finished_at IS NULL
", ",
@@ -505,7 +506,9 @@ impl AppSessionRepository for PgAppSessionRepository<'_> {
.record(&span) .record(&span)
.execute(&mut *self.conn) .execute(&mut *self.conn)
.instrument(span) .instrument(span)
.await?; .await?
.rows_affected();
affected |= compat_affected > 0;
if let Ok([stable_device_as_scope_token, unstable_device_as_scope_token]) = if let Ok([stable_device_as_scope_token, unstable_device_as_scope_token]) =
device.to_scope_token() device.to_scope_token()
@@ -514,7 +517,7 @@ impl AppSessionRepository for PgAppSessionRepository<'_> {
"db.app_session.finish_sessions_to_replace_device.oauth2_sessions", "db.app_session.finish_sessions_to_replace_device.oauth2_sessions",
{ DB_QUERY_TEXT } = tracing::field::Empty, { DB_QUERY_TEXT } = tracing::field::Empty,
); );
sqlx::query!( let oauth2_affected = sqlx::query!(
" "
UPDATE oauth2_sessions UPDATE oauth2_sessions
SET finished_at = $4 SET finished_at = $4
@@ -530,10 +533,12 @@ impl AppSessionRepository for PgAppSessionRepository<'_> {
.record(&span) .record(&span)
.execute(&mut *self.conn) .execute(&mut *self.conn)
.instrument(span) .instrument(span)
.await?; .await?
.rows_affected();
affected |= oauth2_affected > 0;
} }
Ok(()) Ok(affected)
} }
} }

View File

@@ -160,7 +160,15 @@
#![deny(clippy::future_not_send, missing_docs)] #![deny(clippy::future_not_send, missing_docs)]
#![allow(clippy::module_name_repetitions, clippy::blocks_in_conditions)] #![allow(clippy::module_name_repetitions, clippy::blocks_in_conditions)]
use sqlx::migrate::Migrator; use std::collections::{BTreeMap, BTreeSet, HashSet};
use ::tracing::{Instrument, debug, info, info_span, warn};
use opentelemetry_semantic_conventions::trace::DB_QUERY_TEXT;
use sqlx::{
Either, PgConnection,
migrate::{AppliedMigration, Migrate, MigrateError, Migration, Migrator},
postgres::{PgAdvisoryLock, PgAdvisoryLockKey},
};
pub mod app_session; pub mod app_session;
pub mod compat; pub mod compat;
@@ -186,14 +194,290 @@ pub use self::{
tracing::ExecuteExt, tracing::ExecuteExt,
}; };
/// Embedded migrations, allowing them to run on startup /// Embedded migrations in the binary
pub static MIGRATOR: Migrator = { pub static MIGRATOR: Migrator = sqlx::migrate!();
// XXX: The macro does not let us ignore missing migrations, so we have to do it
// like this. See https://github.com/launchbadge/sqlx/issues/1788
let mut m = sqlx::migrate!();
// We manually removed some migrations because they made us depend on the fn available_migrations() -> BTreeMap<i64, &'static Migration> {
// `pgcrypto` extension. See: https://github.com/matrix-org/matrix-authentication-service/issues/1557 MIGRATOR.iter().map(|m| (m.version, m)).collect()
m.ignore_missing = true; }
m
}; /// This is the list of migrations we've removed from the migration history but
/// might have been applied in the past
#[allow(clippy::inconsistent_digit_grouping)]
const ALLOWED_MISSING_MIGRATIONS: &[i64] = &[
// https://github.com/matrix-org/matrix-authentication-service/pull/1585
20220709_210445,
20230330_210841,
20230408_110421,
];
fn allowed_missing_migrations() -> BTreeSet<i64> {
ALLOWED_MISSING_MIGRATIONS.iter().copied().collect()
}
/// This is a list of possible additional checksums from previous versions of
/// migrations. The checksum we store in the database is 48 bytes long. We're
/// not really concerned with partial hash collisions, and to avoid this file to
/// be completely unreadable, we only store the upper 16 bytes of that hash.
#[allow(clippy::inconsistent_digit_grouping)]
const ALLOWED_ALTERNATE_CHECKSUMS: &[(i64, u128)] = &[
// https://github.com/element-hq/matrix-authentication-service/pull/5300
(20250410_000000, 0x8811_c3ef_dbee_8c00_5b49_25da_5d55_9c3f),
(20250410_000001, 0x7990_37b3_2193_8a5d_c72f_bccd_95fd_82e5),
(20250410_000002, 0xf2b8_f120_deae_27e7_60d0_79a3_0b77_eea3),
(20250410_000003, 0x06be_fc2b_cedc_acf4_b981_02c7_b40c_c469),
(20250410_000004, 0x0a90_9c6a_dba7_545c_10d9_60eb_6d30_2f50),
(20250410_000006, 0xcc7f_5152_6497_5729_d94b_be0d_9c95_8316),
(20250410_000007, 0x12e7_cfab_a017_a5a5_4f2c_18fa_541c_ce62),
(20250410_000008, 0x171d_62e5_ee1a_f0d9_3639_6c5a_277c_54cd),
(20250410_000009, 0xb1a0_93c7_6645_92ad_df45_b395_57bb_a281),
(20250410_000010, 0x8089_86ac_7cff_8d86_2850_d287_cdb1_2b57),
(20250410_000011, 0x8d9d_3fae_02c9_3d3f_81e4_6242_2b39_b5b8),
(20250410_000012, 0x9805_1372_41aa_d5b0_ebe1_ba9d_28c7_faf6),
(20250410_000013, 0x7291_9a97_e4d1_0d45_1791_6e8c_3f2d_e34d),
(20250410_000014, 0x811d_f965_8127_e168_4aa2_f177_a4e6_f077),
(20250410_000015, 0xa639_0780_aab7_d60d_5fcb_771d_13ed_73ee),
(20250410_000016, 0x22b6_e909_6de4_39e3_b2b9_c684_7417_fe07),
(20250410_000017, 0x9dfe_b6d3_89e4_e509_651b_2793_8d8d_cd32),
(20250410_000018, 0x638f_bdbc_2276_5094_020b_cec1_ab95_c07f),
(20250410_000019, 0xa283_84bc_5fd5_7cbd_b5fb_b5fe_0255_6845),
(20250410_000020, 0x17d1_54b1_7c6e_fc48_61dd_da3d_f8a5_9546),
(20250410_000022, 0xbc36_af82_994a_6f93_8aca_a46b_fc3c_ffde),
(20250410_000023, 0x54ec_3b07_ac79_443b_9e18_a2b3_2d17_5ab9),
(20250410_000024, 0x8ab4_4f80_00b6_58b2_d757_c40f_bc72_3d87),
(20250410_000025, 0x5dc4_2ff3_3042_2f45_046d_10af_ab3a_b583),
(20250410_000026, 0x5263_c547_0b64_6425_5729_48b2_ce84_7cad),
(20250410_000027, 0x0aad_cb50_1d6a_7794_9017_d24d_55e7_1b9d),
(20250410_000028, 0x8fc1_92f8_68df_ca4e_3e2b_cddf_bc12_cffe),
(20250410_000029, 0x416c_9446_b6a3_1b49_2940_a8ac_c1c2_665a),
(20250410_000030, 0x83a5_e51e_25a6_77fb_2b79_6ea5_db1e_364f),
(20250410_000031, 0xfa18_a707_9438_dbc7_2cde_b5f1_ee21_5c7e),
(20250410_000032, 0xd669_662e_8930_838a_b142_c3fa_7b39_d2a0),
(20250410_000033, 0x4019_1053_cabc_191c_c02e_9aa9_407c_0de5),
(20250410_000034, 0xdd59_e595_24e6_4dad_c5f7_fef2_90b8_df57),
(20250410_000035, 0x09b4_ea53_2da4_9c39_eb10_db33_6a6d_608b),
(20250410_000036, 0x3ca5_9c78_8480_e342_d729_907c_d293_2049),
(20250410_000037, 0xc857_2a10_450b_0612_822c_2b86_535a_ea7d),
(20250410_000038, 0x1642_39da_9c3b_d9fd_b1e1_72b1_db78_b978),
(20250410_000039, 0xdd70_b211_6016_bb84_0d84_f04e_eb8a_59d9),
(20250410_000040, 0xe435_ead6_c363_a0b6_e048_dd85_0ecb_9499),
(20250410_000041, 0xe9f3_122f_70d4_9839_c818_4b18_0192_ae26),
(20250410_000043, 0xec5e_1400_483d_c4bf_6014_aba4_ffc3_6236),
(20250410_000044, 0x4750_5eba_4095_6664_78d0_27f9_64bf_64f4),
(20250410_000045, 0x9a53_bd70_4cad_2bf1_61d4_f143_0c82_681d),
(20250410_121612, 0x25f0_9d20_a897_df18_162d_1c47_b68e_81bd),
(20250602_212101, 0xd1a8_782c_b3f0_5045_3f46_49a0_bab0_822b),
(20250708_155857, 0xb78e_6957_a588_c16a_d292_a0c7_cae9_f290),
(20250915_092635, 0x6854_d58b_99d7_3ac5_82f8_25e5_b1c3_cc0b),
(20251127_145951, 0x3bcd_d92e_8391_2a2c_8a18_1d76_354f_96c6),
];
fn alternate_checksums_map() -> BTreeMap<i64, HashSet<u128>> {
let mut map = BTreeMap::new();
for (version, checksum) in ALLOWED_ALTERNATE_CHECKSUMS {
map.entry(*version)
.or_insert_with(HashSet::new)
.insert(*checksum);
}
map
}
/// Load the list of applied migrations into a map.
///
/// It's important to use a [`BTreeMap`] so that the migrations are naturally
/// ordered by version.
async fn applied_migrations_map(
conn: &mut PgConnection,
) -> Result<BTreeMap<i64, AppliedMigration>, MigrateError> {
let applied_migrations = conn
.list_applied_migrations()
.await?
.into_iter()
.map(|m| (m.version, m))
.collect();
Ok(applied_migrations)
}
/// Checks if the migration table exists
async fn migration_table_exists(conn: &mut PgConnection) -> Result<bool, sqlx::Error> {
sqlx::query_scalar!(
r#"
SELECT EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_name = '_sqlx_migrations'
) AS "exists!"
"#,
)
.fetch_one(conn)
.await
}
/// Run the migrations on the given connection
///
/// This function acquires an advisory lock on the database to ensure that only
/// one migrator is running at a time.
///
/// # Errors
///
/// This function returns an error if the migration fails.
#[::tracing::instrument(name = "db.migrate", skip_all, err)]
pub async fn migrate(conn: &mut PgConnection) -> Result<(), MigrateError> {
// Get the database name and use it to derive an advisory lock key. This
// is the same lock key used by SQLx default migrator, so that it works even
// with older versions of MAS, and when running through `cargo sqlx migrate run`
let database_name = sqlx::query_scalar!(r#"SELECT current_database() as "current_database!""#)
.fetch_one(&mut *conn)
.await
.map_err(MigrateError::from)?;
let lock =
PgAdvisoryLock::with_key(PgAdvisoryLockKey::BigInt(generate_lock_id(&database_name)));
// Try to acquire the migration lock in a loop.
//
// The reason we do that with a `try_acquire` is because in Postgres, `CREATE
// INDEX CONCURRENTLY` will *not* complete whilst an advisory lock is being
// acquired on another connection. This then means that if we run two
// migration process at the same time, one of them will go through and block
// on concurrent index creations, because the other will get stuck trying to
// acquire this lock.
//
// To avoid this, we use `try_acquire`/`pg_advisory_lock_try` in a loop, which
// will fail immediately if the lock is held by another connection, allowing
// potential 'CREATE INDEX CONCURRENTLY' statements to complete.
let mut backoff = std::time::Duration::from_millis(250);
let mut conn = conn;
let mut locked_connection = loop {
match lock.try_acquire(conn).await? {
Either::Left(guard) => break guard,
Either::Right(conn_) => {
warn!(
"Another process is already running migrations on the database, waiting {duration}s and trying again…",
duration = backoff.as_secs_f32()
);
tokio::time::sleep(backoff).await;
backoff = std::cmp::min(backoff * 2, std::time::Duration::from_secs(5));
conn = conn_;
}
}
};
// Creates the migration table if missing
// We check if the table exists before calling `ensure_migrations_table` to
// avoid the pesky 'relation "_sqlx_migrations" already exists, skipping' notice
if !migration_table_exists(locked_connection.as_mut()).await? {
locked_connection.as_mut().ensure_migrations_table().await?;
}
for migration in pending_migrations(locked_connection.as_mut()).await? {
info!(
"Applying migration {version}: {description}",
version = migration.version,
description = migration.description
);
locked_connection
.as_mut()
.apply(migration)
.instrument(info_span!(
"db.migrate.run_migration",
db.migration.version = migration.version,
db.migration.description = &*migration.description,
{ DB_QUERY_TEXT } = &*migration.sql,
))
.await?;
}
locked_connection.release_now().await?;
Ok(())
}
/// Get the list of pending migrations
///
/// # Errors
///
/// This function returns an error if there is a problem checking the applied
/// migrations
pub async fn pending_migrations(
conn: &mut PgConnection,
) -> Result<Vec<&'static Migration>, MigrateError> {
// Load the maps of available migrations, applied migrations, migrations that
// are allowed to be missing, alternate checksums for migrations that changed
let available_migrations = available_migrations();
let allowed_missing = allowed_missing_migrations();
let alternate_checksums = alternate_checksums_map();
let applied_migrations = if migration_table_exists(&mut *conn).await? {
applied_migrations_map(&mut *conn).await?
} else {
BTreeMap::new()
};
// Check that all applied migrations are still valid
for applied_migration in applied_migrations.values() {
// Check that we know about the applied migration
if let Some(migration) = available_migrations.get(&applied_migration.version) {
// Check the migration checksum
if applied_migration.checksum != migration.checksum {
// The checksum we have in the database doesn't match the one we
// have embedded. This might be because a migration was
// intentionally changed, so we check the alternate checksums
if let Some(alternates) = alternate_checksums.get(&applied_migration.version) {
// This converts the first 16 bytes of the checksum into a u128
let Some(applied_checksum_prefix) = applied_migration
.checksum
.get(..16)
.and_then(|bytes| bytes.try_into().ok())
.map(u128::from_be_bytes)
else {
return Err(MigrateError::ExecuteMigration(
sqlx::Error::InvalidArgument(
"checksum stored in database is invalid".to_owned(),
),
applied_migration.version,
));
};
if !alternates.contains(&applied_checksum_prefix) {
warn!(
"The database has a migration applied ({version}) which has known alternative checksums {alternates:x?}, but none of them matched {applied_checksum_prefix:x}",
version = applied_migration.version,
);
return Err(MigrateError::VersionMismatch(applied_migration.version));
}
} else {
return Err(MigrateError::VersionMismatch(applied_migration.version));
}
}
} else if allowed_missing.contains(&applied_migration.version) {
// The migration is missing, but allowed to be missing
debug!(
"The database has a migration applied ({version}) that doesn't exist anymore, but it was intentionally removed",
version = applied_migration.version
);
} else {
// The migration is missing, warn about it
warn!(
"The database has a migration applied ({version}) that doesn't exist anymore! This should not happen, unless rolling back to an older version of MAS.",
version = applied_migration.version
);
}
}
Ok(available_migrations
.values()
.copied()
.filter(|migration| {
!migration.migration_type.is_down_migration()
&& !applied_migrations.contains_key(&migration.version)
})
.collect())
}
// Copied from the sqlx source code, so that we generate the same lock ID
fn generate_lock_id(database_name: &str) -> i64 {
const CRC_IEEE: crc::Crc<u32> = crc::Crc::<u32>::new(&crc::CRC_32_ISO_HDLC);
// 0x3d32ad9e chosen by fair dice roll
0x3d32_ad9e * i64::from(CRC_IEEE.checksum(database_name.as_bytes()))
}

View File

@@ -196,12 +196,14 @@ pub trait AppSessionRepository: Send + Sync {
/// replacing a device). /// replacing a device).
/// ///
/// Should be called *before* creating a new session for the device. /// Should be called *before* creating a new session for the device.
///
/// Returns true if a session was finished.
async fn finish_sessions_to_replace_device( async fn finish_sessions_to_replace_device(
&mut self, &mut self,
clock: &dyn Clock, clock: &dyn Clock,
user: &User, user: &User,
device: &Device, device: &Device,
) -> Result<(), Self::Error>; ) -> Result<bool, Self::Error>;
} }
repository_impl!(AppSessionRepository: repository_impl!(AppSessionRepository:
@@ -218,5 +220,5 @@ repository_impl!(AppSessionRepository:
clock: &dyn Clock, clock: &dyn Clock,
user: &User, user: &User,
device: &Device, device: &Device,
) -> Result<(), Self::Error>; ) -> Result<bool, Self::Error>;
); );

View File

@@ -41,6 +41,7 @@ oauth2-types.workspace = true
mas-data-model.workspace = true mas-data-model.workspace = true
mas-i18n.workspace = true mas-i18n.workspace = true
mas-iana.workspace = true mas-iana.workspace = true
mas-policy.workspace = true
mas-router.workspace = true mas-router.workspace = true
mas-spa.workspace = true mas-spa.workspace = true

View File

@@ -21,13 +21,15 @@ use chrono::{DateTime, Duration, Utc};
use http::{Method, Uri, Version}; use http::{Method, Uri, Version};
use mas_data_model::{ use mas_data_model::{
AuthorizationGrant, BrowserSession, Client, CompatSsoLogin, CompatSsoLoginState, AuthorizationGrant, BrowserSession, Client, CompatSsoLogin, CompatSsoLoginState,
DeviceCodeGrant, UpstreamOAuthLink, UpstreamOAuthProvider, UpstreamOAuthProviderClaimsImports, DeviceCodeGrant, MatrixUser, UpstreamOAuthLink, UpstreamOAuthProvider,
UpstreamOAuthProviderDiscoveryMode, UpstreamOAuthProviderOnBackchannelLogout, UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderDiscoveryMode,
UpstreamOAuthProviderPkceMode, UpstreamOAuthProviderTokenAuthMethod, User, UpstreamOAuthProviderOnBackchannelLogout, UpstreamOAuthProviderPkceMode,
UserEmailAuthentication, UserEmailAuthenticationCode, UserRecoverySession, UserRegistration, UpstreamOAuthProviderTokenAuthMethod, User, UserEmailAuthentication,
UserEmailAuthenticationCode, UserRecoverySession, UserRegistration,
}; };
use mas_i18n::DataLocale; use mas_i18n::DataLocale;
use mas_iana::jose::JsonWebSignatureAlg; use mas_iana::jose::JsonWebSignatureAlg;
use mas_policy::{Violation, ViolationCode};
use mas_router::{Account, GraphQL, PostAuthAction, UrlBuilder}; use mas_router::{Account, GraphQL, PostAuthAction, UrlBuilder};
use oauth2_types::scope::{OPENID, Scope}; use oauth2_types::scope::{OPENID, Scope};
use rand::{ use rand::{
@@ -732,6 +734,7 @@ pub struct ConsentContext {
grant: AuthorizationGrant, grant: AuthorizationGrant,
client: Client, client: Client,
action: PostAuthAction, action: PostAuthAction,
matrix_user: MatrixUser,
} }
impl TemplateContext for ConsentContext { impl TemplateContext for ConsentContext {
@@ -755,6 +758,10 @@ impl TemplateContext for ConsentContext {
grant, grant,
client, client,
action, action,
matrix_user: MatrixUser {
mxid: "@alice:example.com".to_owned(),
display_name: Some("Alice".to_owned()),
},
} }
}) })
.collect(), .collect(),
@@ -765,12 +772,13 @@ impl TemplateContext for ConsentContext {
impl ConsentContext { impl ConsentContext {
/// Constructs a context for the client consent page /// Constructs a context for the client consent page
#[must_use] #[must_use]
pub fn new(grant: AuthorizationGrant, client: Client) -> Self { pub fn new(grant: AuthorizationGrant, client: Client, matrix_user: MatrixUser) -> Self {
let action = PostAuthAction::continue_grant(grant.id); let action = PostAuthAction::continue_grant(grant.id);
Self { Self {
grant, grant,
client, client,
action, action,
matrix_user,
} }
} }
} }
@@ -860,11 +868,50 @@ impl PolicyViolationContext {
} }
} }
/// Context used by the `compat_login_policy_violation.html` template
#[derive(Serialize)]
pub struct CompatLoginPolicyViolationContext {
violations: Vec<Violation>,
}
impl TemplateContext for CompatLoginPolicyViolationContext {
fn sample<R: Rng>(
_now: chrono::DateTime<Utc>,
_rng: &mut R,
_locales: &[DataLocale],
) -> BTreeMap<SampleIdentifier, Self>
where
Self: Sized,
{
sample_list(vec![
CompatLoginPolicyViolationContext { violations: vec![] },
CompatLoginPolicyViolationContext {
violations: vec![Violation {
msg: "user has too many active sessions".to_owned(),
redirect_uri: None,
field: None,
code: Some(ViolationCode::TooManySessions),
}],
},
])
}
}
impl CompatLoginPolicyViolationContext {
/// Constructs a context for the compatibility login policy violation page
/// given the list of violations
#[must_use]
pub const fn for_violations(violations: Vec<Violation>) -> Self {
Self { violations }
}
}
/// Context used by the `sso.html` template /// Context used by the `sso.html` template
#[derive(Serialize)] #[derive(Serialize)]
pub struct CompatSsoContext { pub struct CompatSsoContext {
login: CompatSsoLogin, login: CompatSsoLogin,
action: PostAuthAction, action: PostAuthAction,
matrix_user: MatrixUser,
} }
impl TemplateContext for CompatSsoContext { impl TemplateContext for CompatSsoContext {
@@ -877,23 +924,33 @@ impl TemplateContext for CompatSsoContext {
Self: Sized, Self: Sized,
{ {
let id = Ulid::from_datetime_with_source(now.into(), rng); let id = Ulid::from_datetime_with_source(now.into(), rng);
sample_list(vec![CompatSsoContext::new(CompatSsoLogin { sample_list(vec![CompatSsoContext::new(
id, CompatSsoLogin {
redirect_uri: Url::parse("https://app.element.io/").unwrap(), id,
login_token: "abcdefghijklmnopqrstuvwxyz012345".into(), redirect_uri: Url::parse("https://app.element.io/").unwrap(),
created_at: now, login_token: "abcdefghijklmnopqrstuvwxyz012345".into(),
state: CompatSsoLoginState::Pending, created_at: now,
})]) state: CompatSsoLoginState::Pending,
},
MatrixUser {
mxid: "@alice:example.com".to_owned(),
display_name: Some("Alice".to_owned()),
},
)])
} }
} }
impl CompatSsoContext { impl CompatSsoContext {
/// Constructs a context for the legacy SSO login page /// Constructs a context for the legacy SSO login page
#[must_use] #[must_use]
pub fn new(login: CompatSsoLogin) -> Self pub fn new(login: CompatSsoLogin, matrix_user: MatrixUser) -> Self
where { where {
let action = PostAuthAction::continue_compat_sso_login(login.id); let action = PostAuthAction::continue_compat_sso_login(login.id);
Self { login, action } Self {
login,
action,
matrix_user,
}
} }
} }
@@ -1748,13 +1805,18 @@ impl TemplateContext for DeviceLinkContext {
pub struct DeviceConsentContext { pub struct DeviceConsentContext {
grant: DeviceCodeGrant, grant: DeviceCodeGrant,
client: Client, client: Client,
matrix_user: MatrixUser,
} }
impl DeviceConsentContext { impl DeviceConsentContext {
/// Constructs a new context with an existing linked user /// Constructs a new context with an existing linked user
#[must_use] #[must_use]
pub fn new(grant: DeviceCodeGrant, client: Client) -> Self { pub fn new(grant: DeviceCodeGrant, client: Client, matrix_user: MatrixUser) -> Self {
Self { grant, client } Self {
grant,
client,
matrix_user,
}
} }
} }
@@ -1782,7 +1844,14 @@ impl TemplateContext for DeviceConsentContext {
ip_address: Some(IpAddr::V4(Ipv4Addr::LOCALHOST)), ip_address: Some(IpAddr::V4(Ipv4Addr::LOCALHOST)),
user_agent: Some("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.0.0 Safari/537.36".to_owned()), user_agent: Some("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.0.0 Safari/537.36".to_owned()),
}; };
Self { grant, client } Self {
grant,
client,
matrix_user: MatrixUser {
mxid: "@alice:example.com".to_owned(),
display_name: Some("Alice".to_owned()),
}
}
}) })
.collect()) .collect())
} }

View File

@@ -41,6 +41,7 @@ pub fn register(
env.add_filter("simplify_url", filter_simplify_url); env.add_filter("simplify_url", filter_simplify_url);
env.add_filter("add_slashes", filter_add_slashes); env.add_filter("add_slashes", filter_add_slashes);
env.add_filter("parse_user_agent", filter_parse_user_agent); env.add_filter("parse_user_agent", filter_parse_user_agent);
env.add_filter("id_color_hash", filter_id_color_hash);
env.add_function("add_params_to_url", function_add_params_to_url); env.add_function("add_params_to_url", function_add_params_to_url);
env.add_function("counter", || Ok(Value::from_object(Counter::default()))); env.add_function("counter", || Ok(Value::from_object(Counter::default())));
if let Some(vite_manifest) = vite_manifest { if let Some(vite_manifest) = vite_manifest {
@@ -138,6 +139,12 @@ fn filter_simplify_url(url: &str, kwargs: Kwargs) -> Result<String, minijinja::E
} }
} }
/// Filter which computes a hash between 1 and 6 of an input string, identitical
/// to compound-web's `useIdColorHash`
fn filter_id_color_hash(input: &str) -> u32 {
input.chars().fold(0, |hash, c| hash + c as u32) % 6 + 1
}
/// Filter which parses a user-agent string /// Filter which parses a user-agent string
fn filter_parse_user_agent(user_agent: String) -> Value { fn filter_parse_user_agent(user_agent: String) -> Value {
let user_agent = mas_data_model::UserAgent::parse(user_agent); let user_agent = mas_data_model::UserAgent::parse(user_agent);

View File

@@ -37,14 +37,15 @@ mod macros;
pub use self::{ pub use self::{
context::{ context::{
AccountInactiveContext, ApiDocContext, AppContext, CompatSsoContext, ConsentContext, AccountInactiveContext, ApiDocContext, AppContext, CompatLoginPolicyViolationContext,
DeviceConsentContext, DeviceLinkContext, DeviceLinkFormField, DeviceNameContext, CompatSsoContext, ConsentContext, DeviceConsentContext, DeviceLinkContext,
EmailRecoveryContext, EmailVerificationContext, EmptyContext, ErrorContext, DeviceLinkFormField, DeviceNameContext, EmailRecoveryContext, EmailVerificationContext,
FormPostContext, IndexContext, LoginContext, LoginFormField, NotFoundContext, EmptyContext, ErrorContext, FormPostContext, IndexContext, LoginContext, LoginFormField,
PasswordRegisterContext, PolicyViolationContext, PostAuthContext, PostAuthContextInner, NotFoundContext, PasswordRegisterContext, PolicyViolationContext, PostAuthContext,
RecoveryExpiredContext, RecoveryFinishContext, RecoveryFinishFormField, PostAuthContextInner, RecoveryExpiredContext, RecoveryFinishContext,
RecoveryProgressContext, RecoveryStartContext, RecoveryStartFormField, RegisterContext, RecoveryFinishFormField, RecoveryProgressContext, RecoveryStartContext,
RegisterFormField, RegisterStepsDisplayNameContext, RegisterStepsDisplayNameFormField, RecoveryStartFormField, RegisterContext, RegisterFormField,
RegisterStepsDisplayNameContext, RegisterStepsDisplayNameFormField,
RegisterStepsEmailInUseContext, RegisterStepsRegistrationTokenContext, RegisterStepsEmailInUseContext, RegisterStepsRegistrationTokenContext,
RegisterStepsRegistrationTokenFormField, RegisterStepsVerifyEmailContext, RegisterStepsRegistrationTokenFormField, RegisterStepsVerifyEmailContext,
RegisterStepsVerifyEmailFormField, SiteBranding, SiteConfigExt, SiteFeatures, RegisterStepsVerifyEmailFormField, SiteBranding, SiteConfigExt, SiteFeatures,
@@ -391,6 +392,9 @@ register_templates! {
/// Render the policy violation page /// Render the policy violation page
pub fn render_policy_violation(WithLanguage<WithCsrf<WithSession<PolicyViolationContext>>>) { "pages/policy_violation.html" } pub fn render_policy_violation(WithLanguage<WithCsrf<WithSession<PolicyViolationContext>>>) { "pages/policy_violation.html" }
/// Render the compatibility login policy violation page
pub fn render_compat_login_policy_violation(WithLanguage<WithCsrf<WithSession<CompatLoginPolicyViolationContext>>>) { "pages/compat_login_policy_violation.html" }
/// Render the legacy SSO login consent page /// Render the legacy SSO login consent page
pub fn render_sso_login(WithLanguage<WithCsrf<WithSession<CompatSsoContext>>>) { "pages/sso.html" } pub fn render_sso_login(WithLanguage<WithCsrf<WithSession<CompatSsoContext>>>) { "pages/sso.html" }

View File

@@ -19,6 +19,9 @@ ignore = [
# RSA key extraction "Marvin Attack". This is only relevant when using # RSA key extraction "Marvin Attack". This is only relevant when using
# PKCS#1 v1.5 encryption, which we don't # PKCS#1 v1.5 encryption, which we don't
"RUSTSEC-2023-0071", "RUSTSEC-2023-0071",
# This is a newly unmaintained package that we can allow temporarily.
# Remove ASAP once https://github.com/element-hq/matrix-authentication-service/issues/5337 is fixed.
"RUSTSEC-2025-0134",
] ]
[licenses] [licenses]

View File

@@ -1883,6 +1883,10 @@
"description": "Entrypoint to use when evaluating authorization grants", "description": "Entrypoint to use when evaluating authorization grants",
"type": "string" "type": "string"
}, },
"compat_login_entrypoint": {
"description": "Entrypoint to use when evaluating compatibility logins",
"type": "string"
},
"password_entrypoint": { "password_entrypoint": {
"description": "Entrypoint to use when changing password", "description": "Entrypoint to use when changing password",
"type": "string" "type": "string"
@@ -2467,6 +2471,10 @@
} }
] ]
}, },
"skip_confirmation": {
"description": "Whether to skip the interactive screen prompting the user to confirm the\n attributes that are being imported. This requires `localpart.action` to\n be `require` and other attribute actions to be either `ignore`, `force`\n or `require`",
"type": "boolean"
},
"localpart": { "localpart": {
"description": "Import the localpart of the MXID", "description": "Import the localpart of the MXID",
"allOf": [ "allOf": [
@@ -2484,7 +2492,7 @@
] ]
}, },
"email": { "email": {
"description": "Import the email address of the user based on the `email` and\n `email_verified` claims", "description": "Import the email address of the user",
"allOf": [ "allOf": [
{ {
"$ref": "#/definitions/EmailImportPreference" "$ref": "#/definitions/EmailImportPreference"
@@ -2572,14 +2580,24 @@
"description": "How to handle an existing localpart claim", "description": "How to handle an existing localpart claim",
"oneOf": [ "oneOf": [
{ {
"description": "Fails the sso login on conflict", "description": "Fails the upstream OAuth 2.0 login on conflict",
"type": "string", "type": "string",
"const": "fail" "const": "fail"
}, },
{ {
"description": "Adds the oauth identity link, regardless of whether there is an existing\n link or not", "description": "Adds the upstream OAuth 2.0 identity link, regardless of whether there\n is an existing link or not",
"type": "string", "type": "string",
"const": "add" "const": "add"
},
{
"description": "Replace any existing upstream OAuth 2.0 identity link",
"type": "string",
"const": "replace"
},
{
"description": "Adds the upstream OAuth 2.0 identity link *only* if there is no existing\n link for this provider on the matching user",
"type": "string",
"const": "set"
} }
] ]
}, },

View File

@@ -40,7 +40,7 @@ cargo sqlx prepare
## Migrations ## Migrations
Migration files live in the `migrations` folder in the `mas-core` crate. Migration files live in the `migrations` folder in the `mas-storage-pg` crate.
```sh ```sh
cd crates/storage-pg/ # Again, in the mas-storage-pg crate folder cd crates/storage-pg/ # Again, in the mas-storage-pg crate folder
@@ -50,3 +50,29 @@ cargo sqlx migrate add [description] # Add new migration files
``` ```
Note that migrations are embedded in the final binary and can be run from the service CLI tool. Note that migrations are embedded in the final binary and can be run from the service CLI tool.
### Removing migrations
For various reasons, we may want to delete migrations.
In case we do, we *must* declare that migration version as allowed to be missing.
This is because on startup, MAS will validate that all the applied migrations are known, and warn if some are missing.
To do so, get the migration version and add it to the `ALLOWED_MISSING_MIGRATIONS` array in the `mas-storage-pg` crate.
### Modifying existing migrations
We may want to modify existing migrations to fix mistakes.
In case we do, we *must* save the hash of the original migration file so that MAS can validate it on startup.
To do so, extract the first 16 bytes of the existing applied migration and append it to the `ALLOWED_ALTERNATE_CHECKSUMS` array in the `mas-storage-pg` crate.
```sql
SELECT version, ENCODE(SUBSTRING(checksum FOR 16), 'hex') AS short_checksum
FROM _sqlx_migrations
WHERE version = 20250410000002;
```
```
version | short_checksum
----------------+----------------------------------
20250410000002 | f2b8f120deae27e760d079a30b77eea3
```

View File

@@ -196,7 +196,7 @@ secrets:
# Signing keys # Signing keys
keys: keys:
# It needs at least an RSA key to work properly # At least one RSA key must be configured
- key_file: keys/rsa_key - key_file: keys/rsa_key
- kid: "iv1aShae" - kid: "iv1aShae"
key: | key: |
@@ -222,7 +222,7 @@ The secret is not updated when the content of the file changes.
> Changing the encryption secret afterwards will lead to a loss of all encrypted > Changing the encryption secret afterwards will lead to a loss of all encrypted
> information in the database. > information in the database.
### Singing Keys ### Signing Keys
The service can use a number of key types for signing. The service can use a number of key types for signing.
The following key types are supported: The following key types are supported:
@@ -238,9 +238,24 @@ The following key formats are supported:
- PKCS#8 PEM or DER-encoded RSA or ECDSA private key, encrypted or not - PKCS#8 PEM or DER-encoded RSA or ECDSA private key, encrypted or not
- SEC1 PEM or DER-encoded ECDSA private key - SEC1 PEM or DER-encoded ECDSA private key
The signing keys are used for:
- signing ID Tokens (as returned in the [Token Endpoint] at `/oauth2/token`);
- signing the response of the [UserInfo Endpoint] at `/oauth2/userinfo` if the
client requests a signed response;
- (niche) signing a JWT for authenticating to an upstream OAuth provider when
the `private_key_jwt` client auth method is configured.
At a minimum, an RSA key must be configured in order to be compliant with the
[OpenID Connect Core specification][oidc-core-rs256] which specifies the RS256 algorithm
as mandatory to implement by servers for interoperability reasons.
The keys can be given as a directory path via `secrets.keys_dir` The keys can be given as a directory path via `secrets.keys_dir`
or, alternatively, as an inline configuration list via `secrets.keys`. or, alternatively, as an inline configuration list via `secrets.keys`.
[Token Endpoint]: https://openid.net/specs/openid-connect-core-1_0.html#TokenEndpoint
[UserInfo Endpoint]: https://openid.net/specs/openid-connect-core-1_0.html#UserInfo
[oidc-core-rs256]: https://openid.net/specs/openid-connect-core-1_0.html#ServerMTI
#### `secrets.keys_dir` #### `secrets.keys_dir`
Path to the directory containing MAS signing key files. Path to the directory containing MAS signing key files.
@@ -771,6 +786,14 @@ upstream_oauth2:
subject: subject:
#template: "{{ user.sub }}" #template: "{{ user.sub }}"
# By default, new users will see a screen confirming the attributes they
# are about to have on their account.
#
# Setting this to `true` allows skipping this screen, but requires the
# `localpart.action` to be set to `require` and the other attributes
# actions to be set to `ignore`, `force` or `require`.
#skip_confirmation: false
# The localpart is the local part of the user's Matrix ID. # The localpart is the local part of the user's Matrix ID.
# For example, on the `example.com` server, if the localpart is `alice`, # For example, on the `example.com` server, if the localpart is `alice`,
# the user's Matrix ID will be `@alice:example.com`. # the user's Matrix ID will be `@alice:example.com`.
@@ -780,8 +803,10 @@ upstream_oauth2:
# How to handle when localpart already exists. # How to handle when localpart already exists.
# Possible values are (default: fail): # Possible values are (default: fail):
# - `add` : Adds the upstream account link to the existing user, regardless of whether there is an existing link or not.
# - `fail` : Fails the upstream OAuth 2.0 login. # - `fail` : Fails the upstream OAuth 2.0 login.
# - `add` : Adds the upstream account link to the existing user, regardless of whether there is an existing link or not.
# - `replace` : Replace any existing upstream OAuth 2.0 identity link for this provider on the matching user.
# - `set` : Adds the upstream account link *only* if there is no existing link for this provider on the matching user.
#on_conflict: fail #on_conflict: fail
# The display name is the user's display name. # The display name is the user's display name.

View File

@@ -69,20 +69,25 @@ The template has the following variables available:
## Allow linking existing user accounts ## Allow linking existing user accounts
The authentication service supports linking external provider identities to existing local user accounts. The authentication service supports linking external provider identities to existing local user accounts if the `localpart` matches.
To enable this behavior, the following option must be explicitly set in the provider configuration: If the `localpart` given by the upstream provider matches an existing user and the `claims_imports.localpart.action` is set to `force` or `require`, by default the service will refuse to link to that existing account.
This behaviour is controlled by the `claims_imports.localpart.on_conflict` option, which can be set to:
* `fail` *(default)*: fails the upstream OAuth 2.0 login
* `add`: automatically adds the upstream account to the existing user, regardless of whether the existing user already has another upstream account or not
* `set`: automatically adds the upstream account to the existing user only if there are no other upstream accounts for that provider linked to the user
* `replace`: automatically replaces any upstream account for that provider linked to the user
```yaml ```yaml
claims_imports: upstream_oauth2:
localpart: providers:
on_conflict: add - id:
claims_imports:
localpart:
action: force
on_conflict: set
``` ```
`on_conflict` configuration is specific to `localpart` claim_imports, it can be either:
* `add` : when a user authenticates with the provider for the first time, the system checks whether a local user already exists with a `localpart` matching the attribute mapping `localpart` , _by default `{{ user.preferred_username }}`_. If a match is found, the external identity is linked to the existing local account.
* `fail` *(default)* : fails the sso login.
To enable this option, the `localpart` mapping must be set to either `force` or `require`.
> ⚠️ **Security Notice** > ⚠️ **Security Notice**
> Enabling this option can introduce a risk of account takeover. > Enabling this option can introduce a risk of account takeover.

View File

@@ -27,7 +27,7 @@ export type LocalazyMetadata = {
}; };
const localazyMetadata: LocalazyMetadata = { const localazyMetadata: LocalazyMetadata = {
projectUrl: "https://localazy.com/p/matrix-authentication-service!v1.7", projectUrl: "https://localazy.com/p/matrix-authentication-service!v1.8",
baseLocale: "en", baseLocale: "en",
languages: [ languages: [
{ {
@@ -181,22 +181,22 @@ const localazyMetadata: LocalazyMetadata = {
file: "frontend.json", file: "frontend.json",
path: "", path: "",
cdnFiles: { cdnFiles: {
"cs": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/cs/frontend.json", "cs": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/cs/frontend.json",
"da": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/da/frontend.json", "da": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/da/frontend.json",
"de": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/de/frontend.json", "de": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/de/frontend.json",
"en": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/en/frontend.json", "en": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/en/frontend.json",
"et": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/et/frontend.json", "et": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/et/frontend.json",
"fi": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/fi/frontend.json", "fi": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/fi/frontend.json",
"fr": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/fr/frontend.json", "fr": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/fr/frontend.json",
"hu": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/hu/frontend.json", "hu": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/hu/frontend.json",
"nb_NO": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/nb-NO/frontend.json", "nb_NO": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/nb-NO/frontend.json",
"nl": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/nl/frontend.json", "nl": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/nl/frontend.json",
"pl": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pl/frontend.json", "pl": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pl/frontend.json",
"pt": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pt/frontend.json", "pt": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pt/frontend.json",
"ru": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/ru/frontend.json", "ru": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/ru/frontend.json",
"sv": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/sv/frontend.json", "sv": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/sv/frontend.json",
"uk": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/uk/frontend.json", "uk": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/uk/frontend.json",
"zh#Hans": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/zh-Hans/frontend.json" "zh#Hans": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/zh-Hans/frontend.json"
} }
}, },
{ {
@@ -204,22 +204,22 @@ const localazyMetadata: LocalazyMetadata = {
file: "file.json", file: "file.json",
path: "", path: "",
cdnFiles: { cdnFiles: {
"cs": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/cs/file.json", "cs": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/cs/file.json",
"da": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/da/file.json", "da": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/da/file.json",
"de": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/de/file.json", "de": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/de/file.json",
"en": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/en/file.json", "en": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/en/file.json",
"et": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/et/file.json", "et": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/et/file.json",
"fi": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/fi/file.json", "fi": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/fi/file.json",
"fr": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/fr/file.json", "fr": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/fr/file.json",
"hu": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/hu/file.json", "hu": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/hu/file.json",
"nb_NO": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/nb-NO/file.json", "nb_NO": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/nb-NO/file.json",
"nl": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/nl/file.json", "nl": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/nl/file.json",
"pl": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pl/file.json", "pl": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pl/file.json",
"pt": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pt/file.json", "pt": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pt/file.json",
"ru": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/ru/file.json", "ru": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/ru/file.json",
"sv": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/sv/file.json", "sv": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/sv/file.json",
"uk": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/uk/file.json", "uk": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/uk/file.json",
"zh#Hans": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/zh-Hans/file.json" "zh#Hans": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/zh-Hans/file.json"
} }
} }
] ]

View File

@@ -43,7 +43,7 @@
"alert_description": "Dieses Konto wird dauerhaft entfernt und du hast keinen Zugriff mehr auf deine Nachrichten.", "alert_description": "Dieses Konto wird dauerhaft entfernt und du hast keinen Zugriff mehr auf deine Nachrichten.",
"alert_title": "Du bist kurz davor, alle deine Daten zu verlieren.", "alert_title": "Du bist kurz davor, alle deine Daten zu verlieren.",
"button": "Account löschen", "button": "Account löschen",
"dialog_description": "<text>Bestätige, dass du dein Konto löschen möchtest:</text><profile />\n<list>\n<item>Du kannst dein Konto nicht reaktivieren</item>\n<item>Du kannst dich nicht mehr anmelden</item>\n<item>Niemand kann deinen Benutzernamen (MXID) wieder verwenden, auch du nicht.</item>\n<item>Du verlässt alle Gruppen und Chats</item>\n<item>Du wirst vom Identitätsserver entfernt und niemand kann dich mit deiner E-Mail-Adresse oder Telefonnummer finden</item>\n</list>\n<text>Deine alten Nachrichten sind für Empfänger weiterhin sichtbar. Möchtest du deine gesendeten Nachrichten vor zukünftigen Gruppen-Besuchern verbergen?</text>", "dialog_description": "<text>Bestätige, dass du dein Konto löschen möchtest:</text>\n<profile />\n<list>\n<item>Du kannst dein Konto nicht reaktivieren</item>\n<item>Du kannst dich nicht mehr anmelden</item>\n<item>Niemand kann deinen Benutzernamen (MXID) wieder verwenden, auch du nicht.</item>\n<item>Du verlässt alle Gruppen und Chats</item>\n<item>Du wirst vom Identitätsserver entfernt und niemand kann dich mit deiner E-Mail-Adresse oder Telefonnummer finden</item>\n</list>\n<text>Deine alten Nachrichten sind für die jeweiligen Empfänger weiterhin sichtbar. Möchtest du deine gesendeten Nachrichten vor zukünftigen Gruppen-Besuchern verbergen?</text>",
"dialog_title": "Dieses Konto löschen?", "dialog_title": "Dieses Konto löschen?",
"erase_checkbox_label": "Ja, alle meine Nachrichten vor neuen Mitgliedern verbergen", "erase_checkbox_label": "Ja, alle meine Nachrichten vor neuen Mitgliedern verbergen",
"incorrect_password": "Falsches Passwort, versuch's nochmal", "incorrect_password": "Falsches Passwort, versuch's nochmal",

View File

@@ -319,9 +319,9 @@
"scope": { "scope": {
"edit_profile": "Edit your profile and contact details", "edit_profile": "Edit your profile and contact details",
"manage_sessions": "Manage your devices and sessions", "manage_sessions": "Manage your devices and sessions",
"mas_admin": "Administer any user on the matrix-authentication-service", "mas_admin": "Manage users (urn:mas:admin)",
"send_messages": "Send new messages on your behalf", "send_messages": "Send new messages on your behalf",
"synapse_admin": "Administer the Synapse homeserver", "synapse_admin": "Administer the server (urn:synapse:admin:*)",
"view_messages": "View your existing messages and data", "view_messages": "View your existing messages and data",
"view_profile": "See your profile info and contact details" "view_profile": "See your profile info and contact details"
} }

View File

@@ -391,9 +391,9 @@
"scope": { "scope": {
"edit_profile": "Muuta sinu kasutajaprofiili ning kontaktandmeid", "edit_profile": "Muuta sinu kasutajaprofiili ning kontaktandmeid",
"manage_sessions": "Hallata sinu seadmeid ja sessioone", "manage_sessions": "Hallata sinu seadmeid ja sessioone",
"mas_admin": "Hallata iga kasutajat teenuses matrix-authentication-service", "mas_admin": "Hallata kasutajaid (urn:mas:admin)",
"send_messages": "Saata sõnumeid sinu nimel", "send_messages": "Saata sõnumeid sinu nimel",
"synapse_admin": "Hallata seda Synapse koduserverit", "synapse_admin": "Hallata seda Synapse koduserverit (urn:synapse:admin:*)",
"view_messages": "Vaadata sinu sõnumeid ja andmeid", "view_messages": "Vaadata sinu sõnumeid ja andmeid",
"view_profile": "Vaadata sinu profiili teavet ja kontaktadmeid" "view_profile": "Vaadata sinu profiili teavet ja kontaktadmeid"
} }

View File

@@ -391,9 +391,9 @@
"scope": { "scope": {
"edit_profile": "Modifier votre profil et vos coordonnées", "edit_profile": "Modifier votre profil et vos coordonnées",
"manage_sessions": "Gérer vos appareils et vos sessions", "manage_sessions": "Gérer vos appareils et vos sessions",
"mas_admin": "Administrer n'importe quel utilisateur dans matrix-authentication-service", "mas_admin": "Administrer les utilisateurs (urn:mas:admin)",
"send_messages": "Envoyez de nouveaux messages en votre nom", "send_messages": "Envoyez de nouveaux messages en votre nom",
"synapse_admin": "Administrer le serveur daccueil Synapse", "synapse_admin": "Administrer le serveur (urn:synapse:admin:*)",
"view_messages": "Afficher vos messages et données existants", "view_messages": "Afficher vos messages et données existants",
"view_profile": "Voir les informations de votre profil et vos coordonnées" "view_profile": "Voir les informations de votre profil et vos coordonnées"
} }

View File

@@ -391,9 +391,9 @@
"scope": { "scope": {
"edit_profile": "Edit your profile and contact details", "edit_profile": "Edit your profile and contact details",
"manage_sessions": "Manage your devices and sessions", "manage_sessions": "Manage your devices and sessions",
"mas_admin": "Administer any user on the matrix-authentication-service", "mas_admin": "Manage users (urn:mas:admin)",
"send_messages": "Send new messages on your behalf", "send_messages": "Send new messages on your behalf",
"synapse_admin": "Administer the Synapse homeserver", "synapse_admin": "Administer the server (urn:synapse:admin:*)",
"view_messages": "View your existing messages and data", "view_messages": "View your existing messages and data",
"view_profile": "See your profile info and contact details" "view_profile": "See your profile info and contact details"
} }

View File

@@ -394,9 +394,9 @@
"scope": { "scope": {
"edit_profile": "Редагування профілю та контактних даних", "edit_profile": "Редагування профілю та контактних даних",
"manage_sessions": "Керування пристроями та сеансами", "manage_sessions": "Керування пристроями та сеансами",
"mas_admin": "Адміністрування будь-якого користувача на matrix-authentication-service", "mas_admin": "Керування користувачами (urn:mas:admin)",
"send_messages": "Надсилати нові повідомлення від вашого імені", "send_messages": "Надсилати нові повідомлення від вашого імені",
"synapse_admin": "Адміністрування домашнього сервера Synapse", "synapse_admin": "Адмініструвати сервер (urn:synapse:admin:*)",
"view_messages": "Перегляд наявних повідомлень і даних", "view_messages": "Перегляд наявних повідомлень і даних",
"view_profile": "Перегляд інформації профілю та контактних даних" "view_profile": "Перегляд інформації профілю та контактних даних"
} }

File diff suppressed because it is too large Load Diff

View File

@@ -22,7 +22,7 @@
"@fontsource/inter": "^5.2.8", "@fontsource/inter": "^5.2.8",
"@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@tanstack/react-query": "^5.90.10", "@tanstack/react-query": "^5.90.12",
"@tanstack/react-router": "^1.131.44", "@tanstack/react-router": "^1.131.44",
"@vector-im/compound-design-tokens": "6.4.0", "@vector-im/compound-design-tokens": "6.4.0",
"@vector-im/compound-web": "^8.2.5", "@vector-im/compound-web": "^8.2.5",
@@ -30,44 +30,44 @@
"@zxcvbn-ts/language-common": "^3.0.4", "@zxcvbn-ts/language-common": "^3.0.4",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"i18next": "^25.6.3", "i18next": "^25.7.2",
"react": "^19.2.0", "react": "^19.2.1",
"react-dom": "^19.2.0", "react-dom": "^19.2.1",
"react-i18next": "^16.3.5", "react-i18next": "^16.4.1",
"swagger-ui-dist": "^5.29.5", "swagger-ui-dist": "^5.29.5",
"valibot": "^1.1.0", "valibot": "^1.2.0",
"vaul": "^1.1.2" "vaul": "^1.1.2"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.3.2", "@biomejs/biome": "^2.3.8",
"@browser-logos/chrome": "^2.0.0", "@browser-logos/chrome": "^2.0.0",
"@browser-logos/firefox": "^3.0.10", "@browser-logos/firefox": "^3.0.10",
"@browser-logos/safari": "^2.1.0", "@browser-logos/safari": "^2.1.0",
"@graphql-codegen/cli": "^6.0.2", "@graphql-codegen/cli": "^6.1.0",
"@graphql-codegen/client-preset": "^5.1.1", "@graphql-codegen/client-preset": "^5.1.1",
"@graphql-codegen/typescript-msw": "^3.0.1", "@graphql-codegen/typescript-msw": "^3.0.1",
"@storybook/addon-docs": "^10.0.8", "@storybook/addon-docs": "^10.1.4",
"@storybook/react-vite": "^10.0.8", "@storybook/react-vite": "^10.1.4",
"@tanstack/react-query-devtools": "^5.90.2", "@tanstack/react-query-devtools": "^5.91.1",
"@tanstack/react-router-devtools": "^1.131.44", "@tanstack/react-router-devtools": "^1.131.44",
"@tanstack/router-plugin": "^1.131.44", "@tanstack/router-plugin": "^1.131.44",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@types/react": "19.2.6", "@types/react": "19.2.7",
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"@types/swagger-ui-dist": "^3.30.6", "@types/swagger-ui-dist": "^3.30.6",
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^5.1.2",
"@vitest/coverage-v8": "^3.2.4", "@vitest/coverage-v8": "^4.0.15",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.22",
"browserslist-to-esbuild": "^2.1.1", "browserslist-to-esbuild": "^2.1.1",
"graphql": "^16.11.0", "graphql": "^16.11.0",
"happy-dom": "^20.0.4", "happy-dom": "^20.0.4",
"i18next-cli": "^1.24.20", "i18next-cli": "^1.30.5",
"knip": "^5.66.4", "knip": "^5.66.4",
"msw": "^2.11.6", "msw": "^2.11.6",
"msw-storybook-addon": "^2.0.5", "msw-storybook-addon": "^2.0.6",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"postcss-import": "^16.1.1", "postcss-import": "^16.1.1",
"postcss-nesting": "^13.0.2", "postcss-nesting": "^13.0.2",
@@ -76,11 +76,11 @@
"tailwindcss": "^3.4.18", "tailwindcss": "^3.4.18",
"tinyglobby": "^0.2.15", "tinyglobby": "^0.2.15",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "7.2.4", "vite": "7.2.7",
"vite-plugin-compression": "^0.5.1", "vite-plugin-compression": "^0.5.1",
"vite-plugin-graphql-codegen": "^3.7.0", "vite-plugin-graphql-codegen": "^3.7.0",
"vite-plugin-manifest-sri": "^0.2.0", "vite-plugin-manifest-sri": "^0.2.0",
"vitest": "^3.2.4" "vitest": "^4.0.14"
}, },
"msw": { "msw": {
"workerDirectory": [ "workerDirectory": [

View File

@@ -23,9 +23,9 @@
letter-spacing: var(--cpd-font-letter-spacing-body-lg); letter-spacing: var(--cpd-font-letter-spacing-body-lg);
} }
.cpd-text-heading-xl-semibold { .cpd-text-body-lg-semibold {
font: var(--cpd-font-heading-xl-semibold); font: var(--cpd-font-body-lg-semibold);
letter-spacing: var(--cpd-font-letter-spacing-heading-xl); letter-spacing: var(--cpd-font-letter-spacing-body-lg);
} }
.cpd-text-body-md-regular { .cpd-text-body-md-regular {
@@ -33,6 +33,36 @@
letter-spacing: var(--cpd-font-letter-spacing-body-md); letter-spacing: var(--cpd-font-letter-spacing-body-md);
} }
.cpd-text-body-md-semibold {
font: var(--cpd-font-body-md-semibold);
letter-spacing: var(--cpd-font-letter-spacing-body-md);
}
.cpd-text-body-sm-regular {
font: var(--cpd-font-body-sm-regular);
letter-spacing: var(--cpd-font-letter-spacing-body-sm);
}
.cpd-text-body-sm-semibold {
font: var(--cpd-font-body-sm-semibold);
letter-spacing: var(--cpd-font-letter-spacing-body-sm);
}
.cpd-text-body-xs-regular {
font: var(--cpd-font-body-xs-regular);
letter-spacing: var(--cpd-font-letter-spacing-body-xs);
}
.cpd-text-body-xs-semibold {
font: var(--cpd-font-body-xs-semibold);
letter-spacing: var(--cpd-font-letter-spacing-body-xs);
}
.cpd-text-heading-xl-semibold {
font: var(--cpd-font-heading-xl-semibold);
letter-spacing: var(--cpd-font-letter-spacing-heading-xl);
}
.cpd-text-primary { .cpd-text-primary {
color: var(--cpd-color-text-primary); color: var(--cpd-color-text-primary);
} }
@@ -186,3 +216,48 @@
} }
} }
} }
.avatar-placeholder {
height: var(--cpd-space-14x);
width: var(--cpd-space-14x);
border-radius: 50%;
overflow: hidden;
user-select: none;
line-height: var(--cpd-space-14x);
font-size: 32px;
font-family: var(--cpd-font-family-sans);
font-weight: bold;
text-align: center;
background-color: var(--cpd-avatar-bg);
color: var(--cpd-avatar-color);
&[data-color] {
--cpd-avatar-bg: var(--cpd-color-bg-decorative-1);
--cpd-avatar-color: var(--cpd-color-text-decorative-1);
}
&[data-color="2"] {
--cpd-avatar-bg: var(--cpd-color-bg-decorative-2);
--cpd-avatar-color: var(--cpd-color-text-decorative-2);
}
&[data-color="3"] {
--cpd-avatar-bg: var(--cpd-color-bg-decorative-3);
--cpd-avatar-color: var(--cpd-color-text-decorative-3);
}
&[data-color="4"] {
--cpd-avatar-bg: var(--cpd-color-bg-decorative-4);
--cpd-avatar-color: var(--cpd-color-text-decorative-4);
}
&[data-color="5"] {
--cpd-avatar-bg: var(--cpd-color-bg-decorative-5);
--cpd-avatar-color: var(--cpd-color-text-decorative-5);
}
&[data-color="6"] {
--cpd-avatar-bg: var(--cpd-color-bg-decorative-6);
--cpd-avatar-color: var(--cpd-color-text-decorative-6);
}
}

View File

@@ -12,11 +12,31 @@ import { vi } from "vitest";
* Defaults to `en-GB` * Defaults to `en-GB`
*/ */
export const mockLocale = (defaultLocale = "en-GB"): void => { export const mockLocale = (defaultLocale = "en-GB"): void => {
const { DateTimeFormat } = Intl; const OriginalDateTimeFormat = Intl.DateTimeFormat;
// Vitest 4.x requires function/class implementations for spyOn mocks when
// mocking constructors. For built-in constructors like Intl.DateTimeFormat
// that have internal slots, we use a function that returns a new instance.
// This is valid JavaScript - when a constructor returns an object, that
// object becomes the instance (instead of `this`).
function MockDateTimeFormat(
this: unknown,
locales?: Intl.LocalesArgument,
options?: Intl.DateTimeFormatOptions,
): Intl.DateTimeFormat {
// Apply default locale when no locale is specified
return new OriginalDateTimeFormat(locales || defaultLocale, options);
}
// Inherit static methods from the original DateTimeFormat
Object.setPrototypeOf(MockDateTimeFormat, OriginalDateTimeFormat);
// Set up prototype chain so instanceof checks work correctly
Object.setPrototypeOf(
MockDateTimeFormat.prototype,
OriginalDateTimeFormat.prototype,
);
vi.spyOn(Intl, "DateTimeFormat").mockImplementation( vi.spyOn(Intl, "DateTimeFormat").mockImplementation(
( MockDateTimeFormat as typeof Intl.DateTimeFormat,
locales?: Intl.LocalesArgument,
options?: Intl.DateTimeFormatOptions | undefined,
) => new DateTimeFormat(locales || defaultLocale, options),
); );
}; };

View File

@@ -16,6 +16,7 @@ INPUTS := \
client_registration/client_registration.rego \ client_registration/client_registration.rego \
register/register.rego \ register/register.rego \
authorization_grant/authorization_grant.rego \ authorization_grant/authorization_grant.rego \
compat_login/compat_login.rego \
email/email.rego email/email.rego
ifeq ($(DOCKER), 1) ifeq ($(DOCKER), 1)
@@ -38,6 +39,7 @@ policy.wasm: $(INPUTS)
-e "client_registration/violation" \ -e "client_registration/violation" \
-e "register/violation" \ -e "register/violation" \
-e "authorization_grant/violation" \ -e "authorization_grant/violation" \
-e "compat_login/violation" \
-e "email/violation" \ -e "email/violation" \
$^ $^
tar xzf bundle.tar.gz /policy.wasm tar xzf bundle.tar.gz /policy.wasm

View File

@@ -0,0 +1,74 @@
# Copyright 2025 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.
# METADATA
# schemas:
# - input: schema["compat_login_input"]
package compat_login
import rego.v1
import data.common
default allow := false
allow if {
count(violation) == 0
}
violation contains {"msg": sprintf(
"Requester [%s] isn't allowed to do this action",
[common.format_requester(input.requester)],
)} if {
common.requester_banned(input.requester, data.requester)
}
violation contains {
"code": "too-many-sessions",
"msg": "user has too many active sessions (soft limit)",
} if {
# Only apply if session limits are enabled in the config
data.session_limit != null
# This is a web-based interactive login
is_interactive
# Only apply if this login doesn't replace a session
# (As then this login is not actually increasing the number of devices)
not input.session_replaced
# For web-based 'compat SSO' login, a violation occurs when the soft limit has already been
# reached or exceeded.
# We use the soft limit because the user will be able to interactively remove
# sessions to return under the limit.
data.session_limit.soft_limit <= input.session_counts.total
}
violation contains {
"code": "too-many-sessions",
"msg": "user has too many active sessions (hard limit)",
} if {
# Only apply if session limits are enabled in the config
data.session_limit != null
# This is not a web-based interactive login
not is_interactive
# Only apply if this login doesn't replace a session
# (As then this login is not actually increasing the number of devices)
not input.session_replaced
# For `m.login.password` login, a violation occurs when the hard limit has already been
# reached or exceeded.
# We don't use the soft limit because the user won't be able to interactively remove
# sessions to return under the limit.
data.session_limit.hard_limit <= input.session_counts.total
}
is_interactive if {
# Only `m.login.sso` (the interactive web form) is interactive;
# `m.login.password` and `m.login.token` (including the finalisation of an SSO login) are not
input.login.type == "m.login.sso"
}

View File

@@ -0,0 +1,99 @@
# Copyright 2025 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.
package compat_login_test
import data.compat_login
import rego.v1
user := {"username": "john"}
# Tests session limiting when using (the interactive part of) `m.login.sso`
test_session_limiting_sso if {
compat_login.allow with input.user as user
with input.session_counts as {"total": 1}
with input.login as {"type": "m.login.sso"}
with input.session_replaced as false
with data.session_limit as {"soft_limit": 32, "hard_limit": 64}
compat_login.allow with input.user as user
with input.session_counts as {"total": 31}
with input.login as {"type": "m.login.sso"}
with input.session_replaced as false
with data.session_limit as {"soft_limit": 32, "hard_limit": 64}
not compat_login.allow with input.user as user
with input.session_counts as {"total": 32}
with input.login as {"type": "m.login.sso"}
with input.session_replaced as false
with data.session_limit as {"soft_limit": 32, "hard_limit": 64}
not compat_login.allow with input.user as user
with input.session_counts as {"total": 42}
with input.login as {"type": "m.login.sso"}
with input.session_replaced as false
with data.session_limit as {"soft_limit": 32, "hard_limit": 64}
not compat_login.allow with input.user as user
with input.session_counts as {"total": 65}
with input.login as {"type": "m.login.sso"}
with input.session_replaced as false
with data.session_limit as {"soft_limit": 32, "hard_limit": 64}
# No limit configured
compat_login.allow with input.user as user
with input.session_counts as {"total": 1}
with input.login as {"type": "m.login.sso"}
with input.session_replaced as false
with data.session_limit as null
}
# Test session limiting when using `m.login.password`
test_session_limiting_password if {
compat_login.allow with input.user as user
with input.session_counts as {"total": 1}
with input.login as {"type": "m.login.password"}
with input.session_replaced as false
with data.session_limit as {"soft_limit": 32, "hard_limit": 64}
compat_login.allow with input.user as user
with input.session_counts as {"total": 63}
with input.login as {"type": "m.login.password"}
with input.session_replaced as false
with data.session_limit as {"soft_limit": 32, "hard_limit": 64}
not compat_login.allow with input.user as user
with input.session_counts as {"total": 64}
with input.login as {"type": "m.login.password"}
with input.session_replaced as false
with data.session_limit as {"soft_limit": 32, "hard_limit": 64}
not compat_login.allow with input.user as user
with input.session_counts as {"total": 65}
with input.login as {"type": "m.login.password"}
with input.session_replaced as false
with data.session_limit as {"soft_limit": 32, "hard_limit": 64}
# No limit configured
compat_login.allow with input.user as user
with input.session_counts as {"total": 1}
with input.login as {"type": "m.login.password"}
with input.session_replaced as false
with data.session_limit as null
}
test_no_session_limiting_upon_replacement if {
not compat_login.allow with input.user as user
with input.session_counts as {"total": 65}
with input.login as {"type": "m.login.password"}
with input.session_replaced as false
with data.session_limit as {"soft_limit": 32, "hard_limit": 64}
not compat_login.allow with input.user as user
with input.session_counts as {"total": 65}
with input.login as {"type": "m.login.sso"}
with input.session_replaced as false
with data.session_limit as {"soft_limit": 32, "hard_limit": 64}
}

View File

@@ -0,0 +1,144 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "CompatLoginInput",
"description": "Input for the compatibility login policy.",
"type": "object",
"properties": {
"user": {
"type": "object",
"additionalProperties": true
},
"session_counts": {
"description": "How many sessions the user has.",
"allOf": [
{
"$ref": "#/definitions/SessionCounts"
}
]
},
"session_replaced": {
"description": "Whether a session will be replaced by this login",
"type": "boolean"
},
"login": {
"description": "What type of login is being performed.\n This also determines whether the login is interactive.",
"allOf": [
{
"$ref": "#/definitions/CompatLogin"
}
]
},
"requester": {
"$ref": "#/definitions/Requester"
}
},
"required": [
"user",
"session_counts",
"session_replaced",
"login",
"requester"
],
"definitions": {
"SessionCounts": {
"description": "Information about how many sessions the user has",
"type": "object",
"properties": {
"total": {
"type": "integer",
"format": "uint64",
"minimum": 0
},
"oauth2": {
"type": "integer",
"format": "uint64",
"minimum": 0
},
"compat": {
"type": "integer",
"format": "uint64",
"minimum": 0
},
"personal": {
"type": "integer",
"format": "uint64",
"minimum": 0
}
},
"required": [
"total",
"oauth2",
"compat",
"personal"
]
},
"CompatLogin": {
"oneOf": [
{
"description": "Used as the interactive part of SSO login.",
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "m.login.sso"
},
"redirect_uri": {
"type": "string"
}
},
"required": [
"type",
"redirect_uri"
]
},
{
"description": "Used as the final (non-interactive) stage of SSO login.",
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "m.login.token"
}
},
"required": [
"type"
]
},
{
"description": "Non-interactive password-over-the-API login.",
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "m.login.password"
}
},
"required": [
"type"
]
}
]
},
"Requester": {
"description": "Identity of the requester",
"type": "object",
"properties": {
"ip_address": {
"description": "IP address of the entity making the request",
"type": [
"string",
"null"
],
"format": "ip"
},
"user_agent": {
"description": "User agent of the entity making the request",
"type": [
"string",
"null"
]
}
}
}
}
}

View File

@@ -6,16 +6,18 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details. Please see LICENSE files in the repository root for full details.
-#} -#}
{% macro button(text, csrf_token, as_link=false, post_logout_action={}) %} {% macro button(csrf_token, text="", as_link=false, post_logout_action={}) %}
<form method="POST" action="{{ "/logout" | prefix_url }}" class="inline-flex"> <form method="POST" action="{{ "/logout" | prefix_url }}" class="inline-flex [&>button]:flex-1">
<input type="hidden" name="csrf" value="{{ csrf_token }}" /> <input type="hidden" name="csrf" value="{{ csrf_token }}" />
{% for key, value in post_logout_action|items %} {% for key, value in post_logout_action|items %}
<input type="hidden" name="{{ key }}" value="{{ value }}" /> <input type="hidden" name="{{ key }}" value="{{ value }}" />
{% endfor %} {% endfor %}
{% if as_link %} {% if caller is defined %}
<button class="cpd-link flex-1" data-kind="critical" type="submit">{{ text }}</button> {{ caller() }}
{% elif as_link %}
<button class="cpd-link" data-kind="critical" type="submit">{{ text }}</button>
{% else %} {% else %}
<button class="cpd-button destructive flex-1" data-kind="secondary" data-size="lg" type="submit">{{ text }}</button> <button class="cpd-button destructive" data-kind="secondary" data-size="lg" type="submit">{{ text }}</button>
{% endif %} {% endif %}
</form> </form>
{% endmacro %} {% endmacro %}

View File

@@ -6,9 +6,38 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details. Please see LICENSE files in the repository root for full details.
-#} -#}
{# Macro to remove 'safe' scope from a scope list. Usage:
{% call(scopes) scope.unsafe_scopes(scopes=["openid", "urn:matrix:client:api:*", "urn:synapse:admin:*", "urn:mas:admin"]) %}
`scopes` only has unsafe scopes: ["urn:synapse:admin:*", "urn:mas:admin"]
<ul>
{% for scope in scopes %}
<li>{{ scope }}</li>
{% endfor %}
</ul>
{% endcall %}
#}
{% macro unsafe_scopes(scopes) -%}
{% set ns = namespace(unsafe_scopes=[]) %}
{% set safe_scope_prefixes = ["openid", "urn:matrix:client:api:", "urn:matrix:org.matrix.msc2967.client:api:", "urn:matrix:client:device:", "urn:matrix:org.matrix.msc2967.client:device:"] %}
{% for scope in scopes %}
{% set ns.is_safe = False %}
{% for safe_scope_prefix in safe_scope_prefixes %}
{% if scope.startswith(safe_scope_prefix) %}
{% set ns.is_safe = True %}
{% endif %}
{% endfor %}
{% if not ns.is_safe %}
{% set ns.unsafe_scopes = ns.unsafe_scopes + [scope] %}
{% endif %}
{% endfor %}
{{ caller(ns.unsafe_scopes) }}
{%- endmacro %}
{% macro list(scopes) %} {% macro list(scopes) %}
<ul> <ul>
{% for scope in (scopes | split(" ")) %} {% for scope in scopes %}
{% if scope == "openid" %} {% if scope == "openid" %}
<li>{{ icon.user_profile() }}<p>{{ _("mas.scope.view_profile") }}</p></li> <li>{{ icon.user_profile() }}<p>{{ _("mas.scope.view_profile") }}</p></li>
{% elif scope == "urn:mas:graphql:*" %} {% elif scope == "urn:mas:graphql:*" %}
@@ -18,9 +47,9 @@ Please see LICENSE files in the repository root for full details.
<li>{{ icon.chat() }}<p>{{ _("mas.scope.view_messages") }}</p></li> <li>{{ icon.chat() }}<p>{{ _("mas.scope.view_messages") }}</p></li>
<li>{{ icon.send() }}<p>{{ _("mas.scope.send_messages") }}</p></li> <li>{{ icon.send() }}<p>{{ _("mas.scope.send_messages") }}</p></li>
{% elif scope == "urn:synapse:admin:*" %} {% elif scope == "urn:synapse:admin:*" %}
<li class="dangerous">{{ icon.room() }}<p>{{ _("mas.scope.synapse_admin") }}</p></li> <li class="dangerous">{{ icon.room() }}<p>{{ _("mas.scope.synapse_admin", scope=scope) }}</p></li>
{% elif scope == "urn:mas:admin" %} {% elif scope == "urn:mas:admin" %}
<li class="dangerous">{{ icon.admin() }}<p>{{ _("mas.scope.mas_admin") }}</p></li> <li class="dangerous">{{ icon.admin() }}<p>{{ _("mas.scope.mas_admin", scope=scope) }}</p></li>
{% elif scope is startingwith("urn:matrix:client:device:") or scope is startingwith("urn:matrix:org.matrix.msc2967.client:device:") %} {% elif scope is startingwith("urn:matrix:client:device:") or scope is startingwith("urn:matrix:org.matrix.msc2967.client:device:") %}
{# We hide this scope #} {# We hide this scope #}
{% else %} {% else %}

View File

@@ -0,0 +1,31 @@
{#
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.
-#}
{% extends "base.html" %}
{% block content %}
<header class="page-heading">
<div class="icon invalid">
{{ icon.error_solid() }}
</div>
<div class="header">
<h1 class="title">{{ _("mas.policy_violation.heading") }}</h1>
<p class="text">{{ _("mas.policy_violation.description") }}</p>
</div>
</header>
<main class="flex flex-col gap-10">
<div class="flex gap-1 justify-center items-center">
<p class="cpd-text-secondary cpd-text-body-md-regular">
{{ _("mas.policy_violation.logged_as", username=current_session.user.username) }}
</p>
{{ logout.button(text=_("action.sign_out"), csrf_token=csrf_token, post_logout_action=action, as_link=True) }}
</div>
</main>
{% endblock content %}

View File

@@ -12,6 +12,7 @@ Please see LICENSE files in the repository root for full details.
{% block content %} {% block content %}
{% set client_name = client.client_name or client.client_id %} {% set client_name = client.client_name or client.client_id %}
<header class="page-heading"> <header class="page-heading">
{% if client.logo_uri %} {% if client.logo_uri %}
<img class="consent-client-icon image" referrerpolicy="no-referrer" src="{{ client.logo_uri }}" /> <img class="consent-client-icon image" referrerpolicy="no-referrer" src="{{ client.logo_uri }}" />
@@ -22,33 +23,42 @@ Please see LICENSE files in the repository root for full details.
{% endif %} {% endif %}
<div class="header"> <div class="header">
<h1 class="title">{{ _("mas.consent.heading") }}</h1> <h1 class="title">
<p class="text [&>span]:whitespace-nowrap"> {{ _('mas.consent.continue_to', client_name=client_name) }}
{{ _("mas.consent.client_wants_access", client_name=client_name, redirect_uri=(grant.redirect_uri | simplify_url)) }} </h1>
{{ _("mas.consent.this_will_allow", client_name=client_name) }} <p class="text [&>span]:whitespace-nowrap [&>span]:text-[var(--cpd-color-text-link-external)]">
{{ _("mas.consent.this_will_setup", client_name=client_name, client_uri=((client.client_uri or grant.redirect_uri) | simplify_url), server_name=branding.server_name) }}
</p> </p>
</div> </div>
</header> </header>
<section class="consent-scope-list"> {% call(scopes) scope.unsafe_scopes(scopes=grant.scope.split(" ")) %}
{{ scope.list(scopes=grant.scope) }} {% if scopes is not empty %}
</section> <section class="flex flex-col gap-3">
<p class="text-center cpd-text-body-md-regular">
<section class="text-center cpd-text-secondary cpd-text-body-md-regular [&>span]:whitespace-nowrap"> {{ _('mas.consent.scope_list_preface', client_name=client_name) }}
<strong class="font-semibold cpd-text-primary [&>span]:whitespace-nowrap">{{ _("mas.consent.make_sure_you_trust", client_name=client_name) }}</strong> </p>
{{ _("mas.consent.you_may_be_sharing") }} <div class="consent-scope-list">
{% if client.policy_uri or client.tos_uri %} {{ scope.list(scopes=scopes) }}
Find out how <span>{{ client_name }}</span> will handle your data by reviewing its </div>
{% if client.policy_uri %} </section>
<a target="_blank" href="{{ client.policy_uri }}" class="cpd-link" data-kind="primary">privacy policy</a>{% if not client.tos_uri %}.{% endif %}
{% endif %}
{% if client.policy_uri and client.tos_uri%}
and
{% endif %}
{% if client.tos_uri %}
<a target="_blank" href="{{ client.tos_uri }}" class="cpd-link" data-kind="primary">terms of service</a>.
{% endif %}
{% endif %} {% endif %}
{% endcall %}
{% set initial -%}
{%- if matrix_user.display_name -%}
{{- matrix_user.display_name[0] | upper -}}
{%- else -%}
{{- matrix_user.mxid[1] | upper -}}
{%- endif -%}
{%- endset %}
<section class="flex items-center p-4 gap-4 border border-[var(--cpd-color-gray-400)] rounded-xl">
<div class="avatar-placeholder" data-color="{{ matrix_user.mxid | id_color_hash }}">{{ initial }}</div>
<div class="flex flex-col">
<div class="text-primary cpd-text-body-lg-semibold">{{ matrix_user.display_name or current_session.user.username }}</div>
<div class="text-secondary cpd-text-body-md-regular">{{ matrix_user.mxid }}</div>
</div>
</section> </section>
<section class="flex flex-col gap-6"> <section class="flex flex-col gap-6">
@@ -57,13 +67,11 @@ Please see LICENSE files in the repository root for full details.
{{ button.button(text=_("action.continue")) }} {{ button.button(text=_("action.continue")) }}
</form> </form>
<div class="flex gap-1 justify-center items-center"> {% call logout.button(csrf_token=csrf_token, post_logout_action=action) %}
<p class="cpd-text-secondary cpd-text-body-md-regular"> <button type="submit" class="cpd-button primary" data-kind="secondary" data-size="lg" type="submit">
{{ _("mas.not_you", username=current_session.user.username) }} {{ _("mas.consent.use_another_account") }}
</p> </button>
{% endcall %}
{{ logout.button(text=_("action.sign_out"), csrf_token=csrf_token, post_logout_action=action, as_link=true) }}
</div>
{{ back_to_client.link( {{ back_to_client.link(
text=_("action.cancel"), text=_("action.cancel"),

View File

@@ -25,9 +25,15 @@ Please see LICENSE files in the repository root for full details.
{% endif %} {% endif %}
<div class="header"> <div class="header">
<h1 class="title">{{ _("mas.consent.heading") }}</h1> <h1 class="title">
{{ _('mas.consent.continue_to', client_name=client_name) }}
</h1>
<div class="session-card my-4"> <p class="text [&>span]:whitespace-nowrap [&>span]:text-[var(--cpd-color-text-link-external)]">
{{ _("mas.device_consent.this_will_setup", client_name=client_name, client_uri=((client.client_uri or "") | simplify_url), server_name=branding.server_name) }}
</p>
<div class="session-card mt-4">
<div class="card-header" {%- if user_agent %} title="{{ user_agent.raw }}"{% endif %}> <div class="card-header" {%- if user_agent %} title="{{ user_agent.raw }}"{% endif %}>
<div class="device-type-icon"> <div class="device-type-icon">
{% if user_agent.device_type == "mobile" %} {% if user_agent.device_type == "mobile" %}
@@ -88,33 +94,36 @@ Please see LICENSE files in the repository root for full details.
</div> </div>
</div> </div>
</div> </div>
<p class="text [&>span]:whitespace-nowrap">
{{ _("mas.device_consent.another_device_access") }}
{{ _("mas.consent.this_will_allow", client_name=client_name) }}
</p>
</div> </div>
</header> </header>
<section class="consent-scope-list"> {% call(scopes) scope.unsafe_scopes(scopes=grant.scope.split(" ")) %}
{{ scope.list(scopes=grant.scope) }} {% if scopes is not empty %}
</section> <section class="flex flex-col gap-3">
<p class="text-center cpd-text-body-md-regular">
<section class="text-center text-balance cpd-text-secondary cpd-text-body-md-regular [&>span]:whitespace-nowrap"> {{ _('mas.consent.scope_list_preface', client_name=client_name) }}
<strong class="font-semibold cpd-text-primary [&>span]:whitespace-nowrap">{{ _("mas.consent.make_sure_you_trust", client_name=client_name) }}</strong> </p>
{{ _("mas.consent.you_may_be_sharing") }} <div class="consent-scope-list">
{% if client.policy_uri or client.tos_uri %} {{ scope.list(scopes=scopes) }}
Find out how <span>{{ client_name }}</span> will handle your data by reviewing its </div>
{% if client.policy_uri %} </section>
<a target="_blank" href="{{ client.policy_uri }}" class="cpd-link" data-kind="primary">privacy policy</a>{% if not client.tos_uri %}.{% endif %}
{% endif %}
{% if client.policy_uri and client.tos_uri%}
and
{% endif %}
{% if client.tos_uri %}
<a target="_blank" href="{{ client.tos_uri }}" class="cpd-link" data-kind="primary">terms of service</a>.
{% endif %}
{% endif %} {% endif %}
{% endcall %}
{% set initial -%}
{%- if matrix_user.display_name -%}
{{- matrix_user.display_name[0] | upper -}}
{%- else -%}
{{- matrix_user.mxid[1] | upper -}}
{%- endif -%}
{%- endset %}
<section class="flex items-center p-4 gap-4 border border-[var(--cpd-color-gray-400)] rounded-xl">
<div class="avatar-placeholder" data-color="{{ matrix_user.mxid | id_color_hash }}">{{ initial }}</div>
<div class="flex flex-col">
<div class="text-primary cpd-text-body-lg-semibold">{{ matrix_user.display_name or current_session.user.username }}</div>
<div class="text-secondary cpd-text-body-md-regular">{{ matrix_user.mxid }}</div>
</div>
</section> </section>
<section class="flex flex-col gap-6"> <section class="flex flex-col gap-6">
@@ -123,18 +132,20 @@ Please see LICENSE files in the repository root for full details.
<button type="submit" name="action" value="consent" class="cpd-button" data-kind="primary" data-size="lg"> <button type="submit" name="action" value="consent" class="cpd-button" data-kind="primary" data-size="lg">
{{ _("action.continue") }} {{ _("action.continue") }}
</button> </button>
<button type="submit" name="action" value="reject" class="cpd-button destructive" data-kind="secondary" data-size="lg"> </form>
{% call logout.button(csrf_token=csrf_token, post_logout_action=action) %}
<button type="submit" class="cpd-button primary flex-1" data-kind="secondary" data-size="lg" type="submit">
{{ _("mas.consent.use_another_account") }}
</button>
{% endcall %}
<form method="POST" class="cpd-form-root">
<input type="hidden" name="csrf" value="{{ csrf_token }}" />
<button type="submit" name="action" value="reject" class="cpd-button" data-kind="tertiary" data-size="lg">
{{ _("action.cancel") }} {{ _("action.cancel") }}
</button> </button>
</form> </form>
<div class="flex gap-1 justify-center items-center">
<p class="cpd-text-secondary cpd-text-body-md-regular">
{{ _("mas.not_you", username=current_session.user.username) }}
</p>
{{ logout.button(text=_("action.sign_out"), csrf_token=csrf_token, post_logout_action=action, as_link=true) }}
</div>
</section> </section>
{% elif grant.state == "rejected" %} {% elif grant.state == "rejected" %}
<header class="page-heading"> <header class="page-heading">

View File

@@ -6,6 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details. Please see LICENSE files in the repository root for full details.
-#} -#}
{% set consent_page = true %}
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
@@ -17,18 +19,29 @@ Please see LICENSE files in the repository root for full details.
</div> </div>
<div class="header"> <div class="header">
<h1 class="title">Allow access to your account?</h1> <h1 class="title">
<p class="text"><span class="whitespace-nowrap">{{ client_name }}</span> wants to access your account. This will allow <span class="whitespace-nowrap">{{ client_name }}</span> to:</p> {{ _('mas.consent.continue_to', client_name=client_name) }}
</h1>
<p class="text [&>span]:whitespace-nowrap [&>span]:text-[var(--cpd-color-text-link-external)]">
{{ _("mas.legacy_consent.this_will_setup", client_name=client_name, server_name=branding.server_name) }}
</p>
</div> </div>
</header> </header>
<section class="consent-scope-list"> {% set initial -%}
{{ scope.list(scopes="openid urn:matrix:client:api:*") }} {%- if matrix_user.display_name -%}
</section> {{- matrix_user.display_name[0] | upper -}}
{%- else -%}
{{- matrix_user.mxid[1] | upper -}}
{%- endif -%}
{%- endset %}
<section class="text-center cpd-text-secondary cpd-text-body-md-regular"> <section class="flex items-center p-4 gap-4 border border-[var(--cpd-color-gray-400)] rounded-xl">
<span class="font-semibold cpd-text-primary">Make sure that you trust <span class="whitespace-nowrap">{{ client_name }}</span>.</span> <div class="avatar-placeholder" data-color="{{ matrix_user.mxid | id_color_hash }}">{{ initial }}</div>
You may be sharing sensitive information with this site or app. <div class="flex flex-col">
<div class="text-primary cpd-text-body-lg-semibold">{{ matrix_user.display_name or current_session.user.username }}</div>
<div class="text-secondary cpd-text-body-md-regular">{{ matrix_user.mxid }}</div>
</div>
</section> </section>
<section class="flex flex-col gap-6"> <section class="flex flex-col gap-6">
@@ -37,12 +50,10 @@ Please see LICENSE files in the repository root for full details.
{{ button.button(text=_("action.continue")) }} {{ button.button(text=_("action.continue")) }}
</form> </form>
<div class="flex gap-1 justify-center items-center"> {% call logout.button(csrf_token=csrf_token, post_logout_action=action) %}
<p class="cpd-text-secondary cpd-text-body-md-regular"> <button type="submit" class="cpd-button primary" data-kind="secondary" data-size="lg" type="submit">
{{ _("mas.not_you", username=current_session.user.username) }} {{ _("mas.consent.use_another_account") }}
</p> </button>
{% endcall %}
{{ logout.button(text=_("action.sign_out"), csrf_token=csrf_token, post_logout_action=action, as_link=true) }}
</div>
</section> </section>
{% endblock content %} {% endblock content %}

View File

@@ -6,11 +6,11 @@
}, },
"cancel": "Cancel", "cancel": "Cancel",
"@cancel": { "@cancel": {
"context": "pages/consent.html:69:11-29, pages/device_consent.html:127:13-31, pages/policy_violation.html:44:13-31" "context": "pages/consent.html:77:11-29, pages/device_consent.html:146:13-31, pages/policy_violation.html:44:13-31"
}, },
"continue": "Continue", "continue": "Continue",
"@continue": { "@continue": {
"context": "form_post.html:25:28-48, pages/consent.html:57:28-48, pages/device_consent.html:124:13-33, pages/device_link.html:40:26-46, 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:37:28-48" "context": "form_post.html:25:28-48, pages/consent.html:67:28-48, pages/device_consent.html:133:13-33, pages/device_link.html:40:26-46, 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:50:28-48"
}, },
"create_account": "Create Account", "create_account": "Create Account",
"@create_account": { "@create_account": {
@@ -22,7 +22,7 @@
}, },
"sign_out": "Sign out", "sign_out": "Sign out",
"@sign_out": { "@sign_out": {
"context": "pages/account/logged_out.html:22:28-48, pages/consent.html:65:28-48, pages/device_consent.html:136:30-50, pages/index.html:28:28-48, pages/policy_violation.html:38:28-48, pages/sso.html:45: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:22:28-48, pages/compat_login_policy_violation.html:28:28-48, pages/index.html:28:28-48, pages/policy_violation.html:38:28-48, pages/upstream_oauth2/link_mismatch.html:24:24-44, pages/upstream_oauth2/suggest_link.html:32:26-46"
}, },
"skip": "Skip", "skip": "Skip",
"@skip": { "@skip": {
@@ -165,8 +165,6 @@
"@current": { "@current": {
"description": "Field for the user's current password" "description": "Field for the user's current password"
}, },
"description": "This will change the password on your account.",
"@description": {},
"heading": "Change my password", "heading": "Change my password",
"@heading": { "@heading": {
"description": "Heading on the change password page" "description": "Heading on the change password page"
@@ -189,43 +187,39 @@
} }
}, },
"consent": { "consent": {
"client_wants_access": "<span>%(client_name)s</span> at <span>%(redirect_uri)s</span> wants to access your account.", "continue_to": "Continue to <span>%(client_name)s</span>?",
"@client_wants_access": { "@continue_to": {
"context": "pages/consent.html:27:11-122" "context": "pages/consent.html:27:11-64, pages/device_consent.html:29:13-66, pages/sso.html:23:11-64"
}, },
"heading": "Allow access to your account?", "scope_list_preface": "By continuing, you allow <span>%(client_name)s</span> to:",
"@heading": { "@scope_list_preface": {
"context": "pages/consent.html:25:27-51, pages/device_consent.html:28:29-53" "context": "pages/consent.html:39:13-73, pages/device_consent.html:104:15-75"
}, },
"make_sure_you_trust": "Make sure that you trust <span>%(client_name)s</span>.", "this_will_setup": "This will set up %(client_name)s (<span>%(client_uri)s</span>) with your <span>%(server_name)s</span> account.",
"@make_sure_you_trust": { "@this_will_setup": {
"context": "pages/consent.html:38:81-142, pages/device_consent.html:104:83-144" "context": "pages/consent.html:30:11-173"
}, },
"this_will_allow": "This will allow <span>%(client_name)s</span> to:", "use_another_account": "Use another account",
"@this_will_allow": { "@use_another_account": {
"context": "pages/consent.html:28:11-68, pages/device_consent.html:94:13-70" "context": "pages/consent.html:72:11-47, pages/device_consent.html:139:13-49, pages/sso.html:55:11-47"
},
"you_may_be_sharing": "You may be sharing sensitive information with this site or app.",
"@you_may_be_sharing": {
"context": "pages/consent.html:39:7-42, pages/device_consent.html:105:9-44"
} }
}, },
"device_card": { "device_card": {
"access_requested": "Access requested", "access_requested": "Access requested",
"@access_requested": { "@access_requested": {
"context": "pages/device_consent.html:82:34-71" "context": "pages/device_consent.html:88:34-71"
}, },
"device_code": "Code", "device_code": "Code",
"@device_code": { "@device_code": {
"context": "pages/device_consent.html:86:34-66" "context": "pages/device_consent.html:92:34-66"
}, },
"generic_device": "Device", "generic_device": "Device",
"@generic_device": { "@generic_device": {
"context": "pages/device_consent.html:70:22-57" "context": "pages/device_consent.html:76:22-57"
}, },
"ip_address": "IP address", "ip_address": "IP address",
"@ip_address": { "@ip_address": {
"context": "pages/device_consent.html:77:36-67" "context": "pages/device_consent.html:83:36-67"
} }
}, },
"device_code_link": { "device_code_link": {
@@ -239,29 +233,29 @@
} }
}, },
"device_consent": { "device_consent": {
"another_device_access": "Another device wants to access your account.",
"@another_device_access": {
"context": "pages/device_consent.html:93:13-58"
},
"denied": { "denied": {
"description": "You denied access to %(client_name)s. You can close this window.", "description": "You denied access to %(client_name)s. You can close this window.",
"@description": { "@description": {
"context": "pages/device_consent.html:147:27-94" "context": "pages/device_consent.html:158:27-94"
}, },
"heading": "Access denied", "heading": "Access denied",
"@heading": { "@heading": {
"context": "pages/device_consent.html:146:29-67" "context": "pages/device_consent.html:157:29-67"
} }
}, },
"granted": { "granted": {
"description": "You granted access to %(client_name)s. You can close this window.", "description": "You granted access to %(client_name)s. You can close this window.",
"@description": { "@description": {
"context": "pages/device_consent.html:158:27-95" "context": "pages/device_consent.html:169:27-95"
}, },
"heading": "Access granted", "heading": "Access granted",
"@heading": { "@heading": {
"context": "pages/device_consent.html:157:29-68" "context": "pages/device_consent.html:168:29-68"
} }
},
"this_will_setup": "Another device wants to set up %(client_name)s (<span>%(client_uri)s</span>) with your <span>%(server_name)s</span> account. Make sure you recognise that device.",
"@this_will_setup": {
"context": "pages/device_consent.html:33:13-166"
} }
}, },
"device_display_name": { "device_display_name": {
@@ -416,6 +410,12 @@
"context": "components/field.html:32:11-45" "context": "components/field.html:32:11-45"
} }
}, },
"legacy_consent": {
"this_will_setup": "This will set up <span>%(client_name)s</span> with your <span>%(server_name)s</span> account.",
"@this_will_setup": {
"context": "pages/sso.html:26:11-109"
}
},
"login": { "login": {
"call_to_register": "Don't have an account yet?", "call_to_register": "Don't have an account yet?",
"@call_to_register": { "@call_to_register": {
@@ -485,7 +485,6 @@
}, },
"not_you": "Not %(username)s?", "not_you": "Not %(username)s?",
"@not_you": { "@not_you": {
"context": "pages/consent.html:62:11-67, pages/device_consent.html:133:13-69, pages/sso.html:42:11-67",
"description": "Suggestions for the user to log in as a different user" "description": "Suggestions for the user to log in as a different user"
}, },
"or_separator": "Or", "or_separator": "Or",
@@ -496,17 +495,17 @@
"policy_violation": { "policy_violation": {
"description": "This might be because of the client which authored the request, the currently logged in user, or the request itself.", "description": "This might be because of the client which authored the request, the currently logged in user, or the request itself.",
"@description": { "@description": {
"context": "pages/policy_violation.html:19:25-62", "context": "pages/compat_login_policy_violation.html:18:25-62, pages/policy_violation.html:19:25-62",
"description": "Displayed when an authorization request is denied by the policy" "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": "The authorization request was denied by the policy enforced by this service",
"@heading": { "@heading": {
"context": "pages/policy_violation.html:18:27-60", "context": "pages/compat_login_policy_violation.html:17:27-60, pages/policy_violation.html:18:27-60",
"description": "Displayed when an authorization request is denied by the policy" "description": "Displayed when an authorization request is denied by the policy"
}, },
"logged_as": "Logged as <span class=\"font-semibold\">%(username)s</span>", "logged_as": "Logged as <span class=\"font-semibold\">%(username)s</span>",
"@logged_as": { "@logged_as": {
"context": "pages/policy_violation.html:35:11-86" "context": "pages/compat_login_policy_violation.html:25:11-86, pages/policy_violation.html:35:11-86"
} }
}, },
"recovery": { "recovery": {
@@ -656,36 +655,36 @@
"scope": { "scope": {
"edit_profile": "Edit your profile and contact details", "edit_profile": "Edit your profile and contact details",
"@edit_profile": { "@edit_profile": {
"context": "components/scope.html:15:35-62", "context": "components/scope.html:44:35-62",
"description": "Displayed when the 'urn:mas:graphql:*' scope is requested" "description": "Displayed when the 'urn:mas:graphql:*' scope is requested"
}, },
"manage_sessions": "Manage your devices and sessions", "manage_sessions": "Manage your devices and sessions",
"@manage_sessions": { "@manage_sessions": {
"context": "components/scope.html:16:39-69", "context": "components/scope.html:45:39-69",
"description": "Displayed when the 'urn:mas:graphql:*' scope is requested" "description": "Displayed when the 'urn:mas:graphql:*' scope is requested"
}, },
"mas_admin": "Administer any user on the matrix-authentication-service", "mas_admin": "Manage users (urn:mas:admin)",
"@mas_admin": { "@mas_admin": {
"context": "components/scope.html:23:54-78", "context": "components/scope.html:52:54-91",
"description": "Displayed when the 'urn:mas:admin' scope is requested" "description": "Displayed when the 'urn:mas:admin' scope is requested"
}, },
"send_messages": "Send new messages on your behalf", "send_messages": "Send new messages on your behalf",
"@send_messages": { "@send_messages": {
"context": "components/scope.html:19:35-63" "context": "components/scope.html:48:35-63"
}, },
"synapse_admin": "Administer the Synapse homeserver", "synapse_admin": "Administer the server (urn:synapse:admin:*)",
"@synapse_admin": { "@synapse_admin": {
"context": "components/scope.html:21:53-81", "context": "components/scope.html:50:53-94",
"description": "Displayed when the 'urn:synapse:admin:*' scope is requested" "description": "Displayed when the 'urn:synapse:admin:*' scope is requested"
}, },
"view_messages": "View your existing messages and data", "view_messages": "View your existing messages and data",
"@view_messages": { "@view_messages": {
"context": "components/scope.html:18:35-63", "context": "components/scope.html:47:35-63",
"description": "Displayed when the 'urn:matrix:client:api:*' scope is requested" "description": "Displayed when the 'urn:matrix:client:api:*' scope is requested"
}, },
"view_profile": "See your profile info and contact details", "view_profile": "See your profile info and contact details",
"@view_profile": { "@view_profile": {
"context": "components/scope.html:13:43-70", "context": "components/scope.html:42:43-70",
"description": "Displayed when the 'openid' scope is requested" "description": "Displayed when the 'openid' scope is requested"
} }
}, },

View File

@@ -74,9 +74,13 @@
}, },
"consent": { "consent": {
"client_wants_access": "<span>%(client_name)s</span> aadressil <span>%(redirect_uri)s</span> soovib ligipääsu sinu kasutajakontole.", "client_wants_access": "<span>%(client_name)s</span> aadressil <span>%(redirect_uri)s</span> soovib ligipääsu sinu kasutajakontole.",
"continue_to": "Kas jätkad kliendis <span>%(client_name)s</span>?",
"heading": "Kas lubad ligipääsu sinu kasutajakontole?", "heading": "Kas lubad ligipääsu sinu kasutajakontole?",
"make_sure_you_trust": "Palun kontrolli, et <span>%(client_name)s</span> on sinu jaoks usaldusväärne teenus.", "make_sure_you_trust": "Palun kontrolli, et <span>%(client_name)s</span> on sinu jaoks usaldusväärne teenus.",
"scope_list_preface": "Jätkates lubad sa <span>%(client_name)s</span> kliendil:",
"this_will_allow": "Sellega <span>%(client_name)s</span> saab õigused:", "this_will_allow": "Sellega <span>%(client_name)s</span> saab õigused:",
"this_will_setup": "Sellega seadistad %(client_name)s (<span>%(client_uri)s</span>) kliendi kasutama sinu <span>%(server_name)s</span> kontot.",
"use_another_account": "Kasuta teist kontot",
"you_may_be_sharing": "Sa tõenäoliselt jagad privaatset teavet selle veebisaidi või rakendusega." "you_may_be_sharing": "Sa tõenäoliselt jagad privaatset teavet selle veebisaidi või rakendusega."
}, },
"device_card": { "device_card": {
@@ -98,7 +102,8 @@
"granted": { "granted": {
"description": "Sa lubasid seadmele %(client_name)s ligipääsu. Sa võid nüüd selle akna sulgeda.", "description": "Sa lubasid seadmele %(client_name)s ligipääsu. Sa võid nüüd selle akna sulgeda.",
"heading": "Ligipääs on lubatud" "heading": "Ligipääs on lubatud"
} },
"this_will_setup": "Üks teine seade tahab seadistada %(client_name)s (<span>%(client_uri)s</span>) klienti kasutama sinu <span>%(server_name)s</span> kontot. Palun kontrolli, et see on õige ja sinule vajalik seade."
}, },
"device_display_name": { "device_display_name": {
"client_on_device": "%(client_name)s seadmes %(device_name)s", "client_on_device": "%(client_name)s seadmes %(device_name)s",
@@ -145,6 +150,9 @@
"username_too_long": "Kasutajanimi on liiga pikk", "username_too_long": "Kasutajanimi on liiga pikk",
"username_too_short": "Kasutajanimi on liiga lühike" "username_too_short": "Kasutajanimi on liiga lühike"
}, },
"legacy_consent": {
"this_will_setup": "Sellega seadistad <span>%(client_name)s</span> kliendi kasutama oma <span>%(server_name)s</span> kontot."
},
"login": { "login": {
"call_to_register": "Sul veel pole kasutajakontot?", "call_to_register": "Sul veel pole kasutajakontot?",
"continue_with_provider": "Jätka teenusepakkujaga %(provider)s", "continue_with_provider": "Jätka teenusepakkujaga %(provider)s",
@@ -226,9 +234,9 @@
"scope": { "scope": {
"edit_profile": "Muuta sinu kasutajaprofiili ning kontaktandmeid", "edit_profile": "Muuta sinu kasutajaprofiili ning kontaktandmeid",
"manage_sessions": "Hallata sinu seadmeid ja sessioone", "manage_sessions": "Hallata sinu seadmeid ja sessioone",
"mas_admin": "Hallata iga kasutajat teenuses matrix-authentication-service", "mas_admin": "Hallata kasutajaid (urn:mas:admin)",
"send_messages": "Saata sõnumeid sinu nimel", "send_messages": "Saata sõnumeid sinu nimel",
"synapse_admin": "Hallata seda Synapse koduserverit", "synapse_admin": "Hallata seda Synapse koduserverit (urn:synapse:admin:*)",
"view_messages": "Vaadata sinu sõnumeid ja andmeid", "view_messages": "Vaadata sinu sõnumeid ja andmeid",
"view_profile": "Vaadata sinu profiili teavet ja kontaktadmeid" "view_profile": "Vaadata sinu profiili teavet ja kontaktadmeid"
}, },

View File

@@ -74,9 +74,13 @@
}, },
"consent": { "consent": {
"client_wants_access": "<span>%(client_name)s</span> à l'adresse <span>%(redirect_uri)s</span> souhaite accéder à votre compte.", "client_wants_access": "<span>%(client_name)s</span> à l'adresse <span>%(redirect_uri)s</span> souhaite accéder à votre compte.",
"continue_to": "Continuer vers <span>%(client_name)s</span>?",
"heading": "Autoriser l'accès à votre compte ?", "heading": "Autoriser l'accès à votre compte ?",
"make_sure_you_trust": "Assurez-vous de faire confiance <span>%(client_name)s</span>.", "make_sure_you_trust": "Assurez-vous de faire confiance <span>%(client_name)s</span>.",
"scope_list_preface": "En continuant, vous autorisez <span>%(client_name)s</span> à :",
"this_will_allow": "Cela va permettre à <span>%(client_name)s</span> de :", "this_will_allow": "Cela va permettre à <span>%(client_name)s</span> de :",
"this_will_setup": "Continuer connectera %(client_name)s (<span>%(client_uri)s</span>) avec votre compte %(server_name)s<span></span>.",
"use_another_account": "Utiliser un autre compte",
"you_may_be_sharing": "Vous partagez peut-être des informations sensibles avec ce site ou cette application." "you_may_be_sharing": "Vous partagez peut-être des informations sensibles avec ce site ou cette application."
}, },
"device_card": { "device_card": {
@@ -98,7 +102,8 @@
"granted": { "granted": {
"description": "Vous avez accordé l'accès à %(client_name)s. Vous pouvez fermer cette fenêtre.", "description": "Vous avez accordé l'accès à %(client_name)s. Vous pouvez fermer cette fenêtre.",
"heading": "Accès accordé" "heading": "Accès accordé"
} },
"this_will_setup": "Un autre appareil souhaite connecter %(client_name)s (<span>%(client_uri)s</span>) avec votre compte <span>%(server_name)s</span>. Assurez-vous de reconnaître cet appareil."
}, },
"device_display_name": { "device_display_name": {
"client_on_device": "%(client_name)s sur %(device_name)s", "client_on_device": "%(client_name)s sur %(device_name)s",
@@ -145,6 +150,9 @@
"username_too_long": "Le nom d'utilisateur est trop long", "username_too_long": "Le nom d'utilisateur est trop long",
"username_too_short": "Le nom d'utilisateur est trop court" "username_too_short": "Le nom d'utilisateur est trop court"
}, },
"legacy_consent": {
"this_will_setup": "Continuer connectera <span>%(client_name)s</span> avec votre compte <span>%(server_name)s</span>."
},
"login": { "login": {
"call_to_register": "Vous navez pas encore de compte ?", "call_to_register": "Vous navez pas encore de compte ?",
"continue_with_provider": "Poursuivre avec %(provider)s", "continue_with_provider": "Poursuivre avec %(provider)s",
@@ -226,9 +234,9 @@
"scope": { "scope": {
"edit_profile": "Modifier votre profil et vos coordonnées", "edit_profile": "Modifier votre profil et vos coordonnées",
"manage_sessions": "Gérer vos appareils et vos sessions", "manage_sessions": "Gérer vos appareils et vos sessions",
"mas_admin": "Administrer n'importe quel utilisateur dans matrix-authentication-service", "mas_admin": "Administrer les utilisateurs (urn:mas:admin)",
"send_messages": "Envoyez de nouveaux messages en votre nom", "send_messages": "Envoyez de nouveaux messages en votre nom",
"synapse_admin": "Administrer le serveur daccueil Synapse", "synapse_admin": "Administrer le serveur (urn:synapse:admin:*)",
"view_messages": "Afficher vos messages et données existants", "view_messages": "Afficher vos messages et données existants",
"view_profile": "Voir les informations de votre profil et vos coordonnées" "view_profile": "Voir les informations de votre profil et vos coordonnées"
}, },

View File

@@ -74,9 +74,13 @@
}, },
"consent": { "consent": {
"client_wants_access": "<span>%(client_name)s</span> за <span>%(redirect_uri)s</span> хоче отримати доступ до вашого облікового запису.", "client_wants_access": "<span>%(client_name)s</span> за <span>%(redirect_uri)s</span> хоче отримати доступ до вашого облікового запису.",
"continue_to": "Продовжити в <span>%(client_name)s</span>?",
"heading": "Дозволити доступ до свого облікового запису?", "heading": "Дозволити доступ до свого облікового запису?",
"make_sure_you_trust": "Переконайтеся, що ви довіряєте <span> %(client_name)s</span>.", "make_sure_you_trust": "Переконайтеся, що ви довіряєте <span> %(client_name)s</span>.",
"scope_list_preface": "Продовжуючи, ви дозволяєте <span>%(client_name)s</span>:",
"this_will_allow": "Це дозволить <span>%(client_name)s</span>:", "this_will_allow": "Це дозволить <span>%(client_name)s</span>:",
"this_will_setup": "Це налаштує %(client_name)s (<span>%(client_uri)s</span>) з вашим обліковим записом <span>%(server_name)s</span>.",
"use_another_account": "Використати інший обліковий запис",
"you_may_be_sharing": "Можливо, ви ділитеся конфіденційною інформацією з цим сайтом або застосунком." "you_may_be_sharing": "Можливо, ви ділитеся конфіденційною інформацією з цим сайтом або застосунком."
}, },
"device_card": { "device_card": {
@@ -98,7 +102,8 @@
"granted": { "granted": {
"description": "Ви надали доступ до %(client_name)s. Ви можете закрити це вікно.", "description": "Ви надали доступ до %(client_name)s. Ви можете закрити це вікно.",
"heading": "Доступ надано" "heading": "Доступ надано"
} },
"this_will_setup": "Інший пристрій хоче налаштувати %(client_name)s (<span>%(client_uri)s</span>) з вашим обліковим записом <span>%(server_name)s</span>. Переконайтеся, що ви розпізнаєте цей пристрій."
}, },
"device_display_name": { "device_display_name": {
"client_on_device": "%(client_name)s на %(device_name)s", "client_on_device": "%(client_name)s на %(device_name)s",
@@ -145,6 +150,9 @@
"username_too_long": "Ім'я користувача задовге", "username_too_long": "Ім'я користувача задовге",
"username_too_short": "Ім'я користувача закоротке" "username_too_short": "Ім'я користувача закоротке"
}, },
"legacy_consent": {
"this_will_setup": "Це налаштує <span>%(client_name)s</span> з вашим обліковим записом <span>%(server_name)s</span>."
},
"login": { "login": {
"call_to_register": "У вас ще немає облікового запису?", "call_to_register": "У вас ще немає облікового запису?",
"continue_with_provider": "Продовжити з %(provider)s", "continue_with_provider": "Продовжити з %(provider)s",
@@ -210,6 +218,7 @@
"register": { "register": {
"call_to_login": "Вже маєте обліковий запис?", "call_to_login": "Вже маєте обліковий запис?",
"continue_with_email": "Продовжити за допомогою е-пошти", "continue_with_email": "Продовжити за допомогою е-пошти",
"continue_with_password": "Продовжити з паролем",
"create_account": { "create_account": {
"description": "Виберіть ім'я користувача, щоб продовжити.", "description": "Виберіть ім'я користувача, щоб продовжити.",
"heading": "Створити обліковий запис" "heading": "Створити обліковий запис"
@@ -225,9 +234,9 @@
"scope": { "scope": {
"edit_profile": "Редагування профілю та контактних даних", "edit_profile": "Редагування профілю та контактних даних",
"manage_sessions": "Керування пристроями та сеансами", "manage_sessions": "Керування пристроями та сеансами",
"mas_admin": "Адміністрування будь-якого користувача на matrix-authentication-service", "mas_admin": "Керування користувачами (urn:mas:admin)",
"send_messages": "Надсилати нові повідомлення від вашого імені", "send_messages": "Надсилати нові повідомлення від вашого імені",
"synapse_admin": "Адміністрування домашнього сервера Synapse", "synapse_admin": "Адмініструвати сервер (urn:synapse:admin:*)",
"view_messages": "Перегляд наявних повідомлень і даних", "view_messages": "Перегляд наявних повідомлень і даних",
"view_profile": "Перегляд інформації профілю та контактних даних" "view_profile": "Перегляд інформації профілю та контактних даних"
}, },