UNPKG

@portone/server-sdk

Version:

PortOne JavaScript SDK for server-side usage

145 lines (144 loc) 5.8 kB
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"); } }