@o3co/create-auth-provider 0.16.0 → 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/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 -29
- package/templates/standalone/Dockerfile +6 -2
- package/templates/standalone/README.ja.md +324 -169
- package/templates/standalone/README.md +618 -208
- package/templates/standalone/config/application.conf +211 -352
- 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 +418 -0
- package/templates/standalone/docker-compose.mailpit.yml +36 -0
- package/templates/standalone/docker-compose.production.yml +63 -32
- 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__/adapters.test.mts +419 -0
- package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +87 -0
- package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +287 -198
- package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +71 -35
- package/templates/standalone/src/__tests__/all-modules-composition.test.mts +148 -94
- package/templates/standalone/src/__tests__/audit-sink.test.mts +84 -81
- package/templates/standalone/src/__tests__/config-notices.test.mts +154 -0
- package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
- package/templates/standalone/src/__tests__/core-federations.test.mts +249 -0
- package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
- package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +10 -15
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +970 -288
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +421 -86
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +87 -72
- package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +16 -8
- package/templates/standalone/src/__tests__/federation-types.test.mts +239 -0
- package/templates/standalone/src/__tests__/google-session.fixture.mts +97 -93
- package/templates/standalone/src/__tests__/host-routes.test.mts +4 -11
- package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +1 -1
- package/templates/standalone/src/__tests__/library-references.fixture.mts +188 -0
- package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +216 -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 +326 -0
- package/templates/standalone/src/__tests__/metrics.test.mts +4 -9
- package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +122 -0
- package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
- package/templates/standalone/src/__tests__/mfa-switch.test.mts +825 -0
- package/templates/standalone/src/__tests__/own-modules.test.mts +1486 -0
- package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +10 -14
- 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 +116 -81
- package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
- package/templates/standalone/src/__tests__/replica-safety.test.mts +265 -146
- package/templates/standalone/src/__tests__/router-error-answers.test.mts +4 -7
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +278 -103
- package/templates/standalone/src/__tests__/sections.test.mts +480 -0
- package/templates/standalone/src/__tests__/session-lifecycle.test.mts +215 -0
- package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +69 -61
- package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +130 -0
- package/templates/standalone/src/__tests__/session-requirements.test.mts +382 -0
- package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +270 -0
- package/templates/standalone/src/__tests__/shutdown.test.mts +163 -96
- package/templates/standalone/src/__tests__/smoke.test.mts +176 -258
- package/templates/standalone/src/__tests__/store-credential.test.mts +88 -52
- package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +131 -102
- package/templates/standalone/src/__tests__/two-phase-config.test.mts +604 -0
- package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +382 -0
- package/templates/standalone/src/adapters.mts +162 -0
- package/templates/standalone/src/app.mts +65 -79
- package/templates/standalone/src/bootRefusal.mts +117 -0
- package/templates/standalone/src/buildModules.mts +220 -309
- package/templates/standalone/src/configPath.mts +449 -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 +285 -0
- package/templates/standalone/src/modules.mts +423 -629
- package/templates/standalone/src/rootRenames.mts +96 -0
- package/templates/standalone/src/routes.mts +8 -14
- package/templates/standalone/src/sections.mts +367 -0
- package/templates/standalone/src/shutdown.mts +45 -123
- package/templates/standalone/tests/index.test.js +6 -5
- 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
- package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +0 -135
- package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +0 -199
- package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +0 -138
|
@@ -16,31 +16,25 @@
|
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* Every module this template can turn on, booted together on one replica
|
|
19
|
-
* through the real composition
|
|
20
|
-
* `createApp`, mounted as `app.mts` mounts it
|
|
21
|
-
* exist only when the modules meet
|
|
22
|
-
* truth about every grant and endpoint,
|
|
23
|
-
* same app, each module's body rules
|
|
24
|
-
* order, and a store outage answered
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* made invalid by a neighbour's contribution, a disabled grant still
|
|
29
|
-
* advertised, one module's prefix parser setting another's body limit, a
|
|
30
|
-
* content-type rule that held in one mount order only. See
|
|
31
|
-
* `all-modules-composition.fixture.mts` for what is real and what is
|
|
32
|
-
* substituted, and `all-modules-composition.multi.test.mts` for the same
|
|
33
|
-
* composition on Redis under `deployment.mode = "multi"`.
|
|
19
|
+
* through the real composition (the shipped HOCON, `buildModules`, core's
|
|
20
|
+
* `createApp`, mounted as `app.mts` mounts it), and held to the contracts that
|
|
21
|
+
* exist only when the modules meet, which no package's own suite can see: one
|
|
22
|
+
* discovery document that tells the truth about every grant and endpoint,
|
|
23
|
+
* every module's route answering in the same app, each module's body rules
|
|
24
|
+
* surviving its neighbours in either mount order, and a store outage answered
|
|
25
|
+
* 503 and logged once. The fixture file says what is real and what is
|
|
26
|
+
* substituted; `all-modules-composition.multi.test.mts` boots the same
|
|
27
|
+
* composition on Redis under `core.deployment.mode = "multi"`.
|
|
34
28
|
*
|
|
35
29
|
* `knownDefect` marks a contract the composition breaks today; its comment
|
|
36
30
|
* names the defect. In the monorepo it is `it.fails`: the fix that mends the
|
|
37
31
|
* defect turns the case red, and turns it into a plain `it`. An outage case
|
|
38
|
-
* pins only the part of the
|
|
32
|
+
* pins only the part of the outage rule that is broken, and asserts the rest
|
|
39
33
|
* (`describeOutages` in the fixture).
|
|
40
34
|
*/
|
|
41
35
|
|
|
42
36
|
import { readdirSync, readFileSync } from "node:fs";
|
|
43
|
-
import type
|
|
37
|
+
import { type AppConfig, federationsOf } from "@o3co/auth-provider-core";
|
|
44
38
|
import type express from "express";
|
|
45
39
|
import request from "supertest";
|
|
46
40
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
@@ -112,31 +106,39 @@ async function boot(options: Parameters<typeof compose>[0] = {}): Promise<Compos
|
|
|
112
106
|
const TEMPLATE_PACKAGES: Readonly<Record<string, string>> = {
|
|
113
107
|
"@o3co/auth-provider-core":
|
|
114
108
|
"jwksModule, the memory stores, the refresh-token family rotation and revocation",
|
|
115
|
-
"@o3co/auth-provider-federation-google": "
|
|
109
|
+
"@o3co/auth-provider-federation-google": "googleFederationTypeModule, the google type",
|
|
116
110
|
"@o3co/auth-provider-federation-grants": "federationGrantsModules",
|
|
117
|
-
"@o3co/auth-provider-federation-oidc": "
|
|
118
|
-
"@o3co/auth-provider-foundation":
|
|
111
|
+
"@o3co/auth-provider-federation-oidc": "oidcFederationTypeModule, the oidc type",
|
|
112
|
+
"@o3co/auth-provider-foundation":
|
|
113
|
+
"the HTTP user repository adapter; foundationMfaFactorStoreModule under MFA_MODE with adapters.mfaFactorStore = store (tools/composition boots it)",
|
|
114
|
+
"@o3co/auth-provider-mfa":
|
|
115
|
+
"mfaModules (mfaTotpFactorModule, mfaRecoveryCodeFactorModule, mfaEmailFactorModule, mfaModule) and mfaResetModule, under MFA_MODE (mfa-switch.test.mts)",
|
|
119
116
|
"@o3co/auth-provider-oauth":
|
|
120
|
-
"
|
|
117
|
+
"oauthEndpointsModule, oauthSessionGrantModule, oauthAuthorizationGrantsModule, subjectRevocationServiceModule",
|
|
121
118
|
"@o3co/auth-provider-redis": "the Redis stores (all-modules-composition.multi.test.mts)",
|
|
122
|
-
"@o3co/auth-provider-session": "sessionModule,
|
|
119
|
+
"@o3co/auth-provider-session": "sessionModule, sessionStoreModule",
|
|
120
|
+
"@o3co/auth-provider-standard":
|
|
121
|
+
"standardSmtpMailSenderModule; standardDevelopmentMailSenderModule in development",
|
|
123
122
|
};
|
|
124
123
|
|
|
125
124
|
/**
|
|
126
125
|
* Workspace packages the template does not depend on, so nothing here can
|
|
127
|
-
* boot them. Each
|
|
126
|
+
* boot them. Each but the test kit, which has no module, is a module a
|
|
127
|
+
* deployment adds to this manifest by hand —
|
|
128
128
|
* `modules.mts` provides the Redis clients their stores need for that reason
|
|
129
129
|
* — and `tools/composition` in the monorepo boots each beside every module
|
|
130
130
|
* here, through this file's fixture.
|
|
131
131
|
*/
|
|
132
132
|
const NOT_IN_TEMPLATE: Readonly<Record<string, string>> = {
|
|
133
|
-
"@o3co/auth-provider-device-grant": "
|
|
133
|
+
"@o3co/auth-provider-device-grant": "deviceAuthorizationGrantModule",
|
|
134
134
|
"@o3co/auth-provider-dpop": "dpopModule",
|
|
135
|
-
"@o3co/auth-provider-federation-apple": "
|
|
136
|
-
"@o3co/auth-provider-federation-github": "
|
|
135
|
+
"@o3co/auth-provider-federation-apple": "appleFederationTypeModule",
|
|
136
|
+
"@o3co/auth-provider-federation-github": "githubFederationTypeModule",
|
|
137
137
|
"@o3co/auth-provider-mtls": "mtlsModule",
|
|
138
138
|
"@o3co/auth-provider-oauth-token-exchange": "tokenExchangeModule",
|
|
139
|
-
"@o3co/auth-provider-
|
|
139
|
+
"@o3co/auth-provider-test-kit": "no module: contract suites and fakes, for tests",
|
|
140
|
+
"@o3co/auth-provider-webauthn":
|
|
141
|
+
"webauthnModule, webauthnSessionSubjectModule, webauthnMfaFactorModule",
|
|
140
142
|
};
|
|
141
143
|
|
|
142
144
|
describe("what the all-modules composition covers", () => {
|
|
@@ -173,7 +175,7 @@ describe("what the all-modules composition covers", () => {
|
|
|
173
175
|
|
|
174
176
|
/** What `buildModules` lists with every switch on, one replica, memory stores. */
|
|
175
177
|
const ALL_ON_MODULES = [
|
|
176
|
-
"
|
|
178
|
+
"session-store",
|
|
177
179
|
"federation-grant-background",
|
|
178
180
|
"federation-grants",
|
|
179
181
|
"oauth",
|
|
@@ -181,19 +183,21 @@ const ALL_ON_MODULES = [
|
|
|
181
183
|
"oauth-authorization",
|
|
182
184
|
"jwks",
|
|
183
185
|
"session",
|
|
184
|
-
"federation
|
|
185
|
-
"
|
|
186
|
-
"
|
|
187
|
-
"
|
|
188
|
-
"
|
|
186
|
+
"federation-google-type",
|
|
187
|
+
"federation-oidc",
|
|
188
|
+
"logging",
|
|
189
|
+
"http",
|
|
190
|
+
"key-store",
|
|
189
191
|
"test:repositories",
|
|
190
|
-
"
|
|
191
|
-
"
|
|
192
|
+
"audit-sink",
|
|
193
|
+
"standard-smtp-mail-sender",
|
|
194
|
+
"standalone-in-memory-federation-token-store",
|
|
192
195
|
"core-federation-grant-store-memory",
|
|
193
196
|
"core-federation-grant-intent-store-memory",
|
|
194
|
-
"standalone
|
|
197
|
+
"standalone-in-memory-session-stores",
|
|
198
|
+
"core-session-lifecycle",
|
|
195
199
|
"core-rate-limiter-memory",
|
|
196
|
-
"standalone
|
|
200
|
+
"standalone-in-memory-code-repository",
|
|
197
201
|
"core-access-token-denylist-memory",
|
|
198
202
|
"core-replay-seen-set-memory",
|
|
199
203
|
"core-consent-store-memory",
|
|
@@ -231,23 +235,28 @@ describe("every module the template can turn on boots together", () => {
|
|
|
231
235
|
);
|
|
232
236
|
});
|
|
233
237
|
|
|
234
|
-
it("registers
|
|
238
|
+
it("registers a federation for every enabled entry of a type a module contributes, and every grant", async () => {
|
|
235
239
|
const { modules, handle, app } = await boot();
|
|
236
|
-
const
|
|
237
|
-
expect(
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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();
|
|
241
250
|
expect(grants).toEqual(ENABLED_GRANTS);
|
|
242
251
|
const discovery = await request(app).get("/.well-known/openid-configuration");
|
|
243
252
|
expect([...discovery.body.grant_types_supported].sort()).toEqual(grants);
|
|
244
253
|
});
|
|
245
254
|
|
|
246
255
|
it("hands the deployment's logger to every module that answers a request", async () => {
|
|
247
|
-
// `app.mts` fills the `logger` slot so that `
|
|
256
|
+
// `app.mts` fills the `logger` slot so that `LOGGING_LEVEL` and the JSON
|
|
248
257
|
// envelope reach every module; boot hands a module only the slots its
|
|
249
258
|
// manifest names, so a route or grant module without `logger` writes
|
|
250
|
-
// its outage lines to nobody
|
|
259
|
+
// its outage lines to nobody.
|
|
251
260
|
const { modules } = await boot();
|
|
252
261
|
const answering = modules.filter(
|
|
253
262
|
(m) => contributionNames(m, "routes").length + contributionNames(m, "grants").length > 0,
|
|
@@ -325,10 +334,10 @@ describe("discovery", () => {
|
|
|
325
334
|
* is gone from the document and from the app, and nothing else moves.
|
|
326
335
|
*/
|
|
327
336
|
const GRANT_SWITCHES: ReadonlyArray<readonly [grant: string, variable: string]> = [
|
|
328
|
-
["session", "
|
|
329
|
-
["authorization_code", "
|
|
330
|
-
["refresh_token", "
|
|
331
|
-
["client_credentials", "
|
|
337
|
+
["session", "OAUTH_SESSION_ENABLED"],
|
|
338
|
+
["authorization_code", "OAUTH_AUTHORIZATION_GRANTS_AUTHORIZATION_CODE_ENABLED"],
|
|
339
|
+
["refresh_token", "OAUTH_AUTHORIZATION_GRANTS_REFRESH_TOKEN_ENABLED"],
|
|
340
|
+
["client_credentials", "OAUTH_AUTHORIZATION_GRANTS_CLIENT_CREDENTIALS_ENABLED"],
|
|
332
341
|
];
|
|
333
342
|
|
|
334
343
|
it.each(GRANT_SWITCHES)(
|
|
@@ -336,7 +345,7 @@ describe("discovery", () => {
|
|
|
336
345
|
async (grant, variable) => {
|
|
337
346
|
const { app } = await boot({ env: { ...SINGLE_ENV, [variable]: "false" } });
|
|
338
347
|
const doc = (await request(app).get(DISCOVERY_PATHS[0])).body;
|
|
339
|
-
expectValidMetadata(doc);
|
|
348
|
+
expectValidMetadata(doc, { authorizationCode: grant !== "authorization_code" });
|
|
340
349
|
expect([...doc.grant_types_supported].sort()).toEqual(
|
|
341
350
|
ENABLED_GRANTS.filter((g) => g !== grant),
|
|
342
351
|
);
|
|
@@ -347,11 +356,17 @@ describe("discovery", () => {
|
|
|
347
356
|
.send({ grant_type: grant });
|
|
348
357
|
expect(res.status).toBe(400);
|
|
349
358
|
expect(res.body.error).toBe("unsupported_grant_type");
|
|
359
|
+
if (grant === "authorization_code") {
|
|
360
|
+
// No grant redeems a code, so none is issued: none is served.
|
|
361
|
+
expect((await request(app).get("/oauth/authorize")).status).toBe(404);
|
|
362
|
+
}
|
|
350
363
|
},
|
|
351
364
|
);
|
|
352
365
|
|
|
353
366
|
it("Client ID Metadata Documents off: the flag is gone and nothing else moves", async () => {
|
|
354
|
-
const { app } = await boot({
|
|
367
|
+
const { app } = await boot({
|
|
368
|
+
env: { ...SINGLE_ENV, OAUTH_CLIENT_ID_METADATA_DOCUMENTS_ENABLED: "false" },
|
|
369
|
+
});
|
|
355
370
|
const doc = (await request(app).get(DISCOVERY_PATHS[0])).body;
|
|
356
371
|
expectValidMetadata(doc);
|
|
357
372
|
expect(doc.client_id_metadata_document_supported).toBeUndefined();
|
|
@@ -367,7 +382,7 @@ describe("discovery", () => {
|
|
|
367
382
|
await on.handle.dispose();
|
|
368
383
|
current = undefined;
|
|
369
384
|
|
|
370
|
-
const { app } = await boot({ env: { ...SINGLE_ENV,
|
|
385
|
+
const { app } = await boot({ env: { ...SINGLE_ENV, ADAPTERS_CONSENT_STORE: "none" } });
|
|
371
386
|
const doc = (await request(app).get(DISCOVERY_PATHS[0])).body;
|
|
372
387
|
expectValidMetadata(doc);
|
|
373
388
|
expect(doc.client_id_metadata_document_supported).toBeUndefined();
|
|
@@ -376,7 +391,13 @@ describe("discovery", () => {
|
|
|
376
391
|
|
|
377
392
|
/** The grant connections dropped: they name the OIDC federation. */
|
|
378
393
|
const withoutConnections = (config: AppConfig): AppConfig =>
|
|
379
|
-
({
|
|
394
|
+
({
|
|
395
|
+
...config,
|
|
396
|
+
"federation-grants": {
|
|
397
|
+
...(config["federation-grants"] as Record<string, unknown>),
|
|
398
|
+
connections: {},
|
|
399
|
+
},
|
|
400
|
+
}) as AppConfig;
|
|
380
401
|
|
|
381
402
|
const FEATURE_SWITCHES: ReadonlyArray<
|
|
382
403
|
readonly [
|
|
@@ -389,13 +410,13 @@ describe("discovery", () => {
|
|
|
389
410
|
["federation grants", "FEDERATION_GRANTS_ENABLED", "/oauth/federation-grants", undefined],
|
|
390
411
|
[
|
|
391
412
|
"the Google federation",
|
|
392
|
-
"
|
|
413
|
+
"CORE_FEDERATIONS_GOOGLE_ENABLED",
|
|
393
414
|
"/session/oauth/federation/google",
|
|
394
415
|
undefined,
|
|
395
416
|
],
|
|
396
417
|
[
|
|
397
418
|
"the OIDC federation",
|
|
398
|
-
"
|
|
419
|
+
"CORE_FEDERATIONS_OIDC_ENABLED",
|
|
399
420
|
"/session/oauth/federation/oidc",
|
|
400
421
|
withoutConnections,
|
|
401
422
|
],
|
|
@@ -421,7 +442,7 @@ describe("discovery", () => {
|
|
|
421
442
|
|
|
422
443
|
it("the OIDC federation off while a grant connection names it is refused at boot, naming both", async () => {
|
|
423
444
|
await expect(
|
|
424
|
-
compose({ env: { ...SINGLE_ENV,
|
|
445
|
+
compose({ env: { ...SINGLE_ENV, CORE_FEDERATIONS_OIDC_ENABLED: "false" } }),
|
|
425
446
|
).rejects.toSatisfy((err: unknown) => {
|
|
426
447
|
const e = err as { name?: string; cause?: { message?: string } };
|
|
427
448
|
return (
|
|
@@ -554,7 +575,7 @@ describe("every module's primary route answers in the one app", () => {
|
|
|
554
575
|
|
|
555
576
|
// KNOWN DEFECT (the template's federation config, with the session
|
|
556
577
|
// package's redirect policy): a federation enabled the way the template
|
|
557
|
-
// README documents — its `
|
|
578
|
+
// README documents — its `CORE_FEDERATIONS_OIDC_*` variables — boots, and every
|
|
558
579
|
// login through it then ends at the callback in `500 misconfiguration`
|
|
559
580
|
// "client URL not configured", after the user has signed in upstream.
|
|
560
581
|
// `clientUrl` has no environment form (`application.conf` ships it
|
|
@@ -565,9 +586,12 @@ describe("every module's primary route answers in the one app", () => {
|
|
|
565
586
|
"a federation enabled from the documented variables alone either refuses to boot or completes a login",
|
|
566
587
|
async () => {
|
|
567
588
|
const withoutLanding = (config: AppConfig): AppConfig => {
|
|
568
|
-
const federations = config
|
|
589
|
+
const federations = federationsOf(config) as Record<string, Record<string, unknown>>;
|
|
569
590
|
const { clientUrl: _dropped, ...oidc } = federations.oidc ?? {};
|
|
570
|
-
return {
|
|
591
|
+
return {
|
|
592
|
+
...config,
|
|
593
|
+
core: { ...config.core, federations: { ...federations, oidc } },
|
|
594
|
+
} as unknown as AppConfig;
|
|
571
595
|
};
|
|
572
596
|
let composed: Composition;
|
|
573
597
|
try {
|
|
@@ -591,7 +615,7 @@ describe("every module's primary route answers in the one app", () => {
|
|
|
591
615
|
},
|
|
592
616
|
);
|
|
593
617
|
|
|
594
|
-
it("a federation grant is lodged, beside
|
|
618
|
+
it("a federation grant is lodged, beside oauthEndpointsModule under /oauth", async () => {
|
|
595
619
|
const { app } = await boot();
|
|
596
620
|
const res = await lodgeGrant(app);
|
|
597
621
|
expect(res.status).toBe(201);
|
|
@@ -633,7 +657,7 @@ describe.each([AS_LISTED, REVERSED] satisfies ModuleOrder[])(
|
|
|
633
657
|
});
|
|
634
658
|
|
|
635
659
|
it.each(TRANSFERS)(
|
|
636
|
-
"federation grants keep their 16 KiB bound beneath
|
|
660
|
+
"federation grants keep their 16 KiB bound beneath oauthEndpointsModule's /oauth, a body sent %s",
|
|
637
661
|
async (_transfer, send) => {
|
|
638
662
|
for (const type of [JSON_TYPE, FORM_TYPE]) {
|
|
639
663
|
const body =
|
|
@@ -746,9 +770,10 @@ describe.each([AS_LISTED, REVERSED] satisfies ModuleOrder[])(
|
|
|
746
770
|
);
|
|
747
771
|
|
|
748
772
|
describe("a request body the OAuth endpoints do not parse", () => {
|
|
749
|
-
// KNOWN DEFECT (packages/oauth, `
|
|
750
|
-
//
|
|
751
|
-
//
|
|
773
|
+
// KNOWN DEFECT (packages/oauth, `routes/token.mts`,
|
|
774
|
+
// `routes/introspectCaller.mts` and `routes.mts`): the token and
|
|
775
|
+
// introspection handlers read `req.body` without checking that a parser
|
|
776
|
+
// ran. A body that is neither a form nor JSON — or none at all —
|
|
752
777
|
// leaves it undefined, the handler throws, and the terminal handler answers
|
|
753
778
|
// `500 server_error` and logs `unhandled_request_error` for the client's
|
|
754
779
|
// own mistake. `/oauth/revoke` checks, and answers 400.
|
|
@@ -804,6 +829,16 @@ const oidcCallback = async (app: express.Express, outage: Outage, c: Composition
|
|
|
804
829
|
const VERIFIER_WARN =
|
|
805
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)";
|
|
806
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
|
+
|
|
807
842
|
const OUTAGES: readonly OutageCase[] = [
|
|
808
843
|
{
|
|
809
844
|
module: "oauth-authorization",
|
|
@@ -813,22 +848,6 @@ const OUTAGES: readonly OutageCase[] = [
|
|
|
813
848
|
answer: { status: 503, error: "temporarily_unavailable" },
|
|
814
849
|
event: "authorization_grant_store_unavailable",
|
|
815
850
|
},
|
|
816
|
-
{
|
|
817
|
-
module: "oauth-authorization",
|
|
818
|
-
slot: "sessionFamilyIndex",
|
|
819
|
-
surface: "the code exchange",
|
|
820
|
-
run: codeExchange,
|
|
821
|
-
answer: { status: 503, error: "temporarily_unavailable" },
|
|
822
|
-
event: "authorization_grant_store_unavailable",
|
|
823
|
-
},
|
|
824
|
-
{
|
|
825
|
-
module: "oauth-authorization",
|
|
826
|
-
slot: "sessionRPRegistry",
|
|
827
|
-
surface: "the code exchange",
|
|
828
|
-
run: codeExchange,
|
|
829
|
-
answer: { status: 503, error: "temporarily_unavailable" },
|
|
830
|
-
event: "authorization_grant_store_unavailable",
|
|
831
|
-
},
|
|
832
851
|
{
|
|
833
852
|
module: "oauth",
|
|
834
853
|
slot: "codeRepository",
|
|
@@ -906,6 +925,47 @@ const OUTAGES: readonly OutageCase[] = [
|
|
|
906
925
|
event: "userinfo_store_unavailable",
|
|
907
926
|
unrelatedWarns: ["jwt_verify_aud_skipped"],
|
|
908
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
|
+
},
|
|
909
969
|
{
|
|
910
970
|
module: "oauth-session",
|
|
911
971
|
slot: "userSessionStore",
|
|
@@ -918,7 +978,7 @@ const OUTAGES: readonly OutageCase[] = [
|
|
|
918
978
|
.send({ grant_type: "session", scope: "openid" });
|
|
919
979
|
},
|
|
920
980
|
answer: { status: 503, error: "temporarily_unavailable" },
|
|
921
|
-
event: "
|
|
981
|
+
event: "session_admission_unavailable",
|
|
922
982
|
},
|
|
923
983
|
{
|
|
924
984
|
module: "oauth (consent step)",
|
|
@@ -933,7 +993,7 @@ const OUTAGES: readonly OutageCase[] = [
|
|
|
933
993
|
event: "authorize_consent_store_unavailable",
|
|
934
994
|
defects: {
|
|
935
995
|
"store-field":
|
|
936
|
-
"packages/oauth `routes/
|
|
996
|
+
"packages/oauth `routes/authorizeConsent.mts`: `authorize_consent_store_unavailable` carries `clientId` and `err` but no `store` / `step` / `site` field",
|
|
937
997
|
},
|
|
938
998
|
},
|
|
939
999
|
{
|
|
@@ -949,7 +1009,7 @@ const OUTAGES: readonly OutageCase[] = [
|
|
|
949
1009
|
event: "authorize_pending_consent_store_unavailable",
|
|
950
1010
|
defects: {
|
|
951
1011
|
"store-field":
|
|
952
|
-
"packages/oauth `routes/
|
|
1012
|
+
"packages/oauth `routes/authorizeConsent.mts`: `authorize_pending_consent_store_unavailable` carries `clientId` and `err` but no `store` / `step` / `site` field",
|
|
953
1013
|
},
|
|
954
1014
|
},
|
|
955
1015
|
{
|
|
@@ -962,6 +1022,7 @@ const OUTAGES: readonly OutageCase[] = [
|
|
|
962
1022
|
},
|
|
963
1023
|
answer: { status: 503, error: "temporarily_unavailable" },
|
|
964
1024
|
event: "login_store_unavailable",
|
|
1025
|
+
defects: { "no-warn": ROLLBACK_CLOSE_LOGS },
|
|
965
1026
|
},
|
|
966
1027
|
{
|
|
967
1028
|
module: "session",
|
|
@@ -1006,7 +1067,7 @@ const OUTAGES: readonly OutageCase[] = [
|
|
|
1006
1067
|
outage.down = true;
|
|
1007
1068
|
return (await login(app)).res;
|
|
1008
1069
|
},
|
|
1009
|
-
// Intended
|
|
1070
|
+
// Intended: the login is not denied for a best-effort index write,
|
|
1010
1071
|
// and the missed write is logged at error because a token minted from
|
|
1011
1072
|
// this session keeps introspecting active after a credential change —
|
|
1012
1073
|
// the runbook pages on it.
|
|
@@ -1022,19 +1083,12 @@ const OUTAGES: readonly OutageCase[] = [
|
|
|
1022
1083
|
run: oidcCallback,
|
|
1023
1084
|
answer: { status: 503, error: "temporarily_unavailable" },
|
|
1024
1085
|
event: "federation_callback_store_unavailable",
|
|
1025
|
-
|
|
1026
|
-
{
|
|
1027
|
-
module: "session",
|
|
1028
|
-
slot: "sessionFederationIndex",
|
|
1029
|
-
surface: "the OIDC federation callback",
|
|
1030
|
-
run: oidcCallback,
|
|
1031
|
-
answer: { status: 503, error: "temporarily_unavailable" },
|
|
1032
|
-
event: "federation_callback_store_unavailable",
|
|
1086
|
+
defects: { "no-warn": ROLLBACK_CLOSE_LOGS },
|
|
1033
1087
|
},
|
|
1034
1088
|
{
|
|
1035
1089
|
module: "core (rate-limit guard)",
|
|
1036
1090
|
slot: "rateLimiter",
|
|
1037
|
-
surface: "/oauth/token (
|
|
1091
|
+
surface: "/oauth/token (redis-rate-limiter.failMode = closed)",
|
|
1038
1092
|
run: async (app, outage) => {
|
|
1039
1093
|
outage.down = true;
|
|
1040
1094
|
return tokenRequest(app, M2M).send({ grant_type: "client_credentials", scope: "api.read" });
|
|
@@ -1094,7 +1148,7 @@ const OUTAGES: readonly OutageCase[] = [
|
|
|
1094
1148
|
];
|
|
1095
1149
|
|
|
1096
1150
|
describeOutages(
|
|
1097
|
-
"a store or repository outage
|
|
1151
|
+
"a store or repository outage gets the answer its case pins, and is logged once, at error",
|
|
1098
1152
|
OUTAGES,
|
|
1099
1153
|
(outage) => compose({ outage }),
|
|
1100
1154
|
);
|