diff --git a/CHANGELOG.md b/CHANGELOG.md index fe9be0f..a53ee1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Canonical reference for changes, improvements, and bugfixes for cap. ## Next +* fix (jwt): distinguish explicit Unix-epoch time claims (value `0`) from missing claims during validation * saml: fix InResponseTo request-correlation bypass ([PR #192](https://github.com/hashicorp/cap/pull/192)) ## 0.13.0 diff --git a/jwt/README.md b/jwt/README.md index bd87900..55a1353 100644 --- a/jwt/README.md +++ b/jwt/README.md @@ -8,13 +8,20 @@ Primary types provided by the package: * `KeySet`: Represents a set of keys that can be used to verify the signatures of JWTs. A KeySet is expected to be backed by a set of local or remote keys. - + * `Validator`: Provides signature verification and claims set validation behavior for JWTs. * `Expected`: Defines the expected claims values to assert when validating a JWT. * `Alg`: Represents asymmetric signing algorithms. +### Time claims + +Explicit NumericDate `0` values in `iat`, `nbf`, and `exp` represent the Unix epoch. +Omitted or JSON `null` time claims are internally derived when relevant. +`Validate` requires at least one non-null time claim. +`ValidateAllowMissingIatNbfExp` skips time validation only when all three time claims are omitted or null. + ### Examples: Please see [docs_test.go](./docs_test.go) for additional usage examples. diff --git a/jwt/jwt.go b/jwt/jwt.go index e22c693..8d1a195 100644 --- a/jwt/jwt.go +++ b/jwt/jwt.go @@ -250,18 +250,9 @@ func (v *Validator) validateAll(ctx context.Context, token string, expected Expe return nil, fmt.Errorf("invalid audience (aud) claim: %w", err) } - if claims.IssuedAt == nil { - claims.IssuedAt = new(jwt.NumericDate) - } - if claims.Expiry == nil { - claims.Expiry = new(jwt.NumericDate) - } - if claims.NotBefore == nil { - claims.NotBefore = new(jwt.NumericDate) - } // At least one of the "nbf" (Not Before), "exp" (Expiration Time), or "iat" (Issued At) // claims are required to be set unless allowMissingIatExpNbf is set. - if *claims.IssuedAt == 0 && *claims.Expiry == 0 && *claims.NotBefore == 0 { + if claims.IssuedAt == nil && claims.Expiry == nil && claims.NotBefore == nil { if allowMissingIatExpNbf { return allClaims, nil } @@ -270,25 +261,31 @@ func (v *Validator) validateAll(ctx context.Context, token string, expected Expe // If "exp" (Expiration Time) is not set, then set it to the latest of // either the "iat" (Issued At) or "nbf" (Not Before) claims plus leeway. - if *claims.Expiry == 0 { - latestStart := *claims.IssuedAt - if *claims.NotBefore > *claims.IssuedAt { + if claims.Expiry == nil { + var latestStart jwt.NumericDate + switch { + case claims.IssuedAt != nil && claims.NotBefore != nil: + latestStart = max(*claims.IssuedAt, *claims.NotBefore) + case claims.IssuedAt != nil: + latestStart = *claims.IssuedAt + case claims.NotBefore != nil: latestStart = *claims.NotBefore } leeway := expected.ExpirationLeeway.Seconds() - if expected.ExpirationLeeway.Seconds() < 0 { + if leeway < 0 { leeway = 0 - } else if expected.ExpirationLeeway.Seconds() == 0 { + } else if leeway == 0 { leeway = DefaultLeewaySeconds } - *claims.Expiry = jwt.NumericDate(int64(latestStart) + int64(leeway)) + expiry := jwt.NumericDate(int64(latestStart) + int64(leeway)) + claims.Expiry = &expiry } // If "nbf" (Not Before) is not set, then set it to the "iat" (Issued At) if set. // Otherwise, set it to the "exp" (Expiration Time) minus leeway. - if *claims.NotBefore == 0 { - if *claims.IssuedAt != 0 { - *claims.NotBefore = *claims.IssuedAt + if claims.NotBefore == nil { + if claims.IssuedAt != nil { + claims.NotBefore = claims.IssuedAt } else { leeway := expected.NotBeforeLeeway.Seconds() if expected.NotBeforeLeeway.Seconds() < 0 { @@ -296,7 +293,8 @@ func (v *Validator) validateAll(ctx context.Context, token string, expected Expe } else if expected.NotBeforeLeeway.Seconds() == 0 { leeway = DefaultLeewaySeconds } - *claims.NotBefore = jwt.NumericDate(int64(*claims.Expiry) - int64(leeway)) + notBefore := jwt.NumericDate(int64(*claims.Expiry) - int64(leeway)) + claims.NotBefore = ¬Before } } diff --git a/jwt/time_claims_test.go b/jwt/time_claims_test.go new file mode 100644 index 0000000..3b65b31 --- /dev/null +++ b/jwt/time_claims_test.go @@ -0,0 +1,186 @@ +// Copyright IBM Corp. 2026 +// SPDX-License-Identifier: MPL-2.0 + +package jwt + +import ( + "context" + "crypto" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/hashicorp/cap/oidc" +) + +func TestValidator_TimeClaimPresence(t *testing.T) { + now := time.Unix(2_000_000_000, 0) + epoch := time.Unix(0, 0) + nowUnix := float64(now.Unix()) + future := float64(now.Add(time.Hour).Unix()) + earlierStart := float64(now.Add(-4 * time.Minute).Unix()) + laterStart := float64(now.Add(-time.Minute).Unix()) + beforeEpoch := epoch.Add(-10 * time.Minute) + const ( + expired = "invalid expiration time (exp) claim: token is expired" + notYetValid = "invalid not before (nbf) claim: token not yet valid" + ) + + keySet, err := NewStaticKeySet([]crypto.PublicKey{priv.Public()}) + require.NoError(t, err) + validator, err := NewValidator(keySet) + require.NoError(t, err) + + tests := []struct { + name string + claims map[string]interface{} + now time.Time + wantErr string + }{ + { + name: "explicit epoch start claims with future expiration", + claims: map[string]interface{}{"iat": 0.0, "nbf": 0.0, "exp": future}, + now: now, + }, + { + name: "explicit epoch not before without issued at", + claims: map[string]interface{}{"nbf": 0.0, "exp": future}, + now: now, + }, + { + name: "derive not before from explicit epoch issued at", + claims: map[string]interface{}{"iat": 0.0, "exp": future}, + now: now, + }, + { + name: "zero expiration stays zero and jwt is expired", + claims: map[string]interface{}{"iat": nowUnix, "nbf": nowUnix, "exp": 0.0}, + now: now, + wantErr: expired, + }, + { + name: "all epoch claims are present and expired", + claims: map[string]interface{}{"iat": 0.0, "nbf": 0.0, "exp": 0.0}, + now: now, + wantErr: expired, + }, + { + name: "all epoch claims are valid at epoch", + claims: map[string]interface{}{"iat": 0.0, "nbf": 0.0, "exp": 0.0}, + now: epoch, + }, + { + name: "derive expiration from epoch issued at", + claims: map[string]interface{}{"iat": 0.0}, + now: epoch.Add(100 * time.Second), + }, + { + name: "derive expiration from epoch not before", + claims: map[string]interface{}{"nbf": 0.0}, + now: epoch.Add(100 * time.Second), + }, + { + name: "derived expiration from epoch issued at is expired", + claims: map[string]interface{}{"iat": 0.0}, + now: now, + wantErr: expired, + }, + { + name: "explicit epoch expiration without start claims", + claims: map[string]interface{}{"exp": 0.0}, + now: epoch, + }, + { + name: "pre-epoch not before without issued at is valid", + claims: map[string]interface{}{"nbf": float64(beforeEpoch.Unix())}, + now: beforeEpoch.Add(time.Minute), + }, + { + name: "pre-epoch not before without issued at is expired", + claims: map[string]interface{}{"nbf": float64(beforeEpoch.Unix())}, + now: epoch, + wantErr: expired, + }, + { + name: "derive expiration from later not before", + claims: map[string]interface{}{"iat": earlierStart, "nbf": laterStart}, + now: now, + }, + { + name: "derive expiration from later issued at", + claims: map[string]interface{}{"iat": laterStart, "nbf": earlierStart}, + now: now, + }, + { + name: "omitted start claims still derive not before", + claims: map[string]interface{}{"exp": future}, + now: now, + wantErr: notYetValid, + }, + { + name: "null start claims still derive not before", + claims: map[string]interface{}{"iat": nil, "nbf": nil, "exp": future}, + now: now, + wantErr: notYetValid, + }, + } + + methods := []struct { + name string + validate func(context.Context, string, Expected) (map[string]interface{}, error) + }{ + {name: "Validate", validate: validator.Validate}, + {name: "ValidateAllowMissingIatNbfExp", validate: validator.ValidateAllowMissingIatNbfExp}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + token := oidc.TestSignJWT(t, priv, string(RS256), tt.claims, []byte(testKeyID)) + expected := Expected{Now: func() time.Time { return tt.now }} + for _, method := range methods { + t.Run(method.name, func(t *testing.T) { + got, err := method.validate(context.Background(), token, expected) + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + require.Nil(t, got) + return + } + require.NoError(t, err) + require.Equal(t, tt.claims, got) + }) + } + }) + } +} + +func TestValidator_MissingTimeClaims(t *testing.T) { + keySet, err := NewStaticKeySet([]crypto.PublicKey{priv.Public()}) + require.NoError(t, err) + validator, err := NewValidator(keySet) + require.NoError(t, err) + + tests := []struct { + name string + claims map[string]interface{} + }{ + {name: "omitted", claims: map[string]interface{}{"sub": "test"}}, + {name: "null", claims: map[string]interface{}{"iat": nil, "nbf": nil, "exp": nil}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + token := oidc.TestSignJWT(t, priv, string(RS256), tt.claims, []byte(testKeyID)) + + t.Run("Validate", func(t *testing.T) { + got, err := validator.Validate(context.Background(), token, Expected{}) + require.EqualError(t, err, "no issued at (iat), not before (nbf), or expiration time (exp) claims in token") + require.Nil(t, got) + }) + t.Run("ValidateAllowMissingIatNbfExp", func(t *testing.T) { + got, err := validator.ValidateAllowMissingIatNbfExp(context.Background(), token, Expected{}) + require.NoError(t, err) + require.Equal(t, tt.claims, got) + }) + }) + } +}