@o3co/create-auth-provider 0.17.0 → 0.18.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.md +2 -198
- package/main.js +4 -0
- package/package.json +8 -30
- package/README.ja.md +0 -200
- package/dist/cli.d.mts +0 -3
- package/dist/cli.d.mts.map +0 -1
- package/dist/cli.mjs +0 -18
- package/dist/index.d.mts +0 -48
- package/dist/index.d.mts.map +0 -1
- package/dist/index.mjs +0 -325
- package/dist/internal/template-filter.d.mts +0 -14
- package/dist/internal/template-filter.d.mts.map +0 -1
- package/dist/internal/template-filter.mjs +0 -42
- package/templates/standalone/.dockerignore +0 -23
- package/templates/standalone/.env.example +0 -152
- package/templates/standalone/Dockerfile +0 -125
- package/templates/standalone/Makefile +0 -12
- package/templates/standalone/README.ja.md +0 -912
- package/templates/standalone/README.md +0 -1536
- package/templates/standalone/config/application.conf +0 -351
- package/templates/standalone/config/clients.yaml +0 -4
- package/templates/standalone/config/clients.yaml.example +0 -124
- package/templates/standalone/config/development.conf +0 -17
- package/templates/standalone/config/mailpit.conf +0 -13
- package/templates/standalone/config/production.conf +0 -15
- package/templates/standalone/config/reference.conf +0 -427
- package/templates/standalone/docker-compose.mailpit.yml +0 -39
- package/templates/standalone/docker-compose.production.yml +0 -229
- package/templates/standalone/docker-compose.test.yml +0 -16
- package/templates/standalone/docker-compose.yml +0 -67
- package/templates/standalone/gitignore +0 -23
- package/templates/standalone/package.json +0 -56
- package/templates/standalone/src/__tests__/adapters.test.mts +0 -425
- package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +0 -87
- package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +0 -1188
- package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +0 -285
- package/templates/standalone/src/__tests__/all-modules-composition.test.mts +0 -1154
- package/templates/standalone/src/__tests__/audit-sink.test.mts +0 -355
- package/templates/standalone/src/__tests__/backchannel-peer.fixture.mts +0 -64
- package/templates/standalone/src/__tests__/config-notices.test.mts +0 -157
- package/templates/standalone/src/__tests__/configPath.test.mts +0 -69
- package/templates/standalone/src/__tests__/configured-mfa-mode.fixture.mts +0 -48
- package/templates/standalone/src/__tests__/core-federations.test.mts +0 -252
- package/templates/standalone/src/__tests__/core-sections.test.mts +0 -192
- package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +0 -95
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +0 -1291
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +0 -787
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +0 -464
- package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +0 -141
- package/templates/standalone/src/__tests__/federation-types.test.mts +0 -239
- package/templates/standalone/src/__tests__/frontchannel-logout-csp.test.mts +0 -168
- package/templates/standalone/src/__tests__/google-session.fixture.mts +0 -318
- package/templates/standalone/src/__tests__/host-routes.test.mts +0 -134
- package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +0 -29
- package/templates/standalone/src/__tests__/library-references.fixture.mts +0 -188
- package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +0 -217
- package/templates/standalone/src/__tests__/listen.test.mts +0 -121
- package/templates/standalone/src/__tests__/logger-projection.test.mts +0 -77
- package/templates/standalone/src/__tests__/logout-session-cookie.test.mts +0 -113
- package/templates/standalone/src/__tests__/mail-sender.test.mts +0 -134
- package/templates/standalone/src/__tests__/mailpit-compose.test.mts +0 -393
- package/templates/standalone/src/__tests__/metrics.test.mts +0 -253
- package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +0 -122
- package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +0 -127
- package/templates/standalone/src/__tests__/mfa-switch.test.mts +0 -883
- package/templates/standalone/src/__tests__/own-modules.test.mts +0 -1480
- package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +0 -183
- package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +0 -283
- package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +0 -135
- package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +0 -214
- package/templates/standalone/src/__tests__/redis-stand-in.fixture.mts +0 -98
- package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +0 -249
- package/templates/standalone/src/__tests__/replica-safety.test.mts +0 -657
- package/templates/standalone/src/__tests__/repositories-logger.test.mts +0 -119
- package/templates/standalone/src/__tests__/router-error-answers.test.mts +0 -146
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +0 -610
- package/templates/standalone/src/__tests__/sections.test.mts +0 -516
- package/templates/standalone/src/__tests__/session-lifecycle.test.mts +0 -213
- package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +0 -305
- package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +0 -130
- package/templates/standalone/src/__tests__/session-requirements.test.mts +0 -382
- package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +0 -274
- package/templates/standalone/src/__tests__/shutdown.test.mts +0 -622
- package/templates/standalone/src/__tests__/smoke.test.mts +0 -642
- package/templates/standalone/src/__tests__/startup-log-lines.test.mts +0 -164
- package/templates/standalone/src/__tests__/store-credential.test.mts +0 -314
- package/templates/standalone/src/__tests__/supertest-loopback.test.mts +0 -296
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +0 -265
- package/templates/standalone/src/__tests__/two-phase-config.test.mts +0 -605
- package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +0 -385
- package/templates/standalone/src/adapters.mts +0 -162
- package/templates/standalone/src/app.mts +0 -127
- package/templates/standalone/src/bootRefusal.mts +0 -117
- package/templates/standalone/src/buildModules.mts +0 -358
- package/templates/standalone/src/configPath.mts +0 -487
- package/templates/standalone/src/listen.mts +0 -60
- package/templates/standalone/src/logger.mts +0 -80
- package/templates/standalone/src/metrics.mts +0 -167
- package/templates/standalone/src/mfaSwitch.mts +0 -284
- package/templates/standalone/src/modules.mts +0 -705
- package/templates/standalone/src/rootRenames.mts +0 -95
- package/templates/standalone/src/routes.mts +0 -89
- package/templates/standalone/src/sections.mts +0 -378
- package/templates/standalone/src/securityHeaders.mts +0 -36
- package/templates/standalone/src/shutdown.mts +0 -213
- package/templates/standalone/tests/Makefile +0 -5
- package/templates/standalone/tests/index.test.js +0 -283
- package/templates/standalone/tests/package-lock.json +0 -1617
- package/templates/standalone/tests/package.json +0 -16
- package/templates/standalone/tests/vitest.config.mjs +0 -26
- package/templates/standalone/tsconfig.json +0 -31
- package/templates/standalone/vitest.config.mts +0 -31
- package/templates/standalone/vitest.supertest-loopback.mts +0 -278
- package/templates/versions.json +0 -20
|
@@ -1,1154 +0,0 @@
|
|
|
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
|
-
* Every module this template can turn on, booted together on one replica
|
|
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"`.
|
|
28
|
-
*
|
|
29
|
-
* `knownDefect` marks a contract the composition breaks today; its comment
|
|
30
|
-
* names the defect. In the monorepo it is `it.fails`: the fix that mends the
|
|
31
|
-
* defect turns the case red, and turns it into a plain `it`. An outage case
|
|
32
|
-
* pins only the part of the outage rule that is broken, and asserts the rest
|
|
33
|
-
* (`describeOutages` in the fixture).
|
|
34
|
-
*/
|
|
35
|
-
|
|
36
|
-
import { readdirSync, readFileSync } from "node:fs";
|
|
37
|
-
import { type AppConfig, federationsOf } from "@o3co/auth-provider-core";
|
|
38
|
-
import type express from "express";
|
|
39
|
-
import request from "supertest";
|
|
40
|
-
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
41
|
-
import {
|
|
42
|
-
ALICE,
|
|
43
|
-
AS_LISTED,
|
|
44
|
-
authorize,
|
|
45
|
-
basic,
|
|
46
|
-
type Composition,
|
|
47
|
-
codeFrom,
|
|
48
|
-
compose,
|
|
49
|
-
contributionNames,
|
|
50
|
-
cookiesOf,
|
|
51
|
-
DISCOVERY_PATHS,
|
|
52
|
-
describeOutages,
|
|
53
|
-
expectValidMetadata,
|
|
54
|
-
FEDERATION_LANDING,
|
|
55
|
-
FORM_TYPE,
|
|
56
|
-
federatedCallback,
|
|
57
|
-
ISSUER,
|
|
58
|
-
inMonorepo,
|
|
59
|
-
JSON_TYPE,
|
|
60
|
-
KIB,
|
|
61
|
-
knownDefect,
|
|
62
|
-
lodgeGrant,
|
|
63
|
-
login,
|
|
64
|
-
M2M,
|
|
65
|
-
type ModuleOrder,
|
|
66
|
-
type Outage,
|
|
67
|
-
type OutageCase,
|
|
68
|
-
padForm,
|
|
69
|
-
padJson,
|
|
70
|
-
REVERSED,
|
|
71
|
-
redeem,
|
|
72
|
-
SINGLE_ENV,
|
|
73
|
-
TEMPLATE_DEPENDENCIES,
|
|
74
|
-
THIRD,
|
|
75
|
-
TOO_LARGE,
|
|
76
|
-
TRANSFERS,
|
|
77
|
-
WEB,
|
|
78
|
-
WORKER,
|
|
79
|
-
webTokens,
|
|
80
|
-
withLength,
|
|
81
|
-
} from "./all-modules-composition.fixture.mjs";
|
|
82
|
-
|
|
83
|
-
let current: Composition | undefined;
|
|
84
|
-
|
|
85
|
-
afterEach(async () => {
|
|
86
|
-
await current?.handle.dispose();
|
|
87
|
-
current = undefined;
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
/** Boots and remembers the composition, so `afterEach` disposes it. */
|
|
91
|
-
async function boot(options: Parameters<typeof compose>[0] = {}): Promise<Composition> {
|
|
92
|
-
current = await compose(options);
|
|
93
|
-
return current;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// ---------------------------------------------------------------------------
|
|
97
|
-
// What is composed
|
|
98
|
-
// ---------------------------------------------------------------------------
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Every `@o3co/auth-provider-*` package the template depends on, and what the
|
|
102
|
-
* all-on composition takes from it. A dependency added to `package.json`
|
|
103
|
-
* without a line here fails the first case below, so a new module cannot be
|
|
104
|
-
* shipped in the template without being booted here beside the rest.
|
|
105
|
-
*/
|
|
106
|
-
const TEMPLATE_PACKAGES: Readonly<Record<string, string>> = {
|
|
107
|
-
"@o3co/auth-provider-core":
|
|
108
|
-
"jwksModule, the memory stores, the refresh-token family rotation and revocation",
|
|
109
|
-
"@o3co/auth-provider-federation-google": "googleFederationTypeModule, the google type",
|
|
110
|
-
"@o3co/auth-provider-federation-grants": "federationGrantsModules",
|
|
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)",
|
|
116
|
-
"@o3co/auth-provider-oauth":
|
|
117
|
-
"oauthEndpointsModule, oauthSessionGrantModule, oauthAuthorizationGrantsModule, subjectRevocationServiceModule",
|
|
118
|
-
"@o3co/auth-provider-redis": "the Redis stores (all-modules-composition.multi.test.mts)",
|
|
119
|
-
"@o3co/auth-provider-session": "sessionModule, sessionStoreModule",
|
|
120
|
-
"@o3co/auth-provider-standard":
|
|
121
|
-
"standardSmtpMailSenderModule; standardDevelopmentMailSenderModule in development",
|
|
122
|
-
};
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Workspace packages the template does not compose (the test kit is a
|
|
126
|
-
* devDependency only), so nothing here can boot them. Each but the test kit, which has no module, is a module a
|
|
127
|
-
* deployment adds to this manifest by hand —
|
|
128
|
-
* `modules.mts` provides the Redis clients their stores need for that reason
|
|
129
|
-
* — and `tools/composition` in the monorepo boots each beside every module
|
|
130
|
-
* here, through this file's fixture.
|
|
131
|
-
*/
|
|
132
|
-
const NOT_IN_TEMPLATE: Readonly<Record<string, string>> = {
|
|
133
|
-
"@o3co/auth-provider-device-grant": "deviceAuthorizationGrantModule",
|
|
134
|
-
"@o3co/auth-provider-dpop": "dpopModule",
|
|
135
|
-
"@o3co/auth-provider-federation-apple": "appleFederationTypeModule",
|
|
136
|
-
"@o3co/auth-provider-federation-github": "githubFederationTypeModule",
|
|
137
|
-
"@o3co/auth-provider-mtls": "mtlsModule",
|
|
138
|
-
"@o3co/auth-provider-oauth-token-exchange": "tokenExchangeModule",
|
|
139
|
-
"@o3co/auth-provider-test-kit": "no module: contract suites and fakes, for tests",
|
|
140
|
-
"@o3co/auth-provider-webauthn":
|
|
141
|
-
"webauthnModule, webauthnSessionSubjectModule, webauthnMfaFactorModule",
|
|
142
|
-
};
|
|
143
|
-
|
|
144
|
-
describe("what the all-modules composition covers", () => {
|
|
145
|
-
it("names every @o3co/auth-provider-* package the template depends on", () => {
|
|
146
|
-
const siblings = [...TEMPLATE_DEPENDENCIES].sort();
|
|
147
|
-
expect(siblings).toEqual(Object.keys(TEMPLATE_PACKAGES).sort());
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
describe.runIf(inMonorepo)("in the monorepo", () => {
|
|
151
|
-
it("places every workspace package: composed here, or listed as not in the template", () => {
|
|
152
|
-
const packagesDir = new URL("../../../../packages/", import.meta.url);
|
|
153
|
-
const workspace = readdirSync(packagesDir, { withFileTypes: true })
|
|
154
|
-
.filter((entry) => entry.isDirectory())
|
|
155
|
-
.map(
|
|
156
|
-
(entry) =>
|
|
157
|
-
(
|
|
158
|
-
JSON.parse(
|
|
159
|
-
readFileSync(new URL(`${entry.name}/package.json`, packagesDir), "utf8"),
|
|
160
|
-
) as { name: string }
|
|
161
|
-
).name,
|
|
162
|
-
)
|
|
163
|
-
.sort();
|
|
164
|
-
expect(workspace.length).toBeGreaterThan(0);
|
|
165
|
-
expect(workspace).toEqual(
|
|
166
|
-
[...Object.keys(TEMPLATE_PACKAGES), ...Object.keys(NOT_IN_TEMPLATE)].sort(),
|
|
167
|
-
);
|
|
168
|
-
});
|
|
169
|
-
});
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
// ---------------------------------------------------------------------------
|
|
173
|
-
// Boot
|
|
174
|
-
// ---------------------------------------------------------------------------
|
|
175
|
-
|
|
176
|
-
/** What `buildModules` lists with every switch on, one replica, memory stores. */
|
|
177
|
-
const ALL_ON_MODULES = [
|
|
178
|
-
"session-store",
|
|
179
|
-
"federation-grant-background",
|
|
180
|
-
"federation-grants",
|
|
181
|
-
"oauth",
|
|
182
|
-
"oauth-session",
|
|
183
|
-
"oauth-authorization",
|
|
184
|
-
"jwks",
|
|
185
|
-
"session",
|
|
186
|
-
"federation-google-type",
|
|
187
|
-
"federation-oidc",
|
|
188
|
-
"logging",
|
|
189
|
-
"http",
|
|
190
|
-
"key-store",
|
|
191
|
-
"test:repositories",
|
|
192
|
-
"audit-sink",
|
|
193
|
-
"standard-smtp-mail-sender",
|
|
194
|
-
"standalone-in-memory-federation-token-store",
|
|
195
|
-
"core-federation-grant-store-memory",
|
|
196
|
-
"core-federation-grant-intent-store-memory",
|
|
197
|
-
"standalone-in-memory-session-stores",
|
|
198
|
-
"core-session-lifecycle",
|
|
199
|
-
"core-rate-limiter-memory",
|
|
200
|
-
"standalone-in-memory-code-repository",
|
|
201
|
-
"core-access-token-denylist-memory",
|
|
202
|
-
"core-replay-seen-set-memory",
|
|
203
|
-
"core-consent-store-memory",
|
|
204
|
-
"core-refresh-token-family-store-memory",
|
|
205
|
-
"core-default-refresh-token-family-rotation",
|
|
206
|
-
"core-default-refresh-token-family-revocation",
|
|
207
|
-
"subject-revocation-service",
|
|
208
|
-
];
|
|
209
|
-
|
|
210
|
-
const ENABLED_GRANTS = ["authorization_code", "client_credentials", "refresh_token", "session"];
|
|
211
|
-
|
|
212
|
-
describe("every module the template can turn on boots together", () => {
|
|
213
|
-
it("lists every module, and boots with nothing refused", async () => {
|
|
214
|
-
const { modules } = await boot();
|
|
215
|
-
expect(modules.map((m) => m.name)).toEqual(ALL_ON_MODULES);
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
it("mounts every route a module contributes", async () => {
|
|
219
|
-
const { modules, handle } = await boot();
|
|
220
|
-
const contributing = modules.filter((m) => contributionNames(m, "routes").length > 0);
|
|
221
|
-
expect(contributing.length).toBeGreaterThan(0);
|
|
222
|
-
const mountedBy = new Set(handle.routes.map((r) => r.contributedBy));
|
|
223
|
-
for (const module of contributing) expect(mountedBy, module.name).toContain(module.name);
|
|
224
|
-
expect(handle.routes.map((r) => r.contribution.id).sort()).toEqual(
|
|
225
|
-
[
|
|
226
|
-
"core:oidc-discovery",
|
|
227
|
-
"federation-grants",
|
|
228
|
-
"federation-grants-browser",
|
|
229
|
-
"federation-routes",
|
|
230
|
-
"jwks",
|
|
231
|
-
"oauth-endpoints",
|
|
232
|
-
"session-middleware",
|
|
233
|
-
"session-routes",
|
|
234
|
-
].sort(),
|
|
235
|
-
);
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
it("registers a federation for every enabled entry of a type a module contributes, and every grant", async () => {
|
|
239
|
-
const { modules, handle, app } = await boot();
|
|
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();
|
|
250
|
-
expect(grants).toEqual(ENABLED_GRANTS);
|
|
251
|
-
const discovery = await request(app).get("/.well-known/openid-configuration");
|
|
252
|
-
expect([...discovery.body.grant_types_supported].sort()).toEqual(grants);
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
it("hands the deployment's logger to every module that answers a request", async () => {
|
|
256
|
-
// `app.mts` fills the `logger` slot so that `LOGGING_LEVEL` and the JSON
|
|
257
|
-
// envelope reach every module; boot hands a module only the slots its
|
|
258
|
-
// manifest names, so a route or grant module without `logger` writes
|
|
259
|
-
// its outage lines to nobody.
|
|
260
|
-
const { modules } = await boot();
|
|
261
|
-
const answering = modules.filter(
|
|
262
|
-
(m) => contributionNames(m, "routes").length + contributionNames(m, "grants").length > 0,
|
|
263
|
-
);
|
|
264
|
-
expect(answering.length).toBeGreaterThan(5);
|
|
265
|
-
for (const module of answering) {
|
|
266
|
-
expect([...(module.requires ?? []), ...(module.optional ?? [])], module.name).toContain(
|
|
267
|
-
"logger",
|
|
268
|
-
);
|
|
269
|
-
}
|
|
270
|
-
});
|
|
271
|
-
|
|
272
|
-
it("materializes every slot a module provides and another reads", async () => {
|
|
273
|
-
// A provider boot dropped would leave its readers on their absence
|
|
274
|
-
// branch; an unread one is never activated, so only read slots count.
|
|
275
|
-
const { modules, handle } = await boot();
|
|
276
|
-
const read = new Set(modules.flatMap((m) => [...(m.requires ?? []), ...(m.optional ?? [])]));
|
|
277
|
-
const provided = modules.flatMap((m) => Object.keys(m.provides ?? {}));
|
|
278
|
-
const expected = provided.filter((slot) => read.has(slot as never));
|
|
279
|
-
expect(expected.length).toBeGreaterThan(10);
|
|
280
|
-
const components = handle.components as Record<string, unknown>;
|
|
281
|
-
for (const slot of expected) expect(components[slot], slot).toBeDefined();
|
|
282
|
-
});
|
|
283
|
-
});
|
|
284
|
-
|
|
285
|
-
// ---------------------------------------------------------------------------
|
|
286
|
-
// Discovery
|
|
287
|
-
// ---------------------------------------------------------------------------
|
|
288
|
-
|
|
289
|
-
describe("discovery", () => {
|
|
290
|
-
it.each(DISCOVERY_PATHS)("%s is valid and advertises every enabled grant", async (path) => {
|
|
291
|
-
const { app } = await boot();
|
|
292
|
-
const res = await request(app).get(path);
|
|
293
|
-
expect(res.status).toBe(200);
|
|
294
|
-
expectValidMetadata(res.body);
|
|
295
|
-
expect([...res.body.grant_types_supported].sort()).toEqual(ENABLED_GRANTS);
|
|
296
|
-
expect(res.body.client_id_metadata_document_supported).toBe(true);
|
|
297
|
-
for (const endpoint of [
|
|
298
|
-
"userinfo_endpoint",
|
|
299
|
-
"introspection_endpoint",
|
|
300
|
-
"revocation_endpoint",
|
|
301
|
-
"end_session_endpoint",
|
|
302
|
-
]) {
|
|
303
|
-
expect(res.body[endpoint], endpoint).toBeDefined();
|
|
304
|
-
}
|
|
305
|
-
});
|
|
306
|
-
|
|
307
|
-
it("serves the same document at both well-known paths", async () => {
|
|
308
|
-
const { app } = await boot();
|
|
309
|
-
const [oidc, oauth] = await Promise.all(DISCOVERY_PATHS.map((p) => request(app).get(p)));
|
|
310
|
-
expect(oauth.body).toEqual(oidc.body);
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
it("advertises only endpoints the composed app answers", async () => {
|
|
314
|
-
const { app } = await boot();
|
|
315
|
-
const doc = (await request(app).get(DISCOVERY_PATHS[0])).body as Record<string, string>;
|
|
316
|
-
const advertised = Object.entries(doc).filter(
|
|
317
|
-
([field]) => field.endsWith("_endpoint") || field === "jwks_uri",
|
|
318
|
-
);
|
|
319
|
-
expect(advertised.length).toBeGreaterThanOrEqual(7);
|
|
320
|
-
for (const [field, url] of advertised) {
|
|
321
|
-
const { pathname } = new URL(url);
|
|
322
|
-
const get = await request(app).get(pathname);
|
|
323
|
-
const post = await request(app).post(pathname).type("form").send({});
|
|
324
|
-
// The module's own answer (a refusal is fine), never express's 404.
|
|
325
|
-
expect(
|
|
326
|
-
[get.status, post.status].some((status) => status !== 404),
|
|
327
|
-
field,
|
|
328
|
-
).toBe(true);
|
|
329
|
-
}
|
|
330
|
-
});
|
|
331
|
-
|
|
332
|
-
/**
|
|
333
|
-
* Each switch the template exposes, turned off alone: what it contributed
|
|
334
|
-
* is gone from the document and from the app, and nothing else moves.
|
|
335
|
-
*/
|
|
336
|
-
const GRANT_SWITCHES: ReadonlyArray<readonly [grant: string, variable: string]> = [
|
|
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"],
|
|
341
|
-
];
|
|
342
|
-
|
|
343
|
-
it.each(GRANT_SWITCHES)(
|
|
344
|
-
"%s off: absent from grant_types_supported and refused at the token endpoint",
|
|
345
|
-
async (grant, variable) => {
|
|
346
|
-
const { app } = await boot({ env: { ...SINGLE_ENV, [variable]: "false" } });
|
|
347
|
-
const doc = (await request(app).get(DISCOVERY_PATHS[0])).body;
|
|
348
|
-
expectValidMetadata(doc, { authorizationCode: grant !== "authorization_code" });
|
|
349
|
-
expect([...doc.grant_types_supported].sort()).toEqual(
|
|
350
|
-
ENABLED_GRANTS.filter((g) => g !== grant),
|
|
351
|
-
);
|
|
352
|
-
const res = await request(app)
|
|
353
|
-
.post("/oauth/token")
|
|
354
|
-
.set("Authorization", basic(M2M))
|
|
355
|
-
.type("form")
|
|
356
|
-
.send({ grant_type: grant });
|
|
357
|
-
expect(res.status).toBe(400);
|
|
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
|
-
}
|
|
363
|
-
},
|
|
364
|
-
);
|
|
365
|
-
|
|
366
|
-
it("Client ID Metadata Documents off: the flag is gone and nothing else moves", async () => {
|
|
367
|
-
const { app } = await boot({
|
|
368
|
-
env: { ...SINGLE_ENV, OAUTH_CLIENT_ID_METADATA_DOCUMENTS_ENABLED: "false" },
|
|
369
|
-
});
|
|
370
|
-
const doc = (await request(app).get(DISCOVERY_PATHS[0])).body;
|
|
371
|
-
expectValidMetadata(doc);
|
|
372
|
-
expect(doc.client_id_metadata_document_supported).toBeUndefined();
|
|
373
|
-
expect([...doc.grant_types_supported].sort()).toEqual(ENABLED_GRANTS);
|
|
374
|
-
});
|
|
375
|
-
|
|
376
|
-
it("no consent store: the flag is gone (it could not be completed) and /oauth/consent is not mounted", async () => {
|
|
377
|
-
// On, the consent route answers for itself: no session, `401 login_required`.
|
|
378
|
-
const on = await boot();
|
|
379
|
-
const mounted = await request(on.app).get("/oauth/consent");
|
|
380
|
-
expect(mounted.status).toBe(401);
|
|
381
|
-
expect(mounted.body.error).toBe("login_required");
|
|
382
|
-
await on.handle.dispose();
|
|
383
|
-
current = undefined;
|
|
384
|
-
|
|
385
|
-
const { app } = await boot({ env: { ...SINGLE_ENV, ADAPTERS_CONSENT_STORE: "none" } });
|
|
386
|
-
const doc = (await request(app).get(DISCOVERY_PATHS[0])).body;
|
|
387
|
-
expectValidMetadata(doc);
|
|
388
|
-
expect(doc.client_id_metadata_document_supported).toBeUndefined();
|
|
389
|
-
expect((await request(app).get("/oauth/consent")).status).toBe(404);
|
|
390
|
-
});
|
|
391
|
-
|
|
392
|
-
/** The grant connections dropped: they name the OIDC federation. */
|
|
393
|
-
const withoutConnections = (config: AppConfig): AppConfig =>
|
|
394
|
-
({
|
|
395
|
-
...config,
|
|
396
|
-
"federation-grants": {
|
|
397
|
-
...(config["federation-grants"] as Record<string, unknown>),
|
|
398
|
-
connections: {},
|
|
399
|
-
},
|
|
400
|
-
}) as AppConfig;
|
|
401
|
-
|
|
402
|
-
const FEATURE_SWITCHES: ReadonlyArray<
|
|
403
|
-
readonly [
|
|
404
|
-
feature: string,
|
|
405
|
-
variable: string,
|
|
406
|
-
path: string,
|
|
407
|
-
config: ((config: AppConfig) => AppConfig) | undefined,
|
|
408
|
-
]
|
|
409
|
-
> = [
|
|
410
|
-
["federation grants", "FEDERATION_GRANTS_ENABLED", "/oauth/federation-grants", undefined],
|
|
411
|
-
[
|
|
412
|
-
"the Google federation",
|
|
413
|
-
"CORE_FEDERATIONS_GOOGLE_ENABLED",
|
|
414
|
-
"/session/oauth/federation/google",
|
|
415
|
-
undefined,
|
|
416
|
-
],
|
|
417
|
-
[
|
|
418
|
-
"the OIDC federation",
|
|
419
|
-
"CORE_FEDERATIONS_OIDC_ENABLED",
|
|
420
|
-
"/session/oauth/federation/oidc",
|
|
421
|
-
withoutConnections,
|
|
422
|
-
],
|
|
423
|
-
];
|
|
424
|
-
|
|
425
|
-
it.each(FEATURE_SWITCHES)(
|
|
426
|
-
"%s off: its route answers 404 and the document does not change",
|
|
427
|
-
async (_feature, variable, path, config) => {
|
|
428
|
-
const on = await boot(config ? { config } : {});
|
|
429
|
-
const before = (await request(on.app).get(DISCOVERY_PATHS[0])).body;
|
|
430
|
-
await on.handle.dispose();
|
|
431
|
-
current = undefined;
|
|
432
|
-
|
|
433
|
-
const { app } = await boot({ env: { ...SINGLE_ENV, [variable]: "false" }, config });
|
|
434
|
-
const after = (await request(app).get(DISCOVERY_PATHS[0])).body;
|
|
435
|
-
expect(after).toEqual(before);
|
|
436
|
-
const res = path.startsWith("/oauth/")
|
|
437
|
-
? await request(app).post(path).set("Authorization", basic(WORKER)).send({})
|
|
438
|
-
: await request(app).get(path);
|
|
439
|
-
expect(res.status).toBe(404);
|
|
440
|
-
},
|
|
441
|
-
);
|
|
442
|
-
|
|
443
|
-
it("the OIDC federation off while a grant connection names it is refused at boot, naming both", async () => {
|
|
444
|
-
await expect(
|
|
445
|
-
compose({ env: { ...SINGLE_ENV, CORE_FEDERATIONS_OIDC_ENABLED: "false" } }),
|
|
446
|
-
).rejects.toSatisfy((err: unknown) => {
|
|
447
|
-
const e = err as { name?: string; cause?: { message?: string } };
|
|
448
|
-
return (
|
|
449
|
-
e.name === "BootError" &&
|
|
450
|
-
/connections\.calendar: federation "oidc" is configured but disabled/.test(
|
|
451
|
-
e.cause?.message ?? "",
|
|
452
|
-
)
|
|
453
|
-
);
|
|
454
|
-
});
|
|
455
|
-
});
|
|
456
|
-
});
|
|
457
|
-
|
|
458
|
-
// ---------------------------------------------------------------------------
|
|
459
|
-
// Routes
|
|
460
|
-
// ---------------------------------------------------------------------------
|
|
461
|
-
|
|
462
|
-
describe("every module's primary route answers in the one app", () => {
|
|
463
|
-
it("authorization code with PKCE, end to end, then refresh", async () => {
|
|
464
|
-
const { app } = await boot();
|
|
465
|
-
const { res: loggedIn, cookies } = await login(app);
|
|
466
|
-
expect(loggedIn.status).toBe(200);
|
|
467
|
-
const authorized = await authorize(app, cookies);
|
|
468
|
-
expect(authorized.status).toBe(302);
|
|
469
|
-
expect(authorized.headers.location).toMatch(new RegExp(`^${WEB.redirectUri}\\?code=`));
|
|
470
|
-
const tokens = await redeem(app, codeFrom(authorized));
|
|
471
|
-
expect(tokens.status).toBe(200);
|
|
472
|
-
expect(tokens.body).toMatchObject({ token_type: "Bearer" });
|
|
473
|
-
for (const field of ["access_token", "id_token", "refresh_token"]) {
|
|
474
|
-
expect(typeof tokens.body[field], field).toBe("string");
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
const refreshed = await request(app)
|
|
478
|
-
.post("/oauth/token")
|
|
479
|
-
.set("Authorization", basic(WEB))
|
|
480
|
-
.type("form")
|
|
481
|
-
.send({ grant_type: "refresh_token", refresh_token: tokens.body.refresh_token });
|
|
482
|
-
expect(refreshed.status).toBe(200);
|
|
483
|
-
expect(refreshed.body.refresh_token).not.toBe(tokens.body.refresh_token);
|
|
484
|
-
});
|
|
485
|
-
|
|
486
|
-
it("client_credentials", async () => {
|
|
487
|
-
const { app } = await boot();
|
|
488
|
-
const res = await request(app)
|
|
489
|
-
.post("/oauth/token")
|
|
490
|
-
.set("Authorization", basic(M2M))
|
|
491
|
-
.type("form")
|
|
492
|
-
.send({ grant_type: "client_credentials", scope: "api.read" });
|
|
493
|
-
expect(res.status).toBe(200);
|
|
494
|
-
expect(res.body).toMatchObject({ token_type: "Bearer", scope: "api.read" });
|
|
495
|
-
});
|
|
496
|
-
|
|
497
|
-
it("the session grant, from the browser session", async () => {
|
|
498
|
-
const { app } = await boot();
|
|
499
|
-
const { cookies } = await login(app);
|
|
500
|
-
const res = await request(app)
|
|
501
|
-
.post("/oauth/token")
|
|
502
|
-
.set("Authorization", basic(WEB))
|
|
503
|
-
.set("Cookie", cookies)
|
|
504
|
-
.type("form")
|
|
505
|
-
.send({ grant_type: "session", scope: "openid" });
|
|
506
|
-
expect(res.status).toBe(200);
|
|
507
|
-
expect(typeof res.body.access_token).toBe("string");
|
|
508
|
-
});
|
|
509
|
-
|
|
510
|
-
it("introspection, userinfo and revocation of one access token", async () => {
|
|
511
|
-
const { app } = await boot();
|
|
512
|
-
const { access_token } = await webTokens(app);
|
|
513
|
-
const introspect = () =>
|
|
514
|
-
request(app)
|
|
515
|
-
.post("/oauth/introspect")
|
|
516
|
-
.set("Authorization", basic(WEB))
|
|
517
|
-
.type("form")
|
|
518
|
-
.send({ token: access_token });
|
|
519
|
-
|
|
520
|
-
const active = await introspect();
|
|
521
|
-
expect(active.status).toBe(200);
|
|
522
|
-
expect(active.body).toMatchObject({ active: true, sub: ALICE.sub, client_id: WEB.id });
|
|
523
|
-
|
|
524
|
-
const userinfo = await request(app)
|
|
525
|
-
.get("/oauth/userinfo")
|
|
526
|
-
.set("Authorization", `Bearer ${access_token}`);
|
|
527
|
-
expect(userinfo.status).toBe(200);
|
|
528
|
-
expect(userinfo.body.sub).toBe(ALICE.sub);
|
|
529
|
-
|
|
530
|
-
const revoked = await request(app)
|
|
531
|
-
.post("/oauth/revoke")
|
|
532
|
-
.set("Authorization", basic(WEB))
|
|
533
|
-
.type("form")
|
|
534
|
-
.send({ token: access_token });
|
|
535
|
-
expect(revoked.status).toBe(200);
|
|
536
|
-
expect((await introspect()).body).toEqual({ active: false });
|
|
537
|
-
});
|
|
538
|
-
|
|
539
|
-
it("JWKS publishes the key the tokens are signed under", async () => {
|
|
540
|
-
const { app } = await boot();
|
|
541
|
-
const { access_token } = await webTokens(app);
|
|
542
|
-
const header = JSON.parse(
|
|
543
|
-
Buffer.from(access_token.split(".")[0] as string, "base64url").toString(),
|
|
544
|
-
) as { kid: string; alg: string };
|
|
545
|
-
const jwks = await request(app).get("/.well-known/jwks.json");
|
|
546
|
-
expect(jwks.status).toBe(200);
|
|
547
|
-
expect(jwks.body.keys).toEqual([
|
|
548
|
-
expect.objectContaining({ kid: header.kid, alg: header.alg, use: "sig" }),
|
|
549
|
-
]);
|
|
550
|
-
});
|
|
551
|
-
|
|
552
|
-
it("the consent step sends a client that is not first-party to the consent page", async () => {
|
|
553
|
-
const { app } = await boot();
|
|
554
|
-
const { cookies } = await login(app);
|
|
555
|
-
const res = await authorize(app, cookies, THIRD);
|
|
556
|
-
expect(res.status).toBe(302);
|
|
557
|
-
expect(res.headers.location).toMatch(/^\/consent\?challenge=/);
|
|
558
|
-
});
|
|
559
|
-
|
|
560
|
-
it.each([
|
|
561
|
-
["oidc", "the OIDC federation"],
|
|
562
|
-
["google", "the Google federation"],
|
|
563
|
-
] as const)(
|
|
564
|
-
"a login through %s (%s) ends in a session /authorize accepts",
|
|
565
|
-
async (name, _federation) => {
|
|
566
|
-
const { app, upstreams } = await boot();
|
|
567
|
-
const callback = await (await federatedCallback(app, name, upstreams[name]))();
|
|
568
|
-
expect(callback.status).toBe(302);
|
|
569
|
-
expect(callback.headers.location).toBe(FEDERATION_LANDING);
|
|
570
|
-
const authorized = await authorize(app, cookiesOf(callback));
|
|
571
|
-
expect(authorized.status).toBe(302);
|
|
572
|
-
expect(authorized.headers.location).toMatch(/\?code=/);
|
|
573
|
-
},
|
|
574
|
-
);
|
|
575
|
-
|
|
576
|
-
// KNOWN DEFECT (the template's federation config, with the session
|
|
577
|
-
// package's redirect policy): a federation enabled the way the template
|
|
578
|
-
// README documents — its `CORE_FEDERATIONS_OIDC_*` variables — boots, and every
|
|
579
|
-
// login through it then ends at the callback in `500 misconfiguration`
|
|
580
|
-
// "client URL not configured", after the user has signed in upstream.
|
|
581
|
-
// `clientUrl` has no environment form (`application.conf` ships it
|
|
582
|
-
// commented out) and nothing at boot asks for it. Either shape of fix
|
|
583
|
-
// passes: boot refusing the federation by the missing key, or the login
|
|
584
|
-
// completing.
|
|
585
|
-
knownDefect(
|
|
586
|
-
"a federation enabled from the documented variables alone either refuses to boot or completes a login",
|
|
587
|
-
async () => {
|
|
588
|
-
const withoutLanding = (config: AppConfig): AppConfig => {
|
|
589
|
-
const federations = federationsOf(config) as Record<string, Record<string, unknown>>;
|
|
590
|
-
const { clientUrl: _dropped, ...oidc } = federations.oidc ?? {};
|
|
591
|
-
return {
|
|
592
|
-
...config,
|
|
593
|
-
core: { ...config.core, federations: { ...federations, oidc } },
|
|
594
|
-
} as unknown as AppConfig;
|
|
595
|
-
};
|
|
596
|
-
let composed: Composition;
|
|
597
|
-
try {
|
|
598
|
-
composed = await boot({ config: withoutLanding });
|
|
599
|
-
} catch (err) {
|
|
600
|
-
// A refusal: a BootError that names the key.
|
|
601
|
-
const e = err as { name?: string; message?: string; cause?: { message?: string } };
|
|
602
|
-
expect(e.name).toBe("BootError");
|
|
603
|
-
expect(`${e.message ?? ""} ${e.cause?.message ?? ""}`).toMatch(/clientUrl/);
|
|
604
|
-
return;
|
|
605
|
-
}
|
|
606
|
-
// Or a completed login: the callback lands the browser somewhere, and
|
|
607
|
-
// the session it made is one /authorize accepts.
|
|
608
|
-
const callback = await (
|
|
609
|
-
await federatedCallback(composed.app, "oidc", composed.upstreams.oidc)
|
|
610
|
-
)();
|
|
611
|
-
expect(callback.status).toBe(302);
|
|
612
|
-
const authorized = await authorize(composed.app, cookiesOf(callback));
|
|
613
|
-
expect(authorized.status).toBe(302);
|
|
614
|
-
expect(authorized.headers.location).toMatch(/\?code=/);
|
|
615
|
-
},
|
|
616
|
-
);
|
|
617
|
-
|
|
618
|
-
it("a federation grant is lodged, beside oauthEndpointsModule under /oauth", async () => {
|
|
619
|
-
const { app } = await boot();
|
|
620
|
-
const res = await lodgeGrant(app);
|
|
621
|
-
expect(res.status).toBe(201);
|
|
622
|
-
expect(res.body).toMatchObject({ status: "pending" });
|
|
623
|
-
expect(res.body.connect_uri).toMatch(
|
|
624
|
-
new RegExp(`^${ISSUER}/session/federation-grants/connect\\?request=`),
|
|
625
|
-
);
|
|
626
|
-
});
|
|
627
|
-
});
|
|
628
|
-
|
|
629
|
-
// ---------------------------------------------------------------------------
|
|
630
|
-
// Bodies and content types
|
|
631
|
-
// ---------------------------------------------------------------------------
|
|
632
|
-
|
|
633
|
-
describe.each([AS_LISTED, REVERSED] satisfies ModuleOrder[])(
|
|
634
|
-
"bodies and content types, modules %s",
|
|
635
|
-
(order) => {
|
|
636
|
-
// One boot per order: nothing below changes what the next request meets.
|
|
637
|
-
let composed: Composition;
|
|
638
|
-
beforeAll(async () => {
|
|
639
|
-
composed = await compose({ order });
|
|
640
|
-
});
|
|
641
|
-
afterAll(async () => {
|
|
642
|
-
await composed.handle.dispose();
|
|
643
|
-
});
|
|
644
|
-
|
|
645
|
-
it("mounts in a different order from the other case, under /oauth and under /session", () => {
|
|
646
|
-
const ids = composed.handle.routes.map((r) => r.contribution.id);
|
|
647
|
-
for (const [neighbour, own] of [
|
|
648
|
-
["federation-grants", "oauth-endpoints"],
|
|
649
|
-
["federation-grants-browser", "session-routes"],
|
|
650
|
-
] as const) {
|
|
651
|
-
expect(ids, own).toContain(own);
|
|
652
|
-
expect(ids, neighbour).toContain(neighbour);
|
|
653
|
-
expect(ids.indexOf(neighbour) < ids.indexOf(own), `${neighbour} / ${own}`).toBe(
|
|
654
|
-
order === AS_LISTED,
|
|
655
|
-
);
|
|
656
|
-
}
|
|
657
|
-
});
|
|
658
|
-
|
|
659
|
-
it.each(TRANSFERS)(
|
|
660
|
-
"federation grants keep their 16 KiB bound beneath oauthEndpointsModule's /oauth, a body sent %s",
|
|
661
|
-
async (_transfer, send) => {
|
|
662
|
-
for (const type of [JSON_TYPE, FORM_TYPE]) {
|
|
663
|
-
const body =
|
|
664
|
-
type === JSON_TYPE ? padJson(40 * KIB) : padForm(40 * KIB, "sub=local-subject");
|
|
665
|
-
const res = await send(composed.app, "/oauth/federation-grants", type, body, {
|
|
666
|
-
authorization: basic(WORKER),
|
|
667
|
-
});
|
|
668
|
-
expect(res.status, type).toBe(413);
|
|
669
|
-
expect(res.body, type).toEqual({
|
|
670
|
-
error: "invalid_request",
|
|
671
|
-
error_description: "body_too_large",
|
|
672
|
-
});
|
|
673
|
-
}
|
|
674
|
-
},
|
|
675
|
-
);
|
|
676
|
-
|
|
677
|
-
it.each(TRANSFERS)(
|
|
678
|
-
"the grants consent route keeps its 16 KiB bound beneath sessionModule's /session, a body sent %s",
|
|
679
|
-
async (_transfer, send) => {
|
|
680
|
-
const res = await send(
|
|
681
|
-
composed.app,
|
|
682
|
-
"/session/federation-grants/consent",
|
|
683
|
-
JSON_TYPE,
|
|
684
|
-
padJson(40 * KIB),
|
|
685
|
-
);
|
|
686
|
-
expect(res.status).toBe(413);
|
|
687
|
-
expect(res.body).toEqual({ error: "invalid_request", error_description: "body_too_large" });
|
|
688
|
-
},
|
|
689
|
-
);
|
|
690
|
-
|
|
691
|
-
it.each(TRANSFERS)(
|
|
692
|
-
"the token endpoint parses past a neighbour's 16 KiB and stops at its own 100 KiB, a body sent %s",
|
|
693
|
-
async (_transfer, send) => {
|
|
694
|
-
const headers = { authorization: basic(M2M) };
|
|
695
|
-
const fields = "grant_type=client_credentials";
|
|
696
|
-
const within = await send(
|
|
697
|
-
composed.app,
|
|
698
|
-
"/oauth/token",
|
|
699
|
-
FORM_TYPE,
|
|
700
|
-
padForm(50 * KIB, fields),
|
|
701
|
-
headers,
|
|
702
|
-
);
|
|
703
|
-
// Parsed and handed to the grant, which refuses the missing scope.
|
|
704
|
-
expect(within.status).toBe(400);
|
|
705
|
-
expect(within.body.error).toBe("invalid_scope");
|
|
706
|
-
const over = await send(
|
|
707
|
-
composed.app,
|
|
708
|
-
"/oauth/token",
|
|
709
|
-
FORM_TYPE,
|
|
710
|
-
padForm(150 * KIB, fields),
|
|
711
|
-
headers,
|
|
712
|
-
);
|
|
713
|
-
expect(over.status).toBe(413);
|
|
714
|
-
expect(over.body).toEqual(TOO_LARGE);
|
|
715
|
-
},
|
|
716
|
-
);
|
|
717
|
-
|
|
718
|
-
it.each(TRANSFERS)(
|
|
719
|
-
"the login route parses past a neighbour's 16 KiB and stops at its own 100 KiB, a body sent %s",
|
|
720
|
-
async (_transfer, send) => {
|
|
721
|
-
const fields = `username=${ALICE.username}&password=x`;
|
|
722
|
-
const within = await send(
|
|
723
|
-
composed.app,
|
|
724
|
-
"/session/login",
|
|
725
|
-
FORM_TYPE,
|
|
726
|
-
padForm(50 * KIB, fields),
|
|
727
|
-
);
|
|
728
|
-
// Parsed and handed to the route, whose CSRF check refuses it.
|
|
729
|
-
expect(within.status).toBe(403);
|
|
730
|
-
expect(within.body.error).toBe("access_denied");
|
|
731
|
-
const over = await send(
|
|
732
|
-
composed.app,
|
|
733
|
-
"/session/login",
|
|
734
|
-
FORM_TYPE,
|
|
735
|
-
padForm(150 * KIB, fields),
|
|
736
|
-
);
|
|
737
|
-
expect(over.status).toBe(413);
|
|
738
|
-
expect(over.body).toEqual(TOO_LARGE);
|
|
739
|
-
},
|
|
740
|
-
);
|
|
741
|
-
|
|
742
|
-
it("each module answers a body it cannot read with its own refusal", async () => {
|
|
743
|
-
const { app } = composed;
|
|
744
|
-
const grants = { authorization: basic(WORKER) };
|
|
745
|
-
expect(
|
|
746
|
-
await withLength(app, "/oauth/federation-grants", "text/plain", "hello", grants),
|
|
747
|
-
).toEqual({
|
|
748
|
-
status: 415,
|
|
749
|
-
body: { error: "invalid_request", error_description: "unsupported_content_type" },
|
|
750
|
-
});
|
|
751
|
-
expect(await withLength(app, "/oauth/federation-grants", JSON_TYPE, "{nope", grants)).toEqual(
|
|
752
|
-
{
|
|
753
|
-
status: 400,
|
|
754
|
-
body: { error: "invalid_request", error_description: "malformed_body" },
|
|
755
|
-
},
|
|
756
|
-
);
|
|
757
|
-
expect(
|
|
758
|
-
await withLength(app, "/session/federation-grants/consent", JSON_TYPE, "{nope"),
|
|
759
|
-
).toEqual({
|
|
760
|
-
status: 400,
|
|
761
|
-
body: { error: "invalid_request", error_description: "malformed_body" },
|
|
762
|
-
});
|
|
763
|
-
const token = await withLength(app, "/oauth/token", JSON_TYPE, "{nope", {
|
|
764
|
-
authorization: basic(M2M),
|
|
765
|
-
});
|
|
766
|
-
expect(token.status).toBe(400);
|
|
767
|
-
expect(token.body.error).toBe("invalid_request");
|
|
768
|
-
});
|
|
769
|
-
},
|
|
770
|
-
);
|
|
771
|
-
|
|
772
|
-
describe("a request body the OAuth endpoints do not parse", () => {
|
|
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 —
|
|
777
|
-
// leaves it undefined, the handler throws, and the terminal handler answers
|
|
778
|
-
// `500 server_error` and logs `unhandled_request_error` for the client's
|
|
779
|
-
// own mistake. `/oauth/revoke` checks, and answers 400.
|
|
780
|
-
knownDefect.each([
|
|
781
|
-
["/oauth/token", "text/plain"],
|
|
782
|
-
["/oauth/token", "application/xml"],
|
|
783
|
-
["/oauth/introspect", "text/plain"],
|
|
784
|
-
])(
|
|
785
|
-
"%s with %s is the client's mistake: 400 invalid_request, nothing logged",
|
|
786
|
-
async (path, type) => {
|
|
787
|
-
const { app, logger } = await boot();
|
|
788
|
-
const res = await request(app)
|
|
789
|
-
.post(path)
|
|
790
|
-
.set("Authorization", basic(M2M))
|
|
791
|
-
.set("Content-Type", type)
|
|
792
|
-
.send("grant_type=client_credentials");
|
|
793
|
-
expect(res.status).toBe(400);
|
|
794
|
-
expect(res.body.error).toBe("invalid_request");
|
|
795
|
-
expect(logger.lines.filter((line) => line.level === "error")).toEqual([]);
|
|
796
|
-
},
|
|
797
|
-
);
|
|
798
|
-
});
|
|
799
|
-
|
|
800
|
-
// ---------------------------------------------------------------------------
|
|
801
|
-
// Outages
|
|
802
|
-
// ---------------------------------------------------------------------------
|
|
803
|
-
|
|
804
|
-
const tokenRequest = (app: express.Express, client: { id: string; secret: string }) =>
|
|
805
|
-
request(app).post("/oauth/token").set("Authorization", basic(client)).type("form");
|
|
806
|
-
|
|
807
|
-
/** The code exchange, with the store taken down just before the redeem. */
|
|
808
|
-
const codeExchange = async (app: express.Express, outage: Outage) => {
|
|
809
|
-
const { cookies } = await login(app);
|
|
810
|
-
const code = codeFrom(await authorize(app, cookies));
|
|
811
|
-
outage.down = true;
|
|
812
|
-
return redeem(app, code);
|
|
813
|
-
};
|
|
814
|
-
|
|
815
|
-
/** The refresh grant, with the store taken down just before the refresh. */
|
|
816
|
-
const refresh = async (app: express.Express, outage: Outage) => {
|
|
817
|
-
const { refresh_token } = await webTokens(app);
|
|
818
|
-
outage.down = true;
|
|
819
|
-
return tokenRequest(app, WEB).send({ grant_type: "refresh_token", refresh_token });
|
|
820
|
-
};
|
|
821
|
-
|
|
822
|
-
/** A login through the OIDC federation, with the store taken down just before the callback. */
|
|
823
|
-
const oidcCallback = async (app: express.Express, outage: Outage, c: Composition) => {
|
|
824
|
-
const callback = await federatedCallback(app, "oidc", c.upstreams.oidc);
|
|
825
|
-
outage.down = true;
|
|
826
|
-
return callback();
|
|
827
|
-
};
|
|
828
|
-
|
|
829
|
-
const VERIFIER_WARN =
|
|
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)";
|
|
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
|
-
|
|
842
|
-
const OUTAGES: readonly OutageCase[] = [
|
|
843
|
-
{
|
|
844
|
-
module: "oauth-authorization",
|
|
845
|
-
slot: "codeRepository",
|
|
846
|
-
surface: "the code exchange",
|
|
847
|
-
run: codeExchange,
|
|
848
|
-
answer: { status: 503, error: "temporarily_unavailable" },
|
|
849
|
-
event: "authorization_grant_store_unavailable",
|
|
850
|
-
},
|
|
851
|
-
{
|
|
852
|
-
module: "oauth",
|
|
853
|
-
slot: "codeRepository",
|
|
854
|
-
surface: "/oauth/authorize",
|
|
855
|
-
run: async (app, outage) => {
|
|
856
|
-
const { cookies } = await login(app);
|
|
857
|
-
outage.down = true;
|
|
858
|
-
return authorize(app, cookies);
|
|
859
|
-
},
|
|
860
|
-
answer: { redirect: WEB.redirectUri, error: "temporarily_unavailable" },
|
|
861
|
-
event: "authorize_store_unavailable",
|
|
862
|
-
},
|
|
863
|
-
{
|
|
864
|
-
module: "oauth-authorization",
|
|
865
|
-
slot: "refreshTokenFamilyStore",
|
|
866
|
-
surface: "the refresh grant",
|
|
867
|
-
run: refresh,
|
|
868
|
-
answer: { status: 503, error: "temporarily_unavailable" },
|
|
869
|
-
event: "refresh_token_store_unavailable",
|
|
870
|
-
unrelatedWarns: ["jwt_verify_aud_skipped"],
|
|
871
|
-
},
|
|
872
|
-
{
|
|
873
|
-
module: "oauth-authorization",
|
|
874
|
-
slot: "subjectRevocation",
|
|
875
|
-
surface: "the refresh grant",
|
|
876
|
-
run: refresh,
|
|
877
|
-
answer: { status: 503, error: "temporarily_unavailable" },
|
|
878
|
-
event: "token_verification_unavailable",
|
|
879
|
-
unrelatedWarns: ["jwt_verify_aud_skipped"],
|
|
880
|
-
defects: { "no-warn": VERIFIER_WARN },
|
|
881
|
-
},
|
|
882
|
-
{
|
|
883
|
-
module: "oauth",
|
|
884
|
-
slot: "accessTokenDenylist",
|
|
885
|
-
surface: "/oauth/revoke",
|
|
886
|
-
run: async (app, outage) => {
|
|
887
|
-
const { access_token } = await webTokens(app);
|
|
888
|
-
outage.down = true;
|
|
889
|
-
return request(app)
|
|
890
|
-
.post("/oauth/revoke")
|
|
891
|
-
.set("Authorization", basic(WEB))
|
|
892
|
-
.type("form")
|
|
893
|
-
.send({ token: access_token });
|
|
894
|
-
},
|
|
895
|
-
answer: { status: 503, error: "temporarily_unavailable" },
|
|
896
|
-
event: "revoke_store_unavailable",
|
|
897
|
-
},
|
|
898
|
-
{
|
|
899
|
-
module: "oauth",
|
|
900
|
-
slot: "accessTokenDenylist",
|
|
901
|
-
surface: "/oauth/introspect",
|
|
902
|
-
run: async (app, outage) => {
|
|
903
|
-
const { access_token } = await webTokens(app);
|
|
904
|
-
outage.down = true;
|
|
905
|
-
return request(app)
|
|
906
|
-
.post("/oauth/introspect")
|
|
907
|
-
.set("Authorization", basic(WEB))
|
|
908
|
-
.type("form")
|
|
909
|
-
.send({ token: access_token });
|
|
910
|
-
},
|
|
911
|
-
answer: { status: 503, error: "temporarily_unavailable" },
|
|
912
|
-
event: "token_verification_unavailable",
|
|
913
|
-
defects: { "no-warn": VERIFIER_WARN },
|
|
914
|
-
},
|
|
915
|
-
{
|
|
916
|
-
module: "oauth",
|
|
917
|
-
slot: "userSessionStore",
|
|
918
|
-
surface: "/oauth/userinfo",
|
|
919
|
-
run: async (app, outage) => {
|
|
920
|
-
const { access_token } = await webTokens(app);
|
|
921
|
-
outage.down = true;
|
|
922
|
-
return request(app).get("/oauth/userinfo").set("Authorization", `Bearer ${access_token}`);
|
|
923
|
-
},
|
|
924
|
-
answer: { status: 503, error: "temporarily_unavailable" },
|
|
925
|
-
event: "userinfo_store_unavailable",
|
|
926
|
-
unrelatedWarns: ["jwt_verify_aud_skipped"],
|
|
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
|
-
},
|
|
969
|
-
{
|
|
970
|
-
module: "oauth-session",
|
|
971
|
-
slot: "userSessionStore",
|
|
972
|
-
surface: "the session grant",
|
|
973
|
-
run: async (app, outage) => {
|
|
974
|
-
const { cookies } = await login(app);
|
|
975
|
-
outage.down = true;
|
|
976
|
-
return tokenRequest(app, WEB)
|
|
977
|
-
.set("Cookie", cookies)
|
|
978
|
-
.send({ grant_type: "session", scope: "openid" });
|
|
979
|
-
},
|
|
980
|
-
answer: { status: 503, error: "temporarily_unavailable" },
|
|
981
|
-
event: "session_admission_unavailable",
|
|
982
|
-
},
|
|
983
|
-
{
|
|
984
|
-
module: "oauth (consent step)",
|
|
985
|
-
slot: "consentStore",
|
|
986
|
-
surface: "/oauth/authorize for a client that is not first-party",
|
|
987
|
-
run: async (app, outage) => {
|
|
988
|
-
const { cookies } = await login(app);
|
|
989
|
-
outage.down = true;
|
|
990
|
-
return authorize(app, cookies, THIRD);
|
|
991
|
-
},
|
|
992
|
-
answer: { redirect: THIRD.redirectUri, error: "temporarily_unavailable" },
|
|
993
|
-
event: "authorize_consent_store_unavailable",
|
|
994
|
-
defects: {
|
|
995
|
-
"store-field":
|
|
996
|
-
"packages/oauth `routes/authorizeConsent.mts`: `authorize_consent_store_unavailable` carries `clientId` and `err` but no `store` / `step` / `site` field",
|
|
997
|
-
},
|
|
998
|
-
},
|
|
999
|
-
{
|
|
1000
|
-
module: "oauth (consent step)",
|
|
1001
|
-
slot: "pendingConsentStore",
|
|
1002
|
-
surface: "/oauth/authorize for a client that is not first-party",
|
|
1003
|
-
run: async (app, outage) => {
|
|
1004
|
-
const { cookies } = await login(app);
|
|
1005
|
-
outage.down = true;
|
|
1006
|
-
return authorize(app, cookies, THIRD);
|
|
1007
|
-
},
|
|
1008
|
-
answer: { redirect: THIRD.redirectUri, error: "temporarily_unavailable" },
|
|
1009
|
-
event: "authorize_pending_consent_store_unavailable",
|
|
1010
|
-
defects: {
|
|
1011
|
-
"store-field":
|
|
1012
|
-
"packages/oauth `routes/authorizeConsent.mts`: `authorize_pending_consent_store_unavailable` carries `clientId` and `err` but no `store` / `step` / `site` field",
|
|
1013
|
-
},
|
|
1014
|
-
},
|
|
1015
|
-
{
|
|
1016
|
-
module: "session",
|
|
1017
|
-
slot: "userSessionStore",
|
|
1018
|
-
surface: "/session/login",
|
|
1019
|
-
run: async (app, outage) => {
|
|
1020
|
-
outage.down = true;
|
|
1021
|
-
return (await login(app)).res;
|
|
1022
|
-
},
|
|
1023
|
-
answer: { status: 503, error: "temporarily_unavailable" },
|
|
1024
|
-
event: "login_store_unavailable",
|
|
1025
|
-
defects: { "no-warn": ROLLBACK_CLOSE_LOGS },
|
|
1026
|
-
},
|
|
1027
|
-
{
|
|
1028
|
-
module: "session",
|
|
1029
|
-
slot: "userRepository",
|
|
1030
|
-
surface: "/session/login",
|
|
1031
|
-
run: async (app, outage) => {
|
|
1032
|
-
outage.down = true;
|
|
1033
|
-
return (await login(app)).res;
|
|
1034
|
-
},
|
|
1035
|
-
answer: { status: 503, error: "temporarily_unavailable" },
|
|
1036
|
-
event: "login_store_unavailable",
|
|
1037
|
-
},
|
|
1038
|
-
{
|
|
1039
|
-
module: "oauth (client authentication)",
|
|
1040
|
-
slot: "clientRepository",
|
|
1041
|
-
surface: "/oauth/token",
|
|
1042
|
-
run: async (app, outage) => {
|
|
1043
|
-
outage.down = true;
|
|
1044
|
-
return tokenRequest(app, M2M).send({ grant_type: "client_credentials", scope: "api.read" });
|
|
1045
|
-
},
|
|
1046
|
-
answer: { status: 503, error: "temporarily_unavailable" },
|
|
1047
|
-
event: "client_repository_unavailable",
|
|
1048
|
-
},
|
|
1049
|
-
{
|
|
1050
|
-
module: "oauth",
|
|
1051
|
-
slot: "clientRepository",
|
|
1052
|
-
surface: "/oauth/authorize",
|
|
1053
|
-
run: async (app, outage) => {
|
|
1054
|
-
const { cookies } = await login(app);
|
|
1055
|
-
outage.down = true;
|
|
1056
|
-
return authorize(app, cookies);
|
|
1057
|
-
},
|
|
1058
|
-
// JSON, not a redirect: no redirect target is trusted before the client is known.
|
|
1059
|
-
answer: { status: 503, error: "temporarily_unavailable" },
|
|
1060
|
-
event: "client_repository_unavailable",
|
|
1061
|
-
},
|
|
1062
|
-
{
|
|
1063
|
-
module: "session",
|
|
1064
|
-
slot: "subjectSessionIndex",
|
|
1065
|
-
surface: "/session/login",
|
|
1066
|
-
run: async (app, outage) => {
|
|
1067
|
-
outage.down = true;
|
|
1068
|
-
return (await login(app)).res;
|
|
1069
|
-
},
|
|
1070
|
-
// Intended: the login is not denied for a best-effort index write,
|
|
1071
|
-
// and the missed write is logged at error because a token minted from
|
|
1072
|
-
// this session keeps introspecting active after a credential change —
|
|
1073
|
-
// the runbook pages on it.
|
|
1074
|
-
answer: { status: 200 },
|
|
1075
|
-
event: "subject_session_index_write_failed",
|
|
1076
|
-
storeFieldNotRequired:
|
|
1077
|
-
"the login succeeds; the line records a missed best-effort write, named by its event, with the subject and the session",
|
|
1078
|
-
},
|
|
1079
|
-
{
|
|
1080
|
-
module: "session",
|
|
1081
|
-
slot: "federationTokenStore",
|
|
1082
|
-
surface: "the OIDC federation callback",
|
|
1083
|
-
run: oidcCallback,
|
|
1084
|
-
answer: { status: 503, error: "temporarily_unavailable" },
|
|
1085
|
-
event: "federation_callback_store_unavailable",
|
|
1086
|
-
defects: { "no-warn": ROLLBACK_CLOSE_LOGS },
|
|
1087
|
-
},
|
|
1088
|
-
{
|
|
1089
|
-
module: "core (rate-limit guard)",
|
|
1090
|
-
slot: "rateLimiter",
|
|
1091
|
-
surface: "/oauth/token (redis-rate-limiter.failMode = closed)",
|
|
1092
|
-
run: async (app, outage) => {
|
|
1093
|
-
outage.down = true;
|
|
1094
|
-
return tokenRequest(app, M2M).send({ grant_type: "client_credentials", scope: "api.read" });
|
|
1095
|
-
},
|
|
1096
|
-
answer: { status: 503, error: "service_unavailable" },
|
|
1097
|
-
event: "rate_limiter_failed_closed",
|
|
1098
|
-
defects: {
|
|
1099
|
-
"store-field":
|
|
1100
|
-
"core's rate-limit guard (`packages/core/src/ratelimit/guard.mts`): `rate_limiter_failed_closed` names the limiter by `tag`, not `store` / `step` / `site`",
|
|
1101
|
-
projection:
|
|
1102
|
-
"core's rate-limit guard (`packages/core/src/ratelimit/guard.mts`): the limiter's error is flattened to a string under `error` (the projection's `detail` or `name`), not the projection under `err`",
|
|
1103
|
-
},
|
|
1104
|
-
},
|
|
1105
|
-
{
|
|
1106
|
-
module: "federation-grants",
|
|
1107
|
-
slot: "federationGrantStore",
|
|
1108
|
-
surface: "POST /oauth/federation-grants/:grantId/status",
|
|
1109
|
-
run: async (app, outage) => {
|
|
1110
|
-
const lodged = await lodgeGrant(app);
|
|
1111
|
-
outage.down = true;
|
|
1112
|
-
return request(app)
|
|
1113
|
-
.post(`/oauth/federation-grants/${lodged.body.grant_id as string}/status`)
|
|
1114
|
-
.set("Authorization", basic(WORKER))
|
|
1115
|
-
.send({ sub: ALICE.sub });
|
|
1116
|
-
},
|
|
1117
|
-
answer: { status: 503, error: "temporarily_unavailable" },
|
|
1118
|
-
// store "federation_grant", step "inspect", the store error's projection.
|
|
1119
|
-
event: "federation_grant_status_unavailable",
|
|
1120
|
-
},
|
|
1121
|
-
{
|
|
1122
|
-
module: "federation-grants",
|
|
1123
|
-
slot: "federationGrantIntentStore",
|
|
1124
|
-
surface: "POST /oauth/federation-grants (lodging)",
|
|
1125
|
-
run: async (app, outage) => {
|
|
1126
|
-
outage.down = true;
|
|
1127
|
-
return lodgeGrant(app);
|
|
1128
|
-
},
|
|
1129
|
-
answer: { status: 503, error: "temporarily_unavailable" },
|
|
1130
|
-
// store "federation_grant_intent", step "put_intent", the store error's projection.
|
|
1131
|
-
event: "federation_grant_lodge_unavailable",
|
|
1132
|
-
},
|
|
1133
|
-
{
|
|
1134
|
-
module: "jwks",
|
|
1135
|
-
slot: "keyStore",
|
|
1136
|
-
surface: "/.well-known/jwks.json",
|
|
1137
|
-
run: async (app, outage) => {
|
|
1138
|
-
outage.down = true;
|
|
1139
|
-
return request(app).get("/.well-known/jwks.json");
|
|
1140
|
-
},
|
|
1141
|
-
answer: { status: 503, error: "jwks_unavailable" },
|
|
1142
|
-
event: "jwks_unavailable",
|
|
1143
|
-
defects: {
|
|
1144
|
-
"store-field":
|
|
1145
|
-
"core's JWKS route (`packages/core/src/jwks/router.mts`): `jwks_unavailable` carries `algorithm` and `err` but no `store` / `step` / `site` field naming the key store",
|
|
1146
|
-
},
|
|
1147
|
-
},
|
|
1148
|
-
];
|
|
1149
|
-
|
|
1150
|
-
describeOutages(
|
|
1151
|
-
"a store or repository outage gets the answer its case pins, and is logged once, at error",
|
|
1152
|
-
OUTAGES,
|
|
1153
|
-
(outage) => compose({ outage }),
|
|
1154
|
-
);
|