Compare commits

...

12 commits

12 changed files with 611 additions and 274 deletions

View file

@ -1,4 +1,4 @@
Copyright (C) 2018 Marius Schellenberger Copyright (C) 2025 Marius Schellenberger
All rights reserved. All rights reserved.
Redistribution and use in source and binary forms, with or without Redistribution and use in source and binary forms, with or without

View file

@ -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)
}
}
```

View file

@ -1,4 +1,4 @@
// Copyright (C) 2018 Marius Schellenberger // Copyright (C) 2025 Marius Schellenberger
package jwt package jwt
@ -6,10 +6,10 @@ import "sync"
// Blacklist is the blacklisting storage interface // Blacklist is the blacklisting storage interface
type Blacklist interface { type Blacklist interface {
Add(string, int64) Add(string, int64) error
Remove(string) Remove(string) error
Check(string) bool Check(string) bool
Map() BlacklistMap Map() (BlacklistMap, error)
} }
// BlacklistMap is the blacklist map structure // BlacklistMap is the blacklist map structure
@ -22,23 +22,25 @@ type MemBlacklist struct {
list BlacklistMap list BlacklistMap
} }
// NewMemBlacklist // NewMemBlacklist implements the Blacklist interface using an in-memory map
func NewMemBlacklist() *MemBlacklist { func NewMemBlacklist() *MemBlacklist {
return &MemBlacklist{list: make(BlacklistMap)} return &MemBlacklist{list: make(BlacklistMap)}
} }
// Add adds a new token signature with expiration time to the blacklist // 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.Lock()
mb.list[sig] = exp mb.list[sig] = exp
mb.Unlock() mb.Unlock()
return nil
} }
// Remove deletes a token signature from the blacklist // Remove deletes a token signature from the blacklist
func (mb *MemBlacklist) Remove(sig string) { func (mb *MemBlacklist) Remove(sig string) error {
mb.Lock() mb.Lock()
delete(mb.list, sig) delete(mb.list, sig)
mb.Unlock() mb.Unlock()
return nil
} }
// Check returns true if a token signature is blacklisted and false otherwise // 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 // 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) list = make(BlacklistMap)
mb.RLock() mb.RLock()
for k, v := range mb.list { for k, v := range mb.list {

78
claims.go Normal file
View file

@ -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
}

7
encoding.go Normal file
View file

@ -0,0 +1,7 @@
// Copyright (C) 2025 Marius Schellenberger
package jwt
import "encoding/base64"
var enc = base64.RawURLEncoding

1
go.mod Normal file
View file

@ -0,0 +1 @@
module git.giftfish.de/ston1th/jwt/v3

33
hash.go
View file

@ -1,4 +1,4 @@
// Copyright (C) 2018 Marius Schellenberger // Copyright (C) 2025 Marius Schellenberger
package jwt package jwt
@ -14,19 +14,25 @@ const (
HS512Name = "HS512" HS512Name = "HS512"
) )
// ParseHash returns the Hash type equal to the input string // NewHash returns the Hash type equal to the input string
func ParseHash(alg string) Hash { func NewHash(alg string) Hash {
switch alg { switch alg {
case HS256Name: case HS256Name:
return NewHS256() return hs256
case HS384Name: case HS384Name:
return NewHS384() return hs384
case HS512Name: case HS512Name:
return NewHS512() return hs512
} }
return nil return nil
} }
var (
hs256 = HS256{}
hs384 = HS384{}
hs512 = HS512{}
)
// Hash is the hashsum interface for signing the jwt // Hash is the hashsum interface for signing the jwt
type Hash interface { type Hash interface {
Hash() hash.Hash Hash() hash.Hash
@ -36,11 +42,6 @@ type Hash interface {
// HS256 implements the Hash interface with SHA256 // HS256 implements the Hash interface with SHA256
type HS256 struct{} type HS256 struct{}
// NewHS256 returns a new HS256 instance
func NewHS256() Hash {
return HS256{}
}
// Alg returns the algorithm name "HS256" // Alg returns the algorithm name "HS256"
func (HS256) Alg() string { func (HS256) Alg() string {
return HS256Name return HS256Name
@ -54,11 +55,6 @@ func (HS256) Hash() hash.Hash {
// HS384 implements the Hash interface with SHA384 // HS384 implements the Hash interface with SHA384
type HS384 struct{} type HS384 struct{}
// NewHS384 returns a new HS384 instance
func NewHS384() Hash {
return HS384{}
}
// Alg returns the algorithm name "HS384" // Alg returns the algorithm name "HS384"
func (HS384) Alg() string { func (HS384) Alg() string {
return HS384Name return HS384Name
@ -72,11 +68,6 @@ func (HS384) Hash() hash.Hash {
// HS512 implements the Hash interface with SHA512 // HS512 implements the Hash interface with SHA512
type HS512 struct{} type HS512 struct{}
// NewHS512 returns a new HS512 instance
func NewHS512() Hash {
return HS512{}
}
// Alg returns the algorithm name "HS512" // Alg returns the algorithm name "HS512"
func (HS512) Alg() string { func (HS512) Alg() string {
return HS512Name return HS512Name

383
jwt.go
View file

@ -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 provides a easy to use JSON Web Token and blacklisting library
package jwt package jwt
@ -6,85 +6,134 @@ package jwt
import ( import (
"crypto/hmac" "crypto/hmac"
"crypto/rand" "crypto/rand"
"encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
"io" "io"
"strings" "sync"
"time" "time"
) )
const ( const (
// Header is the default HTTP Authorization header name // HTTPHeader is the default HTTP Authorization header name
Header = "Authorization" HTTPHeader = "Authorization"
// DefaultExpiry is the default token expiration time // DefaultExpiry is the default token expiration time
DefaultExpiry = time.Hour * 12 DefaultExpiry = time.Hour * 12
// KeySize is the secret key size // KeySize is the secret key size
KeySize = 64 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 // DefaultSecretReader is the default secret key generator
var DefaultSecretReader = rand.Reader var DefaultSecretReader = rand.Reader
const jwtErr = "jwt: "
var ( var (
ErrNoJWT = errors.New("not a json web token") ErrNoJWT = errors.New(jwtErr + "not a json web token")
ErrEmptyToken = errors.New("token is empty") ErrEmptyToken = errors.New(jwtErr + "token is empty")
ErrUnsupportedAlg = errors.New("unsupported algorithm") ErrUnsupportedAlg = errors.New(jwtErr + "unsupported algorithm")
ErrInvalid = errors.New("token validation failed") ErrInvalid = errors.New(jwtErr + "token validation failed")
ErrBlacklisted = errors.New("token blacklisted") ErrBlacklisted = errors.New(jwtErr + "token blacklisted")
ErrBlacklistNotEnabled = errors.New("blacklisting is not enabled") ErrBlacklistNotEnabled = errors.New(jwtErr + "blacklisting is not enabled")
ErrNBF = errors.New("token not valid yet") ErrNbf = errors.New(jwtErr + "token not valid yet")
ErrEXP = errors.New("token expired") ErrExp = errors.New(jwtErr + "token expired")
ErrMissingEXP = errors.New("missing exp claim") ErrMissingNbf = errors.New(jwtErr + "missing nbf claim")
ErrMissingTokenParts = errors.New("missing token parts") ErrMissingExp = errors.New(jwtErr + "missing exp claim")
ErrEmptySignature = errors.New("token signature is empty") ErrMissingTokenParts = errors.New(jwtErr + "missing token parts")
ErrTokenIsNil = errors.New("token is nil") ErrEmptySignature = errors.New(jwtErr + "token signature is empty")
ErrInvalidKeySize = errors.New("invalid secret key size") 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 // JWT represents the JSON Web Token signing and blacklisting infrastructure
type JWT struct { type JWT struct {
key []byte secretReader io.Reader
expiry time.Duration key []byte
expiry time.Duration
blacklist Blacklist blacklist Blacklist
done chan struct{} nonce bool
stopOnce sync.Once
done chan struct{}
} }
// New returns a new JWT object with the given expiry timeout. // 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. // 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. // If blacklisting is enabled, the JWT object leaks a goroutine to garbage-collect expired blacklisted tokens.
// Call the Stop() method to exit the goroutine. // Call the Stop() method to exit the goroutine.
func New(expiry time.Duration, blacklist Blacklist, secret io.Reader) (*JWT, error) { func New(options ...JWTOption) (*JWT, error) {
if expiry <= 0 { jwt := &JWT{}
expiry = DefaultExpiry for _, option := range options {
option(jwt)
} }
if secret == nil { if jwt.expiry <= 0 {
secret = DefaultSecretReader 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) key := make([]byte, KeySize)
i, err := secret.Read(key) i, err := secret.Read(key)
if err != nil { 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 { if i < KeySize {
return nil, ErrInvalidKeySize return nil, ErrInvalidKeySize
} }
jwt := &JWT{ jwt.key = key
key: key, if jwt.blacklist != nil {
expiry: expiry,
blacklist: blacklist,
}
if blacklist != nil {
jwt.done = make(chan struct{}) jwt.done = make(chan struct{})
go jwt.clean() go jwt.clean()
} }
return jwt, nil 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 { func (jwt *JWT) sum(token string, h Hash) []byte {
mac := hmac.New(h.Hash, jwt.key) mac := hmac.New(h.Hash, jwt.key)
mac.Write([]byte(token)) mac.Write([]byte(token))
@ -100,28 +149,27 @@ func (jwt *JWT) Invalidate(t *Token) error {
if t == nil { if t == nil {
return ErrTokenIsNil 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 { if err := jwt.blacklisted(t.Sig()); err != nil {
return err return err
} }
jwt.blacklist.Add(t.Sig(), exp) if t.Header.GetString(TypClaim) != Typ {
return nil 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 // blacklisted checks if a token is blacklisted
@ -145,8 +193,12 @@ func (jwt *JWT) clean() {
t.Stop() t.Stop()
return return
} }
now := time.Now().UTC().Unix() 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 { if now > v {
jwt.blacklist.Remove(k) jwt.blacklist.Remove(k)
} }
@ -155,34 +207,31 @@ func (jwt *JWT) clean() {
} }
// Sign will sign the provided token using the secret key // 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) { func (jwt *JWT) Sign(t *Token) (err error) {
if t == nil { if t == nil {
return ErrTokenIsNil return ErrTokenIsNil
} }
header := t.Header() if t.Header.GetString(TypClaim) != Typ {
switch t := header["typ"].(type) {
case string:
if t != typ {
return ErrNoJWT
}
default:
return ErrNoJWT return ErrNoJWT
} }
var h Hash h := NewHash(t.Header.GetString(AlgClaim))
switch a := header["alg"].(type) { if h == nil {
case string:
h = ParseHash(a)
if h == nil {
return ErrUnsupportedAlg
}
default:
return ErrUnsupportedAlg return ErrUnsupportedAlg
} }
now := time.Now().UTC() now := time.Now()
t.Claims["exp"] = now.Add(jwt.expiry).Unix() if _, ok := t.Claims.Get(ExpClaim); !ok {
t.Claims["nbf"] = now.Unix() t.Claims.Set(ExpClaim, newExp(now, jwt.expiry))
head, err := json.Marshal(header) }
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 { if err != nil {
return return
} }
@ -190,8 +239,10 @@ func (jwt *JWT) Sign(t *Token) (err error) {
if err != nil { if err != nil {
return return
} }
t.data = strings.Join([]string{base64.URLEncoding.EncodeToString(head), base64.URLEncoding.EncodeToString(claims)}, ".") t.data = enc.EncodeToString(head) + TokenSeparator + enc.EncodeToString(claims)
t.raw = strings.Join([]string{t.data, base64.URLEncoding.EncodeToString(jwt.sum(t.data, h))}, ".") t.rawSignature = jwt.sum(t.data, h)
t.signature = enc.EncodeToString(t.rawSignature)
t.raw = t.data + TokenSeparator + t.signature
return return
} }
@ -200,64 +251,32 @@ func (jwt *JWT) Verify(t *Token) error {
if t == nil { if t == nil {
return ErrTokenIsNil 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 { if jwt.blacklist != nil {
err := jwt.blacklisted(t.Sig()) if err := jwt.blacklisted(t.Sig()); err != nil {
if err != nil {
return err return err
} }
} }
if expOK && now > exp { now := Now()
return ErrEXP if t.Header.GetString(TypClaim) != Typ {
return ErrNoJWT
} }
if nbfOK && now < nbf { h := NewHash(t.Header.GetString(AlgClaim))
return ErrNBF 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()) { if !hmac.Equal(jwt.sum(t.Data(), h), t.RawSig()) {
return ErrInvalid return ErrInvalid
@ -265,103 +284,13 @@ func (jwt *JWT) Verify(t *Token) error {
return nil return nil
} }
// Stop will end the cleaner goroutinge execution // Stop will end the cleaner goroutine execution
// Calling Stop twice or more will panic
func (jwt *JWT) Stop() error { func (jwt *JWT) Stop() error {
if jwt.blacklist == nil { if jwt.blacklist == nil {
return ErrBlacklistNotEnabled return ErrBlacklistNotEnabled
} }
close(jwt.done) jwt.stopOnce.Do(func() {
close(jwt.done)
})
return nil 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,
}
}

View file

@ -1,4 +1,4 @@
// Copyright (C) 2018 Marius Schellenberger // Copyright (C) 2025 Marius Schellenberger
package jwt package jwt
@ -9,17 +9,22 @@ import (
"time" "time"
) )
var claims = Claims{
"sub": "1234567890",
"name": "John Doe",
"admin": true,
"fizz": "buzz",
}
func TestValidate(t *testing.T) { func TestValidate(t *testing.T) {
jwt, err := New(time.Second, NewMemBlacklist(), nil) jwt, err := New(
WithExpiry(time.Second),
WithBlacklist(NewMemBlacklist()),
)
if err != nil { if err != nil {
t.Error(err) t.Error(err)
} }
token := NewToken(map[string]interface{}{ token := NewToken(claims.Copy(), nil)
"sub": "1234567890",
"name": "John Doe",
"admin": true,
"fizz": "buzz",
}, nil)
err = jwt.Sign(token) err = jwt.Sign(token)
if err != nil { if err != nil {
t.Error(err) t.Error(err)
@ -42,6 +47,21 @@ func TestValidate(t *testing.T) {
t.Error(errors.New("token should be expired")) 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) err = jwt.Invalidate(nt)
if err != nil { if err != nil {
t.Error(err) t.Error(err)
@ -50,6 +70,7 @@ func TestValidate(t *testing.T) {
if err == nil { if err == nil {
t.Error(errors.New("double invalidate")) t.Error(errors.New("double invalidate"))
} }
err = jwt.Verify(nt) err = jwt.Verify(nt)
if err == nil { if err == nil {
t.Error(errors.New("token should be blacklisted")) t.Error(errors.New("token should be blacklisted"))
@ -58,19 +79,46 @@ func TestValidate(t *testing.T) {
if err != nil { if err != nil {
t.Error(err) t.Error(err)
} }
} // should not panic
err = jwt.Stop()
func TestNoBlacklist(t *testing.T) {
jwt, err := New(time.Second, nil, nil)
if err != nil { if err != nil {
t.Error(err) t.Error(err)
} }
token := NewToken(map[string]interface{}{ }
"sub": "1234567890",
"name": "John Doe", func TestNonce(t *testing.T) {
"admin": true, jwt, err := New(
"fizz": "buzz", WithExpiry(time.Second),
}, nil) 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) err = jwt.Sign(token)
if err != nil { if err != nil {
t.Error(err) t.Error(err)
@ -100,15 +148,103 @@ func TestNoBlacklist(t *testing.T) {
} }
func TestEmptySecretReader(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 { if err == nil {
t.Error(errors.New("error should be secret reader error")) t.Error(errors.New("error should be secret reader error"))
} }
} }
func TestInvalidSecretReader(t *testing.T) { 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 { if err != ErrInvalidKeySize {
t.Error(errors.New("error should be invalid key size")) 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)
}
}

11
rand.go Normal file
View file

@ -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
}

24
time.go Normal file
View file

@ -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()
}

98
token.go Normal file
View file

@ -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
}