@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 +27 -0
- package/package.json +1 -1
- package/src/config.mjs +2 -1
- package/src/index.d.ts +20 -2
- package/src/index.js +23 -1
- package/src/node-server.mjs +40 -1
- package/src/oauth.mjs +124 -39
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
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
|
|
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 {
|
package/src/node-server.mjs
CHANGED
|
@@ -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
|
-
?
|
|
205
|
-
:
|
|
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: {
|
|
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
|
-
|
|
238
|
-
|
|
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
|
|
286
|
-
if (!account) return consentPage(res, 401, id, pending,
|
|
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
|
-
|
|
354
|
-
|
|
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, {
|
|
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
|
|
382
|
-
if (!account) return devicePage(res, 401, { userCode, device, error:
|
|
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
|
-
|
|
501
|
-
<
|
|
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
|
|
613
|
+
const account = await approver(req, form)
|
|
533
614
|
if (!account || account !== confirmation.account) {
|
|
534
|
-
return confirmPage(res, 401, {
|
|
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`) {
|