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

@ -4,6 +4,7 @@ import (
"context"
"errors"
"testing"
"time"
)
func TestIntegration_JWTIssueAuthenticate(t *testing.T) {
@ -65,3 +66,35 @@ func TestIntegration_JWTInvalidPrefix(t *testing.T) {
t.Fatalf("expected ErrTokenInvalid for malformed input, got %v", err)
}
}
func TestIntegration_JWTRefreshChainAbsoluteCap(t *testing.T) {
a := freshAuth(t)
ctx := context.Background()
u, err := a.CreateUser(ctx, "cap@example.com")
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
// freshAuth sets RefreshChainAbsoluteTTL = 24h. Pin clock so the issue
// time is fixed, then advance past the cap on refresh.
t0 := time.Now().UTC()
a.cfg.Clock = func() time.Time { return t0 }
_, refresh1, err := a.IssueJWT(ctx, u.ID)
if err != nil {
t.Fatalf("IssueJWT: %v", err)
}
// One rotation well within the cap should still work.
a.cfg.Clock = func() time.Time { return t0.Add(time.Hour) }
_, refresh2, err := a.RefreshJWT(ctx, refresh1)
if err != nil {
t.Fatalf("RefreshJWT within cap: %v", err)
}
// Advance past the absolute cap. The refresh succeeds at the consume
// step (token row is not yet expired by RefreshTokenTTL — only the
// chain cap kicks in), but the chain cap rejects the rotation.
a.cfg.Clock = func() time.Time { return t0.Add(25 * time.Hour) }
if _, _, err := a.RefreshJWT(ctx, refresh2); !errors.Is(err, ErrTokenInvalid) {
t.Fatalf("expected ErrTokenInvalid past chain absolute cap, got %v", err)
}
}