@o3co/create-auth-provider 0.13.0 → 0.15.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 +2 -2
- package/templates/standalone/.env.example +27 -0
- package/templates/standalone/Dockerfile +1 -1
- package/templates/standalone/README.ja.md +80 -2
- package/templates/standalone/README.md +160 -4
- package/templates/standalone/config/application.conf +56 -1
- package/templates/standalone/docker-compose.production.yml +7 -0
- package/templates/standalone/docker-compose.yml +7 -0
- package/templates/standalone/package.json +4 -3
- package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +101 -0
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +62 -3
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +384 -0
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +431 -0
- package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +57 -0
- package/templates/standalone/src/__tests__/metrics.test.mts +14 -2
- package/templates/standalone/src/__tests__/replica-safety.test.mts +58 -0
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +37 -0
- package/templates/standalone/src/__tests__/shutdown.test.mts +96 -2
- package/templates/standalone/src/__tests__/smoke.test.mts +1 -0
- package/templates/standalone/src/__tests__/supertest-loopback.test.mts +125 -0
- package/templates/standalone/src/app.mts +9 -1
- package/templates/standalone/src/buildModules.mts +110 -6
- package/templates/standalone/src/modules.mts +96 -6
- package/templates/standalone/src/shutdown.mts +70 -4
- package/templates/standalone/vitest.config.mts +6 -0
- package/templates/standalone/vitest.supertest-loopback.mts +250 -0
- package/templates/versions.json +15 -14
|
@@ -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 {
|
|
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
|
-
|
|
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:",
|
|
@@ -164,6 +174,20 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
|
|
|
164
174
|
REDIS_FEDERATION_TOKEN_STORE_KEY_PREFIX: "ft:",
|
|
165
175
|
REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_MODE: "required",
|
|
166
176
|
REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY: "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=",
|
|
177
|
+
// #593: federation grants — a user's standing consent that a client may
|
|
178
|
+
// obtain upstream tokens without them. The overrides `reference.conf`
|
|
179
|
+
// declares, and (slice 7) the two adapter switches the template composes
|
|
180
|
+
// the feature from.
|
|
181
|
+
FEDERATION_GRANTS_ENABLED: "true",
|
|
182
|
+
FEDERATION_GRANT_STORE_ADAPTER: "redis",
|
|
183
|
+
FEDERATION_GRANT_INTENT_STORE_ADAPTER: "redis",
|
|
184
|
+
FEDERATION_GRANTS_ENCRYPTION_MODE: "required",
|
|
185
|
+
FEDERATION_GRANTS_ALLOW_KEEP_ON_SUBJECT_REVOCATION: "false",
|
|
186
|
+
// Slice 6: acquisition's two deployment decisions — whether the callback
|
|
187
|
+
// refuses an upstream account linked to another user, and the consent page.
|
|
188
|
+
FEDERATION_GRANTS_IDENTITY_LOOKUP: "required",
|
|
189
|
+
FEDERATION_GRANTS_CONSENT_URL: "/consent/grants",
|
|
190
|
+
REDIS_FEDERATION_GRANT_STORE_KEY_PREFIX: "fg:",
|
|
167
191
|
|
|
168
192
|
// --- federation ---------------------------------------------------
|
|
169
193
|
FEDERATIONS_GOOGLE_ENABLED: "true",
|
|
@@ -185,6 +209,9 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
|
|
|
185
209
|
CLIENT_USER_AUTHENTICATE_URL: "https://users.example.com/authenticate",
|
|
186
210
|
CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL: "https://users.example.com/authenticate-by-token",
|
|
187
211
|
CLIENT_USER_LINK_FEDERATED_IDENTITY_URL: "https://users.example.com/link-federated-identity",
|
|
212
|
+
// #613: the Store's identity lookup for federation grants (D7 check 5).
|
|
213
|
+
CLIENT_USER_FIND_SUBJECT_BY_FEDERATED_IDENTITY_URL:
|
|
214
|
+
"https://users.example.com/find-subject-by-federated-identity",
|
|
188
215
|
CLIENT_USER_TIMEOUT: "5000",
|
|
189
216
|
CLIENT_USER_MAX_RESPONSE_BYTES: "1048576",
|
|
190
217
|
CLIENT_CODE_TYPE: "redis",
|
|
@@ -313,6 +340,10 @@ describe("#288: the shipped config boots with every documented override supplied
|
|
|
313
340
|
expect(config.oauth.resourceIndicator?.enabled).toBe(true);
|
|
314
341
|
expect(config.federations.google?.enabled).toBe(true);
|
|
315
342
|
expect(config.federations.oidc?.enabled).toBe(true);
|
|
343
|
+
// #593: the newest one, and the one where a leftover string would be
|
|
344
|
+
// read as "on" by a truthiness check and as "off" by `=== true` — a
|
|
345
|
+
// feature whose whole default is off.
|
|
346
|
+
expect(config.federationGrants?.enabled).toBe(true);
|
|
316
347
|
});
|
|
317
348
|
|
|
318
349
|
it("turns every non-boolean override into its declared type", () => {
|
|
@@ -320,11 +351,19 @@ describe("#288: the shipped config boots with every documented override supplied
|
|
|
320
351
|
expect(config.http.port).toBe(3000);
|
|
321
352
|
expect(config.http.readinessTimeoutMs).toBe(1500);
|
|
322
353
|
expect(config.http.trustProxy).toEqual(["10.0.0.0/8", "loopback"]);
|
|
323
|
-
|
|
354
|
+
// The new default wins over the deprecated variable, and the parsed
|
|
355
|
+
// config mirrors it onto the old key for readers that predate the split.
|
|
356
|
+
expect(resolveAccessTokenLifetime(config)).toEqual({
|
|
357
|
+
defaultExpiresIn: 900,
|
|
358
|
+
maxExpiresIn: 7200,
|
|
359
|
+
});
|
|
360
|
+
expect(config.oauth.accessToken.expiresIn).toBe(900);
|
|
324
361
|
expect(config.oauth.refreshToken.expiresIn).toBe(86400);
|
|
325
362
|
expect(config.session.maxAge).toBe(3600000);
|
|
326
363
|
expect(config.session.csrf?.ttlSeconds).toBe(7200);
|
|
327
364
|
expect(config.oauth.nonce?.maxLength).toBe(256);
|
|
365
|
+
expect(config.consentStore?.adapter).toBe("redis");
|
|
366
|
+
expect(config.redisConsentStore?.keyPrefix).toBe("tenant-a:consent:");
|
|
328
367
|
// #529: the comma-separated lists become lists, trimmed; the numbers, numbers.
|
|
329
368
|
expect(config.oauth.clientIdMetadataDocuments).toEqual({
|
|
330
369
|
enabled: true,
|
|
@@ -508,6 +547,26 @@ describe("#288: the shipped config boots with every documented override supplied
|
|
|
508
547
|
).toThrow(/allowUnmarkedClients/);
|
|
509
548
|
});
|
|
510
549
|
|
|
550
|
+
it("refuses an access-token default above the max, naming both keys", () => {
|
|
551
|
+
expect(() =>
|
|
552
|
+
buildResolvedConfig({
|
|
553
|
+
...DOCUMENTED_ENV,
|
|
554
|
+
OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN: "7200",
|
|
555
|
+
OAUTH_ACCESS_TOKEN_MAX_EXPIRES_IN: "3600",
|
|
556
|
+
}),
|
|
557
|
+
).toThrow(/defaultExpiresIn.*maxExpiresIn/s);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
for (const name of [
|
|
561
|
+
"OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN",
|
|
562
|
+
"OAUTH_ACCESS_TOKEN_MAX_EXPIRES_IN",
|
|
563
|
+
"OAUTH_ACCESS_TOKEN_EXPIRES_IN",
|
|
564
|
+
]) {
|
|
565
|
+
it(`refuses an empty ${name} rather than minting already-expired tokens`, () => {
|
|
566
|
+
expect(() => buildResolvedConfig({ ...DOCUMENTED_ENV, [name]: "" })).toThrow();
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
|
|
511
570
|
it("still refuses an empty SESSION_CSRF_TTL_SECONDS (#272)", () => {
|
|
512
571
|
// Pinned alongside the boolean cases because it is the same trap
|
|
513
572
|
// read from the other side: for a *number*, empty means fail loudly.
|
|
@@ -0,0 +1,384 @@
|
|
|
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
|
+
* #593 slice 7 — the standalone composes federation grants from its config.
|
|
19
|
+
*
|
|
20
|
+
* What is under test is the WIRING: which modules the switches select, that
|
|
21
|
+
* the shared Redis client module provides the two slots the Redis stores
|
|
22
|
+
* require, and that the refusals a wrong pairing earns come from the modules
|
|
23
|
+
* themselves. The flows a connection needs are the package's own tests; a
|
|
24
|
+
* connection here would need a live IdP, because the OIDC federation
|
|
25
|
+
* discovers at boot.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { fileURLToPath } from "node:url";
|
|
29
|
+
import {
|
|
30
|
+
type AppConfig,
|
|
31
|
+
AppConfigSchema,
|
|
32
|
+
createApp,
|
|
33
|
+
createKeyStoreFactory,
|
|
34
|
+
defineModule,
|
|
35
|
+
InMemoryClientRepository,
|
|
36
|
+
InMemoryUserRepository,
|
|
37
|
+
memoryRefreshTokenFamilyStoreModule,
|
|
38
|
+
registerBuiltinKeyStores,
|
|
39
|
+
} from "@o3co/auth-provider-core";
|
|
40
|
+
import { parseFile } from "@o3co/ts.hocon";
|
|
41
|
+
import { validate } from "@o3co/ts.hocon/zod";
|
|
42
|
+
import express from "express";
|
|
43
|
+
import request from "supertest";
|
|
44
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
45
|
+
import { buildModules } from "../buildModules.mjs";
|
|
46
|
+
import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "../configPath.mjs";
|
|
47
|
+
import { cleanupAllowanceFor, FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS } from "../shutdown.mjs";
|
|
48
|
+
|
|
49
|
+
// The same stand-ins `replica-safety.test.mts` boots under: no socket opens,
|
|
50
|
+
// and the shared clients module's readiness probe gets its PONG.
|
|
51
|
+
vi.mock("redis", () => ({
|
|
52
|
+
createClient: vi.fn(() => ({
|
|
53
|
+
connect: vi.fn().mockResolvedValue(undefined),
|
|
54
|
+
quit: vi.fn().mockResolvedValue(undefined),
|
|
55
|
+
ping: vi.fn().mockResolvedValue("PONG"),
|
|
56
|
+
on: vi.fn(),
|
|
57
|
+
})),
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
vi.mock("connect-redis", async () => {
|
|
61
|
+
const { EventEmitter } = await import("node:events");
|
|
62
|
+
return {
|
|
63
|
+
RedisStore: class MockRedisStore extends EventEmitter {
|
|
64
|
+
constructor(_opts: { client: unknown }) {
|
|
65
|
+
super();
|
|
66
|
+
}
|
|
67
|
+
get(): unknown {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
set(): void {}
|
|
71
|
+
destroy(): void {}
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
vi.mock("ioredis", () => {
|
|
77
|
+
const explicit: Record<string, unknown> = {
|
|
78
|
+
on: () => undefined,
|
|
79
|
+
quit: async () => "OK",
|
|
80
|
+
disconnect: () => undefined,
|
|
81
|
+
ping: async () => "PONG",
|
|
82
|
+
};
|
|
83
|
+
const makeMockRedis = (): object =>
|
|
84
|
+
new Proxy(
|
|
85
|
+
{},
|
|
86
|
+
{
|
|
87
|
+
get(_target, prop) {
|
|
88
|
+
if (typeof prop !== "string" || prop === "then") return undefined;
|
|
89
|
+
if (prop === "duplicate") return makeMockRedis;
|
|
90
|
+
if (prop in explicit) return explicit[prop];
|
|
91
|
+
return async () => null;
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
);
|
|
95
|
+
function MockRedis(): object {
|
|
96
|
+
return makeMockRedis();
|
|
97
|
+
}
|
|
98
|
+
return { Redis: MockRedis, default: MockRedis };
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const configDir = fileURLToPath(new URL("../../config", import.meta.url));
|
|
102
|
+
const ENCRYPTION_KEY = Buffer.alloc(32, 7).toString("base64");
|
|
103
|
+
|
|
104
|
+
/** A single-replica deployment with every shared store on memory, grants off. */
|
|
105
|
+
const BASE_ENV: Readonly<Record<string, string>> = {
|
|
106
|
+
OAUTH_JWT_ALGORITHM: "HS256",
|
|
107
|
+
OAUTH_JWT_SECRET: "federation-grants-composition.at-least-32-bytes.ok",
|
|
108
|
+
OAUTH_JWT_ISSUER: "https://auth.test",
|
|
109
|
+
SESSION_SECRET: "federation-grants-composition-session.at-least-32-bytes.ok",
|
|
110
|
+
SESSION_SECURE: "false",
|
|
111
|
+
SESSION_NAME: "auth.session",
|
|
112
|
+
SESSION_STORAGE_TYPE: "memory",
|
|
113
|
+
CLIENT_USER_TYPE: "yaml",
|
|
114
|
+
REFRESH_TOKEN_FAMILY_STORE_REDIS_URL: "redis://redis.test:6379",
|
|
115
|
+
USER_SESSION_STORES_ADAPTER: "memory",
|
|
116
|
+
RATE_LIMITER_ADAPTER: "memory",
|
|
117
|
+
OAUTH_CODE_ADAPTER: "memory",
|
|
118
|
+
ACCESS_TOKEN_DENYLIST_ADAPTER: "memory",
|
|
119
|
+
REPLAY_SEEN_SET_ADAPTER: "memory",
|
|
120
|
+
FEDERATION_TOKEN_STORE_TYPE: "memory",
|
|
121
|
+
CONSENT_STORE_ADAPTER: "none",
|
|
122
|
+
FEDERATION_GRANT_STORE_ADAPTER: "memory",
|
|
123
|
+
FEDERATION_GRANT_INTENT_STORE_ADAPTER: "memory",
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/** The umbrella E2E's shape: every shared store on Redis, more than one replica. */
|
|
127
|
+
const ALL_REDIS_ENV: Readonly<Record<string, string>> = {
|
|
128
|
+
...BASE_ENV,
|
|
129
|
+
DEPLOYMENT_MODE: "multi",
|
|
130
|
+
SESSION_STORAGE_TYPE: "redis",
|
|
131
|
+
SESSION_STORAGE_REDIS_URL: "redis://redis.test:6379",
|
|
132
|
+
USER_SESSION_STORES_ADAPTER: "redis",
|
|
133
|
+
RATE_LIMITER_ADAPTER: "redis",
|
|
134
|
+
OAUTH_CODE_ADAPTER: "redis",
|
|
135
|
+
ACCESS_TOKEN_DENYLIST_ADAPTER: "redis",
|
|
136
|
+
REPLAY_SEEN_SET_ADAPTER: "redis",
|
|
137
|
+
FEDERATION_TOKEN_STORE_TYPE: "redis",
|
|
138
|
+
REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY: ENCRYPTION_KEY,
|
|
139
|
+
CONSENT_STORE_ADAPTER: "redis",
|
|
140
|
+
FEDERATION_GRANT_STORE_ADAPTER: "redis",
|
|
141
|
+
FEDERATION_GRANT_INTENT_STORE_ADAPTER: "redis",
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/** What enabling the feature adds: the switch, and the page boot requires. */
|
|
145
|
+
const GRANTS_ON: Readonly<Record<string, string>> = {
|
|
146
|
+
FEDERATION_GRANTS_ENABLED: "true",
|
|
147
|
+
FEDERATION_GRANTS_CONSENT_URL: "/consent/grants",
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
function resolveConfig(env: Record<string, string>): AppConfig {
|
|
151
|
+
const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
|
|
152
|
+
const config = validate(
|
|
153
|
+
parseFile(envConfPath, { env })
|
|
154
|
+
.withFallback(parseFile(applicationConfPath, { env }))
|
|
155
|
+
.withFallback(parseFile(resolveLibraryReferenceConfPath(), { env })),
|
|
156
|
+
AppConfigSchema,
|
|
157
|
+
);
|
|
158
|
+
// The key ring has no environment form (a list of { id, key } is HOCON's);
|
|
159
|
+
// the Redis grant store refuses to construct without one under "required".
|
|
160
|
+
return {
|
|
161
|
+
...config,
|
|
162
|
+
federationGrants: {
|
|
163
|
+
...config.federationGrants,
|
|
164
|
+
encryptionKeys: [{ id: "k-test", key: ENCRYPTION_KEY }],
|
|
165
|
+
},
|
|
166
|
+
} as AppConfig;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const testRepositoriesModule = defineModule({
|
|
170
|
+
name: "test:repositories",
|
|
171
|
+
provides: {
|
|
172
|
+
clientRepository: () => new InMemoryClientRepository(new Map()),
|
|
173
|
+
userRepository: () => new InMemoryUserRepository(new Map()),
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const testKeyStoreModule = defineModule({
|
|
178
|
+
name: "test:key-store",
|
|
179
|
+
requires: ["config"] as const,
|
|
180
|
+
provides: {
|
|
181
|
+
keyStore: async ({ config: c }) => {
|
|
182
|
+
const factory = createKeyStoreFactory();
|
|
183
|
+
registerBuiltinKeyStores(factory);
|
|
184
|
+
return factory.create({
|
|
185
|
+
type: "local",
|
|
186
|
+
...((c as AppConfig).oauth.jwt.signingKey.local ?? {}),
|
|
187
|
+
});
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const modulesFor = (config: AppConfig, memoryOnly = false, environment?: string) =>
|
|
193
|
+
buildModules(config, {
|
|
194
|
+
keyStoreModule: testKeyStoreModule,
|
|
195
|
+
repositoriesModule: testRepositoriesModule,
|
|
196
|
+
...(memoryOnly ? { refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule] } : {}),
|
|
197
|
+
...(environment === undefined ? {} : { environment }),
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const boot = (config: AppConfig, memoryOnly = false, environment?: string) =>
|
|
201
|
+
createApp({
|
|
202
|
+
modules: modulesFor(config, memoryOnly, environment),
|
|
203
|
+
bootstrapComponents: { config, pathResolver: (s) => s },
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const names = (config: AppConfig, memoryOnly = false) =>
|
|
207
|
+
modulesFor(config, memoryOnly).map((m) => m.name);
|
|
208
|
+
|
|
209
|
+
const GRANT_MODULES = [
|
|
210
|
+
"federation-grants",
|
|
211
|
+
"federation-grant-background",
|
|
212
|
+
"core-federation-grant-store-memory",
|
|
213
|
+
"core-federation-grant-intent-store-memory",
|
|
214
|
+
"redis-federation-grant-store",
|
|
215
|
+
"redis-federation-grant-intent-store",
|
|
216
|
+
"subject-revocation-service",
|
|
217
|
+
];
|
|
218
|
+
|
|
219
|
+
/** The message of a boot failure and of the module error it wraps, together. */
|
|
220
|
+
const messageChain = (err: unknown): string => {
|
|
221
|
+
const e = err as { message?: string; cause?: { message?: string } };
|
|
222
|
+
return `${e.message ?? ""} ${e.cause?.message ?? ""}`;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
describe("#593 slice 7: the standalone composes federation grants from its config", () => {
|
|
226
|
+
let handleRef: Awaited<ReturnType<typeof boot>> | undefined;
|
|
227
|
+
|
|
228
|
+
afterEach(async () => {
|
|
229
|
+
await handleRef?.dispose();
|
|
230
|
+
handleRef = undefined;
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("installs nothing of the feature while it is off, whatever the switches say", async () => {
|
|
234
|
+
// Off is the default, and off must cost nothing: no store, no socket, no
|
|
235
|
+
// boot requirement a deployment that never asked for grants would meet.
|
|
236
|
+
const config = resolveConfig({ ...BASE_ENV, FEDERATION_GRANT_STORE_ADAPTER: "redis" });
|
|
237
|
+
const installed = names(config, true);
|
|
238
|
+
for (const name of GRANT_MODULES) expect(installed).not.toContain(name);
|
|
239
|
+
expect(installed).not.toContain("standalone:redis-clients");
|
|
240
|
+
|
|
241
|
+
handleRef = await boot(config, true);
|
|
242
|
+
const app = express().use(handleRef.router);
|
|
243
|
+
expect((await request(app).post("/oauth/federation-grants/g/status")).status).toBe(404);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("boots on memory stores with no connection, and mounts the routes", async () => {
|
|
247
|
+
const config = resolveConfig({ ...BASE_ENV, ...GRANTS_ON });
|
|
248
|
+
const installed = names(config, true);
|
|
249
|
+
expect(installed).toEqual(
|
|
250
|
+
expect.arrayContaining([
|
|
251
|
+
"federation-grant-background",
|
|
252
|
+
"federation-grants",
|
|
253
|
+
"core-federation-grant-store-memory",
|
|
254
|
+
"core-federation-grant-intent-store-memory",
|
|
255
|
+
"subject-revocation-service",
|
|
256
|
+
]),
|
|
257
|
+
);
|
|
258
|
+
expect(installed).not.toContain("redis-federation-grant-store");
|
|
259
|
+
expect(installed).not.toContain("redis-federation-grant-intent-store");
|
|
260
|
+
// The routes precede oauthModule's body parsers (the package README's
|
|
261
|
+
// mounting-order rule), and the browser half sits after the session
|
|
262
|
+
// middleware by its own `after`.
|
|
263
|
+
expect(installed.indexOf("federation-grants")).toBeLessThan(installed.indexOf("oauth"));
|
|
264
|
+
|
|
265
|
+
handleRef = await boot(config, true);
|
|
266
|
+
const app = express().use(handleRef.router);
|
|
267
|
+
// Mounted: the JSON router answers for itself (client authentication),
|
|
268
|
+
// not express's 404.
|
|
269
|
+
expect((await request(app).post("/oauth/federation-grants/g/status")).status).toBe(401);
|
|
270
|
+
expect(handleRef.components.subjectRevocationService).toBeDefined();
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("selects the Redis stores under the all-Redis environment, provides their clients, and boots under multi", async () => {
|
|
274
|
+
const config = resolveConfig({ ...ALL_REDIS_ENV, ...GRANTS_ON });
|
|
275
|
+
const modules = modulesFor(config);
|
|
276
|
+
const installed = modules.map((m) => m.name);
|
|
277
|
+
expect(installed).toEqual(
|
|
278
|
+
expect.arrayContaining([
|
|
279
|
+
"redis-federation-grant-store",
|
|
280
|
+
"redis-federation-grant-intent-store",
|
|
281
|
+
]),
|
|
282
|
+
);
|
|
283
|
+
expect(installed).not.toContain("core-federation-grant-store-memory");
|
|
284
|
+
expect(installed).not.toContain("core-federation-grant-intent-store-memory");
|
|
285
|
+
const clients = modules.find((m) => m.name === "standalone:redis-clients");
|
|
286
|
+
expect(Object.keys(clients?.provides ?? {})).toEqual(
|
|
287
|
+
expect.arrayContaining(["federationGrantStoreClient", "federationGrantIntentStoreClient"]),
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
handleRef = await boot(config);
|
|
291
|
+
expect(handleRef.components.federationGrantStore?.kind).toBe("redis");
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("gives cleanup the documented 45 seconds under the shipped budgets, and nothing while off", () => {
|
|
295
|
+
// The allowance is derived from reference.conf's budgets; if those move,
|
|
296
|
+
// the number every README states moves with them, and this says so.
|
|
297
|
+
expect(cleanupAllowanceFor(resolveConfig({ ...BASE_ENV, ...GRANTS_ON }))).toEqual({
|
|
298
|
+
cleanupTimeoutMs: FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS,
|
|
299
|
+
});
|
|
300
|
+
expect(cleanupAllowanceFor(resolveConfig(BASE_ENV))).toEqual({});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("adds the shared Redis client for the grant stores alone", () => {
|
|
304
|
+
// Every other adapter on memory and the RT family store overridden: only
|
|
305
|
+
// the two grant switches ask for Redis, and that is enough.
|
|
306
|
+
const config = resolveConfig({
|
|
307
|
+
...BASE_ENV,
|
|
308
|
+
...GRANTS_ON,
|
|
309
|
+
FEDERATION_GRANT_STORE_ADAPTER: "redis",
|
|
310
|
+
FEDERATION_GRANT_INTENT_STORE_ADAPTER: "redis",
|
|
311
|
+
});
|
|
312
|
+
expect(names(config, true)).toContain("standalone:redis-clients");
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it.each([
|
|
316
|
+
["FEDERATION_GRANT_STORE_ADAPTER", "core-federation-grant-store-memory"],
|
|
317
|
+
["FEDERATION_GRANT_INTENT_STORE_ADAPTER", "core-federation-grant-intent-store-memory"],
|
|
318
|
+
])("%s=memory is refused under multi, naming %s", async (variable, module) => {
|
|
319
|
+
const config = resolveConfig({ ...ALL_REDIS_ENV, ...GRANTS_ON, [variable]: "memory" });
|
|
320
|
+
await expect(boot(config)).rejects.toMatchObject({
|
|
321
|
+
name: "BootError",
|
|
322
|
+
reason: "replica-unsafe-adapter",
|
|
323
|
+
details: { modules: [module] },
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("refuses Redis grants beside memory user-session stores, naming the boundary (D13)", async () => {
|
|
328
|
+
// The grants would outlive the process; the boundary that ends them
|
|
329
|
+
// would not. The routes module refuses the pairing on a single replica
|
|
330
|
+
// too, and its message names the remedy.
|
|
331
|
+
const config = resolveConfig({
|
|
332
|
+
...BASE_ENV,
|
|
333
|
+
...GRANTS_ON,
|
|
334
|
+
FEDERATION_GRANT_STORE_ADAPTER: "redis",
|
|
335
|
+
FEDERATION_GRANT_INTENT_STORE_ADAPTER: "memory",
|
|
336
|
+
});
|
|
337
|
+
let error: unknown;
|
|
338
|
+
try {
|
|
339
|
+
handleRef = await boot(config);
|
|
340
|
+
} catch (caught) {
|
|
341
|
+
error = caught;
|
|
342
|
+
}
|
|
343
|
+
expect(messageChain(error)).toMatch(/subject boundary is kept in "memory"/);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("hands the Redis grant store the environment the config was selected by, so its plaintext guard reads it (#473)", async () => {
|
|
347
|
+
// The federation-token store's guard reads `environment` beside
|
|
348
|
+
// NODE_ENV so that CONFIG_ENV=production is production to it; the grant
|
|
349
|
+
// store's must too, or a deployment selecting its config by CONFIG_ENV
|
|
350
|
+
// with NODE_ENV unset would keep its refresh tokens in plaintext.
|
|
351
|
+
const config = resolveConfig({
|
|
352
|
+
...BASE_ENV,
|
|
353
|
+
...GRANTS_ON,
|
|
354
|
+
USER_SESSION_STORES_ADAPTER: "redis",
|
|
355
|
+
FEDERATION_GRANT_STORE_ADAPTER: "redis",
|
|
356
|
+
FEDERATION_GRANTS_ENCRYPTION_MODE: "allow-plaintext",
|
|
357
|
+
});
|
|
358
|
+
let error: unknown;
|
|
359
|
+
try {
|
|
360
|
+
handleRef = await boot(config, false, "production");
|
|
361
|
+
} catch (caught) {
|
|
362
|
+
error = caught;
|
|
363
|
+
}
|
|
364
|
+
expect(messageChain(error)).toMatch(/federation-grants[\s\S]*allow-plaintext[\s\S]*production/);
|
|
365
|
+
// The same config under the test environment boots: it is the name that
|
|
366
|
+
// selected the config, not a hard refusal of plaintext.
|
|
367
|
+
handleRef = await boot(config);
|
|
368
|
+
expect(handleRef.components.federationGrantStore?.kind).toBe("redis");
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it("keeps Redis grants beside memory intents on a single replica", async () => {
|
|
372
|
+
// A restart loses flows in progress and no established grant.
|
|
373
|
+
const config = resolveConfig({
|
|
374
|
+
...BASE_ENV,
|
|
375
|
+
...GRANTS_ON,
|
|
376
|
+
USER_SESSION_STORES_ADAPTER: "redis",
|
|
377
|
+
FEDERATION_GRANT_STORE_ADAPTER: "redis",
|
|
378
|
+
FEDERATION_GRANT_INTENT_STORE_ADAPTER: "memory",
|
|
379
|
+
});
|
|
380
|
+
handleRef = await boot(config);
|
|
381
|
+
expect(handleRef.components.federationGrantStore?.kind).toBe("redis");
|
|
382
|
+
expect(names(config)).toContain("core-federation-grant-intent-store-memory");
|
|
383
|
+
});
|
|
384
|
+
});
|