@wtfalch/auth 0.31.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/README.md +103 -1
- package/dist/auth.d.ts +4 -0
- package/dist/auth.js +13 -42
- package/dist/body.d.ts +5 -0
- package/dist/body.js +35 -0
- package/dist/cookies.d.ts +4 -0
- package/dist/cookies.js +10 -5
- package/dist/desktop-crypto.d.ts +26 -0
- package/dist/desktop-crypto.js +62 -0
- package/dist/desktop-store-contract.d.ts +13 -0
- package/dist/desktop-store-contract.js +106 -0
- package/dist/desktop-store-postgres.d.ts +40 -0
- package/dist/desktop-store-postgres.js +133 -0
- package/dist/desktop-store.d.ts +130 -0
- package/dist/desktop-store.js +111 -0
- package/dist/desktop.d.ts +11 -76
- package/dist/desktop.js +156 -286
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/preview.d.ts +28 -0
- package/dist/preview.js +56 -0
- package/dist/redirect.d.ts +9 -0
- package/dist/redirect.js +14 -0
- package/dist/util.d.ts +4 -0
- package/dist/util.js +2 -0
- package/dist/workspaces.d.ts +79 -0
- package/dist/workspaces.js +137 -0
- package/package.json +8 -2
package/dist/desktop.js
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import {
|
|
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) =>
|
|
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
|
|
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
|
-
/**
|
|
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 (!
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
|
269
|
-
|
|
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
|
-
/**
|
|
274
|
-
function
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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' || !
|
|
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(
|
|
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
|
-
|
|
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
|
|
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 (!
|
|
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
|
-
|
|
441
|
-
|
|
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
|
|
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/index.d.ts
CHANGED
|
@@ -7,3 +7,4 @@ export type { PersonIdentity, PersonTokenConfig, PersonTokenOutcome, PersonToken
|
|
|
7
7
|
export { BrokerError, type PeoplePage, type Person } from './broker.js';
|
|
8
8
|
export { AuthError, type AuthErrorReason, ORG_CLAIM } from './oidc.js';
|
|
9
9
|
export { safeLoginHint, safeNextPath } from './redirect.js';
|
|
10
|
+
export { PREVIEW_CHECK_PATH, PREVIEW_COOKIE, PREVIEW_PATH, type PreviewRoutes, type PreviewRoutesOptions, createPreviewRoutes, } from './preview.js';
|
package/dist/index.js
CHANGED
|
@@ -3,3 +3,4 @@ export { DesktopHandoffError, } from './desktop.js';
|
|
|
3
3
|
export { BrokerError } from './broker.js';
|
|
4
4
|
export { AuthError, ORG_CLAIM } from './oidc.js';
|
|
5
5
|
export { safeLoginHint, safeNextPath } from './redirect.js';
|
|
6
|
+
export { PREVIEW_CHECK_PATH, PREVIEW_COOKIE, PREVIEW_PATH, createPreviewRoutes, } from './preview.js';
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Auth, User } from './auth.js';
|
|
2
|
+
/** The cookie that selects the idle slot at the balancer. */
|
|
3
|
+
export declare const PREVIEW_COOKIE = "bg_preview";
|
|
4
|
+
/** Where the balancer's forwardAuth asks whether the request may see the idle slot. */
|
|
5
|
+
export declare const PREVIEW_CHECK_PATH = "/api/foundry/preview-check";
|
|
6
|
+
/** Where the app sets (POST) and clears (DELETE) the preview cookie. */
|
|
7
|
+
export declare const PREVIEW_PATH = "/api/foundry/preview";
|
|
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'>;
|
|
16
|
+
/** Whether this session holds `releases:preview` in the app's production authz. */
|
|
17
|
+
mayPreview: (user: User) => Promise<boolean>;
|
|
18
|
+
}
|
|
19
|
+
export interface PreviewRoutes {
|
|
20
|
+
/** GET `PREVIEW_CHECK_PATH`: 200 allowed, 401 no session, 403 otherwise. */
|
|
21
|
+
check: (request: Request) => Promise<Response>;
|
|
22
|
+
/** POST `PREVIEW_PATH`: same-origin, same check, then sets the cookie (204). */
|
|
23
|
+
set: (request: Request) => Promise<Response>;
|
|
24
|
+
/** DELETE `PREVIEW_PATH`: same-origin, clears the cookie (204). */
|
|
25
|
+
clear: (request: Request) => Promise<Response>;
|
|
26
|
+
}
|
|
27
|
+
/** Route handlers for the idle preview: the check the balancer calls, and the cookie the person sets and clears. */
|
|
28
|
+
export declare function createPreviewRoutes(options: PreviewRoutesOptions): PreviewRoutes;
|