@qauth-labs/plugin-sdk 0.0.0-stage → 0.2.0-rc.1

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.
@@ -0,0 +1,526 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { toJSONSchema } from 'zod';
3
+ import { createAuthMethodContext } from '../lib/context.js';
4
+ import { completeUpstreamStep, presentedUpstreamBinding, startUpstreamStep, upstreamBindingMatches, } from '../lib/engine.js';
5
+ import { AuthMethodContractError } from '../lib/errors.js';
6
+ import { authMethodParameters } from '../lib/parameters.js';
7
+ import { authMethodProblems } from '../lib/validate.js';
8
+ import { disableFetch } from './fetch-guard.js';
9
+ /** The kit's realm. */
10
+ const KIT_ORIGIN = 'https://realm.kit.invalid';
11
+ /** Inputs no method's `verify()` may accept: none is a result of its callback. */
12
+ const JUNK_RESULTS = [
13
+ undefined,
14
+ null,
15
+ '',
16
+ 'kit-forged',
17
+ {},
18
+ [],
19
+ { externalSub: 'kit-forged', assuranceLevel: 'high', rawClaims: {} },
20
+ ];
21
+ /** Ends a run that cannot go on; the problems so far are reported. */
22
+ class KitStop extends Error {
23
+ }
24
+ /**
25
+ * Check `method` against the AuthMethod contract.
26
+ *
27
+ * Static checks first: the contract version, `type`, `id`, the display data,
28
+ * the config schema (a Zod object schema that renders as JSON Schema and
29
+ * accepts `scenario.config`), the steps and the routes (each inside the
30
+ * method's prefix). Then a run against a fake realm, with the global `fetch`
31
+ * disabled:
32
+ *
33
+ * 1. `start()` answers an `https` redirect with no credentials and no fragment,
34
+ * a transaction of JSON data within its cap and a binding of 22 to 512
35
+ * base64url characters; two starts answer different transactions.
36
+ * 2. Two starts answer different bindings, and each sends its binding to the
37
+ * provider, in the redirect or in a body it posted.
38
+ * 3. The provider's callback carries the binding back in the callback
39
+ * route's `bindingParameter` (`state` by default): the kit, like the host,
40
+ * hands the handler the transaction only when it does. It ends in an
41
+ * identity whose `rawClaims.iss` names its issuer and whose attributes
42
+ * carry the method's `type`; a second sign-in of the same person resolves
43
+ * the same `externalSub`.
44
+ * 4. When the authorization request asks for a code (`response_type=code`,
45
+ * in the redirect or a body the method posted), it carries
46
+ * `code_challenge_method=S256` and a `code_challenge` (RFC 7636; QAuth
47
+ * requires PKCE with S256), and every token request the callback makes
48
+ * (`grant_type=authorization_code`) carries the `code_verifier` whose S256
49
+ * is that challenge.
50
+ * 5. `verify()` rejects inputs that no callback produced, a well-formed
51
+ * identity, a copy of a real result and a result already used among them.
52
+ * 6. When the scenario gives one, the declined callback ends as `declined`.
53
+ * 7. The method called the global `fetch` nowhere, and no log line carries
54
+ * the value of a config member marked `writeOnly` (a secret), in a field,
55
+ * a message or an error.
56
+ *
57
+ * @throws AuthMethodContractError listing every failed check
58
+ * @throws Error when `NODE_ENV` is `production`
59
+ * @experimental
60
+ */
61
+ export async function checkAuthMethod(method, scenario) {
62
+ if (process.env['NODE_ENV'] === 'production') {
63
+ throw new Error('checkAuthMethod is a test kit: it does not run with NODE_ENV=production');
64
+ }
65
+ const run = {
66
+ problems: [],
67
+ checks: [],
68
+ logLines: [],
69
+ egressBodies: [],
70
+ egressCalls: 0,
71
+ fail(problem) {
72
+ this.problems.push(problem);
73
+ },
74
+ stop(problem) {
75
+ this.problems.push(problem);
76
+ throw new KitStop(problem);
77
+ },
78
+ };
79
+ const fetchGuard = disableFetch();
80
+ let secrets = [];
81
+ try {
82
+ await fetchGuard.run(async () => {
83
+ const staticProblems = authMethodProblems(method);
84
+ if (staticProblems.length > 0) {
85
+ run.problems.push(...staticProblems);
86
+ throw new KitStop('static');
87
+ }
88
+ run.checks.push('static');
89
+ secrets = configSecrets(method, scenario.config, run);
90
+ run.checks.push('config-schema');
91
+ const context = kitContext(method, scenario, run);
92
+ run.checks.push('context');
93
+ await runChecks(method, scenario, context, run);
94
+ });
95
+ }
96
+ catch (error) {
97
+ if (!(error instanceof KitStop))
98
+ run.problems.push(`the run threw: ${describe(error)}`);
99
+ }
100
+ finally {
101
+ fetchGuard.restore();
102
+ }
103
+ if (fetchGuard.calls() > 0) {
104
+ run.problems.push('the method called fetch: its outbound calls go through context.egress');
105
+ }
106
+ const forms = secretForms(secrets, configStrings(scenario.config));
107
+ if (run.logLines.some((line) => forms.some((form) => line.includes(form)))) {
108
+ run.problems.push('a log line carries the value of a writeOnly config member');
109
+ }
110
+ if (run.problems.length > 0) {
111
+ throw new AuthMethodContractError(`The AuthMethod ${String(method?.type)}`, run.problems);
112
+ }
113
+ run.checks.push('egress-only', 'secrets-not-logged');
114
+ return Object.freeze({ checks: Object.freeze([...run.checks]) });
115
+ }
116
+ async function runChecks(method, scenario, context, run) {
117
+ const first = await started(method, context, run);
118
+ const second = await started(method, context, run);
119
+ if (JSON.stringify(first.start.transaction) === JSON.stringify(second.start.transaction)) {
120
+ run.fail('two starts answered the same transaction: its state must be fresh for each start');
121
+ }
122
+ run.checks.push('start');
123
+ bindingChecks(first, second, run);
124
+ run.checks.push('transaction-binding');
125
+ // The provider signs the person in, for the second start.
126
+ const signIn = await scenario.callback(new URL(second.start.redirectTo));
127
+ const bodiesBefore = run.egressBodies.length;
128
+ const happy = await callback(method, context, signIn, second.start, run);
129
+ pkceChecks(second, run.egressBodies.slice(bodiesBefore), run);
130
+ if (happy === HOST_REFUSED) {
131
+ run.stop("the provider's callback does not carry back the binding start() answered, in the callback route's bindingParameter (state by default): the host answers it as transaction_mismatch before the method runs");
132
+ }
133
+ const completion = await completeUpstreamStep(method, happy).catch((error) => run.stop(`the callback's outcome was refused: ${describe(error)}`));
134
+ if (completion.kind !== 'identity') {
135
+ run.stop(`the provider's callback ended as ${describeOutcome(completion)}, not in an identity`);
136
+ }
137
+ const issuer = completion.identity.rawClaims['iss'];
138
+ if (typeof issuer !== 'string' || issuer.length === 0) {
139
+ run.fail('the identity names no issuer (rawClaims.iss): the sign-in flow binds a subject to its issuer, and refuses an identity without one');
140
+ }
141
+ const result = happy.result;
142
+ // The same person, a second time: the same subject.
143
+ const third = await started(method, context, run);
144
+ const again = await callback(method, context, await scenario.callback(new URL(third.start.redirectTo)), third.start, run);
145
+ const repeated = await completeUpstreamStep(method, again).catch(() => undefined);
146
+ if (repeated?.kind !== 'identity' ||
147
+ repeated.identity.externalSub !== completion.identity.externalSub) {
148
+ run.fail('a second sign-in of the same person did not resolve the same externalSub');
149
+ }
150
+ run.checks.push('callback', 'pkce');
151
+ await verifyChecks(method, result, run);
152
+ run.checks.push('verify-fails-closed');
153
+ if (scenario.declined !== undefined) {
154
+ const fourth = await started(method, context, run);
155
+ const declinedRequest = await scenario.declined(new URL(fourth.start.redirectTo));
156
+ const declined = await callback(method, context, declinedRequest, fourth.start, run);
157
+ if (declined.kind !== 'declined') {
158
+ run.fail(`the declined callback answered ${describeOutcome(declined)}, not declined`);
159
+ }
160
+ run.checks.push('declined');
161
+ }
162
+ }
163
+ /**
164
+ * The binding `start()` answers is what the host checks a callback against
165
+ * (the engine has already checked it is 22 to 512 base64url characters, the
166
+ * length of 128 random bits; the kit cannot measure randomness). Two starts
167
+ * must answer different bindings, and the start must send its binding to the
168
+ * provider, in the redirect or in a body the method posted (a pushed
169
+ * authorization request), or the provider cannot carry it back.
170
+ */
171
+ function bindingChecks(first, second, run) {
172
+ if (first.start.binding === second.start.binding) {
173
+ run.fail('two starts answered the same binding: it must be fresh for each start');
174
+ }
175
+ if (!second.values.includes(second.start.binding)) {
176
+ run.fail('start() did not send its binding to the provider, in the redirect or in a body it posted: the provider cannot carry it back');
177
+ }
178
+ }
179
+ /** What a PKCE verifier may be (RFC 7636 §4.1): 43 to 128 unreserved characters. */
180
+ const PKCE_VERIFIER = /^[A-Za-z0-9._~-]{43,128}$/;
181
+ /** What an S256 challenge is: the base64url SHA-256 of a verifier, 43 characters. */
182
+ const PKCE_S256_CHALLENGE = /^[A-Za-z0-9_-]{43}$/;
183
+ /**
184
+ * PKCE with S256, as QAuth requires it of every authorization-code request
185
+ * (RFC 7636): an authorization request that asks for a code carries
186
+ * `code_challenge_method=S256` and a challenge, and every token request the
187
+ * callback made carries the verifier whose S256 is that challenge.
188
+ */
189
+ function pkceChecks(start, callbackBodies, run) {
190
+ const codeRequest = start.authorizationRequests.find((parameters) => parameters.get('response_type') === 'code');
191
+ const challenge = codeRequest?.get('code_challenge') ?? undefined;
192
+ if (codeRequest !== undefined &&
193
+ (codeRequest.get('code_challenge_method') !== 'S256' ||
194
+ challenge === undefined ||
195
+ !PKCE_S256_CHALLENGE.test(challenge))) {
196
+ run.fail('the authorization request asks for a code without PKCE: it must carry code_challenge_method=S256 and a code_challenge (RFC 7636)');
197
+ }
198
+ const tokenRequests = callbackBodies
199
+ .map((body) => new URLSearchParams(body))
200
+ .filter((parameters) => parameters.get('grant_type') === 'authorization_code');
201
+ for (const tokenRequest of tokenRequests) {
202
+ const verifier = tokenRequest.get('code_verifier');
203
+ if (verifier === null ||
204
+ !PKCE_VERIFIER.test(verifier) ||
205
+ challenge === undefined ||
206
+ createHash('sha256').update(verifier, 'ascii').digest('base64url') !== challenge) {
207
+ run.fail("the token request carries no code_verifier whose S256 is the start's code_challenge (RFC 7636 §4.5)");
208
+ return;
209
+ }
210
+ }
211
+ }
212
+ /** `verify()` rejects anything that is not a fresh result of its own callback. */
213
+ async function verifyChecks(method, result, run) {
214
+ const accepts = (input) => Promise.resolve()
215
+ .then(() => method.verify(input))
216
+ .then(() => true, () => false);
217
+ for (const junk of JUNK_RESULTS) {
218
+ if (await accepts(junk)) {
219
+ run.fail(`verify() resolved for ${JSON.stringify(junk) ?? 'undefined'}, which no callback produced`);
220
+ }
221
+ }
222
+ const copy = plainCopy(result);
223
+ if (copy !== undefined && (await accepts(copy))) {
224
+ run.fail("verify() resolved for a copy of a callback's result: it must accept only its own results");
225
+ }
226
+ if (await accepts(result)) {
227
+ run.fail('verify() resolved for a result it had already verified: a result signs one person in once');
228
+ }
229
+ }
230
+ /** The values of a body the method posted: a form, or the strings of a JSON document. */
231
+ function bodyValues(body) {
232
+ const values = [...new URLSearchParams(body).values()];
233
+ try {
234
+ values.push(...stringLeaves(JSON.parse(body), 0));
235
+ }
236
+ catch {
237
+ // Not JSON: the form reading above is all there is.
238
+ }
239
+ return values;
240
+ }
241
+ /** A structural copy of `value`, or `undefined` when it cannot be copied. */
242
+ function plainCopy(value) {
243
+ try {
244
+ return structuredClone(value);
245
+ }
246
+ catch {
247
+ try {
248
+ return JSON.parse(JSON.stringify(value));
249
+ }
250
+ catch {
251
+ return undefined;
252
+ }
253
+ }
254
+ }
255
+ async function started(method, context, run) {
256
+ const before = run.egressBodies.length;
257
+ const start = await startUpstreamStep(method, { uiLocales: ['en'] }, context).catch((error) => run.stop(`start() failed: ${describe(error)}`));
258
+ const posted = run.egressBodies.slice(before);
259
+ const values = [
260
+ ...new URL(start.redirectTo).searchParams.values(),
261
+ ...posted.flatMap(bodyValues),
262
+ ];
263
+ const authorizationRequests = [
264
+ new URL(start.redirectTo).searchParams,
265
+ ...posted.map((body) => new URLSearchParams(body)),
266
+ ];
267
+ return { start, values, authorizationRequests };
268
+ }
269
+ /** `work()`, with a synchronous throw read as a rejection, as the host's `await` reads it. */
270
+ function settled(work) {
271
+ return Promise.resolve().then(work);
272
+ }
273
+ /**
274
+ * What the kit's host answers a callback whose binding does not match, as the
275
+ * route mount does: before the method runs.
276
+ */
277
+ const HOST_REFUSED = Object.freeze({ kind: 'failed', reason: 'transaction_mismatch' });
278
+ /**
279
+ * Run one request through the method's routes as the host would, following a
280
+ * bounce once: the callback's binding is compared with the start's before
281
+ * the handler is called, and a mismatch answers {@link HOST_REFUSED}.
282
+ */
283
+ async function callback(method, context, request, start, run) {
284
+ let route = findRoute(method.routes, request.method, request.path);
285
+ if (route === undefined) {
286
+ return run.stop(`the scenario's callback names ${request.method} ${request.path}, which is not a route of the method`);
287
+ }
288
+ // The objects the host hands a handler: frozen, with no prototype, within
289
+ // the caps (`authMethodParameters`); a GET has an empty form.
290
+ let query = authMethodParameters(request.query);
291
+ let form = authMethodParameters(request.method === 'POST' ? request.form : undefined);
292
+ if (route.kind === 'bounce') {
293
+ const bounceRoute = route;
294
+ // A bounce is handed no context, as the host hands it none.
295
+ const bounce = await settled(() => bounceRoute.handle({ method: bounceRoute.method, query, form })).catch((error) => run.stop(`the bounce route threw: ${describe(error)}`));
296
+ const { to, query: carried } = (typeof bounce === 'object' && bounce !== null ? bounce : {});
297
+ const target = method.routes.find((candidate) => candidate.kind === 'callback' && candidate.method === 'GET' && candidate.path === to);
298
+ if (target === undefined) {
299
+ return run.stop(`the bounce route sent the browser to ${String(to)}, which is not a GET callback route of the method`);
300
+ }
301
+ if (typeof carried !== 'object' ||
302
+ carried === null ||
303
+ Array.isArray(carried) ||
304
+ (Object.getPrototypeOf(carried) !== Object.prototype &&
305
+ Object.getPrototypeOf(carried) !== null) ||
306
+ !Object.values(carried).every((value) => typeof value === 'string')) {
307
+ return run.stop('the bounce route carried a query that is not a plain object of strings');
308
+ }
309
+ route = target;
310
+ // As the browser brings it back: through the URL the host redirects to.
311
+ query = authMethodParameters(Object.fromEntries(new URLSearchParams(carried)));
312
+ form = authMethodParameters(undefined);
313
+ }
314
+ if (route.kind !== 'callback') {
315
+ return run.stop(`${route.method} ${route.path} is not a callback route`);
316
+ }
317
+ const callbackRoute = route;
318
+ if (!upstreamBindingMatches(start.binding, presentedUpstreamBinding(callbackRoute, { query, form }))) {
319
+ return HOST_REFUSED;
320
+ }
321
+ const transaction = start.transaction;
322
+ return settled(() => callbackRoute.handle({ method: callbackRoute.method, query, form, transaction }, context)).catch((error) => run.stop(`the callback route threw: ${describe(error)}`));
323
+ }
324
+ function findRoute(routes, method, path) {
325
+ return routes.find((route) => route.method === method && route.path === path);
326
+ }
327
+ /** The method's context in the kit's realm, over a fake egress, signer and logger. */
328
+ function kitContext(method, scenario, run) {
329
+ try {
330
+ return createAuthMethodContext(method, {
331
+ config: scenario.config,
332
+ origin: KIT_ORIGIN,
333
+ realmName: 'kit',
334
+ realmId: '00000000-0000-4000-8000-000000000000',
335
+ providerId: method.type,
336
+ signerCaller: 'api',
337
+ egress: {
338
+ async request(request) {
339
+ run.egressCalls += 1;
340
+ if (request.body !== undefined) {
341
+ run.egressBodies.push(typeof request.body === 'string'
342
+ ? request.body
343
+ : Buffer.from(request.body).toString('utf8'));
344
+ }
345
+ if (!/^[a-z][a-z0-9_.:-]{0,63}$/.test(request.purpose)) {
346
+ throw new Error('egress refused: purpose must be a fixed label');
347
+ }
348
+ if (new URL(request.url).protocol !== 'https:') {
349
+ throw new Error('egress refused: the URL is not https');
350
+ }
351
+ if (scenario.egress === undefined) {
352
+ throw new Error('egress refused: the scenario answers no outbound call');
353
+ }
354
+ // The scenario sees the call as the method sent it: no realm, and
355
+ // `redirect: 'error'` for the zero redirect cap it asked for.
356
+ const { realm: _realm, limits, ...rest } = request;
357
+ return scenario.egress(limits?.maxRedirects === 0 ? { ...rest, redirect: 'error' } : rest);
358
+ },
359
+ },
360
+ signer: {
361
+ async signClientAssertion() {
362
+ return 'eyJhbGciOiJSUzI1NiJ9.eyJraXQiOnRydWV9.a2l0';
363
+ },
364
+ async getUpstreamJwks() {
365
+ return { keys: [] };
366
+ },
367
+ },
368
+ log: kitLogger(run.logLines),
369
+ });
370
+ }
371
+ catch (error) {
372
+ return run.stop(`the scenario's config does not pass configSchema: ${describe(error)}`);
373
+ }
374
+ }
375
+ /** Check the config schema renders as an object schema; collect its writeOnly values. */
376
+ function configSecrets(method, config, run) {
377
+ let schema;
378
+ try {
379
+ schema = toJSONSchema(method.configSchema);
380
+ }
381
+ catch (error) {
382
+ run.fail(`configSchema does not render as JSON Schema: ${describe(error)}`);
383
+ return [];
384
+ }
385
+ if (schema.type !== 'object') {
386
+ run.fail('configSchema must be an object schema');
387
+ }
388
+ return writeOnlyValues(schema, schema, config, 0);
389
+ }
390
+ /**
391
+ * The string values of `config` under the members its schema marks
392
+ * `writeOnly`: every string leaf of such a member, whatever its shape. The
393
+ * walk follows `properties`, `additionalProperties`, `items`, the members of
394
+ * `anyOf`, `oneOf` and `allOf`, and local `$ref`s into `$defs`.
395
+ */
396
+ function writeOnlyValues(root, schema, config, depth) {
397
+ if (typeof schema !== 'object' || schema === null || depth > 32)
398
+ return [];
399
+ const node = schema;
400
+ const reference = node['$ref'];
401
+ if (typeof reference === 'string') {
402
+ return writeOnlyValues(root, resolveLocalRef(root, reference), config, depth + 1);
403
+ }
404
+ if (node['writeOnly'] === true) {
405
+ // Every one, however short: a short secret is still a secret. A scenario
406
+ // gives realistic ones, so a short one does not match by accident.
407
+ return stringLeaves(config, 0);
408
+ }
409
+ const found = [];
410
+ for (const keyword of ['anyOf', 'oneOf', 'allOf']) {
411
+ const members = node[keyword];
412
+ if (Array.isArray(members)) {
413
+ for (const member of members)
414
+ found.push(...writeOnlyValues(root, member, config, depth + 1));
415
+ }
416
+ }
417
+ if (Array.isArray(config)) {
418
+ found.push(...config.flatMap((item) => writeOnlyValues(root, node['items'], item, depth + 1)));
419
+ return found;
420
+ }
421
+ if (typeof config !== 'object' || config === null)
422
+ return found;
423
+ const properties = (typeof node['properties'] === 'object' && node['properties'] !== null ? node['properties'] : {});
424
+ for (const [key, value] of Object.entries(config)) {
425
+ const child = Object.hasOwn(properties, key) ? properties[key] : node['additionalProperties'];
426
+ found.push(...writeOnlyValues(root, child, value, depth + 1));
427
+ }
428
+ return found;
429
+ }
430
+ /** The schema a local `#/...` reference names, or `undefined`. */
431
+ function resolveLocalRef(root, reference) {
432
+ if (!reference.startsWith('#/'))
433
+ return undefined;
434
+ let node = root;
435
+ for (const segment of reference.slice(2).split('/')) {
436
+ if (typeof node !== 'object' || node === null)
437
+ return undefined;
438
+ node = node[segment.replace(/~1/g, '/').replace(/~0/g, '~')];
439
+ }
440
+ return node;
441
+ }
442
+ /** Every string inside `value`, at any depth. */
443
+ function stringLeaves(value, depth) {
444
+ if (typeof value === 'string')
445
+ return [value];
446
+ if (typeof value !== 'object' || value === null || depth > 32)
447
+ return [];
448
+ return Object.values(value).flatMap((item) => stringLeaves(item, depth + 1));
449
+ }
450
+ /** Every string of the config, for the forms a secret takes beside another value. */
451
+ function configStrings(config) {
452
+ return stringLeaves(config, 0);
453
+ }
454
+ /**
455
+ * The forms a secret can take in a log line: itself, base64 and base64url,
456
+ * percent-encoded, and the HTTP Basic credentials it makes with any other
457
+ * string of the config (`client_secret_basic`, RFC 6749 §2.3.1).
458
+ */
459
+ function secretForms(secrets, strings) {
460
+ const base64 = (text) => Buffer.from(text, 'utf8').toString('base64');
461
+ const base64url = (text) => Buffer.from(text, 'utf8').toString('base64url');
462
+ const forms = new Set();
463
+ for (const secret of secrets) {
464
+ forms.add(secret);
465
+ forms.add(base64(secret));
466
+ forms.add(base64url(secret));
467
+ forms.add(encodeURIComponent(secret));
468
+ for (const other of strings) {
469
+ if (other === secret)
470
+ continue;
471
+ forms.add(base64(`${other}:${secret}`));
472
+ forms.add(base64(`${encodeURIComponent(other)}:${encodeURIComponent(secret)}`));
473
+ }
474
+ }
475
+ // Only the empty string is left out: it is inside every line.
476
+ return [...forms].filter((form) => form.length > 0);
477
+ }
478
+ function kitLogger(lines) {
479
+ const write = (fields, message) => {
480
+ lines.push(`${String(message)} ${serialised(fields)}`);
481
+ };
482
+ return { debug: write, info: write, warn: write, error: write };
483
+ }
484
+ /**
485
+ * JSON of a log line's fields. An Error is written out with its own fields
486
+ * (as the server's error serializer copies them), its message, stack and
487
+ * cause, and an AggregateError's errors.
488
+ */
489
+ function serialised(value) {
490
+ const seen = new WeakSet();
491
+ try {
492
+ return (JSON.stringify(value, (_key, member) => {
493
+ if (member instanceof Error) {
494
+ if (seen.has(member))
495
+ return '[cycle]';
496
+ seen.add(member);
497
+ return {
498
+ ...member,
499
+ name: member.name,
500
+ message: member.message,
501
+ stack: member.stack,
502
+ cause: member.cause,
503
+ ...(member instanceof AggregateError ? { errors: member.errors } : {}),
504
+ };
505
+ }
506
+ if (typeof member === 'object' && member !== null) {
507
+ if (seen.has(member))
508
+ return '[cycle]';
509
+ seen.add(member);
510
+ }
511
+ return typeof member === 'bigint' ? member.toString() : member;
512
+ }) ?? '');
513
+ }
514
+ catch {
515
+ return String(value);
516
+ }
517
+ }
518
+ function describe(error) {
519
+ return error instanceof Error ? error.message : String(error);
520
+ }
521
+ function describeOutcome(outcome) {
522
+ if (typeof outcome !== 'object' || outcome === null)
523
+ return String(outcome);
524
+ const { kind, reason } = outcome;
525
+ return reason === undefined ? String(kind) : `${String(kind)} (${String(reason)})`;
526
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The contract test kit's guard on `fetch` (#599): while the kit runs a
3
+ * method, the global `fetch` refuses and counts every call, so a method that
4
+ * calls `fetch` instead of `context.egress` fails the kit. It never sends
5
+ * anything. It sees only the global `fetch`: a method that imports
6
+ * `node:https` or `undici`, or kept a `fetch` from before the run, gets past
7
+ * it, and the `qauth/no-direct-network` lint rule is what refuses those in
8
+ * QAuth's own code.
9
+ *
10
+ * It lives under `src/testing/`, test support served from the
11
+ * `@qauth-labs/plugin-sdk/testing` entry point only, because it replaces a
12
+ * global that server code must not touch (`qauth/no-direct-network`).
13
+ *
14
+ * @experimental
15
+ */
16
+ /**
17
+ * A disabled `fetch`, and the way back.
18
+ *
19
+ * @experimental
20
+ */
21
+ export interface FetchGuard {
22
+ /** Run `work` as this guard's run: the `fetch` calls it makes count here. */
23
+ run<T>(work: () => Promise<T>): Promise<T>;
24
+ /** How many times `fetch` was called by this guard's run. */
25
+ calls(): number;
26
+ /** Lower this guard. The original `fetch` comes back with the last guard. */
27
+ restore(): void;
28
+ }
29
+ /**
30
+ * Replace the global `fetch` with one that refuses and counts every call.
31
+ * Guards nest, and overlapping runs each count only their own calls: a call
32
+ * is counted against the run it was made in, or, when it belongs to none,
33
+ * against every run that is up. The original `fetch` comes back when the
34
+ * last guard is lowered.
35
+ *
36
+ * @experimental
37
+ */
38
+ export declare function disableFetch(): FetchGuard;
@@ -0,0 +1,49 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ /** The run a `fetch` call belongs to, across its awaits. */
3
+ const runs = new AsyncLocalStorage();
4
+ /** The guards up now, and the `fetch` the first one replaced. */
5
+ let active = 0;
6
+ let saved;
7
+ const counters = new Set();
8
+ /**
9
+ * Replace the global `fetch` with one that refuses and counts every call.
10
+ * Guards nest, and overlapping runs each count only their own calls: a call
11
+ * is counted against the run it was made in, or, when it belongs to none,
12
+ * against every run that is up. The original `fetch` comes back when the
13
+ * last guard is lowered.
14
+ *
15
+ * @experimental
16
+ */
17
+ export function disableFetch() {
18
+ if (active === 0) {
19
+ saved = { fetch: globalThis.fetch };
20
+ globalThis.fetch = (() => {
21
+ const current = runs.getStore();
22
+ if (current !== undefined)
23
+ current.count += 1;
24
+ else
25
+ for (const counter of counters)
26
+ counter.count += 1;
27
+ return Promise.reject(new Error('fetch is disabled: send through context.egress'));
28
+ });
29
+ }
30
+ active += 1;
31
+ const counter = { count: 0 };
32
+ counters.add(counter);
33
+ let lowered = false;
34
+ return {
35
+ run: (work) => runs.run(counter, work),
36
+ calls: () => counter.count,
37
+ restore: () => {
38
+ if (lowered)
39
+ return;
40
+ lowered = true;
41
+ counters.delete(counter);
42
+ active -= 1;
43
+ if (active === 0 && saved !== undefined) {
44
+ globalThis.fetch = saved.fetch;
45
+ saved = undefined;
46
+ }
47
+ },
48
+ };
49
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * `@qauth-labs/plugin-sdk/testing` — test support for authors of AuthMethods
3
+ * (#599, EXPERIMENTAL): the contract test kit, `checkAuthMethod`, which checks
4
+ * a method against the contract in any test runner.
5
+ *
6
+ * Never imported by the main entry point: while the kit runs, it replaces the
7
+ * global `fetch`, and it refuses to run with `NODE_ENV=production`.
8
+ *
9
+ * @packageDocumentation
10
+ */
11
+ export type { AuthMethodKitReport, AuthMethodKitRequest, AuthMethodKitScenario, } from './contract-kit.js';
12
+ export { checkAuthMethod } from './contract-kit.js';
@@ -0,0 +1 @@
1
+ export { checkAuthMethod } from './contract-kit.js';