feat: add generic auth (#39)

* feat: add generic auth

Adds the ability to authenticate against any normal Vault endpoint by added the `authPayload` input.

When an unrecognized method is provided, the action will attempt to hit `v1/auth/<method>/login` with the provided `authPayload and parse out the token in the response
This commit is contained in:
Richard Simpson 2020-04-04 13:19:48 -05:00 committed by GitHub
parent ea29244204
commit 5c464962be
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 449 additions and 397 deletions

242
src/action.js Normal file
View file

@ -0,0 +1,242 @@
// @ts-check
const core = require('@actions/core');
const command = require('@actions/core/lib/command');
const got = require('got').default;
const { retrieveToken } = require('./auth');
const AUTH_METHODS = ['approle', 'token', 'github'];
const VALID_KV_VERSION = [-1, 1, 2];
async function exportSecrets() {
const vaultUrl = core.getInput('url', { required: true });
const vaultNamespace = core.getInput('namespace', { required: false });
const extraHeaders = parseHeadersInput('extraHeaders', { required: false });
const exportEnv = core.getInput('exportEnv', { required: false }) != 'false';
let enginePath = core.getInput('path', { required: false });
/** @type {number | string} */
let kvVersion = core.getInput('kv-version', { required: false });
const secretsInput = core.getInput('secrets', { required: true });
const secretRequests = parseSecretsInput(secretsInput);
const vaultMethod = (core.getInput('method', { required: false }) || 'token').toLowerCase();
if (!AUTH_METHODS.includes(vaultMethod)) {
throw Error(`Sorry, the authentication method ${vaultMethod} is not currently supported.`);
}
const defaultOptions = {
prefixUrl: vaultUrl,
headers: {}
}
for (const [headerName, headerValue] of extraHeaders) {
defaultOptions.headers[headerName] = headerValue;
}
if (vaultNamespace != null) {
defaultOptions.headers["X-Vault-Namespace"] = vaultNamespace;
}
const client = got.extend(defaultOptions);
const vaultToken = await retrieveToken(vaultMethod, client);
if (!enginePath) {
enginePath = 'secret';
}
if (!kvVersion) {
kvVersion = 2;
}
kvVersion = +kvVersion;
if (Number.isNaN(kvVersion) || !VALID_KV_VERSION.includes(kvVersion)) {
throw Error(`You must provide a valid K/V version (${VALID_KV_VERSION.slice(1).join(', ')}). Input: "${kvVersion}"`);
}
const responseCache = new Map();
for (const secretRequest of secretRequests) {
const { secretPath, outputVarName, envVarName, secretSelector, isJSONPath } = secretRequest;
const requestOptions = {
headers: {
'X-Vault-Token': vaultToken
},
};
for (const [headerName, headerValue] of extraHeaders) {
requestOptions.headers[headerName] = headerValue;
}
if (vaultNamespace != null) {
requestOptions.headers["X-Vault-Namespace"] = vaultNamespace;
}
let requestPath = `v1`;
const kvRequest = !secretPath.startsWith('/')
if (!kvRequest) {
requestPath += secretPath;
} else {
requestPath += (kvVersion === 2)
? `/${enginePath}/data/${secretPath}`
: `/${enginePath}/${secretPath}`;
}
let body;
if (responseCache.has(requestPath)) {
body = responseCache.get(requestPath);
core.debug(' using cached response');
} else {
const result = await client.get(requestPath, requestOptions);
body = result.body;
responseCache.set(requestPath, body);
}
let dataDepth = isJSONPath === true ? 0 : kvRequest === false ? 1 : kvVersion;
const secretData = getResponseData(body, dataDepth);
const value = selectData(secretData, secretSelector, isJSONPath);
command.issue('add-mask', value);
if (exportEnv) {
core.exportVariable(envVarName, `${value}`);
}
core.setOutput(outputVarName, `${value}`);
core.debug(`${secretPath} => outputs.${outputVarName}${exportEnv ? ` | env.${envVarName}` : ''}`);
}
};
/** @typedef {Object} SecretRequest
* @property {string} secretPath
* @property {string} envVarName
* @property {string} outputVarName
* @property {string} secretSelector
* @property {boolean} isJSONPath
*/
/**
* Parses a secrets input string into key paths and their resulting environment variable name.
* @param {string} secretsInput
*/
function parseSecretsInput(secretsInput) {
const secrets = secretsInput
.split(';')
.filter(key => !!key)
.map(key => key.trim())
.filter(key => key.length !== 0);
/** @type {SecretRequest[]} */
const output = [];
for (const secret of secrets) {
let path = secret;
let outputVarName = null;
const renameSigilIndex = secret.lastIndexOf('|');
if (renameSigilIndex > -1) {
path = secret.substring(0, renameSigilIndex).trim();
outputVarName = secret.substring(renameSigilIndex + 1).trim();
if (outputVarName.length < 1) {
throw Error(`You must provide a value when mapping a secret to a name. Input: "${secret}"`);
}
}
const pathParts = path
.split(/\s+/)
.map(part => part.trim())
.filter(part => part.length !== 0);
if (pathParts.length !== 2) {
throw Error(`You must provide a valid path and key. Input: "${secret}"`);
}
const [secretPath, secretSelector] = pathParts;
const isJSONPath = secretSelector.includes('.');
if (isJSONPath && !outputVarName) {
throw Error(`You must provide a name for the output key when using json selectors. Input: "${secret}"`);
}
let envVarName = outputVarName;
if (!outputVarName) {
outputVarName = secretSelector;
envVarName = normalizeOutputKey(outputVarName);
}
output.push({
secretPath,
envVarName,
outputVarName,
secretSelector,
isJSONPath
});
}
return output;
}
/**
* Parses a JSON response and returns the secret data
* @param {string} responseBody
* @param {number} dataLevel
*/
function getResponseData(responseBody, dataLevel) {
let secretData = JSON.parse(responseBody);
for (let i = 0; i < dataLevel; i++) {
secretData = secretData['data'];
}
return secretData;
}
/**
* Parses a JSON response and returns the secret data
* @param {Object} data
* @param {string} selector
*/
function selectData(data, selector, isJSONPath) {
if (!isJSONPath) {
return data[selector];
}
// TODO: JSONPath
}
/**
* Replaces any forward-slash characters to
* @param {string} dataKey
*/
function normalizeOutputKey(dataKey) {
return dataKey.replace('/', '__').replace(/[^\w-]/, '').toUpperCase();
}
/**
* @param {string} inputKey
* @param {any} inputOptions
*/
function parseHeadersInput(inputKey, inputOptions) {
/** @type {string}*/
const rawHeadersString = core.getInput(inputKey, inputOptions) || '';
const headerStrings = rawHeadersString
.split('\n')
.map(line => line.trim())
.filter(line => line !== '');
return headerStrings
.reduce((map, line) => {
const seperator = line.indexOf(':');
const key = line.substring(0, seperator).trim().toLowerCase();
const value = line.substring(seperator + 1).trim();
if (map.has(key)) {
map.set(key, [map.get(key), value].join(', '));
} else {
map.set(key, value);
}
return map;
}, new Map());
}
module.exports = {
exportSecrets,
parseSecretsInput,
parseResponse: getResponseData,
normalizeOutputKey,
parseHeadersInput
};

270
src/action.test.js Normal file
View file

@ -0,0 +1,270 @@
jest.mock('got');
jest.mock('@actions/core');
jest.mock('@actions/core/lib/command');
const core = require('@actions/core');
const got = require('got');
const {
exportSecrets,
parseSecretsInput,
parseResponse,
parseHeadersInput
} = require('./action');
const { when } = require('jest-when');
describe('parseSecretsInput', () => {
it('parses simple secret', () => {
const output = parseSecretsInput('test key');
expect(output).toContainEqual({
secretPath: 'test',
secretSelector: 'key',
outputVarName: 'key',
envVarName: 'KEY',
isJSONPath: false
});
});
it('parses mapped secret', () => {
const output = parseSecretsInput('test key|testName');
expect(output).toHaveLength(1);
expect(output[0]).toMatchObject({
outputVarName: 'testName',
envVarName: 'testName',
});
});
it('fails on invalid mapped name', () => {
expect(() => parseSecretsInput('test key|'))
.toThrowError(`You must provide a value when mapping a secret to a name. Input: "test key|"`)
});
it('fails on invalid path for mapped', () => {
expect(() => parseSecretsInput('|testName'))
.toThrowError(`You must provide a valid path and key. Input: "|testName"`)
});
it('parses multiple secrets', () => {
const output = parseSecretsInput('first a;second b;');
expect(output).toHaveLength(2);
expect(output[0]).toMatchObject({
secretPath: 'first',
});
expect(output[1]).toMatchObject({
secretPath: 'second',
});
});
it('parses multiple complex secret input', () => {
const output = parseSecretsInput('first a;second b|secondName');
expect(output).toHaveLength(2);
expect(output[0]).toMatchObject({
outputVarName: 'a',
envVarName: 'A',
});
expect(output[1]).toMatchObject({
outputVarName: 'secondName',
envVarName: 'secondName'
});
});
it('parses multiline input', () => {
const output = parseSecretsInput(`
first a;
second b;
third c | SOME_C;`);
expect(output).toHaveLength(3);
expect(output[0]).toMatchObject({
secretPath: 'first',
});
expect(output[1]).toMatchObject({
outputVarName: 'b',
envVarName: 'B'
});
expect(output[2]).toMatchObject({
outputVarName: 'SOME_C',
envVarName: 'SOME_C',
});
})
});
describe('parseHeaders', () => {
it('parses simple header', () => {
when(core.getInput)
.calledWith('extraHeaders')
.mockReturnValueOnce('TEST: 1');
const result = parseHeadersInput('extraHeaders');
expect(Array.from(result)).toContainEqual(['test', '1']);
});
it('parses simple header with whitespace', () => {
when(core.getInput)
.calledWith('extraHeaders')
.mockReturnValueOnce(`
TEST: 1
`);
const result = parseHeadersInput('extraHeaders');
expect(Array.from(result)).toContainEqual(['test', '1']);
});
it('parses multiple headers', () => {
when(core.getInput)
.calledWith('extraHeaders')
.mockReturnValueOnce(`
TEST: 1
FOO: bAr
`);
const result = parseHeadersInput('extraHeaders');
expect(Array.from(result)).toContainEqual(['test', '1']);
expect(Array.from(result)).toContainEqual(['foo', 'bAr']);
});
it('parses null response', () => {
when(core.getInput)
.calledWith('extraHeaders')
.mockReturnValueOnce(null);
const result = parseHeadersInput('extraHeaders');
expect(Array.from(result)).toHaveLength(0);
});
})
describe('parseResponse', () => {
// https://www.vaultproject.io/api/secret/kv/kv-v1.html#sample-response
it('parses K/V version 1 response', () => {
const response = JSON.stringify({
data: {
foo: 'bar'
}
})
const output = parseResponse(response, 1);
expect(output).toEqual({
foo: 'bar'
});
});
// https://www.vaultproject.io/api/secret/kv/kv-v2.html#read-secret-version
it('parses K/V version 2 response', () => {
const response = JSON.stringify({
data: {
data: {
foo: 'bar'
}
}
})
const output = parseResponse(response, 2);
expect(output).toEqual({
foo: 'bar'
});
});
});
describe('exportSecrets', () => {
beforeEach(() => {
jest.resetAllMocks();
when(core.getInput)
.calledWith('url')
.mockReturnValueOnce('http://vault:8200');
when(core.getInput)
.calledWith('token')
.mockReturnValueOnce('EXAMPLE');
});
function mockInput(key) {
when(core.getInput)
.calledWith('secrets')
.mockReturnValueOnce(key);
}
function mockVersion(version) {
when(core.getInput)
.calledWith('kv-version')
.mockReturnValueOnce(version);
}
function mockExtraHeaders(headerString) {
when(core.getInput)
.calledWith('extraHeaders')
.mockReturnValueOnce(headerString);
}
function mockVaultData(data, version='2') {
switch(version) {
case '1':
got.extend.mockReturnValue({
get: async () => ({ body: JSON.stringify({ data }) })
});
break;
case '2':
got.extend.mockReturnValue({
get: async () => ({ body: JSON.stringify({ data: {
data
} }) })
});
break;
}
}
it('simple secret retrieval', async () => {
mockInput('test key');
mockVaultData({
key: 1
});
await exportSecrets();
expect(core.exportVariable).toBeCalledWith('KEY', '1');
expect(core.setOutput).toBeCalledWith('key', '1');
});
it('mapped secret retrieval', async () => {
mockInput('test key|TEST_NAME');
mockVaultData({
key: 1
});
await exportSecrets();
expect(core.exportVariable).toBeCalledWith('TEST_NAME', '1');
expect(core.setOutput).toBeCalledWith('TEST_NAME', '1');
});
it('simple secret retrieval from K/V v1', async () => {
const version = '1';
mockInput('test key');
mockExtraHeaders(`
TEST: 1
`);
mockVaultData({
key: 1
});
await exportSecrets();
expect(core.exportVariable).toBeCalledWith('KEY', '1');
expect(core.setOutput).toBeCalledWith('key', '1');
});
it('simple secret retrieval with extra headers', async () => {
const version = '1';
mockInput('test key');
mockVersion(version);
mockVaultData({
key: 1
}, version);
await exportSecrets();
expect(core.exportVariable).toBeCalledWith('KEY', '1');
expect(core.setOutput).toBeCalledWith('key', '1');
});
});

81
src/auth.js Normal file
View file

@ -0,0 +1,81 @@
// @ts-check
const core = require('@actions/core');
/***
* Authenticate with Vault and retrieve a Vault token that can be used for requests.
* @param {string} method
* @param {import('got').Got} client
*/
async function retrieveToken(method, client) {
switch (method) {
case 'approle': {
const vaultRoleId = core.getInput('roleId', { required: true });
const vaultSecretId = core.getInput('secretId', { required: true });
return await getClientToken(client, method, { role_id: vaultRoleId, secret_id: vaultSecretId });
}
case 'github': {
const githubToken = core.getInput('githubToken', { required: true });
return await getClientToken(client, method, { token: githubToken });
}
default: {
if (!method || method === 'token') {
return core.getInput('token', { required: true });
} else {
/** @type {string} */
const payload = core.getInput('authPayload', { required: true });
if (!payload) {
throw Error('When using a custom authentication method, you must provide the payload');
}
return await getClientToken(client, method, JSON.parse(payload.trim()));
}
}
}
}
/***
* Call the appropriate login endpoint and parse out the token in the response.
* @param {import('got').Got} client
* @param {string} method
* @param {any} payload
*/
async function getClientToken(client, method, payload) {
/** @type {'json'} */
const responseType = 'json';
var options = {
json: payload,
responseType,
};
core.debug(`Retrieving Vault Token from v1/auth/${method}/login endpoint`);
/** @type {import('got').Response<VaultLoginResponse>} */
const response = await client.post(`v1/auth/${method}/login`, options);
if (response && response.body && response.body.auth && response.body.auth.client_token) {
core.debug('✔ Vault Token successfully retrieved');
core.startGroup('Token Info');
core.debug(`Operating under policies: ${JSON.stringify(response.body.auth.policies)}`);
core.debug(`Token Metadata: ${JSON.stringify(response.body.auth.metadata)}`);
core.endGroup();
return response.body.auth.client_token;
} else {
throw Error(`Unable to retrieve token from ${method}'s login endpoint.`);
}
}
/***
* @typedef {Object} VaultLoginResponse
* @property {{
* client_token: string;
* accessor: string;
* policies: string[];
* metadata: unknown;
* lease_duration: number;
* renewable: boolean;
* }} auth
*/
module.exports = {
retrieveToken,
};

10
src/index.js Normal file
View file

@ -0,0 +1,10 @@
const core = require('@actions/core');
const { exportSecrets } = require('./action');
(async () => {
try {
await core.group('Get Vault Secrets', exportSecrets);
} catch (error) {
core.setFailed(error.message);
}
})();