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/README.md b/README.md index ad89f76..74aa8a3 100644 --- a/README.md +++ b/README.md @@ -1 +1,61 @@ -# jwt - a simple JSON Web Token library in Go +# jwt - a simple JSON Web Token library written in Go + +## Usage + +``` +package main + +import ( + "fmt" + "git.giftfish.de/ston1th/jwt" + "log" + "time" +) + +func main() { + // create a new signing instance + JWT, err := jwt.New(time.Hour, jwt.NewMemBlacklist(), nil) + if err != nil { + log.Fatal(err) + } + + // create a new token + t := jwt.NewToken(jwt.Claims{ + "username": "admin", + }, nil) + + // sign the token + err = JWT.Sign(t) + if err != nil { + log.Fatal(err) + } + + // print the token string + token := t.String() + fmt.Println(token) + + // decode the token string + newToken, err := jwt.DecodeToken(token) + if err != nil { + log.Fatal(err) + } + + // verify the decoded token + err = JWT.Verify(newToken) + if err != nil { + log.Fatal(err) + } + + // add the token to the blacklist + err = JWT.Invalidate(newToken) + if err != nil { + log.Fatal(err) + } + + // try to verify the blacklisted token, it should fail + err = JWT.Verify(newToken) + if err != nil { + log.Fatal(err) + } +} +``` diff --git a/blacklist.go b/blacklist.go index 024796d..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 @@ -22,23 +22,25 @@ type MemBlacklist struct { list BlacklistMap } -// NewMemBlacklist +// NewMemBlacklist implements the Blacklist interface using an in-memory map func NewMemBlacklist() *MemBlacklist { return &MemBlacklist{list: make(BlacklistMap)} } // 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 new file mode 100644 index 0000000..a5f4364 --- /dev/null +++ b/claims.go @@ -0,0 +1,78 @@ +// Copyright (C) 2025 Marius Schellenberger + +package jwt + +// Claims is the claim type of the token +// The Claims map is not goroutine safe +type Claims map[string]interface{} + +// Get returns a value from the claims map +func (c Claims) Get(key string) (v interface{}, ok bool) { + v, ok = c[key] + return +} + +// GetString returns a string from the claims map +func (c Claims) GetString(key string) (s string) { + if v, ok := c.Get(key); ok && v != nil { + s, _ = v.(string) + } + return +} + +// GetBool returns a bool from the claims map +func (c Claims) GetBool(key string) (b bool) { + if v, ok := c.Get(key); ok && v != nil { + b, _ = v.(bool) + } + return +} + +// GetInt returns an int from the claims map +func (c Claims) GetInt(key string) (i int) { + i = int(c.GetInt64(key)) + return +} + +// GetInt64 returns an int64 from the claims map +func (c Claims) GetInt64(key string) (i int64) { + if v, ok := c.Get(key); ok && v != nil { + 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) { + if v, ok := c.Get(key); ok && v != nil { + f, _ = v.(float64) + } + return +} + +// Set sets the value of key in the claims map, if not nil +func (c Claims) Set(key string, v interface{}) { + if c == nil { + return + } + c[key] = v +} + +// Delete removes the key from the claims map +func (c Claims) Delete(key string) { + delete(c, key) +} + +// Copy returns a new Claims map +func (c Claims) Copy() (n Claims) { + n = make(Claims) + for k, v := range c { + n[k] = v + } + 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/go.mod b/go.mod new file mode 100644 index 0000000..6752b42 --- /dev/null +++ b/go.mod @@ -0,0 +1 @@ +module git.giftfish.de/ston1th/jwt/v3 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 2106f13..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,85 +6,134 @@ package jwt import ( "crypto/hmac" "crypto/rand" - "encoding/base64" "encoding/json" "errors" "io" - "strings" + "sync" "time" ) const ( - // Header is the default HTTP Authorization header name - Header = "Authorization" + // HTTPHeader is the default HTTP Authorization header name + HTTPHeader = "Authorization" // DefaultExpiry is the default token expiration time DefaultExpiry = time.Hour * 12 // KeySize is the secret key size KeySize = 64 - typ = "JWT" + // Typ is the JWT type + Typ = "JWT" + // TypClaim is the typ claim name + TypClaim = "typ" + // AlgClaim is the alg claim name + AlgClaim = "alg" + // ExpClaim is the exp claim name + ExpClaim = "exp" + // NbfClaim is the nbf claim name + NbfClaim = "nbf" + // NonceClaim + NonceClaim = "nonce" + // TokenSeparator is the tokens separator char + TokenSeparator = "." ) // DefaultSecretReader is the default secret key generator var DefaultSecretReader = rand.Reader +const jwtErr = "jwt: " + var ( - ErrNoJWT = errors.New("not a json web token") - ErrEmptyToken = errors.New("token is empty") - ErrUnsupportedAlg = errors.New("unsupported algorithm") - ErrInvalid = errors.New("token validation failed") - ErrBlacklisted = errors.New("token blacklisted") - ErrBlacklistNotEnabled = errors.New("blacklisting is not enabled") - ErrNBF = errors.New("token not valid yet") - ErrEXP = errors.New("token expired") - ErrMissingEXP = errors.New("missing exp claim") - ErrMissingTokenParts = errors.New("missing token parts") - ErrEmptySignature = errors.New("token signature is empty") - ErrTokenIsNil = errors.New("token is nil") - ErrInvalidKeySize = errors.New("invalid secret key size") + ErrNoJWT = errors.New(jwtErr + "not a json web token") + ErrEmptyToken = errors.New(jwtErr + "token is empty") + ErrUnsupportedAlg = errors.New(jwtErr + "unsupported algorithm") + ErrInvalid = errors.New(jwtErr + "token validation failed") + ErrBlacklisted = errors.New(jwtErr + "token blacklisted") + ErrBlacklistNotEnabled = errors.New(jwtErr + "blacklisting is not enabled") + ErrNbf = errors.New(jwtErr + "token not valid yet") + ErrExp = errors.New(jwtErr + "token expired") + ErrMissingNbf = errors.New(jwtErr + "missing nbf claim") + ErrMissingExp = errors.New(jwtErr + "missing exp claim") + ErrMissingTokenParts = errors.New(jwtErr + "missing token parts") + ErrEmptySignature = errors.New(jwtErr + "token signature is empty") + ErrTokenIsNil = errors.New(jwtErr + "token is nil") + 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 - 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. // If the timeout is less or equal to zero the default expiry (12 hours) is used. +// The secret key size needs to be at least 64 bytes. +// 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 { - return nil, errors.New("secret reader error: " + err.Error()) + return nil, errors.New(jwtErr + "secret reader error: " + err.Error()) } 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() } return jwt, nil } +// Expiry returns the configured expiry +func (jwt *JWT) Expiry() time.Duration { + return jwt.expiry +} + +// sum calculates the HMAC hash sum of the token func (jwt *JWT) sum(token string, h Hash) []byte { mac := hmac.New(h.Hash, jwt.key) mac.Write([]byte(token)) @@ -100,28 +149,27 @@ func (jwt *JWT) Invalidate(t *Token) error { if t == nil { return ErrTokenIsNil } - var ( - expOK bool - exp int64 - ) - switch n := t.Claims["exp"].(type) { - case json.Number: - if i, err := n.Int64(); err == nil { - exp = i - expOK = true - } - case float64: - exp = int64(n) - expOK = true - } - if !expOK { - return ErrMissingEXP - } if err := jwt.blacklisted(t.Sig()); err != nil { return err } - jwt.blacklist.Add(t.Sig(), exp) - return nil + if t.Header.GetString(TypClaim) != Typ { + return ErrNoJWT + } + h := NewHash(t.Header.GetString(AlgClaim)) + if h == nil { + return ErrUnsupportedAlg + } + exp := t.Claims.GetInt64(ExpClaim) + switch { + case exp == 0: + return ErrMissingExp + case Now() > exp: + return ErrExp + } + if !hmac.Equal(jwt.sum(t.Data(), h), t.RawSig()) { + return ErrInvalid + } + return jwt.blacklist.Add(t.Sig(), exp) } // blacklisted checks if a token is blacklisted @@ -145,8 +193,12 @@ func (jwt *JWT) clean() { t.Stop() return } - now := time.Now().UTC().Unix() - for k, v := range jwt.blacklist.Map() { + now := Now() + m, err := jwt.blacklist.Map() + if err != nil { + continue + } + for k, v := range m { if now > v { jwt.blacklist.Remove(k) } @@ -155,34 +207,31 @@ func (jwt *JWT) clean() { } // Sign will sign the provided token using the secret key -// This will overwrite existing 'exp' and 'nbf' claims +// If the 'exp' and 'nbf' claims do not exist, they will be written to the default values in UNIX format: +// 'exp': Now + expiry specified at New() or DefaultExpiry (UTC) +// 'nbf': Now (UTC) func (jwt *JWT) Sign(t *Token) (err error) { if t == nil { return ErrTokenIsNil } - header := t.Header() - switch t := header["typ"].(type) { - case string: - if t != typ { - return ErrNoJWT - } - default: + if t.Header.GetString(TypClaim) != Typ { return ErrNoJWT } - var h Hash - switch a := header["alg"].(type) { - case string: - h = ParseHash(a) - if h == nil { - return ErrUnsupportedAlg - } - default: + h := NewHash(t.Header.GetString(AlgClaim)) + if h == nil { return ErrUnsupportedAlg } - now := time.Now().UTC() - t.Claims["exp"] = now.Add(jwt.expiry).Unix() - t.Claims["nbf"] = now.Unix() - head, err := json.Marshal(header) + now := time.Now() + if _, ok := t.Claims.Get(ExpClaim); !ok { + t.Claims.Set(ExpClaim, newExp(now, jwt.expiry)) + } + 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 } @@ -190,8 +239,10 @@ func (jwt *JWT) Sign(t *Token) (err error) { if err != nil { return } - t.data = strings.Join([]string{base64.URLEncoding.EncodeToString(head), base64.URLEncoding.EncodeToString(claims)}, ".") - t.raw = strings.Join([]string{t.data, base64.URLEncoding.EncodeToString(jwt.sum(t.data, h))}, ".") + t.data = enc.EncodeToString(head) + TokenSeparator + enc.EncodeToString(claims) + t.rawSignature = jwt.sum(t.data, h) + t.signature = enc.EncodeToString(t.rawSignature) + t.raw = t.data + TokenSeparator + t.signature return } @@ -200,64 +251,32 @@ func (jwt *JWT) Verify(t *Token) error { if t == nil { return ErrTokenIsNil } - now := time.Now().UTC().Unix() - var ( - exp int64 - nbf int64 - expOK bool - nbfOK bool - h Hash - ) - header := t.Header() - switch t := header["typ"].(type) { - case string: - if t != typ { - return ErrNoJWT - } - default: - return ErrNoJWT - } - switch a := header["alg"].(type) { - case string: - h = ParseHash(a) - if h == nil { - return ErrUnsupportedAlg - } - default: - return ErrUnsupportedAlg - } - - switch n := t.Claims["exp"].(type) { - case json.Number: - if i, err := n.Int64(); err == nil { - exp = i - expOK = true - } - case float64: - exp = int64(n) - expOK = true - } - switch n := t.Claims["nbf"].(type) { - case json.Number: - if i, err := n.Int64(); err == nil { - nbf = i - nbfOK = true - } - case float64: - nbf = int64(n) - nbfOK = true - } if jwt.blacklist != nil { - err := jwt.blacklisted(t.Sig()) - if err != nil { + if err := jwt.blacklisted(t.Sig()); err != nil { return err } } - if expOK && now > exp { - return ErrEXP + now := Now() + if t.Header.GetString(TypClaim) != Typ { + return ErrNoJWT } - if nbfOK && now < nbf { - return ErrNBF + h := NewHash(t.Header.GetString(AlgClaim)) + if h == nil { + return ErrUnsupportedAlg + } + exp := t.Claims.GetInt64(ExpClaim) + switch { + case exp == 0: + return ErrMissingExp + case now > exp: + return ErrExp + } + nbf := t.Claims.GetInt64(NbfClaim) + switch { + case nbf == 0: + return ErrMissingNbf + case now < nbf: + return ErrNbf } if !hmac.Equal(jwt.sum(t.Data(), h), t.RawSig()) { return ErrInvalid @@ -265,103 +284,13 @@ func (jwt *JWT) Verify(t *Token) error { return nil } -// Stop will end the cleaner goroutinge execution -// Calling Stop twice or more will panic +// Stop will end the cleaner goroutine execution func (jwt *JWT) Stop() error { if jwt.blacklist == nil { return ErrBlacklistNotEnabled } - close(jwt.done) + jwt.stopOnce.Do(func() { + close(jwt.done) + }) return nil } - -// DecodeToken decodes a raw string token into a *Token object -func DecodeToken(token string) (t *Token, err error) { - if token == "" { - return nil, ErrEmptyToken - } - parts := strings.Split(token, ".") - if len(parts) < 3 { - return nil, ErrMissingTokenParts - } - if len(parts) > 3 { - return nil, ErrNoJWT - } - for _, v := range parts { - if v == "" { - return nil, ErrNoJWT - } - } - t = new(Token) - header, err := base64.URLEncoding.DecodeString(parts[0]) - if err != nil { - return - } - err = json.Unmarshal(header, &t.header) - if err != nil { - return - } - claims, err := base64.URLEncoding.DecodeString(parts[1]) - if err != nil { - return - } - err = json.Unmarshal(claims, &t.Claims) - if err != nil { - return - } - t.rawSignature, err = base64.URLEncoding.DecodeString(parts[2]) - t.signature = parts[2] - t.data = strings.Join(parts[:2], ".") - t.raw = token - return -} - -// Token is the JWT token representation -type Token struct { - raw string - data string - signature string - rawSignature []byte - header map[string]interface{} - Claims map[string]interface{} -} - -// String returns the tokens encoded string -func (t *Token) String() string { - return t.raw -} - -// Sig returns the Tokens URLEncoded signature -func (t *Token) Sig() string { - return t.signature -} - -// RawSig returns the Tokens raw signature -func (t *Token) RawSig() []byte { - return t.rawSignature -} - -// Header returns the Tokens header -func (t *Token) Header() map[string]interface{} { - return t.header -} - -// Data returns the first two token fields -func (t *Token) Data() string { - return t.data -} - -// NewToken returns a new *Token using the provided hash algorithm and claims -// If h is nil HS256 will be used -func NewToken(claims map[string]interface{}, h Hash) *Token { - if h == nil { - h = NewHS256() - } - return &Token{ - header: map[string]interface{}{ - "alg": h.Alg(), - "typ": typ, - }, - Claims: claims, - } -} diff --git a/jwt_test.go b/jwt_test.go index 30cfbfe..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 @@ -9,17 +9,22 @@ import ( "time" ) +var claims = Claims{ + "sub": "1234567890", + "name": "John Doe", + "admin": true, + "fizz": "buzz", +} + 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) } - token := NewToken(map[string]interface{}{ - "sub": "1234567890", - "name": "John Doe", - "admin": true, - "fizz": "buzz", - }, nil) + token := NewToken(claims.Copy(), nil) err = jwt.Sign(token) if err != nil { t.Error(err) @@ -42,6 +47,21 @@ func TestValidate(t *testing.T) { t.Error(errors.New("token should be expired")) } + err = jwt.Invalidate(nt) + if err == nil { + t.Error(errors.New("token is expired")) + } + + nt.Claims.Delete(ExpClaim) + err = jwt.Sign(nt) + if err != nil { + t.Error(err) + } + err = jwt.Verify(nt) + if err != nil { + t.Error(err) + } + err = jwt.Invalidate(nt) if err != nil { t.Error(err) @@ -50,6 +70,7 @@ func TestValidate(t *testing.T) { if err == nil { t.Error(errors.New("double invalidate")) } + err = jwt.Verify(nt) if err == nil { t.Error(errors.New("token should be blacklisted")) @@ -58,19 +79,46 @@ func TestValidate(t *testing.T) { if err != nil { t.Error(err) } -} - -func TestNoBlacklist(t *testing.T) { - jwt, err := New(time.Second, nil, nil) + // should not panic + err = jwt.Stop() if err != nil { t.Error(err) } - token := NewToken(map[string]interface{}{ - "sub": "1234567890", - "name": "John Doe", - "admin": true, - "fizz": "buzz", - }, nil) +} + +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(WithExpiry(time.Second)) + if err != nil { + t.Error(err) + } + token := NewToken(claims.Copy(), nil) err = jwt.Sign(token) if err != nil { t.Error(err) @@ -100,15 +148,103 @@ 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() + t := NewToken(claims.Copy(), nil) + jwt.Sign(t) + token := t.String() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + DecodeToken(token) + } +} + +func BenchmarkNew(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + New() + } +} + +func BenchmarkNewWithBlacklist(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + New(WithBlacklist(NewMemBlacklist())) + } +} + +func BenchmarkSignHS256(b *testing.B) { + jwt, _ := New() + t := NewToken(claims.Copy(), nil) + b.ReportAllocs() + for i := 0; i < b.N; i++ { + jwt.Sign(t) + } +} + +func BenchmarkSignHS384(b *testing.B) { + jwt, _ := New() + t := NewToken(claims.Copy(), NewHash(HS384Name)) + b.ReportAllocs() + for i := 0; i < b.N; i++ { + jwt.Sign(t) + } +} + +func BenchmarkSignHS512(b *testing.B) { + jwt, _ := New() + t := NewToken(claims.Copy(), NewHash(HS512Name)) + b.ReportAllocs() + for i := 0; i < b.N; i++ { + jwt.Sign(t) + } +} + +func BenchmarkVerifyHS256(b *testing.B) { + jwt, _ := New() + t := NewToken(claims.Copy(), nil) + jwt.Sign(t) + b.ReportAllocs() + for i := 0; i < b.N; i++ { + jwt.Verify(t) + } +} + +func BenchmarkVerifyHS384(b *testing.B) { + jwt, _ := New() + t := NewToken(claims.Copy(), NewHash(HS384Name)) + jwt.Sign(t) + b.ReportAllocs() + for i := 0; i < b.N; i++ { + jwt.Verify(t) + } +} + +func BenchmarkVerifyHS512(b *testing.B) { + jwt, _ := New() + t := NewToken(claims.Copy(), NewHash(HS512Name)) + jwt.Sign(t) + b.ReportAllocs() + for i := 0; i < b.N; i++ { + jwt.Verify(t) + } +} 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 new file mode 100644 index 0000000..77e151f --- /dev/null +++ b/time.go @@ -0,0 +1,24 @@ +// Copyright (C) 2025 Marius Schellenberger + +package jwt + +import "time" + +// Now returns the current time in UTC Unix format +func Now() int64 { + return NewNbf(time.Now()) +} + +// NewNbf returns a new 'not before' date +func NewNbf(t time.Time) int64 { + return t.UTC().Unix() +} + +// NewExp returns a new expiration date +func NewExp(d time.Duration) int64 { + return newExp(time.Now(), d) +} + +func newExp(t time.Time, d time.Duration) int64 { + return t.UTC().Add(d).Unix() +} diff --git a/token.go b/token.go new file mode 100644 index 0000000..772415c --- /dev/null +++ b/token.go @@ -0,0 +1,98 @@ +// Copyright (C) 2025 Marius Schellenberger + +package jwt + +import ( + "encoding/json" + "strings" +) + +// Token represents a JWT token +type Token struct { + raw string + data string + signature string + rawSignature []byte + Header Claims + Claims Claims +} + +// String returns the tokens encoded string +func (t *Token) String() string { + return t.raw +} + +// Sig returns the tokens URLEncoded signature +func (t *Token) Sig() string { + return t.signature +} + +// RawSig returns the tokens raw signature +func (t *Token) RawSig() []byte { + return t.rawSignature +} + +// Data returns the first two URLEncoded token fields +func (t *Token) Data() string { + return t.data +} + +// NewToken returns a new *Token using the provided hash algorithm and claims +// If claims is nil, an empty map is used +// If hash is nil, HS256 is used +func NewToken(claims Claims, hash Hash) *Token { + if claims == nil { + claims = make(Claims) + } + if hash == nil { + hash = NewHash(HS256Name) + } + return &Token{ + Header: Claims{ + AlgClaim: hash.Alg(), + TypClaim: Typ, + }, + Claims: claims, + } +} + +// DecodeToken decodes a raw string token into a *Token object +func DecodeToken(token string) (t *Token, err error) { + if token == "" { + return nil, ErrEmptyToken + } + parts := strings.Split(token, TokenSeparator) + if len(parts) < 3 { + return nil, ErrMissingTokenParts + } + if len(parts) > 3 { + return nil, ErrNoJWT + } + for _, v := range parts { + if v == "" { + return nil, ErrNoJWT + } + } + t = new(Token) + header, err := enc.DecodeString(parts[0]) + if err != nil { + return + } + err = json.Unmarshal(header, &t.Header) + if err != nil { + return + } + claims, err := enc.DecodeString(parts[1]) + if err != nil { + return + } + err = json.Unmarshal(claims, &t.Claims) + if err != nil { + return + } + t.rawSignature, err = enc.DecodeString(parts[2]) + t.signature = parts[2] + t.data = parts[0] + TokenSeparator + parts[1] + t.raw = token + return +}