@polyguard/sdk 1.4.1 → 1.5.0
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/coverage/base.css +224 -0
- package/coverage/block-navigation.js +87 -0
- package/coverage/clover.xml +268 -0
- package/coverage/coverage-final.json +8 -0
- package/coverage/favicon.png +0 -0
- package/coverage/index.html +131 -0
- package/coverage/prettify.css +1 -0
- package/coverage/prettify.js +2 -0
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +210 -0
- package/coverage/src/PolyguardClient.js.html +160 -0
- package/coverage/src/PolyguardWebsocketClientImpl.js.html +634 -0
- package/coverage/src/__tests__/helpers/fixtures.js.html +166 -0
- package/coverage/src/__tests__/helpers/index.html +131 -0
- package/coverage/src/__tests__/helpers/mockWebSocket.js.html +283 -0
- package/coverage/src/index.html +176 -0
- package/coverage/src/messageHandler.js.html +448 -0
- package/coverage/src/ticketService.js.html +157 -0
- package/coverage/src/ui.js.html +349 -0
- package/dist/sdk.esm.js +21 -5
- package/dist/sdk.js +21 -5
- package/dist/server.esm.js +83 -0
- package/package.json +9 -2
- package/src/PolyguardWebsocketClientImpl.js +19 -3
- package/src/__tests__/PolyguardWebsocketClientImpl.test.js +45 -2
- package/src/__tests__/server.test.js +148 -0
- package/src/__tests__/sidebar.test.js +24 -0
- package/src/messageHandler.js +5 -0
- package/src/server.js +91 -0
- package/src/ticketService.js +7 -3
package/dist/sdk.js
CHANGED
|
@@ -10953,8 +10953,9 @@ var Polyguard = (() => {
|
|
|
10953
10953
|
}
|
|
10954
10954
|
|
|
10955
10955
|
// src/ticketService.js
|
|
10956
|
-
async function fetchTicket({ apiServer, appId, link_uuid, requiredProofs, scanType }) {
|
|
10957
|
-
const
|
|
10956
|
+
async function fetchTicket({ apiServer, proxyUrl, appId, link_uuid, requiredProofs, scanType }) {
|
|
10957
|
+
const base = proxyUrl ? proxyUrl.replace(/\/$/, "") : `https://${apiServer}`;
|
|
10958
|
+
const ticketUrl = link_uuid ? `${base}/v2/ticket/${appId}/${link_uuid}` : `${base}/v2/ticket/${appId}`;
|
|
10958
10959
|
const ticketRes = await fetch(ticketUrl, {
|
|
10959
10960
|
method: "POST",
|
|
10960
10961
|
headers: { "Content-Type": "application/json" },
|
|
@@ -11017,6 +11018,7 @@ var Polyguard = (() => {
|
|
|
11017
11018
|
const { sidebarUrl, link_uuid } = ctx;
|
|
11018
11019
|
if (sidebarUrl && redirectUrl) {
|
|
11019
11020
|
const hasMsftSession = document.cookie.includes("pg_msft_session");
|
|
11021
|
+
ctx.markClosed();
|
|
11020
11022
|
cleanup();
|
|
11021
11023
|
ws.close();
|
|
11022
11024
|
if (hasMsftSession) {
|
|
@@ -11065,15 +11067,24 @@ var Polyguard = (() => {
|
|
|
11065
11067
|
var PolyguardWebsocketClientImpl = class {
|
|
11066
11068
|
constructor(params = {}) {
|
|
11067
11069
|
this.apiClient = new ApiClient_default();
|
|
11068
|
-
const { apiKey, baseUrl, appId, apiServer, requiredProofs = ["Full Name"], scanType = "single", redirectUrl, callbackUrl, cookieName, link_uuid, sidebarUrl } = params;
|
|
11070
|
+
const { apiKey, baseUrl, appId, apiServer, proxyUrl, iKnowThisIsInsecure, requiredProofs = ["Full Name"], scanType = "single", redirectUrl, callbackUrl, cookieName, link_uuid, sidebarUrl } = params;
|
|
11069
11071
|
this.sidebarUrl = sidebarUrl;
|
|
11070
|
-
if (
|
|
11072
|
+
if (apiKey && typeof window !== "undefined" && iKnowThisIsInsecure !== true) {
|
|
11073
|
+
throw new Error(
|
|
11074
|
+
"apiKey is forbidden in browser code; use proxyUrl with a server-side proxy (see @polyguard/sdk/server). Set iKnowThisIsInsecure: true to bypass."
|
|
11075
|
+
);
|
|
11076
|
+
}
|
|
11077
|
+
if (proxyUrl) {
|
|
11078
|
+
this.apiClient.basePath = proxyUrl.replace(/\/$/, "");
|
|
11079
|
+
} else if (baseUrl) {
|
|
11071
11080
|
this.apiClient.basePath = baseUrl;
|
|
11072
11081
|
}
|
|
11073
11082
|
if (apiKey) {
|
|
11074
11083
|
if (this.apiClient.authentications["bearerAuth"]) {
|
|
11075
11084
|
this.apiClient.authentications["bearerAuth"].apiKey = apiKey;
|
|
11076
11085
|
}
|
|
11086
|
+
this.apiClient.defaultHeaders = this.apiClient.defaultHeaders || {};
|
|
11087
|
+
this.apiClient.defaultHeaders["x-pg-api-key"] = apiKey;
|
|
11077
11088
|
}
|
|
11078
11089
|
for (const key in src_exports) {
|
|
11079
11090
|
if (key.endsWith("Api")) {
|
|
@@ -11084,6 +11095,7 @@ var Polyguard = (() => {
|
|
|
11084
11095
|
this.integrationType = "websocket";
|
|
11085
11096
|
this.appId = appId;
|
|
11086
11097
|
this.apiServer = apiServer;
|
|
11098
|
+
this.proxyUrl = proxyUrl;
|
|
11087
11099
|
this.requiredProofs = requiredProofs;
|
|
11088
11100
|
this.scanType = scanType;
|
|
11089
11101
|
this.redirectUrl = redirectUrl;
|
|
@@ -11164,6 +11176,7 @@ var Polyguard = (() => {
|
|
|
11164
11176
|
const wsProtocol = window.location.protocol === "https:" ? "wss" : "ws";
|
|
11165
11177
|
const newTicket = await fetchTicket({
|
|
11166
11178
|
apiServer: this.apiServer,
|
|
11179
|
+
proxyUrl: this.proxyUrl,
|
|
11167
11180
|
appId: this.appId,
|
|
11168
11181
|
link_uuid: this.link_uuid,
|
|
11169
11182
|
requiredProofs: this.requiredProofs,
|
|
@@ -11178,7 +11191,10 @@ var Polyguard = (() => {
|
|
|
11178
11191
|
reconnectionDelayGrowFactor: 1.5
|
|
11179
11192
|
};
|
|
11180
11193
|
ws = new reconnecting_websocket_mjs_default(wsUrl, [], options);
|
|
11181
|
-
const
|
|
11194
|
+
const markClosed = () => {
|
|
11195
|
+
closed = true;
|
|
11196
|
+
};
|
|
11197
|
+
const ctx = { ws, qrDiv, isTargetMode, modal, cleanup, markClosed, returnError, clearError, resolve, rawJwt, sidebarUrl: this.sidebarUrl, link_uuid: this.link_uuid };
|
|
11182
11198
|
ws.addEventListener("message", (event) => handleWebSocketMessage(event, ctx));
|
|
11183
11199
|
ws.addEventListener("error", () => {
|
|
11184
11200
|
console.error("WebSocket error");
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// src/server.js
|
|
2
|
+
var DEFAULT_ALLOWED_PATHS = [
|
|
3
|
+
"POST /v2/ticket/",
|
|
4
|
+
"PUT /link/",
|
|
5
|
+
"GET /v2/preview/"
|
|
6
|
+
];
|
|
7
|
+
var HOP_BY_HOP = /* @__PURE__ */ new Set([
|
|
8
|
+
"connection",
|
|
9
|
+
"keep-alive",
|
|
10
|
+
"proxy-authenticate",
|
|
11
|
+
"proxy-authorization",
|
|
12
|
+
"te",
|
|
13
|
+
"trailer",
|
|
14
|
+
"transfer-encoding",
|
|
15
|
+
"upgrade"
|
|
16
|
+
]);
|
|
17
|
+
function isAllowed(method, pathname, allowedPaths) {
|
|
18
|
+
const upper = method.toUpperCase();
|
|
19
|
+
for (const rule of allowedPaths) {
|
|
20
|
+
const idx = rule.indexOf(" ");
|
|
21
|
+
if (idx < 0) continue;
|
|
22
|
+
const ruleMethod = rule.slice(0, idx).toUpperCase();
|
|
23
|
+
const rulePrefix = rule.slice(idx + 1);
|
|
24
|
+
if (ruleMethod === upper && pathname.startsWith(rulePrefix)) {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
function createProxyHandler(options = {}) {
|
|
31
|
+
const { apiKey, apiServer = "api.polyguard.ai", allowedPaths = DEFAULT_ALLOWED_PATHS, pathPrefix = "/api/polyguard" } = options;
|
|
32
|
+
if (!apiKey) {
|
|
33
|
+
throw new Error("createProxyHandler: apiKey is required");
|
|
34
|
+
}
|
|
35
|
+
if (!apiServer) {
|
|
36
|
+
throw new Error("createProxyHandler: apiServer is required");
|
|
37
|
+
}
|
|
38
|
+
return async function polyguardProxy(request) {
|
|
39
|
+
const url = new URL(request.url);
|
|
40
|
+
let pathname = url.pathname;
|
|
41
|
+
if (pathPrefix && pathname.startsWith(pathPrefix)) {
|
|
42
|
+
pathname = pathname.slice(pathPrefix.length) || "/";
|
|
43
|
+
}
|
|
44
|
+
if (!isAllowed(request.method, pathname, allowedPaths)) {
|
|
45
|
+
return new Response("Not Found", { status: 404 });
|
|
46
|
+
}
|
|
47
|
+
const targetUrl = `https://${apiServer}${pathname}${url.search}`;
|
|
48
|
+
const forwardHeaders = new Headers();
|
|
49
|
+
for (const [name, value] of request.headers) {
|
|
50
|
+
const lower = name.toLowerCase();
|
|
51
|
+
if (lower === "x-pg-api-key" || lower === "authorization") continue;
|
|
52
|
+
if (HOP_BY_HOP.has(lower)) continue;
|
|
53
|
+
if (lower === "host") continue;
|
|
54
|
+
forwardHeaders.set(name, value);
|
|
55
|
+
}
|
|
56
|
+
forwardHeaders.set("x-pg-api-key", apiKey);
|
|
57
|
+
const init = {
|
|
58
|
+
method: request.method,
|
|
59
|
+
headers: forwardHeaders,
|
|
60
|
+
redirect: "manual"
|
|
61
|
+
};
|
|
62
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
63
|
+
init.body = request.body;
|
|
64
|
+
init.duplex = "half";
|
|
65
|
+
}
|
|
66
|
+
const upstream = await fetch(targetUrl, init);
|
|
67
|
+
const responseHeaders = new Headers();
|
|
68
|
+
for (const [name, value] of upstream.headers) {
|
|
69
|
+
const lower = name.toLowerCase();
|
|
70
|
+
if (HOP_BY_HOP.has(lower)) continue;
|
|
71
|
+
if (lower === "set-cookie") continue;
|
|
72
|
+
responseHeaders.set(name, value);
|
|
73
|
+
}
|
|
74
|
+
return new Response(upstream.body, {
|
|
75
|
+
status: upstream.status,
|
|
76
|
+
statusText: upstream.statusText,
|
|
77
|
+
headers: responseHeaders
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
export {
|
|
82
|
+
createProxyHandler
|
|
83
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polyguard/sdk",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/sdk.esm.js",
|
|
6
6
|
"module": "dist/sdk.esm.js",
|
|
@@ -10,15 +10,21 @@
|
|
|
10
10
|
"import": "./dist/sdk.esm.js",
|
|
11
11
|
"require": "./dist/sdk.esm.js",
|
|
12
12
|
"default": "./dist/sdk.esm.js"
|
|
13
|
+
},
|
|
14
|
+
"./server": {
|
|
15
|
+
"import": "./dist/server.esm.js",
|
|
16
|
+
"default": "./dist/server.esm.js"
|
|
13
17
|
}
|
|
14
18
|
},
|
|
15
19
|
"scripts": {
|
|
16
|
-
"build": "npm run build:esm && npm run build:iife",
|
|
20
|
+
"build": "npm run build:esm && npm run build:iife && npm run build:server",
|
|
17
21
|
"build:esm": "esbuild src/index.js --bundle --format=esm --outfile=dist/sdk.esm.js",
|
|
18
22
|
"build:iife": "esbuild src/browser.js --bundle --format=iife --global-name=Polyguard --outfile=dist/sdk.js",
|
|
23
|
+
"build:server": "esbuild src/server.js --bundle --format=esm --platform=neutral --outfile=dist/server.esm.js",
|
|
19
24
|
"prepare": "npm run build",
|
|
20
25
|
"test": "vitest run",
|
|
21
26
|
"test:watch": "vitest",
|
|
27
|
+
"test:coverage": "vitest run --coverage",
|
|
22
28
|
"regenerate-client": "bash scripts/regenerate-client.sh"
|
|
23
29
|
},
|
|
24
30
|
"keywords": [],
|
|
@@ -31,6 +37,7 @@
|
|
|
31
37
|
"superagent": "^10.3.0"
|
|
32
38
|
},
|
|
33
39
|
"devDependencies": {
|
|
40
|
+
"@vitest/coverage-v8": "^4.1.6",
|
|
34
41
|
"esbuild": "^0.25.0",
|
|
35
42
|
"jsdom": "^28.0.0",
|
|
36
43
|
"vitest": "^4.0.18"
|
|
@@ -8,15 +8,28 @@ import { handleWebSocketMessage } from './messageHandler.js';
|
|
|
8
8
|
export class PolyguardWebsocketClientImpl {
|
|
9
9
|
constructor(params = {}) {
|
|
10
10
|
this.apiClient = new PolyguardApi.ApiClient();
|
|
11
|
-
const { apiKey, baseUrl, appId, apiServer, requiredProofs = ['Full Name'], scanType = 'single', redirectUrl, callbackUrl, cookieName, link_uuid, sidebarUrl } = params;
|
|
11
|
+
const { apiKey, baseUrl, appId, apiServer, proxyUrl, iKnowThisIsInsecure, requiredProofs = ['Full Name'], scanType = 'single', redirectUrl, callbackUrl, cookieName, link_uuid, sidebarUrl } = params;
|
|
12
12
|
this.sidebarUrl = sidebarUrl;
|
|
13
|
-
|
|
13
|
+
|
|
14
|
+
if (apiKey && typeof window !== 'undefined' && iKnowThisIsInsecure !== true) {
|
|
15
|
+
throw new Error(
|
|
16
|
+
'apiKey is forbidden in browser code; use proxyUrl with a server-side proxy (see @polyguard/sdk/server). ' +
|
|
17
|
+
'Set iKnowThisIsInsecure: true to bypass.'
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (proxyUrl) {
|
|
22
|
+
this.apiClient.basePath = proxyUrl.replace(/\/$/, '');
|
|
23
|
+
} else if (baseUrl) {
|
|
14
24
|
this.apiClient.basePath = baseUrl;
|
|
15
25
|
}
|
|
26
|
+
|
|
16
27
|
if (apiKey) {
|
|
17
28
|
if (this.apiClient.authentications['bearerAuth']) {
|
|
18
29
|
this.apiClient.authentications['bearerAuth'].apiKey = apiKey;
|
|
19
30
|
}
|
|
31
|
+
this.apiClient.defaultHeaders = this.apiClient.defaultHeaders || {};
|
|
32
|
+
this.apiClient.defaultHeaders['x-pg-api-key'] = apiKey;
|
|
20
33
|
}
|
|
21
34
|
// Instantiate all API services
|
|
22
35
|
for (const key in PolyguardApi) {
|
|
@@ -29,6 +42,7 @@ export class PolyguardWebsocketClientImpl {
|
|
|
29
42
|
this.integrationType = 'websocket';
|
|
30
43
|
this.appId = appId;
|
|
31
44
|
this.apiServer = apiServer;
|
|
45
|
+
this.proxyUrl = proxyUrl;
|
|
32
46
|
this.requiredProofs = requiredProofs;
|
|
33
47
|
this.scanType = scanType;
|
|
34
48
|
this.redirectUrl = redirectUrl;
|
|
@@ -120,6 +134,7 @@ export class PolyguardWebsocketClientImpl {
|
|
|
120
134
|
|
|
121
135
|
const newTicket = await fetchTicket({
|
|
122
136
|
apiServer: this.apiServer,
|
|
137
|
+
proxyUrl: this.proxyUrl,
|
|
123
138
|
appId: this.appId,
|
|
124
139
|
link_uuid: this.link_uuid,
|
|
125
140
|
requiredProofs: this.requiredProofs,
|
|
@@ -136,7 +151,8 @@ export class PolyguardWebsocketClientImpl {
|
|
|
136
151
|
};
|
|
137
152
|
ws = new ReconnectingWebSocket(wsUrl, [], options);
|
|
138
153
|
|
|
139
|
-
const
|
|
154
|
+
const markClosed = () => { closed = true; };
|
|
155
|
+
const ctx = { ws, qrDiv, isTargetMode, modal, cleanup, markClosed, returnError, clearError, resolve, rawJwt, sidebarUrl: this.sidebarUrl, link_uuid: this.link_uuid };
|
|
140
156
|
ws.addEventListener('message', (event) => handleWebSocketMessage(event, ctx));
|
|
141
157
|
|
|
142
158
|
ws.addEventListener('error', () => {
|
|
@@ -162,11 +162,35 @@ describe('PolyguardWebsocketClientImpl', () => {
|
|
|
162
162
|
expect(client.apiClient.basePath).toBe('https://custom.api');
|
|
163
163
|
});
|
|
164
164
|
|
|
165
|
-
it('
|
|
166
|
-
|
|
165
|
+
it('throws when apiKey is provided in browser without iKnowThisIsInsecure flag', () => {
|
|
166
|
+
expect(() => new PolyguardWebsocketClientImpl({ ...DEFAULT_PARAMS, apiKey: 'my-key' })).toThrow(/apiKey is forbidden in browser code/);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('accepts apiKey in browser with iKnowThisIsInsecure: true', () => {
|
|
170
|
+
const client = new PolyguardWebsocketClientImpl({ ...DEFAULT_PARAMS, apiKey: 'my-key', iKnowThisIsInsecure: true });
|
|
167
171
|
expect(client.apiClient.authentications.bearerAuth.apiKey).toBe('my-key');
|
|
168
172
|
});
|
|
169
173
|
|
|
174
|
+
it('also sets x-pg-api-key default header when apiKey is accepted', () => {
|
|
175
|
+
const client = new PolyguardWebsocketClientImpl({ ...DEFAULT_PARAMS, apiKey: 'my-key', iKnowThisIsInsecure: true });
|
|
176
|
+
expect(client.apiClient.defaultHeaders['x-pg-api-key']).toBe('my-key');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('sets basePath from proxyUrl and strips trailing slash', () => {
|
|
180
|
+
const client = new PolyguardWebsocketClientImpl({ ...DEFAULT_PARAMS, proxyUrl: '/api/polyguard/' });
|
|
181
|
+
expect(client.apiClient.basePath).toBe('/api/polyguard');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('proxyUrl takes precedence over baseUrl', () => {
|
|
185
|
+
const client = new PolyguardWebsocketClientImpl({ ...DEFAULT_PARAMS, proxyUrl: '/api/polyguard', baseUrl: 'https://other.api' });
|
|
186
|
+
expect(client.apiClient.basePath).toBe('/api/polyguard');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('stores proxyUrl on the instance', () => {
|
|
190
|
+
const client = new PolyguardWebsocketClientImpl({ ...DEFAULT_PARAMS, proxyUrl: '/api/polyguard' });
|
|
191
|
+
expect(client.proxyUrl).toBe('/api/polyguard');
|
|
192
|
+
});
|
|
193
|
+
|
|
170
194
|
it('sets integrationType to websocket', () => {
|
|
171
195
|
const client = new PolyguardWebsocketClientImpl(DEFAULT_PARAMS);
|
|
172
196
|
expect(client.integrationType).toBe('websocket');
|
|
@@ -255,6 +279,25 @@ describe('PolyguardWebsocketClientImpl', () => {
|
|
|
255
279
|
await promise.catch(() => {});
|
|
256
280
|
});
|
|
257
281
|
|
|
282
|
+
it('builds ticket URL through proxyUrl when set, bypassing apiServer', async () => {
|
|
283
|
+
const fetchMock = stubFetch();
|
|
284
|
+
const client = new PolyguardWebsocketClientImpl({ ...DEFAULT_PARAMS, proxyUrl: '/api/polyguard' });
|
|
285
|
+
const { promise, ws } = await startVerifyAndGetWs(client);
|
|
286
|
+
const url = fetchMock.mock.calls[0][0];
|
|
287
|
+
expect(url).toBe(`/api/polyguard/v2/ticket/${DEFAULT_PARAMS.appId}`);
|
|
288
|
+
if (ws) ws.simulateMessage({ jwt: 'cleanup' });
|
|
289
|
+
await promise.catch(() => {});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('still uses apiServer for WebSocket upgrade even when proxyUrl is set', async () => {
|
|
293
|
+
stubFetch();
|
|
294
|
+
const client = new PolyguardWebsocketClientImpl({ ...DEFAULT_PARAMS, proxyUrl: '/api/polyguard' });
|
|
295
|
+
const { promise, ws } = await startVerifyAndGetWs(client);
|
|
296
|
+
expect(ws.url).toBe(`wss://${DEFAULT_PARAMS.apiServer}/v2/realtime/test-ticket-123`);
|
|
297
|
+
ws.simulateMessage({ jwt: 'cleanup' });
|
|
298
|
+
await promise.catch(() => {});
|
|
299
|
+
});
|
|
300
|
+
|
|
258
301
|
it('sends POST with requiredProofs and scanType in body', async () => {
|
|
259
302
|
const fetchMock = stubFetch();
|
|
260
303
|
const client = new PolyguardWebsocketClientImpl(DEFAULT_PARAMS);
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { createProxyHandler } from '../server.js';
|
|
3
|
+
|
|
4
|
+
function makeRequest(method, url, { headers = {}, body = null } = {}) {
|
|
5
|
+
const init = { method, headers };
|
|
6
|
+
if (body !== null) init.body = typeof body === 'string' ? body : JSON.stringify(body);
|
|
7
|
+
return new Request(url, init);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function stubFetch(responseOpts = {}) {
|
|
11
|
+
const { status = 200, body = '{"ok":true}', headers = { 'content-type': 'application/json' } } = responseOpts;
|
|
12
|
+
const mock = vi.fn().mockImplementation(async () => new Response(body, { status, headers }));
|
|
13
|
+
vi.stubGlobal('fetch', mock);
|
|
14
|
+
return mock;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('createProxyHandler', () => {
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
vi.restoreAllMocks();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('throws when apiKey is missing', () => {
|
|
23
|
+
expect(() => createProxyHandler({ apiServer: 'api.polyguard.ai' })).toThrow(/apiKey is required/);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('throws when apiServer is falsy', () => {
|
|
27
|
+
expect(() => createProxyHandler({ apiKey: 'pgk_live_x.y', apiServer: '' })).toThrow(/apiServer is required/);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('returns 404 for paths not in the allowlist', async () => {
|
|
31
|
+
stubFetch();
|
|
32
|
+
const handler = createProxyHandler({ apiKey: 'pgk_live_x.y', apiServer: 'api.polyguard.ai' });
|
|
33
|
+
const req = makeRequest('GET', 'http://localhost/api/polyguard/account/1/apps/3/api-keys');
|
|
34
|
+
const res = await handler(req);
|
|
35
|
+
expect(res.status).toBe(404);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('returns 404 for an allowlisted prefix used with the wrong HTTP method', async () => {
|
|
39
|
+
stubFetch();
|
|
40
|
+
const handler = createProxyHandler({ apiKey: 'pgk_live_x.y', apiServer: 'api.polyguard.ai' });
|
|
41
|
+
const req = makeRequest('GET', 'http://localhost/api/polyguard/v2/ticket/3');
|
|
42
|
+
const res = await handler(req);
|
|
43
|
+
expect(res.status).toBe(404);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('forwards POST /v2/ticket/* with x-pg-api-key injected', async () => {
|
|
47
|
+
const fetchMock = stubFetch();
|
|
48
|
+
const handler = createProxyHandler({ apiKey: 'pgk_live_x.y', apiServer: 'api.polyguard.ai' });
|
|
49
|
+
const req = makeRequest('POST', 'http://localhost/api/polyguard/v2/ticket/3', {
|
|
50
|
+
headers: { 'content-type': 'application/json' },
|
|
51
|
+
body: { requiredProofs: ['name'], scanType: 'single' },
|
|
52
|
+
});
|
|
53
|
+
const res = await handler(req);
|
|
54
|
+
expect(res.status).toBe(200);
|
|
55
|
+
expect(fetchMock).toHaveBeenCalledOnce();
|
|
56
|
+
const [forwardedUrl, init] = fetchMock.mock.calls[0];
|
|
57
|
+
expect(forwardedUrl).toBe('https://api.polyguard.ai/v2/ticket/3');
|
|
58
|
+
expect(init.method).toBe('POST');
|
|
59
|
+
const headers = new Headers(init.headers);
|
|
60
|
+
expect(headers.get('x-pg-api-key')).toBe('pgk_live_x.y');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('strips client-supplied x-pg-api-key before injecting', async () => {
|
|
64
|
+
const fetchMock = stubFetch();
|
|
65
|
+
const handler = createProxyHandler({ apiKey: 'pgk_live_real.secret', apiServer: 'api.polyguard.ai' });
|
|
66
|
+
const req = makeRequest('POST', 'http://localhost/api/polyguard/v2/ticket/3', {
|
|
67
|
+
headers: { 'x-pg-api-key': 'pgk_live_attacker.smuggled', 'content-type': 'application/json' },
|
|
68
|
+
body: {},
|
|
69
|
+
});
|
|
70
|
+
await handler(req);
|
|
71
|
+
const init = fetchMock.mock.calls[0][1];
|
|
72
|
+
const headers = new Headers(init.headers);
|
|
73
|
+
expect(headers.get('x-pg-api-key')).toBe('pgk_live_real.secret');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('strips client-supplied authorization header', async () => {
|
|
77
|
+
const fetchMock = stubFetch();
|
|
78
|
+
const handler = createProxyHandler({ apiKey: 'pgk_live_x.y', apiServer: 'api.polyguard.ai' });
|
|
79
|
+
const req = makeRequest('POST', 'http://localhost/api/polyguard/v2/ticket/3', {
|
|
80
|
+
headers: { authorization: 'Bearer pgk_live_attacker.smuggled', 'content-type': 'application/json' },
|
|
81
|
+
body: {},
|
|
82
|
+
});
|
|
83
|
+
await handler(req);
|
|
84
|
+
const init = fetchMock.mock.calls[0][1];
|
|
85
|
+
const headers = new Headers(init.headers);
|
|
86
|
+
expect(headers.get('authorization')).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('preserves query string when forwarding', async () => {
|
|
90
|
+
const fetchMock = stubFetch();
|
|
91
|
+
const handler = createProxyHandler({ apiKey: 'pgk_live_x.y', apiServer: 'api.polyguard.ai' });
|
|
92
|
+
const req = makeRequest('GET', 'http://localhost/api/polyguard/v2/preview/3/link-abc?format=svg');
|
|
93
|
+
await handler(req);
|
|
94
|
+
expect(fetchMock.mock.calls[0][0]).toBe('https://api.polyguard.ai/v2/preview/3/link-abc?format=svg');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('forwards PUT /link/* (link creation)', async () => {
|
|
98
|
+
const fetchMock = stubFetch();
|
|
99
|
+
const handler = createProxyHandler({ apiKey: 'pgk_live_x.y', apiServer: 'api.polyguard.ai' });
|
|
100
|
+
const req = makeRequest('PUT', 'http://localhost/api/polyguard/link/3', {
|
|
101
|
+
headers: { 'content-type': 'application/json' },
|
|
102
|
+
body: { type: 'login' },
|
|
103
|
+
});
|
|
104
|
+
const res = await handler(req);
|
|
105
|
+
expect(res.status).toBe(200);
|
|
106
|
+
expect(fetchMock.mock.calls[0][0]).toBe('https://api.polyguard.ai/link/3');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('honors a custom allowedPaths list', async () => {
|
|
110
|
+
stubFetch();
|
|
111
|
+
const handler = createProxyHandler({
|
|
112
|
+
apiKey: 'pgk_live_x.y',
|
|
113
|
+
apiServer: 'api.polyguard.ai',
|
|
114
|
+
allowedPaths: ['POST /v2/ticket/'],
|
|
115
|
+
});
|
|
116
|
+
const denied = await handler(makeRequest('PUT', 'http://localhost/api/polyguard/link/3'));
|
|
117
|
+
expect(denied.status).toBe(404);
|
|
118
|
+
const allowed = await handler(makeRequest('POST', 'http://localhost/api/polyguard/v2/ticket/3', { body: {} }));
|
|
119
|
+
expect(allowed.status).toBe(200);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('honors a custom pathPrefix', async () => {
|
|
123
|
+
const fetchMock = stubFetch();
|
|
124
|
+
const handler = createProxyHandler({
|
|
125
|
+
apiKey: 'pgk_live_x.y',
|
|
126
|
+
apiServer: 'api.polyguard.ai',
|
|
127
|
+
pathPrefix: '/custom/proxy',
|
|
128
|
+
});
|
|
129
|
+
await handler(makeRequest('POST', 'http://localhost/custom/proxy/v2/ticket/3', { body: {} }));
|
|
130
|
+
expect(fetchMock.mock.calls[0][0]).toBe('https://api.polyguard.ai/v2/ticket/3');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('strips hop-by-hop response headers and set-cookie', async () => {
|
|
134
|
+
stubFetch({
|
|
135
|
+
headers: {
|
|
136
|
+
'content-type': 'application/json',
|
|
137
|
+
'set-cookie': 'session=abc; path=/',
|
|
138
|
+
'transfer-encoding': 'chunked',
|
|
139
|
+
'x-custom': 'visible',
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
const handler = createProxyHandler({ apiKey: 'pgk_live_x.y', apiServer: 'api.polyguard.ai' });
|
|
143
|
+
const res = await handler(makeRequest('POST', 'http://localhost/api/polyguard/v2/ticket/3', { body: {} }));
|
|
144
|
+
expect(res.headers.get('set-cookie')).toBeNull();
|
|
145
|
+
expect(res.headers.get('transfer-encoding')).toBeNull();
|
|
146
|
+
expect(res.headers.get('x-custom')).toBe('visible');
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -243,6 +243,30 @@ describe('sidebarUrl feature', () => {
|
|
|
243
243
|
await promise;
|
|
244
244
|
expect(window.location.assign).toHaveBeenCalledWith('https://teams.microsoft.com/meet/123');
|
|
245
245
|
});
|
|
246
|
+
|
|
247
|
+
it('button survives a late WebSocket close event (race regression)', async () => {
|
|
248
|
+
// Real browsers dispatch the 'close' event asynchronously after ws.close(),
|
|
249
|
+
// so it lands after the synchronous button append. If the close listener's
|
|
250
|
+
// cleanup() runs in that window, it wipes the qrDiv — and the button with it.
|
|
251
|
+
// markClosed() in the message handler must defuse the close listener.
|
|
252
|
+
clearCookies();
|
|
253
|
+
vi.stubGlobal('open', vi.fn().mockReturnValue({}));
|
|
254
|
+
const client = new PolyguardWebsocketClientImpl({
|
|
255
|
+
...DEFAULT_PARAMS,
|
|
256
|
+
sidebarUrl: 'https://teams.polyguard.ai/ms-teams/sidebar-standalone',
|
|
257
|
+
link_uuid: 'link-abc',
|
|
258
|
+
});
|
|
259
|
+
document.body.innerHTML = '<div id="test-target"></div>';
|
|
260
|
+
const jwtClaims = { redirect_url: 'https://teams.microsoft.com/meet/123' };
|
|
261
|
+
const { promise, ws } = await startVerifyAndGetWs(client, 'test-target');
|
|
262
|
+
ws.simulateMessage({ jwt: jwtClaims });
|
|
263
|
+
expect(document.querySelector('#polyguard-join-meeting')).not.toBeNull();
|
|
264
|
+
// Mimic a real browser dispatching close on the next tick (after the message handler returned).
|
|
265
|
+
ws.simulateClose();
|
|
266
|
+
expect(document.querySelector('#polyguard-join-meeting')).not.toBeNull();
|
|
267
|
+
document.querySelector('#polyguard-join-meeting').click();
|
|
268
|
+
await promise;
|
|
269
|
+
});
|
|
246
270
|
});
|
|
247
271
|
|
|
248
272
|
describe('with sidebarUrl but no redirect_url in JWT (non-meeting links)', () => {
|
package/src/messageHandler.js
CHANGED
|
@@ -61,6 +61,11 @@ export function handleWebSocketMessage(event, ctx) {
|
|
|
61
61
|
// the SDK handles the redirect (and optionally opens a sidebar popup).
|
|
62
62
|
if (sidebarUrl && redirectUrl) {
|
|
63
63
|
const hasMsftSession = document.cookie.includes('pg_msft_session');
|
|
64
|
+
// Mark the socket as intentionally closed BEFORE ws.close() so the
|
|
65
|
+
// 'close' event listener in verify() skips its own cleanup(). Otherwise
|
|
66
|
+
// the async close handler races with the synchronous button append below
|
|
67
|
+
// and wipes the qrDiv after we've put the button in it.
|
|
68
|
+
ctx.markClosed();
|
|
64
69
|
cleanup();
|
|
65
70
|
ws.close();
|
|
66
71
|
|
package/src/server.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
const DEFAULT_ALLOWED_PATHS = [
|
|
2
|
+
'POST /v2/ticket/',
|
|
3
|
+
'PUT /link/',
|
|
4
|
+
'GET /v2/preview/',
|
|
5
|
+
];
|
|
6
|
+
|
|
7
|
+
const HOP_BY_HOP = new Set([
|
|
8
|
+
'connection',
|
|
9
|
+
'keep-alive',
|
|
10
|
+
'proxy-authenticate',
|
|
11
|
+
'proxy-authorization',
|
|
12
|
+
'te',
|
|
13
|
+
'trailer',
|
|
14
|
+
'transfer-encoding',
|
|
15
|
+
'upgrade',
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
function isAllowed(method, pathname, allowedPaths) {
|
|
19
|
+
const upper = method.toUpperCase();
|
|
20
|
+
for (const rule of allowedPaths) {
|
|
21
|
+
const idx = rule.indexOf(' ');
|
|
22
|
+
if (idx < 0) continue;
|
|
23
|
+
const ruleMethod = rule.slice(0, idx).toUpperCase();
|
|
24
|
+
const rulePrefix = rule.slice(idx + 1);
|
|
25
|
+
if (ruleMethod === upper && pathname.startsWith(rulePrefix)) {
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function createProxyHandler(options = {}) {
|
|
33
|
+
const { apiKey, apiServer = 'api.polyguard.ai', allowedPaths = DEFAULT_ALLOWED_PATHS, pathPrefix = '/api/polyguard' } = options;
|
|
34
|
+
|
|
35
|
+
if (!apiKey) {
|
|
36
|
+
throw new Error('createProxyHandler: apiKey is required');
|
|
37
|
+
}
|
|
38
|
+
if (!apiServer) {
|
|
39
|
+
throw new Error('createProxyHandler: apiServer is required');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return async function polyguardProxy(request) {
|
|
43
|
+
const url = new URL(request.url);
|
|
44
|
+
let pathname = url.pathname;
|
|
45
|
+
if (pathPrefix && pathname.startsWith(pathPrefix)) {
|
|
46
|
+
pathname = pathname.slice(pathPrefix.length) || '/';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (!isAllowed(request.method, pathname, allowedPaths)) {
|
|
50
|
+
return new Response('Not Found', { status: 404 });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const targetUrl = `https://${apiServer}${pathname}${url.search}`;
|
|
54
|
+
|
|
55
|
+
const forwardHeaders = new Headers();
|
|
56
|
+
for (const [name, value] of request.headers) {
|
|
57
|
+
const lower = name.toLowerCase();
|
|
58
|
+
if (lower === 'x-pg-api-key' || lower === 'authorization') continue;
|
|
59
|
+
if (HOP_BY_HOP.has(lower)) continue;
|
|
60
|
+
if (lower === 'host') continue;
|
|
61
|
+
forwardHeaders.set(name, value);
|
|
62
|
+
}
|
|
63
|
+
forwardHeaders.set('x-pg-api-key', apiKey);
|
|
64
|
+
|
|
65
|
+
const init = {
|
|
66
|
+
method: request.method,
|
|
67
|
+
headers: forwardHeaders,
|
|
68
|
+
redirect: 'manual',
|
|
69
|
+
};
|
|
70
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
71
|
+
init.body = request.body;
|
|
72
|
+
init.duplex = 'half';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const upstream = await fetch(targetUrl, init);
|
|
76
|
+
|
|
77
|
+
const responseHeaders = new Headers();
|
|
78
|
+
for (const [name, value] of upstream.headers) {
|
|
79
|
+
const lower = name.toLowerCase();
|
|
80
|
+
if (HOP_BY_HOP.has(lower)) continue;
|
|
81
|
+
if (lower === 'set-cookie') continue;
|
|
82
|
+
responseHeaders.set(name, value);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return new Response(upstream.body, {
|
|
86
|
+
status: upstream.status,
|
|
87
|
+
statusText: upstream.statusText,
|
|
88
|
+
headers: responseHeaders,
|
|
89
|
+
});
|
|
90
|
+
};
|
|
91
|
+
}
|
package/src/ticketService.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
export async function fetchTicket({ apiServer, appId, link_uuid, requiredProofs, scanType }) {
|
|
1
|
+
export async function fetchTicket({ apiServer, proxyUrl, appId, link_uuid, requiredProofs, scanType }) {
|
|
2
|
+
const base = proxyUrl
|
|
3
|
+
? proxyUrl.replace(/\/$/, '')
|
|
4
|
+
: `https://${apiServer}`;
|
|
5
|
+
|
|
2
6
|
const ticketUrl = link_uuid
|
|
3
|
-
?
|
|
4
|
-
:
|
|
7
|
+
? `${base}/v2/ticket/${appId}/${link_uuid}`
|
|
8
|
+
: `${base}/v2/ticket/${appId}`;
|
|
5
9
|
|
|
6
10
|
const ticketRes = await fetch(ticketUrl, {
|
|
7
11
|
method: 'POST',
|