@wtfalch/auth 0.29.0 → 0.31.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 +112 -0
- package/dist/auth.d.ts +29 -1
- package/dist/auth.js +138 -4
- package/dist/broker.d.ts +6 -0
- package/dist/broker.js +5 -0
- package/dist/browser.d.ts +11 -1
- package/dist/browser.js +17 -1
- package/dist/config.d.ts +34 -4
- package/dist/config.js +1 -0
- package/dist/desktop.d.ts +199 -0
- package/dist/desktop.js +528 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/mcp.d.ts +15 -4
- package/dist/mcp.js +24 -10
- package/dist/names.d.ts +5 -3
- package/dist/names.js +15 -5
- package/dist/namespace-browser.js +4 -3
- package/dist/namespace-session.js +2 -2
- package/dist/next.js +1 -1
- package/dist/oidc.d.ts +24 -1
- package/dist/oidc.js +57 -1
- package/dist/onboarding.d.ts +28 -0
- package/dist/onboarding.js +91 -0
- package/dist/person-token.d.ts +44 -10
- package/dist/person-token.js +108 -36
- package/dist/qr-door-scene.d.ts +5 -0
- package/dist/qr-door-scene.js +14 -0
- package/dist/qr-door.js +14 -3
- package/dist/redirect.js +4 -3
- package/dist/sessions.d.ts +0 -5
- package/dist/sessions.js +0 -60
- package/dist/util.d.ts +14 -0
- package/dist/util.js +16 -0
- package/dist/worker-session.d.ts +14 -0
- package/dist/worker-session.js +29 -16
- package/package.json +8 -4
- package/dist/testing.d.ts +0 -57
- package/dist/testing.js +0 -138
package/dist/desktop.js
ADDED
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHash, hkdfSync, randomBytes, randomUUID, timingSafeEqual, } from 'node:crypto';
|
|
2
|
+
export class DesktopHandoffError extends Error {
|
|
3
|
+
status;
|
|
4
|
+
code;
|
|
5
|
+
constructor(status, code) {
|
|
6
|
+
super(code);
|
|
7
|
+
this.status = status;
|
|
8
|
+
this.code = code;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
const fail = (status, code) => new DesktopHandoffError(status, code);
|
|
12
|
+
/** 32 random bytes as base64url: the shape of a challenge, a verifier and a browser nonce. */
|
|
13
|
+
const OPAQUE = /^[A-Za-z0-9_-]{43}$/;
|
|
14
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
15
|
+
/** The error page's reasons, and an admission hook's, are a closed shape: letters and underscores only. */
|
|
16
|
+
const SAFE_REASON = /^[a-z_]{1,20}$/;
|
|
17
|
+
/** Same shape `@wtfalch/desktop`'s `cookieHandoff({ scheme })` requires. */
|
|
18
|
+
const SCHEME = /^[a-z][a-z0-9+.-]*$/;
|
|
19
|
+
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
|
+
/** Fail-open on purpose, for an app with nothing further to check; see `DesktopHandoffConfig.admit`. */
|
|
181
|
+
const alwaysAdmit = async (user) => ({ ok: true, user: { id: user.id } });
|
|
182
|
+
export function createDesktopHandoff({ auth, appUrl, secret, appId, scheme, store, admit = alwaysAdmit, onError, clientAddressHeader = 'cf-connecting-ip', maxStartsPerCaller = 8, }) {
|
|
183
|
+
if (!APP_ID.test(appId))
|
|
184
|
+
throw new Error('createDesktopHandoff needs a valid appId');
|
|
185
|
+
if (!SCHEME.test(scheme))
|
|
186
|
+
throw new Error('createDesktopHandoff needs a valid scheme');
|
|
187
|
+
const origin = new URL(appUrl).origin;
|
|
188
|
+
const secure = origin.startsWith('https:');
|
|
189
|
+
// `__Host-` needs Secure, so plain-http localhost gets the bare name.
|
|
190
|
+
const cookieName = `${secure ? '__Host-' : ''}${appId}_desktop`;
|
|
191
|
+
const key = sealKey(secret);
|
|
192
|
+
const aad = (id) => `${origin}|${id}`;
|
|
193
|
+
const returnUrl = (id) => `${scheme}://auth/done?id=${id}`;
|
|
194
|
+
const redirect = (location, cookies = []) => {
|
|
195
|
+
const response = new Response(null, {
|
|
196
|
+
status: 303,
|
|
197
|
+
headers: { ...NO_STORE, Location: new URL(location, origin).href },
|
|
198
|
+
});
|
|
199
|
+
for (const cookie of cookies)
|
|
200
|
+
response.headers.append('Set-Cookie', cookie);
|
|
201
|
+
return response;
|
|
202
|
+
};
|
|
203
|
+
const browserCookie = (value) => `${cookieName}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${value ? 300 : 0}${secure ? '; Secure' : ''}`;
|
|
204
|
+
const reasonOf = (error) => error instanceof DesktopHandoffError ? error.code : 'unavailable';
|
|
205
|
+
/** Tells the host about a failure nobody planned for; a throwing hook never changes the answer. */
|
|
206
|
+
function report(step, error) {
|
|
207
|
+
if (error instanceof DesktopHandoffError || !onError)
|
|
208
|
+
return;
|
|
209
|
+
try {
|
|
210
|
+
onError({
|
|
211
|
+
step,
|
|
212
|
+
reason: reasonOf(error),
|
|
213
|
+
errorName: error instanceof Error ? error.name : 'unknown',
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
catch (hookError) {
|
|
217
|
+
console.error('desktop handoff onError hook threw', hookError instanceof Error ? hookError.name : 'unknown');
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/** A browser step answers with a page; a failure sends it to the error page, with a reason from a closed list. */
|
|
221
|
+
async function page(step, run) {
|
|
222
|
+
try {
|
|
223
|
+
return await run();
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
report(step, error);
|
|
227
|
+
return redirect(`/auth/desktop/error?reason=${reasonOf(error)}`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
/** A digest of the caller's address; null when the trusted header is absent (those callers share one bucket). */
|
|
231
|
+
function callerOf(request) {
|
|
232
|
+
const value = request.headers.get(clientAddressHeader)?.trim();
|
|
233
|
+
return value ? digest(value) : null;
|
|
234
|
+
}
|
|
235
|
+
/** The id and nonce the browser holds; the row is the only place either is checked. */
|
|
236
|
+
function browserOf(cookieHeader) {
|
|
237
|
+
const values = (cookieHeader ?? '')
|
|
238
|
+
.split(';')
|
|
239
|
+
.map((part) => part.trim())
|
|
240
|
+
.filter((part) => part.startsWith(`${cookieName}=`));
|
|
241
|
+
const [id, nonce, extra] = (values.length === 1 ? values[0].slice(cookieName.length + 1) : '').split('.');
|
|
242
|
+
if (!UUID.test(id ?? '') || !OPAQUE.test(nonce ?? '') || extra !== undefined)
|
|
243
|
+
throw fail(401, 'browser_required');
|
|
244
|
+
return { id, nonce };
|
|
245
|
+
}
|
|
246
|
+
/** The row this browser started, locked: it must exist, be unexpired, and carry this browser's nonce. */
|
|
247
|
+
async function browserRow(tx, cookieHeader) {
|
|
248
|
+
const browser = browserOf(cookieHeader);
|
|
249
|
+
const found = await store.lock(tx, browser.id);
|
|
250
|
+
if (!found)
|
|
251
|
+
throw fail(404, 'not_found');
|
|
252
|
+
if (!found.live)
|
|
253
|
+
throw fail(410, 'expired');
|
|
254
|
+
if (!found.row.browserHash || !same(found.row.browserHash, digest(browser.nonce)))
|
|
255
|
+
throw fail(403, 'browser_mismatch');
|
|
256
|
+
return found.row;
|
|
257
|
+
}
|
|
258
|
+
function sameOrigin(request) {
|
|
259
|
+
if (request.headers.get('origin') !== origin)
|
|
260
|
+
throw fail(403, 'origin');
|
|
261
|
+
}
|
|
262
|
+
const withoutSession = (cookieHeader) => (cookieHeader ?? '')
|
|
263
|
+
.split(';')
|
|
264
|
+
.map((part) => part.trim())
|
|
265
|
+
.filter((part) => part && !part.startsWith(`${auth.sessionCookieName}=`))
|
|
266
|
+
.join('; ');
|
|
267
|
+
async function formRevision(request) {
|
|
268
|
+
const value = (await request.formData().catch(() => null))?.get('revision');
|
|
269
|
+
if (typeof value !== 'string' || !UUID.test(value))
|
|
270
|
+
throw fail(400, 'invalid_request');
|
|
271
|
+
return value;
|
|
272
|
+
}
|
|
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;
|
|
277
|
+
}
|
|
278
|
+
return {
|
|
279
|
+
/** The cookie name, for the one place a test or a route needs to read it. */
|
|
280
|
+
cookieName,
|
|
281
|
+
/** GET /auth/desktop/start */
|
|
282
|
+
start: (request) => page('start', async () => {
|
|
283
|
+
const params = new URL(request.url).searchParams;
|
|
284
|
+
const challenge = params.get('challenge');
|
|
285
|
+
if (params.getAll('challenge').length !== 1 || !challenge || !OPAQUE.test(challenge))
|
|
286
|
+
throw fail(400, 'invalid_challenge');
|
|
287
|
+
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}`, {
|
|
291
|
+
headers: { accept: 'text/html', 'sec-fetch-mode': 'navigate' },
|
|
292
|
+
}));
|
|
293
|
+
const location = started.headers.get('location');
|
|
294
|
+
const target = location ? new URL(location) : null;
|
|
295
|
+
const state = target?.searchParams.get('state');
|
|
296
|
+
if (!target || !state)
|
|
297
|
+
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
|
+
const id = randomUUID();
|
|
302
|
+
const nonce = newNonce();
|
|
303
|
+
const admitted = await store.admit({
|
|
304
|
+
id,
|
|
305
|
+
challenge,
|
|
306
|
+
browserHash: digest(nonce),
|
|
307
|
+
stateHash: digest(state),
|
|
308
|
+
callerHash: callerOf(request),
|
|
309
|
+
}, { perCaller: maxStartsPerCaller });
|
|
310
|
+
if (!admitted)
|
|
311
|
+
throw fail(429, 'capacity');
|
|
312
|
+
return redirect(target.href, [
|
|
313
|
+
...started.headers.getSetCookie(),
|
|
314
|
+
browserCookie(`${id}.${nonce}`),
|
|
315
|
+
]);
|
|
316
|
+
}),
|
|
317
|
+
/** GET /auth/desktop/callback: where the issuer sends the browser. Sets no session cookie. */
|
|
318
|
+
callback: (request) => page('callback', async () => {
|
|
319
|
+
const cookieHeader = request.headers.get('cookie');
|
|
320
|
+
const url = new URL(request.url);
|
|
321
|
+
// Three steps, and no transaction open across the issuer's answer: validate and let go,
|
|
322
|
+
// ask the issuer, then lock again and apply only if the row is still waiting.
|
|
323
|
+
await store.transaction(async (tx) => {
|
|
324
|
+
const row = await browserRow(tx, cookieHeader);
|
|
325
|
+
if (row.status !== 'started')
|
|
326
|
+
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])))
|
|
329
|
+
throw fail(409, 'state_mismatch');
|
|
330
|
+
});
|
|
331
|
+
// Behind a proxy `request.url` can carry an internal host; the path and query are what the browser asked for.
|
|
332
|
+
const result = await auth.complete(new URL(`${url.pathname}${url.search}`, origin).href, withoutSession(cookieHeader));
|
|
333
|
+
const cookie = result.cookies.find((c) => c.name === auth.sessionCookieName && c.value);
|
|
334
|
+
// Only the SDK's transaction cookie, cleared, ever goes to the browser: never a cookie
|
|
335
|
+
// of the session's name, set or cleared, which would sign the person out of the app.
|
|
336
|
+
const cleared = result.cookies
|
|
337
|
+
.filter((c) => !c.value && c.name !== auth.sessionCookieName)
|
|
338
|
+
.map((c) => c.header);
|
|
339
|
+
const user = cookie ? (await auth.readCookie(cookie.value, { refresh: false })).user : null;
|
|
340
|
+
if (!cookie || !user) {
|
|
341
|
+
// No write: the row (and the binding cookie) stay for a valid callback that may still be running.
|
|
342
|
+
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);
|
|
344
|
+
}
|
|
345
|
+
// Refused here, not at the first request with the cookie: the shell is never handed a
|
|
346
|
+
// session the app would turn away. `admit` runs under the row's lock, so two concurrent
|
|
347
|
+
// callbacks on one row take turns and only the first reaches it: the second finds the
|
|
348
|
+
// row `completed`. The host's hook may be slow; the lock is held for as long as it is.
|
|
349
|
+
return store.transaction(async (tx) => {
|
|
350
|
+
const row = await browserRow(tx, cookieHeader);
|
|
351
|
+
if (row.status !== 'started')
|
|
352
|
+
throw fail(409, 'step_changed');
|
|
353
|
+
const admission = await admit(user);
|
|
354
|
+
if (!admission.ok)
|
|
355
|
+
return redirect(`/auth/desktop/error?reason=${SAFE_REASON.test(admission.reason) ? admission.reason : 'denied'}`, cleared);
|
|
356
|
+
const payload = {
|
|
357
|
+
name: cookie.name,
|
|
358
|
+
value: cookie.value,
|
|
359
|
+
secure: cookie.attributes.secure,
|
|
360
|
+
maxAge: cookie.attributes.maxAge,
|
|
361
|
+
};
|
|
362
|
+
await store.patch(tx, row.id, {
|
|
363
|
+
status: 'completed',
|
|
364
|
+
payload: seal(key, aad(row.id), JSON.stringify(payload)),
|
|
365
|
+
subject: admission.user.id,
|
|
366
|
+
email: user.email,
|
|
367
|
+
revision: randomUUID(),
|
|
368
|
+
});
|
|
369
|
+
return redirect('/auth/desktop/confirm', cleared);
|
|
370
|
+
});
|
|
371
|
+
}),
|
|
372
|
+
/**
|
|
373
|
+
* What the confirm and done pages show, for the browser that holds the cookie; null for any
|
|
374
|
+
* other. A row that is gone (redeemed or cancelled rows are deleted) answers `gone` to the
|
|
375
|
+
* browser that held its binding, which is how the done page still says the app is signed in.
|
|
376
|
+
*/
|
|
377
|
+
async current(cookieHeader) {
|
|
378
|
+
try {
|
|
379
|
+
const browser = browserOf(cookieHeader);
|
|
380
|
+
const found = await store.read(browser.id);
|
|
381
|
+
if (!found)
|
|
382
|
+
return {
|
|
383
|
+
id: browser.id,
|
|
384
|
+
status: 'gone',
|
|
385
|
+
email: null,
|
|
386
|
+
revision: null,
|
|
387
|
+
code: null,
|
|
388
|
+
returnUrl: null,
|
|
389
|
+
};
|
|
390
|
+
if (!found.live ||
|
|
391
|
+
!found.row.browserHash ||
|
|
392
|
+
!same(found.row.browserHash, digest(browser.nonce)))
|
|
393
|
+
return null;
|
|
394
|
+
const { id, status, email, revision, challenge } = found.row;
|
|
395
|
+
return {
|
|
396
|
+
id,
|
|
397
|
+
status,
|
|
398
|
+
email,
|
|
399
|
+
revision,
|
|
400
|
+
code: pairingCode(challenge),
|
|
401
|
+
returnUrl: status === 'approved' ? returnUrl(id) : null,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
},
|
|
408
|
+
/** POST /auth/desktop/approve: the person confirms the account the page showed. */
|
|
409
|
+
approve: (request) => page('approve', async () => {
|
|
410
|
+
sameOrigin(request);
|
|
411
|
+
if (!bodyWithin(request, 4096))
|
|
412
|
+
throw fail(413, 'body_too_large');
|
|
413
|
+
const seen = await formRevision(request);
|
|
414
|
+
return store.transaction(async (tx) => {
|
|
415
|
+
const row = await browserRow(tx, request.headers.get('cookie'));
|
|
416
|
+
if (row.status !== 'completed' || !row.revision || !same(row.revision, seen))
|
|
417
|
+
throw fail(409, 'step_changed');
|
|
418
|
+
await store.patch(tx, row.id, { status: 'approved' });
|
|
419
|
+
return redirect('/auth/desktop/done');
|
|
420
|
+
});
|
|
421
|
+
}),
|
|
422
|
+
/** POST /auth/desktop/cancel: ends the sign-in and deletes its row. */
|
|
423
|
+
cancel: (request) => page('cancel', async () => {
|
|
424
|
+
sameOrigin(request);
|
|
425
|
+
if (!bodyWithin(request, 4096))
|
|
426
|
+
throw fail(413, 'body_too_large');
|
|
427
|
+
return store.transaction(async (tx) => {
|
|
428
|
+
const row = await browserRow(tx, request.headers.get('cookie'));
|
|
429
|
+
await store.remove(tx, row.id);
|
|
430
|
+
return redirect('/auth/desktop/error?reason=cancelled', [browserCookie('')]);
|
|
431
|
+
});
|
|
432
|
+
}),
|
|
433
|
+
/** POST /auth/desktop/redeem, with the body still unread: refuses one that is not small and declared. */
|
|
434
|
+
async redeemRequest(request) {
|
|
435
|
+
const json = request.headers.get('content-type')?.split(';')[0]?.trim() === 'application/json';
|
|
436
|
+
if (!bodyWithin(request, 1024))
|
|
437
|
+
return Response.json({ error: 'body_too_large' }, { status: 413, headers: NO_STORE });
|
|
438
|
+
let input = null;
|
|
439
|
+
try {
|
|
440
|
+
if (json)
|
|
441
|
+
input = JSON.parse(await request.text());
|
|
442
|
+
}
|
|
443
|
+
catch {
|
|
444
|
+
// Not JSON: `input` stays null, and `redeem` refuses it as `invalid_request`.
|
|
445
|
+
input = null;
|
|
446
|
+
}
|
|
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
|
+
}
|
|
526
|
+
},
|
|
527
|
+
};
|
|
528
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,7 +2,8 @@ export { type Auth, type AuthRequest, type ChangePasswordError, type ChangePassw
|
|
|
2
2
|
export type { AccessTokenReason, AccessTokenResult, AuthEvent, AuthOptions } from './config.js';
|
|
3
3
|
export type { SetCookie } from './cookies.js';
|
|
4
4
|
export type { SessionRecord, SessionStore, SessionUpdate } from './sessions.js';
|
|
5
|
-
export type
|
|
5
|
+
export { DesktopHandoffError, type DesktopHandoffAdmit, type DesktopHandoffAuth, type DesktopHandoffConfig, type DesktopHandoffCurrent, type DesktopHandoffFound, type DesktopHandoffRow, type DesktopHandoffStatus, type DesktopHandoffStore, } from './desktop.js';
|
|
6
|
+
export type { PersonIdentity, PersonTokenConfig, PersonTokenOutcome, PersonTokenRefusal, PersonTokenVerifier, } from './person-token.js';
|
|
6
7
|
export { BrokerError, type PeoplePage, type Person } from './broker.js';
|
|
7
8
|
export { AuthError, type AuthErrorReason, ORG_CLAIM } from './oidc.js';
|
|
8
9
|
export { safeLoginHint, safeNextPath } from './redirect.js';
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { STEP_UP_MAX_AGE, createAuth, } from './auth.js';
|
|
2
|
+
export { DesktopHandoffError, } from './desktop.js';
|
|
2
3
|
export { BrokerError } from './broker.js';
|
|
3
4
|
export { AuthError, ORG_CLAIM } from './oidc.js';
|
|
4
5
|
export { safeLoginHint, safeNextPath } from './redirect.js';
|
package/dist/mcp.d.ts
CHANGED
|
@@ -10,7 +10,10 @@ export interface McpOptions {
|
|
|
10
10
|
audience: string;
|
|
11
11
|
/** The MCP URL clients send as `resource` (env `MCP_RESOURCE`). */
|
|
12
12
|
resource: string;
|
|
13
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* Test seam. Pass a stateless function: the first `fetch` seen for an issuer and audience
|
|
15
|
+
* is the one every later call uses, whatever the options of that call say.
|
|
16
|
+
*/
|
|
14
17
|
fetch?: typeof fetch;
|
|
15
18
|
}
|
|
16
19
|
/** What `verifyMcpToken` needs besides the issuer, audience and resource. */
|
|
@@ -60,15 +63,23 @@ export declare function resetMcpCaches(): void;
|
|
|
60
63
|
* signature, issuer, audience and expiry locally, then asks the broker whether
|
|
61
64
|
* the grant behind the token is still live (so Disconnect works within
|
|
62
65
|
* `cacheMs`, not at the token's expiry), then that the grant belongs to this
|
|
63
|
-
* app's organisation and carries `mcp:read`. When the broker cannot be asked
|
|
66
|
+
* app's organisation, was issued for `opts.resource`, and carries `mcp:read`. When the broker cannot be asked
|
|
64
67
|
* the answer is `unavailable`, never an identity. Never throws.
|
|
65
68
|
*/
|
|
66
69
|
export declare function verifyMcpToken(req: Request, opts: McpVerifyOptions): Promise<McpVerification>;
|
|
70
|
+
/**
|
|
71
|
+
* Where an app serves the protected-resource metadata for this resource: the
|
|
72
|
+
* path-suffixed well-known path of RFC 9728 section 3.1, the resource's own
|
|
73
|
+
* path appended as it is. `https://host/a/mcp` gives
|
|
74
|
+
* `/.well-known/oauth-protected-resource/a/mcp`, a root resource gives
|
|
75
|
+
* `/.well-known/oauth-protected-resource`. Exact: `/a/mcp` and `/a/mcp2` get
|
|
76
|
+
* different paths, and a trailing slash is part of the path.
|
|
77
|
+
*/
|
|
78
|
+
export declare function protectedResourceMetadataPath(opts: McpOptions): string;
|
|
67
79
|
/** The 401 or 403 the app returns when `verifyMcpToken` gives null (401) or
|
|
68
80
|
* the user may not do the thing (403). */
|
|
69
81
|
export declare function mcpChallenge(opts: McpOptions, status: 401 | 403): Response;
|
|
70
|
-
/** The document the app serves at
|
|
71
|
-
* the path form). */
|
|
82
|
+
/** The document the app serves at `protectedResourceMetadataPath(opts)`. */
|
|
72
83
|
export declare function protectedResourceMetadata(opts: McpOptions): {
|
|
73
84
|
resource: string;
|
|
74
85
|
authorization_servers: string[];
|
package/dist/mcp.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createPersonTokenCore } from './person-token.js';
|
|
2
|
+
import { ORG_CLAIM } from './util.js';
|
|
2
3
|
/** One person-token core per (issuer, audience), so discovery and the JWKS are
|
|
3
4
|
* fetched once and cached, not once per request. The fetch function is not
|
|
4
5
|
* part of the key: an app that builds its options (and a timeout fetch) per
|
|
@@ -27,7 +28,6 @@ const MAX_LIVE = 1000;
|
|
|
27
28
|
const CACHE_MS_MAX = 60_000;
|
|
28
29
|
const TOKEN_INFO_TIMEOUT_MS = 5_000;
|
|
29
30
|
const REQUIRED_SCOPE = 'mcp:read';
|
|
30
|
-
const RESOURCE_OWNER = 'urn:zitadel:iam:user:resourceowner:id';
|
|
31
31
|
/** For tests: forgets every cached core and token-info answer. */
|
|
32
32
|
export function resetMcpCaches() {
|
|
33
33
|
cores.clear();
|
|
@@ -82,7 +82,8 @@ async function askBroker(token, opts) {
|
|
|
82
82
|
if (body.active !== true)
|
|
83
83
|
return { kind: 'down' };
|
|
84
84
|
const identity = parseInfo(body);
|
|
85
|
-
|
|
85
|
+
const resource = str(body.resource);
|
|
86
|
+
return identity && resource ? { kind: 'active', identity, resource } : { kind: 'down' };
|
|
86
87
|
}
|
|
87
88
|
catch {
|
|
88
89
|
return { kind: 'down' };
|
|
@@ -93,7 +94,7 @@ async function askBroker(token, opts) {
|
|
|
93
94
|
* signature, issuer, audience and expiry locally, then asks the broker whether
|
|
94
95
|
* the grant behind the token is still live (so Disconnect works within
|
|
95
96
|
* `cacheMs`, not at the token's expiry), then that the grant belongs to this
|
|
96
|
-
* app's organisation and carries `mcp:read`. When the broker cannot be asked
|
|
97
|
+
* app's organisation, was issued for `opts.resource`, and carries `mcp:read`. When the broker cannot be asked
|
|
97
98
|
* the answer is `unavailable`, never an identity. Never throws.
|
|
98
99
|
*/
|
|
99
100
|
export async function verifyMcpToken(req, opts) {
|
|
@@ -108,10 +109,11 @@ export async function verifyMcpToken(req, opts) {
|
|
|
108
109
|
if (typeof payload.sub !== 'string' || !payload.sub)
|
|
109
110
|
return deny('invalid');
|
|
110
111
|
// ZITADEL puts the owner in the token when it was asked to; when it did, it must be ours.
|
|
111
|
-
const owner = payload[
|
|
112
|
+
const owner = payload[ORG_CLAIM];
|
|
112
113
|
if (owner !== undefined && owner !== opts.expectedOrgId)
|
|
113
114
|
return deny('wrong_org');
|
|
114
|
-
|
|
115
|
+
// The resource is part of the key: an answer is only reused for the resource it was checked against.
|
|
116
|
+
const key = `${opts.tokenInfo.url}\n${opts.resource}\n${token}`;
|
|
115
117
|
const now = Date.now();
|
|
116
118
|
let identity = null;
|
|
117
119
|
const cached = live.get(key);
|
|
@@ -125,6 +127,9 @@ export async function verifyMcpToken(req, opts) {
|
|
|
125
127
|
return deny('revoked');
|
|
126
128
|
if (asked.kind === 'down')
|
|
127
129
|
return deny('unavailable');
|
|
130
|
+
// A token minted for another MCP resource is not good here, however live it is.
|
|
131
|
+
if (asked.resource !== opts.resource)
|
|
132
|
+
return deny('invalid');
|
|
128
133
|
identity = asked.identity;
|
|
129
134
|
const cacheMs = Math.min(Math.max(opts.tokenInfo.cacheMs ?? CACHE_MS_MAX, 0), CACHE_MS_MAX);
|
|
130
135
|
if (cacheMs > 0) {
|
|
@@ -143,10 +148,20 @@ export async function verifyMcpToken(req, opts) {
|
|
|
143
148
|
return deny('wrong_scope');
|
|
144
149
|
return { identity, reason: null };
|
|
145
150
|
}
|
|
151
|
+
/**
|
|
152
|
+
* Where an app serves the protected-resource metadata for this resource: the
|
|
153
|
+
* path-suffixed well-known path of RFC 9728 section 3.1, the resource's own
|
|
154
|
+
* path appended as it is. `https://host/a/mcp` gives
|
|
155
|
+
* `/.well-known/oauth-protected-resource/a/mcp`, a root resource gives
|
|
156
|
+
* `/.well-known/oauth-protected-resource`. Exact: `/a/mcp` and `/a/mcp2` get
|
|
157
|
+
* different paths, and a trailing slash is part of the path.
|
|
158
|
+
*/
|
|
159
|
+
export function protectedResourceMetadataPath(opts) {
|
|
160
|
+
const { pathname } = new URL(opts.resource);
|
|
161
|
+
return `/.well-known/oauth-protected-resource${pathname === '/' ? '' : pathname}`;
|
|
162
|
+
}
|
|
146
163
|
function metadataUrl(opts) {
|
|
147
|
-
|
|
148
|
-
const path = url.pathname === '/' ? '' : url.pathname;
|
|
149
|
-
return `${url.origin}/.well-known/oauth-protected-resource${path}`;
|
|
164
|
+
return `${new URL(opts.resource).origin}${protectedResourceMetadataPath(opts)}`;
|
|
150
165
|
}
|
|
151
166
|
/** The 401 or 403 the app returns when `verifyMcpToken` gives null (401) or
|
|
152
167
|
* the user may not do the thing (403). */
|
|
@@ -157,8 +172,7 @@ export function mcpChallenge(opts, status) {
|
|
|
157
172
|
: `Bearer ${resourceMetadata}`;
|
|
158
173
|
return new Response(null, { status, headers: { 'WWW-Authenticate': challenge } });
|
|
159
174
|
}
|
|
160
|
-
/** The document the app serves at
|
|
161
|
-
* the path form). */
|
|
175
|
+
/** The document the app serves at `protectedResourceMetadataPath(opts)`. */
|
|
162
176
|
export function protectedResourceMetadata(opts) {
|
|
163
177
|
return { resource: opts.resource, authorization_servers: [opts.issuer] };
|
|
164
178
|
}
|