proposed core.backchannel-logout

Tell each app a browser session signed in to that the session ended

A browser session ends: the person signs out at an app (RP-initiated logout) or at POST /auth/logout, an admin revokes it, a key is retired in an emergency, or it lapses. Every caller ends sessions through the session service, and in the transaction that marks a session revoked and takes the clients it served, QAuth writes one user.client_session_ended event per client with a backchannel_logout_uri to the event store: the notices cannot be lost once the end commits, and the end never waits for them. The delivery role fans each event out to its client when the client registered a backchannel_logout_uri, and delivers it at least once: it reads the client again, has the signer role sign a logout token (typ logout+jwt, with iss, aud, iat, a two-minute exp, a jti every retry keeps, the back-channel logout event, sub and sid, and no nonce), and POSTs it as the logout_token form field through the egress client, which checks the destination's addresses again. A 2xx delivers it; a failure that may pass is retried on the delivery role's schedule; any other answer ends the delivery as failed. A failed delivery is recorded and counted, and changes nothing about the sign-out.

What this view shows

the end of browser sessions through the session service, the notices it queues, and the delivery of one logout token to one app, from the fan-out through every attempt to its last.

Open world: what is not drawn here is not ruled out. Only the guarantees below are binding.

Left out of this view:

The main story

When: The session had signed the person in to at least one app with a backchannel_logout_uri, the client has a backchannel_logout_uri when the delivery role lists its destinations, the client is enabled, not suspended, of the event's realm, and still has its URI, the client did not set backchannel_logout_session_required, the event names the session by its sid, as every session QAuth creates has one, the client's own registration set the URI (dynamic registration, a CIMD document), every address the host resolves to now is public, the deployment allowlist names neither the host nor its range for the client's realm, the app answers 200, or 204 as some frameworks do (§2.8), the delivery has attempts left.

  1. End the sessions, and queue a notice for each client they served: the session ends and its notices are queued, in the same commit
  2. The notices wait in the event store: the delivery role's next poll reaches the event
  3. Match the event to its client: one delivery row for this client and this session
  4. Wait for the delivery role to claim the delivery: the delivery role claims the row, counting one attempt
  5. Read the client as it is now: the client still takes the notice
  6. Have the signer role sign the logout token: the signer answers a compact JWS
  7. Check the destination again through the egress client: the destination passes
  8. POST the logout token to the app: the app answers 2xx

Result: The app logged the session out.

The flow

the session ends; it served no app with a backchannel_logout_uri, so nothing is queued the session ends and its notices are queued, in the same commit the delivery role's next poll reaches the event no destination matches: the client registered no URI, so it gets nothing one delivery row for this client and this session the delivery role claims the row, counting one attempt the client is gone, or of another realm the client is disabled or suspended the client no longer has a backchannel_logout_uri the client requires a sid and the event names none: nothing is sent the client still takes the notice the signer answers a compact JWS the egress client refuses the address: nothing is sent, and a later attempt checks again the destination passes the app answers 2xx the app answers 400 or another status no retry changes the app answers 5xx, 408 or 429 fails: no answer: the connection, TLS or the time limit fails that was the last attempt attempts are left the delivery comes due again, and the role claims it start The session service's revocation: one session, a user's sessions, or a realm's in batches. One transaction locks the sessions, marks them revoked with the reason, deletes their registry rows (the clients each one served), spends their unredeemed codes, and writes one user.client_session_ended event per session and client that has a backchannel_logout_uri to the event store, the outbox: the event names the user, the client and the session's sid. Then the cache entries are tombstoned and the session service's listeners hear of the end. End the sessions, and queue a notice for each client they served endingSessions · server step The caller's answer does not wait for them: a sign-out completes whatever the apps do later. Nothing is lost if the node stops now; the delivery role reads the event store, not this process. The notices wait in the event store queued · server step The back-channel logout route lists every client with a backchannel_logout_uri that is enabled and not suspended, each taking only user.client_session_ended events whose envelope client is its own client_id. The fan-out writes one delivery row per matching client and event, so each client gets one logout token per session. Match the event to its client fanningOut · server step awaitingAttempt Wait for the delivery role to claim the delivery awaitingAttempt · server step The delivery may have waited: the client, its URI and its sid rule are read again at every attempt, by the delivery role's own database user, which may read only the columns that say where a logout token goes. Read the client as it is now readingClient · server step The claims are aud (the client_id), iat (now), exp (two minutes later, Back-Channel Logout 1.0 §4), jti (the delivery's id, the same on every retry of it, so an app can tell a retry it already acted on), events with the http://schemas.openid.net/event/backchannel-logout member set to {}, sub (the user's id, as the ID tokens name the user) and sid (the session). There is no nonce. The route hands them to the delivery role's signer handle with typ logout+jwt; the signer adds iss, the realm's issuer, and signs with the key and algorithm of the realm's ID tokens, which the realm JWKS publishes (§2.4). The route holds no key. Have the signer role sign the logout token signing · server step The egress client resolves the URI's host now and checks every address before it connects, as it did at registration: https only, no credentials, and no private, loopback, link-local or other non-public address. A URI the client's own registration set never uses the deployment allowlist; one an admin set uses it for the client's realm. Check the destination again through the egress client checkingDestination · server step POST to the backchannel_logout_uri, with Content-Type application/x-www-form-urlencoded and the body logout_token=<the token> (§2.5). No redirect is followed, and the answer is read up to 64 KiB. POST the logout token to the app posting · server step The delivery row keeps the error class and the status, and qauth_events_delivery_attempts_total counts the attempt under kind backchannel_logout. The session stays ended. Record the failed attempt attemptFailed · server step The delivery role's backoff: 30 seconds, doubling, at most 6 hours, by default. Wait for the next attempt waitingToRetry · server step delivered The app logged the session out ends here The app said the token is invalid or its logout failed; no retry would change that (§2.5). The app refused the logout token ends here The delivery stays in the queue as failed, with the last attempt's error class. Every attempt failed ends here droppedClientGone Nothing is sent to a client that no longer exists ends here droppedClientInactive Nothing is sent to a disabled or suspended client ends here droppedNoLogoutUri Nothing is sent to a client that dropped its URI ends here droppedSidUnavailable Nothing is sent when the client requires a sid the notice lacks ends here No delivery row is written, so nothing is attempted, counted or shown as failed. A client without a back-channel logout URI gets nothing ends here nothingToNotify The session ended, and served no app ends here
the session ends; it served no app with a backchannel_logout_uri, so nothing is queued the session ends and its notices are queued, in the same commit fails: Postgres cannot store the change: the transaction rolls back the delivery role's next poll reaches the event no destination matches: the client registered no URI, so it gets nothing one delivery row for this client and this session the delivery role claims the row, counting one attempt the client is gone, or of another realm the client is disabled or suspended the client no longer has a backchannel_logout_uri the client requires a sid and the event names none: nothing is sent the client still takes the notice fails: the client cannot be read now the signer answers a compact JWS fails: the signer cannot sign now the egress client refuses the address: nothing is sent, and a later attempt checks again the destination passes the app answers 2xx the app answers 400 or another status no retry changes the app answers 5xx, 408 or 429 fails: no answer: the connection, TLS or the time limit fails that was the last attempt attempts are left the delivery comes due again, and the role claims it start The session service's revocation: one session, a user's sessions, or a realm's in batches. One transaction locks the sessions, marks them revoked with the reason, deletes their registry rows (the clients each one served), spends their unredeemed codes, and writes one user.client_session_ended event per session and client that has a backchannel_logout_uri to the event store, the outbox: the event names the user, the client and the session's sid. Then the cache entries are tombstoned and the session service's listeners hear of the end. End the sessions, and queue a notice for each client they served endingSessions · server step The caller's answer does not wait for them: a sign-out completes whatever the apps do later. Nothing is lost if the node stops now; the delivery role reads the event store, not this process. The notices wait in the event store queued · server step The back-channel logout route lists every client with a backchannel_logout_uri that is enabled and not suspended, each taking only user.client_session_ended events whose envelope client is its own client_id. The fan-out writes one delivery row per matching client and event, so each client gets one logout token per session. Match the event to its client fanningOut · server step awaitingAttempt Wait for the delivery role to claim the delivery awaitingAttempt · server step The delivery may have waited: the client, its URI and its sid rule are read again at every attempt, by the delivery role's own database user, which may read only the columns that say where a logout token goes. Read the client as it is now readingClient · server step The claims are aud (the client_id), iat (now), exp (two minutes later, Back-Channel Logout 1.0 §4), jti (the delivery's id, the same on every retry of it, so an app can tell a retry it already acted on), events with the http://schemas.openid.net/event/backchannel-logout member set to {}, sub (the user's id, as the ID tokens name the user) and sid (the session). There is no nonce. The route hands them to the delivery role's signer handle with typ logout+jwt; the signer adds iss, the realm's issuer, and signs with the key and algorithm of the realm's ID tokens, which the realm JWKS publishes (§2.4). The route holds no key. Have the signer role sign the logout token signing · server step The egress client resolves the URI's host now and checks every address before it connects, as it did at registration: https only, no credentials, and no private, loopback, link-local or other non-public address. A URI the client's own registration set never uses the deployment allowlist; one an admin set uses it for the client's realm. Check the destination again through the egress client checkingDestination · server step POST to the backchannel_logout_uri, with Content-Type application/x-www-form-urlencoded and the body logout_token=<the token> (§2.5). No redirect is followed, and the answer is read up to 64 KiB. POST the logout token to the app posting · server step The delivery row keeps the error class and the status, and qauth_events_delivery_attempts_total counts the attempt under kind backchannel_logout. The session stays ended. Record the failed attempt attemptFailed · server step The delivery role's backoff: 30 seconds, doubling, at most 6 hours, by default. Wait for the next attempt waitingToRetry · server step delivered The app logged the session out ends here The app said the token is invalid or its logout failed; no retry would change that (§2.5). The app refused the logout token ends here The delivery stays in the queue as failed, with the last attempt's error class. Every attempt failed ends here droppedClientGone Nothing is sent to a client that no longer exists ends here droppedClientInactive Nothing is sent to a disabled or suspended client ends here droppedNoLogoutUri Nothing is sent to a client that dropped its URI ends here droppedSidUnavailable Nothing is sent when the client requires a sid the notice lacks ends here No delivery row is written, so nothing is attempted, counted or shown as failed. A client without a back-channel logout URI gets nothing ends here nothingToNotify The session ended, and served no app ends here The revocation's transaction rolled back: no session ended and no notice was queued. The caller's own error answers, so a sign-out that did not happen never looks like one that did. Nothing ended ends here

blue line: the main storyred dashed line: a failure path (shown with “Show failure paths”)dotted line: decided by a rule, nobody actsdashed grey box: the server decides, nothing is shown

What each step answers

StepThe outside world sees
The app logged the session out
delivered
body: {"delivery":"delivered"}
The app refused the logout token
refusedByApp
body: {"delivery":"failed","errorClass":"http_status"}
Every attempt failed
failedForGood
body: {"delivery":"failed","errorClass":"the last attempt's"}
Nothing is sent to a client that no longer exists
droppedClientGone
body: {"delivery":"failed","errorClass":"client_deleted"}
Nothing is sent to a disabled or suspended client
droppedClientInactive
body: {"delivery":"failed","errorClass":"client_inactive"}
Nothing is sent to a client that dropped its URI
droppedNoLogoutUri
body: {"delivery":"failed","errorClass":"logout_uri_removed"}
Nothing is sent when the client requires a sid the notice lacks
droppedSidUnavailable
body: {"delivery":"failed","errorClass":"sid_unavailable"}

How it decides what happens next

Match the event to its client

#IfThen
1no destination matches: the client registered no URI, so it gets nothingA client without a back-channel logout URI gets nothing
2one delivery row for this client and this sessionWait for the delivery role to claim the delivery

The first matching rule wins.

Exact conditions
#ConditionGoes toSituations
1noUriAtFanOutnotSentNoLogoutUri768 of 1536
2otherwiseawaitingAttempt768 of 1536
noUriAtFanOut
uriAtFanOut == 'none'

Check the destination again through the egress client

#IfThen
1the egress client refuses the address: nothing is sent, and a later attempt checks againRecord the failed attempt
2the destination passesPOST the logout token to the app

The first matching rule wins.

Exact conditions
#ConditionGoes toSituations
1destinationBlockedattemptFailed54 of 144
2otherwiseposting90 of 144
destinationBlocked
address == 'private' and not (uriSetBy == 'admin' and allowlisted == true)

Record the failed attempt

#IfThen
1that was the last attemptEvery attempt failed
2attempts are leftWait for the next attempt

The first matching rule wins.

Exact conditions
#ConditionGoes toSituations
1attemptsSpentfailedForGood456 of 912
2otherwisewaitingToRetry456 of 912
attemptsSpent
attempts == 'spent'

Every way it can end

It ends withWhenWhat happens
The app logged the session out
ends here
The session had signed the person in to at least one app with a backchannel_logout_uri, the client has a backchannel_logout_uri when the delivery role lists its destinations, the client is enabled, not suspended, of the event's realm, and still has its URI, the client did not set backchannel_logout_session_required, the event names the session by its sid, as every session QAuth creates has one, the client's own registration set the URI (dynamic registration, a CIMD document), every address the host resolves to now is public, the deployment allowlist names neither the host nor its range for the client's realm, the app answers 200, or 204 as some frameworks do (§2.8), the delivery has attempts left
  1. End the sessions, and queue a notice for each client they served: the session ends and its notices are queued, in the same commit
  2. The notices wait in the event store: the delivery role's next poll reaches the event
  3. Match the event to its client: one delivery row for this client and this session
  4. Wait for the delivery role to claim the delivery: the delivery role claims the row, counting one attempt
  5. Read the client as it is now: the client still takes the notice
  6. Have the signer role sign the logout token: the signer answers a compact JWS
  7. Check the destination again through the egress client: the destination passes
  8. POST the logout token to the app: the app answers 2xx
The app refused the logout token
ends here
The session had signed the person in to at least one app with a backchannel_logout_uri, the client has a backchannel_logout_uri when the delivery role lists its destinations, the client is enabled, not suspended, of the event's realm, and still has its URI, the client did not set backchannel_logout_session_required, the event names the session by its sid, as every session QAuth creates has one, the client's own registration set the URI (dynamic registration, a CIMD document), every address the host resolves to now is public, the deployment allowlist names neither the host nor its range for the client's realm, the app answers 400, or another status no retry changes: a redirect, 401, 403, 404, the delivery has attempts left
  1. End the sessions, and queue a notice for each client they served: the session ends and its notices are queued, in the same commit
  2. The notices wait in the event store: the delivery role's next poll reaches the event
  3. Match the event to its client: one delivery row for this client and this session
  4. Wait for the delivery role to claim the delivery: the delivery role claims the row, counting one attempt
  5. Read the client as it is now: the client still takes the notice
  6. Have the signer role sign the logout token: the signer answers a compact JWS
  7. Check the destination again through the egress client: the destination passes
  8. POST the logout token to the app: the app answers 400 or another status no retry changes
Every attempt failed
ends here
The session had signed the person in to at least one app with a backchannel_logout_uri, the client has a backchannel_logout_uri when the delivery role lists its destinations, the client is enabled, not suspended, of the event's realm, and still has its URI, the client did not set backchannel_logout_session_required, the event names the session by its sid, as every session QAuth creates has one, the client's own registration set the URI (dynamic registration, a CIMD document), every address the host resolves to now is public, the deployment allowlist names neither the host nor its range for the client's realm, the app answers 200, or 204 as some frameworks do (§2.8), this was its last attempt
  1. End the sessions, and queue a notice for each client they served: the session ends and its notices are queued, in the same commit
  2. The notices wait in the event store: the delivery role's next poll reaches the event
  3. Match the event to its client: one delivery row for this client and this session
  4. Wait for the delivery role to claim the delivery: the delivery role claims the row, counting one attempt
  5. Read the client as it is now: it fails, because the client cannot be read now
  6. Record the failed attempt: that was the last attempt
Nothing is sent to a client that no longer exists
ends here
The session had signed the person in to at least one app with a backchannel_logout_uri, the client has a backchannel_logout_uri when the delivery role lists its destinations, the client was deleted since, or the row is of another realm, the client did not set backchannel_logout_session_required, the event names the session by its sid, as every session QAuth creates has one, the client's own registration set the URI (dynamic registration, a CIMD document), every address the host resolves to now is public, the deployment allowlist names neither the host nor its range for the client's realm, the app answers 200, or 204 as some frameworks do (§2.8), the delivery has attempts left
  1. End the sessions, and queue a notice for each client they served: the session ends and its notices are queued, in the same commit
  2. The notices wait in the event store: the delivery role's next poll reaches the event
  3. Match the event to its client: one delivery row for this client and this session
  4. Wait for the delivery role to claim the delivery: the delivery role claims the row, counting one attempt
  5. Read the client as it is now: the client is gone, or of another realm
Nothing is sent to a disabled or suspended client
ends here
The session had signed the person in to at least one app with a backchannel_logout_uri, the client has a backchannel_logout_uri when the delivery role lists its destinations, the client was disabled or suspended since, the client did not set backchannel_logout_session_required, the event names the session by its sid, as every session QAuth creates has one, the client's own registration set the URI (dynamic registration, a CIMD document), every address the host resolves to now is public, the deployment allowlist names neither the host nor its range for the client's realm, the app answers 200, or 204 as some frameworks do (§2.8), the delivery has attempts left
  1. End the sessions, and queue a notice for each client they served: the session ends and its notices are queued, in the same commit
  2. The notices wait in the event store: the delivery role's next poll reaches the event
  3. Match the event to its client: one delivery row for this client and this session
  4. Wait for the delivery role to claim the delivery: the delivery role claims the row, counting one attempt
  5. Read the client as it is now: the client is disabled or suspended
Nothing is sent to a client that dropped its URI
ends here
The session had signed the person in to at least one app with a backchannel_logout_uri, the client has a backchannel_logout_uri when the delivery role lists its destinations, the client dropped its backchannel_logout_uri since, the client did not set backchannel_logout_session_required, the event names the session by its sid, as every session QAuth creates has one, the client's own registration set the URI (dynamic registration, a CIMD document), every address the host resolves to now is public, the deployment allowlist names neither the host nor its range for the client's realm, the app answers 200, or 204 as some frameworks do (§2.8), the delivery has attempts left
  1. End the sessions, and queue a notice for each client they served: the session ends and its notices are queued, in the same commit
  2. The notices wait in the event store: the delivery role's next poll reaches the event
  3. Match the event to its client: one delivery row for this client and this session
  4. Wait for the delivery role to claim the delivery: the delivery role claims the row, counting one attempt
  5. Read the client as it is now: the client no longer has a backchannel_logout_uri
Nothing is sent when the client requires a sid the notice lacks
ends here
The session had signed the person in to at least one app with a backchannel_logout_uri, the client has a backchannel_logout_uri when the delivery role lists its destinations, the client is enabled, not suspended, of the event's realm, and still has its URI, the client set backchannel_logout_session_required, the event names no sid, the client's own registration set the URI (dynamic registration, a CIMD document), every address the host resolves to now is public, the deployment allowlist names neither the host nor its range for the client's realm, the app answers 200, or 204 as some frameworks do (§2.8), the delivery has attempts left
  1. End the sessions, and queue a notice for each client they served: the session ends and its notices are queued, in the same commit
  2. The notices wait in the event store: the delivery role's next poll reaches the event
  3. Match the event to its client: one delivery row for this client and this session
  4. Wait for the delivery role to claim the delivery: the delivery role claims the row, counting one attempt
  5. Read the client as it is now: the client requires a sid and the event names none: nothing is sent
A client without a back-channel logout URI gets nothing
ends here
The session had signed the person in to at least one app with a backchannel_logout_uri, the client registered none, or was deleted, disabled or suspended, before the fan-out, the client is enabled, not suspended, of the event's realm, and still has its URI, the client did not set backchannel_logout_session_required, the event names the session by its sid, as every session QAuth creates has one, the client's own registration set the URI (dynamic registration, a CIMD document), every address the host resolves to now is public, the deployment allowlist names neither the host nor its range for the client's realm, the app answers 200, or 204 as some frameworks do (§2.8), the delivery has attempts left
  1. End the sessions, and queue a notice for each client they served: the session ends and its notices are queued, in the same commit
  2. The notices wait in the event store: the delivery role's next poll reaches the event
  3. Match the event to its client: no destination matches: the client registered no URI, so it gets nothing
The session ended, and served no app
ends here
The session never got as far as an app, or none of its apps registered a backchannel_logout_uri, the client has a backchannel_logout_uri when the delivery role lists its destinations, the client is enabled, not suspended, of the event's realm, and still has its URI, the client did not set backchannel_logout_session_required, the event names the session by its sid, as every session QAuth creates has one, the client's own registration set the URI (dynamic registration, a CIMD document), every address the host resolves to now is public, the deployment allowlist names neither the host nor its range for the client's realm, the app answers 200, or 204 as some frameworks do (§2.8), the delivery has attempts left
  1. End the sessions, and queue a notice for each client they served: the session ends; it served no app with a backchannel_logout_uri, so nothing is queued
Nothing ended
ends here
The session had signed the person in to at least one app with a backchannel_logout_uri, the client has a backchannel_logout_uri when the delivery role lists its destinations, the client is enabled, not suspended, of the event's realm, and still has its URI, the client did not set backchannel_logout_session_required, the event names the session by its sid, as every session QAuth creates has one, the client's own registration set the URI (dynamic registration, a CIMD document), every address the host resolves to now is public, the deployment allowlist names neither the host nor its range for the client's realm, the app answers 200, or 204 as some frameworks do (§2.8), the delivery has attempts left
  1. End the sessions, and queue a notice for each client they served: it fails, because Postgres cannot store the change: the transaction rolls back

Each row is also a generated test: set the starting situation, send the events, expect the ending.

What is guaranteed

These hold in all 15942 situations the model can reach, from 3072 different starting situations.

Exact rules
IdNever
a-notice-commits-with-the-endqueued and not sessionEnded
an-end-is-never-lostsessionEnded and not servedNone and not queued
delivery-never-undoes-the-sign-outstate in ['delivered', 'refusedByApp', 'failedForGood', 'droppedClientGone', 'droppedClientInactive', 'droppedNoLogoutUri', 'droppedSidUnavailable', 'notSentNoLogoutUri'] and not sessionEnded
nothing-for-an-app-the-session-never-serveddeliveryRow and servedNone
no-token-without-a-uriposted and (noUriAtFanOut or clientAtAttempt != 'active')
no-private-address-for-a-client-registrationposted and destinationBlocked
sid-when-the-client-requires-itposted and sidUnavailable
signed-before-sentposted and not signed
delivered-means-postedstate == 'delivered' and not posted

Reference

All transitions (24)
FromEventGuardToEffectsTaken
endingSessionsDONEservedNonenothingToNotifyendSessionsyes
endingSessionsDONEqueuedendSessions queueNoticesyes
endingSessionsFAIL.serverendFailedyes
queuedFAN_OUTfanningOutyes
fanningOutrule 1noUriAtFanOutnotSentNoLogoutUriyes
fanningOutrule 2awaitingAttemptwriteDeliveryyes
awaitingAttemptATTEMPTreadingClientyes
readingClientDONEclientGonedroppedClientGoneyes
readingClientDONEclientInactivedroppedClientInactiveyes
readingClientDONEuriDroppeddroppedNoLogoutUriyes
readingClientDONEsidUnavailabledroppedSidUnavailableyes
readingClientDONEsigningyes
readingClientFAIL.serverattemptFailedyes
signingDONEcheckingDestinationsignTokenyes
signingFAIL.serverattemptFailedyes
checkingDestinationrule 1destinationBlockedattemptFailedyes
checkingDestinationrule 2postingyes
postingDONEansweredOkdeliveredpostTokenyes
postingDONEansweredRefusedrefusedByApppostTokenyes
postingDONEattemptFailedpostTokenyes
postingFAIL.networkattemptFailedpostTokenyes
attemptFailedrule 1attemptsSpentfailedForGoodyes
attemptFailedrule 2waitingToRetryyes
waitingToRetryRETRYreadingClientwaitForRetryyes