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 NetKingdomSign 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.