diff --git a/.changeset/api-key-generation-endpoint.md b/.changeset/api-key-generation-endpoint.md new file mode 100644 index 000000000..32f12b247 --- /dev/null +++ b/.changeset/api-key-generation-endpoint.md @@ -0,0 +1,28 @@ +--- +'@objectstack/runtime': minor +--- + +feat(runtime): API-key generation endpoint — show-once `sys_api_key` (ADR-0036, closes framework#1629) + +Adds `POST /api/v1/keys` — the only path that mints a `sys_api_key`. Phase 1a +shipped key *verification* and the `generateApiKey()` primitive; this is the +missing *generation* half that unblocks the self-serve connect flow. + +- Requires an authenticated principal; returns the **raw secret exactly once** + (`{ id, name, prefix, key }`). Only the sha256 **hash** is persisted — the raw + key is never stored, logged, or re-displayable. +- **Security (zero-tolerance):** `user_id` is pinned to the caller and never read + from the body (no impersonation); the body is whitelisted to `name` (+ optional + validated future `expires_at`) — any `key`/`id`/`user_id`/`revoked` in the body + is ignored, so a caller cannot forge a known-secret or escalate. The row is + written with an elevated `{ isSystem: true }` context (sys_api_key is + protection-locked) with server-controlled contents. Anonymous → 401; + non-POST → 405; past/unparseable `expires_at` → 400. +- `scopes` are intentionally NOT accepted from the body in v1 (the verify path + adds scopes to permissions, so honouring arbitrary body scopes would be an + escalation vector); a generated key acts exactly AS the caller via `user_id` + resolution. Scoped/narrowing keys need subset-enforcement — deferred. + +11 security tests (show-once, hash-not-raw persisted, round-trip auth via the +verify path, impersonation blocked, forgery blocked, 401/405/400, expiry +end-to-end). Full runtime suite green (376). diff --git a/docs/adr/0036-app-as-rest-api-and-mcp-server.md b/docs/adr/0036-app-as-rest-api-and-mcp-server.md index 4c09372eb..fbb4f5da4 100644 --- a/docs/adr/0036-app-as-rest-api-and-mcp-server.md +++ b/docs/adr/0036-app-as-rest-api-and-mcp-server.md @@ -140,4 +140,6 @@ small, well-tested key-verification step. - **Phase 1a (framework auth) — shipped** (#1624): `sys_api_key` Bearer/header verified on the runtime auth path → principal under existing permissions + RLS. - **Phase 1b (objectui surfacing)** — Integrations page + show-once key + publish "View API" link. - **Phase 2 (framework MCP HTTP transport) — shipped** (#1626; package since renamed to `@objectstack/mcp`): Streamable HTTP at `/api/v1/mcp`, opt-in `OS_MCP_SERVER_ENABLED`, fail-closed auth, principal-bound object-CRUD tools. -- **Phase 2b (surfacing, per Amendment C)** — env-level remote-MCP connect (URL + show-once key + one-click deeplink) and a single generic ObjectStack **Skill**; *not* per-app, *not* hand-maintained vendor snippets. +- **Key generation (framework) — shipped**: `POST /api/v1/keys` mints a `sys_api_key` and returns the raw secret **once** (only the hash is stored; `user_id` pinned to the caller; fail-closed auth). The backend for the show-once key UX. +- **Generic ObjectStack Skill — shipped** (#1628): `renderSkillMarkdown({ mcpUrl })` produces the portable, cross-agent `SKILL.md`. +- **Phase 2b (objectui surfacing, per Amendment C)** — env-level remote-MCP connect page wiring `discovery.mcp` + `POST /keys` (show-once) + skill download; *not* per-app, *not* hand-maintained vendor snippets. diff --git a/packages/runtime/src/http-dispatcher.keys.test.ts b/packages/runtime/src/http-dispatcher.keys.test.ts new file mode 100644 index 000000000..bf847366b --- /dev/null +++ b/packages/runtime/src/http-dispatcher.keys.test.ts @@ -0,0 +1,170 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; + +import { HttpDispatcher } from './http-dispatcher.js'; +import { resolveExecutionContext } from './security/resolve-execution-context.js'; +import { hashApiKey } from './security/api-key.js'; + +/** + * Security-critical: the `POST /keys` mint path. We assert the show-once + * contract, that only the hash is persisted, the principal is pinned (no + * impersonation / forgery via the body), auth is fail-closed, and that a minted + * key actually authenticates through the verify path (round-trip). + */ + +function makeKernel() { + const rows: any[] = []; + const ql = { + insert: async (_obj: string, data: any, _opts: any) => { + const id = `key_${rows.length + 1}`; + rows.push({ id, ...data }); + return { id }; + }, + // Minimal find for the round-trip via resolveExecutionContext. + find: async (obj: string, opts: any) => { + const where = opts?.where ?? {}; + if (obj !== 'sys_api_key') return []; + return rows.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v)); + }, + update: async () => ({}), + delete: async () => ({}), + }; + const kernel: any = { + getService: (n: string) => (n === 'objectql' ? ql : undefined), + getServiceAsync: async (n: string) => (n === 'objectql' ? ql : undefined), + }; + return { kernel, rows }; +} + +function ctx(overrides: any = {}) { + return { + request: { headers: {} }, + response: {}, + environmentId: undefined, + executionContext: { userId: 'u1', isSystem: false, roles: [], permissions: [] }, + ...overrides, + }; +} + +function dispatcher(kernel: any) { + return new HttpDispatcher(kernel, undefined, { enforceProjectMembership: false }); +} + +describe('HttpDispatcher.handleKeys (POST /keys — key generation)', () => { + it('mints a key: 201, returns raw once, stores only the hash', async () => { + const { kernel, rows } = makeKernel(); + const res = await dispatcher(kernel).handleKeys('POST', { name: 'CI token' }, ctx()); + + expect(res.response.status).toBe(201); + const data = res.response.body.data; + expect(data.key).toMatch(/^osk_/); + expect(data.prefix).toBe(data.key.slice(0, data.prefix.length)); + expect(data.name).toBe('CI token'); + + // Exactly one row, storing the HASH not the raw key. + expect(rows).toHaveLength(1); + expect(rows[0].key).toBe(hashApiKey(data.key)); + expect(rows[0].key).not.toBe(data.key); + expect(rows[0].user_id).toBe('u1'); + expect(rows[0].revoked).toBe(false); + }); + + it('round-trip: the minted raw key authenticates via resolveExecutionContext', async () => { + const { kernel } = makeKernel(); + const ql = await (kernel.getServiceAsync('objectql')); + const res = await dispatcher(kernel).handleKeys('POST', { name: 'agent' }, ctx()); + const raw = res.response.body.data.key; + + const resolved = await resolveExecutionContext({ + getService: async () => undefined, + getQl: async () => ql, + request: { headers: { 'x-api-key': raw } }, + }); + expect(resolved.userId).toBe('u1'); + }); + + it('rejects anonymous requests (401, no row created)', async () => { + const { kernel, rows } = makeKernel(); + const res = await dispatcher(kernel).handleKeys('POST', { name: 'x' }, ctx({ executionContext: undefined })); + expect(res.response.status).toBe(401); + expect(rows).toHaveLength(0); + }); + + it('pins user_id to the caller — body cannot impersonate', async () => { + const { kernel, rows } = makeKernel(); + await dispatcher(kernel).handleKeys('POST', { name: 'x', user_id: 'evil', userId: 'evil' }, ctx()); + expect(rows[0].user_id).toBe('u1'); + }); + + it('ignores body-injected key/id/revoked — cannot forge a known secret', async () => { + const { kernel, rows } = makeKernel(); + const res = await dispatcher(kernel).handleKeys( + 'POST', + { name: 'x', key: 'attacker-known', id: 'fixed', revoked: false, prefix: 'evil_' }, + ctx(), + ); + const data = res.response.body.data; + // Stored key is the hash of the GENERATED raw, never the attacker's value. + expect(rows[0].key).toBe(hashApiKey(data.key)); + expect(rows[0].key).not.toBe('attacker-known'); + expect(rows[0].key).not.toBe(hashApiKey('attacker-known')); + expect(data.prefix).toMatch(/^osk_/); + }); + + it('rejects non-POST methods (405)', async () => { + const { kernel } = makeKernel(); + const res = await dispatcher(kernel).handleKeys('GET', {}, ctx()); + expect(res.response.status).toBe(405); + }); + + it('defaults the name when omitted', async () => { + const { kernel, rows } = makeKernel(); + await dispatcher(kernel).handleKeys('POST', {}, ctx()); + expect(rows[0].name).toBe('API Key'); + }); + + it('accepts a valid future expires_at and stores it', async () => { + const { kernel, rows } = makeKernel(); + const future = '2999-01-01T00:00:00.000Z'; + const res = await dispatcher(kernel).handleKeys('POST', { name: 'x', expires_at: future }, ctx()); + expect(res.response.status).toBe(201); + expect(rows[0].expires_at).toBe(future); + }); + + it('rejects a past expires_at (400, no row)', async () => { + const { kernel, rows } = makeKernel(); + const res = await dispatcher(kernel).handleKeys('POST', { name: 'x', expires_at: '2000-01-01T00:00:00Z' }, ctx()); + expect(res.response.status).toBe(400); + expect(rows).toHaveLength(0); + }); + + it('rejects an unparseable expires_at (400, no row)', async () => { + const { kernel, rows } = makeKernel(); + const res = await dispatcher(kernel).handleKeys('POST', { name: 'x', expires_at: 'not-a-date' }, ctx()); + expect(res.response.status).toBe(400); + expect(rows).toHaveLength(0); + }); + + it('an expired minted key does NOT authenticate (end-to-end with verify path)', async () => { + // Insert directly with a past expiry to confirm the verify path rejects it + // (handleKeys refuses to mint past-dated keys, so we simulate a stale one). + const { kernel } = makeKernel(); + const ql = await kernel.getServiceAsync('objectql'); + const raw = 'osk_stale_demo'; + await ql.insert('sys_api_key', { + key: hashApiKey(raw), + prefix: 'osk_stale_de', + user_id: 'u1', + revoked: false, + expires_at: '2000-01-01T00:00:00Z', + }, { context: { isSystem: true } }); + + const resolved = await resolveExecutionContext({ + getService: async () => undefined, + getQl: async () => ql, + request: { headers: { 'x-api-key': raw } }, + }); + expect(resolved.userId).toBeUndefined(); + }); +}); diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 1c6e276bc..cdea16a97 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -15,6 +15,7 @@ import { resolveExecutionContext, isPermissionDeniedError, } from './security/resolve-execution-context.js'; +import { generateApiKey } from './security/api-key.js'; /** Browser-safe UUID generator — prefers Web Crypto, falls back to RFC 4122 v4 */ function randomUUID(): string { @@ -393,6 +394,103 @@ export class HttpDispatcher { }; } + /** + * Generate a `sys_api_key` and return the raw secret EXACTLY ONCE + * (`POST /keys`). This is the only mint path — the raw key is never stored + * (only its sha256 hash) and never re-displayable. + * + * Security (zero-tolerance): + * - Requires an authenticated principal; `user_id` is PINNED to that + * caller and is NEVER read from the request body (no impersonation). + * - Body is whitelisted to `name` (+ optional `expires_at`); any + * `key` / `id` / `user_id` / `revoked` in the body is ignored, so a + * caller cannot forge a known-secret or escalate. + * - `scopes` are intentionally NOT accepted from the body in v1: the + * verify path ADDS scopes to the principal's permissions, so honouring + * arbitrary body scopes would be an escalation vector. A generated key + * therefore acts exactly AS the caller (via `user_id` resolution). + * Narrowing/scoped keys need subset-enforcement — deferred. + * - The raw key and its hash never enter logs or error messages. + * - The row is written with an elevated `{ isSystem: true }` context + * because `sys_api_key` is protection-locked; safe because the row's + * contents are fully server-controlled (user_id pinned to caller). + */ + async handleKeys(method: string, body: any, context: HttpProtocolContext): Promise { + if (method !== 'POST') { + return { handled: true, response: this.error('Method not allowed', 405) }; + } + + const ec = context.executionContext; + if (!ec || !ec.userId) { + return { handled: true, response: this.error('Unauthorized: sign in to generate an API key', 401) }; + } + + // ── Whitelist the body. Only `name` and optional `expires_at`. ── + const rawName = typeof body?.name === 'string' ? body.name.trim() : ''; + const name = rawName || 'API Key'; + + let expiresAt: string | undefined; + if (body?.expires_at != null && body.expires_at !== '') { + const ms = typeof body.expires_at === 'number' + ? (body.expires_at < 1e12 ? body.expires_at * 1000 : body.expires_at) + : Date.parse(String(body.expires_at)); + if (Number.isNaN(ms)) { + return { handled: true, response: this.error('Invalid expires_at: must be a parseable date', 400) }; + } + if (ms <= Date.now()) { + return { handled: true, response: this.error('Invalid expires_at: must be in the future', 400) }; + } + expiresAt = new Date(ms).toISOString(); + } + + const ql = (await this.getObjectQLService(context.environmentId)) + ?? (await this.resolveService('objectql', context.environmentId)); + if (!ql || typeof ql.insert !== 'function') { + return { handled: true, response: this.error('Data service not available', 503) }; + } + + // Generate AFTER validation so we never mint on a rejected request. + const generated = generateApiKey(); + + // Server-controlled row. user_id is pinned to the caller; only the hash + // is persisted. NOTHING from the body can set key/id/user_id/revoked. + const row: Record = { + name, + key: generated.hash, + prefix: generated.prefix, + user_id: ec.userId, + revoked: false, + }; + if (expiresAt) row.expires_at = expiresAt; + + let inserted: any; + try { + inserted = await ql.insert('sys_api_key', row, { context: { isSystem: true } }); + } catch { + // Never surface the underlying error (could echo row contents). + return { handled: true, response: this.error('Failed to create API key', 500) }; + } + const id = inserted?.id ?? (Array.isArray(inserted) ? inserted[0]?.id : undefined); + + // Raw key returned ONCE. Do not log it. + return { + handled: true, + response: { + status: 201, + body: { + success: true, + data: { + id, + name, + prefix: generated.prefix, + key: generated.raw, + ...(expiresAt ? { expires_at: expiresAt } : {}), + }, + }, + }, + }; + } + /** * Parse a project UUID out of a scoped URL path such as * `/api/v1/environments/abc-123/data/task` or `/projects/abc-123/meta`. @@ -2737,6 +2835,10 @@ export class HttpDispatcher { return this.handleMcp(body, context); } + if (cleanPath === '/keys' || cleanPath.startsWith('/keys/') || cleanPath.startsWith('/keys?')) { + return this.handleKeys(method, body, context); + } + if (cleanPath.startsWith('/graphql')) { if (method === 'POST') return this.handleGraphQL(body, context); // GraphQL usually GET for Playground is handled by middleware but we can return 405 or handle it