@portone/server-sdk
Version:
PortOne JavaScript SDK for server-side usage
145 lines (144 loc) • 5.8 kB
JavaScript
export * from "./generated/webhook/index.mjs";
import { PortOneError } from "./PortOneError.mjs";
import { timingSafeEqual } from "./utils/timingSafeEqual.mjs";
import { tryCatch } from "./utils/try.mjs";
const WEBHOOK_TOLERANCE_IN_SECONDS = 5 * 60;
export class InvalidInputError extends PortOneError {
/** @ignore */
constructor(message) {
super(message);
Object.setPrototypeOf(this, InvalidInputError.prototype);
this.name = "InvalidInputError";
}
}
export class WebhookVerificationError extends PortOneError {
/**
* 웹훅 검증이 실패한 상세 사유을 나타냅니다.
*/
reason;
/**
* 웹훅 검증 실패 사유로부터 에러 메시지를 생성합니다.
*
* @param reason 에러 메시지를 생성할 실패 사유
* @returns 에러 메시지
*/
static getMessage(reason) {
switch (reason) {
case "MISSING_REQUIRED_HEADERS":
return "\uD544\uC218 \uD5E4\uB354\uAC00 \uB204\uB77D\uB418\uC5C8\uC2B5\uB2C8\uB2E4.";
case "NO_MATCHING_SIGNATURE":
return "\uC62C\uBC14\uB978 \uC6F9\uD6C5 \uC2DC\uADF8\uB2C8\uCC98\uB97C \uCC3E\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.";
case "INVALID_SIGNATURE":
return "\uC6F9\uD6C5 \uC2DC\uADF8\uB2C8\uCC98\uAC00 \uC720\uD6A8\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4.";
case "TIMESTAMP_TOO_OLD":
return "\uC6F9\uD6C5 \uC2DC\uADF8\uB2C8\uCC98\uC758 \uD0C0\uC784\uC2A4\uD0EC\uD504\uAC00 \uB9CC\uB8CC \uAE30\uD55C\uC744 \uCD08\uACFC\uD588\uC2B5\uB2C8\uB2E4.";
case "TIMESTAMP_TOO_NEW":
return "\uC6F9\uD6C5 \uC2DC\uADF8\uB2C8\uCC98\uC758 \uD0C0\uC784\uC2A4\uD0EC\uD504\uAC00 \uBBF8\uB798 \uC2DC\uAC04\uC73C\uB85C \uC124\uC815\uB418\uC5B4 \uC788\uC2B5\uB2C8\uB2E4.";
}
}
/** @ignore */
constructor(reason, options) {
super(WebhookVerificationError.getMessage(reason), options);
Object.setPrototypeOf(this, WebhookVerificationError.prototype);
this.name = "WebhookVerificationError";
this.reason = reason;
}
}
const prefix = "whsec_";
function findHeaderValue(headers, name) {
if (typeof headers !== "object" || headers === null) return void 0;
const nameLowerCase = name.toLowerCase();
let found = void 0;
for (const [key, value] of Object.entries(headers)) {
if (key.toLowerCase() === nameLowerCase) {
for (const v of Array.isArray(value) ? value : [value]) {
if (v == null) continue;
if (typeof v !== "string") return void 0;
if (found !== void 0) return void 0;
found = v;
}
}
}
return found;
}
export async function verify(secret, payload, headers) {
if (typeof payload !== "string")
throw new InvalidInputError(
"`payload` \uD30C\uB77C\uBBF8\uD130\uC758 \uD0C0\uC785\uC774 string\uC774 \uC544\uB2D9\uB2C8\uB2E4."
);
const msgId = findHeaderValue(headers, "webhook-id");
const msgSignature = findHeaderValue(headers, "webhook-signature");
const msgTimestamp = findHeaderValue(headers, "webhook-timestamp");
if (!msgId || !msgSignature || !msgTimestamp) {
throw new WebhookVerificationError("MISSING_REQUIRED_HEADERS");
}
verifyTimestamp(msgTimestamp);
const expectedSignature = await sign(secret, msgId, msgTimestamp, payload);
for (const versionedSignature of msgSignature.split(" ")) {
const split = versionedSignature.split(",", 3);
if (split.length < 2) continue;
const [version, signature] = split;
if (version !== "v1") continue;
const signatureDecoded = tryCatch(
() => Uint8Array.from(atob(signature), (c) => c.charCodeAt(0)),
() => void 0
);
if (signatureDecoded === void 0) continue;
if (timingSafeEqual(signatureDecoded, expectedSignature)) {
return JSON.parse(payload);
}
}
throw new WebhookVerificationError("NO_MATCHING_SIGNATURE");
}
async function sign(secret, msgId, msgTimestamp, payload) {
const cryptoKey = await getCryptoKeyFromSecret(secret);
const encoder = new TextEncoder();
const toSign = encoder.encode(`${msgId}.${msgTimestamp}.${payload}`);
return await crypto.subtle.sign("HMAC", cryptoKey, toSign);
}
const secrets = /* @__PURE__ */ new Map();
async function getCryptoKeyFromSecret(secret) {
const cryptoKeyCached = secrets.get(secret);
if (cryptoKeyCached !== void 0) return cryptoKeyCached;
let rawSecret;
if (secret instanceof Uint8Array) {
rawSecret = secret;
} else if (typeof secret === "string") {
const secretBase64 = secret.startsWith(prefix) ? secret.substring(prefix.length) : secret;
rawSecret = tryCatch(
() => Uint8Array.from(atob(secretBase64), (c) => c.charCodeAt(0)),
() => {
throw new InvalidInputError(
"`secret` \uD30C\uB77C\uBBF8\uD130\uAC00 \uC62C\uBC14\uB978 Base64 \uBB38\uC790\uC5F4\uC774 \uC544\uB2D9\uB2C8\uB2E4."
);
}
);
} else {
throw new InvalidInputError("`secret` \uD30C\uB77C\uBBF8\uD130\uC758 \uD0C0\uC785\uC774 \uC798\uBABB\uB418\uC5C8\uC2B5\uB2C8\uB2E4.");
}
if (rawSecret.length === 0) {
throw new InvalidInputError("\uC2DC\uD06C\uB9BF\uC740 \uBE44\uC5B4 \uC788\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.");
}
const cryptoKey = await crypto.subtle.importKey(
"raw",
rawSecret,
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
secrets.set(secret, cryptoKey);
return cryptoKey;
}
function verifyTimestamp(timestampHeader) {
const now = Math.floor(Date.now() / 1e3);
const timestamp = Number.parseInt(timestampHeader, 10);
if (Number.isNaN(timestamp)) {
throw new WebhookVerificationError("INVALID_SIGNATURE");
}
if (now - timestamp > WEBHOOK_TOLERANCE_IN_SECONDS) {
throw new WebhookVerificationError("TIMESTAMP_TOO_OLD");
}
if (timestamp > now + WEBHOOK_TOLERANCE_IN_SECONDS) {
throw new WebhookVerificationError("TIMESTAMP_TOO_NEW");
}
}