@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
@@ -30,10 +30,23 @@
30
30
  * and these tests are the contract the issue said was missing.
31
31
  */
32
32
 
33
- import type { Server } from "node:http";
34
- import type { Logger } from "@o3co/auth-provider-core";
33
+ import { createServer, type Server } from "node:http";
34
+ import {
35
+ type BootstrapMap,
36
+ createApp,
37
+ createMemoryReplaySeenSet,
38
+ defineModule,
39
+ type Logger,
40
+ } from "@o3co/auth-provider-core";
41
+ import { makeValidCoreConfig } from "@o3co/auth-provider-core/testing";
35
42
  import { describe, expect, it, vi } from "vitest";
36
- import { deferExit, installGracefulShutdown } from "../shutdown.mjs";
43
+ import {
44
+ cleanupAllowanceFor,
45
+ deferExit,
46
+ FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS,
47
+ FEDERATION_GRANTS_CLEANUP_MARGIN_MS,
48
+ installGracefulShutdown,
49
+ } from "../shutdown.mjs";
37
50
 
38
51
  /** A `Server` double whose `close` callback fires only when we say so. */
39
52
  function makeServer() {
@@ -79,10 +92,53 @@ const makeLogger = () => {
79
92
  return spy satisfies Logger;
80
93
  };
81
94
 
95
+ /**
96
+ * A logger that serialises every own property of what it is handed, `cause`
97
+ * and non-enumerable fields included — a deployment is free to install one.
98
+ * `lines` is what it wrote; its levels are spies.
99
+ */
100
+ const serialiseEverythingLogger = () => {
101
+ const lines: string[] = [];
102
+ const walk = (value: unknown, seen = new WeakSet<object>()): unknown => {
103
+ if (typeof value !== "object" || value === null) return value;
104
+ if (seen.has(value)) return "[circular]";
105
+ seen.add(value);
106
+ const out: Record<string, unknown> = {};
107
+ for (const key of Object.getOwnPropertyNames(value)) {
108
+ out[key] = walk((value as Record<string, unknown>)[key], seen);
109
+ }
110
+ return out;
111
+ };
112
+ const record = (level: string) =>
113
+ vi.fn((...args: unknown[]): void => {
114
+ lines.push(JSON.stringify({ level, args: walk(args) }));
115
+ });
116
+ const logger = {
117
+ trace: record("trace"),
118
+ debug: record("debug"),
119
+ info: record("info"),
120
+ warn: record("warn"),
121
+ error: record("error"),
122
+ fatal: record("fatal"),
123
+ child: vi.fn(() => logger as unknown as Logger),
124
+ };
125
+ return { logger: logger satisfies Logger, lines };
126
+ };
127
+
128
+ /** A logged projection's `stack`: frames only, from the first. */
129
+ const FRAMES = expect.stringMatching(/^ {4}at /);
130
+
82
131
  /** Drive one shutdown without touching the real `process` or exiting. */
83
- function install(opts: { cleanup?: () => void | Promise<void>; drainTimeoutMs?: number } = {}) {
132
+ function install(
133
+ opts: {
134
+ cleanup?: () => void | Promise<void>;
135
+ drainTimeoutMs?: number;
136
+ cleanupTimeoutMs?: number;
137
+ logger?: ReturnType<typeof makeLogger>;
138
+ } = {},
139
+ ) {
84
140
  const { server, spies, finishDraining, failClose } = makeServer();
85
- const logger = makeLogger();
141
+ const logger = opts.logger ?? makeLogger();
86
142
  const exit = vi.fn();
87
143
  const signals = new Map<string, () => void>();
88
144
 
@@ -90,6 +146,7 @@ function install(opts: { cleanup?: () => void | Promise<void>; drainTimeoutMs?:
90
146
  logger,
91
147
  cleanup: opts.cleanup ?? (() => {}),
92
148
  ...(opts.drainTimeoutMs === undefined ? {} : { drainTimeoutMs: opts.drainTimeoutMs }),
149
+ ...(opts.cleanupTimeoutMs === undefined ? {} : { cleanupTimeoutMs: opts.cleanupTimeoutMs }),
93
150
  exit,
94
151
  onSignal: (name, handler) => signals.set(name, handler),
95
152
  offSignal: (name) => signals.delete(name),
@@ -159,7 +216,12 @@ describe("installGracefulShutdown (#290)", () => {
159
216
  signals.get("SIGTERM")?.();
160
217
  await vi.advanceTimersByTimeAsync(5_000);
161
218
  expect(exit).toHaveBeenCalledWith(1);
162
- expect(logger.error).toHaveBeenCalled();
219
+ // Each stage is an event: object-first, a snake_case name.
220
+ expect(logger.info).toHaveBeenCalledWith({ drainTimeoutMs: 5_000 }, "shutdown_draining");
221
+ expect(logger.error).toHaveBeenCalledWith(
222
+ { drainTimeoutMs: 5_000 },
223
+ "shutdown_drain_deadline_exceeded",
224
+ );
163
225
  } finally {
164
226
  vi.useRealTimers();
165
227
  }
@@ -180,21 +242,91 @@ describe("installGracefulShutdown (#290)", () => {
180
242
  }
181
243
  });
182
244
 
183
- it("reports a cleanup failure through the app logger, not console", async () => {
245
+ it("reports a cleanup failure through the app logger, not console, as loggableError's projection", async () => {
184
246
  // Every other line this service emits is NDJSON through pino; a bare
185
247
  // console.error on the shutdown path is the one a log pipeline drops.
186
- const { logger, exit, signals, finishDraining } = install({
187
- cleanup: () => {
188
- throw new Error("redis quit failed");
248
+ // What a real `handle.dispose()` rejects with is an AggregateError of
249
+ // every cleanup's own error. The line names each member and its code,
250
+ // and carries nothing of what they hold: an OAuth library's refusal
251
+ // keeps the refresh answer it refused on a non-Error cause, an ioredis
252
+ // reply the write it refused.
253
+ const refusedRefresh = Object.assign(
254
+ new Error("invalid response encountered", {
255
+ cause: { body: { refresh_token: "1//0g-UPSTREAM-S3CRET" } },
256
+ }),
257
+ { name: "ClientError", code: "OAUTH_INVALID_RESPONSE" },
258
+ );
259
+ const refusedWrite = Object.assign(
260
+ new Error("READONLY You can't write against a read only replica."),
261
+ {
262
+ name: "ReplyError",
263
+ command: { name: "set", args: ["fg:credential:grant-1", "1//0g-ARGS-S3CRET"] },
189
264
  },
265
+ );
266
+ const failingCleanups = defineModule({
267
+ name: "test:failing-cleanups",
268
+ provides: {
269
+ replaySeenSet: () => createMemoryReplaySeenSet(),
270
+ challengeStore: () => ({}) as never,
271
+ },
272
+ lifecycle: {
273
+ replaySeenSet: {
274
+ eager: true,
275
+ cleanup: () => {
276
+ throw refusedRefresh;
277
+ },
278
+ },
279
+ challengeStore: {
280
+ eager: true,
281
+ cleanup: () => {
282
+ throw refusedWrite;
283
+ },
284
+ },
285
+ },
286
+ });
287
+ const handle = await createApp({
288
+ modules: [failingCleanups],
289
+ bootstrapComponents: {
290
+ config: makeValidCoreConfig(),
291
+ pathResolver: (path: string) => path,
292
+ } as unknown as BootstrapMap,
293
+ });
294
+ const { logger, lines } = serialiseEverythingLogger();
295
+ const { exit, signals, finishDraining } = install({
296
+ logger,
297
+ cleanup: () => handle.dispose(),
190
298
  });
191
299
  signals.get("SIGTERM")?.();
192
300
  finishDraining();
193
301
  await vi.waitFor(() => expect(exit).toHaveBeenCalled());
194
302
  expect(logger.error).toHaveBeenCalledWith(
195
- expect.objectContaining({ err: expect.any(Error) }),
196
- expect.stringContaining("cleanup"),
303
+ {
304
+ err: {
305
+ name: "AggregateError",
306
+ detail: expect.stringMatching(/^AppHandle\.dispose: 2 cleanup errors /),
307
+ stack: FRAMES,
308
+ aggregateErrors: expect.arrayContaining([
309
+ {
310
+ name: "ClientError",
311
+ detail: "invalid response encountered",
312
+ code: "OAUTH_INVALID_RESPONSE",
313
+ stack: FRAMES,
314
+ },
315
+ {
316
+ name: "ReplyError",
317
+ detail: "READONLY You can't write against a read only replica.",
318
+ command: { name: "set" },
319
+ stack: FRAMES,
320
+ },
321
+ ]),
322
+ },
323
+ },
324
+ "shutdown_cleanup_failed",
197
325
  );
326
+ for (const line of lines) {
327
+ expect(line).not.toContain("S3CRET");
328
+ expect(line).not.toContain("refresh_token");
329
+ }
198
330
  });
199
331
 
200
332
  it("still exits when cleanup throws — a failed dispose must not wedge the process", async () => {
@@ -208,19 +340,37 @@ describe("installGracefulShutdown (#290)", () => {
208
340
  await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1));
209
341
  });
210
342
 
211
- it("does not report a failed close as a clean drain", async () => {
343
+ it("does not report a failed close as a clean drain, and logs the failure as loggableError's projection", async () => {
212
344
  // `server.close` reports through its callback -- "Server is not running"
213
345
  // is the common one, but any listener teardown failure lands there.
214
346
  // Exiting 0 on it would tell an orchestrator the listener came down
215
- // when it did not.
216
- const { exit, logger, signals, failClose } = install();
347
+ // when it did not. A real server that is not listening gives Node's
348
+ // own error; the line keeps its name, message, code and frames.
349
+ const server = createServer();
350
+ const { logger, lines } = serialiseEverythingLogger();
351
+ const exit = vi.fn();
352
+ const signals = new Map<string, () => void>();
353
+ installGracefulShutdown(server, {
354
+ logger,
355
+ exit,
356
+ onSignal: (name, handler) => signals.set(name, handler),
357
+ offSignal: (name) => signals.delete(name),
358
+ });
217
359
  signals.get("SIGTERM")?.();
218
- failClose(new Error("Server is not running"));
219
360
  await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1));
220
361
  expect(logger.error).toHaveBeenCalledWith(
221
- expect.objectContaining({ err: expect.any(Error) }),
222
- expect.stringContaining("close"),
362
+ {
363
+ err: {
364
+ name: "Error",
365
+ detail: "Server is not running.",
366
+ code: "ERR_SERVER_NOT_RUNNING",
367
+ stack: FRAMES,
368
+ },
369
+ },
370
+ "shutdown_server_close_failed",
223
371
  );
372
+ // The frames, never the header line that repeats the message.
373
+ for (const line of lines) expect(line).not.toContain("Error [ERR_SERVER_NOT_RUNNING]");
224
374
  });
225
375
 
226
376
  it("still runs cleanup when close reports a failure", async () => {
@@ -250,8 +400,8 @@ describe("installGracefulShutdown (#290)", () => {
250
400
  finishDraining();
251
401
  await vi.advanceTimersByTimeAsync(5_000);
252
402
  expect(logger.error).toHaveBeenCalledWith(
253
- expect.objectContaining({ cleanupTimeoutMs: 5_000 }),
254
- expect.stringContaining("cleanup timed out"),
403
+ { cleanupTimeoutMs: 5_000 },
404
+ "shutdown_cleanup_timed_out",
255
405
  );
256
406
  expect(exit).toHaveBeenCalledWith(1);
257
407
  } finally {
@@ -298,7 +448,7 @@ describe("installGracefulShutdown (#290)", () => {
298
448
  await new Promise((resolve) => setImmediate(resolve));
299
449
  expect(logger.info).toHaveBeenCalledWith(
300
450
  { reason: "cleanup-failed", drain: "drained", exitCode: 1 },
301
- "graceful shutdown: complete",
451
+ "shutdown_complete",
302
452
  );
303
453
  expect(exit).toHaveBeenCalledWith(1);
304
454
  });
@@ -310,7 +460,7 @@ describe("installGracefulShutdown (#290)", () => {
310
460
  await new Promise((resolve) => setImmediate(resolve));
311
461
  expect(logger.info).toHaveBeenCalledWith(
312
462
  { reason: "drained", drain: "drained", exitCode: 0 },
313
- "graceful shutdown: complete",
463
+ "shutdown_complete",
314
464
  );
315
465
  });
316
466
 
@@ -322,3 +472,84 @@ describe("installGracefulShutdown (#290)", () => {
322
472
  expect(signals.size).toBe(0);
323
473
  });
324
474
  });
475
+
476
+ describe("#593 slice 7: the cleanup allowance federation grants need", () => {
477
+ it("is at least the 45 seconds the package asks for, and only when the feature is on", () => {
478
+ // The package's drain waits for a rotated credential's write; the
479
+ // default cleanup budget is the ten-second drain, which is shorter than
480
+ // the upstream hard timeout and persist budget the feature ships with —
481
+ // a shutdown under it abandons exactly the write the drain exists for.
482
+ expect(FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS).toBeGreaterThanOrEqual(45_000);
483
+ expect(cleanupAllowanceFor({ federationGrants: { enabled: true } })).toEqual({
484
+ cleanupTimeoutMs: FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS,
485
+ });
486
+ // Off, nothing: the cleanup budget stays the drain's, as it was.
487
+ expect(cleanupAllowanceFor({ federationGrants: { enabled: false } })).toEqual({});
488
+ expect(cleanupAllowanceFor({})).toEqual({});
489
+ });
490
+
491
+ it("grows with the configured refresh tail, so a raised budget is not cut off by a fixed timer (Copilot, #614)", () => {
492
+ // The longest tail one refresh has: the upstream hard timeout, the
493
+ // persist budget and the wait for the lock, back to back, plus an exit
494
+ // margin. The shipped budgets (25 s + 3 s + 5 s) land exactly on the
495
+ // floor; a deployment that doubles its upstream timeout gets more.
496
+ const shipped = {
497
+ upstreamHardTimeoutMs: 25_000,
498
+ persistRetryBudgetMs: 3_000,
499
+ lockWaitMs: 5_000,
500
+ };
501
+ expect(
502
+ 25_000 + 3_000 + 5_000 + FEDERATION_GRANTS_CLEANUP_MARGIN_MS,
503
+ "the margin is what makes the shipped budgets the floor",
504
+ ).toBe(FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS);
505
+ expect(cleanupAllowanceFor({ federationGrants: { enabled: true, ...shipped } })).toEqual({
506
+ cleanupTimeoutMs: FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS,
507
+ });
508
+ expect(
509
+ cleanupAllowanceFor({
510
+ federationGrants: { enabled: true, ...shipped, upstreamHardTimeoutMs: 60_000 },
511
+ }),
512
+ ).toEqual({ cleanupTimeoutMs: 60_000 + 3_000 + 5_000 + FEDERATION_GRANTS_CLEANUP_MARGIN_MS });
513
+ // Lowered budgets never go below the documented minimum, and a config
514
+ // without budgets (built by hand) gets the floor rather than a guess.
515
+ expect(
516
+ cleanupAllowanceFor({
517
+ federationGrants: { enabled: true, ...shipped, upstreamHardTimeoutMs: 1_000 },
518
+ }),
519
+ ).toEqual({ cleanupTimeoutMs: FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS });
520
+ expect(
521
+ cleanupAllowanceFor({ federationGrants: { enabled: true, upstreamHardTimeoutMs: 90_000 } }),
522
+ ).toEqual({ cleanupTimeoutMs: FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS });
523
+ });
524
+
525
+ it("never asks a timer for more than Node can count: an oversized sum is capped, not overflowed (Copilot, #614)", () => {
526
+ // setTimeout takes a 32-bit signed delay; past it the timer fires after
527
+ // about a millisecond, which would turn a generous allowance into none.
528
+ const oversized = cleanupAllowanceFor({
529
+ federationGrants: {
530
+ enabled: true,
531
+ upstreamHardTimeoutMs: 2_000_000_000,
532
+ persistRetryBudgetMs: 2_000_000_000,
533
+ lockWaitMs: 5_000,
534
+ },
535
+ });
536
+ expect(oversized).toEqual({ cleanupTimeoutMs: 2_147_483_647 });
537
+ });
538
+
539
+ it("is honoured by the shutdown: a cleanup that needs longer than the drain is given it", async () => {
540
+ vi.useFakeTimers();
541
+ try {
542
+ const { signals, finishDraining, exit } = install({
543
+ cleanup: () => new Promise<void>((resolve) => setTimeout(resolve, 30_000)),
544
+ drainTimeoutMs: 5_000,
545
+ ...cleanupAllowanceFor({ federationGrants: { enabled: true } }),
546
+ });
547
+ signals.get("SIGTERM")?.();
548
+ finishDraining();
549
+ await vi.advanceTimersByTimeAsync(31_000);
550
+ expect(exit).toHaveBeenCalledExactlyOnceWith(0);
551
+ } finally {
552
+ vi.useRealTimers();
553
+ }
554
+ });
555
+ });
@@ -618,27 +618,46 @@ describe("standalone smoke test", () => {
618
618
  expect(names).toContain("standalone:redis-clients");
619
619
  });
620
620
 
621
- it("buildModules honors legacy repositories.code.type='redis' with deprecation warn when oauth.code.adapter is absent", () => {
622
- const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
623
- try {
624
- const legacyConfig = {
625
- ...config,
626
- oauth: { ...config.oauth, code: undefined },
627
- repositories: {
628
- ...config.repositories,
629
- code: { type: "redis" as const, defaultExpiresIn: 600 },
621
+ it("buildModules honors legacy repositories.code.type='redis' with one object-first deprecation warn when oauth.code.adapter is absent", () => {
622
+ const warn = vi.fn();
623
+ const logger = {
624
+ trace: vi.fn(),
625
+ debug: vi.fn(),
626
+ info: vi.fn(),
627
+ warn,
628
+ error: vi.fn(),
629
+ fatal: vi.fn(),
630
+ child: () => logger,
631
+ };
632
+ const legacyConfig = {
633
+ ...config,
634
+ oauth: { ...config.oauth, code: undefined },
635
+ repositories: {
636
+ ...config.repositories,
637
+ code: { type: "redis" as const, defaultExpiresIn: 600 },
638
+ },
639
+ };
640
+ const modules = buildModules(legacyConfig, {
641
+ keyStoreModule: testKeyStoreModule,
642
+ repositoriesModule: testRepositoriesModule,
643
+ refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
644
+ logger,
645
+ });
646
+ const names = modules.map((m) => m.name);
647
+ expect(names).toContain("redis-code-repository");
648
+ expect(warn.mock.calls).toEqual([
649
+ [
650
+ {
651
+ key: "repositories.code.type",
652
+ env: "CLIENT_CODE_TYPE",
653
+ replacement: "oauth.code.adapter",
654
+ replacementEnv: "OAUTH_CODE_ADAPTER",
630
655
  },
631
- };
632
- const modules = buildModules(legacyConfig, {
633
- keyStoreModule: testKeyStoreModule,
634
- repositoriesModule: testRepositoriesModule,
635
- refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
636
- });
637
- const names = modules.map((m) => m.name);
638
- expect(names).toContain("redis-code-repository");
639
- expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("repositories.code.type"));
640
- } finally {
641
- warnSpy.mockRestore();
656
+ "config_key_deprecated",
657
+ ],
658
+ ]);
659
+ for (const level of ["trace", "debug", "info", "error", "fatal"] as const) {
660
+ expect(logger[level]).not.toHaveBeenCalled();
642
661
  }
643
662
  });
644
663