@o3co/create-auth-provider 0.16.0 → 0.17.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +66 -21
- package/README.md +76 -31
- package/dist/index.d.mts +33 -10
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +118 -59
- package/dist/internal/template-filter.d.mts +7 -10
- package/dist/internal/template-filter.d.mts.map +1 -1
- package/dist/internal/template-filter.mjs +7 -10
- package/package.json +6 -4
- package/templates/standalone/.dockerignore +15 -2
- package/templates/standalone/.env.example +59 -26
- package/templates/standalone/Dockerfile +6 -2
- package/templates/standalone/README.ja.md +293 -162
- package/templates/standalone/README.md +533 -199
- package/templates/standalone/config/application.conf +196 -341
- package/templates/standalone/config/clients.yaml +2 -0
- package/templates/standalone/config/clients.yaml.example +4 -0
- package/templates/standalone/config/development.conf +13 -4
- package/templates/standalone/config/mailpit.conf +8 -0
- package/templates/standalone/config/production.conf +5 -6
- package/templates/standalone/config/reference.conf +399 -0
- package/templates/standalone/docker-compose.mailpit.yml +36 -0
- package/templates/standalone/docker-compose.production.yml +58 -30
- package/templates/standalone/docker-compose.yml +13 -3
- package/templates/standalone/gitignore +1 -0
- package/templates/standalone/package.json +9 -6
- package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +19 -20
- package/templates/standalone/src/__tests__/adapters.test.mts +358 -0
- package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +216 -141
- package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +48 -33
- package/templates/standalone/src/__tests__/all-modules-composition.test.mts +77 -58
- package/templates/standalone/src/__tests__/audit-sink.test.mts +78 -76
- package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
- package/templates/standalone/src/__tests__/core-federations.test.mts +226 -0
- package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
- package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +9 -14
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +656 -269
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +425 -84
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +91 -70
- package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +7 -5
- package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +22 -19
- package/templates/standalone/src/__tests__/google-session.fixture.mts +53 -42
- package/templates/standalone/src/__tests__/host-routes.test.mts +4 -11
- package/templates/standalone/src/__tests__/library-references.fixture.mts +183 -0
- package/templates/standalone/src/__tests__/listen.test.mts +6 -13
- package/templates/standalone/src/__tests__/logger-projection.test.mts +1 -1
- package/templates/standalone/src/__tests__/mail-sender.test.mts +134 -0
- package/templates/standalone/src/__tests__/mailpit-compose.test.mts +328 -0
- package/templates/standalone/src/__tests__/metrics.test.mts +4 -9
- package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
- package/templates/standalone/src/__tests__/mfa-switch.test.mts +780 -0
- package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +52 -46
- package/templates/standalone/src/__tests__/own-modules.test.mts +1457 -0
- package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +7 -11
- package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +1 -5
- package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +112 -81
- package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
- package/templates/standalone/src/__tests__/replica-safety.test.mts +153 -142
- package/templates/standalone/src/__tests__/router-error-answers.test.mts +4 -7
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +275 -95
- package/templates/standalone/src/__tests__/sections.test.mts +480 -0
- package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +67 -55
- package/templates/standalone/src/__tests__/session-requirements.test.mts +299 -0
- package/templates/standalone/src/__tests__/shutdown.test.mts +163 -96
- package/templates/standalone/src/__tests__/smoke.test.mts +179 -252
- package/templates/standalone/src/__tests__/store-credential.test.mts +91 -50
- package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +108 -95
- package/templates/standalone/src/__tests__/two-phase-config.test.mts +438 -0
- package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +390 -0
- package/templates/standalone/src/adapters.mts +149 -0
- package/templates/standalone/src/app.mts +64 -79
- package/templates/standalone/src/buildModules.mts +212 -251
- package/templates/standalone/src/configPath.mts +422 -29
- package/templates/standalone/src/listen.mts +12 -18
- package/templates/standalone/src/logger.mts +31 -60
- package/templates/standalone/src/metrics.mts +24 -47
- package/templates/standalone/src/mfaSwitch.mts +262 -0
- package/templates/standalone/src/modules.mts +458 -476
- package/templates/standalone/src/rootRenames.mts +79 -0
- package/templates/standalone/src/routes.mts +8 -14
- package/templates/standalone/src/sections.mts +366 -0
- package/templates/standalone/src/shutdown.mts +45 -123
- package/templates/standalone/tests/index.test.js +2 -2
- package/templates/standalone/tests/package-lock.json +1615 -2223
- package/templates/standalone/tests/package.json +2 -2
- package/templates/standalone/tests/vitest.config.mjs +26 -0
- package/templates/standalone/vitest.config.mts +8 -7
- package/templates/standalone/vitest.supertest-loopback.mts +97 -69
- package/templates/versions.json +18 -15
|
@@ -19,43 +19,38 @@
|
|
|
19
19
|
* template can turn on, switched on together from the shipped HOCON, and
|
|
20
20
|
* mounted the way `app.mts` mounts it.
|
|
21
21
|
*
|
|
22
|
-
*
|
|
23
|
-
* `reference.conf`,
|
|
24
|
-
* `
|
|
25
|
-
*
|
|
22
|
+
* Real: the configuration (`config/*.conf` over every loaded package's
|
|
23
|
+
* `reference.conf`, core's last, under the environment a deployment would
|
|
24
|
+
* export) read in `app.mts`'s two phases (`readSwitches`, then
|
|
25
|
+
* `resolveForBoot` for `createApp`), `buildModules`, core's `createApp`, every
|
|
26
|
+
* module and store it selects, the key store, the audit sink, `helmet` and the
|
|
27
|
+
* terminal error handler.
|
|
26
28
|
*
|
|
27
|
-
*
|
|
29
|
+
* Substituted (the first two are overrides `buildModules` offers):
|
|
30
|
+
* - client and user repositories: in memory, not YAML off disk;
|
|
31
|
+
* - the refresh-token family store under `core.deployment.mode = "single"`: memory,
|
|
32
|
+
* not the shipped Redis, so the single-replica boot opens no sockets;
|
|
33
|
+
* - upstream identity providers: core's fake OpenID Provider, through the
|
|
34
|
+
* `fetch` option the Google and OIDC adapters take; the config bridges'
|
|
35
|
+
* values are read as shipped (`googleFederationConfigModule`, the OIDC
|
|
36
|
+
* package's reader), with only `fetch` added;
|
|
37
|
+
* - configuration with no environment form (grant connections, a key ring, a
|
|
38
|
+
* landing URL): one more HOCON layer above the shipped ones.
|
|
28
39
|
*
|
|
29
|
-
*
|
|
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
|
|
40
|
+
* `tools/composition` in the monorepo imports this file to boot the same
|
|
45
41
|
* composition with the workspace's other modules added (`ComposeOptions`'s
|
|
46
|
-
* `
|
|
47
|
-
* `extraUsers`), so the two suites share one fixture rather than two copies
|
|
48
|
-
* that drift.
|
|
42
|
+
* `extra*` options), so the two suites share one fixture.
|
|
49
43
|
*/
|
|
50
44
|
|
|
51
45
|
import { createHash, generateKeyPairSync, randomBytes } from "node:crypto";
|
|
52
|
-
import { readFileSync } from "node:fs";
|
|
46
|
+
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
53
47
|
import http from "node:http";
|
|
54
48
|
import type { AddressInfo } from "node:net";
|
|
49
|
+
import { tmpdir } from "node:os";
|
|
50
|
+
import { join } from "node:path";
|
|
55
51
|
import { fileURLToPath } from "node:url";
|
|
56
52
|
import {
|
|
57
53
|
type AppConfig,
|
|
58
|
-
AppConfigSchema,
|
|
59
54
|
ClientEntrySchema,
|
|
60
55
|
createApp,
|
|
61
56
|
defineModule,
|
|
@@ -67,14 +62,19 @@ import {
|
|
|
67
62
|
terminalErrorHandler,
|
|
68
63
|
} from "@o3co/auth-provider-core";
|
|
69
64
|
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
65
|
import express from "express";
|
|
73
66
|
import helmet from "helmet";
|
|
74
67
|
import request from "supertest";
|
|
75
68
|
import { beforeAll, describe, expect, it } from "vitest";
|
|
76
69
|
import { buildModules } from "#/buildModules.mjs";
|
|
77
|
-
import {
|
|
70
|
+
import {
|
|
71
|
+
type OwnLayers,
|
|
72
|
+
readOwnLayers,
|
|
73
|
+
readSwitches,
|
|
74
|
+
resolveConfigPaths,
|
|
75
|
+
resolveForBoot,
|
|
76
|
+
type Switches,
|
|
77
|
+
} from "#/configPath.mjs";
|
|
78
78
|
import { googleFederationConfigModule, oidcFederationConfigModule } from "#/modules.mjs";
|
|
79
79
|
|
|
80
80
|
export const ISSUER = "https://auth.test";
|
|
@@ -93,47 +93,49 @@ const ENCRYPTION_KEY = Buffer.alloc(32, 7).toString("base64");
|
|
|
93
93
|
* One replica, every store in memory, every feature the template can switch
|
|
94
94
|
* on switched on: the four token grants, the consent step with Client ID
|
|
95
95
|
* Metadata Documents, federation grants, the Google federation and the
|
|
96
|
-
* shipped generic OIDC one.
|
|
96
|
+
* shipped generic OIDC one. MFA, on by default, is switched off: the suites
|
|
97
|
+
* about MFA turn it on with `MFA_MODE`.
|
|
97
98
|
*/
|
|
98
99
|
export const SINGLE_ENV: Readonly<Record<string, string>> = {
|
|
100
|
+
MFA_MODE: "off",
|
|
99
101
|
OAUTH_JWT_ISSUER: ISSUER,
|
|
100
102
|
// The shipped default algorithm (EdDSA), with the key pair inline.
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
103
|
+
KEY_STORE_LOCAL_PRIVATE_KEY: signingKey.privateKey,
|
|
104
|
+
KEY_STORE_LOCAL_PUBLIC_KEY: signingKey.publicKey,
|
|
105
|
+
SESSION_STORE_SECRET: "all-modules-composition-session.at-least-32-bytes.ok",
|
|
106
|
+
SESSION_STORE_SECURE: "false",
|
|
107
|
+
SESSION_STORE_NAME: "auth.session",
|
|
108
|
+
CORE_DEPLOYMENT_MODE: "single",
|
|
109
|
+
SESSION_STORE_STORAGE_TYPE: "memory",
|
|
110
|
+
ADAPTERS_USER_SESSION_STORES: "memory",
|
|
111
|
+
ADAPTERS_RATE_LIMITER: "memory",
|
|
112
|
+
ADAPTERS_CODE_REPOSITORY: "memory",
|
|
113
|
+
ADAPTERS_ACCESS_TOKEN_DENYLIST: "memory",
|
|
114
|
+
ADAPTERS_REPLAY_SEEN_SET: "memory",
|
|
115
|
+
ADAPTERS_FEDERATION_TOKEN_STORE: "memory",
|
|
116
|
+
ADAPTERS_CONSENT_STORE: "memory",
|
|
117
|
+
ADAPTERS_FEDERATION_GRANT_STORE: "memory",
|
|
118
|
+
ADAPTERS_FEDERATION_GRANT_INTENT_STORE: "memory",
|
|
117
119
|
// The user repository is replaced (see the header); `yaml` keeps the
|
|
118
120
|
// shipped `http` adapter's URL requirements out of config validation.
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
121
|
+
ADAPTERS_USER_REPOSITORY: "yaml",
|
|
122
|
+
OAUTH_SESSION_ENABLED: "true",
|
|
123
|
+
OAUTH_AUTHORIZATION_GRANTS_AUTHORIZATION_CODE_ENABLED: "true",
|
|
124
|
+
OAUTH_AUTHORIZATION_GRANTS_REFRESH_TOKEN_ENABLED: "true",
|
|
125
|
+
OAUTH_AUTHORIZATION_GRANTS_CLIENT_CREDENTIALS_ENABLED: "true",
|
|
126
|
+
OAUTH_CLIENT_ID_METADATA_DOCUMENTS_ENABLED: "true",
|
|
125
127
|
FEDERATION_GRANTS_ENABLED: "true",
|
|
126
128
|
FEDERATION_GRANTS_CONSENT_URL: "/consent/grants",
|
|
127
129
|
// The in-memory user repository covers no registration; `required` is a
|
|
128
130
|
// Store's statement that it does (see the federation-grants README).
|
|
129
131
|
FEDERATION_GRANTS_IDENTITY_LOOKUP: "unsupported",
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
132
|
+
CORE_FEDERATIONS_GOOGLE_ENABLED: "true",
|
|
133
|
+
CORE_FEDERATIONS_GOOGLE_CLIENT_ID: "google-client",
|
|
134
|
+
CORE_FEDERATIONS_GOOGLE_CLIENT_SECRET: "google-secret",
|
|
135
|
+
CORE_FEDERATIONS_OIDC_ENABLED: "true",
|
|
136
|
+
CORE_FEDERATIONS_OIDC_ISSUER: OIDC_ISSUER,
|
|
137
|
+
CORE_FEDERATIONS_OIDC_CLIENT_ID: "oidc-client",
|
|
138
|
+
CORE_FEDERATIONS_OIDC_CLIENT_SECRET: "oidc-secret",
|
|
137
139
|
};
|
|
138
140
|
|
|
139
141
|
/**
|
|
@@ -142,20 +144,20 @@ export const SINGLE_ENV: Readonly<Record<string, string>> = {
|
|
|
142
144
|
*/
|
|
143
145
|
export const MULTI_ENV: Readonly<Record<string, string>> = {
|
|
144
146
|
...SINGLE_ENV,
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
147
|
+
CORE_DEPLOYMENT_MODE: "multi",
|
|
148
|
+
SESSION_STORE_STORAGE_TYPE: "redis",
|
|
149
|
+
SESSION_STORE_STORAGE_REDIS_URL: "redis://redis.test:6379",
|
|
150
|
+
REDIS_CLIENTS_URL: "redis://redis.test:6379",
|
|
151
|
+
ADAPTERS_USER_SESSION_STORES: "redis",
|
|
152
|
+
ADAPTERS_RATE_LIMITER: "redis",
|
|
153
|
+
ADAPTERS_CODE_REPOSITORY: "redis",
|
|
154
|
+
ADAPTERS_ACCESS_TOKEN_DENYLIST: "redis",
|
|
155
|
+
ADAPTERS_REPLAY_SEEN_SET: "redis",
|
|
156
|
+
ADAPTERS_FEDERATION_TOKEN_STORE: "redis",
|
|
155
157
|
REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY: ENCRYPTION_KEY,
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
158
|
+
ADAPTERS_CONSENT_STORE: "redis",
|
|
159
|
+
ADAPTERS_FEDERATION_GRANT_STORE: "redis",
|
|
160
|
+
ADAPTERS_FEDERATION_GRANT_INTENT_STORE: "redis",
|
|
159
161
|
};
|
|
160
162
|
|
|
161
163
|
/** The grant connection the federation-grant flows use, on the shipped `oidc` federation. */
|
|
@@ -164,45 +166,61 @@ export const CONNECTION = "calendar";
|
|
|
164
166
|
const configDir = fileURLToPath(new URL("../../config", import.meta.url));
|
|
165
167
|
|
|
166
168
|
/**
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
|
|
169
|
+
* What has no environment form — the federations' landing page, the grant
|
|
170
|
+
* key ring and one grant connection — as an operator writes it: a HOCON
|
|
171
|
+
* file of their own, the highest layer. Written once per process.
|
|
172
|
+
*/
|
|
173
|
+
const OPERATOR_LAYER: string = (() => {
|
|
174
|
+
const file = join(mkdtempSync(join(tmpdir(), "all-modules-composition-")), "operator.conf");
|
|
175
|
+
const quoted = (value: string) => JSON.stringify(value);
|
|
176
|
+
writeFileSync(
|
|
177
|
+
file,
|
|
178
|
+
`core.federations.google.clientUrl = ${quoted(FEDERATION_LANDING)}
|
|
179
|
+
core.federations.oidc.clientUrl = ${quoted(FEDERATION_LANDING)}
|
|
180
|
+
redis-federation-grant-store.encryptionKeys = [{ id = "k-test", key = ${quoted(ENCRYPTION_KEY)} }]
|
|
181
|
+
federation-grants {
|
|
182
|
+
connections {
|
|
183
|
+
${CONNECTION} {
|
|
184
|
+
federation = "oidc"
|
|
185
|
+
scopes = ["openid", "offline_access", "calendar.read"]
|
|
186
|
+
boundary = "production"
|
|
187
|
+
maxAccessTokenLifetime = 3600
|
|
188
|
+
callbackURL = ${quoted(`${ISSUER}/session/federation-grants/callback/${CONNECTION}`)}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
`,
|
|
193
|
+
);
|
|
194
|
+
return file;
|
|
195
|
+
})();
|
|
196
|
+
|
|
197
|
+
/** `text` in a file of its own, for a layer above the composition's files. */
|
|
198
|
+
function hoconFile(text: string): string {
|
|
199
|
+
const file = join(mkdtempSync(join(tmpdir(), "all-modules-operator-")), "operator.conf");
|
|
200
|
+
writeFileSync(file, text);
|
|
201
|
+
return file;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The composition's own files, highest first: the operator's layer, then the shipped production ones. */
|
|
205
|
+
export function ownFiles(): string[] {
|
|
206
|
+
const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
|
|
207
|
+
return [OPERATOR_LAYER, envConfPath, applicationConfPath];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Phase one, as `app.mts` reads it: the switches `buildModules` chooses
|
|
212
|
+
* the modules by — and `reads`, what a module added to the composition reads
|
|
213
|
+
* when it is built — from the composition's own files under `env` over core's
|
|
214
|
+
* `reference.conf`. What the composition expects of session admission is
|
|
215
|
+
* derived from it (`resolveForBoot`), after `config` adjusts it as an
|
|
216
|
+
* operator's layer would.
|
|
171
217
|
*/
|
|
172
218
|
export function resolveConfig(
|
|
173
219
|
env: Readonly<Record<string, string>>,
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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;
|
|
220
|
+
reads: readonly string[] = [],
|
|
221
|
+
own: OwnLayers = readOwnLayers(ownFiles(), { env }),
|
|
222
|
+
): Switches {
|
|
223
|
+
return readSwitches(own, { reads });
|
|
206
224
|
}
|
|
207
225
|
|
|
208
226
|
// ---------------------------------------------------------------------------
|
|
@@ -337,20 +355,19 @@ export async function sharedUpstreams(): Promise<Upstreams> {
|
|
|
337
355
|
}
|
|
338
356
|
|
|
339
357
|
/**
|
|
340
|
-
* Snapshots each fake's settings
|
|
341
|
-
*
|
|
358
|
+
* Snapshots each fake's settings (every property that is not a function) and
|
|
359
|
+
* returns what puts them back: primitives reassigned, objects restored in
|
|
342
360
|
* place (a fake may hold its own reference to one), recorded requests
|
|
343
361
|
* cleared. A fake's signing key is checked, not restored: a rotated key is
|
|
344
362
|
* refused.
|
|
345
363
|
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
* authorizations are keyed per login
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
* makes a fake of its own with `createFakeIdp`.
|
|
364
|
+
* A fake's closure is not restored: core's fake IdP remembers whether consent
|
|
365
|
+
* was granted (`consentGranted`), how many codes it issued (`codesIssued`) and
|
|
366
|
+
* every authorization it recorded.
|
|
367
|
+
* Codes and authorizations are keyed per login; consent is not, and it decides
|
|
368
|
+
* Google's refresh token only under `refreshTokenOnlyOnConsent`. That knob is
|
|
369
|
+
* therefore forbidden on a shared fake (the next reset refuses to run if a test
|
|
370
|
+
* set it); a test that needs it makes its own fake with `createFakeIdp`.
|
|
354
371
|
*/
|
|
355
372
|
export function resettable(...fakes: readonly object[]): () => void {
|
|
356
373
|
const snapshots = fakes.map((fake) => ({
|
|
@@ -399,7 +416,7 @@ async function createUpstreams(): Promise<Upstreams> {
|
|
|
399
416
|
oidc: await createFakeIdp({
|
|
400
417
|
issuer: OIDC_ISSUER,
|
|
401
418
|
discovery: true,
|
|
402
|
-
clientId: SINGLE_ENV.
|
|
419
|
+
clientId: SINGLE_ENV.CORE_FEDERATIONS_OIDC_CLIENT_ID,
|
|
403
420
|
sub: OIDC_SUB,
|
|
404
421
|
}),
|
|
405
422
|
// Google's endpoints are fixed in the adapter, not discovered.
|
|
@@ -409,7 +426,7 @@ async function createUpstreams(): Promise<Upstreams> {
|
|
|
409
426
|
tokenEndpoint: "https://oauth2.googleapis.com/token",
|
|
410
427
|
jwksUri: "https://www.googleapis.com/oauth2/v3/certs",
|
|
411
428
|
userinfoEndpoint: "https://www.googleapis.com/oauth2/v3/userinfo",
|
|
412
|
-
clientId: SINGLE_ENV.
|
|
429
|
+
clientId: SINGLE_ENV.CORE_FEDERATIONS_GOOGLE_CLIENT_ID,
|
|
413
430
|
sub: GOOGLE_SUB,
|
|
414
431
|
}),
|
|
415
432
|
};
|
|
@@ -434,7 +451,7 @@ const bridged = <T,>(module: Module, slot: string, config: AppConfig): T => {
|
|
|
434
451
|
* config enables — which is when `buildModules` lists the bridges.
|
|
435
452
|
*/
|
|
436
453
|
async function federationOverrides(
|
|
437
|
-
config:
|
|
454
|
+
config: Switches,
|
|
438
455
|
upstreams: Upstreams,
|
|
439
456
|
): Promise<Record<string, unknown>> {
|
|
440
457
|
const overrides: Record<string, unknown> = {};
|
|
@@ -567,18 +584,36 @@ export type ModuleOrder = typeof AS_LISTED | typeof REVERSED;
|
|
|
567
584
|
|
|
568
585
|
export interface ComposeOptions {
|
|
569
586
|
readonly env?: Readonly<Record<string, string>>;
|
|
570
|
-
/**
|
|
571
|
-
readonly
|
|
587
|
+
/** The name the configuration was selected by, as `app.mts` hands `buildModules` it; `production` by default. */
|
|
588
|
+
readonly environment?: string;
|
|
589
|
+
/** HOCON an operator writes above the composition's own files, read in both phases. */
|
|
590
|
+
readonly operatorHocon?: string;
|
|
591
|
+
/**
|
|
592
|
+
* Paths read before boot beside the template's switches: what a module
|
|
593
|
+
* `extraModules` adds reads when it is built (`readSwitches`'s `reads`).
|
|
594
|
+
*/
|
|
595
|
+
readonly reads?: readonly string[];
|
|
596
|
+
/** The deployment's own mail sender modules, handed to `buildModules` as `mailSenderModules`. */
|
|
597
|
+
readonly mailSenderModules?: readonly Module[];
|
|
572
598
|
/** Modules added after the template's own, before the order and the outage apply. */
|
|
573
|
-
readonly extraModules?: (config:
|
|
599
|
+
readonly extraModules?: (config: Switches) => readonly Module[];
|
|
574
600
|
/** Components laid over the boot's, beside the federation config slots. */
|
|
575
|
-
readonly extraOverrides?: (config:
|
|
601
|
+
readonly extraOverrides?: (config: Switches) => Record<string, unknown>;
|
|
576
602
|
/** Client registrations beside the fixture's own, as `ClientEntrySchema` input. */
|
|
577
603
|
readonly extraClients?: Readonly<Record<string, Record<string, unknown>>>;
|
|
578
604
|
/** Users beside the fixture's own, keyed by username. */
|
|
579
605
|
readonly extraUsers?: Readonly<Record<string, Record<string, unknown>>>;
|
|
580
|
-
/**
|
|
581
|
-
|
|
606
|
+
/**
|
|
607
|
+
* Adjust the configuration before anything reads it, as an operator's own
|
|
608
|
+
* layer would: applied to phase one's switches, and to what `createApp` is
|
|
609
|
+
* handed, as resolved.
|
|
610
|
+
*/
|
|
611
|
+
readonly config?: (config: Switches) => Switches;
|
|
612
|
+
/**
|
|
613
|
+
* Adjust phase one's switches alone, after `config`: what only phase one
|
|
614
|
+
* reads, such as the Store transport settings a hand-built root passes.
|
|
615
|
+
*/
|
|
616
|
+
readonly switches?: (switches: Switches) => Switches;
|
|
582
617
|
readonly order?: ModuleOrder;
|
|
583
618
|
readonly outage?: { readonly slot: string; readonly outage: Outage };
|
|
584
619
|
/** Keep the shipped Redis refresh-token family store (the `multi` boot). */
|
|
@@ -593,11 +628,14 @@ export interface ComposeOptions {
|
|
|
593
628
|
}
|
|
594
629
|
|
|
595
630
|
/** The module list the template boots for `config`, as `app.mts` builds it. */
|
|
596
|
-
export function composedModules(config:
|
|
631
|
+
export function composedModules(config: Switches, options: ComposeOptions = {}): Module[] {
|
|
597
632
|
let modules = [
|
|
598
633
|
...buildModules(config, {
|
|
599
|
-
environment: "production",
|
|
634
|
+
environment: options.environment ?? "production",
|
|
600
635
|
repositoriesModule: testRepositoriesModule(options.extraClients, options.extraUsers),
|
|
636
|
+
...(options.mailSenderModules === undefined
|
|
637
|
+
? {}
|
|
638
|
+
: { mailSenderModules: options.mailSenderModules }),
|
|
601
639
|
...(options.shippedRefreshTokenFamilyStore
|
|
602
640
|
? {}
|
|
603
641
|
: { refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule] }),
|
|
@@ -612,7 +650,10 @@ export function composedModules(config: AppConfig, options: ComposeOptions = {})
|
|
|
612
650
|
export interface Composition {
|
|
613
651
|
readonly app: express.Express;
|
|
614
652
|
readonly handle: Awaited<ReturnType<typeof createApp>>;
|
|
653
|
+
/** The configuration boot parsed: what the handle's `config` slot holds. */
|
|
615
654
|
readonly config: AppConfig;
|
|
655
|
+
/** What `createApp` was handed: the configuration as resolved (phase two), unparsed. */
|
|
656
|
+
readonly resolved: AppConfig;
|
|
616
657
|
readonly modules: readonly Module[];
|
|
617
658
|
readonly logger: RecordingLogger;
|
|
618
659
|
readonly upstreams: Upstreams;
|
|
@@ -625,21 +666,43 @@ export interface Composition {
|
|
|
625
666
|
* boot's `logger` component.
|
|
626
667
|
*/
|
|
627
668
|
export async function compose(options: ComposeOptions = {}): Promise<Composition> {
|
|
628
|
-
const
|
|
629
|
-
const
|
|
669
|
+
const env = options.env ?? SINGLE_ENV;
|
|
670
|
+
const adjust = <C extends AppConfig>(config: C): C =>
|
|
671
|
+
options.config ? (options.config(config as unknown as Switches) as unknown as C) : config;
|
|
672
|
+
// The composition's own layers, read once for both phases, as `app.mts`
|
|
673
|
+
// reads them. Phase one: the switches the modules are chosen by.
|
|
674
|
+
const own = readOwnLayers(
|
|
675
|
+
options.operatorHocon === undefined
|
|
676
|
+
? ownFiles()
|
|
677
|
+
: [hoconFile(options.operatorHocon), ...ownFiles()],
|
|
678
|
+
{ env },
|
|
679
|
+
);
|
|
680
|
+
const switches = resolveConfig(env, options.reads, own);
|
|
681
|
+
const config = options.switches ? options.switches(adjust(switches)) : adjust(switches);
|
|
630
682
|
const fakes = await sharedUpstreams();
|
|
631
683
|
const modules = composedModules(config, options);
|
|
632
684
|
const logger = createRecordingLogger();
|
|
685
|
+
// Phase two: the configuration as resolved over every loaded package's
|
|
686
|
+
// reference.conf, which createApp parses once.
|
|
687
|
+
const resolved = adjust(resolveForBoot(own, modules, config));
|
|
633
688
|
const handle = await createApp({
|
|
634
689
|
modules,
|
|
635
|
-
bootstrapComponents: {
|
|
690
|
+
bootstrapComponents: {
|
|
691
|
+
config: resolved,
|
|
692
|
+
pathResolver: (s) => s,
|
|
693
|
+
logger,
|
|
694
|
+
},
|
|
636
695
|
overrideComponents: {
|
|
637
696
|
...(await federationOverrides(config, fakes)),
|
|
638
697
|
...options.extraOverrides?.(config),
|
|
639
698
|
} as never,
|
|
640
699
|
});
|
|
700
|
+
const parsed = handle.components.config;
|
|
701
|
+
if (parsed === undefined) throw new Error("createApp booted without the parsed configuration");
|
|
702
|
+
const httpSettings = handle.components.httpSettings;
|
|
703
|
+
if (httpSettings === undefined) throw new Error("createApp booted without httpSettings");
|
|
641
704
|
const app = express();
|
|
642
|
-
app.set("trust proxy",
|
|
705
|
+
app.set("trust proxy", httpSettings.trustProxy);
|
|
643
706
|
app.use(
|
|
644
707
|
helmet({
|
|
645
708
|
contentSecurityPolicy: { directives: { defaultSrc: ["'none'"], frameAncestors: ["'none'"] } },
|
|
@@ -647,7 +710,7 @@ export async function compose(options: ComposeOptions = {}): Promise<Composition
|
|
|
647
710
|
);
|
|
648
711
|
app.use(handle.router);
|
|
649
712
|
if (options.terminalErrorHandler !== false) app.use(terminalErrorHandler(logger));
|
|
650
|
-
return { app, handle, config, modules, logger, upstreams: fakes };
|
|
713
|
+
return { app, handle, config: parsed, resolved, modules, logger, upstreams: fakes };
|
|
651
714
|
}
|
|
652
715
|
|
|
653
716
|
// ---------------------------------------------------------------------------
|
|
@@ -765,12 +828,17 @@ export const DISCOVERY_PATHS = [
|
|
|
765
828
|
/**
|
|
766
829
|
* What RFC 8414 §2 and OpenID Connect Discovery §3 require of the document
|
|
767
830
|
* this composition serves, and what each advertised URL must be: https, on
|
|
768
|
-
* the issuer's origin.
|
|
831
|
+
* the issuer's origin. `authorizationCode` says whether the composition
|
|
832
|
+
* registers that grant (it does unless a test switches it off): with it the
|
|
833
|
+
* document names the authorization endpoint and lists the code response type
|
|
834
|
+
* and PKCE; without it, none of them.
|
|
769
835
|
*/
|
|
770
|
-
export function expectValidMetadata(
|
|
836
|
+
export function expectValidMetadata(
|
|
837
|
+
doc: Record<string, unknown>,
|
|
838
|
+
{ authorizationCode = true }: { readonly authorizationCode?: boolean } = {},
|
|
839
|
+
): void {
|
|
771
840
|
expect(doc.issuer).toBe(ISSUER);
|
|
772
841
|
for (const field of [
|
|
773
|
-
"authorization_endpoint",
|
|
774
842
|
"token_endpoint",
|
|
775
843
|
"jwks_uri",
|
|
776
844
|
"response_types_supported",
|
|
@@ -786,13 +854,20 @@ export function expectValidMetadata(doc: Record<string, unknown>): void {
|
|
|
786
854
|
expect(url.search + url.hash, field).toBe("");
|
|
787
855
|
}
|
|
788
856
|
if (field.endsWith("_supported") && Array.isArray(value)) {
|
|
789
|
-
expect(value.length, field).toBeGreaterThan(0);
|
|
857
|
+
if (field !== "response_types_supported") expect(value.length, field).toBeGreaterThan(0);
|
|
790
858
|
for (const entry of value) expect(typeof entry, field).toBe("string");
|
|
791
859
|
expect(new Set(value).size, `${field} repeats a value`).toBe(value.length);
|
|
792
860
|
}
|
|
793
861
|
}
|
|
794
|
-
|
|
795
|
-
|
|
862
|
+
if (authorizationCode) {
|
|
863
|
+
expect(doc.authorization_endpoint).toBe(`${ISSUER}/oauth/authorize`);
|
|
864
|
+
expect(doc.response_types_supported).toEqual(["code"]);
|
|
865
|
+
expect(doc.code_challenge_methods_supported).toEqual(["S256"]);
|
|
866
|
+
} else {
|
|
867
|
+
expect(doc).not.toHaveProperty("authorization_endpoint");
|
|
868
|
+
expect(doc.response_types_supported).toEqual([]);
|
|
869
|
+
expect(doc).not.toHaveProperty("code_challenge_methods_supported");
|
|
870
|
+
}
|
|
796
871
|
}
|
|
797
872
|
|
|
798
873
|
// ---------------------------------------------------------------------------
|
|
@@ -937,7 +1012,7 @@ export const TOO_LARGE = { error: "invalid_request", error_description: "body_to
|
|
|
937
1012
|
// ---------------------------------------------------------------------------
|
|
938
1013
|
|
|
939
1014
|
/**
|
|
940
|
-
* The parts of the
|
|
1015
|
+
* The parts of the outage rule, each checked on its own so a case pins
|
|
941
1016
|
* only what is broken and asserts the rest:
|
|
942
1017
|
*
|
|
943
1018
|
* - `answer` — the status and error code (a redirect's location and code);
|