authkit/service_jwt_test.go
juancwu ca5525d4bd 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>
2026-04-26 23:41:02 +00:00

100 lines
2.8 KiB
Go

package authkit
import (
"context"
"errors"
"testing"
"time"
)
func TestIntegration_JWTIssueAuthenticate(t *testing.T) {
a := freshAuth(t)
ctx := context.Background()
u, err := a.CreateUser(ctx, "j@j.com")
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
access, refresh, err := a.IssueJWT(ctx, u.ID)
if err != nil {
t.Fatalf("IssueJWT: %v", err)
}
if access == "" || refresh == "" {
t.Fatalf("missing access/refresh")
}
p, err := a.AuthenticateJWT(ctx, access)
if err != nil {
t.Fatalf("AuthenticateJWT: %v", err)
}
if p.UserID != u.ID {
t.Fatalf("user id mismatch")
}
if p.Method != AuthMethodJWT {
t.Fatalf("method = %s, want jwt", p.Method)
}
}
func TestIntegration_JWTRefreshRotationAndReuse(t *testing.T) {
a := freshAuth(t)
ctx := context.Background()
u, err := a.CreateUser(ctx, "ref@r.com")
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
_, refresh1, err := a.IssueJWT(ctx, u.ID)
if err != nil {
t.Fatalf("IssueJWT: %v", err)
}
_, refresh2, err := a.RefreshJWT(ctx, refresh1)
if err != nil {
t.Fatalf("RefreshJWT: %v", err)
}
if refresh1 == refresh2 {
t.Fatalf("refresh did not rotate")
}
if _, _, err := a.RefreshJWT(ctx, refresh1); !errors.Is(err, ErrTokenReused) {
t.Fatalf("expected ErrTokenReused on replay, got %v", err)
}
if _, _, err := a.RefreshJWT(ctx, refresh2); !errors.Is(err, ErrTokenInvalid) {
t.Fatalf("expected ErrTokenInvalid after chain revocation, got %v", err)
}
}
func TestIntegration_JWTInvalidPrefix(t *testing.T) {
a := freshAuth(t)
ctx := context.Background()
if _, _, err := a.RefreshJWT(ctx, "not-a-refresh-token"); !errors.Is(err, ErrTokenInvalid) {
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)
}
}