Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/api-key-generation-endpoint.md
Original file line number Diff line number Diff line change
@@ -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).
4 changes: 3 additions & 1 deletion docs/adr/0036-app-as-rest-api-and-mcp-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
170 changes: 170 additions & 0 deletions packages/runtime/src/http-dispatcher.keys.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
102 changes: 102 additions & 0 deletions packages/runtime/src/http-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<HttpDispatcherResult> {
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<string, unknown> = {
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`.
Expand Down Expand Up @@ -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
Expand Down
Loading