Merge remote-tracking branch 'origin/main' into quenting/upstream-oauth/skip-interactive

This commit is contained in:
Quentin Gliech
2025-12-03 10:48:31 +01:00
36 changed files with 873 additions and 203 deletions

View File

@@ -46,7 +46,7 @@ jobs:
steps:
- name: Checkout the code
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
# Need a full clone so that `git describe` reports the right version
fetch-depth: 0
@@ -67,7 +67,7 @@ jobs:
steps:
- name: Checkout the code
uses: actions/checkout@v5
uses: actions/checkout@v6
- uses: ./.github/actions/build-frontend
- uses: ./.github/actions/build-policies
@@ -112,7 +112,7 @@ jobs:
steps:
- name: Checkout the code
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
@@ -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

View File

@@ -34,7 +34,7 @@ jobs:
steps:
- name: Checkout the code
uses: actions/checkout@v5
uses: actions/checkout@v6
- uses: ./.github/actions/build-policies
@@ -61,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

View File

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

View File

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

View File

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

View File

@@ -34,7 +34,7 @@ jobs:
run: exit 1
- name: Checkout the code
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
@@ -61,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

View File

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

View File

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

View File

@@ -19,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 }}

View File

@@ -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

5
Cargo.lock generated
View File

@@ -1089,9 +1089,9 @@ checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "convert_case"
version = "0.8.0"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f"
checksum = "db05ffb6856bf0ecdf6367558a76a0e8a77b1713044eb92845c692100ed50190"
dependencies = [
"unicode-segmentation",
]
@@ -3723,6 +3723,7 @@ dependencies = [
"mas-data-model",
"mas-i18n",
"mas-iana",
"mas-policy",
"mas-router",
"mas-spa",
"minijinja",

View File

@@ -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]

View File

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

View File

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

View File

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

View File

@@ -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<Templates>,
State(url_builder): State<UrlBuilder>,
mut policy: Policy,
activity_tracker: BoundActivityTracker,
user_agent: Option<TypedHeader<headers::UserAgent>>,
cookie_jar: CookieJar,
Path(id): Path<Ulid>,
Query(params): Query<Params>,
) -> Result<Response, InternalError> {
let user_agent = user_agent.map(|ua| ua.to_string());
let (cookie_jar, maybe_session) = match load_session_or_fallback(
cookie_jar, &clock, &mut rng, &templates, &locale, &mut repo,
)
@@ -107,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<Templates>,
State(url_builder): State<UrlBuilder>,
mut policy: Policy,
activity_tracker: BoundActivityTracker,
user_agent: Option<TypedHeader<headers::UserAgent>>,
cookie_jar: CookieJar,
Path(id): Path<Ulid>,
Query(params): Query<Params>,
Form(form): Form<ProtectedForm<()>>,
) -> Result<Response, InternalError> {
let user_agent = user_agent.map(|ua| ua.to_string());
let (cookie_jar, maybe_session) = match load_session_or_fallback(
cookie_jar, &clock, &mut rng, &templates, &locale, &mut repo,
)
@@ -200,6 +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()

View File

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

View File

@@ -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(),
};

View File

@@ -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);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<Violation>,
}
impl TemplateContext for CompatLoginPolicyViolationContext {
fn sample<R: Rng>(
_now: chrono::DateTime<Utc>,
_rng: &mut R,
_locales: &[DataLocale],
) -> BTreeMap<SampleIdentifier, Self>
where
Self: Sized,
{
sample_list(vec![
CompatLoginPolicyViolationContext { violations: vec![] },
CompatLoginPolicyViolationContext {
violations: vec![Violation {
msg: "user has too many active sessions".to_owned(),
redirect_uri: None,
field: None,
code: Some(ViolationCode::TooManySessions),
}],
},
])
}
}
impl CompatLoginPolicyViolationContext {
/// Constructs a context for the compatibility login policy violation page
/// given the list of violations
#[must_use]
pub const fn for_violations(violations: Vec<Violation>) -> Self {
Self { violations }
}
}
/// Context used by the `sso.html` template
#[derive(Serialize)]
pub struct CompatSsoContext {

View File

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

View File

@@ -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"

View File

@@ -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"
},

View File

@@ -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",

View File

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

View File

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

View File

@@ -0,0 +1,99 @@
# Copyright 2025 Element Creations Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
# Please see LICENSE files in the repository root for full details.
package compat_login_test
import data.compat_login
import rego.v1
user := {"username": "john"}
# Tests session limiting when using (the interactive part of) `m.login.sso`
test_session_limiting_sso if {
compat_login.allow with input.user as user
with input.session_counts as {"total": 1}
with input.login as {"type": "m.login.sso"}
with input.session_replaced as false
with data.session_limit as {"soft_limit": 32, "hard_limit": 64}
compat_login.allow with input.user as user
with input.session_counts as {"total": 31}
with input.login as {"type": "m.login.sso"}
with input.session_replaced as false
with data.session_limit as {"soft_limit": 32, "hard_limit": 64}
not compat_login.allow with input.user as user
with input.session_counts as {"total": 32}
with input.login as {"type": "m.login.sso"}
with input.session_replaced as false
with data.session_limit as {"soft_limit": 32, "hard_limit": 64}
not compat_login.allow with input.user as user
with input.session_counts as {"total": 42}
with input.login as {"type": "m.login.sso"}
with input.session_replaced as false
with data.session_limit as {"soft_limit": 32, "hard_limit": 64}
not compat_login.allow with input.user as user
with input.session_counts as {"total": 65}
with input.login as {"type": "m.login.sso"}
with input.session_replaced as false
with data.session_limit as {"soft_limit": 32, "hard_limit": 64}
# No limit configured
compat_login.allow with input.user as user
with input.session_counts as {"total": 1}
with input.login as {"type": "m.login.sso"}
with input.session_replaced as false
with data.session_limit as null
}
# Test session limiting when using `m.login.password`
test_session_limiting_password if {
compat_login.allow with input.user as user
with input.session_counts as {"total": 1}
with input.login as {"type": "m.login.password"}
with input.session_replaced as false
with data.session_limit as {"soft_limit": 32, "hard_limit": 64}
compat_login.allow with input.user as user
with input.session_counts as {"total": 63}
with input.login as {"type": "m.login.password"}
with input.session_replaced as false
with data.session_limit as {"soft_limit": 32, "hard_limit": 64}
not compat_login.allow with input.user as user
with input.session_counts as {"total": 64}
with input.login as {"type": "m.login.password"}
with input.session_replaced as false
with data.session_limit as {"soft_limit": 32, "hard_limit": 64}
not compat_login.allow with input.user as user
with input.session_counts as {"total": 65}
with input.login as {"type": "m.login.password"}
with input.session_replaced as false
with data.session_limit as {"soft_limit": 32, "hard_limit": 64}
# No limit configured
compat_login.allow with input.user as user
with input.session_counts as {"total": 1}
with input.login as {"type": "m.login.password"}
with input.session_replaced as false
with data.session_limit as null
}
test_no_session_limiting_upon_replacement if {
not compat_login.allow with input.user as user
with input.session_counts as {"total": 65}
with input.login as {"type": "m.login.password"}
with input.session_replaced as false
with data.session_limit as {"soft_limit": 32, "hard_limit": 64}
not compat_login.allow with input.user as user
with input.session_counts as {"total": 65}
with input.login as {"type": "m.login.sso"}
with input.session_replaced as false
with data.session_limit as {"soft_limit": 32, "hard_limit": 64}
}

View File

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

View File

@@ -0,0 +1,31 @@
{#
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
-#}
{% extends "base.html" %}
{% block content %}
<header class="page-heading">
<div class="icon invalid">
{{ icon.error_solid() }}
</div>
<div class="header">
<h1 class="title">{{ _("mas.policy_violation.heading") }}</h1>
<p class="text">{{ _("mas.policy_violation.description") }}</p>
</div>
</header>
<main class="flex flex-col gap-10">
<div class="flex gap-1 justify-center items-center">
<p class="cpd-text-secondary cpd-text-body-md-regular">
{{ _("mas.policy_violation.logged_as", username=current_session.user.username) }}
</p>
{{ logout.button(text=_("action.sign_out"), csrf_token=csrf_token, post_logout_action=action, as_link=True) }}
</div>
</main>
{% endblock content %}

View File

@@ -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 <span class=\"font-semibold\">%(username)s</span>",
"@logged_as": {
"context": "pages/policy_violation.html:35:11-86"
"context": "pages/compat_login_policy_violation.html:25:11-86, pages/policy_violation.html:35:11-86"
}
},
"recovery": {