feat(oidc): impl back channel logout
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user