@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.
Files changed (92) hide show
  1. package/README.ja.md +139 -48
  2. package/README.md +138 -47
  3. package/dist/index.d.mts +33 -10
  4. package/dist/index.d.mts.map +1 -1
  5. package/dist/index.mjs +118 -59
  6. package/dist/internal/template-filter.d.mts +7 -10
  7. package/dist/internal/template-filter.d.mts.map +1 -1
  8. package/dist/internal/template-filter.mjs +7 -10
  9. package/package.json +6 -4
  10. package/templates/standalone/.dockerignore +15 -2
  11. package/templates/standalone/.env.example +69 -24
  12. package/templates/standalone/Dockerfile +7 -3
  13. package/templates/standalone/README.ja.md +677 -184
  14. package/templates/standalone/README.md +699 -238
  15. package/templates/standalone/config/application.conf +203 -325
  16. package/templates/standalone/config/clients.yaml +2 -0
  17. package/templates/standalone/config/clients.yaml.example +4 -0
  18. package/templates/standalone/config/development.conf +13 -4
  19. package/templates/standalone/config/mailpit.conf +8 -0
  20. package/templates/standalone/config/production.conf +5 -6
  21. package/templates/standalone/config/reference.conf +399 -0
  22. package/templates/standalone/docker-compose.mailpit.yml +36 -0
  23. package/templates/standalone/docker-compose.production.yml +58 -30
  24. package/templates/standalone/docker-compose.yml +13 -3
  25. package/templates/standalone/gitignore +4 -2
  26. package/templates/standalone/package.json +9 -6
  27. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +65 -32
  28. package/templates/standalone/src/__tests__/adapters.test.mts +358 -0
  29. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +1178 -0
  30. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +256 -0
  31. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +1119 -0
  32. package/templates/standalone/src/__tests__/audit-sink.test.mts +78 -76
  33. package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
  34. package/templates/standalone/src/__tests__/core-federations.test.mts +226 -0
  35. package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
  36. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +9 -14
  37. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +672 -271
  38. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +480 -78
  39. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +102 -66
  40. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +135 -0
  41. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +49 -18
  42. package/templates/standalone/src/__tests__/google-session.fixture.mts +315 -0
  43. package/templates/standalone/src/__tests__/host-routes.test.mts +134 -0
  44. package/templates/standalone/src/__tests__/library-references.fixture.mts +183 -0
  45. package/templates/standalone/src/__tests__/listen.test.mts +121 -0
  46. package/templates/standalone/src/__tests__/logger-projection.test.mts +77 -0
  47. package/templates/standalone/src/__tests__/mail-sender.test.mts +134 -0
  48. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +328 -0
  49. package/templates/standalone/src/__tests__/metrics.test.mts +17 -7
  50. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
  51. package/templates/standalone/src/__tests__/mfa-switch.test.mts +780 -0
  52. package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +52 -46
  53. package/templates/standalone/src/__tests__/own-modules.test.mts +1457 -0
  54. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +183 -0
  55. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +135 -0
  56. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +112 -81
  57. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
  58. package/templates/standalone/src/__tests__/replica-safety.test.mts +166 -145
  59. package/templates/standalone/src/__tests__/router-error-answers.test.mts +146 -0
  60. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +275 -95
  61. package/templates/standalone/src/__tests__/sections.test.mts +480 -0
  62. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +67 -55
  63. package/templates/standalone/src/__tests__/session-requirements.test.mts +299 -0
  64. package/templates/standalone/src/__tests__/shutdown.test.mts +319 -115
  65. package/templates/standalone/src/__tests__/smoke.test.mts +179 -233
  66. package/templates/standalone/src/__tests__/store-credential.test.mts +317 -0
  67. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
  68. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +108 -95
  69. package/templates/standalone/src/__tests__/two-phase-config.test.mts +438 -0
  70. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +390 -0
  71. package/templates/standalone/src/adapters.mts +149 -0
  72. package/templates/standalone/src/app.mts +71 -115
  73. package/templates/standalone/src/buildModules.mts +229 -251
  74. package/templates/standalone/src/configPath.mts +422 -29
  75. package/templates/standalone/src/listen.mts +60 -0
  76. package/templates/standalone/src/logger.mts +31 -57
  77. package/templates/standalone/src/metrics.mts +24 -47
  78. package/templates/standalone/src/mfaSwitch.mts +262 -0
  79. package/templates/standalone/src/modules.mts +480 -464
  80. package/templates/standalone/src/rootRenames.mts +79 -0
  81. package/templates/standalone/src/routes.mts +89 -0
  82. package/templates/standalone/src/sections.mts +366 -0
  83. package/templates/standalone/src/shutdown.mts +54 -126
  84. package/templates/standalone/tests/index.test.js +2 -2
  85. package/templates/standalone/tests/package-lock.json +1615 -2223
  86. package/templates/standalone/tests/package.json +2 -2
  87. package/templates/standalone/tests/vitest.config.mjs +26 -0
  88. package/templates/standalone/vitest.config.mts +8 -7
  89. package/templates/standalone/vitest.supertest-loopback.mts +97 -69
  90. package/templates/versions.json +18 -15
  91. package/templates/standalone/src/__tests__/terminal-error.test.mts +0 -96
  92. package/templates/standalone/src/terminalError.mts +0 -60
@@ -15,22 +15,20 @@
15
15
  */
16
16
 
17
17
  /**
18
- * #593 acceptance criterion 8, at the two HTTP logout endpoints (D14).
19
- *
20
- * A federation grant outlives the session it was agreed through. The proof of
21
- * that used to be a direct call to `cascadeLogout()` — which is the cascade
22
- * `/oauth/logout` runs, and not what `/session/logout` runs: that endpoint has
23
- * its own record hygiene (`invalidateSessionRecords`) and reaches the grant
24
- * store on no path, by construction. A proof of the helper is not a proof of
25
- * the endpoints. So this drives both, on the standalone, as a deployment
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
+ * `/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 — the OIDC federation the connection names is
32
- * configured with `discovery = false` and hand-typed endpoints so that the
33
- * deployment boots without a network.
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, resolveLibraryReferenceConfPath } from "../configPath.mjs";
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
- OAUTH_JWT_ALGORITHM: "HS256",
87
- OAUTH_JWT_SECRET: JWT_SECRET,
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
- SESSION_SECRET: "federation-grants-survive-logout-session.at-least-32-bytes.ok",
90
- SESSION_SECURE: "false",
91
- SESSION_NAME: "auth.session",
92
- SESSION_STORAGE_TYPE: "memory",
93
- CLIENT_USER_TYPE: "yaml",
94
- REFRESH_TOKEN_FAMILY_STORE_REDIS_URL: "redis://redis.test:6379",
95
- USER_SESSION_STORES_ADAPTER: "memory",
96
- RATE_LIMITER_ADAPTER: "memory",
97
- OAUTH_CODE_ADAPTER: "memory",
98
- ACCESS_TOKEN_DENYLIST_ADAPTER: "memory",
99
- REPLAY_SEEN_SET_ADAPTER: "memory",
100
- FEDERATION_TOKEN_STORE_TYPE: "memory",
101
- CONSENT_STORE_ADAPTER: "none",
102
- FEDERATION_GRANT_STORE_ADAPTER: "memory",
103
- FEDERATION_GRANT_INTENT_STORE_ADAPTER: "memory",
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 (#613), and what lets a connection be configured at all.
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 (comment 9 on #593) written over it: the
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(): AppConfig {
125
+ function resolveConfig(): Switches {
118
126
  const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
119
- const config = validate(
120
- parseFile(envConfPath, { env: ENV })
121
- .withFallback(parseFile(applicationConfPath, { env: ENV }))
122
- .withFallback(parseFile(resolveLibraryReferenceConfPath(), { env: ENV })),
123
- AppConfigSchema,
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
- federations: {
128
- ...config.federations,
129
- upstream: {
130
- enabled: true,
131
- type: "oidc",
132
- issuer: UPSTREAM,
133
- clientId: UPSTREAM_CLIENT_ID,
134
- clientSecret: "provider-secret",
135
- callbackURL: `${ISSUER}/session/oauth/federation/upstream/callback`,
136
- scopes: [...SCOPES],
137
- discovery: false,
138
- endpoints: {
139
- authorizationEndpoint: `${UPSTREAM}/authorize`,
140
- tokenEndpoint: `${UPSTREAM}/token`,
141
- jwksUri: `${UPSTREAM}/jwks`,
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
- federationGrants: {
147
- ...config.federationGrants,
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 AppConfig;
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 AppConfig).oauth.jwt.signingKey.local ?? {}),
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("#593 AC8: a grant outlives the browser session at both logout endpoints (D14)", () => {
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.session.name}.csrf=`;
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: the record #276's
388
- // guarantee is about, keyed by this session.
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 (#276 holds) ...
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
- * #278: the bridge used to return only `{ clientId, clientSecret, callbackURL }`,
19
- * dropping the four fields the redirect policy reads. Nothing failed — the
20
- * policy was simply constructed with an empty config, and the generated app
21
- * silently ran without a redirect allowlist. These tests fail if the bridge
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` section in the flat shape. */
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({ config: { federations: { google: { enabled: true, ...googleSection } } } });
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 (#278)", () => {
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
- federations: {
101
- google: {
102
- enabled: true,
103
- type: "google",
104
- sessionDomain: ".example.com",
105
- redirectAllowlist: ["https://app.example.com/dashboard"],
106
- google: credentials,
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 (#597)", () => {
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
  });