mirror of
https://github.com/danieldemus/openhab-core.git
synced 2026-07-29 12:34:22 +02:00
OAuth RFC-8628 Device Code Grant Flow (#4632)
Signed-off-by: Andrew Fiddian-Green <software@whitebear.ch>
This commit is contained in:
+41
-1
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+5
-4
@@ -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();
|
||||
|
||||
+474
@@ -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 <a href="https://datatracker.ietf.org/doc/html/rfc8628">RFC-8628</a>
|
||||
*
|
||||
* @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:
|
||||
*
|
||||
* <ul>
|
||||
* <li>Step 1: create a request and POST it to the 'device authorize url'</li>
|
||||
* <li>Step 2: process the response and create a {@link DeviceCodeResponseDTO}</li>
|
||||
* <li>Step 3: the user goes off to authenticate themselves at the 'user authentication url'</li>
|
||||
* <li>Step 4: repeatedly create a request and POST it to the 'token url'</li>
|
||||
* <li>Step 5: repeatedly read the response and eventually create a {@link AccessTokenResponse}</li>
|
||||
* </ul>
|
||||
*
|
||||
* @see <a href="https://datatracker.ietf.org/doc/html/rfc8628">RFC-8628</a>
|
||||
*
|
||||
* @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:
|
||||
*
|
||||
* <ul>
|
||||
* <li>Step 1: create a request and POST it to the authentication (device code) url</li>
|
||||
* <li>Step 2: process the response and create a {@link DeviceCodeResponseDTO}</li>
|
||||
* </ul>
|
||||
*
|
||||
* @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:
|
||||
*
|
||||
* <ul>
|
||||
* <li>Step 4: repeatedly create a request and POST it to the access token url</li>
|
||||
* <li>Step 5: repeatedly read the response and eventually create a {@link AccessTokenResponse}</li>
|
||||
* </ul>
|
||||
*
|
||||
* @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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
@@ -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
|
||||
*
|
||||
|
||||
+98
-5
@@ -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
|
||||
|
||||
+3
-1
@@ -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;
|
||||
|
||||
|
||||
+378
@@ -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<Instant>) (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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+154
@@ -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.
|
||||
* <p>
|
||||
* Note: RFC-8628 says 'verificationUriComplete' and 'interval' are OPTIONAL fields.
|
||||
* <p>
|
||||
* 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 + "]";
|
||||
}
|
||||
}
|
||||
+28
@@ -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:
|
||||
*
|
||||
* <ul>
|
||||
* <li>Step 1: create a request and POST it to the 'device authorize url'</li>
|
||||
* <li>Step 2: process the response and create a {@link DeviceCodeResponseDTO}</li>
|
||||
* <li>Step 3: the user goes off to authenticate themselves at the 'user authentication url'</li>
|
||||
* <li>Step 4: repeatedly create a request and POST it to the 'token url'</li>
|
||||
* <li>Step 5: repeatedly read the response and eventually create a {@link AccessTokenResponse}</li>
|
||||
* </ul>
|
||||
*
|
||||
* @see <a href="https://datatracker.ietf.org/doc/html/rfc8628">RFC-8628</a>
|
||||
*
|
||||
* @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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user