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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

101
Cargo.lock generated
View File

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,7 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// 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 clap::Parser;
@@ -18,9 +18,8 @@ use mas_data_model::SystemClock;
use mas_handlers::{ActivityTracker, CookieManager, Limiter, MetadataCache};
use mas_listener::server::Server;
use mas_router::UrlBuilder;
use mas_storage_pg::{MIGRATOR, PgRepositoryFactory};
use sqlx::migrate::Migrate;
use tracing::{Instrument, info, info_span, warn};
use mas_storage_pg::PgRepositoryFactory;
use tracing::{info, info_span, warn};
use crate::{
app_state::AppState,
@@ -73,24 +72,20 @@ impl Options {
let pool = database_pool_from_config(&config.database).await?;
if self.no_migrate {
// Check that we applied all the migrations
let mut conn = pool.acquire().await?;
let applied = conn.list_applied_migrations().await?;
let applied: BTreeSet<_> = applied.into_iter().map(|m| m.version).collect();
let has_missing_migrations = MIGRATOR.iter().any(|m| !applied.contains(&m.version));
if has_missing_migrations {
let pending_migrations = mas_storage_pg::pending_migrations(&mut conn).await?;
if !pending_migrations.is_empty() {
// Refuse to start if there are pending migrations
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 {
info!("Running pending database migrations");
MIGRATOR
.run(&pool)
.instrument(info_span!("db.migrate"))
let mut conn = pool.acquire().await?;
mas_storage_pg::migrate(&mut conn)
.await
.context("could not run database migrations")?;
.context("could not run migrations")?;
}
let encrypter = config.secrets.encrypter().await?;

View File

@@ -14,13 +14,12 @@ use mas_config::{
UpstreamOAuth2Config,
};
use mas_data_model::SystemClock;
use mas_storage_pg::MIGRATOR;
use rand::thread_rng;
use sqlx::{Connection, Either, PgConnection, postgres::PgConnectOptions, types::Uuid};
use syn2mas::{
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};
@@ -122,9 +121,7 @@ impl Options {
)
.await?;
MIGRATOR
.run(&mut mas_connection)
.instrument(info_span!("db.migrate"))
mas_storage_pg::migrate(&mut mas_connection)
.await
.context("could not run migrations")?;

View File

@@ -45,6 +45,12 @@ fn map_import_on_conflict(
mas_config::UpstreamOAuth2OnConflict::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_data_model::UpstreamOAuthProviderOnConflict::Fail
}
@@ -58,6 +64,7 @@ fn map_claims_imports(
subject: mas_data_model::UpstreamOAuthProviderSubjectPreference {
template: config.subject.template.clone(),
},
skip_confirmation: config.skip_confirmation,
localpart: mas_data_model::UpstreamOAuthProviderLocalpartPreference {
action: map_import_action(config.localpart.action),
template: config.localpart.template.clone(),

View File

@@ -145,6 +145,7 @@ pub async fn policy_factory_from_config(
register: config.register_entrypoint.clone(),
client_registration: config.client_registration_entrypoint.clone(),
authorization_grant: config.authorization_grant_entrypoint.clone(),
compat_login: config.compat_login_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()
}
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 {
"email/violation".to_owned()
}
@@ -111,6 +119,13 @@ pub struct PolicyConfig {
)]
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
#[serde(
default = "default_password_entrypoint",
@@ -137,6 +152,7 @@ impl Default for PolicyConfig {
client_registration_entrypoint: default_client_registration_entrypoint(),
register_entrypoint: default_register_entrypoint(),
authorization_grant_entrypoint: default_authorization_grant_entrypoint(),
compat_login_entrypoint: default_compat_login_entrypoint(),
password_entrypoint: default_password_entrypoint(),
email_entrypoint: default_email_entrypoint(),
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!(
provider.claims_imports.localpart.on_conflict,
OnConflict::Add
OnConflict::Add | OnConflict::Replace | OnConflict::Set
) && !matches!(
provider.claims_imports.localpart.action,
ImportAction::Force | ImportAction::Require
) {
return Err(annotate(figment::Error::custom(
"The field `action` must be either `force` or `require` when `on_conflict` is set to `add`",
)).into());
"The field `action` must be either `force` or `require` when `on_conflict` is set to `add`, `replace` or `set`",
)).with_path("claims_imports.localpart").into());
}
}
@@ -206,13 +226,20 @@ impl ImportAction {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum OnConflict {
/// Fails the sso login on conflict
/// Fails the upstream OAuth 2.0 login on conflict
#[default]
Fail,
/// Adds the oauth identity link, regardless of whether there is an existing
/// link or not
/// Adds the upstream OAuth 2.0 identity link, regardless of whether there
/// is an existing link or not
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 {
@@ -326,6 +353,13 @@ pub struct ClaimsImports {
#[serde(default, skip_serializing_if = "SubjectImportPreference::is_default")]
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
#[serde(default, skip_serializing_if = "LocalpartImportPreference::is_default")]
pub localpart: LocalpartImportPreference,
@@ -337,8 +371,7 @@ pub struct ClaimsImports {
)]
pub displayname: DisplaynameImportPreference,
/// Import the email address of the user based on the `email` and
/// `email_verified` claims
/// Import the email address of the user
#[serde(default, skip_serializing_if = "EmailImportPreference::is_default")]
pub email: EmailImportPreference,
@@ -354,8 +387,10 @@ impl ClaimsImports {
const fn is_default(&self) -> bool {
self.subject.is_default()
&& self.localpart.is_default()
&& !self.skip_confirmation
&& self.displayname.is_default()
&& self.email.is_default()
&& self.account_name.is_default()
}
}

View File

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

View File

@@ -312,6 +312,9 @@ pub struct ClaimsImports {
#[serde(default)]
pub subject: SubjectPreference,
#[serde(default)]
pub skip_confirmation: bool,
#[serde(default)]
pub localpart: LocalpartPreference,
@@ -415,11 +418,18 @@ impl ImportAction {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum OnConflict {
/// Fails the upstream OAuth 2.0 login
/// Fails the upstream OAuth 2.0 login on conflict
#[default]
Fail,
/// Adds the upstream account link, regardless of whether there is an
/// existing link or not
/// Adds the upstream OAuth 2.0 identity link, regardless of whether there
/// is an existing link or not
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 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)]
pub struct User {
pub id: Ulid,

View File

@@ -16,6 +16,7 @@ use mas_data_model::{
User,
};
use mas_matrix::HomeserverConnection;
use mas_policy::{Policy, Requester, ViolationCode, model::CompatLogin};
use mas_storage::{
BoxRepository, BoxRepositoryFactory, RepositoryAccess,
compat::{
@@ -37,6 +38,7 @@ use crate::{
BoundActivityTracker, Limiter, METER, RequesterFingerprint, impl_from_error_for_route,
passwords::{PasswordManager, PasswordVerificationResult},
rate_limit::PasswordCheckLimitedError,
session::count_user_sessions_for_limiting,
};
static LOGIN_COUNTER: LazyLock<Counter<u64>> = LazyLock::new(|| {
@@ -213,9 +215,16 @@ pub enum RouteError {
#[error("failed to provision device")]
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_policy::EvaluationError);
impl From<anyhow::Error> for RouteError {
fn from(err: anyhow::Error) -> Self {
@@ -274,6 +283,16 @@ impl IntoResponse for RouteError {
error: "User account has been locked",
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()
@@ -290,6 +309,7 @@ pub(crate) async fn post(
State(homeserver): State<Arc<dyn HomeserverConnection>>,
State(site_config): State<SiteConfig>,
State(limiter): State<Limiter>,
mut policy: Policy,
requester: RequesterFingerprint,
user_agent: Option<TypedHeader<headers::UserAgent>>,
MatrixJsonBody(input): MatrixJsonBody<RequestBody>,
@@ -329,6 +349,11 @@ pub(crate) async fn post(
&limiter,
requester,
&mut repo,
&mut policy,
Requester {
ip_address: activity_tracker.ip(),
user_agent: user_agent.clone(),
},
username,
password,
input.device_id, // TODO check for validity
@@ -342,6 +367,11 @@ pub(crate) async fn post(
&mut rng,
&clock,
&mut repo,
&mut policy,
Requester {
ip_address: activity_tracker.ip(),
user_agent: user_agent.clone(),
},
&token,
input.device_id,
input.initial_device_display_name,
@@ -459,6 +489,8 @@ async fn token_login(
rng: &mut (dyn RngCore + Send),
clock: &dyn Clock,
repo: &mut BoxRepository,
policy: &mut Policy,
requester: Requester,
token: &str,
requested_device_id: Option<String>,
initial_device_display_name: Option<String>,
@@ -544,10 +576,38 @@ async fn token_login(
Device::generate(rng)
};
repo.app_session()
let session_replaced = repo
.app_session()
.finish_sessions_to_replace_device(clock, &browser_session.user, &device)
.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
// create it on the homeserver, scheduling a device sync job afterwards to
// make sure we don't end up in an inconsistent state.
@@ -578,6 +638,8 @@ async fn user_password_login(
limiter: &Limiter,
requester: RequesterFingerprint,
repo: &mut BoxRepository,
policy: &mut Policy,
policy_requester: Requester,
username: &str,
password: String,
requested_device_id: Option<String>,
@@ -647,10 +709,38 @@ async fn user_password_login(
Device::generate(&mut rng)
};
repo.app_session()
let session_replaced = repo
.app_session()
.finish_sessions_to_replace_device(clock, &user, &device)
.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
.compat_session()
.add(

View File

@@ -4,30 +4,35 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// 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 axum::{
extract::{Form, Path, State},
response::{Html, IntoResponse, Redirect, Response},
};
use axum_extra::extract::Query;
use axum_extra::{TypedHeader, extract::Query};
use chrono::Duration;
use hyper::StatusCode;
use mas_axum_utils::{
InternalError,
cookies::CookieJar,
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_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 ulid::Ulid;
use crate::{
PreferredLanguage,
session::{SessionOrFallback, load_session_or_fallback},
BoundActivityTracker, PreferredLanguage,
session::{SessionOrFallback, count_user_sessions_for_limiting, load_session_or_fallback},
};
#[derive(Serialize)]
@@ -56,10 +61,16 @@ pub async fn get(
mut repo: BoxRepository,
State(templates): State<Templates>,
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,
Path(id): Path<Ulid>,
Query(params): Query<Params>,
) -> Result<Response, InternalError> {
let user_agent = user_agent.map(|ua| ua.to_string());
let (cookie_jar, maybe_session) = match load_session_or_fallback(
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());
}
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_csrf(csrf_token.form_value())
.with_language(locale);
@@ -129,11 +202,16 @@ pub async fn post(
PreferredLanguage(locale): PreferredLanguage,
State(templates): State<Templates>,
State(url_builder): State<UrlBuilder>,
mut policy: Policy,
activity_tracker: BoundActivityTracker,
user_agent: Option<TypedHeader<headers::UserAgent>>,
cookie_jar: CookieJar,
Path(id): Path<Ulid>,
Query(params): Query<Params>,
Form(form): Form<ProtectedForm<()>>,
) -> Result<Response, InternalError> {
let user_agent = user_agent.map(|ua| ua.to_string());
let (cookie_jar, maybe_session) = match load_session_or_fallback(
cookie_jar, &clock, &mut rng, &templates, &locale, &mut repo,
)
@@ -200,6 +278,37 @@ pub async fn post(
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,
// this fails and aborts the transaction.
repo.compat_sso_login()

View File

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

View File

@@ -4,6 +4,8 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use std::{sync::Arc, time::Duration};
use axum::{
extract::{Form, Path, State},
response::{Html, IntoResponse, Response},
@@ -15,8 +17,9 @@ use mas_axum_utils::{
cookies::CookieJar,
csrf::{CsrfExt, ProtectedForm},
};
use mas_data_model::{AuthorizationGrantStage, BoxClock, BoxRng};
use mas_data_model::{AuthorizationGrantStage, BoxClock, BoxRng, MatrixUser};
use mas_keystore::Keystore;
use mas_matrix::HomeserverConnection;
use mas_policy::Policy;
use mas_router::{PostAuthAction, UrlBuilder};
use mas_storage::{
@@ -87,6 +90,7 @@ pub(crate) async fn get(
PreferredLanguage(locale): PreferredLanguage,
State(templates): State<Templates>,
State(url_builder): State<UrlBuilder>,
State(homeserver): State<Arc<dyn HomeserverConnection>>,
mut policy: Policy,
mut repo: BoxRepository,
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?;
// We can close the repository early, we don't need it at this point
repo.save().await?;
let res = policy
.evaluate_authorization_grant(mas_policy::AuthorizationGrantInput {
user: Some(&session.user),
@@ -162,7 +169,37 @@ pub(crate) async fn get(
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_csrf(csrf_token.form_value())
.with_language(locale);

View File

@@ -4,6 +4,8 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use std::{sync::Arc, time::Duration};
use anyhow::Context;
use axum::{
Form,
@@ -16,7 +18,8 @@ use mas_axum_utils::{
cookies::CookieJar,
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_router::UrlBuilder;
use mas_storage::BoxRepository;
@@ -49,6 +52,7 @@ pub(crate) async fn get(
PreferredLanguage(locale): PreferredLanguage,
State(templates): State<Templates>,
State(url_builder): State<UrlBuilder>,
State(homeserver): State<Arc<dyn HomeserverConnection>>,
mut repo: BoxRepository,
mut policy: Policy,
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?;
// We can close the repository early, we don't need it at this point
repo.save().await?;
// Evaluate the policy
let res = policy
.evaluate_authorization_grant(mas_policy::AuthorizationGrantInput {
@@ -133,7 +140,37 @@ pub(crate) async fn get(
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_csrf(csrf_token.form_value())
.with_language(locale);
@@ -153,6 +190,7 @@ pub(crate) async fn post(
PreferredLanguage(locale): PreferredLanguage,
State(templates): State<Templates>,
State(url_builder): State<UrlBuilder>,
State(homeserver): State<Arc<dyn HomeserverConnection>>,
mut repo: BoxRepository,
mut policy: Policy,
activity_tracker: BoundActivityTracker,
@@ -265,7 +303,37 @@ pub(crate) async fn post(
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_csrf(csrf_token.form_value())
.with_language(locale);

View File

@@ -82,6 +82,7 @@ pub(crate) async fn policy_factory(
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(),
};

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -19,8 +19,9 @@ use thiserror::Error;
use tokio::io::{AsyncRead, AsyncReadExt};
pub use self::model::{
AuthorizationGrantInput, ClientRegistrationInput, Code as ViolationCode, EmailInput,
EvaluationResult, GrantType, RegisterInput, RegistrationMethod, Requester, Violation,
AuthorizationGrantInput, ClientRegistrationInput, Code as ViolationCode, CompatLoginInput,
EmailInput, EvaluationResult, GrantType, RegisterInput, RegistrationMethod, Requester,
Violation,
};
#[derive(Debug, Error)]
@@ -72,15 +73,17 @@ pub struct Entrypoints {
pub register: String,
pub client_registration: String,
pub authorization_grant: String,
pub compat_login: String,
pub email: String,
}
impl Entrypoints {
fn all(&self) -> [&str; 4] {
fn all(&self) -> [&str; 5] {
[
self.register.as_str(),
self.client_registration.as_str(),
self.authorization_grant.as_str(),
self.compat_login.as_str(),
self.email.as_str(),
]
}
@@ -459,6 +462,30 @@ impl Policy {
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)]
@@ -468,6 +495,16 @@ mod tests {
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]
async fn test_register() {
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 entrypoints = Entrypoints {
register: "register/violation".to_owned(),
client_registration: "client_registration/violation".to_owned(),
authorization_grant: "authorization_grant/violation".to_owned(),
email: "email/violation".to_owned(),
};
let factory = PolicyFactory::load(file, data, entrypoints).await.unwrap();
let factory = PolicyFactory::load(file, data, make_entrypoints())
.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 entrypoints = Entrypoints {
register: "register/violation".to_owned(),
client_registration: "client_registration/violation".to_owned(),
authorization_grant: "authorization_grant/violation".to_owned(),
email: "email/violation".to_owned(),
};
let factory = PolicyFactory::load(file, data, entrypoints).await.unwrap();
let factory = PolicyFactory::load(file, data, make_entrypoints())
.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 entrypoints = Entrypoints {
register: "register/violation".to_owned(),
client_registration: "client_registration/violation".to_owned(),
authorization_grant: "authorization_grant/violation".to_owned(),
email: "email/violation".to_owned(),
};
let factory = PolicyFactory::load(file, data, entrypoints).await.unwrap();
let factory = PolicyFactory::load(file, data, make_entrypoints())
.await
.unwrap();
// That is around 1 MB of JSON data. Each element is a 5-digit string, so 8
// characters including the quotes and a comma.

View File

@@ -17,7 +17,7 @@ use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
/// 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")]
pub enum Code {
/// The username is too short.
@@ -75,7 +75,7 @@ impl Code {
}
/// A single violation of a policy.
#[derive(Deserialize, Debug, JsonSchema)]
#[derive(Serialize, Deserialize, Debug, JsonSchema)]
pub struct Violation {
pub msg: String,
pub redirect_uri: Option<String>,
@@ -187,6 +187,42 @@ pub struct AuthorizationGrantInput<'a> {
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
#[derive(Serialize, Debug, JsonSchema)]
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]
async-trait.workspace = true
chrono.workspace = true
crc.workspace = true
futures-util.workspace = true
opentelemetry-semantic-conventions.workspace = true
opentelemetry.workspace = true
@@ -31,6 +32,7 @@ sha2.workspace = true
sqlx.workspace = true
thiserror.workspace = true
tracing.workspace = true
tokio.workspace = true
ulid.workspace = true
url.workspace = true
uuid.workspace = true

View File

@@ -487,14 +487,15 @@ impl AppSessionRepository for PgAppSessionRepository<'_> {
clock: &dyn Clock,
user: &User,
device: &Device,
) -> Result<(), Self::Error> {
) -> Result<bool, Self::Error> {
let mut affected = false;
// TODO need to invoke this from all the oauth2 login sites
let span = tracing::info_span!(
"db.app_session.finish_sessions_to_replace_device.compat_sessions",
{ DB_QUERY_TEXT } = tracing::field::Empty,
);
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
",
@@ -505,7 +506,9 @@ impl AppSessionRepository for PgAppSessionRepository<'_> {
.record(&span)
.execute(&mut *self.conn)
.instrument(span)
.await?;
.await?
.rows_affected();
affected |= compat_affected > 0;
if let Ok([stable_device_as_scope_token, unstable_device_as_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_QUERY_TEXT } = tracing::field::Empty,
);
sqlx::query!(
let oauth2_affected = sqlx::query!(
"
UPDATE oauth2_sessions
SET finished_at = $4
@@ -530,10 +533,12 @@ impl AppSessionRepository for PgAppSessionRepository<'_> {
.record(&span)
.execute(&mut *self.conn)
.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)]
#![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 compat;
@@ -186,14 +194,290 @@ pub use self::{
tracing::ExecuteExt,
};
/// Embedded migrations, allowing them to run on startup
pub static MIGRATOR: Migrator = {
// 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!();
/// Embedded migrations in the binary
pub static MIGRATOR: Migrator = sqlx::migrate!();
// We manually removed some migrations because they made us depend on the
// `pgcrypto` extension. See: https://github.com/matrix-org/matrix-authentication-service/issues/1557
m.ignore_missing = true;
m
};
fn available_migrations() -> BTreeMap<i64, &'static Migration> {
MIGRATOR.iter().map(|m| (m.version, m)).collect()
}
/// 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).
///
/// 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(
&mut self,
clock: &dyn Clock,
user: &User,
device: &Device,
) -> Result<(), Self::Error>;
) -> Result<bool, Self::Error>;
}
repository_impl!(AppSessionRepository:
@@ -218,5 +220,5 @@ repository_impl!(AppSessionRepository:
clock: &dyn Clock,
user: &User,
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-i18n.workspace = true
mas-iana.workspace = true
mas-policy.workspace = true
mas-router.workspace = true
mas-spa.workspace = true

View File

@@ -21,13 +21,15 @@ use chrono::{DateTime, Duration, Utc};
use http::{Method, Uri, Version};
use mas_data_model::{
AuthorizationGrant, BrowserSession, Client, CompatSsoLogin, CompatSsoLoginState,
DeviceCodeGrant, UpstreamOAuthLink, UpstreamOAuthProvider, UpstreamOAuthProviderClaimsImports,
UpstreamOAuthProviderDiscoveryMode, UpstreamOAuthProviderOnBackchannelLogout,
UpstreamOAuthProviderPkceMode, UpstreamOAuthProviderTokenAuthMethod, User,
UserEmailAuthentication, UserEmailAuthenticationCode, UserRecoverySession, UserRegistration,
DeviceCodeGrant, MatrixUser, UpstreamOAuthLink, UpstreamOAuthProvider,
UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderDiscoveryMode,
UpstreamOAuthProviderOnBackchannelLogout, UpstreamOAuthProviderPkceMode,
UpstreamOAuthProviderTokenAuthMethod, User, UserEmailAuthentication,
UserEmailAuthenticationCode, UserRecoverySession, UserRegistration,
};
use mas_i18n::DataLocale;
use mas_iana::jose::JsonWebSignatureAlg;
use mas_policy::{Violation, ViolationCode};
use mas_router::{Account, GraphQL, PostAuthAction, UrlBuilder};
use oauth2_types::scope::{OPENID, Scope};
use rand::{
@@ -732,6 +734,7 @@ pub struct ConsentContext {
grant: AuthorizationGrant,
client: Client,
action: PostAuthAction,
matrix_user: MatrixUser,
}
impl TemplateContext for ConsentContext {
@@ -755,6 +758,10 @@ impl TemplateContext for ConsentContext {
grant,
client,
action,
matrix_user: MatrixUser {
mxid: "@alice:example.com".to_owned(),
display_name: Some("Alice".to_owned()),
},
}
})
.collect(),
@@ -765,12 +772,13 @@ impl TemplateContext for ConsentContext {
impl ConsentContext {
/// Constructs a context for the client consent page
#[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);
Self {
grant,
client,
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
#[derive(Serialize)]
pub struct CompatSsoContext {
login: CompatSsoLogin,
action: PostAuthAction,
matrix_user: MatrixUser,
}
impl TemplateContext for CompatSsoContext {
@@ -877,23 +924,33 @@ impl TemplateContext for CompatSsoContext {
Self: Sized,
{
let id = Ulid::from_datetime_with_source(now.into(), rng);
sample_list(vec![CompatSsoContext::new(CompatSsoLogin {
id,
redirect_uri: Url::parse("https://app.element.io/").unwrap(),
login_token: "abcdefghijklmnopqrstuvwxyz012345".into(),
created_at: now,
state: CompatSsoLoginState::Pending,
})])
sample_list(vec![CompatSsoContext::new(
CompatSsoLogin {
id,
redirect_uri: Url::parse("https://app.element.io/").unwrap(),
login_token: "abcdefghijklmnopqrstuvwxyz012345".into(),
created_at: now,
state: CompatSsoLoginState::Pending,
},
MatrixUser {
mxid: "@alice:example.com".to_owned(),
display_name: Some("Alice".to_owned()),
},
)])
}
}
impl CompatSsoContext {
/// Constructs a context for the legacy SSO login page
#[must_use]
pub fn new(login: CompatSsoLogin) -> Self
pub fn new(login: CompatSsoLogin, matrix_user: MatrixUser) -> Self
where {
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 {
grant: DeviceCodeGrant,
client: Client,
matrix_user: MatrixUser,
}
impl DeviceConsentContext {
/// Constructs a new context with an existing linked user
#[must_use]
pub fn new(grant: DeviceCodeGrant, client: Client) -> Self {
Self { grant, client }
pub fn new(grant: DeviceCodeGrant, client: Client, matrix_user: MatrixUser) -> Self {
Self {
grant,
client,
matrix_user,
}
}
}
@@ -1782,7 +1844,14 @@ impl TemplateContext for DeviceConsentContext {
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()),
};
Self { grant, client }
Self {
grant,
client,
matrix_user: MatrixUser {
mxid: "@alice:example.com".to_owned(),
display_name: Some("Alice".to_owned()),
}
}
})
.collect())
}

View File

@@ -41,6 +41,7 @@ pub fn register(
env.add_filter("simplify_url", filter_simplify_url);
env.add_filter("add_slashes", filter_add_slashes);
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("counter", || Ok(Value::from_object(Counter::default())));
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
fn filter_parse_user_agent(user_agent: String) -> Value {
let user_agent = mas_data_model::UserAgent::parse(user_agent);

View File

@@ -37,14 +37,15 @@ mod macros;
pub use self::{
context::{
AccountInactiveContext, ApiDocContext, AppContext, CompatSsoContext, ConsentContext,
DeviceConsentContext, DeviceLinkContext, DeviceLinkFormField, DeviceNameContext,
EmailRecoveryContext, EmailVerificationContext, EmptyContext, ErrorContext,
FormPostContext, IndexContext, LoginContext, LoginFormField, NotFoundContext,
PasswordRegisterContext, PolicyViolationContext, PostAuthContext, PostAuthContextInner,
RecoveryExpiredContext, RecoveryFinishContext, RecoveryFinishFormField,
RecoveryProgressContext, RecoveryStartContext, RecoveryStartFormField, RegisterContext,
RegisterFormField, RegisterStepsDisplayNameContext, RegisterStepsDisplayNameFormField,
AccountInactiveContext, ApiDocContext, AppContext, CompatLoginPolicyViolationContext,
CompatSsoContext, ConsentContext, DeviceConsentContext, DeviceLinkContext,
DeviceLinkFormField, DeviceNameContext, EmailRecoveryContext, EmailVerificationContext,
EmptyContext, ErrorContext, FormPostContext, IndexContext, LoginContext, LoginFormField,
NotFoundContext, PasswordRegisterContext, PolicyViolationContext, PostAuthContext,
PostAuthContextInner, RecoveryExpiredContext, RecoveryFinishContext,
RecoveryFinishFormField, RecoveryProgressContext, RecoveryStartContext,
RecoveryStartFormField, RegisterContext, RegisterFormField,
RegisterStepsDisplayNameContext, RegisterStepsDisplayNameFormField,
RegisterStepsEmailInUseContext, RegisterStepsRegistrationTokenContext,
RegisterStepsRegistrationTokenFormField, RegisterStepsVerifyEmailContext,
RegisterStepsVerifyEmailFormField, SiteBranding, SiteConfigExt, SiteFeatures,
@@ -391,6 +392,9 @@ register_templates! {
/// Render the policy violation page
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
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
# PKCS#1 v1.5 encryption, which we don't
"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]

View File

@@ -1883,6 +1883,10 @@
"description": "Entrypoint to use when evaluating authorization grants",
"type": "string"
},
"compat_login_entrypoint": {
"description": "Entrypoint to use when evaluating compatibility logins",
"type": "string"
},
"password_entrypoint": {
"description": "Entrypoint to use when changing password",
"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": {
"description": "Import the localpart of the MXID",
"allOf": [
@@ -2484,7 +2492,7 @@
]
},
"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": [
{
"$ref": "#/definitions/EmailImportPreference"
@@ -2572,14 +2580,24 @@
"description": "How to handle an existing localpart claim",
"oneOf": [
{
"description": "Fails the sso login on conflict",
"description": "Fails the upstream OAuth 2.0 login on conflict",
"type": "string",
"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",
"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
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
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.
### 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
keys:
# It needs at least an RSA key to work properly
# At least one RSA key must be configured
- key_file: keys/rsa_key
- kid: "iv1aShae"
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
> information in the database.
### Singing Keys
### Signing Keys
The service can use a number of key types for signing.
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
- 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`
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`
Path to the directory containing MAS signing key files.
@@ -771,6 +786,14 @@ upstream_oauth2:
subject:
#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.
# For example, on the `example.com` server, if the localpart is `alice`,
# the user's Matrix ID will be `@alice:example.com`.
@@ -780,8 +803,10 @@ upstream_oauth2:
# How to handle when localpart already exists.
# 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.
# - `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
# 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
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
claims_imports:
localpart:
on_conflict: add
upstream_oauth2:
providers:
- 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**
> Enabling this option can introduce a risk of account takeover.

View File

@@ -27,7 +27,7 @@ export type 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",
languages: [
{
@@ -181,22 +181,22 @@ const localazyMetadata: LocalazyMetadata = {
file: "frontend.json",
path: "",
cdnFiles: {
"cs": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/cs/frontend.json",
"da": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/da/frontend.json",
"de": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/de/frontend.json",
"en": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/en/frontend.json",
"et": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/et/frontend.json",
"fi": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/fi/frontend.json",
"fr": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/fr/frontend.json",
"hu": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/hu/frontend.json",
"nb_NO": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/nb-NO/frontend.json",
"nl": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/nl/frontend.json",
"pl": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pl/frontend.json",
"pt": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pt/frontend.json",
"ru": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/ru/frontend.json",
"sv": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/sv/frontend.json",
"uk": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/uk/frontend.json",
"zh#Hans": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/zh-Hans/frontend.json"
"cs": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/cs/frontend.json",
"da": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/da/frontend.json",
"de": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/de/frontend.json",
"en": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/en/frontend.json",
"et": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/et/frontend.json",
"fi": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/fi/frontend.json",
"fr": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/fr/frontend.json",
"hu": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/hu/frontend.json",
"nb_NO": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/nb-NO/frontend.json",
"nl": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/nl/frontend.json",
"pl": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pl/frontend.json",
"pt": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pt/frontend.json",
"ru": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/ru/frontend.json",
"sv": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/sv/frontend.json",
"uk": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/uk/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",
path: "",
cdnFiles: {
"cs": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/cs/file.json",
"da": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/da/file.json",
"de": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/de/file.json",
"en": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/en/file.json",
"et": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/et/file.json",
"fi": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/fi/file.json",
"fr": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/fr/file.json",
"hu": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/hu/file.json",
"nb_NO": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/nb-NO/file.json",
"nl": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/nl/file.json",
"pl": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pl/file.json",
"pt": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pt/file.json",
"ru": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/ru/file.json",
"sv": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/sv/file.json",
"uk": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/uk/file.json",
"zh#Hans": "https://delivery.localazy.com/_a6730564787461928869ed6dbfd8/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/zh-Hans/file.json"
"cs": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/cs/file.json",
"da": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/da/file.json",
"de": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/de/file.json",
"en": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/en/file.json",
"et": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/et/file.json",
"fi": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/fi/file.json",
"fr": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/fr/file.json",
"hu": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/hu/file.json",
"nb_NO": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/nb-NO/file.json",
"nl": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/nl/file.json",
"pl": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pl/file.json",
"pt": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pt/file.json",
"ru": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/ru/file.json",
"sv": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/sv/file.json",
"uk": "https://delivery.localazy.com/_a6714272761432419575f0da7e87/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/uk/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_title": "Du bist kurz davor, alle deine Daten zu verlieren.",
"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?",
"erase_checkbox_label": "Ja, alle meine Nachrichten vor neuen Mitgliedern verbergen",
"incorrect_password": "Falsches Passwort, versuch's nochmal",

View File

@@ -319,9 +319,9 @@
"scope": {
"edit_profile": "Edit your profile and contact details",
"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",
"synapse_admin": "Administer the Synapse homeserver",
"synapse_admin": "Administer the server (urn:synapse:admin:*)",
"view_messages": "View your existing messages and data",
"view_profile": "See your profile info and contact details"
}

View File

@@ -391,9 +391,9 @@
"scope": {
"edit_profile": "Muuta sinu kasutajaprofiili ning kontaktandmeid",
"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",
"synapse_admin": "Hallata seda Synapse koduserverit",
"synapse_admin": "Hallata seda Synapse koduserverit (urn:synapse:admin:*)",
"view_messages": "Vaadata sinu sõnumeid ja andmeid",
"view_profile": "Vaadata sinu profiili teavet ja kontaktadmeid"
}

View File

@@ -391,9 +391,9 @@
"scope": {
"edit_profile": "Modifier votre profil et vos coordonnées",
"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",
"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_profile": "Voir les informations de votre profil et vos coordonnées"
}

View File

@@ -391,9 +391,9 @@
"scope": {
"edit_profile": "Edit your profile and contact details",
"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",
"synapse_admin": "Administer the Synapse homeserver",
"synapse_admin": "Administer the server (urn:synapse:admin:*)",
"view_messages": "View your existing messages and data",
"view_profile": "See your profile info and contact details"
}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -23,9 +23,9 @@
letter-spacing: var(--cpd-font-letter-spacing-body-lg);
}
.cpd-text-heading-xl-semibold {
font: var(--cpd-font-heading-xl-semibold);
letter-spacing: var(--cpd-font-letter-spacing-heading-xl);
.cpd-text-body-lg-semibold {
font: var(--cpd-font-body-lg-semibold);
letter-spacing: var(--cpd-font-letter-spacing-body-lg);
}
.cpd-text-body-md-regular {
@@ -33,6 +33,36 @@
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 {
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`
*/
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(
(
locales?: Intl.LocalesArgument,
options?: Intl.DateTimeFormatOptions | undefined,
) => new DateTimeFormat(locales || defaultLocale, options),
MockDateTimeFormat as typeof Intl.DateTimeFormat,
);
};

View File

@@ -16,6 +16,7 @@ INPUTS := \
client_registration/client_registration.rego \
register/register.rego \
authorization_grant/authorization_grant.rego \
compat_login/compat_login.rego \
email/email.rego
ifeq ($(DOCKER), 1)
@@ -38,6 +39,7 @@ policy.wasm: $(INPUTS)
-e "client_registration/violation" \
-e "register/violation" \
-e "authorization_grant/violation" \
-e "compat_login/violation" \
-e "email/violation" \
$^
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.
-#}
{% macro button(text, csrf_token, as_link=false, post_logout_action={}) %}
<form method="POST" action="{{ "/logout" | prefix_url }}" class="inline-flex">
{% macro button(csrf_token, text="", as_link=false, post_logout_action={}) %}
<form method="POST" action="{{ "/logout" | prefix_url }}" class="inline-flex [&>button]:flex-1">
<input type="hidden" name="csrf" value="{{ csrf_token }}" />
{% for key, value in post_logout_action|items %}
<input type="hidden" name="{{ key }}" value="{{ value }}" />
{% endfor %}
{% if as_link %}
<button class="cpd-link flex-1" data-kind="critical" type="submit">{{ text }}</button>
{% if caller is defined %}
{{ caller() }}
{% elif as_link %}
<button class="cpd-link" data-kind="critical" type="submit">{{ text }}</button>
{% 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 %}
</form>
{% 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.
-#}
{# 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) %}
<ul>
{% for scope in (scopes | split(" ")) %}
{% for scope in scopes %}
{% if scope == "openid" %}
<li>{{ icon.user_profile() }}<p>{{ _("mas.scope.view_profile") }}</p></li>
{% 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.send() }}<p>{{ _("mas.scope.send_messages") }}</p></li>
{% 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" %}
<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:") %}
{# We hide this scope #}
{% 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 %}
{% set client_name = client.client_name or client.client_id %}
<header class="page-heading">
{% if 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 %}
<div class="header">
<h1 class="title">{{ _("mas.consent.heading") }}</h1>
<p class="text [&>span]:whitespace-nowrap">
{{ _("mas.consent.client_wants_access", client_name=client_name, redirect_uri=(grant.redirect_uri | simplify_url)) }}
{{ _("mas.consent.this_will_allow", client_name=client_name) }}
<h1 class="title">
{{ _('mas.consent.continue_to', client_name=client_name) }}
</h1>
<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>
</div>
</header>
<section class="consent-scope-list">
{{ scope.list(scopes=grant.scope) }}
</section>
<section class="text-center cpd-text-secondary cpd-text-body-md-regular [&>span]:whitespace-nowrap">
<strong class="font-semibold cpd-text-primary [&>span]:whitespace-nowrap">{{ _("mas.consent.make_sure_you_trust", client_name=client_name) }}</strong>
{{ _("mas.consent.you_may_be_sharing") }}
{% if client.policy_uri or client.tos_uri %}
Find out how <span>{{ client_name }}</span> will handle your data by reviewing its
{% if client.policy_uri %}
<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 %}
{% call(scopes) scope.unsafe_scopes(scopes=grant.scope.split(" ")) %}
{% if scopes is not empty %}
<section class="flex flex-col gap-3">
<p class="text-center cpd-text-body-md-regular">
{{ _('mas.consent.scope_list_preface', client_name=client_name) }}
</p>
<div class="consent-scope-list">
{{ scope.list(scopes=scopes) }}
</div>
</section>
{% 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 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")) }}
</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>
{% call logout.button(csrf_token=csrf_token, post_logout_action=action) %}
<button type="submit" class="cpd-button primary" data-kind="secondary" data-size="lg" type="submit">
{{ _("mas.consent.use_another_account") }}
</button>
{% endcall %}
{{ back_to_client.link(
text=_("action.cancel"),

View File

@@ -25,9 +25,15 @@ Please see LICENSE files in the repository root for full details.
{% endif %}
<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="device-type-icon">
{% if user_agent.device_type == "mobile" %}
@@ -88,33 +94,36 @@ Please see LICENSE files in the repository root for full details.
</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>
</header>
<section class="consent-scope-list">
{{ scope.list(scopes=grant.scope) }}
</section>
<section class="text-center text-balance cpd-text-secondary cpd-text-body-md-regular [&>span]:whitespace-nowrap">
<strong class="font-semibold cpd-text-primary [&>span]:whitespace-nowrap">{{ _("mas.consent.make_sure_you_trust", client_name=client_name) }}</strong>
{{ _("mas.consent.you_may_be_sharing") }}
{% if client.policy_uri or client.tos_uri %}
Find out how <span>{{ client_name }}</span> will handle your data by reviewing its
{% if client.policy_uri %}
<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 %}
{% call(scopes) scope.unsafe_scopes(scopes=grant.scope.split(" ")) %}
{% if scopes is not empty %}
<section class="flex flex-col gap-3">
<p class="text-center cpd-text-body-md-regular">
{{ _('mas.consent.scope_list_preface', client_name=client_name) }}
</p>
<div class="consent-scope-list">
{{ scope.list(scopes=scopes) }}
</div>
</section>
{% 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 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">
{{ _("action.continue") }}
</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") }}
</button>
</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>
{% elif grant.state == "rejected" %}
<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.
-#}
{% set consent_page = true %}
{% extends "base.html" %}
{% block content %}
@@ -17,18 +19,29 @@ Please see LICENSE files in the repository root for full details.
</div>
<div class="header">
<h1 class="title">Allow access to your account?</h1>
<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>
<h1 class="title">
{{ _('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>
</header>
<section class="consent-scope-list">
{{ scope.list(scopes="openid urn:matrix:client:api:*") }}
</section>
{% set initial -%}
{%- if matrix_user.display_name -%}
{{- 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">
<span class="font-semibold cpd-text-primary">Make sure that you trust <span class="whitespace-nowrap">{{ client_name }}</span>.</span>
You may be sharing sensitive information with this site or app.
<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 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")) }}
</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>
{% call logout.button(csrf_token=csrf_token, post_logout_action=action) %}
<button type="submit" class="cpd-button primary" data-kind="secondary" data-size="lg" type="submit">
{{ _("mas.consent.use_another_account") }}
</button>
{% endcall %}
</section>
{% endblock content %}

View File

@@ -6,11 +6,11 @@
},
"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": {
"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": {
@@ -22,7 +22,7 @@
},
"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": {
@@ -165,8 +165,6 @@
"@current": {
"description": "Field for the user's current password"
},
"description": "This will change the password on your account.",
"@description": {},
"heading": "Change my password",
"@heading": {
"description": "Heading on the change password page"
@@ -189,43 +187,39 @@
}
},
"consent": {
"client_wants_access": "<span>%(client_name)s</span> at <span>%(redirect_uri)s</span> wants to access your account.",
"@client_wants_access": {
"context": "pages/consent.html:27:11-122"
"continue_to": "Continue to <span>%(client_name)s</span>?",
"@continue_to": {
"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?",
"@heading": {
"context": "pages/consent.html:25:27-51, pages/device_consent.html:28:29-53"
"scope_list_preface": "By continuing, you allow <span>%(client_name)s</span> to:",
"@scope_list_preface": {
"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>.",
"@make_sure_you_trust": {
"context": "pages/consent.html:38:81-142, pages/device_consent.html:104:83-144"
"this_will_setup": "This will set up %(client_name)s (<span>%(client_uri)s</span>) with your <span>%(server_name)s</span> account.",
"@this_will_setup": {
"context": "pages/consent.html:30:11-173"
},
"this_will_allow": "This will allow <span>%(client_name)s</span> to:",
"@this_will_allow": {
"context": "pages/consent.html:28:11-68, pages/device_consent.html:94:13-70"
},
"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"
"use_another_account": "Use another account",
"@use_another_account": {
"context": "pages/consent.html:72:11-47, pages/device_consent.html:139:13-49, pages/sso.html:55:11-47"
}
},
"device_card": {
"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": {
"context": "pages/device_consent.html:86:34-66"
"context": "pages/device_consent.html:92:34-66"
},
"generic_device": "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": {
"context": "pages/device_consent.html:77:36-67"
"context": "pages/device_consent.html:83:36-67"
}
},
"device_code_link": {
@@ -239,29 +233,29 @@
}
},
"device_consent": {
"another_device_access": "Another device wants to access your account.",
"@another_device_access": {
"context": "pages/device_consent.html:93:13-58"
},
"denied": {
"description": "You denied access to %(client_name)s. You can close this window.",
"@description": {
"context": "pages/device_consent.html:147:27-94"
"context": "pages/device_consent.html:158:27-94"
},
"heading": "Access denied",
"@heading": {
"context": "pages/device_consent.html:146:29-67"
"context": "pages/device_consent.html:157:29-67"
}
},
"granted": {
"description": "You granted access to %(client_name)s. You can close this window.",
"@description": {
"context": "pages/device_consent.html:158:27-95"
"context": "pages/device_consent.html:169:27-95"
},
"heading": "Access granted",
"@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": {
@@ -416,6 +410,12 @@
"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": {
"call_to_register": "Don't have an account yet?",
"@call_to_register": {
@@ -485,7 +485,6 @@
},
"not_you": "Not %(username)s?",
"@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"
},
"or_separator": "Or",
@@ -496,17 +495,17 @@
"policy_violation": {
"description": "This might be because of the client which authored the request, the currently logged in user, or the request itself.",
"@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"
},
"heading": "The authorization request was denied by the policy enforced by this service",
"@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"
},
"logged_as": "Logged as <span class=\"font-semibold\">%(username)s</span>",
"@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": {
@@ -656,36 +655,36 @@
"scope": {
"edit_profile": "Edit your profile and contact details",
"@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"
},
"manage_sessions": "Manage your devices and 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"
},
"mas_admin": "Administer any user on the matrix-authentication-service",
"mas_admin": "Manage users (urn: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"
},
"send_messages": "Send new messages on your behalf",
"@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": {
"context": "components/scope.html:21:53-81",
"context": "components/scope.html:50:53-94",
"description": "Displayed when the 'urn:synapse:admin:*' scope is requested"
},
"view_messages": "View your existing messages and data",
"@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"
},
"view_profile": "See your profile info and contact details",
"@view_profile": {
"context": "components/scope.html:13:43-70",
"context": "components/scope.html:42:43-70",
"description": "Displayed when the 'openid' scope is requested"
}
},

View File

@@ -74,9 +74,13 @@
},
"consent": {
"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?",
"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_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."
},
"device_card": {
@@ -98,7 +102,8 @@
"granted": {
"description": "Sa lubasid seadmele %(client_name)s ligipääsu. Sa võid nüüd selle akna sulgeda.",
"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": {
"client_on_device": "%(client_name)s seadmes %(device_name)s",
@@ -145,6 +150,9 @@
"username_too_long": "Kasutajanimi on liiga pikk",
"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": {
"call_to_register": "Sul veel pole kasutajakontot?",
"continue_with_provider": "Jätka teenusepakkujaga %(provider)s",
@@ -226,9 +234,9 @@
"scope": {
"edit_profile": "Muuta sinu kasutajaprofiili ning kontaktandmeid",
"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",
"synapse_admin": "Hallata seda Synapse koduserverit",
"synapse_admin": "Hallata seda Synapse koduserverit (urn:synapse:admin:*)",
"view_messages": "Vaadata sinu sõnumeid ja andmeid",
"view_profile": "Vaadata sinu profiili teavet ja kontaktadmeid"
},

View File

@@ -74,9 +74,13 @@
},
"consent": {
"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 ?",
"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_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."
},
"device_card": {
@@ -98,7 +102,8 @@
"granted": {
"description": "Vous avez accordé l'accès à %(client_name)s. Vous pouvez fermer cette fenêtre.",
"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": {
"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_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": {
"call_to_register": "Vous navez pas encore de compte ?",
"continue_with_provider": "Poursuivre avec %(provider)s",
@@ -226,9 +234,9 @@
"scope": {
"edit_profile": "Modifier votre profil et vos coordonnées",
"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",
"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_profile": "Voir les informations de votre profil et vos coordonnées"
},

View File

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