diff --git a/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/config/AlsoEnergyConfig.java b/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/config/AlsoEnergyConfig.java index 70b3cbb57..30cfb73c9 100644 --- a/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/config/AlsoEnergyConfig.java +++ b/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/config/AlsoEnergyConfig.java @@ -24,6 +24,7 @@ import static net.solarnetwork.central.c2c.config.SolarNetCloudIntegrationsConfiguration.CLOUD_INTEGRATIONS; import static net.solarnetwork.central.common.config.SolarNetCommonConfiguration.OAUTH_CLIENT_REGISTRATION; +import java.time.Clock; import java.util.Arrays; import java.util.Collection; import javax.cache.Cache; @@ -50,6 +51,7 @@ import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.endpoint.DefaultMapOAuth2AccessTokenResponseConverter; import org.springframework.security.oauth2.core.http.converter.OAuth2AccessTokenResponseHttpMessageConverter; import org.springframework.web.client.RestOperations; import net.solarnetwork.central.biz.UserEventAppenderBiz; @@ -69,6 +71,7 @@ import net.solarnetwork.central.c2c.http.OAuth2Utils; import net.solarnetwork.central.security.jdbc.JdbcOAuth2AuthorizedClientService; import net.solarnetwork.central.security.service.CachingOAuth2ClientRegistrationRepository; +import net.solarnetwork.central.security.service.JwtOAuth2AccessTokenResponseConverter; import net.solarnetwork.central.security.service.RetryingOAuth2AuthorizedClientManager; /** @@ -136,12 +139,17 @@ public OAuth2AuthorizedClientManager alsoEnergyOauthAuthorizedClientManager( var clientService = new JdbcOAuth2AuthorizedClientService(bytesEncryptor, jdbcOperations, repo); + // AlsoEnergy not providing expires_in in token response, so extract from JWT exp claim + var tokenResponseConverter = new OAuth2AccessTokenResponseHttpMessageConverter(); + tokenResponseConverter.setAccessTokenResponseConverter(new JwtOAuth2AccessTokenResponseConverter( + Clock.systemUTC(), new DefaultMapOAuth2AccessTokenResponseConverter())); + // @formatter:off var authRestOps = new RestTemplateBuilder() .requestFactory(t -> reqFactory) .messageConverters(Arrays.asList( new FormHttpMessageConverter(), - new OAuth2AccessTokenResponseHttpMessageConverter())) + tokenResponseConverter)) .errorHandler(new OAuth2ErrorResponseErrorHandler()) .build(); // @formatter:on diff --git a/solarnet/common/build.gradle b/solarnet/common/build.gradle index e3d7b0dcf..733a180a9 100644 --- a/solarnet/common/build.gradle +++ b/solarnet/common/build.gradle @@ -74,6 +74,7 @@ dependencies { implementation "software.amazon.awssdk:secretsmanager:${awsSdk2Version}" implementation 'com.cronutils:cron-utils:9.1.5' implementation 'com.fasterxml.uuid:java-uuid-generator:4.0.1' + implementation 'com.nimbusds:nimbus-jose-jwt' implementation 'commons-codec:commons-codec' implementation "commons-io:commons-io:${commonsIoVersion}" implementation 'jakarta.validation:jakarta.validation-api' diff --git a/solarnet/common/src/main/java/net/solarnetwork/central/security/service/JwtOAuth2AccessTokenResponseConverter.java b/solarnet/common/src/main/java/net/solarnetwork/central/security/service/JwtOAuth2AccessTokenResponseConverter.java new file mode 100644 index 000000000..920d3b072 --- /dev/null +++ b/solarnet/common/src/main/java/net/solarnetwork/central/security/service/JwtOAuth2AccessTokenResponseConverter.java @@ -0,0 +1,100 @@ +/* ================================================================== + * JwtOAuth2AccessTokenResponseConverter.java - 23/11/2024 6:27:24 am + * + * Copyright 2024 SolarNetwork.net Dev Team + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA + * 02111-1307 USA + * ================================================================== + */ + +package net.solarnetwork.central.security.service; + +import static net.solarnetwork.util.CollectionUtils.getMapString; +import java.time.InstantSource; +import java.time.temporal.ChronoUnit; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.Map; +import org.springframework.core.convert.converter.Converter; +import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import com.nimbusds.jwt.JWT; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.JWTParser; +import net.solarnetwork.util.ObjectUtils; + +/** + * Decode {@code OAuth2AccessTokenResponse} with support for extracting + * attributes from a JWT token. + * + *

+ * Some OAuth providers do not provide a {@code expires_in} value in their token + * response. If such a provider returns the token as a JWT, this class will + * attempt to extract the {@code exp} token claim date and generate a + * {@code expires_in} value from that. + *

+ * + * @author matt + * @version 1.0 + */ +public class JwtOAuth2AccessTokenResponseConverter + implements Converter, OAuth2AccessTokenResponse> { + + private final InstantSource clock; + private final Converter, OAuth2AccessTokenResponse> delegate; + + /** + * Constructor. + * + * @param clock + * the instant source to use + * @param delegate + * the delegate converter + * @throws IllegalArgumentException + * if any argument is {@code null} + */ + public JwtOAuth2AccessTokenResponseConverter(InstantSource clock, + Converter, OAuth2AccessTokenResponse> delegate) { + super(); + this.clock = ObjectUtils.requireNonNullArgument(clock, "clock"); + this.delegate = ObjectUtils.requireNonNullArgument(delegate, "delegate"); + } + + @Override + public OAuth2AccessTokenResponse convert(Map source) { + // check if no "expires_in" parameter provided, and if not see if token is JWT with expiration date + if ( !source.containsKey(OAuth2ParameterNames.EXPIRES_IN) + && source.containsKey(OAuth2ParameterNames.ACCESS_TOKEN) ) { + try { + JWT jwt = JWTParser.parse(getMapString(OAuth2ParameterNames.ACCESS_TOKEN, source)); + JWTClaimsSet claims = jwt.getJWTClaimsSet(); + if ( claims != null ) { + Date exp = claims.getExpirationTime(); + if ( exp != null ) { + Map newSource = new LinkedHashMap<>(source); + newSource.put(OAuth2ParameterNames.EXPIRES_IN, + ChronoUnit.SECONDS.between(clock.instant(), exp.toInstant())); + source = newSource; + } + } + } catch ( Exception e ) { + // assume not a JWT, so ignore + } + } + return delegate.convert(source); + } + +}