418 lines
14 KiB
TypeScript
418 lines
14 KiB
TypeScript
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<Record<string, GooglePlayTier>> = 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<string, unknown>
|
|
testPurchase?: Record<string, unknown>
|
|
}
|
|
|
|
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<Response> {
|
|
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<GoogleServiceAccount>
|
|
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<string> {
|
|
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<string> {
|
|
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<string> {
|
|
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<string> {
|
|
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<void> {
|
|
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<boolean>,
|
|
): Promise<NormalizedGooglePlayPurchase> {
|
|
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<GooglePlaySubscriptionV2> {
|
|
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<boolean>,
|
|
): Promise<NormalizedGooglePlayPurchase> {
|
|
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,
|
|
)
|
|
}
|