@o3co/create-auth-provider 0.15.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 +139 -48
- package/README.md +138 -47
- 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 +69 -24
- package/templates/standalone/Dockerfile +7 -3
- package/templates/standalone/README.ja.md +677 -184
- package/templates/standalone/README.md +699 -238
- package/templates/standalone/config/application.conf +203 -325
- 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 +4 -2
- package/templates/standalone/package.json +9 -6
- package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +65 -32
- package/templates/standalone/src/__tests__/adapters.test.mts +358 -0
- package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +1178 -0
- package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +256 -0
- package/templates/standalone/src/__tests__/all-modules-composition.test.mts +1119 -0
- 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 +672 -271
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +480 -78
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +102 -66
- package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +135 -0
- package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +49 -18
- package/templates/standalone/src/__tests__/google-session.fixture.mts +315 -0
- package/templates/standalone/src/__tests__/host-routes.test.mts +134 -0
- package/templates/standalone/src/__tests__/library-references.fixture.mts +183 -0
- package/templates/standalone/src/__tests__/listen.test.mts +121 -0
- package/templates/standalone/src/__tests__/logger-projection.test.mts +77 -0
- 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 +17 -7
- 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 +183 -0
- package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +135 -0
- 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 +166 -145
- package/templates/standalone/src/__tests__/router-error-answers.test.mts +146 -0
- 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 +319 -115
- package/templates/standalone/src/__tests__/smoke.test.mts +179 -233
- package/templates/standalone/src/__tests__/store-credential.test.mts +317 -0
- 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 +71 -115
- package/templates/standalone/src/buildModules.mts +229 -251
- package/templates/standalone/src/configPath.mts +422 -29
- package/templates/standalone/src/listen.mts +60 -0
- package/templates/standalone/src/logger.mts +31 -57
- package/templates/standalone/src/metrics.mts +24 -47
- package/templates/standalone/src/mfaSwitch.mts +262 -0
- package/templates/standalone/src/modules.mts +480 -464
- package/templates/standalone/src/rootRenames.mts +79 -0
- package/templates/standalone/src/routes.mts +89 -0
- package/templates/standalone/src/sections.mts +366 -0
- package/templates/standalone/src/shutdown.mts +54 -126
- 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
- package/templates/standalone/src/__tests__/terminal-error.test.mts +0 -96
- package/templates/standalone/src/terminalError.mts +0 -60
|
@@ -0,0 +1,315 @@
|
|
|
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
|
+
* A browser session with Google linked, on the standalone composed as a
|
|
19
|
+
* deployment does: `oauthModule`, the session module and the real Google
|
|
20
|
+
* adapter, booted through `createApp` under the shipped configuration.
|
|
21
|
+
*
|
|
22
|
+
* The two logout routes and `POST /oauth/federation/:name/token` live in
|
|
23
|
+
* `@o3co/auth-provider-oauth`, which depends on no adapter; only here can a
|
|
24
|
+
* test see what they hand the real adapter and what they make of what its
|
|
25
|
+
* library raises.
|
|
26
|
+
*
|
|
27
|
+
* Unlike `all-modules-composition.fixture.mts`, the deployment signs with an
|
|
28
|
+
* HS256 secret this file holds, so tests can present an `id_token_hint` and
|
|
29
|
+
* access token of their own; nothing but Google is federated.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { createHmac } from "node:crypto";
|
|
33
|
+
import { fileURLToPath } from "node:url";
|
|
34
|
+
import {
|
|
35
|
+
AppConfigSchema,
|
|
36
|
+
createApp,
|
|
37
|
+
createKeyStoreFactory,
|
|
38
|
+
defineModule,
|
|
39
|
+
type FederationTokenStore,
|
|
40
|
+
type FederationTokens,
|
|
41
|
+
InMemoryClientRepository,
|
|
42
|
+
InMemoryUserRepository,
|
|
43
|
+
memoryRefreshTokenFamilyStoreModule,
|
|
44
|
+
registerBuiltinKeyStores,
|
|
45
|
+
type SessionFederationIndex,
|
|
46
|
+
type UserSessionStore,
|
|
47
|
+
} from "@o3co/auth-provider-core";
|
|
48
|
+
import {
|
|
49
|
+
type GoogleProviderConfig,
|
|
50
|
+
googleFederationModule,
|
|
51
|
+
} from "@o3co/auth-provider-federation-google";
|
|
52
|
+
import { parseFile } from "@o3co/ts.hocon";
|
|
53
|
+
import { validate } from "@o3co/ts.hocon/zod";
|
|
54
|
+
import express from "express";
|
|
55
|
+
import request from "supertest";
|
|
56
|
+
import { expect } from "vitest";
|
|
57
|
+
import { buildModules } from "#/buildModules.mjs";
|
|
58
|
+
import { resolveConfigPaths, type Switches } from "#/configPath.mjs";
|
|
59
|
+
import { templateReference } from "../modules.mjs";
|
|
60
|
+
import {
|
|
61
|
+
capturedRenames,
|
|
62
|
+
libraryLayers,
|
|
63
|
+
rootSectionsOf,
|
|
64
|
+
sectionsCoreDoesNotDeclare,
|
|
65
|
+
} from "./library-references.fixture.mjs";
|
|
66
|
+
|
|
67
|
+
const configDir = fileURLToPath(new URL("../../config", import.meta.url));
|
|
68
|
+
|
|
69
|
+
export const ISSUER = "https://auth.test";
|
|
70
|
+
const JWT_SECRET = "google-session.fixture.at-least-32-bytes.ok";
|
|
71
|
+
|
|
72
|
+
/** The one client: first-party, allowed federation tokens, one post-logout URI registered. */
|
|
73
|
+
export const CLIENT_ID = "rp";
|
|
74
|
+
const CLIENT_SECRET = "rp-secret-value";
|
|
75
|
+
const BASIC = `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64")}`;
|
|
76
|
+
export const REGISTERED_POST_LOGOUT_REDIRECT_URI = "https://rp.example/signed-out";
|
|
77
|
+
|
|
78
|
+
const USERNAME = "alice";
|
|
79
|
+
const PASSWORD = "correct-horse-battery-staple";
|
|
80
|
+
const SUB = "u-alice";
|
|
81
|
+
|
|
82
|
+
/** The Google client the adapter is configured with. */
|
|
83
|
+
export const GOOGLE_CLIENT_ID = "google-client";
|
|
84
|
+
const GOOGLE_CLIENT_SECRET = "google-secret";
|
|
85
|
+
const GOOGLE_CALLBACK = `${ISSUER}/session/oauth/federation/google/callback`;
|
|
86
|
+
|
|
87
|
+
const ENV: Readonly<Record<string, string>> = {
|
|
88
|
+
// A session signed in through Google alone, no second factor.
|
|
89
|
+
MFA_MODE: "off",
|
|
90
|
+
KEY_STORE_LOCAL_ALGORITHM: "HS256",
|
|
91
|
+
KEY_STORE_LOCAL_SECRET: JWT_SECRET,
|
|
92
|
+
OAUTH_JWT_ISSUER: ISSUER,
|
|
93
|
+
SESSION_STORE_SECRET: "google-session.fixture-session.at-least-32-bytes.ok",
|
|
94
|
+
SESSION_STORE_SECURE: "false",
|
|
95
|
+
SESSION_STORE_NAME: "auth.session",
|
|
96
|
+
SESSION_STORE_STORAGE_TYPE: "memory",
|
|
97
|
+
ADAPTERS_USER_REPOSITORY: "yaml",
|
|
98
|
+
REDIS_CLIENTS_URL: "redis://redis.test:6379",
|
|
99
|
+
ADAPTERS_USER_SESSION_STORES: "memory",
|
|
100
|
+
ADAPTERS_RATE_LIMITER: "memory",
|
|
101
|
+
ADAPTERS_CODE_REPOSITORY: "memory",
|
|
102
|
+
ADAPTERS_ACCESS_TOKEN_DENYLIST: "memory",
|
|
103
|
+
ADAPTERS_REPLAY_SEEN_SET: "memory",
|
|
104
|
+
ADAPTERS_FEDERATION_TOKEN_STORE: "memory",
|
|
105
|
+
ADAPTERS_CONSENT_STORE: "none",
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* `"shipped"`: Google switched on the way an operator does, through its
|
|
110
|
+
* environment variables and the template's own configuration bridge — which
|
|
111
|
+
* has no setting for an end-session endpoint or a `fetch`. Otherwise the
|
|
112
|
+
* adapter's configuration beyond its credentials, composed with
|
|
113
|
+
* `googleFederationModule` directly.
|
|
114
|
+
*/
|
|
115
|
+
export type GoogleWiring =
|
|
116
|
+
| "shipped"
|
|
117
|
+
| Omit<GoogleProviderConfig, "clientId" | "clientSecret" | "callbackURL">;
|
|
118
|
+
|
|
119
|
+
function resolveConfig(google: GoogleWiring): Switches {
|
|
120
|
+
const env: Record<string, string> =
|
|
121
|
+
google === "shipped"
|
|
122
|
+
? {
|
|
123
|
+
...ENV,
|
|
124
|
+
CORE_FEDERATIONS_GOOGLE_ENABLED: "true",
|
|
125
|
+
CORE_FEDERATIONS_GOOGLE_CLIENT_ID: GOOGLE_CLIENT_ID,
|
|
126
|
+
CORE_FEDERATIONS_GOOGLE_CLIENT_SECRET: GOOGLE_CLIENT_SECRET,
|
|
127
|
+
CORE_FEDERATIONS_GOOGLE_CALLBACK_URL: GOOGLE_CALLBACK,
|
|
128
|
+
}
|
|
129
|
+
: { ...ENV };
|
|
130
|
+
const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
|
|
131
|
+
const layers = parseFile(envConfPath, { env })
|
|
132
|
+
.withFallback(parseFile(applicationConfPath, { env }))
|
|
133
|
+
.withFallback(parseFile(fileURLToPath(templateReference()), { env }))
|
|
134
|
+
.withFallback(libraryLayers(env));
|
|
135
|
+
return {
|
|
136
|
+
...sectionsCoreDoesNotDeclare(layers),
|
|
137
|
+
...rootSectionsOf(layers, env),
|
|
138
|
+
...validate(layers, AppConfigSchema),
|
|
139
|
+
// What the resolution captured of core's renamed variables, which the
|
|
140
|
+
// schema's parse drops.
|
|
141
|
+
"renamed-variables": capturedRenames(env),
|
|
142
|
+
} as Switches;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const testRepositoriesModule = defineModule({
|
|
146
|
+
name: "test:repositories",
|
|
147
|
+
provides: {
|
|
148
|
+
clientRepository: () =>
|
|
149
|
+
new InMemoryClientRepository(
|
|
150
|
+
new Map([
|
|
151
|
+
[
|
|
152
|
+
CLIENT_ID,
|
|
153
|
+
{
|
|
154
|
+
tokenEndpointAuthMethod: "client_secret_basic",
|
|
155
|
+
clientSecret: CLIENT_SECRET,
|
|
156
|
+
allowedRedirectUris: [],
|
|
157
|
+
allowedScopes: ["openid", "profile"],
|
|
158
|
+
allowedAudiences: [],
|
|
159
|
+
allowedGrantTypes: ["session"],
|
|
160
|
+
postLogoutRedirectUris: [REGISTERED_POST_LOGOUT_REDIRECT_URI],
|
|
161
|
+
backchannelLogoutSessionRequired: true,
|
|
162
|
+
frontchannelLogoutSessionRequired: true,
|
|
163
|
+
allowedAzpForFederationToken: true,
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
]),
|
|
167
|
+
),
|
|
168
|
+
userRepository: () =>
|
|
169
|
+
new InMemoryUserRepository(
|
|
170
|
+
new Map([[USERNAME, { id: SUB, password: PASSWORD, email: "alice@example.com" }]]),
|
|
171
|
+
),
|
|
172
|
+
} as never,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const testKeyStoreModule = defineModule({
|
|
176
|
+
name: "test:key-store",
|
|
177
|
+
requires: ["config"] as const,
|
|
178
|
+
provides: {
|
|
179
|
+
keyStore: async ({ config: c }) => {
|
|
180
|
+
const factory = createKeyStoreFactory();
|
|
181
|
+
registerBuiltinKeyStores(factory);
|
|
182
|
+
return factory.create({
|
|
183
|
+
type: "local",
|
|
184
|
+
...((c as { "key-store"?: { local?: object } })["key-store"]?.local ?? {}),
|
|
185
|
+
});
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const base64url = (value: unknown): string =>
|
|
191
|
+
Buffer.from(JSON.stringify(value)).toString("base64url");
|
|
192
|
+
|
|
193
|
+
/** A JWT signed with the deployment's own HS256 key: what its keystore verifies. */
|
|
194
|
+
function signed(typ: string, claims: Record<string, unknown>): string {
|
|
195
|
+
const header = base64url({ alg: "HS256", kid: "v0", typ });
|
|
196
|
+
const now = Math.floor(Date.now() / 1000);
|
|
197
|
+
const payload = base64url({ iss: ISSUER, sub: SUB, iat: now, exp: now + 3600, ...claims });
|
|
198
|
+
const signature = createHmac("sha256", JWT_SECRET)
|
|
199
|
+
.update(`${header}.${payload}`)
|
|
200
|
+
.digest("base64url");
|
|
201
|
+
return `${header}.${payload}.${signature}`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* A fresh id_token for the session, issued to the client: what an RP — or
|
|
206
|
+
* anyone holding an id_token of their own — presents as `id_token_hint`.
|
|
207
|
+
*/
|
|
208
|
+
export const idTokenHint = (sid: string): string => signed("JWT", { aud: CLIENT_ID, sid });
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* An access token for the session as the authorization-code grant issues it
|
|
212
|
+
* to the client: `azp`, `sid` and a refresh-token `family_id`. The `session`
|
|
213
|
+
* grant's token names no family, and the federation token route asks for one.
|
|
214
|
+
*/
|
|
215
|
+
export const accessTokenWithFamily = (sid: string): string =>
|
|
216
|
+
signed("at+jwt", { azp: CLIENT_ID, sid, family_id: "family-1", scope: "openid" });
|
|
217
|
+
|
|
218
|
+
const claimsOf = (jwt: string): Record<string, unknown> =>
|
|
219
|
+
JSON.parse(Buffer.from(jwt.split(".")[1] ?? "", "base64url").toString("utf8")) as Record<
|
|
220
|
+
string,
|
|
221
|
+
unknown
|
|
222
|
+
>;
|
|
223
|
+
|
|
224
|
+
export interface GoogleSession {
|
|
225
|
+
readonly app: express.Express;
|
|
226
|
+
readonly sid: string;
|
|
227
|
+
/** The access token the `session` grant minted for the session; it carries `azp`. */
|
|
228
|
+
readonly accessToken: string;
|
|
229
|
+
readonly userSessionStore: UserSessionStore;
|
|
230
|
+
readonly sessionFederationIndex: SessionFederationIndex;
|
|
231
|
+
readonly federationTokenStore: FederationTokenStore;
|
|
232
|
+
readonly dispose: () => Promise<void>;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Boots the deployment, signs alice in with her password, and links Google to
|
|
237
|
+
* her session as a federated sign-in would: the session's federation index
|
|
238
|
+
* names it and the token store holds `tokens`.
|
|
239
|
+
*/
|
|
240
|
+
export async function signInLinkedToGoogle(options: {
|
|
241
|
+
readonly google: GoogleWiring;
|
|
242
|
+
readonly tokens: FederationTokens;
|
|
243
|
+
}): Promise<GoogleSession> {
|
|
244
|
+
const { google, tokens } = options;
|
|
245
|
+
const config = resolveConfig(google);
|
|
246
|
+
const googleModules =
|
|
247
|
+
google === "shipped"
|
|
248
|
+
? []
|
|
249
|
+
: [
|
|
250
|
+
googleFederationModule,
|
|
251
|
+
defineModule({
|
|
252
|
+
name: "test:google-federation-config",
|
|
253
|
+
provides: {
|
|
254
|
+
googleFederationConfig: (): GoogleProviderConfig => ({
|
|
255
|
+
clientId: GOOGLE_CLIENT_ID,
|
|
256
|
+
clientSecret: GOOGLE_CLIENT_SECRET,
|
|
257
|
+
callbackURL: GOOGLE_CALLBACK,
|
|
258
|
+
...google,
|
|
259
|
+
}),
|
|
260
|
+
},
|
|
261
|
+
}),
|
|
262
|
+
];
|
|
263
|
+
const handle = await createApp({
|
|
264
|
+
modules: [
|
|
265
|
+
...buildModules(config, {
|
|
266
|
+
keyStoreModule: testKeyStoreModule,
|
|
267
|
+
repositoriesModule: testRepositoriesModule,
|
|
268
|
+
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
269
|
+
}),
|
|
270
|
+
...googleModules,
|
|
271
|
+
],
|
|
272
|
+
bootstrapComponents: { config, pathResolver: (s) => s },
|
|
273
|
+
});
|
|
274
|
+
try {
|
|
275
|
+
const components = handle.components as {
|
|
276
|
+
userSessionStore: UserSessionStore;
|
|
277
|
+
sessionFederationIndex: SessionFederationIndex;
|
|
278
|
+
federationTokenStore: FederationTokenStore;
|
|
279
|
+
};
|
|
280
|
+
const app = express();
|
|
281
|
+
app.use(handle.router);
|
|
282
|
+
|
|
283
|
+
const csrfRes = await request(app).get("/session/csrf");
|
|
284
|
+
expect(csrfRes.status).toBe(200);
|
|
285
|
+
const loginRes = await request(app)
|
|
286
|
+
.post("/session/login")
|
|
287
|
+
.set("Cookie", csrfRes.headers["set-cookie"] as unknown as string[])
|
|
288
|
+
.set(csrfRes.body.header_name as string, csrfRes.body.csrf_token as string)
|
|
289
|
+
.type("form")
|
|
290
|
+
.send({ username: USERNAME, password: PASSWORD });
|
|
291
|
+
expect(loginRes.status).toBe(200);
|
|
292
|
+
|
|
293
|
+
const tokenRes = await request(app)
|
|
294
|
+
.post("/oauth/token")
|
|
295
|
+
.set("Authorization", BASIC)
|
|
296
|
+
.set("Cookie", loginRes.headers["set-cookie"] as unknown as string[])
|
|
297
|
+
.type("form")
|
|
298
|
+
.send({ grant_type: "session" });
|
|
299
|
+
expect(tokenRes.status).toBe(200);
|
|
300
|
+
const accessToken = tokenRes.body.access_token as string;
|
|
301
|
+
const claims = claimsOf(accessToken);
|
|
302
|
+
expect(claims.azp).toBe(CLIENT_ID);
|
|
303
|
+
const sid = claims.sid as string;
|
|
304
|
+
expect(typeof sid).toBe("string");
|
|
305
|
+
|
|
306
|
+
const session = await components.userSessionStore.get(sid);
|
|
307
|
+
if (session === null) throw new Error("fixture: the sign-in left no session");
|
|
308
|
+
await components.sessionFederationIndex.addFederation(sid, "google", session.expiresAt);
|
|
309
|
+
await components.federationTokenStore.attach(sid, "google", tokens);
|
|
310
|
+
return { app, sid, accessToken, ...components, dispose: () => handle.dispose() };
|
|
311
|
+
} catch (error) {
|
|
312
|
+
await handle.dispose();
|
|
313
|
+
throw error;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
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 host's own routes (liveness, readiness, the metrics scrape), mounted as
|
|
19
|
+
* the process mounts them (`mountRoutes`), and what an error one of them lets
|
|
20
|
+
* through is answered with: core's terminal handler, the same one behind the
|
|
21
|
+
* composed router, so every route's error is answered the same way.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { Logger, ReadinessProbe } from "@o3co/auth-provider-core";
|
|
25
|
+
import express, { Router } from "express";
|
|
26
|
+
import request from "supertest";
|
|
27
|
+
import { describe, expect, it, vi } from "vitest";
|
|
28
|
+
import { createMetrics } from "#/metrics.mjs";
|
|
29
|
+
import { mountRoutes } from "#/routes.mjs";
|
|
30
|
+
|
|
31
|
+
const spyLogger = () => {
|
|
32
|
+
const logger = {
|
|
33
|
+
trace: vi.fn(),
|
|
34
|
+
debug: vi.fn(),
|
|
35
|
+
info: vi.fn(),
|
|
36
|
+
warn: vi.fn(),
|
|
37
|
+
error: vi.fn(),
|
|
38
|
+
fatal: vi.fn(),
|
|
39
|
+
child: () => logger,
|
|
40
|
+
};
|
|
41
|
+
return logger;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** A probe that breaks its own contract: reading its `name` throws. */
|
|
45
|
+
const probeWhoseNameThrows = (thrown: Error): ReadinessProbe =>
|
|
46
|
+
({
|
|
47
|
+
get name(): string {
|
|
48
|
+
throw thrown;
|
|
49
|
+
},
|
|
50
|
+
check: async () => undefined,
|
|
51
|
+
}) as ReadinessProbe;
|
|
52
|
+
|
|
53
|
+
const host = (probes: readonly ReadinessProbe[]) => {
|
|
54
|
+
const logger = spyLogger();
|
|
55
|
+
const app = express();
|
|
56
|
+
mountRoutes(app, {
|
|
57
|
+
router: Router(),
|
|
58
|
+
probes,
|
|
59
|
+
readinessTimeoutMs: 1_000,
|
|
60
|
+
metrics: createMetrics(),
|
|
61
|
+
logger: logger as unknown as Logger,
|
|
62
|
+
});
|
|
63
|
+
return { app, logger };
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** Every call on a level other than `error`. */
|
|
67
|
+
const otherLevels = (logger: ReturnType<typeof spyLogger>) =>
|
|
68
|
+
[logger.trace, logger.debug, logger.info, logger.warn, logger.fatal].flatMap(
|
|
69
|
+
(level) => level.mock.calls,
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
describe("the host's routes, mounted as the process mounts them", () => {
|
|
73
|
+
it("/_healthcheck answers 200, and nothing is logged", async () => {
|
|
74
|
+
const { app, logger } = host([]);
|
|
75
|
+
const res = await request(app).get("/_healthcheck");
|
|
76
|
+
expect(res.status).toBe(200);
|
|
77
|
+
expect(res.body).toEqual({ status: "ok" });
|
|
78
|
+
expect(logger.error).not.toHaveBeenCalled();
|
|
79
|
+
expect(otherLevels(logger)).toEqual([]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it.each(["/readyz", "/metrics"])(
|
|
83
|
+
"%s: an error it lets through is 500 server_error in the envelope, no-store, logged once with its projection",
|
|
84
|
+
async (path) => {
|
|
85
|
+
const { app, logger } = host([
|
|
86
|
+
probeWhoseNameThrows(new Error("probe registry at internal-host-marker is gone")),
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
const res = await request(app).get(path);
|
|
90
|
+
|
|
91
|
+
expect(res.status).toBe(500);
|
|
92
|
+
expect(res.headers["content-type"]).toMatch(/^application\/json/);
|
|
93
|
+
expect(res.headers["cache-control"]).toBe("no-store");
|
|
94
|
+
expect(res.body).toEqual({ error: "server_error", error_description: "unexpected_error" });
|
|
95
|
+
expect(res.text).not.toContain("internal-host-marker");
|
|
96
|
+
expect(logger.error).toHaveBeenCalledTimes(1);
|
|
97
|
+
const [fields, event, ...rest] = logger.error.mock.calls[0] as unknown[];
|
|
98
|
+
expect(event).toBe("unhandled_request_error");
|
|
99
|
+
expect(rest).toEqual([]);
|
|
100
|
+
expect(fields).toEqual({
|
|
101
|
+
endpoint: path,
|
|
102
|
+
err: expect.objectContaining({
|
|
103
|
+
name: "Error",
|
|
104
|
+
detail: "probe registry at internal-host-marker is gone",
|
|
105
|
+
}),
|
|
106
|
+
});
|
|
107
|
+
expect((fields as { err: unknown }).err).not.toBeInstanceOf(Error);
|
|
108
|
+
expect(otherLevels(logger)).toEqual([]);
|
|
109
|
+
},
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
it("takes a 4xx status an error did not mark as the client's for the server's: 500, logged", async () => {
|
|
113
|
+
// A store's or a library's error can carry an HTTP status of its own —
|
|
114
|
+
// the answer it got — which is not this server's answer to the client.
|
|
115
|
+
const { app, logger } = host([
|
|
116
|
+
probeWhoseNameThrows(
|
|
117
|
+
Object.assign(new Error("the probe registry refused this deployment"), { status: 403 }),
|
|
118
|
+
),
|
|
119
|
+
]);
|
|
120
|
+
|
|
121
|
+
const res = await request(app).get("/readyz");
|
|
122
|
+
|
|
123
|
+
expect(res.status).toBe(500);
|
|
124
|
+
expect(res.body).toEqual({ error: "server_error", error_description: "unexpected_error" });
|
|
125
|
+
expect(logger.error).toHaveBeenCalledTimes(1);
|
|
126
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
127
|
+
{
|
|
128
|
+
endpoint: "/readyz",
|
|
129
|
+
err: expect.objectContaining({ name: "Error", status: 403 }),
|
|
130
|
+
},
|
|
131
|
+
"unhandled_request_error",
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
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 libraries' layers beneath the template's own files, for a test that
|
|
19
|
+
* resolves its configuration by hand: the `reference.conf` of every package
|
|
20
|
+
* the template composes a module from, core's last, as `app.mts` layers them;
|
|
21
|
+
* and what that resolution captures of the renamed variables.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
25
|
+
import {
|
|
26
|
+
AppConfigSchema,
|
|
27
|
+
memoryRateLimiterModule,
|
|
28
|
+
moduleReferences,
|
|
29
|
+
} from "@o3co/auth-provider-core";
|
|
30
|
+
import { CORE_RELOCATIONS, renamedVariableCaptures } from "@o3co/auth-provider-core/testing";
|
|
31
|
+
import { federationGrantsModules } from "@o3co/auth-provider-federation-grants";
|
|
32
|
+
import {
|
|
33
|
+
oauthAuthorizationModule,
|
|
34
|
+
oauthModule,
|
|
35
|
+
oauthSessionModule,
|
|
36
|
+
} from "@o3co/auth-provider-oauth";
|
|
37
|
+
import {
|
|
38
|
+
redisCodeRepositoryModule,
|
|
39
|
+
redisFederationGrantStoreModule,
|
|
40
|
+
redisRateLimiterModule,
|
|
41
|
+
redisRefreshTokenFamilyStoreModule,
|
|
42
|
+
} from "@o3co/auth-provider-redis";
|
|
43
|
+
import { sessionModule, sessionStoreModule } from "@o3co/auth-provider-session";
|
|
44
|
+
import { type Config, empty, parseFile } from "@o3co/ts.hocon";
|
|
45
|
+
import { ADAPTERS_SECTION, readAdapters } from "../adapters.mjs";
|
|
46
|
+
import { MFA_SWITCH, readMfaSwitch } from "../mfaSwitch.mjs";
|
|
47
|
+
import {
|
|
48
|
+
httpModule,
|
|
49
|
+
inMemoryCodeRepositoryModule,
|
|
50
|
+
keyStoreModule,
|
|
51
|
+
loggingModule,
|
|
52
|
+
repositoriesModuleFor,
|
|
53
|
+
standaloneRedisClientsModule,
|
|
54
|
+
templateReference,
|
|
55
|
+
} from "../modules.mjs";
|
|
56
|
+
import type { Adapters, MfaSwitch } from "../sections.mjs";
|
|
57
|
+
|
|
58
|
+
/** The oauth package's modules, whose manifests read nothing of the configuration they are handed but the grant switches. */
|
|
59
|
+
const OAUTH_MODULES = [
|
|
60
|
+
oauthModule({ config: {} as never }),
|
|
61
|
+
oauthSessionModule({ config: {} as never }),
|
|
62
|
+
oauthAuthorizationModule({ config: {} as never }),
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
/** The modules the template composes that declare a renamed variable. */
|
|
66
|
+
const RENAMING_MODULES = [
|
|
67
|
+
memoryRateLimiterModule,
|
|
68
|
+
redisRateLimiterModule,
|
|
69
|
+
redisRefreshTokenFamilyStoreModule,
|
|
70
|
+
redisFederationGrantStoreModule,
|
|
71
|
+
...OAUTH_MODULES,
|
|
72
|
+
sessionModule,
|
|
73
|
+
sessionStoreModule,
|
|
74
|
+
loggingModule,
|
|
75
|
+
httpModule,
|
|
76
|
+
keyStoreModule,
|
|
77
|
+
standaloneRedisClientsModule,
|
|
78
|
+
repositoriesModuleFor({ client: "yaml", user: "yaml" }),
|
|
79
|
+
inMemoryCodeRepositoryModule,
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
/** Every package reference the template's modules declare, core's last. */
|
|
83
|
+
const LIBRARY_REFERENCES: readonly URL[] = moduleReferences([
|
|
84
|
+
redisRateLimiterModule,
|
|
85
|
+
...federationGrantsModules,
|
|
86
|
+
...OAUTH_MODULES,
|
|
87
|
+
sessionModule,
|
|
88
|
+
sessionStoreModule,
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
/** The libraries' references resolved under `env`, in the order `app.mts` layers them. */
|
|
92
|
+
export function libraryLayers(env: Readonly<Record<string, string>>): Config {
|
|
93
|
+
return LIBRARY_REFERENCES.reduce<Config>(
|
|
94
|
+
(layered, reference) =>
|
|
95
|
+
layered.withFallback(parseFile(fileURLToPath(reference), { env: { ...env } })),
|
|
96
|
+
empty(),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* What a test that parses `layers` with `AppConfigSchema` lays beside that
|
|
102
|
+
* parse: every top-level section core's schema does not declare — the
|
|
103
|
+
* template's own modules' among them — as written, which the parse drops;
|
|
104
|
+
* not the composition root's `adapters`, which boot is never handed
|
|
105
|
+
* (`adaptersOf`).
|
|
106
|
+
*/
|
|
107
|
+
export function sectionsCoreDoesNotDeclare(layers: Config): Record<string, unknown> {
|
|
108
|
+
const raw = layers.toObject() as Record<string, unknown>;
|
|
109
|
+
return Object.fromEntries(
|
|
110
|
+
Object.entries(raw).filter(
|
|
111
|
+
([key]) =>
|
|
112
|
+
!Object.hasOwn(AppConfigSchema.shape, key) &&
|
|
113
|
+
key !== "renamed-variables" &&
|
|
114
|
+
key !== ADAPTERS_SECTION &&
|
|
115
|
+
key !== MFA_SWITCH,
|
|
116
|
+
),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The composition root's `adapters` a resolution holds — `layers`, the
|
|
122
|
+
* template's `config/reference.conf` among them, under `env` — as phase one
|
|
123
|
+
* reads them.
|
|
124
|
+
*/
|
|
125
|
+
export function adaptersOf(layers: Config, env: Readonly<Record<string, string>>): Adapters {
|
|
126
|
+
return readAdapters(layers.toObject() as Record<string, unknown>, env);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* What phase one reads from a resolution beside core's switches —
|
|
131
|
+
* `layers`, the template's `config/reference.conf` among them, under `env` —
|
|
132
|
+
* as `readSwitches` reads it: the composition root's `adapters` and
|
|
133
|
+
* `mfaMode`, and the Store transport settings.
|
|
134
|
+
*/
|
|
135
|
+
export function rootSectionsOf(
|
|
136
|
+
layers: Config,
|
|
137
|
+
env: Readonly<Record<string, string>>,
|
|
138
|
+
): { readonly adapters: Adapters; readonly mfaMode: MfaSwitch; readonly storeTransport: unknown } {
|
|
139
|
+
const raw = layers.toObject() as Record<string, unknown>;
|
|
140
|
+
const repositories = raw.repositories as { user?: { http?: unknown } } | undefined;
|
|
141
|
+
return {
|
|
142
|
+
adapters: readAdapters(raw, env),
|
|
143
|
+
mfaMode: readMfaSwitch(raw, env),
|
|
144
|
+
storeTransport: repositories?.user?.http,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The adapters the template ships, as its `config/reference.conf` sets them with no environment. */
|
|
149
|
+
export function shippedAdapters(): Adapters {
|
|
150
|
+
return adaptersOf(parseFile(fileURLToPath(templateReference()), { env: {} }), {});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The shipped adapters with every store the template can hold in process there: no Redis connection needed. */
|
|
154
|
+
export function inProcessAdapters(): Adapters {
|
|
155
|
+
return {
|
|
156
|
+
...shippedAdapters(),
|
|
157
|
+
rateLimiter: "memory",
|
|
158
|
+
userSessionStores: "memory",
|
|
159
|
+
accessTokenDenylist: "memory",
|
|
160
|
+
replaySeenSet: "memory",
|
|
161
|
+
federationTokenStore: "memory",
|
|
162
|
+
federationGrantStore: "memory",
|
|
163
|
+
federationGrantIntentStore: "memory",
|
|
164
|
+
mfaFactorStore: "memory",
|
|
165
|
+
mfaTransactionStore: "memory",
|
|
166
|
+
codeRepository: "memory",
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The `renamed-variables` section a resolution under `env` holds: what every
|
|
172
|
+
* module above captures, and the Redis code repository's, which the
|
|
173
|
+
* in-process one's excludes from one composition (both declare
|
|
174
|
+
* CLIENT_CODE_DEFAULT_EXPIRES_IN renamed, each to its own new name).
|
|
175
|
+
*/
|
|
176
|
+
export function capturedRenames(
|
|
177
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
178
|
+
): Record<string, string | null> {
|
|
179
|
+
return {
|
|
180
|
+
...renamedVariableCaptures({ modules: [redisCodeRepositoryModule], env }),
|
|
181
|
+
...renamedVariableCaptures({ modules: RENAMING_MODULES, core: CORE_RELOCATIONS, env }),
|
|
182
|
+
};
|
|
183
|
+
}
|