@o3co/create-auth-provider 0.17.0-rc.1 → 0.17.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/package.json +1 -1
  2. package/templates/standalone/.env.example +0 -3
  3. package/templates/standalone/README.ja.md +42 -18
  4. package/templates/standalone/README.md +120 -44
  5. package/templates/standalone/config/application.conf +40 -36
  6. package/templates/standalone/config/reference.conf +21 -2
  7. package/templates/standalone/docker-compose.production.yml +5 -2
  8. package/templates/standalone/package.json +1 -1
  9. package/templates/standalone/src/__tests__/adapters.test.mts +80 -19
  10. package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +87 -0
  11. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +112 -98
  12. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +27 -6
  13. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +86 -51
  14. package/templates/standalone/src/__tests__/audit-sink.test.mts +10 -9
  15. package/templates/standalone/src/__tests__/config-notices.test.mts +154 -0
  16. package/templates/standalone/src/__tests__/core-federations.test.mts +48 -25
  17. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +1 -1
  18. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +349 -54
  19. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +15 -21
  20. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +9 -15
  21. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +10 -4
  22. package/templates/standalone/src/__tests__/federation-types.test.mts +239 -0
  23. package/templates/standalone/src/__tests__/google-session.fixture.mts +60 -67
  24. package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +1 -1
  25. package/templates/standalone/src/__tests__/library-references.fixture.mts +51 -46
  26. package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +216 -0
  27. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +0 -2
  28. package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +122 -0
  29. package/templates/standalone/src/__tests__/mfa-switch.test.mts +70 -25
  30. package/templates/standalone/src/__tests__/own-modules.test.mts +40 -11
  31. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +3 -3
  32. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +8 -4
  33. package/templates/standalone/src/__tests__/replica-safety.test.mts +127 -19
  34. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +15 -20
  35. package/templates/standalone/src/__tests__/session-lifecycle.test.mts +215 -0
  36. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +3 -7
  37. package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +130 -0
  38. package/templates/standalone/src/__tests__/session-requirements.test.mts +114 -31
  39. package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +270 -0
  40. package/templates/standalone/src/__tests__/smoke.test.mts +19 -28
  41. package/templates/standalone/src/__tests__/store-credential.test.mts +8 -13
  42. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +50 -34
  43. package/templates/standalone/src/__tests__/two-phase-config.test.mts +242 -76
  44. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +23 -31
  45. package/templates/standalone/src/adapters.mts +31 -18
  46. package/templates/standalone/src/app.mts +6 -5
  47. package/templates/standalone/src/bootRefusal.mts +117 -0
  48. package/templates/standalone/src/buildModules.mts +46 -96
  49. package/templates/standalone/src/configPath.mts +180 -153
  50. package/templates/standalone/src/mfaSwitch.mts +43 -20
  51. package/templates/standalone/src/modules.mts +25 -213
  52. package/templates/standalone/src/rootRenames.mts +35 -18
  53. package/templates/standalone/src/sections.mts +1 -0
  54. package/templates/standalone/tests/index.test.js +4 -3
  55. package/templates/versions.json +18 -18
  56. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +0 -134
  57. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +0 -202
  58. package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +0 -144
@@ -22,8 +22,8 @@
22
22
  * - Every memory branch must be refused by name, and the all-Redis
23
23
  * environment must boot. The replica-safety guard reads each module's
24
24
  * manifest declaration, which covers the template's own in-memory modules
25
- * and express-session's store (`buildModules` builds it from config,
26
- * `sessionStoreModuleFor`).
25
+ * and express-session's store (`sessionStoreModule`, which declares from
26
+ * its own section).
27
27
  * - `federationTokenStore.type = "redis"` mounts
28
28
  * `redisFederationTokenStoreModule` off the shared ioredis socket.
29
29
  * - The Redis federation store's `allow-plaintext` guard reads the
@@ -39,28 +39,28 @@
39
39
 
40
40
  import { fileURLToPath } from "node:url";
41
41
  import {
42
- AppConfigSchema,
43
42
  createApp,
44
43
  createKeyStoreFactory,
45
44
  defineModule,
46
45
  InMemoryClientRepository,
47
46
  InMemoryUserRepository,
47
+ type Module,
48
48
  memoryRefreshTokenFamilyStoreModule,
49
49
  registerBuiltinKeyStores,
50
50
  replicaUnsafeReason,
51
51
  } from "@o3co/auth-provider-core";
52
+ import { sessionStoreModule } from "@o3co/auth-provider-session";
52
53
  import { standardSmtpMailSenderConfigForTests } from "@o3co/auth-provider-standard/testing";
53
54
  import { parseFile } from "@o3co/ts.hocon";
54
- import { validate } from "@o3co/ts.hocon/zod";
55
55
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
56
56
  import { buildModules } from "../buildModules.mjs";
57
- import { resolveConfigPaths, type Switches } from "../configPath.mjs";
57
+ import { resolveConfigPaths } from "../configPath.mjs";
58
58
  import { templateReference } from "../modules.mjs";
59
59
  import {
60
+ type BothPhases,
61
+ bothPhasesOf,
60
62
  capturedRenames,
61
63
  libraryLayers,
62
- rootSectionsOf,
63
- sectionsCoreDoesNotDeclare,
64
64
  } from "./library-references.fixture.mjs";
65
65
 
66
66
  // The redis session-store builder, which the baseline selects, dynamically
@@ -152,6 +152,8 @@ const ALL_REDIS_ENV: Readonly<Record<string, string>> = {
152
152
  REDIS_CLIENTS_URL: "redis://redis.test:6379",
153
153
  ADAPTERS_USER_SESSION_STORES: "redis",
154
154
  ADAPTERS_RATE_LIMITER: "redis",
155
+ // The login's attempt counter, shared: per process, `multi` refuses the login.
156
+ ADAPTERS_ATTEMPT_COUNTER: "redis",
155
157
  ADAPTERS_CODE_REPOSITORY: "redis",
156
158
  ADAPTERS_ACCESS_TOKEN_DENYLIST: "redis",
157
159
  ADAPTERS_REPLAY_SEEN_SET: "redis",
@@ -162,20 +164,17 @@ const ALL_REDIS_ENV: Readonly<Record<string, string>> = {
162
164
  ADAPTERS_CONSENT_STORE: "redis",
163
165
  };
164
166
 
165
- function resolveConfig(env: Record<string, string>): Switches {
167
+ function resolveConfig(env: Record<string, string>): BothPhases {
166
168
  const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
167
169
  const layers = parseFile(envConfPath, { env })
168
170
  .withFallback(parseFile(applicationConfPath, { env }))
169
171
  .withFallback(parseFile(fileURLToPath(templateReference()), { env }))
170
172
  .withFallback(libraryLayers(env));
171
173
  return {
172
- ...sectionsCoreDoesNotDeclare(layers),
173
- ...rootSectionsOf(layers, env),
174
- ...validate(layers, AppConfigSchema),
175
- // What the resolution captured of core's renamed variables, which the
176
- // schema's parse drops.
174
+ ...bothPhasesOf(layers, env),
175
+ // What the resolution captured of the renamed variables.
177
176
  "renamed-variables": capturedRenames(env),
178
- } as Switches;
177
+ } as BothPhases;
179
178
  }
180
179
 
181
180
  /** Drops a variable, so the HOCON default takes over. */
@@ -210,14 +209,21 @@ const testKeyStoreModule = defineModule({
210
209
  },
211
210
  });
212
211
 
213
- const modulesFor = (config: Switches, environment?: string) =>
212
+ const modulesFor = (config: BothPhases, environment?: string) =>
214
213
  buildModules(config, {
215
214
  keyStoreModule: testKeyStoreModule,
216
215
  repositoriesModule: testRepositoriesModule,
217
216
  ...(environment === undefined ? {} : { environment }),
218
217
  });
219
218
 
220
- const boot = (config: Switches, environment?: string) =>
219
+ /**
220
+ * `module`'s section in `config`, for a declaration made from it: each module
221
+ * here that has one sits at its name.
222
+ */
223
+ const sectionOf = (config: BothPhases, module: Module): unknown =>
224
+ (config as unknown as Record<string, unknown>)[module.name];
225
+
226
+ const boot = (config: BothPhases, environment?: string) =>
221
227
  createApp({
222
228
  modules: modulesFor(config, environment),
223
229
  bootstrapComponents: { config, pathResolver: (s) => s },
@@ -242,8 +248,8 @@ describe('the standalone\'s memory modules are refused under core.deployment.mod
242
248
  ["ADAPTERS_USER_SESSION_STORES", "standalone-in-memory-session-stores"],
243
249
  ["ADAPTERS_CODE_REPOSITORY", "standalone-in-memory-code-repository"],
244
250
  ["ADAPTERS_FEDERATION_TOKEN_STORE", "standalone-in-memory-federation-token-store"],
245
- // express-session's own store. Not a module of this template but
246
- // built here from its config, which is what lets the manifest declare.
251
+ // express-session's own store. Not a module of this template: it
252
+ // declares from its own section, which boot parses.
247
253
  ["SESSION_STORE_STORAGE_TYPE", "session-store"],
248
254
  // The consent store, wired only when the switch says so.
249
255
  ["ADAPTERS_CONSENT_STORE", "core-consent-store-memory"],
@@ -311,13 +317,115 @@ describe('the standalone\'s memory modules are refused under core.deployment.mod
311
317
  it("boots the all-Redis environment, with nothing declaring replica-unsafe state", async () => {
312
318
  const config = resolveConfig(ALL_REDIS_ENV);
313
319
  for (const m of modulesFor(config)) {
314
- expect(replicaUnsafeReason(m), m.name).toBeUndefined();
320
+ expect(replicaUnsafeReason(m, sectionOf(config, m)), m.name).toBeUndefined();
315
321
  }
316
322
  handleRef = await boot(config);
317
323
  expect(handleRef).toBeDefined();
318
324
  });
319
325
  });
320
326
 
327
+ describe("express-session's store declares its replica safety from its own section", () => {
328
+ it("is the session package's sessionStoreModule, listed first", () => {
329
+ expect(modulesFor(resolveConfig(ALL_REDIS_ENV))[0]).toBe(sessionStoreModule);
330
+ });
331
+
332
+ /** Each way the storage type is set, laid over the all-Redis environment. */
333
+ const STORAGE: ReadonlyArray<readonly [string, Record<string, string>]> = [
334
+ [
335
+ "SESSION_STORE_STORAGE_TYPE=memory",
336
+ { ...ALL_REDIS_ENV, SESSION_STORE_STORAGE_TYPE: "memory" },
337
+ ],
338
+ ["SESSION_STORE_STORAGE_TYPE=redis", ALL_REDIS_ENV],
339
+ [
340
+ "no SESSION_STORE_STORAGE_TYPE, the reference's default",
341
+ without(ALL_REDIS_ENV, "SESSION_STORE_STORAGE_TYPE"),
342
+ ],
343
+ [
344
+ "the variable it was renamed from, SESSION_STORAGE_TYPE=memory",
345
+ { ...without(ALL_REDIS_ENV, "SESSION_STORE_STORAGE_TYPE"), SESSION_STORAGE_TYPE: "memory" },
346
+ ],
347
+ ];
348
+ const MODES: ReadonlyArray<
349
+ readonly [string, (env: Record<string, string>) => Record<string, string>]
350
+ > = [
351
+ ["multi", (env) => ({ ...env, CORE_DEPLOYMENT_MODE: "multi" })],
352
+ ["single", (env) => ({ ...env, CORE_DEPLOYMENT_MODE: "single" })],
353
+ ["unset", (env) => without(env, "CORE_DEPLOYMENT_MODE")],
354
+ ];
355
+
356
+ /** What a boot of `modules` settles as: the refusal, or the replica-safety warnings it logged. */
357
+ async function outcomeOf(config: BothPhases, modules: readonly Module[]): Promise<unknown> {
358
+ const warn = vi.fn();
359
+ const logger = {
360
+ warn,
361
+ info: vi.fn(),
362
+ error: vi.fn(),
363
+ debug: vi.fn(),
364
+ trace: vi.fn(),
365
+ fatal: vi.fn(),
366
+ child: vi.fn(),
367
+ };
368
+ try {
369
+ const handle = await createApp({
370
+ modules: [...modules],
371
+ bootstrapComponents: { config, logger, pathResolver: (s: string) => s } as never,
372
+ });
373
+ await handle.dispose();
374
+ } catch (err) {
375
+ const e = err as { reason?: string; message?: string; details?: unknown };
376
+ return { refused: { reason: e.reason, message: e.message, details: e.details } };
377
+ }
378
+ return {
379
+ warned: warn.mock.calls.filter(([, event]) => event === "replica_unsafe_adapters"),
380
+ };
381
+ }
382
+
383
+ for (const [storage, env] of STORAGE) {
384
+ for (const [mode, withMode] of MODES) {
385
+ it(`${storage} under ${mode}`, async () => {
386
+ const config = resolveConfig(withMode(env));
387
+ const modules = modulesFor(config);
388
+ expect(modules[0]).toBe(sessionStoreModule);
389
+ const outcome = await outcomeOf(config, modules);
390
+ if (
391
+ storage === "SESSION_STORE_STORAGE_TYPE=redis" ||
392
+ storage === "no SESSION_STORE_STORAGE_TYPE, the reference's default"
393
+ ) {
394
+ // A shared store: nothing is refused or warned about.
395
+ expect(outcome).toEqual({ warned: [] });
396
+ }
397
+ if (storage === "SESSION_STORE_STORAGE_TYPE=memory") {
398
+ const named = { modules: ["session-store"] };
399
+ expect(outcome).toEqual(
400
+ mode === "multi"
401
+ ? {
402
+ refused: expect.objectContaining({
403
+ reason: "replica-unsafe-adapter",
404
+ message: expect.stringContaining(
405
+ "session-store: the express-session store forks per replica",
406
+ ),
407
+ details: expect.objectContaining(named),
408
+ }),
409
+ }
410
+ : {
411
+ warned:
412
+ mode === "unset"
413
+ ? [[expect.objectContaining(named), "replica_unsafe_adapters"]]
414
+ : [],
415
+ },
416
+ );
417
+ }
418
+ if (storage === "the variable it was renamed from, SESSION_STORAGE_TYPE=memory") {
419
+ // Refused as renamed before the replica-safety guard reads anything.
420
+ expect(outcome).toEqual({
421
+ refused: expect.objectContaining({ reason: "environment-variable-renamed" }),
422
+ });
423
+ }
424
+ });
425
+ }
426
+ }
427
+ });
428
+
321
429
  describe('adapters.federationTokenStore = "redis" in the standalone', () => {
322
430
  let handleRef: Awaited<ReturnType<typeof boot>> | undefined;
323
431
 
@@ -24,16 +24,14 @@
24
24
 
25
25
  import { existsSync, readFileSync } from "node:fs";
26
26
  import { fileURLToPath } from "node:url";
27
- import { type AppConfig, AppConfigSchema, coreReference } from "@o3co/auth-provider-core";
27
+ import { BootError } from "@o3co/auth-provider-core";
28
28
  import { parseFile } from "@o3co/ts.hocon";
29
- import { validate } from "@o3co/ts.hocon/zod";
30
29
  import { describe, expect, it } from "vitest";
31
30
  import { readAdapters } from "../adapters.mjs";
32
31
  import { buildModules } from "../buildModules.mjs";
33
32
  import { readOwnLayers, readSwitches, resolveConfigPaths } from "../configPath.mjs";
34
33
  import { templateReference } from "../modules.mjs";
35
34
  import { type Adapters, repositoriesSectionSchema } from "../sections.mjs";
36
- import { createRecordingLogger } from "./all-modules-composition.fixture.mjs";
37
35
 
38
36
  const standaloneDir = fileURLToPath(new URL("../..", import.meta.url));
39
37
  const configDir = fileURLToPath(new URL("../../config", import.meta.url));
@@ -64,18 +62,6 @@ function composeAppEnvironment(rel: string): Map<string, string | null> {
64
62
  return env;
65
63
  }
66
64
 
67
- /** Resolve the shipped config layers under a given environment, as `src/app.mts` does. */
68
- function resolveWith(env: Record<string, string>, configEnv = "production"): AppConfig {
69
- const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, configEnv);
70
- return validate(
71
- parseFile(envConfPath, { env })
72
- .withFallback(parseFile(applicationConfPath, { env }))
73
- .withFallback(parseFile(fileURLToPath(templateReference()), { env }))
74
- .withFallback(parseFile(fileURLToPath(coreReference()), { env })),
75
- AppConfigSchema,
76
- );
77
- }
78
-
79
65
  /** The template's own layers under `env`, over its own reference, unparsed. */
80
66
  function ownResolved(
81
67
  env: Record<string, string>,
@@ -88,6 +74,14 @@ function ownResolved(
88
74
  .toObject() as Record<string, unknown>;
89
75
  }
90
76
 
77
+ /** `session-store.storage.type` as the template's own layers resolve it under `env`. */
78
+ function sessionStorageTypeWith(env: Record<string, string>): unknown {
79
+ const section = ownResolved(env)["session-store"] as
80
+ | { readonly storage?: { readonly type?: unknown } }
81
+ | undefined;
82
+ return section?.storage?.type;
83
+ }
84
+
91
85
  /** The adapters phase one reads under `env`. */
92
86
  function adaptersWith(env: Record<string, string>, configEnv = "production"): Adapters {
93
87
  return readAdapters(ownResolved(env, configEnv), env);
@@ -330,7 +324,7 @@ describe("the compose files put a store and its lifetime-sibling on the same bac
330
324
  // Resolved through the real config layers, not read off the file:
331
325
  // what matters is the value the process ends up with, whether the
332
326
  // compose stated it or `config/application.conf` did.
333
- expect(resolveWith(env)["session-store"]?.storage?.type).toBe("redis");
327
+ expect(sessionStorageTypeWith(env)).toBe("redis");
334
328
  expect(adaptersWith(env).userSessionStores).toBe("redis");
335
329
  });
336
330
  }
@@ -341,7 +335,7 @@ describe("the compose files put a store and its lifetime-sibling on the same bac
341
335
  // token store is deliberately absent: this template ships every
342
336
  // federation disabled, so nothing writes to it, and turning it on needs
343
337
  // an AES key the compose file must not invent.
344
- expect(resolveWith(env)["session-store"]?.storage?.type).toBe("redis");
338
+ expect(sessionStorageTypeWith(env)).toBe("redis");
345
339
  expect(adaptersWith(env)).toMatchObject({
346
340
  userSessionStores: "redis",
347
341
  codeRepository: "redis",
@@ -375,12 +369,13 @@ describe("the production compose leaves MFA on, for the deployment to decide", (
375
369
 
376
370
  let err: unknown;
377
371
  try {
378
- buildModules(switches, { environment: configEnv, logger: createRecordingLogger() });
372
+ buildModules(switches, { environment: configEnv });
379
373
  } catch (caught) {
380
374
  err = caught;
381
375
  }
382
- expect(err).toBeInstanceOf(RangeError);
383
- expect((err as RangeError).message).toContain("MFA_MODE=off");
376
+ expect(err).toBeInstanceOf(BootError);
377
+ expect((err as BootError).reason).toBe("config-validation-failed");
378
+ expect((err as BootError).message).toContain("MFA_MODE=off");
384
379
  });
385
380
  });
386
381
 
@@ -0,0 +1,215 @@
1
+ /*
2
+ * Copyright 2026 1o1 Co. Ltd.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ /**
18
+ * The template composes core's session lifecycle module: it serves relying
19
+ * parties, so boot holds it to a session-close notifier, which the oauth
20
+ * module contributes.
21
+ */
22
+
23
+ import {
24
+ type FederationTokenStore,
25
+ readVersionedSessionLifecycle,
26
+ type SessionLifecycle,
27
+ type SessionLifecycleStore,
28
+ type SubjectSessionIndex,
29
+ type UserSessionStore,
30
+ } from "@o3co/auth-provider-core";
31
+ import request from "supertest";
32
+ import { afterEach, describe, expect, it, vi } from "vitest";
33
+ import {
34
+ ALICE,
35
+ basic,
36
+ compose,
37
+ cookiesOf,
38
+ federatedCallback,
39
+ ISSUER,
40
+ login,
41
+ WEB,
42
+ webTokens,
43
+ } from "./all-modules-composition.fixture.mjs";
44
+
45
+ afterEach(() => {
46
+ vi.restoreAllMocks();
47
+ });
48
+
49
+ /** A JWT's claims, unverified. */
50
+ const claimsOf = (token: string): Record<string, unknown> =>
51
+ JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8"));
52
+
53
+ describe("the template, which composes the session lifecycle module", () => {
54
+ it("boots, the oauth module's notifier contributed", async () => {
55
+ const { handle } = await compose();
56
+ try {
57
+ const lifecycle = handle.components.sessionLifecycle as SessionLifecycle | undefined;
58
+ expect(lifecycle).toBeDefined();
59
+ expect(handle.components.sessionCloseNotifierResolver?.get()).toBeDefined();
60
+ expect(await lifecycle?.close("no-such-session", "expiry")).toEqual({
61
+ outcome: "done",
62
+ rps: [],
63
+ federations: [],
64
+ });
65
+ } finally {
66
+ await handle.dispose();
67
+ }
68
+ });
69
+
70
+ it("tells a relying party that joined, through the oauth module's notifier, when the session closes", async () => {
71
+ const { handle } = await compose({
72
+ extraClients: {
73
+ "rp-bc": {
74
+ tokenEndpointAuthMethod: "client_secret_basic",
75
+ clientSecret: "rp-bc-secret-long-enough",
76
+ allowedRedirectUris: ["https://rp-bc.test/cb"],
77
+ allowedScopes: ["openid"],
78
+ allowedGrantTypes: ["authorization_code"],
79
+ backchannelLogoutUri: "https://rp-bc.test/logout",
80
+ },
81
+ },
82
+ });
83
+ try {
84
+ const components = handle.components as Record<string, unknown>;
85
+ const lifecycle = components.sessionLifecycle as SessionLifecycle;
86
+ const sessions = components.userSessionStore as UserSessionStore;
87
+ const expiresAt = new Date(Date.now() + 3_600_000);
88
+ await sessions.create({
89
+ sid: "sid-bc",
90
+ sub: "u-bc",
91
+ authTime: new Date(),
92
+ expiresAt,
93
+ claims: {},
94
+ amr: ["pwd"],
95
+ authentication: undefined,
96
+ });
97
+ expect(await lifecycle.open("sid-bc", { sub: "u-bc", expiresAt })).toEqual({
98
+ outcome: "opened",
99
+ });
100
+ const rp = {
101
+ clientId: "rp-bc",
102
+ backchannelLogoutUri: "https://rp-bc.test/logout",
103
+ backchannelLogoutSessionRequired: true,
104
+ frontchannelLogoutUri: undefined,
105
+ frontchannelLogoutSessionRequired: undefined,
106
+ registeredAt: new Date(),
107
+ };
108
+ expect(await lifecycle.join("sid-bc", { rp, familyId: "family-bc" })).toEqual({
109
+ outcome: "joined",
110
+ });
111
+ const posted = vi
112
+ .spyOn(globalThis, "fetch")
113
+ .mockImplementation(async () => new Response(null, { status: 200 }));
114
+ expect((await lifecycle.close("sid-bc", "rp_logout")).outcome).toBe("done");
115
+ expect(posted).toHaveBeenCalledTimes(1);
116
+ const [url, init] = posted.mock.calls[0] as [string, RequestInit];
117
+ expect(url).toBe("https://rp-bc.test/logout");
118
+ const token = new URLSearchParams(String(init.body)).get("logout_token");
119
+ expect(claimsOf(token ?? "")).toMatchObject({
120
+ iss: ISSUER,
121
+ aud: "rp-bc",
122
+ sub: "u-bc",
123
+ sid: "sid-bc",
124
+ });
125
+ } finally {
126
+ await handle.dispose();
127
+ }
128
+ });
129
+
130
+ it("opens a password login's session in the lifecycle, active with nothing joined yet", async () => {
131
+ const { app, handle } = await compose();
132
+ try {
133
+ expect((await login(app)).res.status).toBe(200);
134
+ const components = handle.components as Record<string, unknown>;
135
+ const [sid] = await (components.subjectSessionIndex as SubjectSessionIndex).listSids(
136
+ ALICE.sub,
137
+ );
138
+ const store = components.sessionLifecycleStore as SessionLifecycleStore;
139
+ const record = readVersionedSessionLifecycle(await store.read(String(sid)));
140
+ expect(record?.value).toMatchObject({ sub: ALICE.sub, state: "active", participants: [] });
141
+ } finally {
142
+ await handle.dispose();
143
+ }
144
+ });
145
+
146
+ it("joins a federated login's federation to the session's lifecycle record, beside its tokens, through the session module's federation routes", async () => {
147
+ const { app, handle, upstreams } = await compose();
148
+ try {
149
+ const callback = await (await federatedCallback(app, "oidc", upstreams.oidc))();
150
+ expect(callback.status).toBe(302);
151
+ expect(cookiesOf(callback)).not.toEqual([]);
152
+ const components = handle.components as Record<string, unknown>;
153
+ const [sid] = await (components.subjectSessionIndex as SubjectSessionIndex).listSids(
154
+ ALICE.sub,
155
+ );
156
+ const store = components.sessionLifecycleStore as SessionLifecycleStore;
157
+ const record = readVersionedSessionLifecycle(await store.read(String(sid)));
158
+ expect(record?.value).toMatchObject({ sub: ALICE.sub, state: "active" });
159
+ expect(record?.value.participants.map((p) => `${p.kind}:${p.id}`)).toEqual([
160
+ "federation:oidc",
161
+ ]);
162
+ const tokens = components.federationTokenStore as FederationTokenStore;
163
+ expect(await tokens.get(String(sid), "oidc")).not.toBeNull();
164
+ } finally {
165
+ await handle.dispose();
166
+ }
167
+ });
168
+
169
+ it("joins a code exchange's relying party and family to the session's lifecycle record", async () => {
170
+ const { app, handle } = await compose();
171
+ try {
172
+ const tokens = await webTokens(app);
173
+ const claims = claimsOf(tokens.refresh_token ?? "");
174
+ const sid = String(claims.sid);
175
+ const store = (handle.components as Record<string, unknown>)
176
+ .sessionLifecycleStore as SessionLifecycleStore;
177
+ const record = readVersionedSessionLifecycle(await store.read(sid));
178
+ expect(record?.value.state).toBe("active");
179
+ expect(record?.value.participants.map((p) => `${p.kind}:${p.id}`)).toEqual([
180
+ `rp:${WEB.id}`,
181
+ `family:${String(claims.family_id)}`,
182
+ ]);
183
+ } finally {
184
+ await handle.dispose();
185
+ }
186
+ });
187
+
188
+ it("closes the session through the lifecycle at /oauth/logout: the record closed, the session and its refresh token gone", async () => {
189
+ const { app, handle } = await compose();
190
+ try {
191
+ const tokens = await webTokens(app);
192
+ const sid = String(claimsOf(tokens.refresh_token ?? "").sid);
193
+ const components = handle.components as Record<string, unknown>;
194
+
195
+ const res = await request(app)
196
+ .post("/oauth/logout")
197
+ .type("form")
198
+ .send({ id_token_hint: tokens.id_token });
199
+
200
+ expect(res.status).toBe(200);
201
+ expect(res.body).toEqual({ logged_out: true });
202
+ const store = components.sessionLifecycleStore as SessionLifecycleStore;
203
+ expect(readVersionedSessionLifecycle(await store.read(sid))?.value.state).toBe("closed");
204
+ expect(await (components.userSessionStore as UserSessionStore).get(sid)).toBeNull();
205
+ const refreshed = await request(app)
206
+ .post("/oauth/token")
207
+ .set("Authorization", basic(WEB))
208
+ .type("form")
209
+ .send({ grant_type: "refresh_token", refresh_token: tokens.refresh_token });
210
+ expect(refreshed.status).toBe(400);
211
+ } finally {
212
+ await handle.dispose();
213
+ }
214
+ });
215
+ });
@@ -90,17 +90,13 @@ const config: AppConfig & Record<string, unknown> = {
90
90
  },
91
91
  // The session requirements this composition expects, as the shipped
92
92
  // `application.conf` does (ADR 2026-09-28-session-admission): none.
93
- ...coreConfigForTests({ federations: { google: { enabled: false } } }),
93
+ ...coreConfigForTests({ federations: { google: { enabled: false, type: "google" } } }),
94
94
  oauth: {
95
95
  jwt: {
96
96
  issuer: "https://auth.test",
97
97
  },
98
- accessToken: { expiresIn: 3600 },
99
- refreshToken: {
100
- expiresIn: 86400,
101
- unknownFamilyPolicy: "reject" as const,
102
- legacyRtPolicy: "reject" as const,
103
- },
98
+ accessToken: { defaultExpiresIn: 3600 },
99
+ refreshToken: { expiresIn: 86400 },
104
100
  oidcMode: "oidc-required",
105
101
  },
106
102
  // The grant this whole test is about: it mints straight from an
@@ -0,0 +1,130 @@
1
+ /*
2
+ * Copyright 2026 1o1 Co. Ltd.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ /**
18
+ * The template with core's session lifecycle module: `POST /session/logout`
19
+ * closes the session through it, so the browser's own logout revokes the
20
+ * session's refresh-token families and tells its relying parties, as
21
+ * `/oauth/logout` does.
22
+ */
23
+
24
+ import {
25
+ type RefreshTokenFamilyRevocation,
26
+ readVersionedSessionLifecycle,
27
+ type SessionLifecycle,
28
+ type SessionLifecycleStore,
29
+ type UserSessionStore,
30
+ } from "@o3co/auth-provider-core";
31
+ import request from "supertest";
32
+ import { afterEach, describe, expect, it, vi } from "vitest";
33
+ import { authorize, codeFrom, compose, login, redeem } from "./all-modules-composition.fixture.mjs";
34
+
35
+ afterEach(() => {
36
+ vi.restoreAllMocks();
37
+ });
38
+
39
+ /** A JWT's claims, unverified. */
40
+ const claimsOf = (token: string): Record<string, unknown> =>
41
+ JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8"));
42
+
43
+ /** The CSRF token a login reissued, read off its double-submit cookie. */
44
+ const csrfTokenOf = (cookies: readonly string[]): string => {
45
+ const cookie = cookies.find((c) => /^[^=]*\.csrf=/.test(c));
46
+ if (cookie === undefined) throw new Error("the login reissued no CSRF cookie");
47
+ return decodeURIComponent(cookie.slice(cookie.indexOf("=") + 1).split(";")[0] ?? "");
48
+ };
49
+
50
+ describe("POST /session/logout with the session lifecycle module", () => {
51
+ it("revokes the session's families, tells its relying parties and closes its record", async () => {
52
+ const { app, handle } = await compose({
53
+ extraClients: {
54
+ "rp-bc": {
55
+ tokenEndpointAuthMethod: "client_secret_basic",
56
+ clientSecret: "rp-bc-secret-long-enough",
57
+ allowedRedirectUris: ["https://rp-bc.test/cb"],
58
+ allowedScopes: ["openid"],
59
+ allowedGrantTypes: ["authorization_code"],
60
+ backchannelLogoutUri: "https://rp-bc.test/logout",
61
+ },
62
+ },
63
+ });
64
+ try {
65
+ const components = handle.components as Record<string, unknown>;
66
+ const lifecycle = components.sessionLifecycle as SessionLifecycle;
67
+ const sessions = components.userSessionStore as UserSessionStore;
68
+ const families = components.refreshTokenFamilyRevocation as RefreshTokenFamilyRevocation;
69
+ const { cookies } = await login(app);
70
+ const exchanged = await redeem(app, codeFrom(await authorize(app, cookies)));
71
+ expect(exchanged.status).toBe(200);
72
+ const claims = claimsOf(exchanged.body.refresh_token as string);
73
+ const sid = String(claims.sid);
74
+ const familyId = String(claims.family_id);
75
+ expect(
76
+ await lifecycle.join(sid, {
77
+ rp: {
78
+ clientId: "rp-bc",
79
+ backchannelLogoutUri: "https://rp-bc.test/logout",
80
+ backchannelLogoutSessionRequired: true,
81
+ frontchannelLogoutUri: undefined,
82
+ frontchannelLogoutSessionRequired: undefined,
83
+ registeredAt: new Date(),
84
+ },
85
+ }),
86
+ ).toEqual({ outcome: "joined" });
87
+ const posted = vi
88
+ .spyOn(globalThis, "fetch")
89
+ .mockImplementation(async () => new Response(null, { status: 200 }));
90
+
91
+ const csrfToken = csrfTokenOf(cookies);
92
+ const res = await request(app)
93
+ .post("/session/logout")
94
+ .set("Cookie", cookies)
95
+ .set("x-csrf-token", csrfToken);
96
+
97
+ expect(res.status).toBe(200);
98
+ expect(await families.isFamilyRevoked(familyId)).toBe(true);
99
+ expect(posted.mock.calls.map(([url]) => String(url))).toEqual(["https://rp-bc.test/logout"]);
100
+ expect(await sessions.get(sid)).toBeNull();
101
+ const store = components.sessionLifecycleStore as SessionLifecycleStore;
102
+ expect(readVersionedSessionLifecycle(await store.read(sid))?.value.state).toBe("closed");
103
+ } finally {
104
+ await handle.dispose();
105
+ }
106
+ });
107
+
108
+ it("answers 503 when the close cannot commit, and the cookie still admits the session for a retry", async () => {
109
+ const { app, handle } = await compose();
110
+ try {
111
+ const { cookies } = await login(app);
112
+ const components = handle.components as Record<string, unknown>;
113
+ const store = components.sessionLifecycleStore as SessionLifecycleStore;
114
+ vi.spyOn(store, "beginClose").mockRejectedValue(new Error("lifecycle store down"));
115
+
116
+ const res = await request(app)
117
+ .post("/session/logout")
118
+ .set("Cookie", cookies)
119
+ .set("x-csrf-token", csrfTokenOf(cookies));
120
+
121
+ expect(res.status).toBe(503);
122
+ expect(res.body.error).toBe("temporarily_unavailable");
123
+ // The session is still live and its cookie still admits it.
124
+ const authorized = await authorize(app, cookies);
125
+ expect(codeFrom(authorized)).toEqual(expect.any(String));
126
+ } finally {
127
+ await handle.dispose();
128
+ }
129
+ });
130
+ });