feat(oidc): RP initiator logout
request token invalidation to IdP (OIDC) on logout
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user