mirror of
https://github.com/element-hq/matrix-authentication-service.git
synced 2026-10-23 19:33:54 +00:00
Receive and validate backchannel logout requests
We don't yet do anything with them, other than logging them
This commit is contained in:
5 files changed
+303
-1
No files matched your search
@@ -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),
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user