369 lines
9.4 KiB
Go
369 lines
9.4 KiB
Go
package ficha
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// helper to build an Issuer with a fixed clock for deterministic tests
|
|
func newTestIssuer(t *testing.T, fixed time.Time) (*Issuer, *MemoryRevocationStore) {
|
|
t.Helper()
|
|
key, err := GenerateKey()
|
|
if err != nil {
|
|
t.Fatalf("GenerateKey: %v", err)
|
|
}
|
|
ring, err := NewKeyRing("k1", key)
|
|
if err != nil {
|
|
t.Fatalf("NewKeyRing: %v", err)
|
|
}
|
|
store := NewMemoryRevocationStore()
|
|
store.now = func() time.Time { return fixed }
|
|
|
|
clock := fixed
|
|
iss, err := NewIssuer(ring, store, WithClock(func() time.Time { return clock }))
|
|
if err != nil {
|
|
t.Fatalf("NewIssuer: %v", err)
|
|
}
|
|
return iss, store
|
|
}
|
|
|
|
func TestNewIssuerRequiresKeys(t *testing.T) {
|
|
if _, err := NewIssuer(nil, nil); err == nil {
|
|
t.Error("expected error for nil keys, got nil")
|
|
}
|
|
}
|
|
|
|
func TestIssueAndValidate(t *testing.T) {
|
|
now := time.Unix(1_700_000_000, 0)
|
|
iss, _ := newTestIssuer(t, now)
|
|
ctx := context.Background()
|
|
|
|
type meta struct {
|
|
UserID string `json:"user_id"`
|
|
}
|
|
tokenStr, err := iss.Issue(ctx, []string{"read", "write"}, meta{UserID: "u_1"}, 1*time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("Issue: %v", err)
|
|
}
|
|
|
|
if !strings.HasPrefix(tokenStr, "v1.k1.") {
|
|
t.Errorf("unexpected token prefix: %s", tokenStr)
|
|
}
|
|
|
|
tok, err := iss.Validate(ctx, tokenStr)
|
|
if err != nil {
|
|
t.Fatalf("Validate: %v", err)
|
|
}
|
|
|
|
if !tok.HasAll("read", "write") {
|
|
t.Error("expected HasAll(read, write)")
|
|
}
|
|
if tok.Has("admin") {
|
|
t.Error("did not expect admin")
|
|
}
|
|
if tok.IssuedAt().Unix() != now.Unix() {
|
|
t.Errorf("IssuedAt: got %d", tok.IssuedAt().Unix())
|
|
}
|
|
if tok.ExpiresAt().Unix() != now.Add(1*time.Hour).Unix() {
|
|
t.Errorf("ExpiresAt: got %d", tok.ExpiresAt().Unix())
|
|
}
|
|
|
|
var got meta
|
|
if err := tok.UnmarshalData(&got); err != nil {
|
|
t.Fatalf("UnmarshalData: %v", err)
|
|
}
|
|
if got.UserID != "u_1" {
|
|
t.Errorf("UserID: got %q", got.UserID)
|
|
}
|
|
}
|
|
|
|
func TestIssueWithoutData(t *testing.T) {
|
|
iss, _ := newTestIssuer(t, time.Unix(1_700_000_000, 0))
|
|
ctx := context.Background()
|
|
|
|
tokenStr, err := iss.Issue(ctx, []string{"read"}, nil, 1*time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("Issue: %v", err)
|
|
}
|
|
|
|
tok, err := iss.Validate(ctx, tokenStr)
|
|
if err != nil {
|
|
t.Fatalf("Validate: %v", err)
|
|
}
|
|
if !tok.Has("read") {
|
|
t.Error("expected Has(read)")
|
|
}
|
|
|
|
// UnmarshalData on empty data should be a no-op.
|
|
var got map[string]any
|
|
if err := tok.UnmarshalData(&got); err != nil {
|
|
t.Errorf("UnmarshalData with no data: %v", err)
|
|
}
|
|
if got != nil {
|
|
t.Errorf("expected nil map, got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestIssueWithoutPermissions(t *testing.T) {
|
|
iss, _ := newTestIssuer(t, time.Unix(1_700_000_000, 0))
|
|
ctx := context.Background()
|
|
|
|
tokenStr, err := iss.Issue(ctx, nil, map[string]string{"x": "y"}, 1*time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("Issue: %v", err)
|
|
}
|
|
tok, err := iss.Validate(ctx, tokenStr)
|
|
if err != nil {
|
|
t.Fatalf("Validate: %v", err)
|
|
}
|
|
if len(tok.Permissions()) != 0 {
|
|
t.Errorf("expected no permissions, got %v", tok.Permissions())
|
|
}
|
|
}
|
|
|
|
func TestIssueRejectsInvalidTTL(t *testing.T) {
|
|
iss, _ := newTestIssuer(t, time.Now())
|
|
ctx := context.Background()
|
|
|
|
for _, ttl := range []time.Duration{0, -1 * time.Second, -1 * time.Hour} {
|
|
if _, err := iss.Issue(ctx, []string{"x"}, nil, ttl); err == nil {
|
|
t.Errorf("expected error for ttl=%v, got nil", ttl)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIssueNoExpiry(t *testing.T) {
|
|
now := time.Unix(1_700_000_000, 0)
|
|
key, _ := GenerateKey()
|
|
ring, _ := NewKeyRing("k1", key)
|
|
store := NewMemoryRevocationStore()
|
|
|
|
clock := now
|
|
iss, _ := NewIssuer(ring, store, WithClock(func() time.Time { return clock }))
|
|
ctx := context.Background()
|
|
|
|
tokenStr, err := iss.Issue(ctx, []string{"read"}, nil, NoExpiry)
|
|
if err != nil {
|
|
t.Fatalf("Issue: %v", err)
|
|
}
|
|
|
|
tok, err := iss.Validate(ctx, tokenStr)
|
|
if err != nil {
|
|
t.Fatalf("Validate: %v", err)
|
|
}
|
|
if !tok.NeverExpires() {
|
|
t.Error("expected NeverExpires() == true")
|
|
}
|
|
if !tok.ExpiresAt().IsZero() {
|
|
t.Errorf("expected zero ExpiresAt, got %v", tok.ExpiresAt())
|
|
}
|
|
|
|
// Far future — still valid.
|
|
clock = now.Add(100 * 365 * 24 * time.Hour)
|
|
if _, err := iss.Validate(ctx, tokenStr); err != nil {
|
|
t.Errorf("Validate far-future: %v", err)
|
|
}
|
|
|
|
// Revocation still works.
|
|
if err := iss.Revoke(ctx, tokenStr); err != nil {
|
|
t.Fatalf("Revoke: %v", err)
|
|
}
|
|
if _, err := iss.Validate(ctx, tokenStr); !errors.Is(err, ErrRevokedToken) {
|
|
t.Errorf("expected ErrRevoked after revoke, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateExpired(t *testing.T) {
|
|
now := time.Unix(1_700_000_000, 0)
|
|
key, _ := GenerateKey()
|
|
ring, _ := NewKeyRing("k1", key)
|
|
store := NewMemoryRevocationStore()
|
|
|
|
clock := now
|
|
iss, _ := NewIssuer(ring, store, WithClock(func() time.Time { return clock }))
|
|
ctx := context.Background()
|
|
|
|
tokenStr, err := iss.Issue(ctx, []string{"read"}, nil, 1*time.Minute)
|
|
if err != nil {
|
|
t.Fatalf("Issue: %v", err)
|
|
}
|
|
|
|
// Advance clock past expiry.
|
|
clock = now.Add(2 * time.Minute)
|
|
|
|
if _, err := iss.Validate(ctx, tokenStr); !errors.Is(err, ErrExpiredToken) {
|
|
t.Errorf("expected ErrExpired, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateInvalidToken(t *testing.T) {
|
|
iss, _ := newTestIssuer(t, time.Now())
|
|
ctx := context.Background()
|
|
|
|
tests := []string{
|
|
"",
|
|
"not-a-token",
|
|
"v1.k1.notbase64!!",
|
|
"v9.k1.AAAA", // unknown version
|
|
}
|
|
for _, tok := range tests {
|
|
t.Run(tok, func(t *testing.T) {
|
|
if _, err := iss.Validate(ctx, tok); !errors.Is(err, ErrInvalidToken) {
|
|
t.Errorf("expected ErrInvalidToken, got %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidateUnknownKey(t *testing.T) {
|
|
now := time.Unix(1_700_000_000, 0)
|
|
iss, _ := newTestIssuer(t, now)
|
|
ctx := context.Background()
|
|
|
|
tokenStr, err := iss.Issue(ctx, []string{"x"}, nil, 1*time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("Issue: %v", err)
|
|
}
|
|
|
|
// Build a second issuer with a totally different key ring.
|
|
otherKey, _ := GenerateKey()
|
|
otherRing, _ := NewKeyRing("k2", otherKey)
|
|
otherIss, _ := NewIssuer(otherRing, nil, WithClock(func() time.Time { return now }))
|
|
|
|
if _, err := otherIss.Validate(ctx, tokenStr); !errors.Is(err, ErrUnknownKey) {
|
|
t.Errorf("expected ErrUnknownKey, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateAfterRevoke(t *testing.T) {
|
|
iss, store := newTestIssuer(t, time.Unix(1_700_000_000, 0))
|
|
ctx := context.Background()
|
|
|
|
tokenStr, err := iss.Issue(ctx, []string{"x"}, nil, 1*time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("Issue: %v", err)
|
|
}
|
|
|
|
// Validates fine before revoke.
|
|
if _, err := iss.Validate(ctx, tokenStr); err != nil {
|
|
t.Fatalf("Validate before revoke: %v", err)
|
|
}
|
|
|
|
if err := iss.Revoke(ctx, tokenStr); err != nil {
|
|
t.Fatalf("Revoke: %v", err)
|
|
}
|
|
|
|
if _, err := iss.Validate(ctx, tokenStr); !errors.Is(err, ErrRevokedToken) {
|
|
t.Errorf("expected ErrRevoked, got %v", err)
|
|
}
|
|
|
|
if store.Len() != 1 {
|
|
t.Errorf("store Len: got %d, want 1", store.Len())
|
|
}
|
|
}
|
|
|
|
func TestRevokeRequiresStore(t *testing.T) {
|
|
now := time.Unix(1_700_000_000, 0)
|
|
key, _ := GenerateKey()
|
|
ring, _ := NewKeyRing("k1", key)
|
|
|
|
// Issuer with no revocation store.
|
|
iss, _ := NewIssuer(ring, nil, WithClock(func() time.Time { return now }))
|
|
ctx := context.Background()
|
|
|
|
tokenStr, err := iss.Issue(ctx, []string{"x"}, nil, 1*time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("Issue: %v", err)
|
|
}
|
|
|
|
if err := iss.Revoke(ctx, tokenStr); err == nil {
|
|
t.Error("expected error revoking without a store, got nil")
|
|
}
|
|
}
|
|
|
|
func TestRevokeMalformedToken(t *testing.T) {
|
|
iss, _ := newTestIssuer(t, time.Now())
|
|
ctx := context.Background()
|
|
|
|
if err := iss.Revoke(ctx, "not-a-token"); !errors.Is(err, ErrInvalidToken) {
|
|
t.Errorf("expected ErrInvalidToken, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestKeyRotation(t *testing.T) {
|
|
// Issue with k1, rotate, validate that old token still works
|
|
// AND that new tokens are signed with k2.
|
|
now := time.Unix(1_700_000_000, 0)
|
|
k1, _ := GenerateKey()
|
|
ring, _ := NewKeyRing("k1", k1)
|
|
store := NewMemoryRevocationStore()
|
|
|
|
clock := now
|
|
iss, _ := NewIssuer(ring, store, WithClock(func() time.Time { return clock }))
|
|
ctx := context.Background()
|
|
|
|
oldToken, err := iss.Issue(ctx, []string{"old"}, nil, 1*time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("Issue old: %v", err)
|
|
}
|
|
|
|
// Rotate: add k2, promote it.
|
|
k2, _ := GenerateKey()
|
|
if err := ring.Add("k2", k2); err != nil {
|
|
t.Fatalf("Add k2: %v", err)
|
|
}
|
|
if err := ring.SetActive("k2"); err != nil {
|
|
t.Fatalf("SetActive: %v", err)
|
|
}
|
|
|
|
// Old token still validates (k1 still in ring).
|
|
tok, err := iss.Validate(ctx, oldToken)
|
|
if err != nil {
|
|
t.Fatalf("Validate old token after rotation: %v", err)
|
|
}
|
|
if !tok.Has("old") {
|
|
t.Error("old permissions missing after rotation")
|
|
}
|
|
|
|
// New tokens use k2.
|
|
newToken, err := iss.Issue(ctx, []string{"new"}, nil, 1*time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("Issue new: %v", err)
|
|
}
|
|
if !strings.HasPrefix(newToken, "v1.k2.") {
|
|
t.Errorf("new token should be signed with k2: %s", newToken)
|
|
}
|
|
|
|
// Remove old key — old token now fails.
|
|
if err := ring.Remove("k1"); err != nil {
|
|
t.Fatalf("Remove k1: %v", err)
|
|
}
|
|
if _, err := iss.Validate(ctx, oldToken); !errors.Is(err, ErrUnknownKey) {
|
|
t.Errorf("expected ErrUnknownKey after k1 removed, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestUniqueTokenIDsAcrossIssues(t *testing.T) {
|
|
iss, _ := newTestIssuer(t, time.Now())
|
|
ctx := context.Background()
|
|
|
|
const N = 200
|
|
seen := make(map[string]bool, N)
|
|
for i := 0; i < N; i++ {
|
|
tokStr, err := iss.Issue(ctx, []string{"x"}, nil, 1*time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("Issue %d: %v", i, err)
|
|
}
|
|
tok, err := iss.Validate(ctx, tokStr)
|
|
if err != nil {
|
|
t.Fatalf("Validate %d: %v", i, err)
|
|
}
|
|
if seen[tok.ID()] {
|
|
t.Fatalf("duplicate token ID at iteration %d: %s", i, tok.ID())
|
|
}
|
|
seen[tok.ID()] = true
|
|
}
|
|
}
|