diff --git a/LICENSE b/LICENSE index 115e96e..74cee2b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (C) 2018 Marius Schellenberger +Copyright (C) 2025 Marius Schellenberger All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/blacklist.go b/blacklist.go index 9ecf662..180c5fd 100644 --- a/blacklist.go +++ b/blacklist.go @@ -1,4 +1,4 @@ -// Copyright (C) 2018 Marius Schellenberger +// Copyright (C) 2025 Marius Schellenberger package jwt @@ -6,10 +6,10 @@ import "sync" // Blacklist is the blacklisting storage interface type Blacklist interface { - Add(string, int64) - Remove(string) + Add(string, int64) error + Remove(string) error Check(string) bool - Map() BlacklistMap + Map() (BlacklistMap, error) } // BlacklistMap is the blacklist map structure @@ -28,17 +28,19 @@ func NewMemBlacklist() *MemBlacklist { } // Add adds a new token signature with expiration time to the blacklist -func (mb *MemBlacklist) Add(sig string, exp int64) { +func (mb *MemBlacklist) Add(sig string, exp int64) error { mb.Lock() mb.list[sig] = exp mb.Unlock() + return nil } // Remove deletes a token signature from the blacklist -func (mb *MemBlacklist) Remove(sig string) { +func (mb *MemBlacklist) Remove(sig string) error { mb.Lock() delete(mb.list, sig) mb.Unlock() + return nil } // Check returns true if a token signature is blacklisted and false otherwise @@ -50,7 +52,7 @@ func (mb *MemBlacklist) Check(sig string) (ok bool) { } // Map returns the blacklist in the form of a iterable map structure for cleanup -func (mb *MemBlacklist) Map() (list BlacklistMap) { +func (mb *MemBlacklist) Map() (list BlacklistMap, err error) { list = make(BlacklistMap) mb.RLock() for k, v := range mb.list { diff --git a/claims.go b/claims.go index 10cbcfe..a5f4364 100644 --- a/claims.go +++ b/claims.go @@ -1,4 +1,4 @@ -// Copyright (C) 2018 Marius Schellenberger +// Copyright (C) 2025 Marius Schellenberger package jwt @@ -30,37 +30,28 @@ func (c Claims) GetBool(key string) (b bool) { // GetInt returns an int from the claims map func (c Claims) GetInt(key string) (i int) { - if f, ok := c.getFloat64(key); ok { - return int(f) - } - if v, ok := c.Get(key); ok && v != nil { - i, _ = v.(int) - } + i = int(c.GetInt64(key)) return } // GetInt64 returns an int64 from the claims map func (c Claims) GetInt64(key string) (i int64) { - if f, ok := c.getFloat64(key); ok { - return int64(f) - } if v, ok := c.Get(key); ok && v != nil { - i, _ = v.(int64) + switch val := v.(type) { + case int64: + i = val + case float64: + i = int64(val) + } } return } // getFloat64 returns a float64 and ok from the claims map -func (c Claims) getFloat64(key string) (f float64, fok bool) { - if v, ok := c.Get(key); ok && v != nil { - f, fok = v.(float64) - } - return -} - -// GetFloat64 returns a float64 from the claims map func (c Claims) GetFloat64(key string) (f float64) { - f, _ = c.getFloat64(key) + if v, ok := c.Get(key); ok && v != nil { + f, _ = v.(float64) + } return } diff --git a/encoding.go b/encoding.go new file mode 100644 index 0000000..3693f5f --- /dev/null +++ b/encoding.go @@ -0,0 +1,7 @@ +// Copyright (C) 2025 Marius Schellenberger + +package jwt + +import "encoding/base64" + +var enc = base64.RawURLEncoding diff --git a/hash.go b/hash.go index 2c224ab..e47c0cc 100644 --- a/hash.go +++ b/hash.go @@ -1,4 +1,4 @@ -// Copyright (C) 2018 Marius Schellenberger +// Copyright (C) 2025 Marius Schellenberger package jwt @@ -14,19 +14,25 @@ const ( HS512Name = "HS512" ) -// ParseHash returns the Hash type equal to the input string -func ParseHash(alg string) Hash { +// NewHash returns the Hash type equal to the input string +func NewHash(alg string) Hash { switch alg { case HS256Name: - return NewHS256() + return hs256 case HS384Name: - return NewHS384() + return hs384 case HS512Name: - return NewHS512() + return hs512 } return nil } +var ( + hs256 = HS256{} + hs384 = HS384{} + hs512 = HS512{} +) + // Hash is the hashsum interface for signing the jwt type Hash interface { Hash() hash.Hash @@ -36,11 +42,6 @@ type Hash interface { // HS256 implements the Hash interface with SHA256 type HS256 struct{} -// NewHS256 returns a new HS256 instance -func NewHS256() Hash { - return HS256{} -} - // Alg returns the algorithm name "HS256" func (HS256) Alg() string { return HS256Name @@ -54,11 +55,6 @@ func (HS256) Hash() hash.Hash { // HS384 implements the Hash interface with SHA384 type HS384 struct{} -// NewHS384 returns a new HS384 instance -func NewHS384() Hash { - return HS384{} -} - // Alg returns the algorithm name "HS384" func (HS384) Alg() string { return HS384Name @@ -72,11 +68,6 @@ func (HS384) Hash() hash.Hash { // HS512 implements the Hash interface with SHA512 type HS512 struct{} -// NewHS512 returns a new HS512 instance -func NewHS512() Hash { - return HS512{} -} - // Alg returns the algorithm name "HS512" func (HS512) Alg() string { return HS512Name diff --git a/jwt.go b/jwt.go index 76217f4..f9ce6af 100644 --- a/jwt.go +++ b/jwt.go @@ -1,4 +1,4 @@ -// Copyright (C) 2018 Marius Schellenberger +// Copyright (C) 2025 Marius Schellenberger // Package jwt provides a easy to use JSON Web Token and blacklisting library package jwt @@ -6,7 +6,6 @@ package jwt import ( "crypto/hmac" "crypto/rand" - "encoding/base64" "encoding/json" "errors" "io" @@ -32,6 +31,8 @@ const ( ExpClaim = "exp" // NbfClaim is the nbf claim name NbfClaim = "nbf" + // NonceClaim + NonceClaim = "nonce" // TokenSeparator is the tokens separator char TokenSeparator = "." ) @@ -58,14 +59,39 @@ var ( ErrInvalidKeySize = errors.New(jwtErr + "invalid secret key size") ) +type JWTOption func(*JWT) + +func WithExpiry(expiry time.Duration) JWTOption { + return func(jwt *JWT) { + jwt.expiry = expiry + } +} + +func WithBlacklist(blacklist Blacklist) JWTOption { + return func(jwt *JWT) { + jwt.blacklist = blacklist + } +} +func WithSecret(secret io.Reader) JWTOption { + return func(jwt *JWT) { + jwt.secretReader = secret + } +} +func WithNonce() JWTOption { + return func(jwt *JWT) { + jwt.nonce = true + } +} + // JWT represents the JSON Web Token signing and blacklisting infrastructure type JWT struct { - key []byte - expiry time.Duration - - blacklist Blacklist - stopOnce sync.Once - done chan struct{} + secretReader io.Reader + key []byte + expiry time.Duration + blacklist Blacklist + nonce bool + stopOnce sync.Once + done chan struct{} } // New returns a new JWT object with the given expiry timeout. @@ -74,14 +100,18 @@ type JWT struct { // If secret is nil the DefaultSecretReader is used. // If blacklisting is enabled, the JWT object leaks a goroutine to garbage-collect expired blacklisted tokens. // Call the Stop() method to exit the goroutine. -func New(expiry time.Duration, blacklist Blacklist, secret io.Reader) (*JWT, error) { - if expiry <= 0 { - expiry = DefaultExpiry +func New(options ...JWTOption) (*JWT, error) { + jwt := &JWT{} + for _, option := range options { + option(jwt) } - if secret == nil { - secret = DefaultSecretReader + if jwt.expiry <= 0 { + jwt.expiry = DefaultExpiry } - secret = io.LimitReader(secret, KeySize) + if jwt.secretReader == nil { + jwt.secretReader = DefaultSecretReader + } + secret := io.LimitReader(jwt.secretReader, KeySize) key := make([]byte, KeySize) i, err := secret.Read(key) if err != nil { @@ -90,12 +120,8 @@ func New(expiry time.Duration, blacklist Blacklist, secret io.Reader) (*JWT, err if i < KeySize { return nil, ErrInvalidKeySize } - jwt := &JWT{ - key: key, - expiry: expiry, - blacklist: blacklist, - } - if blacklist != nil { + jwt.key = key + if jwt.blacklist != nil { jwt.done = make(chan struct{}) go jwt.clean() } @@ -129,7 +155,7 @@ func (jwt *JWT) Invalidate(t *Token) error { if t.Header.GetString(TypClaim) != Typ { return ErrNoJWT } - h := ParseHash(t.Header.GetString(AlgClaim)) + h := NewHash(t.Header.GetString(AlgClaim)) if h == nil { return ErrUnsupportedAlg } @@ -143,8 +169,7 @@ func (jwt *JWT) Invalidate(t *Token) error { if !hmac.Equal(jwt.sum(t.Data(), h), t.RawSig()) { return ErrInvalid } - jwt.blacklist.Add(t.Sig(), exp) - return nil + return jwt.blacklist.Add(t.Sig(), exp) } // blacklisted checks if a token is blacklisted @@ -169,7 +194,11 @@ func (jwt *JWT) clean() { return } now := Now() - for k, v := range jwt.blacklist.Map() { + m, err := jwt.blacklist.Map() + if err != nil { + continue + } + for k, v := range m { if now > v { jwt.blacklist.Remove(k) } @@ -188,7 +217,7 @@ func (jwt *JWT) Sign(t *Token) (err error) { if t.Header.GetString(TypClaim) != Typ { return ErrNoJWT } - h := ParseHash(t.Header.GetString(AlgClaim)) + h := NewHash(t.Header.GetString(AlgClaim)) if h == nil { return ErrUnsupportedAlg } @@ -199,6 +228,9 @@ func (jwt *JWT) Sign(t *Token) (err error) { if _, ok := t.Claims.Get(NbfClaim); !ok { t.Claims.Set(NbfClaim, NewNbf(now)) } + if jwt.nonce { + t.Claims.Set(NonceClaim, enc.EncodeToString(key16())) + } head, err := json.Marshal(t.Header) if err != nil { return @@ -207,9 +239,9 @@ func (jwt *JWT) Sign(t *Token) (err error) { if err != nil { return } - t.data = base64.URLEncoding.EncodeToString(head) + TokenSeparator + base64.URLEncoding.EncodeToString(claims) + t.data = enc.EncodeToString(head) + TokenSeparator + enc.EncodeToString(claims) t.rawSignature = jwt.sum(t.data, h) - t.signature = base64.URLEncoding.EncodeToString(t.rawSignature) + t.signature = enc.EncodeToString(t.rawSignature) t.raw = t.data + TokenSeparator + t.signature return } @@ -228,7 +260,7 @@ func (jwt *JWT) Verify(t *Token) error { if t.Header.GetString(TypClaim) != Typ { return ErrNoJWT } - h := ParseHash(t.Header.GetString(AlgClaim)) + h := NewHash(t.Header.GetString(AlgClaim)) if h == nil { return ErrUnsupportedAlg } diff --git a/jwt_test.go b/jwt_test.go index 59789d3..664cf8a 100644 --- a/jwt_test.go +++ b/jwt_test.go @@ -1,4 +1,4 @@ -// Copyright (C) 2018 Marius Schellenberger +// Copyright (C) 2025 Marius Schellenberger package jwt @@ -17,7 +17,10 @@ var claims = Claims{ } func TestValidate(t *testing.T) { - jwt, err := New(time.Second, NewMemBlacklist(), nil) + jwt, err := New( + WithExpiry(time.Second), + WithBlacklist(NewMemBlacklist()), + ) if err != nil { t.Error(err) } @@ -83,8 +86,35 @@ func TestValidate(t *testing.T) { } } +func TestNonce(t *testing.T) { + jwt, err := New( + WithExpiry(time.Second), + WithNonce(), + ) + if err != nil { + t.Error(err) + } + token := NewToken(claims.Copy(), nil) + err = jwt.Sign(token) + if err != nil { + t.Error(err) + } + nt, err := DecodeToken(token.String()) + if err != nil { + t.Error(err) + } + err = jwt.Verify(nt) + if err != nil { + t.Error(err) + } + nonce := nt.Claims.GetString(NonceClaim) + if nonce == "" { + t.Error(errors.New("nonce is empty")) + } +} + func TestNoBlacklist(t *testing.T) { - jwt, err := New(time.Second, nil, nil) + jwt, err := New(WithExpiry(time.Second)) if err != nil { t.Error(err) } @@ -118,21 +148,27 @@ func TestNoBlacklist(t *testing.T) { } func TestEmptySecretReader(t *testing.T) { - _, err := New(time.Second, nil, new(bytes.Buffer)) + _, err := New( + WithExpiry(time.Second), + WithSecret(new(bytes.Buffer)), + ) if err == nil { t.Error(errors.New("error should be secret reader error")) } } func TestInvalidSecretReader(t *testing.T) { - _, err := New(time.Second, nil, bytes.NewBufferString("123")) + _, err := New( + WithExpiry(time.Second), + WithSecret(bytes.NewBufferString("123")), + ) if err != ErrInvalidKeySize { t.Error(errors.New("error should be invalid key size")) } } func BenchmarkDecodeToken(b *testing.B) { - jwt, _ := New(0, nil, nil) + jwt, _ := New() t := NewToken(claims.Copy(), nil) jwt.Sign(t) token := t.String() @@ -145,19 +181,19 @@ func BenchmarkDecodeToken(b *testing.B) { func BenchmarkNew(b *testing.B) { b.ReportAllocs() for i := 0; i < b.N; i++ { - New(0, nil, nil) + New() } } func BenchmarkNewWithBlacklist(b *testing.B) { b.ReportAllocs() for i := 0; i < b.N; i++ { - New(0, NewMemBlacklist(), nil) + New(WithBlacklist(NewMemBlacklist())) } } func BenchmarkSignHS256(b *testing.B) { - jwt, _ := New(0, nil, nil) + jwt, _ := New() t := NewToken(claims.Copy(), nil) b.ReportAllocs() for i := 0; i < b.N; i++ { @@ -166,8 +202,8 @@ func BenchmarkSignHS256(b *testing.B) { } func BenchmarkSignHS384(b *testing.B) { - jwt, _ := New(0, nil, nil) - t := NewToken(claims.Copy(), NewHS384()) + jwt, _ := New() + t := NewToken(claims.Copy(), NewHash(HS384Name)) b.ReportAllocs() for i := 0; i < b.N; i++ { jwt.Sign(t) @@ -175,8 +211,8 @@ func BenchmarkSignHS384(b *testing.B) { } func BenchmarkSignHS512(b *testing.B) { - jwt, _ := New(0, nil, nil) - t := NewToken(claims.Copy(), NewHS512()) + jwt, _ := New() + t := NewToken(claims.Copy(), NewHash(HS512Name)) b.ReportAllocs() for i := 0; i < b.N; i++ { jwt.Sign(t) @@ -184,7 +220,7 @@ func BenchmarkSignHS512(b *testing.B) { } func BenchmarkVerifyHS256(b *testing.B) { - jwt, _ := New(0, nil, nil) + jwt, _ := New() t := NewToken(claims.Copy(), nil) jwt.Sign(t) b.ReportAllocs() @@ -194,8 +230,8 @@ func BenchmarkVerifyHS256(b *testing.B) { } func BenchmarkVerifyHS384(b *testing.B) { - jwt, _ := New(0, nil, nil) - t := NewToken(claims.Copy(), NewHS384()) + jwt, _ := New() + t := NewToken(claims.Copy(), NewHash(HS384Name)) jwt.Sign(t) b.ReportAllocs() for i := 0; i < b.N; i++ { @@ -204,8 +240,8 @@ func BenchmarkVerifyHS384(b *testing.B) { } func BenchmarkVerifyHS512(b *testing.B) { - jwt, _ := New(0, nil, nil) - t := NewToken(claims.Copy(), NewHS512()) + jwt, _ := New() + t := NewToken(claims.Copy(), NewHash(HS512Name)) jwt.Sign(t) b.ReportAllocs() for i := 0; i < b.N; i++ { diff --git a/rand.go b/rand.go new file mode 100644 index 0000000..bd1b191 --- /dev/null +++ b/rand.go @@ -0,0 +1,11 @@ +// Copyright (C) 2025 Marius Schellenberger + +package jwt + +import "crypto/rand" + +func key16() []byte { + b := make([]byte, 16) + rand.Read(b) + return b +} diff --git a/time.go b/time.go index 71b10ca..77e151f 100644 --- a/time.go +++ b/time.go @@ -1,3 +1,5 @@ +// Copyright (C) 2025 Marius Schellenberger + package jwt import "time" diff --git a/token.go b/token.go index 83aee75..772415c 100644 --- a/token.go +++ b/token.go @@ -1,9 +1,8 @@ -// Copyright (C) 2018 Marius Schellenberger +// Copyright (C) 2025 Marius Schellenberger package jwt import ( - "encoding/base64" "encoding/json" "strings" ) @@ -46,7 +45,7 @@ func NewToken(claims Claims, hash Hash) *Token { claims = make(Claims) } if hash == nil { - hash = NewHS256() + hash = NewHash(HS256Name) } return &Token{ Header: Claims{ @@ -75,7 +74,7 @@ func DecodeToken(token string) (t *Token, err error) { } } t = new(Token) - header, err := base64.URLEncoding.DecodeString(parts[0]) + header, err := enc.DecodeString(parts[0]) if err != nil { return } @@ -83,7 +82,7 @@ func DecodeToken(token string) (t *Token, err error) { if err != nil { return } - claims, err := base64.URLEncoding.DecodeString(parts[1]) + claims, err := enc.DecodeString(parts[1]) if err != nil { return } @@ -91,7 +90,7 @@ func DecodeToken(token string) (t *Token, err error) { if err != nil { return } - t.rawSignature, err = base64.URLEncoding.DecodeString(parts[2]) + t.rawSignature, err = enc.DecodeString(parts[2]) t.signature = parts[2] t.data = parts[0] + TokenSeparator + parts[1] t.raw = token