Skip to content

Commit

Permalink
[netatmo] Consolidate OAuth2 by using core implementation and storage (
Browse files Browse the repository at this point in the history
…#14780)

* Consolidate OAuth2 by using core implementation and storage

Fixes #14755

---------

Signed-off-by: Jacob Laursen <jacob-github@vindvejr.dk>
  • Loading branch information
jlaur authored Apr 21, 2023
1 parent 3ae20b7 commit cf3c3f1
Show file tree
Hide file tree
Showing 7 changed files with 231 additions and 160 deletions.
2 changes: 1 addition & 1 deletion bundles/org.openhab.binding.netatmo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
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;
Expand Down Expand Up @@ -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);
}

Expand All @@ -109,7 +113,8 @@ public boolean supportsThingType(ThingTypeUID thingTypeUID) {

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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,100 +16,54 @@
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;

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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,28 +13,38 @@
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 AccessTokenResponse {
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;

/**
Expand All @@ -44,10 +54,22 @@ public final class AccessTokenResponse {
* 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;
}
Expand All @@ -66,7 +88,28 @@ public List<Scope> getScope() {

@Override
public String toString() {
return "AccessTokenResponse [accessToken=" + accessToken + ", expiresIn=" + expiresIn + ", refreshToken="
+ refreshToken + ", scope=" + scope + "]";
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Loading

0 comments on commit cf3c3f1

Please sign in to comment.