From cb396c3f31318e0011b80a34de46c2cab9e332f8 Mon Sep 17 00:00:00 2001 From: Seth Vargo Date: Sat, 18 Sep 2021 12:12:21 -0400 Subject: [PATCH] Refactor to support access and id tokens (#3) --- .github/workflows/test.yaml | 62 ++++++++++++++++++++++++++++------ README.md | 28 +++++++++++++--- action.yml | 47 ++++++++++++++++++++------ dist/index.js | 66 ++++++++++++++++++++++--------------- package-lock.json | 36 ++++++++++---------- src/client.ts | 8 +++-- src/main.ts | 58 +++++++++++++++++++------------- 7 files changed, 212 insertions(+), 93 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index d695a99f..13fdf20b 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -9,13 +9,34 @@ on: - 'main' jobs: - run: - name: 'test' + unit: + name: 'unit' + runs-on: 'ubuntu-latest' + + steps: + - uses: 'actions/checkout@v2' + + - uses: 'actions/setup-node@master' + with: + node-version: '12.x' + + - name: 'npm install' + run: 'npm install' + + - name: 'npm lint' + run: 'npm run lint' + + - name: 'npm test' + run: 'npm run test' + + access_token: + name: 'access_token' permissions: id-token: write contents: read runs-on: '${{ matrix.operating-system }}' strategy: + fail-fast: false matrix: operating-system: - 'ubuntu-latest' @@ -28,19 +49,40 @@ jobs: with: node-version: '12.x' - - id: 'integration' + - id: 'access-token' name: 'integration' uses: './' with: + token_format: 'access_token' 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' + id_token: + name: 'id_token' + permissions: + id-token: write + contents: read + runs-on: '${{ matrix.operating-system }}' + strategy: + fail-fast: false + matrix: + operating-system: + - 'ubuntu-latest' + - 'windows-latest' + - 'macos-latest' + steps: + - uses: 'actions/checkout@v2' - - name: 'npm lint' - run: 'npm run lint' + - uses: 'actions/setup-node@master' + with: + node-version: '12.x' - - name: 'npm test' - run: 'npm run test' + - id: 'id-token' + name: 'integration' + uses: './' + with: + token_format: 'id_token' + 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: 'my-aud' + id_token_include_email: true diff --git a/README.md b/README.md index d5d62b9b..c40d01af 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ jobs: name: 'Authenticate to Google Cloud' uses: 'github.com/sethvargo/oidc-auth-google-cloud' with: + token_format: 'access_token' workload_identity_provider: 'projects/123456789/locations/global/workloadIdentityPools/my-pool/providers/my-provider' service_account: 'my-service-account@my-project.iam.gserviceaccount.com' @@ -74,23 +75,40 @@ jobs: `"sigstore"`, but this variable exists in case custom values are permitted in the future. The default value is `"sigstore"`. +- `token_format`: (Optional) Format of the generated token. For OAuth 2.0 + access tokens, specify "access_token". For OIDC tokens, specify "id_token". + The default value is "access_token". + - `delegates`: (Optional) List of additional service account emails or unique identities to use for impersonation in the chain. By default there are no delegates. -- `lifetime`: (Optional) Desired lifetime duration of the access token, in - seconds. This must be specified as the number of seconds with a trailing "s" - (e.g. 30s). The default value is 1 hour (3600s). +- `access_token_lifetime`: (Optional) Desired lifetime duration of the access + token, in seconds. This must be specified as the number of seconds with a + trailing "s" (e.g. 30s). The default value is 1 hour (3600s). + +- `access_token_scopes`: (Optional) List of OAuth 2.0 access scopes to be + included in the generated token. This is only valid when "token_format" is + "access_token". The default value is: + + ```text + https://www.googleapis.com/auth/cloud-platform + ``` - `id_token_audience`: (Optional) The audience for the generated ID Token. +- `id_token_include_email`: (Optional) Optional parameter of whether to + include the service account email in the generated token. If true, the token + will contain "email" and "email_verified" claims. This is only valid when + "token_format" is "access_token". The default value is false. + ## Outputs - `access_token`: The authenticated Google Cloud access token for calling other Google Cloud APIs. -- `expiration`: The RFC3339 UTC "Zulu" format timestamp when the token - expires. +- `access_token_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. diff --git a/action.yml b/action.yml index 39de7376..4d97be49 100644 --- a/action.yml +++ b/action.yml @@ -15,8 +15,8 @@ name: 'OIDC Authenticate to Google Cloud' author: 'sethvargo' description: |- - Authenticate to Google Cloud from GitHub Actions using an OIDC token and - Workload Identity Federation. + Generate credentials to authenticate to Google Cloud from GitHub Actions using + an OIDC token and Workload Identity Federation. inputs: workload_identity_provider: @@ -38,35 +38,62 @@ inputs: exists in case custom values are permitted in the future. default: 'sigstore' required: false + token_format: + description: |- + Format for the generated token. For OAuth 2.0 access tokens, specify + "access_token". For OIDC tokens, specify "id_token". + default: 'access_token' + required: true delegates: description: |- List of additional service account emails or unique identities to use for impersonation in the chain. default: '' required: false - lifetime: + + # access token params + access_token_lifetime: description: |- Desired lifetime duration of the access token, in seconds. This must be - specified as the number of seconds with a trailing "s" (e.g. 30s). + specified as the number of seconds with a trailing "s" (e.g. 30s). This is + only valid when "token_format" is "access_token". default: '3600s' required: false + access_token_scopes: + description: |- + List of OAuth 2.0 access scopes to be included in the generated token. + This is only valid when "token_format" is "access_token". + default: 'https://www.googleapis.com/auth/cloud-platform' + + # id token params id_token_audience: description: |- - The audience for the generated Google Cloud ID Token. + The audience (aud) for the generated Google Cloud ID Token. This is only + valid when "token_format" is "id_token". default: '' required: false + id_token_include_email: + description: |- + Optional parameter of whether to include the service account email in the + generated token. If true, the token will contain "email" and + "email_verified" claims. This is only valid when "token_format" is + "access_token". + default: false + required: false outputs: access_token: description: |- - The Google Cloud access token for calling other Google Cloud APIs. - expiration: + The Google Cloud access token for calling other Google Cloud APIs. This + is only available when "token_format" is "access_token". + access_token_expiration: description: |- - The expiration timestamp for the access token. + The expiration timestamp for the access token. This is only available + when "token_format" is "access_token". id_token: description: |- - The Google Cloud ID token. This token is only generated when - `id_token_audience` input parameter was provided. + The Google Cloud ID token. This is only available when "token_format" is + "id_token". branding: icon: 'lock' diff --git a/dist/index.js b/dist/index.js index 6e746448..ef58b4c9 100644 --- a/dist/index.js +++ b/dist/index.js @@ -225,37 +225,51 @@ function run() { }); const serviceAccount = core.getInput('service_account', { required: true }); const audience = core.getInput('audience'); + const tokenFormat = core.getInput('token_format', { required: true }); const delegates = explodeStrings(core.getInput('delegates')); - const lifetime = core.getInput('lifetime'); + const accessTokenLifetime = core.getInput('access_token_lifetime'); + const accessTokenScopes = explodeStrings(core.getInput('access_token_scopes')); const idTokenAudience = core.getInput('id_token_audience'); + const idTokenIncludeEmail = core.getBooleanInput('id_token_include_email'); + // Get the GitHub OIDC token. const githubOIDCToken = yield core.getIDToken(audience); - // Exchange the GitHub OIDC token for a Google Federated Token. const googleFederatedToken = yield client_1.Client.googleFederatedToken({ providerID: workloadIdentityProvider, token: githubOIDCToken, }); core.setSecret(googleFederatedToken); - // Exchange the Google Federated Token for an access token. - const { accessToken, expiration } = yield client_1.Client.googleAccessToken({ - token: googleFederatedToken, - serviceAccount: serviceAccount, - delegates: delegates, - lifetime: lifetime, - }); - 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); + switch (tokenFormat) { + case 'access_token': { + // Exchange the Google Federated Token for an access token. + const { accessToken, expiration } = yield client_1.Client.googleAccessToken({ + token: googleFederatedToken, + serviceAccount: serviceAccount, + delegates: delegates, + lifetime: accessTokenLifetime, + scopes: accessTokenScopes, + }); + core.setSecret(accessToken); + core.setOutput('access_token', accessToken); + core.setOutput('access_token_expiration', expiration); + break; + } + case 'id_token': { + // Exchange the Google Federated Token for an id token. + const { token } = yield client_1.Client.googleIDToken({ + token: googleFederatedToken, + serviceAccount: serviceAccount, + delegates: delegates, + audience: idTokenAudience, + includeEmail: idTokenIncludeEmail, + }); + core.setSecret(token); + core.setOutput('id_token', token); + break; + } + default: { + throw new Error(`unknown token format "${tokenFormat}"`); + } } } catch (err) { @@ -1880,14 +1894,14 @@ class Client { * googleAccessToken generates a Google Cloud access token for the provided * service account email or unique id. */ - static googleAccessToken({ token, serviceAccount, delegates, lifetime, }) { + static googleAccessToken({ token, serviceAccount, delegates, scopes, lifetime, }) { 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}:generateAccessToken`); const data = { delegates: delegates, - scope: 'https://www.googleapis.com/auth/cloud-platform', lifetime: lifetime, + scope: scopes, }; const opts = { hostname: tokenURL.hostname, @@ -1917,14 +1931,14 @@ class Client { * googleIDToken generates a Google Cloud ID token for the provided * service account email or unique id. */ - static googleIDToken({ token, serviceAccount, audience, delegates, }) { + static googleIDToken({ token, serviceAccount, audience, delegates, includeEmail, }) { 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, + includeEmail: includeEmail, }; const opts = { hostname: tokenURL.hostname, diff --git a/package-lock.json b/package-lock.json index 894d8119..8919fb85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,9 +55,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.14.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.14.9.tgz", - "integrity": "sha512-pQYxPY0UP6IHISRitNe8bsijHex4TWZXi2HwKVsjPiltzlhse2znVcm9Ace510VT1kxIHjGJCZZQBX2gJDbo0g==", + "version": "7.15.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.15.7.tgz", + "integrity": "sha512-K4JvCtQqad9OY2+yTU8w+E82ywk/fe+ELNlt1G8z3bVGlZfn/hOcQQsUhGhW/N+tb3fxK800wLtKOE/aM0m72w==", "dev": true, "engines": { "node": ">=6.9.0" @@ -287,9 +287,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "16.9.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.9.1.tgz", - "integrity": "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==", + "version": "16.9.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.9.2.tgz", + "integrity": "sha512-ZHty/hKoOLZvSz6BtP1g7tc7nUeJhoCf3flLjh8ZEv1vFKBWHXcnMbJMyN/pftSljNyy0kNW/UqI3DccnBnZ8w==", "dev": true }, "node_modules/@typescript-eslint/eslint-plugin": { @@ -1975,9 +1975,9 @@ } }, "node_modules/prettier": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.4.0.tgz", - "integrity": "sha512-DsEPLY1dE5HF3BxCRBmD4uYZ+5DCbvatnolqTqcxEgKVZnL2kUfyu7b8pPQ5+hTBkdhU9SLUmK0/pHb07RE4WQ==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.4.1.tgz", + "integrity": "sha512-9fbDAXSBcc6Bs1mZrDYb3XKzDLm4EXXL9sC1LqKP5rZkT6KRr/rf9amVUcODVXgguK/isJz0d0hP72WeaKWsvA==", "dev": true, "bin": { "prettier": "bin-prettier.js" @@ -2714,9 +2714,9 @@ } }, "@babel/helper-validator-identifier": { - "version": "7.14.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.14.9.tgz", - "integrity": "sha512-pQYxPY0UP6IHISRitNe8bsijHex4TWZXi2HwKVsjPiltzlhse2znVcm9Ace510VT1kxIHjGJCZZQBX2gJDbo0g==", + "version": "7.15.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.15.7.tgz", + "integrity": "sha512-K4JvCtQqad9OY2+yTU8w+E82ywk/fe+ELNlt1G8z3bVGlZfn/hOcQQsUhGhW/N+tb3fxK800wLtKOE/aM0m72w==", "dev": true }, "@babel/highlight": { @@ -2906,9 +2906,9 @@ "dev": true }, "@types/node": { - "version": "16.9.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.9.1.tgz", - "integrity": "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==", + "version": "16.9.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.9.2.tgz", + "integrity": "sha512-ZHty/hKoOLZvSz6BtP1g7tc7nUeJhoCf3flLjh8ZEv1vFKBWHXcnMbJMyN/pftSljNyy0kNW/UqI3DccnBnZ8w==", "dev": true }, "@typescript-eslint/eslint-plugin": { @@ -4115,9 +4115,9 @@ "dev": true }, "prettier": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.4.0.tgz", - "integrity": "sha512-DsEPLY1dE5HF3BxCRBmD4uYZ+5DCbvatnolqTqcxEgKVZnL2kUfyu7b8pPQ5+hTBkdhU9SLUmK0/pHb07RE4WQ==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.4.1.tgz", + "integrity": "sha512-9fbDAXSBcc6Bs1mZrDYb3XKzDLm4EXXL9sC1LqKP5rZkT6KRr/rf9amVUcODVXgguK/isJz0d0hP72WeaKWsvA==", "dev": true }, "prettier-linter-helpers": { diff --git a/src/client.ts b/src/client.ts index a4fb53c2..33207d53 100644 --- a/src/client.ts +++ b/src/client.ts @@ -34,6 +34,7 @@ interface GoogleAccessTokenParameters { token: string; serviceAccount: string; delegates?: Array; + scopes?: Array; lifetime?: string; } @@ -68,6 +69,7 @@ interface GoogleIDTokenParameters { serviceAccount: string; audience: string; delegates?: Array; + includeEmail?: boolean; } /** @@ -163,6 +165,7 @@ export class Client { token, serviceAccount, delegates, + scopes, lifetime, }: GoogleAccessTokenParameters): Promise { const serviceAccountID = `projects/-/serviceAccounts/${serviceAccount}`; @@ -172,8 +175,8 @@ export class Client { const data = { delegates: delegates, - scope: 'https://www.googleapis.com/auth/cloud-platform', lifetime: lifetime, + scope: scopes, }; const opts = { @@ -209,6 +212,7 @@ export class Client { serviceAccount, audience, delegates, + includeEmail, }: GoogleIDTokenParameters): Promise { const serviceAccountID = `projects/-/serviceAccounts/${serviceAccount}`; const tokenURL = new URL( @@ -218,7 +222,7 @@ export class Client { const data = { delegates: delegates, audience: audience, - includeEmail: true, + includeEmail: includeEmail, }; const opts = { diff --git a/src/main.ts b/src/main.ts index 286a6075..1f197805 100644 --- a/src/main.ts +++ b/src/main.ts @@ -35,10 +35,14 @@ async function run(): Promise { }); const serviceAccount = core.getInput('service_account', { required: true }); const audience = core.getInput('audience'); + const tokenFormat = core.getInput('token_format', { required: true }); const delegates = explodeStrings(core.getInput('delegates')); - const lifetime = core.getInput('lifetime'); + const accessTokenLifetime = core.getInput('access_token_lifetime'); + const accessTokenScopes = explodeStrings(core.getInput('access_token_scopes')); const idTokenAudience = core.getInput('id_token_audience'); + const idTokenIncludeEmail = core.getBooleanInput('id_token_include_email'); + // Get the GitHub OIDC token. const githubOIDCToken = await core.getIDToken(audience); // Exchange the GitHub OIDC token for a Google Federated Token. @@ -48,27 +52,37 @@ async function run(): Promise { }); core.setSecret(googleFederatedToken); - // Exchange the Google Federated Token for an access token. - const { accessToken, expiration } = await Client.googleAccessToken({ - token: googleFederatedToken, - serviceAccount: serviceAccount, - delegates: delegates, - lifetime: lifetime, - }); - 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); + switch (tokenFormat) { + case 'access_token': { + // Exchange the Google Federated Token for an access token. + const { accessToken, expiration } = await Client.googleAccessToken({ + token: googleFederatedToken, + serviceAccount: serviceAccount, + delegates: delegates, + lifetime: accessTokenLifetime, + scopes: accessTokenScopes, + }); + core.setSecret(accessToken); + core.setOutput('access_token', accessToken); + core.setOutput('access_token_expiration', expiration); + break; + } + case 'id_token': { + // Exchange the Google Federated Token for an id token. + const { token } = await Client.googleIDToken({ + token: googleFederatedToken, + serviceAccount: serviceAccount, + delegates: delegates, + audience: idTokenAudience, + includeEmail: idTokenIncludeEmail, + }); + core.setSecret(token); + core.setOutput('id_token', token); + break; + } + default: { + throw new Error(`unknown token format "${tokenFormat}"`); + } } } catch (err) { core.setFailed(`Action failed with error: ${err}`);