182 lines
6.9 KiB
Go
182 lines
6.9 KiB
Go
package test
|
|
|
|
import (
|
|
"RenewCMS/api/pkg/authtoken"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"errors"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
var authTokenEpoch = time.Date(2026, time.March, 1, 12, 0, 0, 0, time.UTC)
|
|
|
|
func authTokenAt(now time.Time) *authtoken.Auth {
|
|
return authtoken.New([]byte("test-secret"), authtoken.WithClock(func() time.Time { return now }))
|
|
}
|
|
|
|
func signedAuthTokenSegments(secret string, header string, payload string) string {
|
|
encode := base64.RawURLEncoding.EncodeToString
|
|
signingInput := encode([]byte(header)) + "." + encode([]byte(payload))
|
|
mac := hmac.New(sha256.New, []byte(secret))
|
|
mac.Write([]byte(signingInput))
|
|
return signingInput + "." + encode(mac.Sum(nil))
|
|
}
|
|
|
|
func TestAuthTokenEncodeDecode(t *testing.T) {
|
|
t.Run("A token round-trips its claims", func(t *testing.T) {
|
|
auth := authTokenAt(authTokenEpoch)
|
|
|
|
encoded, err := auth.Encode(map[string]any{"user_id": uint32(7), "token_version": uint32(3)})
|
|
if err != nil {
|
|
t.Fatalf("encode failed: %v", err)
|
|
}
|
|
claims, err := auth.Decode(encoded)
|
|
if err != nil {
|
|
t.Fatalf("decode failed: %v", err)
|
|
}
|
|
|
|
if claims["user_id"] != float64(7) || claims["token_version"] != float64(3) {
|
|
t.Errorf("unexpected claims %v", claims)
|
|
}
|
|
})
|
|
|
|
t.Run("Encoding stamps iat and an exp one session lifetime ahead", func(t *testing.T) {
|
|
auth := authTokenAt(authTokenEpoch)
|
|
|
|
encoded, _ := auth.Encode(map[string]any{"user_id": 1})
|
|
claims, _ := auth.Decode(encoded)
|
|
|
|
if claims["iat"] != float64(authTokenEpoch.Unix()) {
|
|
t.Errorf("expected iat %d, got %v", authTokenEpoch.Unix(), claims["iat"])
|
|
}
|
|
if claims["exp"] != float64(authTokenEpoch.Add(authtoken.SessionLifetime).Unix()) {
|
|
t.Errorf("unexpected exp %v", claims["exp"])
|
|
}
|
|
})
|
|
|
|
t.Run("Encoding keeps an explicit exp", func(t *testing.T) {
|
|
auth := authTokenAt(authTokenEpoch)
|
|
explicit := authTokenEpoch.Add(time.Minute).Unix()
|
|
|
|
encoded, _ := auth.Encode(map[string]any{"exp": explicit})
|
|
claims, _ := auth.Decode(encoded)
|
|
|
|
if claims["exp"] != float64(explicit) {
|
|
t.Errorf("expected exp %d, got %v", explicit, claims["exp"])
|
|
}
|
|
})
|
|
|
|
t.Run("Encoding does not mutate the caller's claims", func(t *testing.T) {
|
|
input := map[string]any{"user_id": 1}
|
|
|
|
_, _ = authTokenAt(authTokenEpoch).Encode(input)
|
|
|
|
if len(input) != 1 {
|
|
t.Errorf("input was mutated: %v", input)
|
|
}
|
|
})
|
|
|
|
t.Run("Encoding nil claims still yields a decodable token", func(t *testing.T) {
|
|
auth := authTokenAt(authTokenEpoch)
|
|
|
|
encoded, err := auth.Encode(nil)
|
|
if err != nil {
|
|
t.Fatalf("encode failed: %v", err)
|
|
}
|
|
if _, err := auth.Decode(encoded); err != nil {
|
|
t.Errorf("decode failed: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("Encoding reports claims that cannot be serialized", func(t *testing.T) {
|
|
if _, err := authTokenAt(authTokenEpoch).Encode(map[string]any{"bad": make(chan int)}); err == nil {
|
|
t.Error("expected an error")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestAuthTokenDecodeRejections(t *testing.T) {
|
|
auth := authTokenAt(authTokenEpoch)
|
|
valid, _ := auth.Encode(map[string]any{"user_id": 1})
|
|
segments := strings.Split(valid, ".")
|
|
header := `{"alg":"HS256","typ":"JWT"}`
|
|
|
|
cases := map[string]struct {
|
|
token string
|
|
want error
|
|
}{
|
|
"empty": {"", authtoken.ErrNoToken},
|
|
"one segment": {"abc", authtoken.ErrInvalid},
|
|
"four segments": {valid + ".x", authtoken.ErrInvalid},
|
|
"not base64 header": {"!!!." + segments[1] + "." + segments[2], authtoken.ErrInvalid},
|
|
"signed with another secret": {signedAuthTokenSegments("other", header, `{"user_id":1}`), authtoken.ErrInvalid},
|
|
"alg none": {signedAuthTokenSegments("test-secret", `{"alg":"none"}`, `{"user_id":1}`), authtoken.ErrInvalid},
|
|
"alg HS512": {signedAuthTokenSegments("test-secret", `{"alg":"HS512"}`, `{"user_id":1}`), authtoken.ErrInvalid},
|
|
"alg missing": {signedAuthTokenSegments("test-secret", `{}`, `{"user_id":1}`), authtoken.ErrInvalid},
|
|
"tampered payload": {segments[0] + "." + base64.RawURLEncoding.EncodeToString([]byte(`{"user_id":2}`)) + "." + segments[2], authtoken.ErrInvalid},
|
|
"truncated signature": {segments[0] + "." + segments[1] + "." + segments[2][:10], authtoken.ErrInvalid},
|
|
"empty signature": {segments[0] + "." + segments[1] + ".", authtoken.ErrInvalid},
|
|
"payload not an object": {signedAuthTokenSegments("test-secret", header, `[1]`), authtoken.ErrInvalid},
|
|
"payload null": {signedAuthTokenSegments("test-secret", header, `null`), authtoken.ErrInvalid},
|
|
"exp not a number": {signedAuthTokenSegments("test-secret", header, `{"exp":"soon"}`), authtoken.ErrInvalid},
|
|
"nbf not a number": {signedAuthTokenSegments("test-secret", header, `{"nbf":"soon"}`), authtoken.ErrInvalid},
|
|
"expired": {signedAuthTokenSegments("test-secret", header, `{"exp":`+authTokenUnix(authTokenEpoch.Add(-time.Second).Unix())+`}`), authtoken.ErrExpired},
|
|
"expiring exactly now": {signedAuthTokenSegments("test-secret", header, `{"exp":`+authTokenUnix(authTokenEpoch.Unix())+`}`), authtoken.ErrExpired},
|
|
"not yet valid": {signedAuthTokenSegments("test-secret", header, `{"nbf":`+authTokenUnix(authTokenEpoch.Add(time.Second).Unix())+`}`), authtoken.ErrNotYetValid},
|
|
"larger than the size budget": {strings.Repeat("a", 5000), authtoken.ErrInvalid},
|
|
}
|
|
|
|
for name, tc := range cases {
|
|
t.Run("Rejects "+name, func(t *testing.T) {
|
|
claims, err := auth.Decode(tc.token)
|
|
|
|
if !errors.Is(err, tc.want) {
|
|
t.Errorf("expected %v, got %v", tc.want, err)
|
|
}
|
|
if claims != nil {
|
|
t.Errorf("expected no claims, got %v", claims)
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("Accepts a token with no exp so sessions issued before expiry existed keep working", func(t *testing.T) {
|
|
legacy := signedAuthTokenSegments("test-secret", header, `{"user_id":4,"token_version":1}`)
|
|
|
|
claims, err := auth.Decode(legacy)
|
|
|
|
if err != nil || claims["user_id"] != float64(4) {
|
|
t.Errorf("expected legacy token accepted, got %v, %v", claims, err)
|
|
}
|
|
})
|
|
|
|
t.Run("Accepts a token whose nbf has passed", func(t *testing.T) {
|
|
token := signedAuthTokenSegments("test-secret", header, `{"nbf":`+authTokenUnix(authTokenEpoch.Add(-time.Second).Unix())+`}`)
|
|
|
|
if _, err := auth.Decode(token); err != nil {
|
|
t.Errorf("expected acceptance, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("A token expires once the clock passes its lifetime", func(t *testing.T) {
|
|
encoded, _ := auth.Encode(map[string]any{"user_id": 1})
|
|
|
|
justBefore := authTokenAt(authTokenEpoch.Add(authtoken.SessionLifetime - time.Second))
|
|
justAfter := authTokenAt(authTokenEpoch.Add(authtoken.SessionLifetime))
|
|
|
|
if _, err := justBefore.Decode(encoded); err != nil {
|
|
t.Errorf("expected valid before expiry, got %v", err)
|
|
}
|
|
if _, err := justAfter.Decode(encoded); !errors.Is(err, authtoken.ErrExpired) {
|
|
t.Errorf("expected expired, got %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func authTokenUnix(value int64) string {
|
|
return strconv.FormatInt(value, 10)
|
|
}
|