@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
|
@@ -15,7 +15,6 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import path from "node:path";
|
|
17
17
|
import {
|
|
18
|
-
type AppConfig,
|
|
19
18
|
consoleLogger,
|
|
20
19
|
createAuditSinkFactory,
|
|
21
20
|
createFederationTokenStoreFactory,
|
|
@@ -28,6 +27,7 @@ import {
|
|
|
28
27
|
createKeyStoreFactory,
|
|
29
28
|
createRepositoryFactories,
|
|
30
29
|
defineModule,
|
|
30
|
+
federationsOf,
|
|
31
31
|
type LifecycleRegistrar,
|
|
32
32
|
type Logger,
|
|
33
33
|
loggableError,
|
|
@@ -46,25 +46,29 @@ import {
|
|
|
46
46
|
makeIoredisFederationGrantStoreClient,
|
|
47
47
|
} from "@o3co/auth-provider-redis/ioredis";
|
|
48
48
|
import { extractFederationSection } from "@o3co/auth-provider-session";
|
|
49
|
-
// Named import
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
// The 11 default-import sites elsewhere in the repo all live under
|
|
57
|
-
// `packages/redis/__tests__/` which `packages/redis/tsconfig.json`
|
|
58
|
-
// explicitly excludes from strict tsc build (vitest's vue-tsc is more
|
|
59
|
-
// permissive about CJS interop). Standalone production source is built
|
|
60
|
-
// with strict nodenext, so the named import is the right shape here.
|
|
49
|
+
// Named import, not default: ioredis is CJS (`module.exports = Redis`, the
|
|
50
|
+
// class re-exported as both `default` and `Redis`), so under `module:
|
|
51
|
+
// "nodenext"` with esModuleInterop the default import resolves to the
|
|
52
|
+
// module-exports namespace object and `new Redis(...)` raises TS2351 "not
|
|
53
|
+
// constructable". Verified against ioredis 6.0.0. The repo's default-import
|
|
54
|
+
// sites live under `packages/redis/__tests__/`, which the strict tsc build
|
|
55
|
+
// excludes.
|
|
61
56
|
import { Redis } from "ioredis";
|
|
62
57
|
import { createAuditLogger, createLoggerAuditSink } from "./logger.mjs";
|
|
58
|
+
import {
|
|
59
|
+
type Adapters,
|
|
60
|
+
auditSinkSectionSchema,
|
|
61
|
+
httpSectionSchema,
|
|
62
|
+
inMemoryCodeRepositorySectionSchema,
|
|
63
|
+
keyStoreSectionSchema,
|
|
64
|
+
loggingSectionSchema,
|
|
65
|
+
redisClientsSectionSchema,
|
|
66
|
+
repositoriesSectionSchemaFor,
|
|
67
|
+
} from "./sections.mjs";
|
|
63
68
|
|
|
64
69
|
/**
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
70
|
+
* Turn a `{ type, [type]: {...} }` adapter-config slice into the flat
|
|
71
|
+
* `{ type, ...rest }` shape `AdapterFactory<T>.create()` consumes.
|
|
68
72
|
* Composition-root concern; not a library export.
|
|
69
73
|
*/
|
|
70
74
|
function flattenAdapterConfig(
|
|
@@ -85,188 +89,266 @@ function flattenAdapterConfig(
|
|
|
85
89
|
}
|
|
86
90
|
|
|
87
91
|
/**
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
92
|
+
* The template's `config/reference.conf`, resolved from this file, which sits
|
|
93
|
+
* one directory under the template's root in `src/` and in `dist/` alike.
|
|
94
|
+
*/
|
|
95
|
+
const TEMPLATE_REFERENCE_HREF: string = new URL("../config/reference.conf", import.meta.url).href;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The template's `config/reference.conf`, which every module here that owns a
|
|
99
|
+
* section declares. A new `URL` per call: a shared one could be changed in place.
|
|
100
|
+
*/
|
|
101
|
+
export function templateReference(): URL {
|
|
102
|
+
return new URL(TEMPLATE_REFERENCE_HREF);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Logging module: owns `logging {}`. It provides nothing: the logger is built
|
|
107
|
+
* before boot from this section (`readLogging`) and handed in as a bootstrap
|
|
108
|
+
* component, since the template logs while it chooses its modules.
|
|
109
|
+
* `LOG_LEVEL` is renamed after the path, `LOGGING_LEVEL`.
|
|
110
|
+
*/
|
|
111
|
+
export const loggingModule = defineModule({
|
|
112
|
+
name: "logging",
|
|
113
|
+
section: {
|
|
114
|
+
schema: loggingSectionSchema,
|
|
115
|
+
reference: templateReference(),
|
|
116
|
+
renamedVariables: { LOG_LEVEL: "logging.level" },
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
/** What the host process reads of `http {}`, beside what the `httpSettings` slot carries. */
|
|
121
|
+
export interface HttpHostSettings {
|
|
122
|
+
/** The port the server listens on; `0` lets the OS pick one. */
|
|
123
|
+
readonly port: number;
|
|
124
|
+
/** The per-probe deadline of `/readyz` and the metrics scrape, in milliseconds. */
|
|
125
|
+
readonly readinessTimeoutMs: number;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
declare module "@o3co/auth-provider-core" {
|
|
129
|
+
interface ComponentMap {
|
|
130
|
+
/** What the host process reads of `http {}`: provided by the `http` module. */
|
|
131
|
+
readonly httpHostSettings?: HttpHostSettings;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* HTTP module: owns `http {}`, its CORS list (`http.cors`) included, and
|
|
137
|
+
* provides core's `httpSettings`, authoritative, and the host's
|
|
138
|
+
* `httpHostSettings`, both eager: only `app.mts` reads the host's, and no
|
|
139
|
+
* module requires it. The list moved from `cors`, and `CORS_ALLOWED_ORIGINS`
|
|
140
|
+
* with it.
|
|
141
|
+
*/
|
|
142
|
+
export const httpModule = defineModule({
|
|
143
|
+
name: "http",
|
|
144
|
+
section: {
|
|
145
|
+
schema: httpSectionSchema,
|
|
146
|
+
reference: templateReference(),
|
|
147
|
+
relocatedFrom: { cors: "cors" },
|
|
148
|
+
renamedVariables: { CORS_ALLOWED_ORIGINS: "cors.allowedOrigins" },
|
|
149
|
+
},
|
|
150
|
+
provides: {
|
|
151
|
+
httpSettings: ({ section }) =>
|
|
152
|
+
Object.freeze({
|
|
153
|
+
trustProxy: section.trustProxy,
|
|
154
|
+
cors: Object.freeze({ allowedOrigins: Object.freeze([...section.cors.allowedOrigins]) }),
|
|
155
|
+
}),
|
|
156
|
+
httpHostSettings: ({ section }): HttpHostSettings =>
|
|
157
|
+
Object.freeze({ port: section.port, readinessTimeoutMs: section.readinessTimeoutMs }),
|
|
158
|
+
},
|
|
159
|
+
authoritative: ["httpSettings"],
|
|
160
|
+
lifecycle: { httpSettings: { eager: true }, httpHostSettings: { eager: true } },
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* KeyStore module: owns `key-store {}` and provides the JWT signing KeyStore
|
|
165
|
+
* from it through the built-in local adapter. The section moved from
|
|
166
|
+
* `oauth.jwt.signingKey`, and the variables bound to it are renamed after
|
|
167
|
+
* their paths (`KEY_STORE_*`). Other deployments wire their own KeyStore
|
|
168
|
+
* through a module of the same shape.
|
|
93
169
|
*/
|
|
94
170
|
export const keyStoreModule: Module = defineModule({
|
|
95
|
-
name: "
|
|
96
|
-
|
|
171
|
+
name: "key-store",
|
|
172
|
+
section: {
|
|
173
|
+
schema: keyStoreSectionSchema,
|
|
174
|
+
reference: templateReference(),
|
|
175
|
+
relocatedFrom: { "oauth.jwt.signingKey": "" },
|
|
176
|
+
renamedVariables: {
|
|
177
|
+
OAUTH_JWT_SIGNING_KEY_PROVIDER: "oauth.jwt.signingKey.provider",
|
|
178
|
+
OAUTH_JWT_ALGORITHM: "oauth.jwt.signingKey.local.algorithm",
|
|
179
|
+
OAUTH_JWT_KID: "oauth.jwt.signingKey.local.kid",
|
|
180
|
+
OAUTH_JWT_SECRET: "oauth.jwt.signingKey.local.secret",
|
|
181
|
+
OAUTH_JWT_PRIVATE_KEY_PATH: "oauth.jwt.signingKey.local.privateKeyPath",
|
|
182
|
+
OAUTH_JWT_PUBLIC_KEY_PATH: "oauth.jwt.signingKey.local.publicKeyPath",
|
|
183
|
+
OAUTH_JWT_PRIVATE_KEY: "oauth.jwt.signingKey.local.privateKey",
|
|
184
|
+
OAUTH_JWT_PUBLIC_KEY: "oauth.jwt.signingKey.local.publicKey",
|
|
185
|
+
},
|
|
186
|
+
},
|
|
97
187
|
provides: {
|
|
98
|
-
keyStore: async ({
|
|
188
|
+
keyStore: async ({ section }) => {
|
|
99
189
|
const factory = createKeyStoreFactory();
|
|
100
190
|
registerBuiltinKeyStores(factory);
|
|
101
|
-
return factory.create(flattenAdapterConfig(
|
|
191
|
+
return factory.create(flattenAdapterConfig(section));
|
|
102
192
|
},
|
|
103
193
|
},
|
|
104
194
|
});
|
|
105
195
|
|
|
196
|
+
/** Which adapter fills each repository slot the `repositories` module provides. */
|
|
197
|
+
export interface RepositorySelection {
|
|
198
|
+
readonly client: Adapters["clientRepository"];
|
|
199
|
+
readonly user: Adapters["userRepository"];
|
|
200
|
+
}
|
|
201
|
+
|
|
106
202
|
/**
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
* `inMemoryCodeRepositoryModule` (memory branch) / `redisCodeRepositoryModule`
|
|
112
|
-
* (redis branch, from `@o3co/auth-provider-redis`) so the
|
|
113
|
-
* `oauth.code.adapter` switch can wire mutually-exclusive providers without
|
|
114
|
-
* a slot collision. Same pattern as the Wave-5d userSessionStores split.
|
|
203
|
+
* `block`, the settings `repositories.<repository>.<adapter>` holds. The
|
|
204
|
+
* section's schema (`repositoriesSectionSchemaFor`) has refused a selected
|
|
205
|
+
* `static` without its block, so an absent one is a composition that built
|
|
206
|
+
* the section otherwise: an `Error` naming its path.
|
|
115
207
|
*/
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
208
|
+
function requiredBlock<B>(block: B | undefined, repository: string, adapter: string): B {
|
|
209
|
+
if (block === undefined) {
|
|
210
|
+
throw new Error(
|
|
211
|
+
`repositories.${repository}.${adapter}.path must be set when adapters.${repository}Repository is "${adapter}"`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
return block;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Repositories module: owns `repositories {}` and provides the client and
|
|
219
|
+
* user repositories the composition root's `adapters` select, through the
|
|
220
|
+
* built-in adapter factories: the YAML client registry, and the YAML or the
|
|
221
|
+
* Store's HTTP user repository (`@o3co/auth-provider-foundation`); core's
|
|
222
|
+
* `static`, an alias of `yaml`, reads a block of its own. The
|
|
223
|
+
* variables bound to its keys are renamed after their paths
|
|
224
|
+
* (`REPOSITORIES_*`). `codeRepository` comes from the in-process or the Redis
|
|
225
|
+
* code repository module instead.
|
|
226
|
+
*/
|
|
227
|
+
export function repositoriesModuleFor(selection: RepositorySelection): Module {
|
|
228
|
+
return defineModule({
|
|
229
|
+
name: "repositories",
|
|
230
|
+
section: {
|
|
231
|
+
schema: repositoriesSectionSchemaFor(selection),
|
|
232
|
+
reference: templateReference(),
|
|
233
|
+
renamedVariables: {
|
|
234
|
+
CLIENT_PATH: "repositories.client.yaml.path",
|
|
235
|
+
CLIENT_USER_PATH: "repositories.user.yaml.path",
|
|
236
|
+
CLIENT_USER_AUTHENTICATE_URL: "repositories.user.http.authenticateUrl",
|
|
237
|
+
CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL: "repositories.user.http.authenticateByTokenUrl",
|
|
238
|
+
CLIENT_USER_LINK_FEDERATED_IDENTITY_URL: "repositories.user.http.linkFederatedIdentityUrl",
|
|
239
|
+
CLIENT_USER_FIND_SUBJECT_BY_FEDERATED_IDENTITY_URL:
|
|
240
|
+
"repositories.user.http.findSubjectByFederatedIdentityUrl",
|
|
241
|
+
CLIENT_USER_BEARER_TOKEN: "repositories.user.http.bearerToken",
|
|
242
|
+
CLIENT_USER_TIMEOUT: "repositories.user.http.timeout",
|
|
243
|
+
CLIENT_USER_MAX_RESPONSE_BYTES: "repositories.user.http.maxResponseBytes",
|
|
244
|
+
},
|
|
136
245
|
},
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
)
|
|
145
|
-
|
|
246
|
+
// Forwarded into the repository factories so client/user adapters can
|
|
247
|
+
// register disposal callbacks (file-watch closers, say). Without it, a
|
|
248
|
+
// builder's `ctx.lifecycle?.register` is a no-op and those resources leak.
|
|
249
|
+
optional: ["lifecycleRegistrar"] as const,
|
|
250
|
+
provides: {
|
|
251
|
+
clientRepository: async ({ section, lifecycleRegistrar }) => {
|
|
252
|
+
const { clientFactory } = createRepositoryFactories({ lifecycle: lifecycleRegistrar });
|
|
253
|
+
const file = requiredBlock(section.client[selection.client], "client", selection.client);
|
|
254
|
+
return clientFactory.create({
|
|
255
|
+
type: selection.client,
|
|
256
|
+
path: path.resolve(process.cwd(), file.path),
|
|
257
|
+
});
|
|
258
|
+
},
|
|
259
|
+
userRepository: async ({ section, lifecycleRegistrar }) => {
|
|
260
|
+
const { userFactory } = createRepositoryFactories({ lifecycle: lifecycleRegistrar });
|
|
261
|
+
registerBuiltinAdapters({ userFactory });
|
|
262
|
+
return userFactory.create(
|
|
263
|
+
flattenAdapterConfig({
|
|
264
|
+
type: selection.user,
|
|
265
|
+
[selection.user]: section.user[selection.user],
|
|
266
|
+
}),
|
|
267
|
+
);
|
|
268
|
+
},
|
|
146
269
|
},
|
|
147
|
-
}
|
|
148
|
-
}
|
|
270
|
+
});
|
|
271
|
+
}
|
|
149
272
|
|
|
150
273
|
/**
|
|
151
|
-
* In-memory CodeRepository module
|
|
152
|
-
* `
|
|
274
|
+
* In-memory CodeRepository module, wired by `buildModules` only when
|
|
275
|
+
* `adapters.codeRepository = "memory"`; the Redis choice swaps in
|
|
153
276
|
* `redisCodeRepositoryModule` from `@o3co/auth-provider-redis` (mutually
|
|
154
|
-
* exclusive
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
* inside `repositoriesModule` via the AdapterFactory pattern; that path
|
|
158
|
-
* coupled the slot to `repositories.code.type` which is now superseded by
|
|
159
|
-
* `oauth.code.adapter`.
|
|
277
|
+
* exclusive: both provide the `codeRepository` slot). It reads its own
|
|
278
|
+
* section, which moved from `repositories.code.memory`; the
|
|
279
|
+
* `repositories.code.redis` block is removed.
|
|
160
280
|
*/
|
|
161
281
|
export const inMemoryCodeRepositoryModule: Module = defineModule({
|
|
162
|
-
name: "standalone
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
282
|
+
name: "standalone-in-memory-code-repository",
|
|
283
|
+
section: {
|
|
284
|
+
schema: inMemoryCodeRepositorySectionSchema,
|
|
285
|
+
reference: templateReference(),
|
|
286
|
+
relocatedFrom: { "repositories.code.memory": "", "repositories.code.redis": null },
|
|
287
|
+
renamedVariables: {
|
|
288
|
+
CLIENT_CODE_DEFAULT_EXPIRES_IN: "repositories.code.memory.defaultExpiresIn",
|
|
289
|
+
CLIENT_CODE_ENDPOINT_URI: "repositories.code.redis.endpointUri",
|
|
290
|
+
CLIENT_CODE_PASSWORD: "repositories.code.redis.password",
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
// The replica-safety guard reads this off the manifest, not by module name.
|
|
166
294
|
replicaSafety: {
|
|
167
295
|
unsafe: true,
|
|
168
296
|
reason:
|
|
169
297
|
"authorization codes fork per replica — a code issued by the replica that served /authorize is unknown to the replica that receives the token request, so the exchange fails with invalid_grant everywhere but one replica, and a code redeemed on one replica can be redeemed again on another",
|
|
170
298
|
},
|
|
171
|
-
requires: ["config"] as const,
|
|
172
299
|
optional: ["lifecycleRegistrar"] as const,
|
|
173
300
|
provides: {
|
|
174
|
-
codeRepository: async ({
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
// `codeFactory.register("memory", ...)` is wired automatically by
|
|
178
|
-
// `createRepositoryFactories` itself (see core/repositories/RepositoryFactory.mts).
|
|
179
|
-
// `registerBuiltinAdapters` from `@o3co/auth-provider-foundation` only
|
|
180
|
-
// registers the HTTP user adapter on `userFactory`; it does NOT touch
|
|
181
|
-
// `codeFactory`, so calling it here would be misleading and unnecessary.
|
|
182
|
-
//
|
|
183
|
-
// The flattened slice MAY still have `type = "redis"` when only the
|
|
184
|
-
// legacy `repositories.code.type` is set — `buildModules`' adapter
|
|
185
|
-
// resolution already chose memory by the time we get here, so
|
|
186
|
-
// override the type explicitly to keep this module robust against
|
|
187
|
-
// legacy HOCON shapes.
|
|
188
|
-
const slice = flattenAdapterConfig(
|
|
189
|
-
(config as AppConfig).repositories.code as { type: string } & Record<string, unknown>,
|
|
190
|
-
);
|
|
191
|
-
return codeFactory.create({ ...slice, type: "memory" });
|
|
301
|
+
codeRepository: async ({ section, lifecycleRegistrar }) => {
|
|
302
|
+
const { codeFactory } = createRepositoryFactories({ lifecycle: lifecycleRegistrar });
|
|
303
|
+
return codeFactory.create({ type: "memory", defaultExpiresIn: section.defaultExpiresIn });
|
|
192
304
|
},
|
|
193
305
|
},
|
|
194
306
|
});
|
|
195
307
|
|
|
196
308
|
/**
|
|
197
|
-
* In-memory user-session stores module
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
* `buildModules` only when `userSessionStores
|
|
201
|
-
*
|
|
202
|
-
* `@o3co/auth-provider-redis`.
|
|
203
|
-
*
|
|
204
|
-
* Pre-Wave-5d this module also provided `federationTokenStore` (now
|
|
205
|
-
* extracted into a separate always-wired module so the redis session
|
|
206
|
-
* branch doesn't drop the federation-token-store provider — Copilot
|
|
207
|
-
* review on PR #121).
|
|
309
|
+
* In-memory user-session stores module: the four-store user-session split
|
|
310
|
+
* (userSessionStore, sessionRPRegistry, sessionFamilyIndex,
|
|
311
|
+
* sessionFederationIndex) and the subject-level revocation pair. Wired by
|
|
312
|
+
* `buildModules` only when `adapters.userSessionStores = "memory"`; the Redis
|
|
313
|
+
* branch swaps in `redisSessionStoresModule` from `@o3co/auth-provider-redis`.
|
|
208
314
|
*/
|
|
209
315
|
export const inMemorySessionStoresModule: Module = defineModule({
|
|
210
|
-
name: "standalone
|
|
211
|
-
// #455: the replica-safety guard reads this off the manifest. Before it
|
|
212
|
-
// did, `DEPLOYMENT_MODE=multi` with `USER_SESSION_STORES_ADAPTER=memory`
|
|
213
|
-
// booted — the guard keyed on core's `memorySessionStores`, and this
|
|
214
|
-
// module provides the same six slots under a name it had never heard of.
|
|
316
|
+
name: "standalone-in-memory-session-stores",
|
|
215
317
|
replicaSafety: {
|
|
216
318
|
unsafe: true,
|
|
217
319
|
reason:
|
|
218
|
-
"user sessions, RP registrations, family indexes and the subject-level revocation pair fork per replica — back-channel logout reaches only the replica that received it, so a logged-out session stays valid on the others, and a credential change enumerates and watermarks only the replica that handled it
|
|
320
|
+
"user sessions, RP registrations, family indexes and the subject-level revocation pair fork per replica — back-channel logout reaches only the replica that received it, so a logged-out session stays valid on the others, and a credential change enumerates and watermarks only the replica that handled it",
|
|
219
321
|
},
|
|
220
322
|
provides: {
|
|
221
323
|
userSessionStore: () => createInMemoryUserSessionStore(),
|
|
222
324
|
sessionRPRegistry: () => createInMemorySessionRPRegistry(),
|
|
223
325
|
sessionFamilyIndex: () => createInMemorySessionFamilyIndex(),
|
|
224
326
|
sessionFederationIndex: () => createInMemorySessionFederationIndex(),
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
// answer is to wire them rather than to declare the capability absent.
|
|
232
|
-
//
|
|
233
|
-
// Single-process only, like every other store on this branch. The
|
|
234
|
-
// redis branch swaps in `redisSessionStoresModule`, which has provided
|
|
235
|
-
// both since #321 — before that there was no distributed adapter to
|
|
236
|
-
// point at, which is why this gap outlived #296.
|
|
327
|
+
// The subject-level revocation slots. Without them `subjectRevocation`
|
|
328
|
+
// is undefined: `verifyJwt` skips the watermark, the refresh gate is
|
|
329
|
+
// inert and `revokeAllForSubject` reports `unavailable`, all silently.
|
|
330
|
+
// The boot guard refuses that state; the scaffold wires them rather than
|
|
331
|
+
// declaring the capability absent. Single-process only, like every
|
|
332
|
+
// other store on this branch.
|
|
237
333
|
subjectSessionIndex: () => createInMemorySubjectSessionIndex(),
|
|
238
334
|
subjectRevocation: () => createInMemorySubjectRevocation(),
|
|
239
335
|
},
|
|
240
336
|
});
|
|
241
337
|
|
|
242
338
|
/**
|
|
243
|
-
* In-memory federation token store module
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
339
|
+
* In-memory federation token store module, wired by `buildModules` only when
|
|
340
|
+
* `adapters.federationTokenStore = "memory"` (the default). The redis branch
|
|
341
|
+
* swaps in `redisFederationTokenStoreModule` off the shared ioredis socket
|
|
342
|
+
* (mutually exclusive: both provide the `federationTokenStore` slot). One
|
|
343
|
+
* module per adapter, so the replica-safety guard can tell them apart and the
|
|
344
|
+
* Redis one can require its client. The slot is always wired, independent of
|
|
345
|
+
* the `adapters.userSessionStores` selection.
|
|
248
346
|
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
* adapter factory. That shape failed twice over: the replica-safety guard
|
|
252
|
-
* could not tell the memory store from the Redis one by name, so
|
|
253
|
-
* `deployment.mode = "multi"` booted with the memory store; and the Redis
|
|
254
|
-
* branch handed the builder no client, so `federationTokenStore.type =
|
|
255
|
-
* "redis"` — documented in the README — was a boot failure rather than a
|
|
256
|
-
* Redis-backed store. One module per adapter, like the session stores.
|
|
257
|
-
*
|
|
258
|
-
* The slot is always wired, independent of the `userSessionStores.adapter`
|
|
259
|
-
* switch: pre-Wave-5d it was bundled into the larger `storesModule`, and the
|
|
260
|
-
* redis session-stores branch dropped the provider entirely (Copilot review
|
|
261
|
-
* on PR #121).
|
|
262
|
-
*
|
|
263
|
-
* The memory adapter still comes through core's factory, unchanged: that is
|
|
264
|
-
* where its "dev/test only" boot warning lives.
|
|
347
|
+
* The memory adapter comes through core's factory: that is where its
|
|
348
|
+
* "dev/test only" boot warning lives.
|
|
265
349
|
*/
|
|
266
350
|
export const inMemoryFederationTokenStoreModule: Module = defineModule({
|
|
267
|
-
name: "standalone
|
|
268
|
-
// #455: read by the replica-safety guard. Before the split this module
|
|
269
|
-
// carried one name for both adapters, so no name could have carried it.
|
|
351
|
+
name: "standalone-in-memory-federation-token-store",
|
|
270
352
|
replicaSafety: {
|
|
271
353
|
unsafe: true,
|
|
272
354
|
reason:
|
|
@@ -282,75 +364,52 @@ export const inMemoryFederationTokenStoreModule: Module = defineModule({
|
|
|
282
364
|
});
|
|
283
365
|
|
|
284
366
|
/**
|
|
285
|
-
* Audit-sink module
|
|
286
|
-
* security event reads
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
* deployable artifact, with nothing failing and nothing warning. This module is
|
|
294
|
-
* always in the manifest for that reason — there is no "no audit sink" branch,
|
|
295
|
-
* the way there is a memory/redis branch for the state stores.
|
|
296
|
-
*
|
|
297
|
-
* Two sink kinds are registered:
|
|
298
|
-
*
|
|
299
|
-
* - `"logger"` (the template's default) — one NDJSON line per event on stdout,
|
|
300
|
-
* in the same pino envelope as every other line this template emits, on a
|
|
301
|
-
* stream named `audit` whose level is fixed. See `createAuditLogger`.
|
|
302
|
-
* - `"console"` — core's built-in, from `registerBuiltinAuditSinks`: the bare
|
|
303
|
-
* event as one JSON object per line on stdout, with no log envelope. For a
|
|
304
|
-
* pipeline that wants the event and nothing else.
|
|
305
|
-
*
|
|
306
|
-
* A deployment with a real sink (SIEM, log pipeline, message bus) registers its
|
|
307
|
-
* builder here and names it in `audit.sink.type`; its options ride along in the
|
|
308
|
-
* same config block (`flattenAdapterConfig` unwraps `sink { type = "x", x { … } }`).
|
|
367
|
+
* Audit-sink module: owns `audit-sink {}` and fills the `auditSink` slot every
|
|
368
|
+
* route that emits a security event reads, with the sink the composition
|
|
369
|
+
* root's `adapters.auditSink` names: the template's `"logger"` (its default)
|
|
370
|
+
* or one of core's built-ins, with that sink's options from
|
|
371
|
+
* `audit-sink.<name>`. The slot is `optional` on `oauthModule`,
|
|
372
|
+
* `sessionModule` and `webauthnModule`, and `emitAuditEvent` is a no-op when
|
|
373
|
+
* it is empty, so this module is always in the manifest; a name no builder is
|
|
374
|
+
* registered under refuses boot. The sink's options moved from `audit.sink`.
|
|
309
375
|
*
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
* `ctx.
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
* factory takes a context.
|
|
316
|
-
*
|
|
317
|
-
* There is deliberately no `"none"`. An unknown type — including the `"none"`
|
|
318
|
-
* an operator might reasonably guess at — fails boot naming the sinks that
|
|
319
|
-
* exist, rather than producing a deployment with no audit trail (#304).
|
|
376
|
+
* `createAuditSinkFactory()` takes no `BuilderContext`, so a sink builder
|
|
377
|
+
* receives `{}` and the `ctx.lifecycle?.register(…)` /
|
|
378
|
+
* `ctx.readiness?.register(…)` calls CONTRIBUTING.md requires of a builder
|
|
379
|
+
* opening a connection are silent no-ops: a sink holding a socket must own
|
|
380
|
+
* its cleanup another way.
|
|
320
381
|
*/
|
|
321
|
-
export
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
// config that says nothing about auditing is not a config asking for
|
|
332
|
-
// the events to be dropped. The literal default lives in HOCON
|
|
333
|
-
// (`reference.conf` / `application.conf`); this is the floor under a
|
|
334
|
-
// hand-built config that never met either.
|
|
335
|
-
return factory.create(
|
|
336
|
-
slice
|
|
337
|
-
? flattenAdapterConfig(slice as { type: string } & Record<string, unknown>)
|
|
338
|
-
: { type: "logger" },
|
|
339
|
-
);
|
|
382
|
+
export function auditSinkModuleFor(sink: string): Module {
|
|
383
|
+
return defineModule({
|
|
384
|
+
name: "audit-sink",
|
|
385
|
+
section: {
|
|
386
|
+
schema: auditSinkSectionSchema,
|
|
387
|
+
reference: templateReference(),
|
|
388
|
+
relocatedFrom: {
|
|
389
|
+
"audit.sink": { to: "", environmentVariable: null },
|
|
390
|
+
"audit.sink.type": null,
|
|
391
|
+
},
|
|
340
392
|
},
|
|
341
|
-
|
|
342
|
-
})
|
|
393
|
+
provides: {
|
|
394
|
+
auditSink: async ({ section }) => {
|
|
395
|
+
const factory = createAuditSinkFactory();
|
|
396
|
+
registerBuiltinAuditSinks(factory);
|
|
397
|
+
factory.register("logger", () => createLoggerAuditSink(createAuditLogger()));
|
|
398
|
+
return factory.create(flattenAdapterConfig({ type: sink, [sink]: section?.[sink] }));
|
|
399
|
+
},
|
|
400
|
+
},
|
|
401
|
+
});
|
|
402
|
+
}
|
|
343
403
|
|
|
344
404
|
/**
|
|
345
|
-
* @deprecated
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
* New code should use the two split modules directly.
|
|
405
|
+
* @deprecated Split into `inMemorySessionStoresModule` and
|
|
406
|
+
* `inMemoryFederationTokenStoreModule`; use those. Kept for consumers that
|
|
407
|
+
* imported `storesModule` from this file.
|
|
349
408
|
*/
|
|
350
409
|
export const storesModule: Module = defineModule({
|
|
351
|
-
name: "
|
|
352
|
-
//
|
|
353
|
-
// composition still on it is refused under `deployment.mode = "multi"`
|
|
410
|
+
name: "stores",
|
|
411
|
+
// Everything this bundle provides lives in process memory, so a
|
|
412
|
+
// composition still on it is refused under `core.deployment.mode = "multi"`
|
|
354
413
|
// like the split modules it stands in for.
|
|
355
414
|
replicaSafety: {
|
|
356
415
|
unsafe: true,
|
|
@@ -365,289 +424,231 @@ export const storesModule: Module = defineModule({
|
|
|
365
424
|
});
|
|
366
425
|
|
|
367
426
|
/**
|
|
368
|
-
* Shared ioredis clients module
|
|
369
|
-
* per
|
|
370
|
-
*
|
|
371
|
-
* Redis-backed
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
* Every store module `@o3co/auth-provider-redis` ships finds its client
|
|
377
|
-
* slot here, including the ones this template does not select — pinned by
|
|
378
|
-
* `all-modules-composition.multi.test.mts`.
|
|
379
|
-
*
|
|
380
|
-
* Per F4 PR1 (D-2 v2) + Wave 5d unification: the previous design opened a
|
|
381
|
-
* separate ioredis socket per Redis-backed module (3+ sockets per replica).
|
|
382
|
-
* This module consolidates them — `makeIoredisClients(io)` was always
|
|
383
|
-
* designed to derive multiple per-purpose typed clients from a single
|
|
384
|
-
* connection; using one socket realises that intent and minimises
|
|
385
|
-
* connection-pool pressure on the upstream Redis.
|
|
427
|
+
* Shared ioredis clients module: opens ONE long-lived ioredis connection per
|
|
428
|
+
* replica and derives every per-purpose client from it (`makeIoredisClients`,
|
|
429
|
+
* plus the two federation-grant store clients from their own factories),
|
|
430
|
+
* rather than a socket per Redis-backed module, to keep connection-pool
|
|
431
|
+
* pressure off the upstream Redis. Every store module
|
|
432
|
+
* `@o3co/auth-provider-redis` ships finds its client slot here, including
|
|
433
|
+
* those this template does not select; `all-modules-composition.multi.test.mts`
|
|
434
|
+
* pins that.
|
|
386
435
|
*
|
|
387
|
-
*
|
|
388
|
-
*
|
|
389
|
-
*
|
|
390
|
-
*
|
|
391
|
-
*
|
|
436
|
+
* The connection's URL and password are the module's own section,
|
|
437
|
+
* `redis-clients`, which moved from `refreshTokenFamilyStore.redis` with its
|
|
438
|
+
* variables (`REDIS_CLIENTS_*`). Per-store Redis instances belong in a
|
|
439
|
+
* custom composition root. An empty URL throws rather than falling back to
|
|
440
|
+
* localhost. `io.quit()` is registered once with `lifecycleRegistrar`, so
|
|
441
|
+
* `handle.dispose()` closes the connection.
|
|
392
442
|
*
|
|
393
|
-
*
|
|
394
|
-
* `
|
|
395
|
-
*
|
|
396
|
-
* error instead of a silent localhost fallback (the multi-replica failure
|
|
397
|
-
* mode OR-1 was supposed to close).
|
|
398
|
-
*
|
|
399
|
-
* Lifecycle: registers `io.quit()` once with the boot-planner-pre-seeded
|
|
400
|
-
* `lifecycleRegistrar` (D-5 ComponentMap slot) so `handle.dispose()` quits
|
|
401
|
-
* the single connection cleanly.
|
|
402
|
-
*
|
|
403
|
-
* Conditional inclusion: `buildModules` only adds this module to the
|
|
404
|
-
* manifest when at least one composed module needs a Redis client
|
|
405
|
-
* (`usingRedisAnywhere` there): an adapter switch set to `"redis"` — the two
|
|
406
|
-
* federation-grant store switches only while `federationGrants.enabled` —
|
|
407
|
-
* and the refresh-token family store, which is Redis-backed unless a test
|
|
408
|
-
* override replaces it. Adding the module unconditionally would open an
|
|
409
|
-
* ioredis socket for memory-only compositions.
|
|
443
|
+
* `buildModules` adds this module only when some composed module needs a
|
|
444
|
+
* Redis client (`usingRedisAnywhere`), so memory-only compositions open no
|
|
445
|
+
* socket.
|
|
410
446
|
*/
|
|
411
447
|
export const standaloneRedisClientsModule: Module = defineModule({
|
|
412
|
-
name: "
|
|
413
|
-
|
|
448
|
+
name: "redis-clients",
|
|
449
|
+
section: {
|
|
450
|
+
schema: redisClientsSectionSchema,
|
|
451
|
+
reference: templateReference(),
|
|
452
|
+
relocatedFrom: { "refreshTokenFamilyStore.redis": "" },
|
|
453
|
+
renamedVariables: {
|
|
454
|
+
REFRESH_TOKEN_FAMILY_STORE_REDIS_URL: "refreshTokenFamilyStore.redis.url",
|
|
455
|
+
REFRESH_TOKEN_FAMILY_STORE_REDIS_PASSWORD: "refreshTokenFamilyStore.redis.password",
|
|
456
|
+
},
|
|
457
|
+
},
|
|
414
458
|
optional: ["lifecycleRegistrar", "readinessRegistrar", "logger"] as const,
|
|
415
459
|
provides: {
|
|
416
460
|
refreshTokenFamilyClient: async ({
|
|
417
|
-
|
|
461
|
+
section,
|
|
418
462
|
lifecycleRegistrar,
|
|
419
463
|
readinessRegistrar,
|
|
420
464
|
logger,
|
|
421
465
|
}) => {
|
|
422
|
-
return getOrCreateClients(
|
|
466
|
+
return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
|
|
423
467
|
.refreshTokenFamilyClient;
|
|
424
468
|
},
|
|
425
|
-
userSessionStoreClient: async ({
|
|
426
|
-
return getOrCreateClients(
|
|
469
|
+
userSessionStoreClient: async ({ section, lifecycleRegistrar, readinessRegistrar, logger }) => {
|
|
470
|
+
return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
|
|
427
471
|
.userSessionStoreClient;
|
|
428
472
|
},
|
|
429
|
-
sessionRPRegistryClient: async ({
|
|
430
|
-
|
|
473
|
+
sessionRPRegistryClient: async ({
|
|
474
|
+
section,
|
|
475
|
+
lifecycleRegistrar,
|
|
476
|
+
readinessRegistrar,
|
|
477
|
+
logger,
|
|
478
|
+
}) => {
|
|
479
|
+
return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
|
|
431
480
|
.sessionRPRegistryClient;
|
|
432
481
|
},
|
|
433
482
|
sessionFamilyIndexClient: async ({
|
|
434
|
-
|
|
483
|
+
section,
|
|
435
484
|
lifecycleRegistrar,
|
|
436
485
|
readinessRegistrar,
|
|
437
486
|
logger,
|
|
438
487
|
}) => {
|
|
439
|
-
return getOrCreateClients(
|
|
488
|
+
return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
|
|
440
489
|
.sessionFamilyIndexClient;
|
|
441
490
|
},
|
|
442
491
|
sessionFederationIndexClient: async ({
|
|
443
|
-
|
|
492
|
+
section,
|
|
444
493
|
lifecycleRegistrar,
|
|
445
494
|
readinessRegistrar,
|
|
446
495
|
logger,
|
|
447
496
|
}) => {
|
|
448
|
-
return getOrCreateClients(
|
|
497
|
+
return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
|
|
449
498
|
.sessionFederationIndexClient;
|
|
450
499
|
},
|
|
451
|
-
//
|
|
452
|
-
//
|
|
453
|
-
//
|
|
454
|
-
//
|
|
500
|
+
// The grant store and the intent store, consumed by the Redis grant
|
|
501
|
+
// modules when their switches say "redis". Provided whenever this module
|
|
502
|
+
// is installed, as every slot here is: a slot is cheap, the socket is
|
|
503
|
+
// the cost.
|
|
455
504
|
federationGrantStoreClient: async ({
|
|
456
|
-
|
|
505
|
+
section,
|
|
457
506
|
lifecycleRegistrar,
|
|
458
507
|
readinessRegistrar,
|
|
459
508
|
logger,
|
|
460
509
|
}) => {
|
|
461
|
-
return getOrCreateClients(
|
|
510
|
+
return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
|
|
462
511
|
.federationGrantStoreClient;
|
|
463
512
|
},
|
|
464
513
|
federationGrantIntentStoreClient: async ({
|
|
465
|
-
|
|
514
|
+
section,
|
|
466
515
|
lifecycleRegistrar,
|
|
467
516
|
readinessRegistrar,
|
|
468
517
|
logger,
|
|
469
518
|
}) => {
|
|
470
|
-
return getOrCreateClients(
|
|
519
|
+
return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
|
|
471
520
|
.federationGrantIntentStoreClient;
|
|
472
521
|
},
|
|
473
|
-
//
|
|
474
|
-
replaySeenSetClient: async ({
|
|
475
|
-
return getOrCreateClients(
|
|
522
|
+
// The replay seen-set behind private_key_jwt client assertions.
|
|
523
|
+
replaySeenSetClient: async ({ section, lifecycleRegistrar, readinessRegistrar, logger }) => {
|
|
524
|
+
return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
|
|
476
525
|
.replaySeenSetClient;
|
|
477
526
|
},
|
|
478
|
-
rateLimiterClient: async ({
|
|
479
|
-
return getOrCreateClients(
|
|
527
|
+
rateLimiterClient: async ({ section, lifecycleRegistrar, readinessRegistrar, logger }) => {
|
|
528
|
+
return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
|
|
480
529
|
.rateLimiterClient;
|
|
481
530
|
},
|
|
482
|
-
//
|
|
483
|
-
//
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
// socket while the per-purpose typed wrapper keeps the Redis
|
|
487
|
-
// command surface consumed by RedisCodeRepository explicit.
|
|
488
|
-
codeRepositoryClient: async ({ config, lifecycleRegistrar, readinessRegistrar, logger }) => {
|
|
489
|
-
return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
|
|
531
|
+
// `redisCodeRepositoryModule` consumes this slot when
|
|
532
|
+
// `adapters.codeRepository = "redis"`.
|
|
533
|
+
codeRepositoryClient: async ({ section, lifecycleRegistrar, readinessRegistrar, logger }) => {
|
|
534
|
+
return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
|
|
490
535
|
.codeRepositoryClient;
|
|
491
536
|
},
|
|
492
|
-
//
|
|
493
|
-
//
|
|
494
|
-
//
|
|
495
|
-
// it belongs on the connection the rest of the shared state already uses
|
|
496
|
-
// rather than opening a second one.
|
|
537
|
+
// A denylist is only worth having if every replica reads the same one,
|
|
538
|
+
// so it belongs on the connection the rest of the shared state uses
|
|
539
|
+
// rather than a second one.
|
|
497
540
|
accessTokenDenylistClient: async ({
|
|
498
|
-
|
|
541
|
+
section,
|
|
499
542
|
lifecycleRegistrar,
|
|
500
543
|
readinessRegistrar,
|
|
501
544
|
logger,
|
|
502
545
|
}) => {
|
|
503
|
-
return getOrCreateClients(
|
|
546
|
+
return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
|
|
504
547
|
.accessTokenDenylistClient;
|
|
505
548
|
},
|
|
506
|
-
//
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
//
|
|
511
|
-
// the manifest with `missing-required-component`. Nothing in the
|
|
512
|
-
// standalone consumes these slots directly; they exist so the module
|
|
513
|
-
// that does can be selected.
|
|
549
|
+
// The subject-level revocation pair, both required by
|
|
550
|
+
// `redisSessionStoresModule`: the index enumerates what a credential
|
|
551
|
+
// change cascades over, the watermark refuses what the cascade missed.
|
|
552
|
+
// Nothing in the standalone consumes them directly; they exist so that
|
|
553
|
+
// module can be selected.
|
|
514
554
|
subjectSessionIndexClient: async ({
|
|
515
|
-
|
|
555
|
+
section,
|
|
516
556
|
lifecycleRegistrar,
|
|
517
557
|
readinessRegistrar,
|
|
518
558
|
logger,
|
|
519
559
|
}) => {
|
|
520
|
-
return getOrCreateClients(
|
|
560
|
+
return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
|
|
521
561
|
.subjectSessionIndexClient;
|
|
522
562
|
},
|
|
523
|
-
subjectRevocationClient: async ({
|
|
524
|
-
|
|
563
|
+
subjectRevocationClient: async ({
|
|
564
|
+
section,
|
|
565
|
+
lifecycleRegistrar,
|
|
566
|
+
readinessRegistrar,
|
|
567
|
+
logger,
|
|
568
|
+
}) => {
|
|
569
|
+
return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
|
|
525
570
|
.subjectRevocationClient;
|
|
526
571
|
},
|
|
527
|
-
//
|
|
528
|
-
//
|
|
529
|
-
//
|
|
530
|
-
//
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
// subject-level slots above already paid for (#439) — a Redis-branch
|
|
534
|
-
// module whose client slot this module did not provide, caught only by
|
|
535
|
-
// the umbrella e2e.
|
|
536
|
-
deviceCodeStoreClient: async ({ config, lifecycleRegistrar, readinessRegistrar, logger }) => {
|
|
537
|
-
return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
|
|
572
|
+
// Required by `redisDeviceCodeStoreModule`. This template does not mount
|
|
573
|
+
// the device grant; the slot is provided anyway, so a deployment that
|
|
574
|
+
// adds `deviceGrantModule` with the Redis store is not refused at boot
|
|
575
|
+
// (`missing-required-component`) for a client slot nothing provided.
|
|
576
|
+
deviceCodeStoreClient: async ({ section, lifecycleRegistrar, readinessRegistrar, logger }) => {
|
|
577
|
+
return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
|
|
538
578
|
.deviceCodeStoreClient;
|
|
539
579
|
},
|
|
540
|
-
// The WebAuthn challenge store's client,
|
|
541
|
-
//
|
|
542
|
-
//
|
|
543
|
-
//
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
|
|
580
|
+
// The WebAuthn challenge store's client, for the device-code slot's
|
|
581
|
+
// reason: this template does not mount WebAuthn, and a deployment that
|
|
582
|
+
// adds `webauthnModule` with `redisChallengeStoreModule` would otherwise
|
|
583
|
+
// be refused at boot for a `challengeStoreClient` nothing provided.
|
|
584
|
+
challengeStoreClient: async ({ section, lifecycleRegistrar, readinessRegistrar, logger }) => {
|
|
585
|
+
return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
|
|
547
586
|
.challengeStoreClient;
|
|
548
587
|
},
|
|
549
|
-
//
|
|
550
|
-
//
|
|
551
|
-
//
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
|
|
588
|
+
// The consent stores' clients. `redisConsentStoreModule` requires both
|
|
589
|
+
// (it provides the consent records and the parked requests together),
|
|
590
|
+
// and `buildModules` selects it under `adapters.consentStore = "redis"`.
|
|
591
|
+
consentStoreClient: async ({ section, lifecycleRegistrar, readinessRegistrar, logger }) => {
|
|
592
|
+
return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
|
|
555
593
|
.consentStoreClient;
|
|
556
594
|
},
|
|
557
595
|
pendingConsentStoreClient: async ({
|
|
558
|
-
|
|
596
|
+
section,
|
|
559
597
|
lifecycleRegistrar,
|
|
560
598
|
readinessRegistrar,
|
|
561
599
|
logger,
|
|
562
600
|
}) => {
|
|
563
|
-
return getOrCreateClients(
|
|
601
|
+
return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
|
|
564
602
|
.pendingConsentStoreClient;
|
|
565
603
|
},
|
|
566
|
-
//
|
|
567
|
-
// `redisFederationTokenStoreModule` requires it, and nothing provided
|
|
568
|
-
// it — which is why the Redis branch of the federation store could not
|
|
569
|
-
// boot in this template while the README said it could.
|
|
604
|
+
// Required by `redisFederationTokenStoreModule`.
|
|
570
605
|
federationTokenStoreClient: async ({
|
|
571
|
-
|
|
606
|
+
section,
|
|
572
607
|
lifecycleRegistrar,
|
|
573
608
|
readinessRegistrar,
|
|
574
609
|
logger,
|
|
575
610
|
}) => {
|
|
576
|
-
return getOrCreateClients(
|
|
611
|
+
return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
|
|
577
612
|
.federationTokenStoreClient;
|
|
578
613
|
},
|
|
614
|
+
// The two MFA stores' clients, required by `redisMfaFactorStoreModule`
|
|
615
|
+
// and `redisMfaTransactionStoreModule`, which `buildModules` selects
|
|
616
|
+
// with MFA on and `adapters.mfaFactorStore` / `.mfaTransactionStore`
|
|
617
|
+
// = "redis". Each module checks the server's eviction policy and
|
|
618
|
+
// persistence when it boots.
|
|
619
|
+
mfaFactorStoreClient: async ({ section, lifecycleRegistrar, readinessRegistrar, logger }) => {
|
|
620
|
+
return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
|
|
621
|
+
.mfaFactorStoreClient;
|
|
622
|
+
},
|
|
623
|
+
mfaTransactionStoreClient: async ({
|
|
624
|
+
section,
|
|
625
|
+
lifecycleRegistrar,
|
|
626
|
+
readinessRegistrar,
|
|
627
|
+
logger,
|
|
628
|
+
}) => {
|
|
629
|
+
return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
|
|
630
|
+
.mfaTransactionStoreClient;
|
|
631
|
+
},
|
|
579
632
|
},
|
|
580
633
|
});
|
|
581
634
|
|
|
582
635
|
/**
|
|
583
|
-
* Failure-timing options for the one shared ioredis socket
|
|
584
|
-
*
|
|
585
|
-
*
|
|
586
|
-
*
|
|
587
|
-
*
|
|
588
|
-
*
|
|
589
|
-
*
|
|
590
|
-
* `
|
|
591
|
-
*
|
|
592
|
-
*
|
|
593
|
-
*
|
|
594
|
-
* The values below are chosen against the actual command surface of this
|
|
595
|
-
* socket, which is entirely O(1) primitives and small paged scans
|
|
596
|
-
* (`SET`/`GET`/`DEL`/`EXISTS`/`PTTL`/`INCR`/`EVAL`/`HSCAN`/`SSCAN`/`ZADD`/…).
|
|
597
|
-
* There is no blocking command here, so no legitimate command has any reason
|
|
598
|
-
* to take a meaningful fraction of a second.
|
|
636
|
+
* Failure-timing options for the one shared ioredis socket. Each value's
|
|
637
|
+
* reason, and the trade the offline queue buys, is in
|
|
638
|
+
* `docs/operator-runbook.md`, "Failure timing on the shared socket". In
|
|
639
|
+
* short: on the driver's defaults a partition produces *waiting*, not errors,
|
|
640
|
+
* so the Redis limiter's `redis-rate-limiter.failMode = "closed"` policy, which the
|
|
641
|
+
* guard (`createRateLimitGuard`) applies only when `limiter.check()` rejects,
|
|
642
|
+
* never engages.
|
|
643
|
+
* `commandTimeout` bounds every command, queued or on the wire, zombie socket
|
|
644
|
+
* included; `maxRetriesPerRequest` bounds the offline queue's depth; the
|
|
645
|
+
* queue stays on because the option is per connection and this socket also
|
|
646
|
+
* carries sessions, codes and refresh rotation.
|
|
599
647
|
*
|
|
600
|
-
*
|
|
601
|
-
*
|
|
602
|
-
*
|
|
603
|
-
*
|
|
604
|
-
* hang, and it is also the only guard that survives the nastiest partition
|
|
605
|
-
* shape: a zombie TCP connection where no `close` event ever fires, so the
|
|
606
|
-
* reconnect path described next is never entered at all.
|
|
607
|
-
* - `maxRetriesPerRequest: 3` — on the fourth reconnect attempt (roughly
|
|
608
|
-
* 0.4–1 s in on ioredis 6's jittered exponential backoff) the whole queue is
|
|
609
|
-
* failed in one go with `MaxRetriesPerRequestError`, rather than on the
|
|
610
|
-
* twentieth as the default has it. `commandTimeout` alone bounds each command's
|
|
611
|
-
* *latency*, but the queue would still grow to (request rate × 1 s) entries
|
|
612
|
-
* for as long as the outage lasted; this bounds its *depth*. Three rather
|
|
613
|
-
* than one so an ordinary sub-second reconnect blip is ridden out silently.
|
|
614
|
-
* - `connectTimeout: 5000` — halves the driver's 10 s default. A dropped SYN
|
|
615
|
-
* (a partition that black-holes packets rather than refusing them) is the
|
|
616
|
-
* only case that reaches this ceiling; a real connect on any deployment
|
|
617
|
-
* topology this template targets completes in well under a second.
|
|
618
|
-
* - `enableOfflineQueue: true` — the driver default, declared explicitly
|
|
619
|
-
* because it is a decision, not an inherited value. See below.
|
|
620
|
-
* - `lazyConnect: false` — also the driver default (still, on ioredis 6),
|
|
621
|
-
* declared so the boot-time-connect contract survives a future flip.
|
|
622
|
-
*
|
|
623
|
-
* **Why the offline queue stays on.** Turning it off is the sharper answer for
|
|
624
|
-
* the rate limiter specifically: a command issued while the socket is down
|
|
625
|
-
* rejects immediately instead of after `commandTimeout`, so the fail-closed
|
|
626
|
-
* policy sheds load at the first request rather than one second per request
|
|
627
|
-
* later. But `enableOfflineQueue` is a per-**connection** option, and this
|
|
628
|
-
* template runs every purpose off one socket — refresh-token families, the
|
|
629
|
-
* four user-session stores, the authorization-code repository, the
|
|
630
|
-
* access-token denylist and the rate limiter all come out of a single
|
|
631
|
-
* `makeIoredisClients(io)` call (pinned by "one connection in, one connection
|
|
632
|
-
* used" in `packages/redis/__tests__/ioredis.test.mts`). There is no way to
|
|
633
|
-
* express "off here, on there" without opening a second socket, which is the
|
|
634
|
-
* connection-pool pressure the shared-socket design was built to avoid.
|
|
635
|
-
*
|
|
636
|
-
* So the choice is one setting for all eleven purposes, and `false` is the
|
|
637
|
-
* wrong one to pick for all eleven: it converts every reconnect — including
|
|
638
|
-
* the sub-second blip of a routine managed-Redis failover, during which
|
|
639
|
-
* ioredis would have recovered transparently — into hard errors on session
|
|
640
|
-
* lookup, code redemption and refresh rotation. A failed refresh rotation is
|
|
641
|
-
* not a retryable blip to the end user; it is a re-login.
|
|
642
|
-
*
|
|
643
|
-
* `true` costs the rate limiter up to `commandTimeout` of delay before it
|
|
644
|
-
* starts shedding, and that is the honest trade. It is affordable precisely
|
|
645
|
-
* because `commandTimeout` exists: the pile-up is now bounded by
|
|
646
|
-
* (request rate × 1 s) and self-draining, rather than unbounded and growing.
|
|
647
|
-
* A deployment that wants the sharper behavior gives the rate limiter its own
|
|
648
|
-
* connection in its own composition root — the per-purpose client interfaces
|
|
649
|
-
* exist for exactly that, and it is a deliberate second socket rather than a
|
|
650
|
-
* silent global.
|
|
648
|
+
* One second is ample: the socket's commands are O(1) primitives and small
|
|
649
|
+
* paged scans (`SET`/`GET`/`DEL`/`EXISTS`/`PTTL`/`INCR`/`EVAL`/`HSCAN`/
|
|
650
|
+
* `SSCAN`/`ZADD`/…), none blocking. `lazyConnect: false` is the driver
|
|
651
|
+
* default, declared so the boot-time-connect contract survives a future flip.
|
|
651
652
|
*/
|
|
652
653
|
const SHARED_REDIS_TIMEOUTS = {
|
|
653
654
|
commandTimeout: 1_000,
|
|
@@ -657,26 +658,12 @@ const SHARED_REDIS_TIMEOUTS = {
|
|
|
657
658
|
lazyConnect: false,
|
|
658
659
|
} as const;
|
|
659
660
|
|
|
660
|
-
// Module-scoped cache so each `provides.*` factory invocation reuses the
|
|
661
|
-
// same `Redis` instance (and its `makeIoredisClients()` derivation) within
|
|
662
|
-
// a single createApp() invocation. The boot planner calls each `provides`
|
|
663
|
-
// in dependency order; without this, every consumed slot would create its
|
|
664
|
-
// own Redis socket, defeating the unification purpose.
|
|
665
|
-
//
|
|
666
|
-
// Cache key: the `lifecycleRegistrar` IDENTITY (which is per-boot — each
|
|
667
|
-
// `createApp()` invocation seeds a fresh registrar via the boot planner).
|
|
668
|
-
// Keying solely on `config` would incorrectly share connections across
|
|
669
|
-
// boots when the same config object is reused with a new registrar (and
|
|
670
|
-
// only the FIRST boot's registrar would receive disposal — Copilot review
|
|
671
|
-
// on PR #121). When `lifecycleRegistrar` is undefined (test scenarios
|
|
672
|
-
// that don't seed it), each call creates a fresh client; tests are
|
|
673
|
-
// isolated and don't need cross-slot sharing.
|
|
674
661
|
/**
|
|
675
662
|
* Every per-purpose client off the one socket: the general bundle, plus the
|
|
676
|
-
* two the federation-grant stores need
|
|
677
|
-
*
|
|
678
|
-
*
|
|
679
|
-
*
|
|
663
|
+
* two the federation-grant stores need. Those two have their own factories in
|
|
664
|
+
* the Redis package, so a Cluster deployment can give the grants a
|
|
665
|
+
* connection of their own; this template shares the socket, as it does for
|
|
666
|
+
* every other store.
|
|
680
667
|
*/
|
|
681
668
|
type StandaloneRedisClients = ReturnType<typeof makeIoredisClients> & {
|
|
682
669
|
readonly federationGrantStoreClient: ReturnType<typeof makeIoredisFederationGrantStoreClient>;
|
|
@@ -684,9 +671,15 @@ type StandaloneRedisClients = ReturnType<typeof makeIoredisClients> & {
|
|
|
684
671
|
typeof makeIoredisFederationGrantIntentStoreClient
|
|
685
672
|
>;
|
|
686
673
|
};
|
|
674
|
+
// So every `provides.*` factory reuses one `Redis` instance (and its clients)
|
|
675
|
+
// within a createApp() call, rather than opening a socket per consumed slot.
|
|
676
|
+
// Keyed on the `lifecycleRegistrar` identity, fresh per boot: keying on
|
|
677
|
+
// `config` would share connections across boots that reuse a config object,
|
|
678
|
+
// and only the first boot's registrar would dispose them. Without a
|
|
679
|
+
// registrar (tests that seed none), each call creates a fresh client.
|
|
687
680
|
const clientsCache = new WeakMap<LifecycleRegistrar, StandaloneRedisClients>();
|
|
688
681
|
function getOrCreateClients(
|
|
689
|
-
|
|
682
|
+
section: { readonly url: string; readonly password?: string | undefined },
|
|
690
683
|
lifecycleRegistrar: LifecycleRegistrar | undefined,
|
|
691
684
|
readinessRegistrar?: ReadinessRegistrar,
|
|
692
685
|
injectedLogger?: Logger,
|
|
@@ -695,18 +688,16 @@ function getOrCreateClients(
|
|
|
695
688
|
const cached = lifecycleRegistrar ? clientsCache.get(lifecycleRegistrar) : undefined;
|
|
696
689
|
if (cached) return cached;
|
|
697
690
|
|
|
698
|
-
|
|
699
|
-
if (typeof cfg?.url !== "string" || cfg.url.length === 0) {
|
|
691
|
+
if (section.url.length === 0) {
|
|
700
692
|
throw new Error(
|
|
701
|
-
"standaloneRedisClientsModule: `
|
|
702
|
-
"Redis-backed adapter is selected. Set
|
|
703
|
-
"
|
|
704
|
-
"
|
|
693
|
+
"standaloneRedisClientsModule: `redis-clients.url` is required when any " +
|
|
694
|
+
"Redis-backed adapter is selected. Set REDIS_CLIENTS_URL, or the " +
|
|
695
|
+
"key in a configuration layer, to a non-empty URL. Multi-replica deployments require " +
|
|
696
|
+
"a shared Redis 7.2+ instance.",
|
|
705
697
|
);
|
|
706
698
|
}
|
|
707
|
-
const password = typeof cfg.password === "string" ? cfg.password : undefined;
|
|
708
699
|
|
|
709
|
-
const io = new Redis(
|
|
700
|
+
const io = new Redis(section.url, { password: section.password, ...SHARED_REDIS_TIMEOUTS });
|
|
710
701
|
|
|
711
702
|
// Attach an error handler so unhandled "error" events do not crash the
|
|
712
703
|
// process. Initial connection failures surface here; downstream adapter
|
|
@@ -721,9 +712,9 @@ function getOrCreateClients(
|
|
|
721
712
|
await io.quit();
|
|
722
713
|
});
|
|
723
714
|
|
|
724
|
-
// One probe for the one socket
|
|
725
|
-
//
|
|
726
|
-
//
|
|
715
|
+
// One probe for the one socket, not one per consumed slot: the cache
|
|
716
|
+
// above means this runs once per boot, on the call that constructs the
|
|
717
|
+
// client.
|
|
727
718
|
readinessRegistrar?.register({
|
|
728
719
|
name: "redis",
|
|
729
720
|
check: () => io.ping(),
|
|
@@ -742,13 +733,11 @@ function getOrCreateClients(
|
|
|
742
733
|
}
|
|
743
734
|
|
|
744
735
|
/**
|
|
745
|
-
* Reads an optional string field off a federation slice.
|
|
746
|
-
*
|
|
747
|
-
*
|
|
748
|
-
*
|
|
749
|
-
*
|
|
750
|
-
* shape the file holds, and silently ignoring `sessionDomain = 42` would leave
|
|
751
|
-
* the redirect policy running with one fewer constraint than the operator
|
|
736
|
+
* Reads an optional string field off a federation slice. An unset field stays
|
|
737
|
+
* absent rather than becoming `undefined`, so `"sessionDomain" in config`
|
|
738
|
+
* still distinguishes the two. A present non-string throws: HOCON hands
|
|
739
|
+
* through whatever the file holds, and silently ignoring `sessionDomain = 42`
|
|
740
|
+
* would run the redirect policy with one fewer constraint than the operator
|
|
752
741
|
* wrote down.
|
|
753
742
|
*/
|
|
754
743
|
function optionalString(
|
|
@@ -758,20 +747,19 @@ function optionalString(
|
|
|
758
747
|
const value = slice[field];
|
|
759
748
|
if (value === undefined || value === null) return {};
|
|
760
749
|
if (typeof value !== "string") {
|
|
761
|
-
throw new Error(`federations.google.${field} must be a string when present`);
|
|
750
|
+
throw new Error(`core.federations.google.${field} must be a string when present`);
|
|
762
751
|
}
|
|
763
752
|
return { [field]: value };
|
|
764
753
|
}
|
|
765
754
|
|
|
766
755
|
/**
|
|
767
|
-
* An optional boolean from the `federations.google`
|
|
768
|
-
*
|
|
769
|
-
*
|
|
770
|
-
*
|
|
771
|
-
* an empty
|
|
772
|
-
*
|
|
773
|
-
*
|
|
774
|
-
* silently reads as either value is worse than a boot failure.
|
|
756
|
+
* An optional boolean from the `core.federations.google` entry: a HOCON boolean, or
|
|
757
|
+
* from an environment override (`${?VAR}`) a string in the spellings core's
|
|
758
|
+
* `coerceBooleanFromEnv` accepts ("true" / "false" / "1" / "0", trimmed, any
|
|
759
|
+
* case). Unlike that coercion, an empty value is refused, not read as false:
|
|
760
|
+
* HOCON substitutes an exported-but-empty variable as "", and a security
|
|
761
|
+
* switch must not quietly turn the check off. Anything else is refused too,
|
|
762
|
+
* so a typo fails boot rather than reading as either value.
|
|
775
763
|
*/
|
|
776
764
|
function optionalBoolean(
|
|
777
765
|
slice: Record<string, unknown>,
|
|
@@ -786,12 +774,12 @@ function optionalBoolean(
|
|
|
786
774
|
if (normalized === "false" || normalized === "0") return { [field]: false };
|
|
787
775
|
}
|
|
788
776
|
throw new Error(
|
|
789
|
-
`federations.google.${field} must be one of true, false, "true", "false", "1" or "0" when present`,
|
|
777
|
+
`core.federations.google.${field} must be one of true, false, "true", "false", "1" or "0" when present`,
|
|
790
778
|
);
|
|
791
779
|
}
|
|
792
780
|
|
|
793
781
|
/**
|
|
794
|
-
* `accessType` from the `federations.google`
|
|
782
|
+
* `accessType` from the `core.federations.google` entry: `"offline"` or `"online"`,
|
|
795
783
|
* exactly, or absent. `federation-google` refuses any other value too; this
|
|
796
784
|
* refuses it first, naming the key, as the fields above do.
|
|
797
785
|
*/
|
|
@@ -801,35 +789,30 @@ function optionalAccessType(
|
|
|
801
789
|
const value = slice.accessType;
|
|
802
790
|
if (value === undefined || value === null) return {};
|
|
803
791
|
if (value === "offline" || value === "online") return { accessType: value };
|
|
804
|
-
throw new Error('federations.google.accessType must be "offline" or "online" when present');
|
|
792
|
+
throw new Error('core.federations.google.accessType must be "offline" or "online" when present');
|
|
805
793
|
}
|
|
806
794
|
|
|
807
795
|
/**
|
|
808
|
-
* Google federation config bridge
|
|
809
|
-
*
|
|
810
|
-
*
|
|
811
|
-
*
|
|
812
|
-
* (Phase 7 A5) consume this slot; the bridge is the standalone composition
|
|
813
|
-
* root's responsibility because the slot's content is consumer-specific.
|
|
796
|
+
* Google federation config bridge: supplies the typed `googleFederationConfig`
|
|
797
|
+
* slot from the `core.federations.google` entry. The bridge is the
|
|
798
|
+
* composition root's responsibility because the slot's content is
|
|
799
|
+
* consumer-specific (see the `@o3co/auth-provider-federation-google` README).
|
|
814
800
|
*
|
|
815
|
-
*
|
|
816
|
-
*
|
|
817
|
-
* `
|
|
818
|
-
*
|
|
819
|
-
*
|
|
820
|
-
* that had configured them ran as though it had not. The generated app is the
|
|
821
|
-
* shape most operators start from, so the safe path has to be the one that
|
|
822
|
-
* works by filling in the config file.
|
|
801
|
+
* It carries every field the provider reads, not only the credentials:
|
|
802
|
+
* `googleFederationModule` hands this same object to
|
|
803
|
+
* `createFederationRedirectPolicy`, so a dropped `redirectAllowlist` /
|
|
804
|
+
* `sessionDomain` / `authCallbackUrl` / `clientUrl` would silently run the
|
|
805
|
+
* policy without the constraints the operator configured.
|
|
823
806
|
*/
|
|
824
807
|
export const googleFederationConfigModule: Module = defineModule({
|
|
825
|
-
name: "
|
|
808
|
+
name: "google-federation-config",
|
|
826
809
|
requires: ["config"] as const,
|
|
827
810
|
provides: {
|
|
828
811
|
googleFederationConfig: ({ config }): GoogleProviderConfig => {
|
|
829
|
-
const slice = extractFederationSection((config
|
|
812
|
+
const slice = extractFederationSection(federationsOf(config), "google");
|
|
830
813
|
if (!slice) {
|
|
831
814
|
throw new Error(
|
|
832
|
-
"federations.google must be enabled with credentials when googleFederationModule is in the manifest",
|
|
815
|
+
"core.federations.google must be enabled with credentials when googleFederationModule is in the manifest",
|
|
833
816
|
);
|
|
834
817
|
}
|
|
835
818
|
const clientId = slice.clientId;
|
|
@@ -841,7 +824,7 @@ export const googleFederationConfigModule: Module = defineModule({
|
|
|
841
824
|
typeof callbackURL !== "string"
|
|
842
825
|
) {
|
|
843
826
|
throw new Error(
|
|
844
|
-
"federations.google requires clientId, clientSecret, callbackURL when enabled",
|
|
827
|
+
"core.federations.google requires clientId, clientSecret, callbackURL when enabled",
|
|
845
828
|
);
|
|
846
829
|
}
|
|
847
830
|
|
|
@@ -855,7 +838,7 @@ export const googleFederationConfigModule: Module = defineModule({
|
|
|
855
838
|
if (rawAllowlist !== undefined && rawAllowlist !== null) {
|
|
856
839
|
if (!Array.isArray(rawAllowlist) || rawAllowlist.some((e) => typeof e !== "string")) {
|
|
857
840
|
throw new Error(
|
|
858
|
-
"federations.google.redirectAllowlist must be a list of URL strings, " +
|
|
841
|
+
"core.federations.google.redirectAllowlist must be a list of URL strings, " +
|
|
859
842
|
'e.g. ["https://app.example.com/welcome"]',
|
|
860
843
|
);
|
|
861
844
|
}
|
|
@@ -870,7 +853,7 @@ export const googleFederationConfigModule: Module = defineModule({
|
|
|
870
853
|
...optionalString(slice, "sessionDomain"),
|
|
871
854
|
...optionalString(slice, "authCallbackUrl"),
|
|
872
855
|
...optionalString(slice, "clientUrl"),
|
|
873
|
-
//
|
|
856
|
+
// Absent means the provider's default, which is to require it.
|
|
874
857
|
...optionalBoolean(slice, "requireAuthorizationResponseIss"),
|
|
875
858
|
// Absent means the provider's default, "offline": consent on every
|
|
876
859
|
// sign-in, and a refresh token for every session.
|
|
@@ -881,19 +864,18 @@ export const googleFederationConfigModule: Module = defineModule({
|
|
|
881
864
|
});
|
|
882
865
|
|
|
883
866
|
/**
|
|
884
|
-
* OIDC federation config bridge
|
|
867
|
+
* OIDC federation config bridge: supplies the `oidcFederationConfigs`
|
|
885
868
|
* slot every `oidcFederationModule(<name>)` in the manifest reads its entry
|
|
886
869
|
* from. One bridge for all instances: `readOidcFederationConfigs` walks
|
|
887
|
-
* `
|
|
888
|
-
* refusing a malformed field by `federations.<name>.<field>` at boot.
|
|
870
|
+
* `core.federations` and reads every enabled entry of type `oidc`,
|
|
871
|
+
* refusing a malformed field by `core.federations.<name>.<field>` at boot.
|
|
889
872
|
* `buildModules` lists this module only when at least one such section
|
|
890
873
|
* exists.
|
|
891
874
|
*/
|
|
892
875
|
export const oidcFederationConfigModule: Module = defineModule({
|
|
893
|
-
name: "
|
|
876
|
+
name: "oidc-federation-config",
|
|
894
877
|
requires: ["config"] as const,
|
|
895
878
|
provides: {
|
|
896
|
-
oidcFederationConfigs: ({ config }) =>
|
|
897
|
-
readOidcFederationConfigs((config as AppConfig).federations),
|
|
879
|
+
oidcFederationConfigs: ({ config }) => readOidcFederationConfigs(federationsOf(config)),
|
|
898
880
|
},
|
|
899
881
|
});
|