diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 1bee9fa..d695a99 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -34,6 +34,7 @@ jobs: with: workload_identity_provider: 'projects/469401941463/locations/global/workloadIdentityPools/github-actions/providers/github-oidc-auth-google-cloud' service_account: 'github-secret-accessor@actions-oidc-test.iam.gserviceaccount.com' + id_token_audience: 'foo' - name: 'npm install' run: 'npm install' diff --git a/README.md b/README.md index 45b8bd0..d5d62b9 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,8 @@ jobs: seconds. This must be specified as the number of seconds with a trailing "s" (e.g. 30s). The default value is 1 hour (3600s). +- `id_token_audience`: (Optional) The audience for the generated ID Token. + ## Outputs - `access_token`: The authenticated Google Cloud access token for calling @@ -90,6 +92,9 @@ jobs: - `expiration`: The RFC3339 UTC "Zulu" format timestamp when the token expires. +- `id_token`: The authenticated Google Cloud ID token. This token is only + generated when `id_token_audience` input parameter is provided. + ## Setup To exchange a GitHub Actions OIDC token for a Google Cloud access token, you diff --git a/action.yml b/action.yml index 32f3f43..39de737 100644 --- a/action.yml +++ b/action.yml @@ -50,6 +50,11 @@ inputs: specified as the number of seconds with a trailing "s" (e.g. 30s). default: '3600s' required: false + id_token_audience: + description: |- + The audience for the generated Google Cloud ID Token. + default: '' + required: false outputs: access_token: @@ -58,6 +63,10 @@ outputs: expiration: description: |- The expiration timestamp for the access token. + id_token: + description: |- + The Google Cloud ID token. This token is only generated when + `id_token_audience` input parameter was provided. branding: icon: 'lock' diff --git a/dist/index.js b/dist/index.js index 980474f..efe618b 100644 --- a/dist/index.js +++ b/dist/index.js @@ -220,6 +220,7 @@ function run() { const audience = core.getInput('audience'); const delegates = explodeStrings(core.getInput('delegates')); const lifetime = core.getInput('lifetime'); + const idTokenAudience = core.getInput('id_token_audience'); // Extract the GitHub Actions OIDC token. const requestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; if (!requestToken) { @@ -251,6 +252,17 @@ function run() { core.setSecret(accessToken); core.setOutput('access_token', accessToken); core.setOutput('expiration', expiration); + // Exchange the Google Federated Token for an ID token. + if (idTokenAudience != '') { + const { token } = yield client_1.Client.googleIDToken({ + token: googleFederatedToken, + serviceAccount: serviceAccount, + delegates: delegates, + audience: idTokenAudience, + }); + core.setSecret(token); + core.setOutput('id_token', token); + } } catch (err) { core.setFailed(`Action failed with error: ${err}`); @@ -859,6 +871,42 @@ class Client { } }); } + /** + * googleIDToken generates a Google Cloud ID token for the provided + * service account email or unique id. + */ + static googleIDToken({ token, serviceAccount, audience, delegates, }) { + return __awaiter(this, void 0, void 0, function* () { + const serviceAccountID = `projects/-/serviceAccounts/${serviceAccount}`; + const tokenURL = new url_1.URL(`https://iamcredentials.googleapis.com/v1/${serviceAccountID}:generateIdToken`); + const data = { + delegates: delegates, + audience: audience, + includeEmail: true, + }; + const opts = { + hostname: tokenURL.hostname, + port: tokenURL.port, + path: tokenURL.pathname + tokenURL.search, + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + }; + try { + const resp = yield Client.request(opts, JSON.stringify(data)); + const parsed = JSON.parse(resp); + return { + token: parsed['token'], + }; + } + catch (err) { + throw new Error(`failed to generate Google Cloud ID token for ${serviceAccount}: ${err}`); + } + }); + } } exports.Client = Client; diff --git a/src/client.ts b/src/client.ts index 8a61bba..6278835 100644 --- a/src/client.ts +++ b/src/client.ts @@ -64,6 +64,37 @@ interface GoogleAccessTokenResponse { expiration: string; } +/** + * GoogleIDTokenParameters are the parameters to generate a Google Cloud + * ID token as described in: + * + * https://cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/generateIdToken + * + * @param token OAuth token or Federated access token with permissions to call + * the API. + * @param serviceAccount Email address or unique identifier of the service + * account. + * @param audience The audience for the token. + * @param delegates Optional sequence of service accounts in the delegation + * chain. + */ +interface GoogleIDTokenParameters { + token: string; + serviceAccount: string; + audience: string; + delegates?: Array; +} + +/** + * GoogleIDTokenResponse is the response from generating an ID token. + * + * @param token ID token. + * expires. + */ +interface GoogleIDTokenResponse { + token: string; +} + export class Client { /** * request is a high-level helper that returns a promise from the executed @@ -218,4 +249,48 @@ export class Client { throw new Error(`failed to generate Google Cloud access token for ${serviceAccount}: ${err}`); } } + + /** + * googleIDToken generates a Google Cloud ID token for the provided + * service account email or unique id. + */ + static async googleIDToken({ + token, + serviceAccount, + audience, + delegates, + }: GoogleIDTokenParameters): Promise { + const serviceAccountID = `projects/-/serviceAccounts/${serviceAccount}`; + const tokenURL = new URL( + `https://iamcredentials.googleapis.com/v1/${serviceAccountID}:generateIdToken`, + ); + + const data = { + delegates: delegates, + audience: audience, + includeEmail: true, + }; + + const opts = { + hostname: tokenURL.hostname, + port: tokenURL.port, + path: tokenURL.pathname + tokenURL.search, + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + }; + + try { + const resp = await Client.request(opts, JSON.stringify(data)); + const parsed = JSON.parse(resp); + return { + token: parsed['token'], + }; + } catch (err) { + throw new Error(`failed to generate Google Cloud ID token for ${serviceAccount}: ${err}`); + } + } } diff --git a/src/main.ts b/src/main.ts index 0e65c27..c549e68 100644 --- a/src/main.ts +++ b/src/main.ts @@ -37,6 +37,7 @@ async function run(): Promise { const audience = core.getInput('audience'); const delegates = explodeStrings(core.getInput('delegates')); const lifetime = core.getInput('lifetime'); + const idTokenAudience = core.getInput('id_token_audience'); // Extract the GitHub Actions OIDC token. const requestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; @@ -71,6 +72,18 @@ async function run(): Promise { core.setSecret(accessToken); core.setOutput('access_token', accessToken); core.setOutput('expiration', expiration); + + // Exchange the Google Federated Token for an ID token. + if (idTokenAudience != '') { + const { token } = await Client.googleIDToken({ + token: googleFederatedToken, + serviceAccount: serviceAccount, + delegates: delegates, + audience: idTokenAudience, + }); + core.setSecret(token); + core.setOutput('id_token', token); + } } catch (err) { core.setFailed(`Action failed with error: ${err}`); }