agent-credential-routes.mjs
254 lines 8.1 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago
1 /**
2 * Phase C — Hub REST agent credential routes (Netlify-mounted).
3 * Paths under api/v1/auth/agent/*
4 */
5
6 import express from 'express';
7 import jwt from 'jsonwebtoken';
8 import {
9 AGENT_ACCESS_TTL_SECONDS,
10 AGENT_ACCESS_TYPE,
11 AGENT_ACCESS_TYP,
12 AGENT_ACCESS_AUD,
13 AGENT_CREDENTIAL_PREFIX,
14 DEFAULT_AGENT_SCOPES,
15 applyScopeCeiling,
16 normalizeScopes,
17 normalizeVaultIds,
18 } from '../lib/agent-credential-core.mjs';
19 import {
20 createAgentCredentialStore,
21 AGENT_CREDENTIAL_STORE_INCONSISTENT,
22 } from './agent-credential-store.mjs';
23 import {
24 isMcpAccessPayload,
25 isAgentAccessPayload,
26 resolveActorTokenClass,
27 } from './access-token-authz.mjs';
28
29 const exchangeBuckets = new Map();
30 const EXCHANGE_WINDOW_MS = 60 * 1000;
31 const EXCHANGE_MAX = 60;
32
33 /**
34 * @param {string} key
35 * @returns {boolean}
36 */
37 function allowExchange(key) {
38 const now = Date.now();
39 let bucket = exchangeBuckets.get(key);
40 if (!bucket || now - bucket.start > EXCHANGE_WINDOW_MS) {
41 bucket = { start: now, count: 0 };
42 exchangeBuckets.set(key, bucket);
43 }
44 bucket.count += 1;
45 return bucket.count <= EXCHANGE_MAX;
46 }
47
48 /**
49 * @param {{
50 * sessionSecret: string,
51 * getSessionSub: (req: import('express').Request) => string | null,
52 * getSessionPayload?: (req: import('express').Request) => object | null,
53 * grantedScopes: (sub: string) => string[],
54 * offlineLockedActive?: boolean,
55 * store?: ReturnType<typeof createAgentCredentialStore>,
56 * }} opts
57 */
58 export function createAgentCredentialRouter(opts) {
59 const {
60 sessionSecret,
61 getSessionSub,
62 getSessionPayload,
63 grantedScopes,
64 offlineLockedActive = false,
65 store = createAgentCredentialStore(),
66 } = opts;
67
68 if (!sessionSecret) throw new Error('createAgentCredentialRouter requires sessionSecret');
69
70 function respondStoreError(res, e) {
71 const code = e && e.code ? String(e.code) : 'AGENT_CREDENTIAL_STORE_UNAVAILABLE';
72 if (code === AGENT_CREDENTIAL_STORE_INCONSISTENT) {
73 return res.status(503).json({
74 error: 'credential store inconsistent',
75 code: AGENT_CREDENTIAL_STORE_INCONSISTENT,
76 store: { wipe_required: false, inconsistent: true },
77 });
78 }
79 return res.status(503).json({
80 error: 'credential store unavailable',
81 code: 'AGENT_CREDENTIAL_STORE_UNAVAILABLE',
82 });
83 }
84
85 const router = express.Router();
86 router.use(express.json({ limit: '32kb' }));
87
88 router.use((req, res, next) => {
89 if (offlineLockedActive) {
90 return res.status(503).json({
91 error: 'Agent credentials unsupported while offline-locked',
92 code: 'AGENT_CREDENTIALS_UNSUPPORTED_OFFLINE_LOCKED',
93 });
94 }
95 return next();
96 });
97
98 function requireHumanSession(req, res) {
99 const payload = typeof getSessionPayload === 'function' ? getSessionPayload(req) : null;
100 const sub = getSessionSub(req);
101 if (!sub) {
102 res.status(401).json({ error: 'unauthorized', code: 'UNAUTHORIZED' });
103 return null;
104 }
105 if (payload) {
106 const cls = resolveActorTokenClass(payload);
107 if (cls === 'mcp_access' || cls === 'agent_access' || isMcpAccessPayload(payload) || isAgentAccessPayload(payload)) {
108 res.status(403).json({ error: 'session required', code: 'AGENT_MINT_SESSION_REQUIRED' });
109 return null;
110 }
111 }
112 return sub;
113 }
114
115 router.post('/credentials', async (req, res) => {
116 const sub = requireHumanSession(req, res);
117 if (!sub) return;
118 try {
119 const name = String(req.body?.name || '').trim();
120 const vault_ids = normalizeVaultIds(req.body?.vault_ids ?? req.body?.vaultIds);
121 let scopes = normalizeScopes(req.body?.scopes ?? [...DEFAULT_AGENT_SCOPES]);
122 scopes = applyScopeCeiling(scopes, grantedScopes(sub));
123 let ttlMs;
124 if (req.body?.ttl_seconds != null || req.body?.ttlSeconds != null) {
125 const sec = Number(req.body.ttl_seconds ?? req.body.ttlSeconds);
126 if (!Number.isFinite(sec)) {
127 return res.status(400).json({ error: 'invalid ttl_seconds', code: 'AGENT_TTL_INVALID' });
128 }
129 ttlMs = Math.floor(sec * 1000);
130 }
131 const minted = await store.mint({ sub, name, vault_ids, scopes, ttlMs });
132 return res.status(201).json(minted);
133 } catch (e) {
134 const code = e && e.code ? String(e.code) : '';
135 if (code === 'AGENT_CREDENTIAL_LIMIT') {
136 return res.status(409).json({ error: 'credential limit', code });
137 }
138 if (code.startsWith('AGENT_')) {
139 return res.status(400).json({ error: e.message || 'bad request', code });
140 }
141 console.error('[agent-credentials] mint failed:', e && e.message ? e.message : e);
142 return respondStoreError(res, e);
143 }
144 });
145
146 router.get('/credentials', async (req, res) => {
147 const sub = requireHumanSession(req, res);
148 if (!sub) return;
149 try {
150 const listed = await store.list(sub);
151 return res.status(200).json(listed);
152 } catch (e) {
153 console.error('[agent-credentials] list failed:', e && e.message ? e.message : e);
154 return respondStoreError(res, e);
155 }
156 });
157
158 router.delete('/credentials/:id', async (req, res) => {
159 const sub = requireHumanSession(req, res);
160 if (!sub) return;
161 try {
162 await store.revoke(String(req.params.id || ''), sub);
163 return res.status(200).json({ ok: true });
164 } catch (e) {
165 console.error('[agent-credentials] revoke failed:', e && e.message ? e.message : e);
166 return respondStoreError(res, e);
167 }
168 });
169
170 router.post('/credentials/:id/rotate', async (req, res) => {
171 const sub = requireHumanSession(req, res);
172 if (!sub) return;
173 try {
174 const rotated = await store.rotate(String(req.params.id || ''), sub);
175 return res.status(200).json(rotated);
176 } catch (e) {
177 const code = e && e.code ? String(e.code) : '';
178 if (code === 'AGENT_CREDENTIAL_NOT_FOUND' || code === 'AGENT_CREDENTIAL_EXPIRED') {
179 return res.status(404).json({ error: e.message || 'not found', code });
180 }
181 console.error('[agent-credentials] rotate failed:', e && e.message ? e.message : e);
182 return respondStoreError(res, e);
183 }
184 });
185
186 router.post('/token', async (req, res) => {
187 let presented = '';
188 const auth = req.headers.authorization;
189 if (typeof auth === 'string' && auth.startsWith('Bearer ')) {
190 const raw = auth.slice(7).trim();
191 if (raw.startsWith(AGENT_CREDENTIAL_PREFIX)) {
192 presented = raw;
193 } else {
194 return res.status(401).json({ error: 'invalid credential', code: 'AGENT_CREDENTIAL_INVALID' });
195 }
196 }
197 if (!presented) {
198 presented = String(req.body?.credential || '').trim();
199 }
200 if (!presented.startsWith(AGENT_CREDENTIAL_PREFIX)) {
201 return res.status(401).json({ error: 'invalid credential', code: 'AGENT_CREDENTIAL_INVALID' });
202 }
203
204 let result;
205 try {
206 result = await store.verify(presented);
207 } catch (e) {
208 console.error('[agent-credentials] verify failed:', e && e.message ? e.message : e);
209 return respondStoreError(res, e);
210 }
211 if (!result.ok) {
212 return res.status(401).json({ error: 'invalid credential', code: 'AGENT_CREDENTIAL_INVALID' });
213 }
214 if (!allowExchange(result.id)) {
215 return res.status(429).json({ error: 'rate limited', code: 'AGENT_CREDENTIAL_RATE_LIMIT' });
216 }
217
218 const ceiling = grantedScopes(result.sub);
219 let scopes;
220 try {
221 scopes = applyScopeCeiling(result.scopes, ceiling);
222 } catch (_) {
223 return res.status(401).json({ error: 'invalid credential', code: 'AGENT_CREDENTIAL_INVALID' });
224 }
225
226 const accessToken = jwt.sign(
227 {
228 sub: result.sub,
229 type: AGENT_ACCESS_TYPE,
230 typ: AGENT_ACCESS_TYP,
231 aud: AGENT_ACCESS_AUD,
232 scopes,
233 vault_ids: result.vault_ids,
234 cid: result.id,
235 agent: String(result.name || '').slice(0, 128),
236 },
237 sessionSecret,
238 {
239 expiresIn: AGENT_ACCESS_TTL_SECONDS,
240 header: { typ: AGENT_ACCESS_TYP },
241 }
242 );
243
244 return res.status(200).json({
245 access_token: accessToken,
246 token_type: 'Bearer',
247 expires_in: AGENT_ACCESS_TTL_SECONDS,
248 scopes,
249 vault_ids: result.vault_ids,
250 });
251 });
252
253 return { router, store };
254 }
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago