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.
|
// 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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
19
issuer.go
19
issuer.go
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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++
|
||||||
|
|
|
||||||
13
token.go
13
token.go
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue