Add central login recovery and confirmed shared sign-out
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 44s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c
This commit is contained in:
tegwick 2026-09-12 10:34:41 +02:00
parent 89694ad6df
commit 074c2ce498
9 changed files with 334 additions and 49 deletions

View file

@ -164,3 +164,24 @@ not to a session running against production on its own.
No resource-efficiency or throughput bounds are asserted here. Nothing in this
repository benchmarks KeyCape, so any figure would be invention. Measure it in
your own deployment before sizing against it.
## Account recovery and browser sign-out
Set `KEYCAPE_ACCOUNT_PORTAL_URL=https://users.coulomb.social` and
`KEYCAPE_BROWSER_LOGOUT_URL=https://auth.coulomb.social/logout` together to enable
central browser recovery. Both must be HTTPS, with no credentials, query or
fragment. They override the corresponding YAML accountPortalURL/browserLogoutURL.
Failed browser authorization and expired callbacks redirect to the fixed
`/access-recovery` page with no state, code, claimed user or browser return URL.
API authorization/token validation remains fail-closed.
`GET /account/logout` displays confirmation and creates a short-lived Secure,
HttpOnly host-only CSRF cookie. POST checks that cookie, the form nonce and exact
issuer Origin; only then does it invalidate the current KeyCape session and
redirect to Authelia's browser `/logout`, with a fixed portal `/logged-out` return.
Authelia owns and deletes its session cookie. Its v4.38 SignOut view validates the
return destination and performs the same-origin logout API call. Reference:
https://github.com/authelia/authelia/blob/v4.38.19/web/src/views/LoginPortal/SignOut/SignOut.tsx
Existing RP sessions and issued JWTs are not revoked. The original `/logout`
endpoint retains its local-only contract. Actual shared-session destruction
requires browser execution; a redirect-only smoke is not acceptance evidence.

View file

@ -162,14 +162,15 @@ func main() {
// Authorize handler (with enforcement middleware).
logins := oidc.NewLoginSessionStore()
authorizeHandler := &oidc.AuthorizeHandler{
ClientConfig: clients,
Auth: autheliaAdapter,
MFA: privacyIDEAAdapter,
Sessions: sessions,
Logins: logins,
Handoffs: oidc.NewHandoffStore(),
Issuer: issuer,
Emitter: emitter,
AccountPortalURL: cfg.AccountPortalURL,
ClientConfig: clients,
Auth: autheliaAdapter,
MFA: privacyIDEAAdapter,
Sessions: sessions,
Logins: logins,
Handoffs: oidc.NewHandoffStore(),
Issuer: issuer,
Emitter: emitter,
}
mux.Handle("/authorize", enforcement.Middleware(authorizeHandler))
mux.Handle("/authorize/callback", authorizeHandler)
@ -181,6 +182,13 @@ func main() {
SecureCookie: strings.HasPrefix(strings.ToLower(issuer), "https://"),
})
if cfg.AccountPortalURL != "" {
mux.Handle("/account/logout", &oidc.AccountLogoutHandler{
PortalURL: cfg.AccountPortalURL, UpstreamLogoutURL: cfg.BrowserLogoutURL,
Logins: logins, Issuer: issuer,
})
}
// Token handler (with enforcement middleware).
tokenHandler := &oidc.TokenHandler{
ClientConfig: clients,

View file

@ -0,0 +1,17 @@
package config
import (
"strings"
"testing"
)
func TestAccountRecoveryDestinationsAreOwnerConfiguredHTTPS(t *testing.T) {
for _, raw := range []string{"http://users.example", "https://user:password@users.example", "https://users.example/?next=evil", "https://users.example/#fragment", "//users.example"} {
cfg:=&Config{AccountPortalURL:raw,BrowserLogoutURL:"https://auth.example/logout"}
found:=false
for _, err:=range ValidateConfig(cfg) {if strings.HasPrefix(err,"accountPortalURL:") {found=true}}
if !found {t.Errorf("accepted unsafe account URL %q",raw)}
}
cfg:=&Config{AccountPortalURL:"https://users.example"}
found:=false
for _, err:=range ValidateConfig(cfg) {if strings.Contains(err,"configured together") {found=true}}
if !found {t.Fatal("accepted incomplete logout route")}
}

View file

@ -18,16 +18,18 @@ import (
// Config is the top-level server configuration.
type Config struct {
Issuer string `yaml:"issuer"`
Port int `yaml:"port"`
TokenLifetime string `yaml:"tokenLifetime"`
PrivateKeyPEM string `yaml:"privateKeyPem"`
LLDAP lldap.Config `yaml:"lldap"`
Authelia authelia.Config `yaml:"authelia"`
PrivacyIDEA privacyidea.Config `yaml:"privacyidea"`
Clients []ClientConfig `yaml:"clients"`
Environment string `yaml:"environment"`
TenantEngine TenantEngineConfig `yaml:"tenantEngine,omitempty"`
AccountPortalURL string `yaml:"accountPortalURL,omitempty"`
BrowserLogoutURL string `yaml:"browserLogoutURL,omitempty"`
Issuer string `yaml:"issuer"`
Port int `yaml:"port"`
TokenLifetime string `yaml:"tokenLifetime"`
PrivateKeyPEM string `yaml:"privateKeyPem"`
LLDAP lldap.Config `yaml:"lldap"`
Authelia authelia.Config `yaml:"authelia"`
PrivacyIDEA privacyidea.Config `yaml:"privacyidea"`
Clients []ClientConfig `yaml:"clients"`
Environment string `yaml:"environment"`
TenantEngine TenantEngineConfig `yaml:"tenantEngine,omitempty"`
}
// TenantEngineConfig configures the optional tenant_roles cache claim.
@ -85,6 +87,12 @@ func Load(path string) (*Config, error) {
return nil, fmt.Errorf("config: parse %q: %w", path, err)
}
if value := os.Getenv("KEYCAPE_ACCOUNT_PORTAL_URL"); value != "" {
cfg.AccountPortalURL = value
}
if value := os.Getenv("KEYCAPE_BROWSER_LOGOUT_URL"); value != "" {
cfg.BrowserLogoutURL = value
}
return &cfg, nil
}

View file

@ -12,6 +12,18 @@ import (
// Called at startup — the server must exit 1 if any errors are returned.
func ValidateConfig(cfg *Config) []string {
var errs []string
for name, raw := range map[string]string{"accountPortalURL": cfg.AccountPortalURL, "browserLogoutURL": cfg.BrowserLogoutURL} {
if raw == "" {
continue
}
u, err := url.Parse(raw)
if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
errs = append(errs, name+": must be an HTTPS URL without credentials, query or fragment")
}
}
if (cfg.AccountPortalURL == "") != (cfg.BrowserLogoutURL == "") {
errs = append(errs, "accountPortalURL and browserLogoutURL must be configured together")
}
// Issuer must be a valid URL with an http(s) scheme.
if cfg.Issuer == "" {

View file

@ -0,0 +1,99 @@
package oidc
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"html/template"
"net/http"
"net/url"
"strings"
)
func (h *AuthorizeHandler) authenticationFailure(w http.ResponseWriter, r *http.Request) {
h.browserFailure(w, r, http.StatusUnauthorized, "authentication failed")
}
func (h *AuthorizeHandler) browserFailure(w http.ResponseWriter, r *http.Request, status int, message string) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Referrer-Policy", "no-referrer")
if h.AccountPortalURL != "" {
// Never carry upstream code, state, error details or an unverified identity.
http.Redirect(w, r, strings.TrimRight(h.AccountPortalURL, "/")+"/access-recovery", http.StatusSeeOther)
return
}
http.Error(w, message, status)
}
// AccountLogoutHandler confirms browser-wide sign-out without accepting a return URL
// from the browser. Authelia owns the upstream cookie and destroys it on its origin.
type AccountLogoutHandler struct {
PortalURL string
UpstreamLogoutURL string
Issuer string
Logins *LoginSessionStore
}
const logoutCSRF = "__Host-keycape-logout"
var accountLogoutPage = template.Must(template.New("logout").Parse(`<!doctype html>
<html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Sign out of NetKingdom</title><main><h1>Sign out of NetKingdom?</h1>
<p>This ends your shared sign-in session in this browser so you can use another account.
Applications that already have their own sessions may remain signed in.</p>
<form method="post" action="/account/logout"><input type="hidden" name="csrf" value="{{.CSRF}}">
<button type="submit">Sign out of NetKingdom</button></form>
<p><a href="{{.Portal}}">Back to my account</a></p></main></html>`))
func (h *AccountLogoutHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Content-Security-Policy", "default-src 'none'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'")
w.Header().Set("X-Content-Type-Options", "nosniff")
switch r.Method {
case http.MethodGet:
var nonce [32]byte
if _, err := rand.Read(nonce[:]); err != nil {
http.Error(w, "sign-out unavailable", 503)
return
}
csrf := base64.RawURLEncoding.EncodeToString(nonce[:])
http.SetCookie(w, &http.Cookie{Name: logoutCSRF, Value: csrf, Path: "/", Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode, MaxAge: 600})
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = accountLogoutPage.Execute(w, struct{ CSRF, Portal string }{csrf, h.PortalURL})
case http.MethodPost:
r.Body = http.MaxBytesReader(w, r.Body, 4096)
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form", 400)
return
}
cookie, err := r.Cookie(logoutCSRF)
csrf := r.PostForm.Get("csrf")
origin, parseErr := url.Parse(h.Issuer)
expected := ""
if parseErr == nil {
expected = origin.Scheme + "://" + origin.Host
}
if err != nil || len(csrf) != 43 || subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(csrf)) != 1 || r.Header.Get("Origin") != expected {
http.Error(w, "sign-out confirmation expired; reload this page", http.StatusForbidden)
return
}
if session := h.Logins.fromRequest(r); session != nil {
h.Logins.Delete(session.ID)
}
clearLoginCookie(w, issuerIsHTTPS(h.Issuer))
http.SetCookie(w, &http.Cookie{Name: logoutCSRF, Value: "", Path: "/", Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode, MaxAge: -1})
target, err := url.Parse(h.UpstreamLogoutURL)
if err != nil {
http.Error(w, "sign-out unavailable", 503)
return
}
q := target.Query()
q.Set("rd", strings.TrimRight(h.PortalURL, "/")+"/logged-out")
target.RawQuery = q.Encode()
http.Redirect(w, r, target.String(), http.StatusSeeOther)
default:
w.Header().Set("Allow", "GET, POST")
http.Error(w, "method not allowed", 405)
}
}

View file

@ -0,0 +1,73 @@
package oidc
import (
"keycape/internal/domain"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
func TestRecoveryStripsCredentials(t *testing.T) {
h := &AuthorizeHandler{AccountPortalURL: "https://users.example"}
w := httptest.NewRecorder()
h.authenticationFailure(w, httptest.NewRequest("GET", "https://kc.example/authorize/callback?code=secret&state=secret&redirect=https://evil.example", nil))
if w.Code != 303 || w.Header().Get("Location") != "https://users.example/access-recovery" {
t.Fatal(w.Code, w.Header())
}
if w.Header().Get("Referrer-Policy") != "no-referrer" || strings.Contains(w.Body.String(), "secret") {
t.Fatal("callback leaked")
}
}
func TestSharedLogoutRequiresConfirmationAndFixedReturn(t *testing.T) {
h := &AccountLogoutHandler{PortalURL: "https://users.example", UpstreamLogoutURL: "https://auth.example/logout", Issuer: "https://kc.example", Logins: NewLoginSessionStore()}
session := h.Logins.Create("synthetic", domain.AssuranceAAL1)
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest("GET", "https://kc.example/account/logout", nil))
if _, ok := h.Logins.Get(session.ID); !ok {
t.Fatal("GET logged out")
}
if w.Code != 200 || w.Header().Get("Location") != "" {
t.Fatal("GET must only confirm")
}
c := w.Result().Cookies()[0]
if !c.Secure || !c.HttpOnly || c.Domain != "" {
t.Fatal("unsafe CSRF cookie")
}
for _, origin := range []string{"", "https://evil.example", "https://kc.example"} {
form := url.Values{"csrf": {c.Value}, "rd": {"https://evil.example"}}
r := httptest.NewRequest("POST", "https://kc.example/account/logout", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.Header.Set("Origin", origin)
r.AddCookie(c)
r.AddCookie(&http.Cookie{Name: loginCookieName, Value: session.ID})
w = httptest.NewRecorder()
h.ServeHTTP(w, r)
if origin != "https://kc.example" {
if w.Code != 403 {
t.Fatal("bad origin accepted")
}
continue
}
if _, ok := h.Logins.Get(session.ID); ok {
t.Fatal("local session survived")
}
if w.Code != 303 {
t.Fatal(w.Code)
}
dest, _ := url.Parse(w.Header().Get("Location"))
if dest.Host != "auth.example" || dest.Query().Get("rd") != "https://users.example/logged-out" {
t.Fatal("untrusted return")
}
}
r := httptest.NewRequest("POST", "https://kc.example/account/logout", strings.NewReader("csrf=wrong"))
r.Header.Set("Origin", "https://kc.example")
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(c)
w = httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusForbidden {
t.Fatal("bad csrf accepted")
}
}

View file

@ -66,14 +66,15 @@ func (p *pendingStateStore) Delete(state string) {
// AuthorizeHandler implements GET /authorize and GET /authorize/callback.
type AuthorizeHandler struct {
ClientConfig map[string]*domain.Client
Auth domain.AuthProvider
MFA domain.MFAProvider
Sessions *SessionStore
Logins *LoginSessionStore
Handoffs *HandoffStore
Issuer string
Emitter telemetry.Emitter
AccountPortalURL string
ClientConfig map[string]*domain.Client
Auth domain.AuthProvider
MFA domain.MFAProvider
Sessions *SessionStore
Logins *LoginSessionStore
Handoffs *HandoffStore
Issuer string
Emitter telemetry.Emitter
pending *pendingStateStore
once sync.Once
@ -256,7 +257,7 @@ func (h *AuthorizeHandler) serveAuthorize(w http.ResponseWriter, r *http.Request
PKCEChallengeMethod: codeChallengeMethod,
})
if err != nil {
http.Error(w, "upstream auth provider error", http.StatusBadGateway)
h.browserFailure(w, r, http.StatusBadGateway, "upstream auth provider error")
return
}
@ -274,7 +275,7 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
}
if r.Method != http.MethodGet {
w.Header().Set("Allow", "GET, POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
h.browserFailure(w, r, http.StatusMethodNotAllowed, "method not allowed")
return
}
@ -287,12 +288,12 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
// Recover pending state keyed by state param.
ps, ok := h.pending.Load(state)
if !ok {
http.Error(w, "unknown or expired state", http.StatusBadRequest)
h.browserFailure(w, r, http.StatusBadRequest, "unknown or expired state")
return
}
if time.Now().After(ps.ExpiresAt) {
h.pending.Delete(state)
http.Error(w, "authorization request expired", http.StatusBadRequest)
h.browserFailure(w, r, http.StatusBadRequest, "authorization request expired")
return
}
@ -315,7 +316,7 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
return
}
h.pending.Delete(state)
http.Error(w, "authentication failed", http.StatusUnauthorized)
h.authenticationFailure(w, r)
return
}
@ -330,12 +331,12 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
Result: "failure",
ErrorType: "mfa_check_error",
})
http.Error(w, "mfa check error", http.StatusInternalServerError)
h.browserFailure(w, r, http.StatusInternalServerError, "mfa check error")
return
}
if decision.RequireMFA {
if handed, herr := h.maybeEnrollmentHandoff(ctx, w, r, ps, result.Username); herr != nil {
http.Error(w, "enrollment check error", http.StatusInternalServerError)
h.browserFailure(w, r, http.StatusInternalServerError, "enrollment check error")
return
} else if handed {
return
@ -349,7 +350,7 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
if err := h.MFA.ValidateMFAToken(ctx, result.Username, mfaToken); err != nil {
if errors.Is(err, domain.ErrMFANotEnrolled) {
if handed, herr := h.maybeEnrollmentHandoff(ctx, w, r, ps, result.Username); herr != nil {
http.Error(w, "enrollment check error", http.StatusInternalServerError)
h.browserFailure(w, r, http.StatusInternalServerError, "enrollment check error")
return
} else if handed {
return
@ -357,7 +358,7 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
}
h.pending.Delete(state)
h.emitMFAFailure(ctx, ps.ClientID)
http.Error(w, "MFA validation failed", http.StatusUnauthorized)
h.browserFailure(w, r, http.StatusUnauthorized, "MFA validation failed")
return
}
h.pending.Delete(state)
@ -398,7 +399,7 @@ func (h *AuthorizeHandler) decideAssurance(ctx context.Context, ps *PendingState
func (h *AuthorizeHandler) serveMFASubmission(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form", http.StatusBadRequest)
h.browserFailure(w, r, http.StatusBadRequest, "invalid form")
return
}
@ -407,17 +408,17 @@ func (h *AuthorizeHandler) serveMFASubmission(w http.ResponseWriter, r *http.Req
ps, ok := h.pending.Load(state)
if !ok {
http.Error(w, "unknown or expired state", http.StatusBadRequest)
h.browserFailure(w, r, http.StatusBadRequest, "unknown or expired state")
return
}
if time.Now().After(ps.ExpiresAt) {
h.pending.Delete(state)
http.Error(w, "authorization request expired", http.StatusBadRequest)
h.browserFailure(w, r, http.StatusBadRequest, "authorization request expired")
return
}
if ps.AuthenticatedUser == "" {
h.pending.Delete(state)
http.Error(w, "mfa challenge not active", http.StatusBadRequest)
h.browserFailure(w, r, http.StatusBadRequest, "mfa challenge not active")
return
}
if strings.TrimSpace(mfaToken) == "" {
@ -428,7 +429,7 @@ func (h *AuthorizeHandler) serveMFASubmission(w http.ResponseWriter, r *http.Req
if err := h.MFA.ValidateMFAToken(ctx, ps.AuthenticatedUser, mfaToken); err != nil {
h.pending.Delete(state)
h.emitMFAFailure(ctx, ps.ClientID)
http.Error(w, "MFA validation failed", http.StatusUnauthorized)
h.browserFailure(w, r, http.StatusUnauthorized, "MFA validation failed")
return
}
@ -485,7 +486,7 @@ func (h *AuthorizeHandler) completeAuthorization(w http.ResponseWriter, r *http.
// Redirect to client with code and state.
redirectTo, err := url.Parse(ps.RedirectURI)
if err != nil {
http.Error(w, "invalid redirect_uri", http.StatusInternalServerError)
h.browserFailure(w, r, http.StatusInternalServerError, "invalid redirect_uri")
return
}
q := redirectTo.Query()
@ -515,7 +516,7 @@ func (h *AuthorizeHandler) startHandoff(w http.ResponseWriter, r *http.Request,
}
token, err := h.Handoffs.Issue(kind, ps)
if err != nil {
http.Error(w, "handoff error", http.StatusInternalServerError)
h.browserFailure(w, r, http.StatusInternalServerError, "handoff error")
return
}
loc, err := appendHandoff(dest, token)
@ -530,18 +531,18 @@ func (h *AuthorizeHandler) startHandoff(w http.ResponseWriter, r *http.Request,
func (h *AuthorizeHandler) serveRegisterFromPending(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", "GET")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
h.browserFailure(w, r, http.StatusMethodNotAllowed, "method not allowed")
return
}
state := r.URL.Query().Get("state")
ps, ok := h.pending.Load(state)
if !ok {
http.Error(w, "unknown or expired state", http.StatusBadRequest)
h.browserFailure(w, r, http.StatusBadRequest, "unknown or expired state")
return
}
if time.Now().After(ps.ExpiresAt) {
h.pending.Delete(state)
http.Error(w, "authorization request expired", http.StatusBadRequest)
h.browserFailure(w, r, http.StatusBadRequest, "authorization request expired")
return
}
h.startHandoff(w, r, ps, HandoffRegister)
@ -550,20 +551,20 @@ func (h *AuthorizeHandler) serveRegisterFromPending(w http.ResponseWriter, r *ht
func (h *AuthorizeHandler) serveHandoffReturn(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", "GET")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
h.browserFailure(w, r, http.StatusMethodNotAllowed, "method not allowed")
return
}
token := r.URL.Query().Get("kc_handoff")
env, err := h.Handoffs.Consume(token)
switch {
case errors.Is(err, errHandoffExpired):
http.Error(w, "handoff expired", http.StatusBadRequest)
h.browserFailure(w, r, http.StatusBadRequest, "handoff expired")
return
case errors.Is(err, errHandoffReplay):
http.Error(w, "handoff already used", http.StatusBadRequest)
h.browserFailure(w, r, http.StatusBadRequest, "handoff already used")
return
case err != nil:
http.Error(w, "invalid handoff", http.StatusBadRequest)
h.browserFailure(w, r, http.StatusBadRequest, "invalid handoff")
return
}
client, ok := h.ClientConfig[env.ClientID]

View file

@ -0,0 +1,46 @@
---
id: KEY-WP-0034
type: workplan
title: "Browser authentication recovery and confirmed shared sign-out"
domain: infotech
repo: key-cape
status: active
owner: codex
topic_slug: key-cape
created: "2026-09-12"
updated: "2026-09-12"
---
The operator reports a dead-end authentication error after using an account
outside the product tenant. Recent issuer telemetry indicates token exchange
failure; tenant rejection and provider failure must not be conflated.
## Implement and validate recovery
```task
id: KEY-WP-0034-T01
status: done
priority: high
```
Route failed browser login to the public account recovery surface without codes,
state or unverified identity. Show verified portal identity, tenant memberships,
and recorded workload memberships; preserve operator/customer separation.
Provide CSRF-protected portal logout and confirmed shared provider sign-out with
fixed owner-configured return locations. No automatic reauthentication loops,
MFA downgrade, global JWT revocation claim or inferred workload entitlements.
## Publish and verify the recovery flow
```task
id: KEY-WP-0034-T02
status: progress
priority: high
```
Publish immutable images, update canonical runtime pins, verify anonymous
recovery and sign-out confirmation live, and record actual account switching
only after browser evidence. Existing application sessions may outlive provider
logout. Related: USER-WP-0025-T03 and VERGABE-WP-0019-T06.
Source verification: Full Go suite passed; final OIDC/config tests passed after recovery expansion. Immutable publication and live checks are in progress.