@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
|
@@ -15,22 +15,20 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* the
|
|
26
|
-
*
|
|
27
|
-
* records gone afterwards, the grant and its credential exactly as they were,
|
|
28
|
-
* and `/token` still disclosing.
|
|
18
|
+
* A federation grant outlives the session it was agreed through, at both HTTP
|
|
19
|
+
* logout endpoints (ADR 2026-09-17-federation-grants-offline-delegation).
|
|
20
|
+
* `/oauth/logout` runs `cascadeLogout()`; `/session/logout` has its own record
|
|
21
|
+
* hygiene (`invalidateSessionRecords`) and reaches the grant store on no path,
|
|
22
|
+
* by construction. A proof of the helper is not a proof of the endpoints, so
|
|
23
|
+
* this drives both, on the standalone, as a deployment composes them: a grant
|
|
24
|
+
* seeded beside a live browser session, the session's records gone afterwards,
|
|
25
|
+
* the grant and its credential exactly as they were, and `/token` still
|
|
26
|
+
* disclosing.
|
|
29
27
|
*
|
|
30
28
|
* The seeded grant's access token is fresh, so the disclosure is the cached
|
|
31
|
-
* one and no upstream is asked
|
|
32
|
-
*
|
|
33
|
-
*
|
|
29
|
+
* one and no upstream is asked; the OIDC federation the connection names has
|
|
30
|
+
* `discovery = false` and hand-typed endpoints so the deployment boots without
|
|
31
|
+
* a network.
|
|
34
32
|
*/
|
|
35
33
|
|
|
36
34
|
import { createHmac } from "node:crypto";
|
|
@@ -44,6 +42,7 @@ import {
|
|
|
44
42
|
type FederationTokenStore,
|
|
45
43
|
federationGrantAuthorizationRevision,
|
|
46
44
|
federationGrantIdentityRevision,
|
|
45
|
+
federationsOf,
|
|
47
46
|
InMemoryClientRepository,
|
|
48
47
|
InMemoryUserRepository,
|
|
49
48
|
type MemoryFederationGrantStore,
|
|
@@ -57,7 +56,14 @@ import express from "express";
|
|
|
57
56
|
import request from "supertest";
|
|
58
57
|
import { afterEach, describe, expect, it } from "vitest";
|
|
59
58
|
import { buildModules } from "../buildModules.mjs";
|
|
60
|
-
import { resolveConfigPaths,
|
|
59
|
+
import { resolveConfigPaths, type Switches } from "../configPath.mjs";
|
|
60
|
+
import { templateReference } from "../modules.mjs";
|
|
61
|
+
import {
|
|
62
|
+
capturedRenames,
|
|
63
|
+
libraryLayers,
|
|
64
|
+
rootSectionsOf,
|
|
65
|
+
sectionsCoreDoesNotDeclare,
|
|
66
|
+
} from "./library-references.fixture.mjs";
|
|
61
67
|
|
|
62
68
|
const DAY = 86_400_000;
|
|
63
69
|
const configDir = fileURLToPath(new URL("../../config", import.meta.url));
|
|
@@ -83,69 +89,81 @@ const REFRESH_TOKEN = "SENTINEL-refresh-token";
|
|
|
83
89
|
const ACCESS_TOKEN = "upstream-access-token";
|
|
84
90
|
|
|
85
91
|
const ENV: Readonly<Record<string, string>> = {
|
|
86
|
-
|
|
87
|
-
|
|
92
|
+
// The logout here is a session without a second factor.
|
|
93
|
+
MFA_MODE: "off",
|
|
94
|
+
KEY_STORE_LOCAL_ALGORITHM: "HS256",
|
|
95
|
+
KEY_STORE_LOCAL_SECRET: JWT_SECRET,
|
|
88
96
|
OAUTH_JWT_ISSUER: ISSUER,
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
97
|
+
SESSION_STORE_SECRET: "federation-grants-survive-logout-session.at-least-32-bytes.ok",
|
|
98
|
+
SESSION_STORE_SECURE: "false",
|
|
99
|
+
SESSION_STORE_NAME: "auth.session",
|
|
100
|
+
SESSION_STORE_STORAGE_TYPE: "memory",
|
|
101
|
+
ADAPTERS_USER_REPOSITORY: "yaml",
|
|
102
|
+
REDIS_CLIENTS_URL: "redis://redis.test:6379",
|
|
103
|
+
ADAPTERS_USER_SESSION_STORES: "memory",
|
|
104
|
+
ADAPTERS_RATE_LIMITER: "memory",
|
|
105
|
+
ADAPTERS_CODE_REPOSITORY: "memory",
|
|
106
|
+
ADAPTERS_ACCESS_TOKEN_DENYLIST: "memory",
|
|
107
|
+
ADAPTERS_REPLAY_SEEN_SET: "memory",
|
|
108
|
+
ADAPTERS_FEDERATION_TOKEN_STORE: "memory",
|
|
109
|
+
ADAPTERS_CONSENT_STORE: "none",
|
|
110
|
+
ADAPTERS_FEDERATION_GRANT_STORE: "memory",
|
|
111
|
+
ADAPTERS_FEDERATION_GRANT_INTENT_STORE: "memory",
|
|
104
112
|
FEDERATION_GRANTS_ENABLED: "true",
|
|
105
113
|
FEDERATION_GRANTS_CONSENT_URL: "/consent/grants",
|
|
106
114
|
// The connect flow is not driven here, and the bundled repository has no
|
|
107
115
|
// identity lookup; "unsupported" is the declaration a deployment without one
|
|
108
|
-
// makes
|
|
116
|
+
// makes, and what lets a connection be configured at all.
|
|
109
117
|
FEDERATION_GRANTS_IDENTITY_LOOKUP: "unsupported",
|
|
110
118
|
};
|
|
111
119
|
|
|
112
120
|
/**
|
|
113
121
|
* The shipped configuration under the environment above, with the two lists
|
|
114
|
-
* that have no environment form
|
|
122
|
+
* that have no environment form written over it: the
|
|
115
123
|
* upstream federation the connection names, and the connection.
|
|
116
124
|
*/
|
|
117
|
-
function resolveConfig():
|
|
125
|
+
function resolveConfig(): Switches {
|
|
118
126
|
const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
|
|
119
|
-
const
|
|
120
|
-
parseFile(
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
);
|
|
127
|
+
const layers = parseFile(envConfPath, { env: ENV })
|
|
128
|
+
.withFallback(parseFile(applicationConfPath, { env: ENV }))
|
|
129
|
+
.withFallback(parseFile(fileURLToPath(templateReference()), { env: ENV }))
|
|
130
|
+
.withFallback(libraryLayers(ENV));
|
|
131
|
+
const config = validate(layers, AppConfigSchema);
|
|
125
132
|
return {
|
|
133
|
+
...sectionsCoreDoesNotDeclare(layers),
|
|
134
|
+
...rootSectionsOf(layers, ENV),
|
|
126
135
|
...config,
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
136
|
+
// What the resolution captured of core's renamed variables, which the
|
|
137
|
+
// schema's parse drops.
|
|
138
|
+
"renamed-variables": capturedRenames(ENV),
|
|
139
|
+
core: {
|
|
140
|
+
...config.core,
|
|
141
|
+
federations: {
|
|
142
|
+
...federationsOf(config),
|
|
143
|
+
upstream: {
|
|
144
|
+
enabled: true,
|
|
145
|
+
type: "oidc",
|
|
146
|
+
issuer: UPSTREAM,
|
|
147
|
+
clientId: UPSTREAM_CLIENT_ID,
|
|
148
|
+
clientSecret: "provider-secret",
|
|
149
|
+
callbackURL: `${ISSUER}/session/oauth/federation/upstream/callback`,
|
|
150
|
+
scopes: [...SCOPES],
|
|
151
|
+
discovery: false,
|
|
152
|
+
endpoints: {
|
|
153
|
+
authorizationEndpoint: `${UPSTREAM}/authorize`,
|
|
154
|
+
tokenEndpoint: `${UPSTREAM}/token`,
|
|
155
|
+
jwksUri: `${UPSTREAM}/jwks`,
|
|
156
|
+
},
|
|
157
|
+
redirectAllowlist: [],
|
|
142
158
|
},
|
|
143
|
-
redirectAllowlist: [],
|
|
144
159
|
},
|
|
145
160
|
},
|
|
146
|
-
|
|
147
|
-
...config
|
|
161
|
+
"redis-federation-grant-store": {
|
|
162
|
+
...(config["redis-federation-grant-store"] as object | undefined),
|
|
148
163
|
encryptionKeys: [{ id: "k-test", key: ENCRYPTION_KEY }],
|
|
164
|
+
},
|
|
165
|
+
"federation-grants": {
|
|
166
|
+
...config["federation-grants"],
|
|
149
167
|
connections: {
|
|
150
168
|
[CONNECTION]: {
|
|
151
169
|
federation: "upstream",
|
|
@@ -156,7 +174,7 @@ function resolveConfig(): AppConfig {
|
|
|
156
174
|
},
|
|
157
175
|
},
|
|
158
176
|
},
|
|
159
|
-
} as
|
|
177
|
+
} as Switches;
|
|
160
178
|
}
|
|
161
179
|
|
|
162
180
|
const testRepositoriesModule = defineModule({
|
|
@@ -198,7 +216,7 @@ const testKeyStoreModule = defineModule({
|
|
|
198
216
|
registerBuiltinKeyStores(factory);
|
|
199
217
|
return factory.create({
|
|
200
218
|
type: "local",
|
|
201
|
-
...((c as
|
|
219
|
+
...((c as { "key-store"?: { local?: object } })["key-store"]?.local ?? {}),
|
|
202
220
|
});
|
|
203
221
|
},
|
|
204
222
|
},
|
|
@@ -245,6 +263,7 @@ async function seedGrant(store: MemoryFederationGrantStore): Promise<void> {
|
|
|
245
263
|
tokenType: "Bearer",
|
|
246
264
|
obtainedAt: at,
|
|
247
265
|
issuedLifetime: 3600,
|
|
266
|
+
effectiveExpiresAt: new Date(at.getTime() + 3_600_000),
|
|
248
267
|
scopes: [...SCOPES],
|
|
249
268
|
},
|
|
250
269
|
},
|
|
@@ -284,7 +303,7 @@ const claimsOf = (jwt: string): Record<string, unknown> =>
|
|
|
284
303
|
unknown
|
|
285
304
|
>;
|
|
286
305
|
|
|
287
|
-
describe("
|
|
306
|
+
describe("a grant outlives the browser session at both logout endpoints", () => {
|
|
288
307
|
let handleRef: Awaited<ReturnType<typeof createApp>> | undefined;
|
|
289
308
|
|
|
290
309
|
afterEach(async () => {
|
|
@@ -331,7 +350,7 @@ describe("#593 AC8: a grant outlives the browser session at both logout endpoint
|
|
|
331
350
|
expect(loginRes.status).toBe(200);
|
|
332
351
|
|
|
333
352
|
const cookies = loginRes.headers["set-cookie"] as unknown as string[];
|
|
334
|
-
const csrfCookiePrefix = `${config
|
|
353
|
+
const csrfCookiePrefix = `${(config["session-store"] as { name: string }).name}.csrf=`;
|
|
335
354
|
const reissued = cookies
|
|
336
355
|
.find((c) => c.startsWith(csrfCookiePrefix))
|
|
337
356
|
?.slice(csrfCookiePrefix.length)
|
|
@@ -385,18 +404,19 @@ describe("#593 AC8: a grant outlives the browser session at both logout endpoint
|
|
|
385
404
|
await boot();
|
|
386
405
|
const { cookies, csrfToken, headerName } = await login(app, config);
|
|
387
406
|
const sid = await sidOf(app, cookies);
|
|
388
|
-
// A session-bound upstream token beside the grant
|
|
389
|
-
//
|
|
407
|
+
// A session-bound upstream token beside the grant, keyed by this
|
|
408
|
+
// session: a record logout must end.
|
|
390
409
|
await federationTokenStore.attach(sid, "upstream", {
|
|
391
410
|
accessToken: "session-bound-upstream-token",
|
|
392
411
|
expiresAt: null,
|
|
393
|
-
// Every field is a required key
|
|
394
|
-
//
|
|
412
|
+
// Every field is a required key; this record carries only the
|
|
413
|
+
// access token.
|
|
395
414
|
refreshToken: undefined,
|
|
396
415
|
idToken: undefined,
|
|
397
416
|
tokenType: undefined,
|
|
398
417
|
scope: undefined,
|
|
399
418
|
grantedScope: undefined,
|
|
419
|
+
obtainedAt: undefined,
|
|
400
420
|
});
|
|
401
421
|
expect(await userSessionStore.get(sid)).not.toBeNull();
|
|
402
422
|
await expectGrantUntouched(app, federationGrantStore);
|
|
@@ -407,7 +427,7 @@ describe("#593 AC8: a grant outlives the browser session at both logout endpoint
|
|
|
407
427
|
.set(headerName, csrfToken);
|
|
408
428
|
expect([200, 204]).toContain(logout.status);
|
|
409
429
|
|
|
410
|
-
// The session-bound records are gone
|
|
430
|
+
// The session-bound records are gone ...
|
|
411
431
|
expect(await userSessionStore.get(sid)).toBeNull();
|
|
412
432
|
expect(await federationTokenStore.get(sid, "upstream")).toBeNull();
|
|
413
433
|
// ... and the grant is not among them.
|
|
@@ -422,13 +442,14 @@ describe("#593 AC8: a grant outlives the browser session at both logout endpoint
|
|
|
422
442
|
await federationTokenStore.attach(sid, "upstream", {
|
|
423
443
|
accessToken: "session-bound-upstream-token",
|
|
424
444
|
expiresAt: null,
|
|
425
|
-
// Every field is a required key
|
|
426
|
-
//
|
|
445
|
+
// Every field is a required key; this record carries only the
|
|
446
|
+
// access token.
|
|
427
447
|
refreshToken: undefined,
|
|
428
448
|
idToken: undefined,
|
|
429
449
|
tokenType: undefined,
|
|
430
450
|
scope: undefined,
|
|
431
451
|
grantedScope: undefined,
|
|
452
|
+
obtainedAt: undefined,
|
|
432
453
|
});
|
|
433
454
|
expect(await userSessionStore.get(sid)).not.toBeNull();
|
|
434
455
|
await expectGrantUntouched(app, federationGrantStore);
|
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* `POST /oauth/federation/:name/token` ends a session's upstream tokens only
|
|
19
|
-
* on the upstream's own verdict that the refresh token is bad
|
|
20
|
-
* `invalid_grant` under a 4xx
|
|
19
|
+
* on the upstream's own verdict that the refresh token is bad (a structured
|
|
20
|
+
* `invalid_grant` under a 4xx), and never while the upstream is down, whatever
|
|
21
21
|
* the body of its 5xx says. An outage is `503`, and the tokens are kept for
|
|
22
22
|
* the retry.
|
|
23
23
|
*
|
|
@@ -26,8 +26,7 @@
|
|
|
26
26
|
* are the ones the library raises: a 4xx whose body names a code is a
|
|
27
27
|
* `ResponseBodyError` carrying `error` and `status`; a 5xx is an
|
|
28
28
|
* `OperationProcessingError` raised over the `Response`, whose body the
|
|
29
|
-
* library does not read
|
|
30
|
-
* refresh_failed`, as if the upstream had said something it could not place.
|
|
29
|
+
* library does not read.
|
|
31
30
|
*/
|
|
32
31
|
|
|
33
32
|
import { createFakeIdp, type FakeIdp } from "@o3co/auth-provider-core/testing";
|
|
@@ -76,6 +75,7 @@ describe("POST /oauth/federation/google/token: an upstream outage never ends the
|
|
|
76
75
|
tokenType: "Bearer",
|
|
77
76
|
scope: "openid profile email",
|
|
78
77
|
grantedScope: "openid profile email",
|
|
78
|
+
obtainedAt: undefined,
|
|
79
79
|
},
|
|
80
80
|
});
|
|
81
81
|
const res = await request(session.app)
|
|
@@ -128,6 +128,8 @@ describe("POST /oauth/federation/google/token: an upstream outage never ends the
|
|
|
128
128
|
expect(res.status).toBe(410);
|
|
129
129
|
expect(res.body.error).toBe("re_authentication_required");
|
|
130
130
|
expect(await federationTokenStore.get(sid, "google")).toBeNull();
|
|
131
|
-
|
|
131
|
+
// The route never writes the session's index: the link, now holding no
|
|
132
|
+
// credential, stays listed until federation logout or the session's end.
|
|
133
|
+
expect(await sessionFederationIndex.listFederations(sid)).toContain("google");
|
|
132
134
|
});
|
|
133
135
|
});
|
|
@@ -15,11 +15,10 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* stops forwarding any of them.
|
|
18
|
+
* The bridge forwards, beside `{ clientId, clientSecret, callbackURL }`, the
|
|
19
|
+
* four fields the redirect policy reads. A field it dropped would fail nothing
|
|
20
|
+
* at boot: the policy would be built as if the operator had not configured it.
|
|
21
|
+
* These tests fail if the bridge stops forwarding any of them.
|
|
23
22
|
*/
|
|
24
23
|
|
|
25
24
|
import type { GoogleProviderConfig } from "@o3co/auth-provider-federation-google";
|
|
@@ -34,9 +33,11 @@ const bridge = (
|
|
|
34
33
|
}
|
|
35
34
|
).provides.googleFederationConfig;
|
|
36
35
|
|
|
37
|
-
/** Runs the bridge over a `federations.google`
|
|
36
|
+
/** Runs the bridge over a `core.federations.google` entry in the flat shape. */
|
|
38
37
|
function buildConfig(googleSection: Record<string, unknown>): GoogleProviderConfig {
|
|
39
|
-
return bridge({
|
|
38
|
+
return bridge({
|
|
39
|
+
config: { core: { federations: { google: { enabled: true, ...googleSection } } } },
|
|
40
|
+
});
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
const credentials = {
|
|
@@ -45,7 +46,7 @@ const credentials = {
|
|
|
45
46
|
callbackURL: "https://auth.example.com/session/oauth/federation/google/callback",
|
|
46
47
|
};
|
|
47
48
|
|
|
48
|
-
describe("googleFederationConfigModule — redirect-policy plumbing
|
|
49
|
+
describe("googleFederationConfigModule — redirect-policy plumbing", () => {
|
|
49
50
|
it("forwards sessionDomain, authCallbackUrl, clientUrl and redirectAllowlist", () => {
|
|
50
51
|
const out = buildConfig({
|
|
51
52
|
...credentials,
|
|
@@ -97,13 +98,15 @@ describe("googleFederationConfigModule — redirect-policy plumbing (#278)", ()
|
|
|
97
98
|
it("reads through the nested federation shape as well as the flat one", () => {
|
|
98
99
|
const out = bridge({
|
|
99
100
|
config: {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
101
|
+
core: {
|
|
102
|
+
federations: {
|
|
103
|
+
google: {
|
|
104
|
+
enabled: true,
|
|
105
|
+
type: "google",
|
|
106
|
+
sessionDomain: ".example.com",
|
|
107
|
+
redirectAllowlist: ["https://app.example.com/dashboard"],
|
|
108
|
+
google: credentials,
|
|
109
|
+
},
|
|
107
110
|
},
|
|
108
111
|
},
|
|
109
112
|
},
|
|
@@ -113,7 +116,7 @@ describe("googleFederationConfigModule — redirect-policy plumbing (#278)", ()
|
|
|
113
116
|
});
|
|
114
117
|
});
|
|
115
118
|
|
|
116
|
-
describe("googleFederationConfigModule — requireAuthorizationResponseIss
|
|
119
|
+
describe("googleFederationConfigModule — requireAuthorizationResponseIss", () => {
|
|
117
120
|
it("is absent by default, so the provider's own default (required) applies", () => {
|
|
118
121
|
expect("requireAuthorizationResponseIss" in buildConfig(credentials)).toBe(false);
|
|
119
122
|
});
|
|
@@ -157,14 +160,14 @@ describe("googleFederationConfigModule — requireAuthorizationResponseIss (#597
|
|
|
157
160
|
|
|
158
161
|
it("refuses an empty value, which elsewhere reads as false: exported-but-empty must not switch this check off", () => {
|
|
159
162
|
expect(() => buildConfig({ ...credentials, requireAuthorizationResponseIss: "" })).toThrow(
|
|
160
|
-
/federations\.google\.requireAuthorizationResponseIss/,
|
|
163
|
+
/core\.federations\.google\.requireAuthorizationResponseIss/,
|
|
161
164
|
);
|
|
162
165
|
});
|
|
163
166
|
|
|
164
167
|
it("refuses anything else: a typo must not silently switch the check off, or leave it on", () => {
|
|
165
168
|
for (const bad of ["no", "off", 0, 1, []]) {
|
|
166
169
|
expect(() => buildConfig({ ...credentials, requireAuthorizationResponseIss: bad })).toThrow(
|
|
167
|
-
/federations\.google\.requireAuthorizationResponseIss/,
|
|
170
|
+
/core\.federations\.google\.requireAuthorizationResponseIss/,
|
|
168
171
|
);
|
|
169
172
|
}
|
|
170
173
|
});
|
|
@@ -192,7 +195,7 @@ describe("googleFederationConfigModule — accessType", () => {
|
|
|
192
195
|
it("refuses anything else at boot, naming the key", () => {
|
|
193
196
|
for (const bad of ["", "offine", "Online", "true", false, 1]) {
|
|
194
197
|
expect(() => buildConfig({ ...credentials, accessType: bad })).toThrow(
|
|
195
|
-
/federations\.google\.accessType/,
|
|
198
|
+
/core\.federations\.google\.accessType/,
|
|
196
199
|
);
|
|
197
200
|
}
|
|
198
201
|
});
|
|
@@ -15,27 +15,23 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
|
-
* A browser session with Google linked, on the standalone as a
|
|
19
|
-
*
|
|
20
|
-
* booted through `createApp` under the shipped configuration.
|
|
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
21
|
*
|
|
22
|
-
* The
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* seen here.
|
|
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.
|
|
27
26
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* claims the case needs — so the deployment signs with an HS256 secret this
|
|
32
|
-
* file holds, and nothing but Google is federated.
|
|
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.
|
|
33
30
|
*/
|
|
34
31
|
|
|
35
32
|
import { createHmac } from "node:crypto";
|
|
36
33
|
import { fileURLToPath } from "node:url";
|
|
37
34
|
import {
|
|
38
|
-
type AppConfig,
|
|
39
35
|
AppConfigSchema,
|
|
40
36
|
createApp,
|
|
41
37
|
createKeyStoreFactory,
|
|
@@ -59,7 +55,14 @@ import express from "express";
|
|
|
59
55
|
import request from "supertest";
|
|
60
56
|
import { expect } from "vitest";
|
|
61
57
|
import { buildModules } from "#/buildModules.mjs";
|
|
62
|
-
import { resolveConfigPaths,
|
|
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";
|
|
63
66
|
|
|
64
67
|
const configDir = fileURLToPath(new URL("../../config", import.meta.url));
|
|
65
68
|
|
|
@@ -82,22 +85,24 @@ const GOOGLE_CLIENT_SECRET = "google-secret";
|
|
|
82
85
|
const GOOGLE_CALLBACK = `${ISSUER}/session/oauth/federation/google/callback`;
|
|
83
86
|
|
|
84
87
|
const ENV: Readonly<Record<string, string>> = {
|
|
85
|
-
|
|
86
|
-
|
|
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,
|
|
87
92
|
OAUTH_JWT_ISSUER: ISSUER,
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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",
|
|
101
106
|
};
|
|
102
107
|
|
|
103
108
|
/**
|
|
@@ -111,24 +116,30 @@ export type GoogleWiring =
|
|
|
111
116
|
| "shipped"
|
|
112
117
|
| Omit<GoogleProviderConfig, "clientId" | "clientSecret" | "callbackURL">;
|
|
113
118
|
|
|
114
|
-
function resolveConfig(google: GoogleWiring):
|
|
119
|
+
function resolveConfig(google: GoogleWiring): Switches {
|
|
115
120
|
const env: Record<string, string> =
|
|
116
121
|
google === "shipped"
|
|
117
122
|
? {
|
|
118
123
|
...ENV,
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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,
|
|
123
128
|
}
|
|
124
129
|
: { ...ENV };
|
|
125
130
|
const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
|
|
126
|
-
|
|
127
|
-
parseFile(
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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;
|
|
132
143
|
}
|
|
133
144
|
|
|
134
145
|
const testRepositoriesModule = defineModule({
|
|
@@ -170,7 +181,7 @@ const testKeyStoreModule = defineModule({
|
|
|
170
181
|
registerBuiltinKeyStores(factory);
|
|
171
182
|
return factory.create({
|
|
172
183
|
type: "local",
|
|
173
|
-
...((c as
|
|
184
|
+
...((c as { "key-store"?: { local?: object } })["key-store"]?.local ?? {}),
|
|
174
185
|
});
|
|
175
186
|
},
|
|
176
187
|
},
|
|
@@ -15,17 +15,10 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
|
-
* The host's own routes
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* The composed router answers its own errors with core's terminal handler.
|
|
23
|
-
* The host's routes sat in front of a handler of the template's own, which
|
|
24
|
-
* answered in other words ("Internal server error"), took any 4xx `status`
|
|
25
|
-
* for the client's mistake and answered it as one, handed an error on to
|
|
26
|
-
* Express once the headers were out (whose final handler prints the stack),
|
|
27
|
-
* and logged the path as it arrived. Core's handler is now the one after
|
|
28
|
-
* them too, so a deployment answers every route's error the same way.
|
|
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.
|
|
29
22
|
*/
|
|
30
23
|
|
|
31
24
|
import type { Logger, ReadinessProbe } from "@o3co/auth-provider-core";
|