@o3co/create-auth-provider 0.14.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 (45) 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 +34 -0
  5. package/templates/standalone/Dockerfile +1 -1
  6. package/templates/standalone/README.ja.md +565 -130
  7. package/templates/standalone/README.md +356 -92
  8. package/templates/standalone/config/application.conf +77 -2
  9. package/templates/standalone/docker-compose.production.yml +7 -0
  10. package/templates/standalone/docker-compose.yml +7 -0
  11. package/templates/standalone/gitignore +3 -2
  12. package/templates/standalone/package.json +3 -2
  13. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +49 -15
  14. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +1103 -0
  15. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +241 -0
  16. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +1100 -0
  17. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +40 -5
  18. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +445 -0
  19. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +446 -0
  20. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +133 -0
  21. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +85 -0
  22. package/templates/standalone/src/__tests__/google-session.fixture.mts +304 -0
  23. package/templates/standalone/src/__tests__/host-routes.test.mts +141 -0
  24. package/templates/standalone/src/__tests__/listen.test.mts +128 -0
  25. package/templates/standalone/src/__tests__/logger-projection.test.mts +77 -0
  26. package/templates/standalone/src/__tests__/metrics.test.mts +20 -5
  27. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +187 -0
  28. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +139 -0
  29. package/templates/standalone/src/__tests__/replica-safety.test.mts +13 -3
  30. package/templates/standalone/src/__tests__/router-error-answers.test.mts +149 -0
  31. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +14 -0
  32. package/templates/standalone/src/__tests__/shutdown.test.mts +253 -22
  33. package/templates/standalone/src/__tests__/smoke.test.mts +39 -20
  34. package/templates/standalone/src/__tests__/store-credential.test.mts +276 -0
  35. package/templates/standalone/src/app.mts +30 -51
  36. package/templates/standalone/src/buildModules.mts +94 -12
  37. package/templates/standalone/src/listen.mts +66 -0
  38. package/templates/standalone/src/logger.mts +7 -4
  39. package/templates/standalone/src/modules.mts +118 -12
  40. package/templates/standalone/src/routes.mts +95 -0
  41. package/templates/standalone/src/shutdown.mts +90 -18
  42. package/templates/standalone/vitest.supertest-loopback.mts +22 -3
  43. package/templates/versions.json +15 -14
  44. package/templates/standalone/src/__tests__/terminal-error.test.mts +0 -96
  45. 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";
32
- import { installGracefulShutdown } from "./shutdown.mjs";
33
- import { createTerminalErrorHandler } from "./terminalError.mjs";
27
+ import { mountRoutes } from "./routes.mjs";
28
+ import { cleanupAllowanceFor, installGracefulShutdown } from "./shutdown.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
@@ -164,8 +135,16 @@ await (async (): Promise<void> => {
164
135
  // from the code you deploy. See `shutdown.mts` for the stated guarantees
165
136
  // and for the deadline the previous implementation did not have. Size
166
137
  // `drainTimeoutMs` below your orchestrator's kill grace period.
138
+ //
139
+ // #593 slice 7: with federation grants on, cleanup gets the longest refresh
140
+ // tail the configured budgets allow plus a margin — 45 s under the shipped
141
+ // ones — rather than inheriting the drain's ten: the dispose is what waits
142
+ // for a rotated credential's write. The compose files size
143
+ // `stop_grace_period` to cover the drain, this and an exit margin for the
144
+ // shipped budgets; a raised budget raises both.
167
145
  installGracefulShutdown(server, {
168
146
  logger,
169
147
  cleanup: () => handle.dispose(),
148
+ ...cleanupAllowanceFor(config),
170
149
  });
171
150
  })();
@@ -15,26 +15,34 @@
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,
26
+ memoryFederationGrantIntentStoreModule,
27
+ memoryFederationGrantStoreModule,
24
28
  memoryRateLimiterModule,
25
29
  memoryReplaySeenSetModule,
26
30
  } from "@o3co/auth-provider-core";
27
31
  import { googleFederationModule } from "@o3co/auth-provider-federation-google";
32
+ import { federationGrantsModules } from "@o3co/auth-provider-federation-grants";
28
33
  import { oidcFederationModule, oidcFederationNames } from "@o3co/auth-provider-federation-oidc";
29
34
  import {
30
35
  oauthAuthorizationModule,
31
36
  oauthModule,
32
37
  oauthSessionModule,
38
+ subjectRevocationServiceModule,
33
39
  } from "@o3co/auth-provider-oauth";
34
40
  import {
35
41
  redisAccessTokenDenylistModule,
36
42
  redisCodeRepositoryModule,
37
43
  redisConsentStoreModule,
44
+ redisFederationGrantIntentStoreModule,
45
+ redisFederationGrantStoreModuleFor,
38
46
  redisFederationTokenStoreModuleFor,
39
47
  redisRateLimiterModule,
40
48
  redisRefreshTokenFamilyStoreModule,
@@ -59,10 +67,10 @@ import {
59
67
  } from "./modules.mjs";
60
68
 
61
69
  /**
62
- * Overrides for the composition. All but `environment` are test-only: they let
63
- * the smoke test substitute in-memory implementations of the file-system-backed
64
- * modules, and production callers should not pass them — the defaults match the
65
- * 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.
66
74
  */
67
75
  export interface BuildModulesOverrides {
68
76
  /**
@@ -74,6 +82,12 @@ export interface BuildModulesOverrides {
74
82
  * off the config either way).
75
83
  */
76
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;
77
91
  readonly keyStoreModule?: Module;
78
92
  readonly repositoriesModule?: Module;
79
93
  readonly storesModule?: Module;
@@ -141,6 +155,7 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
141
155
  const googleEnabled =
142
156
  extractFederationSection(config.federations ?? {}, "google")?.type === "google";
143
157
  const oidcFederations = oidcFederationNames(config.federations ?? {});
158
+ const logger = overrides.logger ?? consoleLogger;
144
159
 
145
160
  // Wave 5d (IH-14 + OR-M1 + OR-4) + OR-9: adapter-driven branching for
146
161
  // the OAuth-endpoint rate limiter, the user-session-store family, AND
@@ -174,6 +189,22 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
174
189
  // promised and what the previous factory-based module never delivered: it
175
190
  // built the Redis store without a client and failed at boot.
176
191
  const federationTokenStoreAdapter = config.federationTokenStore?.type ?? "memory";
192
+ // #593 slice 7: federation grants — a user's standing consent that a client
193
+ // may obtain upstream tokens with no session behind the call. Off by
194
+ // default, and off installs nothing: no store, no socket, no boot
195
+ // requirement a deployment that never asked for grants would have to meet.
196
+ // On, the routes and the background registry the shutdown drains come as a
197
+ // pair, each store follows its own switch (grants in Redis with acquisition
198
+ // in memory is a supported single-replica shape), and the subject-revocation
199
+ // service is installed so a credential change can reach the grants — its
200
+ // `federationGrantStore` edge is what makes "revoke everything this subject
201
+ // holds" include them. Enabling the feature is also a statement that the
202
+ // deployment has a consent page, a callback per connection and a user
203
+ // repository that covers each connection's registration; the routes module
204
+ // refuses at boot what is missing, naming it.
205
+ const federationGrantsEnabled = config.federationGrants?.enabled === true;
206
+ const federationGrantStoreAdapter = config.federationGrantStore?.adapter ?? "memory";
207
+ const federationGrantIntentStoreAdapter = config.federationGrantIntentStore?.adapter ?? "memory";
177
208
 
178
209
  // OR-9: effective code-repo adapter. `oauth.code.adapter` is the
179
210
  // authoritative switch; the legacy `repositories.code.type = "redis"`
@@ -186,9 +217,14 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
186
217
  if (oauthCodeAdapter !== undefined) {
187
218
  codeRepositoryAdapter = oauthCodeAdapter;
188
219
  } else if (legacyCodeType === "redis") {
189
- console.warn(
190
- '[buildModules] `repositories.code.type = "redis"` is deprecated; use ' +
191
- '`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",
192
228
  );
193
229
  codeRepositoryAdapter = "redis";
194
230
  } else {
@@ -208,10 +244,14 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
208
244
  accessToken.defaultExpiresIn === undefined &&
209
245
  accessToken.expiresIn !== SHIPPED_ACCESS_TOKEN_EXPIRES_IN
210
246
  ) {
211
- console.warn(
212
- "[buildModules] `oauth.accessToken.expiresIn` (OAUTH_ACCESS_TOKEN_EXPIRES_IN) is " +
213
- "deprecated; use `oauth.accessToken.defaultExpiresIn` " +
214
- "(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",
215
255
  );
216
256
  }
217
257
 
@@ -234,7 +274,11 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
234
274
  accessTokenDenylistAdapter === "redis" ||
235
275
  replaySeenSetAdapter === "redis" ||
236
276
  consentStoreAdapter === "redis" ||
237
- federationTokenStoreAdapter === "redis";
277
+ federationTokenStoreAdapter === "redis" ||
278
+ // Only while the feature is on: a switch left at "redis" for a feature
279
+ // that is off must not open a socket.
280
+ (federationGrantsEnabled &&
281
+ (federationGrantStoreAdapter === "redis" || federationGrantIntentStoreAdapter === "redis"));
238
282
 
239
283
  // The four user-session stores switch on `userSessionStores.adapter`; the
240
284
  // federation-token store is always wired and switches on its own key
@@ -299,6 +343,29 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
299
343
  ? [redisFederationTokenStoreModuleFor({ environment: overrides.environment })]
300
344
  : [inMemoryFederationTokenStoreModule];
301
345
 
346
+ // #593 slice 7: one module per store, nothing while the feature is off.
347
+ // The Redis grant store is built for this composition root so its
348
+ // plaintext guard knows which environment selected the config (#473), as
349
+ // the federation-token store's is. Both memory modules declare
350
+ // `replicaSafety`, so `deployment.mode = "multi"` refuses them by name; a
351
+ // Redis grant store beside memory user-session stores is refused by the
352
+ // routes module itself, because the grants would outlive the boundary that
353
+ // ends them (D13).
354
+ const federationGrantStoreModules: Module[] = !federationGrantsEnabled
355
+ ? []
356
+ : federationGrantStoreAdapter === "redis"
357
+ ? [
358
+ overrides.environment === undefined
359
+ ? redisFederationGrantStoreModuleFor()
360
+ : redisFederationGrantStoreModuleFor({ environment: overrides.environment }),
361
+ ]
362
+ : [memoryFederationGrantStoreModule];
363
+ const federationGrantIntentStoreModules: Module[] = !federationGrantsEnabled
364
+ ? []
365
+ : federationGrantIntentStoreAdapter === "redis"
366
+ ? [redisFederationGrantIntentStoreModule]
367
+ : [memoryFederationGrantIntentStoreModule];
368
+
302
369
  return [
303
370
  // D-5: sessionStoreModule wires the express-session middleware into the
304
371
  // boot-planner-managed lifecycle. **Mount order is enforced by this
@@ -310,6 +377,12 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
310
377
  // `session.storage.type = "memory"` declares itself replica-unsafe and
311
378
  // `deployment.mode = "multi"` refuses it by name like the other stores.
312
379
  sessionStoreModuleFor(config),
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
384
+ // session middleware by its own `after`, wherever it is listed.
385
+ ...(federationGrantsEnabled ? federationGrantsModules : []),
313
386
  oauthModule({ config }),
314
387
  oauthSessionModule({ config }),
315
388
  oauthAuthorizationModule({ config }),
@@ -343,6 +416,9 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
343
416
  // bundled into `storesModule` pre-Wave-5d, and the redis session-stores
344
417
  // branch dropped the provider.
345
418
  ...federationTokenStoreModules,
419
+ // #593 slice 7: the grant store and the intent store, when the feature is on.
420
+ ...federationGrantStoreModules,
421
+ ...federationGrantIntentStoreModules,
346
422
  // User-session-store family: redis (multi-replica) or memory (dev).
347
423
  ...sessionStoresModules,
348
424
  // OAuth-endpoint rate limiter: redis (shared counters) or memory.
@@ -361,5 +437,11 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
361
437
  ...refreshTokenFamilyModules,
362
438
  defaultRefreshTokenFamilyRotationModule,
363
439
  defaultRefreshTokenFamilyRevocationModule,
440
+ // #593 slice 7: the composed "end everything this subject holds" a
441
+ // credential change calls, reached as `handle.components.subjectRevocationService`.
442
+ // Installed with the feature, which is what makes it reach the grants;
443
+ // the template never installed it before, so a deployment without
444
+ // grants is as it was — a Store there calls core's revokeAllForSubject.
445
+ ...(federationGrantsEnabled ? [subjectRevocationServiceModule] : []),
364
446
  ];
365
447
  }
@@ -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
+ }