feat(oidc): RP initiator logout
request token invalidation to IdP (OIDC) on logout
This commit is contained in:
@@ -122,10 +122,22 @@ The verification-piggyback flow above deliberately **bypasses the `has_password`
|
||||
| Token | Effect |
|
||||
| --- | --- |
|
||||
| `permit_magic_link_for_password_users` | Allow magic-link login for accounts that also have a password. OIDC-linked users are still refused. |
|
||||
| `auto_redirect_if_standalone_oidc` | When OIDC is the ONLY working login method (no password, no magic-link — via allowlist or the OIDC-master rule), the login SPA auto-redirects to the IdP on page load instead of showing a click-to-continue SSO button. Off by default to avoid redirect loops on IdP failure and to preserve logout UX (logging out then visiting `/login` would otherwise bounce the user right back in). Silent no-op when other methods are also live. Frontend reads this via `auto_redirect_to_oidc` on `GET /api/auth/oidc/providers`. |
|
||||
| `auto_redirect_if_standalone_oidc` | When OIDC is the ONLY working login method (no password, no magic-link — via allowlist or the OIDC-master rule), `GET /login` returns a **server-side 302** to `/api/auth/oidc/authorize` before the SPA loads (no click-to-continue button, no flash). Off by default to avoid redirect loops on IdP failure; the interceptor falls through to the SPA when `?error=…` or `?oidc_code=…` are present. Silent no-op when other methods are also live. Pair with the RP-initiated logout setup below so users on shared computers can actually log out. |
|
||||
|
||||
Unknown tokens are logged-and-skipped at startup so a typo doesn't silently zero the vector.
|
||||
|
||||
## RP-initiated OIDC logout
|
||||
|
||||
When a session was minted through OIDC, `POST /api/auth/logout` returns a JSON body containing `post_logout_url`. The SPA reads this and navigates the browser there via `window.location.replace(url)` — the IdP kills its SSO cookie and redirects the browser back to `<oxicloud>/login`. Without this hop the IdP session stays alive: the very next `/login` visit would silently re-authenticate through the still-valid SSO cookie, which under `auto_redirect_if_standalone_oidc` looks like the logout button did nothing (shared-computer scenario).
|
||||
|
||||
Requirements:
|
||||
|
||||
- **IdP discovery must advertise `end_session_endpoint`** (OIDC Session Management 1.0). Keycloak does by default. If your IdP doesn't, `post_logout_url` is omitted and the SPA falls back to a local-only logout; the IdP session ends only when it naturally times out.
|
||||
- **The OIDC client must register `<oxicloud-base-url>/login` as a valid post-logout redirect URI.** Keycloak calls this field "Valid post logout redirect URIs" on the client's Settings tab. If it's missing, the IdP shows its own error page after logging out instead of returning the user to OxiCloud.
|
||||
- Backend uses `AppConfig::base_url()` (i.e. `OXICLOUD_BASE_URL` if set, else derived from `server_host` / `server_port`) to build the redirect URI. Set `OXICLOUD_BASE_URL` when the browser reaches OxiCloud through a URL different from what the server binds locally (reverse proxy, Docker, TLS-terminating LB).
|
||||
|
||||
The `id_token` used as `id_token_hint` is captured at login time from the OIDC token-exchange response and persisted on `auth.sessions.oidc_id_token`. Non-OIDC sessions leave the column NULL and `POST /api/auth/logout` returns `{}` (local-only logout).
|
||||
|
||||
## Example Flows
|
||||
|
||||
### Register — classic
|
||||
|
||||
@@ -258,11 +258,30 @@ export async function sendMagicLink(email: string): Promise<MagicLinkResult> {
|
||||
return 'sent';
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
await apiFetch('/api/auth/logout', {
|
||||
export interface LogoutResult {
|
||||
/**
|
||||
* RP-initiated OIDC logout URL, present only when the session was minted
|
||||
* through OIDC AND the IdP advertises an `end_session_endpoint`. The
|
||||
* caller MUST navigate there via `window.location` (not `goto()`) so the
|
||||
* browser leaves the SPA and hits the IdP; the IdP kills its SSO cookie
|
||||
* and redirects back to `/login`. Without this hop the IdP session stays
|
||||
* alive and the next `/login` visit would silently re-authenticate.
|
||||
*/
|
||||
postLogoutUrl?: string;
|
||||
}
|
||||
|
||||
export async function logout(): Promise<LogoutResult> {
|
||||
const res = await apiFetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
||||
body: '{}'
|
||||
});
|
||||
if (!res.ok) return {};
|
||||
try {
|
||||
const body = (await res.json()) as { post_logout_url?: unknown };
|
||||
return typeof body?.post_logout_url === 'string' ? { postLogoutUrl: body.post_logout_url } : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -488,11 +488,26 @@
|
||||
}
|
||||
|
||||
async function onLogout() {
|
||||
let postLogoutUrl: string | undefined;
|
||||
try {
|
||||
await logout();
|
||||
({ postLogoutUrl } = await logout());
|
||||
} catch {
|
||||
/* clear locally regardless */
|
||||
}
|
||||
if (postLogoutUrl) {
|
||||
// Full-page navigation to the IdP end-session endpoint. Do NOT
|
||||
// touch local session state first: `session.reset()` fires the
|
||||
// layout $effect guard which races us with a competing
|
||||
// `goto('/login?redirect=...')`, and any ambient in-flight
|
||||
// fetch that 401s trips the sessionExpiredHandler with yet
|
||||
// another navigation to `/login?source=session_expired`. Two
|
||||
// or three concurrent navigations cancel each other and the
|
||||
// browser stalls on the current page. The IdP round-trip lands
|
||||
// us back on `/login` where the SPA reboots fresh from scratch —
|
||||
// no local cleanup needed here.
|
||||
window.location.replace(postLogoutUrl);
|
||||
return;
|
||||
}
|
||||
session.reset();
|
||||
await goto(resolve('/login'));
|
||||
}
|
||||
|
||||
@@ -165,11 +165,18 @@
|
||||
icon: 'sign-out-alt',
|
||||
run: async () => {
|
||||
close();
|
||||
let postLogoutUrl: string | undefined;
|
||||
try {
|
||||
await logout();
|
||||
({ postLogoutUrl } = await logout());
|
||||
} catch {
|
||||
/* clear locally regardless */
|
||||
}
|
||||
if (postLogoutUrl) {
|
||||
// See AppShell::onLogout — `session.reset()` before this
|
||||
// races the layout $effect guard and the 401 handler.
|
||||
window.location.replace(postLogoutUrl);
|
||||
return;
|
||||
}
|
||||
session.reset();
|
||||
await goto(resolve('/login'));
|
||||
}
|
||||
|
||||
@@ -198,14 +198,23 @@ it('renders an SSO sign-in link when an OIDC provider is configured', async () =
|
||||
expect(sso.getAttribute('href')).toBe('https://idp.test/auth');
|
||||
});
|
||||
|
||||
it('auto-redirects to the IdP when OIDC is the only login method', async () => {
|
||||
// Auto-redirect on standalone OIDC is enforced server-side via the
|
||||
// `auto_redirect_if_standalone_oidc` policy (see
|
||||
// src/interfaces/web/mod.rs::oidc_standalone_login_redirect). The SPA no
|
||||
// longer contains a client-side copy — a duplicate would override the admin's
|
||||
// policy choice. We keep this test asserting the *negative* to lock in
|
||||
// "SPA renders the click-to-continue button, no window.location.replace".
|
||||
it('does not client-side auto-redirect when OIDC is the only login method', async () => {
|
||||
m(auth.getOidcProviders).mockResolvedValue({
|
||||
enabled: true,
|
||||
password_login_enabled: false,
|
||||
authorize_endpoint: '/api/auth/oidc/authorize'
|
||||
});
|
||||
render(LoginPage);
|
||||
await waitFor(() => expect(replaceSpy).toHaveBeenCalledWith('/api/auth/oidc/authorize'));
|
||||
// Give onMount time to finish its probes; the SSO button must appear
|
||||
// and `window.location.replace` must NOT have been called.
|
||||
await screen.findByTestId('login-oidc-btn');
|
||||
expect(replaceSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not auto-redirect when password login is also enabled', async () => {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Persist the OIDC ID token on the session so it can be used as
|
||||
-- `id_token_hint` in the RP-initiated logout URL sent back to the FE.
|
||||
--
|
||||
-- Without this, OxiCloud logout only clears the local session; the IdP
|
||||
-- SSO cookie stays alive and — under the `auto_redirect_if_standalone_oidc`
|
||||
-- posture — the very next `/login` visit silently re-authenticates the
|
||||
-- user via the IdP session. Shared-computer scenario: a user can't
|
||||
-- actually log out.
|
||||
--
|
||||
-- Nullable because the column only applies to OIDC-issued sessions;
|
||||
-- password / magic-link sessions leave it NULL. Stored as-is (unencrypted)
|
||||
-- because ID tokens are short-lived JWTs whose PII payload (email, name)
|
||||
-- is already present in cleartext in auth.users — no new exposure.
|
||||
ALTER TABLE auth.sessions
|
||||
ADD COLUMN IF NOT EXISTS oidc_id_token TEXT;
|
||||
|
||||
COMMENT ON COLUMN auth.sessions.oidc_id_token IS
|
||||
'ID token from the OIDC login exchange, used as id_token_hint for RP-initiated logout. NULL for non-OIDC sessions.';
|
||||
@@ -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