@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
@@ -30,8 +30,6 @@
30
30
  import { createHash, randomBytes } from "node:crypto";
31
31
  import { fileURLToPath } from "node:url";
32
32
  import {
33
- type AppConfig,
34
- AppConfigSchema,
35
33
  createApp,
36
34
  createKeyStoreFactory,
37
35
  defineModule,
@@ -43,19 +41,20 @@ import {
43
41
  registerBuiltinKeyStores,
44
42
  type UserSessionStore,
45
43
  } from "@o3co/auth-provider-core";
44
+ import { federationTypeForTests } from "@o3co/auth-provider-core/testing";
45
+ import type { OAuthSection } from "@o3co/auth-provider-oauth";
46
46
  import { parseFile } from "@o3co/ts.hocon";
47
- import { validate } from "@o3co/ts.hocon/zod";
48
47
  import express from "express";
49
48
  import request from "supertest";
50
49
  import { afterEach, describe, expect, it } from "vitest";
51
50
  import { buildModules } from "#/buildModules.mjs";
52
- import { resolveConfigPaths, type Switches } from "#/configPath.mjs";
51
+ import { resolveConfigPaths } from "#/configPath.mjs";
53
52
  import { templateReference } from "../modules.mjs";
54
53
  import {
54
+ type BothPhases,
55
+ bothPhasesOf,
55
56
  capturedRenames,
56
57
  libraryLayers,
57
- rootSectionsOf,
58
- sectionsCoreDoesNotDeclare,
59
58
  } from "./library-references.fixture.mjs";
60
59
 
61
60
  const configDir = fileURLToPath(new URL("../../config", import.meta.url));
@@ -94,14 +93,14 @@ const ENV: Readonly<Record<string, string>> = {
94
93
  };
95
94
 
96
95
  /** The shipped configuration, with the partner federation and an acr table beside it. */
97
- function resolveConfig(trustUpstreamAmr: boolean | undefined): Switches {
96
+ function resolveConfig(trustUpstreamAmr: boolean | undefined): BothPhases {
98
97
  const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
99
98
  const layers = parseFile(envConfPath, { env: ENV })
100
99
  .withFallback(parseFile(applicationConfPath, { env: ENV }))
101
100
  .withFallback(parseFile(fileURLToPath(templateReference()), { env: ENV }))
102
101
  .withFallback(libraryLayers(ENV));
103
- const shipped = validate(layers, AppConfigSchema) as AppConfig;
104
- const parsed = AppConfigSchema.parse({
102
+ const shipped = bothPhasesOf(layers, ENV);
103
+ return {
105
104
  ...shipped,
106
105
  core: {
107
106
  ...shipped.core,
@@ -116,21 +115,15 @@ function resolveConfig(trustUpstreamAmr: boolean | undefined): Switches {
116
115
  },
117
116
  },
118
117
  oauth: {
119
- ...shipped.oauth,
118
+ ...(shipped.oauth as OAuthSection),
120
119
  authorize: {
121
- ...shipped.oauth.authorize,
120
+ ...(shipped.oauth as OAuthSection).authorize,
122
121
  acrValues: { [MFA_ACR]: ["mfa"], [FED_ACR]: ["fed"] },
123
122
  },
124
123
  },
125
- });
126
- return {
127
- ...sectionsCoreDoesNotDeclare(layers),
128
- ...rootSectionsOf(layers, ENV),
129
- ...parsed,
130
- // What the resolution captured of core's renamed variables, which the
131
- // schema's parse drops.
124
+ // What the resolution captured of the renamed variables.
132
125
  "renamed-variables": capturedRenames(ENV),
133
- } as Switches;
126
+ } as BothPhases;
134
127
  }
135
128
 
136
129
  /** An IdP that authenticates `ext-1` and says it did so with `mfa` and `hwk`. */
@@ -151,18 +144,11 @@ const partner: FederationProvider = {
151
144
  }),
152
145
  };
153
146
 
154
- const partnerFederationModule = defineModule({
155
- name: "test:partner-federation",
156
- contributes: {
157
- federations: { [FEDERATION]: () => partner },
158
- federationRedirectPolicies: {
159
- [FEDERATION]: () => ({
160
- validateRedirect: () => ({ ok: true as const, value: undefined }),
161
- resolveCallbackRedirect: () => ({ ok: true as const, value: "/" }),
162
- }),
163
- },
164
- } as never,
165
- });
147
+ /**
148
+ * The partner federation's type: the `partner` entry is the IdP above, with a
149
+ * redirect policy that accepts every redirect and resolves the callback to `/`.
150
+ */
151
+ const partnerFederationModule = federationTypeForTests(FEDERATION, { provider: () => partner });
166
152
 
167
153
  const testRepositoriesModule = defineModule({
168
154
  name: "test:repositories",
@@ -354,6 +340,9 @@ describe("an upstream IdP's amr counts only for a federation that trusts it", ()
354
340
  federation: FEDERATION,
355
341
  upstreamAmr: UPSTREAM_AMR,
356
342
  mfaAt: undefined,
343
+ // The partner reports no `auth_time`, and the federation does not let
344
+ // its callback stand for one (`callbackMeetsFreshness`, false by default).
345
+ upstreamAuthTime: null,
357
346
  });
358
347
  });
359
348
 
@@ -379,6 +368,9 @@ describe("an upstream IdP's amr counts only for a federation that trusts it", ()
379
368
  federation: FEDERATION,
380
369
  upstreamAmr: undefined,
381
370
  mfaAt: undefined,
371
+ // The partner reports no `auth_time`, and the federation does not let
372
+ // its callback stand for one (`callbackMeetsFreshness`, false by default).
373
+ upstreamAuthTime: null,
382
374
  });
383
375
  });
384
376
 
@@ -19,11 +19,13 @@
19
19
  * slot. Phase one reads it alone, before the modules are chosen, with the
20
20
  * template's own schema; boot is never handed it. The paths the selections
21
21
  * moved from, and the variables renamed with them, are refused here, before
22
- * any module is chosen, in the words boot refuses a module's in: a renamed
23
- * variable set alone, or beside its new name at a different value, is
22
+ * any module is chosen, in the words and under the reasons boot refuses a
23
+ * module's with (`config-path-relocated`, `environment-variable-renamed`): a
24
+ * renamed variable set alone, or beside its new name at a different value, is
24
25
  * refused, naming the variables and never a value.
25
26
  */
26
27
 
28
+ import { configRefused, issuesAt, pathsRelocated } from "./bootRefusal.mjs";
27
29
  import { refuseRenamedVariables } from "./rootRenames.mjs";
28
30
  import { type Adapters, adaptersSchema } from "./sections.mjs";
29
31
 
@@ -33,6 +35,7 @@ export const ADAPTERS_SECTION = "adapters";
33
35
  /** Each selection's key under `adapters`, and the variable its path is bound to. */
34
36
  const VARIABLES: Readonly<Record<keyof Adapters, string>> = {
35
37
  rateLimiter: "ADAPTERS_RATE_LIMITER",
38
+ attemptCounter: "ADAPTERS_ATTEMPT_COUNTER",
36
39
  userSessionStores: "ADAPTERS_USER_SESSION_STORES",
37
40
  accessTokenDenylist: "ADAPTERS_ACCESS_TOKEN_DENYLIST",
38
41
  replaySeenSet: "ADAPTERS_REPLAY_SEEN_SET",
@@ -101,30 +104,40 @@ function valueAt(config: unknown, path: string): unknown {
101
104
  /**
102
105
  * `adapters` from `resolved` — the template's own layers over its
103
106
  * `config/reference.conf` — under `env`, the environment they were
104
- * substituted with, parsed with the template's schema. Refuses, with a
105
- * `RangeError`, a selection still written at the path it moved from, naming
106
- * its new path and variable; then a variable renamed with one
107
- * (`refuseRenamedVariables`); then a value or a key the schema refuses,
108
- * naming its path under `adapters`.
107
+ * substituted with, parsed with the template's schema. Refuses, each with a
108
+ * `BootError` (`bootRefusal.mts`), a selection still written at the path it
109
+ * moved from, naming its new path and variable (`config-path-relocated`);
110
+ * then a variable renamed with one (`refuseRenamedVariables`); then a value
111
+ * or a key the schema refuses, naming its path under `adapters`
112
+ * (`config-validation-failed`).
109
113
  */
110
114
  export function readAdapters(
111
115
  resolved: Readonly<Record<string, unknown>>,
112
116
  env: Readonly<Record<string, string>>,
113
117
  ): Adapters {
114
118
  const moved = MOVED.filter(([from]) => valueAt(resolved, from) !== undefined).map(
115
- ([from, key]) => {
116
- const to = `${ADAPTERS_SECTION}.${key}`;
117
- return `${from} has moved to ${to}; see CHANGELOG. Write it there (environment variable ${VARIABLES[key]}) and remove this field from your config (or unset the environment variable that sets it).`;
118
- },
119
+ ([from, key]) => ({
120
+ module: ADAPTERS_SECTION,
121
+ from,
122
+ to: `${ADAPTERS_SECTION}.${key}`,
123
+ environmentVariable: VARIABLES[key],
124
+ }),
119
125
  );
120
126
  if (moved.length > 0) {
121
- throw new RangeError(
122
- `Configuration sets ${moved.length} path(s) that moved: ${moved.join(" ")}`,
127
+ throw pathsRelocated(
128
+ `Configuration sets ${moved.length} path(s) that moved: ${moved
129
+ .map(
130
+ ({ from, to, environmentVariable }) =>
131
+ `${from} has moved to ${to}; see CHANGELOG. Write it there (environment variable ${environmentVariable}) and remove this field from your config (or unset the environment variable that sets it).`,
132
+ )
133
+ .join(" ")}`,
134
+ moved,
123
135
  );
124
136
  }
125
137
  refuseRenamedVariables(
126
138
  env,
127
139
  RENAMED.map(([from, key]) => ({
140
+ module: ADAPTERS_SECTION,
128
141
  from,
129
142
  to: VARIABLES[key],
130
143
  path: `${ADAPTERS_SECTION}.${key}`,
@@ -132,17 +145,17 @@ export function readAdapters(
132
145
  );
133
146
  const result = adaptersSchema.safeParse(resolved[ADAPTERS_SECTION]);
134
147
  if (!result.success) {
135
- const issues = result.error.issues;
136
- throw new RangeError(
148
+ const issues = issuesAt([ADAPTERS_SECTION], result.error.issues);
149
+ throw configRefused(
137
150
  `Config validation failed — ${issues.length} issue(s) found: ${issues
138
151
  .map((issue) => {
139
152
  const unknown =
140
153
  issue.code === "unrecognized_keys" ? ` ${JSON.stringify(issue.keys)}` : "";
141
- const path = [ADAPTERS_SECTION, ...issue.path.map(String)].join(".");
142
- return `${path}: ${issue.message}${unknown}`;
154
+ return `${issue.path.map(String).join(".")}: ${issue.message}${unknown}`;
143
155
  })
144
156
  .join("; ")}`,
145
- { cause: result.error },
157
+ issues,
158
+ [{ module: ADAPTERS_SECTION, schemaPath: ADAPTERS_SECTION }],
146
159
  );
147
160
  }
148
161
  return result.data;
@@ -19,6 +19,7 @@ import express from "express";
19
19
  import helmet from "helmet";
20
20
  import { buildModules } from "./buildModules.mjs";
21
21
  import {
22
+ configDefaultsFor,
22
23
  readLogging,
23
24
  readOwnLayers,
24
25
  readSwitches,
@@ -43,9 +44,7 @@ const { applicationConfPath, envConfPath } = resolveConfigPaths(configDirPath, e
43
44
  const own = readOwnLayers([envConfPath, applicationConfPath]);
44
45
  // Phase one: what the template reads before it knows its modules, only for
45
46
  // those choices — its own `adapters` and MFA switch (`mfaMode`, MFA_MODE),
46
- // and the switches core's reader parses — and for what the composition
47
- // expects of session admission: the configuration's list, with `mfa` added
48
- // when the MFA switch installs MFA.
47
+ // and whether federation grants are installed.
49
48
  const switches: Switches = readSwitches(own);
50
49
 
51
50
  // From the `logging` module's section, so the level holds from the first line;
@@ -83,13 +82,15 @@ await (async (): Promise<void> => {
83
82
  // so `CONFIG_ENV=production` is production to the guard too.
84
83
  //
85
84
  // Phase two: `createApp` parses the configuration as resolved once, with
86
- // every loaded module's schema (`configPath.mts`). From here on the
85
+ // every loaded module's schema (`configPath.mts`), and names each section
86
+ // nothing loaded reads by the configuration's defaults. From here on the
87
87
  // template reads the parsed configuration.
88
- const modules = buildModules(switches, { environment: env, logger });
88
+ const modules = buildModules(switches, { environment: env });
89
89
  const handle = await createApp({
90
90
  modules,
91
91
  bootstrapComponents: {
92
92
  config: resolveForBoot(own, modules, switches),
93
+ configDefaults: configDefaultsFor(modules),
93
94
  pathResolver: import.meta.resolve,
94
95
  logger,
95
96
  },
@@ -0,0 +1,117 @@
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's refusals before boot, as core's `BootError`: each under the
19
+ * reason boot raises for the same case, at the stage boot raises it in
20
+ * (`validateManifests`), so that an alert keyed on a boot error's reason sees
21
+ * every refusal to start. The template's checks stay its own; only how a
22
+ * refusal is reported is boot's.
23
+ *
24
+ * The details name a module as boot does. A key of the composition root's
25
+ * own — the `adapters` section, the `mfaMode` switch — is named after itself,
26
+ * as boot names core's own section "core": a module with a section under
27
+ * either name is refused (`resolveForBoot`), so the name points at the key.
28
+ */
29
+
30
+ import {
31
+ BootError,
32
+ type ConfigPathRelocatedDetails,
33
+ type ConfigValidationFailedDetails,
34
+ type EnvironmentVariableRenamedDetails,
35
+ } from "@o3co/auth-provider-core";
36
+
37
+ /** A Zod issue as `config-validation-failed` carries one: its path is the path in the configuration. */
38
+ export type ConfigIssue = ConfigValidationFailedDetails["issues"][number];
39
+
40
+ /** The stage boot raises the configuration's refusals in. */
41
+ const STAGE = "validateManifests";
42
+
43
+ /** An issue at `path` of the configuration that no schema raised: what is wrong there, in `message`. */
44
+ export function customIssue(path: readonly string[], message: string): ConfigIssue {
45
+ return { code: "custom", path: [...path], message } as ConfigIssue;
46
+ }
47
+
48
+ /** `issues`, each with `prefix`, the path its schema read at, in front of its own path. */
49
+ export function issuesAt(prefix: readonly string[], issues: readonly ConfigIssue[]): ConfigIssue[] {
50
+ return issues.map((issue) => ({ ...issue, path: [...prefix, ...issue.path] }) as ConfigIssue);
51
+ }
52
+
53
+ /**
54
+ * `config-validation-failed`: `issues` at their paths, refused in the
55
+ * sections `modules` read, each at its `schemaPath`.
56
+ */
57
+ export function configRefused(
58
+ message: string,
59
+ issues: readonly ConfigIssue[],
60
+ modules: ConfigValidationFailedDetails["modules"],
61
+ ): BootError {
62
+ return new BootError({
63
+ message,
64
+ reason: "config-validation-failed",
65
+ stage: STAGE,
66
+ details: { reason: "config-validation-failed", issues: [...issues], modules: [...modules] },
67
+ });
68
+ }
69
+
70
+ /**
71
+ * `config-validation-failed` for one key, at `path`, of the section `module`
72
+ * reads at its own name: `message` is the refusal and the issue's message.
73
+ */
74
+ export function keyRefused(message: string, module: string, path: readonly string[]): BootError {
75
+ return configRefused(message, [customIssue(path, message)], [{ module, schemaPath: module }]);
76
+ }
77
+
78
+ /** `config-path-relocated`: each key still set at a path it moved from. */
79
+ export function pathsRelocated(
80
+ message: string,
81
+ relocated: ConfigPathRelocatedDetails["relocated"],
82
+ ): BootError {
83
+ return new BootError({
84
+ message,
85
+ reason: "config-path-relocated",
86
+ stage: STAGE,
87
+ details: { reason: "config-path-relocated", relocated: [...relocated] },
88
+ });
89
+ }
90
+
91
+ /** `environment-variable-renamed`: each old variable name set, with no value. */
92
+ export function variablesRenamed(
93
+ message: string,
94
+ renamed: EnvironmentVariableRenamedDetails["renamed"],
95
+ ): BootError {
96
+ return new BootError({
97
+ message,
98
+ reason: "environment-variable-renamed",
99
+ stage: STAGE,
100
+ details: { reason: "environment-variable-renamed", renamed: [...renamed] },
101
+ });
102
+ }
103
+
104
+ /** `module-section-path-invalid`: `module`'s section is read at `at`, which `problem` says it may not be. */
105
+ export function sectionPathRefused(
106
+ message: string,
107
+ module: string,
108
+ at: string,
109
+ problem: string,
110
+ ): BootError {
111
+ return new BootError({
112
+ message,
113
+ reason: "module-section-path-invalid",
114
+ stage: STAGE,
115
+ details: { reason: "module-section-path-invalid", module, at, problem },
116
+ });
117
+ }
@@ -14,12 +14,9 @@
14
14
  * limitations under the License.
15
15
  */
16
16
  import {
17
- consoleLogger,
18
17
  defaultRefreshTokenFamilyRevocationModule,
19
18
  defaultRefreshTokenFamilyRotationModule,
20
- federationsOf,
21
19
  jwksModule,
22
- type Logger,
23
20
  type Module,
24
21
  memoryAccessTokenDenylistModule,
25
22
  memoryConsentStoreModule,
@@ -27,18 +24,20 @@ import {
27
24
  memoryFederationGrantStoreModule,
28
25
  memoryRateLimiterModule,
29
26
  memoryReplaySeenSetModule,
27
+ sessionLifecycleModule,
30
28
  } from "@o3co/auth-provider-core";
31
- import { googleFederationModule } from "@o3co/auth-provider-federation-google";
29
+ import { googleFederationTypeModule } from "@o3co/auth-provider-federation-google";
32
30
  import { federationGrantsModules } from "@o3co/auth-provider-federation-grants";
33
- import { oidcFederationModule, oidcFederationNames } from "@o3co/auth-provider-federation-oidc";
31
+ import { oidcFederationTypeModule } from "@o3co/auth-provider-federation-oidc";
34
32
  import {
35
- oauthAuthorizationModule,
36
- oauthModule,
37
- oauthSessionModule,
33
+ oauthAuthorizationGrantsModule,
34
+ oauthEndpointsModule,
35
+ oauthSessionGrantModule,
38
36
  subjectRevocationServiceModule,
39
37
  } from "@o3co/auth-provider-oauth";
40
38
  import {
41
39
  redisAccessTokenDenylistModule,
40
+ redisAttemptCounterModule,
42
41
  redisCodeRepositoryModule,
43
42
  redisConsentStoreModule,
44
43
  redisFederationGrantIntentStoreModule,
@@ -49,11 +48,7 @@ import {
49
48
  redisReplaySeenSetModule,
50
49
  redisSessionStoresModule,
51
50
  } from "@o3co/auth-provider-redis";
52
- import {
53
- extractFederationSection,
54
- sessionModule,
55
- sessionStoreModuleFor,
56
- } from "@o3co/auth-provider-session";
51
+ import { sessionModule, sessionStoreModule } from "@o3co/auth-provider-session";
57
52
  import {
58
53
  standardDevelopmentMailSenderModule,
59
54
  standardSmtpMailSenderModule,
@@ -62,20 +57,18 @@ import type { Switches } from "./configPath.mjs";
62
57
  import { mfaModulesFor } from "./mfaSwitch.mjs";
63
58
  import {
64
59
  auditSinkModuleFor,
65
- googleFederationConfigModule,
66
60
  httpModule,
67
61
  inMemoryCodeRepositoryModule,
68
62
  inMemoryFederationTokenStoreModule,
69
63
  inMemorySessionStoresModule,
70
64
  keyStoreModule,
71
65
  loggingModule,
72
- oidcFederationConfigModule,
73
66
  repositoriesModuleFor,
74
67
  standaloneRedisClientsModule,
75
68
  } from "./modules.mjs";
76
69
 
77
70
  /**
78
- * Overrides for the composition. All but `environment`, `logger` and
71
+ * Overrides for the composition. All but `environment` and
79
72
  * `mailSenderModules` are test-only: they let the smoke test substitute
80
73
  * in-memory implementations of the file-system-backed modules, and production
81
74
  * callers should not pass them — the defaults match the standalone scaffold.
@@ -93,15 +86,8 @@ export interface BuildModulesOverrides {
93
86
  * MFA module reads it too, for the development sample key's refusal.
94
87
  */
95
88
  readonly environment?: string;
96
- /**
97
- * Where the composition's own notices go — a deprecated config key, one
98
- * `config_key_deprecated` line (warn) each. `app.mts` passes the logger it
99
- * hands every module; omitted, `consoleLogger`.
100
- */
101
- readonly logger?: Logger;
102
89
  readonly keyStoreModule?: Module;
103
90
  readonly repositoriesModule?: Module;
104
- readonly storesModule?: Module;
105
91
  /**
106
92
  * Replaces the refresh-token family store modules (default
107
93
  * `[redisRefreshTokenFamilyStoreModule]`, on the shared ioredis socket).
@@ -127,41 +113,16 @@ export interface BuildModulesOverrides {
127
113
  readonly mailSenderModules?: readonly Module[];
128
114
  }
129
115
 
130
- /**
131
- * The access-token lifetime core's `reference.conf` ships on the deprecated
132
- * `oauth.accessToken.expiresIn`. Not a default — nothing mints with it — only
133
- * what an unmodified deployment carries there, so the deprecation line can tell
134
- * an operator's override from the shipped value. Pinned against the real file
135
- * by `access-token-lifetime-alias.test.mts`.
136
- */
137
- const SHIPPED_ACCESS_TOKEN_EXPIRES_IN = 3600;
138
-
139
- /** Whether `value` sets anything: a value, or a section with one somewhere under it. */
140
- function setsAnything(value: unknown): boolean {
141
- if (value === undefined) return false;
142
- if (typeof value !== "object" || value === null || Array.isArray(value)) return true;
143
- return Object.values(value).some(setsAnything);
144
- }
145
-
146
116
  /**
147
117
  * Compose the standalone module list from phase one's `config`: the
148
118
  * composition root's `adapters`, which choose the adapter behind each slot,
149
- * and the switches core's reader parsed. Kept out of `app.mts` so a smoke
150
- * test can check the manifest (that disabling a federation removes its module
151
- * pair, say) without an HTTP server. The rules for editing the list (order,
152
- * one module per store slot, federation adapters with their config bridges)
153
- * are in the template README, "Module Composition Order".
119
+ * its MFA switch, and whether federation grants are installed. Kept out of
120
+ * `app.mts` so a smoke test can check the manifest (that disabling federation
121
+ * grants removes their modules, say) without an HTTP server. The rules for
122
+ * editing the list (order, one module per store slot, the federation types it
123
+ * bundles) are in the template README, "Module Composition Order".
154
124
  */
155
125
  export function buildModules(config: Switches, overrides: BuildModulesOverrides = {}): Module[] {
156
- // An entry's `type` names the implementation, so the two gates never both
157
- // select one entry: `core.federations.google` is the built-in Google
158
- // federation only when its type is `google` (that name's default); with
159
- // `type = "oidc"` it is a generic OIDC instance, and composing both would
160
- // contribute the same federation and redirect-policy keys twice.
161
- const federations = federationsOf(config);
162
- const googleEnabled = extractFederationSection(federations, "google")?.type === "google";
163
- const oidcFederations = oidcFederationNames(federations);
164
- const logger = overrides.logger ?? consoleLogger;
165
126
  const adapters = config.adapters;
166
127
 
167
128
  // Federation grants: a user's standing consent that a client may obtain
@@ -174,11 +135,10 @@ export function buildModules(config: Switches, overrides: BuildModulesOverrides
174
135
  // subject holds" include the grants. Enabling it also states the deployment
175
136
  // has a consent page, a callback per connection and a user repository
176
137
  // covering each connection's registration; the routes module refuses at
177
- // boot what is missing, naming it. A setting still written at the section's
178
- // old path, `federationGrants`, installs the feature too, so that boot
179
- // refuses it naming the new path rather than reading the feature as off.
180
- const federationGrantsEnabled =
181
- config["federation-grants"]?.enabled === true || setsAnything(config.federationGrants);
138
+ // boot what is missing, naming it. Phase one decides it (`readSwitches`): a
139
+ // value boot would refuse installs the feature, so that boot refuses it
140
+ // rather than reading the feature as off.
141
+ const federationGrantsEnabled = config["federation-grants"]?.enabled === true;
182
142
 
183
143
  // MFA: the template's own switch, `mfaMode` (MFA_MODE). `off` installs
184
144
  // nothing of MFA; `optional` and `required` install what `mfaModulesFor`
@@ -187,28 +147,6 @@ export function buildModules(config: Switches, overrides: BuildModulesOverrides
187
147
  // switch (`resolveForBoot`).
188
148
  const mfaInstalled = config.mfaMode !== "off";
189
149
 
190
- // The deprecated alias `oauth.accessToken.expiresIn`
191
- // (OAUTH_ACCESS_TOKEN_EXPIRES_IN) is still read as the default while
192
- // `defaultExpiresIn` is unset; `resolveAccessTokenLifetime` does that, and
193
- // this only warns. Core's `reference.conf` ships the lifetime on the
194
- // deprecated key, so only an override of it has something to move. See
195
- // CHANGELOG for the removal version.
196
- const accessToken = config.oauth.accessToken;
197
- if (
198
- accessToken.defaultExpiresIn === undefined &&
199
- accessToken.expiresIn !== SHIPPED_ACCESS_TOKEN_EXPIRES_IN
200
- ) {
201
- logger.warn(
202
- {
203
- key: "oauth.accessToken.expiresIn",
204
- env: "OAUTH_ACCESS_TOKEN_EXPIRES_IN",
205
- replacement: "oauth.accessToken.defaultExpiresIn",
206
- replacementEnv: "OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN",
207
- },
208
- "config_key_deprecated",
209
- );
210
- }
211
-
212
150
  const refreshTokenFamilyModules: readonly Module[] = overrides.refreshTokenFamilyModules ?? [
213
151
  redisRefreshTokenFamilyStoreModule,
214
152
  ];
@@ -225,6 +163,7 @@ export function buildModules(config: Switches, overrides: BuildModulesOverrides
225
163
  const usingRedisAnywhere =
226
164
  refreshTokenFamilyUsesRedis ||
227
165
  adapters.rateLimiter === "redis" ||
166
+ adapters.attemptCounter === "redis" ||
228
167
  adapters.userSessionStores === "redis" ||
229
168
  adapters.codeRepository === "redis" ||
230
169
  adapters.accessTokenDenylist === "redis" ||
@@ -245,13 +184,16 @@ export function buildModules(config: Switches, overrides: BuildModulesOverrides
245
184
  const sessionStoresModules: Module[] =
246
185
  adapters.userSessionStores === "redis"
247
186
  ? [redisSessionStoresModule]
248
- : overrides.storesModule
249
- ? [overrides.storesModule]
250
- : [inMemorySessionStoresModule];
187
+ : [inMemorySessionStoresModule];
251
188
 
252
189
  const rateLimiterModules: Module[] =
253
190
  adapters.rateLimiter === "redis" ? [redisRateLimiterModule] : [memoryRateLimiterModule];
254
191
 
192
+ // `memory` installs no counter: the login's attempt guard counts per
193
+ // process, which `core.deployment.mode = "multi"` refuses.
194
+ const attemptCounterModules: Module[] =
195
+ adapters.attemptCounter === "redis" ? [redisAttemptCounterModule] : [];
196
+
255
197
  // Mutually exclusive: both modules provide the `codeRepository` slot, and
256
198
  // including both would be a boot-time slot collision.
257
199
  const codeRepositoryModules: Module[] =
@@ -259,7 +201,7 @@ export function buildModules(config: Switches, overrides: BuildModulesOverrides
259
201
  ? [redisCodeRepositoryModule]
260
202
  : [inMemoryCodeRepositoryModule];
261
203
 
262
- // The RFC 7009 access-token denylist has no "none": `oauthModule` mounts
204
+ // The RFC 7009 access-token denylist has no "none": `oauthEndpointsModule` mounts
263
205
  // `/oauth/revoke` and reads the `accessTokenDenylist` slot, and core's boot
264
206
  // validator refuses a composition that reads the slot with nothing filling
265
207
  // it, since the endpoint would answer 200 while the token kept working. The
@@ -349,25 +291,26 @@ export function buildModules(config: Switches, overrides: BuildModulesOverrides
349
291
  return [
350
292
  // MUST stay first: it declares no `before`/`after`, so this position is
351
293
  // what mounts express-session ahead of every session-consuming module.
352
- // Built from `config` so that `session-store.storage.type = "memory"` declares
353
- // itself replica-unsafe and `core.deployment.mode = "multi"` refuses it.
354
- sessionStoreModuleFor(config),
355
- // Under `/oauth` beside `oauthModule`, each parsing its own requests, so
294
+ // It declares `session-store.storage.type = "memory"` replica-unsafe from
295
+ // its own section, so `core.deployment.mode = "multi"` refuses it.
296
+ sessionStoreModule,
297
+ // Under `/oauth` beside `oauthEndpointsModule`, each parsing its own requests, so
356
298
  // their relative order does not matter; the browser half orders itself
357
299
  // after the session middleware by its own `after`.
358
300
  ...(federationGrantsEnabled ? federationGrantsModules : []),
359
- oauthModule({ config }),
360
- oauthSessionModule({ config }),
361
- oauthAuthorizationModule({ config }),
301
+ oauthEndpointsModule,
302
+ oauthSessionGrantModule,
303
+ oauthAuthorizationGrantsModule,
362
304
  // Always wired: a provider that signs tokens must publish its
363
- // verification keys regardless of OIDC issuer config, and `oauthModule`'s
305
+ // verification keys regardless of OIDC issuer config, and `oauthEndpointsModule`'s
364
306
  // discovery `jwks_uri` (advertised when an issuer is set) must resolve.
365
307
  jwksModule,
366
308
  sessionModule,
367
- ...(googleEnabled ? [googleFederationModule, googleFederationConfigModule] : []),
368
- ...(oidcFederations.length > 0
369
- ? [oidcFederationConfigModule, ...oidcFederations.map((name) => oidcFederationModule(name))]
370
- : []),
309
+ // The federation types the template bundles, always: each contributes
310
+ // its type, and boot hands it every enabled `core.federations` entry
311
+ // that names it, under the entry's name. Nothing here reads an entry.
312
+ googleFederationTypeModule(),
313
+ oidcFederationTypeModule(),
371
314
  // The template's own settings: `logging {}` and `http {}`.
372
315
  loggingModule,
373
316
  httpModule,
@@ -386,7 +329,14 @@ export function buildModules(config: Switches, overrides: BuildModulesOverrides
386
329
  ...federationGrantStoreModules,
387
330
  ...federationGrantIntentStoreModules,
388
331
  ...sessionStoresModules,
332
+ // Core's session lifecycle, over the session stores' lifecycle store:
333
+ // a login opens its session's record, a code exchange joins its relying
334
+ // party and family to it, and both logout endpoints close it, which
335
+ // revokes the families and tells the relying parties. Its sweep resumes
336
+ // a close left pending.
337
+ sessionLifecycleModule,
389
338
  ...rateLimiterModules,
339
+ ...attemptCounterModules,
390
340
  ...codeRepositoryModules,
391
341
  ...accessTokenDenylistModules,
392
342
  ...replaySeenSetModules,