@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
|
@@ -14,11 +14,14 @@
|
|
|
14
14
|
* limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
import { generateKeyPairSync } from "node:crypto";
|
|
17
18
|
import { readFileSync } from "node:fs";
|
|
18
19
|
import {
|
|
19
20
|
type AppConfig,
|
|
20
21
|
createApp,
|
|
22
|
+
createHealthcheckRouter,
|
|
21
23
|
createKeyStoreFactory,
|
|
24
|
+
createReadinessRouter,
|
|
22
25
|
defineModule,
|
|
23
26
|
generateToken,
|
|
24
27
|
InMemoryClientRepository,
|
|
@@ -33,17 +36,28 @@ import { buildModules } from "../buildModules.mjs";
|
|
|
33
36
|
|
|
34
37
|
const dockerfile = readFileSync(new URL("../../Dockerfile", import.meta.url), "utf8");
|
|
35
38
|
|
|
39
|
+
// #282: the template's shipped default is EdDSA with a published JWKS, so the
|
|
40
|
+
// smoke test signs the way a real deployment of this scaffold does. Generated
|
|
41
|
+
// per run — no key material is committed.
|
|
42
|
+
const smokeKeyPair = generateKeyPairSync("ed25519", {
|
|
43
|
+
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
44
|
+
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
|
45
|
+
});
|
|
46
|
+
|
|
36
47
|
const config: AppConfig = {
|
|
37
|
-
http: { port: 0, trustProxy: false },
|
|
48
|
+
http: { port: 0, trustProxy: false, readinessTimeoutMs: 1000 },
|
|
49
|
+
logging: { level: "silent" },
|
|
38
50
|
oauth: {
|
|
39
51
|
jwt: {
|
|
52
|
+
issuer: "https://auth.test",
|
|
40
53
|
signingKey: {
|
|
41
54
|
provider: "local",
|
|
42
55
|
local: {
|
|
43
|
-
algorithm: "
|
|
44
|
-
secret: "test-secret-for-smoke-test",
|
|
56
|
+
algorithm: "EdDSA",
|
|
45
57
|
kid: "v0",
|
|
46
|
-
|
|
58
|
+
privateKey: smokeKeyPair.privateKey,
|
|
59
|
+
publicKey: smokeKeyPair.publicKey,
|
|
60
|
+
previousKeys: [],
|
|
47
61
|
},
|
|
48
62
|
},
|
|
49
63
|
},
|
|
@@ -55,6 +69,8 @@ const config: AppConfig = {
|
|
|
55
69
|
},
|
|
56
70
|
grants: {},
|
|
57
71
|
oidcMode: "oidc-required",
|
|
72
|
+
// #330: no `authorize` section — the `allowUnmarkedClients` migration
|
|
73
|
+
// flag was removed; the first-party invariant is unconditional.
|
|
58
74
|
// OR-9: explicit memory adapter for the smoke test. The legacy
|
|
59
75
|
// `repositories.code.type` fallback would also pick memory here,
|
|
60
76
|
// but the explicit setting documents the intent and avoids
|
|
@@ -62,7 +78,8 @@ const config: AppConfig = {
|
|
|
62
78
|
code: { adapter: "memory" as const },
|
|
63
79
|
},
|
|
64
80
|
session: {
|
|
65
|
-
|
|
81
|
+
// #282: `session.secret` carries a 256-bit entropy floor.
|
|
82
|
+
secret: "test-session-secret.at-least-32-bytes.ok",
|
|
66
83
|
name: "auth.sid",
|
|
67
84
|
maxAge: 3600000,
|
|
68
85
|
secure: false,
|
|
@@ -173,9 +190,13 @@ describe("standalone smoke test", () => {
|
|
|
173
190
|
});
|
|
174
191
|
|
|
175
192
|
const app = express();
|
|
176
|
-
app.
|
|
177
|
-
|
|
178
|
-
|
|
193
|
+
app.use(createHealthcheckRouter(express));
|
|
194
|
+
app.use(
|
|
195
|
+
createReadinessRouter(express, {
|
|
196
|
+
probes: handle.readinessProbes,
|
|
197
|
+
timeoutMs: config.http.readinessTimeoutMs,
|
|
198
|
+
}),
|
|
199
|
+
);
|
|
179
200
|
app.use(handle.router);
|
|
180
201
|
return { app, handle };
|
|
181
202
|
}
|
|
@@ -208,11 +229,34 @@ describe("standalone smoke test", () => {
|
|
|
208
229
|
expect(dockerfile).toMatch(
|
|
209
230
|
/CMD\s+wget\s+-q\s+-O\s+\/dev\/null\s+"http:\/\/localhost:[$]\{HTTP_PORT\}\/_healthcheck"\s+\|\|\s+exit\s+1/,
|
|
210
231
|
);
|
|
211
|
-
expect(dockerfile).toMatch(
|
|
232
|
+
expect(dockerfile).toMatch(
|
|
233
|
+
/FROM node-base AS deps[\s\S]*USER node[\s\S]*RUN pnpm install --frozen-lockfile/,
|
|
234
|
+
);
|
|
212
235
|
expect(dockerfile).toMatch(/FROM deps AS builder[\s\S]*USER node[\s\S]*RUN pnpm run build/);
|
|
236
|
+
// #289: dependencies are resolved exactly once, in `deps`. The runtime
|
|
237
|
+
// stage must take its node_modules from a prune of that same tree — a
|
|
238
|
+
// second `pnpm install` would be a second resolution, so its absence
|
|
239
|
+
// after the runtime FROM is part of the contract.
|
|
240
|
+
expect(dockerfile).toMatch(
|
|
241
|
+
/FROM deps AS prod-deps[\s\S]*USER node[\s\S]*RUN pnpm prune --prod/,
|
|
242
|
+
);
|
|
213
243
|
expect(dockerfile).toMatch(
|
|
214
|
-
/FROM node-base AS runtime[\s\S]*
|
|
244
|
+
/FROM node-base AS runtime[\s\S]*COPY --from=prod-deps[\s\S]*USER node/,
|
|
215
245
|
);
|
|
246
|
+
expect(dockerfile.slice(dockerfile.indexOf("FROM node-base AS runtime"))).not.toContain(
|
|
247
|
+
"pnpm install",
|
|
248
|
+
);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("Dockerfile pins its mutable build inputs (#289)", () => {
|
|
252
|
+
// Base image by digest — the tag alone is a moving pointer, and
|
|
253
|
+
// Dependabot's docker ecosystem bumps tag and digest together.
|
|
254
|
+
expect(dockerfile).toMatch(/FROM node:24-alpine@sha256:[0-9a-f]{64} AS node-base/);
|
|
255
|
+
// Global corepack by version — `npm install -g corepack` with no pin
|
|
256
|
+
// resolved whatever was latest at build time.
|
|
257
|
+
expect(dockerfile).toMatch(/npm install -g corepack@\d+\.\d+\.\d+ --force/);
|
|
258
|
+
// The lockfile is a required build input for --frozen-lockfile.
|
|
259
|
+
expect(dockerfile).toMatch(/COPY --chown=node:node package\.json pnpm-lock\.yaml/);
|
|
216
260
|
});
|
|
217
261
|
|
|
218
262
|
it("GET /_healthcheck returns 200", async () => {
|
|
@@ -222,6 +266,39 @@ describe("standalone smoke test", () => {
|
|
|
222
266
|
expect(res.status).toBe(200);
|
|
223
267
|
});
|
|
224
268
|
|
|
269
|
+
it("GET /readyz returns 200 for a memory-only composition with no probes", async () => {
|
|
270
|
+
// These smoke modules are all in-memory, so nothing registers a probe.
|
|
271
|
+
// Readiness must not invent a dependency that is not wired.
|
|
272
|
+
const { app, handle } = await buildApp();
|
|
273
|
+
handleRef = handle;
|
|
274
|
+
const res = await request(app).get("/readyz");
|
|
275
|
+
expect(res.status).toBe(200);
|
|
276
|
+
expect(res.body).toEqual({ status: "ready", checks: [] });
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("GET /readyz returns 503 naming the dependency when a probe fails", async () => {
|
|
280
|
+
const { handle } = await buildApp();
|
|
281
|
+
handleRef = handle;
|
|
282
|
+
const app = express();
|
|
283
|
+
app.use(
|
|
284
|
+
createReadinessRouter(express, {
|
|
285
|
+
probes: [
|
|
286
|
+
{
|
|
287
|
+
name: "redis",
|
|
288
|
+
check: async () => {
|
|
289
|
+
throw new Error("ECONNREFUSED");
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
],
|
|
293
|
+
timeoutMs: config.http.readinessTimeoutMs,
|
|
294
|
+
}),
|
|
295
|
+
);
|
|
296
|
+
const res = await request(app).get("/readyz");
|
|
297
|
+
expect(res.status).toBe(503);
|
|
298
|
+
expect(res.body.status).toBe("unready");
|
|
299
|
+
expect(res.body.checks[0].name).toBe("redis");
|
|
300
|
+
});
|
|
301
|
+
|
|
225
302
|
it("issuer-configured buildModules serves the advertised jwks_uri (discovery <-> JWKS presence contract)", async () => {
|
|
226
303
|
// Guards the cross-module contract end-to-end against the REAL composition
|
|
227
304
|
// root: core's `jwksModule` contributes `jwks_uri` to discovery AND mounts
|
|
@@ -256,6 +333,12 @@ describe("standalone smoke test", () => {
|
|
|
256
333
|
const res = await request(app).get(jwksPath);
|
|
257
334
|
expect(res.status).toBe(200);
|
|
258
335
|
expect(Array.isArray(res.body.keys)).toBe(true);
|
|
336
|
+
// #282: presence is no longer enough — the advertised jwks_uri must
|
|
337
|
+
// publish an actual verification key, and never the private half.
|
|
338
|
+
expect(res.body.keys).toHaveLength(1);
|
|
339
|
+
expect(res.body.keys[0].alg).toBe("EdDSA");
|
|
340
|
+
expect(res.body.keys[0].kid).toBe("v0");
|
|
341
|
+
expect(res.body.keys[0].d).toBeUndefined();
|
|
259
342
|
});
|
|
260
343
|
|
|
261
344
|
it("POST /oauth/token with unsupported grant_type returns 400", async () => {
|
|
@@ -386,6 +469,117 @@ describe("standalone smoke test", () => {
|
|
|
386
469
|
expect(names).not.toContain("standalone:stores");
|
|
387
470
|
expect(names).toContain("standalone:redis-clients");
|
|
388
471
|
});
|
|
472
|
+
|
|
473
|
+
// #321's `redisSessionStoresModule` requires the two subject-level client
|
|
474
|
+
// slots (`subjectSessionIndexClient`, `subjectRevocationClient`) its
|
|
475
|
+
// adapters consume, and the scaffold's shared ioredis module did not
|
|
476
|
+
// provide them — so `userSessionStores.adapter = "redis"`, the
|
|
477
|
+
// template's own multi-replica path, failed stage-1 boot with
|
|
478
|
+
// `missing-required-component`. Nothing here booted that branch: the
|
|
479
|
+
// tests above check which modules are selected, not whether the
|
|
480
|
+
// selection is satisfiable. This pins the invariant the shared clients
|
|
481
|
+
// module exists for: every `*Client` slot any selected module requires
|
|
482
|
+
// is one it provides.
|
|
483
|
+
it("standalone:redis-clients provides every *Client slot the Redis branches require", () => {
|
|
484
|
+
const allRedisConfig = {
|
|
485
|
+
...config,
|
|
486
|
+
userSessionStores: { adapter: "redis" as const },
|
|
487
|
+
rateLimiter: { adapter: "redis" as const },
|
|
488
|
+
accessTokenDenylist: { adapter: "redis" as const },
|
|
489
|
+
oauth: { ...config.oauth, code: { adapter: "redis" as const } },
|
|
490
|
+
};
|
|
491
|
+
// No `refreshTokenFamilyModules` override: the default is the Redis
|
|
492
|
+
// RT-family store, so `refreshTokenFamilyClient` is part of what the
|
|
493
|
+
// invariant covers. Still hermetic — buildModules opens no socket.
|
|
494
|
+
const modules = buildModules(allRedisConfig, {
|
|
495
|
+
keyStoreModule: testKeyStoreModule,
|
|
496
|
+
repositoriesModule: testRepositoriesModule,
|
|
497
|
+
});
|
|
498
|
+
const clients = modules.find((m) => m.name === "standalone:redis-clients");
|
|
499
|
+
expect(clients).toBeDefined();
|
|
500
|
+
const provided = new Set(Object.keys(clients?.provides ?? {}));
|
|
501
|
+
const requiredClientSlots = new Set(
|
|
502
|
+
modules.flatMap((m) => [...(m.requires ?? [])]).filter((key) => key.endsWith("Client")),
|
|
503
|
+
);
|
|
504
|
+
expect(requiredClientSlots.size).toBeGreaterThan(0);
|
|
505
|
+
const missing = [...requiredClientSlots].filter((key) => !provided.has(key));
|
|
506
|
+
expect(missing).toEqual([]);
|
|
507
|
+
});
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
// #277: the scaffold used to wire NO access-token denylist, so the
|
|
511
|
+
// `/oauth/revoke` it shipped answered 200 for an access token and left the
|
|
512
|
+
// JWT working until expiry. The template now always wires one, and its own
|
|
513
|
+
// application.conf selects the Redis-backed adapter — the memory one forks
|
|
514
|
+
// per replica and `deployment.mode = "multi"` refuses it.
|
|
515
|
+
describe("#277: access-token denylist wiring", () => {
|
|
516
|
+
it("always wires a denylist, so /oauth/revoke can keep its promise", () => {
|
|
517
|
+
const modules = buildModules(config, {
|
|
518
|
+
keyStoreModule: testKeyStoreModule,
|
|
519
|
+
repositoriesModule: testRepositoriesModule,
|
|
520
|
+
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
521
|
+
});
|
|
522
|
+
const providesDenylist = modules.filter((m) =>
|
|
523
|
+
Object.keys(m.provides ?? {}).includes("accessTokenDenylist"),
|
|
524
|
+
);
|
|
525
|
+
expect(providesDenylist).toHaveLength(1);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it("defaults to the memory denylist when no adapter is configured", () => {
|
|
529
|
+
const modules = buildModules(config, {
|
|
530
|
+
keyStoreModule: testKeyStoreModule,
|
|
531
|
+
repositoriesModule: testRepositoriesModule,
|
|
532
|
+
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
533
|
+
});
|
|
534
|
+
const names = modules.map((m) => m.name);
|
|
535
|
+
expect(names).toContain("core-access-token-denylist-memory");
|
|
536
|
+
expect(names).not.toContain("redis-access-token-denylist");
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it("switches to the Redis denylist when accessTokenDenylist.adapter = 'redis'", () => {
|
|
540
|
+
const redisDenylistConfig = {
|
|
541
|
+
...config,
|
|
542
|
+
accessTokenDenylist: { adapter: "redis" as const },
|
|
543
|
+
};
|
|
544
|
+
const modules = buildModules(redisDenylistConfig, {
|
|
545
|
+
keyStoreModule: testKeyStoreModule,
|
|
546
|
+
repositoriesModule: testRepositoriesModule,
|
|
547
|
+
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
548
|
+
});
|
|
549
|
+
const names = modules.map((m) => m.name);
|
|
550
|
+
expect(names).toContain("redis-access-token-denylist");
|
|
551
|
+
expect(names).not.toContain("core-access-token-denylist-memory");
|
|
552
|
+
// The Redis branch must also pull in the shared ioredis socket, or the
|
|
553
|
+
// `accessTokenDenylistClient` slot has no provider and boot fails on a
|
|
554
|
+
// missing component instead of on the thing the operator changed.
|
|
555
|
+
expect(names).toContain("standalone:redis-clients");
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
it("the shipped application.conf selects the replica-safe adapter", () => {
|
|
559
|
+
// The template's own config is the artifact operators deploy. It runs
|
|
560
|
+
// with `deployment.mode = multi` in the umbrella E2E, which refuses
|
|
561
|
+
// every in-memory shared store — so shipping the memory denylist here
|
|
562
|
+
// would be a boot failure in the very stack that proves the scaffold
|
|
563
|
+
// works.
|
|
564
|
+
const conf = readFileSync(new URL("../../config/application.conf", import.meta.url), "utf8");
|
|
565
|
+
expect(conf).toMatch(/accessTokenDenylist\s*\{[\s\S]*?adapter\s*=\s*"redis"/);
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
it("keeps the composition boot-valid end to end", async () => {
|
|
569
|
+
// The core boot guard (#277 step 13.9) refuses a composition that reads
|
|
570
|
+
// the denylist slot without one. A scaffold that trips its own library's
|
|
571
|
+
// guard is not a scaffold.
|
|
572
|
+
const handle = await createApp({
|
|
573
|
+
modules: buildModules(config, {
|
|
574
|
+
keyStoreModule: testKeyStoreModule,
|
|
575
|
+
repositoriesModule: testRepositoriesModule,
|
|
576
|
+
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
577
|
+
}),
|
|
578
|
+
bootstrapComponents: { config, pathResolver: (s) => s },
|
|
579
|
+
});
|
|
580
|
+
handleRef = handle;
|
|
581
|
+
expect(handle).toBeDefined();
|
|
582
|
+
});
|
|
389
583
|
});
|
|
390
584
|
|
|
391
585
|
// OR-9 (Wave 5d): adapter switch for the OAuth code repository. Closes
|
|
@@ -476,9 +670,17 @@ describe("standalone smoke test", () => {
|
|
|
476
670
|
type: "local",
|
|
477
671
|
...(config.oauth.jwt.signingKey.local ?? {}),
|
|
478
672
|
});
|
|
673
|
+
// The token must carry the deployment's configured `iss`: introspection
|
|
674
|
+
// pins it (RFC 9068 §4), and since #266 every deployment has one.
|
|
479
675
|
const { token } = await generateToken(
|
|
480
676
|
{},
|
|
481
|
-
{
|
|
677
|
+
{
|
|
678
|
+
keyStore,
|
|
679
|
+
subject: "u1",
|
|
680
|
+
expiresIn: 3600,
|
|
681
|
+
tokenType: "at+jwt",
|
|
682
|
+
issuer: config.oauth.jwt.issuer,
|
|
683
|
+
},
|
|
482
684
|
);
|
|
483
685
|
|
|
484
686
|
const res = await request(app)
|
|
@@ -0,0 +1,96 @@
|
|
|
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 type { Logger } from "@o3co/auth-provider-core";
|
|
17
|
+
import express from "express";
|
|
18
|
+
import request from "supertest";
|
|
19
|
+
import { describe, expect, it, vi } from "vitest";
|
|
20
|
+
import { createTerminalErrorHandler } from "../terminalError.mjs";
|
|
21
|
+
|
|
22
|
+
const makeLogger = () => {
|
|
23
|
+
const error = vi.fn();
|
|
24
|
+
const logger = {
|
|
25
|
+
error,
|
|
26
|
+
warn: vi.fn(),
|
|
27
|
+
info: vi.fn(),
|
|
28
|
+
debug: vi.fn(),
|
|
29
|
+
} as unknown as Logger;
|
|
30
|
+
return { logger, error };
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const makeApp = (logger: Logger) => {
|
|
34
|
+
const app = express();
|
|
35
|
+
app.use(express.json());
|
|
36
|
+
app.post("/echo", (_req, res) => {
|
|
37
|
+
res.status(200).json({ ok: true });
|
|
38
|
+
});
|
|
39
|
+
app.get("/boom", () => {
|
|
40
|
+
throw new Error("route exploded");
|
|
41
|
+
});
|
|
42
|
+
app.use(createTerminalErrorHandler(logger));
|
|
43
|
+
return app;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
describe("terminal error handler (#293 item 8)", () => {
|
|
47
|
+
it("answers malformed JSON with the client's 400 in the shared envelope, not Express's HTML page", async () => {
|
|
48
|
+
const { logger, error } = makeLogger();
|
|
49
|
+
const res = await request(makeApp(logger))
|
|
50
|
+
.post("/echo")
|
|
51
|
+
.set("Content-Type", "application/json")
|
|
52
|
+
.send("{not json");
|
|
53
|
+
|
|
54
|
+
expect(res.status).toBe(400);
|
|
55
|
+
expect(res.headers["content-type"]).toContain("application/json");
|
|
56
|
+
expect(res.body.error).toBe("invalid_request");
|
|
57
|
+
// The client's malformed body is not a server error worth an error line.
|
|
58
|
+
expect(error).not.toHaveBeenCalled();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("keeps the body parser's own status for an over-limit body (413)", async () => {
|
|
62
|
+
const { logger } = makeLogger();
|
|
63
|
+
const app = express();
|
|
64
|
+
app.use(express.json({ limit: 16 }));
|
|
65
|
+
app.post("/echo", (_req, res) => {
|
|
66
|
+
res.status(200).json({ ok: true });
|
|
67
|
+
});
|
|
68
|
+
app.use(createTerminalErrorHandler(logger));
|
|
69
|
+
|
|
70
|
+
const res = await request(app)
|
|
71
|
+
.post("/echo")
|
|
72
|
+
.set("Content-Type", "application/json")
|
|
73
|
+
.send(JSON.stringify({ pad: "x".repeat(64) }));
|
|
74
|
+
|
|
75
|
+
expect(res.status).toBe(413);
|
|
76
|
+
expect(res.body.error).toBe("invalid_request");
|
|
77
|
+
expect(res.body.error_description).toBe("request body too large");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("answers an unexpected route throw with 500 server_error and logs it structurally", async () => {
|
|
81
|
+
const { logger, error } = makeLogger();
|
|
82
|
+
const res = await request(makeApp(logger)).get("/boom");
|
|
83
|
+
|
|
84
|
+
expect(res.status).toBe(500);
|
|
85
|
+
expect(res.body).toEqual({
|
|
86
|
+
error: "server_error",
|
|
87
|
+
error_description: "Internal server error",
|
|
88
|
+
});
|
|
89
|
+
// No stack trace on the wire — the envelope is the whole body.
|
|
90
|
+
expect(JSON.stringify(res.body)).not.toContain("route exploded");
|
|
91
|
+
expect(error).toHaveBeenCalledWith(
|
|
92
|
+
expect.objectContaining({ endpoint: "/boom" }),
|
|
93
|
+
"unhandled_request_error",
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -19,6 +19,7 @@ import { type AppConfig, AppConfigSchema } from "@o3co/auth-provider-core";
|
|
|
19
19
|
import { parseFile } from "@o3co/ts.hocon";
|
|
20
20
|
import { validate } from "@o3co/ts.hocon/zod";
|
|
21
21
|
import { describe, expect, it } from "vitest";
|
|
22
|
+
import { buildModules } from "../buildModules.mjs";
|
|
22
23
|
import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "../configPath.mjs";
|
|
23
24
|
|
|
24
25
|
// config/ is two levels above this test file:
|
|
@@ -26,10 +27,13 @@ import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "../configPa
|
|
|
26
27
|
const configDir = fileURLToPath(new URL("../../config", import.meta.url));
|
|
27
28
|
|
|
28
29
|
// Provide required secrets so AppConfigSchema parse succeeds. These are
|
|
29
|
-
// test-only values — no real keys are embedded here.
|
|
30
|
+
// test-only values — no real keys are embedded here. #282: SESSION_SECRET
|
|
31
|
+
// carries a 256-bit entropy floor, so these clear it (the '.' characters keep
|
|
32
|
+
// them outside the base64 alphabet, so the UTF-8 length is what counts).
|
|
30
33
|
const testEnv = {
|
|
31
|
-
OAUTH_JWT_SECRET: "test-secret-three-tier",
|
|
32
|
-
|
|
34
|
+
OAUTH_JWT_SECRET: "test-secret-three-tier.at-least-32-bytes.ok",
|
|
35
|
+
OAUTH_JWT_ISSUER: "https://auth.test",
|
|
36
|
+
SESSION_SECRET: "test-session-secret-three-tier.at-least-32-bytes.ok",
|
|
33
37
|
};
|
|
34
38
|
|
|
35
39
|
function buildResolvedConfig(env: string, extraEnv: Record<string, string> = {}): AppConfig {
|
|
@@ -75,10 +79,15 @@ describe("three-tier HOCON resolution (env → application.conf → reference.co
|
|
|
75
79
|
// so OAUTH_GRANTS_AUTHORIZATION_CODE_ENABLED=false reaches the resolved value.
|
|
76
80
|
// Without the repeated env line, the substitution at reference.conf is
|
|
77
81
|
// shadowed by the template's literal `true`.
|
|
78
|
-
// Note: ts.hocon substitutes env vars as strings
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
+
// Note: ts.hocon substitutes env vars as strings, and `oauth.grants` is
|
|
83
|
+
// `z.object({}).passthrough()` — an open tree each grant module's own
|
|
84
|
+
// `configSchema` validates — so no shape-walking coercion reaches this
|
|
85
|
+
// leaf and the string survives to the resolved config. #288 unified the
|
|
86
|
+
// coercion for every DECLARED boolean in the schema and deliberately
|
|
87
|
+
// left this one alone: every consumer of a grant's `enabled`
|
|
88
|
+
// (`GrantRegistry.isEnabled`, `oauthAuthorization`, `oauthSession`)
|
|
89
|
+
// already reads `true` and `"true"` as enabled and everything else,
|
|
90
|
+
// `"false"` included, as not-enabled.
|
|
82
91
|
const config = buildResolvedConfig("development", {
|
|
83
92
|
OAUTH_GRANTS_AUTHORIZATION_CODE_ENABLED: "false",
|
|
84
93
|
});
|
|
@@ -114,4 +123,105 @@ describe("three-tier HOCON resolution (env → application.conf → reference.co
|
|
|
114
123
|
});
|
|
115
124
|
expect(config.oauth.resourceIndicator?.enabled).toBe(true);
|
|
116
125
|
});
|
|
126
|
+
|
|
127
|
+
// #287 — the audit trail as the shipped artifact resolves it. The section is
|
|
128
|
+
// declared in core's schema for a load-bearing reason: `AppConfigSchema` is
|
|
129
|
+
// a plain `z.object` and strips what it does not declare, so an undeclared
|
|
130
|
+
// `audit` block would vanish between `parseFile` and `buildModules` and the
|
|
131
|
+
// sink selector would read `undefined` while the config sat in the file
|
|
132
|
+
// looking effective. These assertions run the real three-tier merge, so they
|
|
133
|
+
// fail if either layer stops carrying the key.
|
|
134
|
+
describe("#287: the audit sink as the shipped artifact resolves it", () => {
|
|
135
|
+
it("resolves audit.sink.type to the template's logger sink with nothing set", () => {
|
|
136
|
+
const config = buildResolvedConfig("production");
|
|
137
|
+
expect(config.audit?.sink.type).toBe("logger");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("wires exactly one auditSink provider for that resolved config", () => {
|
|
141
|
+
const modules = buildModules(buildResolvedConfig("production"));
|
|
142
|
+
const providers = modules.filter((m) => Object.keys(m.provides ?? {}).includes("auditSink"));
|
|
143
|
+
expect(providers).toHaveLength(1);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("lets an operator select core's built-in console sink by env var", () => {
|
|
147
|
+
// The env-override line has to be repeated at the template layer or
|
|
148
|
+
// the literal `"logger"` shadows reference.conf's substitution — the
|
|
149
|
+
// same precedence rule the grant tests above pin.
|
|
150
|
+
const config = buildResolvedConfig("production", { AUDIT_SINK_TYPE: "console" });
|
|
151
|
+
expect(config.audit?.sink.type).toBe("console");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("reference.conf's own default is a sink, not a drop", () => {
|
|
155
|
+
// A consumer that resolves against reference.conf alone still lands on
|
|
156
|
+
// a sink. #304's sink policy is stdout JSON, never "none", and the
|
|
157
|
+
// library layer has to hold that on its own — a composition root that
|
|
158
|
+
// forgets to override it must not thereby lose its audit trail.
|
|
159
|
+
const referenceOnly = validate(
|
|
160
|
+
parseFile(resolveLibraryReferenceConfPath(), { env: testEnv }),
|
|
161
|
+
AppConfigSchema,
|
|
162
|
+
);
|
|
163
|
+
expect(referenceOnly.audit?.sink.type).toBe("console");
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// #277 — this is the shape the umbrella E2E (o3co/auth) boots: the shipped
|
|
168
|
+
// application.conf, `DEPLOYMENT_MODE=multi`, and one shared ioredis socket.
|
|
169
|
+
// The assertions below are what keep that stack booting: a memory denylist
|
|
170
|
+
// under `multi` is refused by the replica-safety guard, and NO denylist is
|
|
171
|
+
// refused by the #277 guard, so the template has to land on "redis" without
|
|
172
|
+
// the deployment naming it.
|
|
173
|
+
describe("#277: access-token revocation as the shipped artifact resolves it", () => {
|
|
174
|
+
it("resolves accessTokenDenylist.adapter to redis with nothing set", () => {
|
|
175
|
+
const config = buildResolvedConfig("production");
|
|
176
|
+
expect(config.accessTokenDenylist?.adapter).toBe("redis");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("resolves oauth.revocation.accessToken to denylist with nothing set", () => {
|
|
180
|
+
const config = buildResolvedConfig("production");
|
|
181
|
+
expect(config.oauth.revocation?.accessToken).toBe("denylist");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("picks the replica-safe denylist module for that resolved config", () => {
|
|
185
|
+
const names = buildModules(buildResolvedConfig("production")).map((m) => m.name);
|
|
186
|
+
expect(names).toContain("redis-access-token-denylist");
|
|
187
|
+
expect(names).not.toContain("core-access-token-denylist-memory");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("leaves nothing replica-unsafe under the umbrella E2E's environment", async () => {
|
|
191
|
+
// The environment `o3co/auth`'s tests/docker-compose.yml sets. Under
|
|
192
|
+
// `DEPLOYMENT_MODE=multi` the replica-safety guard fails boot naming
|
|
193
|
+
// every in-memory shared store, so the denylist this change adds has to
|
|
194
|
+
// come out of that environment as the Redis one — from the template's
|
|
195
|
+
// own config, since the compose file names no denylist variable.
|
|
196
|
+
// Asserted against the guard's own list rather than a module name, so a
|
|
197
|
+
// rename cannot quietly invalidate it.
|
|
198
|
+
const { REPLICA_UNSAFE_MODULES } = await import("@o3co/auth-provider-core");
|
|
199
|
+
const config = buildResolvedConfig("production", {
|
|
200
|
+
USER_SESSION_STORES_ADAPTER: "redis",
|
|
201
|
+
RATE_LIMITER_ADAPTER: "redis",
|
|
202
|
+
OAUTH_CODE_ADAPTER: "redis",
|
|
203
|
+
});
|
|
204
|
+
const names = buildModules(config).map((m) => m.name);
|
|
205
|
+
expect(names).toContain("redis-access-token-denylist");
|
|
206
|
+
for (const name of names) {
|
|
207
|
+
expect(REPLICA_UNSAFE_MODULES).not.toContain(name);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("lets a single-instance deployment opt down to memory by env var", () => {
|
|
212
|
+
const config = buildResolvedConfig("production", {
|
|
213
|
+
ACCESS_TOKEN_DENYLIST_ADAPTER: "memory",
|
|
214
|
+
});
|
|
215
|
+
expect(config.accessTokenDenylist?.adapter).toBe("memory");
|
|
216
|
+
const names = buildModules(config).map((m) => m.name);
|
|
217
|
+
expect(names).toContain("core-access-token-denylist-memory");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("lets a deployment declare access-token revocation unsupported by env var", () => {
|
|
221
|
+
const config = buildResolvedConfig("production", {
|
|
222
|
+
OAUTH_REVOCATION_ACCESS_TOKEN: "unsupported",
|
|
223
|
+
});
|
|
224
|
+
expect(config.oauth.revocation?.accessToken).toBe("unsupported");
|
|
225
|
+
});
|
|
226
|
+
});
|
|
117
227
|
});
|