diff --git a/docs/operations.md b/docs/operations.md index c2aaf82..36be767 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -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. diff --git a/src/cmd/keycape/main.go b/src/cmd/keycape/main.go index e1485fd..1e51b2a 100644 --- a/src/cmd/keycape/main.go +++ b/src/cmd/keycape/main.go @@ -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, diff --git a/src/internal/config/account_test.go b/src/internal/config/account_test.go new file mode 100644 index 0000000..f28ad98 --- /dev/null +++ b/src/internal/config/account_test.go @@ -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")} +} diff --git a/src/internal/config/config.go b/src/internal/config/config.go index 6bb6827..9e9d4ad 100644 --- a/src/internal/config/config.go +++ b/src/internal/config/config.go @@ -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 } diff --git a/src/internal/config/validate.go b/src/internal/config/validate.go index a787173..2ba7405 100644 --- a/src/internal/config/validate.go +++ b/src/internal/config/validate.go @@ -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 == "" { diff --git a/src/internal/server/oidc/account.go b/src/internal/server/oidc/account.go new file mode 100644 index 0000000..7fb30c9 --- /dev/null +++ b/src/internal/server/oidc/account.go @@ -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(` + +Sign out of NetKingdom

Sign out of NetKingdom?

+

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.

+
+
+

Back to my account

`)) + +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) + } +} diff --git a/src/internal/server/oidc/account_test.go b/src/internal/server/oidc/account_test.go new file mode 100644 index 0000000..cde654e --- /dev/null +++ b/src/internal/server/oidc/account_test.go @@ -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") + } +} diff --git a/src/internal/server/oidc/authorize.go b/src/internal/server/oidc/authorize.go index cea8cc1..c94bf0a 100644 --- a/src/internal/server/oidc/authorize.go +++ b/src/internal/server/oidc/authorize.go @@ -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] diff --git a/workplans/KEY-WP-0034-account-recovery.md b/workplans/KEY-WP-0034-account-recovery.md new file mode 100644 index 0000000..2cd7f5c --- /dev/null +++ b/workplans/KEY-WP-0034-account-recovery.md @@ -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.