From 6b40d2887e719dae0ac9b74520de199003bdfa07 Mon Sep 17 00:00:00 2001 From: mcalinghee Date: Mon, 17 Mar 2025 15:54:19 +0100 Subject: [PATCH 1/4] support RP initiated logout --- crates/handlers/src/upstream_oauth2/cookie.rs | 10 +- crates/handlers/src/upstream_oauth2/logout.rs | 146 ++++++++++++++++++ crates/handlers/src/upstream_oauth2/mod.rs | 1 + crates/handlers/src/views/logout.rs | 38 ++++- 4 files changed, 189 insertions(+), 6 deletions(-) create mode 100644 crates/handlers/src/upstream_oauth2/logout.rs diff --git a/crates/handlers/src/upstream_oauth2/cookie.rs b/crates/handlers/src/upstream_oauth2/cookie.rs index cbcfb5148..c32bff41c 100644 --- a/crates/handlers/src/upstream_oauth2/cookie.rs +++ b/crates/handlers/src/upstream_oauth2/cookie.rs @@ -65,6 +65,12 @@ impl UpstreamSessions { pub fn is_empty(&self) -> bool { self.0.is_empty() } + /// Returns the session IDs in the cookie + pub fn session_ids(&self) -> Vec { + self.0.iter() + .map(|p| p.session) + .collect() + } /// Save the upstreams sessions to the cookie jar pub fn save(self, cookie_jar: CookieJar, clock: &C) -> CookieJar @@ -149,7 +155,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/logout.rs b/crates/handlers/src/upstream_oauth2/logout.rs new file mode 100644 index 000000000..fb44993f9 --- /dev/null +++ b/crates/handlers/src/upstream_oauth2/logout.rs @@ -0,0 +1,146 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +use mas_axum_utils::cookies::CookieJar; +use mas_router::UrlBuilder; +use mas_storage::{ + upstream_oauth2::UpstreamOAuthProviderRepository, RepositoryAccess +}; +use serde::{Deserialize, Serialize}; +use tracing::{info, error}; +use url::Url; +use crate::impl_from_error_for_route; +use thiserror::Error; + +use super::UpstreamSessionsCookie; + +#[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 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 +/// * `session`: The browser session to log out +/// * `grant_id`: Optional grant ID to use for generating id_token_hint +/// +/// # 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, + repo: &mut impl RepositoryAccess, + cookie_jar: &CookieJar, +) -> Result where RouteError: std::convert::From +{ + let mut result: UpstreamLogoutInfo = UpstreamLogoutInfo::default(); + + // Set the post-logout redirect URI to our app's logout completion page + 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 sessions_cookie = UpstreamSessionsCookie::load(&cookie_jar); + + // Standard location for OIDC end session endpoint + let session_ids = sessions_cookie.session_ids(); + if session_ids.is_empty() { + return Ok(result); + } + // We only support the first upstrea session at a time for now + let upstream_session_id = session_ids[0]; + let upstream_session = repo + .upstream_oauth_session() + .lookup(upstream_session_id) + .await? + .ok_or(RouteError::SessionNotFound)?; + + let provider = repo.upstream_oauth_provider() + .lookup(upstream_session.provider_id) + .await? + .ok_or(RouteError::ProviderNotFound)?; + + // Look for end session endpoint + // In a real implementation, we'd have end_session_endpoint fields in the provider + // For now, we'll try to construct one from the issuer if available + if let Some(issuer) = &provider.issuer { + let end_session_endpoint = format!("{}/protocol/openid-connect/logout", issuer); + let mut logout_url = end_session_endpoint; + + // Add post_logout_redirect_uri + if let Some(post_uri) = &result.post_logout_redirect_uri { + if let Ok(mut url) = Url::parse(&logout_url) { + url.query_pairs_mut() + .append_pair("post_logout_redirect_uri", post_uri); + url.query_pairs_mut() + .append_pair("client_id", &provider.client_id); + + // Add id_token_hint if available + if upstream_session.id_token().is_some(){ + url.query_pairs_mut() + .append_pair("id_token_hint", upstream_session.id_token().unwrap()); + } + logout_url = url.to_string(); + } + } + + info!( + upstream_oauth_provider.id = %provider.id, + logout_url = %logout_url, + "Adding RP-initiated logout URL based on issuer" + ); + + result.logout_endpoints = logout_url.clone(); + } else { + info!( + upstream_oauth_provider.id = %provider.id, + "Provider has no issuer defined, cannot construct RP-initiated logout URL" + ); + } + + Ok(result) +} diff --git a/crates/handlers/src/upstream_oauth2/mod.rs b/crates/handlers/src/upstream_oauth2/mod.rs index c387aca1b..3060d55f9 100644 --- a/crates/handlers/src/upstream_oauth2/mod.rs +++ b/crates/handlers/src/upstream_oauth2/mod.rs @@ -18,6 +18,7 @@ use url::Url; pub(crate) mod authorize; pub(crate) mod cache; pub(crate) mod callback; +pub(crate) mod logout; mod cookie; pub(crate) mod link; mod template; diff --git a/crates/handlers/src/views/logout.rs b/crates/handlers/src/views/logout.rs index 5f717a5cf..06c005cf1 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, @@ -16,7 +16,9 @@ use mas_axum_utils::{ use mas_router::{PostAuthAction, UrlBuilder}; use mas_storage::{BoxClock, BoxRepository, user::BrowserSessionRepository}; -use crate::BoundActivityTracker; +use crate::{BoundActivityTracker, upstream_oauth2::logout::get_rp_initiated_logout_endpoints}; + +use tracing::warn; #[tracing::instrument(name = "handlers.views.logout.post", skip_all, err)] pub(crate) async fn post( @@ -27,10 +29,11 @@ pub(crate) async fn post( 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 +42,25 @@ 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, + &mut repo, + &cookie_jar, + ).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 +72,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()) } From fec35c605d80cb5d4d4c5367a73cd524df7a606e Mon Sep 17 00:00:00 2001 From: mcalinghee Date: Wed, 19 Mar 2025 10:38:20 +0100 Subject: [PATCH 2/4] add allow_rp_initiated_logout config --- crates/cli/src/sync.rs | 1 + crates/config/src/sections/upstream_oauth2.rs | 6 + .../src/upstream_oauth2/provider.rs | 1 + .../src/admin/v1/upstream_oauth_links/mod.rs | 1 + crates/handlers/src/upstream_oauth2/cache.rs | 1 + crates/handlers/src/upstream_oauth2/cookie.rs | 4 +- crates/handlers/src/upstream_oauth2/link.rs | 1 + crates/handlers/src/upstream_oauth2/logout.rs | 122 +++++++++--------- crates/handlers/src/upstream_oauth2/mod.rs | 2 +- crates/handlers/src/views/login.rs | 2 + crates/handlers/src/views/logout.rs | 16 +-- ...45b647bb6637e55b662a5a548aa3308c62a8a.json | 44 ------- ...d1b4f2782498ed8f9582c8f28d94c42b3a95.json} | 5 +- ...8df03d648a17ae7bb4acf76d332e03f5dd47.json} | 10 +- ...b25927cb6b6237cbcb7eb5f86298eb6f2eb05.json | 45 +++++++ ...19706b00ac4bdfd7a41a08b578b50af3adc8.json} | 10 +- ...th_providers_allow_rp_initiated_logout.sql | 7 + crates/storage-pg/src/iden.rs | 1 + crates/storage-pg/src/upstream_oauth2/mod.rs | 2 + .../src/upstream_oauth2/provider.rs | 22 +++- .../storage/src/upstream_oauth2/provider.rs | 3 + ...rite_user_with_upstream_provider_link.snap | 1 + crates/templates/src/context.rs | 1 + docs/config.schema.json | 5 + docs/reference/configuration.md | 6 + docs/setup/sso.md | 1 + 26 files changed, 194 insertions(+), 126 deletions(-) delete mode 100644 crates/storage-pg/.sqlx/query-72de26d5e3c56f4b0658685a95b45b647bb6637e55b662a5a548aa3308c62a8a.json rename crates/storage-pg/.sqlx/{query-e25af41189846e26da99e5d8a1462eab5efe330f60ef8c6c813c747424ba7ec9.json => query-7c8fb255bd0d4f29bfdfc17f382ad1b4f2782498ed8f9582c8f28d94c42b3a95.json} (77%) rename crates/storage-pg/.sqlx/{query-1d758df58ccfead4cb39ee8f88f60b382b7881e9c4ead31ff257ff5ff4414b6e.json => query-840415f3e63e8349bd87da5407248df03d648a17ae7bb4acf76d332e03f5dd47.json} (88%) create mode 100644 crates/storage-pg/.sqlx/query-db64b966add36ea8e1a423b7087b25927cb6b6237cbcb7eb5f86298eb6f2eb05.json rename crates/storage-pg/.sqlx/{query-c1e55ffd09181c0d8ddd0df2843690aeae4a20329045ab23639181a0d0903178.json => query-f616051f5e7bdfb5342c18fffe2919706b00ac4bdfd7a41a08b578b50af3adc8.json} (87%) create mode 100644 crates/storage-pg/migrations/20250319150024_upstream_oauth_providers_allow_rp_initiated_logout.sql diff --git a/crates/cli/src/sync.rs b/crates/cli/src/sync.rs index 647ef2635..10a2bbf0d 100644 --- a/crates/cli/src/sync.rs +++ b/crates/cli/src/sync.rs @@ -292,6 +292,7 @@ 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, 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..29866519c 100644 --- a/crates/config/src/sections/upstream_oauth2.rs +++ b/crates/config/src/sections/upstream_oauth2.rs @@ -536,6 +536,12 @@ 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, + /// 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..5a6f07cc3 100644 --- a/crates/data-model/src/upstream_oauth2/provider.rs +++ b/crates/data-model/src/upstream_oauth2/provider.rs @@ -240,6 +240,7 @@ pub struct UpstreamOAuthProvider { pub created_at: DateTime, pub disabled_at: Option>, pub claims_imports: ClaimsImports, + pub allow_rp_initiated_logout: bool, 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..be0e25fad 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,7 @@ mod test_utils { token_endpoint_override: None, userinfo_endpoint_override: None, jwks_uri_override: None, + allow_rp_initiated_logout: false, 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..43979bcaa 100644 --- a/crates/handlers/src/upstream_oauth2/cache.rs +++ b/crates/handlers/src/upstream_oauth2/cache.rs @@ -422,6 +422,7 @@ mod tests { created_at: clock.now(), disabled_at: None, claims_imports: UpstreamOAuthProviderClaimsImports::default(), + allow_rp_initiated_logout: false, additional_authorization_parameters: Vec::new(), }; diff --git a/crates/handlers/src/upstream_oauth2/cookie.rs b/crates/handlers/src/upstream_oauth2/cookie.rs index c32bff41c..eeb752f85 100644 --- a/crates/handlers/src/upstream_oauth2/cookie.rs +++ b/crates/handlers/src/upstream_oauth2/cookie.rs @@ -67,9 +67,7 @@ impl UpstreamSessions { } /// Returns the session IDs in the cookie pub fn session_ids(&self) -> Vec { - self.0.iter() - .map(|p| p.session) - .collect() + self.0.iter().map(|p| p.session).collect() } /// Save the upstreams sessions to the cookie jar diff --git a/crates/handlers/src/upstream_oauth2/link.rs b/crates/handlers/src/upstream_oauth2/link.rs index cacba650a..8f6b12f22 100644 --- a/crates/handlers/src/upstream_oauth2/link.rs +++ b/crates/handlers/src/upstream_oauth2/link.rs @@ -975,6 +975,7 @@ mod tests { discovery_mode: mas_data_model::UpstreamOAuthProviderDiscoveryMode::Oidc, pkce_mode: mas_data_model::UpstreamOAuthProviderPkceMode::Auto, response_mode: None, + allow_rp_initiated_logout: false, 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 index fb44993f9..75e21a22e 100644 --- a/crates/handlers/src/upstream_oauth2/logout.rs +++ b/crates/handlers/src/upstream_oauth2/logout.rs @@ -5,16 +5,14 @@ use mas_axum_utils::cookies::CookieJar; use mas_router::UrlBuilder; -use mas_storage::{ - upstream_oauth2::UpstreamOAuthProviderRepository, RepositoryAccess -}; +use mas_storage::{RepositoryAccess, upstream_oauth2::UpstreamOAuthProviderRepository}; use serde::{Deserialize, Serialize}; -use tracing::{info, error}; -use url::Url; -use crate::impl_from_error_for_route; use thiserror::Error; +use tracing::{error, warn}; +use url::Url; use super::UpstreamSessionsCookie; +use crate::impl_from_error_for_route; #[derive(Serialize, Deserialize)] struct LogoutToken { @@ -26,7 +24,6 @@ struct LogoutToken { 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, } @@ -60,87 +57,92 @@ impl From for RouteError { /// /// * `repo`: The repository to use /// * `url_builder`: URL builder for constructing redirect URIs -/// * `session`: The browser session to log out -/// * `grant_id`: Optional grant ID to use for generating id_token_hint -/// +/// * `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 +/// Returns a `RouteError` if there's an issue accessing the repository pub async fn get_rp_initiated_logout_endpoints( url_builder: &UrlBuilder, repo: &mut impl RepositoryAccess, cookie_jar: &CookieJar, -) -> Result where RouteError: std::convert::From +) -> Result +where + RouteError: std::convert::From, { let mut result: UpstreamLogoutInfo = UpstreamLogoutInfo::default(); - // Set the post-logout redirect URI to our app's logout completion page 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 sessions_cookie = UpstreamSessionsCookie::load(&cookie_jar); - + let sessions_cookie = UpstreamSessionsCookie::load(cookie_jar); // Standard location for OIDC end session endpoint let session_ids = sessions_cookie.session_ids(); if session_ids.is_empty() { return Ok(result); - } - // We only support the first upstrea session at a time for now - let upstream_session_id = session_ids[0]; - let upstream_session = repo - .upstream_oauth_session() - .lookup(upstream_session_id) - .await? - .ok_or(RouteError::SessionNotFound)?; + } + // We only support the first upstream session + let mut provider = None; + let mut upstream_session = None; + for session_id in session_ids { + // Get the session and assign its value, wrapped in Some + let session = repo + .upstream_oauth_session() + .lookup(session_id) + .await? + .ok_or(RouteError::SessionNotFound)?; + // Get the provider and assign its value, wrapped in Some + let prov = repo + .upstream_oauth_provider() + .lookup(session.provider_id) + .await? + .ok_or(RouteError::ProviderNotFound)?; - let provider = repo.upstream_oauth_provider() - .lookup(upstream_session.provider_id) - .await? - .ok_or(RouteError::ProviderNotFound)?; + if prov.allow_rp_initiated_logout { + upstream_session = Some(session); + provider = Some(prov); + break; + } + } - // Look for end session endpoint - // In a real implementation, we'd have end_session_endpoint fields in the provider - // For now, we'll try to construct one from the issuer if available - if let Some(issuer) = &provider.issuer { - let end_session_endpoint = format!("{}/protocol/openid-connect/logout", issuer); - let mut logout_url = end_session_endpoint; - - // Add post_logout_redirect_uri - if let Some(post_uri) = &result.post_logout_redirect_uri { - if let Ok(mut url) = Url::parse(&logout_url) { - url.query_pairs_mut() - .append_pair("post_logout_redirect_uri", post_uri); - url.query_pairs_mut() - .append_pair("client_id", &provider.client_id); - - // Add id_token_hint if available - if upstream_session.id_token().is_some(){ + // Check if we found a provider with allow_rp_initiated_logout + if let Some(provider) = provider { + // Look for end session endpoint + // In a real implementation, we'd have end_session_endpoint fields in the + // provider For now, we'll try to construct one from the issuer if + // available + if let Some(issuer) = &provider.issuer { + let end_session_endpoint = format!("{issuer}/protocol/openid-connect/logout"); + let mut logout_url = end_session_endpoint; + // Add post_logout_redirect_uri + if let Some(post_uri) = &result.post_logout_redirect_uri { + if let Ok(mut url) = Url::parse(&logout_url) { + url.query_pairs_mut() + .append_pair("post_logout_redirect_uri", post_uri); url.query_pairs_mut() - .append_pair("id_token_hint", upstream_session.id_token().unwrap()); + .append_pair("client_id", &provider.client_id); + // Add id_token_hint if available + if let Some(session) = &upstream_session { + if let Some(id_token) = session.id_token() { + url.query_pairs_mut().append_pair("id_token_hint", id_token); + } + } + logout_url = url.to_string(); } - logout_url = url.to_string(); } + result.logout_endpoints.clone_from(&logout_url); + } else { + warn!( + upstream_oauth_provider.id = %provider.id, + "Provider has no issuer defined, cannot construct RP-initiated logout URL" + ); } - - info!( - upstream_oauth_provider.id = %provider.id, - logout_url = %logout_url, - "Adding RP-initiated logout URL based on issuer" - ); - - result.logout_endpoints = logout_url.clone(); - } else { - info!( - upstream_oauth_provider.id = %provider.id, - "Provider has no issuer defined, cannot construct RP-initiated logout URL" - ); } - Ok(result) } diff --git a/crates/handlers/src/upstream_oauth2/mod.rs b/crates/handlers/src/upstream_oauth2/mod.rs index 3060d55f9..ef7f26231 100644 --- a/crates/handlers/src/upstream_oauth2/mod.rs +++ b/crates/handlers/src/upstream_oauth2/mod.rs @@ -18,9 +18,9 @@ use url::Url; pub(crate) mod authorize; pub(crate) mod cache; pub(crate) mod callback; -pub(crate) mod logout; 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..4afb569f6 100644 --- a/crates/handlers/src/views/login.rs +++ b/crates/handlers/src/views/login.rs @@ -494,6 +494,7 @@ mod test { discovery_mode: mas_data_model::UpstreamOAuthProviderDiscoveryMode::Oidc, pkce_mode: mas_data_model::UpstreamOAuthProviderPkceMode::Auto, response_mode: None, + allow_rp_initiated_logout: false, additional_authorization_parameters: Vec::new(), ui_order: 0, }, @@ -535,6 +536,7 @@ mod test { discovery_mode: mas_data_model::UpstreamOAuthProviderDiscoveryMode::Oidc, pkce_mode: mas_data_model::UpstreamOAuthProviderPkceMode::Auto, response_mode: None, + allow_rp_initiated_logout: false, 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 06c005cf1..95b34a851 100644 --- a/crates/handlers/src/views/logout.rs +++ b/crates/handlers/src/views/logout.rs @@ -15,11 +15,10 @@ use mas_axum_utils::{ }; use mas_router::{PostAuthAction, UrlBuilder}; use mas_storage::{BoxClock, BoxRepository, user::BrowserSessionRepository}; +use tracing::warn; use crate::{BoundActivityTracker, upstream_oauth2::logout::get_rp_initiated_logout_endpoints}; -use tracing::warn; - #[tracing::instrument(name = "handlers.views.logout.post", skip_all, err)] pub(crate) async fn post( clock: BoxClock, @@ -42,24 +41,21 @@ 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, - &mut repo, - &cookie_jar, - ).await { + // First, get RP-initiated logout endpoints before actually finishing the + // session + match get_rp_initiated_logout_endpoints(&url_builder, &mut repo, &cookie_jar).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?; } 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/.sqlx/query-e25af41189846e26da99e5d8a1462eab5efe330f60ef8c6c813c747424ba7ec9.json b/crates/storage-pg/.sqlx/query-7c8fb255bd0d4f29bfdfc17f382ad1b4f2782498ed8f9582c8f28d94c42b3a95.json similarity index 77% rename from crates/storage-pg/.sqlx/query-e25af41189846e26da99e5d8a1462eab5efe330f60ef8c6c813c747424ba7ec9.json rename to crates/storage-pg/.sqlx/query-7c8fb255bd0d4f29bfdfc17f382ad1b4f2782498ed8f9582c8f28d94c42b3a95.json index 1a2a19d81..4083f61f9 100644 --- a/crates/storage-pg/.sqlx/query-e25af41189846e26da99e5d8a1462eab5efe330f60ef8c6c813c747424ba7ec9.json +++ b/crates/storage-pg/.sqlx/query-7c8fb255bd0d4f29bfdfc17f382ad1b4f2782498ed8f9582c8f28d94c42b3a95.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 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 ", "describe": { "columns": [], "parameters": { @@ -25,10 +25,11 @@ "Text", "Text", "Text", + "Bool", "Timestamptz" ] }, "nullable": [] }, - "hash": "e25af41189846e26da99e5d8a1462eab5efe330f60ef8c6c813c747424ba7ec9" + "hash": "7c8fb255bd0d4f29bfdfc17f382ad1b4f2782498ed8f9582c8f28d94c42b3a95" } diff --git a/crates/storage-pg/.sqlx/query-1d758df58ccfead4cb39ee8f88f60b382b7881e9c4ead31ff257ff5ff4414b6e.json b/crates/storage-pg/.sqlx/query-840415f3e63e8349bd87da5407248df03d648a17ae7bb4acf76d332e03f5dd47.json similarity index 88% rename from crates/storage-pg/.sqlx/query-1d758df58ccfead4cb39ee8f88f60b382b7881e9c4ead31ff257ff5ff4414b6e.json rename to crates/storage-pg/.sqlx/query-840415f3e63e8349bd87da5407248df03d648a17ae7bb4acf76d332e03f5dd47.json index 65b97215c..87307cf0f 100644 --- a/crates/storage-pg/.sqlx/query-1d758df58ccfead4cb39ee8f88f60b382b7881e9c4ead31ff257ff5ff4414b6e.json +++ b/crates/storage-pg/.sqlx/query-840415f3e63e8349bd87da5407248df03d648a17ae7bb4acf76d332e03f5dd47.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 additional_parameters as \"additional_parameters: Json>\"\n FROM upstream_oauth_providers\n WHERE upstream_oauth_provider_id = $1\n ", "describe": { "columns": [ { @@ -115,6 +115,11 @@ }, { "ordinal": 22, + "name": "allow_rp_initiated_logout", + "type_info": "Bool" + }, + { + "ordinal": 23, "name": "additional_parameters: Json>", "type_info": "Jsonb" } @@ -147,8 +152,9 @@ false, false, true, + false, true ] }, - "hash": "1d758df58ccfead4cb39ee8f88f60b382b7881e9c4ead31ff257ff5ff4414b6e" + "hash": "840415f3e63e8349bd87da5407248df03d648a17ae7bb4acf76d332e03f5dd47" } diff --git a/crates/storage-pg/.sqlx/query-db64b966add36ea8e1a423b7087b25927cb6b6237cbcb7eb5f86298eb6f2eb05.json b/crates/storage-pg/.sqlx/query-db64b966add36ea8e1a423b7087b25927cb6b6237cbcb7eb5f86298eb6f2eb05.json new file mode 100644 index 000000000..fb6256737 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-db64b966add36ea8e1a423b7087b25927cb6b6237cbcb7eb5f86298eb6f2eb05.json @@ -0,0 +1,45 @@ +{ + "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 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)\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 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", + "Jsonb", + "Int4", + "Timestamptz" + ] + }, + "nullable": [ + false + ] + }, + "hash": "db64b966add36ea8e1a423b7087b25927cb6b6237cbcb7eb5f86298eb6f2eb05" +} diff --git a/crates/storage-pg/.sqlx/query-c1e55ffd09181c0d8ddd0df2843690aeae4a20329045ab23639181a0d0903178.json b/crates/storage-pg/.sqlx/query-f616051f5e7bdfb5342c18fffe2919706b00ac4bdfd7a41a08b578b50af3adc8.json similarity index 87% rename from crates/storage-pg/.sqlx/query-c1e55ffd09181c0d8ddd0df2843690aeae4a20329045ab23639181a0d0903178.json rename to crates/storage-pg/.sqlx/query-f616051f5e7bdfb5342c18fffe2919706b00ac4bdfd7a41a08b578b50af3adc8.json index b929df201..542f67f33 100644 --- a/crates/storage-pg/.sqlx/query-c1e55ffd09181c0d8ddd0df2843690aeae4a20329045ab23639181a0d0903178.json +++ b/crates/storage-pg/.sqlx/query-f616051f5e7bdfb5342c18fffe2919706b00ac4bdfd7a41a08b578b50af3adc8.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 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,11 @@ }, { "ordinal": 22, + "name": "allow_rp_initiated_logout", + "type_info": "Bool" + }, + { + "ordinal": 23, "name": "additional_parameters: Json>", "type_info": "Jsonb" } @@ -145,8 +150,9 @@ false, false, true, + false, true ] }, - "hash": "c1e55ffd09181c0d8ddd0df2843690aeae4a20329045ab23639181a0d0903178" + "hash": "f616051f5e7bdfb5342c18fffe2919706b00ac4bdfd7a41a08b578b50af3adc8" } diff --git a/crates/storage-pg/migrations/20250319150024_upstream_oauth_providers_allow_rp_initiated_logout.sql b/crates/storage-pg/migrations/20250319150024_upstream_oauth_providers_allow_rp_initiated_logout.sql new file mode 100644 index 000000000..9f9051149 --- /dev/null +++ b/crates/storage-pg/migrations/20250319150024_upstream_oauth_providers_allow_rp_initiated_logout.sql @@ -0,0 +1,7 @@ +-- 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; \ No newline at end of file diff --git a/crates/storage-pg/src/iden.rs b/crates/storage-pg/src/iden.rs index 71e6f7591..71c55531c 100644 --- a/crates/storage-pg/src/iden.rs +++ b/crates/storage-pg/src/iden.rs @@ -122,6 +122,7 @@ pub enum UpstreamOAuthProviders { TokenEndpointOverride, AuthorizationEndpointOverride, UserinfoEndpointOverride, + AllowRpInitiatedLogout, } #[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..23c2fec13 100644 --- a/crates/storage-pg/src/upstream_oauth2/mod.rs +++ b/crates/storage-pg/src/upstream_oauth2/mod.rs @@ -75,6 +75,7 @@ mod tests { discovery_mode: mas_data_model::UpstreamOAuthProviderDiscoveryMode::Oidc, pkce_mode: mas_data_model::UpstreamOAuthProviderPkceMode::Auto, response_mode: None, + allow_rp_initiated_logout: false, additional_authorization_parameters: Vec::new(), ui_order: 0, }, @@ -322,6 +323,7 @@ mod tests { discovery_mode: mas_data_model::UpstreamOAuthProviderDiscoveryMode::Oidc, pkce_mode: mas_data_model::UpstreamOAuthProviderPkceMode::Auto, response_mode: None, + allow_rp_initiated_logout: false, 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..59d98faff 100644 --- a/crates/storage-pg/src/upstream_oauth2/provider.rs +++ b/crates/storage-pg/src/upstream_oauth2/provider.rs @@ -69,6 +69,7 @@ struct ProviderLookup { discovery_mode: String, pkce_mode: String, response_mode: Option, + allow_rp_initiated_logout: bool, additional_parameters: Option>>, } @@ -216,6 +217,7 @@ impl TryFrom for UpstreamOAuthProvider { discovery_mode, pkce_mode, response_mode, + allow_rp_initiated_logout: value.allow_rp_initiated_logout, additional_authorization_parameters, }) } @@ -274,6 +276,7 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { discovery_mode, pkce_mode, response_mode, + allow_rp_initiated_logout, additional_parameters as "additional_parameters: Json>" FROM upstream_oauth_providers WHERE upstream_oauth_provider_id = $1 @@ -336,9 +339,10 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { discovery_mode, pkce_mode, response_mode, + allow_rp_initiated_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) + $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22) "#, Uuid::from(id), params.issuer.as_deref(), @@ -375,6 +379,7 @@ 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, created_at, ) .traced() @@ -404,6 +409,7 @@ 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, additional_authorization_parameters: params.additional_authorization_parameters, }) } @@ -516,12 +522,13 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { discovery_mode, pkce_mode, response_mode, + allow_rp_initiated_logout, 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) ON CONFLICT (upstream_oauth_provider_id) DO UPDATE SET @@ -545,6 +552,7 @@ 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, additional_parameters = EXCLUDED.additional_parameters, ui_order = EXCLUDED.ui_order RETURNING created_at @@ -584,6 +592,7 @@ 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, Json(¶ms.additional_authorization_parameters) as _, params.ui_order, created_at, @@ -615,6 +624,7 @@ 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, additional_authorization_parameters: params.additional_authorization_parameters, }) } @@ -819,6 +829,13 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { )), ProviderLookupIden::ResponseMode, ) + .expr_as( + Expr::col(( + UpstreamOAuthProviders::Table, + UpstreamOAuthProviders::AllowRpInitiatedLogout, + )), + ProviderLookupIden::AllowRpInitiatedLogout, + ) .expr_as( Expr::col(( UpstreamOAuthProviders::Table, @@ -918,6 +935,7 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { discovery_mode, pkce_mode, response_mode, + allow_rp_initiated_logout, 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..9218c1bd1 100644 --- a/crates/storage/src/upstream_oauth2/provider.rs +++ b/crates/storage/src/upstream_oauth2/provider.rs @@ -93,6 +93,9 @@ 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, + /// 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..e842da919 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: "{}" diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index 26ed200e1..17ff60c0c 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -1492,6 +1492,7 @@ impl TemplateContext for UpstreamRegister { additional_authorization_parameters: Vec::new(), created_at: now, disabled_at: None, + allow_rp_initiated_logout: false, }, )] } diff --git a/docs/config.schema.json b/docs/config.schema.json index e49a75754..43dd3d226 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -2094,6 +2094,11 @@ } ] }, + "allow_rp_initiated_logout": { + "description": "Whether to allow RP-initiated logout\n\nDefaults to `false`.", + "default": false, + "type": "boolean" + }, "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..0a48ab42c 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -713,6 +713,12 @@ 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 + # If multiple providers are defined, this will try to logout from the first upstream provider + # used by the user. + # 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..b11bbff4c 100644 --- a/docs/setup/sso.md +++ b/docs/setup/sso.md @@ -302,6 +302,7 @@ 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" From 7258e9579506f79fb6711722a760c89e4ac51cc3 Mon Sep 17 00:00:00 2001 From: mcalinghee Date: Mon, 7 Apr 2025 17:07:18 +0200 Subject: [PATCH 3/4] refactor session retrieval --- crates/handlers/src/upstream_oauth2/cookie.rs | 4 - crates/handlers/src/upstream_oauth2/logout.rs | 108 ++++++++---------- crates/handlers/src/views/logout.rs | 5 +- 3 files changed, 50 insertions(+), 67 deletions(-) diff --git a/crates/handlers/src/upstream_oauth2/cookie.rs b/crates/handlers/src/upstream_oauth2/cookie.rs index eeb752f85..f1d43c0f9 100644 --- a/crates/handlers/src/upstream_oauth2/cookie.rs +++ b/crates/handlers/src/upstream_oauth2/cookie.rs @@ -65,10 +65,6 @@ impl UpstreamSessions { pub fn is_empty(&self) -> bool { self.0.is_empty() } - /// Returns the session IDs in the cookie - pub fn session_ids(&self) -> Vec { - self.0.iter().map(|p| p.session).collect() - } /// Save the upstreams sessions to the cookie jar pub fn save(self, cookie_jar: CookieJar, clock: &C) -> CookieJar diff --git a/crates/handlers/src/upstream_oauth2/logout.rs b/crates/handlers/src/upstream_oauth2/logout.rs index 75e21a22e..d373e7fdd 100644 --- a/crates/handlers/src/upstream_oauth2/logout.rs +++ b/crates/handlers/src/upstream_oauth2/logout.rs @@ -3,15 +3,14 @@ // SPDX-License-Identifier: AGPL-3.0-only // Please see LICENSE in the repository root for full details. -use mas_axum_utils::cookies::CookieJar; +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, warn}; +use tracing::error; use url::Url; -use super::UpstreamSessionsCookie; use crate::impl_from_error_for_route; #[derive(Serialize, Deserialize)] @@ -69,7 +68,7 @@ impl From for RouteError { pub async fn get_rp_initiated_logout_endpoints( url_builder: &UrlBuilder, repo: &mut impl RepositoryAccess, - cookie_jar: &CookieJar, + browser_session: &BrowserSession, ) -> Result where RouteError: std::convert::From, @@ -81,68 +80,55 @@ where .to_string(); result.post_logout_redirect_uri = Some(post_logout_redirect_uri.clone()); - let sessions_cookie = UpstreamSessionsCookie::load(cookie_jar); - // Standard location for OIDC end session endpoint - let session_ids = sessions_cookie.session_ids(); - if session_ids.is_empty() { - return Ok(result); - } - // We only support the first upstream session - let mut provider = None; - let mut upstream_session = None; - for session_id in session_ids { - // Get the session and assign its value, wrapped in Some - let session = repo - .upstream_oauth_session() - .lookup(session_id) - .await? - .ok_or(RouteError::SessionNotFound)?; - // Get the provider and assign its value, wrapped in Some - let prov = repo - .upstream_oauth_provider() - .lookup(session.provider_id) - .await? - .ok_or(RouteError::ProviderNotFound)?; + 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)?; - if prov.allow_rp_initiated_logout { - upstream_session = Some(session); - provider = Some(prov); - break; - } - } + // Get the session and assign its value, wrapped in Some + let upstream_session = repo + .upstream_oauth_session() + .lookup(upstream_oauth2_session_id) + .await? + .ok_or(RouteError::SessionNotFound)?; + // Get the provider and assign its value, wrapped in Some + let provider = repo + .upstream_oauth_provider() + .lookup(upstream_session.provider_id) + .await? + .filter(|provider| provider.allow_rp_initiated_logout) + .ok_or(RouteError::ProviderNotFound)?; - // Check if we found a provider with allow_rp_initiated_logout - if let Some(provider) = provider { - // Look for end session endpoint - // In a real implementation, we'd have end_session_endpoint fields in the - // provider For now, we'll try to construct one from the issuer if - // available - if let Some(issuer) = &provider.issuer { - let end_session_endpoint = format!("{issuer}/protocol/openid-connect/logout"); - let mut logout_url = end_session_endpoint; - // Add post_logout_redirect_uri - if let Some(post_uri) = &result.post_logout_redirect_uri { - if let Ok(mut url) = Url::parse(&logout_url) { - url.query_pairs_mut() - .append_pair("post_logout_redirect_uri", post_uri); - url.query_pairs_mut() - .append_pair("client_id", &provider.client_id); - // Add id_token_hint if available - if let Some(session) = &upstream_session { - if let Some(id_token) = session.id_token() { - url.query_pairs_mut().append_pair("id_token_hint", id_token); - } - } - logout_url = url.to_string(); + // Look for end session endpoint + // In a real implementation, we'd have end_session_endpoint fields in the + // provider For now, we'll try to construct one from the issuer if + // available + if let Some(issuer) = &provider.issuer { + let end_session_endpoint = format!("{issuer}/protocol/openid-connect/logout"); + let mut logout_url = end_session_endpoint; + // Add post_logout_redirect_uri + if let Some(post_uri) = &result.post_logout_redirect_uri { + if let Ok(mut url) = Url::parse(&logout_url) { + url.query_pairs_mut() + .append_pair("post_logout_redirect_uri", post_uri); + 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() { + url.query_pairs_mut().append_pair("id_token_hint", id_token); } + logout_url = url.to_string(); } - result.logout_endpoints.clone_from(&logout_url); - } else { - warn!( - upstream_oauth_provider.id = %provider.id, - "Provider has no issuer defined, cannot construct RP-initiated logout URL" - ); } + result.logout_endpoints.clone_from(&logout_url); } Ok(result) } diff --git a/crates/handlers/src/views/logout.rs b/crates/handlers/src/views/logout.rs index 95b34a851..1a6200ee1 100644 --- a/crates/handlers/src/views/logout.rs +++ b/crates/handlers/src/views/logout.rs @@ -43,8 +43,9 @@ pub(crate) async fn post( // First, get RP-initiated logout endpoints before actually finishing the // session - match get_rp_initiated_logout_endpoints(&url_builder, &mut repo, &cookie_jar).await - { + // match get_rp_initiated_logout_endpoints(&url_builder, &mut repo, + // &cookie_jar).await + match get_rp_initiated_logout_endpoints(&url_builder, &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() { From 648a390fff973b2cec54aea01c8f7c97e0365393 Mon Sep 17 00:00:00 2001 From: mcalinghee Date: Tue, 8 Apr 2025 11:23:13 +0200 Subject: [PATCH 4/4] add end_session_endpoint config --- crates/cli/src/sync.rs | 1 + crates/config/src/sections/upstream_oauth2.rs | 6 +++ .../src/upstream_oauth2/provider.rs | 1 + .../src/admin/v1/upstream_oauth_links/mod.rs | 1 + crates/handlers/src/upstream_oauth2/cache.rs | 13 +++++ crates/handlers/src/upstream_oauth2/link.rs | 1 + crates/handlers/src/upstream_oauth2/logout.rs | 51 +++++++++---------- crates/handlers/src/views/login.rs | 2 + crates/handlers/src/views/logout.rs | 18 +++++-- crates/oauth2-types/src/oidc.rs | 9 ++++ ...f8afd0e2a75125197f157aab6cb10a8b3faa.json} | 5 +- ...76cc3308847141a37d1856d7b007c03863e5.json} | 10 +++- ...c642ae6683956b373bc7de3ac5b67e158b623.json | 46 +++++++++++++++++ ...995218646dd2e798f53c5711ea3eeeb539f3.json} | 10 +++- ...b25927cb6b6237cbcb7eb5f86298eb6f2eb05.json | 45 ---------------- ...> 20250407154826_end_session_endpoint.sql} | 3 +- crates/storage-pg/src/iden.rs | 1 + crates/storage-pg/src/upstream_oauth2/mod.rs | 2 + .../src/upstream_oauth2/provider.rs | 39 +++++++++++++- .../storage/src/upstream_oauth2/provider.rs | 4 ++ ...rite_user_with_upstream_provider_link.snap | 1 + crates/templates/src/context.rs | 1 + docs/config.schema.json | 5 ++ docs/reference/configuration.md | 4 +- docs/setup/sso.md | 1 + 25 files changed, 193 insertions(+), 87 deletions(-) rename crates/storage-pg/.sqlx/{query-7c8fb255bd0d4f29bfdfc17f382ad1b4f2782498ed8f9582c8f28d94c42b3a95.json => query-0a5edf3c6ef2c493b605f537a7b5f8afd0e2a75125197f157aab6cb10a8b3faa.json} (79%) rename crates/storage-pg/.sqlx/{query-f616051f5e7bdfb5342c18fffe2919706b00ac4bdfd7a41a08b578b50af3adc8.json => query-0dc81401c212d7cf99b50a7de2e276cc3308847141a37d1856d7b007c03863e5.json} (88%) create mode 100644 crates/storage-pg/.sqlx/query-28207621a6b974e8694e687cc7bc642ae6683956b373bc7de3ac5b67e158b623.json rename crates/storage-pg/.sqlx/{query-840415f3e63e8349bd87da5407248df03d648a17ae7bb4acf76d332e03f5dd47.json => query-4fec1c27c062f7e38f8adfa3b1e9995218646dd2e798f53c5711ea3eeeb539f3.json} (89%) delete mode 100644 crates/storage-pg/.sqlx/query-db64b966add36ea8e1a423b7087b25927cb6b6237cbcb7eb5f86298eb6f2eb05.json rename crates/storage-pg/migrations/{20250319150024_upstream_oauth_providers_allow_rp_initiated_logout.sql => 20250407154826_end_session_endpoint.sql} (80%) diff --git a/crates/cli/src/sync.rs b/crates/cli/src/sync.rs index 10a2bbf0d..d5f3daeed 100644 --- a/crates/cli/src/sync.rs +++ b/crates/cli/src/sync.rs @@ -293,6 +293,7 @@ pub async fn config_sync( 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 29866519c..5c2adb719 100644 --- a/crates/config/src/sections/upstream_oauth2.rs +++ b/crates/config/src/sections/upstream_oauth2.rs @@ -542,6 +542,12 @@ pub struct Provider { #[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 5a6f07cc3..000949b1b 100644 --- a/crates/data-model/src/upstream_oauth2/provider.rs +++ b/crates/data-model/src/upstream_oauth2/provider.rs @@ -241,6 +241,7 @@ pub struct UpstreamOAuthProvider { 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 be0e25fad..a84654b7d 100644 --- a/crates/handlers/src/admin/v1/upstream_oauth_links/mod.rs +++ b/crates/handlers/src/admin/v1/upstream_oauth_links/mod.rs @@ -47,6 +47,7 @@ mod test_utils { 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 43979bcaa..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, @@ -423,6 +435,7 @@ mod tests { 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/link.rs b/crates/handlers/src/upstream_oauth2/link.rs index 8f6b12f22..4bca670bd 100644 --- a/crates/handlers/src/upstream_oauth2/link.rs +++ b/crates/handlers/src/upstream_oauth2/link.rs @@ -976,6 +976,7 @@ mod tests { 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 index d373e7fdd..1b9bd188a 100644 --- a/crates/handlers/src/upstream_oauth2/logout.rs +++ b/crates/handlers/src/upstream_oauth2/logout.rs @@ -9,9 +9,9 @@ use mas_storage::{RepositoryAccess, upstream_oauth2::UpstreamOAuthProviderReposi use serde::{Deserialize, Serialize}; use thiserror::Error; use tracing::error; -use url::Url; -use crate::impl_from_error_for_route; +use super::cache::LazyProviderInfos; +use crate::{MetadataCache, impl_from_error_for_route}; #[derive(Serialize, Deserialize)] struct LogoutToken { @@ -40,6 +40,7 @@ pub enum RouteError { } 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 { @@ -67,6 +68,8 @@ impl From for RouteError { /// 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 @@ -74,7 +77,6 @@ where RouteError: std::convert::From, { let mut result: UpstreamLogoutInfo = UpstreamLogoutInfo::default(); - // Set the post-logout redirect URI to our app's logout completion page let post_logout_redirect_uri = url_builder .absolute_url_for(&mas_router::Login::default()) .to_string(); @@ -93,13 +95,12 @@ where })? .ok_or(RouteError::SessionNotFound)?; - // Get the session and assign its value, wrapped in Some let upstream_session = repo .upstream_oauth_session() .lookup(upstream_oauth2_session_id) .await? .ok_or(RouteError::SessionNotFound)?; - // Get the provider and assign its value, wrapped in Some + let provider = repo .upstream_oauth_provider() .lookup(upstream_session.provider_id) @@ -107,28 +108,26 @@ where .filter(|provider| provider.allow_rp_initiated_logout) .ok_or(RouteError::ProviderNotFound)?; - // Look for end session endpoint - // In a real implementation, we'd have end_session_endpoint fields in the - // provider For now, we'll try to construct one from the issuer if - // available - if let Some(issuer) = &provider.issuer { - let end_session_endpoint = format!("{issuer}/protocol/openid-connect/logout"); - let mut logout_url = end_session_endpoint; - // Add post_logout_redirect_uri - if let Some(post_uri) = &result.post_logout_redirect_uri { - if let Ok(mut url) = Url::parse(&logout_url) { - url.query_pairs_mut() - .append_pair("post_logout_redirect_uri", post_uri); - 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() { - url.query_pairs_mut().append_pair("id_token_hint", id_token); - } - logout_url = url.to_string(); - } + // 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(&logout_url); + result + .logout_endpoints + .clone_from(&end_session_url.to_string()); } + Ok(result) } diff --git a/crates/handlers/src/views/login.rs b/crates/handlers/src/views/login.rs index 4afb569f6..744c02902 100644 --- a/crates/handlers/src/views/login.rs +++ b/crates/handlers/src/views/login.rs @@ -495,6 +495,7 @@ mod test { 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, }, @@ -537,6 +538,7 @@ mod test { 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 1a6200ee1..705f46683 100644 --- a/crates/handlers/src/views/logout.rs +++ b/crates/handlers/src/views/logout.rs @@ -17,7 +17,9 @@ use mas_router::{PostAuthAction, UrlBuilder}; use mas_storage::{BoxClock, BoxRepository, user::BrowserSessionRepository}; use tracing::warn; -use crate::{BoundActivityTracker, upstream_oauth2::logout::get_rp_initiated_logout_endpoints}; +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( @@ -25,6 +27,8 @@ 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 { @@ -43,9 +47,15 @@ pub(crate) async fn post( // First, get RP-initiated logout endpoints before actually finishing the // session - // match get_rp_initiated_logout_endpoints(&url_builder, &mut repo, - // &cookie_jar).await - match get_rp_initiated_logout_endpoints(&url_builder, &mut repo, &session).await { + 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() { 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-7c8fb255bd0d4f29bfdfc17f382ad1b4f2782498ed8f9582c8f28d94c42b3a95.json b/crates/storage-pg/.sqlx/query-0a5edf3c6ef2c493b605f537a7b5f8afd0e2a75125197f157aab6cb10a8b3faa.json similarity index 79% rename from crates/storage-pg/.sqlx/query-7c8fb255bd0d4f29bfdfc17f382ad1b4f2782498ed8f9582c8f28d94c42b3a95.json rename to crates/storage-pg/.sqlx/query-0a5edf3c6ef2c493b605f537a7b5f8afd0e2a75125197f157aab6cb10a8b3faa.json index 4083f61f9..6711f7840 100644 --- a/crates/storage-pg/.sqlx/query-7c8fb255bd0d4f29bfdfc17f382ad1b4f2782498ed8f9582c8f28d94c42b3a95.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 allow_rp_initiated_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, $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 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": { @@ -26,10 +26,11 @@ "Text", "Text", "Bool", + "Text", "Timestamptz" ] }, "nullable": [] }, - "hash": "7c8fb255bd0d4f29bfdfc17f382ad1b4f2782498ed8f9582c8f28d94c42b3a95" + "hash": "0a5edf3c6ef2c493b605f537a7b5f8afd0e2a75125197f157aab6cb10a8b3faa" } diff --git a/crates/storage-pg/.sqlx/query-f616051f5e7bdfb5342c18fffe2919706b00ac4bdfd7a41a08b578b50af3adc8.json b/crates/storage-pg/.sqlx/query-0dc81401c212d7cf99b50a7de2e276cc3308847141a37d1856d7b007c03863e5.json similarity index 88% rename from crates/storage-pg/.sqlx/query-f616051f5e7bdfb5342c18fffe2919706b00ac4bdfd7a41a08b578b50af3adc8.json rename to crates/storage-pg/.sqlx/query-0dc81401c212d7cf99b50a7de2e276cc3308847141a37d1856d7b007c03863e5.json index 542f67f33..cc4bd9b3a 100644 --- a/crates/storage-pg/.sqlx/query-f616051f5e7bdfb5342c18fffe2919706b00ac4bdfd7a41a08b578b50af3adc8.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 allow_rp_initiated_logout,\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": [ { @@ -120,6 +120,11 @@ }, { "ordinal": 23, + "name": "end_session_endpoint_override", + "type_info": "Text" + }, + { + "ordinal": 24, "name": "additional_parameters: Json>", "type_info": "Jsonb" } @@ -151,8 +156,9 @@ false, true, false, + true, true ] }, - "hash": "f616051f5e7bdfb5342c18fffe2919706b00ac4bdfd7a41a08b578b50af3adc8" + "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-840415f3e63e8349bd87da5407248df03d648a17ae7bb4acf76d332e03f5dd47.json b/crates/storage-pg/.sqlx/query-4fec1c27c062f7e38f8adfa3b1e9995218646dd2e798f53c5711ea3eeeb539f3.json similarity index 89% rename from crates/storage-pg/.sqlx/query-840415f3e63e8349bd87da5407248df03d648a17ae7bb4acf76d332e03f5dd47.json rename to crates/storage-pg/.sqlx/query-4fec1c27c062f7e38f8adfa3b1e9995218646dd2e798f53c5711ea3eeeb539f3.json index 87307cf0f..b896b6361 100644 --- a/crates/storage-pg/.sqlx/query-840415f3e63e8349bd87da5407248df03d648a17ae7bb4acf76d332e03f5dd47.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 allow_rp_initiated_logout,\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": [ { @@ -120,6 +120,11 @@ }, { "ordinal": 23, + "name": "end_session_endpoint_override", + "type_info": "Text" + }, + { + "ordinal": 24, "name": "additional_parameters: Json>", "type_info": "Jsonb" } @@ -153,8 +158,9 @@ false, true, false, + true, true ] }, - "hash": "840415f3e63e8349bd87da5407248df03d648a17ae7bb4acf76d332e03f5dd47" + "hash": "4fec1c27c062f7e38f8adfa3b1e9995218646dd2e798f53c5711ea3eeeb539f3" } diff --git a/crates/storage-pg/.sqlx/query-db64b966add36ea8e1a423b7087b25927cb6b6237cbcb7eb5f86298eb6f2eb05.json b/crates/storage-pg/.sqlx/query-db64b966add36ea8e1a423b7087b25927cb6b6237cbcb7eb5f86298eb6f2eb05.json deleted file mode 100644 index fb6256737..000000000 --- a/crates/storage-pg/.sqlx/query-db64b966add36ea8e1a423b7087b25927cb6b6237cbcb7eb5f86298eb6f2eb05.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 allow_rp_initiated_logout,\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)\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 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", - "Jsonb", - "Int4", - "Timestamptz" - ] - }, - "nullable": [ - false - ] - }, - "hash": "db64b966add36ea8e1a423b7087b25927cb6b6237cbcb7eb5f86298eb6f2eb05" -} diff --git a/crates/storage-pg/migrations/20250319150024_upstream_oauth_providers_allow_rp_initiated_logout.sql b/crates/storage-pg/migrations/20250407154826_end_session_endpoint.sql similarity index 80% rename from crates/storage-pg/migrations/20250319150024_upstream_oauth_providers_allow_rp_initiated_logout.sql rename to crates/storage-pg/migrations/20250407154826_end_session_endpoint.sql index 9f9051149..eb9c210b7 100644 --- a/crates/storage-pg/migrations/20250319150024_upstream_oauth_providers_allow_rp_initiated_logout.sql +++ b/crates/storage-pg/migrations/20250407154826_end_session_endpoint.sql @@ -4,4 +4,5 @@ -- 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; \ No newline at end of file + 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 71c55531c..a40e663ef 100644 --- a/crates/storage-pg/src/iden.rs +++ b/crates/storage-pg/src/iden.rs @@ -123,6 +123,7 @@ pub enum UpstreamOAuthProviders { 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 23c2fec13..f16d267db 100644 --- a/crates/storage-pg/src/upstream_oauth2/mod.rs +++ b/crates/storage-pg/src/upstream_oauth2/mod.rs @@ -76,6 +76,7 @@ mod tests { 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, }, @@ -324,6 +325,7 @@ mod tests { 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 59d98faff..7f5f9c179 100644 --- a/crates/storage-pg/src/upstream_oauth2/provider.rs +++ b/crates/storage-pg/src/upstream_oauth2/provider.rs @@ -70,6 +70,7 @@ struct ProviderLookup { pkce_mode: String, response_mode: Option, allow_rp_initiated_logout: bool, + end_session_endpoint_override: Option, additional_parameters: Option>>, } @@ -194,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, @@ -218,6 +230,7 @@ impl TryFrom for UpstreamOAuthProvider { pkce_mode, response_mode, allow_rp_initiated_logout: value.allow_rp_initiated_logout, + end_session_endpoint_override, additional_authorization_parameters, }) } @@ -277,6 +290,7 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { 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 @@ -340,9 +354,10 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { 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, $22) + $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23) "#, Uuid::from(id), params.issuer.as_deref(), @@ -380,6 +395,10 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { 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() @@ -410,6 +429,7 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { 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, }) } @@ -523,12 +543,13 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { 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, $24) + $21, $22, $23, $24, $25) ON CONFLICT (upstream_oauth_provider_id) DO UPDATE SET @@ -553,6 +574,7 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { 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 @@ -593,6 +615,10 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { 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, @@ -625,6 +651,7 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { 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, }) } @@ -836,6 +863,13 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { )), ProviderLookupIden::AllowRpInitiatedLogout, ) + .expr_as( + Expr::col(( + UpstreamOAuthProviders::Table, + UpstreamOAuthProviders::EndSessionEndpointOverride, + )), + ProviderLookupIden::EndSessionEndpointOverride, + ) .expr_as( Expr::col(( UpstreamOAuthProviders::Table, @@ -936,6 +970,7 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { 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 9218c1bd1..3f0317075 100644 --- a/crates/storage/src/upstream_oauth2/provider.rs +++ b/crates/storage/src/upstream_oauth2/provider.rs @@ -96,6 +96,10 @@ pub struct UpstreamOAuthProviderParams { /// 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 e842da919..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 @@ -20,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 17ff60c0c..cd491d8a0 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -1493,6 +1493,7 @@ impl TemplateContext for UpstreamRegister { 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 43dd3d226..a59b60c2a 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -2099,6 +2099,11 @@ "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 0a48ab42c..7dc48f272 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -713,9 +713,7 @@ 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 - # If multiple providers are defined, this will try to logout from the first upstream provider - # used by the user. + # Whether to perform a logout request on the provider when logging out from MAS. # Default to false. # allow_rp_initiated_logout: false diff --git a/docs/setup/sso.md b/docs/setup/sso.md index b11bbff4c..5d90c4a7e 100644 --- a/docs/setup/sso.md +++ b/docs/setup/sso.md @@ -308,6 +308,7 @@ upstream_oauth2: 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: