Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for AWS authentication #347

Merged
merged 1 commit into from Jul 12, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 3 additions & 1 deletion app/common/constants.js
Expand Up @@ -139,14 +139,16 @@ 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'],
[AUTH_DIGEST]: ['Digest', 'Digest Auth'],
[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) {
Expand Down
10 changes: 9 additions & 1 deletion 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';
Expand Down Expand Up @@ -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};
Expand Down
46 changes: 45 additions & 1 deletion app/network/__tests__/network.test.js
Expand Up @@ -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));
Expand Down Expand Up @@ -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);
});
});
37 changes: 36 additions & 1 deletion app/network/network.js
Expand Up @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume this wouldn't be needed if we passed the content-type header in the initial options above?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes I think you're correct here. Refactoring.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually without this we end up sending a duplicate Content-Type header and the signature won't verify.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I think this is good now 👍

.map((name) => ({name, value: signature.headers[name]}));
}

document.addEventListener('keydown', e => {
if (e.ctrlKey || e.metaKey || e.altKey) {
return;
Expand Down
3 changes: 2 additions & 1 deletion app/ui/components/dropdowns/auth-dropdown.js
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)}
<DropdownDivider>Other</DropdownDivider>
{this.renderAuthType(AUTH_NONE, 'No Authentication')}
</Dropdown>
Expand Down
14 changes: 13 additions & 1 deletion 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';

Expand Down Expand Up @@ -92,6 +93,17 @@ class AuthWrapper extends PureComponent {
onChange={onChange}
/>
);
} else if (authentication.type === AUTH_AWS_IAM) {
return (
<AWSAuth
authentication={authentication}
handleRender={handleRender}
handleGetRenderContext={handleGetRenderContext}
handleUpdateSettingsShowPasswords={handleUpdateSettingsShowPasswords}
onChange={onChange}
showPasswords={showPasswords}
/>
);
} else {
return (
<div className="vertically-center text-center">
Expand Down
75 changes: 75 additions & 0 deletions 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 (
<KeyValueEditor
pairs={pairs}
maxPairs={1}
disableDelete
handleRender={handleRender}
handleGetRenderContext={handleGetRenderContext}
namePlaceholder="AWS_ACCESS_KEY_ID"
valuePlaceholder="AWS_SECRET_ACCESS_KEY"
valueInputType={showPasswords ? 'text' : 'password'}
onToggleDisable={this._handleToggleDisable}
onCreate={this._handleOnCreate}
onDelete={this._handleOnDelete}
onChange={this._handleChange}
/>
);
}
}

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;
1 change: 1 addition & 0 deletions package.json
Expand Up @@ -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",
Expand Down