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
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:
parent
fff9e39478
commit
b6af6c5268
22 changed files with 1636 additions and 42 deletions
116
src/internal/domain/assurance.go
Normal file
116
src/internal/domain/assurance.go
Normal 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
|
||||
}
|
||||
113
src/internal/domain/assurance_test.go
Normal file
113
src/internal/domain/assurance_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue