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