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