add ability to issue tokens with no expiry

This commit is contained in:
juancwu 2026-04-29 12:44:48 +00:00
commit de907d83cb
5 changed files with 76 additions and 9 deletions

View file

@ -41,7 +41,10 @@ func decodePayload(b []byte) (payload, error) {
// expired reports whether the payload's expiry has passed at the given time. // expired reports whether the payload's expiry has passed at the given time.
// Uses >= so a token expiring exactly at `now` is considered expired // Uses >= so a token expiring exactly at `now` is considered expired
// (the conservative choice). // (the conservative choice). Exp == 0 means the token never expires.
func (p payload) expired(now time.Time) bool { func (p payload) expired(now time.Time) bool {
if p.Exp == 0 {
return false
}
return now.Unix() >= p.Exp return now.Unix() >= p.Exp
} }

View file

@ -43,10 +43,15 @@ func NewIssuer(keys *KeyRing, revoked RevocationStore, opts ...IssuerOption) (*I
return i, nil 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 // Issue creates a new token carrying the given permissions and optional
// data blob. data may be nil. ttl must be positive. // 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) { func (i *Issuer) Issue(ctx context.Context, perms []string, data any, ttl time.Duration) (string, error) {
if ttl <= 0 { if ttl != NoExpiry && ttl <= 0 {
return "", fmt.Errorf("ficha: ttl must be positive, got %v", ttl) return "", fmt.Errorf("ficha: ttl must be positive, got %v", ttl)
} }
@ -68,10 +73,12 @@ func (i *Issuer) Issue(ctx context.Context, perms []string, data any, ttl time.D
p := payload{ p := payload{
ID: id, ID: id,
Iat: now.Unix(), Iat: now.Unix(),
Exp: now.Add(ttl).Unix(),
Permissions: perms, Permissions: perms,
Data: dataBytes, Data: dataBytes,
} }
if ttl != NoExpiry {
p.Exp = now.Add(ttl).Unix()
}
plaintext, err := encodePayload(p) plaintext, err := encodePayload(p)
if err != nil { if err != nil {
@ -164,7 +171,11 @@ func (i *Issuer) Revoke(ctx context.Context, token string) error {
return ErrInvalidToken return ErrInvalidToken
} }
return i.revoked.Revoke(ctx, p.ID, time.Unix(p.Exp, 0)) 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 // newTokenID returns a 128-bit random hex string suitable for use as

View file

@ -135,6 +135,47 @@ func TestIssueRejectsInvalidTTL(t *testing.T) {
} }
} }
func TestIssueNoExpiry(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, NoExpiry)
if err != nil {
t.Fatalf("Issue: %v", err)
}
tok, err := iss.Validate(ctx, tokenStr)
if err != nil {
t.Fatalf("Validate: %v", err)
}
if !tok.NeverExpires() {
t.Error("expected NeverExpires() == true")
}
if !tok.ExpiresAt().IsZero() {
t.Errorf("expected zero ExpiresAt, got %v", tok.ExpiresAt())
}
// Far future — still valid.
clock = now.Add(100 * 365 * 24 * time.Hour)
if _, err := iss.Validate(ctx, tokenStr); err != nil {
t.Errorf("Validate far-future: %v", err)
}
// Revocation still works.
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 after revoke, got %v", err)
}
}
func TestValidateExpired(t *testing.T) { func TestValidateExpired(t *testing.T) {
now := time.Unix(1_700_000_000, 0) now := time.Unix(1_700_000_000, 0)
key, _ := GenerateKey() key, _ := GenerateKey()

View file

@ -18,6 +18,8 @@ type RevocationStore interface {
// Revoke marks tokenID as revoked. The until parameter is the // Revoke marks tokenID as revoked. The until parameter is the
// token's natural expiry — implementations may discard the entry // token's natural expiry — implementations may discard the entry
// after that time, since expired tokens fail validation anyway. // after that time, since expired tokens fail validation anyway.
// A zero until indicates the token never expires; the entry must
// be retained indefinitely.
Revoke(ctx context.Context, tokenID string, until time.Time) error Revoke(ctx context.Context, tokenID string, until time.Time) error
} }
@ -46,7 +48,7 @@ func (m *MemoryRevocationStore) IsRevoked(_ context.Context, tokenID string) (bo
if !ok { if !ok {
return false, nil return false, nil
} }
if !m.now().Before(until) { if !until.IsZero() && !m.now().Before(until) {
// Past expiry — would fail validation regardless. Treat as not revoked. // Past expiry — would fail validation regardless. Treat as not revoked.
return false, nil return false, nil
} }
@ -69,6 +71,9 @@ func (m *MemoryRevocationStore) Cleanup() int {
now := m.now() now := m.now()
removed := 0 removed := 0
for id, until := range m.revoked { for id, until := range m.revoked {
if until.IsZero() {
continue
}
if !now.Before(until) { if !now.Before(until) {
delete(m.revoked, id) delete(m.revoked, id)
removed++ removed++

View file

@ -21,13 +21,16 @@ type Token struct {
// newToken builds a Token from a decoded payload. Package-private — // newToken builds a Token from a decoded payload. Package-private —
// only the Issuer constructs Tokens, after successful validation. // only the Issuer constructs Tokens, after successful validation.
func newToken(p payload) *Token { func newToken(p payload) *Token {
return &Token{ t := &Token{
id: p.ID, id: p.ID,
issuedAt: time.Unix(p.Iat, 0), issuedAt: time.Unix(p.Iat, 0),
expiresAt: time.Unix(p.Exp, 0),
permissions: p.Permissions, permissions: p.Permissions,
data: p.Data, data: p.Data,
} }
if p.Exp != 0 {
t.expiresAt = time.Unix(p.Exp, 0)
}
return t
} }
// ID returns the unique token identifier (used by the revocation store). // ID returns the unique token identifier (used by the revocation store).
@ -36,9 +39,13 @@ func (t *Token) ID() string { return t.id }
// IssuedAt returns when the token was issued. // IssuedAt returns when the token was issued.
func (t *Token) IssuedAt() time.Time { return t.issuedAt } func (t *Token) IssuedAt() time.Time { return t.issuedAt }
// ExpiresAt returns when the token expires. // ExpiresAt returns when the token expires. Returns the zero time.Time
// if the token was issued with NoExpiry; check NeverExpires before use.
func (t *Token) ExpiresAt() time.Time { return t.expiresAt } func (t *Token) ExpiresAt() time.Time { return t.expiresAt }
// NeverExpires reports whether the token was issued without an expiry.
func (t *Token) NeverExpires() bool { return t.expiresAt.IsZero() }
// Permissions returns a copy of the token's permission strings. // Permissions returns a copy of the token's permission strings.
// The returned slice is safe to retain and modify. // The returned slice is safe to retain and modify.
func (t *Token) Permissions() []string { func (t *Token) Permissions() []string {