Cut user-owned API keys; redesign subject model
Removes the APIKey primitive entirely (Auth.IssueAPIKey/AuthenticateAPIKey/
RevokeAPIKey, APIKeyStore, Deps.APIKeys, Stores.APIKeys, Tables.APIKeys,
ErrAPIKeyInvalid, AuthMethodAPIKey, Principal.{APIKeyID, Abilities, HasAbility},
prefixAPIKey, RequireAPIKey, and the 6 SQL templates). Migration
0003_drop_api_keys.sql hard-drops authkit_api_keys.
The new subject model: *Principal carries identity only (sessions, JWTs);
*ServiceKey is the only abilities-bearing credential and gains a
HasAbility(name) method. RequireAbility now reads *ServiceKey from context
(user principals 403 by design). RequireRole/RequirePermission stay
Principal-only. New RequireServiceKey + ServiceKeyFrom + MustServiceKey,
and a heterogeneous RequireAnyOrServiceKey for routes that accept either.
RequireAny is now Principal-only (default [Session, JWT]).
Adds 7 middleware tests (auth, revoked, ability accept/reject across
subjects, role rejects service key, RequireAnyOrServiceKey both paths) and
1 (*ServiceKey).HasAbility unit test. Existing API-key tests deleted.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4942e4dbdc
commit
7f1db871bc
24 changed files with 773 additions and 496 deletions
106
README.md
106
README.md
|
|
@ -36,11 +36,12 @@ PostgreSQL 12+ is sufficient — the schema avoids `gen_random_uuid()` and
|
||||||
- Email verification, password reset, and magic-link passwordless login
|
- Email verification, password reset, and magic-link passwordless login
|
||||||
|
|
||||||
**Authorization**
|
**Authorization**
|
||||||
- Roles and permissions with many-to-many wiring
|
- Roles and permissions with many-to-many wiring (resolved on user-bound
|
||||||
- API keys with custom abilities for per-endpoint scoping
|
`Principal`s)
|
||||||
- Owner-agnostic service tokens for server-to-server auth (no FK on owner)
|
- Owner-agnostic service tokens with custom abilities for server-to-server
|
||||||
- A unified `Principal` type so middleware works the same regardless of which
|
auth (no FK on owner; cascade-on-delete is the consumer's responsibility)
|
||||||
authentication method ran
|
- A `Principal` for user-bound auth (sessions, JWTs) and a `ServiceKey` for
|
||||||
|
service-token auth — middleware composes around both subject types
|
||||||
|
|
||||||
**Storage**
|
**Storage**
|
||||||
- Interfaces for every store so callers can plug in their own backends
|
- 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
|
helper that takes a session-scoped advisory lock
|
||||||
|
|
||||||
**HTTP**
|
**HTTP**
|
||||||
- `middleware.RequireSession`, `RequireJWT`, `RequireAPIKey`, `RequireAny`
|
- User-bound: `middleware.RequireSession`, `RequireJWT`, `RequireAny`
|
||||||
- `middleware.RequireRole`, `RequireAnyRole`, `RequirePermission`,
|
- Service-bound: `middleware.RequireServiceKey`
|
||||||
`RequireAbility`
|
- Either: `middleware.RequireAnyOrServiceKey` (Session/JWT, falling through to
|
||||||
- `middleware.PrincipalFrom(ctx)` to read the authenticated principal in
|
ServiceKey)
|
||||||
handlers
|
- 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**
|
**Errors**
|
||||||
- Sentinel errors (`ErrEmailTaken`, `ErrInvalidCredentials`, `ErrTokenInvalid`,
|
- Sentinel errors (`ErrEmailTaken`, `ErrInvalidCredentials`, `ErrTokenInvalid`,
|
||||||
`ErrTokenReused`, `ErrSessionInvalid`, `ErrAPIKeyInvalid`,
|
`ErrTokenReused`, `ErrSessionInvalid`, `ErrServiceKeyInvalid`,
|
||||||
`ErrPermissionDenied`, ...) compatible with `errors.Is`
|
`ErrPermissionDenied`, ...) compatible with `errors.Is`
|
||||||
- All internal errors wrap with [`errx`](https://git.juancwu.dev/juancwu/errx)
|
- All internal errors wrap with [`errx`](https://git.juancwu.dev/juancwu/errx)
|
||||||
for op tags
|
for op tags
|
||||||
|
|
@ -117,7 +122,6 @@ auth := authkit.New(authkit.Deps{
|
||||||
Users: stores.Users,
|
Users: stores.Users,
|
||||||
Sessions: stores.Sessions,
|
Sessions: stores.Sessions,
|
||||||
Tokens: stores.Tokens,
|
Tokens: stores.Tokens,
|
||||||
APIKeys: stores.APIKeys,
|
|
||||||
ServiceKeys: stores.ServiceKeys,
|
ServiceKeys: stores.ServiceKeys,
|
||||||
Roles: stores.Roles,
|
Roles: stores.Roles,
|
||||||
Permissions: stores.Permissions,
|
Permissions: stores.Permissions,
|
||||||
|
|
@ -132,9 +136,8 @@ auth := authkit.New(authkit.Deps{
|
||||||
|
|
||||||
`Config` zero values fall back to sensible defaults (24h idle / 30d absolute
|
`Config` zero values fall back to sensible defaults (24h idle / 30d absolute
|
||||||
session TTL, 15m access tokens, 30d refresh tokens, 48h email-verify, 1h
|
session TTL, 15m access tokens, 30d refresh tokens, 48h email-verify, 1h
|
||||||
password-reset, 15m magic-link). `JWTSecret` and all eight `Deps` fields
|
password-reset, 15m magic-link). `JWTSecret` and all seven `Deps` fields are
|
||||||
(including the new `ServiceKeys` field added in v0.2.0) are required; `New`
|
required; `New` panics on a misconfiguration.
|
||||||
panics on a misconfiguration.
|
|
||||||
|
|
||||||
### 3. Use the service
|
### 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.IssueJWT(ctx, u.ID)
|
||||||
access, refresh, err = auth.RefreshJWT(ctx, refresh) // old refresh is consumed
|
access, refresh, err = auth.RefreshJWT(ctx, refresh) // old refresh is consumed
|
||||||
|
|
||||||
// API key with abilities (user-owned)
|
// Service token (owner-agnostic; ownerKind labels the namespace).
|
||||||
plaintext, key, err := auth.IssueAPIKey(ctx, u.ID, "ci",
|
// Service tokens are the only credential type that carries free-form abilities.
|
||||||
[]string{"billing:read", "users:list"}, nil)
|
|
||||||
|
|
||||||
// Service token (owner-agnostic; ownerKind labels the namespace)
|
|
||||||
plaintext, sk, err := auth.IssueServiceKey(ctx,
|
plaintext, sk, err := auth.IssueServiceKey(ctx,
|
||||||
"application", appID, "events-ingest",
|
"application", appID, "events-ingest",
|
||||||
[]string{"events:write"}, nil)
|
[]string{"events:write"}, nil)
|
||||||
|
|
@ -174,9 +174,9 @@ tok, err = auth.RequestMagicLink(ctx, "alice@example.com")
|
||||||
u, err = auth.ConsumeMagicLink(ctx, tok)
|
u, err = auth.ConsumeMagicLink(ctx, tok)
|
||||||
```
|
```
|
||||||
|
|
||||||
The plaintext returned by `IssueSession`, `IssueJWT`, `IssueAPIKey`, and the
|
The plaintext returned by `IssueSession`, `IssueJWT`, `IssueServiceKey`, and
|
||||||
token-minting flows is **show-once** — only its SHA-256 hash is stored. Show
|
the token-minting flows is **show-once** — only its SHA-256 hash is stored.
|
||||||
it to the user immediately; you cannot recover it later.
|
Show it to the user immediately; you cannot recover it later.
|
||||||
|
|
||||||
### 4. Wire middleware
|
### 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
|
// RBAC: stack authz on top of any auth method
|
||||||
admin := mux.Group("/admin", cookieAuth, authkitmw.RequireRole("admin"))
|
admin := mux.Group("/admin", cookieAuth, authkitmw.RequireRole("admin"))
|
||||||
|
|
||||||
// API-key-only route with a per-endpoint ability check
|
// Service-token route with a per-endpoint ability check
|
||||||
api := mux.Group("/api/v1", authkitmw.RequireAPIKey(authkitmw.Options{Auth: auth}))
|
api := mux.Group("/api/v1", authkitmw.RequireServiceKey(authkitmw.Options{Auth: auth}))
|
||||||
api.Get("/billing", billingHandler, authkitmw.RequireAbility("billing:read"))
|
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
|
`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
|
```go
|
||||||
schema := sqlstore.DefaultSchema()
|
schema := sqlstore.DefaultSchema()
|
||||||
schema.Tables.Users = "accounts"
|
schema.Tables.Users = "accounts"
|
||||||
schema.Tables.APIKeys = "api_credentials"
|
schema.Tables.ServiceKeys = "service_credentials"
|
||||||
|
|
||||||
stores, _ := sqlstore.New(db, pgdialect.New(), schema)
|
stores, _ := sqlstore.New(db, pgdialect.New(), schema)
|
||||||
```
|
```
|
||||||
|
|
@ -244,7 +256,7 @@ each table. Adding column overrides later is purely additive.
|
||||||
|
|
||||||
### Secret token format
|
### 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:
|
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
|
`MintOpaqueSecret`, `ParseOpaqueSecret`, and `HashOpaqueSecret` for callers
|
||||||
building bespoke token storage on top of the same shape.
|
building bespoke token storage on top of the same shape.
|
||||||
|
|
||||||
### Service tokens vs. API keys
|
### User credentials vs. service tokens
|
||||||
|
|
||||||
Both produce opaque `<prefix>_<base64>` secrets, but they target different
|
`authkit` exposes two distinct subject types, and middleware composes around
|
||||||
use cases.
|
them differently.
|
||||||
|
|
||||||
`IssueAPIKey` is for **user-owned credentials**. The owner is a row in
|
**User credentials** — sessions and JWTs — prove **identity**. They are
|
||||||
`authkit_users`; deleting that user cascades to the key (`ON DELETE CASCADE`),
|
produced by `IssueSession` / `IssueJWT` and authenticate via
|
||||||
and `AuthenticateAPIKey` returns a `*Principal` carrying the user's roles
|
`AuthenticateSession` / `AuthenticateJWT`, which return a `*Principal`
|
||||||
and permissions resolved through RBAC.
|
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
|
**Service tokens** — `IssueServiceKey` — prove **"this caller may do X"**.
|
||||||
something authkit knows nothing about — an `application` row, a `tenant`,
|
They are owner-agnostic: `OwnerKind` labels the namespace ("application",
|
||||||
whatever. `OwnerKind` labels the namespace and `OwnerID` identifies the
|
"tenant", whatever) and `OwnerID` identifies the entity within it. The
|
||||||
entity within it; the database column has **no foreign key** on purpose, so
|
database column has **no foreign key** on purpose — `authkit` makes no
|
||||||
cascade-on-delete is the consumer's responsibility. `AuthenticateServiceKey`
|
assumption about what the owner is, and cascade-on-delete is the consumer's
|
||||||
returns the `*ServiceKey` directly (no `*Principal`, no role/permission
|
responsibility. `AuthenticateServiceKey` returns a `*ServiceKey` directly
|
||||||
resolution): service tokens have no user, so the `Principal` abstraction
|
(no `*Principal`, no role/permission resolution). Authorization on these
|
||||||
does not fit. Abilities (`[]string`) on a service token are free-form —
|
requests is **ability-based** via `RequireAbility`; the abilities slice is
|
||||||
authkit does not link them to `authkit_roles` or `authkit_permissions`.
|
free-form and not linked to `authkit_roles` / `authkit_permissions`.
|
||||||
|
|
||||||
```go
|
```go
|
||||||
plaintext, key, err := auth.IssueServiceKey(ctx,
|
plaintext, key, err := auth.IssueServiceKey(ctx,
|
||||||
|
|
@ -284,7 +300,7 @@ plaintext, key, err := auth.IssueServiceKey(ctx,
|
||||||
[]string{"events:write"}, nil)
|
[]string{"events:write"}, nil)
|
||||||
|
|
||||||
k, err := auth.AuthenticateServiceKey(ctx, plaintext)
|
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)
|
err = auth.RevokeServiceKey(ctx, plaintext)
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ type Deps struct {
|
||||||
Users UserStore
|
Users UserStore
|
||||||
Sessions SessionStore
|
Sessions SessionStore
|
||||||
Tokens TokenStore
|
Tokens TokenStore
|
||||||
APIKeys APIKeyStore
|
|
||||||
ServiceKeys ServiceKeyStore
|
ServiceKeys ServiceKeyStore
|
||||||
Roles RoleStore
|
Roles RoleStore
|
||||||
Permissions PermissionStore
|
Permissions PermissionStore
|
||||||
|
|
@ -70,8 +69,8 @@ type Auth struct {
|
||||||
// returning an error — these are programmer errors, not runtime ones.
|
// returning an error — these are programmer errors, not runtime ones.
|
||||||
func New(deps Deps, cfg Config) *Auth {
|
func New(deps Deps, cfg Config) *Auth {
|
||||||
if deps.Users == nil || deps.Sessions == nil || deps.Tokens == nil ||
|
if deps.Users == nil || deps.Sessions == nil || deps.Tokens == nil ||
|
||||||
deps.APIKeys == nil || deps.ServiceKeys == nil || deps.Roles == nil ||
|
deps.ServiceKeys == nil || deps.Roles == nil || deps.Permissions == nil ||
|
||||||
deps.Permissions == nil || deps.Hasher == nil {
|
deps.Hasher == nil {
|
||||||
panic(errx.New("authkit.New", "all Deps fields are required"))
|
panic(errx.New("authkit.New", "all Deps fields are required"))
|
||||||
}
|
}
|
||||||
if len(cfg.JWTSecret) == 0 {
|
if len(cfg.JWTSecret) == 0 {
|
||||||
|
|
|
||||||
11
doc.go
11
doc.go
|
|
@ -1,10 +1,11 @@
|
||||||
// Package authkit is an authentication and authorization toolkit for Go web
|
// Package authkit is an authentication and authorization toolkit for Go web
|
||||||
// services. It defines storage interfaces (UserStore, SessionStore, TokenStore,
|
// services. It defines storage interfaces (UserStore, SessionStore, TokenStore,
|
||||||
// APIKeyStore, RoleStore, PermissionStore) and a high-level Auth service that
|
// ServiceKeyStore, RoleStore, PermissionStore) and a high-level Auth service
|
||||||
// composes them to support registration, password login, opaque server-side
|
// that composes them to support registration, password login, opaque
|
||||||
// sessions, JWT access plus rotating refresh tokens, email verification,
|
// server-side sessions, JWT access plus rotating refresh tokens, email
|
||||||
// password resets, magic-link passwordless login, role-based access control,
|
// verification, password resets, magic-link passwordless login, role-based
|
||||||
// and API keys with custom abilities.
|
// 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
|
// Default Postgres implementations of every store live in the pgstore
|
||||||
// subpackage. Argon2id password hashing lives in hasher. Framework-neutral
|
// subpackage. Argon2id password hashing lives in hasher. Framework-neutral
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ var (
|
||||||
ErrTokenInvalid = errors.New("authkit: invalid or expired token")
|
ErrTokenInvalid = errors.New("authkit: invalid or expired token")
|
||||||
ErrTokenReused = errors.New("authkit: token reuse detected")
|
ErrTokenReused = errors.New("authkit: token reuse detected")
|
||||||
ErrSessionInvalid = errors.New("authkit: invalid or expired session")
|
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")
|
ErrServiceKeyInvalid = errors.New("authkit: invalid or expired service key")
|
||||||
ErrPermissionDenied = errors.New("authkit: permission denied")
|
ErrPermissionDenied = errors.New("authkit: permission denied")
|
||||||
ErrRoleNotFound = errors.New("authkit: role not found")
|
ErrRoleNotFound = errors.New("authkit: role not found")
|
||||||
|
|
|
||||||
|
|
@ -262,75 +262,6 @@ func (s *memTokenStore) DeleteExpired(_ context.Context, now time.Time) (int64,
|
||||||
return n, nil
|
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 {
|
type memServiceKeyStore struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
m map[string]*ServiceKey
|
m map[string]*ServiceKey
|
||||||
|
|
@ -663,7 +594,6 @@ func newTestAuth(t interface{ Helper() }) *Auth {
|
||||||
Users: newMemUserStore(),
|
Users: newMemUserStore(),
|
||||||
Sessions: newMemSessionStore(),
|
Sessions: newMemSessionStore(),
|
||||||
Tokens: newMemTokenStore(),
|
Tokens: newMemTokenStore(),
|
||||||
APIKeys: newMemAPIKeyStore(),
|
|
||||||
ServiceKeys: newMemServiceKeyStore(),
|
ServiceKeys: newMemServiceKeyStore(),
|
||||||
Roles: roles,
|
Roles: roles,
|
||||||
Permissions: newMemPermStore(roles),
|
Permissions: newMemPermStore(roles),
|
||||||
|
|
|
||||||
|
|
@ -54,13 +54,26 @@ func RequirePermission(name string, onForbidden ...func(http.ResponseWriter, *ht
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// RequireAbility permits requests whose Principal carries the named ability.
|
// RequireAbility permits requests whose ServiceKey carries the named ability.
|
||||||
// Abilities are populated only for API-key authentication; this middleware
|
// Abilities live only on service tokens — this middleware reads
|
||||||
// will reject session/JWT-authenticated requests by design.
|
// *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 {
|
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 {
|
onForb := firstOrNil(onForbidden)
|
||||||
return p.HasAbility(name)
|
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) {
|
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"
|
"git.juancwu.dev/juancwu/authkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
// principalKey is an unexported context key. Using a distinct empty struct
|
// principalKey and serviceKeyKey are unexported context keys. Using distinct
|
||||||
// type guarantees no collision with caller-defined keys.
|
// empty struct types guarantees no collision with caller-defined keys.
|
||||||
type principalKey struct{}
|
type principalKey struct{}
|
||||||
|
type serviceKeyKey struct{}
|
||||||
|
|
||||||
// withPrincipal stashes p on the request context for downstream handlers.
|
// withPrincipal stashes p on the request context for downstream handlers.
|
||||||
func withPrincipal(ctx context.Context, p *authkit.Principal) context.Context {
|
func withPrincipal(ctx context.Context, p *authkit.Principal) context.Context {
|
||||||
return context.WithValue(ctx, principalKey{}, p)
|
return context.WithValue(ctx, principalKey{}, p)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PrincipalFrom retrieves the authenticated Principal placed by RequireSession,
|
// PrincipalFrom retrieves the authenticated Principal placed by RequireSession
|
||||||
// RequireJWT, or RequireAPIKey. The boolean is false if no auth middleware
|
// or RequireJWT. The boolean is false if no user-bound auth middleware ran for
|
||||||
// ran for this request.
|
// this request (e.g. the request was authenticated via service key instead).
|
||||||
func PrincipalFrom(ctx context.Context) (*authkit.Principal, bool) {
|
func PrincipalFrom(ctx context.Context) (*authkit.Principal, bool) {
|
||||||
p, ok := ctx.Value(principalKey{}).(*authkit.Principal)
|
p, ok := ctx.Value(principalKey{}).(*authkit.Principal)
|
||||||
return p, ok
|
return p, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
// MustPrincipal panics if no Principal is on the context. Use only on
|
// 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 {
|
func MustPrincipal(r *http.Request) *authkit.Principal {
|
||||||
p, ok := PrincipalFrom(r.Context())
|
p, ok := PrincipalFrom(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
@ -37,3 +39,26 @@ func MustPrincipal(r *http.Request) *authkit.Principal {
|
||||||
}
|
}
|
||||||
return p
|
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.
|
// RequireServiceKey authenticates the request via an opaque service token
|
||||||
func RequireAPIKey(opts Options) func(http.Handler) http.Handler {
|
// secret. On success the resolved *authkit.ServiceKey is placed on the
|
||||||
return requireWith(opts, func(r *http.Request, raw string) (*authkit.Principal, error) {
|
// request context; downstream handlers retrieve it via ServiceKeyFrom. Note
|
||||||
return opts.Auth.AuthenticateAPIKey(r.Context(), raw)
|
// 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
|
// RequireAny tries each user-bound method in order until one succeeds. The
|
||||||
// that accept either a session cookie or an API key.
|
// 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 {
|
func RequireAny(opts Options, methods ...authkit.AuthMethod) func(http.Handler) http.Handler {
|
||||||
if len(methods) == 0 {
|
if len(methods) == 0 {
|
||||||
methods = []authkit.AuthMethod{
|
methods = []authkit.AuthMethod{
|
||||||
authkit.AuthMethodSession,
|
authkit.AuthMethodSession,
|
||||||
authkit.AuthMethodJWT,
|
authkit.AuthMethodJWT,
|
||||||
authkit.AuthMethodAPIKey,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return func(next http.Handler) http.Handler {
|
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)
|
p, lastErr = opts.Auth.AuthenticateSession(r.Context(), raw)
|
||||||
case authkit.AuthMethodJWT:
|
case authkit.AuthMethodJWT:
|
||||||
p, lastErr = opts.Auth.AuthenticateJWT(r.Context(), raw)
|
p, lastErr = opts.Auth.AuthenticateJWT(r.Context(), raw)
|
||||||
case authkit.AuthMethodAPIKey:
|
|
||||||
p, lastErr = opts.Auth.AuthenticateAPIKey(r.Context(), raw)
|
|
||||||
}
|
}
|
||||||
if lastErr == nil && p != nil {
|
if lastErr == nil && p != nil {
|
||||||
next.ServeHTTP(w, r.WithContext(withPrincipal(r.Context(), p)))
|
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*
|
// RequireAnyOrServiceKey tries the user-bound methods first (default
|
||||||
// middlewares.
|
// [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 {
|
func requireWith(opts Options, authn func(r *http.Request, raw string) (*authkit.Principal, error)) func(http.Handler) http.Handler {
|
||||||
if opts.Auth == nil {
|
if opts.Auth == nil {
|
||||||
panic("authkit/middleware: Options.Auth is required")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
31
models.go
31
models.go
|
|
@ -49,21 +49,12 @@ type Token struct {
|
||||||
ExpiresAt time.Time
|
ExpiresAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type APIKey struct {
|
// ServiceKey is an owner-agnostic credential for server-to-server auth.
|
||||||
IDHash []byte
|
// OwnerID is not constrained to authkit_users — OwnerKind labels the owner
|
||||||
OwnerID uuid.UUID
|
// namespace (e.g. "application", "tenant") and consumers manage their own
|
||||||
Name string
|
// cascade-on-delete. It is the only credential type that carries free-form
|
||||||
Abilities []string
|
// abilities; user-bound credentials (sessions, JWTs) prove identity and
|
||||||
LastUsedAt *time.Time
|
// resolve permissions through RBAC instead.
|
||||||
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.
|
|
||||||
type ServiceKey struct {
|
type ServiceKey struct {
|
||||||
IDHash []byte
|
IDHash []byte
|
||||||
OwnerID uuid.UUID
|
OwnerID uuid.UUID
|
||||||
|
|
@ -76,6 +67,16 @@ type ServiceKey struct {
|
||||||
RevokedAt *time.Time
|
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 {
|
type Role struct {
|
||||||
ID uuid.UUID
|
ID uuid.UUID
|
||||||
Name string
|
Name string
|
||||||
|
|
|
||||||
16
principal.go
16
principal.go
|
|
@ -11,17 +11,18 @@ type AuthMethod string
|
||||||
const (
|
const (
|
||||||
AuthMethodSession AuthMethod = "session"
|
AuthMethodSession AuthMethod = "session"
|
||||||
AuthMethodJWT AuthMethod = "jwt"
|
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 {
|
type Principal struct {
|
||||||
UserID uuid.UUID
|
UserID uuid.UUID
|
||||||
Method AuthMethod
|
Method AuthMethod
|
||||||
SessionID []byte
|
SessionID []byte
|
||||||
APIKeyID []byte
|
|
||||||
Roles []string
|
Roles []string
|
||||||
Permissions []string
|
Permissions []string
|
||||||
Abilities []string
|
|
||||||
IssuedAt time.Time
|
IssuedAt time.Time
|
||||||
ExpiresAt time.Time
|
ExpiresAt time.Time
|
||||||
}
|
}
|
||||||
|
|
@ -52,12 +53,3 @@ func (p *Principal) HasPermission(name string) bool {
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Principal) HasAbility(name string) bool {
|
|
||||||
for _, a := range p.Abilities {
|
|
||||||
if a == name {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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) {
|
func TestServiceKeyTouchUpdatesLastUsedAt(t *testing.T) {
|
||||||
a := newTestAuth(t)
|
a := newTestAuth(t)
|
||||||
appID := uuid.New()
|
appID := uuid.New()
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestRBACRolesAndPermissions(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
a := newTestAuth(t)
|
a := newTestAuth(t)
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -81,14 +81,6 @@ type Queries struct {
|
||||||
DeleteByChain string
|
DeleteByChain string
|
||||||
DeleteExpiredTokens string
|
DeleteExpiredTokens string
|
||||||
|
|
||||||
// api keys
|
|
||||||
CreateAPIKey string
|
|
||||||
GetAPIKey string
|
|
||||||
ListAPIKeysByOwner string
|
|
||||||
TouchAPIKey string
|
|
||||||
RevokeAPIKey string
|
|
||||||
RevokeAPIKeysByOwner string
|
|
||||||
|
|
||||||
// service keys
|
// service keys
|
||||||
CreateServiceKey string
|
CreateServiceKey string
|
||||||
GetServiceKey string
|
GetServiceKey string
|
||||||
|
|
|
||||||
13
sqlstore/dialect/postgres/migrations/0003_drop_api_keys.sql
Normal file
13
sqlstore/dialect/postgres/migrations/0003_drop_api_keys.sql
Normal file
|
|
@ -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;
|
||||||
|
|
@ -134,20 +134,6 @@ func (Dialect) BuildQueries(s sqlstore.Schema) sqlstore.Queries {
|
||||||
DeleteByChain: `DELETE FROM ` + t.Tokens + ` WHERE chain_id = ?`,
|
DeleteByChain: `DELETE FROM ` + t.Tokens + ` WHERE chain_id = ?`,
|
||||||
DeleteExpiredTokens: `DELETE FROM ` + t.Tokens + ` WHERE expires_at <= ?`,
|
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
|
// service keys
|
||||||
CreateServiceKey: `INSERT INTO ` + t.ServiceKeys + `
|
CreateServiceKey: `INSERT INTO ` + t.ServiceKeys + `
|
||||||
(id_hash, owner_id, owner_kind, name, abilities, last_used_at, created_at, expires_at, revoked_at)
|
(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.DeleteByChain = rebind(q.DeleteByChain)
|
||||||
q.DeleteExpiredTokens = rebind(q.DeleteExpiredTokens)
|
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.CreateServiceKey = rebind(q.CreateServiceKey)
|
||||||
q.GetServiceKey = rebind(q.GetServiceKey)
|
q.GetServiceKey = rebind(q.GetServiceKey)
|
||||||
q.ListServiceKeysByOwner = rebind(q.ListServiceKeysByOwner)
|
q.ListServiceKeysByOwner = rebind(q.ListServiceKeysByOwner)
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ type Tables struct {
|
||||||
Users string
|
Users string
|
||||||
Sessions string
|
Sessions string
|
||||||
Tokens string
|
Tokens string
|
||||||
APIKeys string
|
|
||||||
ServiceKeys string
|
ServiceKeys string
|
||||||
Roles string
|
Roles string
|
||||||
Permissions string
|
Permissions string
|
||||||
|
|
@ -37,7 +36,6 @@ func DefaultSchema() Schema {
|
||||||
Users: "authkit_users",
|
Users: "authkit_users",
|
||||||
Sessions: "authkit_sessions",
|
Sessions: "authkit_sessions",
|
||||||
Tokens: "authkit_tokens",
|
Tokens: "authkit_tokens",
|
||||||
APIKeys: "authkit_api_keys",
|
|
||||||
ServiceKeys: "authkit_service_keys",
|
ServiceKeys: "authkit_service_keys",
|
||||||
Roles: "authkit_roles",
|
Roles: "authkit_roles",
|
||||||
Permissions: "authkit_permissions",
|
Permissions: "authkit_permissions",
|
||||||
|
|
@ -61,7 +59,6 @@ func (s Schema) Validate() error {
|
||||||
{"Users", s.Tables.Users},
|
{"Users", s.Tables.Users},
|
||||||
{"Sessions", s.Tables.Sessions},
|
{"Sessions", s.Tables.Sessions},
|
||||||
{"Tokens", s.Tables.Tokens},
|
{"Tokens", s.Tables.Tokens},
|
||||||
{"APIKeys", s.Tables.APIKeys},
|
|
||||||
{"ServiceKeys", s.Tables.ServiceKeys},
|
{"ServiceKeys", s.Tables.ServiceKeys},
|
||||||
{"Roles", s.Tables.Roles},
|
{"Roles", s.Tables.Roles},
|
||||||
{"Permissions", s.Tables.Permissions},
|
{"Permissions", s.Tables.Permissions},
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ type Stores struct {
|
||||||
Users authkit.UserStore
|
Users authkit.UserStore
|
||||||
Sessions authkit.SessionStore
|
Sessions authkit.SessionStore
|
||||||
Tokens authkit.TokenStore
|
Tokens authkit.TokenStore
|
||||||
APIKeys authkit.APIKeyStore
|
|
||||||
ServiceKeys authkit.ServiceKeyStore
|
ServiceKeys authkit.ServiceKeyStore
|
||||||
Roles authkit.RoleStore
|
Roles authkit.RoleStore
|
||||||
Permissions authkit.PermissionStore
|
Permissions authkit.PermissionStore
|
||||||
|
|
@ -44,7 +43,6 @@ func New(db *sql.DB, dialect Dialect, schema Schema) (*Stores, error) {
|
||||||
Users: &userStore{storeBase: base},
|
Users: &userStore{storeBase: base},
|
||||||
Sessions: &sessionStore{storeBase: base},
|
Sessions: &sessionStore{storeBase: base},
|
||||||
Tokens: &tokenStore{storeBase: base},
|
Tokens: &tokenStore{storeBase: base},
|
||||||
APIKeys: &apiKeyStore{storeBase: base},
|
|
||||||
ServiceKeys: &serviceKeyStore{storeBase: base},
|
ServiceKeys: &serviceKeyStore{storeBase: base},
|
||||||
Roles: &roleStore{storeBase: base},
|
Roles: &roleStore{storeBase: base},
|
||||||
Permissions: &permissionStore{storeBase: base},
|
Permissions: &permissionStore{storeBase: base},
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,6 @@ func freshDB(t *testing.T) (*authkit.Auth, *sql.DB, sqlstore.Schema) {
|
||||||
Users: stores.Users,
|
Users: stores.Users,
|
||||||
Sessions: stores.Sessions,
|
Sessions: stores.Sessions,
|
||||||
Tokens: stores.Tokens,
|
Tokens: stores.Tokens,
|
||||||
APIKeys: stores.APIKeys,
|
|
||||||
ServiceKeys: stores.ServiceKeys,
|
ServiceKeys: stores.ServiceKeys,
|
||||||
Roles: stores.Roles,
|
Roles: stores.Roles,
|
||||||
Permissions: stores.Permissions,
|
Permissions: stores.Permissions,
|
||||||
|
|
@ -90,7 +89,7 @@ func dropAuthkitTables(t *testing.T, db *sql.DB, s sqlstore.Schema) {
|
||||||
tables := []string{
|
tables := []string{
|
||||||
s.Tables.UserRoles, s.Tables.RolePermissions,
|
s.Tables.UserRoles, s.Tables.RolePermissions,
|
||||||
s.Tables.Roles, s.Tables.Permissions,
|
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.Sessions, s.Tables.Users,
|
||||||
s.Tables.SchemaMigrations,
|
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) {
|
func TestIntegration_ServiceKeyFlow(t *testing.T) {
|
||||||
auth, _, _ := freshDB(t)
|
auth, _, _ := freshDB(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
|
||||||
|
|
@ -44,15 +44,6 @@ type TokenStore interface {
|
||||||
DeleteExpired(ctx context.Context, now time.Time) (int64, error)
|
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 {
|
type ServiceKeyStore interface {
|
||||||
CreateServiceKey(ctx context.Context, k *ServiceKey) error
|
CreateServiceKey(ctx context.Context, k *ServiceKey) error
|
||||||
GetServiceKey(ctx context.Context, idHash []byte) (*ServiceKey, error)
|
GetServiceKey(ctx context.Context, idHash []byte) (*ServiceKey, error)
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ const secretRandomBytes = 32
|
||||||
const (
|
const (
|
||||||
prefixSession = "sess"
|
prefixSession = "sess"
|
||||||
prefixRefresh = "rfr"
|
prefixRefresh = "rfr"
|
||||||
prefixAPIKey = "ak"
|
|
||||||
prefixServiceKey = "sk"
|
prefixServiceKey = "sk"
|
||||||
prefixEmailVerify = "evr"
|
prefixEmailVerify = "evr"
|
||||||
prefixPasswordRset = "pwr"
|
prefixPasswordRset = "pwr"
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ func TestParseSecretWrongPrefix(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("mintSecret: %v", err)
|
t.Fatalf("mintSecret: %v", err)
|
||||||
}
|
}
|
||||||
if _, ok := parseSecret(prefixAPIKey, plaintext); ok {
|
if _, ok := parseSecret(prefixServiceKey, plaintext); ok {
|
||||||
t.Fatalf("parseSecret should reject mismatched prefix")
|
t.Fatalf("parseSecret should reject mismatched prefix")
|
||||||
}
|
}
|
||||||
if _, ok := parseSecret(prefixSession, "sessXXXX"); ok {
|
if _, ok := parseSecret(prefixSession, "sessXXXX"); ok {
|
||||||
|
|
@ -44,7 +44,7 @@ func TestParseSecretWrongPrefix(t *testing.T) {
|
||||||
func TestMintSecretUniqueness(t *testing.T) {
|
func TestMintSecretUniqueness(t *testing.T) {
|
||||||
seen := make(map[string]struct{}, 100)
|
seen := make(map[string]struct{}, 100)
|
||||||
for i := 0; i < 100; i++ {
|
for i := 0; i < 100; i++ {
|
||||||
p, _, err := mintSecret(prefixAPIKey, nil)
|
p, _, err := mintSecret(prefixServiceKey, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("mintSecret: %v", err)
|
t.Fatalf("mintSecret: %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue