Rebuild for v1.0.0: postgres-only, slug-keyed authz, predicate API
Drops the Dialect/Queries abstraction in favor of a single PostgreSQL 16+ implementation collapsed into the root authkit package, removes the public store interfaces, and reshapes the authorization model around seeded slugs (roles, permissions, abilities) with optional labels. Schema is now squashed into one migrations/0001_init.sql and applied automatically on authkit.New (opt-out via Config.SkipAutoMigrate). A schema verifier checks tables/columns/types/nullability on startup, tolerates extra columns, and falls back to default table names when a configured override is missing. Auth API: CreateUser + SetPassword replace Register; password is nullable. Email OTP (RequestEmailOTP/ConsumeEmailOTP) joins magic links and password reset, all with anti-enumeration silent-success defaults and a Config.RevealUnknownEmail opt-in. Service tokens drop owner columns and validate ability slugs against authkit_abilities at issue. Direct user permissions live alongside role-derived ones; queries return their UNION. Predicate API: HasRole/HasPermission/HasAbility leaves with AnyLogin/AllLogin/AnyServiceKey/AllServiceKey combinators. Validate runs at middleware construction, panicking on unknown slugs. Middleware collapses to RequireLogin (cookie + JWT), RequireGuest (configurable OnAuthenticated), and RequireServiceKey. UserIDFromCtx / UserFromCtx (lazy) / RefreshUserInCtx provide request-lifetime user caching. Cookie defaults flip to Secure=true and HttpOnly=true via *bool with BoolPtr opt-out. CLIs ship under cmd/perms, cmd/roles, cmd/abilities for seeding the authorization vocabulary; the library never seeds rows itself. Tests cover unit-level (slug validation + fuzz, opaque secrets, email normalization, extractors, predicates, OTP generator) and integration flows gated on AUTHKIT_TEST_DATABASE_URL (every Auth method, schema drift detection, migration idempotency, lazy user cache, all middleware paths). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7f1db871bc
commit
d3c5367492
80 changed files with 5605 additions and 4565 deletions
174
authkit.go
174
authkit.go
|
|
@ -3,6 +3,7 @@ package authkit
|
|||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
|
@ -10,31 +11,53 @@ import (
|
|||
"git.juancwu.dev/juancwu/errx"
|
||||
)
|
||||
|
||||
// Deps bundles every backing store and the password hasher the Auth service
|
||||
// depends on. All fields are required; New panics on a nil dep so misuse is
|
||||
// caught at boot rather than under load.
|
||||
// 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 {
|
||||
Users UserStore
|
||||
Sessions SessionStore
|
||||
Tokens TokenStore
|
||||
ServiceKeys ServiceKeyStore
|
||||
Roles RoleStore
|
||||
Permissions PermissionStore
|
||||
Hasher Hasher
|
||||
DB *sql.DB
|
||||
Hasher Hasher
|
||||
}
|
||||
|
||||
// Config tunes session/JWT/token TTLs, cookie shape, JWT signing material,
|
||||
// and optional hooks. Any zero-valued duration is replaced with a sane
|
||||
// default in New; required fields (notably JWTSecret) cause New to panic.
|
||||
// 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 {
|
||||
// Session (opaque) cookies + DB-backed lifetime
|
||||
SessionIdleTTL time.Duration
|
||||
SessionAbsoluteTTL time.Duration
|
||||
SessionCookieName string
|
||||
SessionCookieDomain string
|
||||
SessionCookiePath string
|
||||
SessionCookieSecure bool
|
||||
SessionCookieHTTPOnly bool
|
||||
// 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)
|
||||
|
|
@ -45,38 +68,82 @@ type Config struct {
|
|||
RefreshTokenTTL time.Duration
|
||||
|
||||
// Single-use tokens
|
||||
EmailVerifyTTL time.Duration
|
||||
PasswordResetTTL time.Duration
|
||||
MagicLinkTTL time.Duration
|
||||
EmailVerifyTTL time.Duration
|
||||
PasswordResetTTL time.Duration
|
||||
MagicLinkTTL time.Duration
|
||||
EmailOTPTTL time.Duration
|
||||
EmailOTPMaxAttempts int
|
||||
EmailOTPDigits int
|
||||
|
||||
// Hooks (optional)
|
||||
// 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 that composes the stores and hasher into the
|
||||
// flows callers use: registration, login, sessions, JWTs, magic links, API
|
||||
// keys, and authz checks. It is safe for concurrent use; method receivers
|
||||
// Auth is the high-level service. Safe for concurrent use; method receivers
|
||||
// never mutate Auth state after construction.
|
||||
type Auth struct {
|
||||
deps Deps
|
||||
cfg Config
|
||||
db *sql.DB
|
||||
hasher Hasher
|
||||
cfg Config
|
||||
q queries
|
||||
schema Schema
|
||||
}
|
||||
|
||||
// New validates Deps and Config, fills in defaults, and returns a ready
|
||||
// service. It panics on missing deps or missing JWT secret rather than
|
||||
// returning an error — these are programmer errors, not runtime ones.
|
||||
func New(deps Deps, cfg Config) *Auth {
|
||||
if deps.Users == nil || deps.Sessions == nil || deps.Tokens == nil ||
|
||||
deps.ServiceKeys == nil || deps.Roles == nil || deps.Permissions == nil ||
|
||||
deps.Hasher == nil {
|
||||
panic(errx.New("authkit.New", "all Deps fields are required"))
|
||||
// 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("authkit.New", "Config.JWTSecret is required"))
|
||||
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
|
||||
}
|
||||
|
|
@ -92,6 +159,14 @@ func New(deps Deps, cfg Config) *Auth {
|
|||
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
|
||||
}
|
||||
|
|
@ -107,15 +182,34 @@ func New(deps Deps, cfg Config) *Auth {
|
|||
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 &Auth{deps: deps, cfg: cfg}
|
||||
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 }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue