607 lines
20 KiB
Go
607 lines
20 KiB
Go
package auth
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/ed25519"
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/sha256"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
)
|
|
|
|
var testSecret = []byte("test-secret-key-must-be-32-bytes")
|
|
|
|
func genRSAKey() *rsa.PrivateKey {
|
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return key
|
|
}
|
|
|
|
// RSA key generation is the dominant cost in this file; amortize it.
|
|
var (
|
|
testRSAKey = sync.OnceValue(genRSAKey)
|
|
testRSAKeyAlt = sync.OnceValue(genRSAKey)
|
|
)
|
|
|
|
func defaultHeader() map[string]any { return map[string]any{"alg": "HS256", "typ": "JWT"} }
|
|
|
|
// signHS256 assembles a token from raw maps, bypassing the package so that
|
|
// malformed and hostile tokens can be constructed.
|
|
func signHS256(t *testing.T, secret []byte, header, claims map[string]any) string {
|
|
t.Helper()
|
|
h, err := json.Marshal(header)
|
|
noErr(t, err, "marshal header")
|
|
c, err := json.Marshal(claims)
|
|
noErr(t, err, "marshal claims")
|
|
|
|
signing := base64.RawURLEncoding.EncodeToString(h) + "." + base64.RawURLEncoding.EncodeToString(c)
|
|
mac := hmac.New(sha256.New, secret)
|
|
mac.Write([]byte(signing))
|
|
return signing + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
|
}
|
|
|
|
func unsignedToken(t *testing.T, header, claims map[string]any) string {
|
|
t.Helper()
|
|
h, err := json.Marshal(header)
|
|
noErr(t, err, "marshal header")
|
|
c, err := json.Marshal(claims)
|
|
noErr(t, err, "marshal claims")
|
|
return base64.RawURLEncoding.EncodeToString(h) + "." + base64.RawURLEncoding.EncodeToString(c) + "."
|
|
}
|
|
|
|
func decodeSegment(t *testing.T, segment string) map[string]any {
|
|
t.Helper()
|
|
raw, err := base64.RawURLEncoding.DecodeString(segment)
|
|
noErr(t, err, "segment decode")
|
|
var m map[string]any
|
|
noErr(t, json.Unmarshal(raw, &m), "segment unmarshal")
|
|
return m
|
|
}
|
|
|
|
func jwtParts(t *testing.T, token string) (header, payload map[string]any) {
|
|
t.Helper()
|
|
parts := strings.Split(token, ".")
|
|
eq(t, len(parts), 3, "token segments")
|
|
return decodeSegment(t, parts[0]), decodeSegment(t, parts[1])
|
|
}
|
|
|
|
func str(t *testing.T, m map[string]any, key string) string {
|
|
t.Helper()
|
|
v, ok := m[key].(string)
|
|
if !ok {
|
|
t.Fatalf("claim %q: %v is not a string", key, m[key])
|
|
}
|
|
return v
|
|
}
|
|
|
|
func num(t *testing.T, m map[string]any, key string) float64 {
|
|
t.Helper()
|
|
v, ok := m[key].(float64)
|
|
if !ok {
|
|
t.Fatalf("claim %q: %v is not a number", key, m[key])
|
|
}
|
|
return v
|
|
}
|
|
|
|
func TestJWTHS256RoundTrip(t *testing.T) {
|
|
manager, err := NewJWT(testSecret)
|
|
noErr(t, err, "NewJWT")
|
|
|
|
token, err := manager.GenerateToken("user123", map[string]any{
|
|
"email": "test@example.com",
|
|
"role": "admin",
|
|
})
|
|
noErr(t, err, "GenerateToken")
|
|
|
|
header, payload := jwtParts(t, token)
|
|
eq(t, str(t, header, "alg"), "HS256", "alg")
|
|
eq(t, str(t, header, "typ"), "JWT", "typ")
|
|
eq(t, str(t, payload, "sub"), "user123", "subject")
|
|
|
|
exp, iat, nbf := num(t, payload, "exp"), num(t, payload, "iat"), num(t, payload, "nbf")
|
|
eq(t, int64(exp-iat), int64(DefaultTokenLifetime/time.Second), "default lifetime")
|
|
eq(t, nbf, iat, "nbf equals iat")
|
|
if _, present := payload["iss"]; present {
|
|
t.Fatal("issuer emitted without configuration")
|
|
}
|
|
|
|
userID, claims, err := manager.ValidateToken(token)
|
|
noErr(t, err, "ValidateToken")
|
|
eq(t, userID, "user123", "user id")
|
|
eq(t, len(claims), 2, "claim count")
|
|
eq(t, str(t, claims, "email"), "test@example.com", "email claim")
|
|
eq(t, str(t, claims, "role"), "admin", "role claim")
|
|
|
|
// nil claims must not emit an extra object
|
|
bare, err := manager.GenerateToken("user123", nil)
|
|
noErr(t, err, "GenerateToken without claims")
|
|
_, barePayload := jwtParts(t, bare)
|
|
if _, present := barePayload["extra"]; present {
|
|
t.Fatal("empty extra claim emitted")
|
|
}
|
|
_, claims, err = manager.ValidateToken(bare)
|
|
noErr(t, err, "ValidateToken without claims")
|
|
eq(t, len(claims), 0, "no extra claims")
|
|
}
|
|
|
|
func TestJWTSecretLength(t *testing.T) {
|
|
_, err := NewJWT(nil)
|
|
errIs(t, err, ErrSecretTooShort, "nil secret")
|
|
_, err = NewJWT(make([]byte, 31))
|
|
errIs(t, err, ErrSecretTooShort, "31 bytes")
|
|
_, err = NewJWT(make([]byte, 32))
|
|
noErr(t, err, "32 bytes")
|
|
|
|
_, err = GenerateHS256Token(make([]byte, 31), "u", nil, time.Hour)
|
|
errIs(t, err, ErrSecretTooShort, "standalone generate")
|
|
_, _, err = ValidateHS256Token(make([]byte, 31), "irrelevant")
|
|
errIs(t, err, ErrSecretTooShort, "standalone validate")
|
|
}
|
|
|
|
func TestJWTEmptyUserID(t *testing.T) {
|
|
manager, err := NewJWT(testSecret)
|
|
noErr(t, err, "NewJWT")
|
|
_, err = manager.GenerateToken("", map[string]any{"role": "admin"})
|
|
errIs(t, err, ErrTokenEmptyUserID, "empty user id")
|
|
_, err = GenerateHS256Token(testSecret, "", nil, time.Hour)
|
|
errIs(t, err, ErrTokenEmptyUserID, "standalone empty user id")
|
|
}
|
|
|
|
func TestJWTRS256(t *testing.T) {
|
|
key := testRSAKey()
|
|
|
|
signer, err := NewJWTRSA(key)
|
|
noErr(t, err, "NewJWTRSA")
|
|
token, err := signer.GenerateToken("user456", map[string]any{"scope": "read:all"})
|
|
noErr(t, err, "GenerateToken")
|
|
|
|
header, _ := jwtParts(t, token)
|
|
eq(t, str(t, header, "alg"), "RS256", "alg")
|
|
|
|
userID, claims, err := signer.ValidateToken(token)
|
|
noErr(t, err, "self validation")
|
|
eq(t, userID, "user456", "user id")
|
|
eq(t, str(t, claims, "scope"), "read:all", "scope claim")
|
|
|
|
verifier, err := NewJWTVerifier(&key.PublicKey)
|
|
noErr(t, err, "NewJWTVerifier")
|
|
userID, _, err = verifier.ValidateToken(token)
|
|
noErr(t, err, "verifier validation")
|
|
eq(t, userID, "user456", "user id from verifier")
|
|
|
|
_, err = verifier.GenerateToken("user456", nil)
|
|
errIs(t, err, ErrTokenNoPrivateKey, "verifier must not sign")
|
|
|
|
// an unrelated key must not verify
|
|
foreign, err := NewJWTVerifier(&testRSAKeyAlt().PublicKey)
|
|
noErr(t, err, "NewJWTVerifier foreign")
|
|
_, _, err = foreign.ValidateToken(token)
|
|
errIs(t, err, ErrTokenInvalidSignature, "foreign public key")
|
|
|
|
_, err = NewJWTRSA(nil)
|
|
errIs(t, err, ErrTokenNoPrivateKey, "nil private key")
|
|
_, err = NewJWTVerifier(nil)
|
|
errIs(t, err, ErrTokenNoPublicKey, "nil public key")
|
|
}
|
|
|
|
func TestJWTAlgorithmEnforcement(t *testing.T) {
|
|
key := testRSAKey()
|
|
hs, err := NewJWT(testSecret)
|
|
noErr(t, err, "NewJWT")
|
|
rs, err := NewJWTRSA(key)
|
|
noErr(t, err, "NewJWTRSA")
|
|
|
|
hsToken, err := hs.GenerateToken("u", nil)
|
|
noErr(t, err, "HS256 token")
|
|
rsToken, err := rs.GenerateToken("u", nil)
|
|
noErr(t, err, "RS256 token")
|
|
|
|
_, _, err = hs.ValidateToken(rsToken)
|
|
errIs(t, err, ErrTokenInvalidSignature, "RS256 token to HS256 manager")
|
|
_, _, err = rs.ValidateToken(hsToken)
|
|
errIs(t, err, ErrTokenInvalidSignature, "HS256 token to RS256 manager")
|
|
|
|
// ☢ algorithm confusion: HS256 token keyed with the RSA public key
|
|
pubBytes, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
|
|
noErr(t, err, "marshal public key")
|
|
pubPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes})
|
|
forged := signHS256(t, pubPEM, defaultHeader(), map[string]any{
|
|
"sub": "attacker", "exp": time.Now().Add(time.Hour).Unix(),
|
|
})
|
|
_, _, err = rs.ValidateToken(forged)
|
|
errIs(t, err, ErrTokenInvalidSignature, "algorithm confusion")
|
|
|
|
// alg: none
|
|
none := unsignedToken(t, map[string]any{"alg": "none", "typ": "JWT"}, map[string]any{
|
|
"sub": "attacker", "exp": time.Now().Add(time.Hour).Unix(),
|
|
})
|
|
_, _, err = hs.ValidateToken(none)
|
|
errIs(t, err, ErrTokenInvalidSignature, "alg none against HS256")
|
|
_, _, err = rs.ValidateToken(none)
|
|
errIs(t, err, ErrTokenInvalidSignature, "alg none against RS256")
|
|
|
|
// an unregistered algorithm is unverifiable, mapped to malformed
|
|
unknown := signHS256(t, testSecret, map[string]any{"alg": "HS999", "typ": "JWT"}, map[string]any{
|
|
"sub": "attacker", "exp": time.Now().Add(time.Hour).Unix(),
|
|
})
|
|
_, _, err = hs.ValidateToken(unknown)
|
|
errIs(t, err, ErrTokenMalformed, "unknown algorithm")
|
|
}
|
|
|
|
func TestJWTTampering(t *testing.T) {
|
|
manager, err := NewJWT(testSecret)
|
|
noErr(t, err, "NewJWT")
|
|
token, err := manager.GenerateToken("user1", map[string]any{"role": "user"})
|
|
noErr(t, err, "GenerateToken")
|
|
parts := strings.Split(token, ".")
|
|
|
|
escalated, err := json.Marshal(map[string]any{
|
|
"sub": "user1", "exp": time.Now().Add(time.Hour).Unix(),
|
|
"extra": map[string]any{"role": "admin"},
|
|
})
|
|
noErr(t, err, "marshal forged claims")
|
|
|
|
// length-preserving corruption, so the segment still decodes and
|
|
// the failure is attributable to the MAC rather than the encoding
|
|
corrupt := []byte(parts[2])
|
|
if corrupt[0] == 'A' {
|
|
corrupt[0] = 'B'
|
|
} else {
|
|
corrupt[0] = 'A'
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
token string
|
|
want error
|
|
}{
|
|
{"payload rewrite", parts[0] + "." + base64.RawURLEncoding.EncodeToString(escalated) + "." + parts[2], ErrTokenInvalidSignature},
|
|
{"corrupt signature", parts[0] + "." + parts[1] + "." + string(corrupt), ErrTokenInvalidSignature},
|
|
{"truncated signature", parts[0] + "." + parts[1] + "." + parts[2][:len(parts[2])-2], ErrTokenMalformed},
|
|
{"replaced signature", parts[0] + "." + parts[1] + ".invalidsignature", ErrTokenInvalidSignature},
|
|
{"empty", "", ErrTokenMalformed},
|
|
{"two segments", parts[0] + "." + parts[1], ErrTokenMalformed},
|
|
{"four segments", token + ".extra", ErrTokenMalformed},
|
|
{"separators only", "..", ErrTokenMalformed},
|
|
{"payload not base64", parts[0] + ".!!!." + parts[2], ErrTokenMalformed},
|
|
{"header not base64", "!!!." + parts[1] + "." + parts[2], ErrTokenMalformed},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
userID, claims, err := manager.ValidateToken(tc.token)
|
|
errIs(t, err, tc.want, tc.name)
|
|
eq(t, userID, "", "user id on failure")
|
|
if claims != nil {
|
|
t.Fatalf("%s: claims returned on failure", tc.name)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestJWTExpiryAndLeeway(t *testing.T) {
|
|
now := time.Now()
|
|
strict, err := NewJWT(testSecret, WithLeeway(0))
|
|
noErr(t, err, "NewJWT strict")
|
|
lenient, err := NewJWT(testSecret, WithLeeway(5*time.Minute))
|
|
noErr(t, err, "NewJWT lenient")
|
|
|
|
expired := signHS256(t, testSecret, defaultHeader(), map[string]any{
|
|
"sub": "u", "iat": now.Add(-2 * time.Hour).Unix(), "exp": now.Add(-time.Minute).Unix(),
|
|
})
|
|
_, _, err = strict.ValidateToken(expired)
|
|
errIs(t, err, ErrTokenExpired, "expired token")
|
|
_, _, err = lenient.ValidateToken(expired)
|
|
noErr(t, err, "expiry inside leeway")
|
|
|
|
notYet := signHS256(t, testSecret, defaultHeader(), map[string]any{
|
|
"sub": "u", "nbf": now.Add(2 * time.Second).Unix(), "exp": now.Add(time.Hour).Unix(),
|
|
})
|
|
_, _, err = strict.ValidateToken(notYet)
|
|
errIs(t, err, ErrTokenNotYetValid, "nbf in the future")
|
|
_, _, err = lenient.ValidateToken(notYet)
|
|
noErr(t, err, "nbf inside leeway")
|
|
|
|
// exp is mandatory. ‼️ mapJWTError has no case for
|
|
// jwt.ErrTokenRequiredClaimMissing, so this surfaces as malformed.
|
|
noExp := signHS256(t, testSecret, defaultHeader(), map[string]any{"sub": "u", "iat": now.Unix()})
|
|
_, _, err = strict.ValidateToken(noExp)
|
|
errIs(t, err, ErrTokenMissingClaim, "missing exp")
|
|
|
|
// generated lifetimes are honored without waiting for them
|
|
short, err := NewJWT(testSecret, WithTokenLifetime(time.Second))
|
|
noErr(t, err, "NewJWT short lifetime")
|
|
token, err := short.GenerateToken("u", nil)
|
|
noErr(t, err, "GenerateToken")
|
|
_, payload := jwtParts(t, token)
|
|
eq(t, int64(num(t, payload, "exp")-num(t, payload, "iat")), int64(1), "encoded lifetime")
|
|
_, _, err = short.ValidateToken(token)
|
|
noErr(t, err, "valid immediately")
|
|
}
|
|
|
|
func TestJWTIssuerAudience(t *testing.T) {
|
|
manager, err := NewJWT(testSecret,
|
|
WithTokenLifetime(time.Hour),
|
|
WithIssuer("test-issuer"),
|
|
WithAudience([]string{"api.example.com"}),
|
|
)
|
|
noErr(t, err, "NewJWT")
|
|
|
|
token, err := manager.GenerateToken("user1", nil)
|
|
noErr(t, err, "GenerateToken")
|
|
|
|
_, payload := jwtParts(t, token)
|
|
eq(t, str(t, payload, "iss"), "test-issuer", "issuer")
|
|
audience, ok := payload["aud"].([]any)
|
|
isTrue(t, ok, "audience encoded as an array")
|
|
eq(t, len(audience), 1, "audience length")
|
|
eq(t, audience[0], any("api.example.com"), "audience value")
|
|
eq(t, int64(num(t, payload, "exp")-num(t, payload, "iat")), int64(3600), "custom lifetime")
|
|
|
|
_, _, err = manager.ValidateToken(token)
|
|
noErr(t, err, "self-issued token")
|
|
|
|
other, err := NewJWT(testSecret, WithTokenLifetime(time.Hour), WithIssuer("other-issuer"))
|
|
noErr(t, err, "NewJWT other issuer")
|
|
otherToken, err := other.GenerateToken("user1", nil)
|
|
noErr(t, err, "GenerateToken other issuer")
|
|
_, _, err = manager.ValidateToken(otherToken)
|
|
errIs(t, err, ErrTokenMissingClaim, "issuer mismatch")
|
|
|
|
missingAudience := signHS256(t, testSecret, defaultHeader(), map[string]any{
|
|
"sub": "u", "iss": "test-issuer", "exp": time.Now().Add(time.Hour).Unix(),
|
|
})
|
|
_, _, err = manager.ValidateToken(missingAudience)
|
|
errIs(t, err, ErrTokenMissingClaim, "absent audience is rejected when expected")
|
|
|
|
superset := signHS256(t, testSecret, defaultHeader(), map[string]any{
|
|
"sub": "u", "iss": "test-issuer", "exp": time.Now().Add(time.Hour).Unix(),
|
|
"aud": []string{"other.example.com", "api.example.com"},
|
|
})
|
|
_, _, err = manager.ValidateToken(superset)
|
|
noErr(t, err, "expected audience among others")
|
|
|
|
// an unconstrained manager imposes neither claim
|
|
plain, err := NewJWT(testSecret)
|
|
noErr(t, err, "NewJWT plain")
|
|
_, _, err = plain.ValidateToken(token)
|
|
noErr(t, err, "unconstrained validation")
|
|
}
|
|
|
|
func TestJWTUnenforcedClaims(t *testing.T) {
|
|
// Documented gaps: sub is not required and iat is not verified.
|
|
// Callers must reject an empty user id themselves.
|
|
manager, err := NewJWT(testSecret)
|
|
noErr(t, err, "NewJWT")
|
|
|
|
token := signHS256(t, testSecret, defaultHeader(), map[string]any{
|
|
"exp": time.Now().Add(time.Hour).Unix(),
|
|
"iat": time.Now().Add(24 * time.Hour).Unix(),
|
|
})
|
|
userID, claims, err := manager.ValidateToken(token)
|
|
noErr(t, err, "token without subject")
|
|
eq(t, userID, "", "empty subject accepted")
|
|
eq(t, len(claims), 0, "no extra claims")
|
|
}
|
|
|
|
func TestJWTOptionGuards(t *testing.T) {
|
|
manager, err := NewJWT(testSecret,
|
|
WithTokenLifetime(0), WithTokenLifetime(-time.Hour), WithLeeway(-time.Second))
|
|
noErr(t, err, "NewJWT")
|
|
eq(t, manager.tokenLifetime, DefaultTokenLifetime, "lifetime unchanged")
|
|
eq(t, manager.leeway, DefaultLeeway, "leeway unchanged")
|
|
|
|
manager, err = NewJWT(testSecret, WithLeeway(0))
|
|
noErr(t, err, "NewJWT zero leeway")
|
|
eq(t, manager.leeway, time.Duration(0), "zero leeway applied")
|
|
|
|
// options apply to every constructor
|
|
verifier, err := NewJWTVerifier(&testRSAKey().PublicKey, WithIssuer("iss"), WithLeeway(time.Minute))
|
|
noErr(t, err, "NewJWTVerifier")
|
|
eq(t, verifier.issuer, "iss", "issuer")
|
|
eq(t, verifier.leeway, time.Minute, "leeway")
|
|
}
|
|
|
|
func TestJWTStandaloneFunctions(t *testing.T) {
|
|
token, err := GenerateHS256Token(testSecret, "standalone-user",
|
|
map[string]any{"test": "value", "count": 42}, time.Hour)
|
|
noErr(t, err, "GenerateHS256Token")
|
|
|
|
userID, claims, err := ValidateHS256Token(testSecret, token)
|
|
noErr(t, err, "ValidateHS256Token")
|
|
eq(t, userID, "standalone-user", "user id")
|
|
eq(t, str(t, claims, "test"), "value", "string claim")
|
|
eq(t, claims["count"], any(float64(42)), "numeric claim after JSON round trip")
|
|
|
|
_, _, err = ValidateHS256Token(bytes.Repeat([]byte("x"), 32), token)
|
|
errIs(t, err, ErrTokenInvalidSignature, "wrong secret")
|
|
|
|
expired, err := GenerateHS256Token(testSecret, "u", nil, -time.Hour)
|
|
noErr(t, err, "expired token")
|
|
_, _, err = ValidateHS256Token(testSecret, expired)
|
|
errIs(t, err, ErrTokenExpired, "expired beyond default leeway")
|
|
|
|
// standalone validation checks neither issuer nor audience
|
|
scoped, err := NewJWT(testSecret, WithIssuer("iss"), WithAudience([]string{"aud"}))
|
|
noErr(t, err, "NewJWT scoped")
|
|
scopedToken, err := scoped.GenerateToken("u", nil)
|
|
noErr(t, err, "GenerateToken scoped")
|
|
_, _, err = ValidateHS256Token(testSecret, scopedToken)
|
|
noErr(t, err, "issuer and audience are not enforced standalone")
|
|
|
|
// tokens are interchangeable with the manager form
|
|
managed, err := NewJWT(testSecret)
|
|
noErr(t, err, "NewJWT")
|
|
_, _, err = managed.ValidateToken(token)
|
|
noErr(t, err, "standalone token accepted by manager")
|
|
}
|
|
|
|
func TestJWTPEM(t *testing.T) {
|
|
key := testRSAKey()
|
|
|
|
pkcs1 := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
|
|
pkcs8Bytes, err := x509.MarshalPKCS8PrivateKey(key)
|
|
noErr(t, err, "marshal pkcs8")
|
|
pkcs8 := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: pkcs8Bytes})
|
|
pkixBytes, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
|
|
noErr(t, err, "marshal pkix")
|
|
pkix := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pkixBytes})
|
|
|
|
for name, blob := range map[string][]byte{"pkcs1": pkcs1, "pkcs8": pkcs8} {
|
|
t.Run(name, func(t *testing.T) {
|
|
signer, err := NewJWTRSAFromPEM(blob, WithTokenLifetime(time.Hour))
|
|
noErr(t, err, "NewJWTRSAFromPEM")
|
|
eq(t, signer.tokenLifetime, time.Hour, "options forwarded")
|
|
|
|
token, err := signer.GenerateToken("user-from-pem", nil)
|
|
noErr(t, err, "GenerateToken")
|
|
|
|
verifier, err := NewJWTVerifierFromPEM(pkix)
|
|
noErr(t, err, "NewJWTVerifierFromPEM")
|
|
userID, _, err := verifier.ValidateToken(token)
|
|
noErr(t, err, "ValidateToken")
|
|
eq(t, userID, "user-from-pem", "user id")
|
|
})
|
|
}
|
|
|
|
_, err = NewJWTRSAFromPEM([]byte("invalid pem data"))
|
|
errIs(t, err, ErrRSAInvalidPEM, "private: not pem")
|
|
_, err = NewJWTRSAFromPEM(nil)
|
|
errIs(t, err, ErrRSAInvalidPEM, "private: empty")
|
|
_, err = NewJWTVerifierFromPEM([]byte("invalid pem data"))
|
|
errIs(t, err, ErrRSAInvalidPEM, "public: not pem")
|
|
|
|
_, err = NewJWTRSAFromPEM(pkix)
|
|
errIs(t, err, ErrRSAInvalidPrivateKey, "public key supplied as private")
|
|
_, err = NewJWTVerifierFromPEM(pkcs8)
|
|
errIs(t, err, ErrRSAInvalidPublicKey, "private key supplied as public")
|
|
|
|
// well-formed keys of the wrong algorithm
|
|
edPub, edPriv, err := ed25519.GenerateKey(rand.Reader)
|
|
noErr(t, err, "ed25519 keygen")
|
|
edPubBytes, err := x509.MarshalPKIXPublicKey(edPub)
|
|
noErr(t, err, "marshal ed25519 public")
|
|
_, err = NewJWTVerifierFromPEM(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: edPubBytes}))
|
|
errIs(t, err, ErrRSANotPublicKey, "non-rsa public key")
|
|
|
|
edPrivBytes, err := x509.MarshalPKCS8PrivateKey(edPriv)
|
|
noErr(t, err, "marshal ed25519 private")
|
|
_, err = NewJWTRSAFromPEM(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: edPrivBytes}))
|
|
errIs(t, err, ErrRSAInvalidPrivateKey, "non-rsa private key")
|
|
}
|
|
|
|
func TestMapJWTError(t *testing.T) {
|
|
cases := []struct {
|
|
in error
|
|
want error
|
|
}{
|
|
{jwt.ErrTokenMalformed, ErrTokenMalformed},
|
|
{jwt.ErrTokenUnverifiable, ErrTokenMalformed},
|
|
{jwt.ErrTokenSignatureInvalid, ErrTokenInvalidSignature},
|
|
{jwt.ErrTokenExpired, ErrTokenExpired},
|
|
{jwt.ErrTokenNotValidYet, ErrTokenNotYetValid},
|
|
{jwt.ErrTokenInvalidAudience, ErrTokenMissingClaim},
|
|
{jwt.ErrTokenInvalidIssuer, ErrTokenMissingClaim},
|
|
{jwt.ErrTokenRequiredClaimMissing, ErrTokenMissingClaim},
|
|
{errors.New("unclassified"), ErrTokenMalformed},
|
|
}
|
|
for _, tc := range cases {
|
|
got := mapJWTError(tc.in)
|
|
errIs(t, got, tc.want, "mapped sentinel")
|
|
errIs(t, got, tc.in, "original error preserved")
|
|
}
|
|
}
|
|
|
|
func TestJWTConcurrency(t *testing.T) {
|
|
manager, err := NewJWT(testSecret, WithTokenLifetime(time.Hour), WithIssuer("iss"))
|
|
noErr(t, err, "NewJWT")
|
|
shared, err := manager.GenerateToken("user1", map[string]any{"role": "admin"})
|
|
noErr(t, err, "GenerateToken")
|
|
|
|
const n = 32
|
|
errs := make(chan error, n)
|
|
var wg sync.WaitGroup
|
|
for i := range n {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
if _, _, err := manager.ValidateToken(shared); err != nil {
|
|
errs <- err
|
|
return
|
|
}
|
|
token, err := manager.GenerateToken(fmt.Sprintf("user-%d", i), map[string]any{"n": i})
|
|
if err != nil {
|
|
errs <- err
|
|
return
|
|
}
|
|
if _, _, err := manager.ValidateToken(token); err != nil {
|
|
errs <- err
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
close(errs)
|
|
for err := range errs {
|
|
t.Errorf("concurrent operation: %v", err)
|
|
}
|
|
}
|
|
|
|
func FuzzValidateHS256Token(f *testing.F) {
|
|
token, err := GenerateHS256Token(testSecret, "seed", map[string]any{"a": 1}, time.Hour)
|
|
if err != nil {
|
|
f.Fatal(err)
|
|
}
|
|
f.Add(token)
|
|
f.Add("")
|
|
f.Add("a.b.c")
|
|
f.Add(strings.Repeat(".", 16))
|
|
|
|
f.Fuzz(func(t *testing.T, s string) {
|
|
_, _, err := ValidateHS256Token(testSecret, s)
|
|
if err != nil {
|
|
return
|
|
}
|
|
// acceptance implies a well-formed, correctly signed token
|
|
parts := strings.Split(s, ".")
|
|
if len(parts) != 3 {
|
|
t.Fatalf("accepted token with %d segments", len(parts))
|
|
}
|
|
mac := hmac.New(sha256.New, testSecret)
|
|
mac.Write([]byte(parts[0] + "." + parts[1]))
|
|
sig, decErr := base64.RawURLEncoding.DecodeString(parts[2])
|
|
if decErr != nil || !hmac.Equal(sig, mac.Sum(nil)) {
|
|
t.Fatal("accepted token with an invalid signature")
|
|
}
|
|
})
|
|
}
|
|
|
|
func BenchmarkJWTHS256(b *testing.B) {
|
|
manager, err := NewJWT(testSecret)
|
|
if err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
claims := map[string]any{"role": "admin"}
|
|
for b.Loop() {
|
|
token, err := manager.GenerateToken("user1", claims)
|
|
if err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
if _, _, err := manager.ValidateToken(token); err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
}
|
|
}
|