@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/README.md +128 -2
- package/package.json +40 -4
- package/src/check.js +16 -0
- package/src/check.mjs +91 -0
- package/src/cli.js +9 -0
- package/src/config.mjs +111 -0
- package/src/http.mjs +43 -0
- package/src/index.d.ts +88 -0
- package/src/index.js +115 -0
- package/src/node-server.mjs +375 -0
- package/src/oauth.mjs +592 -0
- package/src/store.mjs +358 -0
- package/src/web.mjs +56 -0
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
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
|
+
}
|