Skip to main content

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

LayerWhenMechanism
Layer 1 — instantApp is open with a live WebSocketServer broadcasts a logout message to user:{userId}; the app signs out immediately
Layer 2 — on next launchApp was closed when the logout happenedOn 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:

VerbEffect on tokensEffect on consentServer triggerWS message
Logout (keep consent) — all clientsRevoke ALL user tokensKept (auto re-login, no re-consent)logoutEverywhereforceLogout
Logout (keep consent) — one clientRevoke that client's tokensKeptlogoutApp(clientId)appLogout
Disconnect (revoke consent) — one clientRevoke that client's tokensDeleted (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:

MessageConditionResult (errorCode)
forceLogoutalwayslogout (ForceLogout)
appLogoutmessage.clientId === options.clientIdlogout (LoggedOut) — consent kept
appLogoutdifferent clientignore
oAuthConsentRevokedmessage.clientId === options.clientIdlogout (ConsentRevoked) — re-consent needed
oAuthConsentRevokeddifferent clientignore
accountDeletedalwayslogout (AccountDeleted)
accountBannedalwayslogout (AccountBanned)
sessionRevokedalwaysignore (Heimdall ID session, not an app session)
anything elseignore

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();
OptionTypeRequiredDescription
userIdstringYesSigned-in user id; used to subscribe to user:{userId}
clientIdstringNoThis app's OAuth client id; filters appLogout/oAuthConsentRevoked to this app
accessTokenstringNoOAuth access token for the WS connection (?token=)
onLogout(decision) => voidYesCalled once per message that resolves to a logout
websocketOmit<WebSocketConfig, "accessToken">NoReconnect/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:

  1. Layer 1: a thin hook that calls resolveSsoLogout(msg, { clientId }) (or createSsoLogoutListener) on the app's WebSocket and runs next-auth signOut() when shouldLogout is true.
  2. Layer 2: in the next-auth jwt callback's periodic check, call isSessionTokenRevoked(getApiConfig(), token.accessToken) and invalidate the session when it returns true.

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.

  1. Layer 1 (instant, optional) — if the app keeps a live connection, use createSsoLogoutListener. It connects to wss://<api-host>/v1/ws?token=<accessToken>, sends {"type":"Subscribe","channel":"user:<userId>"}, and calls onLogout on forceLogout / 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 the resolveSsoLogout table).
  2. Layer 2 (required) — on app launch / resume, call isSessionTokenRevoked (or replicate: GET /v1/oauth/userinfo with the stored token; treat only 401/403 as revoked). On revoked → clear stored tokens and route to login. Because the refresh_token grant 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.
  3. 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, call logoutEverywhere.
note

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.