@o3co/create-auth-provider 0.15.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 +139 -48
- package/README.md +138 -47
- 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 +69 -24
- package/templates/standalone/Dockerfile +7 -3
- package/templates/standalone/README.ja.md +677 -184
- package/templates/standalone/README.md +699 -238
- package/templates/standalone/config/application.conf +203 -325
- 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 +4 -2
- package/templates/standalone/package.json +9 -6
- package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +65 -32
- package/templates/standalone/src/__tests__/adapters.test.mts +358 -0
- package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +1178 -0
- package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +256 -0
- package/templates/standalone/src/__tests__/all-modules-composition.test.mts +1119 -0
- 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 +672 -271
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +480 -78
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +102 -66
- package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +135 -0
- package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +49 -18
- package/templates/standalone/src/__tests__/google-session.fixture.mts +315 -0
- package/templates/standalone/src/__tests__/host-routes.test.mts +134 -0
- package/templates/standalone/src/__tests__/library-references.fixture.mts +183 -0
- package/templates/standalone/src/__tests__/listen.test.mts +121 -0
- package/templates/standalone/src/__tests__/logger-projection.test.mts +77 -0
- 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 +17 -7
- 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 +183 -0
- package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +135 -0
- 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 +166 -145
- package/templates/standalone/src/__tests__/router-error-answers.test.mts +146 -0
- 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 +319 -115
- package/templates/standalone/src/__tests__/smoke.test.mts +179 -233
- package/templates/standalone/src/__tests__/store-credential.test.mts +317 -0
- 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 +71 -115
- package/templates/standalone/src/buildModules.mts +229 -251
- package/templates/standalone/src/configPath.mts +422 -29
- package/templates/standalone/src/listen.mts +60 -0
- package/templates/standalone/src/logger.mts +31 -57
- package/templates/standalone/src/metrics.mts +24 -47
- package/templates/standalone/src/mfaSwitch.mts +262 -0
- package/templates/standalone/src/modules.mts +480 -464
- package/templates/standalone/src/rootRenames.mts +79 -0
- package/templates/standalone/src/routes.mts +89 -0
- package/templates/standalone/src/sections.mts +366 -0
- package/templates/standalone/src/shutdown.mts +54 -126
- 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
- package/templates/standalone/src/__tests__/terminal-error.test.mts +0 -96
- package/templates/standalone/src/terminalError.mts +0 -60
|
@@ -14,52 +14,59 @@
|
|
|
14
14
|
* limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
17
|
+
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
18
19
|
import { join } from "node:path";
|
|
19
20
|
import { fileURLToPath } from "node:url";
|
|
20
21
|
import {
|
|
21
22
|
type AppConfig,
|
|
22
|
-
|
|
23
|
+
createApp,
|
|
24
|
+
type Module,
|
|
25
|
+
moduleReferences,
|
|
23
26
|
resolveAccessTokenLifetime,
|
|
24
27
|
} from "@o3co/auth-provider-core";
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
28
|
+
import { oauthModule } from "@o3co/auth-provider-oauth";
|
|
29
|
+
import { sessionModule, sessionStoreModule } from "@o3co/auth-provider-session";
|
|
27
30
|
import { describe, expect, it } from "vitest";
|
|
28
31
|
import { buildModules } from "../buildModules.mjs";
|
|
29
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
expectedSessionRequirements,
|
|
34
|
+
readOwnLayers,
|
|
35
|
+
readSwitches,
|
|
36
|
+
resolveConfigPaths,
|
|
37
|
+
resolveForBoot,
|
|
38
|
+
type Switches,
|
|
39
|
+
} from "../configPath.mjs";
|
|
40
|
+
import { httpModule, keyStoreModule, templateReference } from "../modules.mjs";
|
|
30
41
|
|
|
31
42
|
/**
|
|
32
|
-
*
|
|
33
|
-
*
|
|
43
|
+
* Boots the shipped config with EVERY documented override supplied the way an
|
|
44
|
+
* operator supplies one: as a string.
|
|
34
45
|
*
|
|
35
|
-
* HOCON substitutes `${?VAR}` as a string, always.
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* optional/nullable/default/catch/readonly wrapper around one), so wrapping a
|
|
42
|
-
* section in `z.preprocess(...)` silently took the override away — which is
|
|
43
|
-
* precisely what had happened to `OAUTH_JWT_LEGACY_TYP_ACCEPT`.
|
|
46
|
+
* HOCON substitutes `${?VAR}` as a string, always. `app.mts` reads its switches
|
|
47
|
+
* with core's transitional reader and hands `createApp` what it resolved,
|
|
48
|
+
* which boot parses once with plain Zod, so every schema leaf must read the
|
|
49
|
+
* string itself. This suite reads through that path: `readSwitches` for what
|
|
50
|
+
* is read before boot, `resolveForBoot` and `createApp` for the configuration
|
|
51
|
+
* boot parsed.
|
|
44
52
|
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* README, without being exercised here.
|
|
53
|
+
* It is not a sample of interesting variables: `covers every documented
|
|
54
|
+
* override` below fails when a `${?VAR}` is added to either config layer, or
|
|
55
|
+
* documented in the README, without being exercised here.
|
|
49
56
|
*/
|
|
50
57
|
|
|
51
58
|
// config/ is two levels above this test file: src/__tests__/ → src/ → standalone/
|
|
52
59
|
const standaloneDir = fileURLToPath(new URL("../..", import.meta.url));
|
|
53
60
|
const configDir = fileURLToPath(new URL("../../config", import.meta.url));
|
|
54
61
|
const readmePath = fileURLToPath(new URL("../../README.md", import.meta.url));
|
|
62
|
+
const readmeJaPath = fileURLToPath(new URL("../../README.ja.md", import.meta.url));
|
|
55
63
|
|
|
56
64
|
/**
|
|
57
|
-
* Every environment variable the shipped artifact documents,
|
|
58
|
-
*
|
|
59
|
-
* point of the exercise.
|
|
65
|
+
* Every environment variable the shipped artifact documents, as the string an
|
|
66
|
+
* operator would supply.
|
|
60
67
|
*
|
|
61
|
-
* `
|
|
62
|
-
*
|
|
68
|
+
* `KEY_STORE_LOCAL_ALGORITHM` is `EdDSA` so the asymmetric key variables fit in the
|
|
69
|
+
* same map: the HS256 branch of `signingKey.local` is a `.strict()`
|
|
63
70
|
* discriminated-union member and refuses `privateKeyPath` and friends by
|
|
64
71
|
* design. The HS256 shape gets its own test below.
|
|
65
72
|
*/
|
|
@@ -70,20 +77,18 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
|
|
|
70
77
|
HTTP_READINESS_TIMEOUT_MS: "1500",
|
|
71
78
|
|
|
72
79
|
// --- logging ------------------------------------------------------
|
|
73
|
-
|
|
80
|
+
LOGGING_LEVEL: "debug",
|
|
74
81
|
|
|
75
82
|
// --- oauth.jwt ----------------------------------------------------
|
|
76
83
|
OAUTH_JWT_ISSUER: "https://auth.test",
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
// The override this issue was filed over: a bare `z.boolean()` behind a
|
|
86
|
-
// `z.preprocess` wrapper the hocon bridge cannot see through.
|
|
84
|
+
KEY_STORE_PROVIDER: "local",
|
|
85
|
+
KEY_STORE_LOCAL_ALGORITHM: "EdDSA",
|
|
86
|
+
KEY_STORE_LOCAL_KID: "v1",
|
|
87
|
+
KEY_STORE_LOCAL_SECRET: "documented-env-secret.at-least-32-bytes.ok",
|
|
88
|
+
KEY_STORE_LOCAL_PRIVATE_KEY: "-----BEGIN PRIVATE KEY-----\nMC4=\n-----END PRIVATE KEY-----",
|
|
89
|
+
KEY_STORE_LOCAL_PRIVATE_KEY_PATH: "./config/jwt-private.pem",
|
|
90
|
+
KEY_STORE_LOCAL_PUBLIC_KEY: "-----BEGIN PUBLIC KEY-----\nMCo=\n-----END PUBLIC KEY-----",
|
|
91
|
+
KEY_STORE_LOCAL_PUBLIC_KEY_PATH: "./config/jwt-public.pem",
|
|
87
92
|
OAUTH_JWT_LEGACY_TYP_ACCEPT: "true",
|
|
88
93
|
|
|
89
94
|
// --- oauth tokens / policy ----------------------------------------
|
|
@@ -95,140 +100,201 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
|
|
|
95
100
|
OAUTH_REFRESH_TOKEN_EXPIRES_IN: "86400",
|
|
96
101
|
OAUTH_REFRESH_TOKEN_UNKNOWN_FAMILY_POLICY: "reject",
|
|
97
102
|
OAUTH_OIDC_MODE: "dual",
|
|
98
|
-
OAUTH_CODE_ADAPTER: "redis",
|
|
99
103
|
OAUTH_REVOCATION_ACCESS_TOKEN: "denylist",
|
|
100
104
|
OAUTH_REVOCATION_SUBJECT: "unsupported",
|
|
101
105
|
OAUTH_REQUIRE_EMAIL_VERIFIED: "true",
|
|
102
106
|
OAUTH_REQUIRE_GRANT_TYPE_ALLOWLIST: "true",
|
|
103
|
-
OAUTH_GRANTS_JWT_BEARER_ENABLED: "false",
|
|
104
107
|
OAUTH_NONCE_MAX_LENGTH: "256",
|
|
105
|
-
OAUTH_TOKEN_EXCHANGE_MAX_ACTOR_CHAIN_DEPTH: "3",
|
|
106
108
|
OAUTH_RESOURCE_INDICATOR_ENABLED: "true",
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
//
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
SESSION_SAME_SITE: "lax",
|
|
137
|
-
SESSION_DOMAIN: "auth.example.com",
|
|
109
|
+
OAUTH_CLIENT_ID_METADATA_DOCUMENTS_ENABLED: "true",
|
|
110
|
+
OAUTH_CLIENT_ID_METADATA_DOCUMENTS_ALLOWED_SCOPES: "read, write",
|
|
111
|
+
OAUTH_CLIENT_ID_METADATA_DOCUMENTS_ALLOWED_AUDIENCES: "https://mcp.example",
|
|
112
|
+
OAUTH_CLIENT_ID_METADATA_DOCUMENTS_ALLOWED_HOSTS: "client.example, .trusted.example",
|
|
113
|
+
OAUTH_CLIENT_ID_METADATA_DOCUMENTS_DENIED_HOSTS: "evil.example",
|
|
114
|
+
OAUTH_CLIENT_ID_METADATA_DOCUMENTS_MAX_BYTES: "8192",
|
|
115
|
+
OAUTH_CLIENT_ID_METADATA_DOCUMENTS_TIMEOUT_MS: "3000",
|
|
116
|
+
OAUTH_CLIENT_ID_METADATA_DOCUMENTS_CACHE_MAX_AGE_MS: "60000",
|
|
117
|
+
OAUTH_CLIENT_ID_METADATA_DOCUMENTS_MAX_CACHE_ENTRIES: "128",
|
|
118
|
+
OAUTH_CLIENT_ID_METADATA_DOCUMENTS_STALE_IF_ERROR_MS: "120000",
|
|
119
|
+
OAUTH_CLIENT_ID_METADATA_DOCUMENTS_NEGATIVE_CACHE_MS: "30000",
|
|
120
|
+
OAUTH_CLIENT_ID_METADATA_DOCUMENTS_MAX_CONCURRENT_FETCHES: "4",
|
|
121
|
+
CORE_TOKEN_BINDING_DISPATCH_POLICY: "intent-explicit",
|
|
122
|
+
CORE_TOKEN_BINDING_BIND_CONFIDENTIAL_CLIENT_REFRESH_TOKENS: "true",
|
|
123
|
+
CORE_OUTBOUND_ALLOWED_HOSTS: "rp.example, .partner.example",
|
|
124
|
+
CORE_OUTBOUND_DENIED_HOSTS: "blocked.example",
|
|
125
|
+
CORE_OUTBOUND_INTERNAL_HOSTS: ".corp.internal",
|
|
126
|
+
CORE_OUTBOUND_TIMEOUT_MS: "3000",
|
|
127
|
+
CORE_OUTBOUND_MAX_RESPONSE_BYTES: "32768",
|
|
128
|
+
CORE_OUTBOUND_EGRESS: "direct",
|
|
129
|
+
|
|
130
|
+
// --- the grant switches ------------------------------------------
|
|
131
|
+
OAUTH_SESSION_ENABLED: "false",
|
|
132
|
+
OAUTH_AUTHORIZATION_GRANTS_AUTHORIZATION_CODE_ENABLED: "true",
|
|
133
|
+
OAUTH_AUTHORIZATION_GRANTS_REFRESH_TOKEN_ENABLED: "true",
|
|
134
|
+
OAUTH_AUTHORIZATION_GRANTS_CLIENT_CREDENTIALS_ENABLED: "true",
|
|
135
|
+
OAUTH_AUTHORIZATION_GRANTS_JWT_BEARER_ENABLED: "false",
|
|
136
|
+
|
|
137
|
+
// --- session: the login routes -------------------------------------
|
|
138
138
|
SESSION_CSRF_TTL_SECONDS: "7200",
|
|
139
|
-
SESSION_STORAGE_TYPE: "redis",
|
|
140
|
-
SESSION_STORAGE_REDIS_URL: "redis://redis:6379",
|
|
141
|
-
SESSION_STORAGE_REDIS_PASSWORD: "session-store-password",
|
|
142
139
|
|
|
143
|
-
// ---
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
140
|
+
// --- session-store: the session cookie and its store ---------------
|
|
141
|
+
SESSION_STORE_SECRET: "documented-env-session-secret.at-least-32-bytes.ok",
|
|
142
|
+
SESSION_STORE_NAME: "auth.session",
|
|
143
|
+
SESSION_STORE_MAX_AGE: "3600000",
|
|
144
|
+
SESSION_STORE_SECURE: "false",
|
|
145
|
+
SESSION_STORE_SAME_SITE: "lax",
|
|
146
|
+
SESSION_STORE_DOMAIN: "auth.example.com",
|
|
147
|
+
SESSION_STORE_STORAGE_TYPE: "redis",
|
|
148
|
+
SESSION_STORE_STORAGE_REDIS_URL: "redis://redis:6379",
|
|
149
|
+
SESSION_STORE_STORAGE_REDIS_PASSWORD: "session-store-password",
|
|
147
150
|
|
|
148
|
-
// ---
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
|
|
151
|
+
// --- rate limiting ------------------------------------------------
|
|
152
|
+
REDIS_RATE_LIMITER_FAIL_MODE: "open",
|
|
153
|
+
CORE_RATE_LIMITER_MEMORY_MAX_BUCKETS: "10000",
|
|
154
|
+
|
|
155
|
+
// --- adapters -----------------------------------------------------
|
|
156
|
+
// Which adapter fills each slot: the composition root's own section,
|
|
157
|
+
// read before the modules are chosen.
|
|
158
|
+
ADAPTERS_RATE_LIMITER: "redis",
|
|
159
|
+
ADAPTERS_USER_SESSION_STORES: "redis",
|
|
160
|
+
ADAPTERS_ACCESS_TOKEN_DENYLIST: "redis",
|
|
161
|
+
// The replay seen-set behind private_key_jwt client authentication.
|
|
162
|
+
ADAPTERS_REPLAY_SEEN_SET: "redis",
|
|
163
|
+
ADAPTERS_CONSENT_STORE: "redis",
|
|
164
|
+
ADAPTERS_FEDERATION_TOKEN_STORE: "redis",
|
|
165
|
+
ADAPTERS_FEDERATION_GRANT_STORE: "redis",
|
|
166
|
+
ADAPTERS_FEDERATION_GRANT_INTENT_STORE: "redis",
|
|
167
|
+
ADAPTERS_MFA_FACTOR_STORE: "redis",
|
|
168
|
+
ADAPTERS_MFA_TRANSACTION_STORE: "redis",
|
|
169
|
+
ADAPTERS_CODE_REPOSITORY: "redis",
|
|
170
|
+
ADAPTERS_CLIENT_REPOSITORY: "yaml",
|
|
171
|
+
ADAPTERS_USER_REPOSITORY: "yaml",
|
|
172
|
+
// Selects the sink builder; "console" is the registered builtin. There
|
|
173
|
+
// is deliberately no "none" — an unknown sink fails boot in buildModules
|
|
174
|
+
// (pinned by audit-sink.test.mts), not in phase one, because the schema
|
|
175
|
+
// keeps `adapters.auditSink` an open string so out-of-tree sinks need no
|
|
176
|
+
// schema change here.
|
|
177
|
+
ADAPTERS_AUDIT_SINK: "console",
|
|
178
|
+
|
|
179
|
+
// --- JWKS ---------------------------------------------------------
|
|
180
|
+
JWKS_PATH: "/keys/jwks.json",
|
|
181
|
+
JWKS_CACHE_MAX_AGE: "600",
|
|
155
182
|
|
|
156
183
|
// --- shared stores ------------------------------------------------
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
ACCESS_TOKEN_DENYLIST_ADAPTER: "redis",
|
|
160
|
-
// #484: the replay seen-set behind private_key_jwt client authentication.
|
|
161
|
-
REPLAY_SEEN_SET_ADAPTER: "redis",
|
|
162
|
-
// #561: the consent stores' shared backend and its namespace.
|
|
163
|
-
CONSENT_STORE_ADAPTER: "redis",
|
|
184
|
+
CORE_DEPLOYMENT_MODE: "multi",
|
|
185
|
+
// The consent stores' namespace.
|
|
164
186
|
REDIS_CONSENT_STORE_KEY_PREFIX: "tenant-a:consent:",
|
|
165
187
|
REDIS_ACCESS_TOKEN_DENYLIST_KEY_PREFIX: "atdeny:",
|
|
166
188
|
REDIS_SESSION_STORES_KEY_PREFIX: "ss:",
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
//
|
|
172
|
-
// documented and which could not boot.
|
|
173
|
-
FEDERATION_TOKEN_STORE_TYPE: "redis",
|
|
189
|
+
REDIS_REFRESH_TOKEN_FAMILY_STORE_KEY_PREFIX: "rtfam:",
|
|
190
|
+
REDIS_REFRESH_TOKEN_FAMILY_STORE_CAS_RETRY_LIMIT: "3",
|
|
191
|
+
REDIS_CLIENTS_URL: "redis://redis:6379",
|
|
192
|
+
REDIS_CLIENTS_PASSWORD: "rt-family-password",
|
|
193
|
+
// The federation token store's Redis branch.
|
|
174
194
|
REDIS_FEDERATION_TOKEN_STORE_KEY_PREFIX: "ft:",
|
|
175
195
|
REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_MODE: "required",
|
|
176
196
|
REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY: "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=",
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
// declares, and (slice 7) the two adapter switches the template composes
|
|
180
|
-
// the feature from.
|
|
197
|
+
// Federation grants: a user's standing consent that a client may obtain
|
|
198
|
+
// upstream tokens without them. The overrides `reference.conf` declares.
|
|
181
199
|
FEDERATION_GRANTS_ENABLED: "true",
|
|
182
|
-
|
|
183
|
-
FEDERATION_GRANT_INTENT_STORE_ADAPTER: "redis",
|
|
184
|
-
FEDERATION_GRANTS_ENCRYPTION_MODE: "required",
|
|
200
|
+
REDIS_FEDERATION_GRANT_STORE_ENCRYPTION_MODE: "required",
|
|
185
201
|
FEDERATION_GRANTS_ALLOW_KEEP_ON_SUBJECT_REVOCATION: "false",
|
|
186
|
-
//
|
|
202
|
+
// Acquisition's two deployment decisions — whether the callback
|
|
187
203
|
// refuses an upstream account linked to another user, and the consent page.
|
|
188
204
|
FEDERATION_GRANTS_IDENTITY_LOOKUP: "required",
|
|
189
205
|
FEDERATION_GRANTS_CONSENT_URL: "/consent/grants",
|
|
190
206
|
REDIS_FEDERATION_GRANT_STORE_KEY_PREFIX: "fg:",
|
|
207
|
+
// The intent store's own namespace, which a deployment moves with the grant store's.
|
|
208
|
+
REDIS_FEDERATION_GRANT_INTENT_STORE_KEY_PREFIX: "fg:",
|
|
209
|
+
|
|
210
|
+
// --- multi-factor authentication ----------------------------------
|
|
211
|
+
// The template's switch, `mfaMode`: off here, so the parse below
|
|
212
|
+
// layers nothing of MFA. The rest is what the switch installs — the MFA
|
|
213
|
+
// package's settings, foundation's Store-backed factor store's — and is
|
|
214
|
+
// covered by the substitutions it brings (`liveSubstitutions`).
|
|
215
|
+
MFA_MODE: "off",
|
|
216
|
+
MFA_ENCRYPTION_KEY: "CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=",
|
|
217
|
+
MFA_PAGE_URL: "/mfa",
|
|
218
|
+
MFA_STORE_TIMEOUT_MS: "5000",
|
|
219
|
+
MFA_ENROLLMENT_REQUIRE_EMAIL_PROOF: "when-mail",
|
|
220
|
+
MFA_TOTP_FACTOR_ENABLED: "true",
|
|
221
|
+
MFA_TOTP_FACTOR_ALGORITHM: "SHA1",
|
|
222
|
+
MFA_TOTP_FACTOR_DIGITS: "6",
|
|
223
|
+
MFA_TOTP_FACTOR_PERIOD: "30",
|
|
224
|
+
MFA_TOTP_FACTOR_WINDOW: "1",
|
|
225
|
+
MFA_TOTP_FACTOR_ISSUER: "auth.test",
|
|
226
|
+
MFA_RECOVERY_CODE_FACTOR_ENABLED: "true",
|
|
227
|
+
MFA_RECOVERY_CODE_FACTOR_COUNT: "10",
|
|
228
|
+
MFA_EMAIL_FACTOR_ENABLED: "false",
|
|
229
|
+
MFA_EMAIL_FACTOR_ADDS_MFA: "false",
|
|
230
|
+
MFA_EMAIL_FACTOR_CODE_TTL_SECONDS: "600",
|
|
231
|
+
FOUNDATION_MFA_FACTOR_STORE_LIST_URL: "https://users.example.com/mfa/factors/list",
|
|
232
|
+
FOUNDATION_MFA_FACTOR_STORE_CREATE_URL: "https://users.example.com/mfa/factors/create",
|
|
233
|
+
FOUNDATION_MFA_FACTOR_STORE_UPDATE_URL: "https://users.example.com/mfa/factors/update",
|
|
234
|
+
FOUNDATION_MFA_FACTOR_STORE_DELETE_URL: "https://users.example.com/mfa/factors/delete",
|
|
235
|
+
|
|
236
|
+
// --- mail ---------------------------------------------------------
|
|
237
|
+
// The SMTP sender's section, which the template installs outside
|
|
238
|
+
// development and builds only where something sends.
|
|
239
|
+
STANDARD_SMTP_MAIL_SENDER_HOST: "smtp.example.com",
|
|
240
|
+
STANDARD_SMTP_MAIL_SENDER_PORT: "587",
|
|
241
|
+
STANDARD_SMTP_MAIL_SENDER_SECURE: "starttls",
|
|
242
|
+
STANDARD_SMTP_MAIL_SENDER_USER: "mailer",
|
|
243
|
+
STANDARD_SMTP_MAIL_SENDER_PASSWORD: "mailer-password",
|
|
244
|
+
STANDARD_SMTP_MAIL_SENDER_FROM: "auth@example.com",
|
|
245
|
+
// …and the Redis stores' key namespaces, which the Redis package's two MFA
|
|
246
|
+
// modules read.
|
|
247
|
+
REDIS_MFA_FACTOR_STORE_KEY_PREFIX: "tenant-a:mfaf:",
|
|
248
|
+
REDIS_MFA_TRANSACTION_STORE_KEY_PREFIX: "tenant-a:mfat:",
|
|
191
249
|
|
|
192
250
|
// --- federation ---------------------------------------------------
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
251
|
+
CORE_FEDERATIONS_GOOGLE_ENABLED: "true",
|
|
252
|
+
CORE_FEDERATIONS_GOOGLE_CLIENT_ID: "google-client-id",
|
|
253
|
+
CORE_FEDERATIONS_GOOGLE_CLIENT_SECRET: "google-client-secret",
|
|
254
|
+
CORE_FEDERATIONS_GOOGLE_CALLBACK_URL:
|
|
255
|
+
"https://auth.test/session/oauth/federation/google/callback",
|
|
256
|
+
CORE_FEDERATIONS_GOOGLE_ACCESS_TYPE: "online",
|
|
257
|
+
// The generic OIDC federation the template ships disabled.
|
|
258
|
+
CORE_FEDERATIONS_OIDC_ENABLED: "true",
|
|
259
|
+
CORE_FEDERATIONS_OIDC_ISSUER: "https://idp.test",
|
|
260
|
+
CORE_FEDERATIONS_OIDC_CLIENT_ID: "oidc-client-id",
|
|
261
|
+
CORE_FEDERATIONS_OIDC_CLIENT_SECRET: "oidc-client-secret",
|
|
262
|
+
CORE_FEDERATIONS_OIDC_CALLBACK_URL: "https://auth.test/session/oauth/federation/oidc/callback",
|
|
203
263
|
|
|
204
264
|
// --- repositories -------------------------------------------------
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
//
|
|
213
|
-
|
|
265
|
+
REPOSITORIES_CLIENT_YAML_PATH: "./config/clients.yaml",
|
|
266
|
+
REPOSITORIES_USER_YAML_PATH: "./config/users.yaml",
|
|
267
|
+
REPOSITORIES_USER_HTTP_AUTHENTICATE_URL: "https://users.example.com/authenticate",
|
|
268
|
+
REPOSITORIES_USER_HTTP_AUTHENTICATE_BY_TOKEN_URL:
|
|
269
|
+
"https://users.example.com/authenticate-by-token",
|
|
270
|
+
REPOSITORIES_USER_HTTP_LINK_FEDERATED_IDENTITY_URL:
|
|
271
|
+
"https://users.example.com/link-federated-identity",
|
|
272
|
+
// The Store's identity lookup for federation grants (see ADR
|
|
273
|
+
// 2026-09-17-federation-grants-offline-delegation).
|
|
274
|
+
REPOSITORIES_USER_HTTP_FIND_SUBJECT_BY_FEDERATED_IDENTITY_URL:
|
|
214
275
|
"https://users.example.com/find-subject-by-federated-identity",
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
//
|
|
224
|
-
|
|
225
|
-
|
|
276
|
+
// The Store's MFA enrollment witness endpoint (the MFA ADR's D12).
|
|
277
|
+
REPOSITORIES_USER_HTTP_MARK_MFA_ENROLLED_URL: "https://users.example.com/mfa/enrolled",
|
|
278
|
+
REPOSITORIES_USER_HTTP_TIMEOUT: "5000",
|
|
279
|
+
REPOSITORIES_USER_HTTP_MAX_RESPONSE_BYTES: "1048576",
|
|
280
|
+
// The credential the http user adapter presents to the Store; >= 32 bytes.
|
|
281
|
+
REPOSITORIES_USER_HTTP_BEARER_TOKEN:
|
|
282
|
+
"0328d706529061d93abd6d826e09ef0f0a1e71a12af813b29e5cd2977b7dc63a",
|
|
283
|
+
// The authorization-code repositories: the Redis one (selected above) and
|
|
284
|
+
// the in-process one.
|
|
285
|
+
REDIS_CODE_REPOSITORY_DEFAULT_EXPIRES_IN: "600",
|
|
286
|
+
REDIS_CODE_REPOSITORY_KEY_PREFIX: "oauth:code:",
|
|
287
|
+
STANDALONE_IN_MEMORY_CODE_REPOSITORY_DEFAULT_EXPIRES_IN: "600",
|
|
288
|
+
|
|
289
|
+
// --- the login and consent pages ----------------------------------
|
|
290
|
+
SESSION_LOGIN_PAGE_URL: "/login",
|
|
291
|
+
OAUTH_CONSENT_PAGE_URL: "/consent",
|
|
226
292
|
|
|
227
293
|
// --- cors ---------------------------------------------------------
|
|
228
|
-
//
|
|
294
|
+
// A list, in the only shape an environment variable can carry one.
|
|
229
295
|
// The schema splits and validates it; the `turns every non-boolean
|
|
230
296
|
// override into its declared type` case below pins the result.
|
|
231
|
-
|
|
297
|
+
HTTP_CORS_ALLOWED_ORIGINS: "https://app.example.com,http://localhost:5173",
|
|
232
298
|
};
|
|
233
299
|
|
|
234
300
|
/**
|
|
@@ -239,53 +305,286 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
|
|
|
239
305
|
const DELIBERATELY_UNSET: Readonly<Record<string, string>> = {
|
|
240
306
|
OAUTH_AUTHORIZE_ALLOW_UNMARKED_CLIENTS:
|
|
241
307
|
"#330 tombstone — any value must fail boot with migration instructions",
|
|
308
|
+
DEPLOYMENT_MODE:
|
|
309
|
+
"renamed CORE_DEPLOYMENT_MODE, and only captured — set alone, or to another value, it fails boot",
|
|
310
|
+
MEMORY_RATE_LIMITER_MAX_BUCKETS:
|
|
311
|
+
"renamed CORE_RATE_LIMITER_MEMORY_MAX_BUCKETS, and only captured — set alone, or to another value, it fails boot",
|
|
312
|
+
RATE_LIMIT_FAIL_MODE:
|
|
313
|
+
"renamed REDIS_RATE_LIMITER_FAIL_MODE, and only captured — set alone, or to another value, it fails boot",
|
|
314
|
+
REFRESH_TOKEN_FAMILY_STORE_KEY_PREFIX:
|
|
315
|
+
"renamed REDIS_REFRESH_TOKEN_FAMILY_STORE_KEY_PREFIX, and only captured — set alone, or to another value, it fails boot",
|
|
316
|
+
REFRESH_TOKEN_FAMILY_STORE_CAS_RETRY_LIMIT:
|
|
317
|
+
"renamed REDIS_REFRESH_TOKEN_FAMILY_STORE_CAS_RETRY_LIMIT, and only captured — set alone, or to another value, it fails boot",
|
|
318
|
+
FEDERATION_GRANTS_ENCRYPTION_MODE:
|
|
319
|
+
"renamed REDIS_FEDERATION_GRANT_STORE_ENCRYPTION_MODE, and only captured — set alone, or to another value, it fails boot",
|
|
320
|
+
OAUTH_TOKEN_BINDING_DISPATCH_POLICY:
|
|
321
|
+
"renamed CORE_TOKEN_BINDING_DISPATCH_POLICY, and only captured — set alone, or to another value, it fails boot",
|
|
322
|
+
OAUTH_TOKEN_BINDING_BIND_CONFIDENTIAL_CLIENT_REFRESH_TOKENS:
|
|
323
|
+
"renamed CORE_TOKEN_BINDING_BIND_CONFIDENTIAL_CLIENT_REFRESH_TOKENS, and only captured — set alone, or to another value, it fails boot",
|
|
324
|
+
OAUTH_GRANTS_SESSION_ENABLED:
|
|
325
|
+
"renamed OAUTH_SESSION_ENABLED, and only captured — set alone, or to another value, it fails boot",
|
|
326
|
+
OAUTH_GRANTS_AUTHORIZATION_CODE_ENABLED:
|
|
327
|
+
"renamed OAUTH_AUTHORIZATION_GRANTS_AUTHORIZATION_CODE_ENABLED, and only captured — set alone, or to another value, it fails boot",
|
|
328
|
+
OAUTH_GRANTS_REFRESH_TOKEN_ENABLED:
|
|
329
|
+
"renamed OAUTH_AUTHORIZATION_GRANTS_REFRESH_TOKEN_ENABLED, and only captured — set alone, or to another value, it fails boot",
|
|
330
|
+
OAUTH_GRANTS_CLIENT_CREDENTIALS_ENABLED:
|
|
331
|
+
"renamed OAUTH_AUTHORIZATION_GRANTS_CLIENT_CREDENTIALS_ENABLED, and only captured — set alone, or to another value, it fails boot",
|
|
332
|
+
OAUTH_GRANTS_JWT_BEARER_ENABLED:
|
|
333
|
+
"renamed OAUTH_AUTHORIZATION_GRANTS_JWT_BEARER_ENABLED, and only captured — set alone, or to another value, it fails boot",
|
|
334
|
+
ENDPOINTS_CONSENT_URL:
|
|
335
|
+
"renamed OAUTH_CONSENT_PAGE_URL, and only captured — set alone, or to another value, it fails boot",
|
|
336
|
+
OAUTH_CIMD_ENABLED:
|
|
337
|
+
"renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_ENABLED, and only captured — set alone, or to another value, it fails boot",
|
|
338
|
+
OAUTH_CIMD_ALLOWED_SCOPES:
|
|
339
|
+
"renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_ALLOWED_SCOPES, and only captured — set alone, or to another value, it fails boot",
|
|
340
|
+
OAUTH_CIMD_ALLOWED_AUDIENCES:
|
|
341
|
+
"renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_ALLOWED_AUDIENCES, and only captured — set alone, or to another value, it fails boot",
|
|
342
|
+
OAUTH_CIMD_ALLOWED_HOSTS:
|
|
343
|
+
"renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_ALLOWED_HOSTS, and only captured — set alone, or to another value, it fails boot",
|
|
344
|
+
OAUTH_CIMD_DENIED_HOSTS:
|
|
345
|
+
"renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_DENIED_HOSTS, and only captured — set alone, or to another value, it fails boot",
|
|
346
|
+
OAUTH_CIMD_MAX_BYTES:
|
|
347
|
+
"renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_MAX_BYTES, and only captured — set alone, or to another value, it fails boot",
|
|
348
|
+
OAUTH_CIMD_TIMEOUT_MS:
|
|
349
|
+
"renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_TIMEOUT_MS, and only captured — set alone, or to another value, it fails boot",
|
|
350
|
+
OAUTH_CIMD_CACHE_MAX_AGE_MS:
|
|
351
|
+
"renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_CACHE_MAX_AGE_MS, and only captured — set alone, or to another value, it fails boot",
|
|
352
|
+
OAUTH_CIMD_MAX_CACHE_ENTRIES:
|
|
353
|
+
"renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_MAX_CACHE_ENTRIES, and only captured — set alone, or to another value, it fails boot",
|
|
354
|
+
OAUTH_CIMD_STALE_IF_ERROR_MS:
|
|
355
|
+
"renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_STALE_IF_ERROR_MS, and only captured — set alone, or to another value, it fails boot",
|
|
356
|
+
OAUTH_CIMD_NEGATIVE_CACHE_MS:
|
|
357
|
+
"renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_NEGATIVE_CACHE_MS, and only captured — set alone, or to another value, it fails boot",
|
|
358
|
+
OAUTH_CIMD_MAX_CONCURRENT_FETCHES:
|
|
359
|
+
"renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_MAX_CONCURRENT_FETCHES, and only captured — set alone, or to another value, it fails boot",
|
|
360
|
+
OAUTH_GRANTS_AUTHORIZATION_CODE_PKCE_REQUIRE_S256:
|
|
361
|
+
"the authorization-code grant's pkce block was removed, and this is only captured — set at all, it fails boot",
|
|
362
|
+
ENDPOINTS_LOGIN_URL:
|
|
363
|
+
"renamed SESSION_LOGIN_PAGE_URL, and only captured — set alone, or to another value, it fails boot",
|
|
364
|
+
SESSION_SECRET:
|
|
365
|
+
"renamed SESSION_STORE_SECRET, and only captured — set alone, or to another value, it fails boot",
|
|
366
|
+
SESSION_NAME:
|
|
367
|
+
"renamed SESSION_STORE_NAME, and only captured — set alone, or to another value, it fails boot",
|
|
368
|
+
SESSION_MAX_AGE:
|
|
369
|
+
"renamed SESSION_STORE_MAX_AGE, and only captured — set alone, or to another value, it fails boot",
|
|
370
|
+
SESSION_SECURE:
|
|
371
|
+
"renamed SESSION_STORE_SECURE, and only captured — set alone, or to another value, it fails boot",
|
|
372
|
+
SESSION_SAME_SITE:
|
|
373
|
+
"renamed SESSION_STORE_SAME_SITE, and only captured — set alone, or to another value, it fails boot",
|
|
374
|
+
SESSION_DOMAIN:
|
|
375
|
+
"renamed SESSION_STORE_DOMAIN, and only captured — set alone, or to another value, it fails boot",
|
|
376
|
+
SESSION_STORAGE_TYPE:
|
|
377
|
+
"renamed SESSION_STORE_STORAGE_TYPE, and only captured — set alone, or to another value, it fails boot",
|
|
378
|
+
SESSION_STORAGE_REDIS_URL:
|
|
379
|
+
"renamed SESSION_STORE_STORAGE_REDIS_URL, and only captured — set alone, or to another value, it fails boot",
|
|
380
|
+
SESSION_STORAGE_REDIS_PASSWORD:
|
|
381
|
+
"renamed SESSION_STORE_STORAGE_REDIS_PASSWORD, and only captured — set alone, or to another value, it fails boot",
|
|
382
|
+
LOG_LEVEL:
|
|
383
|
+
"renamed LOGGING_LEVEL, and only captured — set alone, or to another value, it fails boot",
|
|
384
|
+
CORS_ALLOWED_ORIGINS:
|
|
385
|
+
"renamed HTTP_CORS_ALLOWED_ORIGINS, and only captured — set alone, or to another value, it fails boot",
|
|
386
|
+
OAUTH_JWT_SIGNING_KEY_PROVIDER:
|
|
387
|
+
"renamed KEY_STORE_PROVIDER, and only captured — set alone, or to another value, it fails boot",
|
|
388
|
+
OAUTH_JWT_ALGORITHM:
|
|
389
|
+
"renamed KEY_STORE_LOCAL_ALGORITHM, and only captured — set alone, or to another value, it fails boot",
|
|
390
|
+
OAUTH_JWT_KID:
|
|
391
|
+
"renamed KEY_STORE_LOCAL_KID, and only captured — set alone, or to another value, it fails boot",
|
|
392
|
+
OAUTH_JWT_SECRET:
|
|
393
|
+
"renamed KEY_STORE_LOCAL_SECRET, and only captured — set alone, or to another value, it fails boot",
|
|
394
|
+
OAUTH_JWT_PRIVATE_KEY_PATH:
|
|
395
|
+
"renamed KEY_STORE_LOCAL_PRIVATE_KEY_PATH, and only captured — set alone, or to another value, it fails boot",
|
|
396
|
+
OAUTH_JWT_PUBLIC_KEY_PATH:
|
|
397
|
+
"renamed KEY_STORE_LOCAL_PUBLIC_KEY_PATH, and only captured — set alone, or to another value, it fails boot",
|
|
398
|
+
OAUTH_JWT_PRIVATE_KEY:
|
|
399
|
+
"renamed KEY_STORE_LOCAL_PRIVATE_KEY, and only captured — set alone, or to another value, it fails boot",
|
|
400
|
+
OAUTH_JWT_PUBLIC_KEY:
|
|
401
|
+
"renamed KEY_STORE_LOCAL_PUBLIC_KEY, and only captured — set alone, or to another value, it fails boot",
|
|
402
|
+
REFRESH_TOKEN_FAMILY_STORE_REDIS_URL:
|
|
403
|
+
"renamed REDIS_CLIENTS_URL, and only captured — set alone, or to another value, it fails boot",
|
|
404
|
+
REFRESH_TOKEN_FAMILY_STORE_REDIS_PASSWORD:
|
|
405
|
+
"renamed REDIS_CLIENTS_PASSWORD, and only captured — set alone, or to another value, it fails boot",
|
|
406
|
+
CLIENT_PATH:
|
|
407
|
+
"renamed REPOSITORIES_CLIENT_YAML_PATH, and only captured — set alone, or to another value, it fails boot",
|
|
408
|
+
CLIENT_USER_PATH:
|
|
409
|
+
"renamed REPOSITORIES_USER_YAML_PATH, and only captured — set alone, or to another value, it fails boot",
|
|
410
|
+
CLIENT_USER_AUTHENTICATE_URL:
|
|
411
|
+
"renamed REPOSITORIES_USER_HTTP_AUTHENTICATE_URL, and only captured — set alone, or to another value, it fails boot",
|
|
412
|
+
CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL:
|
|
413
|
+
"renamed REPOSITORIES_USER_HTTP_AUTHENTICATE_BY_TOKEN_URL, and only captured — set alone, or to another value, it fails boot",
|
|
414
|
+
CLIENT_USER_LINK_FEDERATED_IDENTITY_URL:
|
|
415
|
+
"renamed REPOSITORIES_USER_HTTP_LINK_FEDERATED_IDENTITY_URL, and only captured — set alone, or to another value, it fails boot",
|
|
416
|
+
CLIENT_USER_FIND_SUBJECT_BY_FEDERATED_IDENTITY_URL:
|
|
417
|
+
"renamed REPOSITORIES_USER_HTTP_FIND_SUBJECT_BY_FEDERATED_IDENTITY_URL, and only captured — set alone, or to another value, it fails boot",
|
|
418
|
+
CLIENT_USER_BEARER_TOKEN:
|
|
419
|
+
"renamed REPOSITORIES_USER_HTTP_BEARER_TOKEN, and only captured — set alone, or to another value, it fails boot",
|
|
420
|
+
CLIENT_USER_TIMEOUT:
|
|
421
|
+
"renamed REPOSITORIES_USER_HTTP_TIMEOUT, and only captured — set alone, or to another value, it fails boot",
|
|
422
|
+
CLIENT_USER_MAX_RESPONSE_BYTES:
|
|
423
|
+
"renamed REPOSITORIES_USER_HTTP_MAX_RESPONSE_BYTES, and only captured — set alone, or to another value, it fails boot",
|
|
424
|
+
CLIENT_CODE_DEFAULT_EXPIRES_IN:
|
|
425
|
+
"renamed REDIS_CODE_REPOSITORY_DEFAULT_EXPIRES_IN (the Redis code repository) and STANDALONE_IN_MEMORY_CODE_REPOSITORY_DEFAULT_EXPIRES_IN (the in-process one), and only captured — set alone, or to another value, it fails boot",
|
|
426
|
+
CLIENT_CODE_KEY_PREFIX:
|
|
427
|
+
"renamed REDIS_CODE_REPOSITORY_KEY_PREFIX, and only captured — set alone, or to another value, it fails boot",
|
|
428
|
+
CLIENT_CODE_ENDPOINT_URI:
|
|
429
|
+
"removed: the Redis code repository uses the shared redis-clients connection; only captured — set at all, it fails boot",
|
|
430
|
+
CLIENT_CODE_PASSWORD:
|
|
431
|
+
"removed: the Redis code repository uses the shared redis-clients connection; only captured — set at all, it fails boot",
|
|
432
|
+
ENDPOINTS_MFA_URL:
|
|
433
|
+
"renamed MFA_PAGE_URL, and only captured — set alone, or to another value, it fails boot where MFA is installed",
|
|
434
|
+
MFA_TOTP_ENABLED:
|
|
435
|
+
"renamed MFA_TOTP_FACTOR_ENABLED, and only captured — set alone, or to another value, it fails boot where MFA is installed",
|
|
436
|
+
MFA_TOTP_ISSUER:
|
|
437
|
+
"renamed MFA_TOTP_FACTOR_ISSUER, and only captured — set alone, or to another value, it fails boot where MFA is installed",
|
|
242
438
|
};
|
|
243
439
|
|
|
244
440
|
/**
|
|
245
441
|
* The provider environment `o3co/auth`'s `tests/docker-compose.yml` sets,
|
|
246
|
-
* transcribed
|
|
442
|
+
* transcribed: each renamed variable under its old and its new name, the two
|
|
443
|
+
* at one value. The umbrella E2E boots the shipped template with exactly this,
|
|
247
444
|
* so a parse failure here is a red umbrella build that this repository can see
|
|
248
|
-
* first. `
|
|
249
|
-
* speaks plain HTTP.
|
|
445
|
+
* first. `SESSION_STORE_SECURE=false` is the one it cannot run without: the
|
|
446
|
+
* suite speaks plain HTTP.
|
|
250
447
|
*
|
|
251
|
-
* The two `FEDERATION_TOKEN_STORE` lines are
|
|
252
|
-
*
|
|
253
|
-
* and
|
|
254
|
-
*
|
|
255
|
-
* selects the Redis store and supplies its encryption key. Until that lands
|
|
256
|
-
* in `o3co/auth`, this transcription is ahead of the file it transcribes on
|
|
257
|
-
* exactly those two lines.
|
|
448
|
+
* The two `FEDERATION_TOKEN_STORE` lines are required: the federation token
|
|
449
|
+
* store defaults to memory, the standalone's memory module declares itself
|
|
450
|
+
* replica-unsafe, and `CORE_DEPLOYMENT_MODE=multi` refuses it by name unless the
|
|
451
|
+
* Redis store is selected with its encryption key.
|
|
258
452
|
*/
|
|
259
453
|
const UMBRELLA_E2E_ENV: Readonly<Record<string, string>> = {
|
|
260
454
|
OAUTH_JWT_ALGORITHM: "HS256",
|
|
455
|
+
KEY_STORE_LOCAL_ALGORITHM: "HS256",
|
|
261
456
|
OAUTH_JWT_SECRET: "e2e-shared-hs256-secret.at-least-32-bytes.ok",
|
|
457
|
+
KEY_STORE_LOCAL_SECRET: "e2e-shared-hs256-secret.at-least-32-bytes.ok",
|
|
262
458
|
OAUTH_JWT_ISSUER: "https://auth.e2e.test",
|
|
263
459
|
SESSION_SECRET: "lO0QH09fuKSGuViZ9myJbH3jsgai99A2GpC3RYRuy6Y=",
|
|
460
|
+
SESSION_STORE_SECRET: "lO0QH09fuKSGuViZ9myJbH3jsgai99A2GpC3RYRuy6Y=",
|
|
264
461
|
SESSION_SECURE: "false",
|
|
462
|
+
SESSION_STORE_SECURE: "false",
|
|
265
463
|
SESSION_NAME: "auth.session",
|
|
464
|
+
SESSION_STORE_NAME: "auth.session",
|
|
266
465
|
DEPLOYMENT_MODE: "multi",
|
|
466
|
+
CORE_DEPLOYMENT_MODE: "multi",
|
|
267
467
|
REFRESH_TOKEN_FAMILY_STORE_REDIS_URL: "redis://redis:6379",
|
|
468
|
+
REDIS_CLIENTS_URL: "redis://redis:6379",
|
|
268
469
|
SESSION_STORAGE_REDIS_URL: "redis://redis:6379",
|
|
470
|
+
SESSION_STORE_STORAGE_REDIS_URL: "redis://redis:6379",
|
|
269
471
|
USER_SESSION_STORES_ADAPTER: "redis",
|
|
472
|
+
ADAPTERS_USER_SESSION_STORES: "redis",
|
|
270
473
|
RATE_LIMITER_ADAPTER: "redis",
|
|
474
|
+
ADAPTERS_RATE_LIMITER: "redis",
|
|
271
475
|
OAUTH_CODE_ADAPTER: "redis",
|
|
476
|
+
ADAPTERS_CODE_REPOSITORY: "redis",
|
|
272
477
|
FEDERATION_TOKEN_STORE_TYPE: "redis",
|
|
478
|
+
ADAPTERS_FEDERATION_TOKEN_STORE: "redis",
|
|
273
479
|
REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY: "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=",
|
|
274
480
|
CLIENT_USER_TYPE: "yaml",
|
|
481
|
+
ADAPTERS_USER_REPOSITORY: "yaml",
|
|
275
482
|
OAUTH_RESOURCE_INDICATOR_ENABLED: "true",
|
|
276
483
|
OAUTH_REQUIRE_EMAIL_VERIFIED: "true",
|
|
277
|
-
|
|
484
|
+
OAUTH_GRANTS_SESSION_ENABLED: "true",
|
|
485
|
+
OAUTH_SESSION_ENABLED: "true",
|
|
486
|
+
MFA_MODE: "off",
|
|
278
487
|
};
|
|
279
488
|
|
|
280
|
-
|
|
489
|
+
/** The template's own files for `configEnv`, under `operatorLayer` — HOCON an operator adds above them — when given. */
|
|
490
|
+
function ownFiles(configEnv: string, operatorLayer?: string): string[] {
|
|
281
491
|
const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, configEnv);
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
492
|
+
if (operatorLayer === undefined) return [envConfPath, applicationConfPath];
|
|
493
|
+
const file = join(mkdtempSync(join(tmpdir(), "documented-env-overrides-")), "operator.conf");
|
|
494
|
+
writeFileSync(file, operatorLayer);
|
|
495
|
+
return [file, envConfPath, applicationConfPath];
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** Phase one, as `app.mts` reads it: the switches. */
|
|
499
|
+
function readShippedSwitches(env: Record<string, string>, configEnv = "production"): Switches {
|
|
500
|
+
return readSwitches(readOwnLayers(ownFiles(configEnv), { env }));
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* The stores an enabled federation needs beside it, which boot refuses a
|
|
505
|
+
* composition without. What they hold is not this suite's question.
|
|
506
|
+
*/
|
|
507
|
+
const FEDERATION_STORES = Object.fromEntries(
|
|
508
|
+
[
|
|
509
|
+
"userSessionStore",
|
|
510
|
+
"sessionRPRegistry",
|
|
511
|
+
"sessionFamilyIndex",
|
|
512
|
+
"sessionFederationIndex",
|
|
513
|
+
"federationTokenStore",
|
|
514
|
+
"refreshTokenFamilyRevocation",
|
|
515
|
+
].map((key) => [key, {}]),
|
|
516
|
+
);
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* The shipped layers under `env`, as `app.mts` hands them to boot, and the
|
|
520
|
+
* configuration boot parsed: phase one for what the composition expects of
|
|
521
|
+
* session admission, phase two resolved over the reference of every package
|
|
522
|
+
* the template's modules come from (the template's own and core's) and
|
|
523
|
+
* parsed once by `createApp` — no bridge on the way.
|
|
524
|
+
* No module is loaded unless `modules` names one: the parse is what this
|
|
525
|
+
* suite asks about, and each key it reads is one core's schema declares, or
|
|
526
|
+
* the section of a module it loads.
|
|
527
|
+
*/
|
|
528
|
+
async function bootParsed(
|
|
529
|
+
env: Record<string, string>,
|
|
530
|
+
configEnv = "production",
|
|
531
|
+
operatorLayer?: string,
|
|
532
|
+
modules: readonly Module[] = [],
|
|
533
|
+
): Promise<AppConfig> {
|
|
534
|
+
const own = readOwnLayers(ownFiles(configEnv, operatorLayer), { env });
|
|
535
|
+
const switches = readSwitches(own);
|
|
536
|
+
const handle = await createApp({
|
|
537
|
+
modules: [...modules],
|
|
538
|
+
bootstrapComponents: {
|
|
539
|
+
config: resolveForBoot(own, buildModules(switches, { environment: configEnv }), switches),
|
|
540
|
+
pathResolver: (s: string) => s,
|
|
541
|
+
...FEDERATION_STORES,
|
|
542
|
+
} as never,
|
|
543
|
+
});
|
|
544
|
+
const parsed = handle.components.config;
|
|
545
|
+
await handle.dispose();
|
|
546
|
+
if (parsed === undefined) throw new Error("createApp booted without the parsed configuration");
|
|
547
|
+
return parsed;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** `session-store {}` as the session store's module parses it. */
|
|
551
|
+
function sessionStoreSection(config: AppConfig): {
|
|
552
|
+
readonly secure: boolean;
|
|
553
|
+
readonly sameSite: string;
|
|
554
|
+
readonly maxAge: number;
|
|
555
|
+
} {
|
|
556
|
+
const schema = sessionStoreModule.section?.schema;
|
|
557
|
+
if (schema === undefined) throw new Error("the session store's module declares no section");
|
|
558
|
+
return schema.parse((config as Record<string, unknown>)["session-store"]) as ReturnType<
|
|
559
|
+
typeof sessionStoreSection
|
|
560
|
+
>;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** `session {}` as the session module parses it. */
|
|
564
|
+
function sessionSection(config: AppConfig): { readonly csrf?: { readonly ttlSeconds: number } } {
|
|
565
|
+
const schema = sessionModule.section?.schema;
|
|
566
|
+
if (schema === undefined) throw new Error("the session module declares no section");
|
|
567
|
+
return schema.parse(config.session) as ReturnType<typeof sessionSection>;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** `http {}` as the `http` module parsed it, in a configuration boot parsed with the module loaded. */
|
|
571
|
+
function httpSectionOf(config: AppConfig): {
|
|
572
|
+
readonly port: number;
|
|
573
|
+
readonly readinessTimeoutMs: number;
|
|
574
|
+
readonly trustProxy: unknown;
|
|
575
|
+
readonly cors: { readonly allowedOrigins: readonly string[] };
|
|
576
|
+
} {
|
|
577
|
+
return (config as unknown as { http: ReturnType<typeof httpSectionOf> }).http;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** `oauth {}` as the oauth module's own schema parses it. */
|
|
581
|
+
function oauthSection(config: AppConfig): {
|
|
582
|
+
readonly consentPage?: { readonly url: string };
|
|
583
|
+
readonly clientIdMetadataDocuments?: Readonly<Record<string, unknown>>;
|
|
584
|
+
} {
|
|
585
|
+
const schema = oauthModule({ config }).section?.schema;
|
|
586
|
+
if (schema === undefined) throw new Error("the oauth module declares no section");
|
|
587
|
+
return schema.parse(config.oauth) as ReturnType<typeof oauthSection>;
|
|
289
588
|
}
|
|
290
589
|
|
|
291
590
|
/** Every `${?VAR}` in a HOCON layer, ignoring commented-out lines. */
|
|
@@ -306,51 +605,66 @@ function substitutionsIn(path: string): Set<string> {
|
|
|
306
605
|
* The underscore is what separates an environment variable from the HTTP
|
|
307
606
|
* methods in the endpoint table, which share the shouting-case shape.
|
|
308
607
|
*/
|
|
309
|
-
function documentedInReadme(): Set<string> {
|
|
608
|
+
function documentedInReadme(path: string = readmePath): Set<string> {
|
|
310
609
|
const found = new Set<string>();
|
|
311
|
-
for (const match of readFileSync(
|
|
312
|
-
/^\|\s*`([A-Z][A-Z0-9_]*)`\s*\|/gm,
|
|
313
|
-
)) {
|
|
610
|
+
for (const match of readFileSync(path, "utf8").matchAll(/^\|\s*`([A-Z][A-Z0-9_]*)`\s*\|/gm)) {
|
|
314
611
|
const name = match[1] as string;
|
|
315
612
|
if (name.includes("_")) found.add(name);
|
|
316
613
|
}
|
|
317
614
|
return found;
|
|
318
615
|
}
|
|
319
616
|
|
|
617
|
+
/**
|
|
618
|
+
* The substitutions of the template's own layers, and of the `reference.conf`
|
|
619
|
+
* of every package the template loads a module from under the documented
|
|
620
|
+
* environment in production (core's among them) — and under it with MFA
|
|
621
|
+
* installed, its factors in the Store, which brings the MFA package's and
|
|
622
|
+
* foundation's.
|
|
623
|
+
*/
|
|
320
624
|
function liveSubstitutions(): Set<string> {
|
|
321
625
|
const { applicationConfPath } = resolveConfigPaths(configDir, "production");
|
|
626
|
+
const references = [
|
|
627
|
+
DOCUMENTED_ENV,
|
|
628
|
+
{ ...DOCUMENTED_ENV, MFA_MODE: "required", ADAPTERS_MFA_FACTOR_STORE: "store" },
|
|
629
|
+
].flatMap((env) =>
|
|
630
|
+
moduleReferences(buildModules(readShippedSwitches(env), { environment: "production" })),
|
|
631
|
+
);
|
|
322
632
|
return new Set([
|
|
323
|
-
...substitutionsIn(
|
|
633
|
+
...references.flatMap((reference) => [...substitutionsIn(fileURLToPath(reference))]),
|
|
634
|
+
...substitutionsIn(fileURLToPath(templateReference())),
|
|
324
635
|
...substitutionsIn(applicationConfPath),
|
|
325
636
|
]);
|
|
326
637
|
}
|
|
327
638
|
|
|
328
|
-
describe("
|
|
329
|
-
it("parses with every documented environment variable set", () => {
|
|
330
|
-
expect((
|
|
639
|
+
describe("the shipped config boots with every documented override supplied as a string", () => {
|
|
640
|
+
it("parses with every documented environment variable set", async () => {
|
|
641
|
+
await expect(bootParsed(DOCUMENTED_ENV)).resolves.toBeDefined();
|
|
331
642
|
});
|
|
332
643
|
|
|
333
|
-
it("turns every boolean override into an actual boolean", () => {
|
|
334
|
-
const config =
|
|
644
|
+
it("turns every boolean override into an actual boolean", async () => {
|
|
645
|
+
const config = await bootParsed(DOCUMENTED_ENV);
|
|
335
646
|
// Each of these arrives from HOCON as a string. A leftover string is
|
|
336
647
|
// not a cosmetic defect: `=== true` is how the runtime reads them.
|
|
337
|
-
expect(config.
|
|
648
|
+
expect(sessionStoreSection(config).secure).toBe(false);
|
|
338
649
|
expect(config.oauth.jwt.legacyTypAccept).toBe(true);
|
|
339
650
|
expect(config.oauth.requireEmailVerified).toBe(true);
|
|
340
651
|
expect(config.oauth.resourceIndicator?.enabled).toBe(true);
|
|
341
|
-
expect(config.federations
|
|
342
|
-
expect(config.federations
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
|
|
346
|
-
expect(config.federationGrants?.enabled).toBe(true);
|
|
652
|
+
expect(config.core?.federations?.google?.enabled).toBe(true);
|
|
653
|
+
expect(config.core?.federations?.oidc?.enabled).toBe(true);
|
|
654
|
+
// A leftover string here would be read as "on" by a truthiness check
|
|
655
|
+
// and as "off" by `=== true`, for a feature whose whole default is off.
|
|
656
|
+
expect(config["federation-grants"]?.enabled).toBe(true);
|
|
347
657
|
});
|
|
348
658
|
|
|
349
|
-
it("turns every non-boolean override into its declared type", () => {
|
|
350
|
-
const config =
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
659
|
+
it("turns every non-boolean override into its declared type", async () => {
|
|
660
|
+
const config = await bootParsed(DOCUMENTED_ENV);
|
|
661
|
+
const http = httpSectionOf(
|
|
662
|
+
await bootParsed(DOCUMENTED_ENV, "production", undefined, [httpModule]),
|
|
663
|
+
);
|
|
664
|
+
expect(http.port).toBe(3000);
|
|
665
|
+
expect(http.readinessTimeoutMs).toBe(1500);
|
|
666
|
+
expect(http.trustProxy).toEqual(["10.0.0.0/8", "loopback"]);
|
|
667
|
+
expect(config.core?.federations?.google?.accessType).toBe("online");
|
|
354
668
|
// The new default wins over the deprecated variable, and the parsed
|
|
355
669
|
// config mirrors it onto the old key for readers that predate the split.
|
|
356
670
|
expect(resolveAccessTokenLifetime(config)).toEqual({
|
|
@@ -359,13 +673,16 @@ describe("#288: the shipped config boots with every documented override supplied
|
|
|
359
673
|
});
|
|
360
674
|
expect(config.oauth.accessToken.expiresIn).toBe(900);
|
|
361
675
|
expect(config.oauth.refreshToken.expiresIn).toBe(86400);
|
|
362
|
-
expect(config.
|
|
363
|
-
expect(config.
|
|
676
|
+
expect(sessionStoreSection(config).maxAge).toBe(3600000);
|
|
677
|
+
expect(sessionSection(config).csrf?.ttlSeconds).toBe(7200);
|
|
364
678
|
expect(config.oauth.nonce?.maxLength).toBe(256);
|
|
365
|
-
expect(
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
expect(
|
|
679
|
+
expect(readShippedSwitches(DOCUMENTED_ENV).adapters.consentStore).toBe("redis");
|
|
680
|
+
// A Redis store's section, which its module (not loaded here) parses.
|
|
681
|
+
const sections = config as unknown as Record<string, { keyPrefix?: unknown } | undefined>;
|
|
682
|
+
expect(sections["redis-consent-store"]?.keyPrefix).toBe("tenant-a:consent:");
|
|
683
|
+
// The comma-separated lists become lists, trimmed; the numbers, numbers:
|
|
684
|
+
// the oauth module's schema reads them (the module is not loaded here).
|
|
685
|
+
expect(oauthSection(config).clientIdMetadataDocuments).toEqual({
|
|
369
686
|
enabled: true,
|
|
370
687
|
allowedScopes: ["read", "write"],
|
|
371
688
|
allowedAudiences: ["https://mcp.example"],
|
|
@@ -379,22 +696,35 @@ describe("#288: the shipped config boots with every documented override supplied
|
|
|
379
696
|
maxConcurrentFetches: 4,
|
|
380
697
|
cacheMaxAgeMs: 60000,
|
|
381
698
|
});
|
|
382
|
-
//
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
699
|
+
// The MFA switch is read before boot, by the template; off, it hands
|
|
700
|
+
// boot no `mfa` section.
|
|
701
|
+
expect(readShippedSwitches(DOCUMENTED_ENV).mfaMode).toBe("off");
|
|
702
|
+
expect(config).not.toHaveProperty("mfa");
|
|
703
|
+
expect(config).not.toHaveProperty("endpoints.mfa");
|
|
704
|
+
expect(readShippedSwitches(DOCUMENTED_ENV).adapters).toMatchObject({
|
|
705
|
+
mfaFactorStore: "redis",
|
|
706
|
+
mfaTransactionStore: "redis",
|
|
707
|
+
});
|
|
708
|
+
expect(sections["redis-mfa-factor-store"]?.keyPrefix).toBe("tenant-a:mfaf:");
|
|
709
|
+
expect(sections["redis-mfa-transaction-store"]?.keyPrefix).toBe("tenant-a:mfat:");
|
|
710
|
+
// A comma-separated string becomes a list of origins, trimmed.
|
|
711
|
+
expect(http.cors.allowedOrigins).toEqual(["https://app.example.com", "http://localhost:5173"]);
|
|
387
712
|
});
|
|
388
713
|
|
|
389
|
-
describe("
|
|
390
|
-
it("reads an exported-but-empty variable as no origins, not as an error", () => {
|
|
714
|
+
describe("HTTP_CORS_ALLOWED_ORIGINS", () => {
|
|
715
|
+
it("reads an exported-but-empty variable as no origins, not as an error", async () => {
|
|
391
716
|
// The .env / compose / ConfigMap shape. "CORS off" is what both the
|
|
392
717
|
// unset key and the empty string mean, so they must agree.
|
|
393
|
-
const config =
|
|
394
|
-
|
|
718
|
+
const config = await bootParsed(
|
|
719
|
+
{ ...DOCUMENTED_ENV, HTTP_CORS_ALLOWED_ORIGINS: "" },
|
|
720
|
+
"production",
|
|
721
|
+
undefined,
|
|
722
|
+
[httpModule],
|
|
723
|
+
);
|
|
724
|
+
expect(httpSectionOf(config).cors.allowedOrigins).toEqual([]);
|
|
395
725
|
});
|
|
396
726
|
|
|
397
|
-
it("fails boot on an origin that could never match, naming the key", () => {
|
|
727
|
+
it("fails boot on an origin that could never match, naming the key", async () => {
|
|
398
728
|
// Every one of these parses as a URL and is a real typo: matching is
|
|
399
729
|
// exact string equality against the Origin header, so each would be
|
|
400
730
|
// an allowlist that admits nobody with nothing to say so.
|
|
@@ -405,56 +735,96 @@ describe("#288: the shipped config boots with every documented override supplied
|
|
|
405
735
|
"http://app.example.com", // plaintext off loopback
|
|
406
736
|
"https://app.example.com/callback", // a URL, not an origin
|
|
407
737
|
]) {
|
|
408
|
-
expect(
|
|
409
|
-
|
|
410
|
-
|
|
738
|
+
await expect(
|
|
739
|
+
bootParsed(
|
|
740
|
+
{ ...DOCUMENTED_ENV, HTTP_CORS_ALLOWED_ORIGINS: bad },
|
|
741
|
+
"production",
|
|
742
|
+
undefined,
|
|
743
|
+
[httpModule],
|
|
744
|
+
),
|
|
745
|
+
).rejects.toThrow(/http\.cors\.allowedOrigins/);
|
|
411
746
|
}
|
|
412
747
|
});
|
|
413
748
|
|
|
414
|
-
it("
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
})
|
|
419
|
-
|
|
749
|
+
it("fails boot on a value that is neither a list nor a string, naming the key", async () => {
|
|
750
|
+
// The variable can only ever carry a string, so this shape comes
|
|
751
|
+
// from a configuration file. Read as no origins, it would turn CORS
|
|
752
|
+
// silently off for a key someone wrote.
|
|
753
|
+
for (const value of ["42", "true", '{ origin = "https://app.example.com" }']) {
|
|
754
|
+
await expect(
|
|
755
|
+
bootParsed(DOCUMENTED_ENV, "production", `http.cors.allowedOrigins = ${value}`, [
|
|
756
|
+
httpModule,
|
|
757
|
+
]),
|
|
758
|
+
value,
|
|
759
|
+
).rejects.toThrow(/http\.cors\.allowedOrigins/);
|
|
760
|
+
}
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
it("accepts the loopback http carve-out a dev front-end needs", async () => {
|
|
764
|
+
const config = await bootParsed(
|
|
765
|
+
{
|
|
766
|
+
...DOCUMENTED_ENV,
|
|
767
|
+
HTTP_CORS_ALLOWED_ORIGINS:
|
|
768
|
+
"http://localhost:5173,http://127.0.0.1:5173,https://app.example.com",
|
|
769
|
+
},
|
|
770
|
+
"production",
|
|
771
|
+
undefined,
|
|
772
|
+
[httpModule],
|
|
773
|
+
);
|
|
774
|
+
expect(httpSectionOf(config).cors.allowedOrigins).toHaveLength(3);
|
|
420
775
|
});
|
|
421
776
|
});
|
|
422
777
|
|
|
423
|
-
it("parses the HS256 shape, whose strict union refuses asymmetric key fields", () => {
|
|
424
|
-
// `
|
|
425
|
-
// HS256 member of the
|
|
426
|
-
// that switches algorithm must also stop exporting the
|
|
427
|
-
// variables. Worth pinning — it is the umbrella E2E's shape.
|
|
428
|
-
const {
|
|
429
|
-
|
|
430
|
-
void
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
void
|
|
434
|
-
|
|
435
|
-
|
|
778
|
+
it("parses the HS256 shape, whose strict union refuses asymmetric key fields", async () => {
|
|
779
|
+
// `KEY_STORE_LOCAL_ALGORITHM=HS256` is not a variation on the map above:
|
|
780
|
+
// the HS256 member of the key store's `local` union is strict, so a
|
|
781
|
+
// deployment that switches algorithm must also stop exporting the
|
|
782
|
+
// key-file variables. Worth pinning — it is the umbrella E2E's shape.
|
|
783
|
+
const { KEY_STORE_LOCAL_PRIVATE_KEY, KEY_STORE_LOCAL_PRIVATE_KEY_PATH, ...rest } =
|
|
784
|
+
DOCUMENTED_ENV;
|
|
785
|
+
void KEY_STORE_LOCAL_PRIVATE_KEY;
|
|
786
|
+
void KEY_STORE_LOCAL_PRIVATE_KEY_PATH;
|
|
787
|
+
const { KEY_STORE_LOCAL_PUBLIC_KEY, KEY_STORE_LOCAL_PUBLIC_KEY_PATH, ...hs256 } = rest;
|
|
788
|
+
void KEY_STORE_LOCAL_PUBLIC_KEY;
|
|
789
|
+
void KEY_STORE_LOCAL_PUBLIC_KEY_PATH;
|
|
790
|
+
const config = await bootParsed(
|
|
791
|
+
{ ...hs256, KEY_STORE_LOCAL_ALGORITHM: "HS256" },
|
|
792
|
+
"production",
|
|
793
|
+
undefined,
|
|
794
|
+
[keyStoreModule],
|
|
795
|
+
);
|
|
796
|
+
expect(
|
|
797
|
+
(config as unknown as { "key-store": { local: { algorithm: string } } })["key-store"].local
|
|
798
|
+
.algorithm,
|
|
799
|
+
).toBe("HS256");
|
|
436
800
|
});
|
|
437
801
|
|
|
438
|
-
it("parses the environment the umbrella E2E boots, with
|
|
439
|
-
const config =
|
|
440
|
-
expect(config.
|
|
802
|
+
it("parses the environment the umbrella E2E boots, with SESSION_STORE_SECURE=false as a string", async () => {
|
|
803
|
+
const config = await bootParsed(UMBRELLA_E2E_ENV);
|
|
804
|
+
expect(sessionStoreSection(config).secure).toBe(false);
|
|
441
805
|
expect(config.oauth.requireEmailVerified).toBe(true);
|
|
442
806
|
expect(config.oauth.resourceIndicator?.enabled).toBe(true);
|
|
443
|
-
expect(config.deployment?.mode).toBe("multi");
|
|
807
|
+
expect(config.core?.deployment?.mode).toBe("multi");
|
|
444
808
|
});
|
|
445
809
|
|
|
446
810
|
it("wires nothing replica-unsafe for the umbrella E2E environment", async () => {
|
|
447
|
-
// `
|
|
811
|
+
// `CORE_DEPLOYMENT_MODE=multi` makes the provider audit its own store wiring
|
|
448
812
|
// at boot, so a config that parses but wires a memory store still fails
|
|
449
|
-
// there. Ask each manifest, the way the guard does
|
|
450
|
-
//
|
|
451
|
-
// modules
|
|
813
|
+
// there. Ask each manifest, the way the guard does: the exported name
|
|
814
|
+
// list covers core's modules only, and cannot see the template's own
|
|
815
|
+
// memory modules.
|
|
452
816
|
const { replicaUnsafeReason } = await import("@o3co/auth-provider-core");
|
|
453
|
-
for (const module of buildModules(
|
|
817
|
+
for (const module of buildModules(readShippedSwitches(UMBRELLA_E2E_ENV))) {
|
|
454
818
|
expect(replicaUnsafeReason(module), module.name).toBeUndefined();
|
|
455
819
|
}
|
|
456
820
|
});
|
|
457
821
|
|
|
822
|
+
it("reads MFA_MODE=off before boot, where the shipped configuration expects no session requirement", async () => {
|
|
823
|
+
expect(readShippedSwitches({ ...DOCUMENTED_ENV, MFA_MODE: "off" }).mfaMode).toBe("off");
|
|
824
|
+
const parsed = await bootParsed({ ...DOCUMENTED_ENV, MFA_MODE: "off" });
|
|
825
|
+
expect(parsed.core?.sessionRequirements).toEqual({ expected: [] });
|
|
826
|
+
});
|
|
827
|
+
|
|
458
828
|
describe("boolean overrides accept the spellings an operator writes", () => {
|
|
459
829
|
const cases: ReadonlyArray<[string, boolean]> = [
|
|
460
830
|
["true", true],
|
|
@@ -463,22 +833,22 @@ describe("#288: the shipped config boots with every documented override supplied
|
|
|
463
833
|
["false", false],
|
|
464
834
|
["0", false],
|
|
465
835
|
// An exported-but-empty variable — the `.env` / compose / ConfigMap
|
|
466
|
-
// shape. Reads as false,
|
|
836
|
+
// shape. Reads as false, as it does for trustProxy.
|
|
467
837
|
["", false],
|
|
468
838
|
];
|
|
469
839
|
|
|
470
840
|
for (const [supplied, expected] of cases) {
|
|
471
|
-
it(`
|
|
472
|
-
const config =
|
|
841
|
+
it(`SESSION_STORE_SECURE=${JSON.stringify(supplied)} resolves to ${expected}`, async () => {
|
|
842
|
+
const config = await bootParsed({
|
|
473
843
|
...DOCUMENTED_ENV,
|
|
474
|
-
|
|
475
|
-
|
|
844
|
+
SESSION_STORE_SECURE: supplied,
|
|
845
|
+
SESSION_STORE_SAME_SITE: "lax",
|
|
476
846
|
});
|
|
477
|
-
expect(config.
|
|
847
|
+
expect(sessionStoreSection(config).secure).toBe(expected);
|
|
478
848
|
});
|
|
479
849
|
|
|
480
|
-
it(`OAUTH_JWT_LEGACY_TYP_ACCEPT=${JSON.stringify(supplied)} resolves to ${expected}`, () => {
|
|
481
|
-
const config =
|
|
850
|
+
it(`OAUTH_JWT_LEGACY_TYP_ACCEPT=${JSON.stringify(supplied)} resolves to ${expected}`, async () => {
|
|
851
|
+
const config = await bootParsed({
|
|
482
852
|
...DOCUMENTED_ENV,
|
|
483
853
|
OAUTH_JWT_LEGACY_TYP_ACCEPT: supplied,
|
|
484
854
|
});
|
|
@@ -486,30 +856,27 @@ describe("#288: the shipped config boots with every documented override supplied
|
|
|
486
856
|
});
|
|
487
857
|
}
|
|
488
858
|
|
|
489
|
-
it("
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
);
|
|
859
|
+
it("refuses a spelling it does not recognise rather than guessing", async () => {
|
|
860
|
+
const config = await bootParsed({ ...DOCUMENTED_ENV, SESSION_STORE_SECURE: "ture" });
|
|
861
|
+
expect(() => sessionStoreSection(config)).toThrow(/true.*false/s);
|
|
493
862
|
});
|
|
494
863
|
|
|
495
|
-
it("refuses
|
|
496
|
-
// The
|
|
864
|
+
it("refuses SESSION_STORE_SAME_SITE=none unless SESSION_STORE_SECURE is on", async () => {
|
|
865
|
+
// The SameSite=None guard reads the coerced value, so it has to keep firing
|
|
497
866
|
// for the string form an environment variable actually delivers.
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
}),
|
|
512
|
-
).not.toThrow();
|
|
867
|
+
const insecure = await bootParsed({
|
|
868
|
+
...DOCUMENTED_ENV,
|
|
869
|
+
SESSION_STORE_SAME_SITE: "none",
|
|
870
|
+
SESSION_STORE_SECURE: "false",
|
|
871
|
+
});
|
|
872
|
+
expect(() => sessionStoreSection(insecure)).toThrow(/SESSION_STORE_SECURE=true/);
|
|
873
|
+
const secure = await bootParsed({
|
|
874
|
+
...DOCUMENTED_ENV,
|
|
875
|
+
SESSION_STORE_SAME_SITE: "none",
|
|
876
|
+
SESSION_STORE_SECURE: "true",
|
|
877
|
+
SESSION_STORE_NAME: "auth.session",
|
|
878
|
+
});
|
|
879
|
+
expect(sessionStoreSection(secure).sameSite).toBe("none");
|
|
513
880
|
});
|
|
514
881
|
});
|
|
515
882
|
|
|
@@ -522,12 +889,23 @@ describe("#288: the shipped config boots with every documented override supplied
|
|
|
522
889
|
});
|
|
523
890
|
|
|
524
891
|
it("covers every environment variable the README documents", () => {
|
|
525
|
-
const uncovered = [...documentedInReadme()].filter(
|
|
892
|
+
const uncovered = [...documentedInReadme(), ...documentedInReadme(readmeJaPath)].filter(
|
|
526
893
|
(name) => !(name in DOCUMENTED_ENV) && !(name in DELIBERATELY_UNSET),
|
|
527
894
|
);
|
|
528
895
|
expect(uncovered).toEqual([]);
|
|
529
896
|
});
|
|
530
897
|
|
|
898
|
+
it("documents the same variables in README.md and README.ja.md", () => {
|
|
899
|
+
// The Japanese README carries the same facts (AGENTS.md): a row added
|
|
900
|
+
// to one and not the other is a variable one audience never hears of.
|
|
901
|
+
const en = documentedInReadme();
|
|
902
|
+
const ja = documentedInReadme(readmeJaPath);
|
|
903
|
+
expect({
|
|
904
|
+
onlyInEnglish: [...en].filter((name) => !ja.has(name)).sort(),
|
|
905
|
+
onlyInJapanese: [...ja].filter((name) => !en.has(name)).sort(),
|
|
906
|
+
}).toEqual({ onlyInEnglish: [], onlyInJapanese: [] });
|
|
907
|
+
});
|
|
908
|
+
|
|
531
909
|
it("exercises no variable the config layers no longer substitute", () => {
|
|
532
910
|
// Catches the reverse drift: a README row (and a test entry) left
|
|
533
911
|
// behind by a key that was removed from the HOCON.
|
|
@@ -538,23 +916,23 @@ describe("#288: the shipped config boots with every documented override supplied
|
|
|
538
916
|
});
|
|
539
917
|
|
|
540
918
|
describe("variables whose documented behaviour is to fail boot", () => {
|
|
541
|
-
it("
|
|
542
|
-
expect(
|
|
543
|
-
|
|
919
|
+
it("refuses OAUTH_AUTHORIZE_ALLOW_UNMARKED_CLIENTS, naming allowUnmarkedClients", async () => {
|
|
920
|
+
await expect(
|
|
921
|
+
bootParsed({
|
|
544
922
|
...DOCUMENTED_ENV,
|
|
545
923
|
OAUTH_AUTHORIZE_ALLOW_UNMARKED_CLIENTS: "true",
|
|
546
924
|
}),
|
|
547
|
-
).toThrow(/allowUnmarkedClients/);
|
|
925
|
+
).rejects.toThrow(/allowUnmarkedClients/);
|
|
548
926
|
});
|
|
549
927
|
|
|
550
|
-
it("refuses an access-token default above the max, naming both keys", () => {
|
|
551
|
-
expect(
|
|
552
|
-
|
|
928
|
+
it("refuses an access-token default above the max, naming both keys", async () => {
|
|
929
|
+
await expect(
|
|
930
|
+
bootParsed({
|
|
553
931
|
...DOCUMENTED_ENV,
|
|
554
932
|
OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN: "7200",
|
|
555
933
|
OAUTH_ACCESS_TOKEN_MAX_EXPIRES_IN: "3600",
|
|
556
934
|
}),
|
|
557
|
-
).toThrow(/defaultExpiresIn.*maxExpiresIn/s);
|
|
935
|
+
).rejects.toThrow(/defaultExpiresIn.*maxExpiresIn/s);
|
|
558
936
|
});
|
|
559
937
|
|
|
560
938
|
for (const name of [
|
|
@@ -562,17 +940,40 @@ describe("#288: the shipped config boots with every documented override supplied
|
|
|
562
940
|
"OAUTH_ACCESS_TOKEN_MAX_EXPIRES_IN",
|
|
563
941
|
"OAUTH_ACCESS_TOKEN_EXPIRES_IN",
|
|
564
942
|
]) {
|
|
565
|
-
it(`refuses an empty ${name} rather than minting already-expired tokens`, () => {
|
|
566
|
-
expect((
|
|
943
|
+
it(`refuses an empty ${name} rather than minting already-expired tokens`, async () => {
|
|
944
|
+
await expect(bootParsed({ ...DOCUMENTED_ENV, [name]: "" })).rejects.toThrow();
|
|
567
945
|
});
|
|
568
946
|
}
|
|
569
947
|
|
|
570
|
-
it("
|
|
948
|
+
it("refuses MFA_MODE that is none of the three before boot, naming mfaMode", async () => {
|
|
949
|
+
const env = { ...DOCUMENTED_ENV, MFA_MODE: "on" };
|
|
950
|
+
expect(() => readShippedSwitches(env)).toThrow(RangeError);
|
|
951
|
+
await expect(bootParsed(env)).rejects.toThrow(/mfaMode/);
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
for (const mode of ["optional", "required"] as const) {
|
|
955
|
+
it(`reads MFA_MODE=${mode} as the switch that installs MFA, expecting mfa`, () => {
|
|
956
|
+
const switches = readShippedSwitches({ ...DOCUMENTED_ENV, MFA_MODE: mode });
|
|
957
|
+
expect(switches.mfaMode).toBe(mode);
|
|
958
|
+
expect(expectedSessionRequirements(switches)).toEqual({
|
|
959
|
+
expected: ["mfa"],
|
|
960
|
+
secondFactorAuthority: "mfa",
|
|
961
|
+
});
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
it("refuses DEPLOYMENT_MODE set alone, naming CORE_DEPLOYMENT_MODE", async () => {
|
|
966
|
+
const { CORE_DEPLOYMENT_MODE: _new, ...env } = DOCUMENTED_ENV;
|
|
967
|
+
await expect(bootParsed({ ...env, DEPLOYMENT_MODE: "multi" })).rejects.toThrow(
|
|
968
|
+
/DEPLOYMENT_MODE was renamed CORE_DEPLOYMENT_MODE/,
|
|
969
|
+
);
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
it("still refuses an empty SESSION_CSRF_TTL_SECONDS", async () => {
|
|
571
973
|
// Pinned alongside the boolean cases because it is the same trap
|
|
572
974
|
// read from the other side: for a *number*, empty means fail loudly.
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
).toThrow();
|
|
975
|
+
const config = await bootParsed({ ...DOCUMENTED_ENV, SESSION_CSRF_TTL_SECONDS: "" });
|
|
976
|
+
expect(() => sessionSection(config)).toThrow(/ttlSeconds/);
|
|
576
977
|
});
|
|
577
978
|
});
|
|
578
979
|
|
|
@@ -581,7 +982,7 @@ describe("#288: the shipped config boots with every documented override supplied
|
|
|
581
982
|
// artifact these tests would pass while testing nothing. Checked by
|
|
582
983
|
// what the directory holds, not by what it is called: this file ships
|
|
583
984
|
// in every scaffolded project, and none of those lives at
|
|
584
|
-
// `templates/standalone
|
|
985
|
+
// `templates/standalone`.
|
|
585
986
|
for (const file of ["config/application.conf", "config/production.conf", "src/app.mts"]) {
|
|
586
987
|
expect(existsSync(join(standaloneDir, file)), file).toBe(true);
|
|
587
988
|
}
|