@o3co/create-auth-provider 0.15.0 → 0.17.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +139 -48
- package/README.md +138 -47
- package/dist/index.d.mts +33 -10
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +118 -59
- package/dist/internal/template-filter.d.mts +7 -10
- package/dist/internal/template-filter.d.mts.map +1 -1
- package/dist/internal/template-filter.mjs +7 -10
- package/package.json +6 -4
- package/templates/standalone/.dockerignore +15 -2
- package/templates/standalone/.env.example +69 -24
- package/templates/standalone/Dockerfile +7 -3
- package/templates/standalone/README.ja.md +677 -184
- package/templates/standalone/README.md +699 -238
- package/templates/standalone/config/application.conf +203 -325
- package/templates/standalone/config/clients.yaml +2 -0
- package/templates/standalone/config/clients.yaml.example +4 -0
- package/templates/standalone/config/development.conf +13 -4
- package/templates/standalone/config/mailpit.conf +8 -0
- package/templates/standalone/config/production.conf +5 -6
- package/templates/standalone/config/reference.conf +399 -0
- package/templates/standalone/docker-compose.mailpit.yml +36 -0
- package/templates/standalone/docker-compose.production.yml +58 -30
- package/templates/standalone/docker-compose.yml +13 -3
- package/templates/standalone/gitignore +4 -2
- package/templates/standalone/package.json +9 -6
- package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +65 -32
- package/templates/standalone/src/__tests__/adapters.test.mts +358 -0
- package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +1178 -0
- package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +256 -0
- package/templates/standalone/src/__tests__/all-modules-composition.test.mts +1119 -0
- package/templates/standalone/src/__tests__/audit-sink.test.mts +78 -76
- package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
- package/templates/standalone/src/__tests__/core-federations.test.mts +226 -0
- package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
- package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +9 -14
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +672 -271
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +480 -78
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +102 -66
- package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +135 -0
- package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +49 -18
- package/templates/standalone/src/__tests__/google-session.fixture.mts +315 -0
- package/templates/standalone/src/__tests__/host-routes.test.mts +134 -0
- package/templates/standalone/src/__tests__/library-references.fixture.mts +183 -0
- package/templates/standalone/src/__tests__/listen.test.mts +121 -0
- package/templates/standalone/src/__tests__/logger-projection.test.mts +77 -0
- package/templates/standalone/src/__tests__/mail-sender.test.mts +134 -0
- package/templates/standalone/src/__tests__/mailpit-compose.test.mts +328 -0
- package/templates/standalone/src/__tests__/metrics.test.mts +17 -7
- package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
- package/templates/standalone/src/__tests__/mfa-switch.test.mts +780 -0
- package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +52 -46
- package/templates/standalone/src/__tests__/own-modules.test.mts +1457 -0
- package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +183 -0
- package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +135 -0
- package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +112 -81
- package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
- package/templates/standalone/src/__tests__/replica-safety.test.mts +166 -145
- package/templates/standalone/src/__tests__/router-error-answers.test.mts +146 -0
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +275 -95
- package/templates/standalone/src/__tests__/sections.test.mts +480 -0
- package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +67 -55
- package/templates/standalone/src/__tests__/session-requirements.test.mts +299 -0
- package/templates/standalone/src/__tests__/shutdown.test.mts +319 -115
- package/templates/standalone/src/__tests__/smoke.test.mts +179 -233
- package/templates/standalone/src/__tests__/store-credential.test.mts +317 -0
- package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +108 -95
- package/templates/standalone/src/__tests__/two-phase-config.test.mts +438 -0
- package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +390 -0
- package/templates/standalone/src/adapters.mts +149 -0
- package/templates/standalone/src/app.mts +71 -115
- package/templates/standalone/src/buildModules.mts +229 -251
- package/templates/standalone/src/configPath.mts +422 -29
- package/templates/standalone/src/listen.mts +60 -0
- package/templates/standalone/src/logger.mts +31 -57
- package/templates/standalone/src/metrics.mts +24 -47
- package/templates/standalone/src/mfaSwitch.mts +262 -0
- package/templates/standalone/src/modules.mts +480 -464
- package/templates/standalone/src/rootRenames.mts +79 -0
- package/templates/standalone/src/routes.mts +89 -0
- package/templates/standalone/src/sections.mts +366 -0
- package/templates/standalone/src/shutdown.mts +54 -126
- package/templates/standalone/tests/index.test.js +2 -2
- package/templates/standalone/tests/package-lock.json +1615 -2223
- package/templates/standalone/tests/package.json +2 -2
- package/templates/standalone/tests/vitest.config.mjs +26 -0
- package/templates/standalone/vitest.config.mts +8 -7
- package/templates/standalone/vitest.supertest-loopback.mts +97 -69
- package/templates/versions.json +18 -15
- package/templates/standalone/src/__tests__/terminal-error.test.mts +0 -96
- package/templates/standalone/src/terminalError.mts +0 -60
|
@@ -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
|
},
|
|
@@ -236,6 +254,7 @@ async function seedGrant(store: MemoryFederationGrantStore): Promise<void> {
|
|
|
236
254
|
consent: { at, sid: "sid-consent", scopes: [...SCOPES] },
|
|
237
255
|
authorizedAt: at,
|
|
238
256
|
expiresAt: new Date(at.getTime() + 30 * DAY),
|
|
257
|
+
resource: undefined,
|
|
239
258
|
},
|
|
240
259
|
credentials: {
|
|
241
260
|
refreshToken: REFRESH_TOKEN,
|
|
@@ -244,6 +263,7 @@ async function seedGrant(store: MemoryFederationGrantStore): Promise<void> {
|
|
|
244
263
|
tokenType: "Bearer",
|
|
245
264
|
obtainedAt: at,
|
|
246
265
|
issuedLifetime: 3600,
|
|
266
|
+
effectiveExpiresAt: new Date(at.getTime() + 3_600_000),
|
|
247
267
|
scopes: [...SCOPES],
|
|
248
268
|
},
|
|
249
269
|
},
|
|
@@ -283,7 +303,7 @@ const claimsOf = (jwt: string): Record<string, unknown> =>
|
|
|
283
303
|
unknown
|
|
284
304
|
>;
|
|
285
305
|
|
|
286
|
-
describe("
|
|
306
|
+
describe("a grant outlives the browser session at both logout endpoints", () => {
|
|
287
307
|
let handleRef: Awaited<ReturnType<typeof createApp>> | undefined;
|
|
288
308
|
|
|
289
309
|
afterEach(async () => {
|
|
@@ -330,7 +350,7 @@ describe("#593 AC8: a grant outlives the browser session at both logout endpoint
|
|
|
330
350
|
expect(loginRes.status).toBe(200);
|
|
331
351
|
|
|
332
352
|
const cookies = loginRes.headers["set-cookie"] as unknown as string[];
|
|
333
|
-
const csrfCookiePrefix = `${config
|
|
353
|
+
const csrfCookiePrefix = `${(config["session-store"] as { name: string }).name}.csrf=`;
|
|
334
354
|
const reissued = cookies
|
|
335
355
|
.find((c) => c.startsWith(csrfCookiePrefix))
|
|
336
356
|
?.slice(csrfCookiePrefix.length)
|
|
@@ -384,11 +404,19 @@ describe("#593 AC8: a grant outlives the browser session at both logout endpoint
|
|
|
384
404
|
await boot();
|
|
385
405
|
const { cookies, csrfToken, headerName } = await login(app, config);
|
|
386
406
|
const sid = await sidOf(app, cookies);
|
|
387
|
-
// A session-bound upstream token beside the grant
|
|
388
|
-
//
|
|
407
|
+
// A session-bound upstream token beside the grant, keyed by this
|
|
408
|
+
// session: a record logout must end.
|
|
389
409
|
await federationTokenStore.attach(sid, "upstream", {
|
|
390
410
|
accessToken: "session-bound-upstream-token",
|
|
391
411
|
expiresAt: null,
|
|
412
|
+
// Every field is a required key; this record carries only the
|
|
413
|
+
// access token.
|
|
414
|
+
refreshToken: undefined,
|
|
415
|
+
idToken: undefined,
|
|
416
|
+
tokenType: undefined,
|
|
417
|
+
scope: undefined,
|
|
418
|
+
grantedScope: undefined,
|
|
419
|
+
obtainedAt: undefined,
|
|
392
420
|
});
|
|
393
421
|
expect(await userSessionStore.get(sid)).not.toBeNull();
|
|
394
422
|
await expectGrantUntouched(app, federationGrantStore);
|
|
@@ -399,7 +427,7 @@ describe("#593 AC8: a grant outlives the browser session at both logout endpoint
|
|
|
399
427
|
.set(headerName, csrfToken);
|
|
400
428
|
expect([200, 204]).toContain(logout.status);
|
|
401
429
|
|
|
402
|
-
// The session-bound records are gone
|
|
430
|
+
// The session-bound records are gone ...
|
|
403
431
|
expect(await userSessionStore.get(sid)).toBeNull();
|
|
404
432
|
expect(await federationTokenStore.get(sid, "upstream")).toBeNull();
|
|
405
433
|
// ... and the grant is not among them.
|
|
@@ -414,6 +442,14 @@ describe("#593 AC8: a grant outlives the browser session at both logout endpoint
|
|
|
414
442
|
await federationTokenStore.attach(sid, "upstream", {
|
|
415
443
|
accessToken: "session-bound-upstream-token",
|
|
416
444
|
expiresAt: null,
|
|
445
|
+
// Every field is a required key; this record carries only the
|
|
446
|
+
// access token.
|
|
447
|
+
refreshToken: undefined,
|
|
448
|
+
idToken: undefined,
|
|
449
|
+
tokenType: undefined,
|
|
450
|
+
scope: undefined,
|
|
451
|
+
grantedScope: undefined,
|
|
452
|
+
obtainedAt: undefined,
|
|
417
453
|
});
|
|
418
454
|
expect(await userSessionStore.get(sid)).not.toBeNull();
|
|
419
455
|
await expectGrantUntouched(app, federationGrantStore);
|
|
@@ -0,0 +1,135 @@
|
|
|
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
|
+
* `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 (a structured
|
|
20
|
+
* `invalid_grant` under a 4xx), and never while the upstream is down, whatever
|
|
21
|
+
* the body of its 5xx says. An outage is `503`, and the tokens are kept for
|
|
22
|
+
* the retry.
|
|
23
|
+
*
|
|
24
|
+
* Driven through the real Google adapter and its library (openid-client)
|
|
25
|
+
* against a fake Google (`createFakeIdp`), so the errors the route classifies
|
|
26
|
+
* are the ones the library raises: a 4xx whose body names a code is a
|
|
27
|
+
* `ResponseBodyError` carrying `error` and `status`; a 5xx is an
|
|
28
|
+
* `OperationProcessingError` raised over the `Response`, whose body the
|
|
29
|
+
* library does not read.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { createFakeIdp, type FakeIdp } from "@o3co/auth-provider-core/testing";
|
|
33
|
+
import request from "supertest";
|
|
34
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
35
|
+
import {
|
|
36
|
+
accessTokenWithFamily,
|
|
37
|
+
GOOGLE_CLIENT_ID,
|
|
38
|
+
type GoogleSession,
|
|
39
|
+
signInLinkedToGoogle,
|
|
40
|
+
} from "./google-session.fixture.mjs";
|
|
41
|
+
|
|
42
|
+
const GOOGLE = {
|
|
43
|
+
issuer: "https://accounts.google.com",
|
|
44
|
+
authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
45
|
+
tokenEndpoint: "https://oauth2.googleapis.com/token",
|
|
46
|
+
jwksUri: "https://www.googleapis.com/oauth2/v3/certs",
|
|
47
|
+
userinfoEndpoint: "https://www.googleapis.com/oauth2/v3/userinfo",
|
|
48
|
+
};
|
|
49
|
+
const STORED_REFRESH_TOKEN = "stored-refresh-token";
|
|
50
|
+
|
|
51
|
+
describe("POST /oauth/federation/google/token: an upstream outage never ends the session's Google tokens", () => {
|
|
52
|
+
let session: GoogleSession | undefined;
|
|
53
|
+
|
|
54
|
+
afterEach(async () => {
|
|
55
|
+
await session?.dispose();
|
|
56
|
+
session = undefined;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Alice's session with Google linked, its access token expired so the
|
|
61
|
+
* route refreshes, and the fake Google answering the refresh with `status`
|
|
62
|
+
* and `body`.
|
|
63
|
+
*/
|
|
64
|
+
async function refreshAnswered(status: number, body: Record<string, unknown>) {
|
|
65
|
+
const idp: FakeIdp = await createFakeIdp({ ...GOOGLE, clientId: GOOGLE_CLIENT_ID });
|
|
66
|
+
idp.tokenStatus = status;
|
|
67
|
+
idp.refusal = body;
|
|
68
|
+
session = await signInLinkedToGoogle({
|
|
69
|
+
google: { fetch: idp.fetch },
|
|
70
|
+
tokens: {
|
|
71
|
+
accessToken: "expired-upstream-access-token",
|
|
72
|
+
expiresAt: new Date(Date.now() - 60_000),
|
|
73
|
+
refreshToken: STORED_REFRESH_TOKEN,
|
|
74
|
+
idToken: undefined,
|
|
75
|
+
tokenType: "Bearer",
|
|
76
|
+
scope: "openid profile email",
|
|
77
|
+
grantedScope: "openid profile email",
|
|
78
|
+
obtainedAt: undefined,
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
const res = await request(session.app)
|
|
82
|
+
.post("/oauth/federation/google/token")
|
|
83
|
+
.set("Authorization", `Bearer ${accessTokenWithFamily(session.sid)}`)
|
|
84
|
+
.send();
|
|
85
|
+
// The library did ask Google: what follows is its reading of the answer.
|
|
86
|
+
expect(
|
|
87
|
+
idp.requests.filter(
|
|
88
|
+
(r) =>
|
|
89
|
+
`${r.url.origin}${r.url.pathname}` === GOOGLE.tokenEndpoint &&
|
|
90
|
+
r.body?.get("grant_type") === "refresh_token",
|
|
91
|
+
),
|
|
92
|
+
).toHaveLength(1);
|
|
93
|
+
return { res, ...session };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
it("keeps them and answers 503 when Google answers 503 with a body naming invalid_grant", async () => {
|
|
97
|
+
const { res, sid, federationTokenStore, sessionFederationIndex } = await refreshAnswered(503, {
|
|
98
|
+
error: "invalid_grant",
|
|
99
|
+
error_description: "Backend unavailable",
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(res.status).toBe(503);
|
|
103
|
+
expect(res.body).toEqual({
|
|
104
|
+
error: "temporarily_unavailable",
|
|
105
|
+
error_description: "upstream federation provider temporarily unavailable",
|
|
106
|
+
});
|
|
107
|
+
expect((await federationTokenStore.get(sid, "google"))?.refreshToken).toBe(
|
|
108
|
+
STORED_REFRESH_TOKEN,
|
|
109
|
+
);
|
|
110
|
+
expect(await sessionFederationIndex.listFederations(sid)).toContain("google");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("keeps them and answers 503 when Google answers 502 with no OAuth body at all", async () => {
|
|
114
|
+
const { res, sid, federationTokenStore } = await refreshAnswered(502, {});
|
|
115
|
+
|
|
116
|
+
expect(res.status).toBe(503);
|
|
117
|
+
expect((await federationTokenStore.get(sid, "google"))?.refreshToken).toBe(
|
|
118
|
+
STORED_REFRESH_TOKEN,
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("ends them on Google's structured 400 invalid_grant", async () => {
|
|
123
|
+
const { res, sid, federationTokenStore, sessionFederationIndex } = await refreshAnswered(400, {
|
|
124
|
+
error: "invalid_grant",
|
|
125
|
+
error_description: "Token has been expired or revoked.",
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
expect(res.status).toBe(410);
|
|
129
|
+
expect(res.body.error).toBe("re_authentication_required");
|
|
130
|
+
expect(await federationTokenStore.get(sid, "google")).toBeNull();
|
|
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");
|
|
134
|
+
});
|
|
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,42 @@ 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/,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("googleFederationConfigModule — accessType", () => {
|
|
177
|
+
// federation-google asks every sign-in for consent with offline access, so
|
|
178
|
+
// every session gets a refresh token; "online" is the operator's way to
|
|
179
|
+
// keep Google's consent screen to the first sign-in, at the price of no
|
|
180
|
+
// refresh token at all. A bridge that drops the field leaves the operator
|
|
181
|
+
// without that choice.
|
|
182
|
+
it("is absent by default, so the provider's own default (offline) applies", () => {
|
|
183
|
+
expect("accessType" in buildConfig(credentials)).toBe(false);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("leaves a null accessType absent, never forwarded as null, which the provider refuses", () => {
|
|
187
|
+
expect("accessType" in buildConfig({ ...credentials, accessType: null })).toBe(false);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("forwards offline and online", () => {
|
|
191
|
+
expect(buildConfig({ ...credentials, accessType: "online" }).accessType).toBe("online");
|
|
192
|
+
expect(buildConfig({ ...credentials, accessType: "offline" }).accessType).toBe("offline");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("refuses anything else at boot, naming the key", () => {
|
|
196
|
+
for (const bad of ["", "offine", "Online", "true", false, 1]) {
|
|
197
|
+
expect(() => buildConfig({ ...credentials, accessType: bad })).toThrow(
|
|
198
|
+
/core\.federations\.google\.accessType/,
|
|
168
199
|
);
|
|
169
200
|
}
|
|
170
201
|
});
|