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
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
513
middleware/middleware_test.go
Normal file
513
middleware/middleware_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue