@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
|
@@ -20,12 +20,18 @@ import { join } from "node:path";
|
|
|
20
20
|
import { fileURLToPath } from "node:url";
|
|
21
21
|
import {
|
|
22
22
|
type AppConfig,
|
|
23
|
+
BootError,
|
|
23
24
|
createApp,
|
|
25
|
+
defineModule,
|
|
24
26
|
type Module,
|
|
25
27
|
moduleReferences,
|
|
26
28
|
resolveAccessTokenLifetime,
|
|
27
29
|
} from "@o3co/auth-provider-core";
|
|
28
|
-
import {
|
|
30
|
+
import { createFakeIdp, type FakeIdp } from "@o3co/auth-provider-core/testing";
|
|
31
|
+
import { googleFederationTypeModule } from "@o3co/auth-provider-federation-google";
|
|
32
|
+
import { federationGrantsModule } from "@o3co/auth-provider-federation-grants";
|
|
33
|
+
import { oidcFederationTypeModule } from "@o3co/auth-provider-federation-oidc";
|
|
34
|
+
import { type OAuthSection, oauthEndpointsModule } from "@o3co/auth-provider-oauth";
|
|
29
35
|
import { sessionModule, sessionStoreModule } from "@o3co/auth-provider-session";
|
|
30
36
|
import { describe, expect, it } from "vitest";
|
|
31
37
|
import { buildModules } from "../buildModules.mjs";
|
|
@@ -35,6 +41,7 @@ import {
|
|
|
35
41
|
readSwitches,
|
|
36
42
|
resolveConfigPaths,
|
|
37
43
|
resolveForBoot,
|
|
44
|
+
resolveLayers,
|
|
38
45
|
type Switches,
|
|
39
46
|
} from "../configPath.mjs";
|
|
40
47
|
import { httpModule, keyStoreModule, templateReference } from "../modules.mjs";
|
|
@@ -43,8 +50,8 @@ import { httpModule, keyStoreModule, templateReference } from "../modules.mjs";
|
|
|
43
50
|
* Boots the shipped config with EVERY documented override supplied the way an
|
|
44
51
|
* operator supplies one: as a string.
|
|
45
52
|
*
|
|
46
|
-
* HOCON substitutes `${?VAR}` as a string, always. `app.mts` reads its
|
|
47
|
-
* with
|
|
53
|
+
* HOCON substitutes `${?VAR}` as a string, always. `app.mts` reads its own
|
|
54
|
+
* switches with the template's schema and hands `createApp` what it resolved,
|
|
48
55
|
* which boot parses once with plain Zod, so every schema leaf must read the
|
|
49
56
|
* string itself. This suite reads through that path: `readSwitches` for what
|
|
50
57
|
* is read before boot, `resolveForBoot` and `createApp` for the configuration
|
|
@@ -94,11 +101,7 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
|
|
|
94
101
|
// --- oauth tokens / policy ----------------------------------------
|
|
95
102
|
OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN: "900",
|
|
96
103
|
OAUTH_ACCESS_TOKEN_MAX_EXPIRES_IN: "7200",
|
|
97
|
-
// Deprecated alias of the default. Exported alongside the new variable on
|
|
98
|
-
// purpose, with a different value: the new variable must win.
|
|
99
|
-
OAUTH_ACCESS_TOKEN_EXPIRES_IN: "3600",
|
|
100
104
|
OAUTH_REFRESH_TOKEN_EXPIRES_IN: "86400",
|
|
101
|
-
OAUTH_REFRESH_TOKEN_UNKNOWN_FAMILY_POLICY: "reject",
|
|
102
105
|
OAUTH_OIDC_MODE: "dual",
|
|
103
106
|
OAUTH_REVOCATION_ACCESS_TOKEN: "denylist",
|
|
104
107
|
OAUTH_REVOCATION_SUBJECT: "unsupported",
|
|
@@ -131,6 +134,7 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
|
|
|
131
134
|
OAUTH_SESSION_ENABLED: "false",
|
|
132
135
|
OAUTH_AUTHORIZATION_GRANTS_AUTHORIZATION_CODE_ENABLED: "true",
|
|
133
136
|
OAUTH_AUTHORIZATION_GRANTS_REFRESH_TOKEN_ENABLED: "true",
|
|
137
|
+
OAUTH_AUTHORIZATION_GRANTS_REFRESH_TOKEN_UNKNOWN_FAMILY_POLICY: "reject",
|
|
134
138
|
OAUTH_AUTHORIZATION_GRANTS_CLIENT_CREDENTIALS_ENABLED: "true",
|
|
135
139
|
OAUTH_AUTHORIZATION_GRANTS_JWT_BEARER_ENABLED: "false",
|
|
136
140
|
|
|
@@ -156,6 +160,7 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
|
|
|
156
160
|
// Which adapter fills each slot: the composition root's own section,
|
|
157
161
|
// read before the modules are chosen.
|
|
158
162
|
ADAPTERS_RATE_LIMITER: "redis",
|
|
163
|
+
ADAPTERS_ATTEMPT_COUNTER: "redis",
|
|
159
164
|
ADAPTERS_USER_SESSION_STORES: "redis",
|
|
160
165
|
ADAPTERS_ACCESS_TOKEN_DENYLIST: "redis",
|
|
161
166
|
// The replay seen-set behind private_key_jwt client authentication.
|
|
@@ -304,7 +309,9 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
|
|
|
304
309
|
*/
|
|
305
310
|
const DELIBERATELY_UNSET: Readonly<Record<string, string>> = {
|
|
306
311
|
OAUTH_AUTHORIZE_ALLOW_UNMARKED_CLIENTS:
|
|
307
|
-
"
|
|
312
|
+
"the variable of a removed key, and only captured — any value fails boot",
|
|
313
|
+
OAUTH_ACCESS_TOKEN_EXPIRES_IN:
|
|
314
|
+
"renamed OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN, and only captured — set alone, or to another value, it fails boot",
|
|
308
315
|
DEPLOYMENT_MODE:
|
|
309
316
|
"renamed CORE_DEPLOYMENT_MODE, and only captured — set alone, or to another value, it fails boot",
|
|
310
317
|
MEMORY_RATE_LIMITER_MAX_BUCKETS:
|
|
@@ -327,6 +334,8 @@ const DELIBERATELY_UNSET: Readonly<Record<string, string>> = {
|
|
|
327
334
|
"renamed OAUTH_AUTHORIZATION_GRANTS_AUTHORIZATION_CODE_ENABLED, and only captured — set alone, or to another value, it fails boot",
|
|
328
335
|
OAUTH_GRANTS_REFRESH_TOKEN_ENABLED:
|
|
329
336
|
"renamed OAUTH_AUTHORIZATION_GRANTS_REFRESH_TOKEN_ENABLED, and only captured — set alone, or to another value, it fails boot",
|
|
337
|
+
OAUTH_REFRESH_TOKEN_UNKNOWN_FAMILY_POLICY:
|
|
338
|
+
"renamed OAUTH_AUTHORIZATION_GRANTS_REFRESH_TOKEN_UNKNOWN_FAMILY_POLICY, and only captured — set alone, or to another value, it fails boot",
|
|
330
339
|
OAUTH_GRANTS_CLIENT_CREDENTIALS_ENABLED:
|
|
331
340
|
"renamed OAUTH_AUTHORIZATION_GRANTS_CLIENT_CREDENTIALS_ENABLED, and only captured — set alone, or to another value, it fails boot",
|
|
332
341
|
OAUTH_GRANTS_JWT_BEARER_ENABLED:
|
|
@@ -437,6 +446,39 @@ const DELIBERATELY_UNSET: Readonly<Record<string, string>> = {
|
|
|
437
446
|
"renamed MFA_TOTP_FACTOR_ISSUER, and only captured — set alone, or to another value, it fails boot where MFA is installed",
|
|
438
447
|
};
|
|
439
448
|
|
|
449
|
+
/**
|
|
450
|
+
* The federation variables `config/application.conf` documents on lines it
|
|
451
|
+
* ships commented out — a key an operator binds by uncommenting it — as the
|
|
452
|
+
* string an operator would supply. They are not live substitutions, so they
|
|
453
|
+
* are not in `DOCUMENTED_ENV`; the federation cases below bind them as their
|
|
454
|
+
* comments write them (`commentedFederationBindings`).
|
|
455
|
+
*/
|
|
456
|
+
const COMMENTED_FEDERATION_ENV: Readonly<Record<string, string>> = {
|
|
457
|
+
CORE_FEDERATIONS_GOOGLE_SESSION_DOMAIN: ".example.com",
|
|
458
|
+
CORE_FEDERATIONS_GOOGLE_AUTH_CALLBACK_URL: "https://app.example.com/auth/callback",
|
|
459
|
+
CORE_FEDERATIONS_GOOGLE_CLIENT_URL: "https://app.example.com/",
|
|
460
|
+
CORE_FEDERATIONS_GOOGLE_REQUIRE_AUTHORIZATION_RESPONSE_ISS: "false",
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Each `# <key> = ${?CORE_FEDERATIONS_<NAME>_…}` line of the shipped
|
|
465
|
+
* `config/application.conf`, uncommented as an operator's layer above it:
|
|
466
|
+
* `core.federations.<name>.<key> = ${?…}`.
|
|
467
|
+
*/
|
|
468
|
+
function commentedFederationBindings(): string {
|
|
469
|
+
const { applicationConfPath } = resolveConfigPaths(configDir, "production");
|
|
470
|
+
return readFileSync(applicationConfPath, "utf8")
|
|
471
|
+
.split("\n")
|
|
472
|
+
.flatMap((line) => {
|
|
473
|
+
const match =
|
|
474
|
+
/^\s*#\s*([A-Za-z]+) = \$\{\?(CORE_FEDERATIONS_([A-Z0-9]+)_[A-Z0-9_]+)\}\s*$/.exec(line);
|
|
475
|
+
if (match === null) return [];
|
|
476
|
+
const [, key, variable, name] = match as unknown as [string, string, string, string];
|
|
477
|
+
return [`core.federations.${name.toLowerCase()}.${key} = \${?${variable}}`];
|
|
478
|
+
})
|
|
479
|
+
.join("\n");
|
|
480
|
+
}
|
|
481
|
+
|
|
440
482
|
/**
|
|
441
483
|
* The provider environment `o3co/auth`'s `tests/docker-compose.yml` sets,
|
|
442
484
|
* transcribed: each renamed variable under its old and its new name, the two
|
|
@@ -507,34 +549,106 @@ function readShippedSwitches(env: Record<string, string>, configEnv = "productio
|
|
|
507
549
|
const FEDERATION_STORES = Object.fromEntries(
|
|
508
550
|
[
|
|
509
551
|
"userSessionStore",
|
|
510
|
-
"
|
|
511
|
-
"sessionFamilyIndex",
|
|
512
|
-
"sessionFederationIndex",
|
|
552
|
+
"sessionLifecycle",
|
|
513
553
|
"federationTokenStore",
|
|
514
554
|
"refreshTokenFamilyRevocation",
|
|
515
555
|
].map((key) => [key, {}]),
|
|
516
556
|
);
|
|
517
557
|
|
|
558
|
+
/** What core handed a federation type for one entry: its callback, and its own keys as the type's schema parsed them. */
|
|
559
|
+
interface Dispatched {
|
|
560
|
+
readonly callbackURL: string;
|
|
561
|
+
readonly entry: Readonly<Record<string, unknown>>;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** A federation type's declaration, as far as this suite reads it. */
|
|
565
|
+
interface TypeDeclaration {
|
|
566
|
+
readonly factory: (deps: unknown, instance: { name: string } & Dispatched) => unknown;
|
|
567
|
+
}
|
|
568
|
+
|
|
518
569
|
/**
|
|
519
|
-
*
|
|
520
|
-
*
|
|
521
|
-
*
|
|
522
|
-
*
|
|
523
|
-
|
|
524
|
-
|
|
570
|
+
* `module`, a federation type module, with each type's factory recording what
|
|
571
|
+
* core handed it into `dispatched`, by the entry's name, before building the
|
|
572
|
+
* provider as the module does. The schema, the parse and the provider are the
|
|
573
|
+
* module's own.
|
|
574
|
+
*/
|
|
575
|
+
function recordingDispatch(module: Module, dispatched: Map<string, Dispatched>): Module {
|
|
576
|
+
const contributes = module.contributes as {
|
|
577
|
+
readonly federationTypes: Readonly<Record<string, TypeDeclaration>>;
|
|
578
|
+
};
|
|
579
|
+
const federationTypes = Object.fromEntries(
|
|
580
|
+
Object.entries(contributes.federationTypes).map(([type, declaration]) => [
|
|
581
|
+
type,
|
|
582
|
+
{
|
|
583
|
+
...declaration,
|
|
584
|
+
factory: (deps: unknown, instance: { name: string } & Dispatched) => {
|
|
585
|
+
dispatched.set(instance.name, {
|
|
586
|
+
callbackURL: instance.callbackURL,
|
|
587
|
+
entry: instance.entry,
|
|
588
|
+
});
|
|
589
|
+
return declaration.factory(deps, instance);
|
|
590
|
+
},
|
|
591
|
+
},
|
|
592
|
+
]),
|
|
593
|
+
);
|
|
594
|
+
return { ...module, contributes: { ...contributes, federationTypes } } as Module;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* The upstream the documented OIDC federation's issuer names. Its type
|
|
599
|
+
* discovers the issuer at boot, so the module is handed this fake's `fetch`
|
|
600
|
+
* rather than the network. Made once per file.
|
|
601
|
+
*/
|
|
602
|
+
let oidcUpstream: Promise<FakeIdp> | undefined;
|
|
603
|
+
const documentedOidcUpstream = (): Promise<FakeIdp> => {
|
|
604
|
+
oidcUpstream ??= createFakeIdp({
|
|
605
|
+
issuer: DOCUMENTED_ENV.CORE_FEDERATIONS_OIDC_ISSUER as string,
|
|
606
|
+
discovery: true,
|
|
607
|
+
clientId: DOCUMENTED_ENV.CORE_FEDERATIONS_OIDC_CLIENT_ID as string,
|
|
608
|
+
});
|
|
609
|
+
return oidcUpstream;
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* The shipped layers under `env`, as `app.mts` hands them to boot, booted
|
|
614
|
+
* with the federation types the template bundles: phase one for what the
|
|
615
|
+
* composition expects of session admission, phase two resolved over the
|
|
616
|
+
* reference of every package the template's modules come from (the
|
|
617
|
+
* template's own and core's) and parsed once by `createApp` — no bridge on
|
|
618
|
+
* the way. Core dispatches each enabled federation to its type, which parses
|
|
619
|
+
* the entry with its own schema; `dispatched` is what each type was handed.
|
|
620
|
+
* No other module is loaded unless `modules` names one: the parse is what this
|
|
525
621
|
* suite asks about, and each key it reads is one core's schema declares, or
|
|
526
622
|
* the section of a module it loads.
|
|
527
623
|
*/
|
|
528
|
-
|
|
624
|
+
/**
|
|
625
|
+
* The oauth module's section, read without its factories: the template always
|
|
626
|
+
* loads the module, and core refuses keys of `oauth {}` where no loaded
|
|
627
|
+
* module's section is `oauth`. What refuses `oauth {}` here is the module's
|
|
628
|
+
* own section — its schema, its removed keys and variables.
|
|
629
|
+
*/
|
|
630
|
+
const oauthSectionReader = defineModule({
|
|
631
|
+
name: oauthEndpointsModule.name,
|
|
632
|
+
...(oauthEndpointsModule.section === undefined ? {} : { section: oauthEndpointsModule.section }),
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
async function bootDispatched(
|
|
529
636
|
env: Record<string, string>,
|
|
530
637
|
configEnv = "production",
|
|
531
638
|
operatorLayer?: string,
|
|
532
639
|
modules: readonly Module[] = [],
|
|
533
|
-
): Promise<AppConfig> {
|
|
640
|
+
): Promise<{ readonly parsed: AppConfig; readonly dispatched: ReadonlyMap<string, Dispatched> }> {
|
|
534
641
|
const own = readOwnLayers(ownFiles(configEnv, operatorLayer), { env });
|
|
535
642
|
const switches = readSwitches(own);
|
|
643
|
+
const dispatched = new Map<string, Dispatched>();
|
|
644
|
+
const upstream = await documentedOidcUpstream();
|
|
536
645
|
const handle = await createApp({
|
|
537
|
-
modules: [
|
|
646
|
+
modules: [
|
|
647
|
+
recordingDispatch(googleFederationTypeModule(), dispatched),
|
|
648
|
+
recordingDispatch(oidcFederationTypeModule({ fetch: upstream.fetch }), dispatched),
|
|
649
|
+
oauthSectionReader,
|
|
650
|
+
...modules,
|
|
651
|
+
],
|
|
538
652
|
bootstrapComponents: {
|
|
539
653
|
config: resolveForBoot(own, buildModules(switches, { environment: configEnv }), switches),
|
|
540
654
|
pathResolver: (s: string) => s,
|
|
@@ -544,7 +658,17 @@ async function bootParsed(
|
|
|
544
658
|
const parsed = handle.components.config;
|
|
545
659
|
await handle.dispose();
|
|
546
660
|
if (parsed === undefined) throw new Error("createApp booted without the parsed configuration");
|
|
547
|
-
return parsed;
|
|
661
|
+
return { parsed, dispatched };
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/** The configuration boot parsed, as {@link bootDispatched} boots it. */
|
|
665
|
+
async function bootParsed(
|
|
666
|
+
env: Record<string, string>,
|
|
667
|
+
configEnv = "production",
|
|
668
|
+
operatorLayer?: string,
|
|
669
|
+
modules: readonly Module[] = [],
|
|
670
|
+
): Promise<AppConfig> {
|
|
671
|
+
return (await bootDispatched(env, configEnv, operatorLayer, modules)).parsed;
|
|
548
672
|
}
|
|
549
673
|
|
|
550
674
|
/** `session-store {}` as the session store's module parses it. */
|
|
@@ -560,6 +684,13 @@ function sessionStoreSection(config: AppConfig): {
|
|
|
560
684
|
>;
|
|
561
685
|
}
|
|
562
686
|
|
|
687
|
+
/** `federation-grants {}` as the federation-grants module parses it. */
|
|
688
|
+
function federationGrantsSection(config: AppConfig): { readonly enabled?: boolean } {
|
|
689
|
+
const schema = federationGrantsModule.section?.schema;
|
|
690
|
+
if (schema === undefined) throw new Error("the federation-grants module declares no section");
|
|
691
|
+
return schema.parse(config["federation-grants"]) as ReturnType<typeof federationGrantsSection>;
|
|
692
|
+
}
|
|
693
|
+
|
|
563
694
|
/** `session {}` as the session module parses it. */
|
|
564
695
|
function sessionSection(config: AppConfig): { readonly csrf?: { readonly ttlSeconds: number } } {
|
|
565
696
|
const schema = sessionModule.section?.schema;
|
|
@@ -578,13 +709,10 @@ function httpSectionOf(config: AppConfig): {
|
|
|
578
709
|
}
|
|
579
710
|
|
|
580
711
|
/** `oauth {}` as the oauth module's own schema parses it. */
|
|
581
|
-
function oauthSection(config: AppConfig): {
|
|
582
|
-
|
|
583
|
-
readonly clientIdMetadataDocuments?: Readonly<Record<string, unknown>>;
|
|
584
|
-
} {
|
|
585
|
-
const schema = oauthModule({ config }).section?.schema;
|
|
712
|
+
function oauthSection(config: AppConfig): OAuthSection {
|
|
713
|
+
const schema = oauthEndpointsModule.section?.schema;
|
|
586
714
|
if (schema === undefined) throw new Error("the oauth module declares no section");
|
|
587
|
-
return schema.parse(config.oauth) as
|
|
715
|
+
return schema.parse(config.oauth) as OAuthSection;
|
|
588
716
|
}
|
|
589
717
|
|
|
590
718
|
/** Every `${?VAR}` in a HOCON layer, ignoring commented-out lines. */
|
|
@@ -646,14 +774,14 @@ describe("the shipped config boots with every documented override supplied as a
|
|
|
646
774
|
// Each of these arrives from HOCON as a string. A leftover string is
|
|
647
775
|
// not a cosmetic defect: `=== true` is how the runtime reads them.
|
|
648
776
|
expect(sessionStoreSection(config).secure).toBe(false);
|
|
649
|
-
expect(config.
|
|
650
|
-
expect(config.
|
|
651
|
-
expect(config.
|
|
777
|
+
expect(oauthSection(config).jwt.legacyTypAccept).toBe(true);
|
|
778
|
+
expect(oauthSection(config).requireEmailVerified).toBe(true);
|
|
779
|
+
expect(oauthSection(config).resourceIndicator?.enabled).toBe(true);
|
|
652
780
|
expect(config.core?.federations?.google?.enabled).toBe(true);
|
|
653
781
|
expect(config.core?.federations?.oidc?.enabled).toBe(true);
|
|
654
782
|
// A leftover string here would be read as "on" by a truthiness check
|
|
655
783
|
// and as "off" by `=== true`, for a feature whose whole default is off.
|
|
656
|
-
expect(config
|
|
784
|
+
expect(federationGrantsSection(config).enabled).toBe(true);
|
|
657
785
|
});
|
|
658
786
|
|
|
659
787
|
it("turns every non-boolean override into its declared type", async () => {
|
|
@@ -665,17 +793,18 @@ describe("the shipped config boots with every documented override supplied as a
|
|
|
665
793
|
expect(http.readinessTimeoutMs).toBe(1500);
|
|
666
794
|
expect(http.trustProxy).toEqual(["10.0.0.0/8", "loopback"]);
|
|
667
795
|
expect(config.core?.federations?.google?.accessType).toBe("online");
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
796
|
+
expect(resolveAccessTokenLifetime({ oauth: oauthSection(config) })).toEqual({
|
|
797
|
+
defaultExpiresIn: 900,
|
|
798
|
+
maxExpiresIn: 7200,
|
|
799
|
+
});
|
|
800
|
+
expect(oauthSection(config).accessToken).toEqual({
|
|
671
801
|
defaultExpiresIn: 900,
|
|
672
802
|
maxExpiresIn: 7200,
|
|
673
803
|
});
|
|
674
|
-
expect(config.
|
|
675
|
-
expect(config.oauth.refreshToken.expiresIn).toBe(86400);
|
|
804
|
+
expect(oauthSection(config).refreshToken.expiresIn).toBe(86400);
|
|
676
805
|
expect(sessionStoreSection(config).maxAge).toBe(3600000);
|
|
677
806
|
expect(sessionSection(config).csrf?.ttlSeconds).toBe(7200);
|
|
678
|
-
expect(config.
|
|
807
|
+
expect(oauthSection(config).nonce?.maxLength).toBe(256);
|
|
679
808
|
expect(readShippedSwitches(DOCUMENTED_ENV).adapters.consentStore).toBe("redis");
|
|
680
809
|
// A Redis store's section, which its module (not loaded here) parses.
|
|
681
810
|
const sections = config as unknown as Record<string, { keyPrefix?: unknown } | undefined>;
|
|
@@ -711,6 +840,133 @@ describe("the shipped config boots with every documented override supplied as a
|
|
|
711
840
|
expect(http.cors.allowedOrigins).toEqual(["https://app.example.com", "http://localhost:5173"]);
|
|
712
841
|
});
|
|
713
842
|
|
|
843
|
+
describe("the federations' variables, read by each federation's type", () => {
|
|
844
|
+
it("hands each enabled federation's type the entry its documented variables set", async () => {
|
|
845
|
+
const { dispatched } = await bootDispatched(DOCUMENTED_ENV);
|
|
846
|
+
expect(Object.fromEntries(dispatched)).toEqual({
|
|
847
|
+
google: {
|
|
848
|
+
callbackURL: DOCUMENTED_ENV.CORE_FEDERATIONS_GOOGLE_CALLBACK_URL,
|
|
849
|
+
entry: {
|
|
850
|
+
clientId: DOCUMENTED_ENV.CORE_FEDERATIONS_GOOGLE_CLIENT_ID,
|
|
851
|
+
clientSecret: DOCUMENTED_ENV.CORE_FEDERATIONS_GOOGLE_CLIENT_SECRET,
|
|
852
|
+
redirectAllowlist: [],
|
|
853
|
+
accessType: "online",
|
|
854
|
+
},
|
|
855
|
+
},
|
|
856
|
+
oidc: {
|
|
857
|
+
callbackURL: DOCUMENTED_ENV.CORE_FEDERATIONS_OIDC_CALLBACK_URL,
|
|
858
|
+
entry: {
|
|
859
|
+
issuer: DOCUMENTED_ENV.CORE_FEDERATIONS_OIDC_ISSUER,
|
|
860
|
+
clientId: DOCUMENTED_ENV.CORE_FEDERATIONS_OIDC_CLIENT_ID,
|
|
861
|
+
clientSecret: DOCUMENTED_ENV.CORE_FEDERATIONS_OIDC_CLIENT_SECRET,
|
|
862
|
+
scopes: ["openid", "profile", "email"],
|
|
863
|
+
redirectAllowlist: [],
|
|
864
|
+
},
|
|
865
|
+
},
|
|
866
|
+
});
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
it("reads the Google keys the configuration documents commented out, bound as their comments write them", async () => {
|
|
870
|
+
const { dispatched } = await bootDispatched(
|
|
871
|
+
{ ...DOCUMENTED_ENV, ...COMMENTED_FEDERATION_ENV },
|
|
872
|
+
"production",
|
|
873
|
+
commentedFederationBindings(),
|
|
874
|
+
);
|
|
875
|
+
expect(dispatched.get("google")?.entry).toEqual({
|
|
876
|
+
clientId: DOCUMENTED_ENV.CORE_FEDERATIONS_GOOGLE_CLIENT_ID,
|
|
877
|
+
clientSecret: DOCUMENTED_ENV.CORE_FEDERATIONS_GOOGLE_CLIENT_SECRET,
|
|
878
|
+
redirectAllowlist: [],
|
|
879
|
+
accessType: "online",
|
|
880
|
+
sessionDomain: COMMENTED_FEDERATION_ENV.CORE_FEDERATIONS_GOOGLE_SESSION_DOMAIN,
|
|
881
|
+
authCallbackUrl: COMMENTED_FEDERATION_ENV.CORE_FEDERATIONS_GOOGLE_AUTH_CALLBACK_URL,
|
|
882
|
+
clientUrl: COMMENTED_FEDERATION_ENV.CORE_FEDERATIONS_GOOGLE_CLIENT_URL,
|
|
883
|
+
requireAuthorizationResponseIss: false,
|
|
884
|
+
});
|
|
885
|
+
// `toEqual` passes a key present as `undefined`: no variable binds
|
|
886
|
+
// `endSessionEndpoint`, so the entry must not carry the key at all.
|
|
887
|
+
expect(dispatched.get("google")?.entry).not.toHaveProperty("endSessionEndpoint");
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
it("hands the Google type no key the documented variables leave unset, not even as undefined", async () => {
|
|
891
|
+
const { dispatched } = await bootDispatched(DOCUMENTED_ENV);
|
|
892
|
+
const entry = dispatched.get("google")?.entry;
|
|
893
|
+
expect(entry).toBeDefined();
|
|
894
|
+
for (const key of [
|
|
895
|
+
"sessionDomain",
|
|
896
|
+
"authCallbackUrl",
|
|
897
|
+
"clientUrl",
|
|
898
|
+
"requireAuthorizationResponseIss",
|
|
899
|
+
"endSessionEndpoint",
|
|
900
|
+
]) {
|
|
901
|
+
expect(entry, key).not.toHaveProperty(key);
|
|
902
|
+
}
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
it("binds every variable the configuration documents commented out", () => {
|
|
906
|
+
expect(
|
|
907
|
+
commentedFederationBindings()
|
|
908
|
+
.match(/\$\{\?[A-Z0-9_]+\}/g)
|
|
909
|
+
?.sort(),
|
|
910
|
+
).toEqual(
|
|
911
|
+
Object.keys(COMMENTED_FEDERATION_ENV)
|
|
912
|
+
.map((name) => `\${?${name}}`)
|
|
913
|
+
.sort(),
|
|
914
|
+
);
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
/** The entry the Google type was handed under `variables`, the commented keys bound. */
|
|
918
|
+
const googleEntryUnder = async (variables: Record<string, string>) =>
|
|
919
|
+
(
|
|
920
|
+
await bootDispatched(
|
|
921
|
+
{ ...DOCUMENTED_ENV, ...variables },
|
|
922
|
+
"production",
|
|
923
|
+
commentedFederationBindings(),
|
|
924
|
+
)
|
|
925
|
+
).dispatched.get("google")?.entry;
|
|
926
|
+
|
|
927
|
+
for (const [supplied, expected] of [
|
|
928
|
+
["true", true],
|
|
929
|
+
["TRUE", true],
|
|
930
|
+
["1", true],
|
|
931
|
+
["false", false],
|
|
932
|
+
["False", false],
|
|
933
|
+
[" false ", false],
|
|
934
|
+
["0", false],
|
|
935
|
+
] as const) {
|
|
936
|
+
it(`CORE_FEDERATIONS_GOOGLE_REQUIRE_AUTHORIZATION_RESPONSE_ISS=${JSON.stringify(supplied)} reads as ${expected}`, async () => {
|
|
937
|
+
const entry = await googleEntryUnder({
|
|
938
|
+
CORE_FEDERATIONS_GOOGLE_REQUIRE_AUTHORIZATION_RESPONSE_ISS: supplied,
|
|
939
|
+
});
|
|
940
|
+
expect(entry?.requireAuthorizationResponseIss).toBe(expected);
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
it("refuses CORE_FEDERATIONS_GOOGLE_REQUIRE_AUTHORIZATION_RESPONSE_ISS exported empty or misspelt, naming the key, rather than turning the check off", async () => {
|
|
945
|
+
for (const supplied of ["", "no", "off", "ture"]) {
|
|
946
|
+
await expect(
|
|
947
|
+
googleEntryUnder({
|
|
948
|
+
CORE_FEDERATIONS_GOOGLE_REQUIRE_AUTHORIZATION_RESPONSE_ISS: supplied,
|
|
949
|
+
}),
|
|
950
|
+
supplied,
|
|
951
|
+
).rejects.toThrow(/core\.federations\.google\.requireAuthorizationResponseIss/);
|
|
952
|
+
}
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
it("reads CORE_FEDERATIONS_GOOGLE_ACCESS_TYPE as offline or online, exactly", async () => {
|
|
956
|
+
for (const supplied of ["offline", "online"]) {
|
|
957
|
+
expect(
|
|
958
|
+
(await googleEntryUnder({ CORE_FEDERATIONS_GOOGLE_ACCESS_TYPE: supplied }))?.accessType,
|
|
959
|
+
).toBe(supplied);
|
|
960
|
+
}
|
|
961
|
+
for (const supplied of ["", "Online", "offine", "true"]) {
|
|
962
|
+
await expect(
|
|
963
|
+
googleEntryUnder({ CORE_FEDERATIONS_GOOGLE_ACCESS_TYPE: supplied }),
|
|
964
|
+
supplied,
|
|
965
|
+
).rejects.toThrow(/core\.federations\.google\.accessType/);
|
|
966
|
+
}
|
|
967
|
+
});
|
|
968
|
+
});
|
|
969
|
+
|
|
714
970
|
describe("HTTP_CORS_ALLOWED_ORIGINS", () => {
|
|
715
971
|
it("reads an exported-but-empty variable as no origins, not as an error", async () => {
|
|
716
972
|
// The .env / compose / ConfigMap shape. "CORS off" is what both the
|
|
@@ -802,8 +1058,8 @@ describe("the shipped config boots with every documented override supplied as a
|
|
|
802
1058
|
it("parses the environment the umbrella E2E boots, with SESSION_STORE_SECURE=false as a string", async () => {
|
|
803
1059
|
const config = await bootParsed(UMBRELLA_E2E_ENV);
|
|
804
1060
|
expect(sessionStoreSection(config).secure).toBe(false);
|
|
805
|
-
expect(config.
|
|
806
|
-
expect(config.
|
|
1061
|
+
expect(oauthSection(config).requireEmailVerified).toBe(true);
|
|
1062
|
+
expect(oauthSection(config).resourceIndicator?.enabled).toBe(true);
|
|
807
1063
|
expect(config.core?.deployment?.mode).toBe("multi");
|
|
808
1064
|
});
|
|
809
1065
|
|
|
@@ -814,8 +1070,14 @@ describe("the shipped config boots with every documented override supplied as a
|
|
|
814
1070
|
// list covers core's modules only, and cannot see the template's own
|
|
815
1071
|
// memory modules.
|
|
816
1072
|
const { replicaUnsafeReason } = await import("@o3co/auth-provider-core");
|
|
817
|
-
|
|
818
|
-
|
|
1073
|
+
// Each module's section at its name: a declaration made from the
|
|
1074
|
+
// section is answered for it.
|
|
1075
|
+
const own = readOwnLayers(ownFiles("production"), { env: UMBRELLA_E2E_ENV });
|
|
1076
|
+
const switches = readSwitches(own);
|
|
1077
|
+
const modules = buildModules(switches);
|
|
1078
|
+
const sections = resolveForBoot(own, modules, switches) as unknown as Record<string, unknown>;
|
|
1079
|
+
for (const module of modules) {
|
|
1080
|
+
expect(replicaUnsafeReason(module, sections[module.name]), module.name).toBeUndefined();
|
|
819
1081
|
}
|
|
820
1082
|
});
|
|
821
1083
|
|
|
@@ -852,7 +1114,7 @@ describe("the shipped config boots with every documented override supplied as a
|
|
|
852
1114
|
...DOCUMENTED_ENV,
|
|
853
1115
|
OAUTH_JWT_LEGACY_TYP_ACCEPT: supplied,
|
|
854
1116
|
});
|
|
855
|
-
expect(config.
|
|
1117
|
+
expect(oauthSection(config).jwt.legacyTypAccept).toBe(expected);
|
|
856
1118
|
});
|
|
857
1119
|
}
|
|
858
1120
|
|
|
@@ -916,13 +1178,13 @@ describe("the shipped config boots with every documented override supplied as a
|
|
|
916
1178
|
});
|
|
917
1179
|
|
|
918
1180
|
describe("variables whose documented behaviour is to fail boot", () => {
|
|
919
|
-
it("refuses OAUTH_AUTHORIZE_ALLOW_UNMARKED_CLIENTS, naming allowUnmarkedClients", async () => {
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
).rejects.toThrow(/allowUnmarkedClients/);
|
|
1181
|
+
it("refuses OAUTH_AUTHORIZE_ALLOW_UNMARKED_CLIENTS as the variable of a removed key, naming allowUnmarkedClients", async () => {
|
|
1182
|
+
const booting = bootParsed({
|
|
1183
|
+
...DOCUMENTED_ENV,
|
|
1184
|
+
OAUTH_AUTHORIZE_ALLOW_UNMARKED_CLIENTS: "true",
|
|
1185
|
+
});
|
|
1186
|
+
await expect(booting).rejects.toMatchObject({ reason: "environment-variable-renamed" });
|
|
1187
|
+
await expect(booting).rejects.toThrow(/oauth\.authorize\.allowUnmarkedClients/);
|
|
926
1188
|
});
|
|
927
1189
|
|
|
928
1190
|
it("refuses an access-token default above the max, naming both keys", async () => {
|
|
@@ -938,30 +1200,63 @@ describe("the shipped config boots with every documented override supplied as a
|
|
|
938
1200
|
for (const name of [
|
|
939
1201
|
"OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN",
|
|
940
1202
|
"OAUTH_ACCESS_TOKEN_MAX_EXPIRES_IN",
|
|
941
|
-
"OAUTH_ACCESS_TOKEN_EXPIRES_IN",
|
|
942
1203
|
]) {
|
|
943
1204
|
it(`refuses an empty ${name} rather than minting already-expired tokens`, async () => {
|
|
944
|
-
await expect(bootParsed({ ...DOCUMENTED_ENV, [name]: "" })).rejects.
|
|
1205
|
+
await expect(bootParsed({ ...DOCUMENTED_ENV, [name]: "" })).rejects.toMatchObject({
|
|
1206
|
+
reason: "config-validation-failed",
|
|
1207
|
+
});
|
|
945
1208
|
});
|
|
946
1209
|
}
|
|
947
1210
|
|
|
948
1211
|
it("refuses MFA_MODE that is none of the three before boot, naming mfaMode", async () => {
|
|
949
1212
|
const env = { ...DOCUMENTED_ENV, MFA_MODE: "on" };
|
|
950
|
-
expect(() => readShippedSwitches(env)).toThrow(
|
|
1213
|
+
expect(() => readShippedSwitches(env)).toThrow(BootError);
|
|
951
1214
|
await expect(bootParsed(env)).rejects.toThrow(/mfaMode/);
|
|
952
1215
|
});
|
|
953
1216
|
|
|
954
1217
|
for (const mode of ["optional", "required"] as const) {
|
|
955
1218
|
it(`reads MFA_MODE=${mode} as the switch that installs MFA, expecting mfa`, () => {
|
|
956
|
-
const
|
|
1219
|
+
const own = readOwnLayers(ownFiles("production"), {
|
|
1220
|
+
env: { ...DOCUMENTED_ENV, MFA_MODE: mode },
|
|
1221
|
+
});
|
|
1222
|
+
const switches = readSwitches(own);
|
|
957
1223
|
expect(switches.mfaMode).toBe(mode);
|
|
958
|
-
|
|
1224
|
+
const shipped = (resolveLayers(own, []).core as { sessionRequirements?: unknown })
|
|
1225
|
+
.sessionRequirements;
|
|
1226
|
+
expect(expectedSessionRequirements(shipped, switches.mfaMode)).toEqual({
|
|
959
1227
|
expected: ["mfa"],
|
|
960
1228
|
secondFactorAuthority: "mfa",
|
|
961
1229
|
});
|
|
962
1230
|
});
|
|
963
1231
|
}
|
|
964
1232
|
|
|
1233
|
+
it("refuses OAUTH_ACCESS_TOKEN_EXPIRES_IN set alone, naming OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN", async () => {
|
|
1234
|
+
const { OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN: _new, ...env } = DOCUMENTED_ENV;
|
|
1235
|
+
const booting = bootParsed({ ...env, OAUTH_ACCESS_TOKEN_EXPIRES_IN: "900" });
|
|
1236
|
+
await expect(booting).rejects.toMatchObject({ reason: "environment-variable-renamed" });
|
|
1237
|
+
await expect(booting).rejects.toThrow(
|
|
1238
|
+
/OAUTH_ACCESS_TOKEN_EXPIRES_IN was renamed OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN/,
|
|
1239
|
+
);
|
|
1240
|
+
});
|
|
1241
|
+
|
|
1242
|
+
it("refuses OAUTH_ACCESS_TOKEN_EXPIRES_IN beside its new name set to another value", async () => {
|
|
1243
|
+
await expect(
|
|
1244
|
+
bootParsed({ ...DOCUMENTED_ENV, OAUTH_ACCESS_TOKEN_EXPIRES_IN: "3600" }),
|
|
1245
|
+
).rejects.toMatchObject({ reason: "environment-variable-renamed" });
|
|
1246
|
+
});
|
|
1247
|
+
|
|
1248
|
+
it("boots with OAUTH_ACCESS_TOKEN_EXPIRES_IN beside its new name set to the same value, reading the new name", async () => {
|
|
1249
|
+
const config = await bootParsed({
|
|
1250
|
+
...DOCUMENTED_ENV,
|
|
1251
|
+
OAUTH_ACCESS_TOKEN_EXPIRES_IN:
|
|
1252
|
+
DOCUMENTED_ENV.OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN as string,
|
|
1253
|
+
});
|
|
1254
|
+
expect(oauthSection(config).accessToken).toEqual({
|
|
1255
|
+
defaultExpiresIn: 900,
|
|
1256
|
+
maxExpiresIn: 7200,
|
|
1257
|
+
});
|
|
1258
|
+
});
|
|
1259
|
+
|
|
965
1260
|
it("refuses DEPLOYMENT_MODE set alone, naming CORE_DEPLOYMENT_MODE", async () => {
|
|
966
1261
|
const { CORE_DEPLOYMENT_MODE: _new, ...env } = DOCUMENTED_ENV;
|
|
967
1262
|
await expect(bootParsed({ ...env, DEPLOYMENT_MODE: "multi" })).rejects.toThrow(
|