| // Copyright 2026 Google LLC |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| package jwt_test |
| |
| import ( |
| "encoding/base64" |
| "encoding/json" |
| "reflect" |
| "strings" |
| "testing" |
| "time" |
| |
| "cel.dev/cel-go/cel" |
| "cel.dev/cel-go/common/types" |
| "cel.dev/cel-go/common/types/ref" |
| "cel.dev/cel-go/ext/security/jwt" |
| ) |
| |
| func createTestJWT(t *testing.T, header, payload map[string]any) string { |
| hBytes, err := json.Marshal(header) |
| if err != nil { |
| t.Fatalf("json.Marshal header failed: %v", err) |
| } |
| pBytes, err := json.Marshal(payload) |
| if err != nil { |
| t.Fatalf("json.Marshal payload failed: %v", err) |
| } |
| |
| hB64 := base64.RawURLEncoding.EncodeToString(hBytes) |
| pB64 := base64.RawURLEncoding.EncodeToString(pBytes) |
| sigB64 := base64.RawURLEncoding.EncodeToString([]byte("signature-placeholder")) |
| |
| return hB64 + "." + pB64 + "." + sigB64 |
| } |
| |
| func evalExpr(t *testing.T, env *cel.Env, expr string, vars map[string]any) any { |
| ast, issues := env.Compile(expr) |
| if issues != nil && issues.Err() != nil { |
| t.Fatalf("Compile(%q) failed: %v", expr, issues.Err()) |
| } |
| prg, err := env.Program(ast) |
| if err != nil { |
| t.Fatalf("Program(%q) failed: %v", expr, err) |
| } |
| val, _, err := prg.Eval(vars) |
| if err != nil { |
| t.Fatalf("Eval(%q) failed: %v", expr, err) |
| } |
| return val.Value() |
| } |
| |
| func TestJWTParseAndPresentedBy(t *testing.T) { |
| header := map[string]any{ |
| "alg": "RS256", |
| "typ": "JWT", |
| "kid": "key-123", |
| } |
| payload := map[string]any{ |
| "iss": "https://auth.example.com", |
| "sub": "user_12345", |
| "aud": []string{"https://api.example.com", "https://admin.example.com"}, |
| "exp": time.Now().Add(1 * time.Hour).Unix(), |
| "nbf": time.Now().Add(-1 * time.Minute).Unix(), |
| "iat": time.Now().Add(-1 * time.Minute).Unix(), |
| "jti": "token-unique-id-999", |
| "roles": []string{"admin", "editor"}, |
| "tenant": "tenant_abc", |
| } |
| |
| tokenStr := createTestJWT(t, header, payload) |
| |
| env, err := cel.NewEnv( |
| jwt.Library(), |
| cel.Variable("tokenStr", cel.StringType), |
| cel.Variable("bearerToken", cel.StringType), |
| cel.Variable("upperBearerToken", cel.StringType), |
| ) |
| if err != nil { |
| t.Fatalf("cel.NewEnv failed: %v", err) |
| } |
| |
| vars := map[string]any{ |
| "tokenStr": tokenStr, |
| "bearerToken": "Bearer " + tokenStr, |
| "upperBearerToken": "BEARER " + tokenStr, |
| } |
| |
| tests := []struct { |
| name string |
| expr string |
| want any |
| }{ |
| { |
| name: "parse_has_value", |
| expr: `jwt.parse(tokenStr).hasValue()`, |
| want: true, |
| }, |
| { |
| name: "parse_bearer_prefix_has_value", |
| expr: `jwt.parse(bearerToken).hasValue()`, |
| want: true, |
| }, |
| { |
| name: "parse_uppercase_bearer_prefix_has_value", |
| expr: `jwt.parse(upperBearerToken).hasValue()`, |
| want: true, |
| }, |
| { |
| name: "token_algorithm", |
| expr: `jwt.parse(tokenStr).value().alg == 'RS256'`, |
| want: true, |
| }, |
| { |
| name: "token_issuer", |
| expr: `jwt.parse(tokenStr).value().issuer == 'https://auth.example.com'`, |
| want: true, |
| }, |
| { |
| name: "token_subject", |
| expr: `jwt.parse(tokenStr).value().subject == 'user_12345'`, |
| want: true, |
| }, |
| { |
| name: "token_key_id", |
| expr: `jwt.parse(tokenStr).value().keyId == 'key-123'`, |
| want: true, |
| }, |
| { |
| name: "token_id", |
| expr: `jwt.parse(tokenStr).value().id == 'token-unique-id-999'`, |
| want: true, |
| }, |
| { |
| name: "token_audience", |
| expr: `'https://api.example.com' in jwt.parse(tokenStr).value().aud`, |
| want: true, |
| }, |
| { |
| name: "token_presented_by_direct", |
| expr: `jwt.parse(tokenStr).value().presentedBy('https://auth.example.com', 'https://api.example.com')`, |
| want: true, |
| }, |
| { |
| name: "token_presented_by_on_optional", |
| expr: `jwt.parse(tokenStr).presentedBy('https://auth.example.com', 'https://api.example.com')`, |
| want: true, |
| }, |
| { |
| name: "token_presented_by_mismatch_iss", |
| expr: `jwt.parse(tokenStr).presentedBy('https://evil.com', 'https://api.example.com')`, |
| want: false, |
| }, |
| { |
| name: "token_presented_by_mismatch_aud", |
| expr: `jwt.parse(tokenStr).presentedBy('https://auth.example.com', 'https://wrong-aud.com')`, |
| want: false, |
| }, |
| { |
| name: "claim_tenant", |
| expr: `jwt.parse(tokenStr).value().claim('tenant').orValue('')`, |
| want: "tenant_abc", |
| }, |
| { |
| name: "claim_on_optional", |
| expr: `jwt.parse(tokenStr).claim('tenant').orValue('')`, |
| want: "tenant_abc", |
| }, |
| { |
| name: "claim_missing", |
| expr: `jwt.parse(tokenStr).claim('nonexistent').hasValue()`, |
| want: false, |
| }, |
| } |
| |
| for _, tc := range tests { |
| t.Run(tc.name, func(t *testing.T) { |
| got := evalExpr(t, env, tc.expr, vars) |
| if !reflect.DeepEqual(got, tc.want) { |
| t.Errorf("Eval(%q) = %v (%T), want %v (%T)", tc.expr, got, got, tc.want, tc.want) |
| } |
| }) |
| } |
| } |
| |
| func TestParseUnverifiedTokenAndFieldVariations(t *testing.T) { |
| header := map[string]any{ |
| "alg": "ES256", |
| "kid": "k-42", |
| } |
| payload := map[string]any{ |
| "iss": "https://accounts.google.com", |
| "sub": "10987654321", |
| "aud": "my-client-id", |
| "exp": 1700000000, |
| "nbf": "1699990000", |
| "iat": 1699990000.5, |
| } |
| |
| tokStr := createTestJWT(t, header, payload) |
| |
| tok, err := jwt.ParseToken(tokStr) |
| if err != nil { |
| t.Fatalf("ParseToken failed: %v", err) |
| } |
| |
| tests := []struct { |
| name string |
| got any |
| want any |
| }{ |
| {"alg", tok.Algorithm, "ES256"}, |
| {"issuer", tok.Issuer, "https://accounts.google.com"}, |
| {"subject", tok.Subject, "10987654321"}, |
| {"key_id", tok.KeyID, "k-42"}, |
| {"audience", tok.Audience, []string{"my-client-id"}}, |
| {"exp", tok.ExpiresAt.Unix(), int64(1700000000)}, |
| {"nbf", tok.NotBefore.Unix(), int64(1699990000)}, |
| {"iat", tok.IssuedAt.Unix(), int64(1699990000)}, |
| } |
| |
| for _, tc := range tests { |
| t.Run(tc.name, func(t *testing.T) { |
| if !reflect.DeepEqual(tc.got, tc.want) { |
| t.Errorf("%s = %v, want %v", tc.name, tc.got, tc.want) |
| } |
| }) |
| } |
| } |
| |
| func TestClaimsCustomTypes(t *testing.T) { |
| tok := &jwt.Token{ |
| Payload: map[string]any{ |
| "intNum": json.Number("42"), |
| "floatNum": json.Number("3.14"), |
| "strNum": json.Number("NaN"), |
| "rawJSON": json.RawMessage(`{"nested":"value"}`), |
| "rawMsgBad": json.RawMessage(`bad-json`), |
| "badNumFloat": json.Number("not-a-number"), |
| "simpleStr": "hello", |
| }, |
| } |
| |
| adapter := types.DefaultTypeAdapter |
| |
| tests := []struct { |
| name string |
| claimName string |
| validate func(t *testing.T, val ref.Val) |
| }{ |
| { |
| name: "json_number_int", |
| claimName: "intNum", |
| validate: func(t *testing.T, val ref.Val) { |
| if val.Value() != int64(42) { |
| t.Errorf("expected 42, got %v", val.Value()) |
| } |
| }, |
| }, |
| { |
| name: "json_number_float", |
| claimName: "floatNum", |
| validate: func(t *testing.T, val ref.Val) { |
| if val.Value() != float64(3.14) { |
| t.Errorf("expected 3.14, got %v", val.Value()) |
| } |
| }, |
| }, |
| { |
| name: "json_number_nan_string", |
| claimName: "strNum", |
| validate: func(t *testing.T, val ref.Val) { |
| if val == types.OptionalNone { |
| t.Errorf("expected non-empty optional for strNum") |
| } |
| }, |
| }, |
| { |
| name: "raw_json_message", |
| claimName: "rawJSON", |
| validate: func(t *testing.T, val ref.Val) { |
| if val == types.OptionalNone { |
| t.Errorf("expected rawJSON to be parsed") |
| } |
| }, |
| }, |
| { |
| name: "raw_msg_bad_conversion_error", |
| claimName: "rawMsgBad", |
| validate: func(t *testing.T, val ref.Val) { |
| if !types.IsError(val) { |
| t.Errorf("expected error ref.Val for invalid json.RawMessage, got %v (%T)", val, val) |
| } |
| }, |
| }, |
| { |
| name: "bad_num_conversion_error", |
| claimName: "badNumFloat", |
| validate: func(t *testing.T, val ref.Val) { |
| if !types.IsError(val) { |
| t.Errorf("expected error ref.Val for invalid json.Number, got %v (%T)", val, val) |
| } |
| }, |
| }, |
| { |
| name: "simple_string", |
| claimName: "simpleStr", |
| validate: func(t *testing.T, val ref.Val) { |
| if val.Value() != "hello" { |
| t.Errorf("expected 'hello', got %v", val.Value()) |
| } |
| }, |
| }, |
| { |
| name: "nonexistent_claim", |
| claimName: "nonexistent", |
| validate: func(t *testing.T, val ref.Val) { |
| if val != types.OptionalNone { |
| t.Errorf("expected None for nonexistent claim, got %v", val) |
| } |
| }, |
| }, |
| } |
| |
| for _, tc := range tests { |
| t.Run(tc.name, func(t *testing.T) { |
| val := tok.Claim(adapter, tc.claimName) |
| tc.validate(t, val) |
| }) |
| } |
| } |
| |
| func TestJWTTimestampTypes(t *testing.T) { |
| header := map[string]any{"alg": "RS256", "typ": "JWT"} |
| |
| tests := []struct { |
| name string |
| payload map[string]any |
| wantExp int64 |
| wantIat int64 |
| wantNbf int64 |
| }{ |
| { |
| name: "float64_and_string", |
| payload: map[string]any{ |
| "iss": "https://auth.example.com", |
| "sub": "user-123", |
| "aud": "my-client", |
| "exp": float64(1700000000.5), |
| "iat": int64(1699990000), |
| "nbf": "1699990000", |
| }, |
| wantExp: 1700000000, |
| wantIat: 1699990000, |
| wantNbf: 1699990000, |
| }, |
| { |
| name: "uint64_int32_float32", |
| payload: map[string]any{ |
| "iss": "https://auth.example.com", |
| "sub": "user-123", |
| "aud": "my-client", |
| "exp": uint64(1700000000), |
| "iat": int32(1699990000), |
| "nbf": float32(1699990000), |
| }, |
| wantExp: 1700000000, |
| wantIat: 1699990000, |
| wantNbf: 1699990000, |
| }, |
| { |
| name: "int_uint_uint32", |
| payload: map[string]any{ |
| "iss": "https://auth.example.com", |
| "sub": "user-123", |
| "aud": "my-client", |
| "exp": int(1700000000), |
| "iat": uint(1699990000), |
| "nbf": uint32(1699990000), |
| }, |
| wantExp: 1700000000, |
| wantIat: 1699990000, |
| wantNbf: 1699990000, |
| }, |
| { |
| name: "string_float", |
| payload: map[string]any{ |
| "iss": "https://auth.example.com", |
| "sub": "user-123", |
| "aud": "my-client", |
| "exp": "1700000000.5", |
| "iat": 1699900000, |
| }, |
| wantExp: 1700000000, |
| wantIat: 1699900000, |
| wantNbf: 0, |
| }, |
| { |
| name: "json_number_float", |
| payload: map[string]any{ |
| "iss": "https://auth.example.com", |
| "sub": "user-123", |
| "aud": "my-client", |
| "exp": json.Number("1700000000.75"), |
| "iat": 1699900000, |
| }, |
| wantExp: 1700000000, |
| wantIat: 1699900000, |
| wantNbf: 0, |
| }, |
| } |
| |
| for _, tc := range tests { |
| t.Run(tc.name, func(t *testing.T) { |
| tokStr := createTestJWT(t, header, tc.payload) |
| tok, err := jwt.ParseToken(tokStr) |
| if err != nil { |
| t.Fatalf("ParseToken failed: %v", err) |
| } |
| if tok.ExpiresAt.Unix() != tc.wantExp { |
| t.Errorf("exp = %v, want %v", tok.ExpiresAt.Unix(), tc.wantExp) |
| } |
| if tok.IssuedAt.Unix() != tc.wantIat { |
| t.Errorf("iat = %v, want %v", tok.IssuedAt.Unix(), tc.wantIat) |
| } |
| if tc.wantNbf != 0 && tok.NotBefore.Unix() != tc.wantNbf { |
| t.Errorf("nbf = %v, want %v", tok.NotBefore.Unix(), tc.wantNbf) |
| } |
| }) |
| } |
| } |
| |
| func TestJWTValidateTimesOption(t *testing.T) { |
| fixedNow := time.Unix(1700000000, 0).UTC() |
| header := map[string]any{"alg": "RS256", "typ": "JWT"} |
| |
| tokValid := createTestJWT(t, header, map[string]any{ |
| "iss": "https://auth.example.com", |
| "sub": "user-123", |
| "aud": "my-aud", |
| "iat": fixedNow.Add(-1 * time.Hour).Unix(), |
| "nbf": fixedNow.Add(-1 * time.Hour).Unix(), |
| "exp": fixedNow.Add(1 * time.Hour).Unix(), |
| }) |
| |
| tokExpired := createTestJWT(t, header, map[string]any{ |
| "iss": "https://auth.example.com", |
| "sub": "user-123", |
| "aud": "my-aud", |
| "iat": fixedNow.Add(-2 * time.Hour).Unix(), |
| "exp": fixedNow.Add(-10 * time.Minute).Unix(), |
| }) |
| |
| tokFutureNbf := createTestJWT(t, header, map[string]any{ |
| "iss": "https://auth.example.com", |
| "sub": "user-123", |
| "aud": "my-aud", |
| "iat": fixedNow.Add(-1 * time.Hour).Unix(), |
| "nbf": fixedNow.Add(10 * time.Minute).Unix(), |
| "exp": fixedNow.Add(1 * time.Hour).Unix(), |
| }) |
| |
| tokFutureIat := createTestJWT(t, header, map[string]any{ |
| "iss": "https://auth.example.com", |
| "sub": "user-123", |
| "aud": "my-aud", |
| "iat": fixedNow.Add(10 * time.Minute).Unix(), |
| "exp": fixedNow.Add(1 * time.Hour).Unix(), |
| }) |
| |
| tokInvertedWindow := createTestJWT(t, header, map[string]any{ |
| "iss": "https://auth.example.com", |
| "sub": "user-123", |
| "aud": "my-aud", |
| "iat": fixedNow.Add(-30 * time.Minute).Unix(), |
| "nbf": fixedNow.Add(-10 * time.Minute).Unix(), |
| "exp": fixedNow.Add(-20 * time.Minute).Unix(), |
| }) |
| |
| realNow := time.Now() |
| tokValidRealTime := createTestJWT(t, header, map[string]any{ |
| "iss": "https://auth.example.com", |
| "sub": "user-123", |
| "aud": "my-aud", |
| "iat": realNow.Add(-1 * time.Hour).Unix(), |
| "nbf": realNow.Add(-1 * time.Hour).Unix(), |
| "exp": realNow.Add(1 * time.Hour).Unix(), |
| }) |
| |
| tokExpiredRealTime := createTestJWT(t, header, map[string]any{ |
| "iss": "https://auth.example.com", |
| "sub": "user-123", |
| "aud": "my-aud", |
| "iat": realNow.Add(-2 * time.Hour).Unix(), |
| "exp": realNow.Add(-1 * time.Hour).Unix(), |
| }) |
| |
| tests := []struct { |
| name string |
| options []jwt.Option |
| tokenStr string |
| wantPass bool |
| }{ |
| { |
| name: "default_no_validation_allows_expired", |
| options: nil, |
| tokenStr: tokExpired, |
| wantPass: true, |
| }, |
| { |
| name: "validated_valid_token_passes", |
| options: []jwt.Option{jwt.ValidateTimes(), jwt.Clock(func() time.Time { return fixedNow })}, |
| tokenStr: tokValid, |
| wantPass: true, |
| }, |
| { |
| name: "validated_expired_token_rejected", |
| options: []jwt.Option{jwt.ValidateTimes(), jwt.Clock(func() time.Time { return fixedNow })}, |
| tokenStr: tokExpired, |
| wantPass: false, |
| }, |
| { |
| name: "validated_future_nbf_rejected", |
| options: []jwt.Option{jwt.ValidateTimes(), jwt.Clock(func() time.Time { return fixedNow })}, |
| tokenStr: tokFutureNbf, |
| wantPass: false, |
| }, |
| { |
| name: "validated_future_iat_rejected", |
| options: []jwt.Option{jwt.ValidateTimes(), jwt.Clock(func() time.Time { return fixedNow })}, |
| tokenStr: tokFutureIat, |
| wantPass: false, |
| }, |
| { |
| name: "leeway_allows_token_expired_within_window", |
| options: []jwt.Option{jwt.ValidateTimes(30 * time.Minute), jwt.Clock(func() time.Time { return fixedNow })}, |
| tokenStr: tokExpired, |
| wantPass: true, |
| }, |
| { |
| name: "leeway_rejects_inverted_nbf_after_exp", |
| options: []jwt.Option{jwt.ValidateTimes(30 * time.Minute), jwt.Clock(func() time.Time { return fixedNow })}, |
| tokenStr: tokInvertedWindow, |
| wantPass: false, |
| }, |
| { |
| name: "validated_default_clock_valid_token", |
| options: []jwt.Option{jwt.ValidateTimes()}, |
| tokenStr: tokValidRealTime, |
| wantPass: true, |
| }, |
| { |
| name: "validated_default_clock_expired_token", |
| options: []jwt.Option{jwt.ValidateTimes()}, |
| tokenStr: tokExpiredRealTime, |
| wantPass: false, |
| }, |
| } |
| |
| for _, tc := range tests { |
| t.Run(tc.name, func(t *testing.T) { |
| env, err := cel.NewEnv( |
| jwt.Library(tc.options...), |
| cel.Variable("tok", cel.StringType), |
| ) |
| if err != nil { |
| t.Fatalf("cel.NewEnv failed: %v", err) |
| } |
| got := evalExpr(t, env, `jwt.parse(tok).hasValue()`, map[string]any{"tok": tc.tokenStr}) |
| if got != tc.wantPass { |
| t.Errorf("jwt.parse(tok).hasValue() = %v, want %v", got, tc.wantPass) |
| } |
| }) |
| } |
| } |
| |
| func TestJWTOptionalReceiverChaining(t *testing.T) { |
| header := map[string]any{"alg": "RS256", "typ": "JWT"} |
| goodTokStr := createTestJWT(t, header, map[string]any{ |
| "iss": "https://auth.example.com", |
| "sub": "user-123", |
| "aud": "my-client", |
| "tag": "prod", |
| "exp": 1700000000, |
| "iat": 1699900000, |
| }) |
| |
| envExpired, err := cel.NewEnv( |
| jwt.Library(jwt.ValidateTimes(), jwt.Clock(func() time.Time { return time.Unix(2000000000, 0) })), |
| cel.Variable("tok", cel.StringType), |
| ) |
| if err != nil { |
| t.Fatalf("cel.NewEnv failed: %v", err) |
| } |
| |
| tests := []struct { |
| name string |
| env *cel.Env |
| expr string |
| want any |
| }{ |
| { |
| name: "presented_by_on_optional_none", |
| env: envExpired, |
| expr: `jwt.parse(tok).presentedBy('https://auth.example.com', 'my-client')`, |
| want: false, |
| }, |
| { |
| name: "claim_on_optional_none", |
| env: envExpired, |
| expr: `jwt.parse(tok).claim('tag').orValue('default')`, |
| want: "default", |
| }, |
| } |
| |
| for _, tc := range tests { |
| t.Run(tc.name, func(t *testing.T) { |
| got := evalExpr(t, tc.env, tc.expr, map[string]any{"tok": goodTokStr}) |
| if !reflect.DeepEqual(got, tc.want) { |
| t.Errorf("Eval(%q) = %v, want %v", tc.expr, got, tc.want) |
| } |
| }) |
| } |
| } |
| |
| func TestJWTDirectTokenVariables(t *testing.T) { |
| header := map[string]any{"alg": "RS256", "typ": "JWT"} |
| goodTokStr := createTestJWT(t, header, map[string]any{ |
| "iss": "https://auth.example.com", |
| "sub": "user-123", |
| "aud": "my-client", |
| "tag": "prod", |
| "exp": 1700000000, |
| "iat": 1699900000, |
| }) |
| |
| tok, err := jwt.ParseToken(goodTokStr) |
| if err != nil { |
| t.Fatalf("ParseToken failed: %v", err) |
| } |
| |
| env, err := cel.NewEnv( |
| jwt.Library(), |
| cel.Variable("t", cel.ObjectType("jwt.Token")), |
| ) |
| if err != nil { |
| t.Fatalf("cel.NewEnv failed: %v", err) |
| } |
| |
| vars := map[string]any{"t": tok} |
| |
| tests := []struct { |
| name string |
| expr string |
| want any |
| }{ |
| { |
| name: "presented_by_direct_match", |
| expr: `t.presentedBy('https://auth.example.com', 'my-client')`, |
| want: true, |
| }, |
| { |
| name: "presented_by_direct_mismatch", |
| expr: `t.presentedBy('https://auth.example.com', 'wrong-client')`, |
| want: false, |
| }, |
| { |
| name: "claim_direct_present", |
| expr: `t.claim('tag').orValue('')`, |
| want: "prod", |
| }, |
| { |
| name: "claim_direct_missing", |
| expr: `t.claim('nonexistent').orValue('default')`, |
| want: "default", |
| }, |
| } |
| |
| for _, tc := range tests { |
| t.Run(tc.name, func(t *testing.T) { |
| got := evalExpr(t, env, tc.expr, vars) |
| if !reflect.DeepEqual(got, tc.want) { |
| t.Errorf("Eval(%q) = %v, want %v", tc.expr, got, tc.want) |
| } |
| }) |
| } |
| } |
| |
| func TestJWTPresentedByWithAuthorizedPartyAZP(t *testing.T) { |
| header := map[string]any{"alg": "RS256", "typ": "JWT"} |
| |
| tokPayload := map[string]any{ |
| "iss": "https://accounts.google.com", |
| "sub": "user-456", |
| "aud": "https://api.example.com", |
| "azp": "frontend-client-app-id", |
| "exp": 1700000000, |
| "iat": 1699900000, |
| } |
| tokStr := createTestJWT(t, header, tokPayload) |
| |
| tokNoAZP := createTestJWT(t, header, map[string]any{ |
| "iss": "https://accounts.google.com", |
| "sub": "user-456", |
| "aud": "https://api.example.com", |
| "exp": 1700000000, |
| "iat": 1699900000, |
| }) |
| |
| env, err := cel.NewEnv( |
| jwt.Library(), |
| cel.Variable("tokStr", cel.StringType), |
| ) |
| if err != nil { |
| t.Fatalf("cel.NewEnv failed: %v", err) |
| } |
| |
| tests := []struct { |
| name string |
| expr string |
| tokenStr string |
| want any |
| }{ |
| { |
| name: "azp_field_access", |
| expr: `jwt.parse(tokStr).value().azp`, |
| tokenStr: tokStr, |
| want: "frontend-client-app-id", |
| }, |
| { |
| name: "presented_by_matches_azp", |
| expr: `jwt.parse(tokStr).presentedBy('https://accounts.google.com', 'frontend-client-app-id')`, |
| tokenStr: tokStr, |
| want: true, |
| }, |
| { |
| name: "presented_by_rejects_aud_when_azp_exists", |
| expr: `jwt.parse(tokStr).presentedBy('https://accounts.google.com', 'https://api.example.com')`, |
| tokenStr: tokStr, |
| want: false, |
| }, |
| { |
| name: "presented_by_falls_back_to_aud_when_azp_omitted", |
| expr: `jwt.parse(tokStr).presentedBy('https://accounts.google.com', 'https://api.example.com')`, |
| tokenStr: tokNoAZP, |
| want: true, |
| }, |
| } |
| |
| for _, tc := range tests { |
| t.Run(tc.name, func(t *testing.T) { |
| got := evalExpr(t, env, tc.expr, map[string]any{"tokStr": tc.tokenStr}) |
| if !reflect.DeepEqual(got, tc.want) { |
| t.Errorf("Eval(%q) = %v, want %v", tc.expr, got, tc.want) |
| } |
| }) |
| } |
| } |
| |
| func TestJWTParsingErrorsAndEncodings(t *testing.T) { |
| header := map[string]any{"alg": "RS256", "typ": "JWT"} |
| validPayload := map[string]any{ |
| "iss": "https://auth.example.com", |
| "sub": "user-123", |
| "aud": "my-client", |
| "exp": 1700000000, |
| "iat": 1699900000, |
| } |
| |
| goodHeaderB64 := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256"}`)) |
| validPayloadBytes, _ := json.Marshal(validPayload) |
| validPayloadB64 := base64.RawURLEncoding.EncodeToString(validPayloadBytes) |
| badJSONB64 := base64.RawURLEncoding.EncodeToString([]byte(`not-json`)) |
| |
| tests := []struct { |
| name string |
| tokenStr string |
| errMsg string |
| }{ |
| { |
| name: "one_segment", |
| tokenStr: "one", |
| errMsg: "invalid token format", |
| }, |
| { |
| name: "four_segments", |
| tokenStr: "one.two.three.four", |
| errMsg: "invalid token format", |
| }, |
| { |
| name: "empty_token", |
| tokenStr: "", |
| errMsg: "invalid token format", |
| }, |
| { |
| name: "bad_header_base64", |
| tokenStr: "!bad!.payload.sig", |
| errMsg: "failed to decode header", |
| }, |
| { |
| name: "non_json_header", |
| tokenStr: badJSONB64 + "." + validPayloadB64 + ".sig", |
| errMsg: "failed to parse header JSON", |
| }, |
| { |
| name: "missing_header_alg", |
| tokenStr: base64.RawURLEncoding.EncodeToString([]byte(`{"typ":"JWT"}`)) + "." + validPayloadB64 + ".sig", |
| errMsg: "missing required header: 'alg'", |
| }, |
| { |
| name: "bad_payload_base64", |
| tokenStr: goodHeaderB64 + ".!bad!.sig", |
| errMsg: "failed to decode payload", |
| }, |
| { |
| name: "non_json_payload", |
| tokenStr: goodHeaderB64 + "." + badJSONB64 + ".sig", |
| errMsg: "failed to parse payload JSON", |
| }, |
| { |
| name: "exceeds_max_token_size", |
| tokenStr: strings.Repeat("a", 11*1024*1024), |
| errMsg: "token size exceeds maximum allowed limit", |
| }, |
| { |
| name: "malformed_nbf_claim", |
| tokenStr: createTestJWT(t, header, map[string]any{ |
| "iss": "https://auth.example.com", |
| "sub": "user-123", |
| "aud": "my-client", |
| "exp": 1700000000, |
| "iat": 1699900000, |
| "nbf": "invalid-timestamp", |
| }), |
| errMsg: "invalid claim 'nbf'", |
| }, |
| { |
| name: "non_string_element_in_aud_list", |
| tokenStr: createTestJWT(t, header, map[string]any{ |
| "iss": "https://auth.example.com", |
| "sub": "user-123", |
| "aud": []any{"client-1", 12345}, |
| "exp": 1700000000, |
| "iat": 1699900000, |
| }), |
| errMsg: "invalid claim 'aud'", |
| }, |
| { |
| name: "invalid_aud_type", |
| tokenStr: createTestJWT(t, header, map[string]any{ |
| "iss": "https://auth.example.com", |
| "sub": "user-123", |
| "aud": 12345, |
| "exp": 1700000000, |
| "iat": 1699900000, |
| }), |
| errMsg: "invalid claim 'aud'", |
| }, |
| { |
| name: "excessive_dots", |
| tokenStr: strings.Repeat(".", 100), |
| errMsg: "invalid token format", |
| }, |
| } |
| |
| for _, tc := range tests { |
| t.Run(tc.name, func(t *testing.T) { |
| _, err := jwt.ParseToken(tc.tokenStr) |
| if err == nil { |
| t.Fatalf("expected error containing %q, got nil", tc.errMsg) |
| } |
| if !strings.Contains(err.Error(), tc.errMsg) { |
| t.Errorf("error = %q, want error containing %q", err.Error(), tc.errMsg) |
| } |
| }) |
| } |
| |
| requiredClaims := []string{"iss", "sub", "aud", "exp", "iat"} |
| for _, claim := range requiredClaims { |
| t.Run("missing_claim_"+claim, func(t *testing.T) { |
| p := make(map[string]any) |
| for k, v := range validPayload { |
| if k != claim { |
| p[k] = v |
| } |
| } |
| tokStr := createTestJWT(t, header, p) |
| if _, err := jwt.ParseToken(tokStr); err == nil { |
| t.Errorf("expected error when missing required claim %q, got nil", claim) |
| } |
| }) |
| } |
| } |
| |
| func TestTokenIsValidAt(t *testing.T) { |
| now := time.Date(2026, 8, 10, 12, 0, 0, 0, time.UTC) |
| leeway := 5 * time.Minute |
| |
| tests := []struct { |
| name string |
| token jwt.Token |
| refTime time.Time |
| leeway time.Duration |
| wantValid bool |
| }{ |
| { |
| name: "valid token within active window", |
| token: jwt.Token{ |
| IssuedAt: now.Add(-1 * time.Hour), |
| NotBefore: now.Add(-30 * time.Minute), |
| ExpiresAt: now.Add(1 * time.Hour), |
| }, |
| refTime: now, |
| leeway: 0, |
| wantValid: true, |
| }, |
| { |
| name: "issued-at exactly now", |
| token: jwt.Token{ |
| IssuedAt: now, |
| ExpiresAt: now.Add(1 * time.Hour), |
| }, |
| refTime: now, |
| leeway: 0, |
| wantValid: true, |
| }, |
| { |
| name: "issued-at in future within leeway", |
| token: jwt.Token{ |
| IssuedAt: now.Add(3 * time.Minute), |
| ExpiresAt: now.Add(1 * time.Hour), |
| }, |
| refTime: now, |
| leeway: leeway, |
| wantValid: true, |
| }, |
| { |
| name: "issued-at in future beyond leeway", |
| token: jwt.Token{ |
| IssuedAt: now.Add(10 * time.Minute), |
| ExpiresAt: now.Add(1 * time.Hour), |
| }, |
| refTime: now, |
| leeway: leeway, |
| wantValid: false, |
| }, |
| { |
| name: "not-before in future within leeway", |
| token: jwt.Token{ |
| IssuedAt: now.Add(-10 * time.Minute), |
| NotBefore: now.Add(3 * time.Minute), |
| ExpiresAt: now.Add(1 * time.Hour), |
| }, |
| refTime: now, |
| leeway: leeway, |
| wantValid: true, |
| }, |
| { |
| name: "not-before in future beyond leeway", |
| token: jwt.Token{ |
| IssuedAt: now.Add(-10 * time.Minute), |
| NotBefore: now.Add(10 * time.Minute), |
| ExpiresAt: now.Add(1 * time.Hour), |
| }, |
| refTime: now, |
| leeway: leeway, |
| wantValid: false, |
| }, |
| { |
| name: "expired token in past within leeway", |
| token: jwt.Token{ |
| IssuedAt: now.Add(-1 * time.Hour), |
| ExpiresAt: now.Add(-3 * time.Minute), |
| }, |
| refTime: now, |
| leeway: leeway, |
| wantValid: true, |
| }, |
| { |
| name: "expired token in past beyond leeway", |
| token: jwt.Token{ |
| IssuedAt: now.Add(-1 * time.Hour), |
| ExpiresAt: now.Add(-10 * time.Minute), |
| }, |
| refTime: now, |
| leeway: leeway, |
| wantValid: false, |
| }, |
| { |
| name: "expired token exactly at negative leeway boundary", |
| token: jwt.Token{ |
| IssuedAt: now.Add(-1 * time.Hour), |
| ExpiresAt: now.Add(-5 * time.Minute), |
| }, |
| refTime: now, |
| leeway: leeway, |
| wantValid: false, |
| }, |
| { |
| name: "inverted nbf > exp", |
| token: jwt.Token{ |
| IssuedAt: now.Add(-1 * time.Hour), |
| NotBefore: now.Add(30 * time.Minute), |
| ExpiresAt: now.Add(15 * time.Minute), |
| }, |
| refTime: now, |
| leeway: 0, |
| wantValid: false, |
| }, |
| { |
| name: "inverted iat > exp", |
| token: jwt.Token{ |
| IssuedAt: now.Add(30 * time.Minute), |
| ExpiresAt: now.Add(15 * time.Minute), |
| }, |
| refTime: now, |
| leeway: 1 * time.Hour, |
| wantValid: false, |
| }, |
| { |
| name: "empty claims (all zero time)", |
| token: jwt.Token{ |
| Issuer: "https://auth.example.com", |
| }, |
| refTime: now, |
| leeway: 0, |
| wantValid: true, |
| }, |
| } |
| |
| for _, tc := range tests { |
| t.Run(tc.name, func(t *testing.T) { |
| got := tc.token.IsValidAt(tc.refTime, tc.leeway) |
| if got != tc.wantValid { |
| t.Errorf("token.IsValidAt(%v, %v) = %v, want %v", tc.refTime, tc.leeway, got, tc.wantValid) |
| } |
| }) |
| } |
| } |