OAuth RFC-8628 Device Code Grant Flow (#4632)

Signed-off-by: Andrew Fiddian-Green <software@whitebear.ch>
This commit is contained in:
Andrew Fiddian-Green
2025-03-17 00:40:07 +01:00
committed by GitHub
parent 8de8b4d4b4
commit 2eec58a95f
9 changed files with 1209 additions and 13 deletions
@@ -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));
}
}
@@ -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();
@@ -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");
}
}
}
@@ -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
*
@@ -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) {
} 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
@@ -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;
@@ -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);
}
}
}
@@ -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 + "]";
}
}
@@ -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);
}