diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..47cb129 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/wasim-builds/jwt + +go 1.21 diff --git a/jwt.go b/jwt.go new file mode 100644 index 0000000..5b14fa1 --- /dev/null +++ b/jwt.go @@ -0,0 +1,124 @@ +package jwt + +import ( + "encoding/json" + "errors" + "fmt" + "math" + "strconv" + "time" +) + +// Safe temporal boundaries — year 0000 to year 9999 in UTC +const ( + MinUnixTime int64 = -62135596800 // Jan 1, year 0001 00:00:00 UTC + MaxUnixTime int64 = 253402300799 // Dec 31, year 9999 23:59:59 UTC +) + +var ( + ErrExpiredToken = errors.New("jwt: token is expired (exp)") + ErrTokenNotYetValid = errors.New("jwt: token is not yet valid (nbf)") + ErrMalformedClaims = errors.New("jwt: malformed or invalid claims") + ErrInvalidEpoch = errors.New("jwt: time claim contains out-of-bounds epoch value") + ErrMalformedToken = errors.New("jwt: token is malformed") +) + +// Claims holds standard JWT claims +type Claims struct { + Sub string `json:"sub,omitempty"` + Iss string `json:"iss,omitempty"` + Exp json.Number `json:"exp,omitempty"` + Nbf json.Number `json:"nbf,omitempty"` + Iat json.Number `json:"iat,omitempty"` + Extra map[string]interface{} `json:"-"` +} + +// parsePayload decodes a base64url-encoded JSON payload into Claims +func parsePayload(payload string) (*Claims, error) { + var c Claims + if err := json.Unmarshal([]byte(payload), &c); err != nil { + return nil, ErrMalformedClaims + } + return &c, nil +} + +// parseTimeClaim safely converts a json.Number (from JWT) to time.Time, +// rejecting values that overflow or lie outside safe temporal bounds. +func parseTimeClaim(num json.Number) (time.Time, error) { + if num == "" { + return time.Time{}, nil + } + + // Try float64 first (most common from JSON unmarshal) + f, err := num.Float64() + if err != nil { + return time.Time{}, fmt.Errorf("%w: %v", ErrInvalidEpoch, err) + } + + // Reject NaN and Inf + if math.IsNaN(f) || math.IsInf(f, 0) { + return time.Time{}, fmt.Errorf("%w: NaN or Inf", ErrInvalidEpoch) + } + + // Convert to int64 for Unix timestamp comparison + // Use strconv to handle large integers directly when possible + i, err := strconv.ParseInt(num.String(), 10, 64) + if err != nil { + // Not a clean integer — use the float64 representation + i = int64(f) + if float64(i) != f { + return time.Time{}, fmt.Errorf("%w: precision loss converting %s to int64", ErrInvalidEpoch, num.String()) + } + } + + // Bounds check: reject timestamps outside safe range + if i < MinUnixTime || i > MaxUnixTime { + return time.Time{}, fmt.Errorf("%w: %d is outside [%d, %d]", ErrInvalidEpoch, i, MinUnixTime, MaxUnixTime) + } + + return time.Unix(i, 0).UTC(), nil +} + +// ValidateClaims checks that exp/nbf/iat are within bounds and logically consistent. +// now is the current time; if zero, time.Now() is used. +func ValidateClaims(c *Claims, now time.Time) error { + if c == nil { + return ErrMalformedClaims + } + + if now.IsZero() { + now = time.Now() + } + + // Validate exp + if c.Exp != "" { + exp, err := parseTimeClaim(c.Exp) + if err != nil { + return err + } + if now.After(exp) { + return ErrExpiredToken + } + } + + // Validate nbf + if c.Nbf != "" { + nbf, err := parseTimeClaim(c.Nbf) + if err != nil { + return err + } + if now.Before(nbf) { + return ErrTokenNotYetValid + } + } + + // Validate iat + if c.Iat != "" { + _, err := parseTimeClaim(c.Iat) + if err != nil { + return err + } + } + + return nil +} diff --git a/jwt_test.go b/jwt_test.go new file mode 100644 index 0000000..42271c8 --- /dev/null +++ b/jwt_test.go @@ -0,0 +1,114 @@ +package jwt + +import ( + "encoding/json" + "strconv" + "testing" + "time" +) + +func TestValidToken(t *testing.T) { + // exp = year 2050, should pass with any "now" before 2050 + c := &Claims{ + Exp: jsonNumber(2524608000), + Nbf: jsonNumber(1500000000), + Iat: jsonNumber(1500000000), + } + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + if err := ValidateClaims(c, now); err != nil { + t.Errorf("expected no error, got %v", err) + } +} + +func TestExpiredToken(t *testing.T) { + c := &Claims{Exp: jsonNumber(1000000000)} // expired + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + if err := ValidateClaims(c, now); err != ErrExpiredToken { + t.Errorf("expected ErrExpiredToken, got %v", err) + } +} + +func TestNotYetValid(t *testing.T) { + c := &Claims{Nbf: jsonNumber(4102444800)} // year 2100 + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + if err := ValidateClaims(c, now); err != ErrTokenNotYetValid { + t.Errorf("expected ErrTokenNotYetValid, got %v", err) + } +} + +func TestMaxInt64Overflow(t *testing.T) { + c := &Claims{Exp: jsonNumber(9223372036854775807)} // max int64 + if err := ValidateClaims(c, time.Now()); err == nil { + t.Error("expected error for max int64 exp, got nil") + } +} + +func TestLargeFloatingPoint(t *testing.T) { + c := &Claims{Exp: json.Number("1e21")} + if err := ValidateClaims(c, time.Now()); err == nil { + t.Error("expected error for 1e21 exp, got nil") + } +} + +func TestMinInt64Overflow(t *testing.T) { + c := &Claims{Exp: jsonNumber(-9223372036854775808)} // min int64 + if err := ValidateClaims(c, time.Now()); err == nil { + t.Error("expected error for min int64 exp, got nil") + } +} + +func TestZeroTimestamp(t *testing.T) { + // exp = 0 (epoch) — expired, should fail + c := &Claims{Exp: jsonNumber(0)} + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + if err := ValidateClaims(c, now); err != ErrExpiredToken { + t.Errorf("expected ErrExpiredToken for exp=0, got %v", err) + } +} + +func TestYear9999Max(t *testing.T) { + // MaxUnixTime — should pass + c := &Claims{Exp: jsonNumber(MaxUnixTime)} + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + if err := ValidateClaims(c, now); err != nil { + t.Errorf("expected no error for max valid exp, got %v", err) + } +} + +func TestJustBeyondMax(t *testing.T) { + c := &Claims{Exp: jsonNumber(MaxUnixTime + 1)} + if err := ValidateClaims(c, time.Now()); err == nil { + t.Error("expected error for exp beyond MaxUnixTime, got nil") + } +} + +func TestNilClaims(t *testing.T) { + if err := ValidateClaims(nil, time.Now()); err != ErrMalformedClaims { + t.Errorf("expected ErrMalformedClaims for nil claims, got %v", err) + } +} + +func TestEmptyClaims(t *testing.T) { + c := &Claims{} + if err := ValidateClaims(c, time.Now()); err != nil { + t.Errorf("expected no error for empty claims, got %v", err) + } +} + +func TestValidIat(t *testing.T) { + c := &Claims{Iat: jsonNumber(1500000000)} + if err := ValidateClaims(c, time.Now()); err != nil { + t.Errorf("expected no error for valid iat, got %v", err) + } +} + +func TestIatOverflow(t *testing.T) { + c := &Claims{Iat: jsonNumber(9223372036854775807)} + if err := ValidateClaims(c, time.Now()); err == nil { + t.Error("expected error for overflowed iat, got nil") + } +} + +func jsonNumber(n int64) json.Number { + return json.Number(strconv.FormatInt(n, 10)) +} diff --git a/main.go b/main.go deleted file mode 100644 index 49f4dee..0000000 --- a/main.go +++ /dev/null @@ -1,7 +0,0 @@ -package main - -import "fmt" - -func main() { - fmt.Println("Hello, Bounty Hunter!") -}