@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.
- package/LICENSE +201 -0
- package/README.md +148 -2
- package/dist/host.d.ts +24 -0
- package/dist/host.js +24 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +31 -0
- package/dist/lib/auth-method.d.ts +424 -0
- package/dist/lib/auth-method.js +1 -0
- package/dist/lib/context.d.ts +163 -0
- package/dist/lib/context.js +390 -0
- package/dist/lib/contract.d.ts +274 -0
- package/dist/lib/contract.js +285 -0
- package/dist/lib/credential-provider.d.ts +86 -0
- package/dist/lib/credential-provider.js +15 -0
- package/dist/lib/engine.d.ts +125 -0
- package/dist/lib/engine.js +367 -0
- package/dist/lib/errors.d.ts +49 -0
- package/dist/lib/errors.js +63 -0
- package/dist/lib/parameters.d.ts +21 -0
- package/dist/lib/parameters.js +62 -0
- package/dist/lib/validate.d.ts +55 -0
- package/dist/lib/validate.js +265 -0
- package/dist/testing/contract-kit.d.ts +116 -0
- package/dist/testing/contract-kit.js +526 -0
- package/dist/testing/fetch-guard.d.ts +38 -0
- package/dist/testing/fetch-guard.js +49 -0
- package/dist/testing/index.d.ts +12 -0
- package/dist/testing/index.js +1 -0
- package/package.json +60 -3
|
@@ -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[];
|