diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 548e53eca..1df6a6b9f 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -46,7 +46,7 @@ jobs: steps: - name: Checkout the code - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: # Need a full clone so that `git describe` reports the right version fetch-depth: 0 @@ -67,7 +67,7 @@ jobs: steps: - name: Checkout the code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - uses: ./.github/actions/build-frontend - uses: ./.github/actions/build-policies @@ -112,7 +112,7 @@ jobs: steps: - name: Checkout the code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -276,7 +276,7 @@ jobs: - name: Build and push id: bake - uses: docker/bake-action@v6.9.0 + uses: docker/bake-action@v6.10.0 with: files: | ./docker-bake.hcl @@ -376,7 +376,7 @@ jobs: steps: - name: Checkout the code - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: sparse-checkout: | .github/scripts @@ -454,7 +454,7 @@ jobs: steps: - name: Checkout the code - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: sparse-checkout: | .github/scripts diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ed119c2e3..b4cbdfff8 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -34,7 +34,7 @@ jobs: steps: - name: Checkout the code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - uses: ./.github/actions/build-policies @@ -61,7 +61,7 @@ jobs: steps: - name: Checkout the code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Install Node uses: actions/setup-node@v6.0.0 @@ -85,7 +85,7 @@ jobs: steps: - name: Checkout the code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Install Node uses: actions/setup-node@v6.0.0 @@ -109,7 +109,7 @@ jobs: steps: - name: Checkout the code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Install Node uses: actions/setup-node@v6.0.0 @@ -133,7 +133,7 @@ jobs: steps: - name: Checkout the code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@nightly @@ -156,10 +156,10 @@ jobs: steps: - name: Checkout the code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Run `cargo-deny` - uses: EmbarkStudios/cargo-deny-action@v2.0.13 + uses: EmbarkStudios/cargo-deny-action@v2.0.14 with: rust-version: stable @@ -172,7 +172,7 @@ jobs: steps: - name: Checkout the code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Install Rust toolchain run: | @@ -213,7 +213,7 @@ jobs: steps: - name: Checkout the code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@1.89.0 @@ -238,7 +238,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -291,7 +291,7 @@ jobs: steps: - name: Checkout the code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml index 3046f45cc..74eeef3b4 100644 --- a/.github/workflows/coverage.yaml +++ b/.github/workflows/coverage.yaml @@ -29,7 +29,7 @@ jobs: steps: - name: Checkout the code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - uses: ./.github/actions/build-policies @@ -54,7 +54,7 @@ jobs: steps: - name: Checkout the code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - uses: ./.github/actions/build-frontend env: @@ -99,7 +99,7 @@ jobs: steps: - name: Checkout the code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 84074d85e..1fa2f56a7 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -25,7 +25,7 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Checkout the code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable diff --git a/.github/workflows/merge-back.yaml b/.github/workflows/merge-back.yaml index d28d68c20..8239442fc 100644 --- a/.github/workflows/merge-back.yaml +++ b/.github/workflows/merge-back.yaml @@ -24,7 +24,7 @@ jobs: steps: - name: Checkout the code - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: sparse-checkout: | .github/scripts diff --git a/.github/workflows/release-branch.yaml b/.github/workflows/release-branch.yaml index e31fa2c82..b78c9363f 100644 --- a/.github/workflows/release-branch.yaml +++ b/.github/workflows/release-branch.yaml @@ -34,7 +34,7 @@ jobs: run: exit 1 - name: Checkout the code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -61,7 +61,7 @@ jobs: steps: - name: Checkout the code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Install Node uses: actions/setup-node@v6.0.0 @@ -106,7 +106,7 @@ jobs: needs: [tag, compute-version, localazy] steps: - name: Checkout the code - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: sparse-checkout: | .github/scripts diff --git a/.github/workflows/release-bump.yaml b/.github/workflows/release-bump.yaml index 46251a410..a2a20791a 100644 --- a/.github/workflows/release-bump.yaml +++ b/.github/workflows/release-bump.yaml @@ -33,7 +33,7 @@ jobs: run: exit 1 - name: Checkout the code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -76,7 +76,7 @@ jobs: needs: [tag, compute-version] steps: - name: Checkout the code - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: sparse-checkout: | .github/scripts diff --git a/.github/workflows/tag.yaml b/.github/workflows/tag.yaml index ec3ea290d..c6c394c81 100644 --- a/.github/workflows/tag.yaml +++ b/.github/workflows/tag.yaml @@ -30,7 +30,7 @@ jobs: steps: - name: Checkout the code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable diff --git a/.github/workflows/translations-download.yaml b/.github/workflows/translations-download.yaml index a022203fc..5dbf2f79a 100644 --- a/.github/workflows/translations-download.yaml +++ b/.github/workflows/translations-download.yaml @@ -19,7 +19,7 @@ jobs: run: exit 1 - name: Checkout the code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Install Node uses: actions/setup-node@v6.0.0 @@ -42,7 +42,7 @@ jobs: - name: Create Pull Request id: cpr - uses: peter-evans/create-pull-request@v7.0.8 + uses: peter-evans/create-pull-request@v7.0.9 with: sign-commits: true token: ${{ secrets.BOT_GITHUB_TOKEN }} diff --git a/.github/workflows/translations-upload.yaml b/.github/workflows/translations-upload.yaml index 063a228dd..b09a7d63c 100644 --- a/.github/workflows/translations-upload.yaml +++ b/.github/workflows/translations-upload.yaml @@ -18,7 +18,7 @@ jobs: steps: - name: Checkout the code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Install Node uses: actions/setup-node@v6.0.0 diff --git a/Cargo.lock b/Cargo.lock index 1842a3e75..e7c760ff2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1089,9 +1089,9 @@ checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" [[package]] name = "convert_case" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" +checksum = "db05ffb6856bf0ecdf6367558a76a0e8a77b1713044eb92845c692100ed50190" dependencies = [ "unicode-segmentation", ] @@ -3723,6 +3723,7 @@ dependencies = [ "mas-data-model", "mas-i18n", "mas-iana", + "mas-policy", "mas-router", "mas-spa", "minijinja", diff --git a/Cargo.toml b/Cargo.toml index deafed604..84bc5f77e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -177,7 +177,7 @@ features = ["std"] # Utility for converting between different cases [workspace.dependencies.convert_case] -version = "0.8.0" +version = "0.9.0" # CRC calculation [workspace.dependencies.crc] diff --git a/crates/cli/src/util.rs b/crates/cli/src/util.rs index c0f31557b..454276150 100644 --- a/crates/cli/src/util.rs +++ b/crates/cli/src/util.rs @@ -145,6 +145,7 @@ pub async fn policy_factory_from_config( register: config.register_entrypoint.clone(), client_registration: config.client_registration_entrypoint.clone(), authorization_grant: config.authorization_grant_entrypoint.clone(), + compat_login: config.compat_login_entrypoint.clone(), email: config.email_entrypoint.clone(), }; diff --git a/crates/config/src/sections/policy.rs b/crates/config/src/sections/policy.rs index 37d052ade..3b816b713 100644 --- a/crates/config/src/sections/policy.rs +++ b/crates/config/src/sections/policy.rs @@ -62,6 +62,14 @@ fn is_default_password_entrypoint(value: &String) -> bool { *value == default_password_entrypoint() } +fn default_compat_login_entrypoint() -> String { + "compat_login/violation".to_owned() +} + +fn is_default_compat_login_entrypoint(value: &String) -> bool { + *value == default_compat_login_entrypoint() +} + fn default_email_entrypoint() -> String { "email/violation".to_owned() } @@ -111,6 +119,13 @@ pub struct PolicyConfig { )] pub authorization_grant_entrypoint: String, + /// Entrypoint to use when evaluating compatibility logins + #[serde( + default = "default_compat_login_entrypoint", + skip_serializing_if = "is_default_compat_login_entrypoint" + )] + pub compat_login_entrypoint: String, + /// Entrypoint to use when changing password #[serde( default = "default_password_entrypoint", @@ -137,6 +152,7 @@ impl Default for PolicyConfig { client_registration_entrypoint: default_client_registration_entrypoint(), register_entrypoint: default_register_entrypoint(), authorization_grant_entrypoint: default_authorization_grant_entrypoint(), + compat_login_entrypoint: default_compat_login_entrypoint(), password_entrypoint: default_password_entrypoint(), email_entrypoint: default_email_entrypoint(), data: default_data(), diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index d3c7c979f..9f10d0373 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -16,6 +16,7 @@ use mas_data_model::{ User, }; use mas_matrix::HomeserverConnection; +use mas_policy::{Policy, Requester, ViolationCode, model::CompatLogin}; use mas_storage::{ BoxRepository, BoxRepositoryFactory, RepositoryAccess, compat::{ @@ -37,6 +38,7 @@ use crate::{ BoundActivityTracker, Limiter, METER, RequesterFingerprint, impl_from_error_for_route, passwords::{PasswordManager, PasswordVerificationResult}, rate_limit::PasswordCheckLimitedError, + session::count_user_sessions_for_limiting, }; static LOGIN_COUNTER: LazyLock> = LazyLock::new(|| { @@ -213,9 +215,16 @@ pub enum RouteError { #[error("failed to provision device")] ProvisionDeviceFailed(#[source] anyhow::Error), + + #[error("login rejected by policy")] + PolicyRejected, + + #[error("login rejected by policy (hard session limit reached)")] + PolicyHardSessionLimitReached, } impl_from_error_for_route!(mas_storage::RepositoryError); +impl_from_error_for_route!(mas_policy::EvaluationError); impl From for RouteError { fn from(err: anyhow::Error) -> Self { @@ -274,6 +283,16 @@ impl IntoResponse for RouteError { error: "User account has been locked", status: StatusCode::UNAUTHORIZED, }, + Self::PolicyRejected => MatrixError { + errcode: "M_FORBIDDEN", + error: "Login denied by the policy enforced by this service", + status: StatusCode::FORBIDDEN, + }, + Self::PolicyHardSessionLimitReached => MatrixError { + errcode: "M_FORBIDDEN", + error: "You have reached your hard device limit. Please visit your account page to sign some out.", + status: StatusCode::FORBIDDEN, + }, }; (sentry_event_id, response).into_response() @@ -290,6 +309,7 @@ pub(crate) async fn post( State(homeserver): State>, State(site_config): State, State(limiter): State, + mut policy: Policy, requester: RequesterFingerprint, user_agent: Option>, MatrixJsonBody(input): MatrixJsonBody, @@ -329,6 +349,11 @@ pub(crate) async fn post( &limiter, requester, &mut repo, + &mut policy, + Requester { + ip_address: activity_tracker.ip(), + user_agent: user_agent.clone(), + }, username, password, input.device_id, // TODO check for validity @@ -342,6 +367,11 @@ pub(crate) async fn post( &mut rng, &clock, &mut repo, + &mut policy, + Requester { + ip_address: activity_tracker.ip(), + user_agent: user_agent.clone(), + }, &token, input.device_id, input.initial_device_display_name, @@ -459,6 +489,8 @@ async fn token_login( rng: &mut (dyn RngCore + Send), clock: &dyn Clock, repo: &mut BoxRepository, + policy: &mut Policy, + requester: Requester, token: &str, requested_device_id: Option, initial_device_display_name: Option, @@ -544,10 +576,38 @@ async fn token_login( Device::generate(rng) }; - repo.app_session() + let session_replaced = repo + .app_session() .finish_sessions_to_replace_device(clock, &browser_session.user, &device) .await?; + let session_counts = count_user_sessions_for_limiting(repo, &browser_session.user).await?; + + let res = policy + .evaluate_compat_login(mas_policy::CompatLoginInput { + user: &browser_session.user, + login: CompatLogin::Token, + session_replaced, + session_counts, + requester, + }) + .await?; + if !res.valid() { + // If the only violation is that we have too many sessions, then handle that + // separately. + // In the future, we intend to evict some sessions automatically instead. We + // don't trigger this if there was some other violation anyway, since that means + // that removing a session wouldn't actually unblock the login. + if res.violations.len() == 1 { + let violation = &res.violations[0]; + if violation.code == Some(ViolationCode::TooManySessions) { + // The only violation is having reached the session limit. + return Err(RouteError::PolicyHardSessionLimitReached); + } + } + return Err(RouteError::PolicyRejected); + } + // We first create the session in the database, commit the transaction, then // create it on the homeserver, scheduling a device sync job afterwards to // make sure we don't end up in an inconsistent state. @@ -578,6 +638,8 @@ async fn user_password_login( limiter: &Limiter, requester: RequesterFingerprint, repo: &mut BoxRepository, + policy: &mut Policy, + policy_requester: Requester, username: &str, password: String, requested_device_id: Option, @@ -647,10 +709,38 @@ async fn user_password_login( Device::generate(&mut rng) }; - repo.app_session() + let session_replaced = repo + .app_session() .finish_sessions_to_replace_device(clock, &user, &device) .await?; + let session_counts = count_user_sessions_for_limiting(repo, &user).await?; + + let res = policy + .evaluate_compat_login(mas_policy::CompatLoginInput { + user: &user, + login: CompatLogin::Password, + session_replaced, + session_counts, + requester: policy_requester, + }) + .await?; + if !res.valid() { + // If the only violation is that we have too many sessions, then handle that + // separately. + // In the future, we intend to evict some sessions automatically instead. We + // don't trigger this if there was some other violation anyway, since that means + // that removing a session wouldn't actually unblock the login. + if res.violations.len() == 1 { + let violation = &res.violations[0]; + if violation.code == Some(ViolationCode::TooManySessions) { + // The only violation is having reached the session limit. + return Err(RouteError::PolicyHardSessionLimitReached); + } + } + return Err(RouteError::PolicyRejected); + } + let session = repo .compat_session() .add( diff --git a/crates/handlers/src/compat/login_sso_complete.rs b/crates/handlers/src/compat/login_sso_complete.rs index a4fbb24fb..b0735ffce 100644 --- a/crates/handlers/src/compat/login_sso_complete.rs +++ b/crates/handlers/src/compat/login_sso_complete.rs @@ -11,23 +11,27 @@ use axum::{ extract::{Form, Path, State}, response::{Html, IntoResponse, Redirect, Response}, }; -use axum_extra::extract::Query; +use axum_extra::{TypedHeader, extract::Query}; use chrono::Duration; +use hyper::StatusCode; use mas_axum_utils::{ InternalError, cookies::CookieJar, csrf::{CsrfExt, ProtectedForm}, }; use mas_data_model::{BoxClock, BoxRng, Clock}; +use mas_policy::{Policy, model::CompatLogin}; use mas_router::{CompatLoginSsoAction, UrlBuilder}; use mas_storage::{BoxRepository, RepositoryAccess, compat::CompatSsoLoginRepository}; -use mas_templates::{CompatSsoContext, ErrorContext, TemplateContext, Templates}; +use mas_templates::{ + CompatLoginPolicyViolationContext, CompatSsoContext, ErrorContext, TemplateContext, Templates, +}; use serde::{Deserialize, Serialize}; use ulid::Ulid; use crate::{ - PreferredLanguage, - session::{SessionOrFallback, load_session_or_fallback}, + BoundActivityTracker, PreferredLanguage, + session::{SessionOrFallback, count_user_sessions_for_limiting, load_session_or_fallback}, }; #[derive(Serialize)] @@ -56,10 +60,15 @@ pub async fn get( mut repo: BoxRepository, State(templates): State, State(url_builder): State, + mut policy: Policy, + activity_tracker: BoundActivityTracker, + user_agent: Option>, cookie_jar: CookieJar, Path(id): Path, Query(params): Query, ) -> Result { + let user_agent = user_agent.map(|ua| ua.to_string()); + let (cookie_jar, maybe_session) = match load_session_or_fallback( cookie_jar, &clock, &mut rng, &templates, &locale, &mut repo, ) @@ -107,6 +116,35 @@ pub async fn get( return Ok((cookie_jar, Html(content)).into_response()); } + let session_counts = count_user_sessions_for_limiting(&mut repo, &session.user).await?; + + let res = policy + .evaluate_compat_login(mas_policy::CompatLoginInput { + user: &session.user, + login: CompatLogin::Sso { + redirect_uri: login.redirect_uri.to_string(), + }, + // We don't know if there's going to be a replacement until we received the device ID, + // which happens too late. + session_replaced: false, + session_counts, + requester: mas_policy::Requester { + ip_address: activity_tracker.ip(), + user_agent, + }, + }) + .await?; + if !res.valid() { + let ctx = CompatLoginPolicyViolationContext::for_violations(res.violations) + .with_session(session) + .with_csrf(csrf_token.form_value()) + .with_language(locale); + + let content = templates.render_compat_login_policy_violation(&ctx)?; + + return Ok((StatusCode::FORBIDDEN, cookie_jar, Html(content)).into_response()); + } + let ctx = CompatSsoContext::new(login) .with_session(session) .with_csrf(csrf_token.form_value()) @@ -129,11 +167,16 @@ pub async fn post( PreferredLanguage(locale): PreferredLanguage, State(templates): State, State(url_builder): State, + mut policy: Policy, + activity_tracker: BoundActivityTracker, + user_agent: Option>, cookie_jar: CookieJar, Path(id): Path, Query(params): Query, Form(form): Form>, ) -> Result { + let user_agent = user_agent.map(|ua| ua.to_string()); + let (cookie_jar, maybe_session) = match load_session_or_fallback( cookie_jar, &clock, &mut rng, &templates, &locale, &mut repo, ) @@ -200,6 +243,37 @@ pub async fn post( redirect_uri }; + let session_counts = count_user_sessions_for_limiting(&mut repo, &session.user).await?; + + let res = policy + .evaluate_compat_login(mas_policy::CompatLoginInput { + user: &session.user, + login: CompatLogin::Sso { + redirect_uri: login.redirect_uri.to_string(), + }, + session_counts, + // We don't know if there's going to be a replacement until we received the device ID, + // which happens too late. + session_replaced: false, + requester: mas_policy::Requester { + ip_address: activity_tracker.ip(), + user_agent, + }, + }) + .await?; + + if !res.valid() { + let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); + let ctx = CompatLoginPolicyViolationContext::for_violations(res.violations) + .with_session(session) + .with_csrf(csrf_token.form_value()) + .with_language(locale); + + let content = templates.render_compat_login_policy_violation(&ctx)?; + + return Ok((StatusCode::FORBIDDEN, cookie_jar, Html(content)).into_response()); + } + // Note that if the login is not Pending, // this fails and aborts the transaction. repo.compat_sso_login() diff --git a/crates/handlers/src/lib.rs b/crates/handlers/src/lib.rs index 65a75f550..ebd223e4a 100644 --- a/crates/handlers/src/lib.rs +++ b/crates/handlers/src/lib.rs @@ -272,6 +272,7 @@ where BoxRepository: FromRequestParts, BoxClock: FromRequestParts, BoxRng: FromRequestParts, + Policy: FromRequestParts, { // A sub-router for human-facing routes with error handling let human_router = Router::new() diff --git a/crates/handlers/src/test_utils.rs b/crates/handlers/src/test_utils.rs index 4b93177de..521a4848d 100644 --- a/crates/handlers/src/test_utils.rs +++ b/crates/handlers/src/test_utils.rs @@ -82,6 +82,7 @@ pub(crate) async fn policy_factory( register: "register/violation".to_owned(), client_registration: "client_registration/violation".to_owned(), authorization_grant: "authorization_grant/violation".to_owned(), + compat_login: "compat_login/violation".to_owned(), email: "email/violation".to_owned(), }; diff --git a/crates/handlers/src/upstream_oauth2/link.rs b/crates/handlers/src/upstream_oauth2/link.rs index faf7c8dfb..e52ce4a26 100644 --- a/crates/handlers/src/upstream_oauth2/link.rs +++ b/crates/handlers/src/upstream_oauth2/link.rs @@ -582,7 +582,7 @@ pub(crate) async fn get( .await?; } - // We matched anexisting user and the conflict resolution is to replace any + // We matched an existing user and the conflict resolution is to replace any // link on the existing user with this one UpstreamOAuthProviderOnConflict::Replace => { // Find existing links for this provider and user @@ -629,7 +629,7 @@ pub(crate) async fn get( .await?; } - // We matched an existing user and the conflict resolution is link to the + // We matched an existing user and the conflict resolution is to link to the // existing user *only if* there is no existing link on that user UpstreamOAuthProviderOnConflict::Set => { // Find existing links for this provider and user @@ -643,7 +643,7 @@ pub(crate) async fn get( upstream_oauth_provider.id = %provider.id, upstream_oauth_link.id = %link.id, user.id = %existing_user.id, - "Upstream provider returned a localpart {localpart:?} which is already used by another user. That user already has a ({count}) link to this provider, which isn't allowed by the conflict resolution" + "Upstream provider returned a localpart {localpart:?} matching an existing user who already has {count} link(s) to this provider, which isn't allowed by the conflict resolution" ); // TODO: translate @@ -651,7 +651,7 @@ pub(crate) async fn get( .with_code("User exists") .with_description(format!( r"Upstream account provider returned {localpart:?} as username, - which is not linked to another existing upstream account. + but this user already has an existing link to this provider. Your homeserver does not allow replacing upstream account links automatically." )) .with_language(&locale); diff --git a/crates/policy/src/bin/schema.rs b/crates/policy/src/bin/schema.rs index 8e9c81a07..be778f6e1 100644 --- a/crates/policy/src/bin/schema.rs +++ b/crates/policy/src/bin/schema.rs @@ -12,7 +12,7 @@ use std::path::{Path, PathBuf}; use mas_policy::model::{ - AuthorizationGrantInput, ClientRegistrationInput, EmailInput, RegisterInput, + AuthorizationGrantInput, ClientRegistrationInput, CompatLoginInput, EmailInput, RegisterInput, }; use schemars::{JsonSchema, generate::SchemaSettings}; @@ -42,5 +42,6 @@ fn main() { write_schema::(output_root, "register_input.json"); write_schema::(output_root, "client_registration_input.json"); write_schema::(output_root, "authorization_grant_input.json"); + write_schema::(output_root, "compat_login_input.json"); write_schema::(output_root, "email_input.json"); } diff --git a/crates/policy/src/lib.rs b/crates/policy/src/lib.rs index 8a038aea8..dcb68dd36 100644 --- a/crates/policy/src/lib.rs +++ b/crates/policy/src/lib.rs @@ -19,8 +19,9 @@ use thiserror::Error; use tokio::io::{AsyncRead, AsyncReadExt}; pub use self::model::{ - AuthorizationGrantInput, ClientRegistrationInput, Code as ViolationCode, EmailInput, - EvaluationResult, GrantType, RegisterInput, RegistrationMethod, Requester, Violation, + AuthorizationGrantInput, ClientRegistrationInput, Code as ViolationCode, CompatLoginInput, + EmailInput, EvaluationResult, GrantType, RegisterInput, RegistrationMethod, Requester, + Violation, }; #[derive(Debug, Error)] @@ -72,15 +73,17 @@ pub struct Entrypoints { pub register: String, pub client_registration: String, pub authorization_grant: String, + pub compat_login: String, pub email: String, } impl Entrypoints { - fn all(&self) -> [&str; 4] { + fn all(&self) -> [&str; 5] { [ self.register.as_str(), self.client_registration.as_str(), self.authorization_grant.as_str(), + self.compat_login.as_str(), self.email.as_str(), ] } @@ -459,6 +462,30 @@ impl Policy { Ok(res) } + + /// Evaluate the `compat_login` entrypoint. + /// + /// # Errors + /// + /// Returns an error if the policy engine fails to evaluate the entrypoint. + #[tracing::instrument( + name = "policy.evaluate.compat_login", + skip_all, + fields( + %input.user.id, + ), + )] + pub async fn evaluate_compat_login( + &mut self, + input: CompatLoginInput<'_>, + ) -> Result { + let [res]: [EvaluationResult; 1] = self + .instance + .evaluate(&mut self.store, &self.entrypoints.compat_login, &input) + .await?; + + Ok(res) + } } #[cfg(test)] @@ -468,6 +495,16 @@ mod tests { use super::*; + fn make_entrypoints() -> Entrypoints { + Entrypoints { + register: "register/violation".to_owned(), + client_registration: "client_registration/violation".to_owned(), + authorization_grant: "authorization_grant/violation".to_owned(), + compat_login: "compat_login/violation".to_owned(), + email: "email/violation".to_owned(), + } + } + #[tokio::test] async fn test_register() { let data = Data::new("example.com".to_owned(), None).with_rest(serde_json::json!({ @@ -484,14 +521,9 @@ mod tests { let file = tokio::fs::File::open(path).await.unwrap(); - let entrypoints = Entrypoints { - register: "register/violation".to_owned(), - client_registration: "client_registration/violation".to_owned(), - authorization_grant: "authorization_grant/violation".to_owned(), - email: "email/violation".to_owned(), - }; - - let factory = PolicyFactory::load(file, data, entrypoints).await.unwrap(); + let factory = PolicyFactory::load(file, data, make_entrypoints()) + .await + .unwrap(); let mut policy = factory.instantiate().await.unwrap(); @@ -551,14 +583,9 @@ mod tests { let file = tokio::fs::File::open(path).await.unwrap(); - let entrypoints = Entrypoints { - register: "register/violation".to_owned(), - client_registration: "client_registration/violation".to_owned(), - authorization_grant: "authorization_grant/violation".to_owned(), - email: "email/violation".to_owned(), - }; - - let factory = PolicyFactory::load(file, data, entrypoints).await.unwrap(); + let factory = PolicyFactory::load(file, data, make_entrypoints()) + .await + .unwrap(); let mut policy = factory.instantiate().await.unwrap(); @@ -620,14 +647,9 @@ mod tests { let file = tokio::fs::File::open(path).await.unwrap(); - let entrypoints = Entrypoints { - register: "register/violation".to_owned(), - client_registration: "client_registration/violation".to_owned(), - authorization_grant: "authorization_grant/violation".to_owned(), - email: "email/violation".to_owned(), - }; - - let factory = PolicyFactory::load(file, data, entrypoints).await.unwrap(); + let factory = PolicyFactory::load(file, data, make_entrypoints()) + .await + .unwrap(); // That is around 1 MB of JSON data. Each element is a 5-digit string, so 8 // characters including the quotes and a comma. diff --git a/crates/policy/src/model.rs b/crates/policy/src/model.rs index b85170025..a9f5fb502 100644 --- a/crates/policy/src/model.rs +++ b/crates/policy/src/model.rs @@ -17,7 +17,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; /// A well-known policy code. -#[derive(Deserialize, Debug, Clone, Copy, JsonSchema)] +#[derive(Serialize, Deserialize, Debug, Clone, Copy, JsonSchema, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] pub enum Code { /// The username is too short. @@ -75,7 +75,7 @@ impl Code { } /// A single violation of a policy. -#[derive(Deserialize, Debug, JsonSchema)] +#[derive(Serialize, Deserialize, Debug, JsonSchema)] pub struct Violation { pub msg: String, pub redirect_uri: Option, @@ -187,6 +187,42 @@ pub struct AuthorizationGrantInput<'a> { pub requester: Requester, } +/// Input for the compatibility login policy. +#[derive(Serialize, Debug, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub struct CompatLoginInput<'a> { + #[schemars(with = "std::collections::HashMap")] + pub user: &'a User, + + /// How many sessions the user has. + pub session_counts: SessionCounts, + + /// Whether a session will be replaced by this login + pub session_replaced: bool, + + /// What type of login is being performed. + /// This also determines whether the login is interactive. + pub login: CompatLogin, + + pub requester: Requester, +} + +#[derive(Serialize, Debug, JsonSchema)] +#[serde(tag = "type")] +pub enum CompatLogin { + /// Used as the interactive part of SSO login. + #[serde(rename = "m.login.sso")] + Sso { redirect_uri: String }, + + /// Used as the final (non-interactive) stage of SSO login. + #[serde(rename = "m.login.token")] + Token, + + /// Non-interactive password-over-the-API login. + #[serde(rename = "m.login.password")] + Password, +} + /// Information about how many sessions the user has #[derive(Serialize, Debug, JsonSchema)] pub struct SessionCounts { diff --git a/crates/storage-pg/src/app_session.rs b/crates/storage-pg/src/app_session.rs index 4e12810cc..2867534c3 100644 --- a/crates/storage-pg/src/app_session.rs +++ b/crates/storage-pg/src/app_session.rs @@ -487,14 +487,15 @@ impl AppSessionRepository for PgAppSessionRepository<'_> { clock: &dyn Clock, user: &User, device: &Device, - ) -> Result<(), Self::Error> { + ) -> Result { + let mut affected = false; // TODO need to invoke this from all the oauth2 login sites let span = tracing::info_span!( "db.app_session.finish_sessions_to_replace_device.compat_sessions", { DB_QUERY_TEXT } = tracing::field::Empty, ); let finished_at = clock.now(); - sqlx::query!( + let compat_affected = sqlx::query!( " UPDATE compat_sessions SET finished_at = $3 WHERE user_id = $1 AND device_id = $2 AND finished_at IS NULL ", @@ -505,7 +506,9 @@ impl AppSessionRepository for PgAppSessionRepository<'_> { .record(&span) .execute(&mut *self.conn) .instrument(span) - .await?; + .await? + .rows_affected(); + affected |= compat_affected > 0; if let Ok([stable_device_as_scope_token, unstable_device_as_scope_token]) = device.to_scope_token() @@ -514,7 +517,7 @@ impl AppSessionRepository for PgAppSessionRepository<'_> { "db.app_session.finish_sessions_to_replace_device.oauth2_sessions", { DB_QUERY_TEXT } = tracing::field::Empty, ); - sqlx::query!( + let oauth2_affected = sqlx::query!( " UPDATE oauth2_sessions SET finished_at = $4 @@ -530,10 +533,12 @@ impl AppSessionRepository for PgAppSessionRepository<'_> { .record(&span) .execute(&mut *self.conn) .instrument(span) - .await?; + .await? + .rows_affected(); + affected |= oauth2_affected > 0; } - Ok(()) + Ok(affected) } } diff --git a/crates/storage/src/app_session.rs b/crates/storage/src/app_session.rs index d649ff35e..4c0b7703a 100644 --- a/crates/storage/src/app_session.rs +++ b/crates/storage/src/app_session.rs @@ -196,12 +196,14 @@ pub trait AppSessionRepository: Send + Sync { /// replacing a device). /// /// Should be called *before* creating a new session for the device. + /// + /// Returns true if a session was finished. async fn finish_sessions_to_replace_device( &mut self, clock: &dyn Clock, user: &User, device: &Device, - ) -> Result<(), Self::Error>; + ) -> Result; } repository_impl!(AppSessionRepository: @@ -218,5 +220,5 @@ repository_impl!(AppSessionRepository: clock: &dyn Clock, user: &User, device: &Device, - ) -> Result<(), Self::Error>; + ) -> Result; ); diff --git a/crates/templates/Cargo.toml b/crates/templates/Cargo.toml index 46ff80c74..d9c1bb019 100644 --- a/crates/templates/Cargo.toml +++ b/crates/templates/Cargo.toml @@ -41,6 +41,7 @@ oauth2-types.workspace = true mas-data-model.workspace = true mas-i18n.workspace = true mas-iana.workspace = true +mas-policy.workspace = true mas-router.workspace = true mas-spa.workspace = true diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index f836d7c4b..bb4edfeda 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -28,6 +28,7 @@ use mas_data_model::{ }; use mas_i18n::DataLocale; use mas_iana::jose::JsonWebSignatureAlg; +use mas_policy::{Violation, ViolationCode}; use mas_router::{Account, GraphQL, PostAuthAction, UrlBuilder}; use oauth2_types::scope::{OPENID, Scope}; use rand::{ @@ -860,6 +861,44 @@ impl PolicyViolationContext { } } +/// Context used by the `compat_login_policy_violation.html` template +#[derive(Serialize)] +pub struct CompatLoginPolicyViolationContext { + violations: Vec, +} + +impl TemplateContext for CompatLoginPolicyViolationContext { + fn sample( + _now: chrono::DateTime, + _rng: &mut R, + _locales: &[DataLocale], + ) -> BTreeMap + where + Self: Sized, + { + sample_list(vec![ + CompatLoginPolicyViolationContext { violations: vec![] }, + CompatLoginPolicyViolationContext { + violations: vec![Violation { + msg: "user has too many active sessions".to_owned(), + redirect_uri: None, + field: None, + code: Some(ViolationCode::TooManySessions), + }], + }, + ]) + } +} + +impl CompatLoginPolicyViolationContext { + /// Constructs a context for the compatibility login policy violation page + /// given the list of violations + #[must_use] + pub const fn for_violations(violations: Vec) -> Self { + Self { violations } + } +} + /// Context used by the `sso.html` template #[derive(Serialize)] pub struct CompatSsoContext { diff --git a/crates/templates/src/lib.rs b/crates/templates/src/lib.rs index 1f9aa3337..60b905550 100644 --- a/crates/templates/src/lib.rs +++ b/crates/templates/src/lib.rs @@ -37,14 +37,15 @@ mod macros; pub use self::{ context::{ - AccountInactiveContext, ApiDocContext, AppContext, CompatSsoContext, ConsentContext, - DeviceConsentContext, DeviceLinkContext, DeviceLinkFormField, DeviceNameContext, - EmailRecoveryContext, EmailVerificationContext, EmptyContext, ErrorContext, - FormPostContext, IndexContext, LoginContext, LoginFormField, NotFoundContext, - PasswordRegisterContext, PolicyViolationContext, PostAuthContext, PostAuthContextInner, - RecoveryExpiredContext, RecoveryFinishContext, RecoveryFinishFormField, - RecoveryProgressContext, RecoveryStartContext, RecoveryStartFormField, RegisterContext, - RegisterFormField, RegisterStepsDisplayNameContext, RegisterStepsDisplayNameFormField, + AccountInactiveContext, ApiDocContext, AppContext, CompatLoginPolicyViolationContext, + CompatSsoContext, ConsentContext, DeviceConsentContext, DeviceLinkContext, + DeviceLinkFormField, DeviceNameContext, EmailRecoveryContext, EmailVerificationContext, + EmptyContext, ErrorContext, FormPostContext, IndexContext, LoginContext, LoginFormField, + NotFoundContext, PasswordRegisterContext, PolicyViolationContext, PostAuthContext, + PostAuthContextInner, RecoveryExpiredContext, RecoveryFinishContext, + RecoveryFinishFormField, RecoveryProgressContext, RecoveryStartContext, + RecoveryStartFormField, RegisterContext, RegisterFormField, + RegisterStepsDisplayNameContext, RegisterStepsDisplayNameFormField, RegisterStepsEmailInUseContext, RegisterStepsRegistrationTokenContext, RegisterStepsRegistrationTokenFormField, RegisterStepsVerifyEmailContext, RegisterStepsVerifyEmailFormField, SiteBranding, SiteConfigExt, SiteFeatures, @@ -391,6 +392,9 @@ register_templates! { /// Render the policy violation page pub fn render_policy_violation(WithLanguage>>) { "pages/policy_violation.html" } + /// Render the compatibility login policy violation page + pub fn render_compat_login_policy_violation(WithLanguage>>) { "pages/compat_login_policy_violation.html" } + /// Render the legacy SSO login consent page pub fn render_sso_login(WithLanguage>>) { "pages/sso.html" } diff --git a/docs/config.schema.json b/docs/config.schema.json index 2dc19ce8a..f6d947e48 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -1883,6 +1883,10 @@ "description": "Entrypoint to use when evaluating authorization grants", "type": "string" }, + "compat_login_entrypoint": { + "description": "Entrypoint to use when evaluating compatibility logins", + "type": "string" + }, "password_entrypoint": { "description": "Entrypoint to use when changing password", "type": "string" diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 76e6c4c9a..39955cc30 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -25,15 +25,15 @@ "react-dom": "^19.2.0", "react-i18next": "^16.3.5", "swagger-ui-dist": "^5.29.5", - "valibot": "^1.1.0", + "valibot": "^1.2.0", "vaul": "^1.1.2" }, "devDependencies": { - "@biomejs/biome": "^2.3.2", + "@biomejs/biome": "^2.3.7", "@browser-logos/chrome": "^2.0.0", "@browser-logos/firefox": "^3.0.10", "@browser-logos/safari": "^2.1.0", - "@graphql-codegen/cli": "^6.0.2", + "@graphql-codegen/cli": "^6.1.0", "@graphql-codegen/client-preset": "^5.1.1", "@graphql-codegen/typescript-msw": "^3.0.1", "@storybook/addon-docs": "^10.0.8", @@ -57,7 +57,7 @@ "i18next-cli": "^1.24.20", "knip": "^5.66.4", "msw": "^2.11.6", - "msw-storybook-addon": "^2.0.5", + "msw-storybook-addon": "^2.0.6", "postcss": "^8.5.6", "postcss-import": "^16.1.1", "postcss-nesting": "^13.0.2", @@ -163,6 +163,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1035,9 +1036,9 @@ } }, "node_modules/@biomejs/biome": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.2.tgz", - "integrity": "sha512-8e9tzamuDycx7fdrcJ/F/GDZ8SYukc5ud6tDicjjFqURKYFSWMl0H0iXNXZEGmcmNUmABgGuHThPykcM41INgg==", + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.7.tgz", + "integrity": "sha512-CTbAS/jNAiUc6rcq94BrTB8z83O9+BsgWj2sBCQg9rD6Wkh2gjfR87usjx0Ncx0zGXP1NKgT7JNglay5Zfs9jw==", "dev": true, "license": "MIT OR Apache-2.0", "bin": { @@ -1051,20 +1052,20 @@ "url": "https://opencollective.com/biome" }, "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "2.3.2", - "@biomejs/cli-darwin-x64": "2.3.2", - "@biomejs/cli-linux-arm64": "2.3.2", - "@biomejs/cli-linux-arm64-musl": "2.3.2", - "@biomejs/cli-linux-x64": "2.3.2", - "@biomejs/cli-linux-x64-musl": "2.3.2", - "@biomejs/cli-win32-arm64": "2.3.2", - "@biomejs/cli-win32-x64": "2.3.2" + "@biomejs/cli-darwin-arm64": "2.3.7", + "@biomejs/cli-darwin-x64": "2.3.7", + "@biomejs/cli-linux-arm64": "2.3.7", + "@biomejs/cli-linux-arm64-musl": "2.3.7", + "@biomejs/cli-linux-x64": "2.3.7", + "@biomejs/cli-linux-x64-musl": "2.3.7", + "@biomejs/cli-win32-arm64": "2.3.7", + "@biomejs/cli-win32-x64": "2.3.7" } }, "node_modules/@biomejs/cli-darwin-arm64": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.2.tgz", - "integrity": "sha512-4LECm4kc3If0JISai4c3KWQzukoUdpxy4fRzlrPcrdMSRFksR9ZoXK7JBcPuLBmd2SoT4/d7CQS33VnZpgBjew==", + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.7.tgz", + "integrity": "sha512-LirkamEwzIUULhXcf2D5b+NatXKeqhOwilM+5eRkbrnr6daKz9rsBL0kNZ16Hcy4b8RFq22SG4tcLwM+yx/wFA==", "cpu": [ "arm64" ], @@ -1079,9 +1080,9 @@ } }, "node_modules/@biomejs/cli-darwin-x64": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.2.tgz", - "integrity": "sha512-jNMnfwHT4N3wi+ypRfMTjLGnDmKYGzxVr1EYAPBcauRcDnICFXN81wD6wxJcSUrLynoyyYCdfW6vJHS/IAoTDA==", + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.7.tgz", + "integrity": "sha512-Q4TO633kvrMQkKIV7wmf8HXwF0dhdTD9S458LGE24TYgBjSRbuhvio4D5eOQzirEYg6eqxfs53ga/rbdd8nBKg==", "cpu": [ "x64" ], @@ -1096,9 +1097,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.2.tgz", - "integrity": "sha512-amnqvk+gWybbQleRRq8TMe0rIv7GHss8mFJEaGuEZYWg1Tw14YKOkeo8h6pf1c+d3qR+JU4iT9KXnBKGON4klw==", + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.7.tgz", + "integrity": "sha512-inHOTdlstUBzgjDcx0ge71U4SVTbwAljmkfi3MC5WzsYCRhancqfeL+sa4Ke6v2ND53WIwCFD5hGsYExoI3EZQ==", "cpu": [ "arm64" ], @@ -1113,9 +1114,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.2.tgz", - "integrity": "sha512-2Zz4usDG1GTTPQnliIeNx6eVGGP2ry5vE/v39nT73a3cKN6t5H5XxjcEoZZh62uVZvED7hXXikclvI64vZkYqw==", + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.7.tgz", + "integrity": "sha512-/afy8lto4CB8scWfMdt+NoCZtatBUF62Tk3ilWH2w8ENd5spLhM77zKlFZEvsKJv9AFNHknMl03zO67CiklL2Q==", "cpu": [ "arm64" ], @@ -1130,9 +1131,9 @@ } }, "node_modules/@biomejs/cli-linux-x64": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.2.tgz", - "integrity": "sha512-8BG/vRAhFz1pmuyd24FQPhNeueLqPtwvZk6yblABY2gzL2H8fLQAF/Z2OPIc+BPIVPld+8cSiKY/KFh6k81xfA==", + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.7.tgz", + "integrity": "sha512-fJMc3ZEuo/NaMYo5rvoWjdSS5/uVSW+HPRQujucpZqm2ZCq71b8MKJ9U4th9yrv2L5+5NjPF0nqqILCl8HY/fg==", "cpu": [ "x64" ], @@ -1147,9 +1148,9 @@ } }, "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.2.tgz", - "integrity": "sha512-gzB19MpRdTuOuLtPpFBGrV3Lq424gHyq2lFj8wfX9tvLMLdmA/R9C7k/mqBp/spcbWuHeIEKgEs3RviOPcWGBA==", + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.7.tgz", + "integrity": "sha512-CQUtgH1tIN6e5wiYSJqzSwJumHYolNtaj1dwZGCnZXm2PZU1jOJof9TsyiP3bXNDb+VOR7oo7ZvY01If0W3iFQ==", "cpu": [ "x64" ], @@ -1164,9 +1165,9 @@ } }, "node_modules/@biomejs/cli-win32-arm64": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.2.tgz", - "integrity": "sha512-lCruqQlfWjhMlOdyf5pDHOxoNm4WoyY2vZ4YN33/nuZBRstVDuqPPjS0yBkbUlLEte11FbpW+wWSlfnZfSIZvg==", + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.7.tgz", + "integrity": "sha512-aJAE8eCNyRpcfx2JJAtsPtISnELJ0H4xVVSwnxm13bzI8RwbXMyVtxy2r5DV1xT3WiSP+7LxORcApWw0LM8HiA==", "cpu": [ "arm64" ], @@ -1181,9 +1182,9 @@ } }, "node_modules/@biomejs/cli-win32-x64": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.2.tgz", - "integrity": "sha512-6Ee9P26DTb4D8sN9nXxgbi9Dw5vSOfH98M7UlmkjKB2vtUbrRqCbZiNfryGiwnPIpd6YUoTl7rLVD2/x1CyEHQ==", + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.7.tgz", + "integrity": "sha512-pulzUshqv9Ed//MiE8MOUeeEkbkSHVDVY5Cz5wVAnH1DUqliCQG3j6s1POaITTFqFfo7AVIx2sWdKpx/GS+Nqw==", "cpu": [ "x64" ], @@ -1846,6 +1847,7 @@ "resolved": "https://registry.npmjs.org/@fontsource/inconsolata/-/inconsolata-5.2.8.tgz", "integrity": "sha512-lIZW+WOZYpUH91g9r6rYYhfTmptF3YPPM54ZOs8IYVeeL4SeiAu4tfj7mdr8llYEq31DLYgi6JtGIJa192gB0Q==", "license": "OFL-1.1", + "peer": true, "funding": { "url": "https://github.com/sponsors/ayuhito" } @@ -1855,6 +1857,7 @@ "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz", "integrity": "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==", "license": "OFL-1.1", + "peer": true, "funding": { "url": "https://github.com/sponsors/ayuhito" } @@ -1884,18 +1887,18 @@ "license": "0BSD" }, "node_modules/@graphql-codegen/cli": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@graphql-codegen/cli/-/cli-6.0.2.tgz", - "integrity": "sha512-W+0ime0xMrCyG77q+5xiPkkqPLuXJcTx0Zr9TTOxF4zIqWKVsuImS3qVxtpeTx+GRbb8VWv9IedWMtt91JGzQg==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@graphql-codegen/cli/-/cli-6.1.0.tgz", + "integrity": "sha512-7w3Zq5IFONVOBcyOiP01Nv9WRxGS/TEaBCAb/ALYA3xHq95dqKCpoGnxt/Ut9R18jiS+aMgT0gc8Tr8sHy44jA==", "dev": true, "license": "MIT", "dependencies": { "@babel/generator": "^7.18.13", "@babel/template": "^7.18.10", "@babel/types": "^7.18.13", - "@graphql-codegen/client-preset": "^5.1.2", + "@graphql-codegen/client-preset": "^5.2.0", "@graphql-codegen/core": "^5.0.0", - "@graphql-codegen/plugin-helpers": "^6.0.0", + "@graphql-codegen/plugin-helpers": "^6.1.0", "@graphql-tools/apollo-engine-loader": "^8.0.0", "@graphql-tools/code-file-loader": "^8.0.0", "@graphql-tools/git-loader": "^8.0.0", @@ -2144,21 +2147,21 @@ } }, "node_modules/@graphql-codegen/client-preset": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/@graphql-codegen/client-preset/-/client-preset-5.1.3.tgz", - "integrity": "sha512-8nlKt8/gO/BovWahLb96taMssHKPibBfslry1ed9DIJtbOrceFYF3yNbFZuTHmI644C7ZvoYK93JkE3VzDlCyg==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@graphql-codegen/client-preset/-/client-preset-5.2.1.tgz", + "integrity": "sha512-6qFjHQQUWrEH+MVvWs5sPUgme8X+Ivg3WfzaCESooRBQZ4/EnSFlXkPWUTbOKYLRUoMv4g6iTRcZQf6u1wtHZA==", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.20.2", "@babel/template": "^7.20.7", "@graphql-codegen/add": "^6.0.0", - "@graphql-codegen/gql-tag-operations": "5.0.5", - "@graphql-codegen/plugin-helpers": "^6.0.0", - "@graphql-codegen/typed-document-node": "^6.1.2", - "@graphql-codegen/typescript": "^5.0.4", - "@graphql-codegen/typescript-operations": "^5.0.4", - "@graphql-codegen/visitor-plugin-common": "^6.1.2", + "@graphql-codegen/gql-tag-operations": "5.1.1", + "@graphql-codegen/plugin-helpers": "^6.1.0", + "@graphql-codegen/typed-document-node": "^6.1.4", + "@graphql-codegen/typescript": "^5.0.6", + "@graphql-codegen/typescript-operations": "^5.0.6", + "@graphql-codegen/visitor-plugin-common": "^6.2.1", "@graphql-tools/documents": "^1.0.0", "@graphql-tools/utils": "^10.0.0", "@graphql-typed-document-node/core": "3.2.0", @@ -2211,14 +2214,14 @@ "license": "0BSD" }, "node_modules/@graphql-codegen/gql-tag-operations": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/@graphql-codegen/gql-tag-operations/-/gql-tag-operations-5.0.5.tgz", - "integrity": "sha512-DutUBwA3UMOB2AI6O1FDidYw7N0Br4d/ogGrYg6XOeeVuRYigc6i9wX4qiv4ofD34Ujfcfze0U2PI3ZOR33NKw==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@graphql-codegen/gql-tag-operations/-/gql-tag-operations-5.1.1.tgz", + "integrity": "sha512-XewD0XxN2sgKieEIFeGWV5yT5X2aNy+eg+K8bHlUD7QfyrN2bi67rv/O5Edu7LVDOJR69uqVBp++18d742mn3Q==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-codegen/plugin-helpers": "^6.0.0", - "@graphql-codegen/visitor-plugin-common": "6.1.2", + "@graphql-codegen/plugin-helpers": "^6.1.0", + "@graphql-codegen/visitor-plugin-common": "6.2.1", "@graphql-tools/utils": "^10.0.0", "auto-bind": "~4.0.0", "tslib": "~2.6.0" @@ -2238,9 +2241,9 @@ "license": "0BSD" }, "node_modules/@graphql-codegen/plugin-helpers": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@graphql-codegen/plugin-helpers/-/plugin-helpers-6.0.0.tgz", - "integrity": "sha512-Z7P89vViJvQakRyMbq/JF2iPLruRFOwOB6IXsuSvV/BptuuEd7fsGPuEf8bdjjDxUY0pJZnFN8oC7jIQ8p9GKA==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@graphql-codegen/plugin-helpers/-/plugin-helpers-6.1.0.tgz", + "integrity": "sha512-JJypehWTcty9kxKiqH7TQOetkGdOYjY78RHlI+23qB59cV2wxjFFVf8l7kmuXS4cpGVUNfIjFhVr7A1W7JMtdA==", "dev": true, "license": "MIT", "dependencies": { @@ -2291,14 +2294,14 @@ "license": "0BSD" }, "node_modules/@graphql-codegen/typed-document-node": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/@graphql-codegen/typed-document-node/-/typed-document-node-6.1.2.tgz", - "integrity": "sha512-DelLv7BY8Sx0toyCiEsc46W3FtqipiiqhprUnGnSalfKnKVB8KUodXKaf70migy6hWyDl5d1OJOp5wrttuIy2Q==", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/@graphql-codegen/typed-document-node/-/typed-document-node-6.1.4.tgz", + "integrity": "sha512-ITWsA+qvT7R64z7KmYHXfgyD5ff069FAGq/hpR0EWVfzXT4RW1Xn/3Biw7/jvwMGsS1BTjo8ZLSIMNM8KjE3GA==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-codegen/plugin-helpers": "^6.0.0", - "@graphql-codegen/visitor-plugin-common": "6.1.2", + "@graphql-codegen/plugin-helpers": "^6.1.0", + "@graphql-codegen/visitor-plugin-common": "6.2.1", "auto-bind": "~4.0.0", "change-case-all": "1.0.15", "tslib": "~2.6.0" @@ -2318,15 +2321,15 @@ "license": "0BSD" }, "node_modules/@graphql-codegen/typescript": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript/-/typescript-5.0.4.tgz", - "integrity": "sha512-q6S8hX+aR4BzeGgolac4gp22rBnXbLhedmOwT1UBT9e3lGNmNpYC7WJUEzAPjWf6z1lRSNmojLlwEjTnffhKNA==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript/-/typescript-5.0.6.tgz", + "integrity": "sha512-rKW3wYInAnmO/DmKjhW3/KLMxUauUCZuMEPQmuoHChnwIuMjn5kVXCdArGyQqv+vVtFj55aS+sJLN4MPNNjSNg==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-codegen/plugin-helpers": "^6.0.0", + "@graphql-codegen/plugin-helpers": "^6.1.0", "@graphql-codegen/schema-ast": "^5.0.0", - "@graphql-codegen/visitor-plugin-common": "6.1.2", + "@graphql-codegen/visitor-plugin-common": "6.2.1", "auto-bind": "~4.0.0", "tslib": "~2.6.0" }, @@ -2655,15 +2658,15 @@ } }, "node_modules/@graphql-codegen/typescript-operations": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript-operations/-/typescript-operations-5.0.4.tgz", - "integrity": "sha512-5Bu/BTmyNjdSfSLLBKjC0+4XWcY01uotVcnVIWIxxRdIHoRxnTW6PUkT5CoPHP5r/Uoo3OvIJxh+0LYSH5suwA==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript-operations/-/typescript-operations-5.0.6.tgz", + "integrity": "sha512-pkR/82qWO50OHWeV3BiDuVxNFxiJerpmNjFep71VlabADXiU3GIeSaDd6G9a1/SCniVTXZQk2ivCb0ZJiuwo1A==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-codegen/plugin-helpers": "^6.0.0", - "@graphql-codegen/typescript": "^5.0.4", - "@graphql-codegen/visitor-plugin-common": "6.1.2", + "@graphql-codegen/plugin-helpers": "^6.1.0", + "@graphql-codegen/typescript": "^5.0.6", + "@graphql-codegen/visitor-plugin-common": "6.2.1", "auto-bind": "~4.0.0", "tslib": "~2.6.0" }, @@ -2695,13 +2698,13 @@ "license": "0BSD" }, "node_modules/@graphql-codegen/visitor-plugin-common": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/@graphql-codegen/visitor-plugin-common/-/visitor-plugin-common-6.1.2.tgz", - "integrity": "sha512-zYdrhJKgk8kqE1Xz5/m/Ua42zk+rIvYB/FHh3dE1AhZ6b1IDqgKjF3LnkT+K2qenf9EfT4yNjXd5CEKMeXfHyg==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@graphql-codegen/visitor-plugin-common/-/visitor-plugin-common-6.2.1.tgz", + "integrity": "sha512-5QT1hCV3286mrmoIC7vlFXsTlwELMexhuFIkjh+oVGGL1E8hxkIPAU0kfH/lsPbQHKi8zKmic2pl3tAdyYxNyg==", "dev": true, "license": "MIT", "dependencies": { - "@graphql-codegen/plugin-helpers": "^6.0.0", + "@graphql-codegen/plugin-helpers": "^6.1.0", "@graphql-tools/optimize": "^2.0.0", "@graphql-tools/relay-operation-optimizer": "^7.0.0", "@graphql-tools/utils": "^10.0.0", @@ -3215,14 +3218,14 @@ } }, "node_modules/@graphql-tools/relay-operation-optimizer": { - "version": "7.0.25", - "resolved": "https://registry.npmjs.org/@graphql-tools/relay-operation-optimizer/-/relay-operation-optimizer-7.0.25.tgz", - "integrity": "sha512-1S7qq9eyO6ygPNWX2lZd+oxbpl63OhnTTw8+t5OWprM2Tzws9HEosLUpsMR85z1gbezeKtUDt9a2bsSyu4MMFg==", + "version": "7.0.26", + "resolved": "https://registry.npmjs.org/@graphql-tools/relay-operation-optimizer/-/relay-operation-optimizer-7.0.26.tgz", + "integrity": "sha512-cVdS2Hw4hg/WgPVV2wRIzZM975pW5k4vdih3hR4SvEDQVr6MmozmlTQSqzMyi9yg8LKTq540Oz3bYQa286yGmg==", "dev": true, "license": "MIT", "dependencies": { "@ardatan/relay-compiler": "^12.0.3", - "@graphql-tools/utils": "^10.10.3", + "@graphql-tools/utils": "^10.11.0", "tslib": "^2.4.0" }, "engines": { @@ -3278,9 +3281,9 @@ } }, "node_modules/@graphql-tools/utils": { - "version": "10.10.3", - "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-10.10.3.tgz", - "integrity": "sha512-2EdYiefeLLxsoeZTukSNZJ0E/Z5NnWBUGK2VJa0DQj1scDhVd93HeT1eW9TszJOYmIh3eWAKLv58ri/1XUmdsQ==", + "version": "10.11.0", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-10.11.0.tgz", + "integrity": "sha512-iBFR9GXIs0gCD+yc3hoNswViL1O5josI33dUqiNStFI/MHLCEPduasceAcazRH77YONKNiviHBV8f7OgcT4o2Q==", "dev": true, "license": "MIT", "dependencies": { @@ -5808,6 +5811,7 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.10.tgz", "integrity": "sha512-BKLss9Y8PQ9IUjPYQiv3/Zmlx92uxffUOX8ZZNoQlCIZBJPT5M+GOMQj7xislvVQ6l1BstBjcX0XB/aHfFYVNw==", "license": "MIT", + "peer": true, "dependencies": { "@tanstack/query-core": "5.90.10" }, @@ -5842,6 +5846,7 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.131.44.tgz", "integrity": "sha512-LREJfrl8lSedXHCRAAt0HvnHFP9ikAQWnVhYRM++B26w4ZYQBbLvgCT1BCDZVY7MR6rslcd4OfgpZMOyVhNzFg==", "license": "MIT", + "peer": true, "dependencies": { "@tanstack/history": "1.131.2", "@tanstack/react-store": "^0.7.0", @@ -5937,6 +5942,7 @@ "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.131.44.tgz", "integrity": "sha512-Npi9xB3GSYZhRW8+gPhP6bEbyx0vNc8ZNwsi0JapdiFpIiszgRJ57pesy/rklruv46gYQjLVA5KDOsuaCT/urA==", "license": "MIT", + "peer": true, "dependencies": { "@tanstack/history": "1.131.2", "@tanstack/store": "^0.7.0", @@ -6203,8 +6209,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -6295,6 +6300,7 @@ "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -6305,6 +6311,7 @@ "integrity": "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -6315,6 +6322,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -6362,6 +6370,7 @@ "resolved": "https://registry.npmjs.org/@vector-im/compound-design-tokens/-/compound-design-tokens-6.4.0.tgz", "integrity": "sha512-93nYQZMgUt6apjCwwnMhMxN8VYQXN3GYOnwovwJjavImwsCGwI/e853BV/DstrWumYh6k5pZsP9e6AF+nz3SIQ==", "license": "SEE LICENSE IN README.md", + "peer": true, "peerDependencies": { "@types/react": "*", "react": "^17 || ^18 || ^19.0.0" @@ -7056,6 +7065,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.2", "caniuse-lite": "^1.0.30001741", @@ -7619,7 +7629,8 @@ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "devOptional": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", @@ -7790,8 +7801,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dot-case": { "version": "3.0.4", @@ -7892,6 +7902,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -8417,6 +8428,7 @@ "integrity": "sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } @@ -8628,6 +8640,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.4" }, @@ -9779,7 +9792,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -9986,6 +9998,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@inquirer/confirm": "^5.0.0", "@mswjs/interceptors": "^0.40.0", @@ -10025,9 +10038,9 @@ } }, "node_modules/msw-storybook-addon": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/msw-storybook-addon/-/msw-storybook-addon-2.0.5.tgz", - "integrity": "sha512-uum2gtprDBoUb8GV/rPMwPytHmB8+AUr25BQUY0MpjYey5/ujaew2Edt+4oHiXpLTd0ThyMqmEvGy/sRpDV4lg==", + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/msw-storybook-addon/-/msw-storybook-addon-2.0.6.tgz", + "integrity": "sha512-ExCwDbcJoM2V3iQU+fZNp+axVfNc7DWMRh4lyTXebDO8IbpUNYKGFUrA8UqaeWiRGKVuS7+fU+KXEa9b0OP6uA==", "dev": true, "license": "MIT", "dependencies": { @@ -10706,6 +10719,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -10863,6 +10877,7 @@ "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -10900,7 +10915,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -10916,7 +10930,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -10976,6 +10989,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -11017,6 +11031,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -11056,8 +11071,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-refresh": { "version": "0.18.0", @@ -11473,6 +11487,7 @@ "integrity": "sha512-78E9voJHwnXQMiQdiqswVLZwJIzdBKJ1GdI5Zx6XwoFKUIk09/sSrr+05QFzvYb8q6Y9pPV45zzDuYa3907TZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -11592,6 +11607,7 @@ "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.3.2.tgz", "integrity": "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=10" } @@ -11850,6 +11866,7 @@ "integrity": "sha512-vQMufKKA9TxgoEDHJv3esrqUkjszuuRiDkThiHxENFPdQawHhm2Dei+iwNRwH5W671zTDy9iRT9P1KDjcU5Iyw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^1.6.0", @@ -12309,7 +12326,8 @@ "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tiny-warning": { "version": "1.0.3", @@ -12512,6 +12530,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12757,9 +12776,9 @@ "license": "MIT" }, "node_modules/valibot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.1.0.tgz", - "integrity": "sha512-Nk8lX30Qhu+9txPYTwM0cFlWLdPFsFr6LblzqIySfbZph9+BFsAHsNvHOymEviUepeIW6KFHzpX8TKhbptBXXw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz", + "integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==", "license": "MIT", "peerDependencies": { "typescript": ">=5" @@ -12789,6 +12808,7 @@ "integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -12936,6 +12956,7 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -13154,6 +13175,7 @@ "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, diff --git a/frontend/package.json b/frontend/package.json index f3fbe5c4b..45a7d1b77 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -35,15 +35,15 @@ "react-dom": "^19.2.0", "react-i18next": "^16.3.5", "swagger-ui-dist": "^5.29.5", - "valibot": "^1.1.0", + "valibot": "^1.2.0", "vaul": "^1.1.2" }, "devDependencies": { - "@biomejs/biome": "^2.3.2", + "@biomejs/biome": "^2.3.7", "@browser-logos/chrome": "^2.0.0", "@browser-logos/firefox": "^3.0.10", "@browser-logos/safari": "^2.1.0", - "@graphql-codegen/cli": "^6.0.2", + "@graphql-codegen/cli": "^6.1.0", "@graphql-codegen/client-preset": "^5.1.1", "@graphql-codegen/typescript-msw": "^3.0.1", "@storybook/addon-docs": "^10.0.8", @@ -67,7 +67,7 @@ "i18next-cli": "^1.24.20", "knip": "^5.66.4", "msw": "^2.11.6", - "msw-storybook-addon": "^2.0.5", + "msw-storybook-addon": "^2.0.6", "postcss": "^8.5.6", "postcss-import": "^16.1.1", "postcss-nesting": "^13.0.2", diff --git a/policies/Makefile b/policies/Makefile index 0d515b904..db5991672 100644 --- a/policies/Makefile +++ b/policies/Makefile @@ -16,6 +16,7 @@ INPUTS := \ client_registration/client_registration.rego \ register/register.rego \ authorization_grant/authorization_grant.rego \ + compat_login/compat_login.rego \ email/email.rego ifeq ($(DOCKER), 1) @@ -38,6 +39,7 @@ policy.wasm: $(INPUTS) -e "client_registration/violation" \ -e "register/violation" \ -e "authorization_grant/violation" \ + -e "compat_login/violation" \ -e "email/violation" \ $^ tar xzf bundle.tar.gz /policy.wasm diff --git a/policies/compat_login/compat_login.rego b/policies/compat_login/compat_login.rego new file mode 100644 index 000000000..4f76842cd --- /dev/null +++ b/policies/compat_login/compat_login.rego @@ -0,0 +1,74 @@ +# Copyright 2025 Element Creations Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + +# METADATA +# schemas: +# - input: schema["compat_login_input"] +package compat_login + +import rego.v1 + +import data.common + +default allow := false + +allow if { + count(violation) == 0 +} + +violation contains {"msg": sprintf( + "Requester [%s] isn't allowed to do this action", + [common.format_requester(input.requester)], +)} if { + common.requester_banned(input.requester, data.requester) +} + +violation contains { + "code": "too-many-sessions", + "msg": "user has too many active sessions (soft limit)", +} if { + # Only apply if session limits are enabled in the config + data.session_limit != null + + # This is a web-based interactive login + is_interactive + + # Only apply if this login doesn't replace a session + # (As then this login is not actually increasing the number of devices) + not input.session_replaced + + # For web-based 'compat SSO' login, a violation occurs when the soft limit has already been + # reached or exceeded. + # We use the soft limit because the user will be able to interactively remove + # sessions to return under the limit. + data.session_limit.soft_limit <= input.session_counts.total +} + +violation contains { + "code": "too-many-sessions", + "msg": "user has too many active sessions (hard limit)", +} if { + # Only apply if session limits are enabled in the config + data.session_limit != null + + # This is not a web-based interactive login + not is_interactive + + # Only apply if this login doesn't replace a session + # (As then this login is not actually increasing the number of devices) + not input.session_replaced + + # For `m.login.password` login, a violation occurs when the hard limit has already been + # reached or exceeded. + # We don't use the soft limit because the user won't be able to interactively remove + # sessions to return under the limit. + data.session_limit.hard_limit <= input.session_counts.total +} + +is_interactive if { + # Only `m.login.sso` (the interactive web form) is interactive; + # `m.login.password` and `m.login.token` (including the finalisation of an SSO login) are not + input.login.type == "m.login.sso" +} diff --git a/policies/compat_login/compat_login_test.rego b/policies/compat_login/compat_login_test.rego new file mode 100644 index 000000000..1b8049844 --- /dev/null +++ b/policies/compat_login/compat_login_test.rego @@ -0,0 +1,99 @@ +# Copyright 2025 Element Creations Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the repository root for full details. + +package compat_login_test + +import data.compat_login +import rego.v1 + +user := {"username": "john"} + +# Tests session limiting when using (the interactive part of) `m.login.sso` +test_session_limiting_sso if { + compat_login.allow with input.user as user + with input.session_counts as {"total": 1} + with input.login as {"type": "m.login.sso"} + with input.session_replaced as false + with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + + compat_login.allow with input.user as user + with input.session_counts as {"total": 31} + with input.login as {"type": "m.login.sso"} + with input.session_replaced as false + with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + + not compat_login.allow with input.user as user + with input.session_counts as {"total": 32} + with input.login as {"type": "m.login.sso"} + with input.session_replaced as false + with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + + not compat_login.allow with input.user as user + with input.session_counts as {"total": 42} + with input.login as {"type": "m.login.sso"} + with input.session_replaced as false + with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + + not compat_login.allow with input.user as user + with input.session_counts as {"total": 65} + with input.login as {"type": "m.login.sso"} + with input.session_replaced as false + with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + + # No limit configured + compat_login.allow with input.user as user + with input.session_counts as {"total": 1} + with input.login as {"type": "m.login.sso"} + with input.session_replaced as false + with data.session_limit as null +} + +# Test session limiting when using `m.login.password` +test_session_limiting_password if { + compat_login.allow with input.user as user + with input.session_counts as {"total": 1} + with input.login as {"type": "m.login.password"} + with input.session_replaced as false + with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + + compat_login.allow with input.user as user + with input.session_counts as {"total": 63} + with input.login as {"type": "m.login.password"} + with input.session_replaced as false + with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + + not compat_login.allow with input.user as user + with input.session_counts as {"total": 64} + with input.login as {"type": "m.login.password"} + with input.session_replaced as false + with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + + not compat_login.allow with input.user as user + with input.session_counts as {"total": 65} + with input.login as {"type": "m.login.password"} + with input.session_replaced as false + with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + + # No limit configured + compat_login.allow with input.user as user + with input.session_counts as {"total": 1} + with input.login as {"type": "m.login.password"} + with input.session_replaced as false + with data.session_limit as null +} + +test_no_session_limiting_upon_replacement if { + not compat_login.allow with input.user as user + with input.session_counts as {"total": 65} + with input.login as {"type": "m.login.password"} + with input.session_replaced as false + with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + + not compat_login.allow with input.user as user + with input.session_counts as {"total": 65} + with input.login as {"type": "m.login.sso"} + with input.session_replaced as false + with data.session_limit as {"soft_limit": 32, "hard_limit": 64} +} diff --git a/policies/schema/compat_login_input.json b/policies/schema/compat_login_input.json new file mode 100644 index 000000000..ffb182de4 --- /dev/null +++ b/policies/schema/compat_login_input.json @@ -0,0 +1,144 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CompatLoginInput", + "description": "Input for the compatibility login policy.", + "type": "object", + "properties": { + "user": { + "type": "object", + "additionalProperties": true + }, + "session_counts": { + "description": "How many sessions the user has.", + "allOf": [ + { + "$ref": "#/definitions/SessionCounts" + } + ] + }, + "session_replaced": { + "description": "Whether a session will be replaced by this login", + "type": "boolean" + }, + "login": { + "description": "What type of login is being performed.\n This also determines whether the login is interactive.", + "allOf": [ + { + "$ref": "#/definitions/CompatLogin" + } + ] + }, + "requester": { + "$ref": "#/definitions/Requester" + } + }, + "required": [ + "user", + "session_counts", + "session_replaced", + "login", + "requester" + ], + "definitions": { + "SessionCounts": { + "description": "Information about how many sessions the user has", + "type": "object", + "properties": { + "total": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "oauth2": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "compat": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "personal": { + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "total", + "oauth2", + "compat", + "personal" + ] + }, + "CompatLogin": { + "oneOf": [ + { + "description": "Used as the interactive part of SSO login.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "m.login.sso" + }, + "redirect_uri": { + "type": "string" + } + }, + "required": [ + "type", + "redirect_uri" + ] + }, + { + "description": "Used as the final (non-interactive) stage of SSO login.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "m.login.token" + } + }, + "required": [ + "type" + ] + }, + { + "description": "Non-interactive password-over-the-API login.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "m.login.password" + } + }, + "required": [ + "type" + ] + } + ] + }, + "Requester": { + "description": "Identity of the requester", + "type": "object", + "properties": { + "ip_address": { + "description": "IP address of the entity making the request", + "type": [ + "string", + "null" + ], + "format": "ip" + }, + "user_agent": { + "description": "User agent of the entity making the request", + "type": [ + "string", + "null" + ] + } + } + } + } +} \ No newline at end of file diff --git a/templates/pages/compat_login_policy_violation.html b/templates/pages/compat_login_policy_violation.html new file mode 100644 index 000000000..80bb120c2 --- /dev/null +++ b/templates/pages/compat_login_policy_violation.html @@ -0,0 +1,31 @@ +{# +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +-#} + +{% extends "base.html" %} + +{% block content %} +
+
+ {{ icon.error_solid() }} +
+ +
+

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

+

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

+
+
+ +
+
+

+ {{ _("mas.policy_violation.logged_as", username=current_session.user.username) }} +

+ + {{ logout.button(text=_("action.sign_out"), csrf_token=csrf_token, post_logout_action=action, as_link=True) }} +
+
+{% endblock content %} diff --git a/translations/en.json b/translations/en.json index c935542f3..bfbf99f66 100644 --- a/translations/en.json +++ b/translations/en.json @@ -22,7 +22,7 @@ }, "sign_out": "Sign out", "@sign_out": { - "context": "pages/account/logged_out.html:22:28-48, pages/consent.html:65:28-48, pages/device_consent.html:136:30-50, pages/index.html:28:28-48, pages/policy_violation.html:38:28-48, pages/sso.html:45:28-48, pages/upstream_oauth2/link_mismatch.html:24:24-44, pages/upstream_oauth2/suggest_link.html:32:26-46" + "context": "pages/account/logged_out.html:22:28-48, pages/compat_login_policy_violation.html:28:28-48, pages/consent.html:65:28-48, pages/device_consent.html:136:30-50, pages/index.html:28:28-48, pages/policy_violation.html:38:28-48, pages/sso.html:45:28-48, pages/upstream_oauth2/link_mismatch.html:24:24-44, pages/upstream_oauth2/suggest_link.html:32:26-46" }, "skip": "Skip", "@skip": { @@ -496,17 +496,17 @@ "policy_violation": { "description": "This might be because of the client which authored the request, the currently logged in user, or the request itself.", "@description": { - "context": "pages/policy_violation.html:19:25-62", + "context": "pages/compat_login_policy_violation.html:18:25-62, pages/policy_violation.html:19:25-62", "description": "Displayed when an authorization request is denied by the policy" }, "heading": "The authorization request was denied by the policy enforced by this service", "@heading": { - "context": "pages/policy_violation.html:18:27-60", + "context": "pages/compat_login_policy_violation.html:17:27-60, pages/policy_violation.html:18:27-60", "description": "Displayed when an authorization request is denied by the policy" }, "logged_as": "Logged as %(username)s", "@logged_as": { - "context": "pages/policy_violation.html:35:11-86" + "context": "pages/compat_login_policy_violation.html:25:11-86, pages/policy_violation.html:35:11-86" } }, "recovery": {