User-minted API keys for headless integrations - #35
Merged
Conversation
Named, individually revocable, non-expiring bearer tokens (kind=api) so
integrations (dashboards, cron, monitoring) no longer need a stored
username+password:
- auth: KindAPI; IssueAPIToken (secret shown once, stored hashed);
ListAPITokens (metadata only); owner-scoped RevokeTokenByID;
ResolveToken generalized to ResolveTokenKinds (kind IN ...).
- middleware: requireAuth/requireMediaAuth accept session OR api kinds;
pairing tokens remain excluded, and an api key is never valid for
exchange.
- api: POST/GET /api/v1/auth/tokens + DELETE /api/v1/auth/tokens/{id},
rate-limited and refused for demo accounts like recovery/password;
GET /server advertises the api_keys capability.
- no migration needed: tokens.kind is free-text.
Tests cover the allowed and denied paths: revoked key, pairing token as
bearer, key on exchange, cross-user revoke, demo refusal, non-admin key
on /admin/*, media ?token= acceptance.
This was referenced Jul 9, 2026
Owner
Author
|
Sibling PRs: frontend KodeStar/audiosilo-frontend#55 · docs KodeStar/audiosilo-docs#10 · workspace glue KodeStar/audiosilo#4 |
Extract the per-IP account rate-limit + demo-account refusal shared by handleSetPassword, handleGenerateRecovery and the API-key endpoints into gateSelfService so the guard lives in one place. Add constant-string fast paths to inPlaceholders for the 1- and 2-kind cases the per-request auth path hits, so lookupToken adds no allocation there.
An API key acts as its owner, but a leaked key could previously mint a fresh credential that outlived its own revocation - another API key (POST /auth/tokens), a recovery code (POST /auth/recovery), a pairing token (POST /auth/pair -> exchange), or a first password on a password-less account (POST /auth/password). Revoking the leaked key then failed to contain the compromise. Enforce a containment invariant: revoking a key cuts off everything it could reach. ResolveTokenKinds now returns the matched token kind; middleware stashes it in request context; denyAPIKey refuses an api-key caller (403) on the four durable-credential-minting routes. A key may still list/revoke keys and clear a recovery code (those only reduce access), and a session is unaffected. Mirrors GitHub's "a token cannot create tokens". Tests: TestAPIKeyCannotMintDurableCredentials (denied x4 for a key, allowed for a session, list/revoke still allowed); auth-layer assertions that the resolver reports kind=api. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Named, individually revocable, non-expiring API keys (personal access tokens) a user mints for headless consumers - dashboards (Heimdall), cron, monitoring - so no integration ever stores the admin username+password again.
apialongsidesession/pairing(no migration -tokens.kindis free-text).POST /api/v1/auth/tokens(mint; secret returned exactly once, stored as SHA-256),GET /api/v1/auth/tokens(owner's keys, metadata only, newest first),DELETE /api/v1/auth/tokens/{id}(owner-scoped revoke). All three share the account rate limit and refuse demo accounts, matching the recovery/password handlers.requireAuth/requireMediaAuthnow resolve session or api kinds (ResolveTokenKinds); a key acts as its owner (an admin's key passes/admin/*, a non-admin's does not). Pairing tokens are still never accepted as bearer auth, and an api key is never valid for/auth/exchange.GET /servercapabilities gainapi_keys: trueso clients can gate the management UI.Tests
13 new tests: mint-and-use on
/admin/stats, revoked key rejected, pairing-token-as-bearer rejected, key-on-exchange rejected, non-admin key 403 on admin routes, cross-user revoke 404, demo refusal, empty label 400, metadata-only list, capability flag, media?token=acceptance, plus auth-layer lifecycle/scoping units.Full gate green:
go build && go vet && go test -race && golangci-lint run. Also verified live end-to-end: mint via API ->Authorization: Bearer <key>onGET /api/v1/admin/stats-> 200; revoke -> 401.Pairing
Wire change shipped with the frontend management UI and docs in sibling PRs (links to follow).