@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
|
@@ -33,7 +33,13 @@
|
|
|
33
33
|
import type { Server } from "node:http";
|
|
34
34
|
import type { Logger } from "@o3co/auth-provider-core";
|
|
35
35
|
import { describe, expect, it, vi } from "vitest";
|
|
36
|
-
import {
|
|
36
|
+
import {
|
|
37
|
+
cleanupAllowanceFor,
|
|
38
|
+
deferExit,
|
|
39
|
+
FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS,
|
|
40
|
+
FEDERATION_GRANTS_CLEANUP_MARGIN_MS,
|
|
41
|
+
installGracefulShutdown,
|
|
42
|
+
} from "../shutdown.mjs";
|
|
37
43
|
|
|
38
44
|
/** A `Server` double whose `close` callback fires only when we say so. */
|
|
39
45
|
function makeServer() {
|
|
@@ -80,7 +86,13 @@ const makeLogger = () => {
|
|
|
80
86
|
};
|
|
81
87
|
|
|
82
88
|
/** Drive one shutdown without touching the real `process` or exiting. */
|
|
83
|
-
function install(
|
|
89
|
+
function install(
|
|
90
|
+
opts: {
|
|
91
|
+
cleanup?: () => void | Promise<void>;
|
|
92
|
+
drainTimeoutMs?: number;
|
|
93
|
+
cleanupTimeoutMs?: number;
|
|
94
|
+
} = {},
|
|
95
|
+
) {
|
|
84
96
|
const { server, spies, finishDraining, failClose } = makeServer();
|
|
85
97
|
const logger = makeLogger();
|
|
86
98
|
const exit = vi.fn();
|
|
@@ -90,6 +102,7 @@ function install(opts: { cleanup?: () => void | Promise<void>; drainTimeoutMs?:
|
|
|
90
102
|
logger,
|
|
91
103
|
cleanup: opts.cleanup ?? (() => {}),
|
|
92
104
|
...(opts.drainTimeoutMs === undefined ? {} : { drainTimeoutMs: opts.drainTimeoutMs }),
|
|
105
|
+
...(opts.cleanupTimeoutMs === undefined ? {} : { cleanupTimeoutMs: opts.cleanupTimeoutMs }),
|
|
93
106
|
exit,
|
|
94
107
|
onSignal: (name, handler) => signals.set(name, handler),
|
|
95
108
|
offSignal: (name) => signals.delete(name),
|
|
@@ -322,3 +335,84 @@ describe("installGracefulShutdown (#290)", () => {
|
|
|
322
335
|
expect(signals.size).toBe(0);
|
|
323
336
|
});
|
|
324
337
|
});
|
|
338
|
+
|
|
339
|
+
describe("#593 slice 7: the cleanup allowance federation grants need", () => {
|
|
340
|
+
it("is at least the 45 seconds the package asks for, and only when the feature is on", () => {
|
|
341
|
+
// The package's drain waits for a rotated credential's write; the
|
|
342
|
+
// default cleanup budget is the ten-second drain, which is shorter than
|
|
343
|
+
// the upstream hard timeout and persist budget the feature ships with —
|
|
344
|
+
// a shutdown under it abandons exactly the write the drain exists for.
|
|
345
|
+
expect(FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS).toBeGreaterThanOrEqual(45_000);
|
|
346
|
+
expect(cleanupAllowanceFor({ federationGrants: { enabled: true } })).toEqual({
|
|
347
|
+
cleanupTimeoutMs: FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS,
|
|
348
|
+
});
|
|
349
|
+
// Off, nothing: the cleanup budget stays the drain's, as it was.
|
|
350
|
+
expect(cleanupAllowanceFor({ federationGrants: { enabled: false } })).toEqual({});
|
|
351
|
+
expect(cleanupAllowanceFor({})).toEqual({});
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("grows with the configured refresh tail, so a raised budget is not cut off by a fixed timer (Copilot, #614)", () => {
|
|
355
|
+
// The longest tail one refresh has: the upstream hard timeout, the
|
|
356
|
+
// persist budget and the wait for the lock, back to back, plus an exit
|
|
357
|
+
// margin. The shipped budgets (25 s + 3 s + 5 s) land exactly on the
|
|
358
|
+
// floor; a deployment that doubles its upstream timeout gets more.
|
|
359
|
+
const shipped = {
|
|
360
|
+
upstreamHardTimeoutMs: 25_000,
|
|
361
|
+
persistRetryBudgetMs: 3_000,
|
|
362
|
+
lockWaitMs: 5_000,
|
|
363
|
+
};
|
|
364
|
+
expect(
|
|
365
|
+
25_000 + 3_000 + 5_000 + FEDERATION_GRANTS_CLEANUP_MARGIN_MS,
|
|
366
|
+
"the margin is what makes the shipped budgets the floor",
|
|
367
|
+
).toBe(FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS);
|
|
368
|
+
expect(cleanupAllowanceFor({ federationGrants: { enabled: true, ...shipped } })).toEqual({
|
|
369
|
+
cleanupTimeoutMs: FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS,
|
|
370
|
+
});
|
|
371
|
+
expect(
|
|
372
|
+
cleanupAllowanceFor({
|
|
373
|
+
federationGrants: { enabled: true, ...shipped, upstreamHardTimeoutMs: 60_000 },
|
|
374
|
+
}),
|
|
375
|
+
).toEqual({ cleanupTimeoutMs: 60_000 + 3_000 + 5_000 + FEDERATION_GRANTS_CLEANUP_MARGIN_MS });
|
|
376
|
+
// Lowered budgets never go below the documented minimum, and a config
|
|
377
|
+
// without budgets (built by hand) gets the floor rather than a guess.
|
|
378
|
+
expect(
|
|
379
|
+
cleanupAllowanceFor({
|
|
380
|
+
federationGrants: { enabled: true, ...shipped, upstreamHardTimeoutMs: 1_000 },
|
|
381
|
+
}),
|
|
382
|
+
).toEqual({ cleanupTimeoutMs: FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS });
|
|
383
|
+
expect(
|
|
384
|
+
cleanupAllowanceFor({ federationGrants: { enabled: true, upstreamHardTimeoutMs: 90_000 } }),
|
|
385
|
+
).toEqual({ cleanupTimeoutMs: FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS });
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("never asks a timer for more than Node can count: an oversized sum is capped, not overflowed (Copilot, #614)", () => {
|
|
389
|
+
// setTimeout takes a 32-bit signed delay; past it the timer fires after
|
|
390
|
+
// about a millisecond, which would turn a generous allowance into none.
|
|
391
|
+
const oversized = cleanupAllowanceFor({
|
|
392
|
+
federationGrants: {
|
|
393
|
+
enabled: true,
|
|
394
|
+
upstreamHardTimeoutMs: 2_000_000_000,
|
|
395
|
+
persistRetryBudgetMs: 2_000_000_000,
|
|
396
|
+
lockWaitMs: 5_000,
|
|
397
|
+
},
|
|
398
|
+
});
|
|
399
|
+
expect(oversized).toEqual({ cleanupTimeoutMs: 2_147_483_647 });
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it("is honoured by the shutdown: a cleanup that needs longer than the drain is given it", async () => {
|
|
403
|
+
vi.useFakeTimers();
|
|
404
|
+
try {
|
|
405
|
+
const { signals, finishDraining, exit } = install({
|
|
406
|
+
cleanup: () => new Promise<void>((resolve) => setTimeout(resolve, 30_000)),
|
|
407
|
+
drainTimeoutMs: 5_000,
|
|
408
|
+
...cleanupAllowanceFor({ federationGrants: { enabled: true } }),
|
|
409
|
+
});
|
|
410
|
+
signals.get("SIGTERM")?.();
|
|
411
|
+
finishDraining();
|
|
412
|
+
await vi.advanceTimersByTimeAsync(31_000);
|
|
413
|
+
expect(exit).toHaveBeenCalledExactlyOnceWith(0);
|
|
414
|
+
} finally {
|
|
415
|
+
vi.useRealTimers();
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
});
|
|
@@ -486,6 +486,7 @@ describe("standalone smoke test", () => {
|
|
|
486
486
|
userSessionStores: { adapter: "redis" as const },
|
|
487
487
|
rateLimiter: { adapter: "redis" as const },
|
|
488
488
|
accessTokenDenylist: { adapter: "redis" as const },
|
|
489
|
+
consentStore: { adapter: "redis" as const },
|
|
489
490
|
oauth: { ...config.oauth, code: { adapter: "redis" as const } },
|
|
490
491
|
};
|
|
491
492
|
// No `refreshTokenFamilyModules` override: the default is the Redis
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 1o1 Co. Ltd.
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* #556 — the server `request(app)` starts listens on the address supertest
|
|
19
|
+
* dials, in this project's own test run.
|
|
20
|
+
*
|
|
21
|
+
* Unpatched, supertest starts its server with `app.listen(0)` — the
|
|
22
|
+
* dual-stack wildcard `[::]:P` — and sends the request to `127.0.0.1:P`. On
|
|
23
|
+
* macOS the kernel can hand out a `P` another process already holds as
|
|
24
|
+
* `127.0.0.1:P`; the request then reaches that process, and if it never
|
|
25
|
+
* answers the test hangs until its timeout. `vitest.supertest-loopback.mts`
|
|
26
|
+
* (wired in through `setupFiles` in `vitest.config.mts`) binds the server to
|
|
27
|
+
* `127.0.0.1` instead. These tests fail if that wiring is lost.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import http from "node:http";
|
|
31
|
+
import net, { type AddressInfo } from "node:net";
|
|
32
|
+
import express from "express";
|
|
33
|
+
import request from "supertest";
|
|
34
|
+
import { describe, expect, it } from "vitest";
|
|
35
|
+
|
|
36
|
+
function helloApp() {
|
|
37
|
+
const app = express();
|
|
38
|
+
app.get("/hello", (_req, res) => {
|
|
39
|
+
res.status(200).send("hello");
|
|
40
|
+
});
|
|
41
|
+
return app;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Resolves with the address `server` binds, whoever calls `listen`. */
|
|
45
|
+
function boundAddress(server: net.Server): Promise<AddressInfo> {
|
|
46
|
+
return new Promise((resolve) => {
|
|
47
|
+
server.once("listening", () => resolve(server.address() as AddressInfo));
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("#556 — supertest's own server listens on the loopback address it dials", () => {
|
|
52
|
+
it("binds 127.0.0.1, not the dual-stack wildcard", async () => {
|
|
53
|
+
const server = http.createServer(helloApp());
|
|
54
|
+
const bound = boundAddress(server);
|
|
55
|
+
|
|
56
|
+
const res = await request(server).get("/hello");
|
|
57
|
+
|
|
58
|
+
expect(res.status).toBe(200);
|
|
59
|
+
expect(await bound).toMatchObject({ address: "127.0.0.1", family: "IPv4" });
|
|
60
|
+
// supertest still closes the server it started.
|
|
61
|
+
expect(server.listening).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("rejects promptly with the request's own error when the request cannot be built", async () => {
|
|
65
|
+
// Node refuses this header value (ERR_INVALID_CHAR) while supertest builds
|
|
66
|
+
// the request. With the loopback bind that happens after the bind settles,
|
|
67
|
+
// outside the promise the test awaits; the throw must still reject that
|
|
68
|
+
// promise, as it does unpatched, and not become an unhandled rejection
|
|
69
|
+
// that leaves the request hanging until the test timeout.
|
|
70
|
+
const server = http.createServer(helloApp());
|
|
71
|
+
|
|
72
|
+
await expect(request(server).get("/hello").set("x-test", "bad\nvalue")).rejects.toMatchObject({
|
|
73
|
+
code: "ERR_INVALID_CHAR",
|
|
74
|
+
});
|
|
75
|
+
// The request never went out, so nothing would close the server it started.
|
|
76
|
+
expect(server.listening).toBe(false);
|
|
77
|
+
}, 5_000);
|
|
78
|
+
|
|
79
|
+
it("hands the same error to an .end() callback", async () => {
|
|
80
|
+
const server = http.createServer(helloApp());
|
|
81
|
+
|
|
82
|
+
const err = await new Promise<unknown>((resolve) => {
|
|
83
|
+
request(server)
|
|
84
|
+
.get("/hello")
|
|
85
|
+
.set("x-test", "bad\nvalue")
|
|
86
|
+
.end((error) => resolve(error));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
expect(err).toMatchObject({ code: "ERR_INVALID_CHAR" });
|
|
90
|
+
}, 5_000);
|
|
91
|
+
|
|
92
|
+
it("serves requests an agent sends together over its one server", async () => {
|
|
93
|
+
const agent = request.agent(helloApp());
|
|
94
|
+
|
|
95
|
+
const responses = await Promise.all([agent.get("/hello"), agent.get("/hello")]);
|
|
96
|
+
|
|
97
|
+
expect(responses.map((res) => res.status)).toEqual([200, 200]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("fails the request, instead of hanging on another socket, when 127.0.0.1:P is taken", async () => {
|
|
101
|
+
// The collision the kernel produces by chance, produced on purpose: a
|
|
102
|
+
// socket that accepts and never answers holds 127.0.0.1:P, and the
|
|
103
|
+
// server supertest starts is steered onto P.
|
|
104
|
+
const held: net.Socket[] = [];
|
|
105
|
+
const squatter = net.createServer((socket) => {
|
|
106
|
+
held.push(socket);
|
|
107
|
+
});
|
|
108
|
+
await new Promise<void>((resolve) => squatter.listen(0, "127.0.0.1", resolve));
|
|
109
|
+
const { port } = squatter.address() as AddressInfo;
|
|
110
|
+
|
|
111
|
+
const server = http.createServer(helloApp());
|
|
112
|
+
const listen = server.listen.bind(server) as (...args: unknown[]) => net.Server;
|
|
113
|
+
(server as { listen: (...args: unknown[]) => net.Server }).listen = (_port, ...rest) =>
|
|
114
|
+
listen(port, ...rest);
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
await expect(request(server).get("/hello")).rejects.toMatchObject({
|
|
118
|
+
code: "EADDRINUSE",
|
|
119
|
+
});
|
|
120
|
+
} finally {
|
|
121
|
+
for (const socket of held) socket.destroy();
|
|
122
|
+
squatter.close();
|
|
123
|
+
}
|
|
124
|
+
}, 5_000);
|
|
125
|
+
});
|
|
@@ -29,7 +29,7 @@ import { buildModules } from "./buildModules.mjs";
|
|
|
29
29
|
import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "./configPath.mjs";
|
|
30
30
|
import { createAppLogger } from "./logger.mjs";
|
|
31
31
|
import { createMetrics } from "./metrics.mjs";
|
|
32
|
-
import { installGracefulShutdown } from "./shutdown.mjs";
|
|
32
|
+
import { cleanupAllowanceFor, installGracefulShutdown } from "./shutdown.mjs";
|
|
33
33
|
import { createTerminalErrorHandler } from "./terminalError.mjs";
|
|
34
34
|
|
|
35
35
|
// Step 1: Load and validate application config (HOCON → Zod schema).
|
|
@@ -164,8 +164,16 @@ await (async (): Promise<void> => {
|
|
|
164
164
|
// from the code you deploy. See `shutdown.mts` for the stated guarantees
|
|
165
165
|
// and for the deadline the previous implementation did not have. Size
|
|
166
166
|
// `drainTimeoutMs` below your orchestrator's kill grace period.
|
|
167
|
+
//
|
|
168
|
+
// #593 slice 7: with federation grants on, cleanup gets the longest refresh
|
|
169
|
+
// tail the configured budgets allow plus a margin — 45 s under the shipped
|
|
170
|
+
// ones — rather than inheriting the drain's ten: the dispose is what waits
|
|
171
|
+
// for a rotated credential's write. The compose files size
|
|
172
|
+
// `stop_grace_period` to cover the drain, this and an exit margin for the
|
|
173
|
+
// shipped budgets; a raised budget raises both.
|
|
167
174
|
installGracefulShutdown(server, {
|
|
168
175
|
logger,
|
|
169
176
|
cleanup: () => handle.dispose(),
|
|
177
|
+
...cleanupAllowanceFor(config),
|
|
170
178
|
});
|
|
171
179
|
})();
|
|
@@ -21,19 +21,26 @@ import {
|
|
|
21
21
|
type Module,
|
|
22
22
|
memoryAccessTokenDenylistModule,
|
|
23
23
|
memoryConsentStoreModule,
|
|
24
|
+
memoryFederationGrantIntentStoreModule,
|
|
25
|
+
memoryFederationGrantStoreModule,
|
|
24
26
|
memoryRateLimiterModule,
|
|
25
27
|
memoryReplaySeenSetModule,
|
|
26
28
|
} from "@o3co/auth-provider-core";
|
|
27
29
|
import { googleFederationModule } from "@o3co/auth-provider-federation-google";
|
|
30
|
+
import { federationGrantsModules } from "@o3co/auth-provider-federation-grants";
|
|
28
31
|
import { oidcFederationModule, oidcFederationNames } from "@o3co/auth-provider-federation-oidc";
|
|
29
32
|
import {
|
|
30
33
|
oauthAuthorizationModule,
|
|
31
34
|
oauthModule,
|
|
32
35
|
oauthSessionModule,
|
|
36
|
+
subjectRevocationServiceModule,
|
|
33
37
|
} from "@o3co/auth-provider-oauth";
|
|
34
38
|
import {
|
|
35
39
|
redisAccessTokenDenylistModule,
|
|
36
40
|
redisCodeRepositoryModule,
|
|
41
|
+
redisConsentStoreModule,
|
|
42
|
+
redisFederationGrantIntentStoreModule,
|
|
43
|
+
redisFederationGrantStoreModuleFor,
|
|
37
44
|
redisFederationTokenStoreModuleFor,
|
|
38
45
|
redisRateLimiterModule,
|
|
39
46
|
redisRefreshTokenFamilyStoreModule,
|
|
@@ -102,6 +109,15 @@ export interface BuildModulesOverrides {
|
|
|
102
109
|
readonly auditSinkModule?: Module;
|
|
103
110
|
}
|
|
104
111
|
|
|
112
|
+
/**
|
|
113
|
+
* The access-token lifetime core's `reference.conf` ships on the deprecated
|
|
114
|
+
* `oauth.accessToken.expiresIn`. Not a default — nothing mints with it — only
|
|
115
|
+
* what an unmodified deployment carries there, so the deprecation line can tell
|
|
116
|
+
* an operator's override from the shipped value. Pinned against the real file
|
|
117
|
+
* by `access-token-lifetime-alias.test.mts`.
|
|
118
|
+
*/
|
|
119
|
+
const SHIPPED_ACCESS_TOKEN_EXPIRES_IN = 3600;
|
|
120
|
+
|
|
105
121
|
/**
|
|
106
122
|
* Compose the standalone v0.5.0 module list from `config`. Splitting this
|
|
107
123
|
* out of `app.mts` keeps the composition root testable: a smoke test can
|
|
@@ -156,6 +172,7 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
|
|
|
156
172
|
// first-party. `"none"` (the default) wires nothing — such clients are
|
|
157
173
|
// refused, as before — so a first-party-only deployment pays nothing and
|
|
158
174
|
// a multi-replica one is not handed a memory store it cannot run.
|
|
175
|
+
// `"redis"` (#561) is the store such a deployment selects.
|
|
159
176
|
const consentStoreAdapter = config.consentStore?.adapter ?? "none";
|
|
160
177
|
// #456: adapter switch for the federation token store. `"memory"` by
|
|
161
178
|
// default, the template's local-dev shape; `"redis"` mounts
|
|
@@ -163,6 +180,22 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
|
|
|
163
180
|
// promised and what the previous factory-based module never delivered: it
|
|
164
181
|
// built the Redis store without a client and failed at boot.
|
|
165
182
|
const federationTokenStoreAdapter = config.federationTokenStore?.type ?? "memory";
|
|
183
|
+
// #593 slice 7: federation grants — a user's standing consent that a client
|
|
184
|
+
// may obtain upstream tokens with no session behind the call. Off by
|
|
185
|
+
// default, and off installs nothing: no store, no socket, no boot
|
|
186
|
+
// requirement a deployment that never asked for grants would have to meet.
|
|
187
|
+
// On, the routes and the background registry the shutdown drains come as a
|
|
188
|
+
// pair, each store follows its own switch (grants in Redis with acquisition
|
|
189
|
+
// in memory is a supported single-replica shape), and the subject-revocation
|
|
190
|
+
// service is installed so a credential change can reach the grants — its
|
|
191
|
+
// `federationGrantStore` edge is what makes "revoke everything this subject
|
|
192
|
+
// holds" include them. Enabling the feature is also a statement that the
|
|
193
|
+
// deployment has a consent page, a callback per connection and a user
|
|
194
|
+
// repository that covers each connection's registration; the routes module
|
|
195
|
+
// refuses at boot what is missing, naming it.
|
|
196
|
+
const federationGrantsEnabled = config.federationGrants?.enabled === true;
|
|
197
|
+
const federationGrantStoreAdapter = config.federationGrantStore?.adapter ?? "memory";
|
|
198
|
+
const federationGrantIntentStoreAdapter = config.federationGrantIntentStore?.adapter ?? "memory";
|
|
166
199
|
|
|
167
200
|
// OR-9: effective code-repo adapter. `oauth.code.adapter` is the
|
|
168
201
|
// authoritative switch; the legacy `repositories.code.type = "redis"`
|
|
@@ -184,6 +217,26 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
|
|
|
184
217
|
codeRepositoryAdapter = "memory";
|
|
185
218
|
}
|
|
186
219
|
|
|
220
|
+
// The access-token lifetime's deprecated alias: `oauth.accessToken.expiresIn`
|
|
221
|
+
// (OAUTH_ACCESS_TOKEN_EXPIRES_IN) is still read as the default while
|
|
222
|
+
// `defaultExpiresIn` is unset — `resolveAccessTokenLifetime` does that, and
|
|
223
|
+
// this only says so. Core's `reference.conf` keeps the shipped lifetime on
|
|
224
|
+
// the deprecated key, so "the alias supplied the default" is every
|
|
225
|
+
// deployment that set nothing; an override of it is the one case with
|
|
226
|
+
// something to move, the reading `legacyCodeType === "redis"` gives above.
|
|
227
|
+
// See CHANGELOG for the removal version.
|
|
228
|
+
const accessToken = config.oauth.accessToken;
|
|
229
|
+
if (
|
|
230
|
+
accessToken.defaultExpiresIn === undefined &&
|
|
231
|
+
accessToken.expiresIn !== SHIPPED_ACCESS_TOKEN_EXPIRES_IN
|
|
232
|
+
) {
|
|
233
|
+
console.warn(
|
|
234
|
+
"[buildModules] `oauth.accessToken.expiresIn` (OAUTH_ACCESS_TOKEN_EXPIRES_IN) is " +
|
|
235
|
+
"deprecated; use `oauth.accessToken.defaultExpiresIn` " +
|
|
236
|
+
"(OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN) instead — see CHANGELOG for the removal version.",
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
187
240
|
const refreshTokenFamilyModules: readonly Module[] = overrides.refreshTokenFamilyModules ?? [
|
|
188
241
|
redisRefreshTokenFamilyStoreModule,
|
|
189
242
|
];
|
|
@@ -202,7 +255,12 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
|
|
|
202
255
|
codeRepositoryAdapter === "redis" ||
|
|
203
256
|
accessTokenDenylistAdapter === "redis" ||
|
|
204
257
|
replaySeenSetAdapter === "redis" ||
|
|
205
|
-
|
|
258
|
+
consentStoreAdapter === "redis" ||
|
|
259
|
+
federationTokenStoreAdapter === "redis" ||
|
|
260
|
+
// Only while the feature is on: a switch left at "redis" for a feature
|
|
261
|
+
// that is off must not open a socket.
|
|
262
|
+
(federationGrantsEnabled &&
|
|
263
|
+
(federationGrantStoreAdapter === "redis" || federationGrantIntentStoreAdapter === "redis"));
|
|
206
264
|
|
|
207
265
|
// The four user-session stores switch on `userSessionStores.adapter`; the
|
|
208
266
|
// federation-token store is always wired and switches on its own key
|
|
@@ -237,10 +295,17 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
|
|
|
237
295
|
? [redisAccessTokenDenylistModule]
|
|
238
296
|
: [memoryAccessTokenDenylistModule];
|
|
239
297
|
|
|
240
|
-
// #527: opt-in
|
|
241
|
-
//
|
|
298
|
+
// #527: opt-in. Each module provides both slots the consent step needs —
|
|
299
|
+
// the consent records and the requests parked while the page asks — so the
|
|
300
|
+
// two cannot be wired apart. The memory one declares itself replica-unsafe
|
|
301
|
+
// and `deployment.mode = "multi"` refuses it by name; the Redis one (#561)
|
|
302
|
+
// shares both over the ioredis socket.
|
|
242
303
|
const consentStoreModules: Module[] =
|
|
243
|
-
consentStoreAdapter === "
|
|
304
|
+
consentStoreAdapter === "redis"
|
|
305
|
+
? [redisConsentStoreModule]
|
|
306
|
+
: consentStoreAdapter === "memory"
|
|
307
|
+
? [memoryConsentStoreModule]
|
|
308
|
+
: [];
|
|
244
309
|
|
|
245
310
|
// #455 / #456: mutually-exclusive federation-token-store pair, the same
|
|
246
311
|
// shape as the session stores. One module per adapter, so the memory one
|
|
@@ -260,6 +325,29 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
|
|
|
260
325
|
? [redisFederationTokenStoreModuleFor({ environment: overrides.environment })]
|
|
261
326
|
: [inMemoryFederationTokenStoreModule];
|
|
262
327
|
|
|
328
|
+
// #593 slice 7: one module per store, nothing while the feature is off.
|
|
329
|
+
// The Redis grant store is built for this composition root so its
|
|
330
|
+
// plaintext guard knows which environment selected the config (#473), as
|
|
331
|
+
// the federation-token store's is. Both memory modules declare
|
|
332
|
+
// `replicaSafety`, so `deployment.mode = "multi"` refuses them by name; a
|
|
333
|
+
// Redis grant store beside memory user-session stores is refused by the
|
|
334
|
+
// routes module itself, because the grants would outlive the boundary that
|
|
335
|
+
// ends them (D13).
|
|
336
|
+
const federationGrantStoreModules: Module[] = !federationGrantsEnabled
|
|
337
|
+
? []
|
|
338
|
+
: federationGrantStoreAdapter === "redis"
|
|
339
|
+
? [
|
|
340
|
+
overrides.environment === undefined
|
|
341
|
+
? redisFederationGrantStoreModuleFor()
|
|
342
|
+
: redisFederationGrantStoreModuleFor({ environment: overrides.environment }),
|
|
343
|
+
]
|
|
344
|
+
: [memoryFederationGrantStoreModule];
|
|
345
|
+
const federationGrantIntentStoreModules: Module[] = !federationGrantsEnabled
|
|
346
|
+
? []
|
|
347
|
+
: federationGrantIntentStoreAdapter === "redis"
|
|
348
|
+
? [redisFederationGrantIntentStoreModule]
|
|
349
|
+
: [memoryFederationGrantIntentStoreModule];
|
|
350
|
+
|
|
263
351
|
return [
|
|
264
352
|
// D-5: sessionStoreModule wires the express-session middleware into the
|
|
265
353
|
// boot-planner-managed lifecycle. **Mount order is enforced by this
|
|
@@ -271,6 +359,13 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
|
|
|
271
359
|
// `session.storage.type = "memory"` declares itself replica-unsafe and
|
|
272
360
|
// `deployment.mode = "multi"` refuses it by name like the other stores.
|
|
273
361
|
sessionStoreModuleFor(config),
|
|
362
|
+
// #593 slice 7: before `oauthModule`, whose router mounts under the same
|
|
363
|
+
// `/oauth` prefix with body parsers of its own — mounted after it, a
|
|
364
|
+
// malformed body would be refused by the OAuth router's parser without
|
|
365
|
+
// this package's request id, `Cache-Control: no-store` or throttle (the
|
|
366
|
+
// package README's mounting-order rule). The browser half sits after the
|
|
367
|
+
// session middleware by its own `after`, wherever it is listed.
|
|
368
|
+
...(federationGrantsEnabled ? federationGrantsModules : []),
|
|
274
369
|
oauthModule({ config }),
|
|
275
370
|
oauthSessionModule({ config }),
|
|
276
371
|
oauthAuthorizationModule({ config }),
|
|
@@ -304,6 +399,9 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
|
|
|
304
399
|
// bundled into `storesModule` pre-Wave-5d, and the redis session-stores
|
|
305
400
|
// branch dropped the provider.
|
|
306
401
|
...federationTokenStoreModules,
|
|
402
|
+
// #593 slice 7: the grant store and the intent store, when the feature is on.
|
|
403
|
+
...federationGrantStoreModules,
|
|
404
|
+
...federationGrantIntentStoreModules,
|
|
307
405
|
// User-session-store family: redis (multi-replica) or memory (dev).
|
|
308
406
|
...sessionStoresModules,
|
|
309
407
|
// OAuth-endpoint rate limiter: redis (shared counters) or memory.
|
|
@@ -314,13 +412,19 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
|
|
|
314
412
|
// (single-instance dev). Always wired — see the switch above.
|
|
315
413
|
...accessTokenDenylistModules,
|
|
316
414
|
...replaySeenSetModules,
|
|
317
|
-
// Consent records for clients that are not first-party:
|
|
318
|
-
// nothing (#527).
|
|
415
|
+
// Consent records for clients that are not first-party: redis
|
|
416
|
+
// (multi-replica, #561), memory (single-instance), or nothing (#527).
|
|
319
417
|
...consentStoreModules,
|
|
320
418
|
// RT family store: redis by default (closes OR-1); override path
|
|
321
419
|
// swaps to `[memoryRefreshTokenFamilyStoreModule]` for unit tests.
|
|
322
420
|
...refreshTokenFamilyModules,
|
|
323
421
|
defaultRefreshTokenFamilyRotationModule,
|
|
324
422
|
defaultRefreshTokenFamilyRevocationModule,
|
|
423
|
+
// #593 slice 7: the composed "end everything this subject holds" a
|
|
424
|
+
// credential change calls, reached as `handle.components.subjectRevocationService`.
|
|
425
|
+
// Installed with the feature, which is what makes it reach the grants;
|
|
426
|
+
// the template never installed it before, so a deployment without
|
|
427
|
+
// grants is as it was — a Store there calls core's revokeAllForSubject.
|
|
428
|
+
...(federationGrantsEnabled ? [subjectRevocationServiceModule] : []),
|
|
325
429
|
];
|
|
326
430
|
}
|
|
@@ -39,7 +39,11 @@ import {
|
|
|
39
39
|
import type { GoogleProviderConfig } from "@o3co/auth-provider-federation-google";
|
|
40
40
|
import { readOidcFederationConfigs } from "@o3co/auth-provider-federation-oidc";
|
|
41
41
|
import { registerBuiltinAdapters } from "@o3co/auth-provider-foundation";
|
|
42
|
-
import {
|
|
42
|
+
import {
|
|
43
|
+
makeIoredisClients,
|
|
44
|
+
makeIoredisFederationGrantIntentStoreClient,
|
|
45
|
+
makeIoredisFederationGrantStoreClient,
|
|
46
|
+
} from "@o3co/auth-provider-redis/ioredis";
|
|
43
47
|
import { extractFederationSection } from "@o3co/auth-provider-session";
|
|
44
48
|
// Named import is required here, not default. ioredis is CJS and its entry
|
|
45
49
|
// does `module.exports = Redis` with the class re-exported as both `default`
|
|
@@ -361,11 +365,12 @@ export const storesModule: Module = defineModule({
|
|
|
361
365
|
|
|
362
366
|
/**
|
|
363
367
|
* Shared ioredis clients module — opens ONE long-lived ioredis connection
|
|
364
|
-
* per replica, wraps it via `makeIoredisClients()` (returns
|
|
368
|
+
* per replica, wraps it via `makeIoredisClients()` (returns 16 typed
|
|
365
369
|
* per-purpose clients), and exposes the slots consumed by the standalone's
|
|
366
370
|
* Redis-backed adapters (refresh-token-family + 4 user-session stores +
|
|
367
371
|
* subject-level revocation index + watermark + rate limiter + code
|
|
368
|
-
* repository + access-token denylist
|
|
372
|
+
* repository + access-token denylist + replay seen-set + federation token
|
|
373
|
+
* store + device-code store + the consent stores).
|
|
369
374
|
*
|
|
370
375
|
* Per F4 PR1 (D-2 v2) + Wave 5d unification: the previous design opened a
|
|
371
376
|
* separate ioredis socket per Redis-backed module (3+ sockets per replica).
|
|
@@ -437,6 +442,28 @@ export const standaloneRedisClientsModule: Module = defineModule({
|
|
|
437
442
|
return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
|
|
438
443
|
.sessionFederationIndexClient;
|
|
439
444
|
},
|
|
445
|
+
// #593 slice 7: the grant store and the intent store, off the same
|
|
446
|
+
// socket. Consumed by the Redis grant modules when their switches say
|
|
447
|
+
// "redis"; provided whenever this module is installed, as every slot
|
|
448
|
+
// here is, because a slot is cheap and the socket is the cost.
|
|
449
|
+
federationGrantStoreClient: async ({
|
|
450
|
+
config,
|
|
451
|
+
lifecycleRegistrar,
|
|
452
|
+
readinessRegistrar,
|
|
453
|
+
logger,
|
|
454
|
+
}) => {
|
|
455
|
+
return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
|
|
456
|
+
.federationGrantStoreClient;
|
|
457
|
+
},
|
|
458
|
+
federationGrantIntentStoreClient: async ({
|
|
459
|
+
config,
|
|
460
|
+
lifecycleRegistrar,
|
|
461
|
+
readinessRegistrar,
|
|
462
|
+
logger,
|
|
463
|
+
}) => {
|
|
464
|
+
return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
|
|
465
|
+
.federationGrantIntentStoreClient;
|
|
466
|
+
},
|
|
440
467
|
// #484: the replay seen-set behind private_key_jwt client assertions.
|
|
441
468
|
replaySeenSetClient: async ({ config, lifecycleRegistrar, readinessRegistrar, logger }) => {
|
|
442
469
|
return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
|
|
@@ -504,6 +531,23 @@ export const standaloneRedisClientsModule: Module = defineModule({
|
|
|
504
531
|
return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
|
|
505
532
|
.deviceCodeStoreClient;
|
|
506
533
|
},
|
|
534
|
+
// #561: the consent stores' clients, off the same shared socket.
|
|
535
|
+
// `redisConsentStoreModule` requires both — it provides the consent
|
|
536
|
+
// records and the parked requests together — and `buildModules` selects
|
|
537
|
+
// it under `consentStore.adapter = "redis"`.
|
|
538
|
+
consentStoreClient: async ({ config, lifecycleRegistrar, readinessRegistrar, logger }) => {
|
|
539
|
+
return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
|
|
540
|
+
.consentStoreClient;
|
|
541
|
+
},
|
|
542
|
+
pendingConsentStoreClient: async ({
|
|
543
|
+
config,
|
|
544
|
+
lifecycleRegistrar,
|
|
545
|
+
readinessRegistrar,
|
|
546
|
+
logger,
|
|
547
|
+
}) => {
|
|
548
|
+
return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
|
|
549
|
+
.pendingConsentStoreClient;
|
|
550
|
+
},
|
|
507
551
|
// #456: the federation-token-store client, off the same shared socket.
|
|
508
552
|
// `redisFederationTokenStoreModule` requires it, and nothing provided
|
|
509
553
|
// it — which is why the Redis branch of the federation store could not
|
|
@@ -612,13 +656,26 @@ const SHARED_REDIS_TIMEOUTS = {
|
|
|
612
656
|
// on PR #121). When `lifecycleRegistrar` is undefined (test scenarios
|
|
613
657
|
// that don't seed it), each call creates a fresh client; tests are
|
|
614
658
|
// isolated and don't need cross-slot sharing.
|
|
615
|
-
|
|
659
|
+
/**
|
|
660
|
+
* Every per-purpose client off the one socket: the general bundle, plus the
|
|
661
|
+
* two the federation-grant stores need (#593 slice 7). Those two have their
|
|
662
|
+
* own factories in the Redis package, so a Cluster deployment can give the
|
|
663
|
+
* grants a connection of their own; this template shares the socket, as it
|
|
664
|
+
* does for every other store.
|
|
665
|
+
*/
|
|
666
|
+
type StandaloneRedisClients = ReturnType<typeof makeIoredisClients> & {
|
|
667
|
+
readonly federationGrantStoreClient: ReturnType<typeof makeIoredisFederationGrantStoreClient>;
|
|
668
|
+
readonly federationGrantIntentStoreClient: ReturnType<
|
|
669
|
+
typeof makeIoredisFederationGrantIntentStoreClient
|
|
670
|
+
>;
|
|
671
|
+
};
|
|
672
|
+
const clientsCache = new WeakMap<LifecycleRegistrar, StandaloneRedisClients>();
|
|
616
673
|
function getOrCreateClients(
|
|
617
674
|
config: AppConfig,
|
|
618
675
|
lifecycleRegistrar: LifecycleRegistrar | undefined,
|
|
619
676
|
readinessRegistrar?: ReadinessRegistrar,
|
|
620
677
|
injectedLogger?: Logger,
|
|
621
|
-
):
|
|
678
|
+
): StandaloneRedisClients {
|
|
622
679
|
const logger = injectedLogger ?? consoleLogger;
|
|
623
680
|
const cached = lifecycleRegistrar ? clientsCache.get(lifecycleRegistrar) : undefined;
|
|
624
681
|
if (cached) return cached;
|
|
@@ -658,7 +715,11 @@ function getOrCreateClients(
|
|
|
658
715
|
// The wrapper opens its own connections for refresh rotation
|
|
659
716
|
// (`refreshTokenFamilyClient.duplicate()`), which inherit no listeners from
|
|
660
717
|
// `io`; passing the logger lets those report through the same channel.
|
|
661
|
-
const clients =
|
|
718
|
+
const clients: StandaloneRedisClients = {
|
|
719
|
+
...makeIoredisClients(io, { logger }),
|
|
720
|
+
federationGrantStoreClient: makeIoredisFederationGrantStoreClient(io),
|
|
721
|
+
federationGrantIntentStoreClient: makeIoredisFederationGrantIntentStoreClient(io),
|
|
722
|
+
};
|
|
662
723
|
if (lifecycleRegistrar) clientsCache.set(lifecycleRegistrar, clients);
|
|
663
724
|
return clients;
|
|
664
725
|
}
|
|
@@ -685,6 +746,33 @@ function optionalString(
|
|
|
685
746
|
return { [field]: value };
|
|
686
747
|
}
|
|
687
748
|
|
|
749
|
+
/**
|
|
750
|
+
* An optional boolean from the `federations.google` slice. HOCON gives a
|
|
751
|
+
* boolean; an environment override (`${?VAR}`) gives the string, in the
|
|
752
|
+
* spellings core's `coerceBooleanFromEnv` accepts: "true" / "false" / "1" /
|
|
753
|
+
* "0", trimmed and in any case. One deliberate difference: that coercion reads
|
|
754
|
+
* an empty value as false, and this refuses it. HOCON substitutes an
|
|
755
|
+
* exported-but-empty variable as "", and for a security switch that must not
|
|
756
|
+
* quietly turn the check off. Anything else is refused too — a typo that
|
|
757
|
+
* silently reads as either value is worse than a boot failure.
|
|
758
|
+
*/
|
|
759
|
+
function optionalBoolean(
|
|
760
|
+
slice: Record<string, unknown>,
|
|
761
|
+
field: string,
|
|
762
|
+
): Record<string, boolean> | Record<string, never> {
|
|
763
|
+
const value = slice[field];
|
|
764
|
+
if (value === undefined || value === null) return {};
|
|
765
|
+
if (typeof value === "boolean") return { [field]: value };
|
|
766
|
+
if (typeof value === "string") {
|
|
767
|
+
const normalized = value.trim().toLowerCase();
|
|
768
|
+
if (normalized === "true" || normalized === "1") return { [field]: true };
|
|
769
|
+
if (normalized === "false" || normalized === "0") return { [field]: false };
|
|
770
|
+
}
|
|
771
|
+
throw new Error(
|
|
772
|
+
`federations.google.${field} must be one of true, false, "true", "false", "1" or "0" when present`,
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
|
|
688
776
|
/**
|
|
689
777
|
* Google federation config bridge — supplies the typed `googleFederationConfig`
|
|
690
778
|
* ComponentMap slot from the `config.federations.google` slice.
|
|
@@ -751,6 +839,8 @@ export const googleFederationConfigModule: Module = defineModule({
|
|
|
751
839
|
...optionalString(slice, "sessionDomain"),
|
|
752
840
|
...optionalString(slice, "authCallbackUrl"),
|
|
753
841
|
...optionalString(slice, "clientUrl"),
|
|
842
|
+
// #597: absent means the provider's default, which is to require it.
|
|
843
|
+
...optionalBoolean(slice, "requireAuthorizationResponseIss"),
|
|
754
844
|
};
|
|
755
845
|
},
|
|
756
846
|
},
|