Receive and validate backchannel logout requests

We don't yet do anything with them, other than logging them
This commit is contained in:
Quentin Gliech
2025-07-03 15:14:56 +02:00
parent db8c557f81
commit 93820de8f0
5 changed files with 303 additions and 1 deletions

View File

@@ -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),

View File

@@ -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<dyn std::error::Error + Send + Sync + 'static>),
/// 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<String, Value>,
}
const EVENTS: Claim<LogoutTokenEvents> = 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<MetadataCache>,
State(client): State<reqwest::Client>,
Path(provider_id): Path<Ulid>,
request: Result<Form<BackchannelLogoutRequest>, FormRejection>,
) -> Result<impl IntoResponse, RouteError> {
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
// <https://openid.net/specs/openid-connect-backchannel-1_0.html#Validation>
//
// 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(())
}

View File

@@ -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;

View File

@@ -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<String, serde_json::Value>,
) -> 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<Timestamp> = Claim::new("updated_at");
}
pub use self::{oidc_core::*, rfc7519::*};
/// Claims defined in OpenID.FrontChannel
/// <https://openid.net/specs/openid-connect-frontchannel-1_0.html#ClaimsContents>
mod oidc_frontchannel {
use super::Claim;
pub const SID: Claim<String> = Claim::new("sid");
}
pub use self::{oidc_core::*, oidc_frontchannel::*, rfc7519::*};
#[cfg(test)]
mod tests {

View File

@@ -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 {