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
+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