@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
@@ -16,8 +16,10 @@
16
16
 
17
17
  /**
18
18
  * A browser session with Google linked, on the standalone composed as a
19
- * deployment does: `oauthModule`, the session module and the real Google
20
- * adapter, booted through `createApp` under the shipped configuration.
19
+ * deployment does: `oauthEndpointsModule`, the session module and the real Google
20
+ * adapter — the Google federation type `buildModules` lists, handed the
21
+ * shipped `core.federations.google` entry — booted through `createApp` under
22
+ * the shipped configuration.
21
23
  *
22
24
  * The two logout routes and `POST /oauth/federation/:name/token` live in
23
25
  * `@o3co/auth-provider-oauth`, which depends on no adapter; only here can a
@@ -32,7 +34,6 @@
32
34
  import { createHmac } from "node:crypto";
33
35
  import { fileURLToPath } from "node:url";
34
36
  import {
35
- AppConfigSchema,
36
37
  createApp,
37
38
  createKeyStoreFactory,
38
39
  defineModule,
@@ -42,26 +43,22 @@ import {
42
43
  InMemoryUserRepository,
43
44
  memoryRefreshTokenFamilyStoreModule,
44
45
  registerBuiltinKeyStores,
45
- type SessionFederationIndex,
46
+ type SessionLifecycle,
46
47
  type UserSessionStore,
47
48
  } from "@o3co/auth-provider-core";
48
- import {
49
- type GoogleProviderConfig,
50
- googleFederationModule,
51
- } from "@o3co/auth-provider-federation-google";
49
+ import { googleFederationTypeModule } from "@o3co/auth-provider-federation-google";
52
50
  import { parseFile } from "@o3co/ts.hocon";
53
- import { validate } from "@o3co/ts.hocon/zod";
54
51
  import express from "express";
55
52
  import request from "supertest";
56
53
  import { expect } from "vitest";
57
54
  import { buildModules } from "#/buildModules.mjs";
58
- import { resolveConfigPaths, type Switches } from "#/configPath.mjs";
55
+ import { resolveConfigPaths } from "#/configPath.mjs";
59
56
  import { templateReference } from "../modules.mjs";
60
57
  import {
58
+ type BothPhases,
59
+ bothPhasesOf,
61
60
  capturedRenames,
62
61
  libraryLayers,
63
- rootSectionsOf,
64
- sectionsCoreDoesNotDeclare,
65
62
  } from "./library-references.fixture.mjs";
66
63
 
67
64
  const configDir = fileURLToPath(new URL("../../config", import.meta.url));
@@ -106,40 +103,50 @@ const ENV: Readonly<Record<string, string>> = {
106
103
  };
107
104
 
108
105
  /**
109
- * `"shipped"`: Google switched on the way an operator does, through its
110
- * environment variables and the template's own configuration bridge — which
111
- * has no setting for an end-session endpoint or a `fetch`. Otherwise the
112
- * adapter's configuration beyond its credentials, composed with
113
- * `googleFederationModule` directly.
106
+ * Google switched on the way an operator does, through its environment
107
+ * variables, and handled by the Google federation type the template lists.
108
+ * `"shipped"` is that alone. Otherwise, beside it: `endSessionEndpoint`, a key
109
+ * of the entry with no environment form, written into the entry as an
110
+ * operator's own layer would; and `fetch`, the one the type module is built
111
+ * with in place of the global one.
114
112
  */
115
113
  export type GoogleWiring =
116
114
  | "shipped"
117
- | Omit<GoogleProviderConfig, "clientId" | "clientSecret" | "callbackURL">;
115
+ | { readonly endSessionEndpoint?: string; readonly fetch?: typeof fetch };
116
+
117
+ const GOOGLE_ENV: Readonly<Record<string, string>> = {
118
+ CORE_FEDERATIONS_GOOGLE_ENABLED: "true",
119
+ CORE_FEDERATIONS_GOOGLE_CLIENT_ID: GOOGLE_CLIENT_ID,
120
+ CORE_FEDERATIONS_GOOGLE_CLIENT_SECRET: GOOGLE_CLIENT_SECRET,
121
+ CORE_FEDERATIONS_GOOGLE_CALLBACK_URL: GOOGLE_CALLBACK,
122
+ };
118
123
 
119
- function resolveConfig(google: GoogleWiring): Switches {
120
- const env: Record<string, string> =
121
- google === "shipped"
122
- ? {
123
- ...ENV,
124
- CORE_FEDERATIONS_GOOGLE_ENABLED: "true",
125
- CORE_FEDERATIONS_GOOGLE_CLIENT_ID: GOOGLE_CLIENT_ID,
126
- CORE_FEDERATIONS_GOOGLE_CLIENT_SECRET: GOOGLE_CLIENT_SECRET,
127
- CORE_FEDERATIONS_GOOGLE_CALLBACK_URL: GOOGLE_CALLBACK,
128
- }
129
- : { ...ENV };
124
+ function resolveConfig(google: GoogleWiring): BothPhases {
125
+ const env: Record<string, string> = { ...ENV, ...GOOGLE_ENV };
130
126
  const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
131
127
  const layers = parseFile(envConfPath, { env })
132
128
  .withFallback(parseFile(applicationConfPath, { env }))
133
129
  .withFallback(parseFile(fileURLToPath(templateReference()), { env }))
134
130
  .withFallback(libraryLayers(env));
131
+ const config = bothPhasesOf(layers, env);
132
+ const endSessionEndpoint = google === "shipped" ? undefined : google.endSessionEndpoint;
133
+ const federations = config.core?.federations;
135
134
  return {
136
- ...sectionsCoreDoesNotDeclare(layers),
137
- ...rootSectionsOf(layers, env),
138
- ...validate(layers, AppConfigSchema),
139
- // What the resolution captured of core's renamed variables, which the
140
- // schema's parse drops.
135
+ ...config,
136
+ ...(endSessionEndpoint === undefined
137
+ ? {}
138
+ : {
139
+ core: {
140
+ ...config.core,
141
+ federations: {
142
+ ...federations,
143
+ google: { ...federations?.google, endSessionEndpoint },
144
+ },
145
+ },
146
+ }),
147
+ // What the resolution captured of the renamed variables.
141
148
  "renamed-variables": capturedRenames(env),
142
- } as Switches;
149
+ } as BothPhases;
143
150
  }
144
151
 
145
152
  const testRepositoriesModule = defineModule({
@@ -227,15 +234,15 @@ export interface GoogleSession {
227
234
  /** The access token the `session` grant minted for the session; it carries `azp`. */
228
235
  readonly accessToken: string;
229
236
  readonly userSessionStore: UserSessionStore;
230
- readonly sessionFederationIndex: SessionFederationIndex;
237
+ readonly sessionLifecycle: SessionLifecycle;
231
238
  readonly federationTokenStore: FederationTokenStore;
232
239
  readonly dispose: () => Promise<void>;
233
240
  }
234
241
 
235
242
  /**
236
243
  * Boots the deployment, signs alice in with her password, and links Google to
237
- * her session as a federated sign-in would: the session's federation index
238
- * names it and the token store holds `tokens`.
244
+ * her session as a federated sign-in would: the token store holds `tokens`,
245
+ * and Google joins the session through its lifecycle.
239
246
  */
240
247
  export async function signInLinkedToGoogle(options: {
241
248
  readonly google: GoogleWiring;
@@ -243,38 +250,24 @@ export async function signInLinkedToGoogle(options: {
243
250
  }): Promise<GoogleSession> {
244
251
  const { google, tokens } = options;
245
252
  const config = resolveConfig(google);
246
- const googleModules =
247
- google === "shipped"
248
- ? []
249
- : [
250
- googleFederationModule,
251
- defineModule({
252
- name: "test:google-federation-config",
253
- provides: {
254
- googleFederationConfig: (): GoogleProviderConfig => ({
255
- clientId: GOOGLE_CLIENT_ID,
256
- clientSecret: GOOGLE_CLIENT_SECRET,
257
- callbackURL: GOOGLE_CALLBACK,
258
- ...google,
259
- }),
260
- },
261
- }),
262
- ];
253
+ const upstreamFetch = google === "shipped" ? undefined : google.fetch;
254
+ const googleType = googleFederationTypeModule().name;
263
255
  const handle = await createApp({
264
- modules: [
265
- ...buildModules(config, {
266
- keyStoreModule: testKeyStoreModule,
267
- repositoriesModule: testRepositoriesModule,
268
- refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
269
- }),
270
- ...googleModules,
271
- ],
256
+ modules: buildModules(config, {
257
+ keyStoreModule: testKeyStoreModule,
258
+ repositoriesModule: testRepositoriesModule,
259
+ refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
260
+ }).map((module) =>
261
+ module.name === googleType && upstreamFetch !== undefined
262
+ ? googleFederationTypeModule({ fetch: upstreamFetch })
263
+ : module,
264
+ ),
272
265
  bootstrapComponents: { config, pathResolver: (s) => s },
273
266
  });
274
267
  try {
275
268
  const components = handle.components as {
276
269
  userSessionStore: UserSessionStore;
277
- sessionFederationIndex: SessionFederationIndex;
270
+ sessionLifecycle: SessionLifecycle;
278
271
  federationTokenStore: FederationTokenStore;
279
272
  };
280
273
  const app = express();
@@ -303,10 +296,10 @@ export async function signInLinkedToGoogle(options: {
303
296
  const sid = claims.sid as string;
304
297
  expect(typeof sid).toBe("string");
305
298
 
306
- const session = await components.userSessionStore.get(sid);
307
- if (session === null) throw new Error("fixture: the sign-in left no session");
308
- await components.sessionFederationIndex.addFederation(sid, "google", session.expiresAt);
309
299
  await components.federationTokenStore.attach(sid, "google", tokens);
300
+ expect(await components.sessionLifecycle.join(sid, { federation: "google" })).toEqual({
301
+ outcome: "joined",
302
+ });
310
303
  return { app, sid, accessToken, ...components, dispose: () => handle.dispose() };
311
304
  } catch (error) {
312
305
  await handle.dispose();
@@ -24,6 +24,6 @@ describe("library reference.conf subpath resolution", () => {
24
24
  const path = fileURLToPath(url);
25
25
  expect(existsSync(path)).toBe(true);
26
26
  const content = readFileSync(path, "utf-8");
27
- expect(content).toMatch(/oauth\s*\{/); // sanity: HOCON content
27
+ expect(content).toMatch(/^core\s*\{/m); // sanity: HOCON content, core's own section
28
28
  });
29
29
  });
@@ -18,21 +18,22 @@
18
18
  * The libraries' layers beneath the template's own files, for a test that
19
19
  * resolves its configuration by hand: the `reference.conf` of every package
20
20
  * the template composes a module from, core's last, as `app.mts` layers them;
21
- * and what that resolution captures of the renamed variables.
21
+ * the resolution handed to both phases at once; and what that resolution
22
+ * captures of the renamed variables.
22
23
  */
23
24
 
24
25
  import { fileURLToPath } from "node:url";
25
26
  import {
26
- AppConfigSchema,
27
+ type AppConfig,
27
28
  memoryRateLimiterModule,
28
29
  moduleReferences,
29
30
  } from "@o3co/auth-provider-core";
30
31
  import { CORE_RELOCATIONS, renamedVariableCaptures } from "@o3co/auth-provider-core/testing";
31
32
  import { federationGrantsModules } from "@o3co/auth-provider-federation-grants";
32
33
  import {
33
- oauthAuthorizationModule,
34
- oauthModule,
35
- oauthSessionModule,
34
+ oauthAuthorizationGrantsModule,
35
+ oauthEndpointsModule,
36
+ oauthSessionGrantModule,
36
37
  } from "@o3co/auth-provider-oauth";
37
38
  import {
38
39
  redisCodeRepositoryModule,
@@ -42,8 +43,8 @@ import {
42
43
  } from "@o3co/auth-provider-redis";
43
44
  import { sessionModule, sessionStoreModule } from "@o3co/auth-provider-session";
44
45
  import { type Config, empty, parseFile } from "@o3co/ts.hocon";
45
- import { ADAPTERS_SECTION, readAdapters } from "../adapters.mjs";
46
- import { MFA_SWITCH, readMfaSwitch } from "../mfaSwitch.mjs";
46
+ import { readAdapters } from "../adapters.mjs";
47
+ import { readSwitches, type Switches } from "../configPath.mjs";
47
48
  import {
48
49
  httpModule,
49
50
  inMemoryCodeRepositoryModule,
@@ -53,13 +54,13 @@ import {
53
54
  standaloneRedisClientsModule,
54
55
  templateReference,
55
56
  } from "../modules.mjs";
56
- import type { Adapters, MfaSwitch } from "../sections.mjs";
57
+ import { type Adapters, isPlainSection } from "../sections.mjs";
57
58
 
58
- /** The oauth package's modules, whose manifests read nothing of the configuration they are handed but the grant switches. */
59
+ /** The oauth package's modules: none reads the configuration it is handed. */
59
60
  const OAUTH_MODULES = [
60
- oauthModule({ config: {} as never }),
61
- oauthSessionModule({ config: {} as never }),
62
- oauthAuthorizationModule({ config: {} as never }),
61
+ oauthEndpointsModule,
62
+ oauthSessionGrantModule,
63
+ oauthAuthorizationGrantsModule,
63
64
  ];
64
65
 
65
66
  /** The modules the template composes that declare a renamed variable. */
@@ -97,23 +98,46 @@ export function libraryLayers(env: Readonly<Record<string, string>>): Config {
97
98
  );
98
99
  }
99
100
 
101
+ /** Phase one's switches laid over the configuration boot parses. */
102
+ export type BothPhases = Switches & AppConfig;
103
+
104
+ /**
105
+ * `switches` laid over `resolved`, the configuration boot parses, as a test
106
+ * that hands one object to both phases does: the composition root's own keys
107
+ * beside the sections, and `federation-grants.enabled` written as phase one
108
+ * decided it into the section as resolved. Boot is so handed phase one's
109
+ * boolean, not the value as written, unlike `app.mts`; boot's refusal of a
110
+ * written value phase one installs the modules for is pinned in
111
+ * `two-phase-config.test.mts`.
112
+ */
113
+ export function withSwitches(
114
+ resolved: Readonly<Record<string, unknown>>,
115
+ switches: Switches,
116
+ ): BothPhases {
117
+ const grants = resolved["federation-grants"];
118
+ return {
119
+ ...resolved,
120
+ ...switches,
121
+ "federation-grants": {
122
+ ...(isPlainSection(grants) ? grants : {}),
123
+ ...switches["federation-grants"],
124
+ },
125
+ } as unknown as BothPhases;
126
+ }
127
+
100
128
  /**
101
- * What a test that parses `layers` with `AppConfigSchema` lays beside that
102
- * parse: every top-level section core's schema does not declare — the
103
- * template's own modules' among them — as written, which the parse drops;
104
- * not the composition root's `adapters`, which boot is never handed
105
- * (`adaptersOf`).
129
+ * A resolution — `layers`, the template's `config/reference.conf` among them,
130
+ * under `env` — as a test that resolves by hand hands it to both phases at
131
+ * once: the configuration as resolved and unparsed, which boot parses as
132
+ * `app.mts` hands it on, with what phase one reads of it (`readSwitches`)
133
+ * laid over it — the composition root's `adapters` and `mfaMode`, the Store
134
+ * transport settings, and `federation-grants.enabled` as phase one decides
135
+ * it.
106
136
  */
107
- export function sectionsCoreDoesNotDeclare(layers: Config): Record<string, unknown> {
108
- const raw = layers.toObject() as Record<string, unknown>;
109
- return Object.fromEntries(
110
- Object.entries(raw).filter(
111
- ([key]) =>
112
- !Object.hasOwn(AppConfigSchema.shape, key) &&
113
- key !== "renamed-variables" &&
114
- key !== ADAPTERS_SECTION &&
115
- key !== MFA_SWITCH,
116
- ),
137
+ export function bothPhasesOf(layers: Config, env: Readonly<Record<string, string>>): BothPhases {
138
+ return withSwitches(
139
+ layers.toObject() as Record<string, unknown>,
140
+ readSwitches({ config: layers, env }),
117
141
  );
118
142
  }
119
143
 
@@ -126,25 +150,6 @@ export function adaptersOf(layers: Config, env: Readonly<Record<string, string>>
126
150
  return readAdapters(layers.toObject() as Record<string, unknown>, env);
127
151
  }
128
152
 
129
- /**
130
- * What phase one reads from a resolution beside core's switches —
131
- * `layers`, the template's `config/reference.conf` among them, under `env` —
132
- * as `readSwitches` reads it: the composition root's `adapters` and
133
- * `mfaMode`, and the Store transport settings.
134
- */
135
- export function rootSectionsOf(
136
- layers: Config,
137
- env: Readonly<Record<string, string>>,
138
- ): { readonly adapters: Adapters; readonly mfaMode: MfaSwitch; readonly storeTransport: unknown } {
139
- const raw = layers.toObject() as Record<string, unknown>;
140
- const repositories = raw.repositories as { user?: { http?: unknown } } | undefined;
141
- return {
142
- adapters: readAdapters(raw, env),
143
- mfaMode: readMfaSwitch(raw, env),
144
- storeTransport: repositories?.user?.http,
145
- };
146
- }
147
-
148
153
  /** The adapters the template ships, as its `config/reference.conf` sets them with no environment. */
149
154
  export function shippedAdapters(): Adapters {
150
155
  return adaptersOf(parseFile(fileURLToPath(templateReference()), { env: {} }), {});
@@ -0,0 +1,216 @@
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: a close whose
19
+ * relying-party notice fails stays pending, its user session still there,
20
+ * and from its commit no token reader treats the session as live —
21
+ * introspection, userinfo and the session grant all refuse it.
22
+ */
23
+
24
+ import type { SessionLifecycle, UserSessionStore } from "@o3co/auth-provider-core";
25
+ import request from "supertest";
26
+ import { afterEach, describe, expect, it, vi } from "vitest";
27
+ import {
28
+ authorize,
29
+ basic,
30
+ codeFrom,
31
+ compose,
32
+ login,
33
+ redeem,
34
+ WEB,
35
+ } from "./all-modules-composition.fixture.mjs";
36
+
37
+ afterEach(() => {
38
+ vi.restoreAllMocks();
39
+ });
40
+
41
+ /** A JWT's claims, unverified. */
42
+ const claimsOf = (token: string): Record<string, unknown> =>
43
+ JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8"));
44
+
45
+ describe("a session whose close is pending on a failed relying-party notice", () => {
46
+ it("is refused by introspection, userinfo and the session grant, its user session still there", async () => {
47
+ const { app, handle } = await compose({
48
+ extraClients: {
49
+ "rp-down": {
50
+ tokenEndpointAuthMethod: "client_secret_basic",
51
+ clientSecret: "rp-down-secret-long-enough",
52
+ allowedRedirectUris: ["https://rp-down.test/cb"],
53
+ allowedScopes: ["openid"],
54
+ allowedGrantTypes: ["authorization_code"],
55
+ backchannelLogoutUri: "https://rp-down.test/logout",
56
+ },
57
+ },
58
+ });
59
+ try {
60
+ const components = handle.components as Record<string, unknown>;
61
+ const lifecycle = components.sessionLifecycle as SessionLifecycle;
62
+ const sessions = components.userSessionStore as UserSessionStore;
63
+ const { cookies } = await login(app);
64
+ const sessionGrant = () =>
65
+ request(app)
66
+ .post("/oauth/token")
67
+ .set("Authorization", basic(WEB))
68
+ .set("Cookie", cookies)
69
+ .type("form")
70
+ .send({ grant_type: "session", scope: "openid profile" });
71
+ const issued = await sessionGrant();
72
+ expect(issued.status).toBe(200);
73
+ const accessToken = issued.body.access_token as string;
74
+ const sid = String(claimsOf(accessToken).sid);
75
+ const introspect = () =>
76
+ request(app)
77
+ .post("/oauth/introspect")
78
+ .set("Authorization", basic(WEB))
79
+ .type("form")
80
+ .send({ token: accessToken });
81
+ const userinfo = () =>
82
+ request(app).get("/oauth/userinfo").set("Authorization", `Bearer ${accessToken}`);
83
+ expect((await introspect()).body.active).toBe(true);
84
+ expect((await userinfo()).status).toBe(200);
85
+
86
+ // A relying party joins with its family; its back-channel endpoint is
87
+ // down, so the close commits with its notice pending.
88
+ expect(
89
+ await lifecycle.join(sid, {
90
+ rp: {
91
+ clientId: "rp-down",
92
+ backchannelLogoutUri: "https://rp-down.test/logout",
93
+ backchannelLogoutSessionRequired: true,
94
+ frontchannelLogoutUri: undefined,
95
+ frontchannelLogoutSessionRequired: undefined,
96
+ registeredAt: new Date(),
97
+ },
98
+ familyId: "family-down",
99
+ }),
100
+ ).toEqual({ outcome: "joined" });
101
+ const posted = vi
102
+ .spyOn(globalThis, "fetch")
103
+ .mockImplementation(async () => new Response(null, { status: 503 }));
104
+ expect((await lifecycle.close(sid, "rp_logout")).outcome).toBe("pending");
105
+ expect(posted).toHaveBeenCalled();
106
+ expect(await sessions.get(sid)).not.toBeNull();
107
+
108
+ const afterIntrospect = await introspect();
109
+ expect(afterIntrospect.status).toBe(200);
110
+ expect(afterIntrospect.body).toEqual({ active: false });
111
+
112
+ const afterUserinfo = await userinfo();
113
+ expect(afterUserinfo.status).toBe(401);
114
+ expect(afterUserinfo.body.error_description).toBe("session_invalid");
115
+
116
+ const again = await sessionGrant();
117
+ expect(again.status).toBe(400);
118
+ expect(again.body).toMatchObject({
119
+ error: "invalid_grant",
120
+ error_description: "session_invalid",
121
+ });
122
+ } finally {
123
+ await handle.dispose();
124
+ }
125
+ });
126
+
127
+ it("an RP-initiated logout whose notice fails answers success, and its session is refused by introspection, userinfo and the session grant", async () => {
128
+ const { app, handle } = await compose({
129
+ extraClients: {
130
+ "rp-down": {
131
+ tokenEndpointAuthMethod: "client_secret_basic",
132
+ clientSecret: "rp-down-secret-long-enough",
133
+ allowedRedirectUris: ["https://rp-down.test/cb"],
134
+ allowedScopes: ["openid"],
135
+ allowedGrantTypes: ["authorization_code"],
136
+ backchannelLogoutUri: "https://rp-down.test/logout",
137
+ },
138
+ },
139
+ });
140
+ try {
141
+ const components = handle.components as Record<string, unknown>;
142
+ const lifecycle = components.sessionLifecycle as SessionLifecycle;
143
+ const sessions = components.userSessionStore as UserSessionStore;
144
+ const { cookies } = await login(app);
145
+ // The code exchange joins the session; its id_token is the logout's hint.
146
+ const exchanged = await redeem(app, codeFrom(await authorize(app, cookies)));
147
+ expect(exchanged.status).toBe(200);
148
+ const idToken = exchanged.body.id_token as string;
149
+ const sid = String(claimsOf(idToken).sid);
150
+ const sessionGrant = () =>
151
+ request(app)
152
+ .post("/oauth/token")
153
+ .set("Authorization", basic(WEB))
154
+ .set("Cookie", cookies)
155
+ .type("form")
156
+ .send({ grant_type: "session", scope: "openid profile" });
157
+ const issued = await sessionGrant();
158
+ expect(issued.status).toBe(200);
159
+ // A session-grant token carries the session and no family.
160
+ const accessToken = issued.body.access_token as string;
161
+ expect(claimsOf(accessToken)).toMatchObject({ sid });
162
+ expect(claimsOf(accessToken).family_id).toBeUndefined();
163
+
164
+ // A relying party whose back-channel endpoint is down joins the session.
165
+ expect(
166
+ await lifecycle.join(sid, {
167
+ rp: {
168
+ clientId: "rp-down",
169
+ backchannelLogoutUri: "https://rp-down.test/logout",
170
+ backchannelLogoutSessionRequired: true,
171
+ frontchannelLogoutUri: undefined,
172
+ frontchannelLogoutSessionRequired: undefined,
173
+ registeredAt: new Date(),
174
+ },
175
+ }),
176
+ ).toEqual({ outcome: "joined" });
177
+ const posted = vi
178
+ .spyOn(globalThis, "fetch")
179
+ .mockImplementation(async () => new Response(null, { status: 503 }));
180
+
181
+ const logout = await request(app)
182
+ .post("/oauth/logout")
183
+ .type("form")
184
+ .send({ id_token_hint: idToken });
185
+
186
+ // The close committed with the notice pending: the logout succeeded.
187
+ expect(logout.status).toBe(200);
188
+ expect(logout.body).toEqual({ logged_out: true });
189
+ expect(posted).toHaveBeenCalled();
190
+ expect(await sessions.get(sid)).not.toBeNull();
191
+
192
+ const introspected = await request(app)
193
+ .post("/oauth/introspect")
194
+ .set("Authorization", basic(WEB))
195
+ .type("form")
196
+ .send({ token: accessToken });
197
+ expect(introspected.status).toBe(200);
198
+ expect(introspected.body).toEqual({ active: false });
199
+
200
+ const userinfo = await request(app)
201
+ .get("/oauth/userinfo")
202
+ .set("Authorization", `Bearer ${accessToken}`);
203
+ expect(userinfo.status).toBe(401);
204
+ expect(userinfo.body.error_description).toBe("session_invalid");
205
+
206
+ const again = await sessionGrant();
207
+ expect(again.status).toBe(400);
208
+ expect(again.body).toMatchObject({
209
+ error: "invalid_grant",
210
+ error_description: "session_invalid",
211
+ });
212
+ } finally {
213
+ await handle.dispose();
214
+ }
215
+ });
216
+ });
@@ -262,7 +262,6 @@ describe("the process each development run describes", () => {
262
262
 
263
263
  const modules = buildModules(switches, {
264
264
  environment: "development",
265
- logger: createRecordingLogger(),
266
265
  refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
267
266
  });
268
267
  const handle = await createApp({
@@ -300,7 +299,6 @@ describe("the process each development run describes", () => {
300
299
 
301
300
  const modules = buildModules(switches, {
302
301
  environment: configEnv,
303
- logger: createRecordingLogger(),
304
302
  refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
305
303
  });
306
304
  const names = modules.map((module) => module.name);