@o3co/create-auth-provider 0.17.0-rc.4 → 0.18.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.
Files changed (110) hide show
  1. package/README.md +2 -198
  2. package/main.js +4 -0
  3. package/package.json +8 -30
  4. package/README.ja.md +0 -200
  5. package/dist/cli.d.mts +0 -3
  6. package/dist/cli.d.mts.map +0 -1
  7. package/dist/cli.mjs +0 -18
  8. package/dist/index.d.mts +0 -48
  9. package/dist/index.d.mts.map +0 -1
  10. package/dist/index.mjs +0 -325
  11. package/dist/internal/template-filter.d.mts +0 -14
  12. package/dist/internal/template-filter.d.mts.map +0 -1
  13. package/dist/internal/template-filter.mjs +0 -42
  14. package/templates/standalone/.dockerignore +0 -23
  15. package/templates/standalone/.env.example +0 -152
  16. package/templates/standalone/Dockerfile +0 -125
  17. package/templates/standalone/Makefile +0 -12
  18. package/templates/standalone/README.ja.md +0 -913
  19. package/templates/standalone/README.md +0 -1537
  20. package/templates/standalone/config/application.conf +0 -351
  21. package/templates/standalone/config/clients.yaml +0 -4
  22. package/templates/standalone/config/clients.yaml.example +0 -118
  23. package/templates/standalone/config/development.conf +0 -17
  24. package/templates/standalone/config/mailpit.conf +0 -13
  25. package/templates/standalone/config/production.conf +0 -15
  26. package/templates/standalone/config/reference.conf +0 -424
  27. package/templates/standalone/docker-compose.mailpit.yml +0 -39
  28. package/templates/standalone/docker-compose.production.yml +0 -229
  29. package/templates/standalone/docker-compose.test.yml +0 -16
  30. package/templates/standalone/docker-compose.yml +0 -67
  31. package/templates/standalone/gitignore +0 -23
  32. package/templates/standalone/package.json +0 -55
  33. package/templates/standalone/src/__tests__/adapters.test.mts +0 -425
  34. package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +0 -87
  35. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +0 -1192
  36. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +0 -285
  37. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +0 -1154
  38. package/templates/standalone/src/__tests__/audit-sink.test.mts +0 -346
  39. package/templates/standalone/src/__tests__/config-notices.test.mts +0 -157
  40. package/templates/standalone/src/__tests__/configPath.test.mts +0 -69
  41. package/templates/standalone/src/__tests__/configured-mfa-mode.fixture.mts +0 -48
  42. package/templates/standalone/src/__tests__/core-federations.test.mts +0 -252
  43. package/templates/standalone/src/__tests__/core-sections.test.mts +0 -192
  44. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +0 -95
  45. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +0 -1290
  46. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +0 -784
  47. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +0 -461
  48. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +0 -141
  49. package/templates/standalone/src/__tests__/federation-types.test.mts +0 -239
  50. package/templates/standalone/src/__tests__/google-session.fixture.mts +0 -308
  51. package/templates/standalone/src/__tests__/host-routes.test.mts +0 -134
  52. package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +0 -29
  53. package/templates/standalone/src/__tests__/library-references.fixture.mts +0 -188
  54. package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +0 -216
  55. package/templates/standalone/src/__tests__/listen.test.mts +0 -121
  56. package/templates/standalone/src/__tests__/logger-projection.test.mts +0 -77
  57. package/templates/standalone/src/__tests__/logout-session-cookie.test.mts +0 -113
  58. package/templates/standalone/src/__tests__/mail-sender.test.mts +0 -134
  59. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +0 -393
  60. package/templates/standalone/src/__tests__/metrics.test.mts +0 -253
  61. package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +0 -122
  62. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +0 -127
  63. package/templates/standalone/src/__tests__/mfa-switch.test.mts +0 -883
  64. package/templates/standalone/src/__tests__/own-modules.test.mts +0 -1512
  65. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +0 -183
  66. package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +0 -214
  67. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +0 -135
  68. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +0 -214
  69. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +0 -249
  70. package/templates/standalone/src/__tests__/replica-safety.test.mts +0 -654
  71. package/templates/standalone/src/__tests__/repositories-logger.test.mts +0 -119
  72. package/templates/standalone/src/__tests__/router-error-answers.test.mts +0 -146
  73. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +0 -610
  74. package/templates/standalone/src/__tests__/sections.test.mts +0 -504
  75. package/templates/standalone/src/__tests__/session-lifecycle.test.mts +0 -215
  76. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +0 -296
  77. package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +0 -130
  78. package/templates/standalone/src/__tests__/session-requirements.test.mts +0 -382
  79. package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +0 -274
  80. package/templates/standalone/src/__tests__/shutdown.test.mts +0 -622
  81. package/templates/standalone/src/__tests__/smoke.test.mts +0 -634
  82. package/templates/standalone/src/__tests__/startup-log-lines.test.mts +0 -148
  83. package/templates/standalone/src/__tests__/store-credential.test.mts +0 -312
  84. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +0 -296
  85. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +0 -265
  86. package/templates/standalone/src/__tests__/two-phase-config.test.mts +0 -605
  87. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +0 -382
  88. package/templates/standalone/src/adapters.mts +0 -162
  89. package/templates/standalone/src/app.mts +0 -136
  90. package/templates/standalone/src/bootRefusal.mts +0 -117
  91. package/templates/standalone/src/buildModules.mts +0 -358
  92. package/templates/standalone/src/configPath.mts +0 -487
  93. package/templates/standalone/src/listen.mts +0 -60
  94. package/templates/standalone/src/logger.mts +0 -80
  95. package/templates/standalone/src/metrics.mts +0 -167
  96. package/templates/standalone/src/mfaSwitch.mts +0 -284
  97. package/templates/standalone/src/modules.mts +0 -705
  98. package/templates/standalone/src/rootRenames.mts +0 -95
  99. package/templates/standalone/src/routes.mts +0 -89
  100. package/templates/standalone/src/sections.mts +0 -377
  101. package/templates/standalone/src/shutdown.mts +0 -213
  102. package/templates/standalone/tests/Makefile +0 -5
  103. package/templates/standalone/tests/index.test.js +0 -283
  104. package/templates/standalone/tests/package-lock.json +0 -1617
  105. package/templates/standalone/tests/package.json +0 -16
  106. package/templates/standalone/tests/vitest.config.mjs +0 -26
  107. package/templates/standalone/tsconfig.json +0 -31
  108. package/templates/standalone/vitest.config.mts +0 -31
  109. package/templates/standalone/vitest.supertest-loopback.mts +0 -278
  110. package/templates/versions.json +0 -20
@@ -1,705 +0,0 @@
1
- /*
2
- * Copyright 2026 1o1 Co. Ltd.
3
- *
4
- * Licensed under the Apache License, Version 2.0 (the "License");
5
- * you may not use this file except in compliance with the License.
6
- * You may obtain a copy of the License at
7
- *
8
- * http://www.apache.org/licenses/LICENSE-2.0
9
- *
10
- * Unless required by applicable law or agreed to in writing, software
11
- * distributed under the License is distributed on an "AS IS" BASIS,
12
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
- * See the License for the specific language governing permissions and
14
- * limitations under the License.
15
- */
16
- import path from "node:path";
17
- import {
18
- consoleLogger,
19
- createAuditSinkFactory,
20
- createFederationTokenStoreFactory,
21
- createInMemorySessionLifecycleStore,
22
- createInMemorySubjectRevocation,
23
- createInMemorySubjectSessionIndex,
24
- createInMemoryUserSessionStore,
25
- createKeyStoreFactory,
26
- createRepositoryFactories,
27
- defineModule,
28
- type LifecycleRegistrar,
29
- type Logger,
30
- loggableError,
31
- type Module,
32
- type ReadinessRegistrar,
33
- registerBuiltinAuditSinks,
34
- registerBuiltinFederationTokenStores,
35
- registerBuiltinKeyStores,
36
- } from "@o3co/auth-provider-core";
37
- import { registerBuiltinAdapters } from "@o3co/auth-provider-foundation";
38
- import {
39
- makeIoredisClients,
40
- makeIoredisFederationGrantIntentStoreClient,
41
- makeIoredisFederationGrantStoreClient,
42
- } from "@o3co/auth-provider-redis/ioredis";
43
- // Named import, not default: ioredis is CJS (`module.exports = Redis`, the
44
- // class re-exported as both `default` and `Redis`), so under `module:
45
- // "nodenext"` with esModuleInterop the default import resolves to the
46
- // module-exports namespace object and `new Redis(...)` raises TS2351 "not
47
- // constructable". Verified against ioredis 6.0.0. The repo's default-import
48
- // sites live under `packages/redis/__tests__/`, which the strict tsc build
49
- // excludes.
50
- import { Redis } from "ioredis";
51
- import { createAuditLogger, createLoggerAuditSink } from "./logger.mjs";
52
- import {
53
- type Adapters,
54
- auditSinkSectionSchema,
55
- httpSectionSchema,
56
- inMemoryCodeRepositorySectionSchema,
57
- keyStoreSectionSchema,
58
- loggingSectionSchema,
59
- redisClientsSectionSchema,
60
- repositoriesSectionSchemaFor,
61
- } from "./sections.mjs";
62
-
63
- /**
64
- * Turn a `{ type, [type]: {...} }` adapter-config slice into the flat
65
- * `{ type, ...rest }` shape `AdapterFactory<T>.create()` consumes.
66
- * Composition-root concern; not a library export.
67
- */
68
- function flattenAdapterConfig(
69
- section: ({ type: string } | { provider: string }) & Record<string, unknown>,
70
- ): { type: string } & Record<string, unknown> {
71
- const selector =
72
- (section as { type?: string; provider?: string }).type ??
73
- (section as { provider?: string }).provider;
74
- if (typeof selector !== "string") {
75
- throw new TypeError("flattenAdapterConfig: section requires 'type' or 'provider' string");
76
- }
77
- const sub = section[selector];
78
- const flattenedSub =
79
- typeof sub === "object" && sub !== null && !Array.isArray(sub)
80
- ? (sub as Record<string, unknown>)
81
- : {};
82
- return { type: selector, ...flattenedSub };
83
- }
84
-
85
- /**
86
- * The template's `config/reference.conf`, resolved from this file, which sits
87
- * one directory under the template's root in `src/` and in `dist/` alike.
88
- */
89
- const TEMPLATE_REFERENCE_HREF: string = new URL("../config/reference.conf", import.meta.url).href;
90
-
91
- /**
92
- * The template's `config/reference.conf`, which every module here that owns a
93
- * section declares. A new `URL` per call: a shared one could be changed in place.
94
- */
95
- export function templateReference(): URL {
96
- return new URL(TEMPLATE_REFERENCE_HREF);
97
- }
98
-
99
- /**
100
- * Logging module: owns `logging {}`. It provides nothing: the logger is built
101
- * before boot from this section (`readLogging`) and handed in as a bootstrap
102
- * component, since the template logs while it chooses its modules.
103
- * `LOG_LEVEL` is renamed after the path, `LOGGING_LEVEL`.
104
- */
105
- export const loggingModule = defineModule({
106
- name: "logging",
107
- section: {
108
- schema: loggingSectionSchema,
109
- reference: templateReference(),
110
- renamedVariables: { LOG_LEVEL: "logging.level" },
111
- },
112
- });
113
-
114
- /** What the host process reads of `http {}`, beside what the `httpSettings` slot carries. */
115
- export interface HttpHostSettings {
116
- /** The port the server listens on; `0` lets the OS pick one. */
117
- readonly port: number;
118
- /** The per-probe deadline of `/readyz` and the metrics scrape, in milliseconds. */
119
- readonly readinessTimeoutMs: number;
120
- }
121
-
122
- declare module "@o3co/auth-provider-core" {
123
- interface ComponentMap {
124
- /** What the host process reads of `http {}`: provided by the `http` module. */
125
- readonly httpHostSettings?: HttpHostSettings;
126
- }
127
- }
128
-
129
- /**
130
- * HTTP module: owns `http {}`, its CORS list (`http.cors`) included, and
131
- * provides core's `httpSettings`, authoritative, and the host's
132
- * `httpHostSettings`, both eager: only `app.mts` reads the host's, and no
133
- * module requires it. The list moved from `cors`, and `CORS_ALLOWED_ORIGINS`
134
- * with it.
135
- */
136
- export const httpModule = defineModule({
137
- name: "http",
138
- section: {
139
- schema: httpSectionSchema,
140
- reference: templateReference(),
141
- relocatedFrom: { cors: "cors" },
142
- renamedVariables: { CORS_ALLOWED_ORIGINS: "cors.allowedOrigins" },
143
- },
144
- provides: {
145
- httpSettings: ({ section }) =>
146
- Object.freeze({
147
- trustProxy: section.trustProxy,
148
- cors: Object.freeze({ allowedOrigins: Object.freeze([...section.cors.allowedOrigins]) }),
149
- }),
150
- httpHostSettings: ({ section }): HttpHostSettings =>
151
- Object.freeze({ port: section.port, readinessTimeoutMs: section.readinessTimeoutMs }),
152
- },
153
- authoritative: ["httpSettings"],
154
- lifecycle: { httpSettings: { eager: true }, httpHostSettings: { eager: true } },
155
- });
156
-
157
- /**
158
- * KeyStore module: owns `key-store {}` and provides the JWT signing KeyStore
159
- * from it through the built-in local adapter. The section moved from
160
- * `oauth.jwt.signingKey`, and the variables bound to it are renamed after
161
- * their paths (`KEY_STORE_*`). Other deployments wire their own KeyStore
162
- * through a module of the same shape.
163
- */
164
- export const keyStoreModule: Module = defineModule({
165
- name: "key-store",
166
- section: {
167
- schema: keyStoreSectionSchema,
168
- reference: templateReference(),
169
- relocatedFrom: { "oauth.jwt.signingKey": "" },
170
- renamedVariables: {
171
- OAUTH_JWT_SIGNING_KEY_PROVIDER: "oauth.jwt.signingKey.provider",
172
- OAUTH_JWT_ALGORITHM: "oauth.jwt.signingKey.local.algorithm",
173
- OAUTH_JWT_KID: "oauth.jwt.signingKey.local.kid",
174
- OAUTH_JWT_SECRET: "oauth.jwt.signingKey.local.secret",
175
- OAUTH_JWT_PRIVATE_KEY_PATH: "oauth.jwt.signingKey.local.privateKeyPath",
176
- OAUTH_JWT_PUBLIC_KEY_PATH: "oauth.jwt.signingKey.local.publicKeyPath",
177
- OAUTH_JWT_PRIVATE_KEY: "oauth.jwt.signingKey.local.privateKey",
178
- OAUTH_JWT_PUBLIC_KEY: "oauth.jwt.signingKey.local.publicKey",
179
- },
180
- },
181
- provides: {
182
- keyStore: async ({ section }) => {
183
- const factory = createKeyStoreFactory();
184
- registerBuiltinKeyStores(factory);
185
- return factory.create(flattenAdapterConfig(section));
186
- },
187
- },
188
- });
189
-
190
- /** Which adapter fills each repository slot the `repositories` module provides. */
191
- export interface RepositorySelection {
192
- readonly client: Adapters["clientRepository"];
193
- readonly user: Adapters["userRepository"];
194
- }
195
-
196
- /**
197
- * `block`, the settings `repositories.<repository>.<adapter>` holds. The
198
- * section's schema (`repositoriesSectionSchemaFor`) has refused a selected
199
- * `static` without its block, so an absent one is a composition that built
200
- * the section otherwise: an `Error` naming its path.
201
- */
202
- function requiredBlock<B>(block: B | undefined, repository: string, adapter: string): B {
203
- if (block === undefined) {
204
- throw new Error(
205
- `repositories.${repository}.${adapter}.path must be set when adapters.${repository}Repository is "${adapter}"`,
206
- );
207
- }
208
- return block;
209
- }
210
-
211
- /**
212
- * Repositories module: owns `repositories {}` and provides the client and
213
- * user repositories the composition root's `adapters` select, through the
214
- * built-in adapter factories: the YAML client registry, and the YAML or the
215
- * Store's HTTP user repository (`@o3co/auth-provider-foundation`); core's
216
- * `static`, an alias of `yaml`, reads a block of its own. The
217
- * variables bound to its keys are renamed after their paths
218
- * (`REPOSITORIES_*`). `codeRepository` comes from the in-process or the Redis
219
- * code repository module instead.
220
- */
221
- export function repositoriesModuleFor(selection: RepositorySelection): Module {
222
- return defineModule({
223
- name: "repositories",
224
- section: {
225
- schema: repositoriesSectionSchemaFor(selection),
226
- reference: templateReference(),
227
- renamedVariables: {
228
- CLIENT_PATH: "repositories.client.yaml.path",
229
- CLIENT_USER_PATH: "repositories.user.yaml.path",
230
- CLIENT_USER_AUTHENTICATE_URL: "repositories.user.http.authenticateUrl",
231
- CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL: "repositories.user.http.authenticateByTokenUrl",
232
- CLIENT_USER_LINK_FEDERATED_IDENTITY_URL: "repositories.user.http.linkFederatedIdentityUrl",
233
- CLIENT_USER_FIND_SUBJECT_BY_FEDERATED_IDENTITY_URL:
234
- "repositories.user.http.findSubjectByFederatedIdentityUrl",
235
- CLIENT_USER_BEARER_TOKEN: "repositories.user.http.bearerToken",
236
- CLIENT_USER_TIMEOUT: "repositories.user.http.timeout",
237
- CLIENT_USER_MAX_RESPONSE_BYTES: "repositories.user.http.maxResponseBytes",
238
- },
239
- },
240
- // Forwarded into the repository factories so client/user adapters can
241
- // register disposal callbacks (file-watch closers, say). Without it, a
242
- // builder's `ctx.lifecycle?.register` is a no-op and those resources leak.
243
- // The logger is the composition's, so what a builder logs (the yaml user
244
- // repository's `user_repository_in_memory`) goes where the template's
245
- // logging goes, at its level, rather than to the console.
246
- optional: ["lifecycleRegistrar", "logger"] as const,
247
- provides: {
248
- clientRepository: async ({ section, lifecycleRegistrar }) => {
249
- const { clientFactory } = createRepositoryFactories({ lifecycle: lifecycleRegistrar });
250
- const file = requiredBlock(section.client[selection.client], "client", selection.client);
251
- return clientFactory.create({
252
- type: selection.client,
253
- path: path.resolve(process.cwd(), file.path),
254
- });
255
- },
256
- userRepository: async ({ section, lifecycleRegistrar, logger }) => {
257
- const { userFactory } = createRepositoryFactories({
258
- lifecycle: lifecycleRegistrar,
259
- logger,
260
- });
261
- registerBuiltinAdapters({ userFactory });
262
- return userFactory.create(
263
- flattenAdapterConfig({
264
- type: selection.user,
265
- [selection.user]: section.user[selection.user],
266
- }),
267
- );
268
- },
269
- },
270
- });
271
- }
272
-
273
- /**
274
- * In-memory CodeRepository module, wired by `buildModules` only when
275
- * `adapters.codeRepository = "memory"`; the Redis choice swaps in
276
- * `redisCodeRepositoryModule` from `@o3co/auth-provider-redis` (mutually
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.
280
- */
281
- export const inMemoryCodeRepositoryModule: Module = defineModule({
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.
294
- replicaSafety: {
295
- unsafe: true,
296
- reason:
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",
298
- },
299
- optional: ["lifecycleRegistrar"] as const,
300
- provides: {
301
- codeRepository: async ({ section, lifecycleRegistrar }) => {
302
- const { codeFactory } = createRepositoryFactories({ lifecycle: lifecycleRegistrar });
303
- return codeFactory.create({ type: "memory", defaultExpiresIn: section.defaultExpiresIn });
304
- },
305
- },
306
- });
307
-
308
- /**
309
- * In-memory user-session stores module: the user-session store, the session
310
- * lifecycle store and the subject-level revocation pair. Wired by
311
- * `buildModules` only when `adapters.userSessionStores = "memory"`; the Redis
312
- * branch swaps in `redisSessionStoresModule` from `@o3co/auth-provider-redis`.
313
- */
314
- export const inMemorySessionStoresModule: Module = defineModule({
315
- name: "standalone-in-memory-session-stores",
316
- replicaSafety: {
317
- unsafe: true,
318
- reason:
319
- "user sessions, the session lifecycle record 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, a close begun on one replica fences nothing on another, and a credential change enumerates and watermarks only the replica that handled it",
320
- },
321
- provides: {
322
- userSessionStore: () => createInMemoryUserSessionStore(),
323
- // The subject-level revocation slots. Without them `subjectRevocation`
324
- // is undefined: `verifyJwt` skips the watermark, the refresh gate is
325
- // inert and `revokeAllForSubject` reports `unavailable`, all silently.
326
- // The boot guard refuses that state; the scaffold wires them rather than
327
- // declaring the capability absent. Single-process only, like every
328
- // other store on this branch.
329
- subjectSessionIndex: () => createInMemorySubjectSessionIndex(),
330
- subjectRevocation: () => createInMemorySubjectRevocation(),
331
- // The session lifecycle's record, as the Redis branch provides it; read
332
- // only where core's session lifecycle module is installed.
333
- sessionLifecycleStore: () => createInMemorySessionLifecycleStore(),
334
- },
335
- });
336
-
337
- /**
338
- * In-memory federation token store module, wired by `buildModules` only when
339
- * `adapters.federationTokenStore = "memory"` (the default). The redis branch
340
- * swaps in `redisFederationTokenStoreModule` off the shared ioredis socket
341
- * (mutually exclusive: both provide the `federationTokenStore` slot). One
342
- * module per adapter, so the replica-safety guard can tell them apart and the
343
- * Redis one can require its client. The slot is always wired, independent of
344
- * the `adapters.userSessionStores` selection.
345
- *
346
- * The memory adapter comes through core's factory: that is where its
347
- * "dev/test only" boot warning lives, said on the composition's `logger`.
348
- */
349
- export const inMemoryFederationTokenStoreModule: Module = defineModule({
350
- name: "standalone-in-memory-federation-token-store",
351
- replicaSafety: {
352
- unsafe: true,
353
- reason:
354
- "upstream federation tokens fork per replica — a token stored by the replica that completed the federation callback is missing on the others, so a session cannot refresh its upstream token from another replica, and logout removes only the tokens the replica it lands on can see",
355
- },
356
- optional: ["logger"] as const,
357
- provides: {
358
- federationTokenStore: async ({ logger }) => {
359
- const factory = createFederationTokenStoreFactory();
360
- registerBuiltinFederationTokenStores(factory, logger);
361
- return factory.create({ type: "memory" });
362
- },
363
- },
364
- });
365
-
366
- /**
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 `oauthEndpointsModule`,
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`.
375
- *
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.
381
- */
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
- },
392
- },
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
- }
403
-
404
- /**
405
- * Shared ioredis clients module: opens ONE long-lived ioredis connection per
406
- * replica and derives every per-purpose client from it (`makeIoredisClients`,
407
- * plus the two federation-grant store clients from their own factories),
408
- * rather than a socket per Redis-backed module, to keep connection-pool
409
- * pressure off the upstream Redis. Every store module
410
- * `@o3co/auth-provider-redis` ships finds its client slot here, including
411
- * those this template does not select; `all-modules-composition.multi.test.mts`
412
- * pins that.
413
- *
414
- * The connection's URL and password are the module's own section,
415
- * `redis-clients`, which moved from `refreshTokenFamilyStore.redis` with its
416
- * variables (`REDIS_CLIENTS_*`). Per-store Redis instances belong in a
417
- * custom composition root. An empty URL throws rather than falling back to
418
- * localhost. `assumeNoEviction` is handed to every client, for the Redis
419
- * stores' eviction gate. `io.quit()` is registered once with `lifecycleRegistrar`, so
420
- * `handle.dispose()` closes the connection.
421
- *
422
- * `buildModules` adds this module only when some composed module needs a
423
- * Redis client (`usingRedisAnywhere`), so memory-only compositions open no
424
- * socket.
425
- */
426
- export const standaloneRedisClientsModule: Module = defineModule({
427
- name: "redis-clients",
428
- section: {
429
- schema: redisClientsSectionSchema,
430
- reference: templateReference(),
431
- relocatedFrom: { "refreshTokenFamilyStore.redis": "" },
432
- renamedVariables: {
433
- REFRESH_TOKEN_FAMILY_STORE_REDIS_URL: "refreshTokenFamilyStore.redis.url",
434
- REFRESH_TOKEN_FAMILY_STORE_REDIS_PASSWORD: "refreshTokenFamilyStore.redis.password",
435
- },
436
- },
437
- optional: ["lifecycleRegistrar", "readinessRegistrar", "logger"] as const,
438
- provides: {
439
- refreshTokenFamilyClient: async ({
440
- section,
441
- lifecycleRegistrar,
442
- readinessRegistrar,
443
- logger,
444
- }) => {
445
- return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
446
- .refreshTokenFamilyClient;
447
- },
448
- userSessionStoreClient: async ({ section, lifecycleRegistrar, readinessRegistrar, logger }) => {
449
- return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
450
- .userSessionStoreClient;
451
- },
452
- // The grant store and the intent store, consumed by the Redis grant
453
- // modules when their switches say "redis". Provided whenever this module
454
- // is installed, as every slot here is: a slot is cheap, the socket is
455
- // the cost.
456
- federationGrantStoreClient: async ({
457
- section,
458
- lifecycleRegistrar,
459
- readinessRegistrar,
460
- logger,
461
- }) => {
462
- return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
463
- .federationGrantStoreClient;
464
- },
465
- federationGrantIntentStoreClient: async ({
466
- section,
467
- lifecycleRegistrar,
468
- readinessRegistrar,
469
- logger,
470
- }) => {
471
- return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
472
- .federationGrantIntentStoreClient;
473
- },
474
- // The replay seen-set behind private_key_jwt client assertions.
475
- replaySeenSetClient: async ({ section, lifecycleRegistrar, readinessRegistrar, logger }) => {
476
- return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
477
- .replaySeenSetClient;
478
- },
479
- rateLimiterClient: async ({ section, lifecycleRegistrar, readinessRegistrar, logger }) => {
480
- return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
481
- .rateLimiterClient;
482
- },
483
- // `redisAttemptCounterModule` consumes this slot where a deployment installs it.
484
- attemptCounterClient: async ({ section, lifecycleRegistrar, readinessRegistrar, logger }) => {
485
- return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
486
- .attemptCounterClient;
487
- },
488
- // `redisCodeRepositoryModule` consumes this slot when
489
- // `adapters.codeRepository = "redis"`.
490
- codeRepositoryClient: async ({ section, lifecycleRegistrar, readinessRegistrar, logger }) => {
491
- return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
492
- .codeRepositoryClient;
493
- },
494
- // A denylist is only worth having if every replica reads the same one,
495
- // so it belongs on the connection the rest of the shared state uses
496
- // rather than a second one.
497
- accessTokenDenylistClient: async ({
498
- section,
499
- lifecycleRegistrar,
500
- readinessRegistrar,
501
- logger,
502
- }) => {
503
- return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
504
- .accessTokenDenylistClient;
505
- },
506
- // The subject-level revocation pair, both required by
507
- // `redisSessionStoresModule`: the index enumerates what a credential
508
- // change cascades over, the watermark refuses what the cascade missed.
509
- // Nothing in the standalone consumes them directly; they exist so that
510
- // module can be selected.
511
- subjectSessionIndexClient: async ({
512
- section,
513
- lifecycleRegistrar,
514
- readinessRegistrar,
515
- logger,
516
- }) => {
517
- return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
518
- .subjectSessionIndexClient;
519
- },
520
- subjectRevocationClient: async ({
521
- section,
522
- lifecycleRegistrar,
523
- readinessRegistrar,
524
- logger,
525
- }) => {
526
- return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
527
- .subjectRevocationClient;
528
- },
529
- // Required by `redisSessionStoresModule`, which builds the session
530
- // lifecycle store from it once a module reads that store.
531
- sessionLifecycleStoreClient: async ({
532
- section,
533
- lifecycleRegistrar,
534
- readinessRegistrar,
535
- logger,
536
- }) => {
537
- return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
538
- .sessionLifecycleStoreClient;
539
- },
540
- // Required by `redisDeviceCodeStoreModule`. This template does not mount
541
- // the device grant; the slot is provided anyway, so a deployment that
542
- // adds `deviceAuthorizationGrantModule` with the Redis store is not refused at boot
543
- // (`missing-required-component`) for a client slot nothing provided.
544
- deviceCodeStoreClient: async ({ section, lifecycleRegistrar, readinessRegistrar, logger }) => {
545
- return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
546
- .deviceCodeStoreClient;
547
- },
548
- // The WebAuthn challenge store's client, for the device-code slot's
549
- // reason: this template does not mount WebAuthn, and a deployment that
550
- // adds `webauthnModule` with `redisChallengeStoreModule` would otherwise
551
- // be refused at boot for a `challengeStoreClient` nothing provided.
552
- challengeStoreClient: async ({ section, lifecycleRegistrar, readinessRegistrar, logger }) => {
553
- return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
554
- .challengeStoreClient;
555
- },
556
- // The consent stores' clients. `redisConsentStoreModule` requires both
557
- // (it provides the consent records and the parked requests together),
558
- // and `buildModules` selects it under `adapters.consentStore = "redis"`.
559
- consentStoreClient: async ({ section, lifecycleRegistrar, readinessRegistrar, logger }) => {
560
- return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
561
- .consentStoreClient;
562
- },
563
- pendingConsentStoreClient: async ({
564
- section,
565
- lifecycleRegistrar,
566
- readinessRegistrar,
567
- logger,
568
- }) => {
569
- return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
570
- .pendingConsentStoreClient;
571
- },
572
- // Required by `redisFederationTokenStoreModule`.
573
- federationTokenStoreClient: async ({
574
- section,
575
- lifecycleRegistrar,
576
- readinessRegistrar,
577
- logger,
578
- }) => {
579
- return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
580
- .federationTokenStoreClient;
581
- },
582
- // The two MFA stores' clients, required by `redisMfaFactorStoreModule`
583
- // and `redisMfaTransactionStoreModule`, which `buildModules` selects
584
- // with MFA on and `adapters.mfaFactorStore` / `.mfaTransactionStore`
585
- // = "redis". Each module checks the server's eviction policy and
586
- // persistence when it boots.
587
- mfaFactorStoreClient: async ({ section, lifecycleRegistrar, readinessRegistrar, logger }) => {
588
- return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
589
- .mfaFactorStoreClient;
590
- },
591
- mfaTransactionStoreClient: async ({
592
- section,
593
- lifecycleRegistrar,
594
- readinessRegistrar,
595
- logger,
596
- }) => {
597
- return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
598
- .mfaTransactionStoreClient;
599
- },
600
- },
601
- });
602
-
603
- /**
604
- * Failure-timing options for the one shared ioredis socket. Each value's
605
- * reason, and the trade the offline queue buys, is in
606
- * `docs/operator-runbook.md`, "Failure timing on the shared socket". In
607
- * short: on the driver's defaults a partition produces *waiting*, not errors,
608
- * so the Redis limiter's `redis-rate-limiter.failMode = "closed"` policy, which the
609
- * guard (`createRateLimitGuard`) applies only when `limiter.check()` rejects,
610
- * never engages.
611
- * `commandTimeout` bounds every command, queued or on the wire, zombie socket
612
- * included; `maxRetriesPerRequest` bounds the offline queue's depth; the
613
- * queue stays on because the option is per connection and this socket also
614
- * carries sessions, codes and refresh rotation.
615
- *
616
- * One second is ample: the socket's commands are O(1) primitives and small
617
- * paged scans (`SET`/`GET`/`DEL`/`EXISTS`/`PTTL`/`INCR`/`EVAL`/`HSCAN`/
618
- * `SSCAN`/`ZADD`/…), none blocking. `lazyConnect: false` is the driver
619
- * default, declared so the boot-time-connect contract survives a future flip.
620
- */
621
- const SHARED_REDIS_TIMEOUTS = {
622
- commandTimeout: 1_000,
623
- connectTimeout: 5_000,
624
- maxRetriesPerRequest: 3,
625
- enableOfflineQueue: true,
626
- lazyConnect: false,
627
- } as const;
628
-
629
- /**
630
- * Every per-purpose client off the one socket: the general bundle, plus the
631
- * two the federation-grant stores need. Those two have their own factories in
632
- * the Redis package, so a Cluster deployment can give the grants a
633
- * connection of their own; this template shares the socket, as it does for
634
- * every other store.
635
- */
636
- type StandaloneRedisClients = ReturnType<typeof makeIoredisClients> & {
637
- readonly federationGrantStoreClient: ReturnType<typeof makeIoredisFederationGrantStoreClient>;
638
- readonly federationGrantIntentStoreClient: ReturnType<
639
- typeof makeIoredisFederationGrantIntentStoreClient
640
- >;
641
- };
642
- // So every `provides.*` factory reuses one `Redis` instance (and its clients)
643
- // within a createApp() call, rather than opening a socket per consumed slot.
644
- // Keyed on the `lifecycleRegistrar` identity, fresh per boot: keying on
645
- // `config` would share connections across boots that reuse a config object,
646
- // and only the first boot's registrar would dispose them. Without a
647
- // registrar (tests that seed none), each call creates a fresh client.
648
- const clientsCache = new WeakMap<LifecycleRegistrar, StandaloneRedisClients>();
649
- function getOrCreateClients(
650
- section: {
651
- readonly url: string;
652
- readonly password?: string | undefined;
653
- readonly assumeNoEviction: boolean;
654
- },
655
- lifecycleRegistrar: LifecycleRegistrar | undefined,
656
- readinessRegistrar?: ReadinessRegistrar,
657
- injectedLogger?: Logger,
658
- ): StandaloneRedisClients {
659
- const logger = injectedLogger ?? consoleLogger;
660
- const cached = lifecycleRegistrar ? clientsCache.get(lifecycleRegistrar) : undefined;
661
- if (cached) return cached;
662
-
663
- if (section.url.length === 0) {
664
- throw new Error(
665
- "standaloneRedisClientsModule: `redis-clients.url` is required when any " +
666
- "Redis-backed adapter is selected. Set REDIS_CLIENTS_URL, or the " +
667
- "key in a configuration layer, to a non-empty URL. Multi-replica deployments require " +
668
- "a shared Redis 7.2+ instance.",
669
- );
670
- }
671
-
672
- const io = new Redis(section.url, { password: section.password, ...SHARED_REDIS_TIMEOUTS });
673
-
674
- // Attach an error handler so unhandled "error" events do not crash the
675
- // process. Initial connection failures surface here; downstream adapter
676
- // operations then fail visibly. It logs the projection: ioredis puts the
677
- // command a reply answered on the error, and for a refused handshake that
678
- // is `AUTH` with the configured password.
679
- io.on("error", (err: unknown) => {
680
- logger.error({ err: loggableError(err) }, "standalone_redis_clients_error");
681
- });
682
-
683
- lifecycleRegistrar?.register(async () => {
684
- await io.quit();
685
- });
686
-
687
- // One probe for the one socket, not one per consumed slot: the cache
688
- // above means this runs once per boot, on the call that constructs the
689
- // client.
690
- readinessRegistrar?.register({
691
- name: "redis",
692
- check: () => io.ping(),
693
- });
694
-
695
- // The wrapper opens its own connections for refresh rotation
696
- // (`refreshTokenFamilyClient.duplicate()`), which inherit no listeners from
697
- // `io`; passing the logger lets those report through the same channel.
698
- const clients: StandaloneRedisClients = {
699
- ...makeIoredisClients(io, { logger, assumeNoEviction: section.assumeNoEviction }),
700
- federationGrantStoreClient: makeIoredisFederationGrantStoreClient(io),
701
- federationGrantIntentStoreClient: makeIoredisFederationGrantIntentStoreClient(io),
702
- };
703
- if (lifecycleRegistrar) clientsCache.set(lifecycleRegistrar, clients);
704
- return clients;
705
- }