UNPKG

6.38 kBJavaScriptView Raw
1import { parseQuery } from "./util.js";
2import { ERR_HASS_HOST_REQUIRED, ERR_INVALID_AUTH, ERR_INVALID_HTTPS_TO_HTTP } from "./errors.js";
3export const genClientId = () => `${location.protocol}//${location.host}/`;
4export const genExpires = (expires_in) => {
5 return expires_in * 1000 + Date.now();
6};
7function genRedirectUrl() {
8 // Get current url but without # part.
9 const { protocol, host, pathname, search } = location;
10 return `${protocol}//${host}${pathname}${search}`;
11}
12function genAuthorizeUrl(hassUrl, clientId, redirectUrl, state) {
13 let authorizeUrl = `${hassUrl}/auth/authorize?response_type=code&redirect_uri=${encodeURIComponent(redirectUrl)}`;
14 if (clientId !== null) {
15 authorizeUrl += `&client_id=${encodeURIComponent(clientId)}`;
16 }
17 if (state) {
18 authorizeUrl += `&state=${encodeURIComponent(state)}`;
19 }
20 return authorizeUrl;
21}
22function redirectAuthorize(hassUrl, clientId, redirectUrl, state) {
23 // Add either ?auth_callback=1 or &auth_callback=1
24 redirectUrl += (redirectUrl.includes("?") ? "&" : "?") + "auth_callback=1";
25 document.location.href = genAuthorizeUrl(hassUrl, clientId, redirectUrl, state);
26}
27async function tokenRequest(hassUrl, clientId, data) {
28 // Browsers don't allow fetching tokens from https -> http.
29 // Throw an error because it's a pain to debug this.
30 // Guard against not working in node.
31 const l = typeof location !== "undefined" && location;
32 if (l && l.protocol === "https:") {
33 // Ensure that the hassUrl is hosted on https.
34 const a = document.createElement("a");
35 a.href = hassUrl;
36 if (a.protocol === "http:" && a.hostname !== "localhost") {
37 throw ERR_INVALID_HTTPS_TO_HTTP;
38 }
39 }
40 const formData = new FormData();
41 if (clientId !== null) {
42 formData.append("client_id", clientId);
43 }
44 Object.keys(data).forEach(key => {
45 formData.append(key, data[key]);
46 });
47 const resp = await fetch(`${hassUrl}/auth/token`, {
48 method: "POST",
49 credentials: "same-origin",
50 body: formData
51 });
52 if (!resp.ok) {
53 throw resp.status === 400 /* auth invalid */ ||
54 resp.status === 403 /* user not active */
55 ? ERR_INVALID_AUTH
56 : new Error("Unable to fetch tokens");
57 }
58 const tokens = await resp.json();
59 tokens.hassUrl = hassUrl;
60 tokens.clientId = clientId;
61 tokens.expires = genExpires(tokens.expires_in);
62 return tokens;
63}
64function fetchToken(hassUrl, clientId, code) {
65 return tokenRequest(hassUrl, clientId, {
66 code,
67 grant_type: "authorization_code"
68 });
69}
70function encodeOAuthState(state) {
71 return btoa(JSON.stringify(state));
72}
73function decodeOAuthState(encoded) {
74 return JSON.parse(atob(encoded));
75}
76export class Auth {
77 constructor(data, saveTokens) {
78 this.data = data;
79 this._saveTokens = saveTokens;
80 }
81 get wsUrl() {
82 // Convert from http:// -> ws://, https:// -> wss://
83 return `ws${this.data.hassUrl.substr(4)}/api/websocket`;
84 }
85 get accessToken() {
86 return this.data.access_token;
87 }
88 get expired() {
89 return Date.now() > this.data.expires;
90 }
91 /**
92 * Refresh the access token.
93 */
94 async refreshAccessToken() {
95 if (!this.data.refresh_token)
96 throw new Error("No refresh_token");
97 const data = await tokenRequest(this.data.hassUrl, this.data.clientId, {
98 grant_type: "refresh_token",
99 refresh_token: this.data.refresh_token
100 });
101 // Access token response does not contain refresh token.
102 data.refresh_token = this.data.refresh_token;
103 this.data = data;
104 if (this._saveTokens)
105 this._saveTokens(data);
106 }
107 /**
108 * Revoke the refresh & access tokens.
109 */
110 async revoke() {
111 if (!this.data.refresh_token)
112 throw new Error("No refresh_token to revoke");
113 const formData = new FormData();
114 formData.append("action", "revoke");
115 formData.append("token", this.data.refresh_token);
116 // There is no error checking, as revoke will always return 200
117 await fetch(`${this.data.hassUrl}/auth/token`, {
118 method: "POST",
119 credentials: "same-origin",
120 body: formData
121 });
122 if (this._saveTokens) {
123 this._saveTokens(null);
124 }
125 }
126}
127export function createLongLivedTokenAuth(hassUrl, access_token) {
128 return new Auth({
129 hassUrl,
130 clientId: null,
131 expires: Date.now() + 1e11,
132 refresh_token: "",
133 access_token,
134 expires_in: 1e11
135 });
136}
137export async function getAuth(options = {}) {
138 let data;
139 let hassUrl = options.hassUrl;
140 // Strip trailing slash.
141 if (hassUrl && hassUrl[hassUrl.length - 1] === "/") {
142 hassUrl = hassUrl.substr(0, hassUrl.length - 1);
143 }
144 const clientId = options.clientId !== undefined ? options.clientId : genClientId();
145 // Use auth code if it was passed in
146 if (!data && options.authCode && hassUrl) {
147 data = await fetchToken(hassUrl, clientId, options.authCode);
148 if (options.saveTokens) {
149 options.saveTokens(data);
150 }
151 }
152 // Check if we came back from an authorize redirect
153 if (!data) {
154 const query = parseQuery(location.search.substr(1));
155 // Check if we got redirected here from authorize page
156 if ("auth_callback" in query) {
157 // Restore state
158 const state = decodeOAuthState(query.state);
159 data = await fetchToken(state.hassUrl, state.clientId, query.code);
160 if (options.saveTokens) {
161 options.saveTokens(data);
162 }
163 }
164 }
165 // Check for stored tokens
166 if (!data && options.loadTokens) {
167 data = await options.loadTokens();
168 }
169 if (data) {
170 return new Auth(data, options.saveTokens);
171 }
172 if (hassUrl === undefined) {
173 throw ERR_HASS_HOST_REQUIRED;
174 }
175 // If no tokens found but a hassUrl was passed in, let's go get some tokens!
176 redirectAuthorize(hassUrl, clientId, options.redirectUrl || genRedirectUrl(), encodeOAuthState({
177 hassUrl,
178 clientId
179 }));
180 // Just don't resolve while we navigate to next page
181 return new Promise(() => { });
182}