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

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
}