Global SSO Logout
Heimdall is an OAuth 2.0 / OpenID Connect provider (IdP). When a user signs out
of the ID app, or logs out of a single connected app, the change happens on the
server (tokens are revoked in the database) and is pushed to open apps
over WebSocket. @elcto/api ships small, framework-agnostic helpers so any
JavaScript client — a Next.js webapp, a React Native mobile app, or an
Electron/Tauri desktop app — can adopt single-sign-out in minutes.
Two layers
| Layer | When | Mechanism |
|---|---|---|
| Layer 1 — instant | App is open with a live WebSocket | Server broadcasts a logout message to user:{userId}; the app signs out immediately |
| Layer 2 — on next launch | App was closed when the logout happened | On startup the app probes an authenticated endpoint; a revoked token → sign out + redirect to login |
Layer 2 is the robust fallback: because tokens are revoked server-side, the next API call (or refresh) fails regardless of whether the app was listening.
Taxonomy
Two verbs, applied consistently across the server API and the client helpers:
| Verb | Effect on tokens | Effect on consent | Server trigger | WS message |
|---|---|---|---|---|
| Logout (keep consent) — all clients | Revoke ALL user tokens | Kept (auto re-login, no re-consent) | logoutEverywhere | forceLogout |
| Logout (keep consent) — one client | Revoke that client's tokens | Kept | logoutApp(clientId) | appLogout |
| Disconnect (revoke consent) — one client | Revoke that client's tokens | Deleted (re-authorize needed) | revokeOauthConsent(clientId) | oAuthConsentRevoked |
See the server-side reference for the mutations and REST endpoints: GraphQL and REST.
Client helpers
resolveSsoLogout(message, options)
Pure decision function: maps an incoming WebSocket ServerMessage to a logout
decision. No side effects, no framework code.
import { resolveSsoLogout } from "@elcto/api";
const decision = resolveSsoLogout(message, { clientId: MY_OAUTH_CLIENT_ID });
if (decision.shouldLogout) {
// decision.errorCode: "ForceLogout" | "LoggedOut" | "ConsentRevoked" | ...
signOut();
}
Decision table:
| Message | Condition | Result (errorCode) |
|---|---|---|
forceLogout | always | logout (ForceLogout) |
appLogout | message.clientId === options.clientId | logout (LoggedOut) — consent kept |
appLogout | different client | ignore |
oAuthConsentRevoked | message.clientId === options.clientId | logout (ConsentRevoked) — re-consent needed |
oAuthConsentRevoked | different client | ignore |
accountDeleted | always | logout (AccountDeleted) |
accountBanned | always | logout (AccountBanned) |
sessionRevoked | always | ignore (Heimdall ID session, not an app session) |
| anything else | — | ignore |
isSessionTokenRevoked(config, accessToken) — Layer 2
Probes GET /v1/oauth/userinfo with the stored access token. Returns true
only on an explicit 401/403 (token revoked/invalid). Returns false on
2xx, on 5xx, and on any network error/timeout — it never forces a logout on
transient failures.
import { isSessionTokenRevoked } from "@elcto/api";
if (await isSessionTokenRevoked(getApiConfig(), accessToken)) {
redirectToLogin();
}
createSsoLogoutListener(config, options) — Layer 1, ready-made
Ties createWebSocket and resolveSsoLogout together: it opens
the socket, subscribes to the user's private channel user:{userId} on every
(re)connect, and calls onLogout when a message resolves to a sign-out. Use
this when you don't already have a WebSocket connection to piggyback on (typical
for native apps).
import { createSsoLogoutListener } from "@elcto/api";
const listener = createSsoLogoutListener(getApiConfig(), {
userId,
clientId: MY_OAUTH_CLIENT_ID,
accessToken,
onLogout: (decision) => signOutAndRedirectToLogin(decision.errorCode),
});
// on teardown / unmount:
listener.close();
| Option | Type | Required | Description |
|---|---|---|---|
userId | string | Yes | Signed-in user id; used to subscribe to user:{userId} |
clientId | string | No | This app's OAuth client id; filters appLogout/oAuthConsentRevoked to this app |
accessToken | string | No | OAuth access token for the WS connection (?token=) |
onLogout | (decision) => void | Yes | Called once per message that resolves to a logout |
websocket | Omit<WebSocketConfig, "accessToken"> | No | Reconnect/behaviour options |
Adopting in a webapp (Next.js)
The ID app already revokes tokens and broadcasts on sign-out — a client app only wires the two layers:
- Layer 1: a thin hook that calls
resolveSsoLogout(msg, { clientId })(orcreateSsoLogoutListener) on the app's WebSocket and runs next-authsignOut()whenshouldLogoutis true. - Layer 2: in the next-auth
jwtcallback's periodic check, callisSessionTokenRevoked(getApiConfig(), token.accessToken)and invalidate the session when it returnstrue.
That's it — the IdP handles revocation + broadcast.
Adopting in a native app (mobile / desktop)
Native apps are public OAuth clients and authenticate with PKCE (S256) — that only concerns the login flow, not logout. Token revocation always works for them because it happens server-side; the only app-side work is reacting to it.
- Layer 1 (instant, optional) — if the app keeps a live connection, use
createSsoLogoutListener. It connects towss://<api-host>/v1/ws?token=<accessToken>, sends{"type":"Subscribe","channel":"user:<userId>"}, and callsonLogoutonforceLogout/appLogout(thisClientId). In JS-based native stacks (React Native, Electron, Tauri-with-JS) import it directly from@elcto/api; in a non-JS stack, replicate the same three steps (connect → subscribe → run theresolveSsoLogouttable). - Layer 2 (required) — on app launch / resume, call
isSessionTokenRevoked(or replicate:GET /v1/oauth/userinfowith the stored token; treat only401/403as revoked). On revoked → clear stored tokens and route to login. Because therefresh_tokengrant also fails once the refresh token is revoked, an app that skips Layer 1 still ends up logged out on its next API call — Layer 2 just makes it deterministic at startup. - App-initiated sign-out — when the user logs out inside the native app,
call the standard RFC 7009 revocation endpoint
POST /v1/oauth/revoke(client-authenticated) on its own refresh token, then clear local tokens. To also end the session everywhere, calllogoutEverywhere.
Bearer tokens cached on-device are self-contained: an access token feels "valid" to the app until its next request or until it expires. Revocation invalidates it server-side immediately, so the instant push (Layer 1) is a UX nicety while Layer 2 is the guarantee.