diff --git a/.gitignore b/.gitignore index 8f5f200290..866eca9a7a 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ dist # Secrets .lokalise_token +yarn-error.log diff --git a/gulp/tasks/auth.js b/gulp/tasks/auth.js new file mode 100644 index 0000000000..4fd417ee0e --- /dev/null +++ b/gulp/tasks/auth.js @@ -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 = ""; + +async function buildAuth(es6) { + let stream = await bundledStreamFromHTML('src/authorize.html'); + stream = stream.pipe(replace([['', 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)); diff --git a/index.html b/index.html index e26d966fac..a5b8ad32f7 100644 --- a/index.html +++ b/index.html @@ -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, diff --git a/js/common/auth/fetch_token.js b/js/common/auth/fetch_token.js new file mode 100644 index 0000000000..17c47fa8a7 --- /dev/null +++ b/js/common/auth/fetch_token.js @@ -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(); + }); +} diff --git a/js/common/auth/refresh_token.js b/js/common/auth/refresh_token.js new file mode 100644 index 0000000000..d7d20e4a97 --- /dev/null +++ b/js/common/auth/refresh_token.js @@ -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(); + }); +} diff --git a/js/common/util/parse_query.js b/js/common/util/parse_query.js new file mode 100644 index 0000000000..b654b0172f --- /dev/null +++ b/js/common/util/parse_query.js @@ -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; +} diff --git a/js/core.js b/js/core.js index 31a0434dd4..6e9f0bc484 100644 --- a/js/core.js +++ b/js/core.js @@ -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) => { diff --git a/package.json b/package.json index 846e55e2e7..80681adec2 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/panels/config/config-entries/ha-config-flow.html b/panels/config/config-entries/ha-config-flow.html index eb4bb141b5..439602e4c2 100644 --- a/panels/config/config-entries/ha-config-flow.html +++ b/panels/config/config-entries/ha-config-flow.html @@ -6,7 +6,7 @@ - + @@ -57,10 +57,6 @@ - - [[_computeBaseError(localize, step)]] - - localize(`component.${step.handler}.config.step.${step.step_id}.data.${schema.name}`); diff --git a/script/build_frontend b/script/build_frontend index afb778d8f2..33b92b59ed 100755 --- a/script/build_frontend +++ b/script/build_frontend @@ -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 diff --git a/src/auth/ha-auth-flow.html b/src/auth/ha-auth-flow.html new file mode 100644 index 0000000000..5ab9ecf8db --- /dev/null +++ b/src/auth/ha-auth-flow.html @@ -0,0 +1,143 @@ + + + + + + + + + + Please wait + + + Something went wrong + + + + Aborted + + + Success! + + + + + [[_computeSubmitCaption(_step.type)]] + + + + + diff --git a/src/auth/ha-authorize.html b/src/auth/ha-authorize.html new file mode 100644 index 0000000000..0576bd1ac2 --- /dev/null +++ b/src/auth/ha-authorize.html @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/auth/ha-pick-auth-provider.html b/src/auth/ha-pick-auth-provider.html new file mode 100644 index 0000000000..c60a795b71 --- /dev/null +++ b/src/auth/ha-pick-auth-provider.html @@ -0,0 +1,86 @@ + + + + + + + + + + Loading auth providers. + + + No auth providers found. + + + Error loading + + + Log in with + + [[item.name]] + + + + + + diff --git a/src/authorize.html b/src/authorize.html new file mode 100644 index 0000000000..0517a8d3d1 --- /dev/null +++ b/src/authorize.html @@ -0,0 +1,26 @@ + + + + + Home Assistant + + + + Loading + + + + diff --git a/panels/config/config-entries/ha-form.html b/src/components/ha-form.html similarity index 81% rename from panels/config/config-entries/ha-form.html rename to src/components/ha-form.html index cb54f6baa7..c2e2fba804 100644 --- a/panels/config/config-entries/ha-form.html +++ b/src/components/ha-form.html @@ -1,13 +1,13 @@ - + - - - - - + + + + + - - + + @@ -18,6 +18,10 @@ } + + [[computeError(error.base, 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 }, }; } diff --git a/src/home-assistant.html b/src/home-assistant.html index 1f1789a592..efdc9963fb 100644 --- a/src/home-assistant.html +++ b/src/home-assistant.html @@ -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) { diff --git a/src/util/hass-call-api.html b/src/util/hass-call-api.html index 7d6e36bad9..d4b166cc8f 100644 --- a/src/util/hass-call-api.html +++ b/src/util/hass-call-api.html @@ -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 () { diff --git a/test-mocha/common/util/parse_query_test.js b/test-mocha/common/util/parse_query_test.js new file mode 100644 index 0000000000..07c06b427c --- /dev/null +++ b/test-mocha/common/util/parse_query_test.js @@ -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', + }); + }); +}); diff --git a/wct.conf.json b/wct.conf.json index f19fe9bb50..e3f672b51b 100644 --- a/wct.conf.json +++ b/wct.conf.json @@ -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", diff --git a/yarn.lock b/yarn.lock index e223128240..8382fc4198 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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:
Log in with