feat(oidc): RP initiator logout

request token invalidation to IdP (OIDC) on logout
This commit is contained in:
Edouard Vanbelle
2026-08-03 01:17:16 +02:00
parent 5ebe2d3bae
commit 166b8c4891
12 changed files with 219 additions and 21 deletions
+16
View File
@@ -287,6 +287,22 @@ pub trait OidcServicePort: Send + Sync + 'static {
/// Get the OIDC provider display name
fn provider_name(&self) -> &str;
/// Build an RP-initiated logout URL (OIDC Session Management 1.0).
///
/// Returns `Ok(None)` when the IdP's discovery document does not advertise
/// an `end_session_endpoint` — some providers don't support RP-initiated
/// logout, in which case the caller falls back to a local-only logout.
///
/// `id_token_hint` is required by most IdPs (Keycloak in particular
/// rejects the request without it) so the server can identify the session
/// to terminate. `post_logout_redirect_uri` must be one of the URIs
/// registered on the OIDC client, else the IdP refuses the redirect.
async fn build_end_session_url(
&self,
id_token_hint: &str,
post_logout_redirect_uri: &str,
) -> Result<Option<String>, DomainError>;
}
pub trait SessionStoragePort: Send + Sync + 'static {
@@ -1233,7 +1233,27 @@ impl AuthApplicationService {
})
}
pub async fn logout(&self, user_id: Uuid, refresh_token: &str) -> Result<(), DomainError> {
/// Revoke the caller's session and, when the session was minted through
/// OIDC, build the RP-initiated logout URL so the browser can also end
/// the IdP's SSO session (fixes shared-computer scenario where local
/// logout alone would let the next `/login` visit silently re-auth
/// through a still-valid IdP cookie).
///
/// Returns `Ok(None)` for:
/// - non-OIDC sessions (password / magic-link) — nothing to propagate;
/// - OIDC sessions where the IdP's discovery doesn't advertise an
/// `end_session_endpoint` — no way to propagate. Callers should still
/// clear local cookies; the IdP session will time out on its own.
///
/// `post_logout_redirect_uri` MUST be registered on the OIDC client
/// (Keycloak: "Valid post logout redirect URIs"), else the IdP refuses
/// the redirect back and the user is left on the IdP error page.
pub async fn logout(
&self,
user_id: Uuid,
refresh_token: &str,
post_logout_redirect_uri: &str,
) -> Result<Option<String>, DomainError> {
// Get session
let session = match self
.session_storage
@@ -1242,7 +1262,7 @@ impl AuthApplicationService {
{
Ok(s) => s,
// If the session doesn't exist, we consider the logout successful
Err(_) => return Ok(()),
Err(_) => return Ok(None),
};
// Verify that the session belongs to the user
@@ -1254,6 +1274,12 @@ impl AuthApplicationService {
));
}
// Capture the id_token BEFORE revocation so we can build the
// RP-initiated logout URL. Revocation only flips a boolean, so the
// row (and its oidc_id_token column) survives — this order is
// defensive against a future change that hard-deletes on revoke.
let id_token_hint = session.oidc_id_token().map(str::to_string);
// Revoke session
self.session_storage.revoke_session(session.id()).await?;
@@ -1266,7 +1292,18 @@ impl AuthApplicationService {
lc.dispatch_logout(user, LogoutReason::UserInitiated);
}
Ok(())
// If this was an OIDC session AND the IdP advertises an
// end_session_endpoint, build the RP-initiated logout URL.
// Otherwise return None — the caller clears local state either way.
let Some(id_token) = id_token_hint else {
return Ok(None);
};
let oidc = { self.oidc.read().unwrap().service.clone() };
let Some(oidc) = oidc else {
return Ok(None);
};
oidc.build_end_session_url(&id_token, post_logout_redirect_uri)
.await
}
pub async fn logout_all(&self, user_id: Uuid) -> Result<u64, DomainError> {
@@ -3036,7 +3073,8 @@ impl AuthApplicationService {
None,
self.token_service.refresh_token_expiry_days(),
Uuid::new_v4(),
);
)
.with_oidc_id_token(token_set.id_token.clone());
self.session_storage.create_session(session).await?;
let auth_response = AuthResponseDto {
+19
View File
@@ -14,6 +14,10 @@ pub struct Session {
/// Groups all tokens issued from the same original login.
/// Replaying a revoked token from this family triggers full-family revocation.
family_id: Uuid,
/// ID token from the OIDC login exchange. Used as `id_token_hint` on the
/// RP-initiated logout URL so the IdP can terminate its own SSO session.
/// `None` for password / magic-link sessions.
oidc_id_token: Option<String>,
}
impl Session {
@@ -40,9 +44,18 @@ impl Session {
created_at: now,
revoked: false,
family_id,
oidc_id_token: None,
}
}
/// Attach an OIDC ID token — call on sessions minted via the OIDC exchange.
/// The token is persisted with the session and re-emitted at logout as
/// `id_token_hint` so the IdP can end its own SSO session.
pub fn with_oidc_id_token(mut self, id_token: String) -> Self {
self.oidc_id_token = Some(id_token);
self
}
#[allow(clippy::too_many_arguments)]
pub fn from_raw(
id: Uuid,
@@ -54,6 +67,7 @@ impl Session {
created_at: DateTime<Utc>,
revoked: bool,
family_id: Uuid,
oidc_id_token: Option<String>,
) -> Self {
Self {
id,
@@ -65,6 +79,7 @@ impl Session {
created_at,
revoked,
family_id,
oidc_id_token,
}
}
@@ -112,4 +127,8 @@ impl Session {
pub fn family_id(&self) -> Uuid {
self.family_id
}
pub fn oidc_id_token(&self) -> Option<&str> {
self.oidc_id_token.as_deref()
}
}
@@ -52,9 +52,10 @@ impl SessionRepository for SessionPgRepository {
r#"
INSERT INTO auth.sessions (
id, user_id, refresh_token, expires_at,
ip_address, user_agent, created_at, revoked, family_id
ip_address, user_agent, created_at, revoked, family_id,
oidc_id_token
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10
)
"#,
)
@@ -67,6 +68,7 @@ impl SessionRepository for SessionPgRepository {
.bind(session_clone.created_at())
.bind(session_clone.is_revoked())
.bind(session_clone.family_id())
.bind(session_clone.oidc_id_token())
.execute(&mut **tx)
.await
.map_err(Self::map_sqlx_error)?;
@@ -111,7 +113,8 @@ impl SessionRepository for SessionPgRepository {
r#"
SELECT
id, user_id, refresh_token, expires_at,
ip_address, user_agent, created_at, revoked, family_id
ip_address, user_agent, created_at, revoked, family_id,
oidc_id_token
FROM auth.sessions
WHERE id = $1
"#,
@@ -131,6 +134,7 @@ impl SessionRepository for SessionPgRepository {
row.get("created_at"),
row.get("revoked"),
row.get("family_id"),
row.get("oidc_id_token"),
))
}
@@ -144,7 +148,8 @@ impl SessionRepository for SessionPgRepository {
r#"
SELECT
id, user_id, refresh_token, expires_at,
ip_address, user_agent, created_at, revoked, family_id
ip_address, user_agent, created_at, revoked, family_id,
oidc_id_token
FROM auth.sessions
WHERE refresh_token = $1
"#,
@@ -164,6 +169,7 @@ impl SessionRepository for SessionPgRepository {
row.get("created_at"),
row.get("revoked"),
row.get("family_id"),
row.get("oidc_id_token"),
))
}
@@ -176,7 +182,8 @@ impl SessionRepository for SessionPgRepository {
r#"
SELECT
id, user_id, refresh_token, expires_at,
ip_address, user_agent, created_at, revoked, family_id
ip_address, user_agent, created_at, revoked, family_id,
oidc_id_token
FROM auth.sessions
WHERE user_id = $1
ORDER BY created_at DESC
@@ -200,6 +207,7 @@ impl SessionRepository for SessionPgRepository {
row.get("created_at"),
row.get("revoked"),
row.get("family_id"),
row.get("oidc_id_token"),
)
})
.collect();
@@ -348,9 +356,10 @@ impl SessionStoragePort for SessionPgRepository {
r#"
INSERT INTO auth.sessions (
id, user_id, refresh_token, expires_at,
ip_address, user_agent, created_at, revoked, family_id
ip_address, user_agent, created_at, revoked, family_id,
oidc_id_token
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10
)
"#,
)
@@ -363,6 +372,7 @@ impl SessionStoragePort for SessionPgRepository {
.bind(session_clone.created_at())
.bind(session_clone.is_revoked())
.bind(session_clone.family_id())
.bind(session_clone.oidc_id_token())
.execute(&mut **tx)
.await
.map_err(Self::map_sqlx_error)?;
@@ -28,6 +28,10 @@ struct OidcDiscovery {
token_endpoint: String,
userinfo_endpoint: Option<String>,
jwks_uri: String,
/// RP-initiated logout endpoint (OIDC Session Management 1.0).
/// Optional — not every IdP advertises it. When missing, callers
/// must fall back to local-only logout.
end_session_endpoint: Option<String>,
}
// ============================================================================
@@ -533,6 +537,28 @@ impl OidcServicePort for OidcService {
fn provider_name(&self) -> &str {
&self.config.provider_name
}
async fn build_end_session_url(
&self,
id_token_hint: &str,
post_logout_redirect_uri: &str,
) -> Result<Option<String>, DomainError> {
let discovery = self.get_discovery().await?;
let Some(endpoint) = discovery.end_session_endpoint else {
return Ok(None);
};
// client_id is also included: some IdPs (Keycloak in "legacy" mode)
// use it to look up the registered post_logout_redirect_uri when
// the id_token_hint is expired or missing.
let url = format!(
"{}?id_token_hint={}&post_logout_redirect_uri={}&client_id={}",
endpoint,
urlencoding::encode(id_token_hint),
urlencoding::encode(post_logout_redirect_uri),
urlencoding::encode(&self.config.client_id),
);
Ok(Some(url))
}
}
// We need urlencoding — let's use a minimal inline implementation
+12 -3
View File
@@ -858,13 +858,22 @@ pub async fn logout(
AppError::unauthorized("Refresh token required for logout (JSON body or cookie)")
})?;
auth_service
// Post-logout redirect URI = OxiCloud's `/login`. Must be registered on
// the OIDC client (Keycloak: "Valid post logout redirect URIs"), else
// the IdP will refuse the redirect and strand the user on its error page.
let post_logout_redirect_uri = format!("{}/login", state.core.config.base_url());
let post_logout_url = auth_service
.auth_application_service
.logout(user_id, &refresh_token)
.logout(user_id, &refresh_token, &post_logout_redirect_uri)
.await?;
// Clear HttpOnly + CSRF cookies so the browser forgets the session
let mut response = StatusCode::OK.into_response();
// regardless of whether we also redirect to the IdP.
let body = post_logout_url
.map(|url| serde_json::json!({ "post_logout_url": url }))
.unwrap_or_else(|| serde_json::json!({}));
let mut response = (StatusCode::OK, axum::Json(body)).into_response();
cookie_auth::append_clear_cookies(response.headers_mut());
cookie_auth::append_clear_csrf_cookie(response.headers_mut());
Ok(response)