Add owner-agnostic service tokens
Introduces ServiceKey, a parallel primitive to APIKey for server-to-server auth where the owner is not an authkit user (e.g. an application or tenant row the consumer manages). owner_id has no FK and no RBAC linkage; cascade on owner-delete is the consumer's responsibility. AuthenticateServiceKey returns *ServiceKey directly rather than *Principal since service tokens have no user. Also exports MintOpaqueSecret / HashOpaqueSecret / ParseOpaqueSecret so both API-key and service-key code share one mint/parse implementation instead of duplicating it. Deps.ServiceKeys is required (panics in New if nil) — existing call sites must add ServiceKeys: stores.ServiceKeys. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9aae7b1c12
commit
4942e4dbdc
16 changed files with 664 additions and 24 deletions
60
README.md
60
README.md
|
|
@ -38,6 +38,7 @@ PostgreSQL 12+ is sufficient — the schema avoids `gen_random_uuid()` and
|
||||||
**Authorization**
|
**Authorization**
|
||||||
- Roles and permissions with many-to-many wiring
|
- Roles and permissions with many-to-many wiring
|
||||||
- API keys with custom abilities for per-endpoint scoping
|
- API keys with custom abilities for per-endpoint scoping
|
||||||
|
- Owner-agnostic service tokens for server-to-server auth (no FK on owner)
|
||||||
- A unified `Principal` type so middleware works the same regardless of which
|
- A unified `Principal` type so middleware works the same regardless of which
|
||||||
authentication method ran
|
authentication method ran
|
||||||
|
|
||||||
|
|
@ -117,6 +118,7 @@ auth := authkit.New(authkit.Deps{
|
||||||
Sessions: stores.Sessions,
|
Sessions: stores.Sessions,
|
||||||
Tokens: stores.Tokens,
|
Tokens: stores.Tokens,
|
||||||
APIKeys: stores.APIKeys,
|
APIKeys: stores.APIKeys,
|
||||||
|
ServiceKeys: stores.ServiceKeys,
|
||||||
Roles: stores.Roles,
|
Roles: stores.Roles,
|
||||||
Permissions: stores.Permissions,
|
Permissions: stores.Permissions,
|
||||||
Hasher: hasher.NewArgon2id(hasher.DefaultArgon2idParams(), nil),
|
Hasher: hasher.NewArgon2id(hasher.DefaultArgon2idParams(), nil),
|
||||||
|
|
@ -130,8 +132,9 @@ 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 the seven `Deps` fields are
|
password-reset, 15m magic-link). `JWTSecret` and all eight `Deps` fields
|
||||||
required; `New` panics on a misconfiguration.
|
(including the new `ServiceKeys` field added in v0.2.0) are required; `New`
|
||||||
|
panics on a misconfiguration.
|
||||||
|
|
||||||
### 3. Use the service
|
### 3. Use the service
|
||||||
|
|
||||||
|
|
@ -148,10 +151,18 @@ 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
|
// API key with abilities (user-owned)
|
||||||
plaintext, key, err := auth.IssueAPIKey(ctx, u.ID, "ci",
|
plaintext, key, err := auth.IssueAPIKey(ctx, u.ID, "ci",
|
||||||
[]string{"billing:read", "users:list"}, nil)
|
[]string{"billing:read", "users:list"}, nil)
|
||||||
|
|
||||||
|
// Service token (owner-agnostic; ownerKind labels the namespace)
|
||||||
|
plaintext, sk, err := auth.IssueServiceKey(ctx,
|
||||||
|
"application", appID, "events-ingest",
|
||||||
|
[]string{"events:write"}, nil)
|
||||||
|
got, err := auth.AuthenticateServiceKey(ctx, plaintext)
|
||||||
|
// got.OwnerKind == "application"; got.OwnerID == appID
|
||||||
|
err = auth.RevokeServiceKey(ctx, plaintext)
|
||||||
|
|
||||||
// Email verification + password reset + magic link
|
// Email verification + password reset + magic link
|
||||||
tok, err := auth.RequestEmailVerification(ctx, u.ID)
|
tok, err := auth.RequestEmailVerification(ctx, u.ID)
|
||||||
_, err = auth.ConfirmEmail(ctx, tok)
|
_, err = auth.ConfirmEmail(ctx, tok)
|
||||||
|
|
@ -233,8 +244,8 @@ each table. Adding column overrides later is purely additive.
|
||||||
|
|
||||||
### Secret token format
|
### Secret token format
|
||||||
|
|
||||||
Sessions, refresh tokens, API keys, email-verify tokens, password-reset tokens,
|
Sessions, refresh tokens, API keys, service tokens, email-verify tokens,
|
||||||
and magic-link tokens all share one format:
|
password-reset tokens, and magic-link tokens all share one format:
|
||||||
|
|
||||||
```
|
```
|
||||||
plaintext = "<prefix>_" + base64url(32 random bytes, no padding)
|
plaintext = "<prefix>_" + base64url(32 random bytes, no padding)
|
||||||
|
|
@ -243,7 +254,44 @@ lookup = sha256(plaintext)
|
||||||
|
|
||||||
Plaintext is returned to the caller exactly once and never persisted; the
|
Plaintext is returned to the caller exactly once and never persisted; the
|
||||||
SHA-256 is the database lookup key. Random bytes come from `crypto/rand` (or
|
SHA-256 is the database lookup key. Random bytes come from `crypto/rand` (or
|
||||||
`Config.Random` for tests).
|
`Config.Random` for tests). The mint/parse/hash helpers are exported as
|
||||||
|
`MintOpaqueSecret`, `ParseOpaqueSecret`, and `HashOpaqueSecret` for callers
|
||||||
|
building bespoke token storage on top of the same shape.
|
||||||
|
|
||||||
|
### Service tokens vs. API keys
|
||||||
|
|
||||||
|
Both produce opaque `<prefix>_<base64>` secrets, but they target different
|
||||||
|
use cases.
|
||||||
|
|
||||||
|
`IssueAPIKey` is for **user-owned credentials**. The owner is a row in
|
||||||
|
`authkit_users`; deleting that user cascades to the key (`ON DELETE CASCADE`),
|
||||||
|
and `AuthenticateAPIKey` returns a `*Principal` carrying the user's roles
|
||||||
|
and permissions resolved through RBAC.
|
||||||
|
|
||||||
|
`IssueServiceKey` is for **server-to-server credentials** whose owner is
|
||||||
|
something authkit knows nothing about — an `application` row, a `tenant`,
|
||||||
|
whatever. `OwnerKind` labels the namespace and `OwnerID` identifies the
|
||||||
|
entity within it; the database column has **no foreign key** on purpose, so
|
||||||
|
cascade-on-delete is the consumer's responsibility. `AuthenticateServiceKey`
|
||||||
|
returns the `*ServiceKey` directly (no `*Principal`, no role/permission
|
||||||
|
resolution): service tokens have no user, so the `Principal` abstraction
|
||||||
|
does not fit. Abilities (`[]string`) on a service token are free-form —
|
||||||
|
authkit does not link them to `authkit_roles` or `authkit_permissions`.
|
||||||
|
|
||||||
|
```go
|
||||||
|
plaintext, key, err := auth.IssueServiceKey(ctx,
|
||||||
|
"application", appID, "events-ingest",
|
||||||
|
[]string{"events:write"}, nil)
|
||||||
|
|
||||||
|
k, err := auth.AuthenticateServiceKey(ctx, plaintext)
|
||||||
|
// k.OwnerKind == "application"; k.OwnerID == appID
|
||||||
|
|
||||||
|
err = auth.RevokeServiceKey(ctx, plaintext)
|
||||||
|
```
|
||||||
|
|
||||||
|
When a consumer-owned entity (an application, a tenant) is deleted, the
|
||||||
|
consumer must revoke the associated service tokens itself — typically by
|
||||||
|
iterating `ListServiceKeys(ctx, ownerKind, ownerID)`.
|
||||||
|
|
||||||
### JWT revocation
|
### JWT revocation
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ type Deps struct {
|
||||||
Sessions SessionStore
|
Sessions SessionStore
|
||||||
Tokens TokenStore
|
Tokens TokenStore
|
||||||
APIKeys APIKeyStore
|
APIKeys APIKeyStore
|
||||||
|
ServiceKeys ServiceKeyStore
|
||||||
Roles RoleStore
|
Roles RoleStore
|
||||||
Permissions PermissionStore
|
Permissions PermissionStore
|
||||||
Hasher Hasher
|
Hasher Hasher
|
||||||
|
|
@ -69,8 +70,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.Roles == nil || deps.Permissions == nil ||
|
deps.APIKeys == nil || deps.ServiceKeys == nil || deps.Roles == nil ||
|
||||||
deps.Hasher == nil {
|
deps.Permissions == 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,6 +11,7 @@ var (
|
||||||
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")
|
ErrAPIKeyInvalid = errors.New("authkit: invalid or expired api 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")
|
||||||
ErrPermissionNotFound = errors.New("authkit: permission not found")
|
ErrPermissionNotFound = errors.New("authkit: permission not found")
|
||||||
|
|
|
||||||
|
|
@ -331,6 +331,68 @@ func (s *memAPIKeyStore) RevokeAPIKeysByOwner(_ context.Context, owner uuid.UUID
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type memServiceKeyStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
m map[string]*ServiceKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMemServiceKeyStore() *memServiceKeyStore {
|
||||||
|
return &memServiceKeyStore{m: map[string]*ServiceKey{}}
|
||||||
|
}
|
||||||
|
func (s *memServiceKeyStore) CreateServiceKey(_ context.Context, k *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) (*ServiceKey, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
k, ok := s.m[string(h)]
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrServiceKeyInvalid
|
||||||
|
}
|
||||||
|
cp := *k
|
||||||
|
cp.Abilities = append([]string(nil), k.Abilities...)
|
||||||
|
return &cp, nil
|
||||||
|
}
|
||||||
|
func (s *memServiceKeyStore) ListServiceKeysByOwner(_ context.Context, ownerKind string, owner uuid.UUID) ([]*ServiceKey, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
var out []*ServiceKey
|
||||||
|
for _, k := range s.m {
|
||||||
|
if k.OwnerKind == ownerKind && 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 ErrServiceKeyInvalid
|
||||||
|
}
|
||||||
|
if k.RevokedAt != nil {
|
||||||
|
return ErrServiceKeyInvalid
|
||||||
|
}
|
||||||
|
k.RevokedAt = &at
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type memRoleStore struct {
|
type memRoleStore struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
roles map[uuid.UUID]*Role
|
roles map[uuid.UUID]*Role
|
||||||
|
|
@ -602,6 +664,7 @@ func newTestAuth(t interface{ Helper() }) *Auth {
|
||||||
Sessions: newMemSessionStore(),
|
Sessions: newMemSessionStore(),
|
||||||
Tokens: newMemTokenStore(),
|
Tokens: newMemTokenStore(),
|
||||||
APIKeys: newMemAPIKeyStore(),
|
APIKeys: newMemAPIKeyStore(),
|
||||||
|
ServiceKeys: newMemServiceKeyStore(),
|
||||||
Roles: roles,
|
Roles: roles,
|
||||||
Permissions: newMemPermStore(roles),
|
Permissions: newMemPermStore(roles),
|
||||||
Hasher: stubHasher{},
|
Hasher: stubHasher{},
|
||||||
|
|
|
||||||
16
models.go
16
models.go
|
|
@ -60,6 +60,22 @@ type APIKey struct {
|
||||||
RevokedAt *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 {
|
||||||
|
IDHash []byte
|
||||||
|
OwnerID uuid.UUID
|
||||||
|
OwnerKind string
|
||||||
|
Name string
|
||||||
|
Abilities []string
|
||||||
|
LastUsedAt *time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
ExpiresAt *time.Time
|
||||||
|
RevokedAt *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
type Role struct {
|
type Role struct {
|
||||||
ID uuid.UUID
|
ID uuid.UUID
|
||||||
Name string
|
Name string
|
||||||
|
|
|
||||||
92
service_service_key.go
Normal file
92
service_service_key.go
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
package authkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.juancwu.dev/juancwu/errx"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IssueServiceKey mints a fresh owner-agnostic service token. ownerKind is a
|
||||||
|
// consumer-defined namespace label (e.g. "application", "tenant") and ownerID
|
||||||
|
// is the owning entity's id; authkit makes no assumption about either. The
|
||||||
|
// plaintext is returned (show-once) and the SHA-256 lookup hash is stored.
|
||||||
|
// Pass ttl=nil for a non-expiring key.
|
||||||
|
func (a *Auth) IssueServiceKey(ctx context.Context, ownerKind string, ownerID uuid.UUID, name string, abilities []string, ttl *time.Duration) (string, *ServiceKey, error) {
|
||||||
|
const op = "authkit.Auth.IssueServiceKey"
|
||||||
|
plaintext, hash, err := MintOpaqueSecret(a.cfg.Random, prefixServiceKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, errx.Wrap(op, err)
|
||||||
|
}
|
||||||
|
now := a.now()
|
||||||
|
k := &ServiceKey{
|
||||||
|
IDHash: hash,
|
||||||
|
OwnerID: ownerID,
|
||||||
|
OwnerKind: ownerKind,
|
||||||
|
Name: name,
|
||||||
|
Abilities: append([]string(nil), abilities...),
|
||||||
|
CreatedAt: now,
|
||||||
|
}
|
||||||
|
if ttl != nil {
|
||||||
|
exp := now.Add(*ttl)
|
||||||
|
k.ExpiresAt = &exp
|
||||||
|
}
|
||||||
|
if err := a.deps.ServiceKeys.CreateServiceKey(ctx, k); err != nil {
|
||||||
|
return "", nil, errx.Wrap(op, err)
|
||||||
|
}
|
||||||
|
return plaintext, k, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthenticateServiceKey validates a service token, touches last_used_at
|
||||||
|
// (best-effort), and returns the stored *ServiceKey. Unlike API keys, no
|
||||||
|
// Principal is returned — service tokens have no owning user, so the
|
||||||
|
// Principal abstraction does not fit. Consumers needing a Principal can
|
||||||
|
// build one from the returned key.
|
||||||
|
func (a *Auth) AuthenticateServiceKey(ctx context.Context, plaintext string) (*ServiceKey, error) {
|
||||||
|
const op = "authkit.Auth.AuthenticateServiceKey"
|
||||||
|
hash, ok := ParseOpaqueSecret(prefixServiceKey, plaintext)
|
||||||
|
if !ok {
|
||||||
|
return nil, errx.Wrap(op, ErrServiceKeyInvalid)
|
||||||
|
}
|
||||||
|
k, err := a.deps.ServiceKeys.GetServiceKey(ctx, hash)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errx.Wrap(op, err)
|
||||||
|
}
|
||||||
|
now := a.now()
|
||||||
|
if k.RevokedAt != nil {
|
||||||
|
return nil, errx.Wrap(op, ErrServiceKeyInvalid)
|
||||||
|
}
|
||||||
|
if k.ExpiresAt != nil && !k.ExpiresAt.After(now) {
|
||||||
|
return nil, errx.Wrap(op, ErrServiceKeyInvalid)
|
||||||
|
}
|
||||||
|
_ = a.deps.ServiceKeys.TouchServiceKey(ctx, hash, now)
|
||||||
|
out := *k
|
||||||
|
out.Abilities = append([]string(nil), k.Abilities...)
|
||||||
|
return &out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeServiceKey marks a service token revoked. Idempotent on
|
||||||
|
// already-revoked keys.
|
||||||
|
func (a *Auth) RevokeServiceKey(ctx context.Context, plaintext string) error {
|
||||||
|
const op = "authkit.Auth.RevokeServiceKey"
|
||||||
|
hash, ok := ParseOpaqueSecret(prefixServiceKey, plaintext)
|
||||||
|
if !ok {
|
||||||
|
return errx.Wrap(op, ErrServiceKeyInvalid)
|
||||||
|
}
|
||||||
|
if err := a.deps.ServiceKeys.RevokeServiceKey(ctx, hash, a.now()); err != nil {
|
||||||
|
return errx.Wrap(op, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListServiceKeys returns every service token issued for the given
|
||||||
|
// (ownerKind, ownerID) pair, including revoked and expired keys.
|
||||||
|
func (a *Auth) ListServiceKeys(ctx context.Context, ownerKind string, ownerID uuid.UUID) ([]*ServiceKey, error) {
|
||||||
|
const op = "authkit.Auth.ListServiceKeys"
|
||||||
|
out, err := a.deps.ServiceKeys.ListServiceKeysByOwner(ctx, ownerKind, ownerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errx.Wrap(op, err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
167
service_service_key_test.go
Normal file
167
service_service_key_test.go
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
package authkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestServiceKeyRoundtrip(t *testing.T) {
|
||||||
|
a := newTestAuth(t)
|
||||||
|
appID := uuid.New()
|
||||||
|
plaintext, k, err := a.IssueServiceKey(context.Background(),
|
||||||
|
"application", appID, "events-ingest",
|
||||||
|
[]string{"events:write", "events:read"}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IssueServiceKey: %v", err)
|
||||||
|
}
|
||||||
|
if plaintext == "" || k == nil {
|
||||||
|
t.Fatalf("missing plaintext or key")
|
||||||
|
}
|
||||||
|
got, err := a.AuthenticateServiceKey(context.Background(), plaintext)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AuthenticateServiceKey: %v", err)
|
||||||
|
}
|
||||||
|
if got.OwnerKind != "application" || got.OwnerID != appID {
|
||||||
|
t.Fatalf("owner mismatch: kind=%q id=%v", got.OwnerKind, got.OwnerID)
|
||||||
|
}
|
||||||
|
if got.Name != "events-ingest" {
|
||||||
|
t.Fatalf("name mismatch: %q", got.Name)
|
||||||
|
}
|
||||||
|
if len(got.Abilities) != 2 || got.Abilities[0] != "events:write" || got.Abilities[1] != "events:read" {
|
||||||
|
t.Fatalf("abilities mismatch: %+v", got.Abilities)
|
||||||
|
}
|
||||||
|
got.Abilities[0] = "tampered"
|
||||||
|
again, err := a.AuthenticateServiceKey(context.Background(), plaintext)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AuthenticateServiceKey (re-auth): %v", err)
|
||||||
|
}
|
||||||
|
if again.Abilities[0] != "events:write" {
|
||||||
|
t.Fatalf("returned slice was not deep-copied; saw mutation: %+v", again.Abilities)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceKeyPlaintextShape(t *testing.T) {
|
||||||
|
a := newTestAuth(t)
|
||||||
|
plaintext, _, err := a.IssueServiceKey(context.Background(),
|
||||||
|
"application", uuid.New(), "name", nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IssueServiceKey: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(plaintext, "sk_") {
|
||||||
|
t.Fatalf("plaintext missing sk_ prefix: %q", plaintext)
|
||||||
|
}
|
||||||
|
body := strings.TrimPrefix(plaintext, "sk_")
|
||||||
|
raw, err := base64.RawURLEncoding.DecodeString(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("base64 decode: %v", err)
|
||||||
|
}
|
||||||
|
if len(raw) != 32 {
|
||||||
|
t.Fatalf("body decoded to %d bytes, want 32", len(raw))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceKeyWrongPrefix(t *testing.T) {
|
||||||
|
a := newTestAuth(t)
|
||||||
|
_, err := a.AuthenticateServiceKey(context.Background(), "ak_not-a-service-key")
|
||||||
|
if !errors.Is(err, ErrServiceKeyInvalid) {
|
||||||
|
t.Fatalf("expected ErrServiceKeyInvalid for wrong prefix, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceKeyAfterRevoke(t *testing.T) {
|
||||||
|
a := newTestAuth(t)
|
||||||
|
plaintext, _, 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(), plaintext); err != nil {
|
||||||
|
t.Fatalf("RevokeServiceKey: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := a.AuthenticateServiceKey(context.Background(), plaintext); !errors.Is(err, ErrServiceKeyInvalid) {
|
||||||
|
t.Fatalf("expected ErrServiceKeyInvalid post-revoke, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceKeyAfterExpiry(t *testing.T) {
|
||||||
|
a := newTestAuth(t)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
a.cfg.Clock = func() time.Time { return now }
|
||||||
|
ttl := time.Minute
|
||||||
|
plaintext, _, err := a.IssueServiceKey(context.Background(),
|
||||||
|
"application", uuid.New(), "ephemeral", nil, &ttl)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IssueServiceKey: %v", err)
|
||||||
|
}
|
||||||
|
a.cfg.Clock = func() time.Time { return now.Add(2 * time.Minute) }
|
||||||
|
if _, err := a.AuthenticateServiceKey(context.Background(), plaintext); !errors.Is(err, ErrServiceKeyInvalid) {
|
||||||
|
t.Fatalf("expected ErrServiceKeyInvalid post-expiry, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceKeyListByOwner(t *testing.T) {
|
||||||
|
a := newTestAuth(t)
|
||||||
|
appA := uuid.New()
|
||||||
|
appB := uuid.New()
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
if _, _, err := a.IssueServiceKey(context.Background(), "application", appA, "k", nil, nil); err != nil {
|
||||||
|
t.Fatalf("Issue appA #%d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, _, err := a.IssueServiceKey(context.Background(), "application", appB, "k", nil, nil); err != nil {
|
||||||
|
t.Fatalf("Issue appB: %v", err)
|
||||||
|
}
|
||||||
|
gotA, err := a.ListServiceKeys(context.Background(), "application", appA)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListServiceKeys appA: %v", err)
|
||||||
|
}
|
||||||
|
if len(gotA) != 2 {
|
||||||
|
t.Fatalf("ListServiceKeys appA = %d keys, want 2", len(gotA))
|
||||||
|
}
|
||||||
|
gotB, err := a.ListServiceKeys(context.Background(), "application", appB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListServiceKeys appB: %v", err)
|
||||||
|
}
|
||||||
|
if len(gotB) != 1 {
|
||||||
|
t.Fatalf("ListServiceKeys appB = %d keys, want 1", len(gotB))
|
||||||
|
}
|
||||||
|
gotTenantA, err := a.ListServiceKeys(context.Background(), "tenant", appA)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListServiceKeys tenant/appA: %v", err)
|
||||||
|
}
|
||||||
|
if len(gotTenantA) != 0 {
|
||||||
|
t.Fatalf("ListServiceKeys tenant/appA = %d, want 0 (different owner_kind)", len(gotTenantA))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceKeyTouchUpdatesLastUsedAt(t *testing.T) {
|
||||||
|
a := newTestAuth(t)
|
||||||
|
appID := uuid.New()
|
||||||
|
plaintext, _, err := a.IssueServiceKey(context.Background(), "application", appID, "k", nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IssueServiceKey: %v", err)
|
||||||
|
}
|
||||||
|
keys, err := a.ListServiceKeys(context.Background(), "application", appID)
|
||||||
|
if err != nil || len(keys) != 1 {
|
||||||
|
t.Fatalf("pre-touch list: err=%v len=%d", err, len(keys))
|
||||||
|
}
|
||||||
|
if keys[0].LastUsedAt != nil {
|
||||||
|
t.Fatalf("expected LastUsedAt=nil before authenticate, got %v", *keys[0].LastUsedAt)
|
||||||
|
}
|
||||||
|
if _, err := a.AuthenticateServiceKey(context.Background(), plaintext); err != nil {
|
||||||
|
t.Fatalf("AuthenticateServiceKey: %v", err)
|
||||||
|
}
|
||||||
|
keys, err = a.ListServiceKeys(context.Background(), "application", appID)
|
||||||
|
if err != nil || len(keys) != 1 {
|
||||||
|
t.Fatalf("post-touch list: err=%v len=%d", err, len(keys))
|
||||||
|
}
|
||||||
|
if keys[0].LastUsedAt == nil {
|
||||||
|
t.Fatalf("expected LastUsedAt to be set after authenticate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -89,6 +89,13 @@ type Queries struct {
|
||||||
RevokeAPIKey string
|
RevokeAPIKey string
|
||||||
RevokeAPIKeysByOwner string
|
RevokeAPIKeysByOwner string
|
||||||
|
|
||||||
|
// service keys
|
||||||
|
CreateServiceKey string
|
||||||
|
GetServiceKey string
|
||||||
|
ListServiceKeysByOwner string
|
||||||
|
TouchServiceKey string
|
||||||
|
RevokeServiceKey string
|
||||||
|
|
||||||
// roles
|
// roles
|
||||||
CreateRole string
|
CreateRole string
|
||||||
GetRoleByID string
|
GetRoleByID string
|
||||||
|
|
|
||||||
27
sqlstore/dialect/postgres/migrations/0002_service_keys.sql
Normal file
27
sqlstore/dialect/postgres/migrations/0002_service_keys.sql
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
-- 0002_service_keys.sql
|
||||||
|
-- Adds owner-agnostic service tokens. Unlike authkit_api_keys, owner_id is
|
||||||
|
-- intentionally NOT FK-constrained: consumers manage their own cascades, and
|
||||||
|
-- authkit has no opinion on what "owner" means here (application id, tenant
|
||||||
|
-- id, etc.).
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS authkit_service_keys (
|
||||||
|
id_hash BYTEA PRIMARY KEY,
|
||||||
|
owner_id UUID NOT NULL,
|
||||||
|
owner_kind TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
abilities JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
last_used_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL,
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
revoked_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS authkit_service_keys_owner_idx
|
||||||
|
ON authkit_service_keys(owner_kind, owner_id);
|
||||||
|
|
||||||
|
INSERT INTO authkit_schema_migrations (version, applied_at) VALUES ('0002_service_keys', now())
|
||||||
|
ON CONFLICT (version) DO NOTHING;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -148,6 +148,19 @@ func (Dialect) BuildQueries(s sqlstore.Schema) sqlstore.Queries {
|
||||||
RevokeAPIKey: `UPDATE ` + t.APIKeys + ` SET revoked_at = ? WHERE id_hash = ? AND revoked_at IS NULL`,
|
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`,
|
RevokeAPIKeysByOwner: `UPDATE ` + t.APIKeys + ` SET revoked_at = ? WHERE owner_id = ? AND revoked_at IS NULL`,
|
||||||
|
|
||||||
|
// service keys
|
||||||
|
CreateServiceKey: `INSERT INTO ` + t.ServiceKeys + `
|
||||||
|
(id_hash, owner_id, owner_kind, name, abilities, last_used_at, created_at, expires_at, revoked_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
GetServiceKey: `SELECT id_hash, owner_id, owner_kind, name, abilities, last_used_at,
|
||||||
|
created_at, expires_at, revoked_at
|
||||||
|
FROM ` + t.ServiceKeys + ` WHERE id_hash = ?`,
|
||||||
|
ListServiceKeysByOwner: `SELECT id_hash, owner_id, owner_kind, name, abilities, last_used_at,
|
||||||
|
created_at, expires_at, revoked_at
|
||||||
|
FROM ` + t.ServiceKeys + ` WHERE owner_kind = ? AND owner_id = ? ORDER BY created_at DESC`,
|
||||||
|
TouchServiceKey: `UPDATE ` + t.ServiceKeys + ` SET last_used_at = ? WHERE id_hash = ?`,
|
||||||
|
RevokeServiceKey: `UPDATE ` + t.ServiceKeys + ` SET revoked_at = ? WHERE id_hash = ? AND revoked_at IS NULL`,
|
||||||
|
|
||||||
// roles
|
// roles
|
||||||
CreateRole: `INSERT INTO ` + t.Roles + ` (id, name, description, created_at) VALUES (?, ?, ?, ?)`,
|
CreateRole: `INSERT INTO ` + t.Roles + ` (id, name, description, created_at) VALUES (?, ?, ?, ?)`,
|
||||||
GetRoleByID: `SELECT id, name, description, created_at FROM ` + t.Roles + ` WHERE id = ?`,
|
GetRoleByID: `SELECT id, name, description, created_at FROM ` + t.Roles + ` WHERE id = ?`,
|
||||||
|
|
@ -222,6 +235,12 @@ func (Dialect) BuildQueries(s sqlstore.Schema) sqlstore.Queries {
|
||||||
q.RevokeAPIKey = rebind(q.RevokeAPIKey)
|
q.RevokeAPIKey = rebind(q.RevokeAPIKey)
|
||||||
q.RevokeAPIKeysByOwner = rebind(q.RevokeAPIKeysByOwner)
|
q.RevokeAPIKeysByOwner = rebind(q.RevokeAPIKeysByOwner)
|
||||||
|
|
||||||
|
q.CreateServiceKey = rebind(q.CreateServiceKey)
|
||||||
|
q.GetServiceKey = rebind(q.GetServiceKey)
|
||||||
|
q.ListServiceKeysByOwner = rebind(q.ListServiceKeysByOwner)
|
||||||
|
q.TouchServiceKey = rebind(q.TouchServiceKey)
|
||||||
|
q.RevokeServiceKey = rebind(q.RevokeServiceKey)
|
||||||
|
|
||||||
q.CreateRole = rebind(q.CreateRole)
|
q.CreateRole = rebind(q.CreateRole)
|
||||||
q.GetRoleByID = rebind(q.GetRoleByID)
|
q.GetRoleByID = rebind(q.GetRoleByID)
|
||||||
q.GetRoleByName = rebind(q.GetRoleByName)
|
q.GetRoleByName = rebind(q.GetRoleByName)
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ type Tables struct {
|
||||||
Sessions string
|
Sessions string
|
||||||
Tokens string
|
Tokens string
|
||||||
APIKeys string
|
APIKeys string
|
||||||
|
ServiceKeys string
|
||||||
Roles string
|
Roles string
|
||||||
Permissions string
|
Permissions string
|
||||||
UserRoles string
|
UserRoles string
|
||||||
|
|
@ -37,6 +38,7 @@ func DefaultSchema() Schema {
|
||||||
Sessions: "authkit_sessions",
|
Sessions: "authkit_sessions",
|
||||||
Tokens: "authkit_tokens",
|
Tokens: "authkit_tokens",
|
||||||
APIKeys: "authkit_api_keys",
|
APIKeys: "authkit_api_keys",
|
||||||
|
ServiceKeys: "authkit_service_keys",
|
||||||
Roles: "authkit_roles",
|
Roles: "authkit_roles",
|
||||||
Permissions: "authkit_permissions",
|
Permissions: "authkit_permissions",
|
||||||
UserRoles: "authkit_user_roles",
|
UserRoles: "authkit_user_roles",
|
||||||
|
|
@ -60,6 +62,7 @@ func (s Schema) Validate() error {
|
||||||
{"Sessions", s.Tables.Sessions},
|
{"Sessions", s.Tables.Sessions},
|
||||||
{"Tokens", s.Tables.Tokens},
|
{"Tokens", s.Tables.Tokens},
|
||||||
{"APIKeys", s.Tables.APIKeys},
|
{"APIKeys", s.Tables.APIKeys},
|
||||||
|
{"ServiceKeys", s.Tables.ServiceKeys},
|
||||||
{"Roles", s.Tables.Roles},
|
{"Roles", s.Tables.Roles},
|
||||||
{"Permissions", s.Tables.Permissions},
|
{"Permissions", s.Tables.Permissions},
|
||||||
{"UserRoles", s.Tables.UserRoles},
|
{"UserRoles", s.Tables.UserRoles},
|
||||||
|
|
|
||||||
116
sqlstore/service_keys.go
Normal file
116
sqlstore/service_keys.go
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
package sqlstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.juancwu.dev/juancwu/authkit"
|
||||||
|
"git.juancwu.dev/juancwu/errx"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type serviceKeyStore struct{ storeBase }
|
||||||
|
|
||||||
|
func (s *serviceKeyStore) CreateServiceKey(ctx context.Context, k *authkit.ServiceKey) error {
|
||||||
|
const op = "authkit.sqlstore.ServiceKeyStore.CreateServiceKey"
|
||||||
|
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.CreateServiceKey,
|
||||||
|
k.IDHash, uuidArg(k.OwnerID), k.OwnerKind, 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 *serviceKeyStore) GetServiceKey(ctx context.Context, idHash []byte) (*authkit.ServiceKey, error) {
|
||||||
|
const op = "authkit.sqlstore.ServiceKeyStore.GetServiceKey"
|
||||||
|
k, err := scanServiceKey(s.db.QueryRowContext(ctx, s.q.GetServiceKey, idHash))
|
||||||
|
if err != nil {
|
||||||
|
return nil, errx.Wrap(op, mapNotFound(err, authkit.ErrServiceKeyInvalid))
|
||||||
|
}
|
||||||
|
return k, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serviceKeyStore) ListServiceKeysByOwner(ctx context.Context, ownerKind string, ownerID uuid.UUID) ([]*authkit.ServiceKey, error) {
|
||||||
|
const op = "authkit.sqlstore.ServiceKeyStore.ListServiceKeysByOwner"
|
||||||
|
rows, err := s.db.QueryContext(ctx, s.q.ListServiceKeysByOwner, ownerKind, uuidArg(ownerID))
|
||||||
|
if err != nil {
|
||||||
|
return nil, errx.Wrap(op, err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []*authkit.ServiceKey
|
||||||
|
for rows.Next() {
|
||||||
|
k, err := scanServiceKey(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errx.Wrap(op, err)
|
||||||
|
}
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
return out, errx.Wrap(op, rows.Err())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serviceKeyStore) TouchServiceKey(ctx context.Context, idHash []byte, at time.Time) error {
|
||||||
|
const op = "authkit.sqlstore.ServiceKeyStore.TouchServiceKey"
|
||||||
|
if _, err := s.db.ExecContext(ctx, s.q.TouchServiceKey, at, idHash); err != nil {
|
||||||
|
return errx.Wrap(op, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serviceKeyStore) RevokeServiceKey(ctx context.Context, idHash []byte, at time.Time) error {
|
||||||
|
const op = "authkit.sqlstore.ServiceKeyStore.RevokeServiceKey"
|
||||||
|
tag, err := s.db.ExecContext(ctx, s.q.RevokeServiceKey, at, idHash)
|
||||||
|
if err != nil {
|
||||||
|
return errx.Wrap(op, err)
|
||||||
|
}
|
||||||
|
n, _ := tag.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
return errx.Wrap(op, authkit.ErrServiceKeyInvalid)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanServiceKey(row rowScanner) (*authkit.ServiceKey, error) {
|
||||||
|
var (
|
||||||
|
k authkit.ServiceKey
|
||||||
|
ownerIDStr string
|
||||||
|
abilitiesRaw []byte
|
||||||
|
lastUsed sql.NullTime
|
||||||
|
expires sql.NullTime
|
||||||
|
revoked sql.NullTime
|
||||||
|
)
|
||||||
|
if err := row.Scan(&k.IDHash, &ownerIDStr, &k.OwnerKind, &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
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,7 @@ type Stores struct {
|
||||||
Sessions authkit.SessionStore
|
Sessions authkit.SessionStore
|
||||||
Tokens authkit.TokenStore
|
Tokens authkit.TokenStore
|
||||||
APIKeys authkit.APIKeyStore
|
APIKeys authkit.APIKeyStore
|
||||||
|
ServiceKeys authkit.ServiceKeyStore
|
||||||
Roles authkit.RoleStore
|
Roles authkit.RoleStore
|
||||||
Permissions authkit.PermissionStore
|
Permissions authkit.PermissionStore
|
||||||
}
|
}
|
||||||
|
|
@ -44,6 +45,7 @@ func New(db *sql.DB, dialect Dialect, schema Schema) (*Stores, error) {
|
||||||
Sessions: &sessionStore{storeBase: base},
|
Sessions: &sessionStore{storeBase: base},
|
||||||
Tokens: &tokenStore{storeBase: base},
|
Tokens: &tokenStore{storeBase: base},
|
||||||
APIKeys: &apiKeyStore{storeBase: base},
|
APIKeys: &apiKeyStore{storeBase: base},
|
||||||
|
ServiceKeys: &serviceKeyStore{storeBase: base},
|
||||||
Roles: &roleStore{storeBase: base},
|
Roles: &roleStore{storeBase: base},
|
||||||
Permissions: &permissionStore{storeBase: base},
|
Permissions: &permissionStore{storeBase: base},
|
||||||
}, nil
|
}, nil
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import (
|
||||||
"git.juancwu.dev/juancwu/authkit/sqlstore"
|
"git.juancwu.dev/juancwu/authkit/sqlstore"
|
||||||
pgdialect "git.juancwu.dev/juancwu/authkit/sqlstore/dialect/postgres"
|
pgdialect "git.juancwu.dev/juancwu/authkit/sqlstore/dialect/postgres"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
_ "github.com/jackc/pgx/v5/stdlib"
|
_ "github.com/jackc/pgx/v5/stdlib"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -66,6 +67,7 @@ func freshDB(t *testing.T) (*authkit.Auth, *sql.DB, sqlstore.Schema) {
|
||||||
Sessions: stores.Sessions,
|
Sessions: stores.Sessions,
|
||||||
Tokens: stores.Tokens,
|
Tokens: stores.Tokens,
|
||||||
APIKeys: stores.APIKeys,
|
APIKeys: stores.APIKeys,
|
||||||
|
ServiceKeys: stores.ServiceKeys,
|
||||||
Roles: stores.Roles,
|
Roles: stores.Roles,
|
||||||
Permissions: stores.Permissions,
|
Permissions: stores.Permissions,
|
||||||
Hasher: hasher.NewArgon2id(hasher.DefaultArgon2idParams(), nil),
|
Hasher: hasher.NewArgon2id(hasher.DefaultArgon2idParams(), nil),
|
||||||
|
|
@ -88,7 +90,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.Tokens,
|
s.Tables.APIKeys, s.Tables.ServiceKeys, s.Tables.Tokens,
|
||||||
s.Tables.Sessions, s.Tables.Users,
|
s.Tables.Sessions, s.Tables.Users,
|
||||||
s.Tables.SchemaMigrations,
|
s.Tables.SchemaMigrations,
|
||||||
}
|
}
|
||||||
|
|
@ -217,6 +219,58 @@ func TestIntegration_APIKeyWithAbilities(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIntegration_ServiceKeyFlow(t *testing.T) {
|
||||||
|
auth, _, _ := freshDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
appA := uuid.New()
|
||||||
|
appB := uuid.New()
|
||||||
|
|
||||||
|
plainA1, _, err := auth.IssueServiceKey(ctx, "application", appA, "events-ingest",
|
||||||
|
[]string{"events:write"}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IssueServiceKey appA #1: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, err := auth.IssueServiceKey(ctx, "application", appA, "events-ingest-2", nil, nil); err != nil {
|
||||||
|
t.Fatalf("IssueServiceKey appA #2: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, err := auth.IssueServiceKey(ctx, "application", appB, "billing", nil, nil); err != nil {
|
||||||
|
t.Fatalf("IssueServiceKey appB: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := auth.AuthenticateServiceKey(ctx, plainA1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AuthenticateServiceKey: %v", err)
|
||||||
|
}
|
||||||
|
if got.OwnerKind != "application" || got.OwnerID != appA {
|
||||||
|
t.Fatalf("owner mismatch: kind=%q id=%v", got.OwnerKind, got.OwnerID)
|
||||||
|
}
|
||||||
|
if len(got.Abilities) != 1 || got.Abilities[0] != "events:write" {
|
||||||
|
t.Fatalf("abilities mismatch: %+v", got.Abilities)
|
||||||
|
}
|
||||||
|
|
||||||
|
listA, err := auth.ListServiceKeys(ctx, "application", appA)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListServiceKeys appA: %v", err)
|
||||||
|
}
|
||||||
|
if len(listA) != 2 {
|
||||||
|
t.Fatalf("ListServiceKeys appA = %d, want 2", len(listA))
|
||||||
|
}
|
||||||
|
listB, err := auth.ListServiceKeys(ctx, "application", appB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListServiceKeys appB: %v", err)
|
||||||
|
}
|
||||||
|
if len(listB) != 1 {
|
||||||
|
t.Fatalf("ListServiceKeys appB = %d, want 1", len(listB))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := auth.RevokeServiceKey(ctx, plainA1); err != nil {
|
||||||
|
t.Fatalf("RevokeServiceKey: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := auth.AuthenticateServiceKey(ctx, plainA1); !errors.Is(err, authkit.ErrServiceKeyInvalid) {
|
||||||
|
t.Fatalf("expected ErrServiceKeyInvalid post-revoke, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestIntegration_RBAC(t *testing.T) {
|
func TestIntegration_RBAC(t *testing.T) {
|
||||||
auth, db, schema := freshDB(t)
|
auth, db, schema := freshDB(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,14 @@ type APIKeyStore interface {
|
||||||
RevokeAPIKeysByOwner(ctx context.Context, ownerID uuid.UUID, at time.Time) error
|
RevokeAPIKeysByOwner(ctx context.Context, ownerID uuid.UUID, at time.Time) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ServiceKeyStore interface {
|
||||||
|
CreateServiceKey(ctx context.Context, k *ServiceKey) error
|
||||||
|
GetServiceKey(ctx context.Context, idHash []byte) (*ServiceKey, error)
|
||||||
|
ListServiceKeysByOwner(ctx context.Context, ownerKind string, ownerID uuid.UUID) ([]*ServiceKey, error)
|
||||||
|
TouchServiceKey(ctx context.Context, idHash []byte, at time.Time) error
|
||||||
|
RevokeServiceKey(ctx context.Context, idHash []byte, at time.Time) error
|
||||||
|
}
|
||||||
|
|
||||||
type RoleStore interface {
|
type RoleStore interface {
|
||||||
CreateRole(ctx context.Context, r *Role) error
|
CreateRole(ctx context.Context, r *Role) error
|
||||||
GetRoleByID(ctx context.Context, id uuid.UUID) (*Role, error)
|
GetRoleByID(ctx context.Context, id uuid.UUID) (*Role, error)
|
||||||
|
|
|
||||||
46
tokens.go
46
tokens.go
|
|
@ -19,16 +19,19 @@ const (
|
||||||
prefixSession = "sess"
|
prefixSession = "sess"
|
||||||
prefixRefresh = "rfr"
|
prefixRefresh = "rfr"
|
||||||
prefixAPIKey = "ak"
|
prefixAPIKey = "ak"
|
||||||
|
prefixServiceKey = "sk"
|
||||||
prefixEmailVerify = "evr"
|
prefixEmailVerify = "evr"
|
||||||
prefixPasswordRset = "pwr"
|
prefixPasswordRset = "pwr"
|
||||||
prefixMagicLink = "mlnk"
|
prefixMagicLink = "mlnk"
|
||||||
)
|
)
|
||||||
|
|
||||||
// mintSecret generates a new opaque secret of the given prefix kind and
|
// MintOpaqueSecret generates a fresh opaque secret with the given prefix.
|
||||||
// returns the plaintext (to be returned to the user, never stored) and the
|
// Returns the plaintext (show once, never persist) and the SHA-256 lookup
|
||||||
// SHA-256 lookup hash (to be stored).
|
// hash. A nil rng falls back to crypto/rand.Reader. Exposed so consumers
|
||||||
func mintSecret(prefix string, rng io.Reader) (plaintext string, hash []byte, err error) {
|
// building bespoke storage can produce secrets in the same shape authkit
|
||||||
const op = "authkit.mintSecret"
|
// uses internally.
|
||||||
|
func MintOpaqueSecret(rng io.Reader, prefix string) (plaintext string, hash []byte, err error) {
|
||||||
|
const op = "authkit.MintOpaqueSecret"
|
||||||
if rng == nil {
|
if rng == nil {
|
||||||
rng = rand.Reader
|
rng = rand.Reader
|
||||||
}
|
}
|
||||||
|
|
@ -38,27 +41,40 @@ func mintSecret(prefix string, rng io.Reader) (plaintext string, hash []byte, er
|
||||||
}
|
}
|
||||||
body := base64.RawURLEncoding.EncodeToString(buf)
|
body := base64.RawURLEncoding.EncodeToString(buf)
|
||||||
plaintext = prefix + "_" + body
|
plaintext = prefix + "_" + body
|
||||||
hash = hashSecret(plaintext)
|
hash = HashOpaqueSecret(plaintext)
|
||||||
return plaintext, hash, nil
|
return plaintext, hash, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// hashSecret returns sha256(plaintext) — the lookup key for opaque secrets.
|
// HashOpaqueSecret returns sha256(plaintext) — the lookup key for opaque
|
||||||
// Plaintexts have full entropy from a CSPRNG so a plain hash is sufficient
|
// secrets. Plaintexts have full entropy from a CSPRNG so a plain hash is
|
||||||
// (no per-record salt needed; the random body is the salt).
|
// sufficient (no per-record salt needed; the random body is the salt).
|
||||||
func hashSecret(plaintext string) []byte {
|
func HashOpaqueSecret(plaintext string) []byte {
|
||||||
sum := sha256.Sum256([]byte(plaintext))
|
sum := sha256.Sum256([]byte(plaintext))
|
||||||
return sum[:]
|
return sum[:]
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseSecret validates that a plaintext starts with the expected prefix
|
// ParseOpaqueSecret validates that a plaintext begins with the expected
|
||||||
// and returns the lookup hash. The prefix check is constant-time relative
|
// prefix and returns the lookup hash. Returns ok=false on prefix mismatch.
|
||||||
// to a fixed-length comparison.
|
func ParseOpaqueSecret(prefix, plaintext string) (hash []byte, ok bool) {
|
||||||
func parseSecret(prefix, plaintext string) (hash []byte, ok bool) {
|
|
||||||
want := prefix + "_"
|
want := prefix + "_"
|
||||||
if !strings.HasPrefix(plaintext, want) {
|
if !strings.HasPrefix(plaintext, want) {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
return hashSecret(plaintext), true
|
return HashOpaqueSecret(plaintext), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// mintSecret is the internal entry point; existing callers pass prefix
|
||||||
|
// first to match call-site readability ("mint a token of kind X").
|
||||||
|
func mintSecret(prefix string, rng io.Reader) (plaintext string, hash []byte, err error) {
|
||||||
|
return MintOpaqueSecret(rng, prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashSecret(plaintext string) []byte {
|
||||||
|
return HashOpaqueSecret(plaintext)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSecret(prefix, plaintext string) (hash []byte, ok bool) {
|
||||||
|
return ParseOpaqueSecret(prefix, plaintext)
|
||||||
}
|
}
|
||||||
|
|
||||||
// constantTimeEqual is a thin wrapper for readability at call sites.
|
// constantTimeEqual is a thin wrapper for readability at call sites.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue