Accessing Google API with OAuth2 and a service account from Clojure
How to turn a service account’s service.json
into an access token you can actually use to call Google APIs, when you don’t want to use Google’s SDK? With Buddy’s JWT it is pretty simple, and Tim Pratley’s HappyGAPI will show us how to do it. (I believe that the same approach would work with other OAuth providers, just with changes to some of the values.)
The problem
I want to write to a Google Sheet from a backend service. And I want a lightweight solution using the REST API, which doesn’t require me to include the Google API SDK. But what about authentication?
The solution
This is what I did:
Create a Google Cloud project, and enable the Google Sheets API for it
Create a service account under the project (I didn’t add any privileges to the account itself; instead, I directly shared the spreadsheet file with the account’s email address, making it an editor.)
Create keys for the account and download the resulting
<project name>-<id>.json
file, also known asservice.json
Take a backup copy of the file, and remove all keys other than
client_email, private_key
and all unnecessary whitespace (to minimize it, so that I can store it more easily into an env variable and thus pass it to my service via Fly.io Secrets)Ready to roll!
Now, I started to craft the JWT payload according to Google’s docs but it turned out Tim has already done the work for me in refresh-credentials. Let’s reprint the relevant parts of the code for reference:
(defn refresh-credentials
[{:keys [client_email private_key] :as service-json} scopes]
(let [now (quot (.getTime (Date.)) 1000)]
(:body (http/post "https://oauth2.googleapis.com/token"
{:form-params
{:grant_type "urn:ietf:params:oauth:grant-type:jwt-bearer"
:assertion (buddy.sign.jwt/sign
{:iss client_email,
:scope (str/join " " scopes),
:aud "https://oauth2.googleapis.com/token",
:exp (+ now 3600) ; 1h is the max, could be less
:iat now}
(buddy.core.keys/str->private-key private_key)
{:alg :rs256
:header {:alg "RS256" ; (1)
:typ "JWT"}})}
:accept :json
:as :json}))))
;; Returns a map with `:access_token`, `:expires_in` etc.
1 | I believe that specifying :alg here is unnecessary, as we pass it in on the previous line already and JWT will use that |
The only remaining mystery is what to pass in the scopes
sequence. I found the answer in Google’s OAuth 2.0 Scopes docs. In my case, for using the Google Sheets API to write a spreadsheet, the scope is https://www.googleapis.com/auth/spreadsheets
.
Thus I call HappyGAPI like this, to obtain an access token:
(:access_token
(happy.oauth2/refresh-credentials
(json/parse-string (System/getenv "SERVICE_JSON") keyword)
["https://www.googleapis.com/auth/spreadsheets"]
nil))
(As hinted before, I put the content of the minimized service.json
into the env var SERVICE_JSON
.)
Voilà! I get an access token and can use HappyGAPI to write to my spreadsheet:
(gsheets/values-batchUpdate$
{:headers {"Authorization" (str "Bearer " access-token)}}
{:spreadsheetId "my-spreadsheet-id"} ; from the sheet's URL
{:valueInputOption "RAW"
:data [{:range "Sheet1" :values [["Hello" "World"] [1 2 3]]}]})
(HappyGAPI has an auto-generated function for each of the REST endpoints, in this case spreadsheets.values/batchUpdate, with URL parameters (here :spreadsheetId
) passed in the second argument.)