golib/auth/jwt/validate.go
AJ ONeal 0d99234914
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.
2026-03-17 08:00:45 -06:00

658 lines
24 KiB
Go

// Copyright 2026 AJ ONeal <aj@therootcompany.com> (https://therootcompany.com)
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
//
// SPDX-License-Identifier: MPL-2.0
package jwt
import (
"errors"
"fmt"
"slices"
"strings"
"time"
)
// ValidationError represents a single claim validation failure with a
// machine-readable code suitable for API responses.
//
// Code values and their meanings:
//
// token_expired - exp claim is in the past
// token_not_yet_valid - nbf claim is in the future
// future_issued_at - iat claim is in the future
// future_auth_time - auth_time claim is in the future
// auth_time_exceeded - auth_time exceeds max age
// insufficient_scope - required scopes not granted
// missing_claim - a required claim is absent
// invalid_claim - a claim value is wrong (bad iss, aud, etc.)
// server_error - server-side validator config error (treat as 500)
// unknown_error - unrecognized sentinel (should not occur)
//
// ValidationError satisfies [error] and supports [errors.Is] via [Unwrap]
// against the underlying sentinel (e.g., [ErrAfterExp], [ErrMissingClaim]).
//
// JSON serialization produces {"code": "...", "description": "..."}
// for direct use in API error responses.
//
// Use [ValidationErrors] to extract these from the error returned by
// [Validator.Validate]:
//
// err := v.Validate(nil, &claims, time.Now())
// for _, ve := range jwt.ValidationErrors(err) {
// log.Printf("code=%s: %s", ve.Code, ve.Description)
// }
type ValidationError struct {
Code string `json:"code"` // machine-readable code (see table above)
Description string `json:"description"` // human-readable detail, prefixed with claim name
Err error `json:"-"` // sentinel for errors.Is / Unwrap
}
// Error implements [error]. Returns the human-readable description.
func (e *ValidationError) Error() string { return e.Description }
// Unwrap returns the underlying sentinel error for use with [errors.Is].
func (e *ValidationError) Unwrap() error { return e.Err }
// ValidationErrors extracts structured [*ValidationError] values from the
// error returned by [Validator.Validate] or [TokenClaims.Errors].
//
// Non-ValidationError entries (such as the server-time context line) are
// skipped. Returns nil if err is nil or contains no ValidationError values.
func ValidationErrors(err error) []*ValidationError {
if err == nil {
return nil
}
var errs []error
if joined, ok := err.(interface{ Unwrap() []error }); ok {
errs = joined.Unwrap()
} else {
errs = []error{err}
}
result := make([]*ValidationError, 0, len(errs))
for _, e := range errs {
if ve, ok := e.(*ValidationError); ok {
result = append(result, ve)
}
}
if len(result) == 0 {
return nil
}
return result
}
// GetOAuth2Error returns the OAuth 2.0 error code for the validation error
// returned by [Validator.Validate] or [TokenClaims.Errors].
//
// Returns one of:
//
// - "invalid_token" - the token is expired, malformed, or otherwise invalid
// - "insufficient_scope" - the token lacks required scopes
// - "server_error" - server-side misconfiguration (treat as HTTP 500)
//
// per RFC 6750 §3.1. When multiple validation failures exist, the most severe
// code wins (server_error > insufficient_scope > invalid_token).
//
// Returns "" if err is nil or contains no [*ValidationError] values.
// Use err.Error() for the human-readable description:
//
// err := v.Validate(nil, &claims, time.Now())
// if code := jwt.GetOAuth2Error(err); code != "" {
// vals := url.Values{"error": {code}, "error_description": {err.Error()}}
// http.Redirect(w, r, redirectURI+"?"+vals.Encode(), http.StatusFound)
// }
func GetOAuth2Error(err error) (oauth2Error string) {
ves := ValidationErrors(err)
if len(ves) == 0 {
return ""
}
// Pick the most severe OAuth code across all errors.
code := "invalid_token"
for _, ve := range ves {
switch {
case errors.Is(ve.Err, ErrMisconfigured):
code = "server_error"
case errors.Is(ve.Err, ErrInsufficientScope) && code != "server_error":
code = "insufficient_scope"
}
}
return code
}
// appendError constructs a [*ValidationError] and appends it to the slice.
// sentinel is the error for [errors.Is] matching; format and args produce the
// human-readable description (conventionally prefixed with the claim name,
// e.g. "exp: expired 5m ago").
func appendError(errs []error, sentinel error, format string, args ...any) []error {
return append(errs, &ValidationError{
Code: codeFor(sentinel),
Description: fmt.Sprintf(format, args...),
Err: sentinel,
})
}
// isTimeSentinel reports whether the sentinel is a time-related claim error.
func isTimeSentinel(sentinel error) bool {
return errors.Is(sentinel, ErrAfterExp) ||
errors.Is(sentinel, ErrBeforeNBf) ||
errors.Is(sentinel, ErrBeforeIAt) ||
errors.Is(sentinel, ErrBeforeAuthTime) ||
errors.Is(sentinel, ErrAfterAuthMaxAge)
}
// codeFor maps a sentinel error to a machine-readable code string.
func codeFor(sentinel error) string {
switch {
case errors.Is(sentinel, ErrAfterExp):
return "token_expired"
case errors.Is(sentinel, ErrBeforeNBf):
return "token_not_yet_valid"
case errors.Is(sentinel, ErrBeforeIAt):
return "future_issued_at"
case errors.Is(sentinel, ErrBeforeAuthTime):
return "future_auth_time"
case errors.Is(sentinel, ErrAfterAuthMaxAge):
return "auth_time_exceeded"
case errors.Is(sentinel, ErrInsufficientScope):
return "insufficient_scope"
case errors.Is(sentinel, ErrMissingClaim):
return "missing_claim"
case errors.Is(sentinel, ErrInvalidTyp):
return "invalid_typ"
case errors.Is(sentinel, ErrInvalidClaim):
return "invalid_claim"
case errors.Is(sentinel, ErrMisconfigured):
return "server_error"
default:
return "unknown_error"
}
}
// formatDuration formats a duration as a human-readable string with days,
// hours, minutes, seconds, and milliseconds.
func formatDuration(d time.Duration) string {
if d < 0 {
d = -d
}
days := int(d / (24 * time.Hour))
d -= time.Duration(days) * 24 * time.Hour
hours := int(d / time.Hour)
d -= time.Duration(hours) * time.Hour
minutes := int(d / time.Minute)
d -= time.Duration(minutes) * time.Minute
seconds := int(d / time.Second)
var parts []string
if days > 0 {
parts = append(parts, fmt.Sprintf("%dd", days))
}
if hours > 0 {
parts = append(parts, fmt.Sprintf("%dh", hours))
}
if minutes > 0 {
parts = append(parts, fmt.Sprintf("%dm", minutes))
}
if seconds > 0 {
parts = append(parts, fmt.Sprintf("%ds", seconds))
}
if len(parts) == 0 {
// Sub-second duration: fall back to milliseconds.
millis := int(d / time.Millisecond)
parts = append(parts, fmt.Sprintf("%dms", millis))
}
return strings.Join(parts, " ")
}
// defaultGracePeriod is the tolerance applied to exp, iat, and auth_time checks
// when Validator.GracePeriod is zero.
//
// It should be set to at least 2s in most cases to account for practical edge
// cases of corresponding systems having even a millisecond of clock skew between
// them and the offset of their respective implementations truncating, flooring,
// ceiling, or rounding seconds differently.
//
// For example: If 1.999 is truncated to 1 and 2.001 is ceiled to 3, then there
// is a 2 second difference.
//
// This will very rarely affect calculations on exp (and hopefully a client knows
// better than to ride the very millisecond of expiration), but it can very
// frequently affect calculations on iat and nbf on distributed production
// systems.
const defaultGracePeriod = 2 * time.Second
// Checks is a bitmask that selects which claim validations [Validator]
// performs. Combine with OR:
//
// v := &jwt.Validator{
// Checks: jwt.CheckIss | jwt.CheckExp,
// Iss: []string{"https://example.com"},
// }
//
// Use [NewIDTokenValidator] or [NewAccessTokenValidator] for sensible defaults.
type Checks uint32
const (
// ChecksConfigured is a sentinel bit that distinguishes a deliberately
// configured Checks value from the zero value. Constructors like
// [NewIDTokenValidator] set it automatically. Struct-literal users
// should include it so that [Validator.Validate] does not reject the
// Validator as unconfigured.
ChecksConfigured Checks = 1 << iota
CheckIss // validate issuer
CheckSub // validate subject presence
CheckAud // validate audience
CheckExp // validate expiration
CheckNBf // validate not-before
CheckIAt // validate issued-at is not in the future
CheckClientID // validate client_id presence
CheckJTI // validate jti presence
CheckAuthTime // validate auth_time
CheckAzP // validate authorized party
CheckScope // validate scope presence
)
// resolveSkew converts a GracePeriod configuration value to a skew duration.
// Zero means use [defaultGracePeriod]; negative means no tolerance.
func resolveSkew(gracePeriod time.Duration) time.Duration {
if gracePeriod == 0 {
return defaultGracePeriod
}
if gracePeriod < 0 {
return 0
}
return gracePeriod
}
// Validator checks JWT claims for both ID tokens and access tokens.
//
// Use [NewIDTokenValidator] or [NewAccessTokenValidator] to create one with
// sensible defaults for the token type. You can also construct a Validator
// literal with a custom [Checks] bitmask - but you must OR at least one
// Check* flag or set Iss/Aud/AzP/RequiredScopes/MaxAge, otherwise Validate
// returns a misconfiguration error (a zero-value Validator is never valid).
//
// Iss, Aud, and AzP distinguish nil from empty: nil means unconfigured
// (no check unless the corresponding Check* flag is set), a non-nil empty
// slice is always a misconfiguration error (the empty set allows nothing),
// and ["*"] accepts any non-empty value. A non-nil slice forces its check
// regardless of the Checks bitmask.
//
// GracePeriod is applied to exp, nbf, iat, and auth_time (including maxAge)
// to tolerate minor clock differences between distributed systems. If zero,
// the default grace period (2s) is used. Set to a negative value to disable
// skew tolerance entirely.
//
// Explicit configuration (non-nil Iss/Aud/AzP, non-empty RequiredScopes,
// MaxAge > 0) forces the corresponding check regardless of the Checks bitmask.
type Validator struct {
Checks Checks
GracePeriod time.Duration // 0 = default (2s); negative = no tolerance
MaxAge time.Duration
Iss []string // nil=unchecked, []=misconfigured, ["*"]=any, ["x"]=must match
Aud []string // nil=unchecked, []=misconfigured, ["*"]=any, ["x"]=must intersect
AzP []string // nil=unchecked, []=misconfigured, ["*"]=any, ["x"]=must match
RequiredScopes []string // all of these must appear in the token's scope
}
// NewIDTokenValidator returns a [Validator] configured for OIDC Core §2 ID Tokens.
//
// 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, sub, aud, exp, iat, auth_time, azp
// Not checked: amr, nonce, nbf, jti, client_id, and scope.
// Adjust by OR-ing or masking the returned Validator's Checks field.
//
// https://openid.net/specs/openid-connect-core-1_0.html#IDToken
func NewIDTokenValidator(iss, aud, azp []string) *Validator {
checks := ChecksConfigured | CheckSub | CheckExp | CheckIAt | CheckAuthTime
if iss != nil {
checks |= CheckIss
}
if aud != nil {
checks |= CheckAud
}
if azp != nil {
checks |= CheckAzP
}
return &Validator{
Checks: checks,
GracePeriod: defaultGracePeriod,
Iss: iss,
Aud: aud,
AzP: azp,
}
}
// NewAccessTokenValidator returns a [Validator] configured for OAuth 2.1 JWT
// access tokens per RFC 9068 §2.2.
//
// 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.
// 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 []string, requiredScopes ...string) *Validator {
checks := ChecksConfigured | CheckSub | CheckExp | CheckIAt | CheckJTI | CheckClientID
if iss != nil {
checks |= CheckIss
}
if aud != nil {
checks |= CheckAud
}
if requiredScopes != nil {
checks |= CheckScope
}
return &Validator{
Checks: checks,
GracePeriod: defaultGracePeriod,
Iss: iss,
Aud: aud,
RequiredScopes: requiredScopes,
}
}
// Validate checks JWT claims according to the configured [Checks] bitmask.
//
// Each Check* flag enables its check. Explicit configuration
// (non-nil Iss/Aud/AzP, non-empty RequiredScopes, MaxAge > 0) forces
// the corresponding check regardless of the Checks bitmask.
//
// The individual check methods on [TokenClaims] are exported so that custom
// validators can call them directly without going through Validate.
//
// The errs parameter lets callers thread in errors from earlier checks
// (e.g. [RFCHeader.IsAllowedTyp]) so that all findings appear in a single
// joined error. Pass nil when there are no prior errors.
//
// now is caller-supplied (not time.Now()) so that validation is
// deterministic and testable.
//
// Returns nil on success. On failure, the returned error is a joined
// error that supports [errors.Is] for individual sentinels (e.g.
// [ErrAfterExp], [ErrMissingClaim]). Use Unwrap() []error to iterate
// each finding.
func (v *Validator) Validate(errs []error, claims Claims, now time.Time) error {
tc := claims.GetTokenClaims()
// Detect unconfigured validator: no Check* flags and no explicit config.
if v.Checks == 0 && len(v.Iss) == 0 && len(v.Aud) == 0 && len(v.AzP) == 0 &&
len(v.RequiredScopes) == 0 && v.MaxAge == 0 {
return appendError(nil, ErrMisconfigured, "validator has no checks configured; use a constructor or set Check* flags")[0]
}
skew := resolveSkew(v.GracePeriod)
if v.Iss != nil || v.Checks&CheckIss != 0 {
errs = tc.IsAllowedIss(errs, v.Iss)
}
if v.Checks&CheckSub != 0 {
errs = tc.IsPresentSub(errs)
}
if v.Aud != nil || v.Checks&CheckAud != 0 {
errs = tc.HasAllowedAud(errs, v.Aud)
}
if v.Checks&CheckExp != 0 {
errs = tc.IsBeforeExp(errs, now, skew)
}
if v.Checks&CheckNBf != 0 {
errs = tc.IsAfterNBf(errs, now, skew)
}
if v.Checks&CheckIAt != 0 {
errs = tc.IsAfterIAt(errs, now, skew)
}
if v.Checks&CheckJTI != 0 {
errs = tc.IsPresentJTI(errs)
}
if v.MaxAge > 0 || v.Checks&CheckAuthTime != 0 {
errs = tc.IsValidAuthTime(errs, now, skew, v.MaxAge)
}
if v.AzP != nil || v.Checks&CheckAzP != 0 {
errs = tc.IsAllowedAzP(errs, v.AzP)
}
if v.Checks&CheckClientID != 0 {
errs = tc.IsPresentClientID(errs)
}
if len(v.RequiredScopes) > 0 || v.Checks&CheckScope != 0 {
errs = tc.ContainsScopes(errs, v.RequiredScopes)
}
if len(errs) > 0 {
// Annotate time-related errors with the server's clock for debugging.
serverTime := fmt.Sprintf("server time %s (%s)", now.Format("2006-01-02 15:04:05 MST"), time.Local)
for _, e := range errs {
if ve, ok := e.(*ValidationError); ok && isTimeSentinel(ve.Err) {
ve.Description = fmt.Sprintf("%s; %s", ve.Description, serverTime)
}
}
return errors.Join(errs...)
}
return nil
}
// --- Per-claim check methods on *TokenClaims ---
//
// These exported methods can be called directly by custom validators.
// Each method appends validation errors to the provided slice and returns it.
// The [Validator] decides which checks to call based on its [Checks] bitmask.
//
// Methods are named by assertion kind:
//
// - IsAllowed - value must appear in a configured list
// - HasAllowed - value must intersect a configured list
// - IsPresent - value must be non-empty
// - IsBefore - now must be before a time boundary
// - IsAfter - now must be after a time boundary
// - IsValid - composite check (presence + time bounds)
// - Contains - value must contain all required entries
// IsAllowedIss validates the issuer claim.
//
// Allowed semantics: nil = misconfigured (error), [] = misconfigured (error),
// ["*"] = any non-empty value, ["x","y"] = must match one.
//
// At the [Validator] level, passing nil Iss disables the issuer check
// entirely (the method is never called). Calling this method directly
// with nil is a misconfiguration error.
func (tc *TokenClaims) IsAllowedIss(errs []error, allowed []string) []error {
if allowed == nil {
return appendError(errs, ErrMisconfigured, "iss: issuer checking enabled but Iss is nil")
}
if len(allowed) == 0 {
return appendError(errs, ErrMisconfigured, "iss: non-nil empty Iss allows no issuers")
} else if tc.Iss == "" {
return appendError(errs, ErrMissingClaim, "iss: missing required claim")
} else if !slices.Contains(allowed, "*") && !slices.Contains(allowed, tc.Iss) {
return appendError(errs, ErrInvalidClaim, "iss %q not in allowed list", tc.Iss)
}
return errs
}
// IsPresentSub validates that the subject claim is present.
func (tc *TokenClaims) IsPresentSub(errs []error) []error {
if tc.Sub == "" {
return appendError(errs, ErrMissingClaim, "sub: missing required claim")
}
return errs
}
// HasAllowedAud validates the audience claim.
//
// Allowed semantics: nil = misconfigured (error), [] = misconfigured (error),
// ["*"] = any non-empty value, ["x","y"] = token's aud must intersect.
//
// At the [Validator] level, passing nil Aud disables the audience check
// entirely (the method is never called). Calling this method directly
// with nil is a misconfiguration error.
func (tc *TokenClaims) HasAllowedAud(errs []error, allowed []string) []error {
if allowed == nil {
return appendError(errs, ErrMisconfigured, "aud: audience checking enabled but Aud is nil")
}
if len(allowed) == 0 {
return appendError(errs, ErrMisconfigured, "aud: non-nil empty Aud allows no audiences")
} else if len(tc.Aud) == 0 {
return appendError(errs, ErrMissingClaim, "aud: missing required claim")
} else if !slices.Contains(allowed, "*") && !slices.ContainsFunc([]string(tc.Aud), func(a string) bool {
return slices.Contains(allowed, a)
}) {
return appendError(errs, ErrInvalidClaim, "aud %v not in allowed list", tc.Aud)
}
return errs
}
// IsBeforeExp validates the expiration claim.
// now is caller-supplied for testability; pass time.Now() in production.
func (tc *TokenClaims) IsBeforeExp(errs []error, now time.Time, skew time.Duration) []error {
if tc.Exp <= 0 {
return appendError(errs, ErrMissingClaim, "exp: missing required claim")
}
expTime := time.Unix(tc.Exp, 0)
if now.After(expTime.Add(skew)) {
dur := now.Sub(expTime)
return appendError(errs, ErrAfterExp, "expired %s ago (%s)",
formatDuration(dur), expTime.Format("2006-01-02 15:04:05 MST"))
}
return errs
}
// IsAfterNBf validates the not-before claim. Absence is never an error.
// now is caller-supplied for testability; pass time.Now() in production.
func (tc *TokenClaims) IsAfterNBf(errs []error, now time.Time, skew time.Duration) []error {
if tc.NBf <= 0 {
return errs
}
nbfTime := time.Unix(tc.NBf, 0)
if nbfTime.After(now.Add(skew)) {
dur := nbfTime.Sub(now)
return appendError(errs, ErrBeforeNBf, "nbf is %s in the future (%s)",
formatDuration(dur), nbfTime.Format("2006-01-02 15:04:05 MST"))
}
return errs
}
// IsAfterIAt validates that the issued-at claim is not in the future.
// now is caller-supplied for testability; pass time.Now() in production.
//
// Unlike iss or sub, absence is not an error - iat is optional per
// RFC 7519. However, when present, a future iat is rejected as a
// common-sense sanity check (the spec does not require this).
func (tc *TokenClaims) IsAfterIAt(errs []error, now time.Time, skew time.Duration) []error {
if tc.IAt <= 0 {
return errs // absence is not an error
}
iatTime := time.Unix(tc.IAt, 0)
if iatTime.After(now.Add(skew)) {
dur := iatTime.Sub(now)
return appendError(errs, ErrBeforeIAt, "iat is %s in the future (%s)",
formatDuration(dur), iatTime.Format("2006-01-02 15:04:05 MST"))
}
return errs
}
// IsPresentJTI validates that the JWT ID claim is present.
func (tc *TokenClaims) IsPresentJTI(errs []error) []error {
if tc.JTI == "" {
return appendError(errs, ErrMissingClaim, "jti: missing required claim")
}
return errs
}
// IsValidAuthTime validates the authentication time claim.
// now is caller-supplied for testability; pass time.Now() in production.
//
// When maxAge is positive, auth_time must be present and within maxAge
// of now. When maxAge is zero, only presence and future-time checks apply.
func (tc *TokenClaims) IsValidAuthTime(errs []error, now time.Time, skew time.Duration, maxAge time.Duration) []error {
if tc.AuthTime == 0 {
return appendError(errs, ErrMissingClaim, "auth_time: missing required claim")
}
authTime := time.Unix(tc.AuthTime, 0)
authTimeStr := authTime.Format("2006-01-02 15:04:05 MST")
if authTime.After(now.Add(skew)) {
dur := authTime.Sub(now)
return appendError(errs, ErrBeforeAuthTime, "auth_time %s is %s in the future",
authTimeStr, formatDuration(dur))
} else if maxAge > 0 {
age := now.Sub(authTime)
if age > maxAge+skew {
diff := age - maxAge
return appendError(errs, ErrAfterAuthMaxAge, "auth_time %s is %s old, exceeding max age %s by %s",
authTimeStr, formatDuration(age), formatDuration(maxAge), formatDuration(diff))
}
}
return errs
}
// IsAllowedAzP validates the authorized party claim.
//
// Allowed semantics: nil = misconfigured (error), [] = misconfigured (error),
// ["*"] = any non-empty value, ["x","y"] = must match one.
func (tc *TokenClaims) IsAllowedAzP(errs []error, allowed []string) []error {
if allowed == nil {
return appendError(errs, ErrMisconfigured, "azp: authorized party checking enabled but AzP is nil")
}
if len(allowed) == 0 {
return appendError(errs, ErrMisconfigured, "azp: non-nil empty AzP allows no parties")
} else if tc.AzP == "" {
return appendError(errs, ErrMissingClaim, "azp: missing required claim")
} else if !slices.Contains(allowed, "*") && !slices.Contains(allowed, tc.AzP) {
return appendError(errs, ErrInvalidClaim, "azp %q not in allowed list", tc.AzP)
}
return errs
}
// IsPresentClientID validates that the client_id claim is present.
func (tc *TokenClaims) IsPresentClientID(errs []error) []error {
if tc.ClientID == "" {
return appendError(errs, ErrMissingClaim, "client_id: missing required claim")
}
return errs
}
// ContainsScopes validates that the token's scope claim is present and
// contains all required values. When required is nil, only presence is checked.
func (tc *TokenClaims) ContainsScopes(errs []error, required []string) []error {
if len(tc.Scope) == 0 {
return appendError(errs, ErrMissingClaim, "scope: missing required claim")
}
for _, req := range required {
if !slices.Contains(tc.Scope, req) {
errs = appendError(errs, ErrInsufficientScope, "scope %q not granted", req)
}
}
return errs
}
// IsAllowedTyp validates that the JOSE "typ" header is one of the allowed
// values. Comparison is case-insensitive per RFC 7515 §4.1.9.
// Call this between [Verifier.Verify] and [Validator.Validate] to enforce
// token-type constraints (e.g. reject an access token where an ID token
// is expected).
//
// hdr := jws.GetHeader()
// errs = hdr.IsAllowedTyp(errs, []string{"JWT"})
func (h *RFCHeader) IsAllowedTyp(errs []error, allowed []string) []error {
if len(allowed) == 0 {
return appendError(errs, ErrMisconfigured, "typ: allowed list is empty")
}
for _, a := range allowed {
if strings.EqualFold(h.Typ, a) {
return errs
}
}
return appendError(errs, ErrInvalidTyp, "typ %q not in allowed list", h.Typ)
}