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