export type GooglePlayTier = 'pro' | 'pro_plus' export type StorePurchaseState = | 'pending' | 'purchased' | 'cancelled' | 'expired' | 'refunded' | 'on_hold' | 'paused' export const GOOGLE_PLAY_PACKAGE_NAME = 'com.d3ro.voice' export const GOOGLE_PLAY_PRODUCT_TIERS: Readonly> = Object.freeze({ d3ro_voice_pro_monthly: 'pro', d3ro_voice_pro_plus_monthly: 'pro_plus', }) interface GoogleServiceAccount { client_email: string private_key: string token_uri?: string } interface GooglePlayLineItem { productId?: string expiryTime?: string latestSuccessfulOrderId?: string autoRenewingPlan?: { autoRenewEnabled?: boolean } } export interface GooglePlaySubscriptionV2 { kind?: string startTime?: string subscriptionState?: string acknowledgementState?: string linkedPurchaseToken?: string externalAccountIdentifiers?: { obfuscatedExternalAccountId?: string } outOfAppPurchaseContext?: { expiredExternalAccountIdentifiers?: { obfuscatedExternalAccountId?: string } expiredPurchaseToken?: string } lineItems?: GooglePlayLineItem[] canceledStateContext?: Record testPurchase?: Record } export interface NormalizedGooglePlayPurchase { platform: 'google_play' productId: string tier: GooglePlayTier storeTransactionId: string | null purchaseState: StorePurchaseState purchaseAt: string | null expiresAt: string | null autoRenewing: boolean acknowledged: boolean entitled: boolean verification: GooglePlaySubscriptionV2 linkedPurchaseToken: string | null } export class GooglePlayVerificationError extends Error { constructor( public readonly code: string, public readonly status: number, ) { super(code) this.name = 'GooglePlayVerificationError' } } const encoder = new TextEncoder() let cachedAccessToken: { token: string; expiresAtMs: number } | null = null const GOOGLE_REQUEST_TIMEOUT_MS = 15_000 async function googleFetch( fetchImpl: typeof fetch, input: string, init: RequestInit = {}, ): Promise { try { return await fetchImpl(input, { ...init, signal: AbortSignal.timeout(GOOGLE_REQUEST_TIMEOUT_MS), }) } catch { throw new GooglePlayVerificationError('google_play_timeout', 504) } } function encodeBase64Url(value: Uint8Array | string): string { const bytes = typeof value === 'string' ? encoder.encode(value) : value let binary = '' for (const byte of bytes) binary += String.fromCharCode(byte) return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '') } function decodePem(pem: string): Uint8Array { const body = pem .replace(/-----BEGIN PRIVATE KEY-----/g, '') .replace(/-----END PRIVATE KEY-----/g, '') .replace(/\s/g, '') if (!body) throw new GooglePlayVerificationError('google_play_credentials_invalid', 503) try { const binary = atob(body) return Uint8Array.from(binary, (character) => character.charCodeAt(0)) } catch { throw new GooglePlayVerificationError('google_play_credentials_invalid', 503) } } function getServiceAccount(): GoogleServiceAccount { const json = Deno.env.get('GOOGLE_PLAY_SERVICE_ACCOUNT_JSON') ?? '' if (!json) throw new GooglePlayVerificationError('google_play_not_configured', 503) try { const parsed = JSON.parse(json) as Partial if (!parsed.client_email || !parsed.private_key) { throw new Error('missing service-account fields') } return { client_email: parsed.client_email, private_key: parsed.private_key, token_uri: parsed.token_uri, } } catch (error) { if (error instanceof GooglePlayVerificationError) throw error throw new GooglePlayVerificationError('google_play_credentials_invalid', 503) } } async function createServiceAccountAssertion( account: GoogleServiceAccount, nowSeconds: number, ): Promise { const tokenUri = account.token_uri ?? 'https://oauth2.googleapis.com/token' const header = encodeBase64Url(JSON.stringify({ alg: 'RS256', typ: 'JWT' })) const claims = encodeBase64Url(JSON.stringify({ iss: account.client_email, scope: 'https://www.googleapis.com/auth/androidpublisher', aud: tokenUri, iat: nowSeconds, exp: nowSeconds + 3600, })) const signingInput = `${header}.${claims}` let key: CryptoKey try { const decodedKey = decodePem(account.private_key) const keyBuffer = new ArrayBuffer(decodedKey.byteLength) new Uint8Array(keyBuffer).set(decodedKey) key = await crypto.subtle.importKey( 'pkcs8', keyBuffer, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['sign'], ) } catch { throw new GooglePlayVerificationError('google_play_credentials_invalid', 503) } const signature = await crypto.subtle.sign( 'RSASSA-PKCS1-v1_5', key, encoder.encode(signingInput), ) return `${signingInput}.${encodeBase64Url(new Uint8Array(signature))}` } async function getGoogleAccessToken(fetchImpl: typeof fetch): Promise { const nowMs = Date.now() if (cachedAccessToken && cachedAccessToken.expiresAtMs > nowMs + 60_000) { return cachedAccessToken.token } const account = getServiceAccount() const tokenUri = account.token_uri ?? 'https://oauth2.googleapis.com/token' const assertion = await createServiceAccountAssertion(account, Math.floor(nowMs / 1000)) const response = await googleFetch(fetchImpl, tokenUri, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion, }), }) if (!response.ok) { throw new GooglePlayVerificationError('google_play_auth_failed', 502) } const body = await response.json() as { access_token?: unknown; expires_in?: unknown } if (typeof body.access_token !== 'string' || !body.access_token) { throw new GooglePlayVerificationError('google_play_auth_failed', 502) } const expiresIn = typeof body.expires_in === 'number' ? body.expires_in : 3600 cachedAccessToken = { token: body.access_token, expiresAtMs: nowMs + Math.max(60, expiresIn) * 1000, } return body.access_token } export async function sha256Hex(value: string): Promise { const digest = await crypto.subtle.digest('SHA-256', encoder.encode(value)) return Array.from(new Uint8Array(digest)) .map((byte) => byte.toString(16).padStart(2, '0')) .join('') } export async function googlePlayAccountId(userId: string): Promise { return await sha256Hex(`d3ro-google-play:${userId}`) } function parseTimestamp(value: string | undefined): number | null { if (!value) return null const timestamp = Date.parse(value) return Number.isFinite(timestamp) ? timestamp : null } export function normalizeGooglePlaySubscription( verification: GooglePlaySubscriptionV2, expectedProductId: string, nowMs = Date.now(), ): NormalizedGooglePlayPurchase { const tier = GOOGLE_PLAY_PRODUCT_TIERS[expectedProductId] if (!tier) throw new GooglePlayVerificationError('unknown_product', 400) const lineItem = verification.lineItems?.find((item) => item.productId === expectedProductId) if (!lineItem) throw new GooglePlayVerificationError('product_mismatch', 409) const expiresAtMs = parseTimestamp(lineItem.expiryTime) const hasFutureExpiry = expiresAtMs !== null && expiresAtMs > nowMs let purchaseState: StorePurchaseState let entitled = false switch (verification.subscriptionState) { case 'SUBSCRIPTION_STATE_PENDING': purchaseState = 'pending' break case 'SUBSCRIPTION_STATE_ACTIVE': case 'SUBSCRIPTION_STATE_IN_GRACE_PERIOD': purchaseState = 'purchased' entitled = hasFutureExpiry break case 'SUBSCRIPTION_STATE_CANCELED': purchaseState = 'cancelled' entitled = hasFutureExpiry break case 'SUBSCRIPTION_STATE_PAUSED': purchaseState = 'paused' break case 'SUBSCRIPTION_STATE_ON_HOLD': purchaseState = 'on_hold' break case 'SUBSCRIPTION_STATE_EXPIRED': purchaseState = 'expired' break case 'SUBSCRIPTION_STATE_PENDING_PURCHASE_CANCELED': purchaseState = 'cancelled' break default: throw new GooglePlayVerificationError('unknown_subscription_state', 502) } return { platform: 'google_play', productId: expectedProductId, tier, storeTransactionId: typeof lineItem.latestSuccessfulOrderId === 'string' && lineItem.latestSuccessfulOrderId.length > 0 ? lineItem.latestSuccessfulOrderId : null, purchaseState, purchaseAt: parseTimestamp(verification.startTime) === null ? null : verification.startTime ?? null, expiresAt: expiresAtMs === null ? null : lineItem.expiryTime ?? null, autoRenewing: lineItem.autoRenewingPlan?.autoRenewEnabled === true, acknowledged: verification.acknowledgementState === 'ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED', entitled, verification, linkedPurchaseToken: typeof verification.linkedPurchaseToken === 'string' && verification.linkedPurchaseToken.length >= 8 ? verification.linkedPurchaseToken : null, } } export async function acknowledgeGooglePlaySubscription( productId: string, purchaseToken: string, fetchImpl: typeof fetch = fetch, ): Promise { if (!GOOGLE_PLAY_PRODUCT_TIERS[productId]) { throw new GooglePlayVerificationError('unknown_product', 400) } if (purchaseToken.length < 8 || purchaseToken.length > 4096) { throw new GooglePlayVerificationError('invalid_purchase_token', 400) } const configuredPackage = Deno.env.get('GOOGLE_PLAY_PACKAGE_NAME') ?? GOOGLE_PLAY_PACKAGE_NAME if (configuredPackage !== GOOGLE_PLAY_PACKAGE_NAME) { throw new GooglePlayVerificationError('google_play_package_mismatch', 503) } const accessToken = await getGoogleAccessToken(fetchImpl) const endpoint = 'https://androidpublisher.googleapis.com/androidpublisher/v3/applications/' + `${encodeURIComponent(configuredPackage)}/purchases/subscriptions/` + `${encodeURIComponent(productId)}/tokens/${encodeURIComponent(purchaseToken)}:acknowledge` const response = await googleFetch(fetchImpl, endpoint, { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, body: '{}', }) if (!response.ok) { throw new GooglePlayVerificationError('google_play_acknowledgement_failed', 502) } } export async function verifyGooglePlaySubscriptionPayload( userId: string, productId: string, verification: GooglePlaySubscriptionV2, ownsExpiredPurchaseToken?: (purchaseToken: string) => Promise, ): Promise { const expectedAccountId = await googlePlayAccountId(userId) const currentAccountId = verification.externalAccountIdentifiers?.obfuscatedExternalAccountId if (typeof currentAccountId === 'string' && currentAccountId.length > 0) { if (currentAccountId !== expectedAccountId) { throw new GooglePlayVerificationError('purchase_account_mismatch', 409) } return normalizeGooglePlaySubscription(verification, productId) } const outOfAppContext = verification.outOfAppPurchaseContext const expiredAccountId = outOfAppContext?.expiredExternalAccountIdentifiers ?.obfuscatedExternalAccountId if (typeof expiredAccountId === 'string' && expiredAccountId.length > 0) { if (expiredAccountId !== expectedAccountId) { throw new GooglePlayVerificationError('purchase_account_mismatch', 409) } return normalizeGooglePlaySubscription(verification, productId) } const expiredToken = outOfAppContext?.expiredPurchaseToken if ( typeof expiredToken === 'string' && expiredToken.length >= 8 && expiredToken.length <= 4096 && ownsExpiredPurchaseToken && await ownsExpiredPurchaseToken(expiredToken) ) { return normalizeGooglePlaySubscription(verification, productId) } throw new GooglePlayVerificationError('purchase_account_mismatch', 409) } export async function fetchGooglePlaySubscription( purchaseToken: string, fetchImpl: typeof fetch = fetch, ): Promise { if (purchaseToken.length < 8 || purchaseToken.length > 4096) { throw new GooglePlayVerificationError('invalid_purchase_token', 400) } const configuredPackage = Deno.env.get('GOOGLE_PLAY_PACKAGE_NAME') ?? GOOGLE_PLAY_PACKAGE_NAME if (configuredPackage !== GOOGLE_PLAY_PACKAGE_NAME) { throw new GooglePlayVerificationError('google_play_package_mismatch', 503) } const accessToken = await getGoogleAccessToken(fetchImpl) const endpoint = 'https://androidpublisher.googleapis.com/androidpublisher/v3/applications/' + `${encodeURIComponent(configuredPackage)}/purchases/subscriptionsv2/tokens/` + encodeURIComponent(purchaseToken) const response = await googleFetch(fetchImpl, endpoint, { headers: { Authorization: `Bearer ${accessToken}` }, }) if (response.status === 404) { throw new GooglePlayVerificationError('purchase_not_found', 409) } if (!response.ok) { throw new GooglePlayVerificationError('google_play_verification_failed', 502) } return await response.json() as GooglePlaySubscriptionV2 } export async function verifyGooglePlaySubscription( userId: string, productId: string, purchaseToken: string, fetchImpl: typeof fetch = fetch, ownsExpiredPurchaseToken?: (purchaseToken: string) => Promise, ): Promise { if (!GOOGLE_PLAY_PRODUCT_TIERS[productId]) { throw new GooglePlayVerificationError('unknown_product', 400) } const verification = await fetchGooglePlaySubscription(purchaseToken, fetchImpl) return await verifyGooglePlaySubscriptionPayload( userId, productId, verification, ownsExpiredPurchaseToken, ) }