Signing out of everything
Every protocol family here that can sign somebody in has a sign-out of its own, and each one signs them out of itself:
| Endpoint | What it is |
|---|---|
GET|POST /oauth2/logout |
OpenID Connect RP-Initiated Logout — asks the person to confirm unless a verified id_token_hint names the session |
GET|POST /wsfed?wa=wsignout1.0 |
WS-Federation 1.2 section 13.2.4 |
GET|POST /saml2/slo |
SAML 2.0 Single Logout |
None of those is the question most people arrive with, which is what am I still
signed into, and how do I stop being signed into it. That question is
protocol-independent, and so is /logout.
GET /logout
Lists everything this service is still holding for one identity — across every family — with a checkbox against each.
GET /logout
no session cookie -> 302 /authn/login -> back here signed in
a session cookie -> the list
GET /logout?username=alice somebody else's list (development mode only)
GET /logout?format=json the same thing, for a test
Signing out may mean signing in first. This service has no other way to know who is asking, and the session that creates is listed with everything else. No console role is needed, and that is not an oversight: signing yourself out must not require a role that signing in did not.
?username= naming somebody else works in development mode only. In product
mode a password is verified at every door, so an anonymous request naming a
person would be a way to end their sessions and revoke their tokens with no
credential at all; there, /logout acts only on the signed-in caller (naming
yourself is fine) and anything else is a 403 pointing at /admin/logout and
POST /admin-api/logout, which require an administrator’s credential.
POST /logout
Ends what was ticked. A POST that ticks nothing ends everything — that is the default and the point of the endpoint:
# global logout for whoever holds this cookie
curl -k -b cookies.txt -X POST https://localhost:8081/logout
# global logout for somebody named, no cookie needed
curl -k -X POST https://localhost:8081/logout -d 'username=alice&scope=global'
# just two things
curl -k -X POST https://localhost:8081/logout \
-d 'username=alice&select=session:8Qk3…&select=token:abc…'
Row ids come from the GET. They look like session:<id>, token:<jti>,
wsfed-rp:<session>|<realm>, code:<handle> — and for anything whose natural
key is a credential, the handle is a hash of it rather than the value: an
authorization code in a form field is an authorization code in a browser
history.
What it reaches
| Family | Ending it |
|---|---|
| Browser sign-on session | dropped, through the one function every sign-out here uses — so the RFC 9700 refresh-token revocation and the audit row happen once |
| OpenID Connect relying parties | its frontchannel_logout_uri loads in a hidden iframe, with iss and sid where it asked for them, and its backchannel_logout_uri is POSTed a signed Logout Token |
| WS-Federation realms | wa=wsignoutcleanup1.0 as a one-pixel image, with the URL printed beside it |
| SAML 2.0 service providers | the signed LogoutRequest, offered as a link |
| Federation partners (#167) | the identity provider a session was signed in through: its own sign-out — a signed LogoutRequest, RP-Initiated Logout with the partner’s ID Token as id_token_hint, or wsignout1.0 — offered as a link or a form, from /logout in the person’s own browser only. SAML 1.1 and OAuth 2.0 partners define no sign-out and are not told. See Federation |
| Tokens | the jti joins the same revocation set /oauth2/revoke writes to, so /oauth2/introspect reports it inactive immediately |
| Authorization codes | discarded, so no more tokens come from that sign-on |
| Credential Offer pre-authorized codes | the same |
| Wallet credentials that can sign this person in | disowned: every credential this realm issued to that wallet key for this person up to now stops signing anybody in at /authn/wallet, and its status-list entry is set INVALID so other verifiers learn it too. The credential stays in the wallet and still verifies; one issued afterwards, on a fresh sign-in, works again. Only a global sign-out does this — /logout, /admin/logout, /admin-api/logout — and signing out of one application does not |
| Wallet sign-ins not yet collected | withdrawn: a wallet has presented a credential for this person and the browser that started the sign-in has not come back for the session yet, so that browser is told a sign-out ended it and nobody is signed in |
| Directory connections | the LDAP socket is closed — the bind is the state of a connection (RFC 4511 §4.2), so that is the only sign-out LDAP has |
| Kerberos | a sign-out instant on the principal; a TGS-REQ presenting a ticket authenticated before it is refused KDC_ERR_TGT_REVOKED (20) |
What it cannot reach — and lists anyway
This is the part worth reading before relying on any of it. A SAML assertion
already in a service provider’s hands, a Kerberos service ticket already in a
cache and an X509-SVID already minted cannot be ended — not by this service and
not by a real one. The reason is the same in all three cases: nothing consults
the issuer when they are presented. A relying party checks a signature and some
Conditions and asks nobody. A Kerberos service decrypts a ticket with its own
key. An SVID verifies against a bundle it already has.
So those rows appear on the page with a dash instead of a checkbox and a sentence saying why. Hiding them would make a global logout look complete when it is not, which is the most misleading thing this endpoint could do.
Three more limits, all honest rather than missing:
-
A Kerberos service ticket keeps working against the service that accepts it. The sign-out instant is checked at the KDC, and accepting a service ticket never contacts the KDC. A fresh
AS-REQalso succeeds — signing out is not being locked out — but does not lift the instant (#111): its new ticket is accepted, and every ticket-granting ticket from before the sign-out, renewed or not, stays refused until the latest one could still be valid — the sign-out plus the longer of the ticket and renew lifetimes, plus the clock skew — on every node.Worth being plain about: Kerberos itself has no logout, no session and no revocation. There is no CRL, no status query and no list of issued tickets — a KDC deliberately keeps no state about what it has issued, which is what lets one be replicated read-only. A ticket is valid because it decrypts and hasn’t expired, a service accepts one with its own key without contacting the KDC, and short lifetimes are the entire revocation model.
KDC_ERR_TGT_REVOKED(20) is a registered error code whose text says what is meant (RFC 4120 §7.5.9), but no specification defines a mechanism that emits it. What this service does is an invention using the one lever a real KDC has — theTGS-REQ, which is the only moment the KDC is back in the loop, and the same lever that makes disabling an Active Directory account bite within the service-ticket lifetime rather than the TGT’s. Do not read it as conformance.The check is on the ticket’s
authtime, not its issue time, because a renewed ticket deliberately preservesauthtime; checking anything else would let a renewal launder a signed-out ticket back into a live one. - An LDAP client sees its connection end mid-conversation. The connection is the session, so the sign-out is the socket closing, which is what a directory revoking a session looks like from the other end. An Unsolicited Notice of Disconnection (RFC 4511 section 4.4.1) would be the polite form, and node-ldapjs has no way to send one.
- SPIFFE is not in the list at all. A SPIFFE identity is a workload, attested
per call, holding no session. The registry can end an identity’s ability to
obtain another SVID, which is a ban rather than a logout; that lives at
/admin/spiffe.
Front-channel logout
/logout — and /oauth2/logout, and the console — perform OpenID Connect
Front-Channel Logout 1.0 for any client that registered a
frontchannel_logout_uri:
curl -k -X POST https://localhost:8081/oauth2/register \
-H 'Content-Type: application/json' -d '{
"client_name": "demo",
"redirect_uris": ["http://localhost:3000/callback"],
"frontchannel_logout_uri": "http://localhost:3000/logout",
"frontchannel_logout_session_required": true }'
With that registered, the discovery document says
frontchannel_logout_supported: true, an ID Token issued on a browser session
carries a sid naming that session, and every sign-out renders
<iframe src="http://localhost:3000/logout?iss=…&sid=…">
plus the same URL as a visible link. The link is not decoration: the specification says the provider cannot know whether a front-channel notification succeeded, so a dead relying party, a certificate your browser will not accept and a mistyped URI all look identical to success. Clicking it is the only way to see which happened.
iss and sid are sent only to a client that registered
frontchannel_logout_session_required; the specification says they are otherwise
omitted. The discovery document says frontchannel_logout_session_supported:
true. iss is the issuer that client’s ID Token named. For a client of a
named authorization server (/tenant1/oauth2/…), that is the named server’s
issuer, even when you sign out somewhere else.
The frontchannel_logout_uri must be on a redirect URI’s origin: the same
scheme, host and port as one of the client’s redirect_uris (section 2). In
the example above, both are on http://localhost:3000. A registration, a
console edit or an /admin-api write that breaks the rule is refused. A stored
URI that breaks it, for example after the matching redirect URI was removed,
is not framed, and the sign-out page lists that client with the reason.
Returning to the application. After /oauth2/logout with a
post_logout_redirect_uri, a page that notifies relying parties returns to
that address by itself after oauth2.frontchannelLogoutWaitS seconds (default
3). That gives the iframes time to load. The page also shows the address as a
link. Set the wait to 0 to return only when the link is followed.
Back-channel logout
A relying party can also be told without your browser. Register a
backchannel_logout_uri (and, if you like,
backchannel_logout_session_required) the same way:
curl -s -X POST http://localhost:8081/oauth2/register \
-H 'Content-Type: application/json' \
-d '{"redirect_uris":["http://localhost:3000/cb"],
"backchannel_logout_uri": "https://rp.example/bc-logout",
"backchannel_logout_session_required": true}'
Discovery then says backchannel_logout_supported: true and
backchannel_logout_session_supported: true. Whenever a session that client
was signed into ends — any sign-out: this page, /oauth2/logout,
WS-Federation’s wsignout1.0, SAML Single Logout, the console or the
management API — this service POSTs
application/x-www-form-urlencoded with a logout_token to that address. The
token is a JWT typed logout+jwt, signed like that client’s ID Token, and
carries iss, aud, iat, exp, jti, sub, sid and the
http://schemas.openid.net/event/backchannel-logout event — never a nonce.
What to expect:
- It happens after the sign-out answers. The result page lists each
delivery as
pending;/admin/logoutshows whether it wassentor became a dead letter, and every final outcome is alogout.backchannelrow on the audit log. The list is the whole service’s — every node’s deliveries, because they are rows in the shared store — and it is filtered by state, searched and paged. - Answer 200 (or 204). A 400 is taken as your final refusal and is not
retried. A timeout, a refused connection, a 5xx, 408 or 429 is retried a few
times with a growing pause — see
oauth2.backchannelLogoutAttempts,...TimeoutMs,...BackoffMsand...TokenTtlSin configuration. - The address has to be reachable under the outbound rules: https with the
certificate verified, unless
federation.outboundAllowHttporfederation.outboundSkipTlsVerificationis on — the ordinary case on localhost, and development mode only (#171) — nothing at all withfederation.outboundoff, and — in product mode — never a loopback, private or link-local address. - A session that simply EXPIRES sends one too, while
oauth2.backchannelLogoutOnExpiryis on (it is by default): the specification lets the provider tell its relying parties whenever its own session ends. Front-channel logout cannot follow an expiry — it needs the browser that is no longer there — so an idle timeout reaches yourbackchannel_logout_uriand nothing else. - A delivery that never lands becomes a DEAD LETTER, kept with its reason.
An administrator sends it again with Retry on
/admin/logout, orPOST /admin-api/logout/retry-backchannel— a new Logout Token, and your currentbackchannel_logout_uri, which is the point of retrying after correcting it. - A restart or a node failing does not lose it. The delivery is a row, not a timer: whichever process gets there next picks it up, and exactly one sends each attempt (each is claimed with a lease of its own).
- It is ENCRYPTED if you asked for that: register
id_token_encrypted_response_alg(and_enc) with a public key in an inlinejwks, and the Logout Token arrives as a Nested JWT, encrypted to that key exactly as your ID Tokens are.
The Sign out button on the console and the portal
This service’s own two web surfaces each have a Sign out button of their
own — at the top of every page of /admin, and at the top of every page of
/portal. On the console it is in the account menu, the drop-down in the
head row labelled with the name you are signed in as; the menu’s other row is a
link to your own account in the user portal. (It is a <details> element, so it
opens with no JavaScript — this console ships none — and closes when you click
its label again rather than when you click elsewhere on the page.)
| Endpoint | What it ends |
|---|---|
POST /admin/signout |
the console’s session and the sign-on session behind it |
POST /portal/signout |
the portal’s session and the sign-on session behind it |
Each ends two sessions, and it has to. Both surfaces are ordinary OpenID
Connect clients of this service (sts-admin-console and sts-user-portal), so
they hold a session of their own on top of the sign-on session you have with
the identity provider. A button that ended only the application’s session would
sign nobody out: the next page runs the authorization code flow, meets the
sign-on session that is still live, and lets you back in with nothing typed.
They are the NARROW sign-out. What they do not touch is anything already
issued to other applications — access and refresh tokens, Kerberos tickets,
credential offers, LDAP binds. /logout below is the one that ends those, and
the portal draws a button for it at the foot of its Overview page.
The other two doors
| Who it is for | Difference | |
|---|---|---|
/logout |
a person, about themselves | no console role needed; it is the browser that loads the notifications |
/admin/logout |
an operator, about somebody else | behind the console’s two roles; filtered and paged; has two NON-SPEC undos — restoring a revoked token, and clearing a Kerberos sign-out instant (development mode only) |
GET|POST /admin-api/logout |
a test | four operations: global, end, restore-token, restore-kerberos (refused in product mode) |
All three call one pair of functions, which is what stops them coming to disagree about what a live session is.
What a session IS, in each of the three senses this service uses the word, is Sessions. This page is about ending one.
And a fourth, asking it across everybody
The three above are keyed on ONE identity. /admin/sessions — with
GET /admin-api/sessions and POST /admin-api/sessions/revoke beside it — is
the same model asked across the whole service: every session it is holding right
now, in the three protocols that have one, with a Revoke on each row that calls
the same termination.
| Kind | Why it is a session | When it expires |
|---|---|---|
| Browser sign-on session | the cookie from /authn/login, which OAuth 2.0 / OIDC, WS-Federation, both SAML profiles and the console all read |
at an absolute instant fixed when it was created (authn.sessionLifetimeS) — using it does not extend it — or, where authn.sessionIdleTimeoutS is set, once it has gone that long unused |
| Kerberos ticket-granting ticket | a TGT is the Kerberos session; a service ticket is one use of it | at the endtime the KDC sealed into the ticket, which nothing here can move |
| LDAP connection | RFC 4511 section 4.2 makes a Bind the authorization state of a connection | never — it lasts until the next Bind, an Unbind, or the socket closing |
Revoking is not one act across the three, and each row says which it is before the button is pressed: a browser session ends with its relying parties notified and its refresh tokens revoked; an LDAP row closes a socket; a Kerberos row stamps a sign-out instant on the principal, refusing every ticket that principal authenticated before now, and still reaches no service ticket already in a cache.
A partner ending the session
A federation partner’s own sign-out — a SAML LogoutRequest, an OpenID
Connect Back-Channel or Front-Channel logout, a WS-Federation cleanup the
person confirms — ends the session that partner started, and only that one,
through this same model: the session and the relying parties riding on it,
each told as above, with federation.signout on the audit log. A partner’s
SAML SessionNotOnOrAfter ends the session when it passes, as an expiry. See
Federation.
A session that simply runs out
It ends the same way, and says so the same way. An expiry
writes the session.end audit row and emits CAEP’s session-revoked like any
other ending — with initiating_entity: policy, because nobody signed out: a
lifetime ran out. A sweep runs every 30 seconds so that this happens whether or
not anybody comes back to look, which is the difference between telling a
receiver and telling it only if somebody happens to return.
Turning parts of it off
Two of these mechanisms take something away that used to keep working, and every refusal in this service is switchable — see configuration:
| Setting | Off means |
|---|---|
logout.kerberosSignOut |
the KDC behaves exactly as it did before this existed |
logout.ldapDisconnect |
directory connections are left alone, and listed as untouched rather than hidden |
logout.anyUser |
?username= is refused; /logout acts only on the caller’s own session. Product mode behaves as if this were off, whatever it says |
oauth2.frontchannelLogout |
no front-channel advertisement and no iframes; the sid claim stays while back-channel logout is on |
oauth2.backchannelLogout |
no back-channel advertisement and no Logout Tokens; with both off, the ID Tokens carry no sid, byte-for-byte what this service issued before either |