add Issuer with Issue/Validate/Revoke and end-to-end tests
This commit is contained in:
parent
8b5fcc87c3
commit
bc1c2f97b0
3 changed files with 581 additions and 0 deletions
75
cmd/demo/main.go
Normal file
75
cmd/demo/main.go
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"git.juancwu.dev/juancwu/ficha"
|
||||
)
|
||||
|
||||
type Meta struct {
|
||||
UserID string `json:"user_id"`
|
||||
Tier string `json:"tier"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
key, err := ficha.GenerateKey()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
ring, err := ficha.NewKeyRing("k1", key)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
store := ficha.NewMemoryRevocationStore()
|
||||
iss, err := ficha.NewIssuer(ring, store)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
tokenStr, err := iss.Issue(ctx,
|
||||
[]string{"orders:read", "orders:write", "billing-manager"},
|
||||
Meta{UserID: "u_42", Tier: "pro"},
|
||||
15*time.Minute,
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println("Token:", tokenStr)
|
||||
|
||||
tok, err := iss.Validate(ctx, tokenStr)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Println("\nID: ", tok.ID())
|
||||
fmt.Println("ExpiresAt: ", tok.ExpiresAt())
|
||||
fmt.Println("Permissions:", tok.Permissions())
|
||||
|
||||
var meta Meta
|
||||
if err := tok.UnmarshalData(&meta); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("Data: %+v\n", meta)
|
||||
|
||||
fmt.Println("\nChecks:")
|
||||
fmt.Println(" Has(orders:read): ", tok.Has("orders:read"))
|
||||
fmt.Println(" HasAll(read,write): ", tok.HasAll("orders:read", "orders:write"))
|
||||
fmt.Println(" RequiresAll([]): ", tok.RequiresAll())
|
||||
fmt.Println(" Composed matcher: ", tok.Check(ficha.All(
|
||||
"orders:read",
|
||||
ficha.Any("admin", "billing-manager"),
|
||||
ficha.Not("readonly"),
|
||||
)))
|
||||
|
||||
if err := iss.Revoke(ctx, tokenStr); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println("\nAfter revoke:")
|
||||
if _, err := iss.Validate(ctx, tokenStr); err != nil {
|
||||
fmt.Println(" Validate error:", err)
|
||||
}
|
||||
}
|
||||
178
issuer.go
Normal file
178
issuer.go
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
package ficha
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Issuer issues, validates, and revokes ficha tokens.
|
||||
//
|
||||
// An Issuer is safe for concurrent use by multiple goroutines.
|
||||
type Issuer struct {
|
||||
keys *KeyRing
|
||||
revoked RevocationStore
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// IssuerOption configures an Issuer at construction.
|
||||
type IssuerOption func(*Issuer)
|
||||
|
||||
// WithClock overrides the time source. Used in tests; defaults to time.Now.
|
||||
func WithClock(now func() time.Time) IssuerOption {
|
||||
return func(i *Issuer) { i.now = now }
|
||||
}
|
||||
|
||||
// NewIssuer constructs an Issuer. The keys argument is required; revoked
|
||||
// may be nil to disable revocation entirely (tokens still expire normally).
|
||||
func NewIssuer(keys *KeyRing, revoked RevocationStore, opts ...IssuerOption) (*Issuer, error) {
|
||||
if keys == nil {
|
||||
return nil, fmt.Errorf("ficha: keys is required")
|
||||
}
|
||||
i := &Issuer{
|
||||
keys: keys,
|
||||
revoked: revoked,
|
||||
now: time.Now,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(i)
|
||||
}
|
||||
return i, nil
|
||||
}
|
||||
|
||||
// Issue creates a new token carrying the given permissions and optional
|
||||
// data blob. data may be nil. ttl must be positive.
|
||||
func (i *Issuer) Issue(ctx context.Context, perms []string, data any, ttl time.Duration) (string, error) {
|
||||
if ttl <= 0 {
|
||||
return "", fmt.Errorf("ficha: ttl must be positive, got %v", ttl)
|
||||
}
|
||||
|
||||
var dataBytes json.RawMessage
|
||||
if data != nil {
|
||||
raw, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ficha: marshal data: %w", err)
|
||||
}
|
||||
dataBytes = raw
|
||||
}
|
||||
|
||||
id, err := newTokenID()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
now := i.now()
|
||||
p := payload{
|
||||
ID: id,
|
||||
Iat: now.Unix(),
|
||||
Exp: now.Add(ttl).Unix(),
|
||||
Permissions: perms,
|
||||
Data: dataBytes,
|
||||
}
|
||||
|
||||
plaintext, err := encodePayload(p)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ficha: encode payload: %w", err)
|
||||
}
|
||||
|
||||
keyID, key := i.keys.Active()
|
||||
nonce, ciphertext, err := encrypt(key, plaintext, aadFor(keyID))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return encodeToken(keyID, nonce, ciphertext), nil
|
||||
}
|
||||
|
||||
// Validate parses, decrypts, and authorizes the given token. On success
|
||||
// it returns a *Token whose methods can be used for permission checks.
|
||||
//
|
||||
// Returns ErrInvalidToken for malformed, tampered, or undecryptable tokens;
|
||||
// ErrUnknownKey if the token's key ID isn't in the ring; ErrExpired if
|
||||
// the token's expiry has passed; ErrRevoked if the revocation store
|
||||
// reports the token as revoked.
|
||||
func (i *Issuer) Validate(ctx context.Context, token string) (*Token, error) {
|
||||
keyID, nonce, ciphertext, err := decodeToken(token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
key, ok := i.keys.Get(keyID)
|
||||
if !ok {
|
||||
return nil, ErrUnknownKey
|
||||
}
|
||||
|
||||
plaintext, err := decrypt(key, nonce, ciphertext, aadFor(keyID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p, err := decodePayload(plaintext)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
if p.expired(i.now()) {
|
||||
return nil, ErrExpiredToken
|
||||
}
|
||||
|
||||
if i.revoked != nil {
|
||||
isRev, err := i.revoked.IsRevoked(ctx, p.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ficha: check revocation: %w", err)
|
||||
}
|
||||
if isRev {
|
||||
return nil, ErrRevokedToken
|
||||
}
|
||||
}
|
||||
|
||||
return newToken(p), nil
|
||||
}
|
||||
|
||||
// Revoke marks the given token as revoked until its natural expiry.
|
||||
// Requires a non-nil RevocationStore.
|
||||
//
|
||||
// Revoke decrypts the token to extract its ID, so it returns the same
|
||||
// errors Validate does for malformed input. Note that Revoke succeeds
|
||||
// even on an already-expired token — the store entry will simply be
|
||||
// short-lived.
|
||||
func (i *Issuer) Revoke(ctx context.Context, token string) error {
|
||||
if i.revoked == nil {
|
||||
return fmt.Errorf("ficha: revocation store not configured")
|
||||
}
|
||||
|
||||
keyID, nonce, ciphertext, err := decodeToken(token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
key, ok := i.keys.Get(keyID)
|
||||
if !ok {
|
||||
return ErrUnknownKey
|
||||
}
|
||||
|
||||
plaintext, err := decrypt(key, nonce, ciphertext, aadFor(keyID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
p, err := decodePayload(plaintext)
|
||||
if err != nil {
|
||||
return ErrInvalidToken
|
||||
}
|
||||
|
||||
return i.revoked.Revoke(ctx, p.ID, time.Unix(p.Exp, 0))
|
||||
}
|
||||
|
||||
// newTokenID returns a 128-bit random hex string suitable for use as
|
||||
// a unique token identifier in the revocation store.
|
||||
func newTokenID() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("ficha: generate token id: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
328
issuer_test.go
Normal file
328
issuer_test.go
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
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 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue