agent-credentials-security.test.mjs
181 lines 7.1 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago
1 /**
2 * Phase C + Lane D — security: mint gates, no SESSION_STORE_UNAVAILABLE from agent routes.
3 */
4
5 import { describe, it } from 'node:test';
6 import assert from 'node:assert/strict';
7 import fs from 'node:fs/promises';
8 import os from 'node:os';
9 import path from 'node:path';
10 import http from 'node:http';
11 import jwt from 'jsonwebtoken';
12 import express from 'express';
13 import { readFile } from 'node:fs/promises';
14 import { createAgentCredentialRouter } from '../hub/gateway/agent-credential-routes.mjs';
15 import {
16 mayApplyAdminAllowlistOverride,
17 subFromVerifiedPayload,
18 roleFromVerifiedAccessPayload,
19 } from '../hub/gateway/access-token-authz.mjs';
20 import { roleEligibleForPersonalSelfApply } from '../lib/hub-proposal-personal-self-apply.mjs';
21 import { normalizeScopes, agentScopesPermitMethod } from '../hub/lib/agent-credential-core.mjs';
22
23 const SECRET = 'phase-c-security-test-secret-32byte!!';
24 const routesSrc = await readFile(
25 new URL('../hub/gateway/agent-credential-routes.mjs', import.meta.url),
26 'utf8'
27 );
28
29 describe('Phase C security — agent credentials', () => {
30 it('rejects admin scopes at normalize', () => {
31 assert.throws(() => normalizeScopes(['admin']), (e) => e.code === 'AGENT_SCOPE_FORBIDDEN');
32 assert.throws(() => normalizeScopes(['vault:admin']), (e) => e.code === 'AGENT_SCOPE_FORBIDDEN');
33 });
34
35 it('mayApplyAdminAllowlistOverride is false for agent_access', () => {
36 assert.equal(mayApplyAdminAllowlistOverride({ type: 'agent_access', sub: 'google:1' }), false);
37 const role = roleFromVerifiedAccessPayload(
38 { type: 'agent_access', sub: 'google:1', scopes: ['propose', 'vault:read'] },
39 () => 'admin'
40 );
41 assert.equal(role.role, 'member');
42 assert.equal(role.isAgentAccess, true);
43 });
44
45 it('propose cannot approve paths', () => {
46 const scopes = ['propose', 'vault:read'];
47 assert.equal(agentScopesPermitMethod(scopes, 'POST', '/api/v1/proposals/x/approve'), false);
48 assert.equal(agentScopesPermitMethod(scopes, 'POST', '/api/v1/proposals/x/discard'), false);
49 });
50
51 it('self-apply refuses agent_access tokenType', () => {
52 assert.equal(
53 roleEligibleForPersonalSelfApply('member', { tokenType: 'agent_access', humanActor: true }),
54 false
55 );
56 });
57
58 it('browser refresh-shaped token rejected by exchange; mcp cannot mint', async () => {
59 const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'kt-agent-sec-'));
60 process.env.KNOWTATION_GATEWAY_DATA_DIR = dir;
61 const app = express();
62 const { router } = createAgentCredentialRouter({
63 sessionSecret: SECRET,
64 getSessionSub: (req) => {
65 try {
66 return jwt.verify(req.headers.authorization.slice(7), SECRET).sub;
67 } catch {
68 return null;
69 }
70 },
71 getSessionPayload: (req) => {
72 try {
73 return jwt.verify(req.headers.authorization.slice(7), SECRET);
74 } catch {
75 return null;
76 }
77 },
78 grantedScopes: () => ['vault:read', 'vault:write'],
79 });
80 app.use('/api/v1/auth/agent', router);
81 const server = http.createServer(app);
82 await new Promise((r) => server.listen(0, r));
83 const base = `http://127.0.0.1:${server.address().port}`;
84 try {
85 const bad = await fetch(`${base}/api/v1/auth/agent/token`, {
86 method: 'POST',
87 headers: { 'Content-Type': 'application/json' },
88 body: JSON.stringify({ credential: 'notprefix.secretvalue' }),
89 });
90 assert.equal(bad.status, 401);
91
92 const mcpTok = jwt.sign(
93 { sub: 'google:1', type: 'mcp_access', scopes: ['vault:write'] },
94 SECRET,
95 { expiresIn: '1h' }
96 );
97 const mint = await fetch(`${base}/api/v1/auth/agent/credentials`, {
98 method: 'POST',
99 headers: { Authorization: `Bearer ${mcpTok}`, 'Content-Type': 'application/json' },
100 body: JSON.stringify({ name: 'x', vault_ids: ['default'] }),
101 });
102 assert.equal(mint.status, 403);
103
104 const offlineApp = express();
105 const { router: offlineRouter } = createAgentCredentialRouter({
106 sessionSecret: SECRET,
107 getSessionSub: () => 'google:1',
108 getSessionPayload: () => ({ type: 'session', sub: 'google:1' }),
109 grantedScopes: () => ['vault:read'],
110 offlineLockedActive: true,
111 });
112 offlineApp.use('/api/v1/auth/agent', offlineRouter);
113 const s2 = http.createServer(offlineApp);
114 await new Promise((r) => s2.listen(0, r));
115 const b2 = `http://127.0.0.1:${s2.address().port}`;
116 const ol = await fetch(`${b2}/api/v1/auth/agent/credentials`, {
117 method: 'POST',
118 headers: { Authorization: 'Bearer x', 'Content-Type': 'application/json' },
119 body: JSON.stringify({ name: 'x', vault_ids: ['default'] }),
120 });
121 assert.equal(ol.status, 503);
122 const olBody = await ol.json();
123 assert.equal(olBody.code, 'AGENT_CREDENTIALS_UNSUPPORTED_OFFLINE_LOCKED');
124 await new Promise((r) => s2.close(r));
125
126 // Regression: pre-Phase-C style payload without typ/aud must not authorize propose.
127 assert.equal(
128 subFromVerifiedPayload(
129 { sub: 'google:1', type: 'agent_access', scopes: ['propose', 'vault:read'] },
130 { method: 'POST', path: '/api/v1/proposals' }
131 ),
132 null
133 );
134 } finally {
135 await new Promise((r) => server.close(r));
136 delete process.env.KNOWTATION_GATEWAY_DATA_DIR;
137 await fs.rm(dir, { recursive: true, force: true });
138 }
139 });
140
141 it('agent routes source never emits SESSION_STORE_UNAVAILABLE', () => {
142 assert.ok(!routesSrc.includes('SESSION_STORE_UNAVAILABLE'));
143 });
144
145 it('list omits secret hash and lookup_id on health rows', async () => {
146 const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'kt-agent-sec-list-'));
147 process.env.KNOWTATION_GATEWAY_DATA_DIR = dir;
148 const app = express();
149 const { router } = createAgentCredentialRouter({
150 sessionSecret: SECRET,
151 getSessionSub: () => 'google:1',
152 getSessionPayload: () => ({ sub: 'google:1', type: 'session' }),
153 grantedScopes: () => ['vault:read'],
154 });
155 app.use('/api/v1/auth/agent', router);
156 const server = http.createServer(app);
157 await new Promise((r) => server.listen(0, r));
158 const base = `http://127.0.0.1:${server.address().port}`;
159 const session = jwt.sign({ sub: 'google:1', type: 'session' }, SECRET, { expiresIn: '1h' });
160 try {
161 const mint = await fetch(`${base}/api/v1/auth/agent/credentials`, {
162 method: 'POST',
163 headers: { Authorization: `Bearer ${session}`, 'Content-Type': 'application/json' },
164 body: JSON.stringify({ name: 'sec', vault_ids: ['default'] }),
165 });
166 const m = await mint.json();
167 const list = await (await fetch(`${base}/api/v1/auth/agent/credentials`, {
168 headers: { Authorization: `Bearer ${session}` },
169 })).json();
170 const row = list.credentials[0];
171 assert.equal(row.credential, undefined);
172 assert.equal(row.token_hash, undefined);
173 assert.equal(row.lookup_id, undefined);
174 assert.ok(!JSON.stringify(list).includes(m.credential));
175 } finally {
176 await new Promise((r) => server.close(r));
177 delete process.env.KNOWTATION_GATEWAY_DATA_DIR;
178 await fs.rm(dir, { recursive: true, force: true });
179 }
180 });
181 });
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago