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:
parent
7f1db871bc
commit
d3c5367492
80 changed files with 5605 additions and 4565 deletions
487
README.md
487
README.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue