add ability to issue tokens with no expiry
This commit is contained in:
parent
1d17fe5577
commit
de907d83cb
5 changed files with 76 additions and 9 deletions
5
codec.go
5
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
|
||||
}
|
||||
|
|
|
|||
19
issuer.go
19
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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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++
|
||||
|
|
|
|||
13
token.go
13
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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue