From 7f1db871bc0f9bc30d5eb3017dbef82e0e647906 Mon Sep 17 00:00:00 2001 From: juancwu Date: Sun, 26 Apr 2026 20:29:17 +0000 Subject: [PATCH] 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) --- README.md | 106 ++-- authkit.go | 5 +- doc.go | 11 +- errors.go | 1 - memstore_test.go | 70 --- middleware/authz.go | 25 +- middleware/context.go | 37 +- middleware/middleware.go | 99 +++- middleware/middleware_test.go | 513 ++++++++++++++++++ models.go | 31 +- principal.go | 16 +- service_apikey.go | 92 ---- service_service_key_test.go | 17 + service_test.go | 31 -- sqlstore/apikeys.go | 124 ----- sqlstore/dialect.go | 8 - .../migrations/0003_drop_api_keys.sql | 13 + sqlstore/dialect/postgres/postgres.go | 21 - sqlstore/schema.go | 3 - sqlstore/sqlstore.go | 2 - sqlstore/sqlstore_test.go | 30 +- stores.go | 9 - tokens.go | 1 - tokens_test.go | 4 +- 24 files changed, 773 insertions(+), 496 deletions(-) create mode 100644 middleware/middleware_test.go delete mode 100644 service_apikey.go delete mode 100644 sqlstore/apikeys.go create mode 100644 sqlstore/dialect/postgres/migrations/0003_drop_api_keys.sql diff --git a/README.md b/README.md index 79df4e2..926335b 100644 --- a/README.md +++ b/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 `_` 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) ``` diff --git a/authkit.go b/authkit.go index ce68d55..f1c69a2 100644 --- a/authkit.go +++ b/authkit.go @@ -17,7 +17,6 @@ type Deps struct { Users UserStore Sessions SessionStore Tokens TokenStore - APIKeys APIKeyStore ServiceKeys ServiceKeyStore Roles RoleStore Permissions PermissionStore @@ -70,8 +69,8 @@ type Auth struct { // returning an error — these are programmer errors, not runtime ones. func New(deps Deps, cfg Config) *Auth { if deps.Users == nil || deps.Sessions == nil || deps.Tokens == nil || - deps.APIKeys == nil || deps.ServiceKeys == nil || deps.Roles == nil || - deps.Permissions == nil || deps.Hasher == nil { + deps.ServiceKeys == nil || deps.Roles == nil || deps.Permissions == nil || + deps.Hasher == nil { panic(errx.New("authkit.New", "all Deps fields are required")) } if len(cfg.JWTSecret) == 0 { diff --git a/doc.go b/doc.go index 3c49a23..bbefef9 100644 --- a/doc.go +++ b/doc.go @@ -1,10 +1,11 @@ // Package authkit is an authentication and authorization toolkit for Go web // services. It defines storage interfaces (UserStore, SessionStore, TokenStore, -// APIKeyStore, RoleStore, PermissionStore) and a high-level Auth service that -// composes them to support registration, password login, opaque server-side -// sessions, JWT access plus rotating refresh tokens, email verification, -// password resets, magic-link passwordless login, role-based access control, -// and API keys with custom abilities. +// ServiceKeyStore, RoleStore, PermissionStore) and a high-level Auth service +// that composes them to support registration, password login, opaque +// server-side sessions, JWT access plus rotating refresh tokens, email +// verification, password resets, magic-link passwordless login, role-based +// access control, and owner-agnostic service tokens with custom abilities for +// server-to-server auth. // // Default Postgres implementations of every store live in the pgstore // subpackage. Argon2id password hashing lives in hasher. Framework-neutral diff --git a/errors.go b/errors.go index e1cba45..753f348 100644 --- a/errors.go +++ b/errors.go @@ -10,7 +10,6 @@ var ( ErrTokenInvalid = errors.New("authkit: invalid or expired token") ErrTokenReused = errors.New("authkit: token reuse detected") ErrSessionInvalid = errors.New("authkit: invalid or expired session") - ErrAPIKeyInvalid = errors.New("authkit: invalid or expired api key") ErrServiceKeyInvalid = errors.New("authkit: invalid or expired service key") ErrPermissionDenied = errors.New("authkit: permission denied") ErrRoleNotFound = errors.New("authkit: role not found") diff --git a/memstore_test.go b/memstore_test.go index e8304f4..0d0e408 100644 --- a/memstore_test.go +++ b/memstore_test.go @@ -262,75 +262,6 @@ func (s *memTokenStore) DeleteExpired(_ context.Context, now time.Time) (int64, return n, nil } -type memAPIKeyStore struct { - mu sync.Mutex - m map[string]*APIKey -} - -func newMemAPIKeyStore() *memAPIKeyStore { return &memAPIKeyStore{m: map[string]*APIKey{}} } -func (s *memAPIKeyStore) CreateAPIKey(_ context.Context, k *APIKey) error { - s.mu.Lock() - defer s.mu.Unlock() - cp := *k - cp.Abilities = append([]string(nil), k.Abilities...) - s.m[string(k.IDHash)] = &cp - return nil -} -func (s *memAPIKeyStore) GetAPIKey(_ context.Context, h []byte) (*APIKey, error) { - s.mu.Lock() - defer s.mu.Unlock() - k, ok := s.m[string(h)] - if !ok { - return nil, ErrAPIKeyInvalid - } - cp := *k - cp.Abilities = append([]string(nil), k.Abilities...) - return &cp, nil -} -func (s *memAPIKeyStore) ListAPIKeysByOwner(_ context.Context, owner uuid.UUID) ([]*APIKey, error) { - s.mu.Lock() - defer s.mu.Unlock() - var out []*APIKey - for _, k := range s.m { - if k.OwnerID == owner { - cp := *k - out = append(out, &cp) - } - } - return out, nil -} -func (s *memAPIKeyStore) TouchAPIKey(_ context.Context, h []byte, at time.Time) error { - s.mu.Lock() - defer s.mu.Unlock() - if k, ok := s.m[string(h)]; ok { - k.LastUsedAt = &at - } - return nil -} -func (s *memAPIKeyStore) RevokeAPIKey(_ context.Context, h []byte, at time.Time) error { - s.mu.Lock() - defer s.mu.Unlock() - k, ok := s.m[string(h)] - if !ok { - return ErrAPIKeyInvalid - } - if k.RevokedAt != nil { - return ErrAPIKeyInvalid - } - k.RevokedAt = &at - return nil -} -func (s *memAPIKeyStore) RevokeAPIKeysByOwner(_ context.Context, owner uuid.UUID, at time.Time) error { - s.mu.Lock() - defer s.mu.Unlock() - for _, k := range s.m { - if k.OwnerID == owner && k.RevokedAt == nil { - k.RevokedAt = &at - } - } - return nil -} - type memServiceKeyStore struct { mu sync.Mutex m map[string]*ServiceKey @@ -663,7 +594,6 @@ func newTestAuth(t interface{ Helper() }) *Auth { Users: newMemUserStore(), Sessions: newMemSessionStore(), Tokens: newMemTokenStore(), - APIKeys: newMemAPIKeyStore(), ServiceKeys: newMemServiceKeyStore(), Roles: roles, Permissions: newMemPermStore(roles), diff --git a/middleware/authz.go b/middleware/authz.go index 79d7297..38a69b0 100644 --- a/middleware/authz.go +++ b/middleware/authz.go @@ -54,13 +54,26 @@ func RequirePermission(name string, onForbidden ...func(http.ResponseWriter, *ht }) } -// RequireAbility permits requests whose Principal carries the named ability. -// Abilities are populated only for API-key authentication; this middleware -// will reject session/JWT-authenticated requests by design. +// RequireAbility permits requests whose ServiceKey carries the named ability. +// Abilities live only on service tokens — this middleware reads +// *authkit.ServiceKey from the request context (placed by RequireServiceKey +// or RequireAnyOrServiceKey) and 403s any request authenticated as a user +// (session or JWT), which by definition has no abilities. func RequireAbility(name string, onForbidden ...func(http.ResponseWriter, *http.Request, error)) func(http.Handler) http.Handler { - return authzGuard(firstOrNil(onForbidden), func(p *authkit.Principal) bool { - return p.HasAbility(name) - }) + onForb := firstOrNil(onForbidden) + if onForb == nil { + onForb = defaultJSONError(http.StatusForbidden) + } + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + k, ok := ServiceKeyFrom(r.Context()) + if !ok || !k.HasAbility(name) { + onForb(w, r, authkit.ErrPermissionDenied) + return + } + next.ServeHTTP(w, r) + }) + } } func firstOrNil(s []func(http.ResponseWriter, *http.Request, error)) func(http.ResponseWriter, *http.Request, error) { diff --git a/middleware/context.go b/middleware/context.go index 7de6da0..d1dd960 100644 --- a/middleware/context.go +++ b/middleware/context.go @@ -11,25 +11,27 @@ import ( "git.juancwu.dev/juancwu/authkit" ) -// principalKey is an unexported context key. Using a distinct empty struct -// type guarantees no collision with caller-defined keys. +// principalKey and serviceKeyKey are unexported context keys. Using distinct +// empty struct types guarantees no collision with caller-defined keys. type principalKey struct{} +type serviceKeyKey struct{} // withPrincipal stashes p on the request context for downstream handlers. func withPrincipal(ctx context.Context, p *authkit.Principal) context.Context { return context.WithValue(ctx, principalKey{}, p) } -// PrincipalFrom retrieves the authenticated Principal placed by RequireSession, -// RequireJWT, or RequireAPIKey. The boolean is false if no auth middleware -// ran for this request. +// PrincipalFrom retrieves the authenticated Principal placed by RequireSession +// or RequireJWT. The boolean is false if no user-bound auth middleware ran for +// this request (e.g. the request was authenticated via service key instead). func PrincipalFrom(ctx context.Context) (*authkit.Principal, bool) { p, ok := ctx.Value(principalKey{}).(*authkit.Principal) return p, ok } // MustPrincipal panics if no Principal is on the context. Use only on -// handlers known to be behind a Require* middleware. +// handlers known to be behind a Require* middleware that authenticates a +// user (RequireSession or RequireJWT). func MustPrincipal(r *http.Request) *authkit.Principal { p, ok := PrincipalFrom(r.Context()) if !ok { @@ -37,3 +39,26 @@ func MustPrincipal(r *http.Request) *authkit.Principal { } return p } + +// withServiceKey stashes k on the request context for downstream handlers. +func withServiceKey(ctx context.Context, k *authkit.ServiceKey) context.Context { + return context.WithValue(ctx, serviceKeyKey{}, k) +} + +// ServiceKeyFrom retrieves the authenticated ServiceKey placed by +// RequireServiceKey. The boolean is false if no service-key middleware ran +// for this request. +func ServiceKeyFrom(ctx context.Context) (*authkit.ServiceKey, bool) { + k, ok := ctx.Value(serviceKeyKey{}).(*authkit.ServiceKey) + return k, ok +} + +// MustServiceKey panics if no ServiceKey is on the context. Use only on +// handlers known to be behind RequireServiceKey. +func MustServiceKey(r *http.Request) *authkit.ServiceKey { + k, ok := ServiceKeyFrom(r.Context()) + if !ok { + panic("authkit/middleware: no service key on request context") + } + return k +} diff --git a/middleware/middleware.go b/middleware/middleware.go index 1fbc4dd..79ec7bc 100644 --- a/middleware/middleware.go +++ b/middleware/middleware.go @@ -65,21 +65,27 @@ func RequireJWT(opts Options) func(http.Handler) http.Handler { }) } -// RequireAPIKey authenticates the request via an opaque API secret. -func RequireAPIKey(opts Options) func(http.Handler) http.Handler { - return requireWith(opts, func(r *http.Request, raw string) (*authkit.Principal, error) { - return opts.Auth.AuthenticateAPIKey(r.Context(), raw) +// RequireServiceKey authenticates the request via an opaque service token +// secret. On success the resolved *authkit.ServiceKey is placed on the +// request context; downstream handlers retrieve it via ServiceKeyFrom. Note +// that this middleware does NOT place a *Principal on the context — service +// tokens have no user — so user-bound authz middleware (RequireRole, +// RequirePermission) will reject service-key requests with 403. +func RequireServiceKey(opts Options) func(http.Handler) http.Handler { + return requireWithServiceKey(opts, func(r *http.Request, raw string) (*authkit.ServiceKey, error) { + return opts.Auth.AuthenticateServiceKey(r.Context(), raw) }) } -// RequireAny tries each method in order until one succeeds. Useful for routes -// that accept either a session cookie or an API key. +// RequireAny tries each user-bound method in order until one succeeds. The +// default set is [Session, JWT]; service tokens are NOT included because +// they yield a different subject type. For routes that accept either a user +// credential or a service token, use RequireAnyOrServiceKey. func RequireAny(opts Options, methods ...authkit.AuthMethod) func(http.Handler) http.Handler { if len(methods) == 0 { methods = []authkit.AuthMethod{ authkit.AuthMethodSession, authkit.AuthMethodJWT, - authkit.AuthMethodAPIKey, } } return func(next http.Handler) http.Handler { @@ -99,8 +105,6 @@ func RequireAny(opts Options, methods ...authkit.AuthMethod) func(http.Handler) p, lastErr = opts.Auth.AuthenticateSession(r.Context(), raw) case authkit.AuthMethodJWT: p, lastErr = opts.Auth.AuthenticateJWT(r.Context(), raw) - case authkit.AuthMethodAPIKey: - p, lastErr = opts.Auth.AuthenticateAPIKey(r.Context(), raw) } if lastErr == nil && p != nil { next.ServeHTTP(w, r.WithContext(withPrincipal(r.Context(), p))) @@ -112,8 +116,56 @@ func RequireAny(opts Options, methods ...authkit.AuthMethod) func(http.Handler) } } -// requireWith is the shared scaffolding for the single-method Require* -// middlewares. +// RequireAnyOrServiceKey tries the user-bound methods first (default +// [Session, JWT]); on failure, falls through to a service-key lookup. The +// downstream handler sees either a *Principal or a *ServiceKey on context — +// retrieve via PrincipalFrom or ServiceKeyFrom and dispatch accordingly. +func RequireAnyOrServiceKey(opts Options, methods ...authkit.AuthMethod) func(http.Handler) http.Handler { + if opts.Auth == nil { + panic("authkit/middleware: Options.Auth is required") + } + if len(methods) == 0 { + methods = []authkit.AuthMethod{ + authkit.AuthMethodSession, + authkit.AuthMethodJWT, + } + } + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw, ok := opts.extractor()(r) + if !ok || raw == "" { + opts.onUnauth()(w, r, authkit.ErrSessionInvalid) + return + } + var lastErr error + for _, m := range methods { + var p *authkit.Principal + switch m { + case authkit.AuthMethodSession: + p, lastErr = opts.Auth.AuthenticateSession(r.Context(), raw) + case authkit.AuthMethodJWT: + p, lastErr = opts.Auth.AuthenticateJWT(r.Context(), raw) + } + if lastErr == nil && p != nil { + next.ServeHTTP(w, r.WithContext(withPrincipal(r.Context(), p))) + return + } + } + k, err := opts.Auth.AuthenticateServiceKey(r.Context(), raw) + if err == nil && k != nil { + next.ServeHTTP(w, r.WithContext(withServiceKey(r.Context(), k))) + return + } + if lastErr == nil { + lastErr = err + } + opts.onUnauth()(w, r, lastErr) + }) + } +} + +// requireWith is the shared scaffolding for the single-method user-bound +// Require* middlewares. func requireWith(opts Options, authn func(r *http.Request, raw string) (*authkit.Principal, error)) func(http.Handler) http.Handler { if opts.Auth == nil { panic("authkit/middleware: Options.Auth is required") @@ -136,3 +188,28 @@ func requireWith(opts Options, authn func(r *http.Request, raw string) (*authkit }) } } + +// requireWithServiceKey is the service-key analogue of requireWith. It places +// a *ServiceKey (not a *Principal) on the request context. +func requireWithServiceKey(opts Options, authn func(r *http.Request, raw string) (*authkit.ServiceKey, error)) func(http.Handler) http.Handler { + if opts.Auth == nil { + panic("authkit/middleware: Options.Auth is required") + } + extractor := opts.extractor() + onUnauth := opts.onUnauth() + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw, ok := extractor(r) + if !ok || raw == "" { + onUnauth(w, r, authkit.ErrServiceKeyInvalid) + return + } + k, err := authn(r, raw) + if err != nil { + onUnauth(w, r, err) + return + } + next.ServeHTTP(w, r.WithContext(withServiceKey(r.Context(), k))) + }) + } +} diff --git a/middleware/middleware_test.go b/middleware/middleware_test.go new file mode 100644 index 0000000..f4941ee --- /dev/null +++ b/middleware/middleware_test.go @@ -0,0 +1,513 @@ +package middleware_test + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "net/netip" + "strings" + "sync" + "testing" + "time" + + "git.juancwu.dev/juancwu/authkit" + "git.juancwu.dev/juancwu/authkit/middleware" + "github.com/google/uuid" +) + +// ─── minimal in-memory stores ────────────────────────────────────────────── +// +// The middleware package can't import the parent's _test stores, so we wire +// up a fresh-but-minimal set here. Only the methods actually exercised by +// the middleware tests below have meaningful bodies; unused store methods +// panic to surface unexpected call paths. + +type memUserStore struct { + mu sync.Mutex + m map[uuid.UUID]*authkit.User +} + +func newMemUserStore() *memUserStore { return &memUserStore{m: map[uuid.UUID]*authkit.User{}} } + +func (s *memUserStore) CreateUser(_ context.Context, u *authkit.User) error { + s.mu.Lock() + defer s.mu.Unlock() + for _, existing := range s.m { + if existing.EmailNormalized == u.EmailNormalized { + return authkit.ErrEmailTaken + } + } + cp := *u + s.m[u.ID] = &cp + return nil +} +func (s *memUserStore) GetUserByID(_ context.Context, id uuid.UUID) (*authkit.User, error) { + s.mu.Lock() + defer s.mu.Unlock() + u, ok := s.m[id] + if !ok { + return nil, authkit.ErrUserNotFound + } + cp := *u + return &cp, nil +} +func (s *memUserStore) GetUserByEmail(_ context.Context, normalized string) (*authkit.User, error) { + s.mu.Lock() + defer s.mu.Unlock() + for _, u := range s.m { + if u.EmailNormalized == normalized { + cp := *u + return &cp, nil + } + } + return nil, authkit.ErrUserNotFound +} +func (s *memUserStore) UpdateUser(_ context.Context, u *authkit.User) error { + s.mu.Lock() + defer s.mu.Unlock() + cp := *u + s.m[u.ID] = &cp + return nil +} +func (s *memUserStore) DeleteUser(_ context.Context, id uuid.UUID) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.m, id) + return nil +} +func (s *memUserStore) SetPassword(_ context.Context, id uuid.UUID, encoded string) error { + s.mu.Lock() + defer s.mu.Unlock() + if u, ok := s.m[id]; ok { + u.PasswordHash = encoded + } + return nil +} +func (s *memUserStore) SetEmailVerified(_ context.Context, id uuid.UUID, at time.Time) error { + s.mu.Lock() + defer s.mu.Unlock() + if u, ok := s.m[id]; ok { + u.EmailVerifiedAt = &at + } + return nil +} +func (s *memUserStore) BumpSessionVersion(_ context.Context, id uuid.UUID) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + if u, ok := s.m[id]; ok { + u.SessionVersion++ + return u.SessionVersion, nil + } + return 0, authkit.ErrUserNotFound +} +func (s *memUserStore) IncrementFailedLogins(_ context.Context, id uuid.UUID) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + if u, ok := s.m[id]; ok { + u.FailedLogins++ + return u.FailedLogins, nil + } + return 0, authkit.ErrUserNotFound +} +func (s *memUserStore) ResetFailedLogins(_ context.Context, id uuid.UUID) error { + s.mu.Lock() + defer s.mu.Unlock() + if u, ok := s.m[id]; ok { + u.FailedLogins = 0 + } + return nil +} + +type memSessionStore struct { + mu sync.Mutex + m map[string]*authkit.Session +} + +func newMemSessionStore() *memSessionStore { + return &memSessionStore{m: map[string]*authkit.Session{}} +} +func (s *memSessionStore) CreateSession(_ context.Context, sess *authkit.Session) error { + s.mu.Lock() + defer s.mu.Unlock() + cp := *sess + s.m[string(sess.IDHash)] = &cp + return nil +} +func (s *memSessionStore) GetSession(_ context.Context, h []byte) (*authkit.Session, error) { + s.mu.Lock() + defer s.mu.Unlock() + sess, ok := s.m[string(h)] + if !ok { + return nil, authkit.ErrSessionInvalid + } + cp := *sess + return &cp, nil +} +func (s *memSessionStore) TouchSession(_ context.Context, h []byte, lastSeen, newExp time.Time) error { + s.mu.Lock() + defer s.mu.Unlock() + if sess, ok := s.m[string(h)]; ok { + sess.LastSeenAt = lastSeen + sess.ExpiresAt = newExp + } + return nil +} +func (s *memSessionStore) DeleteSession(_ context.Context, h []byte) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.m, string(h)) + return nil +} +func (s *memSessionStore) DeleteUserSessions(_ context.Context, _ uuid.UUID) error { return nil } +func (s *memSessionStore) DeleteExpired(_ context.Context, _ time.Time) (int64, error) { + return 0, nil +} + +type memTokenStore struct{} + +func (memTokenStore) CreateToken(_ context.Context, _ *authkit.Token) error { return nil } +func (memTokenStore) ConsumeToken(_ context.Context, _ authkit.TokenKind, _ []byte, _ time.Time) (*authkit.Token, error) { + return nil, authkit.ErrTokenInvalid +} +func (memTokenStore) GetToken(_ context.Context, _ authkit.TokenKind, _ []byte) (*authkit.Token, error) { + return nil, authkit.ErrTokenInvalid +} +func (memTokenStore) DeleteByChain(_ context.Context, _ string) (int64, error) { return 0, nil } +func (memTokenStore) DeleteExpired(_ context.Context, _ time.Time) (int64, error) { + return 0, nil +} + +type memServiceKeyStore struct { + mu sync.Mutex + m map[string]*authkit.ServiceKey +} + +func newMemServiceKeyStore() *memServiceKeyStore { + return &memServiceKeyStore{m: map[string]*authkit.ServiceKey{}} +} +func (s *memServiceKeyStore) CreateServiceKey(_ context.Context, k *authkit.ServiceKey) error { + s.mu.Lock() + defer s.mu.Unlock() + cp := *k + cp.Abilities = append([]string(nil), k.Abilities...) + s.m[string(k.IDHash)] = &cp + return nil +} +func (s *memServiceKeyStore) GetServiceKey(_ context.Context, h []byte) (*authkit.ServiceKey, error) { + s.mu.Lock() + defer s.mu.Unlock() + k, ok := s.m[string(h)] + if !ok { + return nil, authkit.ErrServiceKeyInvalid + } + cp := *k + cp.Abilities = append([]string(nil), k.Abilities...) + return &cp, nil +} +func (s *memServiceKeyStore) ListServiceKeysByOwner(_ context.Context, kind string, owner uuid.UUID) ([]*authkit.ServiceKey, error) { + s.mu.Lock() + defer s.mu.Unlock() + var out []*authkit.ServiceKey + for _, k := range s.m { + if k.OwnerKind == kind && k.OwnerID == owner { + cp := *k + cp.Abilities = append([]string(nil), k.Abilities...) + out = append(out, &cp) + } + } + return out, nil +} +func (s *memServiceKeyStore) TouchServiceKey(_ context.Context, h []byte, at time.Time) error { + s.mu.Lock() + defer s.mu.Unlock() + if k, ok := s.m[string(h)]; ok { + k.LastUsedAt = &at + } + return nil +} +func (s *memServiceKeyStore) RevokeServiceKey(_ context.Context, h []byte, at time.Time) error { + s.mu.Lock() + defer s.mu.Unlock() + k, ok := s.m[string(h)] + if !ok { + return authkit.ErrServiceKeyInvalid + } + if k.RevokedAt != nil { + return authkit.ErrServiceKeyInvalid + } + k.RevokedAt = &at + return nil +} + +type memRoleStore struct{} + +func (memRoleStore) CreateRole(_ context.Context, _ *authkit.Role) error { return nil } +func (memRoleStore) GetRoleByID(_ context.Context, _ uuid.UUID) (*authkit.Role, error) { + return nil, authkit.ErrRoleNotFound +} +func (memRoleStore) GetRoleByName(_ context.Context, _ string) (*authkit.Role, error) { + return nil, authkit.ErrRoleNotFound +} +func (memRoleStore) ListRoles(_ context.Context) ([]*authkit.Role, error) { return nil, nil } +func (memRoleStore) DeleteRole(_ context.Context, _ uuid.UUID) error { return nil } +func (memRoleStore) AssignRoleToUser(_ context.Context, _, _ uuid.UUID) error { return nil } +func (memRoleStore) RemoveRoleFromUser(_ context.Context, _, _ uuid.UUID) error { return nil } +func (memRoleStore) GetUserRoles(_ context.Context, _ uuid.UUID) ([]*authkit.Role, error) { + return nil, nil +} +func (memRoleStore) HasAnyRole(_ context.Context, _ uuid.UUID, _ []string) (bool, error) { + return false, nil +} + +type memPermStore struct{} + +func (memPermStore) CreatePermission(_ context.Context, _ *authkit.Permission) error { return nil } +func (memPermStore) GetPermissionByID(_ context.Context, _ uuid.UUID) (*authkit.Permission, error) { + return nil, authkit.ErrPermissionNotFound +} +func (memPermStore) GetPermissionByName(_ context.Context, _ string) (*authkit.Permission, error) { + return nil, authkit.ErrPermissionNotFound +} +func (memPermStore) ListPermissions(_ context.Context) ([]*authkit.Permission, error) { + return nil, nil +} +func (memPermStore) DeletePermission(_ context.Context, _ uuid.UUID) error { return nil } +func (memPermStore) AssignPermissionToRole(_ context.Context, _, _ uuid.UUID) error { return nil } +func (memPermStore) RemovePermissionFromRole(_ context.Context, _, _ uuid.UUID) error { return nil } +func (memPermStore) GetRolePermissions(_ context.Context, _ uuid.UUID) ([]*authkit.Permission, error) { + return nil, nil +} +func (memPermStore) GetUserPermissions(_ context.Context, _ uuid.UUID) ([]*authkit.Permission, error) { + return nil, nil +} + +type stubHasher struct{} + +func (stubHasher) Hash(p string) (string, error) { return "stub:" + p, nil } +func (stubHasher) Verify(p, encoded string) (bool, bool, error) { + return encoded == "stub:"+p, false, nil +} + +func newTestAuth(t *testing.T) *authkit.Auth { + t.Helper() + return authkit.New(authkit.Deps{ + Users: newMemUserStore(), + Sessions: newMemSessionStore(), + Tokens: memTokenStore{}, + ServiceKeys: newMemServiceKeyStore(), + Roles: memRoleStore{}, + Permissions: memPermStore{}, + Hasher: stubHasher{}, + }, authkit.Config{ + JWTSecret: []byte("test-secret-thirty-two-bytes!!!!"), + JWTIssuer: "mw-test", + AccessTokenTTL: 2 * time.Minute, + RefreshTokenTTL: 1 * time.Hour, + SessionIdleTTL: time.Hour, + SessionAbsoluteTTL: 24 * time.Hour, + EmailVerifyTTL: time.Hour, + PasswordResetTTL: time.Hour, + MagicLinkTTL: time.Minute, + }) +} + +// Bearer-style request helper. +func req(token string) *http.Request { + r := httptest.NewRequest(http.MethodGet, "/", nil) + if token != "" { + r.Header.Set("Authorization", "Bearer "+token) + } + return r +} + +func ok200(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) } + +// ─── tests ───────────────────────────────────────────────────────────────── + +func TestRequireServiceKey_Authenticates(t *testing.T) { + a := newTestAuth(t) + plain, _, err := a.IssueServiceKey(context.Background(), + "application", uuid.New(), "ci", []string{"events:write"}, nil) + if err != nil { + t.Fatalf("IssueServiceKey: %v", err) + } + + var seen *authkit.ServiceKey + handler := middleware.RequireServiceKey(middleware.Options{Auth: a})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + k, ok := middleware.ServiceKeyFrom(r.Context()) + if !ok { + t.Fatalf("no ServiceKey on context") + } + seen = k + w.WriteHeader(http.StatusOK) + })) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req(plain)) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + if seen == nil || !seen.HasAbility("events:write") { + t.Fatalf("expected ServiceKey with events:write ability; got %+v", seen) + } +} + +func TestRequireServiceKey_RejectsRevoked(t *testing.T) { + a := newTestAuth(t) + plain, _, err := a.IssueServiceKey(context.Background(), + "application", uuid.New(), "ci", nil, nil) + if err != nil { + t.Fatalf("IssueServiceKey: %v", err) + } + if err := a.RevokeServiceKey(context.Background(), plain); err != nil { + t.Fatalf("RevokeServiceKey: %v", err) + } + + called := false + handler := middleware.RequireServiceKey(middleware.Options{Auth: a})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + })) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req(plain)) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rr.Code) + } + if called { + t.Fatalf("handler should not have been invoked for revoked key") + } +} + +func TestRequireAbility_AcceptsServiceKeyWithAbility(t *testing.T) { + a := newTestAuth(t) + plain, _, err := a.IssueServiceKey(context.Background(), + "application", uuid.New(), "ci", []string{"events:write"}, nil) + if err != nil { + t.Fatalf("IssueServiceKey: %v", err) + } + chain := middleware.RequireServiceKey(middleware.Options{Auth: a})( + middleware.RequireAbility("events:write")(http.HandlerFunc(ok200))) + + rr := httptest.NewRecorder() + chain.ServeHTTP(rr, req(plain)) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + + // Same chain but ability the key does not carry → 403. + chainBad := middleware.RequireServiceKey(middleware.Options{Auth: a})( + middleware.RequireAbility("admin:nuke")(http.HandlerFunc(ok200))) + + rr = httptest.NewRecorder() + chainBad.ServeHTTP(rr, req(plain)) + if rr.Code != http.StatusForbidden { + t.Fatalf("missing-ability status = %d, want 403", rr.Code) + } +} + +func TestRequireAbility_RejectsUserPrincipal(t *testing.T) { + a := newTestAuth(t) + u, err := a.Register(context.Background(), "alice@example.com", "hunter2hunter2") + if err != nil { + t.Fatalf("Register: %v", err) + } + plain, _, err := a.IssueSession(context.Background(), u.ID, "ua", netip.MustParseAddr("127.0.0.1")) + if err != nil { + t.Fatalf("IssueSession: %v", err) + } + chain := middleware.RequireSession(middleware.Options{Auth: a})( + middleware.RequireAbility("events:write")(http.HandlerFunc(ok200))) + + rr := httptest.NewRecorder() + chain.ServeHTTP(rr, req(plain)) + if rr.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403 (user principal carries no abilities)", rr.Code) + } +} + +func TestRequireRole_RejectsServiceKey(t *testing.T) { + a := newTestAuth(t) + plain, _, err := a.IssueServiceKey(context.Background(), + "application", uuid.New(), "ci", nil, nil) + if err != nil { + t.Fatalf("IssueServiceKey: %v", err) + } + chain := middleware.RequireServiceKey(middleware.Options{Auth: a})( + middleware.RequireRole("admin")(http.HandlerFunc(ok200))) + + rr := httptest.NewRecorder() + chain.ServeHTTP(rr, req(plain)) + if rr.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403 (service key carries no Principal/role)", rr.Code) + } +} + +func TestRequireAnyOrServiceKey(t *testing.T) { + a := newTestAuth(t) + u, err := a.Register(context.Background(), "alice@example.com", "hunter2hunter2") + if err != nil { + t.Fatalf("Register: %v", err) + } + sessionPlain, _, err := a.IssueSession(context.Background(), u.ID, "ua", netip.MustParseAddr("127.0.0.1")) + if err != nil { + t.Fatalf("IssueSession: %v", err) + } + servicePlain, _, err := a.IssueServiceKey(context.Background(), + "application", uuid.New(), "ci", nil, nil) + if err != nil { + t.Fatalf("IssueServiceKey: %v", err) + } + + type subject struct { + hasPrincipal bool + hasServiceKey bool + } + var got subject + handler := middleware.RequireAnyOrServiceKey(middleware.Options{Auth: a})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, hp := middleware.PrincipalFrom(r.Context()) + _, hs := middleware.ServiceKeyFrom(r.Context()) + got = subject{hp, hs} + w.WriteHeader(http.StatusOK) + })) + + // Session token → Principal in context, no ServiceKey. + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req(sessionPlain)) + if rr.Code != http.StatusOK { + t.Fatalf("session: status = %d, want 200", rr.Code) + } + if !got.hasPrincipal || got.hasServiceKey { + t.Fatalf("session: ctx subject = %+v, want principal-only", got) + } + + // Service token → ServiceKey in context, no Principal. + rr = httptest.NewRecorder() + got = subject{} + handler.ServeHTTP(rr, req(servicePlain)) + if rr.Code != http.StatusOK { + t.Fatalf("service: status = %d, want 200", rr.Code) + } + if got.hasPrincipal || !got.hasServiceKey { + t.Fatalf("service: ctx subject = %+v, want servicekey-only", got) + } + + // Garbage token → 401, neither subject set. + rr = httptest.NewRecorder() + got = subject{} + handler.ServeHTTP(rr, req(strings.Repeat("x", 50))) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("garbage: status = %d, want 401", rr.Code) + } +} + +// Sanity check: the constructed *authkit.Auth should satisfy errors.Is on the +// canonical sentinels — ensures our minimal stores are wired correctly. +func TestSentinelsReachable(t *testing.T) { + a := newTestAuth(t) + _, err := a.AuthenticateServiceKey(context.Background(), "sk_not-real") + if !errors.Is(err, authkit.ErrServiceKeyInvalid) { + t.Fatalf("expected ErrServiceKeyInvalid, got %v", err) + } +} diff --git a/models.go b/models.go index a0d091c..2425ea7 100644 --- a/models.go +++ b/models.go @@ -49,21 +49,12 @@ type Token struct { ExpiresAt time.Time } -type APIKey struct { - IDHash []byte - OwnerID uuid.UUID - Name string - Abilities []string - LastUsedAt *time.Time - CreatedAt time.Time - ExpiresAt *time.Time - RevokedAt *time.Time -} - -// ServiceKey is an owner-agnostic API key for server-to-server auth. Unlike -// APIKey, OwnerID is not constrained to authkit_users — OwnerKind labels the -// owner namespace (e.g. "application", "tenant") and consumers manage their -// own cascade-on-delete. +// ServiceKey is an owner-agnostic credential for server-to-server auth. +// OwnerID is not constrained to authkit_users — OwnerKind labels the owner +// namespace (e.g. "application", "tenant") and consumers manage their own +// cascade-on-delete. It is the only credential type that carries free-form +// abilities; user-bound credentials (sessions, JWTs) prove identity and +// resolve permissions through RBAC instead. type ServiceKey struct { IDHash []byte OwnerID uuid.UUID @@ -76,6 +67,16 @@ type ServiceKey struct { RevokedAt *time.Time } +// HasAbility reports whether the service key carries the named ability. +func (k *ServiceKey) HasAbility(name string) bool { + for _, a := range k.Abilities { + if a == name { + return true + } + } + return false +} + type Role struct { ID uuid.UUID Name string diff --git a/principal.go b/principal.go index 590dd9b..b52a963 100644 --- a/principal.go +++ b/principal.go @@ -11,17 +11,18 @@ type AuthMethod string const ( AuthMethodSession AuthMethod = "session" AuthMethodJWT AuthMethod = "jwt" - AuthMethodAPIKey AuthMethod = "api_key" ) +// Principal represents an authenticated user. It is produced only by +// user-bound auth methods (session, JWT) and carries identity plus +// RBAC-resolved roles/permissions. Service-token auth produces a +// *ServiceKey instead — those credentials carry abilities, not identity. type Principal struct { UserID uuid.UUID Method AuthMethod SessionID []byte - APIKeyID []byte Roles []string Permissions []string - Abilities []string IssuedAt time.Time ExpiresAt time.Time } @@ -52,12 +53,3 @@ func (p *Principal) HasPermission(name string) bool { } return false } - -func (p *Principal) HasAbility(name string) bool { - for _, a := range p.Abilities { - if a == name { - return true - } - } - return false -} diff --git a/service_apikey.go b/service_apikey.go deleted file mode 100644 index 045e063..0000000 --- a/service_apikey.go +++ /dev/null @@ -1,92 +0,0 @@ -package authkit - -import ( - "context" - "time" - - "git.juancwu.dev/juancwu/errx" - "github.com/google/uuid" -) - -// IssueAPIKey mints a fresh API secret with the given abilities and an -// optional TTL. The plaintext is returned to the caller (show-once) and the -// SHA-256 lookup hash is stored. Pass ttl=nil for a non-expiring key. -func (a *Auth) IssueAPIKey(ctx context.Context, ownerID uuid.UUID, name string, abilities []string, ttl *time.Duration) (string, *APIKey, error) { - const op = "authkit.Auth.IssueAPIKey" - plaintext, hash, err := mintSecret(prefixAPIKey, a.cfg.Random) - if err != nil { - return "", nil, errx.Wrap(op, err) - } - now := a.now() - k := &APIKey{ - IDHash: hash, - OwnerID: ownerID, - Name: name, - Abilities: append([]string(nil), abilities...), - CreatedAt: now, - } - if ttl != nil { - exp := now.Add(*ttl) - k.ExpiresAt = &exp - } - if err := a.deps.APIKeys.CreateAPIKey(ctx, k); err != nil { - return "", nil, errx.Wrap(op, err) - } - return plaintext, k, nil -} - -// AuthenticateAPIKey validates an API secret string, touches last_used_at -// (best-effort), and returns a Principal carrying the key's abilities. The -// owning user's roles+permissions are also resolved so the same Principal -// can satisfy RequireRole / RequirePermission middleware. -func (a *Auth) AuthenticateAPIKey(ctx context.Context, plaintext string) (*Principal, error) { - const op = "authkit.Auth.AuthenticateAPIKey" - hash, ok := parseSecret(prefixAPIKey, plaintext) - if !ok { - return nil, errx.Wrap(op, ErrAPIKeyInvalid) - } - k, err := a.deps.APIKeys.GetAPIKey(ctx, hash) - if err != nil { - return nil, errx.Wrap(op, err) - } - now := a.now() - if k.RevokedAt != nil { - return nil, errx.Wrap(op, ErrAPIKeyInvalid) - } - if k.ExpiresAt != nil && !k.ExpiresAt.After(now) { - return nil, errx.Wrap(op, ErrAPIKeyInvalid) - } - _ = a.deps.APIKeys.TouchAPIKey(ctx, hash, now) - - roles, perms, err := a.resolveRolesAndPermissions(ctx, k.OwnerID) - if err != nil { - return nil, errx.Wrap(op, err) - } - expires := now - if k.ExpiresAt != nil { - expires = *k.ExpiresAt - } - return &Principal{ - UserID: k.OwnerID, - Method: AuthMethodAPIKey, - APIKeyID: hash, - Roles: roles, - Permissions: perms, - Abilities: append([]string(nil), k.Abilities...), - IssuedAt: k.CreatedAt, - ExpiresAt: expires, - }, nil -} - -// RevokeAPIKey marks a key revoked. Idempotent on already-revoked keys. -func (a *Auth) RevokeAPIKey(ctx context.Context, plaintext string) error { - const op = "authkit.Auth.RevokeAPIKey" - hash, ok := parseSecret(prefixAPIKey, plaintext) - if !ok { - return errx.Wrap(op, ErrAPIKeyInvalid) - } - if err := a.deps.APIKeys.RevokeAPIKey(ctx, hash, a.now()); err != nil { - return errx.Wrap(op, err) - } - return nil -} diff --git a/service_service_key_test.go b/service_service_key_test.go index a532762..c95d7a2 100644 --- a/service_service_key_test.go +++ b/service_service_key_test.go @@ -140,6 +140,23 @@ func TestServiceKeyListByOwner(t *testing.T) { } } +func TestServiceKeyHasAbility(t *testing.T) { + k := &ServiceKey{Abilities: []string{"events:write", "events:read"}} + if !k.HasAbility("events:write") { + t.Fatalf("expected HasAbility(events:write) = true") + } + if !k.HasAbility("events:read") { + t.Fatalf("expected HasAbility(events:read) = true") + } + if k.HasAbility("admin:nuke") { + t.Fatalf("expected HasAbility(admin:nuke) = false") + } + empty := &ServiceKey{} + if empty.HasAbility("anything") { + t.Fatalf("HasAbility on empty Abilities must be false") + } +} + func TestServiceKeyTouchUpdatesLastUsedAt(t *testing.T) { a := newTestAuth(t) appID := uuid.New() diff --git a/service_test.go b/service_test.go index 868ec1b..3e13cda 100644 --- a/service_test.go +++ b/service_test.go @@ -148,37 +148,6 @@ func TestMagicLinkFlow(t *testing.T) { } } -func TestAPIKeyFlowWithAbilities(t *testing.T) { - a := newTestAuth(t) - u, err := a.Register(context.Background(), "k@k.com", "pw") - if err != nil { - t.Fatalf("Register: %v", err) - } - plaintext, k, err := a.IssueAPIKey(context.Background(), u.ID, "ci", []string{"billing:read", "users:list"}, nil) - if err != nil { - t.Fatalf("IssueAPIKey: %v", err) - } - if k == nil || plaintext == "" { - t.Fatalf("missing api key") - } - p, err := a.AuthenticateAPIKey(context.Background(), plaintext) - if err != nil { - t.Fatalf("AuthenticateAPIKey: %v", err) - } - if !p.HasAbility("billing:read") || !p.HasAbility("users:list") { - t.Fatalf("abilities missing on principal: %+v", p.Abilities) - } - if p.HasAbility("admin:nuke") { - t.Fatalf("unexpected ability granted") - } - if err := a.RevokeAPIKey(context.Background(), plaintext); err != nil { - t.Fatalf("RevokeAPIKey: %v", err) - } - if _, err := a.AuthenticateAPIKey(context.Background(), plaintext); !errors.Is(err, ErrAPIKeyInvalid) { - t.Fatalf("expected ErrAPIKeyInvalid post-revoke, got %v", err) - } -} - func TestRBACRolesAndPermissions(t *testing.T) { ctx := context.Background() a := newTestAuth(t) diff --git a/sqlstore/apikeys.go b/sqlstore/apikeys.go deleted file mode 100644 index 1212c20..0000000 --- a/sqlstore/apikeys.go +++ /dev/null @@ -1,124 +0,0 @@ -package sqlstore - -import ( - "context" - "database/sql" - "encoding/json" - "time" - - "git.juancwu.dev/juancwu/authkit" - "git.juancwu.dev/juancwu/errx" - "github.com/google/uuid" -) - -type apiKeyStore struct{ storeBase } - -func (s *apiKeyStore) CreateAPIKey(ctx context.Context, k *authkit.APIKey) error { - const op = "authkit.sqlstore.APIKeyStore.CreateAPIKey" - if k.CreatedAt.IsZero() { - k.CreatedAt = time.Now().UTC() - } - if k.Abilities == nil { - k.Abilities = []string{} - } - abilities, err := json.Marshal(k.Abilities) - if err != nil { - return errx.Wrap(op, err) - } - _, err = s.db.ExecContext(ctx, s.q.CreateAPIKey, - k.IDHash, uuidArg(k.OwnerID), k.Name, abilities, - nullableTime(k.LastUsedAt), k.CreatedAt, - nullableTime(k.ExpiresAt), nullableTime(k.RevokedAt)) - if err != nil { - return errx.Wrap(op, err) - } - return nil -} - -func (s *apiKeyStore) GetAPIKey(ctx context.Context, idHash []byte) (*authkit.APIKey, error) { - const op = "authkit.sqlstore.APIKeyStore.GetAPIKey" - k, err := scanAPIKey(s.db.QueryRowContext(ctx, s.q.GetAPIKey, idHash)) - if err != nil { - return nil, errx.Wrap(op, mapNotFound(err, authkit.ErrAPIKeyInvalid)) - } - return k, nil -} - -func (s *apiKeyStore) ListAPIKeysByOwner(ctx context.Context, ownerID uuid.UUID) ([]*authkit.APIKey, error) { - const op = "authkit.sqlstore.APIKeyStore.ListAPIKeysByOwner" - rows, err := s.db.QueryContext(ctx, s.q.ListAPIKeysByOwner, uuidArg(ownerID)) - if err != nil { - return nil, errx.Wrap(op, err) - } - defer rows.Close() - var out []*authkit.APIKey - for rows.Next() { - k, err := scanAPIKey(rows) - if err != nil { - return nil, errx.Wrap(op, err) - } - out = append(out, k) - } - return out, errx.Wrap(op, rows.Err()) -} - -func (s *apiKeyStore) TouchAPIKey(ctx context.Context, idHash []byte, at time.Time) error { - const op = "authkit.sqlstore.APIKeyStore.TouchAPIKey" - if _, err := s.db.ExecContext(ctx, s.q.TouchAPIKey, at, idHash); err != nil { - return errx.Wrap(op, err) - } - return nil -} - -func (s *apiKeyStore) RevokeAPIKey(ctx context.Context, idHash []byte, at time.Time) error { - const op = "authkit.sqlstore.APIKeyStore.RevokeAPIKey" - tag, err := s.db.ExecContext(ctx, s.q.RevokeAPIKey, at, idHash) - if err != nil { - return errx.Wrap(op, err) - } - n, _ := tag.RowsAffected() - if n == 0 { - return errx.Wrap(op, authkit.ErrAPIKeyInvalid) - } - return nil -} - -func (s *apiKeyStore) RevokeAPIKeysByOwner(ctx context.Context, ownerID uuid.UUID, at time.Time) error { - const op = "authkit.sqlstore.APIKeyStore.RevokeAPIKeysByOwner" - if _, err := s.db.ExecContext(ctx, s.q.RevokeAPIKeysByOwner, at, uuidArg(ownerID)); err != nil { - return errx.Wrap(op, err) - } - return nil -} - -func scanAPIKey(row rowScanner) (*authkit.APIKey, error) { - var ( - k authkit.APIKey - ownerIDStr string - abilitiesRaw []byte - lastUsed sql.NullTime - expires sql.NullTime - revoked sql.NullTime - ) - if err := row.Scan(&k.IDHash, &ownerIDStr, &k.Name, &abilitiesRaw, - &lastUsed, &k.CreatedAt, &expires, &revoked); err != nil { - return nil, err - } - owner, err := scanUUID(ownerIDStr) - if err != nil { - return nil, err - } - k.OwnerID = owner - if len(abilitiesRaw) > 0 { - if err := json.Unmarshal(abilitiesRaw, &k.Abilities); err != nil { - return nil, err - } - } - if k.Abilities == nil { - k.Abilities = []string{} - } - k.LastUsedAt = scanNullTimePtr(lastUsed) - k.ExpiresAt = scanNullTimePtr(expires) - k.RevokedAt = scanNullTimePtr(revoked) - return &k, nil -} diff --git a/sqlstore/dialect.go b/sqlstore/dialect.go index 44ef9b8..79b1eae 100644 --- a/sqlstore/dialect.go +++ b/sqlstore/dialect.go @@ -81,14 +81,6 @@ type Queries struct { DeleteByChain string DeleteExpiredTokens string - // api keys - CreateAPIKey string - GetAPIKey string - ListAPIKeysByOwner string - TouchAPIKey string - RevokeAPIKey string - RevokeAPIKeysByOwner string - // service keys CreateServiceKey string GetServiceKey string diff --git a/sqlstore/dialect/postgres/migrations/0003_drop_api_keys.sql b/sqlstore/dialect/postgres/migrations/0003_drop_api_keys.sql new file mode 100644 index 0000000..d67fcb4 --- /dev/null +++ b/sqlstore/dialect/postgres/migrations/0003_drop_api_keys.sql @@ -0,0 +1,13 @@ +-- 0003_drop_api_keys.sql +-- Drops the user-owned API key table. After this migration only service +-- tokens carry abilities; user-owned credentials (sessions, JWTs, +-- magic-links) prove identity, with permissions resolved via RBAC. + +BEGIN; + +DROP TABLE IF EXISTS authkit_api_keys CASCADE; + +INSERT INTO authkit_schema_migrations (version, applied_at) VALUES ('0003_drop_api_keys', now()) +ON CONFLICT (version) DO NOTHING; + +COMMIT; diff --git a/sqlstore/dialect/postgres/postgres.go b/sqlstore/dialect/postgres/postgres.go index 6b1a1fe..8b5f615 100644 --- a/sqlstore/dialect/postgres/postgres.go +++ b/sqlstore/dialect/postgres/postgres.go @@ -134,20 +134,6 @@ func (Dialect) BuildQueries(s sqlstore.Schema) sqlstore.Queries { DeleteByChain: `DELETE FROM ` + t.Tokens + ` WHERE chain_id = ?`, DeleteExpiredTokens: `DELETE FROM ` + t.Tokens + ` WHERE expires_at <= ?`, - // api keys - CreateAPIKey: `INSERT INTO ` + t.APIKeys + ` - (id_hash, owner_id, name, abilities, last_used_at, created_at, expires_at, revoked_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - GetAPIKey: `SELECT id_hash, owner_id, name, abilities, last_used_at, - created_at, expires_at, revoked_at - FROM ` + t.APIKeys + ` WHERE id_hash = ?`, - ListAPIKeysByOwner: `SELECT id_hash, owner_id, name, abilities, last_used_at, - created_at, expires_at, revoked_at - FROM ` + t.APIKeys + ` WHERE owner_id = ? ORDER BY created_at DESC`, - TouchAPIKey: `UPDATE ` + t.APIKeys + ` SET last_used_at = ? WHERE id_hash = ?`, - RevokeAPIKey: `UPDATE ` + t.APIKeys + ` SET revoked_at = ? WHERE id_hash = ? AND revoked_at IS NULL`, - RevokeAPIKeysByOwner: `UPDATE ` + t.APIKeys + ` SET revoked_at = ? WHERE owner_id = ? AND revoked_at IS NULL`, - // service keys CreateServiceKey: `INSERT INTO ` + t.ServiceKeys + ` (id_hash, owner_id, owner_kind, name, abilities, last_used_at, created_at, expires_at, revoked_at) @@ -228,13 +214,6 @@ func (Dialect) BuildQueries(s sqlstore.Schema) sqlstore.Queries { q.DeleteByChain = rebind(q.DeleteByChain) q.DeleteExpiredTokens = rebind(q.DeleteExpiredTokens) - q.CreateAPIKey = rebind(q.CreateAPIKey) - q.GetAPIKey = rebind(q.GetAPIKey) - q.ListAPIKeysByOwner = rebind(q.ListAPIKeysByOwner) - q.TouchAPIKey = rebind(q.TouchAPIKey) - q.RevokeAPIKey = rebind(q.RevokeAPIKey) - q.RevokeAPIKeysByOwner = rebind(q.RevokeAPIKeysByOwner) - q.CreateServiceKey = rebind(q.CreateServiceKey) q.GetServiceKey = rebind(q.GetServiceKey) q.ListServiceKeysByOwner = rebind(q.ListServiceKeysByOwner) diff --git a/sqlstore/schema.go b/sqlstore/schema.go index 40f2e20..82f4182 100644 --- a/sqlstore/schema.go +++ b/sqlstore/schema.go @@ -21,7 +21,6 @@ type Tables struct { Users string Sessions string Tokens string - APIKeys string ServiceKeys string Roles string Permissions string @@ -37,7 +36,6 @@ func DefaultSchema() Schema { Users: "authkit_users", Sessions: "authkit_sessions", Tokens: "authkit_tokens", - APIKeys: "authkit_api_keys", ServiceKeys: "authkit_service_keys", Roles: "authkit_roles", Permissions: "authkit_permissions", @@ -61,7 +59,6 @@ func (s Schema) Validate() error { {"Users", s.Tables.Users}, {"Sessions", s.Tables.Sessions}, {"Tokens", s.Tables.Tokens}, - {"APIKeys", s.Tables.APIKeys}, {"ServiceKeys", s.Tables.ServiceKeys}, {"Roles", s.Tables.Roles}, {"Permissions", s.Tables.Permissions}, diff --git a/sqlstore/sqlstore.go b/sqlstore/sqlstore.go index 702e3e5..d20ab08 100644 --- a/sqlstore/sqlstore.go +++ b/sqlstore/sqlstore.go @@ -18,7 +18,6 @@ type Stores struct { Users authkit.UserStore Sessions authkit.SessionStore Tokens authkit.TokenStore - APIKeys authkit.APIKeyStore ServiceKeys authkit.ServiceKeyStore Roles authkit.RoleStore Permissions authkit.PermissionStore @@ -44,7 +43,6 @@ func New(db *sql.DB, dialect Dialect, schema Schema) (*Stores, error) { Users: &userStore{storeBase: base}, Sessions: &sessionStore{storeBase: base}, Tokens: &tokenStore{storeBase: base}, - APIKeys: &apiKeyStore{storeBase: base}, ServiceKeys: &serviceKeyStore{storeBase: base}, Roles: &roleStore{storeBase: base}, Permissions: &permissionStore{storeBase: base}, diff --git a/sqlstore/sqlstore_test.go b/sqlstore/sqlstore_test.go index b035769..1bc721c 100644 --- a/sqlstore/sqlstore_test.go +++ b/sqlstore/sqlstore_test.go @@ -66,7 +66,6 @@ func freshDB(t *testing.T) (*authkit.Auth, *sql.DB, sqlstore.Schema) { Users: stores.Users, Sessions: stores.Sessions, Tokens: stores.Tokens, - APIKeys: stores.APIKeys, ServiceKeys: stores.ServiceKeys, Roles: stores.Roles, Permissions: stores.Permissions, @@ -90,7 +89,7 @@ func dropAuthkitTables(t *testing.T, db *sql.DB, s sqlstore.Schema) { tables := []string{ s.Tables.UserRoles, s.Tables.RolePermissions, s.Tables.Roles, s.Tables.Permissions, - s.Tables.APIKeys, s.Tables.ServiceKeys, s.Tables.Tokens, + s.Tables.ServiceKeys, s.Tables.Tokens, s.Tables.Sessions, s.Tables.Users, s.Tables.SchemaMigrations, } @@ -192,33 +191,6 @@ func TestIntegration_JWTRefreshRotationAndReuse(t *testing.T) { } } -func TestIntegration_APIKeyWithAbilities(t *testing.T) { - auth, _, _ := freshDB(t) - ctx := context.Background() - u, err := auth.Register(ctx, "k@k.com", "pw") - if err != nil { - t.Fatalf("Register: %v", err) - } - plain, _, err := auth.IssueAPIKey(ctx, u.ID, "ci", - []string{"billing:read", "users:list"}, nil) - if err != nil { - t.Fatalf("IssueAPIKey: %v", err) - } - p, err := auth.AuthenticateAPIKey(ctx, plain) - if err != nil { - t.Fatalf("AuthenticateAPIKey: %v", err) - } - if !p.HasAbility("billing:read") || !p.HasAbility("users:list") { - t.Fatalf("abilities missing: %+v", p.Abilities) - } - if err := auth.RevokeAPIKey(ctx, plain); err != nil { - t.Fatalf("RevokeAPIKey: %v", err) - } - if _, err := auth.AuthenticateAPIKey(ctx, plain); !errors.Is(err, authkit.ErrAPIKeyInvalid) { - t.Fatalf("expected ErrAPIKeyInvalid post-revoke, got %v", err) - } -} - func TestIntegration_ServiceKeyFlow(t *testing.T) { auth, _, _ := freshDB(t) ctx := context.Background() diff --git a/stores.go b/stores.go index 0620fa6..a3f1737 100644 --- a/stores.go +++ b/stores.go @@ -44,15 +44,6 @@ type TokenStore interface { DeleteExpired(ctx context.Context, now time.Time) (int64, error) } -type APIKeyStore interface { - CreateAPIKey(ctx context.Context, k *APIKey) error - GetAPIKey(ctx context.Context, idHash []byte) (*APIKey, error) - ListAPIKeysByOwner(ctx context.Context, ownerID uuid.UUID) ([]*APIKey, error) - TouchAPIKey(ctx context.Context, idHash []byte, at time.Time) error - RevokeAPIKey(ctx context.Context, idHash []byte, at time.Time) error - RevokeAPIKeysByOwner(ctx context.Context, ownerID uuid.UUID, at time.Time) error -} - type ServiceKeyStore interface { CreateServiceKey(ctx context.Context, k *ServiceKey) error GetServiceKey(ctx context.Context, idHash []byte) (*ServiceKey, error) diff --git a/tokens.go b/tokens.go index 06597d9..f5bee47 100644 --- a/tokens.go +++ b/tokens.go @@ -18,7 +18,6 @@ const secretRandomBytes = 32 const ( prefixSession = "sess" prefixRefresh = "rfr" - prefixAPIKey = "ak" prefixServiceKey = "sk" prefixEmailVerify = "evr" prefixPasswordRset = "pwr" diff --git a/tokens_test.go b/tokens_test.go index 391437a..28395ad 100644 --- a/tokens_test.go +++ b/tokens_test.go @@ -33,7 +33,7 @@ func TestParseSecretWrongPrefix(t *testing.T) { if err != nil { t.Fatalf("mintSecret: %v", err) } - if _, ok := parseSecret(prefixAPIKey, plaintext); ok { + if _, ok := parseSecret(prefixServiceKey, plaintext); ok { t.Fatalf("parseSecret should reject mismatched prefix") } if _, ok := parseSecret(prefixSession, "sessXXXX"); ok { @@ -44,7 +44,7 @@ func TestParseSecretWrongPrefix(t *testing.T) { func TestMintSecretUniqueness(t *testing.T) { seen := make(map[string]struct{}, 100) for i := 0; i < 100; i++ { - p, _, err := mintSecret(prefixAPIKey, nil) + p, _, err := mintSecret(prefixServiceKey, nil) if err != nil { t.Fatalf("mintSecret: %v", err) }