gcp

Authenticating using Workload Identity Federation to Cloud Run, Cloud Functions

2022-02-11

This tutorial and code samples cover how customers that use Workload identity federation can authenticate to those GCP Services like Cloud Run and Cloud Functions which accept OpenID Connect (OIDC) tokens.

Basically, this walks you through a self-contained setup where you will:

  1. Configure a fake OIDC identity “provider” that just happens to run on Cloud Run which returns fake OIDC tokens (eg. ambient credentials). Pretend this is your on-prem OIDC system.
  2. Configure GCP Workload Identity with that OIDC provider
  3. Deploy a Cloud Run application which requires Authentication.
  4. Use Workload Federation and IAM API to exchange the fake OIDC token from step 1 for a Google-issued OIDC token
  5. Use google id_token from step 4 to access the secure Cloud Run application.

A brief background:

This article combines both where you can use your ambient workload credentials to authenticate to Cloud Run.

  • Why is this different than plain plain Workload Federation?

    Well, the token returned by GCP for workload federation is an oauth2 access_token intended to be used with GCP services

    The token we actually need to access cloud run is an id_token

  • So how do we exchange an access_token for an id_token?

    Well, we don’t really use an access_token here and instead take a shortcut:

    Since we need to invoke projects.serviceAccounts.generateAccessToken and that endpoint supports using federated token returned by GCP’s STS server, we can skip using an access_token.

    We can use the federated token because the iamcredentials API happens to support that direct token type.

    What this means is there is no need to set impersonation again by setting the service_account_impersonation_url parameter in the ADC config file.

    That IAM endpoint will generate a google-issued id_token with the user-specified audience that matches the Cloud Run service.

images/flow.png


Other usecases

  • gRPC: If what your ultimately interested in is an id_token for gRPC, follow the step to get an id_token and then supply that using gRPC Authentication with Google OpenID Connect tokens

  • SignedURL If what you really want to use do is issue GCS Signed URLs using workload identity, you need to invoke projects.serviceAccounts.signBlob using the correct payload.

    Ideally, customers should not have to manually invoke the signBlob() and then reconstruct the specific protocol to sign the URL. This is manual signing is described here and is certainly a pain. This capability to signURLs should be included in the various google-auth libraries. For example, the federated Credential provider in google-auth-python is external_account.py and that construct is missing the credentials.Signing interface (class Credentials(..., credentials.Signing):). For golang, you can invoke a signer func inline like this. You will also need to allow the federated principal:// or principalSet:// the roles/iam.serivceAccountTokenCreator on the service account (to allow it to signBlob())

  • Domain-wide Delegation: If what want to do is issue a domain-wide delegated credentials (which IMO should be really rare), use projects.serviceAccounts.signJwt with the JWT payload and sub field for the new identity.

For more info see Using Federated Tokens to sign signJWT or get gcp idtokens


You can find the source here


Setup

As mentioned, there are several preliminary steps involved here which are numbered accordingly.

You will need a GCP project and access to deploy to cloud run (ofcourse)

export PROJECT_ID=`gcloud config get-value core/project`
export PROJECT_NUMBER=`gcloud projects describe $PROJECT_ID --format='value(projectNumber)'`

## Create service account that the google_issued id_token will use
gcloud iam service-accounts create oidc-federated

1. Fake OIDC server

The following will build and deploy the fake OIDC server and allow the service account associated with the federated identity to access the service

cd idp_server/

gcloud run deploy   --platform=managed  \
 --region=us-central1   --allow-unauthenticated  \
 --source .   idp-on-cloud-run

The fake oidc server’s

  • /token endpoint will sign whatever well-formed JSON file is sent via POST

  • /.well-known/openid-configuration endpoint will print out the standard oidc configuration

  • /certs will return the JWK formatted public key used for JWT verification

gcloud run services describe idp-on-cloud-run --format="value(status.url)"

# export some environment variables
export URL=`gcloud run services describe idp-on-cloud-run --format="value(status.url)"`
export IAT=`date -u +%s`
export EXP=`date -u +%s -d "+3600 seconds"`
export EMAIL="alice@domain.com"
export SUB="alice@domain.com"
export ISS=$URL
export NAME="alice"
export WF_AUD="https://iam.googleapis.com/projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/oidc-pool-run/providers/oidc-provider-1"

## print a sample fake id_token 

curl -s -X POST -d "{  \"name\":\"$NAME\",  \"isadmin\":\"true\", \"mygroups\": [ \"group1\",\"group2\"], \"iss\":\"$ISS\", \"aud\":\"$WF_AUD\",  \"sub\":\"$SUB\", \"iat\":$IAT, \"exp\":$EXP,  \"email\":\"$EMAIL\",  \"email_verified\":true }"  $URL/token  > /tmp/oidccred.txt

You can view the contents of the JWT by decoding /tmp/oidccred.txt using jwt.io debugger

A sample JWT may look like

{
  "alg": "RS256",
  "kid": "123456",
  "typ": "JWT"
}
{
  "aud": "https://iam.googleapis.com/projects/1071284184436/locations/global/workloadIdentityPools/oidc-pool-run/providers/oidc-provider-1",
  "email": "alice@domain.com",
  "email_verified": true,
  "exp": 1644598160,
  "iat": 1644594560,
  "isadmin": "true",
  "iss": "https://idp-on-cloud-run-6w42z6vi3q-uc.a.run.app",
  "mygroups": [
    "group1",
    "group2"
  ],
  "name": "alice",
  "sub": "alice@domain.com"
}

2. Configure Workload Identity

Now configure workload identity to use the fake oidc server’s JWT’s

gcloud iam workload-identity-pools create oidc-pool-run  \
   --location="global" \
   --description="OIDC Pool"  \
   --display-name="OIDC Pool" \
   --project $PROJECT_ID


gcloud iam workload-identity-pools providers create-oidc oidc-provider-1  \
     --workload-identity-pool="oidc-pool-run"     --issuer-uri="$ISS"   \
      --location="global"  \
      --attribute-mapping="google.subject=assertion.sub,attribute.isadmin=assertion.isadmin,attribute.aud=assertion.aud" \
      --attribute-condition="attribute.isadmin=='true'" \
      --project $PROJECT_ID

# write the GOOGLE_APPLICATION_CREDENTIAL file to /tmp/sts.creds
gcloud iam workload-identity-pools create-cred-config \
     projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/oidc-pool-run/providers/oidc-provider-1  \
     --output-file=/tmp/sts-creds.json  \
     --credential-source-file=/tmp/oidccred.txt


## Associate the federated identity (alice@domain.com) the permission to impersonate the service account
gcloud iam service-accounts add-iam-policy-binding oidc-federated@$PROJECT_ID.iam.gserviceaccount.com \
    --role roles/iam.workloadIdentityUser \
    --member "principal://iam.googleapis.com/projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/oidc-pool-run/subject/alice@domain.com"

Note that the /tmp/sts-creds.json file does NOT specify a service account since IAMCredentials API support direct federation. Also note that were instructing the credential to find the ambient credential set at /tmp/oidccred.txt

{
  "type": "external_account",
  "audience": "//iam.googleapis.com/projects/1071284184436/locations/global/workloadIdentityPools/oidc-pool-run/providers/oidc-provider-1",
  "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
  "token_url": "https://sts.googleapis.com/v1/token",
  "credential_source": {
    "file": "/tmp/oidccred.txt"
  }
}

If you intend to access any other GCP service like GCE or GCS within the same application where you access Cloud Run, please specify service_account_impersonation_url parameter since those services do not support federated tokens yet and require the actual access_token. Your can do that (by setting --service-account to create-cred-config command). This article focuses on just accessing cloud run and acquiring an id_token so the abridged flow using the federated token is shown

If you’re on the cloud console, your configuration should look like

images/provider_details.png

Also note that roles/iam.workloadIdentityUser includes the following permissions:

iam.serviceAccounts.get
iam.serviceAccounts.getAccessToken
iam.serviceAccounts.getOpenIdToken
iam.serviceAccounts.list

But all you need is just the iam.serviceAccounts.getOpenIdToken permission…which means the role is over-privleged. You may want to just instead use a custom role with the required iam.serviceAccounts.getOpenIdToken permission. Its similar to

See Concentric IAMCredentials Permissions: The secret life of signBlob. If you just need to generate an id_token, consider generating a custom role with that specific capability.

3. Deploy secure Cloud Run application

Now deploy an application that is secure. This is the app we want to access using the google id_token

cd server

gcloud run deploy   --platform=managed \
   --region=us-central1  \
   --no-allow-unauthenticated \
   --source .   federated-auth-cloud-run

export TEST_URL=`gcloud run services describe federated-auth-cloud-run --format="value(status.url)"`

# verify that you can't access the service
curl -s $TEST_URL

# allow the service account we will eventually use for access to the cloud run service
gcloud run services add-iam-policy-binding federated-auth-cloud-run \
  --member="serviceAccount:oidc-federated@$PROJECT_ID.iam.gserviceaccount.com" \
  --role="roles/run.invoker"

4. Use Workload Federation and IAM API to create a google id_token

GCP Auth libraries automatically ‘understand’ the specific external identity provided by federation.

For us, export the variable

export GOOGLE_APPLICATION_CREDENTIALS=/tmp/sts-creds.json 

Then when application default is used to call generateIdToken()….well,

see the flow described here

5. Use google id_token to access cloud_run

Now that we have a Google-issued id_token, use that to access Cloud Run

The full flow in python is like this"

#!/usr/bin/python
import jwt

from google.auth import credentials
from google.cloud import  iam_credentials_v1

import google.auth
import google.oauth2.credentials

from google.auth.transport.requests import AuthorizedSession, Request

url = "https://federated-auth-cloud-run-6w42z6vi3q-uc.a.run.app/dump"
aud = "https://federated-auth-cloud-run-6w42z6vi3q-uc.a.run.app"
service_account = 'oidc-federated@your_project.iam.gserviceaccount.com'

## After this point ADC should run its course, exchange the fake oidc creds
##  for a federated token (NOT oauth2 access_token since we skipped service_account_impersonation_url setting)
##  the federated token is then used to invoke the IAM api which returns the final id_token we're interested in
client = iam_credentials_v1.services.iam_credentials.IAMCredentialsClient()

name = "projects/-/serviceAccounts/{}".format(service_account)
id_token = client.generate_id_token(name=name,audience=aud, include_email=True)

print(id_token.token)

r = jwt.decode(id_token, options={"verify_signature": False})
print("token expires at")
print(r["exp"])

creds = google.oauth2.credentials.Credentials(id_token.token)
authed_session = AuthorizedSession(creds)
r = authed_session.get(url)
print(r.status_code)
print(r.text)

Test Clients

This repo woudn’t be a repo without samples, see the clients/ folder for a language of your choice.

Note that for each, you must edit the source file and specify the url audience and service account you used.

Also remember to set export GOOGLE_APPLICATION_CREDENTIALS=/tmp/sts-creds.json

#!/usr/bin/python
from google.auth import credentials
from google.cloud import  iam_credentials_v1

import google.auth
import google.oauth2.credentials

from google.auth.transport.requests import AuthorizedSession, Request

url = "https://federated-auth-cloud-run-6w42z6vi3q-uc.a.run.app/dump"
aud = "https://federated-auth-cloud-run-6w42z6vi3q-uc.a.run.app"
service_account = 'oidc-federated@your_project.iam.gserviceaccount.com'

client = iam_credentials_v1.services.iam_credentials.IAMCredentialsClient()

name = "projects/-/serviceAccounts/{}".format(service_account)
id_token = client.generate_id_token(name=name,audience=aud, include_email=True)

print(id_token.token)

creds = google.oauth2.credentials.Credentials(id_token.token)
authed_session = AuthorizedSession(creds)
r = authed_session.get(url)
print(r.status_code)
print(r.text)
package main

import (
	"context"
	"fmt"
	"io/ioutil"
	"log"
	"net/http"

	credentials "cloud.google.com/go/iam/credentials/apiv1"
	credentialspb "google.golang.org/genproto/googleapis/iam/credentials/v1"
)

var ()

const (
	url            = "https://federated-auth-cloud-run-6w42z6vi3q-uc.a.run.app/dump"
	aud            = "https://federated-auth-cloud-run-6w42z6vi3q-uc.a.run.app"
	serviceAccount = "oidc-federated@your_project.iam.gserviceaccount.com"
)

func main() {
	ctx := context.Background()

	c, err := credentials.NewIamCredentialsClient(ctx)
	if err != nil {
		log.Fatalf("%v", err)
	}
	defer c.Close()

	idreq := &credentialspb.GenerateIdTokenRequest{
		Name:         fmt.Sprintf("projects/-/serviceAccounts/%s", serviceAccount),
		Audience:     aud,
		IncludeEmail: true,
	}
	idresp, err := c.GenerateIdToken(ctx, idreq)
	if err != nil {
		log.Fatalf("%v", err)
	}

	log.Printf("IdToken %v", idresp.Token)

	req, err := http.NewRequest(http.MethodGet, url, nil)
	if err != nil {
		log.Fatalf("%v", err)
	}
	req.Header.Add("Authorization", "Bearer "+idresp.Token)

	client := &http.Client{}
	resp, err := client.Do(req)
	if err != nil {
		log.Println("Error on response.\n[ERROR] -", err)
	}
	defer resp.Body.Close()

	body, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		log.Println("Error while reading the response bytes:", err)
	}
	log.Println(string([]byte(body)))

}
package com.test;

import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;

import com.google.cloud.iam.credentials.v1.GenerateIdTokenRequest;
import com.google.cloud.iam.credentials.v1.GenerateIdTokenResponse;
import com.google.cloud.iam.credentials.v1.IamCredentialsClient;
import com.google.cloud.iam.credentials.v1.ServiceAccountName;

public class TestApp {
	public static void main(String[] args) {
		TestApp tc = new TestApp();
	}

	private static String url = "https://federated-auth-cloud-run-6w42z6vi3q-uc.a.run.app/dump";
	private static String aud = "https://federated-auth-cloud-run-6w42z6vi3q-uc.a.run.app";
	private static String serviceAccount = "oidc-federated@your_project.iam.gserviceaccount.com";

	public TestApp() {
		try {

			// mvn -D"GOOGLE_APPLICATION_CREDENTIALS=/tmp/sts-creds.json" clean install exec:java -q
			
			IamCredentialsClient iamCredentialsClient = IamCredentialsClient.create();

			String name = ServiceAccountName.of("-", serviceAccount).toString();

			GenerateIdTokenRequest idrequest = GenerateIdTokenRequest.newBuilder().setName(name).setAudience(aud)
					.setIncludeEmail(true).build();
			GenerateIdTokenResponse idresponse = iamCredentialsClient.generateIdToken(idrequest);
			System.out.println("IDToken " + idresponse.getToken());

			URL u = new URL(url);
			HttpURLConnection conn = (HttpURLConnection) u.openConnection();

			conn.setRequestProperty("Authorization", "Bearer " + idresponse.getToken());
			conn.setRequestMethod("GET");

			System.out.println("Response Code: " + conn.getResponseCode());

		} catch (Exception ex) {
			System.out.println("Error:  " + ex.getMessage());
		}
	}

}

const { IAMCredentialsClient } = require('@google-cloud/iam-credentials');
const axios = require('axios');

async function main() {

  const url = "https://federated-auth-cloud-run-6w42z6vi3q-uc.a.run.app/dump";
  const aud = "https://federated-auth-cloud-run-6w42z6vi3q-uc.a.run.app";
  const serviceAccount = "oidc-federated@your_project.iam.gserviceaccount.com";


  const iam_client = new IAMCredentialsClient();
  const [resp] = await iam_client.generateIdToken({
    name: `projects/-/serviceAccounts/${serviceAccount}`,
    audience: aud,
    includeEmail: true
  });
  console.info(resp.token);

  axios.get(url, {
    headers: {
      'Authorization': `Bearer ${resp.token}`
    }
  })
    .then((res) => {
      console.log(res.data)
    })
    .catch((error) => {
      console.error(error)
    })
}

main().catch(console.error);

not supported

using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;

using Google.Apis.Auth;
using Google.Apis.Http;
using Google.Apis.Auth.OAuth2;
using System.Net.Http;
using System.Net.Http.Headers;
using Google.Cloud.Iam.Credentials.V1;

//  ERROR: Error reading credential file from location /tmp/sts-creds.json: Error creating credential from JSON. Unrecognized credential type external_account.
//   Please check the value of the Environment Variable GOOGLE_APPLICATION_CREDENTIALS

namespace Program
{
    public class Program
    {
        [STAThread]
        static void Main(string[] args)
        {
            try
            {
                new Program().Run().Wait();
            }
            catch (AggregateException ex)
            {
                foreach (var err in ex.InnerExceptions)
                {
                    Console.WriteLine("ERROR: " + err.Message);
                }
            }
        }

        public async Task<string> Run()
        {

            string url = "https://federated-auth-cloud-run-6w42z6vi3q-uc.a.run.app/dump";
            string aud = "https://federated-auth-cloud-run-6w42z6vi3q-uc.a.run.app";
            string serviceAccount = "oidc-federated@your_project.iam.gserviceaccount.com";

            GoogleCredential sourceCredential = await GoogleCredential.GetApplicationDefaultAsync();

            IAMCredentialsClient client = IAMCredentialsClient.Create();
            GenerateIdTokenResponse resp = client.GenerateIdToken(new GenerateIdTokenRequest()
            {
                Name = "projects/-/serviceAccounts/" + serviceAccount,
                Audience = aud,
                IncludeEmail = true
            });

            Console.WriteLine("ID Token " + resp.Token);

            using (var httpClient = new HttpClient())
            {               
                httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", resp.Token);
                string response = await httpClient.GetStringAsync(url).ConfigureAwait(false);
                Console.WriteLine(response);
                return response;
            }
        }
    }
}

This site supports webmentions. Send me a mention via this form.