agent-credential-routes.mjs
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