diff --git a/app/common/constants.js b/app/common/constants.js index 1cf9d00a67b..c6b81844265 100644 --- a/app/common/constants.js +++ b/app/common/constants.js @@ -139,6 +139,7 @@ export const AUTH_BASIC = 'basic'; export const AUTH_DIGEST = 'digest'; export const AUTH_BEARER = 'bearer'; export const AUTH_NTLM = 'ntlm'; +export const AUTH_AWS_IAM = 'iam'; const authTypesMap = { [AUTH_BASIC]: ['Basic', 'Basic Auth'], @@ -146,7 +147,8 @@ const authTypesMap = { [AUTH_NTLM]: ['NTLM', 'Microsoft NTLM'], [AUTH_BEARER]: ['Bearer', 'Bearer Token'], [AUTH_OAUTH_1]: ['OAuth 1', 'OAuth 1.0'], - [AUTH_OAUTH_2]: ['OAuth 2', 'OAuth 2.0'] + [AUTH_OAUTH_2]: ['OAuth 2', 'OAuth 2.0'], + [AUTH_AWS_IAM]: ['AWS', 'AWS IAM v4'] }; export function getPreviewModeName (previewMode, useLong = false) { diff --git a/app/models/request.js b/app/models/request.js index ebfa1aebca4..950f3c469e4 100644 --- a/app/models/request.js +++ b/app/models/request.js @@ -1,4 +1,4 @@ -import {AUTH_BASIC, AUTH_DIGEST, AUTH_NONE, AUTH_NTLM, AUTH_OAUTH_2, CONTENT_TYPE_FILE, CONTENT_TYPE_FORM_DATA, CONTENT_TYPE_FORM_URLENCODED, CONTENT_TYPE_OTHER, getContentTypeFromHeaders, METHOD_GET} from '../common/constants'; +import {AUTH_BASIC, AUTH_DIGEST, AUTH_NONE, AUTH_NTLM, AUTH_OAUTH_2, AUTH_AWS_IAM, CONTENT_TYPE_FILE, CONTENT_TYPE_FORM_DATA, CONTENT_TYPE_FORM_URLENCODED, CONTENT_TYPE_OTHER, getContentTypeFromHeaders, METHOD_GET} from '../common/constants'; import * as db from '../common/database'; import {getContentTypeHeader} from '../common/misc'; import {buildFromParams, deconstructToParams} from '../common/querystring'; @@ -50,6 +50,14 @@ export function newAuth (type, oldAuth = {}) { case AUTH_OAUTH_2: return {type, grantType: GRANT_TYPE_AUTHORIZATION_CODE}; + case AUTH_AWS_IAM: + return { + type, + disabled: oldAuth.disabled || false, + accessKeyId: oldAuth.accessKeyId || '', + secretAccessKey: oldAuth.secretAccessKey || '' + }; + // Types needing no defaults default: return {type}; diff --git a/app/network/__tests__/network.test.js b/app/network/__tests__/network.test.js index cb9b057e97b..f4b4c6920b3 100644 --- a/app/network/__tests__/network.test.js +++ b/app/network/__tests__/network.test.js @@ -3,7 +3,8 @@ import * as db from '../../common/database'; import {join as pathJoin, resolve as pathResolve} from 'path'; import {getRenderedRequest} from '../../common/render'; import * as models from '../../models'; -import {AUTH_BASIC, CONTENT_TYPE_FILE, CONTENT_TYPE_FORM_DATA, CONTENT_TYPE_FORM_URLENCODED, getAppVersion} from '../../common/constants'; +import {AUTH_BASIC, AUTH_AWS_IAM, CONTENT_TYPE_FILE, CONTENT_TYPE_FORM_DATA, CONTENT_TYPE_FORM_URLENCODED, getAppVersion} from '../../common/constants'; +import {filterHeaders} from '../../common/misc'; describe('actuallySend()', () => { beforeEach(() => db.init(models.types(), {inMemoryOnly: true}, true)); @@ -400,3 +401,46 @@ describe('actuallySend()', () => { }); }); }); + +describe('_getAwsAuthHeaders', () => { + it('should generate expected headers', () => { + const req = { + authentication: { + type: AUTH_AWS_IAM, + accessKeyId: 'AKIA99999999', + secretAccessKey: 'SAK9999999999999' + }, + headers: [{name: 'content-type', value: 'application/json'}], + body: {text: '{}'} + }; + const url = 'https://example.com/path?query=q1'; + const headers = networkUtils._getAwsAuthHeaders(req, url); + expect(filterHeaders(headers, 'x-amz-date')[0].value) + .toMatch(/^\d{8}T\d{6}Z$/); + expect(filterHeaders(headers, 'host')[0].value).toEqual('example.com'); + expect(filterHeaders(headers, 'authorization')[0].value) + .toMatch(/^AWS4-HMAC-SHA256 Credential=AKIA99999999/); + expect(filterHeaders(headers, 'content-type')) + .toHaveLength(0); + }); + + it('should handle sparse request', () => { + const req = { + authentication: { + type: AUTH_AWS_IAM, + accessKeyId: 'AKIA99999999', + secretAccessKey: 'SAK9999999999999' + }, + headers: [] + }; + const url = 'https://example.com'; + const headers = networkUtils._getAwsAuthHeaders(req, url); + expect(filterHeaders(headers, 'x-amz-date')[0].value) + .toMatch(/^\d{8}T\d{6}Z$/); + expect(filterHeaders(headers, 'host')[0].value).toEqual('example.com'); + expect(filterHeaders(headers, 'authorization')[0].value) + .toMatch(/^AWS4-HMAC-SHA256 Credential=AKIA99999999/); + expect(filterHeaders(headers, 'content-type')) + .toHaveLength(0); + }); +}); diff --git a/app/network/network.js b/app/network/network.js index 91c6eb44268..23c22153faf 100644 --- a/app/network/network.js +++ b/app/network/network.js @@ -7,7 +7,7 @@ import {join as pathJoin} from 'path'; import * as models from '../models'; import * as querystring from '../common/querystring'; import * as util from '../common/misc.js'; -import {AUTH_BASIC, AUTH_DIGEST, AUTH_NTLM, CONTENT_TYPE_FORM_DATA, CONTENT_TYPE_FORM_URLENCODED, getAppVersion} from '../common/constants'; +import {AUTH_BASIC, AUTH_DIGEST, AUTH_NTLM, AUTH_AWS_IAM, CONTENT_TYPE_FORM_DATA, CONTENT_TYPE_FORM_URLENCODED, getAppVersion} from '../common/constants'; import {describeByteSize, hasAuthHeader, hasContentTypeHeader, hasUserAgentHeader, setDefaultProtocol} from '../common/misc'; import {getRenderedRequest} from '../common/render'; import fs from 'fs'; @@ -16,6 +16,7 @@ import * as CACerts from './cacert'; import {getAuthHeader} from './authentication'; import {cookiesFromJar, jarFromCookies} from '../common/cookies'; import urlMatchesCertHost from './url-matches-cert-host'; +import aws4 from 'aws4'; // Time since user's last keypress to wait before making the request const MAX_DELAY_TIME = 1000; @@ -383,6 +384,13 @@ export function _actuallySend (renderedRequest, workspace, settings) { setOpt(Curl.option.HTTPAUTH, Curl.auth.NTLM); setOpt(Curl.option.USERNAME, username || ''); setOpt(Curl.option.PASSWORD, password || ''); + } else if (renderedRequest.authentication.type === AUTH_AWS_IAM) { + if (!renderedRequest.body.text) { + throw new Error('AWS authentication not supported for provided body type'); + } + _getAwsAuthHeaders(renderedRequest, finalUrl).forEach((header) => { + headers.push(header); + }); } else { const authHeader = await getAuthHeader( renderedRequest._id, @@ -562,6 +570,33 @@ function _getCurlHeader (curlHeadersObj, name, fallback) { } } +// exported for unit tests only +export function _getAwsAuthHeaders (req, url) { + const credentials = { + accessKeyId: req.authentication.accessKeyId, + secretAccessKey: req.authentication.secretAccessKey + }; + const parsedUrl = urlParse(url); + const contentTypeHeader = util.getContentTypeHeader(req.headers); + const options = { + path: parsedUrl.path, + // hostname is what we want here whether your URL has port or not + host: parsedUrl.hostname, + headers: { + 'content-type': contentTypeHeader ? contentTypeHeader.value : '' + } + }; + const body = req.body && req.body.text; + if (body) { + options.body = body; + } + const signature = aws4.sign(options, credentials); + return Object.keys(signature.headers) + // Don't use the inferred content type aws4 provides + .filter((name) => name !== 'content-type') + .map((name) => ({name, value: signature.headers[name]})); +} + document.addEventListener('keydown', e => { if (e.ctrlKey || e.metaKey || e.altKey) { return; diff --git a/app/ui/components/dropdowns/auth-dropdown.js b/app/ui/components/dropdowns/auth-dropdown.js index 6a7b4574ff8..dadda5d53dd 100644 --- a/app/ui/components/dropdowns/auth-dropdown.js +++ b/app/ui/components/dropdowns/auth-dropdown.js @@ -5,7 +5,7 @@ import {trackEvent} from '../../../analytics'; import {showModal} from '../modals'; import AlertModal from '../modals/alert-modal'; import * as models from '../../../models'; -import {AUTH_BASIC, AUTH_DIGEST, AUTH_BEARER, AUTH_NONE, AUTH_NTLM, AUTH_OAUTH_1, AUTH_OAUTH_2, getAuthTypeName} from '../../../common/constants'; +import {AUTH_BASIC, AUTH_DIGEST, AUTH_BEARER, AUTH_NONE, AUTH_NTLM, AUTH_OAUTH_1, AUTH_OAUTH_2, AUTH_AWS_IAM, getAuthTypeName} from '../../../common/constants'; @autobind class AuthDropdown extends PureComponent { @@ -67,6 +67,7 @@ class AuthDropdown extends PureComponent { {this.renderAuthType(AUTH_DIGEST)} {this.renderAuthType(AUTH_BEARER)} {this.renderAuthType(AUTH_NTLM)} + {this.renderAuthType(AUTH_AWS_IAM)} Other {this.renderAuthType(AUTH_NONE, 'No Authentication')} diff --git a/app/ui/components/editors/auth/auth-wrapper.js b/app/ui/components/editors/auth/auth-wrapper.js index ddb02480402..3adc66c2d0d 100644 --- a/app/ui/components/editors/auth/auth-wrapper.js +++ b/app/ui/components/editors/auth/auth-wrapper.js @@ -1,10 +1,11 @@ import React, {PropTypes, PureComponent} from 'react'; -import {AUTH_BASIC, AUTH_DIGEST, AUTH_BEARER, AUTH_NTLM, AUTH_OAUTH_1, AUTH_OAUTH_2} from '../../../../common/constants'; +import {AUTH_BASIC, AUTH_DIGEST, AUTH_BEARER, AUTH_NTLM, AUTH_OAUTH_1, AUTH_OAUTH_2, AUTH_AWS_IAM} from '../../../../common/constants'; import BasicAuth from './basic-auth'; import DigestAuth from './digest-auth'; import BearerAuth from './bearer-auth'; import NTLMAuth from './ntlm-auth'; import OAuth2 from './o-auth-2'; +import AWSAuth from './aws-auth'; import autobind from 'autobind-decorator'; import Link from '../../base/link'; @@ -92,6 +93,17 @@ class AuthWrapper extends PureComponent { onChange={onChange} /> ); + } else if (authentication.type === AUTH_AWS_IAM) { + return ( + + ); } else { return (
diff --git a/app/ui/components/editors/auth/aws-auth.js b/app/ui/components/editors/auth/aws-auth.js new file mode 100644 index 00000000000..58b2689c237 --- /dev/null +++ b/app/ui/components/editors/auth/aws-auth.js @@ -0,0 +1,75 @@ +import React, {PropTypes, PureComponent} from 'react'; +import autobind from 'autobind-decorator'; +import KeyValueEditor from '../../key-value-editor/editor'; +import {trackEvent} from '../../../../analytics/index'; +import {AUTH_AWS_IAM} from '../../../../common/constants'; + +@autobind +class AWSAuth extends PureComponent { + _handleOnCreate () { + trackEvent('AWS Auth Editor', 'Create'); + } + + _handleOnDelete () { + trackEvent('AWS Auth Editor', 'Delete'); + } + + _handleToggleDisable (pair) { + const label = pair.disabled ? 'Disable' : 'Enable'; + trackEvent('AWS Auth Editor', 'Toggle', label); + } + + _handleChange (pairs) { + const pair = { + type: AUTH_AWS_IAM, + accessKeyId: pairs.length ? pairs[0].name : '', + secretAccessKey: pairs.length ? pairs[0].value : '', + disabled: pairs.length ? pairs[0].disabled : false + }; + + this.props.onChange(pair); + } + + render () { + const { + authentication, + showPasswords, + handleRender, + handleGetRenderContext + } = this.props; + + const pairs = [{ + name: authentication.accessKeyId || '', + value: authentication.secretAccessKey || '', + disabled: authentication.disabled || false + }]; + + return ( + + ); + } +} + +AWSAuth.propTypes = { + handleRender: PropTypes.func.isRequired, + handleGetRenderContext: PropTypes.func.isRequired, + handleUpdateSettingsShowPasswords: PropTypes.func.isRequired, + onChange: PropTypes.func.isRequired, + authentication: PropTypes.object.isRequired, + showPasswords: PropTypes.bool.isRequired +}; + +export default AWSAuth; diff --git a/package.json b/package.json index e510b9e600c..0c6543685ef 100644 --- a/package.json +++ b/package.json @@ -109,6 +109,7 @@ }, "dependencies": { "autobind-decorator": "^1.3.4", + "aws4": "1.6.0", "classnames": "^2.2.5", "clone": "^2.1.0", "codemirror": "^5.24.2",