@o3co/create-auth-provider 0.15.0 → 0.17.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +139 -48
- package/README.md +138 -47
- 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 +69 -24
- package/templates/standalone/Dockerfile +7 -3
- package/templates/standalone/README.ja.md +677 -184
- package/templates/standalone/README.md +699 -238
- package/templates/standalone/config/application.conf +203 -325
- package/templates/standalone/config/clients.yaml +2 -0
- package/templates/standalone/config/clients.yaml.example +4 -0
- package/templates/standalone/config/development.conf +13 -4
- package/templates/standalone/config/mailpit.conf +8 -0
- package/templates/standalone/config/production.conf +5 -6
- package/templates/standalone/config/reference.conf +399 -0
- package/templates/standalone/docker-compose.mailpit.yml +36 -0
- package/templates/standalone/docker-compose.production.yml +58 -30
- package/templates/standalone/docker-compose.yml +13 -3
- package/templates/standalone/gitignore +4 -2
- package/templates/standalone/package.json +9 -6
- package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +65 -32
- package/templates/standalone/src/__tests__/adapters.test.mts +358 -0
- package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +1178 -0
- package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +256 -0
- package/templates/standalone/src/__tests__/all-modules-composition.test.mts +1119 -0
- package/templates/standalone/src/__tests__/audit-sink.test.mts +78 -76
- package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
- package/templates/standalone/src/__tests__/core-federations.test.mts +226 -0
- package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
- package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +9 -14
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +672 -271
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +480 -78
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +102 -66
- package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +135 -0
- package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +49 -18
- package/templates/standalone/src/__tests__/google-session.fixture.mts +315 -0
- package/templates/standalone/src/__tests__/host-routes.test.mts +134 -0
- package/templates/standalone/src/__tests__/library-references.fixture.mts +183 -0
- package/templates/standalone/src/__tests__/listen.test.mts +121 -0
- package/templates/standalone/src/__tests__/logger-projection.test.mts +77 -0
- package/templates/standalone/src/__tests__/mail-sender.test.mts +134 -0
- package/templates/standalone/src/__tests__/mailpit-compose.test.mts +328 -0
- package/templates/standalone/src/__tests__/metrics.test.mts +17 -7
- package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
- package/templates/standalone/src/__tests__/mfa-switch.test.mts +780 -0
- package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +52 -46
- package/templates/standalone/src/__tests__/own-modules.test.mts +1457 -0
- package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +183 -0
- package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +135 -0
- package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +112 -81
- package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
- package/templates/standalone/src/__tests__/replica-safety.test.mts +166 -145
- package/templates/standalone/src/__tests__/router-error-answers.test.mts +146 -0
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +275 -95
- package/templates/standalone/src/__tests__/sections.test.mts +480 -0
- package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +67 -55
- package/templates/standalone/src/__tests__/session-requirements.test.mts +299 -0
- package/templates/standalone/src/__tests__/shutdown.test.mts +319 -115
- package/templates/standalone/src/__tests__/smoke.test.mts +179 -233
- package/templates/standalone/src/__tests__/store-credential.test.mts +317 -0
- package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +108 -95
- package/templates/standalone/src/__tests__/two-phase-config.test.mts +438 -0
- package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +390 -0
- package/templates/standalone/src/adapters.mts +149 -0
- package/templates/standalone/src/app.mts +71 -115
- package/templates/standalone/src/buildModules.mts +229 -251
- package/templates/standalone/src/configPath.mts +422 -29
- package/templates/standalone/src/listen.mts +60 -0
- package/templates/standalone/src/logger.mts +31 -57
- package/templates/standalone/src/metrics.mts +24 -47
- package/templates/standalone/src/mfaSwitch.mts +262 -0
- package/templates/standalone/src/modules.mts +480 -464
- package/templates/standalone/src/rootRenames.mts +79 -0
- package/templates/standalone/src/routes.mts +89 -0
- package/templates/standalone/src/sections.mts +366 -0
- package/templates/standalone/src/shutdown.mts +54 -126
- package/templates/standalone/tests/index.test.js +2 -2
- package/templates/standalone/tests/package-lock.json +1615 -2223
- package/templates/standalone/tests/package.json +2 -2
- package/templates/standalone/tests/vitest.config.mjs +26 -0
- package/templates/standalone/vitest.config.mts +8 -7
- package/templates/standalone/vitest.supertest-loopback.mts +97 -69
- package/templates/versions.json +18 -15
- package/templates/standalone/src/__tests__/terminal-error.test.mts +0 -96
- package/templates/standalone/src/terminalError.mts +0 -60
|
@@ -15,19 +15,19 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
|
-
*
|
|
18
|
+
* The standalone composes federation grants from its config.
|
|
19
19
|
*
|
|
20
20
|
* What is under test is the WIRING: which modules the switches select, that
|
|
21
21
|
* the shared Redis client module provides the two slots the Redis stores
|
|
22
22
|
* require, and that the refusals a wrong pairing earns come from the modules
|
|
23
23
|
* themselves. The flows a connection needs are the package's own tests; a
|
|
24
24
|
* connection here would need a live IdP, because the OIDC federation
|
|
25
|
-
* discovers at boot.
|
|
25
|
+
* discovers at boot. It also pins what the consent answer accepts under the
|
|
26
|
+
* session module's own CSRF guard, the one the standalone composes.
|
|
26
27
|
*/
|
|
27
28
|
|
|
28
29
|
import { fileURLToPath } from "node:url";
|
|
29
30
|
import {
|
|
30
|
-
type AppConfig,
|
|
31
31
|
AppConfigSchema,
|
|
32
32
|
createApp,
|
|
33
33
|
createKeyStoreFactory,
|
|
@@ -37,14 +37,22 @@ import {
|
|
|
37
37
|
memoryRefreshTokenFamilyStoreModule,
|
|
38
38
|
registerBuiltinKeyStores,
|
|
39
39
|
} from "@o3co/auth-provider-core";
|
|
40
|
+
import { standardSmtpMailSenderConfigForTests } from "@o3co/auth-provider-standard/testing";
|
|
40
41
|
import { parseFile } from "@o3co/ts.hocon";
|
|
41
42
|
import { validate } from "@o3co/ts.hocon/zod";
|
|
42
43
|
import express from "express";
|
|
43
44
|
import request from "supertest";
|
|
44
45
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
45
46
|
import { buildModules } from "../buildModules.mjs";
|
|
46
|
-
import { resolveConfigPaths,
|
|
47
|
-
import {
|
|
47
|
+
import { resolveConfigPaths, type Switches } from "../configPath.mjs";
|
|
48
|
+
import { templateReference } from "../modules.mjs";
|
|
49
|
+
import { installGracefulShutdown } from "../shutdown.mjs";
|
|
50
|
+
import {
|
|
51
|
+
capturedRenames,
|
|
52
|
+
libraryLayers,
|
|
53
|
+
rootSectionsOf,
|
|
54
|
+
sectionsCoreDoesNotDeclare,
|
|
55
|
+
} from "./library-references.fixture.mjs";
|
|
48
56
|
|
|
49
57
|
// The same stand-ins `replica-safety.test.mts` boots under: no socket opens,
|
|
50
58
|
// and the shared clients module's readiness probe gets its PONG.
|
|
@@ -101,44 +109,45 @@ vi.mock("ioredis", () => {
|
|
|
101
109
|
const configDir = fileURLToPath(new URL("../../config", import.meta.url));
|
|
102
110
|
const ENCRYPTION_KEY = Buffer.alloc(32, 7).toString("base64");
|
|
103
111
|
|
|
104
|
-
/** A single-replica deployment with every shared store on memory, grants off. */
|
|
112
|
+
/** A single-replica deployment with every shared store on memory, grants off, MFA off. */
|
|
105
113
|
const BASE_ENV: Readonly<Record<string, string>> = {
|
|
106
|
-
|
|
107
|
-
|
|
114
|
+
MFA_MODE: "off",
|
|
115
|
+
KEY_STORE_LOCAL_ALGORITHM: "HS256",
|
|
116
|
+
KEY_STORE_LOCAL_SECRET: "federation-grants-composition.at-least-32-bytes.ok",
|
|
108
117
|
OAUTH_JWT_ISSUER: "https://auth.test",
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
118
|
+
SESSION_STORE_SECRET: "federation-grants-composition-session.at-least-32-bytes.ok",
|
|
119
|
+
SESSION_STORE_SECURE: "false",
|
|
120
|
+
SESSION_STORE_NAME: "auth.session",
|
|
121
|
+
SESSION_STORE_STORAGE_TYPE: "memory",
|
|
122
|
+
ADAPTERS_USER_REPOSITORY: "yaml",
|
|
123
|
+
REDIS_CLIENTS_URL: "redis://redis.test:6379",
|
|
124
|
+
ADAPTERS_USER_SESSION_STORES: "memory",
|
|
125
|
+
ADAPTERS_RATE_LIMITER: "memory",
|
|
126
|
+
ADAPTERS_CODE_REPOSITORY: "memory",
|
|
127
|
+
ADAPTERS_ACCESS_TOKEN_DENYLIST: "memory",
|
|
128
|
+
ADAPTERS_REPLAY_SEEN_SET: "memory",
|
|
129
|
+
ADAPTERS_FEDERATION_TOKEN_STORE: "memory",
|
|
130
|
+
ADAPTERS_CONSENT_STORE: "none",
|
|
131
|
+
ADAPTERS_FEDERATION_GRANT_STORE: "memory",
|
|
132
|
+
ADAPTERS_FEDERATION_GRANT_INTENT_STORE: "memory",
|
|
124
133
|
};
|
|
125
134
|
|
|
126
135
|
/** The umbrella E2E's shape: every shared store on Redis, more than one replica. */
|
|
127
136
|
const ALL_REDIS_ENV: Readonly<Record<string, string>> = {
|
|
128
137
|
...BASE_ENV,
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
+
CORE_DEPLOYMENT_MODE: "multi",
|
|
139
|
+
SESSION_STORE_STORAGE_TYPE: "redis",
|
|
140
|
+
SESSION_STORE_STORAGE_REDIS_URL: "redis://redis.test:6379",
|
|
141
|
+
ADAPTERS_USER_SESSION_STORES: "redis",
|
|
142
|
+
ADAPTERS_RATE_LIMITER: "redis",
|
|
143
|
+
ADAPTERS_CODE_REPOSITORY: "redis",
|
|
144
|
+
ADAPTERS_ACCESS_TOKEN_DENYLIST: "redis",
|
|
145
|
+
ADAPTERS_REPLAY_SEEN_SET: "redis",
|
|
146
|
+
ADAPTERS_FEDERATION_TOKEN_STORE: "redis",
|
|
138
147
|
REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY: ENCRYPTION_KEY,
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
148
|
+
ADAPTERS_CONSENT_STORE: "redis",
|
|
149
|
+
ADAPTERS_FEDERATION_GRANT_STORE: "redis",
|
|
150
|
+
ADAPTERS_FEDERATION_GRANT_INTENT_STORE: "redis",
|
|
142
151
|
};
|
|
143
152
|
|
|
144
153
|
/** What enabling the feature adds: the switch, and the page boot requires. */
|
|
@@ -147,23 +156,29 @@ const GRANTS_ON: Readonly<Record<string, string>> = {
|
|
|
147
156
|
FEDERATION_GRANTS_CONSENT_URL: "/consent/grants",
|
|
148
157
|
};
|
|
149
158
|
|
|
150
|
-
function resolveConfig(env: Record<string, string>):
|
|
159
|
+
function resolveConfig(env: Record<string, string>): Switches {
|
|
151
160
|
const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
|
|
152
|
-
const
|
|
153
|
-
parseFile(
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
161
|
+
const layers = parseFile(envConfPath, { env })
|
|
162
|
+
.withFallback(parseFile(applicationConfPath, { env }))
|
|
163
|
+
.withFallback(parseFile(fileURLToPath(templateReference()), { env }))
|
|
164
|
+
.withFallback(libraryLayers(env));
|
|
165
|
+
const config = {
|
|
166
|
+
...sectionsCoreDoesNotDeclare(layers),
|
|
167
|
+
...validate(layers, AppConfigSchema),
|
|
168
|
+
...rootSectionsOf(layers, env),
|
|
169
|
+
};
|
|
158
170
|
// The key ring has no environment form (a list of { id, key } is HOCON's);
|
|
159
171
|
// the Redis grant store refuses to construct without one under "required".
|
|
160
172
|
return {
|
|
161
173
|
...config,
|
|
162
|
-
|
|
163
|
-
|
|
174
|
+
// What the resolution captured of core's renamed variables, which the
|
|
175
|
+
// schema's parse drops.
|
|
176
|
+
"renamed-variables": capturedRenames(env),
|
|
177
|
+
"redis-federation-grant-store": {
|
|
178
|
+
...(config["redis-federation-grant-store"] as object | undefined),
|
|
164
179
|
encryptionKeys: [{ id: "k-test", key: ENCRYPTION_KEY }],
|
|
165
180
|
},
|
|
166
|
-
} as
|
|
181
|
+
} as Switches;
|
|
167
182
|
}
|
|
168
183
|
|
|
169
184
|
const testRepositoriesModule = defineModule({
|
|
@@ -183,13 +198,13 @@ const testKeyStoreModule = defineModule({
|
|
|
183
198
|
registerBuiltinKeyStores(factory);
|
|
184
199
|
return factory.create({
|
|
185
200
|
type: "local",
|
|
186
|
-
...((c as
|
|
201
|
+
...((c as { "key-store"?: { local?: object } })["key-store"]?.local ?? {}),
|
|
187
202
|
});
|
|
188
203
|
},
|
|
189
204
|
},
|
|
190
205
|
});
|
|
191
206
|
|
|
192
|
-
const modulesFor = (config:
|
|
207
|
+
const modulesFor = (config: Switches, memoryOnly = false, environment?: string) =>
|
|
193
208
|
buildModules(config, {
|
|
194
209
|
keyStoreModule: testKeyStoreModule,
|
|
195
210
|
repositoriesModule: testRepositoriesModule,
|
|
@@ -197,15 +212,65 @@ const modulesFor = (config: AppConfig, memoryOnly = false, environment?: string)
|
|
|
197
212
|
...(environment === undefined ? {} : { environment }),
|
|
198
213
|
});
|
|
199
214
|
|
|
200
|
-
const boot = (config:
|
|
215
|
+
const boot = (config: Switches, memoryOnly = false, environment?: string) =>
|
|
201
216
|
createApp({
|
|
202
217
|
modules: modulesFor(config, memoryOnly, environment),
|
|
203
218
|
bootstrapComponents: { config, pathResolver: (s) => s },
|
|
204
219
|
});
|
|
205
220
|
|
|
206
|
-
const names = (config:
|
|
221
|
+
const names = (config: Switches, memoryOnly = false) =>
|
|
207
222
|
modulesFor(config, memoryOnly).map((m) => m.name);
|
|
208
223
|
|
|
224
|
+
/**
|
|
225
|
+
* `app.mts`'s shutdown over `handle`, with a server double whose drain ends at
|
|
226
|
+
* once and signals that are not the process's: the handle's own dispose and
|
|
227
|
+
* the allowance it reports.
|
|
228
|
+
*/
|
|
229
|
+
function shutDown(handle: Awaited<ReturnType<typeof boot>>) {
|
|
230
|
+
const logger = {
|
|
231
|
+
trace: vi.fn(),
|
|
232
|
+
debug: vi.fn(),
|
|
233
|
+
info: vi.fn(),
|
|
234
|
+
warn: vi.fn(),
|
|
235
|
+
error: vi.fn(),
|
|
236
|
+
fatal: vi.fn(),
|
|
237
|
+
child: vi.fn(),
|
|
238
|
+
};
|
|
239
|
+
const exit = vi.fn();
|
|
240
|
+
const signals = new Map<string, () => void>();
|
|
241
|
+
const server = {
|
|
242
|
+
close: (callback?: (err?: Error) => void) => {
|
|
243
|
+
callback?.();
|
|
244
|
+
return server;
|
|
245
|
+
},
|
|
246
|
+
closeIdleConnections: () => undefined,
|
|
247
|
+
closeAllConnections: () => undefined,
|
|
248
|
+
};
|
|
249
|
+
installGracefulShutdown(server as never, {
|
|
250
|
+
logger: logger as never,
|
|
251
|
+
cleanup: () => handle.dispose(),
|
|
252
|
+
cleanupAllowanceMs: () => handle.cleanupAllowanceMs,
|
|
253
|
+
exit,
|
|
254
|
+
onSignal: (name, handler) => signals.set(name, handler),
|
|
255
|
+
offSignal: (name) => signals.delete(name),
|
|
256
|
+
});
|
|
257
|
+
return { logger, exit, sigterm: () => signals.get("SIGTERM")?.() };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Work the background registry holds open, as a refresh tail does, until the
|
|
262
|
+
* returned release: the drain waits for it.
|
|
263
|
+
*/
|
|
264
|
+
const holdTheDrainOpen = (handle: Awaited<ReturnType<typeof boot>>): (() => void) => {
|
|
265
|
+
let release!: () => void;
|
|
266
|
+
handle.components.federationGrantBackground?.register(
|
|
267
|
+
new Promise<void>((resolve) => {
|
|
268
|
+
release = () => resolve();
|
|
269
|
+
}),
|
|
270
|
+
);
|
|
271
|
+
return release;
|
|
272
|
+
};
|
|
273
|
+
|
|
209
274
|
const GRANT_MODULES = [
|
|
210
275
|
"federation-grants",
|
|
211
276
|
"federation-grant-background",
|
|
@@ -222,7 +287,7 @@ const messageChain = (err: unknown): string => {
|
|
|
222
287
|
return `${e.message ?? ""} ${e.cause?.message ?? ""}`;
|
|
223
288
|
};
|
|
224
289
|
|
|
225
|
-
describe("
|
|
290
|
+
describe("the standalone composes federation grants from its config", () => {
|
|
226
291
|
let handleRef: Awaited<ReturnType<typeof boot>> | undefined;
|
|
227
292
|
|
|
228
293
|
afterEach(async () => {
|
|
@@ -233,10 +298,10 @@ describe("#593 slice 7: the standalone composes federation grants from its confi
|
|
|
233
298
|
it("installs nothing of the feature while it is off, whatever the switches say", async () => {
|
|
234
299
|
// Off is the default, and off must cost nothing: no store, no socket, no
|
|
235
300
|
// boot requirement a deployment that never asked for grants would meet.
|
|
236
|
-
const config = resolveConfig({ ...BASE_ENV,
|
|
301
|
+
const config = resolveConfig({ ...BASE_ENV, ADAPTERS_FEDERATION_GRANT_STORE: "redis" });
|
|
237
302
|
const installed = names(config, true);
|
|
238
303
|
for (const name of GRANT_MODULES) expect(installed).not.toContain(name);
|
|
239
|
-
expect(installed).not.toContain("
|
|
304
|
+
expect(installed).not.toContain("redis-clients");
|
|
240
305
|
|
|
241
306
|
handleRef = await boot(config, true);
|
|
242
307
|
const app = express().use(handleRef.router);
|
|
@@ -257,8 +322,9 @@ describe("#593 slice 7: the standalone composes federation grants from its confi
|
|
|
257
322
|
);
|
|
258
323
|
expect(installed).not.toContain("redis-federation-grant-store");
|
|
259
324
|
expect(installed).not.toContain("redis-federation-grant-intent-store");
|
|
260
|
-
//
|
|
261
|
-
//
|
|
325
|
+
// Listed ahead of oauthModule, as the template writes them: the
|
|
326
|
+
// template's choice, since each module parses its own bodies and the
|
|
327
|
+
// order does not change that; the browser half sits after the session
|
|
262
328
|
// middleware by its own `after`.
|
|
263
329
|
expect(installed.indexOf("federation-grants")).toBeLessThan(installed.indexOf("oauth"));
|
|
264
330
|
|
|
@@ -268,6 +334,14 @@ describe("#593 slice 7: the standalone composes federation grants from its confi
|
|
|
268
334
|
// not express's 404.
|
|
269
335
|
expect((await request(app).post("/oauth/federation-grants/g/status")).status).toBe(401);
|
|
270
336
|
expect(handleRef.components.subjectRevocationService).toBeDefined();
|
|
337
|
+
// The connect flow's login trip is the session module's loginEntry,
|
|
338
|
+
// built from the configured login page: the URL connect sends
|
|
339
|
+
// a browser that is not signed in to.
|
|
340
|
+
const page = (config.session as { loginPage: { url: string } }).loginPage.url;
|
|
341
|
+
const back = "https://auth.example/session/federation-grants/connect?request=h";
|
|
342
|
+
expect(handleRef.components.loginEntry?.urlFor(back)).toBe(
|
|
343
|
+
`${page}${page.includes("?") ? "&" : "?"}redirect_to=${encodeURIComponent(back)}`,
|
|
344
|
+
);
|
|
271
345
|
});
|
|
272
346
|
|
|
273
347
|
it("selects the Redis stores under the all-Redis environment, provides their clients, and boots under multi", async () => {
|
|
@@ -282,7 +356,7 @@ describe("#593 slice 7: the standalone composes federation grants from its confi
|
|
|
282
356
|
);
|
|
283
357
|
expect(installed).not.toContain("core-federation-grant-store-memory");
|
|
284
358
|
expect(installed).not.toContain("core-federation-grant-intent-store-memory");
|
|
285
|
-
const clients = modules.find((m) => m.name === "
|
|
359
|
+
const clients = modules.find((m) => m.name === "redis-clients");
|
|
286
360
|
expect(Object.keys(clients?.provides ?? {})).toEqual(
|
|
287
361
|
expect.arrayContaining(["federationGrantStoreClient", "federationGrantIntentStoreClient"]),
|
|
288
362
|
);
|
|
@@ -291,13 +365,83 @@ describe("#593 slice 7: the standalone composes federation grants from its confi
|
|
|
291
365
|
expect(handleRef.components.federationGrantStore?.kind).toBe("redis");
|
|
292
366
|
});
|
|
293
367
|
|
|
294
|
-
it("gives cleanup the documented 45 seconds under the shipped budgets,
|
|
295
|
-
//
|
|
296
|
-
|
|
297
|
-
expect(
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
368
|
+
it("gives cleanup the documented 45 seconds under the shipped budgets, not the drain's ten", async () => {
|
|
369
|
+
// reference.conf's budgets: 25 s + 3 s + 5 s, plus the 12-second margin.
|
|
370
|
+
handleRef = await boot(resolveConfig({ ...BASE_ENV, ...GRANTS_ON }), true);
|
|
371
|
+
expect(handleRef.cleanupAllowanceMs).toBe(45_000);
|
|
372
|
+
|
|
373
|
+
vi.useFakeTimers();
|
|
374
|
+
const release = holdTheDrainOpen(handleRef);
|
|
375
|
+
try {
|
|
376
|
+
const { logger, exit, sigterm } = shutDown(handleRef);
|
|
377
|
+
sigterm();
|
|
378
|
+
await vi.advanceTimersByTimeAsync(44_999);
|
|
379
|
+
expect(exit).not.toHaveBeenCalled();
|
|
380
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
381
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
382
|
+
{ cleanupTimeoutMs: 45_000 },
|
|
383
|
+
"shutdown_cleanup_timed_out",
|
|
384
|
+
);
|
|
385
|
+
expect(exit).toHaveBeenCalledExactlyOnceWith(1);
|
|
386
|
+
} finally {
|
|
387
|
+
release();
|
|
388
|
+
vi.useRealTimers();
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it("grows the allowance with a raised refresh budget", async () => {
|
|
393
|
+
const shipped = resolveConfig({ ...BASE_ENV, ...GRANTS_ON });
|
|
394
|
+
// The lock must outlive the raised hard timeout, or boot refuses first.
|
|
395
|
+
const raised = {
|
|
396
|
+
...shipped,
|
|
397
|
+
"federation-grants": {
|
|
398
|
+
...shipped["federation-grants"],
|
|
399
|
+
upstreamHardTimeoutMs: 60_000,
|
|
400
|
+
refreshLockTtlMs: 65_000,
|
|
401
|
+
},
|
|
402
|
+
} as Switches;
|
|
403
|
+
handleRef = await boot(raised, true);
|
|
404
|
+
expect(handleRef.cleanupAllowanceMs).toBe(60_000 + 3_000 + 5_000 + 12_000);
|
|
405
|
+
|
|
406
|
+
vi.useFakeTimers();
|
|
407
|
+
const release = holdTheDrainOpen(handleRef);
|
|
408
|
+
try {
|
|
409
|
+
const { logger, exit, sigterm } = shutDown(handleRef);
|
|
410
|
+
sigterm();
|
|
411
|
+
await vi.advanceTimersByTimeAsync(79_999);
|
|
412
|
+
expect(exit).not.toHaveBeenCalled();
|
|
413
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
414
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
415
|
+
{ cleanupTimeoutMs: 80_000 },
|
|
416
|
+
"shutdown_cleanup_timed_out",
|
|
417
|
+
);
|
|
418
|
+
} finally {
|
|
419
|
+
release();
|
|
420
|
+
vi.useRealTimers();
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it("reports no allowance while the feature is off, and cleanup's budget is the drain's ten seconds", async () => {
|
|
425
|
+
handleRef = await boot(resolveConfig(BASE_ENV), true);
|
|
426
|
+
expect(handleRef.cleanupAllowanceMs).toBeUndefined();
|
|
427
|
+
const { logger, exit, sigterm } = shutDown(handleRef);
|
|
428
|
+
sigterm();
|
|
429
|
+
await vi.waitFor(() => expect(exit).toHaveBeenCalledExactlyOnceWith(0));
|
|
430
|
+
expect(logger.info).toHaveBeenCalledWith(
|
|
431
|
+
{ drainTimeoutMs: 10_000, cleanupTimeoutMs: 10_000 },
|
|
432
|
+
"shutdown_draining",
|
|
433
|
+
);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it("covers a tail registered after the shutdown was installed", async () => {
|
|
437
|
+
handleRef = await boot(resolveConfig(BASE_ENV), true);
|
|
438
|
+
const { logger, sigterm } = shutDown(handleRef);
|
|
439
|
+
handleRef.components.lifecycleRegistrar?.register(async () => {}, { tailMs: 60_000 });
|
|
440
|
+
sigterm();
|
|
441
|
+
expect(logger.info).toHaveBeenCalledWith(
|
|
442
|
+
{ drainTimeoutMs: 10_000, cleanupTimeoutMs: 60_000 },
|
|
443
|
+
"shutdown_draining",
|
|
444
|
+
);
|
|
301
445
|
});
|
|
302
446
|
|
|
303
447
|
it("adds the shared Redis client for the grant stores alone", () => {
|
|
@@ -306,15 +450,15 @@ describe("#593 slice 7: the standalone composes federation grants from its confi
|
|
|
306
450
|
const config = resolveConfig({
|
|
307
451
|
...BASE_ENV,
|
|
308
452
|
...GRANTS_ON,
|
|
309
|
-
|
|
310
|
-
|
|
453
|
+
ADAPTERS_FEDERATION_GRANT_STORE: "redis",
|
|
454
|
+
ADAPTERS_FEDERATION_GRANT_INTENT_STORE: "redis",
|
|
311
455
|
});
|
|
312
|
-
expect(names(config, true)).toContain("
|
|
456
|
+
expect(names(config, true)).toContain("redis-clients");
|
|
313
457
|
});
|
|
314
458
|
|
|
315
459
|
it.each([
|
|
316
|
-
["
|
|
317
|
-
["
|
|
460
|
+
["ADAPTERS_FEDERATION_GRANT_STORE", "core-federation-grant-store-memory"],
|
|
461
|
+
["ADAPTERS_FEDERATION_GRANT_INTENT_STORE", "core-federation-grant-intent-store-memory"],
|
|
318
462
|
])("%s=memory is refused under multi, naming %s", async (variable, module) => {
|
|
319
463
|
const config = resolveConfig({ ...ALL_REDIS_ENV, ...GRANTS_ON, [variable]: "memory" });
|
|
320
464
|
await expect(boot(config)).rejects.toMatchObject({
|
|
@@ -324,15 +468,87 @@ describe("#593 slice 7: the standalone composes federation grants from its confi
|
|
|
324
468
|
});
|
|
325
469
|
});
|
|
326
470
|
|
|
327
|
-
it(
|
|
471
|
+
it.each([
|
|
472
|
+
[
|
|
473
|
+
"a __Secure- name that is not secure",
|
|
474
|
+
"name",
|
|
475
|
+
{ SESSION_STORE_NAME: "__Secure-auth.session", SESSION_STORE_SECURE: "false" },
|
|
476
|
+
/__Secure- prefix requires session-store\.secure=true/,
|
|
477
|
+
],
|
|
478
|
+
[
|
|
479
|
+
"a name that is not an RFC 6265 token",
|
|
480
|
+
"name",
|
|
481
|
+
{ SESSION_STORE_NAME: "auth session" },
|
|
482
|
+
/is not a cookie name \(an RFC 6265 token\)/,
|
|
483
|
+
],
|
|
484
|
+
[
|
|
485
|
+
"a domain that is a URL",
|
|
486
|
+
"domain",
|
|
487
|
+
{ SESSION_STORE_NAME: "auth.session", SESSION_STORE_DOMAIN: "https://auth.example.com" },
|
|
488
|
+
/is not a cookie domain/,
|
|
489
|
+
],
|
|
490
|
+
])(
|
|
491
|
+
"refuses at validation a session cookie no browser keeps, %s, naming session-store.%s, with or without the subject revocation service",
|
|
492
|
+
async (_what, key, cookie, refusal) => {
|
|
493
|
+
for (const grants of [{}, GRANTS_ON]) {
|
|
494
|
+
const caught = await boot(resolveConfig({ ...BASE_ENV, ...grants, ...cookie }), true).then(
|
|
495
|
+
async (handle) => {
|
|
496
|
+
await handle.dispose();
|
|
497
|
+
return undefined;
|
|
498
|
+
},
|
|
499
|
+
(err: unknown) => err,
|
|
500
|
+
);
|
|
501
|
+
expect(caught).toMatchObject({
|
|
502
|
+
name: "BootError",
|
|
503
|
+
reason: "config-validation-failed",
|
|
504
|
+
stage: "validateManifests",
|
|
505
|
+
details: {
|
|
506
|
+
issues: [expect.objectContaining({ path: ["session-store", key] })],
|
|
507
|
+
},
|
|
508
|
+
});
|
|
509
|
+
expect(messageChain(caught)).toMatch(refusal);
|
|
510
|
+
}
|
|
511
|
+
},
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
it.each([
|
|
515
|
+
// `config/application.conf`'s own cookie: `__Host-`, secure, host-only.
|
|
516
|
+
["the template's default", { SESSION_STORE_NAME: undefined, SESSION_STORE_SECURE: undefined }],
|
|
517
|
+
// `.env.example`'s plain-HTTP pair, which `make dev` runs with.
|
|
518
|
+
[".env.example's", { SESSION_STORE_NAME: "auth.sid", SESSION_STORE_SECURE: "false" }],
|
|
519
|
+
// `docker-compose.production.yml` restores the default name, secure.
|
|
520
|
+
[
|
|
521
|
+
"the production compose file's",
|
|
522
|
+
{ SESSION_STORE_NAME: "__Host-auth.session", SESSION_STORE_SECURE: "true" },
|
|
523
|
+
],
|
|
524
|
+
// o3co/auth's `tests/docker-compose.yml`, which the umbrella E2E boots.
|
|
525
|
+
["the umbrella E2E's", { SESSION_STORE_NAME: "auth.session", SESSION_STORE_SECURE: "false" }],
|
|
526
|
+
])(
|
|
527
|
+
"boots %s session cookie, with or without the subject revocation service",
|
|
528
|
+
async (_what, cookie) => {
|
|
529
|
+
const env: Record<string, string> = { ...BASE_ENV };
|
|
530
|
+
for (const [name, value] of Object.entries(cookie)) {
|
|
531
|
+
if (value === undefined) delete env[name];
|
|
532
|
+
else env[name] = value;
|
|
533
|
+
}
|
|
534
|
+
for (const grants of [{}, GRANTS_ON]) {
|
|
535
|
+
handleRef = await boot(resolveConfig({ ...env, ...grants }), true);
|
|
536
|
+
expect(handleRef.routes.map((r) => r.contribution.id)).toContain("session-middleware");
|
|
537
|
+
await handleRef.dispose();
|
|
538
|
+
handleRef = undefined;
|
|
539
|
+
}
|
|
540
|
+
},
|
|
541
|
+
);
|
|
542
|
+
|
|
543
|
+
it("refuses Redis grants beside memory user-session stores, naming the boundary", async () => {
|
|
328
544
|
// The grants would outlive the process; the boundary that ends them
|
|
329
545
|
// would not. The routes module refuses the pairing on a single replica
|
|
330
546
|
// too, and its message names the remedy.
|
|
331
547
|
const config = resolveConfig({
|
|
332
548
|
...BASE_ENV,
|
|
333
549
|
...GRANTS_ON,
|
|
334
|
-
|
|
335
|
-
|
|
550
|
+
ADAPTERS_FEDERATION_GRANT_STORE: "redis",
|
|
551
|
+
ADAPTERS_FEDERATION_GRANT_INTENT_STORE: "memory",
|
|
336
552
|
});
|
|
337
553
|
let error: unknown;
|
|
338
554
|
try {
|
|
@@ -343,7 +559,7 @@ describe("#593 slice 7: the standalone composes federation grants from its confi
|
|
|
343
559
|
expect(messageChain(error)).toMatch(/subject boundary is kept in "memory"/);
|
|
344
560
|
});
|
|
345
561
|
|
|
346
|
-
it("hands the Redis grant store the environment the config was selected by, so its plaintext guard reads it
|
|
562
|
+
it("hands the Redis grant store the environment the config was selected by, so its plaintext guard reads it", async () => {
|
|
347
563
|
// The federation-token store's guard reads `environment` beside
|
|
348
564
|
// NODE_ENV so that CONFIG_ENV=production is production to it; the grant
|
|
349
565
|
// store's must too, or a deployment selecting its config by CONFIG_ENV
|
|
@@ -351,13 +567,19 @@ describe("#593 slice 7: the standalone composes federation grants from its confi
|
|
|
351
567
|
const config = resolveConfig({
|
|
352
568
|
...BASE_ENV,
|
|
353
569
|
...GRANTS_ON,
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
570
|
+
ADAPTERS_USER_SESSION_STORES: "redis",
|
|
571
|
+
ADAPTERS_FEDERATION_GRANT_STORE: "redis",
|
|
572
|
+
REDIS_FEDERATION_GRANT_STORE_ENCRYPTION_MODE: "allow-plaintext",
|
|
357
573
|
});
|
|
358
574
|
let error: unknown;
|
|
359
575
|
try {
|
|
360
|
-
|
|
576
|
+
// Under production the template installs the SMTP sender's module,
|
|
577
|
+
// whose section the package's builder carries.
|
|
578
|
+
handleRef = await boot(
|
|
579
|
+
{ ...config, ...standardSmtpMailSenderConfigForTests() },
|
|
580
|
+
false,
|
|
581
|
+
"production",
|
|
582
|
+
);
|
|
361
583
|
} catch (caught) {
|
|
362
584
|
error = caught;
|
|
363
585
|
}
|
|
@@ -373,12 +595,192 @@ describe("#593 slice 7: the standalone composes federation grants from its confi
|
|
|
373
595
|
const config = resolveConfig({
|
|
374
596
|
...BASE_ENV,
|
|
375
597
|
...GRANTS_ON,
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
598
|
+
ADAPTERS_USER_SESSION_STORES: "redis",
|
|
599
|
+
ADAPTERS_FEDERATION_GRANT_STORE: "redis",
|
|
600
|
+
ADAPTERS_FEDERATION_GRANT_INTENT_STORE: "memory",
|
|
379
601
|
});
|
|
380
602
|
handleRef = await boot(config);
|
|
381
603
|
expect(handleRef.components.federationGrantStore?.kind).toBe("redis");
|
|
382
604
|
expect(names(config)).toContain("core-federation-grant-intent-store-memory");
|
|
383
605
|
});
|
|
384
606
|
});
|
|
607
|
+
|
|
608
|
+
describe("the browser consent route parses its own body, with sessionModule listed ahead of it", () => {
|
|
609
|
+
// `sessionModule`'s routers are mounted at `/session`, the prefix the
|
|
610
|
+
// federation grants browser half mounts under too. If their parsers ran
|
|
611
|
+
// for every request beneath `/session`, then with `sessionModule` listed
|
|
612
|
+
// first the consent route's body would arrive parsed, past its 16 KiB
|
|
613
|
+
// bound, its throttle-then-parse order and its JSON refusals.
|
|
614
|
+
let handleRef: Awaited<ReturnType<typeof boot>> | undefined;
|
|
615
|
+
|
|
616
|
+
afterEach(async () => {
|
|
617
|
+
await handleRef?.dispose();
|
|
618
|
+
handleRef = undefined;
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
/** The standalone's modules, with `sessionModule` moved ahead of the grant modules. */
|
|
622
|
+
const sessionFirst = (config: Switches) => {
|
|
623
|
+
const modules = modulesFor(config, true);
|
|
624
|
+
const session = modules.find((m) => m.name === "session");
|
|
625
|
+
if (session === undefined) throw new Error("sessionModule is not in the standalone's list");
|
|
626
|
+
const rest = modules.filter((m) => m !== session);
|
|
627
|
+
const at = rest.findIndex((m) => m.name.startsWith("federation-grant"));
|
|
628
|
+
return [...rest.slice(0, at), session, ...rest.slice(at)];
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
const bootSessionFirst = async () => {
|
|
632
|
+
const config = resolveConfig({ ...BASE_ENV, ...GRANTS_ON });
|
|
633
|
+
handleRef = await createApp({
|
|
634
|
+
modules: sessionFirst(config),
|
|
635
|
+
bootstrapComponents: { config, pathResolver: (s) => s },
|
|
636
|
+
});
|
|
637
|
+
return express().use(handleRef.router);
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
const CONSENT = "/session/federation-grants/consent";
|
|
641
|
+
|
|
642
|
+
it("refuses a body over 16 KiB with the route's own 413", async () => {
|
|
643
|
+
const app = await bootSessionFirst();
|
|
644
|
+
|
|
645
|
+
const res = await request(app)
|
|
646
|
+
.post(CONSENT)
|
|
647
|
+
.set("Content-Type", "application/json")
|
|
648
|
+
.send(JSON.stringify({ challenge: "c", decision: "accept", pad: "a".repeat(40 * 1024) }));
|
|
649
|
+
|
|
650
|
+
expect(res.status).toBe(413);
|
|
651
|
+
expect(res.body).toEqual({ error: "invalid_request", error_description: "body_too_large" });
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
it("refuses malformed JSON with the route's own 400", async () => {
|
|
655
|
+
const app = await bootSessionFirst();
|
|
656
|
+
|
|
657
|
+
const res = await request(app)
|
|
658
|
+
.post(CONSENT)
|
|
659
|
+
.set("Content-Type", "application/json")
|
|
660
|
+
.send("{not json");
|
|
661
|
+
|
|
662
|
+
expect(res.status).toBe(400);
|
|
663
|
+
expect(res.body).toEqual({ error: "invalid_request", error_description: "malformed_body" });
|
|
664
|
+
expect(res.headers["cache-control"]).toContain("no-store");
|
|
665
|
+
});
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
describe("the grants consent answer is held to the session module's CSRF guard", () => {
|
|
669
|
+
// No browser is signed in here: an answer the guard accepts reaches the
|
|
670
|
+
// route's own `401 login_required`, and one it refuses stops at its `403`.
|
|
671
|
+
const CONSENT = "/session/federation-grants/consent";
|
|
672
|
+
const ISSUER_ORIGIN = "https://auth.test";
|
|
673
|
+
const SIBLING = "https://account.auth.test";
|
|
674
|
+
/** What the deployment's proxy forwards: the origin the browser addressed. */
|
|
675
|
+
const FORWARDED = { "X-Forwarded-Proto": "https", "X-Forwarded-Host": "auth.test" };
|
|
676
|
+
const CROSS_SITE = { error: "invalid_request", error_description: "cross-site answer refused" };
|
|
677
|
+
const NO_ORIGIN = {
|
|
678
|
+
error: "invalid_request",
|
|
679
|
+
error_description: "no origin and no valid csrf token",
|
|
680
|
+
};
|
|
681
|
+
|
|
682
|
+
let handleRef: Awaited<ReturnType<typeof boot>> | undefined;
|
|
683
|
+
|
|
684
|
+
afterEach(async () => {
|
|
685
|
+
await handleRef?.dispose();
|
|
686
|
+
handleRef = undefined;
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
/** The standalone with grants on and `SIBLING` on `session.csrf.trustedOrigins`, behind its proxy. */
|
|
690
|
+
const bootTrustingSibling = async () => {
|
|
691
|
+
const config = resolveConfig({ ...BASE_ENV, ...GRANTS_ON });
|
|
692
|
+
const session = config.session as { csrf?: object };
|
|
693
|
+
const trusting = {
|
|
694
|
+
...config,
|
|
695
|
+
session: { ...session, csrf: { ...session.csrf, trustedOrigins: [SIBLING] } },
|
|
696
|
+
} as Switches;
|
|
697
|
+
handleRef = await boot(trusting, true);
|
|
698
|
+
return express().set("trust proxy", "loopback").use(handleRef.router);
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
/** The token `GET /session/csrf` hands out, and the `Cookie` header that carries it back. */
|
|
702
|
+
const tokenFrom = async (app: express.Express) => {
|
|
703
|
+
const issued = await request(app).get("/session/csrf").set(FORWARDED);
|
|
704
|
+
expect(issued.status).toBe(200);
|
|
705
|
+
const [cookie] = (issued.headers["set-cookie"] as unknown as string[])[0]?.split(";") ?? [];
|
|
706
|
+
return { token: issued.body.csrf_token as string, cookie: cookie ?? "" };
|
|
707
|
+
};
|
|
708
|
+
|
|
709
|
+
const answer = (
|
|
710
|
+
app: express.Express,
|
|
711
|
+
headers: Readonly<Record<string, string>>,
|
|
712
|
+
body: Readonly<Record<string, string>> = {},
|
|
713
|
+
) =>
|
|
714
|
+
request(app)
|
|
715
|
+
.post(CONSENT)
|
|
716
|
+
.set(FORWARDED)
|
|
717
|
+
.set({ ...headers })
|
|
718
|
+
.type("form")
|
|
719
|
+
.send({ challenge: "c", decision: "accept", ...body });
|
|
720
|
+
|
|
721
|
+
it("accepts an Origin that is the request's own, as the proxy forwards it", async () => {
|
|
722
|
+
const app = await bootTrustingSibling();
|
|
723
|
+
const res = await answer(app, { Origin: ISSUER_ORIGIN, "Sec-Fetch-Site": "same-origin" });
|
|
724
|
+
expect(res.status).toBe(401);
|
|
725
|
+
expect(res.body.error).toBe("login_required");
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
it("refuses Origin: null, even with the token GET /session/csrf hands out", async () => {
|
|
729
|
+
const app = await bootTrustingSibling();
|
|
730
|
+
const { token, cookie } = await tokenFrom(app);
|
|
731
|
+
const res = await answer(
|
|
732
|
+
app,
|
|
733
|
+
{ Origin: "null", "Sec-Fetch-Site": "same-origin", Cookie: cookie },
|
|
734
|
+
{ csrf_token: token },
|
|
735
|
+
);
|
|
736
|
+
expect(res.status).toBe(403);
|
|
737
|
+
expect(res.body).toEqual(CROSS_SITE);
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
it("refuses an answer with no Origin, no Referer and no token, and accepts one echoing the token GET /session/csrf hands out", async () => {
|
|
741
|
+
const app = await bootTrustingSibling();
|
|
742
|
+
const refused = await answer(app, { "Sec-Fetch-Site": "same-origin" });
|
|
743
|
+
expect(refused.status).toBe(403);
|
|
744
|
+
expect(refused.body).toEqual(NO_ORIGIN);
|
|
745
|
+
const { token, cookie } = await tokenFrom(app);
|
|
746
|
+
const accepted = await answer(app, { Cookie: cookie }, { csrf_token: token });
|
|
747
|
+
expect(accepted.status).toBe(401);
|
|
748
|
+
expect(accepted.body.error).toBe("login_required");
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
it("accepts an answer with no Origin that echoes the token GET /session/csrf hands out in the x-csrf-token header", async () => {
|
|
752
|
+
const app = await bootTrustingSibling();
|
|
753
|
+
const { token, cookie } = await tokenFrom(app);
|
|
754
|
+
const res = await answer(app, { Cookie: cookie, "x-csrf-token": token });
|
|
755
|
+
expect(res.status).toBe(401);
|
|
756
|
+
expect(res.body.error).toBe("login_required");
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
it("refuses an answer with no Origin whose token is not its cookie's", async () => {
|
|
760
|
+
const app = await bootTrustingSibling();
|
|
761
|
+
const first = await tokenFrom(app);
|
|
762
|
+
const second = await tokenFrom(app);
|
|
763
|
+
const res = await answer(app, { Cookie: first.cookie }, { csrf_token: second.token });
|
|
764
|
+
expect(res.status).toBe(403);
|
|
765
|
+
expect(res.body).toEqual(NO_ORIGIN);
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
it("accepts an origin on session.csrf.trustedOrigins sending Sec-Fetch-Site: same-site", async () => {
|
|
769
|
+
const app = await bootTrustingSibling();
|
|
770
|
+
const res = await answer(app, { Origin: SIBLING, "Sec-Fetch-Site": "same-site" });
|
|
771
|
+
expect(res.status).toBe(401);
|
|
772
|
+
expect(res.body.error).toBe("login_required");
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
it("refuses a cross-site answer, even with the token", async () => {
|
|
776
|
+
const app = await bootTrustingSibling();
|
|
777
|
+
const { token, cookie } = await tokenFrom(app);
|
|
778
|
+
const res = await answer(
|
|
779
|
+
app,
|
|
780
|
+
{ Origin: "https://evil.test", "Sec-Fetch-Site": "cross-site", Cookie: cookie },
|
|
781
|
+
{ csrf_token: token },
|
|
782
|
+
);
|
|
783
|
+
expect(res.status).toBe(403);
|
|
784
|
+
expect(res.body).toEqual(CROSS_SITE);
|
|
785
|
+
});
|
|
786
|
+
});
|