]> git.basschouten.com Git - openhab-addons.git/commitdiff
[myq] Switch to using oAuth for logins (#11183)
authorDan Cunningham <dan@digitaldan.com>
Sat, 11 Sep 2021 11:41:28 +0000 (04:41 -0700)
committerGitHub <noreply@github.com>
Sat, 11 Sep 2021 11:41:28 +0000 (13:41 +0200)
* change MyQ binding to use now required oAuth for authentication

Signed-off-by: Dan Cunningham <dan@digitaldan.com>
* Clean up error handling

Signed-off-by: Dan Cunningham <dan@digitaldan.com>
* Cleanup checkstyle errors

Signed-off-by: Dan Cunningham <dan@digitaldan.com>
* missing newline

Signed-off-by: Dan Cunningham <dan@digitaldan.com>
* Remove unused classes

Signed-off-by: Dan Cunningham <dan@digitaldan.com>
* Add token listener

Signed-off-by: Dan Cunningham <dan@digitaldan.com>
* add a redirect limit...just in case

Signed-off-by: Dan Cunningham <dan@digitaldan.com>
* Don't resue the oAuth service if we have been disosed or its closed.  Reduce logging verbosity.

Signed-off-by: Dan Cunningham <dan@digitaldan.com>
* Force login if we get a 401 response

Signed-off-by: Dan Cunningham <dan@digitaldan.com>
bundles/org.openhab.binding.myq/NOTICE
bundles/org.openhab.binding.myq/pom.xml
bundles/org.openhab.binding.myq/src/main/feature/feature.xml
bundles/org.openhab.binding.myq/src/main/java/org/openhab/binding/myq/internal/MyQHandlerFactory.java
bundles/org.openhab.binding.myq/src/main/java/org/openhab/binding/myq/internal/dto/LoginRequestDTO.java [deleted file]
bundles/org.openhab.binding.myq/src/main/java/org/openhab/binding/myq/internal/dto/LoginResponseDTO.java [deleted file]
bundles/org.openhab.binding.myq/src/main/java/org/openhab/binding/myq/internal/handler/MyQAccountHandler.java

index 38d625e349232ff5ffcc71bd75e4692cdac12768..0ca708bef198a5ebda37cd8202a05e6ca5f858ae 100644 (file)
@@ -11,3 +11,10 @@ https://www.eclipse.org/legal/epl-2.0/.
 == Source Code
 
 https://github.com/openhab/openhab-addons
+
+== Third-party Content
+
+jsoup
+* License: MIT License
+* Project: https://jsoup.org/
+* Source:  https://github.com/jhy/jsoup
index 564dd9ca217c05122eb021aa88595030c8087480..a94ba5262dcb1aaa31dac6131ceb14057d43ec43 100644 (file)
 
   <name>openHAB Add-ons :: Bundles :: MyQ Binding</name>
 
+  <dependencies>
+    <dependency>
+      <groupId>org.jsoup</groupId>
+      <artifactId>jsoup</artifactId>
+      <version>1.8.3</version>
+      <scope>provided</scope>
+    </dependency>
+  </dependencies>
 </project>
index 60382373bb63b4784d4d6c5c2825fc1f46272af7..c5fabb87e6b12e34b14e9612b0292daca9c7e05b 100644 (file)
@@ -4,6 +4,7 @@
 
        <feature name="openhab-binding-myq" description="MyQ Binding" version="${project.version}">
                <feature>openhab-runtime-base</feature>
+               <bundle dependency="true">mvn:org.jsoup/jsoup/1.8.3</bundle>
                <bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.myq/${project.version}</bundle>
        </feature>
 </features>
index 2d01eee7788aee4bc61c8fe402697e6dd619e9ff..d3d151cbec59af2a68ffd5cbeb7bf6ba5e981e7c 100644 (file)
@@ -20,6 +20,7 @@ import org.eclipse.jetty.client.HttpClient;
 import org.openhab.binding.myq.internal.handler.MyQAccountHandler;
 import org.openhab.binding.myq.internal.handler.MyQGarageDoorHandler;
 import org.openhab.binding.myq.internal.handler.MyQLampHandler;
+import org.openhab.core.auth.client.oauth2.OAuthFactory;
 import org.openhab.core.io.net.http.HttpClientFactory;
 import org.openhab.core.thing.Bridge;
 import org.openhab.core.thing.Thing;
@@ -41,10 +42,13 @@ import org.osgi.service.component.annotations.Reference;
 @Component(configurationPid = "binding.myq", service = ThingHandlerFactory.class)
 public class MyQHandlerFactory extends BaseThingHandlerFactory {
     private final HttpClient httpClient;
+    private OAuthFactory oAuthFactory;
 
     @Activate
-    public MyQHandlerFactory(final @Reference HttpClientFactory httpClientFactory) {
+    public MyQHandlerFactory(final @Reference HttpClientFactory httpClientFactory,
+            final @Reference OAuthFactory oAuthFactory) {
         this.httpClient = httpClientFactory.getCommonHttpClient();
+        this.oAuthFactory = oAuthFactory;
     }
 
     @Override
@@ -57,7 +61,7 @@ public class MyQHandlerFactory extends BaseThingHandlerFactory {
         ThingTypeUID thingTypeUID = thing.getThingTypeUID();
 
         if (THING_TYPE_ACCOUNT.equals(thingTypeUID)) {
-            return new MyQAccountHandler((Bridge) thing, httpClient);
+            return new MyQAccountHandler((Bridge) thing, httpClient, oAuthFactory);
         }
 
         if (THING_TYPE_GARAGEDOOR.equals(thingTypeUID)) {
diff --git a/bundles/org.openhab.binding.myq/src/main/java/org/openhab/binding/myq/internal/dto/LoginRequestDTO.java b/bundles/org.openhab.binding.myq/src/main/java/org/openhab/binding/myq/internal/dto/LoginRequestDTO.java
deleted file mode 100644 (file)
index 8b2eaf5..0000000
+++ /dev/null
@@ -1,30 +0,0 @@
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.myq.internal.dto;
-
-/**
- * The {@link LoginRequestDTO} entity from the MyQ API
- *
- * @author Dan Cunningham - Initial contribution
- */
-public class LoginRequestDTO {
-
-    public LoginRequestDTO(String username, String password) {
-        super();
-        this.username = username;
-        this.password = password;
-    }
-
-    public String username;
-    public String password;
-}
diff --git a/bundles/org.openhab.binding.myq/src/main/java/org/openhab/binding/myq/internal/dto/LoginResponseDTO.java b/bundles/org.openhab.binding.myq/src/main/java/org/openhab/binding/myq/internal/dto/LoginResponseDTO.java
deleted file mode 100644 (file)
index 2dfcd63..0000000
+++ /dev/null
@@ -1,22 +0,0 @@
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.myq.internal.dto;
-
-/**
- * The {@link LoginResponseDTO} entity from the MyQ API
- *
- * @author Dan Cunningham - Initial contribution
- */
-public class LoginResponseDTO {
-    public String securityToken;
-}
index f9dc5f11bcb0f1394fd179c98698578efa41ddd5..90dd033a90797f5728ea528d480841809defaa91 100644 (file)
@@ -14,31 +14,56 @@ package org.openhab.binding.myq.internal.handler;
 
 import static org.openhab.binding.myq.internal.MyQBindingConstants.*;
 
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.net.HttpCookie;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.util.Arrays;
+import java.util.Base64;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.Map;
 import java.util.Random;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.stream.Collectors;
 
 import org.eclipse.jdt.annotation.NonNullByDefault;
 import org.eclipse.jdt.annotation.Nullable;
 import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.client.HttpContentResponse;
 import org.eclipse.jetty.client.api.ContentProvider;
+import org.eclipse.jetty.client.api.ContentResponse;
 import org.eclipse.jetty.client.api.Request;
+import org.eclipse.jetty.client.api.Response;
 import org.eclipse.jetty.client.api.Result;
 import org.eclipse.jetty.client.util.BufferingResponseListener;
+import org.eclipse.jetty.client.util.FormContentProvider;
 import org.eclipse.jetty.client.util.StringContentProvider;
 import org.eclipse.jetty.http.HttpMethod;
 import org.eclipse.jetty.http.HttpStatus;
+import org.eclipse.jetty.util.Fields;
+import org.jsoup.Jsoup;
+import org.jsoup.nodes.Document;
+import org.jsoup.nodes.Element;
 import org.openhab.binding.myq.internal.MyQDiscoveryService;
 import org.openhab.binding.myq.internal.config.MyQAccountConfiguration;
 import org.openhab.binding.myq.internal.dto.AccountDTO;
 import org.openhab.binding.myq.internal.dto.ActionDTO;
 import org.openhab.binding.myq.internal.dto.DevicesDTO;
-import org.openhab.binding.myq.internal.dto.LoginRequestDTO;
-import org.openhab.binding.myq.internal.dto.LoginResponseDTO;
+import org.openhab.core.auth.client.oauth2.AccessTokenRefreshListener;
+import org.openhab.core.auth.client.oauth2.AccessTokenResponse;
+import org.openhab.core.auth.client.oauth2.OAuthClientService;
+import org.openhab.core.auth.client.oauth2.OAuthException;
+import org.openhab.core.auth.client.oauth2.OAuthFactory;
+import org.openhab.core.auth.client.oauth2.OAuthResponseException;
 import org.openhab.core.thing.Bridge;
 import org.openhab.core.thing.ChannelUID;
 import org.openhab.core.thing.Thing;
@@ -63,7 +88,25 @@ import com.google.gson.JsonSyntaxException;
  * @author Dan Cunningham - Initial contribution
  */
 @NonNullByDefault
-public class MyQAccountHandler extends BaseBridgeHandler {
+public class MyQAccountHandler extends BaseBridgeHandler implements AccessTokenRefreshListener {
+    /*
+     * MyQ oAuth relate fields
+     */
+    private static final String CLIENT_SECRET = "VUQ0RFhuS3lQV3EyNUJTdw==";
+    private static final String CLIENT_ID = "IOS_CGI_MYQ";
+    private static final String REDIRECT_URI = "com.myqops://ios";
+    private static final String SCOPE = "MyQ_Residential offline_access";
+    /*
+     * MyQ authentication API endpoints
+     */
+    private static final String LOGIN_BASE_URL = "https://partner-identity.myq-cloud.com";
+    private static final String LOGIN_AUTHORIZE_URL = LOGIN_BASE_URL + "/connect/authorize";
+    private static final String LOGIN_TOKEN_URL = LOGIN_BASE_URL + "/connect/token";
+    // this should never happen, but lets be safe and give up after so many redirects
+    private static final int LOGIN_MAX_REDIRECTS = 30;
+    /*
+     * MyQ device and account API endpoint
+     */
     private static final String BASE_URL = "https://api.myqdevice.com/api";
     private static final Integer RAPID_REFRESH_SECONDS = 5;
     private final Logger logger = LoggerFactory.getLogger(MyQAccountHandler.class);
@@ -71,20 +114,24 @@ public class MyQAccountHandler extends BaseBridgeHandler {
             .create();
     private final Gson gsonLowerCase = new GsonBuilder()
             .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES).create();
+    private final OAuthFactory oAuthFactory;
     private @Nullable Future<?> normalPollFuture;
     private @Nullable Future<?> rapidPollFuture;
-    private @Nullable String securityToken;
     private @Nullable AccountDTO account;
     private @Nullable DevicesDTO devicesCache;
+    private @Nullable OAuthClientService oAuthService;
     private Integer normalRefreshSeconds = 60;
     private HttpClient httpClient;
     private String username = "";
     private String password = "";
     private String userAgent = "";
+    // force login, even if we have a token
+    private boolean needsLogin = false;
 
-    public MyQAccountHandler(Bridge bridge, HttpClient httpClient) {
+    public MyQAccountHandler(Bridge bridge, HttpClient httpClient, final OAuthFactory oAuthFactory) {
         super(bridge);
         this.httpClient = httpClient;
+        this.oAuthFactory = oAuthFactory;
     }
 
     @Override
@@ -98,8 +145,8 @@ public class MyQAccountHandler extends BaseBridgeHandler {
         username = config.username;
         password = config.password;
         // MyQ can get picky about blocking user agents apparently
-        userAgent = MyQAccountHandler.randomString(40);
-        securityToken = null;
+        userAgent = MyQAccountHandler.randomString(20);
+        needsLogin = true;
         updateStatus(ThingStatus.UNKNOWN);
         restartPolls(false);
     }
@@ -107,6 +154,9 @@ public class MyQAccountHandler extends BaseBridgeHandler {
     @Override
     public void dispose() {
         stopPolls();
+        if (oAuthService != null) {
+            oAuthService.close();
+        }
     }
 
     @Override
@@ -125,6 +175,11 @@ public class MyQAccountHandler extends BaseBridgeHandler {
         }
     }
 
+    @Override
+    public void onAccessTokenResponse(AccessTokenResponse tokenResponse) {
+        logger.debug("Auth Token Refreshed, expires in {}", tokenResponse.getExpiresIn());
+    }
+
     /**
      * Sends an action to the MyQ API
      *
@@ -132,20 +187,26 @@ public class MyQAccountHandler extends BaseBridgeHandler {
      * @param action
      */
     public void sendAction(String serialNumber, String action) {
+        if (getThing().getStatus() != ThingStatus.ONLINE) {
+            logger.debug("Account offline, ignoring action {}", action);
+            return;
+        }
+
         AccountDTO localAccount = account;
         if (localAccount != null) {
             try {
-                HttpResult result = sendRequest(
+                ContentResponse response = sendRequest(
                         String.format("%s/v5.1/Accounts/%s/Devices/%s/actions", BASE_URL, localAccount.account.id,
                                 serialNumber),
-                        HttpMethod.PUT, securityToken,
-                        new StringContentProvider(gsonLowerCase.toJson(new ActionDTO(action))), "application/json");
-                if (HttpStatus.isSuccess(result.responseCode)) {
+                        HttpMethod.PUT, new StringContentProvider(gsonLowerCase.toJson(new ActionDTO(action))),
+                        "application/json");
+                if (HttpStatus.isSuccess(response.getStatus())) {
                     restartPolls(true);
                 } else {
-                    logger.debug("Failed to send action {} : {}", action, result.content);
+                    logger.debug("Failed to send action {} : {}", action, response.getContentAsString());
                 }
-            } catch (InterruptedException e) {
+            } catch (InterruptedException | MyQCommunicationException | MyQAuthenticationException e) {
+                logger.debug("Could not send action", e);
             }
         }
     }
@@ -204,131 +265,317 @@ public class MyQAccountHandler extends BaseBridgeHandler {
 
     private synchronized void fetchData() {
         try {
-            if (securityToken == null) {
-                login();
-                if (securityToken != null) {
-                    getAccount();
-                }
-            }
-            if (securityToken != null) {
-                getDevices();
+            if (account == null) {
+                getAccount();
             }
+            getDevices();
+        } catch (MyQCommunicationException e) {
+            logger.debug("MyQ communication error", e);
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
+        } catch (MyQAuthenticationException e) {
+            logger.debug("MyQ authentication error", e);
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
+            stopPolls();
         } catch (InterruptedException e) {
+            // we were shut down, ignore
         }
     }
 
-    private void login() throws InterruptedException {
-        HttpResult result = sendRequest(BASE_URL + "/v5/Login", HttpMethod.POST, null,
-                new StringContentProvider(gsonUpperCase.toJson(new LoginRequestDTO(username, password))),
-                "application/json");
-        LoginResponseDTO loginResponse = parseResultAndUpdateStatus(result, gsonUpperCase, LoginResponseDTO.class);
-        if (loginResponse != null) {
-            securityToken = loginResponse.securityToken;
-        } else {
-            securityToken = null;
-            if (thing.getStatusInfo().getStatusDetail() == ThingStatusDetail.CONFIGURATION_ERROR) {
-                // bad credentials, stop trying to login
-                stopPolls();
+    /**
+     * This attempts to navigate the MyQ oAuth login flow in order to obtain a @AccessTokenResponse
+     *
+     * @return AccessTokenResponse token
+     * @throws InterruptedException
+     * @throws MyQCommunicationException
+     * @throws MyQAuthenticationException
+     */
+    private AccessTokenResponse login()
+            throws InterruptedException, MyQCommunicationException, MyQAuthenticationException {
+        // make sure we have a fresh session
+        httpClient.getCookieStore().removeAll();
+
+        try {
+            String codeVerifier = generateCodeVerifier();
+
+            ContentResponse loginPageResponse = getLoginPage(codeVerifier);
+
+            // load the login page to get cookies and form parameters
+            Document loginPage = Jsoup.parse(loginPageResponse.getContentAsString());
+            Element form = loginPage.select("form").first();
+            Element requestToken = loginPage.select("input[name=__RequestVerificationToken]").first();
+            Element returnURL = loginPage.select("input[name=ReturnUrl]").first();
+
+            if (form == null || requestToken == null) {
+                throw new MyQCommunicationException("Could not load login page");
+            }
+
+            // url that the form will submit to
+            String action = LOGIN_BASE_URL + form.attr("action");
+
+            // post our user name and password along with elements from the scraped form
+            String location = postLogin(action, requestToken.attr("value"), returnURL.attr("value"));
+            if (location == null) {
+                throw new MyQAuthenticationException("Could not login with credentials");
             }
+
+            // finally complete the oAuth flow and retrieve a JSON oAuth token response
+            ContentResponse tokenResponse = getLoginToken(location, codeVerifier);
+            String loginToken = tokenResponse.getContentAsString();
+
+            AccessTokenResponse accessTokenResponse = gsonLowerCase.fromJson(loginToken, AccessTokenResponse.class);
+            if (accessTokenResponse == null) {
+                throw new MyQAuthenticationException("Could not parse token response");
+            }
+            getOAuthService().importAccessTokenResponse(accessTokenResponse);
+            return accessTokenResponse;
+        } catch (IOException | ExecutionException | TimeoutException | OAuthException e) {
+            throw new MyQCommunicationException(e.getMessage());
         }
     }
 
-    private void getAccount() throws InterruptedException {
-        HttpResult result = sendRequest(BASE_URL + "/v5/My?expand=account", HttpMethod.GET, securityToken, null, null);
-        account = parseResultAndUpdateStatus(result, gsonUpperCase, AccountDTO.class);
+    private void getAccount() throws InterruptedException, MyQCommunicationException, MyQAuthenticationException {
+        ContentResponse response = sendRequest(BASE_URL + "/v5/My?expand=account", HttpMethod.GET, null, null);
+        account = parseResultAndUpdateStatus(response, gsonUpperCase, AccountDTO.class);
     }
 
-    private void getDevices() throws InterruptedException {
+    private void getDevices() throws InterruptedException, MyQCommunicationException, MyQAuthenticationException {
         AccountDTO localAccount = account;
         if (localAccount == null) {
             return;
         }
-        HttpResult result = sendRequest(String.format("%s/v5.1/Accounts/%s/Devices", BASE_URL, localAccount.account.id),
-                HttpMethod.GET, securityToken, null, null);
-        DevicesDTO devices = parseResultAndUpdateStatus(result, gsonLowerCase, DevicesDTO.class);
-        if (devices != null) {
-            devicesCache = devices;
-            devices.items.forEach(device -> {
-                ThingTypeUID thingTypeUID = new ThingTypeUID(BINDING_ID, device.deviceFamily);
-                if (SUPPORTED_DISCOVERY_THING_TYPES_UIDS.contains(thingTypeUID)) {
-                    for (Thing thing : getThing().getThings()) {
-                        ThingHandler handler = thing.getHandler();
-                        if (handler != null && ((MyQDeviceHandler) handler).getSerialNumber()
-                                .equalsIgnoreCase(device.serialNumber)) {
-                            ((MyQDeviceHandler) handler).handleDeviceUpdate(device);
-                        }
+        ContentResponse response = sendRequest(
+                String.format("%s/v5.1/Accounts/%s/Devices", BASE_URL, localAccount.account.id), HttpMethod.GET, null,
+                null);
+        DevicesDTO devices = parseResultAndUpdateStatus(response, gsonLowerCase, DevicesDTO.class);
+        devicesCache = devices;
+        devices.items.forEach(device -> {
+            ThingTypeUID thingTypeUID = new ThingTypeUID(BINDING_ID, device.deviceFamily);
+            if (SUPPORTED_DISCOVERY_THING_TYPES_UIDS.contains(thingTypeUID)) {
+                for (Thing thing : getThing().getThings()) {
+                    ThingHandler handler = thing.getHandler();
+                    if (handler != null
+                            && ((MyQDeviceHandler) handler).getSerialNumber().equalsIgnoreCase(device.serialNumber)) {
+                        ((MyQDeviceHandler) handler).handleDeviceUpdate(device);
                     }
                 }
-            });
-        }
+            }
+        });
     }
 
-    private synchronized HttpResult sendRequest(String url, HttpMethod method, @Nullable String token,
-            @Nullable ContentProvider content, @Nullable String contentType) throws InterruptedException {
-        try {
-            Request request = httpClient.newRequest(url).method(method)
-                    .header("MyQApplicationId", "JVM/G9Nwih5BwKgNCjLxiFUQxQijAebyyg8QUHr7JOrP+tuPb8iHfRHKwTmDzHOu")
-                    .header("ApiVersion", "5.1").header("BrandId", "2").header("Culture", "en").agent(userAgent)
-                    .timeout(10, TimeUnit.SECONDS);
-            if (token != null) {
-                request = request.header("SecurityToken", token);
+    private synchronized ContentResponse sendRequest(String url, HttpMethod method, @Nullable ContentProvider content,
+            @Nullable String contentType)
+            throws InterruptedException, MyQCommunicationException, MyQAuthenticationException {
+        AccessTokenResponse tokenResponse = null;
+        // if we don't need to force a login, attempt to use the token we have
+        if (!needsLogin) {
+            try {
+                tokenResponse = getOAuthService().getAccessTokenResponse();
+            } catch (OAuthException | IOException | OAuthResponseException e) {
+                // ignore error, will try to login below
+                logger.debug("Error accessing token, will attempt to login again", e);
             }
-            if (content != null & contentType != null) {
-                request = request.content(content, contentType);
+        }
+
+        // if no token, or we need to login, do so now
+        if (tokenResponse == null) {
+            tokenResponse = login();
+            needsLogin = false;
+        }
+
+        Request request = httpClient.newRequest(url).method(method).agent(userAgent).timeout(10, TimeUnit.SECONDS)
+                .header("Authorization", authTokenHeader(tokenResponse));
+        if (content != null & contentType != null) {
+            request = request.content(content, contentType);
+        }
+
+        // use asyc jetty as the API service will response with a 401 error when credentials are wrong,
+        // but not a WWW-Authenticate header which causes Jetty to throw a generic execution exception which
+        // prevents us from knowing the response code
+        logger.trace("Sending {} to {}", request.getMethod(), request.getURI());
+        final CompletableFuture<ContentResponse> futureResult = new CompletableFuture<>();
+        request.send(new BufferingResponseListener() {
+            @NonNullByDefault({})
+            @Override
+            public void onComplete(Result result) {
+                Response response = result.getResponse();
+                futureResult.complete(new HttpContentResponse(response, getContent(), getMediaType(), getEncoding()));
             }
-            // use asyc jetty as the API service will response with a 401 error when credentials are wrong,
-            // but not a WWW-Authenticate header which causes Jetty to throw a generic execution exception which
-            // prevents us from knowing the response code
-            logger.trace("Sending {} to {}", request.getMethod(), request.getURI());
-            final CompletableFuture<HttpResult> futureResult = new CompletableFuture<>();
-            request.send(new BufferingResponseListener() {
-                @NonNullByDefault({})
-                @Override
-                public void onComplete(Result result) {
-                    futureResult.complete(new HttpResult(result.getResponse().getStatus(), getContentAsString()));
-                }
-            });
-            HttpResult result = futureResult.get();
-            logger.trace("Account Response - status: {} content: {}", result.responseCode, result.content);
+        });
+
+        try {
+            ContentResponse result = futureResult.get();
+            logger.trace("Account Response - status: {} content: {}", result.getStatus(), result.getContentAsString());
             return result;
         } catch (ExecutionException e) {
-            return new HttpResult(0, e.getMessage());
+            throw new MyQCommunicationException(e.getMessage());
         }
     }
 
-    @Nullable
-    private <T> T parseResultAndUpdateStatus(HttpResult result, Gson parser, Class<T> classOfT) {
-        if (HttpStatus.isSuccess(result.responseCode)) {
+    private <T> T parseResultAndUpdateStatus(ContentResponse response, Gson parser, Class<T> classOfT)
+            throws MyQCommunicationException {
+        if (HttpStatus.isSuccess(response.getStatus())) {
             try {
-                T responseObject = parser.fromJson(result.content, classOfT);
+                T responseObject = parser.fromJson(response.getContentAsString(), classOfT);
                 if (responseObject != null) {
                     if (getThing().getStatus() != ThingStatus.ONLINE) {
                         updateStatus(ThingStatus.ONLINE);
                     }
                     return responseObject;
+                } else {
+                    throw new MyQCommunicationException("Bad response from server");
                 }
             } catch (JsonSyntaxException e) {
-                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
-                        "Invalid JSON Response " + result.content);
+                throw new MyQCommunicationException("Invalid JSON Response " + response.getContentAsString());
             }
-        } else if (result.responseCode == HttpStatus.UNAUTHORIZED_401) {
-            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
-                    "Unauthorized - Check Credentials");
+        } else if (response.getStatus() == HttpStatus.UNAUTHORIZED_401) {
+            // our tokens no longer work, will need to login again
+            needsLogin = true;
+            throw new MyQCommunicationException("Token was rejected for request");
         } else {
-            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
-                    "Invalid Response Code " + result.responseCode + " : " + result.content);
+            throw new MyQCommunicationException(
+                    "Invalid Response Code " + response.getStatus() + " : " + response.getContentAsString());
         }
-        return null;
     }
 
-    private class HttpResult {
-        public final int responseCode;
-        public @Nullable String content;
+    /**
+     * Returns the MyQ login page which contains form elements and cookies needed to login
+     *
+     * @param codeVerifier
+     * @return
+     * @throws InterruptedException
+     * @throws ExecutionException
+     * @throws TimeoutException
+     */
+    private ContentResponse getLoginPage(String codeVerifier)
+            throws InterruptedException, ExecutionException, TimeoutException {
+        try {
+            Request request = httpClient.newRequest(LOGIN_AUTHORIZE_URL) //
+                    .param("client_id", CLIENT_ID) //
+                    .param("code_challenge", generateCodeChallange(codeVerifier)) //
+                    .param("code_challenge_method", "S256") //
+                    .param("redirect_uri", REDIRECT_URI) //
+                    .param("response_type", "code") //
+                    .param("scope", SCOPE) //
+                    .agent(userAgent).followRedirects(true);
+            logger.debug("Sending {} to {}", request.getMethod(), request.getURI());
+            ContentResponse response = request.send();
+            logger.debug("Login Code {} Response {}", response.getStatus(), response.getContentAsString());
+            return response;
+        } catch (UnsupportedEncodingException | NoSuchAlgorithmException e) {
+            throw new ExecutionException(e.getCause());
+        }
+    }
 
-        public HttpResult(int responseCode, @Nullable String content) {
-            this.responseCode = responseCode;
-            this.content = content;
+    /**
+     * Sends configured credentials and elements from the login page in order to obtain a redirect location header value
+     *
+     * @param url
+     * @param requestToken
+     * @param returnURL
+     * @return The location header value
+     * @throws InterruptedException
+     * @throws ExecutionException
+     * @throws TimeoutException
+     */
+    @Nullable
+    private String postLogin(String url, String requestToken, String returnURL)
+            throws InterruptedException, ExecutionException, TimeoutException {
+        /*
+         * on a successful post to this page we will get several redirects, and a final 301 to:
+         * com.myqops://ios?code=0123456789&scope=MyQ_Residential%20offline_access&iss=https%3A%2F%2Fpartner-identity.
+         * myq-cloud.com
+         *
+         * We can then take the parameters out of this location and continue the process
+         */
+        Fields fields = new Fields();
+        fields.add("Email", username);
+        fields.add("Password", password);
+        fields.add("__RequestVerificationToken", requestToken);
+        fields.add("ReturnUrl", returnURL);
+
+        Request request = httpClient.newRequest(url).method(HttpMethod.POST) //
+                .content(new FormContentProvider(fields)) //
+                .agent(userAgent) //
+                .followRedirects(false);
+        setCookies(request);
+
+        logger.debug("Posting Login to {}", url);
+        ContentResponse response = request.send();
+
+        String location = null;
+
+        // follow redirects until we match our REDIRECT_URI or hit a redirect safety limit
+        for (int i = 0; i < LOGIN_MAX_REDIRECTS && HttpStatus.isRedirection(response.getStatus()); i++) {
+
+            String loc = response.getHeaders().get("location");
+            if (logger.isTraceEnabled()) {
+                logger.trace("Redirect Login: Code {} Location Header: {} Response {}", response.getStatus(), loc,
+                        response.getContentAsString());
+            }
+            if (loc == null) {
+                logger.debug("No location value");
+                break;
+            }
+            if (loc.indexOf(REDIRECT_URI) == 0) {
+                location = loc;
+                break;
+            }
+            request = httpClient.newRequest(LOGIN_BASE_URL + loc).agent(userAgent).followRedirects(false);
+            setCookies(request);
+            response = request.send();
+        }
+        return location;
+    }
+
+    /**
+     * Final step of the login process to get a oAuth access response token
+     *
+     * @param redirectLocation
+     * @param codeVerifier
+     * @return
+     * @throws InterruptedException
+     * @throws ExecutionException
+     * @throws TimeoutException
+     */
+    private ContentResponse getLoginToken(String redirectLocation, String codeVerifier)
+            throws InterruptedException, ExecutionException, TimeoutException {
+        try {
+            Map<String, String> params = parseLocationQuery(redirectLocation);
+
+            Fields fields = new Fields();
+            fields.add("client_id", CLIENT_ID);
+            fields.add("client_secret", Base64.getEncoder().encodeToString(CLIENT_SECRET.getBytes()));
+            fields.add("code", params.get("code"));
+            fields.add("code_verifier", codeVerifier);
+            fields.add("grant_type", "authorization_code");
+            fields.add("redirect_uri", REDIRECT_URI);
+            fields.add("scope", params.get("scope"));
+
+            Request request = httpClient.newRequest(LOGIN_TOKEN_URL) //
+                    .content(new FormContentProvider(fields)) //
+                    .method(HttpMethod.POST) //
+                    .agent(userAgent).followRedirects(true);
+            setCookies(request);
+
+            ContentResponse response = request.send();
+            if (logger.isTraceEnabled()) {
+                logger.trace("Login Code {} Response {}", response.getStatus(), response.getContentAsString());
+            }
+            return response;
+        } catch (URISyntaxException e) {
+            throw new ExecutionException(e.getCause());
+        }
+    }
+
+    private OAuthClientService getOAuthService() {
+        OAuthClientService oAuthService = this.oAuthService;
+        if (oAuthService == null || oAuthService.isClosed()) {
+            oAuthService = oAuthFactory.createOAuthClientService(getThing().toString(), LOGIN_TOKEN_URL,
+                    LOGIN_AUTHORIZE_URL, CLIENT_ID, CLIENT_SECRET, SCOPE, false);
+            oAuthService.addAccessTokenRefreshListener(this);
+            this.oAuthService = oAuthService;
         }
+        return oAuthService;
     }
 
     private static String randomString(int length) {
@@ -341,4 +588,58 @@ public class MyQAccountHandler extends BaseBridgeHandler {
         }
         return sb.toString();
     }
+
+    private String generateCodeVerifier() throws UnsupportedEncodingException {
+        SecureRandom secureRandom = new SecureRandom();
+        byte[] codeVerifier = new byte[32];
+        secureRandom.nextBytes(codeVerifier);
+        return Base64.getUrlEncoder().withoutPadding().encodeToString(codeVerifier);
+    }
+
+    private String generateCodeChallange(String codeVerifier)
+            throws UnsupportedEncodingException, NoSuchAlgorithmException {
+        byte[] bytes = codeVerifier.getBytes("US-ASCII");
+        MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");
+        messageDigest.update(bytes, 0, bytes.length);
+        byte[] digest = messageDigest.digest();
+        return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
+    }
+
+    private Map<String, String> parseLocationQuery(String location) throws URISyntaxException {
+        URI uri = new URI(location);
+        return Arrays.stream(uri.getQuery().split("&")).map(str -> str.split("="))
+                .collect(Collectors.toMap(str -> str[0], str -> str[1]));
+    }
+
+    private void setCookies(Request request) {
+        for (HttpCookie c : httpClient.getCookieStore().getCookies()) {
+            request.cookie(c);
+        }
+    }
+
+    private String authTokenHeader(AccessTokenResponse tokenResponse) {
+        return tokenResponse.getTokenType() + " " + tokenResponse.getAccessToken();
+    }
+
+    /**
+     * Exception for authenticated related errors
+     */
+    class MyQAuthenticationException extends Exception {
+        private static final long serialVersionUID = 1L;
+
+        public MyQAuthenticationException(String message) {
+            super(message);
+        }
+    }
+
+    /**
+     * Generic exception for non authentication related errors when communicating with the MyQ service.
+     */
+    class MyQCommunicationException extends IOException {
+        private static final long serialVersionUID = 1L;
+
+        public MyQCommunicationException(@Nullable String message) {
+            super(message);
+        }
+    }
 }