066 - Logout everywhere per network and device¶
User Story¶
As a user who is signed in through one browser on the clearnet and the onion network, or on several devices, I want two explicit choices at logout, “Log out on this device” and “Log out everywhere”, where the first ends every session of this browser the platform can end server to server and the second also ends every session of mine on all other devices, so that no network or device keeps a live session of mine that I did not choose to keep.
Acceptance Criteria¶
Trust boundary¶
[ ] No Keycloak client that this requirement adds or changes has
serviceAccountsEnabled: true, and no service-account user of the realm gains arealm-managementrole through this requirement.[ ]
web-svc-logoutowns no Keycloak client, its env file holds no Keycloak client secret, it serves no route it did not serve before this change, and it sends no request to Keycloak.[ ] The final state holds no
/everywhereroute, noLOGOUT_EVERYWHERE_*key, noweb-svc-logoutclient in the realm render and no update routine that mapsrealm-managementroles to a service account.[ ] The realm keeps the values of
eventsEnabled,enabledEventTypes,eventsExpiration,adminEventsEnabled,adminEventsDetailsEnabledandeventsListenersit had before this change.[ ] No choice, nor any later replacement, uses a Keycloak service account, a
realm-managementrole, the Keycloak admin REST API, stored realm events or a Keycloak extension (SPI provider); “Log out everywhere” uses only the user’s own access token and themanage-accountrole of theaccountclient.[ ] A logout sends no browser request without a user click that it did not send before this change, and the CSP of every vhost is byte-identical to its render before this change.
[ ] Every back-channel endpoint this requirement wires answers a logout token with a bad signature, a foreign
iss, a wrongaudor a missingeventsclaim with a 4xx status and ends no session.[ ] The “Log out everywhere” link carries no token, session id, user id or query parameter; it carries only the fragment marker defined below.
Front-channel sweep per network¶
[ ] The shared OIDC client keeps
frontchannelLogout: true, the conductor asfrontchannel.logout.urlandfrontchannel.logout.session.required: "true", as rendered byroles/web-app-keycloak/templates/import/clients/default.json.j2.[ ] Each conductor still clears the cookies of every domain of its own network, as it does before this change.
[ ] The shared OIDC client carries no
backchannel.logout.url.
Back-channel logout per app¶
[ ] Every app whose pinned upstream receives OIDC back-channel logout tokens declares its own client under
services.sso.oidc.clientsin itsmeta/services.ymland renders it in itstemplates/keycloak/clients.json.j2, the wayweb-app-stalwartdoes, androles/web-app-keycloak/filter_plugins/oidc_clients.pyincludes it without a change on the Keycloak side.[ ] Each such client has
frontchannelLogout: false,backchannel.logout.session.required: "true"and abackchannel.logout.urlon the app’s canonical origin.[ ] Each such client’s
redirectUris,webOriginsandpost.logout.redirect.uriscover every network sibling of the app throughlookup('network_urls', ...).[ ] Each such client carries the client scopes and protocol mappers the app read from the shared client, so the app receives the same claims as before (username attribute, RBAC group claim, app scopes such as the Nextcloud scope of the shared client).
[ ] An existing app user who signs in through the app’s new client lands in the same app account as before the switch, and no second account appears.
[ ] In one browser signed in to the app on the clearnet and on the onion network, a logout started on either network makes the app’s next request on the other network ask for a new login.
[ ] That back-channel logout also happens when JavaScript is disabled in the browser that logs out.
[ ] Keycloak reaches every
backchannel.logout.urlfrom its container on a clearnet, an onion-only and a multi-network deployment; for an onion canonical origin, a pin sends the call over the local network to the proxy that serves the vhost, the way the SSO back-channel pin inplugins/lookup/container_extra_hosts.pydoes for calls from apps to Keycloak.[ ] Keycloak verifies the TLS certificate of every
backchannel.logout.url; underTLS_MODEself_signedit trusts the deployment CA, and no setting turns certificate verification off.
Apps that qualify for back-channel logout¶
[ ]
web-app-matrix: both flavors log Synapse in through its own client and setbackchannel_logout_enabled: true. The compose flavor (templates/flavor/compose/synapse/homeserver.yaml.j2) already sets the flag but logs in through the shared client; the ansible flavor (templates/flavor/ansible/vars.yml.j2) lacks the flag. Thebackchannel.logout.urlis/_synapse/client/oidc/backchannel_logouton the Synapse origin.[ ]
web-app-nextcloud: whenlookup('sso_oidc_plugin')(plugins/lookup/sso_oidc_plugin.py) resolves touser_oidc, Nextcloud logs in through its own client with thebackchannel.logout.url/index.php/apps/user_oidc/backchannel-logout/<provider identifier>; when it resolves tooidc_login(the default with LDAP) orsociallogin(the default without LDAP), Nextcloud stays on the shared client and its README row names its session lifetime.[ ]
web-app-stalwart: itswebuiandwebmailclients already renderfrontchannelLogout: falsewithout abackchannel.logout.url; the pinned Stalwart WebAdmin and Roundcube are checked for a logout-token receiver, each one that has one gets itsbackchannel.logout.url, and each one that has none gets a README row.[ ]
web-app-opencloud,web-app-espocrm,web-app-xwiki,web-app-moodleandweb-app-openwebuiare checked against the upstream documentation of their pinned versions; each one that receives logout tokens gets its own client under the criteria above, and each one that does not gets a README row.[ ] Every other role with
sso.flavor: oidcstays on the shared client and gets a README row naming whether its pinned upstream receives logout tokens and how long its session lives.
oauth2-proxy-gated apps¶
[ ]
roles/web-app-keycloak/templates/sso_proxy/oauth2-proxy-keycloak.cfg.j2renderscookie_refreshfrom one configuration key ofweb-app-keycloakthat the role config sets explicitly, and the render fails when the key is absent.[ ] The shipped
cookie_refreshvalue is the one the operator picks;1mis the proposal.[ ] The value is shorter than the realm’s
ssoSessionIdleTimeoutand than oauth2-proxy’scookie_expire, and a unit test fails on a value that is not.[ ] After Keycloak ends a user session, an oauth2-proxy session of that user on any network or device fails its next refresh, and the first request after one
cookie_refreshinterval redirects to the Keycloak login, never to a 5xx page.
Decided: two logout choices¶
The operator decided that the user gets two explicit choices at logout, “Log out on this device” and “Log out everywhere”. A logout on device A without the second choice ends no Keycloak session of device B.
Where the choices are offered¶
[ ] Every logout control lands on the Keycloak logout confirmation page
/realms/<realm>/protocol/openid-connect/logouton the Keycloak origin of its network:sys-front-inj-logoutpoints every app’s logout control atOIDC.CLIENT.LOGOUT_URLwithout anid_token_hint(roles/sys-front-inj-logout/templates/logout_one_liner.js.j2:1-9), and the dashboard menu item does the same (roles/web-app-dashboard/templates/menu/header.yml.j2:41-47). This page shows both choices.[ ] “Log out on this device” is Keycloak’s existing confirm submit (
#kc-logout), relabelled.[ ] “Log out everywhere” is a plain link to the dashboard of the same network with the fragment
#logout-everywhere.[ ] Both choices render with JavaScript on and with JavaScript off: a
logout-confirm.ftloverride in the corporate login theme (roles/web-app-keycloak/templates/theme/login/, staged byroles/web-app-keycloak/tasks/utils/theme.yml) renders them server side; whenservices.keycloak.designdisables the corporate theme, the logout panel (roles/web-app-keycloak/files/javascript/logout-panel.js) adds the “Log out everywhere” link and the page shows the stock “Log out on this device” submit.[ ] The “Account” menu of the dashboard (
roles/web-app-dashboard/templates/menu/header.yml.j2) shows the same two items: “Log out on this device” with the existingOIDC.CLIENT.LOGOUT_URLand “Log out everywhere” with the#logout-everywheremarker.[ ] When
web-app-dashboardwith OIDC is not part of the deployment, the confirmation page renders no “Log out everywhere”.
“Log out on this device”¶
[ ] It runs the existing logout: the front-channel conductor sweep per network for the shared client, the per-app back-channel logout and the oauth2-proxy
cookie_refreshof the sections above.[ ] It ends this browser’s Keycloak session, so the clearnet and onion siblings of one browser are both logged out, and no session of another device or browser context.
[ ] Its success message (
all_doneinroles/web-app-keycloak/files/logout_i18n.yml) names its scope, this device, and no longer reads “everywhere”.
“Log out everywhere”¶
[ ] The token holder is the existing keycloak-js instance of
roles/web-app-dashboard/templates/javascript/oidc.js.j2:155-173: it runscheck-ssowith the shared client and keeps the user’s access token fresh. The action runs while the user’s session is still valid; whencheck-ssofinds no session, the dashboard startskeycloak.login()and runs the action after the return.[ ] Its access token carries the
accountaudience through the audience resolve mapper of therolesscope (roles/web-app-keycloak/templates/import/realm.json.j2:1154-1194),manage-accountthroughdefault-roles-<realm>(realm.json.j2:59-74) and the dashboard origin inallowed-originsthrough theweb-originsscope (realm.json.j2:1101-1103, fed byKEYCLOAK_WEB_ORIGINSinroles/web-app-keycloak/vars/main.yml:119). Keycloak’s Account REST API checks that audience and answers CORS from thatallowed-originsclaim.[ ] On the
#logout-everywheremarker or the menu click, the dashboard sends, with that token only,GET /realms/<realm>/account/applications, thenDELETE /realms/<realm>/account/applications/<clientId>/consentfor every entry withofflineAccess: true, which revokes that client’s offline token, thenDELETE /realms/<realm>/account/sessions, the call the Account Console’s “Sign out all devices” makes, which ends every other online session of the user.[ ] It then calls
keycloak.logout(), which runs “Log out on this device” for this browser, conductor sweep included.[ ] The realm keeps
manage-accountof theaccountclient indefault-roles-<realm>and keeps theaccountclient, both rendered byroles/web-app-keycloak/templates/import/realm.json.j2.[ ] After “Log out everywhere” on device A, the next OIDC authentication on device B asks for credentials, no offline token of the user still refreshes, B’s sessions of every app with its own back-channel client end at their next request, and B’s oauth2-proxy sessions end within one
cookie_refreshinterval.[ ] Rejected: the Account Console route
/realms/<realm>/account/account-security/device-activityas the place of the action, because its “Sign out all devices” ends online sessions only, is hidden while one session exists and skips the logout when itsDELETEfails (signOutAllin Keycloak’sDeviceActivity.tsxhas no error handling), and changing that needs a custom account theme build.
Labels¶
[ ] The labels “Log out on this device” and “Log out everywhere”, the everywhere success message and the partial-logout message live in the
logoutcatalogue (roles/web-app-keycloak/files/logout_i18n.yml), served throughlookup('i18n_catalogue', 'logout', ...), and have a translation in every language block the catalogue serves; the dashboard reads the same keys.
Failure semantics¶
[ ] When a back-channel endpoint is unreachable or answers with a non-2xx status, Keycloak still ends the user session and completes the logout redirect, and Keycloak’s log names the client whose back-channel call failed.
[ ] The app behind such a failed call keeps its session until its own expiry, which its README row names.
[ ] The per-network sweep keeps its failure reporting: the conductor’s
donereport counts failed domains and the Keycloak logout panel shows itspartialmessage.[ ] A failed oauth2-proxy refresh ends the proxy session and redirects to the Keycloak login.
[ ] When any Account REST call of “Log out everywhere” fails, times out or answers with a non-2xx status, the dashboard still calls
keycloak.logout(), this browser is logged out as with “Log out on this device”, and after the post-logout redirect the user sees the partial-logout message that other devices may still be signed in.[ ] A per-app client reuses
OIDC.CLIENT.SECRET, as the Stalwartwebmailclient does, and the realm render and the Keycloak update routine succeed on an inventory created before this change without running the credential generator.
Documentation¶
[ ]
roles/web-svc-logout/README.mdholds one table row per role with anssoservice: its flavor, whether its pinned upstream receives OIDC back-channel logout tokens (yes, no or to verify), its endpoint, the upstream documentation link, and how long its session lives on another network or device when nothing ends it.[ ] The same README describes the two choices, “Log out on this device” (per-network front-channel sweep, per-app back-channel logout, oauth2-proxy refresh) and “Log out everywhere” (the Account REST calls with the user’s own token, then “Log out on this device”), and lists which sessions each one ends.
Tests¶
[ ] A unit test renders every
templates/keycloak/clients.json.j2and asserts per back-channel clientfrontchannelLogout: false,serviceAccountsEnabled: false, abackchannel.logout.urlon the app’s canonical origin and redirect URIs for every network sibling; a negative control with abackchannel.logout.urlon the shared client fails it.[ ] A unit test renders
roles/web-app-keycloak/templates/import/realm.json.j2against an inventory fixture that holds only the credentials present before this change, asserts that the render succeeds and asserts that no service-account user holds arealm-managementrole.[ ] A unit test renders the oauth2-proxy config, asserts that
cookie_refreshequals the configured value and asserts that the render fails when the key is absent.[ ] A unit test renders both Synapse flavors and asserts the Synapse client id and
backchannel_logout_enabled: truein each.[ ] Network case of the Playwright spec of
web-svc-logout: in one browser context, biber signs in to a back-channel app and to an oauth2-proxy app on the clearnet origin and on the onion origin of the same node, logs out on the clearnet origin, and the spec asserts that the back-channel app’s next onion request asks for a login and that the oauth2-proxy app’s onion request does so after onecookie_refreshinterval.[ ] The network case also passes with
javaScriptEnabled: falsefor the logout step.[ ] A unit test loads
roles/web-app-keycloak/files/logout_i18n.ymlthrough thei18n_cataloguelookup and asserts that both choice labels, the everywhere success message and the partial-logout message have a non-empty translation in every language block.[ ] Choice case of the same spec: on the Keycloak logout confirmation page and in the dashboard “Account” menu, both choices are visible with
javaScriptEnabled: trueand on the confirmation page also withjavaScriptEnabled: false.[ ] Device case “on this device”: biber signs in on two browser contexts A and B, A signed in on the clearnet and the onion origin; after “Log out on this device” in A, both networks of A ask for a login and B is still signed in to Keycloak and to its back-channel app.
[ ] Device case “everywhere”: after “Log out everywhere” in A, both networks of A ask for a login, B’s next OIDC authentication asks for credentials, B’s back-channel app session ended and
GET /realms/<realm>/account/applicationswith a fresh token of biber lists no entry withofflineAccess: true.[ ] Failure case: with the Account REST
DELETEanswered by a non-2xx status through Playwright request routing, A is still logged out on both networks and shows the partial-logout message.[ ] Forged-token case of the same spec: it POSTs a logout token signed with a foreign key to every back-channel endpoint, asserts a 4xx status and asserts that biber’s session in that app is still alive.
[ ] After the spec, Keycloak’s log holds no failed back-channel call for any per-app client.
Rollout¶
[ ] Each app switches to its own client in its own change with its own Playwright proof, and removing its
services.sso.oidc.clientsentry returns it to the shared client.[ ] On an existing realm, the update routine
roles/web-app-keycloak/tasks/update/01_settings.yml, fed byKEYCLOAK_SETTINGS_CLIENT_IDS, creates and converges each per-app client without a realm re-import.[ ] This requirement needs no universal-logout release, and the image pin in
roles/web-svc-logout/meta/services.ymlkeeps the version it had before this change.[ ] The unit tests, the lint suite and ruff pass on every change of this rollout.