From af942df712b53dd49d19d4e242f78b5ece17f0ee Mon Sep 17 00:00:00 2001 From: Stian Thorgersen Date: Thu, 26 Mar 2026 10:35:17 +0100 Subject: [PATCH] Verify resource indicator syntax in authz and token endpoint (#47438) Closes #47116, closes #47119 Signed-off-by: stianst --- .../oidc/endpoints/AuthorizationEndpoint.java | 1 + .../AuthorizationEndpointChecker.java | 12 +++++ .../ResourceIndicatorConstants.java | 8 ++++ .../ResourceIndicatorValidation.java | 40 +++++++++++++++++ .../ResourceIndicatorsPostProcessor.java | 26 ++++++----- ...esourceIndicatorsPostProcessorFactory.java | 4 +- ...tocol.oidc.token.TokenPostProcessorFactory | 2 +- .../ResourceIndicatorValidationTest.java | 44 +++++++++++++++++++ .../tests/oauth/ResourceIndicatorsTest.java | 18 +++++++- 9 files changed, 139 insertions(+), 16 deletions(-) create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/resourceindicators/ResourceIndicatorConstants.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/resourceindicators/ResourceIndicatorValidation.java rename services/src/main/java/org/keycloak/protocol/oidc/{token => resourceindicators}/ResourceIndicatorsPostProcessor.java (75%) rename services/src/main/java/org/keycloak/protocol/oidc/{token => resourceindicators}/ResourceIndicatorsPostProcessorFactory.java (80%) create mode 100644 services/src/test/java/org/keycloak/protocol/oidc/resourceindicators/ResourceIndicatorValidationTest.java diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java index 458ba1db295..f922ef301e3 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java @@ -187,6 +187,7 @@ public class AuthorizationEndpoint extends AuthorizationEndpointBase { checker.checkInvalidRequestMessage(); checker.checkOIDCRequest(); checker.checkValidScope(); + checker.checkValidResource(); checker.checkOIDCParams(); checker.checkPKCEParams(); } catch (AuthorizationEndpointChecker.AuthorizationCheckException ex) { diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpointChecker.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpointChecker.java index eba0bfdbf9c..a95dd12dffd 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpointChecker.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpointChecker.java @@ -40,6 +40,8 @@ import org.keycloak.protocol.oidc.TokenManager; import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequest; import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequestParserProcessor; import org.keycloak.protocol.oidc.endpoints.request.RequestUriType; +import org.keycloak.protocol.oidc.resourceindicators.ResourceIndicatorConstants; +import org.keycloak.protocol.oidc.resourceindicators.ResourceIndicatorValidation; import org.keycloak.protocol.oidc.utils.OIDCResponseMode; import org.keycloak.protocol.oidc.utils.OIDCResponseType; import org.keycloak.protocol.oidc.utils.RedirectUtils; @@ -242,6 +244,16 @@ public class AuthorizationEndpointChecker { } } + public void checkValidResource() throws AuthorizationCheckException { + if (!ResourceIndicatorValidation.isValidResourceIndicator(request.getResource())) { + ServicesLogger.LOGGER.invalidParameter(OIDCLoginProtocol.SCOPE_PARAM); + String errorMessage = "Invalid resource: " + request.getResource(); + event.detail(Details.REASON, errorMessage); + event.error(Errors.INVALID_REQUEST); + throw new AuthorizationCheckException(Response.Status.BAD_REQUEST, OAuthErrorException.INVALID_TARGET, ResourceIndicatorConstants.ERROR_INVALID_RESOURCE); + } + } + public void checkOIDCParams() throws AuthorizationCheckException { // If request is not OIDC request, but pure OAuth2 request and response_type is just 'token', then 'nonce' is not mandatory boolean isOIDCRequest = TokenUtil.isOIDCRequest(request.getScope()); diff --git a/services/src/main/java/org/keycloak/protocol/oidc/resourceindicators/ResourceIndicatorConstants.java b/services/src/main/java/org/keycloak/protocol/oidc/resourceindicators/ResourceIndicatorConstants.java new file mode 100644 index 00000000000..e59cbc10857 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/resourceindicators/ResourceIndicatorConstants.java @@ -0,0 +1,8 @@ +package org.keycloak.protocol.oidc.resourceindicators; + +public interface ResourceIndicatorConstants { + String ERROR_NOT_MATCHING = "The requested resource is not matching the original request."; + String ERROR_INVALID_RESOURCE = "The requested resource is invalid, missing, unknown, or malformed."; + String URN_CLIENT_PREFIX = "urn:client:"; + String CLIENT_RESOURCE_URL_ATTRIBUTE = "resource_url"; +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/resourceindicators/ResourceIndicatorValidation.java b/services/src/main/java/org/keycloak/protocol/oidc/resourceindicators/ResourceIndicatorValidation.java new file mode 100644 index 00000000000..0a4258bcdd5 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/resourceindicators/ResourceIndicatorValidation.java @@ -0,0 +1,40 @@ +package org.keycloak.protocol.oidc.resourceindicators; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.regex.Pattern; + +public class ResourceIndicatorValidation { + + private static final Pattern URN_REGEX = Pattern.compile("^urn:[a-z0-9][a-z0-9-]{0,31}:([a-z0-9()+,-.:=@;$_!*']|%[0-9a-f]{2})++$", Pattern.CASE_INSENSITIVE); + + private ResourceIndicatorValidation() { + } + + public static boolean isValidResourceIndicator(String resourceIndicator) { + if (resourceIndicator == null) { + return true; + } + + try { + URI uri = new URI(resourceIndicator); + if ("urn".equalsIgnoreCase(uri.getScheme())) { + return URN_REGEX.matcher(resourceIndicator).matches(); + } else { + if (!uri.isAbsolute()) { + return false; + } else if (uri.getFragment() != null) { + return false; + } else if (uri.getQuery() != null) { + return false; + } else if (uri.getPath() == null) { + return false; + } + } + return true; + } catch (URISyntaxException e) { + return false; + } + } + +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/token/ResourceIndicatorsPostProcessor.java b/services/src/main/java/org/keycloak/protocol/oidc/resourceindicators/ResourceIndicatorsPostProcessor.java similarity index 75% rename from services/src/main/java/org/keycloak/protocol/oidc/token/ResourceIndicatorsPostProcessor.java rename to services/src/main/java/org/keycloak/protocol/oidc/resourceindicators/ResourceIndicatorsPostProcessor.java index 1e8d99c0252..939e8099bd7 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/token/ResourceIndicatorsPostProcessor.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/resourceindicators/ResourceIndicatorsPostProcessor.java @@ -1,18 +1,16 @@ -package org.keycloak.protocol.oidc.token; +package org.keycloak.protocol.oidc.resourceindicators; import org.keycloak.OAuth2Constants; import org.keycloak.OAuthErrorException; import org.keycloak.models.ClientModel; import org.keycloak.models.Constants; import org.keycloak.models.KeycloakSession; +import org.keycloak.protocol.oidc.token.TokenInterceptorException; +import org.keycloak.protocol.oidc.token.TokenPostProcessor; +import org.keycloak.protocol.oidc.token.TokenPostProcessorContext; public class ResourceIndicatorsPostProcessor implements TokenPostProcessor { - public static final String ERROR_NOT_MATCHING = "The requested resource is not matching the original request."; - public static final String ERROR_INVALID_RESOURCE = "The requested resource is invalid, missing, unknown, or malformed."; - public static final String URN_CLIENT_PREFIX = "urn:client:"; - public static final String CLIENT_RESOURCE_URL_ATTRIBUTE = "resource_url"; - private final KeycloakSession session; public ResourceIndicatorsPostProcessor(KeycloakSession session) { @@ -22,6 +20,10 @@ public class ResourceIndicatorsPostProcessor implements TokenPostProcessor { @Override public void process(TokenPostProcessorContext context) { String requestedResource = context.clientSessionCtx().getAttribute(OAuth2Constants.RESOURCE, String.class); + if (requestedResource != null && !ResourceIndicatorValidation.isValidResourceIndicator(requestedResource)) { + throw new TokenInterceptorException(OAuthErrorException.INVALID_TARGET, ResourceIndicatorConstants.ERROR_INVALID_RESOURCE); + } + String grantType = context.clientSessionCtx().getAttribute(Constants.GRANT_TYPE, String.class); boolean originalResourceParamRequired = false; @@ -40,13 +42,13 @@ public class ResourceIndicatorsPostProcessor implements TokenPostProcessor { if (originalResourceParamRequired) { if (originalResourceParam == null) { - throw new TokenInterceptorException(OAuthErrorException.INVALID_TARGET, ERROR_NOT_MATCHING); + throw new TokenInterceptorException(OAuthErrorException.INVALID_TARGET, ResourceIndicatorConstants.ERROR_NOT_MATCHING); } if (requestedResource == null) { requestedResource = originalResourceParam; } else if (!requestedResource.equals(originalResourceParam)){ - throw new TokenInterceptorException(OAuthErrorException.INVALID_TARGET, ERROR_NOT_MATCHING); + throw new TokenInterceptorException(OAuthErrorException.INVALID_TARGET, ResourceIndicatorConstants.ERROR_NOT_MATCHING); } } @@ -58,7 +60,7 @@ public class ResourceIndicatorsPostProcessor implements TokenPostProcessor { } if (audienceToSet == null) { - throw new TokenInterceptorException(OAuthErrorException.INVALID_TARGET, ERROR_INVALID_RESOURCE); + throw new TokenInterceptorException(OAuthErrorException.INVALID_TARGET, ResourceIndicatorConstants.ERROR_INVALID_RESOURCE); } context.refreshToken().getOtherClaims().put(OAuth2Constants.RESOURCE, requestedResource); @@ -66,11 +68,11 @@ public class ResourceIndicatorsPostProcessor implements TokenPostProcessor { } private boolean isClientUrn(String resource) { - return resource.startsWith(URN_CLIENT_PREFIX); + return resource.startsWith(ResourceIndicatorConstants.URN_CLIENT_PREFIX); } private String findAudienceByClientUrn(String resource, String[] audience) { - String requestedClientId = resource.substring(URN_CLIENT_PREFIX.length()); + String requestedClientId = resource.substring(ResourceIndicatorConstants.URN_CLIENT_PREFIX.length()); return find(requestedClientId, audience); } @@ -78,7 +80,7 @@ public class ResourceIndicatorsPostProcessor implements TokenPostProcessor { for (String a : audience) { ClientModel client = session.clients().getClientByClientId(session.getContext().getRealm(), a); if (client != null) { - String clientResourceUrl = client.getAttribute(CLIENT_RESOURCE_URL_ATTRIBUTE); + String clientResourceUrl = client.getAttribute(ResourceIndicatorConstants.CLIENT_RESOURCE_URL_ATTRIBUTE); if (resource.equals(clientResourceUrl)) { return resource; } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/token/ResourceIndicatorsPostProcessorFactory.java b/services/src/main/java/org/keycloak/protocol/oidc/resourceindicators/ResourceIndicatorsPostProcessorFactory.java similarity index 80% rename from services/src/main/java/org/keycloak/protocol/oidc/token/ResourceIndicatorsPostProcessorFactory.java rename to services/src/main/java/org/keycloak/protocol/oidc/resourceindicators/ResourceIndicatorsPostProcessorFactory.java index 240b7448c1c..455d3ee6527 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/token/ResourceIndicatorsPostProcessorFactory.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/resourceindicators/ResourceIndicatorsPostProcessorFactory.java @@ -1,8 +1,10 @@ -package org.keycloak.protocol.oidc.token; +package org.keycloak.protocol.oidc.resourceindicators; import org.keycloak.Config; import org.keycloak.common.Profile; import org.keycloak.models.KeycloakSession; +import org.keycloak.protocol.oidc.token.TokenPostProcessor; +import org.keycloak.protocol.oidc.token.TokenPostProcessorFactory; import org.keycloak.provider.EnvironmentDependentProviderFactory; public class ResourceIndicatorsPostProcessorFactory implements TokenPostProcessorFactory, EnvironmentDependentProviderFactory { diff --git a/services/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.token.TokenPostProcessorFactory b/services/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.token.TokenPostProcessorFactory index d923657ec96..f55e6cc29ac 100644 --- a/services/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.token.TokenPostProcessorFactory +++ b/services/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.token.TokenPostProcessorFactory @@ -1 +1 @@ -org.keycloak.protocol.oidc.token.ResourceIndicatorsPostProcessorFactory \ No newline at end of file +org.keycloak.protocol.oidc.resourceindicators.ResourceIndicatorsPostProcessorFactory \ No newline at end of file diff --git a/services/src/test/java/org/keycloak/protocol/oidc/resourceindicators/ResourceIndicatorValidationTest.java b/services/src/test/java/org/keycloak/protocol/oidc/resourceindicators/ResourceIndicatorValidationTest.java new file mode 100644 index 00000000000..559f9d9d12f --- /dev/null +++ b/services/src/test/java/org/keycloak/protocol/oidc/resourceindicators/ResourceIndicatorValidationTest.java @@ -0,0 +1,44 @@ +package org.keycloak.protocol.oidc.resourceindicators; + +import org.junit.Assert; +import org.junit.Test; + +public class ResourceIndicatorValidationTest { + + @Test + public void testValidResourceIndicatorUrns() { + assertValid("urn:client:something"); + assertValid("urn:client:asdfasdfs23_asdfasefr43_asdf34f43-asdf34avdrvdr"); + assertValid("urn:something:something"); + } + + @Test + public void testValidResourceIndicatorUrls() { + assertValid("https://something"); + assertValid("https://something:8080"); + assertValid("https://something:8080/something"); + assertValid("https://something/something"); + } + + @Test + public void testInvalidResourceIndicatorUrns() { + assertInvalid("urn:client:something#something"); + assertInvalid("urn:client:something?foo=bar"); + } + + @Test + public void testInvalidResourceIndicatorUrls() { + assertInvalid("https://something#something"); + assertInvalid("https://something?something"); + assertInvalid("/something"); + } + + private void assertValid(String str) { + Assert.assertTrue(ResourceIndicatorValidation.isValidResourceIndicator(str)); + } + + private void assertInvalid(String str) { + Assert.assertFalse(ResourceIndicatorValidation.isValidResourceIndicator(str)); + } + +} diff --git a/tests/base/src/test/java/org/keycloak/tests/oauth/ResourceIndicatorsTest.java b/tests/base/src/test/java/org/keycloak/tests/oauth/ResourceIndicatorsTest.java index 022dd86a370..db5d7bea729 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oauth/ResourceIndicatorsTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oauth/ResourceIndicatorsTest.java @@ -23,8 +23,8 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import static org.keycloak.OAuthErrorException.INVALID_TARGET; -import static org.keycloak.protocol.oidc.token.ResourceIndicatorsPostProcessor.ERROR_INVALID_RESOURCE; -import static org.keycloak.protocol.oidc.token.ResourceIndicatorsPostProcessor.ERROR_NOT_MATCHING; +import static org.keycloak.protocol.oidc.resourceindicators.ResourceIndicatorConstants.ERROR_INVALID_RESOURCE; +import static org.keycloak.protocol.oidc.resourceindicators.ResourceIndicatorConstants.ERROR_NOT_MATCHING; @KeycloakIntegrationTest(config = ResourceIndicatorsTest.ResourceIndicatorServerConfig.class) public class ResourceIndicatorsTest { @@ -65,6 +65,20 @@ public class ResourceIndicatorsTest { assertErrorResponse(tokenResponse, INVALID_TARGET, ERROR_INVALID_RESOURCE); } + @Test + public void testInvalidResourceSyntax() { + AccessTokenResponse tokenResponse = oauth.passwordGrantRequest("user", "pass").resource("/theservice2").send(); + assertErrorResponse(tokenResponse, INVALID_TARGET, ERROR_INVALID_RESOURCE); + } + + @Test + public void testAuthzInvalidResourceParam() { + AuthorizationEndpointResponse authorizationEndpointResponse = oauth.loginForm().resource("/invalid").doLoginWithCookie(); + Assertions.assertTrue(authorizationEndpointResponse.isRedirected()); + Assertions.assertEquals(authorizationEndpointResponse.getError(), INVALID_TARGET); + Assertions.assertEquals(authorizationEndpointResponse.getErrorDescription(), ERROR_INVALID_RESOURCE); + } + @Test public void testAuthzResourceInTokenRequest() { AuthorizationEndpointResponse authorizationEndpointResponse = oauth.loginForm().resource("urn:client:theservice").doLoginWithCookie();