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, `{"exp":`+authTokenUnix(authTokenEpoch.Add(time.Hour).Unix())+`,"nbf":`+authTokenUnix(authTokenEpoch.Add(time.Second).Unix())+`}`), authtoken.ErrNotYetValid}, "without exp": {signedAuthTokenSegments("test-secret", header, `{"user_id":4,"token_version":1}`), authtoken.ErrInvalid}, "jti not a string": {signedAuthTokenSegments("test-secret", header, `{"exp":`+authTokenUnix(authTokenEpoch.Add(time.Hour).Unix())+`,"jti":7}`), authtoken.ErrInvalid}, "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 whose nbf has passed", func(t *testing.T) { token := signedAuthTokenSegments("test-secret", header, `{"exp":`+authTokenUnix(authTokenEpoch.Add(time.Hour).Unix())+`,"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) } type memoryRevocationStore struct { revoked map[string]time.Time failure error } func newMemoryRevocationStore() *memoryRevocationStore { return &memoryRevocationStore{revoked: map[string]time.Time{}} } func (m *memoryRevocationStore) Revoke(id string, expiresAt time.Time) error { if m.failure != nil { return m.failure } m.revoked[id] = expiresAt return nil } func (m *memoryRevocationStore) IsRevoked(id string) (bool, error) { if m.failure != nil { return false, m.failure } _, ok := m.revoked[id] return ok, nil } func TestAuthTokenRevocation(t *testing.T) { newAuth := func(store *memoryRevocationStore) *authtoken.Auth { return authtoken.New([]byte("test-secret"), authtoken.WithClock(func() time.Time { return authTokenEpoch }), authtoken.WithRevocationStore(store)) } t.Run("Every encoded token carries a distinct jti", func(t *testing.T) { auth := authTokenAt(authTokenEpoch) first, _ := auth.Encode(map[string]any{"user_id": 1}) second, _ := auth.Encode(map[string]any{"user_id": 1}) firstClaims, _ := auth.Decode(first) secondClaims, _ := auth.Decode(second) id, ok := firstClaims["jti"].(string) if !ok || id == "" { t.Fatalf("expected a jti, got %v", firstClaims["jti"]) } if id == secondClaims["jti"] { t.Errorf("expected distinct jti values, both were %q", id) } }) t.Run("An explicit jti is kept", func(t *testing.T) { auth := authTokenAt(authTokenEpoch) encoded, _ := auth.Encode(map[string]any{"jti": "fixed-id"}) claims, _ := auth.Decode(encoded) if claims["jti"] != "fixed-id" { t.Errorf("expected jti fixed-id, got %v", claims["jti"]) } }) t.Run("A revoked token is rejected until it would have expired anyway", func(t *testing.T) { store := newMemoryRevocationStore() auth := newAuth(store) encoded, _ := auth.Encode(map[string]any{"user_id": 1}) claims, err := auth.Decode(encoded) if err != nil { t.Fatal(err) } if err := auth.Revoke(claims); err != nil { t.Fatal(err) } if _, err := auth.Decode(encoded); !errors.Is(err, authtoken.ErrRevoked) { t.Errorf("expected ErrRevoked, got %v", err) } if got := store.revoked[claims["jti"].(string)]; !got.Equal(authTokenEpoch.Add(authtoken.SessionLifetime)) { t.Errorf("expected the revocation to last until the token's exp, got %v", got) } }) t.Run("Revoking one token leaves the others valid", func(t *testing.T) { auth := newAuth(newMemoryRevocationStore()) revoked, _ := auth.Encode(map[string]any{"user_id": 1}) kept, _ := auth.Encode(map[string]any{"user_id": 1}) claims, _ := auth.Decode(revoked) _ = auth.Revoke(claims) if _, err := auth.Decode(kept); err != nil { t.Errorf("expected the other token to stay valid, got %v", err) } }) t.Run("A token is rejected when the revocation store cannot answer", func(t *testing.T) { store := newMemoryRevocationStore() auth := newAuth(store) encoded, _ := auth.Encode(map[string]any{"user_id": 1}) store.failure = errors.New("database unavailable") if _, err := auth.Decode(encoded); !errors.Is(err, authtoken.ErrInvalid) { t.Errorf("expected ErrInvalid, got %v", err) } }) t.Run("Revoking surfaces a store failure", func(t *testing.T) { store := newMemoryRevocationStore() auth := newAuth(store) encoded, _ := auth.Encode(map[string]any{"user_id": 1}) claims, _ := auth.Decode(encoded) store.failure = errors.New("database unavailable") if err := auth.Revoke(claims); err == nil { t.Error("expected an error") } }) t.Run("Revoking claims without a jti is a no-op", func(t *testing.T) { store := newMemoryRevocationStore() if err := newAuth(store).Revoke(authtoken.Claims{"user_id": float64(1), "exp": float64(authTokenEpoch.Unix())}); err != nil { t.Fatal(err) } if len(store.revoked) != 0 { t.Errorf("expected nothing revoked, got %v", store.revoked) } }) t.Run("Revoking claims without an exp is refused", func(t *testing.T) { if err := newAuth(newMemoryRevocationStore()).Revoke(authtoken.Claims{"jti": "abc"}); !errors.Is(err, authtoken.ErrInvalid) { t.Errorf("expected ErrInvalid, got %v", err) } }) t.Run("Without a store, revoking is a no-op and tokens stay valid", func(t *testing.T) { auth := authTokenAt(authTokenEpoch) encoded, _ := auth.Encode(map[string]any{"user_id": 1}) claims, _ := auth.Decode(encoded) if err := auth.Revoke(claims); err != nil { t.Fatal(err) } if _, err := auth.Decode(encoded); err != nil { t.Errorf("expected the token to stay valid, got %v", err) } }) } func TestAuthTokenSizeBudget(t *testing.T) { auth := authTokenAt(authTokenEpoch) sized := func(target int) string { for padding := 0; padding < 4096; padding++ { encoded, _ := auth.Encode(map[string]any{"jti": "fixed", "pad": strings.Repeat("a", padding)}) if len(encoded) == target { return encoded } } t.Fatalf("no token of exactly %d bytes", target) return "" } t.Run("A token of exactly 4096 bytes is accepted", func(t *testing.T) { if _, err := auth.Decode(sized(4096)); err != nil { t.Errorf("expected acceptance, got %v", err) } }) t.Run("A token of 4097 bytes is refused before it is parsed", func(t *testing.T) { if _, err := auth.Decode(sized(4097)); !errors.Is(err, authtoken.ErrInvalid) { t.Errorf("expected ErrInvalid, got %v", err) } }) }