Skip to content

Instantly share code, notes, and snippets.

@wvanderdeijl
Last active June 27, 2024 06:24
Show Gist options
  • Save wvanderdeijl/c5dc4a52564be477e0200cfd3f4e45dd to your computer and use it in GitHub Desktop.
Save wvanderdeijl/c5dc4a52564be477e0200cfd3f4e45dd to your computer and use it in GitHub Desktop.
Using an Azure Service Principal as federated identity to Google Cloud Platform

Azure Service Principal to Google Cloud Federation

This is an example how to use an Azure Service Principal identity to access Google Cloud resources using Workload Identity Federation. Google documentation on workload identity federation from Azure can be found at https://cloud.google.com/iam/docs/workload-identity-federation-with-other-clouds#azure and Azure documentation on service principals can be found at https://learn.microsoft.com/en-au/entra/identity-platform/howto-create-service-principal-portal#register-an-application-with-azure-ad-and-create-a-service-principal

Preparation

Start by creating an Azure application:

az ad app create --display-name google-cloud-federation \
    --enable-access-token-issuance false \
    --enable-id-token-issuance false
CLIENT_ID=201ec55d-855a-4c68-abb4-09f2c3a2f3fb # remember the `appId` (also known as clientId) property from the response

Now, create a service principal (identity) for the app:

az ad sp create --id $CLIENT_ID
SERVICE_PRINCIPAL=038b5b0c-b3b6-48dc-92b3-40f4782cb8f0 # remember the `id` of the created principal. This is the identity that GCP will see.

When using this service principal from Azure infrastructure you can attach that service principal to the Virtual Machine and get access to the service principal that way. When not running on Azure infrastructure, you'll need some sort of secret to identify yourself as this service principal. This can through certificates or a secret. See https://learn.microsoft.com/en-au/entra/identity-platform/howto-create-service-principal-portal#set-up-authentication on how to set these up and the advice to use certificates. When using a client secret it has an expiration date that cannot be more than 2 years in the future and you need to make sure you rotate these secrets in time.

Next, setup a Google Cloud Workload Identity Federation Pool and Provider to allow the exchange of a Microsoft Azure access token for this service principal to a Google access token:

GCP_PROJECT_ID=my-project
GCP_PROJECT_NUMBER=999999999999
FEDERATION_POOL=azure-identities
FEDERATION_PROVIDER=my-provider
AZURE_TENANT_ID=b122d479-2bd5-4fcd-b7a4-320a85b8a502
gcloud iam workload-identity-pools create $FEDERATION_POOL \
    --location=global \
    --project=$GCP_PROJECT_ID
# issuer-uri is the basis for getting the `.well-known/openid-configuration` config file with the public keys
# we try to be as strict as possible with addition attribute conditions. These assume we get a v1.0 azure token (claims in v2 tokens are different)
# see https://learn.microsoft.com/en-us/entra/identity-platform/access-token-claims-reference for claims in the token
gcloud iam workload-identity-pools providers create-oidc $FEDERATION_PROVIDER \
    --location=global \
    --project=$GCP_PROJECT_ID \
    --workload-identity-pool=$FEDERATION_POOL \
    --attribute-mapping="google.subject=assertion.sub" \
    --attribute-condition="assertion.appid=='${CLIENT_ID}' && assertion.oid=='${SERVICE_PRINCIPAL}'" \
    --issuer-uri="https://sts.windows.net/${AZURE_TENANT_ID}/" \
    --allowed-audiences=$CLIENT_ID

# Create a configuration file for client libraries that uses a (local) URL to get the microsoft token. Note that this configuration is much
# simpler when running on Azure public cloud as the client library can then automatically retrieve the microsoft token. But this example is
# for running on-premise and still use an Azure service principal. You will then need to supply a listener at http://localhost:9999 that
# gets the microsoft token.
gcloud iam workload-identity-pools create-cred-config \
    projects/$GCP_PROJECT_NUMBER/locations/global/workloadIdentityPools/$FEDERATION_POOL/providers/$FEDERATION_PROVIDER \
    --credential-source-url=http://localhost:9999 \
    --output-file=credentials.json

As an example, we're also create a Google Cloud storage bucket where the federated identity is allowed to read files:

BUCKET_NAME=my-bucket-174646274
gcloud storage buckets create gs://$BUCKET_NAME \
    --project=$GCP_PROJECT_ID \
    --location=europe-west4 \
    --public-access-prevention

# upload an empty file for testing
touch empty.txt
gcloud storage cp empty.txt gs://$BUCKET_NAME

# grant objectViewer role to the azure service principal when it's identity is asserted through one of the identity providers of the
# workload identity federation pool
gcloud storage buckets add-iam-policy-binding gs://$BUCKET_NAME \
    --member=principal://iam.googleapis.com/projects/$GCP_PROJECT_NUMBER/locations/global/workloadIdentityPools/$FEDERATION_POOL/subject/$SERVICE_PRINCIPAL \
    --role=roles/storage.objectViewer

Some Google Cloud services (such as Cloud Storage) allow direct access using these federated principals. But for some other Google Cloud services it will be necessary to first impersonate a Google Cloud Service Account with this federated principal and then use that service account to access Google Cloud resources. Create such a service account if needed:

GCP_SERVICE_ACCOUNT=my-account
GCP_SERVICE_ACCOUNT_EMAIL=$GCP_SERVICE_ACCOUNT@$GCP_PROJECT_ID.iam.gserviceaccount.com
# create the service account
gcloud iam service-accounts create $GCP_SERVICE_ACCOUNT --project $GCP_PROJECT_ID
# allow the federated identity to impersonate the service account
gcloud iam service-accounts add-iam-policy-binding $GCP_SERVICE_ACCOUNT_EMAIL \
    --project=$GCP_PROJECT_ID \
    --member=principal://iam.googleapis.com/projects/$GCP_PROJECT_NUMBER/locations/global/workloadIdentityPools/$FEDERATION_POOL/subject/$SERVICE_PRINCIPAL \
    --role=roles/iam.serviceAccountTokenCreator

Accessing Google resources using an Azure identity

First get an access token from Microsoft Azure. This example uses a client secret, but https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-client-creds-grant-flow also explains how to do this with a certificate.

CLIENT_SECRET=ARR8Q~hOj94eGgGJWgaJYTtxMFnKjA6zi~5-HbQ1
AZURE_ACCESS_TOKEN=$(curl -X POST \
    --url "https://login.microsoftonline.com/$AZURE_TENANT_ID/oauth2/v2.0/token" \
    --header 'content-type: application/x-www-form-urlencoded' \
    --data-urlencode "scope=$CLIENT_ID/.default" \
    --data-urlencode "grant_type=client_credentials" \
    --data-urlencode "client_id=$CLIENT_ID" \
    --data-urlencode "client_secret=$CLIENT_SECRET" | jq --raw-output '.access_token')
echo $AZURE_ACCESS_TOKEN

Once you have this Microsoft Azure access token, you can exchange it for a Google Cloud access token. Look at the curl response for how long this token is valid (typically a little less than 1 hour).

GOOGLE_ACCESS_TOKEN=$(curl https://sts.googleapis.com/v1/token \
    --data-urlencode "audience=//iam.googleapis.com/projects/$GCP_PROJECT_NUMBER/locations/global/workloadIdentityPools/$FEDERATION_POOL/providers/$FEDERATION_PROVIDER" \
    --data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
    --data-urlencode "requested_token_type=urn:ietf:params:oauth:token-type:access_token" \
    --data-urlencode "scope=https://www.googleapis.com/auth/cloud-platform" \
    --data-urlencode "subject_token_type=urn:ietf:params:oauth:token-type:jwt" \
    --data-urlencode "subject_token=$AZURE_ACCESS_TOKEN" | jq --raw-output '.access_token')
echo $GOOGLE_ACCESS_TOKEN

You can now use this Google Cloud access token to access Google Cloud resources:

curl https://storage.googleapis.com/storage/v1/b/$BUCKET_NAME/o \
    --header "authorization: Bearer $GOOGLE_ACCESS_TOKEN"

The examples above show how to invoke Google Cloud API's using raw http commands which gives an understanding of what is happening when using federated identities. But in real life, you probably want to use a Google Client Library to access resources. Most (if not all) Google Cloud client library have support for federated identities. They can get the initial credentials from the AWS or Azure environment they are running in. When not using these public cloud providers, you can supply a URL, file or executable where the client library can get a fresh access token. It is then your responsibility to get a fresh (Azure) token when this URL or executable is invoked.

Client libraries can get their configuration from an environment variable GOOGLE_APPLICATION_CREDENTIALS which points to a configuration file that was created using the gcloud iam workload-identity-pools create-cred-config cli tool. Alternatively you can also supply this configuration at runtime as an argument to the client library. For example:

import { GoogleAuth } from 'google-auth-library';
import http from 'http';
import assert from 'node:assert';

// Start a local server that can fetch a microsoft token and supply it to the google client library. Downside is that anything running
// on localhost can now get a token by making a request to this URL.
const server = http.createServer(async (_req, res) => {
    // getting microsoft access token
    const ms = await fetch(`https://login.microsoftonline.com/${AZURE_TENANT_ID}/oauth2/v2.0/token`, {
        method: 'POST',
        headers: { 'content-type': 'application/x-www-form-urlencoded' },
        body: new URLSearchParams({
            scope: `${CLIENT_ID}/.default`,
            grant_type: 'client_credentials',
            client_id: CLIENT_ID,
            client_secret: CLIENT_SECRET,
        }),
    });
    assert(ms.ok);
    const msToken: { access_token: string } = await ms.json();

    res.statusCode = 200;
    res.end(msToken.access_token);
});
server.listen(9999, '127.0.0.1');

// create a GoogleAuth client with explicit configuration. This step is not needed if the configuration is stored in a file and the
// `GOOGLE_APPLICATION_CREDENTIALS` environment variable contains the path to that file.
const authClient = new GoogleAuth({
    credentials: {
        universe_domain: 'googleapis.com',
        type: 'external_account',
        audience: '//iam.googleapis.com/projects/168746596880/locations/global/workloadIdentityPools/azure-pool/providers/my-provider',
        subject_token_type: 'urn:ietf:params:oauth:token-type:jwt',
        token_url: 'https://sts.googleapis.com/v1/token',
        credential_source: {
            url: 'http://localhost:9999',
        },
        token_info_url: 'https://sts.googleapis.com/v1/introspect',
    },
});

const storage = new Storage({ authClient }); // can be simply `new Storage()` when using the `GOOGLE_APPLICATION_CREDENTIALS` env var
const bucket = storage.bucket(BUCKET_NAME);
const [files] = await bucket.getFiles();
console.log(files.map(f => f.name));

When not running in a public cloud provider (such as AWS or Azure) it can be cumbersome (or insecure) to host a URL or executable that can get a fresh token. Depending on your programming language it might be easier to get the token from code. An example in Node.js:

import { OAuth2Client } from 'google-auth-library';
import assert from 'node:assert';

const authClient = new OAuth2Client();
// set a custom refreshHandler that knows how to get a Google Access token (using federation from an Azure Service Principal)
authClient.refreshHandler = async () => {
    // getting microsoft access token
    const ms = await fetch(`https://login.microsoftonline.com/${AZURE_TENANT_ID}/oauth2/v2.0/token`, {
        method: 'POST',
        headers: { 'content-type': 'application/x-www-form-urlencoded' },
        body: new URLSearchParams({
            scope: `${CLIENT_ID}/.default`,
            grant_type: 'client_credentials',
            client_id: CLIENT_ID,
            client_secret: CLIENT_SECRET,
        }),
    });
    assert(ms.ok);
    const msToken: { access_token: string } = await ms.json();

    // getting google token
    const g = await fetch('https://sts.googleapis.com/v1/token', {
        method: 'POST',
        headers: { 'content-type': 'application/x-www-form-urlencoded' },
        body: new URLSearchParams({
            audience: `//iam.googleapis.com/projects/${GCP_PROJECT_NUMBER}/locations/global/workloadIdentityPools/${FEDERATION_POOL}/providers/${FEDERATION_PROVIDER}`,
            grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
            requested_token_type: 'urn:ietf:params:oauth:token-type:access_token',
            scope: 'https://www.googleapis.com/auth/cloud-platform',
            subject_token_type: 'urn:ietf:params:oauth:token-type:jwt',
            subject_token: msToken.access_token,
        }),
    });
    assert(g.ok);
    const gToken: { access_token: string; expires_in: number } = await g.json();

    return {
        access_token: gToken.access_token,
        expiry_date: new Date().getTime() + gToken.expires_in * 1000,
    };
};

const storage = new Storage({ authClient });
const bucket = storage.bucket(BUCKET_NAME);
const [files] = await bucket.getFiles();
console.log(files.map(f => f.name));

Full Google Service Account impersonation

The previous examples showed how to use a Microsoft Access Token and how to exchange that for a Google Access Token and then using that token to interact with Google Cloud Platform. This does not work for all Google Cloud Platform services and sometimes an additional step is needed where the Google Access Token for the federated identity is exchanged for another Google Access Token for a Google Service Account.

An example using curl:

AZURE_ACCESS_TOKEN=$(curl -X POST \
    --url "https://login.microsoftonline.com/$AZURE_TENANT_ID/oauth2/v2.0/token" \
    --header 'content-type: application/x-www-form-urlencoded' \
    --data-urlencode "scope=$CLIENT_ID/.default" \
    --data-urlencode "grant_type=client_credentials" \
    --data-urlencode "client_id=$CLIENT_ID" \
    --data-urlencode "client_secret=$CLIENT_SECRET" | jq --raw-output '.access_token')
echo $AZURE_ACCESS_TOKEN

GOOGLE_ACCESS_TOKEN=$(curl https://sts.googleapis.com/v1/token \
    --data-urlencode "audience=//iam.googleapis.com/projects/$GCP_PROJECT_NUMBER/locations/global/workloadIdentityPools/$FEDERATION_POOL/providers/$FEDERATION_PROVIDER" \
    --data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
    --data-urlencode "requested_token_type=urn:ietf:params:oauth:token-type:access_token" \
    --data-urlencode "scope=https://www.googleapis.com/auth/cloud-platform" \
    --data-urlencode "subject_token_type=urn:ietf:params:oauth:token-type:jwt" \
    --data-urlencode "subject_token=$AZURE_ACCESS_TOKEN" | jq --raw-output '.access_token')
echo $GOOGLE_ACCESS_TOKEN

# exchange the google token for the federated identity for another google access token (for the impersonated google service account)
curl -X POST \
    https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${GCP_SERVICE_ACCOUNT_EMAIL}:generateAccessToken \
    --header 'content-type: application/json' \
    --header "authorization: Bearer $GOOGLE_ACCESS_TOKEN" \
    --data '{"scope":["https://www.googleapis.com/auth/cloud-platform"]}'

When using this from a GoogleAuth client you only need to add service_account_impersonation_url to the config:

const authClient = new GoogleAuth({
    credentials: {
        universe_domain: 'googleapis.com',
        type: 'external_account',
        audience: '//iam.googleapis.com/projects/168746596880/locations/global/workloadIdentityPools/azure-pool/providers/my-provider',
        subject_token_type: 'urn:ietf:params:oauth:token-type:jwt',
        token_url: 'https://sts.googleapis.com/v1/token',
        credential_source: {
            url: 'http://localhost:9999',
        },
        token_info_url: 'https://sts.googleapis.com/v1/introspect',
        service_account_impersonation_url: `https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${GCP_SERVICE_ACCOUNT_EMAIL}:generateAccessToken`,
    },
});

When implemeting your own handler for fetching an access token, this now becomes a three step process (first a microsoft token, then a google token for the federated identity and then a google token for the service account):

authClient.refreshHandler = async () => {
    // getting microsoft access token
    const ms = await fetch(`https://login.microsoftonline.com/${AZURE_TENANT_ID}/oauth2/v2.0/token`, {
        method: 'POST',
        headers: { 'content-type': 'application/x-www-form-urlencoded' },
        body: new URLSearchParams({
            scope: `${CLIENT_ID}/.default`,
            grant_type: 'client_credentials',
            client_id: CLIENT_ID,
            client_secret: CLIENT_SECRET,
        }),
    });
    assert(ms.ok);
    const msToken: { access_token: string } = await ms.json();

    // getting google token for federated identity
    const g = await fetch('https://sts.googleapis.com/v1/token', {
        method: 'POST',
        headers: { 'content-type': 'application/x-www-form-urlencoded' },
        body: new URLSearchParams({
            audience: `//iam.googleapis.com/projects/${GCP_PROJECT_NUMBER}/locations/global/workloadIdentityPools/${FEDERATION_POOL}/providers/${FEDERATION_PROVIDER}`,
            grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
            requested_token_type: 'urn:ietf:params:oauth:token-type:access_token',
            scope: 'https://www.googleapis.com/auth/cloud-platform',
            subject_token_type: 'urn:ietf:params:oauth:token-type:jwt',
            subject_token: msToken.access_token,
        }),
    });
    assert(g.ok);
    const gToken: { access_token: string; expires_in: number } = await g.json();

    // getting google service account token
    const sa = await fetch(
        `https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${GCP_SERVICE_ACCOUNT_EMAIL}:generateAccessToken`,
        {
            method: 'POST',
            headers: {
                'content-type': 'application/json',
                'authorization': `Bearer ${gToken.access_token}`,
            },
            body: JSON.stringify({
                scope: ['https://www.googleapis.com/auth/cloud-platform'],
            }),
        },
    );
    assert(sa.ok);
    const saToken: { accessToken: string; expireTime: string } = await sa.json();

    return {
        access_token: saToken.accessToken,
        expiry_date: new Date(saToken.expireTime).getTime(),
    };
};

Tear down

Remove the Google Cloud resources

# remove the storage bucket and its content
gcloud storage rm gs://$BUCKET_NAME --recursive
# remove the identity pool (and its providers)
gcloud iam workload-identity-pools delete $FEDERATION_POOL --location=global --project=$GCP_PROJECT_ID
# remove the service account
gcloud iam service-accounts delete $GCP_SERVICE_ACCOUNT_EMAIL --project $GCP_PROJECT_ID

Remove the Azure service application (and automatically the associated service principal)

az ad app delete --id $CLIENT_ID

Other examples

This example is part of a larger serie of posts with examples of federation between different cloud environments.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment