diff --git a/cmd/demo/main.go b/cmd/demo/main.go new file mode 100644 index 0000000..4347c95 --- /dev/null +++ b/cmd/demo/main.go @@ -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) + } +} diff --git a/issuer.go b/issuer.go new file mode 100644 index 0000000..c731290 --- /dev/null +++ b/issuer.go @@ -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 +} diff --git a/issuer_test.go b/issuer_test.go new file mode 100644 index 0000000..6222ea5 --- /dev/null +++ b/issuer_test.go @@ -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 + } +}