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:
juancwu 2026-04-26 23:27:30 +00:00
commit d3c5367492
80 changed files with 5605 additions and 4565 deletions

487
README.md
View file

@ -1,408 +1,283 @@
# authkit
A pragmatic authentication and authorization toolkit for Go web services.
A pragmatic authentication and authorization toolkit for Go web services
on PostgreSQL 16+.
`authkit` ships interfaces for users, sessions, tokens, API keys, roles, and
permissions, plus default `database/sql` Postgres implementations and
framework-neutral HTTP middleware. It supports both opaque server-side
sessions and JWT access tokens with rotating refresh tokens, hashes passwords
with Argon2id, and pairs naturally with [`lightmux`](https://git.juancwu.dev/juancwu/lightmux)
or any `net/http` stack.
`authkit` is a library, not a service. Drop it into a `net/http` stack and
get registration, password login, opaque server-side sessions, JWT access
tokens with rotating refresh, email verification, password reset,
magic-link login, email OTP, and machine-targeted service tokens with
consumer-defined abilities. Authorization is flat RBAC with both
role-derived and direct user permissions.
> **Status:** v1.0.0 development. The API is being stabilised; expect
> breaking changes until the v1.0.0 tag.
## Install
```
go get git.juancwu.dev/juancwu/authkit@v0.1.0
```sh
go get git.juancwu.dev/juancwu/authkit
```
`authkit` itself depends only on `database/sql` and the Go standard library
plus `golang-jwt`, `google/uuid`, `golang.org/x/crypto`, and `errx`. Bring
your own driver: `pgx`, `lib/pq`, or anything else that registers a
`database/sql` driver.
`authkit` depends only on the Go standard library, `golang-jwt`,
`google/uuid`, `golang.org/x/crypto`, and `errx`. Bring your own driver:
`pgx`, `lib/pq`, or anything else that registers a `database/sql` driver.
```go
import _ "github.com/jackc/pgx/v5/stdlib" // or _ "github.com/lib/pq"
```
PostgreSQL 12+ is sufficient — the schema avoids `gen_random_uuid()` and
`pgcrypto` so no extensions are required.
PostgreSQL 16 or newer is required.
## What's included
**Authentication flows**
- Email + password registration and login (Argon2id PHC-encoded hashes)
**Authentication**
- Email-only registration (`CreateUser`); password is optional and can be
set later via `SetPassword`
- Password login with Argon2id PHC-encoded hashes
- Opaque server-side sessions with sliding TTL bounded by an absolute cap
- JWT access tokens (HS256) with rotating refresh tokens and reuse detection
- Email verification, password reset, and magic-link passwordless login
- HS256 JWT access tokens with rotating refresh tokens and reuse
detection
- Email verification, password reset, magic-link login, email OTP
**Authorization**
- Roles and permissions with many-to-many wiring (resolved on user-bound
`Principal`s)
- Owner-agnostic service tokens with custom abilities for server-to-server
auth (no FK on owner; cascade-on-delete is the consumer's responsibility)
- A `Principal` for user-bound auth (sessions, JWTs) and a `ServiceKey` for
service-token auth — middleware composes around both subject types
- Roles and permissions (flat RBAC)
- Direct user-permission grants in addition to role-derived ones —
`UserPermissions` returns the UNION
- Service tokens with consumer-defined abilities (machine credentials, no
user owner)
**Predicate API for middleware authz**
- Leaves: `HasRole(slug)`, `HasPermission(slug)`, `HasAbility(slug)`
- Combinators: `AnyLogin`, `AllLogin`, `AnyServiceKey`, `AllServiceKey`
- Compose freely:
`AnyLogin(HasRole("admin"), AllLogin(HasRole("manager"), HasRole("ads_manager")))`
**HTTP middleware**
- `RequireLogin` — accept session cookie OR JWT, optionally constrain by
`LoginAuthz`
- `RequireGuest` — block authenticated requests (with a configurable
`OnAuthenticated` callback for redirects)
- `RequireServiceKey` — accept a service token, optionally constrain by
`ServiceKeyAuthz`
**Storage**
- Interfaces for every store so callers can plug in their own backends
- Default Postgres implementation built on `*sql.DB` (`sqlstore` package)
- Override table names via `Schema` without forking — useful when authkit
lives alongside an existing application schema
- A `Dialect` abstraction so future MySQL / SQLite implementations slot in
without changes to store code
- Embedded versioned migrations applied by a `Migrate(ctx, db, dialect, schema)`
helper that takes a session-scoped advisory lock
**HTTP**
- User-bound: `middleware.RequireSession`, `RequireJWT`, `RequireAny`
- Service-bound: `middleware.RequireServiceKey`
- Either: `middleware.RequireAnyOrServiceKey` (Session/JWT, falling through to
ServiceKey)
- Authz: `middleware.RequireRole`, `RequireAnyRole`, `RequirePermission`
(operate on `*Principal`); `middleware.RequireAbility` (operates on
`*ServiceKey`)
- `middleware.PrincipalFrom(ctx)` and `middleware.ServiceKeyFrom(ctx)` to
read the authenticated subject in handlers
- PostgreSQL 16+ only
- Migrations and schema verification run on startup (opt-out via
`Config.SkipAutoMigrate` / `Config.SkipSchemaVerify`)
- Override individual table names via `Schema.Tables`
- Schema verifier tolerates extra columns; flags missing tables, missing
columns, type drift, and nullability drift
**Errors**
- Sentinel errors (`ErrEmailTaken`, `ErrInvalidCredentials`, `ErrTokenInvalid`,
`ErrTokenReused`, `ErrSessionInvalid`, `ErrServiceKeyInvalid`,
`ErrPermissionDenied`, ...) compatible with `errors.Is`
- Sentinel errors compatible with `errors.Is`
- All internal errors wrap with [`errx`](https://git.juancwu.dev/juancwu/errx)
for op tags
## Out of scope (v1)
MFA/TOTP, OAuth/social login, soft-delete, in-memory permission caching,
pluggable JWT signers (HS256 only), built-in HTTP handlers, MySQL/SQLite
dialects (architecture supports them; only Postgres ships in v1), and
column-name overrides in `Schema` (table-name overrides only).
## Quick start
### 1. Open a database and run migrations
### 1. Open a database
```go
import (
"database/sql"
"git.juancwu.dev/juancwu/authkit/sqlstore"
pgdialect "git.juancwu.dev/juancwu/authkit/sqlstore/dialect/postgres"
_ "github.com/jackc/pgx/v5/stdlib" // or _ "github.com/lib/pq"
_ "github.com/jackc/pgx/v5/stdlib"
)
db, err := sql.Open("pgx", os.Getenv("DATABASE_URL"))
if err != nil { /* ... */ }
defer db.Close()
if err := sqlstore.Migrate(ctx, db, pgdialect.New(), sqlstore.DefaultSchema()); err != nil {
log.Fatal(err)
}
```
`Migrate` is idempotent and safe to call from multiple processes — it takes
a session-scoped `pg_advisory_lock` to serialise rollouts.
`sqlx` users can pass `sqlxDB.DB` (the underlying `*sql.DB`) to the same
calls — the library only cares about `*sql.DB`.
### 2. Wire the service
### 2. Construct Auth
```go
import (
"context"
"git.juancwu.dev/juancwu/authkit"
"git.juancwu.dev/juancwu/authkit/hasher"
)
stores, err := sqlstore.New(db, pgdialect.New(), sqlstore.DefaultSchema())
if err != nil { /* ... */ }
auth := authkit.New(authkit.Deps{
Users: stores.Users,
Sessions: stores.Sessions,
Tokens: stores.Tokens,
ServiceKeys: stores.ServiceKeys,
Roles: stores.Roles,
Permissions: stores.Permissions,
Hasher: hasher.NewArgon2id(hasher.DefaultArgon2idParams(), nil),
auth, err := authkit.New(ctx, authkit.Deps{
DB: db,
Hasher: hasher.NewArgon2id(hasher.DefaultArgon2idParams(), nil),
}, authkit.Config{
JWTSecret: []byte(os.Getenv("JWT_SECRET")),
JWTIssuer: "myapp",
SessionCookieSecure: true,
SessionCookieHTTPOnly: true,
JWTSecret: []byte(os.Getenv("JWT_SECRET")),
JWTIssuer: "myapp",
})
if err != nil { log.Fatal(err) }
```
`Config` zero values fall back to sensible defaults (24h idle / 30d absolute
session TTL, 15m access tokens, 30d refresh tokens, 48h email-verify, 1h
password-reset, 15m magic-link). `JWTSecret` and all seven `Deps` fields are
required; `New` panics on a misconfiguration.
`New` runs migrations and verifies the schema. `Config` zero values fall
back to sane defaults: 24h idle / 30d absolute session TTL, 15m access /
30d refresh, 48h email-verify, 1h password-reset, 15m magic-link, 10m
email OTP with 5 attempts. Cookie defaults: `Secure=true`, `HttpOnly=true`,
`SameSite=Lax`. Pass `authkit.BoolPtr(false)` to opt out for local dev.
### 3. Use the service
### 3. Seed roles, permissions, and abilities
`authkit` does not seed any rows. Use the bundled CLIs:
```sh
go install git.juancwu.dev/juancwu/authkit/cmd/perms@latest
go install git.juancwu.dev/juancwu/authkit/cmd/roles@latest
go install git.juancwu.dev/juancwu/authkit/cmd/abilities@latest
export AUTHKIT_DATABASE_URL=postgres://...
perms create posts:write --label "Write posts"
perms create posts:read --label "Read posts"
roles create editor --label "Editor"
roles grant editor posts:write
roles grant editor posts:read
abilities create events:write --label "Events ingest"
```
Or call the equivalent methods on `*authkit.Auth` from your own seed
script. Slugs match `^[a-z][a-z0-9_:-]*$` (max 64 bytes); invalid slugs
return `ErrSlugInvalid`.
### 4. User flows
```go
// Registration + password login
u, err := auth.Register(ctx, "alice@example.com", "hunter2hunter2")
u, err = auth.LoginPassword(ctx, "alice@example.com", "hunter2hunter2")
// Email-only account, password set later.
u, _ := auth.CreateUser(ctx, "alice@example.com")
_ = auth.SetPassword(ctx, u.ID, "hunter2hunter2")
u, _ = auth.LoginPassword(ctx, "Alice@Example.com", "hunter2hunter2") // case-insensitive
// Opaque session (cookie-friendly)
plaintext, sess, err := auth.IssueSession(ctx, u.ID, r.UserAgent(), clientIP)
// Opaque session.
plaintext, sess, _ := auth.IssueSession(ctx, u.ID, r.UserAgent(), clientIP)
http.SetCookie(w, auth.SessionCookie(plaintext, sess.ExpiresAt))
// JWT + rotating refresh
access, refresh, err := auth.IssueJWT(ctx, u.ID)
access, refresh, err = auth.RefreshJWT(ctx, refresh) // old refresh is consumed
// JWT + rotating refresh.
access, refresh, _ := auth.IssueJWT(ctx, u.ID)
access, refresh, _ = auth.RefreshJWT(ctx, refresh) // old refresh is consumed
// Service token (owner-agnostic; ownerKind labels the namespace).
// Service tokens are the only credential type that carries free-form abilities.
plaintext, sk, err := auth.IssueServiceKey(ctx,
"application", appID, "events-ingest",
[]string{"events:write"}, nil)
got, err := auth.AuthenticateServiceKey(ctx, plaintext)
// got.OwnerKind == "application"; got.OwnerID == appID
err = auth.RevokeServiceKey(ctx, plaintext)
// Magic link / OTP / password reset (anti-enumeration: silent on unknown email).
linkToken, _ := auth.RequestMagicLink(ctx, "alice@example.com")
otpCode, _ := auth.RequestEmailOTP(ctx, "alice@example.com")
resetToken, _ := auth.RequestPasswordReset(ctx, "alice@example.com")
// Email verification + password reset + magic link
tok, err := auth.RequestEmailVerification(ctx, u.ID)
_, err = auth.ConfirmEmail(ctx, tok)
tok, err = auth.RequestPasswordReset(ctx, "alice@example.com")
err = auth.ConfirmPasswordReset(ctx, tok, "new-password")
tok, err = auth.RequestMagicLink(ctx, "alice@example.com")
u, err = auth.ConsumeMagicLink(ctx, tok)
// Service token with abilities.
plaintext, sk, _ := auth.IssueServiceKey(ctx, authkit.IssueServiceKeyParams{
Name: "events-ingest",
Abilities: []string{"events:write"},
})
got, _ := auth.AuthenticateServiceKey(ctx, plaintext)
```
The plaintext returned by `IssueSession`, `IssueJWT`, `IssueServiceKey`, and
the token-minting flows is **show-once** — only its SHA-256 hash is stored.
Show it to the user immediately; you cannot recover it later.
The plaintext returned by every issue/mint flow is **show-once** — only
its SHA-256 hash is stored. Show it to the user immediately; you cannot
recover it later.
### 4. Wire middleware
`authkit/middleware` returns standard `func(http.Handler) http.Handler`
values, so it composes with `lightmux.Mux.Use`/`Group`/`Handle` and any
`net/http` mux that accepts the same shape.
### 5. Wire middleware
```go
import (
authkitmw "git.juancwu.dev/juancwu/authkit/middleware"
"git.juancwu.dev/juancwu/lightmux"
"git.juancwu.dev/juancwu/authkit"
"git.juancwu.dev/juancwu/authkit/middleware"
)
mux := lightmux.New()
// Default RequireLogin reads the session cookie and falls through to a
// Bearer JWT.
loginMW := middleware.RequireLogin(middleware.LoginOptions{Auth: auth})
cookieAuth := authkitmw.RequireSession(authkitmw.Options{
Auth: auth,
Extractor: authkit.ChainExtractors(
authkit.CookieExtractor("authkit_session"),
authkit.BearerExtractor(),
// Constrain on roles/permissions:
adminMW := middleware.RequireLogin(middleware.LoginOptions{
Auth: auth,
Authz: authkit.AnyLogin(
authkit.HasRole("admin"),
authkit.AllLogin(authkit.HasRole("manager"), authkit.HasRole("ads_manager")),
),
})
me := mux.Group("/me", cookieAuth)
me.Get("", func(w http.ResponseWriter, r *http.Request) {
p := authkitmw.MustPrincipal(r)
json.NewEncoder(w).Encode(p)
// Login/register pages: block if already authenticated.
guestMW := middleware.RequireGuest(middleware.GuestOptions{
Auth: auth,
OnAuthenticated: func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
},
})
// RBAC: stack authz on top of any auth method
admin := mux.Group("/admin", cookieAuth, authkitmw.RequireRole("admin"))
// Service-token route with a per-endpoint ability check
api := mux.Group("/api/v1", authkitmw.RequireServiceKey(authkitmw.Options{Auth: auth}))
api.Get("/events", eventsHandler, authkitmw.RequireAbility("events:write"))
// Mixed route — accept either a session cookie or a service token
mixed := mux.Group("/v1", authkitmw.RequireAnyOrServiceKey(authkitmw.Options{Auth: auth}))
mixed.Get("/profile", func(w http.ResponseWriter, r *http.Request) {
if p, ok := authkitmw.PrincipalFrom(r.Context()); ok {
// user request
_ = p
} else if k, ok := authkitmw.ServiceKeyFrom(r.Context()); ok {
// service request
_ = k
}
// Service tokens with an ability gate.
apiMW := middleware.RequireServiceKey(middleware.ServiceKeyOptions{
Auth: auth,
Authz: authkit.AllServiceKey(authkit.HasAbility("events:write")),
})
```
`Options.Extractor` defaults to `BearerExtractor`; pass `CookieExtractor` (or
chain extractors) when reading session cookies. `Options.OnUnauth` and
`Options.OnForbidden` default to a JSON `401` / `403`; override them to match
your error envelope.
`RequireLogin` and `RequireServiceKey` panic at construction if any slug
referenced by the predicate isn't registered in the database — typos fail
at boot, not at request time.
## Custom table names
### 6. Read the user in handlers
Pass a non-default `Schema` to use your own table names. Identifiers must
match `^[a-zA-Z_][a-zA-Z0-9_]*$`; anything else is rejected at `New()` and
`Migrate()` time, so SQL injection through the schema is impossible.
Middleware attaches the `user_id` to the request context. Handlers fetch
the full user lazily:
```go
schema := sqlstore.DefaultSchema()
schema.Tables.Users = "accounts"
schema.Tables.ServiceKeys = "service_credentials"
func handle(w http.ResponseWriter, r *http.Request) {
id, _ := authkit.UserIDFromCtx(r.Context()) // never queries the DB
u, err := authkit.UserFromCtx(r.Context()) // lazy-load + per-request cache
if err != nil { /* handle */ }
stores, _ := sqlstore.New(db, pgdialect.New(), schema)
```
The bundled migration files use the default `authkit_*` names. If you
override, you're responsible for matching DDL (most consumers with custom
naming already have their own DDL pipeline).
Column-name overrides are not exposed in v1 — the column set is fixed for
each table. Adding column overrides later is purely additive.
## How things work
### Secret token format
Sessions, refresh tokens, service tokens, email-verify tokens,
password-reset tokens, and magic-link tokens all share one format:
```
plaintext = "<prefix>_" + base64url(32 random bytes, no padding)
lookup = sha256(plaintext)
```
Plaintext is returned to the caller exactly once and never persisted; the
SHA-256 is the database lookup key. Random bytes come from `crypto/rand` (or
`Config.Random` for tests). The mint/parse/hash helpers are exported as
`MintOpaqueSecret`, `ParseOpaqueSecret`, and `HashOpaqueSecret` for callers
building bespoke token storage on top of the same shape.
### User credentials vs. service tokens
`authkit` exposes two distinct subject types, and middleware composes around
them differently.
**User credentials** — sessions and JWTs — prove **identity**. They are
produced by `IssueSession` / `IssueJWT` and authenticate via
`AuthenticateSession` / `AuthenticateJWT`, which return a `*Principal`
carrying `UserID`, `Method`, and the user's roles + permissions resolved
through RBAC. Authorization on these requests is **role/permission-based**
via `RequireRole` / `RequirePermission`. User credentials carry no abilities;
"what this user may do" is answered by the user's RBAC, not by anything
embedded on the credential itself.
**Service tokens** — `IssueServiceKey` — prove **"this caller may do X"**.
They are owner-agnostic: `OwnerKind` labels the namespace ("application",
"tenant", whatever) and `OwnerID` identifies the entity within it. The
database column has **no foreign key** on purpose — `authkit` makes no
assumption about what the owner is, and cascade-on-delete is the consumer's
responsibility. `AuthenticateServiceKey` returns a `*ServiceKey` directly
(no `*Principal`, no role/permission resolution). Authorization on these
requests is **ability-based** via `RequireAbility`; the abilities slice is
free-form and not linked to `authkit_roles` / `authkit_permissions`.
```go
plaintext, key, err := auth.IssueServiceKey(ctx,
"application", appID, "events-ingest",
[]string{"events:write"}, nil)
k, err := auth.AuthenticateServiceKey(ctx, plaintext)
// k.OwnerKind == "application"; k.OwnerID == appID; k.HasAbility("events:write")
err = auth.RevokeServiceKey(ctx, plaintext)
```
When a consumer-owned entity (an application, a tenant) is deleted, the
consumer must revoke the associated service tokens itself — typically by
iterating `ListServiceKeys(ctx, ownerKind, ownerID)`.
### JWT revocation
Access tokens carry `sv` (`session_version`) in their claims. When you call
`RevokeAllUserSessions` or `ChangePassword`, the user's `session_version`
column increments and every outstanding access token fails the next
`AuthenticateJWT`. This is the only way to invalidate a JWT before its
`exp`.
### Refresh token rotation
Each `RefreshJWT` consumes the presented refresh token and issues a new one
on the same chain (the `chain_id` column on `authkit_tokens`). If a
*consumed* refresh token is ever presented again — a strong replay signal —
the entire chain is deleted via `TokenStore.DeleteByChain` and the call
returns `ErrTokenReused`.
### Sliding session TTL
Each authenticated request via `AuthenticateSession` slides `expires_at` to
`now + Config.SessionIdleTTL`, capped at `created_at + Config.SessionAbsoluteTTL`.
Long-lived idle sessions still hit the absolute boundary.
### Schema and migrations
`sqlstore.Migrate` applies every embedded `.sql` file under
`sqlstore/dialect/postgres/migrations/` whose version (filename without
`.sql`) is not in `authkit_schema_migrations`. The dialect's
`AcquireMigrationLock` (Postgres uses `pg_advisory_lock`) serialises
concurrent migrators. Each migration owns its own transaction so future
migrations can use statements like `CREATE INDEX CONCURRENTLY`.
Every default table is prefixed `authkit_` so the schema can live alongside
your application's own tables in a shared database.
### Driver and dialect architecture
The `sqlstore` package speaks `database/sql` only. Driver-specific behaviour
lives behind a small `Dialect` interface:
```go
type Dialect interface {
Name() string
BuildQueries(s Schema) Queries
Bootstrap(ctx context.Context, db *sql.DB) error
AcquireMigrationLock(ctx context.Context, conn *sql.Conn) (release func(), err error)
Migrations() fs.FS
IsUniqueViolation(err error) bool
Placeholder(n int) string
PlaceholderList(start, count int) string
// After an admin-side update that should be visible:
u, err = authkit.RefreshUserInCtx(r.Context())
_ = u; _ = id
}
```
v1 ships `dialect/postgres`. A future MySQL or SQLite dialect adds a new
implementation; no changes to store code.
The cache lives only for the request lifetime — nothing persists across
requests. For service-token routes, use `authkit.ServiceKeyFromCtx`.
## Schema verification and drift
On `New`, `authkit` introspects `information_schema.columns` and verifies
the live database matches the expected layout (table presence, column
names, `data_type`, `is_nullable`). Extra columns are tolerated; missing
tables/columns and type drift fail with `ErrSchemaDrift`.
When a table cannot be found under the configured name, the verifier
falls back to the default `authkit_*` name. This handles migrations from
custom names back to defaults without manual intervention.
## Configuration reference
| Field | Default | Notes |
|---|---|---|
| `Schema` | `DefaultSchema()` | Override individual `Tables` fields; missing fields fall back to defaults |
| `SkipAutoMigrate` | `false` | Disables migration run inside `New` |
| `SkipSchemaVerify` | `false` | Disables schema check inside `New` |
| `SessionIdleTTL` | 24h | Sliding window applied on each authenticated request |
| `SessionAbsoluteTTL` | 30d | Cap from `created_at`; sliding never exceeds this |
| `SessionCookieName` | `authkit_session` | |
| `SessionCookieSecure` | `*true` | Pass `BoolPtr(false)` for local HTTP dev |
| `SessionCookieHTTPOnly` | `*true` | Pass `BoolPtr(false)` if JS must read it (rarely correct) |
| `SessionCookieSameSite` | `Lax` | |
| `SessionCookieSecure` / `HTTPOnly` | `false` / `false` | Set both to `true` in production |
| `JWTSecret` | — (required) | HS256 key |
| `JWTIssuer` / `JWTAudience` | empty | When set, parser enforces them |
| `AccessTokenTTL` | 15m | |
| `RefreshTokenTTL` | 30d | |
| `AccessTokenTTL` / `RefreshTokenTTL` | 15m / 30d | |
| `EmailVerifyTTL` / `PasswordResetTTL` / `MagicLinkTTL` | 48h / 1h / 15m | |
| `Clock` | `time.Now().UTC` | Controls every observable timestamp; override for deterministic tests |
| `EmailOTPTTL` / `EmailOTPDigits` / `EmailOTPMaxAttempts` | 10m / 6 / 5 | |
| `RevealUnknownEmail` | `false` | Default anti-enumeration: silent success on unknown email |
| `Clock` | `time.Now().UTC` | Override for deterministic tests |
| `Random` | `crypto/rand.Reader` | Override for deterministic tests |
| `LoginHook` | nil | `func(ctx, email, success) error`; integration point for rate limiting / audit |
## Implementing your own store
Every store is a small interface with explicit semantics — see `stores.go`.
The most subtle contract is `TokenStore.ConsumeToken`: it MUST mark the token
consumed and return it in a single statement (`UPDATE ... RETURNING` on
Postgres / SQLite 3.35+) so two concurrent callers cannot both succeed.
| `LoginHook` | nil | `func(ctx, email, success) error`; integration point for rate limiting / audit. Panics in the hook are recovered. |
## Testing
```
go test ./... # unit tests, no DB
AUTHKIT_TEST_DATABASE_URL=postgres://... go test ./sqlstore... # integration tests
```sh
go test ./... # unit tests, no DB required
AUTHKIT_TEST_DATABASE_URL=postgres://... go test ./... -run Integration
```
Unit tests cover token mint/parse, Argon2id encode/verify (including
`needsRehash` on parameter change), JWT issue/parse (incl. expired,
`sv`-mismatch, refresh rotation, reuse detection), session lifecycle, email
verification, password reset cascading session invalidation, magic-link
self-verification, API keys with abilities, and RBAC role-permission
resolution. Integration tests run the full `sqlstore` contract against a
real Postgres when `AUTHKIT_TEST_DATABASE_URL` is set.
The unit suite covers slug validation (incl. fuzz), opaque-secret
roundtrip, email normalization, HTTP extractors, predicate combinators,
and OTP code generation. Integration tests cover every database-bound
flow: registration, login, sessions, JWT refresh + reuse, magic link,
email OTP (incl. attempt cap), password reset, service tokens, RBAC,
direct user permissions, schema verification (drift cases + fallback),
migration idempotency, lazy user-context cache, and middleware behavior.
## License