@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.
- package/package.json +1 -1
- package/templates/standalone/.env.example +0 -3
- package/templates/standalone/README.ja.md +42 -18
- package/templates/standalone/README.md +120 -44
- package/templates/standalone/config/application.conf +40 -36
- package/templates/standalone/config/reference.conf +21 -2
- package/templates/standalone/docker-compose.production.yml +5 -2
- package/templates/standalone/package.json +1 -1
- package/templates/standalone/src/__tests__/adapters.test.mts +80 -19
- package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +87 -0
- package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +112 -98
- package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +27 -6
- package/templates/standalone/src/__tests__/all-modules-composition.test.mts +86 -51
- package/templates/standalone/src/__tests__/audit-sink.test.mts +10 -9
- package/templates/standalone/src/__tests__/config-notices.test.mts +154 -0
- package/templates/standalone/src/__tests__/core-federations.test.mts +48 -25
- package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +1 -1
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +349 -54
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +15 -21
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +9 -15
- package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +10 -4
- package/templates/standalone/src/__tests__/federation-types.test.mts +239 -0
- package/templates/standalone/src/__tests__/google-session.fixture.mts +60 -67
- package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +1 -1
- package/templates/standalone/src/__tests__/library-references.fixture.mts +51 -46
- package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +216 -0
- package/templates/standalone/src/__tests__/mailpit-compose.test.mts +0 -2
- package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +122 -0
- package/templates/standalone/src/__tests__/mfa-switch.test.mts +70 -25
- package/templates/standalone/src/__tests__/own-modules.test.mts +40 -11
- package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +3 -3
- package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +8 -4
- package/templates/standalone/src/__tests__/replica-safety.test.mts +127 -19
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +15 -20
- package/templates/standalone/src/__tests__/session-lifecycle.test.mts +215 -0
- package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +3 -7
- package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +130 -0
- package/templates/standalone/src/__tests__/session-requirements.test.mts +114 -31
- package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +270 -0
- package/templates/standalone/src/__tests__/smoke.test.mts +19 -28
- package/templates/standalone/src/__tests__/store-credential.test.mts +8 -13
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +50 -34
- package/templates/standalone/src/__tests__/two-phase-config.test.mts +242 -76
- package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +23 -31
- package/templates/standalone/src/adapters.mts +31 -18
- package/templates/standalone/src/app.mts +6 -5
- package/templates/standalone/src/bootRefusal.mts +117 -0
- package/templates/standalone/src/buildModules.mts +46 -96
- package/templates/standalone/src/configPath.mts +180 -153
- package/templates/standalone/src/mfaSwitch.mts +43 -20
- package/templates/standalone/src/modules.mts +25 -213
- package/templates/standalone/src/rootRenames.mts +35 -18
- package/templates/standalone/src/sections.mts +1 -0
- package/templates/standalone/tests/index.test.js +4 -3
- package/templates/versions.json +18 -18
- package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +0 -134
- package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +0 -202
- 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
|
-
|
|
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
|
|
310
|
-
*
|
|
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,
|
|
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 `
|
|
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 `
|
|
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
|
|
22
|
-
*
|
|
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
|
-
|
|
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
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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((
|
|
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
|
|
46
|
-
?
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
//
|
|
266
|
-
//
|
|
267
|
-
// POST /session/login and covered
|
|
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(
|
package/templates/versions.json
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
{
|
|
2
|
-
"@o3co/auth-provider-core": "0.17.0-rc.
|
|
3
|
-
"@o3co/auth-provider-device-grant": "0.17.0-rc.
|
|
4
|
-
"@o3co/auth-provider-dpop": "0.17.0-rc.
|
|
5
|
-
"@o3co/auth-provider-mtls": "0.17.0-rc.
|
|
6
|
-
"@o3co/auth-provider-federation-apple": "0.17.0-rc.
|
|
7
|
-
"@o3co/auth-provider-federation-google": "0.17.0-rc.
|
|
8
|
-
"@o3co/auth-provider-federation-github": "0.17.0-rc.
|
|
9
|
-
"@o3co/auth-provider-federation-grants": "0.17.0-rc.
|
|
10
|
-
"@o3co/auth-provider-federation-oidc": "0.17.0-rc.
|
|
11
|
-
"@o3co/auth-provider-oauth": "0.17.0-rc.
|
|
12
|
-
"@o3co/auth-provider-oauth-token-exchange": "0.17.0-rc.
|
|
13
|
-
"@o3co/auth-provider-session": "0.17.0-rc.
|
|
14
|
-
"@o3co/auth-provider-foundation": "0.17.0-rc.
|
|
15
|
-
"@o3co/auth-provider-mfa": "0.17.0-rc.
|
|
16
|
-
"@o3co/auth-provider-redis": "0.17.0-rc.
|
|
17
|
-
"@o3co/auth-provider-standard": "0.17.0-rc.
|
|
18
|
-
"@o3co/auth-provider-test-kit": "0.17.0-rc.
|
|
19
|
-
"@o3co/auth-provider-webauthn": "0.17.0-rc.
|
|
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
|
-
});
|