7

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)
}
Janpan
  • 2,164
  • 3
  • 27
  • 53
  • 1
    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 Feb 17 '20 at 17:49
  • 1
    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 Feb 17 '20 at 17:49
  • 1
    One last item, edit your question and show how you are calling the endpoint in code. – John Hanley Feb 17 '20 at 18:01
  • @JohnHanley sure thing – Janpan Feb 17 '20 at 18:29
  • 1
    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 Feb 17 '20 at 19:55
  • @john, thanks, I did not see your first comments there, only saw the last comment for some reason. Thanks, I will try those this morning. – Janpan Feb 18 '20 at 05:30

3 Answers3

10

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)
                    }
                }
            );
        }
    })
Jordan Arsenault
  • 7,100
  • 8
  • 53
  • 96
John Hanley
  • 74,467
  • 6
  • 95
  • 159
  • Thanks @John, I will try this and let you know the outcome ! – Janpan Feb 18 '20 at 05:27
  • 1
    Thanks @John Hanley, this solution worked. The funny thing is that I did try this before, however instead of the Google cloud run generated url, I used the google cloud global platform scope url, which according to their documentation provides full access to all google services. I never saw anything about the audience to be the cloud run generated url, since I even tried the cloud run custom domain without success. Thanks for your solution, it saved me a lot of time since there was no proper documentation for this specific use case ! – Janpan Feb 18 '20 at 18:06
  • hey can I do the same thing on browser, without touching server? – Inzamam Malik Jan 23 '21 at 00:01
  • 1
    @InzamamMalik What do you mean in a browser? My example uses node.js. You can setup node.js on your desktop and then just run it. If you mean create a web page in JavaScript, yes but I do not know about the library support required. I recommend creating a new question. – John Hanley Jan 23 '21 at 00:28
2

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

  • hey can I do the same thing on browser, without touching server? – Inzamam Malik Jan 23 '21 at 00:02
  • I do not see the reason why you wouldn't be able to load up the `google-auth-library` library on the browser but you don't want to do that because you would be leaking your `service_account.private_key` – Sebastian Serrano Jan 24 '21 at 01:11
  • thanks @Sebastian Serrano, I got the point, its all about keeping the credentials secret, and google made it dificult to prevent a developer like me to make mistakenly expose their redentials – Inzamam Malik Jan 24 '21 at 20:12
1

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);
Community
  • 1
  • 1
marian.vladoi
  • 7,663
  • 1
  • 15
  • 29
  • 1
    This is a good example, but for a different question. The question is using a service account. Your example is using user credentials. Different authorization flow. – John Hanley Feb 17 '20 at 17:40
  • Thanks @marian, however, I do not have the client secret. I suppose this could be a last resort, however, it does not solve my current issue as my objective is to use the service-account.json file with JWT for service to service auth. – Janpan Feb 17 '20 at 18:34
  • @marian.vladoi thanks, however the solution did not work. I tried the above JWT solution however, I get a 400 or 401 error depending on what I try (with scopes or without scopes, then requesting identity token with audience string, still does not work) – Janpan Feb 17 '20 at 19:29
  • 1
    The updated portion also does not apply to the question. Your code generates an Access Token. This does not work for services that require an Identity Token. They are different forms of authorization. – John Hanley Feb 17 '20 at 19:53