@o3co/create-auth-provider 0.17.0-rc.1 → 0.17.0-rc.3
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/README.ja.md +3 -1
- package/README.md +5 -2
- package/package.json +1 -1
- package/templates/standalone/.env.example +7 -5
- package/templates/standalone/README.ja.md +53 -25
- package/templates/standalone/README.md +140 -58
- package/templates/standalone/config/application.conf +41 -36
- package/templates/standalone/config/clients.yaml.example +8 -0
- package/templates/standalone/config/development.conf +2 -2
- package/templates/standalone/config/mailpit.conf +10 -5
- package/templates/standalone/config/reference.conf +30 -5
- package/templates/standalone/docker-compose.mailpit.yml +3 -0
- package/templates/standalone/docker-compose.production.yml +5 -2
- package/templates/standalone/package.json +1 -1
- package/templates/standalone/src/__tests__/adapters.test.mts +92 -25
- 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 +40 -11
- 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 +157 -0
- package/templates/standalone/src/__tests__/configured-mfa-mode.fixture.mts +48 -0
- package/templates/standalone/src/__tests__/core-federations.test.mts +57 -31
- package/templates/standalone/src/__tests__/core-sections.test.mts +7 -5
- package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +1 -1
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +434 -136
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +19 -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 +81 -16
- package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +122 -0
- package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +8 -4
- package/templates/standalone/src/__tests__/mfa-switch.test.mts +149 -46
- package/templates/standalone/src/__tests__/own-modules.test.mts +78 -23
- package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +3 -3
- package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +214 -0
- package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +8 -4
- package/templates/standalone/src/__tests__/replica-safety.test.mts +131 -19
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +25 -23
- package/templates/standalone/src/__tests__/sections.test.mts +24 -0
- 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 +274 -0
- package/templates/standalone/src/__tests__/smoke.test.mts +19 -28
- package/templates/standalone/src/__tests__/startup-log-lines.test.mts +148 -0
- package/templates/standalone/src/__tests__/store-credential.test.mts +8 -13
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +58 -38
- package/templates/standalone/src/__tests__/two-phase-config.test.mts +243 -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 +49 -27
- package/templates/standalone/src/modules.mts +37 -219
- package/templates/standalone/src/rootRenames.mts +38 -22
- package/templates/standalone/src/sections.mts +13 -2
- 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
|
@@ -34,11 +34,10 @@
|
|
|
34
34
|
*/
|
|
35
35
|
|
|
36
36
|
import { readdirSync, readFileSync } from "node:fs";
|
|
37
|
-
import { federationsOf } from "@o3co/auth-provider-core";
|
|
37
|
+
import { type AppConfig, federationsOf } from "@o3co/auth-provider-core";
|
|
38
38
|
import type express from "express";
|
|
39
39
|
import request from "supertest";
|
|
40
40
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
41
|
-
import type { Switches } from "#/configPath.mjs";
|
|
42
41
|
import {
|
|
43
42
|
ALICE,
|
|
44
43
|
AS_LISTED,
|
|
@@ -107,17 +106,17 @@ async function boot(options: Parameters<typeof compose>[0] = {}): Promise<Compos
|
|
|
107
106
|
const TEMPLATE_PACKAGES: Readonly<Record<string, string>> = {
|
|
108
107
|
"@o3co/auth-provider-core":
|
|
109
108
|
"jwksModule, the memory stores, the refresh-token family rotation and revocation",
|
|
110
|
-
"@o3co/auth-provider-federation-google": "
|
|
109
|
+
"@o3co/auth-provider-federation-google": "googleFederationTypeModule, the google type",
|
|
111
110
|
"@o3co/auth-provider-federation-grants": "federationGrantsModules",
|
|
112
|
-
"@o3co/auth-provider-federation-oidc": "
|
|
111
|
+
"@o3co/auth-provider-federation-oidc": "oidcFederationTypeModule, the oidc type",
|
|
113
112
|
"@o3co/auth-provider-foundation":
|
|
114
113
|
"the HTTP user repository adapter; foundationMfaFactorStoreModule under MFA_MODE with adapters.mfaFactorStore = store (tools/composition boots it)",
|
|
115
114
|
"@o3co/auth-provider-mfa":
|
|
116
115
|
"mfaModules (mfaTotpFactorModule, mfaRecoveryCodeFactorModule, mfaEmailFactorModule, mfaModule) and mfaResetModule, under MFA_MODE (mfa-switch.test.mts)",
|
|
117
116
|
"@o3co/auth-provider-oauth":
|
|
118
|
-
"
|
|
117
|
+
"oauthEndpointsModule, oauthSessionGrantModule, oauthAuthorizationGrantsModule, subjectRevocationServiceModule",
|
|
119
118
|
"@o3co/auth-provider-redis": "the Redis stores (all-modules-composition.multi.test.mts)",
|
|
120
|
-
"@o3co/auth-provider-session": "sessionModule,
|
|
119
|
+
"@o3co/auth-provider-session": "sessionModule, sessionStoreModule",
|
|
121
120
|
"@o3co/auth-provider-standard":
|
|
122
121
|
"standardSmtpMailSenderModule; standardDevelopmentMailSenderModule in development",
|
|
123
122
|
};
|
|
@@ -131,10 +130,10 @@ const TEMPLATE_PACKAGES: Readonly<Record<string, string>> = {
|
|
|
131
130
|
* here, through this file's fixture.
|
|
132
131
|
*/
|
|
133
132
|
const NOT_IN_TEMPLATE: Readonly<Record<string, string>> = {
|
|
134
|
-
"@o3co/auth-provider-device-grant": "
|
|
133
|
+
"@o3co/auth-provider-device-grant": "deviceAuthorizationGrantModule",
|
|
135
134
|
"@o3co/auth-provider-dpop": "dpopModule",
|
|
136
|
-
"@o3co/auth-provider-federation-apple": "
|
|
137
|
-
"@o3co/auth-provider-federation-github": "
|
|
135
|
+
"@o3co/auth-provider-federation-apple": "appleFederationTypeModule",
|
|
136
|
+
"@o3co/auth-provider-federation-github": "githubFederationTypeModule",
|
|
138
137
|
"@o3co/auth-provider-mtls": "mtlsModule",
|
|
139
138
|
"@o3co/auth-provider-oauth-token-exchange": "tokenExchangeModule",
|
|
140
139
|
"@o3co/auth-provider-test-kit": "no module: contract suites and fakes, for tests",
|
|
@@ -184,10 +183,8 @@ const ALL_ON_MODULES = [
|
|
|
184
183
|
"oauth-authorization",
|
|
185
184
|
"jwks",
|
|
186
185
|
"session",
|
|
187
|
-
"federation-google",
|
|
188
|
-
"
|
|
189
|
-
"oidc-federation-config",
|
|
190
|
-
"federation-oidc-oidc",
|
|
186
|
+
"federation-google-type",
|
|
187
|
+
"federation-oidc",
|
|
191
188
|
"logging",
|
|
192
189
|
"http",
|
|
193
190
|
"key-store",
|
|
@@ -198,6 +195,7 @@ const ALL_ON_MODULES = [
|
|
|
198
195
|
"core-federation-grant-store-memory",
|
|
199
196
|
"core-federation-grant-intent-store-memory",
|
|
200
197
|
"standalone-in-memory-session-stores",
|
|
198
|
+
"core-session-lifecycle",
|
|
201
199
|
"core-rate-limiter-memory",
|
|
202
200
|
"standalone-in-memory-code-repository",
|
|
203
201
|
"core-access-token-denylist-memory",
|
|
@@ -237,13 +235,18 @@ describe("every module the template can turn on boots together", () => {
|
|
|
237
235
|
);
|
|
238
236
|
});
|
|
239
237
|
|
|
240
|
-
it("registers
|
|
238
|
+
it("registers a federation for every enabled entry of a type a module contributes, and every grant", async () => {
|
|
241
239
|
const { modules, handle, app } = await boot();
|
|
242
|
-
const
|
|
243
|
-
expect(
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
240
|
+
const types = modules.flatMap((m) => contributionNames(m, "federationTypes")).sort();
|
|
241
|
+
expect(types).toEqual(["google", "oidc"]);
|
|
242
|
+
// The shipped entries are named after their types.
|
|
243
|
+
expect([...(handle.components.federationProviders?.keys() ?? [])].sort()).toEqual(types);
|
|
244
|
+
|
|
245
|
+
// What boot registered: a module's grant switched off by its section
|
|
246
|
+
// answers `null` and registers nothing.
|
|
247
|
+
const grants = [...(handle.components.grantHandlerResolver?.entries() ?? [])]
|
|
248
|
+
.map(([grantType]) => grantType)
|
|
249
|
+
.sort();
|
|
247
250
|
expect(grants).toEqual(ENABLED_GRANTS);
|
|
248
251
|
const discovery = await request(app).get("/.well-known/openid-configuration");
|
|
249
252
|
expect([...discovery.body.grant_types_supported].sort()).toEqual(grants);
|
|
@@ -387,18 +390,21 @@ describe("discovery", () => {
|
|
|
387
390
|
});
|
|
388
391
|
|
|
389
392
|
/** The grant connections dropped: they name the OIDC federation. */
|
|
390
|
-
const withoutConnections = (config:
|
|
393
|
+
const withoutConnections = (config: AppConfig): AppConfig =>
|
|
391
394
|
({
|
|
392
395
|
...config,
|
|
393
|
-
"federation-grants": {
|
|
394
|
-
|
|
396
|
+
"federation-grants": {
|
|
397
|
+
...(config["federation-grants"] as Record<string, unknown>),
|
|
398
|
+
connections: {},
|
|
399
|
+
},
|
|
400
|
+
}) as AppConfig;
|
|
395
401
|
|
|
396
402
|
const FEATURE_SWITCHES: ReadonlyArray<
|
|
397
403
|
readonly [
|
|
398
404
|
feature: string,
|
|
399
405
|
variable: string,
|
|
400
406
|
path: string,
|
|
401
|
-
config: ((config:
|
|
407
|
+
config: ((config: AppConfig) => AppConfig) | undefined,
|
|
402
408
|
]
|
|
403
409
|
> = [
|
|
404
410
|
["federation grants", "FEDERATION_GRANTS_ENABLED", "/oauth/federation-grants", undefined],
|
|
@@ -579,13 +585,13 @@ describe("every module's primary route answers in the one app", () => {
|
|
|
579
585
|
knownDefect(
|
|
580
586
|
"a federation enabled from the documented variables alone either refuses to boot or completes a login",
|
|
581
587
|
async () => {
|
|
582
|
-
const withoutLanding = (config:
|
|
588
|
+
const withoutLanding = (config: AppConfig): AppConfig => {
|
|
583
589
|
const federations = federationsOf(config) as Record<string, Record<string, unknown>>;
|
|
584
590
|
const { clientUrl: _dropped, ...oidc } = federations.oidc ?? {};
|
|
585
591
|
return {
|
|
586
592
|
...config,
|
|
587
593
|
core: { ...config.core, federations: { ...federations, oidc } },
|
|
588
|
-
} as unknown as
|
|
594
|
+
} as unknown as AppConfig;
|
|
589
595
|
};
|
|
590
596
|
let composed: Composition;
|
|
591
597
|
try {
|
|
@@ -609,7 +615,7 @@ describe("every module's primary route answers in the one app", () => {
|
|
|
609
615
|
},
|
|
610
616
|
);
|
|
611
617
|
|
|
612
|
-
it("a federation grant is lodged, beside
|
|
618
|
+
it("a federation grant is lodged, beside oauthEndpointsModule under /oauth", async () => {
|
|
613
619
|
const { app } = await boot();
|
|
614
620
|
const res = await lodgeGrant(app);
|
|
615
621
|
expect(res.status).toBe(201);
|
|
@@ -651,7 +657,7 @@ describe.each([AS_LISTED, REVERSED] satisfies ModuleOrder[])(
|
|
|
651
657
|
});
|
|
652
658
|
|
|
653
659
|
it.each(TRANSFERS)(
|
|
654
|
-
"federation grants keep their 16 KiB bound beneath
|
|
660
|
+
"federation grants keep their 16 KiB bound beneath oauthEndpointsModule's /oauth, a body sent %s",
|
|
655
661
|
async (_transfer, send) => {
|
|
656
662
|
for (const type of [JSON_TYPE, FORM_TYPE]) {
|
|
657
663
|
const body =
|
|
@@ -823,6 +829,16 @@ const oidcCallback = async (app: express.Express, outage: Outage, c: Composition
|
|
|
823
829
|
const VERIFIER_WARN =
|
|
824
830
|
"core's verifier (`verifyJwt`, `packages/core/src/jwt/verify.mts`) writes its own `jwt_verify_rejected` warn (`reason: \"revocation_unavailable\"`) beside the route's error line: two lines for one outage (the runbook's outage table documents both)";
|
|
825
831
|
|
|
832
|
+
/** The CSRF token the session middleware set in `cookies`, for a request that mutates. */
|
|
833
|
+
const csrfTokenOf = (cookies: readonly string[]): string => {
|
|
834
|
+
const cookie = cookies.find((c) => /^[^=]*\.csrf=/.test(c));
|
|
835
|
+
if (cookie === undefined) throw new Error("the login reissued no CSRF cookie");
|
|
836
|
+
return decodeURIComponent(cookie.slice(cookie.indexOf("=") + 1).split(";")[0] ?? "");
|
|
837
|
+
};
|
|
838
|
+
|
|
839
|
+
const ROLLBACK_CLOSE_LOGS =
|
|
840
|
+
"a login that fails after its record's open closes that record (`packages/session/src/establish-session.mts`), and the close's own work meets the same outage: core's session lifecycle (`packages/core/src/session-lifecycle/service.mts`) warns `session_close_item_failed` for the item and keeps the close pending for its sweep, a second line for one outage";
|
|
841
|
+
|
|
826
842
|
const OUTAGES: readonly OutageCase[] = [
|
|
827
843
|
{
|
|
828
844
|
module: "oauth-authorization",
|
|
@@ -832,22 +848,6 @@ const OUTAGES: readonly OutageCase[] = [
|
|
|
832
848
|
answer: { status: 503, error: "temporarily_unavailable" },
|
|
833
849
|
event: "authorization_grant_store_unavailable",
|
|
834
850
|
},
|
|
835
|
-
{
|
|
836
|
-
module: "oauth-authorization",
|
|
837
|
-
slot: "sessionFamilyIndex",
|
|
838
|
-
surface: "the code exchange",
|
|
839
|
-
run: codeExchange,
|
|
840
|
-
answer: { status: 503, error: "temporarily_unavailable" },
|
|
841
|
-
event: "authorization_grant_store_unavailable",
|
|
842
|
-
},
|
|
843
|
-
{
|
|
844
|
-
module: "oauth-authorization",
|
|
845
|
-
slot: "sessionRPRegistry",
|
|
846
|
-
surface: "the code exchange",
|
|
847
|
-
run: codeExchange,
|
|
848
|
-
answer: { status: 503, error: "temporarily_unavailable" },
|
|
849
|
-
event: "authorization_grant_store_unavailable",
|
|
850
|
-
},
|
|
851
851
|
{
|
|
852
852
|
module: "oauth",
|
|
853
853
|
slot: "codeRepository",
|
|
@@ -925,6 +925,47 @@ const OUTAGES: readonly OutageCase[] = [
|
|
|
925
925
|
event: "userinfo_store_unavailable",
|
|
926
926
|
unrelatedWarns: ["jwt_verify_aud_skipped"],
|
|
927
927
|
},
|
|
928
|
+
{
|
|
929
|
+
module: "oauth",
|
|
930
|
+
slot: "sessionLifecycleStore",
|
|
931
|
+
surface: "/oauth/userinfo",
|
|
932
|
+
run: async (app, outage) => {
|
|
933
|
+
const { access_token } = await webTokens(app);
|
|
934
|
+
outage.down = true;
|
|
935
|
+
return request(app).get("/oauth/userinfo").set("Authorization", `Bearer ${access_token}`);
|
|
936
|
+
},
|
|
937
|
+
answer: { status: 503, error: "temporarily_unavailable" },
|
|
938
|
+
event: "userinfo_store_unavailable",
|
|
939
|
+
unrelatedWarns: ["jwt_verify_aud_skipped"],
|
|
940
|
+
},
|
|
941
|
+
{
|
|
942
|
+
module: "oauth",
|
|
943
|
+
slot: "sessionLifecycleStore",
|
|
944
|
+
surface: "POST /oauth/logout",
|
|
945
|
+
run: async (app, outage) => {
|
|
946
|
+
const { id_token } = await webTokens(app);
|
|
947
|
+
outage.down = true;
|
|
948
|
+
return request(app).post("/oauth/logout").type("form").send({ id_token_hint: id_token });
|
|
949
|
+
},
|
|
950
|
+
answer: { status: 503, error: "temporarily_unavailable" },
|
|
951
|
+
event: "logout_store_unavailable",
|
|
952
|
+
unrelatedWarns: ["jwt_verify_aud_skipped"],
|
|
953
|
+
},
|
|
954
|
+
{
|
|
955
|
+
module: "session",
|
|
956
|
+
slot: "sessionLifecycleStore",
|
|
957
|
+
surface: "POST /session/logout",
|
|
958
|
+
run: async (app, outage) => {
|
|
959
|
+
const { cookies } = await login(app);
|
|
960
|
+
outage.down = true;
|
|
961
|
+
return request(app)
|
|
962
|
+
.post("/session/logout")
|
|
963
|
+
.set("Cookie", cookies)
|
|
964
|
+
.set("x-csrf-token", csrfTokenOf(cookies));
|
|
965
|
+
},
|
|
966
|
+
answer: { status: 503, error: "temporarily_unavailable" },
|
|
967
|
+
event: "session_logout_store_unavailable",
|
|
968
|
+
},
|
|
928
969
|
{
|
|
929
970
|
module: "oauth-session",
|
|
930
971
|
slot: "userSessionStore",
|
|
@@ -981,6 +1022,7 @@ const OUTAGES: readonly OutageCase[] = [
|
|
|
981
1022
|
},
|
|
982
1023
|
answer: { status: 503, error: "temporarily_unavailable" },
|
|
983
1024
|
event: "login_store_unavailable",
|
|
1025
|
+
defects: { "no-warn": ROLLBACK_CLOSE_LOGS },
|
|
984
1026
|
},
|
|
985
1027
|
{
|
|
986
1028
|
module: "session",
|
|
@@ -1041,14 +1083,7 @@ const OUTAGES: readonly OutageCase[] = [
|
|
|
1041
1083
|
run: oidcCallback,
|
|
1042
1084
|
answer: { status: 503, error: "temporarily_unavailable" },
|
|
1043
1085
|
event: "federation_callback_store_unavailable",
|
|
1044
|
-
|
|
1045
|
-
{
|
|
1046
|
-
module: "session",
|
|
1047
|
-
slot: "sessionFederationIndex",
|
|
1048
|
-
surface: "the OIDC federation callback",
|
|
1049
|
-
run: oidcCallback,
|
|
1050
|
-
answer: { status: 503, error: "temporarily_unavailable" },
|
|
1051
|
-
event: "federation_callback_store_unavailable",
|
|
1086
|
+
defects: { "no-warn": ROLLBACK_CLOSE_LOGS },
|
|
1052
1087
|
},
|
|
1053
1088
|
{
|
|
1054
1089
|
module: "core (rate-limit guard)",
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
import { generateKeyPairSync } from "node:crypto";
|
|
29
29
|
import { readFileSync } from "node:fs";
|
|
30
30
|
import {
|
|
31
|
+
type AppConfig,
|
|
31
32
|
type AuditEvent,
|
|
32
33
|
type AuditSink,
|
|
33
34
|
createApp,
|
|
@@ -54,8 +55,12 @@ const keyPair = generateKeyPairSync("ed25519", {
|
|
|
54
55
|
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
|
55
56
|
});
|
|
56
57
|
|
|
57
|
-
/**
|
|
58
|
-
|
|
58
|
+
/**
|
|
59
|
+
* Phase one's switches beside the configuration boot is handed; the template
|
|
60
|
+
* modules' sections sit beside core's, so the configuration is wider than
|
|
61
|
+
* `AppConfig`.
|
|
62
|
+
*/
|
|
63
|
+
const baseConfig: Switches & AppConfig & Record<string, unknown> = {
|
|
59
64
|
// The shipped selections, every store in this process.
|
|
60
65
|
adapters: {
|
|
61
66
|
...shippedAdapters(),
|
|
@@ -85,17 +90,13 @@ const baseConfig: Switches & Record<string, unknown> = {
|
|
|
85
90
|
},
|
|
86
91
|
// The shipped `application.conf` expects no session requirement (ADR
|
|
87
92
|
// 2026-09-28-session-admission).
|
|
88
|
-
...coreConfigForTests({ federations: { google: { enabled: false } } }),
|
|
93
|
+
...coreConfigForTests({ federations: { google: { enabled: false, type: "google" } } }),
|
|
89
94
|
oauth: {
|
|
90
95
|
jwt: {
|
|
91
96
|
issuer: "https://auth.test",
|
|
92
97
|
},
|
|
93
|
-
accessToken: {
|
|
94
|
-
refreshToken: {
|
|
95
|
-
expiresIn: 86400,
|
|
96
|
-
unknownFamilyPolicy: "reject" as const,
|
|
97
|
-
legacyRtPolicy: "reject" as const,
|
|
98
|
-
},
|
|
98
|
+
accessToken: { defaultExpiresIn: 3600 },
|
|
99
|
+
refreshToken: { expiresIn: 86400 },
|
|
99
100
|
grants: {},
|
|
100
101
|
oidcMode: "oidc-required",
|
|
101
102
|
},
|
|
@@ -0,0 +1,157 @@
|
|
|
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
|
+
* Core's notices about configuration nothing loaded reads, through the
|
|
19
|
+
* template's own reading: the template hands boot the configuration's
|
|
20
|
+
* defaults (`configDefaultsFor`), and core alone decides what is named. A
|
|
21
|
+
* section the template's `reference.conf` sets for a module the composition
|
|
22
|
+
* does not load is silent while it is as that file sets it, and
|
|
23
|
+
* `config_sections_not_loaded` once the operator's layer or the environment
|
|
24
|
+
* changes it; a section no loaded package sets is `config_sections_ignored`;
|
|
25
|
+
* a renamed variable no loaded module declares is
|
|
26
|
+
* `environment_variables_not_applied`.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { fileURLToPath } from "node:url";
|
|
30
|
+
import { moduleReferences } from "@o3co/auth-provider-core";
|
|
31
|
+
import { parseFile } from "@o3co/ts.hocon";
|
|
32
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
33
|
+
import { configDefaultsFor } from "#/configPath.mjs";
|
|
34
|
+
import {
|
|
35
|
+
type ComposeOptions,
|
|
36
|
+
type Composition,
|
|
37
|
+
compose,
|
|
38
|
+
composedModules,
|
|
39
|
+
type RecordingLogger,
|
|
40
|
+
resolveConfig,
|
|
41
|
+
SINGLE_ENV,
|
|
42
|
+
} from "./all-modules-composition.fixture.mjs";
|
|
43
|
+
|
|
44
|
+
let current: Composition | undefined;
|
|
45
|
+
|
|
46
|
+
afterEach(async () => {
|
|
47
|
+
await current?.handle.dispose();
|
|
48
|
+
current = undefined;
|
|
49
|
+
vi.unstubAllEnvs();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const boot = async (options: ComposeOptions): Promise<Composition> => {
|
|
53
|
+
current = await compose(options);
|
|
54
|
+
return current;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** What each warn line named `event` names: its `sections` or `variables`. */
|
|
58
|
+
const named = (logger: RecordingLogger, event: string): unknown[] =>
|
|
59
|
+
logger.lines
|
|
60
|
+
.filter((line) => line.level === "warn" && line.args[1] === event)
|
|
61
|
+
.map((line) => line.args[0]);
|
|
62
|
+
|
|
63
|
+
/** `redis-clients` is the template's module, which no store on Redis loads under SINGLE_ENV. */
|
|
64
|
+
const NOT_LOADED = "redis-clients";
|
|
65
|
+
|
|
66
|
+
describe("a section the template's reference.conf sets for a module the composition does not load", () => {
|
|
67
|
+
it("is handed to boot and named by nothing while it is as that file sets it", async () => {
|
|
68
|
+
const { logger, resolved, modules } = await boot({ env: SINGLE_ENV });
|
|
69
|
+
expect(modules.map((m) => m.name)).not.toContain(NOT_LOADED);
|
|
70
|
+
expect(resolved).toHaveProperty([NOT_LOADED]);
|
|
71
|
+
expect(named(logger, "config_sections_not_loaded")).toEqual([]);
|
|
72
|
+
expect(named(logger, "config_sections_ignored")).toEqual([]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("is config_sections_not_loaded once the operator's layer changes it", async () => {
|
|
76
|
+
const { logger } = await boot({
|
|
77
|
+
env: SINGLE_ENV,
|
|
78
|
+
operatorHocon: `${NOT_LOADED}.url = "redis://elsewhere.test:6379"\n`,
|
|
79
|
+
});
|
|
80
|
+
expect(named(logger, "config_sections_not_loaded")).toEqual([{ sections: [NOT_LOADED] }]);
|
|
81
|
+
expect(named(logger, "config_sections_ignored")).toEqual([]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("is config_sections_not_loaded once the environment changes it, and the variable environment_variables_not_applied", async () => {
|
|
85
|
+
const { logger } = await boot({
|
|
86
|
+
env: { ...SINGLE_ENV, REDIS_CLIENTS_URL: "redis://elsewhere.test:6379" },
|
|
87
|
+
});
|
|
88
|
+
expect(named(logger, "config_sections_not_loaded")).toEqual([{ sections: [NOT_LOADED] }]);
|
|
89
|
+
expect(named(logger, "config_sections_ignored")).toEqual([]);
|
|
90
|
+
expect(named(logger, "environment_variables_not_applied")).toEqual([
|
|
91
|
+
{ variables: ["REDIS_CLIENTS_URL"] },
|
|
92
|
+
]);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("the limiter sections the template's reference.conf writes for both limiters", () => {
|
|
97
|
+
it.each(["memory", "redis"] as const)(
|
|
98
|
+
"are named by nothing with the %s limiter wired",
|
|
99
|
+
async (rateLimiter) => {
|
|
100
|
+
const { logger, modules } = await boot({
|
|
101
|
+
env: { ...SINGLE_ENV, ADAPTERS_RATE_LIMITER: rateLimiter },
|
|
102
|
+
});
|
|
103
|
+
expect(modules.map((m) => m.name)).toContain(
|
|
104
|
+
rateLimiter === "redis" ? "redis-rate-limiter" : "core-rate-limiter-memory",
|
|
105
|
+
);
|
|
106
|
+
expect(named(logger, "config_sections_not_loaded")).toEqual([]);
|
|
107
|
+
expect(named(logger, "config_sections_ignored")).toEqual([]);
|
|
108
|
+
},
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("a section no loaded package's reference.conf sets", () => {
|
|
113
|
+
it("is config_sections_ignored: a misspelt section name", async () => {
|
|
114
|
+
const { logger } = await boot({
|
|
115
|
+
env: SINGLE_ENV,
|
|
116
|
+
operatorHocon: 'redis-client.url = "redis://elsewhere.test:6379"\n',
|
|
117
|
+
});
|
|
118
|
+
expect(named(logger, "config_sections_ignored")).toEqual([{ sections: ["redis-client"] }]);
|
|
119
|
+
expect(named(logger, "config_sections_not_loaded")).toEqual([]);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("a renamed variable a loaded package captures and no loaded module declares", () => {
|
|
124
|
+
it("is environment_variables_not_applied: RATE_LIMIT_FAIL_MODE with the Redis package loaded but not its rate limiter", async () => {
|
|
125
|
+
const { logger, modules } = await boot({
|
|
126
|
+
env: { ...SINGLE_ENV, ADAPTERS_CODE_REPOSITORY: "redis", RATE_LIMIT_FAIL_MODE: "closed" },
|
|
127
|
+
});
|
|
128
|
+
expect(modules.map((m) => m.name)).not.toContain("redis-rate-limiter");
|
|
129
|
+
expect(named(logger, "environment_variables_not_applied")).toEqual([
|
|
130
|
+
{ variables: ["RATE_LIMIT_FAIL_MODE"] },
|
|
131
|
+
]);
|
|
132
|
+
expect(named(logger, "config_sections_not_loaded")).toEqual([]);
|
|
133
|
+
expect(named(logger, "config_sections_ignored")).toEqual([]);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("configDefaultsFor", () => {
|
|
138
|
+
const modules = composedModules(resolveConfig(SINGLE_ENV));
|
|
139
|
+
|
|
140
|
+
it("is every loaded package's reference.conf, core's last, resolved with no environment", () => {
|
|
141
|
+
const references = moduleReferences(modules).map((reference) =>
|
|
142
|
+
parseFile(fileURLToPath(reference), { env: {} }),
|
|
143
|
+
);
|
|
144
|
+
const expected = references
|
|
145
|
+
.slice(1)
|
|
146
|
+
.reduce((config, next) => config.withFallback(next), references[0]);
|
|
147
|
+
expect(configDefaultsFor(modules)).toEqual(expected?.toObject());
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("takes nothing from the process's environment", () => {
|
|
151
|
+
vi.stubEnv("REDIS_CLIENTS_URL", "redis://operator.test:6379");
|
|
152
|
+
expect(configDefaultsFor(modules)[NOT_LOADED]).toEqual({
|
|
153
|
+
url: "redis://localhost:6379",
|
|
154
|
+
assumeNoEviction: false,
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 1o1 Co. Ltd.
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The MFA switch, `mfaMode`, as this project's own configuration files write
|
|
19
|
+
* it with `MFA_MODE` unset: `{environment}.conf` over `application.conf` over
|
|
20
|
+
* the template's `config/reference.conf`. The template ships it `required`;
|
|
21
|
+
* a project `create-auth-provider --no-mfa` scaffolds writes it `off` at the
|
|
22
|
+
* end of its `application.conf`. A suite whose subject depends on the switch
|
|
23
|
+
* asserts what MFA on (`required`, as the template ships it) or off (as
|
|
24
|
+
* `--no-mfa` writes it) implies, so the suite that ships with a scaffold holds
|
|
25
|
+
* in either project. A project whose files write another mode, such as
|
|
26
|
+
* `optional`, is not what these suites claim to cover.
|
|
27
|
+
*
|
|
28
|
+
* Read with the HOCON parser alone, not through phase one (`readSwitches`),
|
|
29
|
+
* so that a suite about phase one can state what phase one should answer.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { join } from "node:path";
|
|
33
|
+
import { fileURLToPath } from "node:url";
|
|
34
|
+
import { type Config, empty, parseFile } from "@o3co/ts.hocon";
|
|
35
|
+
import { type MfaSwitch, mfaSwitchSchema } from "#/sections.mjs";
|
|
36
|
+
|
|
37
|
+
const configDir = fileURLToPath(new URL("../../config", import.meta.url));
|
|
38
|
+
|
|
39
|
+
/** `mfaMode` as the files selected by `environment` write it, with no variable set. */
|
|
40
|
+
export function configuredMfaMode(environment: string): MfaSwitch {
|
|
41
|
+
const resolved = [`${environment}.conf`, "application.conf", "reference.conf"]
|
|
42
|
+
.reduce<Config>(
|
|
43
|
+
(layered, file) => layered.withFallback(parseFile(join(configDir, file), { env: {} })),
|
|
44
|
+
empty(),
|
|
45
|
+
)
|
|
46
|
+
.toObject() as Record<string, unknown>;
|
|
47
|
+
return mfaSwitchSchema.parse(resolved.mfaMode);
|
|
48
|
+
}
|
|
@@ -16,13 +16,14 @@
|
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* The federations the template ships, in core's own section:
|
|
19
|
-
* `core.federations.google` and `core.federations.oidc`, each
|
|
20
|
-
* variables named after its paths
|
|
21
|
-
*
|
|
19
|
+
* `core.federations.google` and `core.federations.oidc`, each naming its
|
|
20
|
+
* type and bound to the variables named after its paths
|
|
21
|
+
* (`CORE_FEDERATIONS_<NAME>_<KEY>`). The federation types the template
|
|
22
|
+
* bundles are listed whatever the map says (`federation-types.test.mts`
|
|
23
|
+
* boots them). A variable the
|
|
22
24
|
* template bound before, `FEDERATIONS_<NAME>_<KEY>`, set alone or beside its
|
|
23
|
-
* new name at
|
|
24
|
-
*
|
|
25
|
-
* are accepted. The map written at the top level refuses boot, naming its
|
|
25
|
+
* new name, at any value, is refused before any module is chosen, naming the
|
|
26
|
+
* new variable and path and never a value. The map written at the top level refuses boot, naming its
|
|
26
27
|
* paths under `core.federations`.
|
|
27
28
|
*/
|
|
28
29
|
|
|
@@ -30,7 +31,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
|
30
31
|
import { tmpdir } from "node:os";
|
|
31
32
|
import { join } from "node:path";
|
|
32
33
|
import { fileURLToPath } from "node:url";
|
|
33
|
-
import { createApp } from "@o3co/auth-provider-core";
|
|
34
|
+
import { BootError, createApp } from "@o3co/auth-provider-core";
|
|
34
35
|
import { afterAll, describe, expect, it } from "vitest";
|
|
35
36
|
import { buildModules } from "#/buildModules.mjs";
|
|
36
37
|
import { readOwnLayers, readSwitches, resolveConfigPaths, resolveForBoot } from "#/configPath.mjs";
|
|
@@ -71,10 +72,16 @@ function refusal(env: Record<string, string>): Error {
|
|
|
71
72
|
throw new Error("phase one read the switches");
|
|
72
73
|
}
|
|
73
74
|
|
|
74
|
-
/** The federations phase one
|
|
75
|
-
|
|
76
|
-
(
|
|
77
|
-
|
|
75
|
+
/** The federations the composition hands boot under `env`, by name: phase one, then phase two. */
|
|
76
|
+
function federationsOf(env: Record<string, string> = {}) {
|
|
77
|
+
const own = readOwnLayers(ownFiles(), { env: { ...MFA_OFF, ...env } });
|
|
78
|
+
const switches = readSwitches(own);
|
|
79
|
+
const modules = buildModules(switches, { environment: "production" });
|
|
80
|
+
const resolved = resolveForBoot(own, modules, switches) as unknown as {
|
|
81
|
+
readonly core?: { readonly federations?: Record<string, Record<string, unknown>> };
|
|
82
|
+
};
|
|
83
|
+
return resolved.core?.federations ?? {};
|
|
84
|
+
}
|
|
78
85
|
|
|
79
86
|
/** The modules phase one chooses under `env`, by name. */
|
|
80
87
|
const moduleNames = (env: Record<string, string>) =>
|
|
@@ -131,9 +138,9 @@ const RENAMED = [
|
|
|
131
138
|
] as const;
|
|
132
139
|
|
|
133
140
|
describe("the federations the template ships, under core.federations", () => {
|
|
134
|
-
it("ships google and oidc disabled,
|
|
141
|
+
it("ships google and oidc disabled, each of the type of its name", () => {
|
|
135
142
|
const federations = federationsOf();
|
|
136
|
-
expect(federations.google
|
|
143
|
+
expect(federations.google).toMatchObject({ enabled: false, type: "google" });
|
|
137
144
|
expect(federations.oidc).toMatchObject({ enabled: false, type: "oidc" });
|
|
138
145
|
});
|
|
139
146
|
|
|
@@ -144,20 +151,16 @@ describe("the federations the template ships, under core.federations", () => {
|
|
|
144
151
|
},
|
|
145
152
|
);
|
|
146
153
|
|
|
147
|
-
it("
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
),
|
|
158
|
-
{ environment: "production" },
|
|
159
|
-
).map((module) => module.name);
|
|
160
|
-
expect(names).toContain("federation-oidc-okta");
|
|
154
|
+
it("lists the same federation modules whether CORE_FEDERATIONS_GOOGLE_ENABLED is true or not", () => {
|
|
155
|
+
const federationModules = (env: Record<string, string>) =>
|
|
156
|
+
moduleNames(env).filter((name) => name === "federation-oidc" || name.endsWith("-type"));
|
|
157
|
+
expect(federationModules({ CORE_FEDERATIONS_GOOGLE_ENABLED: "true" })).toEqual([
|
|
158
|
+
"federation-google-type",
|
|
159
|
+
"federation-oidc",
|
|
160
|
+
]);
|
|
161
|
+
expect(federationModules({})).toEqual(
|
|
162
|
+
federationModules({ CORE_FEDERATIONS_GOOGLE_ENABLED: "true" }),
|
|
163
|
+
);
|
|
161
164
|
});
|
|
162
165
|
});
|
|
163
166
|
|
|
@@ -166,9 +169,24 @@ describe("a variable the template bound before, FEDERATIONS_<NAME>_<KEY>", () =>
|
|
|
166
169
|
"core.federations.%s.%s: %s set alone is refused before any module is chosen, naming %s",
|
|
167
170
|
(name, key, old, variable, value) => {
|
|
168
171
|
const err = refusal({ [old]: value });
|
|
169
|
-
expect(err).toBeInstanceOf(
|
|
172
|
+
expect(err).toBeInstanceOf(BootError);
|
|
170
173
|
expect(err.message).toContain(`${old} was renamed ${variable}`);
|
|
171
174
|
expect(err.message).toContain(`core.federations.${name}.${key}`);
|
|
175
|
+
expect(err).toMatchObject({
|
|
176
|
+
reason: "environment-variable-renamed",
|
|
177
|
+
details: {
|
|
178
|
+
reason: "environment-variable-renamed",
|
|
179
|
+
renamed: [
|
|
180
|
+
{
|
|
181
|
+
module: "core",
|
|
182
|
+
from: old,
|
|
183
|
+
to: variable,
|
|
184
|
+
path: `core.federations.${name}.${key}`,
|
|
185
|
+
state: "unset",
|
|
186
|
+
},
|
|
187
|
+
],
|
|
188
|
+
},
|
|
189
|
+
});
|
|
172
190
|
},
|
|
173
191
|
);
|
|
174
192
|
|
|
@@ -176,6 +194,10 @@ describe("a variable the template bound before, FEDERATIONS_<NAME>_<KEY>", () =>
|
|
|
176
194
|
"core.federations.%s.%s: %s beside its new name at a different value is refused, quoting neither",
|
|
177
195
|
(_name, _key, old, variable) => {
|
|
178
196
|
const err = refusal({ [old]: "old-value-5e2d", [variable]: "new-value-c81a" });
|
|
197
|
+
expect(err).toMatchObject({
|
|
198
|
+
reason: "environment-variable-renamed",
|
|
199
|
+
details: { renamed: [{ from: old, state: "different" }] },
|
|
200
|
+
});
|
|
179
201
|
expect(err.message).toContain(old);
|
|
180
202
|
expect(err.message).not.toContain("old-value-5e2d");
|
|
181
203
|
expect(err.message).not.toContain("new-value-c81a");
|
|
@@ -183,9 +205,13 @@ describe("a variable the template bound before, FEDERATIONS_<NAME>_<KEY>", () =>
|
|
|
183
205
|
);
|
|
184
206
|
|
|
185
207
|
it.each(RENAMED)(
|
|
186
|
-
"core.federations.%s.%s: %s beside its new name at the same value is
|
|
187
|
-
(
|
|
188
|
-
|
|
208
|
+
"core.federations.%s.%s: %s beside its new name at the same value is refused all the same",
|
|
209
|
+
(_name, _key, old, variable, value) => {
|
|
210
|
+
const err = refusal({ [old]: value, [variable]: value });
|
|
211
|
+
expect(err).toMatchObject({
|
|
212
|
+
reason: "environment-variable-renamed",
|
|
213
|
+
details: { renamed: [{ from: old, state: "different" }] },
|
|
214
|
+
});
|
|
189
215
|
},
|
|
190
216
|
);
|
|
191
217
|
});
|