add Issuer with Issue/Validate/Revoke and end-to-end tests

This commit is contained in:
juancwu 2026-04-29 02:02:44 +00:00
commit bc1c2f97b0
3 changed files with 581 additions and 0 deletions

178
issuer.go Normal file
View 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
}