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

285 lines
10 KiB
Markdown

# authkit
A pragmatic authentication and authorization toolkit for Go web services
on PostgreSQL 16+.
`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
```sh
go get git.juancwu.dev/juancwu/authkit
```
`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 16 or newer is required.
## What's included
**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
- HS256 JWT access tokens with rotating refresh tokens and reuse
detection
- Email verification, password reset, magic-link login, email OTP
**Authorization**
- 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**
- 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 compatible with `errors.Is`
- All internal errors wrap with [`errx`](https://git.juancwu.dev/juancwu/errx)
## Quick start
### 1. Open a database
```go
import (
"database/sql"
_ "github.com/jackc/pgx/v5/stdlib"
)
db, err := sql.Open("pgx", os.Getenv("DATABASE_URL"))
if err != nil { /* ... */ }
defer db.Close()
```
### 2. Construct Auth
```go
import (
"context"
"git.juancwu.dev/juancwu/authkit"
"git.juancwu.dev/juancwu/authkit/hasher"
)
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",
})
if err != nil { log.Fatal(err) }
```
`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. 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
// 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.
plaintext, sess, _ := auth.IssueSession(ctx, u.ID, r.UserAgent(), clientIP)
http.SetCookie(w, auth.SessionCookie(plaintext, sess.ExpiresAt))
// JWT + rotating refresh.
access, refresh, _ := auth.IssueJWT(ctx, u.ID)
access, refresh, _ = auth.RefreshJWT(ctx, refresh) // old refresh is consumed
// 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")
// 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 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.
### 5. Wire middleware
```go
import (
"git.juancwu.dev/juancwu/authkit"
"git.juancwu.dev/juancwu/authkit/middleware"
)
// Default RequireLogin reads the session cookie and falls through to a
// Bearer JWT.
loginMW := middleware.RequireLogin(middleware.LoginOptions{Auth: auth})
// 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")),
),
})
// 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)
},
})
// Service tokens with an ability gate.
apiMW := middleware.RequireServiceKey(middleware.ServiceKeyOptions{
Auth: auth,
Authz: authkit.AllServiceKey(authkit.HasAbility("events:write")),
})
```
`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.
### 6. Read the user in handlers
Middleware attaches the `user_id` to the request context. Handlers fetch
the full user lazily:
```go
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 */ }
// After an admin-side update that should be visible:
u, err = authkit.RefreshUserInCtx(r.Context())
_ = u; _ = id
}
```
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` | |
| `JWTSecret` | — (required) | HS256 key |
| `AccessTokenTTL` / `RefreshTokenTTL` | 15m / 30d | |
| `RefreshChainAbsoluteTTL` | 30d | Hard cap from chain start. Refresh fails past this even if the per-token TTL hasn't elapsed; user must re-authenticate. Mirrors `SessionAbsoluteTTL`. |
| `EmailVerifyTTL` / `PasswordResetTTL` / `MagicLinkTTL` | 48h / 1h / 15m | |
| `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. Panics in the hook are recovered. |
## Testing
```sh
go test ./... # unit tests, no DB required
AUTHKIT_TEST_DATABASE_URL=postgres://... go test ./... -run Integration
```
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
MIT. See `LICENSE`.