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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.ja.md +66 -21
  2. package/README.md +76 -31
  3. package/dist/index.d.mts +33 -10
  4. package/dist/index.d.mts.map +1 -1
  5. package/dist/index.mjs +118 -59
  6. package/dist/internal/template-filter.d.mts +7 -10
  7. package/dist/internal/template-filter.d.mts.map +1 -1
  8. package/dist/internal/template-filter.mjs +7 -10
  9. package/package.json +6 -4
  10. package/templates/standalone/.dockerignore +15 -2
  11. package/templates/standalone/.env.example +59 -26
  12. package/templates/standalone/Dockerfile +6 -2
  13. package/templates/standalone/README.ja.md +293 -162
  14. package/templates/standalone/README.md +533 -199
  15. package/templates/standalone/config/application.conf +196 -341
  16. package/templates/standalone/config/clients.yaml +2 -0
  17. package/templates/standalone/config/clients.yaml.example +4 -0
  18. package/templates/standalone/config/development.conf +13 -4
  19. package/templates/standalone/config/mailpit.conf +8 -0
  20. package/templates/standalone/config/production.conf +5 -6
  21. package/templates/standalone/config/reference.conf +399 -0
  22. package/templates/standalone/docker-compose.mailpit.yml +36 -0
  23. package/templates/standalone/docker-compose.production.yml +58 -30
  24. package/templates/standalone/docker-compose.yml +13 -3
  25. package/templates/standalone/gitignore +1 -0
  26. package/templates/standalone/package.json +9 -6
  27. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +19 -20
  28. package/templates/standalone/src/__tests__/adapters.test.mts +358 -0
  29. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +216 -141
  30. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +48 -33
  31. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +77 -58
  32. package/templates/standalone/src/__tests__/audit-sink.test.mts +78 -76
  33. package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
  34. package/templates/standalone/src/__tests__/core-federations.test.mts +226 -0
  35. package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
  36. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +9 -14
  37. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +656 -269
  38. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +425 -84
  39. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +91 -70
  40. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +7 -5
  41. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +22 -19
  42. package/templates/standalone/src/__tests__/google-session.fixture.mts +53 -42
  43. package/templates/standalone/src/__tests__/host-routes.test.mts +4 -11
  44. package/templates/standalone/src/__tests__/library-references.fixture.mts +183 -0
  45. package/templates/standalone/src/__tests__/listen.test.mts +6 -13
  46. package/templates/standalone/src/__tests__/logger-projection.test.mts +1 -1
  47. package/templates/standalone/src/__tests__/mail-sender.test.mts +134 -0
  48. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +328 -0
  49. package/templates/standalone/src/__tests__/metrics.test.mts +4 -9
  50. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
  51. package/templates/standalone/src/__tests__/mfa-switch.test.mts +780 -0
  52. package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +52 -46
  53. package/templates/standalone/src/__tests__/own-modules.test.mts +1457 -0
  54. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +7 -11
  55. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +1 -5
  56. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +112 -81
  57. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
  58. package/templates/standalone/src/__tests__/replica-safety.test.mts +153 -142
  59. package/templates/standalone/src/__tests__/router-error-answers.test.mts +4 -7
  60. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +275 -95
  61. package/templates/standalone/src/__tests__/sections.test.mts +480 -0
  62. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +67 -55
  63. package/templates/standalone/src/__tests__/session-requirements.test.mts +299 -0
  64. package/templates/standalone/src/__tests__/shutdown.test.mts +163 -96
  65. package/templates/standalone/src/__tests__/smoke.test.mts +179 -252
  66. package/templates/standalone/src/__tests__/store-credential.test.mts +91 -50
  67. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
  68. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +108 -95
  69. package/templates/standalone/src/__tests__/two-phase-config.test.mts +438 -0
  70. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +390 -0
  71. package/templates/standalone/src/adapters.mts +149 -0
  72. package/templates/standalone/src/app.mts +64 -79
  73. package/templates/standalone/src/buildModules.mts +212 -251
  74. package/templates/standalone/src/configPath.mts +422 -29
  75. package/templates/standalone/src/listen.mts +12 -18
  76. package/templates/standalone/src/logger.mts +31 -60
  77. package/templates/standalone/src/metrics.mts +24 -47
  78. package/templates/standalone/src/mfaSwitch.mts +262 -0
  79. package/templates/standalone/src/modules.mts +458 -476
  80. package/templates/standalone/src/rootRenames.mts +79 -0
  81. package/templates/standalone/src/routes.mts +8 -14
  82. package/templates/standalone/src/sections.mts +366 -0
  83. package/templates/standalone/src/shutdown.mts +45 -123
  84. package/templates/standalone/tests/index.test.js +2 -2
  85. package/templates/standalone/tests/package-lock.json +1615 -2223
  86. package/templates/standalone/tests/package.json +2 -2
  87. package/templates/standalone/tests/vitest.config.mjs +26 -0
  88. package/templates/standalone/vitest.config.mts +8 -7
  89. package/templates/standalone/vitest.supertest-loopback.mts +97 -69
  90. package/templates/versions.json +18 -15
@@ -15,94 +15,116 @@
15
15
  */
16
16
 
17
17
  import { fileURLToPath } from "node:url";
18
- import { type AppConfig, AppConfigSchema } from "@o3co/auth-provider-core";
18
+ import { type AppConfig, AppConfigSchema, coreReference } from "@o3co/auth-provider-core";
19
19
  import { parseFile } from "@o3co/ts.hocon";
20
20
  import { validate } from "@o3co/ts.hocon/zod";
21
21
  import { describe, expect, it } from "vitest";
22
+ import { readAdapters } from "../adapters.mjs";
22
23
  import { buildModules } from "../buildModules.mjs";
23
- import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "../configPath.mjs";
24
+ import { resolveConfigPaths, type Switches } from "../configPath.mjs";
25
+ import { readMfaSwitch } from "../mfaSwitch.mjs";
26
+ import { templateReference } from "../modules.mjs";
24
27
 
25
28
  // config/ is two levels above this test file:
26
29
  // src/__tests__/ → src/ → standalone/ → config/
27
30
  const configDir = fileURLToPath(new URL("../../config", import.meta.url));
28
31
 
29
32
  // Provide required secrets so AppConfigSchema parse succeeds. These are
30
- // test-only values — no real keys are embedded here. #282: SESSION_SECRET
33
+ // test-only values — no real keys are embedded here. SESSION_STORE_SECRET
31
34
  // carries a 256-bit entropy floor, so these clear it (the '.' characters keep
32
35
  // them outside the base64 alphabet, so the UTF-8 length is what counts).
33
36
  const testEnv = {
34
- OAUTH_JWT_SECRET: "test-secret-three-tier.at-least-32-bytes.ok",
37
+ KEY_STORE_LOCAL_SECRET: "test-secret-three-tier.at-least-32-bytes.ok",
35
38
  OAUTH_JWT_ISSUER: "https://auth.test",
36
- SESSION_SECRET: "test-session-secret-three-tier.at-least-32-bytes.ok",
39
+ SESSION_STORE_SECRET: "test-session-secret-three-tier.at-least-32-bytes.ok",
37
40
  };
38
41
 
39
- function buildResolvedConfig(env: string, extraEnv: Record<string, string> = {}): AppConfig {
42
+ /**
43
+ * The three tiers under `env`, parsed with core's schema, beside the
44
+ * composition root's `adapters` and `mfaMode` phase one reads from the
45
+ * template's own layers.
46
+ */
47
+ function buildResolvedConfig(env: string, extraEnv: Record<string, string> = {}): Switches {
40
48
  const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, env);
41
- const libraryReferencePath = resolveLibraryReferenceConfPath();
49
+ const libraryReferencePath = fileURLToPath(coreReference());
42
50
  const resolvedEnv = { ...testEnv, ...extraEnv };
43
- return validate(
44
- parseFile(envConfPath, { env: resolvedEnv })
45
- .withFallback(parseFile(applicationConfPath, { env: resolvedEnv }))
46
- .withFallback(parseFile(libraryReferencePath, { env: resolvedEnv })),
47
- AppConfigSchema,
48
- );
51
+ const own = parseFile(envConfPath, { env: resolvedEnv })
52
+ .withFallback(parseFile(applicationConfPath, { env: resolvedEnv }))
53
+ .withFallback(parseFile(fileURLToPath(templateReference()), { env: resolvedEnv }));
54
+ return {
55
+ ...validate(
56
+ own.withFallback(parseFile(libraryReferencePath, { env: resolvedEnv })),
57
+ AppConfigSchema,
58
+ ),
59
+ adapters: readAdapters(own.toObject() as Record<string, unknown>, resolvedEnv),
60
+ mfaMode: readMfaSwitch(own.toObject() as Record<string, unknown>, resolvedEnv),
61
+ storeTransport: undefined,
62
+ };
49
63
  }
50
64
 
51
- // `oauth.grants` is declared as `z.object({}).passthrough()` in the schema,
52
- // so the inferred TS type is `{}` per entry and accessing `.enabled` doesn't
53
- // compile under `tsc --strict`. Tests assert against the resolved runtime
54
- // shape (string from env substitution, boolean from literal), so the cast
55
- // is intentional and well-bounded.
65
+ // The oauth-authorization module's section, which core mirrors for the one
66
+ // key read before the modules are chosen (`grants`), kept as written: the
67
+ // runtime shape is a string from env substitution, a boolean from a literal.
56
68
  type GrantEntry = { enabled?: unknown };
57
69
  const grants = (config: AppConfig) =>
58
- (config.oauth.grants as unknown as Record<string, GrantEntry | undefined>) ?? {};
70
+ ((config["oauth-authorization"] as { grants?: unknown } | undefined)?.grants as
71
+ | Record<string, GrantEntry | undefined>
72
+ | undefined) ?? {};
59
73
 
60
74
  describe("three-tier HOCON resolution (env → application.conf → reference.conf)", () => {
61
75
  it("template application.conf wins over reference.conf for grant.enabled", () => {
62
76
  const config = buildResolvedConfig("development");
63
- // Template's application.conf sets authorization_code.enabled = true.
64
- // This test asserts the resolved value — it remains true whether
65
- // reference.conf says false (current secure-default) or true (legacy).
66
- expect(grants(config).authorization_code?.enabled).toBe(true);
77
+ // Template's application.conf sets authorizationCode.enabled = true,
78
+ // so the resolved value is true whatever reference.conf says.
79
+ expect(grants(config).authorizationCode?.enabled).toBe(true);
67
80
  });
68
81
 
69
82
  it("reference.conf default reaches resolved config when template omits the key", () => {
70
83
  const config = buildResolvedConfig("development");
71
- // tokenExchange.maxActorChainDepth is library-owned in both layers
72
- // (template doesn't override it). Verifies precedence falls through.
73
- expect(config.oauth.tokenExchange?.maxActorChainDepth).toBe(3);
84
+ // nonce.maxLength is library-owned in both layers (template doesn't
85
+ // override it). Verifies precedence falls through.
86
+ expect(config.oauth.nonce?.maxLength).toBe(256);
74
87
  });
75
88
 
76
89
  it("env var at template layer can disable a template-enabled grant (precedence: env-override line must be repeated)", () => {
77
- // Template enables authorization_code via application.conf. The env-override
78
- // line is repeated at the template layer alongside `enabled = true`,
79
- // so OAUTH_GRANTS_AUTHORIZATION_CODE_ENABLED=false reaches the resolved value.
80
- // Without the repeated env line, the substitution at reference.conf is
81
- // shadowed by the template's literal `true`.
82
- // Note: ts.hocon substitutes env vars as strings, and `oauth.grants` is
83
- // `z.object({}).passthrough()` — an open tree each grant module's own
84
- // `configSchema` validates — so no shape-walking coercion reaches this
85
- // leaf and the string survives to the resolved config. #288 unified the
86
- // coercion for every DECLARED boolean in the schema and deliberately
87
- // left this one alone: every consumer of a grant's `enabled`
88
- // (`GrantRegistry.isEnabled`, `oauthAuthorization`, `oauthSession`)
89
- // already reads `true` and `"true"` as enabled and everything else,
90
- // `"false"` included, as not-enabled.
90
+ // The env-override line is repeated at the template layer alongside
91
+ // `enabled = true`; without it, the package reference's substitution is
92
+ // shadowed by the template's literal `true`. The value stays a string:
93
+ // core mirrors the section as written, and the module reads the switch
94
+ // with its own schema, `"false"` as off.
91
95
  const config = buildResolvedConfig("development", {
92
- OAUTH_GRANTS_AUTHORIZATION_CODE_ENABLED: "false",
96
+ OAUTH_AUTHORIZATION_GRANTS_AUTHORIZATION_CODE_ENABLED: "false",
93
97
  });
94
- expect(grants(config).authorization_code?.enabled).toBe("false");
98
+ expect(grants(config).authorizationCode?.enabled).toBe("false");
99
+ });
100
+
101
+ it("reads the MFA switch, mfaMode, as required where MFA_MODE is unset, and installs no MFA module under MFA_MODE=off", () => {
102
+ // The template's reference.conf ships the switch required and binds MFA_MODE.
103
+ const mfaNames = (config: Switches) =>
104
+ buildModules(config)
105
+ .map((m) => m.name)
106
+ .filter((name) => /mfa/i.test(name));
107
+ const shipped = buildResolvedConfig("development");
108
+ expect(shipped.mfaMode).toBe("required");
109
+ expect(mfaNames(shipped)).toContain("mfa");
110
+ const off = buildResolvedConfig("development", { MFA_MODE: "off" });
111
+ expect(off.mfaMode).toBe("off");
112
+ expect(mfaNames(off)).toEqual([]);
113
+ const optional = buildResolvedConfig("development", { MFA_MODE: "optional" });
114
+ expect(optional.mfaMode).toBe("optional");
115
+ expect(mfaNames(optional)).toContain("mfa");
95
116
  });
96
117
 
97
- it("reference.conf default for rateLimit.failMode is 'closed'", () => {
118
+ it("core's reference.conf ships no rateLimit.failMode: it is the Redis limiter's own key", () => {
98
119
  const config = buildResolvedConfig("development");
99
- expect(config.rateLimit?.failMode).toBe("closed");
120
+ expect(config).not.toHaveProperty("rateLimit.failMode");
100
121
  });
101
122
 
102
- it("reference.conf default for client_credentials.enabled is false", () => {
103
- // Template doesn't enable client_credentials. Reference default propagates.
123
+ it("template application.conf ships clientCredentials.enabled off", () => {
124
+ // The template writes every grant switch it reads before boot, so the
125
+ // value phase one reads is the one boot parses.
104
126
  const config = buildResolvedConfig("development");
105
- expect(grants(config).client_credentials?.enabled).toBe(false);
127
+ expect(grants(config).clientCredentials?.enabled).toBe(false);
106
128
  });
107
129
 
108
130
  it("reference.conf default for oauth.resourceIndicator.enabled is false", () => {
@@ -124,17 +146,13 @@ describe("three-tier HOCON resolution (env → application.conf → reference.co
124
146
  expect(config.oauth.resourceIndicator?.enabled).toBe(true);
125
147
  });
126
148
 
127
- // #287 — the audit trail as the shipped artifact resolves it. The section is
128
- // declared in core's schema for a load-bearing reason: `AppConfigSchema` is
129
- // a plain `z.object` and strips what it does not declare, so an undeclared
130
- // `audit` block would vanish between `parseFile` and `buildModules` and the
131
- // sink selector would read `undefined` while the config sat in the file
132
- // looking effective. These assertions run the real three-tier merge, so they
133
- // fail if either layer stops carrying the key.
134
- describe("#287: the audit sink as the shipped artifact resolves it", () => {
135
- it("resolves audit.sink.type to the template's logger sink with nothing set", () => {
149
+ // The selection is the composition root's own, `adapters.auditSink`,
150
+ // which phase one reads over the template's own layers. These assertions
151
+ // run the real merge, so they fail if a layer stops carrying the key.
152
+ describe("the audit sink as the shipped artifact resolves it", () => {
153
+ it("resolves adapters.auditSink to the template's logger sink with nothing set", () => {
136
154
  const config = buildResolvedConfig("production");
137
- expect(config.audit?.sink.type).toBe("logger");
155
+ expect(config.adapters.auditSink).toBe("logger");
138
156
  });
139
157
 
140
158
  it("wires exactly one auditSink provider for that resolved config", () => {
@@ -144,36 +162,30 @@ describe("three-tier HOCON resolution (env → application.conf → reference.co
144
162
  });
145
163
 
146
164
  it("lets an operator select core's built-in console sink by env var", () => {
147
- // The env-override line has to be repeated at the template layer or
148
- // the literal `"logger"` shadows reference.conf's substitution — the
149
- // same precedence rule the grant tests above pin.
150
- const config = buildResolvedConfig("production", { AUDIT_SINK_TYPE: "console" });
151
- expect(config.audit?.sink.type).toBe("console");
165
+ const config = buildResolvedConfig("production", { ADAPTERS_AUDIT_SINK: "console" });
166
+ expect(config.adapters.auditSink).toBe("console");
152
167
  });
153
168
 
154
- it("reference.conf's own default is a sink, not a drop", () => {
155
- // A consumer that resolves against reference.conf alone still lands on
156
- // a sink. #304's sink policy is stdout JSON, never "none", and the
157
- // library layer has to hold that on its own — a composition root that
158
- // forgets to override it must not thereby lose its audit trail.
159
- const referenceOnly = validate(
160
- parseFile(resolveLibraryReferenceConfPath(), { env: testEnv }),
161
- AppConfigSchema,
169
+ it("the template's reference.conf alone selects a sink, not a drop", () => {
170
+ // A composition resolved against the template's reference alone
171
+ // still lands on a sink: an application.conf that forgets the
172
+ // selection must not thereby lose its audit trail.
173
+ const referenceOnly = parseFile(fileURLToPath(templateReference()), { env: {} });
174
+ expect(readAdapters(referenceOnly.toObject() as Record<string, unknown>, {}).auditSink).toBe(
175
+ "logger",
162
176
  );
163
- expect(referenceOnly.audit?.sink.type).toBe("console");
164
177
  });
165
178
  });
166
179
 
167
- // #277 — this is the shape the umbrella E2E (o3co/auth) boots: the shipped
168
- // application.conf, `DEPLOYMENT_MODE=multi`, and one shared ioredis socket.
169
- // The assertions below are what keep that stack booting: a memory denylist
170
- // under `multi` is refused by the replica-safety guard, and NO denylist is
171
- // refused by the #277 guard, so the template has to land on "redis" without
172
- // the deployment naming it.
173
- describe("#277: access-token revocation as the shipped artifact resolves it", () => {
174
- it("resolves accessTokenDenylist.adapter to redis with nothing set", () => {
180
+ // The shape the umbrella E2E (o3co/auth) boots: the shipped
181
+ // application.conf, `CORE_DEPLOYMENT_MODE=multi`, and one shared ioredis socket.
182
+ // A memory denylist under `multi` is refused by the replica-safety guard,
183
+ // and NO denylist by core's denylist boot guard, so the template has to
184
+ // land on "redis" without the deployment naming it.
185
+ describe("access-token revocation as the shipped artifact resolves it", () => {
186
+ it("resolves adapters.accessTokenDenylist to redis with nothing set", () => {
175
187
  const config = buildResolvedConfig("production");
176
- expect(config.accessTokenDenylist?.adapter).toBe("redis");
188
+ expect(config.adapters.accessTokenDenylist).toBe("redis");
177
189
  });
178
190
 
179
191
  it("resolves oauth.revocation.accessToken to denylist with nothing set", () => {
@@ -189,22 +201,23 @@ describe("three-tier HOCON resolution (env → application.conf → reference.co
189
201
 
190
202
  it("leaves nothing replica-unsafe under the umbrella E2E's environment", async () => {
191
203
  // The environment `o3co/auth`'s tests/docker-compose.yml sets. Under
192
- // `DEPLOYMENT_MODE=multi` the replica-safety guard fails boot naming
193
- // every in-memory shared store, so the denylist this change adds has to
194
- // come out of that environment as the Redis one — from the template's
195
- // own config, since the compose file names no denylist variable.
196
- // Asked of each manifest, the way the guard does (#455), so a rename
197
- // cannot quietly invalidate it and the template's own memory modules
198
- // — which no name list in core covers — count too.
204
+ // `CORE_DEPLOYMENT_MODE=multi` the replica-safety guard fails boot naming
205
+ // every in-memory shared store, so the denylist has to come out as
206
+ // the Redis one from the template's own config: the compose file
207
+ // names no denylist variable. Asked of each manifest, as the guard
208
+ // does, so a rename cannot quietly invalidate it and the template's
209
+ // own memory modules count too.
199
210
  const { replicaUnsafeReason } = await import("@o3co/auth-provider-core");
200
211
  const config = buildResolvedConfig("production", {
201
- USER_SESSION_STORES_ADAPTER: "redis",
202
- RATE_LIMITER_ADAPTER: "redis",
203
- OAUTH_CODE_ADAPTER: "redis",
204
- // #456: the federation token store defaults to memory, and the
205
- // memory module declares itself replica-unsafe (#455).
206
- FEDERATION_TOKEN_STORE_TYPE: "redis",
212
+ ADAPTERS_USER_SESSION_STORES: "redis",
213
+ ADAPTERS_RATE_LIMITER: "redis",
214
+ ADAPTERS_CODE_REPOSITORY: "redis",
215
+ // The federation token store defaults to memory, and the memory
216
+ // module declares itself replica-unsafe.
217
+ ADAPTERS_FEDERATION_TOKEN_STORE: "redis",
207
218
  REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY: "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=",
219
+ // The umbrella sets MFA off.
220
+ MFA_MODE: "off",
208
221
  });
209
222
  const modules = buildModules(config);
210
223
  expect(modules.map((m) => m.name)).toContain("redis-access-token-denylist");
@@ -215,9 +228,9 @@ describe("three-tier HOCON resolution (env → application.conf → reference.co
215
228
 
216
229
  it("lets a single-instance deployment opt down to memory by env var", () => {
217
230
  const config = buildResolvedConfig("production", {
218
- ACCESS_TOKEN_DENYLIST_ADAPTER: "memory",
231
+ ADAPTERS_ACCESS_TOKEN_DENYLIST: "memory",
219
232
  });
220
- expect(config.accessTokenDenylist?.adapter).toBe("memory");
233
+ expect(config.adapters.accessTokenDenylist).toBe("memory");
221
234
  const names = buildModules(config).map((m) => m.name);
222
235
  expect(names).toContain("core-access-token-denylist-memory");
223
236
  });