Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Call Google Play Developer API from Firebase Functions

I am trying to develop a server-side validation of my users' in-app purchases and subscriptions as recommended, and I want to use Firebase Functions for that. Basically it has to be an HTTP trigger function that receives a purchase token, calls the Play Developer API to verify the purchase, and then does something with the result.

However, calling many of the Google APIs (including Play Developer API) requires non-trivial authorization. Here's how I understand the required setup:

  1. There has to be a GCP project with Google Play Developer API v2 enabled.
  2. It should be a separate project, since there can be only one linked to Play Store in the Google Play Console.
  3. My Firebase Functions project must somehow authenticate to that other project. I figured that using a Service Account is most suitable in this server-to-server scenario.
  4. Finally, my Firebase Functions code must somehow obtain authentication token (hopefully JWT?) and finally make an API call to get a subscription status.

The problem is that absolutely no human-readable documentation or guidance on that is existent. Given that ingress traffic in Firebase is included in the free plan (so I assume they encourage using Google APIs from Firebase Functions), that fact is pretty disappointing. I've managed to find some bits of info here and there, but having too little experience with Google APIs (most of which required simply using an api key), I need help with putting it together.

Here's what I figured out so far:

  1. I got a GCP project linked to the Play Store and with the API enabled. For some reason though, trying to test it in APIs Explorer results in an error "The project id used to call the Google Play Developer API has not been linked in the Google Play Developer Console".
  2. I made a Service Account and exported a JSON key, which contains the key to produce a JWT.
  3. I also set up read permissions for that Service Account in Play Console.
  4. I found a Node.JS client library for Google APIs, which is in alpha and has very sparse documentation (e.g. there's no obvious documentation on how to authenticate with JWT, and no samples on how to call the android publisher API). At the moment I'm struggling with that. Unfortunately I'm not super-comfortable with reading JS library code, especially when the editor doesn't provide the possibility to jump to highlighted functions' sources.

I'm pretty surprised this hasn't been asked or documented, because verifying in-app purchases from Firebase Functions seems like a common task. Has anyone successfully done it before, or maybe the Firebase team will step in to answer?

like image 469
Actine Avatar asked Apr 14 '18 21:04

Actine


2 Answers

I figured it out myself. I also ditched the heavyweight client library and just coded those few requests manually.

Notes:

  • The same applies to any Node.js server environment. You still need the key file of a separate service account to mint a JWT and the two steps to call the API, and Firebase is no different.
  • The same applies to other APIs that require authentication as well — differing only in scope field of the JWT.
  • There are a few APIs that don't need you to exchange the JWT for an access token — you can mint a JWT and provide it directly in Authentication: Bearer, without a round trip to OAuth backend.

After you've got the JSON file with the private key for a Service Account that's linked to Play Store, the code to call the API is like this (adjust to your needs). Note: I used request-promise as a nicer way to do http.request.

const functions = require('firebase-functions');
const jwt = require('jsonwebtoken');
const keyData = require('./key.json');         // Path to your JSON key file
const request = require('request-promise');

/** 
 * Exchanges the private key file for a temporary access token,
 * which is valid for 1 hour and can be reused for multiple requests
 */
function getAccessToken(keyData) {
  // Create a JSON Web Token for the Service Account linked to Play Store
  const token = jwt.sign(
    { scope: 'https://www.googleapis.com/auth/androidpublisher' },
    keyData.private_key,
    {
      algorithm: 'RS256',
      expiresIn: '1h',
      issuer: keyData.client_email,
      subject: keyData.client_email,
      audience: 'https://www.googleapis.com/oauth2/v4/token'
    }
  );

  // Make a request to Google APIs OAuth backend to exchange it for an access token
  // Returns a promise
  return request.post({
    uri: 'https://www.googleapis.com/oauth2/v4/token',
    form: {
      'grant_type': 'urn:ietf:params:oauth:grant-type:jwt-bearer',
      'assertion': token
    },
    transform: body => JSON.parse(body).access_token
  });
}

/**
 * Makes a GET request to given URL with the access token
 */
function makeApiRequest(url, accessToken) {
  return request.get({
    url: url,
    auth: {
      bearer: accessToken
    },
    transform: body => JSON.parse(body)
  });
}

// Our test function
exports.testApi = functions.https.onRequest((req, res) => {
  // TODO: process the request, extract parameters, authenticate the user etc

  // The API url to call - edit this
  const url = `https://www.googleapis.com/androidpublisher/v2/applications/${packageName}/purchases/subscriptions/${subscriptionId}/tokens/${token}`;

  getAccessToken(keyData)
    .then(token => {
      return makeApiRequest(url, token);
    })
    .then(response => {
      // TODO: process the response, e.g. validate the purchase, set access claims to the user etc.
      res.send(response);
      return;
    })
    .catch(err => {
      res.status(500).send(err);
    });
});

These are the docs I followed.

like image 131
Actine Avatar answered Oct 11 '22 06:10

Actine


I think I found a slightly quicker way to do this... or at least... more simply.

To support scaling and keep index.ts from growing out of control... I have all the functions and globals in the index file but all the actual events are handled by handlers. Easier to maintain.

So here's my index.ts (I heart type safety):

//my imports so you know
import * as functions from 'firebase-functions';
import * as admin from "firebase-admin";
import { SubscriptionEventHandler } from "./subscription/subscription-event-handler";

// honestly not 100% sure this is necessary 
admin.initializeApp({
    credential: admin.credential.applicationDefault(),
    databaseURL: 'dburl'
});

const db = admin.database();

//reference to the class that actually does the logic things
const subscriptionEventHandler = new SubscriptionEventHandler(db);

//yay events!!!
export const onSubscriptionChange = functions.pubsub.topic('subscription_status_channel').onPublish((message, context) => {
    return subscriptionEventHandler.handle(message, context);
});
//aren't you happy this is succinct??? I am!

Now... for the show!

// importing like World Market
import * as admin from "firebase-admin";
import {SubscriptionMessageEvent} from "./model/subscription-message-event";
import {androidpublisher_v3, google, oauth2_v2} from "googleapis";
import {UrlParser} from "../utils/url-parser";
import {AxiosResponse} from "axios";
import Schema$SubscriptionPurchase = androidpublisher_v3.Schema$SubscriptionPurchase;
import Androidpublisher = androidpublisher_v3.Androidpublisher;

// you have to get this from your service account... or you could guess
const key = {
    "type": "service_account",
    "project_id": "not going to tell you",
    "private_key_id": "really not going to tell you",
    "private_key": "okay... I'll tell you",
    "client_email": "doesn't matter",
    "client_id": "some number",
    "auth_uri": "https://accounts.google.com/o/oauth2/auth",
    "token_uri": "https://accounts.google.com/o/oauth2/token",
    "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
    "client_x509_cert_url": "another url"
};

//don't guess this...  this is right
const androidPublisherScope = "https://www.googleapis.com/auth/androidpublisher";

// the handler
export class SubscriptionEventHandler {
    private ref: admin.database.Reference;

    // so you don't need to do this... I just did to log the events in the db
    constructor(db: admin.database.Database) {
        this.ref = db.ref('/subscriptionEvents');
    }

    // where the magic happens
    public handle(message, context): any {
        const data = JSON.parse(Buffer.from(message.data, 'base64').toString()) as SubscriptionMessageEvent;

        // if subscriptionNotification is truthy then we're solid here
        if (message.json.subscriptionNotification) {
            // go get the the auth client but it's async... so wait
            return google.auth.getClient({
                scopes: androidPublisherScope,
                credentials: key
            }).then(auth => {
                //yay!  success!  Build android publisher!
                const androidPublisher = new Androidpublisher({
                    auth: auth
                });

                // get the subscription details
                androidPublisher.purchases.subscriptions.get({
                    packageName: data.packageName,
                    subscriptionId: data.subscriptionNotification.subscriptionId,
                    token: data.subscriptionNotification.purchaseToken
                }).then((response: AxiosResponse<Schema$SubscriptionPurchase>) => {
                    //promise fulfilled... grandma would be so happy
                    console.log("Successfully retrieved details: " + response.data.orderId);
                }).catch(err => console.error('Error during retrieval', err));
            });
        } else {
            console.log('Test event... logging test');
            return this.ref.child('/testSubscriptionEvents').push(data);
        }
    }
}

There are few model classes that help:

export class SubscriptionMessageEvent {
    version: string;
    packageName: string;
    eventTimeMillis: number;
    subscriptionNotification: SubscriptionNotification;
    testNotification: TestNotification;
}

export class SubscriptionNotification {
    version: string;
    notificationType: number;
    purchaseToken: string;
    subscriptionId: string;
}

So that's how we do that thing.

like image 20
Brian Burgess Avatar answered Oct 11 '22 04:10

Brian Burgess