From dc535d74514e785e721ec44221a7c669db15200b Mon Sep 17 00:00:00 2001 From: Olivier 'reivilibre Date: Thu, 30 Oct 2025 16:30:33 +0000 Subject: [PATCH 01/14] Add configuration for session limiting --- crates/cli/src/util.rs | 9 ++++++- crates/config/src/sections/experimental.rs | 15 ++++++++++++ crates/data-model/src/lib.rs | 4 +++- crates/data-model/src/site_config.rs | 10 ++++++++ crates/handlers/src/test_utils.rs | 1 + docs/config.schema.json | 28 ++++++++++++++++++++++ 6 files changed, 65 insertions(+), 2 deletions(-) diff --git a/crates/cli/src/util.rs b/crates/cli/src/util.rs index 4925d9866..eefbb5b0e 100644 --- a/crates/cli/src/util.rs +++ b/crates/cli/src/util.rs @@ -13,7 +13,7 @@ use mas_config::{ PolicyConfig, TemplatesConfig, }; use mas_context::LogContext; -use mas_data_model::{SessionExpirationConfig, SiteConfig}; +use mas_data_model::{SessionExpirationConfig, SessionLimitConfig, SiteConfig}; use mas_email::{MailTransport, Mailer}; use mas_handlers::passwords::PasswordManager; use mas_matrix::{HomeserverConnection, ReadOnlyHomeserverConnection}; @@ -225,6 +225,13 @@ pub fn site_config_from_config( session_expiration, login_with_email_allowed: account_config.login_with_email_allowed, plan_management_iframe_uri: experimental_config.plan_management_iframe_uri.clone(), + session_limit: experimental_config + .session_limit + .as_ref() + .map(|c| SessionLimitConfig { + soft_limit: c.soft_limit, + hard_limit: c.hard_limit, + }), }) } diff --git a/crates/config/src/sections/experimental.rs b/crates/config/src/sections/experimental.rs index c6c50e88d..7824aad33 100644 --- a/crates/config/src/sections/experimental.rs +++ b/crates/config/src/sections/experimental.rs @@ -81,6 +81,13 @@ pub struct ExperimentalConfig { /// validation. #[serde(skip_serializing_if = "Option::is_none")] pub plan_management_iframe_uri: Option, + + /// Experimental feature to limit the number of application sessions per + /// user. + /// + /// Disabled by default. + #[serde(skip_serializing_if = "Option::is_none")] + pub session_limit: Option, } impl Default for ExperimentalConfig { @@ -90,6 +97,7 @@ impl Default for ExperimentalConfig { compat_token_ttl: default_token_ttl(), inactive_session_expiration: None, plan_management_iframe_uri: None, + session_limit: None, } } } @@ -106,3 +114,10 @@ impl ExperimentalConfig { impl ConfigurationSection for ExperimentalConfig { const PATH: Option<&'static str> = Some("experimental"); } + +/// Configuration options for the inactive session expiration feature +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +pub struct SessionLimitConfig { + pub soft_limit: u64, + pub hard_limit: u64, +} diff --git a/crates/data-model/src/lib.rs b/crates/data-model/src/lib.rs index 962c8be00..fd5c0e633 100644 --- a/crates/data-model/src/lib.rs +++ b/crates/data-model/src/lib.rs @@ -39,7 +39,9 @@ pub use self::{ DeviceCodeGrantState, InvalidRedirectUriError, JwksOrJwksUri, Pkce, Session, SessionState, }, policy_data::PolicyData, - site_config::{CaptchaConfig, CaptchaService, SessionExpirationConfig, SiteConfig}, + site_config::{ + CaptchaConfig, CaptchaService, SessionExpirationConfig, SessionLimitConfig, SiteConfig, + }, tokens::{ AccessToken, AccessTokenState, RefreshToken, RefreshTokenState, TokenFormatError, TokenType, }, diff --git a/crates/data-model/src/site_config.rs b/crates/data-model/src/site_config.rs index 9622203ad..96736b4f7 100644 --- a/crates/data-model/src/site_config.rs +++ b/crates/data-model/src/site_config.rs @@ -5,6 +5,7 @@ // Please see LICENSE files in the repository root for full details. use chrono::Duration; +use serde::Serialize; use url::Url; /// Which Captcha service is being used @@ -36,6 +37,12 @@ pub struct SessionExpirationConfig { pub compat_session_inactivity_ttl: Option, } +#[derive(Serialize, Debug, Clone)] +pub struct SessionLimitConfig { + pub soft_limit: u64, + pub hard_limit: u64, +} + /// Random site configuration we want accessible in various places. #[allow(clippy::struct_excessive_bools)] #[derive(Debug, Clone)] @@ -99,4 +106,7 @@ pub struct SiteConfig { /// The iframe URL to show in the plan tab of the UI pub plan_management_iframe_uri: Option, + + /// Limits on the number of application sessions that each user can have + pub session_limit: Option, } diff --git a/crates/handlers/src/test_utils.rs b/crates/handlers/src/test_utils.rs index f1859f352..df60c5c20 100644 --- a/crates/handlers/src/test_utils.rs +++ b/crates/handlers/src/test_utils.rs @@ -148,6 +148,7 @@ pub fn test_site_config() -> SiteConfig { session_expiration: None, login_with_email_allowed: true, plan_management_iframe_uri: None, + session_limit: None, } } diff --git a/docs/config.schema.json b/docs/config.schema.json index 524f02c93..41b40ad41 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -2659,6 +2659,14 @@ "plan_management_iframe_uri": { "description": "Experimental feature to show a plan management tab and iframe. This value is passed through \"as is\" to the client without any validation.", "type": "string" + }, + "session_limit": { + "description": "Experimental feature to limit the number of application sessions per user.\n\nDisabled by default.", + "allOf": [ + { + "$ref": "#/definitions/SessionLimitConfig" + } + ] } } }, @@ -2692,6 +2700,26 @@ "type": "boolean" } } + }, + "SessionLimitConfig": { + "description": "Configuration options for the inactive session expiration feature", + "type": "object", + "required": [ + "hard_limit", + "soft_limit" + ], + "properties": { + "soft_limit": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "hard_limit": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } } } } \ No newline at end of file From 1c056bfdadf2264cd1b1864ce13dd9e37a4869c0 Mon Sep 17 00:00:00 2001 From: Olivier 'reivilibre Date: Thu, 6 Nov 2025 08:16:20 +0000 Subject: [PATCH 02/14] Add SessionCounts struct for use in policy --- crates/policy/src/model.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/policy/src/model.rs b/crates/policy/src/model.rs index 2f54ae8bb..8f778f3d1 100644 --- a/crates/policy/src/model.rs +++ b/crates/policy/src/model.rs @@ -179,6 +179,16 @@ pub struct AuthorizationGrantInput<'a> { pub requester: Requester, } +/// Information about how many sessions the user has +#[derive(Serialize, Debug, JsonSchema)] +pub struct SessionCounts { + pub total: u64, + + pub oauth2: u64, + pub compat: u64, + pub personal: u64, +} + /// Input for the email add policy. #[derive(Serialize, Debug, JsonSchema)] #[serde(rename_all = "snake_case")] From db54d90a321e4ea3ca3035f2f214d83bd9c2bb5e Mon Sep 17 00:00:00 2001 From: Olivier 'reivilibre Date: Tue, 4 Nov 2025 15:05:46 +0000 Subject: [PATCH 03/14] Add helper function to count user sessions for limiting --- crates/handlers/src/session.rs | 67 +++++++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 2 deletions(-) diff --git a/crates/handlers/src/session.rs b/crates/handlers/src/session.rs index cb05510ba..8aeae3a22 100644 --- a/crates/handlers/src/session.rs +++ b/crates/handlers/src/session.rs @@ -8,9 +8,13 @@ use axum::response::{Html, IntoResponse as _, Response}; use mas_axum_utils::{SessionInfoExt, cookies::CookieJar, csrf::CsrfExt}; -use mas_data_model::{BrowserSession, Clock}; +use mas_data_model::{BrowserSession, Clock, User}; use mas_i18n::DataLocale; -use mas_storage::{BoxRepository, RepositoryError}; +use mas_policy::model::SessionCounts; +use mas_storage::{ + BoxRepository, RepositoryError, compat::CompatSessionFilter, oauth2::OAuth2SessionFilter, + personal::PersonalSessionFilter, +}; use mas_templates::{AccountInactiveContext, TemplateContext, Templates}; use rand::RngCore; use thiserror::Error; @@ -102,3 +106,62 @@ pub async fn load_session_or_fallback( maybe_session: Some(session), }) } + +/// Get a count of sessions for the given user, for the purposes of session +/// limiting. +/// +/// Includes: +/// - OAuth 2 sessions +/// - Compatibility sessions +/// - Personal sessions (unless owned by a different user) +/// +/// # Backstory +/// +/// Originally, we were only intending to count sessions with devices in this +/// result, because those are the entries that are expensive for Synapse and +/// also would not hinder use of deviceless clients (like Element Admin, an +/// admin dashboard). +/// +/// However, to do so, we would need to count only sessions including device +/// scopes. To do this efficiently, we'd need a partial index on sessions +/// including device scopes. +/// +/// It turns out that this can't be done cleanly (as we need to, in Postgres, +/// match scope lists where one of the scopes matches one of 2 known prefixes), +/// at least not without somewhat uncomfortable stored functions. +/// +/// So for simplicity's sake, we now count all sessions. +/// For practical use cases, it's not likely to make a noticeable difference +/// (and maybe it's good that there's an overall limit). +pub(crate) async fn count_user_sessions_for_limiting( + repo: &mut BoxRepository, + user: &User, +) -> anyhow::Result { + let oauth2 = repo + .oauth2_session() + .count(OAuth2SessionFilter::new().active_only().for_user(user)) + .await? as u64; + + let compat = repo + .compat_session() + .count(CompatSessionFilter::new().active_only().for_user(user)) + .await? as u64; + + // Only include self-owned personal sessions, not administratively-owned ones + let personal = repo + .personal_session() + .count( + PersonalSessionFilter::new() + .active_only() + .for_actor_user(user) + .for_owner_user(user), + ) + .await? as u64; + + Ok(SessionCounts { + total: oauth2 + compat + personal, + oauth2, + compat, + personal, + }) +} From 7ee32e796aca426423742a24d84bb2c97e7194e1 Mon Sep 17 00:00:00 2001 From: Olivier 'reivilibre Date: Thu, 6 Nov 2025 08:17:03 +0000 Subject: [PATCH 04/14] Add session limit config to policy data --- crates/cli/src/commands/debug.rs | 9 +++++++-- crates/cli/src/commands/server.rs | 4 +++- crates/cli/src/util.rs | 14 ++++++++++++-- crates/handlers/src/test_utils.rs | 2 +- crates/policy/src/lib.rs | 24 +++++++++++++++--------- 5 files changed, 38 insertions(+), 15 deletions(-) diff --git a/crates/cli/src/commands/debug.rs b/crates/cli/src/commands/debug.rs index bb87c5e81..6da64f95b 100644 --- a/crates/cli/src/commands/debug.rs +++ b/crates/cli/src/commands/debug.rs @@ -9,7 +9,8 @@ use std::process::ExitCode; use clap::Parser; use figment::Figment; use mas_config::{ - ConfigurationSection, ConfigurationSectionExt, DatabaseConfig, MatrixConfig, PolicyConfig, + ConfigurationSection, ConfigurationSectionExt, DatabaseConfig, ExperimentalConfig, + MatrixConfig, PolicyConfig, }; use mas_storage_pg::PgRepositoryFactory; use tracing::{info, info_span}; @@ -45,8 +46,12 @@ impl Options { PolicyConfig::extract_or_default(figment).map_err(anyhow::Error::from_boxed)?; let matrix_config = MatrixConfig::extract(figment).map_err(anyhow::Error::from_boxed)?; + let experimental_config = + ExperimentalConfig::extract(figment).map_err(anyhow::Error::from_boxed)?; info!("Loading and compiling the policy module"); - let policy_factory = policy_factory_from_config(&config, &matrix_config).await?; + let policy_factory = + policy_factory_from_config(&config, &matrix_config, &experimental_config) + .await?; if with_dynamic_data { let database_config = diff --git a/crates/cli/src/commands/server.rs b/crates/cli/src/commands/server.rs index 52465f077..020d24d0f 100644 --- a/crates/cli/src/commands/server.rs +++ b/crates/cli/src/commands/server.rs @@ -132,7 +132,9 @@ impl Options { // Load and compile the WASM policies (and fallback to the default embedded one) info!("Loading and compiling the policy module"); - let policy_factory = policy_factory_from_config(&config.policy, &config.matrix).await?; + let policy_factory = + policy_factory_from_config(&config.policy, &config.matrix, &config.experimental) + .await?; let policy_factory = Arc::new(policy_factory); load_policy_factory_dynamic_data_continuously( diff --git a/crates/cli/src/util.rs b/crates/cli/src/util.rs index eefbb5b0e..a9b9a3132 100644 --- a/crates/cli/src/util.rs +++ b/crates/cli/src/util.rs @@ -135,6 +135,7 @@ pub fn test_mailer_in_background(mailer: &Mailer, timeout: Duration) { pub async fn policy_factory_from_config( config: &PolicyConfig, matrix_config: &MatrixConfig, + experimental_config: &ExperimentalConfig, ) -> Result { let policy_file = tokio::fs::File::open(&config.wasm_module) .await @@ -147,8 +148,17 @@ pub async fn policy_factory_from_config( email: config.email_entrypoint.clone(), }; - let data = - mas_policy::Data::new(matrix_config.homeserver.clone()).with_rest(config.data.clone()); + let session_limit_config = + experimental_config + .session_limit + .as_ref() + .map(|c| SessionLimitConfig { + soft_limit: c.soft_limit, + hard_limit: c.hard_limit, + }); + + let data = mas_policy::Data::new(matrix_config.homeserver.clone(), session_limit_config) + .with_rest(config.data.clone()); PolicyFactory::load(policy_file, data, entrypoints) .await diff --git a/crates/handlers/src/test_utils.rs b/crates/handlers/src/test_utils.rs index df60c5c20..cf0466a9c 100644 --- a/crates/handlers/src/test_utils.rs +++ b/crates/handlers/src/test_utils.rs @@ -85,7 +85,7 @@ pub(crate) async fn policy_factory( email: "email/violation".to_owned(), }; - let data = mas_policy::Data::new(server_name.to_owned()).with_rest(data); + let data = mas_policy::Data::new(server_name.to_owned(), None).with_rest(data); let policy_factory = PolicyFactory::load(file, data, entrypoints).await?; let policy_factory = Arc::new(policy_factory); diff --git a/crates/policy/src/lib.rs b/crates/policy/src/lib.rs index 3a3a23c3f..b5b187e1e 100644 --- a/crates/policy/src/lib.rs +++ b/crates/policy/src/lib.rs @@ -9,11 +9,12 @@ pub mod model; use std::sync::Arc; use arc_swap::ArcSwap; -use mas_data_model::Ulid; +use mas_data_model::{SessionLimitConfig, Ulid}; use opa_wasm::{ Runtime, wasmtime::{Config, Engine, Module, OptLevel, Store}, }; +use serde::Serialize; use thiserror::Error; use tokio::io::{AsyncRead, AsyncReadExt}; @@ -85,18 +86,25 @@ impl Entrypoints { } } -#[derive(Debug)] +#[derive(Serialize, Debug)] pub struct Data { server_name: String, + /// Limits on the number of application sessions that each user can have + session_limit: Option, + + // We will merge this in a custom way, so don't emit as part of the base + #[serde(skip)] rest: Option, } impl Data { #[must_use] - pub fn new(server_name: String) -> Self { + pub fn new(server_name: String, session_limit: Option) -> Self { Self { server_name, + session_limit, + rest: None, } } @@ -108,9 +116,7 @@ impl Data { } fn to_value(&self) -> Result { - let base = serde_json::json!({ - "server_name": self.server_name, - }); + let base = serde_json::to_value(self)?; if let Some(rest) = &self.rest { merge_data(base, rest.clone()) @@ -458,7 +464,7 @@ mod tests { #[tokio::test] async fn test_register() { - let data = Data::new("example.com".to_owned()).with_rest(serde_json::json!({ + let data = Data::new("example.com".to_owned(), None).with_rest(serde_json::json!({ "allowed_domains": ["element.io", "*.element.io"], "banned_domains": ["staging.element.io"], })); @@ -528,7 +534,7 @@ mod tests { #[tokio::test] async fn test_dynamic_data() { - let data = Data::new("example.com".to_owned()); + let data = Data::new("example.com".to_owned(), None); #[allow(clippy::disallowed_types)] let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) @@ -597,7 +603,7 @@ mod tests { #[tokio::test] async fn test_big_dynamic_data() { - let data = Data::new("example.com".to_owned()); + let data = Data::new("example.com".to_owned(), None); #[allow(clippy::disallowed_types)] let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) From cb5ea26792eff8d0a635359de34753860f068882 Mon Sep 17 00:00:00 2001 From: Olivier 'reivilibre Date: Thu, 6 Nov 2025 08:44:31 +0000 Subject: [PATCH 05/14] Add session counts to policy input --- .../src/oauth2/authorization/consent.rs | 12 +++++- crates/handlers/src/oauth2/device/consent.rs | 12 +++++- crates/handlers/src/oauth2/token.rs | 1 + crates/policy/src/model.rs | 4 ++ .../schema/authorization_grant_input.json | 40 +++++++++++++++++++ 5 files changed, 67 insertions(+), 2 deletions(-) diff --git a/crates/handlers/src/oauth2/authorization/consent.rs b/crates/handlers/src/oauth2/authorization/consent.rs index 968aec08a..9e8491141 100644 --- a/crates/handlers/src/oauth2/authorization/consent.rs +++ b/crates/handlers/src/oauth2/authorization/consent.rs @@ -32,7 +32,7 @@ use super::callback::CallbackDestination; use crate::{ BoundActivityTracker, PreferredLanguage, impl_from_error_for_route, oauth2::generate_id_token, - session::{SessionOrFallback, load_session_or_fallback}, + session::{SessionOrFallback, count_user_sessions_for_limiting, load_session_or_fallback}, }; #[derive(Debug, Error)] @@ -136,10 +136,15 @@ pub(crate) async fn get( let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); + let session_counts = count_user_sessions_for_limiting(&mut repo, &session.user) + .await + .map_err(|e| RouteError::Internal(e.into()))?; + let res = policy .evaluate_authorization_grant(mas_policy::AuthorizationGrantInput { user: Some(&session.user), client: &client, + session_counts: Some(session_counts), scope: &grant.scope, grant_type: mas_policy::GrantType::AuthorizationCode, requester: mas_policy::Requester { @@ -235,10 +240,15 @@ pub(crate) async fn post( return Err(RouteError::GrantNotPending(grant.id)); } + let session_counts = count_user_sessions_for_limiting(&mut repo, &browser_session.user) + .await + .map_err(|e| RouteError::Internal(e.into()))?; + let res = policy .evaluate_authorization_grant(mas_policy::AuthorizationGrantInput { user: Some(&browser_session.user), client: &client, + session_counts: Some(session_counts), scope: &grant.scope, grant_type: mas_policy::GrantType::AuthorizationCode, requester: mas_policy::Requester { diff --git a/crates/handlers/src/oauth2/device/consent.rs b/crates/handlers/src/oauth2/device/consent.rs index 30a35aa17..22cf1fca0 100644 --- a/crates/handlers/src/oauth2/device/consent.rs +++ b/crates/handlers/src/oauth2/device/consent.rs @@ -27,7 +27,7 @@ use ulid::Ulid; use crate::{ BoundActivityTracker, PreferredLanguage, - session::{SessionOrFallback, load_session_or_fallback}, + session::{SessionOrFallback, count_user_sessions_for_limiting, load_session_or_fallback}, }; #[derive(Deserialize, Debug)] @@ -103,11 +103,16 @@ pub(crate) async fn get( .context("Client not found") .map_err(InternalError::from_anyhow)?; + let session_counts = count_user_sessions_for_limiting(&mut repo, &session.user) + .await + .map_err(InternalError::from_anyhow)?; + // Evaluate the policy let res = policy .evaluate_authorization_grant(mas_policy::AuthorizationGrantInput { grant_type: mas_policy::GrantType::DeviceCode, client: &client, + session_counts: Some(session_counts), scope: &grant.scope, user: Some(&session.user), requester: mas_policy::Requester { @@ -205,11 +210,16 @@ pub(crate) async fn post( .context("Client not found") .map_err(InternalError::from_anyhow)?; + let session_counts = count_user_sessions_for_limiting(&mut repo, &session.user) + .await + .map_err(InternalError::from_anyhow)?; + // Evaluate the policy let res = policy .evaluate_authorization_grant(mas_policy::AuthorizationGrantInput { grant_type: mas_policy::GrantType::DeviceCode, client: &client, + session_counts: Some(session_counts), scope: &grant.scope, user: Some(&session.user), requester: mas_policy::Requester { diff --git a/crates/handlers/src/oauth2/token.rs b/crates/handlers/src/oauth2/token.rs index 4a63d8290..99506ac29 100644 --- a/crates/handlers/src/oauth2/token.rs +++ b/crates/handlers/src/oauth2/token.rs @@ -781,6 +781,7 @@ async fn client_credentials_grant( .evaluate_authorization_grant(mas_policy::AuthorizationGrantInput { user: None, client, + session_counts: None, scope: &scope, grant_type: mas_policy::GrantType::ClientCredentials, requester: mas_policy::Requester { diff --git a/crates/policy/src/model.rs b/crates/policy/src/model.rs index 8f778f3d1..9977d6653 100644 --- a/crates/policy/src/model.rs +++ b/crates/policy/src/model.rs @@ -168,6 +168,10 @@ pub struct AuthorizationGrantInput<'a> { #[schemars(with = "Option>")] pub user: Option<&'a User>, + /// How many sessions the user has. + /// Not populated if it's not a user logging in. + pub session_counts: Option, + #[schemars(with = "std::collections::HashMap")] pub client: &'a Client, diff --git a/policies/schema/authorization_grant_input.json b/policies/schema/authorization_grant_input.json index f23bf7a73..a5d49e304 100644 --- a/policies/schema/authorization_grant_input.json +++ b/policies/schema/authorization_grant_input.json @@ -14,6 +14,14 @@ "type": "object", "additionalProperties": true }, + "session_counts": { + "description": "How many sessions the user has. Not populated if it's not a user logging in.", + "allOf": [ + { + "$ref": "#/definitions/SessionCounts" + } + ] + }, "client": { "type": "object", "additionalProperties": true @@ -29,6 +37,38 @@ } }, "definitions": { + "SessionCounts": { + "description": "Information about how many sessions the user has", + "type": "object", + "required": [ + "compat", + "oauth2", + "personal", + "total" + ], + "properties": { + "total": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "oauth2": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "compat": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "personal": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + }, "GrantType": { "type": "string", "enum": [ From ea2506d2c70cbc40616bfb10f9798c391c499d66 Mon Sep 17 00:00:00 2001 From: Olivier 'reivilibre Date: Thu, 6 Nov 2025 08:44:57 +0000 Subject: [PATCH 06/14] Add TooManySessions violation code --- crates/policy/src/model.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/policy/src/model.rs b/crates/policy/src/model.rs index 9977d6653..b85170025 100644 --- a/crates/policy/src/model.rs +++ b/crates/policy/src/model.rs @@ -49,6 +49,9 @@ pub enum Code { /// The email address is banned. EmailBanned, + + /// The user has reached their session limit. + TooManySessions, } impl Code { @@ -66,6 +69,7 @@ impl Code { Self::EmailDomainBanned => "email-domain-banned", Self::EmailNotAllowed => "email-not-allowed", Self::EmailBanned => "email-banned", + Self::TooManySessions => "too-many-sessions", } } } From f599728f21c9c505b5dde85a4cc47fcc4e7069b7 Mon Sep 17 00:00:00 2001 From: Olivier 'reivilibre Date: Thu, 6 Nov 2025 08:57:23 +0000 Subject: [PATCH 07/14] Add policy violation for too many devices --- .../authorization_grant.rego | 17 ++++++++++ .../authorization_grant_test.rego | 32 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/policies/authorization_grant/authorization_grant.rego b/policies/authorization_grant/authorization_grant.rego index 79f737af1..e7d1e68e5 100644 --- a/policies/authorization_grant/authorization_grant.rego +++ b/policies/authorization_grant/authorization_grant.rego @@ -153,3 +153,20 @@ violation contains {"msg": sprintf( )} if { common.requester_banned(input.requester, data.requester) } + +violation contains { + "code": "too-many-sessions", + "msg": "user has too many active sessions", +} if { + # Only apply if session limits are enabled in the config + data.session_limit != null + + # Only apply if it's a user logging in (who therefore has countable sessions) + input.session_counts != null + + # For OAuth 2 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 +} diff --git a/policies/authorization_grant/authorization_grant_test.rego b/policies/authorization_grant/authorization_grant_test.rego index 6634eacb9..e2ca74086 100644 --- a/policies/authorization_grant/authorization_grant_test.rego +++ b/policies/authorization_grant/authorization_grant_test.rego @@ -222,3 +222,35 @@ test_mas_scopes if { with input.grant_type as "authorization_code" with input.scope as "urn:mas:admin" } + +test_session_limiting if { + authorization_grant.allow with input.user as user + with input.session_counts as {"total": 1} + with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + + authorization_grant.allow with input.user as user + with input.session_counts as {"total": 31} + with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + + not authorization_grant.allow with input.user as user + with input.session_counts as {"total": 32} + with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + + not authorization_grant.allow with input.user as user + with input.session_counts as {"total": 42} + with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + + not authorization_grant.allow with input.user as user + with input.session_counts as {"total": 65} + with data.session_limit as {"soft_limit": 32, "hard_limit": 64} + + # No limit configured + authorization_grant.allow with input.user as user + with input.session_counts as {"total": 1} + with data.session_limit as null + + # Client credentials grant + authorization_grant.allow with input.user as user + with input.session_counts as null + with data.session_limit as {"soft_limit": 32, "hard_limit": 64} +} From b137e49b998bcc5b236367527ae96b00f618577e Mon Sep 17 00:00:00 2001 From: Olivier 'reivilibre Date: Thu, 6 Nov 2025 09:07:10 +0000 Subject: [PATCH 08/14] drive-by english string fix --- translations/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/translations/en.json b/translations/en.json index 5881171fb..e551e15c2 100644 --- a/translations/en.json +++ b/translations/en.json @@ -499,7 +499,7 @@ "context": "pages/policy_violation.html:19:25-62", "description": "Displayed when an authorization request is denied by the policy" }, - "heading": "The authorization request was denied the policy enforced by this service", + "heading": "The authorization request was denied by the policy enforced by this service", "@heading": { "context": "pages/policy_violation.html:18:27-60", "description": "Displayed when an authorization request is denied by the policy" From 78db4a11fe9affd35bee12255097c7a1acaf8c2a Mon Sep 17 00:00:00 2001 From: Olivier 'reivilibre Date: Thu, 6 Nov 2025 15:01:49 +0000 Subject: [PATCH 09/14] Use NonZeroU64 --- crates/config/src/sections/experimental.rs | 6 ++++-- crates/data-model/src/site_config.rs | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/config/src/sections/experimental.rs b/crates/config/src/sections/experimental.rs index 7824aad33..b14bcb5a0 100644 --- a/crates/config/src/sections/experimental.rs +++ b/crates/config/src/sections/experimental.rs @@ -4,6 +4,8 @@ // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // Please see LICENSE files in the repository root for full details. +use std::num::NonZeroU64; + use chrono::Duration; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -118,6 +120,6 @@ impl ConfigurationSection for ExperimentalConfig { /// Configuration options for the inactive session expiration feature #[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] pub struct SessionLimitConfig { - pub soft_limit: u64, - pub hard_limit: u64, + pub soft_limit: NonZeroU64, + pub hard_limit: NonZeroU64, } diff --git a/crates/data-model/src/site_config.rs b/crates/data-model/src/site_config.rs index 96736b4f7..bb92dc3e4 100644 --- a/crates/data-model/src/site_config.rs +++ b/crates/data-model/src/site_config.rs @@ -4,6 +4,8 @@ // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // Please see LICENSE files in the repository root for full details. +use std::num::NonZeroU64; + use chrono::Duration; use serde::Serialize; use url::Url; @@ -39,8 +41,8 @@ pub struct SessionExpirationConfig { #[derive(Serialize, Debug, Clone)] pub struct SessionLimitConfig { - pub soft_limit: u64, - pub hard_limit: u64, + pub soft_limit: NonZeroU64, + pub hard_limit: NonZeroU64, } /// Random site configuration we want accessible in various places. From 24152a47cbcc752148190b2407fb7374f3a5aacd Mon Sep 17 00:00:00 2001 From: Olivier 'reivilibre Date: Thu, 6 Nov 2025 15:02:04 +0000 Subject: [PATCH 10/14] Make explicit the data...base --- crates/policy/src/lib.rs | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/crates/policy/src/lib.rs b/crates/policy/src/lib.rs index b5b187e1e..8a038aea8 100644 --- a/crates/policy/src/lib.rs +++ b/crates/policy/src/lib.rs @@ -86,24 +86,30 @@ impl Entrypoints { } } -#[derive(Serialize, Debug)] +#[derive(Debug)] pub struct Data { + base: BaseData, + + // We will merge this in a custom way, so don't emit as part of the base + rest: Option, +} + +#[derive(Serialize, Debug)] +struct BaseData { server_name: String, /// Limits on the number of application sessions that each user can have session_limit: Option, - - // We will merge this in a custom way, so don't emit as part of the base - #[serde(skip)] - rest: Option, } impl Data { #[must_use] pub fn new(server_name: String, session_limit: Option) -> Self { Self { - server_name, - session_limit, + base: BaseData { + server_name, + session_limit, + }, rest: None, } @@ -116,7 +122,7 @@ impl Data { } fn to_value(&self) -> Result { - let base = serde_json::to_value(self)?; + let base = serde_json::to_value(&self.base)?; if let Some(rest) = &self.rest { merge_data(base, rest.clone()) From 184c2845ea1c2c06618abf1a0a42c11ca1751c1d Mon Sep 17 00:00:00 2001 From: Olivier 'reivilibre Date: Thu, 6 Nov 2025 15:05:40 +0000 Subject: [PATCH 11/14] Pass out RepositoryError --- crates/handlers/src/oauth2/authorization/consent.rs | 8 ++------ crates/handlers/src/oauth2/device/consent.rs | 8 ++------ crates/handlers/src/session.rs | 2 +- 3 files changed, 5 insertions(+), 13 deletions(-) diff --git a/crates/handlers/src/oauth2/authorization/consent.rs b/crates/handlers/src/oauth2/authorization/consent.rs index 9e8491141..2587828b5 100644 --- a/crates/handlers/src/oauth2/authorization/consent.rs +++ b/crates/handlers/src/oauth2/authorization/consent.rs @@ -136,9 +136,7 @@ pub(crate) async fn get( let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); - let session_counts = count_user_sessions_for_limiting(&mut repo, &session.user) - .await - .map_err(|e| RouteError::Internal(e.into()))?; + let session_counts = count_user_sessions_for_limiting(&mut repo, &session.user).await?; let res = policy .evaluate_authorization_grant(mas_policy::AuthorizationGrantInput { @@ -240,9 +238,7 @@ pub(crate) async fn post( return Err(RouteError::GrantNotPending(grant.id)); } - let session_counts = count_user_sessions_for_limiting(&mut repo, &browser_session.user) - .await - .map_err(|e| RouteError::Internal(e.into()))?; + let session_counts = count_user_sessions_for_limiting(&mut repo, &browser_session.user).await?; let res = policy .evaluate_authorization_grant(mas_policy::AuthorizationGrantInput { diff --git a/crates/handlers/src/oauth2/device/consent.rs b/crates/handlers/src/oauth2/device/consent.rs index 22cf1fca0..e1d32870f 100644 --- a/crates/handlers/src/oauth2/device/consent.rs +++ b/crates/handlers/src/oauth2/device/consent.rs @@ -103,9 +103,7 @@ pub(crate) async fn get( .context("Client not found") .map_err(InternalError::from_anyhow)?; - let session_counts = count_user_sessions_for_limiting(&mut repo, &session.user) - .await - .map_err(InternalError::from_anyhow)?; + let session_counts = count_user_sessions_for_limiting(&mut repo, &session.user).await?; // Evaluate the policy let res = policy @@ -210,9 +208,7 @@ pub(crate) async fn post( .context("Client not found") .map_err(InternalError::from_anyhow)?; - let session_counts = count_user_sessions_for_limiting(&mut repo, &session.user) - .await - .map_err(InternalError::from_anyhow)?; + let session_counts = count_user_sessions_for_limiting(&mut repo, &session.user).await?; // Evaluate the policy let res = policy diff --git a/crates/handlers/src/session.rs b/crates/handlers/src/session.rs index 8aeae3a22..aa3836a26 100644 --- a/crates/handlers/src/session.rs +++ b/crates/handlers/src/session.rs @@ -136,7 +136,7 @@ pub async fn load_session_or_fallback( pub(crate) async fn count_user_sessions_for_limiting( repo: &mut BoxRepository, user: &User, -) -> anyhow::Result { +) -> Result { let oauth2 = repo .oauth2_session() .count(OAuth2SessionFilter::new().active_only().for_user(user)) From 29ab273e5ab3c7120dd947b1b48a05da4f9efd0c Mon Sep 17 00:00:00 2001 From: reivilibre Date: Thu, 6 Nov 2025 15:11:51 +0000 Subject: [PATCH 12/14] Update crates/config/src/sections/experimental.rs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- crates/config/src/sections/experimental.rs | 2 +- docs/config.schema.json | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/config/src/sections/experimental.rs b/crates/config/src/sections/experimental.rs index b14bcb5a0..3ae1020f7 100644 --- a/crates/config/src/sections/experimental.rs +++ b/crates/config/src/sections/experimental.rs @@ -117,7 +117,7 @@ impl ConfigurationSection for ExperimentalConfig { const PATH: Option<&'static str> = Some("experimental"); } -/// Configuration options for the inactive session expiration feature +/// Configuration options for the session limit feature #[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] pub struct SessionLimitConfig { pub soft_limit: NonZeroU64, diff --git a/docs/config.schema.json b/docs/config.schema.json index 41b40ad41..409d49fdf 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -2702,7 +2702,7 @@ } }, "SessionLimitConfig": { - "description": "Configuration options for the inactive session expiration feature", + "description": "Configuration options for the session limit feature", "type": "object", "required": [ "hard_limit", @@ -2712,12 +2712,12 @@ "soft_limit": { "type": "integer", "format": "uint64", - "minimum": 0.0 + "minimum": 1.0 }, "hard_limit": { "type": "integer", "format": "uint64", - "minimum": 0.0 + "minimum": 1.0 } } } From bcb9a04597e44e914aa5c4ae2f1b949a1219c286 Mon Sep 17 00:00:00 2001 From: Olivier 'reivilibre Date: Thu, 6 Nov 2025 15:12:40 +0000 Subject: [PATCH 13/14] also update is_default --- crates/config/src/sections/experimental.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/config/src/sections/experimental.rs b/crates/config/src/sections/experimental.rs index 3ae1020f7..b8f3920b0 100644 --- a/crates/config/src/sections/experimental.rs +++ b/crates/config/src/sections/experimental.rs @@ -110,6 +110,7 @@ impl ExperimentalConfig { && is_default_token_ttl(&self.compat_token_ttl) && self.inactive_session_expiration.is_none() && self.plan_management_iframe_uri.is_none() + && self.session_limit.is_none() } } From c007695e045e01af8cd66b9de12442a7b3ca45f4 Mon Sep 17 00:00:00 2001 From: Olivier 'reivilibre Date: Thu, 13 Nov 2025 15:55:25 +0000 Subject: [PATCH 14/14] (update files after merge) --- docs/config.schema.json | 2883 +++++++++++++++++ .../schema/authorization_grant_input.json | 29 +- 2 files changed, 2899 insertions(+), 13 deletions(-) diff --git a/docs/config.schema.json b/docs/config.schema.json index e69de29bb..b2e74a6f1 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -0,0 +1,2883 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "RootConfig", + "description": "Application configuration root", + "type": "object", + "properties": { + "clients": { + "description": "List of OAuth 2.0/OIDC clients config", + "type": "array", + "items": { + "$ref": "#/definitions/ClientConfig" + } + }, + "http": { + "description": "Configuration of the HTTP server", + "default": { + "listeners": [ + { + "name": "web", + "resources": [ + { + "name": "discovery" + }, + { + "name": "human" + }, + { + "name": "oauth" + }, + { + "name": "compat" + }, + { + "name": "graphql" + }, + { + "name": "assets" + } + ], + "binds": [ + { + "address": "[::]:8080" + } + ], + "proxy_protocol": false + }, + { + "name": "internal", + "resources": [ + { + "name": "health" + } + ], + "binds": [ + { + "host": "localhost", + "port": 8081 + } + ], + "proxy_protocol": false + } + ], + "trusted_proxies": [ + "192.168.0.0/16", + "172.16.0.0/12", + "10.0.0.0/10", + "127.0.0.1/8", + "fd00::/8", + "::1/128" + ], + "public_base": "http://[::]:8080/", + "issuer": "http://[::]:8080/" + }, + "allOf": [ + { + "$ref": "#/definitions/HttpConfig" + } + ] + }, + "database": { + "description": "Database connection configuration", + "default": { + "uri": "postgresql://", + "max_connections": 10, + "min_connections": 0, + "connect_timeout": 30, + "idle_timeout": 600, + "max_lifetime": 1800 + }, + "allOf": [ + { + "$ref": "#/definitions/DatabaseConfig" + } + ] + }, + "telemetry": { + "description": "Configuration related to sending monitoring data", + "allOf": [ + { + "$ref": "#/definitions/TelemetryConfig" + } + ] + }, + "templates": { + "description": "Configuration related to templates", + "allOf": [ + { + "$ref": "#/definitions/TemplatesConfig" + } + ] + }, + "email": { + "description": "Configuration related to sending emails", + "default": { + "from": "\"Authentication Service\" ", + "reply_to": "\"Authentication Service\" ", + "transport": "blackhole" + }, + "allOf": [ + { + "$ref": "#/definitions/EmailConfig" + } + ] + }, + "secrets": { + "description": "Application secrets", + "allOf": [ + { + "$ref": "#/definitions/SecretsConfig" + } + ] + }, + "passwords": { + "description": "Configuration related to user passwords", + "default": { + "enabled": true, + "schemes": [ + { + "version": 1, + "algorithm": "argon2id" + } + ], + "minimum_complexity": 3 + }, + "allOf": [ + { + "$ref": "#/definitions/PasswordsConfig" + } + ] + }, + "matrix": { + "description": "Configuration related to the homeserver", + "allOf": [ + { + "$ref": "#/definitions/MatrixConfig" + } + ] + }, + "policy": { + "description": "Configuration related to the OPA policies", + "allOf": [ + { + "$ref": "#/definitions/PolicyConfig" + } + ] + }, + "rate_limiting": { + "description": "Configuration related to limiting the rate of user actions to prevent\n abuse", + "allOf": [ + { + "$ref": "#/definitions/RateLimitingConfig" + } + ] + }, + "upstream_oauth2": { + "description": "Configuration related to upstream OAuth providers", + "allOf": [ + { + "$ref": "#/definitions/UpstreamOAuth2Config" + } + ] + }, + "branding": { + "description": "Configuration section for tweaking the branding of the service", + "allOf": [ + { + "$ref": "#/definitions/BrandingConfig" + } + ] + }, + "captcha": { + "description": "Configuration section to setup CAPTCHA protection on a few operations", + "allOf": [ + { + "$ref": "#/definitions/CaptchaConfig" + } + ] + }, + "account": { + "description": "Configuration section to configure features related to account\n management", + "allOf": [ + { + "$ref": "#/definitions/AccountConfig" + } + ] + }, + "experimental": { + "description": "Experimental configuration options", + "allOf": [ + { + "$ref": "#/definitions/ExperimentalConfig" + } + ] + } + }, + "required": [ + "secrets", + "matrix" + ], + "definitions": { + "ClientConfig": { + "description": "An OAuth 2.0 client configuration", + "type": "object", + "properties": { + "client_id": { + "description": "A ULID as per https://github.com/ulid/spec", + "type": "string", + "pattern": "^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}$" + }, + "client_auth_method": { + "description": "Authentication method used for this client", + "allOf": [ + { + "$ref": "#/definitions/ClientAuthMethodConfig" + } + ] + }, + "client_name": { + "description": "Name of the `OAuth2` client", + "type": [ + "string", + "null" + ] + }, + "client_secret_file": { + "description": "Path to the file containing the client secret. The client secret is used\n by the `client_secret_basic`, `client_secret_post` and\n `client_secret_jwt` authentication methods.", + "type": [ + "string", + "null" + ] + }, + "client_secret": { + "description": "Alternative to `client_secret_file`: Reads the client secret directly\n from the config.", + "type": [ + "string", + "null" + ] + }, + "jwks": { + "description": "The JSON Web Key Set (JWKS) used by the `private_key_jwt` authentication\n method. Mutually exclusive with `jwks_uri`", + "anyOf": [ + { + "$ref": "#/definitions/JsonWebKeySet_for_JsonWebKeyPublicParameters" + }, + { + "type": "null" + } + ] + }, + "jwks_uri": { + "description": "The URL of the JSON Web Key Set (JWKS) used by the `private_key_jwt`\n authentication method. Mutually exclusive with `jwks`", + "type": [ + "string", + "null" + ], + "format": "uri" + }, + "redirect_uris": { + "description": "List of allowed redirect URIs", + "type": "array", + "items": { + "type": "string", + "format": "uri" + } + } + }, + "required": [ + "client_id", + "client_auth_method" + ] + }, + "ClientAuthMethodConfig": { + "description": "Authentication method used by clients", + "oneOf": [ + { + "description": "`none`: No authentication", + "type": "string", + "const": "none" + }, + { + "description": "`client_secret_basic`: `client_id` and `client_secret` used as basic\n authorization credentials", + "type": "string", + "const": "client_secret_basic" + }, + { + "description": "`client_secret_post`: `client_id` and `client_secret` sent in the\n request body", + "type": "string", + "const": "client_secret_post" + }, + { + "description": "`client_secret_basic`: a `client_assertion` sent in the request body and\n signed using the `client_secret`", + "type": "string", + "const": "client_secret_jwt" + }, + { + "description": "`client_secret_basic`: a `client_assertion` sent in the request body and\n signed by an asymmetric key", + "type": "string", + "const": "private_key_jwt" + } + ] + }, + "JsonWebKeySet_for_JsonWebKeyPublicParameters": { + "type": "object", + "properties": { + "keys": { + "type": "array", + "items": { + "$ref": "#/definitions/JsonWebKey_for_JsonWebKeyPublicParameters" + } + } + }, + "required": [ + "keys" + ] + }, + "JsonWebKey_for_JsonWebKeyPublicParameters": { + "type": "object", + "properties": { + "use": { + "anyOf": [ + { + "$ref": "#/definitions/JsonWebKeyUse" + }, + { + "type": "null" + } + ] + }, + "key_ops": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/JsonWebKeyOperation" + } + }, + "alg": { + "anyOf": [ + { + "$ref": "#/definitions/JsonWebSignatureAlg" + }, + { + "type": "null" + } + ] + }, + "kid": { + "type": [ + "string", + "null" + ] + }, + "x5u": { + "type": [ + "string", + "null" + ] + }, + "x5c": { + "type": "array", + "items": { + "type": "string" + } + }, + "x5t": { + "type": [ + "string", + "null" + ] + }, + "x5t#S256": { + "type": [ + "string", + "null" + ] + } + }, + "oneOf": [ + { + "type": "object", + "properties": { + "kty": { + "type": "string", + "const": "RSA" + } + }, + "required": [ + "kty" + ], + "allOf": [ + { + "$ref": "#/definitions/RsaPublicParameters" + } + ] + }, + { + "type": "object", + "properties": { + "kty": { + "type": "string", + "const": "EC" + } + }, + "required": [ + "kty" + ], + "allOf": [ + { + "$ref": "#/definitions/EcPublicParameters" + } + ] + }, + { + "type": "object", + "properties": { + "kty": { + "type": "string", + "const": "OKP" + } + }, + "required": [ + "kty" + ], + "allOf": [ + { + "$ref": "#/definitions/OkpPublicParameters" + } + ] + } + ] + }, + "RsaPublicParameters": { + "type": "object", + "properties": { + "n": { + "type": "string" + }, + "e": { + "type": "string" + } + }, + "required": [ + "n", + "e" + ] + }, + "JsonWebKeyEcEllipticCurve": { + "description": "JSON Web Key EC Elliptic Curve", + "anyOf": [ + { + "description": "P-256 Curve", + "const": "P-256" + }, + { + "description": "P-384 Curve", + "const": "P-384" + }, + { + "description": "P-521 Curve", + "const": "P-521" + }, + { + "description": "SECG secp256k1 curve", + "const": "secp256k1" + } + ] + }, + "EcPublicParameters": { + "type": "object", + "properties": { + "crv": { + "$ref": "#/definitions/JsonWebKeyEcEllipticCurve" + }, + "x": { + "type": "string" + }, + "y": { + "type": "string" + } + }, + "required": [ + "crv", + "x", + "y" + ] + }, + "JsonWebKeyOkpEllipticCurve": { + "description": "JSON Web Key OKP Elliptic Curve", + "anyOf": [ + { + "description": "Ed25519 signature algorithm key pairs", + "const": "Ed25519" + }, + { + "description": "Ed448 signature algorithm key pairs", + "const": "Ed448" + }, + { + "description": "X25519 function key pairs", + "const": "X25519" + }, + { + "description": "X448 function key pairs", + "const": "X448" + } + ] + }, + "OkpPublicParameters": { + "type": "object", + "properties": { + "crv": { + "$ref": "#/definitions/JsonWebKeyOkpEllipticCurve" + }, + "x": { + "type": "string" + } + }, + "required": [ + "crv", + "x" + ] + }, + "JsonWebKeyUse": { + "description": "JSON Web Key Use", + "anyOf": [ + { + "description": "Digital Signature or MAC", + "const": "sig" + }, + { + "description": "Encryption", + "const": "enc" + } + ] + }, + "JsonWebKeyOperation": { + "description": "JSON Web Key Operation", + "anyOf": [ + { + "description": "Compute digital signature or MAC", + "const": "sign" + }, + { + "description": "Verify digital signature or MAC", + "const": "verify" + }, + { + "description": "Encrypt content", + "const": "encrypt" + }, + { + "description": "Decrypt content and validate decryption, if applicable", + "const": "decrypt" + }, + { + "description": "Encrypt key", + "const": "wrapKey" + }, + { + "description": "Decrypt key and validate decryption, if applicable", + "const": "unwrapKey" + }, + { + "description": "Derive key", + "const": "deriveKey" + }, + { + "description": "Derive bits not to be used as a key", + "const": "deriveBits" + } + ] + }, + "JsonWebSignatureAlg": { + "description": "JSON Web Signature \"alg\" parameter", + "anyOf": [ + { + "description": "HMAC using SHA-256", + "const": "HS256" + }, + { + "description": "HMAC using SHA-384", + "const": "HS384" + }, + { + "description": "HMAC using SHA-512", + "const": "HS512" + }, + { + "description": "RSASSA-PKCS1-v1_5 using SHA-256", + "const": "RS256" + }, + { + "description": "RSASSA-PKCS1-v1_5 using SHA-384", + "const": "RS384" + }, + { + "description": "RSASSA-PKCS1-v1_5 using SHA-512", + "const": "RS512" + }, + { + "description": "ECDSA using P-256 and SHA-256", + "const": "ES256" + }, + { + "description": "ECDSA using P-384 and SHA-384", + "const": "ES384" + }, + { + "description": "ECDSA using P-521 and SHA-512", + "const": "ES512" + }, + { + "description": "RSASSA-PSS using SHA-256 and MGF1 with SHA-256", + "const": "PS256" + }, + { + "description": "RSASSA-PSS using SHA-384 and MGF1 with SHA-384", + "const": "PS384" + }, + { + "description": "RSASSA-PSS using SHA-512 and MGF1 with SHA-512", + "const": "PS512" + }, + { + "description": "No digital signature or MAC performed", + "const": "none" + }, + { + "description": "EdDSA signature algorithms", + "const": "EdDSA" + }, + { + "description": "ECDSA using secp256k1 curve and SHA-256", + "const": "ES256K" + }, + { + "description": "EdDSA using Ed25519 curve", + "const": "Ed25519" + }, + { + "description": "EdDSA using Ed448 curve", + "const": "Ed448" + } + ] + }, + "HttpConfig": { + "description": "Configuration related to the web server", + "type": "object", + "properties": { + "listeners": { + "description": "List of listeners to run", + "type": "array", + "items": { + "$ref": "#/definitions/ListenerConfig" + }, + "default": [] + }, + "trusted_proxies": { + "description": "List of trusted reverse proxies that can set the `X-Forwarded-For`\n header", + "type": "array", + "items": { + "type": "string", + "format": "ip" + }, + "default": [ + "192.168.0.0/16", + "172.16.0.0/12", + "10.0.0.0/10", + "127.0.0.1/8", + "fd00::/8", + "::1/128" + ] + }, + "public_base": { + "description": "Public URL base from where the authentication service is reachable", + "type": "string", + "format": "uri" + }, + "issuer": { + "description": "OIDC issuer URL. Defaults to `public_base` if not set.", + "type": [ + "string", + "null" + ], + "format": "uri" + } + }, + "required": [ + "public_base" + ] + }, + "ListenerConfig": { + "description": "Configuration of a listener", + "type": "object", + "properties": { + "name": { + "description": "A unique name for this listener which will be shown in traces and in\n metrics labels", + "type": [ + "string", + "null" + ] + }, + "resources": { + "description": "List of resources to mount", + "type": "array", + "items": { + "$ref": "#/definitions/Resource" + } + }, + "prefix": { + "description": "HTTP prefix to mount the resources on", + "type": [ + "string", + "null" + ] + }, + "binds": { + "description": "List of sockets to bind", + "type": "array", + "items": { + "$ref": "#/definitions/BindConfig" + } + }, + "proxy_protocol": { + "description": "Accept `HAProxy`'s Proxy Protocol V1", + "type": "boolean", + "default": false + }, + "tls": { + "description": "If set, makes the listener use TLS with the provided certificate and key", + "anyOf": [ + { + "$ref": "#/definitions/TlsConfig" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "resources", + "binds" + ] + }, + "Resource": { + "description": "HTTP resources to mount", + "oneOf": [ + { + "description": "Healthcheck endpoint (/health)", + "type": "object", + "properties": { + "name": { + "type": "string", + "const": "health" + } + }, + "required": [ + "name" + ] + }, + { + "description": "Prometheus metrics endpoint (/metrics)", + "type": "object", + "properties": { + "name": { + "type": "string", + "const": "prometheus" + } + }, + "required": [ + "name" + ] + }, + { + "description": "OIDC discovery endpoints", + "type": "object", + "properties": { + "name": { + "type": "string", + "const": "discovery" + } + }, + "required": [ + "name" + ] + }, + { + "description": "Pages destined to be viewed by humans", + "type": "object", + "properties": { + "name": { + "type": "string", + "const": "human" + } + }, + "required": [ + "name" + ] + }, + { + "description": "GraphQL endpoint", + "type": "object", + "properties": { + "playground": { + "description": "Enabled the GraphQL playground", + "type": "boolean" + }, + "undocumented_oauth2_access": { + "description": "Allow access for OAuth 2.0 clients (undocumented)", + "type": "boolean" + }, + "name": { + "type": "string", + "const": "graphql" + } + }, + "required": [ + "name" + ] + }, + { + "description": "OAuth-related APIs", + "type": "object", + "properties": { + "name": { + "type": "string", + "const": "oauth" + } + }, + "required": [ + "name" + ] + }, + { + "description": "Matrix compatibility API", + "type": "object", + "properties": { + "name": { + "type": "string", + "const": "compat" + } + }, + "required": [ + "name" + ] + }, + { + "description": "Static files", + "type": "object", + "properties": { + "path": { + "description": "Path to the directory to serve.", + "type": "string" + }, + "name": { + "type": "string", + "const": "assets" + } + }, + "required": [ + "name" + ] + }, + { + "description": "Admin API, served at `/api/admin/v1`", + "type": "object", + "properties": { + "name": { + "type": "string", + "const": "adminapi" + } + }, + "required": [ + "name" + ] + }, + { + "description": "Mount a \"/connection-info\" handler which helps debugging informations on\n the upstream connection", + "type": "object", + "properties": { + "name": { + "type": "string", + "const": "connection-info" + } + }, + "required": [ + "name" + ] + } + ] + }, + "BindConfig": { + "description": "Configuration of a single listener", + "anyOf": [ + { + "description": "Listen on the specified host and port", + "type": "object", + "properties": { + "host": { + "description": "Host on which to listen.\n\n Defaults to listening on all addresses", + "type": [ + "string", + "null" + ] + }, + "port": { + "description": "Port on which to listen.", + "type": "integer", + "format": "uint16", + "minimum": 0, + "maximum": 65535 + } + }, + "required": [ + "port" + ] + }, + { + "description": "Listen on the specified address", + "type": "object", + "properties": { + "address": { + "description": "Host and port on which to listen", + "type": "string", + "examples": [ + "[::1]:8080", + "[::]:8080", + "127.0.0.1:8080", + "0.0.0.0:8080" + ] + } + }, + "required": [ + "address" + ] + }, + { + "description": "Listen on a UNIX domain socket", + "type": "object", + "properties": { + "socket": { + "description": "Path to the socket", + "type": "string" + } + }, + "required": [ + "socket" + ] + }, + { + "description": "Accept connections on file descriptors passed by the parent process.\n\n This is useful for grabbing sockets passed by systemd.\n\n See ", + "type": "object", + "properties": { + "fd": { + "description": "Index of the file descriptor. Note that this is offseted by 3\n because of the standard input/output sockets, so setting\n here a value of `0` will grab the file descriptor `3`", + "type": "integer", + "format": "uint", + "minimum": 0, + "default": 0 + }, + "kind": { + "description": "Whether the socket is a TCP socket or a UNIX domain socket. Defaults\n to TCP.", + "default": "tcp", + "allOf": [ + { + "$ref": "#/definitions/UnixOrTcp" + } + ] + } + } + } + ] + }, + "UnixOrTcp": { + "description": "Kind of socket", + "oneOf": [ + { + "description": "UNIX domain socket", + "type": "string", + "const": "unix" + }, + { + "description": "TCP socket", + "type": "string", + "const": "tcp" + } + ] + }, + "TlsConfig": { + "description": "Configuration related to TLS on a listener", + "type": "object", + "properties": { + "certificate": { + "description": "PEM-encoded X509 certificate chain\n\n Exactly one of `certificate` or `certificate_file` must be set.", + "type": [ + "string", + "null" + ] + }, + "certificate_file": { + "description": "File containing the PEM-encoded X509 certificate chain\n\n Exactly one of `certificate` or `certificate_file` must be set.", + "type": [ + "string", + "null" + ] + }, + "key": { + "description": "PEM-encoded private key\n\n Exactly one of `key` or `key_file` must be set.", + "type": [ + "string", + "null" + ] + }, + "key_file": { + "description": "File containing a PEM or DER-encoded private key\n\n Exactly one of `key` or `key_file` must be set.", + "type": [ + "string", + "null" + ] + }, + "password": { + "description": "Password used to decode the private key\n\n One of `password` or `password_file` must be set if the key is\n encrypted.", + "type": [ + "string", + "null" + ] + }, + "password_file": { + "description": "Password file used to decode the private key\n\n One of `password` or `password_file` must be set if the key is\n encrypted.", + "type": [ + "string", + "null" + ] + } + } + }, + "DatabaseConfig": { + "description": "Database connection configuration", + "type": "object", + "properties": { + "uri": { + "description": "Connection URI\n\n This must not be specified if `host`, `port`, `socket`, `username`,\n `password`, or `database` are specified.", + "type": [ + "string", + "null" + ], + "format": "uri", + "default": "postgresql://" + }, + "host": { + "description": "Name of host to connect to\n\n This must not be specified if `uri` is specified.", + "anyOf": [ + { + "$ref": "#/definitions/Hostname" + }, + { + "type": "null" + } + ] + }, + "port": { + "description": "Port number to connect at the server host\n\n This must not be specified if `uri` is specified.", + "type": [ + "integer", + "null" + ], + "format": "uint16", + "minimum": 1, + "maximum": 65535 + }, + "socket": { + "description": "Directory containing the UNIX socket to connect to\n\n This must not be specified if `uri` is specified.", + "type": [ + "string", + "null" + ] + }, + "username": { + "description": "PostgreSQL user name to connect as\n\n This must not be specified if `uri` is specified.", + "type": [ + "string", + "null" + ] + }, + "password": { + "description": "Password to be used if the server demands password authentication\n\n This must not be specified if `uri` is specified.", + "type": [ + "string", + "null" + ] + }, + "database": { + "description": "The database name\n\n This must not be specified if `uri` is specified.", + "type": [ + "string", + "null" + ] + }, + "ssl_mode": { + "description": "How to handle SSL connections", + "anyOf": [ + { + "$ref": "#/definitions/PgSslMode" + }, + { + "type": "null" + } + ] + }, + "ssl_ca": { + "description": "The PEM-encoded root certificate for SSL connections\n\n This must not be specified if the `ssl_ca_file` option is specified.", + "type": [ + "string", + "null" + ] + }, + "ssl_ca_file": { + "description": "Path to the root certificate for SSL connections\n\n This must not be specified if the `ssl_ca` option is specified.", + "type": [ + "string", + "null" + ] + }, + "ssl_certificate": { + "description": "The PEM-encoded client certificate for SSL connections\n\n This must not be specified if the `ssl_certificate_file` option is\n specified.", + "type": [ + "string", + "null" + ] + }, + "ssl_certificate_file": { + "description": "Path to the client certificate for SSL connections\n\n This must not be specified if the `ssl_certificate` option is specified.", + "type": [ + "string", + "null" + ] + }, + "ssl_key": { + "description": "The PEM-encoded client key for SSL connections\n\n This must not be specified if the `ssl_key_file` option is specified.", + "type": [ + "string", + "null" + ] + }, + "ssl_key_file": { + "description": "Path to the client key for SSL connections\n\n This must not be specified if the `ssl_key` option is specified.", + "type": [ + "string", + "null" + ] + }, + "max_connections": { + "description": "Set the maximum number of connections the pool should maintain", + "type": "integer", + "format": "uint32", + "minimum": 1, + "default": 10 + }, + "min_connections": { + "description": "Set the minimum number of connections the pool should maintain", + "type": "integer", + "format": "uint32", + "minimum": 0, + "default": 0 + }, + "connect_timeout": { + "description": "Set the amount of time to attempt connecting to the database", + "type": "integer", + "format": "uint64", + "minimum": 0, + "default": 30 + }, + "idle_timeout": { + "description": "Set a maximum idle duration for individual connections", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0, + "default": 600 + }, + "max_lifetime": { + "description": "Set the maximum lifetime of individual connections", + "type": "integer", + "format": "uint64", + "minimum": 0, + "default": 1800 + } + } + }, + "Hostname": { + "type": "string", + "format": "hostname" + }, + "PgSslMode": { + "description": "Options for controlling the level of protection provided for PostgreSQL SSL\n connections.", + "oneOf": [ + { + "description": "Only try a non-SSL connection.", + "type": "string", + "const": "disable" + }, + { + "description": "First try a non-SSL connection; if that fails, try an SSL connection.", + "type": "string", + "const": "allow" + }, + { + "description": "First try an SSL connection; if that fails, try a non-SSL connection.", + "type": "string", + "const": "prefer" + }, + { + "description": "Only try an SSL connection. If a root CA file is present, verify the\n connection in the same way as if `VerifyCa` was specified.", + "type": "string", + "const": "require" + }, + { + "description": "Only try an SSL connection, and verify that the server certificate is\n issued by a trusted certificate authority (CA).", + "type": "string", + "const": "verify-ca" + }, + { + "description": "Only try an SSL connection; verify that the server certificate is issued\n by a trusted CA and that the requested server host name matches that\n in the certificate.", + "type": "string", + "const": "verify-full" + } + ] + }, + "TelemetryConfig": { + "description": "Configuration related to sending monitoring data", + "type": "object", + "properties": { + "tracing": { + "description": "Configuration related to exporting traces", + "allOf": [ + { + "$ref": "#/definitions/TracingConfig" + } + ] + }, + "metrics": { + "description": "Configuration related to exporting metrics", + "allOf": [ + { + "$ref": "#/definitions/MetricsConfig" + } + ] + }, + "sentry": { + "description": "Configuration related to the Sentry integration", + "allOf": [ + { + "$ref": "#/definitions/SentryConfig" + } + ] + } + } + }, + "TracingConfig": { + "description": "Configuration related to exporting traces", + "type": "object", + "properties": { + "exporter": { + "description": "Exporter to use when exporting traces", + "default": "none", + "allOf": [ + { + "$ref": "#/definitions/TracingExporterKind" + } + ] + }, + "endpoint": { + "description": "OTLP exporter: OTLP over HTTP compatible endpoint", + "type": [ + "string", + "null" + ], + "format": "uri", + "default": "https://localhost:4318" + }, + "propagators": { + "description": "List of propagation formats to use for incoming and outgoing requests", + "type": "array", + "items": { + "$ref": "#/definitions/Propagator" + }, + "default": [] + }, + "sample_rate": { + "description": "Sample rate for traces\n\n Defaults to `1.0` if not set.", + "type": [ + "number", + "null" + ], + "format": "double", + "examples": [ + 0.5 + ], + "minimum": 0.0, + "maximum": 1.0 + } + } + }, + "TracingExporterKind": { + "description": "Exporter to use when exporting traces", + "oneOf": [ + { + "description": "Don't export traces", + "type": "string", + "const": "none" + }, + { + "description": "Export traces to the standard output. Only useful for debugging", + "type": "string", + "const": "stdout" + }, + { + "description": "Export traces to an OpenTelemetry protocol compatible endpoint", + "type": "string", + "const": "otlp" + } + ] + }, + "Propagator": { + "description": "Propagation format for incoming and outgoing requests", + "oneOf": [ + { + "description": "Propagate according to the W3C Trace Context specification", + "type": "string", + "const": "tracecontext" + }, + { + "description": "Propagate according to the W3C Baggage specification", + "type": "string", + "const": "baggage" + }, + { + "description": "Propagate trace context with Jaeger compatible headers", + "type": "string", + "const": "jaeger" + } + ] + }, + "MetricsConfig": { + "description": "Configuration related to exporting metrics", + "type": "object", + "properties": { + "exporter": { + "description": "Exporter to use when exporting metrics", + "default": "none", + "allOf": [ + { + "$ref": "#/definitions/MetricsExporterKind" + } + ] + }, + "endpoint": { + "description": "OTLP exporter: OTLP over HTTP compatible endpoint", + "type": [ + "string", + "null" + ], + "format": "uri", + "default": "https://localhost:4318" + } + } + }, + "MetricsExporterKind": { + "description": "Exporter to use when exporting metrics", + "oneOf": [ + { + "description": "Don't export metrics", + "type": "string", + "const": "none" + }, + { + "description": "Export metrics to stdout. Only useful for debugging", + "type": "string", + "const": "stdout" + }, + { + "description": "Export metrics to an OpenTelemetry protocol compatible endpoint", + "type": "string", + "const": "otlp" + }, + { + "description": "Export metrics via Prometheus. An HTTP listener with the `prometheus`\n resource must be setup to expose the Promethes metrics.", + "type": "string", + "const": "prometheus" + } + ] + }, + "SentryConfig": { + "description": "Configuration related to the Sentry integration", + "type": "object", + "properties": { + "dsn": { + "description": "Sentry DSN", + "type": [ + "string", + "null" + ], + "format": "uri", + "examples": [ + "https://public@host:port/1" + ] + }, + "environment": { + "description": "Environment to use when sending events to Sentry\n\n Defaults to `production` if not set.", + "type": [ + "string", + "null" + ], + "examples": [ + "production" + ] + }, + "sample_rate": { + "description": "Sample rate for event submissions\n\n Defaults to `1.0` if not set.", + "type": [ + "number", + "null" + ], + "format": "float", + "examples": [ + 0.5 + ], + "minimum": 0.0, + "maximum": 1.0 + }, + "traces_sample_rate": { + "description": "Sample rate for tracing transactions\n\n Defaults to `0.0` if not set.", + "type": [ + "number", + "null" + ], + "format": "float", + "examples": [ + 0.5 + ], + "minimum": 0.0, + "maximum": 1.0 + } + } + }, + "TemplatesConfig": { + "description": "Configuration related to templates", + "type": "object", + "properties": { + "path": { + "description": "Path to the folder which holds the templates", + "type": [ + "string", + "null" + ] + }, + "assets_manifest": { + "description": "Path to the assets manifest", + "type": [ + "string", + "null" + ] + }, + "translations_path": { + "description": "Path to the translations", + "type": [ + "string", + "null" + ] + } + } + }, + "EmailConfig": { + "description": "Configuration related to sending emails", + "type": "object", + "properties": { + "from": { + "description": "Email address to use as From when sending emails", + "type": "string", + "format": "email", + "default": "\"Authentication Service\" " + }, + "reply_to": { + "description": "Email address to use as Reply-To when sending emails", + "type": "string", + "format": "email", + "default": "\"Authentication Service\" " + }, + "transport": { + "description": "What backend should be used when sending emails", + "allOf": [ + { + "$ref": "#/definitions/EmailTransportKind" + } + ] + }, + "mode": { + "description": "SMTP transport: Connection mode to the relay", + "anyOf": [ + { + "$ref": "#/definitions/EmailSmtpMode" + }, + { + "type": "null" + } + ] + }, + "hostname": { + "description": "SMTP transport: Hostname to connect to", + "anyOf": [ + { + "$ref": "#/definitions/Hostname" + }, + { + "type": "null" + } + ] + }, + "port": { + "description": "SMTP transport: Port to connect to. Default is 25 for plain, 465 for TLS\n and 587 for `StartTLS`", + "type": [ + "integer", + "null" + ], + "format": "uint16", + "minimum": 1, + "maximum": 65535 + }, + "username": { + "description": "SMTP transport: Username for use to authenticate when connecting to the\n SMTP server\n\n Must be set if the `password` field is set", + "type": [ + "string", + "null" + ] + }, + "password": { + "description": "SMTP transport: Password for use to authenticate when connecting to the\n SMTP server\n\n Must be set if the `username` field is set", + "type": [ + "string", + "null" + ] + }, + "command": { + "description": "Sendmail transport: Command to use to send emails", + "type": [ + "string", + "null" + ], + "default": "sendmail" + } + }, + "required": [ + "transport" + ] + }, + "EmailTransportKind": { + "description": "What backend should be used when sending emails", + "oneOf": [ + { + "description": "Don't send emails anywhere", + "type": "string", + "const": "blackhole" + }, + { + "description": "Send emails via an SMTP relay", + "type": "string", + "const": "smtp" + }, + { + "description": "Send emails by calling sendmail", + "type": "string", + "const": "sendmail" + } + ] + }, + "EmailSmtpMode": { + "description": "Encryption mode to use", + "oneOf": [ + { + "description": "Plain text", + "type": "string", + "const": "plain" + }, + { + "description": "`StartTLS` (starts as plain text then upgrade to TLS)", + "type": "string", + "const": "starttls" + }, + { + "description": "TLS", + "type": "string", + "const": "tls" + } + ] + }, + "SecretsConfig": { + "description": "Application secrets", + "type": "object", + "properties": { + "encryption_file": { + "description": "File containing the encryption key for secure cookies.", + "type": [ + "string", + "null" + ] + }, + "encryption": { + "description": "Encryption key for secure cookies.", + "type": [ + "string", + "null" + ], + "examples": [ + "0000111122223333444455556666777788889999aaaabbbbccccddddeeeeffff" + ], + "pattern": "[0-9a-fA-F]{64}" + }, + "keys": { + "description": "List of private keys to use for signing and encrypting payloads", + "type": "array", + "items": { + "$ref": "#/definitions/KeyConfig" + }, + "default": [] + } + } + }, + "KeyConfig": { + "description": "A single key with its key ID and optional password.", + "type": "object", + "properties": { + "kid": { + "description": "The key ID `kid` of the key as used by JWKs.\n\n If not given, `kid` will be the key’s RFC 7638 JWK Thumbprint.", + "type": [ + "string", + "null" + ] + }, + "password_file": { + "type": [ + "string", + "null" + ] + }, + "password": { + "type": [ + "string", + "null" + ] + }, + "key_file": { + "type": [ + "string", + "null" + ] + }, + "key": { + "type": [ + "string", + "null" + ] + } + } + }, + "PasswordsConfig": { + "description": "User password hashing config", + "type": "object", + "properties": { + "enabled": { + "description": "Whether password-based authentication is enabled", + "type": "boolean", + "default": true + }, + "schemes": { + "description": "The hashing schemes to use for hashing and validating passwords\n\n The hashing scheme with the highest version number will be used for\n hashing new passwords.", + "type": "array", + "items": { + "$ref": "#/definitions/HashingScheme" + }, + "default": [ + { + "version": 1, + "algorithm": "argon2id" + } + ] + }, + "minimum_complexity": { + "description": "Score between 0 and 4 determining the minimum allowed password\n complexity. Scores are based on the ESTIMATED number of guesses\n needed to guess the password.\n\n - 0: less than 10^2 (100)\n - 1: less than 10^4 (10'000)\n - 2: less than 10^6 (1'000'000)\n - 3: less than 10^8 (100'000'000)\n - 4: any more than that", + "type": "integer", + "format": "uint8", + "minimum": 0, + "maximum": 255, + "default": 3 + } + } + }, + "HashingScheme": { + "description": "Parameters for a password hashing scheme", + "type": "object", + "properties": { + "version": { + "description": "The version of the hashing scheme. They must be unique, and the highest\n version will be used for hashing new passwords.", + "type": "integer", + "format": "uint16", + "minimum": 0, + "maximum": 65535 + }, + "algorithm": { + "description": "The hashing algorithm to use", + "allOf": [ + { + "$ref": "#/definitions/Algorithm" + } + ] + }, + "unicode_normalization": { + "description": "Whether to apply Unicode normalization to the password before hashing\n\n Defaults to `false`, and generally recommended to stay false. This is\n although recommended when importing password hashs from Synapse, as it\n applies an NFKC normalization to the password before hashing it.", + "type": "boolean" + }, + "cost": { + "description": "Cost for the bcrypt algorithm", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0, + "default": 12 + }, + "secret": { + "description": "An optional secret to use when hashing passwords. This makes it harder\n to brute-force the passwords in case of a database leak.", + "type": [ + "string", + "null" + ] + }, + "secret_file": { + "description": "Same as `secret`, but read from a file.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "version", + "algorithm" + ] + }, + "Algorithm": { + "description": "A hashing algorithm", + "oneOf": [ + { + "description": "bcrypt", + "type": "string", + "const": "bcrypt" + }, + { + "description": "argon2id", + "type": "string", + "const": "argon2id" + }, + { + "description": "PBKDF2", + "type": "string", + "const": "pbkdf2" + } + ] + }, + "MatrixConfig": { + "description": "Configuration related to the Matrix homeserver", + "type": "object", + "properties": { + "kind": { + "description": "The kind of homeserver it is.", + "default": "synapse", + "allOf": [ + { + "$ref": "#/definitions/HomeserverKind" + } + ] + }, + "homeserver": { + "description": "The server name of the homeserver.", + "type": "string", + "default": "localhost:8008" + }, + "secret_file": { + "type": [ + "string", + "null" + ] + }, + "secret": { + "type": [ + "string", + "null" + ] + }, + "endpoint": { + "description": "The base URL of the homeserver's client API", + "type": "string", + "format": "uri", + "default": "http://localhost:8008/" + } + } + }, + "HomeserverKind": { + "description": "The kind of homeserver it is.", + "oneOf": [ + { + "description": "Homeserver is Synapse, version 1.135.0 or newer", + "type": "string", + "const": "synapse" + }, + { + "description": "Homeserver is Synapse, version 1.135.0 or newer, in read-only mode\n\n This is meant for testing rolling out Matrix Authentication Service with\n no risk of writing data to the homeserver.", + "type": "string", + "const": "synapse_read_only" + }, + { + "description": "Homeserver is Synapse, using the legacy API", + "type": "string", + "const": "synapse_legacy" + }, + { + "description": "Homeserver is Synapse, with the modern API available (>= 1.135.0)", + "type": "string", + "const": "synapse_modern" + } + ] + }, + "PolicyConfig": { + "description": "Application secrets", + "type": "object", + "properties": { + "wasm_module": { + "description": "Path to the WASM module", + "type": "string" + }, + "client_registration_entrypoint": { + "description": "Entrypoint to use when evaluating client registrations", + "type": "string" + }, + "register_entrypoint": { + "description": "Entrypoint to use when evaluating user registrations", + "type": "string" + }, + "authorization_grant_entrypoint": { + "description": "Entrypoint to use when evaluating authorization grants", + "type": "string" + }, + "password_entrypoint": { + "description": "Entrypoint to use when changing password", + "type": "string" + }, + "email_entrypoint": { + "description": "Entrypoint to use when adding an email address", + "type": "string" + }, + "data": { + "description": "Arbitrary data to pass to the policy" + } + } + }, + "RateLimitingConfig": { + "description": "Configuration related to sending emails", + "type": "object", + "properties": { + "account_recovery": { + "description": "Account Recovery-specific rate limits", + "default": { + "per_ip": { + "burst": 3, + "per_second": 0.0008333333333333334 + }, + "per_address": { + "burst": 3, + "per_second": 0.0002777777777777778 + } + }, + "allOf": [ + { + "$ref": "#/definitions/AccountRecoveryRateLimitingConfig" + } + ] + }, + "login": { + "description": "Login-specific rate limits", + "default": { + "per_ip": { + "burst": 3, + "per_second": 0.05 + }, + "per_account": { + "burst": 1800, + "per_second": 0.5 + } + }, + "allOf": [ + { + "$ref": "#/definitions/LoginRateLimitingConfig" + } + ] + }, + "registration": { + "description": "Controls how many registrations attempts are permitted\n based on source address.", + "default": { + "burst": 3, + "per_second": 0.0008333333333333334 + }, + "allOf": [ + { + "$ref": "#/definitions/RateLimiterConfiguration" + } + ] + }, + "email_authentication": { + "description": "Email authentication-specific rate limits", + "default": { + "per_ip": { + "burst": 5, + "per_second": 0.016666666666666666 + }, + "per_address": { + "burst": 3, + "per_second": 0.0002777777777777778 + }, + "emails_per_session": { + "burst": 2, + "per_second": 0.0033333333333333335 + }, + "attempt_per_session": { + "burst": 10, + "per_second": 0.016666666666666666 + } + }, + "allOf": [ + { + "$ref": "#/definitions/EmailauthenticationRateLimitingConfig" + } + ] + } + } + }, + "AccountRecoveryRateLimitingConfig": { + "type": "object", + "properties": { + "per_ip": { + "description": "Controls how many account recovery attempts are permitted\n based on source IP address.\n This can protect against causing e-mail spam to many targets.\n\n Note: this limit also applies to re-sends.", + "default": { + "burst": 3, + "per_second": 0.0008333333333333334 + }, + "allOf": [ + { + "$ref": "#/definitions/RateLimiterConfiguration" + } + ] + }, + "per_address": { + "description": "Controls how many account recovery attempts are permitted\n based on the e-mail address entered into the recovery form.\n This can protect against causing e-mail spam to one target.\n\n Note: this limit also applies to re-sends.", + "default": { + "burst": 3, + "per_second": 0.0002777777777777778 + }, + "allOf": [ + { + "$ref": "#/definitions/RateLimiterConfiguration" + } + ] + } + } + }, + "RateLimiterConfiguration": { + "type": "object", + "properties": { + "burst": { + "description": "A one-off burst of actions that the user can perform\n in one go without waiting.", + "type": "integer", + "format": "uint32", + "minimum": 1 + }, + "per_second": { + "description": "How quickly the allowance replenishes, in number of actions per second.\n Can be fractional to replenish slower.", + "type": "number", + "format": "double" + } + }, + "required": [ + "burst", + "per_second" + ] + }, + "LoginRateLimitingConfig": { + "type": "object", + "properties": { + "per_ip": { + "description": "Controls how many login attempts are permitted\n based on source IP address.\n This can protect against brute force login attempts.\n\n Note: this limit also applies to password checks when a user attempts to\n change their own password.", + "default": { + "burst": 3, + "per_second": 0.05 + }, + "allOf": [ + { + "$ref": "#/definitions/RateLimiterConfiguration" + } + ] + }, + "per_account": { + "description": "Controls how many login attempts are permitted\n based on the account that is being attempted to be logged into.\n This can protect against a distributed brute force attack\n but should be set high enough to prevent someone's account being\n casually locked out.\n\n Note: this limit also applies to password checks when a user attempts to\n change their own password.", + "default": { + "burst": 1800, + "per_second": 0.5 + }, + "allOf": [ + { + "$ref": "#/definitions/RateLimiterConfiguration" + } + ] + } + } + }, + "EmailauthenticationRateLimitingConfig": { + "type": "object", + "properties": { + "per_ip": { + "description": "Controls how many email authentication attempts are permitted\n based on the source IP address.\n This can protect against causing e-mail spam to many targets.", + "default": { + "burst": 5, + "per_second": 0.016666666666666666 + }, + "allOf": [ + { + "$ref": "#/definitions/RateLimiterConfiguration" + } + ] + }, + "per_address": { + "description": "Controls how many email authentication attempts are permitted\n based on the e-mail address entered into the authentication form.\n This can protect against causing e-mail spam to one target.\n\n Note: this limit also applies to re-sends.", + "default": { + "burst": 3, + "per_second": 0.0002777777777777778 + }, + "allOf": [ + { + "$ref": "#/definitions/RateLimiterConfiguration" + } + ] + }, + "emails_per_session": { + "description": "Controls how many authentication emails are permitted to be sent per\n authentication session. This ensures not too many authentication codes\n are created for the same authentication session.", + "default": { + "burst": 2, + "per_second": 0.0033333333333333335 + }, + "allOf": [ + { + "$ref": "#/definitions/RateLimiterConfiguration" + } + ] + }, + "attempt_per_session": { + "description": "Controls how many code authentication attempts are permitted per\n authentication session. This can protect against brute-forcing the\n code.", + "default": { + "burst": 10, + "per_second": 0.016666666666666666 + }, + "allOf": [ + { + "$ref": "#/definitions/RateLimiterConfiguration" + } + ] + } + } + }, + "UpstreamOAuth2Config": { + "description": "Upstream OAuth 2.0 providers configuration", + "type": "object", + "properties": { + "providers": { + "description": "List of OAuth 2.0 providers", + "type": "array", + "items": { + "$ref": "#/definitions/Provider" + } + } + }, + "required": [ + "providers" + ] + }, + "Provider": { + "description": "Configuration for one upstream OAuth 2 provider.", + "type": "object", + "properties": { + "enabled": { + "description": "Whether this provider is enabled.\n\n Defaults to `true`", + "type": "boolean" + }, + "id": { + "description": "A ULID as per https://github.com/ulid/spec", + "type": "string", + "pattern": "^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}$" + }, + "synapse_idp_id": { + "description": "The ID of the provider that was used by Synapse.\n In order to perform a Synapse-to-MAS migration, this must be specified.\n\n ## For providers that used OAuth 2.0 or OpenID Connect in Synapse\n\n ### For `oidc_providers`:\n This should be specified as `oidc-` followed by the ID that was\n configured as `idp_id` in one of the `oidc_providers` in the Synapse\n configuration.\n For example, if Synapse's configuration contained `idp_id: wombat` for\n this provider, then specify `oidc-wombat` here.\n\n ### For `oidc_config` (legacy):\n Specify `oidc` here.", + "type": [ + "string", + "null" + ] + }, + "issuer": { + "description": "The OIDC issuer URL\n\n This is required if OIDC discovery is enabled (which is the default)", + "type": [ + "string", + "null" + ] + }, + "human_name": { + "description": "A human-readable name for the provider, that will be shown to users", + "type": [ + "string", + "null" + ] + }, + "brand_name": { + "description": "A brand identifier used to customise the UI, e.g. `apple`, `google`,\n `github`, etc.\n\n Values supported by the default template are:\n\n - `apple`\n - `google`\n - `facebook`\n - `github`\n - `gitlab`\n - `twitter`\n - `discord`", + "type": [ + "string", + "null" + ] + }, + "client_id": { + "description": "The client ID to use when authenticating with the provider", + "type": "string" + }, + "client_secret": { + "description": "The client secret to use when authenticating with the provider\n\n Used by the `client_secret_basic`, `client_secret_post`, and\n `client_secret_jwt` methods", + "type": [ + "string", + "null" + ] + }, + "token_endpoint_auth_method": { + "description": "The method to authenticate the client with the provider", + "allOf": [ + { + "$ref": "#/definitions/TokenAuthMethod" + } + ] + }, + "sign_in_with_apple": { + "description": "Additional parameters for the `sign_in_with_apple` method", + "anyOf": [ + { + "$ref": "#/definitions/SignInWithApple" + }, + { + "type": "null" + } + ] + }, + "token_endpoint_auth_signing_alg": { + "description": "The JWS algorithm to use when authenticating the client with the\n provider\n\n Used by the `client_secret_jwt` and `private_key_jwt` methods", + "anyOf": [ + { + "$ref": "#/definitions/JsonWebSignatureAlg" + }, + { + "type": "null" + } + ] + }, + "id_token_signed_response_alg": { + "description": "Expected signature for the JWT payload returned by the token\n authentication endpoint.\n\n Defaults to `RS256`.", + "allOf": [ + { + "$ref": "#/definitions/JsonWebSignatureAlg" + } + ] + }, + "scope": { + "description": "The scopes to request from the provider\n\n Defaults to `openid`.", + "type": "string" + }, + "discovery_mode": { + "description": "How to discover the provider's configuration\n\n Defaults to `oidc`, which uses OIDC discovery with strict metadata\n verification", + "allOf": [ + { + "$ref": "#/definitions/DiscoveryMode" + } + ] + }, + "pkce_method": { + "description": "Whether to use proof key for code exchange (PKCE) when requesting and\n exchanging the token.\n\n Defaults to `auto`, which uses PKCE if the provider supports it.", + "allOf": [ + { + "$ref": "#/definitions/PkceMethod" + } + ] + }, + "fetch_userinfo": { + "description": "Whether to fetch the user profile from the userinfo endpoint,\n or to rely on the data returned in the `id_token` from the\n `token_endpoint`.\n\n Defaults to `false`.", + "type": "boolean", + "default": false + }, + "userinfo_signed_response_alg": { + "description": "Expected signature for the JWT payload returned by the userinfo\n endpoint.\n\n If not specified, the response is expected to be an unsigned JSON\n payload.", + "anyOf": [ + { + "$ref": "#/definitions/JsonWebSignatureAlg" + }, + { + "type": "null" + } + ] + }, + "authorization_endpoint": { + "description": "The URL to use for the provider's authorization endpoint\n\n Defaults to the `authorization_endpoint` provided through discovery", + "type": [ + "string", + "null" + ], + "format": "uri" + }, + "userinfo_endpoint": { + "description": "The URL to use for the provider's userinfo endpoint\n\n Defaults to the `userinfo_endpoint` provided through discovery", + "type": [ + "string", + "null" + ], + "format": "uri" + }, + "token_endpoint": { + "description": "The URL to use for the provider's token endpoint\n\n Defaults to the `token_endpoint` provided through discovery", + "type": [ + "string", + "null" + ], + "format": "uri" + }, + "jwks_uri": { + "description": "The URL to use for getting the provider's public keys\n\n Defaults to the `jwks_uri` provided through discovery", + "type": [ + "string", + "null" + ], + "format": "uri" + }, + "response_mode": { + "description": "The response mode we ask the provider to use for the callback", + "anyOf": [ + { + "$ref": "#/definitions/ResponseMode" + }, + { + "type": "null" + } + ] + }, + "claims_imports": { + "description": "How claims should be imported from the `id_token` provided by the\n provider", + "allOf": [ + { + "$ref": "#/definitions/ClaimsImports" + } + ] + }, + "additional_authorization_parameters": { + "description": "Additional parameters to include in the authorization request\n\n Orders of the keys are not preserved.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "forward_login_hint": { + "description": "Whether the `login_hint` should be forwarded to the provider in the\n authorization request.\n\n Defaults to `false`.", + "type": "boolean", + "default": false + }, + "on_backchannel_logout": { + "description": "What to do when receiving an OIDC Backchannel logout request.\n\n Defaults to `do_nothing`.", + "allOf": [ + { + "$ref": "#/definitions/OnBackchannelLogout" + } + ] + } + }, + "required": [ + "id", + "client_id", + "token_endpoint_auth_method" + ] + }, + "TokenAuthMethod": { + "description": "Authentication methods used against the OAuth 2.0 provider", + "oneOf": [ + { + "description": "`none`: No authentication", + "type": "string", + "const": "none" + }, + { + "description": "`client_secret_basic`: `client_id` and `client_secret` used as basic\n authorization credentials", + "type": "string", + "const": "client_secret_basic" + }, + { + "description": "`client_secret_post`: `client_id` and `client_secret` sent in the\n request body", + "type": "string", + "const": "client_secret_post" + }, + { + "description": "`client_secret_jwt`: a `client_assertion` sent in the request body and\n signed using the `client_secret`", + "type": "string", + "const": "client_secret_jwt" + }, + { + "description": "`private_key_jwt`: a `client_assertion` sent in the request body and\n signed by an asymmetric key", + "type": "string", + "const": "private_key_jwt" + }, + { + "description": "`sign_in_with_apple`: a special method for Signin with Apple", + "type": "string", + "const": "sign_in_with_apple" + } + ] + }, + "SignInWithApple": { + "type": "object", + "properties": { + "private_key_file": { + "description": "The private key file used to sign the `id_token`", + "type": [ + "string", + "null" + ] + }, + "private_key": { + "description": "The private key used to sign the `id_token`", + "type": [ + "string", + "null" + ] + }, + "team_id": { + "description": "The Team ID of the Apple Developer Portal", + "type": "string" + }, + "key_id": { + "description": "The key ID of the Apple Developer Portal", + "type": "string" + } + }, + "required": [ + "team_id", + "key_id" + ] + }, + "DiscoveryMode": { + "description": "How to discover the provider's configuration", + "oneOf": [ + { + "description": "Use OIDC discovery with strict metadata verification", + "type": "string", + "const": "oidc" + }, + { + "description": "Use OIDC discovery with relaxed metadata verification", + "type": "string", + "const": "insecure" + }, + { + "description": "Use a static configuration", + "type": "string", + "const": "disabled" + } + ] + }, + "PkceMethod": { + "description": "Whether to use proof key for code exchange (PKCE) when requesting and\n exchanging the token.", + "oneOf": [ + { + "description": "Use PKCE if the provider supports it\n\n Defaults to no PKCE if provider discovery is disabled", + "type": "string", + "const": "auto" + }, + { + "description": "Always use PKCE with the S256 challenge method", + "type": "string", + "const": "always" + }, + { + "description": "Never use PKCE", + "type": "string", + "const": "never" + } + ] + }, + "ResponseMode": { + "description": "The response mode we ask the provider to use for the callback", + "oneOf": [ + { + "description": "`query`: The provider will send the response as a query string in the\n URL search parameters", + "type": "string", + "const": "query" + }, + { + "description": "`form_post`: The provider will send the response as a POST request with\n the response parameters in the request body\n\n ", + "type": "string", + "const": "form_post" + } + ] + }, + "ClaimsImports": { + "description": "How claims should be imported", + "type": "object", + "properties": { + "subject": { + "description": "How to determine the subject of the user", + "allOf": [ + { + "$ref": "#/definitions/SubjectImportPreference" + } + ] + }, + "localpart": { + "description": "Import the localpart of the MXID", + "allOf": [ + { + "$ref": "#/definitions/LocalpartImportPreference" + } + ] + }, + "displayname": { + "description": "Import the displayname of the user.", + "allOf": [ + { + "$ref": "#/definitions/DisplaynameImportPreference" + } + ] + }, + "email": { + "description": "Import the email address of the user based on the `email` and\n `email_verified` claims", + "allOf": [ + { + "$ref": "#/definitions/EmailImportPreference" + } + ] + }, + "account_name": { + "description": "Set a human-readable name for the upstream account for display purposes", + "allOf": [ + { + "$ref": "#/definitions/AccountNameImportPreference" + } + ] + } + } + }, + "SubjectImportPreference": { + "description": "What should be done for the subject attribute", + "type": "object", + "properties": { + "template": { + "description": "The Jinja2 template to use for the subject attribute\n\n If not provided, the default template is `{{ user.sub }}`", + "type": [ + "string", + "null" + ] + } + } + }, + "LocalpartImportPreference": { + "description": "What should be done for the localpart attribute", + "type": "object", + "properties": { + "action": { + "description": "How to handle the attribute", + "allOf": [ + { + "$ref": "#/definitions/ImportAction" + } + ] + }, + "template": { + "description": "The Jinja2 template to use for the localpart attribute\n\n If not provided, the default template is `{{ user.preferred_username }}`", + "type": [ + "string", + "null" + ] + }, + "on_conflict": { + "description": "How to handle conflicts on the claim, default value is `Fail`", + "allOf": [ + { + "$ref": "#/definitions/OnConflict" + } + ] + } + } + }, + "ImportAction": { + "description": "How to handle a claim", + "oneOf": [ + { + "description": "Ignore the claim", + "type": "string", + "const": "ignore" + }, + { + "description": "Suggest the claim value, but allow the user to change it", + "type": "string", + "const": "suggest" + }, + { + "description": "Force the claim value, but don't fail if it is missing", + "type": "string", + "const": "force" + }, + { + "description": "Force the claim value, and fail if it is missing", + "type": "string", + "const": "require" + } + ] + }, + "OnConflict": { + "description": "How to handle an existing localpart claim", + "oneOf": [ + { + "description": "Fails the sso login on conflict", + "type": "string", + "const": "fail" + }, + { + "description": "Adds the oauth identity link, regardless of whether there is an existing\n link or not", + "type": "string", + "const": "add" + } + ] + }, + "DisplaynameImportPreference": { + "description": "What should be done for the displayname attribute", + "type": "object", + "properties": { + "action": { + "description": "How to handle the attribute", + "allOf": [ + { + "$ref": "#/definitions/ImportAction" + } + ] + }, + "template": { + "description": "The Jinja2 template to use for the displayname attribute\n\n If not provided, the default template is `{{ user.name }}`", + "type": [ + "string", + "null" + ] + } + } + }, + "EmailImportPreference": { + "description": "What should be done with the email attribute", + "type": "object", + "properties": { + "action": { + "description": "How to handle the claim", + "allOf": [ + { + "$ref": "#/definitions/ImportAction" + } + ] + }, + "template": { + "description": "The Jinja2 template to use for the email address attribute\n\n If not provided, the default template is `{{ user.email }}`", + "type": [ + "string", + "null" + ] + } + } + }, + "AccountNameImportPreference": { + "description": "What should be done for the account name attribute", + "type": "object", + "properties": { + "template": { + "description": "The Jinja2 template to use for the account name. This name is only used\n for display purposes.\n\n If not provided, it will be ignored.", + "type": [ + "string", + "null" + ] + } + } + }, + "OnBackchannelLogout": { + "description": "What to do when receiving an OIDC Backchannel logout request.", + "oneOf": [ + { + "description": "Do nothing", + "type": "string", + "const": "do_nothing" + }, + { + "description": "Only log out the MAS 'browser session' started by this OIDC session", + "type": "string", + "const": "logout_browser_only" + }, + { + "description": "Log out all sessions started by this OIDC session, including MAS\n 'browser sessions' and client sessions", + "type": "string", + "const": "logout_all" + } + ] + }, + "BrandingConfig": { + "description": "Configuration section for tweaking the branding of the service", + "type": "object", + "properties": { + "service_name": { + "description": "A human-readable name. Defaults to the server's address.", + "type": [ + "string", + "null" + ] + }, + "policy_uri": { + "description": "Link to a privacy policy, displayed in the footer of web pages and\n emails. It is also advertised to clients through the `op_policy_uri`\n OIDC provider metadata.", + "type": [ + "string", + "null" + ], + "format": "uri" + }, + "tos_uri": { + "description": "Link to a terms of service document, displayed in the footer of web\n pages and emails. It is also advertised to clients through the\n `op_tos_uri` OIDC provider metadata.", + "type": [ + "string", + "null" + ], + "format": "uri" + }, + "imprint": { + "description": "Legal imprint, displayed in the footer in the footer of web pages and\n emails.", + "type": [ + "string", + "null" + ] + }, + "logo_uri": { + "description": "Logo displayed in some web pages.", + "type": [ + "string", + "null" + ], + "format": "uri" + } + } + }, + "CaptchaConfig": { + "description": "Configuration section to setup CAPTCHA protection on a few operations", + "type": "object", + "properties": { + "service": { + "description": "Which service should be used for CAPTCHA protection", + "anyOf": [ + { + "$ref": "#/definitions/CaptchaServiceKind" + }, + { + "type": "null" + } + ] + }, + "site_key": { + "description": "The site key to use", + "type": [ + "string", + "null" + ] + }, + "secret_key": { + "description": "The secret key to use", + "type": [ + "string", + "null" + ] + } + } + }, + "CaptchaServiceKind": { + "description": "Which service should be used for CAPTCHA protection", + "oneOf": [ + { + "description": "Use Google's reCAPTCHA v2 API", + "type": "string", + "const": "recaptcha_v2" + }, + { + "description": "Use Cloudflare Turnstile", + "type": "string", + "const": "cloudflare_turnstile" + }, + { + "description": "Use ``HCaptcha``", + "type": "string", + "const": "hcaptcha" + } + ] + }, + "AccountConfig": { + "description": "Configuration section to configure features related to account management", + "type": "object", + "properties": { + "email_change_allowed": { + "description": "Whether users are allowed to change their email addresses. Defaults to\n `true`.", + "type": "boolean" + }, + "displayname_change_allowed": { + "description": "Whether users are allowed to change their display names. Defaults to\n `true`.\n\n This should be in sync with the policy in the homeserver configuration.", + "type": "boolean" + }, + "password_registration_enabled": { + "description": "Whether to enable self-service password registration. Defaults to\n `false` if password authentication is enabled.\n\n This has no effect if password login is disabled.", + "type": "boolean" + }, + "password_registration_email_required": { + "description": "Whether self-service password registrations require a valid email.\n Defaults to `true`.\n\n This has no effect if password registration is disabled.", + "type": "boolean" + }, + "password_change_allowed": { + "description": "Whether users are allowed to change their passwords. Defaults to `true`.\n\n This has no effect if password login is disabled.", + "type": "boolean" + }, + "password_recovery_enabled": { + "description": "Whether email-based password recovery is enabled. Defaults to `false`.\n\n This has no effect if password login is disabled.", + "type": "boolean" + }, + "account_deactivation_allowed": { + "description": "Whether users are allowed to delete their own account. Defaults to\n `true`.", + "type": "boolean" + }, + "login_with_email_allowed": { + "description": "Whether users can log in with their email address. Defaults to `false`.\n\n This has no effect if password login is disabled.", + "type": "boolean" + }, + "registration_token_required": { + "description": "Whether registration tokens are required for password registrations.\n Defaults to `false`.\n\n When enabled, users must provide a valid registration token during\n password registration. This has no effect if password registration\n is disabled.", + "type": "boolean" + } + } + }, + "ExperimentalConfig": { + "description": "Configuration sections for experimental options\n\n Do not change these options unless you know what you are doing.", + "type": "object", + "properties": { + "access_token_ttl": { + "description": "Time-to-live of access tokens in seconds. Defaults to 5 minutes.", + "type": "integer", + "format": "uint64", + "minimum": 60, + "maximum": 86400 + }, + "compat_token_ttl": { + "description": "Time-to-live of compatibility access tokens in seconds. Defaults to 5\n minutes.", + "type": "integer", + "format": "uint64", + "minimum": 60, + "maximum": 86400 + }, + "inactive_session_expiration": { + "description": "Experimetal feature to automatically expire inactive sessions\n\n Disabled by default", + "anyOf": [ + { + "$ref": "#/definitions/InactiveSessionExpirationConfig" + }, + { + "type": "null" + } + ] + }, + "plan_management_iframe_uri": { + "description": "Experimental feature to show a plan management tab and iframe.\n This value is passed through \"as is\" to the client without any\n validation.", + "type": [ + "string", + "null" + ] + }, + "session_limit": { + "description": "Experimental feature to limit the number of application sessions per\n user.\n\n Disabled by default.", + "anyOf": [ + { + "$ref": "#/definitions/SessionLimitConfig" + }, + { + "type": "null" + } + ] + } + } + }, + "InactiveSessionExpirationConfig": { + "description": "Configuration options for the inactive session expiration feature", + "type": "object", + "properties": { + "ttl": { + "description": "Time after which an inactive session is automatically finished", + "type": "integer", + "format": "uint64", + "minimum": 600, + "maximum": 7776000 + }, + "expire_compat_sessions": { + "description": "Should compatibility sessions expire after inactivity", + "type": "boolean", + "default": true + }, + "expire_oauth_sessions": { + "description": "Should OAuth 2.0 sessions expire after inactivity", + "type": "boolean", + "default": true + }, + "expire_user_sessions": { + "description": "Should user sessions expire after inactivity", + "type": "boolean", + "default": true + } + }, + "required": [ + "ttl" + ] + }, + "SessionLimitConfig": { + "description": "Configuration options for the session limit feature", + "type": "object", + "properties": { + "soft_limit": { + "type": "integer", + "format": "uint64", + "minimum": 1 + }, + "hard_limit": { + "type": "integer", + "format": "uint64", + "minimum": 1 + } + }, + "required": [ + "soft_limit", + "hard_limit" + ] + } + } +} \ No newline at end of file diff --git a/policies/schema/authorization_grant_input.json b/policies/schema/authorization_grant_input.json index fb96e7070..8f346cc5c 100644 --- a/policies/schema/authorization_grant_input.json +++ b/policies/schema/authorization_grant_input.json @@ -12,10 +12,13 @@ "additionalProperties": true }, "session_counts": { - "description": "How many sessions the user has. Not populated if it's not a user logging in.", - "allOf": [ + "description": "How many sessions the user has.\n Not populated if it's not a user logging in.", + "anyOf": [ { "$ref": "#/definitions/SessionCounts" + }, + { + "type": "null" } ] }, @@ -43,34 +46,34 @@ "SessionCounts": { "description": "Information about how many sessions the user has", "type": "object", - "required": [ - "compat", - "oauth2", - "personal", - "total" - ], "properties": { "total": { "type": "integer", "format": "uint64", - "minimum": 0.0 + "minimum": 0 }, "oauth2": { "type": "integer", "format": "uint64", - "minimum": 0.0 + "minimum": 0 }, "compat": { "type": "integer", "format": "uint64", - "minimum": 0.0 + "minimum": 0 }, "personal": { "type": "integer", "format": "uint64", - "minimum": 0.0 + "minimum": 0 } - } + }, + "required": [ + "total", + "oauth2", + "compat", + "personal" + ] }, "GrantType": { "type": "string",