@easypayment/medusa-payment-paypal 0.9.17 → 0.9.19

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.
Files changed (44) hide show
  1. package/.medusa/server/src/admin/index.js +326 -130
  2. package/.medusa/server/src/admin/index.mjs +326 -130
  3. package/.medusa/server/src/admin/routes/settings/paypal/connection/page.d.ts +4 -1
  4. package/.medusa/server/src/admin/routes/settings/paypal/connection/page.d.ts.map +1 -1
  5. package/.medusa/server/src/admin/routes/settings/paypal/connection/page.js +434 -113
  6. package/.medusa/server/src/admin/routes/settings/paypal/connection/page.js.map +1 -1
  7. package/.medusa/server/src/api/admin/paypal/onboarding-link/route.d.ts.map +1 -1
  8. package/.medusa/server/src/api/admin/paypal/onboarding-link/route.js +4 -0
  9. package/.medusa/server/src/api/admin/paypal/onboarding-link/route.js.map +1 -1
  10. package/.medusa/server/src/api/middlewares.d.ts.map +1 -1
  11. package/.medusa/server/src/api/middlewares.js +9 -0
  12. package/.medusa/server/src/api/middlewares.js.map +1 -1
  13. package/.medusa/server/src/api/store/paypal/webhook/route.d.ts.map +1 -1
  14. package/.medusa/server/src/api/store/paypal/webhook/route.js +13 -8
  15. package/.medusa/server/src/api/store/paypal/webhook/route.js.map +1 -1
  16. package/.medusa/server/src/modules/paypal/payment-provider/service.d.ts.map +1 -1
  17. package/.medusa/server/src/modules/paypal/payment-provider/service.js +54 -31
  18. package/.medusa/server/src/modules/paypal/payment-provider/service.js.map +1 -1
  19. package/.medusa/server/src/modules/paypal/payment-provider/status-utils.d.ts +32 -0
  20. package/.medusa/server/src/modules/paypal/payment-provider/status-utils.d.ts.map +1 -0
  21. package/.medusa/server/src/modules/paypal/payment-provider/status-utils.js +65 -0
  22. package/.medusa/server/src/modules/paypal/payment-provider/status-utils.js.map +1 -0
  23. package/.medusa/server/src/modules/paypal/service.d.ts +13 -1
  24. package/.medusa/server/src/modules/paypal/service.d.ts.map +1 -1
  25. package/.medusa/server/src/modules/paypal/service.js +19 -4
  26. package/.medusa/server/src/modules/paypal/service.js.map +1 -1
  27. package/.medusa/server/src/modules/paypal/utils/secret-crypto.d.ts +2 -1
  28. package/.medusa/server/src/modules/paypal/utils/secret-crypto.d.ts.map +1 -1
  29. package/.medusa/server/src/modules/paypal/utils/secret-crypto.js +82 -26
  30. package/.medusa/server/src/modules/paypal/utils/secret-crypto.js.map +1 -1
  31. package/.medusa/server/src/modules/paypal/utils/webhook-verify.d.ts +28 -0
  32. package/.medusa/server/src/modules/paypal/utils/webhook-verify.d.ts.map +1 -0
  33. package/.medusa/server/src/modules/paypal/utils/webhook-verify.js +58 -0
  34. package/.medusa/server/src/modules/paypal/utils/webhook-verify.js.map +1 -0
  35. package/package.json +1 -1
  36. package/src/admin/routes/settings/paypal/connection/page.tsx +471 -127
  37. package/src/api/admin/paypal/onboarding-link/route.ts +6 -0
  38. package/src/api/middlewares.ts +9 -0
  39. package/src/api/store/paypal/webhook/route.ts +22 -8
  40. package/src/modules/paypal/payment-provider/service.ts +73 -29
  41. package/src/modules/paypal/payment-provider/status-utils.ts +59 -0
  42. package/src/modules/paypal/service.ts +24 -5
  43. package/src/modules/paypal/utils/secret-crypto.ts +90 -27
  44. package/src/modules/paypal/utils/webhook-verify.ts +61 -0
@@ -8,6 +8,7 @@ import type PayPalModuleService from "../../../../modules/paypal/service"
8
8
  type Body = {
9
9
  email?: string
10
10
  products?: string[]
11
+ environment?: "sandbox" | "live"
11
12
  }
12
13
 
13
14
  export async function POST(req: AuthenticatedMedusaRequest, res: MedusaResponse) {
@@ -19,10 +20,15 @@ export async function POST(req: AuthenticatedMedusaRequest, res: MedusaResponse)
19
20
  ? String(req.auth_context.user_metadata.email)
20
21
  : undefined
21
22
  const email = authEmail ?? body.email ?? "admin@paypal.com"
23
+ const env =
24
+ body.environment === "sandbox" || body.environment === "live"
25
+ ? body.environment
26
+ : undefined
22
27
 
23
28
  const link = await paypal.createOnboardingLink({
24
29
  email,
25
30
  products: body.products,
31
+ env,
26
32
  })
27
33
 
28
34
  return res.json({
@@ -2,6 +2,15 @@ import { defineMiddlewares } from "@medusajs/framework/http"
2
2
 
3
3
  export default defineMiddlewares({
4
4
  routes: [
5
+ {
6
+ // Keep the exact bytes PayPal signed (`req.rawBody`) so webhook signature
7
+ // verification hashes the original payload, not a re-serialized copy. This
8
+ // entry must precede the "/store/paypal/:path*" catch-all below.
9
+ matcher: "/store/paypal/webhook",
10
+ method: ["POST"],
11
+ bodyParser: { preserveRawBody: true },
12
+ middlewares: [],
13
+ },
5
14
  {
6
15
  matcher: "/store/paypal-complete",
7
16
  middlewares: [],
@@ -8,6 +8,11 @@ import {
8
8
  normalizeEventVersion,
9
9
  processPayPalWebhookEvent,
10
10
  } from "../../../../modules/paypal/webhook-processor"
11
+ import {
12
+ composeVerifyRequestBody,
13
+ rawBodyToString,
14
+ resolveWebhookEventJson,
15
+ } from "../../../../modules/paypal/utils/webhook-verify"
11
16
 
12
17
  const REPLAY_WINDOW_MINUTES = (() => {
13
18
  const v = Number(process.env.PAYPAL_WEBHOOK_REPLAY_WINDOW_MINUTES)
@@ -110,7 +115,8 @@ async function verifyWebhookSignature(
110
115
  paypal: PayPalModuleService,
111
116
  environment: string,
112
117
  body: Record<string, unknown>,
113
- headers: Record<string, string | string[] | undefined>
118
+ headers: Record<string, string | string[] | undefined>,
119
+ rawBody: string | null
114
120
  ): Promise<void> {
115
121
  const settings = await paypal.getSettings().catch(() => ({ data: {} }))
116
122
  const webhookId = resolveWebhookId(
@@ -131,17 +137,16 @@ async function verifyWebhookSignature(
131
137
 
132
138
  const accessToken = await paypal.getAppAccessToken()
133
139
 
134
- const verifyPayload = {
140
+ const verifyFields = {
135
141
  auth_algo: getHeader(headers, "paypal-auth-algo"),
136
142
  cert_url: getHeader(headers, "paypal-cert-url"),
137
143
  transmission_id: getHeader(headers, "paypal-transmission-id"),
138
144
  transmission_sig: getHeader(headers, "paypal-transmission-sig"),
139
145
  transmission_time: getHeader(headers, "paypal-transmission-time"),
140
146
  webhook_id: webhookId,
141
- webhook_event: body,
142
147
  }
143
148
 
144
- const certUrl = verifyPayload.cert_url
149
+ const certUrl = verifyFields.cert_url
145
150
  if (
146
151
  certUrl &&
147
152
  !certUrl.startsWith("https://www.paypal.com/") &&
@@ -151,21 +156,29 @@ async function verifyWebhookSignature(
151
156
  throw new Error("Invalid paypal-cert-url: must originate from paypal.com")
152
157
  }
153
158
 
154
- const missing = Object.entries(verifyPayload)
155
- .filter(([k, v]) => k !== "webhook_id" && k !== "webhook_event" && !v)
159
+ const missing = Object.entries(verifyFields)
160
+ .filter(([k, v]) => k !== "webhook_id" && !v)
156
161
  .map(([k]) => k)
157
162
 
158
163
  if (missing.length > 0) {
159
164
  throw new Error(`Missing required PayPal webhook headers: ${missing.join(", ")}`)
160
165
  }
161
166
 
167
+ // Use the raw bytes PayPal signed for `webhook_event` whenever available
168
+ // (preserved by the route's `bodyParser: { preserveRawBody: true }`); fall
169
+ // back to serializing the parsed body so verification still runs if it isn't.
170
+ const requestBody = composeVerifyRequestBody(
171
+ verifyFields,
172
+ resolveWebhookEventJson(rawBody, body)
173
+ )
174
+
162
175
  const resp = await paypalFetch(`${base}/v1/notifications/verify-webhook-signature`, {
163
176
  method: "POST",
164
177
  headers: {
165
178
  Authorization: `Bearer ${accessToken}`,
166
179
  "Content-Type": "application/json",
167
180
  },
168
- body: JSON.stringify(verifyPayload),
181
+ body: requestBody,
169
182
  })
170
183
 
171
184
  const json = await resp.json().catch(() => ({}))
@@ -214,7 +227,8 @@ export async function POST(req: MedusaRequest, res: MedusaResponse) {
214
227
 
215
228
  try {
216
229
  const creds = await paypal.getActiveCredentials()
217
- await verifyWebhookSignature(paypal, creds.environment, payload, req.headers)
230
+ const rawBody = rawBodyToString((req as unknown as { rawBody?: unknown }).rawBody)
231
+ await verifyWebhookSignature(paypal, creds.environment, payload, req.headers, rawBody)
218
232
  } catch (e: any) {
219
233
  console.error("[PayPal] webhook: signature verification failed:", e?.message)
220
234
  return res
@@ -32,6 +32,12 @@ import {
32
32
  import type PayPalModuleService from "../service"
33
33
  import { getPayPalWebhookActionAndData } from "./webhook-utils"
34
34
  import { paypalFetch } from "../utils/paypal-fetch"
35
+ import {
36
+ extractCaptureStatus,
37
+ isCaptureCompleted,
38
+ isRefundFailureStatus,
39
+ mapPayPalCaptureStatus,
40
+ } from "./status-utils"
35
41
 
36
42
  type Options = {}
37
43
 
@@ -154,13 +160,7 @@ class PayPalPaymentProvider extends AbstractPaymentProvider<Options> {
154
160
  }
155
161
 
156
162
  private mapCaptureStatus(status?: string) {
157
- const normalized = String(status || "").toUpperCase()
158
- if (!normalized) return null
159
- if (normalized === "COMPLETED") return "captured"
160
- if (normalized === "PENDING") return "pending"
161
- if (["DENIED", "DECLINED", "FAILED"].includes(normalized)) return "error"
162
- if (["REFUNDED", "PARTIALLY_REFUNDED", "REVERSED"].includes(normalized)) return "canceled"
163
- return null
163
+ return mapPayPalCaptureStatus(status)
164
164
  }
165
165
 
166
166
  private mapAuthorizationStatus(status?: string) {
@@ -587,15 +587,19 @@ class PayPalPaymentProvider extends AbstractPaymentProvider<Options> {
587
587
  }
588
588
  }
589
589
 
590
- const requestId = this.getIdempotencyKey(input, `capture-${orderId}`)
591
590
  const { amount, currencyCode } = await this.normalizePaymentData(input)
591
+ // Include the amount in the idempotency suffix: PayPal deduplicates by
592
+ // PayPal-Request-Id, so two sequential partial captures of the same order
593
+ // that share an upstream idempotency_key would otherwise collide and the
594
+ // second capture would silently return the first one's result.
595
+ const requestId = this.getIdempotencyKey(input, `capture-${orderId}-${amount}`)
592
596
  let debugId: string | null = null
593
597
 
594
598
  try {
595
599
  const { accessToken, base } = await this.getPayPalAccessToken()
596
600
  const order = await this.getOrderDetails(orderId).catch(() => null)
597
601
  const existingCapture = order?.purchase_units?.[0]?.payments?.captures?.[0]
598
- if (existingCapture?.id) {
602
+ if (existingCapture?.id && isCaptureCompleted(existingCapture)) {
599
603
  return {
600
604
  data: {
601
605
  ...(input.data || {}),
@@ -648,26 +652,35 @@ class PayPalPaymentProvider extends AbstractPaymentProvider<Options> {
648
652
  const captureValue = amount > 0
649
653
  ? formatAmountForPayPal(amount, currencyCode || "EUR")
650
654
  : null
651
- const capturePayload =
652
- captureValue
653
- ? {
654
- amount: {
655
- currency_code: currencyCode || "EUR",
656
- value: captureValue,
657
- },
658
- ...(typeof isFinalCapture === "boolean"
659
- ? { is_final_capture: isFinalCapture }
660
- : {}),
661
- }
662
- : {
663
- ...(typeof isFinalCapture === "boolean"
664
- ? { is_final_capture: isFinalCapture }
665
- : {}),
666
- }
667
655
 
668
- const captureUrl = authorizationId
669
- ? `${base}/v2/payments/authorizations/${encodeURIComponent(authorizationId)}/capture`
670
- : `${base}/v2/checkout/orders/${encodeURIComponent(orderId)}/capture`
656
+ // `amount` and `is_final_capture` are only valid on the authorizations
657
+ // capture endpoint. The orders capture endpoint always captures the FULL
658
+ // order and silently ignores an `amount` body — so sending a partial
659
+ // amount there would over-capture while we record the (smaller) requested
660
+ // value. Route partial captures to the authorization, and fail closed if a
661
+ // partial capture is attempted against a capture-intent order.
662
+ let capturePayload: Record<string, unknown>
663
+ let captureUrl: string
664
+ if (authorizationId) {
665
+ captureUrl = `${base}/v2/payments/authorizations/${encodeURIComponent(authorizationId)}/capture`
666
+ capturePayload = {
667
+ ...(captureValue
668
+ ? { amount: { currency_code: currencyCode || "EUR", value: captureValue } }
669
+ : {}),
670
+ ...(typeof isFinalCapture === "boolean" ? { is_final_capture: isFinalCapture } : {}),
671
+ }
672
+ } else {
673
+ captureUrl = `${base}/v2/checkout/orders/${encodeURIComponent(orderId)}/capture`
674
+ capturePayload = {}
675
+ const orderTotal = order?.purchase_units?.[0]?.amount?.value
676
+ if (captureValue && orderTotal && captureValue !== String(orderTotal)) {
677
+ throw new Error(
678
+ `PayPal partial capture (${captureValue} ${currencyCode || "EUR"}) is not supported for ` +
679
+ `capture-intent orders (order total ${orderTotal}). Create the order with intent ` +
680
+ `AUTHORIZE to capture a partial amount.`
681
+ )
682
+ }
683
+ }
671
684
 
672
685
  const ppResp = await paypalFetch(captureUrl, {
673
686
  method: "POST",
@@ -691,6 +704,19 @@ class PayPalPaymentProvider extends AbstractPaymentProvider<Options> {
691
704
  }
692
705
 
693
706
  const capture = JSON.parse(ppText)
707
+
708
+ // A 2xx response does NOT mean the funds were captured. PayPal returns 201
709
+ // for captures that are PENDING (e.g. pending review / eCheck), DECLINED,
710
+ // or FAILED. Recording any of these as "captured" books money that never
711
+ // settled, so only a COMPLETED capture is treated as success.
712
+ const captureStatus = extractCaptureStatus(capture)
713
+ if (captureStatus !== "COMPLETED") {
714
+ throw new Error(
715
+ `PayPal capture did not complete (status=${captureStatus || "UNKNOWN"}). ` +
716
+ `The payment was not captured.${debugId ? ` debug_id=${debugId}` : ""}`
717
+ )
718
+ }
719
+
694
720
  const captureId =
695
721
  capture?.id || capture?.purchase_units?.[0]?.payments?.captures?.[0]?.id
696
722
  const existingCaptures = Array.isArray(paypalData.captures) ? paypalData.captures : []
@@ -752,7 +778,12 @@ class PayPalPaymentProvider extends AbstractPaymentProvider<Options> {
752
778
  )
753
779
  }
754
780
 
755
- const requestId = this.getIdempotencyKey(input, `refund-${captureId}`)
781
+ // Include the amount in the idempotency suffix so two sequential partial
782
+ // refunds of the same capture are not deduplicated by PayPal into one.
783
+ const requestId = this.getIdempotencyKey(
784
+ input,
785
+ `refund-${captureId}-${Number(input.amount ?? 0)}`
786
+ )
756
787
 
757
788
  const currencyOverride = await this.resolveCurrencyOverride()
758
789
  const currencyCode = normalizeCurrencyCode(
@@ -804,6 +835,19 @@ class PayPalPaymentProvider extends AbstractPaymentProvider<Options> {
804
835
  }
805
836
 
806
837
  const refund = JSON.parse(ppText)
838
+
839
+ // As with captures, a 2xx response does not guarantee the refund stuck.
840
+ // FAILED / CANCELLED / DENIED refunds also return 2xx and must not be
841
+ // recorded as a successful refund. PENDING is accepted: PayPal processes
842
+ // refunds asynchronously and a pending refund will settle.
843
+ const refundStatus = String(refund?.status || "").toUpperCase()
844
+ if (isRefundFailureStatus(refundStatus)) {
845
+ throw new Error(
846
+ `PayPal refund did not succeed (status=${refundStatus}). The refund was not issued.` +
847
+ (debugId ? ` debug_id=${debugId}` : "")
848
+ )
849
+ }
850
+
807
851
  const existingRefunds = Array.isArray(paypalData.refunds) ? paypalData.refunds : []
808
852
  const refundEntry = {
809
853
  id: refund?.id,
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Pure mapping/validation helpers for PayPal capture & refund resource statuses.
3
+ *
4
+ * Kept free of any container/IO dependencies so the money-flow decisions (what
5
+ * counts as a settled capture, when a refund must be rejected, how a PayPal
6
+ * status maps onto a Medusa payment status) can be unit-tested in isolation.
7
+ */
8
+
9
+ /** Map a PayPal capture status onto a Medusa payment status. */
10
+ export function mapPayPalCaptureStatus(
11
+ status?: string
12
+ ): "captured" | "pending" | "error" | "canceled" | null {
13
+ const normalized = String(status || "").toUpperCase()
14
+ if (!normalized) return null
15
+ if (normalized === "COMPLETED") return "captured"
16
+ if (normalized === "PENDING") return "pending"
17
+ if (["DENIED", "DECLINED", "FAILED"].includes(normalized)) return "error"
18
+ // A partially-refunded capture is still a captured payment — only a portion of
19
+ // the funds was returned. Mapping it to "canceled" would wrongly unwind a live
20
+ // capture, so it must stay "captured". A full refund/reversal does unwind it.
21
+ if (normalized === "PARTIALLY_REFUNDED") return "captured"
22
+ if (["REFUNDED", "REVERSED"].includes(normalized)) return "canceled"
23
+ return null
24
+ }
25
+
26
+ /**
27
+ * Effective status of a PayPal capture resource.
28
+ *
29
+ * The orders-capture response nests the capture under
30
+ * `purchase_units[].payments.captures[]` — the order's own top-level status can
31
+ * read COMPLETED while the capture is still PENDING — whereas the
32
+ * authorizations-capture response is the capture object directly. Prefer the
33
+ * nested capture status, falling back to the top level.
34
+ */
35
+ export function extractCaptureStatus(resource: any): string {
36
+ if (!resource || typeof resource !== "object") return ""
37
+ const nested = resource?.purchase_units?.[0]?.payments?.captures?.[0]?.status
38
+ return String(nested ?? resource.status ?? "").toUpperCase()
39
+ }
40
+
41
+ /**
42
+ * True only when a capture resource is COMPLETED. A 2xx HTTP response does NOT
43
+ * imply this: PayPal returns 201 for PENDING (pending review / eCheck),
44
+ * DECLINED, and FAILED captures too.
45
+ */
46
+ export function isCaptureCompleted(resource: any): boolean {
47
+ return extractCaptureStatus(resource) === "COMPLETED"
48
+ }
49
+
50
+ /**
51
+ * Refund statuses that mean the refund did NOT go through and must never be
52
+ * recorded as a successful refund. PENDING is intentionally excluded: PayPal
53
+ * processes refunds asynchronously and a pending refund settles later.
54
+ */
55
+ export function isRefundFailureStatus(status?: string): boolean {
56
+ return ["FAILED", "CANCELLED", "CANCELED", "DENIED"].includes(
57
+ String(status || "").toUpperCase()
58
+ )
59
+ }
@@ -493,10 +493,15 @@ class PayPalModuleService extends MedusaService({
493
493
  return await this.getCurrentRow()
494
494
  }
495
495
 
496
- async createOnboardingLink(input?: { email?: string; products?: string[] }) {
496
+ async createOnboardingLink(input?: { email?: string; products?: string[]; env?: Environment }) {
497
497
  const { onboarding } = await this.ensureSettingsDefaults()
498
498
  const return_url = `${String(onboarding.backend_url || "").replace(/\/$/, "")}/admin/paypal/onboard-complete`
499
- const env = await this.getCurrentEnvironment()
499
+ // Honor an explicit environment from the caller so the generated link can't
500
+ // depend on a racing "set environment" request having landed first.
501
+ const env =
502
+ input?.env === "sandbox" || input?.env === "live"
503
+ ? input.env
504
+ : await this.getCurrentEnvironment()
500
505
  const partner_merchant_id = await this.getPartnerMerchantId(env)
501
506
 
502
507
  const email = (input?.email || "").trim()
@@ -899,7 +904,9 @@ class PayPalModuleService extends MedusaService({
899
904
  console.warn("[PayPal] saveAndHydrateSellerCredentials lookup failed:", e?.message || e)
900
905
  }
901
906
 
902
- return await this.getStatus(env)
907
+ // This is a write context (credentials were just saved), so it is allowed to
908
+ // backfill the seller profile if it is still missing.
909
+ return await this.getStatus(env, { hydrateMissingProfile: true })
903
910
  }
904
911
 
905
912
  private async resolveWebhookUrl() {
@@ -1028,7 +1035,19 @@ class PayPalModuleService extends MedusaService({
1028
1035
  )}`
1029
1036
  }
1030
1037
 
1031
- async getStatus(envOverride?: Environment) {
1038
+ /**
1039
+ * Report the current connection status.
1040
+ *
1041
+ * `hydrateMissingProfile` is opt-in and OFF by default: status is read on
1042
+ * `GET /admin/paypal/status` (and other read-only routes), where it must be a
1043
+ * safe, side-effect-free read. The seller-profile backfill makes an outbound
1044
+ * PayPal call and persists to the DB, so it only runs from write contexts
1045
+ * (e.g. saving credentials) that explicitly request it.
1046
+ */
1047
+ async getStatus(
1048
+ envOverride?: Environment,
1049
+ opts: { hydrateMissingProfile?: boolean } = {}
1050
+ ) {
1032
1051
  const row = await this.getCurrentRow()
1033
1052
  const env = envOverride ?? (await this.getCurrentEnvironment())
1034
1053
 
@@ -1041,7 +1060,7 @@ class PayPalModuleService extends MedusaService({
1041
1060
  let sellerEmail: string | null = c.sellerEmail || row.seller_email || null
1042
1061
  let sellerMerchantId: string | null = c.sellerMerchantId || row.seller_merchant_id || null
1043
1062
 
1044
- if (!sellerEmail && hasCreds) {
1063
+ if (!sellerEmail && hasCreds && opts.hydrateMissingProfile) {
1045
1064
  try {
1046
1065
  const hydrated = await this.fetchSellerProfileFromDirectCredentials(env)
1047
1066
  if (hydrated.sellerEmail || hydrated.sellerMerchantId) {
@@ -1,4 +1,4 @@
1
- import { createCipheriv, createDecipheriv, createHash, randomBytes } from "crypto"
1
+ import { createCipheriv, createDecipheriv, createHash, randomBytes, scryptSync } from "crypto"
2
2
 
3
3
  /**
4
4
  * Opt-in encryption-at-rest for PayPal secrets (client secret, app access
@@ -8,15 +8,35 @@ import { createCipheriv, createDecipheriv, createHash, randomBytes } from "crypt
8
8
  * default), both functions are the identity — behavior is byte-for-byte
9
9
  * unchanged, so existing deployments are unaffected.
10
10
  *
11
- * Format: `enc:v1:<iv_b64>:<tag_b64>:<ciphertext_b64>` using AES-256-GCM
12
- * (authenticated, so tampering is detected on decrypt). Legacy plaintext values
13
- * (no prefix) are returned as-is on decrypt, so secrets stored before
14
- * encryption was enabled keep working and are upgraded on the next save.
11
+ * Format (current): `enc:v2:<salt_b64>:<iv_b64>:<tag_b64>:<ciphertext_b64>`
12
+ * using AES-256-GCM with a key derived via **scrypt** over the env key and a
13
+ * random per-secret salt. scrypt is a memory-hard KDF, so even a low-entropy
14
+ * passphrase resists brute-force/rainbow-table attacks (the previous unsalted,
15
+ * single-pass SHA-256 did not).
16
+ *
17
+ * Format (legacy): `enc:v1:<iv_b64>:<tag_b64>:<ciphertext_b64>` with an
18
+ * unsalted SHA-256 key. Still decryptable so secrets written before the KDF
19
+ * upgrade keep working; they are transparently upgraded to v2 on the next save.
20
+ *
21
+ * Legacy plaintext values (no prefix) are returned as-is on decrypt.
15
22
  */
16
- const PREFIX = "enc:v1:"
23
+ const PREFIX_V2 = "enc:v2:"
24
+ const PREFIX_V1 = "enc:v1:"
25
+
26
+ // scrypt cost parameters. N=2^14 with r=8 needs ~16MB (128*N*r), which stays
27
+ // under Node's default 32MB scrypt memory limit while imposing a meaningful
28
+ // per-derivation cost. Derived keys are cached, so this runs at most once per
29
+ // (env key, salt) pair rather than on every encrypt/decrypt.
30
+ const SCRYPT_N = 16384
31
+ const SCRYPT_R = 8
32
+ const SCRYPT_P = 1
33
+ const KEY_LEN = 32
34
+ const SALT_LEN = 16
17
35
 
18
36
  let cachedRaw: string | undefined
19
- let cachedKey: Buffer | null = null
37
+ let cachedRawKey: string | null = null
38
+ let legacyV1Key: Buffer | null = null
39
+ const v2KeyCache = new Map<string, Buffer>()
20
40
 
21
41
  /**
22
42
  * `@types/node` 22.0-22.6 typed `Buffer` as `Buffer<ArrayBufferLike>`, which is
@@ -27,41 +47,75 @@ let cachedKey: Buffer | null = null
27
47
  */
28
48
  const bytes = (view: Buffer): Uint8Array => view as unknown as Uint8Array
29
49
 
30
- function getKey(): Buffer | null {
50
+ /** The raw env key (trimmed), or null when encryption is disabled. */
51
+ function getRawKey(): string | null {
31
52
  const raw = (process.env.PAYPAL_ENCRYPTION_KEY || "").trim()
32
53
  if (raw !== cachedRaw) {
33
54
  cachedRaw = raw
34
- // Accept a key of any length/format; derive a stable 32-byte AES key.
35
- cachedKey = raw ? createHash("sha256").update(raw).digest() : null
55
+ cachedRawKey = raw || null
56
+ // The unsalted SHA-256 key is only needed to decrypt legacy v1 values.
57
+ legacyV1Key = raw ? createHash("sha256").update(raw).digest() : null
58
+ v2KeyCache.clear()
59
+ }
60
+ return cachedRawKey
61
+ }
62
+
63
+ /** Derive (and cache) the AES key for a v2 secret from the env key + salt. */
64
+ function deriveV2Key(raw: string, salt: Buffer): Buffer {
65
+ const cacheKey = `${raw}::${salt.toString("base64")}`
66
+ let key = v2KeyCache.get(cacheKey)
67
+ if (!key) {
68
+ key = scryptSync(raw, bytes(salt), KEY_LEN, {
69
+ N: SCRYPT_N,
70
+ r: SCRYPT_R,
71
+ p: SCRYPT_P,
72
+ })
73
+ v2KeyCache.set(cacheKey, key)
36
74
  }
37
- return cachedKey
75
+ return key
76
+ }
77
+
78
+ function gcmDecrypt(key: Buffer, ivB64: string, tagB64: string, ctB64: string): string {
79
+ const decipher = createDecipheriv("aes-256-gcm", bytes(key), bytes(Buffer.from(ivB64, "base64")))
80
+ decipher.setAuthTag(bytes(Buffer.from(tagB64, "base64")))
81
+ const plaintext = Buffer.concat([
82
+ bytes(decipher.update(bytes(Buffer.from(ctB64, "base64")))),
83
+ bytes(decipher.final()),
84
+ ])
85
+ return plaintext.toString("utf8")
38
86
  }
39
87
 
40
88
  export function isSecretEncryptionEnabled(): boolean {
41
- return getKey() !== null
89
+ return getRawKey() !== null
42
90
  }
43
91
 
44
92
  export function isEncrypted(value: unknown): boolean {
45
- return typeof value === "string" && value.startsWith(PREFIX)
93
+ return (
94
+ typeof value === "string" &&
95
+ (value.startsWith(PREFIX_V2) || value.startsWith(PREFIX_V1))
96
+ )
46
97
  }
47
98
 
48
99
  /**
49
100
  * Encrypt a secret for storage. Returns the input unchanged when no key is
50
- * configured or when the value is empty/already encrypted.
101
+ * configured or when the value is empty/already encrypted. Always writes the
102
+ * current (v2) format.
51
103
  */
52
104
  export function encryptSecret<T extends string | null | undefined>(value: T): T | string {
53
105
  if (!value) {
54
106
  return value
55
107
  }
56
- const key = getKey()
57
- if (!key || isEncrypted(value)) {
108
+ const raw = getRawKey()
109
+ if (!raw || isEncrypted(value)) {
58
110
  return value
59
111
  }
112
+ const salt = randomBytes(SALT_LEN)
113
+ const key = deriveV2Key(raw, salt)
60
114
  const iv = randomBytes(12)
61
115
  const cipher = createCipheriv("aes-256-gcm", bytes(key), bytes(iv))
62
116
  const ciphertext = Buffer.concat([bytes(cipher.update(String(value), "utf8")), bytes(cipher.final())])
63
117
  const tag = cipher.getAuthTag()
64
- return `${PREFIX}${iv.toString("base64")}:${tag.toString("base64")}:${ciphertext.toString("base64")}`
118
+ return `${PREFIX_V2}${salt.toString("base64")}:${iv.toString("base64")}:${tag.toString("base64")}:${ciphertext.toString("base64")}`
65
119
  }
66
120
 
67
121
  /**
@@ -73,22 +127,31 @@ export function decryptSecret<T extends string | null | undefined>(value: T): T
73
127
  if (!value || !isEncrypted(value)) {
74
128
  return value
75
129
  }
76
- const key = getKey()
77
- if (!key) {
130
+ const raw = getRawKey()
131
+ if (!raw) {
78
132
  throw new Error(
79
133
  "PayPal secret is encrypted but PAYPAL_ENCRYPTION_KEY is not set. Restore the key to decrypt stored credentials."
80
134
  )
81
135
  }
82
- const body = String(value).slice(PREFIX.length)
136
+
137
+ if (String(value).startsWith(PREFIX_V2)) {
138
+ const body = String(value).slice(PREFIX_V2.length)
139
+ const [saltB64, ivB64, tagB64, ctB64] = body.split(":")
140
+ if (!saltB64 || !ivB64 || !tagB64 || !ctB64) {
141
+ throw new Error("PayPal secret ciphertext is malformed.")
142
+ }
143
+ const key = deriveV2Key(raw, Buffer.from(saltB64, "base64"))
144
+ return gcmDecrypt(key, ivB64, tagB64, ctB64)
145
+ }
146
+
147
+ // Legacy v1 (unsalted SHA-256 key).
148
+ const body = String(value).slice(PREFIX_V1.length)
83
149
  const [ivB64, tagB64, ctB64] = body.split(":")
84
150
  if (!ivB64 || !tagB64 || !ctB64) {
85
151
  throw new Error("PayPal secret ciphertext is malformed.")
86
152
  }
87
- const decipher = createDecipheriv("aes-256-gcm", bytes(key), bytes(Buffer.from(ivB64, "base64")))
88
- decipher.setAuthTag(bytes(Buffer.from(tagB64, "base64")))
89
- const plaintext = Buffer.concat([
90
- bytes(decipher.update(bytes(Buffer.from(ctB64, "base64")))),
91
- bytes(decipher.final()),
92
- ])
93
- return plaintext.toString("utf8")
153
+ if (!legacyV1Key) {
154
+ throw new Error("PayPal secret is encrypted but PAYPAL_ENCRYPTION_KEY is not set.")
155
+ }
156
+ return gcmDecrypt(legacyV1Key, ivB64, tagB64, ctB64)
94
157
  }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Helpers for calling PayPal's `verify-webhook-signature` API correctly.
3
+ *
4
+ * PayPal computes the expected signature over the EXACT bytes it transmitted.
5
+ * Re-serializing the parsed body (`JSON.stringify(req.body)`) can change those
6
+ * bytes — key ordering, non-ASCII escaping (`José` vs `José`), number and
7
+ * whitespace formatting — which makes verification intermittently return
8
+ * FAILURE for legitimate webhooks. When the original raw body is available we
9
+ * inject it verbatim as `webhook_event`; otherwise we fall back to serializing
10
+ * the parsed body so behavior never regresses.
11
+ */
12
+
13
+ /** Normalize Medusa's `req.rawBody` (a Buffer when `preserveRawBody` is set) to a string. */
14
+ export function rawBodyToString(rawBody: unknown): string | null {
15
+ if (rawBody == null) {
16
+ return null
17
+ }
18
+ if (typeof rawBody === "string") {
19
+ return rawBody.length > 0 ? rawBody : null
20
+ }
21
+ if (Buffer.isBuffer(rawBody)) {
22
+ return rawBody.length > 0 ? rawBody.toString("utf8") : null
23
+ }
24
+ if (rawBody instanceof Uint8Array) {
25
+ return rawBody.length > 0 ? Buffer.from(rawBody).toString("utf8") : null
26
+ }
27
+ return null
28
+ }
29
+
30
+ /**
31
+ * Compose the JSON request body for `verify-webhook-signature`.
32
+ *
33
+ * `fields` are the transmission headers + webhook_id; `webhookEventJson` is the
34
+ * raw webhook body verbatim (preferred) or a serialized fallback. Undefined /
35
+ * null header values are omitted. The webhook event is concatenated as raw JSON
36
+ * (not re-encoded) so the bytes PayPal hashes match what it signed.
37
+ */
38
+ export function composeVerifyRequestBody(
39
+ fields: Record<string, string | undefined | null>,
40
+ webhookEventJson: string
41
+ ): string {
42
+ const head = Object.entries(fields)
43
+ .filter(([, v]) => v !== undefined && v !== null)
44
+ .map(([k, v]) => `${JSON.stringify(k)}:${JSON.stringify(v)}`)
45
+ .join(",")
46
+ return `{${head}${head ? "," : ""}"webhook_event":${webhookEventJson}}`
47
+ }
48
+
49
+ /**
50
+ * Pick the bytes to use for `webhook_event`: the raw body when present and
51
+ * non-blank, otherwise a JSON serialization of the parsed body.
52
+ */
53
+ export function resolveWebhookEventJson(
54
+ rawBody: string | null | undefined,
55
+ parsedBody: unknown
56
+ ): string {
57
+ if (typeof rawBody === "string" && rawBody.trim().length > 0) {
58
+ return rawBody
59
+ }
60
+ return JSON.stringify(parsedBody ?? {})
61
+ }