feat(backend): add multiple API key support#45
Merged
Conversation
- Add api_keys table with hash storage, name, description, expiration - Update requireApiKey middleware to check DB for jack_* prefixed keys - Add CRUD endpoints for API key management at /api-keys - Inject authenticated key name into request context - Add key name to log spans when available - Reject expired keys automatically Keys use 'jack_' prefix for fast rejection of invalid formats. Master key from config still works unchanged.
Move create/update request schemas into api-keys.schema.ts (schema const + inferred type, mirroring lib/config.ts). Router imports the schemas for validation; controller imports the inferred types, replacing its duplicate request interface. Align zod import with the default-import convention used by sibling routers.
ApiKeyRecord was a structural clone of the drizzle-inferred ApiKeyRow and toRecord was an identity copy — the Row/Record split only earns its keep when the stored shape differs from the domain shape (as in downloads, which parses releaseJson into a Release). API keys need no such transform, so the repository now returns ApiKeyRow directly. Input interfaces stay (they carry keyHash, which the HTTP body doesn't).
.unique() on key_hash already creates a unique index the planner uses for every findByHash lookup, so the explicit api_keys_key_hash_idx just made Drizzle emit and maintain a second index on the same column. Removed it from the schema and hand-edited the 0006 migration + snapshot rather than cutting a new migration. drizzle-kit check passes; no schema drift.
🐳 Docker images publishedThis PR has been built and pushed to GHCR: Pull them locally: docker pull ghcr.io/roziscoding/jack:pr-45
docker pull ghcr.io/roziscoding/jack-ui:pr-45Run the backend standalone: docker run --rm ghcr.io/roziscoding/jack:pr-45The UI needs the backend + a management key, so run the two together with
|
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.
Summary
api_keystable with hash storage, optional name/description/expirationrequireApiKeymiddleware to check DB forjack_*prefixed keys/api-keyson the management API (POST, GET, GET/:id, PATCH/:id, DELETE/:id)Key Details
jack_prefix (69 chars:jack_+ 64 hex)jack_that don't match master are rejected immediately (no DB lookup)Test plan
Greptile Summary
This PR adds multi-key API authentication to Jack: a new
api_keystable stores SHA-256 hashes ofjack_-prefixed keys, therequireApiKeymiddleware gains a DB lookup path for those keys while keeping the config master key as a fast path, and a full CRUD surface at/api-keyson the management API is protected behind the existing management key. A new Settings page in the UI exposes key creation, editing, and revocation.jack_that don't match the master key are rejected immediately without a DB round-trip;jack_keys are hashed and looked up, with expiry enforced at request time and the key name injected into the OTel span viaAuthVariables.jack_keys are identical to prevent enumeration, and a previous module-level singleton caching bug inrequireApiKeyis fixed as a side effect.ApiKeyFormowns the future-date guard for expiry (by team decision).Confidence Score: 5/5
Safe to merge — the auth middleware, repository, and management CRUD all behave correctly; the only finding is a missing error-handler on the clipboard API in the settings UI.
The auth logic is well-structured with clear fast paths, constant-time hash comparison for DB keys, and proper expiry enforcement. The management API routes are registered after the global requireManagementKey middleware so they are fully protected. The singleton caching bug in the old middleware is fixed as a side effect. The test suite covers all auth scenarios and CRUD operations. The only issue is that copyKey in the settings page does not catch clipboard errors, which causes a silent failure on HTTP deployments or when the user denies clipboard permission.
apps/ui/app/pages/settings.vue — the copyKey function should handle clipboard API errors gracefully.
Important Files Changed
Sequence Diagram
%%{init: {'theme': 'neutral'}}%% sequenceDiagram participant Client participant requireApiKey participant DB as api_keys DB Client->>requireApiKey: request + X-Api-Key header alt masterKey is empty string requireApiKey-->>Client: pass (auth disabled) else key matches masterKey exactly requireApiKey-->>Client: pass (no DB lookup) else key does NOT start with jack_ requireApiKey-->>Client: 401 invalid API key else apiKeysRepository not provided requireApiKey-->>Client: 401 invalid API key else jack_ prefixed key requireApiKey->>DB: findByHash(SHA-256(key)) alt not found DB-->>requireApiKey: null requireApiKey-->>Client: 401 invalid API key else "found & expired" DB-->>requireApiKey: ApiKeyRow requireApiKey-->>Client: 401 API key expired else "found & valid" DB-->>requireApiKey: ApiKeyRow requireApiKey->>requireApiKey: ctx.set('apiKeyName', name) requireApiKey-->>Client: pass (key name in span) end end%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% sequenceDiagram participant Client participant requireApiKey participant DB as api_keys DB Client->>requireApiKey: request + X-Api-Key header alt masterKey is empty string requireApiKey-->>Client: pass (auth disabled) else key matches masterKey exactly requireApiKey-->>Client: pass (no DB lookup) else key does NOT start with jack_ requireApiKey-->>Client: 401 invalid API key else apiKeysRepository not provided requireApiKey-->>Client: 401 invalid API key else jack_ prefixed key requireApiKey->>DB: findByHash(SHA-256(key)) alt not found DB-->>requireApiKey: null requireApiKey-->>Client: 401 invalid API key else "found & expired" DB-->>requireApiKey: ApiKeyRow requireApiKey-->>Client: 401 API key expired else "found & valid" DB-->>requireApiKey: ApiKeyRow requireApiKey->>requireApiKey: ctx.set('apiKeyName', name) requireApiKey-->>Client: pass (key name in span) end endComments Outside Diff (1)
apps/backend/drizzle/0006_gigantic_otto_octavius.sql, line 17-18 (link)key_hashapi_keys_key_hash_uniqueis a unique index onkey_hash, which already acts as a covering B-tree index for lookups. The non-uniqueapi_keys_key_hash_idxon the same column is entirely redundant — SQLite (and every major engine) uses the unique index forWHERE key_hash = ?queries without needing a second index. The redundant index wastes write overhead and disk space on every INSERT/UPDATE. The explicitindex()call in the Drizzle schema definition should be removed, and the corresponding migration line should be dropped before the migration runs.Reviews (3): Last reviewed commit: "feat(ui): add Settings tab with API key ..." | Re-trigger Greptile