@particle-academy/fancy-connector-core 0.12.0 → 0.13.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/CHANGELOG.md +42 -0
- package/dist/index.cjs +371 -172
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +259 -97
- package/dist/index.d.ts +259 -97
- package/dist/index.js +362 -172
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1232,6 +1232,88 @@ function fnv1a(value) {
|
|
|
1232
1232
|
return hash.toString(16).padStart(8, "0");
|
|
1233
1233
|
}
|
|
1234
1234
|
|
|
1235
|
+
// src/jwt.ts
|
|
1236
|
+
var JWT_ALGORITHMS = ["RS256"];
|
|
1237
|
+
var fail = (reason) => ({ ok: false, reason });
|
|
1238
|
+
async function verifyJwt(token, options) {
|
|
1239
|
+
const parts = typeof token === "string" ? token.split(".") : [];
|
|
1240
|
+
if (parts.length !== 3 || parts.some((p2, i) => p2 === "" && i < 2)) {
|
|
1241
|
+
return fail("token is not a JWS compact serialization");
|
|
1242
|
+
}
|
|
1243
|
+
const [h, p, s] = parts;
|
|
1244
|
+
const header3 = jsonSegment(h);
|
|
1245
|
+
if (!header3) return fail("token header is not base64url JSON");
|
|
1246
|
+
if (!JWT_ALGORITHMS.includes(header3.alg)) {
|
|
1247
|
+
return fail(`alg ${JSON.stringify(header3.alg)} is not accepted`);
|
|
1248
|
+
}
|
|
1249
|
+
const key = selectKey(options.keys, header3.kid);
|
|
1250
|
+
if ("reason" in key) return fail(key.reason);
|
|
1251
|
+
const signature = base64urlBytes(s);
|
|
1252
|
+
if (!signature) return fail("signature is not base64url");
|
|
1253
|
+
const subtle = globalThis.crypto?.subtle;
|
|
1254
|
+
if (!subtle) return fail("no WebCrypto in this runtime");
|
|
1255
|
+
let valid;
|
|
1256
|
+
try {
|
|
1257
|
+
const imported = await subtle.importKey(
|
|
1258
|
+
"jwk",
|
|
1259
|
+
{ kty: "RSA", n: key.jwk.n, e: key.jwk.e, alg: "RS256", ext: true },
|
|
1260
|
+
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
|
1261
|
+
false,
|
|
1262
|
+
["verify"]
|
|
1263
|
+
);
|
|
1264
|
+
valid = await subtle.verify("RSASSA-PKCS1-v1_5", imported, signature, new TextEncoder().encode(`${h}.${p}`));
|
|
1265
|
+
} catch {
|
|
1266
|
+
return fail("the key could not be used to verify");
|
|
1267
|
+
}
|
|
1268
|
+
if (!valid) return fail("signature did not match");
|
|
1269
|
+
const claims = jsonSegment(p);
|
|
1270
|
+
if (!claims) return fail("token payload is not base64url JSON");
|
|
1271
|
+
const now = options.now ?? Math.floor(Date.now() / 1e3);
|
|
1272
|
+
const leeway = options.leeway ?? 60;
|
|
1273
|
+
if (typeof claims.exp !== "number") return fail("token has no exp");
|
|
1274
|
+
if (now > claims.exp + leeway) return fail("token has expired");
|
|
1275
|
+
if (typeof claims.nbf === "number" && now + leeway < claims.nbf) return fail("token is not valid yet");
|
|
1276
|
+
if (options.issuer !== void 0) {
|
|
1277
|
+
const issuers = typeof options.issuer === "string" ? [options.issuer] : options.issuer;
|
|
1278
|
+
if (typeof claims.iss !== "string" || !issuers.includes(claims.iss)) return fail("token issuer is not accepted");
|
|
1279
|
+
}
|
|
1280
|
+
if (options.audience !== null) {
|
|
1281
|
+
const wanted = typeof options.audience === "string" ? [options.audience] : options.audience;
|
|
1282
|
+
const offered = typeof claims.aud === "string" ? [claims.aud] : Array.isArray(claims.aud) ? claims.aud : [];
|
|
1283
|
+
if (!offered.some((a) => typeof a === "string" && wanted.includes(a))) return fail("token audience is not accepted");
|
|
1284
|
+
}
|
|
1285
|
+
return { ok: true, header: header3, claims };
|
|
1286
|
+
}
|
|
1287
|
+
function selectKey(keys, kid) {
|
|
1288
|
+
const rsa = keys.filter((k) => k.kty === "RSA" && typeof k.n === "string" && typeof k.e === "string");
|
|
1289
|
+
if (typeof kid === "string") {
|
|
1290
|
+
const match = rsa.find((k) => k.kid === kid);
|
|
1291
|
+
return match ? { jwk: match } : { reason: `no key for kid ${JSON.stringify(kid)}` };
|
|
1292
|
+
}
|
|
1293
|
+
const [only] = rsa;
|
|
1294
|
+
return rsa.length === 1 && only ? { jwk: only } : { reason: "token names no kid and the key is ambiguous" };
|
|
1295
|
+
}
|
|
1296
|
+
function jsonSegment(segment) {
|
|
1297
|
+
const bytes = base64urlBytes(segment);
|
|
1298
|
+
if (!bytes) return null;
|
|
1299
|
+
try {
|
|
1300
|
+
const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
1301
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
1302
|
+
} catch {
|
|
1303
|
+
return null;
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
function base64urlBytes(segment) {
|
|
1307
|
+
if (!/^[A-Za-z0-9_-]*$/.test(segment) || segment.length % 4 === 1) return null;
|
|
1308
|
+
const standard = segment.replace(/-/g, "+").replace(/_/g, "/");
|
|
1309
|
+
const padded = standard + "=".repeat((4 - standard.length % 4) % 4);
|
|
1310
|
+
try {
|
|
1311
|
+
return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
|
|
1312
|
+
} catch {
|
|
1313
|
+
return null;
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1235
1317
|
// src/lease.ts
|
|
1236
1318
|
var EXPIRES_AT_UNITS = ["rfc3339", "epoch-ms"];
|
|
1237
1319
|
var INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
@@ -1666,6 +1748,65 @@ function parseMime(raw) {
|
|
|
1666
1748
|
};
|
|
1667
1749
|
}
|
|
1668
1750
|
|
|
1751
|
+
// src/pubsub-push.ts
|
|
1752
|
+
var PUBSUB_PUSH_REASONS = ["not-json", "missing-message", "missing-data", "bad-encoding", "data-not-json"];
|
|
1753
|
+
var PubSubPushError = class extends Error {
|
|
1754
|
+
constructor(reason, message) {
|
|
1755
|
+
super(message);
|
|
1756
|
+
this.reason = reason;
|
|
1757
|
+
this.name = "PubSubPushError";
|
|
1758
|
+
}
|
|
1759
|
+
reason;
|
|
1760
|
+
};
|
|
1761
|
+
var UTF8 = new TextDecoder("utf-8", { fatal: true });
|
|
1762
|
+
function decodePubSubPush(raw) {
|
|
1763
|
+
let body;
|
|
1764
|
+
try {
|
|
1765
|
+
body = JSON.parse(typeof raw === "string" ? raw : UTF8.decode(raw));
|
|
1766
|
+
} catch {
|
|
1767
|
+
throw new PubSubPushError("not-json", "the push body is not JSON");
|
|
1768
|
+
}
|
|
1769
|
+
if (!isObject(body)) throw new PubSubPushError("not-json", "the push body is not a JSON object");
|
|
1770
|
+
const message = body.message;
|
|
1771
|
+
if (!isObject(message)) throw new PubSubPushError("missing-message", "the push body has no message");
|
|
1772
|
+
const data = message.data;
|
|
1773
|
+
if (typeof data !== "string" || data === "") {
|
|
1774
|
+
throw new PubSubPushError("missing-data", "the message carries no data");
|
|
1775
|
+
}
|
|
1776
|
+
let text;
|
|
1777
|
+
try {
|
|
1778
|
+
text = UTF8.decode(base64Bytes2(data));
|
|
1779
|
+
} catch {
|
|
1780
|
+
throw new PubSubPushError("bad-encoding", "the message data is not base64 of UTF-8 text");
|
|
1781
|
+
}
|
|
1782
|
+
let parsed;
|
|
1783
|
+
try {
|
|
1784
|
+
parsed = JSON.parse(text);
|
|
1785
|
+
} catch (error) {
|
|
1786
|
+
throw new PubSubPushError("data-not-json", `the message data is not JSON: ${error.message}`);
|
|
1787
|
+
}
|
|
1788
|
+
return {
|
|
1789
|
+
subscription: stringOrNull(body.subscription),
|
|
1790
|
+
messageId: stringOrNull(message.messageId) ?? stringOrNull(message.message_id),
|
|
1791
|
+
publishTime: stringOrNull(message.publishTime) ?? stringOrNull(message.publish_time),
|
|
1792
|
+
attributes: isObject(message.attributes) ? message.attributes : {},
|
|
1793
|
+
deliveryAttempt: Number.isInteger(body.deliveryAttempt) ? body.deliveryAttempt : null,
|
|
1794
|
+
data: parsed
|
|
1795
|
+
};
|
|
1796
|
+
}
|
|
1797
|
+
function base64Bytes2(data) {
|
|
1798
|
+
const unpadded = data.replace(/={1,2}$/, "");
|
|
1799
|
+
if (!/^[A-Za-z0-9+/_-]*$/.test(unpadded) || unpadded.length % 4 === 1) throw new Error("not base64");
|
|
1800
|
+
const standard = unpadded.replace(/-/g, "+").replace(/_/g, "/");
|
|
1801
|
+
return Uint8Array.from(atob(standard + "=".repeat((4 - standard.length % 4) % 4)), (c) => c.charCodeAt(0));
|
|
1802
|
+
}
|
|
1803
|
+
function isObject(value) {
|
|
1804
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1805
|
+
}
|
|
1806
|
+
function stringOrNull(value) {
|
|
1807
|
+
return typeof value === "string" ? value : null;
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1669
1810
|
// src/form-field-json.ts
|
|
1670
1811
|
var FORM_FIELD_JSON_REASONS = ["missing-field", "duplicate-field", "bad-encoding", "not-json"];
|
|
1671
1812
|
var FormFieldJsonError = class extends Error {
|
|
@@ -1678,7 +1819,7 @@ var FormFieldJsonError = class extends Error {
|
|
|
1678
1819
|
reason;
|
|
1679
1820
|
field;
|
|
1680
1821
|
};
|
|
1681
|
-
var
|
|
1822
|
+
var UTF82 = new TextDecoder("utf-8", { fatal: true });
|
|
1682
1823
|
var HEX = /^[0-9A-Fa-f]{2}$/;
|
|
1683
1824
|
function decodeFormFieldJson(raw, field) {
|
|
1684
1825
|
const text = typeof raw === "string" ? raw : latin12(raw);
|
|
@@ -1721,14 +1862,15 @@ function component(encoded, field) {
|
|
|
1721
1862
|
}
|
|
1722
1863
|
}
|
|
1723
1864
|
try {
|
|
1724
|
-
return
|
|
1865
|
+
return UTF82.decode(new Uint8Array(bytes));
|
|
1725
1866
|
} catch {
|
|
1726
1867
|
throw new FormFieldJsonError("bad-encoding", field, "the decoded bytes are not valid UTF-8");
|
|
1727
1868
|
}
|
|
1728
1869
|
}
|
|
1729
|
-
var DELIVERY_DECODE_KINDS = ["form-field-json"];
|
|
1870
|
+
var DELIVERY_DECODE_KINDS = ["form-field-json", "pubsub-push"];
|
|
1730
1871
|
function decodeDelivery(decode, raw) {
|
|
1731
1872
|
if (decode.kind === "form-field-json") return decodeFormFieldJson(raw, decode.field);
|
|
1873
|
+
if (decode.kind === "pubsub-push") return decodePubSubPush(raw);
|
|
1732
1874
|
throw new Error(`unknown decode kind ${JSON.stringify(decode.kind)}`);
|
|
1733
1875
|
}
|
|
1734
1876
|
function latin12(bytes) {
|
|
@@ -1737,6 +1879,202 @@ function latin12(bytes) {
|
|
|
1737
1879
|
return out;
|
|
1738
1880
|
}
|
|
1739
1881
|
|
|
1882
|
+
// src/webhook.ts
|
|
1883
|
+
var SIGNED_URI_RULES = ["hubspot-v3"];
|
|
1884
|
+
var HUBSPOT_V3_DECODE = {
|
|
1885
|
+
"%3A": ":",
|
|
1886
|
+
"%2F": "/",
|
|
1887
|
+
"%3F": "?",
|
|
1888
|
+
"%40": "@",
|
|
1889
|
+
"%21": "!",
|
|
1890
|
+
"%24": "$",
|
|
1891
|
+
"%27": "'",
|
|
1892
|
+
"%28": "(",
|
|
1893
|
+
"%29": ")",
|
|
1894
|
+
"%2A": "*",
|
|
1895
|
+
"%2C": ",",
|
|
1896
|
+
"%3B": ";"
|
|
1897
|
+
};
|
|
1898
|
+
function normaliseSignedUri(url, rule) {
|
|
1899
|
+
if (rule !== "hubspot-v3") throw new Error(`unknown signed-uri rule ${JSON.stringify(rule)}`);
|
|
1900
|
+
const withoutFragment = url.split("#", 1)[0] ?? "";
|
|
1901
|
+
const q = withoutFragment.indexOf("?");
|
|
1902
|
+
if (q === -1) return withoutFragment;
|
|
1903
|
+
const query = withoutFragment.slice(q + 1).replace(/%[0-9A-Fa-f]{2}/g, (e) => HUBSPOT_V3_DECODE[e.toUpperCase()] ?? e);
|
|
1904
|
+
return `${withoutFragment.slice(0, q)}?${query}`;
|
|
1905
|
+
}
|
|
1906
|
+
function secretKeyBytes(secret, options = {}) {
|
|
1907
|
+
let material = secret;
|
|
1908
|
+
if (options.secretPrefix !== void 0) {
|
|
1909
|
+
if (!material.startsWith(options.secretPrefix)) {
|
|
1910
|
+
return { reason: `signing secret does not start with ${JSON.stringify(options.secretPrefix)}` };
|
|
1911
|
+
}
|
|
1912
|
+
material = material.slice(options.secretPrefix.length);
|
|
1913
|
+
}
|
|
1914
|
+
if ((options.secretEncoding ?? "utf8") === "base64") {
|
|
1915
|
+
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(material) || material.length % 4 !== 0) {
|
|
1916
|
+
return { reason: "signing secret is not valid base64" };
|
|
1917
|
+
}
|
|
1918
|
+
try {
|
|
1919
|
+
return { bytes: Uint8Array.from(atob(material), (c) => c.charCodeAt(0)) };
|
|
1920
|
+
} catch {
|
|
1921
|
+
return { reason: "signing secret is not valid base64" };
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
return { bytes: new TextEncoder().encode(material) };
|
|
1925
|
+
}
|
|
1926
|
+
async function verifyHmac(options) {
|
|
1927
|
+
const { raw, secret, scheme, timestamp, id } = options;
|
|
1928
|
+
const signatures = (Array.isArray(options.signature) ? options.signature : [options.signature]).filter(
|
|
1929
|
+
(s) => typeof s === "string" && s !== ""
|
|
1930
|
+
);
|
|
1931
|
+
if (!secret) {
|
|
1932
|
+
return { ok: false, reason: "no signing secret configured for this trigger" };
|
|
1933
|
+
}
|
|
1934
|
+
if (signatures.length === 0) return { ok: false, reason: "delivery carried no signature header" };
|
|
1935
|
+
if (scheme.tolerance !== void 0) {
|
|
1936
|
+
if (!timestamp) return { ok: false, reason: "delivery carried no timestamp header" };
|
|
1937
|
+
const sent = Number(timestamp);
|
|
1938
|
+
if (!Number.isFinite(sent)) return { ok: false, reason: "timestamp header is not a number" };
|
|
1939
|
+
const now = options.now ?? Math.floor(Date.now() / 1e3);
|
|
1940
|
+
const sentSeconds = scheme.timestampUnit === "ms" ? sent / 1e3 : sent;
|
|
1941
|
+
if (Math.abs(now - sentSeconds) > scheme.tolerance) {
|
|
1942
|
+
return { ok: false, reason: `delivery is outside the ${scheme.tolerance}s replay window` };
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
const key = secretKeyBytes(secret, scheme);
|
|
1946
|
+
if ("reason" in key) return { ok: false, reason: key.reason };
|
|
1947
|
+
let line;
|
|
1948
|
+
if (scheme.signsRequestLine) {
|
|
1949
|
+
const { method, url } = options.request ?? {};
|
|
1950
|
+
if (!method || !url) return { ok: false, reason: "delivery carried no request method or url" };
|
|
1951
|
+
line = { method, uri: scheme.uri ? normaliseSignedUri(url, scheme.uri) : url };
|
|
1952
|
+
}
|
|
1953
|
+
const expected = await hmac(
|
|
1954
|
+
secret,
|
|
1955
|
+
scheme.payload(raw, timestamp, id, line),
|
|
1956
|
+
scheme.algorithm,
|
|
1957
|
+
scheme.encoding ?? "hex",
|
|
1958
|
+
scheme
|
|
1959
|
+
);
|
|
1960
|
+
return signatures.some((candidate) => constantTimeEquals(expected, candidate)) ? { ok: true } : { ok: false, reason: "signature did not match" };
|
|
1961
|
+
}
|
|
1962
|
+
async function hmac(secret, payload, algorithm, encoding = "hex", options = {}) {
|
|
1963
|
+
const subtle = globalThis.crypto?.subtle;
|
|
1964
|
+
if (!subtle) {
|
|
1965
|
+
throw new Error(
|
|
1966
|
+
"WebCrypto is not available in this runtime, so webhook signatures cannot be verified. Refusing to accept deliveries unverified."
|
|
1967
|
+
);
|
|
1968
|
+
}
|
|
1969
|
+
const material = secretKeyBytes(secret, options);
|
|
1970
|
+
if ("reason" in material) throw new Error(material.reason);
|
|
1971
|
+
const encoder2 = new TextEncoder();
|
|
1972
|
+
const key = await subtle.importKey(
|
|
1973
|
+
"raw",
|
|
1974
|
+
material.bytes,
|
|
1975
|
+
{ name: "HMAC", hash: algorithm },
|
|
1976
|
+
false,
|
|
1977
|
+
["sign"]
|
|
1978
|
+
);
|
|
1979
|
+
const bytes = new Uint8Array(await subtle.sign("HMAC", key, encoder2.encode(payload)));
|
|
1980
|
+
if (encoding === "base64") {
|
|
1981
|
+
let binary = "";
|
|
1982
|
+
bytes.forEach((byte) => {
|
|
1983
|
+
binary += String.fromCharCode(byte);
|
|
1984
|
+
});
|
|
1985
|
+
return btoa(binary);
|
|
1986
|
+
}
|
|
1987
|
+
return Array.from(bytes).map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
1988
|
+
}
|
|
1989
|
+
function constantTimeEquals(a, b) {
|
|
1990
|
+
if (a.length !== b.length) return false;
|
|
1991
|
+
let diff = 0;
|
|
1992
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
1993
|
+
return diff === 0;
|
|
1994
|
+
}
|
|
1995
|
+
function isSharedTokenScheme(scheme) {
|
|
1996
|
+
return "kind" in scheme && scheme.kind === "shared-token";
|
|
1997
|
+
}
|
|
1998
|
+
function verifySharedToken(options) {
|
|
1999
|
+
const { raw, headers, secret, scheme } = options;
|
|
2000
|
+
if (!secret) {
|
|
2001
|
+
return { ok: false, reason: "no shared token configured for this trigger" };
|
|
2002
|
+
}
|
|
2003
|
+
let tokens;
|
|
2004
|
+
if (scheme.in === "header") {
|
|
2005
|
+
tokens = [header2(headers, scheme.name)];
|
|
2006
|
+
} else {
|
|
2007
|
+
let parsed;
|
|
2008
|
+
try {
|
|
2009
|
+
parsed = JSON.parse(raw);
|
|
2010
|
+
} catch {
|
|
2011
|
+
return { ok: false, reason: "delivery body is not JSON" };
|
|
2012
|
+
}
|
|
2013
|
+
tokens = readTokens(parsed, scheme.path.split("."));
|
|
2014
|
+
}
|
|
2015
|
+
const present = tokens.filter((t) => t !== void 0 && t !== null && t !== "");
|
|
2016
|
+
if (present.length === 0) return { ok: false, reason: "delivery carried no token" };
|
|
2017
|
+
let matched = present.length === tokens.length;
|
|
2018
|
+
for (const token of present) {
|
|
2019
|
+
matched = typeof token === "string" && constantTimeEquals(secret, token) && matched;
|
|
2020
|
+
}
|
|
2021
|
+
return matched ? { ok: true } : { ok: false, reason: "token did not match" };
|
|
2022
|
+
}
|
|
2023
|
+
function readTokens(value, segments) {
|
|
2024
|
+
if (segments.length === 0) return [value];
|
|
2025
|
+
const [head, ...rest] = segments;
|
|
2026
|
+
const eachElement = head.endsWith("[]");
|
|
2027
|
+
const key = eachElement ? head.slice(0, -2) : head;
|
|
2028
|
+
if (value === null || typeof value !== "object") return [void 0];
|
|
2029
|
+
const next = value[key];
|
|
2030
|
+
if (!eachElement) return readTokens(next, rest);
|
|
2031
|
+
if (!Array.isArray(next)) return [void 0];
|
|
2032
|
+
return next.flatMap((element) => readTokens(element, rest));
|
|
2033
|
+
}
|
|
2034
|
+
function handshakeResponse(handshake, query) {
|
|
2035
|
+
if (!handshake) return void 0;
|
|
2036
|
+
const raw = query[handshake.param];
|
|
2037
|
+
const token = Array.isArray(raw) ? raw[0] : raw;
|
|
2038
|
+
if (!token) return void 0;
|
|
2039
|
+
return { status: 200, contentType: handshake.contentType ?? "text/plain", body: token };
|
|
2040
|
+
}
|
|
2041
|
+
function header2(headers, name) {
|
|
2042
|
+
const wanted = name.toLowerCase();
|
|
2043
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
2044
|
+
if (key.toLowerCase() !== wanted) continue;
|
|
2045
|
+
return Array.isArray(value) ? value[0] : value;
|
|
2046
|
+
}
|
|
2047
|
+
return void 0;
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
// src/oidc-delivery.ts
|
|
2051
|
+
var OIDC_JWT_SCHEME_KIND = "oidc-jwt";
|
|
2052
|
+
function isOidcJwtScheme(scheme) {
|
|
2053
|
+
return typeof scheme === "object" && scheme !== null && scheme.kind === OIDC_JWT_SCHEME_KIND;
|
|
2054
|
+
}
|
|
2055
|
+
var fail2 = (reason) => ({ ok: false, reason });
|
|
2056
|
+
var BEARER = /^bearer +(\S+)$/i;
|
|
2057
|
+
async function verifyOidcDelivery(options) {
|
|
2058
|
+
const { headers, scheme, expected } = options;
|
|
2059
|
+
if (!expected || expected.keys.length === 0) return fail2("no signing keys supplied for this trigger");
|
|
2060
|
+
if (!expected.audience) return fail2("no audience configured for this trigger");
|
|
2061
|
+
if (!expected.principal) return fail2("no principal configured for this trigger");
|
|
2062
|
+
const token = BEARER.exec(header2(headers, scheme.header) ?? "")?.[1];
|
|
2063
|
+
if (!token) return fail2("delivery carried no bearer token");
|
|
2064
|
+
const verified = await verifyJwt(token, {
|
|
2065
|
+
keys: expected.keys,
|
|
2066
|
+
audience: expected.audience,
|
|
2067
|
+
issuer: scheme.issuers,
|
|
2068
|
+
...options.now === void 0 ? {} : { now: options.now }
|
|
2069
|
+
});
|
|
2070
|
+
if (!verified.ok) return fail2(verified.reason);
|
|
2071
|
+
if (verified.claims[scheme.principalClaim] !== expected.principal) return fail2("token principal is not accepted");
|
|
2072
|
+
if (scheme.verifiedClaim !== void 0 && verified.claims[scheme.verifiedClaim] !== true) {
|
|
2073
|
+
return fail2("token principal is not verified");
|
|
2074
|
+
}
|
|
2075
|
+
return { ok: true };
|
|
2076
|
+
}
|
|
2077
|
+
|
|
1740
2078
|
// src/probe.ts
|
|
1741
2079
|
async function probe(spec) {
|
|
1742
2080
|
let response;
|
|
@@ -1952,179 +2290,14 @@ var CREDENTIAL_SCOPES = ["provider", "account"];
|
|
|
1952
2290
|
var PROBLEM_SEVERITIES = ["block", "warn"];
|
|
1953
2291
|
var CANONICAL_METRICS = ["like", "share", "reply", "quote", "view"];
|
|
1954
2292
|
|
|
1955
|
-
// src/webhook.ts
|
|
1956
|
-
var SIGNED_URI_RULES = ["hubspot-v3"];
|
|
1957
|
-
var HUBSPOT_V3_DECODE = {
|
|
1958
|
-
"%3A": ":",
|
|
1959
|
-
"%2F": "/",
|
|
1960
|
-
"%3F": "?",
|
|
1961
|
-
"%40": "@",
|
|
1962
|
-
"%21": "!",
|
|
1963
|
-
"%24": "$",
|
|
1964
|
-
"%27": "'",
|
|
1965
|
-
"%28": "(",
|
|
1966
|
-
"%29": ")",
|
|
1967
|
-
"%2A": "*",
|
|
1968
|
-
"%2C": ",",
|
|
1969
|
-
"%3B": ";"
|
|
1970
|
-
};
|
|
1971
|
-
function normaliseSignedUri(url, rule) {
|
|
1972
|
-
if (rule !== "hubspot-v3") throw new Error(`unknown signed-uri rule ${JSON.stringify(rule)}`);
|
|
1973
|
-
const withoutFragment = url.split("#", 1)[0] ?? "";
|
|
1974
|
-
const q = withoutFragment.indexOf("?");
|
|
1975
|
-
if (q === -1) return withoutFragment;
|
|
1976
|
-
const query = withoutFragment.slice(q + 1).replace(/%[0-9A-Fa-f]{2}/g, (e) => HUBSPOT_V3_DECODE[e.toUpperCase()] ?? e);
|
|
1977
|
-
return `${withoutFragment.slice(0, q)}?${query}`;
|
|
1978
|
-
}
|
|
1979
|
-
function secretKeyBytes(secret, options = {}) {
|
|
1980
|
-
let material = secret;
|
|
1981
|
-
if (options.secretPrefix !== void 0) {
|
|
1982
|
-
if (!material.startsWith(options.secretPrefix)) {
|
|
1983
|
-
return { reason: `signing secret does not start with ${JSON.stringify(options.secretPrefix)}` };
|
|
1984
|
-
}
|
|
1985
|
-
material = material.slice(options.secretPrefix.length);
|
|
1986
|
-
}
|
|
1987
|
-
if ((options.secretEncoding ?? "utf8") === "base64") {
|
|
1988
|
-
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(material) || material.length % 4 !== 0) {
|
|
1989
|
-
return { reason: "signing secret is not valid base64" };
|
|
1990
|
-
}
|
|
1991
|
-
try {
|
|
1992
|
-
return { bytes: Uint8Array.from(atob(material), (c) => c.charCodeAt(0)) };
|
|
1993
|
-
} catch {
|
|
1994
|
-
return { reason: "signing secret is not valid base64" };
|
|
1995
|
-
}
|
|
1996
|
-
}
|
|
1997
|
-
return { bytes: new TextEncoder().encode(material) };
|
|
1998
|
-
}
|
|
1999
|
-
async function verifyHmac(options) {
|
|
2000
|
-
const { raw, secret, scheme, timestamp, id } = options;
|
|
2001
|
-
const signatures = (Array.isArray(options.signature) ? options.signature : [options.signature]).filter(
|
|
2002
|
-
(s) => typeof s === "string" && s !== ""
|
|
2003
|
-
);
|
|
2004
|
-
if (!secret) {
|
|
2005
|
-
return { ok: false, reason: "no signing secret configured for this trigger" };
|
|
2006
|
-
}
|
|
2007
|
-
if (signatures.length === 0) return { ok: false, reason: "delivery carried no signature header" };
|
|
2008
|
-
if (scheme.tolerance !== void 0) {
|
|
2009
|
-
if (!timestamp) return { ok: false, reason: "delivery carried no timestamp header" };
|
|
2010
|
-
const sent = Number(timestamp);
|
|
2011
|
-
if (!Number.isFinite(sent)) return { ok: false, reason: "timestamp header is not a number" };
|
|
2012
|
-
const now = options.now ?? Math.floor(Date.now() / 1e3);
|
|
2013
|
-
const sentSeconds = scheme.timestampUnit === "ms" ? sent / 1e3 : sent;
|
|
2014
|
-
if (Math.abs(now - sentSeconds) > scheme.tolerance) {
|
|
2015
|
-
return { ok: false, reason: `delivery is outside the ${scheme.tolerance}s replay window` };
|
|
2016
|
-
}
|
|
2017
|
-
}
|
|
2018
|
-
const key = secretKeyBytes(secret, scheme);
|
|
2019
|
-
if ("reason" in key) return { ok: false, reason: key.reason };
|
|
2020
|
-
let line;
|
|
2021
|
-
if (scheme.signsRequestLine) {
|
|
2022
|
-
const { method, url } = options.request ?? {};
|
|
2023
|
-
if (!method || !url) return { ok: false, reason: "delivery carried no request method or url" };
|
|
2024
|
-
line = { method, uri: scheme.uri ? normaliseSignedUri(url, scheme.uri) : url };
|
|
2025
|
-
}
|
|
2026
|
-
const expected = await hmac(
|
|
2027
|
-
secret,
|
|
2028
|
-
scheme.payload(raw, timestamp, id, line),
|
|
2029
|
-
scheme.algorithm,
|
|
2030
|
-
scheme.encoding ?? "hex",
|
|
2031
|
-
scheme
|
|
2032
|
-
);
|
|
2033
|
-
return signatures.some((candidate) => constantTimeEquals(expected, candidate)) ? { ok: true } : { ok: false, reason: "signature did not match" };
|
|
2034
|
-
}
|
|
2035
|
-
async function hmac(secret, payload, algorithm, encoding = "hex", options = {}) {
|
|
2036
|
-
const subtle = globalThis.crypto?.subtle;
|
|
2037
|
-
if (!subtle) {
|
|
2038
|
-
throw new Error(
|
|
2039
|
-
"WebCrypto is not available in this runtime, so webhook signatures cannot be verified. Refusing to accept deliveries unverified."
|
|
2040
|
-
);
|
|
2041
|
-
}
|
|
2042
|
-
const material = secretKeyBytes(secret, options);
|
|
2043
|
-
if ("reason" in material) throw new Error(material.reason);
|
|
2044
|
-
const encoder2 = new TextEncoder();
|
|
2045
|
-
const key = await subtle.importKey(
|
|
2046
|
-
"raw",
|
|
2047
|
-
material.bytes,
|
|
2048
|
-
{ name: "HMAC", hash: algorithm },
|
|
2049
|
-
false,
|
|
2050
|
-
["sign"]
|
|
2051
|
-
);
|
|
2052
|
-
const bytes = new Uint8Array(await subtle.sign("HMAC", key, encoder2.encode(payload)));
|
|
2053
|
-
if (encoding === "base64") {
|
|
2054
|
-
let binary = "";
|
|
2055
|
-
bytes.forEach((byte) => {
|
|
2056
|
-
binary += String.fromCharCode(byte);
|
|
2057
|
-
});
|
|
2058
|
-
return btoa(binary);
|
|
2059
|
-
}
|
|
2060
|
-
return Array.from(bytes).map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
2061
|
-
}
|
|
2062
|
-
function constantTimeEquals(a, b) {
|
|
2063
|
-
if (a.length !== b.length) return false;
|
|
2064
|
-
let diff = 0;
|
|
2065
|
-
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
2066
|
-
return diff === 0;
|
|
2067
|
-
}
|
|
2068
|
-
function isSharedTokenScheme(scheme) {
|
|
2069
|
-
return "kind" in scheme && scheme.kind === "shared-token";
|
|
2070
|
-
}
|
|
2071
|
-
function verifySharedToken(options) {
|
|
2072
|
-
const { raw, headers, secret, scheme } = options;
|
|
2073
|
-
if (!secret) {
|
|
2074
|
-
return { ok: false, reason: "no shared token configured for this trigger" };
|
|
2075
|
-
}
|
|
2076
|
-
let tokens;
|
|
2077
|
-
if (scheme.in === "header") {
|
|
2078
|
-
tokens = [header2(headers, scheme.name)];
|
|
2079
|
-
} else {
|
|
2080
|
-
let parsed;
|
|
2081
|
-
try {
|
|
2082
|
-
parsed = JSON.parse(raw);
|
|
2083
|
-
} catch {
|
|
2084
|
-
return { ok: false, reason: "delivery body is not JSON" };
|
|
2085
|
-
}
|
|
2086
|
-
tokens = readTokens(parsed, scheme.path.split("."));
|
|
2087
|
-
}
|
|
2088
|
-
const present = tokens.filter((t) => t !== void 0 && t !== null && t !== "");
|
|
2089
|
-
if (present.length === 0) return { ok: false, reason: "delivery carried no token" };
|
|
2090
|
-
let matched = present.length === tokens.length;
|
|
2091
|
-
for (const token of present) {
|
|
2092
|
-
matched = typeof token === "string" && constantTimeEquals(secret, token) && matched;
|
|
2093
|
-
}
|
|
2094
|
-
return matched ? { ok: true } : { ok: false, reason: "token did not match" };
|
|
2095
|
-
}
|
|
2096
|
-
function readTokens(value, segments) {
|
|
2097
|
-
if (segments.length === 0) return [value];
|
|
2098
|
-
const [head, ...rest] = segments;
|
|
2099
|
-
const eachElement = head.endsWith("[]");
|
|
2100
|
-
const key = eachElement ? head.slice(0, -2) : head;
|
|
2101
|
-
if (value === null || typeof value !== "object") return [void 0];
|
|
2102
|
-
const next = value[key];
|
|
2103
|
-
if (!eachElement) return readTokens(next, rest);
|
|
2104
|
-
if (!Array.isArray(next)) return [void 0];
|
|
2105
|
-
return next.flatMap((element) => readTokens(element, rest));
|
|
2106
|
-
}
|
|
2107
|
-
function handshakeResponse(handshake, query) {
|
|
2108
|
-
if (!handshake) return void 0;
|
|
2109
|
-
const raw = query[handshake.param];
|
|
2110
|
-
const token = Array.isArray(raw) ? raw[0] : raw;
|
|
2111
|
-
if (!token) return void 0;
|
|
2112
|
-
return { status: 200, contentType: handshake.contentType ?? "text/plain", body: token };
|
|
2113
|
-
}
|
|
2114
|
-
function header2(headers, name) {
|
|
2115
|
-
const wanted = name.toLowerCase();
|
|
2116
|
-
for (const [key, value] of Object.entries(headers)) {
|
|
2117
|
-
if (key.toLowerCase() !== wanted) continue;
|
|
2118
|
-
return Array.isArray(value) ? value[0] : value;
|
|
2119
|
-
}
|
|
2120
|
-
return void 0;
|
|
2121
|
-
}
|
|
2122
|
-
|
|
2123
2293
|
// src/trigger.ts
|
|
2294
|
+
function isOidcJwtSpec(spec) {
|
|
2295
|
+
return isOidcJwtScheme(spec.scheme);
|
|
2296
|
+
}
|
|
2124
2297
|
function isSharedTokenSpec(spec) {
|
|
2125
2298
|
return isSharedTokenScheme(spec.scheme);
|
|
2126
2299
|
}
|
|
2127
|
-
async function verifyDelivery(trigger, delivery, secret, now) {
|
|
2300
|
+
async function verifyDelivery(trigger, delivery, secret, now, oidc) {
|
|
2128
2301
|
const spec = trigger.verification;
|
|
2129
2302
|
if (!spec) {
|
|
2130
2303
|
return {
|
|
@@ -2135,6 +2308,14 @@ async function verifyDelivery(trigger, delivery, secret, now) {
|
|
|
2135
2308
|
if (isSharedTokenSpec(spec)) {
|
|
2136
2309
|
return verifySharedToken({ raw: delivery.raw, headers: delivery.headers, secret, scheme: spec.scheme });
|
|
2137
2310
|
}
|
|
2311
|
+
if (isOidcJwtSpec(spec)) {
|
|
2312
|
+
return verifyOidcDelivery({
|
|
2313
|
+
headers: delivery.headers,
|
|
2314
|
+
scheme: spec.scheme,
|
|
2315
|
+
expected: oidc,
|
|
2316
|
+
...now === void 0 ? {} : { now }
|
|
2317
|
+
});
|
|
2318
|
+
}
|
|
2138
2319
|
const kind = spec.scheme.kind ?? "hmac";
|
|
2139
2320
|
if (kind !== "hmac") {
|
|
2140
2321
|
return {
|
|
@@ -2200,9 +2381,13 @@ export {
|
|
|
2200
2381
|
FAKE_EPOCH,
|
|
2201
2382
|
FORM_FIELD_JSON_REASONS,
|
|
2202
2383
|
FormFieldJsonError,
|
|
2384
|
+
JWT_ALGORITHMS,
|
|
2203
2385
|
MAX_IDEMPOTENCY_KEY_LENGTH,
|
|
2204
2386
|
NO_IDEMPOTENCY_KEY_WARNING,
|
|
2387
|
+
OIDC_JWT_SCHEME_KIND,
|
|
2205
2388
|
PROBLEM_SEVERITIES,
|
|
2389
|
+
PUBSUB_PUSH_REASONS,
|
|
2390
|
+
PubSubPushError,
|
|
2206
2391
|
RENDERER_VERSION,
|
|
2207
2392
|
REVIEW_STALE_DAYS,
|
|
2208
2393
|
SANDBOX_KINDS,
|
|
@@ -2223,6 +2408,7 @@ export {
|
|
|
2223
2408
|
constantTimeEquals,
|
|
2224
2409
|
decodeDelivery,
|
|
2225
2410
|
decodeFormFieldJson,
|
|
2411
|
+
decodePubSubPush,
|
|
2226
2412
|
deliver,
|
|
2227
2413
|
fakeRequest,
|
|
2228
2414
|
fakeValues,
|
|
@@ -2234,6 +2420,8 @@ export {
|
|
|
2234
2420
|
httpFailure,
|
|
2235
2421
|
idempotencyKeyFor,
|
|
2236
2422
|
isEmptyPayload,
|
|
2423
|
+
isOidcJwtScheme,
|
|
2424
|
+
isOidcJwtSpec,
|
|
2237
2425
|
isRemote,
|
|
2238
2426
|
isSharedTokenScheme,
|
|
2239
2427
|
isSharedTokenSpec,
|
|
@@ -2280,6 +2468,8 @@ export {
|
|
|
2280
2468
|
utf8Length,
|
|
2281
2469
|
verifyDelivery,
|
|
2282
2470
|
verifyHmac,
|
|
2471
|
+
verifyJwt,
|
|
2472
|
+
verifyOidcDelivery,
|
|
2283
2473
|
verifySharedToken,
|
|
2284
2474
|
withResolvedLimit
|
|
2285
2475
|
};
|