Skip to content

logout to upstream OIDC Provider when logging out from MAS #4249

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crates/cli/src/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
12 changes: 12 additions & 0 deletions crates/config/src/sections/upstream_oauth2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Url>,

/// Additional parameters to include in the authorization request
///
/// Orders of the keys are not preserved.
Expand Down
2 changes: 2 additions & 0 deletions crates/data-model/src/upstream_oauth2/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,8 @@ pub struct UpstreamOAuthProvider {
pub created_at: DateTime<Utc>,
pub disabled_at: Option<DateTime<Utc>>,
pub claims_imports: ClaimsImports,
pub allow_rp_initiated_logout: bool,
pub end_session_endpoint_override: Option<Url>,
pub additional_authorization_parameters: Vec<(String, String)>,
}

Expand Down
2 changes: 2 additions & 0 deletions crates/handlers/src/admin/v1/upstream_oauth_links/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down
14 changes: 14 additions & 0 deletions crates/handlers/src/upstream_oauth2/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
};

Expand Down
4 changes: 3 additions & 1 deletion crates/handlers/src/upstream_oauth2/cookie.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
2 changes: 2 additions & 0 deletions crates/handlers/src/upstream_oauth2/link.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
133 changes: 133 additions & 0 deletions crates/handlers/src/upstream_oauth2/logout.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
}

#[derive(Debug, Error)]
pub enum RouteError {
#[error(transparent)]
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),

#[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<reqwest::Error> 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<E>(
url_builder: &UrlBuilder,
metadata_cache: &MetadataCache,
client: &reqwest::Client,
repo: &mut impl RepositoryAccess<Error = E>,
browser_session: &BrowserSession,
) -> Result<UpstreamLogoutInfo, RouteError>
where
RouteError: std::convert::From<E>,
{
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)
}
1 change: 1 addition & 0 deletions crates/handlers/src/upstream_oauth2/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions crates/handlers/src/views/login.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down Expand Up @@ -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,
},
Expand Down
45 changes: 40 additions & 5 deletions crates/handlers/src/views/logout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

use axum::{
extract::{Form, State},
response::IntoResponse,
response::{IntoResponse, Redirect},
};
use mas_axum_utils::{
FancyError, SessionInfoExt,
Expand All @@ -15,22 +15,28 @@ 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(
clock: BoxClock,
mut repo: BoxRepository,
cookie_jar: CookieJar,
State(url_builder): State<UrlBuilder>,
State(metadata_cache): State<MetadataCache>,
State(client): State<reqwest::Client>,
activity_tracker: BoundActivityTracker,
Form(form): Form<ProtectedForm<Option<PostAuthAction>>>,
) -> Result<impl IntoResponse, FancyError> {
let form = cookie_jar.verify_form(&clock, form)?;

let form: Option<PostAuthAction> = 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 {
Expand All @@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to make sure that this logic also happens when the logout is done through the React frontend. I don't have a good suggestion on how to do so for now though

Copy link
Contributor Author

@mcalinghee mcalinghee Apr 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am looking on how to integrate this here but I am not sure this is the way to go

We need to do a redirect to the upsteam end_session_endpoint.
We might also need to integrate some component in the State to perform such as a http client to get/discover the URL.

Let me what your thoughts are.

// 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?;
}
}
Expand All @@ -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())
}
9 changes: 9 additions & 0 deletions crates/oauth2-types/src/oidc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading