Notes about In App Purchase validation on Android

I mentioned in an earlier post that I’d started dabbling with Android. This incredible journey brought me to the realm of in-app purchase validation which is part of what the Google Cloud Platform offers. This is ideal if you already use one of the dozen services they provide, like BigQuery and such. In my case, all I was looking for was a way to validate my purchases, so I found the documentation a little bit overwhelming and got lost quite a few times. Since nearly broke my sanity, I figured I would share what I’ve learned.

Service Accounts Shenanigans

The authentication mechanism on Google Cloud is a little confusing, but if you dabbled in an equally terribly confusing console such as AWS’, you have the mental toughness to make it through the setup required to use a “service account”. This is the recommended way in the documentation.

Once you’ve created your service account, you need to generate a key that you will use for authentication… except I couldn’t. Service accounts pose a security threat if you’re not careful (and your private key gets leaked), so the creation of keys for service accounts was disabled. It didn’t matter that I was the owner of the project the service account belonged to, since this was enforced at the organization level. I had to ask the organization owner to grant me more permissions, disable the iam.disableServiceAccountKeyCreation policy temporarily so I could create and download a key.

After you’ve downloaded it, the key is in the JSON format and looks like this:

{
  "type": "service_account",

  "project_id": "acme-super-project",

  "private_key_id": "80b3ef695024f82087d23f6c527c6585",

  "private_key": "-----BEGIN PRIVATE KEY-----\nbmljZSB0cnksIHBsZWFzZSBzZW5kIG1lIGEgbWVzc2FnZSBvbiBtYXN0b2RvbiBpZiB5b3UgYWN0dWFsbHkgdHJpZWQgdG8gZGVjb2RlIHRoaXM6IGh0dHBzOi8vbWFzdG9kb24uc29jaWFsL0BwYWxsZWFzCg-----END PRIVATE KEY-----\n",

  "client_email": "some-account@acme.iam.gserviceaccount.com",

  "client_id": "11111111111111111111",
  
  "auth_uri": "https://accounts.google.com/o/oauth2/auth",

  "token_uri": "https://oauth2.googleapis.com/token",

  "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",

  "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/some-account%40acme.iam.gserviceaccount.com",

  "universe_domain": "googleapis.com"
}

It contains a few information that we’ll need later like the key and its id, the client email, the token URI and the auth URI, although it’s probably safe to hard-code the last 2.

Once you’ve created your service account, you need to invite it to your developer account in the Google Play Console and grant it the required permissions: “View financial data, orders, and cancellation survey responses” and “Manage orders and subscriptions”. This is probably where I messed up the first time, but I’ll get back to it later.

Access Token Generation

I cannot overstate how mad I got trying to make this access token generation work, or even figuring out what the API endpoint expected of me. Would it have been easier if I showed patience, if I read everything carefully and if I took a walk when I started to get frustrated? Maybe. Who are you, my mom? I didn’t think so.

Once you figure out that getting an access token is a 2 steps process, it’s pretty easy because chances are you’ve worked with similar API before. I originally expected to use the official client from Google, but I kept getting lost in the documentation, finding examples of code using deprecated API and I didn’t love the amount of dependencies it came with. Fortunately, the documentation also shows how to use HTTP, if you’re not too worried about the sentence “severe impact on the security of your application”.

Getting an access token from Google is a two-step process. First, you need to create a JWT using your private key and the information in the JSON key file. I ended up using Auth0’s JWT library, since I already know it well:

JWT.create()
    .withKeyId(keyId)
    .withIssuer(clientEmail)
    .withClaim("scope", "https://www.googleapis.com/auth/androidpublisher")
    .withAudience(tokenUri)
    .withExpiresAt(Instant.now().plusSeconds(timeToLiveInSeconds))
    .withIssuedAt(Instant.now())
    .sign(Algorithm.RSA256(publicKey, privateKey))

Then, use that JWT to request an access token from a Google API. Here I used Ktor’s HTTP Client.

val client = HTTPClient { ... }
val response = client.submitForm(
    "https://oauth2.googleapis.com/token",
    formParameters = parameters {
        append("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")
        append("assertion", jwt)
    }
) {
    headers { accept(ContentType.Application.Json) }
}

In App Purchase Validation

I’m not going to go into details on how to use the Android Billing library on Android but once you have a purchase to validate, you’ll need to send 3 things to your backend:

  • packageName: your application package
  • productId: the identifier of the product the user is buying and that you defined in the play developer console
  • token: the super long token that the billing gives you after the user has gone through the purchase.

Once you have that and your access token, you can call the purchases.products.get method to check the status of the purchase and finally acknowledge and consume the purchase. Phew.

One last thing that caused me to scratch my head was that some API endpoints worked, while some other did not. For example, I was able to call the inappproducts.list method, but calling the purchases.products.get one failed with a permission issue, even if both operations required the https://www.googleapis.com/auth/androidpublisher OAuth scope. I ended up granting explicit access to my application to the service account and after a few hours my calls to purchases.products.get were successful. I’m not sure if it makes sense (since both operations needs the package name to be provided) or if I simply triggered some permission refresh somewhere.

Conclusion

I’m not sure how I ended up deciding to write a blog post about Android. This year I’ve decided to write more about what I feel like and try not to be blocked on the idea that this might not be what people expect to read here. I hope this was useful to someone, but I for one learned a lot.