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