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