@conciergeprotocol/server 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/oauth.mjs ADDED
@@ -0,0 +1,677 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
2
+ import { isIP } from 'node:net'
3
+ import { readBody, send } from './http.mjs'
4
+
5
+ const CODE_TTL_MS = 10 * 60_000
6
+ const REQUEST_TTL_MS = 10 * 60_000
7
+ const DEVICE_TTL_SECONDS = 600
8
+ const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code'
9
+ // No vowels or look-alikes, so a code never spells a word or misreads (RFC 8628, 6.1).
10
+ const USER_CODE_ALPHABET = 'BCDFGHJKLMNPQRSTVWXZ'
11
+ const REFRESH_TTL_MS = 30 * 24 * 3_600_000
12
+ const MAX_CLIENT_DOC = 16_000
13
+ const CAP_FROM_RE = /^agent:([A-Za-z0-9_.-]{1,64})$/
14
+ const PKCE_RE = /^[A-Za-z0-9._~-]{43,128}$/
15
+ const LOOPBACK = new Set(['127.0.0.1', '[::1]'])
16
+ const FORM_COOKIE = 'cap_form'
17
+ const FORM_TTL_SECONDS = 600
18
+
19
+ const random = (bytes = 32) => randomBytes(bytes).toString('base64url')
20
+ const s256 = (verifier) => createHash('sha256').update(verifier).digest('base64url')
21
+
22
+ const escape = (text) =>
23
+ String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
24
+
25
+ function oauthError(res, status, error, description) {
26
+ send(res, status, { error, error_description: description })
27
+ }
28
+
29
+ function redirect(res, target) {
30
+ res.writeHead(302, { location: target, 'cache-control': 'no-store' })
31
+ res.end()
32
+ }
33
+
34
+ function withParams(uri, params) {
35
+ const url = new URL(uri)
36
+ for (const [key, value] of Object.entries(params)) if (value) url.searchParams.set(key, value)
37
+ return url.toString()
38
+ }
39
+
40
+ export function cookieValue(header, name) {
41
+ for (const part of String(header || '').split(';')) {
42
+ const split = part.indexOf('=')
43
+ if (split > 0 && part.slice(0, split).trim() === name) return part.slice(split + 1).trim()
44
+ }
45
+ return ''
46
+ }
47
+
48
+ function sameToken(a, b) {
49
+ if (!a || !b || a.length !== b.length) return false
50
+ return timingSafeEqual(Buffer.from(a), Buffer.from(b))
51
+ }
52
+
53
+ async function readForm(req) {
54
+ const type = String(req.headers['content-type'] || '')
55
+ if (!type.startsWith('application/x-www-form-urlencoded')) return null
56
+ return new URLSearchParams(await readBody(req))
57
+ }
58
+
59
+ /** A loopback redirect matches on any port (RFC 8252). Anything else matches exactly. */
60
+ export function redirectAllowed(registered, requested) {
61
+ if (registered.includes(requested)) return true
62
+ let asked
63
+ try {
64
+ asked = new URL(requested)
65
+ } catch {
66
+ return false
67
+ }
68
+ if (asked.protocol !== 'http:' || !LOOPBACK.has(asked.hostname)) return false
69
+ return registered.some((uri) => {
70
+ try {
71
+ const listed = new URL(uri)
72
+ return listed.protocol === 'http:'
73
+ && listed.hostname === asked.hostname
74
+ && listed.pathname === asked.pathname
75
+ && listed.search === asked.search
76
+ } catch {
77
+ return false
78
+ }
79
+ })
80
+ }
81
+
82
+ /**
83
+ * The visiting agent's client id is a URL to its own metadata document. There
84
+ * is no registration step. The document names the CAP-From the token is bound to.
85
+ */
86
+ export async function fetchClient(clientId, { allowHttpClients = false, fetchImpl = fetch } = {}) {
87
+ let url
88
+ try {
89
+ url = new URL(clientId)
90
+ } catch {
91
+ return { error: 'client_id must be a URL.' }
92
+ }
93
+ const local = url.hostname === 'localhost' || isIP(url.hostname.replace(/^\[|\]$/g, '')) !== 0
94
+ if (!allowHttpClients && (url.protocol !== 'https:' || local)) {
95
+ return { error: 'client_id must be an https URL on a public host.' }
96
+ }
97
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') return { error: 'client_id must be an http(s) URL.' }
98
+
99
+ let text
100
+ try {
101
+ const res = await fetchImpl(url, { redirect: 'error', signal: AbortSignal.timeout(5000), headers: { accept: 'application/json' } })
102
+ if (!res.ok) return { error: 'The client document could not be fetched.' }
103
+ text = await res.text()
104
+ } catch {
105
+ return { error: 'The client document could not be fetched.' }
106
+ }
107
+ if (text.length > MAX_CLIENT_DOC) return { error: 'The client document is too large.' }
108
+
109
+ let doc
110
+ try {
111
+ doc = JSON.parse(text)
112
+ } catch {
113
+ return { error: 'The client document is not JSON.' }
114
+ }
115
+ if (doc?.client_id !== clientId) return { error: 'The client document names a different client_id.' }
116
+ const redirectUris = Array.isArray(doc.redirect_uris) ? doc.redirect_uris.filter((uri) => typeof uri === 'string') : []
117
+ if (!redirectUris.length) return { error: 'The client document lists no redirect_uris.' }
118
+ const capFrom = String(doc.cap_from || '').match(CAP_FROM_RE)
119
+ if (!capFrom) return { error: 'The client document needs cap_from, such as agent:example.' }
120
+ const name = typeof doc.client_name === 'string' && doc.client_name.trim() ? doc.client_name.trim().slice(0, 80) : url.host
121
+ return { client: { clientId, name, host: url.host, redirectUris, capFrom: capFrom[1] } }
122
+ }
123
+
124
+ /**
125
+ * `read` unless the caller asked for `write`. `null` for a scope this owner does
126
+ * not offer, which the caller gets back as `invalid_scope`.
127
+ */
128
+ export function parseScope(raw, { write = false } = {}) {
129
+ const asked = String(raw || '').split(/\s+/).filter(Boolean)
130
+ const known = write ? ['read', 'write'] : ['read']
131
+ if (asked.some((item) => !known.includes(item))) return null
132
+ return asked.includes('write') ? 'write' : 'read'
133
+ }
134
+
135
+ /** Write is granted only when it was asked for and the person chose it. */
136
+ export function grantedScope(requested, decision) {
137
+ return requested === 'write' && decision === 'allow_write' ? 'write' : 'read'
138
+ }
139
+
140
+ export const scopeString = (scope) => (scope === 'write' ? 'read write' : 'read')
141
+
142
+ export function newUserCode() {
143
+ const bytes = randomBytes(8)
144
+ const chars = [...bytes].map((b) => USER_CODE_ALPHABET[b % USER_CODE_ALPHABET.length]).join('')
145
+ return `${chars.slice(0, 4)}-${chars.slice(4)}`
146
+ }
147
+
148
+ export function normalizeUserCode(input) {
149
+ const chars = String(input || '').toUpperCase().replace(/[^A-Z]/g, '')
150
+ return chars.length === 8 ? `${chars.slice(0, 4)}-${chars.slice(4)}` : ''
151
+ }
152
+
153
+ /**
154
+ * The client id for the code login: either the caller's own CAP-From, which the
155
+ * owner shows as unverified, or a client id metadata document URL.
156
+ */
157
+ export async function resolveDeviceClient(clientId, clientName, options) {
158
+ const bare = String(clientId || '').match(CAP_FROM_RE)
159
+ if (bare) {
160
+ const name = typeof clientName === 'string' && clientName.trim() ? clientName.trim().slice(0, 80) : bare[0]
161
+ return { client: { clientId: bare[0], name, host: '', capFrom: bare[1] } }
162
+ }
163
+ const found = await fetchClient(clientId, options)
164
+ if (found.error) return found
165
+ return { client: { ...found.client } }
166
+ }
167
+
168
+ /**
169
+ * OAuth 2.1 for account questions: the device code login, authorization code
170
+ * with PKCE, client id metadata documents, refresh rotation, and revocation.
171
+ *
172
+ * `login({ username, password })` resolves to the account id, or null.
173
+ * `currentAccount({ headers })` reads the company's own session from the
174
+ * request and resolves to the signed-in account id, or null. When it finds
175
+ * one, the login and confirm pages ask only for the decision.
176
+ * `write` offers the `write` scope. `act({ account, authorization_details, caller })`
177
+ * runs an action the person approved on the confirm page, and resolves to
178
+ * `{ message }` or `{ error: { message } }`.
179
+ */
180
+ export function createOAuth({
181
+ store,
182
+ agentName,
183
+ login = async () => null,
184
+ currentAccount = null,
185
+ path = '/oauth',
186
+ accessTokenSeconds = 3600,
187
+ writeAccessTokenSeconds = 900,
188
+ write = false,
189
+ act = null,
190
+ allowHttpClients = false,
191
+ hint = '',
192
+ fetchImpl = fetch,
193
+ deviceInterval = 5,
194
+ }) {
195
+ const requests = new Map()
196
+ const issuer = (origin) => `${origin}${path}`
197
+ const metadataPath = `/.well-known/oauth-authorization-server${path}`
198
+
199
+ function metadata(origin) {
200
+ return {
201
+ issuer: issuer(origin),
202
+ device_authorization_endpoint: `${issuer(origin)}/device`,
203
+ authorization_endpoint: `${issuer(origin)}/authorize`,
204
+ token_endpoint: `${issuer(origin)}/token`,
205
+ revocation_endpoint: `${issuer(origin)}/revoke`,
206
+ response_types_supported: ['code'],
207
+ grant_types_supported: [DEVICE_GRANT, 'authorization_code', 'refresh_token'],
208
+ code_challenge_methods_supported: ['S256'],
209
+ token_endpoint_auth_methods_supported: ['none'],
210
+ revocation_endpoint_auth_methods_supported: ['none'],
211
+ client_id_metadata_document_supported: true,
212
+ authorization_response_iss_parameter_supported: true,
213
+ ...(write ? { scopes_supported: ['read', 'write'] } : {}),
214
+ }
215
+ }
216
+
217
+ async function sessionAccount(req) {
218
+ if (!currentAccount) return null
219
+ try {
220
+ const account = await currentAccount({ headers: req.headers })
221
+ return typeof account === 'string' && account ? account : null
222
+ } catch {
223
+ return null
224
+ }
225
+ }
226
+
227
+ /**
228
+ * A signed-in approval is one click, so a cross-site form must not be able
229
+ * to make it. The page sets a SameSite=Strict cookie and posts the same value.
230
+ */
231
+ function formGuard(origin) {
232
+ const token = random(24)
233
+ const secure = origin.startsWith('https:') ? '; Secure' : ''
234
+ return {
235
+ token,
236
+ headers: { 'set-cookie': `${FORM_COOKIE}=${token}; Path=${path}; Max-Age=${FORM_TTL_SECONDS}; HttpOnly; SameSite=Strict${secure}` },
237
+ }
238
+ }
239
+
240
+ async function guardFor(req, origin, account = null) {
241
+ const signedIn = await sessionAccount(req)
242
+ if (!signedIn || (account && signedIn !== account)) return null
243
+ return formGuard(origin)
244
+ }
245
+
246
+ function guardFields(guard) {
247
+ return guard
248
+ ? `<input type="hidden" name="form_token" value="${escape(guard.token)}"><p class="muted">You are signed in.</p>`
249
+ : `<label>Username <input name="username" autocomplete="username" required></label>
250
+ <label>Password <input name="password" type="password" autocomplete="current-password" required></label>`
251
+ }
252
+
253
+ /** The signed-in account when the form came from this site's own page, else the password login. */
254
+ async function approver(req, form) {
255
+ if (form.get('form_token')) {
256
+ const posted = form.get('form_token') || ''
257
+ if (!sameToken(posted, cookieValue(req.headers.cookie, FORM_COOKIE))) return null
258
+ return sessionAccount(req)
259
+ }
260
+ const username = form.get('username') || ''
261
+ const password = form.get('password') || ''
262
+ if (!username || !password) return null
263
+ return login({ username, password })
264
+ }
265
+
266
+ const failedLogin = (form) => (form.get('form_token')
267
+ ? 'Your sign-in could not be checked. Reload this page and try again.'
268
+ : 'That username and password did not match.')
269
+
270
+ const wants = (scope) => (scope === 'write'
271
+ ? `wants ${escape(agentName)} to read your account and make changes for you.`
272
+ : `wants to ask ${escape(agentName)} about your account.`)
273
+
274
+ function decisionButtons(scope, signedIn = false) {
275
+ const lead = signedIn ? 'Allow' : 'Log in and allow'
276
+ const allow = scope === 'write'
277
+ ? `<button name="decision" value="allow_write">${lead} changes</button><button name="decision" value="allow">Allow read only</button>`
278
+ : `<button name="decision" value="allow">${lead}</button>`
279
+ const warning = scope === 'write'
280
+ ? '<p class="muted">Allow changes only if you asked this agent to make them. Deletes and other actions that are hard to undo still need your OK each time.</p>'
281
+ : ''
282
+ return `${warning}<div class="row">${allow}<button name="decision" value="deny" formnovalidate>Deny</button></div>`
283
+ }
284
+
285
+ function page(res, status, { title, body, guard = null }) {
286
+ send(res, status, null, {
287
+ type: 'text/html; charset=utf-8',
288
+ headers: {
289
+ 'x-frame-options': 'DENY',
290
+ 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'",
291
+ ...(guard ? guard.headers : {}),
292
+ },
293
+ raw: `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escape(title)}</title>
294
+ <style>body{font:16px/1.5 system-ui,sans-serif;max-width:420px;margin:48px auto;padding:0 20px}label{display:block;margin-top:12px}input{width:100%;padding:8px;font:inherit;box-sizing:border-box}.row{display:flex;gap:8px;margin-top:20px}button{padding:10px 14px;font:inherit}.muted{color:#666}.error{color:#b00020}</style>
295
+ </head><body>${body}</body></html>`,
296
+ })
297
+ }
298
+
299
+ function errorPage(res, status, message) {
300
+ page(res, status, { title: 'Login error', body: `<h1>Login error</h1><p>${escape(message)}</p><p class="muted">Go back to your agent and start again.</p>` })
301
+ }
302
+
303
+ function consentPage(res, status, id, pending, error = '', guard = null) {
304
+ const { client } = pending
305
+ page(res, status, {
306
+ guard,
307
+ title: `Log in to ${agentName}`,
308
+ body: `<h1>Log in to ${escape(agentName)}</h1>
309
+ <p><strong>${escape(client.name)}</strong> (${escape(client.host)}) ${wants(pending.scope)}</p>
310
+ <p class="muted">It will get answers about your account. It will not see your password.</p>
311
+ ${error ? `<p class="error">${escape(error)}</p>` : ''}
312
+ ${hint ? `<p class="muted">${escape(hint)}</p>` : ''}
313
+ <form method="post" action="${escape(path)}/authorize">
314
+ <input type="hidden" name="request" value="${escape(id)}">
315
+ ${guardFields(guard)}
316
+ ${decisionButtons(pending.scope, Boolean(guard))}
317
+ </form>`,
318
+ })
319
+ }
320
+
321
+ async function authorizeStart(req, res, url, origin) {
322
+ const q = url.searchParams
323
+ const clientId = q.get('client_id') || ''
324
+ const redirectUri = q.get('redirect_uri') || ''
325
+ const found = await fetchClient(clientId, { allowHttpClients, fetchImpl })
326
+ if (found.error) return errorPage(res, 400, found.error)
327
+ if (!redirectAllowed(found.client.redirectUris, redirectUri)) {
328
+ return errorPage(res, 400, 'redirect_uri is not one the client document lists.')
329
+ }
330
+ const state = q.get('state') || ''
331
+ const back = (error, description) => redirect(res, withParams(redirectUri, { error, error_description: description, state, iss: issuer(origin) }))
332
+ if (q.get('response_type') !== 'code') return back('unsupported_response_type', 'response_type must be code.')
333
+ if (!state) return back('invalid_request', 'state is required.')
334
+ if (q.get('code_challenge_method') !== 'S256' || !PKCE_RE.test(q.get('code_challenge') || '')) {
335
+ return back('invalid_request', 'PKCE with S256 is required.')
336
+ }
337
+ const scope = parseScope(q.get('scope'), { write })
338
+ if (!scope) return back('invalid_scope', 'scope must be read or write.')
339
+
340
+ const now = Date.now()
341
+ for (const [key, value] of requests) if (value.expires < now) requests.delete(key)
342
+ const id = random(24)
343
+ const pending = { client: found.client, redirectUri, state, scope, codeChallenge: q.get('code_challenge'), expires: now + REQUEST_TTL_MS }
344
+ requests.set(id, pending)
345
+ consentPage(res, 200, id, pending, '', await guardFor(req, origin))
346
+ }
347
+
348
+ async function authorizeFinish(req, res, origin) {
349
+ const form = await readForm(req)
350
+ const id = form?.get('request') || ''
351
+ const pending = requests.get(id)
352
+ if (!pending || pending.expires < Date.now()) {
353
+ requests.delete(id)
354
+ return errorPage(res, 400, 'This login expired.')
355
+ }
356
+ const back = (params) => redirect(res, withParams(pending.redirectUri, { ...params, state: pending.state, iss: issuer(origin) }))
357
+ const decision = form.get('decision')
358
+ if (decision !== 'allow' && decision !== 'allow_write') {
359
+ requests.delete(id)
360
+ return back({ error: 'access_denied', error_description: 'The person denied access.' })
361
+ }
362
+ const account = await approver(req, form)
363
+ if (!account) return consentPage(res, 401, id, pending, failedLogin(form), await guardFor(req, origin))
364
+
365
+ requests.delete(id)
366
+ const code = random()
367
+ await store.insertCode({
368
+ code,
369
+ account,
370
+ oauthClient: pending.client.clientId,
371
+ capFrom: pending.client.capFrom,
372
+ redirectUri: pending.redirectUri,
373
+ codeChallenge: pending.codeChallenge,
374
+ expiresAt: new Date(Date.now() + CODE_TTL_MS).toISOString(),
375
+ scope: grantedScope(pending.scope, decision),
376
+ })
377
+ back({ code })
378
+ }
379
+
380
+ async function deviceStart(req, res, origin) {
381
+ const form = await readForm(req)
382
+ if (!form) return oauthError(res, 400, 'invalid_request', 'The body must be a form.')
383
+ const found = await resolveDeviceClient(form.get('client_id'), form.get('client_name'), { allowHttpClients, fetchImpl })
384
+ if (found.error) return oauthError(res, 400, 'invalid_client', found.error)
385
+ const scope = parseScope(form.get('scope'), { write })
386
+ if (!scope) return oauthError(res, 400, 'invalid_scope', 'scope must be read or write.')
387
+ const { client } = found
388
+ const deviceCode = random()
389
+ let userCode = newUserCode()
390
+ for (let attempt = 0; await store.deviceByUserCode(userCode); attempt++) {
391
+ if (attempt > 5) return oauthError(res, 503, 'temporarily_unavailable', 'Try again.')
392
+ userCode = newUserCode()
393
+ }
394
+ await store.insertDevice({
395
+ deviceCode,
396
+ userCode,
397
+ oauthClient: client.clientId,
398
+ capFrom: client.capFrom,
399
+ clientName: client.name,
400
+ verifiedHost: client.host,
401
+ expiresAt: new Date(Date.now() + DEVICE_TTL_SECONDS * 1000).toISOString(),
402
+ scope,
403
+ })
404
+ const verification = `${issuer(origin)}/device`
405
+ send(res, 200, {
406
+ device_code: deviceCode,
407
+ user_code: userCode,
408
+ verification_uri: verification,
409
+ verification_uri_complete: `${verification}?user_code=${encodeURIComponent(userCode)}`,
410
+ expires_in: DEVICE_TTL_SECONDS,
411
+ interval: deviceInterval,
412
+ })
413
+ }
414
+
415
+ function devicePage(res, status, { userCode = '', device = null, error = '', done = '', guard = null }) {
416
+ const who = device
417
+ ? device.verified_host
418
+ ? `<p><strong>${escape(device.client_name)}</strong> (${escape(device.verified_host)}) ${wants(device.scope)}</p>`
419
+ : `<p><strong>${escape(device.client_name)}</strong> (agent:${escape(device.cap_from)}, name not verified) ${wants(device.scope)}</p>`
420
+ : ''
421
+ const body = done
422
+ ? `<h1>${escape(done)}</h1><p class="muted">Go back to your agent.</p>`
423
+ : `<h1>Log in to ${escape(agentName)}</h1>
424
+ ${who}
425
+ <p class="muted">Allow this only if your agent just showed you this code. It will get answers about your account. It will not see your password.</p>
426
+ ${error ? `<p class="error">${escape(error)}</p>` : ''}
427
+ ${hint ? `<p class="muted">${escape(hint)}</p>` : ''}
428
+ <form method="post" action="${escape(path)}/device/approve">
429
+ <label>Code <input name="user_code" value="${escape(userCode)}" autocomplete="off" required></label>
430
+ ${guardFields(guard)}
431
+ ${decisionButtons(device?.scope, Boolean(guard))}
432
+ </form>`
433
+ page(res, status, { title: `Log in to ${agentName}`, body, guard: done ? null : guard })
434
+ }
435
+
436
+ async function deviceView(req, res, url, origin) {
437
+ const userCode = normalizeUserCode(url.searchParams.get('user_code'))
438
+ const device = userCode ? await store.deviceByUserCode(userCode) : null
439
+ const status = device && device.status === 'pending' ? 200 : userCode ? 400 : 200
440
+ const error = userCode && (!device || device.status !== 'pending') ? 'That code is expired or already used.' : ''
441
+ devicePage(res, status, {
442
+ userCode: device ? userCode : '',
443
+ device: device && device.status === 'pending' ? device : null,
444
+ error,
445
+ guard: await guardFor(req, origin),
446
+ })
447
+ }
448
+
449
+ async function deviceApprove(req, res, origin) {
450
+ const form = await readForm(req)
451
+ if (!form) return devicePage(res, 400, { error: 'The body must be a form.' })
452
+ const userCode = normalizeUserCode(form.get('user_code'))
453
+ const device = userCode ? await store.deviceByUserCode(userCode) : null
454
+ if (!device || device.status !== 'pending') {
455
+ return devicePage(res, 400, { userCode: form.get('user_code') || '', error: 'That code is expired or already used.' })
456
+ }
457
+ const decision = form.get('decision')
458
+ if (decision !== 'allow' && decision !== 'allow_write') {
459
+ await store.decideDevice(userCode, { status: 'denied' })
460
+ return devicePage(res, 200, { done: 'Access denied' })
461
+ }
462
+ const account = await approver(req, form)
463
+ if (!account) return devicePage(res, 401, { userCode, device, error: failedLogin(form), guard: await guardFor(req, origin) })
464
+ // An agent that cannot keep polling asks once the person says so, so the
465
+ // token stays collectable for a full window after approval.
466
+ const scope = grantedScope(device.scope, decision)
467
+ const decided = await store.decideDevice(userCode, {
468
+ status: 'approved',
469
+ account,
470
+ expiresAt: new Date(Date.now() + DEVICE_TTL_SECONDS * 1000).toISOString(),
471
+ scope,
472
+ })
473
+ if (!decided) return devicePage(res, 400, { error: 'That code is expired or already used.' })
474
+ devicePage(res, 200, { done: scope === 'write' ? 'You are logged in, with changes allowed' : 'You are logged in' })
475
+ }
476
+
477
+ async function deviceToken(res, form) {
478
+ const deviceCode = form.get('device_code') || ''
479
+ const clientId = form.get('client_id') || ''
480
+ const device = await store.deviceByCode(deviceCode)
481
+ if (!device || device.oauth_client !== clientId) {
482
+ return oauthError(res, 400, 'invalid_grant', 'The device code is invalid or already used.')
483
+ }
484
+ if (Date.parse(device.expires_at) <= Date.now()) return oauthError(res, 400, 'expired_token', 'The login expired. Start again.')
485
+ if (device.status === 'denied') return oauthError(res, 400, 'access_denied', 'The person denied access.')
486
+ if (device.status === 'pending') {
487
+ const now = Date.now()
488
+ const last = device.last_poll_at ? Date.parse(device.last_poll_at) : 0
489
+ await store.touchDevice(deviceCode, new Date(now).toISOString())
490
+ if (last && now - last < deviceInterval * 1000) {
491
+ return oauthError(res, 400, 'slow_down', 'Poll less often.')
492
+ }
493
+ return oauthError(res, 400, 'authorization_pending', 'The person has not decided yet.')
494
+ }
495
+ const row = await store.takeApprovedDevice(deviceCode)
496
+ if (!row) return oauthError(res, 400, 'invalid_grant', 'The device code is invalid or already used.')
497
+ return issue(res, { account: row.account, capFrom: row.cap_from, oauthClient: row.oauth_client, grantId: random(16), scope: row.scope })
498
+ }
499
+
500
+ async function issue(res, { account, capFrom, oauthClient, grantId, scope = 'read' }) {
501
+ const accessToken = `cap_at_${random()}`
502
+ const refreshToken = `cap_rt_${random()}`
503
+ const seconds = scope === 'write' ? writeAccessTokenSeconds : accessTokenSeconds
504
+ const shared = { account, clientId: capFrom, oauthClient, grantId, scope }
505
+ await store.upsertGrant({ ...shared, token: accessToken, kind: 'access', expiresAt: new Date(Date.now() + seconds * 1000).toISOString() })
506
+ await store.upsertGrant({ ...shared, token: refreshToken, kind: 'refresh', expiresAt: new Date(Date.now() + REFRESH_TTL_MS).toISOString() })
507
+ send(res, 200, { access_token: accessToken, token_type: 'Bearer', expires_in: seconds, refresh_token: refreshToken, scope: scopeString(scope) })
508
+ }
509
+
510
+ async function token(req, res) {
511
+ const form = await readForm(req)
512
+ if (!form) return oauthError(res, 400, 'invalid_request', 'The body must be a form.')
513
+ const clientId = form.get('client_id') || ''
514
+ const grantType = form.get('grant_type')
515
+
516
+ if (grantType === DEVICE_GRANT) return deviceToken(res, form)
517
+
518
+ if (grantType === 'authorization_code') {
519
+ const row = await store.takeCode(form.get('code') || '')
520
+ const verifier = form.get('code_verifier') || ''
521
+ const valid = row
522
+ && row.oauth_client === clientId
523
+ && row.redirect_uri === (form.get('redirect_uri') || '')
524
+ && PKCE_RE.test(verifier)
525
+ && s256(verifier) === row.code_challenge
526
+ if (!valid) return oauthError(res, 400, 'invalid_grant', 'The code is invalid, expired, or already used.')
527
+ return issue(res, { account: row.account, capFrom: row.cap_from, oauthClient: row.oauth_client, grantId: random(16), scope: row.scope })
528
+ }
529
+
530
+ if (grantType === 'refresh_token') {
531
+ const presented = form.get('refresh_token') || ''
532
+ const grant = await store.grantByToken(presented)
533
+ if (!grant || grant.kind !== 'refresh' || grant.oauth_client !== clientId) {
534
+ return oauthError(res, 400, 'invalid_grant', 'The refresh token is invalid, expired, or revoked.')
535
+ }
536
+ await store.revokeGrant(presented)
537
+ return issue(res, { account: grant.account, capFrom: grant.client_id, oauthClient: grant.oauth_client, grantId: grant.grant_id, scope: grant.scope })
538
+ }
539
+
540
+ oauthError(res, 400, 'unsupported_grant_type', `grant_type must be ${DEVICE_GRANT}, authorization_code, or refresh_token.`)
541
+ }
542
+
543
+ // Always 200, so a caller cannot probe which tokens exist (RFC 7009).
544
+ async function revoke(req, res) {
545
+ const form = await readForm(req)
546
+ if (!form) return oauthError(res, 400, 'invalid_request', 'The body must be a form.')
547
+ const presented = form.get('token') || ''
548
+ const grant = await store.grantByToken(presented)
549
+ if (grant && grant.oauth_client === (form.get('client_id') || '')) {
550
+ if (grant.grant_id) await store.revokeGrantId(grant.grant_id)
551
+ else await store.revokeGrant(presented)
552
+ }
553
+ send(res, 200, {})
554
+ }
555
+
556
+ const confirmUrl = (origin, id) => `${issuer(origin)}/confirm?id=${encodeURIComponent(id)}`
557
+
558
+ const CONFIRM_DONE = {
559
+ working: 'This is running',
560
+ done: 'Done',
561
+ failed: 'This did not finish',
562
+ denied: 'You denied this',
563
+ expired: 'This request expired',
564
+ }
565
+
566
+ function confirmPage(res, status, { confirmation = null, error = '', done = '', message = '', guard = null }) {
567
+ if (done || !confirmation) {
568
+ const heading = done || 'Request not found'
569
+ const note = message || (confirmation ? 'Go back to your agent.' : 'It may have expired. Ask your agent again.')
570
+ return page(res, status, { title: heading, body: `<h1>${escape(heading)}</h1><p class="muted">${escape(note)}</p>` })
571
+ }
572
+ page(res, status, {
573
+ guard,
574
+ title: `Confirm with ${agentName}`,
575
+ body: `<h1>Confirm with ${escape(agentName)}</h1>
576
+ <p><strong>${escape(confirmation.summary)}</strong></p>
577
+ <p class="muted">Your agent (agent:${escape(confirmation.caller_id)}) asked for this. Approve only if you asked for it. It runs once.</p>
578
+ ${error ? `<p class="error">${escape(error)}</p>` : ''}
579
+ ${hint ? `<p class="muted">${escape(hint)}</p>` : ''}
580
+ <form method="post" action="${escape(path)}/confirm">
581
+ <input type="hidden" name="id" value="${escape(confirmation.id)}">
582
+ ${guardFields(guard)}
583
+ <div class="row"><button name="decision" value="approve">${guard ? 'Approve' : 'Log in and approve'}</button><button name="decision" value="deny" formnovalidate>Deny</button></div>
584
+ </form>`,
585
+ })
586
+ }
587
+
588
+ const isOpen = (c) => c && c.status === 'pending' && Date.parse(c.expires_at) > Date.now()
589
+
590
+ async function confirmView(req, res, url, origin) {
591
+ const confirmation = await store.confirmationById(url.searchParams.get('id') || '')
592
+ if (!confirmation) return confirmPage(res, 404, {})
593
+ if (!isOpen(confirmation)) {
594
+ const status = confirmation.status === 'pending' ? 'expired' : confirmation.status
595
+ return confirmPage(res, 200, { confirmation, done: CONFIRM_DONE[status] || 'This request is closed' })
596
+ }
597
+ confirmPage(res, 200, { confirmation, guard: await guardFor(req, origin, confirmation.account) })
598
+ }
599
+
600
+ async function confirmDecide(req, res, origin) {
601
+ const form = await readForm(req)
602
+ const confirmation = form ? await store.confirmationById(form.get('id') || '') : null
603
+ if (!confirmation) return confirmPage(res, 404, {})
604
+ if (!isOpen(confirmation)) return confirmPage(res, 400, { confirmation, done: 'This request is closed' })
605
+
606
+ if (form.get('decision') !== 'approve') {
607
+ const claimed = await store.claimConfirmation(confirmation.id, 'denied')
608
+ if (!claimed) return confirmPage(res, 400, { confirmation, done: 'This request is closed' })
609
+ await store.finishConfirmation(confirmation.id, { status: 'denied' })
610
+ return confirmPage(res, 200, { confirmation, done: CONFIRM_DONE.denied, message: 'Nothing changed. Go back to your agent.' })
611
+ }
612
+ // The person who approves must be the account the request is for.
613
+ const account = await approver(req, form)
614
+ if (!account || account !== confirmation.account) {
615
+ return confirmPage(res, 401, {
616
+ confirmation,
617
+ error: 'That login does not match the account this request is for.',
618
+ guard: await guardFor(req, origin, confirmation.account),
619
+ })
620
+ }
621
+ const claimed = await store.claimConfirmation(confirmation.id, 'working')
622
+ if (!claimed) return confirmPage(res, 400, { confirmation, done: 'This request is closed' })
623
+
624
+ let outcome
625
+ try {
626
+ outcome = act
627
+ ? await act({ account, authorization_details: claimed.authorization_details, caller: claimed.caller_id })
628
+ : { error: { message: `${agentName} cannot run this action.` } }
629
+ } catch {
630
+ outcome = { error: { message: 'The action could not finish.' } }
631
+ }
632
+ const failed = Boolean(outcome?.error)
633
+ const message = String((failed ? outcome.error.message : outcome?.message) || (failed ? 'The action could not finish.' : 'Done.'))
634
+ await store.finishConfirmation(confirmation.id, { status: failed ? 'failed' : 'done', result: { message } })
635
+ confirmPage(res, 200, { confirmation, done: failed ? CONFIRM_DONE.failed : CONFIRM_DONE.done, message })
636
+ }
637
+
638
+ async function handle(req, res, url, origin) {
639
+ if (url.pathname === `${path}/confirm`) {
640
+ if (req.method === 'GET') await confirmView(req, res, url, origin)
641
+ else if (req.method === 'POST') await confirmDecide(req, res, origin)
642
+ else return false
643
+ return true
644
+ }
645
+ if (req.method === 'GET' && url.pathname === metadataPath) {
646
+ send(res, 200, metadata(origin))
647
+ return true
648
+ }
649
+ if (url.pathname === `${path}/authorize`) {
650
+ if (req.method === 'GET') await authorizeStart(req, res, url, origin)
651
+ else if (req.method === 'POST') await authorizeFinish(req, res, origin)
652
+ else return false
653
+ return true
654
+ }
655
+ if (url.pathname === `${path}/device`) {
656
+ if (req.method === 'POST') await deviceStart(req, res, origin)
657
+ else if (req.method === 'GET') await deviceView(req, res, url, origin)
658
+ else return false
659
+ return true
660
+ }
661
+ if (req.method === 'POST' && url.pathname === `${path}/device/approve`) {
662
+ await deviceApprove(req, res, origin)
663
+ return true
664
+ }
665
+ if (req.method === 'POST' && url.pathname === `${path}/token`) {
666
+ await token(req, res)
667
+ return true
668
+ }
669
+ if (req.method === 'POST' && url.pathname === `${path}/revoke`) {
670
+ await revoke(req, res)
671
+ return true
672
+ }
673
+ return false
674
+ }
675
+
676
+ return { handle, issuer, confirmUrl, canConfirm: Boolean(act) }
677
+ }