@o3co/create-auth-provider 0.13.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@o3co/create-auth-provider",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Scaffold a new o3co/auth.provider project from the standalone template",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -28,7 +28,7 @@
28
28
  "node": ">=22.0.0"
29
29
  },
30
30
  "devDependencies": {
31
- "@types/node": "^26.5.0",
31
+ "@types/node": "^26.5.1",
32
32
  "vitest": "^5.0.0"
33
33
  },
34
34
  "scripts": {
@@ -72,6 +72,11 @@ SESSION_NAME=auth.sid
72
72
 
73
73
  # Optional
74
74
  # HTTP_PORT=3000
75
+ # OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN=3600
76
+ # The most a token-exchange `expires_in` can obtain; unset = the default.
77
+ # OAUTH_ACCESS_TOKEN_MAX_EXPIRES_IN=3600
78
+ # DEPRECATED alias of OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN, read only while
79
+ # that one is unset.
75
80
  # OAUTH_ACCESS_TOKEN_EXPIRES_IN=3600
76
81
  # OAUTH_REFRESH_TOKEN_EXPIRES_IN=86400
77
82
  # SESSION_MAX_AGE=3600000
@@ -103,7 +103,7 @@ CMD ["node", "dist/app.mjs"]
103
103
  #############################################
104
104
  FROM builder AS test
105
105
 
106
- COPY --chown=node:node vitest.config.mts ./
106
+ COPY --chown=node:node vitest.config.mts vitest.supertest-loopback.mts ./
107
107
  COPY --chown=node:node tests/ ./tests/
108
108
  # #512: the suite reads these from the project root (scaffold-assets,
109
109
  # documented-env-overrides, smoke). The builder stage brings only what the
@@ -79,13 +79,18 @@ openssl pkey -in jwt-private.pem -pubout -out jwt-public.pem
79
79
 
80
80
  | 変数 | デフォルト | 説明 |
81
81
  |---|---|---|
82
- | `OAUTH_ACCESS_TOKEN_EXPIRES_IN` | `3600` | アクセストークンの有効期間(秒)。正の整数、上限は 1 年(`31536000`)。 |
82
+ | `OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN` | `3600` | リクエストが有効期間を指定しないときに全グラントが発行するアクセストークンの有効期間(秒)。正の整数、上限は 1 年(`31536000`)。 |
83
+ | `OAUTH_ACCESS_TOKEN_MAX_EXPIRES_IN` | デフォルトと同じ | token exchange リクエストの `expires_in` パラメータで得られる上限。超えるリクエストはこの値に切り詰められる。未設定ならデフォルトと同じで、設定しない限り延長されない。デフォルトがこれを超えると両キーを名指しして起動失敗。オフラインで検証するリソースサーバーに対し、交換で発行されたトークンが失効後も通用し得る期間の上限でもある。 |
84
+ | `OAUTH_ACCESS_TOKEN_EXPIRES_IN` | — | `OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN` の**非推奨(deprecated)**エイリアス(config キーでは `oauth.accessToken.expiresIn` が `oauth.accessToken.defaultExpiresIn` のエイリアス)。新しい変数が未設定の間だけ読まれる。値は新しい変数へ移すこと。 |
83
85
  | `OAUTH_REFRESH_TOKEN_EXPIRES_IN` | `86400` | リフレッシュトークンの有効期間(秒)。正の整数、上限は 1 年(`31536000`)。 |
84
86
 
85
87
  これらを空文字で export すると fallback ではなく起動失敗になる: HOCON は `FOO=` を
86
88
  `""` に解決し、それが `0` に coerce され、有効期間 0 は「発行時点で期限切れ」の
87
89
  トークンを作るため。
88
90
 
91
+ `expires_in` リクエストパラメータを読むのは token exchange(RFC 8693)だけで、
92
+ 他のグラントはそれを無視してデフォルトを発行する。
93
+
89
94
  ### グラントタイプ
90
95
 
91
96
  | 変数 | デフォルト | 説明 |
@@ -113,6 +113,7 @@ Other multi-replica considerations covered by the default modules:
113
113
  - The user-session stores switch on `userSessionStores.adapter = "redis"` (`USER_SESSION_STORES_ADAPTER`), which wires `redisSessionStoresModule` off the shared ioredis connection — the one `REFRESH_TOKEN_FAMILY_STORE_REDIS_URL` configures.
114
114
  - The authorization-code repository switches on `oauth.code.adapter` (`OAUTH_CODE_ADAPTER`); the template ships `"redis"`, on that same connection. The older `repositories.code.type = "redis"` (`CLIENT_CODE_TYPE`) switch is still honoured with a deprecation warning at boot. `CLIENT_CODE_ENDPOINT_URI` (`repositories.code.redis.endpointUri`) is still bound by `config/application.conf` but nothing reads it: the Redis code repository has run on the shared connection since the `{ endpointUri }` builder shape was retired, so there is one Redis URL for every adapter and that variable is legacy.
115
115
  - The replay seen-set — the `jti` single-use record behind `private_key_jwt` client authentication (#484) — switches on `replaySeenSet.adapter` (`REPLAY_SEEN_SET_ADAPTER`); the template ships `"redis"` on the shared connection, and `memory` is refused under `DEPLOYMENT_MODE=multi` because a captured client assertion would replay once per replica.
116
+ - The consent step for clients that are not first-party (#527) switches on `consentStore.adapter` (`CONSENT_STORE_ADAPTER`). It is off (`none`) by default; `memory` is refused under `DEPLOYMENT_MODE=multi`, because a consent granted on one replica would be asked for again on every other and a consent page's parked request would be unknown to the replica that receives the answer. `redis` (#561) keeps both on the shared connection. See [Consent Store](#consent-store).
116
117
  - The federation token store defaults to memory. Set `FEDERATION_TOKEN_STORE_TYPE=redis` (`federationTokenStore.type = "redis"`) and supply `REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY` — 32 bytes, base64-encoded (`openssl rand -base64 32`); the store encrypts the upstream refresh tokens it holds. It shares the ioredis socket configured by `REFRESH_TOKEN_FAMILY_STORE_REDIS_URL`. See [Federation Token Store](#federation-token-store).
117
118
 
118
119
  ## Usage
@@ -210,13 +211,18 @@ every relying party must be handed the shared secret — which also lets it
210
211
 
211
212
  | Variable | Default | Description |
212
213
  |---|---|---|
213
- | `OAUTH_ACCESS_TOKEN_EXPIRES_IN` | `3600` | Access token lifetime in seconds. Whole positive number, at most one year (`31536000`). |
214
+ | `OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN` | `3600` | Access token lifetime in seconds that every grant mints when the request asks for none. Whole positive number, at most one year (`31536000`). |
215
+ | `OAUTH_ACCESS_TOKEN_MAX_EXPIRES_IN` | the default | The most a token-exchange request's `expires_in` parameter can obtain; a larger request is clamped to it. Unset means the default, so no token is extended unless you set this. A default above it is a boot failure naming both keys. It also bounds how long an exchanged token outlives a revocation at a resource server that validates it offline. |
216
+ | `OAUTH_ACCESS_TOKEN_EXPIRES_IN` | — | **Deprecated** alias of `OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN` (config key `oauth.accessToken.expiresIn`, of `oauth.accessToken.defaultExpiresIn`), read only while the new one is unset. Move the value to the new variable. |
214
217
  | `OAUTH_REFRESH_TOKEN_EXPIRES_IN` | `86400` | Refresh token lifetime in seconds. Whole positive number, at most one year (`31536000`). |
215
218
 
216
219
  Exporting one of these as an empty string is a boot failure, not a fallback:
217
220
  HOCON resolves `FOO=` to `""`, which coerces to `0`, and a zero lifetime mints
218
221
  tokens that have already expired.
219
222
 
223
+ Only token exchange (RFC 8693) reads an `expires_in` request parameter; every
224
+ other grant ignores it and mints the default.
225
+
220
226
  ### Grant Types
221
227
 
222
228
  | Variable | Default | Description |
@@ -417,7 +423,6 @@ needs a key.
417
423
  | Variable | Default | Description |
418
424
  |---|---|---|
419
425
  | `FEDERATION_TOKEN_STORE_TYPE` | `memory` | Federation token store backend: `memory` or `redis` |
420
- | `CONSENT_STORE_ADAPTER` | `none` | Consent store for clients that are not first-party (#527): `none` (such clients are refused) or `memory` (single replica) |
421
426
  | `REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY` | — | AES-256-GCM key for records at rest: 32 bytes, base64-encoded (`openssl rand -base64 32`). **Required** with `redis` unless the mode below is `allow-plaintext` |
422
427
  | `REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_MODE` | `required` | `required` or `allow-plaintext`. Plaintext is refused when the config was selected by a production/staging environment (`CONFIG_ENV` or `NODE_ENV`) and under `DEPLOYMENT_MODE=multi` in any environment, unless `FEDERATION_TOKENS_ALLOW_INSECURE=1` is also set — development only |
423
428
 
@@ -425,6 +430,19 @@ needs a key.
425
430
  `scanFallback` migration flag live under `redisFederationTokenStore` in a
426
431
  config layer rather than behind an environment variable.
427
432
 
433
+ ### Consent Store
434
+
435
+ Where an end-user's consent to a client that is not first-party is recorded,
436
+ together with the `/authorize` request parked while the consent page asks
437
+ (#527, #552). One switch wires both. `none` by default: such clients are
438
+ refused and only first-party clients are served. `memory` forks per replica and
439
+ is refused under `DEPLOYMENT_MODE=multi`; `redis` (#561) shares both over the
440
+ socket configured by `REFRESH_TOKEN_FAMILY_STORE_REDIS_URL`.
441
+
442
+ | Variable | Default | Description |
443
+ |---|---|---|
444
+ | `CONSENT_STORE_ADAPTER` | `none` | Consent store for clients that are not first-party (#527): `none` (such clients are refused), `memory` (single replica) or `redis` (shared, #561) |
445
+
428
446
  ### Redis Namespacing
429
447
 
430
448
  For multi-tenant Redis clusters, set deployment-specific key prefixes so two
@@ -436,9 +454,10 @@ auth.provider instances cannot collide in the same database:
436
454
  | `REFRESH_TOKEN_FAMILY_STORE_KEY_PREFIX` | `rtfam:` | Prefix for refresh-token family records. |
437
455
  | `CLIENT_CODE_KEY_PREFIX` | `oauth:code:` | Prefix for OAuth authorization codes. |
438
456
  | `REDIS_FEDERATION_TOKEN_STORE_KEY_PREFIX` | `ft:` | Prefix for federation token records, their per-session index and their lock keys. |
457
+ | `REDIS_CONSENT_STORE_KEY_PREFIX` | `consent:` | Prefix for consent records and for parked consent requests with their per-session index (`CONSENT_STORE_ADAPTER=redis`, #561). |
439
458
 
440
459
  Use values that include the deployment name, for example `tenant-a:ss:`,
441
- `tenant-a:rtfam:`, `tenant-a:code:`, and `tenant-a:ft:`.
460
+ `tenant-a:rtfam:`, `tenant-a:code:`, `tenant-a:ft:`, and `tenant-a:consent:`.
442
461
 
443
462
  ### Endpoints
444
463
 
@@ -162,7 +162,10 @@ replaySeenSet {
162
162
  # #527: where consent to a client that is not first-party is recorded.
163
163
  # "none" (the default) wires no store and such clients are refused at
164
164
  # /authorize; "memory" is single-replica only (refused under
165
- # DEPLOYMENT_MODE=multi by name).
165
+ # DEPLOYMENT_MODE=multi by name); "redis" (#561) keeps the consent records and
166
+ # the requests parked while the consent page asks on the shared ioredis socket,
167
+ # and is what a deployment of more than one replica selects. Its key namespace
168
+ # is REDIS_CONSENT_STORE_KEY_PREFIX (default "consent:").
166
169
  consentStore {
167
170
  adapter = "none"
168
171
  adapter = ${?CONSENT_STORE_ADAPTER}
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@o3co/auth-provider-standalone",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "engines": {
@@ -41,7 +41,7 @@
41
41
  "devDependencies": {
42
42
  "@types/express": "^5.0.6",
43
43
  "@types/express-session": "^1",
44
- "@types/node": "^26.5.0",
44
+ "@types/node": "^26.5.1",
45
45
  "@types/supertest": "^7.2.1",
46
46
  "supertest": "^7.1.0",
47
47
  "tsx": "^4.23.13",
@@ -0,0 +1,101 @@
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
+ * `oauth.accessToken.expiresIn` / OAUTH_ACCESS_TOKEN_EXPIRES_IN is a deprecated
19
+ * alias of `defaultExpiresIn`, and the composition says so once — the OR-9
20
+ * shape `repositories.code.type` already has.
21
+ *
22
+ * The line has to be exact in both directions. Core's `reference.conf` keeps
23
+ * the shipped lifetime on the deprecated key, so "the alias supplied the
24
+ * default" describes every deployment that set nothing; warning on that would
25
+ * train operators to ignore the line. Only an override of the old key is
26
+ * something to move.
27
+ */
28
+
29
+ import { fileURLToPath } from "node:url";
30
+ import { type AppConfig, AppConfigSchema } from "@o3co/auth-provider-core";
31
+ import { parseFile } from "@o3co/ts.hocon";
32
+ import { validate } from "@o3co/ts.hocon/zod";
33
+ import { afterEach, describe, expect, it, vi } from "vitest";
34
+ import { buildModules } from "../buildModules.mjs";
35
+ import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "../configPath.mjs";
36
+
37
+ const configDir = fileURLToPath(new URL("../../config", import.meta.url));
38
+
39
+ const REQUIRED_ENV = {
40
+ OAUTH_JWT_SECRET: "access-token-alias.at-least-32-bytes.ok",
41
+ OAUTH_JWT_ISSUER: "https://auth.test",
42
+ SESSION_SECRET: "access-token-alias-session.at-least-32-bytes.ok",
43
+ };
44
+
45
+ /** The shipped layers, resolved the way `app.mts` resolves them. */
46
+ function loadShipped(env: Record<string, string> = {}): AppConfig {
47
+ const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "development");
48
+ const resolvedEnv = { ...REQUIRED_ENV, ...env };
49
+ return validate(
50
+ parseFile(envConfPath, { env: resolvedEnv })
51
+ .withFallback(parseFile(applicationConfPath, { env: resolvedEnv }))
52
+ .withFallback(parseFile(resolveLibraryReferenceConfPath(), { env: resolvedEnv })),
53
+ AppConfigSchema,
54
+ );
55
+ }
56
+
57
+ /** Every console warning `buildModules` emits about the deprecated key. */
58
+ function aliasWarnings(config: AppConfig): string[] {
59
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
60
+ buildModules(config);
61
+ return warn.mock.calls
62
+ .map((call) => String(call[0]))
63
+ .filter((message) => message.includes("oauth.accessToken.expiresIn"));
64
+ }
65
+
66
+ describe("the deprecated access-token lifetime alias", () => {
67
+ afterEach(() => {
68
+ vi.restoreAllMocks();
69
+ });
70
+
71
+ it("says nothing for a deployment that sets no lifetime at all", () => {
72
+ // Also what keeps the template's idea of "the shipped value" honest: it
73
+ // is measured against core's real `reference.conf`, so a change to the
74
+ // shipped literal fails here rather than warning every deployment.
75
+ expect(aliasWarnings(loadShipped())).toEqual([]);
76
+ });
77
+
78
+ it("says nothing for a deployment on the new variable", () => {
79
+ expect(aliasWarnings(loadShipped({ OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN: "900" }))).toEqual(
80
+ [],
81
+ );
82
+ });
83
+
84
+ it("warns once when OAUTH_ACCESS_TOKEN_EXPIRES_IN still decides the default", () => {
85
+ const warnings = aliasWarnings(loadShipped({ OAUTH_ACCESS_TOKEN_EXPIRES_IN: "900" }));
86
+ expect(warnings).toHaveLength(1);
87
+ expect(warnings[0]).toMatch(/deprecated/);
88
+ expect(warnings[0]).toMatch(/oauth\.accessToken\.defaultExpiresIn/);
89
+ expect(warnings[0]).toMatch(/OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN/);
90
+ });
91
+
92
+ it("warns for a hand-built configuration that overrides only the old key", () => {
93
+ const config = loadShipped();
94
+ expect(
95
+ aliasWarnings({
96
+ ...config,
97
+ oauth: { ...config.oauth, accessToken: { expiresIn: 900 } },
98
+ }),
99
+ ).toHaveLength(1);
100
+ });
101
+ });
@@ -17,7 +17,11 @@
17
17
  import { existsSync, readFileSync } from "node:fs";
18
18
  import { join } from "node:path";
19
19
  import { fileURLToPath } from "node:url";
20
- import { type AppConfig, AppConfigSchema } from "@o3co/auth-provider-core";
20
+ import {
21
+ type AppConfig,
22
+ AppConfigSchema,
23
+ resolveAccessTokenLifetime,
24
+ } from "@o3co/auth-provider-core";
21
25
  import { parseFile } from "@o3co/ts.hocon";
22
26
  import { validate } from "@o3co/ts.hocon/zod";
23
27
  import { describe, expect, it } from "vitest";
@@ -83,6 +87,10 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
83
87
  OAUTH_JWT_LEGACY_TYP_ACCEPT: "true",
84
88
 
85
89
  // --- oauth tokens / policy ----------------------------------------
90
+ OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN: "900",
91
+ OAUTH_ACCESS_TOKEN_MAX_EXPIRES_IN: "7200",
92
+ // Deprecated alias of the default. Exported alongside the new variable on
93
+ // purpose, with a different value: the new variable must win.
86
94
  OAUTH_ACCESS_TOKEN_EXPIRES_IN: "3600",
87
95
  OAUTH_REFRESH_TOKEN_EXPIRES_IN: "86400",
88
96
  OAUTH_REFRESH_TOKEN_UNKNOWN_FAMILY_POLICY: "reject",
@@ -151,7 +159,9 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
151
159
  ACCESS_TOKEN_DENYLIST_ADAPTER: "redis",
152
160
  // #484: the replay seen-set behind private_key_jwt client authentication.
153
161
  REPLAY_SEEN_SET_ADAPTER: "redis",
154
- CONSENT_STORE_ADAPTER: "none",
162
+ // #561: the consent stores' shared backend and its namespace.
163
+ CONSENT_STORE_ADAPTER: "redis",
164
+ REDIS_CONSENT_STORE_KEY_PREFIX: "tenant-a:consent:",
155
165
  REDIS_ACCESS_TOKEN_DENYLIST_KEY_PREFIX: "atdeny:",
156
166
  REDIS_SESSION_STORES_KEY_PREFIX: "ss:",
157
167
  REFRESH_TOKEN_FAMILY_STORE_KEY_PREFIX: "rtfam:",
@@ -320,11 +330,19 @@ describe("#288: the shipped config boots with every documented override supplied
320
330
  expect(config.http.port).toBe(3000);
321
331
  expect(config.http.readinessTimeoutMs).toBe(1500);
322
332
  expect(config.http.trustProxy).toEqual(["10.0.0.0/8", "loopback"]);
323
- expect(config.oauth.accessToken.expiresIn).toBe(3600);
333
+ // The new default wins over the deprecated variable, and the parsed
334
+ // config mirrors it onto the old key for readers that predate the split.
335
+ expect(resolveAccessTokenLifetime(config)).toEqual({
336
+ defaultExpiresIn: 900,
337
+ maxExpiresIn: 7200,
338
+ });
339
+ expect(config.oauth.accessToken.expiresIn).toBe(900);
324
340
  expect(config.oauth.refreshToken.expiresIn).toBe(86400);
325
341
  expect(config.session.maxAge).toBe(3600000);
326
342
  expect(config.session.csrf?.ttlSeconds).toBe(7200);
327
343
  expect(config.oauth.nonce?.maxLength).toBe(256);
344
+ expect(config.consentStore?.adapter).toBe("redis");
345
+ expect(config.redisConsentStore?.keyPrefix).toBe("tenant-a:consent:");
328
346
  // #529: the comma-separated lists become lists, trimmed; the numbers, numbers.
329
347
  expect(config.oauth.clientIdMetadataDocuments).toEqual({
330
348
  enabled: true,
@@ -508,6 +526,26 @@ describe("#288: the shipped config boots with every documented override supplied
508
526
  ).toThrow(/allowUnmarkedClients/);
509
527
  });
510
528
 
529
+ it("refuses an access-token default above the max, naming both keys", () => {
530
+ expect(() =>
531
+ buildResolvedConfig({
532
+ ...DOCUMENTED_ENV,
533
+ OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN: "7200",
534
+ OAUTH_ACCESS_TOKEN_MAX_EXPIRES_IN: "3600",
535
+ }),
536
+ ).toThrow(/defaultExpiresIn.*maxExpiresIn/s);
537
+ });
538
+
539
+ for (const name of [
540
+ "OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN",
541
+ "OAUTH_ACCESS_TOKEN_MAX_EXPIRES_IN",
542
+ "OAUTH_ACCESS_TOKEN_EXPIRES_IN",
543
+ ]) {
544
+ it(`refuses an empty ${name} rather than minting already-expired tokens`, () => {
545
+ expect(() => buildResolvedConfig({ ...DOCUMENTED_ENV, [name]: "" })).toThrow();
546
+ });
547
+ }
548
+
511
549
  it("still refuses an empty SESSION_CSRF_TTL_SECONDS (#272)", () => {
512
550
  // Pinned alongside the boolean cases because it is the same trap
513
551
  // read from the other side: for a *number*, empty means fail loudly.
@@ -37,6 +37,18 @@ function buildApp(probes: Parameters<ReturnType<typeof createMetrics>["route"]>[
37
37
  return app;
38
38
  }
39
39
 
40
+ /**
41
+ * Starts `app` on 127.0.0.1, the address the raw-socket tests dial. A hostless
42
+ * `listen(0)` binds `[::]`, and on macOS the port it gets can be one another
43
+ * process holds on 127.0.0.1 — the request then reaches that process and can
44
+ * hang until the test times out (#556).
45
+ */
46
+ function listenOnLoopback(app: express.Express): Promise<ReturnType<express.Express["listen"]>> {
47
+ return new Promise((resolve) => {
48
+ const server = app.listen(0, "127.0.0.1", () => resolve(server));
49
+ });
50
+ }
51
+
40
52
  describe("GET /metrics", () => {
41
53
  it("serves the Prometheus text exposition format", async () => {
42
54
  const res = await request(buildApp()).get("/metrics");
@@ -95,7 +107,7 @@ describe("GET /metrics", () => {
95
107
  // route label, reachable without any access to /metrics. Sent over a real
96
108
  // socket because supertest cannot express a non-standard method.
97
109
  const app = buildApp();
98
- const server = app.listen(0);
110
+ const server = await listenOnLoopback(app);
99
111
  const port = (server.address() as { port: number }).port;
100
112
  try {
101
113
  for (const method of ["FOO", "M000001", "PURGE"]) {
@@ -135,7 +147,7 @@ describe("GET /metrics", () => {
135
147
  void res;
136
148
  });
137
149
 
138
- const server = app.listen(0);
150
+ const server = await listenOnLoopback(app);
139
151
  const port = (server.address() as { port: number }).port;
140
152
  try {
141
153
  const controller = new AbortController();
@@ -60,6 +60,7 @@ import {
60
60
  defineModule,
61
61
  InMemoryClientRepository,
62
62
  InMemoryUserRepository,
63
+ memoryRefreshTokenFamilyStoreModule,
63
64
  registerBuiltinKeyStores,
64
65
  replicaUnsafeReason,
65
66
  } from "@o3co/auth-provider-core";
@@ -162,6 +163,9 @@ const ALL_REDIS_ENV: Readonly<Record<string, string>> = {
162
163
  REPLAY_SEEN_SET_ADAPTER: "redis",
163
164
  FEDERATION_TOKEN_STORE_TYPE: "redis",
164
165
  REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY: ENCRYPTION_KEY,
166
+ // #561: the consent step for clients that are not first-party, on the
167
+ // shared store — the one switch value `multi` accepts besides `none`.
168
+ CONSENT_STORE_ADAPTER: "redis",
165
169
  };
166
170
 
167
171
  function resolveConfig(env: Record<string, string>): AppConfig {
@@ -397,6 +401,60 @@ describe('#456: federationTokenStore.type = "redis" in the standalone', () => {
397
401
  });
398
402
  });
399
403
 
404
+ describe('#561: consentStore.adapter = "redis" in the standalone', () => {
405
+ let handleRef: Awaited<ReturnType<typeof boot>> | undefined;
406
+
407
+ afterEach(async () => {
408
+ await handleRef?.dispose();
409
+ handleRef = undefined;
410
+ });
411
+
412
+ it("selects the Redis consent module, providing both slots, and not the memory one", () => {
413
+ const modules = modulesFor(resolveConfig(ALL_REDIS_ENV));
414
+ const names = modules.map((m) => m.name);
415
+ expect(names).toContain("redis-consent-store");
416
+ expect(names).not.toContain("core-consent-store-memory");
417
+ // Exactly one provider for each slot: the consent step refuses a
418
+ // composition with one and not the other, and two would collide.
419
+ for (const slot of ["consentStore", "pendingConsentStore"]) {
420
+ const providers = modules.filter((m) => Object.keys(m.provides ?? {}).includes(slot));
421
+ expect(
422
+ providers.map((m) => m.name),
423
+ slot,
424
+ ).toEqual(["redis-consent-store"]);
425
+ }
426
+ });
427
+
428
+ it("pulls the shared clients module in for the consent stores alone", () => {
429
+ // Every other store on memory, single replica: the consent module still
430
+ // needs its two client slots, which only the shared clients module
431
+ // provides — the #439 shape otherwise.
432
+ const config = resolveConfig({
433
+ ...ALL_REDIS_ENV,
434
+ DEPLOYMENT_MODE: "single",
435
+ USER_SESSION_STORES_ADAPTER: "memory",
436
+ OAUTH_CODE_ADAPTER: "memory",
437
+ RATE_LIMITER_ADAPTER: "memory",
438
+ ACCESS_TOKEN_DENYLIST_ADAPTER: "memory",
439
+ REPLAY_SEEN_SET_ADAPTER: "memory",
440
+ FEDERATION_TOKEN_STORE_TYPE: "memory",
441
+ });
442
+ const names = buildModules(config, {
443
+ keyStoreModule: testKeyStoreModule,
444
+ repositoriesModule: testRepositoriesModule,
445
+ refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
446
+ }).map((m) => m.name);
447
+ expect(names).toContain("redis-consent-store");
448
+ expect(names).toContain("standalone:redis-clients");
449
+ });
450
+
451
+ it("boots under DEPLOYMENT_MODE=multi with both slots resolved to the Redis adapters", async () => {
452
+ handleRef = await boot(resolveConfig(ALL_REDIS_ENV));
453
+ expect(handleRef.components.consentStore?.kind).toBe("redis");
454
+ expect(handleRef.components.pendingConsentStore?.kind).toBe("redis");
455
+ });
456
+ });
457
+
400
458
  describe("#473: the Redis federation store's plaintext guard, booted from the shipped config", () => {
401
459
  let handleRef: Awaited<ReturnType<typeof boot>> | undefined;
402
460
  let warnSpy: ReturnType<typeof vi.spyOn>;
@@ -324,6 +324,29 @@ describe("#512 — the shipped suite is green outside this repository", () => {
324
324
  ).toBe(false);
325
325
  }
326
326
  });
327
+
328
+ it("ships every setup file vitest.config.mts loads, into the `test` image as well (#556)", async () => {
329
+ // vitest refuses to start when a `setupFiles` entry is missing, so a setup
330
+ // file the image never copied fails the whole of `make test`, not one test.
331
+ const configPath = fileURLToPath(new URL("../../vitest.config.mts", import.meta.url));
332
+ const { default: config } = (await import(configPath)) as {
333
+ default: { test?: { setupFiles?: string | readonly string[] } };
334
+ };
335
+ const setupFiles = [config.test?.setupFiles ?? []].flat();
336
+ expect(setupFiles, "the #556 supertest loopback guard is not wired in").not.toHaveLength(0);
337
+
338
+ const copied = copiedIntoStage(read("/Dockerfile"), "test");
339
+ const dockerignore = read("/.dockerignore");
340
+ for (const entry of setupFiles) {
341
+ const file = entry.replace(/^\.\//, "");
342
+ expect(existsSync(`${standaloneDir}${file}`), `${file} does not exist`).toBe(true);
343
+ expect(copied.has(file), `${file} is not copied into the test image`).toBe(true);
344
+ expect(
345
+ dockerignoreExcludes(dockerignore, file),
346
+ `${file} is excluded from the build context`,
347
+ ).toBe(false);
348
+ }
349
+ });
327
350
  });
328
351
 
329
352
  /**
@@ -486,6 +486,7 @@ describe("standalone smoke test", () => {
486
486
  userSessionStores: { adapter: "redis" as const },
487
487
  rateLimiter: { adapter: "redis" as const },
488
488
  accessTokenDenylist: { adapter: "redis" as const },
489
+ consentStore: { adapter: "redis" as const },
489
490
  oauth: { ...config.oauth, code: { adapter: "redis" as const } },
490
491
  };
491
492
  // No `refreshTokenFamilyModules` override: the default is the Redis
@@ -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
+ });
@@ -34,6 +34,7 @@ import {
34
34
  import {
35
35
  redisAccessTokenDenylistModule,
36
36
  redisCodeRepositoryModule,
37
+ redisConsentStoreModule,
37
38
  redisFederationTokenStoreModuleFor,
38
39
  redisRateLimiterModule,
39
40
  redisRefreshTokenFamilyStoreModule,
@@ -102,6 +103,15 @@ export interface BuildModulesOverrides {
102
103
  readonly auditSinkModule?: Module;
103
104
  }
104
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
+
105
115
  /**
106
116
  * Compose the standalone v0.5.0 module list from `config`. Splitting this
107
117
  * out of `app.mts` keeps the composition root testable: a smoke test can
@@ -156,6 +166,7 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
156
166
  // first-party. `"none"` (the default) wires nothing — such clients are
157
167
  // refused, as before — so a first-party-only deployment pays nothing and
158
168
  // a multi-replica one is not handed a memory store it cannot run.
169
+ // `"redis"` (#561) is the store such a deployment selects.
159
170
  const consentStoreAdapter = config.consentStore?.adapter ?? "none";
160
171
  // #456: adapter switch for the federation token store. `"memory"` by
161
172
  // default, the template's local-dev shape; `"redis"` mounts
@@ -184,6 +195,26 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
184
195
  codeRepositoryAdapter = "memory";
185
196
  }
186
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
+
187
218
  const refreshTokenFamilyModules: readonly Module[] = overrides.refreshTokenFamilyModules ?? [
188
219
  redisRefreshTokenFamilyStoreModule,
189
220
  ];
@@ -202,6 +233,7 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
202
233
  codeRepositoryAdapter === "redis" ||
203
234
  accessTokenDenylistAdapter === "redis" ||
204
235
  replaySeenSetAdapter === "redis" ||
236
+ consentStoreAdapter === "redis" ||
205
237
  federationTokenStoreAdapter === "redis";
206
238
 
207
239
  // The four user-session stores switch on `userSessionStores.adapter`; the
@@ -237,10 +269,17 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
237
269
  ? [redisAccessTokenDenylistModule]
238
270
  : [memoryAccessTokenDenylistModule];
239
271
 
240
- // #527: opt-in, memory only for now. The module declares itself
241
- // replica-unsafe, so `deployment.mode = "multi"` refuses it by name.
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.
242
277
  const consentStoreModules: Module[] =
243
- consentStoreAdapter === "memory" ? [memoryConsentStoreModule] : [];
278
+ consentStoreAdapter === "redis"
279
+ ? [redisConsentStoreModule]
280
+ : consentStoreAdapter === "memory"
281
+ ? [memoryConsentStoreModule]
282
+ : [];
244
283
 
245
284
  // #455 / #456: mutually-exclusive federation-token-store pair, the same
246
285
  // shape as the session stores. One module per adapter, so the memory one
@@ -314,8 +353,8 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
314
353
  // (single-instance dev). Always wired — see the switch above.
315
354
  ...accessTokenDenylistModules,
316
355
  ...replaySeenSetModules,
317
- // Consent records for clients that are not first-party: memory, or
318
- // nothing (#527).
356
+ // Consent records for clients that are not first-party: redis
357
+ // (multi-replica, #561), memory (single-instance), or nothing (#527).
319
358
  ...consentStoreModules,
320
359
  // RT family store: redis by default (closes OR-1); override path
321
360
  // swaps to `[memoryRefreshTokenFamilyStoreModule]` for unit tests.
@@ -361,11 +361,12 @@ export const storesModule: Module = defineModule({
361
361
 
362
362
  /**
363
363
  * Shared ioredis clients module — opens ONE long-lived ioredis connection
364
- * per replica, wraps it via `makeIoredisClients()` (returns 13 typed
364
+ * per replica, wraps it via `makeIoredisClients()` (returns 16 typed
365
365
  * per-purpose clients), and exposes the slots consumed by the standalone's
366
366
  * Redis-backed adapters (refresh-token-family + 4 user-session stores +
367
367
  * subject-level revocation index + watermark + rate limiter + code
368
- * repository + access-token denylist).
368
+ * repository + access-token denylist + replay seen-set + federation token
369
+ * store + device-code store + the consent stores).
369
370
  *
370
371
  * Per F4 PR1 (D-2 v2) + Wave 5d unification: the previous design opened a
371
372
  * separate ioredis socket per Redis-backed module (3+ sockets per replica).
@@ -504,6 +505,23 @@ export const standaloneRedisClientsModule: Module = defineModule({
504
505
  return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
505
506
  .deviceCodeStoreClient;
506
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
+ },
507
525
  // #456: the federation-token-store client, off the same shared socket.
508
526
  // `redisFederationTokenStoreModule` requires it, and nothing provided
509
527
  // it — which is why the Redis branch of the federation store could not
@@ -20,5 +20,11 @@ export default defineConfig({
20
20
  // monorepo the same packages are symlinks to source and are inlined
21
21
  // anyway; this makes the two layouts behave the same.
22
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"],
23
29
  },
24
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,16 +1,16 @@
1
1
  {
2
- "@o3co/auth-provider-core": "0.13.0",
3
- "@o3co/auth-provider-device-grant": "0.13.0",
4
- "@o3co/auth-provider-dpop": "0.13.0",
5
- "@o3co/auth-provider-mtls": "0.13.0",
6
- "@o3co/auth-provider-federation-apple": "0.13.0",
7
- "@o3co/auth-provider-federation-google": "0.13.0",
8
- "@o3co/auth-provider-federation-github": "0.13.0",
9
- "@o3co/auth-provider-federation-oidc": "0.13.0",
10
- "@o3co/auth-provider-oauth": "0.13.0",
11
- "@o3co/auth-provider-oauth-token-exchange": "0.13.0",
12
- "@o3co/auth-provider-session": "0.13.0",
13
- "@o3co/auth-provider-foundation": "0.13.0",
14
- "@o3co/auth-provider-redis": "0.13.0",
15
- "@o3co/auth-provider-webauthn": "0.13.0"
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"
16
16
  }