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

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,367 @@
1
+ import { timingSafeEqual } from 'node:crypto';
2
+ import { AUTH_METHOD_ATTRIBUTE_KEY_PATTERN, AUTH_METHOD_BINDING_PATTERN, DEFAULT_AUTH_METHOD_BINDING_PARAMETER, MAX_AUTH_METHOD_ATTRIBUTE_VALUE_LENGTH, MAX_AUTH_METHOD_EXTERNAL_SUB_LENGTH, MAX_AUTH_METHOD_RAW_CLAIMS_BYTES, MAX_UPSTREAM_REDIRECT_LENGTH, MAX_UPSTREAM_TRANSACTION_BYTES, UPSTREAM_FAILURE_REASONS, } from './contract.js';
3
+ import { AuthMethodFaultError } from './errors.js';
4
+ /**
5
+ * The engine's half of the upstream step (#599): what it checks around the
6
+ * two calls it makes into a method. The engine never acts on an answer that
7
+ * fails a check; it throws {@link AuthMethodFaultError}, which the sign-in
8
+ * flow answers with the generic error page and its trace id.
9
+ *
10
+ * Pure: the transaction store, the flow cookie and the account decisions
11
+ * belong to the sign-in flow that calls these (#639).
12
+ *
13
+ * @experimental
14
+ */
15
+ const ASSURANCE_LEVELS = ['low', 'substantial', 'high'];
16
+ /**
17
+ * A control character an attribute value may not hold: NUL (which Postgres
18
+ * `text` refuses) and the other C0 controls and DEL, but tab, line feed and
19
+ * carriage return, which a postal address may hold.
20
+ */
21
+ // eslint-disable-next-line no-control-regex -- matching control characters is the point
22
+ const VALUE_CONTROL_CHARACTER = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/;
23
+ /** A C0 or C1 control character, or DEL. */
24
+ // eslint-disable-next-line no-control-regex -- matching control characters is the point
25
+ const CONTROL_CHARACTER = /[\u0000-\u001f\u007f-\u009f]/;
26
+ /**
27
+ * Start an upstream transaction: call `method.start()` and check its answer.
28
+ * The redirect must be an absolute `https` URL with no credentials and no
29
+ * fragment, the transaction JSON data within its cap, and the binding 22 to
30
+ * 512 base64url characters. The returned transaction is a frozen copy; the
31
+ * host stores it with the binding (a `PendingUpstreamTransaction`).
32
+ *
33
+ * A rejection of `start()` (the provider's discovery document cannot be read,
34
+ * for instance) propagates unchanged.
35
+ *
36
+ * @throws AuthMethodFaultError when the answer fails a check
37
+ * @experimental
38
+ */
39
+ export async function startUpstreamStep(method, request, context) {
40
+ const answer = await method.start(request, context);
41
+ if (typeof answer !== 'object' || answer === null) {
42
+ throw new AuthMethodFaultError(method.type, 'start() did not answer an object');
43
+ }
44
+ const { redirectTo, transaction, binding } = answer;
45
+ const redirectProblem = upstreamRedirectProblem(redirectTo);
46
+ if (redirectProblem !== undefined) {
47
+ throw new AuthMethodFaultError(method.type, `start() answered ${redirectProblem}`);
48
+ }
49
+ // An object: the host tells "no transaction" apart by its absence, so a
50
+ // transaction that is null, a string or a list could read as none.
51
+ const stored = typeof transaction === 'object' && transaction !== null && !Array.isArray(transaction)
52
+ ? storableTransaction(transaction)
53
+ : undefined;
54
+ if (stored === undefined) {
55
+ throw new AuthMethodFaultError(method.type, `start() answered a transaction that is not an object of JSON data of at most ${MAX_UPSTREAM_TRANSACTION_BYTES} bytes`);
56
+ }
57
+ // The URL as parsed, so what the engine sends is what it checked; parsing
58
+ // percent-encodes, so the cap is checked again.
59
+ const href = new URL(redirectTo).href;
60
+ if (href.length > MAX_UPSTREAM_REDIRECT_LENGTH) {
61
+ throw new AuthMethodFaultError(method.type, `start() answered a redirect longer than ${MAX_UPSTREAM_REDIRECT_LENGTH} characters`);
62
+ }
63
+ if (typeof binding !== 'string' || !AUTH_METHOD_BINDING_PATTERN.test(binding)) {
64
+ throw new AuthMethodFaultError(method.type, 'start() answered a binding that is not 22 to 512 base64url characters');
65
+ }
66
+ return Object.freeze({ redirectTo: href, transaction: stored, binding });
67
+ }
68
+ /**
69
+ * The binding a request to `route` carries: the single value of the route's
70
+ * binding parameter (`state` unless the route names another), from the
71
+ * query of a `GET` route and the form of a `POST` one. `undefined` when the
72
+ * parameter is missing or repeated, or `route` is not a callback route.
73
+ *
74
+ * @experimental
75
+ */
76
+ export function presentedUpstreamBinding(route, request) {
77
+ if (route.kind !== 'callback')
78
+ return undefined;
79
+ const name = route.bindingParameter ?? DEFAULT_AUTH_METHOD_BINDING_PARAMETER;
80
+ const parameters = route.method === 'GET' ? request.query : request.form;
81
+ const value = Object.hasOwn(parameters, name) ? parameters[name] : undefined;
82
+ return typeof value === 'string' ? value : undefined;
83
+ }
84
+ /**
85
+ * Whether the binding a callback carried is the pending transaction's, in
86
+ * constant time (`timingSafeEqual` over the UTF-8 bytes). `false` unless both are strings and
87
+ * the expected one is a binding: a missing or repeated parameter never
88
+ * matches, and neither does an empty one.
89
+ *
90
+ * The host runs it before it takes the transaction, so a callback from
91
+ * another site cannot spend or cancel a pending sign-in.
92
+ *
93
+ * @experimental
94
+ */
95
+ export function upstreamBindingMatches(expected, presented) {
96
+ return (typeof expected === 'string' &&
97
+ typeof presented === 'string' &&
98
+ AUTH_METHOD_BINDING_PATTERN.test(expected) &&
99
+ constantTimeEquals(expected, presented));
100
+ }
101
+ /**
102
+ * Whether two strings are equal, compared in constant time over their UTF-8
103
+ * bytes; a length mismatch answers `false` at once (the expected binding's
104
+ * length is no secret: the pattern fixes its range).
105
+ */
106
+ function constantTimeEquals(left, right) {
107
+ const a = Buffer.from(left, 'utf8');
108
+ const b = Buffer.from(right, 'utf8');
109
+ if (a.length !== b.length)
110
+ return false;
111
+ return timingSafeEqual(a, b);
112
+ }
113
+ /**
114
+ * The lowest assurance level, the ceiling of a method's identity when the
115
+ * caller names none.
116
+ *
117
+ * @experimental
118
+ */
119
+ export const DEFAULT_UPSTREAM_MAX_ASSURANCE = ASSURANCE_LEVELS[0];
120
+ /**
121
+ * Turn what a method's callback handed back into the end of the upstream
122
+ * step. A `result` goes to `method.verify()`; the identity it resolves is
123
+ * checked and its assurance level lowered to `options.maxAssurance`, then
124
+ * `method.extractAttributes()` gives its attributes, which are checked too.
125
+ *
126
+ * @param outcome - what the callback route's handler returned, unchecked, or
127
+ * the {@link AuthMethodFaultError} the route mount made of a handler that
128
+ * threw or a context that could not be built
129
+ * @param options - the assurance ceiling
130
+ * @throws AuthMethodFaultError when the outcome is a fault or outside the
131
+ * closed set, or the identity or an attribute fails a check
132
+ * @throws TypeError when `options.maxAssurance` is not an assurance level
133
+ * @experimental
134
+ */
135
+ export async function completeUpstreamStep(method, outcome, options = {}) {
136
+ const maxAssurance = options.maxAssurance ?? DEFAULT_UPSTREAM_MAX_ASSURANCE;
137
+ if (!ASSURANCE_LEVELS.includes(maxAssurance)) {
138
+ throw new TypeError(`maxAssurance must be one of ${ASSURANCE_LEVELS.join(', ')}`);
139
+ }
140
+ // A fault the route mount caught (a handler that threw, a bounce that went
141
+ // wrong, a context that could not be built) arrives as the outcome, so it
142
+ // ends the step the way every other fault of the method does.
143
+ if (outcome instanceof AuthMethodFaultError) {
144
+ throw outcome.methodType === method.type
145
+ ? outcome
146
+ : new AuthMethodFaultError(method.type, 'the callback answered a fault of another method', {
147
+ cause: outcome,
148
+ });
149
+ }
150
+ if (typeof outcome !== 'object' || outcome === null) {
151
+ throw new AuthMethodFaultError(method.type, 'the callback did not answer an outcome');
152
+ }
153
+ const { kind } = outcome;
154
+ if (kind === 'declined')
155
+ return Object.freeze({ kind: 'declined' });
156
+ if (kind === 'failed') {
157
+ const { reason } = outcome;
158
+ if (!UPSTREAM_FAILURE_REASONS.includes(reason)) {
159
+ throw new AuthMethodFaultError(method.type, `the callback failed with a reason outside ${UPSTREAM_FAILURE_REASONS.join(', ')}`);
160
+ }
161
+ return Object.freeze({ kind: 'failed', reason: reason });
162
+ }
163
+ if (kind !== 'result' || !('result' in outcome)) {
164
+ throw new AuthMethodFaultError(method.type, 'the callback answered an outcome outside result, declined and failed');
165
+ }
166
+ let identity;
167
+ try {
168
+ identity = await method.verify(outcome.result);
169
+ }
170
+ catch (cause) {
171
+ return Object.freeze({ kind: 'failed', reason: 'verification_refused', cause });
172
+ }
173
+ const checked = checkedIdentity(method.type, identity, maxAssurance);
174
+ let attributes;
175
+ try {
176
+ attributes = method.extractAttributes(checked);
177
+ }
178
+ catch (cause) {
179
+ throw new AuthMethodFaultError(method.type, 'extractAttributes() threw', { cause });
180
+ }
181
+ return Object.freeze({
182
+ kind: 'identity',
183
+ identity: checked,
184
+ attributes: checkedAttributes(method.type, attributes),
185
+ });
186
+ }
187
+ /**
188
+ * Why `value` cannot be where an upstream step sends the browser, or
189
+ * `undefined` when it can.
190
+ *
191
+ * @experimental
192
+ */
193
+ export function upstreamRedirectProblem(value) {
194
+ if (typeof value !== 'string')
195
+ return 'a redirect that is not a string';
196
+ if (value.length > MAX_UPSTREAM_REDIRECT_LENGTH) {
197
+ return `a redirect longer than ${MAX_UPSTREAM_REDIRECT_LENGTH} characters`;
198
+ }
199
+ let url;
200
+ try {
201
+ url = new URL(value);
202
+ }
203
+ catch {
204
+ return 'a redirect that is not an absolute URL';
205
+ }
206
+ if (url.protocol !== 'https:')
207
+ return 'a redirect that is not https';
208
+ if (url.username !== '' || url.password !== '')
209
+ return 'a redirect with credentials';
210
+ if (value.includes('#'))
211
+ return 'a redirect with a fragment';
212
+ return undefined;
213
+ }
214
+ /**
215
+ * A frozen copy of `value` when it is JSON data within the transaction cap:
216
+ * plain objects, arrays, strings, finite numbers, booleans and `null`, nested
217
+ * at most 16 deep. `undefined` otherwise.
218
+ *
219
+ * @experimental
220
+ */
221
+ export function storableTransaction(value) {
222
+ return frozenJsonCopy(value, MAX_UPSTREAM_TRANSACTION_BYTES);
223
+ }
224
+ /** A frozen copy of `value` when it is JSON data of at most `maxBytes`; `undefined` otherwise. */
225
+ function frozenJsonCopy(value, maxBytes) {
226
+ if (!isJsonData(value, 0))
227
+ return undefined;
228
+ const json = JSON.stringify(value);
229
+ if (Buffer.byteLength(json, 'utf8') > maxBytes)
230
+ return undefined;
231
+ return deepFreeze(JSON.parse(json));
232
+ }
233
+ function isJsonData(value, depth) {
234
+ if (depth > 16)
235
+ return false;
236
+ if (value === null || typeof value === 'string' || typeof value === 'boolean')
237
+ return true;
238
+ if (typeof value === 'number')
239
+ return Number.isFinite(value);
240
+ if (Array.isArray(value))
241
+ return value.every((item) => isJsonData(item, depth + 1));
242
+ if (typeof value !== 'object')
243
+ return false;
244
+ const prototype = Object.getPrototypeOf(value);
245
+ if (prototype !== Object.prototype && prototype !== null)
246
+ return false;
247
+ if (Object.getOwnPropertySymbols(value).length > 0)
248
+ return false;
249
+ return Object.values(value).every((item) => isJsonData(item, depth + 1));
250
+ }
251
+ function deepFreeze(value) {
252
+ if (typeof value === 'object' && value !== null) {
253
+ for (const item of Object.values(value))
254
+ deepFreeze(item);
255
+ Object.freeze(value);
256
+ }
257
+ return value;
258
+ }
259
+ function checkedIdentity(type, identity, maxAssurance) {
260
+ const fault = (problem) => new AuthMethodFaultError(type, `verify() resolved ${problem}`);
261
+ if (typeof identity !== 'object' || identity === null)
262
+ throw fault('something not an object');
263
+ const { externalSub, assuranceLevel, rawClaims } = identity;
264
+ if (typeof externalSub !== 'string' ||
265
+ externalSub.length === 0 ||
266
+ externalSub.length > MAX_AUTH_METHOD_EXTERNAL_SUB_LENGTH ||
267
+ CONTROL_CHARACTER.test(externalSub) ||
268
+ !isWellFormed(externalSub)) {
269
+ throw fault(`an externalSub that is not 1 to ${MAX_AUTH_METHOD_EXTERNAL_SUB_LENGTH} characters of well-formed text with no control character`);
270
+ }
271
+ if (!ASSURANCE_LEVELS.includes(assuranceLevel)) {
272
+ throw fault(`an assuranceLevel outside ${ASSURANCE_LEVELS.join(', ')}`);
273
+ }
274
+ const claims = typeof rawClaims === 'object' && rawClaims !== null && !Array.isArray(rawClaims)
275
+ ? frozenJsonCopy(rawClaims, MAX_AUTH_METHOD_RAW_CLAIMS_BYTES)
276
+ : undefined;
277
+ if (claims === undefined) {
278
+ throw fault(`rawClaims that are not an object of JSON data of at most ${MAX_AUTH_METHOD_RAW_CLAIMS_BYTES} bytes`);
279
+ }
280
+ return Object.freeze({
281
+ externalSub,
282
+ assuranceLevel: lowerAssurance(assuranceLevel, maxAssurance),
283
+ rawClaims: claims,
284
+ });
285
+ }
286
+ /** The lower of two assurance levels. */
287
+ function lowerAssurance(level, ceiling) {
288
+ return ASSURANCE_LEVELS.indexOf(level) <= ASSURANCE_LEVELS.indexOf(ceiling) ? level : ceiling;
289
+ }
290
+ function checkedAttributes(type, attributes) {
291
+ const fault = (problem) => new AuthMethodFaultError(type, `extractAttributes() returned ${problem}`);
292
+ if (!Array.isArray(attributes))
293
+ throw fault('something not a list');
294
+ // An index loop into a fresh array, with the length read once: `map` would
295
+ // build its result through `attributes.constructor[Symbol.species]`, which
296
+ // the method controls, so a list whose species hands back other objects
297
+ // (an attribute under `wallet`, say) would skip every check below.
298
+ const length = attributes.length;
299
+ if (typeof length !== 'number' || !Number.isSafeInteger(length) || length < 0) {
300
+ throw fault('a list whose length is not a count');
301
+ }
302
+ const checked = [];
303
+ for (let index = 0; index < length; index += 1) {
304
+ checked[index] = checkedAttribute(type, attributes[index], index, fault);
305
+ }
306
+ return Object.freeze(checked);
307
+ }
308
+ function checkedAttribute(type, attribute, index, fault) {
309
+ if (typeof attribute !== 'object' || attribute === null) {
310
+ throw fault(`an attribute [${index}] that is not an object`);
311
+ }
312
+ const { source, attrKey, attrValue, verified, expiresAt } = attribute;
313
+ // The source must be the method's own type: a method cannot write
314
+ // attributes under another source, such as `wallet`, which outranks every
315
+ // other in claim resolution.
316
+ if (source !== type)
317
+ throw fault(`an attribute [${index}] whose source is not ${type}`);
318
+ if (typeof attrKey !== 'string' || !AUTH_METHOD_ATTRIBUTE_KEY_PATTERN.test(attrKey)) {
319
+ throw fault(`an attribute [${index}] whose key does not match ${AUTH_METHOD_ATTRIBUTE_KEY_PATTERN.source}`);
320
+ }
321
+ if (typeof attrValue !== 'string' ||
322
+ attrValue.length > MAX_AUTH_METHOD_ATTRIBUTE_VALUE_LENGTH ||
323
+ VALUE_CONTROL_CHARACTER.test(attrValue) ||
324
+ !isWellFormed(attrValue)) {
325
+ throw fault(`an attribute [${index}] whose value is not well-formed text of at most ${MAX_AUTH_METHOD_ATTRIBUTE_VALUE_LENGTH} characters without control characters other than tab and line breaks`);
326
+ }
327
+ if (typeof verified !== 'boolean') {
328
+ throw fault(`an attribute [${index}] whose verified flag is not a boolean`);
329
+ }
330
+ // A copy of the time, never the method's Date: a Date stays mutable when
331
+ // the attribute holding it is frozen.
332
+ const expiry = expiresAt === undefined ? undefined : dateTime(expiresAt);
333
+ if (expiry !== undefined && Number.isNaN(expiry)) {
334
+ throw fault(`an attribute [${index}] whose expiresAt is not a valid Date`);
335
+ }
336
+ return Object.freeze({
337
+ source,
338
+ attrKey,
339
+ attrValue,
340
+ verified,
341
+ ...(expiry === undefined ? {} : { expiresAt: new Date(expiry) }),
342
+ });
343
+ }
344
+ /**
345
+ * The time a Date holds, read from the Date itself rather than through a
346
+ * `getTime` a subclass may override; `NaN` for anything that is not a Date.
347
+ */
348
+ function dateTime(value) {
349
+ if (!(value instanceof Date))
350
+ return Number.NaN;
351
+ try {
352
+ return Date.prototype.getTime.call(value);
353
+ }
354
+ catch {
355
+ // A proxy around a Date passes `instanceof` but holds no time.
356
+ return Number.NaN;
357
+ }
358
+ }
359
+ /**
360
+ * Whether `value` is well-formed UTF-16, with no lone surrogate:
361
+ * `String.prototype.isWellFormed` (ES2024; the workspace's TypeScript lib is
362
+ * ES2022). Postgres stores a lone surrogate as U+FFFD, so two different
363
+ * ill-formed strings could be stored as one: two subjects, one account.
364
+ */
365
+ function isWellFormed(value) {
366
+ return String.prototype.isWellFormed.call(value);
367
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The errors of the AuthMethod contract (#599). None of them reaches the wire
3
+ * with its details: they name a fault of a method's code or of its config,
4
+ * for the operator's log. A request that meets one answers with the generic
5
+ * error page and its trace id.
6
+ *
7
+ * @experimental
8
+ */
9
+ /**
10
+ * A method does not meet the contract: thrown when it is registered (the boot
11
+ * fails) and by the contract test kit. `problems` lists every problem found,
12
+ * one sentence each.
13
+ *
14
+ * @experimental
15
+ */
16
+ export declare class AuthMethodContractError extends Error {
17
+ /** Every problem found, one sentence each. */
18
+ readonly problems: readonly string[];
19
+ constructor(subject: string, problems: readonly string[]);
20
+ }
21
+ /**
22
+ * A method broke the contract at run time: `start()` answered something the
23
+ * engine cannot use, a callback handed back an outcome outside the closed set,
24
+ * or `verify()`/`extractAttributes()` produced an identity or attributes the
25
+ * engine refuses. The engine never acts on such an answer.
26
+ *
27
+ * @experimental
28
+ */
29
+ export declare class AuthMethodFaultError extends Error {
30
+ /** The method's `type`. */
31
+ readonly methodType: string;
32
+ constructor(methodType: string, problem: string, options?: {
33
+ cause?: unknown;
34
+ });
35
+ }
36
+ /**
37
+ * A realm's stored config of a method does not pass the method's
38
+ * `configSchema`. `issues` names the members, never their values, since a
39
+ * member may be a secret.
40
+ *
41
+ * @experimental
42
+ */
43
+ export declare class AuthMethodConfigError extends Error {
44
+ /** The method's `type`. */
45
+ readonly methodType: string;
46
+ /** Each failing member's path and the schema's code, never its value. */
47
+ readonly issues: readonly string[];
48
+ constructor(methodType: string, issues: readonly string[]);
49
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The errors of the AuthMethod contract (#599). None of them reaches the wire
3
+ * with its details: they name a fault of a method's code or of its config,
4
+ * for the operator's log. A request that meets one answers with the generic
5
+ * error page and its trace id.
6
+ *
7
+ * @experimental
8
+ */
9
+ /**
10
+ * A method does not meet the contract: thrown when it is registered (the boot
11
+ * fails) and by the contract test kit. `problems` lists every problem found,
12
+ * one sentence each.
13
+ *
14
+ * @experimental
15
+ */
16
+ export class AuthMethodContractError extends Error {
17
+ /** Every problem found, one sentence each. */
18
+ problems;
19
+ constructor(subject, problems) {
20
+ super(`${subject} does not meet the AuthMethod contract:\n- ${problems.join('\n- ')}`);
21
+ this.name = 'AuthMethodContractError';
22
+ this.problems = Object.freeze([...problems]);
23
+ Object.setPrototypeOf(this, AuthMethodContractError.prototype);
24
+ }
25
+ }
26
+ /**
27
+ * A method broke the contract at run time: `start()` answered something the
28
+ * engine cannot use, a callback handed back an outcome outside the closed set,
29
+ * or `verify()`/`extractAttributes()` produced an identity or attributes the
30
+ * engine refuses. The engine never acts on such an answer.
31
+ *
32
+ * @experimental
33
+ */
34
+ export class AuthMethodFaultError extends Error {
35
+ /** The method's `type`. */
36
+ methodType;
37
+ constructor(methodType, problem, options) {
38
+ super(`AuthMethod ${methodType}: ${problem}`, options);
39
+ this.name = 'AuthMethodFaultError';
40
+ this.methodType = methodType;
41
+ Object.setPrototypeOf(this, AuthMethodFaultError.prototype);
42
+ }
43
+ }
44
+ /**
45
+ * A realm's stored config of a method does not pass the method's
46
+ * `configSchema`. `issues` names the members, never their values, since a
47
+ * member may be a secret.
48
+ *
49
+ * @experimental
50
+ */
51
+ export class AuthMethodConfigError extends Error {
52
+ /** The method's `type`. */
53
+ methodType;
54
+ /** Each failing member's path and the schema's code, never its value. */
55
+ issues;
56
+ constructor(methodType, issues) {
57
+ super(`AuthMethod ${methodType}: the config does not pass the method's schema`);
58
+ this.name = 'AuthMethodConfigError';
59
+ this.methodType = methodType;
60
+ this.issues = Object.freeze([...issues]);
61
+ Object.setPrototypeOf(this, AuthMethodConfigError.prototype);
62
+ }
63
+ }
@@ -0,0 +1,21 @@
1
+ import type { AuthMethodParameters } from './auth-method.js';
2
+ /**
3
+ * The parameters a method's route handler sees (#599): the string and
4
+ * string-list members of a parsed query or form, within the caps, in a
5
+ * frozen object with no prototype, so a parameter named `__proto__` stays a
6
+ * parameter. Anything else is dropped: a method that misses a parameter it
7
+ * needs refuses the request.
8
+ *
9
+ * The route mount builds every handler's `query` and `form` with it, and the
10
+ * contract test kit does the same, so a method meets in its tests exactly the
11
+ * objects the host hands it.
12
+ *
13
+ * - at most {@link MAX_AUTH_METHOD_PARAMETERS} parameters, the first ones;
14
+ * - a value longer than {@link MAX_AUTH_METHOD_PARAMETER_LENGTH} drops its
15
+ * parameter;
16
+ * - a repeated parameter is a frozen list of at most
17
+ * {@link MAX_AUTH_METHOD_PARAMETER_VALUES} values; more drops it.
18
+ *
19
+ * @experimental
20
+ */
21
+ export declare function authMethodParameters(value: unknown): AuthMethodParameters;
@@ -0,0 +1,62 @@
1
+ import { MAX_AUTH_METHOD_PARAMETER_LENGTH, MAX_AUTH_METHOD_PARAMETER_VALUES, MAX_AUTH_METHOD_PARAMETERS, } from './contract.js';
2
+ /**
3
+ * The parameters a method's route handler sees (#599): the string and
4
+ * string-list members of a parsed query or form, within the caps, in a
5
+ * frozen object with no prototype, so a parameter named `__proto__` stays a
6
+ * parameter. Anything else is dropped: a method that misses a parameter it
7
+ * needs refuses the request.
8
+ *
9
+ * The route mount builds every handler's `query` and `form` with it, and the
10
+ * contract test kit does the same, so a method meets in its tests exactly the
11
+ * objects the host hands it.
12
+ *
13
+ * - at most {@link MAX_AUTH_METHOD_PARAMETERS} parameters, the first ones;
14
+ * - a value longer than {@link MAX_AUTH_METHOD_PARAMETER_LENGTH} drops its
15
+ * parameter;
16
+ * - a repeated parameter is a frozen list of at most
17
+ * {@link MAX_AUTH_METHOD_PARAMETER_VALUES} values; more drops it.
18
+ *
19
+ * @experimental
20
+ */
21
+ export function authMethodParameters(value) {
22
+ const parameters = Object.create(null);
23
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
24
+ let count = 0;
25
+ for (const [name, member] of Object.entries(value)) {
26
+ if (count >= MAX_AUTH_METHOD_PARAMETERS)
27
+ break;
28
+ if (typeof member === 'string') {
29
+ if (member.length > MAX_AUTH_METHOD_PARAMETER_LENGTH)
30
+ continue;
31
+ parameters[name] = member;
32
+ count += 1;
33
+ }
34
+ else if (Array.isArray(member)) {
35
+ const values = stringList(member);
36
+ if (values === undefined)
37
+ continue;
38
+ parameters[name] = values;
39
+ count += 1;
40
+ }
41
+ }
42
+ }
43
+ return Object.freeze(parameters);
44
+ }
45
+ /** A frozen copy of a list of capped strings, read by index; `undefined` otherwise. */
46
+ function stringList(member) {
47
+ const length = member.length;
48
+ if (typeof length !== 'number' ||
49
+ !Number.isSafeInteger(length) ||
50
+ length > MAX_AUTH_METHOD_PARAMETER_VALUES) {
51
+ return undefined;
52
+ }
53
+ const values = [];
54
+ for (let index = 0; index < length; index += 1) {
55
+ const item = member[index];
56
+ if (typeof item !== 'string' || item.length > MAX_AUTH_METHOD_PARAMETER_LENGTH) {
57
+ return undefined;
58
+ }
59
+ values[index] = item;
60
+ }
61
+ return Object.freeze(values);
62
+ }
@@ -0,0 +1,55 @@
1
+ import type { AuthMethod } from './auth-method.js';
2
+ import type { CredentialProvider } from './credential-provider.js';
3
+ /**
4
+ * Whether `provider` presents itself as an AuthMethod: it has a `contract`
5
+ * member. A plain `CredentialProvider` has none. The registry then holds it to
6
+ * the whole contract ({@link assertAuthMethod}), so a provider cannot carry a
7
+ * `contract` member and skip the checks.
8
+ *
9
+ * @experimental
10
+ */
11
+ export declare function declaresAuthMethodContract(provider: CredentialProvider): boolean;
12
+ /**
13
+ * A frozen snapshot of a method: each member read once, functions bound to
14
+ * the method, routes and steps copied. The registry validates and keeps the
15
+ * snapshot, so the `type` it checked, the routes it mounts and the members the
16
+ * engine reads cannot change afterwards, whatever getters the method has.
17
+ * Members that are not what the contract asks are copied as they are, for
18
+ * the validation to report.
19
+ *
20
+ * @experimental
21
+ */
22
+ export declare function authMethodSnapshot(method: object): unknown;
23
+ /**
24
+ * Whether `value` meets the static checks of the contract.
25
+ *
26
+ * @experimental
27
+ */
28
+ export declare function isAuthMethod(value: unknown): value is AuthMethod;
29
+ /**
30
+ * Throw an {@link AuthMethodContractError} listing every problem when `value`
31
+ * does not meet the static checks of the contract.
32
+ *
33
+ * @experimental
34
+ */
35
+ export declare function assertAuthMethod(value: unknown): asserts value is AuthMethod;
36
+ /**
37
+ * Every static problem of `value` as an AuthMethod, one sentence each; empty
38
+ * when there is none.
39
+ *
40
+ * @experimental
41
+ */
42
+ export declare function authMethodProblems(value: unknown): string[];
43
+ /**
44
+ * The problems of a route list; exported for the mount, which checks again.
45
+ *
46
+ * @experimental
47
+ */
48
+ export declare function routeProblems(routes: unknown): string[];
49
+ /**
50
+ * The problems of `type` as an AuthMethod's type: its pattern, and not one of
51
+ * the reserved types.
52
+ *
53
+ * @experimental
54
+ */
55
+ export declare function authMethodTypeProblems(type: unknown): string[];