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:
juancwu 2026-04-26 19:54:26 +00:00
commit 4942e4dbdc
16 changed files with 664 additions and 24 deletions

View file

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

View file

@ -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 {

View file

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

View file

@ -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{},

View file

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

View file

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

View 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;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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.