Skip to content

Commit d36f03d

Browse files
committed
support RP initiated logout
1 parent c24c3ee commit d36f03d

File tree

4 files changed

+189
-6
lines changed

4 files changed

+189
-6
lines changed

crates/handlers/src/upstream_oauth2/cookie.rs

+9-1
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,12 @@ impl UpstreamSessions {
6565
pub fn is_empty(&self) -> bool {
6666
self.0.is_empty()
6767
}
68+
/// Returns the session IDs in the cookie
69+
pub fn session_ids(&self) -> Vec<Ulid> {
70+
self.0.iter()
71+
.map(|p| p.session)
72+
.collect()
73+
}
6874

6975
/// Save the upstreams sessions to the cookie jar
7076
pub fn save<C>(self, cookie_jar: CookieJar, clock: &C) -> CookieJar
@@ -149,7 +155,9 @@ impl UpstreamSessions {
149155
.position(|p| p.link == Some(link_id))
150156
.ok_or(UpstreamSessionNotFound)?;
151157

152-
self.0.remove(pos);
158+
// We do not remove the session from the cookie, because it might be used by
159+
// in the logout
160+
self.0[pos].link = None;
153161

154162
Ok(self)
155163
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
// Copyright 2025 New Vector Ltd.
2+
//
3+
// SPDX-License-Identifier: AGPL-3.0-only
4+
// Please see LICENSE in the repository root for full details.
5+
6+
use mas_axum_utils::cookies::CookieJar;
7+
use mas_router::UrlBuilder;
8+
use mas_storage::{
9+
upstream_oauth2::UpstreamOAuthProviderRepository, RepositoryAccess
10+
};
11+
use serde::{Deserialize, Serialize};
12+
use tracing::{info, error};
13+
use url::Url;
14+
use crate::impl_from_error_for_route;
15+
use thiserror::Error;
16+
17+
use super::UpstreamSessionsCookie;
18+
19+
#[derive(Serialize, Deserialize)]
20+
struct LogoutToken {
21+
logout_token: String,
22+
}
23+
24+
/// Structure to collect upstream RP-initiated logout endpoints for a user
25+
#[derive(Debug, Default)]
26+
pub struct UpstreamLogoutInfo {
27+
/// Collection of logout endpoints that the user needs to be redirected to
28+
pub logout_endpoints: String,
29+
30+
/// Optional post-logout redirect URI to come back to our app
31+
pub post_logout_redirect_uri: Option<String>,
32+
}
33+
34+
#[derive(Debug, Error)]
35+
pub enum RouteError {
36+
#[error(transparent)]
37+
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
38+
39+
#[error("provider was not found")]
40+
ProviderNotFound,
41+
42+
#[error("session was not found")]
43+
SessionNotFound,
44+
}
45+
46+
impl_from_error_for_route!(mas_storage::RepositoryError);
47+
48+
impl From<reqwest::Error> for RouteError {
49+
fn from(err: reqwest::Error) -> Self {
50+
Self::Internal(Box::new(err))
51+
}
52+
}
53+
54+
/// Get RP-initiated logout URLs for a user's upstream providers
55+
///
56+
/// This retrieves logout endpoints from all connected upstream providers that
57+
/// support RP-initiated logout.
58+
///
59+
/// # Parameters
60+
///
61+
/// * `repo`: The repository to use
62+
/// * `url_builder`: URL builder for constructing redirect URIs
63+
/// * `session`: The browser session to log out
64+
/// * `grant_id`: Optional grant ID to use for generating id_token_hint
65+
///
66+
/// # Returns
67+
///
68+
/// Information about upstream logout endpoints the user should be redirected to
69+
///
70+
/// # Errors
71+
///
72+
/// Returns a RouteError if there's an issue accessing the repository
73+
pub async fn get_rp_initiated_logout_endpoints<E>(
74+
url_builder: &UrlBuilder,
75+
repo: &mut impl RepositoryAccess<Error = E>,
76+
cookie_jar: &CookieJar,
77+
) -> Result<UpstreamLogoutInfo, RouteError> where RouteError: std::convert::From<E>
78+
{
79+
let mut result: UpstreamLogoutInfo = UpstreamLogoutInfo::default();
80+
81+
// Set the post-logout redirect URI to our app's logout completion page
82+
let post_logout_redirect_uri = url_builder
83+
.absolute_url_for(&mas_router::Login::default())
84+
.to_string();
85+
result.post_logout_redirect_uri = Some(post_logout_redirect_uri.clone());
86+
87+
let sessions_cookie = UpstreamSessionsCookie::load(&cookie_jar);
88+
89+
// Standard location for OIDC end session endpoint
90+
let session_ids = sessions_cookie.session_ids();
91+
if session_ids.is_empty() {
92+
return Ok(result);
93+
}
94+
// We only support the first upstrea session at a time for now
95+
let upstream_session_id = session_ids[0];
96+
let upstream_session = repo
97+
.upstream_oauth_session()
98+
.lookup(upstream_session_id)
99+
.await?
100+
.ok_or(RouteError::SessionNotFound)?;
101+
102+
let provider = repo.upstream_oauth_provider()
103+
.lookup(upstream_session.provider_id)
104+
.await?
105+
.ok_or(RouteError::ProviderNotFound)?;
106+
107+
// Look for end session endpoint
108+
// In a real implementation, we'd have end_session_endpoint fields in the provider
109+
// For now, we'll try to construct one from the issuer if available
110+
if let Some(issuer) = &provider.issuer {
111+
let end_session_endpoint = format!("{}/protocol/openid-connect/logout", issuer);
112+
let mut logout_url = end_session_endpoint;
113+
114+
// Add post_logout_redirect_uri
115+
if let Some(post_uri) = &result.post_logout_redirect_uri {
116+
if let Ok(mut url) = Url::parse(&logout_url) {
117+
url.query_pairs_mut()
118+
.append_pair("post_logout_redirect_uri", post_uri);
119+
url.query_pairs_mut()
120+
.append_pair("client_id", &provider.client_id);
121+
122+
// Add id_token_hint if available
123+
if upstream_session.id_token().is_some(){
124+
url.query_pairs_mut()
125+
.append_pair("id_token_hint", upstream_session.id_token().unwrap());
126+
}
127+
logout_url = url.to_string();
128+
}
129+
}
130+
131+
info!(
132+
upstream_oauth_provider.id = %provider.id,
133+
logout_url = %logout_url,
134+
"Adding RP-initiated logout URL based on issuer"
135+
);
136+
137+
result.logout_endpoints = logout_url.clone();
138+
} else {
139+
info!(
140+
upstream_oauth_provider.id = %provider.id,
141+
"Provider has no issuer defined, cannot construct RP-initiated logout URL"
142+
);
143+
}
144+
145+
Ok(result)
146+
}

crates/handlers/src/upstream_oauth2/mod.rs

+1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ use url::Url;
1818
pub(crate) mod authorize;
1919
pub(crate) mod cache;
2020
pub(crate) mod callback;
21+
pub(crate) mod logout;
2122
mod cookie;
2223
pub(crate) mod link;
2324
mod template;

crates/handlers/src/views/logout.rs

+33-5
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
use axum::{
88
extract::{Form, State},
9-
response::IntoResponse,
9+
response::{IntoResponse, Redirect},
1010
};
1111
use mas_axum_utils::{
1212
FancyError, SessionInfoExt,
@@ -16,7 +16,9 @@ use mas_axum_utils::{
1616
use mas_router::{PostAuthAction, UrlBuilder};
1717
use mas_storage::{BoxClock, BoxRepository, user::BrowserSessionRepository};
1818

19-
use crate::BoundActivityTracker;
19+
use crate::{BoundActivityTracker, upstream_oauth2::logout::get_rp_initiated_logout_endpoints};
20+
21+
use tracing::warn;
2022

2123
#[tracing::instrument(name = "handlers.views.logout.post", skip_all, err)]
2224
pub(crate) async fn post(
@@ -27,10 +29,11 @@ pub(crate) async fn post(
2729
activity_tracker: BoundActivityTracker,
2830
Form(form): Form<ProtectedForm<Option<PostAuthAction>>>,
2931
) -> Result<impl IntoResponse, FancyError> {
30-
let form = cookie_jar.verify_form(&clock, form)?;
31-
32+
let form: Option<PostAuthAction> = cookie_jar.verify_form(&clock, form)?;
3233
let (session_info, cookie_jar) = cookie_jar.session_info();
3334

35+
let mut upstream_logout_url = None;
36+
3437
if let Some(session_id) = session_info.current_session_id() {
3538
let maybe_session = repo.browser_session().lookup(session_id).await?;
3639
if let Some(session) = maybe_session {
@@ -39,6 +42,25 @@ pub(crate) async fn post(
3942
.record_browser_session(&clock, &session)
4043
.await;
4144

45+
// First, get RP-initiated logout endpoints before actually finishing the session
46+
match get_rp_initiated_logout_endpoints(
47+
&url_builder,
48+
&mut repo,
49+
&cookie_jar,
50+
).await {
51+
Ok(logout_info) => {
52+
// If we have any RP-initiated logout endpoints, use the first one
53+
if !logout_info.logout_endpoints.is_empty() {
54+
upstream_logout_url = Some(logout_info.logout_endpoints.clone());
55+
}
56+
},
57+
Err(e) => {
58+
warn!("Failed to get RP-initiated logout endpoints: {}", e);
59+
// Continue with logout even if endpoint retrieval fails
60+
}
61+
}
62+
63+
// Now finish the session
4264
repo.browser_session().finish(&clock, session).await?;
4365
}
4466
}
@@ -50,11 +72,17 @@ pub(crate) async fn post(
5072
// invalid
5173
let cookie_jar = cookie_jar.update_session_info(&session_info.mark_session_ended());
5274

75+
// If we have an upstream provider to logout from, redirect to it
76+
if let Some(logout_url) = upstream_logout_url {
77+
return Ok((cookie_jar, Redirect::to(&logout_url)).into_response());
78+
}
79+
80+
// Default behavior - redirect to login or specified action
5381
let destination = if let Some(action) = form {
5482
action.go_next(&url_builder)
5583
} else {
5684
url_builder.redirect(&mas_router::Login::default())
5785
};
5886

59-
Ok((cookie_jar, destination))
87+
Ok((cookie_jar, destination).into_response())
6088
}

0 commit comments

Comments
 (0)