From 8cc33eea3e056bad389f5470340315a845ede162 Mon Sep 17 00:00:00 2001 From: KodeStar Date: Thu, 9 Jul 2026 11:56:12 +0100 Subject: [PATCH 1/3] feat(auth): user-minted API keys for headless integrations 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. --- CLAUDE.md | 16 ++- internal/api/api.go | 5 + internal/api/apikeys_test.go | 255 ++++++++++++++++++++++++++++++++++ internal/api/handlers_auth.go | 105 ++++++++++++++ internal/api/middleware.go | 17 ++- internal/auth/auth.go | 157 ++++++++++++++++++--- internal/auth/auth_test.go | 101 ++++++++++++++ 7 files changed, 631 insertions(+), 25 deletions(-) create mode 100644 internal/api/apikeys_test.go diff --git a/CLAUDE.md b/CLAUDE.md index a7d7979..2607a95 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -211,6 +211,17 @@ future metadata site can attach enrichment without reshaping the schema. password needs no challenge, but changing an existing one requires `current_password` (`CheckPassword`), an empty password is rejected (clearing is admin-only), and the admin-must-keep-a-password guard still holds. +- **Personal API keys (`tokens.kind='api'`)**: user-minted, **non-expiring** bearer + tokens for headless integrations, owner-scoped mint/list/revoke via + `POST`/`GET`/`DELETE /auth/tokens` (`IssueAPIToken`/`ListAPITokens`/ + `RevokeTokenByID`; label carried in `device_name`, ≤100 chars). `requireAuth` now + accepts **session OR api** (`ResolveTokenKinds(KindSession, KindAPI)`), so a key + authenticates like a session acting as its owner (an admin's key passes + `requireAdmin`; media `?token=` accepts it too) but is never valid for pairing + `/auth/exchange`; a pairing token is never accepted as a bearer credential. + Secrets are stored SHA-256-only and shown once; create/list/revoke go through + `gateSelfService` (shared `accountLimiter` + demo refusal). Surfaced by the + `api_keys` capability. - **Web player at `/web`** (`web.go`, served from `cfg.WebDir`): a separate Expo Router project (`~/dev/audiosilo/audiosilo-frontend`) exported as a static site. It is **not vendored** in this repo or the binary - the server serves it at runtime @@ -358,8 +369,9 @@ future metadata site can attach enrichment without reshaping the schema. See the plan file. `GET /api/v1/server` advertises capability flags (`admin_ui`, `web_player`, -`upload`, `transcode`, `websocket`); flip them on as phases land. `transcode` -already reflects whether ffmpeg is configured. +`upload`, `transcode`, `websocket`, `api_keys`); flip them on as phases land. +`transcode` already reflects whether ffmpeg is configured; `api_keys` is true +(user-minted personal access tokens are supported). ## API surface diff --git a/internal/api/api.go b/internal/api/api.go index a30e89c..1318927 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -136,6 +136,11 @@ func (a *API) Handler() http.Handler { mux.Handle("POST /api/v1/auth/password", a.requireAuth(http.HandlerFunc(a.handleSetPassword))) mux.Handle("POST /api/v1/auth/recovery", a.requireAuth(http.HandlerFunc(a.handleGenerateRecovery))) mux.Handle("DELETE /api/v1/auth/recovery", a.requireAuth(http.HandlerFunc(a.handleDeleteRecovery))) + // Personal API keys: user-minted, non-expiring bearer credentials for headless + // integrations. Owner-scoped mint/list/revoke; each key acts as its owner. + mux.Handle("POST /api/v1/auth/tokens", a.requireAuth(http.HandlerFunc(a.handleCreateAPIToken))) + mux.Handle("GET /api/v1/auth/tokens", a.requireAuth(http.HandlerFunc(a.handleListAPITokens))) + mux.Handle("DELETE /api/v1/auth/tokens/{id}", a.requireAuth(http.HandlerFunc(a.handleRevokeAPIToken))) mux.Handle("GET /api/v1/me", a.requireAuth(http.HandlerFunc(a.handleMe))) // Content is addressed by (library, path) via ?path= - the path is the diff --git a/internal/api/apikeys_test.go b/internal/api/apikeys_test.go new file mode 100644 index 0000000..01abcf8 --- /dev/null +++ b/internal/api/apikeys_test.go @@ -0,0 +1,255 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/url" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/kodestar/audiosilo-server/internal/auth" + "github.com/kodestar/audiosilo-server/internal/catalog" + "github.com/kodestar/audiosilo-server/internal/library" +) + +// mintAPIKey POSTs /auth/tokens with sessionTok and returns the new key's id and +// plaintext secret, asserting the 200 create-response shape along the way. +func (e *testEnv) mintAPIKey(t *testing.T, sessionTok, label string) (int64, string) { + t.Helper() + resp, b := e.do(t, "POST", "/api/v1/auth/tokens", sessionTok, `{"label":"`+label+`"}`) + if resp.StatusCode != http.StatusOK { + t.Fatalf("mint api key = %d %s, want 200", resp.StatusCode, b) + } + var out struct { + Token string `json:"token"` + APIKey struct { + ID int64 `json:"id"` + Label string `json:"label"` + CreatedAt string `json:"created_at"` + LastSeen *string `json:"last_seen"` + } `json:"api_key"` + } + if err := json.Unmarshal([]byte(b), &out); err != nil { + t.Fatalf("decode mint response: %v (%s)", err, b) + } + if out.Token == "" || out.APIKey.ID == 0 || out.APIKey.Label != label || out.APIKey.CreatedAt == "" { + t.Fatalf("bad mint response: %s", b) + } + if out.APIKey.LastSeen != nil { + t.Fatalf("a freshly minted key should carry last_seen=null: %s", b) + } + return out.APIKey.ID, out.Token +} + +// TestAPIKeyMintAndUseOnAdminStats is the acceptance target: an admin mints a key +// and it authenticates GET /admin/stats as a plain Bearer credential. +func TestAPIKeyMintAndUseOnAdminStats(t *testing.T) { + e := newTestEnv(t) + ctx := context.Background() + adminTok, _ := e.auth.IssueToken(ctx, e.adminID, auth.KindSession, "t", 0) + + _, key := e.mintAPIKey(t, adminTok, "Heimdall dashboard") + if resp, b := e.do(t, "GET", "/api/v1/admin/stats", key, ""); resp.StatusCode != http.StatusOK { + t.Fatalf("admin stats with api key = %d %s, want 200", resp.StatusCode, b) + } +} + +// TestAPIKeyRevokedRejected: a revoked key no longer authenticates (allowed +// before, 401 after). +func TestAPIKeyRevokedRejected(t *testing.T) { + e := newTestEnv(t) + ctx := context.Background() + adminTok, _ := e.auth.IssueToken(ctx, e.adminID, auth.KindSession, "t", 0) + + id, key := e.mintAPIKey(t, adminTok, "temp") + if resp, _ := e.do(t, "GET", "/api/v1/me", key, ""); resp.StatusCode != http.StatusOK { + t.Fatalf("api key should authenticate before revocation, got %d", resp.StatusCode) + } + del := "/api/v1/auth/tokens/" + strconv.FormatInt(id, 10) + if resp, _ := e.do(t, "DELETE", del, adminTok, ""); resp.StatusCode != http.StatusNoContent { + t.Fatalf("revoke own api key = %d, want 204", resp.StatusCode) + } + if resp, _ := e.do(t, "GET", "/api/v1/me", key, ""); resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("revoked api key = %d, want 401", resp.StatusCode) + } + // Revoking again is a 404 (it is gone). + if resp, _ := e.do(t, "DELETE", del, adminTok, ""); resp.StatusCode != http.StatusNotFound { + t.Fatalf("double revoke = %d, want 404", resp.StatusCode) + } +} + +// TestPairingTokenNotAcceptedAsAuth: a pairing token is pairing-only and must be +// rejected on a requireAuth route (it is not a durable credential). +func TestPairingTokenNotAcceptedAsAuth(t *testing.T) { + e := newTestEnv(t) + ctx := context.Background() + ptok, _ := e.auth.IssueToken(ctx, e.adminID, auth.KindPairing, "", 10*time.Minute) + if resp, _ := e.do(t, "GET", "/api/v1/me", ptok, ""); resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("pairing token on requireAuth = %d, want 401", resp.StatusCode) + } +} + +// TestAPIKeyRejectedOnExchange: an api key must NOT be accepted by /auth/exchange +// (that path is pairing-only). +func TestAPIKeyRejectedOnExchange(t *testing.T) { + e := newTestEnv(t) + ctx := context.Background() + adminTok, _ := e.auth.IssueToken(ctx, e.adminID, auth.KindSession, "t", 0) + _, key := e.mintAPIKey(t, adminTok, "k") + + if status, b := e.exchangeToken(t, key, "device"); status != http.StatusUnauthorized { + t.Fatalf("api key on exchange = %d %s, want 401", status, b) + } +} + +// TestAPIKeyNonAdminForbiddenOnAdmin: a non-admin's key authenticates normal +// routes but the admin role is still enforced (allowed on /me, 403 on /admin). +func TestAPIKeyNonAdminForbiddenOnAdmin(t *testing.T) { + e := newTestEnv(t) + ctx := context.Background() + member, _ := e.auth.CreateUser(ctx, "member", "", auth.RoleUser) + memberTok, _ := e.auth.IssueToken(ctx, member.ID, auth.KindSession, "t", 0) + _, key := e.mintAPIKey(t, memberTok, "k") + + if resp, _ := e.do(t, "GET", "/api/v1/me", key, ""); resp.StatusCode != http.StatusOK { + t.Fatalf("member key on /me = %d, want 200", resp.StatusCode) + } + if resp, _ := e.do(t, "GET", "/api/v1/admin/stats", key, ""); resp.StatusCode != http.StatusForbidden { + t.Fatalf("member key on /admin/stats = %d, want 403", resp.StatusCode) + } +} + +// TestAPIKeyRevokeCrossUser404: a user cannot revoke another user's key by id. +func TestAPIKeyRevokeCrossUser404(t *testing.T) { + e := newTestEnv(t) + ctx := context.Background() + adminTok, _ := e.auth.IssueToken(ctx, e.adminID, auth.KindSession, "t", 0) + adminKeyID, adminKey := e.mintAPIKey(t, adminTok, "admin key") + + member, _ := e.auth.CreateUser(ctx, "member", "", auth.RoleUser) + memberTok, _ := e.auth.IssueToken(ctx, member.ID, auth.KindSession, "t", 0) + + del := "/api/v1/auth/tokens/" + strconv.FormatInt(adminKeyID, 10) + if resp, _ := e.do(t, "DELETE", del, memberTok, ""); resp.StatusCode != http.StatusNotFound { + t.Fatalf("cross-user revoke = %d, want 404", resp.StatusCode) + } + // The admin's key is untouched. + if resp, _ := e.do(t, "GET", "/api/v1/me", adminKey, ""); resp.StatusCode != http.StatusOK { + t.Fatalf("admin key should still work after a failed cross-user revoke, got %d", resp.StatusCode) + } +} + +// TestAPIKeyDemoRefused: demo accounts cannot mint, list, or revoke API keys +// (403, matching the recovery/password refusal). +func TestAPIKeyDemoRefused(t *testing.T) { + e := newTestEnv(t) + ctx := context.Background() + demo, _ := e.auth.CreateDemoUser(ctx, "demo_x") + demoTok, _ := e.auth.IssueToken(ctx, demo.ID, auth.KindSession, "t", 0) + + if resp, _ := e.do(t, "POST", "/api/v1/auth/tokens", demoTok, `{"label":"x"}`); resp.StatusCode != http.StatusForbidden { + t.Fatalf("demo mint = %d, want 403", resp.StatusCode) + } + if resp, _ := e.do(t, "GET", "/api/v1/auth/tokens", demoTok, ""); resp.StatusCode != http.StatusForbidden { + t.Fatalf("demo list = %d, want 403", resp.StatusCode) + } + if resp, _ := e.do(t, "DELETE", "/api/v1/auth/tokens/1", demoTok, ""); resp.StatusCode != http.StatusForbidden { + t.Fatalf("demo revoke = %d, want 403", resp.StatusCode) + } +} + +// TestAPIKeyEmptyLabelRejected: a blank/missing label is a 400 (a key must be +// named so the owner can tell them apart to revoke one). +func TestAPIKeyEmptyLabelRejected(t *testing.T) { + e := newTestEnv(t) + ctx := context.Background() + adminTok, _ := e.auth.IssueToken(ctx, e.adminID, auth.KindSession, "t", 0) + + if resp, _ := e.do(t, "POST", "/api/v1/auth/tokens", adminTok, `{"label":" "}`); resp.StatusCode != http.StatusBadRequest { + t.Fatalf("whitespace label = %d, want 400", resp.StatusCode) + } + if resp, _ := e.do(t, "POST", "/api/v1/auth/tokens", adminTok, `{}`); resp.StatusCode != http.StatusBadRequest { + t.Fatalf("missing label = %d, want 400", resp.StatusCode) + } +} + +// TestAPIKeyListScopedAndMetadataOnly: the list returns only the caller's live +// keys, newest first, and never a secret, hash, or another user's key. +func TestAPIKeyListScopedAndMetadataOnly(t *testing.T) { + e := newTestEnv(t) + ctx := context.Background() + adminTok, _ := e.auth.IssueToken(ctx, e.adminID, auth.KindSession, "t", 0) + + _, alphaSecret := e.mintAPIKey(t, adminTok, "alpha") + betaID, betaSecret := e.mintAPIKey(t, adminTok, "beta") + _, gammaSecret := e.mintAPIKey(t, adminTok, "gamma") + // Revoke beta so it must be excluded from the list. + if resp, _ := e.do(t, "DELETE", "/api/v1/auth/tokens/"+strconv.FormatInt(betaID, 10), adminTok, ""); resp.StatusCode != http.StatusNoContent { + t.Fatalf("revoke beta = %d, want 204", resp.StatusCode) + } + + // A second user's key must not appear in the admin's list. + member, _ := e.auth.CreateUser(ctx, "member", "", auth.RoleUser) + memberTok, _ := e.auth.IssueToken(ctx, member.ID, auth.KindSession, "t", 0) + e.mintAPIKey(t, memberTok, "member key") + + resp, b := e.do(t, "GET", "/api/v1/auth/tokens", adminTok, "") + if resp.StatusCode != http.StatusOK { + t.Fatalf("list = %d %s, want 200", resp.StatusCode, b) + } + var out struct { + APIKeys []struct { + ID int64 `json:"id"` + Label string `json:"label"` + LastSeen *string `json:"last_seen"` + } `json:"api_keys"` + } + if err := json.Unmarshal([]byte(b), &out); err != nil { + t.Fatalf("decode list: %v (%s)", err, b) + } + // Only the two live keys, newest first (gamma then alpha). + if len(out.APIKeys) != 2 || out.APIKeys[0].Label != "gamma" || out.APIKeys[1].Label != "alpha" { + t.Fatalf("list scope/order wrong: %s", b) + } + // Metadata only: no secret leaks, no revoked key, no other user's key. + for _, s := range []string{alphaSecret, betaSecret, gammaSecret, "member key", "beta", "token_hash"} { + if strings.Contains(b, s) { + t.Fatalf("list leaked %q: %s", s, b) + } + } +} + +// TestServerInfoAdvertisesAPIKeys: the capability flag is advertised so clients +// can gate the API-keys UI on it. +func TestServerInfoAdvertisesAPIKeys(t *testing.T) { + e := newTestEnv(t) + _, body := e.do(t, "GET", "/api/v1/server", "", "") + if !strings.Contains(body, `"api_keys":true`) { + t.Fatalf("/server missing api_keys capability: %s", body) + } +} + +// TestAPIKeyWorksAsMediaQueryToken: an api key rides in the media ?token= query +// param exactly like a session token (browser