@o3co/create-auth-provider 0.14.0 → 0.16.0
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/README.ja.md +93 -47
- package/README.md +92 -46
- package/package.json +1 -1
- package/templates/standalone/.env.example +34 -0
- package/templates/standalone/Dockerfile +1 -1
- package/templates/standalone/README.ja.md +565 -130
- package/templates/standalone/README.md +356 -92
- package/templates/standalone/config/application.conf +77 -2
- package/templates/standalone/docker-compose.production.yml +7 -0
- package/templates/standalone/docker-compose.yml +7 -0
- package/templates/standalone/gitignore +3 -2
- package/templates/standalone/package.json +3 -2
- package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +49 -15
- package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +1103 -0
- package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +241 -0
- package/templates/standalone/src/__tests__/all-modules-composition.test.mts +1100 -0
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +40 -5
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +445 -0
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +446 -0
- package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +133 -0
- package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +85 -0
- package/templates/standalone/src/__tests__/google-session.fixture.mts +304 -0
- package/templates/standalone/src/__tests__/host-routes.test.mts +141 -0
- package/templates/standalone/src/__tests__/listen.test.mts +128 -0
- package/templates/standalone/src/__tests__/logger-projection.test.mts +77 -0
- package/templates/standalone/src/__tests__/metrics.test.mts +20 -5
- package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +187 -0
- package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +139 -0
- package/templates/standalone/src/__tests__/replica-safety.test.mts +13 -3
- package/templates/standalone/src/__tests__/router-error-answers.test.mts +149 -0
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +14 -0
- package/templates/standalone/src/__tests__/shutdown.test.mts +253 -22
- package/templates/standalone/src/__tests__/smoke.test.mts +39 -20
- package/templates/standalone/src/__tests__/store-credential.test.mts +276 -0
- package/templates/standalone/src/app.mts +30 -51
- package/templates/standalone/src/buildModules.mts +94 -12
- package/templates/standalone/src/listen.mts +66 -0
- package/templates/standalone/src/logger.mts +7 -4
- package/templates/standalone/src/modules.mts +118 -12
- package/templates/standalone/src/routes.mts +95 -0
- package/templates/standalone/src/shutdown.mts +90 -18
- package/templates/standalone/vitest.supertest-loopback.mts +22 -3
- package/templates/versions.json +15 -14
- package/templates/standalone/src/__tests__/terminal-error.test.mts +0 -96
- package/templates/standalone/src/terminalError.mts +0 -60
|
@@ -0,0 +1,1103 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 1o1 Co. Ltd.
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The composition `all-modules-composition*.test.mts` boot: every module this
|
|
19
|
+
* template can turn on, switched on together from the shipped HOCON, and
|
|
20
|
+
* mounted the way `app.mts` mounts it.
|
|
21
|
+
*
|
|
22
|
+
* What is real: the configuration (`config/*.conf` over core's
|
|
23
|
+
* `reference.conf`, resolved with the environment a deployment would export),
|
|
24
|
+
* `buildModules`, core's `createApp`, every module and store it selects, the
|
|
25
|
+
* key store, the audit sink, `helmet` and the terminal error handler.
|
|
26
|
+
*
|
|
27
|
+
* What is substituted, and why:
|
|
28
|
+
*
|
|
29
|
+
* - The client and user repositories — `repositoriesModule` reads YAML files
|
|
30
|
+
* off disk; the in-memory repositories carry the registrations the flows
|
|
31
|
+
* need. `buildModules` offers this override for the purpose.
|
|
32
|
+
* - The refresh-token family store under `deployment.mode = "single"` — the
|
|
33
|
+
* shipped composition always puts it on Redis; the memory store keeps the
|
|
34
|
+
* single-replica boot free of sockets. `buildModules` offers this override.
|
|
35
|
+
* - The upstream identity providers — core's fake OpenID Provider behind a
|
|
36
|
+
* `fetch`, handed to the Google and OIDC adapters through the `fetch` option
|
|
37
|
+
* each adapter takes for exactly this. The config bridges' values are read
|
|
38
|
+
* through the template's own bridge (`googleFederationConfigModule`) and the
|
|
39
|
+
* OIDC package's reader, and only `fetch` is added.
|
|
40
|
+
* - Configuration with no environment form (a map of grant connections, a key
|
|
41
|
+
* ring, a landing URL): laid over the resolved config, as an operator would
|
|
42
|
+
* write it in `application.conf`.
|
|
43
|
+
*
|
|
44
|
+
* `tools/composition` in the monorepo imports this file and boots the same
|
|
45
|
+
* composition with the workspace's other modules added (`ComposeOptions`'s
|
|
46
|
+
* `referenceConfs`, `extraModules`, `extraOverrides`, `extraClients` and
|
|
47
|
+
* `extraUsers`), so the two suites share one fixture rather than two copies
|
|
48
|
+
* that drift.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
import { createHash, generateKeyPairSync, randomBytes } from "node:crypto";
|
|
52
|
+
import { readFileSync } from "node:fs";
|
|
53
|
+
import http from "node:http";
|
|
54
|
+
import type { AddressInfo } from "node:net";
|
|
55
|
+
import { fileURLToPath } from "node:url";
|
|
56
|
+
import {
|
|
57
|
+
type AppConfig,
|
|
58
|
+
AppConfigSchema,
|
|
59
|
+
ClientEntrySchema,
|
|
60
|
+
createApp,
|
|
61
|
+
defineModule,
|
|
62
|
+
InMemoryClientRepository,
|
|
63
|
+
InMemoryUserRepository,
|
|
64
|
+
type Logger,
|
|
65
|
+
type Module,
|
|
66
|
+
memoryRefreshTokenFamilyStoreModule,
|
|
67
|
+
terminalErrorHandler,
|
|
68
|
+
} from "@o3co/auth-provider-core";
|
|
69
|
+
import { createFakeIdp, type FakeIdp } from "@o3co/auth-provider-core/testing";
|
|
70
|
+
import { parseFile } from "@o3co/ts.hocon";
|
|
71
|
+
import { validate } from "@o3co/ts.hocon/zod";
|
|
72
|
+
import express from "express";
|
|
73
|
+
import helmet from "helmet";
|
|
74
|
+
import request from "supertest";
|
|
75
|
+
import { beforeAll, describe, expect, it } from "vitest";
|
|
76
|
+
import { buildModules } from "#/buildModules.mjs";
|
|
77
|
+
import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "#/configPath.mjs";
|
|
78
|
+
import { googleFederationConfigModule, oidcFederationConfigModule } from "#/modules.mjs";
|
|
79
|
+
|
|
80
|
+
export const ISSUER = "https://auth.test";
|
|
81
|
+
const OIDC_ISSUER = "https://idp.test";
|
|
82
|
+
/** Where a federated login lands the browser when the start carried no `redirect_to`. */
|
|
83
|
+
export const FEDERATION_LANDING = "https://app.test/home";
|
|
84
|
+
|
|
85
|
+
const signingKey = generateKeyPairSync("ed25519", {
|
|
86
|
+
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
87
|
+
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
|
88
|
+
});
|
|
89
|
+
/** 32 bytes, base64 — the shape every at-rest encryption key here takes. */
|
|
90
|
+
const ENCRYPTION_KEY = Buffer.alloc(32, 7).toString("base64");
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* One replica, every store in memory, every feature the template can switch
|
|
94
|
+
* on switched on: the four token grants, the consent step with Client ID
|
|
95
|
+
* Metadata Documents, federation grants, the Google federation and the
|
|
96
|
+
* shipped generic OIDC one.
|
|
97
|
+
*/
|
|
98
|
+
export const SINGLE_ENV: Readonly<Record<string, string>> = {
|
|
99
|
+
OAUTH_JWT_ISSUER: ISSUER,
|
|
100
|
+
// The shipped default algorithm (EdDSA), with the key pair inline.
|
|
101
|
+
OAUTH_JWT_PRIVATE_KEY: signingKey.privateKey,
|
|
102
|
+
OAUTH_JWT_PUBLIC_KEY: signingKey.publicKey,
|
|
103
|
+
SESSION_SECRET: "all-modules-composition-session.at-least-32-bytes.ok",
|
|
104
|
+
SESSION_SECURE: "false",
|
|
105
|
+
SESSION_NAME: "auth.session",
|
|
106
|
+
DEPLOYMENT_MODE: "single",
|
|
107
|
+
SESSION_STORAGE_TYPE: "memory",
|
|
108
|
+
USER_SESSION_STORES_ADAPTER: "memory",
|
|
109
|
+
RATE_LIMITER_ADAPTER: "memory",
|
|
110
|
+
OAUTH_CODE_ADAPTER: "memory",
|
|
111
|
+
ACCESS_TOKEN_DENYLIST_ADAPTER: "memory",
|
|
112
|
+
REPLAY_SEEN_SET_ADAPTER: "memory",
|
|
113
|
+
FEDERATION_TOKEN_STORE_TYPE: "memory",
|
|
114
|
+
CONSENT_STORE_ADAPTER: "memory",
|
|
115
|
+
FEDERATION_GRANT_STORE_ADAPTER: "memory",
|
|
116
|
+
FEDERATION_GRANT_INTENT_STORE_ADAPTER: "memory",
|
|
117
|
+
// The user repository is replaced (see the header); `yaml` keeps the
|
|
118
|
+
// shipped `http` adapter's URL requirements out of config validation.
|
|
119
|
+
CLIENT_USER_TYPE: "yaml",
|
|
120
|
+
OAUTH_GRANTS_SESSION_ENABLED: "true",
|
|
121
|
+
OAUTH_GRANTS_AUTHORIZATION_CODE_ENABLED: "true",
|
|
122
|
+
OAUTH_GRANTS_REFRESH_TOKEN_ENABLED: "true",
|
|
123
|
+
OAUTH_GRANTS_CLIENT_CREDENTIALS_ENABLED: "true",
|
|
124
|
+
OAUTH_CIMD_ENABLED: "true",
|
|
125
|
+
FEDERATION_GRANTS_ENABLED: "true",
|
|
126
|
+
FEDERATION_GRANTS_CONSENT_URL: "/consent/grants",
|
|
127
|
+
// The in-memory user repository covers no registration; `required` is a
|
|
128
|
+
// Store's statement that it does (see the federation-grants README).
|
|
129
|
+
FEDERATION_GRANTS_IDENTITY_LOOKUP: "unsupported",
|
|
130
|
+
FEDERATIONS_GOOGLE_ENABLED: "true",
|
|
131
|
+
FEDERATIONS_GOOGLE_CLIENT_ID: "google-client",
|
|
132
|
+
FEDERATIONS_GOOGLE_CLIENT_SECRET: "google-secret",
|
|
133
|
+
FEDERATIONS_OIDC_ENABLED: "true",
|
|
134
|
+
FEDERATIONS_OIDC_ISSUER: OIDC_ISSUER,
|
|
135
|
+
FEDERATIONS_OIDC_CLIENT_ID: "oidc-client",
|
|
136
|
+
FEDERATIONS_OIDC_CLIENT_SECRET: "oidc-secret",
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The same deployment on more than one replica: every shared store on Redis,
|
|
141
|
+
* express-session's own included (the umbrella E2E's shape).
|
|
142
|
+
*/
|
|
143
|
+
export const MULTI_ENV: Readonly<Record<string, string>> = {
|
|
144
|
+
...SINGLE_ENV,
|
|
145
|
+
DEPLOYMENT_MODE: "multi",
|
|
146
|
+
SESSION_STORAGE_TYPE: "redis",
|
|
147
|
+
SESSION_STORAGE_REDIS_URL: "redis://redis.test:6379",
|
|
148
|
+
REFRESH_TOKEN_FAMILY_STORE_REDIS_URL: "redis://redis.test:6379",
|
|
149
|
+
USER_SESSION_STORES_ADAPTER: "redis",
|
|
150
|
+
RATE_LIMITER_ADAPTER: "redis",
|
|
151
|
+
OAUTH_CODE_ADAPTER: "redis",
|
|
152
|
+
ACCESS_TOKEN_DENYLIST_ADAPTER: "redis",
|
|
153
|
+
REPLAY_SEEN_SET_ADAPTER: "redis",
|
|
154
|
+
FEDERATION_TOKEN_STORE_TYPE: "redis",
|
|
155
|
+
REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY: ENCRYPTION_KEY,
|
|
156
|
+
CONSENT_STORE_ADAPTER: "redis",
|
|
157
|
+
FEDERATION_GRANT_STORE_ADAPTER: "redis",
|
|
158
|
+
FEDERATION_GRANT_INTENT_STORE_ADAPTER: "redis",
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/** The grant connection the federation-grant flows use, on the shipped `oidc` federation. */
|
|
162
|
+
export const CONNECTION = "calendar";
|
|
163
|
+
|
|
164
|
+
const configDir = fileURLToPath(new URL("../../config", import.meta.url));
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The shipped HOCON under `env`, with what has no environment form laid over
|
|
168
|
+
* it: the federations' landing page, the grant key ring and one grant
|
|
169
|
+
* connection. `referenceConfs` — other packages' `reference.conf` files — are
|
|
170
|
+
* layered between `application.conf` and core's, as a deployment layers them.
|
|
171
|
+
*/
|
|
172
|
+
export function resolveConfig(
|
|
173
|
+
env: Readonly<Record<string, string>>,
|
|
174
|
+
referenceConfs: readonly string[] = [],
|
|
175
|
+
): AppConfig {
|
|
176
|
+
const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
|
|
177
|
+
const read = (path: string) => parseFile(path, { env: { ...env } });
|
|
178
|
+
const layered = [
|
|
179
|
+
applicationConfPath,
|
|
180
|
+
...referenceConfs,
|
|
181
|
+
resolveLibraryReferenceConfPath(),
|
|
182
|
+
].reduce((config, path) => config.withFallback(read(path)), read(envConfPath));
|
|
183
|
+
const resolved = validate(layered, AppConfigSchema);
|
|
184
|
+
const federations = resolved.federations as Record<string, Record<string, unknown>>;
|
|
185
|
+
return {
|
|
186
|
+
...resolved,
|
|
187
|
+
federations: {
|
|
188
|
+
...federations,
|
|
189
|
+
google: { ...federations.google, clientUrl: FEDERATION_LANDING },
|
|
190
|
+
oidc: { ...federations.oidc, clientUrl: FEDERATION_LANDING },
|
|
191
|
+
},
|
|
192
|
+
federationGrants: {
|
|
193
|
+
...resolved.federationGrants,
|
|
194
|
+
encryptionKeys: [{ id: "k-test", key: ENCRYPTION_KEY }],
|
|
195
|
+
connections: {
|
|
196
|
+
[CONNECTION]: {
|
|
197
|
+
federation: "oidc",
|
|
198
|
+
scopes: ["openid", "offline_access", "calendar.read"],
|
|
199
|
+
boundary: "production",
|
|
200
|
+
maxAccessTokenLifetime: 3600,
|
|
201
|
+
callbackURL: `${ISSUER}/session/federation-grants/callback/${CONNECTION}`,
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
} as unknown as AppConfig;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
// Registrations
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
export const WEB = { id: "web", secret: "web-secret", redirectUri: "https://rp.test/cb" } as const;
|
|
213
|
+
export const M2M = { id: "m2m", secret: "m2m-secret" } as const;
|
|
214
|
+
export const WORKER = {
|
|
215
|
+
id: "worker",
|
|
216
|
+
secret: "worker-secret",
|
|
217
|
+
redirectUri: "https://worker.test/connected",
|
|
218
|
+
} as const;
|
|
219
|
+
/** Not first-party: `/authorize` sends its user through the consent step. */
|
|
220
|
+
export const THIRD = {
|
|
221
|
+
id: "third",
|
|
222
|
+
secret: "third-secret",
|
|
223
|
+
redirectUri: "https://third.test/cb",
|
|
224
|
+
} as const;
|
|
225
|
+
|
|
226
|
+
export const ALICE = { username: "alice", password: "correct-horse-battery", sub: "u-alice" };
|
|
227
|
+
/** The subjects the fake IdPs sign in, as the Store has them linked. */
|
|
228
|
+
const OIDC_SUB = "idp-sub";
|
|
229
|
+
const GOOGLE_SUB = "google-sub";
|
|
230
|
+
|
|
231
|
+
type ClientEntry = ReturnType<typeof ClientEntrySchema.parse>;
|
|
232
|
+
|
|
233
|
+
const clientEntries = (
|
|
234
|
+
extra: Readonly<Record<string, Record<string, unknown>>> = {},
|
|
235
|
+
): Map<string, ClientEntry> =>
|
|
236
|
+
new Map<string, ClientEntry>([
|
|
237
|
+
...Object.entries(extra).map(
|
|
238
|
+
([id, entry]) => [id, ClientEntrySchema.parse(entry)] as [string, ClientEntry],
|
|
239
|
+
),
|
|
240
|
+
[
|
|
241
|
+
WEB.id,
|
|
242
|
+
ClientEntrySchema.parse({
|
|
243
|
+
tokenEndpointAuthMethod: "client_secret_basic",
|
|
244
|
+
clientSecret: WEB.secret,
|
|
245
|
+
allowedRedirectUris: [WEB.redirectUri],
|
|
246
|
+
allowedScopes: ["openid", "profile", "email", "offline_access"],
|
|
247
|
+
allowedGrantTypes: ["authorization_code", "refresh_token", "session"],
|
|
248
|
+
firstParty: true,
|
|
249
|
+
}),
|
|
250
|
+
],
|
|
251
|
+
[
|
|
252
|
+
M2M.id,
|
|
253
|
+
ClientEntrySchema.parse({
|
|
254
|
+
tokenEndpointAuthMethod: "client_secret_basic",
|
|
255
|
+
clientSecret: M2M.secret,
|
|
256
|
+
allowedScopes: ["api.read"],
|
|
257
|
+
allowedGrantTypes: ["client_credentials"],
|
|
258
|
+
}),
|
|
259
|
+
],
|
|
260
|
+
[
|
|
261
|
+
WORKER.id,
|
|
262
|
+
ClientEntrySchema.parse({
|
|
263
|
+
tokenEndpointAuthMethod: "client_secret_basic",
|
|
264
|
+
clientSecret: WORKER.secret,
|
|
265
|
+
allowedScopes: ["openid"],
|
|
266
|
+
allowedGrantTypes: [],
|
|
267
|
+
allowedFederationGrantConnections: [CONNECTION],
|
|
268
|
+
federationGrantRedirectUris: [WORKER.redirectUri],
|
|
269
|
+
}),
|
|
270
|
+
],
|
|
271
|
+
[
|
|
272
|
+
THIRD.id,
|
|
273
|
+
ClientEntrySchema.parse({
|
|
274
|
+
tokenEndpointAuthMethod: "client_secret_basic",
|
|
275
|
+
clientSecret: THIRD.secret,
|
|
276
|
+
allowedRedirectUris: [THIRD.redirectUri],
|
|
277
|
+
allowedScopes: ["openid"],
|
|
278
|
+
allowedGrantTypes: ["authorization_code"],
|
|
279
|
+
}),
|
|
280
|
+
],
|
|
281
|
+
]);
|
|
282
|
+
|
|
283
|
+
const testRepositoriesModule = (
|
|
284
|
+
extraClients: Readonly<Record<string, Record<string, unknown>>> = {},
|
|
285
|
+
extraUsers: Readonly<Record<string, Record<string, unknown>>> = {},
|
|
286
|
+
): Module =>
|
|
287
|
+
defineModule({
|
|
288
|
+
name: "test:repositories",
|
|
289
|
+
provides: {
|
|
290
|
+
clientRepository: () => new InMemoryClientRepository(clientEntries(extraClients)),
|
|
291
|
+
userRepository: () =>
|
|
292
|
+
new InMemoryUserRepository(
|
|
293
|
+
new Map([
|
|
294
|
+
[
|
|
295
|
+
ALICE.username,
|
|
296
|
+
{
|
|
297
|
+
id: ALICE.sub,
|
|
298
|
+
password: ALICE.password,
|
|
299
|
+
email: "alice@example.com",
|
|
300
|
+
token: `oidc:${OIDC_SUB}`,
|
|
301
|
+
},
|
|
302
|
+
],
|
|
303
|
+
["bob", { id: "u-bob", password: "bob-password-long", token: `google:${GOOGLE_SUB}` }],
|
|
304
|
+
...Object.entries(extraUsers),
|
|
305
|
+
]) as ConstructorParameters<typeof InMemoryUserRepository>[0],
|
|
306
|
+
),
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
// Upstream identity providers
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
export interface Upstreams {
|
|
315
|
+
readonly oidc: FakeIdp;
|
|
316
|
+
readonly google: FakeIdp;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
let upstreams: Promise<{ fakes: Upstreams; reset: () => void }> | undefined;
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* The two fake upstreams, made once per test file — each generates an RSA key,
|
|
323
|
+
* and a login's state lives in the authorization it recorded, so boots can
|
|
324
|
+
* share them — and put back as they were made before every boot (`compose`
|
|
325
|
+
* calls this). A test may set a fake's knobs for its own boot; it may not
|
|
326
|
+
* rotate a fake's key, which cannot be put back, and the next boot refuses to
|
|
327
|
+
* run if one did.
|
|
328
|
+
*/
|
|
329
|
+
export async function sharedUpstreams(): Promise<Upstreams> {
|
|
330
|
+
upstreams ??= createUpstreams().then((fakes) => ({
|
|
331
|
+
fakes,
|
|
332
|
+
reset: resettable(fakes.oidc, fakes.google),
|
|
333
|
+
}));
|
|
334
|
+
const { fakes, reset } = await upstreams;
|
|
335
|
+
reset();
|
|
336
|
+
return fakes;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Snapshots each fake's settings — every property that is not a function —
|
|
341
|
+
* and returns what puts them back: primitives reassigned, objects restored in
|
|
342
|
+
* place (a fake may hold its own reference to one), recorded requests
|
|
343
|
+
* cleared. A fake's signing key is checked, not restored: a rotated key is
|
|
344
|
+
* refused.
|
|
345
|
+
*
|
|
346
|
+
* What a fake keeps in its closure is not restored either: core's fake IdP
|
|
347
|
+
* remembers whether consent was granted (`consentGranted`), how many codes it
|
|
348
|
+
* issued (`codesIssued`) and every authorization it recorded. Codes and
|
|
349
|
+
* authorizations are keyed per login, so a later boot's login is unaffected;
|
|
350
|
+
* consent is not, and it decides Google's refresh token only under
|
|
351
|
+
* `refreshTokenOnlyOnConsent`. That knob is therefore forbidden on a shared
|
|
352
|
+
* fake — the next reset refuses to run if a test set it; a test that needs it
|
|
353
|
+
* makes a fake of its own with `createFakeIdp`.
|
|
354
|
+
*/
|
|
355
|
+
export function resettable(...fakes: readonly object[]): () => void {
|
|
356
|
+
const snapshots = fakes.map((fake) => ({
|
|
357
|
+
fake: fake as Record<string, unknown>,
|
|
358
|
+
clone: new Map(
|
|
359
|
+
Object.entries(fake)
|
|
360
|
+
.filter(([name, value]) => typeof value !== "function" && name !== "requests")
|
|
361
|
+
.map(([name, value]) => [name, structuredClone(value)]),
|
|
362
|
+
),
|
|
363
|
+
kid: currentKidOf(fake),
|
|
364
|
+
}));
|
|
365
|
+
return () => {
|
|
366
|
+
for (const { fake, clone, kid } of snapshots) {
|
|
367
|
+
if (fake.refreshTokenOnlyOnConsent === true) {
|
|
368
|
+
throw new Error(
|
|
369
|
+
"a test set refreshTokenOnlyOnConsent on a shared fake upstream: the consent it reads is closure state no reset restores; make a fake of its own instead",
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
if (currentKidOf(fake) !== kid) {
|
|
373
|
+
throw new Error(
|
|
374
|
+
"a test rotated a shared fake upstream's key: the fakes are shared by every boot in the file; make a fake of its own instead",
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
for (const [name, original] of clone) {
|
|
378
|
+
const current = fake[name];
|
|
379
|
+
if (typeof current === "object" && current !== null && !Array.isArray(current)) {
|
|
380
|
+
for (const key of Object.keys(current)) delete (current as Record<string, unknown>)[key];
|
|
381
|
+
Object.assign(current, structuredClone(original));
|
|
382
|
+
} else {
|
|
383
|
+
fake[name] = structuredClone(original);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
const requests = fake.requests;
|
|
387
|
+
if (Array.isArray(requests)) requests.splice(0);
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const currentKidOf = (fake: object): string | undefined => {
|
|
393
|
+
const currentKid = (fake as { currentKid?: unknown }).currentKid;
|
|
394
|
+
return typeof currentKid === "function" ? (currentKid as () => string)() : undefined;
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
async function createUpstreams(): Promise<Upstreams> {
|
|
398
|
+
return {
|
|
399
|
+
oidc: await createFakeIdp({
|
|
400
|
+
issuer: OIDC_ISSUER,
|
|
401
|
+
discovery: true,
|
|
402
|
+
clientId: SINGLE_ENV.FEDERATIONS_OIDC_CLIENT_ID,
|
|
403
|
+
sub: OIDC_SUB,
|
|
404
|
+
}),
|
|
405
|
+
// Google's endpoints are fixed in the adapter, not discovered.
|
|
406
|
+
google: await createFakeIdp({
|
|
407
|
+
issuer: "https://accounts.google.com",
|
|
408
|
+
authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
409
|
+
tokenEndpoint: "https://oauth2.googleapis.com/token",
|
|
410
|
+
jwksUri: "https://www.googleapis.com/oauth2/v3/certs",
|
|
411
|
+
userinfoEndpoint: "https://www.googleapis.com/oauth2/v3/userinfo",
|
|
412
|
+
clientId: SINGLE_ENV.FEDERATIONS_GOOGLE_CLIENT_ID,
|
|
413
|
+
sub: GOOGLE_SUB,
|
|
414
|
+
}),
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* What one of the template's config bridges provides for `config`. Read
|
|
420
|
+
* through a record, not the typed slot: a program that loads this file without
|
|
421
|
+
* a federation package's ComponentMap augmentation (`tools/composition` does)
|
|
422
|
+
* has no key to name.
|
|
423
|
+
*/
|
|
424
|
+
const bridged = <T,>(module: Module, slot: string, config: AppConfig): T => {
|
|
425
|
+
const provider = (module.provides as Record<string, unknown> | undefined)?.[slot];
|
|
426
|
+
if (typeof provider !== "function") throw new Error(`${module.name} provides no ${slot}`);
|
|
427
|
+
return (provider as (deps: { config: AppConfig }) => T)({ config });
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* The two federation config slots, read by the template's own bridges
|
|
432
|
+
* (`oidcFederationConfigModule`, `googleFederationConfigModule`), with each
|
|
433
|
+
* adapter's `fetch` pointed at its fake upstream. Only for the federations the
|
|
434
|
+
* config enables — which is when `buildModules` lists the bridges.
|
|
435
|
+
*/
|
|
436
|
+
async function federationOverrides(
|
|
437
|
+
config: AppConfig,
|
|
438
|
+
upstreams: Upstreams,
|
|
439
|
+
): Promise<Record<string, unknown>> {
|
|
440
|
+
const overrides: Record<string, unknown> = {};
|
|
441
|
+
const modules = buildModules(config).map((m) => m.name);
|
|
442
|
+
if (modules.includes(oidcFederationConfigModule.name)) {
|
|
443
|
+
const oidc = bridged<Record<string, object>>(
|
|
444
|
+
oidcFederationConfigModule,
|
|
445
|
+
"oidcFederationConfigs",
|
|
446
|
+
config,
|
|
447
|
+
);
|
|
448
|
+
overrides.oidcFederationConfigs = Object.fromEntries(
|
|
449
|
+
Object.entries(oidc).map(([name, entry]) => [
|
|
450
|
+
name,
|
|
451
|
+
{ ...entry, fetch: upstreams.oidc.fetch },
|
|
452
|
+
]),
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
if (modules.includes(googleFederationConfigModule.name)) {
|
|
456
|
+
overrides.googleFederationConfig = {
|
|
457
|
+
...bridged<object>(googleFederationConfigModule, "googleFederationConfig", config),
|
|
458
|
+
fetch: upstreams.google.fetch,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
return overrides;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ---------------------------------------------------------------------------
|
|
465
|
+
// Logger spy
|
|
466
|
+
// ---------------------------------------------------------------------------
|
|
467
|
+
|
|
468
|
+
export interface LogLine {
|
|
469
|
+
readonly level: "trace" | "debug" | "info" | "warn" | "error" | "fatal";
|
|
470
|
+
readonly args: readonly unknown[];
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export interface RecordingLogger extends Logger {
|
|
474
|
+
readonly lines: LogLine[];
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** A `Logger` that records every call, its children's included, in one list. */
|
|
478
|
+
export function createRecordingLogger(): RecordingLogger {
|
|
479
|
+
const lines: LogLine[] = [];
|
|
480
|
+
const make = (bindings: Record<string, unknown>): Logger => {
|
|
481
|
+
const at =
|
|
482
|
+
(level: LogLine["level"]) =>
|
|
483
|
+
(...args: unknown[]): void => {
|
|
484
|
+
const [first, ...rest] = args;
|
|
485
|
+
lines.push({
|
|
486
|
+
level,
|
|
487
|
+
args:
|
|
488
|
+
typeof first === "object" && first !== null && Object.keys(bindings).length > 0
|
|
489
|
+
? [{ ...bindings, ...first }, ...rest]
|
|
490
|
+
: args,
|
|
491
|
+
});
|
|
492
|
+
};
|
|
493
|
+
return {
|
|
494
|
+
trace: at("trace"),
|
|
495
|
+
debug: at("debug"),
|
|
496
|
+
info: at("info"),
|
|
497
|
+
warn: at("warn"),
|
|
498
|
+
error: at("error"),
|
|
499
|
+
fatal: at("fatal"),
|
|
500
|
+
child: (more) => make({ ...bindings, ...more }),
|
|
501
|
+
} as Logger;
|
|
502
|
+
};
|
|
503
|
+
return Object.assign(make({}), { lines }) as RecordingLogger;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// ---------------------------------------------------------------------------
|
|
507
|
+
// Composition
|
|
508
|
+
// ---------------------------------------------------------------------------
|
|
509
|
+
|
|
510
|
+
/** A switch an outage wrapper reads on every call. */
|
|
511
|
+
export interface Outage {
|
|
512
|
+
down: boolean;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** What a store adapter's client throws when its connection is refused. */
|
|
516
|
+
const connectionRefused = (): Error =>
|
|
517
|
+
Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:6379"), { code: "ECONNREFUSED" });
|
|
518
|
+
|
|
519
|
+
/** Every method of `target` rejects while `outage.down`, and is the real one otherwise. */
|
|
520
|
+
function failWhileDown<T extends object>(target: T, outage: Outage): T {
|
|
521
|
+
return new Proxy(target, {
|
|
522
|
+
get(object, property) {
|
|
523
|
+
const value = Reflect.get(object, property, object);
|
|
524
|
+
if (typeof value !== "function") return value;
|
|
525
|
+
return (...args: unknown[]): unknown =>
|
|
526
|
+
outage.down
|
|
527
|
+
? Promise.reject(connectionRefused())
|
|
528
|
+
: (value as (...a: unknown[]) => unknown).apply(object, args);
|
|
529
|
+
},
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* `modules` with the provider of `slot` wrapped by {@link failWhileDown}: the
|
|
535
|
+
* real module, named and declared as it is, handing out the real store.
|
|
536
|
+
*/
|
|
537
|
+
function withOutage(modules: readonly Module[], slot: string, outage: Outage): Module[] {
|
|
538
|
+
let found = false;
|
|
539
|
+
const wrapped = modules.map((module) => {
|
|
540
|
+
const provides = module.provides as
|
|
541
|
+
| Record<string, (deps: unknown) => unknown | Promise<unknown>>
|
|
542
|
+
| undefined;
|
|
543
|
+
const provider = provides?.[slot];
|
|
544
|
+
if (provider === undefined) return module;
|
|
545
|
+
found = true;
|
|
546
|
+
return {
|
|
547
|
+
...module,
|
|
548
|
+
provides: {
|
|
549
|
+
...provides,
|
|
550
|
+
[slot]: async (deps: unknown) => failWhileDown((await provider(deps)) as object, outage),
|
|
551
|
+
},
|
|
552
|
+
} as Module;
|
|
553
|
+
});
|
|
554
|
+
if (!found) throw new Error(`no module in the composition provides ${slot}`);
|
|
555
|
+
return wrapped;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** The order `buildModules` lists the modules in. */
|
|
559
|
+
export const AS_LISTED = "as listed";
|
|
560
|
+
/**
|
|
561
|
+
* Every module after the first in reverse. The first, express-session's
|
|
562
|
+
* middleware, is placed by list position alone (`buildModules` says so), so
|
|
563
|
+
* it stays first; everything else changes place.
|
|
564
|
+
*/
|
|
565
|
+
export const REVERSED = "reversed";
|
|
566
|
+
export type ModuleOrder = typeof AS_LISTED | typeof REVERSED;
|
|
567
|
+
|
|
568
|
+
export interface ComposeOptions {
|
|
569
|
+
readonly env?: Readonly<Record<string, string>>;
|
|
570
|
+
/** Other packages' `reference.conf` files, layered above core's (see `resolveConfig`). */
|
|
571
|
+
readonly referenceConfs?: readonly string[];
|
|
572
|
+
/** Modules added after the template's own, before the order and the outage apply. */
|
|
573
|
+
readonly extraModules?: (config: AppConfig) => readonly Module[];
|
|
574
|
+
/** Components laid over the boot's, beside the federation config slots. */
|
|
575
|
+
readonly extraOverrides?: (config: AppConfig) => Record<string, unknown>;
|
|
576
|
+
/** Client registrations beside the fixture's own, as `ClientEntrySchema` input. */
|
|
577
|
+
readonly extraClients?: Readonly<Record<string, Record<string, unknown>>>;
|
|
578
|
+
/** Users beside the fixture's own, keyed by username. */
|
|
579
|
+
readonly extraUsers?: Readonly<Record<string, Record<string, unknown>>>;
|
|
580
|
+
/** Adjust the resolved config before anything reads it. */
|
|
581
|
+
readonly config?: (config: AppConfig) => AppConfig;
|
|
582
|
+
readonly order?: ModuleOrder;
|
|
583
|
+
readonly outage?: { readonly slot: string; readonly outage: Outage };
|
|
584
|
+
/** Keep the shipped Redis refresh-token family store (the `multi` boot). */
|
|
585
|
+
readonly shippedRefreshTokenFamilyStore?: boolean;
|
|
586
|
+
/**
|
|
587
|
+
* Mount core's terminal error handler after the composed router again, as
|
|
588
|
+
* `app.mts` does for its host routes (the default). `false` mounts the
|
|
589
|
+
* router alone, as a composition root that copies nothing of `app.mts`
|
|
590
|
+
* does.
|
|
591
|
+
*/
|
|
592
|
+
readonly terminalErrorHandler?: boolean;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** The module list the template boots for `config`, as `app.mts` builds it. */
|
|
596
|
+
export function composedModules(config: AppConfig, options: ComposeOptions = {}): Module[] {
|
|
597
|
+
let modules = [
|
|
598
|
+
...buildModules(config, {
|
|
599
|
+
environment: "production",
|
|
600
|
+
repositoriesModule: testRepositoriesModule(options.extraClients, options.extraUsers),
|
|
601
|
+
...(options.shippedRefreshTokenFamilyStore
|
|
602
|
+
? {}
|
|
603
|
+
: { refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule] }),
|
|
604
|
+
}),
|
|
605
|
+
...(options.extraModules?.(config) ?? []),
|
|
606
|
+
];
|
|
607
|
+
if (options.outage) modules = withOutage(modules, options.outage.slot, options.outage.outage);
|
|
608
|
+
if (options.order === REVERSED) modules = [modules[0], ...modules.slice(1).reverse()];
|
|
609
|
+
return modules;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
export interface Composition {
|
|
613
|
+
readonly app: express.Express;
|
|
614
|
+
readonly handle: Awaited<ReturnType<typeof createApp>>;
|
|
615
|
+
readonly config: AppConfig;
|
|
616
|
+
readonly modules: readonly Module[];
|
|
617
|
+
readonly logger: RecordingLogger;
|
|
618
|
+
readonly upstreams: Upstreams;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Boots the composition and mounts it as `app.mts` does: `helmet`, the
|
|
623
|
+
* composed router, and the terminal error handler last (unless
|
|
624
|
+
* `terminalErrorHandler` is `false`), all on one logger that is also the
|
|
625
|
+
* boot's `logger` component.
|
|
626
|
+
*/
|
|
627
|
+
export async function compose(options: ComposeOptions = {}): Promise<Composition> {
|
|
628
|
+
const base = resolveConfig(options.env ?? SINGLE_ENV, options.referenceConfs);
|
|
629
|
+
const config = options.config ? options.config(base) : base;
|
|
630
|
+
const fakes = await sharedUpstreams();
|
|
631
|
+
const modules = composedModules(config, options);
|
|
632
|
+
const logger = createRecordingLogger();
|
|
633
|
+
const handle = await createApp({
|
|
634
|
+
modules,
|
|
635
|
+
bootstrapComponents: { config, pathResolver: (s) => s, logger },
|
|
636
|
+
overrideComponents: {
|
|
637
|
+
...(await federationOverrides(config, fakes)),
|
|
638
|
+
...options.extraOverrides?.(config),
|
|
639
|
+
} as never,
|
|
640
|
+
});
|
|
641
|
+
const app = express();
|
|
642
|
+
app.set("trust proxy", config.http.trustProxy);
|
|
643
|
+
app.use(
|
|
644
|
+
helmet({
|
|
645
|
+
contentSecurityPolicy: { directives: { defaultSrc: ["'none'"], frameAncestors: ["'none'"] } },
|
|
646
|
+
}),
|
|
647
|
+
);
|
|
648
|
+
app.use(handle.router);
|
|
649
|
+
if (options.terminalErrorHandler !== false) app.use(terminalErrorHandler(logger));
|
|
650
|
+
return { app, handle, config, modules, logger, upstreams: fakes };
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// ---------------------------------------------------------------------------
|
|
654
|
+
// Flows
|
|
655
|
+
// ---------------------------------------------------------------------------
|
|
656
|
+
|
|
657
|
+
export const basic = (client: { id: string; secret: string }): string =>
|
|
658
|
+
`Basic ${Buffer.from(`${client.id}:${client.secret}`).toString("base64")}`;
|
|
659
|
+
|
|
660
|
+
const cookiesOf = (res: request.Response): string[] =>
|
|
661
|
+
(res.headers["set-cookie"] as unknown as string[] | undefined) ?? [];
|
|
662
|
+
|
|
663
|
+
/** The browser half: a CSRF pair, then the password login. */
|
|
664
|
+
export async function login(
|
|
665
|
+
app: express.Express,
|
|
666
|
+
): Promise<{ readonly res: request.Response; readonly cookies: string[] }> {
|
|
667
|
+
const csrf = await request(app).get("/session/csrf");
|
|
668
|
+
const res = await request(app)
|
|
669
|
+
.post("/session/login")
|
|
670
|
+
.set("Cookie", cookiesOf(csrf))
|
|
671
|
+
.set(csrf.body.header_name as string, csrf.body.csrf_token as string)
|
|
672
|
+
.type("form")
|
|
673
|
+
.send({ username: ALICE.username, password: ALICE.password });
|
|
674
|
+
return { res, cookies: res.status === 200 ? cookiesOf(res) : cookiesOf(csrf) };
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
export const PKCE = (() => {
|
|
678
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
679
|
+
return { verifier, challenge: createHash("sha256").update(verifier).digest("base64url") };
|
|
680
|
+
})();
|
|
681
|
+
|
|
682
|
+
export function authorize(
|
|
683
|
+
app: express.Express,
|
|
684
|
+
cookies: readonly string[],
|
|
685
|
+
client: { id: string; redirectUri: string } = WEB,
|
|
686
|
+
): request.Test {
|
|
687
|
+
return request(app)
|
|
688
|
+
.get("/oauth/authorize")
|
|
689
|
+
.set("Cookie", [...cookies])
|
|
690
|
+
.query({
|
|
691
|
+
response_type: "code",
|
|
692
|
+
client_id: client.id,
|
|
693
|
+
redirect_uri: client.redirectUri,
|
|
694
|
+
scope: client === WEB ? "openid profile offline_access" : "openid",
|
|
695
|
+
state: "af0ifjsldkj",
|
|
696
|
+
nonce: "n-0S6_WzA2Mj",
|
|
697
|
+
code_challenge: PKCE.challenge,
|
|
698
|
+
code_challenge_method: "S256",
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
export const codeFrom = (res: request.Response): string => {
|
|
703
|
+
const code = new URL(res.headers.location as string).searchParams.get("code");
|
|
704
|
+
if (code === null) throw new Error(`no code in ${res.status} ${res.headers.location}`);
|
|
705
|
+
return code;
|
|
706
|
+
};
|
|
707
|
+
|
|
708
|
+
export function redeem(app: express.Express, code: string): request.Test {
|
|
709
|
+
return request(app).post("/oauth/token").set("Authorization", basic(WEB)).type("form").send({
|
|
710
|
+
grant_type: "authorization_code",
|
|
711
|
+
code,
|
|
712
|
+
redirect_uri: WEB.redirectUri,
|
|
713
|
+
code_verifier: PKCE.verifier,
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/** Log in, authorize with PKCE, redeem: the web client's tokens. */
|
|
718
|
+
export async function webTokens(app: express.Express): Promise<Record<string, string>> {
|
|
719
|
+
const { cookies } = await login(app);
|
|
720
|
+
const res = await redeem(app, codeFrom(await authorize(app, cookies)));
|
|
721
|
+
if (res.status !== 200) throw new Error(`code exchange answered ${res.status}`);
|
|
722
|
+
return res.body as Record<string, string>;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Start a federated login and play the upstream. What comes back sends the
|
|
727
|
+
* callback when called — a function, because a supertest request is a
|
|
728
|
+
* thenable, and returning one from an async function would send it.
|
|
729
|
+
*/
|
|
730
|
+
export async function federatedCallback(
|
|
731
|
+
app: express.Express,
|
|
732
|
+
name: "oidc" | "google",
|
|
733
|
+
upstream: FakeIdp,
|
|
734
|
+
): Promise<() => request.Test> {
|
|
735
|
+
const start = await request(app).get(`/session/oauth/federation/${name}`);
|
|
736
|
+
if (start.status !== 302) throw new Error(`federation start answered ${start.status}`);
|
|
737
|
+
const answer = upstream.authorize(start.headers.location as string);
|
|
738
|
+
return () =>
|
|
739
|
+
request(app)
|
|
740
|
+
.get(`/session/oauth/federation/${name}/callback`)
|
|
741
|
+
.set("Cookie", cookiesOf(start))
|
|
742
|
+
.query({ code: answer.code, state: answer.state ?? "", iss: answer.iss });
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
export function lodgeGrant(app: express.Express): request.Test {
|
|
746
|
+
return request(app).post("/oauth/federation-grants").set("Authorization", basic(WORKER)).send({
|
|
747
|
+
connection: CONNECTION,
|
|
748
|
+
sub: ALICE.sub,
|
|
749
|
+
redirect_uri: WORKER.redirectUri,
|
|
750
|
+
state: "worker-state",
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
export { cookiesOf };
|
|
755
|
+
|
|
756
|
+
// ---------------------------------------------------------------------------
|
|
757
|
+
// Discovery
|
|
758
|
+
// ---------------------------------------------------------------------------
|
|
759
|
+
|
|
760
|
+
export const DISCOVERY_PATHS = [
|
|
761
|
+
"/.well-known/openid-configuration",
|
|
762
|
+
"/.well-known/oauth-authorization-server",
|
|
763
|
+
] as const;
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* What RFC 8414 §2 and OpenID Connect Discovery §3 require of the document
|
|
767
|
+
* this composition serves, and what each advertised URL must be: https, on
|
|
768
|
+
* the issuer's origin.
|
|
769
|
+
*/
|
|
770
|
+
export function expectValidMetadata(doc: Record<string, unknown>): void {
|
|
771
|
+
expect(doc.issuer).toBe(ISSUER);
|
|
772
|
+
for (const field of [
|
|
773
|
+
"authorization_endpoint",
|
|
774
|
+
"token_endpoint",
|
|
775
|
+
"jwks_uri",
|
|
776
|
+
"response_types_supported",
|
|
777
|
+
"subject_types_supported",
|
|
778
|
+
"id_token_signing_alg_values_supported",
|
|
779
|
+
]) {
|
|
780
|
+
expect(doc[field], field).toBeDefined();
|
|
781
|
+
}
|
|
782
|
+
for (const [field, value] of Object.entries(doc)) {
|
|
783
|
+
if (field.endsWith("_endpoint") || field === "jwks_uri") {
|
|
784
|
+
const url = new URL(value as string);
|
|
785
|
+
expect(url.origin, field).toBe(ISSUER);
|
|
786
|
+
expect(url.search + url.hash, field).toBe("");
|
|
787
|
+
}
|
|
788
|
+
if (field.endsWith("_supported") && Array.isArray(value)) {
|
|
789
|
+
expect(value.length, field).toBeGreaterThan(0);
|
|
790
|
+
for (const entry of value) expect(typeof entry, field).toBe("string");
|
|
791
|
+
expect(new Set(value).size, `${field} repeats a value`).toBe(value.length);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
expect(doc.response_types_supported).toEqual(["code"]);
|
|
795
|
+
expect(doc.code_challenge_methods_supported).toEqual(["S256"]);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// ---------------------------------------------------------------------------
|
|
799
|
+
// Known defects
|
|
800
|
+
// ---------------------------------------------------------------------------
|
|
801
|
+
|
|
802
|
+
const templateManifest = JSON.parse(
|
|
803
|
+
readFileSync(new URL("../../package.json", import.meta.url), "utf8"),
|
|
804
|
+
) as { dependencies: Record<string, string> };
|
|
805
|
+
|
|
806
|
+
/** The template's `@o3co/auth-provider-*` dependencies, as its `package.json` names them. */
|
|
807
|
+
export const TEMPLATE_DEPENDENCIES: readonly string[] = Object.keys(
|
|
808
|
+
templateManifest.dependencies,
|
|
809
|
+
).filter((name) => name.startsWith("@o3co/auth-provider-"));
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Inside the monorepo the template names its siblings `workspace:*`; a
|
|
813
|
+
* scaffold, and CI's packed-tarball run, name versions or tarballs.
|
|
814
|
+
*/
|
|
815
|
+
export const inMonorepo =
|
|
816
|
+
templateManifest.dependencies["@o3co/auth-provider-core"]?.startsWith("workspace:") === true;
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* A contract the composition breaks today. `it.fails` in the monorepo, where
|
|
820
|
+
* the fix lands beside this file and flips it; skipped in a scaffold, which
|
|
821
|
+
* pins released packages and would otherwise turn red on the upgrade that
|
|
822
|
+
* carries the fix.
|
|
823
|
+
*/
|
|
824
|
+
export const knownDefect = (inMonorepo ? it.fails : it.skip) as unknown as typeof it;
|
|
825
|
+
|
|
826
|
+
// ---------------------------------------------------------------------------
|
|
827
|
+
// Manifests
|
|
828
|
+
// ---------------------------------------------------------------------------
|
|
829
|
+
|
|
830
|
+
/** The names a module contributes under `kind`: its own name for a list-shaped kind. */
|
|
831
|
+
export const contributionNames = (module: Module, kind: string): string[] => {
|
|
832
|
+
const contribution = (module.contributes as Record<string, unknown> | undefined)?.[kind];
|
|
833
|
+
if (contribution === undefined) return [];
|
|
834
|
+
return Array.isArray(contribution) ? [module.name] : Object.keys(contribution as object);
|
|
835
|
+
};
|
|
836
|
+
|
|
837
|
+
// ---------------------------------------------------------------------------
|
|
838
|
+
// Bodies
|
|
839
|
+
// ---------------------------------------------------------------------------
|
|
840
|
+
|
|
841
|
+
export const KIB = 1024;
|
|
842
|
+
export const JSON_TYPE = "application/json";
|
|
843
|
+
export const FORM_TYPE = "application/x-www-form-urlencoded";
|
|
844
|
+
|
|
845
|
+
export type Send = (
|
|
846
|
+
app: express.Express,
|
|
847
|
+
path: string,
|
|
848
|
+
contentType: string,
|
|
849
|
+
body: string,
|
|
850
|
+
headers?: Record<string, string>,
|
|
851
|
+
) => Promise<{ status: number; body: Record<string, unknown> }>;
|
|
852
|
+
|
|
853
|
+
/** A POST with its `Content-Length` declared, through supertest. */
|
|
854
|
+
export const withLength: Send = async (app, path, contentType, body, headers = {}) => {
|
|
855
|
+
const res = await request(app)
|
|
856
|
+
.post(path)
|
|
857
|
+
.set(headers)
|
|
858
|
+
.set("Content-Type", contentType)
|
|
859
|
+
.send(body);
|
|
860
|
+
return { status: res.status, body: res.body as Record<string, unknown> };
|
|
861
|
+
};
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* A POST whose body has no `Content-Length` — `Transfer-Encoding: chunked`,
|
|
865
|
+
* one KiB a chunk — so only a parser's own running count can bound it.
|
|
866
|
+
* supertest always sets the length, hence a raw request on a real socket.
|
|
867
|
+
*/
|
|
868
|
+
export const postChunked: Send = async (app, path, contentType, body, headers = {}) => {
|
|
869
|
+
const server = http.createServer(app);
|
|
870
|
+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
871
|
+
try {
|
|
872
|
+
const { port } = server.address() as AddressInfo;
|
|
873
|
+
return await new Promise((resolve, reject) => {
|
|
874
|
+
let answered = false;
|
|
875
|
+
const req = http.request(
|
|
876
|
+
{
|
|
877
|
+
host: "127.0.0.1",
|
|
878
|
+
port,
|
|
879
|
+
path,
|
|
880
|
+
method: "POST",
|
|
881
|
+
// One connection per request, closed after it: nothing keeps the server open.
|
|
882
|
+
agent: false,
|
|
883
|
+
headers: { ...headers, "content-type": contentType, "transfer-encoding": "chunked" },
|
|
884
|
+
},
|
|
885
|
+
(res) => {
|
|
886
|
+
answered = true;
|
|
887
|
+
let text = "";
|
|
888
|
+
res.setEncoding("utf8");
|
|
889
|
+
res.on("data", (chunk: string) => {
|
|
890
|
+
text += chunk;
|
|
891
|
+
});
|
|
892
|
+
res.on("end", () => {
|
|
893
|
+
let parsed: Record<string, unknown> = {};
|
|
894
|
+
try {
|
|
895
|
+
parsed = JSON.parse(text) as Record<string, unknown>;
|
|
896
|
+
} catch {
|
|
897
|
+
// Not JSON: the status carries the verdict.
|
|
898
|
+
}
|
|
899
|
+
resolve({ status: res.statusCode ?? 0, body: parsed });
|
|
900
|
+
});
|
|
901
|
+
},
|
|
902
|
+
);
|
|
903
|
+
// A server that answers 413 mid-body may close the socket while the
|
|
904
|
+
// rest is still being written; that is the answer, not a failure.
|
|
905
|
+
req.on("error", (err) => {
|
|
906
|
+
if (!answered) reject(err);
|
|
907
|
+
});
|
|
908
|
+
for (let at = 0; at < body.length; at += KIB) req.write(body.slice(at, at + KIB));
|
|
909
|
+
req.end();
|
|
910
|
+
});
|
|
911
|
+
} finally {
|
|
912
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
913
|
+
}
|
|
914
|
+
};
|
|
915
|
+
|
|
916
|
+
export const TRANSFERS: ReadonlyArray<readonly [string, Send]> = [
|
|
917
|
+
["with Content-Length", withLength],
|
|
918
|
+
["chunked", postChunked],
|
|
919
|
+
];
|
|
920
|
+
|
|
921
|
+
/** A JSON body: `fields`, and `bytes` of padding. */
|
|
922
|
+
export const padJson = (bytes: number, fields: Record<string, unknown> = {}): string =>
|
|
923
|
+
JSON.stringify({ ...fields, pad: "a".repeat(bytes) });
|
|
924
|
+
|
|
925
|
+
/** A form body: `fields`, and `bytes` of padding. */
|
|
926
|
+
export const padForm = (bytes: number, fields: string): string =>
|
|
927
|
+
`${fields}&pad=${"a".repeat(bytes)}`;
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* What a parser's refusal of an oversized body is answered with on a route
|
|
931
|
+
* of the composed router — by core's terminal handler, which ends it.
|
|
932
|
+
*/
|
|
933
|
+
export const TOO_LARGE = { error: "invalid_request", error_description: "body_too_large" };
|
|
934
|
+
|
|
935
|
+
// ---------------------------------------------------------------------------
|
|
936
|
+
// Outages
|
|
937
|
+
// ---------------------------------------------------------------------------
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* The parts of the #685 outage rule, each checked on its own so a case pins
|
|
941
|
+
* only what is broken and asserts the rest:
|
|
942
|
+
*
|
|
943
|
+
* - `answer` — the status and error code (a redirect's location and code);
|
|
944
|
+
* - `one-error-line` — exactly one error-level line, object-first with a name;
|
|
945
|
+
* - `event-name` — that name (checked only where the case names one);
|
|
946
|
+
* - `store-field` — a `store`, `step` or `site` field naming what failed;
|
|
947
|
+
* - `projection` — its `err` is core's `loggableError` projection;
|
|
948
|
+
* - `no-warn` — no warn line for the outage beside it.
|
|
949
|
+
*/
|
|
950
|
+
export type OutagePredicate =
|
|
951
|
+
| "answer"
|
|
952
|
+
| "one-error-line"
|
|
953
|
+
| "event-name"
|
|
954
|
+
| "store-field"
|
|
955
|
+
| "projection"
|
|
956
|
+
| "no-warn";
|
|
957
|
+
|
|
958
|
+
export interface OutageCase<C extends Composition = Composition> {
|
|
959
|
+
/** The module whose route answers. */
|
|
960
|
+
readonly module: string;
|
|
961
|
+
/** The ComponentMap slot whose store goes down. */
|
|
962
|
+
readonly slot: string;
|
|
963
|
+
readonly surface: string;
|
|
964
|
+
/** Drives the route, taking the store down at the step under test. */
|
|
965
|
+
readonly run: (app: express.Express, outage: Outage, c: C) => Promise<request.Response>;
|
|
966
|
+
readonly answer:
|
|
967
|
+
| { readonly status: number; readonly error?: string }
|
|
968
|
+
| { readonly redirect: string; readonly error: string };
|
|
969
|
+
/**
|
|
970
|
+
* The one line's name. Absent where the composition writes no such line
|
|
971
|
+
* today — and then `event-name` is not checked at all, so the fix that
|
|
972
|
+
* flips such a row adds its `event` in the same change.
|
|
973
|
+
*/
|
|
974
|
+
readonly event?: string;
|
|
975
|
+
/** Warn lines this route writes whatever the store does — not the outage's. */
|
|
976
|
+
readonly unrelatedWarns?: readonly string[];
|
|
977
|
+
/**
|
|
978
|
+
* Why `store-field` does not apply: the line is not an outage's 503, the
|
|
979
|
+
* request succeeds. Absent, the predicate is checked.
|
|
980
|
+
*/
|
|
981
|
+
readonly storeFieldNotRequired?: string;
|
|
982
|
+
/** The predicates the composition breaks today, each naming its defect. */
|
|
983
|
+
readonly defects?: Partial<Record<OutagePredicate, string>>;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/** The same defect text for every predicate that depends on the one missing line. */
|
|
987
|
+
export const withoutTheLine = (
|
|
988
|
+
defect: string,
|
|
989
|
+
): Pick<Record<OutagePredicate, string>, "one-error-line" | "store-field" | "projection"> => ({
|
|
990
|
+
"one-error-line": defect,
|
|
991
|
+
"store-field": `no error line to carry it: ${defect}`,
|
|
992
|
+
projection: `no error line to carry it: ${defect}`,
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
/** The object argument of a log line, when the line is object-first. */
|
|
996
|
+
export const fieldsOf = (line: LogLine | undefined): Record<string, unknown> =>
|
|
997
|
+
typeof line?.args[0] === "object" && line.args[0] !== null
|
|
998
|
+
? (line.args[0] as Record<string, unknown>)
|
|
999
|
+
: {};
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* One `describe` per case: the case's composition is booted and its flow run
|
|
1003
|
+
* once, the store taken down at the step under test, and each predicate is its
|
|
1004
|
+
* own test — a plain `it` where the composition keeps it, `knownDefect` where
|
|
1005
|
+
* it does not.
|
|
1006
|
+
*/
|
|
1007
|
+
export function describeOutages<C extends Composition>(
|
|
1008
|
+
title: string,
|
|
1009
|
+
cases: readonly OutageCase<C>[],
|
|
1010
|
+
boot: (outage: { readonly slot: string; readonly outage: Outage }) => Promise<C>,
|
|
1011
|
+
): void {
|
|
1012
|
+
describe(title, () => {
|
|
1013
|
+
for (const c of cases) {
|
|
1014
|
+
describe(`${c.module}: ${c.slot} down at ${c.surface}`, () => {
|
|
1015
|
+
let res: request.Response;
|
|
1016
|
+
let lines: LogLine[] = [];
|
|
1017
|
+
let tookDown = false;
|
|
1018
|
+
|
|
1019
|
+
beforeAll(async () => {
|
|
1020
|
+
let composition: C | undefined;
|
|
1021
|
+
let from = -1;
|
|
1022
|
+
let down = false;
|
|
1023
|
+
const outage: Outage = {
|
|
1024
|
+
get down() {
|
|
1025
|
+
return down;
|
|
1026
|
+
},
|
|
1027
|
+
set down(value: boolean) {
|
|
1028
|
+
if (value && from < 0) from = composition?.logger.lines.length ?? 0;
|
|
1029
|
+
down = value;
|
|
1030
|
+
},
|
|
1031
|
+
};
|
|
1032
|
+
composition = await boot({ slot: c.slot, outage });
|
|
1033
|
+
try {
|
|
1034
|
+
res = await c.run(composition.app, outage, composition);
|
|
1035
|
+
tookDown = from >= 0;
|
|
1036
|
+
// Everything logged from the moment the store went down is the outage's.
|
|
1037
|
+
lines = composition.logger.lines.slice(Math.max(from, 0));
|
|
1038
|
+
} finally {
|
|
1039
|
+
await composition.handle.dispose();
|
|
1040
|
+
}
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
const errors = () => lines.filter((line) => line.level === "error");
|
|
1044
|
+
const check = (predicate: OutagePredicate, name: string, assertion: () => void) => {
|
|
1045
|
+
const defect = c.defects?.[predicate];
|
|
1046
|
+
(defect === undefined ? it : knownDefect)(name, assertion);
|
|
1047
|
+
};
|
|
1048
|
+
|
|
1049
|
+
// Plain whatever the case pins: a pinned predicate must fail for its
|
|
1050
|
+
// defect, not because the flow never reached the step under test.
|
|
1051
|
+
it("reaches the step under test and takes the store down there", () => {
|
|
1052
|
+
expect(tookDown).toBe(true);
|
|
1053
|
+
});
|
|
1054
|
+
check("answer", "answers as an outage", () => {
|
|
1055
|
+
if ("redirect" in c.answer) {
|
|
1056
|
+
expect(res.status).toBe(302);
|
|
1057
|
+
const location = new URL(res.headers.location as string);
|
|
1058
|
+
expect(`${location.origin}${location.pathname}`).toBe(c.answer.redirect);
|
|
1059
|
+
expect(location.searchParams.get("error")).toBe(c.answer.error);
|
|
1060
|
+
} else {
|
|
1061
|
+
expect(res.status).toBe(c.answer.status);
|
|
1062
|
+
expect(res.body.error).toBe(c.answer.error);
|
|
1063
|
+
expect(res.headers["www-authenticate"]).toBeUndefined();
|
|
1064
|
+
}
|
|
1065
|
+
});
|
|
1066
|
+
check("one-error-line", "logs exactly one error line, object-first with a name", () => {
|
|
1067
|
+
expect(
|
|
1068
|
+
errors().map((line) => line.args[1] ?? line.args[0]),
|
|
1069
|
+
"exactly one error line",
|
|
1070
|
+
).toHaveLength(1);
|
|
1071
|
+
expect(typeof errors()[0]?.args[0]).toBe("object");
|
|
1072
|
+
expect(typeof errors()[0]?.args[1]).toBe("string");
|
|
1073
|
+
});
|
|
1074
|
+
if (c.event !== undefined) {
|
|
1075
|
+
const event = c.event;
|
|
1076
|
+
check("event-name", `names it ${event}`, () => {
|
|
1077
|
+
expect(errors().map((line) => line.args[1])).toEqual([event]);
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
if (c.storeFieldNotRequired === undefined) {
|
|
1081
|
+
check("store-field", "names what failed (store, step or site)", () => {
|
|
1082
|
+
const fields = fieldsOf(errors()[0]);
|
|
1083
|
+
expect(
|
|
1084
|
+
["store", "step", "site"].filter((field) => typeof fields[field] === "string"),
|
|
1085
|
+
).not.toEqual([]);
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1088
|
+
check("projection", "carries the error's projection", () => {
|
|
1089
|
+
const err = fieldsOf(errors()[0]).err;
|
|
1090
|
+
expect(err).toMatchObject({ name: expect.any(String) });
|
|
1091
|
+
expect(err).not.toBeInstanceOf(Error);
|
|
1092
|
+
});
|
|
1093
|
+
check("no-warn", "writes no warn line for it", () => {
|
|
1094
|
+
const warns = lines
|
|
1095
|
+
.filter((line) => line.level === "warn")
|
|
1096
|
+
.map((line) => (typeof line.args[1] === "string" ? line.args[1] : String(line.args[0])))
|
|
1097
|
+
.filter((event) => !(c.unrelatedWarns ?? []).includes(event));
|
|
1098
|
+
expect(warns).toEqual([]);
|
|
1099
|
+
});
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
});
|
|
1103
|
+
}
|