diff --git a/pkg/authn/middleware_test.go b/pkg/authn/middleware_test.go index 172ddaa..6323f5b 100644 --- a/pkg/authn/middleware_test.go +++ b/pkg/authn/middleware_test.go @@ -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{ @@ -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") } } diff --git a/pkg/token/claims.go b/pkg/token/claims.go index 06eebeb..aacd78d 100644 --- a/pkg/token/claims.go +++ b/pkg/token/claims.go @@ -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) diff --git a/pkg/tokenservice/exchange.go b/pkg/tokenservice/exchange.go index 4d78067..3740b11 100644 --- a/pkg/tokenservice/exchange.go +++ b/pkg/tokenservice/exchange.go @@ -70,37 +70,56 @@ 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 { @@ -108,8 +127,6 @@ func (s *TokenService) ExchangeToken(ctx context.Context, idtoken string) (strin claims.AuthEvents[index] = authEvent } } - } else { - return "", fmt.Errorf("missing required claim: auth_events") } if groupsRaw, ok := introspection.Claims["groups"]; ok { @@ -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) +} diff --git a/pkg/tokenservice/exchange_oidc_mfa_test.go b/pkg/tokenservice/exchange_oidc_mfa_test.go new file mode 100644 index 0000000..24b4e47 --- /dev/null +++ b/pkg/tokenservice/exchange_oidc_mfa_test.go @@ -0,0 +1,318 @@ +// Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC +// +// SPDX-License-Identifier: MIT + +package tokenservice + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "testing" + "time" + + "github.com/openchami/tokensmith/pkg/keys" + "github.com/openchami/tokensmith/pkg/oidc" + "github.com/openchami/tokensmith/pkg/token" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_ExchangeToken_OIDCMFAClaims(t *testing.T) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + keyManager := keys.NewKeyManager() + err = keyManager.SetKeyPair(privateKey, &privateKey.PublicKey) + require.NoError(t, err) + + tokenManager := token.NewTokenManager(keyManager, "test-issuer", "test-cluster", "test-openchami", true) + + t.Run("Extract AMR from OIDC introspection", func(t *testing.T) { + mockProvider := oidc.NewMockProvider() + mockProvider.IntrospectTokenFunc = func(ctx context.Context, token string) (*oidc.IntrospectionResponse, error) { + now := time.Now() + return &oidc.IntrospectionResponse{ + Active: true, + Username: "mfa-user", + ExpiresAt: now.Add(time.Hour).Unix(), + IssuedAt: now.Unix(), + Claims: map[string]interface{}{ + "sub": "mfa-user", + "aud": []interface{}{"test-audience"}, + "amr": []interface{}{"pwd", "otp"}, + "auth_time": float64(now.Unix()), + }, + TokenType: "Bearer", + }, nil + } + + service := &TokenService{ + TokenManager: tokenManager, + Issuer: "test-issuer", + ClusterID: "test-cluster", + OpenCHAMIID: "test-openchami", + OIDCProvider: mockProvider, + } + + jwtToken, err := service.ExchangeToken(context.Background(), "test-token") + require.NoError(t, err) + require.NotEmpty(t, jwtToken) + + claims, _, err := tokenManager.ParseToken(jwtToken) + require.NoError(t, err) + assert.Equal(t, []string{"pwd", "otp"}, claims.AMR) + assert.Equal(t, 2, claims.AuthFactors, "Should derive 2 factors from pwd (knowledge) + otp (possession)") + assert.Equal(t, []string{"pwd", "otp"}, claims.AuthMethods, "Should map AMR to AuthMethods for backward compatibility") + }) + + t.Run("Extract ACR from OIDC introspection", func(t *testing.T) { + mockProvider := oidc.NewMockProvider() + mockProvider.IntrospectTokenFunc = func(ctx context.Context, token string) (*oidc.IntrospectionResponse, error) { + now := time.Now() + return &oidc.IntrospectionResponse{ + Active: true, + Username: "acr-user", + ExpiresAt: now.Add(time.Hour).Unix(), + IssuedAt: now.Unix(), + Claims: map[string]interface{}{ + "sub": "acr-user", + "aud": []interface{}{"test-audience"}, + "acr": "urn:mfa:required", + "amr": []interface{}{"pwd", "fido2"}, + }, + TokenType: "Bearer", + }, nil + } + + service := &TokenService{ + TokenManager: tokenManager, + Issuer: "test-issuer", + ClusterID: "test-cluster", + OpenCHAMIID: "test-openchami", + OIDCProvider: mockProvider, + } + + jwtToken, err := service.ExchangeToken(context.Background(), "test-token") + require.NoError(t, err) + + claims, _, err := tokenManager.ParseToken(jwtToken) + require.NoError(t, err) + assert.Equal(t, "urn:mfa:required", claims.ACR) + }) + + t.Run("Extract auth_time from OIDC introspection", func(t *testing.T) { + mockProvider := oidc.NewMockProvider() + authTime := time.Now().Add(-30 * time.Minute).Unix() + mockProvider.IntrospectTokenFunc = func(ctx context.Context, token string) (*oidc.IntrospectionResponse, error) { + now := time.Now() + return &oidc.IntrospectionResponse{ + Active: true, + Username: "auth-time-user", + ExpiresAt: now.Add(time.Hour).Unix(), + IssuedAt: now.Unix(), + Claims: map[string]interface{}{ + "sub": "auth-time-user", + "aud": []interface{}{"test-audience"}, + "auth_time": float64(authTime), + "amr": []interface{}{"pwd"}, + }, + TokenType: "Bearer", + }, nil + } + + service := &TokenService{ + TokenManager: tokenManager, + Issuer: "test-issuer", + ClusterID: "test-cluster", + OpenCHAMIID: "test-openchami", + OIDCProvider: mockProvider, + } + + jwtToken, err := service.ExchangeToken(context.Background(), "test-token") + require.NoError(t, err) + + claims, _, err := tokenManager.ParseToken(jwtToken) + require.NoError(t, err) + assert.Equal(t, authTime, claims.AuthTime) + }) + + t.Run("Derive AuthFactors from AMR - multiple factor categories", func(t *testing.T) { + mockProvider := oidc.NewMockProvider() + mockProvider.IntrospectTokenFunc = func(ctx context.Context, token string) (*oidc.IntrospectionResponse, error) { + now := time.Now() + return &oidc.IntrospectionResponse{ + Active: true, + Username: "multi-factor-user", + ExpiresAt: now.Add(time.Hour).Unix(), + IssuedAt: now.Unix(), + Claims: map[string]interface{}{ + "sub": "multi-factor-user", + "aud": []interface{}{"test-audience"}, + "amr": []interface{}{"pwd", "sms", "fido2"}, + }, + TokenType: "Bearer", + }, nil + } + + service := &TokenService{ + TokenManager: tokenManager, + Issuer: "test-issuer", + ClusterID: "test-cluster", + OpenCHAMIID: "test-openchami", + OIDCProvider: mockProvider, + } + + jwtToken, err := service.ExchangeToken(context.Background(), "test-token") + require.NoError(t, err) + + claims, _, err := tokenManager.ParseToken(jwtToken) + require.NoError(t, err) + assert.Equal(t, 3, claims.AuthFactors, "pwd=knowledge, sms=possession, fido2=inherence = 3 factors") + assert.Equal(t, []string{"pwd", "sms", "fido2"}, claims.AMR) + }) + + t.Run("Backward compatibility - custom NIST claims still work", func(t *testing.T) { + mockProvider := oidc.NewMockProvider() + mockProvider.IntrospectTokenFunc = func(ctx context.Context, token string) (*oidc.IntrospectionResponse, error) { + now := time.Now() + return &oidc.IntrospectionResponse{ + Active: true, + Username: "nist-user", + ExpiresAt: now.Add(time.Hour).Unix(), + IssuedAt: now.Unix(), + Claims: map[string]interface{}{ + "sub": "nist-user", + "aud": []interface{}{"test-audience"}, + "auth_level": "IAL2", + "auth_factors": float64(2), + "auth_methods": []interface{}{"password", "webauthn"}, + "amr": []interface{}{"pwd", "fido2"}, + "acr": "urn:mfa:required", + "auth_time": float64(now.Unix()), + }, + TokenType: "Bearer", + }, nil + } + + service := &TokenService{ + TokenManager: tokenManager, + Issuer: "test-issuer", + ClusterID: "test-cluster", + OpenCHAMIID: "test-openchami", + OIDCProvider: mockProvider, + } + + jwtToken, err := service.ExchangeToken(context.Background(), "test-token") + require.NoError(t, err) + + claims, _, err := tokenManager.ParseToken(jwtToken) + require.NoError(t, err) + + assert.Equal(t, "IAL2", claims.AuthLevel, "Custom auth_level preserved") + assert.Equal(t, 2, claims.AuthFactors, "Custom auth_factors takes precedence") + assert.Equal(t, []string{"password", "webauthn"}, claims.AuthMethods, "Custom auth_methods takes precedence") + + assert.Equal(t, []string{"pwd", "fido2"}, claims.AMR, "OIDC AMR also populated") + assert.Equal(t, "urn:mfa:required", claims.ACR, "OIDC ACR also populated") + assert.NotZero(t, claims.AuthTime, "OIDC auth_time also populated") + }) + + t.Run("OIDC-only claims work without custom NIST claims", func(t *testing.T) { + mockProvider := oidc.NewMockProvider() + mockProvider.IntrospectTokenFunc = func(ctx context.Context, token string) (*oidc.IntrospectionResponse, error) { + now := time.Now() + return &oidc.IntrospectionResponse{ + Active: true, + Username: "oidc-only-user", + ExpiresAt: now.Add(time.Hour).Unix(), + IssuedAt: now.Unix(), + Claims: map[string]interface{}{ + "sub": "oidc-only-user", + "aud": []interface{}{"test-audience"}, + "amr": []interface{}{"pwd", "otp"}, + "acr": "AAL2", + "auth_time": float64(now.Unix()), + }, + TokenType: "Bearer", + }, nil + } + + service := &TokenService{ + TokenManager: tokenManager, + Issuer: "test-issuer", + ClusterID: "test-cluster", + OpenCHAMIID: "test-openchami", + OIDCProvider: mockProvider, + } + + jwtToken, err := service.ExchangeToken(context.Background(), "test-token") + require.NoError(t, err, "Should succeed with OIDC-only claims") + + claims, _, err := tokenManager.ParseToken(jwtToken) + require.NoError(t, err) + + assert.Equal(t, []string{"pwd", "otp"}, claims.AMR) + assert.Equal(t, "AAL2", claims.ACR) + assert.NotZero(t, claims.AuthTime) + assert.Equal(t, 2, claims.AuthFactors, "Derived from AMR") + assert.Equal(t, []string{"pwd", "otp"}, claims.AuthMethods, "Mapped from AMR") + assert.Empty(t, claims.AuthLevel, "Not provided, not required") + }) +} + +func Test_deriveAuthFactorsFromAMR(t *testing.T) { + tests := []struct { + name string + amr []string + expected int + }{ + { + name: "Single factor - password only", + amr: []string{"pwd"}, + expected: 1, + }, + { + name: "Two factors - password + OTP", + amr: []string{"pwd", "otp"}, + expected: 2, + }, + { + name: "Three factors - knowledge + possession + inherence", + amr: []string{"pwd", "sms", "fido2"}, + expected: 3, + }, + { + name: "Multiple methods in same category count as one", + amr: []string{"pwd", "pin", "kba"}, + expected: 1, + }, + { + name: "FIDO2 alone", + amr: []string{"fido2"}, + expected: 1, + }, + { + name: "Biometric + password", + amr: []string{"bio", "pwd"}, + expected: 2, + }, + { + name: "Unknown method", + amr: []string{"custom-method"}, + expected: 1, + }, + { + name: "Empty AMR", + amr: []string{}, + expected: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := deriveAuthFactorsFromAMR(tt.amr) + assert.Equal(t, tt.expected, result) + }) + } +}