@openparachute/hub 0.5.10-rc.9 → 0.5.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/admin-handlers.test.ts +141 -6
- package/src/__tests__/api-account.test.ts +463 -0
- package/src/__tests__/api-modules-ops.test.ts +74 -0
- package/src/__tests__/api-modules.test.ts +134 -0
- package/src/__tests__/api-users.test.ts +522 -0
- package/src/__tests__/cors.test.ts +587 -0
- package/src/__tests__/hub-db.test.ts +126 -1
- package/src/__tests__/hub-settings.test.ts +152 -0
- package/src/__tests__/jwt-sign.test.ts +59 -0
- package/src/__tests__/oauth-handlers.test.ts +912 -10
- package/src/__tests__/oauth-ui.test.ts +210 -0
- package/src/__tests__/scope-explanations.test.ts +23 -0
- package/src/__tests__/serve.test.ts +8 -1
- package/src/__tests__/setup-wizard.test.ts +216 -3
- package/src/__tests__/users.test.ts +196 -0
- package/src/__tests__/vault-names.test.ts +172 -0
- package/src/account-change-password-ui.ts +379 -0
- package/src/admin-handlers.ts +68 -2
- package/src/admin-host-admin-token.ts +5 -0
- package/src/admin-vault-admin-token.ts +7 -0
- package/src/api-account.ts +443 -0
- package/src/api-mint-token.ts +6 -0
- package/src/api-modules-ops.ts +15 -6
- package/src/api-modules.ts +101 -0
- package/src/api-users.ts +393 -0
- package/src/commands/auth.ts +10 -1
- package/src/commands/serve.ts +5 -1
- package/src/cors.ts +263 -0
- package/src/hub-db.ts +30 -0
- package/src/hub-server.ts +138 -18
- package/src/hub-settings.ts +98 -1
- package/src/jwt-sign.ts +17 -1
- package/src/oauth-handlers.ts +237 -29
- package/src/oauth-ui.ts +451 -38
- package/src/operator-token.ts +4 -0
- package/src/scope-explanations.ts +26 -1
- package/src/setup-wizard.ts +134 -16
- package/src/users.ts +210 -3
- package/src/vault-names.ts +57 -0
- package/web/ui/dist/assets/index-XhxYXDT5.js +61 -0
- package/web/ui/dist/assets/{index-D54otIhv.css → index-p6DkOcsk.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-AX_UHJ5e.js +0 -61
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
CORS_PREFLIGHT_HEADERS,
|
|
7
|
+
CORS_RESPONSE_HEADERS,
|
|
8
|
+
applyCorsHeaders,
|
|
9
|
+
corsPreflightResponse,
|
|
10
|
+
isCorsAllowedRoute,
|
|
11
|
+
} from "../cors.ts";
|
|
12
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
13
|
+
import { hubFetch } from "../hub-server.ts";
|
|
14
|
+
import { writeManifest } from "../services-manifest.ts";
|
|
15
|
+
|
|
16
|
+
const GITCOIN_BRAIN_ORIGIN = "https://unforced-dev.github.io";
|
|
17
|
+
const EXAMPLE_ORIGIN = "https://example.com";
|
|
18
|
+
const ISSUER = "https://parachute.taildf9ce2.ts.net";
|
|
19
|
+
|
|
20
|
+
function preflight(path: string, origin: string | null = GITCOIN_BRAIN_ORIGIN): Request {
|
|
21
|
+
const headers: Record<string, string> = {
|
|
22
|
+
"access-control-request-method": "POST",
|
|
23
|
+
"access-control-request-headers": "content-type",
|
|
24
|
+
};
|
|
25
|
+
if (origin !== null) headers.origin = origin;
|
|
26
|
+
return new Request(`http://127.0.0.1${path}`, { method: "OPTIONS", headers });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface Harness {
|
|
30
|
+
dir: string;
|
|
31
|
+
manifestPath: string;
|
|
32
|
+
cleanup: () => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function makeHarness(): Harness {
|
|
36
|
+
const dir = mkdtempSync(join(tmpdir(), "phub-cors-"));
|
|
37
|
+
return {
|
|
38
|
+
dir,
|
|
39
|
+
manifestPath: join(dir, "services.json"),
|
|
40
|
+
cleanup: () => rmSync(dir, { recursive: true, force: true }),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("cors helper module", () => {
|
|
45
|
+
test("CORS_RESPONSE_HEADERS exposes WWW-Authenticate (always-on, request-independent)", () => {
|
|
46
|
+
// Expose-Headers surfaces RFC 6750 WWW-Authenticate so cross-origin SPAs
|
|
47
|
+
// can read OAuth error responses. The dynamic Origin/Credentials/Vary
|
|
48
|
+
// triple is no longer static — it's computed per-request in
|
|
49
|
+
// applyCorsHeaders + corsPreflightResponse from the request's Origin
|
|
50
|
+
// header (echo-origin posture, not wildcard).
|
|
51
|
+
expect(CORS_RESPONSE_HEADERS["access-control-expose-headers"]).toContain("WWW-Authenticate");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("CORS_PREFLIGHT_HEADERS announces GET + POST + OPTIONS and standard request headers", () => {
|
|
55
|
+
const methods = CORS_PREFLIGHT_HEADERS["access-control-allow-methods"] ?? "";
|
|
56
|
+
expect(methods).toContain("GET");
|
|
57
|
+
expect(methods).toContain("POST");
|
|
58
|
+
expect(methods).toContain("OPTIONS");
|
|
59
|
+
const headers = CORS_PREFLIGHT_HEADERS["access-control-allow-headers"] ?? "";
|
|
60
|
+
expect(headers).toContain("Authorization");
|
|
61
|
+
expect(headers).toContain("Content-Type");
|
|
62
|
+
expect(CORS_PREFLIGHT_HEADERS["access-control-max-age"]).toBe("86400");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("isCorsAllowedRoute matches /oauth/* and nothing else", () => {
|
|
66
|
+
expect(isCorsAllowedRoute("/oauth/register")).toBe(true);
|
|
67
|
+
expect(isCorsAllowedRoute("/oauth/token")).toBe(true);
|
|
68
|
+
expect(isCorsAllowedRoute("/oauth/authorize")).toBe(true);
|
|
69
|
+
expect(isCorsAllowedRoute("/oauth/authorize/approve")).toBe(true);
|
|
70
|
+
expect(isCorsAllowedRoute("/oauth/revoke")).toBe(true);
|
|
71
|
+
// Out-of-scope surfaces. /.well-known/* handlers carry their own inline
|
|
72
|
+
// CORS posture in hub-server.ts — see the comment in cors.ts on why
|
|
73
|
+
// they're intentionally excluded from this predicate.
|
|
74
|
+
expect(isCorsAllowedRoute("/.well-known/oauth-authorization-server")).toBe(false);
|
|
75
|
+
expect(isCorsAllowedRoute("/.well-known/parachute.json")).toBe(false);
|
|
76
|
+
expect(isCorsAllowedRoute("/.well-known/jwks.json")).toBe(false);
|
|
77
|
+
expect(isCorsAllowedRoute("/api/me")).toBe(false);
|
|
78
|
+
expect(isCorsAllowedRoute("/api/users")).toBe(false);
|
|
79
|
+
expect(isCorsAllowedRoute("/admin/vaults")).toBe(false);
|
|
80
|
+
expect(isCorsAllowedRoute("/admin/host-admin-token")).toBe(false);
|
|
81
|
+
expect(isCorsAllowedRoute("/login")).toBe(false);
|
|
82
|
+
expect(isCorsAllowedRoute("/logout")).toBe(false);
|
|
83
|
+
expect(isCorsAllowedRoute("/account/change-password")).toBe(false);
|
|
84
|
+
expect(isCorsAllowedRoute("/vault/default")).toBe(false);
|
|
85
|
+
expect(isCorsAllowedRoute("/")).toBe(false);
|
|
86
|
+
// Bare /oauth doesn't match — there's no route there and the prefix
|
|
87
|
+
// intentionally requires the trailing slash so it doesn't silently widen.
|
|
88
|
+
expect(isCorsAllowedRoute("/oauth")).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("corsPreflightResponse with Origin echoes origin + credentials:true + Vary:Origin", async () => {
|
|
92
|
+
const res = corsPreflightResponse(
|
|
93
|
+
new Request("http://127.0.0.1/oauth/register", {
|
|
94
|
+
method: "OPTIONS",
|
|
95
|
+
headers: { origin: EXAMPLE_ORIGIN },
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
98
|
+
expect(res.status).toBe(204);
|
|
99
|
+
expect(res.headers.get("access-control-allow-origin")).toBe(EXAMPLE_ORIGIN);
|
|
100
|
+
expect(res.headers.get("access-control-allow-credentials")).toBe("true");
|
|
101
|
+
// Vary: Origin is critical — without it a browser/CDN can cache a
|
|
102
|
+
// response for one origin and reuse it for a different origin. Pin its
|
|
103
|
+
// presence as a regression guard.
|
|
104
|
+
expect(res.headers.get("vary")).toBe("Origin");
|
|
105
|
+
expect(res.headers.get("access-control-allow-methods")).toContain("POST");
|
|
106
|
+
expect(res.headers.get("access-control-allow-headers")).toContain("Authorization");
|
|
107
|
+
expect(res.headers.get("access-control-max-age")).toBe("86400");
|
|
108
|
+
// 204 = no body. Reading it returns the empty string.
|
|
109
|
+
expect(await res.text()).toBe("");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("corsPreflightResponse without Origin falls back to wildcard + credentials:false", async () => {
|
|
113
|
+
// Non-browser caller (curl, server-side fetch). No Origin → safe wildcard
|
|
114
|
+
// fallback with credentials:false (the only legal pairing per CORS spec
|
|
115
|
+
// when ACAO is `*`).
|
|
116
|
+
const res = corsPreflightResponse(
|
|
117
|
+
new Request("http://127.0.0.1/oauth/register", { method: "OPTIONS" }),
|
|
118
|
+
);
|
|
119
|
+
expect(res.status).toBe(204);
|
|
120
|
+
expect(res.headers.get("access-control-allow-origin")).toBe("*");
|
|
121
|
+
expect(res.headers.get("access-control-allow-credentials")).toBe("false");
|
|
122
|
+
// No Vary needed on a wildcard response — it doesn't vary by origin.
|
|
123
|
+
expect(res.headers.get("vary")).toBeNull();
|
|
124
|
+
expect(res.headers.get("access-control-allow-methods")).toContain("POST");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("applyCorsHeaders with Origin echoes origin + credentials:true + Vary:Origin", async () => {
|
|
128
|
+
const original = Response.json({ ok: true }, { status: 201 });
|
|
129
|
+
const wrapped = applyCorsHeaders(
|
|
130
|
+
new Request("http://127.0.0.1/oauth/register", {
|
|
131
|
+
method: "POST",
|
|
132
|
+
headers: { origin: EXAMPLE_ORIGIN },
|
|
133
|
+
}),
|
|
134
|
+
original,
|
|
135
|
+
);
|
|
136
|
+
expect(wrapped.status).toBe(201);
|
|
137
|
+
expect(wrapped.headers.get("access-control-allow-origin")).toBe(EXAMPLE_ORIGIN);
|
|
138
|
+
expect(wrapped.headers.get("access-control-allow-credentials")).toBe("true");
|
|
139
|
+
expect(wrapped.headers.get("vary")).toBe("Origin");
|
|
140
|
+
expect(wrapped.headers.get("content-type")).toBe("application/json;charset=utf-8");
|
|
141
|
+
expect((await wrapped.json()) as { ok: boolean }).toEqual({ ok: true });
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("applyCorsHeaders without Origin falls back to wildcard + credentials:false", async () => {
|
|
145
|
+
const original = Response.json({ ok: true }, { status: 201 });
|
|
146
|
+
const wrapped = applyCorsHeaders(
|
|
147
|
+
new Request("http://127.0.0.1/oauth/register", { method: "POST" }),
|
|
148
|
+
original,
|
|
149
|
+
);
|
|
150
|
+
expect(wrapped.headers.get("access-control-allow-origin")).toBe("*");
|
|
151
|
+
expect(wrapped.headers.get("access-control-allow-credentials")).toBe("false");
|
|
152
|
+
expect(wrapped.headers.get("vary")).toBeNull();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("applyCorsHeaders preserves a handler's existing CORS header (no overwrite)", () => {
|
|
156
|
+
// If a handler already set Access-Control-Allow-Origin (e.g. a different
|
|
157
|
+
// posture for a specific route), we don't clobber it. Defensive; no
|
|
158
|
+
// current caller does this, but the contract should be additive.
|
|
159
|
+
const original = new Response("hi", {
|
|
160
|
+
status: 200,
|
|
161
|
+
headers: { "access-control-allow-origin": "https://specific.example" },
|
|
162
|
+
});
|
|
163
|
+
const wrapped = applyCorsHeaders(
|
|
164
|
+
new Request("http://127.0.0.1/oauth/register", {
|
|
165
|
+
method: "POST",
|
|
166
|
+
headers: { origin: EXAMPLE_ORIGIN },
|
|
167
|
+
}),
|
|
168
|
+
original,
|
|
169
|
+
);
|
|
170
|
+
expect(wrapped.headers.get("access-control-allow-origin")).toBe("https://specific.example");
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("hubFetch CORS on /oauth/* — echo origin (credentials:'include' SPAs)", () => {
|
|
175
|
+
// rc.17 used a static `Access-Control-Allow-Origin: *` + Allow-Credentials:
|
|
176
|
+
// false. That works for SPAs that fetch with `credentials: 'omit'`, but the
|
|
177
|
+
// Gitcoin Brain UI (and most SPA frameworks by default) fetches with
|
|
178
|
+
// `credentials: 'include'`, which the browser rejects against a wildcard
|
|
179
|
+
// ACAO. rc.18 echoes the request Origin + sets Allow-Credentials: true so
|
|
180
|
+
// both SPA postures work. These tests pin the echo-origin behavior.
|
|
181
|
+
|
|
182
|
+
test("OPTIONS preflight on /oauth/register from a third-party origin echoes that origin", async () => {
|
|
183
|
+
const h = makeHarness();
|
|
184
|
+
try {
|
|
185
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
186
|
+
try {
|
|
187
|
+
const res = await hubFetch(h.dir, { getDb: () => db, issuer: ISSUER })(
|
|
188
|
+
preflight("/oauth/register", EXAMPLE_ORIGIN),
|
|
189
|
+
);
|
|
190
|
+
expect(res.status).toBe(204);
|
|
191
|
+
expect(res.headers.get("access-control-allow-origin")).toBe(EXAMPLE_ORIGIN);
|
|
192
|
+
expect(res.headers.get("access-control-allow-credentials")).toBe("true");
|
|
193
|
+
expect(res.headers.get("vary")).toBe("Origin");
|
|
194
|
+
expect(res.headers.get("access-control-allow-methods")).toContain("POST");
|
|
195
|
+
expect(res.headers.get("access-control-allow-headers")).toContain("Content-Type");
|
|
196
|
+
expect(res.headers.get("access-control-max-age")).toBe("86400");
|
|
197
|
+
} finally {
|
|
198
|
+
db.close();
|
|
199
|
+
}
|
|
200
|
+
} finally {
|
|
201
|
+
h.cleanup();
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("OPTIONS preflight on /oauth/register with no Origin falls back to wildcard + credentials:false", async () => {
|
|
206
|
+
// Server-shaped `curl` without `-H Origin: …`. Wildcard + credentials:
|
|
207
|
+
// false is the safe shape — non-browser callers don't enforce CORS, but
|
|
208
|
+
// the response should still be well-formed for diagnostic probes.
|
|
209
|
+
const h = makeHarness();
|
|
210
|
+
try {
|
|
211
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
212
|
+
try {
|
|
213
|
+
const res = await hubFetch(h.dir, { getDb: () => db, issuer: ISSUER })(
|
|
214
|
+
preflight("/oauth/register", null),
|
|
215
|
+
);
|
|
216
|
+
expect(res.status).toBe(204);
|
|
217
|
+
expect(res.headers.get("access-control-allow-origin")).toBe("*");
|
|
218
|
+
expect(res.headers.get("access-control-allow-credentials")).toBe("false");
|
|
219
|
+
expect(res.headers.get("vary")).toBeNull();
|
|
220
|
+
} finally {
|
|
221
|
+
db.close();
|
|
222
|
+
}
|
|
223
|
+
} finally {
|
|
224
|
+
h.cleanup();
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("POST /oauth/register response with Origin echoes that origin + credentials:true (the actual bug)", async () => {
|
|
229
|
+
const h = makeHarness();
|
|
230
|
+
try {
|
|
231
|
+
writeManifest({ services: [] }, h.manifestPath);
|
|
232
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
233
|
+
try {
|
|
234
|
+
const res = await hubFetch(h.dir, {
|
|
235
|
+
getDb: () => db,
|
|
236
|
+
issuer: ISSUER,
|
|
237
|
+
manifestPath: h.manifestPath,
|
|
238
|
+
})(
|
|
239
|
+
new Request(`${ISSUER}/oauth/register`, {
|
|
240
|
+
method: "POST",
|
|
241
|
+
headers: { "content-type": "application/json", origin: EXAMPLE_ORIGIN },
|
|
242
|
+
body: JSON.stringify({
|
|
243
|
+
client_name: "example-spa",
|
|
244
|
+
redirect_uris: [`${EXAMPLE_ORIGIN}/callback`],
|
|
245
|
+
}),
|
|
246
|
+
}),
|
|
247
|
+
);
|
|
248
|
+
// Status is whatever DCR produces (typically 201 created on the
|
|
249
|
+
// public-DCR path); the CORS headers are the load-bearing assertion.
|
|
250
|
+
expect(res.headers.get("access-control-allow-origin")).toBe(EXAMPLE_ORIGIN);
|
|
251
|
+
expect(res.headers.get("access-control-allow-credentials")).toBe("true");
|
|
252
|
+
expect(res.headers.get("vary")).toBe("Origin");
|
|
253
|
+
} finally {
|
|
254
|
+
db.close();
|
|
255
|
+
}
|
|
256
|
+
} finally {
|
|
257
|
+
h.cleanup();
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("POST /oauth/register response with no Origin falls back to wildcard + credentials:false", async () => {
|
|
262
|
+
const h = makeHarness();
|
|
263
|
+
try {
|
|
264
|
+
writeManifest({ services: [] }, h.manifestPath);
|
|
265
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
266
|
+
try {
|
|
267
|
+
const res = await hubFetch(h.dir, {
|
|
268
|
+
getDb: () => db,
|
|
269
|
+
issuer: ISSUER,
|
|
270
|
+
manifestPath: h.manifestPath,
|
|
271
|
+
})(
|
|
272
|
+
new Request(`${ISSUER}/oauth/register`, {
|
|
273
|
+
method: "POST",
|
|
274
|
+
headers: { "content-type": "application/json" },
|
|
275
|
+
body: JSON.stringify({
|
|
276
|
+
client_name: "server-side-caller",
|
|
277
|
+
redirect_uris: [`${EXAMPLE_ORIGIN}/callback`],
|
|
278
|
+
}),
|
|
279
|
+
}),
|
|
280
|
+
);
|
|
281
|
+
expect(res.headers.get("access-control-allow-origin")).toBe("*");
|
|
282
|
+
expect(res.headers.get("access-control-allow-credentials")).toBe("false");
|
|
283
|
+
} finally {
|
|
284
|
+
db.close();
|
|
285
|
+
}
|
|
286
|
+
} finally {
|
|
287
|
+
h.cleanup();
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("OPTIONS preflight on /oauth/authorize echoes origin", async () => {
|
|
292
|
+
const h = makeHarness();
|
|
293
|
+
try {
|
|
294
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
295
|
+
try {
|
|
296
|
+
const res = await hubFetch(h.dir, { getDb: () => db, issuer: ISSUER })(
|
|
297
|
+
preflight("/oauth/authorize"),
|
|
298
|
+
);
|
|
299
|
+
expect(res.status).toBe(204);
|
|
300
|
+
expect(res.headers.get("access-control-allow-origin")).toBe(GITCOIN_BRAIN_ORIGIN);
|
|
301
|
+
expect(res.headers.get("access-control-allow-credentials")).toBe("true");
|
|
302
|
+
expect(res.headers.get("vary")).toBe("Origin");
|
|
303
|
+
} finally {
|
|
304
|
+
db.close();
|
|
305
|
+
}
|
|
306
|
+
} finally {
|
|
307
|
+
h.cleanup();
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("GET /oauth/authorize response carries echo-origin CORS (the sync-handler branch)", async () => {
|
|
312
|
+
// The other oauth handlers are async (`Promise<Response>`); only
|
|
313
|
+
// `handleAuthorizeGet` is sync. Folding `applyCorsHeaders` over a sync
|
|
314
|
+
// return is exercised here so a future refactor that breaks the
|
|
315
|
+
// sync-vs-async distinction (e.g. dropping the wrapper, double-wrapping,
|
|
316
|
+
// accidentally awaiting a non-Promise into a hang) is caught.
|
|
317
|
+
//
|
|
318
|
+
// 400 branch — missing required PKCE params triggers the htmlError
|
|
319
|
+
// path inside parseAuthorizeFormParams. Cleanest no-DB-seeding fixture
|
|
320
|
+
// since the params fail validation before the client lookup runs.
|
|
321
|
+
const h = makeHarness();
|
|
322
|
+
try {
|
|
323
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
324
|
+
try {
|
|
325
|
+
const res = await hubFetch(h.dir, { getDb: () => db, issuer: ISSUER })(
|
|
326
|
+
new Request(
|
|
327
|
+
`${ISSUER}/oauth/authorize?client_id=test&redirect_uri=${EXAMPLE_ORIGIN}/cb&response_type=code&state=foo`,
|
|
328
|
+
{ method: "GET", headers: { origin: EXAMPLE_ORIGIN } },
|
|
329
|
+
),
|
|
330
|
+
);
|
|
331
|
+
expect(res.status).toBe(400);
|
|
332
|
+
expect(res.headers.get("access-control-allow-origin")).toBe(EXAMPLE_ORIGIN);
|
|
333
|
+
expect(res.headers.get("access-control-allow-credentials")).toBe("true");
|
|
334
|
+
expect(res.headers.get("vary")).toBe("Origin");
|
|
335
|
+
} finally {
|
|
336
|
+
db.close();
|
|
337
|
+
}
|
|
338
|
+
} finally {
|
|
339
|
+
h.cleanup();
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("OPTIONS preflight on /oauth/token echoes origin", async () => {
|
|
344
|
+
const h = makeHarness();
|
|
345
|
+
try {
|
|
346
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
347
|
+
try {
|
|
348
|
+
const res = await hubFetch(h.dir, { getDb: () => db, issuer: ISSUER })(
|
|
349
|
+
preflight("/oauth/token"),
|
|
350
|
+
);
|
|
351
|
+
expect(res.status).toBe(204);
|
|
352
|
+
expect(res.headers.get("access-control-allow-origin")).toBe(GITCOIN_BRAIN_ORIGIN);
|
|
353
|
+
expect(res.headers.get("access-control-allow-credentials")).toBe("true");
|
|
354
|
+
expect(res.headers.get("vary")).toBe("Origin");
|
|
355
|
+
expect(res.headers.get("access-control-allow-methods")).toContain("POST");
|
|
356
|
+
} finally {
|
|
357
|
+
db.close();
|
|
358
|
+
}
|
|
359
|
+
} finally {
|
|
360
|
+
h.cleanup();
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("OPTIONS preflight on /oauth/revoke echoes origin", async () => {
|
|
365
|
+
const h = makeHarness();
|
|
366
|
+
try {
|
|
367
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
368
|
+
try {
|
|
369
|
+
const res = await hubFetch(h.dir, { getDb: () => db, issuer: ISSUER })(
|
|
370
|
+
preflight("/oauth/revoke"),
|
|
371
|
+
);
|
|
372
|
+
expect(res.status).toBe(204);
|
|
373
|
+
expect(res.headers.get("access-control-allow-origin")).toBe(GITCOIN_BRAIN_ORIGIN);
|
|
374
|
+
expect(res.headers.get("vary")).toBe("Origin");
|
|
375
|
+
} finally {
|
|
376
|
+
db.close();
|
|
377
|
+
}
|
|
378
|
+
} finally {
|
|
379
|
+
h.cleanup();
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test("OPTIONS preflight on /oauth/authorize/approve echoes origin", async () => {
|
|
384
|
+
const h = makeHarness();
|
|
385
|
+
try {
|
|
386
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
387
|
+
try {
|
|
388
|
+
const res = await hubFetch(h.dir, { getDb: () => db, issuer: ISSUER })(
|
|
389
|
+
preflight("/oauth/authorize/approve"),
|
|
390
|
+
);
|
|
391
|
+
expect(res.status).toBe(204);
|
|
392
|
+
expect(res.headers.get("access-control-allow-origin")).toBe(GITCOIN_BRAIN_ORIGIN);
|
|
393
|
+
expect(res.headers.get("vary")).toBe("Origin");
|
|
394
|
+
} finally {
|
|
395
|
+
db.close();
|
|
396
|
+
}
|
|
397
|
+
} finally {
|
|
398
|
+
h.cleanup();
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test("POST /oauth/token method-not-allowed branch still carries echo-origin CORS", async () => {
|
|
403
|
+
// Bad-method on an in-scope path still has to ship CORS so the SPA can
|
|
404
|
+
// *read* the error response. Without it, the browser drops the response
|
|
405
|
+
// body and the SPA sees an opaque network failure instead of a clear
|
|
406
|
+
// 405.
|
|
407
|
+
const h = makeHarness();
|
|
408
|
+
try {
|
|
409
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
410
|
+
try {
|
|
411
|
+
const res = await hubFetch(h.dir, { getDb: () => db, issuer: ISSUER })(
|
|
412
|
+
new Request(`${ISSUER}/oauth/token`, {
|
|
413
|
+
method: "GET",
|
|
414
|
+
headers: { origin: EXAMPLE_ORIGIN },
|
|
415
|
+
}),
|
|
416
|
+
);
|
|
417
|
+
expect(res.status).toBe(405);
|
|
418
|
+
expect(res.headers.get("access-control-allow-origin")).toBe(EXAMPLE_ORIGIN);
|
|
419
|
+
expect(res.headers.get("access-control-allow-credentials")).toBe("true");
|
|
420
|
+
} finally {
|
|
421
|
+
db.close();
|
|
422
|
+
}
|
|
423
|
+
} finally {
|
|
424
|
+
h.cleanup();
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test("503 dbNotConfigured response on an oauth route still carries echo-origin CORS", async () => {
|
|
429
|
+
// No getDb → service_unavailable. Same as method-not-allowed: the SPA
|
|
430
|
+
// needs to be able to read the error.
|
|
431
|
+
const h = makeHarness();
|
|
432
|
+
try {
|
|
433
|
+
const res = await hubFetch(h.dir, { issuer: ISSUER })(
|
|
434
|
+
new Request(`${ISSUER}/oauth/register`, {
|
|
435
|
+
method: "POST",
|
|
436
|
+
headers: { "content-type": "application/json", origin: EXAMPLE_ORIGIN },
|
|
437
|
+
body: "{}",
|
|
438
|
+
}),
|
|
439
|
+
);
|
|
440
|
+
expect(res.status).toBe(503);
|
|
441
|
+
expect(res.headers.get("access-control-allow-origin")).toBe(EXAMPLE_ORIGIN);
|
|
442
|
+
expect(res.headers.get("access-control-allow-credentials")).toBe("true");
|
|
443
|
+
} finally {
|
|
444
|
+
h.cleanup();
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
test("the exact bug Aaron hit — preflight from unforced-dev.github.io to /oauth/register echoes that origin", async () => {
|
|
449
|
+
// Reproduces the exact request shape from the browser console error in
|
|
450
|
+
// the rc.17 follow-up PR brief. The Gitcoin Brain UI on
|
|
451
|
+
// https://unforced-dev.github.io fetches with `credentials: 'include'`;
|
|
452
|
+
// the browser preflights and requires the response to specify an
|
|
453
|
+
// explicit origin (not `*`) AND set `Allow-Credentials: true`. This is
|
|
454
|
+
// the canonical regression test for the rc.17→rc.18 fix.
|
|
455
|
+
const h = makeHarness();
|
|
456
|
+
try {
|
|
457
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
458
|
+
try {
|
|
459
|
+
const res = await hubFetch(h.dir, { getDb: () => db, issuer: ISSUER })(
|
|
460
|
+
new Request(`${ISSUER}/oauth/register`, {
|
|
461
|
+
method: "OPTIONS",
|
|
462
|
+
headers: {
|
|
463
|
+
origin: GITCOIN_BRAIN_ORIGIN,
|
|
464
|
+
"access-control-request-method": "POST",
|
|
465
|
+
"access-control-request-headers": "content-type",
|
|
466
|
+
},
|
|
467
|
+
}),
|
|
468
|
+
);
|
|
469
|
+
expect(res.status).toBe(204);
|
|
470
|
+
expect(res.headers.get("access-control-allow-origin")).toBe(GITCOIN_BRAIN_ORIGIN);
|
|
471
|
+
expect(res.headers.get("access-control-allow-credentials")).toBe("true");
|
|
472
|
+
expect(res.headers.get("vary")).toBe("Origin");
|
|
473
|
+
expect(res.headers.get("access-control-allow-methods")).toContain("POST");
|
|
474
|
+
expect(res.headers.get("access-control-allow-headers")).toContain("Content-Type");
|
|
475
|
+
} finally {
|
|
476
|
+
db.close();
|
|
477
|
+
}
|
|
478
|
+
} finally {
|
|
479
|
+
h.cleanup();
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
describe("hubFetch CORS scope discipline — out-of-scope routes stay same-origin", () => {
|
|
485
|
+
// Sanity: this PR is supposed to be tightly scoped to /oauth/*. Lock in
|
|
486
|
+
// that the admin / API / login / account surfaces still respond same-
|
|
487
|
+
// origin (no wildcard CORS header). Catches any future regression where
|
|
488
|
+
// someone broadens isCorsAllowedRoute to "all /api/*" or similar.
|
|
489
|
+
|
|
490
|
+
test("OPTIONS on /api/me does not return a CORS preflight echo response", async () => {
|
|
491
|
+
const h = makeHarness();
|
|
492
|
+
try {
|
|
493
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
494
|
+
try {
|
|
495
|
+
const res = await hubFetch(h.dir, { getDb: () => db, issuer: ISSUER })(
|
|
496
|
+
preflight("/api/me"),
|
|
497
|
+
);
|
|
498
|
+
// Whatever the API surface does with OPTIONS, it must not be the
|
|
499
|
+
// CORS preflight echo-origin shape.
|
|
500
|
+
const acao = res.headers.get("access-control-allow-origin");
|
|
501
|
+
expect(acao).not.toBe(GITCOIN_BRAIN_ORIGIN);
|
|
502
|
+
expect(acao).not.toBe("*");
|
|
503
|
+
} finally {
|
|
504
|
+
db.close();
|
|
505
|
+
}
|
|
506
|
+
} finally {
|
|
507
|
+
h.cleanup();
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
test("OPTIONS on /admin/host-admin-token does not return a CORS preflight echo response", async () => {
|
|
512
|
+
const h = makeHarness();
|
|
513
|
+
try {
|
|
514
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
515
|
+
try {
|
|
516
|
+
const res = await hubFetch(h.dir, { getDb: () => db, issuer: ISSUER })(
|
|
517
|
+
preflight("/admin/host-admin-token"),
|
|
518
|
+
);
|
|
519
|
+
const acao = res.headers.get("access-control-allow-origin");
|
|
520
|
+
expect(acao).not.toBe(GITCOIN_BRAIN_ORIGIN);
|
|
521
|
+
expect(acao).not.toBe("*");
|
|
522
|
+
} finally {
|
|
523
|
+
db.close();
|
|
524
|
+
}
|
|
525
|
+
} finally {
|
|
526
|
+
h.cleanup();
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
test("OPTIONS on /login does not return a CORS preflight echo response", async () => {
|
|
531
|
+
const h = makeHarness();
|
|
532
|
+
try {
|
|
533
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
534
|
+
try {
|
|
535
|
+
const res = await hubFetch(h.dir, { getDb: () => db, issuer: ISSUER })(preflight("/login"));
|
|
536
|
+
const acao = res.headers.get("access-control-allow-origin");
|
|
537
|
+
expect(acao).not.toBe(GITCOIN_BRAIN_ORIGIN);
|
|
538
|
+
expect(acao).not.toBe("*");
|
|
539
|
+
} finally {
|
|
540
|
+
db.close();
|
|
541
|
+
}
|
|
542
|
+
} finally {
|
|
543
|
+
h.cleanup();
|
|
544
|
+
}
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
test("OPTIONS on /account/change-password does not return a CORS preflight echo response", async () => {
|
|
548
|
+
const h = makeHarness();
|
|
549
|
+
try {
|
|
550
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
551
|
+
try {
|
|
552
|
+
const res = await hubFetch(h.dir, { getDb: () => db, issuer: ISSUER })(
|
|
553
|
+
preflight("/account/change-password"),
|
|
554
|
+
);
|
|
555
|
+
const acao = res.headers.get("access-control-allow-origin");
|
|
556
|
+
expect(acao).not.toBe(GITCOIN_BRAIN_ORIGIN);
|
|
557
|
+
expect(acao).not.toBe("*");
|
|
558
|
+
} finally {
|
|
559
|
+
db.close();
|
|
560
|
+
}
|
|
561
|
+
} finally {
|
|
562
|
+
h.cleanup();
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
test("OPTIONS on /vault/default content proxy is not a CORS preflight echo response", async () => {
|
|
567
|
+
const h = makeHarness();
|
|
568
|
+
try {
|
|
569
|
+
writeManifest({ services: [] }, h.manifestPath);
|
|
570
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
571
|
+
try {
|
|
572
|
+
const res = await hubFetch(h.dir, {
|
|
573
|
+
getDb: () => db,
|
|
574
|
+
issuer: ISSUER,
|
|
575
|
+
manifestPath: h.manifestPath,
|
|
576
|
+
})(preflight("/vault/default"));
|
|
577
|
+
const acao = res.headers.get("access-control-allow-origin");
|
|
578
|
+
expect(acao).not.toBe(GITCOIN_BRAIN_ORIGIN);
|
|
579
|
+
expect(acao).not.toBe("*");
|
|
580
|
+
} finally {
|
|
581
|
+
db.close();
|
|
582
|
+
}
|
|
583
|
+
} finally {
|
|
584
|
+
h.cleanup();
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
});
|