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>
90 lines
2.5 KiB
Go
90 lines
2.5 KiB
Go
package authkit
|
|
|
|
// Integration test infrastructure. Skipped when AUTHKIT_TEST_DATABASE_URL is
|
|
// unset so the unit-test suite remains usable without a database.
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"net/netip"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.juancwu.dev/juancwu/authkit/hasher"
|
|
|
|
_ "github.com/jackc/pgx/v5/stdlib"
|
|
)
|
|
|
|
// noIP returns the zero-value netip.Addr — used by tests that don't care
|
|
// about the originating IP.
|
|
func noIP() netip.Addr { return netip.Addr{} }
|
|
|
|
func dbURL(t *testing.T) string {
|
|
t.Helper()
|
|
url := os.Getenv("AUTHKIT_TEST_DATABASE_URL")
|
|
if url == "" {
|
|
t.Skip("AUTHKIT_TEST_DATABASE_URL not set; skipping integration test")
|
|
}
|
|
return url
|
|
}
|
|
|
|
// freshAuth returns a fully-initialized *Auth bound to a clean database.
|
|
// All authkit_* tables are dropped before Migrate runs, so each test sees
|
|
// an empty schema.
|
|
func freshAuth(t *testing.T) *Auth {
|
|
t.Helper()
|
|
url := dbURL(t)
|
|
db, err := sql.Open("pgx", url)
|
|
if err != nil {
|
|
t.Fatalf("sql.Open: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
if err := db.PingContext(context.Background()); err != nil {
|
|
t.Fatalf("ping: %v", err)
|
|
}
|
|
|
|
dropAllAuthkitTables(t, db, DefaultSchema())
|
|
t.Cleanup(func() { dropAllAuthkitTables(t, db, DefaultSchema()) })
|
|
|
|
a, err := New(context.Background(), Deps{
|
|
DB: db,
|
|
Hasher: hasher.NewArgon2id(hasher.DefaultArgon2idParams(), nil),
|
|
}, Config{
|
|
JWTSecret: []byte("integration-secret-thirty-two!!!"),
|
|
JWTIssuer: "authkit-int",
|
|
AccessTokenTTL: 2 * time.Minute,
|
|
RefreshTokenTTL: 48 * time.Hour,
|
|
RefreshChainAbsoluteTTL: 24 * time.Hour,
|
|
SessionIdleTTL: time.Hour,
|
|
SessionAbsoluteTTL: 24 * time.Hour,
|
|
EmailVerifyTTL: time.Hour,
|
|
PasswordResetTTL: time.Hour,
|
|
MagicLinkTTL: time.Minute,
|
|
EmailOTPTTL: time.Minute,
|
|
EmailOTPMaxAttempts: 3,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("authkit.New: %v", err)
|
|
}
|
|
return a
|
|
}
|
|
|
|
func dropAllAuthkitTables(t *testing.T, db *sql.DB, s Schema) {
|
|
t.Helper()
|
|
tables := []string{
|
|
s.Tables.ServiceKeyAbilities, s.Tables.UserPermissions,
|
|
s.Tables.UserRoles, s.Tables.RolePermissions,
|
|
s.Tables.ServiceKeys, s.Tables.Abilities,
|
|
s.Tables.Roles, s.Tables.Permissions,
|
|
s.Tables.Tokens, s.Tables.Sessions, s.Tables.Users,
|
|
s.Tables.SchemaMigrations,
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
for _, name := range tables {
|
|
_, _ = db.ExecContext(ctx,
|
|
fmt.Sprintf("DROP TABLE IF EXISTS %s CASCADE", name))
|
|
}
|
|
}
|