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