diff --git a/auth/jwt/coverage_test.go b/auth/jwt/coverage_test.go index 4ad9b64..059491e 100644 --- a/auth/jwt/coverage_test.go +++ b/auth/jwt/coverage_test.go @@ -1924,7 +1924,7 @@ func TestCov_NewIDTokenValidator(t *testing.T) { } func TestCov_NewAccessTokenValidator(t *testing.T) { - v := NewAccessTokenValidator([]string{"iss"}, []string{"aud"}, nil) + v := NewAccessTokenValidator([]string{"iss"}, []string{"aud"}) if v.Checks&ChecksConfigured == 0 { t.Fatal("expected ChecksConfigured") } @@ -1932,12 +1932,76 @@ func TestCov_NewAccessTokenValidator(t *testing.T) { t.Fatal("expected CheckJTI and CheckClientID for access token") } - v2 := NewAccessTokenValidator(nil, nil, nil) + v2 := NewAccessTokenValidator(nil, nil) if v2.Checks&CheckIss != 0 { t.Fatal("expected no CheckIss for nil iss") } } +func TestCov_NewAccessTokenValidator_Scopes(t *testing.T) { + iss := []string{"https://example.com"} + aud := []string{"https://api.example.com"} + + t.Run("nil_no_scope_check", func(t *testing.T) { + // No scope args: CheckScope not set, scope claim not validated. + v := NewAccessTokenValidator(iss, aud) + if v.Checks&CheckScope != 0 { + t.Fatal("expected CheckScope not set for nil scopes") + } + if v.RequiredScopes != nil { + t.Fatal("expected nil RequiredScopes") + } + // Validate passes even with no scope claim. + claims := goodClaims() + claims.Scope = nil + claims.JTI = "jti-x" + if err := v.Validate(nil, claims, testNow); err != nil { + t.Fatalf("expected no error without scope check, got %v", err) + } + }) + + t.Run("empty_presence_only", func(t *testing.T) { + // Empty spread: CheckScope set, any non-empty scope accepted. + v := NewAccessTokenValidator(iss, aud, []string{}...) + if v.Checks&CheckScope == 0 { + t.Fatal("expected CheckScope set for empty non-nil scopes") + } + if v.RequiredScopes == nil { + t.Fatal("expected non-nil RequiredScopes") + } + // Validate passes when scope is present. + if err := v.Validate(nil, goodClaims(), testNow); err != nil { + t.Fatalf("expected no error with scope present, got %v", err) + } + // Validate fails when scope is absent. + claims := goodClaims() + claims.Scope = nil + err := v.Validate(nil, claims, testNow) + if !errors.Is(err, ErrMissingClaim) { + t.Fatalf("expected ErrMissingClaim for absent scope, got %v", err) + } + }) + + t.Run("specific_scope", func(t *testing.T) { + // Specific scope: CheckScope set, token must contain "openid". + v := NewAccessTokenValidator(iss, aud, "openid") + if v.Checks&CheckScope == 0 { + t.Fatal("expected CheckScope set") + } + // Validate passes when scope contains "openid". + if err := v.Validate(nil, goodClaims(), testNow); err != nil { + t.Fatalf("expected no error, got %v", err) + } + // Validate fails when "openid" is absent from scope. + claims := goodClaims() + claims.Scope = SpaceDelimited{"profile"} + err := v.Validate(nil, claims, testNow) + if !errors.Is(err, ErrInsufficientScope) { + t.Fatalf("expected ErrInsufficientScope, got %v", err) + } + }) +} + func TestCov_Validate_Unconfigured(t *testing.T) { v := &Validator{} // zero value err := v.Validate(nil, goodClaims(), testNow) diff --git a/auth/jwt/doc.go b/auth/jwt/doc.go index a45abff..0ecc328 100644 --- a/auth/jwt/doc.go +++ b/auth/jwt/doc.go @@ -82,7 +82,7 @@ // use [NewAccessTokenValidator] with [TokenClaims] (which includes the // client_id and scope fields): // -// v := jwt.NewAccessTokenValidator(issuers, audiences, relyingParties) +// v := jwt.NewAccessTokenValidator(issuers, audiences, "openid", "profile") // if err := v.Validate(nil, &claims, time.Now()); err != nil { /* ... */ } // // - [NewAccessToken] creates a JWS with the correct "at+jwt" typ header diff --git a/auth/jwt/validate.go b/auth/jwt/validate.go index f24c675..0acad90 100644 --- a/auth/jwt/validate.go +++ b/auth/jwt/validate.go @@ -335,12 +335,16 @@ func NewIDTokenValidator(iss, aud, azp []string) *Validator { // Pass the allowed issuers and audiences, or nil to skip that check. // Use []string{"*"} to require the claim be present without restricting its value. // -// Checks enabled by default: iss, exp, aud, sub, client_id, iat, jti. and scope. -// Not checked: nbf, auth_time, and, azp. -// Populate RequiredScopes to enforce specific scope values (overrides CheckScope). +// Checks enabled by default: iss, exp, aud, sub, client_id, iat, jti, and scope. +// requiredScopes controls scope validation: +// - no args: scope not checked +// - []string{}...: scope must be present (any value accepted) +// - "openid", "profile", ...: scope must contain all listed values +// +// Not checked: nbf, auth_time, azp. // // https://www.rfc-editor.org/rfc/rfc9068.html#section-2.2 -func NewAccessTokenValidator(iss, aud, scopes []string) *Validator { +func NewAccessTokenValidator(iss, aud []string, requiredScopes ...string) *Validator { checks := ChecksConfigured | CheckSub | CheckExp | CheckIAt | CheckJTI | CheckClientID if iss != nil { checks |= CheckIss @@ -348,7 +352,7 @@ func NewAccessTokenValidator(iss, aud, scopes []string) *Validator { if aud != nil { checks |= CheckAud } - if scopes != nil { + if requiredScopes != nil { checks |= CheckScope } return &Validator{ @@ -356,7 +360,7 @@ func NewAccessTokenValidator(iss, aud, scopes []string) *Validator { GracePeriod: defaultGracePeriod, Iss: iss, Aud: aud, - RequiredScopes: scopes, + RequiredScopes: requiredScopes, } }