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:

  1. Create a Google Cloud project, and enable the Google Sheets API for it

  2. 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.)

  3. Create keys for the account and download the resulting <project name>-<id>.json file, also known as service.json

  4. 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)

  5. 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.
1I 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.)


Tags: clojure


Copyright © 2024 Jakub Holý
Powered by Cryogen
Theme by KingMob