@o3co/create-auth-provider 0.15.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 +12 -0
- package/templates/standalone/Dockerfile +1 -1
- package/templates/standalone/README.ja.md +516 -154
- package/templates/standalone/README.md +218 -91
- package/templates/standalone/config/application.conf +26 -3
- package/templates/standalone/gitignore +3 -2
- package/templates/standalone/package.json +1 -1
- 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 +19 -5
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +64 -3
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +15 -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 +28 -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__/shutdown.test.mts +157 -20
- 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 +21 -50
- package/templates/standalone/src/buildModules.mts +33 -16
- package/templates/standalone/src/listen.mts +66 -0
- package/templates/standalone/src/logger.mts +7 -4
- package/templates/standalone/src/modules.mts +42 -8
- package/templates/standalone/src/routes.mts +95 -0
- package/templates/standalone/src/shutdown.mts +20 -14
- package/templates/versions.json +15 -15
- 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";
|
|
27
|
+
import { mountRoutes } from "./routes.mjs";
|
|
32
28
|
import { cleanupAllowanceFor, installGracefulShutdown } from "./shutdown.mjs";
|
|
33
|
-
import { createTerminalErrorHandler } from "./terminalError.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
|
|
@@ -15,9 +15,11 @@
|
|
|
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,
|
|
@@ -65,10 +67,10 @@ import {
|
|
|
65
67
|
} from "./modules.mjs";
|
|
66
68
|
|
|
67
69
|
/**
|
|
68
|
-
* Overrides for the composition. All but `environment`
|
|
69
|
-
* the smoke test substitute in-memory implementations of
|
|
70
|
-
* modules, and production callers should not pass them
|
|
71
|
-
* 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.
|
|
72
74
|
*/
|
|
73
75
|
export interface BuildModulesOverrides {
|
|
74
76
|
/**
|
|
@@ -80,6 +82,12 @@ export interface BuildModulesOverrides {
|
|
|
80
82
|
* off the config either way).
|
|
81
83
|
*/
|
|
82
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;
|
|
83
91
|
readonly keyStoreModule?: Module;
|
|
84
92
|
readonly repositoriesModule?: Module;
|
|
85
93
|
readonly storesModule?: Module;
|
|
@@ -147,6 +155,7 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
|
|
|
147
155
|
const googleEnabled =
|
|
148
156
|
extractFederationSection(config.federations ?? {}, "google")?.type === "google";
|
|
149
157
|
const oidcFederations = oidcFederationNames(config.federations ?? {});
|
|
158
|
+
const logger = overrides.logger ?? consoleLogger;
|
|
150
159
|
|
|
151
160
|
// Wave 5d (IH-14 + OR-M1 + OR-4) + OR-9: adapter-driven branching for
|
|
152
161
|
// the OAuth-endpoint rate limiter, the user-session-store family, AND
|
|
@@ -208,9 +217,14 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
|
|
|
208
217
|
if (oauthCodeAdapter !== undefined) {
|
|
209
218
|
codeRepositoryAdapter = oauthCodeAdapter;
|
|
210
219
|
} else if (legacyCodeType === "redis") {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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",
|
|
214
228
|
);
|
|
215
229
|
codeRepositoryAdapter = "redis";
|
|
216
230
|
} else {
|
|
@@ -230,10 +244,14 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
|
|
|
230
244
|
accessToken.defaultExpiresIn === undefined &&
|
|
231
245
|
accessToken.expiresIn !== SHIPPED_ACCESS_TOKEN_EXPIRES_IN
|
|
232
246
|
) {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
"
|
|
236
|
-
|
|
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",
|
|
237
255
|
);
|
|
238
256
|
}
|
|
239
257
|
|
|
@@ -359,11 +377,10 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
|
|
|
359
377
|
// `session.storage.type = "memory"` declares itself replica-unsafe and
|
|
360
378
|
// `deployment.mode = "multi"` refuses it by name like the other stores.
|
|
361
379
|
sessionStoreModuleFor(config),
|
|
362
|
-
// #593 slice 7:
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
// package README's mounting-order rule). The browser half sits after the
|
|
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
|
|
367
384
|
// session middleware by its own `after`, wherever it is listed.
|
|
368
385
|
...(federationGrantsEnabled ? federationGrantsModules : []),
|
|
369
386
|
oauthModule({ config }),
|
|
@@ -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
|
+
}
|
|
@@ -44,10 +44,13 @@ export function createAppLogger(config: AppConfig): Logger {
|
|
|
44
44
|
return pino({
|
|
45
45
|
name: "provider",
|
|
46
46
|
level: config.logging.level,
|
|
47
|
-
// `err` is pino's conventional key for an
|
|
48
|
-
// event in this stack uses it — `logger.error({ err },
|
|
49
|
-
//
|
|
50
|
-
//
|
|
47
|
+
// `err` is pino's conventional key for an error, and every structured
|
|
48
|
+
// event in this stack uses it — `logger.error({ err: loggableError(err) },
|
|
49
|
+
// "…_error")`. Core's projection is plain data with no `message`, so
|
|
50
|
+
// this serialiser hands it through as it is, every cause's fields
|
|
51
|
+
// included. It is here for an Error handed to it as it is, which would
|
|
52
|
+
// otherwise stringify to `{}`, the stack lost exactly where it is
|
|
53
|
+
// needed.
|
|
51
54
|
serializers: { err: stdSerializers.err },
|
|
52
55
|
});
|
|
53
56
|
}
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
defineModule,
|
|
31
31
|
type LifecycleRegistrar,
|
|
32
32
|
type Logger,
|
|
33
|
+
loggableError,
|
|
33
34
|
type Module,
|
|
34
35
|
type ReadinessRegistrar,
|
|
35
36
|
registerBuiltinAuditSinks,
|
|
@@ -370,7 +371,11 @@ export const storesModule: Module = defineModule({
|
|
|
370
371
|
* Redis-backed adapters (refresh-token-family + 4 user-session stores +
|
|
371
372
|
* subject-level revocation index + watermark + rate limiter + code
|
|
372
373
|
* repository + access-token denylist + replay seen-set + federation token
|
|
373
|
-
* store + device-code store + the consent stores)
|
|
374
|
+
* store + device-code store + WebAuthn challenge store + the consent stores),
|
|
375
|
+
* and the two federation-grant stores' clients from their own factories.
|
|
376
|
+
* Every store module `@o3co/auth-provider-redis` ships finds its client
|
|
377
|
+
* slot here, including the ones this template does not select — pinned by
|
|
378
|
+
* `all-modules-composition.multi.test.mts`.
|
|
374
379
|
*
|
|
375
380
|
* Per F4 PR1 (D-2 v2) + Wave 5d unification: the previous design opened a
|
|
376
381
|
* separate ioredis socket per Redis-backed module (3+ sockets per replica).
|
|
@@ -396,11 +401,12 @@ export const storesModule: Module = defineModule({
|
|
|
396
401
|
* the single connection cleanly.
|
|
397
402
|
*
|
|
398
403
|
* Conditional inclusion: `buildModules` only adds this module to the
|
|
399
|
-
* manifest when at least one
|
|
400
|
-
* (`
|
|
401
|
-
*
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
+
* manifest when at least one composed module needs a Redis client
|
|
405
|
+
* (`usingRedisAnywhere` there): an adapter switch set to `"redis"` — the two
|
|
406
|
+
* federation-grant store switches only while `federationGrants.enabled` —
|
|
407
|
+
* and the refresh-token family store, which is Redis-backed unless a test
|
|
408
|
+
* override replaces it. Adding the module unconditionally would open an
|
|
409
|
+
* ioredis socket for memory-only compositions.
|
|
404
410
|
*/
|
|
405
411
|
export const standaloneRedisClientsModule: Module = defineModule({
|
|
406
412
|
name: "standalone:redis-clients",
|
|
@@ -531,6 +537,15 @@ export const standaloneRedisClientsModule: Module = defineModule({
|
|
|
531
537
|
return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
|
|
532
538
|
.deviceCodeStoreClient;
|
|
533
539
|
},
|
|
540
|
+
// The WebAuthn challenge store's client, off the same shared socket, for
|
|
541
|
+
// the reason the device-code slot above is here: this template does not
|
|
542
|
+
// mount WebAuthn, and a deployment that adds `webauthnModule` with
|
|
543
|
+
// `redisChallengeStoreModule` would otherwise be refused at boot for a
|
|
544
|
+
// `challengeStoreClient` nothing provided.
|
|
545
|
+
challengeStoreClient: async ({ config, lifecycleRegistrar, readinessRegistrar, logger }) => {
|
|
546
|
+
return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
|
|
547
|
+
.challengeStoreClient;
|
|
548
|
+
},
|
|
534
549
|
// #561: the consent stores' clients, off the same shared socket.
|
|
535
550
|
// `redisConsentStoreModule` requires both — it provides the consent
|
|
536
551
|
// records and the parked requests together — and `buildModules` selects
|
|
@@ -695,9 +710,11 @@ function getOrCreateClients(
|
|
|
695
710
|
|
|
696
711
|
// Attach an error handler so unhandled "error" events do not crash the
|
|
697
712
|
// process. Initial connection failures surface here; downstream adapter
|
|
698
|
-
// operations then fail visibly.
|
|
713
|
+
// operations then fail visibly. It logs the projection: ioredis puts the
|
|
714
|
+
// command a reply answered on the error, and for a refused handshake that
|
|
715
|
+
// is `AUTH` with the configured password.
|
|
699
716
|
io.on("error", (err: unknown) => {
|
|
700
|
-
logger.error({ err }, "standalone_redis_clients_error");
|
|
717
|
+
logger.error({ err: loggableError(err) }, "standalone_redis_clients_error");
|
|
701
718
|
});
|
|
702
719
|
|
|
703
720
|
lifecycleRegistrar?.register(async () => {
|
|
@@ -773,6 +790,20 @@ function optionalBoolean(
|
|
|
773
790
|
);
|
|
774
791
|
}
|
|
775
792
|
|
|
793
|
+
/**
|
|
794
|
+
* `accessType` from the `federations.google` slice: `"offline"` or `"online"`,
|
|
795
|
+
* exactly, or absent. `federation-google` refuses any other value too; this
|
|
796
|
+
* refuses it first, naming the key, as the fields above do.
|
|
797
|
+
*/
|
|
798
|
+
function optionalAccessType(
|
|
799
|
+
slice: Record<string, unknown>,
|
|
800
|
+
): { accessType: "offline" | "online" } | Record<string, never> {
|
|
801
|
+
const value = slice.accessType;
|
|
802
|
+
if (value === undefined || value === null) return {};
|
|
803
|
+
if (value === "offline" || value === "online") return { accessType: value };
|
|
804
|
+
throw new Error('federations.google.accessType must be "offline" or "online" when present');
|
|
805
|
+
}
|
|
806
|
+
|
|
776
807
|
/**
|
|
777
808
|
* Google federation config bridge — supplies the typed `googleFederationConfig`
|
|
778
809
|
* ComponentMap slot from the `config.federations.google` slice.
|
|
@@ -841,6 +872,9 @@ export const googleFederationConfigModule: Module = defineModule({
|
|
|
841
872
|
...optionalString(slice, "clientUrl"),
|
|
842
873
|
// #597: absent means the provider's default, which is to require it.
|
|
843
874
|
...optionalBoolean(slice, "requireAuthorizationResponseIss"),
|
|
875
|
+
// Absent means the provider's default, "offline": consent on every
|
|
876
|
+
// sign-in, and a refresh token for every session.
|
|
877
|
+
...optionalAccessType(slice),
|
|
844
878
|
};
|
|
845
879
|
},
|
|
846
880
|
},
|