From d5a354ef10caa069020ece86802cc4f76a3f7559 Mon Sep 17 00:00:00 2001 From: Bharath KKB Date: Tue, 12 Oct 2021 22:17:42 -0500 Subject: [PATCH] chore: refactor WIF (#33) * define common interfaces * common base client * refactor WIF to use interfaces and base client * refactor main * add build in CI * add name for build step * address comments * fix import * interface for credfile return * regen dist --- .github/workflows/test.yaml | 15 + dist/index.js | 684 +++++++++++++++++++++--------------- src/actionauth.ts | 79 +++++ src/base.ts | 139 ++++++++ src/client.ts | 328 ----------------- src/main.ts | 109 ++---- src/utils.ts | 47 +++ src/workload_identity.ts | 178 ++++++++++ 8 files changed, 882 insertions(+), 697 deletions(-) create mode 100644 src/actionauth.ts create mode 100644 src/base.ts delete mode 100644 src/client.ts create mode 100644 src/utils.ts create mode 100644 src/workload_identity.ts diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index d0e09ca..6e51b0a 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -50,6 +50,11 @@ jobs: with: node-version: '12.x' + - name: 'build' + run: |- + npm ci + npm run build + - uses: 'google-github-actions/setup-gcloud@master' with: project_id: 'actions-oidc-test' @@ -84,6 +89,11 @@ jobs: with: node-version: '12.x' + - name: 'build' + run: |- + npm ci + npm run build + - id: 'auth' name: 'auth' uses: './' @@ -108,6 +118,11 @@ jobs: with: node-version: '12.x' + - name: 'build' + run: |- + npm ci + npm run build + - id: 'auth' name: 'auth' uses: './' diff --git a/dist/index.js b/dist/index.js index 9567b70..9734422 100644 --- a/dist/index.js +++ b/dist/index.js @@ -194,27 +194,8 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge }; Object.defineProperty(exports, "__esModule", { value: true }); const core = __importStar(__webpack_require__(470)); -const client_1 = __webpack_require__(976); -const url_1 = __webpack_require__(835); -/** - * Converts a multi-line or comma-separated collection of strings into an array - * of trimmed strings. - */ -function explodeStrings(input) { - if (input == null || input.length === 0) { - return []; - } - const list = new Array(); - for (const line of input.split(`\n`)) { - for (const piece of line.split(',')) { - const entry = piece.trim(); - if (entry !== '') { - list.push(entry); - } - } - } - return list; -} +const workload_identity_1 = __webpack_require__(313); +const utils_1 = __webpack_require__(163); /** * Executes the main action, documented inline. */ @@ -226,11 +207,17 @@ function run() { required: true, }); const serviceAccount = core.getInput('service_account', { required: true }); - const audience = core.getInput('audience') || `https://iam.googleapis.com/${workloadIdentityProvider}`; + // audience will default to the WIF provider ID when used with WIF + const audience = core.getInput('audience'); const createCredentialsFile = core.getBooleanInput('create_credentials_file'); const activateCredentialsFile = core.getBooleanInput('activate_credentials_file'); const tokenFormat = core.getInput('token_format'); - const delegates = explodeStrings(core.getInput('delegates')); + const delegates = (0, utils_1.explodeStrings)(core.getInput('delegates')); + const client = new workload_identity_1.WIFClient({ + providerID: workloadIdentityProvider, + serviceAccount: serviceAccount, + audience: audience, + }); // Always write the credentials file first, before trying to generate // tokens. This will ensure the file is written even if token generation // fails, which means continue-on-error actions will still have the file @@ -240,50 +227,16 @@ function run() { if (!runnerTempDir) { throw new Error('$RUNNER_TEMP is not set'); } - // Extract the request token and request URL from the environment. These - // are only set when an id-token is requested and the submitter has - // collaborator permissions. - const requestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; - const requestURLRaw = process.env.ACTIONS_ID_TOKEN_REQUEST_URL; - if (!requestToken || !requestURLRaw) { - throw new Error('GitHub Actions did not inject $ACTIONS_ID_TOKEN_REQUEST_TOKEN or ' + - '$ACTIONS_ID_TOKEN_REQUEST_URL into this job. This most likely ' + - 'means the GitHub Actions workflow permissions are incorrect, or ' + - 'this job is being run from a fork. For more information, please ' + - 'see the GitHub documentation at https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token'); - } - const requestURL = new url_1.URL(requestURLRaw); - // Append the audience value to the request. - const params = requestURL.searchParams; - params.set('audience', audience); - requestURL.search = params.toString(); - // Create the credentials file. - const outputPath = yield client_1.Client.createCredentialsFile({ - providerID: workloadIdentityProvider, - serviceAccount: serviceAccount, - requestToken: requestToken, - requestURL: requestURL.toString(), - outputDir: runnerTempDir, - }); - core.setOutput('credentials_file_path', outputPath); + const { credentialsPath, envVars } = yield client.createCredentialsFile(runnerTempDir); + core.setOutput('credentials_file_path', credentialsPath); // Also set the magic environment variable for gcloud and SDKs if // requested. - if (activateCredentialsFile) { - core.exportVariable('GOOGLE_APPLICATION_CREDENTIALS', outputPath); + if (activateCredentialsFile && envVars) { + for (const [k, v] of envVars) { + core.exportVariable(k, v); + } } } - // getFederatedToken is a closure that gets the federated token. - const getFederatedToken = () => __awaiter(this, void 0, void 0, function* () { - // 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); - return googleFederatedToken; - }); switch (tokenFormat) { case '': { break; @@ -293,14 +246,12 @@ function run() { } case 'access_token': { const accessTokenLifetime = core.getInput('access_token_lifetime'); - const accessTokenScopes = explodeStrings(core.getInput('access_token_scopes')); - const googleFederatedToken = yield getFederatedToken(); - const { accessToken, expiration } = yield client_1.Client.googleAccessToken({ - token: googleFederatedToken, - serviceAccount: serviceAccount, - delegates: delegates, - lifetime: accessTokenLifetime, + const accessTokenScopes = (0, utils_1.explodeStrings)(core.getInput('access_token_scopes')); + const { accessToken, expiration } = yield client.getAccessToken({ + serviceAccount, + delegates, scopes: accessTokenScopes, + lifetime: accessTokenLifetime, }); core.setSecret(accessToken); core.setOutput('access_token', accessToken); @@ -310,12 +261,10 @@ function run() { case 'id_token': { const idTokenAudience = core.getInput('id_token_audience', { required: true }); const idTokenIncludeEmail = core.getBooleanInput('id_token_include_email'); - const googleFederatedToken = yield getFederatedToken(); - const { token } = yield client_1.Client.googleIDToken({ - token: googleFederatedToken, - serviceAccount: serviceAccount, - delegates: delegates, + const { token } = yield client.getIDToken({ + serviceAccount, audience: idTokenAudience, + delegates, includeEmail: idTokenIncludeEmail, }); core.setSecret(token); @@ -607,6 +556,75 @@ if (process.env.NODE_DEBUG && /\btunnel\b/.test(process.env.NODE_DEBUG)) { exports.debug = debug; // for test +/***/ }), + +/***/ 163: +/***/ (function(__unusedmodule, exports, __webpack_require__) { + +"use strict"; + +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.explodeStrings = exports.writeCredFile = void 0; +const fs_1 = __webpack_require__(747); +const crypto_1 = __importDefault(__webpack_require__(417)); +const path_1 = __importDefault(__webpack_require__(622)); +/** + * writeCredFile writes a file to disk in a given directory with a + * random name. + * + * @param outputDir Directory to create random file in. + * @param data Data to write to file. + * @returns Path to written file. + */ +function writeCredFile(outputDir, data) { + return __awaiter(this, void 0, void 0, function* () { + // Generate a random filename to store the credential. 12 bytes is 24 + // characters in hex. It's not the ideal entropy, but we have to be under + // the 255 character limit for Windows filenames (which includes their + // entire leading path). + const uniqueName = crypto_1.default.randomBytes(12).toString('hex'); + const pth = path_1.default.join(outputDir, uniqueName); + // Write the file as 0640 so the owner has RW, group as R, and the file is + // otherwise unreadable. Also write with EXCL to prevent a symlink attack. + yield fs_1.promises.writeFile(pth, data, { mode: 0o640, flag: 'wx' }); + return pth; + }); +} +exports.writeCredFile = writeCredFile; +/** + * Converts a multi-line or comma-separated collection of strings into an array + * of trimmed strings. + */ +function explodeStrings(input) { + if (input == null || input.length === 0) { + return []; + } + const list = new Array(); + for (const line of input.split(`\n`)) { + for (const piece of line.split(',')) { + const entry = piece.trim(); + if (entry !== '') { + list.push(entry); + } + } + } + return list; +} +exports.explodeStrings = explodeStrings; + + /***/ }), /***/ 211: @@ -680,6 +698,174 @@ class PersonalAccessTokenCredentialHandler { exports.PersonalAccessTokenCredentialHandler = PersonalAccessTokenCredentialHandler; +/***/ }), + +/***/ 313: +/***/ (function(__unusedmodule, exports, __webpack_require__) { + +"use strict"; + +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } }); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); + __setModuleDefault(result, mod); + return result; +}; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.WIFClient = void 0; +const url_1 = __webpack_require__(835); +const core = __importStar(__webpack_require__(470)); +const utils_1 = __webpack_require__(163); +const base_1 = __webpack_require__(843); +class WIFClient { + constructor(opts) { + this.providerID = opts.providerID; + this.serviceAccount = opts.serviceAccount; + this.audience = opts.audience ? opts.audience : `https://iam.googleapis.com/${this.providerID}`; + } + /** + * googleFederatedToken generates a Google Cloud federated token using the + * provided OIDC token and Workload Identity Provider. + */ + static googleFederatedToken({ providerID, token, }) { + return __awaiter(this, void 0, void 0, function* () { + const stsURL = new url_1.URL('https://sts.googleapis.com/v1/token'); + const data = { + audience: '//iam.googleapis.com/' + providerID, + 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:oauth:token-type:jwt', + subjectToken: token, + }; + const opts = { + hostname: stsURL.hostname, + port: stsURL.port, + path: stsURL.pathname + stsURL.search, + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + }; + try { + const resp = yield base_1.BaseClient.request(opts, JSON.stringify(data)); + const parsed = JSON.parse(resp); + return parsed['access_token']; + } + catch (err) { + throw new Error(`failed to generate Google Cloud federated token for ${providerID}: ${err}`); + } + }); + } + /** + * getFederatedToken generates a Google Cloud federated token using the + * GitHub OIDC token. + */ + getFederatedToken() { + return __awaiter(this, void 0, void 0, function* () { + // Get the GitHub OIDC token. + const githubOIDCToken = yield core.getIDToken(this.audience); + // Exchange the GitHub OIDC token for a Google Federated Token. + const googleFederatedToken = yield WIFClient.googleFederatedToken({ + providerID: this.providerID, + token: githubOIDCToken, + }); + core.setSecret(googleFederatedToken); + return googleFederatedToken; + }); + } + /** + * getAccessToken generates a Google Cloud access token for the provided + * service account email or unique id. + */ + getAccessToken(opts) { + return __awaiter(this, void 0, void 0, function* () { + const googleFederatedToken = yield this.getFederatedToken(); + return yield base_1.BaseClient.googleAccessToken(googleFederatedToken, opts); + }); + } + /** + * getIDToken generates a Google Cloud ID token for the provided + * service account email or unique id. + */ + getIDToken(tokenParams) { + return __awaiter(this, void 0, void 0, function* () { + const googleFederatedToken = yield this.getFederatedToken(); + return yield base_1.BaseClient.googleIDToken(googleFederatedToken, tokenParams); + }); + } + /** + * createCredentialsFile creates a Google Cloud credentials file that can be + * set as GOOGLE_APPLICATION_CREDENTIALS for gcloud and client libraries. + */ + createCredentialsFile(outputDir) { + return __awaiter(this, void 0, void 0, function* () { + // Extract the request token and request URL from the environment. These + // are only set when an id-token is requested and the submitter has + // collaborator permissions. + const requestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; + const requestURLRaw = process.env.ACTIONS_ID_TOKEN_REQUEST_URL; + if (!requestToken || !requestURLRaw) { + throw new Error('GitHub Actions did not inject $ACTIONS_ID_TOKEN_REQUEST_TOKEN or ' + + '$ACTIONS_ID_TOKEN_REQUEST_URL into this job. This most likely ' + + 'means the GitHub Actions workflow permissions are incorrect, or ' + + 'this job is being run from a fork. For more information, please ' + + 'see the GitHub documentation at https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token'); + } + const requestURL = new url_1.URL(requestURLRaw); + // Append the audience value to the request. + const params = requestURL.searchParams; + params.set('audience', this.audience); + requestURL.search = params.toString(); + const data = { + type: 'external_account', + audience: `//iam.googleapis.com/${this.providerID}`, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + token_url: 'https://sts.googleapis.com/v1/token', + service_account_impersonation_url: `https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${this.serviceAccount}:generateAccessToken`, + credential_source: { + url: requestURL, + headers: { + Authorization: `Bearer ${requestToken}`, + }, + format: { + type: 'json', + subject_token_field_name: 'value', + }, + }, + }; + const credentialsPath = yield (0, utils_1.writeCredFile)(outputDir, JSON.stringify(data)); + const envVars = new Map([['GOOGLE_APPLICATION_CREDENTIALS', credentialsPath]]); + return { credentialsPath, envVars }; + }); + } +} +exports.WIFClient = WIFClient; + + /***/ }), /***/ 357: @@ -1798,6 +1984,144 @@ module.exports = require("fs"); module.exports = require("url"); +/***/ }), + +/***/ 843: +/***/ (function(__unusedmodule, exports, __webpack_require__) { + +"use strict"; + +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.BaseClient = void 0; +const https_1 = __importDefault(__webpack_require__(211)); +const url_1 = __webpack_require__(835); +class BaseClient { + /** + * request is a high-level helper that returns a promise from the executed + * request. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types + static request(opts, data) { + if (!opts.headers) { + opts.headers = {}; + } + if (!opts.headers['User-Agent']) { + opts.headers['User-Agent'] = 'google-github-actions:auth/0.3.1'; + } + return new Promise((resolve, reject) => { + const req = https_1.default.request(opts, (res) => { + res.setEncoding('utf8'); + let body = ''; + res.on('data', (data) => { + body += data; + }); + res.on('end', () => { + if (res.statusCode && res.statusCode >= 400) { + reject(body); + } + else { + resolve(body); + } + }); + }); + req.on('error', (err) => { + reject(err); + }); + if (data != null) { + req.write(data); + } + req.end(); + }); + } + /** + * googleIDToken generates a Google Cloud ID token for the provided + * service account email or unique id. + */ + 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: includeEmail, + }; + 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 BaseClient.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}`); + } + }); + } + /** + * googleAccessToken generates a Google Cloud access token for the provided + * service account email or unique id. + */ + 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, + lifetime: lifetime, + scope: scopes, + }; + 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 BaseClient.request(opts, JSON.stringify(data)); + const parsed = JSON.parse(resp); + return { + accessToken: parsed['accessToken'], + expiration: parsed['expireTime'], + }; + } + catch (err) { + throw new Error(`failed to generate Google Cloud access token for ${serviceAccount}: ${err}`); + } + }); + } +} +exports.BaseClient = BaseClient; + + /***/ }), /***/ 950: @@ -1863,216 +2187,6 @@ function checkBypass(reqUrl) { exports.checkBypass = checkBypass; -/***/ }), - -/***/ 976: -/***/ (function(__unusedmodule, exports, __webpack_require__) { - -"use strict"; - -var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { - function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } - return new (P || (P = Promise))(function (resolve, reject) { - function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } - function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } - function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } - step((generator = generator.apply(thisArg, _arguments || [])).next()); - }); -}; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.Client = void 0; -const https_1 = __importDefault(__webpack_require__(211)); -const fs_1 = __webpack_require__(747); -const crypto_1 = __importDefault(__webpack_require__(417)); -const path_1 = __importDefault(__webpack_require__(622)); -const url_1 = __webpack_require__(835); -class Client { - /** - * request is a high-level helper that returns a promise from the executed - * request. - */ - static request(opts, data) { - if (!opts.headers) { - opts.headers = {}; - } - if (!opts.headers['User-Agent']) { - opts.headers['User-Agent'] = 'google-github-actions:auth/0.3.0'; - } - return new Promise((resolve, reject) => { - const req = https_1.default.request(opts, (res) => { - res.setEncoding('utf8'); - let body = ''; - res.on('data', (data) => { - body += data; - }); - res.on('end', () => { - if (res.statusCode && res.statusCode >= 400) { - reject(body); - } - else { - resolve(body); - } - }); - }); - req.on('error', (err) => { - reject(err); - }); - if (data != null) { - req.write(data); - } - req.end(); - }); - } - /** - * googleFederatedToken generates a Google Cloud federated token using the - * provided OIDC token and Workload Identity Provider. - */ - static googleFederatedToken({ providerID, token, }) { - return __awaiter(this, void 0, void 0, function* () { - const stsURL = new url_1.URL('https://sts.googleapis.com/v1/token'); - const data = { - audience: '//iam.googleapis.com/' + providerID, - 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:oauth:token-type:jwt', - subjectToken: token, - }; - const opts = { - hostname: stsURL.hostname, - port: stsURL.port, - path: stsURL.pathname + stsURL.search, - method: 'POST', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - }, - }; - try { - const resp = yield Client.request(opts, JSON.stringify(data)); - const parsed = JSON.parse(resp); - return parsed['access_token']; - } - catch (err) { - throw new Error(`failed to generate Google Cloud federated token for ${providerID}: ${err}`); - } - }); - } - /** - * googleAccessToken generates a Google Cloud access token for the provided - * service account email or unique id. - */ - 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, - lifetime: lifetime, - scope: scopes, - }; - 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 { - accessToken: parsed['accessToken'], - expiration: parsed['expireTime'], - }; - } - catch (err) { - 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 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: includeEmail, - }; - 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}`); - } - }); - } - /** - * createCredentialsFile creates a Google Cloud credentials file that can be - * set as GOOGLE_APPLICATION_CREDENTIALS for gcloud and client libraries. - */ - static createCredentialsFile({ providerID, serviceAccount, requestToken, requestURL, outputDir, }) { - return __awaiter(this, void 0, void 0, function* () { - const data = { - type: 'external_account', - audience: `//iam.googleapis.com/${providerID}`, - subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', - token_url: 'https://sts.googleapis.com/v1/token', - service_account_impersonation_url: `https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${serviceAccount}:generateAccessToken`, - credential_source: { - url: requestURL, - headers: { - Authorization: `Bearer ${requestToken}`, - }, - format: { - type: 'json', - subject_token_field_name: 'value', - }, - }, - }; - // Generate a random filename to store the credential. 12 bytes is 24 - // characters in hex. It's not the ideal entropy, but we have to be under - // the 255 character limit for Windows filenames (which includes their - // entire leading path). - const uniqueName = crypto_1.default.randomBytes(12).toString('hex'); - const pth = path_1.default.join(outputDir, uniqueName); - // Write the file as 0640 so the owner has RW, group as R, and the file is - // otherwise unreadable. Also write with EXCL to prevent a symlink attack. - yield fs_1.promises.writeFile(pth, JSON.stringify(data), { mode: 0o640, flag: 'wx' }); - return pth; - }); - } -} -exports.Client = Client; - - /***/ }) /******/ }); \ No newline at end of file diff --git a/src/actionauth.ts b/src/actionauth.ts new file mode 100644 index 0000000..128a467 --- /dev/null +++ b/src/actionauth.ts @@ -0,0 +1,79 @@ +/** + * Defines the main interface for all clients that generate credentials. + */ +export interface ActionAuth { + getAccessToken(opts: GoogleAccessTokenParameters): Promise; + getIDToken(opts: GoogleIDTokenParameters): Promise; + createCredentialsFile(outputDir: string): Promise; +} + +/** + * GoogleAccessTokenParameters are the parameters to generate a Google Cloud + * access token as described in: + * + * https://cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/generateAccessToken + * + * @param serviceAccount Optional email address or unique identifier of the service + * account. + * @param delegates Optional sequence of service accounts in the delegation + * chain. + * @param lifetime Optional validity period as a duration. + */ +export interface GoogleAccessTokenParameters { + serviceAccount?: string; + delegates?: Array; + scopes?: Array; + lifetime?: string; +} + +/** + * GoogleAccessTokenResponse is the response from generating an access token. + * + * @param accessToken OAuth 2.0 access token. + * @param expiration A timestamp in RFC3339 UTC "Zulu" format when the token + * expires. + */ +export interface GoogleAccessTokenResponse { + accessToken: string; + 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 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. + */ +export interface GoogleIDTokenParameters { + serviceAccount?: string; + audience: string; + delegates?: Array; + includeEmail?: boolean; +} + +/** + * GoogleIDTokenResponse is the response from generating an ID token. + * + * @param token ID token. + * expires. + */ +export interface GoogleIDTokenResponse { + token: string; +} + +/** + * CreateCredentialsFileResponse is the response from creating a credential file. + * + * @param credentialsPath Path to the created credentials file. + * @param envVars Optional key value pairs that can be exported as env variables. + */ +export interface CreateCredentialsFileResponse { + credentialsPath: string; + envVars?: Map; +} diff --git a/src/base.ts b/src/base.ts new file mode 100644 index 0000000..1ed0cc2 --- /dev/null +++ b/src/base.ts @@ -0,0 +1,139 @@ +import https, { RequestOptions } from 'https'; +import { URL } from 'url'; +import { + GoogleAccessTokenParameters, + GoogleAccessTokenResponse, + GoogleIDTokenParameters, + GoogleIDTokenResponse, +} from './actionauth'; + +export class BaseClient { + /** + * request is a high-level helper that returns a promise from the executed + * request. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types + static request(opts: RequestOptions, data?: any): Promise { + if (!opts.headers) { + opts.headers = {}; + } + + if (!opts.headers['User-Agent']) { + opts.headers['User-Agent'] = 'google-github-actions:auth/0.3.1'; + } + + return new Promise((resolve, reject) => { + const req = https.request(opts, (res) => { + res.setEncoding('utf8'); + + let body = ''; + res.on('data', (data) => { + body += data; + }); + + res.on('end', () => { + if (res.statusCode && res.statusCode >= 400) { + reject(body); + } else { + resolve(body); + } + }); + }); + + req.on('error', (err) => { + reject(err); + }); + + if (data != null) { + req.write(data); + } + + req.end(); + }); + } + + /** + * googleIDToken generates a Google Cloud ID token for the provided + * service account email or unique id. + */ + static async googleIDToken( + token: string, + { serviceAccount, audience, delegates, includeEmail }: 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: includeEmail, + }; + + 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 BaseClient.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}`); + } + } + + /** + * googleAccessToken generates a Google Cloud access token for the provided + * service account email or unique id. + */ + static async googleAccessToken( + token: string, + { serviceAccount, delegates, scopes, lifetime }: GoogleAccessTokenParameters, + ): Promise { + const serviceAccountID = `projects/-/serviceAccounts/${serviceAccount}`; + const tokenURL = new URL( + `https://iamcredentials.googleapis.com/v1/${serviceAccountID}:generateAccessToken`, + ); + + const data = { + delegates: delegates, + lifetime: lifetime, + scope: scopes, + }; + + 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 BaseClient.request(opts, JSON.stringify(data)); + const parsed = JSON.parse(resp); + return { + accessToken: parsed['accessToken'], + expiration: parsed['expireTime'], + }; + } catch (err) { + throw new Error(`failed to generate Google Cloud access token for ${serviceAccount}: ${err}`); + } + } +} diff --git a/src/client.ts b/src/client.ts deleted file mode 100644 index af8262b..0000000 --- a/src/client.ts +++ /dev/null @@ -1,328 +0,0 @@ -'use strict'; - -import https, { RequestOptions } from 'https'; -import { promises as fs } from 'fs'; -import crypto from 'crypto'; -import path from 'path'; -import { URL } from 'url'; - -/** - * GoogleFederatedTokenParameters are the parameters to generate a Federated - * Identity Token as described in: - * - * https://cloud.google.com/iam/docs/access-resources-oidc#exchange-token - * - * @param providerID Full path (including project, location, etc) to the Google - * Cloud Workload Identity Provider. - * @param token OIDC token to exchange for a Google Cloud federated token. - */ -interface GoogleFederatedTokenParameters { - providerID: string; - token: string; -} - -/** - * GoogleAccessTokenParameters are the parameters to generate a Google Cloud - * access token as described in: - * - * https://cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/generateAccessToken - * - * @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 delegates Optional sequence of service accounts in the delegation - * chain. - * @param lifetime Optional validity period as a duration. - */ -interface GoogleAccessTokenParameters { - token: string; - serviceAccount: string; - delegates?: Array; - scopes?: Array; - lifetime?: string; -} - -/** - * GoogleAccessTokenResponse is the response from generating an access token. - * - * @param accessToken OAuth 2.0 access token. - * @param expiration A timestamp in RFC3339 UTC "Zulu" format when the token - * expires. - */ -interface GoogleAccessTokenResponse { - accessToken: string; - 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; - includeEmail?: boolean; -} - -/** - * GoogleIDTokenResponse is the response from generating an ID token. - * - * @param token ID token. - * expires. - */ -interface GoogleIDTokenResponse { - token: string; -} - -/** - * CreateCredentialsFileParameters are the parameters to generate a Google Cloud - * credentials file for use with gcloud and other SDKs. - * - * @param providerID Full path (including project, location, etc) to the Google - * Cloud Workload Identity Provider. - * @param serviceAccount Email address or unique identifier of the service - * account to impersonate - * @param requestToken Local environment token to use as authentication to - * acquire the real OIDC token. - * @param requestURL URL endpoint to use to request the token. - * @param outputDir Path to a directory on disk where the file should be - * written. The function will determine the file name and write to it securely. - */ -interface CreateCredentialsFileParameters { - providerID: string; - serviceAccount: string; - requestToken: string; - requestURL: string; - outputDir: string; -} - -export class Client { - /** - * request is a high-level helper that returns a promise from the executed - * request. - */ - static request(opts: RequestOptions, data?: any): Promise { - if (!opts.headers) { - opts.headers = {}; - } - - if (!opts.headers['User-Agent']) { - opts.headers['User-Agent'] = 'google-github-actions:auth/0.3.0'; - } - - return new Promise((resolve, reject) => { - const req = https.request(opts, (res) => { - res.setEncoding('utf8'); - - let body = ''; - res.on('data', (data) => { - body += data; - }); - - res.on('end', () => { - if (res.statusCode && res.statusCode >= 400) { - reject(body); - } else { - resolve(body); - } - }); - }); - - req.on('error', (err) => { - reject(err); - }); - - if (data != null) { - req.write(data); - } - - req.end(); - }); - } - - /** - * googleFederatedToken generates a Google Cloud federated token using the - * provided OIDC token and Workload Identity Provider. - */ - static async googleFederatedToken({ - providerID, - token, - }: GoogleFederatedTokenParameters): Promise { - const stsURL = new URL('https://sts.googleapis.com/v1/token'); - - const data = { - audience: '//iam.googleapis.com/' + providerID, - 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:oauth:token-type:jwt', - subjectToken: token, - }; - - const opts = { - hostname: stsURL.hostname, - port: stsURL.port, - path: stsURL.pathname + stsURL.search, - method: 'POST', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - }, - }; - - try { - const resp = await Client.request(opts, JSON.stringify(data)); - const parsed = JSON.parse(resp); - return parsed['access_token']; - } catch (err) { - throw new Error(`failed to generate Google Cloud federated token for ${providerID}: ${err}`); - } - } - - /** - * googleAccessToken generates a Google Cloud access token for the provided - * service account email or unique id. - */ - static async googleAccessToken({ - token, - serviceAccount, - delegates, - scopes, - lifetime, - }: GoogleAccessTokenParameters): Promise { - const serviceAccountID = `projects/-/serviceAccounts/${serviceAccount}`; - const tokenURL = new URL( - `https://iamcredentials.googleapis.com/v1/${serviceAccountID}:generateAccessToken`, - ); - - const data = { - delegates: delegates, - lifetime: lifetime, - scope: scopes, - }; - - 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 { - accessToken: parsed['accessToken'], - expiration: parsed['expireTime'], - }; - } catch (err) { - 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, - includeEmail, - }: 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: includeEmail, - }; - - 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}`); - } - } - - /** - * createCredentialsFile creates a Google Cloud credentials file that can be - * set as GOOGLE_APPLICATION_CREDENTIALS for gcloud and client libraries. - */ - static async createCredentialsFile({ - providerID, - serviceAccount, - requestToken, - requestURL, - outputDir, - }: CreateCredentialsFileParameters): Promise { - const data = { - type: 'external_account', - audience: `//iam.googleapis.com/${providerID}`, - subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', - token_url: 'https://sts.googleapis.com/v1/token', - service_account_impersonation_url: `https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${serviceAccount}:generateAccessToken`, - credential_source: { - url: requestURL, - headers: { - Authorization: `Bearer ${requestToken}`, - }, - format: { - type: 'json', - subject_token_field_name: 'value', - }, - }, - }; - - // Generate a random filename to store the credential. 12 bytes is 24 - // characters in hex. It's not the ideal entropy, but we have to be under - // the 255 character limit for Windows filenames (which includes their - // entire leading path). - const uniqueName = crypto.randomBytes(12).toString('hex'); - const pth = path.join(outputDir, uniqueName); - - // Write the file as 0640 so the owner has RW, group as R, and the file is - // otherwise unreadable. Also write with EXCL to prevent a symlink attack. - await fs.writeFile(pth, JSON.stringify(data), { mode: 0o640, flag: 'wx' }); - - return pth; - } -} diff --git a/src/main.ts b/src/main.ts index 267dd50..6d99219 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,29 +1,9 @@ 'use strict'; import * as core from '@actions/core'; -import { Client } from './client'; -import { URL } from 'url'; - -/** - * Converts a multi-line or comma-separated collection of strings into an array - * of trimmed strings. - */ -function explodeStrings(input: string): Array { - if (input == null || input.length === 0) { - return []; - } - - const list = new Array(); - for (const line of input.split(`\n`)) { - for (const piece of line.split(',')) { - const entry = piece.trim(); - if (entry !== '') { - list.push(entry); - } - } - } - return list; -} +import { WIFClient } from './workload_identity'; +import { ActionAuth } from './actionauth'; +import { explodeStrings } from './utils'; /** * Executes the main action, documented inline. @@ -35,13 +15,19 @@ async function run(): Promise { required: true, }); const serviceAccount = core.getInput('service_account', { required: true }); - const audience = - core.getInput('audience') || `https://iam.googleapis.com/${workloadIdentityProvider}`; + // audience will default to the WIF provider ID when used with WIF + const audience = core.getInput('audience'); const createCredentialsFile = core.getBooleanInput('create_credentials_file'); const activateCredentialsFile = core.getBooleanInput('activate_credentials_file'); const tokenFormat = core.getInput('token_format'); const delegates = explodeStrings(core.getInput('delegates')); + const client: ActionAuth = new WIFClient({ + providerID: workloadIdentityProvider, + serviceAccount: serviceAccount, + audience: audience, + }); + // Always write the credentials file first, before trying to generate // tokens. This will ensure the file is written even if token generation // fails, which means continue-on-error actions will still have the file @@ -52,59 +38,18 @@ async function run(): Promise { throw new Error('$RUNNER_TEMP is not set'); } - // Extract the request token and request URL from the environment. These - // are only set when an id-token is requested and the submitter has - // collaborator permissions. - const requestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; - const requestURLRaw = process.env.ACTIONS_ID_TOKEN_REQUEST_URL; - if (!requestToken || !requestURLRaw) { - throw new Error( - 'GitHub Actions did not inject $ACTIONS_ID_TOKEN_REQUEST_TOKEN or ' + - '$ACTIONS_ID_TOKEN_REQUEST_URL into this job. This most likely ' + - 'means the GitHub Actions workflow permissions are incorrect, or ' + - 'this job is being run from a fork. For more information, please ' + - 'see the GitHub documentation at https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token', - ); - } - - const requestURL = new URL(requestURLRaw); - - // Append the audience value to the request. - const params = requestURL.searchParams; - params.set('audience', audience); - requestURL.search = params.toString(); - - // Create the credentials file. - const outputPath = await Client.createCredentialsFile({ - providerID: workloadIdentityProvider, - serviceAccount: serviceAccount, - requestToken: requestToken, - requestURL: requestURL.toString(), - outputDir: runnerTempDir, - }); - core.setOutput('credentials_file_path', outputPath); + const { credentialsPath, envVars } = await client.createCredentialsFile(runnerTempDir); + core.setOutput('credentials_file_path', credentialsPath); // Also set the magic environment variable for gcloud and SDKs if // requested. - if (activateCredentialsFile) { - core.exportVariable('GOOGLE_APPLICATION_CREDENTIALS', outputPath); + if (activateCredentialsFile && envVars) { + for (const [k, v] of envVars) { + core.exportVariable(k, v); + } } } - // getFederatedToken is a closure that gets the federated token. - const getFederatedToken = async (): Promise => { - // Get the GitHub OIDC token. - const githubOIDCToken = await core.getIDToken(audience); - - // Exchange the GitHub OIDC token for a Google Federated Token. - const googleFederatedToken = await Client.googleFederatedToken({ - providerID: workloadIdentityProvider, - token: githubOIDCToken, - }); - core.setSecret(googleFederatedToken); - return googleFederatedToken; - }; - switch (tokenFormat) { case '': { break; @@ -116,14 +61,13 @@ async function run(): Promise { const accessTokenLifetime = core.getInput('access_token_lifetime'); const accessTokenScopes = explodeStrings(core.getInput('access_token_scopes')); - const googleFederatedToken = await getFederatedToken(); - const { accessToken, expiration } = await Client.googleAccessToken({ - token: googleFederatedToken, - serviceAccount: serviceAccount, - delegates: delegates, - lifetime: accessTokenLifetime, + const { accessToken, expiration } = await client.getAccessToken({ + serviceAccount, + delegates, scopes: accessTokenScopes, + lifetime: accessTokenLifetime, }); + core.setSecret(accessToken); core.setOutput('access_token', accessToken); core.setOutput('access_token_expiration', expiration); @@ -132,13 +76,10 @@ async function run(): Promise { case 'id_token': { const idTokenAudience = core.getInput('id_token_audience', { required: true }); const idTokenIncludeEmail = core.getBooleanInput('id_token_include_email'); - - const googleFederatedToken = await getFederatedToken(); - const { token } = await Client.googleIDToken({ - token: googleFederatedToken, - serviceAccount: serviceAccount, - delegates: delegates, + const { token } = await client.getIDToken({ + serviceAccount, audience: idTokenAudience, + delegates, includeEmail: idTokenIncludeEmail, }); core.setSecret(token); diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..9bf284c --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,47 @@ +import { promises as fs } from 'fs'; +import crypto from 'crypto'; +import path from 'path'; + +/** + * writeCredFile writes a file to disk in a given directory with a + * random name. + * + * @param outputDir Directory to create random file in. + * @param data Data to write to file. + * @returns Path to written file. + */ +export async function writeCredFile(outputDir: string, data: string): Promise { + // Generate a random filename to store the credential. 12 bytes is 24 + // characters in hex. It's not the ideal entropy, but we have to be under + // the 255 character limit for Windows filenames (which includes their + // entire leading path). + const uniqueName = crypto.randomBytes(12).toString('hex'); + const pth = path.join(outputDir, uniqueName); + + // Write the file as 0640 so the owner has RW, group as R, and the file is + // otherwise unreadable. Also write with EXCL to prevent a symlink attack. + await fs.writeFile(pth, data, { mode: 0o640, flag: 'wx' }); + + return pth; +} + +/** + * Converts a multi-line or comma-separated collection of strings into an array + * of trimmed strings. + */ +export function explodeStrings(input: string): Array { + if (input == null || input.length === 0) { + return []; + } + + const list = new Array(); + for (const line of input.split(`\n`)) { + for (const piece of line.split(',')) { + const entry = piece.trim(); + if (entry !== '') { + list.push(entry); + } + } + } + return list; +} diff --git a/src/workload_identity.ts b/src/workload_identity.ts new file mode 100644 index 0000000..2a6e857 --- /dev/null +++ b/src/workload_identity.ts @@ -0,0 +1,178 @@ +'use strict'; + +import { URL } from 'url'; +import * as core from '@actions/core'; +import { + ActionAuth, + CreateCredentialsFileResponse, + GoogleAccessTokenParameters, + GoogleAccessTokenResponse, + GoogleIDTokenParameters, + GoogleIDTokenResponse, +} from './actionauth'; +import { writeCredFile } from './utils'; +import { BaseClient } from './base'; + +/** + * GoogleFederatedTokenParameters are the parameters to generate a Federated + * Identity Token as described in: + * + * https://cloud.google.com/iam/docs/access-resources-oidc#exchange-token + * + * @param providerID Full path (including project, location, etc) to the Google + * Cloud Workload Identity Provider. + * @param token OIDC token to exchange for a Google Cloud federated token. + */ +interface GoogleFederatedTokenParameters { + providerID: string; + token: string; +} + +/** + * Available options to create the WIF client. + * + * @param providerID Full path (including project, location, etc) to the Google + * Cloud Workload Identity Provider. + * @param serviceAccount Email address or unique identifier of the service + * account to impersonate + * @param audience The value for the audience parameter in the generated GitHub Actions OIDC token, + * defaults to the value of workload_identity_provider + */ +interface WIFClientOptions { + providerID: string; + serviceAccount: string; + audience?: string; +} + +export class WIFClient implements ActionAuth { + readonly providerID: string; + readonly serviceAccount: string; + readonly audience: string; + + constructor(opts: WIFClientOptions) { + this.providerID = opts.providerID; + this.serviceAccount = opts.serviceAccount; + this.audience = opts.audience ? opts.audience : `https://iam.googleapis.com/${this.providerID}`; + } + + /** + * googleFederatedToken generates a Google Cloud federated token using the + * provided OIDC token and Workload Identity Provider. + */ + static async googleFederatedToken({ + providerID, + token, + }: GoogleFederatedTokenParameters): Promise { + const stsURL = new URL('https://sts.googleapis.com/v1/token'); + + const data = { + audience: '//iam.googleapis.com/' + providerID, + 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:oauth:token-type:jwt', + subjectToken: token, + }; + + const opts = { + hostname: stsURL.hostname, + port: stsURL.port, + path: stsURL.pathname + stsURL.search, + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + }; + + try { + const resp = await BaseClient.request(opts, JSON.stringify(data)); + const parsed = JSON.parse(resp); + return parsed['access_token']; + } catch (err) { + throw new Error(`failed to generate Google Cloud federated token for ${providerID}: ${err}`); + } + } + + /** + * getFederatedToken generates a Google Cloud federated token using the + * GitHub OIDC token. + */ + private async getFederatedToken(): Promise { + // Get the GitHub OIDC token. + const githubOIDCToken = await core.getIDToken(this.audience); + // Exchange the GitHub OIDC token for a Google Federated Token. + const googleFederatedToken = await WIFClient.googleFederatedToken({ + providerID: this.providerID, + token: githubOIDCToken, + }); + core.setSecret(googleFederatedToken); + return googleFederatedToken; + } + + /** + * getAccessToken generates a Google Cloud access token for the provided + * service account email or unique id. + */ + async getAccessToken(opts: GoogleAccessTokenParameters): Promise { + const googleFederatedToken = await this.getFederatedToken(); + return await BaseClient.googleAccessToken(googleFederatedToken, opts); + } + + /** + * getIDToken generates a Google Cloud ID token for the provided + * service account email or unique id. + */ + async getIDToken(tokenParams: GoogleIDTokenParameters): Promise { + const googleFederatedToken = await this.getFederatedToken(); + return await BaseClient.googleIDToken(googleFederatedToken, tokenParams); + } + + /** + * createCredentialsFile creates a Google Cloud credentials file that can be + * set as GOOGLE_APPLICATION_CREDENTIALS for gcloud and client libraries. + */ + async createCredentialsFile(outputDir: string): Promise { + // Extract the request token and request URL from the environment. These + // are only set when an id-token is requested and the submitter has + // collaborator permissions. + const requestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; + const requestURLRaw = process.env.ACTIONS_ID_TOKEN_REQUEST_URL; + if (!requestToken || !requestURLRaw) { + throw new Error( + 'GitHub Actions did not inject $ACTIONS_ID_TOKEN_REQUEST_TOKEN or ' + + '$ACTIONS_ID_TOKEN_REQUEST_URL into this job. This most likely ' + + 'means the GitHub Actions workflow permissions are incorrect, or ' + + 'this job is being run from a fork. For more information, please ' + + 'see the GitHub documentation at https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token', + ); + } + const requestURL = new URL(requestURLRaw); + + // Append the audience value to the request. + const params = requestURL.searchParams; + params.set('audience', this.audience); + requestURL.search = params.toString(); + const data = { + type: 'external_account', + audience: `//iam.googleapis.com/${this.providerID}`, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + token_url: 'https://sts.googleapis.com/v1/token', + service_account_impersonation_url: `https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${this.serviceAccount}:generateAccessToken`, + credential_source: { + url: requestURL, + headers: { + Authorization: `Bearer ${requestToken}`, + }, + format: { + type: 'json', + subject_token_field_name: 'value', + }, + }, + }; + + const credentialsPath = await writeCredFile(outputDir, JSON.stringify(data)); + const envVars = new Map([['GOOGLE_APPLICATION_CREDENTIALS', credentialsPath]]); + return { credentialsPath, envVars }; + } +}