Verify resource indicator syntax in authz and token endpoint (#47438)

Closes #47116, closes #47119

Signed-off-by: stianst <stianst@gmail.com>
This commit is contained in:
Stian Thorgersen 2026-03-26 10:35:17 +01:00 committed by GitHub
parent 02be20e9fa
commit af942df712
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 139 additions and 16 deletions

View file

@ -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) {

View file

@ -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());

View file

@ -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";
}

View file

@ -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;
}
}
}

View file

@ -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;
}

View file

@ -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 {

View file

@ -1 +1 @@
org.keycloak.protocol.oidc.token.ResourceIndicatorsPostProcessorFactory
org.keycloak.protocol.oidc.resourceindicators.ResourceIndicatorsPostProcessorFactory

View file

@ -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));
}
}

View file

@ -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();