Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions pkg/authn/middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ func TestAuthN_JWKSRejectsMismatchedJWKAlg(t *testing.T) {
}
}

func TestAuthN_RejectsMissingTokenSmithClaims(t *testing.T) {
func TestAuthN_AcceptsStandardOIDCClaimsOnly(t *testing.T) {
priv, _ := rsa.GenerateKey(rand.Reader, 2048)

claims := jwt.MapClaims{
Expand Down Expand Up @@ -331,12 +331,17 @@ func TestAuthN_RejectsMissingTokenSmithClaims(t *testing.T) {
req.Header.Set("Authorization", "Bearer "+s)
rr := httptest.NewRecorder()

nextCalled := false
h := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("next should not be called")
nextCalled = true
w.WriteHeader(http.StatusOK)
}))
h.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", rr.Code)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
if !nextCalled {
t.Fatal("next handler should be called for valid standard OIDC claims")
}
}

Expand Down
24 changes: 3 additions & 21 deletions pkg/token/claims.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,41 +207,23 @@ func (c *TSClaims) ValidateAt(enforce bool, now time.Time) error {
logs = append(logs, "Missing audience claim")
}

// NIST SP 800-63B requirements
// Optional NIST SP 800-63B requirements
if c.AuthLevel == "" {
if enforce {
return errors.New("auth_level claim is required")
}
logs = append(logs, "Missing auth_level claim")
}
if c.AuthFactors < 2 {
if enforce {
return errors.New("at least 2 authentication factors are required")
}
logs = append(logs, "Less than 2 authentication factors")
if c.AuthFactors == 0 {
logs = append(logs, "No authentication factors specified")
}
if len(c.AuthMethods) == 0 {
if enforce {
return errors.New("auth_methods claim is required")
}
logs = append(logs, "Missing auth_methods claim")
}
if c.SessionID == "" {
if enforce {
return errors.New("session_id claim is required")
}
logs = append(logs, "Missing session_id claim")
}
if c.SessionExp == 0 {
if enforce {
return errors.New("session_exp claim is required")
}
logs = append(logs, "Missing session_exp claim")
}
if len(c.AuthEvents) == 0 {
if enforce {
return errors.New("auth_events claim is required")
}
logs = append(logs, "Missing auth_events claim")
}
iat := int64(0)
Expand Down
70 changes: 53 additions & 17 deletions pkg/tokenservice/exchange.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,46 +70,63 @@ func (s *TokenService) ExchangeToken(ctx context.Context, idtoken string) (strin
claims.EmailVerified = emailVerified
}

// Extract standard OIDC MFA claims (amr, acr, auth_time)
// Per OIDC Core 1.0 Section 2: Authentication Methods Reference (amr)
amr := extractStringArrayFromClaims(introspection.Claims, "amr")
if len(amr) > 0 {
claims.AMR = amr
}

// Authentication Context Class Reference (acr)
if acr, ok := introspection.Claims["acr"].(string); ok && acr != "" {
claims.ACR = acr
}

// Authentication time (auth_time) - when user actually authenticated
if authTime, ok := introspection.Claims["auth_time"].(float64); ok {
claims.AuthTime = int64(authTime)
}

// Extract custom NIST claims (optional for backward compatibility)
// If present, use them; otherwise derive from standard OIDC claims
if authLevel, ok := introspection.Claims["auth_level"].(string); ok {
claims.AuthLevel = authLevel
} else {
return "", fmt.Errorf("missing required claim: auth_level")
}

// Derive AuthFactors from AMR if not explicitly provided
if authFactors, ok := introspection.Claims["auth_factors"].(float64); ok {
claims.AuthFactors = int(authFactors)
} else if _, exists := introspection.Claims["auth_factors"]; !exists {
return "", fmt.Errorf("missing required claim: auth_factors")
} else {
return "", fmt.Errorf("invalid type for claim auth_factors: expected number")
} else if len(amr) > 0 {
// Derive auth factors by counting distinct factor categories from AMR
// Per NIST SP 800-63B: knowledge (pwd), possession (otp, sms), inherence (bio, fido2)
claims.AuthFactors = deriveAuthFactorsFromAMR(amr)
}

// Extract auth_methods from custom claim or map from AMR for backward compatibility
authMethods := extractStringArrayFromClaims(introspection.Claims, "auth_methods")
if len(authMethods) == 0 {
return "", fmt.Errorf("missing required claim: auth_methods")
if len(authMethods) > 0 {
claims.AuthMethods = authMethods
} else if len(amr) > 0 {
// Map AMR to AuthMethods for backward compatibility
claims.AuthMethods = amr
}
claims.AuthMethods = authMethods

// Extract custom session claims (optional)
if sessionID, ok := introspection.Claims["session_id"].(string); ok {
claims.SessionID = sessionID
} else {
return "", fmt.Errorf("missing required claim: session_id")
}
if sessionExp, ok := introspection.Claims["session_exp"].(float64); ok {
claims.SessionExp = int64(sessionExp)
} else if _, exists := introspection.Claims["session_exp"]; !exists {
return "", fmt.Errorf("missing required claim: session_exp")
} else {
return "", fmt.Errorf("invalid type for claim session_exp: expected number")
}

// Extract auth_events (optional)
if authEvents, ok := introspection.Claims["auth_events"].([]interface{}); ok {
claims.AuthEvents = make([]string, len(authEvents))
for index, value := range authEvents {
if authEvent, ok := value.(string); ok {
claims.AuthEvents[index] = authEvent
}
}
} else {
return "", fmt.Errorf("missing required claim: auth_events")
}

if groupsRaw, ok := introspection.Claims["groups"]; ok {
Expand Down Expand Up @@ -171,3 +188,22 @@ func extractStringArrayFromClaims(claims map[string]interface{}, key string) []s

return strings
}

func deriveAuthFactorsFromAMR(amr []string) int {
factorCategories := make(map[string]bool)

for _, method := range amr {
switch method {
case "pwd", "pin", "kba":
factorCategories["knowledge"] = true
case "otp", "sms", "hwk", "swk":
factorCategories["possession"] = true
case "bio", "fido2", "fido", "face", "fpt", "iris", "retina", "vbm":
factorCategories["inherence"] = true
default:
factorCategories["other"] = true
}
}

return len(factorCategories)
}
Loading
Loading