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

View file

@ -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)))
})
}
}