Skip to content

Commit 59b1593

Browse files
committed
add end_session endpoint to perform rp_initiated_logout
1 parent d30e7c8 commit 59b1593

File tree

9 files changed

+367
-0
lines changed

9 files changed

+367
-0
lines changed

crates/handlers/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,10 @@ where
429429
mas_router::OAuth2AuthorizationEndpoint::route(),
430430
get(self::oauth2::authorization::get),
431431
)
432+
.route(
433+
mas_router::OAuth2EndSession::route(),
434+
get(self::oauth2::end_session::get),
435+
)
432436
.route(
433437
mas_router::Consent::route(),
434438
get(self::oauth2::authorization::consent::get)

crates/handlers/src/oauth2/discovery.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ pub(crate) async fn get(
6565
let revocation_endpoint = Some(url_builder.oauth_revocation_endpoint());
6666
let userinfo_endpoint = Some(url_builder.oidc_userinfo_endpoint());
6767
let registration_endpoint = Some(url_builder.oauth_registration_endpoint());
68+
let end_session_endpoint = Some(url_builder.oauth_end_session_endpoint());
6869

6970
let scopes_supported = Some(vec![scope::OPENID.to_string(), scope::EMAIL.to_string()]);
7071

@@ -172,6 +173,7 @@ pub(crate) async fn get(
172173
request_uri_parameter_supported,
173174
prompt_values_supported,
174175
device_authorization_endpoint,
176+
end_session_endpoint,
175177
..ProviderMetadata::default()
176178
};
177179

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
// Copyright 2024, 2025 New Vector Ltd.
2+
// Copyright 2023, 2024 The Matrix.org Foundation C.I.C.
3+
//
4+
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
// Please see LICENSE files in the repository root for full details.
6+
use axum::{
7+
Json,
8+
extract::{Query, State},
9+
response::{IntoResponse, Redirect, Response},
10+
};
11+
use hyper::StatusCode;
12+
use mas_axum_utils::{SessionInfoExt, cookies::CookieJar, record_error};
13+
use mas_data_model::{BoxClock, BoxRng, Clock};
14+
use mas_keystore::Keystore;
15+
use mas_oidc_client::{
16+
error::IdTokenError,
17+
requests::jose::{JwtVerificationData, verify_id_token},
18+
};
19+
use mas_router::UrlBuilder;
20+
use mas_storage::{
21+
BoxRepository, RepositoryAccess,
22+
queue::{QueueJobRepositoryExt as _, SyncDevicesJob},
23+
user::BrowserSessionRepository,
24+
};
25+
use oauth2_types::errors::{ClientError, ClientErrorCode};
26+
use serde::{Deserialize, Serialize};
27+
use thiserror::Error;
28+
29+
use crate::{BoundActivityTracker, impl_from_error_for_route};
30+
31+
#[derive(Debug, Deserialize, Serialize)]
32+
pub(crate) struct EndSessionParam {
33+
id_token_hint: String,
34+
post_logout_redirect_uri: String,
35+
}
36+
37+
#[derive(Debug, Error)]
38+
pub(crate) enum RouteError {
39+
#[error(transparent)]
40+
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
41+
42+
#[error("bad request")]
43+
BadRequest,
44+
45+
#[error("client not found")]
46+
ClientNotFound,
47+
48+
#[error("client is unauthorized")]
49+
UnauthorizedClient,
50+
51+
// #[error("unsupported token type")]
52+
// UnsupportedTokenType,
53+
#[error("unknown token")]
54+
UnknownToken,
55+
}
56+
57+
impl_from_error_for_route!(mas_storage::RepositoryError);
58+
59+
impl IntoResponse for RouteError {
60+
fn into_response(self) -> Response {
61+
let sentry_event_id = record_error!(self, Self::Internal(_));
62+
let response = match self {
63+
Self::Internal(_) => (
64+
StatusCode::INTERNAL_SERVER_ERROR,
65+
Json(ClientError::from(ClientErrorCode::ServerError)),
66+
)
67+
.into_response(),
68+
69+
Self::BadRequest => (
70+
StatusCode::BAD_REQUEST,
71+
Json(ClientError::from(ClientErrorCode::InvalidRequest)),
72+
)
73+
.into_response(),
74+
75+
Self::ClientNotFound => (
76+
StatusCode::UNAUTHORIZED,
77+
Json(ClientError::from(ClientErrorCode::InvalidClient)),
78+
)
79+
.into_response(),
80+
81+
// Self::ClientNotAllowed |
82+
Self::UnauthorizedClient => (
83+
StatusCode::UNAUTHORIZED,
84+
Json(ClientError::from(ClientErrorCode::UnauthorizedClient)),
85+
)
86+
.into_response(),
87+
88+
// Self::UnsupportedTokenType => (
89+
// StatusCode::BAD_REQUEST,
90+
// Json(ClientError::from(ClientErrorCode::UnsupportedTokenType)),
91+
// )
92+
// .into_response(),
93+
94+
// If the token is unknown, we still return a 200 OK response.
95+
Self::UnknownToken => StatusCode::OK.into_response(),
96+
};
97+
98+
(sentry_event_id, response).into_response()
99+
}
100+
}
101+
102+
impl From<IdTokenError> for RouteError {
103+
fn from(_e: IdTokenError) -> Self {
104+
Self::UnknownToken
105+
}
106+
}
107+
108+
#[tracing::instrument(name = "handlers.oauth2.end_session.get", skip_all)]
109+
pub(crate) async fn get(
110+
mut rng: BoxRng,
111+
clock: BoxClock,
112+
State(key_store): State<Keystore>,
113+
State(url_builder): State<UrlBuilder>,
114+
mut repo: BoxRepository,
115+
activity_tracker: BoundActivityTracker,
116+
Query(params): Query<EndSessionParam>,
117+
cookie_jar: CookieJar,
118+
) -> Result<Response, RouteError> {
119+
let (session_info, cookie_jar) = cookie_jar.session_info();
120+
121+
let browser_session_id = session_info
122+
.current_session_id()
123+
.ok_or(RouteError::BadRequest)?;
124+
125+
let browser_session = repo
126+
.browser_session()
127+
.lookup(browser_session_id)
128+
.await?
129+
.ok_or(RouteError::BadRequest)?;
130+
131+
let oauth_session = repo
132+
.oauth2_session()
133+
.find_by_browser_session(browser_session.id)
134+
.await?
135+
.ok_or(RouteError::BadRequest)?;
136+
137+
let client = repo
138+
.oauth2_client()
139+
.lookup(oauth_session.client_id)
140+
.await?
141+
.filter(|client| client.id_token_signed_response_alg.is_some())
142+
.ok_or(RouteError::ClientNotFound)?;
143+
144+
let jwks = key_store.public_jwks();
145+
let issuer: String = url_builder.oidc_issuer().into();
146+
147+
let id_token_verification_data = JwtVerificationData {
148+
issuer: Some(&issuer),
149+
jwks: &jwks,
150+
signing_algorithm: &client.id_token_signed_response_alg.unwrap(),
151+
client_id: &client.client_id,
152+
};
153+
154+
verify_id_token(
155+
&params.id_token_hint,
156+
id_token_verification_data,
157+
None,
158+
clock.now(),
159+
)?;
160+
161+
// Check that the session is still valid.
162+
if !oauth_session.is_valid() {
163+
// If the session is not valid, we redirect to post logout uri
164+
return Ok((cookie_jar, Redirect::to(&params.post_logout_redirect_uri)).into_response());
165+
}
166+
167+
// Check that the client ending the session is the same as the client that
168+
// created it.
169+
if client.id != oauth_session.client_id {
170+
return Err(RouteError::UnauthorizedClient);
171+
}
172+
173+
activity_tracker
174+
.record_oauth2_session(&clock, &oauth_session)
175+
.await;
176+
177+
// If the session is associated with a user, make sure we schedule a device
178+
// deletion job for all the devices associated with the session.
179+
if let Some(user_id) = oauth_session.user_id {
180+
// Fetch the user
181+
let user = repo
182+
.user()
183+
.lookup(user_id)
184+
.await?
185+
.ok_or(RouteError::UnknownToken)?;
186+
187+
// Schedule a job to sync the devices of the user with the homeserver
188+
repo.queue_job()
189+
.schedule_job(&mut rng, &clock, SyncDevicesJob::new(&user))
190+
.await?;
191+
}
192+
193+
// Now that we checked everything, we can end the session.
194+
repo.oauth2_session().finish(&clock, oauth_session).await?;
195+
196+
activity_tracker
197+
.record_browser_session(&clock, &browser_session)
198+
.await;
199+
repo.browser_session()
200+
.finish(&clock, browser_session)
201+
.await?;
202+
203+
repo.save().await?;
204+
205+
// We always want to clear out the session cookie, even if the session was
206+
// invalid
207+
let cookie_jar = cookie_jar.update_session_info(&session_info.mark_session_ended());
208+
209+
Ok((cookie_jar, Redirect::to(&params.post_logout_redirect_uri)).into_response())
210+
}

crates/handlers/src/oauth2/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ use thiserror::Error;
2525
pub mod authorization;
2626
pub mod device;
2727
pub mod discovery;
28+
pub mod end_session;
2829
pub mod introspection;
2930
pub mod keys;
3031
pub mod registration;

crates/router/src/endpoints.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,14 @@ impl SimpleRoute for OAuth2AuthorizationEndpoint {
155155
const PATH: &'static str = "/authorize";
156156
}
157157

158+
/// `POST /oauth2/end_session`
159+
#[derive(Default, Debug, Clone)]
160+
pub struct OAuth2EndSession;
161+
162+
impl SimpleRoute for OAuth2EndSession {
163+
const PATH: &'static str = "/oauth2/end_session";
164+
}
165+
158166
/// `GET /`
159167
#[derive(Default, Debug, Clone)]
160168
pub struct Index;

crates/router/src/url_builder.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,12 @@ impl UrlBuilder {
160160
self.absolute_url_for(&crate::endpoints::OAuth2Revocation)
161161
}
162162

163+
/// OAuth 2.0 revocation endpoint
164+
#[must_use]
165+
pub fn oauth_end_session_endpoint(&self) -> Url {
166+
self.absolute_url_for(&crate::endpoints::OAuth2EndSession)
167+
}
168+
163169
/// OAuth 2.0 client registration endpoint
164170
#[must_use]
165171
pub fn oauth_registration_endpoint(&self) -> Url {

crates/storage-pg/.sqlx/query-39f88cc7c5c4d2e206aa5f2e91c9c2c0abdcf4672438c54b7152733e66bb85cf.json

Lines changed: 82 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)