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>
10 KiB
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
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.
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 viaSetPassword - 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 —
UserPermissionsreturns 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 byLoginAuthzRequireGuest— block authenticated requests (with a configurableOnAuthenticatedcallback for redirects)RequireServiceKey— accept a service token, optionally constrain byServiceKeyAuthz
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
Quick start
1. Open a database
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
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:
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
// 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
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:
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
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.