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
@@ -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)
}
}
+185 -1
View File
@@ -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