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>
100 lines
2.8 KiB
Go
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)
|
|
}
|
|
}
|