From 80065647965393cdbf69338138ec7828ae08cc06 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 3 Jul 2025 15:14:56 +0200 Subject: [PATCH] Receive and validate backchannel logout requests We don't yet do anything with them, other than logging them --- crates/handlers/src/lib.rs | 4 + .../src/upstream_oauth2/backchannel_logout.rs | 250 ++++++++++++++++++ crates/handlers/src/upstream_oauth2/mod.rs | 1 + crates/jose/src/claims.rs | 26 +- crates/router/src/endpoints.rs | 23 ++ 5 files changed, 303 insertions(+), 1 deletion(-) create mode 100644 crates/handlers/src/upstream_oauth2/backchannel_logout.rs diff --git a/crates/handlers/src/lib.rs b/crates/handlers/src/lib.rs index 51dde981b..5cba740a5 100644 --- a/crates/handlers/src/lib.rs +++ b/crates/handlers/src/lib.rs @@ -440,6 +440,10 @@ where mas_router::UpstreamOAuth2Link::route(), get(self::upstream_oauth2::link::get).post(self::upstream_oauth2::link::post), ) + .route( + mas_router::UpstreamOAuth2BackchannelLogout::route(), + post(self::upstream_oauth2::backchannel_logout::post), + ) .route( mas_router::DeviceCodeLink::route(), get(self::oauth2::device::link::get), diff --git a/crates/handlers/src/upstream_oauth2/backchannel_logout.rs b/crates/handlers/src/upstream_oauth2/backchannel_logout.rs new file mode 100644 index 000000000..082c0465f --- /dev/null +++ b/crates/handlers/src/upstream_oauth2/backchannel_logout.rs @@ -0,0 +1,250 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use std::collections::HashMap; + +use axum::{ + Form, Json, + extract::{Path, State, rejection::FormRejection}, + response::IntoResponse, +}; +use hyper::StatusCode; +use mas_axum_utils::record_error; +use mas_data_model::UpstreamOAuthProvider; +use mas_jose::{ + claims::{self, Claim, TimeOptions}, + jwt::JwtDecodeError, +}; +use mas_oidc_client::{ + error::JwtVerificationError, + requests::jose::{JwtVerificationData, verify_signed_jwt}, +}; +use mas_storage::{ + BoxClock, BoxRepository, Pagination, upstream_oauth2::UpstreamOAuthSessionFilter, +}; +use oauth2_types::errors::{ClientError, ClientErrorCode}; +use serde::Deserialize; +use serde_json::Value; +use thiserror::Error; +use ulid::Ulid; + +use crate::{MetadataCache, impl_from_error_for_route, upstream_oauth2::cache::LazyProviderInfos}; + +#[derive(Debug, Error)] +pub enum RouteError { + /// An internal error occurred. + #[error(transparent)] + Internal(Box), + + /// Invalid request body + #[error(transparent)] + InvalidRequestBody(#[from] FormRejection), + + /// Logout token is not a JWT + #[error("failed to decode logout token")] + InvalidLogoutToken(#[from] JwtDecodeError), + + /// Logout token failed to be verified + #[error("failed to verify logout token")] + LogoutTokenVerification(#[from] JwtVerificationError), + + /// Logout token had invalid claims + #[error("invalid claims in logout token")] + InvalidLogoutTokenClaims(#[from] claims::ClaimError), + + /// Logout token has neither a sub nor a sid claim + #[error("logout token has neither a sub nor a sid claim")] + NoSubOrSidClaim, + + /// Provider not found + #[error("provider not found")] + ProviderNotFound, +} + +impl IntoResponse for RouteError { + fn into_response(self) -> axum::response::Response { + let sentry_event_id = record_error!(self, Self::Internal(_)); + + let response = match self { + e @ Self::Internal(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json( + ClientError::from(ClientErrorCode::ServerError).with_description(e.to_string()), + ), + ) + .into_response(), + + e @ (Self::InvalidLogoutToken(_) + | Self::LogoutTokenVerification(_) + | Self::InvalidRequestBody(_) + | Self::InvalidLogoutTokenClaims(_) + | Self::NoSubOrSidClaim) => ( + StatusCode::BAD_REQUEST, + Json( + ClientError::from(ClientErrorCode::InvalidRequest) + .with_description(e.to_string()), + ), + ) + .into_response(), + + Self::ProviderNotFound => ( + StatusCode::NOT_FOUND, + Json( + ClientError::from(ClientErrorCode::InvalidRequest).with_description( + "Upstream OAuth provider not found, is the backchannel logout URI right?" + .to_owned(), + ), + ), + ) + .into_response(), + }; + + (sentry_event_id, response).into_response() + } +} + +impl_from_error_for_route!(mas_storage::RepositoryError); +impl_from_error_for_route!(mas_oidc_client::error::DiscoveryError); +impl_from_error_for_route!(mas_oidc_client::error::JwksError); + +#[derive(Deserialize)] +pub(crate) struct BackchannelLogoutRequest { + logout_token: String, +} + +#[derive(Deserialize)] +struct LogoutTokenEvents { + #[allow(dead_code)] // We just want to check it deserializes + #[serde(rename = "http://schemas.openid.net/event/backchannel-logout")] + backchannel_logout: HashMap, +} + +const EVENTS: Claim = Claim::new("events"); + +#[tracing::instrument( + name = "handlers.upstream_oauth2.backchannel_logout.post", + fields(upstream_oauth_provider.id = %provider_id), + skip_all, +)] +pub(crate) async fn post( + clock: BoxClock, + mut repo: BoxRepository, + State(metadata_cache): State, + State(client): State, + Path(provider_id): Path, + request: Result, FormRejection>, +) -> Result { + let Form(request) = request?; + let provider = repo + .upstream_oauth_provider() + .lookup(provider_id) + .await? + .filter(UpstreamOAuthProvider::enabled) + .ok_or(RouteError::ProviderNotFound)?; + + let mut lazy_metadata = LazyProviderInfos::new(&metadata_cache, &provider, &client); + + let jwks = + mas_oidc_client::requests::jose::fetch_jwks(&client, lazy_metadata.jwks_uri().await?) + .await?; + + // Validate the logout token. The rules are defined in + // + // + // Upon receiving a logout request at the back-channel logout URI, the RP MUST + // validate the Logout Token as follows: + // + // 1. If the Logout Token is encrypted, decrypt it using the keys and + // algorithms that the Client specified during Registration that the OP was + // to use to encrypt ID Tokens. If ID Token encryption was negotiated with + // the OP at Registration time and the Logout Token is not encrypted, the RP + // SHOULD reject it. + // 2. Validate the Logout Token signature in the same way that an ID Token + // signature is validated, with the following refinements. + // 3. Validate the alg (algorithm) Header Parameter in the same way it is + // validated for ID Tokens. Like ID Tokens, selection of the algorithm used + // is governed by the id_token_signing_alg_values_supported Discovery + // parameter and the id_token_signed_response_alg Registration parameter + // when they are used; otherwise, the value SHOULD be the default of RS256. + // Additionally, an alg with the value none MUST NOT be used for Logout + // Tokens. + // 4. Validate the iss, aud, iat, and exp Claims in the same way they are + // validated in ID Tokens. + // 5. Verify that the Logout Token contains a sub Claim, a sid Claim, or both. + // 6. Verify that the Logout Token contains an events Claim whose value is JSON + // object containing the member name http://schemas.openid.net/event/backchannel-logout. + // 7. Verify that the Logout Token does not contain a nonce Claim. + // 8. Optionally verify that another Logout Token with the same jti value has + // not been recently received. + // 9. Optionally verify that the iss Logout Token Claim matches the iss Claim + // in an ID Token issued for the current session or a recent session of this + // RP with the OP. + // 10. Optionally verify that any sub Logout Token Claim matches the sub Claim + // in an ID Token issued for the current session or a recent session of + // this RP with the OP. + // 11. Optionally verify that any sid Logout Token Claim matches the sid Claim + // in an ID Token issued for the current session or a recent session of + // this RP with the OP. + // + // If any of the validation steps fails, reject the Logout Token and return an + // HTTP 400 Bad Request error. Otherwise, proceed to perform the logout actions. + // + // The ISS and AUD claims are already checked by the verify_signed_jwt() + // function. + + // This verifies (1), (2), (3) and the iss and aud claims for (4) + let token = verify_signed_jwt( + &request.logout_token, + JwtVerificationData { + issuer: provider.issuer.as_deref(), + jwks: &jwks, + client_id: &provider.client_id, + signing_algorithm: &provider.id_token_signed_response_alg, + }, + )?; + + let (_header, mut claims) = token.into_parts(); + + let time_options = TimeOptions::new(clock.now()); + claims::EXP.extract_required_with_options(&mut claims, &time_options)?; // (4) + claims::IAT.extract_required_with_options(&mut claims, &time_options)?; // (4) + + let sub = claims::SUB.extract_optional(&mut claims)?; // (5) + let sid = claims::SID.extract_optional(&mut claims)?; // (5) + if sub.is_none() && sid.is_none() { + return Err(RouteError::NoSubOrSidClaim); + } + + EVENTS.extract_required(&mut claims)?; // (6) + claims::NONCE.assert_absent(&claims)?; // (7) + + // Find the corresponding upstream OAuth 2.0 sessions + let mut filter = UpstreamOAuthSessionFilter::new().for_provider(&provider); + if let Some(sub) = &sub { + filter = filter.with_sub_claim(sub); + } + if let Some(sid) = &sid { + filter = filter.with_sid_claim(sid); + } + + let mut cursor = Pagination::first(100); + let mut sessions = Vec::new(); + loop { + let page = repo.upstream_oauth_session().list(filter, cursor).await?; + + for session in page.edges { + cursor = cursor.after(session.id); + sessions.push(session); + } + + if !page.has_next_page { + break; + } + } + + tracing::info!(sub, sid, %provider.id, "Backchannel logout received, found {} corresponding sessions", sessions.len()); + + Ok(()) +} diff --git a/crates/handlers/src/upstream_oauth2/mod.rs b/crates/handlers/src/upstream_oauth2/mod.rs index e4fa88d6f..272af648b 100644 --- a/crates/handlers/src/upstream_oauth2/mod.rs +++ b/crates/handlers/src/upstream_oauth2/mod.rs @@ -16,6 +16,7 @@ use thiserror::Error; use url::Url; pub(crate) mod authorize; +pub(crate) mod backchannel_logout; pub(crate) mod cache; pub(crate) mod callback; mod cookie; diff --git a/crates/jose/src/claims.rs b/crates/jose/src/claims.rs index 8115d2a76..a4449695f 100644 --- a/crates/jose/src/claims.rs +++ b/crates/jose/src/claims.rs @@ -182,6 +182,22 @@ where Err(e) => Err(e), } } + + /// Assert that the claim is absent. + /// + /// # Errors + /// + /// Returns an error if the claim is present. + pub fn assert_absent( + &self, + claims: &HashMap, + ) -> Result<(), ClaimError> { + if claims.contains_key(self.claim) { + Err(ClaimError::InvalidClaim(self.claim)) + } else { + Ok(()) + } + } } #[derive(Debug, Clone)] @@ -525,7 +541,15 @@ mod oidc_core { pub const UPDATED_AT: Claim = Claim::new("updated_at"); } -pub use self::{oidc_core::*, rfc7519::*}; +/// Claims defined in OpenID.FrontChannel +/// +mod oidc_frontchannel { + use super::Claim; + + pub const SID: Claim = Claim::new("sid"); +} + +pub use self::{oidc_core::*, oidc_frontchannel::*, rfc7519::*}; #[cfg(test)] mod tests { diff --git a/crates/router/src/endpoints.rs b/crates/router/src/endpoints.rs index c285a36e1..3440f8bc6 100644 --- a/crates/router/src/endpoints.rs +++ b/crates/router/src/endpoints.rs @@ -738,6 +738,29 @@ impl Route for UpstreamOAuth2Link { } } +/// `POST /upstream/backchannel-logout/{id}` +pub struct UpstreamOAuth2BackchannelLogout { + id: Ulid, +} + +impl UpstreamOAuth2BackchannelLogout { + #[must_use] + pub const fn new(id: Ulid) -> Self { + Self { id } + } +} + +impl Route for UpstreamOAuth2BackchannelLogout { + type Query = (); + fn route() -> &'static str { + "/upstream/backchannel-logout/{provider_id}" + } + + fn path(&self) -> std::borrow::Cow<'static, str> { + format!("/upstream/backchannel-logout/{}", self.id).into() + } +} + /// `GET|POST /link` #[derive(Default, Serialize, Deserialize, Debug, Clone)] pub struct DeviceCodeLink {