diff --git a/crates/cli/src/sync.rs b/crates/cli/src/sync.rs index 647ef2635..d5f3daeed 100644 --- a/crates/cli/src/sync.rs +++ b/crates/cli/src/sync.rs @@ -292,6 +292,8 @@ pub async fn config_sync( fetch_userinfo: provider.fetch_userinfo, userinfo_signed_response_alg: provider.userinfo_signed_response_alg, response_mode, + allow_rp_initiated_logout: provider.allow_rp_initiated_logout, + end_session_endpoint_override: provider.end_session_endpoint, additional_authorization_parameters: provider .additional_authorization_parameters .into_iter() diff --git a/crates/config/src/sections/upstream_oauth2.rs b/crates/config/src/sections/upstream_oauth2.rs index 98b5f3c3c..5c2adb719 100644 --- a/crates/config/src/sections/upstream_oauth2.rs +++ b/crates/config/src/sections/upstream_oauth2.rs @@ -536,6 +536,18 @@ pub struct Provider { #[serde(default, skip_serializing_if = "ClaimsImports::is_default")] pub claims_imports: ClaimsImports, + /// Whether to allow RP-initiated logout + /// + /// Defaults to `false`. + #[serde(default)] + pub allow_rp_initiated_logout: bool, + + /// The URL to use when ending a session onto the upstream provider + /// + /// Defaults to the `end_session_endpoint` provided through discovery + #[serde(skip_serializing_if = "Option::is_none")] + pub end_session_endpoint: Option, + /// Additional parameters to include in the authorization request /// /// Orders of the keys are not preserved. diff --git a/crates/data-model/src/upstream_oauth2/provider.rs b/crates/data-model/src/upstream_oauth2/provider.rs index b81704661..000949b1b 100644 --- a/crates/data-model/src/upstream_oauth2/provider.rs +++ b/crates/data-model/src/upstream_oauth2/provider.rs @@ -240,6 +240,8 @@ pub struct UpstreamOAuthProvider { pub created_at: DateTime, pub disabled_at: Option>, pub claims_imports: ClaimsImports, + pub allow_rp_initiated_logout: bool, + pub end_session_endpoint_override: Option, pub additional_authorization_parameters: Vec<(String, String)>, } 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 12792e3a6..a84654b7d 100644 --- a/crates/handlers/src/admin/v1/upstream_oauth_links/mod.rs +++ b/crates/handlers/src/admin/v1/upstream_oauth_links/mod.rs @@ -46,6 +46,8 @@ mod test_utils { token_endpoint_override: None, userinfo_endpoint_override: None, jwks_uri_override: None, + allow_rp_initiated_logout: false, + end_session_endpoint_override: None, additional_authorization_parameters: Vec::new(), ui_order: 0, } diff --git a/crates/handlers/src/upstream_oauth2/cache.rs b/crates/handlers/src/upstream_oauth2/cache.rs index 02a202745..47b9296ec 100644 --- a/crates/handlers/src/upstream_oauth2/cache.rs +++ b/crates/handlers/src/upstream_oauth2/cache.rs @@ -121,6 +121,18 @@ impl<'a> LazyProviderInfos<'a> { Ok(self.load().await?.userinfo_endpoint()) } + /// Get the end session endpoint for the provider. + /// + /// Uses [`UpstreamOAuthProvider.end_session_endpoint_override`] if set, + /// otherwise uses the one from discovery. + pub async fn end_session_endpoint(&mut self) -> Result<&Url, DiscoveryError> { + if let Some(end_session_endpoint) = &self.provider.end_session_endpoint_override { + return Ok(end_session_endpoint); + } + + Ok(self.load().await?.end_session_endpoint()) + } + /// Get the PKCE methods supported by the provider. /// /// If the mode is set to auto, it will use the ones from discovery, @@ -422,6 +434,8 @@ mod tests { created_at: clock.now(), disabled_at: None, claims_imports: UpstreamOAuthProviderClaimsImports::default(), + allow_rp_initiated_logout: false, + end_session_endpoint_override: None, additional_authorization_parameters: Vec::new(), }; diff --git a/crates/handlers/src/upstream_oauth2/cookie.rs b/crates/handlers/src/upstream_oauth2/cookie.rs index cbcfb5148..f1d43c0f9 100644 --- a/crates/handlers/src/upstream_oauth2/cookie.rs +++ b/crates/handlers/src/upstream_oauth2/cookie.rs @@ -149,7 +149,9 @@ impl UpstreamSessions { .position(|p| p.link == Some(link_id)) .ok_or(UpstreamSessionNotFound)?; - self.0.remove(pos); + // We do not remove the session from the cookie, because it might be used by + // in the logout + self.0[pos].link = None; Ok(self) } diff --git a/crates/handlers/src/upstream_oauth2/link.rs b/crates/handlers/src/upstream_oauth2/link.rs index cacba650a..4bca670bd 100644 --- a/crates/handlers/src/upstream_oauth2/link.rs +++ b/crates/handlers/src/upstream_oauth2/link.rs @@ -975,6 +975,8 @@ mod tests { discovery_mode: mas_data_model::UpstreamOAuthProviderDiscoveryMode::Oidc, pkce_mode: mas_data_model::UpstreamOAuthProviderPkceMode::Auto, response_mode: None, + allow_rp_initiated_logout: false, + end_session_endpoint_override: None, additional_authorization_parameters: Vec::new(), ui_order: 0, }, diff --git a/crates/handlers/src/upstream_oauth2/logout.rs b/crates/handlers/src/upstream_oauth2/logout.rs new file mode 100644 index 000000000..1b9bd188a --- /dev/null +++ b/crates/handlers/src/upstream_oauth2/logout.rs @@ -0,0 +1,133 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +use mas_data_model::{AuthenticationMethod, BrowserSession}; +use mas_router::UrlBuilder; +use mas_storage::{RepositoryAccess, upstream_oauth2::UpstreamOAuthProviderRepository}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use tracing::error; + +use super::cache::LazyProviderInfos; +use crate::{MetadataCache, impl_from_error_for_route}; + +#[derive(Serialize, Deserialize)] +struct LogoutToken { + logout_token: String, +} + +/// Structure to collect upstream RP-initiated logout endpoints for a user +#[derive(Debug, Default)] +pub struct UpstreamLogoutInfo { + /// Collection of logout endpoints that the user needs to be redirected to + pub logout_endpoints: String, + /// Optional post-logout redirect URI to come back to our app + pub post_logout_redirect_uri: Option, +} + +#[derive(Debug, Error)] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[error("provider was not found")] + ProviderNotFound, + + #[error("session was not found")] + SessionNotFound, +} + +impl_from_error_for_route!(mas_storage::RepositoryError); +impl_from_error_for_route!(mas_oidc_client::error::DiscoveryError); + +impl From for RouteError { + fn from(err: reqwest::Error) -> Self { + Self::Internal(Box::new(err)) + } +} + +/// Get RP-initiated logout URLs for a user's upstream providers +/// +/// This retrieves logout endpoints from all connected upstream providers that +/// support RP-initiated logout. +/// +/// # Parameters +/// +/// * `repo`: The repository to use +/// * `url_builder`: URL builder for constructing redirect URIs +/// * `cookie_jar`: Cookie from user's browser session +/// +/// # Returns +/// +/// Information about upstream logout endpoints the user should be redirected to +/// +/// # Errors +/// +/// Returns a `RouteError` if there's an issue accessing the repository +pub async fn get_rp_initiated_logout_endpoints( + url_builder: &UrlBuilder, + metadata_cache: &MetadataCache, + client: &reqwest::Client, + repo: &mut impl RepositoryAccess, + browser_session: &BrowserSession, +) -> Result +where + RouteError: std::convert::From, +{ + let mut result: UpstreamLogoutInfo = UpstreamLogoutInfo::default(); + let post_logout_redirect_uri = url_builder + .absolute_url_for(&mas_router::Login::default()) + .to_string(); + result.post_logout_redirect_uri = Some(post_logout_redirect_uri.clone()); + + let upstream_oauth2_session_id = repo + .browser_session() + .get_last_authentication(browser_session) + .await? + .ok_or(RouteError::SessionNotFound) + .map(|auth| match auth.authentication_method { + AuthenticationMethod::UpstreamOAuth2 { + upstream_oauth2_session_id, + } => Some(upstream_oauth2_session_id), + _ => None, + })? + .ok_or(RouteError::SessionNotFound)?; + + let upstream_session = repo + .upstream_oauth_session() + .lookup(upstream_oauth2_session_id) + .await? + .ok_or(RouteError::SessionNotFound)?; + + let provider = repo + .upstream_oauth_provider() + .lookup(upstream_session.provider_id) + .await? + .filter(|provider| provider.allow_rp_initiated_logout) + .ok_or(RouteError::ProviderNotFound)?; + + // Add post_logout_redirect_uri + if let Some(post_uri) = &result.post_logout_redirect_uri { + let mut lazy_metadata = LazyProviderInfos::new(metadata_cache, &provider, client); + let mut end_session_url = lazy_metadata.end_session_endpoint().await?.clone(); + end_session_url + .query_pairs_mut() + .append_pair("post_logout_redirect_uri", post_uri); + end_session_url + .query_pairs_mut() + .append_pair("client_id", &provider.client_id); + // Add id_token_hint if available + if let Some(id_token) = upstream_session.id_token() { + end_session_url + .query_pairs_mut() + .append_pair("id_token_hint", id_token); + } + result + .logout_endpoints + .clone_from(&end_session_url.to_string()); + } + + Ok(result) +} diff --git a/crates/handlers/src/upstream_oauth2/mod.rs b/crates/handlers/src/upstream_oauth2/mod.rs index c387aca1b..ef7f26231 100644 --- a/crates/handlers/src/upstream_oauth2/mod.rs +++ b/crates/handlers/src/upstream_oauth2/mod.rs @@ -20,6 +20,7 @@ pub(crate) mod cache; pub(crate) mod callback; mod cookie; pub(crate) mod link; +pub(crate) mod logout; mod template; use self::cookie::UpstreamSessions as UpstreamSessionsCookie; diff --git a/crates/handlers/src/views/login.rs b/crates/handlers/src/views/login.rs index 869e9a89d..744c02902 100644 --- a/crates/handlers/src/views/login.rs +++ b/crates/handlers/src/views/login.rs @@ -494,6 +494,8 @@ mod test { discovery_mode: mas_data_model::UpstreamOAuthProviderDiscoveryMode::Oidc, pkce_mode: mas_data_model::UpstreamOAuthProviderPkceMode::Auto, response_mode: None, + allow_rp_initiated_logout: false, + end_session_endpoint_override: None, additional_authorization_parameters: Vec::new(), ui_order: 0, }, @@ -535,6 +537,8 @@ mod test { discovery_mode: mas_data_model::UpstreamOAuthProviderDiscoveryMode::Oidc, pkce_mode: mas_data_model::UpstreamOAuthProviderPkceMode::Auto, response_mode: None, + allow_rp_initiated_logout: false, + end_session_endpoint_override: None, additional_authorization_parameters: Vec::new(), ui_order: 1, }, diff --git a/crates/handlers/src/views/logout.rs b/crates/handlers/src/views/logout.rs index 5f717a5cf..705f46683 100644 --- a/crates/handlers/src/views/logout.rs +++ b/crates/handlers/src/views/logout.rs @@ -6,7 +6,7 @@ use axum::{ extract::{Form, State}, - response::IntoResponse, + response::{IntoResponse, Redirect}, }; use mas_axum_utils::{ FancyError, SessionInfoExt, @@ -15,8 +15,11 @@ use mas_axum_utils::{ }; use mas_router::{PostAuthAction, UrlBuilder}; use mas_storage::{BoxClock, BoxRepository, user::BrowserSessionRepository}; +use tracing::warn; -use crate::BoundActivityTracker; +use crate::{ + BoundActivityTracker, MetadataCache, upstream_oauth2::logout::get_rp_initiated_logout_endpoints, +}; #[tracing::instrument(name = "handlers.views.logout.post", skip_all, err)] pub(crate) async fn post( @@ -24,13 +27,16 @@ pub(crate) async fn post( mut repo: BoxRepository, cookie_jar: CookieJar, State(url_builder): State, + State(metadata_cache): State, + State(client): State, activity_tracker: BoundActivityTracker, Form(form): Form>>, ) -> Result { - let form = cookie_jar.verify_form(&clock, form)?; - + let form: Option = cookie_jar.verify_form(&clock, form)?; let (session_info, cookie_jar) = cookie_jar.session_info(); + let mut upstream_logout_url = None; + if let Some(session_id) = session_info.current_session_id() { let maybe_session = repo.browser_session().lookup(session_id).await?; if let Some(session) = maybe_session { @@ -39,6 +45,29 @@ pub(crate) async fn post( .record_browser_session(&clock, &session) .await; + // First, get RP-initiated logout endpoints before actually finishing the + // session + match get_rp_initiated_logout_endpoints( + &url_builder, + &metadata_cache, + &client, + &mut repo, + &session, + ) + .await + { + Ok(logout_info) => { + // If we have any RP-initiated logout endpoints, use the first one + if !logout_info.logout_endpoints.is_empty() { + upstream_logout_url = Some(logout_info.logout_endpoints.clone()); + } + } + Err(e) => { + warn!("Failed to get RP-initiated logout endpoints: {}", e); + // Continue with logout even if endpoint retrieval fails + } + } + // Now finish the session repo.browser_session().finish(&clock, session).await?; } } @@ -50,11 +79,17 @@ pub(crate) async fn post( // invalid let cookie_jar = cookie_jar.update_session_info(&session_info.mark_session_ended()); + // If we have an upstream provider to logout from, redirect to it + if let Some(logout_url) = upstream_logout_url { + return Ok((cookie_jar, Redirect::to(&logout_url)).into_response()); + } + + // Default behavior - redirect to login or specified action let destination = if let Some(action) = form { action.go_next(&url_builder) } else { url_builder.redirect(&mas_router::Login::default()) }; - Ok((cookie_jar, destination)) + Ok((cookie_jar, destination).into_response()) } diff --git a/crates/oauth2-types/src/oidc.rs b/crates/oauth2-types/src/oidc.rs index 5cbdf2e4b..45504a2b8 100644 --- a/crates/oauth2-types/src/oidc.rs +++ b/crates/oauth2-types/src/oidc.rs @@ -968,6 +968,15 @@ impl VerifiedProviderMetadata { } } + /// URL of the authorization server's end session endpoint. + #[must_use] + pub fn end_session_endpoint(&self) -> &Url { + match &self.end_session_endpoint { + Some(u) => u, + None => unreachable!(), + } + } + /// URL of the authorization server's JWK Set document. #[must_use] pub fn jwks_uri(&self) -> &Url { diff --git a/crates/storage-pg/.sqlx/query-e25af41189846e26da99e5d8a1462eab5efe330f60ef8c6c813c747424ba7ec9.json b/crates/storage-pg/.sqlx/query-0a5edf3c6ef2c493b605f537a7b5f8afd0e2a75125197f157aab6cb10a8b3faa.json similarity index 74% rename from crates/storage-pg/.sqlx/query-e25af41189846e26da99e5d8a1462eab5efe330f60ef8c6c813c747424ba7ec9.json rename to crates/storage-pg/.sqlx/query-0a5edf3c6ef2c493b605f537a7b5f8afd0e2a75125197f157aab6cb10a8b3faa.json index 1a2a19d81..6711f7840 100644 --- a/crates/storage-pg/.sqlx/query-e25af41189846e26da99e5d8a1462eab5efe330f60ef8c6c813c747424ba7ec9.json +++ b/crates/storage-pg/.sqlx/query-0a5edf3c6ef2c493b605f537a7b5f8afd0e2a75125197f157aab6cb10a8b3faa.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 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)\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 allow_rp_initiated_logout,\n end_session_endpoint_override,\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, $23)\n ", "describe": { "columns": [], "parameters": { @@ -25,10 +25,12 @@ "Text", "Text", "Text", + "Bool", + "Text", "Timestamptz" ] }, "nullable": [] }, - "hash": "e25af41189846e26da99e5d8a1462eab5efe330f60ef8c6c813c747424ba7ec9" + "hash": "0a5edf3c6ef2c493b605f537a7b5f8afd0e2a75125197f157aab6cb10a8b3faa" } diff --git a/crates/storage-pg/.sqlx/query-c1e55ffd09181c0d8ddd0df2843690aeae4a20329045ab23639181a0d0903178.json b/crates/storage-pg/.sqlx/query-0dc81401c212d7cf99b50a7de2e276cc3308847141a37d1856d7b007c03863e5.json similarity index 84% rename from crates/storage-pg/.sqlx/query-c1e55ffd09181c0d8ddd0df2843690aeae4a20329045ab23639181a0d0903178.json rename to crates/storage-pg/.sqlx/query-0dc81401c212d7cf99b50a7de2e276cc3308847141a37d1856d7b007c03863e5.json index b929df201..cc4bd9b3a 100644 --- a/crates/storage-pg/.sqlx/query-c1e55ffd09181c0d8ddd0df2843690aeae4a20329045ab23639181a0d0903178.json +++ b/crates/storage-pg/.sqlx/query-0dc81401c212d7cf99b50a7de2e276cc3308847141a37d1856d7b007c03863e5.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 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 allow_rp_initiated_logout,\n end_session_endpoint_override,\n additional_parameters as \"additional_parameters: Json>\"\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": [ { @@ -115,6 +115,16 @@ }, { "ordinal": 22, + "name": "allow_rp_initiated_logout", + "type_info": "Bool" + }, + { + "ordinal": 23, + "name": "end_session_endpoint_override", + "type_info": "Text" + }, + { + "ordinal": 24, "name": "additional_parameters: Json>", "type_info": "Jsonb" } @@ -145,8 +155,10 @@ false, false, true, + false, + true, true ] }, - "hash": "c1e55ffd09181c0d8ddd0df2843690aeae4a20329045ab23639181a0d0903178" + "hash": "0dc81401c212d7cf99b50a7de2e276cc3308847141a37d1856d7b007c03863e5" } diff --git a/crates/storage-pg/.sqlx/query-28207621a6b974e8694e687cc7bc642ae6683956b373bc7de3ac5b67e158b623.json b/crates/storage-pg/.sqlx/query-28207621a6b974e8694e687cc7bc642ae6683956b373bc7de3ac5b67e158b623.json new file mode 100644 index 000000000..9a4658362 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-28207621a6b974e8694e687cc7bc642ae6683956b373bc7de3ac5b67e158b623.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 allow_rp_initiated_logout,\n end_session_endpoint_override,\n additional_parameters,\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, $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 allow_rp_initiated_logout = EXCLUDED.allow_rp_initiated_logout,\n end_session_endpoint_override = EXCLUDED.end_session_endpoint_override,\n additional_parameters = EXCLUDED.additional_parameters,\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", + "Bool", + "Text", + "Jsonb", + "Int4", + "Timestamptz" + ] + }, + "nullable": [ + false + ] + }, + "hash": "28207621a6b974e8694e687cc7bc642ae6683956b373bc7de3ac5b67e158b623" +} diff --git a/crates/storage-pg/.sqlx/query-1d758df58ccfead4cb39ee8f88f60b382b7881e9c4ead31ff257ff5ff4414b6e.json b/crates/storage-pg/.sqlx/query-4fec1c27c062f7e38f8adfa3b1e9995218646dd2e798f53c5711ea3eeeb539f3.json similarity index 85% rename from crates/storage-pg/.sqlx/query-1d758df58ccfead4cb39ee8f88f60b382b7881e9c4ead31ff257ff5ff4414b6e.json rename to crates/storage-pg/.sqlx/query-4fec1c27c062f7e38f8adfa3b1e9995218646dd2e798f53c5711ea3eeeb539f3.json index 65b97215c..b896b6361 100644 --- a/crates/storage-pg/.sqlx/query-1d758df58ccfead4cb39ee8f88f60b382b7881e9c4ead31ff257ff5ff4414b6e.json +++ b/crates/storage-pg/.sqlx/query-4fec1c27c062f7e38f8adfa3b1e9995218646dd2e798f53c5711ea3eeeb539f3.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 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 allow_rp_initiated_logout,\n end_session_endpoint_override,\n additional_parameters as \"additional_parameters: Json>\"\n FROM upstream_oauth_providers\n WHERE upstream_oauth_provider_id = $1\n ", "describe": { "columns": [ { @@ -115,6 +115,16 @@ }, { "ordinal": 22, + "name": "allow_rp_initiated_logout", + "type_info": "Bool" + }, + { + "ordinal": 23, + "name": "end_session_endpoint_override", + "type_info": "Text" + }, + { + "ordinal": 24, "name": "additional_parameters: Json>", "type_info": "Jsonb" } @@ -147,8 +157,10 @@ false, false, true, + false, + true, true ] }, - "hash": "1d758df58ccfead4cb39ee8f88f60b382b7881e9c4ead31ff257ff5ff4414b6e" + "hash": "4fec1c27c062f7e38f8adfa3b1e9995218646dd2e798f53c5711ea3eeeb539f3" } diff --git a/crates/storage-pg/.sqlx/query-72de26d5e3c56f4b0658685a95b45b647bb6637e55b662a5a548aa3308c62a8a.json b/crates/storage-pg/.sqlx/query-72de26d5e3c56f4b0658685a95b45b647bb6637e55b662a5a548aa3308c62a8a.json deleted file mode 100644 index 7ab023046..000000000 --- a/crates/storage-pg/.sqlx/query-72de26d5e3c56f4b0658685a95b45b647bb6637e55b662a5a548aa3308c62a8a.json +++ /dev/null @@ -1,44 +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 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)\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 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", - "Int4", - "Timestamptz" - ] - }, - "nullable": [ - false - ] - }, - "hash": "72de26d5e3c56f4b0658685a95b45b647bb6637e55b662a5a548aa3308c62a8a" -} diff --git a/crates/storage-pg/migrations/20250407154826_end_session_endpoint.sql b/crates/storage-pg/migrations/20250407154826_end_session_endpoint.sql new file mode 100644 index 000000000..eb9c210b7 --- /dev/null +++ b/crates/storage-pg/migrations/20250407154826_end_session_endpoint.sql @@ -0,0 +1,8 @@ +-- Copyright 2025 The Matrix.org Foundation C.I.C. +-- +-- SPDX-License-Identifier: AGPL-3.0-only +-- Please see LICENSE in the repository root for full details. + +ALTER TABLE "upstream_oauth_providers" + ADD COLUMN "allow_rp_initiated_logout" BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN "end_session_endpoint_override" TEXT; diff --git a/crates/storage-pg/src/iden.rs b/crates/storage-pg/src/iden.rs index 71e6f7591..a40e663ef 100644 --- a/crates/storage-pg/src/iden.rs +++ b/crates/storage-pg/src/iden.rs @@ -122,6 +122,8 @@ pub enum UpstreamOAuthProviders { TokenEndpointOverride, AuthorizationEndpointOverride, UserinfoEndpointOverride, + AllowRpInitiatedLogout, + EndSessionEndpointOverride, } #[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 d802e9bdb..f16d267db 100644 --- a/crates/storage-pg/src/upstream_oauth2/mod.rs +++ b/crates/storage-pg/src/upstream_oauth2/mod.rs @@ -75,6 +75,8 @@ mod tests { discovery_mode: mas_data_model::UpstreamOAuthProviderDiscoveryMode::Oidc, pkce_mode: mas_data_model::UpstreamOAuthProviderPkceMode::Auto, response_mode: None, + allow_rp_initiated_logout: false, + end_session_endpoint_override: None, additional_authorization_parameters: Vec::new(), ui_order: 0, }, @@ -322,6 +324,8 @@ mod tests { discovery_mode: mas_data_model::UpstreamOAuthProviderDiscoveryMode::Oidc, pkce_mode: mas_data_model::UpstreamOAuthProviderPkceMode::Auto, response_mode: None, + allow_rp_initiated_logout: false, + end_session_endpoint_override: None, additional_authorization_parameters: Vec::new(), ui_order: 0, }, diff --git a/crates/storage-pg/src/upstream_oauth2/provider.rs b/crates/storage-pg/src/upstream_oauth2/provider.rs index 2e5f7233f..7f5f9c179 100644 --- a/crates/storage-pg/src/upstream_oauth2/provider.rs +++ b/crates/storage-pg/src/upstream_oauth2/provider.rs @@ -69,6 +69,8 @@ struct ProviderLookup { discovery_mode: String, pkce_mode: String, response_mode: Option, + allow_rp_initiated_logout: bool, + end_session_endpoint_override: Option, additional_parameters: Option>>, } @@ -193,6 +195,17 @@ impl TryFrom for UpstreamOAuthProvider { .map(|Json(x)| x) .unwrap_or_default(); + let end_session_endpoint_override = value + .end_session_endpoint_override + .map(|x| x.parse()) + .transpose() + .map_err(|e| { + DatabaseInconsistencyError::on("upstream_oauth_providers") + .column("end_session_endpoint_override") + .row(id) + .source(e) + })?; + Ok(UpstreamOAuthProvider { id, issuer: value.issuer, @@ -216,6 +229,8 @@ impl TryFrom for UpstreamOAuthProvider { discovery_mode, pkce_mode, response_mode, + allow_rp_initiated_logout: value.allow_rp_initiated_logout, + end_session_endpoint_override, additional_authorization_parameters, }) } @@ -274,6 +289,8 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { discovery_mode, pkce_mode, response_mode, + allow_rp_initiated_logout, + end_session_endpoint_override, additional_parameters as "additional_parameters: Json>" FROM upstream_oauth_providers WHERE upstream_oauth_provider_id = $1 @@ -336,9 +353,11 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { discovery_mode, pkce_mode, response_mode, + allow_rp_initiated_logout, + end_session_endpoint_override, created_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, - $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21) + $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23) "#, Uuid::from(id), params.issuer.as_deref(), @@ -375,6 +394,11 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { params.discovery_mode.as_str(), params.pkce_mode.as_str(), params.response_mode.as_ref().map(ToString::to_string), + params.allow_rp_initiated_logout, + params + .end_session_endpoint_override + .as_ref() + .map(ToString::to_string), created_at, ) .traced() @@ -404,6 +428,8 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { discovery_mode: params.discovery_mode, pkce_mode: params.pkce_mode, response_mode: params.response_mode, + allow_rp_initiated_logout: params.allow_rp_initiated_logout, + end_session_endpoint_override: params.end_session_endpoint_override, additional_authorization_parameters: params.additional_authorization_parameters, }) } @@ -516,12 +542,14 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { discovery_mode, pkce_mode, response_mode, + allow_rp_initiated_logout, + end_session_endpoint_override, additional_parameters, ui_order, 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) + $21, $22, $23, $24, $25) ON CONFLICT (upstream_oauth_provider_id) DO UPDATE SET @@ -545,6 +573,8 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { discovery_mode = EXCLUDED.discovery_mode, pkce_mode = EXCLUDED.pkce_mode, response_mode = EXCLUDED.response_mode, + allow_rp_initiated_logout = EXCLUDED.allow_rp_initiated_logout, + end_session_endpoint_override = EXCLUDED.end_session_endpoint_override, additional_parameters = EXCLUDED.additional_parameters, ui_order = EXCLUDED.ui_order RETURNING created_at @@ -584,6 +614,11 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { params.discovery_mode.as_str(), params.pkce_mode.as_str(), params.response_mode.as_ref().map(ToString::to_string), + params.allow_rp_initiated_logout, + params + .end_session_endpoint_override + .as_ref() + .map(ToString::to_string), Json(¶ms.additional_authorization_parameters) as _, params.ui_order, created_at, @@ -615,6 +650,8 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { discovery_mode: params.discovery_mode, pkce_mode: params.pkce_mode, response_mode: params.response_mode, + allow_rp_initiated_logout: params.allow_rp_initiated_logout, + end_session_endpoint_override: params.end_session_endpoint_override, additional_authorization_parameters: params.additional_authorization_parameters, }) } @@ -819,6 +856,20 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { )), ProviderLookupIden::ResponseMode, ) + .expr_as( + Expr::col(( + UpstreamOAuthProviders::Table, + UpstreamOAuthProviders::AllowRpInitiatedLogout, + )), + ProviderLookupIden::AllowRpInitiatedLogout, + ) + .expr_as( + Expr::col(( + UpstreamOAuthProviders::Table, + UpstreamOAuthProviders::EndSessionEndpointOverride, + )), + ProviderLookupIden::EndSessionEndpointOverride, + ) .expr_as( Expr::col(( UpstreamOAuthProviders::Table, @@ -918,6 +969,8 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { discovery_mode, pkce_mode, response_mode, + allow_rp_initiated_logout, + end_session_endpoint_override, additional_parameters as "additional_parameters: Json>" FROM upstream_oauth_providers WHERE disabled_at IS NULL diff --git a/crates/storage/src/upstream_oauth2/provider.rs b/crates/storage/src/upstream_oauth2/provider.rs index 673050a8f..3f0317075 100644 --- a/crates/storage/src/upstream_oauth2/provider.rs +++ b/crates/storage/src/upstream_oauth2/provider.rs @@ -93,6 +93,13 @@ pub struct UpstreamOAuthProviderParams { /// What response mode it should ask pub response_mode: Option, + /// Whether to allow RP-initiated logout + pub allow_rp_initiated_logout: bool, + + /// The URL to use as the `end_session` endpoint. If `None`, the URL will be + /// discovered + pub end_session_endpoint_override: Option, + /// Additional parameters to include in the authorization request pub additional_authorization_parameters: Vec<(String, String)>, 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 1fbf6a100..ae0a2481a 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 @@ -11,6 +11,7 @@ upstream_oauth_links: user_id: 00000000-0000-0000-0000-000000000001 upstream_oauth_providers: - additional_parameters: ~ + allow_rp_initiated_logout: "false" authorization_endpoint_override: ~ brand_name: ~ claims_imports: "{}" @@ -19,6 +20,7 @@ upstream_oauth_providers: disabled_at: ~ discovery_mode: oidc encrypted_client_secret: ~ + end_session_endpoint_override: ~ fetch_userinfo: "false" human_name: ~ id_token_signed_response_alg: RS256 diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index 26ed200e1..cd491d8a0 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -1492,6 +1492,8 @@ impl TemplateContext for UpstreamRegister { additional_authorization_parameters: Vec::new(), created_at: now, disabled_at: None, + allow_rp_initiated_logout: false, + end_session_endpoint_override: None, }, )] } diff --git a/docs/config.schema.json b/docs/config.schema.json index e49a75754..a59b60c2a 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -2094,6 +2094,16 @@ } ] }, + "allow_rp_initiated_logout": { + "description": "Whether to allow RP-initiated logout\n\nDefaults to `false`.", + "default": false, + "type": "boolean" + }, + "end_session_endpoint": { + "description": "The URL to use when ending a session onto the upstream provider\n\nDefaults to the `end_session_endpoint` provided through discovery", + "type": "string", + "format": "uri" + }, "additional_authorization_parameters": { "description": "Additional parameters to include in the authorization request\n\nOrders of the keys are not preserved.", "type": "object", diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 30dbbfca9..7dc48f272 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -713,6 +713,10 @@ upstream_oauth2: # the response parameters in the request body #response_mode: query + # Whether to perform a logout request on the provider when logging out from MAS. + # Default to false. + # allow_rp_initiated_logout: false + # Additional parameters to include in the authorization request #additional_authorization_parameters: # foo: "bar" diff --git a/docs/setup/sso.md b/docs/setup/sso.md index 0dafd9045..5d90c4a7e 100644 --- a/docs/setup/sso.md +++ b/docs/setup/sso.md @@ -302,11 +302,13 @@ upstream_oauth2: discovery_mode: disabled fetch_userinfo: true token_endpoint_auth_method: "client_secret_post" + allow_rp_initiated_logout: false client_id: "" # TO BE FILLED client_secret: "" # TO BE FILLED authorization_endpoint: "https://github.com/login/oauth/authorize" token_endpoint: "https://github.com/login/oauth/access_token" userinfo_endpoint: "https://api.github.com/user" + end_session_endpoint: "https://github.com/login/oauth/logout" scope: "read:user" claims_imports: subject: