@o3co/create-auth-provider 0.16.0 → 0.17.0-rc.1
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 +66 -21
- package/README.md +76 -31
- package/dist/index.d.mts +33 -10
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +118 -59
- package/dist/internal/template-filter.d.mts +7 -10
- package/dist/internal/template-filter.d.mts.map +1 -1
- package/dist/internal/template-filter.mjs +7 -10
- package/package.json +6 -4
- package/templates/standalone/.dockerignore +15 -2
- package/templates/standalone/.env.example +59 -26
- package/templates/standalone/Dockerfile +6 -2
- package/templates/standalone/README.ja.md +293 -162
- package/templates/standalone/README.md +533 -199
- package/templates/standalone/config/application.conf +196 -341
- package/templates/standalone/config/clients.yaml +2 -0
- package/templates/standalone/config/clients.yaml.example +4 -0
- package/templates/standalone/config/development.conf +13 -4
- package/templates/standalone/config/mailpit.conf +8 -0
- package/templates/standalone/config/production.conf +5 -6
- package/templates/standalone/config/reference.conf +399 -0
- package/templates/standalone/docker-compose.mailpit.yml +36 -0
- package/templates/standalone/docker-compose.production.yml +58 -30
- package/templates/standalone/docker-compose.yml +13 -3
- package/templates/standalone/gitignore +1 -0
- package/templates/standalone/package.json +9 -6
- package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +19 -20
- package/templates/standalone/src/__tests__/adapters.test.mts +358 -0
- package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +216 -141
- package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +48 -33
- package/templates/standalone/src/__tests__/all-modules-composition.test.mts +77 -58
- package/templates/standalone/src/__tests__/audit-sink.test.mts +78 -76
- package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
- package/templates/standalone/src/__tests__/core-federations.test.mts +226 -0
- package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
- package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +9 -14
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +656 -269
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +425 -84
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +91 -70
- package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +7 -5
- package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +22 -19
- package/templates/standalone/src/__tests__/google-session.fixture.mts +53 -42
- package/templates/standalone/src/__tests__/host-routes.test.mts +4 -11
- package/templates/standalone/src/__tests__/library-references.fixture.mts +183 -0
- package/templates/standalone/src/__tests__/listen.test.mts +6 -13
- package/templates/standalone/src/__tests__/logger-projection.test.mts +1 -1
- package/templates/standalone/src/__tests__/mail-sender.test.mts +134 -0
- package/templates/standalone/src/__tests__/mailpit-compose.test.mts +328 -0
- package/templates/standalone/src/__tests__/metrics.test.mts +4 -9
- package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
- package/templates/standalone/src/__tests__/mfa-switch.test.mts +780 -0
- package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +52 -46
- package/templates/standalone/src/__tests__/own-modules.test.mts +1457 -0
- package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +7 -11
- package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +1 -5
- package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +112 -81
- package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
- package/templates/standalone/src/__tests__/replica-safety.test.mts +153 -142
- package/templates/standalone/src/__tests__/router-error-answers.test.mts +4 -7
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +275 -95
- package/templates/standalone/src/__tests__/sections.test.mts +480 -0
- package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +67 -55
- package/templates/standalone/src/__tests__/session-requirements.test.mts +299 -0
- package/templates/standalone/src/__tests__/shutdown.test.mts +163 -96
- package/templates/standalone/src/__tests__/smoke.test.mts +179 -252
- package/templates/standalone/src/__tests__/store-credential.test.mts +91 -50
- package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +108 -95
- package/templates/standalone/src/__tests__/two-phase-config.test.mts +438 -0
- package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +390 -0
- package/templates/standalone/src/adapters.mts +149 -0
- package/templates/standalone/src/app.mts +64 -79
- package/templates/standalone/src/buildModules.mts +212 -251
- package/templates/standalone/src/configPath.mts +422 -29
- package/templates/standalone/src/listen.mts +12 -18
- package/templates/standalone/src/logger.mts +31 -60
- package/templates/standalone/src/metrics.mts +24 -47
- package/templates/standalone/src/mfaSwitch.mts +262 -0
- package/templates/standalone/src/modules.mts +458 -476
- package/templates/standalone/src/rootRenames.mts +79 -0
- package/templates/standalone/src/routes.mts +8 -14
- package/templates/standalone/src/sections.mts +366 -0
- package/templates/standalone/src/shutdown.mts +45 -123
- package/templates/standalone/tests/index.test.js +2 -2
- package/templates/standalone/tests/package-lock.json +1615 -2223
- package/templates/standalone/tests/package.json +2 -2
- package/templates/standalone/tests/vitest.config.mjs +26 -0
- package/templates/standalone/vitest.config.mts +8 -7
- package/templates/standalone/vitest.supertest-loopback.mts +97 -69
- package/templates/versions.json +18 -15
|
@@ -0,0 +1,79 @@
|
|
|
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 variables renamed with a path of the composition root's own layers,
|
|
19
|
+
* which no module declares: the adapter selections and the federations the
|
|
20
|
+
* template ships. Phase one refuses an old name set alone, or beside its new
|
|
21
|
+
* name at a different value, before any module is chosen; the two at one
|
|
22
|
+
* value are accepted.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** A variable renamed with a path of the composition root's own: its old name, its new one, and the path the new one binds. */
|
|
26
|
+
export interface RootRename {
|
|
27
|
+
readonly from: string;
|
|
28
|
+
readonly to: string;
|
|
29
|
+
readonly path: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Refuses, with a `RangeError`, an old name in `env` set alone, or beside its
|
|
34
|
+
* new name at a different value; the two at one value are accepted. Every
|
|
35
|
+
* such rename is named at once; no value is quoted.
|
|
36
|
+
*/
|
|
37
|
+
export function refuseRenamedVariables(
|
|
38
|
+
env: Readonly<Record<string, string>>,
|
|
39
|
+
renames: readonly RootRename[],
|
|
40
|
+
): void {
|
|
41
|
+
const refused = renames.flatMap(({ from, to, path }) => {
|
|
42
|
+
const old = env[from];
|
|
43
|
+
if (old === undefined || env[to] === old) return [];
|
|
44
|
+
const renamed = `${from} was renamed ${to}, the variable ${path} is bound to; see CHANGELOG.`;
|
|
45
|
+
return env[to] === undefined
|
|
46
|
+
? [`${renamed} Set ${to} instead and unset ${from}.`]
|
|
47
|
+
: [
|
|
48
|
+
`${renamed} ${to} is set to a different value: keep the one you mean in ${to} and unset ${from}.`,
|
|
49
|
+
];
|
|
50
|
+
});
|
|
51
|
+
if (refused.length > 0) {
|
|
52
|
+
throw new RangeError(
|
|
53
|
+
`The environment sets ${refused.length} variable(s) that were renamed: ${refused.join(" ")}`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The variables `config/application.conf` binds for the two federations the
|
|
60
|
+
* template ships, each renamed after its path under `core.federations`.
|
|
61
|
+
*/
|
|
62
|
+
export const SHIPPED_FEDERATION_RENAMES: readonly RootRename[] = (
|
|
63
|
+
[
|
|
64
|
+
["GOOGLE", "google", ["ENABLED", "enabled"]],
|
|
65
|
+
["GOOGLE", "google", ["CLIENT_ID", "clientId"]],
|
|
66
|
+
["GOOGLE", "google", ["CLIENT_SECRET", "clientSecret"]],
|
|
67
|
+
["GOOGLE", "google", ["CALLBACK_URL", "callbackURL"]],
|
|
68
|
+
["GOOGLE", "google", ["ACCESS_TYPE", "accessType"]],
|
|
69
|
+
["OIDC", "oidc", ["ENABLED", "enabled"]],
|
|
70
|
+
["OIDC", "oidc", ["ISSUER", "issuer"]],
|
|
71
|
+
["OIDC", "oidc", ["CLIENT_ID", "clientId"]],
|
|
72
|
+
["OIDC", "oidc", ["CLIENT_SECRET", "clientSecret"]],
|
|
73
|
+
["OIDC", "oidc", ["CALLBACK_URL", "callbackURL"]],
|
|
74
|
+
] as const
|
|
75
|
+
).map(([federation, name, [key, path]]) => ({
|
|
76
|
+
from: `FEDERATIONS_${federation}_${key}`,
|
|
77
|
+
to: `CORE_FEDERATIONS_${federation}_${key}`,
|
|
78
|
+
path: `core.federations.${name}.${path}`,
|
|
79
|
+
}));
|
|
@@ -16,22 +16,16 @@
|
|
|
16
16
|
|
|
17
17
|
/*
|
|
18
18
|
* The host's routes, in the order `app.mts` mounts them: liveness, readiness
|
|
19
|
-
* and the metrics scrape ahead of the composed auth router
|
|
20
|
-
* answering while the auth pipeline is degraded
|
|
21
|
-
* needs them
|
|
19
|
+
* and the metrics scrape ahead of the composed auth router, so they keep
|
|
20
|
+
* answering while the auth pipeline is degraded (which is when an operator
|
|
21
|
+
* needs them); then that router; then core's terminal error handler, last,
|
|
22
22
|
* because Express hands an error only to handlers mounted after the route
|
|
23
23
|
* that raised it. A function rather than inline in `app.mts`, so the order
|
|
24
24
|
* and the handler can be tested as the process mounts them.
|
|
25
25
|
*
|
|
26
|
-
* The handler is core's `terminalErrorHandler`, the one that already ends
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* `unexpected_error` in the RFC 6749 envelope with `Cache-Control:
|
|
30
|
-
* no-store`, logged once as `unhandled_request_error` with the path through
|
|
31
|
-
* `auditErrorText` and the error's `loggableError` projection; a refusal an
|
|
32
|
-
* `http-errors` error marks as the client's keeps its 4xx; a response whose
|
|
33
|
-
* headers already went out is closed rather than handed to Express's final
|
|
34
|
-
* handler, which would print the stack.
|
|
26
|
+
* The handler is core's `terminalErrorHandler`, the one that already ends the
|
|
27
|
+
* composed router, so an error a host route lets through is answered as every
|
|
28
|
+
* other route's is (see core's `middleware/terminalError.mts`).
|
|
35
29
|
*/
|
|
36
30
|
|
|
37
31
|
import {
|
|
@@ -49,7 +43,7 @@ export interface MountRoutesOptions {
|
|
|
49
43
|
readonly router: Router;
|
|
50
44
|
/** The readiness probes the builders registered (`handle.readinessProbes`). */
|
|
51
45
|
readonly probes: readonly ReadinessProbe[];
|
|
52
|
-
/** Per-probe deadline, in milliseconds: `
|
|
46
|
+
/** Per-probe deadline, in milliseconds: the `http` module's `readinessTimeoutMs`. */
|
|
53
47
|
readonly readinessTimeoutMs: number;
|
|
54
48
|
readonly metrics: Metrics;
|
|
55
49
|
readonly logger: Logger;
|
|
@@ -88,7 +82,7 @@ export function mountRoutes(app: Express, options: MountRoutesOptions): void {
|
|
|
88
82
|
|
|
89
83
|
// The composed auth router, which ends in core's terminal handler.
|
|
90
84
|
app.use(options.router);
|
|
91
|
-
// Core's terminal handler again, LAST
|
|
85
|
+
// Core's terminal handler again, LAST, for the host routes
|
|
92
86
|
// above: Express routes an error only to handlers registered after the
|
|
93
87
|
// route that threw it. See the file header.
|
|
94
88
|
app.use(terminalErrorHandler(options.logger));
|
|
@@ -0,0 +1,366 @@
|
|
|
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 schemas of the template's own sections: its modules' — `logging`,
|
|
19
|
+
* `http` (with its CORS list), `key-store`, `redis-clients`, `repositories`,
|
|
20
|
+
* the in-process code repository's and `audit-sink` — and the composition
|
|
21
|
+
* root's own `adapters` and `mfaMode`. Each is strict, a key it does not
|
|
22
|
+
* declare refused;
|
|
23
|
+
* each reads the strings an environment variable carries; none holds a
|
|
24
|
+
* default, which lives in `config/reference.conf`. The rules a value is held
|
|
25
|
+
* to are core's shared vocabulary (trusted-proxy entries, serialized origins,
|
|
26
|
+
* key ids), applied here, never restated.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
checkSerializedOrigin,
|
|
31
|
+
checkTrustedProxyEntry,
|
|
32
|
+
describeSerializedOriginRejection,
|
|
33
|
+
describeTrustedProxyEntryRejection,
|
|
34
|
+
isWellFormedKid,
|
|
35
|
+
MAX_KID_LENGTH,
|
|
36
|
+
MAX_TRUST_PROXY_HOPS,
|
|
37
|
+
normalizeAllowedOrigins,
|
|
38
|
+
wholeNumberFromEnv,
|
|
39
|
+
wholeNumberInRangeFromEnv,
|
|
40
|
+
} from "@o3co/auth-provider-core";
|
|
41
|
+
import { mfaConfigSchema } from "@o3co/auth-provider-mfa";
|
|
42
|
+
import { z } from "zod";
|
|
43
|
+
|
|
44
|
+
/** `logging`: the level the process logs at. `silent` is a threshold, not a level. */
|
|
45
|
+
export const loggingSectionSchema = z
|
|
46
|
+
.object({ level: z.enum(["trace", "debug", "info", "warn", "error", "fatal", "silent"]) })
|
|
47
|
+
.strict();
|
|
48
|
+
|
|
49
|
+
/** What the logger is built from: the `logging` module's section. */
|
|
50
|
+
export type LoggingSettings = z.output<typeof loggingSectionSchema>;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A decimal, optionally signed or fractional: the variable's shapes meant as a
|
|
54
|
+
* hop count, so `-1` and `1.5` fail as hop counts instead of reading as
|
|
55
|
+
* one-entry address lists.
|
|
56
|
+
*/
|
|
57
|
+
const NUMERIC_STRING = /^-?[0-9]+(\.[0-9]+)?$/;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A `trust proxy` value in one of Express's shapes: the variable's string read
|
|
61
|
+
* as `true` / `false` / a hop count / a comma-separated list, a list's entries
|
|
62
|
+
* trimmed. `1` and `0` are hop counts here, not booleans. An exported-but-empty
|
|
63
|
+
* variable is `false`: trust nothing.
|
|
64
|
+
*/
|
|
65
|
+
const normalizeTrustProxy = (raw: unknown): unknown => {
|
|
66
|
+
if (Array.isArray(raw)) {
|
|
67
|
+
return raw.map((entry) => (typeof entry === "string" ? entry.trim() : entry));
|
|
68
|
+
}
|
|
69
|
+
if (typeof raw !== "string") return raw;
|
|
70
|
+
const value = raw.trim();
|
|
71
|
+
if (value === "") return false;
|
|
72
|
+
const lower = value.toLowerCase();
|
|
73
|
+
if (lower === "true") return true;
|
|
74
|
+
if (lower === "false") return false;
|
|
75
|
+
if (NUMERIC_STRING.test(value)) return Number(value);
|
|
76
|
+
return value
|
|
77
|
+
.split(",")
|
|
78
|
+
.map((entry) => entry.trim())
|
|
79
|
+
.filter((entry) => entry !== "");
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* `http.trustProxy`, handed to Express's `trust proxy`: `false` (trust
|
|
84
|
+
* nothing), `true` (trust every hop), a hop count up to
|
|
85
|
+
* `MAX_TRUST_PROXY_HOPS`, or a non-empty list of IP literals, CIDR ranges and
|
|
86
|
+
* named ranges, each held to core's trusted-proxy vocabulary so a typo fails
|
|
87
|
+
* at boot, naming its index, instead of never matching.
|
|
88
|
+
*/
|
|
89
|
+
const trustProxySchema = z
|
|
90
|
+
.preprocess(
|
|
91
|
+
normalizeTrustProxy,
|
|
92
|
+
z.union([
|
|
93
|
+
z.boolean(),
|
|
94
|
+
z.number().int().min(0).max(MAX_TRUST_PROXY_HOPS),
|
|
95
|
+
z
|
|
96
|
+
.array(z.string())
|
|
97
|
+
.min(
|
|
98
|
+
1,
|
|
99
|
+
"must list at least one address, CIDR range, or named range — use `false` to trust no forwarding hop",
|
|
100
|
+
),
|
|
101
|
+
]),
|
|
102
|
+
)
|
|
103
|
+
.superRefine((value, ctx) => {
|
|
104
|
+
if (!Array.isArray(value)) return;
|
|
105
|
+
value.forEach((entry, index) => {
|
|
106
|
+
const rejection = checkTrustedProxyEntry(entry);
|
|
107
|
+
if (rejection !== null) {
|
|
108
|
+
ctx.addIssue({
|
|
109
|
+
code: "custom",
|
|
110
|
+
message: `http.trustProxy[${index}] ${describeTrustedProxyEntryRejection(rejection)}`,
|
|
111
|
+
path: [index],
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* `http.cors.allowedOrigins`: the browser origins allowed to read the token,
|
|
119
|
+
* userinfo, revocation and discovery/JWKS responses; empty is CORS off. A list,
|
|
120
|
+
* or the one comma-separated string `HTTP_CORS_ALLOWED_ORIGINS` carries
|
|
121
|
+
* (`normalizeAllowedOrigins`, core's reading of an origin list);
|
|
122
|
+
* `null` reads as no origins, any other shape is refused. Each entry must be a
|
|
123
|
+
* serialized origin, matched by exact string equality: one that could never
|
|
124
|
+
* match is refused, naming its index.
|
|
125
|
+
*/
|
|
126
|
+
const allowedOriginsSchema = z
|
|
127
|
+
.preprocess((raw, ctx) => {
|
|
128
|
+
if (raw === undefined) return raw;
|
|
129
|
+
if (raw !== null && typeof raw !== "string" && !Array.isArray(raw)) {
|
|
130
|
+
ctx.addIssue({
|
|
131
|
+
code: "custom",
|
|
132
|
+
message: `http.cors.allowedOrigins must be a list of origins, or one comma-separated string of them (HTTP_CORS_ALLOWED_ORIGINS); got ${typeof raw === "object" ? "an object" : `a ${typeof raw}`}`,
|
|
133
|
+
});
|
|
134
|
+
return raw;
|
|
135
|
+
}
|
|
136
|
+
return normalizeAllowedOrigins(raw);
|
|
137
|
+
}, z.array(z.string()))
|
|
138
|
+
.superRefine((value, ctx) => {
|
|
139
|
+
value.forEach((entry, index) => {
|
|
140
|
+
const rejection = checkSerializedOrigin(entry);
|
|
141
|
+
if (rejection !== null) {
|
|
142
|
+
ctx.addIssue({
|
|
143
|
+
code: "custom",
|
|
144
|
+
message: `http.cors.allowedOrigins[${index}] ${describeSerializedOriginRejection(rejection)}`,
|
|
145
|
+
path: [index],
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const PORT_RULE =
|
|
152
|
+
"http.port must be a port number from 0 to 65535 in decimal digits, 0 letting the OS choose a free port (HTTP_PORT); an exported-but-empty HTTP_PORT is not one";
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* `http.port`, read strictly: an empty or non-decimal value fails boot instead
|
|
156
|
+
* of reading as 0, which would listen on a port the OS picks at random.
|
|
157
|
+
*/
|
|
158
|
+
const portSchema = wholeNumberFromEnv(
|
|
159
|
+
z
|
|
160
|
+
.number({ error: PORT_RULE })
|
|
161
|
+
.int({ error: PORT_RULE })
|
|
162
|
+
.min(0, { error: PORT_RULE })
|
|
163
|
+
.max(65_535, { error: PORT_RULE }),
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* `http`: the listener's port, the trusted forwarding hops, the readiness
|
|
168
|
+
* deadline, and the CORS list. The deadline is bounded both ways, because
|
|
169
|
+
* `setTimeout` turns 0 and anything above 2^31-1 into 1 ms: every probe would
|
|
170
|
+
* time out.
|
|
171
|
+
*/
|
|
172
|
+
export const httpSectionSchema = z
|
|
173
|
+
.object({
|
|
174
|
+
port: portSchema,
|
|
175
|
+
trustProxy: trustProxySchema,
|
|
176
|
+
readinessTimeoutMs: wholeNumberInRangeFromEnv(1, 2_147_483_647),
|
|
177
|
+
cors: z.object({ allowedOrigins: allowedOriginsSchema }).strict(),
|
|
178
|
+
})
|
|
179
|
+
.strict();
|
|
180
|
+
|
|
181
|
+
/** A key id `verifyJwt` accepts: one it would refuse would fail every token signed under it. */
|
|
182
|
+
const kidSchema = z.string().refine(isWellFormedKid, {
|
|
183
|
+
message: `must be a key id: a string of 1 to ${MAX_KID_LENGTH} characters with no control character`,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
/** HS256: a shared secret, rotated through `previousSecrets`. */
|
|
187
|
+
const hs256LocalSchema = z
|
|
188
|
+
.object({
|
|
189
|
+
algorithm: z.literal("HS256"),
|
|
190
|
+
kid: kidSchema,
|
|
191
|
+
secret: z.string().optional(),
|
|
192
|
+
previousSecrets: z
|
|
193
|
+
.array(z.object({ kid: kidSchema, secret: z.string(), expiresAt: z.string() }).strict())
|
|
194
|
+
.optional(),
|
|
195
|
+
})
|
|
196
|
+
.strict();
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* RS256, ES256, EdDSA: a key pair, rotated through `previousKeys`. `secret`,
|
|
200
|
+
* which the reference binds for every algorithm, and `previousSecrets` are
|
|
201
|
+
* accepted and left to the key store, which names what an asymmetric key
|
|
202
|
+
* cannot use them for.
|
|
203
|
+
*/
|
|
204
|
+
const asymmetricLocalSchema = z
|
|
205
|
+
.object({
|
|
206
|
+
algorithm: z.enum(["RS256", "ES256", "EdDSA"]),
|
|
207
|
+
kid: kidSchema,
|
|
208
|
+
privateKey: z.string().optional(),
|
|
209
|
+
privateKeyPath: z.string().optional(),
|
|
210
|
+
publicKey: z.string().optional(),
|
|
211
|
+
publicKeyPath: z.string().optional(),
|
|
212
|
+
previousKeys: z
|
|
213
|
+
.array(
|
|
214
|
+
z
|
|
215
|
+
.object({
|
|
216
|
+
kid: kidSchema,
|
|
217
|
+
publicKey: z.string().optional(),
|
|
218
|
+
publicKeyPath: z.string().optional(),
|
|
219
|
+
expiresAt: z.string(),
|
|
220
|
+
})
|
|
221
|
+
.strict(),
|
|
222
|
+
)
|
|
223
|
+
.optional(),
|
|
224
|
+
secret: z.string().optional(),
|
|
225
|
+
previousSecrets: z.unknown().optional(),
|
|
226
|
+
})
|
|
227
|
+
.strict();
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* `key-store`: which key store builds the signing key (`provider`, `local` the
|
|
231
|
+
* one this template registers) and the local store's settings, shape only:
|
|
232
|
+
* whether the key material is there is the key store's check, when it is
|
|
233
|
+
* built.
|
|
234
|
+
*/
|
|
235
|
+
export const keyStoreSectionSchema = z
|
|
236
|
+
.object({
|
|
237
|
+
provider: z.string(),
|
|
238
|
+
local: z.discriminatedUnion("algorithm", [hs256LocalSchema, asymmetricLocalSchema]).optional(),
|
|
239
|
+
})
|
|
240
|
+
.strict();
|
|
241
|
+
|
|
242
|
+
/** `redis-clients`: the one Redis connection every Redis-backed store shares. */
|
|
243
|
+
export const redisClientsSectionSchema = z
|
|
244
|
+
.object({ url: z.string(), password: z.string().optional() })
|
|
245
|
+
.strict();
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* `adapters`: which adapter fills each slot, the composition root's own
|
|
249
|
+
* choice. `userSessionStores` switches the four user-session stores and the
|
|
250
|
+
* subject-level revocation pair together; the repositories take core's
|
|
251
|
+
* `static`, an alias of `yaml` with a block of its own; `mfaFactorStore` and
|
|
252
|
+
* `mfaTransactionStore` are read by a composition that installs MFA;
|
|
253
|
+
* `auditSink` names a sink the audit-sink module's factory registers.
|
|
254
|
+
*/
|
|
255
|
+
export const adaptersSchema = z
|
|
256
|
+
.object({
|
|
257
|
+
rateLimiter: z.enum(["memory", "redis"]),
|
|
258
|
+
userSessionStores: z.enum(["memory", "redis"]),
|
|
259
|
+
accessTokenDenylist: z.enum(["memory", "redis"]),
|
|
260
|
+
replaySeenSet: z.enum(["memory", "redis"]),
|
|
261
|
+
consentStore: z.enum(["none", "memory", "redis"]),
|
|
262
|
+
federationTokenStore: z.enum(["memory", "redis"]),
|
|
263
|
+
federationGrantStore: z.enum(["none", "memory", "redis"]),
|
|
264
|
+
federationGrantIntentStore: z.enum(["none", "memory", "redis"]),
|
|
265
|
+
mfaFactorStore: z.enum(["memory", "redis", "store"]),
|
|
266
|
+
mfaTransactionStore: z.enum(["memory", "redis"]),
|
|
267
|
+
codeRepository: z.enum(["memory", "redis"]),
|
|
268
|
+
clientRepository: z.enum(["yaml", "static"]),
|
|
269
|
+
userRepository: z.enum(["yaml", "static", "http"]),
|
|
270
|
+
auditSink: z.string().min(1),
|
|
271
|
+
})
|
|
272
|
+
.strict();
|
|
273
|
+
|
|
274
|
+
/** The composition root's adapter selections, as `adaptersSchema` reads them. */
|
|
275
|
+
export type Adapters = z.output<typeof adaptersSchema>;
|
|
276
|
+
|
|
277
|
+
/** A section of keys: an object whose prototype is `Object.prototype` or none. */
|
|
278
|
+
export function isPlainSection(value: unknown): value is Readonly<Record<string, unknown>> {
|
|
279
|
+
if (typeof value !== "object" || value === null) return false;
|
|
280
|
+
const prototype: unknown = Object.getPrototypeOf(value);
|
|
281
|
+
return prototype === Object.prototype || prototype === null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* `mfaMode`: whether the composition root installs MFA, its own choice — the
|
|
286
|
+
* MFA package's mode, read with that package's schema for `mfa.mode`. `off`
|
|
287
|
+
* installs nothing of MFA; `optional` and `required` install it and are
|
|
288
|
+
* written to `mfa.mode`.
|
|
289
|
+
*/
|
|
290
|
+
export const mfaSwitchSchema = mfaConfigSchema.shape.mode;
|
|
291
|
+
|
|
292
|
+
/** The composition root's MFA switch, as `mfaSwitchSchema` reads it. */
|
|
293
|
+
export type MfaSwitch = z.output<typeof mfaSwitchSchema>;
|
|
294
|
+
|
|
295
|
+
/** A YAML file's path: a repository reads its entries from it when it is built. */
|
|
296
|
+
const yamlSchema = z.object({ path: z.string() }).strict();
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* `repositories`: the YAML client registry, and the user repository's
|
|
300
|
+
* settings for each adapter it may be — the YAML file, or the Store's HTTP
|
|
301
|
+
* endpoints. `static`, core's alias of `yaml`, reads the same shape from a
|
|
302
|
+
* block of its own, which has no default. The HTTP settings are shape only: the repository holds each
|
|
303
|
+
* value to its rules (https or loopback, a credential's strength, positive
|
|
304
|
+
* whole numbers) when it is built, and names what it refuses.
|
|
305
|
+
*/
|
|
306
|
+
export const repositoriesSectionSchema = z
|
|
307
|
+
.object({
|
|
308
|
+
client: z.object({ yaml: yamlSchema, static: yamlSchema.optional() }).strict(),
|
|
309
|
+
user: z
|
|
310
|
+
.object({
|
|
311
|
+
yaml: yamlSchema,
|
|
312
|
+
static: yamlSchema.optional(),
|
|
313
|
+
http: z
|
|
314
|
+
.object({
|
|
315
|
+
authenticateUrl: z.unknown().optional(),
|
|
316
|
+
authenticateByTokenUrl: z.unknown().optional(),
|
|
317
|
+
linkFederatedIdentityUrl: z.unknown().optional(),
|
|
318
|
+
findSubjectByFederatedIdentityUrl: z.unknown().optional(),
|
|
319
|
+
markMfaEnrolledUrl: z.unknown().optional(),
|
|
320
|
+
federatedIdentityLookupCoverage: z.unknown().optional(),
|
|
321
|
+
bearerToken: z.unknown().optional(),
|
|
322
|
+
timeout: z.unknown().optional(),
|
|
323
|
+
maxResponseBytes: z.unknown().optional(),
|
|
324
|
+
})
|
|
325
|
+
.strict(),
|
|
326
|
+
})
|
|
327
|
+
.strict(),
|
|
328
|
+
})
|
|
329
|
+
.strict();
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* `repositories` for the adapters `selection` names: `repositoriesSectionSchema`,
|
|
333
|
+
* and the `static` block's path required for a repository that selects
|
|
334
|
+
* `static`, since the block has no default. Refused at the section's parse,
|
|
335
|
+
* naming the path, before any repository is built.
|
|
336
|
+
*/
|
|
337
|
+
export function repositoriesSectionSchemaFor(selection: {
|
|
338
|
+
readonly client: Adapters["clientRepository"];
|
|
339
|
+
readonly user: Adapters["userRepository"];
|
|
340
|
+
}) {
|
|
341
|
+
return repositoriesSectionSchema.superRefine((section, ctx) => {
|
|
342
|
+
for (const repository of ["client", "user"] as const) {
|
|
343
|
+
if (selection[repository] === "static" && section[repository].static === undefined) {
|
|
344
|
+
ctx.addIssue({
|
|
345
|
+
code: "custom",
|
|
346
|
+
path: [repository, "static", "path"],
|
|
347
|
+
message: `required when adapters.${repository}Repository is "static": the static block has no default`,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** The in-process code repository's section: the default lifetime, in positive whole seconds. */
|
|
355
|
+
export const inMemoryCodeRepositorySectionSchema = z
|
|
356
|
+
.object({ defaultExpiresIn: wholeNumberInRangeFromEnv(1) })
|
|
357
|
+
.strict();
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* `audit-sink`: each sink's options, keyed by the sink's name, for the sink
|
|
361
|
+
* `adapters.auditSink` selects. Open: the sinks are the factory's, and each
|
|
362
|
+
* builder holds its own options to its rules.
|
|
363
|
+
*/
|
|
364
|
+
export const auditSinkSectionSchema = z
|
|
365
|
+
.record(z.string(), z.record(z.string(), z.unknown()))
|
|
366
|
+
.optional();
|