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>
285 lines
10 KiB
Markdown
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`.
|