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:
parent
4942e4dbdc
commit
7f1db871bc
24 changed files with 773 additions and 496 deletions
106
README.md
106
README.md
|
|
@ -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)
|
||||
```
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue