@wtfalch/auth 0.32.0 → 0.33.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/dist/desktop.js CHANGED
@@ -1,4 +1,11 @@
1
- import { createCipheriv, createDecipheriv, createHash, hkdfSync, randomBytes, randomUUID, timingSafeEqual, } from 'node:crypto';
1
+ import { randomUUID } from 'node:crypto';
2
+ import { declaredWithin, readBounded } from './body.js';
3
+ import { clearCookie, digest, setCookie, withCookies } from './cookies.js';
4
+ import { newNonce, open, pairingCode, same, seal, sealKey } from './desktop-crypto.js';
5
+ import { NO_STORE } from './util.js';
6
+ export { createMemoryDesktopHandoffStore, } from './desktop-store.js';
7
+ export { DESKTOP_HANDOFFS_TABLE_SQL, createPostgresDesktopHandoffStore, } from './desktop-store-postgres.js';
8
+ export { checkDesktopHandoffStore } from './desktop-store-contract.js';
2
9
  export class DesktopHandoffError extends Error {
3
10
  status;
4
11
  code;
@@ -16,167 +23,9 @@ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
16
23
  const SAFE_REASON = /^[a-z_]{1,20}$/;
17
24
  /** Same shape `@wtfalch/desktop`'s `cookieHandoff({ scheme })` requires. */
18
25
  const SCHEME = /^[a-z][a-z0-9+.-]*$/;
26
+ /** Where the handler is mounted. */
27
+ const BASE = '/auth/desktop';
19
28
  const APP_ID = /^[a-z][a-z0-9-]*$/;
20
- const HINT = /^[^\s@<>]{1,64}@[^\s@<>]{1,190}$/;
21
- const NO_STORE = { 'Cache-Control': 'no-store' };
22
- const digest = (value) => createHash('sha256').update(value).digest('base64url');
23
- const newNonce = () => randomBytes(32).toString('base64url');
24
- function same(a, b) {
25
- const x = Buffer.from(a);
26
- const y = Buffer.from(b);
27
- return x.length === y.length && timingSafeEqual(x, y);
28
- }
29
- /**
30
- * The key that seals a payload, derived from the cookie secret the app
31
- * already holds, so no new secret exists. The fixed label keeps it apart
32
- * from every other use of that secret. It is not the label Boule and Archon
33
- * used (`boule-desktop-handoff`, `archon-desktop-handoff`): a row sealed under
34
- * the old label fails to open here, so a sign-in in flight when an app cuts
35
- * over to this handler ends in `payload_invalid`. The window is the row's
36
- * lifetime, five minutes.
37
- */
38
- function sealKey(secret) {
39
- return Buffer.from(hkdfSync('sha256', secret, Buffer.alloc(0), 'wtfalch-auth-desktop-handoff', 32));
40
- }
41
- /** AES-256-GCM. `aad` is authenticated, not secret: it ties the ciphertext to one row and one origin. */
42
- function seal(key, aad, plaintext) {
43
- const iv = randomBytes(12);
44
- const cipher = createCipheriv('aes-256-gcm', key, iv);
45
- cipher.setAAD(Buffer.from(aad));
46
- const data = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
47
- return Buffer.concat([iv, cipher.getAuthTag(), data]).toString('base64url');
48
- }
49
- /** Throws when the ciphertext, the tag or the `aad` is not what `seal` was given. */
50
- function open(key, aad, sealed) {
51
- const data = Buffer.from(sealed, 'base64url');
52
- const decipher = createDecipheriv('aes-256-gcm', key, data.subarray(0, 12));
53
- decipher.setAAD(Buffer.from(aad));
54
- decipher.setAuthTag(data.subarray(12, 28));
55
- return Buffer.concat([decipher.update(data.subarray(28)), decipher.final()]).toString('utf8');
56
- }
57
- const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
58
- /**
59
- * The code the browser and the shell each derive from the challenge, so the
60
- * person can see both belong to one sign-in: the first 30 bits of
61
- * sha256("boule-desktop-pair:" + challenge), 5 bits each, shown as `ABC-DEF`.
62
- * The label is fixed text, not this app's id: it is shared with
63
- * `@wtfalch/desktop`'s `login.mjs`, which derives the same code
64
- * independently of which app it is signing into. Changing it breaks pairing
65
- * for every shell already built against it.
66
- */
67
- function pairingCode(challenge) {
68
- const bits = createHash('sha256')
69
- .update(`boule-desktop-pair:${challenge}`)
70
- .digest()
71
- .readUInt32BE(0);
72
- let code = '';
73
- for (let i = 0; i < 6; i++)
74
- code += CROCKFORD[(bits >>> (27 - 5 * i)) & 31];
75
- return `${code.slice(0, 3)}-${code.slice(3)}`;
76
- }
77
- /**
78
- * One process only. For tests and a single-instance development server: two
79
- * replicas with separate memory stores do not share rows.
80
- */
81
- export function createMemoryDesktopHandoffStore(options) {
82
- const capacity = options?.capacity ?? 128;
83
- const ttlMs = options?.ttlMs ?? 5 * 60 * 1000;
84
- const pollGapMs = options?.pollGapMs ?? 1000;
85
- const rows = new Map();
86
- /** Per row id, the end of the line of transactions waiting for it. */
87
- const lineEnds = new Map();
88
- async function acquire(tx, id) {
89
- if (tx.held.has(id))
90
- return;
91
- const before = lineEnds.get(id) ?? Promise.resolve();
92
- let release;
93
- const turn = new Promise((resolve) => {
94
- release = resolve;
95
- });
96
- const end = before.then(() => turn);
97
- lineEnds.set(id, end);
98
- await before;
99
- tx.held.set(id, () => {
100
- if (lineEnds.get(id) === end)
101
- lineEnds.delete(id);
102
- release();
103
- });
104
- }
105
- const sweep = () => {
106
- const now = Date.now();
107
- for (const [id, entry] of rows)
108
- if (entry.expiresAt <= now)
109
- rows.delete(id);
110
- };
111
- const found = (entry) => {
112
- const { expiresAt, lastPoll, callerHash: _caller, ...row } = entry;
113
- return {
114
- row,
115
- live: expiresAt > Date.now(),
116
- pollOk: lastPoll === null || lastPoll < Date.now() - pollGapMs,
117
- };
118
- };
119
- return {
120
- async admit(row, limits) {
121
- sweep();
122
- if (rows.size >= capacity)
123
- return false;
124
- let mine = 0;
125
- for (const entry of rows.values())
126
- if (entry.callerHash === row.callerHash)
127
- mine++;
128
- if (mine >= limits.perCaller)
129
- return false;
130
- rows.set(row.id, {
131
- id: row.id,
132
- status: 'started',
133
- challenge: row.challenge,
134
- browserHash: row.browserHash,
135
- stateHash: row.stateHash,
136
- payload: null,
137
- subject: null,
138
- email: null,
139
- revision: null,
140
- expiresAt: Date.now() + ttlMs,
141
- lastPoll: null,
142
- callerHash: row.callerHash,
143
- });
144
- return true;
145
- },
146
- async transaction(fn) {
147
- const tx = { held: new Map() };
148
- try {
149
- return await fn(tx);
150
- }
151
- finally {
152
- for (const release of tx.held.values())
153
- release();
154
- }
155
- },
156
- async lock(tx, id) {
157
- await acquire(tx, id);
158
- const entry = rows.get(id);
159
- return entry ? found(entry) : undefined;
160
- },
161
- async read(id) {
162
- const entry = rows.get(id);
163
- return entry ? found(entry) : undefined;
164
- },
165
- async patch(_tx, id, set) {
166
- const entry = rows.get(id);
167
- if (entry)
168
- Object.assign(entry, set);
169
- },
170
- async remove(_tx, id) {
171
- rows.delete(id);
172
- },
173
- async polled(_tx, id) {
174
- const entry = rows.get(id);
175
- if (entry)
176
- entry.lastPoll = Date.now();
177
- },
178
- };
179
- }
180
29
  /** Fail-open on purpose, for an app with nothing further to check; see `DesktopHandoffConfig.admit`. */
181
30
  const alwaysAdmit = async (user) => ({ ok: true, user: { id: user.id } });
182
31
  export function createDesktopHandoff({ auth, appUrl, secret, appId, scheme, store, admit = alwaysAdmit, onError, clientAddressHeader = 'cf-connecting-ip', maxStartsPerCaller = 8, }) {
@@ -200,7 +49,8 @@ export function createDesktopHandoff({ auth, appUrl, secret, appId, scheme, stor
200
49
  response.headers.append('Set-Cookie', cookie);
201
50
  return response;
202
51
  };
203
- const browserCookie = (value) => `${cookieName}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${value ? 300 : 0}${secure ? '; Secure' : ''}`;
52
+ const browserCookie = (value) => (value ? setCookie(cookieName, value, 300, secure) : clearCookie(cookieName, secure)).header;
53
+ const errorPage = (reason, cookies = []) => redirect(`${BASE}/error?reason=${reason}`, cookies);
204
54
  const reasonOf = (error) => error instanceof DesktopHandoffError ? error.code : 'unavailable';
205
55
  /** Tells the host about a failure nobody planned for; a throwing hook never changes the answer. */
206
56
  function report(step, error) {
@@ -224,15 +74,19 @@ export function createDesktopHandoff({ auth, appUrl, secret, appId, scheme, stor
224
74
  }
225
75
  catch (error) {
226
76
  report(step, error);
227
- return redirect(`/auth/desktop/error?reason=${reasonOf(error)}`);
77
+ return errorPage(reasonOf(error));
228
78
  }
229
79
  }
230
80
  /** A digest of the caller's address; null when the trusted header is absent (those callers share one bucket). */
231
- function callerOf(request) {
81
+ async function callerOf(request) {
232
82
  const value = request.headers.get(clientAddressHeader)?.trim();
233
83
  return value ? digest(value) : null;
234
84
  }
235
- /** The id and nonce the browser holds; the row is the only place either is checked. */
85
+ /**
86
+ * The id and nonce the browser holds; the row is the only place either is checked. Not
87
+ * `cookieFrom`: that takes the first cookie of the name, and this refuses a request that
88
+ * carries the name twice.
89
+ */
236
90
  function browserOf(cookieHeader) {
237
91
  const values = (cookieHeader ?? '')
238
92
  .split(';')
@@ -243,6 +97,8 @@ export function createDesktopHandoff({ auth, appUrl, secret, appId, scheme, stor
243
97
  throw fail(401, 'browser_required');
244
98
  return { id, nonce };
245
99
  }
100
+ /** Whether `nonce` is the one this row was started with. */
101
+ const holds = async (row, nonce) => same(row.browserHash, await digest(nonce));
246
102
  /** The row this browser started, locked: it must exist, be unexpired, and carry this browser's nonce. */
247
103
  async function browserRow(tx, cookieHeader) {
248
104
  const browser = browserOf(cookieHeader);
@@ -251,29 +107,113 @@ export function createDesktopHandoff({ auth, appUrl, secret, appId, scheme, stor
251
107
  throw fail(404, 'not_found');
252
108
  if (!found.live)
253
109
  throw fail(410, 'expired');
254
- if (!found.row.browserHash || !same(found.row.browserHash, digest(browser.nonce)))
110
+ if (!(await holds(found.row, browser.nonce)))
255
111
  throw fail(403, 'browser_mismatch');
256
112
  return found.row;
257
113
  }
114
+ /**
115
+ * Stricter than `isSameOrigin` on purpose: these POSTs come from the page this
116
+ * handler served, so the browser always sends `Origin`; `Sec-Fetch-Site` alone is not accepted.
117
+ */
258
118
  function sameOrigin(request) {
259
119
  if (request.headers.get('origin') !== origin)
260
120
  throw fail(403, 'origin');
261
121
  }
262
- const withoutSession = (cookieHeader) => (cookieHeader ?? '')
263
- .split(';')
264
- .map((part) => part.trim())
265
- .filter((part) => part && !part.startsWith(`${auth.sessionCookieName}=`))
266
- .join('; ');
122
+ const withoutSession = (cookieHeader) => withCookies(cookieHeader, [clearCookie(auth.sessionCookieName, secure)]);
123
+ /** The form body as text, read through the bounded reader: larger than 4096 bytes is refused, declared or not. */
124
+ async function formBody(request) {
125
+ const text = await readBounded(request, 4096);
126
+ if (text === null)
127
+ throw fail(413, 'body_too_large');
128
+ return text;
129
+ }
267
130
  async function formRevision(request) {
268
- const value = (await request.formData().catch(() => null))?.get('revision');
269
- if (typeof value !== 'string' || !UUID.test(value))
131
+ const form = request.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase();
132
+ const text = await formBody(request);
133
+ const value = form === 'application/x-www-form-urlencoded'
134
+ ? new URLSearchParams(text).getAll('revision')
135
+ : [];
136
+ if (value.length !== 1 || !UUID.test(value[0]))
270
137
  throw fail(400, 'invalid_request');
271
- return value;
138
+ return value[0];
272
139
  }
273
- /** A body is read only when its length is declared and within `max`: a missing length is refused too. */
274
- function bodyWithin(request, max) {
275
- const length = request.headers.get('content-length');
276
- return length !== null && /^\d{1,9}$/.test(length) && Number(length) <= max;
140
+ /** The shell trades its verifier for the cookie, once. No browser cookie involved. */
141
+ async function redeem(input) {
142
+ try {
143
+ const { id, verifier } = (input ?? {});
144
+ if (typeof id !== 'string' ||
145
+ !UUID.test(id) ||
146
+ typeof verifier !== 'string' ||
147
+ !OPAQUE.test(verifier))
148
+ throw fail(400, 'invalid_request');
149
+ const verifierDigest = await digest(verifier);
150
+ // Step one: check the verifier, stamp the attempt, and let go of the row. A refusal after
151
+ // the stamp is returned, not thrown, so the stamp (or the delete of an expired row) commits.
152
+ const claimed = await store.transaction(async (tx) => {
153
+ const found = await store.lock(tx, id);
154
+ if (!found)
155
+ throw fail(404, 'not_found');
156
+ const { row } = found;
157
+ if (!same(row.challenge, verifierDigest))
158
+ throw fail(403, 'invalid_verifier');
159
+ if (!found.live) {
160
+ await store.remove(tx, row.id);
161
+ return { error: fail(410, 'expired') };
162
+ }
163
+ if (!(await store.stampPoll(tx, row.id)))
164
+ throw fail(429, 'too_fast');
165
+ if (row.status !== 'approved')
166
+ return { error: fail(409, 'not_approved') };
167
+ return { id: row.id, payload: row.payload, subject: row.subject };
168
+ });
169
+ if ('error' in claimed)
170
+ throw claimed.error;
171
+ // Step two, with no lock held: `readCookie` is not local. It verifies the id token and
172
+ // may fetch the issuer's signing keys.
173
+ let payload;
174
+ try {
175
+ payload = JSON.parse(open(key, aad(claimed.id), claimed.payload));
176
+ }
177
+ catch {
178
+ throw fail(500, 'payload_invalid');
179
+ }
180
+ const verified = await auth.readCookie(payload.value, { refresh: false }).catch(() => {
181
+ throw fail(503, 'unavailable');
182
+ });
183
+ const admitted = verified.user ? await admit(verified.user) : null;
184
+ if (!admitted?.ok || admitted.user.id !== claimed.subject)
185
+ throw fail(401, 'identity_expired');
186
+ // Step three: lock again and take the row. Whoever deletes it first wins.
187
+ await store.transaction(async (tx) => {
188
+ const found = await store.lock(tx, claimed.id);
189
+ if (!found)
190
+ throw fail(404, 'not_found');
191
+ if (found.row.status !== 'approved')
192
+ throw fail(409, 'not_approved');
193
+ if (!found.live)
194
+ throw fail(410, 'expired');
195
+ await store.remove(tx, claimed.id);
196
+ });
197
+ const { name, value, secure: cookieSecure, maxAge } = payload;
198
+ return Response.json({
199
+ cookies: [
200
+ {
201
+ name,
202
+ value,
203
+ path: '/',
204
+ httpOnly: true,
205
+ secure: cookieSecure,
206
+ sameSite: 'lax',
207
+ maxAge,
208
+ },
209
+ ],
210
+ }, { headers: NO_STORE });
211
+ }
212
+ catch (error) {
213
+ report('redeem', error);
214
+ const status = error instanceof DesktopHandoffError ? error.status : 503;
215
+ return Response.json({ error: reasonOf(error) }, { status, headers: NO_STORE });
216
+ }
277
217
  }
278
218
  return {
279
219
  /** The cookie name, for the one place a test or a route needs to read it. */
@@ -284,10 +224,16 @@ export function createDesktopHandoff({ auth, appUrl, secret, appId, scheme, stor
284
224
  const challenge = params.get('challenge');
285
225
  if (params.getAll('challenge').length !== 1 || !challenge || !OPAQUE.test(challenge))
286
226
  throw fail(400, 'invalid_challenge');
227
+ // The SDK reads `prompt` and `login_hint` from the request it is given (it drops a
228
+ // `login_hint` that is not an email address), and wants a navigation.
229
+ const forwarded = new URLSearchParams();
230
+ if (params.get('prompt') === 'select_account')
231
+ forwarded.set('prompt', 'select_account');
287
232
  const hint = params.get('login_hint');
288
- const prompt = params.get('prompt') === 'select_account' ? '?prompt=select_account' : '';
289
- // The SDK reads only `prompt` from the request it is given, and wants a navigation.
290
- const started = await auth.start(new Request(`${origin}/auth/desktop/start${prompt}`, {
233
+ if (hint)
234
+ forwarded.set('login_hint', hint);
235
+ const query = forwarded.size ? `?${forwarded}` : '';
236
+ const started = await auth.start(new Request(`${origin}${BASE}/start${query}`, {
291
237
  headers: { accept: 'text/html', 'sec-fetch-mode': 'navigate' },
292
238
  }));
293
239
  const location = started.headers.get('location');
@@ -295,17 +241,14 @@ export function createDesktopHandoff({ auth, appUrl, secret, appId, scheme, stor
295
241
  const state = target?.searchParams.get('state');
296
242
  if (!target || !state)
297
243
  throw fail(503, 'unavailable');
298
- // The SDK does not forward `login_hint`; the issuer's authorize URL takes it as a plain parameter.
299
- if (hint && hint.length <= 254 && HINT.test(hint))
300
- target.searchParams.set('login_hint', hint);
301
244
  const id = randomUUID();
302
245
  const nonce = newNonce();
303
246
  const admitted = await store.admit({
304
247
  id,
305
248
  challenge,
306
- browserHash: digest(nonce),
307
- stateHash: digest(state),
308
- callerHash: callerOf(request),
249
+ browserHash: await digest(nonce),
250
+ stateHash: await digest(state),
251
+ callerHash: await callerOf(request),
309
252
  }, { perCaller: maxStartsPerCaller });
310
253
  if (!admitted)
311
254
  throw fail(429, 'capacity');
@@ -320,12 +263,13 @@ export function createDesktopHandoff({ auth, appUrl, secret, appId, scheme, stor
320
263
  const url = new URL(request.url);
321
264
  // Three steps, and no transaction open across the issuer's answer: validate and let go,
322
265
  // ask the issuer, then lock again and apply only if the row is still waiting.
266
+ const states = url.searchParams.getAll('state');
267
+ const stateDigest = states.length === 1 ? await digest(states[0]) : null;
323
268
  await store.transaction(async (tx) => {
324
269
  const row = await browserRow(tx, cookieHeader);
325
270
  if (row.status !== 'started')
326
271
  throw fail(409, 'step_changed');
327
- const states = url.searchParams.getAll('state');
328
- if (states.length !== 1 || !row.stateHash || !same(row.stateHash, digest(states[0])))
272
+ if (stateDigest === null || !same(row.stateHash, stateDigest))
329
273
  throw fail(409, 'state_mismatch');
330
274
  });
331
275
  // Behind a proxy `request.url` can carry an internal host; the path and query are what the browser asked for.
@@ -340,7 +284,7 @@ export function createDesktopHandoff({ auth, appUrl, secret, appId, scheme, stor
340
284
  if (!cookie || !user) {
341
285
  // No write: the row (and the binding cookie) stay for a valid callback that may still be running.
342
286
  const reason = new URL(result.location, origin).searchParams.get('auth_error');
343
- return redirect(`/auth/desktop/error?reason=${reason && SAFE_REASON.test(reason) ? reason : 'failed'}`, cleared);
287
+ return errorPage(reason && SAFE_REASON.test(reason) ? reason : 'failed', cleared);
344
288
  }
345
289
  // Refused here, not at the first request with the cookie: the shell is never handed a
346
290
  // session the app would turn away. `admit` runs under the row's lock, so two concurrent
@@ -352,7 +296,7 @@ export function createDesktopHandoff({ auth, appUrl, secret, appId, scheme, stor
352
296
  throw fail(409, 'step_changed');
353
297
  const admission = await admit(user);
354
298
  if (!admission.ok)
355
- return redirect(`/auth/desktop/error?reason=${SAFE_REASON.test(admission.reason) ? admission.reason : 'denied'}`, cleared);
299
+ return errorPage(SAFE_REASON.test(admission.reason) ? admission.reason : 'denied', cleared);
356
300
  const payload = {
357
301
  name: cookie.name,
358
302
  value: cookie.value,
@@ -366,7 +310,7 @@ export function createDesktopHandoff({ auth, appUrl, secret, appId, scheme, stor
366
310
  email: user.email,
367
311
  revision: randomUUID(),
368
312
  });
369
- return redirect('/auth/desktop/confirm', cleared);
313
+ return redirect(`${BASE}/confirm`, cleared);
370
314
  });
371
315
  }),
372
316
  /**
@@ -387,9 +331,7 @@ export function createDesktopHandoff({ auth, appUrl, secret, appId, scheme, stor
387
331
  code: null,
388
332
  returnUrl: null,
389
333
  };
390
- if (!found.live ||
391
- !found.row.browserHash ||
392
- !same(found.row.browserHash, digest(browser.nonce)))
334
+ if (!found.live || !(await holds(found.row, browser.nonce)))
393
335
  return null;
394
336
  const { id, status, email, revision, challenge } = found.row;
395
337
  return {
@@ -401,128 +343,56 @@ export function createDesktopHandoff({ auth, appUrl, secret, appId, scheme, stor
401
343
  returnUrl: status === 'approved' ? returnUrl(id) : null,
402
344
  };
403
345
  }
404
- catch {
405
- return null;
346
+ catch (error) {
347
+ // A cookie that does not open or match is a refusal: signed out. A store or issuer
348
+ // failure is not, and must not look like one: log what is safe to log and let it rise.
349
+ if (error instanceof DesktopHandoffError)
350
+ return null;
351
+ console.error('desktop handoff current failed', error instanceof Error ? error.name : 'unknown');
352
+ throw error;
406
353
  }
407
354
  },
408
355
  /** POST /auth/desktop/approve: the person confirms the account the page showed. */
409
356
  approve: (request) => page('approve', async () => {
410
357
  sameOrigin(request);
411
- if (!bodyWithin(request, 4096))
412
- throw fail(413, 'body_too_large');
413
358
  const seen = await formRevision(request);
414
359
  return store.transaction(async (tx) => {
415
360
  const row = await browserRow(tx, request.headers.get('cookie'));
416
- if (row.status !== 'completed' || !row.revision || !same(row.revision, seen))
361
+ if (row.status !== 'completed' || !same(row.revision, seen))
417
362
  throw fail(409, 'step_changed');
418
363
  await store.patch(tx, row.id, { status: 'approved' });
419
- return redirect('/auth/desktop/done');
364
+ return redirect(`${BASE}/done`);
420
365
  });
421
366
  }),
422
367
  /** POST /auth/desktop/cancel: ends the sign-in and deletes its row. */
423
368
  cancel: (request) => page('cancel', async () => {
424
369
  sameOrigin(request);
425
- if (!bodyWithin(request, 4096))
426
- throw fail(413, 'body_too_large');
370
+ await formBody(request);
427
371
  return store.transaction(async (tx) => {
428
372
  const row = await browserRow(tx, request.headers.get('cookie'));
429
373
  await store.remove(tx, row.id);
430
- return redirect('/auth/desktop/error?reason=cancelled', [browserCookie('')]);
374
+ return errorPage('cancelled', [browserCookie('')]);
431
375
  });
432
376
  }),
433
377
  /** POST /auth/desktop/redeem, with the body still unread: refuses one that is not small and declared. */
434
378
  async redeemRequest(request) {
435
379
  const json = request.headers.get('content-type')?.split(';')[0]?.trim() === 'application/json';
436
- if (!bodyWithin(request, 1024))
380
+ if (!declaredWithin(request, 1024))
437
381
  return Response.json({ error: 'body_too_large' }, { status: 413, headers: NO_STORE });
438
382
  let input = null;
439
383
  try {
440
- if (json)
441
- input = JSON.parse(await request.text());
384
+ const text = json ? await readBounded(request, 1024) : null;
385
+ if (text === null && json)
386
+ return Response.json({ error: 'body_too_large' }, { status: 413, headers: NO_STORE });
387
+ if (text !== null)
388
+ input = JSON.parse(text);
442
389
  }
443
390
  catch {
444
391
  // Not JSON: `input` stays null, and `redeem` refuses it as `invalid_request`.
445
392
  input = null;
446
393
  }
447
- return this.redeem(input);
448
- },
449
- /** The shell trades its verifier for the cookie, once. No browser cookie involved. */
450
- async redeem(input) {
451
- try {
452
- const { id, verifier } = (input ?? {});
453
- if (typeof id !== 'string' ||
454
- !UUID.test(id) ||
455
- typeof verifier !== 'string' ||
456
- !OPAQUE.test(verifier))
457
- throw fail(400, 'invalid_request');
458
- // Step one: check the verifier, stamp the attempt, and let go of the row. A refusal after
459
- // the stamp is returned, not thrown, so the stamp (or the delete of an expired row) commits.
460
- const claimed = await store.transaction(async (tx) => {
461
- const found = await store.lock(tx, id);
462
- if (!found)
463
- throw fail(404, 'not_found');
464
- const { row } = found;
465
- if (!same(row.challenge, digest(verifier)))
466
- throw fail(403, 'invalid_verifier');
467
- if (!found.live) {
468
- await store.remove(tx, row.id);
469
- return { error: fail(410, 'expired') };
470
- }
471
- if (!found.pollOk)
472
- throw fail(429, 'too_fast');
473
- await store.polled(tx, row.id);
474
- if (row.status !== 'approved' || !row.payload)
475
- return { error: fail(409, 'not_approved') };
476
- return { id: row.id, payload: row.payload, subject: row.subject };
477
- });
478
- if ('error' in claimed)
479
- throw claimed.error;
480
- // Step two, with no lock held: `readCookie` is not local. It verifies the id token and
481
- // may fetch the issuer's signing keys.
482
- let payload;
483
- try {
484
- payload = JSON.parse(open(key, aad(claimed.id), claimed.payload));
485
- }
486
- catch {
487
- throw fail(500, 'payload_invalid');
488
- }
489
- const verified = await auth.readCookie(payload.value, { refresh: false }).catch(() => {
490
- throw fail(503, 'unavailable');
491
- });
492
- const admitted = verified.user ? await admit(verified.user) : null;
493
- if (!admitted?.ok || admitted.user.id !== claimed.subject)
494
- throw fail(401, 'identity_expired');
495
- // Step three: lock again and take the row. Whoever deletes it first wins.
496
- await store.transaction(async (tx) => {
497
- const found = await store.lock(tx, claimed.id);
498
- if (!found)
499
- throw fail(404, 'not_found');
500
- if (found.row.status !== 'approved')
501
- throw fail(409, 'not_approved');
502
- if (!found.live)
503
- throw fail(410, 'expired');
504
- await store.remove(tx, claimed.id);
505
- });
506
- const { name, value, secure: cookieSecure, maxAge } = payload;
507
- return Response.json({
508
- cookies: [
509
- {
510
- name,
511
- value,
512
- path: '/',
513
- httpOnly: true,
514
- secure: cookieSecure,
515
- sameSite: 'lax',
516
- maxAge,
517
- },
518
- ],
519
- }, { headers: NO_STORE });
520
- }
521
- catch (error) {
522
- report('redeem', error);
523
- const status = error instanceof DesktopHandoffError ? error.status : 503;
524
- return Response.json({ error: reasonOf(error) }, { status, headers: NO_STORE });
525
- }
394
+ return redeem(input);
526
395
  },
396
+ redeem,
527
397
  };
528
398
  }
package/dist/preview.d.ts CHANGED
@@ -1,16 +1,20 @@
1
+ import type { Auth, User } from './auth.js';
1
2
  /** The cookie that selects the idle slot at the balancer. */
2
3
  export declare const PREVIEW_COOKIE = "bg_preview";
3
4
  /** Where the balancer's forwardAuth asks whether the request may see the idle slot. */
4
5
  export declare const PREVIEW_CHECK_PATH = "/api/foundry/preview-check";
5
6
  /** Where the app sets (POST) and clears (DELETE) the preview cookie. */
6
7
  export declare const PREVIEW_PATH = "/api/foundry/preview";
7
- export interface PreviewRoutesOptions<S> {
8
- /** The app's own session reader (for example `auth.read`). Null means signed out. */
9
- readSession: (request: Request) => Promise<S | null>;
8
+ export interface PreviewRoutesOptions {
9
+ /**
10
+ * The app's auth instance. The session is read through it (without
11
+ * refreshing, since these routes set no session cookie), and the app's origin
12
+ * and the cookie's `Secure` rule come from its `appUrl`, read on the first
13
+ * request so importing this module at build time needs no environment.
14
+ */
15
+ auth: Pick<Auth, 'read' | 'appOrigin' | 'cookiesSecure'>;
10
16
  /** Whether this session holds `releases:preview` in the app's production authz. */
11
- mayPreview: (session: S) => Promise<boolean>;
12
- /** The app's own origin, the one `appUrl` names. Required: the trusted origin never comes from the request. */
13
- appOrigin: string;
17
+ mayPreview: (user: User) => Promise<boolean>;
14
18
  }
15
19
  export interface PreviewRoutes {
16
20
  /** GET `PREVIEW_CHECK_PATH`: 200 allowed, 401 no session, 403 otherwise. */
@@ -21,4 +25,4 @@ export interface PreviewRoutes {
21
25
  clear: (request: Request) => Promise<Response>;
22
26
  }
23
27
  /** Route handlers for the idle preview: the check the balancer calls, and the cookie the person sets and clears. */
24
- export declare function createPreviewRoutes<S>(options: PreviewRoutesOptions<S>): PreviewRoutes;
28
+ export declare function createPreviewRoutes(options: PreviewRoutesOptions): PreviewRoutes;