@o3co/create-auth-provider 0.16.0 → 0.17.0-rc.2
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 -29
- package/templates/standalone/Dockerfile +6 -2
- package/templates/standalone/README.ja.md +324 -169
- package/templates/standalone/README.md +618 -208
- package/templates/standalone/config/application.conf +211 -352
- 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 +418 -0
- package/templates/standalone/docker-compose.mailpit.yml +36 -0
- package/templates/standalone/docker-compose.production.yml +63 -32
- 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__/adapters.test.mts +419 -0
- package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +87 -0
- package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +287 -198
- package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +71 -35
- package/templates/standalone/src/__tests__/all-modules-composition.test.mts +148 -94
- package/templates/standalone/src/__tests__/audit-sink.test.mts +84 -81
- package/templates/standalone/src/__tests__/config-notices.test.mts +154 -0
- package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
- package/templates/standalone/src/__tests__/core-federations.test.mts +249 -0
- package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
- package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +10 -15
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +970 -288
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +421 -86
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +87 -72
- package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +16 -8
- package/templates/standalone/src/__tests__/federation-types.test.mts +239 -0
- package/templates/standalone/src/__tests__/google-session.fixture.mts +97 -93
- package/templates/standalone/src/__tests__/host-routes.test.mts +4 -11
- package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +1 -1
- package/templates/standalone/src/__tests__/library-references.fixture.mts +188 -0
- package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +216 -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 +326 -0
- package/templates/standalone/src/__tests__/metrics.test.mts +4 -9
- package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +122 -0
- package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
- package/templates/standalone/src/__tests__/mfa-switch.test.mts +825 -0
- package/templates/standalone/src/__tests__/own-modules.test.mts +1486 -0
- package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +10 -14
- 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 +116 -81
- package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
- package/templates/standalone/src/__tests__/replica-safety.test.mts +265 -146
- package/templates/standalone/src/__tests__/router-error-answers.test.mts +4 -7
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +278 -103
- package/templates/standalone/src/__tests__/sections.test.mts +480 -0
- package/templates/standalone/src/__tests__/session-lifecycle.test.mts +215 -0
- package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +69 -61
- package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +130 -0
- package/templates/standalone/src/__tests__/session-requirements.test.mts +382 -0
- package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +270 -0
- package/templates/standalone/src/__tests__/shutdown.test.mts +163 -96
- package/templates/standalone/src/__tests__/smoke.test.mts +176 -258
- package/templates/standalone/src/__tests__/store-credential.test.mts +88 -52
- package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +131 -102
- package/templates/standalone/src/__tests__/two-phase-config.test.mts +604 -0
- package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +382 -0
- package/templates/standalone/src/adapters.mts +162 -0
- package/templates/standalone/src/app.mts +65 -79
- package/templates/standalone/src/bootRefusal.mts +117 -0
- package/templates/standalone/src/buildModules.mts +220 -309
- package/templates/standalone/src/configPath.mts +449 -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 +285 -0
- package/templates/standalone/src/modules.mts +423 -629
- package/templates/standalone/src/rootRenames.mts +96 -0
- package/templates/standalone/src/routes.mts +8 -14
- package/templates/standalone/src/sections.mts +367 -0
- package/templates/standalone/src/shutdown.mts +45 -123
- package/templates/standalone/tests/index.test.js +6 -5
- 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__/access-token-lifetime-alias.test.mts +0 -135
- package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +0 -199
- package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +0 -138
|
@@ -15,35 +15,32 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* composes them: a grant seeded beside a live browser session, the session's
|
|
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
|
+
* Both close the session through core's session lifecycle, whose close work
|
|
21
|
+
* reaches the grant store on no path, by construction. A proof of the helper is not a proof of the endpoints, so
|
|
22
|
+
* this drives both, on the standalone, as a deployment composes them: a grant
|
|
23
|
+
* seeded beside a live browser session, the session's records gone afterwards,
|
|
24
|
+
* the grant and its credential exactly as they were, and `/token` still
|
|
25
|
+
* disclosing.
|
|
29
26
|
*
|
|
30
27
|
* The seeded grant's access token is fresh, so the disclosure is the cached
|
|
31
|
-
* one and no upstream is asked
|
|
32
|
-
*
|
|
33
|
-
*
|
|
28
|
+
* one and no upstream is asked; the OIDC federation the connection names has
|
|
29
|
+
* `discovery = false` and hand-typed endpoints so the deployment boots without
|
|
30
|
+
* a network.
|
|
34
31
|
*/
|
|
35
32
|
|
|
36
33
|
import { createHmac } from "node:crypto";
|
|
37
34
|
import { fileURLToPath } from "node:url";
|
|
38
35
|
import {
|
|
39
36
|
type AppConfig,
|
|
40
|
-
AppConfigSchema,
|
|
41
37
|
createApp,
|
|
42
38
|
createKeyStoreFactory,
|
|
43
39
|
defineModule,
|
|
44
40
|
type FederationTokenStore,
|
|
45
41
|
federationGrantAuthorizationRevision,
|
|
46
42
|
federationGrantIdentityRevision,
|
|
43
|
+
federationsOf,
|
|
47
44
|
InMemoryClientRepository,
|
|
48
45
|
InMemoryUserRepository,
|
|
49
46
|
type MemoryFederationGrantStore,
|
|
@@ -52,12 +49,18 @@ import {
|
|
|
52
49
|
type UserSessionStore,
|
|
53
50
|
} from "@o3co/auth-provider-core";
|
|
54
51
|
import { parseFile } from "@o3co/ts.hocon";
|
|
55
|
-
import { validate } from "@o3co/ts.hocon/zod";
|
|
56
52
|
import express from "express";
|
|
57
53
|
import request from "supertest";
|
|
58
54
|
import { afterEach, describe, expect, it } from "vitest";
|
|
59
55
|
import { buildModules } from "../buildModules.mjs";
|
|
60
|
-
import { resolveConfigPaths
|
|
56
|
+
import { resolveConfigPaths } from "../configPath.mjs";
|
|
57
|
+
import { templateReference } from "../modules.mjs";
|
|
58
|
+
import {
|
|
59
|
+
type BothPhases,
|
|
60
|
+
bothPhasesOf,
|
|
61
|
+
capturedRenames,
|
|
62
|
+
libraryLayers,
|
|
63
|
+
} from "./library-references.fixture.mjs";
|
|
61
64
|
|
|
62
65
|
const DAY = 86_400_000;
|
|
63
66
|
const configDir = fileURLToPath(new URL("../../config", import.meta.url));
|
|
@@ -83,69 +86,78 @@ const REFRESH_TOKEN = "SENTINEL-refresh-token";
|
|
|
83
86
|
const ACCESS_TOKEN = "upstream-access-token";
|
|
84
87
|
|
|
85
88
|
const ENV: Readonly<Record<string, string>> = {
|
|
86
|
-
|
|
87
|
-
|
|
89
|
+
// The logout here is a session without a second factor.
|
|
90
|
+
MFA_MODE: "off",
|
|
91
|
+
KEY_STORE_LOCAL_ALGORITHM: "HS256",
|
|
92
|
+
KEY_STORE_LOCAL_SECRET: JWT_SECRET,
|
|
88
93
|
OAUTH_JWT_ISSUER: ISSUER,
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
94
|
+
SESSION_STORE_SECRET: "federation-grants-survive-logout-session.at-least-32-bytes.ok",
|
|
95
|
+
SESSION_STORE_SECURE: "false",
|
|
96
|
+
SESSION_STORE_NAME: "auth.session",
|
|
97
|
+
SESSION_STORE_STORAGE_TYPE: "memory",
|
|
98
|
+
ADAPTERS_USER_REPOSITORY: "yaml",
|
|
99
|
+
REDIS_CLIENTS_URL: "redis://redis.test:6379",
|
|
100
|
+
ADAPTERS_USER_SESSION_STORES: "memory",
|
|
101
|
+
ADAPTERS_RATE_LIMITER: "memory",
|
|
102
|
+
ADAPTERS_CODE_REPOSITORY: "memory",
|
|
103
|
+
ADAPTERS_ACCESS_TOKEN_DENYLIST: "memory",
|
|
104
|
+
ADAPTERS_REPLAY_SEEN_SET: "memory",
|
|
105
|
+
ADAPTERS_FEDERATION_TOKEN_STORE: "memory",
|
|
106
|
+
ADAPTERS_CONSENT_STORE: "none",
|
|
107
|
+
ADAPTERS_FEDERATION_GRANT_STORE: "memory",
|
|
108
|
+
ADAPTERS_FEDERATION_GRANT_INTENT_STORE: "memory",
|
|
104
109
|
FEDERATION_GRANTS_ENABLED: "true",
|
|
105
110
|
FEDERATION_GRANTS_CONSENT_URL: "/consent/grants",
|
|
106
111
|
// The connect flow is not driven here, and the bundled repository has no
|
|
107
112
|
// identity lookup; "unsupported" is the declaration a deployment without one
|
|
108
|
-
// makes
|
|
113
|
+
// makes, and what lets a connection be configured at all.
|
|
109
114
|
FEDERATION_GRANTS_IDENTITY_LOOKUP: "unsupported",
|
|
110
115
|
};
|
|
111
116
|
|
|
112
117
|
/**
|
|
113
118
|
* The shipped configuration under the environment above, with the two lists
|
|
114
|
-
* that have no environment form
|
|
119
|
+
* that have no environment form written over it: the
|
|
115
120
|
* upstream federation the connection names, and the connection.
|
|
116
121
|
*/
|
|
117
|
-
function resolveConfig():
|
|
122
|
+
function resolveConfig(): BothPhases {
|
|
118
123
|
const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
|
|
119
|
-
const
|
|
120
|
-
parseFile(
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
);
|
|
124
|
+
const layers = parseFile(envConfPath, { env: ENV })
|
|
125
|
+
.withFallback(parseFile(applicationConfPath, { env: ENV }))
|
|
126
|
+
.withFallback(parseFile(fileURLToPath(templateReference()), { env: ENV }))
|
|
127
|
+
.withFallback(libraryLayers(ENV));
|
|
128
|
+
const config = bothPhasesOf(layers, ENV);
|
|
125
129
|
return {
|
|
126
130
|
...config,
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
131
|
+
// What the resolution captured of the renamed variables.
|
|
132
|
+
"renamed-variables": capturedRenames(ENV),
|
|
133
|
+
core: {
|
|
134
|
+
...config.core,
|
|
135
|
+
federations: {
|
|
136
|
+
...federationsOf(config),
|
|
137
|
+
upstream: {
|
|
138
|
+
enabled: true,
|
|
139
|
+
type: "oidc",
|
|
140
|
+
issuer: UPSTREAM,
|
|
141
|
+
clientId: UPSTREAM_CLIENT_ID,
|
|
142
|
+
clientSecret: "provider-secret",
|
|
143
|
+
callbackURL: `${ISSUER}/session/oauth/federation/upstream/callback`,
|
|
144
|
+
scopes: [...SCOPES],
|
|
145
|
+
discovery: false,
|
|
146
|
+
endpoints: {
|
|
147
|
+
authorizationEndpoint: `${UPSTREAM}/authorize`,
|
|
148
|
+
tokenEndpoint: `${UPSTREAM}/token`,
|
|
149
|
+
jwksUri: `${UPSTREAM}/jwks`,
|
|
150
|
+
},
|
|
151
|
+
redirectAllowlist: [],
|
|
142
152
|
},
|
|
143
|
-
redirectAllowlist: [],
|
|
144
153
|
},
|
|
145
154
|
},
|
|
146
|
-
|
|
147
|
-
...config
|
|
155
|
+
"redis-federation-grant-store": {
|
|
156
|
+
...(config["redis-federation-grant-store"] as object | undefined),
|
|
148
157
|
encryptionKeys: [{ id: "k-test", key: ENCRYPTION_KEY }],
|
|
158
|
+
},
|
|
159
|
+
"federation-grants": {
|
|
160
|
+
...config["federation-grants"],
|
|
149
161
|
connections: {
|
|
150
162
|
[CONNECTION]: {
|
|
151
163
|
federation: "upstream",
|
|
@@ -156,7 +168,7 @@ function resolveConfig(): AppConfig {
|
|
|
156
168
|
},
|
|
157
169
|
},
|
|
158
170
|
},
|
|
159
|
-
} as
|
|
171
|
+
} as BothPhases;
|
|
160
172
|
}
|
|
161
173
|
|
|
162
174
|
const testRepositoriesModule = defineModule({
|
|
@@ -198,7 +210,7 @@ const testKeyStoreModule = defineModule({
|
|
|
198
210
|
registerBuiltinKeyStores(factory);
|
|
199
211
|
return factory.create({
|
|
200
212
|
type: "local",
|
|
201
|
-
...((c as
|
|
213
|
+
...((c as { "key-store"?: { local?: object } })["key-store"]?.local ?? {}),
|
|
202
214
|
});
|
|
203
215
|
},
|
|
204
216
|
},
|
|
@@ -245,6 +257,7 @@ async function seedGrant(store: MemoryFederationGrantStore): Promise<void> {
|
|
|
245
257
|
tokenType: "Bearer",
|
|
246
258
|
obtainedAt: at,
|
|
247
259
|
issuedLifetime: 3600,
|
|
260
|
+
effectiveExpiresAt: new Date(at.getTime() + 3_600_000),
|
|
248
261
|
scopes: [...SCOPES],
|
|
249
262
|
},
|
|
250
263
|
},
|
|
@@ -284,7 +297,7 @@ const claimsOf = (jwt: string): Record<string, unknown> =>
|
|
|
284
297
|
unknown
|
|
285
298
|
>;
|
|
286
299
|
|
|
287
|
-
describe("
|
|
300
|
+
describe("a grant outlives the browser session at both logout endpoints", () => {
|
|
288
301
|
let handleRef: Awaited<ReturnType<typeof createApp>> | undefined;
|
|
289
302
|
|
|
290
303
|
afterEach(async () => {
|
|
@@ -331,7 +344,7 @@ describe("#593 AC8: a grant outlives the browser session at both logout endpoint
|
|
|
331
344
|
expect(loginRes.status).toBe(200);
|
|
332
345
|
|
|
333
346
|
const cookies = loginRes.headers["set-cookie"] as unknown as string[];
|
|
334
|
-
const csrfCookiePrefix = `${config
|
|
347
|
+
const csrfCookiePrefix = `${(config["session-store"] as { name: string }).name}.csrf=`;
|
|
335
348
|
const reissued = cookies
|
|
336
349
|
.find((c) => c.startsWith(csrfCookiePrefix))
|
|
337
350
|
?.slice(csrfCookiePrefix.length)
|
|
@@ -385,18 +398,19 @@ describe("#593 AC8: a grant outlives the browser session at both logout endpoint
|
|
|
385
398
|
await boot();
|
|
386
399
|
const { cookies, csrfToken, headerName } = await login(app, config);
|
|
387
400
|
const sid = await sidOf(app, cookies);
|
|
388
|
-
// A session-bound upstream token beside the grant
|
|
389
|
-
//
|
|
401
|
+
// A session-bound upstream token beside the grant, keyed by this
|
|
402
|
+
// session: a record logout must end.
|
|
390
403
|
await federationTokenStore.attach(sid, "upstream", {
|
|
391
404
|
accessToken: "session-bound-upstream-token",
|
|
392
405
|
expiresAt: null,
|
|
393
|
-
// Every field is a required key
|
|
394
|
-
//
|
|
406
|
+
// Every field is a required key; this record carries only the
|
|
407
|
+
// access token.
|
|
395
408
|
refreshToken: undefined,
|
|
396
409
|
idToken: undefined,
|
|
397
410
|
tokenType: undefined,
|
|
398
411
|
scope: undefined,
|
|
399
412
|
grantedScope: undefined,
|
|
413
|
+
obtainedAt: undefined,
|
|
400
414
|
});
|
|
401
415
|
expect(await userSessionStore.get(sid)).not.toBeNull();
|
|
402
416
|
await expectGrantUntouched(app, federationGrantStore);
|
|
@@ -407,7 +421,7 @@ describe("#593 AC8: a grant outlives the browser session at both logout endpoint
|
|
|
407
421
|
.set(headerName, csrfToken);
|
|
408
422
|
expect([200, 204]).toContain(logout.status);
|
|
409
423
|
|
|
410
|
-
// The session-bound records are gone
|
|
424
|
+
// The session-bound records are gone ...
|
|
411
425
|
expect(await userSessionStore.get(sid)).toBeNull();
|
|
412
426
|
expect(await federationTokenStore.get(sid, "upstream")).toBeNull();
|
|
413
427
|
// ... and the grant is not among them.
|
|
@@ -422,13 +436,14 @@ describe("#593 AC8: a grant outlives the browser session at both logout endpoint
|
|
|
422
436
|
await federationTokenStore.attach(sid, "upstream", {
|
|
423
437
|
accessToken: "session-bound-upstream-token",
|
|
424
438
|
expiresAt: null,
|
|
425
|
-
// Every field is a required key
|
|
426
|
-
//
|
|
439
|
+
// Every field is a required key; this record carries only the
|
|
440
|
+
// access token.
|
|
427
441
|
refreshToken: undefined,
|
|
428
442
|
idToken: undefined,
|
|
429
443
|
tokenType: undefined,
|
|
430
444
|
scope: undefined,
|
|
431
445
|
grantedScope: undefined,
|
|
446
|
+
obtainedAt: undefined,
|
|
432
447
|
});
|
|
433
448
|
expect(await userSessionStore.get(sid)).not.toBeNull();
|
|
434
449
|
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)
|
|
@@ -94,7 +94,7 @@ describe("POST /oauth/federation/google/token: an upstream outage never ends the
|
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
it("keeps them and answers 503 when Google answers 503 with a body naming invalid_grant", async () => {
|
|
97
|
-
const { res, sid, federationTokenStore,
|
|
97
|
+
const { res, sid, federationTokenStore, sessionLifecycle } = await refreshAnswered(503, {
|
|
98
98
|
error: "invalid_grant",
|
|
99
99
|
error_description: "Backend unavailable",
|
|
100
100
|
});
|
|
@@ -107,7 +107,10 @@ describe("POST /oauth/federation/google/token: an upstream outage never ends the
|
|
|
107
107
|
expect((await federationTokenStore.get(sid, "google"))?.refreshToken).toBe(
|
|
108
108
|
STORED_REFRESH_TOKEN,
|
|
109
109
|
);
|
|
110
|
-
expect(await
|
|
110
|
+
expect(await sessionLifecycle.federations(sid)).toEqual({
|
|
111
|
+
outcome: "listed",
|
|
112
|
+
federations: ["google"],
|
|
113
|
+
});
|
|
111
114
|
});
|
|
112
115
|
|
|
113
116
|
it("keeps them and answers 503 when Google answers 502 with no OAuth body at all", async () => {
|
|
@@ -120,7 +123,7 @@ describe("POST /oauth/federation/google/token: an upstream outage never ends the
|
|
|
120
123
|
});
|
|
121
124
|
|
|
122
125
|
it("ends them on Google's structured 400 invalid_grant", async () => {
|
|
123
|
-
const { res, sid, federationTokenStore,
|
|
126
|
+
const { res, sid, federationTokenStore, sessionLifecycle } = await refreshAnswered(400, {
|
|
124
127
|
error: "invalid_grant",
|
|
125
128
|
error_description: "Token has been expired or revoked.",
|
|
126
129
|
});
|
|
@@ -128,6 +131,11 @@ describe("POST /oauth/federation/google/token: an upstream outage never ends the
|
|
|
128
131
|
expect(res.status).toBe(410);
|
|
129
132
|
expect(res.body.error).toBe("re_authentication_required");
|
|
130
133
|
expect(await federationTokenStore.get(sid, "google")).toBeNull();
|
|
131
|
-
|
|
134
|
+
// The route never writes the session's index: the link, now holding no
|
|
135
|
+
// credential, stays listed until federation logout or the session's end.
|
|
136
|
+
expect(await sessionLifecycle.federations(sid)).toEqual({
|
|
137
|
+
outcome: "listed",
|
|
138
|
+
federations: ["google"],
|
|
139
|
+
});
|
|
132
140
|
});
|
|
133
141
|
});
|
|
@@ -0,0 +1,239 @@
|
|
|
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 federations the template ships are handled by the modules of their
|
|
19
|
+
* types. The template loads the federation types it bundles, `google` and
|
|
20
|
+
* `oidc`, whatever the configuration enables, and core dispatches each
|
|
21
|
+
* enabled `core.federations` entry to the module of its `type`, under the
|
|
22
|
+
* entry's name: two entries of one type are two federations. The template
|
|
23
|
+
* reads no entry before boot, so a malformed one is core's to refuse, at its
|
|
24
|
+
* path.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { mkdtempSync, readdirSync, writeFileSync } from "node:fs";
|
|
28
|
+
import { tmpdir } from "node:os";
|
|
29
|
+
import { join } from "node:path";
|
|
30
|
+
import { fileURLToPath } from "node:url";
|
|
31
|
+
import request from "supertest";
|
|
32
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
33
|
+
import { buildModules } from "#/buildModules.mjs";
|
|
34
|
+
import { readOwnLayers, readSwitches, resolveConfigPaths, resolveLayers } from "#/configPath.mjs";
|
|
35
|
+
import {
|
|
36
|
+
type Composition,
|
|
37
|
+
compose,
|
|
38
|
+
ISSUER,
|
|
39
|
+
ownFiles,
|
|
40
|
+
resolveConfig,
|
|
41
|
+
SINGLE_ENV,
|
|
42
|
+
} from "./all-modules-composition.fixture.mjs";
|
|
43
|
+
|
|
44
|
+
const configDir = fileURLToPath(new URL("../../config", import.meta.url));
|
|
45
|
+
|
|
46
|
+
/** Google's authorization endpoint, where a Google federation's start sends the browser. */
|
|
47
|
+
const GOOGLE_AUTHORIZE = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
48
|
+
|
|
49
|
+
let current: Composition | undefined;
|
|
50
|
+
|
|
51
|
+
afterEach(async () => {
|
|
52
|
+
await current?.handle.dispose();
|
|
53
|
+
current = undefined;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/** Boots and remembers the composition, so `afterEach` disposes it. */
|
|
57
|
+
async function boot(options: Parameters<typeof compose>[0] = {}): Promise<Composition> {
|
|
58
|
+
current = await compose(options);
|
|
59
|
+
return current;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** What `compose` refuses under `options`. */
|
|
63
|
+
async function refusal(options: Parameters<typeof compose>[0]): Promise<{
|
|
64
|
+
readonly reason?: string;
|
|
65
|
+
readonly message: string;
|
|
66
|
+
}> {
|
|
67
|
+
try {
|
|
68
|
+
current = await compose(options);
|
|
69
|
+
} catch (err) {
|
|
70
|
+
return err as { reason?: string; message: string };
|
|
71
|
+
}
|
|
72
|
+
throw new Error("the composition booted");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** `text` in a file of its own, for a layer above the composition's files. */
|
|
76
|
+
function hoconFile(text: string): string {
|
|
77
|
+
const file = join(mkdtempSync(join(tmpdir(), "federation-types-")), "operator.conf");
|
|
78
|
+
writeFileSync(file, text);
|
|
79
|
+
return file;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The module names `buildModules` lists for `env`, federation ones only. */
|
|
83
|
+
const federationModules = (env: Readonly<Record<string, string>>): string[] =>
|
|
84
|
+
buildModules(resolveConfig(env))
|
|
85
|
+
.map((module) => module.name)
|
|
86
|
+
.filter((name) => name.startsWith("federation-") && !name.startsWith("federation-grant"));
|
|
87
|
+
|
|
88
|
+
/** Where the start of federation `name` sends the browser. */
|
|
89
|
+
async function startOf(composed: Composition, name: string): Promise<URL> {
|
|
90
|
+
const res = await request(composed.app).get(`/session/oauth/federation/${name}`);
|
|
91
|
+
expect(res.status).toBe(302);
|
|
92
|
+
return new URL(res.headers.location as string);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
describe("the federation types the template bundles", () => {
|
|
96
|
+
it("are listed whatever the configuration enables", () => {
|
|
97
|
+
const off = {
|
|
98
|
+
...SINGLE_ENV,
|
|
99
|
+
CORE_FEDERATIONS_GOOGLE_ENABLED: "false",
|
|
100
|
+
CORE_FEDERATIONS_OIDC_ENABLED: "false",
|
|
101
|
+
};
|
|
102
|
+
expect(federationModules(off)).toEqual(["federation-google-type", "federation-oidc"]);
|
|
103
|
+
expect(federationModules(SINGLE_ENV)).toEqual(["federation-google-type", "federation-oidc"]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("handle every entry the template ships, each of which names its type", () => {
|
|
107
|
+
const { applicationConfPath } = resolveConfigPaths(configDir, "production");
|
|
108
|
+
const resolved = resolveLayers(readOwnLayers([applicationConfPath], { env: {} }), []) as {
|
|
109
|
+
core?: { federations?: Record<string, { type?: unknown }> };
|
|
110
|
+
};
|
|
111
|
+
const types = Object.fromEntries(
|
|
112
|
+
Object.entries(resolved.core?.federations ?? {}).map(([name, entry]) => [name, entry.type]),
|
|
113
|
+
);
|
|
114
|
+
expect(types).toEqual({ google: "google", oidc: "oidc" });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("boot the shipped Google federation, enabled, as the provider named google that its type built", async () => {
|
|
118
|
+
const composed = await boot();
|
|
119
|
+
const names = composed.modules.map((module) => module.name);
|
|
120
|
+
expect(names).toContain("federation-google-type");
|
|
121
|
+
expect(names).not.toContain("federation-google");
|
|
122
|
+
expect(composed.handle.components.federationProviders?.get("google")?.name).toBe("google");
|
|
123
|
+
|
|
124
|
+
const start = await startOf(composed, "google");
|
|
125
|
+
expect(`${start.origin}${start.pathname}`).toBe(GOOGLE_AUTHORIZE);
|
|
126
|
+
expect(start.searchParams.get("client_id")).toBe(SINGLE_ENV.CORE_FEDERATIONS_GOOGLE_CLIENT_ID);
|
|
127
|
+
expect(start.searchParams.get("redirect_uri")).toBe(
|
|
128
|
+
"http://localhost:3000/session/oauth/federation/google/callback",
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("boot a second entry of type google, under its own name, beside the shipped one", async () => {
|
|
133
|
+
const callbackURL = `${ISSUER}/session/oauth/federation/google-work/callback`;
|
|
134
|
+
const composed = await boot({
|
|
135
|
+
operatorHocon: `core.federations.google-work {
|
|
136
|
+
enabled = true
|
|
137
|
+
type = "google"
|
|
138
|
+
clientId = "google-work-client"
|
|
139
|
+
clientSecret = "google-work-secret"
|
|
140
|
+
callbackURL = ${JSON.stringify(callbackURL)}
|
|
141
|
+
}
|
|
142
|
+
`,
|
|
143
|
+
});
|
|
144
|
+
const providers = composed.handle.components.federationProviders;
|
|
145
|
+
expect([...(providers?.keys() ?? [])].sort()).toEqual(["google", "google-work", "oidc"]);
|
|
146
|
+
expect(providers?.get("google-work")?.name).toBe("google-work");
|
|
147
|
+
|
|
148
|
+
const work = await startOf(composed, "google-work");
|
|
149
|
+
expect(`${work.origin}${work.pathname}`).toBe(GOOGLE_AUTHORIZE);
|
|
150
|
+
expect(work.searchParams.get("client_id")).toBe("google-work-client");
|
|
151
|
+
expect(work.searchParams.get("redirect_uri")).toBe(callbackURL);
|
|
152
|
+
const shipped = await startOf(composed, "google");
|
|
153
|
+
expect(shipped.searchParams.get("client_id")).toBe(
|
|
154
|
+
SINGLE_ENV.CORE_FEDERATIONS_GOOGLE_CLIENT_ID,
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("hand an entry to the type it names, not the type its name reads as", async () => {
|
|
159
|
+
// `google` of type `oidc` is a generic OIDC federation named google.
|
|
160
|
+
const composed = await boot({
|
|
161
|
+
operatorHocon: `core.federations.google { type = "oidc", issuer = ${JSON.stringify(SINGLE_ENV.CORE_FEDERATIONS_OIDC_ISSUER)} }\n`,
|
|
162
|
+
});
|
|
163
|
+
const start = await startOf(composed, "google");
|
|
164
|
+
expect(start.origin).toBe(SINGLE_ENV.CORE_FEDERATIONS_OIDC_ISSUER);
|
|
165
|
+
expect(start.searchParams.get("client_id")).toBe(SINGLE_ENV.CORE_FEDERATIONS_GOOGLE_CLIENT_ID);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("register the type of every entry each shipped configuration file writes", () => {
|
|
169
|
+
const registered = new Set(
|
|
170
|
+
buildModules(resolveConfig(SINGLE_ENV)).flatMap((module) =>
|
|
171
|
+
Object.keys(module.contributes?.federationTypes ?? {}),
|
|
172
|
+
),
|
|
173
|
+
);
|
|
174
|
+
const written = readdirSync(configDir)
|
|
175
|
+
.filter((file) => file.endsWith(".conf"))
|
|
176
|
+
.flatMap((file) => {
|
|
177
|
+
const resolved = resolveLayers(readOwnLayers([join(configDir, file)], { env: {} }), []) as {
|
|
178
|
+
core?: { federations?: Record<string, { type?: unknown }> };
|
|
179
|
+
};
|
|
180
|
+
return Object.entries(resolved.core?.federations ?? {}).map(([name, entry]) => ({
|
|
181
|
+
file,
|
|
182
|
+
name,
|
|
183
|
+
type: entry.type,
|
|
184
|
+
}));
|
|
185
|
+
});
|
|
186
|
+
expect(written).not.toEqual([]);
|
|
187
|
+
expect(written.filter(({ type }) => typeof type !== "string" || !registered.has(type))).toEqual(
|
|
188
|
+
[],
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("the template reads no federation entry before boot", () => {
|
|
194
|
+
it("passes a malformed entry through phase one, and boot refuses it at its path", async () => {
|
|
195
|
+
const operatorHocon = 'core.federations.okta { enabled = "maybe", type = "oidc" }\n';
|
|
196
|
+
expect(() =>
|
|
197
|
+
readSwitches(readOwnLayers([hoconFile(operatorHocon), ...ownFiles()], { env: SINGLE_ENV })),
|
|
198
|
+
).not.toThrow();
|
|
199
|
+
const err = await refusal({ operatorHocon });
|
|
200
|
+
expect(err.reason).toBe("config-validation-failed");
|
|
201
|
+
expect(err.message).toContain("core.federations.okta.enabled");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it.each([
|
|
205
|
+
["accessType", '"sometimes"'],
|
|
206
|
+
// A bare string where a list belongs: the natural typo.
|
|
207
|
+
["redirectAllowlist", '"https://app.test/home"'],
|
|
208
|
+
["sessionDomain", "42"],
|
|
209
|
+
["requireAuthorizationResponseIss", '"no"'],
|
|
210
|
+
// Only an environment variable's string is read as a flag's spelling.
|
|
211
|
+
["requireAuthorizationResponseIss", "1"],
|
|
212
|
+
["requireAuthorizationResponseIss", "[true]"],
|
|
213
|
+
])(
|
|
214
|
+
"leaves %s, a key of the entry's type, to the type's schema, which core refuses it by, at its path",
|
|
215
|
+
async (key, value) => {
|
|
216
|
+
const err = await refusal({ operatorHocon: `core.federations.google.${key} = ${value}\n` });
|
|
217
|
+
expect(err.reason).toBe("config-validation-failed");
|
|
218
|
+
expect(err.message).toContain(`core.federations.google.${key}`);
|
|
219
|
+
},
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
describe("an enabled Google federation's credentials", () => {
|
|
224
|
+
it.each([
|
|
225
|
+
["unset", undefined],
|
|
226
|
+
["exported empty", ""],
|
|
227
|
+
])(
|
|
228
|
+
"refuse the boot when the client secret is %s, at its path, quoting no value of the entry",
|
|
229
|
+
async (_case, secret) => {
|
|
230
|
+
const { CORE_FEDERATIONS_GOOGLE_CLIENT_SECRET: _shipped, ...env } = SINGLE_ENV;
|
|
231
|
+
const err = await refusal({
|
|
232
|
+
env: secret === undefined ? env : { ...env, CORE_FEDERATIONS_GOOGLE_CLIENT_SECRET: secret },
|
|
233
|
+
});
|
|
234
|
+
expect(err.reason).toBe("config-validation-failed");
|
|
235
|
+
expect(err.message).toContain("core.federations.google.clientSecret");
|
|
236
|
+
expect(err.message).not.toContain(SINGLE_ENV.CORE_FEDERATIONS_GOOGLE_CLIENT_ID as string);
|
|
237
|
+
},
|
|
238
|
+
);
|
|
239
|
+
});
|