@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
|
@@ -14,9 +14,14 @@
|
|
|
14
14
|
* limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { readFileSync } from "node:fs";
|
|
17
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
18
19
|
import { fileURLToPath } from "node:url";
|
|
19
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
type AppConfig,
|
|
22
|
+
AppConfigSchema,
|
|
23
|
+
resolveAccessTokenLifetime,
|
|
24
|
+
} from "@o3co/auth-provider-core";
|
|
20
25
|
import { parseFile } from "@o3co/ts.hocon";
|
|
21
26
|
import { validate } from "@o3co/ts.hocon/zod";
|
|
22
27
|
import { describe, expect, it } from "vitest";
|
|
@@ -82,6 +87,10 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
|
|
|
82
87
|
OAUTH_JWT_LEGACY_TYP_ACCEPT: "true",
|
|
83
88
|
|
|
84
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.
|
|
85
94
|
OAUTH_ACCESS_TOKEN_EXPIRES_IN: "3600",
|
|
86
95
|
OAUTH_REFRESH_TOKEN_EXPIRES_IN: "86400",
|
|
87
96
|
OAUTH_REFRESH_TOKEN_UNKNOWN_FAMILY_POLICY: "reject",
|
|
@@ -95,6 +104,18 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
|
|
|
95
104
|
OAUTH_NONCE_MAX_LENGTH: "256",
|
|
96
105
|
OAUTH_TOKEN_EXCHANGE_MAX_ACTOR_CHAIN_DEPTH: "3",
|
|
97
106
|
OAUTH_RESOURCE_INDICATOR_ENABLED: "true",
|
|
107
|
+
OAUTH_CIMD_ENABLED: "true",
|
|
108
|
+
OAUTH_CIMD_ALLOWED_SCOPES: "read, write",
|
|
109
|
+
OAUTH_CIMD_ALLOWED_AUDIENCES: "https://mcp.example",
|
|
110
|
+
OAUTH_CIMD_ALLOWED_HOSTS: "client.example, .trusted.example",
|
|
111
|
+
OAUTH_CIMD_DENIED_HOSTS: "evil.example",
|
|
112
|
+
OAUTH_CIMD_MAX_BYTES: "8192",
|
|
113
|
+
OAUTH_CIMD_TIMEOUT_MS: "3000",
|
|
114
|
+
OAUTH_CIMD_CACHE_MAX_AGE_MS: "60000",
|
|
115
|
+
OAUTH_CIMD_MAX_CACHE_ENTRIES: "128",
|
|
116
|
+
OAUTH_CIMD_STALE_IF_ERROR_MS: "120000",
|
|
117
|
+
OAUTH_CIMD_NEGATIVE_CACHE_MS: "30000",
|
|
118
|
+
OAUTH_CIMD_MAX_CONCURRENT_FETCHES: "4",
|
|
98
119
|
OAUTH_TOKEN_BINDING_DISPATCH_POLICY: "intent-explicit",
|
|
99
120
|
OAUTH_TOKEN_BINDING_BIND_CONFIDENTIAL_CLIENT_REFRESH_TOKENS: "true",
|
|
100
121
|
|
|
@@ -136,6 +157,11 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
|
|
|
136
157
|
DEPLOYMENT_MODE: "multi",
|
|
137
158
|
USER_SESSION_STORES_ADAPTER: "redis",
|
|
138
159
|
ACCESS_TOKEN_DENYLIST_ADAPTER: "redis",
|
|
160
|
+
// #484: the replay seen-set behind private_key_jwt client authentication.
|
|
161
|
+
REPLAY_SEEN_SET_ADAPTER: "redis",
|
|
162
|
+
// #561: the consent stores' shared backend and its namespace.
|
|
163
|
+
CONSENT_STORE_ADAPTER: "redis",
|
|
164
|
+
REDIS_CONSENT_STORE_KEY_PREFIX: "tenant-a:consent:",
|
|
139
165
|
REDIS_ACCESS_TOKEN_DENYLIST_KEY_PREFIX: "atdeny:",
|
|
140
166
|
REDIS_SESSION_STORES_KEY_PREFIX: "ss:",
|
|
141
167
|
REFRESH_TOKEN_FAMILY_STORE_KEY_PREFIX: "rtfam:",
|
|
@@ -154,6 +180,12 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
|
|
|
154
180
|
FEDERATIONS_GOOGLE_CLIENT_ID: "google-client-id",
|
|
155
181
|
FEDERATIONS_GOOGLE_CLIENT_SECRET: "google-client-secret",
|
|
156
182
|
FEDERATIONS_GOOGLE_CALLBACK_URL: "https://auth.test/session/oauth/federation/google/callback",
|
|
183
|
+
// #524: the generic OIDC federation the template ships disabled.
|
|
184
|
+
FEDERATIONS_OIDC_ENABLED: "true",
|
|
185
|
+
FEDERATIONS_OIDC_ISSUER: "https://idp.test",
|
|
186
|
+
FEDERATIONS_OIDC_CLIENT_ID: "oidc-client-id",
|
|
187
|
+
FEDERATIONS_OIDC_CLIENT_SECRET: "oidc-client-secret",
|
|
188
|
+
FEDERATIONS_OIDC_CALLBACK_URL: "https://auth.test/session/oauth/federation/oidc/callback",
|
|
157
189
|
|
|
158
190
|
// --- repositories -------------------------------------------------
|
|
159
191
|
CLIENT_TYPE: "yaml",
|
|
@@ -162,6 +194,7 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
|
|
|
162
194
|
CLIENT_USER_PATH: "./config/users.yaml",
|
|
163
195
|
CLIENT_USER_AUTHENTICATE_URL: "https://users.example.com/authenticate",
|
|
164
196
|
CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL: "https://users.example.com/authenticate-by-token",
|
|
197
|
+
CLIENT_USER_LINK_FEDERATED_IDENTITY_URL: "https://users.example.com/link-federated-identity",
|
|
165
198
|
CLIENT_USER_TIMEOUT: "5000",
|
|
166
199
|
CLIENT_USER_MAX_RESPONSE_BYTES: "1048576",
|
|
167
200
|
CLIENT_CODE_TYPE: "redis",
|
|
@@ -172,6 +205,7 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
|
|
|
172
205
|
|
|
173
206
|
// --- endpoints ----------------------------------------------------
|
|
174
207
|
ENDPOINTS_LOGIN_URL: "/login",
|
|
208
|
+
ENDPOINTS_CONSENT_URL: "/consent",
|
|
175
209
|
|
|
176
210
|
// --- cors ---------------------------------------------------------
|
|
177
211
|
// #500: a list, in the only shape an environment variable can carry one.
|
|
@@ -288,6 +322,7 @@ describe("#288: the shipped config boots with every documented override supplied
|
|
|
288
322
|
expect(config.oauth.requireEmailVerified).toBe(true);
|
|
289
323
|
expect(config.oauth.resourceIndicator?.enabled).toBe(true);
|
|
290
324
|
expect(config.federations.google?.enabled).toBe(true);
|
|
325
|
+
expect(config.federations.oidc?.enabled).toBe(true);
|
|
291
326
|
});
|
|
292
327
|
|
|
293
328
|
it("turns every non-boolean override into its declared type", () => {
|
|
@@ -295,11 +330,34 @@ describe("#288: the shipped config boots with every documented override supplied
|
|
|
295
330
|
expect(config.http.port).toBe(3000);
|
|
296
331
|
expect(config.http.readinessTimeoutMs).toBe(1500);
|
|
297
332
|
expect(config.http.trustProxy).toEqual(["10.0.0.0/8", "loopback"]);
|
|
298
|
-
|
|
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);
|
|
299
340
|
expect(config.oauth.refreshToken.expiresIn).toBe(86400);
|
|
300
341
|
expect(config.session.maxAge).toBe(3600000);
|
|
301
342
|
expect(config.session.csrf?.ttlSeconds).toBe(7200);
|
|
302
343
|
expect(config.oauth.nonce?.maxLength).toBe(256);
|
|
344
|
+
expect(config.consentStore?.adapter).toBe("redis");
|
|
345
|
+
expect(config.redisConsentStore?.keyPrefix).toBe("tenant-a:consent:");
|
|
346
|
+
// #529: the comma-separated lists become lists, trimmed; the numbers, numbers.
|
|
347
|
+
expect(config.oauth.clientIdMetadataDocuments).toEqual({
|
|
348
|
+
enabled: true,
|
|
349
|
+
allowedScopes: ["read", "write"],
|
|
350
|
+
allowedAudiences: ["https://mcp.example"],
|
|
351
|
+
allowedHosts: ["client.example", ".trusted.example"],
|
|
352
|
+
deniedHosts: ["evil.example"],
|
|
353
|
+
maxBytes: 8192,
|
|
354
|
+
timeoutMs: 3000,
|
|
355
|
+
maxCacheEntries: 128,
|
|
356
|
+
staleIfErrorMs: 120000,
|
|
357
|
+
negativeCacheMs: 30000,
|
|
358
|
+
maxConcurrentFetches: 4,
|
|
359
|
+
cacheMaxAgeMs: 60000,
|
|
360
|
+
});
|
|
303
361
|
// #500: a comma-separated string becomes a list of origins, trimmed.
|
|
304
362
|
expect(config.cors?.allowedOrigins).toEqual([
|
|
305
363
|
"https://app.example.com",
|
|
@@ -468,6 +526,26 @@ describe("#288: the shipped config boots with every documented override supplied
|
|
|
468
526
|
).toThrow(/allowUnmarkedClients/);
|
|
469
527
|
});
|
|
470
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
|
+
|
|
471
549
|
it("still refuses an empty SESSION_CSRF_TTL_SECONDS (#272)", () => {
|
|
472
550
|
// Pinned alongside the boolean cases because it is the same trap
|
|
473
551
|
// read from the other side: for a *number*, empty means fail loudly.
|
|
@@ -477,10 +555,15 @@ describe("#288: the shipped config boots with every documented override supplied
|
|
|
477
555
|
});
|
|
478
556
|
});
|
|
479
557
|
|
|
480
|
-
it("resolves against the shipped standalone
|
|
558
|
+
it("resolves against the shipped standalone artifact, not a fixture", () => {
|
|
481
559
|
// Guards the guard: if the paths above ever stop pointing at the real
|
|
482
|
-
// artifact these tests would pass while testing nothing.
|
|
483
|
-
|
|
560
|
+
// artifact these tests would pass while testing nothing. Checked by
|
|
561
|
+
// what the directory holds, not by what it is called: this file ships
|
|
562
|
+
// in every scaffolded project, and none of those lives at
|
|
563
|
+
// `templates/standalone` (#512).
|
|
564
|
+
for (const file of ["config/application.conf", "config/production.conf", "src/app.mts"]) {
|
|
565
|
+
expect(existsSync(join(standaloneDir, file)), file).toBe(true);
|
|
566
|
+
}
|
|
484
567
|
expect(liveSubstitutions().size).toBeGreaterThan(40);
|
|
485
568
|
});
|
|
486
569
|
});
|
|
@@ -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
|
|
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
|
|
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();
|
|
@@ -0,0 +1,138 @@
|
|
|
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
|
+
import type { AppConfig } from "@o3co/auth-provider-core";
|
|
18
|
+
import { makeValidAppConfig } from "@o3co/auth-provider-core/testing";
|
|
19
|
+
import { describe, expect, it } from "vitest";
|
|
20
|
+
import { buildModules } from "../buildModules.mjs";
|
|
21
|
+
import { oidcFederationConfigModule } from "../modules.mjs";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* #524: the scaffold turns every enabled `federations.<name>` of type
|
|
25
|
+
* `oidc` into one instance of the generic OIDC federation module, fed by a
|
|
26
|
+
* single config bridge. A deployment adds an IdP with configuration only.
|
|
27
|
+
*/
|
|
28
|
+
type Factory = (deps: { config: unknown }) => Record<string, unknown>;
|
|
29
|
+
|
|
30
|
+
const bridge = (
|
|
31
|
+
oidcFederationConfigModule as unknown as {
|
|
32
|
+
provides: { oidcFederationConfigs: Factory };
|
|
33
|
+
}
|
|
34
|
+
).provides.oidcFederationConfigs;
|
|
35
|
+
|
|
36
|
+
const okta = {
|
|
37
|
+
issuer: "https://dev-1.okta.test",
|
|
38
|
+
clientId: "okta-client",
|
|
39
|
+
clientSecret: "okta-secret",
|
|
40
|
+
callbackURL: "https://auth.test/session/oauth/federation/okta/callback",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Core's valid fixture, with the federations under test and every adapter on memory. */
|
|
44
|
+
const configWith = (federations: Record<string, unknown>): AppConfig => {
|
|
45
|
+
const base = makeValidAppConfig();
|
|
46
|
+
return {
|
|
47
|
+
...base,
|
|
48
|
+
federations,
|
|
49
|
+
oauth: { ...base.oauth, code: { adapter: "memory" } },
|
|
50
|
+
} as unknown as AppConfig;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
describe("oidcFederationConfigModule (#524)", () => {
|
|
54
|
+
it("has the scaffold's module name and reads config", () => {
|
|
55
|
+
expect(oidcFederationConfigModule.name).toBe("standalone:oidc-federation-config");
|
|
56
|
+
expect(oidcFederationConfigModule.requires).toEqual(["config"]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("reads every enabled oidc section into the slot, keyed by federation name", () => {
|
|
60
|
+
const out = bridge({
|
|
61
|
+
config: {
|
|
62
|
+
federations: {
|
|
63
|
+
okta: { enabled: true, type: "oidc", ...okta },
|
|
64
|
+
keycloak: {
|
|
65
|
+
enabled: true,
|
|
66
|
+
type: "oidc",
|
|
67
|
+
clientUrl: "https://app.test/",
|
|
68
|
+
oidc: {
|
|
69
|
+
issuer: "https://kc.test/realms/dev",
|
|
70
|
+
clientId: "kc-client",
|
|
71
|
+
clientSecret: "kc-secret",
|
|
72
|
+
callbackURL: "https://auth.test/session/oauth/federation/keycloak/callback",
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
google: {
|
|
76
|
+
enabled: true,
|
|
77
|
+
clientId: "g",
|
|
78
|
+
clientSecret: "s",
|
|
79
|
+
callbackURL: "https://auth.test/g",
|
|
80
|
+
},
|
|
81
|
+
paused: { enabled: false, type: "oidc", ...okta },
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
expect(Object.keys(out).sort()).toEqual(["keycloak", "okta"]);
|
|
86
|
+
expect(out.okta).toEqual(okta);
|
|
87
|
+
expect(out.keycloak).toMatchObject({
|
|
88
|
+
issuer: "https://kc.test/realms/dev",
|
|
89
|
+
clientUrl: "https://app.test/",
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("refuses a malformed section at boot, naming the field", () => {
|
|
94
|
+
const { issuer: _dropped, ...noIssuer } = okta;
|
|
95
|
+
expect(() =>
|
|
96
|
+
bridge({ config: { federations: { okta: { enabled: true, type: "oidc", ...noIssuer } } } }),
|
|
97
|
+
).toThrow(/federations\.okta\.issuer/);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("is an empty slot when no section is of type oidc", () => {
|
|
101
|
+
expect(bridge({ config: { federations: { google: { enabled: false } } } })).toEqual({});
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("buildModules gating for OIDC federations (#524)", () => {
|
|
106
|
+
it("lists one federation:oidc:<name> per enabled section, plus the bridge once", () => {
|
|
107
|
+
const names = buildModules(
|
|
108
|
+
configWith({
|
|
109
|
+
okta: { enabled: true, type: "oidc", ...okta },
|
|
110
|
+
keycloak: { enabled: true, type: "oidc", ...okta },
|
|
111
|
+
google: { enabled: false },
|
|
112
|
+
}),
|
|
113
|
+
).map((m) => m.name);
|
|
114
|
+
expect(names).toContain("federation:oidc:okta");
|
|
115
|
+
expect(names).toContain("federation:oidc:keycloak");
|
|
116
|
+
expect(names.filter((n) => n === "standalone:oidc-federation-config")).toHaveLength(1);
|
|
117
|
+
expect(names).not.toContain("federation:google");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("a google section of type oidc is the generic provider, not the built-in Google pair", () => {
|
|
121
|
+
// The section's `type` names the implementation. Composing both would
|
|
122
|
+
// contribute the same federation and redirect-policy keys twice.
|
|
123
|
+
const names = buildModules(
|
|
124
|
+
configWith({ google: { enabled: true, type: "oidc", ...okta } }),
|
|
125
|
+
).map((m) => m.name);
|
|
126
|
+
expect(names).toContain("federation:oidc:google");
|
|
127
|
+
expect(names).not.toContain("federation:google");
|
|
128
|
+
expect(names).not.toContain("standalone:google-federation-config");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("lists nothing OIDC when no section is enabled (the shipped default)", () => {
|
|
132
|
+
const names = buildModules(
|
|
133
|
+
configWith({ google: { enabled: false }, oidc: { enabled: false, type: "oidc" } }),
|
|
134
|
+
).map((m) => m.name);
|
|
135
|
+
expect(names.some((n) => n.startsWith("federation:oidc:"))).toBe(false);
|
|
136
|
+
expect(names).not.toContain("standalone:oidc-federation-config");
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -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";
|
|
@@ -159,8 +160,12 @@ const ALL_REDIS_ENV: Readonly<Record<string, string>> = {
|
|
|
159
160
|
RATE_LIMITER_ADAPTER: "redis",
|
|
160
161
|
OAUTH_CODE_ADAPTER: "redis",
|
|
161
162
|
ACCESS_TOKEN_DENYLIST_ADAPTER: "redis",
|
|
163
|
+
REPLAY_SEEN_SET_ADAPTER: "redis",
|
|
162
164
|
FEDERATION_TOKEN_STORE_TYPE: "redis",
|
|
163
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",
|
|
164
169
|
};
|
|
165
170
|
|
|
166
171
|
function resolveConfig(env: Record<string, string>): AppConfig {
|
|
@@ -240,10 +245,14 @@ describe('#455: the standalone\'s memory modules are refused under deployment.mo
|
|
|
240
245
|
// #474: express-session's own store. Not a module of this template but
|
|
241
246
|
// built here from its config, which is what lets the manifest declare.
|
|
242
247
|
["SESSION_STORAGE_TYPE", "sessionStoreModule"],
|
|
248
|
+
// #527: the consent store, wired only when the switch says so.
|
|
249
|
+
["CONSENT_STORE_ADAPTER", "core-consent-store-memory"],
|
|
243
250
|
// Core's, selected by the same kind of switch. The guard knew these by
|
|
244
251
|
// name; pinned so the name table's departure (#455) did not lose them.
|
|
245
252
|
["RATE_LIMITER_ADAPTER", "core-rate-limiter-memory"],
|
|
246
253
|
["ACCESS_TOKEN_DENYLIST_ADAPTER", "core-access-token-denylist-memory"],
|
|
254
|
+
// #484: the jti single-use record behind private_key_jwt client auth.
|
|
255
|
+
["REPLAY_SEEN_SET_ADAPTER", "core-replay-seen-set-memory"],
|
|
247
256
|
];
|
|
248
257
|
|
|
249
258
|
for (const [variable, module] of cases) {
|
|
@@ -266,6 +275,8 @@ describe('#455: the standalone\'s memory modules are refused under deployment.mo
|
|
|
266
275
|
SESSION_STORAGE_TYPE: "memory",
|
|
267
276
|
RATE_LIMITER_ADAPTER: "memory",
|
|
268
277
|
ACCESS_TOKEN_DENYLIST_ADAPTER: "memory",
|
|
278
|
+
REPLAY_SEEN_SET_ADAPTER: "memory",
|
|
279
|
+
CONSENT_STORE_ADAPTER: "memory",
|
|
269
280
|
});
|
|
270
281
|
await expect(boot(config)).rejects.toMatchObject({
|
|
271
282
|
reason: "replica-unsafe-adapter",
|
|
@@ -390,6 +401,60 @@ describe('#456: federationTokenStore.type = "redis" in the standalone', () => {
|
|
|
390
401
|
});
|
|
391
402
|
});
|
|
392
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
|
+
|
|
393
458
|
describe("#473: the Redis federation store's plaintext guard, booted from the shipped config", () => {
|
|
394
459
|
let handleRef: Awaited<ReturnType<typeof boot>> | undefined;
|
|
395
460
|
let warnSpy: ReturnType<typeof vi.spyOn>;
|
|
@@ -244,3 +244,171 @@ describe("#407 — the two READMEs agree on security advice", () => {
|
|
|
244
244
|
expect(read("/README.ja.md")).not.toMatch(/HTTP_TRUST_PROXY=true/);
|
|
245
245
|
});
|
|
246
246
|
});
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Issue #512 — the suite that ships with a scaffolded project has to be green
|
|
250
|
+
* in that project, not only in this workspace.
|
|
251
|
+
*
|
|
252
|
+
* A scaffold installs `@o3co/auth-provider-*` from npm, where the packages sit
|
|
253
|
+
* under `node_modules` and vitest externalizes them: Node loads them natively
|
|
254
|
+
* and their own `import "ioredis"` / `import "redis"` never meet the
|
|
255
|
+
* `vi.mock` registry. In this workspace the same packages are symlinks to
|
|
256
|
+
* source outside `node_modules`, vitest inlines them, and the mocks apply —
|
|
257
|
+
* which is how replica-safety.test.mts stayed green here while dialling
|
|
258
|
+
* `redis.test` for real in every scaffold. The second half is `make test`:
|
|
259
|
+
* it runs this suite inside the `test` image, where a file the Dockerfile
|
|
260
|
+
* never copied simply does not exist.
|
|
261
|
+
*/
|
|
262
|
+
describe("#512 — the shipped suite is green outside this repository", () => {
|
|
263
|
+
it("runs the published packages through vitest, so module mocks reach them", async () => {
|
|
264
|
+
// The config is loaded, not grepped: what matters is the value vitest
|
|
265
|
+
// resolves, and a `server.deps.inline` naming the wrong package would
|
|
266
|
+
// satisfy a substring check just as well.
|
|
267
|
+
const configPath = fileURLToPath(new URL("../../vitest.config.mts", import.meta.url));
|
|
268
|
+
const { default: config } = (await import(configPath)) as {
|
|
269
|
+
default: {
|
|
270
|
+
test?: { server?: { deps?: { inline?: ReadonlyArray<string | RegExp> | true } } };
|
|
271
|
+
};
|
|
272
|
+
};
|
|
273
|
+
const inline = config.test?.server?.deps?.inline;
|
|
274
|
+
expect(inline).toBeDefined();
|
|
275
|
+
if (inline === true) return;
|
|
276
|
+
for (const name of [
|
|
277
|
+
"@o3co/auth-provider-core",
|
|
278
|
+
"@o3co/auth-provider-redis",
|
|
279
|
+
"@o3co/auth-provider-session",
|
|
280
|
+
]) {
|
|
281
|
+
expect(
|
|
282
|
+
(inline ?? []).some((m) => (m instanceof RegExp ? m.test(name) : m === name)),
|
|
283
|
+
`${name} is not inlined`,
|
|
284
|
+
).toBe(true);
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Every file this suite reads from the project root. `config/` and `src/`
|
|
290
|
+
* reach the image through the builder stage; these do not, so the `test`
|
|
291
|
+
* stage has to bring them — and `.dockerignore` has to let them into the
|
|
292
|
+
* build context first.
|
|
293
|
+
*/
|
|
294
|
+
const READ_FROM_THE_PROJECT_ROOT = [
|
|
295
|
+
"Dockerfile",
|
|
296
|
+
".dockerignore",
|
|
297
|
+
".env.example",
|
|
298
|
+
".gitignore",
|
|
299
|
+
"README.md",
|
|
300
|
+
"README.ja.md",
|
|
301
|
+
"docker-compose.yml",
|
|
302
|
+
"docker-compose.production.yml",
|
|
303
|
+
] as const;
|
|
304
|
+
|
|
305
|
+
it("reads only files the template actually ships", () => {
|
|
306
|
+
for (const file of READ_FROM_THE_PROJECT_ROOT) {
|
|
307
|
+
expect(existsSync(`${standaloneDir}${file}`), file).toBe(true);
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("copies each of them into the image the `test` target runs", () => {
|
|
312
|
+
const copied = copiedIntoStage(read("/Dockerfile"), "test");
|
|
313
|
+
for (const file of READ_FROM_THE_PROJECT_ROOT) {
|
|
314
|
+
expect(copied.has(file), `${file} is not copied into the test image`).toBe(true);
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("lets each of them through .dockerignore", () => {
|
|
319
|
+
const dockerignore = read("/.dockerignore");
|
|
320
|
+
for (const file of READ_FROM_THE_PROJECT_ROOT) {
|
|
321
|
+
expect(
|
|
322
|
+
dockerignoreExcludes(dockerignore, file),
|
|
323
|
+
`${file} is excluded from the build context`,
|
|
324
|
+
).toBe(false);
|
|
325
|
+
}
|
|
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
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* The build-context sources every `COPY` in `stage` and its ancestors brings
|
|
354
|
+
* into the image. `COPY --from=` is skipped — it reads another stage, not
|
|
355
|
+
* the context — and `--chown` / other flags are dropped. Sources are
|
|
356
|
+
* normalised to the bare entry name (`./config/` → `config`).
|
|
357
|
+
*/
|
|
358
|
+
function copiedIntoStage(dockerfile: string, stage: string): Set<string> {
|
|
359
|
+
const stages = new Map<string, { parent: string; sources: string[] }>();
|
|
360
|
+
let current: { parent: string; sources: string[] } | undefined;
|
|
361
|
+
// Continuation lines are joined first, so a multi-line instruction reads
|
|
362
|
+
// as one.
|
|
363
|
+
for (const line of dockerfile.replace(/\\\n/g, " ").split("\n")) {
|
|
364
|
+
const text = line.trim();
|
|
365
|
+
if (text === "" || text.startsWith("#")) continue;
|
|
366
|
+
const from = /^FROM\s+(\S+)(?:\s+AS\s+(\S+))?/i.exec(text);
|
|
367
|
+
if (from) {
|
|
368
|
+
const parent = from[1] as string;
|
|
369
|
+
current = { parent, sources: [] };
|
|
370
|
+
stages.set(from[2] ?? parent, current);
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
if (!current || !/^COPY\b/i.test(text)) continue;
|
|
374
|
+
const args = text.split(/\s+/).slice(1);
|
|
375
|
+
if (args.some((a) => a.startsWith("--from="))) continue;
|
|
376
|
+
const operands = args.filter((a) => !a.startsWith("--"));
|
|
377
|
+
for (const source of operands.slice(0, -1)) {
|
|
378
|
+
current.sources.push(source.replace(/^\.\//, "").replace(/\/$/, ""));
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
const copied = new Set<string>();
|
|
382
|
+
for (let name: string | undefined = stage; name !== undefined; ) {
|
|
383
|
+
const s = stages.get(name);
|
|
384
|
+
if (!s) break;
|
|
385
|
+
for (const source of s.sources) copied.add(source);
|
|
386
|
+
name = s.parent;
|
|
387
|
+
}
|
|
388
|
+
return copied;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Whether `.dockerignore` keeps a root-level entry out of the build context.
|
|
393
|
+
* The last matching pattern wins and a leading `!` re-includes; `*` and `?`
|
|
394
|
+
* stop at a `/` while `**` does not — the subset of Docker's matching that
|
|
395
|
+
* root-level file names exercise.
|
|
396
|
+
*/
|
|
397
|
+
function dockerignoreExcludes(dockerignore: string, file: string): boolean {
|
|
398
|
+
let excluded = false;
|
|
399
|
+
for (const raw of dockerignore.split("\n")) {
|
|
400
|
+
const line = raw.trim();
|
|
401
|
+
if (line === "" || line.startsWith("#")) continue;
|
|
402
|
+
const negated = line.startsWith("!");
|
|
403
|
+
const pattern = negated ? line.slice(1) : line;
|
|
404
|
+
// `**` is split out first so the single-`*` rewrite cannot see it.
|
|
405
|
+
const segment = (s: string): string =>
|
|
406
|
+
s
|
|
407
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
408
|
+
.replace(/\*/g, "[^/]*")
|
|
409
|
+
.replace(/\?/g, "[^/]");
|
|
410
|
+
const regex = new RegExp(`^${pattern.split("**").map(segment).join(".*")}$`);
|
|
411
|
+
if (regex.test(file)) excluded = !negated;
|
|
412
|
+
}
|
|
413
|
+
return excluded;
|
|
414
|
+
}
|
|
@@ -251,7 +251,7 @@ describe("standalone smoke test", () => {
|
|
|
251
251
|
it("Dockerfile pins its mutable build inputs (#289)", () => {
|
|
252
252
|
// Base image by digest — the tag alone is a moving pointer, and
|
|
253
253
|
// Dependabot's docker ecosystem bumps tag and digest together.
|
|
254
|
-
expect(dockerfile).toMatch(/FROM node:
|
|
254
|
+
expect(dockerfile).toMatch(/FROM node:26-alpine@sha256:[0-9a-f]{64} AS node-base/);
|
|
255
255
|
// Global corepack by version — `npm install -g corepack` with no pin
|
|
256
256
|
// resolved whatever was latest at build time.
|
|
257
257
|
expect(dockerfile).toMatch(/npm install -g corepack@\d+\.\d+\.\d+ --force/);
|
|
@@ -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
|