ref!(auth/jwt): variadic requiredScopes in NewAccessTokenValidator

Distinguishes the two validator constructors by signature:
- NewIDTokenValidator(iss, aud, azp []string) — allowlist semantics
- NewAccessTokenValidator(iss, aud []string, requiredScopes ...string) — requirement semantics

Variadic scopes read naturally at the call site:
  NewAccessTokenValidator(issuers, audiences, "openid", "profile")

Three-state semantics preserved:
  no args        → scope not checked
  []string{}...  → scope must be present (any value)
  "openid", ...  → scope must contain all listed values

Also removes the old gracePeriod parameter from both constructors
(was 0 at all call sites; set GracePeriod on the struct directly
if a non-default value is needed).

Adds TestCov_NewAccessTokenValidator_Scopes covering all three cases.
This commit is contained in:
AJ ONeal 2026-03-17 08:00:45 -06:00
parent 26bdc0a3db
commit 0d99234914
No known key found for this signature in database
3 changed files with 77 additions and 9 deletions

View File

@ -1924,7 +1924,7 @@ func TestCov_NewIDTokenValidator(t *testing.T) {
} }
func TestCov_NewAccessTokenValidator(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 { if v.Checks&ChecksConfigured == 0 {
t.Fatal("expected ChecksConfigured") t.Fatal("expected ChecksConfigured")
} }
@ -1932,12 +1932,76 @@ func TestCov_NewAccessTokenValidator(t *testing.T) {
t.Fatal("expected CheckJTI and CheckClientID for access token") t.Fatal("expected CheckJTI and CheckClientID for access token")
} }
v2 := NewAccessTokenValidator(nil, nil, nil) v2 := NewAccessTokenValidator(nil, nil)
if v2.Checks&CheckIss != 0 { if v2.Checks&CheckIss != 0 {
t.Fatal("expected no CheckIss for nil iss") 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) { func TestCov_Validate_Unconfigured(t *testing.T) {
v := &Validator{} // zero value v := &Validator{} // zero value
err := v.Validate(nil, goodClaims(), testNow) err := v.Validate(nil, goodClaims(), testNow)

View File

@ -82,7 +82,7 @@
// use [NewAccessTokenValidator] with [TokenClaims] (which includes the // use [NewAccessTokenValidator] with [TokenClaims] (which includes the
// client_id and scope fields): // 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 { /* ... */ } // if err := v.Validate(nil, &claims, time.Now()); err != nil { /* ... */ }
// //
// - [NewAccessToken] creates a JWS with the correct "at+jwt" typ header // - [NewAccessToken] creates a JWS with the correct "at+jwt" typ header

View File

@ -335,12 +335,16 @@ func NewIDTokenValidator(iss, aud, azp []string) *Validator {
// Pass the allowed issuers and audiences, or nil to skip that check. // Pass the allowed issuers and audiences, or nil to skip that check.
// Use []string{"*"} to require the claim be present without restricting its value. // 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. // Checks enabled by default: iss, exp, aud, sub, client_id, iat, jti, and scope.
// Not checked: nbf, auth_time, and, azp. // requiredScopes controls scope validation:
// Populate RequiredScopes to enforce specific scope values (overrides CheckScope). // - 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 // 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 checks := ChecksConfigured | CheckSub | CheckExp | CheckIAt | CheckJTI | CheckClientID
if iss != nil { if iss != nil {
checks |= CheckIss checks |= CheckIss
@ -348,7 +352,7 @@ func NewAccessTokenValidator(iss, aud, scopes []string) *Validator {
if aud != nil { if aud != nil {
checks |= CheckAud checks |= CheckAud
} }
if scopes != nil { if requiredScopes != nil {
checks |= CheckScope checks |= CheckScope
} }
return &Validator{ return &Validator{
@ -356,7 +360,7 @@ func NewAccessTokenValidator(iss, aud, scopes []string) *Validator {
GracePeriod: defaultGracePeriod, GracePeriod: defaultGracePeriod,
Iss: iss, Iss: iss,
Aud: aud, Aud: aud,
RequiredScopes: scopes, RequiredScopes: requiredScopes,
} }
} }