From 4942e4dbdc393b645e2c70edb67fcbce55232613 Mon Sep 17 00:00:00 2001 From: juancwu Date: Sun, 26 Apr 2026 19:54:26 +0000 Subject: [PATCH] Add owner-agnostic service tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- README.md | 60 ++++++- authkit.go | 5 +- errors.go | 1 + memstore_test.go | 63 +++++++ models.go | 16 ++ service_service_key.go | 92 ++++++++++ service_service_key_test.go | 167 ++++++++++++++++++ sqlstore/dialect.go | 7 + .../postgres/migrations/0002_service_keys.sql | 27 +++ sqlstore/dialect/postgres/postgres.go | 19 ++ sqlstore/schema.go | 3 + sqlstore/service_keys.go | 116 ++++++++++++ sqlstore/sqlstore.go | 2 + sqlstore/sqlstore_test.go | 56 +++++- stores.go | 8 + tokens.go | 46 +++-- 16 files changed, 664 insertions(+), 24 deletions(-) create mode 100644 service_service_key.go create mode 100644 service_service_key_test.go create mode 100644 sqlstore/dialect/postgres/migrations/0002_service_keys.sql create mode 100644 sqlstore/service_keys.go diff --git a/README.md b/README.md index 63957c6..79df4e2 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ PostgreSQL 12+ is sufficient — the schema avoids `gen_random_uuid()` and **Authorization** - Roles and permissions with many-to-many wiring - 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 authentication method ran @@ -117,6 +118,7 @@ auth := authkit.New(authkit.Deps{ Sessions: stores.Sessions, Tokens: stores.Tokens, APIKeys: stores.APIKeys, + ServiceKeys: stores.ServiceKeys, Roles: stores.Roles, Permissions: stores.Permissions, 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 session TTL, 15m access tokens, 30d refresh tokens, 48h email-verify, 1h -password-reset, 15m magic-link). `JWTSecret` and the seven `Deps` fields are -required; `New` panics on a misconfiguration. +password-reset, 15m magic-link). `JWTSecret` and all eight `Deps` fields +(including the new `ServiceKeys` field added in v0.2.0) are required; `New` +panics on a misconfiguration. ### 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.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", []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 tok, err := auth.RequestEmailVerification(ctx, u.ID) _, err = auth.ConfirmEmail(ctx, tok) @@ -233,8 +244,8 @@ each table. Adding column overrides later is purely additive. ### Secret token format -Sessions, refresh tokens, API keys, email-verify tokens, password-reset tokens, -and magic-link tokens all share one format: +Sessions, refresh tokens, API keys, service tokens, email-verify tokens, +password-reset tokens, and magic-link tokens all share one format: ``` plaintext = "_" + 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 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 `_` 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 diff --git a/authkit.go b/authkit.go index f23c2e7..ce68d55 100644 --- a/authkit.go +++ b/authkit.go @@ -18,6 +18,7 @@ type Deps struct { Sessions SessionStore Tokens TokenStore APIKeys APIKeyStore + ServiceKeys ServiceKeyStore Roles RoleStore Permissions PermissionStore Hasher Hasher @@ -69,8 +70,8 @@ type Auth struct { // returning an error — these are programmer errors, not runtime ones. func New(deps Deps, cfg Config) *Auth { if deps.Users == nil || deps.Sessions == nil || deps.Tokens == nil || - deps.APIKeys == nil || deps.Roles == nil || deps.Permissions == nil || - deps.Hasher == nil { + deps.APIKeys == nil || deps.ServiceKeys == nil || deps.Roles == nil || + deps.Permissions == nil || deps.Hasher == nil { panic(errx.New("authkit.New", "all Deps fields are required")) } if len(cfg.JWTSecret) == 0 { diff --git a/errors.go b/errors.go index a5acf10..e1cba45 100644 --- a/errors.go +++ b/errors.go @@ -11,6 +11,7 @@ var ( ErrTokenReused = errors.New("authkit: token reuse detected") 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") ErrPermissionDenied = errors.New("authkit: permission denied") ErrRoleNotFound = errors.New("authkit: role not found") ErrPermissionNotFound = errors.New("authkit: permission not found") diff --git a/memstore_test.go b/memstore_test.go index 4b16216..e8304f4 100644 --- a/memstore_test.go +++ b/memstore_test.go @@ -331,6 +331,68 @@ func (s *memAPIKeyStore) RevokeAPIKeysByOwner(_ context.Context, owner uuid.UUID 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 { mu sync.Mutex roles map[uuid.UUID]*Role @@ -602,6 +664,7 @@ func newTestAuth(t interface{ Helper() }) *Auth { Sessions: newMemSessionStore(), Tokens: newMemTokenStore(), APIKeys: newMemAPIKeyStore(), + ServiceKeys: newMemServiceKeyStore(), Roles: roles, Permissions: newMemPermStore(roles), Hasher: stubHasher{}, diff --git a/models.go b/models.go index 9b31616..a0d091c 100644 --- a/models.go +++ b/models.go @@ -60,6 +60,22 @@ type APIKey struct { 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 { ID uuid.UUID Name string diff --git a/service_service_key.go b/service_service_key.go new file mode 100644 index 0000000..1ccc9b3 --- /dev/null +++ b/service_service_key.go @@ -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 +} diff --git a/service_service_key_test.go b/service_service_key_test.go new file mode 100644 index 0000000..a532762 --- /dev/null +++ b/service_service_key_test.go @@ -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") + } +} diff --git a/sqlstore/dialect.go b/sqlstore/dialect.go index 4b6c3d8..44ef9b8 100644 --- a/sqlstore/dialect.go +++ b/sqlstore/dialect.go @@ -89,6 +89,13 @@ type Queries struct { RevokeAPIKey string RevokeAPIKeysByOwner string + // service keys + CreateServiceKey string + GetServiceKey string + ListServiceKeysByOwner string + TouchServiceKey string + RevokeServiceKey string + // roles CreateRole string GetRoleByID string diff --git a/sqlstore/dialect/postgres/migrations/0002_service_keys.sql b/sqlstore/dialect/postgres/migrations/0002_service_keys.sql new file mode 100644 index 0000000..5c11b19 --- /dev/null +++ b/sqlstore/dialect/postgres/migrations/0002_service_keys.sql @@ -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; diff --git a/sqlstore/dialect/postgres/postgres.go b/sqlstore/dialect/postgres/postgres.go index 56ec880..6b1a1fe 100644 --- a/sqlstore/dialect/postgres/postgres.go +++ b/sqlstore/dialect/postgres/postgres.go @@ -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`, 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 CreateRole: `INSERT INTO ` + t.Roles + ` (id, name, description, created_at) VALUES (?, ?, ?, ?)`, 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.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.GetRoleByID = rebind(q.GetRoleByID) q.GetRoleByName = rebind(q.GetRoleByName) diff --git a/sqlstore/schema.go b/sqlstore/schema.go index cb254e4..40f2e20 100644 --- a/sqlstore/schema.go +++ b/sqlstore/schema.go @@ -22,6 +22,7 @@ type Tables struct { Sessions string Tokens string APIKeys string + ServiceKeys string Roles string Permissions string UserRoles string @@ -37,6 +38,7 @@ func DefaultSchema() Schema { Sessions: "authkit_sessions", Tokens: "authkit_tokens", APIKeys: "authkit_api_keys", + ServiceKeys: "authkit_service_keys", Roles: "authkit_roles", Permissions: "authkit_permissions", UserRoles: "authkit_user_roles", @@ -60,6 +62,7 @@ func (s Schema) Validate() error { {"Sessions", s.Tables.Sessions}, {"Tokens", s.Tables.Tokens}, {"APIKeys", s.Tables.APIKeys}, + {"ServiceKeys", s.Tables.ServiceKeys}, {"Roles", s.Tables.Roles}, {"Permissions", s.Tables.Permissions}, {"UserRoles", s.Tables.UserRoles}, diff --git a/sqlstore/service_keys.go b/sqlstore/service_keys.go new file mode 100644 index 0000000..0c52fde --- /dev/null +++ b/sqlstore/service_keys.go @@ -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 +} diff --git a/sqlstore/sqlstore.go b/sqlstore/sqlstore.go index a87d9ca..702e3e5 100644 --- a/sqlstore/sqlstore.go +++ b/sqlstore/sqlstore.go @@ -19,6 +19,7 @@ type Stores struct { Sessions authkit.SessionStore Tokens authkit.TokenStore APIKeys authkit.APIKeyStore + ServiceKeys authkit.ServiceKeyStore Roles authkit.RoleStore Permissions authkit.PermissionStore } @@ -44,6 +45,7 @@ func New(db *sql.DB, dialect Dialect, schema Schema) (*Stores, error) { Sessions: &sessionStore{storeBase: base}, Tokens: &tokenStore{storeBase: base}, APIKeys: &apiKeyStore{storeBase: base}, + ServiceKeys: &serviceKeyStore{storeBase: base}, Roles: &roleStore{storeBase: base}, Permissions: &permissionStore{storeBase: base}, }, nil diff --git a/sqlstore/sqlstore_test.go b/sqlstore/sqlstore_test.go index 8c444b6..b035769 100644 --- a/sqlstore/sqlstore_test.go +++ b/sqlstore/sqlstore_test.go @@ -20,6 +20,7 @@ import ( "git.juancwu.dev/juancwu/authkit/sqlstore" pgdialect "git.juancwu.dev/juancwu/authkit/sqlstore/dialect/postgres" + "github.com/google/uuid" _ "github.com/jackc/pgx/v5/stdlib" ) @@ -66,6 +67,7 @@ func freshDB(t *testing.T) (*authkit.Auth, *sql.DB, sqlstore.Schema) { Sessions: stores.Sessions, Tokens: stores.Tokens, APIKeys: stores.APIKeys, + ServiceKeys: stores.ServiceKeys, Roles: stores.Roles, Permissions: stores.Permissions, Hasher: hasher.NewArgon2id(hasher.DefaultArgon2idParams(), nil), @@ -88,7 +90,7 @@ func dropAuthkitTables(t *testing.T, db *sql.DB, s sqlstore.Schema) { tables := []string{ s.Tables.UserRoles, s.Tables.RolePermissions, 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.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) { auth, db, schema := freshDB(t) ctx := context.Background() diff --git a/stores.go b/stores.go index 36f2374..0620fa6 100644 --- a/stores.go +++ b/stores.go @@ -53,6 +53,14 @@ type APIKeyStore interface { 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 { CreateRole(ctx context.Context, r *Role) error GetRoleByID(ctx context.Context, id uuid.UUID) (*Role, error) diff --git a/tokens.go b/tokens.go index 78d5e22..06597d9 100644 --- a/tokens.go +++ b/tokens.go @@ -19,16 +19,19 @@ const ( prefixSession = "sess" prefixRefresh = "rfr" prefixAPIKey = "ak" + prefixServiceKey = "sk" prefixEmailVerify = "evr" prefixPasswordRset = "pwr" prefixMagicLink = "mlnk" ) -// mintSecret generates a new opaque secret of the given prefix kind and -// returns the plaintext (to be returned to the user, never stored) and the -// SHA-256 lookup hash (to be stored). -func mintSecret(prefix string, rng io.Reader) (plaintext string, hash []byte, err error) { - const op = "authkit.mintSecret" +// MintOpaqueSecret generates a fresh opaque secret with the given prefix. +// Returns the plaintext (show once, never persist) and the SHA-256 lookup +// hash. A nil rng falls back to crypto/rand.Reader. Exposed so consumers +// building bespoke storage can produce secrets in the same shape authkit +// uses internally. +func MintOpaqueSecret(rng io.Reader, prefix string) (plaintext string, hash []byte, err error) { + const op = "authkit.MintOpaqueSecret" if rng == nil { rng = rand.Reader } @@ -38,27 +41,40 @@ func mintSecret(prefix string, rng io.Reader) (plaintext string, hash []byte, er } body := base64.RawURLEncoding.EncodeToString(buf) plaintext = prefix + "_" + body - hash = hashSecret(plaintext) + hash = HashOpaqueSecret(plaintext) return plaintext, hash, nil } -// hashSecret returns sha256(plaintext) — the lookup key for opaque secrets. -// Plaintexts have full entropy from a CSPRNG so a plain hash is sufficient -// (no per-record salt needed; the random body is the salt). -func hashSecret(plaintext string) []byte { +// HashOpaqueSecret returns sha256(plaintext) — the lookup key for opaque +// secrets. Plaintexts have full entropy from a CSPRNG so a plain hash is +// sufficient (no per-record salt needed; the random body is the salt). +func HashOpaqueSecret(plaintext string) []byte { sum := sha256.Sum256([]byte(plaintext)) return sum[:] } -// parseSecret validates that a plaintext starts with the expected prefix -// and returns the lookup hash. The prefix check is constant-time relative -// to a fixed-length comparison. -func parseSecret(prefix, plaintext string) (hash []byte, ok bool) { +// ParseOpaqueSecret validates that a plaintext begins with the expected +// prefix and returns the lookup hash. Returns ok=false on prefix mismatch. +func ParseOpaqueSecret(prefix, plaintext string) (hash []byte, ok bool) { want := prefix + "_" if !strings.HasPrefix(plaintext, want) { 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.