From 14e766faabd654b2710f6428cfc3eb24d7eed89d Mon Sep 17 00:00:00 2001 From: Boubaker Khanfir Date: Tue, 5 May 2026 14:43:44 +0100 Subject: [PATCH] fix: Allow Public Client Refresh Token Handling (#21) Prior to this change, the usage of Refresh Tokens in Token Endpoint was restricted to Confidential Clients. This change will allow the usage of Refresh Tokens on Public clients. --- .../OAuthSecurityConfiguration.java | 8 + ...reshTokenPublicAuthenticationProvider.java | 69 +++++ ...enPublicClientAuthenticationConverter.java | 71 +++++ .../server/service/OAuthClientService.java | 8 +- .../OAuthSecurityIntegrationTest.java | 244 +++++++++++++++++- 5 files changed, 385 insertions(+), 15 deletions(-) create mode 100644 auth-server-service/src/main/java/io/meeds/oauth2/server/plugin/OAuthRefreshTokenPublicAuthenticationProvider.java create mode 100644 auth-server-service/src/main/java/io/meeds/oauth2/server/plugin/OAuthRefreshTokenPublicClientAuthenticationConverter.java diff --git a/auth-server-service/src/main/java/io/meeds/oauth2/server/configuration/OAuthSecurityConfiguration.java b/auth-server-service/src/main/java/io/meeds/oauth2/server/configuration/OAuthSecurityConfiguration.java index 471a3dc..4a33bf0 100755 --- a/auth-server-service/src/main/java/io/meeds/oauth2/server/configuration/OAuthSecurityConfiguration.java +++ b/auth-server-service/src/main/java/io/meeds/oauth2/server/configuration/OAuthSecurityConfiguration.java @@ -74,6 +74,8 @@ import io.meeds.oauth2.server.configuration.model.OAuthDefaultSettings; import io.meeds.oauth2.server.plugin.OAuthAuthorizationRequestConverter; import io.meeds.oauth2.server.plugin.OAuthDcrHttpAuthenticationConverter; +import io.meeds.oauth2.server.plugin.OAuthRefreshTokenPublicAuthenticationProvider; +import io.meeds.oauth2.server.plugin.OAuthRefreshTokenPublicClientAuthenticationConverter; import io.meeds.oauth2.server.security.OAuthCimdAuthenticationProvider; import io.meeds.oauth2.server.security.OAuthDcrAuthenticationProvider; import io.meeds.oauth2.server.security.OAuthPortalAuthenticationProvider; @@ -109,6 +111,8 @@ SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http, OAuthCimdAuthenticationProvider cimdAuthenticationProvider, OAuthDcrHttpAuthenticationConverter oAuthDcrHttpAuthenticationConverter, OAuthAuthorizationRequestConverter oAuthAuthorizationRequestConverter, + OAuthRefreshTokenPublicAuthenticationProvider oAuthRefreshTokenPublicAuthenticationProvider, + OAuthRefreshTokenPublicClientAuthenticationConverter oAuthRefreshTokenPublicClientAuthenticationConverter, SecurityContextRepository securityContextRepository, @Qualifier("oauthAuthenticationProvider") OAuthDcrAuthenticationProvider oauthAuthenticationProvider, @@ -129,6 +133,10 @@ SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http, oAuthAuthorizationRequestConverter)) .authorizationServerMetadataEndpoint(oauth -> oauth.authorizationServerMetadataCustomizer(c -> customizeMetadata(c, oAuthSettingService))) + .clientAuthentication(oauth -> oauth.authenticationConverters(converters -> converters.add(0, + oAuthRefreshTokenPublicClientAuthenticationConverter)) + .authenticationProviders(providers -> providers.add(0, + oAuthRefreshTokenPublicAuthenticationProvider))) .oidc(oidc -> oidc.clientRegistrationEndpoint(e -> customizeRegistrationEndpoint(e, oauthAuthenticationProvider, oAuthDcrHttpAuthenticationConverter)) diff --git a/auth-server-service/src/main/java/io/meeds/oauth2/server/plugin/OAuthRefreshTokenPublicAuthenticationProvider.java b/auth-server-service/src/main/java/io/meeds/oauth2/server/plugin/OAuthRefreshTokenPublicAuthenticationProvider.java new file mode 100644 index 0000000..5c66ba4 --- /dev/null +++ b/auth-server-service/src/main/java/io/meeds/oauth2/server/plugin/OAuthRefreshTokenPublicAuthenticationProvider.java @@ -0,0 +1,69 @@ +/** + * This file is part of the Meeds project (https://meeds.io/). + * + * Copyright (C) 2020 - 2026 Meeds Association contact@meeds.io + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package io.meeds.oauth2.server.plugin; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.stereotype.Component; + +import io.meeds.oauth2.server.service.OAuthClientService; + +@Component +public class OAuthRefreshTokenPublicAuthenticationProvider implements AuthenticationProvider { + + @Autowired + private OAuthClientService oAuthClientService; + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + if (!(authentication instanceof OAuth2ClientAuthenticationToken clientAuthentication) + || !ClientAuthenticationMethod.NONE.equals(clientAuthentication.getClientAuthenticationMethod())) { + return null; + } else { + String clientId = clientAuthentication.getPrincipal().toString(); + RegisteredClient registeredClient = oAuthClientService.getClient(clientId); + if (registeredClient == null) { + throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_CLIENT); + } else if (!registeredClient.getClientAuthenticationMethods().contains(ClientAuthenticationMethod.NONE)) { + throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_CLIENT); + } else if (!registeredClient.getAuthorizationGrantTypes() + .contains(AuthorizationGrantType.REFRESH_TOKEN)) { + throw new OAuth2AuthenticationException(OAuth2ErrorCodes.UNAUTHORIZED_CLIENT); + } else { + return new OAuth2ClientAuthenticationToken(registeredClient, + ClientAuthenticationMethod.NONE, + null); + } + } + } + + @Override + public boolean supports(Class authentication) { + return OAuth2ClientAuthenticationToken.class.isAssignableFrom(authentication); + } + +} diff --git a/auth-server-service/src/main/java/io/meeds/oauth2/server/plugin/OAuthRefreshTokenPublicClientAuthenticationConverter.java b/auth-server-service/src/main/java/io/meeds/oauth2/server/plugin/OAuthRefreshTokenPublicClientAuthenticationConverter.java new file mode 100644 index 0000000..50c4aa9 --- /dev/null +++ b/auth-server-service/src/main/java/io/meeds/oauth2/server/plugin/OAuthRefreshTokenPublicClientAuthenticationConverter.java @@ -0,0 +1,71 @@ +/** + * This file is part of the Meeds project (https://meeds.io/). + * + * Copyright (C) 2020 - 2026 Meeds Association contact@meeds.io + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package io.meeds.oauth2.server.plugin; + +import java.util.Collections; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.web.authentication.AuthenticationConverter; +import org.springframework.stereotype.Component; + +import io.meeds.oauth2.server.service.OAuthClientService; + +import jakarta.servlet.http.HttpServletRequest; + +@Component +public final class OAuthRefreshTokenPublicClientAuthenticationConverter implements AuthenticationConverter { + + @Autowired + private OAuthClientService oAuthClientService; + + @Override + public Authentication convert(HttpServletRequest request) { + String grantType = request.getParameter(OAuth2ParameterNames.GRANT_TYPE); + if (!AuthorizationGrantType.REFRESH_TOKEN.getValue().equals(grantType)) { + return null; + } + String clientId = request.getParameter(OAuth2ParameterNames.CLIENT_ID); + if (StringUtils.isBlank(clientId)) { + return null; + } + RegisteredClient registeredClient = oAuthClientService.getClient(clientId); + if (registeredClient == null + || !registeredClient.getClientAuthenticationMethods().contains(ClientAuthenticationMethod.NONE)) { + return null; + } else if (!registeredClient.getAuthorizationGrantTypes() + .contains(AuthorizationGrantType.REFRESH_TOKEN)) { + throw new OAuth2AuthenticationException(OAuth2ErrorCodes.UNAUTHORIZED_CLIENT); + } else { + return new OAuth2ClientAuthenticationToken(clientId, + ClientAuthenticationMethod.NONE, + null, + Collections.emptyMap()); + } + } + +} diff --git a/auth-server-service/src/main/java/io/meeds/oauth2/server/service/OAuthClientService.java b/auth-server-service/src/main/java/io/meeds/oauth2/server/service/OAuthClientService.java index 2be8090..de2daeb 100755 --- a/auth-server-service/src/main/java/io/meeds/oauth2/server/service/OAuthClientService.java +++ b/auth-server-service/src/main/java/io/meeds/oauth2/server/service/OAuthClientService.java @@ -45,6 +45,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatusCode; import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; import org.springframework.security.oauth2.core.oidc.OidcScopes; import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; import org.springframework.security.oauth2.server.authorization.client.RegisteredClient.Builder; @@ -249,7 +250,12 @@ public RegisteredClient createClient(RegisteredClient client) throws ObjectAlrea if (getClient(client.getClientId(), true) != null) { throw new ObjectAlreadyExistsException("A client with id '%s' already exists".formatted(client.getClientId())); } - RegisteredClient clientToCreate = normalizeClient(client.getClientId(), client, null, true); + boolean isPublicClient = client.getClientAuthenticationMethods().contains(ClientAuthenticationMethod.NONE); + + boolean shouldApplyPublicDefaults = Objects.equals(client.getClientSettings().getSetting(CLIENT_IS_CIMD_SETTING), true) + || Objects.equals(client.getClientSettings().getSetting(CLIENT_IS_DCR_SETTING), true) + || isPublicClient; + RegisteredClient clientToCreate = normalizeClient(client.getClientId(), client, null, shouldApplyPublicDefaults); saveClient(clientToCreate); return getClient(client.getClientId(), true); } diff --git a/auth-server-service/src/test/java/io/meeds/oauth2/server/security/OAuthSecurityIntegrationTest.java b/auth-server-service/src/test/java/io/meeds/oauth2/server/security/OAuthSecurityIntegrationTest.java index 4b162dc..e23e4ac 100644 --- a/auth-server-service/src/test/java/io/meeds/oauth2/server/security/OAuthSecurityIntegrationTest.java +++ b/auth-server-service/src/test/java/io/meeds/oauth2/server/security/OAuthSecurityIntegrationTest.java @@ -18,8 +18,10 @@ */ package io.meeds.oauth2.server.security; +import static io.meeds.oauth2.server.configuration.OAuthSecurityConfiguration.CONSENT_URL; import static io.meeds.oauth2.server.configuration.OAuthSecurityConfiguration.LOGIN_URL; import static io.meeds.oauth2.server.configuration.OAuthSecurityConfiguration.REGISTER_URL; +import static io.meeds.oauth2.server.util.Utils.OFFLINE_ACCESS_SCOPE; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.hamcrest.Matchers.containsString; @@ -91,8 +93,6 @@ @DisplayName("OAuth2 security integration suite") class OAuthSecurityIntegrationTest extends OAuthServiceIntegrationTestSupport { - private static final String SCOPE_PATH = "$.scope"; - private static final String USERNAME = "root"; private static final String USERS_ROLE = "users"; @@ -145,6 +145,13 @@ class OAuthSecurityIntegrationTest extends OAuthServiceIntegrationTestSupport { private static final String TOKEN_PARAM = "token"; + private static final String CODE_VERIFIER_PARAM = "code_verifier"; + + private static final String OPENID_AND_OFFLINE_ACCESS_PARAM = "%s %s".formatted(OidcScopes.OPENID, + OFFLINE_ACCESS_SCOPE); + + private static final String REFRESH_TOKEN_PATH = "refresh_token"; + private static final String ERROR_PATH = "$.error"; private static final String TOKEN_TYPE_PATH = "$.token_type"; @@ -153,8 +160,9 @@ class OAuthSecurityIntegrationTest extends OAuthServiceIntegrationTestSupport { private static final String CLIENT_ID_PATH = "$.client_id"; - private static final String CODE_VERIFIER = - "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678901234567890123456789"; + private static final String SCOPE_PATH = "$.scope"; + + private static final String CODE_VERIFIER = UUID.randomUUID().toString(); private static final String CLIENT_SECRET_VALUE = "secret"; @@ -584,7 +592,7 @@ void publicClientCompletesAuthorizationCodeFlowWithPkce() throws Exception { .param("code", code) .param(REDIRECT_URI_PARAM, redirectUri) .param(CLIENT_ID_PARAM, client.getClientId()) - .param("code_verifier", CODE_VERIFIER)) + .param(CODE_VERIFIER_PARAM, CODE_VERIFIER)) .andExpect(status().isOk()) .andExpect(jsonPath(ACCESS_TOKEN_PATH).exists()) .andExpect(jsonPath(TOKEN_TYPE_PATH).value(BEARER_VALUE)); @@ -603,8 +611,7 @@ void publicClientConsentDenialReturnsAccessDeniedWithoutAuthorizationCode() thro MockHttpSession session = new MockHttpSession(); - MvcResult consentRedirectResult = mvc.perform(get(AUTHORIZE_ENDPOINT) - .session(session) + MvcResult consentRedirectResult = mvc.perform(get(AUTHORIZE_ENDPOINT).session(session) .with(user(USERNAME).roles(USERS_ROLE)) .queryParam(RESPONSE_TYPE_PARAM, "code") .queryParam(CLIENT_ID_PARAM, client.getClientId()) @@ -615,7 +622,7 @@ void publicClientConsentDenialReturnsAccessDeniedWithoutAuthorizationCode() thro .queryParam(CODE_CHALLENGE_PARAM, codeChallenge) .queryParam(CODE_CHALLENGE_METHOD_PARAM, "S256")) .andExpect(status().is3xxRedirection()) - .andExpect(header().string(HttpHeaders.LOCATION, containsString("/portal/consent"))) + .andExpect(header().string(HttpHeaders.LOCATION, containsString(CONSENT_URL))) .andReturn(); String consentLocation = consentRedirectResult.getResponse().getHeader(HttpHeaders.LOCATION); @@ -625,8 +632,7 @@ void publicClientConsentDenialReturnsAccessDeniedWithoutAuthorizationCode() thro assertThat(consentState).isNotBlank(); assertThat(consentState).isNotEqualTo(state); - MvcResult cancelResult = mvc.perform(post(AUTHORIZE_ENDPOINT) - .session(session) + MvcResult cancelResult = mvc.perform(post(AUTHORIZE_ENDPOINT).session(session) .with(user(USERNAME).roles(USERS_ROLE)) .contentType(APPLICATION_FORM_URLENCODED_VALUE) .param(CLIENT_ID_PARAM, client.getClientId()) @@ -674,7 +680,7 @@ void publicClientAuthorizationCodeExchangeRejectsInvalidPkceVerifier() throws Ex .param(REDIRECT_URI_PARAM, redirectUri) .param(CLIENT_ID_PARAM, client.getClientId()) .param("code", code) - .param("code_verifier", "invalid-verifier")) + .param(CODE_VERIFIER_PARAM, "invalid-verifier")) .andExpect(status().isBadRequest()) .andExpect(jsonPath(ERROR_PATH).exists()); } @@ -695,8 +701,7 @@ void mistralDcrConfidentialRegistrationReturnsDeterministicReusableClientSecret( .andExpect(status().isCreated()) .andExpect(jsonPath(CLIENT_ID_PATH).isNotEmpty()) .andExpect(jsonPath("$.client_secret").isNotEmpty()) - .andExpect(jsonPath("$.token_endpoint_auth_method") - .value(ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue())) + .andExpect(jsonPath("$.token_endpoint_auth_method").value(ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue())) .andReturn(); JsonNode firstBody = objectMapper.readTree(firstResult.getResponse().getContentAsString()); @@ -723,6 +728,217 @@ void mistralDcrConfidentialRegistrationReturnsDeterministicReusableClientSecret( .andExpect(jsonPath(TOKEN_TYPE_PATH).value(BEARER_VALUE)); } + @Test + @DisplayName("Public client can refresh access token with client_id and no client secret") + void publicClientCanRefreshAccessTokenWithoutClientSecret() throws Exception { + String redirectUri = CLIENT_ORIGIN + "/callback/refresh-" + UUID.randomUUID(); + + RegisteredClient client = RegisteredClient.withId("refresh-public-client-" + UUID.randomUUID()) + .clientId("refresh-public-client-" + UUID.randomUUID()) + .clientName("Refresh public client") + .clientAuthenticationMethod(ClientAuthenticationMethod.NONE) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) + .redirectUri(redirectUri) + .scope(OidcScopes.OPENID) + .scope(OFFLINE_ACCESS_SCOPE) + .clientSettings(ClientSettings.builder() + .requireProofKey(true) + .requireAuthorizationConsent(true) + .build()) + .tokenSettings(TokenSettings.builder() + .accessTokenFormat(OAuth2TokenFormat.REFERENCE) + .accessTokenTimeToLive(Duration.ofMinutes(1)) + .refreshTokenTimeToLive(Duration.ofHours(1)) + .reuseRefreshTokens(false) + .build()) + .build(); + + oAuthClientService.createClient(client); + grantConsent(client, USERNAME, OidcScopes.OPENID, OFFLINE_ACCESS_SCOPE); + + String codeChallenge = s256(CODE_VERIFIER); + String state = getRandomState(); + MockHttpSession session = new MockHttpSession(); + + MvcResult consentRedirectResult = mvc.perform(get(AUTHORIZE_ENDPOINT).session(session) + .with(user(USERNAME).roles(USERS_ROLE)) + .queryParam(RESPONSE_TYPE_PARAM, "code") + .queryParam(CLIENT_ID_PARAM, client.getClientId()) + .queryParam(REDIRECT_URI_PARAM, redirectUri) + .queryParam(SCOPE_PARAM, OPENID_AND_OFFLINE_ACCESS_PARAM) + .queryParam(STATE_PARAM, state) + .queryParam(CODE_CHALLENGE_PARAM, codeChallenge) + .queryParam(CODE_CHALLENGE_METHOD_PARAM, "S256")) + .andExpect(status().is3xxRedirection()) + .andExpect(header().string(HttpHeaders.LOCATION, containsString(CONSENT_URL))) + .andReturn(); + + String consentLocation = consentRedirectResult.getResponse().getHeader(HttpHeaders.LOCATION); + String consentState = queryParams(consentLocation).get(STATE_PARAM); + + MvcResult authorizeResult = mvc.perform(post(AUTHORIZE_ENDPOINT).session(session) + .with(user(USERNAME).roles(USERS_ROLE)) + .contentType(APPLICATION_FORM_URLENCODED_VALUE) + .param(CLIENT_ID_PARAM, client.getClientId()) + .param(STATE_PARAM, consentState) + .param(SCOPE_PARAM, OidcScopes.OPENID) + .param(SCOPE_PARAM, OFFLINE_ACCESS_SCOPE)) + .andExpect(status().is3xxRedirection()) + .andExpect(header().string(HttpHeaders.LOCATION, containsString(redirectUri))) + .andReturn(); + String code = queryParams(authorizeResult.getResponse().getHeader(HttpHeaders.LOCATION)).get("code"); + assertThat(code).isNotBlank(); + + MvcResult tokenResult = mvc.perform(post(TOKEN_ENDPOINT).contentType(APPLICATION_FORM_URLENCODED_VALUE) + .param(GRANT_TYPE_PARAM, + AuthorizationGrantType.AUTHORIZATION_CODE.getValue()) + .param("code", code) + .param(REDIRECT_URI_PARAM, redirectUri) + .param(CLIENT_ID_PARAM, client.getClientId()) + .param(CODE_VERIFIER_PARAM, CODE_VERIFIER)) + .andExpect(status().isOk()) + .andExpect(jsonPath(ACCESS_TOKEN_PATH).exists()) + .andExpect(jsonPath("$.refresh_token").exists()) + .andReturn(); + + JsonNode tokenBody = objectMapper.readTree(tokenResult.getResponse().getContentAsString()); + String refreshToken = tokenBody.path(REFRESH_TOKEN_PATH).asText(); + + mvc.perform(post(TOKEN_ENDPOINT).contentType(APPLICATION_FORM_URLENCODED_VALUE) + .param(GRANT_TYPE_PARAM, AuthorizationGrantType.REFRESH_TOKEN.getValue()) + .param(CLIENT_ID_PARAM, client.getClientId()) + .param(REFRESH_TOKEN_PATH, refreshToken) + .param(RESOURCE_PARAM, "http://localhost:8080/mcp-server/mcp")) + .andExpect(status().isOk()) + .andExpect(jsonPath(ACCESS_TOKEN_PATH).exists()) + .andExpect(jsonPath(TOKEN_TYPE_PATH).value(BEARER_VALUE)); + } + + @Test + @DisplayName("Confidential client can refresh access token with client secret") + void confidentialClientCanRefreshAccessTokenWithClientSecret() throws Exception { + String redirectUri = CLIENT_ORIGIN + "/callback/confidential-refresh-" + UUID.randomUUID(); + String clientId = "confidential-refresh-client-" + UUID.randomUUID(); + + RegisteredClient client = RegisteredClient.withId(clientId) + .clientId(clientId) + .clientSecret(passwordEncoder.encode(CLIENT_SECRET_VALUE)) // NOSONAR + .clientName("Confidential refresh client") + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) + .redirectUri(redirectUri) + .scope(OidcScopes.OPENID) + .scope("offline_access") + .clientSettings(ClientSettings.builder() + .requireAuthorizationConsent(true) + .build()) + .tokenSettings(TokenSettings.builder() + .accessTokenFormat(OAuth2TokenFormat.REFERENCE) + .accessTokenTimeToLive(Duration.ofMinutes(1)) + .refreshTokenTimeToLive(Duration.ofHours(1)) + .reuseRefreshTokens(false) + .build()) + .build(); + oAuthClientService.createClient(client); + + String codeChallenge = s256(CODE_VERIFIER); + String state = getRandomState(); + + MockHttpSession session = new MockHttpSession(); + MvcResult consentRedirectResult = mvc.perform(get(AUTHORIZE_ENDPOINT).session(session) + .with(user(USERNAME).roles(USERS_ROLE)) + .queryParam(RESPONSE_TYPE_PARAM, "code") + .queryParam(CLIENT_ID_PARAM, client.getClientId()) + .queryParam(REDIRECT_URI_PARAM, redirectUri) + .queryParam(SCOPE_PARAM, OPENID_AND_OFFLINE_ACCESS_PARAM) + .queryParam(STATE_PARAM, state) + .queryParam(CODE_CHALLENGE_PARAM, codeChallenge) + .queryParam(CODE_CHALLENGE_METHOD_PARAM, "S256")) + .andExpect(status().is3xxRedirection()) + .andExpect(header().string(HttpHeaders.LOCATION, containsString(CONSENT_URL))) + .andReturn(); + + String consentState = queryParams(consentRedirectResult.getResponse() + .getHeader(HttpHeaders.LOCATION)).get(STATE_PARAM); + + MvcResult authorizeResult = mvc.perform(post(AUTHORIZE_ENDPOINT).session(session) + .with(user(USERNAME).roles(USERS_ROLE)) + .contentType(APPLICATION_FORM_URLENCODED_VALUE) + .param(CLIENT_ID_PARAM, client.getClientId()) + .param(STATE_PARAM, consentState) + .param(SCOPE_PARAM, OidcScopes.OPENID) + .param(SCOPE_PARAM, OFFLINE_ACCESS_SCOPE)) + .andExpect(status().is3xxRedirection()) + .andExpect(header().string(HttpHeaders.LOCATION, containsString(redirectUri))) + .andReturn(); + + String code = queryParams(authorizeResult.getResponse() + .getHeader(HttpHeaders.LOCATION)).get("code"); + assertThat(code).isNotBlank(); + + MvcResult tokenResult = mvc.perform(post(TOKEN_ENDPOINT).contentType(APPLICATION_FORM_URLENCODED_VALUE) + .header(AUTHORIZATION, + basic(client.getClientId(), CLIENT_SECRET_VALUE)) + .param(GRANT_TYPE_PARAM, + AuthorizationGrantType.AUTHORIZATION_CODE.getValue()) + .param("code", code) + .param(REDIRECT_URI_PARAM, redirectUri) + .param(CODE_VERIFIER_PARAM, CODE_VERIFIER)) + .andExpect(status().isOk()) + .andExpect(jsonPath(ACCESS_TOKEN_PATH).exists()) + .andExpect(jsonPath("$.refresh_token").exists()) + .andReturn(); + + String refreshToken = objectMapper.readTree(tokenResult.getResponse().getContentAsString()) + .path(REFRESH_TOKEN_PATH) + .asText(); + + mvc.perform(post(TOKEN_ENDPOINT).contentType(APPLICATION_FORM_URLENCODED_VALUE) + .header(AUTHORIZATION, basic(client.getClientId(), CLIENT_SECRET_VALUE)) + .param(GRANT_TYPE_PARAM, AuthorizationGrantType.REFRESH_TOKEN.getValue()) + .param(REFRESH_TOKEN_PATH, refreshToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath(ACCESS_TOKEN_PATH).exists()) + .andExpect(jsonPath(TOKEN_TYPE_PATH).value(BEARER_VALUE)); + } + + @Test + @DisplayName("Confidential client cannot use refresh token grant without client secret") + void confidentialClientCannotRefreshWithoutClientSecret() throws Exception { + String redirectUri = CLIENT_ORIGIN + "/callback/confidential-refresh-no-secret-" + UUID.randomUUID(); + String clientId = "confidential-refresh-no-secret-client-" + UUID.randomUUID(); + + RegisteredClient client = RegisteredClient.withId(clientId) + .clientId(clientId) + .clientSecret(passwordEncoder.encode(CLIENT_SECRET_VALUE)) // NOSONAR + .clientName("Confidential refresh no secret client") + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) + .redirectUri(redirectUri) + .scope(OidcScopes.OPENID) + .scope("offline_access") + .clientSettings(ClientSettings.builder() + .requireAuthorizationConsent(true) + .build()) + .tokenSettings(TokenSettings.builder() + .accessTokenFormat(OAuth2TokenFormat.REFERENCE) + .accessTokenTimeToLive(Duration.ofMinutes(1)) + .refreshTokenTimeToLive(Duration.ofHours(1)) + .reuseRefreshTokens(false) + .build()) + .build(); + oAuthClientService.createClient(client); + + mvc.perform(post(TOKEN_ENDPOINT).contentType(APPLICATION_FORM_URLENCODED_VALUE) + .param(GRANT_TYPE_PARAM, AuthorizationGrantType.REFRESH_TOKEN.getValue()) + .param(CLIENT_ID_PARAM, client.getClientId()) + .param(REFRESH_TOKEN_PATH, "dummy-refresh-token-" + UUID.randomUUID())) + .andExpect(status().is3xxRedirection()); + } + private String s256(String verifier) throws Exception { // NOSONAR byte[] digest = MessageDigest.getInstance("SHA-256") .digest(verifier.getBytes(StandardCharsets.US_ASCII)); @@ -818,7 +1034,7 @@ private Map dcrRegistration(List redirectUris, List