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
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:
parent
89694ad6df
commit
074c2ce498
9 changed files with 334 additions and 49 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
17
src/internal/config/account_test.go
Normal file
17
src/internal/config/account_test.go
Normal 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")}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 == "" {
|
||||
|
|
|
|||
99
src/internal/server/oidc/account.go
Normal file
99
src/internal/server/oidc/account.go
Normal 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)
|
||||
}
|
||||
}
|
||||
73
src/internal/server/oidc/account_test.go
Normal file
73
src/internal/server/oidc/account_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
46
workplans/KEY-WP-0034-account-recovery.md
Normal file
46
workplans/KEY-WP-0034-account-recovery.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue