From de907d83cb544e19b46231306604478f360373e8 Mon Sep 17 00:00:00 2001 From: juancwu Date: Wed, 29 Apr 2026 12:44:48 +0000 Subject: [PATCH] add ability to issue tokens with no expiry --- codec.go | 5 ++++- issuer.go | 19 +++++++++++++++---- issuer_test.go | 41 +++++++++++++++++++++++++++++++++++++++++ revocation.go | 7 ++++++- token.go | 13 ++++++++++--- 5 files changed, 76 insertions(+), 9 deletions(-) diff --git a/codec.go b/codec.go index 63897d9..e7f8198 100644 --- a/codec.go +++ b/codec.go @@ -41,7 +41,10 @@ func decodePayload(b []byte) (payload, error) { // expired reports whether the payload's expiry has passed at the given time. // 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 { + if p.Exp == 0 { + return false + } return now.Unix() >= p.Exp } diff --git a/issuer.go b/issuer.go index c731290..6f4069b 100644 --- a/issuer.go +++ b/issuer.go @@ -43,10 +43,15 @@ func NewIssuer(keys *KeyRing, revoked RevocationStore, opts ...IssuerOption) (*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. +// 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 <= 0 { + if ttl != NoExpiry && ttl <= 0 { 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{ ID: id, Iat: now.Unix(), - Exp: now.Add(ttl).Unix(), Permissions: perms, Data: dataBytes, } + if ttl != NoExpiry { + p.Exp = now.Add(ttl).Unix() + } plaintext, err := encodePayload(p) if err != nil { @@ -164,7 +171,11 @@ func (i *Issuer) Revoke(ctx context.Context, token string) error { 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 diff --git a/issuer_test.go b/issuer_test.go index 6222ea5..5a37a1c 100644 --- a/issuer_test.go +++ b/issuer_test.go @@ -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) { now := time.Unix(1_700_000_000, 0) key, _ := GenerateKey() diff --git a/revocation.go b/revocation.go index 10c63c2..f082180 100644 --- a/revocation.go +++ b/revocation.go @@ -18,6 +18,8 @@ type RevocationStore interface { // Revoke marks tokenID as revoked. The until parameter is the // token's natural expiry — implementations may discard the entry // 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 } @@ -46,7 +48,7 @@ func (m *MemoryRevocationStore) IsRevoked(_ context.Context, tokenID string) (bo if !ok { 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. return false, nil } @@ -69,6 +71,9 @@ func (m *MemoryRevocationStore) Cleanup() int { now := m.now() removed := 0 for id, until := range m.revoked { + if until.IsZero() { + continue + } if !now.Before(until) { delete(m.revoked, id) removed++ diff --git a/token.go b/token.go index 19bf3f3..01c1c23 100644 --- a/token.go +++ b/token.go @@ -21,13 +21,16 @@ type Token struct { // newToken builds a Token from a decoded payload. Package-private — // only the Issuer constructs Tokens, after successful validation. func newToken(p payload) *Token { - return &Token{ + t := &Token{ id: p.ID, issuedAt: time.Unix(p.Iat, 0), - expiresAt: time.Unix(p.Exp, 0), permissions: p.Permissions, 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). @@ -36,9 +39,13 @@ func (t *Token) ID() string { return t.id } // IssuedAt returns when the token was issued. 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 } +// 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. // The returned slice is safe to retain and modify. func (t *Token) Permissions() []string {