@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,318 +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
|
-
/**
|
|
18
|
-
* A browser session with Google linked, on the standalone composed as a
|
|
19
|
-
* deployment does: `oauthEndpointsModule`, the session module and the real Google
|
|
20
|
-
* adapter — the Google federation type `buildModules` lists, handed the
|
|
21
|
-
* shipped `core.federations.google` entry — booted through `createApp` under
|
|
22
|
-
* the shipped configuration.
|
|
23
|
-
*
|
|
24
|
-
* The two logout routes and `POST /oauth/federation/:name/token` live in
|
|
25
|
-
* `@o3co/auth-provider-oauth`, which depends on no adapter; only here can a
|
|
26
|
-
* test see what they hand the real adapter and what they make of what its
|
|
27
|
-
* library raises.
|
|
28
|
-
*
|
|
29
|
-
* Unlike `all-modules-composition.fixture.mts`, the deployment signs with an
|
|
30
|
-
* HS256 secret this file holds, so tests can present an `id_token_hint` and
|
|
31
|
-
* access token of their own; nothing but Google is federated.
|
|
32
|
-
*/
|
|
33
|
-
|
|
34
|
-
import { createHmac } from "node:crypto";
|
|
35
|
-
import { fileURLToPath } from "node:url";
|
|
36
|
-
import {
|
|
37
|
-
createApp,
|
|
38
|
-
createKeyStoreFactory,
|
|
39
|
-
defineModule,
|
|
40
|
-
type FederationTokenStore,
|
|
41
|
-
type FederationTokens,
|
|
42
|
-
InMemoryClientRepository,
|
|
43
|
-
InMemoryUserRepository,
|
|
44
|
-
memoryRefreshTokenFamilyStoreModule,
|
|
45
|
-
registerBuiltinKeyStores,
|
|
46
|
-
type SessionLifecycle,
|
|
47
|
-
type UserSessionStore,
|
|
48
|
-
} from "@o3co/auth-provider-core";
|
|
49
|
-
import { googleFederationTypeModule } from "@o3co/auth-provider-federation-google";
|
|
50
|
-
import { parseFile } from "@o3co/ts.hocon";
|
|
51
|
-
import express from "express";
|
|
52
|
-
import request from "supertest";
|
|
53
|
-
import { expect } from "vitest";
|
|
54
|
-
import { buildModules } from "#/buildModules.mjs";
|
|
55
|
-
import { resolveConfigPaths } from "#/configPath.mjs";
|
|
56
|
-
import { templateReference } from "../modules.mjs";
|
|
57
|
-
import { keyStoreSectionSchema } from "../sections.mjs";
|
|
58
|
-
import {
|
|
59
|
-
type BothPhases,
|
|
60
|
-
bothPhasesOf,
|
|
61
|
-
capturedRenames,
|
|
62
|
-
libraryLayers,
|
|
63
|
-
} from "./library-references.fixture.mjs";
|
|
64
|
-
|
|
65
|
-
const configDir = fileURLToPath(new URL("../../config", import.meta.url));
|
|
66
|
-
|
|
67
|
-
export const ISSUER = "https://auth.test";
|
|
68
|
-
const JWT_SECRET = "google-session.fixture.at-least-32-bytes.ok";
|
|
69
|
-
|
|
70
|
-
/** The one client: first-party, allowed federation tokens, one post-logout URI registered. */
|
|
71
|
-
export const CLIENT_ID = "rp";
|
|
72
|
-
const CLIENT_SECRET = "rp-secret-value";
|
|
73
|
-
const BASIC = `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64")}`;
|
|
74
|
-
export const REGISTERED_POST_LOGOUT_REDIRECT_URI = "https://rp.example/signed-out";
|
|
75
|
-
|
|
76
|
-
const USERNAME = "alice";
|
|
77
|
-
const PASSWORD = "correct-horse-battery-staple";
|
|
78
|
-
const SUB = "u-alice";
|
|
79
|
-
|
|
80
|
-
/** The Google client the adapter is configured with. */
|
|
81
|
-
export const GOOGLE_CLIENT_ID = "google-client";
|
|
82
|
-
const GOOGLE_CLIENT_SECRET = "google-secret";
|
|
83
|
-
const GOOGLE_CALLBACK = `${ISSUER}/session/oauth/federation/google/callback`;
|
|
84
|
-
|
|
85
|
-
const ENV: Readonly<Record<string, string>> = {
|
|
86
|
-
// A session signed in through Google alone, no second factor.
|
|
87
|
-
MFA_MODE: "off",
|
|
88
|
-
KEY_STORE_LOCAL_ALGORITHM: "HS256",
|
|
89
|
-
KEY_STORE_LOCAL_SECRET: JWT_SECRET,
|
|
90
|
-
OAUTH_JWT_ISSUER: ISSUER,
|
|
91
|
-
SESSION_STORE_SECRET: "google-session.fixture-session.at-least-32-bytes.ok",
|
|
92
|
-
SESSION_STORE_SECURE: "false",
|
|
93
|
-
SESSION_STORE_NAME: "auth.session",
|
|
94
|
-
SESSION_STORE_STORAGE_TYPE: "memory",
|
|
95
|
-
ADAPTERS_USER_REPOSITORY: "yaml",
|
|
96
|
-
REDIS_CLIENTS_URL: "redis://redis.test:6379",
|
|
97
|
-
ADAPTERS_USER_SESSION_STORES: "memory",
|
|
98
|
-
ADAPTERS_RATE_LIMITER: "memory",
|
|
99
|
-
ADAPTERS_CODE_REPOSITORY: "memory",
|
|
100
|
-
ADAPTERS_ACCESS_TOKEN_DENYLIST: "memory",
|
|
101
|
-
ADAPTERS_REPLAY_SEEN_SET: "memory",
|
|
102
|
-
ADAPTERS_FEDERATION_TOKEN_STORE: "memory",
|
|
103
|
-
ADAPTERS_CONSENT_STORE: "none",
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Google switched on the way an operator does, through its environment
|
|
108
|
-
* variables, and handled by the Google federation type the template lists.
|
|
109
|
-
* `"shipped"` is that alone. Otherwise, beside it: `endSessionEndpoint`, a key
|
|
110
|
-
* of the entry with no environment form, written into the entry as an
|
|
111
|
-
* operator's own layer would; and `fetch`, the one the type module is built
|
|
112
|
-
* with in place of the global one.
|
|
113
|
-
*/
|
|
114
|
-
export type GoogleWiring =
|
|
115
|
-
| "shipped"
|
|
116
|
-
| { readonly endSessionEndpoint?: string; readonly fetch?: typeof fetch };
|
|
117
|
-
|
|
118
|
-
const GOOGLE_ENV: Readonly<Record<string, string>> = {
|
|
119
|
-
CORE_FEDERATIONS_GOOGLE_ENABLED: "true",
|
|
120
|
-
CORE_FEDERATIONS_GOOGLE_CLIENT_ID: GOOGLE_CLIENT_ID,
|
|
121
|
-
CORE_FEDERATIONS_GOOGLE_CLIENT_SECRET: GOOGLE_CLIENT_SECRET,
|
|
122
|
-
CORE_FEDERATIONS_GOOGLE_CALLBACK_URL: GOOGLE_CALLBACK,
|
|
123
|
-
};
|
|
124
|
-
|
|
125
|
-
function resolveConfig(google: GoogleWiring): BothPhases {
|
|
126
|
-
const env: Record<string, string> = { ...ENV, ...GOOGLE_ENV };
|
|
127
|
-
const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
|
|
128
|
-
const layers = parseFile(envConfPath, { env })
|
|
129
|
-
.withFallback(parseFile(applicationConfPath, { env }))
|
|
130
|
-
.withFallback(parseFile(fileURLToPath(templateReference()), { env }))
|
|
131
|
-
.withFallback(libraryLayers(env));
|
|
132
|
-
const config = bothPhasesOf(layers, env);
|
|
133
|
-
const endSessionEndpoint = google === "shipped" ? undefined : google.endSessionEndpoint;
|
|
134
|
-
const federations = config.core?.federations;
|
|
135
|
-
return {
|
|
136
|
-
...config,
|
|
137
|
-
...(endSessionEndpoint === undefined
|
|
138
|
-
? {}
|
|
139
|
-
: {
|
|
140
|
-
core: {
|
|
141
|
-
...config.core,
|
|
142
|
-
federations: {
|
|
143
|
-
...federations,
|
|
144
|
-
google: { ...federations?.google, endSessionEndpoint },
|
|
145
|
-
},
|
|
146
|
-
},
|
|
147
|
-
}),
|
|
148
|
-
// What the resolution captured of the renamed variables.
|
|
149
|
-
"renamed-variables": capturedRenames(env),
|
|
150
|
-
} as BothPhases;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const testRepositoriesModule = defineModule({
|
|
154
|
-
name: "test:repositories",
|
|
155
|
-
provides: {
|
|
156
|
-
clientRepository: () =>
|
|
157
|
-
new InMemoryClientRepository(
|
|
158
|
-
new Map([
|
|
159
|
-
[
|
|
160
|
-
CLIENT_ID,
|
|
161
|
-
{
|
|
162
|
-
tokenEndpointAuthMethod: "client_secret_basic",
|
|
163
|
-
clientSecret: CLIENT_SECRET,
|
|
164
|
-
allowedRedirectUris: [],
|
|
165
|
-
allowedScopes: ["openid", "profile"],
|
|
166
|
-
allowedAudiences: [],
|
|
167
|
-
allowedGrantTypes: ["session"],
|
|
168
|
-
postLogoutRedirectUris: [REGISTERED_POST_LOGOUT_REDIRECT_URI],
|
|
169
|
-
backchannelLogoutSessionRequired: true,
|
|
170
|
-
frontchannelLogoutSessionRequired: true,
|
|
171
|
-
allowedAzpForFederationToken: true,
|
|
172
|
-
},
|
|
173
|
-
],
|
|
174
|
-
]),
|
|
175
|
-
),
|
|
176
|
-
userRepository: () =>
|
|
177
|
-
new InMemoryUserRepository(
|
|
178
|
-
new Map([[USERNAME, { id: SUB, password: PASSWORD, email: "alice@example.com" }]]),
|
|
179
|
-
),
|
|
180
|
-
} as never,
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
// The template's `key-store` section, read by its own schema, without the
|
|
184
|
-
// section's relocation bridge.
|
|
185
|
-
const testKeyStoreModule = defineModule({
|
|
186
|
-
name: "key-store",
|
|
187
|
-
section: { schema: keyStoreSectionSchema },
|
|
188
|
-
provides: {
|
|
189
|
-
keyStore: async ({ section }) => {
|
|
190
|
-
const factory = createKeyStoreFactory();
|
|
191
|
-
registerBuiltinKeyStores(factory);
|
|
192
|
-
return factory.create({
|
|
193
|
-
type: "local",
|
|
194
|
-
...(section.local ?? {}),
|
|
195
|
-
});
|
|
196
|
-
},
|
|
197
|
-
},
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
const base64url = (value: unknown): string =>
|
|
201
|
-
Buffer.from(JSON.stringify(value)).toString("base64url");
|
|
202
|
-
|
|
203
|
-
/** A JWT signed with the deployment's own HS256 key: what its keystore verifies. */
|
|
204
|
-
function signed(typ: string, claims: Record<string, unknown>): string {
|
|
205
|
-
const header = base64url({ alg: "HS256", kid: "v0", typ });
|
|
206
|
-
const now = Math.floor(Date.now() / 1000);
|
|
207
|
-
const payload = base64url({ iss: ISSUER, sub: SUB, iat: now, exp: now + 3600, ...claims });
|
|
208
|
-
const signature = createHmac("sha256", JWT_SECRET)
|
|
209
|
-
.update(`${header}.${payload}`)
|
|
210
|
-
.digest("base64url");
|
|
211
|
-
return `${header}.${payload}.${signature}`;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
* A fresh id_token for the session, issued to the client: what an RP — or
|
|
216
|
-
* anyone holding an id_token of their own — presents as `id_token_hint`.
|
|
217
|
-
*/
|
|
218
|
-
export const idTokenHint = (sid: string): string => signed("JWT", { aud: CLIENT_ID, sid });
|
|
219
|
-
|
|
220
|
-
/**
|
|
221
|
-
* An access token for the session as the authorization-code grant issues it
|
|
222
|
-
* to the client, for the client itself: `aud` and `azp`, `sid` and a
|
|
223
|
-
* refresh-token `family_id`. The `session` grant's token names no family, and
|
|
224
|
-
* the federation token route asks for one.
|
|
225
|
-
*/
|
|
226
|
-
export const accessTokenWithFamily = (sid: string): string =>
|
|
227
|
-
signed("at+jwt", {
|
|
228
|
-
aud: CLIENT_ID,
|
|
229
|
-
azp: CLIENT_ID,
|
|
230
|
-
sid,
|
|
231
|
-
family_id: "family-1",
|
|
232
|
-
scope: "openid",
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
const claimsOf = (jwt: string): Record<string, unknown> =>
|
|
236
|
-
JSON.parse(Buffer.from(jwt.split(".")[1] ?? "", "base64url").toString("utf8")) as Record<
|
|
237
|
-
string,
|
|
238
|
-
unknown
|
|
239
|
-
>;
|
|
240
|
-
|
|
241
|
-
export interface GoogleSession {
|
|
242
|
-
readonly app: express.Express;
|
|
243
|
-
readonly sid: string;
|
|
244
|
-
/** The access token the `session` grant minted for the session; it carries `azp`. */
|
|
245
|
-
readonly accessToken: string;
|
|
246
|
-
readonly userSessionStore: UserSessionStore;
|
|
247
|
-
readonly sessionLifecycle: SessionLifecycle;
|
|
248
|
-
readonly federationTokenStore: FederationTokenStore;
|
|
249
|
-
readonly dispose: () => Promise<void>;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
/**
|
|
253
|
-
* Boots the deployment, signs alice in with her password, and links Google to
|
|
254
|
-
* her session as a federated sign-in would: the token store holds `tokens`,
|
|
255
|
-
* and Google joins the session through its lifecycle.
|
|
256
|
-
*/
|
|
257
|
-
export async function signInLinkedToGoogle(options: {
|
|
258
|
-
readonly google: GoogleWiring;
|
|
259
|
-
readonly tokens: FederationTokens;
|
|
260
|
-
}): Promise<GoogleSession> {
|
|
261
|
-
const { google, tokens } = options;
|
|
262
|
-
const config = resolveConfig(google);
|
|
263
|
-
const upstreamFetch = google === "shipped" ? undefined : google.fetch;
|
|
264
|
-
const googleType = googleFederationTypeModule().name;
|
|
265
|
-
const handle = await createApp({
|
|
266
|
-
modules: buildModules(config, {
|
|
267
|
-
keyStoreModule: testKeyStoreModule,
|
|
268
|
-
repositoriesModule: testRepositoriesModule,
|
|
269
|
-
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
270
|
-
}).map((module) =>
|
|
271
|
-
module.name === googleType && upstreamFetch !== undefined
|
|
272
|
-
? googleFederationTypeModule({ fetch: upstreamFetch })
|
|
273
|
-
: module,
|
|
274
|
-
),
|
|
275
|
-
bootstrapComponents: { config, pathResolver: (s) => s },
|
|
276
|
-
});
|
|
277
|
-
try {
|
|
278
|
-
const components = handle.components as {
|
|
279
|
-
userSessionStore: UserSessionStore;
|
|
280
|
-
sessionLifecycle: SessionLifecycle;
|
|
281
|
-
federationTokenStore: FederationTokenStore;
|
|
282
|
-
};
|
|
283
|
-
const app = express();
|
|
284
|
-
app.use(handle.router);
|
|
285
|
-
|
|
286
|
-
const csrfRes = await request(app).get("/session/csrf");
|
|
287
|
-
expect(csrfRes.status).toBe(200);
|
|
288
|
-
const loginRes = await request(app)
|
|
289
|
-
.post("/session/login")
|
|
290
|
-
.set("Cookie", csrfRes.headers["set-cookie"] as unknown as string[])
|
|
291
|
-
.set(csrfRes.body.header_name as string, csrfRes.body.csrf_token as string)
|
|
292
|
-
.type("form")
|
|
293
|
-
.send({ username: USERNAME, password: PASSWORD });
|
|
294
|
-
expect(loginRes.status).toBe(200);
|
|
295
|
-
|
|
296
|
-
const tokenRes = await request(app)
|
|
297
|
-
.post("/oauth/token")
|
|
298
|
-
.set("Authorization", BASIC)
|
|
299
|
-
.set("Cookie", loginRes.headers["set-cookie"] as unknown as string[])
|
|
300
|
-
.type("form")
|
|
301
|
-
.send({ grant_type: "session" });
|
|
302
|
-
expect(tokenRes.status).toBe(200);
|
|
303
|
-
const accessToken = tokenRes.body.access_token as string;
|
|
304
|
-
const claims = claimsOf(accessToken);
|
|
305
|
-
expect(claims.azp).toBe(CLIENT_ID);
|
|
306
|
-
const sid = claims.sid as string;
|
|
307
|
-
expect(typeof sid).toBe("string");
|
|
308
|
-
|
|
309
|
-
await components.federationTokenStore.attach(sid, "google", tokens);
|
|
310
|
-
expect(await components.sessionLifecycle.join(sid, { federation: "google" })).toEqual({
|
|
311
|
-
outcome: "joined",
|
|
312
|
-
});
|
|
313
|
-
return { app, sid, accessToken, ...components, dispose: () => handle.dispose() };
|
|
314
|
-
} catch (error) {
|
|
315
|
-
await handle.dispose();
|
|
316
|
-
throw error;
|
|
317
|
-
}
|
|
318
|
-
}
|
|
@@ -1,134 +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
|
-
/**
|
|
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
|
-
});
|
|
@@ -1,29 +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 { existsSync, readFileSync } from "node:fs";
|
|
18
|
-
import { fileURLToPath } from "node:url";
|
|
19
|
-
import { describe, expect, it } from "vitest";
|
|
20
|
-
|
|
21
|
-
describe("library reference.conf subpath resolution", () => {
|
|
22
|
-
it("resolves @o3co/auth-provider-core/reference.conf to a readable file", () => {
|
|
23
|
-
const url = import.meta.resolve("@o3co/auth-provider-core/reference.conf");
|
|
24
|
-
const path = fileURLToPath(url);
|
|
25
|
-
expect(existsSync(path)).toBe(true);
|
|
26
|
-
const content = readFileSync(path, "utf-8");
|
|
27
|
-
expect(content).toMatch(/^core\s*\{/m); // sanity: HOCON content, core's own section
|
|
28
|
-
});
|
|
29
|
-
});
|
|
@@ -1,188 +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
|
-
/**
|
|
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
|
-
* the resolution handed to both phases at once; and what that resolution
|
|
22
|
-
* captures of the renamed variables.
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
import { fileURLToPath } from "node:url";
|
|
26
|
-
import {
|
|
27
|
-
type AppConfig,
|
|
28
|
-
memoryRateLimiterModule,
|
|
29
|
-
moduleReferences,
|
|
30
|
-
} from "@o3co/auth-provider-core";
|
|
31
|
-
import { CORE_RELOCATIONS, renamedVariableCaptures } from "@o3co/auth-provider-core/testing";
|
|
32
|
-
import { federationGrantsModules } from "@o3co/auth-provider-federation-grants";
|
|
33
|
-
import {
|
|
34
|
-
oauthAuthorizationGrantsModule,
|
|
35
|
-
oauthEndpointsModule,
|
|
36
|
-
oauthSessionGrantModule,
|
|
37
|
-
} from "@o3co/auth-provider-oauth";
|
|
38
|
-
import {
|
|
39
|
-
redisCodeRepositoryModule,
|
|
40
|
-
redisFederationGrantStoreModule,
|
|
41
|
-
redisRateLimiterModule,
|
|
42
|
-
redisRefreshTokenFamilyStoreModule,
|
|
43
|
-
} from "@o3co/auth-provider-redis";
|
|
44
|
-
import { sessionModule, sessionStoreModule } from "@o3co/auth-provider-session";
|
|
45
|
-
import { type Config, empty, parseFile } from "@o3co/ts.hocon";
|
|
46
|
-
import { readAdapters } from "../adapters.mjs";
|
|
47
|
-
import { readSwitches, type Switches } from "../configPath.mjs";
|
|
48
|
-
import {
|
|
49
|
-
httpModule,
|
|
50
|
-
inMemoryCodeRepositoryModule,
|
|
51
|
-
keyStoreModule,
|
|
52
|
-
loggingModule,
|
|
53
|
-
repositoriesModuleFor,
|
|
54
|
-
standaloneRedisClientsModule,
|
|
55
|
-
templateReference,
|
|
56
|
-
} from "../modules.mjs";
|
|
57
|
-
import { type Adapters, isPlainSection } from "../sections.mjs";
|
|
58
|
-
|
|
59
|
-
/** The oauth package's modules: none reads the configuration it is handed. */
|
|
60
|
-
const OAUTH_MODULES = [
|
|
61
|
-
oauthEndpointsModule,
|
|
62
|
-
oauthSessionGrantModule,
|
|
63
|
-
oauthAuthorizationGrantsModule,
|
|
64
|
-
];
|
|
65
|
-
|
|
66
|
-
/** The modules the template composes that declare a renamed variable. */
|
|
67
|
-
const RENAMING_MODULES = [
|
|
68
|
-
memoryRateLimiterModule,
|
|
69
|
-
redisRateLimiterModule,
|
|
70
|
-
redisRefreshTokenFamilyStoreModule,
|
|
71
|
-
redisFederationGrantStoreModule,
|
|
72
|
-
...OAUTH_MODULES,
|
|
73
|
-
sessionModule,
|
|
74
|
-
sessionStoreModule,
|
|
75
|
-
loggingModule,
|
|
76
|
-
httpModule,
|
|
77
|
-
keyStoreModule,
|
|
78
|
-
standaloneRedisClientsModule,
|
|
79
|
-
repositoriesModuleFor({ client: "yaml", user: "yaml" }),
|
|
80
|
-
inMemoryCodeRepositoryModule,
|
|
81
|
-
];
|
|
82
|
-
|
|
83
|
-
/** Every package reference the template's modules declare, core's last. */
|
|
84
|
-
const LIBRARY_REFERENCES: readonly URL[] = moduleReferences([
|
|
85
|
-
redisRateLimiterModule,
|
|
86
|
-
...federationGrantsModules,
|
|
87
|
-
...OAUTH_MODULES,
|
|
88
|
-
sessionModule,
|
|
89
|
-
sessionStoreModule,
|
|
90
|
-
]);
|
|
91
|
-
|
|
92
|
-
/** The libraries' references resolved under `env`, in the order `app.mts` layers them. */
|
|
93
|
-
export function libraryLayers(env: Readonly<Record<string, string>>): Config {
|
|
94
|
-
return LIBRARY_REFERENCES.reduce<Config>(
|
|
95
|
-
(layered, reference) =>
|
|
96
|
-
layered.withFallback(parseFile(fileURLToPath(reference), { env: { ...env } })),
|
|
97
|
-
empty(),
|
|
98
|
-
);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/** Phase one's switches laid over the configuration boot parses. */
|
|
102
|
-
export type BothPhases = Switches & AppConfig;
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* `switches` laid over `resolved`, the configuration boot parses, as a test
|
|
106
|
-
* that hands one object to both phases does: the composition root's own keys
|
|
107
|
-
* beside the sections, and `federation-grants.enabled` written as phase one
|
|
108
|
-
* decided it into the section as resolved. Boot is so handed phase one's
|
|
109
|
-
* boolean, not the value as written, unlike `app.mts`; boot's refusal of a
|
|
110
|
-
* written value phase one installs the modules for is pinned in
|
|
111
|
-
* `two-phase-config.test.mts`.
|
|
112
|
-
*/
|
|
113
|
-
export function withSwitches(
|
|
114
|
-
resolved: Readonly<Record<string, unknown>>,
|
|
115
|
-
switches: Switches,
|
|
116
|
-
): BothPhases {
|
|
117
|
-
const grants = resolved["federation-grants"];
|
|
118
|
-
return {
|
|
119
|
-
...resolved,
|
|
120
|
-
...switches,
|
|
121
|
-
"federation-grants": {
|
|
122
|
-
...(isPlainSection(grants) ? grants : {}),
|
|
123
|
-
...switches["federation-grants"],
|
|
124
|
-
},
|
|
125
|
-
} as unknown as BothPhases;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* A resolution — `layers`, the template's `config/reference.conf` among them,
|
|
130
|
-
* under `env` — as a test that resolves by hand hands it to both phases at
|
|
131
|
-
* once: the configuration as resolved and unparsed, which boot parses as
|
|
132
|
-
* `app.mts` hands it on, with what phase one reads of it (`readSwitches`)
|
|
133
|
-
* laid over it — the composition root's `adapters` and `mfaMode`, the Store
|
|
134
|
-
* transport settings, and `federation-grants.enabled` as phase one decides
|
|
135
|
-
* it.
|
|
136
|
-
*/
|
|
137
|
-
export function bothPhasesOf(layers: Config, env: Readonly<Record<string, string>>): BothPhases {
|
|
138
|
-
return withSwitches(
|
|
139
|
-
layers.toObject() as Record<string, unknown>,
|
|
140
|
-
readSwitches({ config: layers, env }),
|
|
141
|
-
);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* The composition root's `adapters` a resolution holds — `layers`, the
|
|
146
|
-
* template's `config/reference.conf` among them, under `env` — as phase one
|
|
147
|
-
* reads them.
|
|
148
|
-
*/
|
|
149
|
-
export function adaptersOf(layers: Config, env: Readonly<Record<string, string>>): Adapters {
|
|
150
|
-
return readAdapters(layers.toObject() as Record<string, unknown>, env);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/** The adapters the template ships, as its `config/reference.conf` sets them with no environment. */
|
|
154
|
-
export function shippedAdapters(): Adapters {
|
|
155
|
-
return adaptersOf(parseFile(fileURLToPath(templateReference()), { env: {} }), {});
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/** The shipped adapters with every store the template can hold in process there: no Redis connection needed. */
|
|
159
|
-
export function inProcessAdapters(): Adapters {
|
|
160
|
-
return {
|
|
161
|
-
...shippedAdapters(),
|
|
162
|
-
rateLimiter: "memory",
|
|
163
|
-
userSessionStores: "memory",
|
|
164
|
-
accessTokenDenylist: "memory",
|
|
165
|
-
replaySeenSet: "memory",
|
|
166
|
-
federationTokenStore: "memory",
|
|
167
|
-
federationGrantStore: "memory",
|
|
168
|
-
federationGrantIntentStore: "memory",
|
|
169
|
-
mfaFactorStore: "memory",
|
|
170
|
-
mfaTransactionStore: "memory",
|
|
171
|
-
codeRepository: "memory",
|
|
172
|
-
};
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
* The `renamed-variables` section a resolution under `env` holds: what every
|
|
177
|
-
* module above captures, and the Redis code repository's, which the
|
|
178
|
-
* in-process one's excludes from one composition (both declare
|
|
179
|
-
* CLIENT_CODE_DEFAULT_EXPIRES_IN renamed, each to its own new name).
|
|
180
|
-
*/
|
|
181
|
-
export function capturedRenames(
|
|
182
|
-
env: Readonly<Record<string, string | undefined>>,
|
|
183
|
-
): Record<string, string | null> {
|
|
184
|
-
return {
|
|
185
|
-
...renamedVariableCaptures({ modules: [redisCodeRepositoryModule], env }),
|
|
186
|
-
...renamedVariableCaptures({ modules: RENAMING_MODULES, core: CORE_RELOCATIONS, env }),
|
|
187
|
-
};
|
|
188
|
-
}
|