d3ro-voice/server/supabase/functions/_shared/google-play.ts
2026-08-29 18:33:45 +09:00

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,
)
}