From 926304f7271e05eb376ab61a5d311797f324a54a Mon Sep 17 00:00:00 2001 From: lo92fr Date: Tue, 10 Mar 2026 22:41:54 +0100 Subject: [PATCH] Add extra fields support to oAuth AccessTokenResponse (#5355) * add extra fields support to oAuth AccessTokenResponse Signed-off-by: Laurent ARNAL --- .../internal/OAuthClientServiceImpl.java | 9 +- .../oauth2client/internal/OAuthConnector.java | 13 +- .../internal/OAuthStoreHandlerImpl.java | 5 +- .../AccessTokenResponseExtraFieldTest.java | 54 +++++++++ .../client/oauth2/AccessTokenResponse.java | 77 +++++++++--- ...okenResponseExtraFieldsAdapterFactory.java | 112 ++++++++++++++++++ 6 files changed, 244 insertions(+), 26 deletions(-) create mode 100644 bundles/org.openhab.core.auth.oauth2client/src/test/java/org/openhab/core/auth/oauth2client/internal/AccessTokenResponseExtraFieldTest.java create mode 100644 bundles/org.openhab.core/src/main/java/org/openhab/core/auth/client/oauth2/AccessTokenResponseExtraFieldsAdapterFactory.java diff --git a/bundles/org.openhab.core.auth.oauth2client/src/main/java/org/openhab/core/auth/oauth2client/internal/OAuthClientServiceImpl.java b/bundles/org.openhab.core.auth.oauth2client/src/main/java/org/openhab/core/auth/oauth2client/internal/OAuthClientServiceImpl.java index 50cfe9463..c84927b2f 100644 --- a/bundles/org.openhab.core.auth.oauth2client/src/main/java/org/openhab/core/auth/oauth2client/internal/OAuthClientServiceImpl.java +++ b/bundles/org.openhab.core.auth.oauth2client/src/main/java/org/openhab/core/auth/oauth2client/internal/OAuthClientServiceImpl.java @@ -458,10 +458,13 @@ public class OAuthClientServiceImpl implements OAuthClientService { */ @Override public void addExtraAuthField(String key, String value) { - if (extraAuthFields == null) { - extraAuthFields = new Fields(); + Fields lcExtraAuthFields = extraAuthFields; + if (lcExtraAuthFields == null) { + lcExtraAuthFields = new Fields(); + extraAuthFields = lcExtraAuthFields; } - extraAuthFields.add(key, value); + + lcExtraAuthFields.add(key, value); } @Override diff --git a/bundles/org.openhab.core.auth.oauth2client/src/main/java/org/openhab/core/auth/oauth2client/internal/OAuthConnector.java b/bundles/org.openhab.core.auth.oauth2client/src/main/java/org/openhab/core/auth/oauth2client/internal/OAuthConnector.java index be41bfc30..4fe787a96 100644 --- a/bundles/org.openhab.core.auth.oauth2client/src/main/java/org/openhab/core/auth/oauth2client/internal/OAuthConnector.java +++ b/bundles/org.openhab.core.auth.oauth2client/src/main/java/org/openhab/core/auth/oauth2client/internal/OAuthConnector.java @@ -37,6 +37,7 @@ import org.eclipse.jetty.http.HttpMethod; import org.eclipse.jetty.http.HttpStatus; import org.eclipse.jetty.util.Fields; 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.OAuthResponseException; import org.openhab.core.io.net.http.HttpClientFactory; @@ -89,7 +90,11 @@ public class OAuthConnector { public OAuthConnector(HttpClientFactory httpClientFactory, @Nullable Fields extraFields, GsonBuilder gsonBuilder) { this.httpClientFactory = httpClientFactory; 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) .registerTypeAdapter(OAuthResponseException.class, (JsonDeserializer) (json, typeOfT, context) -> { @@ -120,7 +125,7 @@ public class OAuthConnector { } catch (DateTimeParseException e) { 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 * Response */ - public AccessTokenResponse grantTypeRefreshToken(String tokenUrl, String refreshToken, @Nullable String clientId, - @Nullable String clientSecret, @Nullable String scope, boolean supportsBasicAuth) + public AccessTokenResponse grantTypeRefreshToken(String tokenUrl, @Nullable String refreshToken, + @Nullable String clientId, @Nullable String clientSecret, @Nullable String scope, boolean supportsBasicAuth) throws OAuthResponseException, OAuthException, IOException { HttpClient httpClient = null; try { diff --git a/bundles/org.openhab.core.auth.oauth2client/src/main/java/org/openhab/core/auth/oauth2client/internal/OAuthStoreHandlerImpl.java b/bundles/org.openhab.core.auth.oauth2client/src/main/java/org/openhab/core/auth/oauth2client/internal/OAuthStoreHandlerImpl.java index 40d051cff..c9bac8b1c 100644 --- a/bundles/org.openhab.core.auth.oauth2client/src/main/java/org/openhab/core/auth/oauth2client/internal/OAuthStoreHandlerImpl.java +++ b/bundles/org.openhab.core.auth.oauth2client/src/main/java/org/openhab/core/auth/oauth2client/internal/OAuthStoreHandlerImpl.java @@ -252,7 +252,10 @@ public class OAuthStoreHandlerImpl implements OAuthStoreHandler { 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()) { return token; // do nothing if no cipher } else { diff --git a/bundles/org.openhab.core.auth.oauth2client/src/test/java/org/openhab/core/auth/oauth2client/internal/AccessTokenResponseExtraFieldTest.java b/bundles/org.openhab.core.auth.oauth2client/src/test/java/org/openhab/core/auth/oauth2client/internal/AccessTokenResponseExtraFieldTest.java new file mode 100644 index 000000000..36439cbb3 --- /dev/null +++ b/bundles/org.openhab.core.auth.oauth2client/src/test/java/org/openhab/core/auth/oauth2client/internal/AccessTokenResponseExtraFieldTest.java @@ -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 extraFields = atr.getExtraFields(); + + assertEquals(1, extraFields.size()); + assertTrue(extraFields.containsKey("app_client_id")); + assertEquals("ApplicationClientId", extraFields.get("app_client_id")); + } + } +} diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/auth/client/oauth2/AccessTokenResponse.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/auth/client/oauth2/AccessTokenResponse.java index a3292bd38..08dab028f 100644 --- a/bundles/org.openhab.core/src/main/java/org/openhab/core/auth/client/oauth2/AccessTokenResponse.java +++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/auth/client/oauth2/AccessTokenResponse.java @@ -15,8 +15,13 @@ package org.openhab.core.auth.client.oauth2; import java.io.Serial; import java.io.Serializable; import java.time.Instant; +import java.util.Collections; +import java.util.Map; 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 * from an Access Token Request, as listed in RFC 6749: @@ -28,6 +33,7 @@ import java.util.Objects; * @author Michael Bock - Initial contribution * @author Gary Tse - Adaptation for Eclipse SmartHome */ +@NonNullByDefault public final class AccessTokenResponse implements Serializable, Cloneable { /** @@ -46,14 +52,14 @@ public final class AccessTokenResponse implements Serializable, Cloneable { * @see rfc6749 section-1.4 * @see rfc6749 section-10.3 */ - private String accessToken; + private @Nullable String accessToken; /** * Token type. e.g. Bearer, MAC * * @see rfc6749 section-7.1 */ - private String tokenType; + private @Nullable String tokenType; /** * 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 rfc6749 section-1.5 * @see rfc6749 section-10.4 */ - private String refreshToken; + private @Nullable String refreshToken; /** * A space-delimited case-sensitive un-ordered string. This may be used @@ -83,7 +89,7 @@ public final class AccessTokenResponse implements Serializable, Cloneable { * * @see rfc6749 section-3.3 */ - private String scope; + private @Nullable String scope; /** * If the {@code state} parameter was present in the access token request. @@ -91,7 +97,7 @@ public final class AccessTokenResponse implements Serializable, Cloneable { * * rfc6749 section-4.2.2 */ - private String state; + private @Nullable String state; /** * 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 * 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. + *

+ * 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). + *

+ * 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 extraFields = Collections.emptyMap(); + + /** + * Returns the additional provider-specific fields from the token response. + *

+ * 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 getExtraFields() { + return Collections.unmodifiableMap(extraFields); + } + + public void setExtraFields(Map extraFields) { + this.extraFields = (extraFields.isEmpty()) ? Collections.emptyMap() : Map.copyOf(extraFields); + } /** * 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); } - public String getAccessToken() { + public @Nullable String getAccessToken() { return accessToken; } - public void setAccessToken(String accessToken) { + public void setAccessToken(@Nullable String accessToken) { this.accessToken = accessToken; } - public String getTokenType() { + public @Nullable String getTokenType() { return tokenType; } @@ -141,15 +177,15 @@ public final class AccessTokenResponse implements Serializable, Cloneable { this.expiresIn = expiresIn; } - public String getRefreshToken() { + public @Nullable String getRefreshToken() { return refreshToken; } - public void setRefreshToken(String refreshToken) { + public void setRefreshToken(@Nullable String refreshToken) { this.refreshToken = refreshToken; } - public String getScope() { + public @Nullable String getScope() { return scope; } @@ -157,7 +193,7 @@ public final class AccessTokenResponse implements Serializable, Cloneable { this.scope = scope; } - public String getState() { + public @Nullable String getState() { return state; } @@ -165,7 +201,7 @@ public final class AccessTokenResponse implements Serializable, Cloneable { this.state = state; } - public Instant getCreatedOn() { + public @Nullable Instant getCreatedOn() { return createdOn; } @@ -184,11 +220,11 @@ public final class AccessTokenResponse implements Serializable, Cloneable { @Override 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 - public boolean equals(Object thatAuthTokenObj) { + public boolean equals(@Nullable Object thatAuthTokenObj) { if (this == thatAuthTokenObj) { 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) && Objects.equals(this.expiresIn, that.expiresIn) && 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 + /* + * Warning: the toString() function may returns sensitive information that should not go into the log. + * + */ public String toString() { return "AccessTokenResponse [accessToken=" + accessToken + ", tokenType=" + tokenType + ", expiresIn=" + expiresIn + ", refreshToken=" + refreshToken + ", scope=" + scope + ", state=" + state - + ", createdOn=" + createdOn + "]"; + + ", createdOn=" + createdOn + ", extraFields= " + extraFields + "]"; } } diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/auth/client/oauth2/AccessTokenResponseExtraFieldsAdapterFactory.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/auth/client/oauth2/AccessTokenResponseExtraFieldsAdapterFactory.java new file mode 100644 index 000000000..b82207cb2 --- /dev/null +++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/auth/client/oauth2/AccessTokenResponseExtraFieldsAdapterFactory.java @@ -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 KNOWN_FIELDS = Set.of("access_token", "token_type", "expires_in", "refresh_token", + "scope", "state", "created_on", "extra_fields"); + + @Override + public @Nullable TypeAdapter create(@Nullable Gson gson, @Nullable TypeToken type) { + if (type == null || !AccessTokenResponse.class.isAssignableFrom(type.getRawType())) { + return null; + } + + if (gson == null) { + return null; + } + + final TypeAdapter delegate = gson.getDelegateAdapter(this, type); + final TypeAdapter 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 extras = new HashMap<>(); + for (Map.Entry 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); + } +}