@o3co/create-auth-provider 0.15.0 → 0.16.0

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 (41) hide show
  1. package/README.ja.md +93 -47
  2. package/README.md +92 -46
  3. package/package.json +1 -1
  4. package/templates/standalone/.env.example +12 -0
  5. package/templates/standalone/Dockerfile +1 -1
  6. package/templates/standalone/README.ja.md +516 -154
  7. package/templates/standalone/README.md +218 -91
  8. package/templates/standalone/config/application.conf +26 -3
  9. package/templates/standalone/gitignore +3 -2
  10. package/templates/standalone/package.json +1 -1
  11. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +49 -15
  12. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +1103 -0
  13. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +241 -0
  14. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +1100 -0
  15. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +19 -5
  16. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +64 -3
  17. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +15 -0
  18. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +133 -0
  19. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +28 -0
  20. package/templates/standalone/src/__tests__/google-session.fixture.mts +304 -0
  21. package/templates/standalone/src/__tests__/host-routes.test.mts +141 -0
  22. package/templates/standalone/src/__tests__/listen.test.mts +128 -0
  23. package/templates/standalone/src/__tests__/logger-projection.test.mts +77 -0
  24. package/templates/standalone/src/__tests__/metrics.test.mts +20 -5
  25. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +187 -0
  26. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +139 -0
  27. package/templates/standalone/src/__tests__/replica-safety.test.mts +13 -3
  28. package/templates/standalone/src/__tests__/router-error-answers.test.mts +149 -0
  29. package/templates/standalone/src/__tests__/shutdown.test.mts +157 -20
  30. package/templates/standalone/src/__tests__/smoke.test.mts +39 -20
  31. package/templates/standalone/src/__tests__/store-credential.test.mts +276 -0
  32. package/templates/standalone/src/app.mts +21 -50
  33. package/templates/standalone/src/buildModules.mts +33 -16
  34. package/templates/standalone/src/listen.mts +66 -0
  35. package/templates/standalone/src/logger.mts +7 -4
  36. package/templates/standalone/src/modules.mts +42 -8
  37. package/templates/standalone/src/routes.mts +95 -0
  38. package/templates/standalone/src/shutdown.mts +20 -14
  39. package/templates/versions.json +15 -15
  40. package/templates/standalone/src/__tests__/terminal-error.test.mts +0 -96
  41. package/templates/standalone/src/terminalError.mts +0 -60
@@ -52,6 +52,7 @@ import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "../configPa
52
52
  const standaloneDir = fileURLToPath(new URL("../..", import.meta.url));
53
53
  const configDir = fileURLToPath(new URL("../../config", import.meta.url));
54
54
  const readmePath = fileURLToPath(new URL("../../README.md", import.meta.url));
55
+ const readmeJaPath = fileURLToPath(new URL("../../README.ja.md", import.meta.url));
55
56
 
56
57
  /**
57
58
  * Every environment variable the shipped artifact documents, with a value in
@@ -194,6 +195,7 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
194
195
  FEDERATIONS_GOOGLE_CLIENT_ID: "google-client-id",
195
196
  FEDERATIONS_GOOGLE_CLIENT_SECRET: "google-client-secret",
196
197
  FEDERATIONS_GOOGLE_CALLBACK_URL: "https://auth.test/session/oauth/federation/google/callback",
198
+ FEDERATIONS_GOOGLE_ACCESS_TYPE: "online",
197
199
  // #524: the generic OIDC federation the template ships disabled.
198
200
  FEDERATIONS_OIDC_ENABLED: "true",
199
201
  FEDERATIONS_OIDC_ISSUER: "https://idp.test",
@@ -214,6 +216,8 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
214
216
  "https://users.example.com/find-subject-by-federated-identity",
215
217
  CLIENT_USER_TIMEOUT: "5000",
216
218
  CLIENT_USER_MAX_RESPONSE_BYTES: "1048576",
219
+ // The credential the http user adapter presents to the Store; >= 32 bytes.
220
+ CLIENT_USER_BEARER_TOKEN: "0328d706529061d93abd6d826e09ef0f0a1e71a12af813b29e5cd2977b7dc63a",
217
221
  CLIENT_CODE_TYPE: "redis",
218
222
  CLIENT_CODE_DEFAULT_EXPIRES_IN: "600",
219
223
  CLIENT_CODE_ENDPOINT_URI: "redis://redis:6379",
@@ -306,11 +310,9 @@ function substitutionsIn(path: string): Set<string> {
306
310
  * The underscore is what separates an environment variable from the HTTP
307
311
  * methods in the endpoint table, which share the shouting-case shape.
308
312
  */
309
- function documentedInReadme(): Set<string> {
313
+ function documentedInReadme(path: string = readmePath): Set<string> {
310
314
  const found = new Set<string>();
311
- for (const match of readFileSync(readmePath, "utf8").matchAll(
312
- /^\|\s*`([A-Z][A-Z0-9_]*)`\s*\|/gm,
313
- )) {
315
+ for (const match of readFileSync(path, "utf8").matchAll(/^\|\s*`([A-Z][A-Z0-9_]*)`\s*\|/gm)) {
314
316
  const name = match[1] as string;
315
317
  if (name.includes("_")) found.add(name);
316
318
  }
@@ -351,6 +353,7 @@ describe("#288: the shipped config boots with every documented override supplied
351
353
  expect(config.http.port).toBe(3000);
352
354
  expect(config.http.readinessTimeoutMs).toBe(1500);
353
355
  expect(config.http.trustProxy).toEqual(["10.0.0.0/8", "loopback"]);
356
+ expect(config.federations.google?.accessType).toBe("online");
354
357
  // The new default wins over the deprecated variable, and the parsed
355
358
  // config mirrors it onto the old key for readers that predate the split.
356
359
  expect(resolveAccessTokenLifetime(config)).toEqual({
@@ -522,12 +525,23 @@ describe("#288: the shipped config boots with every documented override supplied
522
525
  });
523
526
 
524
527
  it("covers every environment variable the README documents", () => {
525
- const uncovered = [...documentedInReadme()].filter(
528
+ const uncovered = [...documentedInReadme(), ...documentedInReadme(readmeJaPath)].filter(
526
529
  (name) => !(name in DOCUMENTED_ENV) && !(name in DELIBERATELY_UNSET),
527
530
  );
528
531
  expect(uncovered).toEqual([]);
529
532
  });
530
533
 
534
+ it("documents the same variables in README.md and README.ja.md", () => {
535
+ // The Japanese README carries the same facts (AGENTS.md): a row added
536
+ // to one and not the other is a variable one audience never hears of.
537
+ const en = documentedInReadme();
538
+ const ja = documentedInReadme(readmeJaPath);
539
+ expect({
540
+ onlyInEnglish: [...en].filter((name) => !ja.has(name)).sort(),
541
+ onlyInJapanese: [...ja].filter((name) => !en.has(name)).sort(),
542
+ }).toEqual({ onlyInEnglish: [], onlyInJapanese: [] });
543
+ });
544
+
531
545
  it("exercises no variable the config layers no longer substitute", () => {
532
546
  // Catches the reverse drift: a README row (and a test entry) left
533
547
  // behind by a key that was removed from the HOCON.
@@ -257,9 +257,10 @@ describe("#593 slice 7: the standalone composes federation grants from its confi
257
257
  );
258
258
  expect(installed).not.toContain("redis-federation-grant-store");
259
259
  expect(installed).not.toContain("redis-federation-grant-intent-store");
260
- // The routes precede oauthModule's body parsers (the package README's
261
- // mounting-order rule), and the browser half sits after the session
262
- // middleware by its own `after`.
260
+ // Listed ahead of oauthModule, as the template writes them — the
261
+ // template's choice, since each module parses its own bodies and the
262
+ // order no longer changes that; the browser half sits after the
263
+ // session middleware by its own `after`.
263
264
  expect(installed.indexOf("federation-grants")).toBeLessThan(installed.indexOf("oauth"));
264
265
 
265
266
  handleRef = await boot(config, true);
@@ -382,3 +383,63 @@ describe("#593 slice 7: the standalone composes federation grants from its confi
382
383
  expect(names(config)).toContain("core-federation-grant-intent-store-memory");
383
384
  });
384
385
  });
386
+
387
+ describe("the browser consent route parses its own body, with sessionModule listed ahead of it", () => {
388
+ // `sessionModule`'s routers are mounted at `/session`, the prefix the
389
+ // federation grants browser half mounts under too. Their parsers ran for
390
+ // every request beneath `/session`, so with `sessionModule` listed first
391
+ // the consent route's body arrived parsed — past its 16 KiB bound, its
392
+ // throttle-then-parse order and its JSON refusals.
393
+ let handleRef: Awaited<ReturnType<typeof boot>> | undefined;
394
+
395
+ afterEach(async () => {
396
+ await handleRef?.dispose();
397
+ handleRef = undefined;
398
+ });
399
+
400
+ /** The standalone's modules, with `sessionModule` moved ahead of the grant modules. */
401
+ const sessionFirst = (config: AppConfig) => {
402
+ const modules = modulesFor(config, true);
403
+ const session = modules.find((m) => m.name === "session");
404
+ if (session === undefined) throw new Error("sessionModule is not in the standalone's list");
405
+ const rest = modules.filter((m) => m !== session);
406
+ const at = rest.findIndex((m) => m.name.startsWith("federation-grant"));
407
+ return [...rest.slice(0, at), session, ...rest.slice(at)];
408
+ };
409
+
410
+ const bootSessionFirst = async () => {
411
+ const config = resolveConfig({ ...BASE_ENV, ...GRANTS_ON });
412
+ handleRef = await createApp({
413
+ modules: sessionFirst(config),
414
+ bootstrapComponents: { config, pathResolver: (s) => s },
415
+ });
416
+ return express().use(handleRef.router);
417
+ };
418
+
419
+ const CONSENT = "/session/federation-grants/consent";
420
+
421
+ it("refuses a body over 16 KiB with the route's own 413", async () => {
422
+ const app = await bootSessionFirst();
423
+
424
+ const res = await request(app)
425
+ .post(CONSENT)
426
+ .set("Content-Type", "application/json")
427
+ .send(JSON.stringify({ challenge: "c", decision: "accept", pad: "a".repeat(40 * 1024) }));
428
+
429
+ expect(res.status).toBe(413);
430
+ expect(res.body).toEqual({ error: "invalid_request", error_description: "body_too_large" });
431
+ });
432
+
433
+ it("refuses malformed JSON with the route's own 400", async () => {
434
+ const app = await bootSessionFirst();
435
+
436
+ const res = await request(app)
437
+ .post(CONSENT)
438
+ .set("Content-Type", "application/json")
439
+ .send("{not json");
440
+
441
+ expect(res.status).toBe(400);
442
+ expect(res.body).toEqual({ error: "invalid_request", error_description: "malformed_body" });
443
+ expect(res.headers["cache-control"]).toContain("no-store");
444
+ });
445
+ });
@@ -236,6 +236,7 @@ async function seedGrant(store: MemoryFederationGrantStore): Promise<void> {
236
236
  consent: { at, sid: "sid-consent", scopes: [...SCOPES] },
237
237
  authorizedAt: at,
238
238
  expiresAt: new Date(at.getTime() + 30 * DAY),
239
+ resource: undefined,
239
240
  },
240
241
  credentials: {
241
242
  refreshToken: REFRESH_TOKEN,
@@ -389,6 +390,13 @@ describe("#593 AC8: a grant outlives the browser session at both logout endpoint
389
390
  await federationTokenStore.attach(sid, "upstream", {
390
391
  accessToken: "session-bound-upstream-token",
391
392
  expiresAt: null,
393
+ // Every field is a required key since the #645 follow-up; this
394
+ // record carries only the access token.
395
+ refreshToken: undefined,
396
+ idToken: undefined,
397
+ tokenType: undefined,
398
+ scope: undefined,
399
+ grantedScope: undefined,
392
400
  });
393
401
  expect(await userSessionStore.get(sid)).not.toBeNull();
394
402
  await expectGrantUntouched(app, federationGrantStore);
@@ -414,6 +422,13 @@ describe("#593 AC8: a grant outlives the browser session at both logout endpoint
414
422
  await federationTokenStore.attach(sid, "upstream", {
415
423
  accessToken: "session-bound-upstream-token",
416
424
  expiresAt: null,
425
+ // Every field is a required key since the #645 follow-up; this
426
+ // record carries only the access token.
427
+ refreshToken: undefined,
428
+ idToken: undefined,
429
+ tokenType: undefined,
430
+ scope: undefined,
431
+ grantedScope: undefined,
417
432
  });
418
433
  expect(await userSessionStore.get(sid)).not.toBeNull();
419
434
  await expectGrantUntouched(app, federationGrantStore);
@@ -0,0 +1,133 @@
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 — and which the route used to answer `500
30
+ * refresh_failed`, as if the upstream had said something it could not place.
31
+ */
32
+
33
+ import { createFakeIdp, type FakeIdp } from "@o3co/auth-provider-core/testing";
34
+ import request from "supertest";
35
+ import { afterEach, describe, expect, it } from "vitest";
36
+ import {
37
+ accessTokenWithFamily,
38
+ GOOGLE_CLIENT_ID,
39
+ type GoogleSession,
40
+ signInLinkedToGoogle,
41
+ } from "./google-session.fixture.mjs";
42
+
43
+ const GOOGLE = {
44
+ issuer: "https://accounts.google.com",
45
+ authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth",
46
+ tokenEndpoint: "https://oauth2.googleapis.com/token",
47
+ jwksUri: "https://www.googleapis.com/oauth2/v3/certs",
48
+ userinfoEndpoint: "https://www.googleapis.com/oauth2/v3/userinfo",
49
+ };
50
+ const STORED_REFRESH_TOKEN = "stored-refresh-token";
51
+
52
+ describe("POST /oauth/federation/google/token: an upstream outage never ends the session's Google tokens", () => {
53
+ let session: GoogleSession | undefined;
54
+
55
+ afterEach(async () => {
56
+ await session?.dispose();
57
+ session = undefined;
58
+ });
59
+
60
+ /**
61
+ * Alice's session with Google linked, its access token expired so the
62
+ * route refreshes, and the fake Google answering the refresh with `status`
63
+ * and `body`.
64
+ */
65
+ async function refreshAnswered(status: number, body: Record<string, unknown>) {
66
+ const idp: FakeIdp = await createFakeIdp({ ...GOOGLE, clientId: GOOGLE_CLIENT_ID });
67
+ idp.tokenStatus = status;
68
+ idp.refusal = body;
69
+ session = await signInLinkedToGoogle({
70
+ google: { fetch: idp.fetch },
71
+ tokens: {
72
+ accessToken: "expired-upstream-access-token",
73
+ expiresAt: new Date(Date.now() - 60_000),
74
+ refreshToken: STORED_REFRESH_TOKEN,
75
+ idToken: undefined,
76
+ tokenType: "Bearer",
77
+ scope: "openid profile email",
78
+ grantedScope: "openid profile email",
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
+ expect(await sessionFederationIndex.listFederations(sid)).not.toContain("google");
132
+ });
133
+ });
@@ -169,3 +169,31 @@ describe("googleFederationConfigModule — requireAuthorizationResponseIss (#597
169
169
  }
170
170
  });
171
171
  });
172
+
173
+ describe("googleFederationConfigModule — accessType", () => {
174
+ // federation-google asks every sign-in for consent with offline access, so
175
+ // every session gets a refresh token; "online" is the operator's way to
176
+ // keep Google's consent screen to the first sign-in, at the price of no
177
+ // refresh token at all. A bridge that drops the field leaves the operator
178
+ // without that choice.
179
+ it("is absent by default, so the provider's own default (offline) applies", () => {
180
+ expect("accessType" in buildConfig(credentials)).toBe(false);
181
+ });
182
+
183
+ it("leaves a null accessType absent, never forwarded as null, which the provider refuses", () => {
184
+ expect("accessType" in buildConfig({ ...credentials, accessType: null })).toBe(false);
185
+ });
186
+
187
+ it("forwards offline and online", () => {
188
+ expect(buildConfig({ ...credentials, accessType: "online" }).accessType).toBe("online");
189
+ expect(buildConfig({ ...credentials, accessType: "offline" }).accessType).toBe("offline");
190
+ });
191
+
192
+ it("refuses anything else at boot, naming the key", () => {
193
+ for (const bad of ["", "offine", "Online", "true", false, 1]) {
194
+ expect(() => buildConfig({ ...credentials, accessType: bad })).toThrow(
195
+ /federations\.google\.accessType/,
196
+ );
197
+ }
198
+ });
199
+ });
@@ -0,0 +1,304 @@
1
+ /*
2
+ * Copyright 2026 1o1 Co. Ltd.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ /**
18
+ * A browser session with Google linked, on the standalone as a deployment
19
+ * composes it: `oauthModule`, the session module and the real Google adapter,
20
+ * booted through `createApp` under the shipped configuration.
21
+ *
22
+ * The routes that act on a session's federation — the two logout routes and
23
+ * `POST /oauth/federation/:name/token` — live in `@o3co/auth-provider-oauth`,
24
+ * which depends on no adapter, so its own tests stand one in. What they hand
25
+ * the adapter, and what they make of what its library raises, can only be
26
+ * seen here.
27
+ *
28
+ * Not `all-modules-composition.fixture.mts`: that one switches every module
29
+ * on and signs with a key it keeps to itself. The tests here present an
30
+ * `id_token_hint` and an access token of their own — a caller's, with the
31
+ * claims the case needs — so the deployment signs with an HS256 secret this
32
+ * file holds, and nothing but Google is federated.
33
+ */
34
+
35
+ import { createHmac } from "node:crypto";
36
+ import { fileURLToPath } from "node:url";
37
+ import {
38
+ type AppConfig,
39
+ AppConfigSchema,
40
+ createApp,
41
+ createKeyStoreFactory,
42
+ defineModule,
43
+ type FederationTokenStore,
44
+ type FederationTokens,
45
+ InMemoryClientRepository,
46
+ InMemoryUserRepository,
47
+ memoryRefreshTokenFamilyStoreModule,
48
+ registerBuiltinKeyStores,
49
+ type SessionFederationIndex,
50
+ type UserSessionStore,
51
+ } from "@o3co/auth-provider-core";
52
+ import {
53
+ type GoogleProviderConfig,
54
+ googleFederationModule,
55
+ } from "@o3co/auth-provider-federation-google";
56
+ import { parseFile } from "@o3co/ts.hocon";
57
+ import { validate } from "@o3co/ts.hocon/zod";
58
+ import express from "express";
59
+ import request from "supertest";
60
+ import { expect } from "vitest";
61
+ import { buildModules } from "#/buildModules.mjs";
62
+ import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "#/configPath.mjs";
63
+
64
+ const configDir = fileURLToPath(new URL("../../config", import.meta.url));
65
+
66
+ export const ISSUER = "https://auth.test";
67
+ const JWT_SECRET = "google-session.fixture.at-least-32-bytes.ok";
68
+
69
+ /** The one client: first-party, allowed federation tokens, one post-logout URI registered. */
70
+ export const CLIENT_ID = "rp";
71
+ const CLIENT_SECRET = "rp-secret-value";
72
+ const BASIC = `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64")}`;
73
+ export const REGISTERED_POST_LOGOUT_REDIRECT_URI = "https://rp.example/signed-out";
74
+
75
+ const USERNAME = "alice";
76
+ const PASSWORD = "correct-horse-battery-staple";
77
+ const SUB = "u-alice";
78
+
79
+ /** The Google client the adapter is configured with. */
80
+ export const GOOGLE_CLIENT_ID = "google-client";
81
+ const GOOGLE_CLIENT_SECRET = "google-secret";
82
+ const GOOGLE_CALLBACK = `${ISSUER}/session/oauth/federation/google/callback`;
83
+
84
+ const ENV: Readonly<Record<string, string>> = {
85
+ OAUTH_JWT_ALGORITHM: "HS256",
86
+ OAUTH_JWT_SECRET: JWT_SECRET,
87
+ OAUTH_JWT_ISSUER: ISSUER,
88
+ SESSION_SECRET: "google-session.fixture-session.at-least-32-bytes.ok",
89
+ SESSION_SECURE: "false",
90
+ SESSION_NAME: "auth.session",
91
+ SESSION_STORAGE_TYPE: "memory",
92
+ CLIENT_USER_TYPE: "yaml",
93
+ REFRESH_TOKEN_FAMILY_STORE_REDIS_URL: "redis://redis.test:6379",
94
+ USER_SESSION_STORES_ADAPTER: "memory",
95
+ RATE_LIMITER_ADAPTER: "memory",
96
+ OAUTH_CODE_ADAPTER: "memory",
97
+ ACCESS_TOKEN_DENYLIST_ADAPTER: "memory",
98
+ REPLAY_SEEN_SET_ADAPTER: "memory",
99
+ FEDERATION_TOKEN_STORE_TYPE: "memory",
100
+ CONSENT_STORE_ADAPTER: "none",
101
+ };
102
+
103
+ /**
104
+ * `"shipped"`: Google switched on the way an operator does, through its
105
+ * environment variables and the template's own configuration bridge — which
106
+ * has no setting for an end-session endpoint or a `fetch`. Otherwise the
107
+ * adapter's configuration beyond its credentials, composed with
108
+ * `googleFederationModule` directly.
109
+ */
110
+ export type GoogleWiring =
111
+ | "shipped"
112
+ | Omit<GoogleProviderConfig, "clientId" | "clientSecret" | "callbackURL">;
113
+
114
+ function resolveConfig(google: GoogleWiring): AppConfig {
115
+ const env: Record<string, string> =
116
+ google === "shipped"
117
+ ? {
118
+ ...ENV,
119
+ FEDERATIONS_GOOGLE_ENABLED: "true",
120
+ FEDERATIONS_GOOGLE_CLIENT_ID: GOOGLE_CLIENT_ID,
121
+ FEDERATIONS_GOOGLE_CLIENT_SECRET: GOOGLE_CLIENT_SECRET,
122
+ FEDERATIONS_GOOGLE_CALLBACK_URL: GOOGLE_CALLBACK,
123
+ }
124
+ : { ...ENV };
125
+ const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
126
+ return validate(
127
+ parseFile(envConfPath, { env })
128
+ .withFallback(parseFile(applicationConfPath, { env }))
129
+ .withFallback(parseFile(resolveLibraryReferenceConfPath(), { env })),
130
+ AppConfigSchema,
131
+ ) as AppConfig;
132
+ }
133
+
134
+ const testRepositoriesModule = defineModule({
135
+ name: "test:repositories",
136
+ provides: {
137
+ clientRepository: () =>
138
+ new InMemoryClientRepository(
139
+ new Map([
140
+ [
141
+ CLIENT_ID,
142
+ {
143
+ tokenEndpointAuthMethod: "client_secret_basic",
144
+ clientSecret: CLIENT_SECRET,
145
+ allowedRedirectUris: [],
146
+ allowedScopes: ["openid", "profile"],
147
+ allowedAudiences: [],
148
+ allowedGrantTypes: ["session"],
149
+ postLogoutRedirectUris: [REGISTERED_POST_LOGOUT_REDIRECT_URI],
150
+ backchannelLogoutSessionRequired: true,
151
+ frontchannelLogoutSessionRequired: true,
152
+ allowedAzpForFederationToken: true,
153
+ },
154
+ ],
155
+ ]),
156
+ ),
157
+ userRepository: () =>
158
+ new InMemoryUserRepository(
159
+ new Map([[USERNAME, { id: SUB, password: PASSWORD, email: "alice@example.com" }]]),
160
+ ),
161
+ } as never,
162
+ });
163
+
164
+ const testKeyStoreModule = defineModule({
165
+ name: "test:key-store",
166
+ requires: ["config"] as const,
167
+ provides: {
168
+ keyStore: async ({ config: c }) => {
169
+ const factory = createKeyStoreFactory();
170
+ registerBuiltinKeyStores(factory);
171
+ return factory.create({
172
+ type: "local",
173
+ ...((c as AppConfig).oauth.jwt.signingKey.local ?? {}),
174
+ });
175
+ },
176
+ },
177
+ });
178
+
179
+ const base64url = (value: unknown): string =>
180
+ Buffer.from(JSON.stringify(value)).toString("base64url");
181
+
182
+ /** A JWT signed with the deployment's own HS256 key: what its keystore verifies. */
183
+ function signed(typ: string, claims: Record<string, unknown>): string {
184
+ const header = base64url({ alg: "HS256", kid: "v0", typ });
185
+ const now = Math.floor(Date.now() / 1000);
186
+ const payload = base64url({ iss: ISSUER, sub: SUB, iat: now, exp: now + 3600, ...claims });
187
+ const signature = createHmac("sha256", JWT_SECRET)
188
+ .update(`${header}.${payload}`)
189
+ .digest("base64url");
190
+ return `${header}.${payload}.${signature}`;
191
+ }
192
+
193
+ /**
194
+ * A fresh id_token for the session, issued to the client: what an RP — or
195
+ * anyone holding an id_token of their own — presents as `id_token_hint`.
196
+ */
197
+ export const idTokenHint = (sid: string): string => signed("JWT", { aud: CLIENT_ID, sid });
198
+
199
+ /**
200
+ * An access token for the session as the authorization-code grant issues it
201
+ * to the client: `azp`, `sid` and a refresh-token `family_id`. The `session`
202
+ * grant's token names no family, and the federation token route asks for one.
203
+ */
204
+ export const accessTokenWithFamily = (sid: string): string =>
205
+ signed("at+jwt", { azp: CLIENT_ID, sid, family_id: "family-1", scope: "openid" });
206
+
207
+ const claimsOf = (jwt: string): Record<string, unknown> =>
208
+ JSON.parse(Buffer.from(jwt.split(".")[1] ?? "", "base64url").toString("utf8")) as Record<
209
+ string,
210
+ unknown
211
+ >;
212
+
213
+ export interface GoogleSession {
214
+ readonly app: express.Express;
215
+ readonly sid: string;
216
+ /** The access token the `session` grant minted for the session; it carries `azp`. */
217
+ readonly accessToken: string;
218
+ readonly userSessionStore: UserSessionStore;
219
+ readonly sessionFederationIndex: SessionFederationIndex;
220
+ readonly federationTokenStore: FederationTokenStore;
221
+ readonly dispose: () => Promise<void>;
222
+ }
223
+
224
+ /**
225
+ * Boots the deployment, signs alice in with her password, and links Google to
226
+ * her session as a federated sign-in would: the session's federation index
227
+ * names it and the token store holds `tokens`.
228
+ */
229
+ export async function signInLinkedToGoogle(options: {
230
+ readonly google: GoogleWiring;
231
+ readonly tokens: FederationTokens;
232
+ }): Promise<GoogleSession> {
233
+ const { google, tokens } = options;
234
+ const config = resolveConfig(google);
235
+ const googleModules =
236
+ google === "shipped"
237
+ ? []
238
+ : [
239
+ googleFederationModule,
240
+ defineModule({
241
+ name: "test:google-federation-config",
242
+ provides: {
243
+ googleFederationConfig: (): GoogleProviderConfig => ({
244
+ clientId: GOOGLE_CLIENT_ID,
245
+ clientSecret: GOOGLE_CLIENT_SECRET,
246
+ callbackURL: GOOGLE_CALLBACK,
247
+ ...google,
248
+ }),
249
+ },
250
+ }),
251
+ ];
252
+ const handle = await createApp({
253
+ modules: [
254
+ ...buildModules(config, {
255
+ keyStoreModule: testKeyStoreModule,
256
+ repositoriesModule: testRepositoriesModule,
257
+ refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
258
+ }),
259
+ ...googleModules,
260
+ ],
261
+ bootstrapComponents: { config, pathResolver: (s) => s },
262
+ });
263
+ try {
264
+ const components = handle.components as {
265
+ userSessionStore: UserSessionStore;
266
+ sessionFederationIndex: SessionFederationIndex;
267
+ federationTokenStore: FederationTokenStore;
268
+ };
269
+ const app = express();
270
+ app.use(handle.router);
271
+
272
+ const csrfRes = await request(app).get("/session/csrf");
273
+ expect(csrfRes.status).toBe(200);
274
+ const loginRes = await request(app)
275
+ .post("/session/login")
276
+ .set("Cookie", csrfRes.headers["set-cookie"] as unknown as string[])
277
+ .set(csrfRes.body.header_name as string, csrfRes.body.csrf_token as string)
278
+ .type("form")
279
+ .send({ username: USERNAME, password: PASSWORD });
280
+ expect(loginRes.status).toBe(200);
281
+
282
+ const tokenRes = await request(app)
283
+ .post("/oauth/token")
284
+ .set("Authorization", BASIC)
285
+ .set("Cookie", loginRes.headers["set-cookie"] as unknown as string[])
286
+ .type("form")
287
+ .send({ grant_type: "session" });
288
+ expect(tokenRes.status).toBe(200);
289
+ const accessToken = tokenRes.body.access_token as string;
290
+ const claims = claimsOf(accessToken);
291
+ expect(claims.azp).toBe(CLIENT_ID);
292
+ const sid = claims.sid as string;
293
+ expect(typeof sid).toBe("string");
294
+
295
+ const session = await components.userSessionStore.get(sid);
296
+ if (session === null) throw new Error("fixture: the sign-in left no session");
297
+ await components.sessionFederationIndex.addFederation(sid, "google", session.expiresAt);
298
+ await components.federationTokenStore.attach(sid, "google", tokens);
299
+ return { app, sid, accessToken, ...components, dispose: () => handle.dispose() };
300
+ } catch (error) {
301
+ await handle.dispose();
302
+ throw error;
303
+ }
304
+ }