@o3co/create-auth-provider 0.12.1 → 0.14.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.
@@ -0,0 +1,125 @@
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
+ * #556 — the server `request(app)` starts listens on the address supertest
19
+ * dials, in this project's own test run.
20
+ *
21
+ * Unpatched, supertest starts its server with `app.listen(0)` — the
22
+ * dual-stack wildcard `[::]:P` — and sends the request to `127.0.0.1:P`. On
23
+ * macOS the kernel can hand out a `P` another process already holds as
24
+ * `127.0.0.1:P`; the request then reaches that process, and if it never
25
+ * answers the test hangs until its timeout. `vitest.supertest-loopback.mts`
26
+ * (wired in through `setupFiles` in `vitest.config.mts`) binds the server to
27
+ * `127.0.0.1` instead. These tests fail if that wiring is lost.
28
+ */
29
+
30
+ import http from "node:http";
31
+ import net, { type AddressInfo } from "node:net";
32
+ import express from "express";
33
+ import request from "supertest";
34
+ import { describe, expect, it } from "vitest";
35
+
36
+ function helloApp() {
37
+ const app = express();
38
+ app.get("/hello", (_req, res) => {
39
+ res.status(200).send("hello");
40
+ });
41
+ return app;
42
+ }
43
+
44
+ /** Resolves with the address `server` binds, whoever calls `listen`. */
45
+ function boundAddress(server: net.Server): Promise<AddressInfo> {
46
+ return new Promise((resolve) => {
47
+ server.once("listening", () => resolve(server.address() as AddressInfo));
48
+ });
49
+ }
50
+
51
+ describe("#556 — supertest's own server listens on the loopback address it dials", () => {
52
+ it("binds 127.0.0.1, not the dual-stack wildcard", async () => {
53
+ const server = http.createServer(helloApp());
54
+ const bound = boundAddress(server);
55
+
56
+ const res = await request(server).get("/hello");
57
+
58
+ expect(res.status).toBe(200);
59
+ expect(await bound).toMatchObject({ address: "127.0.0.1", family: "IPv4" });
60
+ // supertest still closes the server it started.
61
+ expect(server.listening).toBe(false);
62
+ });
63
+
64
+ it("rejects promptly with the request's own error when the request cannot be built", async () => {
65
+ // Node refuses this header value (ERR_INVALID_CHAR) while supertest builds
66
+ // the request. With the loopback bind that happens after the bind settles,
67
+ // outside the promise the test awaits; the throw must still reject that
68
+ // promise, as it does unpatched, and not become an unhandled rejection
69
+ // that leaves the request hanging until the test timeout.
70
+ const server = http.createServer(helloApp());
71
+
72
+ await expect(request(server).get("/hello").set("x-test", "bad\nvalue")).rejects.toMatchObject({
73
+ code: "ERR_INVALID_CHAR",
74
+ });
75
+ // The request never went out, so nothing would close the server it started.
76
+ expect(server.listening).toBe(false);
77
+ }, 5_000);
78
+
79
+ it("hands the same error to an .end() callback", async () => {
80
+ const server = http.createServer(helloApp());
81
+
82
+ const err = await new Promise<unknown>((resolve) => {
83
+ request(server)
84
+ .get("/hello")
85
+ .set("x-test", "bad\nvalue")
86
+ .end((error) => resolve(error));
87
+ });
88
+
89
+ expect(err).toMatchObject({ code: "ERR_INVALID_CHAR" });
90
+ }, 5_000);
91
+
92
+ it("serves requests an agent sends together over its one server", async () => {
93
+ const agent = request.agent(helloApp());
94
+
95
+ const responses = await Promise.all([agent.get("/hello"), agent.get("/hello")]);
96
+
97
+ expect(responses.map((res) => res.status)).toEqual([200, 200]);
98
+ });
99
+
100
+ it("fails the request, instead of hanging on another socket, when 127.0.0.1:P is taken", async () => {
101
+ // The collision the kernel produces by chance, produced on purpose: a
102
+ // socket that accepts and never answers holds 127.0.0.1:P, and the
103
+ // server supertest starts is steered onto P.
104
+ const held: net.Socket[] = [];
105
+ const squatter = net.createServer((socket) => {
106
+ held.push(socket);
107
+ });
108
+ await new Promise<void>((resolve) => squatter.listen(0, "127.0.0.1", resolve));
109
+ const { port } = squatter.address() as AddressInfo;
110
+
111
+ const server = http.createServer(helloApp());
112
+ const listen = server.listen.bind(server) as (...args: unknown[]) => net.Server;
113
+ (server as { listen: (...args: unknown[]) => net.Server }).listen = (_port, ...rest) =>
114
+ listen(port, ...rest);
115
+
116
+ try {
117
+ await expect(request(server).get("/hello")).rejects.toMatchObject({
118
+ code: "EADDRINUSE",
119
+ });
120
+ } finally {
121
+ for (const socket of held) socket.destroy();
122
+ squatter.close();
123
+ }
124
+ }, 5_000);
125
+ });
@@ -20,9 +20,12 @@ import {
20
20
  jwksModule,
21
21
  type Module,
22
22
  memoryAccessTokenDenylistModule,
23
+ memoryConsentStoreModule,
23
24
  memoryRateLimiterModule,
25
+ memoryReplaySeenSetModule,
24
26
  } from "@o3co/auth-provider-core";
25
27
  import { googleFederationModule } from "@o3co/auth-provider-federation-google";
28
+ import { oidcFederationModule, oidcFederationNames } from "@o3co/auth-provider-federation-oidc";
26
29
  import {
27
30
  oauthAuthorizationModule,
28
31
  oauthModule,
@@ -31,12 +34,18 @@ import {
31
34
  import {
32
35
  redisAccessTokenDenylistModule,
33
36
  redisCodeRepositoryModule,
37
+ redisConsentStoreModule,
34
38
  redisFederationTokenStoreModuleFor,
35
39
  redisRateLimiterModule,
36
40
  redisRefreshTokenFamilyStoreModule,
41
+ redisReplaySeenSetModule,
37
42
  redisSessionStoresModule,
38
43
  } from "@o3co/auth-provider-redis";
39
- import { sessionModule, sessionStoreModuleFor } from "@o3co/auth-provider-session";
44
+ import {
45
+ extractFederationSection,
46
+ sessionModule,
47
+ sessionStoreModuleFor,
48
+ } from "@o3co/auth-provider-session";
40
49
  import {
41
50
  auditSinkModule,
42
51
  googleFederationConfigModule,
@@ -44,6 +53,7 @@ import {
44
53
  inMemoryFederationTokenStoreModule,
45
54
  inMemorySessionStoresModule,
46
55
  keyStoreModule,
56
+ oidcFederationConfigModule,
47
57
  repositoriesModule,
48
58
  standaloneRedisClientsModule,
49
59
  } from "./modules.mjs";
@@ -93,6 +103,15 @@ export interface BuildModulesOverrides {
93
103
  readonly auditSinkModule?: Module;
94
104
  }
95
105
 
106
+ /**
107
+ * The access-token lifetime core's `reference.conf` ships on the deprecated
108
+ * `oauth.accessToken.expiresIn`. Not a default — nothing mints with it — only
109
+ * what an unmodified deployment carries there, so the deprecation line can tell
110
+ * an operator's override from the shipped value. Pinned against the real file
111
+ * by `access-token-lifetime-alias.test.mts`.
112
+ */
113
+ const SHIPPED_ACCESS_TOKEN_EXPIRES_IN = 3600;
114
+
96
115
  /**
97
116
  * Compose the standalone v0.5.0 module list from `config`. Splitting this
98
117
  * out of `app.mts` keeps the composition root testable: a smoke test can
@@ -105,10 +124,23 @@ export interface BuildModulesOverrides {
105
124
  * absent), the config-bridge module's provider throws — so the entire pair
106
125
  * MUST be conditionally included at composition time, not gated inside the
107
126
  * provider.
127
+ *
128
+ * #524: the generic OIDC federation follows the same rule, once per enabled
129
+ * `federations.<name>` of type "oidc": `oidcFederationModule(name)` per
130
+ * instance plus the one `oidcFederationConfigModule` that reads every
131
+ * instance's config into the slot they share. A section's `type` decides
132
+ * which implementation owns the name, so the two gates never both select
133
+ * the same section.
108
134
  */
109
135
  export function buildModules(config: AppConfig, overrides: BuildModulesOverrides = {}): Module[] {
136
+ // A section's `type` names the implementation: `federations.google` is the
137
+ // built-in Google federation only when its type is `google` (the default
138
+ // for that name). With `type = "oidc"` it is a generic OIDC instance named
139
+ // "google" (#524), and composing both would contribute the same
140
+ // federation and redirect-policy keys twice.
110
141
  const googleEnabled =
111
- (config.federations?.google as { enabled?: boolean } | undefined)?.enabled === true;
142
+ extractFederationSection(config.federations ?? {}, "google")?.type === "google";
143
+ const oidcFederations = oidcFederationNames(config.federations ?? {});
112
144
 
113
145
  // Wave 5d (IH-14 + OR-M1 + OR-4) + OR-9: adapter-driven branching for
114
146
  // the OAuth-endpoint rate limiter, the user-session-store family, AND
@@ -128,6 +160,14 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
128
160
  // endpoint would answer 200 while the token kept working. The switch here is
129
161
  // only over WHICH denylist.
130
162
  const accessTokenDenylistAdapter = config.accessTokenDenylist?.adapter ?? "memory";
163
+ // #484: the jti single-use record behind private_key_jwt client auth.
164
+ const replaySeenSetAdapter = config.replaySeenSet?.adapter ?? "memory";
165
+ // #527: the consent store behind /authorize for clients that are not
166
+ // first-party. `"none"` (the default) wires nothing — such clients are
167
+ // refused, as before — so a first-party-only deployment pays nothing and
168
+ // a multi-replica one is not handed a memory store it cannot run.
169
+ // `"redis"` (#561) is the store such a deployment selects.
170
+ const consentStoreAdapter = config.consentStore?.adapter ?? "none";
131
171
  // #456: adapter switch for the federation token store. `"memory"` by
132
172
  // default, the template's local-dev shape; `"redis"` mounts
133
173
  // `redisFederationTokenStoreModule` off the shared socket — what the README
@@ -155,6 +195,26 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
155
195
  codeRepositoryAdapter = "memory";
156
196
  }
157
197
 
198
+ // The access-token lifetime's deprecated alias: `oauth.accessToken.expiresIn`
199
+ // (OAUTH_ACCESS_TOKEN_EXPIRES_IN) is still read as the default while
200
+ // `defaultExpiresIn` is unset — `resolveAccessTokenLifetime` does that, and
201
+ // this only says so. Core's `reference.conf` keeps the shipped lifetime on
202
+ // the deprecated key, so "the alias supplied the default" is every
203
+ // deployment that set nothing; an override of it is the one case with
204
+ // something to move, the reading `legacyCodeType === "redis"` gives above.
205
+ // See CHANGELOG for the removal version.
206
+ const accessToken = config.oauth.accessToken;
207
+ if (
208
+ accessToken.defaultExpiresIn === undefined &&
209
+ accessToken.expiresIn !== SHIPPED_ACCESS_TOKEN_EXPIRES_IN
210
+ ) {
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.",
215
+ );
216
+ }
217
+
158
218
  const refreshTokenFamilyModules: readonly Module[] = overrides.refreshTokenFamilyModules ?? [
159
219
  redisRefreshTokenFamilyStoreModule,
160
220
  ];
@@ -172,6 +232,8 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
172
232
  userSessionStoresAdapter === "redis" ||
173
233
  codeRepositoryAdapter === "redis" ||
174
234
  accessTokenDenylistAdapter === "redis" ||
235
+ replaySeenSetAdapter === "redis" ||
236
+ consentStoreAdapter === "redis" ||
175
237
  federationTokenStoreAdapter === "redis";
176
238
 
177
239
  // The four user-session stores switch on `userSessionStores.adapter`; the
@@ -207,6 +269,18 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
207
269
  ? [redisAccessTokenDenylistModule]
208
270
  : [memoryAccessTokenDenylistModule];
209
271
 
272
+ // #527: opt-in. Each module provides both slots the consent step needs —
273
+ // the consent records and the requests parked while the page asks — so the
274
+ // two cannot be wired apart. The memory one declares itself replica-unsafe
275
+ // and `deployment.mode = "multi"` refuses it by name; the Redis one (#561)
276
+ // shares both over the ioredis socket.
277
+ const consentStoreModules: Module[] =
278
+ consentStoreAdapter === "redis"
279
+ ? [redisConsentStoreModule]
280
+ : consentStoreAdapter === "memory"
281
+ ? [memoryConsentStoreModule]
282
+ : [];
283
+
210
284
  // #455 / #456: mutually-exclusive federation-token-store pair, the same
211
285
  // shape as the session stores. One module per adapter, so the memory one
212
286
  // can declare `replicaSafety` on its manifest and the Redis one can
@@ -217,6 +291,9 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
217
291
  // #473: the Redis module is built for this composition root so its
218
292
  // plaintext guard knows which environment selected the config; it reads
219
293
  // `deployment.mode` off the config itself.
294
+ const replaySeenSetModules: Module[] =
295
+ replaySeenSetAdapter === "redis" ? [redisReplaySeenSetModule] : [memoryReplaySeenSetModule];
296
+
220
297
  const federationTokenStoreModules: Module[] =
221
298
  federationTokenStoreAdapter === "redis"
222
299
  ? [redisFederationTokenStoreModuleFor({ environment: overrides.environment })]
@@ -245,6 +322,9 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
245
322
  jwksModule,
246
323
  sessionModule,
247
324
  ...(googleEnabled ? [googleFederationModule, googleFederationConfigModule] : []),
325
+ ...(oidcFederations.length > 0
326
+ ? [oidcFederationConfigModule, ...oidcFederations.map((name) => oidcFederationModule(name))]
327
+ : []),
248
328
  overrides.keyStoreModule ?? keyStoreModule,
249
329
  overrides.repositoriesModule ?? repositoriesModule,
250
330
  // #287: the audit sink, always wired. Unlike every switch above there is
@@ -272,6 +352,10 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
272
352
  // RFC 7009 access-token denylist: redis (shared revocations) or memory
273
353
  // (single-instance dev). Always wired — see the switch above.
274
354
  ...accessTokenDenylistModules,
355
+ ...replaySeenSetModules,
356
+ // Consent records for clients that are not first-party: redis
357
+ // (multi-replica, #561), memory (single-instance), or nothing (#527).
358
+ ...consentStoreModules,
275
359
  // RT family store: redis by default (closes OR-1); override path
276
360
  // swaps to `[memoryRefreshTokenFamilyStoreModule]` for unit tests.
277
361
  ...refreshTokenFamilyModules,
@@ -37,6 +37,7 @@ import {
37
37
  registerBuiltinKeyStores,
38
38
  } from "@o3co/auth-provider-core";
39
39
  import type { GoogleProviderConfig } from "@o3co/auth-provider-federation-google";
40
+ import { readOidcFederationConfigs } from "@o3co/auth-provider-federation-oidc";
40
41
  import { registerBuiltinAdapters } from "@o3co/auth-provider-foundation";
41
42
  import { makeIoredisClients } from "@o3co/auth-provider-redis/ioredis";
42
43
  import { extractFederationSection } from "@o3co/auth-provider-session";
@@ -360,11 +361,12 @@ export const storesModule: Module = defineModule({
360
361
 
361
362
  /**
362
363
  * Shared ioredis clients module — opens ONE long-lived ioredis connection
363
- * per replica, wraps it via `makeIoredisClients()` (returns 13 typed
364
+ * per replica, wraps it via `makeIoredisClients()` (returns 16 typed
364
365
  * per-purpose clients), and exposes the slots consumed by the standalone's
365
366
  * Redis-backed adapters (refresh-token-family + 4 user-session stores +
366
367
  * subject-level revocation index + watermark + rate limiter + code
367
- * repository + access-token denylist).
368
+ * repository + access-token denylist + replay seen-set + federation token
369
+ * store + device-code store + the consent stores).
368
370
  *
369
371
  * Per F4 PR1 (D-2 v2) + Wave 5d unification: the previous design opened a
370
372
  * separate ioredis socket per Redis-backed module (3+ sockets per replica).
@@ -436,6 +438,11 @@ export const standaloneRedisClientsModule: Module = defineModule({
436
438
  return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
437
439
  .sessionFederationIndexClient;
438
440
  },
441
+ // #484: the replay seen-set behind private_key_jwt client assertions.
442
+ replaySeenSetClient: async ({ config, lifecycleRegistrar, readinessRegistrar, logger }) => {
443
+ return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
444
+ .replaySeenSetClient;
445
+ },
439
446
  rateLimiterClient: async ({ config, lifecycleRegistrar, readinessRegistrar, logger }) => {
440
447
  return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
441
448
  .rateLimiterClient;
@@ -498,6 +505,23 @@ export const standaloneRedisClientsModule: Module = defineModule({
498
505
  return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
499
506
  .deviceCodeStoreClient;
500
507
  },
508
+ // #561: the consent stores' clients, off the same shared socket.
509
+ // `redisConsentStoreModule` requires both — it provides the consent
510
+ // records and the parked requests together — and `buildModules` selects
511
+ // it under `consentStore.adapter = "redis"`.
512
+ consentStoreClient: async ({ config, lifecycleRegistrar, readinessRegistrar, logger }) => {
513
+ return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
514
+ .consentStoreClient;
515
+ },
516
+ pendingConsentStoreClient: async ({
517
+ config,
518
+ lifecycleRegistrar,
519
+ readinessRegistrar,
520
+ logger,
521
+ }) => {
522
+ return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
523
+ .pendingConsentStoreClient;
524
+ },
501
525
  // #456: the federation-token-store client, off the same shared socket.
502
526
  // `redisFederationTokenStoreModule` requires it, and nothing provided
503
527
  // it — which is why the Redis branch of the federation store could not
@@ -749,3 +773,21 @@ export const googleFederationConfigModule: Module = defineModule({
749
773
  },
750
774
  },
751
775
  });
776
+
777
+ /**
778
+ * OIDC federation config bridge (#524) — supplies the `oidcFederationConfigs`
779
+ * slot every `oidcFederationModule(<name>)` in the manifest reads its entry
780
+ * from. One bridge for all instances: `readOidcFederationConfigs` walks
781
+ * `config.federations` and reads every enabled section of type `oidc`,
782
+ * refusing a malformed field by `federations.<name>.<field>` at boot.
783
+ * `buildModules` lists this module only when at least one such section
784
+ * exists.
785
+ */
786
+ export const oidcFederationConfigModule: Module = defineModule({
787
+ name: "standalone:oidc-federation-config",
788
+ requires: ["config"] as const,
789
+ provides: {
790
+ oidcFederationConfigs: ({ config }) =>
791
+ readOidcFederationConfigs((config as AppConfig).federations),
792
+ },
793
+ });
@@ -11,5 +11,20 @@ export default defineConfig({
11
11
  // 20s.
12
12
  testTimeout: 20_000,
13
13
  hookTimeout: 20_000,
14
+ // #512: run the @o3co/auth-provider-* packages through vitest instead of
15
+ // letting Node load them natively. In a scaffolded project they are
16
+ // installed from npm under node_modules, which vitest externalizes by
17
+ // default — and an externalized package's own `import "ioredis"` /
18
+ // `import "redis"` never sees a `vi.mock` from a test file, so
19
+ // replica-safety.test.mts dialled a Redis that is not there. Inside the
20
+ // monorepo the same packages are symlinks to source and are inlined
21
+ // anyway; this makes the two layouts behave the same.
22
+ server: { deps: { inline: [/@o3co\/auth-provider-/] } },
23
+ // #556: the server `request(app)` starts listens on 127.0.0.1, the
24
+ // address supertest dials. Unpatched, macOS can give it a port another
25
+ // process holds on 127.0.0.1, and the request hangs on that process until
26
+ // the test timeout. Rationale and mechanics in the file itself. The
27
+ // Dockerfile `test` stage copies it; scaffold-assets.test.mts checks that.
28
+ setupFiles: ["./vitest.supertest-loopback.mts"],
14
29
  },
15
30
  });
@@ -0,0 +1,231 @@
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
+ * vitest setup file (#556): the server supertest starts listens on the
19
+ * address supertest dials.
20
+ *
21
+ * `request(app)` / `request.agent(app)` start a server with `app.listen(0)`
22
+ * and send the request to `127.0.0.1:<port>`. A hostless `listen` binds the
23
+ * dual-stack wildcard `[::]:P`, and on macOS (BSD `SO_REUSEADDR` semantics —
24
+ * libuv sets it on every TCP listener) the kernel will choose a `P` that
25
+ * another process already holds as `127.0.0.1:P`. The more specific socket
26
+ * wins, so the test's request is delivered to that process. When it accepts
27
+ * and never answers — an editor helper, in the case that was caught — the
28
+ * request hangs until the test timeout, in whichever supertest test drew the
29
+ * port, and passes on the next run. Linux refuses the conflicting bind
30
+ * (EADDRINUSE), so CI does not show it.
31
+ *
32
+ * Binding `127.0.0.1` explicitly removes both halves: the kernel will not
33
+ * give out a `127.0.0.1:P` someone else holds, and against a wildcard holder
34
+ * our specific socket is the one that receives. A specific-host `listen` is
35
+ * asynchronous (it goes through `dns.lookup`), and supertest reads the port
36
+ * synchronously in its constructor, so the URL is completed when the request
37
+ * is sent (`end`, which `then` and `expect(..., fn)` go through). A server the
38
+ * test started itself is never touched.
39
+ *
40
+ * supertest is loaded from the package that owns the running test file (the
41
+ * nearest `package.json` above it), so the patched class is the one that test
42
+ * imports, whatever directory vitest was started from. A package that does
43
+ * not declare supertest is left alone. Everything else this file cannot do —
44
+ * no test path, a declared supertest that does not load, a supertest whose
45
+ * internals no longer look like this — throws, so the guard is never silently
46
+ * off.
47
+ *
48
+ * This file is part of the project template and ships with every scaffold.
49
+ * The auth.provider workspace loads this same file for its packages (see
50
+ * `WORKSPACE_TEST_SETUP` in its `vitest.shared.mts`).
51
+ */
52
+
53
+ import { once } from "node:events";
54
+ import { existsSync, readFileSync } from "node:fs";
55
+ import { createRequire } from "node:module";
56
+ import type { Server } from "node:net";
57
+ import { dirname, join } from "node:path";
58
+ import { Server as TlsServer } from "node:tls";
59
+ import { expect } from "vitest";
60
+
61
+ const LOOPBACK = "127.0.0.1";
62
+ const PATCHED = Symbol.for("o3co.auth.provider.supertest-loopback");
63
+ const PENDING = Symbol("o3co.auth.provider.supertest-loopback.pending");
64
+
65
+ type Pending = {
66
+ readonly server: Server;
67
+ readonly protocol: string;
68
+ readonly path: string;
69
+ /** Settles on `listening`, rejects on a listen `error`. */
70
+ readonly ready: Promise<unknown>;
71
+ };
72
+
73
+ type TestInstance = {
74
+ url: string;
75
+ _server?: Server;
76
+ [PENDING]?: Pending;
77
+ };
78
+
79
+ type TestPrototype = {
80
+ serverAddress(this: TestInstance, app: Server, path: string): string;
81
+ end(this: TestInstance, fn?: (err: unknown, res?: unknown) => void): TestInstance;
82
+ [PATCHED]?: true;
83
+ };
84
+
85
+ const DEPENDENCY_FIELDS = [
86
+ "dependencies",
87
+ "devDependencies",
88
+ "peerDependencies",
89
+ "optionalDependencies",
90
+ ] as const;
91
+
92
+ /** The nearest `package.json` at or above `dir`. */
93
+ function owningManifest(dir: string): string | undefined {
94
+ for (let current = dir; ; current = dirname(current)) {
95
+ const candidate = join(current, "package.json");
96
+ if (existsSync(candidate)) return candidate;
97
+ if (dirname(current) === current) return undefined;
98
+ }
99
+ }
100
+
101
+ /** supertest's `Test.prototype` as the running test file sees it, or `undefined` when its package has no supertest. */
102
+ function supertestFor(testPath: string): TestPrototype | undefined {
103
+ const manifestPath = owningManifest(dirname(testPath));
104
+ if (!manifestPath) {
105
+ throw new Error(`vitest.supertest-loopback.mts: no package.json above ${testPath}.`);
106
+ }
107
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as Record<string, unknown>;
108
+ const declared = DEPENDENCY_FIELDS.some(
109
+ (field) => (manifest[field] as Record<string, unknown> | undefined)?.supertest !== undefined,
110
+ );
111
+ if (!declared) return undefined;
112
+
113
+ let supertest: { Test?: { prototype: TestPrototype } };
114
+ try {
115
+ supertest = createRequire(manifestPath)("supertest");
116
+ } catch (cause) {
117
+ throw new Error(
118
+ `vitest.supertest-loopback.mts: ${manifestPath} declares supertest, but it cannot be loaded from there; the #556 loopback guard would be off.`,
119
+ { cause },
120
+ );
121
+ }
122
+ const proto = supertest.Test?.prototype;
123
+ if (typeof proto?.serverAddress !== "function" || typeof proto.end !== "function") {
124
+ throw new Error(
125
+ "vitest.supertest-loopback.mts: supertest no longer exposes Test.prototype.serverAddress/end; " +
126
+ "re-check the #556 loopback guard against the installed supertest before removing it.",
127
+ );
128
+ }
129
+ return proto;
130
+ }
131
+
132
+ /**
133
+ * The bind in progress per server. A second request on a server whose bind has
134
+ * not finished (an agent's requests sent together share one server) waits on
135
+ * the same bind instead of calling `listen` twice — and, like unpatched
136
+ * supertest when it finds a server already listening, does not own it.
137
+ */
138
+ const binding = new WeakMap<Server, Promise<unknown>>();
139
+
140
+ /**
141
+ * A throw from the test's own callback, raised where it cannot propagate to
142
+ * the test: reported as an uncaught exception, which fails the run by name,
143
+ * instead of an unhandled rejection of a promise nobody holds.
144
+ */
145
+ function rethrowOutsideThisChain(err: unknown): void {
146
+ process.nextTick(() => {
147
+ throw err;
148
+ });
149
+ }
150
+
151
+ function patch(proto: TestPrototype): void {
152
+ if (proto[PATCHED]) return;
153
+ const originalServerAddress = proto.serverAddress;
154
+ const originalEnd = proto.end;
155
+
156
+ proto.serverAddress = function serverAddress(app, path) {
157
+ // Already listening: the test owns the server and chose its address.
158
+ if (app.address()) return originalServerAddress.call(this, app, path);
159
+
160
+ const protocol = app instanceof TlsServer ? "https" : "http";
161
+ let ready = binding.get(app);
162
+ if (!ready) {
163
+ // supertest closes `_server` after the response, as it does for the
164
+ // server it would have started itself.
165
+ this._server = app.listen(0, LOOPBACK);
166
+ // Subscribed now, not in `end`: a listen error emitted before the test
167
+ // sends the request must still reach it, not crash the worker as an
168
+ // unhandled 'error' event.
169
+ const bound = once(app, "listening");
170
+ binding.set(app, bound);
171
+ bound.then(
172
+ () => binding.delete(app),
173
+ () => binding.delete(app),
174
+ );
175
+ ready = bound;
176
+ }
177
+ this[PENDING] = { server: app, protocol, path, ready };
178
+ // No port yet; `end` fills it in before anything is sent.
179
+ return `${protocol}://${LOOPBACK}${path}`;
180
+ };
181
+
182
+ proto.end = function end(fn) {
183
+ const pending = this[PENDING];
184
+ if (!pending) return originalEnd.call(this, fn);
185
+ this[PENDING] = undefined;
186
+
187
+ const { server, protocol, path, ready } = pending;
188
+ let called = false;
189
+ const callback = (err: unknown, res?: unknown) => {
190
+ called = true;
191
+ fn?.(err, res);
192
+ };
193
+ // Unpatched, whatever `end` throws while building the request (a header
194
+ // value Node refuses, a body that will not serialize) reaches the caller
195
+ // at once — and `then` runs `end` inside a promise executor, so the
196
+ // awaited request rejects with it. Deferred, there is no caller left: the
197
+ // throw, like a failed bind, is handed to the request's callback, which
198
+ // is what rejects that promise. Nothing here may reject: a rejection of
199
+ // this chain is one nobody awaits.
200
+ const fail = (err: unknown) => {
201
+ if (called) return rethrowOutsideThisChain(err);
202
+ // The request never went out, so supertest will not close the server
203
+ // this request started.
204
+ if (this._server?.listening) this._server.close();
205
+ try {
206
+ callback(err);
207
+ } catch (thrown) {
208
+ rethrowOutsideThisChain(thrown);
209
+ }
210
+ };
211
+ ready
212
+ .then(() => {
213
+ const { port } = server.address() as { port: number };
214
+ this.url = `${protocol}://${LOOPBACK}:${port}${path}`;
215
+ originalEnd.call(this, callback);
216
+ })
217
+ .catch(fail);
218
+ return this;
219
+ };
220
+
221
+ proto[PATCHED] = true;
222
+ }
223
+
224
+ const { testPath } = expect.getState();
225
+ if (!testPath) {
226
+ throw new Error(
227
+ "vitest.supertest-loopback.mts: vitest did not say which test file this setup runs for, so the #556 loopback guard cannot find supertest.",
228
+ );
229
+ }
230
+ const proto = supertestFor(testPath);
231
+ if (proto) patch(proto);
@@ -1,15 +1,16 @@
1
1
  {
2
- "@o3co/auth-provider-core": "0.12.1",
3
- "@o3co/auth-provider-device-grant": "0.12.1",
4
- "@o3co/auth-provider-dpop": "0.12.1",
5
- "@o3co/auth-provider-mtls": "0.12.1",
6
- "@o3co/auth-provider-federation-apple": "0.12.1",
7
- "@o3co/auth-provider-federation-google": "0.12.1",
8
- "@o3co/auth-provider-federation-github": "0.12.1",
9
- "@o3co/auth-provider-oauth": "0.12.1",
10
- "@o3co/auth-provider-oauth-token-exchange": "0.12.1",
11
- "@o3co/auth-provider-session": "0.12.1",
12
- "@o3co/auth-provider-foundation": "0.12.1",
13
- "@o3co/auth-provider-redis": "0.12.1",
14
- "@o3co/auth-provider-webauthn": "0.12.1"
2
+ "@o3co/auth-provider-core": "0.14.0",
3
+ "@o3co/auth-provider-device-grant": "0.14.0",
4
+ "@o3co/auth-provider-dpop": "0.14.0",
5
+ "@o3co/auth-provider-mtls": "0.14.0",
6
+ "@o3co/auth-provider-federation-apple": "0.14.0",
7
+ "@o3co/auth-provider-federation-google": "0.14.0",
8
+ "@o3co/auth-provider-federation-github": "0.14.0",
9
+ "@o3co/auth-provider-federation-oidc": "0.14.0",
10
+ "@o3co/auth-provider-oauth": "0.14.0",
11
+ "@o3co/auth-provider-oauth-token-exchange": "0.14.0",
12
+ "@o3co/auth-provider-session": "0.14.0",
13
+ "@o3co/auth-provider-foundation": "0.14.0",
14
+ "@o3co/auth-provider-redis": "0.14.0",
15
+ "@o3co/auth-provider-webauthn": "0.14.0"
15
16
  }