Add extra fields support to oAuth AccessTokenResponse (#5355)

* add extra fields support to oAuth AccessTokenResponse

Signed-off-by: Laurent ARNAL <laurent@clae.net>
This commit is contained in:
lo92fr
2026-03-10 22:41:54 +01:00
committed by GitHub
parent 2e7be80e21
commit 926304f727
6 changed files with 244 additions and 26 deletions
@@ -458,10 +458,13 @@ public class OAuthClientServiceImpl implements OAuthClientService {
*/ */
@Override @Override
public void addExtraAuthField(String key, String value) { public void addExtraAuthField(String key, String value) {
if (extraAuthFields == null) { Fields lcExtraAuthFields = extraAuthFields;
extraAuthFields = new Fields(); if (lcExtraAuthFields == null) {
lcExtraAuthFields = new Fields();
extraAuthFields = lcExtraAuthFields;
} }
extraAuthFields.add(key, value);
lcExtraAuthFields.add(key, value);
} }
@Override @Override
@@ -37,6 +37,7 @@ import org.eclipse.jetty.http.HttpMethod;
import org.eclipse.jetty.http.HttpStatus; import org.eclipse.jetty.http.HttpStatus;
import org.eclipse.jetty.util.Fields; import org.eclipse.jetty.util.Fields;
import org.openhab.core.auth.client.oauth2.AccessTokenResponse; import org.openhab.core.auth.client.oauth2.AccessTokenResponse;
import org.openhab.core.auth.client.oauth2.AccessTokenResponseExtraFieldsAdapterFactory;
import org.openhab.core.auth.client.oauth2.OAuthException; import org.openhab.core.auth.client.oauth2.OAuthException;
import org.openhab.core.auth.client.oauth2.OAuthResponseException; import org.openhab.core.auth.client.oauth2.OAuthResponseException;
import org.openhab.core.io.net.http.HttpClientFactory; import org.openhab.core.io.net.http.HttpClientFactory;
@@ -89,7 +90,11 @@ public class OAuthConnector {
public OAuthConnector(HttpClientFactory httpClientFactory, @Nullable Fields extraFields, GsonBuilder gsonBuilder) { public OAuthConnector(HttpClientFactory httpClientFactory, @Nullable Fields extraFields, GsonBuilder gsonBuilder) {
this.httpClientFactory = httpClientFactory; this.httpClientFactory = httpClientFactory;
this.extraFields = extraFields; this.extraFields = extraFields;
gson = gsonBuilder.setDateFormat(DateTimeType.DATE_PATTERN_JSON_COMPAT) this.gson = getGson(gsonBuilder);
}
static Gson getGson(GsonBuilder gsonBuilder) {
return gsonBuilder.setDateFormat(DateTimeType.DATE_PATTERN_JSON_COMPAT)
.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
.registerTypeAdapter(OAuthResponseException.class, .registerTypeAdapter(OAuthResponseException.class,
(JsonDeserializer<OAuthResponseException>) (json, typeOfT, context) -> { (JsonDeserializer<OAuthResponseException>) (json, typeOfT, context) -> {
@@ -120,7 +125,7 @@ public class OAuthConnector {
} catch (DateTimeParseException e) { } catch (DateTimeParseException e) {
return LocalDateTime.parse(json.getAsString()).atZone(ZoneId.systemDefault()).toInstant(); return LocalDateTime.parse(json.getAsString()).atZone(ZoneId.systemDefault()).toInstant();
} }
}).create(); }).registerTypeAdapterFactory(new AccessTokenResponseExtraFieldsAdapterFactory()).create();
} }
/** /**
@@ -214,8 +219,8 @@ public class OAuthConnector {
* @throws OAuthResponseException Error codes given by authorization provider, as in RFC 6749 section 5.2 Error * @throws OAuthResponseException Error codes given by authorization provider, as in RFC 6749 section 5.2 Error
* Response * Response
*/ */
public AccessTokenResponse grantTypeRefreshToken(String tokenUrl, String refreshToken, @Nullable String clientId, public AccessTokenResponse grantTypeRefreshToken(String tokenUrl, @Nullable String refreshToken,
@Nullable String clientSecret, @Nullable String scope, boolean supportsBasicAuth) @Nullable String clientId, @Nullable String clientSecret, @Nullable String scope, boolean supportsBasicAuth)
throws OAuthResponseException, OAuthException, IOException { throws OAuthResponseException, OAuthException, IOException {
HttpClient httpClient = null; HttpClient httpClient = null;
try { try {
@@ -252,7 +252,10 @@ public class OAuthStoreHandlerImpl implements OAuthStoreHandler {
return dcrDecrypted; return dcrDecrypted;
} }
private @Nullable String encrypt(String token) throws GeneralSecurityException { private @Nullable String encrypt(@Nullable String token) throws GeneralSecurityException {
if (token == null) {
return null;
}
if (storageCipher.isEmpty()) { if (storageCipher.isEmpty()) {
return token; // do nothing if no cipher return token; // do nothing if no cipher
} else { } else {
@@ -0,0 +1,54 @@
/*
* Copyright (c) 2010-2026 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 java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.Test;
import org.openhab.core.auth.client.oauth2.AccessTokenResponse;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
/**
* JUnit tests for {@link AccessTokenResponse}
*
* @author Laurent Arnal - Initial contribution
*/
@NonNullByDefault
class AccessTokenResponseExtraFieldTest {
@Test
public void testExtraFieldDeserialization() {
Gson gson = OAuthConnector.getGson(new GsonBuilder());
// \"created_on\":\"2026-02-26T15:10:49.965249200Z\"
String json = "{\"access_token\":\"AccessToken\",\"expires_in\":60,\"refresh_token\":\"RefreshToken\",\"app_client_id\":\"ApplicationClientId\"}";
AccessTokenResponse atr = gson.fromJson(json, AccessTokenResponse.class);
if (atr != null) {
assertEquals("AccessToken", atr.getAccessToken());
assertEquals(60, atr.getExpiresIn());
assertEquals("RefreshToken", atr.getRefreshToken());
Map<String, String> extraFields = atr.getExtraFields();
assertEquals(1, extraFields.size());
assertTrue(extraFields.containsKey("app_client_id"));
assertEquals("ApplicationClientId", extraFields.get("app_client_id"));
}
}
}
@@ -15,8 +15,13 @@ package org.openhab.core.auth.client.oauth2;
import java.io.Serial; import java.io.Serial;
import java.io.Serializable; import java.io.Serializable;
import java.time.Instant; import java.time.Instant;
import java.util.Collections;
import java.util.Map;
import java.util.Objects; import java.util.Objects;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/** /**
* This is the Access Token Response, a simple value-object that holds the result of the * This is the Access Token Response, a simple value-object that holds the result of the
* from an Access Token Request, as listed in RFC 6749: * from an Access Token Request, as listed in RFC 6749:
@@ -28,6 +33,7 @@ import java.util.Objects;
* @author Michael Bock - Initial contribution * @author Michael Bock - Initial contribution
* @author Gary Tse - Adaptation for Eclipse SmartHome * @author Gary Tse - Adaptation for Eclipse SmartHome
*/ */
@NonNullByDefault
public final class AccessTokenResponse implements Serializable, Cloneable { public final class AccessTokenResponse implements Serializable, Cloneable {
/** /**
@@ -46,14 +52,14 @@ public final class AccessTokenResponse implements Serializable, Cloneable {
* @see <a href="https://tools.ietf.org/html/rfc6749#section-1.4">rfc6749 section-1.4</a> * @see <a href="https://tools.ietf.org/html/rfc6749#section-1.4">rfc6749 section-1.4</a>
* @see <a href="https://tools.ietf.org/html/rfc6749#section-10.3">rfc6749 section-10.3</a> * @see <a href="https://tools.ietf.org/html/rfc6749#section-10.3">rfc6749 section-10.3</a>
*/ */
private String accessToken; private @Nullable String accessToken;
/** /**
* Token type. e.g. Bearer, MAC * Token type. e.g. Bearer, MAC
* *
* @see <a href="https://tools.ietf.org/html/rfc6749#section-7.1">rfc6749 section-7.1</a> * @see <a href="https://tools.ietf.org/html/rfc6749#section-7.1">rfc6749 section-7.1</a>
*/ */
private String tokenType; private @Nullable String tokenType;
/** /**
* Number of seconds that this OAuthToken is valid for since the time it was created. * Number of seconds that this OAuthToken is valid for since the time it was created.
@@ -74,7 +80,7 @@ public final class AccessTokenResponse implements Serializable, Cloneable {
* @see <a href="https://tools.ietf.org/html/rfc6749#section-1.5">rfc6749 section-1.5</a> * @see <a href="https://tools.ietf.org/html/rfc6749#section-1.5">rfc6749 section-1.5</a>
* @see <a href="https://tools.ietf.org/html/rfc6749#section-10.4">rfc6749 section-10.4</a> * @see <a href="https://tools.ietf.org/html/rfc6749#section-10.4">rfc6749 section-10.4</a>
*/ */
private String refreshToken; private @Nullable String refreshToken;
/** /**
* A space-delimited case-sensitive un-ordered string. This may be used * A space-delimited case-sensitive un-ordered string. This may be used
@@ -83,7 +89,7 @@ public final class AccessTokenResponse implements Serializable, Cloneable {
* *
* @see <a href="https://tools.ietf.org/html/rfc6749#section-3.3">rfc6749 section-3.3</a> * @see <a href="https://tools.ietf.org/html/rfc6749#section-3.3">rfc6749 section-3.3</a>
*/ */
private String scope; private @Nullable String scope;
/** /**
* If the {@code state} parameter was present in the access token request. * If the {@code state} parameter was present in the access token request.
@@ -91,7 +97,7 @@ public final class AccessTokenResponse implements Serializable, Cloneable {
* *
* <a href="https://tools.ietf.org/html/rfc6749#section-4.2.2">rfc6749 section-4.2.2</a> * <a href="https://tools.ietf.org/html/rfc6749#section-4.2.2">rfc6749 section-4.2.2</a>
*/ */
private String state; private @Nullable String state;
/** /**
* Created datetime of this access token. This is generated locally * Created datetime of this access token. This is generated locally
@@ -100,7 +106,37 @@ public final class AccessTokenResponse implements Serializable, Cloneable {
* This should be slightly later than the actual time the access token * This should be slightly later than the actual time the access token
* is produced at the server. * is produced at the server.
*/ */
private Instant createdOn; private @Nullable Instant createdOn;
/**
* Extra elements that may be passed in the token response by a specific OAuth implementation.
* <p>
* These fields are provider-specific and are not restricted to standard OAuth fields. As such,
* they may contain sensitive information (for example, identifiers, metadata or other values
* that should not be logged or exposed).
* <p>
* Consumers of this value MUST treat all entries as potentially sensitive and avoid logging or
* otherwise exposing them unless they have explicitly verified that the data is safe to do so.
*/
private Map<String, String> extraFields = Collections.emptyMap();
/**
* Returns the additional provider-specific fields from the token response.
* <p>
* Note: the returned map may contain sensitive information in non-standard fields. Callers
* MUST take care not to log, persist, or expose these values without first ensuring that
* doing so is appropriate in their security context.
*
* @return a map of additional fields as provided by the authorization server
*/
public Map<String, String> getExtraFields() {
return Collections.unmodifiableMap(extraFields);
}
public void setExtraFields(Map<String, String> extraFields) {
this.extraFields = (extraFields.isEmpty()) ? Collections.emptyMap() : Map.copyOf(extraFields);
}
/** /**
* Calculate if the token is expired against the given time. * Calculate if the token is expired against the given time.
@@ -117,15 +153,15 @@ public final class AccessTokenResponse implements Serializable, Cloneable {
|| createdOn.plusSeconds(expiresIn).minusSeconds(tokenExpiresInBuffer).isBefore(givenTime); || createdOn.plusSeconds(expiresIn).minusSeconds(tokenExpiresInBuffer).isBefore(givenTime);
} }
public String getAccessToken() { public @Nullable String getAccessToken() {
return accessToken; return accessToken;
} }
public void setAccessToken(String accessToken) { public void setAccessToken(@Nullable String accessToken) {
this.accessToken = accessToken; this.accessToken = accessToken;
} }
public String getTokenType() { public @Nullable String getTokenType() {
return tokenType; return tokenType;
} }
@@ -141,15 +177,15 @@ public final class AccessTokenResponse implements Serializable, Cloneable {
this.expiresIn = expiresIn; this.expiresIn = expiresIn;
} }
public String getRefreshToken() { public @Nullable String getRefreshToken() {
return refreshToken; return refreshToken;
} }
public void setRefreshToken(String refreshToken) { public void setRefreshToken(@Nullable String refreshToken) {
this.refreshToken = refreshToken; this.refreshToken = refreshToken;
} }
public String getScope() { public @Nullable String getScope() {
return scope; return scope;
} }
@@ -157,7 +193,7 @@ public final class AccessTokenResponse implements Serializable, Cloneable {
this.scope = scope; this.scope = scope;
} }
public String getState() { public @Nullable String getState() {
return state; return state;
} }
@@ -165,7 +201,7 @@ public final class AccessTokenResponse implements Serializable, Cloneable {
this.state = state; this.state = state;
} }
public Instant getCreatedOn() { public @Nullable Instant getCreatedOn() {
return createdOn; return createdOn;
} }
@@ -184,11 +220,11 @@ public final class AccessTokenResponse implements Serializable, Cloneable {
@Override @Override
public int hashCode() { public int hashCode() {
return Objects.hash(accessToken, tokenType, expiresIn, refreshToken, scope, state, createdOn); return Objects.hash(accessToken, tokenType, expiresIn, refreshToken, scope, state, createdOn, extraFields);
} }
@Override @Override
public boolean equals(Object thatAuthTokenObj) { public boolean equals(@Nullable Object thatAuthTokenObj) {
if (this == thatAuthTokenObj) { if (this == thatAuthTokenObj) {
return true; return true;
} }
@@ -203,13 +239,18 @@ public final class AccessTokenResponse implements Serializable, Cloneable {
return Objects.equals(this.accessToken, that.accessToken) && Objects.equals(this.tokenType, that.tokenType) return Objects.equals(this.accessToken, that.accessToken) && Objects.equals(this.tokenType, that.tokenType)
&& Objects.equals(this.expiresIn, that.expiresIn) && Objects.equals(this.expiresIn, that.expiresIn)
&& Objects.equals(this.refreshToken, that.refreshToken) && Objects.equals(this.scope, that.scope) && Objects.equals(this.refreshToken, that.refreshToken) && Objects.equals(this.scope, that.scope)
&& Objects.equals(this.state, that.state) && Objects.equals(this.createdOn, that.createdOn); && Objects.equals(this.state, that.state) && Objects.equals(this.createdOn, that.createdOn)
&& Objects.equals(this.extraFields, that.extraFields);
} }
@Override @Override
/*
* Warning: the toString() function may returns sensitive information that should not go into the log.
*
*/
public String toString() { public String toString() {
return "AccessTokenResponse [accessToken=" + accessToken + ", tokenType=" + tokenType + ", expiresIn=" return "AccessTokenResponse [accessToken=" + accessToken + ", tokenType=" + tokenType + ", expiresIn="
+ expiresIn + ", refreshToken=" + refreshToken + ", scope=" + scope + ", state=" + state + expiresIn + ", refreshToken=" + refreshToken + ", scope=" + scope + ", state=" + state
+ ", createdOn=" + createdOn + "]"; + ", createdOn=" + createdOn + ", extraFields= " + extraFields + "]";
} }
} }
@@ -0,0 +1,112 @@
/*
* Copyright (c) 2010-2026 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.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import com.google.gson.Gson;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonPrimitive;
import com.google.gson.TypeAdapter;
import com.google.gson.TypeAdapterFactory;
import com.google.gson.reflect.TypeToken;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonWriter;
/**
* A {@link TypeAdapterFactory} that decorates the default {@link AccessTokenResponse} adapter in order to capture
* additional fields returned by an OAuth 2.0 authorization server that are not part of the standard RFC 6749
* specification. All unknown JSON properties are collected into a map and exposed via {@code extraFields} on the
* {@link AccessTokenResponse}.
*
* @author Laurent Arnal - Initial contribution
*/
@NonNullByDefault
public final class AccessTokenResponseExtraFieldsAdapterFactory implements TypeAdapterFactory {
private static final Set<String> KNOWN_FIELDS = Set.of("access_token", "token_type", "expires_in", "refresh_token",
"scope", "state", "created_on", "extra_fields");
@Override
public <T> @Nullable TypeAdapter<T> create(@Nullable Gson gson, @Nullable TypeToken<T> type) {
if (type == null || !AccessTokenResponse.class.isAssignableFrom(type.getRawType())) {
return null;
}
if (gson == null) {
return null;
}
final TypeAdapter<T> delegate = gson.getDelegateAdapter(this, type);
final TypeAdapter<JsonElement> elementAdapter = gson.getAdapter(JsonElement.class);
return new TypeAdapter<>() {
@Override
public void write(JsonWriter out, T value) throws IOException {
delegate.write(out, value);
}
@Override
public T read(JsonReader in) throws IOException {
JsonElement tree = elementAdapter.read(in);
if (tree == null) {
return null;
}
if (!tree.isJsonObject()) {
return delegate.fromJsonTree(tree);
}
JsonObject obj = tree.getAsJsonObject();
T parsed = delegate.fromJsonTree(tree);
AccessTokenResponse response = (AccessTokenResponse) parsed;
Map<String, String> extras = new HashMap<>();
for (Map.Entry<String, JsonElement> entry : obj.entrySet()) {
String key = entry.getKey();
if (KNOWN_FIELDS.contains(key)) {
continue;
}
extras.put(key, toStringValue(gson, entry.getValue()));
}
if (response != null) {
response.setExtraFields(extras);
}
return parsed;
}
};
}
private static String toStringValue(Gson gson, JsonElement el) {
if (el.isJsonNull()) {
return "";
}
if (el.isJsonPrimitive()) {
JsonPrimitive p = el.getAsJsonPrimitive();
return p.getAsString();
}
return gson.toJson(el);
}
}