@o3co/create-auth-provider 0.17.0 → 0.18.0-rc.1
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.md +2 -198
- package/main.js +4 -0
- package/package.json +8 -30
- package/README.ja.md +0 -200
- package/dist/cli.d.mts +0 -3
- package/dist/cli.d.mts.map +0 -1
- package/dist/cli.mjs +0 -18
- package/dist/index.d.mts +0 -48
- package/dist/index.d.mts.map +0 -1
- package/dist/index.mjs +0 -325
- package/dist/internal/template-filter.d.mts +0 -14
- package/dist/internal/template-filter.d.mts.map +0 -1
- package/dist/internal/template-filter.mjs +0 -42
- package/templates/standalone/.dockerignore +0 -23
- package/templates/standalone/.env.example +0 -152
- package/templates/standalone/Dockerfile +0 -125
- package/templates/standalone/Makefile +0 -12
- package/templates/standalone/README.ja.md +0 -912
- package/templates/standalone/README.md +0 -1536
- package/templates/standalone/config/application.conf +0 -351
- package/templates/standalone/config/clients.yaml +0 -4
- package/templates/standalone/config/clients.yaml.example +0 -124
- package/templates/standalone/config/development.conf +0 -17
- package/templates/standalone/config/mailpit.conf +0 -13
- package/templates/standalone/config/production.conf +0 -15
- package/templates/standalone/config/reference.conf +0 -427
- package/templates/standalone/docker-compose.mailpit.yml +0 -39
- package/templates/standalone/docker-compose.production.yml +0 -229
- package/templates/standalone/docker-compose.test.yml +0 -16
- package/templates/standalone/docker-compose.yml +0 -67
- package/templates/standalone/gitignore +0 -23
- package/templates/standalone/package.json +0 -56
- package/templates/standalone/src/__tests__/adapters.test.mts +0 -425
- package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +0 -87
- package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +0 -1188
- package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +0 -285
- package/templates/standalone/src/__tests__/all-modules-composition.test.mts +0 -1154
- package/templates/standalone/src/__tests__/audit-sink.test.mts +0 -355
- package/templates/standalone/src/__tests__/backchannel-peer.fixture.mts +0 -64
- package/templates/standalone/src/__tests__/config-notices.test.mts +0 -157
- package/templates/standalone/src/__tests__/configPath.test.mts +0 -69
- package/templates/standalone/src/__tests__/configured-mfa-mode.fixture.mts +0 -48
- package/templates/standalone/src/__tests__/core-federations.test.mts +0 -252
- package/templates/standalone/src/__tests__/core-sections.test.mts +0 -192
- package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +0 -95
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +0 -1291
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +0 -787
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +0 -464
- package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +0 -141
- package/templates/standalone/src/__tests__/federation-types.test.mts +0 -239
- package/templates/standalone/src/__tests__/frontchannel-logout-csp.test.mts +0 -168
- package/templates/standalone/src/__tests__/google-session.fixture.mts +0 -318
- package/templates/standalone/src/__tests__/host-routes.test.mts +0 -134
- package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +0 -29
- package/templates/standalone/src/__tests__/library-references.fixture.mts +0 -188
- package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +0 -217
- package/templates/standalone/src/__tests__/listen.test.mts +0 -121
- package/templates/standalone/src/__tests__/logger-projection.test.mts +0 -77
- package/templates/standalone/src/__tests__/logout-session-cookie.test.mts +0 -113
- package/templates/standalone/src/__tests__/mail-sender.test.mts +0 -134
- package/templates/standalone/src/__tests__/mailpit-compose.test.mts +0 -393
- package/templates/standalone/src/__tests__/metrics.test.mts +0 -253
- package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +0 -122
- package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +0 -127
- package/templates/standalone/src/__tests__/mfa-switch.test.mts +0 -883
- package/templates/standalone/src/__tests__/own-modules.test.mts +0 -1480
- package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +0 -183
- package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +0 -283
- package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +0 -135
- package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +0 -214
- package/templates/standalone/src/__tests__/redis-stand-in.fixture.mts +0 -98
- package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +0 -249
- package/templates/standalone/src/__tests__/replica-safety.test.mts +0 -657
- package/templates/standalone/src/__tests__/repositories-logger.test.mts +0 -119
- package/templates/standalone/src/__tests__/router-error-answers.test.mts +0 -146
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +0 -610
- package/templates/standalone/src/__tests__/sections.test.mts +0 -516
- package/templates/standalone/src/__tests__/session-lifecycle.test.mts +0 -213
- package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +0 -305
- package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +0 -130
- package/templates/standalone/src/__tests__/session-requirements.test.mts +0 -382
- package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +0 -274
- package/templates/standalone/src/__tests__/shutdown.test.mts +0 -622
- package/templates/standalone/src/__tests__/smoke.test.mts +0 -642
- package/templates/standalone/src/__tests__/startup-log-lines.test.mts +0 -164
- package/templates/standalone/src/__tests__/store-credential.test.mts +0 -314
- package/templates/standalone/src/__tests__/supertest-loopback.test.mts +0 -296
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +0 -265
- package/templates/standalone/src/__tests__/two-phase-config.test.mts +0 -605
- package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +0 -385
- package/templates/standalone/src/adapters.mts +0 -162
- package/templates/standalone/src/app.mts +0 -127
- package/templates/standalone/src/bootRefusal.mts +0 -117
- package/templates/standalone/src/buildModules.mts +0 -358
- package/templates/standalone/src/configPath.mts +0 -487
- package/templates/standalone/src/listen.mts +0 -60
- package/templates/standalone/src/logger.mts +0 -80
- package/templates/standalone/src/metrics.mts +0 -167
- package/templates/standalone/src/mfaSwitch.mts +0 -284
- package/templates/standalone/src/modules.mts +0 -705
- package/templates/standalone/src/rootRenames.mts +0 -95
- package/templates/standalone/src/routes.mts +0 -89
- package/templates/standalone/src/sections.mts +0 -378
- package/templates/standalone/src/securityHeaders.mts +0 -36
- package/templates/standalone/src/shutdown.mts +0 -213
- package/templates/standalone/tests/Makefile +0 -5
- package/templates/standalone/tests/index.test.js +0 -283
- package/templates/standalone/tests/package-lock.json +0 -1617
- package/templates/standalone/tests/package.json +0 -16
- package/templates/standalone/tests/vitest.config.mjs +0 -26
- package/templates/standalone/tsconfig.json +0 -31
- package/templates/standalone/vitest.config.mts +0 -31
- package/templates/standalone/vitest.supertest-loopback.mts +0 -278
- package/templates/versions.json +0 -20
|
@@ -1,642 +0,0 @@
|
|
|
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
|
-
import { generateKeyPairSync } from "node:crypto";
|
|
18
|
-
import { readFileSync } from "node:fs";
|
|
19
|
-
import {
|
|
20
|
-
type AppConfig,
|
|
21
|
-
createApp,
|
|
22
|
-
createHealthcheckRouter,
|
|
23
|
-
createKeyStoreFactory,
|
|
24
|
-
createReadinessRouter,
|
|
25
|
-
defineModule,
|
|
26
|
-
generateToken,
|
|
27
|
-
InMemoryClientRepository,
|
|
28
|
-
InMemoryUserRepository,
|
|
29
|
-
memoryRefreshTokenFamilyStoreModule,
|
|
30
|
-
registerBuiltinKeyStores,
|
|
31
|
-
} from "@o3co/auth-provider-core";
|
|
32
|
-
import { coreConfigForTests } from "@o3co/auth-provider-core/testing";
|
|
33
|
-
import type { OAuthSection } from "@o3co/auth-provider-oauth";
|
|
34
|
-
import express from "express";
|
|
35
|
-
import request from "supertest";
|
|
36
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
37
|
-
import { buildModules } from "../buildModules.mjs";
|
|
38
|
-
import type { Switches } from "../configPath.mjs";
|
|
39
|
-
import { type Adapters, keyStoreSectionSchema } from "../sections.mjs";
|
|
40
|
-
import {
|
|
41
|
-
capturedRenames,
|
|
42
|
-
inProcessAdapters,
|
|
43
|
-
shippedAdapters,
|
|
44
|
-
} from "./library-references.fixture.mjs";
|
|
45
|
-
|
|
46
|
-
const dockerfile = readFileSync(new URL("../../Dockerfile", import.meta.url), "utf8");
|
|
47
|
-
|
|
48
|
-
// The template's shipped default is EdDSA with a published JWKS, so the
|
|
49
|
-
// smoke test signs the way a real deployment of this scaffold does. Generated
|
|
50
|
-
// per run — no key material is committed.
|
|
51
|
-
const smokeKeyPair = generateKeyPairSync("ed25519", {
|
|
52
|
-
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
53
|
-
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
/** The `http` module's section, as the hand-built configuration below carries it. */
|
|
57
|
-
const HTTP = {
|
|
58
|
-
port: 0,
|
|
59
|
-
trustProxy: false,
|
|
60
|
-
readinessTimeoutMs: 1000,
|
|
61
|
-
cors: { allowedOrigins: [] as string[] },
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
/** The template modules' sections sit beside core's, so the configuration is wider than `AppConfig`. */
|
|
65
|
-
const config: AppConfig & Record<string, unknown> = {
|
|
66
|
-
// What a resolution under an environment that sets none captures of
|
|
67
|
-
// core's renamed variables.
|
|
68
|
-
...{
|
|
69
|
-
"renamed-variables": capturedRenames({}),
|
|
70
|
-
},
|
|
71
|
-
http: HTTP,
|
|
72
|
-
logging: { level: "silent" },
|
|
73
|
-
"key-store": {
|
|
74
|
-
provider: "local",
|
|
75
|
-
local: {
|
|
76
|
-
algorithm: "EdDSA",
|
|
77
|
-
kid: "v0",
|
|
78
|
-
privateKey: smokeKeyPair.privateKey,
|
|
79
|
-
publicKey: smokeKeyPair.publicKey,
|
|
80
|
-
previousKeys: [],
|
|
81
|
-
},
|
|
82
|
-
},
|
|
83
|
-
// A composition with a consumer of session admission states what it
|
|
84
|
-
// expects (ADR 2026-09-28-session-admission): the shipped
|
|
85
|
-
// `application.conf` expects none, and so does this hand-built config.
|
|
86
|
-
...coreConfigForTests({ federations: { google: { enabled: false, type: "google" } } }),
|
|
87
|
-
oauth: {
|
|
88
|
-
jwt: {
|
|
89
|
-
issuer: "https://auth.test",
|
|
90
|
-
},
|
|
91
|
-
accessToken: { defaultExpiresIn: 3600 },
|
|
92
|
-
refreshToken: { expiresIn: 86400 },
|
|
93
|
-
grants: {},
|
|
94
|
-
oidcMode: "oidc-required",
|
|
95
|
-
// No `authorize` section: the first-party invariant is unconditional.
|
|
96
|
-
},
|
|
97
|
-
"session-store": {
|
|
98
|
-
// `session-store.secret` carries a 256-bit entropy floor.
|
|
99
|
-
secret: "test-session-secret.at-least-32-bytes.ok",
|
|
100
|
-
name: "auth.sid",
|
|
101
|
-
maxAge: 3600000,
|
|
102
|
-
secure: false,
|
|
103
|
-
sameSite: "lax",
|
|
104
|
-
domain: null,
|
|
105
|
-
storage: { type: "memory", redis: { url: "redis://localhost:6379" } },
|
|
106
|
-
},
|
|
107
|
-
session: {
|
|
108
|
-
loginPage: { url: "/login" },
|
|
109
|
-
rateLimit: { login: { windowMs: 60000, limit: 10 } },
|
|
110
|
-
},
|
|
111
|
-
rateLimit: { failMode: "open" },
|
|
112
|
-
"standalone-in-memory-code-repository": { defaultExpiresIn: 600 },
|
|
113
|
-
// The in-process limiter's section as core's `reference.conf` ships it.
|
|
114
|
-
"core-rate-limiter-memory": {
|
|
115
|
-
maxBuckets: 10000,
|
|
116
|
-
defaultLimit: { limit: 60, windowSeconds: 60 },
|
|
117
|
-
limits: {},
|
|
118
|
-
},
|
|
119
|
-
};
|
|
120
|
-
|
|
121
|
-
/** What phase one hands `buildModules`: the configuration, with `changes` over every store in process. */
|
|
122
|
-
const switchesWith = (changes: Partial<Adapters> = {}): Switches =>
|
|
123
|
-
({
|
|
124
|
-
...config,
|
|
125
|
-
adapters: { ...inProcessAdapters(), ...changes },
|
|
126
|
-
mfaMode: "off",
|
|
127
|
-
}) as unknown as Switches;
|
|
128
|
-
|
|
129
|
-
const switches = switchesWith();
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* The one confidential client, so the `clientAuthMw` middleware in front of
|
|
133
|
-
* `/oauth/token` has a record to authenticate against. Smoke tests that hit
|
|
134
|
-
* `/oauth/token` send `Authorization: ${SMOKE_BASIC_AUTH}`.
|
|
135
|
-
*/
|
|
136
|
-
const SMOKE_CLIENT_ID = "smoke-client";
|
|
137
|
-
const SMOKE_CLIENT_SECRET = "smoke-secret";
|
|
138
|
-
const SMOKE_BASIC_AUTH = `Basic ${Buffer.from(`${SMOKE_CLIENT_ID}:${SMOKE_CLIENT_SECRET}`).toString("base64")}`;
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Test-only repository module in place of the file-system-backed repositories
|
|
142
|
-
* `repositoriesModule` provides in production: the smoke tests verify the
|
|
143
|
-
* boot pipeline shape, not the data layer. `codeRepository` is not provided
|
|
144
|
-
* here; the `adapters.codeRepository` selection in `buildModules` wires it.
|
|
145
|
-
*/
|
|
146
|
-
const testRepositoriesModule = defineModule({
|
|
147
|
-
name: "test:repositories",
|
|
148
|
-
provides: {
|
|
149
|
-
// The map takes `ClientEntry`, the schema's OUTPUT type, where fields
|
|
150
|
-
// with `.default(...)` are required; every field is supplied so the
|
|
151
|
-
// literal needs no cast.
|
|
152
|
-
clientRepository: () =>
|
|
153
|
-
new InMemoryClientRepository(
|
|
154
|
-
new Map([
|
|
155
|
-
[
|
|
156
|
-
SMOKE_CLIENT_ID,
|
|
157
|
-
{
|
|
158
|
-
tokenEndpointAuthMethod: "client_secret_basic",
|
|
159
|
-
clientSecret: SMOKE_CLIENT_SECRET,
|
|
160
|
-
allowedRedirectUris: [],
|
|
161
|
-
allowedScopes: [],
|
|
162
|
-
allowedAudiences: [],
|
|
163
|
-
backchannelLogoutSessionRequired: true,
|
|
164
|
-
frontchannelLogoutSessionRequired: true,
|
|
165
|
-
allowedAzpForFederationToken: false,
|
|
166
|
-
},
|
|
167
|
-
],
|
|
168
|
-
]),
|
|
169
|
-
),
|
|
170
|
-
userRepository: () => new InMemoryUserRepository(new Map()),
|
|
171
|
-
},
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
// The template's `key-store` section, read by its own schema, without the
|
|
175
|
-
// section's relocation bridge.
|
|
176
|
-
const testKeyStoreModule = defineModule({
|
|
177
|
-
name: "key-store",
|
|
178
|
-
section: { schema: keyStoreSectionSchema },
|
|
179
|
-
provides: {
|
|
180
|
-
keyStore: async ({ section }) => {
|
|
181
|
-
const factory = createKeyStoreFactory();
|
|
182
|
-
registerBuiltinKeyStores(factory);
|
|
183
|
-
return factory.create({
|
|
184
|
-
type: "local",
|
|
185
|
-
...(section.local ?? {}),
|
|
186
|
-
});
|
|
187
|
-
},
|
|
188
|
-
},
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
describe("standalone smoke test", () => {
|
|
192
|
-
let handleRef: Awaited<ReturnType<typeof buildApp>>["handle"] | undefined;
|
|
193
|
-
|
|
194
|
-
async function buildApp() {
|
|
195
|
-
const handle = await createApp({
|
|
196
|
-
modules: buildModules(switches, {
|
|
197
|
-
keyStoreModule: testKeyStoreModule,
|
|
198
|
-
repositoriesModule: testRepositoriesModule,
|
|
199
|
-
// A memory RT family store in place of the default Redis store and
|
|
200
|
-
// its client, so no real ioredis connection is opened.
|
|
201
|
-
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
202
|
-
}),
|
|
203
|
-
bootstrapComponents: { config, pathResolver: (s) => s },
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
const app = express();
|
|
207
|
-
app.use(createHealthcheckRouter(express));
|
|
208
|
-
app.use(
|
|
209
|
-
createReadinessRouter(express, {
|
|
210
|
-
probes: handle.readinessProbes,
|
|
211
|
-
timeoutMs: HTTP.readinessTimeoutMs,
|
|
212
|
-
}),
|
|
213
|
-
);
|
|
214
|
-
app.use(handle.router);
|
|
215
|
-
return { app, handle };
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
afterEach(async () => {
|
|
219
|
-
await handleRef?.dispose();
|
|
220
|
-
handleRef = undefined;
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
it("Dockerfile declares runtime metadata and runs install/build steps as node", () => {
|
|
224
|
-
// HEALTHCHECK and EXPOSE must read the port from ${HTTP_PORT} so an
|
|
225
|
-
// operator overriding it keeps the app, EXPOSE and the healthcheck in
|
|
226
|
-
// sync; they are asserted by that token, not the literal port. The token
|
|
227
|
-
// is built by concatenation to sidestep biome's
|
|
228
|
-
// `noTemplateCurlyInString`: it is a Dockerfile substitution, not JS.
|
|
229
|
-
const HTTP_PORT_VAR = `$${"{"}HTTP_PORT}`;
|
|
230
|
-
expect(dockerfile).toContain("ENV HTTP_PORT=3000");
|
|
231
|
-
expect(dockerfile).toContain(`EXPOSE ${HTTP_PORT_VAR}`);
|
|
232
|
-
expect(dockerfile).toMatch(
|
|
233
|
-
/HEALTHCHECK\s+--interval=30s\s+--timeout=3s\s+--start-period=10s\s+--retries=3/,
|
|
234
|
-
);
|
|
235
|
-
// Match the literal `${HTTP_PORT}` token inside the HEALTHCHECK CMD.
|
|
236
|
-
// `[$]` (character class) sidesteps biome's `noTemplateCurlyInString`
|
|
237
|
-
// false-positive without changing what we actually accept.
|
|
238
|
-
expect(dockerfile).toMatch(
|
|
239
|
-
/CMD\s+wget\s+-q\s+-O\s+\/dev\/null\s+"http:\/\/localhost:[$]\{HTTP_PORT\}\/_healthcheck"\s+\|\|\s+exit\s+1/,
|
|
240
|
-
);
|
|
241
|
-
expect(dockerfile).toMatch(
|
|
242
|
-
/FROM node-base AS deps[\s\S]*USER node[\s\S]*RUN pnpm install --frozen-lockfile/,
|
|
243
|
-
);
|
|
244
|
-
expect(dockerfile).toMatch(/FROM deps AS builder[\s\S]*USER node[\s\S]*RUN pnpm run build/);
|
|
245
|
-
// Dependencies are resolved exactly once, in `deps`. The runtime
|
|
246
|
-
// stage must take its node_modules from a prune of that same tree — a
|
|
247
|
-
// second `pnpm install` would be a second resolution, so its absence
|
|
248
|
-
// after the runtime FROM is part of the contract.
|
|
249
|
-
expect(dockerfile).toMatch(
|
|
250
|
-
/FROM deps AS prod-deps[\s\S]*USER node[\s\S]*RUN pnpm prune --prod/,
|
|
251
|
-
);
|
|
252
|
-
expect(dockerfile).toMatch(
|
|
253
|
-
/FROM node-base AS runtime[\s\S]*COPY --from=prod-deps[\s\S]*USER node/,
|
|
254
|
-
);
|
|
255
|
-
expect(dockerfile.slice(dockerfile.indexOf("FROM node-base AS runtime"))).not.toContain(
|
|
256
|
-
"pnpm install",
|
|
257
|
-
);
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
it("Dockerfile pins its mutable build inputs", () => {
|
|
261
|
-
// Base image by digest — the tag alone is a moving pointer, and
|
|
262
|
-
// Dependabot's docker ecosystem bumps tag and digest together.
|
|
263
|
-
expect(dockerfile).toMatch(/FROM node:26-alpine@sha256:[0-9a-f]{64} AS node-base/);
|
|
264
|
-
// Global corepack by version — `npm install -g corepack` with no pin
|
|
265
|
-
// resolves whatever is latest at build time.
|
|
266
|
-
expect(dockerfile).toMatch(/npm install -g corepack@\d+\.\d+\.\d+ --force/);
|
|
267
|
-
// The lockfile is a required build input for --frozen-lockfile.
|
|
268
|
-
expect(dockerfile).toMatch(/COPY --chown=node:node package\.json pnpm-lock\.yaml/);
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
it("GET /_healthcheck returns 200", async () => {
|
|
272
|
-
const { app, handle } = await buildApp();
|
|
273
|
-
handleRef = handle;
|
|
274
|
-
const res = await request(app).get("/_healthcheck");
|
|
275
|
-
expect(res.status).toBe(200);
|
|
276
|
-
});
|
|
277
|
-
|
|
278
|
-
it("GET /readyz returns 200 for a memory-only composition with no probes", async () => {
|
|
279
|
-
// These smoke modules are all in-memory, so nothing registers a probe.
|
|
280
|
-
// Readiness must not invent a dependency that is not wired.
|
|
281
|
-
const { app, handle } = await buildApp();
|
|
282
|
-
handleRef = handle;
|
|
283
|
-
const res = await request(app).get("/readyz");
|
|
284
|
-
expect(res.status).toBe(200);
|
|
285
|
-
expect(res.body).toEqual({ status: "ready", checks: [] });
|
|
286
|
-
});
|
|
287
|
-
|
|
288
|
-
it("GET /readyz returns 503 naming the dependency when a probe fails", async () => {
|
|
289
|
-
const { handle } = await buildApp();
|
|
290
|
-
handleRef = handle;
|
|
291
|
-
const app = express();
|
|
292
|
-
app.use(
|
|
293
|
-
createReadinessRouter(express, {
|
|
294
|
-
probes: [
|
|
295
|
-
{
|
|
296
|
-
name: "redis",
|
|
297
|
-
check: async () => {
|
|
298
|
-
throw new Error("ECONNREFUSED");
|
|
299
|
-
},
|
|
300
|
-
},
|
|
301
|
-
],
|
|
302
|
-
timeoutMs: HTTP.readinessTimeoutMs,
|
|
303
|
-
}),
|
|
304
|
-
);
|
|
305
|
-
const res = await request(app).get("/readyz");
|
|
306
|
-
expect(res.status).toBe(503);
|
|
307
|
-
expect(res.body.status).toBe("unready");
|
|
308
|
-
expect(res.body.checks[0].name).toBe("redis");
|
|
309
|
-
});
|
|
310
|
-
|
|
311
|
-
it("issuer-configured buildModules serves the advertised jwks_uri (discovery <-> JWKS presence contract)", async () => {
|
|
312
|
-
// Against the REAL composition root: core's `jwksModule` contributes
|
|
313
|
-
// `jwks_uri` to discovery AND mounts the JWKS route. Without it the
|
|
314
|
-
// issuer-configured composition fails the discovery presence contract
|
|
315
|
-
// at boot. The oauth package's integration test composes both modules by
|
|
316
|
-
// hand, so only a scaffold-level test catches `buildModules` dropping it.
|
|
317
|
-
const issuerConfig: AppConfig = {
|
|
318
|
-
...config,
|
|
319
|
-
oauth: {
|
|
320
|
-
...(config.oauth as OAuthSection),
|
|
321
|
-
jwt: { ...(config.oauth as OAuthSection).jwt, issuer: "https://auth.example.com" },
|
|
322
|
-
},
|
|
323
|
-
};
|
|
324
|
-
const handle = await createApp({
|
|
325
|
-
modules: buildModules(switches, {
|
|
326
|
-
keyStoreModule: testKeyStoreModule,
|
|
327
|
-
repositoriesModule: testRepositoriesModule,
|
|
328
|
-
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
329
|
-
}),
|
|
330
|
-
bootstrapComponents: { config: issuerConfig, pathResolver: (s) => s },
|
|
331
|
-
});
|
|
332
|
-
handleRef = handle;
|
|
333
|
-
const app = express();
|
|
334
|
-
app.use(handle.router);
|
|
335
|
-
const disco = await request(app).get("/.well-known/openid-configuration");
|
|
336
|
-
expect(disco.status).toBe(200);
|
|
337
|
-
const jwksPath = new URL(disco.body.jwks_uri as string).pathname;
|
|
338
|
-
const res = await request(app).get(jwksPath);
|
|
339
|
-
expect(res.status).toBe(200);
|
|
340
|
-
expect(Array.isArray(res.body.keys)).toBe(true);
|
|
341
|
-
// Presence is not enough — the advertised jwks_uri must
|
|
342
|
-
// publish an actual verification key, and never the private half.
|
|
343
|
-
expect(res.body.keys).toHaveLength(1);
|
|
344
|
-
expect(res.body.keys[0].alg).toBe("EdDSA");
|
|
345
|
-
expect(res.body.keys[0].kid).toBe("v0");
|
|
346
|
-
expect(res.body.keys[0].d).toBeUndefined();
|
|
347
|
-
});
|
|
348
|
-
|
|
349
|
-
it("POST /oauth/token with unsupported grant_type returns 400", async () => {
|
|
350
|
-
const { app, handle } = await buildApp();
|
|
351
|
-
handleRef = handle;
|
|
352
|
-
const res = await request(app)
|
|
353
|
-
.post("/oauth/token")
|
|
354
|
-
.set("Authorization", SMOKE_BASIC_AUTH)
|
|
355
|
-
.type("form")
|
|
356
|
-
.send({ grant_type: "unsupported" });
|
|
357
|
-
expect(res.status).toBe(400);
|
|
358
|
-
});
|
|
359
|
-
|
|
360
|
-
it("POST /oauth/token 400 responses do NOT have Cache-Control: no-store", async () => {
|
|
361
|
-
const { app, handle } = await buildApp();
|
|
362
|
-
handleRef = handle;
|
|
363
|
-
const res = await request(app)
|
|
364
|
-
.post("/oauth/token")
|
|
365
|
-
.set("Authorization", SMOKE_BASIC_AUTH)
|
|
366
|
-
.type("form")
|
|
367
|
-
.send({ grant_type: "unsupported" });
|
|
368
|
-
expect(res.status).toBe(400);
|
|
369
|
-
expect(res.headers["cache-control"]).not.toBe("no-store");
|
|
370
|
-
});
|
|
371
|
-
|
|
372
|
-
// A freshly scaffolded app must boot under the default
|
|
373
|
-
// `core.federations.google.enabled = false`. The federation types the
|
|
374
|
-
// template bundles are loaded whatever the configuration says, and with no
|
|
375
|
-
// entry enabled they handle nothing: no federation is registered.
|
|
376
|
-
it("boots when google federation is disabled (default scaffold config)", async () => {
|
|
377
|
-
const modules = buildModules(switches, {
|
|
378
|
-
keyStoreModule: testKeyStoreModule,
|
|
379
|
-
repositoriesModule: testRepositoriesModule,
|
|
380
|
-
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
381
|
-
});
|
|
382
|
-
const moduleNames = modules.map((m) => m.name);
|
|
383
|
-
expect(moduleNames).toContain("federation-google-type");
|
|
384
|
-
expect(moduleNames).toContain("federation-oidc");
|
|
385
|
-
|
|
386
|
-
const handle = await createApp({
|
|
387
|
-
modules,
|
|
388
|
-
bootstrapComponents: { config, pathResolver: (s) => s },
|
|
389
|
-
});
|
|
390
|
-
handleRef = handle;
|
|
391
|
-
expect(handle.components.federationProviders?.size ?? 0).toBe(0);
|
|
392
|
-
});
|
|
393
|
-
|
|
394
|
-
// The composition root must default to the Redis-backed RT family store
|
|
395
|
-
// and the ioredis client module, NOT the in-memory store: multi-replica
|
|
396
|
-
// deployments lose RT family persistence when each replica holds families
|
|
397
|
-
// in-process. The smoke tests above exercise the memory override.
|
|
398
|
-
describe("redis-clients + adapter wiring", () => {
|
|
399
|
-
it("buildModules includes the shared redis-clients module + redis store by default", () => {
|
|
400
|
-
const modules = buildModules(switches, {
|
|
401
|
-
keyStoreModule: testKeyStoreModule,
|
|
402
|
-
repositoriesModule: testRepositoriesModule,
|
|
403
|
-
});
|
|
404
|
-
const names = modules.map((m) => m.name);
|
|
405
|
-
expect(names).toContain("redis-clients");
|
|
406
|
-
expect(names).toContain("redis-refresh-token-family-store");
|
|
407
|
-
expect(names).not.toContain("core-refresh-token-family-store-memory");
|
|
408
|
-
});
|
|
409
|
-
|
|
410
|
-
it("buildModules drops the shared redis-clients module when override forces memory-only", () => {
|
|
411
|
-
const modules = buildModules(switches, {
|
|
412
|
-
keyStoreModule: testKeyStoreModule,
|
|
413
|
-
repositoriesModule: testRepositoriesModule,
|
|
414
|
-
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
415
|
-
});
|
|
416
|
-
const names = modules.map((m) => m.name);
|
|
417
|
-
expect(names).toContain("core-refresh-token-family-store-memory");
|
|
418
|
-
expect(names).not.toContain("redis-clients");
|
|
419
|
-
expect(names).not.toContain("redis-refresh-token-family-store");
|
|
420
|
-
});
|
|
421
|
-
|
|
422
|
-
it("buildModules wires memoryRateLimiterModule by default", () => {
|
|
423
|
-
const modules = buildModules(switches, {
|
|
424
|
-
keyStoreModule: testKeyStoreModule,
|
|
425
|
-
repositoriesModule: testRepositoriesModule,
|
|
426
|
-
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
427
|
-
});
|
|
428
|
-
const names = modules.map((m) => m.name);
|
|
429
|
-
expect(names).toContain("core-rate-limiter-memory");
|
|
430
|
-
expect(names).not.toContain("redis-rate-limiter");
|
|
431
|
-
});
|
|
432
|
-
|
|
433
|
-
it("buildModules switches to redisRateLimiterModule when adapters.rateLimiter = 'redis'", () => {
|
|
434
|
-
const modules = buildModules(switchesWith({ rateLimiter: "redis" }), {
|
|
435
|
-
keyStoreModule: testKeyStoreModule,
|
|
436
|
-
repositoriesModule: testRepositoriesModule,
|
|
437
|
-
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
438
|
-
});
|
|
439
|
-
const names = modules.map((m) => m.name);
|
|
440
|
-
expect(names).toContain("redis-rate-limiter");
|
|
441
|
-
expect(names).not.toContain("core-rate-limiter-memory");
|
|
442
|
-
// Adapter = redis pulls in the shared redis-clients module too.
|
|
443
|
-
expect(names).toContain("redis-clients");
|
|
444
|
-
});
|
|
445
|
-
|
|
446
|
-
it("buildModules switches to redisSessionStoresModule when adapters.userSessionStores = 'redis'", () => {
|
|
447
|
-
const modules = buildModules(switchesWith({ userSessionStores: "redis" }), {
|
|
448
|
-
keyStoreModule: testKeyStoreModule,
|
|
449
|
-
repositoriesModule: testRepositoriesModule,
|
|
450
|
-
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
451
|
-
});
|
|
452
|
-
const names = modules.map((m) => m.name);
|
|
453
|
-
expect(names).toContain("redis-session-stores");
|
|
454
|
-
expect(names).not.toContain("standalone-in-memory-session-stores");
|
|
455
|
-
expect(names).toContain("redis-clients");
|
|
456
|
-
});
|
|
457
|
-
|
|
458
|
-
// The tests above check which modules are selected, not whether the
|
|
459
|
-
// selection is satisfiable: a `*Client` slot with no provider (such as
|
|
460
|
-
// `redisSessionStoresModule`'s `subjectSessionIndexClient`) fails
|
|
461
|
-
// stage-1 boot with `missing-required-component`. The invariant the
|
|
462
|
-
// shared clients module exists for: every `*Client` slot any selected
|
|
463
|
-
// module requires is one it provides.
|
|
464
|
-
it("redis-clients provides every *Client slot the Redis branches require", () => {
|
|
465
|
-
const allRedisConfig = switchesWith({
|
|
466
|
-
userSessionStores: "redis",
|
|
467
|
-
rateLimiter: "redis",
|
|
468
|
-
accessTokenDenylist: "redis",
|
|
469
|
-
consentStore: "redis",
|
|
470
|
-
codeRepository: "redis",
|
|
471
|
-
});
|
|
472
|
-
// No `refreshTokenFamilyModules` override: the default is the Redis
|
|
473
|
-
// RT-family store, so `refreshTokenFamilyClient` is part of what the
|
|
474
|
-
// invariant covers. Still hermetic — buildModules opens no socket.
|
|
475
|
-
const modules = buildModules(allRedisConfig, {
|
|
476
|
-
keyStoreModule: testKeyStoreModule,
|
|
477
|
-
repositoriesModule: testRepositoriesModule,
|
|
478
|
-
});
|
|
479
|
-
const clients = modules.find((m) => m.name === "redis-clients");
|
|
480
|
-
expect(clients).toBeDefined();
|
|
481
|
-
const provided = new Set(Object.keys(clients?.provides ?? {}));
|
|
482
|
-
const requiredClientSlots = new Set(
|
|
483
|
-
modules.flatMap((m) => [...(m.requires ?? [])]).filter((key) => key.endsWith("Client")),
|
|
484
|
-
);
|
|
485
|
-
expect(requiredClientSlots.size).toBeGreaterThan(0);
|
|
486
|
-
const missing = [...requiredClientSlots].filter((key) => !provided.has(key));
|
|
487
|
-
expect(missing).toEqual([]);
|
|
488
|
-
});
|
|
489
|
-
});
|
|
490
|
-
|
|
491
|
-
// Without an access-token denylist, `/oauth/revoke` would answer 200 for
|
|
492
|
-
// an access token and leave the JWT working until expiry. The template
|
|
493
|
-
// always wires one, and its own application.conf selects the Redis-backed
|
|
494
|
-
// adapter — the memory one forks per replica and `core.deployment.mode =
|
|
495
|
-
// "multi"` refuses it.
|
|
496
|
-
describe("access-token denylist wiring", () => {
|
|
497
|
-
it("always wires a denylist, so /oauth/revoke can keep its promise", () => {
|
|
498
|
-
const modules = buildModules(switches, {
|
|
499
|
-
keyStoreModule: testKeyStoreModule,
|
|
500
|
-
repositoriesModule: testRepositoriesModule,
|
|
501
|
-
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
502
|
-
});
|
|
503
|
-
const providesDenylist = modules.filter((m) =>
|
|
504
|
-
Object.keys(m.provides ?? {}).includes("accessTokenDenylist"),
|
|
505
|
-
);
|
|
506
|
-
expect(providesDenylist).toHaveLength(1);
|
|
507
|
-
});
|
|
508
|
-
|
|
509
|
-
it("defaults to the memory denylist when no adapter is configured", () => {
|
|
510
|
-
const modules = buildModules(switches, {
|
|
511
|
-
keyStoreModule: testKeyStoreModule,
|
|
512
|
-
repositoriesModule: testRepositoriesModule,
|
|
513
|
-
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
514
|
-
});
|
|
515
|
-
const names = modules.map((m) => m.name);
|
|
516
|
-
expect(names).toContain("core-access-token-denylist-memory");
|
|
517
|
-
expect(names).not.toContain("redis-access-token-denylist");
|
|
518
|
-
});
|
|
519
|
-
|
|
520
|
-
it("switches to the Redis denylist when adapters.accessTokenDenylist = 'redis'", () => {
|
|
521
|
-
const modules = buildModules(switchesWith({ accessTokenDenylist: "redis" }), {
|
|
522
|
-
keyStoreModule: testKeyStoreModule,
|
|
523
|
-
repositoriesModule: testRepositoriesModule,
|
|
524
|
-
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
525
|
-
});
|
|
526
|
-
const names = modules.map((m) => m.name);
|
|
527
|
-
expect(names).toContain("redis-access-token-denylist");
|
|
528
|
-
expect(names).not.toContain("core-access-token-denylist-memory");
|
|
529
|
-
// The Redis branch must also pull in the shared ioredis socket, or the
|
|
530
|
-
// `accessTokenDenylistClient` slot has no provider and boot fails on a
|
|
531
|
-
// missing component instead of on the thing the operator changed.
|
|
532
|
-
expect(names).toContain("redis-clients");
|
|
533
|
-
});
|
|
534
|
-
|
|
535
|
-
it("the template's reference.conf selects the replica-safe adapter", () => {
|
|
536
|
-
// The template's own config is the artifact operators deploy. It runs
|
|
537
|
-
// with `core.deployment.mode = multi` in the umbrella E2E, which refuses
|
|
538
|
-
// every in-memory shared store — so shipping the memory denylist here
|
|
539
|
-
// would be a boot failure in the very stack that proves the scaffold
|
|
540
|
-
// works.
|
|
541
|
-
expect(shippedAdapters().accessTokenDenylist).toBe("redis");
|
|
542
|
-
});
|
|
543
|
-
|
|
544
|
-
it("keeps the composition boot-valid end to end", async () => {
|
|
545
|
-
// The core boot guard refuses a composition that reads
|
|
546
|
-
// the denylist slot without one. A scaffold that trips its own library's
|
|
547
|
-
// guard is not a scaffold.
|
|
548
|
-
const handle = await createApp({
|
|
549
|
-
modules: buildModules(switches, {
|
|
550
|
-
keyStoreModule: testKeyStoreModule,
|
|
551
|
-
repositoriesModule: testRepositoriesModule,
|
|
552
|
-
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
553
|
-
}),
|
|
554
|
-
bootstrapComponents: { config, pathResolver: (s) => s },
|
|
555
|
-
});
|
|
556
|
-
handleRef = handle;
|
|
557
|
-
expect(handle).toBeDefined();
|
|
558
|
-
});
|
|
559
|
-
});
|
|
560
|
-
|
|
561
|
-
// The OAuth code repository's selection: the memory branch wires
|
|
562
|
-
// `inMemoryCodeRepositoryModule`; the redis branch wires
|
|
563
|
-
// `redisCodeRepositoryModule` against the shared ioredis socket.
|
|
564
|
-
describe("code-repository adapter wiring", () => {
|
|
565
|
-
it("buildModules wires inMemoryCodeRepositoryModule when adapters.codeRepository = 'memory'", () => {
|
|
566
|
-
const modules = buildModules(switches, {
|
|
567
|
-
keyStoreModule: testKeyStoreModule,
|
|
568
|
-
repositoriesModule: testRepositoriesModule,
|
|
569
|
-
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
570
|
-
});
|
|
571
|
-
const names = modules.map((m) => m.name);
|
|
572
|
-
expect(names).toContain("standalone-in-memory-code-repository");
|
|
573
|
-
expect(names).not.toContain("redis-code-repository");
|
|
574
|
-
});
|
|
575
|
-
|
|
576
|
-
it("buildModules switches to redisCodeRepositoryModule when adapters.codeRepository = 'redis'", () => {
|
|
577
|
-
const modules = buildModules(switchesWith({ codeRepository: "redis" }), {
|
|
578
|
-
keyStoreModule: testKeyStoreModule,
|
|
579
|
-
repositoriesModule: testRepositoriesModule,
|
|
580
|
-
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
581
|
-
});
|
|
582
|
-
const names = modules.map((m) => m.name);
|
|
583
|
-
expect(names).toContain("redis-code-repository");
|
|
584
|
-
expect(names).not.toContain("standalone-in-memory-code-repository");
|
|
585
|
-
// adapter = "redis" pulls in the shared ioredis socket so the
|
|
586
|
-
// `codeRepositoryClient` slot is satisfied.
|
|
587
|
-
expect(names).toContain("redis-clients");
|
|
588
|
-
});
|
|
589
|
-
|
|
590
|
-
it("buildModules has only ONE codeRepository provider in the manifest (no slot collision)", () => {
|
|
591
|
-
// The `repositories` module must NOT provide `codeRepository` — the
|
|
592
|
-
// slot is owned by `inMemoryCodeRepositoryModule` or
|
|
593
|
-
// `redisCodeRepositoryModule` exclusively, selected via
|
|
594
|
-
// `adapters.codeRepository`.
|
|
595
|
-
const modules = buildModules(switches, {
|
|
596
|
-
keyStoreModule: testKeyStoreModule,
|
|
597
|
-
// The production `repositories` module (not the test override), so
|
|
598
|
-
// its own provides are exercised.
|
|
599
|
-
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
600
|
-
});
|
|
601
|
-
const codeRepoProviders = modules.filter((m) =>
|
|
602
|
-
Object.keys(m.provides ?? {}).includes("codeRepository"),
|
|
603
|
-
);
|
|
604
|
-
expect(codeRepoProviders).toHaveLength(1);
|
|
605
|
-
expect(codeRepoProviders[0]?.name).toBe("standalone-in-memory-code-repository");
|
|
606
|
-
});
|
|
607
|
-
});
|
|
608
|
-
|
|
609
|
-
it("POST /oauth/introspect returns iat in active token response", async () => {
|
|
610
|
-
const { app, handle } = await buildApp();
|
|
611
|
-
handleRef = handle;
|
|
612
|
-
|
|
613
|
-
const ksf = createKeyStoreFactory();
|
|
614
|
-
registerBuiltinKeyStores(ksf);
|
|
615
|
-
const keyStore = await ksf.create({
|
|
616
|
-
type: "local",
|
|
617
|
-
...(config["key-store"] as { local: object }).local,
|
|
618
|
-
});
|
|
619
|
-
// The token must carry the deployment's configured `iss`: introspection
|
|
620
|
-
// pins it (RFC 9068 §4), and every deployment has one.
|
|
621
|
-
const { token } = await generateToken(
|
|
622
|
-
{},
|
|
623
|
-
{
|
|
624
|
-
keyStore,
|
|
625
|
-
subject: "u1",
|
|
626
|
-
expiresIn: 3600,
|
|
627
|
-
tokenType: "at+jwt",
|
|
628
|
-
issuer: (config.oauth as OAuthSection).jwt.issuer,
|
|
629
|
-
},
|
|
630
|
-
);
|
|
631
|
-
|
|
632
|
-
const res = await request(app)
|
|
633
|
-
.post("/oauth/introspect")
|
|
634
|
-
.set("Authorization", `Bearer ${token}`)
|
|
635
|
-
.type("form")
|
|
636
|
-
.send({ token });
|
|
637
|
-
|
|
638
|
-
expect(res.status).toBe(200);
|
|
639
|
-
expect(res.body.active).toBe(true);
|
|
640
|
-
expect(typeof res.body.iat).toBe("number");
|
|
641
|
-
});
|
|
642
|
-
});
|