authkit/authkit.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

224 lines
6.6 KiB
Go

package authkit
import (
"context"
"crypto/rand"
"database/sql"
"io"
"net/http"
"time"
"git.juancwu.dev/juancwu/errx"
)
// Hasher is the password hashing interface. The default implementation is
// hasher.Argon2id; consumers can swap in alternative KDFs as long as the
// encoded form lets Verify roundtrip and report needsRehash on parameter
// drift.
type Hasher interface {
Hash(password string) (string, error)
Verify(password, encoded string) (ok bool, needsRehash bool, err error)
}
// Deps bundles the runtime dependencies the Auth service requires. DB and
// Hasher are required; New panics on either being nil.
type Deps struct {
DB *sql.DB
Hasher Hasher
}
// Config tunes session/JWT/token TTLs, cookie shape, JWT signing material,
// schema overrides, and optional hooks. Zero-valued durations are replaced
// with sane defaults in New; required fields (notably JWTSecret) cause New
// to panic.
type Config struct {
// Schema lets consumers override table names. Zero value uses
// DefaultSchema().
Schema Schema
// SkipAutoMigrate disables the migration run inside New. The verifier
// still runs; consumers running their own migrate pipeline should set
// this and call authkit.Migrate manually before New (or skip it
// entirely if they manage DDL out-of-band).
SkipAutoMigrate bool
// SkipSchemaVerify disables the startup schema check. Recommended only
// for tests that expect schema drift; production callers should let the
// verifier run.
SkipSchemaVerify bool
// Sessions
SessionIdleTTL time.Duration
SessionAbsoluteTTL time.Duration
SessionCookieName string
SessionCookieDomain string
SessionCookiePath string
// SessionCookieSecure / SessionCookieHTTPOnly use *bool so a nil value
// means "fall back to the secure default (true)" while *bool(false) is
// an explicit opt-out for local dev. BoolPtr is a one-line constructor.
SessionCookieSecure *bool
SessionCookieHTTPOnly *bool
SessionCookieSameSite http.SameSite
// JWT (HS256)
JWTSecret []byte
JWTIssuer string
JWTAudience string
AccessTokenTTL time.Duration
RefreshTokenTTL time.Duration
// RefreshChainAbsoluteTTL caps the maximum lifetime of a refresh chain.
// A user can refresh as often as they want within RefreshTokenTTL of the
// last rotation, but the chain itself dies once now > chainStartedAt +
// RefreshChainAbsoluteTTL — at which point the user must re-authenticate.
// Mirrors SessionAbsoluteTTL on the session path.
RefreshChainAbsoluteTTL time.Duration
// Single-use tokens
EmailVerifyTTL time.Duration
PasswordResetTTL time.Duration
MagicLinkTTL time.Duration
EmailOTPTTL time.Duration
EmailOTPMaxAttempts int
EmailOTPDigits int
// RevealUnknownEmail flips request flows (RequestPasswordReset,
// RequestMagicLink, RequestEmailOTP) from anti-enumeration silent
// success to returning ErrUserNotFound when the email isn't
// registered. Default false (silent).
RevealUnknownEmail bool
// Hooks
Clock func() time.Time
Random io.Reader
LoginHook func(ctx context.Context, email string, success bool) error
}
// Auth is the high-level service. Safe for concurrent use; method receivers
// never mutate Auth state after construction.
type Auth struct {
db *sql.DB
hasher Hasher
cfg Config
q queries
schema Schema
}
// New validates Deps and Config, fills in defaults, runs migrations
// (unless SkipAutoMigrate), verifies the schema (unless SkipSchemaVerify),
// and returns a ready service.
//
// Panics on missing deps, missing JWT secret, invalid schema, or schema
// drift — these are programmer/operator errors, not runtime failures.
func New(ctx context.Context, deps Deps, cfg Config) (*Auth, error) {
const op = "authkit.New"
if deps.DB == nil {
panic(errx.New(op, "Deps.DB is required"))
}
if deps.Hasher == nil {
panic(errx.New(op, "Deps.Hasher is required"))
}
if len(cfg.JWTSecret) == 0 {
panic(errx.New(op, "Config.JWTSecret is required"))
}
cfg.Schema = mergeSchemaDefaults(cfg.Schema)
if err := cfg.Schema.Validate(); err != nil {
panic(errx.Wrap(op, err))
}
cfg = applyDefaults(cfg)
a := &Auth{
db: deps.DB,
hasher: deps.Hasher,
cfg: cfg,
q: buildQueries(cfg.Schema.Tables),
schema: cfg.Schema,
}
if !cfg.SkipAutoMigrate {
if err := Migrate(ctx, deps.DB, cfg.Schema); err != nil {
return nil, errx.Wrap(op, err)
}
}
if !cfg.SkipSchemaVerify {
if err := VerifySchema(ctx, deps.DB, cfg.Schema); err != nil {
return nil, errx.Wrap(op, err)
}
}
return a, nil
}
func applyDefaults(cfg Config) Config {
if cfg.SessionIdleTTL == 0 {
cfg.SessionIdleTTL = 24 * time.Hour
}
if cfg.SessionAbsoluteTTL == 0 {
cfg.SessionAbsoluteTTL = 30 * 24 * time.Hour
}
if cfg.SessionCookieName == "" {
cfg.SessionCookieName = "authkit_session"
}
if cfg.SessionCookiePath == "" {
cfg.SessionCookiePath = "/"
}
if cfg.SessionCookieSameSite == 0 {
cfg.SessionCookieSameSite = http.SameSiteLaxMode
}
// Secure & HTTPOnly default to true. Consumers wanting plain HTTP for
// local dev must pass an explicit *false via BoolPtr.
if cfg.SessionCookieSecure == nil {
cfg.SessionCookieSecure = BoolPtr(true)
}
if cfg.SessionCookieHTTPOnly == nil {
cfg.SessionCookieHTTPOnly = BoolPtr(true)
}
if cfg.AccessTokenTTL == 0 {
cfg.AccessTokenTTL = 15 * time.Minute
}
if cfg.RefreshTokenTTL == 0 {
cfg.RefreshTokenTTL = 30 * 24 * time.Hour
}
if cfg.RefreshChainAbsoluteTTL == 0 {
cfg.RefreshChainAbsoluteTTL = 30 * 24 * time.Hour
}
if cfg.EmailVerifyTTL == 0 {
cfg.EmailVerifyTTL = 48 * time.Hour
}
if cfg.PasswordResetTTL == 0 {
cfg.PasswordResetTTL = time.Hour
}
if cfg.MagicLinkTTL == 0 {
cfg.MagicLinkTTL = 15 * time.Minute
}
if cfg.EmailOTPTTL == 0 {
cfg.EmailOTPTTL = 10 * time.Minute
}
if cfg.EmailOTPMaxAttempts == 0 {
cfg.EmailOTPMaxAttempts = 5
}
if cfg.EmailOTPDigits == 0 {
cfg.EmailOTPDigits = 6
}
if cfg.Clock == nil {
cfg.Clock = func() time.Time { return time.Now().UTC() }
}
if cfg.Random == nil {
cfg.Random = rand.Reader
}
return cfg
}
// BoolPtr is a one-line helper for Config fields that take *bool. Use it to
// opt out of the secure cookie defaults: cfg.SessionCookieSecure = BoolPtr(false).
func BoolPtr(b bool) *bool { return &b }
// now returns the configured wall clock, defaulting to time.Now in UTC.
func (a *Auth) now() time.Time { return a.cfg.Clock() }
// DB exposes the underlying *sql.DB. Useful for callers that want to run
// admin queries on the same pool.
func (a *Auth) DB() *sql.DB { return a.db }
// Schema returns the configured schema.
func (a *Auth) Schema() Schema { return a.schema }