Skip to content

feat(backend): add multiple API key support#45

Merged
roziscoding merged 5 commits into
mainfrom
feat/multiple-api-keys
Jun 24, 2026
Merged

feat(backend): add multiple API key support#45
roziscoding merged 5 commits into
mainfrom
feat/multiple-api-keys

Conversation

@roziscoding

@roziscoding roziscoding commented Jun 24, 2026

Copy link
Copy Markdown
Owner

Summary

  • Add api_keys table with hash storage, optional name/description/expiration
  • Update requireApiKey middleware to check DB for jack_* prefixed keys
  • Add CRUD endpoints at /api-keys on the management API (POST, GET, GET/:id, PATCH/:id, DELETE/:id)
  • Inject authenticated key name into request context and log spans
  • Reject expired keys automatically

Key Details

  • Generated keys use jack_ prefix (69 chars: jack_ + 64 hex)
  • Keys not starting with jack_ that don't match master are rejected immediately (no DB lookup)
  • Master key from config still works unchanged (fast path, no DB lookup)
  • Key hash stored with SHA-256, constant-time comparison via hash-then-compare
  • Raw key only returned once on creation

Test plan

  • Repository tests pass (CRUD operations)
  • Middleware tests pass (auth scenarios: master key, DB key, expired key, missing key)
  • Router tests pass (HTTP CRUD flow)
  • All 278 tests pass
  • Manual: Create key via management API, use it to access main API, verify log shows name

Greptile Summary

This PR adds multi-key API authentication to Jack: a new api_keys table stores SHA-256 hashes of jack_-prefixed keys, the requireApiKey middleware 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-keys on the management API is protected behind the existing management key. A new Settings page in the UI exposes key creation, editing, and revocation.

  • Backend auth flow: keys not starting with 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 via AuthVariables.
  • Security hygiene: raw key is returned only on creation, hashes are stored (never raw values), error messages for unknown vs. non-jack_ keys are identical to prevent enumeration, and a previous module-level singleton caching bug in requireApiKey is fixed as a side effect.
  • UI: the settings page includes a one-time reveal modal with a copy button and a confirmation dialog for revocation; the ApiKeyForm owns 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

Filename Overview
apps/backend/src/middleware/require-auth.ts Rewrites requireApiKey to support DB-backed jack_ keys with expiry checks; fixes a pre-existing singleton-caching bug where a module-level _middleware was reused across calls with different keys
apps/backend/src/lib/crypto.ts Adds hashKey (SHA-256 via Bun.CryptoHasher), generateApiKey (jack_ + 32 random bytes as hex), and isGeneratedKey helpers; all well-scoped and tested
apps/backend/src/modules/api-keys/api-keys.repository.ts Clean CRUD repository over the api_keys table; update uses RETURNING to detect missing ID, delete performs a pre-flight get which is safe under SQLite's serialized write model
apps/backend/src/modules/api-keys/api-keys.controller.ts Thin controller mapping CRUD operations; raw key is stripped from list/get/update/delete responses so it's only visible in the create response
apps/backend/src/management-app.ts Mounts /api-keys under requireManagementKey (line 29) so the CRUD endpoints are fully authenticated; correctly optional on the apiKeysRepository dependency
apps/ui/app/pages/settings.vue New settings page for API key CRUD; copyKey silently drops clipboard API errors on HTTP or when permission is denied
apps/ui/app/components/ApiKeyForm.vue Form with preset/custom expiry picker; UI owns the future-date guard (team decision), min attribute on datetime-local and customInFuture validation are both in place
apps/backend/src/database/schema.ts Adds apiKeys table definition with .unique() on keyHash; comment documents why no separate index is needed

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
Loading
%%{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
    end
Loading

Comments Outside Diff (1)

  1. apps/backend/drizzle/0006_gigantic_otto_octavius.sql, line 17-18 (link)

    P2 Redundant index on key_hash

    api_keys_key_hash_unique is a unique index on key_hash, which already acts as a covering B-tree index for lookups. The non-unique api_keys_key_hash_idx on the same column is entirely redundant — SQLite (and every major engine) uses the unique index for WHERE key_hash = ? queries without needing a second index. The redundant index wastes write overhead and disk space on every INSERT/UPDATE. The explicit index() call in the Drizzle schema definition should be removed, and the corresponding migration line should be dropped before the migration runs.

    Fix in Claude Code Fix in Codex

Reviews (3): Last reviewed commit: "feat(ui): add Settings tab with API key ..." | Re-trigger Greptile

- 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).
@roziscoding roziscoding marked this pull request as ready for review June 24, 2026 11:00
Comment thread apps/backend/src/modules/api-keys/api-keys.schema.ts
Comment thread apps/backend/src/database/schema.ts Outdated
roziscoding and others added 2 commits June 24, 2026 13:07
.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.
@roziscoding roziscoding merged commit e3c91d4 into main Jun 24, 2026
9 checks passed
@roziscoding roziscoding deleted the feat/multiple-api-keys branch June 24, 2026 19:08
@github-actions

Copy link
Copy Markdown

🐳 Docker images published

This PR has been built and pushed to GHCR:

ghcr.io/roziscoding/jack:pr-45        # backend
ghcr.io/roziscoding/jack-ui:pr-45     # management UI

Pull them locally:

docker pull ghcr.io/roziscoding/jack:pr-45
docker pull ghcr.io/roziscoding/jack-ui:pr-45

Run the backend standalone:

docker run --rm ghcr.io/roziscoding/jack:pr-45

The UI needs the backend + a management key, so run the two together with examples/docker-compose.yml, overriding the image tags to pr-45.

Last built from commit a0e964f. Heads up: these images are automatically deleted when the PR is closed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant