@pithy-sh/payments 0.1.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/LICENSE +21 -0
- package/README.md +157 -0
- package/dist/paddle-prices.iife.js +1 -0
- package/dist/pithy.manifest.json +68 -0
- package/docs/lemon-squeezy.md +167 -0
- package/docs/paddle.md +486 -0
- package/package.json +73 -0
- package/pithy.manifest.json +68 -0
- package/src/admin/coverage.ts +107 -0
- package/src/admin/read.ts +360 -0
- package/src/audit/actions.ts +214 -0
- package/src/capability.ts +377 -0
- package/src/client/api.ts +734 -0
- package/src/client/checkout.ts +252 -0
- package/src/client/fixtures/price-preview-de.json +54 -0
- package/src/client/fixtures/price-preview-gb.json +54 -0
- package/src/client/fixtures/price-preview-jp-yen.json +54 -0
- package/src/client/fixtures/price-preview-us-country-only.json +54 -0
- package/src/client/fixtures/price-preview-us-new-york.json +54 -0
- package/src/client/fixtures/pricePreview.ts +67 -0
- package/src/client/hooks.ts +590 -0
- package/src/client/paddle.ts +733 -0
- package/src/client/paddleCache.ts +265 -0
- package/src/client/paddlePrices.iife.ts +43 -0
- package/src/client/paddlePrices.ts +143 -0
- package/src/client/paddlePricesTag.ts +332 -0
- package/src/client/projection.ts +133 -0
- package/src/client/test-utils/cacheStore.ts +41 -0
- package/src/client/wholeUnits.ts +147 -0
- package/src/cloudflare-test.d.ts +16 -0
- package/src/config/config.ts +563 -0
- package/src/data/discount.ts +238 -0
- package/src/data/entitlement.ts +67 -0
- package/src/data/money.ts +75 -0
- package/src/data/pause.ts +148 -0
- package/src/data/providerAccount.ts +47 -0
- package/src/data/purchase.ts +131 -0
- package/src/data/rail.ts +74 -0
- package/src/data/reconcileRun.ts +208 -0
- package/src/data/renderMoney.ts +180 -0
- package/src/data/status.ts +67 -0
- package/src/data/subject.ts +148 -0
- package/src/data/subscription.ts +675 -0
- package/src/data/syncCursor.ts +44 -0
- package/src/data/tables.ts +61 -0
- package/src/data/webhookEvent.ts +196 -0
- package/src/entitlement/manual.ts +209 -0
- package/src/entitlement/resolver.ts +125 -0
- package/src/entitlement/subjectSeam.ts +171 -0
- package/src/error/errors.ts +421 -0
- package/src/grants/apply.ts +207 -0
- package/src/grants/clawback.ts +128 -0
- package/src/grants/ledgerSeam.ts +141 -0
- package/src/http/dispatch.ts +48 -0
- package/src/http/guards.ts +58 -0
- package/src/http/manifestConfig.ts +56 -0
- package/src/http/responses.ts +1147 -0
- package/src/http/routes.ts +2911 -0
- package/src/http/schemas.ts +577 -0
- package/src/http/scopes.ts +260 -0
- package/src/http/view.ts +188 -0
- package/src/http/webhookGuard.ts +341 -0
- package/src/index.ts +231 -0
- package/src/migrations/0001_purchases.ts +312 -0
- package/src/pricing/location.ts +175 -0
- package/src/pricing/visitor.ts +82 -0
- package/src/projection/event.ts +101 -0
- package/src/projection/orphans.ts +226 -0
- package/src/projection/owner.ts +247 -0
- package/src/projection/resolve.ts +57 -0
- package/src/projection/writer.ts +554 -0
- package/src/provision/resolvePaymentsConfig.ts +91 -0
- package/src/rails/apple/certs.ts +66 -0
- package/src/rails/apple/der.ts +249 -0
- package/src/rails/apple/fixtures/chain.ts +412 -0
- package/src/rails/apple/fixtures/did-fail-to-renew-grace.json +43 -0
- package/src/rails/apple/fixtures/did-renew.json +48 -0
- package/src/rails/apple/fixtures/one-time-charge.json +32 -0
- package/src/rails/apple/fixtures/refund.json +33 -0
- package/src/rails/apple/fixtures/subscribed-initial-buy-sandbox.json +38 -0
- package/src/rails/apple/fixtures/test.json +14 -0
- package/src/rails/apple/http.ts +128 -0
- package/src/rails/apple/jws.ts +123 -0
- package/src/rails/apple/notification.ts +357 -0
- package/src/rails/apple/rail.ts +114 -0
- package/src/rails/apple/serverApi.ts +412 -0
- package/src/rails/apple/verify.ts +93 -0
- package/src/rails/apple/x509.ts +424 -0
- package/src/rails/contract.ts +939 -0
- package/src/rails/google/fixtures/play-product-purchased.json +14 -0
- package/src/rails/google/fixtures/play-subscription-active.json +23 -0
- package/src/rails/google/fixtures/push.ts +152 -0
- package/src/rails/google/fixtures/rtdn-one-time-purchased.json +11 -0
- package/src/rails/google/fixtures/rtdn-subscription-renewed.json +11 -0
- package/src/rails/google/fixtures/rtdn-subscription-revoked.json +11 -0
- package/src/rails/google/fixtures/rtdn-test.json +8 -0
- package/src/rails/google/fixtures/rtdn-voided-purchase.json +11 -0
- package/src/rails/google/http.ts +124 -0
- package/src/rails/google/jwt.ts +127 -0
- package/src/rails/google/oidc.ts +308 -0
- package/src/rails/google/playApi.ts +598 -0
- package/src/rails/google/rail.ts +174 -0
- package/src/rails/google/rtdn.ts +331 -0
- package/src/rails/google/verify.ts +127 -0
- package/src/rails/lemonSqueezy/api.ts +192 -0
- package/src/rails/lemonSqueezy/checkout.ts +186 -0
- package/src/rails/lemonSqueezy/discounts.ts +166 -0
- package/src/rails/lemonSqueezy/fixtures/events.ts +180 -0
- package/src/rails/lemonSqueezy/objects.ts +511 -0
- package/src/rails/lemonSqueezy/portal.ts +82 -0
- package/src/rails/lemonSqueezy/pricing.ts +124 -0
- package/src/rails/lemonSqueezy/rail.ts +137 -0
- package/src/rails/lemonSqueezy/read.ts +72 -0
- package/src/rails/lemonSqueezy/refresh.ts +86 -0
- package/src/rails/lemonSqueezy/signature.ts +119 -0
- package/src/rails/lemonSqueezy/verify.ts +45 -0
- package/src/rails/lemonSqueezy/webhook.ts +326 -0
- package/src/rails/paddle/adjustments.ts +94 -0
- package/src/rails/paddle/api.ts +266 -0
- package/src/rails/paddle/checkout.ts +240 -0
- package/src/rails/paddle/discounts.ts +176 -0
- package/src/rails/paddle/events.ts +285 -0
- package/src/rails/paddle/fixtures/browserForged.ts +54 -0
- package/src/rails/paddle/fixtures/pausedSubscription.ts +62 -0
- package/src/rails/paddle/objects.ts +883 -0
- package/src/rails/paddle/portal.ts +130 -0
- package/src/rails/paddle/rail.ts +219 -0
- package/src/rails/paddle/read.ts +108 -0
- package/src/rails/paddle/recorded.ts +83 -0
- package/src/rails/paddle/refresh.ts +153 -0
- package/src/rails/paddle/refund.ts +297 -0
- package/src/rails/paddle/signature.ts +225 -0
- package/src/rails/paddle/subscription.ts +793 -0
- package/src/rails/paddle/verify.ts +120 -0
- package/src/rails/paddle/webhook.ts +405 -0
- package/src/rails/providers.ts +168 -0
- package/src/rails/stripe/api.ts +253 -0
- package/src/rails/stripe/checkout.ts +175 -0
- package/src/rails/stripe/discounts.ts +169 -0
- package/src/rails/stripe/fixtures/event-charge-refunded.json +23 -0
- package/src/rails/stripe/fixtures/event-invoice-paid.json +19 -0
- package/src/rails/stripe/fixtures/event-session-completed-payment.json +27 -0
- package/src/rails/stripe/fixtures/event-session-completed-subscription.json +27 -0
- package/src/rails/stripe/fixtures/event-subscription-canceled.json +43 -0
- package/src/rails/stripe/fixtures/event-subscription-created.json +42 -0
- package/src/rails/stripe/fixtures/event-subscription-deleted.json +44 -0
- package/src/rails/stripe/fixtures/events.ts +111 -0
- package/src/rails/stripe/objects.ts +642 -0
- package/src/rails/stripe/portal.ts +45 -0
- package/src/rails/stripe/pricing.ts +123 -0
- package/src/rails/stripe/rail.ts +139 -0
- package/src/rails/stripe/refresh.ts +79 -0
- package/src/rails/stripe/signature.ts +127 -0
- package/src/rails/stripe/verify.ts +105 -0
- package/src/rails/stripe/webhook.ts +79 -0
- package/src/secret/registry.ts +275 -0
- package/src/seeds/example.ts +181 -0
- package/src/version.generated.ts +16 -0
- package/src/workflows/paddleSweep.ts +717 -0
- package/src/workflows/railAccess.ts +87 -0
- package/src/workflows/reconcile.ts +648 -0
- package/src/workflows/report.ts +64 -0
- package/src/workflows/retryPolicy.ts +40 -0
- package/src/workflows/specs.ts +143 -0
- package/src/workflows/worker.ts +166 -0
- package/src/workflows/workerConfig.ts +73 -0
- package/src/workflows/wrangler.jsonc +55 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { PaymentsInvalidReceiptError, PaymentsVerificationFailedError } from "../../error/errors";
|
|
6
|
+
import { type GoogleHttpFetch, googleHttpFetch, googleJson } from "./http";
|
|
7
|
+
import { base64UrlDecode, decodeJwtJson, splitJwt } from "./jwt";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The authenticity boundary on Google's webhook: the OIDC token a Pub/Sub push carries.
|
|
11
|
+
*
|
|
12
|
+
* Play's Real-time Developer Notifications do not arrive from Google Play. They arrive from **Pub/Sub**, which
|
|
13
|
+
* POSTs to a URL an operator configured, carrying a Google-signed OIDC token in the `Authorization` header.
|
|
14
|
+
* So there is no signature over the body the way Apple signs a notification — the token is the proof, and what
|
|
15
|
+
* it proves is narrow.
|
|
16
|
+
*
|
|
17
|
+
* **The audience check is the actual boundary.** Google signs these tokens with the same handful of keys for
|
|
18
|
+
* every push subscription on the planet. A valid signature therefore says only *Google minted this*, which
|
|
19
|
+
* anyone can arrange for themselves in thirty seconds. What makes a token ours is the `aud` claim: Pub/Sub sets
|
|
20
|
+
* it to the audience configured on the push subscription, and that value is the endpoint the token was minted
|
|
21
|
+
* for. Verify the signature and skip the audience and you have built an endpoint that accepts notifications
|
|
22
|
+
* from any Google customer who points a subscription at it.
|
|
23
|
+
*
|
|
24
|
+
* Four checks, in this order, and the order is deliberate:
|
|
25
|
+
*
|
|
26
|
+
* 1. **Pin the algorithm before touching a key.** `alg` arrives in a header nobody has verified, so it is
|
|
27
|
+
* parsed against a literal `RS256` rather than looked up. That is what makes the two published confusions
|
|
28
|
+
* unreachable — `none`, which asks for the signature to be skipped, and `HS256`, which asks the verifier to
|
|
29
|
+
* HMAC with the public key as if it were a shared secret. Neither survives a literal.
|
|
30
|
+
* 2. **Resolve the key by `kid` from Google's published set.** Never from the token, which is why there is no
|
|
31
|
+
* `jwk`/`x5u` handling here: a token that carries its own key is a token that verifies itself.
|
|
32
|
+
* 3. **Verify the signature over the exact received segments** — `header.claims` as encoded.
|
|
33
|
+
* 4. **Then read the claims,** and only then, because a claim from an unverified token means nothing.
|
|
34
|
+
*
|
|
35
|
+
* A failure to *read* the token is `payments/invalid_receipt`; a token that is well-formed and does not check
|
|
36
|
+
* out is `payments/verification_failed`. On the webhook path the guard maps either to
|
|
37
|
+
* `payments/webhook_unverified` (401), so the distinction serves the operator reading `detail` rather than the
|
|
38
|
+
* caller — and a forger is told nothing about how close it got.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/** Where Google publishes the keys that sign an OIDC token. Public, so it is pinned here rather than stored. */
|
|
42
|
+
export const GOOGLE_JWKS_URL = "https://www.googleapis.com/oauth2/v3/certs";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The two spellings Google issues an OIDC token under. Both are current, and which one arrives is not ours to
|
|
46
|
+
* decide, so both are accepted and nothing else is.
|
|
47
|
+
*/
|
|
48
|
+
export const GOOGLE_OIDC_ISSUERS: readonly string[] = ["https://accounts.google.com", "accounts.google.com"];
|
|
49
|
+
|
|
50
|
+
/** How long a fetched key set is reused. Google rotates slowly, and an unknown `kid` forces a refresh anyway. */
|
|
51
|
+
const JWKS_TTL_MS = 60 * 60 * 1000;
|
|
52
|
+
|
|
53
|
+
/** Tolerance on `exp` and `iat`. A minute covers clock drift; an hour would cover a replay. */
|
|
54
|
+
const DEFAULT_CLOCK_SKEW_SECONDS = 60;
|
|
55
|
+
|
|
56
|
+
/** RSASSA-PKCS1-v1_5 with SHA-256 — the one algorithm `alg: RS256` names, fixed here rather than derived. */
|
|
57
|
+
const RS256 = { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" } as const;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The header of a Pub/Sub push token, as tightly as it can be stated. `alg` is a literal for the reason in the
|
|
61
|
+
* module doc; `kid` is required because a key is resolved by it and a token that names no key cannot be
|
|
62
|
+
* checked against one.
|
|
63
|
+
*/
|
|
64
|
+
const GoogleOidcHeader = z
|
|
65
|
+
.object({
|
|
66
|
+
alg: z
|
|
67
|
+
.literal("RS256")
|
|
68
|
+
.describe(
|
|
69
|
+
"The signing algorithm. A literal, not a lookup: `alg` arrives in an unverified header, and accepting `none` or `HS256` from it is how JWS verifiers are bypassed.",
|
|
70
|
+
),
|
|
71
|
+
kid: z
|
|
72
|
+
.string()
|
|
73
|
+
.min(1)
|
|
74
|
+
.describe("Which of Google's published keys signed the token. Resolved against Google's key endpoint."),
|
|
75
|
+
})
|
|
76
|
+
.loose()
|
|
77
|
+
.describe("The header of a Google-signed OIDC token, constrained to the one shape Pub/Sub sends.");
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* One published RSA verification key, in the shape Google's key endpoint returns. `.loose()` because Google
|
|
81
|
+
* adds fields and a key set must not be rejected for carrying something new.
|
|
82
|
+
*/
|
|
83
|
+
export const GoogleJwk = z
|
|
84
|
+
.object({
|
|
85
|
+
kid: z.string().min(1).describe("The key id a token header names."),
|
|
86
|
+
kty: z.string().min(1).describe("The key type. Only `RSA` is accepted — Google signs these with RSA keys."),
|
|
87
|
+
n: z.string().min(1).describe("The RSA modulus, base64url."),
|
|
88
|
+
e: z.string().min(1).describe("The RSA public exponent, base64url."),
|
|
89
|
+
alg: z.string().min(1).optional().describe("The algorithm the key is published for, when stated."),
|
|
90
|
+
use: z.string().min(1).optional().describe("What the key is published for — `sig` for a signing key."),
|
|
91
|
+
})
|
|
92
|
+
.loose()
|
|
93
|
+
.describe("One of Google's published OIDC verification keys.");
|
|
94
|
+
export type GoogleJwk = z.infer<typeof GoogleJwk>;
|
|
95
|
+
|
|
96
|
+
/** Google's published key set. At least one key: an empty set is an endpoint that is not answering properly. */
|
|
97
|
+
export const GoogleJwks = z
|
|
98
|
+
.object({
|
|
99
|
+
keys: z.array(GoogleJwk).min(1).describe("Every key currently signing Google OIDC tokens."),
|
|
100
|
+
})
|
|
101
|
+
.loose()
|
|
102
|
+
.describe("The response of Google's OIDC key endpoint.");
|
|
103
|
+
export type GoogleJwks = z.infer<typeof GoogleJwks>;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The claims a Pub/Sub push token carries, narrowed to the ones that decide anything. `.loose()` keeps the
|
|
107
|
+
* rest — the whole token is never stored, but a claim set must not be refused for being richer than expected.
|
|
108
|
+
*/
|
|
109
|
+
export const GoogleOidcClaims = z
|
|
110
|
+
.object({
|
|
111
|
+
iss: z.string().min(1).describe("Who issued the token. One of Google's two issuer spellings, and nothing else."),
|
|
112
|
+
aud: z
|
|
113
|
+
.string()
|
|
114
|
+
.min(1)
|
|
115
|
+
.describe(
|
|
116
|
+
"Who the token was minted for — the audience configured on the push subscription. The claim that makes a token ours rather than merely Google's.",
|
|
117
|
+
),
|
|
118
|
+
exp: z.number().int().describe("When the token expires, in seconds since the epoch."),
|
|
119
|
+
iat: z.number().int().optional().describe("When the token was issued, in seconds since the epoch."),
|
|
120
|
+
email: z
|
|
121
|
+
.string()
|
|
122
|
+
.min(1)
|
|
123
|
+
.optional()
|
|
124
|
+
.describe("The service account Pub/Sub used to mint the token. Checked against the configured one."),
|
|
125
|
+
email_verified: z
|
|
126
|
+
.boolean()
|
|
127
|
+
.optional()
|
|
128
|
+
.describe("Whether Google vouches for that address. Always true on a real push token."),
|
|
129
|
+
sub: z.string().min(1).optional().describe("The service account's numeric id."),
|
|
130
|
+
})
|
|
131
|
+
.loose()
|
|
132
|
+
.describe("The claims on a Pub/Sub push OIDC token, narrowed to the ones verification depends on.");
|
|
133
|
+
export type GoogleOidcClaims = z.infer<typeof GoogleOidcClaims>;
|
|
134
|
+
|
|
135
|
+
/** What verifying a push token needs. Everything is explicit, so verification is deterministic in a test. */
|
|
136
|
+
export interface VerifyGoogleOidcOptions {
|
|
137
|
+
/** The audience the token must claim — the push subscription's configured audience. The real boundary. */
|
|
138
|
+
audience: string;
|
|
139
|
+
/** The service account the token must have been minted by. Google's own recommended second check. */
|
|
140
|
+
serviceAccountEmail: string;
|
|
141
|
+
/** The clock. Injected so an expiry refusal is deterministic. */
|
|
142
|
+
now: Date;
|
|
143
|
+
/** The HTTP seam Google's key endpoint is reached through. Defaults to the runtime's `fetch`. */
|
|
144
|
+
transport?: GoogleHttpFetch;
|
|
145
|
+
/**
|
|
146
|
+
* Keys accepted **in addition to** Google's published set, matched by `kid`.
|
|
147
|
+
*
|
|
148
|
+
* Additive, so nothing can narrow production's trust: a token whose `kid` these do not cover still resolves
|
|
149
|
+
* against Google. Two callers have a real reason — the tests, which mint their own key so the signature check
|
|
150
|
+
* is exercised for real, and a local Pub/Sub emulator, whose tokens are signed by a key Google never saw.
|
|
151
|
+
*/
|
|
152
|
+
trustedKeys?: readonly GoogleJwk[];
|
|
153
|
+
/** Tolerance on `exp` and `iat`, in seconds. Defaults to a minute. */
|
|
154
|
+
clockSkewSeconds?: number;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Google's published keys, cached per isolate.
|
|
159
|
+
*
|
|
160
|
+
* A cache of *public* keys is not the thing CLAUDE.md's secrets rule forbids — nothing here is confidential,
|
|
161
|
+
* and the whole point of the endpoint is that anyone may read it. What the cache buys is one round-trip per
|
|
162
|
+
* hour instead of one per notification, on a path that already has a mandatory Play API call in it.
|
|
163
|
+
*/
|
|
164
|
+
let publishedKeys: { keys: GoogleJwk[]; expiresAt: number } | null = null;
|
|
165
|
+
|
|
166
|
+
/** Drop the cached key set. For tests, which must not inherit another suite's keys. */
|
|
167
|
+
export function resetGoogleJwksCache(): void {
|
|
168
|
+
publishedKeys = null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Fetch and validate Google's key set, replacing the cache. */
|
|
172
|
+
async function refreshPublishedKeys(transport: GoogleHttpFetch, now: Date): Promise<GoogleJwk[]> {
|
|
173
|
+
const body = await googleJson(transport, GOOGLE_JWKS_URL, { what: "Google's OIDC verification keys" });
|
|
174
|
+
const parsed = GoogleJwks.safeParse(body);
|
|
175
|
+
if (!parsed.success) {
|
|
176
|
+
// Fail closed. A verifier that cannot read a key set must deny, not wave the token through.
|
|
177
|
+
throw new PaymentsVerificationFailedError({
|
|
178
|
+
detail: `Google: the OIDC key endpoint answered an unexpected shape — ${issues(parsed.error)}.`,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
publishedKeys = { keys: parsed.data.keys, expiresAt: now.getTime() + JWKS_TTL_MS };
|
|
182
|
+
return parsed.data.keys;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The key a token's `kid` names.
|
|
187
|
+
*
|
|
188
|
+
* A `kid` the cache does not hold triggers **exactly one** refresh, which is what makes Google's key rotation
|
|
189
|
+
* invisible: a new signing key appears in a token before anything tells us to look for it. Exactly one,
|
|
190
|
+
* because otherwise a stream of forged tokens naming random `kid`s is a way to make us hammer Google.
|
|
191
|
+
*/
|
|
192
|
+
async function resolveKey(kid: string, options: VerifyGoogleOidcOptions): Promise<GoogleJwk> {
|
|
193
|
+
const configured = options.trustedKeys?.find((key) => key.kid === kid);
|
|
194
|
+
if (configured) return configured;
|
|
195
|
+
|
|
196
|
+
const transport = options.transport ?? googleHttpFetch;
|
|
197
|
+
const fresh = publishedKeys !== null && publishedKeys.expiresAt > options.now.getTime();
|
|
198
|
+
if (fresh) {
|
|
199
|
+
const cached = publishedKeys?.keys.find((key) => key.kid === kid);
|
|
200
|
+
if (cached) return cached;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const refreshed = await refreshPublishedKeys(transport, options.now);
|
|
204
|
+
const found = refreshed.find((key) => key.kid === kid);
|
|
205
|
+
if (!found) {
|
|
206
|
+
throw new PaymentsVerificationFailedError({
|
|
207
|
+
detail: `Google: no published key matches the token's kid "${kid}".`,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
return found;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Import one published JWK as a verification key. A non-RSA key is refused rather than handed to WebCrypto. */
|
|
214
|
+
async function importKey(jwk: GoogleJwk): Promise<CryptoKey> {
|
|
215
|
+
if (jwk.kty !== "RSA") {
|
|
216
|
+
throw new PaymentsVerificationFailedError({
|
|
217
|
+
detail: `Google: published key "${jwk.kid}" is ${jwk.kty}, and only RSA keys sign an OIDC token.`,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
return await crypto.subtle.importKey("jwk", { ...jwk, alg: "RS256" }, RS256, false, ["verify"]);
|
|
222
|
+
} catch (cause) {
|
|
223
|
+
throw new PaymentsVerificationFailedError(
|
|
224
|
+
{ detail: `Google: published key "${jwk.kid}" could not be imported.` },
|
|
225
|
+
{ cause },
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Verify one Pub/Sub push OIDC token and return its claims.
|
|
232
|
+
*
|
|
233
|
+
* @throws {@link PaymentsInvalidReceiptError} when the token cannot be read — wrong segment count, a header
|
|
234
|
+
* Google would never send, a non-JSON segment.
|
|
235
|
+
* @throws {@link PaymentsVerificationFailedError} when it is well-formed and does not check out — an unknown
|
|
236
|
+
* key, a bad signature, the wrong audience, the wrong issuer, an expiry, the wrong service account.
|
|
237
|
+
*/
|
|
238
|
+
export async function verifyGoogleOidcToken(
|
|
239
|
+
token: string,
|
|
240
|
+
options: VerifyGoogleOidcOptions,
|
|
241
|
+
): Promise<GoogleOidcClaims> {
|
|
242
|
+
const { head, body, mac } = splitJwt(token);
|
|
243
|
+
|
|
244
|
+
const header = GoogleOidcHeader.safeParse(decodeJwtJson(head, "the token header"));
|
|
245
|
+
if (!header.success) {
|
|
246
|
+
throw new PaymentsInvalidReceiptError({
|
|
247
|
+
detail: `Google: the token header is not one Pub/Sub sends — ${issues(header.error)}.`,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const key = await importKey(await resolveKey(header.data.kid, options));
|
|
252
|
+
const verified = await crypto.subtle.verify(
|
|
253
|
+
RS256.name,
|
|
254
|
+
key,
|
|
255
|
+
base64UrlDecode(mac, "the token signature") as unknown as ArrayBuffer,
|
|
256
|
+
new TextEncoder().encode(`${head}.${body}`) as unknown as ArrayBuffer,
|
|
257
|
+
);
|
|
258
|
+
if (!verified) {
|
|
259
|
+
throw new PaymentsVerificationFailedError({
|
|
260
|
+
detail: `Google: the token signature does not match the signed segments under key "${header.data.kid}".`,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Only now. A claim read from a token whose signature has not been checked is a claim an attacker wrote.
|
|
265
|
+
const parsed = GoogleOidcClaims.safeParse(decodeJwtJson(body, "the token claims"));
|
|
266
|
+
if (!parsed.success) {
|
|
267
|
+
throw new PaymentsVerificationFailedError({
|
|
268
|
+
detail: `Google: the token claims are not those of a Pub/Sub push token — ${issues(parsed.error)}.`,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
const claims = parsed.data;
|
|
272
|
+
const skew = options.clockSkewSeconds ?? DEFAULT_CLOCK_SKEW_SECONDS;
|
|
273
|
+
const seconds = Math.floor(options.now.getTime() / 1000);
|
|
274
|
+
|
|
275
|
+
if (!GOOGLE_OIDC_ISSUERS.includes(claims.iss)) {
|
|
276
|
+
throw failed(`the token issuer is "${claims.iss}", not one of Google's.`);
|
|
277
|
+
}
|
|
278
|
+
// The boundary. Everything above proves Google minted the token; this is what proves it was minted for us.
|
|
279
|
+
if (claims.aud !== options.audience) {
|
|
280
|
+
throw failed(`the token audience is "${claims.aud}", and this endpoint expects "${options.audience}".`);
|
|
281
|
+
}
|
|
282
|
+
if (claims.exp + skew < seconds) {
|
|
283
|
+
throw failed(`the token expired at ${new Date(claims.exp * 1000).toISOString()}.`);
|
|
284
|
+
}
|
|
285
|
+
if (claims.iat !== undefined && claims.iat - skew > seconds) {
|
|
286
|
+
throw failed(`the token was issued at ${new Date(claims.iat * 1000).toISOString()}, which is in the future.`);
|
|
287
|
+
}
|
|
288
|
+
// Google's own guidance, and a second, independent statement: the audience says which endpoint the token was
|
|
289
|
+
// minted for, the email says which identity minted it. Both are always present on a real push token.
|
|
290
|
+
if (claims.email !== options.serviceAccountEmail) {
|
|
291
|
+
throw failed(`the token names service account "${claims.email ?? "none"}", not the configured one.`);
|
|
292
|
+
}
|
|
293
|
+
if (claims.email_verified !== true) {
|
|
294
|
+
throw failed("the token's service-account address is not marked verified by Google.");
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return claims;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** A post-signature refusal. `Google:` prefixed, and the token itself never appears. */
|
|
301
|
+
function failed(detail: string): PaymentsVerificationFailedError {
|
|
302
|
+
return new PaymentsVerificationFailedError({ detail: `Google: ${detail}` });
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Zod issues as `path:code` pairs — never `message` or `received`, which would echo the token. */
|
|
306
|
+
function issues(error: z.ZodError): string {
|
|
307
|
+
return error.issues.map((issue) => `${issue.path.join(".") || "<root>"}:${issue.code}`).join(", ");
|
|
308
|
+
}
|