UNPKG

home-assistant-js-websocket

Version:
189 lines (188 loc) 6.73 kB
import { parseQuery } from "./util.js"; import { ERR_HASS_HOST_REQUIRED, ERR_INVALID_AUTH, ERR_INVALID_AUTH_CALLBACK, ERR_INVALID_HTTPS_TO_HTTP, } from "./errors.js"; export const genClientId = () => `${location.protocol}//${location.host}/`; export const genExpires = (expires_in) => { return expires_in * 1000 + Date.now(); }; function genRedirectUrl() { // Get current url but without # part. const { protocol, host, pathname, search } = location; return `${protocol}//${host}${pathname}${search}`; } function genAuthorizeUrl(hassUrl, clientId, redirectUrl, state) { let authorizeUrl = `${hassUrl}/auth/authorize?response_type=code&redirect_uri=${encodeURIComponent(redirectUrl)}`; if (clientId !== null) { authorizeUrl += `&client_id=${encodeURIComponent(clientId)}`; } if (state) { authorizeUrl += `&state=${encodeURIComponent(state)}`; } return authorizeUrl; } function redirectAuthorize(hassUrl, clientId, redirectUrl, state) { // Add either ?auth_callback=1 or &auth_callback=1 redirectUrl += (redirectUrl.includes("?") ? "&" : "?") + "auth_callback=1"; document.location.href = genAuthorizeUrl(hassUrl, clientId, redirectUrl, state); } async function tokenRequest(hassUrl, clientId, data) { // Browsers don't allow fetching tokens from https -> http. // Throw an error because it's a pain to debug this. // Guard against not working in node. const l = typeof location !== "undefined" && location; if (l && l.protocol === "https:") { // Ensure that the hassUrl is hosted on https. const a = document.createElement("a"); a.href = hassUrl; if (a.protocol === "http:" && a.hostname !== "localhost") { throw ERR_INVALID_HTTPS_TO_HTTP; } } const formData = new FormData(); if (clientId !== null) { formData.append("client_id", clientId); } Object.keys(data).forEach((key) => { // @ts-ignore formData.append(key, data[key]); }); const resp = await fetch(`${hassUrl}/auth/token`, { method: "POST", credentials: "same-origin", body: formData, }); if (!resp.ok) { throw resp.status === 400 /* auth invalid */ || resp.status === 403 /* user not active */ ? ERR_INVALID_AUTH : new Error("Unable to fetch tokens"); } const tokens = await resp.json(); tokens.hassUrl = hassUrl; tokens.clientId = clientId; tokens.expires = genExpires(tokens.expires_in); return tokens; } function fetchToken(hassUrl, clientId, code) { return tokenRequest(hassUrl, clientId, { code, grant_type: "authorization_code", }); } function encodeOAuthState(state) { return btoa(JSON.stringify(state)); } function decodeOAuthState(encoded) { return JSON.parse(atob(encoded)); } export class Auth { constructor(data, saveTokens) { this.data = data; this._saveTokens = saveTokens; } get wsUrl() { // Convert from http:// -> ws://, https:// -> wss:// return `ws${this.data.hassUrl.substr(4)}/api/websocket`; } get accessToken() { return this.data.access_token; } get expired() { return Date.now() > this.data.expires; } /** * Refresh the access token. */ async refreshAccessToken() { if (!this.data.refresh_token) throw new Error("No refresh_token"); const data = await tokenRequest(this.data.hassUrl, this.data.clientId, { grant_type: "refresh_token", refresh_token: this.data.refresh_token, }); // Access token response does not contain refresh token. data.refresh_token = this.data.refresh_token; this.data = data; if (this._saveTokens) this._saveTokens(data); } /** * Revoke the refresh & access tokens. */ async revoke() { if (!this.data.refresh_token) throw new Error("No refresh_token to revoke"); const formData = new FormData(); formData.append("token", this.data.refresh_token); // There is no error checking, as revoke will always return 200 await fetch(`${this.data.hassUrl}/auth/revoke`, { method: "POST", credentials: "same-origin", body: formData, }); if (this._saveTokens) { this._saveTokens(null); } } } export function createLongLivedTokenAuth(hassUrl, access_token) { return new Auth({ hassUrl, clientId: null, expires: Date.now() + 1e11, refresh_token: "", access_token, expires_in: 1e11, }); } export async function getAuth(options = {}) { let data; let hassUrl = options.hassUrl; // Strip trailing slash. if (hassUrl && hassUrl[hassUrl.length - 1] === "/") { hassUrl = hassUrl.substr(0, hassUrl.length - 1); } const clientId = options.clientId !== undefined ? options.clientId : genClientId(); const limitHassInstance = options.limitHassInstance === true; // Use auth code if it was passed in if (options.authCode && hassUrl) { data = await fetchToken(hassUrl, clientId, options.authCode); if (options.saveTokens) { options.saveTokens(data); } } // Check if we came back from an authorize redirect if (!data) { const query = parseQuery(location.search.substr(1)); // Check if we got redirected here from authorize page if ("auth_callback" in query) { // Restore state const state = decodeOAuthState(query.state); if (limitHassInstance && (state.hassUrl !== hassUrl || state.clientId !== clientId)) { throw ERR_INVALID_AUTH_CALLBACK; } data = await fetchToken(state.hassUrl, state.clientId, query.code); if (options.saveTokens) { options.saveTokens(data); } } } // Check for stored tokens if (!data && options.loadTokens) { data = await options.loadTokens(); } // If the token is for another url, ignore it if (data && (hassUrl === undefined || data.hassUrl === hassUrl)) { return new Auth(data, options.saveTokens); } if (hassUrl === undefined) { throw ERR_HASS_HOST_REQUIRED; } // If no tokens found but a hassUrl was passed in, let's go get some tokens! redirectAuthorize(hassUrl, clientId, options.redirectUrl || genRedirectUrl(), encodeOAuthState({ hassUrl, clientId, })); // Just don't resolve while we navigate to next page return new Promise(() => { }); }