@o3co/create-auth-provider 0.11.0 → 0.12.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.
@@ -38,7 +38,6 @@ import {
38
38
  } from "@o3co/auth-provider-core";
39
39
  import type { GoogleProviderConfig } from "@o3co/auth-provider-federation-google";
40
40
  import { registerBuiltinAdapters } from "@o3co/auth-provider-foundation";
41
- import { redisFederationTokenStoreBuilder } from "@o3co/auth-provider-redis";
42
41
  import { makeIoredisClients } from "@o3co/auth-provider-redis/ioredis";
43
42
  import { extractFederationSection } from "@o3co/auth-provider-session";
44
43
  // Named import is required here, not default. ioredis is CJS and its entry
@@ -155,6 +154,14 @@ export const repositoriesModule: Module = defineModule({
155
154
  */
156
155
  export const inMemoryCodeRepositoryModule: Module = defineModule({
157
156
  name: "standalone:in-memory-code-repository",
157
+ // #455: the replica-safety guard reads this off the manifest. Before it
158
+ // did, this module booted under `deployment.mode = "multi"` — the guard
159
+ // keyed on core's module names and had never heard of this one.
160
+ replicaSafety: {
161
+ unsafe: true,
162
+ reason:
163
+ "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",
164
+ },
158
165
  requires: ["config"] as const,
159
166
  optional: ["lifecycleRegistrar"] as const,
160
167
  provides: {
@@ -195,6 +202,15 @@ export const inMemoryCodeRepositoryModule: Module = defineModule({
195
202
  */
196
203
  export const inMemorySessionStoresModule: Module = defineModule({
197
204
  name: "standalone:in-memory-session-stores",
205
+ // #455: the replica-safety guard reads this off the manifest. Before it
206
+ // did, `DEPLOYMENT_MODE=multi` with `USER_SESSION_STORES_ADAPTER=memory`
207
+ // booted — the guard keyed on core's `memorySessionStores`, and this
208
+ // module provides the same six slots under a name it had never heard of.
209
+ replicaSafety: {
210
+ unsafe: true,
211
+ reason:
212
+ "user sessions, RP registrations, family indexes and the subject-level revocation pair fork per replica — back-channel logout reaches only the replica that received it, so a logged-out session stays valid on the others, and a credential change enumerates and watermarks only the replica that handled it (#321)",
213
+ },
198
214
  provides: {
199
215
  userSessionStore: () => createInMemoryUserSessionStore(),
200
216
  sessionRPRegistry: () => createInMemorySessionRPRegistry(),
@@ -218,27 +234,43 @@ export const inMemorySessionStoresModule: Module = defineModule({
218
234
  });
219
235
 
220
236
  /**
221
- * Federation token store module — always wired (independent of the
222
- * `userSessionStores.adapter` switch). Pre-Wave-5d this slot was bundled
223
- * into the larger `storesModule`; that meant the redis session-stores
224
- * branch dropped the federation-token-store provider entirely and boot
225
- * failed on the missing component (Copilot review on PR #121). Splitting
226
- * the slot out lets both adapters reuse it unchanged.
237
+ * In-memory federation token store module — wired by `buildModules` only
238
+ * when `federationTokenStore.type = "memory"` (the default). The redis
239
+ * branch swaps in `redisFederationTokenStoreModule` from
240
+ * `@o3co/auth-provider-redis` (mutually exclusive — both provide the same
241
+ * `federationTokenStore` slot), off the shared ioredis socket.
242
+ *
243
+ * Until #455 / #456 this was one module for both adapters
244
+ * (`standalone:federation-token-store`), choosing at runtime through the
245
+ * adapter factory. That shape failed twice over: the replica-safety guard
246
+ * could not tell the memory store from the Redis one by name, so
247
+ * `deployment.mode = "multi"` booted with the memory store; and the Redis
248
+ * branch handed the builder no client, so `federationTokenStore.type =
249
+ * "redis"` — documented in the README — was a boot failure rather than a
250
+ * Redis-backed store. One module per adapter, like the session stores.
251
+ *
252
+ * The slot is always wired, independent of the `userSessionStores.adapter`
253
+ * switch: pre-Wave-5d it was bundled into the larger `storesModule`, and the
254
+ * redis session-stores branch dropped the provider entirely (Copilot review
255
+ * on PR #121).
256
+ *
257
+ * The memory adapter still comes through core's factory, unchanged: that is
258
+ * where its "dev/test only" boot warning lives.
227
259
  */
228
- export const federationTokenStoreModule: Module = defineModule({
229
- name: "standalone:federation-token-store",
230
- requires: ["config"] as const,
260
+ export const inMemoryFederationTokenStoreModule: Module = defineModule({
261
+ name: "standalone:in-memory-federation-token-store",
262
+ // #455: read by the replica-safety guard. Before the split this module
263
+ // carried one name for both adapters, so no name could have carried it.
264
+ replicaSafety: {
265
+ unsafe: true,
266
+ reason:
267
+ "upstream federation tokens fork per replica — a token stored by the replica that completed the federation callback is missing on the others, so a session cannot refresh its upstream token from another replica, and logout removes only the tokens the replica it lands on can see",
268
+ },
231
269
  provides: {
232
- federationTokenStore: async ({ config }) => {
270
+ federationTokenStore: async () => {
233
271
  const factory = createFederationTokenStoreFactory();
234
272
  registerBuiltinFederationTokenStores(factory);
235
- factory.register("redis", redisFederationTokenStoreBuilder);
236
- const slice = (
237
- config as AppConfig & {
238
- federationTokenStore?: { type: string } & Record<string, unknown>;
239
- }
240
- ).federationTokenStore;
241
- return factory.create(slice ? flattenAdapterConfig(slice) : { type: "memory" });
273
+ return factory.create({ type: "memory" });
242
274
  },
243
275
  },
244
276
  });
@@ -305,16 +337,24 @@ export const auditSinkModule: Module = defineModule({
305
337
 
306
338
  /**
307
339
  * @deprecated since Wave 5d (F4 PR2): split into `inMemorySessionStoresModule`
308
- * + `federationTokenStoreModule`. Re-exported for backward compatibility
309
- * with consumers that imported `storesModule` from this file. New code
310
- * should use the two split modules directly.
340
+ * + `inMemoryFederationTokenStoreModule`. Re-exported for backward
341
+ * compatibility with consumers that imported `storesModule` from this file.
342
+ * New code should use the two split modules directly.
311
343
  */
312
344
  export const storesModule: Module = defineModule({
313
345
  name: "standalone:stores",
346
+ // #455: everything this bundle provides lives in process memory, so a
347
+ // composition still on it is refused under `deployment.mode = "multi"`
348
+ // like the split modules it stands in for.
349
+ replicaSafety: {
350
+ unsafe: true,
351
+ reason:
352
+ "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",
353
+ },
314
354
  requires: ["config"] as const,
315
355
  provides: {
316
356
  ...inMemorySessionStoresModule.provides,
317
- ...federationTokenStoreModule.provides,
357
+ ...inMemoryFederationTokenStoreModule.provides,
318
358
  },
319
359
  });
320
360
 
@@ -445,6 +485,32 @@ export const standaloneRedisClientsModule: Module = defineModule({
445
485
  return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
446
486
  .subjectRevocationClient;
447
487
  },
488
+ // #433: the device-code store client, off the same shared socket.
489
+ // `redisDeviceCodeStoreModule` requires it. This template does not
490
+ // mount the device grant, so nothing in `buildModules` selects that
491
+ // module today; the slot is provided anyway, because a deployment that
492
+ // adds `deviceGrantModule` to this manifest and picks the Redis store
493
+ // must not hit the `missing-required-component` boot failure the
494
+ // subject-level slots above already paid for (#439) — a Redis-branch
495
+ // module whose client slot this module did not provide, caught only by
496
+ // the umbrella e2e.
497
+ deviceCodeStoreClient: async ({ config, lifecycleRegistrar, readinessRegistrar, logger }) => {
498
+ return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
499
+ .deviceCodeStoreClient;
500
+ },
501
+ // #456: the federation-token-store client, off the same shared socket.
502
+ // `redisFederationTokenStoreModule` requires it, and nothing provided
503
+ // it — which is why the Redis branch of the federation store could not
504
+ // boot in this template while the README said it could.
505
+ federationTokenStoreClient: async ({
506
+ config,
507
+ lifecycleRegistrar,
508
+ readinessRegistrar,
509
+ logger,
510
+ }) => {
511
+ return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
512
+ .federationTokenStoreClient;
513
+ },
448
514
  },
449
515
  });
450
516
 
@@ -55,7 +55,8 @@ import type { Logger } from "@o3co/auth-provider-core";
55
55
  * `0` cannot tell a clean drain from one that ran out of time.
56
56
  * 5. **`cleanup` runs after draining, before exit**, and its failure is logged
57
57
  * through the app logger and reflected in the exit code. It never wedges the
58
- * process: a dispose that throws still exits.
58
+ * process: a dispose that throws still exits, and one that never settles is
59
+ * cut off at `cleanupTimeoutMs`.
59
60
  * 6. **A `close` that fails is not reported as a clean drain.** `server.close`
60
61
  * reports through its callback, and treating that as success would tell an
61
62
  * orchestrator the listener came down when it did not.
@@ -71,7 +72,13 @@ export interface GracefulShutdownOptions {
71
72
  readonly cleanup?: () => void | Promise<void>;
72
73
  /** How long in-flight requests get before connections are cut. Default 10s. */
73
74
  readonly drainTimeoutMs?: number;
74
- /** Injected in tests; defaults to `process.exit`. */
75
+ /**
76
+ * How long `cleanup` gets before the shutdown gives up on it. Defaults to
77
+ * `drainTimeoutMs`, so the worst-case shutdown is the two budgets in
78
+ * sequence — size both against the orchestrator's grace period, not one.
79
+ */
80
+ readonly cleanupTimeoutMs?: number;
81
+ /** Injected in tests; defaults to {@link deferExit}. */
75
82
  readonly exit?: (code: number) => void;
76
83
  /** Injected in tests; defaults to `process.on`. */
77
84
  readonly onSignal?: (signal: NodeJS.Signals, handler: () => void) => void;
@@ -79,6 +86,19 @@ export interface GracefulShutdownOptions {
79
86
  readonly offSignal?: (signal: NodeJS.Signals, handler: () => void) => void;
80
87
  }
81
88
 
89
+ /**
90
+ * Exit after yielding the loop once.
91
+ *
92
+ * The scaffolded app logs NDJSON through pino, whose default destination is not
93
+ * synchronous, so calling `process.exit` in the same tick as the last
94
+ * `logger.error` can drop exactly the lines that say why the shutdown failed.
95
+ * One turn is a flush window, not a guarantee: a deployment that needs
96
+ * certainty should pass an `exit` that flushes its own transport first.
97
+ */
98
+ export function deferExit(code: number, exitProcess: (code: number) => void = process.exit): void {
99
+ setImmediate(() => exitProcess(code));
100
+ }
101
+
82
102
  const DEFAULT_DRAIN_TIMEOUT_MS = 10_000;
83
103
  const SIGNALS: readonly NodeJS.Signals[] = ["SIGTERM", "SIGINT"];
84
104
 
@@ -87,9 +107,7 @@ export function installGracefulShutdown(server: Server, options: GracefulShutdow
87
107
  logger,
88
108
  cleanup,
89
109
  drainTimeoutMs = DEFAULT_DRAIN_TIMEOUT_MS,
90
- exit = (code: number): void => {
91
- process.exit(code);
92
- },
110
+ exit = deferExit,
93
111
  onSignal = (signal, handler): void => {
94
112
  process.on(signal, handler);
95
113
  },
@@ -98,16 +116,54 @@ export function installGracefulShutdown(server: Server, options: GracefulShutdow
98
116
  },
99
117
  } = options;
100
118
 
119
+ const cleanupTimeoutMs = options.cleanupTimeoutMs ?? drainTimeoutMs;
120
+
101
121
  let shuttingDown = false;
102
122
  let finished = false;
103
123
 
124
+ /** Sentinel so a timed-out cleanup is reported as that, not as a throw. */
125
+ const CLEANUP_TIMED_OUT = Symbol("cleanup-timed-out");
126
+
127
+ /**
128
+ * Await `cleanup`, but not forever. `cleanup()` is invoked inside the async
129
+ * wrapper so a synchronous throw lands in the same rejection path as an
130
+ * async one.
131
+ */
132
+ const runCleanup = async (): Promise<typeof CLEANUP_TIMED_OUT | undefined> => {
133
+ if (!cleanup) return;
134
+ let timer: ReturnType<typeof setTimeout> | undefined;
135
+ try {
136
+ return await Promise.race([
137
+ (async (): Promise<undefined> => {
138
+ await cleanup();
139
+ return undefined;
140
+ })(),
141
+ new Promise<typeof CLEANUP_TIMED_OUT>((resolve) => {
142
+ timer = setTimeout(() => resolve(CLEANUP_TIMED_OUT), cleanupTimeoutMs);
143
+ timer.unref?.();
144
+ }),
145
+ ]);
146
+ } finally {
147
+ if (timer) clearTimeout(timer);
148
+ }
149
+ };
150
+
104
151
  /** Run `cleanup` and exit. Called by whichever of drain / deadline wins. */
105
152
  const finish = async (code: number, reason: string): Promise<void> => {
106
153
  if (finished) return;
107
154
  finished = true;
108
155
  let exitCode = code;
156
+ // `reason` names whatever decided the exit code, so the line an operator
157
+ // alerts on cannot say "drained" next to a non-zero code. The drain
158
+ // outcome keeps its own key rather than being overwritten — both facts
159
+ // are wanted, and a stable shape is what makes the line queryable.
160
+ let outcome = reason;
109
161
  try {
110
- await cleanup?.();
162
+ if ((await runCleanup()) === CLEANUP_TIMED_OUT) {
163
+ logger.error({ cleanupTimeoutMs }, "graceful shutdown: cleanup timed out");
164
+ exitCode = 1;
165
+ outcome = "cleanup-timeout";
166
+ }
111
167
  } catch (err) {
112
168
  // Through the app logger, not `console.error`: a shutdown that
113
169
  // failed to release its Redis connections is exactly the line an
@@ -115,8 +171,9 @@ export function installGracefulShutdown(server: Server, options: GracefulShutdow
115
171
  // pipeline drops.
116
172
  logger.error({ err }, "graceful shutdown: cleanup failed");
117
173
  exitCode = 1;
174
+ outcome = "cleanup-failed";
118
175
  }
119
- logger.info({ reason, exitCode }, "graceful shutdown: complete");
176
+ logger.info({ reason: outcome, drain: reason, exitCode }, "graceful shutdown: complete");
120
177
  exit(exitCode);
121
178
  };
122
179
 
@@ -1,14 +1,15 @@
1
1
  {
2
- "@o3co/auth-provider-core": "0.11.0",
3
- "@o3co/auth-provider-device-grant": "0.11.0",
4
- "@o3co/auth-provider-dpop": "0.11.0",
5
- "@o3co/auth-provider-mtls": "0.11.0",
6
- "@o3co/auth-provider-federation-google": "0.11.0",
7
- "@o3co/auth-provider-federation-github": "0.11.0",
8
- "@o3co/auth-provider-oauth": "0.11.0",
9
- "@o3co/auth-provider-oauth-token-exchange": "0.11.0",
10
- "@o3co/auth-provider-session": "0.11.0",
11
- "@o3co/auth-provider-foundation": "0.11.0",
12
- "@o3co/auth-provider-redis": "0.11.0",
13
- "@o3co/auth-provider-webauthn": "0.11.0"
2
+ "@o3co/auth-provider-core": "0.12.0",
3
+ "@o3co/auth-provider-device-grant": "0.12.0",
4
+ "@o3co/auth-provider-dpop": "0.12.0",
5
+ "@o3co/auth-provider-mtls": "0.12.0",
6
+ "@o3co/auth-provider-federation-apple": "0.12.0",
7
+ "@o3co/auth-provider-federation-google": "0.12.0",
8
+ "@o3co/auth-provider-federation-github": "0.12.0",
9
+ "@o3co/auth-provider-oauth": "0.12.0",
10
+ "@o3co/auth-provider-oauth-token-exchange": "0.12.0",
11
+ "@o3co/auth-provider-session": "0.12.0",
12
+ "@o3co/auth-provider-foundation": "0.12.0",
13
+ "@o3co/auth-provider-redis": "0.12.0",
14
+ "@o3co/auth-provider-webauthn": "0.12.0"
14
15
  }