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

@ -53,3 +53,7 @@ clients:
allowedScopes: ["openid", "profile", "email", "groups"] allowedScopes: ["openid", "profile", "email", "groups"]
grantTypes: ["authorization_code"] grantTypes: ["authorization_code"]
clientType: "public" clientType: "public"
# Ordinary login is AAL1; acr_values=aal2 still forces step-up.
# Other clients keep the provider default (mandatory MFA).
mfaRequired: false
registrationUrl: "https://users.92-205-62-239.nip.io/register"

View file

@ -131,21 +131,33 @@ func main() {
TokenEndpoint: issuer + "/token", TokenEndpoint: issuer + "/token",
JWKSUri: issuer + "/jwks", JWKSUri: issuer + "/jwks",
UserinfoEndpoint: issuer + "/userinfo", UserinfoEndpoint: issuer + "/userinfo",
EndSessionEndpoint: issuer + "/logout",
})) }))
// JWKS. // JWKS.
mux.Handle("/jwks", oidc.NewJWKSHandler(ks)) mux.Handle("/jwks", oidc.NewJWKSHandler(ks))
// Authorize handler (with enforcement middleware). // Authorize handler (with enforcement middleware).
logins := oidc.NewLoginSessionStore()
authorizeHandler := &oidc.AuthorizeHandler{ authorizeHandler := &oidc.AuthorizeHandler{
ClientConfig: clients, ClientConfig: clients,
Auth: autheliaAdapter, Auth: autheliaAdapter,
MFA: privacyIDEAAdapter, MFA: privacyIDEAAdapter,
Sessions: sessions, Sessions: sessions,
Logins: logins,
Handoffs: oidc.NewHandoffStore(),
Issuer: issuer,
Emitter: emitter, Emitter: emitter,
} }
mux.Handle("/authorize", enforcement.Middleware(authorizeHandler)) mux.Handle("/authorize", enforcement.Middleware(authorizeHandler))
mux.Handle("/authorize/callback", authorizeHandler) mux.Handle("/authorize/callback", authorizeHandler)
mux.Handle("/authorize/return", authorizeHandler)
mux.Handle("/authorize/register", authorizeHandler)
mux.Handle("/logout", &oidc.LogoutHandler{
ClientConfig: clients,
Logins: logins,
SecureCookie: strings.HasPrefix(strings.ToLower(issuer), "https://"),
})
// Token handler (with enforcement middleware). // Token handler (with enforcement middleware).
tokenHandler := &oidc.TokenHandler{ tokenHandler := &oidc.TokenHandler{
@ -272,8 +284,10 @@ func buildClientRegistry(cfgClients []config.ClientConfig) (map[string]*domain.C
ClientSecret: clientSecret, ClientSecret: clientSecret,
ServiceSubject: c.ServiceSubject, ServiceSubject: c.ServiceSubject,
Tenant: c.Tenant, Tenant: c.Tenant,
Roles: c.Roles, Roles: c.Roles,
MFARequired: c.MFARequired, MFARequired: c.MFARequired,
RegistrationURL: c.RegistrationURL,
EnrollmentURL: c.EnrollmentURL,
} }
} }
return m, nil return m, nil

View file

@ -41,7 +41,17 @@ func (a *PrivacyIDEAAdapter) CheckMFARequired(ctx context.Context, userID string
if a.cfg.RequireForAll { if a.cfg.RequireForAll {
return true, nil return true, nil
} }
return a.hasActiveToken(ctx, userID)
}
// HasEnrolledFactor reports whether privacyIDEA has an active token for the
// user. RequireForAll does not skip this check — enrollment is independent
// of the global require-for-all policy.
func (a *PrivacyIDEAAdapter) HasEnrolledFactor(ctx context.Context, userID string) (bool, error) {
return a.hasActiveToken(ctx, userID)
}
func (a *PrivacyIDEAAdapter) hasActiveToken(ctx context.Context, userID string) (bool, error) {
endpoint := strings.TrimRight(a.cfg.BaseURL, "/") + "/token/" endpoint := strings.TrimRight(a.cfg.BaseURL, "/") + "/token/"
q := url.Values{} q := url.Values{}

View file

@ -141,6 +141,48 @@ func TestCheckMFARequired_InactiveTokenOnly_ReturnsFalse(t *testing.T) {
} }
} }
func TestHasEnrolledFactor_RequireForAllStillListsTokens(t *testing.T) {
called := false
client := &mockHTTPClient{
doFn: func(_ *http.Request) (*http.Response, error) {
called = true
return jsonResponse(tokenListResponse(nil)), nil
},
}
cfg := testConfig()
cfg.RequireForAll = true
adapter := privacyidea.New(cfg, client)
enrolled, err := adapter.HasEnrolledFactor(context.Background(), "alice")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if enrolled {
t.Error("expected enrolled=false when no tokens even if RequireForAll")
}
if !called {
t.Error("HasEnrolledFactor must consult the token list")
}
}
func TestHasEnrolledFactor_ActiveToken_ReturnsTrue(t *testing.T) {
client := &mockHTTPClient{
doFn: func(_ *http.Request) (*http.Response, error) {
return jsonResponse(tokenListResponse([]map[string]interface{}{
{"active": true},
})), nil
},
}
adapter := privacyidea.New(testConfig(), client)
enrolled, err := adapter.HasEnrolledFactor(context.Background(), "alice")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !enrolled {
t.Error("expected enrolled=true when an active token is present")
}
}
func TestCheckMFARequired_NoTokens_ReturnsFalse(t *testing.T) { func TestCheckMFARequired_NoTokens_ReturnsFalse(t *testing.T) {
client := &mockHTTPClient{ client := &mockHTTPClient{
doFn: func(_ *http.Request) (*http.Response, error) { doFn: func(_ *http.Request) (*http.Response, error) {

View file

@ -38,8 +38,10 @@ type ClientConfig struct {
SecretRef string `yaml:"secretRef,omitempty"` SecretRef string `yaml:"secretRef,omitempty"`
ServiceSubject string `yaml:"serviceSubject,omitempty"` ServiceSubject string `yaml:"serviceSubject,omitempty"`
Tenant string `yaml:"tenant,omitempty"` Tenant string `yaml:"tenant,omitempty"`
Roles []string `yaml:"roles,omitempty"` Roles []string `yaml:"roles,omitempty"`
MFARequired *bool `yaml:"mfaRequired,omitempty"` MFARequired *bool `yaml:"mfaRequired,omitempty"`
RegistrationURL string `yaml:"registrationUrl,omitempty"`
EnrollmentURL string `yaml:"enrollmentUrl,omitempty"`
} }
// Load reads and parses the YAML config file at path. // Load reads and parses the YAML config file at path.

View file

@ -127,6 +127,50 @@ clients:
} }
} }
func TestLoad_ClientMFAAndRegistrationURL(t *testing.T) {
keyPath := writeTempFile(t, "placeholder-key")
yaml := `
issuer: "https://kc.example.com"
port: 8080
tokenLifetime: "15m"
privateKeyPem: "` + keyPath + `"
environment: "dev"
clients:
- clientId: "coulomb-social"
displayName: "coulomb.social"
redirectUris:
- "https://coulomb.social/auth/callback/"
clientType: "public"
mfaRequired: false
registrationUrl: "https://users.example.com/register"
`
cfgPath := writeTempFile(t, yaml)
cfg, err := config.Load(cfgPath)
if err != nil {
t.Fatalf("Load: unexpected error: %v", err)
}
if len(cfg.Clients) != 1 {
t.Fatalf("clients: got %d", len(cfg.Clients))
}
c := cfg.Clients[0]
if c.MFARequired == nil || *c.MFARequired {
t.Fatalf("mfaRequired: want false, got %+v", c.MFARequired)
}
if c.RegistrationURL != "https://users.example.com/register" {
t.Errorf("registrationUrl: got %q", c.RegistrationURL)
}
}
func TestValidate_InvalidRegistrationURL(t *testing.T) {
keyPath := writeTempFile(t, "key")
cfg := validConfig(keyPath)
cfg.Clients[0].RegistrationURL = "javascript:alert(1)"
errs := config.ValidateConfig(cfg)
if !containsErr(errs, "registrationUrl") {
t.Errorf("expected registrationUrl error, got %v", errs)
}
}
func TestLoad_PrivacyIDEARequireForAll(t *testing.T) { func TestLoad_PrivacyIDEARequireForAll(t *testing.T) {
keyPath := writeTempFile(t, "placeholder-key") keyPath := writeTempFile(t, "placeholder-key")
yaml := ` yaml := `

View file

@ -63,6 +63,16 @@ func ValidateConfig(cfg *Config) []string {
errs = append(errs, prefix+fmt.Sprintf(": redirect_uri %q must not contain wildcards", uri)) errs = append(errs, prefix+fmt.Sprintf(": redirect_uri %q must not contain wildcards", uri))
} }
} }
if c.RegistrationURL != "" {
if err := validateHandoffURL(c.RegistrationURL); err != nil {
errs = append(errs, prefix+": registrationUrl: "+err.Error())
}
}
if c.EnrollmentURL != "" {
if err := validateHandoffURL(c.EnrollmentURL); err != nil {
errs = append(errs, prefix+": enrollmentUrl: "+err.Error())
}
}
} }
// Private key PEM path must be provided (existence is checked at startup). // Private key PEM path must be provided (existence is checked at startup).
@ -81,3 +91,20 @@ func contains(values []string, wanted string) bool {
} }
return false return false
} }
func validateHandoffURL(raw string) error {
u, err := url.Parse(raw)
if err != nil || u.Scheme == "" || u.Host == "" {
return fmt.Errorf("%q is not an absolute URL", raw)
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("%q scheme must be http or https", raw)
}
if u.User != nil {
return fmt.Errorf("%q must not contain userinfo", raw)
}
if strings.ContainsAny(u.Host, "*?") || strings.ContainsAny(u.Path, "*") {
return fmt.Errorf("%q must not contain wildcards", raw)
}
return nil
}

View file

@ -0,0 +1,116 @@
package domain
import (
"strings"
"time"
)
// AssuranceLevel is the NetKingdom IAM Profile authentication assurance
// level required or satisfied for a request.
type AssuranceLevel int
const (
// AssuranceNone means no KeyCape login session is present.
AssuranceNone AssuranceLevel = 0
// AssuranceAAL1 is password (or equivalent single-factor) assurance.
AssuranceAAL1 AssuranceLevel = 1
// AssuranceAAL2 is MFA or equivalent strong assurance.
AssuranceAAL2 AssuranceLevel = 2
)
// AssuranceInput is the evidence DecideAssurance combines. Client override,
// requested ACR, provider default, and current session are evaluated for
// the current request only — never for another client.
type AssuranceInput struct {
Client *Client
ACRValues []string
ProviderRequired bool
SessionLevel AssuranceLevel
SessionUser string
RequestUser string
SessionIssuedAt time.Time
Now time.Time
MaxAge *time.Duration
PromptLogin bool
}
// AssuranceDecision is the per-request MFA/session outcome.
type AssuranceDecision struct {
RequiredLevel AssuranceLevel
RequireMFA bool
SessionSatisfies bool
MFAVerified bool
Source string
}
// ACRRequiresAAL2 reports whether requested acr_values ask for step-up.
func ACRRequiresAAL2(acrValues []string) bool {
for _, acr := range acrValues {
switch strings.ToLower(strings.TrimSpace(acr)) {
case "aal2", "mfa", "urn:netkingdom:aal2":
return true
}
}
return false
}
// DecideAssurance combines client minimum assurance, requested ACR/step-up,
// provider/tenant default, and current session assurance. ACR can only raise
// the requirement. A client override applies only to that client. An AAL1
// session cannot satisfy an AAL2 request.
func DecideAssurance(in AssuranceInput) AssuranceDecision {
required, source := requiredLevel(in)
decision := AssuranceDecision{
RequiredLevel: required,
Source: source,
}
if sessionUsable(in) && in.SessionLevel >= required {
decision.SessionSatisfies = true
decision.RequireMFA = false
decision.MFAVerified = in.SessionLevel >= AssuranceAAL2
return decision
}
decision.RequireMFA = required >= AssuranceAAL2
decision.MFAVerified = false
return decision
}
func requiredLevel(in AssuranceInput) (AssuranceLevel, string) {
if ACRRequiresAAL2(in.ACRValues) {
return AssuranceAAL2, "acr"
}
if in.Client != nil && in.Client.MFARequired != nil {
if *in.Client.MFARequired {
return AssuranceAAL2, "client"
}
return AssuranceAAL1, "client"
}
if in.ProviderRequired {
return AssuranceAAL2, "provider"
}
return AssuranceAAL1, "default"
}
func sessionUsable(in AssuranceInput) bool {
if in.PromptLogin {
return false
}
if in.SessionLevel == AssuranceNone {
return false
}
if in.RequestUser != "" && in.SessionUser != "" && in.SessionUser != in.RequestUser {
return false
}
now := in.Now
if now.IsZero() {
now = time.Now()
}
if in.MaxAge != nil {
if in.SessionIssuedAt.IsZero() || now.Sub(in.SessionIssuedAt) > *in.MaxAge {
return false
}
}
return true
}

View file

@ -0,0 +1,113 @@
package domain
import (
"testing"
"time"
)
func boolPtr(v bool) *bool { return &v }
func TestDecideAssurance_ClientOverrideIsPerClient(t *testing.T) {
low := &Client{ClientID: "coulomb-social", MFARequired: boolPtr(false)}
high := &Client{ClientID: "openbao-console"}
lowDec := DecideAssurance(AssuranceInput{Client: low, ProviderRequired: true})
if lowDec.RequireMFA || lowDec.RequiredLevel != AssuranceAAL1 || lowDec.Source != "client" {
t.Fatalf("low-assurance client: %+v", lowDec)
}
highDec := DecideAssurance(AssuranceInput{Client: high, ProviderRequired: true})
if !highDec.RequireMFA || highDec.RequiredLevel != AssuranceAAL2 || highDec.Source != "provider" {
t.Fatalf("high-assurance client must keep provider MFA: %+v", highDec)
}
}
func TestDecideAssurance_ACRRaisesClientAAL1(t *testing.T) {
client := &Client{ClientID: "coulomb-social", MFARequired: boolPtr(false)}
dec := DecideAssurance(AssuranceInput{
Client: client,
ACRValues: []string{"aal2"},
})
if !dec.RequireMFA || dec.Source != "acr" {
t.Fatalf("acr must raise AAL1 client: %+v", dec)
}
}
func TestDecideAssurance_AAL1SessionCannotSatisfyAAL2(t *testing.T) {
high := &Client{ClientID: "openbao-console"}
dec := DecideAssurance(AssuranceInput{
Client: high,
ProviderRequired: true,
SessionLevel: AssuranceAAL1,
SessionUser: "alice",
RequestUser: "alice",
})
if dec.SessionSatisfies || !dec.RequireMFA || dec.MFAVerified {
t.Fatalf("AAL1 session must not satisfy AAL2: %+v", dec)
}
}
func TestDecideAssurance_AAL2SessionSatisfiesHighAssurance(t *testing.T) {
high := &Client{ClientID: "openbao-console"}
dec := DecideAssurance(AssuranceInput{
Client: high,
ProviderRequired: true,
SessionLevel: AssuranceAAL2,
SessionUser: "alice",
RequestUser: "alice",
})
if !dec.SessionSatisfies || dec.RequireMFA || !dec.MFAVerified {
t.Fatalf("AAL2 session should satisfy AAL2: %+v", dec)
}
}
func TestDecideAssurance_MaxAgeInvalidatesSession(t *testing.T) {
maxAge := 30 * time.Second
now := time.Unix(1_700_000_100, 0)
dec := DecideAssurance(AssuranceInput{
Client: &Client{ClientID: "coulomb-social", MFARequired: boolPtr(false)},
SessionLevel: AssuranceAAL1,
SessionUser: "alice",
RequestUser: "alice",
SessionIssuedAt: now.Add(-time.Minute),
Now: now,
MaxAge: &maxAge,
})
if dec.SessionSatisfies {
t.Fatalf("expired max_age session must not satisfy: %+v", dec)
}
}
func TestDecideAssurance_PromptLoginIgnoresSession(t *testing.T) {
dec := DecideAssurance(AssuranceInput{
Client: &Client{ClientID: "coulomb-social", MFARequired: boolPtr(false)},
SessionLevel: AssuranceAAL2,
SessionUser: "alice",
RequestUser: "alice",
PromptLogin: true,
})
if dec.SessionSatisfies {
t.Fatalf("prompt=login must ignore session: %+v", dec)
}
}
func TestDecideAssurance_SessionUserMismatchIgnored(t *testing.T) {
dec := DecideAssurance(AssuranceInput{
ProviderRequired: true,
SessionLevel: AssuranceAAL2,
SessionUser: "alice",
RequestUser: "bob",
})
if dec.SessionSatisfies || !dec.RequireMFA {
t.Fatalf("foreign session must not satisfy: %+v", dec)
}
}
func TestACRRequiresAAL2(t *testing.T) {
if !ACRRequiresAAL2([]string{"urn:netkingdom:aal2"}) {
t.Fatal("expected urn:netkingdom:aal2 to require AAL2")
}
if ACRRequiresAAL2([]string{"aal1"}) {
t.Fatal("aal1 must not require AAL2")
}
}

View file

@ -11,6 +11,11 @@ type MFAProvider interface {
// CheckMFARequired returns true if MFA is required for the given user. // CheckMFARequired returns true if MFA is required for the given user.
CheckMFARequired(ctx context.Context, userID string) (bool, error) CheckMFARequired(ctx context.Context, userID string) (bool, error)
// HasEnrolledFactor reports whether the user has at least one active
// factor. Distinct from CheckMFARequired: a provider-wide require-for-all
// policy can demand MFA even when the user has not enrolled yet.
HasEnrolledFactor(ctx context.Context, userID string) (bool, error)
// ValidateMFAToken validates the given OTP token for the user. // ValidateMFAToken validates the given OTP token for the user.
// Returns ErrMFAFailed if the token is invalid or expired. // Returns ErrMFAFailed if the token is invalid or expired.
ValidateMFAToken(ctx context.Context, userID, token string) error ValidateMFAToken(ctx context.Context, userID, token string) error

View file

@ -52,8 +52,10 @@ type Client struct {
ClientSecret string `yaml:"-" json:"-"` ClientSecret string `yaml:"-" json:"-"`
ServiceSubject string `yaml:"serviceSubject,omitempty" json:"serviceSubject,omitempty"` ServiceSubject string `yaml:"serviceSubject,omitempty" json:"serviceSubject,omitempty"`
Tenant string `yaml:"tenant,omitempty" json:"tenant,omitempty"` Tenant string `yaml:"tenant,omitempty" json:"tenant,omitempty"`
Roles []string `yaml:"roles,omitempty" json:"roles,omitempty"` Roles []string `yaml:"roles,omitempty" json:"roles,omitempty"`
MFARequired *bool `yaml:"mfaRequired,omitempty" json:"mfaRequired,omitempty"` MFARequired *bool `yaml:"mfaRequired,omitempty" json:"mfaRequired,omitempty"`
RegistrationURL string `yaml:"registrationUrl,omitempty" json:"registrationUrl,omitempty"`
EnrollmentURL string `yaml:"enrollmentUrl,omitempty" json:"enrollmentUrl,omitempty"`
} }
// Membership links a user to a group. // Membership links a user to a group.

View file

@ -2,9 +2,11 @@ package oidc
import ( import (
"context" "context"
"errors"
"html/template" "html/template"
"net/http" "net/http"
"net/url" "net/url"
"strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
@ -28,6 +30,9 @@ type PendingState struct {
ExpiresAt time.Time ExpiresAt time.Time
AuthenticatedUser string AuthenticatedUser string
ACRValues []string ACRValues []string
TenantHint string
MaxAge *time.Duration
PromptLogin bool
} }
// pendingStateStore is a thread-safe map of state → PendingState. // pendingStateStore is a thread-safe map of state → PendingState.
@ -65,6 +70,9 @@ type AuthorizeHandler struct {
Auth domain.AuthProvider Auth domain.AuthProvider
MFA domain.MFAProvider MFA domain.MFAProvider
Sessions *SessionStore Sessions *SessionStore
Logins *LoginSessionStore
Handoffs *HandoffStore
Issuer string
Emitter telemetry.Emitter Emitter telemetry.Emitter
pending *pendingStateStore pending *pendingStateStore
@ -82,17 +90,28 @@ func (h *AuthorizeHandler) init() {
if h.pending == nil { if h.pending == nil {
h.pending = newPendingStateStore() 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. // ServeHTTP dispatches to the authorize or callback handler based on path.
func (h *AuthorizeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (h *AuthorizeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.init() h.init()
if strings.HasSuffix(r.URL.Path, "/callback") { switch {
case strings.HasSuffix(r.URL.Path, "/callback"):
h.ServeHTTPCallback(w, r) 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. // 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") codeChallenge := q.Get("code_challenge")
codeChallengeMethod := q.Get("code_challenge_method") codeChallengeMethod := q.Get("code_challenge_method")
acrValues := strings.Fields(q.Get("acr_values")) 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. // Emit auth_start telemetry immediately.
h.Emitter.Emit(ctx, telemetry.Event{ 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. // Store pending state so the callback can reconstruct the session.
h.pending.Store(state, &PendingState{ ps := &PendingState{
ClientID: clientID, ClientID: clientID,
RedirectURI: redirectURI, RedirectURI: redirectURI,
PKCEChallenge: codeChallenge, PKCEChallenge: codeChallenge,
@ -198,8 +225,17 @@ func (h *AuthorizeHandler) serveAuthorize(w http.ResponseWriter, r *http.Request
Nonce: nonce, Nonce: nonce,
Scopes: strings.Fields(scope), Scopes: strings.Fields(scope),
ACRValues: acrValues, ACRValues: acrValues,
TenantHint: tenantHint,
MaxAge: maxAge,
PromptLogin: promptLogin,
ExpiresAt: time.Now().Add(10 * time.Minute), 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. // Delegate to Auth provider.
authURL, err := h.Auth.AuthorizeURL(ctx, domain.AuthRequest{ authURL, err := h.Auth.AuthorizeURL(ctx, domain.AuthRequest{
@ -256,7 +292,7 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
Code: code, Code: code,
State: state, State: state,
}) })
if err != nil { if err != nil || result == nil || result.Username == "" {
h.Emitter.Emit(ctx, telemetry.Event{ h.Emitter.Emit(ctx, telemetry.Event{
Timestamp: time.Now(), Timestamp: time.Now(),
EventType: telemetry.EventAuthFailure, EventType: telemetry.EventAuthFailure,
@ -265,25 +301,16 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
Result: "failure", Result: "failure",
ErrorType: "auth_failed", ErrorType: "auth_failed",
}) })
http.Error(w, "authentication failed", http.StatusUnauthorized) if h.clientEligible(ps.ClientID, HandoffRegister) {
return h.renderUnknownUserSignup(w, ps)
} return
if result == nil || result.Username == "" { }
h.pending.Delete(state) 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) http.Error(w, "authentication failed", http.StatusUnauthorized)
return return
} }
// Check MFA requirement. decision, err := h.decideAssurance(ctx, ps, result.Username, h.Logins.fromRequest(r))
mfaRequired, _, err := h.mfaRequirement(ps, result.Username)
if err != nil { if err != nil {
h.Emitter.Emit(ctx, telemetry.Event{ h.Emitter.Emit(ctx, telemetry.Event{
Timestamp: time.Now(), 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) http.Error(w, "mfa check error", http.StatusInternalServerError)
return 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 == "" { if mfaToken == "" {
ps.AuthenticatedUser = result.Username ps.AuthenticatedUser = result.Username
h.pending.Store(state, ps) h.pending.Store(state, ps)
@ -304,29 +337,52 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
return return
} }
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 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.pending.Delete(state)
h.emitMFAFailure(ctx, ps.ClientID) h.emitMFAFailure(ctx, ps.ClientID)
http.Error(w, "MFA validation failed", http.StatusUnauthorized) http.Error(w, "MFA validation failed", http.StatusUnauthorized)
return return
} }
h.pending.Delete(state)
h.completeAuthorization(w, r, ps, result.Username, true)
return
} }
h.pending.Delete(state) 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) { func (h *AuthorizeHandler) decideAssurance(ctx context.Context, ps *PendingState, username string, login *LoginSession) (domain.AssuranceDecision, error) {
for _, acr := range ps.ACRValues { client := h.ClientConfig[ps.ClientID]
switch strings.ToLower(acr) { providerRequired := false
case "aal2", "mfa", "urn:netkingdom:aal2": if !domain.ACRRequiresAAL2(ps.ACRValues) && (client == nil || client.MFARequired == nil) {
return true, false, 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 { in := domain.AssuranceInput{
return *client.MFARequired, false, nil Client: client,
ACRValues: ps.ACRValues,
ProviderRequired: providerRequired,
RequestUser: username,
PromptLogin: ps.PromptLogin,
MaxAge: ps.MaxAge,
} }
required, err := h.MFA.CheckMFARequired(context.Background(), username) if login != nil {
return required, true, err 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) { 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) { 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. // Generate authorization code and store PKCE session.
sess := &PKCESession{ sess := &PKCESession{
ClientID: ps.ClientID, ClientID: ps.ClientID,
@ -411,6 +475,157 @@ func (h *AuthorizeHandler) completeAuthorization(w http.ResponseWriter, r *http.
http.Redirect(w, r, redirectTo.String(), http.StatusFound) 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) { func (h *AuthorizeHandler) emitMFAFailure(ctx context.Context, clientID string) {
h.Emitter.Emit(ctx, telemetry.Event{ h.Emitter.Emit(ctx, telemetry.Event{
Timestamp: time.Now(), Timestamp: time.Now(),
@ -487,6 +702,63 @@ var mfaChallengeTemplate = template.Must(template.New("mfa-challenge").Parse(`<!
</body> </body>
</html>`)) </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 { func uriRegistered(registered []string, target string) bool {
for _, u := range registered { for _, u := range registered {
if u == target { if u == target {

View file

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

View file

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

View file

@ -64,6 +64,10 @@ func (m *mockMFA) CheckMFARequired(_ context.Context, _ string) (bool, error) {
return m.required, m.checkErr return m.required, m.checkErr
} }
func (m *mockMFA) HasEnrolledFactor(_ context.Context, _ string) (bool, error) {
return m.required, m.checkErr
}
func (m *mockMFA) ValidateMFAToken(_ context.Context, _, _ string) error { func (m *mockMFA) ValidateMFAToken(_ context.Context, _, _ string) error {
return m.mfaErr return m.mfaErr
} }

View file

@ -4,11 +4,11 @@ type: workplan
title: "Registration handoff and client-aware MFA policy" title: "Registration handoff and client-aware MFA policy"
domain: infotech domain: infotech
repo: key-cape repo: key-cape
status: active status: finished
owner: codex owner: grok
topic_slug: netkingdom topic_slug: netkingdom
created: "2026-08-09" created: "2026-08-09"
updated: "2026-08-09" updated: "2026-08-16"
depends_on: depends_on:
- NK-WP-0025 - NK-WP-0025
state_hub_workstream_id: "70b78f21-be6d-4d6c-a537-037c38b2884a" state_hub_workstream_id: "70b78f21-be6d-4d6c-a537-037c38b2884a"
@ -36,11 +36,17 @@ normal authorization flow and must not mint a token directly.
Done when unknown users can choose signup from an eligible authorization flow Done when unknown users can choose signup from an eligible authorization flow
without open redirect, client substitution, or state replay. without open redirect, client substitution, or state replay.
Implemented `prompt=create` and `/authorize/register` against the client's
static `registrationUrl`, plus HMAC-signed `kc_handoff` envelopes consumed
once at `/authorize/return`. Return restarts `/authorize` and never mints a
code. Ineligible clients get no signup link. Live registration entry remains
user-engine-owned per NK-WP-0025; KeyCape only issues the return envelope.
## T02 - Replace global MFA with client-aware minimum assurance ## T02 - Replace global MFA with client-aware minimum assurance
```task ```task
id: KEY-WP-0008-T02 id: KEY-WP-0008-T02
status: progress status: done
priority: high priority: high
state_hub_task_id: "c2b56182-e717-4ca3-84e3-0963b69ce32f" state_hub_task_id: "c2b56182-e717-4ca3-84e3-0963b69ce32f"
``` ```
@ -58,6 +64,13 @@ Implemented with nullable per-client `mfaRequired`: an explicit client value
overrides the provider default only for that client. Absent values preserve overrides the provider default only for that client. Absent values preserve
the existing provider-driven policy. the existing provider-driven policy.
2026-08-16: `DecideAssurance` now combines client minimum, requested ACR,
provider default, `max_age`, `prompt=login`, and current KeyCape login-session
level. An AAL1 session cannot satisfy an AAL2 client or `acr_values=aal2`.
`coulomb-social` in `config/dev-config.yaml` is `mfaRequired: false`; other
clients keep the provider default. Users without an enrolled factor are sent
to the client's `enrollmentUrl` instead of completing authorization.
## T03 - Support explicit step-up and fresh authentication ## T03 - Support explicit step-up and fresh authentication
```task ```task
@ -83,7 +96,7 @@ and `mfa: true` only after successful verification.
```task ```task
id: KEY-WP-0008-T04 id: KEY-WP-0008-T04
status: todo status: done
priority: high priority: high
state_hub_task_id: "d4208f77-f4a6-4f2e-a436-de4f779cfaca" state_hub_task_id: "d4208f77-f4a6-4f2e-a436-de4f779cfaca"
``` ```
@ -95,3 +108,11 @@ Keep static client registration and exact redirect rules unchanged.
Done when existing high-assurance clients pass unchanged and the new Done when existing high-assurance clients pass unchanged and the new
coulomb-social journey passes live. coulomb-social journey passes live.
2026-08-16: isolation tests cover known/unknown users, registration
eligibility, state expiry/replay, password-only coulomb-social, ACR step-up,
no-factor enrollment handoff, OpenBao mandatory MFA, cross-client AAL1
session reuse, logout, and exact redirect enforcement. Full Go suite passes.
Live coulomb-social AAL1/AAL2 isolation was already proven on railiance01
under NK-WP-0025-T05 (2026-08-14); this closeout adds the KeyCape-side
regression suite and `/logout`.