Example: Google OAuth 2.0 for Service Accounts using CF Worker

I wanted to use my CloudFlare workers to interact with various Google APIs using a Service Account (i.e. server-to-server) rather than User Accounts (i.e. browser-to-server).

The problem was that the Google APIs required the Service Account to authenticate using OAuth 2.0, and the node.js helper libraries Google provide don’t work with CloudFlare workers.

There was already a solution posted in this forum by @webchad from 2018, but back then crypto.subtle.importKey didn’t support pkcs8, so the solution relied on an external application to generate the JWT token. I wanted the entire OAuth process to happen within the CloudFlare worker without any dependencies on external systems.

I’ve pasted my demo code below which performs the OAuth process and returns the bearer token. Hopefully this is helpful for others.

I’m not a Javascript coder by profession, so I know this code isn’t great! In particular its missing error handling. I’d be really grateful for any suggestions to improve the robustness and performance of this code.

Things still to do:

1 - The private key should live in worker secrets, but there is a 1kb limit. Hopefully this can be soon increased to 5kb… (see: Any news on increasing secrets limit from 1kb to 5kb?)

2 - Add caching of the OAuth response rather than retrieving afresh every time…

Also the Google OAuth 2.0 documentation for Service Accounts is available here: Using OAuth 2.0 for Server to Server Applications  |  Google Identity

async function handleRequest(request) {
try {


// Function to encode an object with base64
function objectToBase64url(object) {
   return arrayBufferToBase64Url(
     new TextEncoder().encode(JSON.stringify(object)),
   )
 };

 // Function to encode array buffer with base64
 function arrayBufferToBase64Url(buffer) {
    return btoa(String.fromCharCode(...new Uint8Array(buffer)))
      .replace(/=/g, '')
      .replace(/\+/g, '-')
      .replace(/\//g, '_')
  };

  // Function to convert string to array buffer
  function str2ab(str) {
    const buf = new ArrayBuffer(str.length);
    const bufView = new Uint8Array(buf);
    for (let i = 0, strLen = str.length; i < strLen; i++) {
      bufView[i] = str.charCodeAt(i);
    }
    return buf;
  };



//
// Step 1 - Form the JWT header and encode to base64
const GOOGLE_JWT_HEADER = objectToBase64url({
  // RSA SHA-256 algorithm is mandatory for Google
  alg: 'RS256',
  // JWT token format is mandatory for Google
  typ: 'JWT'
});



//
// Step 2 - Form the JWT claim set and encode to base64

// Define the time the assertion was issued
let assertiontime = Math.round(Date.now() / 1000)

// Define the expiration time of the assertion, maximum 1 hour
let expirytime = assertiontime + 3600

// JWT claim payload
const GOOGLE_JWT_CLAIMSET = objectToBase64url({
  // Service account email address from https://console.cloud.google.com
  // This should be defined as a secret rather than hardcoded!!
  'iss': '[service-account-username]@[project-code].iam.gserviceaccount.com',
  // A listing of available scopes is provided here:
  // https://developers.google.com/identity/protocols/oauth2/scopes
  'scope': 'https://www.googleapis.com/auth/cloud-platform',
  'aud': 'https://oauth2.googleapis.com/token',
  'exp': expirytime,
  'iat': assertiontime
});

// Combine the JWT header + claim and convert to byte array for signing
const GOOGLE_JWT_COMBINED = str2ab(
  "{" +
  GOOGLE_JWT_HEADER +
  "}.{" +
  GOOGLE_JWT_CLAIMSET +
  "}"
);



//
// Step 3 - Sign the combined GOOGLE_JWT_HEADER and GOOGLE_JWT_CLAIMSET

// Private key - TESTING ONLY - SHOULD BE A SECRET, NOT BE STORED IN CODE!!
// Paste the whole key into here from your secrets.json, including \n
const private_key_raw = '-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhk[----REPLACE---WHOLE-SECTION-WITH--YOUR--PRIVATE--KEY]/C0fNU95T0g=\n-----END PRIVATE KEY-----\n';


// Tidy up the key ahead of importing
var private_key_clean = private_key_raw.replace('-----BEGIN PRIVATE KEY-----', '');
var private_key_clean = private_key_clean.replace('-----END PRIVATE KEY-----', '');
var private_key_clean = private_key_clean.replace(/(\r\n|\n|\r)/gm, "");

// base64 decode the string to get the binary data
var private_key_binary = atob(private_key_clean);

// convert from a binary string to an ArrayBuffer
var private_key_binary = str2ab(private_key_binary);

// Import the private key into the crypto store
const SIGNING_KEY = await crypto.subtle.importKey(
    "pkcs8",
    private_key_binary,
        {
            name: "RSASSA-PKCS1-V1_5",
            hash: {name: "SHA-256"}
        },
    false,
    ["sign"]
);


// Sign the GOOGLE_JWT_HEADER and GOOGLE_JWT_CLAIMSET
const rawToken = await crypto.subtle.sign(
  { name: 'RSASSA-PKCS1-V1_5' },
  SIGNING_KEY,
  GOOGLE_JWT_COMBINED
);


// Convert the signature to Base64URL format
const GOOGLE_JWT_SIGNED = arrayBufferToBase64Url(rawToken);


// Combine the headers with signature
var combined_headers_signed =
  "{" +
  GOOGLE_JWT_HEADER +
  "}.{" +
  GOOGLE_JWT_CLAIMSET +
  "}.{"
  + GOOGLE_JWT_SIGNED +
  "}";



//
// Step 4 - Send the request

// Create payload
const GOOGLE_JWT_PAYLOAD =
  "grant_type=" +
  "urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=" +
  combined_headers_signed;

console.log("Payload = " + GOOGLE_JWT_PAYLOAD);


// Make the OAUTH request
const response = await fetch('https://oauth2.googleapis.com/token', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/x-www-form-urlencoded',
    'Cache-Control': 'no-cache',
    'Host': 'oauth2.googleapis.com'
  },
  body: GOOGLE_JWT_PAYLOAD
});


// Grab the JSON from the response
const oauth = await response.json();

console.log("Response = " + JSON.stringify(oauth));


// Capture the access token
const GOOGLE_OAUTH_ACCESS_TOKEN = oauth.access_token;



return new Response(`Authorization bearer token = ${GOOGLE_OAUTH_ACCESS_TOKEN}`, {
  headers: { 'content-type': 'text/plain' },
})



} catch (err) {
console.log(err)
}
}



addEventListener('fetch', event => {
  event.respondWith(handleRequest(event.request))
})