From 2eec58a95fce9f1402666b81791074e81d1b09b7 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Sun, 16 Mar 2025 23:40:07 +0000 Subject: [PATCH] OAuth RFC-8628 Device Code Grant Flow (#4632) Signed-off-by: Andrew Fiddian-Green --- .../internal/OAuthClientServiceImpl.java | 42 +- .../oauth2client/internal/OAuthConnector.java | 9 +- .../internal/OAuthConnectorRFC8628.java | 474 ++++++++++++++++++ .../internal/OAuthStoreHandler.java | 26 + .../internal/OAuthStoreHandlerImpl.java | 103 +++- .../internal/StorageRecordType.java | 4 +- .../internal/OAuthRFC8628ClientTest.java | 378 ++++++++++++++ .../client/oauth2/DeviceCodeResponseDTO.java | 154 ++++++ .../client/oauth2/OAuthClientService.java | 32 +- 9 files changed, 1209 insertions(+), 13 deletions(-) create mode 100644 bundles/org.openhab.core.auth.oauth2client/src/main/java/org/openhab/core/auth/oauth2client/internal/OAuthConnectorRFC8628.java create mode 100644 bundles/org.openhab.core.auth.oauth2client/src/test/java/org/openhab/core/auth/oauth2client/internal/OAuthRFC8628ClientTest.java create mode 100644 bundles/org.openhab.core/src/main/java/org/openhab/core/auth/client/oauth2/DeviceCodeResponseDTO.java diff --git a/bundles/org.openhab.core.auth.oauth2client/src/main/java/org/openhab/core/auth/oauth2client/internal/OAuthClientServiceImpl.java b/bundles/org.openhab.core.auth.oauth2client/src/main/java/org/openhab/core/auth/oauth2client/internal/OAuthClientServiceImpl.java index 7a817e02a..767dc56e4 100644 --- a/bundles/org.openhab.core.auth.oauth2client/src/main/java/org/openhab/core/auth/oauth2client/internal/OAuthClientServiceImpl.java +++ b/bundles/org.openhab.core.auth.oauth2client/src/main/java/org/openhab/core/auth/oauth2client/internal/OAuthClientServiceImpl.java @@ -31,6 +31,7 @@ import org.eclipse.jetty.util.Fields; import org.eclipse.jetty.util.UrlEncoded; import org.openhab.core.auth.client.oauth2.AccessTokenRefreshListener; import org.openhab.core.auth.client.oauth2.AccessTokenResponse; +import org.openhab.core.auth.client.oauth2.DeviceCodeResponseDTO; import org.openhab.core.auth.client.oauth2.OAuthClientService; import org.openhab.core.auth.client.oauth2.OAuthException; import org.openhab.core.auth.client.oauth2.OAuthResponseException; @@ -77,6 +78,7 @@ public class OAuthClientServiceImpl implements OAuthClientService { private PersistedParams persistedParams = new PersistedParams(); private @Nullable Fields extraAuthFields = null; + private @Nullable OAuthConnectorRFC8628 oAuthConnectorRFC8628 = null; private volatile boolean closed = false; @@ -392,8 +394,16 @@ public class OAuthClientServiceImpl implements OAuthClientService { public void close() { closed = true; storeHandler = null; - logger.debug("closing oauth client, handle: {}", handle); + closeOAuthConnectorRFC8628(); + } + + private synchronized void closeOAuthConnectorRFC8628() { + OAuthConnectorRFC8628 connector = this.oAuthConnectorRFC8628; + if (connector != null) { + connector.close(); + } + this.oAuthConnectorRFC8628 = null; } @Override @@ -440,4 +450,34 @@ public class OAuthClientServiceImpl implements OAuthClientService { return clientService; } + + @Override + public @Nullable DeviceCodeResponseDTO getDeviceCodeResponse() throws OAuthException { + closeOAuthConnectorRFC8628(); + + if (persistedParams.tokenUrl == null) { + throw new OAuthException("Missing access token request url"); + } + if (persistedParams.authorizationUrl == null) { + throw new OAuthException("Missing device code request url"); + } + if (persistedParams.clientId == null) { + throw new OAuthException("Missing client id"); + } + if (persistedParams.scope == null) { + throw new OAuthException("Missing scope"); + } + + OAuthConnectorRFC8628 connector = new OAuthConnectorRFC8628(this, handle, storeHandler, httpClientFactory, + gsonBuilder, persistedParams.tokenUrl, persistedParams.authorizationUrl, persistedParams.clientId, + persistedParams.scope); + + oAuthConnectorRFC8628 = connector; + return connector.getDeviceCodeResponse(); + } + + @Override + public void notifyAccessTokenResponse(AccessTokenResponse atr) { + accessTokenRefreshListeners.forEach(l -> l.onAccessTokenResponse(atr)); + } } diff --git a/bundles/org.openhab.core.auth.oauth2client/src/main/java/org/openhab/core/auth/oauth2client/internal/OAuthConnector.java b/bundles/org.openhab.core.auth.oauth2client/src/main/java/org/openhab/core/auth/oauth2client/internal/OAuthConnector.java index d95098cf3..5fe999f2a 100644 --- a/bundles/org.openhab.core.auth.oauth2client/src/main/java/org/openhab/core/auth/oauth2client/internal/OAuthConnector.java +++ b/bundles/org.openhab.core.auth.oauth2client/src/main/java/org/openhab/core/auth/oauth2client/internal/OAuthConnector.java @@ -62,12 +62,13 @@ public class OAuthConnector { private static final String HTTP_CLIENT_CONSUMER_NAME = "OAuthConnector"; - private final HttpClientFactory httpClientFactory; + protected final HttpClientFactory httpClientFactory; private final @Nullable Fields extraFields; private final Logger logger = LoggerFactory.getLogger(OAuthConnector.class); - private final Gson gson; + + protected final Gson gson; public OAuthConnector(HttpClientFactory httpClientFactory) { this(httpClientFactory, null, new GsonBuilder()); @@ -371,7 +372,7 @@ public class OAuthConnector { * @throws OAuthException If any exception is thrown while starting the http client. * @see org.openhab.core.io.net.http.ExtensibleTrustManager */ - private HttpClient createHttpClient(String tokenUrl) throws OAuthException { + protected HttpClient createHttpClient(String tokenUrl) throws OAuthException { HttpClient httpClient = httpClientFactory.createHttpClient(HTTP_CLIENT_CONSUMER_NAME); if (!httpClient.isStarted()) { try { @@ -383,7 +384,7 @@ public class OAuthConnector { return httpClient; } - private void shutdownQuietly(@Nullable HttpClient httpClient) { + protected void shutdownQuietly(@Nullable HttpClient httpClient) { try { if (httpClient != null) { httpClient.stop(); diff --git a/bundles/org.openhab.core.auth.oauth2client/src/main/java/org/openhab/core/auth/oauth2client/internal/OAuthConnectorRFC8628.java b/bundles/org.openhab.core.auth.oauth2client/src/main/java/org/openhab/core/auth/oauth2client/internal/OAuthConnectorRFC8628.java new file mode 100644 index 000000000..0fd6a7a0d --- /dev/null +++ b/bundles/org.openhab.core.auth.oauth2client/src/main/java/org/openhab/core/auth/oauth2client/internal/OAuthConnectorRFC8628.java @@ -0,0 +1,474 @@ +/* + * Copyright (c) 2010-2025 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.core.auth.oauth2client.internal; + +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.time.Instant; +import java.util.Objects; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.api.ContentResponse; +import org.eclipse.jetty.client.api.Request; +import org.eclipse.jetty.http.HttpMethod; +import org.eclipse.jetty.http.HttpStatus; +import org.openhab.core.auth.client.oauth2.AccessTokenResponse; +import org.openhab.core.auth.client.oauth2.DeviceCodeResponseDTO; +import org.openhab.core.auth.client.oauth2.OAuthClientService; +import org.openhab.core.auth.client.oauth2.OAuthException; +import org.openhab.core.auth.client.oauth2.OAuthResponseException; +import org.openhab.core.common.ThreadPoolManager; +import org.openhab.core.io.net.http.HttpClientFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.GsonBuilder; + +/** + * The {@link OAuthConnectorRFC8628} extends {@link OAuthConnector} to implement + * the oAuth RFC-8628 Device Code Grant Flow authentication process. + * + * @see RFC-8628 + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class OAuthConnectorRFC8628 extends OAuthConnector implements AutoCloseable { + + private static final String OAUTH_RFC8628 = "oauth-rfc8628"; + + // URL parameter names + private static final String PARAM_SCOPE = "scope"; + private static final String PARAM_CLIENT_ID = "client_id"; + private static final String PARAM_DEVICE_CODE = "device_code"; + private static final String PARAM_GRANT_TYPE = "grant_type"; + + // HTTP 400 error messages for RFC-8628 + private static final String SLOW_DOWN = "slow_down"; + private static final String ACCESS_DENIED = "access_denied"; + private static final String EXPIRED_TOKEN = "expired_token"; + + // URL parameter values + private static final String PARAM_GRANT_TYPE_VALUE = "urn:ietf:params:oauth:grant-type:device_code"; + + // logger string + private static final String LOG_NULL_ATR = "AccessTokenResponse [null]"; + + private static final int HTTP_TIMEOUT_MILLISECONDS = 5000; + private static final long DEFAULT_POLL_INTERVAL = 5; + + private final Logger logger = LoggerFactory.getLogger(OAuthConnectorRFC8628.class); + + private final OAuthClientService oAuthClientService; + private final OAuthStoreHandler oAuthStoreHandler; + private final ScheduledExecutorService scheduler; + private final String handle; + + private final String accessTokenRequestUrl; + private final String deviceCodeRequestUrl; + private final String clientIdParameter; + private final String scopeParameter; + + private @Nullable ScheduledFuture atrPollTaskSchedule; + private @Nullable DeviceCodeResponseDTO dcrCached; + private @Nullable HttpClient httpClient; + + /** + * Create an extension of the {link OAuthConnector} that implements the oAuth RFC-8628 Device Code + * Grant Flow authentication process. The parameters are as follows -- whereby (*) means that the + * parameters would usually be from the private fields of the calling {@link OAuthClientService}. + * + * @param oAuthClientService the calling {@link OAuthClientService} + * @param handle an oAuth storage handle (*) + * @param oAuthStoreHandler a {@link OAuthStoreHandler} (*) + * @param httpClientFactory a {@link HttpClientFactory} (*) + * @param gsonBuilder a {@link GsonBuilder} (may be null) (*) + * @param accessTokenRequestUrl the URL that provides {@link AccessTokenResponse} responses + * @param deviceCodeRequestUrl the URL that provides {@link DeviceCodeResponseDTO} responses + * @param clientId the RFC-8628 request client id parameter + * @param scope the RFC-8628 request scope parameter + * + * @throws OAuthException + */ + public OAuthConnectorRFC8628(OAuthClientService oAuthClientService, String handle, + OAuthStoreHandler oAuthStoreHandler, HttpClientFactory httpClientFactory, @Nullable GsonBuilder gsonBuilder, + String accessTokenRequestUrl, String deviceCodeRequestUrl, String clientId, String scope) + throws OAuthException { + super(httpClientFactory, null, gsonBuilder != null ? gsonBuilder : new GsonBuilder()); + this.oAuthClientService = oAuthClientService; + this.oAuthStoreHandler = oAuthStoreHandler; + this.scheduler = ThreadPoolManager.getScheduledPool(OAUTH_RFC8628); + this.deviceCodeRequestUrl = deviceCodeRequestUrl; + this.accessTokenRequestUrl = accessTokenRequestUrl; + this.clientIdParameter = clientId; + this.scopeParameter = scope; + this.handle = handle; + } + + /** + * Begins the RFC-8628 Device Code Grant Flow authentication process. + * Specifically it executes the following steps as described in the article in the link below: + * + * + * + * @see RFC-8628 + * + * @return either null or a {@link DeviceCodeResponseDTO} containing the verification uri's where + * users are expected authenticate themselves + * @throws OAuthException + */ + public synchronized @Nullable DeviceCodeResponseDTO getDeviceCodeResponse() throws OAuthException { + /* + * 'finally' control variable to create a poll task schedule with the given interval + */ + long createNewAtrPollTaskScheduleSeconds = 0; + try { + logger.trace("getDeviceCodeResponse() start.."); + if (oAuthClientService.isClosed()) { + /* + * The service is closed: + * => throw an exception + */ + throw new OAuthException("OAuthClientService closed"); + } + + /* + * Retrieve local AccessTokenResponse from service (if any) + */ + AccessTokenResponse atr; + try { + atr = oAuthClientService.getAccessTokenResponse(); + } catch (IOException | OAuthResponseException e) { + atr = null; + } + logger.trace("getDeviceCodeResponse() loaded from service: {}", atr != null ? atr : LOG_NULL_ATR); + + if (atr != null) { + /* + * The local AccessTokenResponse exists: + * => no further user authentication is needed + * => return null + */ + return null; + } + + DeviceCodeResponseDTO dcr; + try { + /* + * Load local DeviceCodeResponse from service storage (if any) and check it is valid + */ + dcr = checkDeviceCodeResponse(oAuthStoreHandler.loadDeviceCodeResponse(handle)); + logger.trace("getDeviceCodeResponse() loaded from storage: {}", dcr); + } catch (GeneralSecurityException e) { + throw new OAuthException(e); + } catch (OAuthException e) { + /* + * The DeviceCodeResponse is not valid + * => try to fetch a new one from the remote server and check it is valid + * => note: a second OAuthException exits from this method with that exception + */ + dcr = checkDeviceCodeResponse(fetchDeviceCodeResponse()); + logger.trace("getDeviceCodeResponse() fetched from remote: {}", dcr); + } + + /* + * If we got here without exception then the (local or remote) DeviceCodeResponse is all good: + * => try to fetch an AccessTokenResponse for it from the remote server + * => if the fetch fails-soft (authentication not yet done) it returns a null AccessTokenResponse + * => if the fetch fails-hard it throws an exception + */ + try { + atr = fetchAccessTokenResponse(dcr); + } catch (OAuthResponseException e) { + /* + * An AccessTokenResponse was not returned + * => so atr remains null; as it was prior to calling fetchAccessTokenResponse() + */ + } + logger.trace("getDeviceCodeResponse() fetched from remote: {}", atr != null ? atr : LOG_NULL_ATR); + + if (atr != null) { + /* + * The fetched AccessTokenResponse exists: + * => import the AccessTokenResponse into the service storage + * => notify service AccessTokenRefreshListeners + * => no further user authentication is needed + * => exit by returning null + */ + oAuthClientService.importAccessTokenResponse(atr); + oAuthClientService.notifyAccessTokenResponse(atr); + logger.trace("getDeviceCodeResponse() imported into service: {}", atr); + return null; + } + + /* + * If we got to this point we have a good DeviceCodeResponse but AccessTokenResponse is null: + * => cache the DeviceCodeResponse across polling cycles + * => save the DeviceCodeResponse in the service storage + * => schedule a new AccessTokenResponse poll task + * => return the DeviceCodeResponse + */ + logger.trace("getDeviceCodeResponse() service save, cache, schedule poll, return: {}", dcr); + createNewAtrPollTaskScheduleSeconds = Objects.requireNonNull(dcr.getInterval()); + oAuthStoreHandler.saveDeviceCodeResponse(handle, dcr); + dcrCached = dcr; + return (DeviceCodeResponseDTO) dcr.clone(); + } catch (OAuthException e) { + logger.debug("getDeviceCodeResponse() error: {}", e.getMessage()); + createNewAtrPollTaskScheduleSeconds = 0; + throw e; + } finally { + if (createNewAtrPollTaskScheduleSeconds > 0) { + cancelAtrPollTaskSchedule(); + createAtrPollTaskSchedule(createNewAtrPollTaskScheduleSeconds); + } else { + close(); + } + } + } + + /** + * Check the validity of the given {@link DeviceCodeResponseDTO} + * + * @param dcr the incoming {@link DeviceCodeResponseDTO} (may be null) + * @return a fully valid {@link DeviceCodeResponseDTO} (guaranteed non null) + * + * @throws OAuthException + */ + private DeviceCodeResponseDTO checkDeviceCodeResponse(@Nullable DeviceCodeResponseDTO dcr) throws OAuthException { + if (dcr == null) { + throw new OAuthException("DeviceCodeResponse is null"); + } + if (dcr.isExpired(Instant.now(), 0)) { + throw new OAuthException("DeviceCodeResponse expired"); + } + Long interval = dcr.getInterval(); + if (interval == null || interval <= 0) { + throw new OAuthException("DeviceCodeResponse interval invalid"); + } + return dcr; + } + + /** + * Start the first steps of the Device Code Grant Flow authentication process as follows: + * + * + * + * @return a new {@link DeviceCodeResponseDTO} object (non null) + * + * @throws OAuthException + */ + private synchronized DeviceCodeResponseDTO fetchDeviceCodeResponse() throws OAuthException { + Request request = createHttpClient().newRequest(deviceCodeRequestUrl); + request.method(HttpMethod.POST); + request.timeout(HTTP_TIMEOUT_MILLISECONDS, TimeUnit.MILLISECONDS); + request.param(PARAM_CLIENT_ID, clientIdParameter); + request.param(PARAM_SCOPE, scopeParameter); + logger.trace("fetchDeviceCodeResponse() request: {}", request.getURI()); + + try { + ContentResponse response = request.send(); + String content = response.getContentAsString(); + logger.trace("fetchDeviceCodeResponse() response: {}", content); + + if (response.getStatus() == HttpStatus.OK_200) { + DeviceCodeResponseDTO dcr = gson.fromJson(content, DeviceCodeResponseDTO.class); + if (dcr != null) { + dcr.setCreatedOn(Instant.now()); + // in RFC-8628 'interval' is OPTIONAL so if absent use default + if (dcr.getInterval() == null) { + dcr.setInterval(DEFAULT_POLL_INTERVAL); + } + logger.trace("fetchDeviceCodeResponse() return: {}", dcr); + return dcr; + } + } + throw new OAuthException("fetchDeviceCodeResponse() error: " + response); + } catch (InterruptedException | TimeoutException | ExecutionException e) { + throw new OAuthException("fetchDeviceCodeResponse() error", e); + } + } + + /** + * Whilst the user is completing the Device Code Grant Flow authentication process step 3 + * we continue, in parallel, the completion of the authentication process by repeating the + * following steps: + * + * + * + * @param dcr the {@link DeviceCodeResponseDTO} + * + * @return an {@link AccessTokenResponse} object or null + * @throws OAuthResponseException if the response content is an OAuth JSON error packet + * @throws OAuthException for any other errors + */ + private synchronized @Nullable AccessTokenResponse fetchAccessTokenResponse(DeviceCodeResponseDTO dcr) + throws OAuthException, OAuthResponseException { + Request request = createHttpClient().newRequest(accessTokenRequestUrl); + request.method(HttpMethod.POST); + request.timeout(HTTP_TIMEOUT_MILLISECONDS, TimeUnit.MILLISECONDS); + request.param(PARAM_CLIENT_ID, clientIdParameter); + request.param(PARAM_GRANT_TYPE, PARAM_GRANT_TYPE_VALUE); + request.param(PARAM_DEVICE_CODE, dcr.getDeviceCode()); + logger.trace("fetchAccessTokenResponse() request: {}", request.getURI()); + + try { + ContentResponse response = request.send(); + String content = response.getContentAsString(); + logger.trace("fetchAccessTokenResponse() response: {}", content); + + switch (response.getStatus()) { + case HttpStatus.OK_200: + AccessTokenResponse atr = gson.fromJson(content, AccessTokenResponse.class); + if (atr != null) { + atr.setCreatedOn(Instant.now()); + logger.trace("fetchAccessTokenResponse() return: {}", atr); + return atr; + } + case HttpStatus.BAD_REQUEST_400: + OAuthResponseException err = gson.fromJson(content, OAuthResponseException.class); + if (err != null) { + throw err; + } + } + + /* + * Return null without throwing an exception since other HTTP responses + * may occur during AccessTokenResponse polling before the user has + * completed the verification process + */ + return null; + } catch (InterruptedException | TimeoutException | ExecutionException e) { + throw new OAuthException("fetchAccessTokenResponse() error", e); + } + } + + @Override + public void close() { + dcrCached = null; + cancelAtrPollTaskSchedule(); + closeHttpClient(); + } + + private synchronized void cancelAtrPollTaskSchedule() { + ScheduledFuture future = atrPollTaskSchedule; + if (future != null) { + future.cancel(false); + logger.trace("cancelAtrPollTaskSchedule() cancelled schedule of poll tasks"); + } + atrPollTaskSchedule = null; + } + + private void closeHttpClient() { + shutdownQuietly(httpClient); + httpClient = null; + } + + private void createAtrPollTaskSchedule(long seconds) { + atrPollTaskSchedule = scheduler.scheduleWithFixedDelay(() -> atrPollTask(), seconds, seconds, TimeUnit.SECONDS); + logger.trace("createAtrPollTaskSchedule() created schedule of poll tasks every {}s", seconds); + } + + private HttpClient createHttpClient() throws OAuthException { + HttpClient httpClient = this.httpClient; + if (httpClient == null) { + httpClient = createHttpClient(OAUTH_RFC8628); + this.httpClient = httpClient; + } + return httpClient; + } + + /** + * This method is called repeatedly on the scheduler to poll for an {@link AccessTokenResponse}. + * It cancels its own scheduler when either a) an AccessTokenResponse is returned, or b) the + * cached {@link DeviceCodeResponseDTO} expires. + */ + private synchronized void atrPollTask() { + /* + * 'finally' control variable to cancel the poll task schedule and close the http client + */ + boolean close = false; + try { + DeviceCodeResponseDTO dcr = dcrCached; + logger.trace("atrPollTask() started with cached: {}", dcr); + try { + dcr = checkDeviceCodeResponse(dcr); + + /* + * The cached DeviceCodeResponse is still valid: + * => get an AccessTokenResponse from it + * => in case of error, cancel the polling schedule + */ + AccessTokenResponse atr = fetchAccessTokenResponse(dcr); + logger.trace("atrPollTask() fetched from remote: {}", atr); + + if (atr != null) { + /* + * The AccessTokenResponse is OK: + * => import the AccessTokenResponse into the service + * => notify service AccessTokenRefreshListeners + * => cancel the polling schedule + */ + oAuthClientService.importAccessTokenResponse(atr); + oAuthClientService.notifyAccessTokenResponse(atr); + logger.trace("atrPollTask() imported into service: {}", atr); + close = true; + } + } catch (OAuthResponseException e) { + String error = e.getError(); + logger.trace("atrPollTask() poll response error: {}", error); + switch (error) { + case ACCESS_DENIED, EXPIRED_TOKEN: + close = true; + break; + + case SLOW_DOWN: + ScheduledFuture future = atrPollTaskSchedule; + if (future != null) { + long priorDelay = future.getDelay(TimeUnit.SECONDS); + cancelAtrPollTaskSchedule(); + createAtrPollTaskSchedule(priorDelay + 1); + } + break; + } + } catch (OAuthException e) { + logger.debug("atrPollTask() error: {}", e.getMessage()); + close = true; + } + } finally { + if (close) { + close(); + } + logger.trace("atrPollTask() done"); + } + } +} diff --git a/bundles/org.openhab.core.auth.oauth2client/src/main/java/org/openhab/core/auth/oauth2client/internal/OAuthStoreHandler.java b/bundles/org.openhab.core.auth.oauth2client/src/main/java/org/openhab/core/auth/oauth2client/internal/OAuthStoreHandler.java index f99b87ee0..f82baaba7 100644 --- a/bundles/org.openhab.core.auth.oauth2client/src/main/java/org/openhab/core/auth/oauth2client/internal/OAuthStoreHandler.java +++ b/bundles/org.openhab.core.auth.oauth2client/src/main/java/org/openhab/core/auth/oauth2client/internal/OAuthStoreHandler.java @@ -17,11 +17,13 @@ import java.security.GeneralSecurityException; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.core.auth.client.oauth2.AccessTokenResponse; +import org.openhab.core.auth.client.oauth2.DeviceCodeResponseDTO; /** * This is for OAuth client internal use. * * @author Gary Tse - Initial contribution + * @author Andrew Fiddian-Green - added RFC-8628 support */ @NonNullByDefault public interface OAuthStoreHandler { @@ -50,6 +52,30 @@ public interface OAuthStoreHandler { */ void saveAccessTokenResponse(String handle, @Nullable AccessTokenResponse accessTokenResponse); + /** + * Get a {@link DeviceCodeResponseDTO} from the store. The device code is encrypted and therefore will be decrypted + * before returning. + * + * If the storage is not available, it is still possible to get the DeviceCodeResponse from memory cache. + * However, the last-used statistics will be broken. It is a measured risk to take. + * + * @param handle the handle given by the call + * {@code OAuthFactory#createOAuthClientService(String, String, String, String, String, Boolean)} + * @return DeviceCodeResponse if available, null if not. + * @throws GeneralSecurityException when the token cannot be decrypted. + */ + @Nullable + DeviceCodeResponseDTO loadDeviceCodeResponse(String handle) throws GeneralSecurityException; + + /** + * Save the {@code DeviceCodeResponse} by the handle + * + * @param handle unique string used as a handle/ reference to the OAuth client service, and the underlying + * access tokens, configs. + * @param deviceCodeResponse This can be null, which explicitly removes the DeviceCodeResponse from store. + */ + void saveDeviceCodeResponse(String handle, @Nullable DeviceCodeResponseDTO deviceCodeResponse); + /** * Remove the token for the given handler. No exception is thrown in all cases * diff --git a/bundles/org.openhab.core.auth.oauth2client/src/main/java/org/openhab/core/auth/oauth2client/internal/OAuthStoreHandlerImpl.java b/bundles/org.openhab.core.auth.oauth2client/src/main/java/org/openhab/core/auth/oauth2client/internal/OAuthStoreHandlerImpl.java index 1b68723a2..188c56a21 100644 --- a/bundles/org.openhab.core.auth.oauth2client/src/main/java/org/openhab/core/auth/oauth2client/internal/OAuthStoreHandlerImpl.java +++ b/bundles/org.openhab.core.auth.oauth2client/src/main/java/org/openhab/core/auth/oauth2client/internal/OAuthStoreHandlerImpl.java @@ -33,6 +33,7 @@ import java.util.concurrent.locks.ReentrantLock; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.core.auth.client.oauth2.AccessTokenResponse; +import org.openhab.core.auth.client.oauth2.DeviceCodeResponseDTO; import org.openhab.core.auth.client.oauth2.StorageCipher; import org.openhab.core.auth.oauth2client.internal.cipher.SymmetricKeyCipher; import org.openhab.core.library.types.DateTimeType; @@ -51,6 +52,7 @@ import com.google.gson.GsonBuilder; import com.google.gson.JsonDeserializer; import com.google.gson.JsonPrimitive; import com.google.gson.JsonSerializer; +import com.google.gson.JsonSyntaxException; /** * This class handles the storage directly. It is internal to the OAuthClientService and there is @@ -73,6 +75,7 @@ import com.google.gson.JsonSerializer; * The recycle is performed when then instance is deactivated * * @author Gary Tse - Initial contribution + * @author Andrew Fiddian-Green - added RFC-8628 support */ @NonNullByDefault @Component(property = "CIPHER_TARGET=SymmetricKeyCipher") @@ -148,6 +151,33 @@ public class OAuthStoreHandlerImpl implements OAuthStoreHandler { storageFacade.put(handle, encryptedToken); } + @Override + public @Nullable DeviceCodeResponseDTO loadDeviceCodeResponse(String handle) throws GeneralSecurityException { + DeviceCodeResponseDTO dcr = (DeviceCodeResponseDTO) storageFacade.get(handle, DEVICE_CODE_RESPONSE); + if (dcr == null) { + // device code response does not exist + return null; + } + return decryptDeviceCodeResponse(dcr); + } + + @Override + public void saveDeviceCodeResponse(String handle, @Nullable DeviceCodeResponseDTO dcrArg) { + DeviceCodeResponseDTO dcr = dcrArg; + if (dcr == null) { + dcr = new DeviceCodeResponseDTO(); // put empty + } + + DeviceCodeResponseDTO dcrEncrypted; + try { + dcrEncrypted = encryptDeviceCodeResponse(dcr); + } catch (GeneralSecurityException e) { + logger.warn("Unable to encrypt token, storing as-is", e); + dcrEncrypted = dcr; + } + storageFacade.put(handle, dcrEncrypted); + } + @Override public void remove(String handle) { storageFacade.removeByHandle(handle); @@ -181,17 +211,47 @@ public class OAuthStoreHandlerImpl implements OAuthStoreHandler { return encryptedAccessToken; } + private DeviceCodeResponseDTO encryptDeviceCodeResponse(DeviceCodeResponseDTO dcr) throws GeneralSecurityException { + DeviceCodeResponseDTO dcrEncrypted = (DeviceCodeResponseDTO) dcr.clone(); + if (dcr.getDeviceCode() != null) { + dcrEncrypted.setDeviceCode(encrypt(dcr.getDeviceCode())); + } + if (dcr.getUserCode() != null) { + dcrEncrypted.setUserCode(encrypt(dcr.getUserCode())); + } + if (dcr.getVerificationUri() != null) { + dcrEncrypted.setVerificationUri(encrypt(dcr.getVerificationUri())); + } + if (dcr.getVerificationUriComplete() != null) { + dcrEncrypted.setVerificationUriComplete(encrypt(dcr.getVerificationUriComplete())); + } + return dcrEncrypted; + } + private AccessTokenResponse decryptToken(AccessTokenResponse accessTokenResponse) throws GeneralSecurityException { AccessTokenResponse decryptedToken = (AccessTokenResponse) accessTokenResponse.clone(); if (storageCipher.isEmpty()) { return decryptedToken; // do nothing if no cipher } - logger.debug("Decrypting token: {}", accessTokenResponse); + logger.debug("Decrypting: {}", accessTokenResponse); decryptedToken.setAccessToken(storageCipher.get().decrypt(accessTokenResponse.getAccessToken())); decryptedToken.setRefreshToken(storageCipher.get().decrypt(accessTokenResponse.getRefreshToken())); return decryptedToken; } + private DeviceCodeResponseDTO decryptDeviceCodeResponse(DeviceCodeResponseDTO dcr) throws GeneralSecurityException { + DeviceCodeResponseDTO dcrDecrypted = (DeviceCodeResponseDTO) dcr.clone(); + if (storageCipher.isEmpty()) { + return dcrDecrypted; // do nothing if no cipher + } + logger.debug("Decrypting: {}", dcr); + dcrDecrypted.setDeviceCode(storageCipher.get().decrypt(dcr.getDeviceCode())); + dcrDecrypted.setUserCode(storageCipher.get().decrypt(dcr.getUserCode())); + dcrDecrypted.setVerificationUri(storageCipher.get().decrypt(dcr.getVerificationUri())); + dcrDecrypted.setVerificationUriComplete(storageCipher.get().decrypt(dcr.getVerificationUriComplete())); + return dcrDecrypted; + } + private @Nullable String encrypt(String token) throws GeneralSecurityException { if (storageCipher.isEmpty()) { return token; // do nothing if no cipher @@ -287,7 +347,7 @@ public class OAuthStoreHandlerImpl implements OAuthStoreHandler { if (ACCESS_TOKEN_RESPONSE.equals(recordType)) { try { return gson.fromJson(value, AccessTokenResponse.class); - } catch (Exception e) { + } catch (JsonSyntaxException e) { logger.error( "Unable to deserialize json, discarding AccessTokenResponse. " + "Please check json against standard or with oauth provider. json:\n{}", @@ -297,17 +357,27 @@ public class OAuthStoreHandlerImpl implements OAuthStoreHandler { } else if (SERVICE_CONFIGURATION.equals(recordType)) { try { return gson.fromJson(value, PersistedParams.class); - } catch (Exception e) { + } catch (JsonSyntaxException e) { logger.error("Unable to deserialize json, discarding PersistedParams. json:\n{}", value, e); return null; } } else if (LAST_USED.equals(recordType)) { try { return gson.fromJson(value, Instant.class); - } catch (Exception e) { - logger.info("Unable to deserialize json, reset LAST_USED to now. json:\n{}", value); + } catch (JsonSyntaxException e) { + logger.info("Unable to deserialize json, reset LAST_USED to now. json:\n{}", value); return Instant.now(); } + } else if (DEVICE_CODE_RESPONSE.equals(recordType)) { + try { + return gson.fromJson(value, DeviceCodeResponseDTO.class); + } catch (JsonSyntaxException e) { + logger.error( + "Unable to deserialize json, discarding DeviceCodeResponse. " + + "Please check json against standard or with oauth provider. json:\n{}", + value, e); + return null; + } } return null; } finally { @@ -337,6 +407,28 @@ public class OAuthStoreHandlerImpl implements OAuthStoreHandler { } } + public void put(String handle, @Nullable DeviceCodeResponseDTO dcr) { + storageLock.lock(); + try { + if (dcr == null) { + storage.put(DEVICE_CODE_RESPONSE.getKey(handle), (String) null); + } else { + String gsonDcrString = gson.toJson(dcr); + storage.put(DEVICE_CODE_RESPONSE.getKey(handle), gsonDcrString); + String gsonDateStr = gson.toJson(Instant.now()); + storage.put(LAST_USED.getKey(handle), gsonDateStr); + + if (!allHandles.contains(handle)) { + // update all handles index + allHandles.add(handle); + storage.put(STORE_KEY_INDEX_OF_HANDLES, gson.toJson(allHandles)); + } + } + } finally { + storageLock.unlock(); + } + } + public void put(String handle, @Nullable PersistedParams persistedParams) { storageLock.lock(); try { @@ -364,6 +456,7 @@ public class OAuthStoreHandlerImpl implements OAuthStoreHandler { try { if (allHandles.remove(handle)) { // entry exists and successfully removed storage.remove(ACCESS_TOKEN_RESPONSE.getKey(handle)); + storage.remove(DEVICE_CODE_RESPONSE.getKey(handle)); storage.remove(LAST_USED.getKey(handle)); storage.remove(SERVICE_CONFIGURATION.getKey(handle)); storage.put(STORE_KEY_INDEX_OF_HANDLES, gson.toJson(allHandles)); // update all handles diff --git a/bundles/org.openhab.core.auth.oauth2client/src/main/java/org/openhab/core/auth/oauth2client/internal/StorageRecordType.java b/bundles/org.openhab.core.auth.oauth2client/src/main/java/org/openhab/core/auth/oauth2client/internal/StorageRecordType.java index c5351ca18..f0d587c42 100644 --- a/bundles/org.openhab.core.auth.oauth2client/src/main/java/org/openhab/core/auth/oauth2client/internal/StorageRecordType.java +++ b/bundles/org.openhab.core.auth.oauth2client/src/main/java/org/openhab/core/auth/oauth2client/internal/StorageRecordType.java @@ -19,13 +19,15 @@ import org.eclipse.jdt.annotation.Nullable; * Enum of types being used in the store * * @author Gary Tse - Initial contribution + * @author Andrew Fiddian-Green - added RFC-8628 support */ @NonNullByDefault public enum StorageRecordType { LAST_USED(".LastUsed"), ACCESS_TOKEN_RESPONSE(".AccessTokenResponse"), - SERVICE_CONFIGURATION(".ServiceConfiguration"); + SERVICE_CONFIGURATION(".ServiceConfiguration"), + DEVICE_CODE_RESPONSE(".DeviceCodeResponse"); private String suffix; diff --git a/bundles/org.openhab.core.auth.oauth2client/src/test/java/org/openhab/core/auth/oauth2client/internal/OAuthRFC8628ClientTest.java b/bundles/org.openhab.core.auth.oauth2client/src/test/java/org/openhab/core/auth/oauth2client/internal/OAuthRFC8628ClientTest.java new file mode 100644 index 000000000..f45608243 --- /dev/null +++ b/bundles/org.openhab.core.auth.oauth2client/src/test/java/org/openhab/core/auth/oauth2client/internal/OAuthRFC8628ClientTest.java @@ -0,0 +1,378 @@ +/* + * Copyright (c) 2010-2025 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.core.auth.oauth2client.internal; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.time.Instant; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.api.ContentResponse; +import org.eclipse.jetty.client.api.Request; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.openhab.core.auth.client.oauth2.AccessTokenResponse; +import org.openhab.core.auth.client.oauth2.DeviceCodeResponseDTO; +import org.openhab.core.auth.client.oauth2.OAuthClientService; +import org.openhab.core.auth.client.oauth2.OAuthException; +import org.openhab.core.auth.client.oauth2.OAuthResponseException; +import org.openhab.core.io.net.http.HttpClientFactory; +import org.openhab.core.library.types.DateTimeType; + +import com.google.gson.FieldNamingPolicy; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonPrimitive; +import com.google.gson.JsonSerializer; + +/** + * JUnit tests for {@link OAuthConnectorRFC8628} + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +class OAuthRFC8628ClientTest { + + private final Gson gson = new GsonBuilder().setDateFormat(DateTimeType.DATE_PATTERN_JSON_COMPAT) + .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) + .registerTypeAdapter(Instant.class, (JsonSerializer) (date, type, + jsonSerializationContext) -> new JsonPrimitive(date.toString())) + .create(); + + /** + * Private wrapper class for test purposes + */ + private static class OAuthConnectorRFC8628Ext extends OAuthConnectorRFC8628 { + + public OAuthConnectorRFC8628Ext(OAuthClientService oAuthClientService, String handle, + OAuthStoreHandler oAuthStoreHandler, HttpClientFactory httpClientFactory, + @Nullable GsonBuilder gsonBuilder, String accessTokenRequestUrl, String deviceCodeRequestUrl, + String clientId, String scope) throws OAuthException { + super(oAuthClientService, handle, oAuthStoreHandler, httpClientFactory, gsonBuilder, accessTokenRequestUrl, + deviceCodeRequestUrl, clientId, scope); + } + + @Override + public void close() { + // this suppresses the life-cycle errors that otherwise appear in the test log + } + } + + @Test + void testDeviceCodeResponseGoodWhenLoadedFromRemote() { + OAuthClientService oAuthClientService = mock(OAuthClientService.class); + OAuthStoreHandler oAuthStoreHandler = mock(OAuthStoreHandler.class); + HttpClientFactory httpClientFactory = mock(HttpClientFactory.class); + + try (OAuthConnectorRFC8628 oAuthConnectorRFC8628 = new OAuthConnectorRFC8628Ext(oAuthClientService, "handle", + oAuthStoreHandler, httpClientFactory, null, "accessTokenRequestUrl", "deviceCodeRequestUrl", "clientId", + "scope")) { + assertNotNull(oAuthConnectorRFC8628); + + Request request = mock(Request.class); + HttpClient httpClient = mock(HttpClient.class); + ContentResponse contentResponse = Mockito.mock(ContentResponse.class); + + when(httpClientFactory.createHttpClient(anyString())).thenReturn(httpClient); + when(httpClient.isStarted()).thenReturn(true); + + when(httpClient.newRequest(anyString())).thenReturn(request); + + try { + when(request.send()).thenReturn(contentResponse); + } catch (InterruptedException | TimeoutException | ExecutionException e) { + fail(e); + } + + DeviceCodeResponseDTO dcr = new DeviceCodeResponseDTO(); + dcr.setDeviceCode("DeviceCode"); + dcr.setExpiresIn(123); + dcr.setInterval(4L); + dcr.setUserCode("UserCode"); + dcr.setVerificationUri("VerificationUri"); + dcr.setVerificationUriComplete("VerificationUriComplete"); + + when(contentResponse.getStatus()).thenReturn(200); + when(contentResponse.getContentAsString()).thenReturn(gson.toJson(dcr), ""); + + DeviceCodeResponseDTO dcrOut = null; + try { + dcrOut = oAuthConnectorRFC8628.getDeviceCodeResponse(); + } catch (OAuthException e) { + fail(e); + } + assertNotNull(dcrOut); + dcr.setCreatedOn(dcrOut.getCreatedOn()); // allow for test running time; + + assertEquals(dcr, dcrOut); + + verify(oAuthClientService, times(1)).isClosed(); + + try { + verify(oAuthClientService, times(1)).getAccessTokenResponse(); + } catch (OAuthException | IOException | OAuthResponseException e) { + fail(e); + } + + try { + verify(oAuthStoreHandler, times(1)).loadDeviceCodeResponse(anyString()); + } catch (GeneralSecurityException e) { + fail(e); + } + + try { + verify(oAuthClientService, times(0)).importAccessTokenResponse(any(AccessTokenResponse.class)); + verify(oAuthClientService, times(0)).notifyAccessTokenResponse(any(AccessTokenResponse.class)); + } catch (OAuthException e) { + fail(e); + } + } catch (OAuthException e) { + fail(e); + } + } + + @Test + void testDeviceCodeResponseGoodWhenLoadedFromStorage() { + OAuthClientService oAuthClientService = mock(OAuthClientService.class); + OAuthStoreHandler oAuthStoreHandler = mock(OAuthStoreHandler.class); + HttpClientFactory httpClientFactory = mock(HttpClientFactory.class); + + try (OAuthConnectorRFC8628 oAuthConnectorRFC8628 = new OAuthConnectorRFC8628Ext(oAuthClientService, "handle", + oAuthStoreHandler, httpClientFactory, null, "accessTokenRequestUrl", "deviceCodeRequestUrl", "clientId", + "scope")) { + assertNotNull(oAuthConnectorRFC8628); + + Request request = mock(Request.class); + HttpClient httpClient = mock(HttpClient.class); + ContentResponse contentResponse = Mockito.mock(ContentResponse.class); + + when(httpClientFactory.createHttpClient(anyString())).thenReturn(httpClient); + when(httpClient.isStarted()).thenReturn(true); + + when(httpClient.newRequest(anyString())).thenReturn(request); + + try { + when(request.send()).thenReturn(contentResponse); + } catch (InterruptedException | TimeoutException | ExecutionException e) { + fail(e); + } + + DeviceCodeResponseDTO dcr = new DeviceCodeResponseDTO(); + dcr.setCreatedOn(Instant.now()); + dcr.setDeviceCode("DeviceCode"); + dcr.setExpiresIn(123); + dcr.setInterval(4L); + dcr.setUserCode("UserCode"); + dcr.setVerificationUri("VerificationUri"); + dcr.setVerificationUriComplete("VerificationUriComplete"); + + when(contentResponse.getStatus()).thenReturn(200); + when(contentResponse.getContentAsString()).thenReturn(""); + + try { + when(oAuthStoreHandler.loadDeviceCodeResponse(anyString())).thenReturn(dcr); + } catch (GeneralSecurityException e) { + fail(e); + } + + DeviceCodeResponseDTO dcrOut = null; + try { + dcrOut = oAuthConnectorRFC8628.getDeviceCodeResponse(); + } catch (OAuthException e) { + fail(e); + } + assertNotNull(dcrOut); + dcr.setCreatedOn(dcrOut.getCreatedOn()); // allow for test running time; + + assertEquals(dcr, dcrOut); + + verify(oAuthClientService, times(1)).isClosed(); + + try { + verify(oAuthClientService, times(1)).getAccessTokenResponse(); + } catch (OAuthException | IOException | OAuthResponseException e) { + fail(e); + } + + try { + verify(oAuthStoreHandler, times(1)).loadDeviceCodeResponse(anyString()); + } catch (GeneralSecurityException e) { + fail(e); + } + + try { + verify(oAuthClientService, times(0)).importAccessTokenResponse(any(AccessTokenResponse.class)); + verify(oAuthClientService, times(0)).notifyAccessTokenResponse(any(AccessTokenResponse.class)); + } catch (OAuthException e) { + fail(e); + } + } catch (OAuthException e) { + fail(e); + } + } + + @Test + void testDeviceCodeResponseNullWhenAccessTokenResponseLoadedFromRemote() { + OAuthClientService oAuthClientService = mock(OAuthClientService.class); + OAuthStoreHandler oAuthStoreHandler = mock(OAuthStoreHandler.class); + HttpClientFactory httpClientFactory = mock(HttpClientFactory.class); + + try (OAuthConnectorRFC8628 oAuthConnectorRFC8628 = new OAuthConnectorRFC8628Ext(oAuthClientService, "handle", + oAuthStoreHandler, httpClientFactory, null, "accessTokenRequestUrl", "deviceCodeRequestUrl", "clientId", + "scope")) { + assertNotNull(oAuthConnectorRFC8628); + + Request request = mock(Request.class); + HttpClient httpClient = mock(HttpClient.class); + ContentResponse contentResponse = Mockito.mock(ContentResponse.class); + + when(httpClientFactory.createHttpClient(anyString())).thenReturn(httpClient); + when(httpClient.isStarted()).thenReturn(true); + + when(httpClient.newRequest(anyString())).thenReturn(request); + + try { + when(request.send()).thenReturn(contentResponse); + } catch (InterruptedException | TimeoutException | ExecutionException e) { + fail(e); + } + + DeviceCodeResponseDTO dcr = new DeviceCodeResponseDTO(); + dcr.setDeviceCode("DeviceCode"); + dcr.setExpiresIn(123); + dcr.setInterval(4L); + dcr.setUserCode("UserCode"); + dcr.setVerificationUri("VerificationUri"); + dcr.setVerificationUriComplete("VerificationUriComplete"); + + AccessTokenResponse atr = new AccessTokenResponse(); + atr.setAccessToken("AccessToken"); + atr.setExpiresIn(123); + atr.setRefreshToken("RefreshToken"); + + when(contentResponse.getStatus()).thenReturn(200); + when(contentResponse.getContentAsString()).thenReturn(gson.toJson(dcr), gson.toJson(atr)); + + DeviceCodeResponseDTO dcrOut = null; + try { + dcrOut = oAuthConnectorRFC8628.getDeviceCodeResponse(); + } catch (OAuthException e) { + fail(e); + } + assertNull(dcrOut); + + verify(oAuthClientService, times(1)).isClosed(); + + try { + verify(oAuthClientService, times(1)).getAccessTokenResponse(); + } catch (OAuthException | IOException | OAuthResponseException e) { + fail(e); + } + + try { + verify(oAuthStoreHandler, times(1)).loadDeviceCodeResponse(anyString()); + } catch (GeneralSecurityException e) { + fail(e); + } + + try { + verify(oAuthClientService, times(1)).importAccessTokenResponse(any(AccessTokenResponse.class)); + verify(oAuthClientService, times(1)).notifyAccessTokenResponse(any(AccessTokenResponse.class)); + } catch (OAuthException e) { + fail(e); + } + } catch (OAuthException e) { + fail(e); + } + } + + @Test + void testDeviceCodeResponseNullWhenAccessTokenResponseLoadedFromStorage() { + OAuthClientService oAuthClientService = mock(OAuthClientService.class); + OAuthStoreHandler oAuthStoreHandler = mock(OAuthStoreHandler.class); + HttpClientFactory httpClientFactory = mock(HttpClientFactory.class); + + try (OAuthConnectorRFC8628 oAuthConnectorRFC8628 = new OAuthConnectorRFC8628Ext(oAuthClientService, "handle", + oAuthStoreHandler, httpClientFactory, null, "accessTokenRequestUrl", "deviceCodeRequestUrl", "clientId", + "scope")) { + assertNotNull(oAuthConnectorRFC8628); + + AccessTokenResponse atr = new AccessTokenResponse(); + atr.setAccessToken("AccessToken"); + atr.setExpiresIn(123); + atr.setRefreshToken("RefreshToken"); + + try { + when(oAuthClientService.getAccessTokenResponse()).thenReturn(atr); + } catch (OAuthException | IOException | OAuthResponseException e) { + fail(e); + } + + DeviceCodeResponseDTO dcr = null; + try { + dcr = oAuthConnectorRFC8628.getDeviceCodeResponse(); + } catch (OAuthException e) { + fail(e); + } + assertNull(dcr); + + verify(oAuthClientService, times(1)).isClosed(); + + try { + verify(oAuthClientService, times(1)).getAccessTokenResponse(); + } catch (OAuthException | IOException | OAuthResponseException e) { + fail(e); + } + + try { + verify(oAuthStoreHandler, times(0)).loadDeviceCodeResponse(anyString()); + } catch (GeneralSecurityException e) { + fail(e); + } + + try { + verify(oAuthClientService, times(0)).importAccessTokenResponse(any(AccessTokenResponse.class)); + verify(oAuthClientService, times(0)).notifyAccessTokenResponse(any(AccessTokenResponse.class)); + } catch (OAuthException e) { + fail(e); + } + } catch (OAuthException e) { + fail(e); + } + } + + @Test + void testDeviceCodeResponseNullWhenServiceIsClosed() { + OAuthClientService oAuthClientService = mock(OAuthClientService.class); + OAuthStoreHandler oAuthStoreHandler = mock(OAuthStoreHandler.class); + HttpClientFactory httpClientFactory = mock(HttpClientFactory.class); + + when(oAuthClientService.isClosed()).thenReturn(true); + + try (OAuthConnectorRFC8628 oAuthConnectorRFC8628 = new OAuthConnectorRFC8628Ext(oAuthClientService, "handle", + oAuthStoreHandler, httpClientFactory, null, "accessTokenRequestUrl", "deviceCodeRequestUrl", "clientId", + "scope")) { + assertThrows(OAuthException.class, () -> oAuthConnectorRFC8628.getDeviceCodeResponse()); + } catch (OAuthException e) { + fail(e); + } + } +} diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/auth/client/oauth2/DeviceCodeResponseDTO.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/auth/client/oauth2/DeviceCodeResponseDTO.java new file mode 100644 index 000000000..ef9eb111e --- /dev/null +++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/auth/client/oauth2/DeviceCodeResponseDTO.java @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2010-2025 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.core.auth.client.oauth2; + +import java.io.Serial; +import java.io.Serializable; +import java.time.Instant; +import java.util.Objects; + +/** + * This {@link DeviceCodeResponseDTO} is a DTO with fields that encapsulate the data from RFC-8628 device code + * responses. + *

+ * Note: RFC-8628 says 'verificationUriComplete' and 'interval' are OPTIONAL fields. + *

+ * See {@link AccessTokenResponse} for reference. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +public final class DeviceCodeResponseDTO implements Serializable, Cloneable { + /** + * For Serializable + */ + @Serial + private static final long serialVersionUID = 4261783375996959200L; + + private String deviceCode; + private long expiresIn; + private Long interval; // optional + private String userCode; + private String verificationUri; + private String verificationUriComplete; // optional + private Instant createdOn; + + @Override + public Object clone() { + try { + return super.clone(); + } catch (CloneNotSupportedException e) { + throw new IllegalStateException("not possible", e); + } + } + + public Instant getCreatedOn() { + return createdOn; + } + + public String getDeviceCode() { + return deviceCode; + } + + public long getExpiresIn() { + return expiresIn; + } + + public Long getInterval() { + return interval; + } + + public String getUserCode() { + return userCode; + } + + public String getVerificationUri() { + return verificationUri; + } + + public String getVerificationUriComplete() { + return verificationUriComplete; + } + + /** + * Calculate if the device code response is expired against the given time. + * It also returns true even if the response is not initialized (i.e. newly created). + * + * @param givenTime To calculate if the response is expired against the givenTime. + * @param tokenExpiresInBuffer A positive integer in seconds to act as additional buffer to the calculation. + * This causes the response to expire earlier then the stated expiry-time given. + * @return true if object is not-initialized, or expired, or expired early due to buffer. + */ + public boolean isExpired(Instant givenTime, int tokenExpiresInBuffer) { + return createdOn == null + || createdOn.plusSeconds(expiresIn).minusSeconds(tokenExpiresInBuffer).isBefore(givenTime); + } + + public void setCreatedOn(Instant createdOn) { + this.createdOn = createdOn; + } + + public void setDeviceCode(String deviceCode) { + this.deviceCode = deviceCode; + } + + public void setExpiresIn(long expiresIn) { + this.expiresIn = expiresIn; + } + + public void setInterval(Long interval) { + this.interval = interval; + } + + public void setUserCode(String userCode) { + this.userCode = userCode; + } + + public void setVerificationUri(String verificationUri) { + this.verificationUri = verificationUri; + } + + public void setVerificationUriComplete(String verificationUriComplete) { + this.verificationUriComplete = verificationUriComplete; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + DeviceCodeResponseDTO other = (DeviceCodeResponseDTO) obj; + return Objects.equals(createdOn, other.createdOn) && Objects.equals(deviceCode, other.deviceCode) + && expiresIn == other.expiresIn && Objects.equals(interval, other.interval) + && Objects.equals(userCode, other.userCode) && Objects.equals(verificationUri, other.verificationUri) + && Objects.equals(verificationUriComplete, other.verificationUriComplete); + } + + @Override + public int hashCode() { + return Objects.hash(createdOn, deviceCode, expiresIn, interval, userCode, verificationUri, + verificationUriComplete); + } + + @Override + public String toString() { + return "DeviceCodeResponseDTO [deviceCode=" + deviceCode + ", expiresIn=" + expiresIn + ", interval=" + interval + + ", userCode=" + userCode + ", verificationUri=" + verificationUri + ", verificationUriComplete=" + + verificationUriComplete + ", createdOn=" + createdOn + "]"; + } +} diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/auth/client/oauth2/OAuthClientService.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/auth/client/oauth2/OAuthClientService.java index db62a8934..092ca160e 100644 --- a/bundles/org.openhab.core/src/main/java/org/openhab/core/auth/client/oauth2/OAuthClientService.java +++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/auth/client/oauth2/OAuthClientService.java @@ -122,12 +122,12 @@ public interface OAuthClientService extends AutoCloseable { * returned by the oauth provider. It is encoded in application/x-www-form-urlencoded format * as stated in RFC 6749 section 4.1.2. * To quote from the RFC: - * + * *

{@code
      *            HTTP/1.1 302 Found
      *            Location: https://client.example.com/cb?code=SplxlOBeZQQYbYS6WxSbIA&state=xyz
      *            }
- * + * * @return AuthorizationCode This authorizationCode can be used in the call {#getOAuthTokenByAuthCode(String)} * @throws OAuthException If the state from redirectURLwithParams does not exactly match the expectedState, or * exceptions arise while parsing redirectURLwithParams. @@ -309,4 +309,32 @@ public interface OAuthClientService extends AutoCloseable { * @return the OAuth service */ OAuthClientService withGsonBuilder(GsonBuilder gsonBuilder); + + /** + * Begins the RFC-8628 Device Code Grant Flow authentication process. + * Specifically it executes the following steps as described in the article in the link below: + * + *
    + *
  • Step 1: create a request and POST it to the 'device authorize url'
  • + *
  • Step 2: process the response and create a {@link DeviceCodeResponseDTO}
  • + *
  • Step 3: the user goes off to authenticate themselves at the 'user authentication url'
  • + *
  • Step 4: repeatedly create a request and POST it to the 'token url'
  • + *
  • Step 5: repeatedly read the response and eventually create a {@link AccessTokenResponse}
  • + *
+ * + * @see RFC-8628 + * + * @return either null or a {@link DeviceCodeResponseDTO} containing the verification uri's where + * users are expected authenticate themselves + * @throws OAuthException + * @throws IOException + * @throws OAuthResponseException + */ + @Nullable + DeviceCodeResponseDTO getDeviceCodeResponse() throws OAuthException; + + /** + * Notify the service listeners about the given {@link AccessTokenResponse}. + */ + void notifyAccessTokenResponse(AccessTokenResponse accessTokenResponse); }