189 lines
4.6 KiB
Go
189 lines
4.6 KiB
Go
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
|
|
}
|
|
|
|
// NoExpiry, when passed as the ttl to Issue, produces a token that never
|
|
// expires. Such tokens can still be invalidated through the RevocationStore.
|
|
const NoExpiry time.Duration = -1
|
|
|
|
// Issue creates a new token carrying the given permissions and optional
|
|
// data blob. data may be nil. ttl must be positive, or NoExpiry to mint
|
|
// a token without an expiry.
|
|
func (i *Issuer) Issue(ctx context.Context, perms []string, data any, ttl time.Duration) (string, error) {
|
|
if ttl != NoExpiry && 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(),
|
|
Permissions: perms,
|
|
Data: dataBytes,
|
|
}
|
|
if ttl != NoExpiry {
|
|
p.Exp = now.Add(ttl).Unix()
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
var until time.Time
|
|
if p.Exp != 0 {
|
|
until = time.Unix(p.Exp, 0)
|
|
}
|
|
return i.revoked.Revoke(ctx, p.ID, until)
|
|
}
|
|
|
|
// 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
|
|
}
|