@o3co/create-auth-provider 0.15.0 → 0.16.0

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 (41) hide show
  1. package/README.ja.md +93 -47
  2. package/README.md +92 -46
  3. package/package.json +1 -1
  4. package/templates/standalone/.env.example +12 -0
  5. package/templates/standalone/Dockerfile +1 -1
  6. package/templates/standalone/README.ja.md +516 -154
  7. package/templates/standalone/README.md +218 -91
  8. package/templates/standalone/config/application.conf +26 -3
  9. package/templates/standalone/gitignore +3 -2
  10. package/templates/standalone/package.json +1 -1
  11. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +49 -15
  12. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +1103 -0
  13. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +241 -0
  14. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +1100 -0
  15. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +19 -5
  16. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +64 -3
  17. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +15 -0
  18. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +133 -0
  19. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +28 -0
  20. package/templates/standalone/src/__tests__/google-session.fixture.mts +304 -0
  21. package/templates/standalone/src/__tests__/host-routes.test.mts +141 -0
  22. package/templates/standalone/src/__tests__/listen.test.mts +128 -0
  23. package/templates/standalone/src/__tests__/logger-projection.test.mts +77 -0
  24. package/templates/standalone/src/__tests__/metrics.test.mts +20 -5
  25. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +187 -0
  26. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +139 -0
  27. package/templates/standalone/src/__tests__/replica-safety.test.mts +13 -3
  28. package/templates/standalone/src/__tests__/router-error-answers.test.mts +149 -0
  29. package/templates/standalone/src/__tests__/shutdown.test.mts +157 -20
  30. package/templates/standalone/src/__tests__/smoke.test.mts +39 -20
  31. package/templates/standalone/src/__tests__/store-credential.test.mts +276 -0
  32. package/templates/standalone/src/app.mts +21 -50
  33. package/templates/standalone/src/buildModules.mts +33 -16
  34. package/templates/standalone/src/listen.mts +66 -0
  35. package/templates/standalone/src/logger.mts +7 -4
  36. package/templates/standalone/src/modules.mts +42 -8
  37. package/templates/standalone/src/routes.mts +95 -0
  38. package/templates/standalone/src/shutdown.mts +20 -14
  39. package/templates/versions.json +15 -15
  40. package/templates/standalone/src/__tests__/terminal-error.test.mts +0 -96
  41. package/templates/standalone/src/terminalError.mts +0 -60
@@ -0,0 +1,276 @@
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
+
17
+ /**
18
+ * The credential auth.provider presents to the Store, through the shipped
19
+ * composition: `CLIENT_USER_BEARER_TOKEN` in the environment → the HOCON
20
+ * layers → `AppConfigSchema` → the production `repositoriesModule` → the
21
+ * `"http"` user adapter → the `Authorization` header a real `node:http` Store
22
+ * receives. Unset, the Store receives no `Authorization` header; set blank —
23
+ * an exported-but-empty variable — the user repository is refused.
24
+ *
25
+ * And what a mismatch looks like from outside, with the app booted from the
26
+ * shipped config through `createApp`: a Store that refuses the token with a
27
+ * `401` and a `Bearer` challenge turns `POST /session/login` into a `503`
28
+ * whose log line names the refused credential and never the token; a `401`
29
+ * without the challenge is still a wrong password.
30
+ */
31
+
32
+ import { createServer, type OutgoingHttpHeaders, type Server } from "node:http";
33
+ import { fileURLToPath } from "node:url";
34
+ import { inspect } from "node:util";
35
+ import {
36
+ type AppConfig,
37
+ AppConfigSchema,
38
+ createApp,
39
+ createKeyStoreFactory,
40
+ defineModule,
41
+ type Logger,
42
+ memoryRefreshTokenFamilyStoreModule,
43
+ registerBuiltinKeyStores,
44
+ type UserRepository,
45
+ } from "@o3co/auth-provider-core";
46
+ import { parseFile } from "@o3co/ts.hocon";
47
+ import { validate } from "@o3co/ts.hocon/zod";
48
+ import express from "express";
49
+ import request from "supertest";
50
+ import { afterEach, describe, expect, it } from "vitest";
51
+ import { buildModules } from "../buildModules.mjs";
52
+ import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "../configPath.mjs";
53
+ import { repositoriesModule } from "../modules.mjs";
54
+
55
+ const configDir = fileURLToPath(new URL("../../config", import.meta.url));
56
+
57
+ /** 32 bytes of key material — what `openssl rand -hex 32` prints. */
58
+ const TOKEN = "7d1f0c3b9a5e2d4f6a8c0e1b3d5f7a9c2e4b6d8f0a1c3e5b7d9f2a4c6e8b0d1f";
59
+
60
+ let servers: Server[] = [];
61
+ afterEach(async () => {
62
+ await Promise.all(
63
+ servers.map((server) => {
64
+ server.closeAllConnections();
65
+ return new Promise<void>((resolve) => server.close(() => resolve()));
66
+ }),
67
+ );
68
+ servers = [];
69
+ });
70
+
71
+ /** How the Store answers every request. */
72
+ interface StoreAnswer {
73
+ readonly status: number;
74
+ readonly headers?: OutgoingHttpHeaders;
75
+ readonly body: unknown;
76
+ }
77
+
78
+ const USER: StoreAnswer = { status: 200, body: { id: "user-1", username: "alice" } };
79
+
80
+ /** A Store on its own loopback port that records each request's Authorization header. */
81
+ const recordingStore = async (
82
+ answer: StoreAnswer = USER,
83
+ ): Promise<{ origin: string; heard: (string | undefined)[] }> => {
84
+ const heard: (string | undefined)[] = [];
85
+ const server = createServer((req, res) => {
86
+ req.resume();
87
+ req.on("end", () => {
88
+ heard.push(req.headers.authorization);
89
+ res.writeHead(answer.status, { "Content-Type": "application/json", ...answer.headers });
90
+ res.end(JSON.stringify(answer.body));
91
+ });
92
+ });
93
+ servers.push(server);
94
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
95
+ const { port } = server.address() as { port: number };
96
+ return { origin: `http://127.0.0.1:${port}`, heard };
97
+ };
98
+
99
+ /** The shipped config for the production overlay, resolved against `env`. */
100
+ const resolve = (env: Record<string, string>): AppConfig => {
101
+ const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
102
+ return validate(
103
+ parseFile(envConfPath, { env })
104
+ .withFallback(parseFile(applicationConfPath, { env }))
105
+ .withFallback(parseFile(resolveLibraryReferenceConfPath(), { env })),
106
+ AppConfigSchema,
107
+ );
108
+ };
109
+
110
+ /** The user repository the production `repositoriesModule` builds from `config`. */
111
+ const userRepositoryFrom = (config: AppConfig): Promise<UserRepository> =>
112
+ Promise.resolve(
113
+ repositoriesModule.provides?.userRepository?.({ config } as never) as
114
+ | UserRepository
115
+ | Promise<UserRepository>,
116
+ );
117
+
118
+ const envFor = (origin: string): Record<string, string> => ({
119
+ OAUTH_JWT_ALGORITHM: "HS256",
120
+ OAUTH_JWT_SECRET: "store-credential-composition.at-least-32-bytes.ok",
121
+ OAUTH_JWT_ISSUER: "https://auth.test",
122
+ SESSION_SECRET: "store-credential-composition-session.at-least-32-bytes.ok",
123
+ CLIENT_USER_TYPE: "http",
124
+ CLIENT_USER_AUTHENTICATE_URL: `${origin}/authenticate`,
125
+ CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL: `${origin}/authenticate-by-token`,
126
+ });
127
+
128
+ describe("CLIENT_USER_BEARER_TOKEN reaches the Store through the shipped composition", () => {
129
+ it("sends Authorization: Bearer <token> on every Store call when the variable is set", async () => {
130
+ const { origin, heard } = await recordingStore();
131
+ const repo = await userRepositoryFrom(
132
+ resolve({ ...envFor(origin), CLIENT_USER_BEARER_TOKEN: TOKEN }),
133
+ );
134
+
135
+ await repo.authenticate("alice", "pass");
136
+ await repo.authenticateByToken("apple:a1");
137
+
138
+ expect(heard).toEqual([`Bearer ${TOKEN}`, `Bearer ${TOKEN}`]);
139
+ });
140
+
141
+ it("sends no Authorization header when the variable is unset", async () => {
142
+ const { origin, heard } = await recordingStore();
143
+ const repo = await userRepositoryFrom(resolve(envFor(origin)));
144
+
145
+ await repo.authenticate("alice", "pass");
146
+
147
+ expect(heard).toEqual([undefined]);
148
+ });
149
+
150
+ it("refuses the user repository when the variable is exported but empty", async () => {
151
+ const { origin, heard } = await recordingStore();
152
+ const config = resolve({ ...envFor(origin), CLIENT_USER_BEARER_TOKEN: "" });
153
+
154
+ await expect(userRepositoryFrom(config)).rejects.toThrow(/"bearerToken" must not be empty/);
155
+ expect(heard).toEqual([]);
156
+ });
157
+ });
158
+
159
+ describe("a token the Store refuses, seen from outside the booted app", () => {
160
+ /** Every store on memory, so the boot opens no socket but the Store's. */
161
+ const MEMORY_ENV: Readonly<Record<string, string>> = {
162
+ SESSION_SECURE: "false",
163
+ SESSION_NAME: "auth.session",
164
+ SESSION_STORAGE_TYPE: "memory",
165
+ USER_SESSION_STORES_ADAPTER: "memory",
166
+ RATE_LIMITER_ADAPTER: "memory",
167
+ OAUTH_CODE_ADAPTER: "memory",
168
+ ACCESS_TOKEN_DENYLIST_ADAPTER: "memory",
169
+ REPLAY_SEEN_SET_ADAPTER: "memory",
170
+ FEDERATION_TOKEN_STORE_TYPE: "memory",
171
+ CONSENT_STORE_ADAPTER: "none",
172
+ FEDERATION_GRANT_STORE_ADAPTER: "memory",
173
+ FEDERATION_GRANT_INTENT_STORE_ADAPTER: "memory",
174
+ };
175
+
176
+ const testKeyStoreModule = defineModule({
177
+ name: "test:key-store",
178
+ requires: ["config"] as const,
179
+ provides: {
180
+ keyStore: async ({ config: c }) => {
181
+ const factory = createKeyStoreFactory();
182
+ registerBuiltinKeyStores(factory);
183
+ return factory.create({
184
+ type: "local",
185
+ ...((c as AppConfig).oauth.jwt.signingKey.local ?? {}),
186
+ });
187
+ },
188
+ },
189
+ });
190
+
191
+ /** Every log call, as the arguments it was made with. */
192
+ const capturingLogger = (): { logger: Logger; lines: { level: string; args: unknown[] }[] } => {
193
+ const lines: { level: string; args: unknown[] }[] = [];
194
+ const at =
195
+ (level: string) =>
196
+ (...args: unknown[]): void => {
197
+ lines.push({ level, args });
198
+ };
199
+ const logger = {
200
+ trace: at("trace"),
201
+ debug: at("debug"),
202
+ info: at("info"),
203
+ warn: at("warn"),
204
+ error: at("error"),
205
+ fatal: at("fatal"),
206
+ child: () => logger,
207
+ } as Logger;
208
+ return { logger, lines };
209
+ };
210
+
211
+ let handleRef: Awaited<ReturnType<typeof createApp>> | undefined;
212
+ afterEach(async () => {
213
+ await handleRef?.dispose();
214
+ handleRef = undefined;
215
+ });
216
+
217
+ /**
218
+ * The shipped config against a Store answering `answer`, booted with the
219
+ * production repositories module; then a real browser login — a CSRF pair,
220
+ * then the form post.
221
+ */
222
+ const logInAgainst = async (answer: StoreAnswer) => {
223
+ const { origin, heard } = await recordingStore(answer);
224
+ const config = resolve({ ...envFor(origin), ...MEMORY_ENV, CLIENT_USER_BEARER_TOKEN: TOKEN });
225
+ const { logger, lines } = capturingLogger();
226
+ handleRef = await createApp({
227
+ modules: buildModules(config, {
228
+ keyStoreModule: testKeyStoreModule,
229
+ refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
230
+ }),
231
+ bootstrapComponents: { config, pathResolver: (s: string) => s, logger },
232
+ });
233
+ const app = express().use(handleRef.router);
234
+
235
+ const csrf = await request(app).get("/session/csrf");
236
+ expect(csrf.status).toBe(200);
237
+ const login = await request(app)
238
+ .post("/session/login")
239
+ .set("Cookie", csrf.headers["set-cookie"] as unknown as string[])
240
+ .set(csrf.body.header_name as string, csrf.body.csrf_token as string)
241
+ .type("form")
242
+ .send({ username: "alice", password: "correct-horse-battery-staple" });
243
+ const logged = lines.map((line) =>
244
+ inspect(line, { depth: Number.POSITIVE_INFINITY, showHidden: true }),
245
+ );
246
+ return { login, heard, logged };
247
+ };
248
+
249
+ it("answers the login 503 and logs the refused credential by name, never the token", async () => {
250
+ const { login, heard, logged } = await logInAgainst({
251
+ status: 401,
252
+ headers: { "WWW-Authenticate": 'Bearer error="invalid_token"' },
253
+ body: { error: "invalid_token" },
254
+ });
255
+
256
+ expect(heard).toEqual([`Bearer ${TOKEN}`]);
257
+ expect(login.status).toBe(503);
258
+ expect(login.body.error).toBe("temporarily_unavailable");
259
+ expect(logged.filter((line) => /refused this deployment's credential/.test(line))).toHaveLength(
260
+ 1,
261
+ );
262
+ expect(logged.filter((line) => line.includes(TOKEN))).toEqual([]);
263
+ });
264
+
265
+ it("still answers a 401 without the challenge as a wrong password", async () => {
266
+ const { login, heard, logged } = await logInAgainst({
267
+ status: 401,
268
+ body: { error: "invalid_credentials" },
269
+ });
270
+
271
+ expect(heard).toEqual([`Bearer ${TOKEN}`]);
272
+ expect(login.status).toBe(401);
273
+ expect(login.body.error).toBe("invalid_credentials");
274
+ expect(logged.filter((line) => /refused this deployment's credential/.test(line))).toEqual([]);
275
+ });
276
+ });
@@ -14,23 +14,18 @@
14
14
  * limitations under the License.
15
15
  */
16
16
  import { fileURLToPath } from "node:url";
17
- import {
18
- type AppConfig,
19
- AppConfigSchema,
20
- createApp,
21
- createHealthcheckRouter,
22
- createReadinessRouter,
23
- } from "@o3co/auth-provider-core";
17
+ import { type AppConfig, AppConfigSchema, createApp } from "@o3co/auth-provider-core";
24
18
  import { parseFile } from "@o3co/ts.hocon";
25
19
  import { validate } from "@o3co/ts.hocon/zod";
26
20
  import express from "express";
27
21
  import helmet from "helmet";
28
22
  import { buildModules } from "./buildModules.mjs";
29
23
  import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "./configPath.mjs";
24
+ import { listen } from "./listen.mjs";
30
25
  import { createAppLogger } from "./logger.mjs";
31
26
  import { createMetrics } from "./metrics.mjs";
27
+ import { mountRoutes } from "./routes.mjs";
32
28
  import { cleanupAllowanceFor, installGracefulShutdown } from "./shutdown.mjs";
33
- import { createTerminalErrorHandler } from "./terminalError.mjs";
34
29
 
35
30
  // Step 1: Load and validate application config (HOCON → Zod schema).
36
31
  // ENV = CONFIG_ENV || NODE_ENV || "development"; missing {ENV}.conf is fatal.
@@ -103,8 +98,10 @@ await (async (): Promise<void> => {
103
98
  // `environment` is the name the config above was selected by (#473): the
104
99
  // Redis federation-token store's plaintext guard reads it in addition to
105
100
  // NODE_ENV, so `CONFIG_ENV=production` is production to the guard too.
101
+ // `logger` is where the composition's own notices go (a deprecated config
102
+ // key), the same logger every module gets through the slot below.
106
103
  const handle = await createApp({
107
- modules: buildModules(config, { environment: env }),
104
+ modules: buildModules(config, { environment: env, logger }),
108
105
  bootstrapComponents: {
109
106
  config,
110
107
  pathResolver: import.meta.resolve,
@@ -112,48 +109,22 @@ await (async (): Promise<void> => {
112
109
  },
113
110
  });
114
111
 
115
- // Step 4: Wire host-level routes before the auth router so they remain
116
- // reachable even when the auth pipeline is degraded — which is exactly when
117
- // an operator needs an answer from them.
118
- //
119
- // Liveness: the process is up and its event loop is turning. Deliberately
120
- // static — restarting the process would not bring Redis back, so a Redis
121
- // outage must not read as "this container is broken, kill it".
122
- app.use(createHealthcheckRouter(express));
123
-
124
- // Readiness: can this replica serve right now? Redis backs sessions,
125
- // authorization codes and refresh-token families in the deployable
126
- // defaults, so a replica that has lost it answers 503 here and should be
127
- // taken out of rotation. Probes are contributed by the builders that own
128
- // each connection; a memory-only deployment registers none and is always
129
- // ready.
130
- app.use(
131
- createReadinessRouter(express, {
132
- probes: handle.readinessProbes,
133
- timeoutMs: config.http.readinessTimeoutMs,
134
- logger,
135
- }),
136
- );
137
-
138
- // Prometheus scrape endpoint. Same reasoning as the probes above: it has to
139
- // keep answering while the auth pipeline is degraded, because that is when
140
- // the series matter.
141
- app.use(
142
- metrics.route(express, {
143
- probes: handle.readinessProbes,
144
- probeTimeoutMs: config.http.readinessTimeoutMs,
145
- }),
146
- );
147
-
148
- // Step 5: Mount the composed auth router and start the HTTP server.
149
- app.use(handle.router);
150
- // Terminal error handler LAST (#293 item 8): Express routes an error only
151
- // to handlers registered after the route that threw it. See
152
- // `terminalError.mts` for what it catches and why.
153
- app.use(createTerminalErrorHandler(logger));
154
- const server = app.listen(config.http.port, (): void => {
155
- logger.info(`Server is running on http://localhost:${config.http.port}`);
112
+ // Step 4: the host-level routes (liveness, readiness, metrics) ahead of the
113
+ // auth router so they remain reachable even when the auth pipeline is
114
+ // degraded — which is exactly when an operator needs an answer from them —
115
+ // then the composed auth router, then the terminal error handler. See
116
+ // `routes.mts` for what each is and why the order is what it is.
117
+ mountRoutes(app, {
118
+ router: handle.router,
119
+ probes: handle.readinessProbes,
120
+ readinessTimeoutMs: config.http.readinessTimeoutMs,
121
+ metrics,
122
+ logger,
156
123
  });
124
+ // Step 5: start the HTTP server.
125
+ // Resolves once the socket is bound (`server_listening`); a port that
126
+ // cannot be bound rejects, and boot fails with that error (`listen.mts`).
127
+ const server = await listen(app, config.http.port, logger);
157
128
 
158
129
  // Step 6: Graceful shutdown — handle.dispose() runs reverse-topological
159
130
  // per-component cleanup (per A2-β §8.1) plus D-5 LifecycleRegistrar drain
@@ -15,9 +15,11 @@
15
15
  */
16
16
  import {
17
17
  type AppConfig,
18
+ consoleLogger,
18
19
  defaultRefreshTokenFamilyRevocationModule,
19
20
  defaultRefreshTokenFamilyRotationModule,
20
21
  jwksModule,
22
+ type Logger,
21
23
  type Module,
22
24
  memoryAccessTokenDenylistModule,
23
25
  memoryConsentStoreModule,
@@ -65,10 +67,10 @@ import {
65
67
  } from "./modules.mjs";
66
68
 
67
69
  /**
68
- * Overrides for the composition. All but `environment` are test-only: they let
69
- * the smoke test substitute in-memory implementations of the file-system-backed
70
- * modules, and production callers should not pass them — the defaults match the
71
- * standalone scaffold.
70
+ * Overrides for the composition. All but `environment` and `logger` are
71
+ * test-only: they let the smoke test substitute in-memory implementations of
72
+ * the file-system-backed modules, and production callers should not pass them
73
+ * — the defaults match the standalone scaffold.
72
74
  */
73
75
  export interface BuildModulesOverrides {
74
76
  /**
@@ -80,6 +82,12 @@ export interface BuildModulesOverrides {
80
82
  * off the config either way).
81
83
  */
82
84
  readonly environment?: string;
85
+ /**
86
+ * Where the composition's own notices go — a deprecated config key, one
87
+ * `config_key_deprecated` line (warn) each. `app.mts` passes the logger it
88
+ * hands every module; omitted, `consoleLogger`.
89
+ */
90
+ readonly logger?: Logger;
83
91
  readonly keyStoreModule?: Module;
84
92
  readonly repositoriesModule?: Module;
85
93
  readonly storesModule?: Module;
@@ -147,6 +155,7 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
147
155
  const googleEnabled =
148
156
  extractFederationSection(config.federations ?? {}, "google")?.type === "google";
149
157
  const oidcFederations = oidcFederationNames(config.federations ?? {});
158
+ const logger = overrides.logger ?? consoleLogger;
150
159
 
151
160
  // Wave 5d (IH-14 + OR-M1 + OR-4) + OR-9: adapter-driven branching for
152
161
  // the OAuth-endpoint rate limiter, the user-session-store family, AND
@@ -208,9 +217,14 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
208
217
  if (oauthCodeAdapter !== undefined) {
209
218
  codeRepositoryAdapter = oauthCodeAdapter;
210
219
  } else if (legacyCodeType === "redis") {
211
- console.warn(
212
- '[buildModules] `repositories.code.type = "redis"` is deprecated; use ' +
213
- '`oauth.code.adapter = "redis"` instead — see CHANGELOG for the removal version.',
220
+ logger.warn(
221
+ {
222
+ key: "repositories.code.type",
223
+ env: "CLIENT_CODE_TYPE",
224
+ replacement: "oauth.code.adapter",
225
+ replacementEnv: "OAUTH_CODE_ADAPTER",
226
+ },
227
+ "config_key_deprecated",
214
228
  );
215
229
  codeRepositoryAdapter = "redis";
216
230
  } else {
@@ -230,10 +244,14 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
230
244
  accessToken.defaultExpiresIn === undefined &&
231
245
  accessToken.expiresIn !== SHIPPED_ACCESS_TOKEN_EXPIRES_IN
232
246
  ) {
233
- console.warn(
234
- "[buildModules] `oauth.accessToken.expiresIn` (OAUTH_ACCESS_TOKEN_EXPIRES_IN) is " +
235
- "deprecated; use `oauth.accessToken.defaultExpiresIn` " +
236
- "(OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN) instead — see CHANGELOG for the removal version.",
247
+ logger.warn(
248
+ {
249
+ key: "oauth.accessToken.expiresIn",
250
+ env: "OAUTH_ACCESS_TOKEN_EXPIRES_IN",
251
+ replacement: "oauth.accessToken.defaultExpiresIn",
252
+ replacementEnv: "OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN",
253
+ },
254
+ "config_key_deprecated",
237
255
  );
238
256
  }
239
257
 
@@ -359,11 +377,10 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
359
377
  // `session.storage.type = "memory"` declares itself replica-unsafe and
360
378
  // `deployment.mode = "multi"` refuses it by name like the other stores.
361
379
  sessionStoreModuleFor(config),
362
- // #593 slice 7: before `oauthModule`, whose router mounts under the same
363
- // `/oauth` prefix with body parsers of its own — mounted after it, a
364
- // malformed body would be refused by the OAuth router's parser without
365
- // this package's request id, `Cache-Control: no-store` or throttle (the
366
- // package README's mounting-order rule). The browser half sits after the
380
+ // #593 slice 7: the grants routes mount under `/oauth` beside
381
+ // `oauthModule`'s router, which parses the bodies of its own routes
382
+ // only, so each parses its own requests and their place relative to
383
+ // `oauthModule` does not matter. The browser half sits after the
367
384
  // session middleware by its own `after`, wherever it is listed.
368
385
  ...(federationGrantsEnabled ? federationGrantsModules : []),
369
386
  oauthModule({ config }),
@@ -0,0 +1,66 @@
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 { createServer, type Server } from "node:http";
17
+ import type { AddressInfo } from "node:net";
18
+ import { type Logger, loggableError } from "@o3co/auth-provider-core";
19
+ import type { Express } from "express";
20
+
21
+ /**
22
+ * Start the HTTP listener, and say so once it is bound.
23
+ *
24
+ * Resolves with the server once its socket is bound, after logging
25
+ * `server_listening` (info) with the port the socket holds — the configured
26
+ * one, or the one the OS picked for `0`. Rejects with the server's error when
27
+ * the socket cannot be bound (`EADDRINUSE`, `EACCES`), and logs nothing: the
28
+ * composition root awaits this, so the error ends boot the way any other boot
29
+ * failure does.
30
+ *
31
+ * The server is built here (`http.createServer(app)`, as `app.listen` itself
32
+ * does) and its events are wired here, so none of this rests on how a given
33
+ * Express version wires them. Until the socket is bound, one `error` listener
34
+ * turns a bind failure into the rejection. Once it is bound, that listener is
35
+ * removed and the server gets the one it keeps: a later `error` — an `accept`
36
+ * that fails with EMFILE — is logged as `server_error` (error, `err`: core's
37
+ * `loggableError` projection) and the process keeps running, the server
38
+ * accepting what it can. Without a listener of its own, such an error would be
39
+ * thrown out of the process.
40
+ *
41
+ * The standalone used to call `app.listen` with a callback that ignored its
42
+ * argument: Express 5 hands that callback the bind error too, so it announced
43
+ * a server on a port another process held and swallowed the error that should
44
+ * have ended the process.
45
+ */
46
+ export function listen(app: Express, port: number, logger: Logger): Promise<Server> {
47
+ const server = createServer(app);
48
+ return new Promise((resolve, reject) => {
49
+ const refuse = (err: Error): void => {
50
+ server.off("listening", bound);
51
+ reject(err);
52
+ };
53
+ const bound = (): void => {
54
+ server.off("error", refuse);
55
+ server.on("error", (serverErr: Error) => {
56
+ logger.error({ err: loggableError(serverErr) }, "server_error");
57
+ });
58
+ const address = server.address() as AddressInfo | null;
59
+ logger.info({ port: address?.port ?? port }, "server_listening");
60
+ resolve(server);
61
+ };
62
+ server.once("error", refuse);
63
+ server.once("listening", bound);
64
+ server.listen(port);
65
+ });
66
+ }
@@ -44,10 +44,13 @@ export function createAppLogger(config: AppConfig): Logger {
44
44
  return pino({
45
45
  name: "provider",
46
46
  level: config.logging.level,
47
- // `err` is pino's conventional key for an Error, and every structured
48
- // event in this stack uses it — `logger.error({ err }, "…_error")`.
49
- // Without the serialiser an Error stringifies to `{}` and the stack is
50
- // lost exactly where it is needed.
47
+ // `err` is pino's conventional key for an error, and every structured
48
+ // event in this stack uses it — `logger.error({ err: loggableError(err) },
49
+ // "…_error")`. Core's projection is plain data with no `message`, so
50
+ // this serialiser hands it through as it is, every cause's fields
51
+ // included. It is here for an Error handed to it as it is, which would
52
+ // otherwise stringify to `{}`, the stack lost exactly where it is
53
+ // needed.
51
54
  serializers: { err: stdSerializers.err },
52
55
  });
53
56
  }
@@ -30,6 +30,7 @@ import {
30
30
  defineModule,
31
31
  type LifecycleRegistrar,
32
32
  type Logger,
33
+ loggableError,
33
34
  type Module,
34
35
  type ReadinessRegistrar,
35
36
  registerBuiltinAuditSinks,
@@ -370,7 +371,11 @@ export const storesModule: Module = defineModule({
370
371
  * Redis-backed adapters (refresh-token-family + 4 user-session stores +
371
372
  * subject-level revocation index + watermark + rate limiter + code
372
373
  * repository + access-token denylist + replay seen-set + federation token
373
- * store + device-code store + the consent stores).
374
+ * store + device-code store + WebAuthn challenge store + the consent stores),
375
+ * and the two federation-grant stores' clients from their own factories.
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`.
374
379
  *
375
380
  * Per F4 PR1 (D-2 v2) + Wave 5d unification: the previous design opened a
376
381
  * separate ioredis socket per Redis-backed module (3+ sockets per replica).
@@ -396,11 +401,12 @@ export const storesModule: Module = defineModule({
396
401
  * the single connection cleanly.
397
402
  *
398
403
  * Conditional inclusion: `buildModules` only adds this module to the
399
- * manifest when at least one Redis-backed adapter is selected
400
- * (`userSessionStores.adapter = "redis"` OR `rateLimiter.adapter = "redis"`
401
- * — refresh-token-family is always Redis-backed in production). Adding the
402
- * module unconditionally would open an ioredis socket for memory-only
403
- * deployments.
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.
404
410
  */
405
411
  export const standaloneRedisClientsModule: Module = defineModule({
406
412
  name: "standalone:redis-clients",
@@ -531,6 +537,15 @@ export const standaloneRedisClientsModule: Module = defineModule({
531
537
  return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
532
538
  .deviceCodeStoreClient;
533
539
  },
540
+ // The WebAuthn challenge store's client, off the same shared socket, for
541
+ // the reason the device-code slot above is here: this template does not
542
+ // mount WebAuthn, and a deployment that adds `webauthnModule` with
543
+ // `redisChallengeStoreModule` would otherwise be refused at boot for a
544
+ // `challengeStoreClient` nothing provided.
545
+ challengeStoreClient: async ({ config, lifecycleRegistrar, readinessRegistrar, logger }) => {
546
+ return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
547
+ .challengeStoreClient;
548
+ },
534
549
  // #561: the consent stores' clients, off the same shared socket.
535
550
  // `redisConsentStoreModule` requires both — it provides the consent
536
551
  // records and the parked requests together — and `buildModules` selects
@@ -695,9 +710,11 @@ function getOrCreateClients(
695
710
 
696
711
  // Attach an error handler so unhandled "error" events do not crash the
697
712
  // process. Initial connection failures surface here; downstream adapter
698
- // operations then fail visibly.
713
+ // operations then fail visibly. It logs the projection: ioredis puts the
714
+ // command a reply answered on the error, and for a refused handshake that
715
+ // is `AUTH` with the configured password.
699
716
  io.on("error", (err: unknown) => {
700
- logger.error({ err }, "standalone_redis_clients_error");
717
+ logger.error({ err: loggableError(err) }, "standalone_redis_clients_error");
701
718
  });
702
719
 
703
720
  lifecycleRegistrar?.register(async () => {
@@ -773,6 +790,20 @@ function optionalBoolean(
773
790
  );
774
791
  }
775
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
+
776
807
  /**
777
808
  * Google federation config bridge — supplies the typed `googleFederationConfig`
778
809
  * ComponentMap slot from the `config.federations.google` slice.
@@ -841,6 +872,9 @@ export const googleFederationConfigModule: Module = defineModule({
841
872
  ...optionalString(slice, "clientUrl"),
842
873
  // #597: absent means the provider's default, which is to require it.
843
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),
844
878
  };
845
879
  },
846
880
  },