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