@o3co/create-auth-provider 0.16.0 → 0.17.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.ja.md +66 -21
- package/README.md +76 -31
- package/dist/index.d.mts +33 -10
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +118 -59
- package/dist/internal/template-filter.d.mts +7 -10
- package/dist/internal/template-filter.d.mts.map +1 -1
- package/dist/internal/template-filter.mjs +7 -10
- package/package.json +6 -4
- package/templates/standalone/.dockerignore +15 -2
- package/templates/standalone/.env.example +59 -26
- package/templates/standalone/Dockerfile +6 -2
- package/templates/standalone/README.ja.md +293 -162
- package/templates/standalone/README.md +533 -199
- package/templates/standalone/config/application.conf +196 -341
- package/templates/standalone/config/clients.yaml +2 -0
- package/templates/standalone/config/clients.yaml.example +4 -0
- package/templates/standalone/config/development.conf +13 -4
- package/templates/standalone/config/mailpit.conf +8 -0
- package/templates/standalone/config/production.conf +5 -6
- package/templates/standalone/config/reference.conf +399 -0
- package/templates/standalone/docker-compose.mailpit.yml +36 -0
- package/templates/standalone/docker-compose.production.yml +58 -30
- package/templates/standalone/docker-compose.yml +13 -3
- package/templates/standalone/gitignore +1 -0
- package/templates/standalone/package.json +9 -6
- package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +19 -20
- package/templates/standalone/src/__tests__/adapters.test.mts +358 -0
- package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +216 -141
- package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +48 -33
- package/templates/standalone/src/__tests__/all-modules-composition.test.mts +77 -58
- package/templates/standalone/src/__tests__/audit-sink.test.mts +78 -76
- package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
- package/templates/standalone/src/__tests__/core-federations.test.mts +226 -0
- package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
- package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +9 -14
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +656 -269
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +425 -84
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +91 -70
- package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +7 -5
- package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +22 -19
- package/templates/standalone/src/__tests__/google-session.fixture.mts +53 -42
- package/templates/standalone/src/__tests__/host-routes.test.mts +4 -11
- package/templates/standalone/src/__tests__/library-references.fixture.mts +183 -0
- package/templates/standalone/src/__tests__/listen.test.mts +6 -13
- package/templates/standalone/src/__tests__/logger-projection.test.mts +1 -1
- package/templates/standalone/src/__tests__/mail-sender.test.mts +134 -0
- package/templates/standalone/src/__tests__/mailpit-compose.test.mts +328 -0
- package/templates/standalone/src/__tests__/metrics.test.mts +4 -9
- package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
- package/templates/standalone/src/__tests__/mfa-switch.test.mts +780 -0
- package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +52 -46
- package/templates/standalone/src/__tests__/own-modules.test.mts +1457 -0
- package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +7 -11
- package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +1 -5
- package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +112 -81
- package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
- package/templates/standalone/src/__tests__/replica-safety.test.mts +153 -142
- package/templates/standalone/src/__tests__/router-error-answers.test.mts +4 -7
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +275 -95
- package/templates/standalone/src/__tests__/sections.test.mts +480 -0
- package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +67 -55
- package/templates/standalone/src/__tests__/session-requirements.test.mts +299 -0
- package/templates/standalone/src/__tests__/shutdown.test.mts +163 -96
- package/templates/standalone/src/__tests__/smoke.test.mts +179 -252
- package/templates/standalone/src/__tests__/store-credential.test.mts +91 -50
- package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +108 -95
- package/templates/standalone/src/__tests__/two-phase-config.test.mts +438 -0
- package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +390 -0
- package/templates/standalone/src/adapters.mts +149 -0
- package/templates/standalone/src/app.mts +64 -79
- package/templates/standalone/src/buildModules.mts +212 -251
- package/templates/standalone/src/configPath.mts +422 -29
- package/templates/standalone/src/listen.mts +12 -18
- package/templates/standalone/src/logger.mts +31 -60
- package/templates/standalone/src/metrics.mts +24 -47
- package/templates/standalone/src/mfaSwitch.mts +262 -0
- package/templates/standalone/src/modules.mts +458 -476
- package/templates/standalone/src/rootRenames.mts +79 -0
- package/templates/standalone/src/routes.mts +8 -14
- package/templates/standalone/src/sections.mts +366 -0
- package/templates/standalone/src/shutdown.mts +45 -123
- package/templates/standalone/tests/index.test.js +2 -2
- package/templates/standalone/tests/package-lock.json +1615 -2223
- package/templates/standalone/tests/package.json +2 -2
- package/templates/standalone/tests/vitest.config.mjs +26 -0
- package/templates/standalone/vitest.config.mts +8 -7
- package/templates/standalone/vitest.supertest-loopback.mts +97 -69
- package/templates/versions.json +18 -15
|
@@ -0,0 +1,390 @@
|
|
|
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
|
+
* What an upstream IdP asserted about its own login counts only for a
|
|
19
|
+
* federation configured with `core.federations.<name>.trustUpstreamAmr = true`
|
|
20
|
+
* (ADR 2026-09-25-multi-factor-authentication), end to end: a federated login
|
|
21
|
+
* through the session routes, `/oauth/authorize` with `acr_values`, and
|
|
22
|
+
* `/oauth/token`, on the standalone as a deployment composes it
|
|
23
|
+
* (`buildModules`), with a federation whose IdP asserts `mfa` and `hwk`.
|
|
24
|
+
* By default they are kept in `authentication.upstreamAmr` and an `acr` entry
|
|
25
|
+
* only they could meet is withheld; trusted, they are recorded beside `fed`.
|
|
26
|
+
* The session routes and `oauth` read the switch separately (neither package
|
|
27
|
+
* depends on the other); this is where the two meet.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
31
|
+
import { fileURLToPath } from "node:url";
|
|
32
|
+
import {
|
|
33
|
+
type AppConfig,
|
|
34
|
+
AppConfigSchema,
|
|
35
|
+
createApp,
|
|
36
|
+
createKeyStoreFactory,
|
|
37
|
+
defineModule,
|
|
38
|
+
type FederationProvider,
|
|
39
|
+
federationsOf,
|
|
40
|
+
InMemoryClientRepository,
|
|
41
|
+
InMemoryUserRepository,
|
|
42
|
+
memoryRefreshTokenFamilyStoreModule,
|
|
43
|
+
registerBuiltinKeyStores,
|
|
44
|
+
type UserSessionStore,
|
|
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, type Switches } from "#/configPath.mjs";
|
|
53
|
+
import { templateReference } from "../modules.mjs";
|
|
54
|
+
import {
|
|
55
|
+
capturedRenames,
|
|
56
|
+
libraryLayers,
|
|
57
|
+
rootSectionsOf,
|
|
58
|
+
sectionsCoreDoesNotDeclare,
|
|
59
|
+
} from "./library-references.fixture.mjs";
|
|
60
|
+
|
|
61
|
+
const configDir = fileURLToPath(new URL("../../config", import.meta.url));
|
|
62
|
+
|
|
63
|
+
const ISSUER = "https://auth.test";
|
|
64
|
+
const CLIENT_ID = "rp";
|
|
65
|
+
const CLIENT_SECRET = "rp-secret-value";
|
|
66
|
+
const BASIC = `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64")}`;
|
|
67
|
+
const REDIRECT_URI = "https://rp.example/callback";
|
|
68
|
+
const FEDERATION = "partner";
|
|
69
|
+
const CALLBACK_URL = `${ISSUER}/session/oauth/federation/${FEDERATION}/callback`;
|
|
70
|
+
/** What the partner IdP asserts about its own login. */
|
|
71
|
+
const UPSTREAM_AMR = ["mfa", "hwk"];
|
|
72
|
+
const MFA_ACR = "urn:example:mfa";
|
|
73
|
+
const FED_ACR = "urn:example:fed";
|
|
74
|
+
|
|
75
|
+
const ENV: Readonly<Record<string, string>> = {
|
|
76
|
+
// This provider verifies no second factor of its own here: only upstream's.
|
|
77
|
+
MFA_MODE: "off",
|
|
78
|
+
KEY_STORE_LOCAL_ALGORITHM: "HS256",
|
|
79
|
+
KEY_STORE_LOCAL_SECRET: "upstream-amr.acceptance.at-least-32-bytes",
|
|
80
|
+
OAUTH_JWT_ISSUER: ISSUER,
|
|
81
|
+
SESSION_STORE_SECRET: "upstream-amr.acceptance-session.at-least-32-bytes",
|
|
82
|
+
SESSION_STORE_SECURE: "false",
|
|
83
|
+
SESSION_STORE_NAME: "auth.session",
|
|
84
|
+
SESSION_STORE_STORAGE_TYPE: "memory",
|
|
85
|
+
ADAPTERS_USER_REPOSITORY: "yaml",
|
|
86
|
+
REDIS_CLIENTS_URL: "redis://redis.test:6379",
|
|
87
|
+
ADAPTERS_USER_SESSION_STORES: "memory",
|
|
88
|
+
ADAPTERS_RATE_LIMITER: "memory",
|
|
89
|
+
ADAPTERS_CODE_REPOSITORY: "memory",
|
|
90
|
+
ADAPTERS_ACCESS_TOKEN_DENYLIST: "memory",
|
|
91
|
+
ADAPTERS_REPLAY_SEEN_SET: "memory",
|
|
92
|
+
ADAPTERS_FEDERATION_TOKEN_STORE: "memory",
|
|
93
|
+
ADAPTERS_CONSENT_STORE: "none",
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** The shipped configuration, with the partner federation and an acr table beside it. */
|
|
97
|
+
function resolveConfig(trustUpstreamAmr: boolean | undefined): Switches {
|
|
98
|
+
const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
|
|
99
|
+
const layers = parseFile(envConfPath, { env: ENV })
|
|
100
|
+
.withFallback(parseFile(applicationConfPath, { env: ENV }))
|
|
101
|
+
.withFallback(parseFile(fileURLToPath(templateReference()), { env: ENV }))
|
|
102
|
+
.withFallback(libraryLayers(ENV));
|
|
103
|
+
const shipped = validate(layers, AppConfigSchema) as AppConfig;
|
|
104
|
+
const parsed = AppConfigSchema.parse({
|
|
105
|
+
...shipped,
|
|
106
|
+
core: {
|
|
107
|
+
...shipped.core,
|
|
108
|
+
federations: {
|
|
109
|
+
...federationsOf(shipped),
|
|
110
|
+
[FEDERATION]: {
|
|
111
|
+
enabled: true,
|
|
112
|
+
type: FEDERATION,
|
|
113
|
+
callbackURL: CALLBACK_URL,
|
|
114
|
+
...(trustUpstreamAmr === undefined ? {} : { trustUpstreamAmr }),
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
oauth: {
|
|
119
|
+
...shipped.oauth,
|
|
120
|
+
authorize: {
|
|
121
|
+
...shipped.oauth.authorize,
|
|
122
|
+
acrValues: { [MFA_ACR]: ["mfa"], [FED_ACR]: ["fed"] },
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
return {
|
|
127
|
+
...sectionsCoreDoesNotDeclare(layers),
|
|
128
|
+
...rootSectionsOf(layers, ENV),
|
|
129
|
+
...parsed,
|
|
130
|
+
// What the resolution captured of core's renamed variables, which the
|
|
131
|
+
// schema's parse drops.
|
|
132
|
+
"renamed-variables": capturedRenames(ENV),
|
|
133
|
+
} as Switches;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** An IdP that authenticates `ext-1` and says it did so with `mfa` and `hwk`. */
|
|
137
|
+
const partner: FederationProvider = {
|
|
138
|
+
name: FEDERATION,
|
|
139
|
+
scope: ["openid"],
|
|
140
|
+
buildAuthorizationUrl: ({ state }) => {
|
|
141
|
+
const url = new URL("https://idp.partner.example/authorize");
|
|
142
|
+
url.searchParams.set("state", state);
|
|
143
|
+
return url;
|
|
144
|
+
},
|
|
145
|
+
exchangeCode: async () => ({
|
|
146
|
+
issuer: "https://idp.partner.example",
|
|
147
|
+
sub: "ext-1",
|
|
148
|
+
accessToken: "upstream-at",
|
|
149
|
+
expiresAt: null,
|
|
150
|
+
amr: UPSTREAM_AMR,
|
|
151
|
+
}),
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const partnerFederationModule = defineModule({
|
|
155
|
+
name: "test:partner-federation",
|
|
156
|
+
contributes: {
|
|
157
|
+
federations: { [FEDERATION]: () => partner },
|
|
158
|
+
federationRedirectPolicies: {
|
|
159
|
+
[FEDERATION]: () => ({
|
|
160
|
+
validateRedirect: () => ({ ok: true as const, value: undefined }),
|
|
161
|
+
resolveCallbackRedirect: () => ({ ok: true as const, value: "/" }),
|
|
162
|
+
}),
|
|
163
|
+
},
|
|
164
|
+
} as never,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const testRepositoriesModule = defineModule({
|
|
168
|
+
name: "test:repositories",
|
|
169
|
+
provides: {
|
|
170
|
+
clientRepository: () =>
|
|
171
|
+
new InMemoryClientRepository(
|
|
172
|
+
new Map([
|
|
173
|
+
[
|
|
174
|
+
CLIENT_ID,
|
|
175
|
+
{
|
|
176
|
+
tokenEndpointAuthMethod: "client_secret_basic",
|
|
177
|
+
clientSecret: CLIENT_SECRET,
|
|
178
|
+
allowedRedirectUris: [REDIRECT_URI],
|
|
179
|
+
allowedScopes: ["openid"],
|
|
180
|
+
allowedAudiences: [],
|
|
181
|
+
allowedGrantTypes: ["authorization_code"],
|
|
182
|
+
firstParty: true,
|
|
183
|
+
backchannelLogoutSessionRequired: false,
|
|
184
|
+
frontchannelLogoutSessionRequired: false,
|
|
185
|
+
allowedAzpForFederationToken: false,
|
|
186
|
+
},
|
|
187
|
+
],
|
|
188
|
+
]),
|
|
189
|
+
),
|
|
190
|
+
// The partner's `ext-1` is alice's linked identity.
|
|
191
|
+
userRepository: () =>
|
|
192
|
+
new InMemoryUserRepository(
|
|
193
|
+
new Map([["alice", { id: "u-alice", password: "unused", token: `${FEDERATION}:ext-1` }]]),
|
|
194
|
+
),
|
|
195
|
+
} as never,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const testKeyStoreModule = defineModule({
|
|
199
|
+
name: "test:key-store",
|
|
200
|
+
requires: ["config"] as const,
|
|
201
|
+
provides: {
|
|
202
|
+
keyStore: async ({ config: c }) => {
|
|
203
|
+
const factory = createKeyStoreFactory();
|
|
204
|
+
registerBuiltinKeyStores(factory);
|
|
205
|
+
return factory.create({
|
|
206
|
+
type: "local",
|
|
207
|
+
...((c as { "key-store"?: { local?: object } })["key-store"]?.local ?? {}),
|
|
208
|
+
});
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
/** A JWT's claims, unverified: the deployment signed it, and the test reads what it said. */
|
|
214
|
+
const decodeJwt = (jwt: string): Record<string, unknown> =>
|
|
215
|
+
JSON.parse(Buffer.from(jwt.split(".")[1] ?? "", "base64url").toString("utf8")) as Record<
|
|
216
|
+
string,
|
|
217
|
+
unknown
|
|
218
|
+
>;
|
|
219
|
+
|
|
220
|
+
const cookiesOf = (res: request.Response): string[] =>
|
|
221
|
+
(res.headers["set-cookie"] as unknown as string[] | undefined) ?? [];
|
|
222
|
+
|
|
223
|
+
interface Deployment {
|
|
224
|
+
readonly app: express.Express;
|
|
225
|
+
readonly userSessionStore: UserSessionStore;
|
|
226
|
+
readonly dispose: () => Promise<void>;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function boot(trustUpstreamAmr: boolean | undefined): Promise<Deployment> {
|
|
230
|
+
const config = resolveConfig(trustUpstreamAmr);
|
|
231
|
+
const handle = await createApp({
|
|
232
|
+
modules: [
|
|
233
|
+
...buildModules(config, {
|
|
234
|
+
keyStoreModule: testKeyStoreModule,
|
|
235
|
+
repositoriesModule: testRepositoriesModule,
|
|
236
|
+
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
237
|
+
}),
|
|
238
|
+
partnerFederationModule,
|
|
239
|
+
],
|
|
240
|
+
bootstrapComponents: { config, pathResolver: (s) => s },
|
|
241
|
+
});
|
|
242
|
+
const app = express();
|
|
243
|
+
app.use(handle.router);
|
|
244
|
+
return {
|
|
245
|
+
app,
|
|
246
|
+
userSessionStore: (handle.components as { userSessionStore: UserSessionStore })
|
|
247
|
+
.userSessionStore,
|
|
248
|
+
dispose: () => handle.dispose(),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Signs alice in through the partner federation; the browser's cookies after the callback. */
|
|
253
|
+
async function signInThroughPartner(app: express.Express): Promise<string[]> {
|
|
254
|
+
const start = await request(app).get(`/session/oauth/federation/${FEDERATION}`);
|
|
255
|
+
expect(start.status).toBe(302);
|
|
256
|
+
const state = new URL(start.headers.location as string).searchParams.get("state");
|
|
257
|
+
expect(state).toBeTruthy();
|
|
258
|
+
const callback = await request(app)
|
|
259
|
+
.get(`/session/oauth/federation/${FEDERATION}/callback`)
|
|
260
|
+
.query({ state, code: "upstream-code" })
|
|
261
|
+
.set("Cookie", cookiesOf(start));
|
|
262
|
+
expect(callback.status).toBe(302);
|
|
263
|
+
return cookiesOf(callback);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** `/oauth/authorize` for the signed-in browser: the redirect back to the client, as parameters. */
|
|
267
|
+
async function authorize(
|
|
268
|
+
app: express.Express,
|
|
269
|
+
cookies: readonly string[],
|
|
270
|
+
acrValues: string,
|
|
271
|
+
): Promise<{ readonly params: URLSearchParams; readonly verifier: string }> {
|
|
272
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
273
|
+
const res = await request(app)
|
|
274
|
+
.get("/oauth/authorize")
|
|
275
|
+
.query({
|
|
276
|
+
response_type: "code",
|
|
277
|
+
client_id: CLIENT_ID,
|
|
278
|
+
redirect_uri: REDIRECT_URI,
|
|
279
|
+
scope: "openid",
|
|
280
|
+
state: "rp-state",
|
|
281
|
+
code_challenge: createHash("sha256").update(verifier).digest("base64url"),
|
|
282
|
+
code_challenge_method: "S256",
|
|
283
|
+
acr_values: acrValues,
|
|
284
|
+
})
|
|
285
|
+
.set("Cookie", [...cookies]);
|
|
286
|
+
expect(res.status).toBe(302);
|
|
287
|
+
const location = new URL(res.headers.location as string);
|
|
288
|
+
expect(`${location.origin}${location.pathname}`).toBe(REDIRECT_URI);
|
|
289
|
+
return { params: location.searchParams, verifier };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** The tokens `/oauth/token` mints for `code`, decoded. */
|
|
293
|
+
async function tokensFor(
|
|
294
|
+
app: express.Express,
|
|
295
|
+
code: string,
|
|
296
|
+
verifier: string,
|
|
297
|
+
): Promise<{
|
|
298
|
+
readonly idToken: Record<string, unknown>;
|
|
299
|
+
readonly accessToken: Record<string, unknown>;
|
|
300
|
+
}> {
|
|
301
|
+
const res = await request(app)
|
|
302
|
+
.post("/oauth/token")
|
|
303
|
+
.set("Authorization", BASIC)
|
|
304
|
+
.type("form")
|
|
305
|
+
.send({
|
|
306
|
+
grant_type: "authorization_code",
|
|
307
|
+
code,
|
|
308
|
+
redirect_uri: REDIRECT_URI,
|
|
309
|
+
code_verifier: verifier,
|
|
310
|
+
});
|
|
311
|
+
expect(res.status).toBe(200);
|
|
312
|
+
return {
|
|
313
|
+
idToken: decodeJwt(res.body.id_token as string),
|
|
314
|
+
accessToken: decodeJwt(res.body.access_token as string),
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
describe("an upstream IdP's amr counts only for a federation that trusts it", () => {
|
|
319
|
+
let deployment: Deployment | undefined;
|
|
320
|
+
|
|
321
|
+
afterEach(async () => {
|
|
322
|
+
await deployment?.dispose();
|
|
323
|
+
deployment = undefined;
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("by default: kept apart in the session, withheld from discovery, unmet at /authorize, on no token", async () => {
|
|
327
|
+
deployment = await boot(undefined);
|
|
328
|
+
const { app, userSessionStore } = deployment;
|
|
329
|
+
|
|
330
|
+
const discovery = await request(app).get("/.well-known/openid-configuration");
|
|
331
|
+
// The entry only the IdP's `mfa` could meet is dropped, since that `mfa`
|
|
332
|
+
// is not recorded: no deployment advertises what it cannot meet.
|
|
333
|
+
expect(discovery.body.acr_values_supported).toEqual([FED_ACR]);
|
|
334
|
+
|
|
335
|
+
const cookies = await signInThroughPartner(app);
|
|
336
|
+
|
|
337
|
+
const unmet = await authorize(app, cookies, MFA_ACR);
|
|
338
|
+
expect(unmet.params.get("error")).toBe("unmet_authentication_requirements");
|
|
339
|
+
expect(unmet.params.get("code")).toBeNull();
|
|
340
|
+
|
|
341
|
+
const { params, verifier } = await authorize(app, cookies, FED_ACR);
|
|
342
|
+
const code = params.get("code");
|
|
343
|
+
expect(code).toBeTruthy();
|
|
344
|
+
const { idToken, accessToken } = await tokensFor(app, code as string, verifier);
|
|
345
|
+
expect(idToken.acr).toBe(FED_ACR);
|
|
346
|
+
expect(idToken.amr).toEqual(["fed"]);
|
|
347
|
+
expect(accessToken.amr).toEqual(["fed"]);
|
|
348
|
+
|
|
349
|
+
// The session keeps what the IdP said, for the record.
|
|
350
|
+
const session = await userSessionStore.get(idToken.sid as string);
|
|
351
|
+
expect(session?.amr).toEqual(["fed"]);
|
|
352
|
+
expect(session?.authentication).toStrictEqual({
|
|
353
|
+
primary: "fed",
|
|
354
|
+
federation: FEDERATION,
|
|
355
|
+
upstreamAmr: UPSTREAM_AMR,
|
|
356
|
+
mfaAt: undefined,
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("trusted: recorded beside fed, advertised, met at /authorize and stamped", async () => {
|
|
361
|
+
deployment = await boot(true);
|
|
362
|
+
const { app, userSessionStore } = deployment;
|
|
363
|
+
|
|
364
|
+
const discovery = await request(app).get("/.well-known/openid-configuration");
|
|
365
|
+
expect(discovery.body.acr_values_supported).toEqual([MFA_ACR, FED_ACR]);
|
|
366
|
+
|
|
367
|
+
const cookies = await signInThroughPartner(app);
|
|
368
|
+
const { params, verifier } = await authorize(app, cookies, MFA_ACR);
|
|
369
|
+
const code = params.get("code");
|
|
370
|
+
expect(code).toBeTruthy();
|
|
371
|
+
const { idToken, accessToken } = await tokensFor(app, code as string, verifier);
|
|
372
|
+
expect(idToken.acr).toBe(MFA_ACR);
|
|
373
|
+
expect(idToken.amr).toEqual([...UPSTREAM_AMR, "fed"]);
|
|
374
|
+
expect(accessToken.amr).toEqual([...UPSTREAM_AMR, "fed"]);
|
|
375
|
+
|
|
376
|
+
const session = await userSessionStore.get(idToken.sid as string);
|
|
377
|
+
expect(session?.authentication).toStrictEqual({
|
|
378
|
+
primary: "fed",
|
|
379
|
+
federation: FEDERATION,
|
|
380
|
+
upstreamAmr: undefined,
|
|
381
|
+
mfaAt: undefined,
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("says false explicitly as it says nothing: kept apart", async () => {
|
|
386
|
+
deployment = await boot(false);
|
|
387
|
+
const discovery = await request(deployment.app).get("/.well-known/openid-configuration");
|
|
388
|
+
expect(discovery.body.acr_values_supported).toEqual([FED_ACR]);
|
|
389
|
+
});
|
|
390
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
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 composition root's own section, `adapters`: which adapter fills each
|
|
19
|
+
* slot. Phase one reads it alone, before the modules are chosen, with the
|
|
20
|
+
* template's own schema; boot is never handed it. The paths the selections
|
|
21
|
+
* moved from, and the variables renamed with them, are refused here, before
|
|
22
|
+
* any module is chosen, in the words boot refuses a module's in: a renamed
|
|
23
|
+
* variable set alone, or beside its new name at a different value, is
|
|
24
|
+
* refused, naming the variables and never a value.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { refuseRenamedVariables } from "./rootRenames.mjs";
|
|
28
|
+
import { type Adapters, adaptersSchema } from "./sections.mjs";
|
|
29
|
+
|
|
30
|
+
/** The composition root's own section. No module may be named after it. */
|
|
31
|
+
export const ADAPTERS_SECTION = "adapters";
|
|
32
|
+
|
|
33
|
+
/** Each selection's key under `adapters`, and the variable its path is bound to. */
|
|
34
|
+
const VARIABLES: Readonly<Record<keyof Adapters, string>> = {
|
|
35
|
+
rateLimiter: "ADAPTERS_RATE_LIMITER",
|
|
36
|
+
userSessionStores: "ADAPTERS_USER_SESSION_STORES",
|
|
37
|
+
accessTokenDenylist: "ADAPTERS_ACCESS_TOKEN_DENYLIST",
|
|
38
|
+
replaySeenSet: "ADAPTERS_REPLAY_SEEN_SET",
|
|
39
|
+
consentStore: "ADAPTERS_CONSENT_STORE",
|
|
40
|
+
federationTokenStore: "ADAPTERS_FEDERATION_TOKEN_STORE",
|
|
41
|
+
federationGrantStore: "ADAPTERS_FEDERATION_GRANT_STORE",
|
|
42
|
+
federationGrantIntentStore: "ADAPTERS_FEDERATION_GRANT_INTENT_STORE",
|
|
43
|
+
mfaFactorStore: "ADAPTERS_MFA_FACTOR_STORE",
|
|
44
|
+
mfaTransactionStore: "ADAPTERS_MFA_TRANSACTION_STORE",
|
|
45
|
+
codeRepository: "ADAPTERS_CODE_REPOSITORY",
|
|
46
|
+
clientRepository: "ADAPTERS_CLIENT_REPOSITORY",
|
|
47
|
+
userRepository: "ADAPTERS_USER_REPOSITORY",
|
|
48
|
+
auditSink: "ADAPTERS_AUDIT_SINK",
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Each selection's old path, and its key under `adapters`. */
|
|
52
|
+
const MOVED: readonly (readonly [string, keyof Adapters])[] = [
|
|
53
|
+
["rateLimiter.adapter", "rateLimiter"],
|
|
54
|
+
["userSessionStores.adapter", "userSessionStores"],
|
|
55
|
+
["accessTokenDenylist.adapter", "accessTokenDenylist"],
|
|
56
|
+
["replaySeenSet.adapter", "replaySeenSet"],
|
|
57
|
+
["consentStore.adapter", "consentStore"],
|
|
58
|
+
["federationTokenStore.type", "federationTokenStore"],
|
|
59
|
+
["federationGrantStore.adapter", "federationGrantStore"],
|
|
60
|
+
["federationGrantIntentStore.adapter", "federationGrantIntentStore"],
|
|
61
|
+
["mfaFactorStore.adapter", "mfaFactorStore"],
|
|
62
|
+
["mfaTransactionStore.adapter", "mfaTransactionStore"],
|
|
63
|
+
["oauth.code.adapter", "codeRepository"],
|
|
64
|
+
["repositories.code.type", "codeRepository"],
|
|
65
|
+
["repositories.client.type", "clientRepository"],
|
|
66
|
+
["repositories.user.type", "userRepository"],
|
|
67
|
+
["audit.sink.type", "auditSink"],
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
/** Each variable renamed with a selection, and the selection's key under `adapters`. */
|
|
71
|
+
const RENAMED: readonly (readonly [string, keyof Adapters])[] = [
|
|
72
|
+
["RATE_LIMITER_ADAPTER", "rateLimiter"],
|
|
73
|
+
["USER_SESSION_STORES_ADAPTER", "userSessionStores"],
|
|
74
|
+
["ACCESS_TOKEN_DENYLIST_ADAPTER", "accessTokenDenylist"],
|
|
75
|
+
["REPLAY_SEEN_SET_ADAPTER", "replaySeenSet"],
|
|
76
|
+
["CONSENT_STORE_ADAPTER", "consentStore"],
|
|
77
|
+
["FEDERATION_TOKEN_STORE_TYPE", "federationTokenStore"],
|
|
78
|
+
["FEDERATION_GRANT_STORE_ADAPTER", "federationGrantStore"],
|
|
79
|
+
["FEDERATION_GRANT_INTENT_STORE_ADAPTER", "federationGrantIntentStore"],
|
|
80
|
+
["MFA_FACTOR_STORE_ADAPTER", "mfaFactorStore"],
|
|
81
|
+
["MFA_TRANSACTION_STORE_ADAPTER", "mfaTransactionStore"],
|
|
82
|
+
["OAUTH_CODE_ADAPTER", "codeRepository"],
|
|
83
|
+
["CLIENT_CODE_TYPE", "codeRepository"],
|
|
84
|
+
["CLIENT_TYPE", "clientRepository"],
|
|
85
|
+
["CLIENT_USER_TYPE", "userRepository"],
|
|
86
|
+
["AUDIT_SINK_TYPE", "auditSink"],
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
/** The value at a dotted `path` of `config`, read as own properties; `undefined` when absent. */
|
|
90
|
+
function valueAt(config: unknown, path: string): unknown {
|
|
91
|
+
let cursor: unknown = config;
|
|
92
|
+
for (const key of path.split(".")) {
|
|
93
|
+
if (typeof cursor !== "object" || cursor === null || !Object.hasOwn(cursor, key)) {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
cursor = (cursor as Record<string, unknown>)[key];
|
|
97
|
+
}
|
|
98
|
+
return cursor;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* `adapters` from `resolved` — the template's own layers over its
|
|
103
|
+
* `config/reference.conf` — under `env`, the environment they were
|
|
104
|
+
* substituted with, parsed with the template's schema. Refuses, with a
|
|
105
|
+
* `RangeError`, a selection still written at the path it moved from, naming
|
|
106
|
+
* its new path and variable; then a variable renamed with one
|
|
107
|
+
* (`refuseRenamedVariables`); then a value or a key the schema refuses,
|
|
108
|
+
* naming its path under `adapters`.
|
|
109
|
+
*/
|
|
110
|
+
export function readAdapters(
|
|
111
|
+
resolved: Readonly<Record<string, unknown>>,
|
|
112
|
+
env: Readonly<Record<string, string>>,
|
|
113
|
+
): Adapters {
|
|
114
|
+
const moved = MOVED.filter(([from]) => valueAt(resolved, from) !== undefined).map(
|
|
115
|
+
([from, key]) => {
|
|
116
|
+
const to = `${ADAPTERS_SECTION}.${key}`;
|
|
117
|
+
return `${from} has moved to ${to}; see CHANGELOG. Write it there (environment variable ${VARIABLES[key]}) and remove this field from your config (or unset the environment variable that sets it).`;
|
|
118
|
+
},
|
|
119
|
+
);
|
|
120
|
+
if (moved.length > 0) {
|
|
121
|
+
throw new RangeError(
|
|
122
|
+
`Configuration sets ${moved.length} path(s) that moved: ${moved.join(" ")}`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
refuseRenamedVariables(
|
|
126
|
+
env,
|
|
127
|
+
RENAMED.map(([from, key]) => ({
|
|
128
|
+
from,
|
|
129
|
+
to: VARIABLES[key],
|
|
130
|
+
path: `${ADAPTERS_SECTION}.${key}`,
|
|
131
|
+
})),
|
|
132
|
+
);
|
|
133
|
+
const result = adaptersSchema.safeParse(resolved[ADAPTERS_SECTION]);
|
|
134
|
+
if (!result.success) {
|
|
135
|
+
const issues = result.error.issues;
|
|
136
|
+
throw new RangeError(
|
|
137
|
+
`Config validation failed — ${issues.length} issue(s) found: ${issues
|
|
138
|
+
.map((issue) => {
|
|
139
|
+
const unknown =
|
|
140
|
+
issue.code === "unrecognized_keys" ? ` ${JSON.stringify(issue.keys)}` : "";
|
|
141
|
+
const path = [ADAPTERS_SECTION, ...issue.path.map(String)].join(".");
|
|
142
|
+
return `${path}: ${issue.message}${unknown}`;
|
|
143
|
+
})
|
|
144
|
+
.join("; ")}`,
|
|
145
|
+
{ cause: result.error },
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
return result.data;
|
|
149
|
+
}
|