Cut user-owned API keys; redesign subject model

Removes the APIKey primitive entirely (Auth.IssueAPIKey/AuthenticateAPIKey/
RevokeAPIKey, APIKeyStore, Deps.APIKeys, Stores.APIKeys, Tables.APIKeys,
ErrAPIKeyInvalid, AuthMethodAPIKey, Principal.{APIKeyID, Abilities, HasAbility},
prefixAPIKey, RequireAPIKey, and the 6 SQL templates). Migration
0003_drop_api_keys.sql hard-drops authkit_api_keys.

The new subject model: *Principal carries identity only (sessions, JWTs);
*ServiceKey is the only abilities-bearing credential and gains a
HasAbility(name) method. RequireAbility now reads *ServiceKey from context
(user principals 403 by design). RequireRole/RequirePermission stay
Principal-only. New RequireServiceKey + ServiceKeyFrom + MustServiceKey,
and a heterogeneous RequireAnyOrServiceKey for routes that accept either.
RequireAny is now Principal-only (default [Session, JWT]).

Adds 7 middleware tests (auth, revoked, ability accept/reject across
subjects, role rejects service key, RequireAnyOrServiceKey both paths) and
1 (*ServiceKey).HasAbility unit test. Existing API-key tests deleted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
juancwu 2026-04-26 20:29:17 +00:00
commit 7f1db871bc
24 changed files with 773 additions and 496 deletions

106
README.md
View file

@ -36,11 +36,12 @@ PostgreSQL 12+ is sufficient — the schema avoids `gen_random_uuid()` and
- 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
- Owner-agnostic service tokens for server-to-server auth (no FK on owner)
- A unified `Principal` type so middleware works the same regardless of which
authentication method ran
- 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
**Storage**
- Interfaces for every store so callers can plug in their own backends
@ -53,15 +54,19 @@ PostgreSQL 12+ is sufficient — the schema avoids `gen_random_uuid()` and
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
- 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
**Errors**
- Sentinel errors (`ErrEmailTaken`, `ErrInvalidCredentials`, `ErrTokenInvalid`,
`ErrTokenReused`, `ErrSessionInvalid`, `ErrAPIKeyInvalid`,
`ErrTokenReused`, `ErrSessionInvalid`, `ErrServiceKeyInvalid`,
`ErrPermissionDenied`, ...) compatible with `errors.Is`
- All internal errors wrap with [`errx`](https://git.juancwu.dev/juancwu/errx)
for op tags
@ -117,7 +122,6 @@ auth := authkit.New(authkit.Deps{
Users: stores.Users,
Sessions: stores.Sessions,
Tokens: stores.Tokens,
APIKeys: stores.APIKeys,
ServiceKeys: stores.ServiceKeys,
Roles: stores.Roles,
Permissions: stores.Permissions,
@ -132,9 +136,8 @@ auth := authkit.New(authkit.Deps{
`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 eight `Deps` fields
(including the new `ServiceKeys` field added in v0.2.0) are required; `New`
panics on a misconfiguration.
password-reset, 15m magic-link). `JWTSecret` and all seven `Deps` fields are
required; `New` panics on a misconfiguration.
### 3. Use the service
@ -151,11 +154,8 @@ http.SetCookie(w, auth.SessionCookie(plaintext, sess.ExpiresAt))
access, refresh, err := auth.IssueJWT(ctx, u.ID)
access, refresh, err = auth.RefreshJWT(ctx, refresh) // old refresh is consumed
// API key with abilities (user-owned)
plaintext, key, err := auth.IssueAPIKey(ctx, u.ID, "ci",
[]string{"billing:read", "users:list"}, nil)
// Service token (owner-agnostic; ownerKind labels the namespace)
// 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)
@ -174,9 +174,9 @@ 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.
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.
### 4. Wire middleware
@ -209,9 +209,21 @@ me.Get("", func(w http.ResponseWriter, r *http.Request) {
// 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"))
// 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
}
})
```
`Options.Extractor` defaults to `BearerExtractor`; pass `CookieExtractor` (or
@ -228,7 +240,7 @@ match `^[a-zA-Z_][a-zA-Z0-9_]*$`; anything else is rejected at `New()` and
```go
schema := sqlstore.DefaultSchema()
schema.Tables.Users = "accounts"
schema.Tables.APIKeys = "api_credentials"
schema.Tables.ServiceKeys = "service_credentials"
stores, _ := sqlstore.New(db, pgdialect.New(), schema)
```
@ -244,7 +256,7 @@ each table. Adding column overrides later is purely additive.
### Secret token format
Sessions, refresh tokens, API keys, service tokens, email-verify tokens,
Sessions, refresh tokens, service tokens, email-verify tokens,
password-reset tokens, and magic-link tokens all share one format:
```
@ -258,25 +270,29 @@ SHA-256 is the database lookup key. Random bytes come from `crypto/rand` (or
`MintOpaqueSecret`, `ParseOpaqueSecret`, and `HashOpaqueSecret` for callers
building bespoke token storage on top of the same shape.
### Service tokens vs. API keys
### User credentials vs. service tokens
Both produce opaque `<prefix>_<base64>` secrets, but they target different
use cases.
`authkit` exposes two distinct subject types, and middleware composes around
them differently.
`IssueAPIKey` is for **user-owned credentials**. The owner is a row in
`authkit_users`; deleting that user cascades to the key (`ON DELETE CASCADE`),
and `AuthenticateAPIKey` returns a `*Principal` carrying the user's roles
and permissions resolved through RBAC.
**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.
`IssueServiceKey` is for **server-to-server credentials** whose owner is
something authkit knows nothing about — an `application` row, a `tenant`,
whatever. `OwnerKind` labels the namespace and `OwnerID` identifies the
entity within it; the database column has **no foreign key** on purpose, so
cascade-on-delete is the consumer's responsibility. `AuthenticateServiceKey`
returns the `*ServiceKey` directly (no `*Principal`, no role/permission
resolution): service tokens have no user, so the `Principal` abstraction
does not fit. Abilities (`[]string`) on a service token are free-form —
authkit does not link them to `authkit_roles` or `authkit_permissions`.
**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,
@ -284,7 +300,7 @@ plaintext, key, err := auth.IssueServiceKey(ctx,
[]string{"events:write"}, nil)
k, err := auth.AuthenticateServiceKey(ctx, plaintext)
// k.OwnerKind == "application"; k.OwnerID == appID
// k.OwnerKind == "application"; k.OwnerID == appID; k.HasAbility("events:write")
err = auth.RevokeServiceKey(ctx, plaintext)
```