@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.
- package/LICENSE +201 -0
- package/README.md +148 -0
- 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 +61 -11
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import { AUTH_METHOD_ROUTE_PATH_PATTERN, authMethodRoutePrefix } from './contract.js';
|
|
2
|
+
import { AuthMethodConfigError } from './errors.js';
|
|
3
|
+
const EGRESS_METHODS = ['GET', 'POST'];
|
|
4
|
+
const EGRESS_SOURCES = ['operator', 'admin', 'remote_document'];
|
|
5
|
+
const EGRESS_REDIRECTS = [undefined, 'follow', 'error'];
|
|
6
|
+
const ASSERTION_ALGORITHMS = ['RS256', 'ES256', 'EdDSA'];
|
|
7
|
+
/**
|
|
8
|
+
* The longest a client assertion signed through the context stays valid, in
|
|
9
|
+
* seconds: its `exp` is lowered to at most this far past the moment it is
|
|
10
|
+
* signed. Five minutes covers a token request and clock skew; an assertion
|
|
11
|
+
* that outlives it is a replayable credential at the provider.
|
|
12
|
+
*
|
|
13
|
+
* @experimental
|
|
14
|
+
*/
|
|
15
|
+
export const MAX_AUTH_METHOD_CLIENT_ASSERTION_LIFETIME_SECONDS = 300;
|
|
16
|
+
/**
|
|
17
|
+
* How far a client assertion's `iat` may lie in the future, in seconds, for
|
|
18
|
+
* the clock skew between the method's idea of now and the host's.
|
|
19
|
+
*/
|
|
20
|
+
const ASSERTION_IAT_SKEW_SECONDS = 60;
|
|
21
|
+
/**
|
|
22
|
+
* The fields a method's log line may not carry, at any depth, matched without
|
|
23
|
+
* regard to case, `_` or `-` (`clientSecret` matches `client_secret`): the
|
|
24
|
+
* value is replaced with `[Redacted]`. Codes, verifiers, client secrets and
|
|
25
|
+
* assertions, tokens, nonces and passwords: what an upstream sign-in handles
|
|
26
|
+
* and a log must never hold. The last segment of each of the method's own
|
|
27
|
+
* secret paths joins them (`AuthMethodContextParts.secrets`), so does every
|
|
28
|
+
* occurrence of a secret's value.
|
|
29
|
+
*
|
|
30
|
+
* @experimental
|
|
31
|
+
*/
|
|
32
|
+
export const AUTH_METHOD_LOG_REDACTED_FIELDS = Object.freeze([
|
|
33
|
+
'code',
|
|
34
|
+
'code_verifier',
|
|
35
|
+
'client_secret',
|
|
36
|
+
'client_assertion',
|
|
37
|
+
'refresh_token',
|
|
38
|
+
'access_token',
|
|
39
|
+
'id_token',
|
|
40
|
+
'nonce',
|
|
41
|
+
'password',
|
|
42
|
+
]);
|
|
43
|
+
/**
|
|
44
|
+
* The key a method's log fields are nested under, so they never overwrite
|
|
45
|
+
* the host's own fields of the line (`reqId`, `realm`, …).
|
|
46
|
+
*
|
|
47
|
+
* @experimental
|
|
48
|
+
*/
|
|
49
|
+
export const AUTH_METHOD_LOG_FIELDS_KEY = 'authMethod';
|
|
50
|
+
/** What a redacted log value is replaced with. */
|
|
51
|
+
const LOG_CENSOR = '[Redacted]';
|
|
52
|
+
/** How deep a method's log fields are copied; anything deeper is cut. */
|
|
53
|
+
const MAX_LOG_DEPTH = 8;
|
|
54
|
+
/**
|
|
55
|
+
* Build the context a method is handed for one realm (ADR-019 Decision 12).
|
|
56
|
+
*
|
|
57
|
+
* The context holds exactly five members: the config, parsed by the method's
|
|
58
|
+
* `configSchema`, copied and deep-frozen (it must be plain data: objects,
|
|
59
|
+
* arrays and primitives, so a Map, Set, Date or other class instance is
|
|
60
|
+
* refused, as none of them freezes); `egress`; `signer`; `log`; and
|
|
61
|
+
* `routeUrl`. Each is an adapter that calls the host's object with the members
|
|
62
|
+
* the contract names and nothing else:
|
|
63
|
+
*
|
|
64
|
+
* - `egress.request` passes `method` (`GET` or `POST`), `url`, `headers`,
|
|
65
|
+
* `body`, `purpose` and `source`, and adds this realm's name. One cap only,
|
|
66
|
+
* `maxRedirects: 0` when the method asks for `redirect: 'error'`; no other
|
|
67
|
+
* cap, no other realm, no other method. `source` is `operator` or `admin` (the
|
|
68
|
+
* labels the realm's egress allowlist applies to, for non-public
|
|
69
|
+
* addresses) only when the URL's origin is the origin of a URL among the
|
|
70
|
+
* plain members of the method's parsed config, never a secret one; any
|
|
71
|
+
* other call goes as `remote_document`, so a method cannot reach an
|
|
72
|
+
* allowlisted address its config does not name.
|
|
73
|
+
* - `signer.signClientAssertion` passes `audience`, `claims` and `algorithm`,
|
|
74
|
+
* with this realm, this provider and the host's caller role. It sets `iat`
|
|
75
|
+
* when the claims carry none, and lowers `exp` to at most
|
|
76
|
+
* {@link MAX_AUTH_METHOD_CLIENT_ASSERTION_LIFETIME_SECONDS} from now.
|
|
77
|
+
* `signer.getUpstreamJwks` takes no argument and asks for this realm and
|
|
78
|
+
* provider.
|
|
79
|
+
* - `log` calls the logger's four levels with a message and the method's
|
|
80
|
+
* fields nested under {@link AUTH_METHOD_LOG_FIELDS_KEY}, as a copy in which
|
|
81
|
+
* every field {@link AUTH_METHOD_LOG_REDACTED_FIELDS} names, or a secret
|
|
82
|
+
* path's last segment, is redacted at any depth, and every secret value is
|
|
83
|
+
* cut out of the message and of every string.
|
|
84
|
+
* - `routeUrl` answers URLs only for the paths the method's own `routes`
|
|
85
|
+
* declare.
|
|
86
|
+
*
|
|
87
|
+
* So the host can hand over its whole egress client, signer and logger, and a
|
|
88
|
+
* method still reaches no `validateDestination`, no `decrypt`, no `child`, no
|
|
89
|
+
* database or cache handle and no key material.
|
|
90
|
+
*
|
|
91
|
+
* @throws AuthMethodConfigError when the config does not pass `configSchema`,
|
|
92
|
+
* or its parsed form is not plain data; the error names the failing
|
|
93
|
+
* members, never their values
|
|
94
|
+
* @experimental
|
|
95
|
+
*/
|
|
96
|
+
export function createAuthMethodContext(method, parts) {
|
|
97
|
+
const type = method.type;
|
|
98
|
+
const config = parseAuthMethodConfig(method, parts.config);
|
|
99
|
+
const origin = new URL(parts.origin);
|
|
100
|
+
if (origin.origin !== parts.origin) {
|
|
101
|
+
throw new TypeError('origin must be a bare origin, such as https://auth.example.com');
|
|
102
|
+
}
|
|
103
|
+
const prefix = authMethodRoutePrefix(type);
|
|
104
|
+
const routePaths = declaredRoutePaths(method.routes);
|
|
105
|
+
const secrets = parts.secrets ?? new Map();
|
|
106
|
+
const secretValues = [...secrets.values()].filter((value) => value !== '');
|
|
107
|
+
const configOrigins = urlOrigins(config, new Set(secrets.keys()), new Set(secretValues));
|
|
108
|
+
const redaction = {
|
|
109
|
+
fields: new Set([
|
|
110
|
+
...AUTH_METHOD_LOG_REDACTED_FIELDS.map(fieldKey),
|
|
111
|
+
...[...secrets.keys()].map((path) => fieldKey(path.split('.').at(-1) ?? path)),
|
|
112
|
+
]),
|
|
113
|
+
// The longest first, so no shorter value cuts a longer one apart.
|
|
114
|
+
values: [...secretValues].sort((a, b) => b.length - a.length),
|
|
115
|
+
};
|
|
116
|
+
const { egress, signer, log, realmName, realmId, providerId, signerCaller } = parts;
|
|
117
|
+
const logged = (fields) => ({
|
|
118
|
+
[AUTH_METHOD_LOG_FIELDS_KEY]: redactedCopy(fields, 0, new WeakSet(), redaction),
|
|
119
|
+
});
|
|
120
|
+
const message = (text) => withoutSecrets(String(text), redaction);
|
|
121
|
+
return Object.freeze({
|
|
122
|
+
config: deepFreeze(config),
|
|
123
|
+
egress: Object.freeze({
|
|
124
|
+
request(request) {
|
|
125
|
+
// Each member read once: what is checked is what is sent.
|
|
126
|
+
const { method: verb, url, headers, body, purpose, source, redirect } = request;
|
|
127
|
+
if (!EGRESS_METHODS.includes(verb)) {
|
|
128
|
+
return Promise.reject(new TypeError('egress method must be GET or POST'));
|
|
129
|
+
}
|
|
130
|
+
if (!EGRESS_SOURCES.includes(source)) {
|
|
131
|
+
return Promise.reject(new TypeError('egress source must be operator, admin or remote_document'));
|
|
132
|
+
}
|
|
133
|
+
if (!EGRESS_REDIRECTS.includes(redirect)) {
|
|
134
|
+
return Promise.reject(new TypeError('egress redirect must be follow or error'));
|
|
135
|
+
}
|
|
136
|
+
return egress.request({
|
|
137
|
+
method: verb,
|
|
138
|
+
url,
|
|
139
|
+
...(headers === undefined ? {} : { headers: { ...headers } }),
|
|
140
|
+
...(body === undefined ? {} : { body }),
|
|
141
|
+
purpose,
|
|
142
|
+
source: source === 'remote_document' || configOrigins.has(originOf(url))
|
|
143
|
+
? source
|
|
144
|
+
: 'remote_document',
|
|
145
|
+
realm: realmName,
|
|
146
|
+
// The one cap a method may set: zero redirects, the floor.
|
|
147
|
+
...(redirect === 'error' ? { limits: { maxRedirects: 0 } } : {}),
|
|
148
|
+
});
|
|
149
|
+
},
|
|
150
|
+
}),
|
|
151
|
+
signer: Object.freeze({
|
|
152
|
+
signClientAssertion(request) {
|
|
153
|
+
const { audience, claims, algorithm } = request;
|
|
154
|
+
if (algorithm !== undefined && !ASSERTION_ALGORITHMS.includes(algorithm)) {
|
|
155
|
+
return Promise.reject(new TypeError('algorithm must be RS256, ES256 or EdDSA'));
|
|
156
|
+
}
|
|
157
|
+
if (typeof claims !== 'object' || claims === null || Array.isArray(claims)) {
|
|
158
|
+
return Promise.reject(new TypeError('claims must be an object'));
|
|
159
|
+
}
|
|
160
|
+
const now = Math.floor(Date.now() / 1000);
|
|
161
|
+
const bounded = { ...claims };
|
|
162
|
+
const { iat, exp } = bounded;
|
|
163
|
+
if (iat === undefined) {
|
|
164
|
+
bounded['iat'] = now;
|
|
165
|
+
}
|
|
166
|
+
else if (typeof iat !== 'number' ||
|
|
167
|
+
!Number.isFinite(iat) ||
|
|
168
|
+
iat > now + ASSERTION_IAT_SKEW_SECONDS) {
|
|
169
|
+
return Promise.reject(new TypeError('iat must be a time, in seconds, not in the future'));
|
|
170
|
+
}
|
|
171
|
+
// A missing or non-numeric exp goes on as it is: the signer refuses it.
|
|
172
|
+
const latest = now + MAX_AUTH_METHOD_CLIENT_ASSERTION_LIFETIME_SECONDS;
|
|
173
|
+
if (typeof exp === 'number' && exp > latest)
|
|
174
|
+
bounded['exp'] = latest;
|
|
175
|
+
return signer.signClientAssertion(signerCaller, {
|
|
176
|
+
realmId,
|
|
177
|
+
providerId,
|
|
178
|
+
audience,
|
|
179
|
+
claims: bounded,
|
|
180
|
+
...(algorithm === undefined ? {} : { algorithm }),
|
|
181
|
+
});
|
|
182
|
+
},
|
|
183
|
+
async getUpstreamJwks() {
|
|
184
|
+
const jwks = await signer.getUpstreamJwks(signerCaller, realmId, providerId);
|
|
185
|
+
return { keys: jwks.keys };
|
|
186
|
+
},
|
|
187
|
+
}),
|
|
188
|
+
log: Object.freeze({
|
|
189
|
+
debug: (fields, text) => log.debug(logged(fields), message(text)),
|
|
190
|
+
info: (fields, text) => log.info(logged(fields), message(text)),
|
|
191
|
+
warn: (fields, text) => log.warn(logged(fields), message(text)),
|
|
192
|
+
error: (fields, text) => log.error(logged(fields), message(text)),
|
|
193
|
+
}),
|
|
194
|
+
routeUrl(path) {
|
|
195
|
+
if (typeof path !== 'string' ||
|
|
196
|
+
!AUTH_METHOD_ROUTE_PATH_PATTERN.test(path) ||
|
|
197
|
+
!routePaths.has(path)) {
|
|
198
|
+
throw new TypeError("routeUrl takes the path of one of the method's own routes, such as /callback");
|
|
199
|
+
}
|
|
200
|
+
return `${origin.origin}${prefix}${path}`;
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
/** The paths of a method's routes, read once by index. */
|
|
205
|
+
function declaredRoutePaths(routes) {
|
|
206
|
+
const paths = new Set();
|
|
207
|
+
if (!Array.isArray(routes))
|
|
208
|
+
return paths;
|
|
209
|
+
const length = routes.length;
|
|
210
|
+
if (typeof length !== 'number' || !Number.isSafeInteger(length))
|
|
211
|
+
return paths;
|
|
212
|
+
for (let index = 0; index < length; index += 1) {
|
|
213
|
+
const route = routes[index];
|
|
214
|
+
if (typeof route !== 'object' || route === null)
|
|
215
|
+
continue;
|
|
216
|
+
const path = route['path'];
|
|
217
|
+
if (typeof path === 'string')
|
|
218
|
+
paths.add(path);
|
|
219
|
+
}
|
|
220
|
+
return paths;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* The origins of every absolute URL among the strings of a (plain) config's
|
|
224
|
+
* plain members: a member at one of `secretPaths`, and a string that is one
|
|
225
|
+
* of `secretValues`, is skipped, so a secret that parses as a URL opens no
|
|
226
|
+
* destination.
|
|
227
|
+
*/
|
|
228
|
+
function urlOrigins(config, secretPaths, secretValues) {
|
|
229
|
+
const origins = new Set();
|
|
230
|
+
const walk = (value, path, depth) => {
|
|
231
|
+
if (path !== '' && secretPaths.has(path))
|
|
232
|
+
return;
|
|
233
|
+
if (typeof value === 'string') {
|
|
234
|
+
if (secretValues.has(value))
|
|
235
|
+
return;
|
|
236
|
+
const found = originOf(value);
|
|
237
|
+
if (found !== '')
|
|
238
|
+
origins.add(found);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (typeof value !== 'object' || value === null || depth > 32)
|
|
242
|
+
return;
|
|
243
|
+
// A list's items have no member path; a secret is never inside one.
|
|
244
|
+
const inList = Array.isArray(value);
|
|
245
|
+
for (const [key, item] of Object.entries(value)) {
|
|
246
|
+
walk(item, inList ? `${path}[]` : path === '' ? key : `${path}.${key}`, depth + 1);
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
walk(config, '', 0);
|
|
250
|
+
return origins;
|
|
251
|
+
}
|
|
252
|
+
/** The origin of an absolute http(s) URL, or `''`. */
|
|
253
|
+
function originOf(value) {
|
|
254
|
+
if (typeof value !== 'string')
|
|
255
|
+
return '';
|
|
256
|
+
let url;
|
|
257
|
+
try {
|
|
258
|
+
url = new URL(value);
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
return '';
|
|
262
|
+
}
|
|
263
|
+
return url.protocol === 'https:' || url.protocol === 'http:' ? url.origin : '';
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* A method's config as a context holds it: parsed by its `configSchema`, and
|
|
267
|
+
* a copy of plain data (objects with the plain prototype or none, arrays,
|
|
268
|
+
* primitives). The admin API checks a plugin's config with it before it
|
|
269
|
+
* writes it (#600, #639), so a config the context would refuse is refused at
|
|
270
|
+
* the configure, never at every sign-in.
|
|
271
|
+
*
|
|
272
|
+
* @throws AuthMethodConfigError when the config does not pass the schema, or
|
|
273
|
+
* its parsed form is not plain data; the error names the failing member's
|
|
274
|
+
* path, never its value
|
|
275
|
+
* @experimental
|
|
276
|
+
*/
|
|
277
|
+
export function parseAuthMethodConfig(method, raw) {
|
|
278
|
+
const type = method.type;
|
|
279
|
+
const parsed = method.configSchema.safeParse(raw);
|
|
280
|
+
if (!parsed.success) {
|
|
281
|
+
throw new AuthMethodConfigError(type, parsed.error.issues.map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.code}`));
|
|
282
|
+
}
|
|
283
|
+
const notPlain = notPlainDataPath(parsed.data, [], 0);
|
|
284
|
+
if (notPlain !== undefined) {
|
|
285
|
+
throw new AuthMethodConfigError(type, [
|
|
286
|
+
`${notPlain.join('.') || '(root)'}: not plain data (a Map, Set, Date or class instance does not freeze)`,
|
|
287
|
+
]);
|
|
288
|
+
}
|
|
289
|
+
try {
|
|
290
|
+
// A copy, so freezing it leaves the caller's objects alone.
|
|
291
|
+
return structuredClone(parsed.data);
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
throw new AuthMethodConfigError(type, ['(root): the parsed config is not plain data']);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* The path of the first member of `value` that is not plain data (an object
|
|
299
|
+
* with the plain prototype or none, an array, or a primitive), or
|
|
300
|
+
* `undefined` when there is none.
|
|
301
|
+
*/
|
|
302
|
+
function notPlainDataPath(value, path, depth) {
|
|
303
|
+
if (typeof value !== 'object' || value === null)
|
|
304
|
+
return undefined;
|
|
305
|
+
if (depth > 32)
|
|
306
|
+
return path;
|
|
307
|
+
if (!Array.isArray(value)) {
|
|
308
|
+
const prototype = Object.getPrototypeOf(value);
|
|
309
|
+
if (prototype !== Object.prototype && prototype !== null)
|
|
310
|
+
return path;
|
|
311
|
+
}
|
|
312
|
+
for (const [key, item] of Object.entries(value)) {
|
|
313
|
+
const found = notPlainDataPath(item, [...path, key], depth + 1);
|
|
314
|
+
if (found !== undefined)
|
|
315
|
+
return found;
|
|
316
|
+
}
|
|
317
|
+
return undefined;
|
|
318
|
+
}
|
|
319
|
+
/** A field name as redaction compares it: lower case, without `_` or `-`. */
|
|
320
|
+
function fieldKey(name) {
|
|
321
|
+
return name.toLowerCase().replace(/[_-]/g, '');
|
|
322
|
+
}
|
|
323
|
+
/** `text` with every occurrence of a secret value replaced by the censor. */
|
|
324
|
+
function withoutSecrets(text, redaction) {
|
|
325
|
+
let result = text;
|
|
326
|
+
for (const value of redaction.values) {
|
|
327
|
+
if (result.includes(value))
|
|
328
|
+
result = result.split(value).join(LOG_CENSOR);
|
|
329
|
+
}
|
|
330
|
+
return result;
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* A copy of a method's log fields with every field `redaction` names redacted,
|
|
334
|
+
* at any depth, and every secret value cut out of every string. An Error is
|
|
335
|
+
* copied as its name, message, stack, cause and own fields, as the host's
|
|
336
|
+
* error serializer would write it; binary data as its size.
|
|
337
|
+
*/
|
|
338
|
+
function redactedCopy(value, depth, seen, redaction) {
|
|
339
|
+
if (typeof value !== 'object' || value === null) {
|
|
340
|
+
if (typeof value === 'string')
|
|
341
|
+
return withoutSecrets(value, redaction);
|
|
342
|
+
return typeof value === 'function' || typeof value === 'symbol'
|
|
343
|
+
? withoutSecrets(String(value), redaction)
|
|
344
|
+
: value;
|
|
345
|
+
}
|
|
346
|
+
if (depth >= MAX_LOG_DEPTH)
|
|
347
|
+
return '[Truncated]';
|
|
348
|
+
if (seen.has(value))
|
|
349
|
+
return '[Circular]';
|
|
350
|
+
seen.add(value);
|
|
351
|
+
if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer) {
|
|
352
|
+
return `[${value.byteLength} bytes]`;
|
|
353
|
+
}
|
|
354
|
+
if (value instanceof Date)
|
|
355
|
+
return value.toISOString();
|
|
356
|
+
if (Array.isArray(value)) {
|
|
357
|
+
const copy = [];
|
|
358
|
+
const length = value.length;
|
|
359
|
+
for (let index = 0; index < length; index += 1) {
|
|
360
|
+
copy[index] = redactedCopy(value[index], depth + 1, seen, redaction);
|
|
361
|
+
}
|
|
362
|
+
return copy;
|
|
363
|
+
}
|
|
364
|
+
// No prototype: a field named `__proto__` stays a field.
|
|
365
|
+
const copy = Object.create(null);
|
|
366
|
+
if (value instanceof Error) {
|
|
367
|
+
copy['type'] = redactedCopy(value.name, depth + 1, seen, redaction);
|
|
368
|
+
copy['message'] = redactedCopy(value.message, depth + 1, seen, redaction);
|
|
369
|
+
copy['stack'] = redactedCopy(value.stack, depth + 1, seen, redaction);
|
|
370
|
+
if (value.cause !== undefined) {
|
|
371
|
+
copy['cause'] = redactedCopy(value.cause, depth + 1, seen, redaction);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
for (const [key, item] of Object.entries(value)) {
|
|
375
|
+
copy[key] = redaction.fields.has(fieldKey(key))
|
|
376
|
+
? LOG_CENSOR
|
|
377
|
+
: redactedCopy(item, depth + 1, seen, redaction);
|
|
378
|
+
}
|
|
379
|
+
return copy;
|
|
380
|
+
}
|
|
381
|
+
/** Freeze `value` and everything it holds; `seen` stops at cycles. */
|
|
382
|
+
function deepFreeze(value, seen = new WeakSet()) {
|
|
383
|
+
if (typeof value !== 'object' || value === null || seen.has(value))
|
|
384
|
+
return value;
|
|
385
|
+
seen.add(value);
|
|
386
|
+
for (const item of Object.values(value))
|
|
387
|
+
deepFreeze(item, seen);
|
|
388
|
+
Object.freeze(value);
|
|
389
|
+
return value;
|
|
390
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fixed values of the AuthMethod contract (#599, ADR-003 as amended,
|
|
3
|
+
* ADR-018 §4, ADR-019 Decision 12).
|
|
4
|
+
*
|
|
5
|
+
* EXPERIMENTAL. The AuthMethod contract is the plugin API of ADR-018 §4: it is
|
|
6
|
+
* public and documented, so others can write methods against it, but it stays
|
|
7
|
+
* outside the 1.0 stability promise and may change in a minor release. It
|
|
8
|
+
* becomes stable in 1.x. QAuth's experimental-feature registry lists it as
|
|
9
|
+
* `plugin-api`.
|
|
10
|
+
*
|
|
11
|
+
* This package imports no other QAuth package, so the few values it shares
|
|
12
|
+
* with the server's other contracts (the Interaction API's version label and
|
|
13
|
+
* display rule, the credential types QAuth writes itself) are written out
|
|
14
|
+
* here. The server checks at boot, and in its tests, that each still equals
|
|
15
|
+
* its source.
|
|
16
|
+
*
|
|
17
|
+
* @packageDocumentation
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* The contract's name, as it appears in {@link AUTH_METHOD_CONTRACT}.
|
|
21
|
+
*
|
|
22
|
+
* @experimental
|
|
23
|
+
*/
|
|
24
|
+
export declare const AUTH_METHOD_CONTRACT_NAME = "qauth-auth-method";
|
|
25
|
+
/**
|
|
26
|
+
* The contract's version: a release candidate until QAuth 1.0.0
|
|
27
|
+
* (ADR-018 §5). A breaking change raises the release-candidate number, so one
|
|
28
|
+
* identifier never names two contracts.
|
|
29
|
+
*
|
|
30
|
+
* @experimental
|
|
31
|
+
*/
|
|
32
|
+
export declare const AUTH_METHOD_CONTRACT_VERSION = "1-rc.0";
|
|
33
|
+
/**
|
|
34
|
+
* The contract a method declares it is written against, as a format id:
|
|
35
|
+
* `qauth-auth-method/1-rc.0`. The registry refuses a method that declares
|
|
36
|
+
* another one, so a method built for another version fails the boot instead of
|
|
37
|
+
* running against a contract it does not know.
|
|
38
|
+
*
|
|
39
|
+
* @experimental
|
|
40
|
+
*/
|
|
41
|
+
export declare const AUTH_METHOD_CONTRACT: "qauth-auth-method/1-rc.0";
|
|
42
|
+
/**
|
|
43
|
+
* The export a plugin package names its method by: `authMethod`, an
|
|
44
|
+
* AuthMethod object. A CommonJS package may put it under `default`.
|
|
45
|
+
*
|
|
46
|
+
* ```ts
|
|
47
|
+
* // index.ts of @example/qauth-plugin-corp-sso
|
|
48
|
+
* export const authMethod: AuthMethod<CorpSsoConfig, CorpSsoTransaction> = { … };
|
|
49
|
+
* ```
|
|
50
|
+
*
|
|
51
|
+
* @experimental
|
|
52
|
+
*/
|
|
53
|
+
export declare const AUTH_METHOD_PLUGIN_EXPORT = "authMethod";
|
|
54
|
+
/**
|
|
55
|
+
* The upstream step: the Interaction API step through which a person signs in
|
|
56
|
+
* at an upstream identity provider. The method starts an upstream transaction
|
|
57
|
+
* (`AuthMethod.start`), the browser goes to the provider, and the
|
|
58
|
+
* method's callback route hands the provider's answer back to the engine.
|
|
59
|
+
*
|
|
60
|
+
* Step names are QAuth identifiers that carry the Interaction API's version
|
|
61
|
+
* (ADR-018 §5): `urn:qauth:ia:upstream:v1-rc.0` until 1.0.0. The step's params
|
|
62
|
+
* and its place in ADR-017's step table come with the upstream sign-in flow
|
|
63
|
+
* (#639).
|
|
64
|
+
*
|
|
65
|
+
* @experimental
|
|
66
|
+
*/
|
|
67
|
+
export declare const UPSTREAM_STEP_URN: "urn:qauth:ia:upstream:v1-rc.0";
|
|
68
|
+
/**
|
|
69
|
+
* Every step a method may contribute in this version of the contract, by URN.
|
|
70
|
+
* Only the upstream step today; a later version may add others (an LDAP form,
|
|
71
|
+
* for instance).
|
|
72
|
+
*
|
|
73
|
+
* @experimental
|
|
74
|
+
*/
|
|
75
|
+
export declare const AUTH_METHOD_STEP_URNS: readonly ["urn:qauth:ia:upstream:v1-rc.0"];
|
|
76
|
+
/**
|
|
77
|
+
* A step a method may contribute: one of {@link AUTH_METHOD_STEP_URNS}.
|
|
78
|
+
*
|
|
79
|
+
* @experimental
|
|
80
|
+
*/
|
|
81
|
+
export type AuthMethodStepUrn = (typeof AUTH_METHOD_STEP_URNS)[number];
|
|
82
|
+
/**
|
|
83
|
+
* The root every method's routes are mounted under, on the realm host. A
|
|
84
|
+
* method's own prefix is `/upstream/<type>` ({@link authMethodRoutePrefix}),
|
|
85
|
+
* so each provider has its own callback URL, and a response meant for one
|
|
86
|
+
* provider cannot be replayed at another.
|
|
87
|
+
*
|
|
88
|
+
* No version in the path: the callback URL is registered at the upstream
|
|
89
|
+
* provider, and a path that changed at 1.0 would break every registration.
|
|
90
|
+
*
|
|
91
|
+
* @experimental
|
|
92
|
+
*/
|
|
93
|
+
export declare const AUTH_METHOD_ROUTE_ROOT = "/upstream";
|
|
94
|
+
/**
|
|
95
|
+
* The prefix a method's routes are mounted under: `/upstream/<type>`.
|
|
96
|
+
*
|
|
97
|
+
* @experimental
|
|
98
|
+
*/
|
|
99
|
+
export declare function authMethodRoutePrefix(type: string): string;
|
|
100
|
+
/**
|
|
101
|
+
* An AuthMethod's `type`: lower-case ASCII letters, digits and `_`, starting
|
|
102
|
+
* with a letter, at most 63 characters. It is the registry key, the value
|
|
103
|
+
* written to `user_credentials.provider_type`, the `source` of the attributes
|
|
104
|
+
* the method extracts and a segment of its route prefix, so it must be safe in
|
|
105
|
+
* all four. A type with `:` or `/` (an ID-JAG issuer type, `oidc_<issuer URL>`)
|
|
106
|
+
* can never match.
|
|
107
|
+
*
|
|
108
|
+
* @experimental
|
|
109
|
+
*/
|
|
110
|
+
export declare const AUTH_METHOD_TYPE_PATTERN: RegExp;
|
|
111
|
+
/**
|
|
112
|
+
* An AuthMethod's `id`: the stable name of the method's implementation, such
|
|
113
|
+
* as `oidc` or `corp-sso`. Lower-case ASCII letters, digits, `_` and `-`,
|
|
114
|
+
* starting with a letter, at most 63 characters.
|
|
115
|
+
*
|
|
116
|
+
* @experimental
|
|
117
|
+
*/
|
|
118
|
+
export declare const AUTH_METHOD_ID_PATTERN: RegExp;
|
|
119
|
+
/**
|
|
120
|
+
* Types an AuthMethod may not take: the credential types and the attribute
|
|
121
|
+
* source QAuth writes itself, built or planned. A method under one of them
|
|
122
|
+
* would read as that built-in credential, and `wallet` would outrank every
|
|
123
|
+
* other attribute source in claim resolution (`rankAttributeSource`).
|
|
124
|
+
*
|
|
125
|
+
* @experimental
|
|
126
|
+
*/
|
|
127
|
+
export declare const RESERVED_AUTH_METHOD_TYPES: readonly string[];
|
|
128
|
+
/**
|
|
129
|
+
* A route path below a method's prefix: one to four segments of lower-case
|
|
130
|
+
* ASCII letters, digits, `_` and `-`, such as `/callback`. No dot segment, no
|
|
131
|
+
* encoded character, no route parameter and no wildcard can match, so a path
|
|
132
|
+
* cannot leave the prefix it is mounted under.
|
|
133
|
+
*
|
|
134
|
+
* @experimental
|
|
135
|
+
*/
|
|
136
|
+
export declare const AUTH_METHOD_ROUTE_PATH_PATTERN: RegExp;
|
|
137
|
+
/**
|
|
138
|
+
* The display rule of the Interaction API (ADR-019 Decision 6), which a
|
|
139
|
+
* method's default name follows: no markup bracket, no control, format,
|
|
140
|
+
* private-use or unassigned character, no line separator and no space at
|
|
141
|
+
* either end.
|
|
142
|
+
*
|
|
143
|
+
* @experimental
|
|
144
|
+
*/
|
|
145
|
+
export declare const AUTH_METHOD_DISPLAY_NAME_PATTERN: RegExp;
|
|
146
|
+
/**
|
|
147
|
+
* The longest default display name, in UTF-16 code units.
|
|
148
|
+
*
|
|
149
|
+
* @experimental
|
|
150
|
+
*/
|
|
151
|
+
export declare const MAX_AUTH_METHOD_DISPLAY_NAME_LENGTH = 64;
|
|
152
|
+
/**
|
|
153
|
+
* The largest default icon, in bytes.
|
|
154
|
+
*
|
|
155
|
+
* @experimental
|
|
156
|
+
*/
|
|
157
|
+
export declare const MAX_AUTH_METHOD_ICON_BYTES: number;
|
|
158
|
+
/**
|
|
159
|
+
* The media types a default icon may have: raster PNG only. An SVG can carry
|
|
160
|
+
* script, and the icon is served from the realm origin.
|
|
161
|
+
*
|
|
162
|
+
* @experimental
|
|
163
|
+
*/
|
|
164
|
+
export declare const AUTH_METHOD_ICON_MEDIA_TYPES: readonly ["image/png"];
|
|
165
|
+
/**
|
|
166
|
+
* The largest upstream transaction a method may ask the engine to keep, in
|
|
167
|
+
* bytes of its JSON form. The engine stores it server-side, bound to the
|
|
168
|
+
* interaction and its flow cookie, for the lifetime of the interaction.
|
|
169
|
+
*
|
|
170
|
+
* @experimental
|
|
171
|
+
*/
|
|
172
|
+
export declare const MAX_UPSTREAM_TRANSACTION_BYTES = 4096;
|
|
173
|
+
/**
|
|
174
|
+
* A binding (#599): the value `start()` answers beside its transaction, which
|
|
175
|
+
* the authorization request carries to the provider (as `state`, usually)
|
|
176
|
+
* and the provider's callback carries back. 22 to 512 base64url characters:
|
|
177
|
+
* 22 is the length of 128 random bits in base64url. The pattern checks the
|
|
178
|
+
* length and the alphabet only; the method makes the value random.
|
|
179
|
+
*
|
|
180
|
+
* @experimental
|
|
181
|
+
*/
|
|
182
|
+
export declare const AUTH_METHOD_BINDING_PATTERN: RegExp;
|
|
183
|
+
/**
|
|
184
|
+
* The parameter a callback route carries the binding back in when the route
|
|
185
|
+
* names none: `state` (RFC 6749 §4.1.2).
|
|
186
|
+
*
|
|
187
|
+
* @experimental
|
|
188
|
+
*/
|
|
189
|
+
export declare const DEFAULT_AUTH_METHOD_BINDING_PARAMETER = "state";
|
|
190
|
+
/**
|
|
191
|
+
* A parameter name a callback route may name as its binding parameter:
|
|
192
|
+
* ASCII letters, digits, `_`, `.` and `-`, at most 64 characters.
|
|
193
|
+
*
|
|
194
|
+
* @experimental
|
|
195
|
+
*/
|
|
196
|
+
export declare const AUTH_METHOD_BINDING_PARAMETER_PATTERN: RegExp;
|
|
197
|
+
/**
|
|
198
|
+
* The longest URL a method may send the browser to when it starts.
|
|
199
|
+
*
|
|
200
|
+
* @experimental
|
|
201
|
+
*/
|
|
202
|
+
export declare const MAX_UPSTREAM_REDIRECT_LENGTH = 8192;
|
|
203
|
+
/**
|
|
204
|
+
* The longest `externalSub` a method may resolve: 255 characters, the limit
|
|
205
|
+
* OpenID Connect Core 1.0 §2 sets on `sub`.
|
|
206
|
+
*
|
|
207
|
+
* @experimental
|
|
208
|
+
*/
|
|
209
|
+
export declare const MAX_AUTH_METHOD_EXTERNAL_SUB_LENGTH = 255;
|
|
210
|
+
/**
|
|
211
|
+
* The largest `rawClaims` a method's `verify()` may resolve, in bytes of its
|
|
212
|
+
* JSON form. The engine keeps a frozen copy.
|
|
213
|
+
*
|
|
214
|
+
* @experimental
|
|
215
|
+
*/
|
|
216
|
+
export declare const MAX_AUTH_METHOD_RAW_CLAIMS_BYTES: number;
|
|
217
|
+
/**
|
|
218
|
+
* An attribute key a method may extract: lower-case ASCII letters, digits
|
|
219
|
+
* and `_`, starting with a letter, at most 64 characters (`email`,
|
|
220
|
+
* `given_name`).
|
|
221
|
+
*
|
|
222
|
+
* @experimental
|
|
223
|
+
*/
|
|
224
|
+
export declare const AUTH_METHOD_ATTRIBUTE_KEY_PATTERN: RegExp;
|
|
225
|
+
/**
|
|
226
|
+
* The longest attribute value a method may extract, in UTF-16 code units.
|
|
227
|
+
*
|
|
228
|
+
* @experimental
|
|
229
|
+
*/
|
|
230
|
+
export declare const MAX_AUTH_METHOD_ATTRIBUTE_VALUE_LENGTH = 8192;
|
|
231
|
+
/**
|
|
232
|
+
* The most parameters a method's route handler sees from one query or one
|
|
233
|
+
* form; the rest are dropped.
|
|
234
|
+
*
|
|
235
|
+
* @experimental
|
|
236
|
+
*/
|
|
237
|
+
export declare const MAX_AUTH_METHOD_PARAMETERS = 64;
|
|
238
|
+
/**
|
|
239
|
+
* The longest parameter value a method's route handler sees, in UTF-16 code
|
|
240
|
+
* units; a parameter with a longer value is dropped.
|
|
241
|
+
*
|
|
242
|
+
* @experimental
|
|
243
|
+
*/
|
|
244
|
+
export declare const MAX_AUTH_METHOD_PARAMETER_LENGTH = 8192;
|
|
245
|
+
/**
|
|
246
|
+
* The most values of one repeated parameter a method's route handler sees;
|
|
247
|
+
* a parameter repeated more often is dropped.
|
|
248
|
+
*
|
|
249
|
+
* @experimental
|
|
250
|
+
*/
|
|
251
|
+
export declare const MAX_AUTH_METHOD_PARAMETER_VALUES = 16;
|
|
252
|
+
/**
|
|
253
|
+
* Why a method's callback did not produce a result, other than the person
|
|
254
|
+
* declining at the provider:
|
|
255
|
+
*
|
|
256
|
+
* - `transaction_mismatch`: no transaction is pending for this browser, or the
|
|
257
|
+
* callback does not belong to it (its binding, `state` by default, is
|
|
258
|
+
* missing, repeated or another's). A callback replayed in another browser,
|
|
259
|
+
* or forged, ends here; the host answers it before the method runs.
|
|
260
|
+
* - `provider_error`: the provider answered with an error, other than the
|
|
261
|
+
* person declining (`access_denied`).
|
|
262
|
+
* - `invalid_response`: the provider's answer failed a check, such as the ID
|
|
263
|
+
* token's signature, `iss`, `aud` or `nonce`.
|
|
264
|
+
* - `upstream_unavailable`: the provider could not be reached in time.
|
|
265
|
+
*
|
|
266
|
+
* @experimental
|
|
267
|
+
*/
|
|
268
|
+
export declare const UPSTREAM_FAILURE_REASONS: readonly ["transaction_mismatch", "provider_error", "invalid_response", "upstream_unavailable"];
|
|
269
|
+
/**
|
|
270
|
+
* One of {@link UPSTREAM_FAILURE_REASONS}.
|
|
271
|
+
*
|
|
272
|
+
* @experimental
|
|
273
|
+
*/
|
|
274
|
+
export type UpstreamFailureReason = (typeof UPSTREAM_FAILURE_REASONS)[number];
|