payment-kit 1.27.2 → 1.29.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/__blocklet__.js +37 -0
- package/api/ocap-1.30-subpath-shims.d.ts +35 -0
- package/api/src/crons/index.ts +32 -0
- package/api/src/crons/metering-subscription-detection.ts +12 -14
- package/api/src/crons/overdue-detection.ts +51 -74
- package/api/src/crons/retry-pending-events.ts +58 -0
- package/api/src/integrations/app-store/apple-root-certs.ts +26 -0
- package/api/src/integrations/app-store/client.ts +369 -0
- package/api/src/integrations/app-store/handlers/index.ts +46 -0
- package/api/src/integrations/app-store/handlers/subscription.ts +635 -0
- package/api/src/integrations/app-store/node-apple-receipt-verify.d.ts +17 -0
- package/api/src/integrations/app-store/notification-routing.ts +18 -0
- package/api/src/integrations/app-store/signed-data-verifier.ts +150 -0
- package/api/src/integrations/arcblock/nft.ts +6 -2
- package/api/src/integrations/arcblock/stake.ts +3 -2
- package/api/src/integrations/arcblock/token.ts +4 -4
- package/api/src/integrations/blocklet/notification.ts +1 -1
- package/api/src/integrations/ethereum/tx.ts +29 -0
- package/api/src/integrations/google-play/client.ts +276 -0
- package/api/src/integrations/google-play/handlers/index.ts +69 -0
- package/api/src/integrations/google-play/handlers/subscription.ts +565 -0
- package/api/src/integrations/google-play/handlers/voided.ts +106 -0
- package/api/src/integrations/google-play/setup.ts +43 -0
- package/api/src/integrations/google-play/verify.ts +251 -0
- package/api/src/integrations/iap-reconcile.ts +415 -0
- package/api/src/integrations/stripe/handlers/invoice.ts +70 -53
- package/api/src/integrations/stripe/handlers/payment-intent.ts +8 -1
- package/api/src/integrations/stripe/resource.ts +8 -0
- package/api/src/libs/audit.ts +70 -24
- package/api/src/libs/auth.ts +49 -2
- package/api/src/libs/chain-error.ts +31 -0
- package/api/src/libs/entitlement.ts +399 -0
- package/api/src/libs/env.ts +2 -0
- package/api/src/libs/error.ts +15 -0
- package/api/src/libs/event.ts +42 -1
- package/api/src/libs/invoice.ts +69 -34
- package/api/src/libs/notification/template/customer-auto-recharge-daily-limit-exceeded.ts +1 -3
- package/api/src/libs/notification/template/customer-auto-recharge-failed.ts +1 -3
- package/api/src/libs/notification/template/customer-credit-grant-granted.ts +1 -3
- package/api/src/libs/notification/template/customer-credit-insufficient.ts +1 -3
- package/api/src/libs/notification/template/customer-credit-low-balance.ts +1 -3
- package/api/src/libs/notification/template/customer-revenue-succeeded.ts +1 -3
- package/api/src/libs/notification/template/customer-reward-succeeded.ts +1 -3
- package/api/src/libs/notification/template/one-time-payment-refund-succeeded.ts +1 -3
- package/api/src/libs/notification/template/one-time-payment-succeeded.ts +1 -3
- package/api/src/libs/notification/template/subscription-renew-failed.ts +1 -3
- package/api/src/libs/notification/template/subscription-slippage-exceeded.ts +1 -3
- package/api/src/libs/notification/template/subscription-slippage-warning.ts +1 -3
- package/api/src/libs/notification/template/subscription-succeeded.ts +1 -1
- package/api/src/libs/pagination.ts +14 -9
- package/api/src/libs/payment.ts +25 -10
- package/api/src/libs/security.ts +51 -0
- package/api/src/libs/session.ts +1 -1
- package/api/src/libs/subscription.ts +13 -1
- package/api/src/libs/timing.ts +35 -0
- package/api/src/libs/util.ts +29 -15
- package/api/src/libs/wallet-migration.ts +72 -53
- package/api/src/queues/auto-recharge.ts +1 -1
- package/api/src/queues/credit-consume.ts +94 -12
- package/api/src/queues/credit-grant.ts +4 -0
- package/api/src/queues/event.ts +39 -21
- package/api/src/queues/invoice.ts +1 -0
- package/api/src/queues/payment.ts +83 -15
- package/api/src/queues/refund.ts +84 -71
- package/api/src/queues/subscription.ts +1 -0
- package/api/src/queues/webhook.ts +12 -2
- package/api/src/routes/checkout-sessions.ts +82 -43
- package/api/src/routes/connect/change-payment.ts +2 -0
- package/api/src/routes/connect/change-plan.ts +2 -0
- package/api/src/routes/connect/pay.ts +12 -3
- package/api/src/routes/connect/setup.ts +3 -1
- package/api/src/routes/connect/shared.ts +52 -39
- package/api/src/routes/connect/subscribe.ts +4 -1
- package/api/src/routes/credit-grants.ts +25 -17
- package/api/src/routes/donations.ts +2 -2
- package/api/src/routes/entitlements.ts +105 -0
- package/api/src/routes/events.ts +2 -2
- package/api/src/routes/index.ts +12 -2
- package/api/src/routes/integrations/app-store.ts +267 -0
- package/api/src/routes/integrations/google-play.ts +324 -0
- package/api/src/routes/meter-events.ts +16 -6
- package/api/src/routes/payment-links.ts +1 -1
- package/api/src/routes/payment-methods.ts +131 -1
- package/api/src/routes/settings.ts +1 -1
- package/api/src/routes/tax-rates.ts +1 -1
- package/api/src/store/migrations/20260526-iap-foundation.ts +105 -0
- package/api/src/store/models/customer.ts +37 -1
- package/api/src/store/models/entitlement-grant.ts +118 -0
- package/api/src/store/models/entitlement-product.ts +48 -0
- package/api/src/store/models/entitlement.ts +86 -0
- package/api/src/store/models/index.ts +9 -0
- package/api/src/store/models/invoice.ts +20 -0
- package/api/src/store/models/payment-method.ts +66 -1
- package/api/src/store/models/price.ts +23 -14
- package/api/src/store/models/refund.ts +10 -0
- package/api/src/store/models/subscription.ts +14 -0
- package/api/src/store/models/types.ts +32 -0
- package/api/tests/integrations/app-store/client.spec.ts +335 -0
- package/api/tests/integrations/app-store/handlers.spec.ts +480 -0
- package/api/tests/integrations/app-store/notifications.spec.ts +381 -0
- package/api/tests/integrations/app-store/signed-data-verifier.spec.ts +72 -0
- package/api/tests/integrations/app-store/webhook-routing.spec.ts +27 -0
- package/api/tests/integrations/google-play/handlers.spec.ts +341 -0
- package/api/tests/integrations/google-play/verify.spec.ts +215 -0
- package/api/tests/integrations/iap-reconcile.spec.ts +237 -0
- package/api/tests/libs/entitlement.spec.ts +347 -0
- package/api/tests/libs/wallet-migration.spec.ts +4 -4
- package/api/tests/queues/credit-consume-batch.spec.ts +5 -2
- package/api/tests/queues/credit-consume.spec.ts +8 -4
- package/api/tests/routes/credit-grants.spec.ts +1 -0
- package/blocklet.yml +1 -1
- package/cloudflare/MIGRATION-CHALLENGES.md +676 -0
- package/cloudflare/MIGRATION-RUNBOOK.md +777 -0
- package/cloudflare/README.md +499 -0
- package/cloudflare/STAGING-MIGRATION-GUIDE.md +602 -0
- package/cloudflare/build.ts +151 -0
- package/cloudflare/did-connect-auth.ts +527 -0
- package/cloudflare/docs/2026-04-22-sdk-1.30.9-upgrade-retro.md +324 -0
- package/cloudflare/docs/2026-04-24-queue-ops-followup.md +218 -0
- package/cloudflare/docs/cf-queues-ops-alert-analysis.md +663 -0
- package/cloudflare/docs/cf-workers-local-dev-and-fixes.md +284 -0
- package/cloudflare/docs/cleanup-tasks-2026-05.md +62 -0
- package/cloudflare/docs/payment-kit-platform-analysis-2026-04-20.md +354 -0
- package/cloudflare/frontend-shims/buffer-polyfill.ts +9 -0
- package/cloudflare/frontend-shims/js-sdk.ts +43 -0
- package/cloudflare/frontend-shims/mime-types.ts +46 -0
- package/cloudflare/frontend-shims/session.ts +24 -0
- package/cloudflare/frontend-shims/vite-plugin-noop.ts +6 -0
- package/cloudflare/index.html +40 -0
- package/cloudflare/migrate-to-d1.js +252 -0
- package/cloudflare/migrations/0001_initial_schema.sql +82 -0
- package/cloudflare/migrations/0002_indexes.sql +75 -0
- package/cloudflare/migrations/0003_locks_and_constraints.sql +18 -0
- package/cloudflare/migrations/0004_iap_foundation.sql +72 -0
- package/cloudflare/migrations/0005_iap_tenant_backfill.sql +112 -0
- package/cloudflare/run-build.js +391 -0
- package/cloudflare/scripts/test-decrypt.js +102 -0
- package/cloudflare/shims/arcblock-ws.ts +20 -0
- package/cloudflare/shims/axios-http-adapter.ts +4 -0
- package/cloudflare/shims/axios-lite.ts +117 -0
- package/cloudflare/shims/blocklet-sdk/auth-service.ts +33 -0
- package/cloudflare/shims/blocklet-sdk/cdn.ts +3 -0
- package/cloudflare/shims/blocklet-sdk/component-api.ts +35 -0
- package/cloudflare/shims/blocklet-sdk/component.ts +18 -0
- package/cloudflare/shims/blocklet-sdk/config.ts +8 -0
- package/cloudflare/shims/blocklet-sdk/did.ts +14 -0
- package/cloudflare/shims/blocklet-sdk/env.ts +12 -0
- package/cloudflare/shims/blocklet-sdk/eventbus.ts +3 -0
- package/cloudflare/shims/blocklet-sdk/fallback.ts +3 -0
- package/cloudflare/shims/blocklet-sdk/index.ts +11 -0
- package/cloudflare/shims/blocklet-sdk/logger.ts +11 -0
- package/cloudflare/shims/blocklet-sdk/middlewares.ts +15 -0
- package/cloudflare/shims/blocklet-sdk/notification.ts +11 -0
- package/cloudflare/shims/blocklet-sdk/security.ts +53 -0
- package/cloudflare/shims/blocklet-sdk/session.ts +8 -0
- package/cloudflare/shims/blocklet-sdk/verify-session.ts +44 -0
- package/cloudflare/shims/blocklet-sdk/verify-sign.ts +38 -0
- package/cloudflare/shims/blocklet-sdk/wallet-authenticator.ts +3 -0
- package/cloudflare/shims/blocklet-sdk/wallet-handler.ts +6 -0
- package/cloudflare/shims/blocklet-sdk/wallet.ts +103 -0
- package/cloudflare/shims/cookie-parser.ts +3 -0
- package/cloudflare/shims/cors.ts +21 -0
- package/cloudflare/shims/cron.ts +189 -0
- package/cloudflare/shims/crypto-js-warn.ts +7 -0
- package/cloudflare/shims/did-space-js.ts +17 -0
- package/cloudflare/shims/did-space.ts +11 -0
- package/cloudflare/shims/error.ts +18 -0
- package/cloudflare/shims/express-compat/index.ts +80 -0
- package/cloudflare/shims/express-compat/types.ts +41 -0
- package/cloudflare/shims/fastq.ts +105 -0
- package/cloudflare/shims/lock.ts +115 -0
- package/cloudflare/shims/mime-types.ts +56 -0
- package/cloudflare/shims/nedb-storage.ts +9 -0
- package/cloudflare/shims/node-child-process.ts +9 -0
- package/cloudflare/shims/node-fs.ts +20 -0
- package/cloudflare/shims/node-http.ts +13 -0
- package/cloudflare/shims/node-https.ts +4 -0
- package/cloudflare/shims/node-misc.ts +15 -0
- package/cloudflare/shims/node-net.ts +8 -0
- package/cloudflare/shims/node-os.ts +14 -0
- package/cloudflare/shims/node-tty.ts +8 -0
- package/cloudflare/shims/node-zlib.ts +17 -0
- package/cloudflare/shims/noop.ts +26 -0
- package/cloudflare/shims/payment-vendor.ts +14 -0
- package/cloudflare/shims/querystring.ts +12 -0
- package/cloudflare/shims/queue.ts +611 -0
- package/cloudflare/shims/rolldown-runtime.ts +43 -0
- package/cloudflare/shims/sequelize-d1/datatypes.ts +24 -0
- package/cloudflare/shims/sequelize-d1/helpers.ts +46 -0
- package/cloudflare/shims/sequelize-d1/index.ts +34 -0
- package/cloudflare/shims/sequelize-d1/model.ts +1176 -0
- package/cloudflare/shims/sequelize-d1/operators.ts +306 -0
- package/cloudflare/shims/sequelize-d1/retry.ts +85 -0
- package/cloudflare/shims/sequelize-d1/sequelize-class.ts +119 -0
- package/cloudflare/shims/sequelize-d1/timing.ts +81 -0
- package/cloudflare/shims/sequelize-d1/types.ts +35 -0
- package/cloudflare/shims/stripe-cf.ts +29 -0
- package/cloudflare/shims/ws-lite.ts +103 -0
- package/cloudflare/shims/xss.ts +3 -0
- package/cloudflare/tests/shims/cron.spec.ts +210 -0
- package/cloudflare/tests/shims/queue-delayed-persist.spec.ts +87 -0
- package/cloudflare/tests/shims/queue-scheduled.spec.ts +186 -0
- package/cloudflare/vite.config.ts +162 -0
- package/cloudflare/worker.ts +1608 -0
- package/cloudflare/wrangler.json +63 -0
- package/cloudflare/wrangler.jsonc +75 -0
- package/cloudflare/wrangler.staging.json +67 -0
- package/cloudflare/wrangler.toml +28 -0
- package/jest.config.js +4 -12
- package/package.json +30 -22
- package/scripts/seed-google-play.ts +79 -0
- package/src/app.tsx +62 -4
- package/src/components/customer/link.tsx +9 -13
- package/src/components/customer/notification-preference.tsx +3 -2
- package/src/components/filter-toolbar.tsx +4 -0
- package/src/components/invoice/list.tsx +9 -1
- package/src/components/invoice-pdf/utils.ts +2 -1
- package/src/components/layout/admin.tsx +39 -5
- package/src/components/layout/user-cf.tsx +77 -0
- package/src/components/payment-intent/actions.tsx +23 -3
- package/src/components/payment-method/app-store.tsx +103 -0
- package/src/components/payment-method/form.tsx +7 -1
- package/src/components/payment-method/google-play.tsx +85 -0
- package/src/components/safe-did-address.tsx +75 -0
- package/src/components/subscription/list.tsx +20 -0
- package/src/libs/patch-user-card.ts +25 -0
- package/src/libs/util.ts +5 -7
- package/src/locales/en.tsx +63 -0
- package/src/locales/zh.tsx +63 -0
- package/src/pages/admin/billing/meter-events/index.tsx +4 -0
- package/src/pages/admin/billing/subscriptions/detail.tsx +80 -0
- package/src/pages/admin/customers/customers/detail.tsx +8 -2
- package/src/pages/admin/customers/customers/index.tsx +2 -2
- package/src/pages/admin/overview.tsx +3 -1
- package/src/pages/admin/settings/payment-methods/create.tsx +12 -0
- package/src/pages/admin/settings/payment-methods/index.tsx +1 -1
- package/src/pages/customer/subscription/detail.tsx +4 -4
- package/tsconfig.api.json +1 -6
- package/tsconfig.json +3 -4
- package/tsconfig.types.json +2 -1
- package/vite.config.ts +6 -1
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Google Play integration startup checks.
|
|
2
|
+
//
|
|
3
|
+
// Unlike Stripe (we register webhook URL with Stripe at startup), Google Play's
|
|
4
|
+
// Real-Time Developer Notifications is configured **server-side in Google Cloud
|
|
5
|
+
// Pub/Sub + Play Console**, not via API call. This file just sanity-checks the
|
|
6
|
+
// PaymentMethod settings on app start and surfaces clear errors early.
|
|
7
|
+
|
|
8
|
+
import logger from '../../libs/logger';
|
|
9
|
+
import { PaymentMethod } from '../../store/models';
|
|
10
|
+
|
|
11
|
+
export async function ensureGooglePlayConfigured(): Promise<void> {
|
|
12
|
+
const methods = await PaymentMethod.findAll({ where: { type: 'google_play' } });
|
|
13
|
+
if (methods.length === 0) {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
for (const method of methods) {
|
|
18
|
+
const settings = PaymentMethod.decryptSettings(method.settings);
|
|
19
|
+
const cfg = settings.google_play;
|
|
20
|
+
if (!cfg) {
|
|
21
|
+
logger.warn('google_play PaymentMethod missing settings', { id: method.id });
|
|
22
|
+
// eslint-disable-next-line no-continue
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (!cfg.package_name) {
|
|
26
|
+
logger.warn('google_play PaymentMethod missing package_name', { id: method.id });
|
|
27
|
+
}
|
|
28
|
+
if (!cfg.service_account_json) {
|
|
29
|
+
logger.warn('google_play PaymentMethod missing service_account_json', { id: method.id });
|
|
30
|
+
} else {
|
|
31
|
+
try {
|
|
32
|
+
JSON.parse(cfg.service_account_json);
|
|
33
|
+
} catch (err) {
|
|
34
|
+
logger.error('google_play service_account_json is not valid JSON', { id: method.id });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (!cfg.pubsub_topic_name) {
|
|
38
|
+
logger.warn('google_play PaymentMethod missing pubsub_topic_name', { id: method.id });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
logger.info('google_play PaymentMethod config checked', { count: methods.length });
|
|
43
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// Google Cloud Pub/Sub Push 验签。
|
|
2
|
+
//
|
|
3
|
+
// Pub/Sub Push 推送的每个请求都会带 `Authorization: Bearer <JWT>` 头,JWT 由 Google
|
|
4
|
+
// 用其内部服务账号私钥签发(RS256),通过 https://www.googleapis.com/oauth2/v3/certs
|
|
5
|
+
// 拉到的 JWK Set 可以做真实签名校验。
|
|
6
|
+
//
|
|
7
|
+
// 此前 A2 mock 阶段仅做 claim 检查不验签,等同于 iOS JWS 漏洞的 Android 镜像版
|
|
8
|
+
// (CWE-347)。本文件接入 Web Crypto 完成 RS256 验签,运行时同时兼容 Node 22+ 与
|
|
9
|
+
// Cloudflare Workers(payment-kit 是统一代码)。
|
|
10
|
+
|
|
11
|
+
import logger from '../../libs/logger';
|
|
12
|
+
|
|
13
|
+
export type PubSubJwtClaims = {
|
|
14
|
+
iss: string;
|
|
15
|
+
aud: string;
|
|
16
|
+
iat: number;
|
|
17
|
+
exp: number;
|
|
18
|
+
email?: string;
|
|
19
|
+
email_verified?: boolean;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type JwtHeader = {
|
|
23
|
+
alg: string;
|
|
24
|
+
kid?: string;
|
|
25
|
+
typ?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const GOOGLE_PUBSUB_ISSUERS = new Set(['https://accounts.google.com', 'accounts.google.com']);
|
|
29
|
+
const GOOGLE_JWKS_URL = 'https://www.googleapis.com/oauth2/v3/certs';
|
|
30
|
+
|
|
31
|
+
// Cache parsed JWKS keys by `kid`. Google rotates keys ~daily; 1h TTL keeps us
|
|
32
|
+
// fresh enough without hammering the JWKS endpoint on every webhook.
|
|
33
|
+
const JWKS_CACHE_TTL_MS = 60 * 60 * 1000;
|
|
34
|
+
let jwksCache: { fetchedAt: number; keys: Map<string, CryptoKey> } | null = null;
|
|
35
|
+
|
|
36
|
+
function decodeSegment(segment: string): Buffer {
|
|
37
|
+
return Buffer.from(segment, 'base64url');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseJsonSegment<T>(segment: string): T {
|
|
41
|
+
return JSON.parse(decodeSegment(segment).toString('utf8')) as T;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Decode JWT claims (payload segment only). Does NOT verify signature. */
|
|
45
|
+
export function decodePubSubJwt(token: string): PubSubJwtClaims {
|
|
46
|
+
const parts = token.split('.');
|
|
47
|
+
if (parts.length !== 3) {
|
|
48
|
+
throw new Error('Pub/Sub JWT format invalid: expected 3 segments');
|
|
49
|
+
}
|
|
50
|
+
return parseJsonSegment<PubSubJwtClaims>(parts[1]!);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type FetchJwks = () => Promise<Map<string, CryptoKey>>;
|
|
54
|
+
|
|
55
|
+
export type VerifyOptions = {
|
|
56
|
+
/** 当前 Worker 的公网 URL,必须匹配 JWT 的 `aud` claim */
|
|
57
|
+
expectedAudience: string;
|
|
58
|
+
/**
|
|
59
|
+
* The Pub/Sub push subscription's OIDC service-account email. When set, the
|
|
60
|
+
* JWT MUST carry `email_verified=true` and a matching `email` — otherwise any
|
|
61
|
+
* Google-issued token for this audience could forge RTDN payloads (PR #1381
|
|
62
|
+
* review P1). Leave undefined to skip (the call site logs a warning).
|
|
63
|
+
*/
|
|
64
|
+
expectedEmail?: string;
|
|
65
|
+
/** 容忍的时钟偏移(秒),默认 60 */
|
|
66
|
+
clockTolerance?: number;
|
|
67
|
+
/** 当前 unix 时间(秒),默认 Date.now() / 1000;用于测试注入 */
|
|
68
|
+
now?: number;
|
|
69
|
+
/** 覆盖 JWKS fetcher(测试用)。默认拉 Google `oauth2/v3/certs` 并缓存 1h */
|
|
70
|
+
fetchJwks?: FetchJwks;
|
|
71
|
+
/**
|
|
72
|
+
* Bypass RS256 signature verification.
|
|
73
|
+
*
|
|
74
|
+
* Only safe values:
|
|
75
|
+
* - true in unit tests that use synthetic JWTs (no real signing key)
|
|
76
|
+
* - true via `GOOGLE_PUBSUB_SKIP_SIGNATURE_VERIFY=true` for local sandbox
|
|
77
|
+
* debugging — NEVER in production.
|
|
78
|
+
*/
|
|
79
|
+
skipSignature?: boolean;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
function defaultSkipSignature(): boolean {
|
|
83
|
+
return process.env.GOOGLE_PUBSUB_SKIP_SIGNATURE_VERIFY === 'true';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Production fail-closed wrapper around the skipSignature flag — whether it
|
|
88
|
+
* came from `options.skipSignature` or `GOOGLE_PUBSUB_SKIP_SIGNATURE_VERIFY`.
|
|
89
|
+
* In production we refuse to honor the bypass (logs loudly so the
|
|
90
|
+
* misconfiguration is visible). The flag is meant for tests / local sandbox
|
|
91
|
+
* debugging only; in production it would silently let any caller forge an
|
|
92
|
+
* RTDN with arbitrary JWT claims (CWE-347).
|
|
93
|
+
*/
|
|
94
|
+
function effectiveSkipSignature(requested: boolean): boolean {
|
|
95
|
+
if (!requested) return false;
|
|
96
|
+
if (process.env.BLOCKLET_MODE === 'production') {
|
|
97
|
+
logger.error(
|
|
98
|
+
'google_play: signature verification skip refused in production — Pub/Sub JWT signature verification stays enabled'
|
|
99
|
+
);
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function fetchGoogleJwks(): Promise<Map<string, CryptoKey>> {
|
|
106
|
+
if (jwksCache && Date.now() - jwksCache.fetchedAt < JWKS_CACHE_TTL_MS) {
|
|
107
|
+
return jwksCache.keys;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const res = await fetch(GOOGLE_JWKS_URL);
|
|
111
|
+
if (!res.ok) {
|
|
112
|
+
throw new Error(`failed to fetch Google JWKS: HTTP ${res.status}`);
|
|
113
|
+
}
|
|
114
|
+
// Google JWKS entries always carry `kid` and `alg`; the lib `JsonWebKey` type
|
|
115
|
+
// doesn't, so widen here. Other fields (`kty`, `n`, `e`, `use`) are validated
|
|
116
|
+
// implicitly by `crypto.subtle.importKey`.
|
|
117
|
+
const body = (await res.json()) as { keys: Array<JsonWebKey & { kid?: string; alg?: string }> };
|
|
118
|
+
if (!Array.isArray(body?.keys)) {
|
|
119
|
+
throw new Error('Google JWKS response missing `keys` array');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const keys = new Map<string, CryptoKey>();
|
|
123
|
+
for (const jwk of body.keys) {
|
|
124
|
+
// eslint-disable-next-line no-continue -- guard-style skip for JWK entries we won't use
|
|
125
|
+
if (!jwk.kid) continue;
|
|
126
|
+
// eslint-disable-next-line no-continue -- guard-style skip for JWK entries we won't use
|
|
127
|
+
if (jwk.alg && jwk.alg !== 'RS256') continue;
|
|
128
|
+
// eslint-disable-next-line no-await-in-loop -- sequential key import keeps the call deterministic + small N
|
|
129
|
+
const key = await globalThis.crypto.subtle.importKey(
|
|
130
|
+
'jwk',
|
|
131
|
+
jwk,
|
|
132
|
+
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
|
|
133
|
+
false,
|
|
134
|
+
['verify']
|
|
135
|
+
);
|
|
136
|
+
keys.set(jwk.kid, key);
|
|
137
|
+
}
|
|
138
|
+
jwksCache = { fetchedAt: Date.now(), keys };
|
|
139
|
+
return keys;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Full Pub/Sub JWT verification: RS256 signature + issuer + audience + expiry.
|
|
144
|
+
*
|
|
145
|
+
* Async because both the JWKS fetch and `crypto.subtle.verify` are async on
|
|
146
|
+
* Web Crypto. Use `skipSignature: true` (or set
|
|
147
|
+
* `GOOGLE_PUBSUB_SKIP_SIGNATURE_VERIFY=true`) ONLY for unit tests / local
|
|
148
|
+
* sandbox debugging — never in production.
|
|
149
|
+
*/
|
|
150
|
+
export async function verifyPubSubJwt(token: string, options: VerifyOptions): Promise<PubSubJwtClaims> {
|
|
151
|
+
const parts = token.split('.');
|
|
152
|
+
if (parts.length !== 3) {
|
|
153
|
+
throw new Error('Pub/Sub JWT format invalid: expected 3 segments');
|
|
154
|
+
}
|
|
155
|
+
const [headerSeg, payloadSeg, signatureSeg] = parts as [string, string, string];
|
|
156
|
+
|
|
157
|
+
const header = parseJsonSegment<JwtHeader>(headerSeg);
|
|
158
|
+
const claims = parseJsonSegment<PubSubJwtClaims>(payloadSeg);
|
|
159
|
+
|
|
160
|
+
if (!GOOGLE_PUBSUB_ISSUERS.has(claims.iss)) {
|
|
161
|
+
throw new Error(`Pub/Sub JWT issuer not recognized: ${claims.iss}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (claims.aud !== options.expectedAudience) {
|
|
165
|
+
throw new Error(`Pub/Sub JWT audience mismatch: got ${claims.aud}, expected ${options.expectedAudience}`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const now = options.now ?? Math.floor(Date.now() / 1000);
|
|
169
|
+
const tolerance = options.clockTolerance ?? 60;
|
|
170
|
+
if (claims.exp + tolerance < now) {
|
|
171
|
+
throw new Error(`Pub/Sub JWT expired (exp=${claims.exp}, now=${now})`);
|
|
172
|
+
}
|
|
173
|
+
if (claims.iat - tolerance > now) {
|
|
174
|
+
throw new Error(`Pub/Sub JWT issued in the future (iat=${claims.iat}, now=${now})`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Sender-identity check: a valid Google-issued token for our audience is not
|
|
178
|
+
// enough — require the token to belong to the configured Pub/Sub push service
|
|
179
|
+
// account, with a verified email. Without this, any Google identity able to
|
|
180
|
+
// mint a token for this audience could forge RTDN payloads (PR #1381 P1).
|
|
181
|
+
if (options.expectedEmail) {
|
|
182
|
+
if (claims.email_verified !== true) {
|
|
183
|
+
throw new Error('Pub/Sub JWT email not verified');
|
|
184
|
+
}
|
|
185
|
+
if (claims.email !== options.expectedEmail) {
|
|
186
|
+
throw new Error(`Pub/Sub JWT email mismatch: got ${claims.email ?? '(none)'}, expected ${options.expectedEmail}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const skipSignature = effectiveSkipSignature(options.skipSignature ?? defaultSkipSignature());
|
|
191
|
+
if (skipSignature) {
|
|
192
|
+
logger.warn('Pub/Sub JWT signature verification skipped — set only in tests / local sandbox', {
|
|
193
|
+
iss: claims.iss,
|
|
194
|
+
aud: claims.aud,
|
|
195
|
+
});
|
|
196
|
+
return claims;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (header.alg !== 'RS256') {
|
|
200
|
+
throw new Error(`Pub/Sub JWT alg not supported: ${header.alg} (expected RS256)`);
|
|
201
|
+
}
|
|
202
|
+
if (!header.kid) {
|
|
203
|
+
throw new Error('Pub/Sub JWT header missing `kid`');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const fetchKeys = options.fetchJwks ?? fetchGoogleJwks;
|
|
207
|
+
const keys = await fetchKeys();
|
|
208
|
+
const key = keys.get(header.kid);
|
|
209
|
+
if (!key) {
|
|
210
|
+
throw new Error(`Pub/Sub JWT kid not found in JWKS: ${header.kid}`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const signingInput = new TextEncoder().encode(`${headerSeg}.${payloadSeg}`);
|
|
214
|
+
const signature = decodeSegment(signatureSeg);
|
|
215
|
+
const ok = await globalThis.crypto.subtle.verify(
|
|
216
|
+
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
|
|
217
|
+
key,
|
|
218
|
+
signature,
|
|
219
|
+
signingInput
|
|
220
|
+
);
|
|
221
|
+
if (!ok) {
|
|
222
|
+
throw new Error('Pub/Sub JWT signature verification failed');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return claims;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Test-only: reset the in-memory JWKS cache between cases. */
|
|
229
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
230
|
+
export function __resetJwksCacheForTests(): void {
|
|
231
|
+
jwksCache = null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export type PubSubMessage = {
|
|
235
|
+
message: {
|
|
236
|
+
data: string; // base64-encoded JSON
|
|
237
|
+
messageId: string;
|
|
238
|
+
publishTime: string;
|
|
239
|
+
attributes?: Record<string, string>;
|
|
240
|
+
};
|
|
241
|
+
subscription: string;
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
/** Decode the base64 `data` field inside a Pub/Sub push envelope. */
|
|
245
|
+
export function decodePubSubMessage<T = unknown>(envelope: PubSubMessage): T {
|
|
246
|
+
if (!envelope?.message?.data) {
|
|
247
|
+
throw new Error('Pub/Sub envelope has no message.data');
|
|
248
|
+
}
|
|
249
|
+
const decoded = Buffer.from(envelope.message.data, 'base64').toString('utf8');
|
|
250
|
+
return JSON.parse(decoded) as T;
|
|
251
|
+
}
|