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
This commit is contained in:
parent
3fe2a3779a
commit
d5a354ef10
15
.github/workflows/test.yaml
vendored
15
.github/workflows/test.yaml
vendored
@ -50,6 +50,11 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: '12.x'
|
node-version: '12.x'
|
||||||
|
|
||||||
|
- name: 'build'
|
||||||
|
run: |-
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
|
||||||
- uses: 'google-github-actions/setup-gcloud@master'
|
- uses: 'google-github-actions/setup-gcloud@master'
|
||||||
with:
|
with:
|
||||||
project_id: 'actions-oidc-test'
|
project_id: 'actions-oidc-test'
|
||||||
@ -84,6 +89,11 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: '12.x'
|
node-version: '12.x'
|
||||||
|
|
||||||
|
- name: 'build'
|
||||||
|
run: |-
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
|
||||||
- id: 'auth'
|
- id: 'auth'
|
||||||
name: 'auth'
|
name: 'auth'
|
||||||
uses: './'
|
uses: './'
|
||||||
@ -108,6 +118,11 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: '12.x'
|
node-version: '12.x'
|
||||||
|
|
||||||
|
- name: 'build'
|
||||||
|
run: |-
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
|
||||||
- id: 'auth'
|
- id: 'auth'
|
||||||
name: 'auth'
|
name: 'auth'
|
||||||
uses: './'
|
uses: './'
|
||||||
|
684
dist/index.js
vendored
684
dist/index.js
vendored
@ -194,27 +194,8 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
|
|||||||
};
|
};
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
const core = __importStar(__webpack_require__(470));
|
const core = __importStar(__webpack_require__(470));
|
||||||
const client_1 = __webpack_require__(976);
|
const workload_identity_1 = __webpack_require__(313);
|
||||||
const url_1 = __webpack_require__(835);
|
const utils_1 = __webpack_require__(163);
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
/**
|
/**
|
||||||
* Executes the main action, documented inline.
|
* Executes the main action, documented inline.
|
||||||
*/
|
*/
|
||||||
@ -226,11 +207,17 @@ function run() {
|
|||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const serviceAccount = core.getInput('service_account', { 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 createCredentialsFile = core.getBooleanInput('create_credentials_file');
|
||||||
const activateCredentialsFile = core.getBooleanInput('activate_credentials_file');
|
const activateCredentialsFile = core.getBooleanInput('activate_credentials_file');
|
||||||
const tokenFormat = core.getInput('token_format');
|
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
|
// Always write the credentials file first, before trying to generate
|
||||||
// tokens. This will ensure the file is written even if token generation
|
// tokens. This will ensure the file is written even if token generation
|
||||||
// fails, which means continue-on-error actions will still have the file
|
// fails, which means continue-on-error actions will still have the file
|
||||||
@ -240,50 +227,16 @@ function run() {
|
|||||||
if (!runnerTempDir) {
|
if (!runnerTempDir) {
|
||||||
throw new Error('$RUNNER_TEMP is not set');
|
throw new Error('$RUNNER_TEMP is not set');
|
||||||
}
|
}
|
||||||
// Extract the request token and request URL from the environment. These
|
const { credentialsPath, envVars } = yield client.createCredentialsFile(runnerTempDir);
|
||||||
// are only set when an id-token is requested and the submitter has
|
core.setOutput('credentials_file_path', credentialsPath);
|
||||||
// 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);
|
|
||||||
// Also set the magic environment variable for gcloud and SDKs if
|
// Also set the magic environment variable for gcloud and SDKs if
|
||||||
// requested.
|
// requested.
|
||||||
if (activateCredentialsFile) {
|
if (activateCredentialsFile && envVars) {
|
||||||
core.exportVariable('GOOGLE_APPLICATION_CREDENTIALS', outputPath);
|
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) {
|
switch (tokenFormat) {
|
||||||
case '': {
|
case '': {
|
||||||
break;
|
break;
|
||||||
@ -293,14 +246,12 @@ function run() {
|
|||||||
}
|
}
|
||||||
case 'access_token': {
|
case 'access_token': {
|
||||||
const accessTokenLifetime = core.getInput('access_token_lifetime');
|
const accessTokenLifetime = core.getInput('access_token_lifetime');
|
||||||
const accessTokenScopes = explodeStrings(core.getInput('access_token_scopes'));
|
const accessTokenScopes = (0, utils_1.explodeStrings)(core.getInput('access_token_scopes'));
|
||||||
const googleFederatedToken = yield getFederatedToken();
|
const { accessToken, expiration } = yield client.getAccessToken({
|
||||||
const { accessToken, expiration } = yield client_1.Client.googleAccessToken({
|
serviceAccount,
|
||||||
token: googleFederatedToken,
|
delegates,
|
||||||
serviceAccount: serviceAccount,
|
|
||||||
delegates: delegates,
|
|
||||||
lifetime: accessTokenLifetime,
|
|
||||||
scopes: accessTokenScopes,
|
scopes: accessTokenScopes,
|
||||||
|
lifetime: accessTokenLifetime,
|
||||||
});
|
});
|
||||||
core.setSecret(accessToken);
|
core.setSecret(accessToken);
|
||||||
core.setOutput('access_token', accessToken);
|
core.setOutput('access_token', accessToken);
|
||||||
@ -310,12 +261,10 @@ function run() {
|
|||||||
case 'id_token': {
|
case 'id_token': {
|
||||||
const idTokenAudience = core.getInput('id_token_audience', { required: true });
|
const idTokenAudience = core.getInput('id_token_audience', { required: true });
|
||||||
const idTokenIncludeEmail = core.getBooleanInput('id_token_include_email');
|
const idTokenIncludeEmail = core.getBooleanInput('id_token_include_email');
|
||||||
const googleFederatedToken = yield getFederatedToken();
|
const { token } = yield client.getIDToken({
|
||||||
const { token } = yield client_1.Client.googleIDToken({
|
serviceAccount,
|
||||||
token: googleFederatedToken,
|
|
||||||
serviceAccount: serviceAccount,
|
|
||||||
delegates: delegates,
|
|
||||||
audience: idTokenAudience,
|
audience: idTokenAudience,
|
||||||
|
delegates,
|
||||||
includeEmail: idTokenIncludeEmail,
|
includeEmail: idTokenIncludeEmail,
|
||||||
});
|
});
|
||||||
core.setSecret(token);
|
core.setSecret(token);
|
||||||
@ -607,6 +556,75 @@ if (process.env.NODE_DEBUG && /\btunnel\b/.test(process.env.NODE_DEBUG)) {
|
|||||||
exports.debug = debug; // for test
|
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:
|
/***/ 211:
|
||||||
@ -680,6 +698,174 @@ class PersonalAccessTokenCredentialHandler {
|
|||||||
exports.PersonalAccessTokenCredentialHandler = 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:
|
/***/ 357:
|
||||||
@ -1798,6 +1984,144 @@ module.exports = require("fs");
|
|||||||
|
|
||||||
module.exports = require("url");
|
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:
|
/***/ 950:
|
||||||
@ -1863,216 +2187,6 @@ function checkBypass(reqUrl) {
|
|||||||
exports.checkBypass = checkBypass;
|
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;
|
|
||||||
|
|
||||||
|
|
||||||
/***/ })
|
/***/ })
|
||||||
|
|
||||||
/******/ });
|
/******/ });
|
79
src/actionauth.ts
Normal file
79
src/actionauth.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* Defines the main interface for all clients that generate credentials.
|
||||||
|
*/
|
||||||
|
export interface ActionAuth {
|
||||||
|
getAccessToken(opts: GoogleAccessTokenParameters): Promise<GoogleAccessTokenResponse>;
|
||||||
|
getIDToken(opts: GoogleIDTokenParameters): Promise<GoogleIDTokenResponse>;
|
||||||
|
createCredentialsFile(outputDir: string): Promise<CreateCredentialsFileResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string>;
|
||||||
|
scopes?: Array<string>;
|
||||||
|
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<string>;
|
||||||
|
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<string, string>;
|
||||||
|
}
|
139
src/base.ts
Normal file
139
src/base.ts
Normal file
@ -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<string> {
|
||||||
|
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<GoogleIDTokenResponse> {
|
||||||
|
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<GoogleAccessTokenResponse> {
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
328
src/client.ts
328
src/client.ts
@ -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<string>;
|
|
||||||
scopes?: Array<string>;
|
|
||||||
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<string>;
|
|
||||||
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<string> {
|
|
||||||
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<string> {
|
|
||||||
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<GoogleAccessTokenResponse> {
|
|
||||||
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<GoogleIDTokenResponse> {
|
|
||||||
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<string> {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
109
src/main.ts
109
src/main.ts
@ -1,29 +1,9 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
import * as core from '@actions/core';
|
import * as core from '@actions/core';
|
||||||
import { Client } from './client';
|
import { WIFClient } from './workload_identity';
|
||||||
import { URL } from 'url';
|
import { ActionAuth } from './actionauth';
|
||||||
|
import { explodeStrings } from './utils';
|
||||||
/**
|
|
||||||
* Converts a multi-line or comma-separated collection of strings into an array
|
|
||||||
* of trimmed strings.
|
|
||||||
*/
|
|
||||||
function explodeStrings(input: string): Array<string> {
|
|
||||||
if (input == null || input.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const list = new Array<string>();
|
|
||||||
for (const line of input.split(`\n`)) {
|
|
||||||
for (const piece of line.split(',')) {
|
|
||||||
const entry = piece.trim();
|
|
||||||
if (entry !== '') {
|
|
||||||
list.push(entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return list;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Executes the main action, documented inline.
|
* Executes the main action, documented inline.
|
||||||
@ -35,13 +15,19 @@ async function run(): Promise<void> {
|
|||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const serviceAccount = core.getInput('service_account', { required: true });
|
const serviceAccount = core.getInput('service_account', { required: true });
|
||||||
const audience =
|
// audience will default to the WIF provider ID when used with WIF
|
||||||
core.getInput('audience') || `https://iam.googleapis.com/${workloadIdentityProvider}`;
|
const audience = core.getInput('audience');
|
||||||
const createCredentialsFile = core.getBooleanInput('create_credentials_file');
|
const createCredentialsFile = core.getBooleanInput('create_credentials_file');
|
||||||
const activateCredentialsFile = core.getBooleanInput('activate_credentials_file');
|
const activateCredentialsFile = core.getBooleanInput('activate_credentials_file');
|
||||||
const tokenFormat = core.getInput('token_format');
|
const tokenFormat = core.getInput('token_format');
|
||||||
const delegates = explodeStrings(core.getInput('delegates'));
|
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
|
// Always write the credentials file first, before trying to generate
|
||||||
// tokens. This will ensure the file is written even if token generation
|
// tokens. This will ensure the file is written even if token generation
|
||||||
// fails, which means continue-on-error actions will still have the file
|
// fails, which means continue-on-error actions will still have the file
|
||||||
@ -52,59 +38,18 @@ async function run(): Promise<void> {
|
|||||||
throw new Error('$RUNNER_TEMP is not set');
|
throw new Error('$RUNNER_TEMP is not set');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract the request token and request URL from the environment. These
|
const { credentialsPath, envVars } = await client.createCredentialsFile(runnerTempDir);
|
||||||
// are only set when an id-token is requested and the submitter has
|
core.setOutput('credentials_file_path', credentialsPath);
|
||||||
// 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);
|
|
||||||
|
|
||||||
// Also set the magic environment variable for gcloud and SDKs if
|
// Also set the magic environment variable for gcloud and SDKs if
|
||||||
// requested.
|
// requested.
|
||||||
if (activateCredentialsFile) {
|
if (activateCredentialsFile && envVars) {
|
||||||
core.exportVariable('GOOGLE_APPLICATION_CREDENTIALS', outputPath);
|
for (const [k, v] of envVars) {
|
||||||
|
core.exportVariable(k, v);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// getFederatedToken is a closure that gets the federated token.
|
|
||||||
const getFederatedToken = async (): Promise<string> => {
|
|
||||||
// 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) {
|
switch (tokenFormat) {
|
||||||
case '': {
|
case '': {
|
||||||
break;
|
break;
|
||||||
@ -116,14 +61,13 @@ async function run(): Promise<void> {
|
|||||||
const accessTokenLifetime = core.getInput('access_token_lifetime');
|
const accessTokenLifetime = core.getInput('access_token_lifetime');
|
||||||
const accessTokenScopes = explodeStrings(core.getInput('access_token_scopes'));
|
const accessTokenScopes = explodeStrings(core.getInput('access_token_scopes'));
|
||||||
|
|
||||||
const googleFederatedToken = await getFederatedToken();
|
const { accessToken, expiration } = await client.getAccessToken({
|
||||||
const { accessToken, expiration } = await Client.googleAccessToken({
|
serviceAccount,
|
||||||
token: googleFederatedToken,
|
delegates,
|
||||||
serviceAccount: serviceAccount,
|
|
||||||
delegates: delegates,
|
|
||||||
lifetime: accessTokenLifetime,
|
|
||||||
scopes: accessTokenScopes,
|
scopes: accessTokenScopes,
|
||||||
|
lifetime: accessTokenLifetime,
|
||||||
});
|
});
|
||||||
|
|
||||||
core.setSecret(accessToken);
|
core.setSecret(accessToken);
|
||||||
core.setOutput('access_token', accessToken);
|
core.setOutput('access_token', accessToken);
|
||||||
core.setOutput('access_token_expiration', expiration);
|
core.setOutput('access_token_expiration', expiration);
|
||||||
@ -132,13 +76,10 @@ async function run(): Promise<void> {
|
|||||||
case 'id_token': {
|
case 'id_token': {
|
||||||
const idTokenAudience = core.getInput('id_token_audience', { required: true });
|
const idTokenAudience = core.getInput('id_token_audience', { required: true });
|
||||||
const idTokenIncludeEmail = core.getBooleanInput('id_token_include_email');
|
const idTokenIncludeEmail = core.getBooleanInput('id_token_include_email');
|
||||||
|
const { token } = await client.getIDToken({
|
||||||
const googleFederatedToken = await getFederatedToken();
|
serviceAccount,
|
||||||
const { token } = await Client.googleIDToken({
|
|
||||||
token: googleFederatedToken,
|
|
||||||
serviceAccount: serviceAccount,
|
|
||||||
delegates: delegates,
|
|
||||||
audience: idTokenAudience,
|
audience: idTokenAudience,
|
||||||
|
delegates,
|
||||||
includeEmail: idTokenIncludeEmail,
|
includeEmail: idTokenIncludeEmail,
|
||||||
});
|
});
|
||||||
core.setSecret(token);
|
core.setSecret(token);
|
||||||
|
47
src/utils.ts
Normal file
47
src/utils.ts
Normal file
@ -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<string> {
|
||||||
|
// 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<string> {
|
||||||
|
if (input == null || input.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = new Array<string>();
|
||||||
|
for (const line of input.split(`\n`)) {
|
||||||
|
for (const piece of line.split(',')) {
|
||||||
|
const entry = piece.trim();
|
||||||
|
if (entry !== '') {
|
||||||
|
list.push(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
178
src/workload_identity.ts
Normal file
178
src/workload_identity.ts
Normal file
@ -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<string> {
|
||||||
|
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<string> {
|
||||||
|
// 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<GoogleAccessTokenResponse> {
|
||||||
|
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<GoogleIDTokenResponse> {
|
||||||
|
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<CreateCredentialsFileResponse> {
|
||||||
|
// 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<string, string>([['GOOGLE_APPLICATION_CREDENTIALS', credentialsPath]]);
|
||||||
|
return { credentialsPath, envVars };
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user