Last active
June 27, 2024 06:27
-
-
Save wvanderdeijl/28c736b55f7977baae1cf5d3f9aee377 to your computer and use it in GitHub Desktop.
Showcasing Google Cloud Workload Identity Federation from AWS using raw http requests
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// This example is part of a [larger serie of posts](https://gist.github.com/wvanderdeijl/734cc05dd2438a9946c396d714d5e83e) with | |
// examples of federation between different cloud environments. | |
import { Sha256 } from '@aws-crypto/sha256-universal'; // or @aws-crypto/sha256-js | |
import { AssumeRoleCommand, STSClient } from '@aws-sdk/client-sts'; | |
import { SignatureV4 } from '@aws-sdk/signature-v4'; | |
import axios from 'axios'; | |
const AWS_REGION = 'eu-west-1'; | |
const AWS_ROLE_ARN = 'arn:aws:iam::999999999999:role/my-federated-role'; | |
const GCP_IDENTITY_PROVIDER = '//iam.googleapis.com/projects/PROJECTNR/locations/global/workloadIdentityPools/POOL-ID/providers/PROVIDER-ID'; | |
const GCP_SERVICE_ACCOUNT = '[email protected]'; | |
(async () => { | |
try { | |
// use IAM user credentials to get temporary security credentials. Normally, this step is not necessary as using google workload | |
// identity federation from an AWS identity is intended to be used from AWS infrastructure that already has an attached IAM Role | |
const stsClient = new STSClient({ region: AWS_REGION }); | |
const sessionName = new Date().toISOString().replace(/[:.-]/g, ''); | |
const assumeRoleResponse = await stsClient.send(new AssumeRoleCommand({ RoleArn: AWS_ROLE_ARN, RoleSessionName: sessionName })); | |
// create a signer utility from the AWS SDK to create a Signature Version 4 (SigV4) | |
const signer = new SignatureV4({ | |
applyChecksum: false, // do not include a x-amz-content-sha256 header as google sts will throw a 400 when given unexpected headers | |
service: 'sts', | |
region: AWS_REGION, | |
sha256: Sha256, | |
credentials: { | |
// use the temporary security credentials we got from 'sts assume-role' | |
accessKeyId: assumeRoleResponse.Credentials.AccessKeyId, | |
secretAccessKey: assumeRoleResponse.Credentials.SecretAccessKey, | |
sessionToken: assumeRoleResponse.Credentials.SessionToken, | |
}, | |
}); | |
// Build a signed request to AWS STS to get our own identity (which is the assumed IAM Role), but do not execute this request. | |
// Instead, we are going to give this fully signed request to google so it can execute this request on our behalf as a means to | |
// prove our identity. | |
// Depending on your programming language and AWS SDK there might be better ways to build a SigV4 signed request. | |
const signedRequest = await signer.sign({ | |
method: 'POST', | |
protocol: 'https', // not used for signing | |
hostname: `sts.${AWS_REGION}.amazonaws.com`, // not used for signing | |
path: '/', | |
query: { | |
Action: 'GetCallerIdentity', | |
Version: '2011-06-15', | |
}, | |
headers: { | |
'host': `sts.${AWS_REGION}.amazonaws.com`, | |
'x-goog-cloud-target-resource': GCP_IDENTITY_PROVIDER, | |
}, | |
}); | |
console.log('signed request:'); | |
console.log(signedRequest); | |
/* | |
{ | |
method: 'POST', | |
protocol: 'https', | |
hostname: 'sts.eu-west-1.amazonaws.com', | |
path: '/', | |
headers: { | |
host: 'sts.eu-west-1.amazonaws.com', | |
'x-goog-cloud-target-resource': '//iam.googleapis.com/....', | |
'x-amz-date': '20210531T163536Z', | |
'x-amz-security-token': 'IQoJ....1h/u', | |
authorization: 'AWS4-HMAC-SHA256 Credential=ASIA....TYOH/20210531/eu-west-1/sts/aws4_request, SignedHeaders=host;x-amz-date;x-amz-security-token;x-goog-cloud-target-resource, Signature=187e....9d71' | |
}, | |
query: { Action: 'GetCallerIdentity', Version: '2011-06-15' } | |
} | |
*/ | |
// Convert the signed request to a format as google wants to receive it | |
// see https://cloud.google.com/iam/docs/access-resources-aws#exchange-token | |
const getCallerIdentityToken = { | |
// https://sts.${AWS_REGION}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15 | |
url: `${signedRequest.protocol}://${signedRequest.hostname}${signedRequest.path}?${new URLSearchParams( | |
signedRequest.query as Record<string, string>, | |
).toString()}`, | |
method: signedRequest.method, | |
// google wants to receive the headers as an array of objects with a key and value property | |
headers: Object.entries(signedRequest.headers).map(([key, value]) => ({ key, value })), | |
}; | |
console.log('-'.repeat(80)); | |
console.log('getCallerIdentityToken:'); | |
console.log(getCallerIdentityToken); | |
/* | |
{ | |
url: 'https://sts.eu-west-1.amazonaws.com/?Action=GetCallerIdentity&Version=2011-06-15', | |
method: 'POST', | |
headers: [ | |
{ key: 'host', value: 'sts.eu-west-1.amazonaws.com' }, | |
{ key: 'x-goog-cloud-target-resource', value: '//iam.googleapis.com/....' }, | |
{ key: 'x-amz-date', value: '20210531T163817Z' }, | |
{ key: 'x-amz-security-token', value: 'IQoJ....1h/u' }, | |
{ key: 'authorization', value: 'AWS4-HMAC-SHA256 Credential=ASIA....TYOH/20210531/eu-west-1/sts/aws4_request, SignedHeaders=host;x-amz-date;x-amz-security-token;x-goog-cloud-target-resource, Signature=187e....9d71' } | |
] | |
} | |
*/ | |
// Invoke google sts service to get a federated access token. | |
const federated = await axios.post<FederatedToken>('https://sts.googleapis.com/v1/token', { | |
audience: GCP_IDENTITY_PROVIDER, | |
grantType: 'urn:ietf:params:oauth:grant-type:token-exchange', | |
requestedTokenType: 'urn:ietf:params:oauth:token-type:access_token', | |
scope: 'https://www.googleapis.com/auth/cloud-platform', | |
subjectTokenType: 'urn:ietf:params:aws:token-type:aws4_request', | |
// send the aws sts request as an uri-encoded JSON object | |
subjectToken: encodeURIComponent(JSON.stringify(getCallerIdentityToken)), | |
}); | |
console.log('-'.repeat(80)); | |
console.log('federated access token:'); | |
console.log(federated.data); | |
/* | |
{ | |
access_token: 'ya29.d.Kt0E....iHPg', | |
issued_token_type: 'urn:ietf:params:oauth:token-type:access_token', | |
token_type: 'Bearer', | |
expires_in: 900 | |
} | |
*/ | |
// Sign a custom token with the federated service account | |
// see https://cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/signJwt | |
// The token will be signed by the service account and the associated public key that is needed to validate the token can be | |
// retrieved in several formats from the following endpoints: | |
// - RSA public key wrapped in an X.509 v3 certificate: https://www.googleapis.com/service_accounts/v1/metadata/x509/{ACCOUNT_EMAIL} | |
// - Raw key in JSON format: https://www.googleapis.com/service_accounts/v1/metadata/raw/{ACCOUNT_EMAIL} | |
// - JSON Web Key (JWK): https://www.googleapis.com/service_accounts/v1/metadata/jwk/{ACCOUNT_EMAIL} | |
const customTokenPayload = { | |
sub: '[email protected]', | |
aud: 'https://example.com', | |
iat: Math.floor(Date.now() / 1000), | |
exp: Math.floor(Date.now() / 1000) + 300, | |
}; | |
const signedCustomToken = await axios.post<{ keyId: string; signedJwt: string }>( | |
`https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${GCP_SERVICE_ACCOUNT}:signJwt`, | |
{ | |
payload: JSON.stringify(customTokenPayload), | |
}, | |
{ headers: { authorization: `Bearer ${federated.data.access_token}` } }, | |
); | |
console.log('-'.repeat(80)); | |
console.log('signJwt result:'); | |
console.log(signedCustomToken.data); | |
/* | |
decoded token header: | |
{ | |
"alg": "RS256", | |
"kid": "e8c4....e35a", | |
"typ": "JWT" | |
} | |
decoded token payload: | |
{ | |
"sub": "[email protected]", | |
"aud": "https://example.com", | |
"iat": 1622480215, | |
"exp": 1622480515 | |
} | |
*/ | |
// Get an OIDC id-token for the federated service account | |
// see https://cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/generateIdToken | |
const idtoken = await axios.post<{ token: string }>( | |
`https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${GCP_SERVICE_ACCOUNT}:generateIdToken`, | |
{ | |
audience: 'https://example.com', | |
includeEmail: false, | |
}, | |
{ headers: { authorization: `Bearer ${federated.data.access_token}` } }, | |
); | |
console.log('-'.repeat(80)); | |
console.log('generateIdToken result:'); | |
console.log(idtoken.data); | |
/* | |
decoded token header: | |
{ | |
"alg": "RS256", | |
"kid": "1719....08de", | |
"typ": "JWT" | |
} | |
decoded token payload: | |
{ | |
"aud": "https://example.com", | |
"azp": "1000....6293", // numerical unique id of the service account | |
"exp": 1622483939, | |
"iat": 1622480339, | |
"iss": "https://accounts.google.com", | |
"sub": "1000....6293" // numerical unique id of the service account | |
} | |
*/ | |
// Only a limited number of Google Cloud APIs support federated tokens. Call `generateAccessToken` to get a "service account | |
// access token" which can be used to invoke any Google Cloud API this service account has access to. | |
// see https://cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/generateAccessToken | |
// But if you want to invoke Google API's, it is much more convenient to use the `google-auth-library` for your programming | |
// language as that will handle all the fetching (and refreshing) of tokens. | |
const serviceAccountToken = await axios.post<{ accessToken: string; expireTime: string }>( | |
`https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${GCP_SERVICE_ACCOUNT}:generateAccessToken`, | |
{ | |
// see https://developers.google.com/identity/protocols/oauth2/scopes | |
scope: ['https://www.googleapis.com/auth/cloud-platform'], | |
}, | |
{ headers: { authorization: `Bearer ${federated.data.access_token}` } }, | |
); | |
// Example of invoking a google cloud api with the service account access token | |
const projects = await axios.get('https://cloudresourcemanager.googleapis.com/v1/projects', { | |
headers: { authorization: `Bearer ${serviceAccountToken.data.accessToken}` }, | |
}); | |
console.log('-'.repeat(80)); | |
console.log('GET projects result (with service account access token):'); | |
console.log(projects.data); | |
} catch (e) { | |
if (axios.isAxiosError(e)) { | |
console.log('ERROR RESPONSE', e.response?.data); | |
} | |
throw e; | |
} | |
})().catch(e => { | |
console.log(e); | |
process.exit(1); | |
}); | |
// see https://cloud.google.com/iam/docs/reference/sts/rest/v1/TopLevel/token#response-body | |
interface FederatedToken { | |
access_token: string; | |
issued_token_type: 'urn:ietf:params:oauth:token-type:access_token'; // same as requestedTokenType from token request | |
token_type: 'Bearer'; | |
expires_in: number; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment