import {createVerify} from "crypto"; import * as admin from "firebase-admin"; import * as express from "express"; import {https} from "firebase-functions"; import {error, info} from "firebase-functions/logger"; import {onRequest} from "firebase-functions/v2/https"; /** * AdMob rewarded-ads Server-Side Verification (SSV). * * When a user finishes a rewarded (or rewarded-interstitial) ad, Google calls * this endpoint with the reward details and a cryptographic signature. We verify * the signature against Google's public keys, then grant the reward exactly once * (idempotent on `transaction_id`). This is the secure way to grant rewards: a * tampered client can never fake it. * * Setup: deploy, then in the AdMob console set this function's URL as the SSV * callback for each rewarded ad unit: * https://-.cloudfunctions.net/ads-verifyAdReward * * Docs: https://developers.google.com/admob/flutter/ssv */ const VERIFIER_KEYS_URL = "https://gstatic.com/admob/reward/verifier-keys.json"; const KEYS_TTL_MS = 60 * 60 * 1000; // 1h interface VerifierKey { keyId: number; pem: string; base64: string; } let cachedKeys: VerifierKey[] | null = null; let cachedAt = 0; /** Fetches (and caches) Google's rewarded-ads public verifier keys. */ async function getVerifierKeys(): Promise { const now = Date.now(); if (cachedKeys && now - cachedAt < KEYS_TTL_MS) return cachedKeys; const res = await fetch(VERIFIER_KEYS_URL); if (!res.ok) { throw new Error(`Failed to fetch verifier keys: ${res.status}`); } const json = (await res.json()) as {keys: VerifierKey[]}; cachedKeys = json.keys; cachedAt = now; return cachedKeys; } /** * Verifies the SSV signature. The signed content is the raw query string up to * (but excluding) `&signature=`; `signature` and `key_id` are always the last * two parameters, in that order. */ async function isSignatureValid(rawQuery: string): Promise { const signatureIndex = rawQuery.indexOf("&signature="); if (signatureIndex < 0) return false; const contentToVerify = rawQuery.substring(0, signatureIndex); const params = new URLSearchParams(rawQuery); const signature = params.get("signature"); const keyId = params.get("key_id"); if (!signature || !keyId) return false; const keys = await getVerifierKeys(); const key = keys.find((k) => String(k.keyId) === keyId); if (!key) { error(`[ads-ssv] no verifier key for key_id=${keyId}`); return false; } const verifier = createVerify("SHA256"); verifier.update(contentToVerify); verifier.end(); // AdMob signatures are base64url-encoded ASN.1 DER ECDSA over secp256r1. return verifier.verify(key.pem, Buffer.from(signature, "base64url")); } export const verifyAdReward = onRequest( {cors: false}, async (req: https.Request, res: express.Response) => { if (req.method !== "GET") { res.status(405).send("Method Not Allowed"); return; } const rawQuery = req.originalUrl.includes("?") ? req.originalUrl.substring(req.originalUrl.indexOf("?") + 1) : ""; try { let valid = false; try { valid = await isSignatureValid(rawQuery); } catch (e) { // Transient (e.g. verifier-key fetch failed) — 500 so Google retries. error("[ads-ssv] verification error", e); res.status(500).send("verification error"); return; } if (!valid) { // Forged/invalid: ack with 200 so Google doesn't retry a request that // can never become valid. We simply don't grant anything. info("[ads-ssv] invalid signature — dropping"); res.status(200).send("ok"); return; } const userId = req.query.user_id as string | undefined; const transactionId = req.query.transaction_id as string | undefined; const rewardAmount = Number(req.query.reward_amount ?? 0); const rewardItem = (req.query.reward_item as string | undefined) ?? ""; const customData = (req.query.custom_data as string | undefined) ?? ""; const adUnit = (req.query.ad_unit as string | undefined) ?? ""; if (!userId || !transactionId) { // Signature is valid but no user to grant to: ack so Google stops // retrying. Configure `userId` via setServerSideOptions on the client. info("[ads-ssv] valid callback without user_id/transaction_id"); res.status(200).send("ok"); return; } const db = admin.firestore(); const rewardRef = db .collection("users") .doc(userId) .collection("ad_rewards") .doc(transactionId); await db.runTransaction(async (tx) => { const existing = await tx.get(rewardRef); if (existing.exists) return; // already granted: idempotent tx.set(rewardRef, { amount: rewardAmount, item: rewardItem, customData, adUnit, createdAt: admin.firestore.FieldValue.serverTimestamp(), }); // ─── Grant the reward ───────────────────────────────────────────── // Replace this with your own entitlement (coins, lives, no-ads pass…). // The example keeps a running balance on the user document. // // HEADS UP: `users/{uid}` is writable by its owner (firestore.rules // allows the profile update, blocking only `role`), so this demo field // could be edited straight from a tampered client — which would defeat // the whole point of verifying server-side. Nothing in the app reads it, // so it is harmless as a placeholder, but when you swap in a REAL // entitlement put it somewhere the client cannot write (a subcollection // or document covered by an `allow write: if false` rule). tx.set( db.collection("users").doc(userId), { adRewardBalance: admin.firestore.FieldValue.increment(rewardAmount), }, {merge: true}, ); }); info(`[ads-ssv] reward granted user=${userId} tx=${transactionId}`); res.status(200).send("ok"); } catch (e) { error("[ads-ssv]", e); res.status(500).send(e instanceof Error ? e.message : String(e)); } }, );