@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
|
@@ -30,8 +30,6 @@
|
|
|
30
30
|
import { createHash, randomBytes } from "node:crypto";
|
|
31
31
|
import { fileURLToPath } from "node:url";
|
|
32
32
|
import {
|
|
33
|
-
type AppConfig,
|
|
34
|
-
AppConfigSchema,
|
|
35
33
|
createApp,
|
|
36
34
|
createKeyStoreFactory,
|
|
37
35
|
defineModule,
|
|
@@ -43,19 +41,20 @@ import {
|
|
|
43
41
|
registerBuiltinKeyStores,
|
|
44
42
|
type UserSessionStore,
|
|
45
43
|
} from "@o3co/auth-provider-core";
|
|
44
|
+
import { federationTypeForTests } from "@o3co/auth-provider-core/testing";
|
|
45
|
+
import type { OAuthSection } from "@o3co/auth-provider-oauth";
|
|
46
46
|
import { parseFile } from "@o3co/ts.hocon";
|
|
47
|
-
import { validate } from "@o3co/ts.hocon/zod";
|
|
48
47
|
import express from "express";
|
|
49
48
|
import request from "supertest";
|
|
50
49
|
import { afterEach, describe, expect, it } from "vitest";
|
|
51
50
|
import { buildModules } from "#/buildModules.mjs";
|
|
52
|
-
import { resolveConfigPaths
|
|
51
|
+
import { resolveConfigPaths } from "#/configPath.mjs";
|
|
53
52
|
import { templateReference } from "../modules.mjs";
|
|
54
53
|
import {
|
|
54
|
+
type BothPhases,
|
|
55
|
+
bothPhasesOf,
|
|
55
56
|
capturedRenames,
|
|
56
57
|
libraryLayers,
|
|
57
|
-
rootSectionsOf,
|
|
58
|
-
sectionsCoreDoesNotDeclare,
|
|
59
58
|
} from "./library-references.fixture.mjs";
|
|
60
59
|
|
|
61
60
|
const configDir = fileURLToPath(new URL("../../config", import.meta.url));
|
|
@@ -94,14 +93,14 @@ const ENV: Readonly<Record<string, string>> = {
|
|
|
94
93
|
};
|
|
95
94
|
|
|
96
95
|
/** The shipped configuration, with the partner federation and an acr table beside it. */
|
|
97
|
-
function resolveConfig(trustUpstreamAmr: boolean | undefined):
|
|
96
|
+
function resolveConfig(trustUpstreamAmr: boolean | undefined): BothPhases {
|
|
98
97
|
const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
|
|
99
98
|
const layers = parseFile(envConfPath, { env: ENV })
|
|
100
99
|
.withFallback(parseFile(applicationConfPath, { env: ENV }))
|
|
101
100
|
.withFallback(parseFile(fileURLToPath(templateReference()), { env: ENV }))
|
|
102
101
|
.withFallback(libraryLayers(ENV));
|
|
103
|
-
const shipped =
|
|
104
|
-
|
|
102
|
+
const shipped = bothPhasesOf(layers, ENV);
|
|
103
|
+
return {
|
|
105
104
|
...shipped,
|
|
106
105
|
core: {
|
|
107
106
|
...shipped.core,
|
|
@@ -116,21 +115,15 @@ function resolveConfig(trustUpstreamAmr: boolean | undefined): Switches {
|
|
|
116
115
|
},
|
|
117
116
|
},
|
|
118
117
|
oauth: {
|
|
119
|
-
...shipped.oauth,
|
|
118
|
+
...(shipped.oauth as OAuthSection),
|
|
120
119
|
authorize: {
|
|
121
|
-
...shipped.oauth.authorize,
|
|
120
|
+
...(shipped.oauth as OAuthSection).authorize,
|
|
122
121
|
acrValues: { [MFA_ACR]: ["mfa"], [FED_ACR]: ["fed"] },
|
|
123
122
|
},
|
|
124
123
|
},
|
|
125
|
-
|
|
126
|
-
return {
|
|
127
|
-
...sectionsCoreDoesNotDeclare(layers),
|
|
128
|
-
...rootSectionsOf(layers, ENV),
|
|
129
|
-
...parsed,
|
|
130
|
-
// What the resolution captured of core's renamed variables, which the
|
|
131
|
-
// schema's parse drops.
|
|
124
|
+
// What the resolution captured of the renamed variables.
|
|
132
125
|
"renamed-variables": capturedRenames(ENV),
|
|
133
|
-
} as
|
|
126
|
+
} as BothPhases;
|
|
134
127
|
}
|
|
135
128
|
|
|
136
129
|
/** An IdP that authenticates `ext-1` and says it did so with `mfa` and `hwk`. */
|
|
@@ -151,18 +144,11 @@ const partner: FederationProvider = {
|
|
|
151
144
|
}),
|
|
152
145
|
};
|
|
153
146
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
[FEDERATION]: () => ({
|
|
160
|
-
validateRedirect: () => ({ ok: true as const, value: undefined }),
|
|
161
|
-
resolveCallbackRedirect: () => ({ ok: true as const, value: "/" }),
|
|
162
|
-
}),
|
|
163
|
-
},
|
|
164
|
-
} as never,
|
|
165
|
-
});
|
|
147
|
+
/**
|
|
148
|
+
* The partner federation's type: the `partner` entry is the IdP above, with a
|
|
149
|
+
* redirect policy that accepts every redirect and resolves the callback to `/`.
|
|
150
|
+
*/
|
|
151
|
+
const partnerFederationModule = federationTypeForTests(FEDERATION, { provider: () => partner });
|
|
166
152
|
|
|
167
153
|
const testRepositoriesModule = defineModule({
|
|
168
154
|
name: "test:repositories",
|
|
@@ -354,6 +340,9 @@ describe("an upstream IdP's amr counts only for a federation that trusts it", ()
|
|
|
354
340
|
federation: FEDERATION,
|
|
355
341
|
upstreamAmr: UPSTREAM_AMR,
|
|
356
342
|
mfaAt: undefined,
|
|
343
|
+
// The partner reports no `auth_time`, and the federation does not let
|
|
344
|
+
// its callback stand for one (`callbackMeetsFreshness`, false by default).
|
|
345
|
+
upstreamAuthTime: null,
|
|
357
346
|
});
|
|
358
347
|
});
|
|
359
348
|
|
|
@@ -379,6 +368,9 @@ describe("an upstream IdP's amr counts only for a federation that trusts it", ()
|
|
|
379
368
|
federation: FEDERATION,
|
|
380
369
|
upstreamAmr: undefined,
|
|
381
370
|
mfaAt: undefined,
|
|
371
|
+
// The partner reports no `auth_time`, and the federation does not let
|
|
372
|
+
// its callback stand for one (`callbackMeetsFreshness`, false by default).
|
|
373
|
+
upstreamAuthTime: null,
|
|
382
374
|
});
|
|
383
375
|
});
|
|
384
376
|
|
|
@@ -19,11 +19,13 @@
|
|
|
19
19
|
* slot. Phase one reads it alone, before the modules are chosen, with the
|
|
20
20
|
* template's own schema; boot is never handed it. The paths the selections
|
|
21
21
|
* moved from, and the variables renamed with them, are refused here, before
|
|
22
|
-
* any module is chosen, in the words
|
|
23
|
-
*
|
|
22
|
+
* any module is chosen, in the words and under the reasons boot refuses a
|
|
23
|
+
* module's with (`config-path-relocated`, `environment-variable-renamed`): a
|
|
24
|
+
* renamed variable set alone, or beside its new name at a different value, is
|
|
24
25
|
* refused, naming the variables and never a value.
|
|
25
26
|
*/
|
|
26
27
|
|
|
28
|
+
import { configRefused, issuesAt, pathsRelocated } from "./bootRefusal.mjs";
|
|
27
29
|
import { refuseRenamedVariables } from "./rootRenames.mjs";
|
|
28
30
|
import { type Adapters, adaptersSchema } from "./sections.mjs";
|
|
29
31
|
|
|
@@ -33,6 +35,7 @@ export const ADAPTERS_SECTION = "adapters";
|
|
|
33
35
|
/** Each selection's key under `adapters`, and the variable its path is bound to. */
|
|
34
36
|
const VARIABLES: Readonly<Record<keyof Adapters, string>> = {
|
|
35
37
|
rateLimiter: "ADAPTERS_RATE_LIMITER",
|
|
38
|
+
attemptCounter: "ADAPTERS_ATTEMPT_COUNTER",
|
|
36
39
|
userSessionStores: "ADAPTERS_USER_SESSION_STORES",
|
|
37
40
|
accessTokenDenylist: "ADAPTERS_ACCESS_TOKEN_DENYLIST",
|
|
38
41
|
replaySeenSet: "ADAPTERS_REPLAY_SEEN_SET",
|
|
@@ -101,30 +104,40 @@ function valueAt(config: unknown, path: string): unknown {
|
|
|
101
104
|
/**
|
|
102
105
|
* `adapters` from `resolved` — the template's own layers over its
|
|
103
106
|
* `config/reference.conf` — under `env`, the environment they were
|
|
104
|
-
* substituted with, parsed with the template's schema. Refuses, with a
|
|
105
|
-
* `
|
|
106
|
-
* its new path and variable;
|
|
107
|
-
* (`refuseRenamedVariables`); then a value
|
|
108
|
-
* naming its path under `adapters
|
|
107
|
+
* substituted with, parsed with the template's schema. Refuses, each with a
|
|
108
|
+
* `BootError` (`bootRefusal.mts`), a selection still written at the path it
|
|
109
|
+
* moved from, naming its new path and variable (`config-path-relocated`);
|
|
110
|
+
* then a variable renamed with one (`refuseRenamedVariables`); then a value
|
|
111
|
+
* or a key the schema refuses, naming its path under `adapters`
|
|
112
|
+
* (`config-validation-failed`).
|
|
109
113
|
*/
|
|
110
114
|
export function readAdapters(
|
|
111
115
|
resolved: Readonly<Record<string, unknown>>,
|
|
112
116
|
env: Readonly<Record<string, string>>,
|
|
113
117
|
): Adapters {
|
|
114
118
|
const moved = MOVED.filter(([from]) => valueAt(resolved, from) !== undefined).map(
|
|
115
|
-
([from, key]) => {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
+
([from, key]) => ({
|
|
120
|
+
module: ADAPTERS_SECTION,
|
|
121
|
+
from,
|
|
122
|
+
to: `${ADAPTERS_SECTION}.${key}`,
|
|
123
|
+
environmentVariable: VARIABLES[key],
|
|
124
|
+
}),
|
|
119
125
|
);
|
|
120
126
|
if (moved.length > 0) {
|
|
121
|
-
throw
|
|
122
|
-
`Configuration sets ${moved.length} path(s) that moved: ${moved
|
|
127
|
+
throw pathsRelocated(
|
|
128
|
+
`Configuration sets ${moved.length} path(s) that moved: ${moved
|
|
129
|
+
.map(
|
|
130
|
+
({ from, to, environmentVariable }) =>
|
|
131
|
+
`${from} has moved to ${to}; see CHANGELOG. Write it there (environment variable ${environmentVariable}) and remove this field from your config (or unset the environment variable that sets it).`,
|
|
132
|
+
)
|
|
133
|
+
.join(" ")}`,
|
|
134
|
+
moved,
|
|
123
135
|
);
|
|
124
136
|
}
|
|
125
137
|
refuseRenamedVariables(
|
|
126
138
|
env,
|
|
127
139
|
RENAMED.map(([from, key]) => ({
|
|
140
|
+
module: ADAPTERS_SECTION,
|
|
128
141
|
from,
|
|
129
142
|
to: VARIABLES[key],
|
|
130
143
|
path: `${ADAPTERS_SECTION}.${key}`,
|
|
@@ -132,17 +145,17 @@ export function readAdapters(
|
|
|
132
145
|
);
|
|
133
146
|
const result = adaptersSchema.safeParse(resolved[ADAPTERS_SECTION]);
|
|
134
147
|
if (!result.success) {
|
|
135
|
-
const issues = result.error.issues;
|
|
136
|
-
throw
|
|
148
|
+
const issues = issuesAt([ADAPTERS_SECTION], result.error.issues);
|
|
149
|
+
throw configRefused(
|
|
137
150
|
`Config validation failed — ${issues.length} issue(s) found: ${issues
|
|
138
151
|
.map((issue) => {
|
|
139
152
|
const unknown =
|
|
140
153
|
issue.code === "unrecognized_keys" ? ` ${JSON.stringify(issue.keys)}` : "";
|
|
141
|
-
|
|
142
|
-
return `${path}: ${issue.message}${unknown}`;
|
|
154
|
+
return `${issue.path.map(String).join(".")}: ${issue.message}${unknown}`;
|
|
143
155
|
})
|
|
144
156
|
.join("; ")}`,
|
|
145
|
-
|
|
157
|
+
issues,
|
|
158
|
+
[{ module: ADAPTERS_SECTION, schemaPath: ADAPTERS_SECTION }],
|
|
146
159
|
);
|
|
147
160
|
}
|
|
148
161
|
return result.data;
|
|
@@ -19,6 +19,7 @@ import express from "express";
|
|
|
19
19
|
import helmet from "helmet";
|
|
20
20
|
import { buildModules } from "./buildModules.mjs";
|
|
21
21
|
import {
|
|
22
|
+
configDefaultsFor,
|
|
22
23
|
readLogging,
|
|
23
24
|
readOwnLayers,
|
|
24
25
|
readSwitches,
|
|
@@ -43,9 +44,7 @@ const { applicationConfPath, envConfPath } = resolveConfigPaths(configDirPath, e
|
|
|
43
44
|
const own = readOwnLayers([envConfPath, applicationConfPath]);
|
|
44
45
|
// Phase one: what the template reads before it knows its modules, only for
|
|
45
46
|
// those choices — its own `adapters` and MFA switch (`mfaMode`, MFA_MODE),
|
|
46
|
-
// and
|
|
47
|
-
// expects of session admission: the configuration's list, with `mfa` added
|
|
48
|
-
// when the MFA switch installs MFA.
|
|
47
|
+
// and whether federation grants are installed.
|
|
49
48
|
const switches: Switches = readSwitches(own);
|
|
50
49
|
|
|
51
50
|
// From the `logging` module's section, so the level holds from the first line;
|
|
@@ -83,13 +82,15 @@ await (async (): Promise<void> => {
|
|
|
83
82
|
// so `CONFIG_ENV=production` is production to the guard too.
|
|
84
83
|
//
|
|
85
84
|
// Phase two: `createApp` parses the configuration as resolved once, with
|
|
86
|
-
// every loaded module's schema (`configPath.mts`)
|
|
85
|
+
// every loaded module's schema (`configPath.mts`), and names each section
|
|
86
|
+
// nothing loaded reads by the configuration's defaults. From here on the
|
|
87
87
|
// template reads the parsed configuration.
|
|
88
|
-
const modules = buildModules(switches, { environment: env
|
|
88
|
+
const modules = buildModules(switches, { environment: env });
|
|
89
89
|
const handle = await createApp({
|
|
90
90
|
modules,
|
|
91
91
|
bootstrapComponents: {
|
|
92
92
|
config: resolveForBoot(own, modules, switches),
|
|
93
|
+
configDefaults: configDefaultsFor(modules),
|
|
93
94
|
pathResolver: import.meta.resolve,
|
|
94
95
|
logger,
|
|
95
96
|
},
|
|
@@ -0,0 +1,117 @@
|
|
|
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 template's refusals before boot, as core's `BootError`: each under the
|
|
19
|
+
* reason boot raises for the same case, at the stage boot raises it in
|
|
20
|
+
* (`validateManifests`), so that an alert keyed on a boot error's reason sees
|
|
21
|
+
* every refusal to start. The template's checks stay its own; only how a
|
|
22
|
+
* refusal is reported is boot's.
|
|
23
|
+
*
|
|
24
|
+
* The details name a module as boot does. A key of the composition root's
|
|
25
|
+
* own — the `adapters` section, the `mfaMode` switch — is named after itself,
|
|
26
|
+
* as boot names core's own section "core": a module with a section under
|
|
27
|
+
* either name is refused (`resolveForBoot`), so the name points at the key.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
BootError,
|
|
32
|
+
type ConfigPathRelocatedDetails,
|
|
33
|
+
type ConfigValidationFailedDetails,
|
|
34
|
+
type EnvironmentVariableRenamedDetails,
|
|
35
|
+
} from "@o3co/auth-provider-core";
|
|
36
|
+
|
|
37
|
+
/** A Zod issue as `config-validation-failed` carries one: its path is the path in the configuration. */
|
|
38
|
+
export type ConfigIssue = ConfigValidationFailedDetails["issues"][number];
|
|
39
|
+
|
|
40
|
+
/** The stage boot raises the configuration's refusals in. */
|
|
41
|
+
const STAGE = "validateManifests";
|
|
42
|
+
|
|
43
|
+
/** An issue at `path` of the configuration that no schema raised: what is wrong there, in `message`. */
|
|
44
|
+
export function customIssue(path: readonly string[], message: string): ConfigIssue {
|
|
45
|
+
return { code: "custom", path: [...path], message } as ConfigIssue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** `issues`, each with `prefix`, the path its schema read at, in front of its own path. */
|
|
49
|
+
export function issuesAt(prefix: readonly string[], issues: readonly ConfigIssue[]): ConfigIssue[] {
|
|
50
|
+
return issues.map((issue) => ({ ...issue, path: [...prefix, ...issue.path] }) as ConfigIssue);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* `config-validation-failed`: `issues` at their paths, refused in the
|
|
55
|
+
* sections `modules` read, each at its `schemaPath`.
|
|
56
|
+
*/
|
|
57
|
+
export function configRefused(
|
|
58
|
+
message: string,
|
|
59
|
+
issues: readonly ConfigIssue[],
|
|
60
|
+
modules: ConfigValidationFailedDetails["modules"],
|
|
61
|
+
): BootError {
|
|
62
|
+
return new BootError({
|
|
63
|
+
message,
|
|
64
|
+
reason: "config-validation-failed",
|
|
65
|
+
stage: STAGE,
|
|
66
|
+
details: { reason: "config-validation-failed", issues: [...issues], modules: [...modules] },
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* `config-validation-failed` for one key, at `path`, of the section `module`
|
|
72
|
+
* reads at its own name: `message` is the refusal and the issue's message.
|
|
73
|
+
*/
|
|
74
|
+
export function keyRefused(message: string, module: string, path: readonly string[]): BootError {
|
|
75
|
+
return configRefused(message, [customIssue(path, message)], [{ module, schemaPath: module }]);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** `config-path-relocated`: each key still set at a path it moved from. */
|
|
79
|
+
export function pathsRelocated(
|
|
80
|
+
message: string,
|
|
81
|
+
relocated: ConfigPathRelocatedDetails["relocated"],
|
|
82
|
+
): BootError {
|
|
83
|
+
return new BootError({
|
|
84
|
+
message,
|
|
85
|
+
reason: "config-path-relocated",
|
|
86
|
+
stage: STAGE,
|
|
87
|
+
details: { reason: "config-path-relocated", relocated: [...relocated] },
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** `environment-variable-renamed`: each old variable name set, with no value. */
|
|
92
|
+
export function variablesRenamed(
|
|
93
|
+
message: string,
|
|
94
|
+
renamed: EnvironmentVariableRenamedDetails["renamed"],
|
|
95
|
+
): BootError {
|
|
96
|
+
return new BootError({
|
|
97
|
+
message,
|
|
98
|
+
reason: "environment-variable-renamed",
|
|
99
|
+
stage: STAGE,
|
|
100
|
+
details: { reason: "environment-variable-renamed", renamed: [...renamed] },
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** `module-section-path-invalid`: `module`'s section is read at `at`, which `problem` says it may not be. */
|
|
105
|
+
export function sectionPathRefused(
|
|
106
|
+
message: string,
|
|
107
|
+
module: string,
|
|
108
|
+
at: string,
|
|
109
|
+
problem: string,
|
|
110
|
+
): BootError {
|
|
111
|
+
return new BootError({
|
|
112
|
+
message,
|
|
113
|
+
reason: "module-section-path-invalid",
|
|
114
|
+
stage: STAGE,
|
|
115
|
+
details: { reason: "module-section-path-invalid", module, at, problem },
|
|
116
|
+
});
|
|
117
|
+
}
|
|
@@ -14,12 +14,9 @@
|
|
|
14
14
|
* limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
import {
|
|
17
|
-
consoleLogger,
|
|
18
17
|
defaultRefreshTokenFamilyRevocationModule,
|
|
19
18
|
defaultRefreshTokenFamilyRotationModule,
|
|
20
|
-
federationsOf,
|
|
21
19
|
jwksModule,
|
|
22
|
-
type Logger,
|
|
23
20
|
type Module,
|
|
24
21
|
memoryAccessTokenDenylistModule,
|
|
25
22
|
memoryConsentStoreModule,
|
|
@@ -27,18 +24,20 @@ import {
|
|
|
27
24
|
memoryFederationGrantStoreModule,
|
|
28
25
|
memoryRateLimiterModule,
|
|
29
26
|
memoryReplaySeenSetModule,
|
|
27
|
+
sessionLifecycleModule,
|
|
30
28
|
} from "@o3co/auth-provider-core";
|
|
31
|
-
import {
|
|
29
|
+
import { googleFederationTypeModule } from "@o3co/auth-provider-federation-google";
|
|
32
30
|
import { federationGrantsModules } from "@o3co/auth-provider-federation-grants";
|
|
33
|
-
import {
|
|
31
|
+
import { oidcFederationTypeModule } from "@o3co/auth-provider-federation-oidc";
|
|
34
32
|
import {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
33
|
+
oauthAuthorizationGrantsModule,
|
|
34
|
+
oauthEndpointsModule,
|
|
35
|
+
oauthSessionGrantModule,
|
|
38
36
|
subjectRevocationServiceModule,
|
|
39
37
|
} from "@o3co/auth-provider-oauth";
|
|
40
38
|
import {
|
|
41
39
|
redisAccessTokenDenylistModule,
|
|
40
|
+
redisAttemptCounterModule,
|
|
42
41
|
redisCodeRepositoryModule,
|
|
43
42
|
redisConsentStoreModule,
|
|
44
43
|
redisFederationGrantIntentStoreModule,
|
|
@@ -49,11 +48,7 @@ import {
|
|
|
49
48
|
redisReplaySeenSetModule,
|
|
50
49
|
redisSessionStoresModule,
|
|
51
50
|
} from "@o3co/auth-provider-redis";
|
|
52
|
-
import {
|
|
53
|
-
extractFederationSection,
|
|
54
|
-
sessionModule,
|
|
55
|
-
sessionStoreModuleFor,
|
|
56
|
-
} from "@o3co/auth-provider-session";
|
|
51
|
+
import { sessionModule, sessionStoreModule } from "@o3co/auth-provider-session";
|
|
57
52
|
import {
|
|
58
53
|
standardDevelopmentMailSenderModule,
|
|
59
54
|
standardSmtpMailSenderModule,
|
|
@@ -62,20 +57,18 @@ import type { Switches } from "./configPath.mjs";
|
|
|
62
57
|
import { mfaModulesFor } from "./mfaSwitch.mjs";
|
|
63
58
|
import {
|
|
64
59
|
auditSinkModuleFor,
|
|
65
|
-
googleFederationConfigModule,
|
|
66
60
|
httpModule,
|
|
67
61
|
inMemoryCodeRepositoryModule,
|
|
68
62
|
inMemoryFederationTokenStoreModule,
|
|
69
63
|
inMemorySessionStoresModule,
|
|
70
64
|
keyStoreModule,
|
|
71
65
|
loggingModule,
|
|
72
|
-
oidcFederationConfigModule,
|
|
73
66
|
repositoriesModuleFor,
|
|
74
67
|
standaloneRedisClientsModule,
|
|
75
68
|
} from "./modules.mjs";
|
|
76
69
|
|
|
77
70
|
/**
|
|
78
|
-
* Overrides for the composition. All but `environment
|
|
71
|
+
* Overrides for the composition. All but `environment` and
|
|
79
72
|
* `mailSenderModules` are test-only: they let the smoke test substitute
|
|
80
73
|
* in-memory implementations of the file-system-backed modules, and production
|
|
81
74
|
* callers should not pass them — the defaults match the standalone scaffold.
|
|
@@ -93,15 +86,8 @@ export interface BuildModulesOverrides {
|
|
|
93
86
|
* MFA module reads it too, for the development sample key's refusal.
|
|
94
87
|
*/
|
|
95
88
|
readonly environment?: string;
|
|
96
|
-
/**
|
|
97
|
-
* Where the composition's own notices go — a deprecated config key, one
|
|
98
|
-
* `config_key_deprecated` line (warn) each. `app.mts` passes the logger it
|
|
99
|
-
* hands every module; omitted, `consoleLogger`.
|
|
100
|
-
*/
|
|
101
|
-
readonly logger?: Logger;
|
|
102
89
|
readonly keyStoreModule?: Module;
|
|
103
90
|
readonly repositoriesModule?: Module;
|
|
104
|
-
readonly storesModule?: Module;
|
|
105
91
|
/**
|
|
106
92
|
* Replaces the refresh-token family store modules (default
|
|
107
93
|
* `[redisRefreshTokenFamilyStoreModule]`, on the shared ioredis socket).
|
|
@@ -127,41 +113,16 @@ export interface BuildModulesOverrides {
|
|
|
127
113
|
readonly mailSenderModules?: readonly Module[];
|
|
128
114
|
}
|
|
129
115
|
|
|
130
|
-
/**
|
|
131
|
-
* The access-token lifetime core's `reference.conf` ships on the deprecated
|
|
132
|
-
* `oauth.accessToken.expiresIn`. Not a default — nothing mints with it — only
|
|
133
|
-
* what an unmodified deployment carries there, so the deprecation line can tell
|
|
134
|
-
* an operator's override from the shipped value. Pinned against the real file
|
|
135
|
-
* by `access-token-lifetime-alias.test.mts`.
|
|
136
|
-
*/
|
|
137
|
-
const SHIPPED_ACCESS_TOKEN_EXPIRES_IN = 3600;
|
|
138
|
-
|
|
139
|
-
/** Whether `value` sets anything: a value, or a section with one somewhere under it. */
|
|
140
|
-
function setsAnything(value: unknown): boolean {
|
|
141
|
-
if (value === undefined) return false;
|
|
142
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) return true;
|
|
143
|
-
return Object.values(value).some(setsAnything);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
116
|
/**
|
|
147
117
|
* Compose the standalone module list from phase one's `config`: the
|
|
148
118
|
* composition root's `adapters`, which choose the adapter behind each slot,
|
|
149
|
-
* and
|
|
150
|
-
* test can check the manifest (that disabling
|
|
151
|
-
*
|
|
152
|
-
* one module per store slot, federation
|
|
153
|
-
* are in the template README, "Module Composition Order".
|
|
119
|
+
* its MFA switch, and whether federation grants are installed. Kept out of
|
|
120
|
+
* `app.mts` so a smoke test can check the manifest (that disabling federation
|
|
121
|
+
* grants removes their modules, say) without an HTTP server. The rules for
|
|
122
|
+
* editing the list (order, one module per store slot, the federation types it
|
|
123
|
+
* bundles) are in the template README, "Module Composition Order".
|
|
154
124
|
*/
|
|
155
125
|
export function buildModules(config: Switches, overrides: BuildModulesOverrides = {}): Module[] {
|
|
156
|
-
// An entry's `type` names the implementation, so the two gates never both
|
|
157
|
-
// select one entry: `core.federations.google` is the built-in Google
|
|
158
|
-
// federation only when its type is `google` (that name's default); with
|
|
159
|
-
// `type = "oidc"` it is a generic OIDC instance, and composing both would
|
|
160
|
-
// contribute the same federation and redirect-policy keys twice.
|
|
161
|
-
const federations = federationsOf(config);
|
|
162
|
-
const googleEnabled = extractFederationSection(federations, "google")?.type === "google";
|
|
163
|
-
const oidcFederations = oidcFederationNames(federations);
|
|
164
|
-
const logger = overrides.logger ?? consoleLogger;
|
|
165
126
|
const adapters = config.adapters;
|
|
166
127
|
|
|
167
128
|
// Federation grants: a user's standing consent that a client may obtain
|
|
@@ -174,11 +135,10 @@ export function buildModules(config: Switches, overrides: BuildModulesOverrides
|
|
|
174
135
|
// subject holds" include the grants. Enabling it also states the deployment
|
|
175
136
|
// has a consent page, a callback per connection and a user repository
|
|
176
137
|
// covering each connection's registration; the routes module refuses at
|
|
177
|
-
// boot what is missing, naming it.
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
const federationGrantsEnabled =
|
|
181
|
-
config["federation-grants"]?.enabled === true || setsAnything(config.federationGrants);
|
|
138
|
+
// boot what is missing, naming it. Phase one decides it (`readSwitches`): a
|
|
139
|
+
// value boot would refuse installs the feature, so that boot refuses it
|
|
140
|
+
// rather than reading the feature as off.
|
|
141
|
+
const federationGrantsEnabled = config["federation-grants"]?.enabled === true;
|
|
182
142
|
|
|
183
143
|
// MFA: the template's own switch, `mfaMode` (MFA_MODE). `off` installs
|
|
184
144
|
// nothing of MFA; `optional` and `required` install what `mfaModulesFor`
|
|
@@ -187,28 +147,6 @@ export function buildModules(config: Switches, overrides: BuildModulesOverrides
|
|
|
187
147
|
// switch (`resolveForBoot`).
|
|
188
148
|
const mfaInstalled = config.mfaMode !== "off";
|
|
189
149
|
|
|
190
|
-
// The deprecated alias `oauth.accessToken.expiresIn`
|
|
191
|
-
// (OAUTH_ACCESS_TOKEN_EXPIRES_IN) is still read as the default while
|
|
192
|
-
// `defaultExpiresIn` is unset; `resolveAccessTokenLifetime` does that, and
|
|
193
|
-
// this only warns. Core's `reference.conf` ships the lifetime on the
|
|
194
|
-
// deprecated key, so only an override of it has something to move. See
|
|
195
|
-
// CHANGELOG for the removal version.
|
|
196
|
-
const accessToken = config.oauth.accessToken;
|
|
197
|
-
if (
|
|
198
|
-
accessToken.defaultExpiresIn === undefined &&
|
|
199
|
-
accessToken.expiresIn !== SHIPPED_ACCESS_TOKEN_EXPIRES_IN
|
|
200
|
-
) {
|
|
201
|
-
logger.warn(
|
|
202
|
-
{
|
|
203
|
-
key: "oauth.accessToken.expiresIn",
|
|
204
|
-
env: "OAUTH_ACCESS_TOKEN_EXPIRES_IN",
|
|
205
|
-
replacement: "oauth.accessToken.defaultExpiresIn",
|
|
206
|
-
replacementEnv: "OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN",
|
|
207
|
-
},
|
|
208
|
-
"config_key_deprecated",
|
|
209
|
-
);
|
|
210
|
-
}
|
|
211
|
-
|
|
212
150
|
const refreshTokenFamilyModules: readonly Module[] = overrides.refreshTokenFamilyModules ?? [
|
|
213
151
|
redisRefreshTokenFamilyStoreModule,
|
|
214
152
|
];
|
|
@@ -225,6 +163,7 @@ export function buildModules(config: Switches, overrides: BuildModulesOverrides
|
|
|
225
163
|
const usingRedisAnywhere =
|
|
226
164
|
refreshTokenFamilyUsesRedis ||
|
|
227
165
|
adapters.rateLimiter === "redis" ||
|
|
166
|
+
adapters.attemptCounter === "redis" ||
|
|
228
167
|
adapters.userSessionStores === "redis" ||
|
|
229
168
|
adapters.codeRepository === "redis" ||
|
|
230
169
|
adapters.accessTokenDenylist === "redis" ||
|
|
@@ -245,13 +184,16 @@ export function buildModules(config: Switches, overrides: BuildModulesOverrides
|
|
|
245
184
|
const sessionStoresModules: Module[] =
|
|
246
185
|
adapters.userSessionStores === "redis"
|
|
247
186
|
? [redisSessionStoresModule]
|
|
248
|
-
:
|
|
249
|
-
? [overrides.storesModule]
|
|
250
|
-
: [inMemorySessionStoresModule];
|
|
187
|
+
: [inMemorySessionStoresModule];
|
|
251
188
|
|
|
252
189
|
const rateLimiterModules: Module[] =
|
|
253
190
|
adapters.rateLimiter === "redis" ? [redisRateLimiterModule] : [memoryRateLimiterModule];
|
|
254
191
|
|
|
192
|
+
// `memory` installs no counter: the login's attempt guard counts per
|
|
193
|
+
// process, which `core.deployment.mode = "multi"` refuses.
|
|
194
|
+
const attemptCounterModules: Module[] =
|
|
195
|
+
adapters.attemptCounter === "redis" ? [redisAttemptCounterModule] : [];
|
|
196
|
+
|
|
255
197
|
// Mutually exclusive: both modules provide the `codeRepository` slot, and
|
|
256
198
|
// including both would be a boot-time slot collision.
|
|
257
199
|
const codeRepositoryModules: Module[] =
|
|
@@ -259,7 +201,7 @@ export function buildModules(config: Switches, overrides: BuildModulesOverrides
|
|
|
259
201
|
? [redisCodeRepositoryModule]
|
|
260
202
|
: [inMemoryCodeRepositoryModule];
|
|
261
203
|
|
|
262
|
-
// The RFC 7009 access-token denylist has no "none": `
|
|
204
|
+
// The RFC 7009 access-token denylist has no "none": `oauthEndpointsModule` mounts
|
|
263
205
|
// `/oauth/revoke` and reads the `accessTokenDenylist` slot, and core's boot
|
|
264
206
|
// validator refuses a composition that reads the slot with nothing filling
|
|
265
207
|
// it, since the endpoint would answer 200 while the token kept working. The
|
|
@@ -349,25 +291,26 @@ export function buildModules(config: Switches, overrides: BuildModulesOverrides
|
|
|
349
291
|
return [
|
|
350
292
|
// MUST stay first: it declares no `before`/`after`, so this position is
|
|
351
293
|
// what mounts express-session ahead of every session-consuming module.
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
|
|
355
|
-
// Under `/oauth` beside `
|
|
294
|
+
// It declares `session-store.storage.type = "memory"` replica-unsafe from
|
|
295
|
+
// its own section, so `core.deployment.mode = "multi"` refuses it.
|
|
296
|
+
sessionStoreModule,
|
|
297
|
+
// Under `/oauth` beside `oauthEndpointsModule`, each parsing its own requests, so
|
|
356
298
|
// their relative order does not matter; the browser half orders itself
|
|
357
299
|
// after the session middleware by its own `after`.
|
|
358
300
|
...(federationGrantsEnabled ? federationGrantsModules : []),
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
301
|
+
oauthEndpointsModule,
|
|
302
|
+
oauthSessionGrantModule,
|
|
303
|
+
oauthAuthorizationGrantsModule,
|
|
362
304
|
// Always wired: a provider that signs tokens must publish its
|
|
363
|
-
// verification keys regardless of OIDC issuer config, and `
|
|
305
|
+
// verification keys regardless of OIDC issuer config, and `oauthEndpointsModule`'s
|
|
364
306
|
// discovery `jwks_uri` (advertised when an issuer is set) must resolve.
|
|
365
307
|
jwksModule,
|
|
366
308
|
sessionModule,
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
309
|
+
// The federation types the template bundles, always: each contributes
|
|
310
|
+
// its type, and boot hands it every enabled `core.federations` entry
|
|
311
|
+
// that names it, under the entry's name. Nothing here reads an entry.
|
|
312
|
+
googleFederationTypeModule(),
|
|
313
|
+
oidcFederationTypeModule(),
|
|
371
314
|
// The template's own settings: `logging {}` and `http {}`.
|
|
372
315
|
loggingModule,
|
|
373
316
|
httpModule,
|
|
@@ -386,7 +329,14 @@ export function buildModules(config: Switches, overrides: BuildModulesOverrides
|
|
|
386
329
|
...federationGrantStoreModules,
|
|
387
330
|
...federationGrantIntentStoreModules,
|
|
388
331
|
...sessionStoresModules,
|
|
332
|
+
// Core's session lifecycle, over the session stores' lifecycle store:
|
|
333
|
+
// a login opens its session's record, a code exchange joins its relying
|
|
334
|
+
// party and family to it, and both logout endpoints close it, which
|
|
335
|
+
// revokes the families and tells the relying parties. Its sweep resumes
|
|
336
|
+
// a close left pending.
|
|
337
|
+
sessionLifecycleModule,
|
|
389
338
|
...rateLimiterModules,
|
|
339
|
+
...attemptCounterModules,
|
|
390
340
|
...codeRepositoryModules,
|
|
391
341
|
...accessTokenDenylistModules,
|
|
392
342
|
...replaySeenSetModules,
|