diff --git a/docs/config/authentication.md b/docs/config/authentication.md index 2622d8d2..05365cb3 100644 --- a/docs/config/authentication.md +++ b/docs/config/authentication.md @@ -138,6 +138,41 @@ Requirements: The `id_token` used as `id_token_hint` is captured at login time from the OIDC token-exchange response and persisted on `auth.sessions.oidc_id_token`. Non-OIDC sessions leave the column NULL and `POST /api/auth/logout` returns `{}` (local-only logout). +## OIDC Back-Channel Logout + +Complements RP-initiated logout by letting the **IdP** kick OxiCloud sessions server-to-server, without any browser involvement. Fires when: + +- The user logged out of another RP (single sign-out across your fleet). +- An admin revoked the user's SSO session from the Keycloak admin console. +- The user's account was disabled at the IdP. + +Endpoint: `POST /api/auth/oidc/backchannel-logout`. Public (no auth middleware, no CSRF, no cookies) — the signed `logout_token` JWT IS the authentication. + +**IdP-side setup (Keycloak):** + +1. On the client's Settings tab, set **Backchannel Logout URL** to `/api/auth/oidc/backchannel-logout`. +2. Turn on **Backchannel Logout Session Required**. This makes Keycloak include the `sid` claim on both id_tokens (which OxiCloud persists on `auth.sessions.oidc_sid`) AND on the logout_tokens it sends. With `sid` present, OxiCloud revokes only the specific device that logged out; without it, we fall back to revoking every session belonging to the same OIDC subject (all of the user's OxiCloud devices). +3. Leave **Backchannel Logout Revoke Offline Sessions** off unless you have a reason — OxiCloud uses only online sessions today. + +**What OxiCloud validates on the logout_token** (per OIDC Back-Channel Logout 1.0): + +- Signature via the IdP's JWKS (same key material as id_token validation). +- `iss` matches the discovery document's issuer. +- `aud` contains our `client_id`. +- `events` claim contains the `http://schemas.openid.net/event/backchannel-logout` key. +- `sub` and/or `sid` present (else there's nothing to revoke — 400). +- `nonce` absent (spec §2.4 forbids it — a token with a nonce is either an IdP bug or a replay of an id_token; 400). +- `iat` within a 5-minute freshness window. +- `jti` (if present) deduped for 5 minutes so retransmissions don't cause double-audit. + +Response codes are constrained by the spec: + +- **200** — token validated; 0 or more sessions revoked (both are "handled" from the IdP's view). +- **400** — validation failed. Real reason is logged locally (`event=oidc.backchannel_logout_rejected`) and NOT returned in the body; the IdP just sees `invalid_request`. +- **503** — OIDC is not enabled on this deployment. The IdP shouldn't be calling us in that case. + +**Compared to RP-initiated logout** (the flow triggered by `POST /api/auth/logout`): RP-initiated is browser-driven and evicts the local session + kills the IdP session. Back-channel is IdP-driven and evicts the local session; the IdP's own state is not affected. The two are complementary — enable both. + ## Example Flows ### Register — classic diff --git a/migrations/20261001000001_sessions_oidc_sid.sql b/migrations/20261001000001_sessions_oidc_sid.sql new file mode 100644 index 00000000..05594ed9 --- /dev/null +++ b/migrations/20261001000001_sessions_oidc_sid.sql @@ -0,0 +1,26 @@ +-- Persist the OIDC session identifier (`sid` claim from the id_token) so +-- the Back-Channel Logout endpoint can revoke a specific device without +-- wiping every other OxiCloud session the user has open. +-- +-- OIDC Back-Channel Logout 1.0 requires the logout_token to carry `sub` +-- and/or `sid`. Preferring `sid` (per-session) over `sub` (all sessions) +-- matters when a user is logged in from a laptop AND a phone through the +-- same IdP: logging out on the laptop should not evict the phone. +-- +-- Nullable because: +-- * non-OIDC sessions (password / magic-link) don't have a sid; +-- * OIDC IdPs are free to omit the `sid` claim from id_tokens — Keycloak +-- only emits it when "Backchannel Logout Session Required" is enabled +-- on the client. When it's missing we fall back to sub-based revocation +-- (all sessions for that OIDC subject). +-- +-- Indexed for the O(1) revoke-by-sid lookup path called from the BCL handler. +ALTER TABLE auth.sessions + ADD COLUMN IF NOT EXISTS oidc_sid TEXT; + +CREATE INDEX IF NOT EXISTS idx_sessions_oidc_sid + ON auth.sessions(oidc_sid) + WHERE oidc_sid IS NOT NULL AND NOT revoked; + +COMMENT ON COLUMN auth.sessions.oidc_sid IS + 'OIDC session identifier (sid claim) from the id_token. Used by the backchannel-logout endpoint to revoke a single device.'; diff --git a/src/application/ports/auth_ports.rs b/src/application/ports/auth_ports.rs index 393d75fc..2488b392 100644 --- a/src/application/ports/auth_ports.rs +++ b/src/application/ports/auth_ports.rs @@ -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, + /// 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, +} + +/// 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, + pub sid: Option, + /// JWT identifier — used by the app service to prevent replay of the + /// same logout_token within the token's freshness window. + pub jti: Option, } /// 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; + /// 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; + + /// 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, 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, DomainError>; } // ============================================================================ diff --git a/src/application/ports/user_lifecycle.rs b/src/application/ports/user_lifecycle.rs index de92a46d..e6d2e34c 100644 --- a/src/application/ports/user_lifecycle.rs +++ b/src/application/ports/user_lifecycle.rs @@ -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 diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index 3b667cf8..1061623a 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -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, completed_oidc_logins: Cache, + /// 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, /// Magic-link token repository — populated when the magic-link feature /// is enabled (PR 8+). `None` means redemption endpoints return 503. magic_link_repo: Option>, @@ -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 { + 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 = 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 = + 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 { // 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 { diff --git a/src/domain/entities/session.rs b/src/domain/entities/session.rs index 0d113d24..f0ce4bfb 100644 --- a/src/domain/entities/session.rs +++ b/src/domain/entities/session.rs @@ -18,6 +18,11 @@ pub struct Session { /// RP-initiated logout URL so the IdP can terminate its own SSO session. /// `None` for password / magic-link sessions. oidc_id_token: Option, + /// OIDC session identifier (sid claim). Populated only when the IdP + /// emits it. Enables per-device Back-Channel Logout — without it, a + /// BCL notification would revoke all of the user's sessions rather + /// than just the one that logged out on the far end. + oidc_sid: Option, } impl Session { @@ -45,6 +50,7 @@ impl Session { revoked: false, family_id, oidc_id_token: None, + oidc_sid: None, } } @@ -56,6 +62,16 @@ impl Session { self } + /// Attach the OIDC session identifier from the id_token's `sid` claim. + /// Optional even for OIDC sessions — only present when the IdP emits + /// sid (Keycloak requires "Backchannel Logout Session Required" on the + /// client). Without it, Back-Channel Logout falls back to sub-based + /// revocation which is coarser (all of the user's OxiCloud sessions). + pub fn with_oidc_sid(mut self, sid: String) -> Self { + self.oidc_sid = Some(sid); + self + } + #[allow(clippy::too_many_arguments)] pub fn from_raw( id: Uuid, @@ -68,6 +84,7 @@ impl Session { revoked: bool, family_id: Uuid, oidc_id_token: Option, + oidc_sid: Option, ) -> Self { Self { id, @@ -80,6 +97,7 @@ impl Session { revoked, family_id, oidc_id_token, + oidc_sid, } } @@ -131,4 +149,8 @@ impl Session { pub fn oidc_id_token(&self) -> Option<&str> { self.oidc_id_token.as_deref() } + + pub fn oidc_sid(&self) -> Option<&str> { + self.oidc_sid.as_deref() + } } diff --git a/src/domain/repositories/session_repository.rs b/src/domain/repositories/session_repository.rs index 4ca17c84..8041e79b 100644 --- a/src/domain/repositories/session_repository.rs +++ b/src/domain/repositories/session_repository.rs @@ -55,6 +55,28 @@ pub trait SessionRepository: Send + Sync + 'static { /// Revokes all sessions in a token family (theft response) async fn revoke_session_family(&self, family_id: Uuid) -> SessionRepositoryResult; + /// Revokes every OxiCloud session whose OIDC sid claim matches. + /// + /// Used by the Back-Channel Logout handler when the IdP sends a + /// logout_token with a `sid` — this is the per-device path and + /// matches (in the typical case) exactly one session row. Returns + /// user IDs of every affected session so the caller can dispatch + /// per-user lifecycle hooks. + async fn revoke_sessions_by_oidc_sid(&self, sid: &str) -> SessionRepositoryResult>; + + /// Revokes every session belonging to the user identified by + /// `(oidc_provider, oidc_subject)`. + /// + /// Fallback path for the Back-Channel Logout handler when the IdP + /// omits `sid` from the logout_token — coarser than sid-based + /// revocation (kills the user's other devices too). Returns the + /// user id of the affected account, or `None` if no matching user. + async fn revoke_user_sessions_by_oidc_subject( + &self, + oidc_provider: &str, + oidc_subject: &str, + ) -> SessionRepositoryResult>; + /// Deletes expired sessions async fn delete_expired_sessions(&self) -> SessionRepositoryResult; } diff --git a/src/infrastructure/repositories/pg/session_pg_repository.rs b/src/infrastructure/repositories/pg/session_pg_repository.rs index b85da13f..2535dd94 100644 --- a/src/infrastructure/repositories/pg/session_pg_repository.rs +++ b/src/infrastructure/repositories/pg/session_pg_repository.rs @@ -53,9 +53,9 @@ impl SessionRepository for SessionPgRepository { INSERT INTO auth.sessions ( id, user_id, refresh_token, expires_at, ip_address, user_agent, created_at, revoked, family_id, - oidc_id_token + oidc_id_token, oidc_sid ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11 ) "#, ) @@ -69,6 +69,7 @@ impl SessionRepository for SessionPgRepository { .bind(session_clone.is_revoked()) .bind(session_clone.family_id()) .bind(session_clone.oidc_id_token()) + .bind(session_clone.oidc_sid()) .execute(&mut **tx) .await .map_err(Self::map_sqlx_error)?; @@ -114,7 +115,7 @@ impl SessionRepository for SessionPgRepository { SELECT id, user_id, refresh_token, expires_at, ip_address, user_agent, created_at, revoked, family_id, - oidc_id_token + oidc_id_token, oidc_sid FROM auth.sessions WHERE id = $1 "#, @@ -135,6 +136,7 @@ impl SessionRepository for SessionPgRepository { row.get("revoked"), row.get("family_id"), row.get("oidc_id_token"), + row.get("oidc_sid"), )) } @@ -149,7 +151,7 @@ impl SessionRepository for SessionPgRepository { SELECT id, user_id, refresh_token, expires_at, ip_address, user_agent, created_at, revoked, family_id, - oidc_id_token + oidc_id_token, oidc_sid FROM auth.sessions WHERE refresh_token = $1 "#, @@ -170,6 +172,7 @@ impl SessionRepository for SessionPgRepository { row.get("revoked"), row.get("family_id"), row.get("oidc_id_token"), + row.get("oidc_sid"), )) } @@ -183,7 +186,7 @@ impl SessionRepository for SessionPgRepository { SELECT id, user_id, refresh_token, expires_at, ip_address, user_agent, created_at, revoked, family_id, - oidc_id_token + oidc_id_token, oidc_sid FROM auth.sessions WHERE user_id = $1 ORDER BY created_at DESC @@ -208,6 +211,7 @@ impl SessionRepository for SessionPgRepository { row.get("revoked"), row.get("family_id"), row.get("oidc_id_token"), + row.get("oidc_sid"), ) }) .collect(); @@ -308,6 +312,92 @@ impl SessionRepository for SessionPgRepository { Ok(affected) } + /// Back-Channel Logout — revoke sessions matched by the IdP-supplied + /// `sid`. Filters `NOT revoked` so double-notifications are idempotent + /// (returning empty second time). Only session rows with a non-null + /// oidc_sid ever match, so this is safe against sid values happening + /// to collide with anything else. + async fn revoke_sessions_by_oidc_sid(&self, sid: &str) -> SessionRepositoryResult> { + let rows = sqlx::query( + r#" + UPDATE auth.sessions + SET revoked = true + WHERE oidc_sid = $1 AND NOT revoked + RETURNING user_id + "#, + ) + .bind(sid) + .fetch_all(&*self.pool) + .await + .map_err(Self::map_sqlx_error)?; + + let user_ids: Vec = rows.iter().map(|r| r.get("user_id")).collect(); + if !user_ids.is_empty() { + tracing::info!( + target: "audit", + event = "oidc.backchannel_logout_by_sid", + sid = %sid, + revoked_count = user_ids.len(), + "👮🏻‍♂️ OIDC backchannel-logout revoked sessions by sid" + ); + } + Ok(user_ids) + } + + /// Back-Channel Logout fallback — the IdP omitted `sid` in the + /// logout_token, so we revoke every session belonging to the user + /// identified by (oidc_provider, oidc_subject). Users are looked up + /// through the existing auth.users columns. + async fn revoke_user_sessions_by_oidc_subject( + &self, + oidc_provider: &str, + oidc_subject: &str, + ) -> SessionRepositoryResult> { + // Two-step: look up the user first (deterministic error class if + // the user is unknown), then revoke. Combining into a single + // UPDATE-FROM would work but the audit log wants the user_id + // separately from the revocation count. + let user_row = sqlx::query( + r#" + SELECT id FROM auth.users + WHERE oidc_provider = $1 AND oidc_subject = $2 + "#, + ) + .bind(oidc_provider) + .bind(oidc_subject) + .fetch_optional(&*self.pool) + .await + .map_err(Self::map_sqlx_error)?; + + let Some(row) = user_row else { + return Ok(None); + }; + let user_id: Uuid = row.get("id"); + + let result = sqlx::query( + r#" + UPDATE auth.sessions + SET revoked = true + WHERE user_id = $1 AND NOT revoked + "#, + ) + .bind(user_id) + .execute(&*self.pool) + .await + .map_err(Self::map_sqlx_error)?; + + tracing::info!( + target: "audit", + event = "oidc.backchannel_logout_by_sub", + oidc_provider = %oidc_provider, + oidc_subject = %oidc_subject, + user_id = %user_id, + revoked_count = result.rows_affected(), + "👮🏻‍♂️ OIDC backchannel-logout revoked all user sessions by sub" + ); + Ok(Some(user_id)) + } + /// Deletes expired sessions async fn delete_expired_sessions(&self) -> SessionRepositoryResult { let now = Utc::now(); @@ -357,9 +447,9 @@ impl SessionStoragePort for SessionPgRepository { INSERT INTO auth.sessions ( id, user_id, refresh_token, expires_at, ip_address, user_agent, created_at, revoked, family_id, - oidc_id_token + oidc_id_token, oidc_sid ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11 ) "#, ) @@ -373,6 +463,7 @@ impl SessionStoragePort for SessionPgRepository { .bind(session_clone.is_revoked()) .bind(session_clone.family_id()) .bind(session_clone.oidc_id_token()) + .bind(session_clone.oidc_sid()) .execute(&mut **tx) .await .map_err(Self::map_sqlx_error)?; @@ -434,4 +525,20 @@ impl SessionStoragePort for SessionPgRepository { .await .map_err(DomainError::from) } + + async fn revoke_sessions_by_oidc_sid(&self, sid: &str) -> Result, DomainError> { + SessionRepository::revoke_sessions_by_oidc_sid(self, sid) + .await + .map_err(DomainError::from) + } + + async fn revoke_user_sessions_by_oidc_subject( + &self, + oidc_provider: &str, + oidc_subject: &str, + ) -> Result, DomainError> { + SessionRepository::revoke_user_sessions_by_oidc_subject(self, oidc_provider, oidc_subject) + .await + .map_err(DomainError::from) + } } diff --git a/src/infrastructure/services/oidc_service.rs b/src/infrastructure/services/oidc_service.rs index 9ddf361b..f6e2da23 100644 --- a/src/infrastructure/services/oidc_service.rs +++ b/src/infrastructure/services/oidc_service.rs @@ -9,7 +9,9 @@ use serde::Deserialize; use std::time::{Duration, Instant}; use tokio::sync::RwLock; -use crate::application::ports::auth_ports::{OidcIdClaims, OidcServicePort, OidcTokenSet}; +use crate::application::ports::auth_ports::{ + OidcIdClaims, OidcLogoutClaims, OidcServicePort, OidcTokenSet, +}; use crate::common::config::OidcConfig; use crate::common::errors::{DomainError, ErrorKind}; @@ -75,6 +77,10 @@ struct IdTokenClaims { nonce: Option, picture: Option, locale: Option, + /// OIDC session identifier — only set by IdPs configured to emit it + /// (Keycloak: "Backchannel Logout Session Required"). When present, + /// bind it to the OxiCloud session so BCL can revoke just that device. + sid: Option, // Standard JWT fields #[allow(dead_code)] iss: Option, @@ -86,6 +92,28 @@ struct IdTokenClaims { iat: Option, } +/// OIDC Back-Channel Logout 1.0, §2.4 — the logout_token JWT. +/// +/// Structural differences from an id_token: +/// - MUST have `sub` OR `sid` (or both). +/// - MUST have `events` claim containing the backchannel-logout URI. +/// - MUST NOT have `nonce`. +/// - `exp` is optional (unlike id_token where it's required); a missing +/// exp is fine, we clamp with our own iat-based freshness check. +#[derive(Debug, Deserialize)] +struct LogoutTokenClaims { + iss: String, + aud: serde_json::Value, + iat: i64, + jti: Option, + sub: Option, + sid: Option, + events: serde_json::Value, + nonce: Option, +} + +const BACKCHANNEL_LOGOUT_EVENT: &str = "http://schemas.openid.net/event/backchannel-logout"; + // ============================================================================ // UserInfo response // ============================================================================ @@ -476,6 +504,7 @@ impl OidcServicePort for OidcService { groups: claims.groups.unwrap_or_default(), picture: claims.picture, locale: claims.locale, + sid: claims.sid, }) } @@ -531,6 +560,10 @@ impl OidcServicePort for OidcService { groups: info.groups.unwrap_or_default(), picture: info.picture, locale: info.locale, + // UserInfo endpoint doesn't emit sid — it's an id_token-only + // claim. Callers merging UserInfo into id_token claims must + // preserve the id_token's sid. + sid: None, }) } @@ -559,6 +592,157 @@ impl OidcServicePort for OidcService { ); Ok(Some(url)) } + + async fn validate_logout_token( + &self, + logout_token: &str, + ) -> Result { + let jwks = self.get_jwks().await?; + let discovery = self.get_discovery().await?; + + let kid = Self::extract_jwt_kid(logout_token); + let jwk = Self::find_key(&jwks, kid.as_deref()).ok_or_else(|| { + DomainError::new( + ErrorKind::AccessDenied, + "OIDC", + "No suitable key found in JWKS for logout_token validation", + ) + })?; + + let decoding_key = jsonwebtoken::DecodingKey::from_jwk(jwk).map_err(|e| { + DomainError::new( + ErrorKind::InternalError, + "OIDC", + format!("Failed to create decoding key from JWK: {}", e), + ) + })?; + + let alg = match jwk.common.key_algorithm { + Some(jsonwebtoken::jwk::KeyAlgorithm::RS256) => jsonwebtoken::Algorithm::RS256, + Some(jsonwebtoken::jwk::KeyAlgorithm::RS384) => jsonwebtoken::Algorithm::RS384, + Some(jsonwebtoken::jwk::KeyAlgorithm::RS512) => jsonwebtoken::Algorithm::RS512, + Some(jsonwebtoken::jwk::KeyAlgorithm::ES256) => jsonwebtoken::Algorithm::ES256, + Some(jsonwebtoken::jwk::KeyAlgorithm::ES384) => jsonwebtoken::Algorithm::ES384, + _ => jsonwebtoken::Algorithm::RS256, + }; + + // Spec: iss + aud validated same as id_token. exp is OPTIONAL for + // logout_tokens (unlike id_tokens where it's mandatory), so tell + // jsonwebtoken not to require it; the iat-based freshness clamp + // below enforces our own upper bound. + let mut validation = jsonwebtoken::Validation::new(alg); + validation.set_issuer(&[&discovery.issuer]); + validation.set_audience(&[&self.config.client_id]); + validation.required_spec_claims.remove("exp"); + + let token_data = + jsonwebtoken::decode::(logout_token, &decoding_key, &validation) + .map_err(|e| { + tracing::warn!("OIDC logout_token validation failed: {}", e); + DomainError::new( + ErrorKind::AccessDenied, + "OIDC", + format!("logout_token validation failed: {}", e), + ) + })?; + + let claims = token_data.claims; + + // Spec §2.4: MUST NOT contain a nonce claim (that's an id_token thing). + // If we see one, the IdP is confused or an attacker is replaying an + // id_token as a logout_token; refuse. + if claims.nonce.is_some() { + tracing::warn!( + "OIDC logout_token rejected: nonce claim present (spec §2.4 forbids it)" + ); + return Err(DomainError::new( + ErrorKind::AccessDenied, + "OIDC", + "logout_token must not contain nonce", + )); + } + + // Spec §2.4: MUST have `events` claim as a JSON object with a + // property whose name is the backchannel-logout URI. Value is + // typically `{}` — we don't inspect it. + let has_event = claims + .events + .as_object() + .map(|o| o.contains_key(BACKCHANNEL_LOGOUT_EVENT)) + .unwrap_or(false); + if !has_event { + tracing::warn!( + "OIDC logout_token rejected: missing events.'{}'", + BACKCHANNEL_LOGOUT_EVENT + ); + return Err(DomainError::new( + ErrorKind::AccessDenied, + "OIDC", + "logout_token missing required backchannel-logout event", + )); + } + + // Spec §2.4: MUST contain `sub` and/or `sid`. Without one, we have + // nothing to key the revocation on. + if claims.sub.is_none() && claims.sid.is_none() { + tracing::warn!("OIDC logout_token rejected: neither sub nor sid present"); + return Err(DomainError::new( + ErrorKind::AccessDenied, + "OIDC", + "logout_token must contain sub or sid", + )); + } + + // Freshness clamp — iat within the last 5 minutes. Prevents + // rogue replay of an old logout_token. Not spec-mandated but + // recommended (BCL §2.6). + let now = chrono::Utc::now().timestamp(); + const MAX_AGE_SECS: i64 = 300; + if (now - claims.iat).abs() > MAX_AGE_SECS { + tracing::warn!( + "OIDC logout_token rejected: iat too old (age={}s, max={}s)", + now - claims.iat, + MAX_AGE_SECS + ); + return Err(DomainError::new( + ErrorKind::AccessDenied, + "OIDC", + "logout_token iat outside freshness window", + )); + } + + // Belt-and-suspenders — the jsonwebtoken decode already enforced + // iss+aud, but log if we get here somehow. Actively used only if + // future changes to Validation config regress the check. + if claims.iss != discovery.issuer { + return Err(DomainError::new( + ErrorKind::AccessDenied, + "OIDC", + "logout_token iss mismatch", + )); + } + // aud may be string or array — accept either shape carrying our client_id. + let aud_ok = match &claims.aud { + serde_json::Value::String(s) => s == &self.config.client_id, + serde_json::Value::Array(a) => a + .iter() + .any(|v| v.as_str() == Some(self.config.client_id.as_str())), + _ => false, + }; + if !aud_ok { + return Err(DomainError::new( + ErrorKind::AccessDenied, + "OIDC", + "logout_token aud mismatch", + )); + } + + Ok(OidcLogoutClaims { + sub: claims.sub, + sid: claims.sid, + jti: claims.jti, + }) + } } // We need urlencoding — let's use a minimal inline implementation diff --git a/src/interfaces/api/handlers/auth_handler.rs b/src/interfaces/api/handlers/auth_handler.rs index f66c8bb2..817f2073 100644 --- a/src/interfaces/api/handlers/auth_handler.rs +++ b/src/interfaces/api/handlers/auth_handler.rs @@ -32,6 +32,9 @@ pub fn auth_public_routes() -> Router> { .route("/oidc/authorize", get(oidc_authorize)) .route("/oidc/callback", get(oidc_callback)) .route("/oidc/exchange", post(oidc_exchange)) + // OIDC Back-Channel Logout 1.0 — public (server-to-server call + // from the IdP with a signed logout_token; no cookies, no CSRF). + .route("/oidc/backchannel-logout", post(oidc_backchannel_logout)) // Login-via-email — sends a magic-link to the user's email so // accounts with no other login credential can sign in. .route("/magic-link/send", post(send_magic_link)) @@ -879,6 +882,100 @@ pub async fn logout( Ok(response) } +/// OIDC Back-Channel Logout 1.0 receiver. +/// +/// The IdP POSTs a signed `logout_token` JWT here when a user's SSO +/// session ends (they logged out elsewhere, admin revoked the session, +/// account was disabled). Body is `application/x-www-form-urlencoded` +/// per spec §2.5 with a single `logout_token` field. +/// +/// Response codes are constrained by the spec (§2.8): +/// - 200 on successful processing (including a validated token that +/// matched no OxiCloud sessions — the notification is still "handled"). +/// - 400 on any validation failure (bad signature, expired, missing +/// required claims, replay). We do NOT return 200 with an error body. +/// +/// This endpoint is public: no auth middleware, no CSRF, no cookie. +/// The `logout_token` signature IS the authentication — an unsigned or +/// wrongly-signed token gets rejected by the OIDC service validator. +#[utoipa::path( + post, + path = "/api/auth/oidc/backchannel-logout", + request_body( + content_type = "application/x-www-form-urlencoded", + description = "Form-encoded body with a single `logout_token` field (the signed JWT from the IdP)", + ), + responses( + (status = 200, description = "Logout notification accepted (0 or more sessions revoked)"), + (status = 400, description = "logout_token missing, malformed, or failed validation"), + (status = 503, description = "OIDC not configured on this deployment"), + ), + tag = "auth" +)] +pub async fn oidc_backchannel_logout( + State(state): State>, + axum::Form(form): axum::Form, +) -> Response { + let auth_service = match state.auth_service.as_ref() { + Some(s) => s, + None => { + return ( + StatusCode::SERVICE_UNAVAILABLE, + axum::Json(serde_json::json!({ + "error": "authentication_not_configured", + "error_description": "OIDC is not enabled on this deployment" + })), + ) + .into_response(); + } + }; + + match auth_service + .auth_application_service + .backchannel_logout(&form.logout_token) + .await + { + Ok(revoked) => { + tracing::info!( + target: "audit", + event = "oidc.backchannel_logout_accepted", + revoked_count = revoked, + "👮🏻‍♂️ OIDC backchannel-logout accepted" + ); + // Spec §2.8: response body has no defined content. Empty JSON + // object keeps content-type coherent and is cheap for the IdP + // to skip. + (StatusCode::OK, axum::Json(serde_json::json!({}))).into_response() + } + Err(e) => { + // Log the real reason for operators; return a spec-compliant + // 400 with a minimal error payload (spec §2.8 recommends + // `application/json` with `error` + `error_description` per + // OAuth 2.0 error style). + tracing::warn!( + target: "audit", + event = "oidc.backchannel_logout_rejected", + reason = %e, + "👮🏻‍♂️ OIDC backchannel-logout rejected" + ); + ( + StatusCode::BAD_REQUEST, + axum::Json(serde_json::json!({ + "error": "invalid_request", + "error_description": "logout_token validation failed" + })), + ) + .into_response() + } + } +} + +/// Form body carried by OIDC Back-Channel Logout notifications. +#[derive(Debug, serde::Deserialize)] +pub struct BackchannelLogoutForm { + pub logout_token: String, +} + /// One-time endpoint to create the first admin user. /// /// Available only when the system is not yet initialized (no admin exists). diff --git a/src/interfaces/api/mod.rs b/src/interfaces/api/mod.rs index 25f8918b..7f3b7108 100644 --- a/src/interfaces/api/mod.rs +++ b/src/interfaces/api/mod.rs @@ -79,6 +79,7 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload; handlers::auth_handler::oidc_authorize, handlers::auth_handler::oidc_callback, handlers::auth_handler::oidc_exchange, + handlers::auth_handler::oidc_backchannel_logout, // File handlers (free functions — see file_handler.rs for why) handlers::file_handler::list_files_query, handlers::file_handler::upload_file_with_thumbnails,