Files
anchorage/internal/pkg/token/rotation_test.go
William Gill 12bf35caf8 anchorage v1.0 initial tree
Greenfield Go multi-tenant IPFS Pinning Service wire-compatible with the
IPFS Pinning Services API spec. Paired 1:1 with Kubo over localhost RPC,
clustered via embedded NATS JetStream, Postgres source-of-truth with
RLS-enforced tenancy, Fiber + huma v2 for the HTTP surface, Authentik
OIDC for session login with kid-rotated HS256 JWT API tokens.

Feature-complete against the 22-milestone build plan, including the
ship-it v1.0 gap items:

  * admin CLIs: drain/uncordon, maintenance, mint-token, rotate-key,
    prune-denylist, rebalance --dry-run, cache-stats, cluster-presences
  * TTL leader election via NATS KV, fence tokens, JetStream dedup
  * rebalancer (plan/apply split), reconciler, requeue sweeper
  * ristretto caches with NATS-backed cross-node invalidation
    (placements live-nodes + token denylist)
  * maintenance watchdog for stuck cluster-pause flag
  * Prometheus /metrics with CIDR ACL, HTTP/pin/scheduler/cache gauges
  * rate limiting: session (10/min) + anonymous global (120/min)
  * integration tests: rebalance, refcount multi-org, RLS belt
  * goreleaser (tar + deb/rpm/apk + Alpine Docker) targeting Gitea

Stack: Cobra/Viper, Fiber v2 + huma v2, embedded NATS JetStream,
pgx/sqlc/golang-migrate, ristretto, TypeID, prometheus/client_golang,
testcontainers-go.
2026-04-16 18:13:36 -05:00

268 lines
8.2 KiB
Go

package token_test
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
jwtPkg "github.com/golang-jwt/jwt/v5"
"anchorage/internal/pkg/ids"
"anchorage/internal/pkg/token"
)
// keyBytes produces a deterministic 32-byte key. Using fmt'd values
// rather than crypto/rand keeps the test reproducible.
func keyBytes(seed byte) []byte {
b := make([]byte, 48)
for i := range b {
b[i] = seed
}
return b
}
const (
issuer = "https://anchorage.test/"
aud = "anchorage"
)
// TestRotationOverlap walks the canonical rotation procedure:
//
// 1. Start with signer_A (key_A primary).
// 2. Add key_B as secondary → signer_A+B (A primary, B loaded).
// 3. Flip primary to B → signer_A+B (B primary, A retained).
// 4. Drop key_A → signer_B (only B).
//
// Tokens minted at each stage must keep verifying at least until their
// minting key is dropped, and new mints must use the primary key's
// kid.
func TestRotationOverlap(t *testing.T) {
keyA := keyBytes(0xA0)
keyB := keyBytes(0xB0)
makeSigner := func(t *testing.T, keys []token.SigningKey) *token.Signer {
t.Helper()
s, err := token.NewSigner(keys, issuer, aud, nil)
if err != nil {
t.Fatalf("NewSigner: %v", err)
}
return s
}
mintHere := func(t *testing.T, s *token.Signer) string {
t.Helper()
raw, _, err := s.Mint(context.Background(), ids.MustNewOrg(), ids.MustNewUser(), "member", nil, time.Hour)
if err != nil {
t.Fatalf("Mint: %v", err)
}
return raw
}
parseOK := func(t *testing.T, s *token.Signer, raw, label string) {
t.Helper()
if _, err := s.Parse(context.Background(), raw); err != nil {
t.Errorf("%s should parse: %v", label, err)
}
}
parseFail := func(t *testing.T, s *token.Signer, raw, label string) {
t.Helper()
if _, err := s.Parse(context.Background(), raw); err == nil {
t.Errorf("%s should NOT parse", label)
}
}
// --- stage 1: only A ---
stage1 := makeSigner(t, []token.SigningKey{
{ID: "A", Key: keyA, Primary: true},
})
tokenA := mintHere(t, stage1)
if stage1.PrimaryKeyID() != "A" {
t.Fatalf("stage1 primary = %q", stage1.PrimaryKeyID())
}
// --- stage 2: A primary, B added as secondary ---
stage2 := makeSigner(t, []token.SigningKey{
{ID: "A", Key: keyA, Primary: true},
{ID: "B", Key: keyB},
})
parseOK(t, stage2, tokenA, "stage2: old A-minted token")
tokenA2 := mintHere(t, stage2)
// Both stage2 tokens are still A-minted (primary unchanged).
parseOK(t, stage2, tokenA2, "stage2: new mint still on key A")
// --- stage 3: B primary, A retained ---
stage3 := makeSigner(t, []token.SigningKey{
{ID: "A", Key: keyA},
{ID: "B", Key: keyB, Primary: true},
})
parseOK(t, stage3, tokenA, "stage3: A-era token still verifies")
tokenB := mintHere(t, stage3)
parseOK(t, stage3, tokenB, "stage3: new B-minted token")
if stage3.PrimaryKeyID() != "B" {
t.Fatalf("stage3 primary = %q", stage3.PrimaryKeyID())
}
// --- stage 4: drop A ---
stage4 := makeSigner(t, []token.SigningKey{
{ID: "B", Key: keyB, Primary: true},
})
parseFail(t, stage4, tokenA, "stage4: key A dropped → A-minted tokens rejected")
parseOK(t, stage4, tokenB, "stage4: B-minted still verifies")
}
// TestRotationRejectsUnknownKid confirms an attacker who knows the
// mint algorithm can't trick anchorage into accepting a kid that isn't
// configured. Silent "try all keys" would defeat the whole feature.
func TestRotationRejectsUnknownKid(t *testing.T) {
s, _ := token.NewSigner([]token.SigningKey{
{ID: "real-key", Key: keyBytes(0x11), Primary: true},
}, issuer, aud, nil)
// Mint a token with a signer that happens to use the same bytes but
// a DIFFERENT kid. Verification against the first signer must fail.
attacker, _ := token.NewSigner([]token.SigningKey{
{ID: "attacker-key", Key: keyBytes(0x11), Primary: true},
}, issuer, aud, nil)
raw, _, err := attacker.Mint(context.Background(), ids.MustNewOrg(), ids.MustNewUser(), "sysadmin", nil, time.Hour)
if err != nil {
t.Fatalf("attacker mint: %v", err)
}
_, err = s.Parse(context.Background(), raw)
if err == nil {
t.Fatal("expected unknown-kid token to be rejected")
}
if !strings.Contains(err.Error(), "unknown kid") {
t.Errorf("error should mention unknown kid, got: %v", err)
}
}
// TestRotationRejectsMissingKid confirms that a token without a `kid`
// header is rejected outright. anchorage is new software — there is
// no pre-rotation token population to accept via a fallback.
func TestRotationRejectsMissingKid(t *testing.T) {
s, err := token.NewSigner([]token.SigningKey{
{ID: "A", Key: keyBytes(0xAA), Primary: true},
}, issuer, aud, nil)
if err != nil {
t.Fatalf("NewSigner: %v", err)
}
// Hand-craft a JWT signed with the right bytes but no `kid` header.
raw := jwtNoKid(t, keyBytes(0xAA))
_, err = s.Parse(context.Background(), raw)
if err == nil {
t.Fatal("expected missing-kid token to be rejected")
}
if !strings.Contains(err.Error(), "kid") {
t.Errorf("error should mention kid, got: %v", err)
}
}
// jwtNoKid mints a minimally-valid HS256 JWT without a `kid` header,
// used by TestRotationRejectsMissingKid to prove Parse rejects the
// pre-rotation shape.
func jwtNoKid(t *testing.T, key []byte) string {
t.Helper()
tok := jwtPkg.NewWithClaims(jwtPkg.SigningMethodHS256, &token.Claims{
RegisteredClaims: jwtPkg.RegisteredClaims{
ID: ids.MustNewToken().String(),
Issuer: issuer,
Audience: jwtPkg.ClaimStrings{aud},
IssuedAt: jwtPkg.NewNumericDate(time.Now()),
NotBefore: jwtPkg.NewNumericDate(time.Now()),
ExpiresAt: jwtPkg.NewNumericDate(time.Now().Add(time.Hour)),
},
})
// Deliberately do NOT set tok.Header["kid"].
signed, err := tok.SignedString(key)
if err != nil {
t.Fatalf("sign: %v", err)
}
return signed
}
// TestNewSignerRejectsInvalidConfig covers the validation surface.
func TestNewSignerRejectsInvalidConfig(t *testing.T) {
tests := []struct {
name string
keys []token.SigningKey
}{
{"empty", nil},
{"no-primary", []token.SigningKey{{ID: "A", Key: keyBytes(0xAA)}}},
{"two-primaries", []token.SigningKey{
{ID: "A", Key: keyBytes(0xAA), Primary: true},
{ID: "B", Key: keyBytes(0xBB), Primary: true},
}},
{"duplicate-id", []token.SigningKey{
{ID: "A", Key: keyBytes(0xAA), Primary: true},
{ID: "A", Key: keyBytes(0xBB)},
}},
{"empty-id", []token.SigningKey{
{ID: "", Key: keyBytes(0xAA), Primary: true},
}},
{"short-key", []token.SigningKey{
{ID: "A", Key: []byte("too-short"), Primary: true},
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if _, err := token.NewSigner(tt.keys, issuer, aud, nil); err == nil {
t.Error("expected error")
}
})
}
}
// TestLoadSigningKeysEmptyReturnsDevFallback confirms zero-config runs
// land on the built-in dev key.
func TestLoadSigningKeysEmptyReturnsDevFallback(t *testing.T) {
keys, err := token.LoadSigningKeys(nil)
if err != nil {
t.Fatalf("LoadSigningKeys: %v", err)
}
if len(keys) != 1 || keys[0].ID != token.DevKeyID || !keys[0].Primary {
t.Fatalf("empty load should produce single primary DevKeyID entry, got %+v", keys)
}
}
// TestLoadSigningKeysRotation verifies multi-key loading from config.
func TestLoadSigningKeysRotation(t *testing.T) {
dir := t.TempDir()
writeKey := func(name string, seed byte) string {
p := filepath.Join(dir, name)
if err := os.WriteFile(p, keyBytes(seed), 0o600); err != nil {
t.Fatalf("write %s: %v", name, err)
}
return p
}
pathA := writeKey("jwt.a.key", 0xAA)
pathB := writeKey("jwt.b.key", 0xBB)
keys, err := token.LoadSigningKeys([]token.KeyFileSpec{
{ID: "2025-10", Path: pathA},
{ID: "2026-04", Path: pathB, Primary: true},
})
if err != nil {
t.Fatalf("LoadSigningKeys: %v", err)
}
if len(keys) != 2 {
t.Fatalf("want 2 keys, got %d", len(keys))
}
if keys[1].ID != "2026-04" || !keys[1].Primary {
t.Errorf("primary entry mis-marked: %+v", keys)
}
}
// TestLoadSigningKeysRejectsNoPrimary makes sure a malformed config
// can't silently produce a signer that can't mint.
func TestLoadSigningKeysRejectsNoPrimary(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "k")
_ = os.WriteFile(p, keyBytes(0x55), 0o600)
if _, err := token.LoadSigningKeys([]token.KeyFileSpec{
{ID: "A", Path: p}, {ID: "B", Path: p},
}); err == nil {
t.Error("expected error when no primary is set")
}
}