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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,265 @@
1
+ import { AUTH_METHOD_BINDING_PARAMETER_PATTERN, AUTH_METHOD_CONTRACT, AUTH_METHOD_CONTRACT_NAME, AUTH_METHOD_DISPLAY_NAME_PATTERN, AUTH_METHOD_ICON_MEDIA_TYPES, AUTH_METHOD_ID_PATTERN, AUTH_METHOD_ROUTE_PATH_PATTERN, AUTH_METHOD_STEP_URNS, AUTH_METHOD_TYPE_PATTERN, MAX_AUTH_METHOD_DISPLAY_NAME_LENGTH, MAX_AUTH_METHOD_ICON_BYTES, RESERVED_AUTH_METHOD_TYPES, } from './contract.js';
2
+ import { AuthMethodContractError } from './errors.js';
3
+ /**
4
+ * The static checks of the AuthMethod contract (#599): what a method must be
5
+ * before the registry accepts it. The registry runs them at registration, so a
6
+ * method that fails one fails the boot. The contract test kit runs them too,
7
+ * then exercises the method.
8
+ *
9
+ * @experimental
10
+ */
11
+ /** The eight bytes every PNG file starts with. */
12
+ const PNG_SIGNATURE = Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a);
13
+ /**
14
+ * Whether `provider` presents itself as an AuthMethod: it has a `contract`
15
+ * member. A plain `CredentialProvider` has none. The registry then holds it to
16
+ * the whole contract ({@link assertAuthMethod}), so a provider cannot carry a
17
+ * `contract` member and skip the checks.
18
+ *
19
+ * @experimental
20
+ */
21
+ export function declaresAuthMethodContract(provider) {
22
+ return 'contract' in provider;
23
+ }
24
+ /**
25
+ * A frozen snapshot of a method: each member read once, functions bound to
26
+ * the method, routes and steps copied. The registry validates and keeps the
27
+ * snapshot, so the `type` it checked, the routes it mounts and the members the
28
+ * engine reads cannot change afterwards, whatever getters the method has.
29
+ * Members that are not what the contract asks are copied as they are, for
30
+ * the validation to report.
31
+ *
32
+ * @experimental
33
+ */
34
+ export function authMethodSnapshot(method) {
35
+ const member = method;
36
+ const bound = (value, self) => typeof value === 'function' ? value.bind(self) : value;
37
+ const display = member['display'];
38
+ const steps = member['steps'];
39
+ const routes = member['routes'];
40
+ return Object.freeze({
41
+ contract: member['contract'],
42
+ type: member['type'],
43
+ id: member['id'],
44
+ display: typeof display === 'object' && display !== null ? displaySnapshot(display) : display,
45
+ configSchema: member['configSchema'],
46
+ steps: Array.isArray(steps) ? Object.freeze([...steps]) : steps,
47
+ routes: Array.isArray(routes)
48
+ ? Object.freeze(routes.map((route) => {
49
+ if (typeof route !== 'object' || route === null)
50
+ return route;
51
+ const { kind, method: verb, path, handle, bindingParameter, } = route;
52
+ return Object.freeze({
53
+ kind,
54
+ method: verb,
55
+ path,
56
+ handle: bound(handle, route),
57
+ ...(bindingParameter === undefined ? {} : { bindingParameter }),
58
+ });
59
+ }))
60
+ : routes,
61
+ start: bound(member['start'], method),
62
+ verify: bound(member['verify'], method),
63
+ extractAttributes: bound(member['extractAttributes'], method),
64
+ });
65
+ }
66
+ function displaySnapshot(display) {
67
+ const { name, icon } = display;
68
+ if (typeof icon !== 'object' || icon === null) {
69
+ return Object.freeze(icon === undefined ? { name } : { name, icon });
70
+ }
71
+ const { mediaType, bytes } = icon;
72
+ return Object.freeze({
73
+ name,
74
+ icon: Object.freeze({
75
+ mediaType,
76
+ bytes: bytes instanceof Uint8Array ? new Uint8Array(bytes) : bytes,
77
+ }),
78
+ });
79
+ }
80
+ /**
81
+ * Whether `value` meets the static checks of the contract.
82
+ *
83
+ * @experimental
84
+ */
85
+ export function isAuthMethod(value) {
86
+ return authMethodProblems(value).length === 0;
87
+ }
88
+ /**
89
+ * Throw an {@link AuthMethodContractError} listing every problem when `value`
90
+ * does not meet the static checks of the contract.
91
+ *
92
+ * @experimental
93
+ */
94
+ export function assertAuthMethod(value) {
95
+ const problems = authMethodProblems(value);
96
+ if (problems.length > 0) {
97
+ throw new AuthMethodContractError(describeMethod(value), problems);
98
+ }
99
+ }
100
+ /**
101
+ * Every static problem of `value` as an AuthMethod, one sentence each; empty
102
+ * when there is none.
103
+ *
104
+ * @experimental
105
+ */
106
+ export function authMethodProblems(value) {
107
+ if (typeof value !== 'object' || value === null)
108
+ return ['it is not an object'];
109
+ const method = value;
110
+ const problems = [];
111
+ problems.push(...contractProblems(method['contract']));
112
+ problems.push(...authMethodTypeProblems(method['type']));
113
+ if (typeof method['id'] !== 'string' || !AUTH_METHOD_ID_PATTERN.test(method['id'])) {
114
+ problems.push(`id must match ${AUTH_METHOD_ID_PATTERN.source}`);
115
+ }
116
+ for (const member of ['verify', 'extractAttributes', 'start']) {
117
+ if (typeof method[member] !== 'function')
118
+ problems.push(`${member} must be a function`);
119
+ }
120
+ problems.push(...displayProblems(method['display']));
121
+ const schema = method['configSchema'];
122
+ if (typeof schema !== 'object' ||
123
+ schema === null ||
124
+ typeof schema['safeParse'] !== 'function') {
125
+ problems.push('configSchema must be a Zod schema');
126
+ }
127
+ problems.push(...stepProblems(method['steps']));
128
+ problems.push(...routeProblems(method['routes']));
129
+ return problems;
130
+ }
131
+ /**
132
+ * The problems of a route list; exported for the mount, which checks again.
133
+ *
134
+ * @experimental
135
+ */
136
+ export function routeProblems(routes) {
137
+ if (!Array.isArray(routes))
138
+ return ['routes must be a list'];
139
+ const problems = [];
140
+ const seen = new Set();
141
+ let callbacks = 0;
142
+ routes.forEach((route, index) => {
143
+ const at = `routes[${index}]`;
144
+ if (typeof route !== 'object' || route === null) {
145
+ problems.push(`${at} must be an object`);
146
+ return;
147
+ }
148
+ const entry = route;
149
+ if (entry['kind'] !== 'callback' && entry['kind'] !== 'bounce') {
150
+ problems.push(`${at}.kind must be callback or bounce`);
151
+ }
152
+ if (entry['kind'] === 'callback')
153
+ callbacks += 1;
154
+ if (entry['method'] !== 'GET' && entry['method'] !== 'POST') {
155
+ problems.push(`${at}.method must be GET or POST`);
156
+ }
157
+ const path = entry['path'];
158
+ if (typeof path !== 'string' || !AUTH_METHOD_ROUTE_PATH_PATTERN.test(path)) {
159
+ problems.push(`${at}.path must be one to four lower-case segments, such as /callback: a route cannot leave the method's prefix`);
160
+ }
161
+ else {
162
+ const key = `${String(entry['method'])} ${path}`;
163
+ if (seen.has(key))
164
+ problems.push(`${at} repeats ${key}`);
165
+ seen.add(key);
166
+ }
167
+ if (typeof entry['handle'] !== 'function')
168
+ problems.push(`${at}.handle must be a function`);
169
+ const bindingParameter = entry['bindingParameter'];
170
+ if (bindingParameter !== undefined &&
171
+ (entry['kind'] !== 'callback' ||
172
+ typeof bindingParameter !== 'string' ||
173
+ !AUTH_METHOD_BINDING_PARAMETER_PATTERN.test(bindingParameter))) {
174
+ problems.push(`${at}.bindingParameter must be the name of a parameter (${AUTH_METHOD_BINDING_PARAMETER_PATTERN.source}), on a callback route only`);
175
+ }
176
+ });
177
+ if (callbacks === 0) {
178
+ problems.push('routes must hold at least one callback route: the upstream step returns there');
179
+ }
180
+ return problems;
181
+ }
182
+ function contractProblems(contract) {
183
+ if (contract === AUTH_METHOD_CONTRACT)
184
+ return [];
185
+ if (typeof contract === 'string' && contract.startsWith(`${AUTH_METHOD_CONTRACT_NAME}/`)) {
186
+ return [
187
+ `it is written against the contract ${contract}, and this server implements ${AUTH_METHOD_CONTRACT}`,
188
+ ];
189
+ }
190
+ return [`contract must be ${AUTH_METHOD_CONTRACT}`];
191
+ }
192
+ /**
193
+ * The problems of `type` as an AuthMethod's type: its pattern, and not one of
194
+ * the reserved types.
195
+ *
196
+ * @experimental
197
+ */
198
+ export function authMethodTypeProblems(type) {
199
+ if (typeof type !== 'string' || !AUTH_METHOD_TYPE_PATTERN.test(type)) {
200
+ return [`type must match ${AUTH_METHOD_TYPE_PATTERN.source}`];
201
+ }
202
+ if (RESERVED_AUTH_METHOD_TYPES.includes(type)) {
203
+ return [`type ${type} is reserved for QAuth's own credentials`];
204
+ }
205
+ return [];
206
+ }
207
+ function displayProblems(display) {
208
+ if (typeof display !== 'object' || display === null)
209
+ return ['display must be an object'];
210
+ const { name, icon } = display;
211
+ const problems = [];
212
+ if (typeof name !== 'string' ||
213
+ name.length > MAX_AUTH_METHOD_DISPLAY_NAME_LENGTH ||
214
+ !AUTH_METHOD_DISPLAY_NAME_PATTERN.test(name)) {
215
+ problems.push(`display.name must be at most ${MAX_AUTH_METHOD_DISPLAY_NAME_LENGTH} characters under the display rule: no markup bracket, no control or invisible character, no space at either end`);
216
+ }
217
+ if (icon !== undefined)
218
+ problems.push(...iconProblems(icon));
219
+ return problems;
220
+ }
221
+ function iconProblems(icon) {
222
+ if (typeof icon !== 'object' || icon === null)
223
+ return ['display.icon must be an object'];
224
+ const { mediaType, bytes } = icon;
225
+ const problems = [];
226
+ if (!AUTH_METHOD_ICON_MEDIA_TYPES.includes(mediaType)) {
227
+ problems.push(`display.icon.mediaType must be ${AUTH_METHOD_ICON_MEDIA_TYPES.join(' or ')}`);
228
+ }
229
+ if (!(bytes instanceof Uint8Array)) {
230
+ problems.push('display.icon.bytes must be a Uint8Array');
231
+ }
232
+ else if (bytes.byteLength > MAX_AUTH_METHOD_ICON_BYTES) {
233
+ problems.push(`display.icon.bytes must be at most ${MAX_AUTH_METHOD_ICON_BYTES} bytes`);
234
+ }
235
+ else if (!PNG_SIGNATURE.every((byte, index) => bytes[index] === byte)) {
236
+ problems.push('display.icon.bytes must be a PNG file');
237
+ }
238
+ return problems;
239
+ }
240
+ function stepProblems(steps) {
241
+ if (!Array.isArray(steps) || steps.length === 0) {
242
+ return ['steps must list at least one step'];
243
+ }
244
+ const problems = [];
245
+ const seen = new Set();
246
+ for (const step of steps) {
247
+ if (seen.has(step))
248
+ problems.push(`steps repeats ${String(step)}`);
249
+ seen.add(step);
250
+ if (AUTH_METHOD_STEP_URNS.includes(step))
251
+ continue;
252
+ problems.push(`steps holds ${String(step)}, which is not a step a method may contribute here (${AUTH_METHOD_STEP_URNS.join(', ')})`);
253
+ }
254
+ return problems;
255
+ }
256
+ /** How a candidate is named in an error: its type, when it has a usable one. */
257
+ function describeMethod(value) {
258
+ if (typeof value === 'object' && value !== null) {
259
+ const type = value['type'];
260
+ if (typeof type === 'string' && AUTH_METHOD_TYPE_PATTERN.test(type)) {
261
+ return `The AuthMethod ${type}`;
262
+ }
263
+ }
264
+ return 'An AuthMethod';
265
+ }
@@ -0,0 +1,116 @@
1
+ import type { AuthMethod, AuthMethodEgressRequest, AuthMethodEgressResponse, AuthMethodParameters } from '../lib/auth-method.js';
2
+ /**
3
+ * The contract test kit (#599): one function a method's author calls, in any
4
+ * test runner, to check the method against the AuthMethod contract. It is
5
+ * test support, served from the `@qauth-labs/plugin-sdk/testing` entry point,
6
+ * never from the main one, and it refuses to run under `NODE_ENV=production`: while it runs, it
7
+ * replaces the global `fetch`.
8
+ *
9
+ * ```ts
10
+ * import { checkAuthMethod } from '@qauth-labs/plugin-sdk/testing';
11
+ *
12
+ * it('meets the AuthMethod contract', async () => {
13
+ * await checkAuthMethod(myMethod, {
14
+ * config: { clientId: 'kit', clientSecret: 'kit-secret' },
15
+ * egress: (request) => myFakeProvider.answer(request),
16
+ * callback: (authorizationRequest) => myFakeProvider.signIn(authorizationRequest),
17
+ * });
18
+ * });
19
+ * ```
20
+ *
21
+ * It resolves with the checks it ran, and rejects with an
22
+ * {@link AuthMethodContractError} that lists every check the method failed.
23
+ *
24
+ * EXPERIMENTAL, like the contract it checks.
25
+ *
26
+ * @experimental
27
+ */
28
+ /**
29
+ * A request the provider sends the browser back with, to one of the method's
30
+ * routes.
31
+ *
32
+ * @experimental
33
+ */
34
+ export interface AuthMethodKitRequest {
35
+ /** The route's path below the method's prefix: `/callback`. */
36
+ readonly path: string;
37
+ readonly method: 'GET' | 'POST';
38
+ readonly query?: AuthMethodParameters;
39
+ readonly form?: AuthMethodParameters;
40
+ }
41
+ /**
42
+ * How the provider behaves in the kit's run: the method's author plays it.
43
+ *
44
+ * @experimental
45
+ */
46
+ export interface AuthMethodKitScenario {
47
+ /**
48
+ * A config for one realm, which the method's `configSchema` accepts. Give
49
+ * its `writeOnly` members realistic secrets (a dozen characters or more):
50
+ * the kit looks for every one of them, however short, in every log line,
51
+ * and a secret such as `abc` matches text that is no leak.
52
+ */
53
+ readonly config: unknown;
54
+ /**
55
+ * Answer the method's outbound calls as the provider would. Without it,
56
+ * every call the method makes is refused.
57
+ */
58
+ egress?(request: AuthMethodEgressRequest): AuthMethodEgressResponse | Promise<AuthMethodEgressResponse>;
59
+ /**
60
+ * The request the browser comes back with once the person signed in at the
61
+ * provider, for the authorization request `start()` sent it to. Each call
62
+ * signs the same person in.
63
+ */
64
+ callback(authorizationRequest: URL): AuthMethodKitRequest | Promise<AuthMethodKitRequest>;
65
+ /**
66
+ * The request the browser comes back with when the person declines at the
67
+ * provider (`error=access_denied`). Checked when given.
68
+ */
69
+ declined?(authorizationRequest: URL): AuthMethodKitRequest | Promise<AuthMethodKitRequest>;
70
+ }
71
+ /**
72
+ * What a passing run checked, in order.
73
+ *
74
+ * @experimental
75
+ */
76
+ export interface AuthMethodKitReport {
77
+ readonly checks: readonly string[];
78
+ }
79
+ /**
80
+ * Check `method` against the AuthMethod contract.
81
+ *
82
+ * Static checks first: the contract version, `type`, `id`, the display data,
83
+ * the config schema (a Zod object schema that renders as JSON Schema and
84
+ * accepts `scenario.config`), the steps and the routes (each inside the
85
+ * method's prefix). Then a run against a fake realm, with the global `fetch`
86
+ * disabled:
87
+ *
88
+ * 1. `start()` answers an `https` redirect with no credentials and no fragment,
89
+ * a transaction of JSON data within its cap and a binding of 22 to 512
90
+ * base64url characters; two starts answer different transactions.
91
+ * 2. Two starts answer different bindings, and each sends its binding to the
92
+ * provider, in the redirect or in a body it posted.
93
+ * 3. The provider's callback carries the binding back in the callback
94
+ * route's `bindingParameter` (`state` by default): the kit, like the host,
95
+ * hands the handler the transaction only when it does. It ends in an
96
+ * identity whose `rawClaims.iss` names its issuer and whose attributes
97
+ * carry the method's `type`; a second sign-in of the same person resolves
98
+ * the same `externalSub`.
99
+ * 4. When the authorization request asks for a code (`response_type=code`,
100
+ * in the redirect or a body the method posted), it carries
101
+ * `code_challenge_method=S256` and a `code_challenge` (RFC 7636; QAuth
102
+ * requires PKCE with S256), and every token request the callback makes
103
+ * (`grant_type=authorization_code`) carries the `code_verifier` whose S256
104
+ * is that challenge.
105
+ * 5. `verify()` rejects inputs that no callback produced, a well-formed
106
+ * identity, a copy of a real result and a result already used among them.
107
+ * 6. When the scenario gives one, the declined callback ends as `declined`.
108
+ * 7. The method called the global `fetch` nowhere, and no log line carries
109
+ * the value of a config member marked `writeOnly` (a secret), in a field,
110
+ * a message or an error.
111
+ *
112
+ * @throws AuthMethodContractError listing every failed check
113
+ * @throws Error when `NODE_ENV` is `production`
114
+ * @experimental
115
+ */
116
+ export declare function checkAuthMethod(method: AuthMethod, scenario: AuthMethodKitScenario): Promise<AuthMethodKitReport>;