feat(oidc): impl back channel logout

This commit is contained in:
Edouard Vanbelle
2026-08-03 08:00:13 +02:00
parent 166b8c4891
commit acd4420fe3
11 changed files with 673 additions and 9 deletions
+47
View File
@@ -253,6 +253,24 @@ pub struct OidcIdClaims {
/// `LocaleRegistry`; ignored on subsequent logins so a later
/// UI-driven choice isn't overwritten by the IdP.
pub locale: Option<String>,
/// OIDC session identifier. Populated only when the IdP emits `sid`
/// on the id_token (Keycloak: "Backchannel Logout Session Required"
/// on the client). When present, we persist it on the OxiCloud
/// session so Back-Channel Logout can revoke that specific device.
pub sid: Option<String>,
}
/// OIDC Back-Channel Logout 1.0 identifiers extracted from a validated
/// logout_token. The BCL handler uses these to resolve which OxiCloud
/// session(s) to revoke: `sid` for per-device (preferred), else `sub` for
/// all of the user's sessions.
#[derive(Debug, Clone)]
pub struct OidcLogoutClaims {
pub sub: Option<String>,
pub sid: Option<String>,
/// JWT identifier — used by the app service to prevent replay of the
/// same logout_token within the token's freshness window.
pub jti: Option<String>,
}
/// Port for OIDC operations — implemented in infrastructure layer
@@ -288,6 +306,20 @@ pub trait OidcServicePort: Send + Sync + 'static {
/// Get the OIDC provider display name
fn provider_name(&self) -> &str;
/// Validate an OIDC Back-Channel Logout 1.0 logout_token.
///
/// Enforces all mandatory spec checks: JWKS signature, iss+aud match,
/// `events` claim contains the backchannel-logout URI, presence of
/// `sub` and/or `sid`, absence of `nonce`. On any failure returns
/// `AccessDenied` — the handler translates to a 400 per spec.
///
/// The caller is responsible for jti replay prevention (this validator
/// is stateless).
async fn validate_logout_token(
&self,
logout_token: &str,
) -> Result<OidcLogoutClaims, DomainError>;
/// Build an RP-initiated logout URL (OIDC Session Management 1.0).
///
/// Returns `Ok(None)` when the IdP's discovery document does not advertise
@@ -333,6 +365,21 @@ pub trait SessionStoragePort: Send + Sync + 'static {
/// Revokes all sessions in a token family (used when replay of a revoked token is detected)
async fn revoke_session_family(&self, family_id: Uuid) -> Result<u64, DomainError>;
/// OIDC Back-Channel Logout: revoke sessions matching an IdP-supplied
/// `sid` (per-device). Returns the user id(s) of revoked sessions so
/// the caller can dispatch lifecycle hooks.
async fn revoke_sessions_by_oidc_sid(&self, sid: &str) -> Result<Vec<Uuid>, DomainError>;
/// OIDC Back-Channel Logout fallback when the IdP didn't supply a `sid`:
/// revoke every session belonging to the user identified by
/// `(oidc_provider, oidc_subject)`. Returns the affected user id, or
/// `None` if we don't know that user.
async fn revoke_user_sessions_by_oidc_subject(
&self,
oidc_provider: &str,
oidc_subject: &str,
) -> Result<Option<Uuid>, DomainError>;
}
// ============================================================================
+5
View File
@@ -123,6 +123,11 @@ pub enum LogoutReason {
/// Refresh-token reuse detected by the session-family guard. Entire
/// family revoked because the rotation was probably stolen.
TokenReused,
/// OIDC Back-Channel Logout — the IdP notified us that a session
/// ended on its side (user logged out on another RP, or admin
/// revoked the SSO session). Session(s) revoked without any user
/// action on OxiCloud itself.
IdpNotification,
}
/// How aggressively `on_user_deleted` cleanup should run. Today both
@@ -141,6 +141,16 @@ pub struct AuthApplicationService {
/// Auto-expires after 60 seconds via moka TTL; max 10 000 entries for DoS protection.
pending_oidc_tokens: Cache<String, PendingOidcToken>,
completed_oidc_logins: Cache<String, String>,
/// Back-Channel Logout replay guard — dedupes logout_tokens by their
/// `jti` claim within the token's freshness window (5 min per BCL §2.6).
/// A cooperative IdP will not re-send a logout_token, but the endpoint
/// is public and unauthenticated so a rogue caller could try to; we
/// short-circuit repeats to avoid burning DB writes on duplicates.
/// Note: tokens without a jti bypass this guard — the validator has
/// already enforced signature + freshness + subject-presence, so at
/// worst a legitimate re-notification runs the (idempotent) revoke path
/// a second time and returns "no rows changed".
backchannel_logout_jti_seen: Cache<String, ()>,
/// Magic-link token repository — populated when the magic-link feature
/// is enabled (PR 8+). `None` means redemption endpoints return 503.
magic_link_repo: Option<Arc<dyn MagicLinkTokenRepository>>,
@@ -208,6 +218,13 @@ impl AuthApplicationService {
.max_capacity(10_000)
.time_to_live(Duration::from_secs(120))
.build(),
backchannel_logout_jti_seen: Cache::builder()
.max_capacity(10_000)
// Matches OidcService::validate_logout_token freshness clamp
// (5 min). Any token older than that fails validation before
// reaching the jti check, so no need to remember jtis longer.
.time_to_live(Duration::from_secs(300))
.build(),
magic_link_repo: None,
user_flags_cache: moka::future::Cache::builder()
.max_capacity(10_000)
@@ -1306,6 +1323,99 @@ impl AuthApplicationService {
.await
}
/// OIDC Back-Channel Logout 1.0 entry point.
///
/// Called by the public BCL handler with an unvalidated logout_token
/// (as delivered by the IdP over server-to-server HTTP). This method
/// owns the full flow:
///
/// 1. Validate the token (signature + spec-mandated claims).
/// 2. Reject replays via the `jti` seen-cache (best-effort — tokens
/// without a jti are impossible to dedupe cheaply, so the revoke
/// path stays idempotent as a safety net).
/// 3. Prefer `sid` (per-device revocation) over `sub` (all-device)
/// when both are present — matches the intent of the IdP that
/// chose to include `sid`.
/// 4. Dispatch per-user lifecycle hooks so downstream systems
/// (websocket subscriptions, etc.) can react.
///
/// Returns the count of session rows actually flipped from
/// `revoked=false` to `revoked=true` — 0 is a fine outcome (already
/// logged out or unknown user; both are indistinguishable from the
/// IdP's viewpoint and both mean "OxiCloud has no live session for
/// that identity").
pub async fn backchannel_logout(&self, logout_token: &str) -> Result<u64, DomainError> {
let oidc = {
let state = self.oidc.read().unwrap();
state.service.clone().ok_or_else(|| {
DomainError::new(
ErrorKind::InternalError,
"OIDC",
"OIDC service not configured — cannot process backchannel logout",
)
})?
};
let claims = oidc.validate_logout_token(logout_token).await?;
// Replay guard. Insertion-first-then-check: `get()` + `insert()`
// is racy across concurrent BCL calls with the same jti (both
// could observe absent, both would run the revocation), but the
// revocation is idempotent so at worst we double-audit. If it
// matters more we can move to `entry().or_insert()` semantics.
if let Some(jti) = claims.jti.as_ref() {
if self.backchannel_logout_jti_seen.get(jti).is_some() {
tracing::info!(
target: "audit",
event = "oidc.backchannel_logout_replayed",
jti = %jti,
"👮🏻‍♂️ OIDC backchannel-logout token replayed — ignored"
);
return Ok(0);
}
self.backchannel_logout_jti_seen.insert(jti.clone(), ());
}
let provider_name = oidc.provider_name().to_string();
// Resolve which sessions to revoke.
let affected_user_ids: Vec<Uuid> = if let Some(sid) = claims.sid.as_ref() {
self.session_storage
.revoke_sessions_by_oidc_sid(sid)
.await?
} else if let Some(sub) = claims.sub.as_ref() {
self.session_storage
.revoke_user_sessions_by_oidc_subject(&provider_name, sub)
.await?
.into_iter()
.collect()
} else {
// Validator already enforced sub-or-sid presence; being here
// means the validator has drifted. Fail loud.
return Err(DomainError::new(
ErrorKind::InternalError,
"OIDC",
"backchannel_logout: validator returned claims without sub or sid",
));
};
// Dispatch lifecycle hooks per unique affected user. Best-effort;
// hook failures don't undo the revocation (which already committed).
// Deduped because sid-based revocation could theoretically match
// multiple sessions for the same user if the IdP re-issued sids.
if let Some(lc) = &self.user_lifecycle {
let unique: std::collections::HashSet<Uuid> =
affected_user_ids.iter().copied().collect();
for uid in unique {
if let Ok(user) = self.user_storage.get_user_by_id(uid).await {
lc.dispatch_logout(user, LogoutReason::IdpNotification);
}
}
}
Ok(affected_user_ids.len() as u64)
}
pub async fn logout_all(&self, user_id: Uuid) -> Result<u64, DomainError> {
// Revoke all user sessions
let revoked_count = self
@@ -3066,7 +3176,7 @@ impl AuthApplicationService {
let access_token = self.token_service.generate_access_token(&user)?;
let refresh_token = self.token_service.generate_refresh_token();
let session = Session::new(
let mut session = Session::new(
user.id(),
refresh_token.clone(),
None,
@@ -3075,6 +3185,14 @@ impl AuthApplicationService {
Uuid::new_v4(),
)
.with_oidc_id_token(token_set.id_token.clone());
// Bind the IdP's session identifier so Back-Channel Logout can
// revoke this specific device (see auth_ports::OidcLogoutClaims
// and session_pg_repository::revoke_sessions_by_oidc_sid). IdPs
// that don't emit sid leave this None; BCL then falls back to
// sub-based revocation.
if let Some(sid) = claims.sid.as_ref() {
session = session.with_oidc_sid(sid.clone());
}
self.session_storage.create_session(session).await?;
let auth_response = AuthResponseDto {