Add owner-agnostic service tokens

Introduces ServiceKey, a parallel primitive to APIKey for server-to-server
auth where the owner is not an authkit user (e.g. an application or tenant
row the consumer manages). owner_id has no FK and no RBAC linkage; cascade
on owner-delete is the consumer's responsibility. AuthenticateServiceKey
returns *ServiceKey directly rather than *Principal since service tokens
have no user.

Also exports MintOpaqueSecret / HashOpaqueSecret / ParseOpaqueSecret so
both API-key and service-key code share one mint/parse implementation
instead of duplicating it. Deps.ServiceKeys is required (panics in New
if nil) — existing call sites must add ServiceKeys: stores.ServiceKeys.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
juancwu 2026-04-26 19:54:26 +00:00
commit 4942e4dbdc
16 changed files with 664 additions and 24 deletions

View file

@ -38,6 +38,7 @@ PostgreSQL 12+ is sufficient — the schema avoids `gen_random_uuid()` and
**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
@ -117,6 +118,7 @@ auth := authkit.New(authkit.Deps{
Sessions: stores.Sessions,
Tokens: stores.Tokens,
APIKeys: stores.APIKeys,
ServiceKeys: stores.ServiceKeys,
Roles: stores.Roles,
Permissions: stores.Permissions,
Hasher: hasher.NewArgon2id(hasher.DefaultArgon2idParams(), nil),
@ -130,8 +132,9 @@ 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 the seven `Deps` fields are
required; `New` panics on a misconfiguration.
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.
### 3. Use the service
@ -148,10 +151,18 @@ 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
// 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)
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)
// Email verification + password reset + magic link
tok, err := auth.RequestEmailVerification(ctx, u.ID)
_, err = auth.ConfirmEmail(ctx, tok)
@ -233,8 +244,8 @@ each table. Adding column overrides later is purely additive.
### Secret token format
Sessions, refresh tokens, API keys, email-verify tokens, password-reset tokens,
and magic-link tokens all share one format:
Sessions, refresh tokens, API keys, service tokens, email-verify tokens,
password-reset tokens, and magic-link tokens all share one format:
```
plaintext = "<prefix>_" + base64url(32 random bytes, no padding)
@ -243,7 +254,44 @@ 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).
`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.
### Service tokens vs. API keys
Both produce opaque `<prefix>_<base64>` secrets, but they target different
use cases.
`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.
`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`.
```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
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