@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
|
@@ -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,8 +27,10 @@ import {
|
|
|
28
27
|
createKeyStoreFactory,
|
|
29
28
|
createRepositoryFactories,
|
|
30
29
|
defineModule,
|
|
30
|
+
federationsOf,
|
|
31
31
|
type LifecycleRegistrar,
|
|
32
32
|
type Logger,
|
|
33
|
+
loggableError,
|
|
33
34
|
type Module,
|
|
34
35
|
type ReadinessRegistrar,
|
|
35
36
|
registerBuiltinAuditSinks,
|
|
@@ -45,25 +46,29 @@ import {
|
|
|
45
46
|
makeIoredisFederationGrantStoreClient,
|
|
46
47
|
} from "@o3co/auth-provider-redis/ioredis";
|
|
47
48
|
import { extractFederationSection } from "@o3co/auth-provider-session";
|
|
48
|
-
// Named import
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
// The 11 default-import sites elsewhere in the repo all live under
|
|
56
|
-
// `packages/redis/__tests__/` which `packages/redis/tsconfig.json`
|
|
57
|
-
// explicitly excludes from strict tsc build (vitest's vue-tsc is more
|
|
58
|
-
// permissive about CJS interop). Standalone production source is built
|
|
59
|
-
// 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.
|
|
60
56
|
import { Redis } from "ioredis";
|
|
61
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";
|
|
62
68
|
|
|
63
69
|
/**
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
70
|
+
* Turn a `{ type, [type]: {...} }` adapter-config slice into the flat
|
|
71
|
+
* `{ type, ...rest }` shape `AdapterFactory<T>.create()` consumes.
|
|
67
72
|
* Composition-root concern; not a library export.
|
|
68
73
|
*/
|
|
69
74
|
function flattenAdapterConfig(
|
|
@@ -84,188 +89,266 @@ function flattenAdapterConfig(
|
|
|
84
89
|
}
|
|
85
90
|
|
|
86
91
|
/**
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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.
|
|
92
169
|
*/
|
|
93
170
|
export const keyStoreModule: Module = defineModule({
|
|
94
|
-
name: "
|
|
95
|
-
|
|
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
|
+
},
|
|
96
187
|
provides: {
|
|
97
|
-
keyStore: async ({
|
|
188
|
+
keyStore: async ({ section }) => {
|
|
98
189
|
const factory = createKeyStoreFactory();
|
|
99
190
|
registerBuiltinKeyStores(factory);
|
|
100
|
-
return factory.create(flattenAdapterConfig(
|
|
191
|
+
return factory.create(flattenAdapterConfig(section));
|
|
101
192
|
},
|
|
102
193
|
},
|
|
103
194
|
});
|
|
104
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
|
+
|
|
105
202
|
/**
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
* `inMemoryCodeRepositoryModule` (memory branch) / `redisCodeRepositoryModule`
|
|
111
|
-
* (redis branch, from `@o3co/auth-provider-redis`) so the
|
|
112
|
-
* `oauth.code.adapter` switch can wire mutually-exclusive providers without
|
|
113
|
-
* 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.
|
|
114
207
|
*/
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
+
},
|
|
135
245
|
},
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
)
|
|
144
|
-
|
|
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
|
+
},
|
|
145
269
|
},
|
|
146
|
-
}
|
|
147
|
-
}
|
|
270
|
+
});
|
|
271
|
+
}
|
|
148
272
|
|
|
149
273
|
/**
|
|
150
|
-
* In-memory CodeRepository module
|
|
151
|
-
* `
|
|
274
|
+
* In-memory CodeRepository module, wired by `buildModules` only when
|
|
275
|
+
* `adapters.codeRepository = "memory"`; the Redis choice swaps in
|
|
152
276
|
* `redisCodeRepositoryModule` from `@o3co/auth-provider-redis` (mutually
|
|
153
|
-
* exclusive
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
* inside `repositoriesModule` via the AdapterFactory pattern; that path
|
|
157
|
-
* coupled the slot to `repositories.code.type` which is now superseded by
|
|
158
|
-
* `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.
|
|
159
280
|
*/
|
|
160
281
|
export const inMemoryCodeRepositoryModule: Module = defineModule({
|
|
161
|
-
name: "standalone
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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.
|
|
165
294
|
replicaSafety: {
|
|
166
295
|
unsafe: true,
|
|
167
296
|
reason:
|
|
168
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",
|
|
169
298
|
},
|
|
170
|
-
requires: ["config"] as const,
|
|
171
299
|
optional: ["lifecycleRegistrar"] as const,
|
|
172
300
|
provides: {
|
|
173
|
-
codeRepository: async ({
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
// `codeFactory.register("memory", ...)` is wired automatically by
|
|
177
|
-
// `createRepositoryFactories` itself (see core/repositories/RepositoryFactory.mts).
|
|
178
|
-
// `registerBuiltinAdapters` from `@o3co/auth-provider-foundation` only
|
|
179
|
-
// registers the HTTP user adapter on `userFactory`; it does NOT touch
|
|
180
|
-
// `codeFactory`, so calling it here would be misleading and unnecessary.
|
|
181
|
-
//
|
|
182
|
-
// The flattened slice MAY still have `type = "redis"` when only the
|
|
183
|
-
// legacy `repositories.code.type` is set — `buildModules`' adapter
|
|
184
|
-
// resolution already chose memory by the time we get here, so
|
|
185
|
-
// override the type explicitly to keep this module robust against
|
|
186
|
-
// legacy HOCON shapes.
|
|
187
|
-
const slice = flattenAdapterConfig(
|
|
188
|
-
(config as AppConfig).repositories.code as { type: string } & Record<string, unknown>,
|
|
189
|
-
);
|
|
190
|
-
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 });
|
|
191
304
|
},
|
|
192
305
|
},
|
|
193
306
|
});
|
|
194
307
|
|
|
195
308
|
/**
|
|
196
|
-
* In-memory user-session stores module
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
* `buildModules` only when `userSessionStores
|
|
200
|
-
*
|
|
201
|
-
* `@o3co/auth-provider-redis`.
|
|
202
|
-
*
|
|
203
|
-
* Pre-Wave-5d this module also provided `federationTokenStore` (now
|
|
204
|
-
* extracted into a separate always-wired module so the redis session
|
|
205
|
-
* branch doesn't drop the federation-token-store provider — Copilot
|
|
206
|
-
* 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`.
|
|
207
314
|
*/
|
|
208
315
|
export const inMemorySessionStoresModule: Module = defineModule({
|
|
209
|
-
name: "standalone
|
|
210
|
-
// #455: the replica-safety guard reads this off the manifest. Before it
|
|
211
|
-
// did, `DEPLOYMENT_MODE=multi` with `USER_SESSION_STORES_ADAPTER=memory`
|
|
212
|
-
// booted — the guard keyed on core's `memorySessionStores`, and this
|
|
213
|
-
// module provides the same six slots under a name it had never heard of.
|
|
316
|
+
name: "standalone-in-memory-session-stores",
|
|
214
317
|
replicaSafety: {
|
|
215
318
|
unsafe: true,
|
|
216
319
|
reason:
|
|
217
|
-
"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",
|
|
218
321
|
},
|
|
219
322
|
provides: {
|
|
220
323
|
userSessionStore: () => createInMemoryUserSessionStore(),
|
|
221
324
|
sessionRPRegistry: () => createInMemorySessionRPRegistry(),
|
|
222
325
|
sessionFamilyIndex: () => createInMemorySessionFamilyIndex(),
|
|
223
326
|
sessionFederationIndex: () => createInMemorySessionFederationIndex(),
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
// answer is to wire them rather than to declare the capability absent.
|
|
231
|
-
//
|
|
232
|
-
// Single-process only, like every other store on this branch. The
|
|
233
|
-
// redis branch swaps in `redisSessionStoresModule`, which has provided
|
|
234
|
-
// both since #321 — before that there was no distributed adapter to
|
|
235
|
-
// 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.
|
|
236
333
|
subjectSessionIndex: () => createInMemorySubjectSessionIndex(),
|
|
237
334
|
subjectRevocation: () => createInMemorySubjectRevocation(),
|
|
238
335
|
},
|
|
239
336
|
});
|
|
240
337
|
|
|
241
338
|
/**
|
|
242
|
-
* In-memory federation token store module
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
* (`standalone:federation-token-store`), choosing at runtime through the
|
|
250
|
-
* adapter factory. That shape failed twice over: the replica-safety guard
|
|
251
|
-
* could not tell the memory store from the Redis one by name, so
|
|
252
|
-
* `deployment.mode = "multi"` booted with the memory store; and the Redis
|
|
253
|
-
* branch handed the builder no client, so `federationTokenStore.type =
|
|
254
|
-
* "redis"` — documented in the README — was a boot failure rather than a
|
|
255
|
-
* Redis-backed store. One module per adapter, like the session stores.
|
|
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.
|
|
256
346
|
*
|
|
257
|
-
* The
|
|
258
|
-
*
|
|
259
|
-
* redis session-stores branch dropped the provider entirely (Copilot review
|
|
260
|
-
* on PR #121).
|
|
261
|
-
*
|
|
262
|
-
* The memory adapter still comes through core's factory, unchanged: that is
|
|
263
|
-
* 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.
|
|
264
349
|
*/
|
|
265
350
|
export const inMemoryFederationTokenStoreModule: Module = defineModule({
|
|
266
|
-
name: "standalone
|
|
267
|
-
// #455: read by the replica-safety guard. Before the split this module
|
|
268
|
-
// carried one name for both adapters, so no name could have carried it.
|
|
351
|
+
name: "standalone-in-memory-federation-token-store",
|
|
269
352
|
replicaSafety: {
|
|
270
353
|
unsafe: true,
|
|
271
354
|
reason:
|
|
@@ -281,75 +364,52 @@ export const inMemoryFederationTokenStoreModule: Module = defineModule({
|
|
|
281
364
|
});
|
|
282
365
|
|
|
283
366
|
/**
|
|
284
|
-
* Audit-sink module
|
|
285
|
-
* security event reads
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
* deployable artifact, with nothing failing and nothing warning. This module is
|
|
293
|
-
* always in the manifest for that reason — there is no "no audit sink" branch,
|
|
294
|
-
* the way there is a memory/redis branch for the state stores.
|
|
295
|
-
*
|
|
296
|
-
* Two sink kinds are registered:
|
|
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`.
|
|
297
375
|
*
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
* pipeline that wants the event and nothing else.
|
|
304
|
-
*
|
|
305
|
-
* A deployment with a real sink (SIEM, log pipeline, message bus) registers its
|
|
306
|
-
* builder here and names it in `audit.sink.type`; its options ride along in the
|
|
307
|
-
* same config block (`flattenAdapterConfig` unwraps `sink { type = "x", x { … } }`).
|
|
308
|
-
*
|
|
309
|
-
* One thing to know before writing such a builder: `createAuditSinkFactory()`
|
|
310
|
-
* takes no `BuilderContext`, so a sink builder receives `{}` and the
|
|
311
|
-
* `ctx.lifecycle?.register(…)` / `ctx.readiness?.register(…)` calls that
|
|
312
|
-
* CONTRIBUTING.md requires of a builder opening a connection are silent no-ops
|
|
313
|
-
* here. A sink holding a socket must own its cleanup another way until that
|
|
314
|
-
* factory takes a context.
|
|
315
|
-
*
|
|
316
|
-
* There is deliberately no `"none"`. An unknown type — including the `"none"`
|
|
317
|
-
* an operator might reasonably guess at — fails boot naming the sinks that
|
|
318
|
-
* 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.
|
|
319
381
|
*/
|
|
320
|
-
export
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
// config that says nothing about auditing is not a config asking for
|
|
331
|
-
// the events to be dropped. The literal default lives in HOCON
|
|
332
|
-
// (`reference.conf` / `application.conf`); this is the floor under a
|
|
333
|
-
// hand-built config that never met either.
|
|
334
|
-
return factory.create(
|
|
335
|
-
slice
|
|
336
|
-
? flattenAdapterConfig(slice as { type: string } & Record<string, unknown>)
|
|
337
|
-
: { type: "logger" },
|
|
338
|
-
);
|
|
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
|
+
},
|
|
339
392
|
},
|
|
340
|
-
|
|
341
|
-
})
|
|
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
|
+
}
|
|
342
403
|
|
|
343
404
|
/**
|
|
344
|
-
* @deprecated
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
* 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.
|
|
348
408
|
*/
|
|
349
409
|
export const storesModule: Module = defineModule({
|
|
350
|
-
name: "
|
|
351
|
-
//
|
|
352
|
-
// 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"`
|
|
353
413
|
// like the split modules it stands in for.
|
|
354
414
|
replicaSafety: {
|
|
355
415
|
unsafe: true,
|
|
@@ -364,275 +424,231 @@ export const storesModule: Module = defineModule({
|
|
|
364
424
|
});
|
|
365
425
|
|
|
366
426
|
/**
|
|
367
|
-
* Shared ioredis clients module
|
|
368
|
-
* per
|
|
369
|
-
*
|
|
370
|
-
* Redis-backed
|
|
371
|
-
*
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
* Per F4 PR1 (D-2 v2) + Wave 5d unification: the previous design opened a
|
|
376
|
-
* separate ioredis socket per Redis-backed module (3+ sockets per replica).
|
|
377
|
-
* This module consolidates them — `makeIoredisClients(io)` was always
|
|
378
|
-
* designed to derive multiple per-purpose typed clients from a single
|
|
379
|
-
* connection; using one socket realises that intent and minimises
|
|
380
|
-
* connection-pool pressure on the upstream Redis.
|
|
381
|
-
*
|
|
382
|
-
* Connection-config: reuses `refreshTokenFamilyStore.redis.{url, password}`
|
|
383
|
-
* (declared in `fullSectionsSchema` by D-2 v2 §1). No new top-level config
|
|
384
|
-
* key is introduced — the connection is shared so its config is too. Future
|
|
385
|
-
* splits (per-store distinct Redis instances) are operator-side via custom
|
|
386
|
-
* composition roots, not standalone configuration.
|
|
387
|
-
*
|
|
388
|
-
* Fails fast (throws) when the Redis URL is absent. The HOCON default in
|
|
389
|
-
* `application.conf` covers single-instance / dev scenarios; an operator
|
|
390
|
-
* who deliberately removes the section in production sees an explicit
|
|
391
|
-
* error instead of a silent localhost fallback (the multi-replica failure
|
|
392
|
-
* mode OR-1 was supposed to close).
|
|
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.
|
|
393
435
|
*
|
|
394
|
-
*
|
|
395
|
-
* `
|
|
396
|
-
*
|
|
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.
|
|
397
442
|
*
|
|
398
|
-
*
|
|
399
|
-
*
|
|
400
|
-
*
|
|
401
|
-
* — refresh-token-family is always Redis-backed in production). Adding the
|
|
402
|
-
* module unconditionally would open an ioredis socket for memory-only
|
|
403
|
-
* deployments.
|
|
443
|
+
* `buildModules` adds this module only when some composed module needs a
|
|
444
|
+
* Redis client (`usingRedisAnywhere`), so memory-only compositions open no
|
|
445
|
+
* socket.
|
|
404
446
|
*/
|
|
405
447
|
export const standaloneRedisClientsModule: Module = defineModule({
|
|
406
|
-
name: "
|
|
407
|
-
|
|
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
|
+
},
|
|
408
458
|
optional: ["lifecycleRegistrar", "readinessRegistrar", "logger"] as const,
|
|
409
459
|
provides: {
|
|
410
460
|
refreshTokenFamilyClient: async ({
|
|
411
|
-
|
|
461
|
+
section,
|
|
412
462
|
lifecycleRegistrar,
|
|
413
463
|
readinessRegistrar,
|
|
414
464
|
logger,
|
|
415
465
|
}) => {
|
|
416
|
-
return getOrCreateClients(
|
|
466
|
+
return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
|
|
417
467
|
.refreshTokenFamilyClient;
|
|
418
468
|
},
|
|
419
|
-
userSessionStoreClient: async ({
|
|
420
|
-
return getOrCreateClients(
|
|
469
|
+
userSessionStoreClient: async ({ section, lifecycleRegistrar, readinessRegistrar, logger }) => {
|
|
470
|
+
return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
|
|
421
471
|
.userSessionStoreClient;
|
|
422
472
|
},
|
|
423
|
-
sessionRPRegistryClient: async ({
|
|
424
|
-
|
|
473
|
+
sessionRPRegistryClient: async ({
|
|
474
|
+
section,
|
|
475
|
+
lifecycleRegistrar,
|
|
476
|
+
readinessRegistrar,
|
|
477
|
+
logger,
|
|
478
|
+
}) => {
|
|
479
|
+
return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
|
|
425
480
|
.sessionRPRegistryClient;
|
|
426
481
|
},
|
|
427
482
|
sessionFamilyIndexClient: async ({
|
|
428
|
-
|
|
483
|
+
section,
|
|
429
484
|
lifecycleRegistrar,
|
|
430
485
|
readinessRegistrar,
|
|
431
486
|
logger,
|
|
432
487
|
}) => {
|
|
433
|
-
return getOrCreateClients(
|
|
488
|
+
return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
|
|
434
489
|
.sessionFamilyIndexClient;
|
|
435
490
|
},
|
|
436
491
|
sessionFederationIndexClient: async ({
|
|
437
|
-
|
|
492
|
+
section,
|
|
438
493
|
lifecycleRegistrar,
|
|
439
494
|
readinessRegistrar,
|
|
440
495
|
logger,
|
|
441
496
|
}) => {
|
|
442
|
-
return getOrCreateClients(
|
|
497
|
+
return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
|
|
443
498
|
.sessionFederationIndexClient;
|
|
444
499
|
},
|
|
445
|
-
//
|
|
446
|
-
//
|
|
447
|
-
//
|
|
448
|
-
//
|
|
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.
|
|
449
504
|
federationGrantStoreClient: async ({
|
|
450
|
-
|
|
505
|
+
section,
|
|
451
506
|
lifecycleRegistrar,
|
|
452
507
|
readinessRegistrar,
|
|
453
508
|
logger,
|
|
454
509
|
}) => {
|
|
455
|
-
return getOrCreateClients(
|
|
510
|
+
return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
|
|
456
511
|
.federationGrantStoreClient;
|
|
457
512
|
},
|
|
458
513
|
federationGrantIntentStoreClient: async ({
|
|
459
|
-
|
|
514
|
+
section,
|
|
460
515
|
lifecycleRegistrar,
|
|
461
516
|
readinessRegistrar,
|
|
462
517
|
logger,
|
|
463
518
|
}) => {
|
|
464
|
-
return getOrCreateClients(
|
|
519
|
+
return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
|
|
465
520
|
.federationGrantIntentStoreClient;
|
|
466
521
|
},
|
|
467
|
-
//
|
|
468
|
-
replaySeenSetClient: async ({
|
|
469
|
-
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)
|
|
470
525
|
.replaySeenSetClient;
|
|
471
526
|
},
|
|
472
|
-
rateLimiterClient: async ({
|
|
473
|
-
return getOrCreateClients(
|
|
527
|
+
rateLimiterClient: async ({ section, lifecycleRegistrar, readinessRegistrar, logger }) => {
|
|
528
|
+
return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
|
|
474
529
|
.rateLimiterClient;
|
|
475
530
|
},
|
|
476
|
-
//
|
|
477
|
-
//
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
// socket while the per-purpose typed wrapper keeps the Redis
|
|
481
|
-
// command surface consumed by RedisCodeRepository explicit.
|
|
482
|
-
codeRepositoryClient: async ({ config, lifecycleRegistrar, readinessRegistrar, logger }) => {
|
|
483
|
-
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)
|
|
484
535
|
.codeRepositoryClient;
|
|
485
536
|
},
|
|
486
|
-
//
|
|
487
|
-
//
|
|
488
|
-
//
|
|
489
|
-
// it belongs on the connection the rest of the shared state already uses
|
|
490
|
-
// 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.
|
|
491
540
|
accessTokenDenylistClient: async ({
|
|
492
|
-
|
|
541
|
+
section,
|
|
493
542
|
lifecycleRegistrar,
|
|
494
543
|
readinessRegistrar,
|
|
495
544
|
logger,
|
|
496
545
|
}) => {
|
|
497
|
-
return getOrCreateClients(
|
|
546
|
+
return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
|
|
498
547
|
.accessTokenDenylistClient;
|
|
499
548
|
},
|
|
500
|
-
//
|
|
501
|
-
//
|
|
502
|
-
//
|
|
503
|
-
//
|
|
504
|
-
//
|
|
505
|
-
// the manifest with `missing-required-component`. Nothing in the
|
|
506
|
-
// standalone consumes these slots directly; they exist so the module
|
|
507
|
-
// 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.
|
|
508
554
|
subjectSessionIndexClient: async ({
|
|
509
|
-
|
|
555
|
+
section,
|
|
510
556
|
lifecycleRegistrar,
|
|
511
557
|
readinessRegistrar,
|
|
512
558
|
logger,
|
|
513
559
|
}) => {
|
|
514
|
-
return getOrCreateClients(
|
|
560
|
+
return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
|
|
515
561
|
.subjectSessionIndexClient;
|
|
516
562
|
},
|
|
517
|
-
subjectRevocationClient: async ({
|
|
518
|
-
|
|
563
|
+
subjectRevocationClient: async ({
|
|
564
|
+
section,
|
|
565
|
+
lifecycleRegistrar,
|
|
566
|
+
readinessRegistrar,
|
|
567
|
+
logger,
|
|
568
|
+
}) => {
|
|
569
|
+
return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
|
|
519
570
|
.subjectRevocationClient;
|
|
520
571
|
},
|
|
521
|
-
//
|
|
522
|
-
//
|
|
523
|
-
//
|
|
524
|
-
//
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
// subject-level slots above already paid for (#439) — a Redis-branch
|
|
528
|
-
// module whose client slot this module did not provide, caught only by
|
|
529
|
-
// the umbrella e2e.
|
|
530
|
-
deviceCodeStoreClient: async ({ config, lifecycleRegistrar, readinessRegistrar, logger }) => {
|
|
531
|
-
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)
|
|
532
578
|
.deviceCodeStoreClient;
|
|
533
579
|
},
|
|
534
|
-
//
|
|
535
|
-
//
|
|
536
|
-
//
|
|
537
|
-
//
|
|
538
|
-
|
|
539
|
-
return getOrCreateClients(
|
|
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)
|
|
586
|
+
.challengeStoreClient;
|
|
587
|
+
},
|
|
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)
|
|
540
593
|
.consentStoreClient;
|
|
541
594
|
},
|
|
542
595
|
pendingConsentStoreClient: async ({
|
|
543
|
-
|
|
596
|
+
section,
|
|
544
597
|
lifecycleRegistrar,
|
|
545
598
|
readinessRegistrar,
|
|
546
599
|
logger,
|
|
547
600
|
}) => {
|
|
548
|
-
return getOrCreateClients(
|
|
601
|
+
return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
|
|
549
602
|
.pendingConsentStoreClient;
|
|
550
603
|
},
|
|
551
|
-
//
|
|
552
|
-
// `redisFederationTokenStoreModule` requires it, and nothing provided
|
|
553
|
-
// it — which is why the Redis branch of the federation store could not
|
|
554
|
-
// boot in this template while the README said it could.
|
|
604
|
+
// Required by `redisFederationTokenStoreModule`.
|
|
555
605
|
federationTokenStoreClient: async ({
|
|
556
|
-
|
|
606
|
+
section,
|
|
557
607
|
lifecycleRegistrar,
|
|
558
608
|
readinessRegistrar,
|
|
559
609
|
logger,
|
|
560
610
|
}) => {
|
|
561
|
-
return getOrCreateClients(
|
|
611
|
+
return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
|
|
562
612
|
.federationTokenStoreClient;
|
|
563
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
|
+
},
|
|
564
632
|
},
|
|
565
633
|
});
|
|
566
634
|
|
|
567
635
|
/**
|
|
568
|
-
* Failure-timing options for the one shared ioredis socket
|
|
569
|
-
*
|
|
570
|
-
*
|
|
571
|
-
*
|
|
572
|
-
*
|
|
573
|
-
*
|
|
574
|
-
*
|
|
575
|
-
* `
|
|
576
|
-
*
|
|
577
|
-
*
|
|
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.
|
|
578
647
|
*
|
|
579
|
-
*
|
|
580
|
-
*
|
|
581
|
-
*
|
|
582
|
-
*
|
|
583
|
-
* to take a meaningful fraction of a second.
|
|
584
|
-
*
|
|
585
|
-
* - `commandTimeout: 1000` — the hard ceiling on any single command. ioredis
|
|
586
|
-
* arms this timer in `sendCommand`, *before* the writability check, so it
|
|
587
|
-
* bounds a command that went into the offline queue exactly as it bounds one
|
|
588
|
-
* already on the wire. That is what makes the queued case fail rather than
|
|
589
|
-
* hang, and it is also the only guard that survives the nastiest partition
|
|
590
|
-
* shape: a zombie TCP connection where no `close` event ever fires, so the
|
|
591
|
-
* reconnect path described next is never entered at all.
|
|
592
|
-
* - `maxRetriesPerRequest: 3` — on the fourth reconnect attempt (roughly
|
|
593
|
-
* 0.4–1 s in on ioredis 6's jittered exponential backoff) the whole queue is
|
|
594
|
-
* failed in one go with `MaxRetriesPerRequestError`, rather than on the
|
|
595
|
-
* twentieth as the default has it. `commandTimeout` alone bounds each command's
|
|
596
|
-
* *latency*, but the queue would still grow to (request rate × 1 s) entries
|
|
597
|
-
* for as long as the outage lasted; this bounds its *depth*. Three rather
|
|
598
|
-
* than one so an ordinary sub-second reconnect blip is ridden out silently.
|
|
599
|
-
* - `connectTimeout: 5000` — halves the driver's 10 s default. A dropped SYN
|
|
600
|
-
* (a partition that black-holes packets rather than refusing them) is the
|
|
601
|
-
* only case that reaches this ceiling; a real connect on any deployment
|
|
602
|
-
* topology this template targets completes in well under a second.
|
|
603
|
-
* - `enableOfflineQueue: true` — the driver default, declared explicitly
|
|
604
|
-
* because it is a decision, not an inherited value. See below.
|
|
605
|
-
* - `lazyConnect: false` — also the driver default (still, on ioredis 6),
|
|
606
|
-
* declared so the boot-time-connect contract survives a future flip.
|
|
607
|
-
*
|
|
608
|
-
* **Why the offline queue stays on.** Turning it off is the sharper answer for
|
|
609
|
-
* the rate limiter specifically: a command issued while the socket is down
|
|
610
|
-
* rejects immediately instead of after `commandTimeout`, so the fail-closed
|
|
611
|
-
* policy sheds load at the first request rather than one second per request
|
|
612
|
-
* later. But `enableOfflineQueue` is a per-**connection** option, and this
|
|
613
|
-
* template runs every purpose off one socket — refresh-token families, the
|
|
614
|
-
* four user-session stores, the authorization-code repository, the
|
|
615
|
-
* access-token denylist and the rate limiter all come out of a single
|
|
616
|
-
* `makeIoredisClients(io)` call (pinned by "one connection in, one connection
|
|
617
|
-
* used" in `packages/redis/__tests__/ioredis.test.mts`). There is no way to
|
|
618
|
-
* express "off here, on there" without opening a second socket, which is the
|
|
619
|
-
* connection-pool pressure the shared-socket design was built to avoid.
|
|
620
|
-
*
|
|
621
|
-
* So the choice is one setting for all eleven purposes, and `false` is the
|
|
622
|
-
* wrong one to pick for all eleven: it converts every reconnect — including
|
|
623
|
-
* the sub-second blip of a routine managed-Redis failover, during which
|
|
624
|
-
* ioredis would have recovered transparently — into hard errors on session
|
|
625
|
-
* lookup, code redemption and refresh rotation. A failed refresh rotation is
|
|
626
|
-
* not a retryable blip to the end user; it is a re-login.
|
|
627
|
-
*
|
|
628
|
-
* `true` costs the rate limiter up to `commandTimeout` of delay before it
|
|
629
|
-
* starts shedding, and that is the honest trade. It is affordable precisely
|
|
630
|
-
* because `commandTimeout` exists: the pile-up is now bounded by
|
|
631
|
-
* (request rate × 1 s) and self-draining, rather than unbounded and growing.
|
|
632
|
-
* A deployment that wants the sharper behavior gives the rate limiter its own
|
|
633
|
-
* connection in its own composition root — the per-purpose client interfaces
|
|
634
|
-
* exist for exactly that, and it is a deliberate second socket rather than a
|
|
635
|
-
* 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.
|
|
636
652
|
*/
|
|
637
653
|
const SHARED_REDIS_TIMEOUTS = {
|
|
638
654
|
commandTimeout: 1_000,
|
|
@@ -642,26 +658,12 @@ const SHARED_REDIS_TIMEOUTS = {
|
|
|
642
658
|
lazyConnect: false,
|
|
643
659
|
} as const;
|
|
644
660
|
|
|
645
|
-
// Module-scoped cache so each `provides.*` factory invocation reuses the
|
|
646
|
-
// same `Redis` instance (and its `makeIoredisClients()` derivation) within
|
|
647
|
-
// a single createApp() invocation. The boot planner calls each `provides`
|
|
648
|
-
// in dependency order; without this, every consumed slot would create its
|
|
649
|
-
// own Redis socket, defeating the unification purpose.
|
|
650
|
-
//
|
|
651
|
-
// Cache key: the `lifecycleRegistrar` IDENTITY (which is per-boot — each
|
|
652
|
-
// `createApp()` invocation seeds a fresh registrar via the boot planner).
|
|
653
|
-
// Keying solely on `config` would incorrectly share connections across
|
|
654
|
-
// boots when the same config object is reused with a new registrar (and
|
|
655
|
-
// only the FIRST boot's registrar would receive disposal — Copilot review
|
|
656
|
-
// on PR #121). When `lifecycleRegistrar` is undefined (test scenarios
|
|
657
|
-
// that don't seed it), each call creates a fresh client; tests are
|
|
658
|
-
// isolated and don't need cross-slot sharing.
|
|
659
661
|
/**
|
|
660
662
|
* Every per-purpose client off the one socket: the general bundle, plus the
|
|
661
|
-
* two the federation-grant stores need
|
|
662
|
-
*
|
|
663
|
-
*
|
|
664
|
-
*
|
|
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.
|
|
665
667
|
*/
|
|
666
668
|
type StandaloneRedisClients = ReturnType<typeof makeIoredisClients> & {
|
|
667
669
|
readonly federationGrantStoreClient: ReturnType<typeof makeIoredisFederationGrantStoreClient>;
|
|
@@ -669,9 +671,15 @@ type StandaloneRedisClients = ReturnType<typeof makeIoredisClients> & {
|
|
|
669
671
|
typeof makeIoredisFederationGrantIntentStoreClient
|
|
670
672
|
>;
|
|
671
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.
|
|
672
680
|
const clientsCache = new WeakMap<LifecycleRegistrar, StandaloneRedisClients>();
|
|
673
681
|
function getOrCreateClients(
|
|
674
|
-
|
|
682
|
+
section: { readonly url: string; readonly password?: string | undefined },
|
|
675
683
|
lifecycleRegistrar: LifecycleRegistrar | undefined,
|
|
676
684
|
readinessRegistrar?: ReadinessRegistrar,
|
|
677
685
|
injectedLogger?: Logger,
|
|
@@ -680,33 +688,33 @@ function getOrCreateClients(
|
|
|
680
688
|
const cached = lifecycleRegistrar ? clientsCache.get(lifecycleRegistrar) : undefined;
|
|
681
689
|
if (cached) return cached;
|
|
682
690
|
|
|
683
|
-
|
|
684
|
-
if (typeof cfg?.url !== "string" || cfg.url.length === 0) {
|
|
691
|
+
if (section.url.length === 0) {
|
|
685
692
|
throw new Error(
|
|
686
|
-
"standaloneRedisClientsModule: `
|
|
687
|
-
"Redis-backed adapter is selected. Set
|
|
688
|
-
"
|
|
689
|
-
"
|
|
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.",
|
|
690
697
|
);
|
|
691
698
|
}
|
|
692
|
-
const password = typeof cfg.password === "string" ? cfg.password : undefined;
|
|
693
699
|
|
|
694
|
-
const io = new Redis(
|
|
700
|
+
const io = new Redis(section.url, { password: section.password, ...SHARED_REDIS_TIMEOUTS });
|
|
695
701
|
|
|
696
702
|
// Attach an error handler so unhandled "error" events do not crash the
|
|
697
703
|
// process. Initial connection failures surface here; downstream adapter
|
|
698
|
-
// operations then fail visibly.
|
|
704
|
+
// operations then fail visibly. It logs the projection: ioredis puts the
|
|
705
|
+
// command a reply answered on the error, and for a refused handshake that
|
|
706
|
+
// is `AUTH` with the configured password.
|
|
699
707
|
io.on("error", (err: unknown) => {
|
|
700
|
-
logger.error({ err }, "standalone_redis_clients_error");
|
|
708
|
+
logger.error({ err: loggableError(err) }, "standalone_redis_clients_error");
|
|
701
709
|
});
|
|
702
710
|
|
|
703
711
|
lifecycleRegistrar?.register(async () => {
|
|
704
712
|
await io.quit();
|
|
705
713
|
});
|
|
706
714
|
|
|
707
|
-
// One probe for the one socket
|
|
708
|
-
//
|
|
709
|
-
//
|
|
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.
|
|
710
718
|
readinessRegistrar?.register({
|
|
711
719
|
name: "redis",
|
|
712
720
|
check: () => io.ping(),
|
|
@@ -725,13 +733,11 @@ function getOrCreateClients(
|
|
|
725
733
|
}
|
|
726
734
|
|
|
727
735
|
/**
|
|
728
|
-
* Reads an optional string field off a federation slice.
|
|
729
|
-
*
|
|
730
|
-
*
|
|
731
|
-
*
|
|
732
|
-
*
|
|
733
|
-
* shape the file holds, and silently ignoring `sessionDomain = 42` would leave
|
|
734
|
-
* 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
|
|
735
741
|
* wrote down.
|
|
736
742
|
*/
|
|
737
743
|
function optionalString(
|
|
@@ -741,20 +747,19 @@ function optionalString(
|
|
|
741
747
|
const value = slice[field];
|
|
742
748
|
if (value === undefined || value === null) return {};
|
|
743
749
|
if (typeof value !== "string") {
|
|
744
|
-
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`);
|
|
745
751
|
}
|
|
746
752
|
return { [field]: value };
|
|
747
753
|
}
|
|
748
754
|
|
|
749
755
|
/**
|
|
750
|
-
* An optional boolean from the `federations.google`
|
|
751
|
-
*
|
|
752
|
-
*
|
|
753
|
-
*
|
|
754
|
-
* an empty
|
|
755
|
-
*
|
|
756
|
-
*
|
|
757
|
-
* 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.
|
|
758
763
|
*/
|
|
759
764
|
function optionalBoolean(
|
|
760
765
|
slice: Record<string, unknown>,
|
|
@@ -769,36 +774,45 @@ function optionalBoolean(
|
|
|
769
774
|
if (normalized === "false" || normalized === "0") return { [field]: false };
|
|
770
775
|
}
|
|
771
776
|
throw new Error(
|
|
772
|
-
`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`,
|
|
773
778
|
);
|
|
774
779
|
}
|
|
775
780
|
|
|
776
781
|
/**
|
|
777
|
-
*
|
|
778
|
-
*
|
|
779
|
-
*
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
782
|
+
* `accessType` from the `core.federations.google` entry: `"offline"` or `"online"`,
|
|
783
|
+
* exactly, or absent. `federation-google` refuses any other value too; this
|
|
784
|
+
* refuses it first, naming the key, as the fields above do.
|
|
785
|
+
*/
|
|
786
|
+
function optionalAccessType(
|
|
787
|
+
slice: Record<string, unknown>,
|
|
788
|
+
): { accessType: "offline" | "online" } | Record<string, never> {
|
|
789
|
+
const value = slice.accessType;
|
|
790
|
+
if (value === undefined || value === null) return {};
|
|
791
|
+
if (value === "offline" || value === "online") return { accessType: value };
|
|
792
|
+
throw new Error('core.federations.google.accessType must be "offline" or "online" when present');
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
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).
|
|
783
800
|
*
|
|
784
|
-
*
|
|
785
|
-
*
|
|
786
|
-
* `
|
|
787
|
-
*
|
|
788
|
-
*
|
|
789
|
-
* that had configured them ran as though it had not. The generated app is the
|
|
790
|
-
* shape most operators start from, so the safe path has to be the one that
|
|
791
|
-
* 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.
|
|
792
806
|
*/
|
|
793
807
|
export const googleFederationConfigModule: Module = defineModule({
|
|
794
|
-
name: "
|
|
808
|
+
name: "google-federation-config",
|
|
795
809
|
requires: ["config"] as const,
|
|
796
810
|
provides: {
|
|
797
811
|
googleFederationConfig: ({ config }): GoogleProviderConfig => {
|
|
798
|
-
const slice = extractFederationSection((config
|
|
812
|
+
const slice = extractFederationSection(federationsOf(config), "google");
|
|
799
813
|
if (!slice) {
|
|
800
814
|
throw new Error(
|
|
801
|
-
"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",
|
|
802
816
|
);
|
|
803
817
|
}
|
|
804
818
|
const clientId = slice.clientId;
|
|
@@ -810,7 +824,7 @@ export const googleFederationConfigModule: Module = defineModule({
|
|
|
810
824
|
typeof callbackURL !== "string"
|
|
811
825
|
) {
|
|
812
826
|
throw new Error(
|
|
813
|
-
"federations.google requires clientId, clientSecret, callbackURL when enabled",
|
|
827
|
+
"core.federations.google requires clientId, clientSecret, callbackURL when enabled",
|
|
814
828
|
);
|
|
815
829
|
}
|
|
816
830
|
|
|
@@ -824,7 +838,7 @@ export const googleFederationConfigModule: Module = defineModule({
|
|
|
824
838
|
if (rawAllowlist !== undefined && rawAllowlist !== null) {
|
|
825
839
|
if (!Array.isArray(rawAllowlist) || rawAllowlist.some((e) => typeof e !== "string")) {
|
|
826
840
|
throw new Error(
|
|
827
|
-
"federations.google.redirectAllowlist must be a list of URL strings, " +
|
|
841
|
+
"core.federations.google.redirectAllowlist must be a list of URL strings, " +
|
|
828
842
|
'e.g. ["https://app.example.com/welcome"]',
|
|
829
843
|
);
|
|
830
844
|
}
|
|
@@ -839,27 +853,29 @@ export const googleFederationConfigModule: Module = defineModule({
|
|
|
839
853
|
...optionalString(slice, "sessionDomain"),
|
|
840
854
|
...optionalString(slice, "authCallbackUrl"),
|
|
841
855
|
...optionalString(slice, "clientUrl"),
|
|
842
|
-
//
|
|
856
|
+
// Absent means the provider's default, which is to require it.
|
|
843
857
|
...optionalBoolean(slice, "requireAuthorizationResponseIss"),
|
|
858
|
+
// Absent means the provider's default, "offline": consent on every
|
|
859
|
+
// sign-in, and a refresh token for every session.
|
|
860
|
+
...optionalAccessType(slice),
|
|
844
861
|
};
|
|
845
862
|
},
|
|
846
863
|
},
|
|
847
864
|
});
|
|
848
865
|
|
|
849
866
|
/**
|
|
850
|
-
* OIDC federation config bridge
|
|
867
|
+
* OIDC federation config bridge: supplies the `oidcFederationConfigs`
|
|
851
868
|
* slot every `oidcFederationModule(<name>)` in the manifest reads its entry
|
|
852
869
|
* from. One bridge for all instances: `readOidcFederationConfigs` walks
|
|
853
|
-
* `
|
|
854
|
-
* 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.
|
|
855
872
|
* `buildModules` lists this module only when at least one such section
|
|
856
873
|
* exists.
|
|
857
874
|
*/
|
|
858
875
|
export const oidcFederationConfigModule: Module = defineModule({
|
|
859
|
-
name: "
|
|
876
|
+
name: "oidc-federation-config",
|
|
860
877
|
requires: ["config"] as const,
|
|
861
878
|
provides: {
|
|
862
|
-
oidcFederationConfigs: ({ config }) =>
|
|
863
|
-
readOidcFederationConfigs((config as AppConfig).federations),
|
|
879
|
+
oidcFederationConfigs: ({ config }) => readOidcFederationConfigs(federationsOf(config)),
|
|
864
880
|
},
|
|
865
881
|
});
|