diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..95ec35e --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/heathivorjocelyn6/jwt + +go 1.26.5 diff --git a/jwt/claims.go b/jwt/claims.go new file mode 100644 index 0000000..7eda7dc --- /dev/null +++ b/jwt/claims.go @@ -0,0 +1,139 @@ +package jwt + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "math" + "strings" + "time" +) + +// Safe timestamp bounds to prevent time.Time overflow. +const ( + // MaxUnixTime is December 31, 9999 23:59:59 UTC — the largest timestamp + // that Go's time.Time can safely represent without overflow in comparisons. + MaxUnixTime int64 = 253402300799 + + // MinUnixTime is January 1, year 1 — the earliest reasonable timestamp. + // Negative Unix timestamps (pre-1970) are allowed only within this bound. + MinUnixTime int64 = -62135596800 +) + +// Common validation errors. +var ( + ErrInvalidEpoch = fmt.Errorf("jwt: timestamp out of bounds") + ErrMalformedClaims = fmt.Errorf("jwt: malformed claims") + ErrExpired = fmt.Errorf("jwt: token is expired") + ErrNotYetValid = fmt.Errorf("jwt: token is not yet valid") +) + +// RegisteredClaims holds the standard JWT registered claims. +type RegisteredClaims struct { + Issuer string `json:"iss,omitempty"` + Subject string `json:"sub,omitempty"` + Audience string `json:"aud,omitempty"` + ExpiresAt float64 `json:"exp,omitempty"` + NotBefore float64 `json:"nbf,omitempty"` + IssuedAt float64 `json:"iat,omitempty"` + ID string `json:"jti,omitempty"` +} + +// ParseClaims extracts registered claims from a raw JWT token. +// It does NOT verify the signature — only parses the payload. +func ParseClaims(tokenString string) (*RegisteredClaims, error) { + parts := strings.Split(tokenString, ".") + if len(parts) != 3 { + return nil, fmt.Errorf("%w: expected 3 parts, got %d", ErrMalformedClaims, len(parts)) + } + + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, fmt.Errorf("%w: invalid base64: %v", ErrMalformedClaims, err) + } + + var claims RegisteredClaims + if err := json.Unmarshal(payload, &claims); err != nil { + return nil, fmt.Errorf("%w: %v", ErrMalformedClaims, err) + } + + return &claims, nil +} + +// ValidateTimestamps checks that all time-based claims (exp, nbf, iat) +// fall within the safe timestamp range. Returns the specific error type +// on failure so callers can distinguish overflow from expiration. +func ValidateTimestamps(claims *RegisteredClaims) error { + // Check exp + if claims.ExpiresAt != 0 { + if err := validateTimestamp(claims.ExpiresAt); err != nil { + return fmt.Errorf("exp: %w", err) + } + } + + // Check nbf + if claims.NotBefore != 0 { + if err := validateTimestamp(claims.NotBefore); err != nil { + return fmt.Errorf("nbf: %w", err) + } + } + + // Check iat + if claims.IssuedAt != 0 { + if err := validateTimestamp(claims.IssuedAt); err != nil { + return fmt.Errorf("iat: %w", err) + } + } + + return nil +} + +// Validate checks all claims and verifies the token is currently valid. +// 1. Timestamps are within safe bounds (no overflow) +// 2. Token is not expired (exp > now) +// 3. Token is valid now (nbf <= now) +func Validate(claims *RegisteredClaims, leeway time.Duration) error { + if err := ValidateTimestamps(claims); err != nil { + return err + } + + now := time.Now() + + if claims.ExpiresAt != 0 { + exp := time.Unix(int64(claims.ExpiresAt), 0) + if now.After(exp.Add(leeway)) { + return fmt.Errorf("%w: token expired at %v", ErrExpired, exp) + } + } + + if claims.NotBefore != 0 { + nbf := time.Unix(int64(claims.NotBefore), 0) + if now.Before(nbf.Add(-leeway)) { + return fmt.Errorf("%w: token valid from %v", ErrNotYetValid, nbf) + } + } + + return nil +} + +// validateTimestamp checks that a single numeric claim value is within the +// safe range for conversion to a Go time.Time. It handles float64 (JSON +// default), extreme values, and negative timestamps. +func validateTimestamp(val float64) error { + // Reject NaN and infinity + if math.IsNaN(val) || math.IsInf(val, 0) { + return fmt.Errorf("%w: timestamp is NaN or Inf", ErrInvalidEpoch) + } + + // Reject negative values that are too far in the past + if val < float64(MinUnixTime) { + return fmt.Errorf("%w: timestamp %v is before year 1", ErrInvalidEpoch, val) + } + + // Reject values beyond year 9999 + if val > float64(MaxUnixTime) { + return fmt.Errorf("%w: timestamp %v exceeds year 9999", ErrInvalidEpoch, val) + } + + return nil +} diff --git a/jwt/claims_test.go b/jwt/claims_test.go new file mode 100644 index 0000000..7090357 --- /dev/null +++ b/jwt/claims_test.go @@ -0,0 +1,228 @@ +package jwt + +import ( + "encoding/base64" + "encoding/json" + "errors" + "math" + "strings" + "testing" + "time" +) + +// Helper to build a valid JWT token string with given claims. +func makeToken(claims map[string]interface{}) string { + payload, _ := json.Marshal(claims) + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none"}`)) + body := base64.RawURLEncoding.EncodeToString(payload) + return header + "." + body + "." +} + +func TestParseClaims(t *testing.T) { + // Valid token + token := makeToken(map[string]interface{}{ + "iss": "test", + "exp": 2524608000.0, + }) + claims, err := ParseClaims(token) + if err != nil { + t.Fatalf("ParseClaims failed: %v", err) + } + if claims.Issuer != "test" { + t.Errorf("expected iss=test, got %v", claims.Issuer) + } + if claims.ExpiresAt != 2524608000 { + t.Errorf("expected exp=2524608000, got %v", claims.ExpiresAt) + } +} + +func TestParseClaims_Invalid(t *testing.T) { + // Not enough parts + _, err := ParseClaims("a.b") + if err == nil { + t.Error("expected error for 2-part token") + } + + // Invalid base64 + _, err = ParseClaims("a.!@#$.c") + if err == nil { + t.Error("expected error for invalid base64") + } + + // Invalid JSON in payload + invalidJSON := base64.RawURLEncoding.EncodeToString([]byte(`not json`)) + _, err = ParseClaims("a." + invalidJSON + ".c") + if err == nil { + t.Error("expected error for invalid JSON") + } +} + +func TestValidateTimestamps_Overflow(t *testing.T) { + // Max int64 value + claims := &RegisteredClaims{ExpiresAt: math.MaxInt64} + err := ValidateTimestamps(claims) + if err == nil { + t.Error("expected error for MaxInt64 exp") + } + if !strings.Contains(err.Error(), "exceeds year 9999") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestValidateTimestamps_LargeFloat(t *testing.T) { + // 1e21 — way beyond year 9999 + claims := &RegisteredClaims{ExpiresAt: 1e21} + err := ValidateTimestamps(claims) + if err == nil { + t.Error("expected error for 1e21 exp") + } +} + +func TestValidateTimestamps_Negative(t *testing.T) { + // Negative value beyond MinUnixTime + claims := &RegisteredClaims{ExpiresAt: -1e20} + err := ValidateTimestamps(claims) + if err == nil { + t.Error("expected error for extreme negative exp") + } +} + +func TestValidateTimestamps_NaN(t *testing.T) { + claims := &RegisteredClaims{ExpiresAt: math.NaN()} + err := ValidateTimestamps(claims) + if err == nil { + t.Error("expected error for NaN exp") + } +} + +func TestValidateTimestamps_Inf(t *testing.T) { + claims := &RegisteredClaims{ExpiresAt: math.Inf(1)} + err := ValidateTimestamps(claims) + if err == nil { + t.Error("expected error for +Inf exp") + } + + claims2 := &RegisteredClaims{ExpiresAt: math.Inf(-1)} + err = ValidateTimestamps(claims2) + if err == nil { + t.Error("expected error for -Inf exp") + } +} + +func TestValidateTimestamps_Valid(t *testing.T) { + // Year 2050 — well within bounds + claims := &RegisteredClaims{ + ExpiresAt: 2524608000, // 2050-01-01 + NotBefore: 0, + IssuedAt: 2524600000, + } + err := ValidateTimestamps(claims) + if err != nil { + t.Errorf("unexpected error for valid timestamps: %v", err) + } + + // Year 9999 boundary — should pass + claims2 := &RegisteredClaims{ExpiresAt: float64(MaxUnixTime)} + err = ValidateTimestamps(claims2) + if err != nil { + t.Errorf("unexpected error for MaxUnixTime boundary: %v", err) + } + + // Year 1 boundary — should pass + claims3 := &RegisteredClaims{NotBefore: float64(MinUnixTime)} + err = ValidateTimestamps(claims3) + if err != nil { + t.Errorf("unexpected error for MinUnixTime boundary: %v", err) + } +} + +func TestValidate_Expired(t *testing.T) { + // Expired 1 hour ago + claims := &RegisteredClaims{ExpiresAt: float64(time.Now().Unix() - 3600)} + err := Validate(claims, 0) + if err == nil { + t.Error("expected expired error") + } + if !errors.Is(err, ErrExpired) { + t.Errorf("expected ErrExpired, got %v", err) + } +} + +func TestValidate_NotYetValid(t *testing.T) { + // Valid from 1 hour in the future + claims := &RegisteredClaims{NotBefore: float64(time.Now().Unix() + 3600)} + err := Validate(claims, 0) + if err == nil { + t.Error("expected not-yet-valid error") + } + if !errors.Is(err, ErrNotYetValid) { + t.Errorf("expected ErrNotYetValid, got %v", err) + } +} + +func TestValidate_Leeway(t *testing.T) { + // Expired 30 seconds ago, with 60s leeway — should pass + claims := &RegisteredClaims{ExpiresAt: float64(time.Now().Unix() - 30)} + err := Validate(claims, time.Minute) + if err != nil { + t.Errorf("unexpected error with leeway: %v", err) + } +} + +func TestValidateTimestamps_AllClaimTypes(t *testing.T) { + // All three time claims set to overflow + overflow := float64(math.MaxInt64) + claims := &RegisteredClaims{ + ExpiresAt: overflow, + NotBefore: overflow, + IssuedAt: overflow, + } + err := ValidateTimestamps(claims) + if err == nil { + t.Error("expected error for all overflow claims") + } +} + +func TestValidateTimestamps_Zero(t *testing.T) { + // Zero values (unset claims) should be skipped + claims := &RegisteredClaims{} + err := ValidateTimestamps(claims) + if err != nil { + t.Errorf("unexpected error for zero claims: %v", err) + } +} + +func TestRoundTrip(t *testing.T) { + // Full round-trip: parse, validate timestamps, validate token + token := makeToken(map[string]interface{}{ + "iss": "test-issuer", + "sub": "test-subject", + "exp": float64(time.Now().Unix() + 86400), // 24h from now + "nbf": float64(time.Now().Unix() - 3600), // 1h ago + "iat": float64(time.Now().Unix()), + "jti": "unique-id-123", + }) + + claims, err := ParseClaims(token) + if err != nil { + t.Fatalf("parse failed: %v", err) + } + + if claims.Issuer != "test-issuer" { + t.Errorf("issuer mismatch") + } + if claims.Subject != "test-subject" { + t.Errorf("subject mismatch") + } + if claims.ID != "unique-id-123" { + t.Errorf("jti mismatch") + } + + if err := ValidateTimestamps(claims); err != nil { + t.Errorf("timestamp validation failed: %v", err) + } + + if err := Validate(claims, 30*time.Second); err != nil { + t.Errorf("validation failed: %v", err) + } +}