@o3co/create-auth-provider 0.14.0 → 0.16.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/README.ja.md +93 -47
- package/README.md +92 -46
- package/package.json +1 -1
- package/templates/standalone/.env.example +34 -0
- package/templates/standalone/Dockerfile +1 -1
- package/templates/standalone/README.ja.md +565 -130
- package/templates/standalone/README.md +356 -92
- package/templates/standalone/config/application.conf +77 -2
- package/templates/standalone/docker-compose.production.yml +7 -0
- package/templates/standalone/docker-compose.yml +7 -0
- package/templates/standalone/gitignore +3 -2
- package/templates/standalone/package.json +3 -2
- package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +49 -15
- package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +1103 -0
- package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +241 -0
- package/templates/standalone/src/__tests__/all-modules-composition.test.mts +1100 -0
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +40 -5
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +445 -0
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +446 -0
- package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +133 -0
- package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +85 -0
- package/templates/standalone/src/__tests__/google-session.fixture.mts +304 -0
- package/templates/standalone/src/__tests__/host-routes.test.mts +141 -0
- package/templates/standalone/src/__tests__/listen.test.mts +128 -0
- package/templates/standalone/src/__tests__/logger-projection.test.mts +77 -0
- package/templates/standalone/src/__tests__/metrics.test.mts +20 -5
- package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +187 -0
- package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +139 -0
- package/templates/standalone/src/__tests__/replica-safety.test.mts +13 -3
- package/templates/standalone/src/__tests__/router-error-answers.test.mts +149 -0
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +14 -0
- package/templates/standalone/src/__tests__/shutdown.test.mts +253 -22
- package/templates/standalone/src/__tests__/smoke.test.mts +39 -20
- package/templates/standalone/src/__tests__/store-credential.test.mts +276 -0
- package/templates/standalone/src/app.mts +30 -51
- package/templates/standalone/src/buildModules.mts +94 -12
- package/templates/standalone/src/listen.mts +66 -0
- package/templates/standalone/src/logger.mts +7 -4
- package/templates/standalone/src/modules.mts +118 -12
- package/templates/standalone/src/routes.mts +95 -0
- package/templates/standalone/src/shutdown.mts +90 -18
- package/templates/standalone/vitest.supertest-loopback.mts +22 -3
- package/templates/versions.json +15 -14
- package/templates/standalone/src/__tests__/terminal-error.test.mts +0 -96
- package/templates/standalone/src/terminalError.mts +0 -60
|
@@ -0,0 +1,276 @@
|
|
|
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
|
+
* The credential auth.provider presents to the Store, through the shipped
|
|
19
|
+
* composition: `CLIENT_USER_BEARER_TOKEN` in the environment → the HOCON
|
|
20
|
+
* layers → `AppConfigSchema` → the production `repositoriesModule` → the
|
|
21
|
+
* `"http"` user adapter → the `Authorization` header a real `node:http` Store
|
|
22
|
+
* receives. Unset, the Store receives no `Authorization` header; set blank —
|
|
23
|
+
* an exported-but-empty variable — the user repository is refused.
|
|
24
|
+
*
|
|
25
|
+
* And what a mismatch looks like from outside, with the app booted from the
|
|
26
|
+
* shipped config through `createApp`: a Store that refuses the token with a
|
|
27
|
+
* `401` and a `Bearer` challenge turns `POST /session/login` into a `503`
|
|
28
|
+
* whose log line names the refused credential and never the token; a `401`
|
|
29
|
+
* without the challenge is still a wrong password.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { createServer, type OutgoingHttpHeaders, type Server } from "node:http";
|
|
33
|
+
import { fileURLToPath } from "node:url";
|
|
34
|
+
import { inspect } from "node:util";
|
|
35
|
+
import {
|
|
36
|
+
type AppConfig,
|
|
37
|
+
AppConfigSchema,
|
|
38
|
+
createApp,
|
|
39
|
+
createKeyStoreFactory,
|
|
40
|
+
defineModule,
|
|
41
|
+
type Logger,
|
|
42
|
+
memoryRefreshTokenFamilyStoreModule,
|
|
43
|
+
registerBuiltinKeyStores,
|
|
44
|
+
type UserRepository,
|
|
45
|
+
} from "@o3co/auth-provider-core";
|
|
46
|
+
import { parseFile } from "@o3co/ts.hocon";
|
|
47
|
+
import { validate } from "@o3co/ts.hocon/zod";
|
|
48
|
+
import express from "express";
|
|
49
|
+
import request from "supertest";
|
|
50
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
51
|
+
import { buildModules } from "../buildModules.mjs";
|
|
52
|
+
import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "../configPath.mjs";
|
|
53
|
+
import { repositoriesModule } from "../modules.mjs";
|
|
54
|
+
|
|
55
|
+
const configDir = fileURLToPath(new URL("../../config", import.meta.url));
|
|
56
|
+
|
|
57
|
+
/** 32 bytes of key material — what `openssl rand -hex 32` prints. */
|
|
58
|
+
const TOKEN = "7d1f0c3b9a5e2d4f6a8c0e1b3d5f7a9c2e4b6d8f0a1c3e5b7d9f2a4c6e8b0d1f";
|
|
59
|
+
|
|
60
|
+
let servers: Server[] = [];
|
|
61
|
+
afterEach(async () => {
|
|
62
|
+
await Promise.all(
|
|
63
|
+
servers.map((server) => {
|
|
64
|
+
server.closeAllConnections();
|
|
65
|
+
return new Promise<void>((resolve) => server.close(() => resolve()));
|
|
66
|
+
}),
|
|
67
|
+
);
|
|
68
|
+
servers = [];
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/** How the Store answers every request. */
|
|
72
|
+
interface StoreAnswer {
|
|
73
|
+
readonly status: number;
|
|
74
|
+
readonly headers?: OutgoingHttpHeaders;
|
|
75
|
+
readonly body: unknown;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const USER: StoreAnswer = { status: 200, body: { id: "user-1", username: "alice" } };
|
|
79
|
+
|
|
80
|
+
/** A Store on its own loopback port that records each request's Authorization header. */
|
|
81
|
+
const recordingStore = async (
|
|
82
|
+
answer: StoreAnswer = USER,
|
|
83
|
+
): Promise<{ origin: string; heard: (string | undefined)[] }> => {
|
|
84
|
+
const heard: (string | undefined)[] = [];
|
|
85
|
+
const server = createServer((req, res) => {
|
|
86
|
+
req.resume();
|
|
87
|
+
req.on("end", () => {
|
|
88
|
+
heard.push(req.headers.authorization);
|
|
89
|
+
res.writeHead(answer.status, { "Content-Type": "application/json", ...answer.headers });
|
|
90
|
+
res.end(JSON.stringify(answer.body));
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
servers.push(server);
|
|
94
|
+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
95
|
+
const { port } = server.address() as { port: number };
|
|
96
|
+
return { origin: `http://127.0.0.1:${port}`, heard };
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** The shipped config for the production overlay, resolved against `env`. */
|
|
100
|
+
const resolve = (env: Record<string, string>): AppConfig => {
|
|
101
|
+
const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
|
|
102
|
+
return validate(
|
|
103
|
+
parseFile(envConfPath, { env })
|
|
104
|
+
.withFallback(parseFile(applicationConfPath, { env }))
|
|
105
|
+
.withFallback(parseFile(resolveLibraryReferenceConfPath(), { env })),
|
|
106
|
+
AppConfigSchema,
|
|
107
|
+
);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/** The user repository the production `repositoriesModule` builds from `config`. */
|
|
111
|
+
const userRepositoryFrom = (config: AppConfig): Promise<UserRepository> =>
|
|
112
|
+
Promise.resolve(
|
|
113
|
+
repositoriesModule.provides?.userRepository?.({ config } as never) as
|
|
114
|
+
| UserRepository
|
|
115
|
+
| Promise<UserRepository>,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const envFor = (origin: string): Record<string, string> => ({
|
|
119
|
+
OAUTH_JWT_ALGORITHM: "HS256",
|
|
120
|
+
OAUTH_JWT_SECRET: "store-credential-composition.at-least-32-bytes.ok",
|
|
121
|
+
OAUTH_JWT_ISSUER: "https://auth.test",
|
|
122
|
+
SESSION_SECRET: "store-credential-composition-session.at-least-32-bytes.ok",
|
|
123
|
+
CLIENT_USER_TYPE: "http",
|
|
124
|
+
CLIENT_USER_AUTHENTICATE_URL: `${origin}/authenticate`,
|
|
125
|
+
CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL: `${origin}/authenticate-by-token`,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("CLIENT_USER_BEARER_TOKEN reaches the Store through the shipped composition", () => {
|
|
129
|
+
it("sends Authorization: Bearer <token> on every Store call when the variable is set", async () => {
|
|
130
|
+
const { origin, heard } = await recordingStore();
|
|
131
|
+
const repo = await userRepositoryFrom(
|
|
132
|
+
resolve({ ...envFor(origin), CLIENT_USER_BEARER_TOKEN: TOKEN }),
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
await repo.authenticate("alice", "pass");
|
|
136
|
+
await repo.authenticateByToken("apple:a1");
|
|
137
|
+
|
|
138
|
+
expect(heard).toEqual([`Bearer ${TOKEN}`, `Bearer ${TOKEN}`]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("sends no Authorization header when the variable is unset", async () => {
|
|
142
|
+
const { origin, heard } = await recordingStore();
|
|
143
|
+
const repo = await userRepositoryFrom(resolve(envFor(origin)));
|
|
144
|
+
|
|
145
|
+
await repo.authenticate("alice", "pass");
|
|
146
|
+
|
|
147
|
+
expect(heard).toEqual([undefined]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("refuses the user repository when the variable is exported but empty", async () => {
|
|
151
|
+
const { origin, heard } = await recordingStore();
|
|
152
|
+
const config = resolve({ ...envFor(origin), CLIENT_USER_BEARER_TOKEN: "" });
|
|
153
|
+
|
|
154
|
+
await expect(userRepositoryFrom(config)).rejects.toThrow(/"bearerToken" must not be empty/);
|
|
155
|
+
expect(heard).toEqual([]);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("a token the Store refuses, seen from outside the booted app", () => {
|
|
160
|
+
/** Every store on memory, so the boot opens no socket but the Store's. */
|
|
161
|
+
const MEMORY_ENV: Readonly<Record<string, string>> = {
|
|
162
|
+
SESSION_SECURE: "false",
|
|
163
|
+
SESSION_NAME: "auth.session",
|
|
164
|
+
SESSION_STORAGE_TYPE: "memory",
|
|
165
|
+
USER_SESSION_STORES_ADAPTER: "memory",
|
|
166
|
+
RATE_LIMITER_ADAPTER: "memory",
|
|
167
|
+
OAUTH_CODE_ADAPTER: "memory",
|
|
168
|
+
ACCESS_TOKEN_DENYLIST_ADAPTER: "memory",
|
|
169
|
+
REPLAY_SEEN_SET_ADAPTER: "memory",
|
|
170
|
+
FEDERATION_TOKEN_STORE_TYPE: "memory",
|
|
171
|
+
CONSENT_STORE_ADAPTER: "none",
|
|
172
|
+
FEDERATION_GRANT_STORE_ADAPTER: "memory",
|
|
173
|
+
FEDERATION_GRANT_INTENT_STORE_ADAPTER: "memory",
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const testKeyStoreModule = defineModule({
|
|
177
|
+
name: "test:key-store",
|
|
178
|
+
requires: ["config"] as const,
|
|
179
|
+
provides: {
|
|
180
|
+
keyStore: async ({ config: c }) => {
|
|
181
|
+
const factory = createKeyStoreFactory();
|
|
182
|
+
registerBuiltinKeyStores(factory);
|
|
183
|
+
return factory.create({
|
|
184
|
+
type: "local",
|
|
185
|
+
...((c as AppConfig).oauth.jwt.signingKey.local ?? {}),
|
|
186
|
+
});
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
/** Every log call, as the arguments it was made with. */
|
|
192
|
+
const capturingLogger = (): { logger: Logger; lines: { level: string; args: unknown[] }[] } => {
|
|
193
|
+
const lines: { level: string; args: unknown[] }[] = [];
|
|
194
|
+
const at =
|
|
195
|
+
(level: string) =>
|
|
196
|
+
(...args: unknown[]): void => {
|
|
197
|
+
lines.push({ level, args });
|
|
198
|
+
};
|
|
199
|
+
const logger = {
|
|
200
|
+
trace: at("trace"),
|
|
201
|
+
debug: at("debug"),
|
|
202
|
+
info: at("info"),
|
|
203
|
+
warn: at("warn"),
|
|
204
|
+
error: at("error"),
|
|
205
|
+
fatal: at("fatal"),
|
|
206
|
+
child: () => logger,
|
|
207
|
+
} as Logger;
|
|
208
|
+
return { logger, lines };
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
let handleRef: Awaited<ReturnType<typeof createApp>> | undefined;
|
|
212
|
+
afterEach(async () => {
|
|
213
|
+
await handleRef?.dispose();
|
|
214
|
+
handleRef = undefined;
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The shipped config against a Store answering `answer`, booted with the
|
|
219
|
+
* production repositories module; then a real browser login — a CSRF pair,
|
|
220
|
+
* then the form post.
|
|
221
|
+
*/
|
|
222
|
+
const logInAgainst = async (answer: StoreAnswer) => {
|
|
223
|
+
const { origin, heard } = await recordingStore(answer);
|
|
224
|
+
const config = resolve({ ...envFor(origin), ...MEMORY_ENV, CLIENT_USER_BEARER_TOKEN: TOKEN });
|
|
225
|
+
const { logger, lines } = capturingLogger();
|
|
226
|
+
handleRef = await createApp({
|
|
227
|
+
modules: buildModules(config, {
|
|
228
|
+
keyStoreModule: testKeyStoreModule,
|
|
229
|
+
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
230
|
+
}),
|
|
231
|
+
bootstrapComponents: { config, pathResolver: (s: string) => s, logger },
|
|
232
|
+
});
|
|
233
|
+
const app = express().use(handleRef.router);
|
|
234
|
+
|
|
235
|
+
const csrf = await request(app).get("/session/csrf");
|
|
236
|
+
expect(csrf.status).toBe(200);
|
|
237
|
+
const login = await request(app)
|
|
238
|
+
.post("/session/login")
|
|
239
|
+
.set("Cookie", csrf.headers["set-cookie"] as unknown as string[])
|
|
240
|
+
.set(csrf.body.header_name as string, csrf.body.csrf_token as string)
|
|
241
|
+
.type("form")
|
|
242
|
+
.send({ username: "alice", password: "correct-horse-battery-staple" });
|
|
243
|
+
const logged = lines.map((line) =>
|
|
244
|
+
inspect(line, { depth: Number.POSITIVE_INFINITY, showHidden: true }),
|
|
245
|
+
);
|
|
246
|
+
return { login, heard, logged };
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
it("answers the login 503 and logs the refused credential by name, never the token", async () => {
|
|
250
|
+
const { login, heard, logged } = await logInAgainst({
|
|
251
|
+
status: 401,
|
|
252
|
+
headers: { "WWW-Authenticate": 'Bearer error="invalid_token"' },
|
|
253
|
+
body: { error: "invalid_token" },
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
expect(heard).toEqual([`Bearer ${TOKEN}`]);
|
|
257
|
+
expect(login.status).toBe(503);
|
|
258
|
+
expect(login.body.error).toBe("temporarily_unavailable");
|
|
259
|
+
expect(logged.filter((line) => /refused this deployment's credential/.test(line))).toHaveLength(
|
|
260
|
+
1,
|
|
261
|
+
);
|
|
262
|
+
expect(logged.filter((line) => line.includes(TOKEN))).toEqual([]);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("still answers a 401 without the challenge as a wrong password", async () => {
|
|
266
|
+
const { login, heard, logged } = await logInAgainst({
|
|
267
|
+
status: 401,
|
|
268
|
+
body: { error: "invalid_credentials" },
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
expect(heard).toEqual([`Bearer ${TOKEN}`]);
|
|
272
|
+
expect(login.status).toBe(401);
|
|
273
|
+
expect(login.body.error).toBe("invalid_credentials");
|
|
274
|
+
expect(logged.filter((line) => /refused this deployment's credential/.test(line))).toEqual([]);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
@@ -14,23 +14,18 @@
|
|
|
14
14
|
* limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
import { fileURLToPath } from "node:url";
|
|
17
|
-
import {
|
|
18
|
-
type AppConfig,
|
|
19
|
-
AppConfigSchema,
|
|
20
|
-
createApp,
|
|
21
|
-
createHealthcheckRouter,
|
|
22
|
-
createReadinessRouter,
|
|
23
|
-
} from "@o3co/auth-provider-core";
|
|
17
|
+
import { type AppConfig, AppConfigSchema, createApp } from "@o3co/auth-provider-core";
|
|
24
18
|
import { parseFile } from "@o3co/ts.hocon";
|
|
25
19
|
import { validate } from "@o3co/ts.hocon/zod";
|
|
26
20
|
import express from "express";
|
|
27
21
|
import helmet from "helmet";
|
|
28
22
|
import { buildModules } from "./buildModules.mjs";
|
|
29
23
|
import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "./configPath.mjs";
|
|
24
|
+
import { listen } from "./listen.mjs";
|
|
30
25
|
import { createAppLogger } from "./logger.mjs";
|
|
31
26
|
import { createMetrics } from "./metrics.mjs";
|
|
32
|
-
import {
|
|
33
|
-
import {
|
|
27
|
+
import { mountRoutes } from "./routes.mjs";
|
|
28
|
+
import { cleanupAllowanceFor, installGracefulShutdown } from "./shutdown.mjs";
|
|
34
29
|
|
|
35
30
|
// Step 1: Load and validate application config (HOCON → Zod schema).
|
|
36
31
|
// ENV = CONFIG_ENV || NODE_ENV || "development"; missing {ENV}.conf is fatal.
|
|
@@ -103,8 +98,10 @@ await (async (): Promise<void> => {
|
|
|
103
98
|
// `environment` is the name the config above was selected by (#473): the
|
|
104
99
|
// Redis federation-token store's plaintext guard reads it in addition to
|
|
105
100
|
// NODE_ENV, so `CONFIG_ENV=production` is production to the guard too.
|
|
101
|
+
// `logger` is where the composition's own notices go (a deprecated config
|
|
102
|
+
// key), the same logger every module gets through the slot below.
|
|
106
103
|
const handle = await createApp({
|
|
107
|
-
modules: buildModules(config, { environment: env }),
|
|
104
|
+
modules: buildModules(config, { environment: env, logger }),
|
|
108
105
|
bootstrapComponents: {
|
|
109
106
|
config,
|
|
110
107
|
pathResolver: import.meta.resolve,
|
|
@@ -112,48 +109,22 @@ await (async (): Promise<void> => {
|
|
|
112
109
|
},
|
|
113
110
|
});
|
|
114
111
|
|
|
115
|
-
// Step 4:
|
|
116
|
-
// reachable even when the auth pipeline is
|
|
117
|
-
// an operator needs an answer from them
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
// defaults, so a replica that has lost it answers 503 here and should be
|
|
127
|
-
// taken out of rotation. Probes are contributed by the builders that own
|
|
128
|
-
// each connection; a memory-only deployment registers none and is always
|
|
129
|
-
// ready.
|
|
130
|
-
app.use(
|
|
131
|
-
createReadinessRouter(express, {
|
|
132
|
-
probes: handle.readinessProbes,
|
|
133
|
-
timeoutMs: config.http.readinessTimeoutMs,
|
|
134
|
-
logger,
|
|
135
|
-
}),
|
|
136
|
-
);
|
|
137
|
-
|
|
138
|
-
// Prometheus scrape endpoint. Same reasoning as the probes above: it has to
|
|
139
|
-
// keep answering while the auth pipeline is degraded, because that is when
|
|
140
|
-
// the series matter.
|
|
141
|
-
app.use(
|
|
142
|
-
metrics.route(express, {
|
|
143
|
-
probes: handle.readinessProbes,
|
|
144
|
-
probeTimeoutMs: config.http.readinessTimeoutMs,
|
|
145
|
-
}),
|
|
146
|
-
);
|
|
147
|
-
|
|
148
|
-
// Step 5: Mount the composed auth router and start the HTTP server.
|
|
149
|
-
app.use(handle.router);
|
|
150
|
-
// Terminal error handler LAST (#293 item 8): Express routes an error only
|
|
151
|
-
// to handlers registered after the route that threw it. See
|
|
152
|
-
// `terminalError.mts` for what it catches and why.
|
|
153
|
-
app.use(createTerminalErrorHandler(logger));
|
|
154
|
-
const server = app.listen(config.http.port, (): void => {
|
|
155
|
-
logger.info(`Server is running on http://localhost:${config.http.port}`);
|
|
112
|
+
// Step 4: the host-level routes (liveness, readiness, metrics) ahead of the
|
|
113
|
+
// auth router so they remain reachable even when the auth pipeline is
|
|
114
|
+
// degraded — which is exactly when an operator needs an answer from them —
|
|
115
|
+
// then the composed auth router, then the terminal error handler. See
|
|
116
|
+
// `routes.mts` for what each is and why the order is what it is.
|
|
117
|
+
mountRoutes(app, {
|
|
118
|
+
router: handle.router,
|
|
119
|
+
probes: handle.readinessProbes,
|
|
120
|
+
readinessTimeoutMs: config.http.readinessTimeoutMs,
|
|
121
|
+
metrics,
|
|
122
|
+
logger,
|
|
156
123
|
});
|
|
124
|
+
// Step 5: start the HTTP server.
|
|
125
|
+
// Resolves once the socket is bound (`server_listening`); a port that
|
|
126
|
+
// cannot be bound rejects, and boot fails with that error (`listen.mts`).
|
|
127
|
+
const server = await listen(app, config.http.port, logger);
|
|
157
128
|
|
|
158
129
|
// Step 6: Graceful shutdown — handle.dispose() runs reverse-topological
|
|
159
130
|
// per-component cleanup (per A2-β §8.1) plus D-5 LifecycleRegistrar drain
|
|
@@ -164,8 +135,16 @@ await (async (): Promise<void> => {
|
|
|
164
135
|
// from the code you deploy. See `shutdown.mts` for the stated guarantees
|
|
165
136
|
// and for the deadline the previous implementation did not have. Size
|
|
166
137
|
// `drainTimeoutMs` below your orchestrator's kill grace period.
|
|
138
|
+
//
|
|
139
|
+
// #593 slice 7: with federation grants on, cleanup gets the longest refresh
|
|
140
|
+
// tail the configured budgets allow plus a margin — 45 s under the shipped
|
|
141
|
+
// ones — rather than inheriting the drain's ten: the dispose is what waits
|
|
142
|
+
// for a rotated credential's write. The compose files size
|
|
143
|
+
// `stop_grace_period` to cover the drain, this and an exit margin for the
|
|
144
|
+
// shipped budgets; a raised budget raises both.
|
|
167
145
|
installGracefulShutdown(server, {
|
|
168
146
|
logger,
|
|
169
147
|
cleanup: () => handle.dispose(),
|
|
148
|
+
...cleanupAllowanceFor(config),
|
|
170
149
|
});
|
|
171
150
|
})();
|
|
@@ -15,26 +15,34 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import {
|
|
17
17
|
type AppConfig,
|
|
18
|
+
consoleLogger,
|
|
18
19
|
defaultRefreshTokenFamilyRevocationModule,
|
|
19
20
|
defaultRefreshTokenFamilyRotationModule,
|
|
20
21
|
jwksModule,
|
|
22
|
+
type Logger,
|
|
21
23
|
type Module,
|
|
22
24
|
memoryAccessTokenDenylistModule,
|
|
23
25
|
memoryConsentStoreModule,
|
|
26
|
+
memoryFederationGrantIntentStoreModule,
|
|
27
|
+
memoryFederationGrantStoreModule,
|
|
24
28
|
memoryRateLimiterModule,
|
|
25
29
|
memoryReplaySeenSetModule,
|
|
26
30
|
} from "@o3co/auth-provider-core";
|
|
27
31
|
import { googleFederationModule } from "@o3co/auth-provider-federation-google";
|
|
32
|
+
import { federationGrantsModules } from "@o3co/auth-provider-federation-grants";
|
|
28
33
|
import { oidcFederationModule, oidcFederationNames } from "@o3co/auth-provider-federation-oidc";
|
|
29
34
|
import {
|
|
30
35
|
oauthAuthorizationModule,
|
|
31
36
|
oauthModule,
|
|
32
37
|
oauthSessionModule,
|
|
38
|
+
subjectRevocationServiceModule,
|
|
33
39
|
} from "@o3co/auth-provider-oauth";
|
|
34
40
|
import {
|
|
35
41
|
redisAccessTokenDenylistModule,
|
|
36
42
|
redisCodeRepositoryModule,
|
|
37
43
|
redisConsentStoreModule,
|
|
44
|
+
redisFederationGrantIntentStoreModule,
|
|
45
|
+
redisFederationGrantStoreModuleFor,
|
|
38
46
|
redisFederationTokenStoreModuleFor,
|
|
39
47
|
redisRateLimiterModule,
|
|
40
48
|
redisRefreshTokenFamilyStoreModule,
|
|
@@ -59,10 +67,10 @@ import {
|
|
|
59
67
|
} from "./modules.mjs";
|
|
60
68
|
|
|
61
69
|
/**
|
|
62
|
-
* Overrides for the composition. All but `environment`
|
|
63
|
-
* the smoke test substitute in-memory implementations of
|
|
64
|
-
* modules, and production callers should not pass them
|
|
65
|
-
* standalone scaffold.
|
|
70
|
+
* Overrides for the composition. All but `environment` and `logger` are
|
|
71
|
+
* test-only: they let the smoke test substitute in-memory implementations of
|
|
72
|
+
* the file-system-backed modules, and production callers should not pass them
|
|
73
|
+
* — the defaults match the standalone scaffold.
|
|
66
74
|
*/
|
|
67
75
|
export interface BuildModulesOverrides {
|
|
68
76
|
/**
|
|
@@ -74,6 +82,12 @@ export interface BuildModulesOverrides {
|
|
|
74
82
|
* off the config either way).
|
|
75
83
|
*/
|
|
76
84
|
readonly environment?: string;
|
|
85
|
+
/**
|
|
86
|
+
* Where the composition's own notices go — a deprecated config key, one
|
|
87
|
+
* `config_key_deprecated` line (warn) each. `app.mts` passes the logger it
|
|
88
|
+
* hands every module; omitted, `consoleLogger`.
|
|
89
|
+
*/
|
|
90
|
+
readonly logger?: Logger;
|
|
77
91
|
readonly keyStoreModule?: Module;
|
|
78
92
|
readonly repositoriesModule?: Module;
|
|
79
93
|
readonly storesModule?: Module;
|
|
@@ -141,6 +155,7 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
|
|
|
141
155
|
const googleEnabled =
|
|
142
156
|
extractFederationSection(config.federations ?? {}, "google")?.type === "google";
|
|
143
157
|
const oidcFederations = oidcFederationNames(config.federations ?? {});
|
|
158
|
+
const logger = overrides.logger ?? consoleLogger;
|
|
144
159
|
|
|
145
160
|
// Wave 5d (IH-14 + OR-M1 + OR-4) + OR-9: adapter-driven branching for
|
|
146
161
|
// the OAuth-endpoint rate limiter, the user-session-store family, AND
|
|
@@ -174,6 +189,22 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
|
|
|
174
189
|
// promised and what the previous factory-based module never delivered: it
|
|
175
190
|
// built the Redis store without a client and failed at boot.
|
|
176
191
|
const federationTokenStoreAdapter = config.federationTokenStore?.type ?? "memory";
|
|
192
|
+
// #593 slice 7: federation grants — a user's standing consent that a client
|
|
193
|
+
// may obtain upstream tokens with no session behind the call. Off by
|
|
194
|
+
// default, and off installs nothing: no store, no socket, no boot
|
|
195
|
+
// requirement a deployment that never asked for grants would have to meet.
|
|
196
|
+
// On, the routes and the background registry the shutdown drains come as a
|
|
197
|
+
// pair, each store follows its own switch (grants in Redis with acquisition
|
|
198
|
+
// in memory is a supported single-replica shape), and the subject-revocation
|
|
199
|
+
// service is installed so a credential change can reach the grants — its
|
|
200
|
+
// `federationGrantStore` edge is what makes "revoke everything this subject
|
|
201
|
+
// holds" include them. Enabling the feature is also a statement that the
|
|
202
|
+
// deployment has a consent page, a callback per connection and a user
|
|
203
|
+
// repository that covers each connection's registration; the routes module
|
|
204
|
+
// refuses at boot what is missing, naming it.
|
|
205
|
+
const federationGrantsEnabled = config.federationGrants?.enabled === true;
|
|
206
|
+
const federationGrantStoreAdapter = config.federationGrantStore?.adapter ?? "memory";
|
|
207
|
+
const federationGrantIntentStoreAdapter = config.federationGrantIntentStore?.adapter ?? "memory";
|
|
177
208
|
|
|
178
209
|
// OR-9: effective code-repo adapter. `oauth.code.adapter` is the
|
|
179
210
|
// authoritative switch; the legacy `repositories.code.type = "redis"`
|
|
@@ -186,9 +217,14 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
|
|
|
186
217
|
if (oauthCodeAdapter !== undefined) {
|
|
187
218
|
codeRepositoryAdapter = oauthCodeAdapter;
|
|
188
219
|
} else if (legacyCodeType === "redis") {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
220
|
+
logger.warn(
|
|
221
|
+
{
|
|
222
|
+
key: "repositories.code.type",
|
|
223
|
+
env: "CLIENT_CODE_TYPE",
|
|
224
|
+
replacement: "oauth.code.adapter",
|
|
225
|
+
replacementEnv: "OAUTH_CODE_ADAPTER",
|
|
226
|
+
},
|
|
227
|
+
"config_key_deprecated",
|
|
192
228
|
);
|
|
193
229
|
codeRepositoryAdapter = "redis";
|
|
194
230
|
} else {
|
|
@@ -208,10 +244,14 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
|
|
|
208
244
|
accessToken.defaultExpiresIn === undefined &&
|
|
209
245
|
accessToken.expiresIn !== SHIPPED_ACCESS_TOKEN_EXPIRES_IN
|
|
210
246
|
) {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
"
|
|
214
|
-
|
|
247
|
+
logger.warn(
|
|
248
|
+
{
|
|
249
|
+
key: "oauth.accessToken.expiresIn",
|
|
250
|
+
env: "OAUTH_ACCESS_TOKEN_EXPIRES_IN",
|
|
251
|
+
replacement: "oauth.accessToken.defaultExpiresIn",
|
|
252
|
+
replacementEnv: "OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN",
|
|
253
|
+
},
|
|
254
|
+
"config_key_deprecated",
|
|
215
255
|
);
|
|
216
256
|
}
|
|
217
257
|
|
|
@@ -234,7 +274,11 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
|
|
|
234
274
|
accessTokenDenylistAdapter === "redis" ||
|
|
235
275
|
replaySeenSetAdapter === "redis" ||
|
|
236
276
|
consentStoreAdapter === "redis" ||
|
|
237
|
-
federationTokenStoreAdapter === "redis"
|
|
277
|
+
federationTokenStoreAdapter === "redis" ||
|
|
278
|
+
// Only while the feature is on: a switch left at "redis" for a feature
|
|
279
|
+
// that is off must not open a socket.
|
|
280
|
+
(federationGrantsEnabled &&
|
|
281
|
+
(federationGrantStoreAdapter === "redis" || federationGrantIntentStoreAdapter === "redis"));
|
|
238
282
|
|
|
239
283
|
// The four user-session stores switch on `userSessionStores.adapter`; the
|
|
240
284
|
// federation-token store is always wired and switches on its own key
|
|
@@ -299,6 +343,29 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
|
|
|
299
343
|
? [redisFederationTokenStoreModuleFor({ environment: overrides.environment })]
|
|
300
344
|
: [inMemoryFederationTokenStoreModule];
|
|
301
345
|
|
|
346
|
+
// #593 slice 7: one module per store, nothing while the feature is off.
|
|
347
|
+
// The Redis grant store is built for this composition root so its
|
|
348
|
+
// plaintext guard knows which environment selected the config (#473), as
|
|
349
|
+
// the federation-token store's is. Both memory modules declare
|
|
350
|
+
// `replicaSafety`, so `deployment.mode = "multi"` refuses them by name; a
|
|
351
|
+
// Redis grant store beside memory user-session stores is refused by the
|
|
352
|
+
// routes module itself, because the grants would outlive the boundary that
|
|
353
|
+
// ends them (D13).
|
|
354
|
+
const federationGrantStoreModules: Module[] = !federationGrantsEnabled
|
|
355
|
+
? []
|
|
356
|
+
: federationGrantStoreAdapter === "redis"
|
|
357
|
+
? [
|
|
358
|
+
overrides.environment === undefined
|
|
359
|
+
? redisFederationGrantStoreModuleFor()
|
|
360
|
+
: redisFederationGrantStoreModuleFor({ environment: overrides.environment }),
|
|
361
|
+
]
|
|
362
|
+
: [memoryFederationGrantStoreModule];
|
|
363
|
+
const federationGrantIntentStoreModules: Module[] = !federationGrantsEnabled
|
|
364
|
+
? []
|
|
365
|
+
: federationGrantIntentStoreAdapter === "redis"
|
|
366
|
+
? [redisFederationGrantIntentStoreModule]
|
|
367
|
+
: [memoryFederationGrantIntentStoreModule];
|
|
368
|
+
|
|
302
369
|
return [
|
|
303
370
|
// D-5: sessionStoreModule wires the express-session middleware into the
|
|
304
371
|
// boot-planner-managed lifecycle. **Mount order is enforced by this
|
|
@@ -310,6 +377,12 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
|
|
|
310
377
|
// `session.storage.type = "memory"` declares itself replica-unsafe and
|
|
311
378
|
// `deployment.mode = "multi"` refuses it by name like the other stores.
|
|
312
379
|
sessionStoreModuleFor(config),
|
|
380
|
+
// #593 slice 7: the grants routes mount under `/oauth` beside
|
|
381
|
+
// `oauthModule`'s router, which parses the bodies of its own routes
|
|
382
|
+
// only, so each parses its own requests and their place relative to
|
|
383
|
+
// `oauthModule` does not matter. The browser half sits after the
|
|
384
|
+
// session middleware by its own `after`, wherever it is listed.
|
|
385
|
+
...(federationGrantsEnabled ? federationGrantsModules : []),
|
|
313
386
|
oauthModule({ config }),
|
|
314
387
|
oauthSessionModule({ config }),
|
|
315
388
|
oauthAuthorizationModule({ config }),
|
|
@@ -343,6 +416,9 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
|
|
|
343
416
|
// bundled into `storesModule` pre-Wave-5d, and the redis session-stores
|
|
344
417
|
// branch dropped the provider.
|
|
345
418
|
...federationTokenStoreModules,
|
|
419
|
+
// #593 slice 7: the grant store and the intent store, when the feature is on.
|
|
420
|
+
...federationGrantStoreModules,
|
|
421
|
+
...federationGrantIntentStoreModules,
|
|
346
422
|
// User-session-store family: redis (multi-replica) or memory (dev).
|
|
347
423
|
...sessionStoresModules,
|
|
348
424
|
// OAuth-endpoint rate limiter: redis (shared counters) or memory.
|
|
@@ -361,5 +437,11 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
|
|
|
361
437
|
...refreshTokenFamilyModules,
|
|
362
438
|
defaultRefreshTokenFamilyRotationModule,
|
|
363
439
|
defaultRefreshTokenFamilyRevocationModule,
|
|
440
|
+
// #593 slice 7: the composed "end everything this subject holds" a
|
|
441
|
+
// credential change calls, reached as `handle.components.subjectRevocationService`.
|
|
442
|
+
// Installed with the feature, which is what makes it reach the grants;
|
|
443
|
+
// the template never installed it before, so a deployment without
|
|
444
|
+
// grants is as it was — a Store there calls core's revokeAllForSubject.
|
|
445
|
+
...(federationGrantsEnabled ? [subjectRevocationServiceModule] : []),
|
|
364
446
|
];
|
|
365
447
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
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
|
+
import { createServer, type Server } from "node:http";
|
|
17
|
+
import type { AddressInfo } from "node:net";
|
|
18
|
+
import { type Logger, loggableError } from "@o3co/auth-provider-core";
|
|
19
|
+
import type { Express } from "express";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Start the HTTP listener, and say so once it is bound.
|
|
23
|
+
*
|
|
24
|
+
* Resolves with the server once its socket is bound, after logging
|
|
25
|
+
* `server_listening` (info) with the port the socket holds — the configured
|
|
26
|
+
* one, or the one the OS picked for `0`. Rejects with the server's error when
|
|
27
|
+
* the socket cannot be bound (`EADDRINUSE`, `EACCES`), and logs nothing: the
|
|
28
|
+
* composition root awaits this, so the error ends boot the way any other boot
|
|
29
|
+
* failure does.
|
|
30
|
+
*
|
|
31
|
+
* The server is built here (`http.createServer(app)`, as `app.listen` itself
|
|
32
|
+
* does) and its events are wired here, so none of this rests on how a given
|
|
33
|
+
* Express version wires them. Until the socket is bound, one `error` listener
|
|
34
|
+
* turns a bind failure into the rejection. Once it is bound, that listener is
|
|
35
|
+
* removed and the server gets the one it keeps: a later `error` — an `accept`
|
|
36
|
+
* that fails with EMFILE — is logged as `server_error` (error, `err`: core's
|
|
37
|
+
* `loggableError` projection) and the process keeps running, the server
|
|
38
|
+
* accepting what it can. Without a listener of its own, such an error would be
|
|
39
|
+
* thrown out of the process.
|
|
40
|
+
*
|
|
41
|
+
* The standalone used to call `app.listen` with a callback that ignored its
|
|
42
|
+
* argument: Express 5 hands that callback the bind error too, so it announced
|
|
43
|
+
* a server on a port another process held and swallowed the error that should
|
|
44
|
+
* have ended the process.
|
|
45
|
+
*/
|
|
46
|
+
export function listen(app: Express, port: number, logger: Logger): Promise<Server> {
|
|
47
|
+
const server = createServer(app);
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
const refuse = (err: Error): void => {
|
|
50
|
+
server.off("listening", bound);
|
|
51
|
+
reject(err);
|
|
52
|
+
};
|
|
53
|
+
const bound = (): void => {
|
|
54
|
+
server.off("error", refuse);
|
|
55
|
+
server.on("error", (serverErr: Error) => {
|
|
56
|
+
logger.error({ err: loggableError(serverErr) }, "server_error");
|
|
57
|
+
});
|
|
58
|
+
const address = server.address() as AddressInfo | null;
|
|
59
|
+
logger.info({ port: address?.port ?? port }, "server_listening");
|
|
60
|
+
resolve(server);
|
|
61
|
+
};
|
|
62
|
+
server.once("error", refuse);
|
|
63
|
+
server.once("listening", bound);
|
|
64
|
+
server.listen(port);
|
|
65
|
+
});
|
|
66
|
+
}
|