authkit initial

This commit is contained in:
juancwu 2026-04-26 01:36:53 +00:00
commit 134393fbca
43 changed files with 5188 additions and 1 deletions

344
README.md
View file

@ -1,3 +1,345 @@
# authkit
Just a concoction of auth stuff in one place.
A pragmatic authentication and authorization toolkit for Go web services.
`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.
## Install
```
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.
```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.
## What's included
**Authentication flows**
- Email + password registration and login (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
**Authorization**
- Roles and permissions with many-to-many wiring
- API keys with custom abilities for per-endpoint scoping
- A unified `Principal` type so middleware works the same regardless of which
authentication method ran
**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**
- `middleware.RequireSession`, `RequireJWT`, `RequireAPIKey`, `RequireAny`
- `middleware.RequireRole`, `RequireAnyRole`, `RequirePermission`,
`RequireAbility`
- `middleware.PrincipalFrom(ctx)` to read the authenticated principal in
handlers
**Errors**
- Sentinel errors (`ErrEmailTaken`, `ErrInvalidCredentials`, `ErrTokenInvalid`,
`ErrTokenReused`, `ErrSessionInvalid`, `ErrAPIKeyInvalid`,
`ErrPermissionDenied`, ...) 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
```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"
)
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
```go
import (
"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,
APIKeys: stores.APIKeys,
Roles: stores.Roles,
Permissions: stores.Permissions,
Hasher: hasher.NewArgon2id(hasher.DefaultArgon2idParams(), nil),
}, authkit.Config{
JWTSecret: []byte(os.Getenv("JWT_SECRET")),
JWTIssuer: "myapp",
SessionCookieSecure: true,
SessionCookieHTTPOnly: true,
})
```
`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 the seven `Deps` fields are
required; `New` panics on a misconfiguration.
### 3. Use the service
```go
// Registration + password login
u, err := auth.Register(ctx, "alice@example.com", "hunter2hunter2")
u, err = auth.LoginPassword(ctx, "alice@example.com", "hunter2hunter2")
// Opaque session (cookie-friendly)
plaintext, sess, err := 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
// API key with abilities
plaintext, key, err := auth.IssueAPIKey(ctx, u.ID, "ci",
[]string{"billing:read", "users:list"}, nil)
// 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)
```
The plaintext returned by `IssueSession`, `IssueJWT`, `IssueAPIKey`, 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.
### 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.
```go
import (
authkitmw "git.juancwu.dev/juancwu/authkit/middleware"
"git.juancwu.dev/juancwu/lightmux"
)
mux := lightmux.New()
cookieAuth := authkitmw.RequireSession(authkitmw.Options{
Auth: auth,
Extractor: authkit.ChainExtractors(
authkit.CookieExtractor("authkit_session"),
authkit.BearerExtractor(),
),
})
me := mux.Group("/me", cookieAuth)
me.Get("", func(w http.ResponseWriter, r *http.Request) {
p := authkitmw.MustPrincipal(r)
json.NewEncoder(w).Encode(p)
})
// RBAC: stack authz on top of any auth method
admin := mux.Group("/admin", cookieAuth, authkitmw.RequireRole("admin"))
// API-key-only route with a per-endpoint ability check
api := mux.Group("/api/v1", authkitmw.RequireAPIKey(authkitmw.Options{Auth: auth}))
api.Get("/billing", billingHandler, authkitmw.RequireAbility("billing:read"))
```
`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.
## Custom table names
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.
```go
schema := sqlstore.DefaultSchema()
schema.Tables.Users = "accounts"
schema.Tables.APIKeys = "api_credentials"
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, API keys, 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).
### 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
}
```
v1 ships `dialect/postgres`. A future MySQL or SQLite dialect adds a new
implementation; no changes to store code.
## Configuration reference
| Field | Default | Notes |
|---|---|---|
| `SessionIdleTTL` | 24h | Sliding window applied on each authenticated request |
| `SessionAbsoluteTTL` | 30d | Cap from `created_at`; sliding never exceeds this |
| `SessionCookieName` | `authkit_session` | |
| `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 | |
| `EmailVerifyTTL` / `PasswordResetTTL` / `MagicLinkTTL` | 48h / 1h / 15m | |
| `Clock` | `time.Now().UTC` | Controls every observable timestamp; 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.
## Testing
```
go test ./... # unit tests, no DB
AUTHKIT_TEST_DATABASE_URL=postgres://... go test ./sqlstore... # integration tests
```
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.
## License
MIT. See `LICENSE`.