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

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