Cap refresh chain lifetime via RefreshChainAbsoluteTTL

Sessions had an absolute cap (created_at + SessionAbsoluteTTL) but the
JWT path only had per-token TTL on the refresh row, letting a
well-behaved client refresh indefinitely. Add chain_started_at to
authkit_tokens, copy it forward on every rotation, and reject in
RefreshJWT when now > chainStartedAt + RefreshChainAbsoluteTTL.
Default 30d, mirroring SessionAbsoluteTTL.

Schema, verifier, queries, model, and integration test updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
juancwu 2026-04-26 23:41:02 +00:00
commit ca5525d4bd
11 changed files with 129 additions and 53 deletions

View file

@ -3,6 +3,7 @@ package authkit
import (
"context"
"errors"
"time"
"git.juancwu.dev/juancwu/errx"
"github.com/google/uuid"
@ -11,6 +12,8 @@ import (
// IssueJWT issues a fresh access JWT and a rotating opaque refresh token.
// The refresh token is bound to a chain via Token.ChainID; rotation
// preserves that chain so reuse-detection can revoke the whole family.
// chainStartedAt is stamped on this row and copied forward on every
// rotation so RefreshJWT can enforce RefreshChainAbsoluteTTL in O(1).
func (a *Auth) IssueJWT(ctx context.Context, userID uuid.UUID) (access, refresh string, err error) {
const op = "authkit.Auth.IssueJWT"
u, err := a.storeGetUserByID(ctx, userID)
@ -21,7 +24,7 @@ func (a *Auth) IssueJWT(ctx context.Context, userID uuid.UUID) (access, refresh
if err != nil {
return "", "", errx.Wrap(op, err)
}
refresh, err = a.mintRefreshToken(ctx, u.ID, uuid.NewString())
refresh, err = a.mintRefreshToken(ctx, u.ID, uuid.NewString(), a.now())
if err != nil {
return "", "", errx.Wrap(op, err)
}
@ -67,7 +70,9 @@ func (a *Auth) AuthenticateJWT(ctx context.Context, access string) (*Principal,
// RefreshJWT consumes the presented refresh token and mints a new
// access+refresh pair. Reuse of an already-consumed refresh token deletes
// the entire chain (logout-everywhere on that device family) and returns
// ErrTokenReused.
// ErrTokenReused. The chain itself is capped at RefreshChainAbsoluteTTL
// from chain_started_at — past that, refresh fails with ErrTokenInvalid
// and the chain is deleted, forcing the user to re-authenticate.
func (a *Auth) RefreshJWT(ctx context.Context, plaintextRefresh string) (access, refresh string, err error) {
const op = "authkit.Auth.RefreshJWT"
hash, ok := ParseOpaqueSecret(prefixRefresh, plaintextRefresh)
@ -92,6 +97,20 @@ func (a *Auth) RefreshJWT(ctx context.Context, plaintextRefresh string) (access,
return "", "", errx.Wrap(op, err)
}
// Enforce the absolute chain cap. If the chain is older than the
// configured ceiling, kill the whole chain rather than minting a
// successor — re-authentication is required.
chainStartedAt := now
if consumed.ChainStartedAt != nil {
chainStartedAt = *consumed.ChainStartedAt
}
if now.After(chainStartedAt.Add(a.cfg.RefreshChainAbsoluteTTL)) {
if consumed.ChainID != nil && *consumed.ChainID != "" {
_, _ = a.storeDeleteByChain(ctx, *consumed.ChainID)
}
return "", "", errx.Wrap(op, ErrTokenInvalid)
}
var chainID string
if consumed.ChainID != nil {
chainID = *consumed.ChainID
@ -100,20 +119,21 @@ func (a *Auth) RefreshJWT(ctx context.Context, plaintextRefresh string) (access,
// Defensive: every refresh token should be chain-bound. Fall back
// to a fresh chain rather than throwing on missing metadata.
chainID = uuid.NewString()
chainStartedAt = now
}
access, err = a.signAccessToken(consumed.UserID, a.userSessionVersion(ctx, consumed.UserID))
if err != nil {
return "", "", errx.Wrap(op, err)
}
refresh, err = a.mintRefreshToken(ctx, consumed.UserID, chainID)
refresh, err = a.mintRefreshToken(ctx, consumed.UserID, chainID, chainStartedAt)
if err != nil {
return "", "", errx.Wrap(op, err)
}
return access, refresh, nil
}
func (a *Auth) mintRefreshToken(ctx context.Context, userID uuid.UUID, chainID string) (string, error) {
func (a *Auth) mintRefreshToken(ctx context.Context, userID uuid.UUID, chainID string, chainStartedAt time.Time) (string, error) {
const op = "authkit.Auth.mintRefreshToken"
plaintext, hash, err := MintOpaqueSecret(a.cfg.Random, prefixRefresh)
if err != nil {
@ -121,12 +141,13 @@ func (a *Auth) mintRefreshToken(ctx context.Context, userID uuid.UUID, chainID s
}
now := a.now()
t := &Token{
Hash: hash,
Kind: TokenRefresh,
UserID: userID,
ChainID: &chainID,
CreatedAt: now,
ExpiresAt: now.Add(a.cfg.RefreshTokenTTL),
Hash: hash,
Kind: TokenRefresh,
UserID: userID,
ChainID: &chainID,
ChainStartedAt: &chainStartedAt,
CreatedAt: now,
ExpiresAt: now.Add(a.cfg.RefreshTokenTTL),
}
if err := a.storeCreateToken(ctx, t); err != nil {
return "", errx.Wrap(op, err)