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 No resource-efficiency or throughput bounds are asserted here. Nothing in this
repository benchmarks KeyCape, so any figure would be invention. Measure it in repository benchmarks KeyCape, so any figure would be invention. Measure it in
your own deployment before sizing against it. 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). // Authorize handler (with enforcement middleware).
logins := oidc.NewLoginSessionStore() logins := oidc.NewLoginSessionStore()
authorizeHandler := &oidc.AuthorizeHandler{ authorizeHandler := &oidc.AuthorizeHandler{
ClientConfig: clients, AccountPortalURL: cfg.AccountPortalURL,
Auth: autheliaAdapter, ClientConfig: clients,
MFA: privacyIDEAAdapter, Auth: autheliaAdapter,
Sessions: sessions, MFA: privacyIDEAAdapter,
Logins: logins, Sessions: sessions,
Handoffs: oidc.NewHandoffStore(), Logins: logins,
Issuer: issuer, Handoffs: oidc.NewHandoffStore(),
Emitter: emitter, Issuer: issuer,
Emitter: emitter,
} }
mux.Handle("/authorize", enforcement.Middleware(authorizeHandler)) mux.Handle("/authorize", enforcement.Middleware(authorizeHandler))
mux.Handle("/authorize/callback", authorizeHandler) mux.Handle("/authorize/callback", authorizeHandler)
@ -181,6 +182,13 @@ func main() {
SecureCookie: strings.HasPrefix(strings.ToLower(issuer), "https://"), 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). // Token handler (with enforcement middleware).
tokenHandler := &oidc.TokenHandler{ tokenHandler := &oidc.TokenHandler{
ClientConfig: clients, 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. // Config is the top-level server configuration.
type Config struct { type Config struct {
Issuer string `yaml:"issuer"` AccountPortalURL string `yaml:"accountPortalURL,omitempty"`
Port int `yaml:"port"` BrowserLogoutURL string `yaml:"browserLogoutURL,omitempty"`
TokenLifetime string `yaml:"tokenLifetime"` Issuer string `yaml:"issuer"`
PrivateKeyPEM string `yaml:"privateKeyPem"` Port int `yaml:"port"`
LLDAP lldap.Config `yaml:"lldap"` TokenLifetime string `yaml:"tokenLifetime"`
Authelia authelia.Config `yaml:"authelia"` PrivateKeyPEM string `yaml:"privateKeyPem"`
PrivacyIDEA privacyidea.Config `yaml:"privacyidea"` LLDAP lldap.Config `yaml:"lldap"`
Clients []ClientConfig `yaml:"clients"` Authelia authelia.Config `yaml:"authelia"`
Environment string `yaml:"environment"` PrivacyIDEA privacyidea.Config `yaml:"privacyidea"`
TenantEngine TenantEngineConfig `yaml:"tenantEngine,omitempty"` Clients []ClientConfig `yaml:"clients"`
Environment string `yaml:"environment"`
TenantEngine TenantEngineConfig `yaml:"tenantEngine,omitempty"`
} }
// TenantEngineConfig configures the optional tenant_roles cache claim. // 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) 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 return &cfg, nil
} }

View file

@ -12,6 +12,18 @@ import (
// Called at startup — the server must exit 1 if any errors are returned. // Called at startup — the server must exit 1 if any errors are returned.
func ValidateConfig(cfg *Config) []string { func ValidateConfig(cfg *Config) []string {
var errs []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. // Issuer must be a valid URL with an http(s) scheme.
if cfg.Issuer == "" { 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. // AuthorizeHandler implements GET /authorize and GET /authorize/callback.
type AuthorizeHandler struct { type AuthorizeHandler struct {
ClientConfig map[string]*domain.Client AccountPortalURL string
Auth domain.AuthProvider ClientConfig map[string]*domain.Client
MFA domain.MFAProvider Auth domain.AuthProvider
Sessions *SessionStore MFA domain.MFAProvider
Logins *LoginSessionStore Sessions *SessionStore
Handoffs *HandoffStore Logins *LoginSessionStore
Issuer string Handoffs *HandoffStore
Emitter telemetry.Emitter Issuer string
Emitter telemetry.Emitter
pending *pendingStateStore pending *pendingStateStore
once sync.Once once sync.Once
@ -256,7 +257,7 @@ func (h *AuthorizeHandler) serveAuthorize(w http.ResponseWriter, r *http.Request
PKCEChallengeMethod: codeChallengeMethod, PKCEChallengeMethod: codeChallengeMethod,
}) })
if err != nil { if err != nil {
http.Error(w, "upstream auth provider error", http.StatusBadGateway) h.browserFailure(w, r, http.StatusBadGateway, "upstream auth provider error")
return return
} }
@ -274,7 +275,7 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
} }
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
w.Header().Set("Allow", "GET, POST") w.Header().Set("Allow", "GET, POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed) h.browserFailure(w, r, http.StatusMethodNotAllowed, "method not allowed")
return return
} }
@ -287,12 +288,12 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
// Recover pending state keyed by state param. // Recover pending state keyed by state param.
ps, ok := h.pending.Load(state) ps, ok := h.pending.Load(state)
if !ok { if !ok {
http.Error(w, "unknown or expired state", http.StatusBadRequest) h.browserFailure(w, r, http.StatusBadRequest, "unknown or expired state")
return return
} }
if time.Now().After(ps.ExpiresAt) { if time.Now().After(ps.ExpiresAt) {
h.pending.Delete(state) h.pending.Delete(state)
http.Error(w, "authorization request expired", http.StatusBadRequest) h.browserFailure(w, r, http.StatusBadRequest, "authorization request expired")
return return
} }
@ -315,7 +316,7 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
return return
} }
h.pending.Delete(state) h.pending.Delete(state)
http.Error(w, "authentication failed", http.StatusUnauthorized) h.authenticationFailure(w, r)
return return
} }
@ -330,12 +331,12 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
Result: "failure", Result: "failure",
ErrorType: "mfa_check_error", ErrorType: "mfa_check_error",
}) })
http.Error(w, "mfa check error", http.StatusInternalServerError) h.browserFailure(w, r, http.StatusInternalServerError, "mfa check error")
return return
} }
if decision.RequireMFA { if decision.RequireMFA {
if handed, herr := h.maybeEnrollmentHandoff(ctx, w, r, ps, result.Username); herr != nil { 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 return
} else if handed { } else if handed {
return 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 err := h.MFA.ValidateMFAToken(ctx, result.Username, mfaToken); err != nil {
if errors.Is(err, domain.ErrMFANotEnrolled) { if errors.Is(err, domain.ErrMFANotEnrolled) {
if handed, herr := h.maybeEnrollmentHandoff(ctx, w, r, ps, result.Username); herr != nil { 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 return
} else if handed { } else if handed {
return return
@ -357,7 +358,7 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
} }
h.pending.Delete(state) h.pending.Delete(state)
h.emitMFAFailure(ctx, ps.ClientID) h.emitMFAFailure(ctx, ps.ClientID)
http.Error(w, "MFA validation failed", http.StatusUnauthorized) h.browserFailure(w, r, http.StatusUnauthorized, "MFA validation failed")
return return
} }
h.pending.Delete(state) 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) { func (h *AuthorizeHandler) serveMFASubmission(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form", http.StatusBadRequest) h.browserFailure(w, r, http.StatusBadRequest, "invalid form")
return return
} }
@ -407,17 +408,17 @@ func (h *AuthorizeHandler) serveMFASubmission(w http.ResponseWriter, r *http.Req
ps, ok := h.pending.Load(state) ps, ok := h.pending.Load(state)
if !ok { if !ok {
http.Error(w, "unknown or expired state", http.StatusBadRequest) h.browserFailure(w, r, http.StatusBadRequest, "unknown or expired state")
return return
} }
if time.Now().After(ps.ExpiresAt) { if time.Now().After(ps.ExpiresAt) {
h.pending.Delete(state) h.pending.Delete(state)
http.Error(w, "authorization request expired", http.StatusBadRequest) h.browserFailure(w, r, http.StatusBadRequest, "authorization request expired")
return return
} }
if ps.AuthenticatedUser == "" { if ps.AuthenticatedUser == "" {
h.pending.Delete(state) h.pending.Delete(state)
http.Error(w, "mfa challenge not active", http.StatusBadRequest) h.browserFailure(w, r, http.StatusBadRequest, "mfa challenge not active")
return return
} }
if strings.TrimSpace(mfaToken) == "" { 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 { if err := h.MFA.ValidateMFAToken(ctx, ps.AuthenticatedUser, mfaToken); err != nil {
h.pending.Delete(state) h.pending.Delete(state)
h.emitMFAFailure(ctx, ps.ClientID) h.emitMFAFailure(ctx, ps.ClientID)
http.Error(w, "MFA validation failed", http.StatusUnauthorized) h.browserFailure(w, r, http.StatusUnauthorized, "MFA validation failed")
return return
} }
@ -485,7 +486,7 @@ func (h *AuthorizeHandler) completeAuthorization(w http.ResponseWriter, r *http.
// Redirect to client with code and state. // Redirect to client with code and state.
redirectTo, err := url.Parse(ps.RedirectURI) redirectTo, err := url.Parse(ps.RedirectURI)
if err != nil { if err != nil {
http.Error(w, "invalid redirect_uri", http.StatusInternalServerError) h.browserFailure(w, r, http.StatusInternalServerError, "invalid redirect_uri")
return return
} }
q := redirectTo.Query() q := redirectTo.Query()
@ -515,7 +516,7 @@ func (h *AuthorizeHandler) startHandoff(w http.ResponseWriter, r *http.Request,
} }
token, err := h.Handoffs.Issue(kind, ps) token, err := h.Handoffs.Issue(kind, ps)
if err != nil { if err != nil {
http.Error(w, "handoff error", http.StatusInternalServerError) h.browserFailure(w, r, http.StatusInternalServerError, "handoff error")
return return
} }
loc, err := appendHandoff(dest, token) 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) { func (h *AuthorizeHandler) serveRegisterFromPending(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
w.Header().Set("Allow", "GET") w.Header().Set("Allow", "GET")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed) h.browserFailure(w, r, http.StatusMethodNotAllowed, "method not allowed")
return return
} }
state := r.URL.Query().Get("state") state := r.URL.Query().Get("state")
ps, ok := h.pending.Load(state) ps, ok := h.pending.Load(state)
if !ok { if !ok {
http.Error(w, "unknown or expired state", http.StatusBadRequest) h.browserFailure(w, r, http.StatusBadRequest, "unknown or expired state")
return return
} }
if time.Now().After(ps.ExpiresAt) { if time.Now().After(ps.ExpiresAt) {
h.pending.Delete(state) h.pending.Delete(state)
http.Error(w, "authorization request expired", http.StatusBadRequest) h.browserFailure(w, r, http.StatusBadRequest, "authorization request expired")
return return
} }
h.startHandoff(w, r, ps, HandoffRegister) 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) { func (h *AuthorizeHandler) serveHandoffReturn(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
w.Header().Set("Allow", "GET") w.Header().Set("Allow", "GET")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed) h.browserFailure(w, r, http.StatusMethodNotAllowed, "method not allowed")
return return
} }
token := r.URL.Query().Get("kc_handoff") token := r.URL.Query().Get("kc_handoff")
env, err := h.Handoffs.Consume(token) env, err := h.Handoffs.Consume(token)
switch { switch {
case errors.Is(err, errHandoffExpired): case errors.Is(err, errHandoffExpired):
http.Error(w, "handoff expired", http.StatusBadRequest) h.browserFailure(w, r, http.StatusBadRequest, "handoff expired")
return return
case errors.Is(err, errHandoffReplay): case errors.Is(err, errHandoffReplay):
http.Error(w, "handoff already used", http.StatusBadRequest) h.browserFailure(w, r, http.StatusBadRequest, "handoff already used")
return return
case err != nil: case err != nil:
http.Error(w, "invalid handoff", http.StatusBadRequest) h.browserFailure(w, r, http.StatusBadRequest, "invalid handoff")
return return
} }
client, ok := h.ClientConfig[env.ClientID] 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.