@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
@@ -18,16 +18,13 @@ import {
18
18
  consoleLogger,
19
19
  createAuditSinkFactory,
20
20
  createFederationTokenStoreFactory,
21
- createInMemorySessionFamilyIndex,
22
- createInMemorySessionFederationIndex,
23
- createInMemorySessionRPRegistry,
21
+ createInMemorySessionLifecycleStore,
24
22
  createInMemorySubjectRevocation,
25
23
  createInMemorySubjectSessionIndex,
26
24
  createInMemoryUserSessionStore,
27
25
  createKeyStoreFactory,
28
26
  createRepositoryFactories,
29
27
  defineModule,
30
- federationsOf,
31
28
  type LifecycleRegistrar,
32
29
  type Logger,
33
30
  loggableError,
@@ -37,15 +34,12 @@ import {
37
34
  registerBuiltinFederationTokenStores,
38
35
  registerBuiltinKeyStores,
39
36
  } from "@o3co/auth-provider-core";
40
- import type { GoogleProviderConfig } from "@o3co/auth-provider-federation-google";
41
- import { readOidcFederationConfigs } from "@o3co/auth-provider-federation-oidc";
42
37
  import { registerBuiltinAdapters } from "@o3co/auth-provider-foundation";
43
38
  import {
44
39
  makeIoredisClients,
45
40
  makeIoredisFederationGrantIntentStoreClient,
46
41
  makeIoredisFederationGrantStoreClient,
47
42
  } from "@o3co/auth-provider-redis/ioredis";
48
- import { extractFederationSection } from "@o3co/auth-provider-session";
49
43
  // Named import, not default: ioredis is CJS (`module.exports = Redis`, the
50
44
  // class re-exported as both `default` and `Redis`), so under `module:
51
45
  // "nodenext"` with esModuleInterop the default import resolves to the
@@ -306,9 +300,8 @@ export const inMemoryCodeRepositoryModule: Module = defineModule({
306
300
  });
307
301
 
308
302
  /**
309
- * In-memory user-session stores module: the four-store user-session split
310
- * (userSessionStore, sessionRPRegistry, sessionFamilyIndex,
311
- * sessionFederationIndex) and the subject-level revocation pair. Wired by
303
+ * In-memory user-session stores module: the user-session store, the session
304
+ * lifecycle store and the subject-level revocation pair. Wired by
312
305
  * `buildModules` only when `adapters.userSessionStores = "memory"`; the Redis
313
306
  * branch swaps in `redisSessionStoresModule` from `@o3co/auth-provider-redis`.
314
307
  */
@@ -317,13 +310,10 @@ export const inMemorySessionStoresModule: Module = defineModule({
317
310
  replicaSafety: {
318
311
  unsafe: true,
319
312
  reason:
320
- "user sessions, RP registrations, family indexes and the subject-level revocation pair fork per replica — back-channel logout reaches only the replica that received it, so a logged-out session stays valid on the others, and a credential change enumerates and watermarks only the replica that handled it",
313
+ "user sessions, the session lifecycle record and the subject-level revocation pair fork per replica — back-channel logout reaches only the replica that received it, so a logged-out session stays valid on the others, a close begun on one replica fences nothing on another, and a credential change enumerates and watermarks only the replica that handled it",
321
314
  },
322
315
  provides: {
323
316
  userSessionStore: () => createInMemoryUserSessionStore(),
324
- sessionRPRegistry: () => createInMemorySessionRPRegistry(),
325
- sessionFamilyIndex: () => createInMemorySessionFamilyIndex(),
326
- sessionFederationIndex: () => createInMemorySessionFederationIndex(),
327
317
  // The subject-level revocation slots. Without them `subjectRevocation`
328
318
  // is undefined: `verifyJwt` skips the watermark, the refresh gate is
329
319
  // inert and `revokeAllForSubject` reports `unavailable`, all silently.
@@ -332,6 +322,9 @@ export const inMemorySessionStoresModule: Module = defineModule({
332
322
  // other store on this branch.
333
323
  subjectSessionIndex: () => createInMemorySubjectSessionIndex(),
334
324
  subjectRevocation: () => createInMemorySubjectRevocation(),
325
+ // The session lifecycle's record, as the Redis branch provides it; read
326
+ // only where core's session lifecycle module is installed.
327
+ sessionLifecycleStore: () => createInMemorySessionLifecycleStore(),
335
328
  },
336
329
  });
337
330
 
@@ -368,7 +361,7 @@ export const inMemoryFederationTokenStoreModule: Module = defineModule({
368
361
  * route that emits a security event reads, with the sink the composition
369
362
  * root's `adapters.auditSink` names: the template's `"logger"` (its default)
370
363
  * or one of core's built-ins, with that sink's options from
371
- * `audit-sink.<name>`. The slot is `optional` on `oauthModule`,
364
+ * `audit-sink.<name>`. The slot is `optional` on `oauthEndpointsModule`,
372
365
  * `sessionModule` and `webauthnModule`, and `emitAuditEvent` is a no-op when
373
366
  * it is empty, so this module is always in the manifest; a name no builder is
374
367
  * registered under refuses boot. The sink's options moved from `audit.sink`.
@@ -401,28 +394,6 @@ export function auditSinkModuleFor(sink: string): Module {
401
394
  });
402
395
  }
403
396
 
404
- /**
405
- * @deprecated Split into `inMemorySessionStoresModule` and
406
- * `inMemoryFederationTokenStoreModule`; use those. Kept for consumers that
407
- * imported `storesModule` from this file.
408
- */
409
- export const storesModule: Module = defineModule({
410
- name: "stores",
411
- // Everything this bundle provides lives in process memory, so a
412
- // composition still on it is refused under `core.deployment.mode = "multi"`
413
- // like the split modules it stands in for.
414
- replicaSafety: {
415
- unsafe: true,
416
- reason:
417
- "user sessions, RP registrations, family indexes, the subject-level revocation pair and upstream federation tokens fork per replica — back-channel logout reaches only the replica that received it, so a logged-out session stays valid on the others",
418
- },
419
- requires: ["config"] as const,
420
- provides: {
421
- ...inMemorySessionStoresModule.provides,
422
- ...inMemoryFederationTokenStoreModule.provides,
423
- },
424
- });
425
-
426
397
  /**
427
398
  * Shared ioredis clients module: opens ONE long-lived ioredis connection per
428
399
  * replica and derives every per-purpose client from it (`makeIoredisClients`,
@@ -470,33 +441,6 @@ export const standaloneRedisClientsModule: Module = defineModule({
470
441
  return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
471
442
  .userSessionStoreClient;
472
443
  },
473
- sessionRPRegistryClient: async ({
474
- section,
475
- lifecycleRegistrar,
476
- readinessRegistrar,
477
- logger,
478
- }) => {
479
- return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
480
- .sessionRPRegistryClient;
481
- },
482
- sessionFamilyIndexClient: async ({
483
- section,
484
- lifecycleRegistrar,
485
- readinessRegistrar,
486
- logger,
487
- }) => {
488
- return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
489
- .sessionFamilyIndexClient;
490
- },
491
- sessionFederationIndexClient: async ({
492
- section,
493
- lifecycleRegistrar,
494
- readinessRegistrar,
495
- logger,
496
- }) => {
497
- return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
498
- .sessionFederationIndexClient;
499
- },
500
444
  // The grant store and the intent store, consumed by the Redis grant
501
445
  // modules when their switches say "redis". Provided whenever this module
502
446
  // is installed, as every slot here is: a slot is cheap, the socket is
@@ -528,6 +472,11 @@ export const standaloneRedisClientsModule: Module = defineModule({
528
472
  return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
529
473
  .rateLimiterClient;
530
474
  },
475
+ // `redisAttemptCounterModule` consumes this slot where a deployment installs it.
476
+ attemptCounterClient: async ({ section, lifecycleRegistrar, readinessRegistrar, logger }) => {
477
+ return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
478
+ .attemptCounterClient;
479
+ },
531
480
  // `redisCodeRepositoryModule` consumes this slot when
532
481
  // `adapters.codeRepository = "redis"`.
533
482
  codeRepositoryClient: async ({ section, lifecycleRegistrar, readinessRegistrar, logger }) => {
@@ -569,9 +518,20 @@ export const standaloneRedisClientsModule: Module = defineModule({
569
518
  return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
570
519
  .subjectRevocationClient;
571
520
  },
521
+ // Required by `redisSessionStoresModule`, which builds the session
522
+ // lifecycle store from it once a module reads that store.
523
+ sessionLifecycleStoreClient: async ({
524
+ section,
525
+ lifecycleRegistrar,
526
+ readinessRegistrar,
527
+ logger,
528
+ }) => {
529
+ return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
530
+ .sessionLifecycleStoreClient;
531
+ },
572
532
  // Required by `redisDeviceCodeStoreModule`. This template does not mount
573
533
  // the device grant; the slot is provided anyway, so a deployment that
574
- // adds `deviceGrantModule` with the Redis store is not refused at boot
534
+ // adds `deviceAuthorizationGrantModule` with the Redis store is not refused at boot
575
535
  // (`missing-required-component`) for a client slot nothing provided.
576
536
  deviceCodeStoreClient: async ({ section, lifecycleRegistrar, readinessRegistrar, logger }) => {
577
537
  return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
@@ -731,151 +691,3 @@ function getOrCreateClients(
731
691
  if (lifecycleRegistrar) clientsCache.set(lifecycleRegistrar, clients);
732
692
  return clients;
733
693
  }
734
-
735
- /**
736
- * Reads an optional string field off a federation slice. An unset field stays
737
- * absent rather than becoming `undefined`, so `"sessionDomain" in config`
738
- * still distinguishes the two. A present non-string throws: HOCON hands
739
- * through whatever the file holds, and silently ignoring `sessionDomain = 42`
740
- * would run the redirect policy with one fewer constraint than the operator
741
- * wrote down.
742
- */
743
- function optionalString(
744
- slice: Record<string, unknown>,
745
- field: string,
746
- ): Record<string, string> | Record<string, never> {
747
- const value = slice[field];
748
- if (value === undefined || value === null) return {};
749
- if (typeof value !== "string") {
750
- throw new Error(`core.federations.google.${field} must be a string when present`);
751
- }
752
- return { [field]: value };
753
- }
754
-
755
- /**
756
- * An optional boolean from the `core.federations.google` entry: a HOCON boolean, or
757
- * from an environment override (`${?VAR}`) a string in the spellings core's
758
- * `coerceBooleanFromEnv` accepts ("true" / "false" / "1" / "0", trimmed, any
759
- * case). Unlike that coercion, an empty value is refused, not read as false:
760
- * HOCON substitutes an exported-but-empty variable as "", and a security
761
- * switch must not quietly turn the check off. Anything else is refused too,
762
- * so a typo fails boot rather than reading as either value.
763
- */
764
- function optionalBoolean(
765
- slice: Record<string, unknown>,
766
- field: string,
767
- ): Record<string, boolean> | Record<string, never> {
768
- const value = slice[field];
769
- if (value === undefined || value === null) return {};
770
- if (typeof value === "boolean") return { [field]: value };
771
- if (typeof value === "string") {
772
- const normalized = value.trim().toLowerCase();
773
- if (normalized === "true" || normalized === "1") return { [field]: true };
774
- if (normalized === "false" || normalized === "0") return { [field]: false };
775
- }
776
- throw new Error(
777
- `core.federations.google.${field} must be one of true, false, "true", "false", "1" or "0" when present`,
778
- );
779
- }
780
-
781
- /**
782
- * `accessType` from the `core.federations.google` entry: `"offline"` or `"online"`,
783
- * exactly, or absent. `federation-google` refuses any other value too; this
784
- * refuses it first, naming the key, as the fields above do.
785
- */
786
- function optionalAccessType(
787
- slice: Record<string, unknown>,
788
- ): { accessType: "offline" | "online" } | Record<string, never> {
789
- const value = slice.accessType;
790
- if (value === undefined || value === null) return {};
791
- if (value === "offline" || value === "online") return { accessType: value };
792
- throw new Error('core.federations.google.accessType must be "offline" or "online" when present');
793
- }
794
-
795
- /**
796
- * Google federation config bridge: supplies the typed `googleFederationConfig`
797
- * slot from the `core.federations.google` entry. The bridge is the
798
- * composition root's responsibility because the slot's content is
799
- * consumer-specific (see the `@o3co/auth-provider-federation-google` README).
800
- *
801
- * It carries every field the provider reads, not only the credentials:
802
- * `googleFederationModule` hands this same object to
803
- * `createFederationRedirectPolicy`, so a dropped `redirectAllowlist` /
804
- * `sessionDomain` / `authCallbackUrl` / `clientUrl` would silently run the
805
- * policy without the constraints the operator configured.
806
- */
807
- export const googleFederationConfigModule: Module = defineModule({
808
- name: "google-federation-config",
809
- requires: ["config"] as const,
810
- provides: {
811
- googleFederationConfig: ({ config }): GoogleProviderConfig => {
812
- const slice = extractFederationSection(federationsOf(config), "google");
813
- if (!slice) {
814
- throw new Error(
815
- "core.federations.google must be enabled with credentials when googleFederationModule is in the manifest",
816
- );
817
- }
818
- const clientId = slice.clientId;
819
- const clientSecret = slice.clientSecret;
820
- const callbackURL = slice.callbackURL;
821
- if (
822
- typeof clientId !== "string" ||
823
- typeof clientSecret !== "string" ||
824
- typeof callbackURL !== "string"
825
- ) {
826
- throw new Error(
827
- "core.federations.google requires clientId, clientSecret, callbackURL when enabled",
828
- );
829
- }
830
-
831
- // The allowlist is checked for shape here and for content by
832
- // `createFederationRedirectPolicy`, which owns the URL rules. This
833
- // only has to establish that HOCON produced a list of strings —
834
- // `redirectAllowlist = "https://…"` (a bare string, the natural typo)
835
- // would otherwise reach the policy as a config it cannot read.
836
- const rawAllowlist = slice.redirectAllowlist;
837
- let redirectAllowlist: Record<string, readonly string[]> | Record<string, never> = {};
838
- if (rawAllowlist !== undefined && rawAllowlist !== null) {
839
- if (!Array.isArray(rawAllowlist) || rawAllowlist.some((e) => typeof e !== "string")) {
840
- throw new Error(
841
- "core.federations.google.redirectAllowlist must be a list of URL strings, " +
842
- 'e.g. ["https://app.example.com/welcome"]',
843
- );
844
- }
845
- redirectAllowlist = { redirectAllowlist: rawAllowlist as readonly string[] };
846
- }
847
-
848
- return {
849
- clientId,
850
- clientSecret,
851
- callbackURL,
852
- ...redirectAllowlist,
853
- ...optionalString(slice, "sessionDomain"),
854
- ...optionalString(slice, "authCallbackUrl"),
855
- ...optionalString(slice, "clientUrl"),
856
- // Absent means the provider's default, which is to require it.
857
- ...optionalBoolean(slice, "requireAuthorizationResponseIss"),
858
- // Absent means the provider's default, "offline": consent on every
859
- // sign-in, and a refresh token for every session.
860
- ...optionalAccessType(slice),
861
- };
862
- },
863
- },
864
- });
865
-
866
- /**
867
- * OIDC federation config bridge: supplies the `oidcFederationConfigs`
868
- * slot every `oidcFederationModule(<name>)` in the manifest reads its entry
869
- * from. One bridge for all instances: `readOidcFederationConfigs` walks
870
- * `core.federations` and reads every enabled entry of type `oidc`,
871
- * refusing a malformed field by `core.federations.<name>.<field>` at boot.
872
- * `buildModules` lists this module only when at least one such section
873
- * exists.
874
- */
875
- export const oidcFederationConfigModule: Module = defineModule({
876
- name: "oidc-federation-config",
877
- requires: ["config"] as const,
878
- provides: {
879
- oidcFederationConfigs: ({ config }) => readOidcFederationConfigs(federationsOf(config)),
880
- },
881
- });
@@ -18,46 +18,62 @@
18
18
  * The variables renamed with a path of the composition root's own layers,
19
19
  * which no module declares: the adapter selections and the federations the
20
20
  * template ships. Phase one refuses an old name set alone, or beside its new
21
- * name at a different value, before any module is chosen; the two at one
22
- * value are accepted.
21
+ * name at a different value, before any module is chosen, as boot refuses a
22
+ * variable a module declares renamed (`environment-variable-renamed`); the
23
+ * two at one value are accepted. Boot's own check cannot hold these: an
24
+ * adapter selection decides which modules are chosen before boot, and a
25
+ * federation's key sits in core's section, whose renames core alone declares.
23
26
  */
24
27
 
25
- /** A variable renamed with a path of the composition root's own: its old name, its new one, and the path the new one binds. */
28
+ import { variablesRenamed } from "./bootRefusal.mjs";
29
+
30
+ /**
31
+ * A variable renamed with a path of the composition root's own: its old name,
32
+ * its new one, the path the new one binds, and the module that path's section
33
+ * is named after in a refusal's details (`bootRefusal.mts`).
34
+ */
26
35
  export interface RootRename {
36
+ readonly module: string;
27
37
  readonly from: string;
28
38
  readonly to: string;
29
39
  readonly path: string;
30
40
  }
31
41
 
32
42
  /**
33
- * Refuses, with a `RangeError`, an old name in `env` set alone, or beside its
34
- * new name at a different value; the two at one value are accepted. Every
35
- * such rename is named at once; no value is quoted.
43
+ * Refuses, with an `environment-variable-renamed` `BootError`, an old name in
44
+ * `env` set alone (`unset`), or beside its new name at a different value
45
+ * (`different`); the two at one value are accepted. Every such rename is
46
+ * named at once; no value is quoted or carried.
36
47
  */
37
48
  export function refuseRenamedVariables(
38
49
  env: Readonly<Record<string, string>>,
39
50
  renames: readonly RootRename[],
40
51
  ): void {
41
- const refused = renames.flatMap(({ from, to, path }) => {
52
+ const refused = renames.flatMap((rename) => {
53
+ const { from, to } = rename;
42
54
  const old = env[from];
43
55
  if (old === undefined || env[to] === old) return [];
56
+ return [
57
+ { ...rename, state: env[to] === undefined ? ("unset" as const) : ("different" as const) },
58
+ ];
59
+ });
60
+ if (refused.length === 0) return;
61
+ const named = refused.map(({ from, to, path, state }) => {
44
62
  const renamed = `${from} was renamed ${to}, the variable ${path} is bound to; see CHANGELOG.`;
45
- return env[to] === undefined
46
- ? [`${renamed} Set ${to} instead and unset ${from}.`]
47
- : [
48
- `${renamed} ${to} is set to a different value: keep the one you mean in ${to} and unset ${from}.`,
49
- ];
63
+ return state === "unset"
64
+ ? `${renamed} Set ${to} instead and unset ${from}.`
65
+ : `${renamed} ${to} is set to a different value: keep the one you mean in ${to} and unset ${from}.`;
50
66
  });
51
- if (refused.length > 0) {
52
- throw new RangeError(
53
- `The environment sets ${refused.length} variable(s) that were renamed: ${refused.join(" ")}`,
54
- );
55
- }
67
+ throw variablesRenamed(
68
+ `The environment sets ${refused.length} variable(s) that were renamed: ${named.join(" ")}`,
69
+ refused.map(({ module, from, to, path, state }) => ({ module, from, to, path, state })),
70
+ );
56
71
  }
57
72
 
58
73
  /**
59
74
  * The variables `config/application.conf` binds for the two federations the
60
- * template ships, each renamed after its path under `core.federations`.
75
+ * template ships, each renamed after its path under `core.federations`: in
76
+ * core's section, so named under module "core" in a refusal's details.
61
77
  */
62
78
  export const SHIPPED_FEDERATION_RENAMES: readonly RootRename[] = (
63
79
  [
@@ -73,6 +89,7 @@ export const SHIPPED_FEDERATION_RENAMES: readonly RootRename[] = (
73
89
  ["OIDC", "oidc", ["CALLBACK_URL", "callbackURL"]],
74
90
  ] as const
75
91
  ).map(([federation, name, [key, path]]) => ({
92
+ module: "core",
76
93
  from: `FEDERATIONS_${federation}_${key}`,
77
94
  to: `CORE_FEDERATIONS_${federation}_${key}`,
78
95
  path: `core.federations.${name}.${path}`,
@@ -255,6 +255,7 @@ export const redisClientsSectionSchema = z
255
255
  export const adaptersSchema = z
256
256
  .object({
257
257
  rateLimiter: z.enum(["memory", "redis"]),
258
+ attemptCounter: z.enum(["memory", "redis"]),
258
259
  userSessionStores: z.enum(["memory", "redis"]),
259
260
  accessTokenDenylist: z.enum(["memory", "redis"]),
260
261
  replaySeenSet: z.enum(["memory", "redis"]),
@@ -262,9 +262,10 @@ describe("GET /oauth/authorize", () => {
262
262
 
263
263
  describe("GET /session/oauth/federation/google", () => {
264
264
  // NOTE: Development config has Google federation disabled (google.enabled: false).
265
- // The googleEnabled check runs first and returns 404, so redirect_to validation
266
- // tests cannot run in this environment. Validation logic is identical to
267
- // POST /session/login and covered by those tests.
265
+ // No federation is registered under that name, so the route answers 404 before
266
+ // redirect_to is validated, and redirect_to validation tests cannot run in this
267
+ // environment. Validation logic is identical to POST /session/login and covered
268
+ // by those tests.
268
269
 
269
270
  it("returns 404 when Google federation is disabled", async () => {
270
271
  const res = await client.get(
@@ -1,20 +1,20 @@
1
1
  {
2
- "@o3co/auth-provider-core": "0.17.0-rc.1",
3
- "@o3co/auth-provider-device-grant": "0.17.0-rc.1",
4
- "@o3co/auth-provider-dpop": "0.17.0-rc.1",
5
- "@o3co/auth-provider-mtls": "0.17.0-rc.1",
6
- "@o3co/auth-provider-federation-apple": "0.17.0-rc.1",
7
- "@o3co/auth-provider-federation-google": "0.17.0-rc.1",
8
- "@o3co/auth-provider-federation-github": "0.17.0-rc.1",
9
- "@o3co/auth-provider-federation-grants": "0.17.0-rc.1",
10
- "@o3co/auth-provider-federation-oidc": "0.17.0-rc.1",
11
- "@o3co/auth-provider-oauth": "0.17.0-rc.1",
12
- "@o3co/auth-provider-oauth-token-exchange": "0.17.0-rc.1",
13
- "@o3co/auth-provider-session": "0.17.0-rc.1",
14
- "@o3co/auth-provider-foundation": "0.17.0-rc.1",
15
- "@o3co/auth-provider-mfa": "0.17.0-rc.1",
16
- "@o3co/auth-provider-redis": "0.17.0-rc.1",
17
- "@o3co/auth-provider-standard": "0.17.0-rc.1",
18
- "@o3co/auth-provider-test-kit": "0.17.0-rc.1",
19
- "@o3co/auth-provider-webauthn": "0.17.0-rc.1"
2
+ "@o3co/auth-provider-core": "0.17.0-rc.2",
3
+ "@o3co/auth-provider-device-grant": "0.17.0-rc.2",
4
+ "@o3co/auth-provider-dpop": "0.17.0-rc.2",
5
+ "@o3co/auth-provider-mtls": "0.17.0-rc.2",
6
+ "@o3co/auth-provider-federation-apple": "0.17.0-rc.2",
7
+ "@o3co/auth-provider-federation-google": "0.17.0-rc.2",
8
+ "@o3co/auth-provider-federation-github": "0.17.0-rc.2",
9
+ "@o3co/auth-provider-federation-grants": "0.17.0-rc.2",
10
+ "@o3co/auth-provider-federation-oidc": "0.17.0-rc.2",
11
+ "@o3co/auth-provider-oauth": "0.17.0-rc.2",
12
+ "@o3co/auth-provider-oauth-token-exchange": "0.17.0-rc.2",
13
+ "@o3co/auth-provider-session": "0.17.0-rc.2",
14
+ "@o3co/auth-provider-foundation": "0.17.0-rc.2",
15
+ "@o3co/auth-provider-mfa": "0.17.0-rc.2",
16
+ "@o3co/auth-provider-redis": "0.17.0-rc.2",
17
+ "@o3co/auth-provider-standard": "0.17.0-rc.2",
18
+ "@o3co/auth-provider-test-kit": "0.17.0-rc.2",
19
+ "@o3co/auth-provider-webauthn": "0.17.0-rc.2"
20
20
  }
@@ -1,134 +0,0 @@
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
- * `oauth.accessToken.expiresIn` / OAUTH_ACCESS_TOKEN_EXPIRES_IN is a deprecated
19
- * alias of `defaultExpiresIn`, and the composition says so once.
20
- *
21
- * Core's `reference.conf` keeps the shipped lifetime on the deprecated key, so
22
- * the alias supplies the default for every deployment that set nothing; a
23
- * warning there would train operators to ignore it. Only an override of the old
24
- * key is something to move.
25
- */
26
-
27
- import { fileURLToPath } from "node:url";
28
- import { AppConfigSchema, coreReference, type Logger } from "@o3co/auth-provider-core";
29
- import { parseFile } from "@o3co/ts.hocon";
30
- import { validate } from "@o3co/ts.hocon/zod";
31
- import { afterEach, describe, expect, it, vi } from "vitest";
32
- import { buildModules } from "../buildModules.mjs";
33
- import { resolveConfigPaths, type Switches } from "../configPath.mjs";
34
- import { templateReference } from "../modules.mjs";
35
- import { rootSectionsOf } from "./library-references.fixture.mjs";
36
-
37
- const configDir = fileURLToPath(new URL("../../config", import.meta.url));
38
-
39
- const REQUIRED_ENV = {
40
- KEY_STORE_LOCAL_SECRET: "access-token-alias.at-least-32-bytes.ok",
41
- OAUTH_JWT_ISSUER: "https://auth.test",
42
- SESSION_STORE_SECRET: "access-token-alias-session.at-least-32-bytes.ok",
43
- };
44
-
45
- /** The shipped layers, resolved the way `app.mts` resolves them. */
46
- function loadShipped(env: Record<string, string> = {}): Switches {
47
- const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "development");
48
- const resolvedEnv = { ...REQUIRED_ENV, ...env };
49
- const layers = parseFile(envConfPath, { env: resolvedEnv })
50
- .withFallback(parseFile(applicationConfPath, { env: resolvedEnv }))
51
- .withFallback(parseFile(fileURLToPath(templateReference()), { env: resolvedEnv }))
52
- .withFallback(parseFile(fileURLToPath(coreReference()), { env: resolvedEnv }));
53
- return { ...validate(layers, AppConfigSchema), ...rootSectionsOf(layers, resolvedEnv) };
54
- }
55
-
56
- /** What `buildModules` hands the logger it is given, per level. */
57
- function logged(config: Switches): Array<{ level: string; args: unknown[] }> {
58
- const calls: Array<{ level: string; args: unknown[] }> = [];
59
- const record =
60
- (level: string) =>
61
- (...args: unknown[]): void => {
62
- calls.push({ level, args });
63
- };
64
- const logger: Logger = {
65
- trace: record("trace"),
66
- debug: record("debug"),
67
- info: record("info"),
68
- warn: record("warn"),
69
- error: record("error"),
70
- fatal: record("fatal"),
71
- child: () => logger,
72
- };
73
- buildModules(config, { logger });
74
- return calls;
75
- }
76
-
77
- /** The one line the alias is reported by: object-first, an event name, at warn. */
78
- const ALIAS_WARNING = {
79
- level: "warn",
80
- args: [
81
- {
82
- key: "oauth.accessToken.expiresIn",
83
- env: "OAUTH_ACCESS_TOKEN_EXPIRES_IN",
84
- replacement: "oauth.accessToken.defaultExpiresIn",
85
- replacementEnv: "OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN",
86
- },
87
- "config_key_deprecated",
88
- ],
89
- };
90
-
91
- /** Every line `buildModules` writes about the deprecated key. */
92
- const aliasWarnings = (config: Switches) =>
93
- logged(config).filter(
94
- ({ args }) => (args[0] as { key?: unknown } | undefined)?.key === "oauth.accessToken.expiresIn",
95
- );
96
-
97
- describe("the deprecated access-token lifetime alias", () => {
98
- afterEach(() => {
99
- vi.restoreAllMocks();
100
- });
101
-
102
- it("says nothing for a deployment that sets no lifetime at all", () => {
103
- // Also what keeps the template's idea of "the shipped value" honest: it
104
- // is measured against core's real `reference.conf`, so a change to the
105
- // shipped literal fails here rather than warning every deployment.
106
- expect(aliasWarnings(loadShipped())).toEqual([]);
107
- });
108
-
109
- it("says nothing for a deployment on the new variable", () => {
110
- expect(aliasWarnings(loadShipped({ OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN: "900" }))).toEqual(
111
- [],
112
- );
113
- });
114
-
115
- it("warns once, object-first, when OAUTH_ACCESS_TOKEN_EXPIRES_IN still decides the default", () => {
116
- expect(logged(loadShipped({ OAUTH_ACCESS_TOKEN_EXPIRES_IN: "900" }))).toEqual([ALIAS_WARNING]);
117
- });
118
-
119
- it("warns for a hand-built configuration that overrides only the old key", () => {
120
- const config = loadShipped();
121
- expect(
122
- aliasWarnings({
123
- ...config,
124
- oauth: { ...config.oauth, accessToken: { expiresIn: 900 } },
125
- }),
126
- ).toEqual([ALIAS_WARNING]);
127
- });
128
-
129
- it("writes the same line through consoleLogger when no logger is handed over", () => {
130
- const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
131
- buildModules(loadShipped({ OAUTH_ACCESS_TOKEN_EXPIRES_IN: "900" }));
132
- expect(warn.mock.calls).toEqual([ALIAS_WARNING.args]);
133
- });
134
- });