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