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/wasim-builds/jwt

go 1.21
124 changes: 124 additions & 0 deletions jwt.go
Original file line number Diff line number Diff line change
@@ -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
}
114 changes: 114 additions & 0 deletions jwt_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
7 changes: 0 additions & 7 deletions main.go

This file was deleted.