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

@ -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]