@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.
- package/package.json +3 -3
- package/templates/standalone/.dockerignore +4 -3
- package/templates/standalone/.env.example +5 -0
- package/templates/standalone/Dockerfile +10 -3
- package/templates/standalone/README.ja.md +6 -1
- package/templates/standalone/README.md +83 -2
- package/templates/standalone/config/application.conf +88 -0
- package/templates/standalone/config/clients.yaml.example +22 -0
- package/templates/standalone/package.json +5 -4
- package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +101 -0
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +89 -6
- package/templates/standalone/src/__tests__/metrics.test.mts +14 -2
- package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +138 -0
- package/templates/standalone/src/__tests__/replica-safety.test.mts +65 -0
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +168 -0
- package/templates/standalone/src/__tests__/smoke.test.mts +2 -1
- package/templates/standalone/src/__tests__/supertest-loopback.test.mts +125 -0
- package/templates/standalone/src/buildModules.mts +86 -2
- package/templates/standalone/src/modules.mts +44 -2
- package/templates/standalone/vitest.config.mts +15 -0
- package/templates/standalone/vitest.supertest-loopback.mts +231 -0
- package/templates/versions.json +14 -13
|
@@ -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 {
|
|
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
|
|
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
|
|
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);
|
package/templates/versions.json
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
{
|
|
2
|
-
"@o3co/auth-provider-core": "0.
|
|
3
|
-
"@o3co/auth-provider-device-grant": "0.
|
|
4
|
-
"@o3co/auth-provider-dpop": "0.
|
|
5
|
-
"@o3co/auth-provider-mtls": "0.
|
|
6
|
-
"@o3co/auth-provider-federation-apple": "0.
|
|
7
|
-
"@o3co/auth-provider-federation-google": "0.
|
|
8
|
-
"@o3co/auth-provider-federation-github": "0.
|
|
9
|
-
"@o3co/auth-provider-
|
|
10
|
-
"@o3co/auth-provider-oauth
|
|
11
|
-
"@o3co/auth-provider-
|
|
12
|
-
"@o3co/auth-provider-
|
|
13
|
-
"@o3co/auth-provider-
|
|
14
|
-
"@o3co/auth-provider-
|
|
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
|
}
|