Finish KEY-WP-0008: registration handoff and client MFA isolation
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 34s

Add signed registration/enrollment handoffs, per-request assurance
policy with login-session isolation, and /logout. coulomb-social
stays AAL1 unless acr_values or another client raises the bar.
This commit is contained in:
tegwick 2026-08-16 01:05:27 +02:00
parent fff9e39478
commit b6af6c5268
22 changed files with 1636 additions and 42 deletions

View file

@ -2,9 +2,11 @@ package oidc
import (
"context"
"errors"
"html/template"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
@ -28,6 +30,9 @@ type PendingState struct {
ExpiresAt time.Time
AuthenticatedUser string
ACRValues []string
TenantHint string
MaxAge *time.Duration
PromptLogin bool
}
// pendingStateStore is a thread-safe map of state → PendingState.
@ -65,6 +70,9 @@ type AuthorizeHandler struct {
Auth domain.AuthProvider
MFA domain.MFAProvider
Sessions *SessionStore
Logins *LoginSessionStore
Handoffs *HandoffStore
Issuer string
Emitter telemetry.Emitter
pending *pendingStateStore
@ -82,17 +90,28 @@ func (h *AuthorizeHandler) init() {
if h.pending == nil {
h.pending = newPendingStateStore()
}
if h.Logins == nil {
h.Logins = NewLoginSessionStore()
}
if h.Handoffs == nil {
h.Handoffs = NewHandoffStore()
}
})
}
// ServeHTTP dispatches to the authorize or callback handler based on path.
func (h *AuthorizeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.init()
if strings.HasSuffix(r.URL.Path, "/callback") {
switch {
case strings.HasSuffix(r.URL.Path, "/callback"):
h.ServeHTTPCallback(w, r)
return
case strings.HasSuffix(r.URL.Path, "/return"):
h.serveHandoffReturn(w, r)
case strings.HasSuffix(r.URL.Path, "/register"):
h.serveRegisterFromPending(w, r)
default:
h.serveAuthorize(w, r)
}
h.serveAuthorize(w, r)
}
// serveAuthorize handles the initial GET /authorize request.
@ -109,6 +128,14 @@ func (h *AuthorizeHandler) serveAuthorize(w http.ResponseWriter, r *http.Request
codeChallenge := q.Get("code_challenge")
codeChallengeMethod := q.Get("code_challenge_method")
acrValues := strings.Fields(q.Get("acr_values"))
tenantHint := firstNonEmpty(q.Get("tenant_hint"), q.Get("tenant"))
promptCreate, promptLogin := parsePrompt(q.Get("prompt"))
maxAge, maxAgeErr := parseMaxAge(q.Get("max_age"))
if maxAgeErr != nil {
profileerrors.InvalidProfileUsage("max_age must be a non-negative integer", "max_age").
Write(w, http.StatusBadRequest)
return
}
// Emit auth_start telemetry immediately.
h.Emitter.Emit(ctx, telemetry.Event{
@ -189,7 +216,7 @@ func (h *AuthorizeHandler) serveAuthorize(w http.ResponseWriter, r *http.Request
}
// Store pending state so the callback can reconstruct the session.
h.pending.Store(state, &PendingState{
ps := &PendingState{
ClientID: clientID,
RedirectURI: redirectURI,
PKCEChallenge: codeChallenge,
@ -198,8 +225,17 @@ func (h *AuthorizeHandler) serveAuthorize(w http.ResponseWriter, r *http.Request
Nonce: nonce,
Scopes: strings.Fields(scope),
ACRValues: acrValues,
TenantHint: tenantHint,
MaxAge: maxAge,
PromptLogin: promptLogin,
ExpiresAt: time.Now().Add(10 * time.Minute),
})
}
h.pending.Store(state, ps)
if promptCreate {
h.startHandoff(w, r, ps, HandoffRegister)
return
}
// Delegate to Auth provider.
authURL, err := h.Auth.AuthorizeURL(ctx, domain.AuthRequest{
@ -256,7 +292,7 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
Code: code,
State: state,
})
if err != nil {
if err != nil || result == nil || result.Username == "" {
h.Emitter.Emit(ctx, telemetry.Event{
Timestamp: time.Now(),
EventType: telemetry.EventAuthFailure,
@ -265,25 +301,16 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
Result: "failure",
ErrorType: "auth_failed",
})
http.Error(w, "authentication failed", http.StatusUnauthorized)
return
}
if result == nil || result.Username == "" {
if h.clientEligible(ps.ClientID, HandoffRegister) {
h.renderUnknownUserSignup(w, ps)
return
}
h.pending.Delete(state)
h.Emitter.Emit(ctx, telemetry.Event{
Timestamp: time.Now(),
EventType: telemetry.EventAuthFailure,
ClientID: ps.ClientID,
Endpoint: "/authorize/callback",
Result: "failure",
ErrorType: "auth_failed",
})
http.Error(w, "authentication failed", http.StatusUnauthorized)
return
}
// Check MFA requirement.
mfaRequired, _, err := h.mfaRequirement(ps, result.Username)
decision, err := h.decideAssurance(ctx, ps, result.Username, h.Logins.fromRequest(r))
if err != nil {
h.Emitter.Emit(ctx, telemetry.Event{
Timestamp: time.Now(),
@ -296,7 +323,13 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
http.Error(w, "mfa check error", http.StatusInternalServerError)
return
}
if mfaRequired {
if decision.RequireMFA {
if handed, herr := h.maybeEnrollmentHandoff(ctx, w, r, ps, result.Username); herr != nil {
http.Error(w, "enrollment check error", http.StatusInternalServerError)
return
} else if handed {
return
}
if mfaToken == "" {
ps.AuthenticatedUser = result.Username
h.pending.Store(state, ps)
@ -304,29 +337,52 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
return
}
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)
return
} else if handed {
return
}
}
h.pending.Delete(state)
h.emitMFAFailure(ctx, ps.ClientID)
http.Error(w, "MFA validation failed", http.StatusUnauthorized)
return
}
h.pending.Delete(state)
h.completeAuthorization(w, r, ps, result.Username, true)
return
}
h.pending.Delete(state)
h.completeAuthorization(w, r, ps, result.Username, mfaRequired)
h.completeAuthorization(w, r, ps, result.Username, decision.MFAVerified)
}
func (h *AuthorizeHandler) mfaRequirement(ps *PendingState, username string) (bool, bool, error) {
for _, acr := range ps.ACRValues {
switch strings.ToLower(acr) {
case "aal2", "mfa", "urn:netkingdom:aal2":
return true, false, nil
func (h *AuthorizeHandler) decideAssurance(ctx context.Context, ps *PendingState, username string, login *LoginSession) (domain.AssuranceDecision, error) {
client := h.ClientConfig[ps.ClientID]
providerRequired := false
if !domain.ACRRequiresAAL2(ps.ACRValues) && (client == nil || client.MFARequired == nil) {
var err error
providerRequired, err = h.MFA.CheckMFARequired(ctx, username)
if err != nil {
return domain.AssuranceDecision{}, err
}
}
if client, ok := h.ClientConfig[ps.ClientID]; ok && client.MFARequired != nil {
return *client.MFARequired, false, nil
in := domain.AssuranceInput{
Client: client,
ACRValues: ps.ACRValues,
ProviderRequired: providerRequired,
RequestUser: username,
PromptLogin: ps.PromptLogin,
MaxAge: ps.MaxAge,
}
required, err := h.MFA.CheckMFARequired(context.Background(), username)
return required, true, err
if login != nil {
in.SessionLevel = login.Level
in.SessionUser = login.Username
in.SessionIssuedAt = login.IssuedAt
}
return domain.DecideAssurance(in), nil
}
func (h *AuthorizeHandler) serveMFASubmission(w http.ResponseWriter, r *http.Request) {
@ -374,6 +430,14 @@ func (h *AuthorizeHandler) serveMFASubmission(w http.ResponseWriter, r *http.Req
}
func (h *AuthorizeHandler) completeAuthorization(w http.ResponseWriter, r *http.Request, ps *PendingState, username string, mfaVerified bool) {
level := domain.AssuranceAAL1
if mfaVerified {
level = domain.AssuranceAAL2
}
if login := h.Logins.Create(username, level); login != nil {
writeLoginCookie(w, login, issuerIsHTTPS(h.Issuer))
}
// Generate authorization code and store PKCE session.
sess := &PKCESession{
ClientID: ps.ClientID,
@ -411,6 +475,157 @@ func (h *AuthorizeHandler) completeAuthorization(w http.ResponseWriter, r *http.
http.Redirect(w, r, redirectTo.String(), http.StatusFound)
}
func (h *AuthorizeHandler) startHandoff(w http.ResponseWriter, r *http.Request, ps *PendingState, kind HandoffKind) {
client, ok := h.ClientConfig[ps.ClientID]
if !ok {
profileerrors.InvalidProfileUsage("unknown client_id", "client_id").
Write(w, http.StatusBadRequest)
return
}
dest := client.RegistrationURL
if kind == HandoffEnroll {
dest = client.EnrollmentURL
}
if dest == "" {
profileerrors.RejectedForSafety(
"client is not eligible for this handoff",
string(kind),
).Write(w, http.StatusBadRequest)
return
}
token, err := h.Handoffs.Issue(kind, ps)
if err != nil {
http.Error(w, "handoff error", http.StatusInternalServerError)
return
}
loc, err := appendHandoff(dest, token)
if err != nil {
profileerrors.RejectedForSafety("handoff destination is not a valid URL", string(kind)).
Write(w, http.StatusBadRequest)
return
}
http.Redirect(w, r, loc, http.StatusFound)
}
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)
return
}
state := r.URL.Query().Get("state")
ps, ok := h.pending.Load(state)
if !ok {
http.Error(w, "unknown or expired state", http.StatusBadRequest)
return
}
if time.Now().After(ps.ExpiresAt) {
h.pending.Delete(state)
http.Error(w, "authorization request expired", http.StatusBadRequest)
return
}
h.startHandoff(w, r, ps, HandoffRegister)
}
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)
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)
return
case errors.Is(err, errHandoffReplay):
http.Error(w, "handoff already used", http.StatusBadRequest)
return
case err != nil:
http.Error(w, "invalid handoff", http.StatusBadRequest)
return
}
client, ok := h.ClientConfig[env.ClientID]
if !ok {
profileerrors.InvalidProfileUsage("unknown client_id", "client_id").
Write(w, http.StatusBadRequest)
return
}
if !uriRegistered(client.RedirectURIs, env.RedirectURI) {
profileerrors.RejectedForSafety(
"handoff redirect_uri does not match the registered client",
"redirect_uri",
).Write(w, http.StatusBadRequest)
return
}
restart := url.Values{}
restart.Set("client_id", env.ClientID)
restart.Set("redirect_uri", env.RedirectURI)
restart.Set("response_type", "code")
restart.Set("scope", strings.Join(env.Scopes, " "))
restart.Set("state", env.State)
restart.Set("code_challenge", env.PKCEChallenge)
restart.Set("code_challenge_method", env.PKCEChallengeMethod)
if env.Nonce != "" {
restart.Set("nonce", env.Nonce)
}
if env.TenantHint != "" {
restart.Set("tenant_hint", env.TenantHint)
}
http.Redirect(w, r, "/authorize?"+restart.Encode(), http.StatusFound)
}
func (h *AuthorizeHandler) maybeEnrollmentHandoff(ctx context.Context, w http.ResponseWriter, r *http.Request, ps *PendingState, username string) (bool, error) {
if !h.clientEligible(ps.ClientID, HandoffEnroll) {
return false, nil
}
enrolled, err := h.MFA.HasEnrolledFactor(ctx, username)
if err != nil {
return false, err
}
if enrolled {
return false, nil
}
ps.AuthenticatedUser = username
h.pending.Store(ps.State, ps)
h.startHandoff(w, r, ps, HandoffEnroll)
return true, nil
}
func (h *AuthorizeHandler) clientEligible(clientID string, kind HandoffKind) bool {
client, ok := h.ClientConfig[clientID]
if !ok {
return false
}
switch kind {
case HandoffRegister:
return client.RegistrationURL != ""
case HandoffEnroll:
return client.EnrollmentURL != ""
default:
return false
}
}
func (h *AuthorizeHandler) renderUnknownUserSignup(w http.ResponseWriter, ps *PendingState) {
clientName := ps.ClientID
if client, ok := h.ClientConfig[ps.ClientID]; ok && client.DisplayName != "" {
clientName = client.DisplayName
}
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusUnauthorized)
_ = unknownUserTemplate.Execute(w, struct {
State string
ClientName string
}{
State: ps.State,
ClientName: clientName,
})
}
func (h *AuthorizeHandler) emitMFAFailure(ctx context.Context, clientID string) {
h.Emitter.Emit(ctx, telemetry.Event{
Timestamp: time.Now(),
@ -487,6 +702,63 @@ var mfaChallengeTemplate = template.Must(template.New("mfa-challenge").Parse(`<!
</body>
</html>`))
var unknownUserTemplate = template.Must(template.New("unknown-user").Parse(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>KeyCape sign-in</title>
<style>
:root { color-scheme: light; font-family: Inter, ui-sans-serif, system-ui, sans-serif; }
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #f6f7f9; color: #17202a; }
main { width: min(420px, calc(100vw - 32px)); background: #fff; border: 1px solid #dfe4ea; border-radius: 8px; padding: 28px; }
h1 { margin: 0 0 8px; font-size: 22px; }
p { margin: 0 0 16px; color: #52606d; line-height: 1.45; }
a { display: inline-block; height: 44px; line-height: 44px; padding: 0 16px; border-radius: 6px; background: #17324d; color: #fff; text-decoration: none; font-weight: 650; }
</style>
</head>
<body>
<main>
<h1>Account not found</h1>
<p>No KeyCape identity is available for this {{.ClientName}} sign-in. Create an account to continue. This does not issue a token.</p>
<a href="/authorize/register?state={{.State}}">Create account</a>
</main>
</body>
</html>`))
func parsePrompt(raw string) (create, login bool) {
for _, part := range strings.Fields(raw) {
switch strings.ToLower(part) {
case "create":
create = true
case "login":
login = true
}
}
return create, login
}
func parseMaxAge(raw string) (*time.Duration, error) {
if strings.TrimSpace(raw) == "" {
return nil, nil
}
secs, err := strconv.Atoi(raw)
if err != nil || secs < 0 {
return nil, errors.New("invalid max_age")
}
d := time.Duration(secs) * time.Second
return &d, nil
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
func uriRegistered(registered []string, target string) bool {
for _, u := range registered {
if u == target {

View file

@ -44,6 +44,8 @@ func (m *mockAuthProvider) HandleCallback(_ context.Context, _ domain.CallbackPa
type mockMFAProvider struct {
required bool
requiredErr error
enrolled bool
enrolledErr error
validateErr error
validateCalls int
@ -55,6 +57,10 @@ func (m *mockMFAProvider) CheckMFARequired(_ context.Context, _ string) (bool, e
return m.required, m.requiredErr
}
func (m *mockMFAProvider) HasEnrolledFactor(_ context.Context, _ string) (bool, error) {
return m.enrolled, m.enrolledErr
}
func (m *mockMFAProvider) ValidateMFAToken(_ context.Context, user, token string) error {
m.validateCalls++
m.validatedUser = user

View file

@ -16,6 +16,7 @@ type DiscoveryConfig struct {
TokenEndpoint string
JWKSUri string
UserinfoEndpoint string // optional, empty = not advertised
EndSessionEndpoint string // optional, empty = not advertised
}
// discoveryDocument is the JSON shape of /.well-known/openid-configuration.
@ -27,6 +28,7 @@ type discoveryDocument struct {
TokenEndpoint string `json:"token_endpoint"`
JWKSUri string `json:"jwks_uri"`
UserinfoEndpoint string `json:"userinfo_endpoint,omitempty"`
EndSessionEndpoint string `json:"end_session_endpoint,omitempty"`
ResponseTypesSupported []string `json:"response_types_supported"`
GrantTypesSupported []string `json:"grant_types_supported"`
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
@ -53,6 +55,7 @@ func NewDiscoveryHandler(cfg DiscoveryConfig) http.Handler {
TokenEndpoint: cfg.TokenEndpoint,
JWKSUri: cfg.JWKSUri,
UserinfoEndpoint: cfg.UserinfoEndpoint,
EndSessionEndpoint: cfg.EndSessionEndpoint,
// Profile-locked values — not negotiable.
ResponseTypesSupported: []string{"code"},

View file

@ -119,6 +119,33 @@ func TestDiscoveryHandler_Endpoints(t *testing.T) {
}
}
func TestDiscoveryHandler_EndSessionAdvertisedWhenConfigured(t *testing.T) {
cfg := oidc.DiscoveryConfig{
Issuer: "https://auth.netkingdom.local",
AuthorizationEndpoint: "https://auth.netkingdom.local/oauth2/authorize",
TokenEndpoint: "https://auth.netkingdom.local/oauth2/token",
JWKSUri: "https://auth.netkingdom.local/jwks",
EndSessionEndpoint: "https://auth.netkingdom.local/logout",
}
doc := discoveryDoc(t, cfg)
if doc["end_session_endpoint"] != cfg.EndSessionEndpoint {
t.Errorf("end_session_endpoint: expected %q, got %v", cfg.EndSessionEndpoint, doc["end_session_endpoint"])
}
}
func TestDiscoveryHandler_EndSessionOmittedWhenEmpty(t *testing.T) {
cfg := oidc.DiscoveryConfig{
Issuer: "https://auth.netkingdom.local",
AuthorizationEndpoint: "https://auth.netkingdom.local/oauth2/authorize",
TokenEndpoint: "https://auth.netkingdom.local/oauth2/token",
JWKSUri: "https://auth.netkingdom.local/jwks",
}
doc := discoveryDoc(t, cfg)
if _, ok := doc["end_session_endpoint"]; ok {
t.Error("end_session_endpoint must be absent when not configured")
}
}
func TestDiscoveryHandler_UserinfoOmittedWhenEmpty(t *testing.T) {
cfg := oidc.DiscoveryConfig{
Issuer: "https://auth.netkingdom.local",

View file

@ -0,0 +1,183 @@
package oidc
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"net/url"
"strings"
"sync"
"time"
)
// HandoffKind distinguishes registration from MFA-enrollment return envelopes.
type HandoffKind string
const (
HandoffRegister HandoffKind = "register"
HandoffEnroll HandoffKind = "enroll"
)
const defaultHandoffTTL = 10 * time.Minute
// HandoffEnvelope is the signed, expiring state carried to an allow-listed
// registration or enrollment URL. Completing a handoff restarts /authorize
// and never mints a token.
type HandoffEnvelope struct {
Kind HandoffKind `json:"kind"`
ClientID string `json:"client_id"`
RedirectURI string `json:"redirect_uri"`
PKCEChallenge string `json:"code_challenge"`
PKCEChallengeMethod string `json:"code_challenge_method"`
State string `json:"state"`
Nonce string `json:"nonce,omitempty"`
Scopes []string `json:"scopes,omitempty"`
TenantHint string `json:"tenant_hint,omitempty"`
JTI string `json:"jti"`
ExpiresAt time.Time `json:"exp"`
}
var (
errHandoffInvalid = errors.New("invalid handoff")
errHandoffExpired = errors.New("handoff expired")
errHandoffReplay = errors.New("handoff replayed")
)
// HandoffStore signs and atomically consumes registration/enrollment envelopes.
type HandoffStore struct {
secret []byte
ttl time.Duration
mu sync.Mutex
consumed map[string]time.Time
}
// NewHandoffStore returns a store with an ephemeral HMAC key. Envelopes are
// short-lived, so a process restart simply invalidates in-flight handoffs.
func NewHandoffStore() *HandoffStore {
secret := make([]byte, 32)
if _, err := rand.Read(secret); err != nil {
panic("oidc: failed to generate handoff secret: " + err.Error())
}
return &HandoffStore{
secret: secret,
ttl: defaultHandoffTTL,
consumed: make(map[string]time.Time),
}
}
// Issue signs a new envelope for the given pending authorization.
func (s *HandoffStore) Issue(kind HandoffKind, ps *PendingState) (string, error) {
if s == nil {
return "", errHandoffInvalid
}
jti, err := randomID()
if err != nil {
return "", err
}
env := HandoffEnvelope{
Kind: kind,
ClientID: ps.ClientID,
RedirectURI: ps.RedirectURI,
PKCEChallenge: ps.PKCEChallenge,
PKCEChallengeMethod: ps.PKCEChallengeMethod,
State: ps.State,
Nonce: ps.Nonce,
Scopes: append([]string(nil), ps.Scopes...),
TenantHint: ps.TenantHint,
JTI: jti,
ExpiresAt: time.Now().Add(s.ttl),
}
payload, err := json.Marshal(env)
if err != nil {
return "", err
}
mac := hmac.New(sha256.New, s.secret)
mac.Write(payload)
token := base64.RawURLEncoding.EncodeToString(payload) + "." +
base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
return token, nil
}
// Consume verifies the envelope, rejects expiry/tamper/replay, and marks the
// JTI used. The caller must restart /authorize; it must not mint a token.
func (s *HandoffStore) Consume(token string) (*HandoffEnvelope, error) {
if s == nil {
return nil, errHandoffInvalid
}
payload, err := s.verify(token)
if err != nil {
return nil, err
}
var env HandoffEnvelope
if err := json.Unmarshal(payload, &env); err != nil {
return nil, errHandoffInvalid
}
if env.JTI == "" || env.ClientID == "" || env.RedirectURI == "" {
return nil, errHandoffInvalid
}
if time.Now().After(env.ExpiresAt) {
return nil, errHandoffExpired
}
s.mu.Lock()
defer s.mu.Unlock()
s.gcLocked()
if _, used := s.consumed[env.JTI]; used {
return nil, errHandoffReplay
}
s.consumed[env.JTI] = env.ExpiresAt
return &env, nil
}
func (s *HandoffStore) verify(token string) ([]byte, error) {
dot := strings.LastIndex(token, ".")
if dot <= 0 || dot == len(token)-1 {
return nil, errHandoffInvalid
}
payload, err := base64.RawURLEncoding.DecodeString(token[:dot])
if err != nil {
return nil, errHandoffInvalid
}
sig, err := base64.RawURLEncoding.DecodeString(token[dot+1:])
if err != nil {
return nil, errHandoffInvalid
}
mac := hmac.New(sha256.New, s.secret)
mac.Write(payload)
if !hmac.Equal(mac.Sum(nil), sig) {
return nil, errHandoffInvalid
}
return payload, nil
}
func (s *HandoffStore) gcLocked() {
now := time.Now()
for jti, exp := range s.consumed {
if now.After(exp) {
delete(s.consumed, jti)
}
}
}
func randomID() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
func appendHandoff(destURL, token string) (string, error) {
u, err := url.Parse(destURL)
if err != nil || u.Scheme == "" || u.Host == "" {
return "", errHandoffInvalid
}
q := u.Query()
q.Set("kc_handoff", token)
u.RawQuery = q.Encode()
return u.String(), nil
}

View file

@ -0,0 +1,274 @@
package oidc_test
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"keycape/internal/domain"
"keycape/internal/server/oidc"
)
func boolPtr(v bool) *bool { return &v }
func isolationClients() map[string]*domain.Client {
return map[string]*domain.Client{
"coulomb-social": {
ClientID: "coulomb-social",
DisplayName: "coulomb.social",
RedirectURIs: []string{"https://coulomb.social/auth/callback/"},
AllowedScopes: []string{"openid", "profile"},
ClientType: "public",
MFARequired: boolPtr(false),
RegistrationURL: "https://users.example.com/register",
EnrollmentURL: "https://users.example.com/enroll",
},
"openbao-console": {
ClientID: "openbao-console",
DisplayName: "OpenBao",
RedirectURIs: []string{"https://bao.example.com/oidc/callback"},
AllowedScopes: []string{"openid", "profile"},
ClientType: "public",
},
}
}
func isolationHandler(auth domain.AuthProvider, mfa domain.MFAProvider) *oidc.AuthorizeHandler {
return &oidc.AuthorizeHandler{
ClientConfig: isolationClients(),
Auth: auth,
MFA: mfa,
Sessions: oidc.NewSessionStore(),
Logins: oidc.NewLoginSessionStore(),
Handoffs: oidc.NewHandoffStore(),
Emitter: &captureEmitter{},
}
}
func TestHandoff_PromptCreate_EligibleClientRedirectsToAllowList(t *testing.T) {
h := isolationHandler(&mockAuthProvider{authorizeURL: "https://authelia.example/auth"}, &mockMFAProvider{})
params := url.Values{
"client_id": {"coulomb-social"},
"redirect_uri": {"https://coulomb.social/auth/callback/"},
"response_type": {"code"},
"scope": {"openid profile"},
"state": {"app-state"},
"code_challenge": {"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"},
"code_challenge_method": {"S256"},
"prompt": {"create"},
"tenant_hint": {"tenant:coulomb"},
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/authorize?"+params.Encode(), nil))
if rec.Code != http.StatusFound {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
loc, err := url.Parse(rec.Header().Get("Location"))
if err != nil {
t.Fatal(err)
}
if loc.Host != "users.example.com" || loc.Path != "/register" {
t.Fatalf("expected allow-listed registration URL, got %s", loc)
}
if loc.Query().Get("kc_handoff") == "" {
t.Fatal("expected kc_handoff on registration redirect")
}
}
func TestHandoff_PromptCreate_IneligibleClientRejected(t *testing.T) {
h := isolationHandler(&mockAuthProvider{}, &mockMFAProvider{})
params := url.Values{
"client_id": {"openbao-console"},
"redirect_uri": {"https://bao.example.com/oidc/callback"},
"response_type": {"code"},
"scope": {"openid profile"},
"state": {"app-state"},
"code_challenge": {"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"},
"code_challenge_method": {"S256"},
"prompt": {"create"},
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/authorize?"+params.Encode(), nil))
if rec.Code != http.StatusBadRequest {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestHandoff_ReturnRestartsAuthorizeWithoutToken(t *testing.T) {
h := isolationHandler(&mockAuthProvider{authorizeURL: "https://authelia.example/auth"}, &mockMFAProvider{})
params := url.Values{
"client_id": {"coulomb-social"},
"redirect_uri": {"https://coulomb.social/auth/callback/"},
"response_type": {"code"},
"scope": {"openid profile"},
"state": {"app-state"},
"code_challenge": {"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"},
"code_challenge_method": {"S256"},
"prompt": {"create"},
}
start := httptest.NewRecorder()
h.ServeHTTP(start, httptest.NewRequest(http.MethodGet, "/authorize?"+params.Encode(), nil))
token := mustQuery(t, start.Header().Get("Location"), "kc_handoff")
ret := httptest.NewRecorder()
h.ServeHTTP(ret, httptest.NewRequest(http.MethodGet, "/authorize/return?kc_handoff="+url.QueryEscape(token), nil))
if ret.Code != http.StatusFound {
t.Fatalf("return status=%d body=%s", ret.Code, ret.Body.String())
}
loc, err := url.Parse(ret.Header().Get("Location"))
if err != nil {
t.Fatal(err)
}
if loc.Path != "/authorize" {
t.Fatalf("return must restart /authorize, got %s", loc)
}
if loc.Query().Get("code") != "" {
t.Fatal("handoff return must not mint a token or code")
}
if loc.Query().Get("client_id") != "coulomb-social" {
t.Fatalf("client_id not preserved: %s", loc)
}
}
func TestHandoff_ReplayRejected(t *testing.T) {
h := isolationHandler(&mockAuthProvider{authorizeURL: "https://authelia.example/auth"}, &mockMFAProvider{})
params := url.Values{
"client_id": {"coulomb-social"},
"redirect_uri": {"https://coulomb.social/auth/callback/"},
"response_type": {"code"},
"scope": {"openid"},
"state": {"app-state"},
"code_challenge": {"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"},
"code_challenge_method": {"S256"},
"prompt": {"create"},
}
start := httptest.NewRecorder()
h.ServeHTTP(start, httptest.NewRequest(http.MethodGet, "/authorize?"+params.Encode(), nil))
token := mustQuery(t, start.Header().Get("Location"), "kc_handoff")
first := httptest.NewRecorder()
h.ServeHTTP(first, httptest.NewRequest(http.MethodGet, "/authorize/return?kc_handoff="+url.QueryEscape(token), nil))
if first.Code != http.StatusFound {
t.Fatalf("first return status=%d", first.Code)
}
replay := httptest.NewRecorder()
h.ServeHTTP(replay, httptest.NewRequest(http.MethodGet, "/authorize/return?kc_handoff="+url.QueryEscape(token), nil))
if replay.Code != http.StatusBadRequest {
t.Fatalf("replay status=%d body=%s", replay.Code, replay.Body.String())
}
}
func TestHandoff_TamperedEnvelopeRejected(t *testing.T) {
h := isolationHandler(&mockAuthProvider{authorizeURL: "https://authelia.example/auth"}, &mockMFAProvider{})
params := url.Values{
"client_id": {"coulomb-social"},
"redirect_uri": {"https://coulomb.social/auth/callback/"},
"response_type": {"code"},
"scope": {"openid"},
"state": {"app-state"},
"code_challenge": {"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"},
"code_challenge_method": {"S256"},
"prompt": {"create"},
}
start := httptest.NewRecorder()
h.ServeHTTP(start, httptest.NewRequest(http.MethodGet, "/authorize?"+params.Encode(), nil))
token := mustQuery(t, start.Header().Get("Location"), "kc_handoff")
tampered := token + "x"
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/authorize/return?kc_handoff="+url.QueryEscape(tampered), nil))
if rec.Code != http.StatusBadRequest {
t.Fatalf("tampered status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestHandoff_UnknownUserOffersSignupWithoutToken(t *testing.T) {
h := isolationHandler(&mockAuthProvider{callbackErr: domain.ErrAuthFailed}, &mockMFAProvider{})
h.PendingStates().Store("s-unknown", &oidc.PendingState{
ClientID: "coulomb-social",
RedirectURI: "https://coulomb.social/auth/callback/",
State: "s-unknown",
ExpiresAt: time.Now().Add(time.Minute),
})
rec := httptest.NewRecorder()
h.ServeHTTPCallback(rec, httptest.NewRequest(http.MethodGet, "/authorize/callback?code=x&state=s-unknown", nil))
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, "/authorize/register?state=s-unknown") {
t.Fatalf("expected signup link, body=%s", body)
}
if strings.Contains(body, "code=") {
t.Fatal("unknown-user page must not mint a code")
}
}
func TestHandoff_UnknownUserIneligibleHasNoSignupLink(t *testing.T) {
h := isolationHandler(&mockAuthProvider{callbackErr: domain.ErrAuthFailed}, &mockMFAProvider{})
h.PendingStates().Store("s-admin", &oidc.PendingState{
ClientID: "openbao-console",
RedirectURI: "https://bao.example.com/oidc/callback",
State: "s-admin",
ExpiresAt: time.Now().Add(time.Minute),
})
rec := httptest.NewRecorder()
h.ServeHTTPCallback(rec, httptest.NewRequest(http.MethodGet, "/authorize/callback?code=x&state=s-admin", nil))
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if strings.Contains(rec.Body.String(), "/authorize/register") {
t.Fatal("ineligible client must not receive a registration link")
}
}
func TestAuthorizeCallback_ExpiredStateRejected(t *testing.T) {
h := isolationHandler(&mockAuthProvider{callbackResult: &domain.AuthResult{Username: "alice"}}, &mockMFAProvider{})
h.PendingStates().Store("expired", &oidc.PendingState{
ClientID: "coulomb-social",
RedirectURI: "https://coulomb.social/auth/callback/",
State: "expired",
ExpiresAt: time.Now().Add(-time.Minute),
})
rec := httptest.NewRecorder()
h.ServeHTTPCallback(rec, httptest.NewRequest(http.MethodGet, "/authorize/callback?code=x&state=expired", nil))
if rec.Code != http.StatusBadRequest {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestAuthorizeCallback_ReplayAfterSuccessRejected(t *testing.T) {
h := isolationHandler(&mockAuthProvider{callbackResult: &domain.AuthResult{Username: "alice"}}, &mockMFAProvider{required: true})
h.PendingStates().Store("once", &oidc.PendingState{
ClientID: "coulomb-social",
RedirectURI: "https://coulomb.social/auth/callback/",
State: "once",
ExpiresAt: time.Now().Add(time.Minute),
})
first := httptest.NewRecorder()
h.ServeHTTPCallback(first, httptest.NewRequest(http.MethodGet, "/authorize/callback?code=x&state=once", nil))
if first.Code != http.StatusFound {
t.Fatalf("first status=%d body=%s", first.Code, first.Body.String())
}
second := httptest.NewRecorder()
h.ServeHTTPCallback(second, httptest.NewRequest(http.MethodGet, "/authorize/callback?code=x&state=once", nil))
if second.Code != http.StatusBadRequest {
t.Fatalf("replay status=%d body=%s", second.Code, second.Body.String())
}
}
func mustQuery(t *testing.T, raw, key string) string {
t.Helper()
u, err := url.Parse(raw)
if err != nil {
t.Fatal(err)
}
v := u.Query().Get(key)
if v == "" {
t.Fatalf("missing %s in %s", key, raw)
}
return v
}

View file

@ -0,0 +1,130 @@
package oidc
import (
"net/http"
"sync"
"time"
"keycape/internal/domain"
)
const (
loginCookieName = "kc_login"
loginSessionTTL = 8 * time.Hour
)
// LoginSession is a KeyCape browser session that records the assurance
// already proven for a user. It is not client-specific: a later high-
// assurance client must still step up if the stored level is too low.
type LoginSession struct {
ID string
Username string
Level domain.AssuranceLevel
IssuedAt time.Time
ExpiresAt time.Time
}
// LoginSessionStore is an in-memory login-session map keyed by cookie value.
type LoginSessionStore struct {
mu sync.Mutex
sessions map[string]*LoginSession
}
// NewLoginSessionStore returns an empty login-session store.
func NewLoginSessionStore() *LoginSessionStore {
return &LoginSessionStore{sessions: make(map[string]*LoginSession)}
}
// Create stores a session and returns it.
func (s *LoginSessionStore) Create(username string, level domain.AssuranceLevel) *LoginSession {
if s == nil {
return nil
}
id, err := randomID()
if err != nil {
panic("oidc: failed to generate login session id: " + err.Error())
}
now := time.Now()
sess := &LoginSession{
ID: id,
Username: username,
Level: level,
IssuedAt: now,
ExpiresAt: now.Add(loginSessionTTL),
}
s.mu.Lock()
s.sessions[id] = sess
s.mu.Unlock()
return sess
}
// Get returns a live session by id.
func (s *LoginSessionStore) Get(id string) (*LoginSession, bool) {
if s == nil || id == "" {
return nil, false
}
s.mu.Lock()
sess, ok := s.sessions[id]
s.mu.Unlock()
if !ok {
return nil, false
}
if time.Now().After(sess.ExpiresAt) {
s.Delete(id)
return nil, false
}
return sess, true
}
// Delete removes a session.
func (s *LoginSessionStore) Delete(id string) {
if s == nil {
return
}
s.mu.Lock()
delete(s.sessions, id)
s.mu.Unlock()
}
func (s *LoginSessionStore) fromRequest(r *http.Request) *LoginSession {
if s == nil || r == nil {
return nil
}
c, err := r.Cookie(loginCookieName)
if err != nil || c.Value == "" {
return nil
}
sess, ok := s.Get(c.Value)
if !ok {
return nil
}
return sess
}
func writeLoginCookie(w http.ResponseWriter, sess *LoginSession, secure bool) {
if sess == nil {
return
}
http.SetCookie(w, &http.Cookie{
Name: loginCookieName,
Value: sess.ID,
Path: "/",
Expires: sess.ExpiresAt,
MaxAge: int(time.Until(sess.ExpiresAt).Seconds()),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: secure,
})
}
func clearLoginCookie(w http.ResponseWriter, secure bool) {
http.SetCookie(w, &http.Cookie{
Name: loginCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: secure,
})
}

View file

@ -0,0 +1,73 @@
package oidc
import (
"net/http"
"net/url"
"strings"
"keycape/internal/domain"
profileerrors "keycape/internal/errors"
)
// LogoutHandler implements GET /logout (OIDC RP-initiated logout subset).
// It clears the KeyCape login session and, when requested, redirects only to
// a statically registered client redirect URI.
type LogoutHandler struct {
ClientConfig map[string]*domain.Client
Logins *LoginSessionStore
SecureCookie bool
}
// ServeHTTP handles GET /logout.
func (h *LogoutHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", "GET")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if sess := h.Logins.fromRequest(r); sess != nil {
h.Logins.Delete(sess.ID)
}
clearLoginCookie(w, h.SecureCookie)
clientID := r.URL.Query().Get("client_id")
postLogout := r.URL.Query().Get("post_logout_redirect_uri")
if postLogout == "" {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("logged out"))
return
}
client, ok := h.ClientConfig[clientID]
if !ok {
profileerrors.InvalidProfileUsage("unknown client_id", "client_id").
Write(w, http.StatusBadRequest)
return
}
if !uriRegistered(client.RedirectURIs, postLogout) {
profileerrors.RejectedForSafety(
"post_logout_redirect_uri is not a registered redirect URI",
"post_logout_redirect_uri",
).Write(w, http.StatusBadRequest)
return
}
loc, err := url.Parse(postLogout)
if err != nil {
profileerrors.InvalidProfileUsage("invalid post_logout_redirect_uri", "post_logout_redirect_uri").
Write(w, http.StatusBadRequest)
return
}
if state := r.URL.Query().Get("state"); state != "" {
q := loc.Query()
q.Set("state", state)
loc.RawQuery = q.Encode()
}
http.Redirect(w, r, loc.String(), http.StatusFound)
}
func issuerIsHTTPS(issuer string) bool {
return strings.HasPrefix(strings.ToLower(issuer), "https://")
}

View file

@ -0,0 +1,222 @@
package oidc_test
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"keycape/internal/domain"
"keycape/internal/server/oidc"
)
func TestPolicy_CoulombSocialPasswordOnlyWhenNoStrongerRule(t *testing.T) {
h := isolationHandler(
&mockAuthProvider{callbackResult: &domain.AuthResult{Username: "alice"}},
&mockMFAProvider{required: true, enrolled: true},
)
h.PendingStates().Store("social", &oidc.PendingState{
ClientID: "coulomb-social",
RedirectURI: "https://coulomb.social/auth/callback/",
PKCEChallenge: "abc",
PKCEChallengeMethod: "S256",
State: "social",
Scopes: []string{"openid"},
ExpiresAt: time.Now().Add(time.Minute),
})
rec := httptest.NewRecorder()
h.ServeHTTPCallback(rec, httptest.NewRequest(http.MethodGet, "/authorize/callback?code=x&state=social", nil))
if rec.Code != http.StatusFound {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if strings.Contains(rec.Body.String(), "KeyCape MFA") {
t.Fatal("ordinary coulomb-social login must not render MFA")
}
loc, _ := url.Parse(rec.Header().Get("Location"))
if loc.Query().Get("code") == "" {
t.Fatal("expected authorization code")
}
sess, ok := h.Sessions.Get(loc.Query().Get("code"))
if !ok || sess.MFAVerified {
t.Fatalf("AAL1 login must record MFAVerified=false: %+v", sess)
}
}
func TestPolicy_ProfileActionStepUpForcesMFA(t *testing.T) {
h := isolationHandler(
&mockAuthProvider{callbackResult: &domain.AuthResult{Username: "alice"}},
&mockMFAProvider{required: false, enrolled: true},
)
h.PendingStates().Store("step", &oidc.PendingState{
ClientID: "coulomb-social",
RedirectURI: "https://coulomb.social/auth/callback/",
State: "step",
ACRValues: []string{"aal2"},
ExpiresAt: time.Now().Add(time.Minute),
})
rec := httptest.NewRecorder()
h.ServeHTTPCallback(rec, httptest.NewRequest(http.MethodGet, "/authorize/callback?code=x&state=step", nil))
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "KeyCape MFA") {
t.Fatalf("expected MFA challenge, status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestPolicy_OpenBaoKeepsMandatoryMFA(t *testing.T) {
h := isolationHandler(
&mockAuthProvider{callbackResult: &domain.AuthResult{Username: "alice"}},
&mockMFAProvider{required: true, enrolled: true},
)
h.PendingStates().Store("bao", &oidc.PendingState{
ClientID: "openbao-console",
RedirectURI: "https://bao.example.com/oidc/callback",
State: "bao",
ExpiresAt: time.Now().Add(time.Minute),
})
rec := httptest.NewRecorder()
h.ServeHTTPCallback(rec, httptest.NewRequest(http.MethodGet, "/authorize/callback?code=x&state=bao", nil))
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "KeyCape MFA") {
t.Fatalf("OpenBao must keep MFA, status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestPolicy_LowAssuranceClientDoesNotSuppressHighAssurance(t *testing.T) {
h := isolationHandler(
&mockAuthProvider{callbackResult: &domain.AuthResult{Username: "alice"}},
&mockMFAProvider{required: true, enrolled: true},
)
h.PendingStates().Store("social", &oidc.PendingState{
ClientID: "coulomb-social",
RedirectURI: "https://coulomb.social/auth/callback/",
PKCEChallenge: "abc",
PKCEChallengeMethod: "S256",
State: "social",
Scopes: []string{"openid"},
ExpiresAt: time.Now().Add(time.Minute),
})
first := httptest.NewRecorder()
h.ServeHTTPCallback(first, httptest.NewRequest(http.MethodGet, "/authorize/callback?code=x&state=social", nil))
if first.Code != http.StatusFound {
t.Fatalf("AAL1 status=%d body=%s", first.Code, first.Body.String())
}
cookie := first.Result().Cookies()
if len(cookie) == 0 {
t.Fatal("expected login session cookie after AAL1")
}
h.PendingStates().Store("bao", &oidc.PendingState{
ClientID: "openbao-console",
RedirectURI: "https://bao.example.com/oidc/callback",
State: "bao",
ExpiresAt: time.Now().Add(time.Minute),
})
req := httptest.NewRequest(http.MethodGet, "/authorize/callback?code=y&state=bao", nil)
req.AddCookie(cookie[0])
second := httptest.NewRecorder()
h.ServeHTTPCallback(second, req)
if second.Code != http.StatusOK || !strings.Contains(second.Body.String(), "KeyCape MFA") {
t.Fatalf("AAL1 session must not satisfy OpenBao: status=%d body=%s", second.Code, second.Body.String())
}
}
func TestPolicy_NoFactorEnrollmentHandoffDoesNotBypass(t *testing.T) {
h := isolationHandler(
&mockAuthProvider{callbackResult: &domain.AuthResult{Username: "alice"}},
&mockMFAProvider{required: false, enrolled: false},
)
h.PendingStates().Store("enroll", &oidc.PendingState{
ClientID: "coulomb-social",
RedirectURI: "https://coulomb.social/auth/callback/",
State: "enroll",
ACRValues: []string{"aal2"},
ExpiresAt: time.Now().Add(time.Minute),
})
rec := httptest.NewRecorder()
h.ServeHTTPCallback(rec, httptest.NewRequest(http.MethodGet, "/authorize/callback?code=x&state=enroll", nil))
if rec.Code != http.StatusFound {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
loc, err := url.Parse(rec.Header().Get("Location"))
if err != nil {
t.Fatal(err)
}
if loc.Host != "users.example.com" || loc.Path != "/enroll" {
t.Fatalf("expected enrollment handoff, got %s", loc)
}
if loc.Query().Get("code") != "" {
t.Fatal("enrollment handoff must not mint a code")
}
}
func TestPolicy_ExactRedirectStillEnforced(t *testing.T) {
h := isolationHandler(&mockAuthProvider{authorizeURL: "https://authelia.example/auth"}, &mockMFAProvider{})
params := url.Values{
"client_id": {"coulomb-social"},
"redirect_uri": {"https://evil.example/callback"},
"response_type": {"code"},
"scope": {"openid"},
"state": {"s"},
"code_challenge": {"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"},
"code_challenge_method": {"S256"},
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/authorize?"+params.Encode(), nil))
if rec.Code != http.StatusBadRequest {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestLogout_ClearsSessionSoHighAssuranceRequiresMFAAgain(t *testing.T) {
logins := oidc.NewLoginSessionStore()
h := &oidc.AuthorizeHandler{
ClientConfig: isolationClients(),
Auth: &mockAuthProvider{callbackResult: &domain.AuthResult{Username: "alice"}},
MFA: &mockMFAProvider{required: true, enrolled: true},
Sessions: oidc.NewSessionStore(),
Logins: logins,
Handoffs: oidc.NewHandoffStore(),
Emitter: &captureEmitter{},
}
aal2 := logins.Create("alice", domain.AssuranceAAL2)
logout := &oidc.LogoutHandler{ClientConfig: isolationClients(), Logins: logins}
req := httptest.NewRequest(http.MethodGet, "/logout?client_id=coulomb-social&post_logout_redirect_uri="+
url.QueryEscape("https://coulomb.social/auth/callback/"), nil)
req.AddCookie(&http.Cookie{Name: "kc_login", Value: aal2.ID})
rec := httptest.NewRecorder()
logout.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("logout status=%d body=%s", rec.Code, rec.Body.String())
}
if _, ok := logins.Get(aal2.ID); ok {
t.Fatal("logout must delete the login session")
}
h.PendingStates().Store("bao", &oidc.PendingState{
ClientID: "openbao-console",
RedirectURI: "https://bao.example.com/oidc/callback",
State: "bao",
ExpiresAt: time.Now().Add(time.Minute),
})
after := httptest.NewRequest(http.MethodGet, "/authorize/callback?code=z&state=bao", nil)
after.AddCookie(&http.Cookie{Name: "kc_login", Value: aal2.ID})
second := httptest.NewRecorder()
h.ServeHTTPCallback(second, after)
if second.Code != http.StatusOK || !strings.Contains(second.Body.String(), "KeyCape MFA") {
t.Fatalf("after logout OpenBao must require MFA, status=%d body=%s", second.Code, second.Body.String())
}
}
func TestLogout_RejectsUnregisteredPostLogoutRedirect(t *testing.T) {
logout := &oidc.LogoutHandler{
ClientConfig: isolationClients(),
Logins: oidc.NewLoginSessionStore(),
}
req := httptest.NewRequest(http.MethodGet, "/logout?client_id=coulomb-social&post_logout_redirect_uri="+
url.QueryEscape("https://evil.example/out"), nil)
rec := httptest.NewRecorder()
logout.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
}