Skip to content

Commit f68be58

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(runtime): API-key generation endpoint — show-once sys_api_key (closes #1629) (#1630)
Adds POST /api/v1/keys — the only mint path for sys_api_key. Phase 1a shipped verification + the generateApiKey() primitive; this is the missing generation half that unblocks the self-serve connect flow (ADR-0036 Phase 2b). - Authenticated principal required; returns the raw secret EXACTLY ONCE ({ id, name, prefix, key }). Only the sha256 hash is persisted — raw key never stored, logged, or re-displayable. - Security (zero-tolerance): user_id pinned to the caller, never from the body (no impersonation); body whitelisted to name (+ optional validated future expires_at) — body key/id/user_id/revoked ignored (no forgery/escalation); elevated { isSystem:true } insert with server-controlled contents (sys_api_key is protection-locked). Anonymous→401, non-POST→405, bad expires_at→400. - scopes NOT accepted from body in v1 (verify path adds scopes to permissions → escalation risk); a key acts AS the caller via user_id resolution. Scoped keys need subset-enforcement — deferred. Tests: 11 security cases (show-once, hash-not-raw, round-trip auth via verify path, impersonation blocked, forgery blocked, 401/405/400, expiry e2e). Full runtime suite green (376). ADR-0036 Status updated. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 87cb13c commit f68be58

4 files changed

Lines changed: 303 additions & 1 deletion

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
'@objectstack/runtime': minor
3+
---
4+
5+
feat(runtime): API-key generation endpoint — show-once `sys_api_key` (ADR-0036, closes framework#1629)
6+
7+
Adds `POST /api/v1/keys` — the only path that mints a `sys_api_key`. Phase 1a
8+
shipped key *verification* and the `generateApiKey()` primitive; this is the
9+
missing *generation* half that unblocks the self-serve connect flow.
10+
11+
- Requires an authenticated principal; returns the **raw secret exactly once**
12+
(`{ id, name, prefix, key }`). Only the sha256 **hash** is persisted — the raw
13+
key is never stored, logged, or re-displayable.
14+
- **Security (zero-tolerance):** `user_id` is pinned to the caller and never read
15+
from the body (no impersonation); the body is whitelisted to `name` (+ optional
16+
validated future `expires_at`) — any `key`/`id`/`user_id`/`revoked` in the body
17+
is ignored, so a caller cannot forge a known-secret or escalate. The row is
18+
written with an elevated `{ isSystem: true }` context (sys_api_key is
19+
protection-locked) with server-controlled contents. Anonymous → 401;
20+
non-POST → 405; past/unparseable `expires_at` → 400.
21+
- `scopes` are intentionally NOT accepted from the body in v1 (the verify path
22+
adds scopes to permissions, so honouring arbitrary body scopes would be an
23+
escalation vector); a generated key acts exactly AS the caller via `user_id`
24+
resolution. Scoped/narrowing keys need subset-enforcement — deferred.
25+
26+
11 security tests (show-once, hash-not-raw persisted, round-trip auth via the
27+
verify path, impersonation blocked, forgery blocked, 401/405/400, expiry
28+
end-to-end). Full runtime suite green (376).

docs/adr/0036-app-as-rest-api-and-mcp-server.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,4 +140,6 @@ small, well-tested key-verification step.
140140
- **Phase 1a (framework auth) — shipped** (#1624): `sys_api_key` Bearer/header verified on the runtime auth path → principal under existing permissions + RLS.
141141
- **Phase 1b (objectui surfacing)** — Integrations page + show-once key + publish "View API" link.
142142
- **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.
143-
- **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.
143+
- **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.
144+
- **Generic ObjectStack Skill — shipped** (#1628): `renderSkillMarkdown({ mcpUrl })` produces the portable, cross-agent `SKILL.md`.
145+
- **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.
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
5+
import { HttpDispatcher } from './http-dispatcher.js';
6+
import { resolveExecutionContext } from './security/resolve-execution-context.js';
7+
import { hashApiKey } from './security/api-key.js';
8+
9+
/**
10+
* Security-critical: the `POST /keys` mint path. We assert the show-once
11+
* contract, that only the hash is persisted, the principal is pinned (no
12+
* impersonation / forgery via the body), auth is fail-closed, and that a minted
13+
* key actually authenticates through the verify path (round-trip).
14+
*/
15+
16+
function makeKernel() {
17+
const rows: any[] = [];
18+
const ql = {
19+
insert: async (_obj: string, data: any, _opts: any) => {
20+
const id = `key_${rows.length + 1}`;
21+
rows.push({ id, ...data });
22+
return { id };
23+
},
24+
// Minimal find for the round-trip via resolveExecutionContext.
25+
find: async (obj: string, opts: any) => {
26+
const where = opts?.where ?? {};
27+
if (obj !== 'sys_api_key') return [];
28+
return rows.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v));
29+
},
30+
update: async () => ({}),
31+
delete: async () => ({}),
32+
};
33+
const kernel: any = {
34+
getService: (n: string) => (n === 'objectql' ? ql : undefined),
35+
getServiceAsync: async (n: string) => (n === 'objectql' ? ql : undefined),
36+
};
37+
return { kernel, rows };
38+
}
39+
40+
function ctx(overrides: any = {}) {
41+
return {
42+
request: { headers: {} },
43+
response: {},
44+
environmentId: undefined,
45+
executionContext: { userId: 'u1', isSystem: false, roles: [], permissions: [] },
46+
...overrides,
47+
};
48+
}
49+
50+
function dispatcher(kernel: any) {
51+
return new HttpDispatcher(kernel, undefined, { enforceProjectMembership: false });
52+
}
53+
54+
describe('HttpDispatcher.handleKeys (POST /keys — key generation)', () => {
55+
it('mints a key: 201, returns raw once, stores only the hash', async () => {
56+
const { kernel, rows } = makeKernel();
57+
const res = await dispatcher(kernel).handleKeys('POST', { name: 'CI token' }, ctx());
58+
59+
expect(res.response.status).toBe(201);
60+
const data = res.response.body.data;
61+
expect(data.key).toMatch(/^osk_/);
62+
expect(data.prefix).toBe(data.key.slice(0, data.prefix.length));
63+
expect(data.name).toBe('CI token');
64+
65+
// Exactly one row, storing the HASH not the raw key.
66+
expect(rows).toHaveLength(1);
67+
expect(rows[0].key).toBe(hashApiKey(data.key));
68+
expect(rows[0].key).not.toBe(data.key);
69+
expect(rows[0].user_id).toBe('u1');
70+
expect(rows[0].revoked).toBe(false);
71+
});
72+
73+
it('round-trip: the minted raw key authenticates via resolveExecutionContext', async () => {
74+
const { kernel } = makeKernel();
75+
const ql = await (kernel.getServiceAsync('objectql'));
76+
const res = await dispatcher(kernel).handleKeys('POST', { name: 'agent' }, ctx());
77+
const raw = res.response.body.data.key;
78+
79+
const resolved = await resolveExecutionContext({
80+
getService: async () => undefined,
81+
getQl: async () => ql,
82+
request: { headers: { 'x-api-key': raw } },
83+
});
84+
expect(resolved.userId).toBe('u1');
85+
});
86+
87+
it('rejects anonymous requests (401, no row created)', async () => {
88+
const { kernel, rows } = makeKernel();
89+
const res = await dispatcher(kernel).handleKeys('POST', { name: 'x' }, ctx({ executionContext: undefined }));
90+
expect(res.response.status).toBe(401);
91+
expect(rows).toHaveLength(0);
92+
});
93+
94+
it('pins user_id to the caller — body cannot impersonate', async () => {
95+
const { kernel, rows } = makeKernel();
96+
await dispatcher(kernel).handleKeys('POST', { name: 'x', user_id: 'evil', userId: 'evil' }, ctx());
97+
expect(rows[0].user_id).toBe('u1');
98+
});
99+
100+
it('ignores body-injected key/id/revoked — cannot forge a known secret', async () => {
101+
const { kernel, rows } = makeKernel();
102+
const res = await dispatcher(kernel).handleKeys(
103+
'POST',
104+
{ name: 'x', key: 'attacker-known', id: 'fixed', revoked: false, prefix: 'evil_' },
105+
ctx(),
106+
);
107+
const data = res.response.body.data;
108+
// Stored key is the hash of the GENERATED raw, never the attacker's value.
109+
expect(rows[0].key).toBe(hashApiKey(data.key));
110+
expect(rows[0].key).not.toBe('attacker-known');
111+
expect(rows[0].key).not.toBe(hashApiKey('attacker-known'));
112+
expect(data.prefix).toMatch(/^osk_/);
113+
});
114+
115+
it('rejects non-POST methods (405)', async () => {
116+
const { kernel } = makeKernel();
117+
const res = await dispatcher(kernel).handleKeys('GET', {}, ctx());
118+
expect(res.response.status).toBe(405);
119+
});
120+
121+
it('defaults the name when omitted', async () => {
122+
const { kernel, rows } = makeKernel();
123+
await dispatcher(kernel).handleKeys('POST', {}, ctx());
124+
expect(rows[0].name).toBe('API Key');
125+
});
126+
127+
it('accepts a valid future expires_at and stores it', async () => {
128+
const { kernel, rows } = makeKernel();
129+
const future = '2999-01-01T00:00:00.000Z';
130+
const res = await dispatcher(kernel).handleKeys('POST', { name: 'x', expires_at: future }, ctx());
131+
expect(res.response.status).toBe(201);
132+
expect(rows[0].expires_at).toBe(future);
133+
});
134+
135+
it('rejects a past expires_at (400, no row)', async () => {
136+
const { kernel, rows } = makeKernel();
137+
const res = await dispatcher(kernel).handleKeys('POST', { name: 'x', expires_at: '2000-01-01T00:00:00Z' }, ctx());
138+
expect(res.response.status).toBe(400);
139+
expect(rows).toHaveLength(0);
140+
});
141+
142+
it('rejects an unparseable expires_at (400, no row)', async () => {
143+
const { kernel, rows } = makeKernel();
144+
const res = await dispatcher(kernel).handleKeys('POST', { name: 'x', expires_at: 'not-a-date' }, ctx());
145+
expect(res.response.status).toBe(400);
146+
expect(rows).toHaveLength(0);
147+
});
148+
149+
it('an expired minted key does NOT authenticate (end-to-end with verify path)', async () => {
150+
// Insert directly with a past expiry to confirm the verify path rejects it
151+
// (handleKeys refuses to mint past-dated keys, so we simulate a stale one).
152+
const { kernel } = makeKernel();
153+
const ql = await kernel.getServiceAsync('objectql');
154+
const raw = 'osk_stale_demo';
155+
await ql.insert('sys_api_key', {
156+
key: hashApiKey(raw),
157+
prefix: 'osk_stale_de',
158+
user_id: 'u1',
159+
revoked: false,
160+
expires_at: '2000-01-01T00:00:00Z',
161+
}, { context: { isSystem: true } });
162+
163+
const resolved = await resolveExecutionContext({
164+
getService: async () => undefined,
165+
getQl: async () => ql,
166+
request: { headers: { 'x-api-key': raw } },
167+
});
168+
expect(resolved.userId).toBeUndefined();
169+
});
170+
});

packages/runtime/src/http-dispatcher.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
resolveExecutionContext,
1616
isPermissionDeniedError,
1717
} from './security/resolve-execution-context.js';
18+
import { generateApiKey } from './security/api-key.js';
1819

1920
/** Browser-safe UUID generator — prefers Web Crypto, falls back to RFC 4122 v4 */
2021
function randomUUID(): string {
@@ -393,6 +394,103 @@ export class HttpDispatcher {
393394
};
394395
}
395396

397+
/**
398+
* Generate a `sys_api_key` and return the raw secret EXACTLY ONCE
399+
* (`POST /keys`). This is the only mint path — the raw key is never stored
400+
* (only its sha256 hash) and never re-displayable.
401+
*
402+
* Security (zero-tolerance):
403+
* - Requires an authenticated principal; `user_id` is PINNED to that
404+
* caller and is NEVER read from the request body (no impersonation).
405+
* - Body is whitelisted to `name` (+ optional `expires_at`); any
406+
* `key` / `id` / `user_id` / `revoked` in the body is ignored, so a
407+
* caller cannot forge a known-secret or escalate.
408+
* - `scopes` are intentionally NOT accepted from the body in v1: the
409+
* verify path ADDS scopes to the principal's permissions, so honouring
410+
* arbitrary body scopes would be an escalation vector. A generated key
411+
* therefore acts exactly AS the caller (via `user_id` resolution).
412+
* Narrowing/scoped keys need subset-enforcement — deferred.
413+
* - The raw key and its hash never enter logs or error messages.
414+
* - The row is written with an elevated `{ isSystem: true }` context
415+
* because `sys_api_key` is protection-locked; safe because the row's
416+
* contents are fully server-controlled (user_id pinned to caller).
417+
*/
418+
async handleKeys(method: string, body: any, context: HttpProtocolContext): Promise<HttpDispatcherResult> {
419+
if (method !== 'POST') {
420+
return { handled: true, response: this.error('Method not allowed', 405) };
421+
}
422+
423+
const ec = context.executionContext;
424+
if (!ec || !ec.userId) {
425+
return { handled: true, response: this.error('Unauthorized: sign in to generate an API key', 401) };
426+
}
427+
428+
// ── Whitelist the body. Only `name` and optional `expires_at`. ──
429+
const rawName = typeof body?.name === 'string' ? body.name.trim() : '';
430+
const name = rawName || 'API Key';
431+
432+
let expiresAt: string | undefined;
433+
if (body?.expires_at != null && body.expires_at !== '') {
434+
const ms = typeof body.expires_at === 'number'
435+
? (body.expires_at < 1e12 ? body.expires_at * 1000 : body.expires_at)
436+
: Date.parse(String(body.expires_at));
437+
if (Number.isNaN(ms)) {
438+
return { handled: true, response: this.error('Invalid expires_at: must be a parseable date', 400) };
439+
}
440+
if (ms <= Date.now()) {
441+
return { handled: true, response: this.error('Invalid expires_at: must be in the future', 400) };
442+
}
443+
expiresAt = new Date(ms).toISOString();
444+
}
445+
446+
const ql = (await this.getObjectQLService(context.environmentId))
447+
?? (await this.resolveService('objectql', context.environmentId));
448+
if (!ql || typeof ql.insert !== 'function') {
449+
return { handled: true, response: this.error('Data service not available', 503) };
450+
}
451+
452+
// Generate AFTER validation so we never mint on a rejected request.
453+
const generated = generateApiKey();
454+
455+
// Server-controlled row. user_id is pinned to the caller; only the hash
456+
// is persisted. NOTHING from the body can set key/id/user_id/revoked.
457+
const row: Record<string, unknown> = {
458+
name,
459+
key: generated.hash,
460+
prefix: generated.prefix,
461+
user_id: ec.userId,
462+
revoked: false,
463+
};
464+
if (expiresAt) row.expires_at = expiresAt;
465+
466+
let inserted: any;
467+
try {
468+
inserted = await ql.insert('sys_api_key', row, { context: { isSystem: true } });
469+
} catch {
470+
// Never surface the underlying error (could echo row contents).
471+
return { handled: true, response: this.error('Failed to create API key', 500) };
472+
}
473+
const id = inserted?.id ?? (Array.isArray(inserted) ? inserted[0]?.id : undefined);
474+
475+
// Raw key returned ONCE. Do not log it.
476+
return {
477+
handled: true,
478+
response: {
479+
status: 201,
480+
body: {
481+
success: true,
482+
data: {
483+
id,
484+
name,
485+
prefix: generated.prefix,
486+
key: generated.raw,
487+
...(expiresAt ? { expires_at: expiresAt } : {}),
488+
},
489+
},
490+
},
491+
};
492+
}
493+
396494
/**
397495
* Parse a project UUID out of a scoped URL path such as
398496
* `/api/v1/environments/abc-123/data/task` or `/projects/abc-123/meta`.
@@ -2737,6 +2835,10 @@ export class HttpDispatcher {
27372835
return this.handleMcp(body, context);
27382836
}
27392837

2838+
if (cleanPath === '/keys' || cleanPath.startsWith('/keys/') || cleanPath.startsWith('/keys?')) {
2839+
return this.handleKeys(method, body, context);
2840+
}
2841+
27402842
if (cleanPath.startsWith('/graphql')) {
27412843
if (method === 'POST') return this.handleGraphQL(body, context);
27422844
// GraphQL usually GET for Playground is handled by middleware but we can return 405 or handle it

0 commit comments

Comments
 (0)