Merge branch 'element-hq:main' into main

This commit is contained in:
Adis Veletanlic
2025-04-14 18:44:34 +02:00
committed by GitHub
111 changed files with 1030 additions and 1585 deletions

View File

@@ -1,14 +1,2 @@
[test-groups]
database = { max-threads = 1 }
[profile.default]
retries = 1
# sqlx has a problem with nextest, as it uses a process-local semaphore to have
# tests use different databases. This doesn't work with nextest, as it has a
# process-per-test model, which is why we need to make sure only one test uses
# the database at a time.
# See https://github.com/launchbadge/sqlx/pull/3334
[[profile.default.overrides]]
filter = 'package(mas-handlers) or package(mas-storage-pg)'
test-group = 'database'

View File

@@ -340,7 +340,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Install Node
uses: actions/setup-node@v4.3.0
uses: actions/setup-node@v4.4.0
with:
node-version-file: ./tools/syn2mas/.nvmrc

View File

@@ -58,7 +58,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Install Node
uses: actions/setup-node@v4.3.0
uses: actions/setup-node@v4.4.0
with:
node-version: 22
@@ -82,7 +82,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Install Node
uses: actions/setup-node@v4.3.0
uses: actions/setup-node@v4.4.0
with:
node-version: 22
@@ -106,7 +106,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Install Node
uses: actions/setup-node@v4.3.0
uses: actions/setup-node@v4.4.0
with:
node-version: 20
@@ -323,7 +323,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Install Node
uses: actions/setup-node@v4.3.0
uses: actions/setup-node@v4.4.0
with:
node-version-file: ./tools/syn2mas/.nvmrc

View File

@@ -34,7 +34,7 @@ jobs:
tool: mdbook
- name: Install Node
uses: actions/setup-node@v4.3.0
uses: actions/setup-node@v4.4.0
with:
node-version: 22

View File

@@ -48,7 +48,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Install Node
uses: actions/setup-node@v4.3.0
uses: actions/setup-node@v4.4.0
with:
node-version: 22

View File

@@ -17,7 +17,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Install Node
uses: actions/setup-node@v4.3.0
uses: actions/setup-node@v4.4.0
with:
node-version: 22

View File

@@ -16,7 +16,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Install Node
uses: actions/setup-node@v4.3.0
uses: actions/setup-node@v4.4.0
with:
node-version: 22

101
Cargo.lock generated
View File

@@ -190,9 +190,9 @@ dependencies = [
[[package]]
name = "anyhow"
version = "1.0.97"
version = "1.0.98"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f"
checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487"
[[package]]
name = "arbitrary"
@@ -1004,9 +1004,9 @@ dependencies = [
[[package]]
name = "clap"
version = "4.5.35"
version = "4.5.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8aa86934b44c19c50f87cc2790e19f54f7a67aedb64101c2e1a2e5ecfb73944"
checksum = "2df961d8c8a0d08aa9945718ccf584145eee3f3aa06cddbeac12933781102e04"
dependencies = [
"clap_builder",
"clap_derive",
@@ -1014,9 +1014,9 @@ dependencies = [
[[package]]
name = "clap_builder"
version = "4.5.35"
version = "4.5.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2414dbb2dd0695280da6ea9261e327479e9d37b0630f6b53ba2a11c60c679fd9"
checksum = "132dbda40fb6753878316a489d5a1242a8ef2f0d9e47ba01c951ea8aa7d013a5"
dependencies = [
"anstream",
"anstyle",
@@ -3114,7 +3114,7 @@ dependencies = [
[[package]]
name = "mas-axum-utils"
version = "0.14.1"
version = "0.15.0-rc.0"
dependencies = [
"axum",
"axum-extra",
@@ -3147,7 +3147,7 @@ dependencies = [
[[package]]
name = "mas-cli"
version = "0.14.1"
version = "0.15.0-rc.0"
dependencies = [
"anyhow",
"axum",
@@ -3218,7 +3218,7 @@ dependencies = [
[[package]]
name = "mas-config"
version = "0.14.1"
version = "0.15.0-rc.0"
dependencies = [
"anyhow",
"camino",
@@ -3248,7 +3248,7 @@ dependencies = [
[[package]]
name = "mas-data-model"
version = "0.14.1"
version = "0.15.0-rc.0"
dependencies = [
"base64ct",
"chrono",
@@ -3269,7 +3269,7 @@ dependencies = [
[[package]]
name = "mas-email"
version = "0.14.1"
version = "0.15.0-rc.0"
dependencies = [
"async-trait",
"lettre",
@@ -3280,7 +3280,7 @@ dependencies = [
[[package]]
name = "mas-handlers"
version = "0.14.1"
version = "0.15.0-rc.0"
dependencies = [
"aide",
"anyhow",
@@ -3357,7 +3357,7 @@ dependencies = [
[[package]]
name = "mas-http"
version = "0.14.1"
version = "0.15.0-rc.0"
dependencies = [
"futures-util",
"headers",
@@ -3378,7 +3378,7 @@ dependencies = [
[[package]]
name = "mas-i18n"
version = "0.14.1"
version = "0.15.0-rc.0"
dependencies = [
"camino",
"icu_calendar",
@@ -3400,7 +3400,7 @@ dependencies = [
[[package]]
name = "mas-i18n-scan"
version = "0.14.1"
version = "0.15.0-rc.0"
dependencies = [
"camino",
"clap",
@@ -3414,7 +3414,7 @@ dependencies = [
[[package]]
name = "mas-iana"
version = "0.14.1"
version = "0.15.0-rc.0"
dependencies = [
"schemars",
"serde",
@@ -3422,7 +3422,7 @@ dependencies = [
[[package]]
name = "mas-iana-codegen"
version = "0.14.1"
version = "0.15.0-rc.0"
dependencies = [
"anyhow",
"async-trait",
@@ -3438,7 +3438,7 @@ dependencies = [
[[package]]
name = "mas-jose"
version = "0.14.1"
version = "0.15.0-rc.0"
dependencies = [
"base64ct",
"chrono",
@@ -3468,7 +3468,7 @@ dependencies = [
[[package]]
name = "mas-keystore"
version = "0.14.1"
version = "0.15.0-rc.0"
dependencies = [
"aead",
"base64ct",
@@ -3496,7 +3496,7 @@ dependencies = [
[[package]]
name = "mas-listener"
version = "0.14.1"
version = "0.15.0-rc.0"
dependencies = [
"anyhow",
"bytes",
@@ -3520,7 +3520,7 @@ dependencies = [
[[package]]
name = "mas-matrix"
version = "0.14.1"
version = "0.15.0-rc.0"
dependencies = [
"anyhow",
"async-trait",
@@ -3530,7 +3530,7 @@ dependencies = [
[[package]]
name = "mas-matrix-synapse"
version = "0.14.1"
version = "0.15.0-rc.0"
dependencies = [
"anyhow",
"async-trait",
@@ -3547,7 +3547,7 @@ dependencies = [
[[package]]
name = "mas-oidc-client"
version = "0.14.1"
version = "0.15.0-rc.0"
dependencies = [
"assert_matches",
"async-trait",
@@ -3583,7 +3583,7 @@ dependencies = [
[[package]]
name = "mas-policy"
version = "0.14.1"
version = "0.15.0-rc.0"
dependencies = [
"anyhow",
"arc-swap",
@@ -3600,7 +3600,7 @@ dependencies = [
[[package]]
name = "mas-router"
version = "0.14.1"
version = "0.15.0-rc.0"
dependencies = [
"axum",
"serde",
@@ -3611,7 +3611,7 @@ dependencies = [
[[package]]
name = "mas-spa"
version = "0.14.1"
version = "0.15.0-rc.0"
dependencies = [
"camino",
"serde",
@@ -3620,7 +3620,7 @@ dependencies = [
[[package]]
name = "mas-storage"
version = "0.14.1"
version = "0.15.0-rc.0"
dependencies = [
"async-trait",
"chrono",
@@ -3642,7 +3642,7 @@ dependencies = [
[[package]]
name = "mas-storage-pg"
version = "0.14.1"
version = "0.15.0-rc.0"
dependencies = [
"async-trait",
"chrono",
@@ -3668,7 +3668,7 @@ dependencies = [
[[package]]
name = "mas-tasks"
version = "0.14.1"
version = "0.15.0-rc.0"
dependencies = [
"anyhow",
"async-trait",
@@ -3699,7 +3699,7 @@ dependencies = [
[[package]]
name = "mas-templates"
version = "0.14.1"
version = "0.15.0-rc.0"
dependencies = [
"anyhow",
"arc-swap",
@@ -3729,7 +3729,7 @@ dependencies = [
[[package]]
name = "mas-tower"
version = "0.14.1"
version = "0.15.0-rc.0"
dependencies = [
"http",
"opentelemetry",
@@ -3999,7 +3999,7 @@ dependencies = [
[[package]]
name = "oauth2-types"
version = "0.14.1"
version = "0.15.0-rc.0"
dependencies = [
"assert_matches",
"base64ct",
@@ -4724,9 +4724,9 @@ dependencies = [
[[package]]
name = "psl"
version = "2.1.99"
version = "2.1.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89186bbd6ebdabc007b20bda4807abe8f4ad0636d0fb86be20f8cfce25b8f4b3"
checksum = "70295efe3fd3db60e81f452e2eacc407b4e6c2e1ff7f763424ae6e16105cee26"
dependencies = [
"psl-types",
]
@@ -5861,9 +5861,9 @@ checksum = "3b9b39299b249ad65f3b7e96443bad61c02ca5cd3589f46cb6d610a0fd6c0d6a"
[[package]]
name = "sqlx"
version = "0.8.3"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4410e73b3c0d8442c5f99b425d7a435b5ee0ae4167b3196771dd3f7a01be745f"
checksum = "14e22987355fbf8cfb813a0cf8cd97b1b4ec834b94dbd759a9e8679d41fabe83"
dependencies = [
"sqlx-core",
"sqlx-macros",
@@ -5874,10 +5874,11 @@ dependencies = [
[[package]]
name = "sqlx-core"
version = "0.8.3"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a007b6936676aa9ab40207cde35daab0a04b823be8ae004368c0793b96a61e0"
checksum = "55c4720d7d4cd3d5b00f61d03751c685ad09c33ae8290c8a2c11335e0604300b"
dependencies = [
"base64 0.22.1",
"bytes",
"chrono",
"crc",
@@ -5897,7 +5898,6 @@ dependencies = [
"once_cell",
"percent-encoding",
"rustls",
"rustls-pemfile",
"serde",
"serde_json",
"sha2",
@@ -5913,9 +5913,9 @@ dependencies = [
[[package]]
name = "sqlx-macros"
version = "0.8.3"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3112e2ad78643fef903618d78cf0aec1cb3134b019730edb039b69eaf531f310"
checksum = "175147fcb75f353ac7675509bc58abb2cb291caf0fd24a3623b8f7e3eb0a754b"
dependencies = [
"proc-macro2",
"quote",
@@ -5926,9 +5926,9 @@ dependencies = [
[[package]]
name = "sqlx-macros-core"
version = "0.8.3"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e9f90acc5ab146a99bf5061a7eb4976b573f560bc898ef3bf8435448dd5e7ad"
checksum = "1cde983058e53bfa75998e1982086c5efe3c370f3250bf0357e344fa3352e32b"
dependencies = [
"dotenvy",
"either",
@@ -5952,9 +5952,9 @@ dependencies = [
[[package]]
name = "sqlx-mysql"
version = "0.8.3"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4560278f0e00ce64938540546f59f590d60beee33fffbd3b9cd47851e5fff233"
checksum = "847d2e5393a4f39e47e4f36cab419709bc2b83cbe4223c60e86e1471655be333"
dependencies = [
"atoi",
"base64 0.22.1",
@@ -5996,9 +5996,9 @@ dependencies = [
[[package]]
name = "sqlx-postgres"
version = "0.8.3"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5b98a57f363ed6764d5b3a12bfedf62f07aa16e1856a7ddc2a0bb190a959613"
checksum = "cc35947a541b9e0a2e3d85da444f1c4137c13040267141b208395a0d0ca4659f"
dependencies = [
"atoi",
"base64 0.22.1",
@@ -6036,9 +6036,9 @@ dependencies = [
[[package]]
name = "sqlx-sqlite"
version = "0.8.3"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f85ca71d3a5b24e64e1d08dd8fe36c6c95c339a896cc33068148906784620540"
checksum = "6c48291dac4e5ed32da0927a0b981788be65674aeb62666d19873ab4289febde"
dependencies = [
"atoi",
"chrono",
@@ -6054,6 +6054,7 @@ dependencies = [
"serde",
"serde_urlencoded",
"sqlx-core",
"thiserror 2.0.12",
"tracing",
"url",
"uuid",
@@ -6148,7 +6149,7 @@ dependencies = [
[[package]]
name = "syn2mas"
version = "0.14.1"
version = "0.15.0-rc.0"
dependencies = [
"anyhow",
"arc-swap",

View File

@@ -4,7 +4,7 @@ members = ["crates/*"]
resolver = "2"
# Updated in the CI with a `sed` command
package.version = "0.14.1"
package.version = "0.15.0-rc.0"
package.license = "AGPL-3.0-only"
package.authors = ["Element Backend Team"]
package.edition = "2024"
@@ -27,34 +27,34 @@ broken_intra_doc_links = "deny"
[workspace.dependencies]
# Workspace crates
mas-axum-utils = { path = "./crates/axum-utils/", version = "=0.14.1" }
mas-cli = { path = "./crates/cli/", version = "=0.14.1" }
mas-config = { path = "./crates/config/", version = "=0.14.1" }
mas-data-model = { path = "./crates/data-model/", version = "=0.14.1" }
mas-email = { path = "./crates/email/", version = "=0.14.1" }
mas-graphql = { path = "./crates/graphql/", version = "=0.14.1" }
mas-handlers = { path = "./crates/handlers/", version = "=0.14.1" }
mas-http = { path = "./crates/http/", version = "=0.14.1" }
mas-i18n = { path = "./crates/i18n/", version = "=0.14.1" }
mas-i18n-scan = { path = "./crates/i18n-scan/", version = "=0.14.1" }
mas-iana = { path = "./crates/iana/", version = "=0.14.1" }
mas-iana-codegen = { path = "./crates/iana-codegen/", version = "=0.14.1" }
mas-jose = { path = "./crates/jose/", version = "=0.14.1" }
mas-keystore = { path = "./crates/keystore/", version = "=0.14.1" }
mas-listener = { path = "./crates/listener/", version = "=0.14.1" }
mas-matrix = { path = "./crates/matrix/", version = "=0.14.1" }
mas-matrix-synapse = { path = "./crates/matrix-synapse/", version = "=0.14.1" }
mas-oidc-client = { path = "./crates/oidc-client/", version = "=0.14.1" }
mas-policy = { path = "./crates/policy/", version = "=0.14.1" }
mas-router = { path = "./crates/router/", version = "=0.14.1" }
mas-spa = { path = "./crates/spa/", version = "=0.14.1" }
mas-storage = { path = "./crates/storage/", version = "=0.14.1" }
mas-storage-pg = { path = "./crates/storage-pg/", version = "=0.14.1" }
mas-tasks = { path = "./crates/tasks/", version = "=0.14.1" }
mas-templates = { path = "./crates/templates/", version = "=0.14.1" }
mas-tower = { path = "./crates/tower/", version = "=0.14.1" }
oauth2-types = { path = "./crates/oauth2-types/", version = "=0.14.1" }
syn2mas = { path = "./crates/syn2mas", version = "=0.14.1" }
mas-axum-utils = { path = "./crates/axum-utils/", version = "=0.15.0-rc.0" }
mas-cli = { path = "./crates/cli/", version = "=0.15.0-rc.0" }
mas-config = { path = "./crates/config/", version = "=0.15.0-rc.0" }
mas-data-model = { path = "./crates/data-model/", version = "=0.15.0-rc.0" }
mas-email = { path = "./crates/email/", version = "=0.15.0-rc.0" }
mas-graphql = { path = "./crates/graphql/", version = "=0.15.0-rc.0" }
mas-handlers = { path = "./crates/handlers/", version = "=0.15.0-rc.0" }
mas-http = { path = "./crates/http/", version = "=0.15.0-rc.0" }
mas-i18n = { path = "./crates/i18n/", version = "=0.15.0-rc.0" }
mas-i18n-scan = { path = "./crates/i18n-scan/", version = "=0.15.0-rc.0" }
mas-iana = { path = "./crates/iana/", version = "=0.15.0-rc.0" }
mas-iana-codegen = { path = "./crates/iana-codegen/", version = "=0.15.0-rc.0" }
mas-jose = { path = "./crates/jose/", version = "=0.15.0-rc.0" }
mas-keystore = { path = "./crates/keystore/", version = "=0.15.0-rc.0" }
mas-listener = { path = "./crates/listener/", version = "=0.15.0-rc.0" }
mas-matrix = { path = "./crates/matrix/", version = "=0.15.0-rc.0" }
mas-matrix-synapse = { path = "./crates/matrix-synapse/", version = "=0.15.0-rc.0" }
mas-oidc-client = { path = "./crates/oidc-client/", version = "=0.15.0-rc.0" }
mas-policy = { path = "./crates/policy/", version = "=0.15.0-rc.0" }
mas-router = { path = "./crates/router/", version = "=0.15.0-rc.0" }
mas-spa = { path = "./crates/spa/", version = "=0.15.0-rc.0" }
mas-storage = { path = "./crates/storage/", version = "=0.15.0-rc.0" }
mas-storage-pg = { path = "./crates/storage-pg/", version = "=0.15.0-rc.0" }
mas-tasks = { path = "./crates/tasks/", version = "=0.15.0-rc.0" }
mas-templates = { path = "./crates/templates/", version = "=0.15.0-rc.0" }
mas-tower = { path = "./crates/tower/", version = "=0.15.0-rc.0" }
oauth2-types = { path = "./crates/oauth2-types/", version = "=0.15.0-rc.0" }
syn2mas = { path = "./crates/syn2mas", version = "=0.15.0-rc.0" }
# OpenAPI schema generation and validation
[workspace.dependencies.aide]
@@ -80,7 +80,7 @@ version = "0.1.88"
# High-level error handling
[workspace.dependencies.anyhow]
version = "1.0.97"
version = "1.0.98"
# HTTP router
[workspace.dependencies.axum]
@@ -119,7 +119,7 @@ features = ["serde", "clock"]
# CLI argument parsing
[workspace.dependencies.clap]
version = "4.5.35"
version = "4.5.36"
features = ["derive"]
# Cron expressions
@@ -337,7 +337,7 @@ features = ["preserve_order"]
# SQL database support
[workspace.dependencies.sqlx]
version = "0.8.3"
version = "0.8.4"
features = [
"runtime-tokio",
"tls-rustls-aws-lc-rs",

View File

@@ -4,9 +4,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
use std::num::NonZeroU32;
use chrono::{DateTime, Duration, Utc};
use chrono::{DateTime, Utc};
use mas_iana::oauth::PkceCodeChallengeMethod;
use oauth2_types::{
pkce::{CodeChallengeError, CodeChallengeMethodExt},
@@ -158,11 +156,9 @@ pub struct AuthorizationGrant {
pub scope: Scope,
pub state: Option<String>,
pub nonce: Option<String>,
pub max_age: Option<NonZeroU32>,
pub response_mode: ResponseMode,
pub response_type_id_token: bool,
pub created_at: DateTime<Utc>,
pub requires_consent: bool,
pub login_hint: Option<String>,
}
@@ -174,18 +170,7 @@ impl std::ops::Deref for AuthorizationGrant {
}
}
const DEFAULT_MAX_AGE: Duration = Duration::microseconds(3600 * 24 * 365 * 1000 * 1000);
impl AuthorizationGrant {
#[must_use]
pub fn max_auth_time(&self) -> DateTime<Utc> {
let max_age = self
.max_age
.and_then(|x| Duration::try_seconds(x.get().into()))
.unwrap_or(DEFAULT_MAX_AGE);
self.created_at - max_age
}
#[must_use]
pub fn parse_login_hint(&self, homeserver: &str) -> LoginHint {
let Some(login_hint) = &self.login_hint else {
@@ -274,11 +259,9 @@ impl AuthorizationGrant {
scope: Scope::from_iter([OPENID, PROFILE]),
state: Some(Alphanumeric.sample_string(rng, 10)),
nonce: Some(Alphanumeric.sample_string(rng, 10)),
max_age: None,
response_mode: ResponseMode::Query,
response_type_id_token: false,
created_at: now,
requires_consent: false,
login_hint: Some(String::from("mxid:@example-user:example.com")),
}
}

View File

@@ -76,7 +76,7 @@ hex.workspace = true
governor.workspace = true
indexmap.workspace = true
pkcs8.workspace = true
psl = "2.1.99"
psl = "2.1.100"
sha2.workspace = true
time = "0.3.41"
url.workspace = true

View File

@@ -38,6 +38,7 @@ use opentelemetry_semantic_conventions::trace::{GRAPHQL_DOCUMENT, GRAPHQL_OPERAT
use rand::{SeedableRng, thread_rng};
use rand_chacha::ChaChaRng;
use sqlx::PgPool;
use state::has_session_ended;
use tracing::{Instrument, info_span};
use ulid::Ulid;
@@ -237,7 +238,7 @@ async fn get_requester(
clock: &impl Clock,
activity_tracker: &BoundActivityTracker,
mut repo: BoxRepository,
session_info: SessionInfo,
session_info: &SessionInfo,
user_agent: Option<String>,
token: Option<&str>,
) -> Result<Requester, RouteError> {
@@ -328,13 +329,13 @@ pub async fn post(
.as_ref()
.map(|TypedHeader(Authorization(bearer))| bearer.token());
let user_agent = user_agent.map(|TypedHeader(h)| h.to_string());
let (session_info, _cookie_jar) = cookie_jar.session_info();
let (session_info, mut cookie_jar) = cookie_jar.session_info();
let requester = get_requester(
undocumented_oauth2_access,
&clock,
&activity_tracker,
repo,
session_info,
&session_info,
user_agent,
token,
)
@@ -352,7 +353,12 @@ pub async fn post(
.data(requester); // XXX: this should probably return another error response?
let span = span_for_graphql_request(&request);
let response = schema.execute(request).instrument(span).await;
let mut response = schema.execute(request).instrument(span).await;
if has_session_ended(&mut response) {
let session_info = session_info.mark_session_ended();
cookie_jar = cookie_jar.update_session_info(&session_info);
}
let cache_control = response
.cache_control
@@ -362,7 +368,7 @@ pub async fn post(
let headers = response.http_headers.clone();
Ok((headers, cache_control, Json(response)))
Ok((headers, cache_control, cookie_jar, Json(response)))
}
pub async fn get(
@@ -382,13 +388,13 @@ pub async fn get(
.as_ref()
.map(|TypedHeader(Authorization(bearer))| bearer.token());
let user_agent = user_agent.map(|TypedHeader(h)| h.to_string());
let (session_info, _cookie_jar) = cookie_jar.session_info();
let (session_info, mut cookie_jar) = cookie_jar.session_info();
let requester = get_requester(
undocumented_oauth2_access,
&clock,
&activity_tracker,
repo,
session_info,
&session_info,
user_agent,
token,
)
@@ -398,7 +404,12 @@ pub async fn get(
async_graphql::http::parse_query_string(&query.unwrap_or_default())?.data(requester);
let span = span_for_graphql_request(&request);
let response = schema.execute(request).instrument(span).await;
let mut response = schema.execute(request).instrument(span).await;
if has_session_ended(&mut response) {
let session_info = session_info.mark_session_ended();
cookie_jar = cookie_jar.update_session_info(&session_info);
}
let cache_control = response
.cache_control
@@ -408,7 +419,7 @@ pub async fn get(
let headers = response.http_headers.clone();
Ok((headers, cache_control, Json(response)))
Ok((headers, cache_control, cookie_jar, Json(response)))
}
pub async fn playground() -> impl IntoResponse {

View File

@@ -88,6 +88,15 @@ impl BrowserSessionMutations {
repo.save().await?;
// If we are ending the *current* session, we need to clear the session cookie
// as well
if requester
.browser_session()
.is_some_and(|s| s.id == session.id)
{
ctx.mark_session_ended();
}
Ok(EndBrowserSessionPayload::Ended(Box::new(session)))
}
}

View File

@@ -4,6 +4,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
use async_graphql::{Response, ServerError};
use mas_data_model::SiteConfig;
use mas_matrix::HomeserverConnection;
use mas_policy::Policy;
@@ -12,6 +13,8 @@ use mas_storage::{BoxClock, BoxRepository, BoxRng, RepositoryError};
use crate::{Limiter, graphql::Requester, passwords::PasswordManager};
const CLEAR_SESSION_SENTINEL: &str = "__CLEAR_SESSION__";
#[async_trait::async_trait]
pub trait State {
async fn repository(&self) -> Result<BoxRepository, RepositoryError>;
@@ -30,6 +33,8 @@ pub type BoxState = Box<dyn State + Send + Sync + 'static>;
pub trait ContextExt {
fn state(&self) -> &BoxState;
fn mark_session_ended(&self);
fn requester(&self) -> &Requester;
}
@@ -38,7 +43,32 @@ impl ContextExt for async_graphql::Context<'_> {
self.data_unchecked()
}
fn mark_session_ended(&self) {
// Add a sentinel to the error context, so that we can know that we need to
// clear the session
// XXX: this is a bit of a hack, but the only sane way to get infos from within
// a mutation up to the HTTP handler
self.add_error(ServerError::new(CLEAR_SESSION_SENTINEL, None));
}
fn requester(&self) -> &Requester {
self.data_unchecked()
}
}
/// Returns true if the response contains a sentinel error indicating that the
/// current cookie session has ended, and the session cookie should be cleared.
///
/// Also removes the sentinel error from the response.
pub fn has_session_ended(response: &mut Response) -> bool {
let errors = std::mem::take(&mut response.errors);
let mut must_clear_session = false;
for error in errors {
if error.message == CLEAR_SESSION_SENTINEL {
must_clear_session = true;
} else {
response.errors.push(error);
}
}
must_clear_session
}

View File

@@ -371,10 +371,6 @@ where
get(self::views::login::get).post(self::views::login::post),
)
.route(mas_router::Logout::route(), post(self::views::logout::post))
.route(
mas_router::Reauth::route(),
get(self::views::reauth::get).post(self::views::reauth::post),
)
.route(
mas_router::Register::route(),
get(self::views::register::get),
@@ -409,13 +405,10 @@ where
mas_router::OAuth2AuthorizationEndpoint::route(),
get(self::oauth2::authorization::get),
)
.route(
mas_router::ContinueAuthorizationGrant::route(),
get(self::oauth2::authorization::complete::get),
)
.route(
mas_router::Consent::route(),
get(self::oauth2::consent::get).post(self::oauth2::consent::post),
get(self::oauth2::authorization::consent::get)
.post(self::oauth2::authorization::consent::post),
)
.route(
mas_router::CompatLoginSsoComplete::route(),

View File

@@ -101,7 +101,7 @@ impl CallbackDestination {
})
}
pub async fn go<T: Serialize + Send + Sync>(
pub fn go<T: Serialize + Send + Sync>(
self,
templates: &Templates,
locale: &DataLocale,

View File

@@ -1,309 +0,0 @@
// Copyright 2024 New Vector Ltd.
// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
use axum::{
extract::{Path, State},
response::{Html, IntoResponse, Response},
};
use axum_extra::TypedHeader;
use hyper::StatusCode;
use mas_axum_utils::{SessionInfoExt, cookies::CookieJar, csrf::CsrfExt, sentry::SentryEventID};
use mas_data_model::{AuthorizationGrant, BrowserSession, Client, Device};
use mas_keystore::Keystore;
use mas_policy::{EvaluationResult, Policy};
use mas_router::{PostAuthAction, UrlBuilder};
use mas_storage::{
BoxClock, BoxRepository, BoxRng, Clock, RepositoryAccess,
oauth2::{OAuth2AuthorizationGrantRepository, OAuth2ClientRepository, OAuth2SessionRepository},
user::BrowserSessionRepository,
};
use mas_templates::{PolicyViolationContext, TemplateContext, Templates};
use oauth2_types::requests::AuthorizationResponse;
use thiserror::Error;
use tracing::warn;
use ulid::Ulid;
use super::callback::CallbackDestination;
use crate::{
BoundActivityTracker, PreferredLanguage, impl_from_error_for_route, oauth2::generate_id_token,
};
#[derive(Debug, Error)]
pub enum RouteError {
#[error(transparent)]
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
#[error("authorization grant was not found")]
NotFound,
#[error("authorization grant is not in a pending state")]
NotPending,
#[error("failed to load client")]
NoSuchClient,
}
impl IntoResponse for RouteError {
fn into_response(self) -> axum::response::Response {
let event = sentry::capture_error(&self);
// TODO: better error pages
let response = match self {
RouteError::NotFound => {
(StatusCode::NOT_FOUND, "authorization grant was not found").into_response()
}
RouteError::NotPending => (
StatusCode::BAD_REQUEST,
"authorization grant not in a pending state",
)
.into_response(),
RouteError::Internal(_) | Self::NoSuchClient => {
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string()).into_response()
}
};
(SentryEventID::from(event), response).into_response()
}
}
impl_from_error_for_route!(mas_storage::RepositoryError);
impl_from_error_for_route!(mas_templates::TemplateError);
impl_from_error_for_route!(mas_policy::LoadError);
impl_from_error_for_route!(mas_policy::EvaluationError);
impl_from_error_for_route!(super::callback::IntoCallbackDestinationError);
impl_from_error_for_route!(super::callback::CallbackDestinationError);
#[tracing::instrument(
name = "handlers.oauth2.authorization_complete.get",
fields(grant.id = %grant_id),
skip_all,
err,
)]
pub(crate) async fn get(
mut rng: BoxRng,
clock: BoxClock,
PreferredLanguage(locale): PreferredLanguage,
State(templates): State<Templates>,
State(url_builder): State<UrlBuilder>,
State(key_store): State<Keystore>,
policy: Policy,
activity_tracker: BoundActivityTracker,
user_agent: Option<TypedHeader<headers::UserAgent>>,
mut repo: BoxRepository,
cookie_jar: CookieJar,
Path(grant_id): Path<Ulid>,
) -> Result<Response, RouteError> {
let (session_info, cookie_jar) = cookie_jar.session_info();
let maybe_session = session_info.load_active_session(&mut repo).await?;
let user_agent = user_agent.map(|TypedHeader(ua)| ua.to_string());
let grant = repo
.oauth2_authorization_grant()
.lookup(grant_id)
.await?
.ok_or(RouteError::NotFound)?;
let callback_destination = CallbackDestination::try_from(&grant)?;
let continue_grant = PostAuthAction::continue_grant(grant.id);
let Some(session) = maybe_session else {
// If there is no session, redirect to the login screen, redirecting here after
// logout
return Ok((
cookie_jar,
url_builder.redirect(&mas_router::Login::and_then(continue_grant)),
)
.into_response());
};
activity_tracker
.record_browser_session(&clock, &session)
.await;
let client = repo
.oauth2_client()
.lookup(grant.client_id)
.await?
.ok_or(RouteError::NoSuchClient)?;
match complete(
&mut rng,
&clock,
&activity_tracker,
user_agent,
repo,
key_store,
policy,
&url_builder,
grant,
&client,
&session,
)
.await
{
Ok(params) => {
let res = callback_destination.go(&templates, &locale, params).await?;
Ok((cookie_jar, res).into_response())
}
Err(GrantCompletionError::RequiresReauth) => Ok((
cookie_jar,
url_builder.redirect(&mas_router::Reauth::and_then(continue_grant)),
)
.into_response()),
Err(GrantCompletionError::RequiresConsent) => {
let next = mas_router::Consent(grant_id);
Ok((cookie_jar, url_builder.redirect(&next)).into_response())
}
Err(GrantCompletionError::PolicyViolation(grant, res)) => {
warn!(violation = ?res, "Authorization grant for client {} denied by policy", client.id);
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
let ctx = PolicyViolationContext::for_authorization_grant(grant, client)
.with_session(session)
.with_csrf(csrf_token.form_value())
.with_language(locale);
let content = templates.render_policy_violation(&ctx)?;
Ok((cookie_jar, Html(content)).into_response())
}
Err(GrantCompletionError::NotPending) => Err(RouteError::NotPending),
Err(GrantCompletionError::Internal(e)) => Err(RouteError::Internal(e)),
}
}
#[derive(Debug, Error)]
pub enum GrantCompletionError {
#[error(transparent)]
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
#[error("authorization grant is not in a pending state")]
NotPending,
#[error("user needs to reauthenticate")]
RequiresReauth,
#[error("client lacks consent")]
RequiresConsent,
#[error("denied by the policy")]
PolicyViolation(AuthorizationGrant, EvaluationResult),
}
impl_from_error_for_route!(GrantCompletionError: mas_storage::RepositoryError);
impl_from_error_for_route!(GrantCompletionError: super::callback::IntoCallbackDestinationError);
impl_from_error_for_route!(GrantCompletionError: mas_policy::LoadError);
impl_from_error_for_route!(GrantCompletionError: mas_policy::EvaluationError);
impl_from_error_for_route!(GrantCompletionError: super::super::IdTokenSignatureError);
pub(crate) async fn complete(
rng: &mut (impl rand::RngCore + rand::CryptoRng + Send),
clock: &impl Clock,
activity_tracker: &BoundActivityTracker,
user_agent: Option<String>,
mut repo: BoxRepository,
key_store: Keystore,
mut policy: Policy,
url_builder: &UrlBuilder,
grant: AuthorizationGrant,
client: &Client,
browser_session: &BrowserSession,
) -> Result<AuthorizationResponse, GrantCompletionError> {
// Verify that the grant is in a pending stage
if !grant.stage.is_pending() {
return Err(GrantCompletionError::NotPending);
}
// Check if the authentication is fresh enough
let authentication = repo
.browser_session()
.get_last_authentication(browser_session)
.await?;
let authentication = authentication.filter(|auth| auth.created_at > grant.max_auth_time());
let Some(valid_authentication) = authentication else {
repo.save().await?;
return Err(GrantCompletionError::RequiresReauth);
};
// Run through the policy
let res = policy
.evaluate_authorization_grant(mas_policy::AuthorizationGrantInput {
user: Some(&browser_session.user),
client,
scope: &grant.scope,
grant_type: mas_policy::GrantType::AuthorizationCode,
requester: mas_policy::Requester {
ip_address: activity_tracker.ip(),
user_agent,
},
})
.await?;
if !res.valid() {
return Err(GrantCompletionError::PolicyViolation(grant, res));
}
let current_consent = repo
.oauth2_client()
.get_consent_for_user(client, &browser_session.user)
.await?;
let lacks_consent = grant
.scope
.difference(&current_consent)
.filter(|scope| Device::from_scope_token(scope).is_none())
.any(|_| true);
// Check if the client lacks consent *or* if consent was explicitly asked
if lacks_consent || grant.requires_consent {
repo.save().await?;
return Err(GrantCompletionError::RequiresConsent);
}
// All good, let's start the session
let session = repo
.oauth2_session()
.add_from_browser_session(rng, clock, client, browser_session, grant.scope.clone())
.await?;
let grant = repo
.oauth2_authorization_grant()
.fulfill(clock, &session, grant)
.await?;
// Yep! Let's complete the auth now
let mut params = AuthorizationResponse::default();
// Did they request an ID token?
if grant.response_type_id_token {
params.id_token = Some(generate_id_token(
rng,
clock,
url_builder,
&key_store,
client,
Some(&grant),
browser_session,
None,
Some(&valid_authentication),
)?);
}
// Did they request an auth code?
if let Some(code) = grant.code {
params.code = Some(code.code);
}
repo.save().await?;
activity_tracker
.record_oauth2_session(clock, &session)
.await;
Ok(params)
}

View File

@@ -15,7 +15,8 @@ use mas_axum_utils::{
csrf::{CsrfExt, ProtectedForm},
sentry::SentryEventID,
};
use mas_data_model::{AuthorizationGrantStage, Device};
use mas_data_model::AuthorizationGrantStage;
use mas_keystore::Keystore;
use mas_policy::Policy;
use mas_router::{PostAuthAction, UrlBuilder};
use mas_storage::{
@@ -23,11 +24,14 @@ use mas_storage::{
oauth2::{OAuth2AuthorizationGrantRepository, OAuth2ClientRepository},
};
use mas_templates::{ConsentContext, PolicyViolationContext, TemplateContext, Templates};
use oauth2_types::requests::AuthorizationResponse;
use thiserror::Error;
use ulid::Ulid;
use super::callback::CallbackDestination;
use crate::{
BoundActivityTracker, PreferredLanguage, impl_from_error_for_route,
oauth2::generate_id_token,
session::{SessionOrFallback, load_session_or_fallback},
};
@@ -45,9 +49,6 @@ pub enum RouteError {
#[error("Authorization grant already used")]
GrantNotPending,
#[error("Policy violation")]
PolicyViolation,
#[error("Failed to load client")]
NoSuchClient,
}
@@ -57,20 +58,24 @@ impl_from_error_for_route!(mas_storage::RepositoryError);
impl_from_error_for_route!(mas_policy::LoadError);
impl_from_error_for_route!(mas_policy::EvaluationError);
impl_from_error_for_route!(crate::session::SessionLoadError);
impl_from_error_for_route!(crate::oauth2::IdTokenSignatureError);
impl_from_error_for_route!(super::callback::IntoCallbackDestinationError);
impl_from_error_for_route!(super::callback::CallbackDestinationError);
impl IntoResponse for RouteError {
fn into_response(self) -> axum::response::Response {
let event_id = sentry::capture_error(&self);
(
SentryEventID::from(event_id),
StatusCode::INTERNAL_SERVER_ERROR,
SentryEventID::from(event_id),
self.to_string(),
)
.into_response()
}
}
#[tracing::instrument(
name = "handlers.oauth2.consent.get",
name = "handlers.oauth2.authorization.consent.get",
fields(grant.id = %grant_id),
skip_all,
err,
@@ -142,17 +147,7 @@ pub(crate) async fn get(
},
})
.await?;
if res.valid() {
let ctx = ConsentContext::new(grant, client)
.with_session(session)
.with_csrf(csrf_token.form_value())
.with_language(locale);
let content = templates.render_consent(&ctx)?;
Ok((cookie_jar, Html(content)).into_response())
} else {
if !res.valid() {
let ctx = PolicyViolationContext::for_authorization_grant(grant, client)
.with_session(session)
.with_csrf(csrf_token.form_value())
@@ -160,12 +155,21 @@ pub(crate) async fn get(
let content = templates.render_policy_violation(&ctx)?;
Ok((cookie_jar, Html(content)).into_response())
return Ok((cookie_jar, Html(content)).into_response());
}
let ctx = ConsentContext::new(grant, client)
.with_session(session)
.with_csrf(csrf_token.form_value())
.with_language(locale);
let content = templates.render_consent(&ctx)?;
Ok((cookie_jar, Html(content)).into_response())
}
#[tracing::instrument(
name = "handlers.oauth2.consent.post",
name = "handlers.oauth2.authorization.consent.post",
fields(grant.id = %grant_id),
skip_all,
err,
@@ -175,6 +179,7 @@ pub(crate) async fn post(
clock: BoxClock,
PreferredLanguage(locale): PreferredLanguage,
State(templates): State<Templates>,
State(key_store): State<Keystore>,
mut policy: Policy,
mut repo: BoxRepository,
activity_tracker: BoundActivityTracker,
@@ -199,6 +204,8 @@ pub(crate) async fn post(
SessionOrFallback::Fallback { response } => return Ok(response),
};
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
let user_agent = user_agent.map(|ua| ua.to_string());
let grant = repo
@@ -206,15 +213,16 @@ pub(crate) async fn post(
.lookup(grant_id)
.await?
.ok_or(RouteError::GrantNotFound)?;
let next = PostAuthAction::continue_grant(grant_id);
let callback_destination = CallbackDestination::try_from(&grant)?;
let Some(session) = maybe_session else {
let Some(browser_session) = maybe_session else {
let next = PostAuthAction::continue_grant(grant_id);
let login = mas_router::Login::and_then(next);
return Ok((cookie_jar, url_builder.redirect(&login)).into_response());
};
activity_tracker
.record_browser_session(&clock, &session)
.record_browser_session(&clock, &browser_session)
.await;
let client = repo
@@ -225,7 +233,7 @@ pub(crate) async fn post(
let res = policy
.evaluate_authorization_grant(mas_policy::AuthorizationGrantInput {
user: Some(&session.user),
user: Some(&browser_session.user),
client: &client,
scope: &grant.scope,
grant_type: mas_policy::GrantType::AuthorizationCode,
@@ -237,32 +245,70 @@ pub(crate) async fn post(
.await?;
if !res.valid() {
return Err(RouteError::PolicyViolation);
let ctx = PolicyViolationContext::for_authorization_grant(grant, client)
.with_session(browser_session)
.with_csrf(csrf_token.form_value())
.with_language(locale);
let content = templates.render_policy_violation(&ctx)?;
return Ok((cookie_jar, Html(content)).into_response());
}
// Do not consent for the "urn:matrix:org.matrix.msc2967.client:device:*" scope
let scope_without_device = grant
.scope
.iter()
.filter(|s| Device::from_scope_token(s).is_none())
.cloned()
.collect();
repo.oauth2_client()
.give_consent_for_user(
// All good, let's start the session
let session = repo
.oauth2_session()
.add_from_browser_session(
&mut rng,
&clock,
&client,
&session.user,
&scope_without_device,
&browser_session,
grant.scope.clone(),
)
.await?;
repo.oauth2_authorization_grant()
.give_consent(grant)
let grant = repo
.oauth2_authorization_grant()
.fulfill(&clock, &session, grant)
.await?;
let mut params = AuthorizationResponse::default();
// Did they request an ID token?
if grant.response_type_id_token {
// Fetch the last authentication
let last_authentication = repo
.browser_session()
.get_last_authentication(&browser_session)
.await?;
params.id_token = Some(generate_id_token(
&mut rng,
&clock,
&url_builder,
&key_store,
&client,
Some(&grant),
&browser_session,
None,
last_authentication.as_ref(),
)?);
}
// Did they request an auth code?
if let Some(code) = grant.code {
params.code = Some(code.code);
}
repo.save().await?;
Ok((cookie_jar, next.go_next(&url_builder)).into_response())
activity_tracker
.record_oauth2_session(&clock, &session)
.await;
Ok((
cookie_jar,
callback_destination.go(&templates, &locale, params)?,
)
.into_response())
}

View File

@@ -6,20 +6,17 @@
use axum::{
extract::{Form, State},
response::{Html, IntoResponse, Response},
response::{IntoResponse, Response},
};
use axum_extra::TypedHeader;
use hyper::StatusCode;
use mas_axum_utils::{SessionInfoExt, cookies::CookieJar, csrf::CsrfExt, sentry::SentryEventID};
use mas_axum_utils::{SessionInfoExt, cookies::CookieJar, sentry::SentryEventID};
use mas_data_model::{AuthorizationCode, Pkce};
use mas_keystore::Keystore;
use mas_policy::Policy;
use mas_router::{PostAuthAction, UrlBuilder};
use mas_storage::{
BoxClock, BoxRepository, BoxRng,
oauth2::{OAuth2AuthorizationGrantRepository, OAuth2ClientRepository},
};
use mas_templates::{PolicyViolationContext, TemplateContext, Templates};
use mas_templates::Templates;
use oauth2_types::{
errors::{ClientError, ClientErrorCode},
pkce,
@@ -29,13 +26,12 @@ use oauth2_types::{
use rand::{Rng, distributions::Alphanumeric};
use serde::Deserialize;
use thiserror::Error;
use tracing::warn;
use self::{callback::CallbackDestination, complete::GrantCompletionError};
use self::callback::CallbackDestination;
use crate::{BoundActivityTracker, PreferredLanguage, impl_from_error_for_route};
mod callback;
pub mod complete;
pub(crate) mod consent;
#[derive(Debug, Error)]
pub enum RouteError {
@@ -134,10 +130,7 @@ pub(crate) async fn get(
clock: BoxClock,
PreferredLanguage(locale): PreferredLanguage,
State(templates): State<Templates>,
State(key_store): State<Keystore>,
State(url_builder): State<UrlBuilder>,
policy: Policy,
user_agent: Option<TypedHeader<headers::UserAgent>>,
activity_tracker: BoundActivityTracker,
mut repo: BoxRepository,
cookie_jar: CookieJar,
@@ -166,9 +159,6 @@ pub(crate) async fn get(
// Get the session info from the cookie
let (session_info, cookie_jar) = cookie_jar.session_info();
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
let user_agent = user_agent.map(|TypedHeader(ua)| ua.to_string());
// One day, we will have try blocks
let res: Result<Response, RouteError> = ({
@@ -182,80 +172,66 @@ pub(crate) async fn get(
// Check if the request/request_uri/registration params are used. If so, reply
// with the right error since we don't support them.
if params.auth.request.is_some() {
return Ok(callback_destination
.go(
&templates,
&locale,
ClientError::from(ClientErrorCode::RequestNotSupported),
)
.await?);
return Ok(callback_destination.go(
&templates,
&locale,
ClientError::from(ClientErrorCode::RequestNotSupported),
)?);
}
if params.auth.request_uri.is_some() {
return Ok(callback_destination
.go(
&templates,
&locale,
ClientError::from(ClientErrorCode::RequestUriNotSupported),
)
.await?);
return Ok(callback_destination.go(
&templates,
&locale,
ClientError::from(ClientErrorCode::RequestUriNotSupported),
)?);
}
// Check if the client asked for a `token` response type, and bail out if it's
// the case, since we don't support them
if response_type.has_token() {
return Ok(callback_destination
.go(
&templates,
&locale,
ClientError::from(ClientErrorCode::UnsupportedResponseType),
)
.await?);
return Ok(callback_destination.go(
&templates,
&locale,
ClientError::from(ClientErrorCode::UnsupportedResponseType),
)?);
}
// If the client asked for a `id_token` response type, we must check if it can
// use the `implicit` grant type
if response_type.has_id_token() && !client.grant_types.contains(&GrantType::Implicit) {
return Ok(callback_destination
.go(
&templates,
&locale,
ClientError::from(ClientErrorCode::UnauthorizedClient),
)
.await?);
return Ok(callback_destination.go(
&templates,
&locale,
ClientError::from(ClientErrorCode::UnauthorizedClient),
)?);
}
if params.auth.registration.is_some() {
return Ok(callback_destination
.go(
&templates,
&locale,
ClientError::from(ClientErrorCode::RegistrationNotSupported),
)
.await?);
return Ok(callback_destination.go(
&templates,
&locale,
ClientError::from(ClientErrorCode::RegistrationNotSupported),
)?);
}
// Fail early if prompt=none and there is no active session
if prompt.contains(&Prompt::None) && maybe_session.is_none() {
return Ok(callback_destination
.go(
&templates,
&locale,
ClientError::from(ClientErrorCode::LoginRequired),
)
.await?);
// Fail early if prompt=none; we never let it go through
if prompt.contains(&Prompt::None) {
return Ok(callback_destination.go(
&templates,
&locale,
ClientError::from(ClientErrorCode::LoginRequired),
)?);
}
let code: Option<AuthorizationCode> = if response_type.has_code() {
// Check if it is allowed to use this grant type
if !client.grant_types.contains(&GrantType::AuthorizationCode) {
return Ok(callback_destination
.go(
&templates,
&locale,
ClientError::from(ClientErrorCode::UnauthorizedClient),
)
.await?);
return Ok(callback_destination.go(
&templates,
&locale,
ClientError::from(ClientErrorCode::UnauthorizedClient),
)?);
}
// 32 random alphanumeric characters, about 190bit of entropy
@@ -275,20 +251,16 @@ pub(crate) async fn get(
// If the request had PKCE params but no code asked, it should get back with an
// error
if params.pkce.is_some() {
return Ok(callback_destination
.go(
&templates,
&locale,
ClientError::from(ClientErrorCode::InvalidRequest),
)
.await?);
return Ok(callback_destination.go(
&templates,
&locale,
ClientError::from(ClientErrorCode::InvalidRequest),
)?);
}
None
};
let requires_consent = prompt.contains(&Prompt::Consent);
let grant = repo
.oauth2_authorization_grant()
.add(
@@ -300,151 +272,43 @@ pub(crate) async fn get(
code,
params.auth.state.clone(),
params.auth.nonce,
params.auth.max_age,
response_mode,
response_type.has_id_token(),
requires_consent,
params.auth.login_hint,
)
.await?;
let continue_grant = PostAuthAction::continue_grant(grant.id);
let res = match maybe_session {
// Cases where there is no active session, redirect to the relevant page
None if prompt.contains(&Prompt::None) => {
// This case should already be handled earlier
unreachable!();
}
None if prompt.contains(&Prompt::Create) => {
// Client asked for a registration, show the registration prompt
repo.save().await?;
url_builder.redirect(&mas_router::Register::and_then(continue_grant))
url_builder
.redirect(&mas_router::Register::and_then(continue_grant))
.into_response()
}
None => {
// Other cases where we don't have a session, ask for a login
repo.save().await?;
url_builder.redirect(&mas_router::Login::and_then(continue_grant))
url_builder
.redirect(&mas_router::Login::and_then(continue_grant))
.into_response()
}
// Special case when we already have a session but prompt=login|select_account
Some(session)
if prompt.contains(&Prompt::Login)
|| prompt.contains(&Prompt::SelectAccount) =>
{
// TODO: better pages here
Some(user_session) => {
// TODO: better support for prompt=create when we have a session
repo.save().await?;
activity_tracker.record_browser_session(&clock, &session).await;
url_builder.redirect(&mas_router::Reauth::and_then(continue_grant))
activity_tracker
.record_browser_session(&clock, &user_session)
.await;
url_builder
.redirect(&mas_router::Consent(grant.id))
.into_response()
}
// Else, we immediately try to complete the authorization grant
Some(user_session) if prompt.contains(&Prompt::None) => {
activity_tracker.record_browser_session(&clock, &user_session).await;
// With prompt=none, we should get back to the client immediately
match self::complete::complete(
&mut rng,
&clock,
&activity_tracker,
user_agent,
repo,
key_store,
policy,
&url_builder,
grant,
&client,
&user_session,
)
.await
{
Ok(params) => callback_destination.go(&templates, &locale, params).await?,
Err(GrantCompletionError::RequiresConsent) => {
callback_destination
.go(
&templates,
&locale,
ClientError::from(ClientErrorCode::ConsentRequired),
)
.await?
}
Err(GrantCompletionError::RequiresReauth) => {
callback_destination
.go(
&templates,
&locale,
ClientError::from(ClientErrorCode::InteractionRequired),
)
.await?
}
Err(GrantCompletionError::PolicyViolation(_grant, _res)) => {
callback_destination
.go(&templates, &locale, ClientError::from(ClientErrorCode::AccessDenied))
.await?
}
Err(GrantCompletionError::Internal(e)) => {
return Err(RouteError::Internal(e))
}
Err(e @ GrantCompletionError::NotPending) => {
// This should never happen
return Err(RouteError::Internal(Box::new(e)));
}
}
}
Some(user_session) => {
activity_tracker.record_browser_session(&clock, &user_session).await;
let grant_id = grant.id;
// Else, we show the relevant reauth/consent page if necessary
match self::complete::complete(
&mut rng,
&clock,
&activity_tracker,
user_agent,
repo,
key_store,
policy,
&url_builder,
grant,
&client,
&user_session,
)
.await
{
Ok(params) => callback_destination.go(&templates, &locale, params).await?,
Err(GrantCompletionError::RequiresConsent) => {
url_builder.redirect(&mas_router::Consent(grant_id)).into_response()
}
Err(GrantCompletionError::PolicyViolation(grant, res)) => {
warn!(violation = ?res, "Authorization grant for client {} denied by policy", client.id);
let ctx = PolicyViolationContext::for_authorization_grant(grant, client)
.with_session(user_session)
.with_csrf(csrf_token.form_value())
.with_language(locale);
let content = templates.render_policy_violation(&ctx)?;
Html(content).into_response()
}
Err(GrantCompletionError::RequiresReauth) => {
url_builder.redirect(&mas_router::Reauth::and_then(continue_grant))
.into_response()
}
Err(GrantCompletionError::Internal(e)) => {
return Err(RouteError::Internal(e))
}
Err(e @ GrantCompletionError::NotPending) => {
// This should never happen
return Err(RouteError::Internal(Box::new(e)));
}
}
}
};
Ok(res)
@@ -456,13 +320,11 @@ pub(crate) async fn get(
Ok(r) => r,
Err(err) => {
tracing::error!(%err);
callback_destination
.go(
&templates,
&locale,
ClientError::from(ClientErrorCode::ServerError),
)
.await?
callback_destination.go(
&templates,
&locale,
ClientError::from(ClientErrorCode::ServerError),
)?
}
};

View File

@@ -132,7 +132,7 @@ pub(crate) async fn get(
let request_uri_parameter_supported = Some(false);
let prompt_values_supported = Some({
let mut v = vec![Prompt::None, Prompt::Login];
let mut v = vec![Prompt::Login];
// Advertise for prompt=create if password registration is enabled
// TODO: we may want to be able to forward that to upstream providers if they
// support it

View File

@@ -23,7 +23,6 @@ use mas_storage::{Clock, RepositoryAccess};
use thiserror::Error;
pub mod authorization;
pub mod consent;
pub mod device;
pub mod discovery;
pub mod introspection;

View File

@@ -978,10 +978,8 @@ mod tests {
}),
Some("state".to_owned()),
Some("nonce".to_owned()),
None,
ResponseMode::Query,
false,
false,
None,
)
.await
@@ -1079,10 +1077,8 @@ mod tests {
}),
Some("state".to_owned()),
Some("nonce".to_owned()),
None,
ResponseMode::Query,
false,
false,
None,
)
.await

View File

@@ -8,7 +8,6 @@ pub mod app;
pub mod index;
pub mod login;
pub mod logout;
pub mod reauth;
pub mod recovery;
pub mod register;
pub mod shared;

View File

@@ -1,189 +0,0 @@
// Copyright 2024 New Vector Ltd.
// Copyright 2021-2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
use anyhow::Context;
use axum::{
extract::{Form, Query, State},
response::{Html, IntoResponse, Response},
};
use hyper::StatusCode;
use mas_axum_utils::{
FancyError, SessionInfoExt,
cookies::CookieJar,
csrf::{CsrfExt, ProtectedForm},
};
use mas_router::UrlBuilder;
use mas_storage::{
BoxClock, BoxRepository, BoxRng,
user::{BrowserSessionRepository, UserPasswordRepository},
};
use mas_templates::{ReauthContext, TemplateContext, Templates};
use serde::Deserialize;
use zeroize::Zeroizing;
use super::shared::OptionalPostAuthAction;
use crate::{
BoundActivityTracker, PreferredLanguage, SiteConfig,
passwords::PasswordManager,
session::{SessionOrFallback, load_session_or_fallback},
};
#[derive(Deserialize, Debug)]
pub(crate) struct ReauthForm {
password: String,
}
#[tracing::instrument(name = "handlers.views.reauth.get", skip_all, err)]
pub(crate) async fn get(
mut rng: BoxRng,
clock: BoxClock,
PreferredLanguage(locale): PreferredLanguage,
State(templates): State<Templates>,
State(url_builder): State<UrlBuilder>,
State(site_config): State<SiteConfig>,
activity_tracker: BoundActivityTracker,
mut repo: BoxRepository,
Query(query): Query<OptionalPostAuthAction>,
cookie_jar: CookieJar,
) -> Result<Response, FancyError> {
if !site_config.password_login_enabled {
// XXX: do something better here
return Ok(url_builder
.redirect(&mas_router::Account::default())
.into_response());
}
let (cookie_jar, maybe_session) = match load_session_or_fallback(
cookie_jar, &clock, &mut rng, &templates, &locale, &mut repo,
)
.await?
{
SessionOrFallback::MaybeSession {
cookie_jar,
maybe_session,
..
} => (cookie_jar, maybe_session),
SessionOrFallback::Fallback { response } => return Ok(response),
};
let Some(session) = maybe_session else {
// If there is no session, redirect to the login screen, keeping the
// PostAuthAction
let login = mas_router::Login::from(query.post_auth_action);
return Ok((cookie_jar, url_builder.redirect(&login)).into_response());
};
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
activity_tracker
.record_browser_session(&clock, &session)
.await;
let ctx = ReauthContext::default();
let next = query.load_context(&mut repo).await?;
let ctx = if let Some(next) = next {
ctx.with_post_action(next)
} else {
ctx
};
let ctx = ctx
.with_session(session)
.with_csrf(csrf_token.form_value())
.with_language(locale);
let content = templates.render_reauth(&ctx)?;
Ok((cookie_jar, Html(content)).into_response())
}
#[tracing::instrument(name = "handlers.views.reauth.post", skip_all, err)]
pub(crate) async fn post(
mut rng: BoxRng,
clock: BoxClock,
PreferredLanguage(locale): PreferredLanguage,
State(templates): State<Templates>,
State(password_manager): State<PasswordManager>,
State(url_builder): State<UrlBuilder>,
State(site_config): State<SiteConfig>,
mut repo: BoxRepository,
Query(query): Query<OptionalPostAuthAction>,
cookie_jar: CookieJar,
Form(form): Form<ProtectedForm<ReauthForm>>,
) -> Result<Response, FancyError> {
if !site_config.password_login_enabled {
// XXX: do something better here
return Ok(StatusCode::METHOD_NOT_ALLOWED.into_response());
}
let form = cookie_jar.verify_form(&clock, form)?;
let (cookie_jar, maybe_session) = match load_session_or_fallback(
cookie_jar, &clock, &mut rng, &templates, &locale, &mut repo,
)
.await?
{
SessionOrFallback::MaybeSession {
cookie_jar,
maybe_session,
..
} => (cookie_jar, maybe_session),
SessionOrFallback::Fallback { response } => return Ok(response),
};
let Some(session) = maybe_session else {
// If there is no session, redirect to the login screen, keeping the
// PostAuthAction
let login = mas_router::Login::from(query.post_auth_action);
return Ok((cookie_jar, url_builder.redirect(&login)).into_response());
};
// Load the user password
let user_password = repo
.user_password()
.active(&session.user)
.await?
.context("User has no password")?;
let password = Zeroizing::new(form.password.as_bytes().to_vec());
// TODO: recover from errors
// Verify the password, and upgrade it on-the-fly if needed
let new_password_hash = password_manager
.verify_and_upgrade(
&mut rng,
user_password.version,
password,
user_password.hashed_password.clone(),
)
.await?;
let user_password = if let Some((version, new_password_hash)) = new_password_hash {
// Save the upgraded password
repo.user_password()
.add(
&mut rng,
&clock,
&session.user,
version,
new_password_hash,
Some(&user_password),
)
.await?
} else {
user_password
};
// Mark the session as authenticated by the password
repo.browser_session()
.authenticate_with_password(&mut rng, &clock, &session, &user_password)
.await?;
let cookie_jar = cookie_jar.set_session(&session);
repo.save().await?;
let reply = query.go_next(&url_builder);
Ok((cookie_jar, reply).into_response())
}

View File

@@ -60,9 +60,7 @@ impl PostAuthAction {
pub fn go_next(&self, url_builder: &UrlBuilder) -> axum::response::Redirect {
match self {
Self::ContinueAuthorizationGrant { id } => {
url_builder.redirect(&ContinueAuthorizationGrant(*id))
}
Self::ContinueAuthorizationGrant { id } => url_builder.redirect(&Consent(*id)),
Self::ContinueDeviceCodeGrant { id } => {
url_builder.redirect(&DeviceCodeConsent::new(*id))
}
@@ -255,66 +253,6 @@ impl SimpleRoute for Logout {
const PATH: &'static str = "/logout";
}
/// `GET|POST /reauth`
#[derive(Default, Debug, Clone)]
pub struct Reauth {
post_auth_action: Option<PostAuthAction>,
}
impl Reauth {
#[must_use]
pub fn and_then(action: PostAuthAction) -> Self {
Self {
post_auth_action: Some(action),
}
}
#[must_use]
pub fn and_continue_grant(data: Ulid) -> Self {
Self {
post_auth_action: Some(PostAuthAction::continue_grant(data)),
}
}
#[must_use]
pub fn and_continue_device_code_grant(data: Ulid) -> Self {
Self {
post_auth_action: Some(PostAuthAction::continue_device_code_grant(data)),
}
}
/// Get a reference to the reauth's post auth action.
#[must_use]
pub fn post_auth_action(&self) -> Option<&PostAuthAction> {
self.post_auth_action.as_ref()
}
pub fn go_next(&self, url_builder: &UrlBuilder) -> axum::response::Redirect {
match &self.post_auth_action {
Some(action) => action.go_next(url_builder),
None => url_builder.redirect(&Index),
}
}
}
impl Route for Reauth {
type Query = PostAuthAction;
fn route() -> &'static str {
"/reauth"
}
fn query(&self) -> Option<&Self::Query> {
self.post_auth_action.as_ref()
}
}
impl From<Option<PostAuthAction>> for Reauth {
fn from(post_auth_action: Option<PostAuthAction>) -> Self {
Self { post_auth_action }
}
}
/// `POST /register`
#[derive(Default, Debug, Clone)]
pub struct Register {
@@ -581,21 +519,6 @@ impl SimpleRoute for AccountPasswordChange {
const PATH: &'static str = "/account/password/change";
}
/// `GET /authorize/{grant_id}`
#[derive(Debug, Clone)]
pub struct ContinueAuthorizationGrant(pub Ulid);
impl Route for ContinueAuthorizationGrant {
type Query = ();
fn route() -> &'static str {
"/authorize/{grant_id}"
}
fn path(&self) -> std::borrow::Cow<'static, str> {
format!("/authorize/{}", self.0).into()
}
}
/// `GET /consent/{grant_id}`
#[derive(Debug, Clone)]
pub struct Consent(pub Ulid);

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT EXISTS(\n SELECT 1 FROM users WHERE username = $1\n ) AS \"exists!\"\n ",
"query": "\n SELECT EXISTS(\n SELECT 1 FROM users WHERE LOWER(username) = LOWER($1)\n ) AS \"exists!\"\n ",
"describe": {
"columns": [
{
@@ -18,5 +18,5 @@
null
]
},
"hash": "94fd96446b237c87bd6bf741f3c42b37ee751b87b7fcc459602bdf8c46962443"
"hash": "7f8335cc94347bc3a15afe7051658659347a1bf71dd62335df046708f19c967e"
}

View File

@@ -1,29 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO oauth2_authorization_grants (\n oauth2_authorization_grant_id,\n oauth2_client_id,\n redirect_uri,\n scope,\n state,\n nonce,\n max_age,\n response_mode,\n code_challenge,\n code_challenge_method,\n response_type_code,\n response_type_id_token,\n authorization_code,\n requires_consent,\n login_hint,\n created_at\n )\n VALUES\n ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid",
"Text",
"Text",
"Text",
"Text",
"Int4",
"Text",
"Text",
"Text",
"Bool",
"Bool",
"Text",
"Bool",
"Text",
"Timestamptz"
]
},
"nullable": []
},
"hash": "854cc8cd3c1fc3dbbdf4ce81b561aafadb0f4e98caeaba01597c6f62875ae691"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT oauth2_authorization_grant_id\n , created_at\n , cancelled_at\n , fulfilled_at\n , exchanged_at\n , scope\n , state\n , redirect_uri\n , response_mode\n , nonce\n , max_age\n , oauth2_client_id\n , authorization_code\n , response_type_code\n , response_type_id_token\n , code_challenge\n , code_challenge_method\n , requires_consent\n , login_hint\n , oauth2_session_id\n FROM\n oauth2_authorization_grants\n\n WHERE authorization_code = $1\n ",
"query": "\n SELECT oauth2_authorization_grant_id\n , created_at\n , cancelled_at\n , fulfilled_at\n , exchanged_at\n , scope\n , state\n , redirect_uri\n , response_mode\n , nonce\n , oauth2_client_id\n , authorization_code\n , response_type_code\n , response_type_id_token\n , code_challenge\n , code_challenge_method\n , login_hint\n , oauth2_session_id\n FROM\n oauth2_authorization_grants\n\n WHERE authorization_code = $1\n ",
"describe": {
"columns": [
{
@@ -55,51 +55,41 @@
},
{
"ordinal": 10,
"name": "max_age",
"type_info": "Int4"
},
{
"ordinal": 11,
"name": "oauth2_client_id",
"type_info": "Uuid"
},
{
"ordinal": 12,
"ordinal": 11,
"name": "authorization_code",
"type_info": "Text"
},
{
"ordinal": 13,
"ordinal": 12,
"name": "response_type_code",
"type_info": "Bool"
},
{
"ordinal": 14,
"ordinal": 13,
"name": "response_type_id_token",
"type_info": "Bool"
},
{
"ordinal": 15,
"ordinal": 14,
"name": "code_challenge",
"type_info": "Text"
},
{
"ordinal": 16,
"ordinal": 15,
"name": "code_challenge_method",
"type_info": "Text"
},
{
"ordinal": 17,
"name": "requires_consent",
"type_info": "Bool"
},
{
"ordinal": 18,
"ordinal": 16,
"name": "login_hint",
"type_info": "Text"
},
{
"ordinal": 19,
"ordinal": 17,
"name": "oauth2_session_id",
"type_info": "Uuid"
}
@@ -120,17 +110,15 @@
false,
false,
true,
true,
false,
true,
false,
false,
true,
true,
false,
true,
true
]
},
"hash": "1d9c478c7a5e3a672610376a290b9a1afaaa6fa2fb137f7307002f058b206dbd"
"hash": "890516adeeaf2d6a4fe83a69e42e18b8817d1bb511e08ee761a031fa12d27251"
}

View File

@@ -1,23 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT scope_token\n FROM oauth2_consents\n WHERE user_id = $1 AND oauth2_client_id = $2\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "scope_token",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Uuid",
"Uuid"
]
},
"nullable": [
false
]
},
"hash": "8b7297c263336d70c2b647212b16f7ae39bc5cb1572e3a2dcfcd67f196a1fa39"
}

View File

@@ -0,0 +1,27 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO oauth2_authorization_grants (\n oauth2_authorization_grant_id,\n oauth2_client_id,\n redirect_uri,\n scope,\n state,\n nonce,\n response_mode,\n code_challenge,\n code_challenge_method,\n response_type_code,\n response_type_id_token,\n authorization_code,\n login_hint,\n created_at\n )\n VALUES\n ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid",
"Text",
"Text",
"Text",
"Text",
"Text",
"Text",
"Text",
"Bool",
"Bool",
"Text",
"Text",
"Timestamptz"
]
},
"nullable": []
},
"hash": "96208afd8b81e00f2700b0ded40c1eb529d321d0aa543be70f86ef96d0d8ff28"
}

View File

@@ -1,18 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO oauth2_consents\n (oauth2_consent_id, user_id, oauth2_client_id, scope_token, created_at)\n SELECT id, $2, $3, scope_token, $5 FROM UNNEST($1::uuid[], $4::text[]) u(id, scope_token)\n ON CONFLICT (user_id, oauth2_client_id, scope_token) DO UPDATE SET refreshed_at = $5\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"UuidArray",
"Uuid",
"Uuid",
"TextArray",
"Timestamptz"
]
},
"nullable": []
},
"hash": "9a6c197ff4ad80217262d48f8792ce7e16bc5df0677c7cd4ecb4fdbc5ee86395"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT oauth2_authorization_grant_id\n , created_at\n , cancelled_at\n , fulfilled_at\n , exchanged_at\n , scope\n , state\n , redirect_uri\n , response_mode\n , nonce\n , max_age\n , oauth2_client_id\n , authorization_code\n , response_type_code\n , response_type_id_token\n , code_challenge\n , code_challenge_method\n , requires_consent\n , login_hint\n , oauth2_session_id\n FROM\n oauth2_authorization_grants\n\n WHERE oauth2_authorization_grant_id = $1\n ",
"query": "\n SELECT oauth2_authorization_grant_id\n , created_at\n , cancelled_at\n , fulfilled_at\n , exchanged_at\n , scope\n , state\n , redirect_uri\n , response_mode\n , nonce\n , oauth2_client_id\n , authorization_code\n , response_type_code\n , response_type_id_token\n , code_challenge\n , code_challenge_method\n , login_hint\n , oauth2_session_id\n FROM\n oauth2_authorization_grants\n\n WHERE oauth2_authorization_grant_id = $1\n ",
"describe": {
"columns": [
{
@@ -55,51 +55,41 @@
},
{
"ordinal": 10,
"name": "max_age",
"type_info": "Int4"
},
{
"ordinal": 11,
"name": "oauth2_client_id",
"type_info": "Uuid"
},
{
"ordinal": 12,
"ordinal": 11,
"name": "authorization_code",
"type_info": "Text"
},
{
"ordinal": 13,
"ordinal": 12,
"name": "response_type_code",
"type_info": "Bool"
},
{
"ordinal": 14,
"ordinal": 13,
"name": "response_type_id_token",
"type_info": "Bool"
},
{
"ordinal": 15,
"ordinal": 14,
"name": "code_challenge",
"type_info": "Text"
},
{
"ordinal": 16,
"ordinal": 15,
"name": "code_challenge_method",
"type_info": "Text"
},
{
"ordinal": 17,
"name": "requires_consent",
"type_info": "Bool"
},
{
"ordinal": 18,
"ordinal": 16,
"name": "login_hint",
"type_info": "Text"
},
{
"ordinal": 19,
"ordinal": 17,
"name": "oauth2_session_id",
"type_info": "Uuid"
}
@@ -120,17 +110,15 @@
false,
false,
true,
true,
false,
true,
false,
false,
true,
true,
false,
true,
true
]
},
"hash": "e0d3be7e741581430e3e4719c7e19596837234c94a398570bdac42652c2c4652"
"hash": "bf6d1e3e3145438c988b1a47fc13f0168a63e278d8f8c947cb3ab65173f92ae4"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT user_id\n , username\n , created_at\n , locked_at\n , deactivated_at\n , can_request_admin\n FROM users\n WHERE username = $1\n ",
"query": "\n SELECT user_id\n , username\n , created_at\n , locked_at\n , deactivated_at\n , can_request_admin\n FROM users\n WHERE LOWER(username) = LOWER($1)\n ",
"describe": {
"columns": [
{
@@ -48,5 +48,5 @@
false
]
},
"hash": "48213d718a256a12540c0aec595ca3e436be423f2d0c868700c6397745ed0455"
"hash": "d2a4f5c01603463b78198529d295f7f121769ea5730d01c20c0ddbcdc79a5716"
}

View File

@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE oauth2_authorization_grants AS og\n SET\n requires_consent = 'f'\n WHERE\n og.oauth2_authorization_grant_id = $1\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "d83421d4a16f4ad084dd0db5abb56d3688851c36a48a50aa6104e8291e73630d"
}

View File

@@ -0,0 +1,9 @@
-- no-transaction
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
CREATE INDEX CONCURRENTLY
compat_access_tokens_session_fk
ON compat_access_tokens (compat_session_id);

View File

@@ -0,0 +1,9 @@
-- no-transaction
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
CREATE INDEX CONCURRENTLY
compat_refresh_tokens_session_fk
ON compat_refresh_tokens (compat_session_id);

View File

@@ -0,0 +1,9 @@
-- no-transaction
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
CREATE INDEX CONCURRENTLY
compat_refresh_tokens_access_token_fk
ON compat_refresh_tokens (compat_access_token_id);

View File

@@ -0,0 +1,13 @@
-- no-transaction
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
-- Including the `last_active_at` column lets us effeciently filter in-memory
-- for those sessions without fetching the rows, and without including it in the
-- index btree
CREATE INDEX CONCURRENTLY
compat_sessions_user_fk
ON compat_sessions (user_id)
INCLUDE (last_active_at);

View File

@@ -0,0 +1,9 @@
-- no-transaction
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
CREATE INDEX CONCURRENTLY
compat_sessions_user_session_fk
ON compat_sessions (user_session_id);

View File

@@ -0,0 +1,8 @@
-- no-transaction
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
-- Redundant with the `compat_sessions_user_fk`
DROP INDEX IF EXISTS compat_sessions_user_id_last_active_at;

View File

@@ -0,0 +1,9 @@
-- no-transaction
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
CREATE INDEX CONCURRENTLY
compat_sso_logins_session_fk
ON compat_sso_logins (compat_session_id);

View File

@@ -0,0 +1,9 @@
-- no-transaction
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
CREATE INDEX CONCURRENTLY
oauth2_access_tokens_session_fk
ON oauth2_access_tokens (oauth2_session_id);

View File

@@ -0,0 +1,9 @@
-- no-transaction
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
CREATE INDEX CONCURRENTLY
oauth2_authorization_grants_session_fk
ON oauth2_authorization_grants (oauth2_session_id);

View File

@@ -0,0 +1,9 @@
-- no-transaction
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
CREATE INDEX CONCURRENTLY
oauth2_authorization_grants_client_fk
ON oauth2_authorization_grants (oauth2_client_id);

View File

@@ -0,0 +1,9 @@
-- no-transaction
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
CREATE INDEX CONCURRENTLY
oauth2_consents_client_fk
ON oauth2_consents (oauth2_client_id);

View File

@@ -0,0 +1,9 @@
-- no-transaction
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
CREATE INDEX CONCURRENTLY
oauth2_consents_user_fk
ON oauth2_consents (user_id);

View File

@@ -0,0 +1,9 @@
-- no-transaction
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
CREATE INDEX CONCURRENTLY
oauth2_device_code_grants_client_fk
ON oauth2_device_code_grant (oauth2_client_id);

View File

@@ -0,0 +1,9 @@
-- no-transaction
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
CREATE INDEX CONCURRENTLY
oauth2_device_code_grants_session_fk
ON oauth2_device_code_grant (oauth2_session_id);

View File

@@ -0,0 +1,9 @@
-- no-transaction
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
CREATE INDEX CONCURRENTLY
oauth2_device_code_grants_user_session_fk
ON oauth2_device_code_grant (user_session_id);

View File

@@ -0,0 +1,9 @@
-- no-transaction
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
CREATE INDEX CONCURRENTLY
oauth2_refresh_tokens_session_fk
ON oauth2_refresh_tokens (oauth2_session_id);

View File

@@ -0,0 +1,9 @@
-- no-transaction
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
CREATE INDEX CONCURRENTLY
oauth2_refresh_tokens_access_token_fk
ON oauth2_refresh_tokens (oauth2_access_token_id);

View File

@@ -0,0 +1,9 @@
-- no-transaction
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
CREATE INDEX CONCURRENTLY
oauth2_refresh_tokens_next_refresh_token_fk
ON oauth2_refresh_tokens (next_oauth2_refresh_token_id);

View File

@@ -0,0 +1,9 @@
-- no-transaction
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
CREATE INDEX CONCURRENTLY
oauth2_sessions_user_session_fk
ON oauth2_sessions (user_session_id);

View File

@@ -0,0 +1,9 @@
-- no-transaction
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
CREATE INDEX CONCURRENTLY
oauth2_sessions_client_fk
ON oauth2_sessions (oauth2_client_id);

View File

@@ -0,0 +1,13 @@
-- no-transaction
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
-- Including the `last_active_at` column lets us effeciently filter in-memory
-- for those sessions without fetching the rows, and without including it in the
-- index btree
CREATE INDEX CONCURRENTLY
oauth2_sessions_user_fk
ON oauth2_sessions (user_id)
INCLUDE (last_active_at);

View File

@@ -0,0 +1,8 @@
-- no-transaction
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
-- Redundant with the `oauth2_sessions_user_fk`
DROP INDEX IF EXISTS oauth2_sessions_user_id_last_active_at;

View File

@@ -0,0 +1,9 @@
-- no-transaction
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
CREATE INDEX CONCURRENTLY
queue_jobs_started_by_fk
ON queue_jobs (started_by);

View File

@@ -0,0 +1,9 @@
-- no-transaction
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
CREATE INDEX CONCURRENTLY
queue_jobs_next_attempt_fk
ON queue_jobs (next_attempt_id);

View File

@@ -0,0 +1,9 @@
-- no-transaction
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
CREATE INDEX CONCURRENTLY
queue_jobs_schedule_name_fk
ON queue_jobs (schedule_name);

View File

@@ -0,0 +1,9 @@
-- no-transaction
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
CREATE INDEX CONCURRENTLY
upstream_oauth_authorization_sessions_provider_fk
ON upstream_oauth_authorization_sessions (upstream_oauth_provider_id);

View File

@@ -0,0 +1,9 @@
-- no-transaction
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
CREATE INDEX CONCURRENTLY
upstream_oauth_authorization_sessions_link_fk
ON upstream_oauth_authorization_sessions (upstream_oauth_link_id);

View File

@@ -0,0 +1,9 @@
-- no-transaction
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
CREATE INDEX CONCURRENTLY
upstream_oauth_links_provider_fk
ON upstream_oauth_links (upstream_oauth_provider_id);

View File

@@ -0,0 +1,9 @@
-- no-transaction
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
CREATE INDEX CONCURRENTLY
upstream_oauth_links_user_fk
ON upstream_oauth_links (user_id);

View File

@@ -0,0 +1,9 @@
-- no-transaction
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
CREATE INDEX CONCURRENTLY
user_email_authentication_codes_authentication_fk
ON user_email_authentication_codes (user_email_authentication_id);

View File

@@ -0,0 +1,9 @@
-- no-transaction
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
CREATE INDEX CONCURRENTLY
user_email_authentications_user_session_fk
ON user_email_authentications (user_session_id);

View File

@@ -0,0 +1,9 @@
-- no-transaction
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
CREATE INDEX CONCURRENTLY
user_email_authentications_user_registration_fk
ON user_email_authentications (user_registration_id);

View File

@@ -0,0 +1,9 @@
-- no-transaction
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
CREATE INDEX CONCURRENTLY
user_emails_user_fk
ON user_emails (user_id);

View File

@@ -0,0 +1,10 @@
-- no-transaction
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
-- This isn't a foreign key, but we really need that to be indexed
CREATE INDEX CONCURRENTLY
user_emails_email_idx
ON user_emails (email);

View File

@@ -0,0 +1,9 @@
-- no-transaction
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
CREATE INDEX CONCURRENTLY
user_passwords_user_fk
ON user_passwords (user_id);

View File

@@ -0,0 +1,9 @@
-- no-transaction
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
CREATE INDEX CONCURRENTLY
user_recovery_tickets_session_fk
ON user_recovery_tickets (user_recovery_session_id);

View File

@@ -0,0 +1,9 @@
-- no-transaction
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
CREATE INDEX CONCURRENTLY
user_recovery_tickets_user_email_fk
ON user_recovery_tickets (user_email_id);

View File

@@ -0,0 +1,9 @@
-- no-transaction
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
CREATE INDEX CONCURRENTLY
user_registrations_email_authentication_fk
ON user_registrations (email_authentication_id);

View File

@@ -0,0 +1,9 @@
-- no-transaction
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
CREATE INDEX CONCURRENTLY
user_session_authentications_user_session_fk
ON user_session_authentications (user_session_id);

View File

@@ -0,0 +1,9 @@
-- no-transaction
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
CREATE INDEX CONCURRENTLY
user_session_authentications_user_password_fk
ON user_session_authentications (user_password_id);

View File

@@ -0,0 +1,9 @@
-- no-transaction
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
CREATE INDEX CONCURRENTLY
user_session_authentications_upstream_oauth_session_fk
ON user_session_authentications (upstream_oauth_authorization_session_id);

View File

@@ -0,0 +1,13 @@
-- no-transaction
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
-- Including the `last_active_at` column lets us effeciently filter in-memory
-- for those sessions without fetching the rows, and without including it in the
-- index btree
CREATE INDEX CONCURRENTLY
user_sessions_user_fk
ON user_sessions (user_id)
INCLUDE (last_active_at);

View File

@@ -0,0 +1,8 @@
-- no-transaction
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
-- Redundant with the `user_sessions_user_fk`
DROP INDEX IF EXISTS user_sessions_user_id_last_active_at;

View File

@@ -0,0 +1,9 @@
-- no-transaction
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
CREATE INDEX CONCURRENTLY
user_terms_user_fk
ON user_terms (user_id);

View File

@@ -0,0 +1,11 @@
-- no-transaction
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
-- We don't use this column anymore, but… it will still tank the performance on
-- deletions of user_emails if we don't have it
CREATE INDEX CONCURRENTLY
users_primary_email_fk
ON users (primary_user_email_id);

View File

@@ -0,0 +1,10 @@
-- no-transaction
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
-- This isn't a foreign key, but we really need that to be indexed
CREATE INDEX CONCURRENTLY
user_recovery_tickets_ticket_idx
ON user_recovery_tickets (ticket);

View File

@@ -0,0 +1,10 @@
-- no-transaction
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
-- Create an index on the username column, lower-cased, so that we can lookup
-- usernames in a case-insensitive manner.
CREATE INDEX CONCURRENTLY users_lower_username_idx
ON users (LOWER(username));

View File

@@ -0,0 +1,9 @@
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
-- We stopped reading/writing to this column, but it's not nullable.
-- So we need to add a default value, and drop it in the next release
ALTER TABLE oauth2_authorization_grants
ALTER COLUMN requires_consent SET DEFAULT false;

View File

@@ -4,8 +4,6 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
use std::num::NonZeroU32;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use mas_data_model::{
@@ -48,13 +46,11 @@ struct GrantLookup {
nonce: Option<String>,
redirect_uri: String,
response_mode: String,
max_age: Option<i32>,
response_type_code: bool,
response_type_id_token: bool,
authorization_code: Option<String>,
code_challenge: Option<String>,
code_challenge_method: Option<String>,
requires_consent: bool,
login_hint: Option<String>,
oauth2_client_id: Uuid,
oauth2_session_id: Option<Uuid>,
@@ -153,25 +149,6 @@ impl TryFrom<GrantLookup> for AuthorizationGrant {
.source(e)
})?;
let max_age = value
.max_age
.map(u32::try_from)
.transpose()
.map_err(|e| {
DatabaseInconsistencyError::on("oauth2_authorization_grants")
.column("max_age")
.row(id)
.source(e)
})?
.map(NonZeroU32::try_from)
.transpose()
.map_err(|e| {
DatabaseInconsistencyError::on("oauth2_authorization_grants")
.column("max_age")
.row(id)
.source(e)
})?;
Ok(AuthorizationGrant {
id,
stage,
@@ -180,12 +157,10 @@ impl TryFrom<GrantLookup> for AuthorizationGrant {
scope,
state: value.state,
nonce: value.nonce,
max_age,
response_mode,
redirect_uri,
created_at: value.created_at,
response_type_id_token: value.response_type_id_token,
requires_consent: value.requires_consent,
login_hint: value.login_hint,
})
}
@@ -216,10 +191,8 @@ impl OAuth2AuthorizationGrantRepository for PgOAuth2AuthorizationGrantRepository
code: Option<AuthorizationCode>,
state: Option<String>,
nonce: Option<String>,
max_age: Option<NonZeroU32>,
response_mode: ResponseMode,
response_type_id_token: bool,
requires_consent: bool,
login_hint: Option<String>,
) -> Result<AuthorizationGrant, Self::Error> {
let code_challenge = code
@@ -230,8 +203,6 @@ impl OAuth2AuthorizationGrantRepository for PgOAuth2AuthorizationGrantRepository
.as_ref()
.and_then(|c| c.pkce.as_ref())
.map(|p| p.challenge_method.to_string());
// TODO: this conversion is a bit ugly
let max_age_i32 = max_age.map(|x| i32::try_from(u32::from(x)).unwrap_or(i32::MAX));
let code_str = code.as_ref().map(|c| &c.code);
let created_at = clock.now();
@@ -247,19 +218,17 @@ impl OAuth2AuthorizationGrantRepository for PgOAuth2AuthorizationGrantRepository
scope,
state,
nonce,
max_age,
response_mode,
code_challenge,
code_challenge_method,
response_type_code,
response_type_id_token,
authorization_code,
requires_consent,
login_hint,
created_at
)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
"#,
Uuid::from(id),
Uuid::from(client.id),
@@ -267,14 +236,12 @@ impl OAuth2AuthorizationGrantRepository for PgOAuth2AuthorizationGrantRepository
scope.to_string(),
state,
nonce,
max_age_i32,
response_mode.to_string(),
code_challenge,
code_challenge_method,
code.is_some(),
response_type_id_token,
code_str,
requires_consent,
login_hint,
created_at,
)
@@ -291,11 +258,9 @@ impl OAuth2AuthorizationGrantRepository for PgOAuth2AuthorizationGrantRepository
scope,
state,
nonce,
max_age,
response_mode,
created_at,
response_type_id_token,
requires_consent,
login_hint,
})
}
@@ -323,14 +288,12 @@ impl OAuth2AuthorizationGrantRepository for PgOAuth2AuthorizationGrantRepository
, redirect_uri
, response_mode
, nonce
, max_age
, oauth2_client_id
, authorization_code
, response_type_code
, response_type_id_token
, code_challenge
, code_challenge_method
, requires_consent
, login_hint
, oauth2_session_id
FROM
@@ -374,14 +337,12 @@ impl OAuth2AuthorizationGrantRepository for PgOAuth2AuthorizationGrantRepository
, redirect_uri
, response_mode
, nonce
, max_age
, oauth2_client_id
, authorization_code
, response_type_code
, response_type_id_token
, code_challenge
, code_challenge_method
, requires_consent
, login_hint
, oauth2_session_id
FROM
@@ -480,37 +441,4 @@ impl OAuth2AuthorizationGrantRepository for PgOAuth2AuthorizationGrantRepository
Ok(grant)
}
#[tracing::instrument(
name = "db.oauth2_authorization_grant.give_consent",
skip_all,
fields(
db.query.text,
%grant.id,
client.id = %grant.client_id,
),
err,
)]
async fn give_consent(
&mut self,
mut grant: AuthorizationGrant,
) -> Result<AuthorizationGrant, Self::Error> {
sqlx::query!(
r#"
UPDATE oauth2_authorization_grants AS og
SET
requires_consent = 'f'
WHERE
og.oauth2_authorization_grant_id = $1
"#,
Uuid::from(grant.id),
)
.traced()
.execute(&mut *self.conn)
.await?;
grant.requires_consent = false;
Ok(grant)
}
}

View File

@@ -6,20 +6,15 @@
use std::{
collections::{BTreeMap, BTreeSet},
str::FromStr,
string::ToString,
};
use async_trait::async_trait;
use mas_data_model::{Client, JwksOrJwksUri, User};
use mas_data_model::{Client, JwksOrJwksUri};
use mas_iana::{jose::JsonWebSignatureAlg, oauth::OAuthClientAuthenticationMethod};
use mas_jose::jwk::PublicJsonWebKeySet;
use mas_storage::{Clock, oauth2::OAuth2ClientRepository};
use oauth2_types::{
oidc::ApplicationType,
requests::GrantType,
scope::{Scope, ScopeToken},
};
use oauth2_types::{oidc::ApplicationType, requests::GrantType};
use opentelemetry_semantic_conventions::attribute::DB_QUERY_TEXT;
use rand::RngCore;
use sqlx::PgConnection;
@@ -698,97 +693,6 @@ impl OAuth2ClientRepository for PgOAuth2ClientRepository<'_> {
.collect()
}
#[tracing::instrument(
name = "db.oauth2_client.get_consent_for_user",
skip_all,
fields(
db.query.text,
%user.id,
%client.id,
),
err,
)]
async fn get_consent_for_user(
&mut self,
client: &Client,
user: &User,
) -> Result<Scope, Self::Error> {
let scope_tokens: Vec<String> = sqlx::query_scalar!(
r#"
SELECT scope_token
FROM oauth2_consents
WHERE user_id = $1 AND oauth2_client_id = $2
"#,
Uuid::from(user.id),
Uuid::from(client.id),
)
.fetch_all(&mut *self.conn)
.await?;
let scope: Result<Scope, _> = scope_tokens
.into_iter()
.map(|s| ScopeToken::from_str(&s))
.collect();
let scope = scope.map_err(|e| {
DatabaseInconsistencyError::on("oauth2_consents")
.column("scope_token")
.source(e)
})?;
Ok(scope)
}
#[tracing::instrument(
name = "db.oauth2_client.give_consent_for_user",
skip_all,
fields(
db.query.text,
%user.id,
%client.id,
%scope,
),
err,
)]
async fn give_consent_for_user(
&mut self,
rng: &mut (dyn RngCore + Send),
clock: &dyn Clock,
client: &Client,
user: &User,
scope: &Scope,
) -> Result<(), Self::Error> {
let now = clock.now();
let (tokens, ids): (Vec<String>, Vec<Uuid>) = scope
.iter()
.map(|token| {
(
token.to_string(),
Uuid::from(Ulid::from_datetime_with_source(now.into(), rng)),
)
})
.unzip();
sqlx::query!(
r#"
INSERT INTO oauth2_consents
(oauth2_consent_id, user_id, oauth2_client_id, scope_token, created_at)
SELECT id, $2, $3, scope_token, $5 FROM UNNEST($1::uuid[], $4::text[]) u(id, scope_token)
ON CONFLICT (user_id, oauth2_client_id, scope_token) DO UPDATE SET refreshed_at = $5
"#,
&ids,
Uuid::from(user.id),
Uuid::from(client.id),
&tokens,
now,
)
.traced()
.execute(&mut *self.conn)
.await?;
Ok(())
}
#[tracing::instrument(
name = "db.oauth2_client.delete_by_id",
skip_all,

View File

@@ -135,10 +135,8 @@ mod tests {
}),
Some("state".to_owned()),
Some("nonce".to_owned()),
None,
ResponseMode::Query,
true,
false,
None,
)
.await
@@ -175,29 +173,6 @@ mod tests {
.await
.unwrap();
// Lookup the consent the user gave to the client
let consent = repo
.oauth2_client()
.get_consent_for_user(&client, &user)
.await
.unwrap();
assert!(consent.is_empty());
// Give consent to the client
let scope = Scope::from_iter([OPENID]);
repo.oauth2_client()
.give_consent_for_user(&mut rng, &clock, &client, &user, &scope)
.await
.unwrap();
// Lookup the consent the user gave to the client
let consent = repo
.oauth2_client()
.get_consent_for_user(&client, &user)
.await
.unwrap();
assert_eq!(scope, consent);
// Lookup a non-existing session
let session = repo.oauth2_session().lookup(Ulid::nil()).await.unwrap();
assert_eq!(session, None);

View File

@@ -165,6 +165,9 @@ impl UserRepository for PgUserRepository<'_> {
err,
)]
async fn find_by_username(&mut self, username: &str) -> Result<Option<User>, Self::Error> {
// We may have multiple users with the same username, but with a different
// casing. In this case, we want to return the one which matches the exact
// casing
let res = sqlx::query_as!(
UserLookup,
r#"
@@ -175,17 +178,30 @@ impl UserRepository for PgUserRepository<'_> {
, deactivated_at
, can_request_admin
FROM users
WHERE username = $1
WHERE LOWER(username) = LOWER($1)
"#,
username,
)
.traced()
.fetch_optional(&mut *self.conn)
.fetch_all(&mut *self.conn)
.await?;
let Some(res) = res else { return Ok(None) };
Ok(Some(res.into()))
match &res[..] {
// Happy path: there is only one user matching the username…
[user] => Ok(Some(user.clone().into())),
// …or none.
[] => Ok(None),
list => {
// If there are multiple users with the same username, we want to
// return the one which matches the exact casing
if let Some(user) = list.iter().find(|user| user.username == username) {
Ok(Some(user.clone().into()))
} else {
// If none match exactly, we prefer to return nothing
Ok(None)
}
}
}
}
#[tracing::instrument(
@@ -250,7 +266,7 @@ impl UserRepository for PgUserRepository<'_> {
let exists = sqlx::query_scalar!(
r#"
SELECT EXISTS(
SELECT 1 FROM users WHERE username = $1
SELECT 1 FROM users WHERE LOWER(username) = LOWER($1)
) AS "exists!"
"#,
username

View File

@@ -216,6 +216,50 @@ async fn test_user_repo(pool: PgPool) {
repo.save().await.unwrap();
}
/// Test [`UserRepository::find_by_username`] with different casings.
#[sqlx::test(migrator = "crate::MIGRATOR")]
async fn test_user_repo_find_by_username(pool: PgPool) {
let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed();
let mut rng = ChaChaRng::seed_from_u64(42);
let clock = MockClock::default();
let alice = repo
.user()
.add(&mut rng, &clock, "Alice".to_owned())
.await
.unwrap();
let bob1 = repo
.user()
.add(&mut rng, &clock, "Bob".to_owned())
.await
.unwrap();
let bob2 = repo
.user()
.add(&mut rng, &clock, "BOB".to_owned())
.await
.unwrap();
// This is fine, we can do a case-insensitive search
assert_eq!(
repo.user().find_by_username("alice").await.unwrap(),
Some(alice)
);
// In case there are multiple users with the same username, we should return the
// one that matches the exact casing
assert_eq!(
repo.user().find_by_username("Bob").await.unwrap(),
Some(bob1)
);
assert_eq!(
repo.user().find_by_username("BOB").await.unwrap(),
Some(bob2)
);
// If none match, we should return None
assert!(repo.user().find_by_username("bob").await.unwrap().is_none());
}
/// Test the user email repository, by trying out most of its methods
#[sqlx::test(migrator = "crate::MIGRATOR")]
async fn test_user_email_repo(pool: PgPool) {

View File

@@ -4,8 +4,6 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
use std::num::NonZeroU32;
use async_trait::async_trait;
use mas_data_model::{AuthorizationCode, AuthorizationGrant, Client, Session};
use oauth2_types::{requests::ResponseMode, scope::Scope};
@@ -37,12 +35,9 @@ pub trait OAuth2AuthorizationGrantRepository: Send + Sync {
/// `response_type` was requested
/// * `state`: The state the client sent, if set
/// * `nonce`: The nonce the client sent, if set
/// * `max_age`: The maximum age since the user last authenticated, if asked
/// by the client
/// * `response_mode`: The response mode the client requested
/// * `response_type_id_token`: Whether the `id_token` `response_type` was
/// requested
/// * `requires_consent`: Whether the client explicitly requested consent
/// * `login_hint`: The login_hint the client sent, if set
///
/// # Errors
@@ -59,10 +54,8 @@ pub trait OAuth2AuthorizationGrantRepository: Send + Sync {
code: Option<AuthorizationCode>,
state: Option<String>,
nonce: Option<String>,
max_age: Option<NonZeroU32>,
response_mode: ResponseMode,
response_type_id_token: bool,
requires_consent: bool,
login_hint: Option<String>,
) -> Result<AuthorizationGrant, Self::Error>;
@@ -131,22 +124,6 @@ pub trait OAuth2AuthorizationGrantRepository: Send + Sync {
clock: &dyn Clock,
authorization_grant: AuthorizationGrant,
) -> Result<AuthorizationGrant, Self::Error>;
/// Unset the `requires_consent` flag on an authorization grant
///
/// Returns the updated authorization grant
///
/// # Parameters
///
/// * `authorization_grant`: The authorization grant to update
///
/// # Errors
///
/// Returns [`Self::Error`] if the underlying repository fails
async fn give_consent(
&mut self,
authorization_grant: AuthorizationGrant,
) -> Result<AuthorizationGrant, Self::Error>;
}
repository_impl!(OAuth2AuthorizationGrantRepository:
@@ -160,10 +137,8 @@ repository_impl!(OAuth2AuthorizationGrantRepository:
code: Option<AuthorizationCode>,
state: Option<String>,
nonce: Option<String>,
max_age: Option<NonZeroU32>,
response_mode: ResponseMode,
response_type_id_token: bool,
requires_consent: bool,
login_hint: Option<String>,
) -> Result<AuthorizationGrant, Self::Error>;
@@ -184,9 +159,4 @@ repository_impl!(OAuth2AuthorizationGrantRepository:
clock: &dyn Clock,
authorization_grant: AuthorizationGrant,
) -> Result<AuthorizationGrant, Self::Error>;
async fn give_consent(
&mut self,
authorization_grant: AuthorizationGrant,
) -> Result<AuthorizationGrant, Self::Error>;
);

View File

@@ -7,10 +7,10 @@
use std::collections::{BTreeMap, BTreeSet};
use async_trait::async_trait;
use mas_data_model::{Client, User};
use mas_data_model::Client;
use mas_iana::{jose::JsonWebSignatureAlg, oauth::OAuthClientAuthenticationMethod};
use mas_jose::jwk::PublicJsonWebKeySet;
use oauth2_types::{oidc::ApplicationType, requests::GrantType, scope::Scope};
use oauth2_types::{oidc::ApplicationType, requests::GrantType};
use rand_core::RngCore;
use ulid::Ulid;
use url::Url;
@@ -171,45 +171,6 @@ pub trait OAuth2ClientRepository: Send + Sync {
/// Returns [`Self::Error`] if the underlying repository fails
async fn all_static(&mut self) -> Result<Vec<Client>, Self::Error>;
/// Get the list of scopes that the user has given consent for the given
/// client
///
/// # Parameters
///
/// * `client`: The client to get the consent for
/// * `user`: The user to get the consent for
///
/// # Errors
///
/// Returns [`Self::Error`] if the underlying repository fails
async fn get_consent_for_user(
&mut self,
client: &Client,
user: &User,
) -> Result<Scope, Self::Error>;
/// Give consent for a set of scopes for the given client and user
///
/// # Parameters
///
/// * `rng`: The random number generator to use
/// * `clock`: The clock used to generate timestamps
/// * `client`: The client to give the consent for
/// * `user`: The user to give the consent for
/// * `scope`: The scope to give consent for
///
/// # Errors
///
/// Returns [`Self::Error`] if the underlying repository fails
async fn give_consent_for_user(
&mut self,
rng: &mut (dyn RngCore + Send),
clock: &dyn Clock,
client: &Client,
user: &User,
scope: &Scope,
) -> Result<(), Self::Error>;
/// Delete a client
///
/// # Parameters
@@ -288,19 +249,4 @@ repository_impl!(OAuth2ClientRepository:
async fn delete(&mut self, client: Client) -> Result<(), Self::Error>;
async fn delete_by_id(&mut self, id: Ulid) -> Result<(), Self::Error>;
async fn get_consent_for_user(
&mut self,
client: &Client,
user: &User,
) -> Result<Scope, Self::Error>;
async fn give_consent_for_user(
&mut self,
rng: &mut (dyn RngCore + Send),
clock: &dyn Clock,
client: &Client,
user: &User,
scope: &Scope,
) -> Result<(), Self::Error>;
);

View File

@@ -155,7 +155,7 @@ pub trait UserRepository: Send + Sync {
/// Returns [`Self::Error`] if the underlying repository fails
async fn lookup(&mut self, id: Ulid) -> Result<Option<User>, Self::Error>;
/// Find a [`User`] by its username
/// Find a [`User`] by its username, in a case-insensitive manner
///
/// Returns `None` if no [`User`] was found
///

View File

@@ -381,7 +381,7 @@ impl FormField for LoginFormField {
}
}
/// Inner context used in login and reauth screens. See [`PostAuthContext`].
/// Inner context used in login screen. See [`PostAuthContext`].
#[derive(Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum PostAuthContextInner {
@@ -420,7 +420,7 @@ pub enum PostAuthContextInner {
ManageAccount,
}
/// Context used in login and reauth screens, for the post-auth action to do
/// Context used in login screen, for the post-auth action to do
#[derive(Serialize)]
pub struct PostAuthContext {
/// The post auth action params from the URL
@@ -734,59 +734,6 @@ impl PolicyViolationContext {
}
}
/// Fields of the reauthentication form
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum ReauthFormField {
/// The password field
Password,
}
impl FormField for ReauthFormField {
fn keep(&self) -> bool {
match self {
Self::Password => false,
}
}
}
/// Context used by the `reauth.html` template
#[derive(Serialize, Default)]
pub struct ReauthContext {
form: FormState<ReauthFormField>,
next: Option<PostAuthContext>,
}
impl TemplateContext for ReauthContext {
fn sample(_now: chrono::DateTime<Utc>, _rng: &mut impl Rng) -> Vec<Self>
where
Self: Sized,
{
// TODO: samples with errors
vec![ReauthContext {
form: FormState::default(),
next: None,
}]
}
}
impl ReauthContext {
/// Add an error on the reauthentication form
#[must_use]
pub fn with_form_state(self, form: FormState<ReauthFormField>) -> Self {
Self { form, ..self }
}
/// Add a post authentication action to the context
#[must_use]
pub fn with_post_action(self, next: PostAuthContext) -> Self {
Self {
next: Some(next),
..self
}
}
}
/// Context used by the `sso.html` template
#[derive(Serialize)]
pub struct CompatSsoContext {

View File

@@ -38,10 +38,10 @@ pub use self::{
DeviceConsentContext, DeviceLinkContext, DeviceLinkFormField, EmailRecoveryContext,
EmailVerificationContext, EmptyContext, ErrorContext, FormPostContext, IndexContext,
LoginContext, LoginFormField, NotFoundContext, PasswordRegisterContext,
PolicyViolationContext, PostAuthContext, PostAuthContextInner, ReauthContext,
ReauthFormField, RecoveryExpiredContext, RecoveryFinishContext, RecoveryFinishFormField,
RecoveryProgressContext, RecoveryStartContext, RecoveryStartFormField, RegisterContext,
RegisterFormField, RegisterStepsDisplayNameContext, RegisterStepsDisplayNameFormField,
PolicyViolationContext, PostAuthContext, PostAuthContextInner, RecoveryExpiredContext,
RecoveryFinishContext, RecoveryFinishFormField, RecoveryProgressContext,
RecoveryStartContext, RecoveryStartFormField, RegisterContext, RegisterFormField,
RegisterStepsDisplayNameContext, RegisterStepsDisplayNameFormField,
RegisterStepsEmailInUseContext, RegisterStepsVerifyEmailContext,
RegisterStepsVerifyEmailFormField, SiteBranding, SiteConfigExt, SiteFeatures,
TemplateContext, UpstreamExistingLinkContext, UpstreamRegister, UpstreamRegisterFormField,
@@ -372,9 +372,6 @@ register_templates! {
/// Render the account recovery disabled page
pub fn render_recovery_disabled(WithLanguage<EmptyContext>) { "pages/recovery/disabled.html" }
/// Render the re-authentication form
pub fn render_reauth(WithLanguage<WithCsrf<WithSession<ReauthContext>>>) { "pages/reauth.html" }
/// Render the form used by the form_post response mode
pub fn render_form_post<T: Serialize>(WithLanguage<FormPostContext<T>>) { "form_post.html" }
@@ -456,7 +453,6 @@ impl Templates {
check::render_recovery_expired(self, now, rng)?;
check::render_recovery_consumed(self, now, rng)?;
check::render_recovery_disabled(self, now, rng)?;
check::render_reauth(self, now, rng)?;
check::render_form_post::<EmptyContext>(self, now, rng)?;
check::render_error(self, now, rng)?;
check::render_email_verification_txt(self, now, rng)?;

View File

@@ -8,7 +8,7 @@
* - Please do NOT serve this file on production.
*/
const PACKAGE_VERSION = '2.7.3'
const PACKAGE_VERSION = '2.7.4'
const INTEGRITY_CHECKSUM = '00729d72e3b82faf54ca8b9621dbb96f'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()

View File

@@ -87,8 +87,8 @@
"title": "Vigane e-posti aadress"
},
"email_invalid_error": "Lisatud e-posti aadress on vigane",
"incorrect_password_error": "Vale parool, proovige uuesti",
"password_confirmation": "Selle e-posti aadressi lisamiseks kinnitage oma konto salasõnaga"
"incorrect_password_error": "Vale salasõna, palun proovi uuesti",
"password_confirmation": "Selle e-posti aadressi lisamiseks kinnita tegevus oma kasutajakonto salasõnaga"
},
"app_sessions_list": {
"error": "Rakenduse sessioonide laadimine ei õnnestunud",
@@ -327,8 +327,8 @@
"delete_button_confirmation_modal": {
"action": "Kustuta e-posti aadress",
"body": "Kas kustutame selle e-posti aadressi?",
"incorrect_password": "Vale parool, proovige uuesti",
"password_confirmation": "Selle e-posti aadressi kustutamiseks kinnitage parooliga"
"incorrect_password": "Vale salasõna, palun proovi uuesti",
"password_confirmation": "Selle e-posti aadressi kustutamiseks kinnita tegevus oma salasõnaga"
},
"delete_button_title": "Eemalda e-posti aadress",
"email": "E-posti aadress",

View File

@@ -16,12 +16,12 @@
},
"branding": {
"privacy_policy": {
"alt": "Посилання на політику конфіденційності сервісу",
"link": "Політика конфіденційності"
"alt": "Посилання на політику приватності служби",
"link": "Політика приватності"
},
"terms_and_conditions": {
"alt": "Посилання на умови надання послуг",
"link": "Умови використання"
"link": "Умови та положення"
}
},
"common": {
@@ -43,7 +43,7 @@
"alert_description": "Цей обліковий запис буде стерто назавжди, і ви більше не матимете доступу до всіх своїх повідомлень.",
"alert_title": "Ви можете втратити всі свої дані",
"button": "Видалити обліковий запис",
"dialog_description": "<text>Підтвердьте, що хочете видалити свій обліковий запис:</text>\n<profile />\n<list>\n<item>Ви не зможете повторно активувати свій обліковий запис</item>\n<item>Ви більше не зможете ввійти </item>\n<item>Ніхто не зможе повторно використовувати ваше ім'я користувача (MXID), включно з вами</item>\n<item>Ви вийдете з усіх кімнат та особистих розмов, в яких ви перебуваєте</item>\n<item>Вас буде вилучено з сервера ідентифікації, і ніхто не зможе знайти вас за вашою електронною поштою або номером телефону</item>\n</list>\n<text>Ваші старі повідомлення все одно будуть видимі людям, які їх отримали. Чи хотіли б ви сховати свої надіслані повідомлення від людей, які приєднуються до кімнат у майбутньому?</text>",
"dialog_description": "<text>Підтвердьте, що хочете видалити свій обліковий запис:</text>\n<profile />\n<list>\n<item>Ви не зможете повторно активувати свій обліковий запис</item>\n<item>Ви більше не зможете ввійти </item>\n<item>Ніхто не зможе повторно використовувати ваше ім'я користувача (MXID), включно з вами</item>\n<item>Ви вийдете з усіх кімнат та особистих розмов, в яких ви перебуваєте</item>\n<item>Вас буде вилучено з сервера ідентифікації, і ніхто не зможе знайти вас за вашою електронною поштою або номером телефону</item>\n</list>\n<text>Ваші старі повідомлення все одно будуть видимі людям, які їх отримали. Чи хотіли б ви сховати свої надіслані повідомлення від людей, які приєднаються до кімнат у майбутньому?</text>",
"dialog_title": "Видалити цей обліковий запис?",
"erase_checkbox_label": "Так, сховати всі мої повідомлення від нових учасників",
"incorrect_password": "Пароль неправильний, повторіть спробу",
@@ -132,7 +132,7 @@
"error": {
"hideDetails": "Сховати подробиці",
"showDetails": "Показати подробиці",
"subtitle": "Сталася неочікувана помилка. Будь ласка спробуйте ще раз.",
"subtitle": "Сталася неочікувана помилка. Повторіть спробу.",
"title": "Щось пішло не так"
},
"error_boundary_title": "Щось пішло не так",
@@ -155,7 +155,7 @@
"not_logged_in_alert": "Ви не ввійшли в систему.",
"oauth2_client_detail": {
"details_title": "Інформація про клієнт",
"id": "Ідентифікатор клієнта",
"id": "ID клієнта",
"name": "Ім'я",
"policy": "Політика",
"terms": "Умови надання послуг"
@@ -173,8 +173,8 @@
"failure": {
"description": {
"account_locked": "Ваш обліковий запис заблокований і не може бути відновлений на цей час. Якщо цього не очікується, зверніться до адміністратора сервера.",
"expired_recovery_ticket": "Термін дії посилання для відновлення закінчився. Будь ласка, почніть процес відновлення облікового запису спочатку.",
"invalid_new_password": "Вибраний вами новий пароль недійсний; він може не відповідати налаштованій політиці безпеки.",
"expired_recovery_ticket": "Посилання для відновлення застаріло. Розпочніть процес відновлення облікового запису спочатку.",
"invalid_new_password": "Обраний вами новий пароль неприпустимий; він може не відповідати налаштованій політиці безпеки.",
"no_current_password": "У вас немає поточного пароля.",
"no_such_recovery_ticket": "Посилання для відновлення недійсне. Якщо ви скопіювали посилання з електронної пошти для відновлення, перевірте, чи скопійовано повне посилання.",
"password_changes_disabled": "Зміна пароля вимкнена.",
@@ -203,7 +203,7 @@
"expired": {
"resend_email": "Повторно надіслати електронний лист",
"subtitle": "Запит на новий електронний лист, який буде надіслано на адресу: {{email}}",
"title": "Термін дії посилання для скидання пароля закінчився"
"title": "Посилання для скидання пароля застаріло"
},
"subtitle": "Виберіть новий пароль для свого облікового запису.",
"title": "Скидання пароля"
@@ -218,15 +218,15 @@
"4": "Дуже надійний пароль"
},
"suggestion": {
"all_uppercase": "Використайте великі літери, але не всі.",
"another_word": "Додайте більше слів, які є менш поширеними.",
"all_uppercase": "Використайте великі букви, але не всі.",
"another_word": "Додайте більше менш вживаних слів.",
"associated_years": "Уникайте років, які пов'язані з вами.",
"capitalization": "Використайте більше великих літер, не тільки першу.",
"capitalization": "Використайте більше великих букв, не лише першу.",
"dates": "Уникайте дат і років, які пов'язані з вами.",
"l33t": "Уникайте передбачуваних замін букв, таких як «@» замість «a».",
"longer_keyboard_pattern": "Використовуйте довші патерни клавіатури та змінюйте напрямок друку кілька разів.",
"no_need": "Ви можете створювати надійні паролі без використання символів, цифр або великих літер.",
"pwned": "Якщо ви використовуєте цей пароль деінде, вам слід змінити його.",
"no_need": "Ви можете створювати надійні паролі не вживаючи символів, цифр або великих букв.",
"pwned": "Якщо ви використовуєте цей пароль ще десь, вам слід змінити його.",
"recent_years": "Уникайте останніх років.",
"repeated": "Уникайте повторювання слів і символів.",
"reverse_words": "Уникайте зворотного написання звичайних слів.",
@@ -241,7 +241,7 @@
"extended_repeat": "Повторювані шаблони символів, такі як \"abcabcabc\", легко вгадати.",
"key_pattern": "Короткі послідовності клавіш легко вгадати.",
"names_by_themselves": "Поодинокі імена або прізвища легко вгадати.",
"pwned": "Ваш пароль було розкрито внаслідок витоку даних в Інтернеті.",
"pwned": "Ваш пароль розкрито внаслідок витоку даних в інтернеті.",
"recent_years": "Пароль із нещодавніми роками легко вгадати.",
"sequences": "Поширені послідовності символів, такі як «abc», легко вгадати.",
"similar_to_common": "Це схоже на часто використовуваний пароль.",
@@ -249,7 +249,7 @@
"straight_row": "Прямі послідовності клавіш на клавіатурі легко вгадати.",
"top_hundred": "Це часто використовуваний пароль.",
"top_ten": "Це широко використовуваний пароль.",
"user_inputs": "Не повинно бути ніяких особистих даних або даних, пов'язаних зі сторінкою.",
"user_inputs": "Не повинно бути жодних особистих даних або даних, пов'язаних зі сторінкою.",
"word_by_itself": "Окремі слова легко вгадати."
}
},
@@ -263,9 +263,9 @@
"description": "Якщо ви не ввійшли в обліковий запис на інших пристроях і втратили ключ відновлення, вам потрібно буде скинути свою ідентичність, щоб продовжити користуватися застосунком.",
"effect_list": {
"negative_1": "Ви втратите наявну історію повідомлень",
"negative_2": "Вам потрібно буде знову підтвердити всі наявні пристрої та контакти",
"negative_2": "Вам потрібно буде знову верифікувати всі наявні пристрої та контакти",
"neutral_1": "Ви втратите історію повідомлень, яка зберігається лише на сервері",
"neutral_2": "Вам потрібно буде знову підтвердити всі наявні пристрої та контакти",
"neutral_2": "Вам потрібно буде знову верифікувати всі наявні пристрої та контакти",
"positive_1": "Ваші дані облікового запису, контакти, налаштування та список бесід будуть збережені"
},
"failure": {
@@ -277,7 +277,7 @@
"heading": "Скиньте свій обліковий запис, якщо не можете підтвердити його іншим способом",
"start_reset": "Почати скидання",
"success": {
"description": "Скидання профілю було схвалено на наступні {{minutes}} хвилин. Ви можете закрити це вікно та повернутися до застосунку, щоб продовжити.",
"description": "Скидання профілю схвалено на наступні {{minutes}} хвилин. Ви можете закрити це вікно та повернутися до застосунку, щоб продовжити.",
"heading": "Облікові дані успішно скинуто. Поверніться до застосунку, щоб завершити процес.",
"title": "Скидання криптоідентичності тимчасово дозволено"
},
@@ -287,7 +287,7 @@
"label": "Вибрати сеанс"
},
"session": {
"client_id_label": "Ідентифікатор клієнта",
"client_id_label": "ID клієнта",
"current": "Поточний",
"current_badge": "Поточний",
"device_id_label": "ID пристрою",
@@ -306,7 +306,7 @@
"unknown_browser": "Невідомий браузер",
"unknown_device": "Невідомий пристрій",
"uri_label": "Uri",
"user_id_label": "Ідентифікатор користувача",
"user_id_label": "ID користувача",
"username_label": "Ім'я користувача"
},
"session_detail": {
@@ -318,7 +318,7 @@
},
"unknown_route": "Невідомий роут {{route}}",
"unverified_email_alert": {
"button": "Перегляньте та перевірте",
"button": "Переглянути та підтвердити",
"text:one": "У вас є {{count}} непідтверджена адреса електронної пошти.",
"text:few": "У вас є {{count}} непідтверджені адреси електронної пошти.",
"text:many": "У вас є {{count}} непідтверджених адрес електронної пошти.",
@@ -363,7 +363,7 @@
"verify_email": {
"code_expired_alert": {
"description": "Термін дії коду закінчився. Будь ласка, надішліть запит на новий код.",
"title": "Термін дії коду закінчився"
"title": "Код застарів"
},
"code_field_error": "Код не розпізнано",
"code_field_label": "6-значний код",

View File

@@ -12,8 +12,8 @@
"@fontsource/inter": "^5.2.5",
"@radix-ui/react-collapsible": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.6",
"@tanstack/react-query": "^5.72.2",
"@tanstack/react-router": "^1.115.2",
"@tanstack/react-query": "^5.74.3",
"@tanstack/react-router": "^1.116.0",
"@vector-im/compound-design-tokens": "4.0.1",
"@vector-im/compound-web": "^7.10.1",
"@zxcvbn-ts/core": "^3.0.4",
@@ -42,14 +42,14 @@
"@storybook/react": "^8.6.12",
"@storybook/react-vite": "^8.6.12",
"@storybook/test": "^8.5.5",
"@tanstack/react-query-devtools": "^5.72.2",
"@tanstack/react-router-devtools": "^1.115.2",
"@tanstack/router-plugin": "^1.115.2",
"@tanstack/react-query-devtools": "^5.74.3",
"@tanstack/react-router-devtools": "^1.116.0",
"@tanstack/router-plugin": "^1.116.1",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^22.14.0",
"@types/react": "19.1.0",
"@types/node": "^22.14.1",
"@types/react": "19.1.1",
"@types/react-dom": "19.1.2",
"@types/swagger-ui-dist": "^3.30.5",
"@vitejs/plugin-react": "^4.3.4",
@@ -60,7 +60,7 @@
"happy-dom": "^17.4.4",
"i18next-parser": "^9.3.0",
"knip": "^5.50.2",
"msw": "^2.7.3",
"msw": "^2.7.4",
"msw-storybook-addon": "^2.0.4",
"postcss": "^8.5.3",
"postcss-import": "^16.1.0",
@@ -5303,9 +5303,9 @@
}
},
"node_modules/@tanstack/query-core": {
"version": "5.72.2",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.72.2.tgz",
"integrity": "sha512-fxl9/0yk3mD/FwTmVEf1/H6N5B975H0luT+icKyX566w6uJG0x6o+Yl+I38wJRCaogiMkstByt+seXfDbWDAcA==",
"version": "5.74.3",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.74.3.tgz",
"integrity": "sha512-Mqk+5o3qTuAiZML248XpNH8r2cOzl15+LTbUsZQEwvSvn1GU4VQhvqzAbil36p+MBxpr/58oBSnRzhrBevDhfg==",
"license": "MIT",
"funding": {
"type": "github",
@@ -5313,9 +5313,9 @@
}
},
"node_modules/@tanstack/query-devtools": {
"version": "5.72.2",
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.72.2.tgz",
"integrity": "sha512-mMKnGb+iOhVBcj6jaerCFRpg8pACStdG8hmUBHPtToeZzs4ctjBUL1FajqpVn2WaMxnq8Wya+P3Q5tPFNM9jQw==",
"version": "5.73.3",
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.73.3.tgz",
"integrity": "sha512-hBQyYwsOuO7QOprK75NzfrWs/EQYjgFA0yykmcvsV62q0t6Ua97CU3sYgjHx0ZvxkXSOMkY24VRJ5uv9f5Ik4w==",
"dev": true,
"license": "MIT",
"funding": {
@@ -5324,12 +5324,12 @@
}
},
"node_modules/@tanstack/react-query": {
"version": "5.72.2",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.72.2.tgz",
"integrity": "sha512-SVNHzyBUYiis+XiCl+8yiPZmMYei2AKYY94wM/zpvB5l1jxqOo82FQTziSJ4pBi96jtYqvYrTMxWynmbQh3XKw==",
"version": "5.74.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.74.3.tgz",
"integrity": "sha512-QrycUn0wxjVPzITvQvOxFRdhlAwIoOQSuav7qWD4SWCoKCdLbyRZ2vji2GuBq/glaxbF4wBx3fqcYRDOt8KDTA==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.72.2"
"@tanstack/query-core": "5.74.3"
},
"funding": {
"type": "github",
@@ -5340,32 +5340,32 @@
}
},
"node_modules/@tanstack/react-query-devtools": {
"version": "5.72.2",
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.72.2.tgz",
"integrity": "sha512-n53qr9JdHCJTCUba6OvMhwiV2CcsckngOswKEE7nM5pQBa/fW9c43qw8omw1RPT2s+aC7MuwS8fHsWT8g+j6IQ==",
"version": "5.74.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.74.3.tgz",
"integrity": "sha512-H7TsOBB1fRCuuawrBzKMoIszqqILr2IN5oGLYMl7QG7ERJpMdc4hH8OwzBhVxJnmKeGwgtTQgcdKepfoJCWvFg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@tanstack/query-devtools": "5.72.2"
"@tanstack/query-devtools": "5.73.3"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"@tanstack/react-query": "^5.72.2",
"@tanstack/react-query": "^5.74.3",
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-router": {
"version": "1.115.2",
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.115.2.tgz",
"integrity": "sha512-KWRtoDp1odMUUd0m7utTot3NsAxfb/W8UlPG5omtS0TCl8F+ErwurjS6Qn7rKg7q0CF8KcFDvhhBC1cXnOpoSQ==",
"version": "1.116.0",
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.116.0.tgz",
"integrity": "sha512-ZBAg5Q6zJf0mnP9DYPiaaQ/wLDH2ujCMi/2RllpH86VUkdkyvQQzpAyKoiYJ891wh9OPgj6W6tPrzB4qy5FpRA==",
"license": "MIT",
"dependencies": {
"@tanstack/history": "1.115.0",
"@tanstack/react-store": "^0.7.0",
"@tanstack/router-core": "1.115.0",
"@tanstack/router-core": "1.115.3",
"jsesc": "^3.1.0",
"tiny-invariant": "^1.3.3",
"tiny-warning": "^1.0.3"
@@ -5383,13 +5383,13 @@
}
},
"node_modules/@tanstack/react-router-devtools": {
"version": "1.115.2",
"resolved": "https://registry.npmjs.org/@tanstack/react-router-devtools/-/react-router-devtools-1.115.2.tgz",
"integrity": "sha512-g8lK8MXj9Mv0QKUNNC6QooUn9KJXcRZFQ0JiWUZNxeluTww43JFZ37zmD3fQugWRPOrcX9UaaJCjMaO/b+Sb6g==",
"version": "1.116.0",
"resolved": "https://registry.npmjs.org/@tanstack/react-router-devtools/-/react-router-devtools-1.116.0.tgz",
"integrity": "sha512-PsJZWPjcmwZGe71kUvH4bI1ozkv1FgBuBEE0hTYlTCSJ3uG+qv3ndGEI+AiFyuF5OStrbfg0otW1OxeNq5vdGQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@tanstack/router-devtools-core": "^1.115.0",
"@tanstack/router-devtools-core": "^1.115.3",
"solid-js": "^1.9.5"
},
"engines": {
@@ -5400,7 +5400,7 @@
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"@tanstack/react-router": "^1.115.2",
"@tanstack/react-router": "^1.116.0",
"react": ">=18.0.0 || >=19.0.0",
"react-dom": ">=18.0.0 || >=19.0.0"
}
@@ -5424,9 +5424,9 @@
}
},
"node_modules/@tanstack/router-core": {
"version": "1.115.0",
"resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.115.0.tgz",
"integrity": "sha512-5XgesPkppANSnR3lrzakjx5+Vx1q4azI1t+kG2ZFvcLG8iRiJ564bDB1W3X2PZQgfKD78jDO/uWAcJTHH4sXuw==",
"version": "1.115.3",
"resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.115.3.tgz",
"integrity": "sha512-gynHs72LHVg05fuJTwZZYhDL4VNEAK0sXz7IqiBv7a3qsYeEmIZsGaFr9sVjTkuF1kbrFBdJd5JYutzBh9Uuhw==",
"license": "MIT",
"dependencies": {
"@tanstack/history": "1.115.0",
@@ -5442,9 +5442,9 @@
}
},
"node_modules/@tanstack/router-devtools-core": {
"version": "1.115.0",
"resolved": "https://registry.npmjs.org/@tanstack/router-devtools-core/-/router-devtools-core-1.115.0.tgz",
"integrity": "sha512-s46V8bWxp4fWWjjDm7aGIYw8uPDXu8l1HkwGJwxkf1OQn1MdE7KRIVhGs/GM3Hp2KptQe4Gjomr7r1xrajuMhA==",
"version": "1.115.3",
"resolved": "https://registry.npmjs.org/@tanstack/router-devtools-core/-/router-devtools-core-1.115.3.tgz",
"integrity": "sha512-VBdgw1qxeOD/6FlZ9gitrWPUKGW83CuAW31gf32E0dxL7sIXP+yEFyPlNsVlENan1oSaEuV8tjKkuq5s4MfaPw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5459,7 +5459,7 @@
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"@tanstack/router-core": "^1.115.0",
"@tanstack/router-core": "^1.115.3",
"csstype": "^3.0.10",
"solid-js": ">=1.9.5",
"tiny-invariant": "^1.3.3"
@@ -5471,9 +5471,9 @@
}
},
"node_modules/@tanstack/router-generator": {
"version": "1.115.2",
"resolved": "https://registry.npmjs.org/@tanstack/router-generator/-/router-generator-1.115.2.tgz",
"integrity": "sha512-T77B6MnEdCPU9QFhjX/bhzaHKlKSo6n2MkIc78WrsnZ0Zx/zTtbzsGiLYyFZQ0tvB4/eazRrBh6YYY3qRwkGhg==",
"version": "1.116.0",
"resolved": "https://registry.npmjs.org/@tanstack/router-generator/-/router-generator-1.116.0.tgz",
"integrity": "sha512-XhCp85zP87G2bpSXnosiP3fiMo8HMQD2mvWqFFTFKz87WocabQYGlfhmNYWmBwI50EuS7Ph9lwXsSkV0oKh0xw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5490,7 +5490,7 @@
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"@tanstack/react-router": "^1.115.2"
"@tanstack/react-router": "^1.116.0"
},
"peerDependenciesMeta": {
"@tanstack/react-router": {
@@ -5499,9 +5499,9 @@
}
},
"node_modules/@tanstack/router-plugin": {
"version": "1.115.2",
"resolved": "https://registry.npmjs.org/@tanstack/router-plugin/-/router-plugin-1.115.2.tgz",
"integrity": "sha512-81poBAU55nauRPddjbtRzGZwPy0/+SXIn6yRUXlMBQhnpMNlnsWbMyigV/iNm5F7SEUOI2u2Q79bt5Fvk2FNbA==",
"version": "1.116.1",
"resolved": "https://registry.npmjs.org/@tanstack/router-plugin/-/router-plugin-1.116.1.tgz",
"integrity": "sha512-9A8DAyRejTzvkVOzgVPUY6l2aH7xOMEXSJJtV9GNbi4NtE6AXUCoFe3mtvYnHSzRqAUMCO0wnfVENCjXQoQYZw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5511,8 +5511,8 @@
"@babel/template": "^7.26.8",
"@babel/traverse": "^7.26.8",
"@babel/types": "^7.26.8",
"@tanstack/router-core": "^1.115.0",
"@tanstack/router-generator": "^1.115.2",
"@tanstack/router-core": "^1.115.3",
"@tanstack/router-generator": "^1.116.0",
"@tanstack/router-utils": "^1.115.0",
"@tanstack/virtual-file-routes": "^1.115.0",
"@types/babel__core": "^7.20.5",
@@ -5532,7 +5532,7 @@
},
"peerDependencies": {
"@rsbuild/core": ">=1.0.2",
"@tanstack/react-router": "^1.115.2",
"@tanstack/react-router": "^1.116.0",
"vite": ">=5.0.0 || >=6.0.0",
"vite-plugin-solid": "^2.11.2",
"webpack": ">=5.92.0"
@@ -5819,9 +5819,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.14.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.0.tgz",
"integrity": "sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA==",
"version": "22.14.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz",
"integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5829,9 +5829,9 @@
}
},
"node_modules/@types/react": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.0.tgz",
"integrity": "sha512-UaicktuQI+9UKyA4njtDOGBD/67t8YEBt2xdfqu8+gP9hqPUPsiXlNPcpS2gVdjmis5GKPG3fCxbQLVgxsQZ8w==",
"version": "19.1.1",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.1.tgz",
"integrity": "sha512-ePapxDL7qrgqSF67s0h9m412d9DbXyC1n59O2st+9rjuuamWsZuD2w55rqY12CbzsZ7uVXb5Nw0gEp9Z8MMutQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {
@@ -10528,9 +10528,9 @@
"license": "MIT"
},
"node_modules/msw": {
"version": "2.7.3",
"resolved": "https://registry.npmjs.org/msw/-/msw-2.7.3.tgz",
"integrity": "sha512-+mycXv8l2fEAjFZ5sjrtjJDmm2ceKGjrNbBr1durRg6VkU9fNUE/gsmQ51hWbHqs+l35W1iM+ZsmOD9Fd6lspw==",
"version": "2.7.4",
"resolved": "https://registry.npmjs.org/msw/-/msw-2.7.4.tgz",
"integrity": "sha512-A2kuMopOjAjNEYkn0AnB1uj+x7oBjLIunFk7Ud4icEnVWFf6iBekn8oXW4zIwcpfEdWP9sLqyVaHVzneWoGEww==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",

View File

@@ -22,8 +22,8 @@
"@fontsource/inter": "^5.2.5",
"@radix-ui/react-collapsible": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.6",
"@tanstack/react-query": "^5.72.2",
"@tanstack/react-router": "^1.115.2",
"@tanstack/react-query": "^5.74.3",
"@tanstack/react-router": "^1.116.0",
"@vector-im/compound-design-tokens": "4.0.1",
"@vector-im/compound-web": "^7.10.1",
"@zxcvbn-ts/core": "^3.0.4",
@@ -52,14 +52,14 @@
"@storybook/react": "^8.6.12",
"@storybook/react-vite": "^8.6.12",
"@storybook/test": "^8.5.5",
"@tanstack/react-query-devtools": "^5.72.2",
"@tanstack/react-router-devtools": "^1.115.2",
"@tanstack/router-plugin": "^1.115.2",
"@tanstack/react-query-devtools": "^5.74.3",
"@tanstack/react-router-devtools": "^1.116.0",
"@tanstack/router-plugin": "^1.116.1",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^22.14.0",
"@types/react": "19.1.0",
"@types/node": "^22.14.1",
"@types/react": "19.1.1",
"@types/react-dom": "19.1.2",
"@types/swagger-ui-dist": "^3.30.5",
"@vitejs/plugin-react": "^4.3.4",
@@ -70,7 +70,7 @@
"happy-dom": "^17.4.4",
"i18next-parser": "^9.3.0",
"knip": "^5.50.2",
"msw": "^2.7.3",
"msw": "^2.7.4",
"msw-storybook-addon": "^2.0.4",
"postcss": "^8.5.3",
"postcss-import": "^16.1.0",

View File

@@ -1,12 +1,12 @@
{
"name": "@vector-im/syn2mas",
"version": "0.14.1",
"version": "0.15.0-rc.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@vector-im/syn2mas",
"version": "0.14.1",
"version": "0.15.0-rc.0",
"license": "AGPL-3.0-only",
"dependencies": {
"command-line-args": "^6.0.0",
@@ -687,9 +687,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.14.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.0.tgz",
"integrity": "sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA==",
"version": "22.14.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz",
"integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==",
"dev": true,
"license": "MIT",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@vector-im/syn2mas",
"version": "0.14.1",
"version": "0.15.0-rc.0",
"description": "A tool to migrate Synapse users and sessions to the Matrix Authentication Service",
"license": "AGPL-3.0-only",
"author": "Matrix.org",

View File

@@ -151,7 +151,8 @@
"headline": "Přihlaste se k odkazu"
},
"no_login_methods": "Nejsou k dispozici žádné metody přihlášení.",
"separator": "Nebo"
"separator": "Nebo",
"username_or_email": "Uživatelské jméno nebo e-mail"
},
"navbar": {
"my_account": "Můj účet",

Some files were not shown because too many files have changed in this diff Show More