]> git.basschouten.com Git - openhab-addons.git/blob
90dd033a90797f5728ea528d480841809defaa91
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2021 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
7  * This program and the accompanying materials are made available under the
8  * terms of the Eclipse Public License 2.0 which is available at
9  * http://www.eclipse.org/legal/epl-2.0
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.myq.internal.handler;
14
15 import static org.openhab.binding.myq.internal.MyQBindingConstants.*;
16
17 import java.io.IOException;
18 import java.io.UnsupportedEncodingException;
19 import java.net.HttpCookie;
20 import java.net.URI;
21 import java.net.URISyntaxException;
22 import java.security.MessageDigest;
23 import java.security.NoSuchAlgorithmException;
24 import java.security.SecureRandom;
25 import java.util.Arrays;
26 import java.util.Base64;
27 import java.util.Collection;
28 import java.util.Collections;
29 import java.util.Map;
30 import java.util.Random;
31 import java.util.concurrent.CompletableFuture;
32 import java.util.concurrent.ExecutionException;
33 import java.util.concurrent.Future;
34 import java.util.concurrent.TimeUnit;
35 import java.util.concurrent.TimeoutException;
36 import java.util.stream.Collectors;
37
38 import org.eclipse.jdt.annotation.NonNullByDefault;
39 import org.eclipse.jdt.annotation.Nullable;
40 import org.eclipse.jetty.client.HttpClient;
41 import org.eclipse.jetty.client.HttpContentResponse;
42 import org.eclipse.jetty.client.api.ContentProvider;
43 import org.eclipse.jetty.client.api.ContentResponse;
44 import org.eclipse.jetty.client.api.Request;
45 import org.eclipse.jetty.client.api.Response;
46 import org.eclipse.jetty.client.api.Result;
47 import org.eclipse.jetty.client.util.BufferingResponseListener;
48 import org.eclipse.jetty.client.util.FormContentProvider;
49 import org.eclipse.jetty.client.util.StringContentProvider;
50 import org.eclipse.jetty.http.HttpMethod;
51 import org.eclipse.jetty.http.HttpStatus;
52 import org.eclipse.jetty.util.Fields;
53 import org.jsoup.Jsoup;
54 import org.jsoup.nodes.Document;
55 import org.jsoup.nodes.Element;
56 import org.openhab.binding.myq.internal.MyQDiscoveryService;
57 import org.openhab.binding.myq.internal.config.MyQAccountConfiguration;
58 import org.openhab.binding.myq.internal.dto.AccountDTO;
59 import org.openhab.binding.myq.internal.dto.ActionDTO;
60 import org.openhab.binding.myq.internal.dto.DevicesDTO;
61 import org.openhab.core.auth.client.oauth2.AccessTokenRefreshListener;
62 import org.openhab.core.auth.client.oauth2.AccessTokenResponse;
63 import org.openhab.core.auth.client.oauth2.OAuthClientService;
64 import org.openhab.core.auth.client.oauth2.OAuthException;
65 import org.openhab.core.auth.client.oauth2.OAuthFactory;
66 import org.openhab.core.auth.client.oauth2.OAuthResponseException;
67 import org.openhab.core.thing.Bridge;
68 import org.openhab.core.thing.ChannelUID;
69 import org.openhab.core.thing.Thing;
70 import org.openhab.core.thing.ThingStatus;
71 import org.openhab.core.thing.ThingStatusDetail;
72 import org.openhab.core.thing.ThingTypeUID;
73 import org.openhab.core.thing.binding.BaseBridgeHandler;
74 import org.openhab.core.thing.binding.ThingHandler;
75 import org.openhab.core.thing.binding.ThingHandlerService;
76 import org.openhab.core.types.Command;
77 import org.slf4j.Logger;
78 import org.slf4j.LoggerFactory;
79
80 import com.google.gson.FieldNamingPolicy;
81 import com.google.gson.Gson;
82 import com.google.gson.GsonBuilder;
83 import com.google.gson.JsonSyntaxException;
84
85 /**
86  * The {@link MyQAccountHandler} is responsible for communicating with the MyQ API based on an account.
87  *
88  * @author Dan Cunningham - Initial contribution
89  */
90 @NonNullByDefault
91 public class MyQAccountHandler extends BaseBridgeHandler implements AccessTokenRefreshListener {
92     /*
93      * MyQ oAuth relate fields
94      */
95     private static final String CLIENT_SECRET = "VUQ0RFhuS3lQV3EyNUJTdw==";
96     private static final String CLIENT_ID = "IOS_CGI_MYQ";
97     private static final String REDIRECT_URI = "com.myqops://ios";
98     private static final String SCOPE = "MyQ_Residential offline_access";
99     /*
100      * MyQ authentication API endpoints
101      */
102     private static final String LOGIN_BASE_URL = "https://partner-identity.myq-cloud.com";
103     private static final String LOGIN_AUTHORIZE_URL = LOGIN_BASE_URL + "/connect/authorize";
104     private static final String LOGIN_TOKEN_URL = LOGIN_BASE_URL + "/connect/token";
105     // this should never happen, but lets be safe and give up after so many redirects
106     private static final int LOGIN_MAX_REDIRECTS = 30;
107     /*
108      * MyQ device and account API endpoint
109      */
110     private static final String BASE_URL = "https://api.myqdevice.com/api";
111     private static final Integer RAPID_REFRESH_SECONDS = 5;
112     private final Logger logger = LoggerFactory.getLogger(MyQAccountHandler.class);
113     private final Gson gsonUpperCase = new GsonBuilder().setFieldNamingPolicy(FieldNamingPolicy.UPPER_CAMEL_CASE)
114             .create();
115     private final Gson gsonLowerCase = new GsonBuilder()
116             .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES).create();
117     private final OAuthFactory oAuthFactory;
118     private @Nullable Future<?> normalPollFuture;
119     private @Nullable Future<?> rapidPollFuture;
120     private @Nullable AccountDTO account;
121     private @Nullable DevicesDTO devicesCache;
122     private @Nullable OAuthClientService oAuthService;
123     private Integer normalRefreshSeconds = 60;
124     private HttpClient httpClient;
125     private String username = "";
126     private String password = "";
127     private String userAgent = "";
128     // force login, even if we have a token
129     private boolean needsLogin = false;
130
131     public MyQAccountHandler(Bridge bridge, HttpClient httpClient, final OAuthFactory oAuthFactory) {
132         super(bridge);
133         this.httpClient = httpClient;
134         this.oAuthFactory = oAuthFactory;
135     }
136
137     @Override
138     public void handleCommand(ChannelUID channelUID, Command command) {
139     }
140
141     @Override
142     public void initialize() {
143         MyQAccountConfiguration config = getConfigAs(MyQAccountConfiguration.class);
144         normalRefreshSeconds = config.refreshInterval;
145         username = config.username;
146         password = config.password;
147         // MyQ can get picky about blocking user agents apparently
148         userAgent = MyQAccountHandler.randomString(20);
149         needsLogin = true;
150         updateStatus(ThingStatus.UNKNOWN);
151         restartPolls(false);
152     }
153
154     @Override
155     public void dispose() {
156         stopPolls();
157         if (oAuthService != null) {
158             oAuthService.close();
159         }
160     }
161
162     @Override
163     public Collection<Class<? extends ThingHandlerService>> getServices() {
164         return Collections.singleton(MyQDiscoveryService.class);
165     }
166
167     @Override
168     public void childHandlerInitialized(ThingHandler childHandler, Thing childThing) {
169         DevicesDTO localDeviceCaches = devicesCache;
170         if (localDeviceCaches != null && childHandler instanceof MyQDeviceHandler) {
171             MyQDeviceHandler handler = (MyQDeviceHandler) childHandler;
172             localDeviceCaches.items.stream()
173                     .filter(d -> ((MyQDeviceHandler) childHandler).getSerialNumber().equalsIgnoreCase(d.serialNumber))
174                     .findFirst().ifPresent(handler::handleDeviceUpdate);
175         }
176     }
177
178     @Override
179     public void onAccessTokenResponse(AccessTokenResponse tokenResponse) {
180         logger.debug("Auth Token Refreshed, expires in {}", tokenResponse.getExpiresIn());
181     }
182
183     /**
184      * Sends an action to the MyQ API
185      *
186      * @param serialNumber
187      * @param action
188      */
189     public void sendAction(String serialNumber, String action) {
190         if (getThing().getStatus() != ThingStatus.ONLINE) {
191             logger.debug("Account offline, ignoring action {}", action);
192             return;
193         }
194
195         AccountDTO localAccount = account;
196         if (localAccount != null) {
197             try {
198                 ContentResponse response = sendRequest(
199                         String.format("%s/v5.1/Accounts/%s/Devices/%s/actions", BASE_URL, localAccount.account.id,
200                                 serialNumber),
201                         HttpMethod.PUT, new StringContentProvider(gsonLowerCase.toJson(new ActionDTO(action))),
202                         "application/json");
203                 if (HttpStatus.isSuccess(response.getStatus())) {
204                     restartPolls(true);
205                 } else {
206                     logger.debug("Failed to send action {} : {}", action, response.getContentAsString());
207                 }
208             } catch (InterruptedException | MyQCommunicationException | MyQAuthenticationException e) {
209                 logger.debug("Could not send action", e);
210             }
211         }
212     }
213
214     /**
215      * Last known state of MyQ Devices
216      *
217      * @return cached MyQ devices
218      */
219     public @Nullable DevicesDTO devicesCache() {
220         return devicesCache;
221     }
222
223     private void stopPolls() {
224         stopNormalPoll();
225         stopRapidPoll();
226     }
227
228     private synchronized void stopNormalPoll() {
229         stopFuture(normalPollFuture);
230         normalPollFuture = null;
231     }
232
233     private synchronized void stopRapidPoll() {
234         stopFuture(rapidPollFuture);
235         rapidPollFuture = null;
236     }
237
238     private void stopFuture(@Nullable Future<?> future) {
239         if (future != null) {
240             future.cancel(true);
241         }
242     }
243
244     private synchronized void restartPolls(boolean rapid) {
245         stopPolls();
246         if (rapid) {
247             normalPollFuture = scheduler.scheduleWithFixedDelay(this::normalPoll, 35, normalRefreshSeconds,
248                     TimeUnit.SECONDS);
249             rapidPollFuture = scheduler.scheduleWithFixedDelay(this::rapidPoll, 3, RAPID_REFRESH_SECONDS,
250                     TimeUnit.SECONDS);
251         } else {
252             normalPollFuture = scheduler.scheduleWithFixedDelay(this::normalPoll, 0, normalRefreshSeconds,
253                     TimeUnit.SECONDS);
254         }
255     }
256
257     private void normalPoll() {
258         stopRapidPoll();
259         fetchData();
260     }
261
262     private void rapidPoll() {
263         fetchData();
264     }
265
266     private synchronized void fetchData() {
267         try {
268             if (account == null) {
269                 getAccount();
270             }
271             getDevices();
272         } catch (MyQCommunicationException e) {
273             logger.debug("MyQ communication error", e);
274             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
275         } catch (MyQAuthenticationException e) {
276             logger.debug("MyQ authentication error", e);
277             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
278             stopPolls();
279         } catch (InterruptedException e) {
280             // we were shut down, ignore
281         }
282     }
283
284     /**
285      * This attempts to navigate the MyQ oAuth login flow in order to obtain a @AccessTokenResponse
286      *
287      * @return AccessTokenResponse token
288      * @throws InterruptedException
289      * @throws MyQCommunicationException
290      * @throws MyQAuthenticationException
291      */
292     private AccessTokenResponse login()
293             throws InterruptedException, MyQCommunicationException, MyQAuthenticationException {
294         // make sure we have a fresh session
295         httpClient.getCookieStore().removeAll();
296
297         try {
298             String codeVerifier = generateCodeVerifier();
299
300             ContentResponse loginPageResponse = getLoginPage(codeVerifier);
301
302             // load the login page to get cookies and form parameters
303             Document loginPage = Jsoup.parse(loginPageResponse.getContentAsString());
304             Element form = loginPage.select("form").first();
305             Element requestToken = loginPage.select("input[name=__RequestVerificationToken]").first();
306             Element returnURL = loginPage.select("input[name=ReturnUrl]").first();
307
308             if (form == null || requestToken == null) {
309                 throw new MyQCommunicationException("Could not load login page");
310             }
311
312             // url that the form will submit to
313             String action = LOGIN_BASE_URL + form.attr("action");
314
315             // post our user name and password along with elements from the scraped form
316             String location = postLogin(action, requestToken.attr("value"), returnURL.attr("value"));
317             if (location == null) {
318                 throw new MyQAuthenticationException("Could not login with credentials");
319             }
320
321             // finally complete the oAuth flow and retrieve a JSON oAuth token response
322             ContentResponse tokenResponse = getLoginToken(location, codeVerifier);
323             String loginToken = tokenResponse.getContentAsString();
324
325             AccessTokenResponse accessTokenResponse = gsonLowerCase.fromJson(loginToken, AccessTokenResponse.class);
326             if (accessTokenResponse == null) {
327                 throw new MyQAuthenticationException("Could not parse token response");
328             }
329             getOAuthService().importAccessTokenResponse(accessTokenResponse);
330             return accessTokenResponse;
331         } catch (IOException | ExecutionException | TimeoutException | OAuthException e) {
332             throw new MyQCommunicationException(e.getMessage());
333         }
334     }
335
336     private void getAccount() throws InterruptedException, MyQCommunicationException, MyQAuthenticationException {
337         ContentResponse response = sendRequest(BASE_URL + "/v5/My?expand=account", HttpMethod.GET, null, null);
338         account = parseResultAndUpdateStatus(response, gsonUpperCase, AccountDTO.class);
339     }
340
341     private void getDevices() throws InterruptedException, MyQCommunicationException, MyQAuthenticationException {
342         AccountDTO localAccount = account;
343         if (localAccount == null) {
344             return;
345         }
346         ContentResponse response = sendRequest(
347                 String.format("%s/v5.1/Accounts/%s/Devices", BASE_URL, localAccount.account.id), HttpMethod.GET, null,
348                 null);
349         DevicesDTO devices = parseResultAndUpdateStatus(response, gsonLowerCase, DevicesDTO.class);
350         devicesCache = devices;
351         devices.items.forEach(device -> {
352             ThingTypeUID thingTypeUID = new ThingTypeUID(BINDING_ID, device.deviceFamily);
353             if (SUPPORTED_DISCOVERY_THING_TYPES_UIDS.contains(thingTypeUID)) {
354                 for (Thing thing : getThing().getThings()) {
355                     ThingHandler handler = thing.getHandler();
356                     if (handler != null
357                             && ((MyQDeviceHandler) handler).getSerialNumber().equalsIgnoreCase(device.serialNumber)) {
358                         ((MyQDeviceHandler) handler).handleDeviceUpdate(device);
359                     }
360                 }
361             }
362         });
363     }
364
365     private synchronized ContentResponse sendRequest(String url, HttpMethod method, @Nullable ContentProvider content,
366             @Nullable String contentType)
367             throws InterruptedException, MyQCommunicationException, MyQAuthenticationException {
368         AccessTokenResponse tokenResponse = null;
369         // if we don't need to force a login, attempt to use the token we have
370         if (!needsLogin) {
371             try {
372                 tokenResponse = getOAuthService().getAccessTokenResponse();
373             } catch (OAuthException | IOException | OAuthResponseException e) {
374                 // ignore error, will try to login below
375                 logger.debug("Error accessing token, will attempt to login again", e);
376             }
377         }
378
379         // if no token, or we need to login, do so now
380         if (tokenResponse == null) {
381             tokenResponse = login();
382             needsLogin = false;
383         }
384
385         Request request = httpClient.newRequest(url).method(method).agent(userAgent).timeout(10, TimeUnit.SECONDS)
386                 .header("Authorization", authTokenHeader(tokenResponse));
387         if (content != null & contentType != null) {
388             request = request.content(content, contentType);
389         }
390
391         // use asyc jetty as the API service will response with a 401 error when credentials are wrong,
392         // but not a WWW-Authenticate header which causes Jetty to throw a generic execution exception which
393         // prevents us from knowing the response code
394         logger.trace("Sending {} to {}", request.getMethod(), request.getURI());
395         final CompletableFuture<ContentResponse> futureResult = new CompletableFuture<>();
396         request.send(new BufferingResponseListener() {
397             @NonNullByDefault({})
398             @Override
399             public void onComplete(Result result) {
400                 Response response = result.getResponse();
401                 futureResult.complete(new HttpContentResponse(response, getContent(), getMediaType(), getEncoding()));
402             }
403         });
404
405         try {
406             ContentResponse result = futureResult.get();
407             logger.trace("Account Response - status: {} content: {}", result.getStatus(), result.getContentAsString());
408             return result;
409         } catch (ExecutionException e) {
410             throw new MyQCommunicationException(e.getMessage());
411         }
412     }
413
414     private <T> T parseResultAndUpdateStatus(ContentResponse response, Gson parser, Class<T> classOfT)
415             throws MyQCommunicationException {
416         if (HttpStatus.isSuccess(response.getStatus())) {
417             try {
418                 T responseObject = parser.fromJson(response.getContentAsString(), classOfT);
419                 if (responseObject != null) {
420                     if (getThing().getStatus() != ThingStatus.ONLINE) {
421                         updateStatus(ThingStatus.ONLINE);
422                     }
423                     return responseObject;
424                 } else {
425                     throw new MyQCommunicationException("Bad response from server");
426                 }
427             } catch (JsonSyntaxException e) {
428                 throw new MyQCommunicationException("Invalid JSON Response " + response.getContentAsString());
429             }
430         } else if (response.getStatus() == HttpStatus.UNAUTHORIZED_401) {
431             // our tokens no longer work, will need to login again
432             needsLogin = true;
433             throw new MyQCommunicationException("Token was rejected for request");
434         } else {
435             throw new MyQCommunicationException(
436                     "Invalid Response Code " + response.getStatus() + " : " + response.getContentAsString());
437         }
438     }
439
440     /**
441      * Returns the MyQ login page which contains form elements and cookies needed to login
442      *
443      * @param codeVerifier
444      * @return
445      * @throws InterruptedException
446      * @throws ExecutionException
447      * @throws TimeoutException
448      */
449     private ContentResponse getLoginPage(String codeVerifier)
450             throws InterruptedException, ExecutionException, TimeoutException {
451         try {
452             Request request = httpClient.newRequest(LOGIN_AUTHORIZE_URL) //
453                     .param("client_id", CLIENT_ID) //
454                     .param("code_challenge", generateCodeChallange(codeVerifier)) //
455                     .param("code_challenge_method", "S256") //
456                     .param("redirect_uri", REDIRECT_URI) //
457                     .param("response_type", "code") //
458                     .param("scope", SCOPE) //
459                     .agent(userAgent).followRedirects(true);
460             logger.debug("Sending {} to {}", request.getMethod(), request.getURI());
461             ContentResponse response = request.send();
462             logger.debug("Login Code {} Response {}", response.getStatus(), response.getContentAsString());
463             return response;
464         } catch (UnsupportedEncodingException | NoSuchAlgorithmException e) {
465             throw new ExecutionException(e.getCause());
466         }
467     }
468
469     /**
470      * Sends configured credentials and elements from the login page in order to obtain a redirect location header value
471      *
472      * @param url
473      * @param requestToken
474      * @param returnURL
475      * @return The location header value
476      * @throws InterruptedException
477      * @throws ExecutionException
478      * @throws TimeoutException
479      */
480     @Nullable
481     private String postLogin(String url, String requestToken, String returnURL)
482             throws InterruptedException, ExecutionException, TimeoutException {
483         /*
484          * on a successful post to this page we will get several redirects, and a final 301 to:
485          * com.myqops://ios?code=0123456789&scope=MyQ_Residential%20offline_access&iss=https%3A%2F%2Fpartner-identity.
486          * myq-cloud.com
487          *
488          * We can then take the parameters out of this location and continue the process
489          */
490         Fields fields = new Fields();
491         fields.add("Email", username);
492         fields.add("Password", password);
493         fields.add("__RequestVerificationToken", requestToken);
494         fields.add("ReturnUrl", returnURL);
495
496         Request request = httpClient.newRequest(url).method(HttpMethod.POST) //
497                 .content(new FormContentProvider(fields)) //
498                 .agent(userAgent) //
499                 .followRedirects(false);
500         setCookies(request);
501
502         logger.debug("Posting Login to {}", url);
503         ContentResponse response = request.send();
504
505         String location = null;
506
507         // follow redirects until we match our REDIRECT_URI or hit a redirect safety limit
508         for (int i = 0; i < LOGIN_MAX_REDIRECTS && HttpStatus.isRedirection(response.getStatus()); i++) {
509
510             String loc = response.getHeaders().get("location");
511             if (logger.isTraceEnabled()) {
512                 logger.trace("Redirect Login: Code {} Location Header: {} Response {}", response.getStatus(), loc,
513                         response.getContentAsString());
514             }
515             if (loc == null) {
516                 logger.debug("No location value");
517                 break;
518             }
519             if (loc.indexOf(REDIRECT_URI) == 0) {
520                 location = loc;
521                 break;
522             }
523             request = httpClient.newRequest(LOGIN_BASE_URL + loc).agent(userAgent).followRedirects(false);
524             setCookies(request);
525             response = request.send();
526         }
527         return location;
528     }
529
530     /**
531      * Final step of the login process to get a oAuth access response token
532      *
533      * @param redirectLocation
534      * @param codeVerifier
535      * @return
536      * @throws InterruptedException
537      * @throws ExecutionException
538      * @throws TimeoutException
539      */
540     private ContentResponse getLoginToken(String redirectLocation, String codeVerifier)
541             throws InterruptedException, ExecutionException, TimeoutException {
542         try {
543             Map<String, String> params = parseLocationQuery(redirectLocation);
544
545             Fields fields = new Fields();
546             fields.add("client_id", CLIENT_ID);
547             fields.add("client_secret", Base64.getEncoder().encodeToString(CLIENT_SECRET.getBytes()));
548             fields.add("code", params.get("code"));
549             fields.add("code_verifier", codeVerifier);
550             fields.add("grant_type", "authorization_code");
551             fields.add("redirect_uri", REDIRECT_URI);
552             fields.add("scope", params.get("scope"));
553
554             Request request = httpClient.newRequest(LOGIN_TOKEN_URL) //
555                     .content(new FormContentProvider(fields)) //
556                     .method(HttpMethod.POST) //
557                     .agent(userAgent).followRedirects(true);
558             setCookies(request);
559
560             ContentResponse response = request.send();
561             if (logger.isTraceEnabled()) {
562                 logger.trace("Login Code {} Response {}", response.getStatus(), response.getContentAsString());
563             }
564             return response;
565         } catch (URISyntaxException e) {
566             throw new ExecutionException(e.getCause());
567         }
568     }
569
570     private OAuthClientService getOAuthService() {
571         OAuthClientService oAuthService = this.oAuthService;
572         if (oAuthService == null || oAuthService.isClosed()) {
573             oAuthService = oAuthFactory.createOAuthClientService(getThing().toString(), LOGIN_TOKEN_URL,
574                     LOGIN_AUTHORIZE_URL, CLIENT_ID, CLIENT_SECRET, SCOPE, false);
575             oAuthService.addAccessTokenRefreshListener(this);
576             this.oAuthService = oAuthService;
577         }
578         return oAuthService;
579     }
580
581     private static String randomString(int length) {
582         int low = 97; // a-z
583         int high = 122; // A-Z
584         StringBuilder sb = new StringBuilder(length);
585         Random random = new Random();
586         for (int i = 0; i < length; i++) {
587             sb.append((char) (low + (int) (random.nextFloat() * (high - low + 1))));
588         }
589         return sb.toString();
590     }
591
592     private String generateCodeVerifier() throws UnsupportedEncodingException {
593         SecureRandom secureRandom = new SecureRandom();
594         byte[] codeVerifier = new byte[32];
595         secureRandom.nextBytes(codeVerifier);
596         return Base64.getUrlEncoder().withoutPadding().encodeToString(codeVerifier);
597     }
598
599     private String generateCodeChallange(String codeVerifier)
600             throws UnsupportedEncodingException, NoSuchAlgorithmException {
601         byte[] bytes = codeVerifier.getBytes("US-ASCII");
602         MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");
603         messageDigest.update(bytes, 0, bytes.length);
604         byte[] digest = messageDigest.digest();
605         return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
606     }
607
608     private Map<String, String> parseLocationQuery(String location) throws URISyntaxException {
609         URI uri = new URI(location);
610         return Arrays.stream(uri.getQuery().split("&")).map(str -> str.split("="))
611                 .collect(Collectors.toMap(str -> str[0], str -> str[1]));
612     }
613
614     private void setCookies(Request request) {
615         for (HttpCookie c : httpClient.getCookieStore().getCookies()) {
616             request.cookie(c);
617         }
618     }
619
620     private String authTokenHeader(AccessTokenResponse tokenResponse) {
621         return tokenResponse.getTokenType() + " " + tokenResponse.getAccessToken();
622     }
623
624     /**
625      * Exception for authenticated related errors
626      */
627     class MyQAuthenticationException extends Exception {
628         private static final long serialVersionUID = 1L;
629
630         public MyQAuthenticationException(String message) {
631             super(message);
632         }
633     }
634
635     /**
636      * Generic exception for non authentication related errors when communicating with the MyQ service.
637      */
638     class MyQCommunicationException extends IOException {
639         private static final long serialVersionUID = 1L;
640
641         public MyQCommunicationException(@Nullable String message) {
642             super(message);
643         }
644     }
645 }