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>
224 lines
6.6 KiB
Go
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 }
|