Skip to content

Commit 30b1e45

Browse files
committed
feat(kilo-chat): per-sandbox token auth with admin mint/revoke routes
Replace the shared KILOCHAT_API_TOKEN + x-kilo-sandbox-id header auth with per-sandbox tokens looked up through SandboxRegistryDO. A leaked token's blast radius is now one tenant instead of all — the shared secret that every kiloclaw machine carried is gone. Auth path changes: - Tokens starting with "ksk_" are treated as sandbox bearers and resolved through the registry. The x-kilo-sandbox-id header is no longer read at all — attempts to spoof identity via header are ignored (a regression test pins this behavior). - JWTs for human users are unchanged. - Admin routes under /v1/admin/* no-op the tenant middleware so their separate ADMIN_API_TOKEN guard runs instead. New admin surface for the control plane: - POST /v1/admin/sandboxes/:sandboxId/token — mint (replaces any existing token atomically; plaintext is returned once). - DELETE /v1/admin/sandboxes/:sandboxId/token — revoke. Wrangler config swaps the shared KILOCHAT_API_TOKEN secrets_store binding for ADMIN_API_TOKEN, adds SANDBOX_REGISTRY_DO durable object binding, and bumps migrations to v2 for the new SQLite class. The e2e-test script now mints a sandbox token via the admin endpoint instead of reading KILOCHAT_API_TOKEN from the environment.
1 parent 305c0af commit 30b1e45

8 files changed

Lines changed: 378 additions & 101 deletions

File tree

services/kilo-chat/scripts/e2e-test.ts

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*
55
* Prerequisites:
66
* 1. kilo-chat running: cd services/kilo-chat && wrangler dev
7-
* 2. .dev.vars configured with KILOCHAT_API_TOKEN, NEXTAUTH_SECRET, etc.
7+
* 2. .dev.vars configured with ADMIN_API_TOKEN, NEXTAUTH_SECRET, etc.
88
* 3. (For full agent loop) kiloclaw running with kilo-chat plugin
99
*
1010
* Usage:
@@ -13,16 +13,16 @@
1313
* Environment variables:
1414
* KILO_CHAT_URL - kilo-chat base URL (default: http://localhost:8802)
1515
* NEXTAUTH_SECRET - JWT signing secret (must match .dev.vars)
16-
* KILOCHAT_API_TOKEN - API key for bot auth (must match .dev.vars)
17-
* SANDBOX_ID - sandbox ID for the bot (default: test-sandbox)
16+
* ADMIN_API_TOKEN - Admin token for minting the sandbox token
17+
* SANDBOX_ID - sandbox ID for the bot (default: e2e-test-sandbox)
1818
* TIMEOUT_MS - how long to wait for agent response (default: 30000)
1919
*/
2020

2121
import { SignJWT } from 'jose';
2222

2323
const BASE_URL = process.env.KILO_CHAT_URL ?? 'http://localhost:8802';
2424
const JWT_SECRET = process.env.NEXTAUTH_SECRET;
25-
const API_KEY = process.env.KILOCHAT_API_TOKEN;
25+
const ADMIN_TOKEN = process.env.ADMIN_API_TOKEN;
2626
const SANDBOX_ID = process.env.SANDBOX_ID ?? 'e2e-test-sandbox';
2727
const TIMEOUT_MS = Number(process.env.TIMEOUT_MS ?? '30000');
2828

@@ -46,11 +46,27 @@ async function signUserToken(userId: string): Promise<string> {
4646
.sign(new TextEncoder().encode(JWT_SECRET));
4747
}
4848

49-
function botHeaders(): Record<string, string> {
50-
if (!API_KEY) throw new Error('KILOCHAT_API_TOKEN required for bot auth');
49+
/**
50+
* Mint a fresh sandbox token via the admin API. Per-sandbox tokens replace
51+
* the old shared KILOCHAT_API_TOKEN flow entirely: the token alone identifies
52+
* the sandbox upstream, no x-kilo-sandbox-id header is sent.
53+
*/
54+
async function mintSandboxToken(): Promise<string> {
55+
if (!ADMIN_TOKEN) throw new Error('ADMIN_API_TOKEN required to mint a sandbox token');
56+
const res = await fetch(`${BASE_URL}/v1/admin/sandboxes/${SANDBOX_ID}/token`, {
57+
method: 'POST',
58+
headers: { authorization: `Bearer ${ADMIN_TOKEN}` },
59+
});
60+
if (!res.ok) {
61+
throw new Error(`Admin mint failed: ${res.status} ${await res.text()}`);
62+
}
63+
const body = (await res.json()) as { token: string };
64+
return body.token;
65+
}
66+
67+
function botHeaders(sandboxToken: string): Record<string, string> {
5168
return {
52-
authorization: `Bearer ${API_KEY}`,
53-
'x-kilo-sandbox-id': SANDBOX_ID,
69+
authorization: `Bearer ${sandboxToken}`,
5470
'content-type': 'application/json',
5571
};
5672
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { env, SELF } from 'cloudflare:test';
2+
import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
3+
import { SANDBOX_TOKEN_PREFIX, type SandboxRegistryDO } from '../do/sandbox-registry-do';
4+
import { getSandboxRegistryStub } from '../do/sandbox-registry-client';
5+
6+
const ADMIN_TOKEN = 'test-admin-token';
7+
8+
beforeAll(async () => {
9+
// wrangler.jsonc points ADMIN_API_TOKEN at a secrets_store binding; in
10+
// vitest-pool-workers the store is mocked and we need to prime it.
11+
const binding = env.ADMIN_API_TOKEN as unknown as {
12+
get: () => Promise<string>;
13+
// Some miniflare versions allow setting via internal API; fall back to
14+
// stubbing via spy if not present.
15+
};
16+
// Most straightforward: monkey-patch the `get` on this test run so the
17+
// route's call resolves to our fixed token.
18+
binding.get = async () => ADMIN_TOKEN;
19+
});
20+
21+
beforeEach(async () => {
22+
// Ensure a clean registry between tests — revoke anything left by a prior one.
23+
// Uses the singleton stub directly.
24+
const stub: DurableObjectStub<SandboxRegistryDO> = getSandboxRegistryStub(
25+
env.SANDBOX_REGISTRY_DO
26+
);
27+
// Nothing to list, so we just no-op — the tests below each use a distinct
28+
// sandbox id, so no cross-contamination is expected.
29+
void stub;
30+
});
31+
32+
async function req(
33+
path: string,
34+
init: RequestInit & { auth?: string | false } = {}
35+
): Promise<Response> {
36+
const headers = new Headers(init.headers);
37+
if (init.auth !== false) {
38+
headers.set('authorization', `Bearer ${init.auth ?? ADMIN_TOKEN}`);
39+
}
40+
return SELF.fetch(`https://kilo-chat.test${path}`, { ...init, headers });
41+
}
42+
43+
describe('admin routes', () => {
44+
describe('auth', () => {
45+
it('401 when authorization header is missing', async () => {
46+
const res = await req('/v1/admin/sandboxes/sbx_a/token', {
47+
method: 'POST',
48+
auth: false,
49+
});
50+
expect(res.status).toBe(401);
51+
});
52+
53+
it('401 when admin token is wrong', async () => {
54+
const res = await req('/v1/admin/sandboxes/sbx_a/token', {
55+
method: 'POST',
56+
auth: 'wrong',
57+
});
58+
expect(res.status).toBe(401);
59+
});
60+
61+
it('does not accept a sandbox token for admin routes', async () => {
62+
// Even a legitimate sandbox bearer cannot access admin endpoints.
63+
const stub = getSandboxRegistryStub(env.SANDBOX_REGISTRY_DO);
64+
const { token } = await stub.mintToken('sbx_admin_cross_tenant');
65+
const res = await req('/v1/admin/sandboxes/sbx_admin_cross_tenant/token', {
66+
method: 'POST',
67+
auth: token,
68+
});
69+
expect(res.status).toBe(401);
70+
});
71+
});
72+
73+
describe('POST /v1/admin/sandboxes/:sandboxId/token', () => {
74+
it('mints a fresh token and returns it with the hash', async () => {
75+
const res = await req('/v1/admin/sandboxes/sbx_mint_1/token', { method: 'POST' });
76+
expect(res.status).toBe(201);
77+
const body: { sandboxId: string; token: string; tokenHash: string } = await res.json();
78+
expect(body.sandboxId).toBe('sbx_mint_1');
79+
expect(body.token.startsWith(SANDBOX_TOKEN_PREFIX)).toBe(true);
80+
expect(body.tokenHash).toMatch(/^[0-9a-f]{64}$/);
81+
});
82+
83+
it('re-minting replaces the previous token', async () => {
84+
const first: { token: string } = await (
85+
await req('/v1/admin/sandboxes/sbx_remint/token', { method: 'POST' })
86+
).json();
87+
const second: { token: string } = await (
88+
await req('/v1/admin/sandboxes/sbx_remint/token', { method: 'POST' })
89+
).json();
90+
expect(first.token).not.toBe(second.token);
91+
92+
const stub = getSandboxRegistryStub(env.SANDBOX_REGISTRY_DO);
93+
expect(await stub.lookupSandbox(first.token)).toBeNull();
94+
expect(await stub.lookupSandbox(second.token)).toBe('sbx_remint');
95+
});
96+
97+
it('rejects malformed sandboxId with 400', async () => {
98+
const res = await req('/v1/admin/sandboxes/has%20spaces/token', { method: 'POST' });
99+
expect(res.status).toBe(400);
100+
});
101+
});
102+
103+
describe('DELETE /v1/admin/sandboxes/:sandboxId/token', () => {
104+
it('revokes a live token (204) and the token stops authenticating', async () => {
105+
const minted: { token: string } = await (
106+
await req('/v1/admin/sandboxes/sbx_revoke_1/token', { method: 'POST' })
107+
).json();
108+
109+
const del = await req('/v1/admin/sandboxes/sbx_revoke_1/token', { method: 'DELETE' });
110+
expect(del.status).toBe(204);
111+
112+
const stub = getSandboxRegistryStub(env.SANDBOX_REGISTRY_DO);
113+
expect(await stub.lookupSandbox(minted.token)).toBeNull();
114+
});
115+
116+
it('404 when no token exists for that sandbox', async () => {
117+
const res = await req('/v1/admin/sandboxes/sbx_never_minted/token', { method: 'DELETE' });
118+
expect(res.status).toBe(404);
119+
});
120+
121+
it('rejects malformed sandboxId with 400', async () => {
122+
const res = await req('/v1/admin/sandboxes/a b/token', { method: 'DELETE' });
123+
expect(res.status).toBe(400);
124+
});
125+
});
126+
});

services/kilo-chat/src/__tests__/auth.test.ts

Lines changed: 76 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -3,135 +3,142 @@ import { Hono } from 'hono';
33
import { signKiloToken } from '@kilocode/worker-utils';
44
import { authMiddleware } from '../auth';
55
import type { AuthContext } from '../auth';
6+
import { SANDBOX_TOKEN_PREFIX } from '../do/sandbox-registry-do';
7+
8+
const TEST_JWT_SECRET = 'test-secret-that-is-long-enough-for-hs256';
9+
10+
/**
11+
* Minimal fake of the SandboxRegistryDO binding: tests supply a token→sandbox
12+
* map and the fake's stub surfaces only `lookupSandbox` (the one method auth
13+
* touches). Avoids pulling in cloudflare:test just to exercise middleware.
14+
*/
15+
function fakeRegistryBinding(tokens: Record<string, string>) {
16+
const stub = {
17+
lookupSandbox: async (token: string) => tokens[token] ?? null,
18+
};
19+
return {
20+
idFromName: (_name: string) => ({ toString: () => 'fake-id' }),
21+
get: (_id: unknown) => stub,
22+
} as unknown;
23+
}
624

725
type MockEnv = {
8-
KILOCHAT_API_TOKEN: { get: () => Promise<string> };
926
NEXTAUTH_SECRET: { get: () => Promise<string> };
27+
SANDBOX_REGISTRY_DO: unknown;
1028
};
1129

12-
const TEST_API_KEY = 'test-api-key';
13-
const TEST_JWT_SECRET = 'test-secret-that-is-long-enough-for-hs256';
30+
function makeEnv(tokens: Record<string, string> = {}): MockEnv {
31+
return {
32+
NEXTAUTH_SECRET: { get: async () => TEST_JWT_SECRET },
33+
SANDBOX_REGISTRY_DO: fakeRegistryBinding(tokens),
34+
};
35+
}
1436

15-
function makeApp(_env: MockEnv) {
37+
function makeApp() {
1638
const app = new Hono<{ Bindings: MockEnv; Variables: AuthContext }>();
1739
app.use('*', authMiddleware);
1840
app.get('/test', c => c.json({ callerId: c.get('callerId'), callerKind: c.get('callerKind') }));
1941
return app;
2042
}
2143

22-
const defaultEnv: MockEnv = {
23-
KILOCHAT_API_TOKEN: { get: async () => TEST_API_KEY },
24-
NEXTAUTH_SECRET: { get: async () => TEST_JWT_SECRET },
25-
};
26-
2744
describe('authMiddleware', () => {
2845
it('returns 401 with no authorization header', async () => {
29-
const app = makeApp(defaultEnv);
30-
const res = await app.request('/test', {}, defaultEnv);
46+
const env = makeEnv();
47+
const res = await makeApp().request('/test', {}, env);
3148
expect(res.status).toBe(401);
32-
const body = await res.json();
33-
expect(body).toEqual({ error: 'Unauthorized' });
49+
expect(await res.json()).toEqual({ error: 'Unauthorized' });
3450
});
3551

36-
it('authenticates with valid API key + sandbox header', async () => {
37-
const app = makeApp(defaultEnv);
38-
const res = await app.request(
52+
it('authenticates a valid sandbox token and derives identity from the registry', async () => {
53+
const sandboxToken = `${SANDBOX_TOKEN_PREFIX}valid-token-for-sandbox-A`;
54+
const env = makeEnv({ [sandboxToken]: 'sandbox-A' });
55+
const res = await makeApp().request(
3956
'/test',
40-
{
41-
headers: {
42-
authorization: `Bearer ${TEST_API_KEY}`,
43-
'x-kilo-sandbox-id': 'sandbox-abc123',
44-
},
45-
},
46-
defaultEnv
57+
{ headers: { authorization: `Bearer ${sandboxToken}` } },
58+
env
4759
);
4860
expect(res.status).toBe(200);
49-
const body = await res.json();
50-
expect(body).toEqual({
51-
callerId: 'bot:kiloclaw:sandbox-abc123',
61+
expect(await res.json()).toEqual({
62+
callerId: 'bot:kiloclaw:sandbox-A',
5263
callerKind: 'bot',
5364
});
5465
});
5566

56-
it('returns 401 with valid API key but missing sandbox header', async () => {
57-
const app = makeApp(defaultEnv);
58-
const res = await app.request(
67+
it('ignores x-kilo-sandbox-id - identity is derived from the token, never the header', async () => {
68+
// A caller that knows a valid token for sandbox-A cannot impersonate
69+
// sandbox-B by setting x-kilo-sandbox-id: sandbox-B. The header is not
70+
// consulted at all by the new auth path.
71+
const sandboxToken = `${SANDBOX_TOKEN_PREFIX}token-bound-to-sandbox-A`;
72+
const env = makeEnv({ [sandboxToken]: 'sandbox-A' });
73+
const res = await makeApp().request(
5974
'/test',
6075
{
6176
headers: {
62-
authorization: `Bearer ${TEST_API_KEY}`,
77+
authorization: `Bearer ${sandboxToken}`,
78+
'x-kilo-sandbox-id': 'sandbox-B',
6379
},
6480
},
65-
defaultEnv
81+
env
6682
);
67-
expect(res.status).toBe(401);
68-
const body = await res.json();
69-
expect(body).toEqual({ error: 'Unauthorized' });
83+
expect(res.status).toBe(200);
84+
expect(await res.json()).toMatchObject({ callerId: 'bot:kiloclaw:sandbox-A' });
7085
});
7186

72-
it('returns 401 with wrong API key and no valid JWT', async () => {
73-
const app = makeApp(defaultEnv);
74-
const res = await app.request(
87+
it('returns 401 when a ksk_-prefixed token is unknown to the registry', async () => {
88+
const env = makeEnv({}); // no tokens registered
89+
const res = await makeApp().request(
7590
'/test',
76-
{
77-
headers: {
78-
authorization: 'Bearer wrong-key',
79-
'x-kilo-sandbox-id': 'sandbox-abc123',
80-
},
81-
},
82-
defaultEnv
91+
{ headers: { authorization: `Bearer ${SANDBOX_TOKEN_PREFIX}unknown` } },
92+
env
8393
);
8494
expect(res.status).toBe(401);
85-
const body = await res.json();
86-
expect(body).toEqual({ error: 'Unauthorized' });
95+
expect(await res.json()).toEqual({ error: 'Unauthorized' });
8796
});
8897

89-
it('authenticates with valid JWT and sets callerId + callerKind', async () => {
98+
it('authenticates with a valid JWT and sets user identity', async () => {
9099
const { token } = await signKiloToken({
91100
userId: 'user-xyz-789',
92101
pepper: null,
93102
secret: TEST_JWT_SECRET,
94103
expiresInSeconds: 3600,
95104
});
96-
97-
const app = makeApp(defaultEnv);
98-
const res = await app.request(
105+
const env = makeEnv();
106+
const res = await makeApp().request(
99107
'/test',
100-
{
101-
headers: {
102-
authorization: `Bearer ${token}`,
103-
},
104-
},
105-
defaultEnv
108+
{ headers: { authorization: `Bearer ${token}` } },
109+
env
106110
);
107111
expect(res.status).toBe(200);
108-
const body = await res.json();
109-
expect(body).toEqual({
112+
expect(await res.json()).toEqual({
110113
callerId: 'user-xyz-789',
111114
callerKind: 'user',
112115
});
113116
});
114117

115-
it('returns 401 with expired JWT', async () => {
118+
it('returns 401 with an expired JWT', async () => {
116119
const { token } = await signKiloToken({
117120
userId: 'user-xyz-789',
118121
pepper: null,
119122
secret: TEST_JWT_SECRET,
120123
expiresInSeconds: -1,
121124
});
125+
const env = makeEnv();
126+
const res = await makeApp().request(
127+
'/test',
128+
{ headers: { authorization: `Bearer ${token}` } },
129+
env
130+
);
131+
expect(res.status).toBe(401);
132+
expect(await res.json()).toEqual({ error: 'Unauthorized' });
133+
});
122134

123-
const app = makeApp(defaultEnv);
124-
const res = await app.request(
135+
it('returns 401 with a bearer token that is neither a sandbox token nor a valid JWT', async () => {
136+
const env = makeEnv();
137+
const res = await makeApp().request(
125138
'/test',
126-
{
127-
headers: {
128-
authorization: `Bearer ${token}`,
129-
},
130-
},
131-
defaultEnv
139+
{ headers: { authorization: 'Bearer not-a-jwt-and-not-ksk' } },
140+
env
132141
);
133142
expect(res.status).toBe(401);
134-
const body = await res.json();
135-
expect(body).toEqual({ error: 'Unauthorized' });
136143
});
137144
});

0 commit comments

Comments
 (0)