@conciergeprotocol/server 0.1.0 → 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/README.md CHANGED
@@ -102,8 +102,35 @@ const cap = await createCapServer({
102
102
 
103
103
  With a token, `answer` receives `account` and `scope` (`read` or `write`). A token sent by a different agent is `403`.
104
104
 
105
+ If people are usually signed in to your site already, pass `currentAccount` too. It reads your own session from the request headers and returns the account id, or null. When it finds one, the login and confirm pages show only Allow or Approve. Without a session, they fall back to `login`. A confirm page asks for a login when the session is for a different account than the request.
106
+
107
+ ```js
108
+ const cap = await createCapServer({
109
+ agent: { name: 'Harbor Goods' },
110
+ answer,
111
+ login: async ({ username, password }) => verifyUser(username, password),
112
+ currentAccount: async ({ headers }) => accountFromSessionCookie(headers.cookie),
113
+ })
114
+ ```
115
+
116
+ Serve `/oauth` on the same origin as your sign-in, so your session cookie reaches it. A signed-in approval also needs a `SameSite=Strict` cookie the page sets, so a form on another site cannot submit it.
117
+
105
118
  To hold a change for the person's approval, return `{ confirm: { summary, authorization_details }, reply }` instead of running it. After they approve, the server calls `act` once.
106
119
 
120
+ ## Account routes
121
+
122
+ Another agent can call your own JSON routes with the same token, for example a hosted agent that answers public questions itself and fetches account data from you. `accountFor(request)` checks the bearer token against the agent in `CAP-From` and returns `{ account, scope, caller }`, or null. Filter every query by `account`.
123
+
124
+ ```js
125
+ export async function GET(request) {
126
+ const access = await door.accountFor(request)
127
+ if (!access) return Response.json({ error: { code: 'login_required' } }, { status: 401 })
128
+ return Response.json({ account: access.account, orders: await ordersFor(access.account) })
129
+ }
130
+ ```
131
+
132
+ `requestApproval(request, { summary, authorization_details })` holds a change for the person's OK and returns `{ confirmation: { id, url, summary } }`. It needs a write login and `act`, which runs once after approval.
133
+
107
134
  ## cap.json
108
135
 
109
136
  `cap-server` reads `cap.json` from the working directory, or `CAP_CONFIG`. The same file the reference server uses:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@conciergeprotocol/server",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Run a Concierge Agent Protocol front door. You write the answer.",
5
5
  "type": "module",
6
6
  "exports": {
package/src/config.mjs CHANGED
@@ -79,11 +79,12 @@ export async function boot(options = {}) {
79
79
  }
80
80
  }
81
81
  const oauthConfig = config.oauth
82
- const oauth = oauthConfig || options.login
82
+ const oauth = oauthConfig || options.login || options.currentAccount
83
83
  ? createOAuth({
84
84
  store,
85
85
  agentName: config.agent.name,
86
86
  login: options.login || accountsLogin(oauthConfig?.accounts),
87
+ currentAccount: options.currentAccount || null,
87
88
  write: Boolean(oauthConfig?.write),
88
89
  act: options.act || handlerAct(config.handler),
89
90
  accessTokenSeconds: oauthConfig?.access_token_seconds ?? 3600,
package/src/index.d.ts CHANGED
@@ -38,6 +38,11 @@ export type CapServerOptions = {
38
38
  answer: Answer
39
39
  /** Checks the company's own user store. Returning an account id turns on OAuth. */
40
40
  login?: (input: { username: string; password: string }) => string | null | Promise<string | null>
41
+ /**
42
+ * Reads the company's own session from the request headers and returns the
43
+ * signed-in account id. The login and confirm pages then ask only for the decision.
44
+ */
45
+ currentAccount?: (input: { headers: Record<string, string | string[] | undefined> }) => string | null | Promise<string | null>
41
46
  /** Runs once after a person approves a held action. */
42
47
  act?: (input: { account: string; authorization_details: unknown; caller: string }) => { message: string } | { error: { message: string } } | Promise<{ message: string } | { error: { message: string } }>
43
48
  write?: boolean
@@ -67,7 +72,20 @@ export type ListenAddress = {
67
72
  family: string
68
73
  }
69
74
 
70
- export type CapServer = {
75
+ export type AccountAccess = { account: string; scope: 'read' | 'write'; caller: string }
76
+
77
+ export type ApprovalResult =
78
+ | { confirmation: { id: string; status: 'pending'; url: string; summary: string; expires_at: string } }
79
+ | AnswerError
80
+
81
+ export type AccountHelpers = {
82
+ /** The account behind the request's bearer token, for your own account routes. Null without a valid token for the agent in CAP-From. */
83
+ accountFor(request: Request | IncomingMessage): Promise<AccountAccess | null>
84
+ /** Holds an action until the person approves it on the confirm page. `act` runs it once after. */
85
+ requestApproval(request: Request | IncomingMessage, input: { summary: string; authorization_details?: unknown[] }): Promise<ApprovalResult>
86
+ }
87
+
88
+ export type CapServer = AccountHelpers & {
71
89
  listen(port?: number, host?: string): Promise<ListenAddress | string | null>
72
90
  close(): Promise<void>
73
91
  fetch(request: Request): Promise<Response>
@@ -76,7 +94,7 @@ export type CapServer = {
76
94
 
77
95
  export function createCapServer(options: CapServerOptions): Promise<CapServer>
78
96
 
79
- export function createCapHandler(options: CapServerOptions): Promise<{
97
+ export function createCapHandler(options: CapServerOptions): Promise<AccountHelpers & {
80
98
  handle(request: Request): Promise<Response>
81
99
  close(): Promise<void>
82
100
  }>
package/src/index.js CHANGED
@@ -35,11 +35,12 @@ export async function createCapServer(options = {}) {
35
35
  }
36
36
  }
37
37
 
38
- const oauth = options.login
38
+ const oauth = options.login || options.currentAccount
39
39
  ? createOAuth({
40
40
  store,
41
41
  agentName: agent.name,
42
42
  login: options.login,
43
+ currentAccount: options.currentAccount || null,
43
44
  write: Boolean(options.write),
44
45
  act: options.act || null,
45
46
  accessTokenSeconds: options.accessTokenSeconds ?? 3600,
@@ -70,6 +71,8 @@ export async function createCapServer(options = {}) {
70
71
  fetch(request) {
71
72
  return handleWeb(node.handle, request)
72
73
  },
74
+ accountFor: (req) => node.accountFor(nodeLike(req)),
75
+ requestApproval: (req, input) => node.requestApproval(nodeLike(req), input),
73
76
  listen(port = Number(process.env.PORT || 8088), host = process.env.HOST || '0.0.0.0') {
74
77
  return new Promise((resolve, reject) => {
75
78
  node.server.once('error', reject)
@@ -91,10 +94,29 @@ export async function createCapHandler(options) {
91
94
  const cap = await createCapServer(options)
92
95
  return {
93
96
  handle: (request) => cap.fetch(request),
97
+ accountFor: cap.accountFor,
98
+ requestApproval: cap.requestApproval,
94
99
  close: () => cap.close(),
95
100
  }
96
101
  }
97
102
 
103
+ /** A Fetch API Request reads like a Node request for header checks. */
104
+ function nodeLike(req) {
105
+ if (typeof req?.headers?.get !== 'function') return req
106
+ const headers = {}
107
+ req.headers.forEach((value, key) => {
108
+ headers[key] = value
109
+ })
110
+ try {
111
+ const url = new URL(req.url)
112
+ headers.host ||= url.host
113
+ headers['x-forwarded-proto'] ||= url.protocol.replace(':', '')
114
+ } catch {
115
+ // Without a URL the origin falls back to the Host header.
116
+ }
117
+ return { headers }
118
+ }
119
+
98
120
  function normalizeAgent(agent) {
99
121
  if (!agent?.name || typeof agent.name !== 'string') throw new Error('createCapServer needs agent.name.')
100
122
  return {
@@ -168,6 +168,45 @@ export function createCapServer({
168
168
  return { grant }
169
169
  }
170
170
 
171
+ /**
172
+ * The account behind a request's bearer token, for the owner's own account
173
+ * routes. Null without a valid token for the agent named in CAP-From.
174
+ */
175
+ async function accountFor(req) {
176
+ const caller = callerId(req)
177
+ if (!caller || !req.headers.authorization) return null
178
+ const access = await grantFor(req, caller)
179
+ if (!access.grant) return null
180
+ return { account: access.grant.account, scope: access.grant.scope === 'write' ? 'write' : 'read', caller }
181
+ }
182
+
183
+ /** Holds an action from an owner's account route until the person approves it. */
184
+ async function requestApproval(req, { summary, authorization_details } = {}) {
185
+ const access = await accountFor(req)
186
+ if (!access) return { error: { code: 'login_required', message: 'Log in to make this change.' } }
187
+ if (access.scope !== 'write') {
188
+ return { error: { code: 'write_required', message: 'This login can read but not make changes. Log in again and allow changes.' } }
189
+ }
190
+ if (!oauth?.canConfirm) return { error: { code: 'failed', message: `${agent.name} cannot hold an action for approval.` } }
191
+ const text = String(summary || '').trim().slice(0, MAX_SUMMARY)
192
+ if (!text) return { error: { code: 'invalid_request', message: 'summary is required.' } }
193
+ const id = newConfirmationId()
194
+ const now = Date.now()
195
+ const expiresAt = new Date(now + CONFIRM_TTL_MS).toISOString()
196
+ await store.insertConfirmation({
197
+ id,
198
+ caller_id: access.caller,
199
+ account: access.account,
200
+ client_id: access.caller,
201
+ message_id: '',
202
+ summary: text,
203
+ authorization_details: Array.isArray(authorization_details) ? authorization_details : [],
204
+ expires_at: expiresAt,
205
+ created_at: new Date(now).toISOString(),
206
+ })
207
+ return { confirmation: { id, status: 'pending', url: oauth.confirmUrl(requestOrigin(req, publicUrl), id), summary: text, expires_at: expiresAt } }
208
+ }
209
+
171
210
  async function route(req, res) {
172
211
  const url = new URL(req.url || '/', 'http://localhost')
173
212
  if (req.method === 'OPTIONS') {
@@ -371,5 +410,5 @@ export function createCapServer({
371
410
  send(res, 200, await view(msg, origin))
372
411
  }
373
412
 
374
- return { server, handle: onRequest }
413
+ return { server, handle: onRequest, accountFor, requestApproval }
375
414
  }
package/src/oauth.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { createHash, randomBytes } from 'node:crypto'
1
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
2
2
  import { isIP } from 'node:net'
3
3
  import { readBody, send } from './http.mjs'
4
4
 
@@ -13,6 +13,8 @@ const MAX_CLIENT_DOC = 16_000
13
13
  const CAP_FROM_RE = /^agent:([A-Za-z0-9_.-]{1,64})$/
14
14
  const PKCE_RE = /^[A-Za-z0-9._~-]{43,128}$/
15
15
  const LOOPBACK = new Set(['127.0.0.1', '[::1]'])
16
+ const FORM_COOKIE = 'cap_form'
17
+ const FORM_TTL_SECONDS = 600
16
18
 
17
19
  const random = (bytes = 32) => randomBytes(bytes).toString('base64url')
18
20
  const s256 = (verifier) => createHash('sha256').update(verifier).digest('base64url')
@@ -35,6 +37,19 @@ function withParams(uri, params) {
35
37
  return url.toString()
36
38
  }
37
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
+
38
53
  async function readForm(req) {
39
54
  const type = String(req.headers['content-type'] || '')
40
55
  if (!type.startsWith('application/x-www-form-urlencoded')) return null
@@ -155,6 +170,9 @@ export async function resolveDeviceClient(clientId, clientName, options) {
155
170
  * with PKCE, client id metadata documents, refresh rotation, and revocation.
156
171
  *
157
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.
158
176
  * `write` offers the `write` scope. `act({ account, authorization_details, caller })`
159
177
  * runs an action the person approved on the confirm page, and resolves to
160
178
  * `{ message }` or `{ error: { message } }`.
@@ -162,7 +180,8 @@ export async function resolveDeviceClient(clientId, clientName, options) {
162
180
  export function createOAuth({
163
181
  store,
164
182
  agentName,
165
- login,
183
+ login = async () => null,
184
+ currentAccount = null,
166
185
  path = '/oauth',
167
186
  accessTokenSeconds = 3600,
168
187
  writeAccessTokenSeconds = 900,
@@ -195,24 +214,82 @@ export function createOAuth({
195
214
  }
196
215
  }
197
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
+
198
270
  const wants = (scope) => (scope === 'write'
199
271
  ? `wants ${escape(agentName)} to read your account and make changes for you.`
200
272
  : `wants to ask ${escape(agentName)} about your account.`)
201
273
 
202
- function decisionButtons(scope) {
274
+ function decisionButtons(scope, signedIn = false) {
275
+ const lead = signedIn ? 'Allow' : 'Log in and allow'
203
276
  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>'
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>`
206
279
  const warning = scope === 'write'
207
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>'
208
281
  : ''
209
282
  return `${warning}<div class="row">${allow}<button name="decision" value="deny" formnovalidate>Deny</button></div>`
210
283
  }
211
284
 
212
- function page(res, status, { title, body }) {
285
+ function page(res, status, { title, body, guard = null }) {
213
286
  send(res, status, null, {
214
287
  type: 'text/html; charset=utf-8',
215
- headers: { 'x-frame-options': 'DENY', 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'" },
288
+ headers: {
289
+ 'x-frame-options': 'DENY',
290
+ 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'",
291
+ ...(guard ? guard.headers : {}),
292
+ },
216
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>
217
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>
218
295
  </head><body>${body}</body></html>`,
@@ -223,9 +300,10 @@ export function createOAuth({
223
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>` })
224
301
  }
225
302
 
226
- function consentPage(res, status, id, pending, error = '') {
303
+ function consentPage(res, status, id, pending, error = '', guard = null) {
227
304
  const { client } = pending
228
305
  page(res, status, {
306
+ guard,
229
307
  title: `Log in to ${agentName}`,
230
308
  body: `<h1>Log in to ${escape(agentName)}</h1>
231
309
  <p><strong>${escape(client.name)}</strong> (${escape(client.host)}) ${wants(pending.scope)}</p>
@@ -234,14 +312,13 @@ ${error ? `<p class="error">${escape(error)}</p>` : ''}
234
312
  ${hint ? `<p class="muted">${escape(hint)}</p>` : ''}
235
313
  <form method="post" action="${escape(path)}/authorize">
236
314
  <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)}
315
+ ${guardFields(guard)}
316
+ ${decisionButtons(pending.scope, Boolean(guard))}
240
317
  </form>`,
241
318
  })
242
319
  }
243
320
 
244
- async function authorizeStart(res, url, origin) {
321
+ async function authorizeStart(req, res, url, origin) {
245
322
  const q = url.searchParams
246
323
  const clientId = q.get('client_id') || ''
247
324
  const redirectUri = q.get('redirect_uri') || ''
@@ -265,7 +342,7 @@ ${decisionButtons(pending.scope)}
265
342
  const id = random(24)
266
343
  const pending = { client: found.client, redirectUri, state, scope, codeChallenge: q.get('code_challenge'), expires: now + REQUEST_TTL_MS }
267
344
  requests.set(id, pending)
268
- consentPage(res, 200, id, pending)
345
+ consentPage(res, 200, id, pending, '', await guardFor(req, origin))
269
346
  }
270
347
 
271
348
  async function authorizeFinish(req, res, origin) {
@@ -282,8 +359,8 @@ ${decisionButtons(pending.scope)}
282
359
  requests.delete(id)
283
360
  return back({ error: 'access_denied', error_description: 'The person denied access.' })
284
361
  }
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.')
362
+ const account = await approver(req, form)
363
+ if (!account) return consentPage(res, 401, id, pending, failedLogin(form), await guardFor(req, origin))
287
364
 
288
365
  requests.delete(id)
289
366
  const code = random()
@@ -335,7 +412,7 @@ ${decisionButtons(pending.scope)}
335
412
  })
336
413
  }
337
414
 
338
- function devicePage(res, status, { userCode = '', device = null, error = '', done = '' }) {
415
+ function devicePage(res, status, { userCode = '', device = null, error = '', done = '', guard = null }) {
339
416
  const who = device
340
417
  ? device.verified_host
341
418
  ? `<p><strong>${escape(device.client_name)}</strong> (${escape(device.verified_host)}) ${wants(device.scope)}</p>`
@@ -350,22 +427,26 @@ ${error ? `<p class="error">${escape(error)}</p>` : ''}
350
427
  ${hint ? `<p class="muted">${escape(hint)}</p>` : ''}
351
428
  <form method="post" action="${escape(path)}/device/approve">
352
429
  <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)}
430
+ ${guardFields(guard)}
431
+ ${decisionButtons(device?.scope, Boolean(guard))}
356
432
  </form>`
357
- page(res, status, { title: `Log in to ${agentName}`, body })
433
+ page(res, status, { title: `Log in to ${agentName}`, body, guard: done ? null : guard })
358
434
  }
359
435
 
360
- async function deviceView(res, url) {
436
+ async function deviceView(req, res, url, origin) {
361
437
  const userCode = normalizeUserCode(url.searchParams.get('user_code'))
362
438
  const device = userCode ? await store.deviceByUserCode(userCode) : null
363
439
  const status = device && device.status === 'pending' ? 200 : userCode ? 400 : 200
364
440
  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 })
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
+ })
366
447
  }
367
448
 
368
- async function deviceApprove(req, res) {
449
+ async function deviceApprove(req, res, origin) {
369
450
  const form = await readForm(req)
370
451
  if (!form) return devicePage(res, 400, { error: 'The body must be a form.' })
371
452
  const userCode = normalizeUserCode(form.get('user_code'))
@@ -378,8 +459,8 @@ ${decisionButtons(device?.scope)}
378
459
  await store.decideDevice(userCode, { status: 'denied' })
379
460
  return devicePage(res, 200, { done: 'Access denied' })
380
461
  }
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.' })
462
+ const account = await approver(req, form)
463
+ if (!account) return devicePage(res, 401, { userCode, device, error: failedLogin(form), guard: await guardFor(req, origin) })
383
464
  // An agent that cannot keep polling asks once the person says so, so the
384
465
  // token stays collectable for a full window after approval.
385
466
  const scope = grantedScope(device.scope, decision)
@@ -482,13 +563,14 @@ ${decisionButtons(device?.scope)}
482
563
  expired: 'This request expired',
483
564
  }
484
565
 
485
- function confirmPage(res, status, { confirmation = null, error = '', done = '', message = '' }) {
566
+ function confirmPage(res, status, { confirmation = null, error = '', done = '', message = '', guard = null }) {
486
567
  if (done || !confirmation) {
487
568
  const heading = done || 'Request not found'
488
569
  const note = message || (confirmation ? 'Go back to your agent.' : 'It may have expired. Ask your agent again.')
489
570
  return page(res, status, { title: heading, body: `<h1>${escape(heading)}</h1><p class="muted">${escape(note)}</p>` })
490
571
  }
491
572
  page(res, status, {
573
+ guard,
492
574
  title: `Confirm with ${agentName}`,
493
575
  body: `<h1>Confirm with ${escape(agentName)}</h1>
494
576
  <p><strong>${escape(confirmation.summary)}</strong></p>
@@ -497,26 +579,25 @@ ${error ? `<p class="error">${escape(error)}</p>` : ''}
497
579
  ${hint ? `<p class="muted">${escape(hint)}</p>` : ''}
498
580
  <form method="post" action="${escape(path)}/confirm">
499
581
  <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>
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>
503
584
  </form>`,
504
585
  })
505
586
  }
506
587
 
507
588
  const isOpen = (c) => c && c.status === 'pending' && Date.parse(c.expires_at) > Date.now()
508
589
 
509
- async function confirmView(res, url) {
590
+ async function confirmView(req, res, url, origin) {
510
591
  const confirmation = await store.confirmationById(url.searchParams.get('id') || '')
511
592
  if (!confirmation) return confirmPage(res, 404, {})
512
593
  if (!isOpen(confirmation)) {
513
594
  const status = confirmation.status === 'pending' ? 'expired' : confirmation.status
514
595
  return confirmPage(res, 200, { confirmation, done: CONFIRM_DONE[status] || 'This request is closed' })
515
596
  }
516
- confirmPage(res, 200, { confirmation })
597
+ confirmPage(res, 200, { confirmation, guard: await guardFor(req, origin, confirmation.account) })
517
598
  }
518
599
 
519
- async function confirmDecide(req, res) {
600
+ async function confirmDecide(req, res, origin) {
520
601
  const form = await readForm(req)
521
602
  const confirmation = form ? await store.confirmationById(form.get('id') || '') : null
522
603
  if (!confirmation) return confirmPage(res, 404, {})
@@ -529,9 +610,13 @@ ${hint ? `<p class="muted">${escape(hint)}</p>` : ''}
529
610
  return confirmPage(res, 200, { confirmation, done: CONFIRM_DONE.denied, message: 'Nothing changed. Go back to your agent.' })
530
611
  }
531
612
  // 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') || '' })
613
+ const account = await approver(req, form)
533
614
  if (!account || account !== confirmation.account) {
534
- return confirmPage(res, 401, { confirmation, error: 'That login does not match the account this request is for.' })
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
+ })
535
620
  }
536
621
  const claimed = await store.claimConfirmation(confirmation.id, 'working')
537
622
  if (!claimed) return confirmPage(res, 400, { confirmation, done: 'This request is closed' })
@@ -552,8 +637,8 @@ ${hint ? `<p class="muted">${escape(hint)}</p>` : ''}
552
637
 
553
638
  async function handle(req, res, url, origin) {
554
639
  if (url.pathname === `${path}/confirm`) {
555
- if (req.method === 'GET') await confirmView(res, url)
556
- else if (req.method === 'POST') await confirmDecide(req, res)
640
+ if (req.method === 'GET') await confirmView(req, res, url, origin)
641
+ else if (req.method === 'POST') await confirmDecide(req, res, origin)
557
642
  else return false
558
643
  return true
559
644
  }
@@ -562,19 +647,19 @@ ${hint ? `<p class="muted">${escape(hint)}</p>` : ''}
562
647
  return true
563
648
  }
564
649
  if (url.pathname === `${path}/authorize`) {
565
- if (req.method === 'GET') await authorizeStart(res, url, origin)
650
+ if (req.method === 'GET') await authorizeStart(req, res, url, origin)
566
651
  else if (req.method === 'POST') await authorizeFinish(req, res, origin)
567
652
  else return false
568
653
  return true
569
654
  }
570
655
  if (url.pathname === `${path}/device`) {
571
656
  if (req.method === 'POST') await deviceStart(req, res, origin)
572
- else if (req.method === 'GET') await deviceView(res, url)
657
+ else if (req.method === 'GET') await deviceView(req, res, url, origin)
573
658
  else return false
574
659
  return true
575
660
  }
576
661
  if (req.method === 'POST' && url.pathname === `${path}/device/approve`) {
577
- await deviceApprove(req, res)
662
+ await deviceApprove(req, res, origin)
578
663
  return true
579
664
  }
580
665
  if (req.method === 'POST' && url.pathname === `${path}/token`) {