Add an authorize page for authentication (#1147)

* Use authorize page if auth provider

* Add webcomponent polyfill

* More fixes

* ES5 fix

* Lint

* Use redirect_uri

* upgrade uglify to fix tests?

* Update browsers used for testing
This commit is contained in:
Paulus Schoutsen 2018-05-10 14:25:36 -04:00 committed by GitHub
parent 912969111f
commit 3b7a206cec
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 550 additions and 55 deletions

1
.gitignore vendored
View File

@ -28,3 +28,4 @@ dist
# Secrets
.lokalise_token
yarn-error.log

23
gulp/tasks/auth.js Normal file
View File

@ -0,0 +1,23 @@
const gulp = require('gulp');
const replace = require('gulp-batch-replace');
const rename = require('gulp-rename');
const config = require('../config');
const minifyStream = require('../common/transform').minifyStream;
const {
bundledStreamFromHTML,
} = require('../common/html');
const es5Extra = "<script src='/frontend_es5/custom-elements-es5-adapter.js'></script>";
async function buildAuth(es6) {
let stream = await bundledStreamFromHTML('src/authorize.html');
stream = stream.pipe(replace([['<!--EXTRA_SCRIPTS-->', es6 ? '' : es5Extra]]));
return minifyStream(stream, /* es6= */ es6)
.pipe(rename('authorize.html'))
.pipe(gulp.dest(es6 ? config.output : config.output_es5));
}
gulp.task('authorize-es5', () => buildAuth(/* es6= */ false));
gulp.task('authorize', () => buildAuth(/* es6= */ true));

View File

@ -61,6 +61,7 @@
document.getElementById('ha-init-skeleton').classList.add('error');
};
window.noAuth = '{{ no_auth }}';
window.clientId = '{{ client_id }}'
window.Polymer = {
lazyRegister: true,
useNativeCSSProperties: true,

View File

@ -0,0 +1,15 @@
export default function fetchToken(clientId, code) {
const data = new FormData();
data.append('grant_type', 'authorization_code');
data.append('code', code);
return fetch('/auth/token', {
method: 'POST',
headers: {
authorization: `Basic ${btoa(clientId)}`
},
body: data,
}).then((resp) => {
if (!resp.ok) throw new Error('Unable to fetch tokens');
return resp.json();
});
}

View File

@ -0,0 +1,15 @@
export default function refreshAccessToken(clientId, refreshToken) {
const data = new FormData();
data.append('grant_type', 'refresh_token');
data.append('refresh_token', refreshToken);
return fetch('/auth/token', {
method: 'POST',
headers: {
authorization: `Basic ${btoa(clientId)}`
},
body: data,
}).then((resp) => {
if (!resp.ok) throw new Error('Unable to fetch tokens');
return resp.json();
});
}

View File

@ -0,0 +1,11 @@
export default function parseQuery(queryString) {
const query = {};
const items = queryString.split('&');
for (let i = 0; i < items.length; i++) {
const item = items[i].split('=');
const key = decodeURIComponent(item[0]);
const value = item.length > 1 ? decodeURIComponent(item[1]) : undefined;
query[key] = value;
}
return query;
}

View File

@ -1,21 +1,26 @@
import * as HAWS from 'home-assistant-js-websocket';
import fetchToken from './common/auth/fetch_token.js';
import refreshToken_ from './common/auth/refresh_token.js';
import parseQuery from './common/util/parse_query.js';
window.HAWS = HAWS;
window.HASS_DEMO = __DEMO__;
window.HASS_DEV = __DEV__;
window.HASS_BUILD = __BUILD__;
window.HASS_VERSION = __VERSION__;
const init = window.createHassConnection = function (password) {
const init = window.createHassConnection = function (password, accessToken) {
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
const url = `${proto}://${window.location.host}/api/websocket?${window.HASS_BUILD}`;
const options = {
setupRetry: 10,
};
if (password !== undefined) {
if (password) {
options.authToken = password;
} else if (accessToken) {
options.accessToken = accessToken;
}
return HAWS.createConnection(url, options)
.then(function (conn) {
HAWS.subscribeEntities(conn);
@ -24,12 +29,65 @@ const init = window.createHassConnection = function (password) {
});
};
if (window.noAuth === '1') {
window.hassConnection = init();
} else if (window.localStorage.authToken) {
window.hassConnection = init(window.localStorage.authToken);
function redirectLogin() {
const urlBase = __DEV__ ? '/home-assistant-polymer/src' : `/frontend_${__BUILD__}`;
document.location = `${urlBase}/authorize.html?response_type=code&client_id=${window.clientId}&redirect_uri=/`;
}
window.refreshToken = () =>
refreshToken_(window.clientId, window.tokens.refresh_token).then((accessTokenResp) => {
window.tokens.access_token = accessTokenResp.access_token;
localStorage.tokens = JSON.stringify(window.tokens);
return accessTokenResp.access_token;
}, () => redirectLogin());
function resolveCode(code) {
fetchToken(window.clientId, code).then((tokens) => {
localStorage.tokens = JSON.stringify(tokens);
// Refresh the page and have tokens in place.
document.location = location.pathname;
}, (err) => {
// eslint-disable-next-line
console.error('Resolve token failed', err);
alert('Unable to fetch tokens');
redirectLogin();
});
}
function main() {
if (location.search) {
const query = parseQuery(location.search.substr(1));
if (query.code) {
resolveCode(query.code);
return;
}
}
if (localStorage.tokens) {
window.tokens = JSON.parse(localStorage.tokens);
window.hassConnection = init(null, window.tokens.access_token).catch((err) => {
if (err !== HAWS.ERR_INVALID_AUTH) throw err;
return window.refreshToken().then(accessToken => init(null, accessToken));
});
return;
}
redirectLogin();
}
function mainLegacy() {
if (window.noAuth === '1') {
window.hassConnection = init();
} else if (window.localStorage.authToken) {
window.hassConnection = init(window.localStorage.authToken);
} else {
window.hassConnection = null;
}
}
if (window.clientId) {
main();
} else {
window.hassConnection = null;
mainLegacy();
}
window.addEventListener('error', (e) => {

View File

@ -25,7 +25,7 @@
"dependencies": {
"es6-object-assign": "^1.1.0",
"fecha": "^2.3.3",
"home-assistant-js-websocket": "^1.1.2",
"home-assistant-js-websocket": "1.2.0",
"mdn-polyfills": "^5.5.0",
"preact": "^8.2.6",
"unfetch": "^3.0.0"

View File

@ -6,7 +6,7 @@
<link rel='import' href='../../../src/util/hass-mixins.html'>
<link rel="import" href='../../../src/components/ha-markdown.html'>
<link rel='import' href='../../../src/resources/ha-style.html'>
<link rel="import" href='./ha-form.html'>
<link rel="import" href='../../../src/components/ha-form.html'>
<dom-module id="ha-config-flow">
<template>
@ -57,10 +57,6 @@
<ha-markdown content='[[_computeStepDescription(localize, step)]]'></ha-markdown>
</template>
<template is='dom-if' if='[[step.errors.base]]'>
<div class='error'>[[_computeBaseError(localize, step)]]</div>
</template>
<ha-form
data='{{stepData}}'
schema='[[step.data_schema]]'
@ -196,10 +192,6 @@ class HaConfigFlow extends
return localize(`component.${step.handler}.config.step.${step.step_id}.description`);
}
_computeBaseError(localize, step) {
return localize(`component.${step.handler}.config.error.${step.errors.base}`);
}
_computeLabelCallback(localize, step) {
// Returns a callback for ha-form to calculate labels per schema object
return schema => localize(`component.${step.handler}.config.step.${step.step_id}.data.${schema.name}`);

View File

@ -16,6 +16,7 @@ cp -r public/__init__.py $OUTPUT_DIR_ES5/
# Build frontend
BUILD_DEV=0 ./node_modules/.bin/gulp
BUILD_DEV=0 ./node_modules/.bin/gulp authorize authorize-es5
# Entry points
cp build/*.js build/*.html $OUTPUT_DIR

143
src/auth/ha-auth-flow.html Normal file
View File

@ -0,0 +1,143 @@
<link rel='import' href='../../bower_components/polymer/polymer-element.html'>
<link rel="import" href='../../bower_components/paper-button/paper-button.html'>
<link rel='import' href='../components/ha-form.html'>
<link rel='import' href='../util/hass-mixins.html'>
<dom-module id='ha-auth-flow'>
<template>
<template is='dom-if' if='[[_equals(_state, "loading")]]'>
Please wait
</template>
<template is='dom-if' if='[[_equals(_state, "error")]]'>
Something went wrong
</template>
<template is='dom-if' if='[[_equals(_state, "step")]]'>
<template is='dom-if' if='[[_equals(_step.type, "abort")]]'>
Aborted
</template>
<template is='dom-if' if='[[_equals(_step.type, "create_entry")]]'>
Success!
</template>
<template is='dom-if' if='[[_equals(_step.type, "form")]]'>
<ha-form
data='{{_stepData}}'
schema='[[_step.data_schema]]'
error='[[_step.errors]]'
></ha-form>
</template>
<paper-button on-click='_handleSubmit'>[[_computeSubmitCaption(_step.type)]]</paper-button>
</template>
</template>
</dom-module>
<script>
/*
* @appliesMixin window.hassMixins.EventsMixin
*/
class HaAuthFlow extends window.hassMixins.EventsMixin(Polymer.Element) {
static get is() { return 'ha-auth-flow'; }
static get properties() {
return {
authProvider: Object,
clientId: String,
clientSecret: String,
redirectUri: String,
oauth2State: String,
_state: {
type: String,
value: 'loading'
},
_stepData: {
type: Object,
value: () => ({}),
},
_step: Object,
};
}
connectedCallback() {
super.connectedCallback();
fetch('/auth/login_flow', {
method: 'POST',
headers: {
Authorization: `Basic ${btoa(`${this.clientId}:${this.clientSecret}`)}`
},
body: JSON.stringify({
handler: [this.authProvider.type, this.authProvider.id],
redirect_uri: this.redirectUri,
})
}).then((response) => {
if (!response.ok) throw new Error();
return response.json();
}).then(step => this.setProperties({
_step: step,
_state: 'step',
})).catch((err) => {
// eslint-disable-next-line
console.error('Error starting auth flow', err);
this._state = 'error';
});
}
_equals(a, b) {
return a === b;
}
_computeSubmitCaption(stepType) {
return stepType === 'form' ? 'Submit' : 'Start over';
}
_handleSubmit() {
if (this._step.type !== 'form') {
this.fire('reset');
return;
}
this._state = 'loading';
fetch(`/auth/login_flow/${this._step.flow_id}`, {
method: 'POST',
headers: {
Authorization: `Basic ${btoa(`${this.clientId}:${this.clientSecret}`)}`
},
body: JSON.stringify(this._stepData)
}).then((response) => {
if (!response.ok) throw new Error();
return response.json();
}).then((newStep) => {
if (newStep.type === 'create_entry') {
// OAuth 2: 3.1.2 we need to retain query component of a redirect URI
let url = this.redirectUri;
if (!url.includes('?')) {
url += '?';
} else if (!url.endsWith('&')) {
url += '&';
}
url += `code=${encodeURIComponent(newStep.result)}`;
if (this.oauth2State) {
url += `&state=${encodeURIComponent(this.oauth2State)}`;
}
document.location = url;
return;
}
const props = {
_step: newStep,
_state: 'step',
};
if (newStep.step_id !== this._step.step_id) {
props._stepData = {};
}
this.setProperties(props);
}).catch((err) => {
// eslint-disable-next-line
console.error('Error loading auth providers', err);
this._state = 'error-loading';
});
}
}
customElements.define(HaAuthFlow.is, HaAuthFlow);
</script>

View File

@ -0,0 +1,81 @@
<link rel='import' href='../../bower_components/polymer/polymer-element.html'>
<link rel='import' href='../../bower_components/polymer/lib/elements/dom-if.html'>
<link rel='import' href='../../bower_components/polymer/lib/elements/dom-repeat.html'>
<link rel="import" href="../../bower_components/iron-flex-layout/iron-flex-layout-classes.html">
<link rel='import' href='./ha-pick-auth-provider.html'>
<link rel='import' href='./ha-auth-flow.html'>
<dom-module id='ha-authorize'>
<template>
<style is="custom-style" include="iron-flex iron-positioning"></style>
<style>
.layout {
padding-top: 20px;
}
</style>
<div class="layout vertical center fit">
<img src="/static/icons/favicon-192x192.png" height="192" />
<template is='dom-if' if='[[_authProvider]]'>
<ha-auth-flow
client-id='[[clientId]]'
client-secret='[[clientSecret]]'
redirect-uri='[[redirectUri]]'
oauth2-state='[[oauth2State]]'
auth-provider='[[_authProvider]]'
on-reset='_handleReset'
/>
</template>
<template is='dom-if' if='[[!_authProvider]]'>
<ha-pick-auth-provider
client-id='[[clientId]]'
client-secret='[[clientSecret]]'
on-pick='_handleAuthProviderPick'
></ha-pick-auth-provider>
</template>
</div>
</template>
</dom-module>
<script>
class HaAuthorize extends Polymer.Element {
static get is() { return 'ha-authorize'; }
static get properties() {
return {
_authProvider: {
type: String,
value: null,
},
clientId: String,
clientSecret: String,
redirectUri: String,
oauth2State: String,
};
}
ready() {
super.ready();
const query = {};
const values = location.search.substr(1).split('&');
for (let i = 0; i < values.length; i++) {
const value = values[i].split('=');
if (value.length > 1) {
query[decodeURIComponent(value[0])] = decodeURIComponent(value[1]);
}
}
const props = {};
if (query.client_id) props.clientId = query.client_id;
if (query.client_secret) props.clientSecret = query.client_secret;
if (query.redirect_uri) props.redirectUri = query.redirect_uri;
if (query.state) props.oauth2State = query.state;
this.setProperties(props);
}
_handleAuthProviderPick(ev) {
this._authProvider = ev.detail;
}
_handleReset() {
this._authProvider = null;
}
}
customElements.define(HaAuthorize.is, HaAuthorize);
</script>

View File

@ -0,0 +1,86 @@
<link rel='import' href='../../bower_components/polymer/polymer-element.html'>
<link rel='import' href='../../bower_components/paper-item/paper-item.html'>
<link rel='import' href='../util/hass-mixins.html'>
<dom-module id='ha-pick-auth-provider'>
<template>
<style>
:host {
text-align: center;
font-family: Roboto;
}
paper-item {
cursor: pointer;
}
</style>
<template is='dom-if' if='[[_equal(_state, "loading")]]'>
Loading auth providers.
</template>
<template is='dom-if' if='[[_equal(_state, "no-results")]]'>
No auth providers found.
</template>
<template is='dom-if' if='[[_equal(_state, "error-loading")]]'>
Error loading
</template>
<template is='dom-if' if='[[_equal(_state, "pick")]]'>
<p>Log in with</p>
<template is='dom-repeat' items='[[authProviders]]'>
<paper-item on-click='_handlePick'>[[item.name]]</paper-item>
</template>
</template>
</template>
</dom-module>
<script>
/*
* @appliesMixin window.hassMixins.EventsMixin
*/
class HaPickAuthProvider extends window.hassMixins.EventsMixin(Polymer.Element) {
static get is() { return 'ha-pick-auth-provider'; }
static get properties() {
return {
_state: {
type: String,
value: 'loading'
},
authProviders: Array,
clientId: String,
clientSecret: String,
};
}
connectedCallback() {
super.connectedCallback();
fetch('/auth/providers', {
headers: {
Authorization: `Basic ${btoa(`${this.clientId}:${this.clientSecret}`)}`
}
}).then((response) => {
if (!response.ok) throw new Error();
return response.json();
}).then((authProviders) => {
this.setProperties({
authProviders,
_state: 'pick',
});
if (authProviders.length === 1) {
this.fire('pick', authProviders[0]);
}
}).catch((err) => {
// eslint-disable-next-line
console.error('Error loading auth providers', err);
this._state = 'error-loading';
});
}
_handlePick(ev) {
this.fire('pick', ev.model.item);
}
_equal(a, b) {
return a === b;
}
}
customElements.define(HaPickAuthProvider.is, HaPickAuthProvider);
</script>

26
src/authorize.html Normal file
View File

@ -0,0 +1,26 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Home Assistant</title>
<!--EXTRA_SCRIPTS-->
</head>
<body>
<ha-authorize>Loading</ha-authorize>
<script>
function addScript(src) {
var e = document.createElement('script');
e.src = src;
document.head.appendChild(e);
}
var webComponentsSupported = (
'customElements' in window &&
'import' in document.createElement('link') &&
'content' in document.createElement('template'));
if (!webComponentsSupported) {
addScript('/static/webcomponents-lite.js');
}
</script>
<link rel='import' href='./auth/ha-authorize.html'>
</body>
</html>

View File

@ -1,13 +1,13 @@
<link rel="import" href='../../../bower_components/polymer/polymer-element.html'>
<link rel="import" href='../../bower_components/polymer/polymer-element.html'>
<link rel="import" href='../../../bower_components/paper-input/paper-input.html'>
<link rel="import" href='../../../bower_components/paper-checkbox/paper-checkbox.html'>
<link rel='import' href='../../../bower_components/paper-dropdown-menu/paper-dropdown-menu.html'>
<link rel='import' href='../../../bower_components/paper-listbox/paper-listbox.html'>
<link rel='import' href='../../../bower_components/paper-item/paper-item.html'>
<link rel="import" href='../../bower_components/paper-input/paper-input.html'>
<link rel="import" href='../../bower_components/paper-checkbox/paper-checkbox.html'>
<link rel='import' href='../../bower_components/paper-dropdown-menu/paper-dropdown-menu.html'>
<link rel='import' href='../../bower_components/paper-listbox/paper-listbox.html'>
<link rel='import' href='../../bower_components/paper-item/paper-item.html'>
<link rel='import' href='../../../src/util/hass-mixins.html'>
<link rel="import" href='../../../src/components/ha-paper-slider.html'>
<link rel='import' href='../util/hass-mixins.html'>
<link rel="import" href='./ha-paper-slider.html'>
<dom-module id="ha-form">
@ -18,6 +18,10 @@
}
</style>
<template is='dom-if' if='[[_isArray(schema)]]' restamp>
<template is='dom-if' if='[[error.base]]'>
[[computeError(error.base, schema)]]
</template>
<template is='dom-repeat' items='[[schema]]'>
<ha-form
data='[[_getValue(data, item)]]'
@ -112,14 +116,14 @@ class HaForm extends window.hassMixins.EventsMixin(Polymer.Element) {
// schema object.
computeLabel: {
type: Function,
value: schema => schema && schema.name,
value: () => schema => schema && schema.name,
},
// A function that will computes an error message to be displayed for a
// given error ID, and relevant schema object
computeError: {
type: Function,
value: (error, schema) => error, // eslint-disable-line no-unused-vars
value: () => (error, schema) => error, // eslint-disable-line no-unused-vars
},
};
}

View File

@ -205,9 +205,17 @@ class HomeAssistant extends Polymer.Element {
}
),
callApi: (method, path, parameters) => {
var host = window.location.protocol + '//' + window.location.host;
var auth = conn.options.authToken ? conn.options : {};
return window.hassCallApi(host, auth, method, path, parameters);
const host = window.location.protocol + '//' + window.location.host;
const auth = conn.options;
return window.hassCallApi(host, auth, method, path, parameters).catch((err) => {
if (err.status_code !== 401 || !auth.accessToken) throw err;
// If we connect with access token and get 401, refresh token and try again
return window.refreshToken().then((accessToken) => {
conn.options.accessToken = accessToken;
return window.hassCallApi(host, auth, method, path, parameters);
});
});
},
}, this.$.storage.getStoredState());
@ -216,12 +224,21 @@ class HomeAssistant extends Polymer.Element {
this.loadBackendTranslations();
};
conn.addEventListener('ready', reconnected);
var disconnected = () => {
const disconnected = () => {
this._updateHass({ connected: false });
};
conn.addEventListener('ready', reconnected);
// If we reconnect after losing connection and access token is no longer
// valid.
conn.addEventListener('reconnect-error', (_conn, err) => {
if (err !== window.HAWS.ERR_INVALID_AUTH) return;
disconnected();
this.unsubConnection();
window.refreshToken().then(accessToken =>
this.handleConnectionPromise(window.createHassConnection(null, accessToken)));
});
conn.addEventListener('disconnected', disconnected);
var unsubEntities;
@ -310,16 +327,9 @@ class HomeAssistant extends Polymer.Element {
}
handleLogout() {
delete localStorage.authToken;
var conn = this.connection;
this.connectionPromise = null;
try {
this.connection = null;
} catch (err) {
// home-assistant-main crashes when hass is set to null.
// However, after it is done, home-assistant-main is removed from the DOM by this element.
}
conn.close();
this.connection.close();
localStorage.clear();
document.location = '/';
}
setTheme(event) {

View File

@ -33,6 +33,8 @@ window.hassCallApi = function (host, auth, method, path, parameters) {
if (auth.authToken) {
req.setRequestHeader('X-HA-access', auth.authToken);
} else if (auth.accessToken) {
req.setRequestHeader('authorization', `Bearer ${auth.accessToken}`);
}
req.onload = function () {

View File

@ -0,0 +1,18 @@
import { assert } from 'chai';
import parseQuery from '../../../js/common/util/parse_query.js';
describe('parseQuery', () => {
it('works', () => {
assert.deepEqual(parseQuery('hello=world'), { hello: 'world' });
assert.deepEqual(parseQuery('hello=world&drink=soda'), {
hello: 'world',
drink: 'soda',
});
assert.deepEqual(parseQuery('hello=world&no_value&drink=soda'), {
hello: 'world',
no_value: undefined,
drink: 'soda',
});
});
});

View File

@ -9,7 +9,7 @@
}, {
"browserName": "safari",
"platform": "macOS 10.12",
"version": "10"
"version": "latest"
}, {
"browserName": "firefox",
"platform": "Windows 10",
@ -17,7 +17,7 @@
}, {
"browserName": "MicrosoftEdge",
"platform": "Windows 10",
"version": "14.14393"
"version": "latest"
}, {
"deviceName": "Android GoogleAPI Emulator",
"platformName": "Android",

View File

@ -2163,6 +2163,14 @@ commander@^2.8.1:
version "2.12.2"
resolved "https://registry.yarnpkg.com/commander/-/commander-2.12.2.tgz#0f5946c427ed9ec0d91a46bb9def53e54650e555"
commander@~2.13.0:
version "2.13.0"
resolved "https://registry.yarnpkg.com/commander/-/commander-2.13.0.tgz#6964bca67685df7c1f1430c584f07d7597885b9c"
commander@~2.15.0:
version "2.15.1"
resolved "https://registry.yarnpkg.com/commander/-/commander-2.15.1.tgz#df46e867d0fc2aec66a34662b406a9ccafff5b0f"
commondir@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b"
@ -4234,9 +4242,9 @@ hoek@4.x.x:
version "4.2.0"
resolved "https://registry.yarnpkg.com/hoek/-/hoek-4.2.0.tgz#72d9d0754f7fe25ca2d01ad8f8f9a9449a89526d"
home-assistant-js-websocket@^1.1.2:
version "1.1.4"
resolved "https://registry.yarnpkg.com/home-assistant-js-websocket/-/home-assistant-js-websocket-1.1.4.tgz#36da056be18210ada76abfa2bc1f247ecbebce11"
home-assistant-js-websocket@1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/home-assistant-js-websocket/-/home-assistant-js-websocket-1.2.0.tgz#aa965a7ae47606ea82b919ce74a310ef18412cd7"
home-or-tmp@^2.0.0:
version "2.0.0"
@ -8197,10 +8205,10 @@ ua-parser-js@^0.7.9:
resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.17.tgz#e9ec5f9498b9ec910e7ae3ac626a805c4d09ecac"
uglify-es@^3.1.9:
version "3.1.9"
resolved "https://registry.yarnpkg.com/uglify-es/-/uglify-es-3.1.9.tgz#6c82df628ac9eb7af9c61fd70c744a084abe6161"
version "3.3.9"
resolved "https://registry.yarnpkg.com/uglify-es/-/uglify-es-3.3.9.tgz#0c1c4f0700bed8dbc124cdb304d2592ca203e677"
dependencies:
commander "~2.11.0"
commander "~2.13.0"
source-map "~0.6.1"
uglify-js@2.6.x:
@ -8227,10 +8235,10 @@ uglify-js@^3.0.5:
source-map "~0.5.1"
uglify-js@^3.1.9:
version "3.1.9"
resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.1.9.tgz#dffca799308cf327ec3ac77eeacb8e196ce3b452"
version "3.3.24"
resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.3.24.tgz#abeae7690c602ebd006f4567387a0c0c333bdc0d"
dependencies:
commander "~2.11.0"
commander "~2.15.0"
source-map "~0.6.1"
uglify-to-browserify@~1.0.0: