a2a-exposed 0.7.8 → 0.7.9
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/package.json
CHANGED
|
@@ -125,46 +125,93 @@ function logUnreachable(host: string, op: IdpOp, status: number, error?: unknown
|
|
|
125
125
|
console.log(JSON.stringify(kw));
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
function startDeadline(): { signal: AbortSignal; stop: () => void } {
|
|
129
|
+
const ac = new AbortController();
|
|
130
|
+
const timer = setTimeout(() => ac.abort(), FETCH_MS);
|
|
131
|
+
return { signal: ac.signal, stop: () => clearTimeout(timer) };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function aborted(signal: AbortSignal): Promise<never> {
|
|
135
|
+
if (signal.aborted) return Promise.reject(new DOMException("The operation was aborted", "AbortError"));
|
|
136
|
+
return new Promise((_, reject) => {
|
|
137
|
+
signal.addEventListener("abort", () => reject(new DOMException("The operation was aborted", "AbortError")), { once: true });
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** An error page is cancelled and not read. Only a successful body is buffered, and only up to `max`. */
|
|
142
|
+
function dropBody(res: Response): void {
|
|
143
|
+
try { void res.body?.cancel().catch(() => {}); } catch { /* already closed */ }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function takeBody(res: Response, signal: AbortSignal, max: number): Promise<Response> {
|
|
147
|
+
if (!res.ok) {
|
|
148
|
+
dropBody(res);
|
|
149
|
+
return new Response(null, { status: res.status, statusText: res.statusText });
|
|
150
|
+
}
|
|
151
|
+
const len = Number(res.headers.get("content-length") || 0);
|
|
152
|
+
if (len > max) {
|
|
153
|
+
dropBody(res);
|
|
154
|
+
throw new Error("large");
|
|
155
|
+
}
|
|
156
|
+
const text = await Promise.race([res.text(), aborted(signal)]);
|
|
157
|
+
if (text.length > max) throw new Error("large");
|
|
158
|
+
const headers = new Headers(res.headers);
|
|
159
|
+
headers.delete("content-length");
|
|
160
|
+
return new Response(text, { status: res.status, statusText: res.statusText, headers });
|
|
161
|
+
}
|
|
162
|
+
|
|
128
163
|
/**
|
|
129
164
|
* Discovery, token and JWKS for an issuer on this tenant's zone go through CONTROL_SERVICE.
|
|
130
165
|
* A public fetch of that zone can miss the control Worker (wildcard route, 522, or the site Worker).
|
|
131
166
|
* Any other issuer, and a deploy with the binding unset, uses fetch.
|
|
167
|
+
* The binding request sets Host to the issuer host. Without that header the control Worker on the
|
|
168
|
+
* zone apex sees no Host, and the subrequest does not finish. Every call, binding or fetch, stops
|
|
169
|
+
* after FETCH_MS and the page says the provider could not be reached.
|
|
132
170
|
*/
|
|
133
171
|
async function idpFetch(ctx: TenantContext, url: string, init: RequestInit, op: IdpOp): Promise<Response> {
|
|
134
172
|
let host = "";
|
|
135
173
|
try { host = new URL(url).hostname; } catch { /* logged as a failed fetch */ }
|
|
136
174
|
const binding = ctx.controlService();
|
|
137
175
|
const viaBinding = !!(binding && host && reachesControl(pageHostname(ctx), host, ctx.gates.tenantDomain));
|
|
176
|
+
const { signal, stop } = startDeadline();
|
|
138
177
|
try {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
178
|
+
const res = await Promise.race([
|
|
179
|
+
viaBinding ? bindingFetch(binding!, url, init, signal) : publicFetch(url, init, signal),
|
|
180
|
+
aborted(signal),
|
|
181
|
+
]);
|
|
182
|
+
const full = await Promise.race([takeBody(res, signal, 65536), aborted(signal)]);
|
|
183
|
+
if (!full.ok) logUnreachable(host, op, full.status);
|
|
184
|
+
return full;
|
|
144
185
|
} catch (error) {
|
|
145
186
|
logUnreachable(host, op, 0, error);
|
|
146
187
|
throw new Error("http");
|
|
188
|
+
} finally {
|
|
189
|
+
stop();
|
|
147
190
|
}
|
|
148
191
|
}
|
|
149
192
|
|
|
150
|
-
|
|
193
|
+
function publicFetch(url: string, init: RequestInit, signal: AbortSignal): Promise<Response> {
|
|
194
|
+
return fetch(url, { method: init.method, headers: init.headers, body: init.body, redirect: "error", signal });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function bindingFetch(binding: NonNullable<ReturnType<TenantContext["controlService"]>>, url: string, init: RequestInit, signal: AbortSignal): Promise<Response> {
|
|
151
198
|
const headers = new Headers(init.headers);
|
|
199
|
+
// `new Request(url)` does not keep Host across a service binding. The apex issuer is then
|
|
200
|
+
// a request with no Host, which does not match the control Worker's route or ISSUER, and the
|
|
201
|
+
// subrequest never completes. Set Host to the URL host. `redirect` and `signal` stay off the
|
|
202
|
+
// Request (they do not cross the binding). `signal` is passed to fetch so the runtime cancels it.
|
|
203
|
+
headers.set("host", new URL(url).host);
|
|
152
204
|
const req = new Request(url, { method: init.method || "GET", headers, body: init.body });
|
|
153
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
154
205
|
try {
|
|
155
|
-
return await
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}),
|
|
160
|
-
]);
|
|
161
|
-
} finally {
|
|
162
|
-
if (timer !== undefined) clearTimeout(timer);
|
|
206
|
+
return await binding.fetch(req, { signal });
|
|
207
|
+
} catch (error) {
|
|
208
|
+
if (error instanceof TypeError && !signal.aborted) return binding.fetch(req);
|
|
209
|
+
throw error;
|
|
163
210
|
}
|
|
164
211
|
}
|
|
165
212
|
|
|
166
213
|
async function getJson(ctx: TenantContext, url: string, op: IdpOp): Promise<any> {
|
|
167
|
-
const res = await idpFetch(ctx, url, { headers: { accept: "application/json" }
|
|
214
|
+
const res = await idpFetch(ctx, url, { headers: { accept: "application/json" } }, op);
|
|
168
215
|
if (!res.ok) throw new Error("http");
|
|
169
216
|
return readJson(res, 65536);
|
|
170
217
|
}
|
|
@@ -307,7 +354,7 @@ export async function finishOidc(ctx: TenantContext, o: {
|
|
|
307
354
|
});
|
|
308
355
|
const res = await idpFetch(ctx, disc.token_endpoint, {
|
|
309
356
|
method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
310
|
-
body,
|
|
357
|
+
body,
|
|
311
358
|
}, "token");
|
|
312
359
|
if (!res.ok) return { ok: false, error: UNREACHABLE, reason: "token", userCode };
|
|
313
360
|
const tok = await readJson(res, 65536);
|
package/worker/src/pairing.ts
CHANGED
|
@@ -194,7 +194,7 @@ export function devicePage(m: PageModel, nonce: string): string {
|
|
|
194
194
|
const rows = r ? [
|
|
195
195
|
["Code", formatUserCode(r.userCode)],
|
|
196
196
|
["Agent (as it calls itself)", r.clientName || r.clientId || "(no name given)"],
|
|
197
|
-
...(r.clientId && r.clientName && r.clientId !== r.clientName ? [["
|
|
197
|
+
...(r.clientId && r.clientName && r.clientId !== r.clientName ? [["Alias (claimed)", r.clientId]] : []),
|
|
198
198
|
["Agent card (claimed)", r.agentCardUrl ? r.agentCardUrl + (cardIsPrivate(r.agentCardUrl) ? " (private network address, e.g. a Tailnet: not publicly reachable, so this inbox can't check it; informational only)" : "") : "(none given)"],
|
|
199
199
|
["Requested from", [r.ip || "unknown address", r.country].filter(Boolean).join(", ")],
|
|
200
200
|
["Expires", `${Math.max(0, Math.round((r.expiresMs - Date.now()) / 60000))} min`],
|
|
@@ -202,7 +202,7 @@ export function devicePage(m: PageModel, nonce: string): string {
|
|
|
202
202
|
] : [];
|
|
203
203
|
const notice = m.notice ? `<p class="n ${m.notice.kind}">${esc(m.notice.text)}</p>` : "";
|
|
204
204
|
const details = r ? `<table>${rows.map(([k, v]) => `<tr><th>${esc(k)}</th><td>${esc(v)}</td></tr>`).join("")}</table>
|
|
205
|
-
<p class="w">Approve only if you expected this request and the code matches the one you were given. The name and card URL are claimed by the requester. Approving gives it a token to send messages to this inbox; revoke it any time with <code>${esc(m.cli)} token revoke <label></code>.</p>` : "";
|
|
205
|
+
<p class="w">Approve only if you expected this request and the code matches the one you were given. The name, alias and card URL are claimed by the requester. Approving gives it a token to send messages to this inbox; revoke it any time with <code>${esc(m.cli)} token revoke <label></code>.</p>` : "";
|
|
206
206
|
const noPassword = `<p class="n info">No approval password is set yet. Easiest: ask your agent to run <code>${esc(m.cli)} pair set-password --web</code> and send you the one-time link, then set the password on that page. Or run <code>${esc(m.cli)} pair set-password</code> yourself in a terminal on the owner's machine. Then reload this page. You can deny without a password.</p>`;
|
|
207
207
|
const denyOnly = `<form method="post" action="/device">
|
|
208
208
|
<input type="hidden" name="csrf" value="${esc(m.csrf)}"><input type="hidden" name="user_code" value="${esc(r ? formatUserCode(r.userCode) : "")}">
|
|
@@ -447,3 +447,67 @@ test("a failed same-zone fetch logs idp_unreachable with status and host", async
|
|
|
447
447
|
assert.equal(JSON.stringify(last).includes("connect failed"), false);
|
|
448
448
|
assert.equal(JSON.stringify(last).includes(SECRET), false);
|
|
449
449
|
});
|
|
450
|
+
|
|
451
|
+
test("an identity-provider error page is cancelled and not buffered", async (t) => {
|
|
452
|
+
const s = setup({ ...oidcEnv, APPROVAL_OIDC_ISSUER: ZONE }); t.after(s.restore);
|
|
453
|
+
let pulls = 0;
|
|
454
|
+
let cancelled = false;
|
|
455
|
+
s.env.CONTROL_SERVICE = {
|
|
456
|
+
async fetch() {
|
|
457
|
+
return new Response(new ReadableStream({
|
|
458
|
+
pull(controller) {
|
|
459
|
+
pulls++;
|
|
460
|
+
controller.enqueue(new Uint8Array(64 * 1024));
|
|
461
|
+
},
|
|
462
|
+
cancel() { cancelled = true; },
|
|
463
|
+
}), { status: 522 });
|
|
464
|
+
},
|
|
465
|
+
};
|
|
466
|
+
globalThis.fetch = (async () => { throw new Error("public fetch must not run"); }) as typeof fetch;
|
|
467
|
+
const d = await s.start();
|
|
468
|
+
const started = Date.now();
|
|
469
|
+
const { post } = await s.postOidc(d.data.user_code);
|
|
470
|
+
assert.ok(Date.now() - started < 1000, "error headers should return without reading the body");
|
|
471
|
+
assert.equal(post.status, 502);
|
|
472
|
+
assert.match(post.text, /The identity provider could not be reached/);
|
|
473
|
+
assert.equal(cancelled, true);
|
|
474
|
+
assert.ok(pulls < 3, `buffered ${pulls} chunks`);
|
|
475
|
+
const line = s.logs.map((entry) => { try { return JSON.parse(entry); } catch { return null; } }).find((entry) => entry?.event === "idp_unreachable");
|
|
476
|
+
assert.equal(line?.status, 522);
|
|
477
|
+
assert.equal(line?.op, "discovery");
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test("a same-zone issuer that does not answer is given up, and the binding request carries the issuer host", async (t) => {
|
|
481
|
+
const issuer = "https://example.com";
|
|
482
|
+
const s = setup({
|
|
483
|
+
...oidcEnv, APPROVAL_OIDC_ISSUER: issuer, PUBLIC_URL: "https://northwind.example.com", TENANT_DOMAIN: "example.com",
|
|
484
|
+
});
|
|
485
|
+
t.after(s.restore);
|
|
486
|
+
let host = "";
|
|
487
|
+
s.env.CONTROL_SERVICE = {
|
|
488
|
+
async fetch(input: RequestInfo | URL) {
|
|
489
|
+
const req = input instanceof Request ? input : new Request(input);
|
|
490
|
+
host = req.headers.get("host") || "";
|
|
491
|
+
assert.equal(new URL(req.url).origin, issuer);
|
|
492
|
+
await new Promise((resolve) => setTimeout(resolve, 8000));
|
|
493
|
+
return Response.json({
|
|
494
|
+
issuer, authorization_endpoint: `${issuer}/authorize`, token_endpoint: `${issuer}/token`, jwks_uri: `${issuer}/jwks`,
|
|
495
|
+
});
|
|
496
|
+
},
|
|
497
|
+
};
|
|
498
|
+
globalThis.fetch = (async () => { throw new Error("public fetch must not run"); }) as typeof fetch;
|
|
499
|
+
const d = await s.start();
|
|
500
|
+
const started = Date.now();
|
|
501
|
+
const { post } = await s.postOidc(d.data.user_code);
|
|
502
|
+
const waited = Date.now() - started;
|
|
503
|
+
assert.equal(host, "example.com");
|
|
504
|
+
assert.ok(waited < 7000, `waited ${waited}ms`);
|
|
505
|
+
assert.equal(post.status, 502, post.text);
|
|
506
|
+
assert.match(post.text, /The identity provider could not be reached/);
|
|
507
|
+
const line = s.logs.map((entry) => { try { return JSON.parse(entry); } catch { return null; } }).find((entry) => entry?.event === "idp_unreachable");
|
|
508
|
+
assert.equal(line?.op, "discovery");
|
|
509
|
+
assert.equal(line?.status, 0);
|
|
510
|
+
assert.equal(line?.host, "example.com");
|
|
511
|
+
assert.equal(line?.error, "AbortError");
|
|
512
|
+
assert.equal(JSON.stringify(line).includes("The operation was aborted"), false);
|
|
513
|
+
});
|
|
@@ -343,3 +343,141 @@ test("hosted approval reaches a same-zone issuer through CONTROL_SERVICE", async
|
|
|
343
343
|
assert.equal(missedPost.status, 502);
|
|
344
344
|
assert.match(await missedPost.text(), /The identity provider could not be reached/);
|
|
345
345
|
});
|
|
346
|
+
|
|
347
|
+
test("an apex issuer is fetched with that Host, and a control worker that does not answer still returns", async (t) => {
|
|
348
|
+
const issuer = "https://example.com";
|
|
349
|
+
const tenant = "https://northwind.example.com";
|
|
350
|
+
const control = `
|
|
351
|
+
export default {
|
|
352
|
+
fetch(request) {
|
|
353
|
+
const url = new URL(request.url);
|
|
354
|
+
const host = request.headers.get("host");
|
|
355
|
+
if (url.origin !== ${JSON.stringify(issuer)} || host !== "example.com") return new Promise(() => {});
|
|
356
|
+
if (url.pathname !== "/.well-known/openid-configuration") return new Response("missing", { status: 404 });
|
|
357
|
+
return Response.json({
|
|
358
|
+
issuer: ${JSON.stringify(issuer)},
|
|
359
|
+
authorization_endpoint: ${JSON.stringify(issuer + "/api/oidc/authorize")},
|
|
360
|
+
token_endpoint: ${JSON.stringify(issuer + "/api/oidc/token")},
|
|
361
|
+
jwks_uri: ${JSON.stringify(issuer + "/api/auth/jwks")},
|
|
362
|
+
});
|
|
363
|
+
},
|
|
364
|
+
}
|
|
365
|
+
`;
|
|
366
|
+
const mf = new Miniflare(convertV4MiniflareOptions({
|
|
367
|
+
workers: [
|
|
368
|
+
{
|
|
369
|
+
name: "inbox",
|
|
370
|
+
modules: true,
|
|
371
|
+
script: await SCRIPT,
|
|
372
|
+
compatibilityDate: "2026-10-06",
|
|
373
|
+
routes: ["*://*.example.com/*"],
|
|
374
|
+
d1Databases: { DB: "inbox-apex" },
|
|
375
|
+
kvNamespaces: { TENANT_DIRECTORY: "tenants-apex" },
|
|
376
|
+
durableObjects: { TENANT_DO: { className: "TenantStore", useSQLite: true } },
|
|
377
|
+
bindings: { TENANCY: "host", TENANT_DOMAIN: "example.com", TENANT_SECRETS_KEY: "platform-secret" },
|
|
378
|
+
serviceBindings: { CONTROL_SERVICE: "control" },
|
|
379
|
+
},
|
|
380
|
+
{
|
|
381
|
+
name: "control",
|
|
382
|
+
modules: true,
|
|
383
|
+
script: control,
|
|
384
|
+
compatibilityDate: "2026-10-06",
|
|
385
|
+
routes: ["*://example.com/*"],
|
|
386
|
+
},
|
|
387
|
+
],
|
|
388
|
+
} as never));
|
|
389
|
+
t.after(() => mf.dispose());
|
|
390
|
+
await mf.ready;
|
|
391
|
+
const kv = await mf.getKVNamespace("TENANT_DIRECTORY", "inbox");
|
|
392
|
+
const ns = await mf.getDurableObjectNamespace("TENANT_DO", "inbox");
|
|
393
|
+
const hash = await sha256("owner-apex");
|
|
394
|
+
const stub = ns.get(ns.idFromName("id-apex")) as unknown as { pushConfig(body: Record<string, unknown>): Promise<{ applied: boolean }> };
|
|
395
|
+
assert.equal((await stub.pushConfig({
|
|
396
|
+
version: 1, tenantId: "id-apex", name: "northwind", status: "active", ownerTokenHash: hash,
|
|
397
|
+
approval: { issuer, clientId: "client-1", clientSecret: "tenant-secret-value", allowedSubjects: ["owner-1"], label: "Example" },
|
|
398
|
+
})).applied, true);
|
|
399
|
+
await kv.put("tenant:northwind", JSON.stringify({ id: "id-apex", status: "active", region: "default", version: 1 }));
|
|
400
|
+
const started = await mf.dispatchFetch(`${tenant}/oauth/device_authorization`, {
|
|
401
|
+
method: "POST",
|
|
402
|
+
headers: { "content-type": "application/x-www-form-urlencoded", "cf-connecting-ip": "198.51.100.20" },
|
|
403
|
+
body: "client_name=Barry+Bot&client_id=barry",
|
|
404
|
+
});
|
|
405
|
+
const device = await started.json() as { user_code?: string };
|
|
406
|
+
assert.equal(started.status, 200, JSON.stringify(device));
|
|
407
|
+
const page = await mf.dispatchFetch(`${tenant}/device?user_code=${device.user_code}`, {
|
|
408
|
+
headers: { "cf-connecting-ip": "198.51.100.20" },
|
|
409
|
+
});
|
|
410
|
+
const html = await page.text();
|
|
411
|
+
assert.match(html, /Alias \(claimed\)/);
|
|
412
|
+
assert.match(html, />barry</);
|
|
413
|
+
assert.equal(html.includes("Client id"), false);
|
|
414
|
+
const csrf = /name="csrf" value="([^"]+)"/.exec(html)?.[1] ?? "";
|
|
415
|
+
const cookie = (page.headers.get("set-cookie") || "").split(";")[0];
|
|
416
|
+
const t0 = Date.now();
|
|
417
|
+
const post = await mf.dispatchFetch(`${tenant}/device`, {
|
|
418
|
+
method: "POST",
|
|
419
|
+
redirect: "manual",
|
|
420
|
+
headers: {
|
|
421
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
422
|
+
origin: tenant,
|
|
423
|
+
cookie,
|
|
424
|
+
"cf-connecting-ip": "198.51.100.20",
|
|
425
|
+
},
|
|
426
|
+
body: new URLSearchParams({ csrf, user_code: device.user_code || "", decision: "oidc" }).toString(),
|
|
427
|
+
});
|
|
428
|
+
assert.ok(Date.now() - t0 < 3000, "a matching Host must not wait out the deadline");
|
|
429
|
+
assert.equal(post.status, 302, await post.text());
|
|
430
|
+
assert.equal(new URL(post.headers.get("location") || "").origin, issuer);
|
|
431
|
+
|
|
432
|
+
const stalled = await start({
|
|
433
|
+
bindings: { TENANCY: "host", TENANT_DOMAIN: "example.com", TENANT_SECRETS_KEY: "platform-secret" },
|
|
434
|
+
kvNamespaces: { TENANT_DIRECTORY: "tenants-apex-stall" },
|
|
435
|
+
durableObjects: { TENANT_DO: { className: "TenantStore", useSQLite: true } },
|
|
436
|
+
serviceBindings: {
|
|
437
|
+
CONTROL_SERVICE: async () => {
|
|
438
|
+
await new Promise((resolve) => setTimeout(resolve, 8000));
|
|
439
|
+
return Response.json({ issuer });
|
|
440
|
+
},
|
|
441
|
+
},
|
|
442
|
+
});
|
|
443
|
+
t.after(() => stalled.dispose());
|
|
444
|
+
await stalled.ready;
|
|
445
|
+
const stalledKv = await stalled.getKVNamespace("TENANT_DIRECTORY");
|
|
446
|
+
const stalledNs = await stalled.getDurableObjectNamespace("TENANT_DO");
|
|
447
|
+
const stalledStub = stalledNs.get(stalledNs.idFromName("id-apex")) as unknown as { pushConfig(body: Record<string, unknown>): Promise<{ applied: boolean }> };
|
|
448
|
+
await stalledStub.pushConfig({
|
|
449
|
+
version: 1, tenantId: "id-apex", name: "northwind", status: "active", ownerTokenHash: hash,
|
|
450
|
+
approval: { issuer, clientId: "client-1", clientSecret: "tenant-secret-value", allowedSubjects: ["owner-1"], label: "Example" },
|
|
451
|
+
});
|
|
452
|
+
await stalledKv.put("tenant:northwind", JSON.stringify({ id: "id-apex", status: "active", region: "default", version: 1 }));
|
|
453
|
+
const stalledStart = await stalled.dispatchFetch(`${tenant}/oauth/device_authorization`, {
|
|
454
|
+
method: "POST",
|
|
455
|
+
headers: { "content-type": "application/x-www-form-urlencoded", "cf-connecting-ip": "198.51.100.21" },
|
|
456
|
+
body: "client_name=Barry+Bot&client_id=barry",
|
|
457
|
+
});
|
|
458
|
+
const stalledDevice = await stalledStart.json() as { user_code: string };
|
|
459
|
+
const stalledPage = await stalled.dispatchFetch(`${tenant}/device?user_code=${stalledDevice.user_code}`, {
|
|
460
|
+
headers: { "cf-connecting-ip": "198.51.100.21" },
|
|
461
|
+
});
|
|
462
|
+
const stalledHtml = await stalledPage.text();
|
|
463
|
+
const stalledCsrf = /name="csrf" value="([^"]+)"/.exec(stalledHtml)?.[1] ?? "";
|
|
464
|
+
const stalledCookie = (stalledPage.headers.get("set-cookie") || "").split(";")[0];
|
|
465
|
+
const stallBegan = Date.now();
|
|
466
|
+
const stalledPost = await Promise.race([
|
|
467
|
+
stalled.dispatchFetch(`${tenant}/device`, {
|
|
468
|
+
method: "POST",
|
|
469
|
+
redirect: "manual",
|
|
470
|
+
headers: {
|
|
471
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
472
|
+
origin: tenant,
|
|
473
|
+
cookie: stalledCookie,
|
|
474
|
+
"cf-connecting-ip": "198.51.100.21",
|
|
475
|
+
},
|
|
476
|
+
body: new URLSearchParams({ csrf: stalledCsrf, user_code: stalledDevice.user_code, decision: "oidc" }).toString(),
|
|
477
|
+
}),
|
|
478
|
+
new Promise<Response>((_, reject) => setTimeout(() => reject(new Error("device post hung")), 7000)),
|
|
479
|
+
]);
|
|
480
|
+
assert.ok(Date.now() - stallBegan < 7000);
|
|
481
|
+
assert.equal(stalledPost.status, 502);
|
|
482
|
+
assert.match(await stalledPost.text(), /The identity provider could not be reached/);
|
|
483
|
+
});
|