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:
parent
9aae7b1c12
commit
4942e4dbdc
16 changed files with 664 additions and 24 deletions
60
README.md
60
README.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue