Skip to content

Commit 385fc40

Browse files
committed
support RP initiated logout
1 parent ddec53a commit 385fc40

File tree

4 files changed

+221
-6
lines changed

4 files changed

+221
-6
lines changed

crates/handlers/src/upstream_oauth2/cookie.rs

Lines changed: 9 additions & 1 deletion
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
}
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
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 std::collections::HashMap;
7+
8+
9+
use mas_axum_utils::cookies::CookieJar;
10+
use mas_router::UrlBuilder;
11+
use mas_storage::{
12+
upstream_oauth2::UpstreamOAuthProviderRepository, RepositoryAccess
13+
};
14+
use serde::{Deserialize, Serialize};
15+
use tracing::{info, warn, error};
16+
use url::Url;
17+
use crate::{impl_from_error_for_route};
18+
use thiserror::Error;
19+
20+
use super::UpstreamSessionsCookie;
21+
22+
/// Back-channel logout token
23+
///
24+
/// See https://openid.net/specs/openid-connect-backchannel-1_0.html#BCTokens
25+
#[derive(Debug, Serialize, Deserialize)]
26+
pub struct BackChannelLogoutToken {
27+
/// Issuer Identifier
28+
pub iss: String,
29+
30+
/// Subject Identifier
31+
pub sub: String,
32+
33+
/// Session ID (if available)
34+
#[serde(skip_serializing_if = "Option::is_none")]
35+
pub sid: Option<String>,
36+
37+
/// Audience
38+
pub aud: String,
39+
40+
/// Issued At
41+
pub iat: i64,
42+
43+
/// JWT ID
44+
pub jti: String,
45+
46+
/// Expiration Time
47+
pub exp: i64,
48+
49+
/// Events claim - according to the spec, it must contain the "http://schemas.openid.net/event/backchannel-logout" key
50+
pub events: HashMap<String, serde_json::Value>,
51+
}
52+
53+
#[derive(Serialize, Deserialize)]
54+
struct LogoutToken {
55+
logout_token: String,
56+
}
57+
58+
/// Structure to collect upstream RP-initiated logout endpoints for a user
59+
#[derive(Debug, Default)]
60+
pub struct UpstreamLogoutInfo {
61+
/// Collection of logout endpoints that the user needs to be redirected to
62+
pub logout_endpoints: Vec<String>,
63+
64+
/// Optional post-logout redirect URI to come back to our app
65+
pub post_logout_redirect_uri: Option<String>,
66+
}
67+
68+
#[derive(Debug, Error)]
69+
pub enum RouteError {
70+
#[error(transparent)]
71+
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
72+
73+
#[error("provider was not found")]
74+
ProviderNotFound,
75+
76+
#[error("session was not found")]
77+
SessionNotFound,
78+
}
79+
80+
impl_from_error_for_route!(mas_storage::RepositoryError);
81+
82+
impl From<reqwest::Error> for RouteError {
83+
fn from(err: reqwest::Error) -> Self {
84+
Self::Internal(Box::new(err))
85+
}
86+
}
87+
88+
/// Get RP-initiated logout URLs for a user's upstream providers
89+
///
90+
/// This retrieves logout endpoints from all connected upstream providers that
91+
/// support RP-initiated logout.
92+
///
93+
/// # Parameters
94+
///
95+
/// * `repo`: The repository to use
96+
/// * `url_builder`: URL builder for constructing redirect URIs
97+
/// * `session`: The browser session to log out
98+
/// * `grant_id`: Optional grant ID to use for generating id_token_hint
99+
///
100+
/// # Returns
101+
///
102+
/// Information about upstream logout endpoints the user should be redirected to
103+
///
104+
/// # Errors
105+
///
106+
/// Returns a RouteError if there's an issue accessing the repository
107+
pub async fn get_rp_initiated_logout_endpoints<E>(
108+
url_builder: &UrlBuilder,
109+
repo: &mut impl RepositoryAccess<Error = E>,
110+
cookie_jar: &CookieJar,
111+
) -> Result<UpstreamLogoutInfo, RouteError> where RouteError: std::convert::From<E>
112+
{
113+
let mut result = UpstreamLogoutInfo::default();
114+
115+
// Set the post-logout redirect URI to our app's logout completion page
116+
let post_logout_redirect_uri = url_builder
117+
.absolute_url_for(&mas_router::Login::default())
118+
.to_string();
119+
result.post_logout_redirect_uri = Some(post_logout_redirect_uri.clone());
120+
121+
let sessions_cookie = UpstreamSessionsCookie::load(&cookie_jar);
122+
warn!("sessions_cookie: {:?}", sessions_cookie);
123+
124+
// Standard location for OIDC end session endpoint
125+
for upstream_session_id in sessions_cookie.session_ids() {
126+
let upstream_session = repo
127+
.upstream_oauth_session()
128+
.lookup(upstream_session_id)
129+
.await?
130+
.ok_or(RouteError::SessionNotFound)?;
131+
132+
let provider = repo.upstream_oauth_provider()
133+
.lookup(upstream_session.provider_id)
134+
.await?
135+
.ok_or(RouteError::ProviderNotFound)?;
136+
137+
// Look for end session endpoint
138+
// In a real implementation, we'd have end_session_endpoint fields in the provider
139+
// For now, we'll try to construct one from the issuer if available
140+
if let Some(issuer) = &provider.issuer {
141+
let end_session_endpoint = format!("{}/protocol/openid-connect/logout", issuer);
142+
let mut logout_url = end_session_endpoint;
143+
144+
// Add post_logout_redirect_uri
145+
if let Some(post_uri) = &result.post_logout_redirect_uri {
146+
if let Ok(mut url) = Url::parse(&logout_url) {
147+
url.query_pairs_mut()
148+
.append_pair("post_logout_redirect_uri", post_uri);
149+
url.query_pairs_mut()
150+
.append_pair("client_id", &provider.client_id);
151+
152+
// Add id_token_hint if available
153+
if upstream_session.id_token().is_some(){
154+
url.query_pairs_mut()
155+
.append_pair("id_token_hint", upstream_session.id_token().unwrap());
156+
}
157+
logout_url = url.to_string();
158+
}
159+
}
160+
161+
info!(
162+
upstream_oauth_provider.id = %provider.id,
163+
logout_url = %logout_url,
164+
"Adding RP-initiated logout URL based on issuer"
165+
);
166+
167+
result.logout_endpoints.push(logout_url);
168+
} else {
169+
info!(
170+
upstream_oauth_provider.id = %provider.id,
171+
"Provider has no issuer defined, cannot construct RP-initiated logout URL"
172+
);
173+
}
174+
175+
}
176+
177+
Ok(result)
178+
}

crates/handlers/src/upstream_oauth2/mod.rs

Lines changed: 1 addition & 0 deletions
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

Lines changed: 33 additions & 5 deletions
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[0].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)