From b6af6c52685be07c2758e57ec7c7a44e19a4ec41 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 16 Aug 2026 01:05:27 +0200 Subject: [PATCH] Finish KEY-WP-0008: registration handoff and client MFA isolation 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. --- config/dev-config.yaml | 4 + src/cmd/keycape/main.go | 18 +- src/internal/adapters/privacyidea/adapter.go | 10 + .../adapters/privacyidea/adapter_test.go | 42 +++ src/internal/config/config.go | 6 +- src/internal/config/config_test.go | 44 +++ src/internal/config/validate.go | 27 ++ src/internal/domain/assurance.go | 116 ++++++ src/internal/domain/assurance_test.go | 113 ++++++ src/internal/domain/mfa.go | 5 + src/internal/domain/model.go | 6 +- src/internal/server/oidc/authorize.go | 334 ++++++++++++++++-- src/internal/server/oidc/authorize_test.go | 6 + src/internal/server/oidc/discovery.go | 3 + src/internal/server/oidc/discovery_test.go | 27 ++ src/internal/server/oidc/handoff.go | 183 ++++++++++ src/internal/server/oidc/handoff_test.go | 274 ++++++++++++++ src/internal/server/oidc/login_session.go | 130 +++++++ src/internal/server/oidc/logout.go | 73 ++++ .../server/oidc/policy_isolation_test.go | 222 ++++++++++++ src/tests/profile/profile_test.go | 4 + ...istration-handoff-and-client-mfa-policy.md | 31 +- 22 files changed, 1636 insertions(+), 42 deletions(-) create mode 100644 src/internal/domain/assurance.go create mode 100644 src/internal/domain/assurance_test.go create mode 100644 src/internal/server/oidc/handoff.go create mode 100644 src/internal/server/oidc/handoff_test.go create mode 100644 src/internal/server/oidc/login_session.go create mode 100644 src/internal/server/oidc/logout.go create mode 100644 src/internal/server/oidc/policy_isolation_test.go diff --git a/config/dev-config.yaml b/config/dev-config.yaml index 8e7bc98..d5e991e 100644 --- a/config/dev-config.yaml +++ b/config/dev-config.yaml @@ -53,3 +53,7 @@ clients: allowedScopes: ["openid", "profile", "email", "groups"] grantTypes: ["authorization_code"] 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" diff --git a/src/cmd/keycape/main.go b/src/cmd/keycape/main.go index 5f7341a..b83f78a 100644 --- a/src/cmd/keycape/main.go +++ b/src/cmd/keycape/main.go @@ -131,21 +131,33 @@ func main() { TokenEndpoint: issuer + "/token", JWKSUri: issuer + "/jwks", UserinfoEndpoint: issuer + "/userinfo", + EndSessionEndpoint: issuer + "/logout", })) // JWKS. mux.Handle("/jwks", oidc.NewJWKSHandler(ks)) // Authorize handler (with enforcement middleware). + logins := oidc.NewLoginSessionStore() authorizeHandler := &oidc.AuthorizeHandler{ ClientConfig: clients, Auth: autheliaAdapter, MFA: privacyIDEAAdapter, Sessions: sessions, + Logins: logins, + Handoffs: oidc.NewHandoffStore(), + Issuer: issuer, Emitter: emitter, } mux.Handle("/authorize", enforcement.Middleware(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). tokenHandler := &oidc.TokenHandler{ @@ -272,8 +284,10 @@ func buildClientRegistry(cfgClients []config.ClientConfig) (map[string]*domain.C ClientSecret: clientSecret, ServiceSubject: c.ServiceSubject, Tenant: c.Tenant, - Roles: c.Roles, - MFARequired: c.MFARequired, + Roles: c.Roles, + MFARequired: c.MFARequired, + RegistrationURL: c.RegistrationURL, + EnrollmentURL: c.EnrollmentURL, } } return m, nil diff --git a/src/internal/adapters/privacyidea/adapter.go b/src/internal/adapters/privacyidea/adapter.go index cfdf4ce..c3618bc 100644 --- a/src/internal/adapters/privacyidea/adapter.go +++ b/src/internal/adapters/privacyidea/adapter.go @@ -41,7 +41,17 @@ func (a *PrivacyIDEAAdapter) CheckMFARequired(ctx context.Context, userID string if a.cfg.RequireForAll { 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/" q := url.Values{} diff --git a/src/internal/adapters/privacyidea/adapter_test.go b/src/internal/adapters/privacyidea/adapter_test.go index 99f07e1..f17359c 100644 --- a/src/internal/adapters/privacyidea/adapter_test.go +++ b/src/internal/adapters/privacyidea/adapter_test.go @@ -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) { client := &mockHTTPClient{ doFn: func(_ *http.Request) (*http.Response, error) { diff --git a/src/internal/config/config.go b/src/internal/config/config.go index 8f81bc5..87df11a 100644 --- a/src/internal/config/config.go +++ b/src/internal/config/config.go @@ -38,8 +38,10 @@ type ClientConfig struct { SecretRef string `yaml:"secretRef,omitempty"` ServiceSubject string `yaml:"serviceSubject,omitempty"` Tenant string `yaml:"tenant,omitempty"` - Roles []string `yaml:"roles,omitempty"` - MFARequired *bool `yaml:"mfaRequired,omitempty"` + Roles []string `yaml:"roles,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. diff --git a/src/internal/config/config_test.go b/src/internal/config/config_test.go index 73060c1..89c89b6 100644 --- a/src/internal/config/config_test.go +++ b/src/internal/config/config_test.go @@ -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) { keyPath := writeTempFile(t, "placeholder-key") yaml := ` diff --git a/src/internal/config/validate.go b/src/internal/config/validate.go index 1dda896..45437e2 100644 --- a/src/internal/config/validate.go +++ b/src/internal/config/validate.go @@ -63,6 +63,16 @@ func ValidateConfig(cfg *Config) []string { 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). @@ -81,3 +91,20 @@ func contains(values []string, wanted string) bool { } 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 +} diff --git a/src/internal/domain/assurance.go b/src/internal/domain/assurance.go new file mode 100644 index 0000000..afbc48c --- /dev/null +++ b/src/internal/domain/assurance.go @@ -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 +} diff --git a/src/internal/domain/assurance_test.go b/src/internal/domain/assurance_test.go new file mode 100644 index 0000000..b9096ef --- /dev/null +++ b/src/internal/domain/assurance_test.go @@ -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") + } +} diff --git a/src/internal/domain/mfa.go b/src/internal/domain/mfa.go index 0f71feb..30b0a92 100644 --- a/src/internal/domain/mfa.go +++ b/src/internal/domain/mfa.go @@ -11,6 +11,11 @@ type MFAProvider interface { // CheckMFARequired returns true if MFA is required for the given user. 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. // Returns ErrMFAFailed if the token is invalid or expired. ValidateMFAToken(ctx context.Context, userID, token string) error diff --git a/src/internal/domain/model.go b/src/internal/domain/model.go index d444d29..98787cd 100644 --- a/src/internal/domain/model.go +++ b/src/internal/domain/model.go @@ -52,8 +52,10 @@ type Client struct { ClientSecret string `yaml:"-" json:"-"` ServiceSubject string `yaml:"serviceSubject,omitempty" json:"serviceSubject,omitempty"` Tenant string `yaml:"tenant,omitempty" json:"tenant,omitempty"` - Roles []string `yaml:"roles,omitempty" json:"roles,omitempty"` - MFARequired *bool `yaml:"mfaRequired,omitempty" json:"mfaRequired,omitempty"` + Roles []string `yaml:"roles,omitempty" json:"roles,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. diff --git a/src/internal/server/oidc/authorize.go b/src/internal/server/oidc/authorize.go index affd423..5eced25 100644 --- a/src/internal/server/oidc/authorize.go +++ b/src/internal/server/oidc/authorize.go @@ -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(` `)) +var unknownUserTemplate = template.Must(template.New("unknown-user").Parse(` + + + + + KeyCape sign-in + + + +
+

Account not found

+

No KeyCape identity is available for this {{.ClientName}} sign-in. Create an account to continue. This does not issue a token.

+ Create account +
+ +`)) + +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 { diff --git a/src/internal/server/oidc/authorize_test.go b/src/internal/server/oidc/authorize_test.go index e1d6508..a01e91b 100644 --- a/src/internal/server/oidc/authorize_test.go +++ b/src/internal/server/oidc/authorize_test.go @@ -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 diff --git a/src/internal/server/oidc/discovery.go b/src/internal/server/oidc/discovery.go index 58b7cac..312c980 100644 --- a/src/internal/server/oidc/discovery.go +++ b/src/internal/server/oidc/discovery.go @@ -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"}, diff --git a/src/internal/server/oidc/discovery_test.go b/src/internal/server/oidc/discovery_test.go index d984a9c..3a0807c 100644 --- a/src/internal/server/oidc/discovery_test.go +++ b/src/internal/server/oidc/discovery_test.go @@ -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", diff --git a/src/internal/server/oidc/handoff.go b/src/internal/server/oidc/handoff.go new file mode 100644 index 0000000..31954ca --- /dev/null +++ b/src/internal/server/oidc/handoff.go @@ -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 +} diff --git a/src/internal/server/oidc/handoff_test.go b/src/internal/server/oidc/handoff_test.go new file mode 100644 index 0000000..d8a33fb --- /dev/null +++ b/src/internal/server/oidc/handoff_test.go @@ -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 +} diff --git a/src/internal/server/oidc/login_session.go b/src/internal/server/oidc/login_session.go new file mode 100644 index 0000000..7953f06 --- /dev/null +++ b/src/internal/server/oidc/login_session.go @@ -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, + }) +} diff --git a/src/internal/server/oidc/logout.go b/src/internal/server/oidc/logout.go new file mode 100644 index 0000000..496d237 --- /dev/null +++ b/src/internal/server/oidc/logout.go @@ -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://") +} diff --git a/src/internal/server/oidc/policy_isolation_test.go b/src/internal/server/oidc/policy_isolation_test.go new file mode 100644 index 0000000..221ed02 --- /dev/null +++ b/src/internal/server/oidc/policy_isolation_test.go @@ -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()) + } +} diff --git a/src/tests/profile/profile_test.go b/src/tests/profile/profile_test.go index 306ba74..d3cb519 100644 --- a/src/tests/profile/profile_test.go +++ b/src/tests/profile/profile_test.go @@ -64,6 +64,10 @@ func (m *mockMFA) CheckMFARequired(_ context.Context, _ string) (bool, error) { 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 { return m.mfaErr } diff --git a/workplans/KEY-WP-0008-registration-handoff-and-client-mfa-policy.md b/workplans/KEY-WP-0008-registration-handoff-and-client-mfa-policy.md index 77a014c..2279d73 100644 --- a/workplans/KEY-WP-0008-registration-handoff-and-client-mfa-policy.md +++ b/workplans/KEY-WP-0008-registration-handoff-and-client-mfa-policy.md @@ -4,11 +4,11 @@ type: workplan title: "Registration handoff and client-aware MFA policy" domain: infotech repo: key-cape -status: active -owner: codex +status: finished +owner: grok topic_slug: netkingdom created: "2026-08-09" -updated: "2026-08-09" +updated: "2026-08-16" depends_on: - NK-WP-0025 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 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 ```task id: KEY-WP-0008-T02 -status: progress +status: done priority: high 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 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 ```task @@ -83,7 +96,7 @@ and `mfa: true` only after successful verification. ```task id: KEY-WP-0008-T04 -status: todo +status: done priority: high 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 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`.