Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module github.com/heathivorjocelyn6/jwt

go 1.26.5
139 changes: 139 additions & 0 deletions jwt/claims.go
Original file line number Diff line number Diff line change
@@ -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
}
228 changes: 228 additions & 0 deletions jwt/claims_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}