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

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

View file

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