From db8c557f81cddaf29ca9ca7ddc2134d521e210fa Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Mon, 30 Jun 2025 16:31:57 +0200 Subject: [PATCH] Backchannel logout behavior settings on upstream providers --- crates/cli/src/sync.rs | 10 ++++ crates/config/src/sections/mod.rs | 6 ++- crates/config/src/sections/upstream_oauth2.rs | 25 ++++++++++ crates/data-model/src/lib.rs | 6 +-- crates/data-model/src/upstream_oauth2/mod.rs | 1 + .../src/upstream_oauth2/provider.rs | 40 ++++++++++++++++ .../src/admin/v1/upstream_oauth_links/mod.rs | 4 +- crates/handlers/src/upstream_oauth2/cache.rs | 4 +- crates/handlers/src/upstream_oauth2/link.rs | 2 + crates/handlers/src/views/login.rs | 5 +- ...ea304d43c336ce80723789ff3e66c0dd4d86c.json | 46 +++++++++++++++++++ ...ffe11da64835ae297c9277271b8971d5de81.json} | 5 +- ...48b0f551b16f4cb57c022b50212cfc3d8431f.json | 45 ------------------ ...e08bab57721007c64ef2597cb09a62100792.json} | 10 +++- ...44dfab024b42e47ddc7bd9e551897ba6e9b8.json} | 10 +++- ...3_upstream_oauth_on_backchannel_logout.sql | 10 ++++ crates/storage-pg/src/iden.rs | 1 + crates/storage-pg/src/upstream_oauth2/mod.rs | 6 ++- .../src/upstream_oauth2/provider.rs | 42 +++++++++++++---- .../storage/src/upstream_oauth2/provider.rs | 7 ++- ...rite_user_with_upstream_provider_link.snap | 1 + .../syn2mas/src/synapse_reader/config/oidc.rs | 15 +++--- crates/templates/src/context.rs | 7 +-- docs/config.schema.json | 27 +++++++++++ 24 files changed, 256 insertions(+), 79 deletions(-) create mode 100644 crates/storage-pg/.sqlx/query-0f2ea548e00b080502edc04ee97ea304d43c336ce80723789ff3e66c0dd4d86c.json rename crates/storage-pg/.sqlx/{query-a711f4c6fa38b98c960ee565038d42ea16db436352b19fcd3b2c620c73d9cc0c.json => query-3312f901f70c3b69e0d315206c31ffe11da64835ae297c9277271b8971d5de81.json} (78%) delete mode 100644 crates/storage-pg/.sqlx/query-585a1e78834c953c80a0af9215348b0f551b16f4cb57c022b50212cfc3d8431f.json rename crates/storage-pg/.sqlx/{query-a82b87ccfaa1de9a8e6433aaa67382fbb5029d5f7adf95aaa0decd668d25ba89.json => query-6589987e88fa9dbbd2bd48acd910e08bab57721007c64ef2597cb09a62100792.json} (91%) rename crates/storage-pg/.sqlx/{query-e6d66a7980933c12ab046958e02d419129ef52ac45bea4345471838016cae917.json => query-99394fbd9c07d6d24429934b3f7344dfab024b42e47ddc7bd9e551897ba6e9b8.json} (89%) create mode 100644 crates/storage-pg/migrations/20250630120643_upstream_oauth_on_backchannel_logout.sql diff --git a/crates/cli/src/sync.rs b/crates/cli/src/sync.rs index aa99e1d35..d8433c291 100644 --- a/crates/cli/src/sync.rs +++ b/crates/cli/src/sync.rs @@ -276,6 +276,15 @@ pub async fn config_sync( } }; + let on_backchannel_logout = match provider.on_backchannel_logout { + mas_config::UpstreamOAuth2OnBackchannelLogout::DoNothing => { + mas_data_model::UpstreamOAuthProviderOnBackchannelLogout::DoNothing + } + mas_config::UpstreamOAuth2OnBackchannelLogout::LogoutBrowserOnly => { + mas_data_model::UpstreamOAuthProviderOnBackchannelLogout::LogoutBrowserOnly + } + }; + repo.upstream_oauth_provider() .upsert( clock, @@ -306,6 +315,7 @@ pub async fn config_sync( .collect(), forward_login_hint: provider.forward_login_hint, ui_order, + on_backchannel_logout, }, ) .await?; diff --git a/crates/config/src/sections/mod.rs b/crates/config/src/sections/mod.rs index f1f880594..850ca0c61 100644 --- a/crates/config/src/sections/mod.rs +++ b/crates/config/src/sections/mod.rs @@ -52,8 +52,10 @@ pub use self::{ upstream_oauth2::{ ClaimsImports as UpstreamOAuth2ClaimsImports, DiscoveryMode as UpstreamOAuth2DiscoveryMode, EmailImportPreference as UpstreamOAuth2EmailImportPreference, - ImportAction as UpstreamOAuth2ImportAction, PkceMethod as UpstreamOAuth2PkceMethod, - Provider as UpstreamOAuth2Provider, ResponseMode as UpstreamOAuth2ResponseMode, + ImportAction as UpstreamOAuth2ImportAction, + OnBackchannelLogout as UpstreamOAuth2OnBackchannelLogout, + PkceMethod as UpstreamOAuth2PkceMethod, Provider as UpstreamOAuth2Provider, + ResponseMode as UpstreamOAuth2ResponseMode, TokenAuthMethod as UpstreamOAuth2TokenAuthMethod, UpstreamOAuth2Config, }, }; diff --git a/crates/config/src/sections/upstream_oauth2.rs b/crates/config/src/sections/upstream_oauth2.rs index a2e62036a..2cf43b530 100644 --- a/crates/config/src/sections/upstream_oauth2.rs +++ b/crates/config/src/sections/upstream_oauth2.rs @@ -408,6 +408,25 @@ fn is_default_scope(scope: &str) -> bool { scope == default_scope() } +/// What to do when receiving an OIDC Backchannel logout request. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, Default)] +#[serde(rename_all = "snake_case")] +pub enum OnBackchannelLogout { + /// Do nothing + #[default] + DoNothing, + + /// Only log out the MAS 'browser session' started by this OIDC session + LogoutBrowserOnly, +} + +impl OnBackchannelLogout { + #[allow(clippy::trivially_copy_pass_by_ref)] + const fn is_default(&self) -> bool { + matches!(self, OnBackchannelLogout::DoNothing) + } +} + /// Configuration for one upstream OAuth 2 provider. #[skip_serializing_none] #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] @@ -583,4 +602,10 @@ pub struct Provider { /// Defaults to `false`. #[serde(default)] pub forward_login_hint: bool, + + /// What to do when receiving an OIDC Backchannel logout request. + /// + /// Defaults to "do_nothing". + #[serde(default, skip_serializing_if = "OnBackchannelLogout::is_default")] + pub on_backchannel_logout: OnBackchannelLogout, } diff --git a/crates/data-model/src/lib.rs b/crates/data-model/src/lib.rs index ce9631068..1ed15adcc 100644 --- a/crates/data-model/src/lib.rs +++ b/crates/data-model/src/lib.rs @@ -42,9 +42,9 @@ pub use self::{ UpstreamOAuthAuthorizationSession, UpstreamOAuthAuthorizationSessionState, UpstreamOAuthLink, UpstreamOAuthProvider, UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderDiscoveryMode, UpstreamOAuthProviderImportAction, - UpstreamOAuthProviderImportPreference, UpstreamOAuthProviderPkceMode, - UpstreamOAuthProviderResponseMode, UpstreamOAuthProviderSubjectPreference, - UpstreamOAuthProviderTokenAuthMethod, + UpstreamOAuthProviderImportPreference, UpstreamOAuthProviderOnBackchannelLogout, + UpstreamOAuthProviderPkceMode, UpstreamOAuthProviderResponseMode, + UpstreamOAuthProviderSubjectPreference, UpstreamOAuthProviderTokenAuthMethod, }, user_agent::{DeviceType, UserAgent}, users::{ diff --git a/crates/data-model/src/upstream_oauth2/mod.rs b/crates/data-model/src/upstream_oauth2/mod.rs index 1ed54e337..8e2638b9b 100644 --- a/crates/data-model/src/upstream_oauth2/mod.rs +++ b/crates/data-model/src/upstream_oauth2/mod.rs @@ -15,6 +15,7 @@ pub use self::{ DiscoveryMode as UpstreamOAuthProviderDiscoveryMode, ImportAction as UpstreamOAuthProviderImportAction, ImportPreference as UpstreamOAuthProviderImportPreference, + OnBackchannelLogout as UpstreamOAuthProviderOnBackchannelLogout, PkceMode as UpstreamOAuthProviderPkceMode, ResponseMode as UpstreamOAuthProviderResponseMode, SubjectPreference as UpstreamOAuthProviderSubjectPreference, diff --git a/crates/data-model/src/upstream_oauth2/provider.rs b/crates/data-model/src/upstream_oauth2/provider.rs index c4e990102..c384366df 100644 --- a/crates/data-model/src/upstream_oauth2/provider.rs +++ b/crates/data-model/src/upstream_oauth2/provider.rs @@ -216,6 +216,45 @@ impl std::str::FromStr for TokenAuthMethod { #[error("Invalid upstream OAuth 2.0 token auth method: {0}")] pub struct InvalidUpstreamOAuth2TokenAuthMethod(String); +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum OnBackchannelLogout { + DoNothing, + LogoutBrowserOnly, +} + +impl OnBackchannelLogout { + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::DoNothing => "do_nothing", + Self::LogoutBrowserOnly => "logout_browser_only", + } + } +} + +impl std::fmt::Display for OnBackchannelLogout { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +impl std::str::FromStr for OnBackchannelLogout { + type Err = InvalidUpstreamOAuth2OnBackchannelLogout; + + fn from_str(s: &str) -> Result { + match s { + "do_nothing" => Ok(Self::DoNothing), + "logout_browser_only" => Ok(Self::LogoutBrowserOnly), + s => Err(InvalidUpstreamOAuth2OnBackchannelLogout(s.to_owned())), + } + } +} + +#[derive(Debug, Clone, Error)] +#[error("Invalid upstream OAuth 2.0 'on backchannel logout': {0}")] +pub struct InvalidUpstreamOAuth2OnBackchannelLogout(String); + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct UpstreamOAuthProvider { pub id: Ulid, @@ -242,6 +281,7 @@ pub struct UpstreamOAuthProvider { pub claims_imports: ClaimsImports, pub additional_authorization_parameters: Vec<(String, String)>, pub forward_login_hint: bool, + pub on_backchannel_logout: OnBackchannelLogout, } impl PartialOrd for UpstreamOAuthProvider { diff --git a/crates/handlers/src/admin/v1/upstream_oauth_links/mod.rs b/crates/handlers/src/admin/v1/upstream_oauth_links/mod.rs index 9d2e6599c..3433aa3ca 100644 --- a/crates/handlers/src/admin/v1/upstream_oauth_links/mod.rs +++ b/crates/handlers/src/admin/v1/upstream_oauth_links/mod.rs @@ -19,7 +19,8 @@ pub use self::{ mod test_utils { use mas_data_model::{ UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderDiscoveryMode, - UpstreamOAuthProviderPkceMode, UpstreamOAuthProviderTokenAuthMethod, + UpstreamOAuthProviderOnBackchannelLogout, UpstreamOAuthProviderPkceMode, + UpstreamOAuthProviderTokenAuthMethod, }; use mas_iana::jose::JsonWebSignatureAlg; use mas_storage::upstream_oauth2::UpstreamOAuthProviderParams; @@ -49,6 +50,7 @@ mod test_utils { additional_authorization_parameters: Vec::new(), forward_login_hint: false, ui_order: 0, + on_backchannel_logout: UpstreamOAuthProviderOnBackchannelLogout::DoNothing, } } } diff --git a/crates/handlers/src/upstream_oauth2/cache.rs b/crates/handlers/src/upstream_oauth2/cache.rs index d93743bc3..79a9fe5fb 100644 --- a/crates/handlers/src/upstream_oauth2/cache.rs +++ b/crates/handlers/src/upstream_oauth2/cache.rs @@ -296,7 +296,8 @@ mod tests { // 'insecure' discovery use mas_data_model::{ - UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderTokenAuthMethod, + UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderOnBackchannelLogout, + UpstreamOAuthProviderTokenAuthMethod, }; use mas_iana::jose::JsonWebSignatureAlg; use mas_storage::{Clock, clock::MockClock}; @@ -427,6 +428,7 @@ mod tests { claims_imports: UpstreamOAuthProviderClaimsImports::default(), additional_authorization_parameters: Vec::new(), forward_login_hint: false, + on_backchannel_logout: UpstreamOAuthProviderOnBackchannelLogout::DoNothing, }; // Without any override, it should just use discovery diff --git a/crates/handlers/src/upstream_oauth2/link.rs b/crates/handlers/src/upstream_oauth2/link.rs index feb9e9074..934af3626 100644 --- a/crates/handlers/src/upstream_oauth2/link.rs +++ b/crates/handlers/src/upstream_oauth2/link.rs @@ -986,6 +986,8 @@ mod tests { additional_authorization_parameters: Vec::new(), forward_login_hint: false, ui_order: 0, + on_backchannel_logout: + mas_data_model::UpstreamOAuthProviderOnBackchannelLogout::DoNothing, }, ) .await diff --git a/crates/handlers/src/views/login.rs b/crates/handlers/src/views/login.rs index f684f32ad..cf49dece2 100644 --- a/crates/handlers/src/views/login.rs +++ b/crates/handlers/src/views/login.rs @@ -424,7 +424,8 @@ mod test { header::{CONTENT_TYPE, LOCATION}, }; use mas_data_model::{ - UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderTokenAuthMethod, + UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderOnBackchannelLogout, + UpstreamOAuthProviderTokenAuthMethod, }; use mas_iana::jose::JsonWebSignatureAlg; use mas_router::Route; @@ -500,6 +501,7 @@ mod test { additional_authorization_parameters: Vec::new(), forward_login_hint: false, ui_order: 0, + on_backchannel_logout: UpstreamOAuthProviderOnBackchannelLogout::DoNothing, }, ) .await @@ -542,6 +544,7 @@ mod test { additional_authorization_parameters: Vec::new(), forward_login_hint: false, ui_order: 1, + on_backchannel_logout: UpstreamOAuthProviderOnBackchannelLogout::DoNothing, }, ) .await diff --git a/crates/storage-pg/.sqlx/query-0f2ea548e00b080502edc04ee97ea304d43c336ce80723789ff3e66c0dd4d86c.json b/crates/storage-pg/.sqlx/query-0f2ea548e00b080502edc04ee97ea304d43c336ce80723789ff3e66c0dd4d86c.json new file mode 100644 index 000000000..1eb87fd3f --- /dev/null +++ b/crates/storage-pg/.sqlx/query-0f2ea548e00b080502edc04ee97ea304d43c336ce80723789ff3e66c0dd4d86c.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO upstream_oauth_providers (\n upstream_oauth_provider_id,\n issuer,\n human_name,\n brand_name,\n scope,\n token_endpoint_auth_method,\n token_endpoint_signing_alg,\n id_token_signed_response_alg,\n fetch_userinfo,\n userinfo_signed_response_alg,\n client_id,\n encrypted_client_secret,\n claims_imports,\n authorization_endpoint_override,\n token_endpoint_override,\n userinfo_endpoint_override,\n jwks_uri_override,\n discovery_mode,\n pkce_mode,\n response_mode,\n additional_parameters,\n forward_login_hint,\n ui_order,\n on_backchannel_logout,\n created_at\n ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10,\n $11, $12, $13, $14, $15, $16, $17, $18, $19, $20,\n $21, $22, $23, $24, $25)\n ON CONFLICT (upstream_oauth_provider_id)\n DO UPDATE\n SET\n issuer = EXCLUDED.issuer,\n human_name = EXCLUDED.human_name,\n brand_name = EXCLUDED.brand_name,\n scope = EXCLUDED.scope,\n token_endpoint_auth_method = EXCLUDED.token_endpoint_auth_method,\n token_endpoint_signing_alg = EXCLUDED.token_endpoint_signing_alg,\n id_token_signed_response_alg = EXCLUDED.id_token_signed_response_alg,\n fetch_userinfo = EXCLUDED.fetch_userinfo,\n userinfo_signed_response_alg = EXCLUDED.userinfo_signed_response_alg,\n disabled_at = NULL,\n client_id = EXCLUDED.client_id,\n encrypted_client_secret = EXCLUDED.encrypted_client_secret,\n claims_imports = EXCLUDED.claims_imports,\n authorization_endpoint_override = EXCLUDED.authorization_endpoint_override,\n token_endpoint_override = EXCLUDED.token_endpoint_override,\n userinfo_endpoint_override = EXCLUDED.userinfo_endpoint_override,\n jwks_uri_override = EXCLUDED.jwks_uri_override,\n discovery_mode = EXCLUDED.discovery_mode,\n pkce_mode = EXCLUDED.pkce_mode,\n response_mode = EXCLUDED.response_mode,\n additional_parameters = EXCLUDED.additional_parameters,\n forward_login_hint = EXCLUDED.forward_login_hint,\n ui_order = EXCLUDED.ui_order,\n on_backchannel_logout = EXCLUDED.on_backchannel_logout\n RETURNING created_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Bool", + "Text", + "Text", + "Text", + "Jsonb", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Jsonb", + "Bool", + "Int4", + "Text", + "Timestamptz" + ] + }, + "nullable": [ + false + ] + }, + "hash": "0f2ea548e00b080502edc04ee97ea304d43c336ce80723789ff3e66c0dd4d86c" +} diff --git a/crates/storage-pg/.sqlx/query-a711f4c6fa38b98c960ee565038d42ea16db436352b19fcd3b2c620c73d9cc0c.json b/crates/storage-pg/.sqlx/query-3312f901f70c3b69e0d315206c31ffe11da64835ae297c9277271b8971d5de81.json similarity index 78% rename from crates/storage-pg/.sqlx/query-a711f4c6fa38b98c960ee565038d42ea16db436352b19fcd3b2c620c73d9cc0c.json rename to crates/storage-pg/.sqlx/query-3312f901f70c3b69e0d315206c31ffe11da64835ae297c9277271b8971d5de81.json index 9944e855b..3f837630f 100644 --- a/crates/storage-pg/.sqlx/query-a711f4c6fa38b98c960ee565038d42ea16db436352b19fcd3b2c620c73d9cc0c.json +++ b/crates/storage-pg/.sqlx/query-3312f901f70c3b69e0d315206c31ffe11da64835ae297c9277271b8971d5de81.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO upstream_oauth_providers (\n upstream_oauth_provider_id,\n issuer,\n human_name,\n brand_name,\n scope,\n token_endpoint_auth_method,\n token_endpoint_signing_alg,\n id_token_signed_response_alg,\n fetch_userinfo,\n userinfo_signed_response_alg,\n client_id,\n encrypted_client_secret,\n claims_imports,\n authorization_endpoint_override,\n token_endpoint_override,\n userinfo_endpoint_override,\n jwks_uri_override,\n discovery_mode,\n pkce_mode,\n response_mode,\n forward_login_hint,\n created_at\n ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10,\n $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)\n ", + "query": "\n INSERT INTO upstream_oauth_providers (\n upstream_oauth_provider_id,\n issuer,\n human_name,\n brand_name,\n scope,\n token_endpoint_auth_method,\n token_endpoint_signing_alg,\n id_token_signed_response_alg,\n fetch_userinfo,\n userinfo_signed_response_alg,\n client_id,\n encrypted_client_secret,\n claims_imports,\n authorization_endpoint_override,\n token_endpoint_override,\n userinfo_endpoint_override,\n jwks_uri_override,\n discovery_mode,\n pkce_mode,\n response_mode,\n forward_login_hint,\n on_backchannel_logout,\n created_at\n ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11,\n $12, $13, $14, $15, $16, $17, $18, $19, $20,\n $21, $22, $23)\n ", "describe": { "columns": [], "parameters": { @@ -26,10 +26,11 @@ "Text", "Text", "Bool", + "Text", "Timestamptz" ] }, "nullable": [] }, - "hash": "a711f4c6fa38b98c960ee565038d42ea16db436352b19fcd3b2c620c73d9cc0c" + "hash": "3312f901f70c3b69e0d315206c31ffe11da64835ae297c9277271b8971d5de81" } diff --git a/crates/storage-pg/.sqlx/query-585a1e78834c953c80a0af9215348b0f551b16f4cb57c022b50212cfc3d8431f.json b/crates/storage-pg/.sqlx/query-585a1e78834c953c80a0af9215348b0f551b16f4cb57c022b50212cfc3d8431f.json deleted file mode 100644 index a7b63ca21..000000000 --- a/crates/storage-pg/.sqlx/query-585a1e78834c953c80a0af9215348b0f551b16f4cb57c022b50212cfc3d8431f.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO upstream_oauth_providers (\n upstream_oauth_provider_id,\n issuer,\n human_name,\n brand_name,\n scope,\n token_endpoint_auth_method,\n token_endpoint_signing_alg,\n id_token_signed_response_alg,\n fetch_userinfo,\n userinfo_signed_response_alg,\n client_id,\n encrypted_client_secret,\n claims_imports,\n authorization_endpoint_override,\n token_endpoint_override,\n userinfo_endpoint_override,\n jwks_uri_override,\n discovery_mode,\n pkce_mode,\n response_mode,\n additional_parameters,\n forward_login_hint,\n ui_order,\n created_at\n ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11,\n $12, $13, $14, $15, $16, $17, $18, $19, $20,\n $21, $22, $23, $24)\n ON CONFLICT (upstream_oauth_provider_id)\n DO UPDATE\n SET\n issuer = EXCLUDED.issuer,\n human_name = EXCLUDED.human_name,\n brand_name = EXCLUDED.brand_name,\n scope = EXCLUDED.scope,\n token_endpoint_auth_method = EXCLUDED.token_endpoint_auth_method,\n token_endpoint_signing_alg = EXCLUDED.token_endpoint_signing_alg,\n id_token_signed_response_alg = EXCLUDED.id_token_signed_response_alg,\n fetch_userinfo = EXCLUDED.fetch_userinfo,\n userinfo_signed_response_alg = EXCLUDED.userinfo_signed_response_alg,\n disabled_at = NULL,\n client_id = EXCLUDED.client_id,\n encrypted_client_secret = EXCLUDED.encrypted_client_secret,\n claims_imports = EXCLUDED.claims_imports,\n authorization_endpoint_override = EXCLUDED.authorization_endpoint_override,\n token_endpoint_override = EXCLUDED.token_endpoint_override,\n userinfo_endpoint_override = EXCLUDED.userinfo_endpoint_override,\n jwks_uri_override = EXCLUDED.jwks_uri_override,\n discovery_mode = EXCLUDED.discovery_mode,\n pkce_mode = EXCLUDED.pkce_mode,\n response_mode = EXCLUDED.response_mode,\n additional_parameters = EXCLUDED.additional_parameters,\n forward_login_hint = EXCLUDED.forward_login_hint,\n ui_order = EXCLUDED.ui_order\n RETURNING created_at\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "created_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Text", - "Text", - "Text", - "Text", - "Text", - "Text", - "Text", - "Bool", - "Text", - "Text", - "Text", - "Jsonb", - "Text", - "Text", - "Text", - "Text", - "Text", - "Text", - "Text", - "Jsonb", - "Bool", - "Int4", - "Timestamptz" - ] - }, - "nullable": [ - false - ] - }, - "hash": "585a1e78834c953c80a0af9215348b0f551b16f4cb57c022b50212cfc3d8431f" -} diff --git a/crates/storage-pg/.sqlx/query-a82b87ccfaa1de9a8e6433aaa67382fbb5029d5f7adf95aaa0decd668d25ba89.json b/crates/storage-pg/.sqlx/query-6589987e88fa9dbbd2bd48acd910e08bab57721007c64ef2597cb09a62100792.json similarity index 91% rename from crates/storage-pg/.sqlx/query-a82b87ccfaa1de9a8e6433aaa67382fbb5029d5f7adf95aaa0decd668d25ba89.json rename to crates/storage-pg/.sqlx/query-6589987e88fa9dbbd2bd48acd910e08bab57721007c64ef2597cb09a62100792.json index 7c1a26a86..6bd2768cc 100644 --- a/crates/storage-pg/.sqlx/query-a82b87ccfaa1de9a8e6433aaa67382fbb5029d5f7adf95aaa0decd668d25ba89.json +++ b/crates/storage-pg/.sqlx/query-6589987e88fa9dbbd2bd48acd910e08bab57721007c64ef2597cb09a62100792.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n upstream_oauth_provider_id,\n issuer,\n human_name,\n brand_name,\n scope,\n client_id,\n encrypted_client_secret,\n token_endpoint_signing_alg,\n token_endpoint_auth_method,\n id_token_signed_response_alg,\n fetch_userinfo,\n userinfo_signed_response_alg,\n created_at,\n disabled_at,\n claims_imports as \"claims_imports: Json\",\n jwks_uri_override,\n authorization_endpoint_override,\n token_endpoint_override,\n userinfo_endpoint_override,\n discovery_mode,\n pkce_mode,\n response_mode,\n additional_parameters as \"additional_parameters: Json>\",\n forward_login_hint\n FROM upstream_oauth_providers\n WHERE upstream_oauth_provider_id = $1\n ", + "query": "\n SELECT\n upstream_oauth_provider_id,\n issuer,\n human_name,\n brand_name,\n scope,\n client_id,\n encrypted_client_secret,\n token_endpoint_signing_alg,\n token_endpoint_auth_method,\n id_token_signed_response_alg,\n fetch_userinfo,\n userinfo_signed_response_alg,\n created_at,\n disabled_at,\n claims_imports as \"claims_imports: Json\",\n jwks_uri_override,\n authorization_endpoint_override,\n token_endpoint_override,\n userinfo_endpoint_override,\n discovery_mode,\n pkce_mode,\n response_mode,\n additional_parameters as \"additional_parameters: Json>\",\n forward_login_hint,\n on_backchannel_logout\n FROM upstream_oauth_providers\n WHERE upstream_oauth_provider_id = $1\n ", "describe": { "columns": [ { @@ -122,6 +122,11 @@ "ordinal": 23, "name": "forward_login_hint", "type_info": "Bool" + }, + { + "ordinal": 24, + "name": "on_backchannel_logout", + "type_info": "Text" } ], "parameters": { @@ -153,8 +158,9 @@ false, true, true, + false, false ] }, - "hash": "a82b87ccfaa1de9a8e6433aaa67382fbb5029d5f7adf95aaa0decd668d25ba89" + "hash": "6589987e88fa9dbbd2bd48acd910e08bab57721007c64ef2597cb09a62100792" } diff --git a/crates/storage-pg/.sqlx/query-e6d66a7980933c12ab046958e02d419129ef52ac45bea4345471838016cae917.json b/crates/storage-pg/.sqlx/query-99394fbd9c07d6d24429934b3f7344dfab024b42e47ddc7bd9e551897ba6e9b8.json similarity index 89% rename from crates/storage-pg/.sqlx/query-e6d66a7980933c12ab046958e02d419129ef52ac45bea4345471838016cae917.json rename to crates/storage-pg/.sqlx/query-99394fbd9c07d6d24429934b3f7344dfab024b42e47ddc7bd9e551897ba6e9b8.json index d544590c4..eb1a801c4 100644 --- a/crates/storage-pg/.sqlx/query-e6d66a7980933c12ab046958e02d419129ef52ac45bea4345471838016cae917.json +++ b/crates/storage-pg/.sqlx/query-99394fbd9c07d6d24429934b3f7344dfab024b42e47ddc7bd9e551897ba6e9b8.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n upstream_oauth_provider_id,\n issuer,\n human_name,\n brand_name,\n scope,\n client_id,\n encrypted_client_secret,\n token_endpoint_signing_alg,\n token_endpoint_auth_method,\n id_token_signed_response_alg,\n fetch_userinfo,\n userinfo_signed_response_alg,\n created_at,\n disabled_at,\n claims_imports as \"claims_imports: Json\",\n jwks_uri_override,\n authorization_endpoint_override,\n token_endpoint_override,\n userinfo_endpoint_override,\n discovery_mode,\n pkce_mode,\n response_mode,\n additional_parameters as \"additional_parameters: Json>\",\n forward_login_hint\n FROM upstream_oauth_providers\n WHERE disabled_at IS NULL\n ORDER BY ui_order ASC, upstream_oauth_provider_id ASC\n ", + "query": "\n SELECT\n upstream_oauth_provider_id,\n issuer,\n human_name,\n brand_name,\n scope,\n client_id,\n encrypted_client_secret,\n token_endpoint_signing_alg,\n token_endpoint_auth_method,\n id_token_signed_response_alg,\n fetch_userinfo,\n userinfo_signed_response_alg,\n created_at,\n disabled_at,\n claims_imports as \"claims_imports: Json\",\n jwks_uri_override,\n authorization_endpoint_override,\n token_endpoint_override,\n userinfo_endpoint_override,\n discovery_mode,\n pkce_mode,\n response_mode,\n additional_parameters as \"additional_parameters: Json>\",\n forward_login_hint,\n on_backchannel_logout\n FROM upstream_oauth_providers\n WHERE disabled_at IS NULL\n ORDER BY ui_order ASC, upstream_oauth_provider_id ASC\n ", "describe": { "columns": [ { @@ -122,6 +122,11 @@ "ordinal": 23, "name": "forward_login_hint", "type_info": "Bool" + }, + { + "ordinal": 24, + "name": "on_backchannel_logout", + "type_info": "Text" } ], "parameters": { @@ -151,8 +156,9 @@ false, true, true, + false, false ] }, - "hash": "e6d66a7980933c12ab046958e02d419129ef52ac45bea4345471838016cae917" + "hash": "99394fbd9c07d6d24429934b3f7344dfab024b42e47ddc7bd9e551897ba6e9b8" } diff --git a/crates/storage-pg/migrations/20250630120643_upstream_oauth_on_backchannel_logout.sql b/crates/storage-pg/migrations/20250630120643_upstream_oauth_on_backchannel_logout.sql new file mode 100644 index 000000000..f6031ca62 --- /dev/null +++ b/crates/storage-pg/migrations/20250630120643_upstream_oauth_on_backchannel_logout.sql @@ -0,0 +1,10 @@ +-- Copyright 2025 New Vector Ltd. +-- +-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +-- Please see LICENSE in the repository root for full details. + +-- This defines the behavior when receiving a backchannel logout notification +ALTER TABLE "upstream_oauth_providers" + ADD COLUMN "on_backchannel_logout" TEXT + NOT NULL + DEFAULT 'do_nothing'; diff --git a/crates/storage-pg/src/iden.rs b/crates/storage-pg/src/iden.rs index f7342adf5..ab3ebe967 100644 --- a/crates/storage-pg/src/iden.rs +++ b/crates/storage-pg/src/iden.rs @@ -124,6 +124,7 @@ pub enum UpstreamOAuthProviders { TokenEndpointOverride, AuthorizationEndpointOverride, UserinfoEndpointOverride, + OnBackchannelLogout, } #[derive(sea_query::Iden)] diff --git a/crates/storage-pg/src/upstream_oauth2/mod.rs b/crates/storage-pg/src/upstream_oauth2/mod.rs index 84a52defd..0ec2c3670 100644 --- a/crates/storage-pg/src/upstream_oauth2/mod.rs +++ b/crates/storage-pg/src/upstream_oauth2/mod.rs @@ -20,7 +20,8 @@ pub use self::{ mod tests { use chrono::Duration; use mas_data_model::{ - UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderTokenAuthMethod, + UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderOnBackchannelLogout, + UpstreamOAuthProviderTokenAuthMethod, }; use mas_iana::jose::JsonWebSignatureAlg; use mas_storage::{ @@ -78,6 +79,7 @@ mod tests { additional_authorization_parameters: Vec::new(), forward_login_hint: false, ui_order: 0, + on_backchannel_logout: UpstreamOAuthProviderOnBackchannelLogout::DoNothing, }, ) .await @@ -349,6 +351,7 @@ mod tests { additional_authorization_parameters: Vec::new(), forward_login_hint: false, ui_order: 0, + on_backchannel_logout: UpstreamOAuthProviderOnBackchannelLogout::DoNothing, }, ) .await @@ -486,6 +489,7 @@ mod tests { additional_authorization_parameters: Vec::new(), forward_login_hint: false, ui_order: 0, + on_backchannel_logout: UpstreamOAuthProviderOnBackchannelLogout::DoNothing, }, ) .await diff --git a/crates/storage-pg/src/upstream_oauth2/provider.rs b/crates/storage-pg/src/upstream_oauth2/provider.rs index 4cdb35a11..4086e623c 100644 --- a/crates/storage-pg/src/upstream_oauth2/provider.rs +++ b/crates/storage-pg/src/upstream_oauth2/provider.rs @@ -71,6 +71,7 @@ struct ProviderLookup { response_mode: Option, additional_parameters: Option>>, forward_login_hint: bool, + on_backchannel_logout: String, } impl TryFrom for UpstreamOAuthProvider { @@ -194,6 +195,13 @@ impl TryFrom for UpstreamOAuthProvider { .map(|Json(x)| x) .unwrap_or_default(); + let on_backchannel_logout = value.on_backchannel_logout.parse().map_err(|e| { + DatabaseInconsistencyError::on("upstream_oauth_providers") + .column("on_backchannel_logout") + .row(id) + .source(e) + })?; + Ok(UpstreamOAuthProvider { id, issuer: value.issuer, @@ -219,6 +227,7 @@ impl TryFrom for UpstreamOAuthProvider { response_mode, additional_authorization_parameters, forward_login_hint: value.forward_login_hint, + on_backchannel_logout, }) } } @@ -277,7 +286,8 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { pkce_mode, response_mode, additional_parameters as "additional_parameters: Json>", - forward_login_hint + forward_login_hint, + on_backchannel_logout FROM upstream_oauth_providers WHERE upstream_oauth_provider_id = $1 "#, @@ -340,9 +350,11 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { pkce_mode, response_mode, forward_login_hint, + on_backchannel_logout, created_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, - $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, + $12, $13, $14, $15, $16, $17, $18, $19, $20, + $21, $22, $23) "#, Uuid::from(id), params.issuer.as_deref(), @@ -380,6 +392,7 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { params.pkce_mode.as_str(), params.response_mode.as_ref().map(ToString::to_string), params.forward_login_hint, + params.on_backchannel_logout.as_str(), created_at, ) .traced() @@ -410,6 +423,7 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { pkce_mode: params.pkce_mode, response_mode: params.response_mode, additional_authorization_parameters: params.additional_authorization_parameters, + on_backchannel_logout: params.on_backchannel_logout, forward_login_hint: params.forward_login_hint, }) } @@ -525,10 +539,11 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { additional_parameters, forward_login_hint, ui_order, + on_backchannel_logout, created_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, - $12, $13, $14, $15, $16, $17, $18, $19, $20, - $21, $22, $23, $24) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, + $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, + $21, $22, $23, $24, $25) ON CONFLICT (upstream_oauth_provider_id) DO UPDATE SET @@ -554,7 +569,8 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { response_mode = EXCLUDED.response_mode, additional_parameters = EXCLUDED.additional_parameters, forward_login_hint = EXCLUDED.forward_login_hint, - ui_order = EXCLUDED.ui_order + ui_order = EXCLUDED.ui_order, + on_backchannel_logout = EXCLUDED.on_backchannel_logout RETURNING created_at "#, Uuid::from(id), @@ -595,6 +611,7 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { Json(¶ms.additional_authorization_parameters) as _, params.forward_login_hint, params.ui_order, + params.on_backchannel_logout.as_str(), created_at, ) .traced() @@ -626,6 +643,7 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { response_mode: params.response_mode, additional_authorization_parameters: params.additional_authorization_parameters, forward_login_hint: params.forward_login_hint, + on_backchannel_logout: params.on_backchannel_logout, }) } @@ -843,6 +861,13 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { )), ProviderLookupIden::ForwardLoginHint, ) + .expr_as( + Expr::col(( + UpstreamOAuthProviders::Table, + UpstreamOAuthProviders::OnBackchannelLogout, + )), + ProviderLookupIden::OnBackchannelLogout, + ) .from(UpstreamOAuthProviders::Table) .apply_filter(filter) .generate_pagination( @@ -936,7 +961,8 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { pkce_mode, response_mode, additional_parameters as "additional_parameters: Json>", - forward_login_hint + forward_login_hint, + on_backchannel_logout FROM upstream_oauth_providers WHERE disabled_at IS NULL ORDER BY ui_order ASC, upstream_oauth_provider_id ASC diff --git a/crates/storage/src/upstream_oauth2/provider.rs b/crates/storage/src/upstream_oauth2/provider.rs index d28ed5c73..bc44bfab7 100644 --- a/crates/storage/src/upstream_oauth2/provider.rs +++ b/crates/storage/src/upstream_oauth2/provider.rs @@ -9,8 +9,8 @@ use std::marker::PhantomData; use async_trait::async_trait; use mas_data_model::{ UpstreamOAuthProvider, UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderDiscoveryMode, - UpstreamOAuthProviderPkceMode, UpstreamOAuthProviderResponseMode, - UpstreamOAuthProviderTokenAuthMethod, + UpstreamOAuthProviderOnBackchannelLogout, UpstreamOAuthProviderPkceMode, + UpstreamOAuthProviderResponseMode, UpstreamOAuthProviderTokenAuthMethod, }; use mas_iana::jose::JsonWebSignatureAlg; use oauth2_types::scope::Scope; @@ -101,6 +101,9 @@ pub struct UpstreamOAuthProviderParams { /// The position of the provider in the UI pub ui_order: i32, + + /// The behavior when receiving a backchannel logout notification + pub on_backchannel_logout: UpstreamOAuthProviderOnBackchannelLogout, } /// Filter parameters for listing upstream OAuth 2.0 providers diff --git a/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_upstream_provider_link.snap b/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_upstream_provider_link.snap index a368aa9a5..adb6d4ee4 100644 --- a/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_upstream_provider_link.snap +++ b/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_upstream_provider_link.snap @@ -25,6 +25,7 @@ upstream_oauth_providers: id_token_signed_response_alg: RS256 issuer: ~ jwks_uri_override: ~ + on_backchannel_logout: do_nothing pkce_mode: auto response_mode: query scope: openid diff --git a/crates/syn2mas/src/synapse_reader/config/oidc.rs b/crates/syn2mas/src/synapse_reader/config/oidc.rs index d59c185d3..c08023bf2 100644 --- a/crates/syn2mas/src/synapse_reader/config/oidc.rs +++ b/crates/syn2mas/src/synapse_reader/config/oidc.rs @@ -8,7 +8,8 @@ use std::{collections::BTreeMap, str::FromStr as _}; use chrono::{DateTime, Utc}; use mas_config::{ UpstreamOAuth2ClaimsImports, UpstreamOAuth2DiscoveryMode, UpstreamOAuth2ImportAction, - UpstreamOAuth2PkceMethod, UpstreamOAuth2ResponseMode, UpstreamOAuth2TokenAuthMethod, + UpstreamOAuth2OnBackchannelLogout, UpstreamOAuth2PkceMethod, UpstreamOAuth2ResponseMode, + UpstreamOAuth2TokenAuthMethod, }; use mas_iana::jose::JsonWebSignatureAlg; use oauth2_types::scope::{OPENID, Scope, ScopeToken}; @@ -159,7 +160,6 @@ pub struct OidcProvider { #[serde(default)] skip_verification: bool, - // Unsupported, we want to shout about it #[serde(default)] backchannel_logout_enabled: bool, @@ -219,10 +219,6 @@ impl OidcProvider { warn!("The `id_token_signing_alg_values_supported` option is not supported, ignoring."); } - if self.backchannel_logout_enabled { - warn!("The `backchannel_logout_enabled` option is not supported, ignoring."); - } - if !self.enable_registration { warn!( "Setting the `enable_registration` option to `false` is not supported, ignoring." @@ -319,6 +315,12 @@ impl OidcProvider { self.user_mapping_provider.config.into_mas_config() }; + let on_backchannel_logout = if self.backchannel_logout_enabled { + UpstreamOAuth2OnBackchannelLogout::DoNothing + } else { + UpstreamOAuth2OnBackchannelLogout::LogoutBrowserOnly + }; + Some(mas_config::UpstreamOAuth2Provider { enabled: true, id, @@ -345,6 +347,7 @@ impl OidcProvider { claims_imports, additional_authorization_parameters, forward_login_hint: self.forward_login_hint, + on_backchannel_logout, }) } } diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index d6bf86585..33c973d1e 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -21,9 +21,9 @@ use http::{Method, Uri, Version}; use mas_data_model::{ AuthorizationGrant, BrowserSession, Client, CompatSsoLogin, CompatSsoLoginState, DeviceCodeGrant, UpstreamOAuthLink, UpstreamOAuthProvider, UpstreamOAuthProviderClaimsImports, - UpstreamOAuthProviderDiscoveryMode, UpstreamOAuthProviderPkceMode, - UpstreamOAuthProviderTokenAuthMethod, User, UserEmailAuthentication, - UserEmailAuthenticationCode, UserRecoverySession, UserRegistration, + UpstreamOAuthProviderDiscoveryMode, UpstreamOAuthProviderOnBackchannelLogout, + UpstreamOAuthProviderPkceMode, UpstreamOAuthProviderTokenAuthMethod, User, + UserEmailAuthentication, UserEmailAuthenticationCode, UserRecoverySession, UserRegistration, }; use mas_i18n::DataLocale; use mas_iana::jose::JsonWebSignatureAlg; @@ -1543,6 +1543,7 @@ impl TemplateContext for UpstreamRegister { forward_login_hint: false, created_at: now, disabled_at: None, + on_backchannel_logout: UpstreamOAuthProviderOnBackchannelLogout::DoNothing, }, )] } diff --git a/docs/config.schema.json b/docs/config.schema.json index 534165920..cf2793c25 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -2136,6 +2136,14 @@ "description": "Whether the `login_hint` should be forwarded to the provider in the authorization request.\n\nDefaults to `false`.", "default": false, "type": "boolean" + }, + "on_backchannel_logout": { + "description": "What to do when receiving an OIDC Backchannel logout request.\n\nDefaults to \"do_nothing\".", + "allOf": [ + { + "$ref": "#/definitions/OnBackchannelLogout" + } + ] } } }, @@ -2435,6 +2443,25 @@ } } }, + "OnBackchannelLogout": { + "description": "What to do when receiving an OIDC Backchannel logout request.", + "oneOf": [ + { + "description": "Do nothing", + "type": "string", + "enum": [ + "do_nothing" + ] + }, + { + "description": "Only log out the MAS 'browser session' started by this OIDC session", + "type": "string", + "enum": [ + "logout_browser_only" + ] + } + ] + }, "BrandingConfig": { "description": "Configuration section for tweaking the branding of the service", "type": "object",