3
votes

My client has a GraphQL API running on Google cloud run.

I have recieved a service account for authentication as well as access to the gcloud command line tool.

When using gcloud command line like so:

gcloud auth print-identity-token

I can generate a token that can be used to make post requests to the api. This works and I can make successful post requests to the api from postman, insomnia and from my nodejs app.

However, when I use JWT authentication with "googleapis" or "google-auth" npm libraries like so :

var { google } = require('googleapis')

let privatekey = require('./auth/google/service-account.json')

let jwtClient = new google.auth.JWT(
  privatekey.client_email,
  null,
  privatekey.private_key,
  ['https://www.googleapis.com/auth/cloud-platform']
)

jwtClient.authorize(function(err, _token) {
  if (err) {
    console.log(err)
    return err
  } else {
    console.log('token obj:', _token)
  }
})

This outputs a "bearer" token:

token obj: {
  access_token: 'ya29.c.Ko8BvQcMD5zU-0raojM_u2FZooWMyhB9Ni0Yv2_dsGdjuIDeL1tftPg0O17uFrdtkCuJrupBBBK2IGfUW0HGtgkYk-DZiS1aKyeY9wpXTwvbinGe9sud0k1POA2vEKiGONRqFBSh9-xms3JhZVdCmpBi5EO5aGjkkJeFI_EBry0E12m2DTm0T_7izJTuGQ9hmyw',
  token_type: 'Bearer',
  expiry_date: 1581954138000,
  id_token: undefined,
  refresh_token: 'jwt-placeholder'
}

however this bearer token does not work as the one above and always gives an "unauthorised error 401" when making the same requests as with the gcloud command "gcloud auth print-identity-token".

Please help, I am not sure why the first bearer token works but the one generated with JWT does not.

EDIT

I have also tried to get an identity token instead of an access token like so :

let privatekey = require('./auth/google/service-account.json')

let jwtClient = new google.auth.JWT(
  privatekey.client_email,
  null,
  privatekey.private_key,
  []
)

jwtClient
  .fetchIdToken('https://my.audience.url')
  .then((res) => console.log('res:', res))
  .catch((err) => console.log('err', err))

This prints an identity token, however, using this also just gives a "401 unauthorised" message.

Edit to show how I am calling the endpoint

Just a side note, any of these methods below work with the command line identity token, however when generated via JWT, it returns a 401

Method 1:

 const client = new GraphQLClient(baseUrl, {
        headers: {
          Authorization: 'Bearer ' + _token.id_token
        }
      })
      const query = `{
        ... my graphql query goes here ...
    }`
      client
        .request(query)
        .then((data) => {
          console.log('result from query:', data)
          res.send({ data })
          return 0
        })
        .catch((err) => {
          res.send({ message: 'error ' + err })
          return 0
        })
    }

Method 2 (using the "authorized" client I have created with google-auth):

  const res = await client.request({
    url: url,
    method: 'post',
    data: `{
        My graphQL query goes here ...
    }`
  })
  console.log(res.data)
}
3
I think your problem is the Identity Token audience. In your last example, what is the audience? Is this code running in Cloud Run or are you calling a Cloud Run service? If you are calling a Cloud Run service the audience value must match the Assigned by Cloud Run URL that looks like this: https://example-ylyxpergiq-uc.a.run.app that you can copy from the Google Cloud Console. Your first example will never work as it generates an Access Token. Your second example is creating the correct type of token which is an Identity Token.John Hanley
Use jwt.io to decode the Identity Token and show those values in your question (mask sensitive information such as project and email).John Hanley
One last item, edit your question and show how you are calling the endpoint in code.John Hanley
@JohnHanley sure thingJanpan
You did not answer my questions in my first comment. You did not provide the identity token information from my second comment. Without details you will continue to get answers that are just guesses.John Hanley

3 Answers

6
votes

Here is an example in node.js that correctly creates an Identity Token with the correct audience for calling a Cloud Run or Cloud Functions service.

Modify this example to fit the GraphQLClient. Don't forget to include the Authorization header in each call.

    // This program creates an OIDC Identity Token from a service account
    // and calls an HTTP endpoint with the Identity Token as the authorization
    
    var { google } = require('googleapis')
    const request = require('request')
    
    // The service account JSON key file to use to create the Identity Token
    let privatekey = require('/config/service-account.json')
    
    // The HTTP endpoint to call with an Identity Token for authorization
    // Note: This url is using a custom domain. Do not use the same domain for the audience
    let url = 'https://example.jhanley.dev'
    
    // The audience that this ID token is intended for (example Google Cloud Run service URL)
    // Do not use a custom domain name, use the Assigned by Cloud Run url
    let audience = 'https://example-ylabperdfq-uc.a.run.app'
    
    let jwtClient = new google.auth.JWT(
        privatekey.client_email,
        null,
        privatekey.private_key,
        audience
    )
    
    jwtClient.authorize(function(err, _token) {
        if (err) {
            console.log(err)
            return err
        } else {
            // console.log('token obj:', _token)
    
            request(
                {
                    url: url,
                    headers: {
                        "Authorization": "Bearer " + _token.id_token
                    }
                },
                function(err, response, body) {
                    if (err) {
                        console.log(err)
                        return err
                    } else {
                        // console.log('Response:', response)
                        console.log(body)
                    }
                }
            );
        }
    })
1
votes

You can find the official documentation for node OAuth2

A complete OAuth2 example:

const {OAuth2Client} = require('google-auth-library');
const http = require('http');
const url = require('url');
const open = require('open');
const destroyer = require('server-destroy');

// Download your OAuth2 configuration from the Google
const keys = require('./oauth2.keys.json');

/**
 * Start by acquiring a pre-authenticated oAuth2 client.
 */
async function main() {
  const oAuth2Client = await getAuthenticatedClient();
  // Make a simple request to the People API using our pre-authenticated client. The `request()` method
  // takes an GaxiosOptions object.  Visit https://github.com/JustinBeckwith/gaxios.
  const url = 'https://people.googleapis.com/v1/people/me?personFields=names';
  const res = await oAuth2Client.request({url});
  console.log(res.data);

  // After acquiring an access_token, you may want to check on the audience, expiration,
  // or original scopes requested.  You can do that with the `getTokenInfo` method.
  const tokenInfo = await oAuth2Client.getTokenInfo(
    oAuth2Client.credentials.access_token
  );
  console.log(tokenInfo);
}


/**
 * Create a new OAuth2Client, and go through the OAuth2 content
 * workflow.  Return the full client to the callback.
 */
function getAuthenticatedClient() {
  return new Promise((resolve, reject) => {
    // create an oAuth client to authorize the API call.  Secrets are kept in a `keys.json` file,
    // which should be downloaded from the Google Developers Console.
    const oAuth2Client = new OAuth2Client(
      keys.web.client_id,
      keys.web.client_secret,
      keys.web.redirect_uris[0]
    );

    // Generate the url that will be used for the consent dialog.
    const authorizeUrl = oAuth2Client.generateAuthUrl({
      access_type: 'offline',
      scope: 'https://www.googleapis.com/auth/userinfo.profile',
    });

    // Open an http server to accept the oauth callback. In this simple example, the
    // only request to our webserver is to /oauth2callback?code=<code>
    const server = http
      .createServer(async (req, res) => {
        try {
          if (req.url.indexOf('/oauth2callback') > -1) {
            // acquire the code from the querystring, and close the web server.
            const qs = new url.URL(req.url, 'http://localhost:3000')
              .searchParams;
            const code = qs.get('code');
            console.log(`Code is ${code}`);
            res.end('Authentication successful! Please return to the console.');
            server.destroy();

            // Now that we have the code, use that to acquire tokens.
            const r = await oAuth2Client.getToken(code);
            // Make sure to set the credentials on the OAuth2 client.
            oAuth2Client.setCredentials(r.tokens);
            console.info('Tokens acquired.');
            resolve(oAuth2Client);
          }
        } catch (e) {
          reject(e);
        }
      })
      .listen(3000, () => {
        // open the browser to the authorize url to start the workflow
        open(authorizeUrl, {wait: false}).then(cp => cp.unref());
      });
    destroyer(server);
  });
}

main().catch(console.error);

Edit

Another example for cloud run.

// sample-metadata:
//   title: ID Tokens for Cloud Run
//   description: Requests a Cloud Run URL with an ID Token.
//   usage: node idtokens-cloudrun.js <url> [<target-audience>]

'use strict';

function main(
  url = 'https://service-1234-uc.a.run.app',
  targetAudience = null
) {
  // [START google_auth_idtoken_cloudrun]
  /**
   * TODO(developer): Uncomment these variables before running the sample.
   */
  // const url = 'https://YOUR_CLOUD_RUN_URL.run.app';
  const {GoogleAuth} = require('google-auth-library');
  const auth = new GoogleAuth();

  async function request() {
    if (!targetAudience) {
      // Use the request URL hostname as the target audience for Cloud Run requests
      const {URL} = require('url');
      targetAudience = new URL(url).origin;
    }
    console.info(
      `request Cloud Run ${url} with target audience ${targetAudience}`
    );
    const client = await auth.getIdTokenClient(targetAudience);
    const res = await client.request({url});
    console.info(res.data);
  }

  request().catch(err => {
    console.error(err.message);
    process.exitCode = 1;
  });
  // [END google_auth_idtoken_cloudrun]
}

const args = process.argv.slice(2);
main(...args);
1
votes

For those of you out there that do not want to waste a full days worth of work because of the lack of documentation. Here is the accepted answer in today's world since the JWT class does not accept an audience in the constructor anymore.

import { JWT } from "google-auth-library"

const client = new JWT({
  forceRefreshOnFailure: true,
  key: service_account.private_key,
  email: service_account.client_email,
})

const token = await client.fetchIdToken("cloud run endpoint")
const { data } = await axios.post("cloud run endpoint"/path, payload, {
  headers: {
    Authorization: `Bearer ${token}`
  }
}) 

return data