MotorSignal-App/cloudflare/motorsignal-r2-worker/src/play-verification.ts

647 lines
19 KiB
TypeScript

/**
* MotorSignal — Play Purchase Verification & Server Acknowledgement Module
* Communicates with Google Play Developer API v3 (Android Publisher API)
* to authoritatively verify and acknowledge subscriptions and one-time purchases.
*/
export interface PlayVerificationEnv {
GOOGLE_SERVICE_ACCOUNT_EMAIL?: string;
GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY?: string;
GOOGLE_PLAY_PACKAGE_NAME?: string;
}
export const AUTHORITATIVE_PACKAGE_NAME = 'app.motorsignal.motorcycle';
export const ALLOWED_PRODUCTS = {
PRO_MONTHLY: 'motorsignal_pro_monthly',
PRO_LIFETIME: 'motorsignal_pro_lifetime',
} as const;
export interface VerifyPurchaseRequest {
productId: string;
purchaseToken: string;
}
export type AcknowledgementState = 'acknowledged' | 'pendingRetry' | 'notApplicable';
export interface VerificationResponse {
schemaVersion: number;
productId: string;
plan: 'proMonthly' | 'proLifetime' | 'free';
status:
| 'proMonthlyActive'
| 'proMonthlyGrace'
| 'proMonthlyAccountHold'
| 'proMonthlyPaused'
| 'proMonthlyExpired'
| 'proLifetime'
| 'unknown'
| 'syncPending';
verificationStatus: 'verified' | 'unverified' | 'expired' | 'revoked';
acknowledgementState: AcknowledgementState;
verifiedAt: string;
expiresAt: string | null;
tokenHash: string;
error?: string;
}
// In-memory token cache for short-lived Google OAuth access token
let cachedAccessToken: { token: string; expiresAtMs: number } | null = null;
/**
* Derives SHA-256 fingerprint of purchase token for replay tracking / cache key.
*/
export async function computeTokenHash(token: string): Promise<string> {
const enc = new TextEncoder().encode(token);
const digest = await crypto.subtle.digest('SHA-256', enc);
return Array.from(new Uint8Array(digest))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
/**
* Generates an OAuth2 access token for Google APIs via a service account private key.
*/
export async function getGoogleAccessToken(
clientEmail: string,
privateKeyPem: string
): Promise<string> {
const nowMs = Date.now();
if (cachedAccessToken && cachedAccessToken.expiresAtMs > nowMs + 60000) {
return cachedAccessToken.token;
}
const nowSec = Math.floor(nowMs / 1000);
const expSec = nowSec + 3600;
const header = {
alg: 'RS256',
typ: 'JWT',
};
const claimSet = {
iss: clientEmail,
scope: 'https://www.googleapis.com/auth/androidpublisher',
aud: 'https://oauth2.googleapis.com/token',
exp: expSec,
iat: nowSec,
};
const encodedHeader = base64UrlEncode(JSON.stringify(header));
const encodedClaimSet = base64UrlEncode(JSON.stringify(claimSet));
const signingInput = `${encodedHeader}.${encodedClaimSet}`;
const binaryKey = pemToBinary(privateKeyPem);
const cryptoKey = await crypto.subtle.importKey(
'pkcs8',
binaryKey,
{
name: 'RSASSA-PKCS1-v1_5',
hash: { name: 'SHA-256' },
},
false,
['sign']
);
const signature = await crypto.subtle.sign(
'RSASSA-PKCS1-v1_5',
cryptoKey,
new TextEncoder().encode(signingInput)
);
const jwt = `${signingInput}.${base64UrlEncode(signature)}`;
const tokenResp = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: jwt,
}),
});
if (!tokenResp.ok) {
const errText = await tokenResp.text().catch(() => '');
throw new Error(`Google OAuth exchange failed (${tokenResp.status}): ${errText}`);
}
const tokenData = (await tokenResp.json()) as {
access_token: string;
expires_in: number;
};
cachedAccessToken = {
token: tokenData.access_token,
expiresAtMs: nowMs + (tokenData.expires_in || 3600) * 1000,
};
return cachedAccessToken.token;
}
/**
* Authoritatively verifies and acknowledges a Google Play purchase token.
*/
export async function verifyPlayPurchase(
req: VerifyPurchaseRequest,
env: PlayVerificationEnv
): Promise<{ status: number; body: VerificationResponse | { error: string; code: string } }> {
const { productId, purchaseToken } = req;
if (!productId || typeof productId !== 'string') {
return { status: 400, body: { error: 'Invalid or missing productId', code: 'INVALID_PRODUCT' } };
}
if (!purchaseToken || typeof purchaseToken !== 'string' || purchaseToken.length < 10) {
return { status: 400, body: { error: 'Invalid or missing purchaseToken', code: 'INVALID_PURCHASE' } };
}
if (
productId !== ALLOWED_PRODUCTS.PRO_MONTHLY &&
productId !== ALLOWED_PRODUCTS.PRO_LIFETIME
) {
return {
status: 400,
body: { error: `Product '${productId}' is not recognized`, code: 'INVALID_PRODUCT' },
};
}
const tokenHash = await computeTokenHash(purchaseToken);
const packageName = env.GOOGLE_PLAY_PACKAGE_NAME || AUTHORITATIVE_PACKAGE_NAME;
const clientEmail = env.GOOGLE_SERVICE_ACCOUNT_EMAIL;
const privateKey = env.GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY;
if (!clientEmail || !privateKey) {
return {
status: 503,
body: {
error: 'Google Play verification credentials not configured on server',
code: 'GOOGLE_TEMPORARILY_UNAVAILABLE',
},
};
}
let accessToken: string;
try {
accessToken = await getGoogleAccessToken(clientEmail, privateKey);
} catch (err: unknown) {
const tokenPrefix = tokenHash.substring(0, 8);
console.error(`[PlayVerification] [hash=${tokenPrefix}] OAuth token acquisition failed`, err);
return {
status: 502,
body: {
error: 'Failed to authenticate with Google Developer API',
code: 'GOOGLE_TEMPORARILY_UNAVAILABLE',
},
};
}
if (productId === ALLOWED_PRODUCTS.PRO_MONTHLY) {
return await verifySubscription(packageName, purchaseToken, tokenHash, accessToken);
} else {
return await verifyOneTimeProduct(packageName, productId, purchaseToken, tokenHash, accessToken);
}
}
/**
* Extracts latest valid expiration timestamp across all line items matching the target product.
*/
export function extractLatestExpiry(
lineItems: Array<{ productId?: string; expiryTime?: string }> | undefined,
targetProductId: string
): { expiryTimeIso: string | null; expiryTimeMs: number } {
if (!lineItems || lineItems.length === 0) {
return { expiryTimeIso: null, expiryTimeMs: NaN };
}
let latestMs = NaN;
let latestIso: string | null = null;
for (const item of lineItems) {
if (item.productId && item.productId !== targetProductId) {
continue;
}
if (item.expiryTime) {
const ms = Date.parse(item.expiryTime);
if (!isNaN(ms) && (isNaN(latestMs) || ms > latestMs)) {
latestMs = ms;
latestIso = new Date(ms).toISOString();
}
}
}
return { expiryTimeIso: latestIso, expiryTimeMs: latestMs };
}
/**
* Acknowledges a subscription token server-side via purchases.subscriptions.acknowledge.
*/
export async function acknowledgeSubscription(
packageName: string,
subscriptionId: string,
token: string,
accessToken: string,
tokenHash: string
): Promise<{ success: boolean; isRetryable: boolean }> {
const endpoint = `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${encodeURIComponent(
packageName
)}/purchases/subscriptions/${encodeURIComponent(subscriptionId)}/tokens/${encodeURIComponent(
token
)}:acknowledge`;
try {
const res = await fetch(endpoint, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({}),
});
if (res.ok || res.status === 204) {
return { success: true, isRetryable: false };
}
const tokenPrefix = tokenHash.substring(0, 8);
const text = await res.text().catch(() => '');
// Idempotency: If already acknowledged, Google may respond with 400 'Purchase already acknowledged' or similar
if (res.status === 400 && text.toLowerCase().includes('already acknowledged')) {
return { success: true, isRetryable: false };
}
console.error(
`[PlayVerification] [hash=${tokenPrefix}] Subscription acknowledge failed: status=${res.status} body=${text}`
);
const isRetryable = res.status === 429 || res.status >= 500;
return { success: false, isRetryable };
} catch (e) {
const tokenPrefix = tokenHash.substring(0, 8);
console.error(`[PlayVerification] [hash=${tokenPrefix}] Subscription acknowledge network exception:`, e);
return { success: false, isRetryable: true };
}
}
/**
* Acknowledges a one-time product token server-side via purchases.products.acknowledge.
*/
export async function acknowledgeOneTimeProduct(
packageName: string,
productId: string,
token: string,
accessToken: string,
tokenHash: string
): Promise<{ success: boolean; isRetryable: boolean }> {
const endpoint = `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${encodeURIComponent(
packageName
)}/purchases/products/${encodeURIComponent(productId)}/tokens/${encodeURIComponent(
token
)}:acknowledge`;
try {
const res = await fetch(endpoint, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({}),
});
if (res.ok || res.status === 204) {
return { success: true, isRetryable: false };
}
const tokenPrefix = tokenHash.substring(0, 8);
const text = await res.text().catch(() => '');
// Idempotency check for product acknowledgement
if (res.status === 400 && text.toLowerCase().includes('already acknowledged')) {
return { success: true, isRetryable: false };
}
console.error(
`[PlayVerification] [hash=${tokenPrefix}] Product acknowledge failed: status=${res.status} body=${text}`
);
const isRetryable = res.status === 429 || res.status >= 500;
return { success: false, isRetryable };
} catch (e) {
const tokenPrefix = tokenHash.substring(0, 8);
console.error(`[PlayVerification] [hash=${tokenPrefix}] Product acknowledge network exception:`, e);
return { success: false, isRetryable: true };
}
}
/**
* Verifies subscription using purchases.subscriptionsv2.get
*/
async function verifySubscription(
packageName: string,
token: string,
tokenHash: string,
accessToken: string
): Promise<{ status: number; body: VerificationResponse | { error: string; code: string } }> {
const endpoint = `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${encodeURIComponent(
packageName
)}/purchases/subscriptionsv2/tokens/${encodeURIComponent(token)}`;
const res = await fetch(endpoint, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
});
if (res.status === 404) {
return {
status: 404,
body: { error: 'Purchase token not found in Google Play', code: 'INVALID_PURCHASE' },
};
}
if (res.status === 401 || res.status === 403) {
const tokenPrefix = tokenHash.substring(0, 8);
console.error(`[PlayVerification] [hash=${tokenPrefix}] Google returned status ${res.status}`);
return {
status: 502,
body: { error: 'Google API authorization failure', code: 'GOOGLE_TEMPORARILY_UNAVAILABLE' },
};
}
if (res.status === 429) {
return {
status: 429,
body: { error: 'Google Developer API rate limited', code: 'RATE_LIMITED' },
};
}
if (!res.ok) {
return {
status: 502,
body: { error: `Google API returned error status ${res.status}`, code: 'GOOGLE_TEMPORARILY_UNAVAILABLE' },
};
}
const sub = (await res.json()) as {
subscriptionState?: string;
acknowledgementState?: string;
lineItems?: Array<{
productId?: string;
expiryTime?: string;
}>;
};
const { expiryTimeIso, expiryTimeMs } = extractLatestExpiry(
sub.lineItems,
ALLOWED_PRODUCTS.PRO_MONTHLY
);
const nowMs = Date.now();
const state = sub.subscriptionState || 'SUBSCRIPTION_STATE_UNSPECIFIED';
let plan: 'proMonthly' | 'free' = 'proMonthly';
let status: VerificationResponse['status'] = 'proMonthlyActive';
let verificationStatus: VerificationResponse['verificationStatus'] = 'verified';
switch (state) {
case 'SUBSCRIPTION_STATE_ACTIVE':
status = 'proMonthlyActive';
verificationStatus = 'verified';
break;
case 'SUBSCRIPTION_STATE_IN_GRACE_PERIOD':
status = 'proMonthlyGrace';
verificationStatus = 'verified';
break;
case 'SUBSCRIPTION_STATE_ON_HOLD':
status = 'proMonthlyAccountHold';
verificationStatus = 'verified'; // Hold is confirmed verified, but entitlement state will deny pro
break;
case 'SUBSCRIPTION_STATE_PAUSED':
status = 'proMonthlyPaused';
verificationStatus = 'verified'; // Explicit Paused state; denies Pro access
break;
case 'SUBSCRIPTION_STATE_CANCELED':
// Canceled: user keeps entitlement until paid period expires
if (!isNaN(expiryTimeMs) && expiryTimeMs > nowMs) {
status = 'proMonthlyActive';
verificationStatus = 'verified';
} else {
status = 'proMonthlyExpired';
verificationStatus = 'expired';
}
break;
case 'SUBSCRIPTION_STATE_EXPIRED':
status = 'proMonthlyExpired';
verificationStatus = 'expired';
break;
case 'SUBSCRIPTION_STATE_PENDING':
status = 'syncPending';
verificationStatus = 'unverified';
break;
default:
status = 'unknown';
verificationStatus = 'unverified';
break;
}
// Server-Side Acknowledgement
// Only acknowledge if purchase is in active/entitled state and not already acknowledged.
let ackState: AcknowledgementState = 'notApplicable';
const isAlreadyAcknowledged =
sub.acknowledgementState === 'ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED';
const shouldAcknowledge =
(status === 'proMonthlyActive' ||
status === 'proMonthlyGrace' ||
status === 'proMonthlyAccountHold') &&
verificationStatus === 'verified';
if (shouldAcknowledge) {
if (isAlreadyAcknowledged) {
ackState = 'acknowledged';
} else {
const ackResult = await acknowledgeSubscription(
packageName,
ALLOWED_PRODUCTS.PRO_MONTHLY,
token,
accessToken,
tokenHash
);
if (ackResult.success) {
ackState = 'acknowledged';
} else {
// Verification succeeded, but acknowledgement requires retry
ackState = 'pendingRetry';
}
}
}
return {
status: 200,
body: {
schemaVersion: 1,
productId: ALLOWED_PRODUCTS.PRO_MONTHLY,
plan,
status,
verificationStatus,
acknowledgementState: ackState,
verifiedAt: new Date().toISOString(),
expiresAt: expiryTimeIso,
tokenHash,
},
};
}
/**
* Verifies one-time lifetime purchase using purchases.products.get
*/
async function verifyOneTimeProduct(
packageName: string,
productId: string,
token: string,
tokenHash: string,
accessToken: string
): Promise<{ status: number; body: VerificationResponse | { error: string; code: string } }> {
const endpoint = `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${encodeURIComponent(
packageName
)}/purchases/products/${encodeURIComponent(productId)}/tokens/${encodeURIComponent(token)}`;
const res = await fetch(endpoint, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
});
if (res.status === 404) {
return {
status: 404,
body: { error: 'Purchase token not found in Google Play', code: 'INVALID_PURCHASE' },
};
}
if (res.status === 401 || res.status === 403) {
const tokenPrefix = tokenHash.substring(0, 8);
console.error(`[PlayVerification] [hash=${tokenPrefix}] Google returned status ${res.status}`);
return {
status: 502,
body: { error: 'Google API authorization failure', code: 'GOOGLE_TEMPORARILY_UNAVAILABLE' },
};
}
if (res.status === 429) {
return {
status: 429,
body: { error: 'Google Developer API rate limited', code: 'RATE_LIMITED' },
};
}
if (!res.ok) {
return {
status: 502,
body: { error: `Google API returned error status ${res.status}`, code: 'GOOGLE_TEMPORARILY_UNAVAILABLE' },
};
}
const prod = (await res.json()) as {
purchaseState?: number; // 0 = Purchased, 1 = Canceled/Refunded, 2 = Pending
consumptionState?: number;
acknowledgementState?: number; // 0 = Yet to be acknowledged, 1 = Acknowledged
};
const purchaseState = prod.purchaseState ?? 0;
let status: VerificationResponse['status'] = 'proLifetime';
let verificationStatus: VerificationResponse['verificationStatus'] = 'verified';
if (purchaseState === 0) {
// Purchased & valid
status = 'proLifetime';
verificationStatus = 'verified';
} else if (purchaseState === 1) {
// Canceled / refunded
status = 'unknown';
verificationStatus = 'revoked';
} else if (purchaseState === 2) {
// Pending
status = 'syncPending';
verificationStatus = 'unverified';
}
// Server-Side Acknowledgement for Lifetime
let ackState: AcknowledgementState = 'notApplicable';
const isAlreadyAcknowledged = prod.acknowledgementState === 1;
if (purchaseState === 0 && verificationStatus === 'verified') {
if (isAlreadyAcknowledged) {
ackState = 'acknowledged';
} else {
const ackResult = await acknowledgeOneTimeProduct(
packageName,
productId,
token,
accessToken,
tokenHash
);
if (ackResult.success) {
ackState = 'acknowledged';
} else {
ackState = 'pendingRetry';
}
}
}
return {
status: 200,
body: {
schemaVersion: 1,
productId: ALLOWED_PRODUCTS.PRO_LIFETIME,
plan: 'proLifetime',
status,
verificationStatus,
acknowledgementState: ackState,
verifiedAt: new Date().toISOString(),
expiresAt: null,
tokenHash,
},
};
}
// Helpers for PKCS#8 PEM decoding and Base64URL
function pemToBinary(pem: string): ArrayBuffer {
const cleanPem = pem
.replace(/-----BEGIN[ A-Z0-9_-]+-----/g, '')
.replace(/-----END[ A-Z0-9_-]+-----/g, '')
.replace(/\s+/g, '');
const raw = atob(cleanPem);
const bytes = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) {
bytes[i] = raw.charCodeAt(i);
}
return bytes.buffer;
}
function base64UrlEncode(input: string | ArrayBuffer): string {
let base64: string;
if (typeof input === 'string') {
base64 = btoa(input);
} else {
const bytes = new Uint8Array(input);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
base64 = btoa(binary);
}
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}