@o3co/create-auth-provider 0.9.0 → 0.11.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/dist/index.d.mts +20 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +110 -7
- package/package.json +4 -4
- package/templates/standalone/.env.example +49 -5
- package/templates/standalone/Dockerfile +59 -16
- package/templates/standalone/README.ja.md +92 -13
- package/templates/standalone/README.md +358 -18
- package/templates/standalone/config/application.conf +132 -4
- package/templates/standalone/config/clients.yaml.example +56 -2
- package/templates/standalone/config/production.conf +9 -3
- package/templates/standalone/docker-compose.production.yml +132 -0
- package/templates/standalone/docker-compose.yml +7 -0
- package/templates/standalone/gitignore +21 -0
- package/templates/standalone/package.json +15 -20
- package/templates/standalone/src/__tests__/audit-sink.test.mts +343 -0
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +423 -0
- package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +114 -0
- package/templates/standalone/src/__tests__/metrics.test.mts +231 -0
- package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +76 -1
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +117 -0
- package/templates/standalone/src/__tests__/shutdown.test.mts +244 -0
- package/templates/standalone/src/__tests__/smoke.test.mts +213 -11
- package/templates/standalone/src/__tests__/terminal-error.test.mts +96 -0
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +117 -7
- package/templates/standalone/src/app.mts +78 -10
- package/templates/standalone/src/buildModules.mts +43 -1
- package/templates/standalone/src/configPath.mts +2 -2
- package/templates/standalone/src/logger.mts +90 -2
- package/templates/standalone/src/metrics.mts +190 -0
- package/templates/standalone/src/modules.mts +319 -33
- package/templates/standalone/src/shutdown.mts +162 -0
- package/templates/standalone/src/terminalError.mts +60 -0
- package/templates/standalone/tests/index.test.js +69 -5
- package/templates/standalone/tsconfig.json +9 -1
- package/templates/standalone/vitest.config.mts +8 -0
- package/templates/versions.json +12 -11
|
@@ -0,0 +1,343 @@
|
|
|
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
|
+
* #287 — the audit-event pipeline existed and the scaffold wired no sink, so
|
|
19
|
+
* every security-relevant event the routes emit (`token.issued.failure`,
|
|
20
|
+
* `authorize.rejected`, `rate_limit.unavailable`, …) was dropped by the
|
|
21
|
+
* artifact operators actually deploy. `emitAuditEvent` is a no-op when the
|
|
22
|
+
* slot is empty, so nothing failed and nothing warned: the deployment simply
|
|
23
|
+
* had no audit trail.
|
|
24
|
+
*
|
|
25
|
+
* These tests pin the closure from three sides — the sink implementation, the
|
|
26
|
+
* module that resolves it from config, and the manifest that must contain
|
|
27
|
+
* exactly one provider for the slot.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { generateKeyPairSync } from "node:crypto";
|
|
31
|
+
import { readFileSync } from "node:fs";
|
|
32
|
+
import {
|
|
33
|
+
type AppConfig,
|
|
34
|
+
type AuditEvent,
|
|
35
|
+
type AuditSink,
|
|
36
|
+
createApp,
|
|
37
|
+
createKeyStoreFactory,
|
|
38
|
+
defineModule,
|
|
39
|
+
InMemoryClientRepository,
|
|
40
|
+
InMemoryUserRepository,
|
|
41
|
+
type Logger,
|
|
42
|
+
memoryRefreshTokenFamilyStoreModule,
|
|
43
|
+
registerBuiltinKeyStores,
|
|
44
|
+
} from "@o3co/auth-provider-core";
|
|
45
|
+
import express from "express";
|
|
46
|
+
import request from "supertest";
|
|
47
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
48
|
+
import { buildModules } from "../buildModules.mjs";
|
|
49
|
+
import { createAppLogger, createAuditLogger, createLoggerAuditSink } from "../logger.mjs";
|
|
50
|
+
import { auditSinkModule } from "../modules.mjs";
|
|
51
|
+
|
|
52
|
+
const keyPair = generateKeyPairSync("ed25519", {
|
|
53
|
+
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
54
|
+
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const baseConfig: AppConfig = {
|
|
58
|
+
http: { port: 0, trustProxy: false, readinessTimeoutMs: 1000 },
|
|
59
|
+
logging: { level: "silent" },
|
|
60
|
+
oauth: {
|
|
61
|
+
jwt: {
|
|
62
|
+
issuer: "https://auth.test",
|
|
63
|
+
signingKey: {
|
|
64
|
+
provider: "local",
|
|
65
|
+
local: {
|
|
66
|
+
algorithm: "EdDSA",
|
|
67
|
+
kid: "v0",
|
|
68
|
+
privateKey: keyPair.privateKey,
|
|
69
|
+
publicKey: keyPair.publicKey,
|
|
70
|
+
previousKeys: [],
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
accessToken: { expiresIn: 3600 },
|
|
75
|
+
refreshToken: {
|
|
76
|
+
expiresIn: 86400,
|
|
77
|
+
unknownFamilyPolicy: "reject" as const,
|
|
78
|
+
legacyRtPolicy: "reject" as const,
|
|
79
|
+
},
|
|
80
|
+
grants: {},
|
|
81
|
+
oidcMode: "oidc-required",
|
|
82
|
+
code: { adapter: "memory" as const },
|
|
83
|
+
},
|
|
84
|
+
session: {
|
|
85
|
+
secret: "test-session-secret.at-least-32-bytes.ok",
|
|
86
|
+
name: "auth.sid",
|
|
87
|
+
maxAge: 3600000,
|
|
88
|
+
secure: false,
|
|
89
|
+
sameSite: "lax",
|
|
90
|
+
domain: null,
|
|
91
|
+
storage: { type: "memory", redis: { url: "redis://localhost:6379" } },
|
|
92
|
+
},
|
|
93
|
+
rateLimit: {
|
|
94
|
+
login: { windowMs: 60000, limit: 10 },
|
|
95
|
+
failMode: "open",
|
|
96
|
+
},
|
|
97
|
+
federations: { google: { enabled: false } },
|
|
98
|
+
repositories: {
|
|
99
|
+
client: { type: "yaml", path: "./config/clients.yaml" },
|
|
100
|
+
user: { type: "yaml", path: "./config/users.yaml", timeout: 5000 },
|
|
101
|
+
code: { type: "memory", defaultExpiresIn: 600 },
|
|
102
|
+
},
|
|
103
|
+
endpoints: { login: { url: "/login" } },
|
|
104
|
+
cors: { allowedOrigins: [] },
|
|
105
|
+
audit: { sink: { type: "logger" } },
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const CLIENT_ID = "audit-client";
|
|
109
|
+
const CLIENT_SECRET = "audit-secret";
|
|
110
|
+
const BASIC_AUTH = `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64")}`;
|
|
111
|
+
|
|
112
|
+
const testRepositoriesModule = defineModule({
|
|
113
|
+
name: "test:repositories",
|
|
114
|
+
provides: {
|
|
115
|
+
clientRepository: () =>
|
|
116
|
+
new InMemoryClientRepository(
|
|
117
|
+
new Map([
|
|
118
|
+
[
|
|
119
|
+
CLIENT_ID,
|
|
120
|
+
{
|
|
121
|
+
tokenEndpointAuthMethod: "client_secret_basic",
|
|
122
|
+
clientSecret: CLIENT_SECRET,
|
|
123
|
+
allowedRedirectUris: [],
|
|
124
|
+
allowedScopes: [],
|
|
125
|
+
allowedAudiences: [],
|
|
126
|
+
backchannelLogoutSessionRequired: true,
|
|
127
|
+
frontchannelLogoutSessionRequired: true,
|
|
128
|
+
allowedAzpForFederationToken: false,
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
]),
|
|
132
|
+
),
|
|
133
|
+
userRepository: () => new InMemoryUserRepository(new Map()),
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const testKeyStoreModule = defineModule({
|
|
138
|
+
name: "test:key-store",
|
|
139
|
+
requires: ["config"] as const,
|
|
140
|
+
provides: {
|
|
141
|
+
keyStore: async ({ config: c }) => {
|
|
142
|
+
const factory = createKeyStoreFactory();
|
|
143
|
+
registerBuiltinKeyStores(factory);
|
|
144
|
+
return factory.create({
|
|
145
|
+
type: "local",
|
|
146
|
+
...((c as AppConfig).oauth.jwt.signingKey.local ?? {}),
|
|
147
|
+
});
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
/** Minimal `Logger` double — the interface is pino's, so only what we assert on. */
|
|
153
|
+
function fakeLogger(): Logger & { info: ReturnType<typeof vi.fn> } {
|
|
154
|
+
const self = {
|
|
155
|
+
trace: vi.fn(),
|
|
156
|
+
debug: vi.fn(),
|
|
157
|
+
info: vi.fn(),
|
|
158
|
+
warn: vi.fn(),
|
|
159
|
+
error: vi.fn(),
|
|
160
|
+
fatal: vi.fn(),
|
|
161
|
+
child: () => self,
|
|
162
|
+
};
|
|
163
|
+
return self as unknown as Logger & { info: ReturnType<typeof vi.fn> };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Resolve `auditSink` the way the boot planner does: call the module's provider. */
|
|
167
|
+
async function resolveSink(config: AppConfig): Promise<AuditSink> {
|
|
168
|
+
const provider = auditSinkModule.provides?.auditSink;
|
|
169
|
+
if (!provider) throw new Error("auditSinkModule must provide the auditSink slot");
|
|
170
|
+
return (await provider({ config } as never)) as AuditSink;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
describe("#287: the template's audit sink", () => {
|
|
174
|
+
describe("createLoggerAuditSink — one event, one line, through the app's own JSON stream", () => {
|
|
175
|
+
it("records the event through the injected logger", async () => {
|
|
176
|
+
const logger = fakeLogger();
|
|
177
|
+
const sink = createLoggerAuditSink(logger);
|
|
178
|
+
const event: AuditEvent = {
|
|
179
|
+
timestamp: new Date("2026-08-27T00:00:00Z"),
|
|
180
|
+
type: "authorize.rejected",
|
|
181
|
+
clientId: "c1",
|
|
182
|
+
subject: "u1",
|
|
183
|
+
details: { reason: "client_not_first_party" },
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
await sink.record(event);
|
|
187
|
+
|
|
188
|
+
expect(logger.info).toHaveBeenCalledTimes(1);
|
|
189
|
+
const [payload, message] = logger.info.mock.calls[0] as [Record<string, unknown>, string];
|
|
190
|
+
// The event type doubles as the message so an operator can alert on
|
|
191
|
+
// the name without a JSON path, matching how this template's other
|
|
192
|
+
// structured events are named.
|
|
193
|
+
expect(message).toBe("authorize.rejected");
|
|
194
|
+
expect(payload.audit).toMatchObject({
|
|
195
|
+
type: "authorize.rejected",
|
|
196
|
+
clientId: "c1",
|
|
197
|
+
subject: "u1",
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("nests the event under `audit` so it cannot collide with pino's own keys", async () => {
|
|
202
|
+
// `level`, `time`, `name` and `msg` belong to the log envelope. An
|
|
203
|
+
// event spread at the top level would let a future audit field
|
|
204
|
+
// overwrite one of them and corrupt the line for every consumer.
|
|
205
|
+
const logger = fakeLogger();
|
|
206
|
+
await createLoggerAuditSink(logger).record({
|
|
207
|
+
timestamp: new Date("2026-08-27T00:00:00Z"),
|
|
208
|
+
type: "rate_limit.unavailable",
|
|
209
|
+
});
|
|
210
|
+
const [payload] = logger.info.mock.calls[0] as [Record<string, unknown>];
|
|
211
|
+
expect(Object.keys(payload)).toEqual(["audit"]);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("reports the sink kind it was registered under", () => {
|
|
215
|
+
expect(createLoggerAuditSink(fakeLogger()).kind).toBe("logger");
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe("the audit trail is not gated by logging.level", () => {
|
|
220
|
+
it("keeps the audit logger at info while the app logger is silenced", () => {
|
|
221
|
+
// An audit trail is evidence, not diagnostics. `LOG_LEVEL=warn` is an
|
|
222
|
+
// ordinary production setting and `silent` is a legitimate one; if
|
|
223
|
+
// either silenced audit events, #287 would be back — the same silent
|
|
224
|
+
// drop, reached from the operator's side instead of the scaffold's.
|
|
225
|
+
const appLogger = createAppLogger({ ...baseConfig, logging: { level: "silent" } });
|
|
226
|
+
const auditLogger = createAuditLogger();
|
|
227
|
+
expect((appLogger as unknown as { level: string }).level).toBe("silent");
|
|
228
|
+
expect((auditLogger as unknown as { level: string }).level).toBe("info");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("names the audit stream so it is separable from application logs", () => {
|
|
232
|
+
const bindings = (
|
|
233
|
+
createAuditLogger() as unknown as { bindings(): Record<string, unknown> }
|
|
234
|
+
).bindings();
|
|
235
|
+
expect(bindings.name).toBe("audit");
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
describe("auditSinkModule — resolves the sink from config", () => {
|
|
240
|
+
it("resolves the logger sink for the shipped default", async () => {
|
|
241
|
+
expect((await resolveSink(baseConfig)).kind).toBe("logger");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("resolves core's built-in console sink when selected", async () => {
|
|
245
|
+
const sink = await resolveSink({ ...baseConfig, audit: { sink: { type: "console" } } });
|
|
246
|
+
expect(sink.kind).toBe("console");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("still produces a sink when the audit section is absent entirely", async () => {
|
|
250
|
+
// The failure #287 describes is silent absence. A config that says
|
|
251
|
+
// nothing about auditing must land on a sink, not on `undefined`.
|
|
252
|
+
const { audit: _audit, ...withoutAudit } = baseConfig;
|
|
253
|
+
const sink = await resolveSink(withoutAudit as AppConfig);
|
|
254
|
+
expect(sink.kind).toBe("logger");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("refuses an unknown sink type at boot, naming what is registered", async () => {
|
|
258
|
+
// There is no "none" — #304's sink policy. An operator who writes one
|
|
259
|
+
// gets a boot failure naming the sinks that exist, not a silent
|
|
260
|
+
// deployment with no audit trail.
|
|
261
|
+
await expect(
|
|
262
|
+
resolveSink({ ...baseConfig, audit: { sink: { type: "none" } } }),
|
|
263
|
+
).rejects.toThrow(/none/);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
describe("buildModules — the slot is filled in the manifest operators deploy", () => {
|
|
268
|
+
it("wires exactly one auditSink provider", () => {
|
|
269
|
+
const modules = buildModules(baseConfig, {
|
|
270
|
+
keyStoreModule: testKeyStoreModule,
|
|
271
|
+
repositoriesModule: testRepositoriesModule,
|
|
272
|
+
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
273
|
+
});
|
|
274
|
+
const providers = modules.filter((m) => Object.keys(m.provides ?? {}).includes("auditSink"));
|
|
275
|
+
expect(providers).toHaveLength(1);
|
|
276
|
+
expect(providers[0]?.name).toBe("standalone:audit-sink");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("the shipped application.conf selects a sink, and never 'none'", () => {
|
|
280
|
+
const conf = readFileSync(new URL("../../config/application.conf", import.meta.url), "utf8");
|
|
281
|
+
expect(conf).toMatch(/audit\s*\{[\s\S]*?sink\s*\{[\s\S]*?type\s*=\s*"logger"/);
|
|
282
|
+
expect(conf).not.toMatch(/type\s*=\s*"none"/);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
describe("end to end: an OAuth failure reaches the sink", () => {
|
|
287
|
+
let handleRef: { dispose(): Promise<void> } | undefined;
|
|
288
|
+
|
|
289
|
+
afterEach(async () => {
|
|
290
|
+
await handleRef?.dispose();
|
|
291
|
+
handleRef = undefined;
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("records token.issued.failure for an unsupported grant_type", async () => {
|
|
295
|
+
// Before #287 this assertion could not fail: nothing filled the slot,
|
|
296
|
+
// so `emitAuditEvent` returned without recording anything.
|
|
297
|
+
const logger = fakeLogger();
|
|
298
|
+
const spyAuditModule = defineModule({
|
|
299
|
+
name: "standalone:audit-sink",
|
|
300
|
+
provides: { auditSink: () => createLoggerAuditSink(logger) },
|
|
301
|
+
});
|
|
302
|
+
const handle = await createApp({
|
|
303
|
+
modules: buildModules(baseConfig, {
|
|
304
|
+
keyStoreModule: testKeyStoreModule,
|
|
305
|
+
repositoriesModule: testRepositoriesModule,
|
|
306
|
+
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
307
|
+
auditSinkModule: spyAuditModule,
|
|
308
|
+
}),
|
|
309
|
+
bootstrapComponents: { config: baseConfig, pathResolver: (s) => s },
|
|
310
|
+
});
|
|
311
|
+
handleRef = handle;
|
|
312
|
+
|
|
313
|
+
const app = express();
|
|
314
|
+
app.use(handle.router);
|
|
315
|
+
const res = await request(app)
|
|
316
|
+
.post("/oauth/token")
|
|
317
|
+
.set("Authorization", BASIC_AUTH)
|
|
318
|
+
.type("form")
|
|
319
|
+
.send({ grant_type: "definitely-not-a-grant" });
|
|
320
|
+
|
|
321
|
+
expect(res.status).toBe(400);
|
|
322
|
+
// `emitAuditEvent` is fire-and-forget; let the detached promise settle.
|
|
323
|
+
await new Promise((r) => setImmediate(r));
|
|
324
|
+
const types = logger.info.mock.calls.map(
|
|
325
|
+
(call) => (call[0] as { audit: AuditEvent }).audit.type,
|
|
326
|
+
);
|
|
327
|
+
expect(types).toContain("token.issued.failure");
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("boots the shipped manifest with its own audit sink wired", async () => {
|
|
331
|
+
const handle = await createApp({
|
|
332
|
+
modules: buildModules(baseConfig, {
|
|
333
|
+
keyStoreModule: testKeyStoreModule,
|
|
334
|
+
repositoriesModule: testRepositoriesModule,
|
|
335
|
+
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
336
|
+
}),
|
|
337
|
+
bootstrapComponents: { config: baseConfig, pathResolver: (s) => s },
|
|
338
|
+
});
|
|
339
|
+
handleRef = handle;
|
|
340
|
+
expect(handle).toBeDefined();
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
});
|