feat(oidc): impl back channel logout
This commit is contained in:
@@ -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 `<oxicloud-base-url>/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
|
||||
|
||||
@@ -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.';
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<String>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
oidc_sid: Option<String>,
|
||||
) -> 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<u64>;
|
||||
|
||||
/// 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<Vec<Uuid>>;
|
||||
|
||||
/// 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<Option<Uuid>>;
|
||||
|
||||
/// Deletes expired sessions
|
||||
async fn delete_expired_sessions(&self) -> SessionRepositoryResult<u64>;
|
||||
}
|
||||
|
||||
@@ -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<Vec<Uuid>> {
|
||||
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<Uuid> = 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<Option<Uuid>> {
|
||||
// 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<u64> {
|
||||
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<Vec<Uuid>, 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<Option<Uuid>, DomainError> {
|
||||
SessionRepository::revoke_user_sessions_by_oidc_subject(self, oidc_provider, oidc_subject)
|
||||
.await
|
||||
.map_err(DomainError::from)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
picture: Option<String>,
|
||||
locale: Option<String>,
|
||||
/// 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<String>,
|
||||
// Standard JWT fields
|
||||
#[allow(dead_code)]
|
||||
iss: Option<String>,
|
||||
@@ -86,6 +92,28 @@ struct IdTokenClaims {
|
||||
iat: Option<i64>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
sub: Option<String>,
|
||||
sid: Option<String>,
|
||||
events: serde_json::Value,
|
||||
nonce: Option<String>,
|
||||
}
|
||||
|
||||
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<OidcLogoutClaims, DomainError> {
|
||||
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::<LogoutTokenClaims>(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
|
||||
|
||||
@@ -32,6 +32,9 @@ pub fn auth_public_routes() -> Router<Arc<AppState>> {
|
||||
.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<Arc<AppState>>,
|
||||
axum::Form(form): axum::Form<BackchannelLogoutForm>,
|
||||
) -> 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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user