]> git.basschouten.com Git - openhab-addons.git/commitdiff
[netatmo] Consolidate OAuth2 by using core implementation and storage (#14780)
authorJacob Laursen <jacob-github@vindvejr.dk>
Fri, 21 Apr 2023 18:52:51 +0000 (20:52 +0200)
committerGitHub <noreply@github.com>
Fri, 21 Apr 2023 18:52:51 +0000 (20:52 +0200)
* Consolidate OAuth2 by using core implementation and storage

Fixes #14755

---------

Signed-off-by: Jacob Laursen <jacob-github@vindvejr.dk>
bundles/org.openhab.binding.netatmo/README.md
bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/NetatmoHandlerFactory.java
bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/api/AuthenticationApi.java
bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/api/dto/AccessTokenResponse.java [deleted file]
bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/api/dto/NetatmoAccessTokenResponse.java [new file with mode: 0644]
bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/config/ApiHandlerConfiguration.java
bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/deserialization/AccessTokenResponseDeserializer.java [new file with mode: 0644]
bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/handler/ApiBridgeHandler.java

index e57373ef2edd9409b60ba313210debf4feff1fd2..93fe27c99f235cf610007bcc0ee7710342e56109 100644 (file)
@@ -65,7 +65,7 @@ The Account bridge has the following configuration elements:
 1. The bridge thing will go _OFFLINE_ / _CONFIGURATION_ERROR_ - this is fine. You have to authorize this bridge with Netatmo Connect.
 1. Go to the authorization page of your server. `http://<your openHAB address>:8080/netatmo/connect/<_CLIENT_ID_>`. Your newly added bridge should be listed there (no need for you to expose your openHAB server outside your local network for this).
 1. Press the _"Authorize Thing"_ button. This will take you either to the login page of Netatmo Connect or directly to the authorization screen. Login and/or authorize the application. You will be returned and the entry should go green.
-1. The bridge configuration will be updated with a refresh token and go _ONLINE_. The refresh token is used to re-authorize the bridge with Netatmo Connect Web API whenever required. So you can consult this token by opening the Thing page in MainUI, this is the value of the advanced parameter named “Refresh Token”.
+1. The bridge will go _ONLINE_.
 
 Now that you have got your bridge _ONLINE_ you can now start a scan with the binding to auto discover your things.
 
index 3d85c3e1f1984052a918c44cc39bb297043197f7..ab9db84f52c8e2bbfdeb29c3ad61d248cf09d1c2 100644 (file)
@@ -42,6 +42,7 @@ import org.openhab.binding.netatmo.internal.handler.capability.RoomCapability;
 import org.openhab.binding.netatmo.internal.handler.capability.WeatherCapability;
 import org.openhab.binding.netatmo.internal.handler.channelhelper.ChannelHelper;
 import org.openhab.binding.netatmo.internal.providers.NetatmoDescriptionProvider;
+import org.openhab.core.auth.client.oauth2.OAuthFactory;
 import org.openhab.core.config.core.ConfigParser;
 import org.openhab.core.io.net.http.HttpClientFactory;
 import org.openhab.core.thing.Bridge;
@@ -75,15 +76,18 @@ public class NetatmoHandlerFactory extends BaseThingHandlerFactory {
     private final NADeserializer deserializer;
     private final HttpClient httpClient;
     private final HttpService httpService;
+    private final OAuthFactory oAuthFactory;
 
     @Activate
-    public NetatmoHandlerFactory(@Reference NetatmoDescriptionProvider stateDescriptionProvider,
-            @Reference HttpClientFactory factory, @Reference NADeserializer deserializer,
-            @Reference HttpService httpService, Map<String, @Nullable Object> config) {
+    public NetatmoHandlerFactory(final @Reference NetatmoDescriptionProvider stateDescriptionProvider,
+            final @Reference HttpClientFactory factory, final @Reference NADeserializer deserializer,
+            final @Reference HttpService httpService, final @Reference OAuthFactory oAuthFactory,
+            Map<String, @Nullable Object> config) {
         this.stateDescriptionProvider = stateDescriptionProvider;
         this.httpClient = factory.getCommonHttpClient();
         this.deserializer = deserializer;
         this.httpService = httpService;
+        this.oAuthFactory = oAuthFactory;
         configChanged(config);
     }
 
@@ -109,7 +113,8 @@ public class NetatmoHandlerFactory extends BaseThingHandlerFactory {
 
     private BaseThingHandler buildHandler(Thing thing, ModuleType moduleType) {
         if (ModuleType.ACCOUNT.equals(moduleType)) {
-            return new ApiBridgeHandler((Bridge) thing, httpClient, deserializer, configuration, httpService);
+            return new ApiBridgeHandler((Bridge) thing, httpClient, deserializer, configuration, httpService,
+                    oAuthFactory);
         }
         CommonInterface handler = moduleType.isABridge() ? new DeviceHandler((Bridge) thing) : new ModuleHandler(thing);
 
index 0d174aecf550588ce8f1b3fe1e6c6db9fcdbb519..9e1aee1e7102a5e3e7609ff41d45f0e984e05953 100644 (file)
@@ -16,14 +16,10 @@ import static org.openhab.binding.netatmo.internal.api.data.NetatmoConstants.*;
 import static org.openhab.core.auth.oauth2client.internal.Keyword.*;
 
 import java.net.URI;
-import java.util.HashMap;
 import java.util.List;
-import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
-import java.util.concurrent.ScheduledExecutorService;
-import java.util.concurrent.ScheduledFuture;
-import java.util.concurrent.TimeUnit;
+import java.util.stream.Stream;
 
 import javax.ws.rs.core.UriBuilder;
 
@@ -31,85 +27,43 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
 import org.eclipse.jdt.annotation.Nullable;
 import org.openhab.binding.netatmo.internal.api.data.NetatmoConstants.FeatureArea;
 import org.openhab.binding.netatmo.internal.api.data.NetatmoConstants.Scope;
-import org.openhab.binding.netatmo.internal.api.dto.AccessTokenResponse;
-import org.openhab.binding.netatmo.internal.config.ApiHandlerConfiguration;
 import org.openhab.binding.netatmo.internal.handler.ApiBridgeHandler;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 /**
  * The {@link AuthenticationApi} handles oAuth2 authentication and token refreshing
  *
  * @author Gaël L'hopital - Initial contribution
+ * @author Jacob Laursen - Refactored to use standard OAuth2 implementation
  */
 @NonNullByDefault
 public class AuthenticationApi extends RestManager {
-    private static final URI TOKEN_URI = getApiBaseBuilder(PATH_OAUTH, SUB_PATH_TOKEN).build();
+    public static final URI TOKEN_URI = getApiBaseBuilder(PATH_OAUTH, SUB_PATH_TOKEN).build();
+    public static final URI AUTH_URI = getApiBaseBuilder(PATH_OAUTH, SUB_PATH_AUTHORIZE).build();
 
-    private final Logger logger = LoggerFactory.getLogger(AuthenticationApi.class);
-    private final ScheduledExecutorService scheduler;
-
-    private Optional<ScheduledFuture<?>> refreshTokenJob = Optional.empty();
     private List<Scope> grantedScope = List.of();
     private @Nullable String authorization;
 
-    public AuthenticationApi(ApiBridgeHandler bridge, ScheduledExecutorService scheduler) {
+    public AuthenticationApi(ApiBridgeHandler bridge) {
         super(bridge, FeatureArea.NONE);
-        this.scheduler = scheduler;
     }
 
-    public void authorize(ApiHandlerConfiguration credentials, String refreshToken, @Nullable String code,
-            @Nullable String redirectUri) throws NetatmoException {
-        if (!(credentials.clientId.isBlank() || credentials.clientSecret.isBlank())) {
-            Map<String, String> params = new HashMap<>(Map.of(SCOPE, FeatureArea.ALL_SCOPES));
-
-            if (!refreshToken.isBlank()) {
-                params.put(REFRESH_TOKEN, refreshToken);
-            } else if (code != null && redirectUri != null) {
-                params.putAll(Map.of(REDIRECT_URI, redirectUri, CODE, code));
-            }
-
-            if (params.size() > 1) {
-                requestToken(credentials.clientId, credentials.clientSecret, params);
-                return;
-            }
+    public void setAccessToken(@Nullable String accessToken) {
+        if (accessToken != null) {
+            authorization = "Bearer " + accessToken;
+        } else {
+            authorization = null;
         }
-        throw new IllegalArgumentException("Inconsistent configuration state, please file a bug report.");
     }
 
-    private void requestToken(String clientId, String secret, Map<String, String> entries) throws NetatmoException {
-        disconnect();
-
-        Map<String, String> payload = new HashMap<>(entries);
-        payload.putAll(Map.of(GRANT_TYPE, payload.keySet().contains(CODE) ? AUTHORIZATION_CODE : REFRESH_TOKEN,
-                CLIENT_ID, clientId, CLIENT_SECRET, secret));
-
-        AccessTokenResponse response = post(TOKEN_URI, AccessTokenResponse.class, payload);
-
-        refreshTokenJob = Optional.of(scheduler.schedule(() -> {
-            try {
-                requestToken(clientId, secret, Map.of(REFRESH_TOKEN, response.getRefreshToken()));
-            } catch (NetatmoException e) {
-                logger.warn("Unable to refresh access token : {}", e.getMessage());
-            }
-        }, Math.round(response.getExpiresIn() * 0.9), TimeUnit.SECONDS));
-
-        grantedScope = response.getScope();
-        authorization = "Bearer %s".formatted(response.getAccessToken());
-        apiBridge.storeRefreshToken(response.getRefreshToken());
+    public void setScope(String scope) {
+        grantedScope = Stream.of(scope.split(" ")).map(s -> Scope.valueOf(s.toUpperCase())).toList();
     }
 
-    public void disconnect() {
+    public void dispose() {
         authorization = null;
         grantedScope = List.of();
     }
 
-    public void dispose() {
-        disconnect();
-        refreshTokenJob.ifPresent(job -> job.cancel(true));
-        refreshTokenJob = Optional.empty();
-    }
-
     public Optional<String> getAuthorization() {
         return Optional.ofNullable(authorization);
     }
diff --git a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/api/dto/AccessTokenResponse.java b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/api/dto/AccessTokenResponse.java
deleted file mode 100644 (file)
index c94a9d5..0000000
+++ /dev/null
@@ -1,72 +0,0 @@
-/**
- * Copyright (c) 2010-2023 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.netatmo.internal.api.dto;
-
-import java.util.List;
-
-import org.openhab.binding.netatmo.internal.api.data.NetatmoConstants.Scope;
-
-/**
- * This is the Access Token Response, a simple value-object holding the result of an Access Token Request, as
- * provided by Netatmo API.
- *
- * @author Gaël L'hopital - Initial contribution
- */
-public final class AccessTokenResponse {
-
-    /**
-     * The access token issued by the authorization server. It is used
-     * by the client to gain access to a resource.
-     *
-     */
-    private String accessToken;
-
-    /**
-     * Number of seconds that this OAuthToken is valid for since the time it was created.
-     *
-     */
-    private long expiresIn;
-
-    /**
-     * Refresh token is a string representing the authorization granted to
-     * the client by the resource owner. Unlike access tokens, refresh tokens are
-     * intended for use only with authorization servers and are never sent
-     * to resource servers.
-     *
-     */
-    private String refreshToken;
-
-    private List<Scope> scope;
-
-    public String getAccessToken() {
-        return accessToken;
-    }
-
-    public long getExpiresIn() {
-        return expiresIn;
-    }
-
-    public String getRefreshToken() {
-        return refreshToken;
-    }
-
-    public List<Scope> getScope() {
-        return scope;
-    }
-
-    @Override
-    public String toString() {
-        return "AccessTokenResponse [accessToken=" + accessToken + ", expiresIn=" + expiresIn + ", refreshToken="
-                + refreshToken + ", scope=" + scope + "]";
-    }
-}
diff --git a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/api/dto/NetatmoAccessTokenResponse.java b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/api/dto/NetatmoAccessTokenResponse.java
new file mode 100644 (file)
index 0000000..b4896cb
--- /dev/null
@@ -0,0 +1,115 @@
+/**
+ * Copyright (c) 2010-2023 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.netatmo.internal.api.dto;
+
+import java.util.List;
+import java.util.StringJoiner;
+
+import org.openhab.binding.netatmo.internal.api.data.NetatmoConstants.Scope;
+import org.openhab.core.auth.client.oauth2.AccessTokenResponse;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * This is the Access Token Response, a simple value-object holding the result of an Access Token Request, as
+ * provided by Netatmo API.
+ * 
+ * This is different from {@link AccessTokenResponse} because it violates RFC 6749 by having {@link #scope}
+ * defined as an array of strings.
+ *
+ * @author Gaël L'hopital - Initial contribution
+ */
+public final class NetatmoAccessTokenResponse {
+
+    /**
+     * The access token issued by the authorization server. It is used
+     * by the client to gain access to a resource.
+     */
+    @SerializedName("access_token")
+    private String accessToken;
+
+    @SerializedName("token_type")
+    private String tokenType;
+
+    /**
+     * Number of seconds that this OAuthToken is valid for since the time it was created.
+     */
+    @SerializedName("expires_in")
+    private long expiresIn;
+
+    /**
+     * Refresh token is a string representing the authorization granted to
+     * the client by the resource owner. Unlike access tokens, refresh tokens are
+     * intended for use only with authorization servers and are never sent
+     * to resource servers.
+     *
+     */
+    @SerializedName("refresh_token")
+    private String refreshToken;
+
+    /**
+     * A list of scopes. This is not compliant with RFC 6749 which defines scope
+     * as a list of space-delimited case-sensitive strings.
+     *
+     * @see <a href="https://tools.ietf.org/html/rfc6749#section-3.3">rfc6749 section-3.3</a>
+     */
+    private List<Scope> scope;
+
+    /**
+     * State from prior access token request (if present).
+     */
+    private String state;
+
+    public String getAccessToken() {
+        return accessToken;
+    }
+
+    public long getExpiresIn() {
+        return expiresIn;
+    }
+
+    public String getRefreshToken() {
+        return refreshToken;
+    }
+
+    public List<Scope> getScope() {
+        return scope;
+    }
+
+    @Override
+    public String toString() {
+        return "AccessTokenResponse [accessToken=" + accessToken + ", tokenType=" + tokenType + ", expiresIn="
+                + expiresIn + ", refreshToken=" + refreshToken + ", scope=" + scope + ", state=" + state + "]";
+    }
+
+    /**
+     * Convert Netatmo-specific DTO to standard DTO in core resembling RFC 6749.
+     * 
+     * @return response converted into {@link AccessTokenResponse}
+     */
+    public AccessTokenResponse toStandard() {
+        var standardResponse = new AccessTokenResponse();
+
+        standardResponse.setAccessToken(accessToken);
+        standardResponse.setTokenType(tokenType);
+        standardResponse.setExpiresIn(expiresIn);
+        standardResponse.setRefreshToken(refreshToken);
+
+        StringJoiner stringJoiner = new StringJoiner(" ");
+        scope.forEach(s -> stringJoiner.add(s.name().toLowerCase()));
+        standardResponse.setScope(stringJoiner.toString());
+        standardResponse.setState(state);
+
+        return standardResponse;
+    }
+}
index 71230d521a44e4e18fa281842859c89281507e34..fd8f0018cea3e0957f1cd294f11c0a15ae2cfac6 100644 (file)
@@ -29,15 +29,4 @@ public class ApiHandlerConfiguration {
     public String webHookUrl = "";
     public String webHookPostfix = "";
     public int reconnectInterval = 300;
-
-    public ConfigurationLevel check(String refreshToken) {
-        if (clientId.isBlank()) {
-            return ConfigurationLevel.EMPTY_CLIENT_ID;
-        } else if (clientSecret.isBlank()) {
-            return ConfigurationLevel.EMPTY_CLIENT_SECRET;
-        } else if (refreshToken.isBlank()) {
-            return ConfigurationLevel.REFRESH_TOKEN_NEEDED;
-        }
-        return ConfigurationLevel.COMPLETED;
-    }
 }
diff --git a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/deserialization/AccessTokenResponseDeserializer.java b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/deserialization/AccessTokenResponseDeserializer.java
new file mode 100644 (file)
index 0000000..610d06d
--- /dev/null
@@ -0,0 +1,48 @@
+/**
+ * Copyright (c) 2010-2023 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.netatmo.internal.deserialization;
+
+import java.lang.reflect.Type;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.netatmo.internal.api.dto.NetatmoAccessTokenResponse;
+import org.openhab.core.auth.client.oauth2.AccessTokenResponse;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonDeserializationContext;
+import com.google.gson.JsonDeserializer;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonParseException;
+
+/**
+ * Specialized deserializer for {@link NetatmoAccessTokenResponse}
+ *
+ * @author Jacob Laursen - Initial contribution
+ */
+@NonNullByDefault
+public class AccessTokenResponseDeserializer implements JsonDeserializer<AccessTokenResponse> {
+
+    private final Gson gson = new GsonBuilder().create();
+
+    @Override
+    public @Nullable AccessTokenResponse deserialize(JsonElement element, Type arg1, JsonDeserializationContext arg2)
+            throws JsonParseException {
+        NetatmoAccessTokenResponse response = gson.fromJson(element, NetatmoAccessTokenResponse.class);
+        if (response == null) {
+            return null;
+        }
+        return response.toStandard();
+    }
+}
index 6e8da662910ca0745a43d694e3b8309bbcf19970..9e7ad42a39681cd69891f4d457cbaba757669dbc 100644 (file)
@@ -21,9 +21,6 @@ import java.io.InputStream;
 import java.lang.reflect.Constructor;
 import java.net.URI;
 import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.Paths;
 import java.time.LocalDateTime;
 import java.util.ArrayDeque;
 import java.util.Collection;
@@ -70,11 +67,16 @@ import org.openhab.binding.netatmo.internal.api.dto.NAModule;
 import org.openhab.binding.netatmo.internal.config.ApiHandlerConfiguration;
 import org.openhab.binding.netatmo.internal.config.BindingConfiguration;
 import org.openhab.binding.netatmo.internal.config.ConfigurationLevel;
+import org.openhab.binding.netatmo.internal.deserialization.AccessTokenResponseDeserializer;
 import org.openhab.binding.netatmo.internal.deserialization.NADeserializer;
 import org.openhab.binding.netatmo.internal.discovery.NetatmoDiscoveryService;
 import org.openhab.binding.netatmo.internal.servlet.GrantServlet;
 import org.openhab.binding.netatmo.internal.servlet.WebhookServlet;
-import org.openhab.core.OpenHAB;
+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.library.types.DecimalType;
 import org.openhab.core.thing.Bridge;
 import org.openhab.core.thing.ChannelUID;
@@ -89,130 +91,147 @@ import org.osgi.service.http.HttpService;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import com.google.gson.GsonBuilder;
+
 /**
  * {@link ApiBridgeHandler} is the handler for a Netatmo API and connects it to the framework.
  *
  * @author Gaël L'hopital - Initial contribution
- *
+ * @author Jacob Laursen - Refactored to use standard OAuth2 implementation
  */
 @NonNullByDefault
 public class ApiBridgeHandler extends BaseBridgeHandler {
     private static final int TIMEOUT_S = 20;
-    private static final String REFRESH_TOKEN = "refreshToken";
 
     private final Logger logger = LoggerFactory.getLogger(ApiBridgeHandler.class);
-    private final AuthenticationApi connectApi = new AuthenticationApi(this, scheduler);
+    private final AuthenticationApi connectApi = new AuthenticationApi(this);
     private final Map<Class<? extends RestManager>, RestManager> managers = new HashMap<>();
     private final Deque<LocalDateTime> requestsTimestamps = new ArrayDeque<>(200);
     private final BindingConfiguration bindingConf;
     private final HttpClient httpClient;
+    private final OAuthFactory oAuthFactory;
     private final NADeserializer deserializer;
     private final HttpService httpService;
     private final ChannelUID requestCountChannelUID;
-    private final Path tokenFile;
 
+    private @Nullable OAuthClientService oAuthClientService;
     private Optional<ScheduledFuture<?>> connectJob = Optional.empty();
     private Optional<WebhookServlet> webHookServlet = Optional.empty();
     private Optional<GrantServlet> grantServlet = Optional.empty();
 
     public ApiBridgeHandler(Bridge bridge, HttpClient httpClient, NADeserializer deserializer,
-            BindingConfiguration configuration, HttpService httpService) {
+            BindingConfiguration configuration, HttpService httpService, OAuthFactory oAuthFactory) {
         super(bridge);
         this.bindingConf = configuration;
         this.httpClient = httpClient;
         this.deserializer = deserializer;
         this.httpService = httpService;
-        this.requestCountChannelUID = new ChannelUID(thing.getUID(), GROUP_MONITORING, CHANNEL_REQUEST_COUNT);
+        this.oAuthFactory = oAuthFactory;
 
-        Path homeFolder = Paths.get(OpenHAB.getUserDataFolder(), BINDING_ID);
-        if (Files.notExists(homeFolder)) {
-            try {
-                Files.createDirectory(homeFolder);
-            } catch (IOException e) {
-                logger.warn("Unable to create {} folder : {}", homeFolder.toString(), e.getMessage());
-            }
-        }
-        tokenFile = homeFolder.resolve(REFRESH_TOKEN + "_" + thing.getUID().toString().replace(":", "_"));
+        requestCountChannelUID = new ChannelUID(thing.getUID(), GROUP_MONITORING, CHANNEL_REQUEST_COUNT);
     }
 
     @Override
     public void initialize() {
         logger.debug("Initializing Netatmo API bridge handler.");
+
+        ApiHandlerConfiguration configuration = getConfiguration();
+
+        if (configuration.clientId.isBlank()) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+                    ConfigurationLevel.EMPTY_CLIENT_ID.message);
+            return;
+        }
+
+        if (configuration.clientSecret.isBlank()) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+                    ConfigurationLevel.EMPTY_CLIENT_SECRET.message);
+            return;
+        }
+
+        oAuthClientService = oAuthFactory
+                .createOAuthClientService(this.getThing().getUID().getAsString(),
+                        AuthenticationApi.TOKEN_URI.toString(), AuthenticationApi.AUTH_URI.toString(),
+                        configuration.clientId, configuration.clientSecret, FeatureArea.ALL_SCOPES, false)
+                .withGsonBuilder(new GsonBuilder().registerTypeAdapter(AccessTokenResponse.class,
+                        new AccessTokenResponseDeserializer()));
+
         updateStatus(ThingStatus.UNKNOWN);
+
         scheduler.execute(() -> openConnection(null, null));
     }
 
     public void openConnection(@Nullable String code, @Nullable String redirectUri) {
-        ApiHandlerConfiguration configuration = getConfiguration();
+        if (!authenticate(code, redirectUri)) {
+            return;
+        }
 
-        String refreshToken = readRefreshToken();
-
-        ConfigurationLevel level = configuration.check(refreshToken);
-        switch (level) {
-            case EMPTY_CLIENT_ID:
-            case EMPTY_CLIENT_SECRET:
-                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, level.message);
-                break;
-            case REFRESH_TOKEN_NEEDED:
-                if (code == null || redirectUri == null) {
-                    GrantServlet servlet = new GrantServlet(this, httpService);
-                    servlet.startListening();
-                    grantServlet = Optional.of(servlet);
-                    updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, level.message);
-                    break;
-                } // else we can proceed to get the token refresh
-            case COMPLETED:
-                try {
-                    logger.debug("Connecting to Netatmo API.");
-
-                    connectApi.authorize(configuration, refreshToken, code, redirectUri);
-
-                    if (!configuration.webHookUrl.isBlank()) {
-                        SecurityApi securityApi = getRestManager(SecurityApi.class);
-                        if (securityApi != null) {
-                            WebhookServlet servlet = new WebhookServlet(this, httpService, deserializer, securityApi,
-                                    configuration.webHookUrl, configuration.webHookPostfix);
-                            servlet.startListening();
-                            this.webHookServlet = Optional.of(servlet);
-                        }
-                    }
+        logger.debug("Connecting to Netatmo API.");
 
-                    updateStatus(ThingStatus.ONLINE);
+        ApiHandlerConfiguration configuration = getConfiguration();
+        if (!configuration.webHookUrl.isBlank()) {
+            SecurityApi securityApi = getRestManager(SecurityApi.class);
+            if (securityApi != null) {
+                webHookServlet.ifPresent(servlet -> servlet.dispose());
+                WebhookServlet servlet = new WebhookServlet(this, httpService, deserializer, securityApi,
+                        configuration.webHookUrl, configuration.webHookPostfix);
+                servlet.startListening();
+                this.webHookServlet = Optional.of(servlet);
+            }
+        }
 
-                    getThing().getThings().stream().filter(Thing::isEnabled).map(Thing::getHandler)
-                            .filter(Objects::nonNull).map(CommonInterface.class::cast)
-                            .forEach(CommonInterface::expireData);
+        updateStatus(ThingStatus.ONLINE);
 
-                } catch (NetatmoException e) {
-                    updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
-                    prepareReconnection(code, redirectUri);
-                }
-                break;
-        }
+        getThing().getThings().stream().filter(Thing::isEnabled).map(Thing::getHandler).filter(Objects::nonNull)
+                .map(CommonInterface.class::cast).forEach(CommonInterface::expireData);
     }
 
-    private String readRefreshToken() {
-        if (Files.exists(tokenFile)) {
-            try {
-                return Files.readString(tokenFile);
-            } catch (IOException e) {
-                logger.warn("Unable to read token file {} : {}", tokenFile.toString(), e.getMessage());
-            }
+    private boolean authenticate(@Nullable String code, @Nullable String redirectUri) {
+        OAuthClientService oAuthClientService = this.oAuthClientService;
+        if (oAuthClientService == null) {
+            logger.debug("ApiBridgeHandler is not ready, OAuthClientService not initialized");
+            return false;
         }
-        return "";
-    }
 
-    public void storeRefreshToken(String refreshToken) {
-        if (refreshToken.isBlank()) {
-            logger.trace("Blank refresh token received - ignored");
-        } else {
-            logger.trace("Updating refresh token in {} : {}", tokenFile.toString(), refreshToken);
-            try {
-                Files.write(tokenFile, refreshToken.getBytes());
-            } catch (IOException e) {
-                logger.warn("Error saving refresh token to {} : {}", tokenFile.toString(), e.getMessage());
+        AccessTokenResponse accessTokenResponse;
+        try {
+            if (code != null) {
+                accessTokenResponse = oAuthClientService.getAccessTokenResponseByAuthorizationCode(code, redirectUri);
+
+                // Dispose grant servlet upon completion of authorization flow.
+                grantServlet.ifPresent(servlet -> servlet.dispose());
+                grantServlet = Optional.empty();
+            } else {
+                accessTokenResponse = oAuthClientService.getAccessTokenResponse();
             }
+        } catch (OAuthException | OAuthResponseException e) {
+            logger.debug("Failed to load access token: {}", e.getMessage());
+            startAuthorizationFlow();
+            return false;
+        } catch (IOException e) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
+            prepareReconnection(code, redirectUri);
+            return false;
+        }
+
+        if (accessTokenResponse == null) {
+            logger.debug("Authorization failed, restarting authorization flow");
+            startAuthorizationFlow();
+            return false;
         }
+
+        connectApi.setAccessToken(accessTokenResponse.getAccessToken());
+        connectApi.setScope(accessTokenResponse.getScope());
+
+        return true;
+    }
+
+    private void startAuthorizationFlow() {
+        GrantServlet servlet = new GrantServlet(this, httpService);
+        servlet.startListening();
+        grantServlet = Optional.of(servlet);
+        updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+                ConfigurationLevel.REFRESH_TOKEN_NEEDED.message);
     }
 
     public ApiHandlerConfiguration getConfiguration() {
@@ -220,7 +239,7 @@ public class ApiBridgeHandler extends BaseBridgeHandler {
     }
 
     private void prepareReconnection(@Nullable String code, @Nullable String redirectUri) {
-        connectApi.disconnect();
+        connectApi.dispose();
         freeConnectJob();
         connectJob = Optional.of(scheduler.schedule(() -> openConnection(code, redirectUri),
                 getConfiguration().reconnectInterval, TimeUnit.SECONDS));
@@ -243,9 +262,18 @@ public class ApiBridgeHandler extends BaseBridgeHandler {
 
         connectApi.dispose();
         freeConnectJob();
+
+        oAuthFactory.ungetOAuthService(this.getThing().getUID().getAsString());
+
         super.dispose();
     }
 
+    @Override
+    public void handleRemoval() {
+        oAuthFactory.deleteServiceAndAccessToken(this.getThing().getUID().getAsString());
+        super.handleRemoval();
+    }
+
     @Override
     public void handleCommand(ChannelUID channelUID, Command command) {
         logger.debug("Netatmo Bridge is read-only and does not handle commands");
@@ -277,6 +305,10 @@ public class ApiBridgeHandler extends BaseBridgeHandler {
 
             Request request = httpClient.newRequest(uri).method(method).timeout(TIMEOUT_S, TimeUnit.SECONDS);
 
+            if (!authenticate(null, null)) {
+                prepareReconnection(null, null);
+                throw new NetatmoException("Not authenticated");
+            }
             connectApi.getAuthorization().ifPresent(auth -> request.header(HttpHeader.AUTHORIZATION, auth));
 
             if (payload != null && contentType != null