KEYCLOAK-14019 Improvements for request_uri parameter

(cherry picked from commit da38b36297a5bd9890f7df031696b516268d6cff)
This commit is contained in:
mposolda 2021-01-07 14:52:19 +01:00 committed by Stian Thorgersen
parent acfea8ecd2
commit eac3329d22
15 changed files with 214 additions and 4 deletions

View file

@ -109,6 +109,9 @@ public class OIDCConfigurationRepresentation {
@JsonProperty("request_uri_parameter_supported")
private Boolean requestUriParameterSupported;
@JsonProperty("require_request_uri_registration")
private Boolean requireRequestUriRegistration;
// KEYCLOAK-7451 OAuth Authorization Server Metadata for Proof Key for Code Exchange
@JsonProperty("code_challenge_methods_supported")
private List<String> codeChallengeMethodsSupported;
@ -343,6 +346,14 @@ public class OIDCConfigurationRepresentation {
this.requestUriParameterSupported = requestUriParameterSupported;
}
public Boolean getRequireRequestUriRegistration() {
return requireRequestUriRegistration;
}
public void setRequireRequestUriRegistration(Boolean requireRequestUriRegistration) {
this.requireRequestUriRegistration = requireRequestUriRegistration;
}
// KEYCLOAK-7451 OAuth Authorization Server Metadata for Proof Key for Code Exchange
public List<String> getCodeChallengeMethodsSupported() {
return codeChallengeMethodsSupported;

View file

@ -20,9 +20,13 @@ package org.keycloak.protocol.oidc;
import org.keycloak.authentication.authenticators.client.X509ClientAuthenticator;
import org.keycloak.jose.jws.Algorithm;
import org.keycloak.models.ClientModel;
import org.keycloak.models.Constants;
import org.keycloak.representations.idm.ClientRepresentation;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
@ -79,6 +83,14 @@ public class OIDCAdvancedConfigWrapper {
setAttribute(OIDCConfigAttributes.REQUEST_OBJECT_REQUIRED, requestObjectRequired);
}
public List<String> getRequestUris() {
return getAttributeMultivalued(OIDCConfigAttributes.REQUEST_URIS);
}
public void setRequestUris(List<String> requestUris) {
setAttributeMultivalued(OIDCConfigAttributes.REQUEST_URIS, requestUris);
}
public boolean isUseJwksUrl() {
String useJwksUrl = getAttribute(OIDCConfigAttributes.USE_JWKS_URL);
return Boolean.parseBoolean(useJwksUrl);
@ -244,4 +256,20 @@ public class OIDCAdvancedConfigWrapper {
}
}
}
private List<String> getAttributeMultivalued(String attrKey) {
String attrValue = getAttribute(attrKey);
if (attrValue == null) return Collections.emptyList();
return Arrays.asList(Constants.CFG_DELIMITER_PATTERN.split(attrValue));
}
private void setAttributeMultivalued(String attrKey, List<String> attrValues) {
if (attrValues == null || attrValues.size() == 0) {
// Remove attribute
setAttribute(attrKey, null);
} else {
String attrValueFull = String.join(Constants.CFG_DELIMITER, attrValues);
setAttribute(attrKey, attrValueFull);
}
}
}

View file

@ -27,6 +27,8 @@ public final class OIDCConfigAttributes {
public static final String REQUEST_OBJECT_REQUIRED_REQUEST = "request only";
public static final String REQUEST_OBJECT_REQUIRED_REQUEST_URI = "request_uri only";
public static final String REQUEST_URIS = "request.uris";
public static final String JWKS_URL = "jwks.url";
public static final String USE_JWKS_URL = "use.jwks.url";

View file

@ -133,6 +133,7 @@ public class OIDCWellKnownProvider implements WellKnownProvider {
config.setRequestParameterSupported(true);
config.setRequestUriParameterSupported(true);
config.setRequireRequestUriRegistration(true);
// KEYCLOAK-7451 OAuth Authorization Server Metadata for Proof Key for Code Exchange
config.setCodeChallengeMethodsSupported(DEFAULT_CODE_CHALLENGE_METHODS_SUPPORTED);

View file

@ -26,6 +26,7 @@ import org.keycloak.models.KeycloakSession;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.OIDCConfigAttributes;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.utils.RedirectUtils;
import org.keycloak.services.ErrorPageException;
import org.keycloak.services.ServicesLogger;
import org.keycloak.services.messages.Messages;
@ -33,6 +34,7 @@ import org.keycloak.services.messages.Messages;
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response;
import java.io.InputStream;
import java.util.HashSet;
import java.util.List;
/**
@ -75,7 +77,14 @@ public class AuthorizationEndpointRequestParserProcessor {
if (requestParam != null) {
new AuthzEndpointRequestObjectParser(session, requestParam, client).parseRequest(request);
} else if (requestUriParam != null) {
try (InputStream is = session.getProvider(HttpClientProvider.class).get(requestUriParam)) {
// Validate "requestUriParam" with allowed requestUris
List<String> requestUris = OIDCAdvancedConfigWrapper.fromClientModel(client).getRequestUris();
String requestUri = RedirectUtils.verifyRedirectUri(session, client.getRootUrl(), requestUriParam, new HashSet<>(requestUris), false);
if (requestUri == null) {
throw new RuntimeException("Specified 'request_uri' not allowed for this client.");
}
try (InputStream is = session.getProvider(HttpClientProvider.class).get(requestUri)) {
String retrievedRequest = StreamUtil.readString(is);
new AuthzEndpointRequestObjectParser(session, retrievedRequest, client).parseRequest(request);
}

View file

@ -78,7 +78,7 @@ public class RedirectUtils {
.collect(Collectors.toSet());
}
private static String verifyRedirectUri(KeycloakSession session, String rootUrl, String redirectUri, Set<String> validRedirects, boolean requireRedirectUri) {
public static String verifyRedirectUri(KeycloakSession session, String rootUrl, String redirectUri, Set<String> validRedirects, boolean requireRedirectUri) {
KeycloakUriInfo uriInfo = session.getContext().getUri();
RealmModel realm = session.getContext().getRealm();

View file

@ -142,6 +142,10 @@ public class DescriptionConverter {
configWrapper.setIdTokenEncryptedResponseEnc(clientOIDC.getIdTokenEncryptedResponseEnc());
}
if (clientOIDC.getRequestUris() != null) {
configWrapper.setRequestUris(clientOIDC.getRequestUris());
}
configWrapper.setTokenEndpointAuthSigningAlg(clientOIDC.getTokenEndpointAuthSigningAlg());
configWrapper.setBackchannelLogoutUrl(clientOIDC.getBackchannelLogoutUri());
@ -253,6 +257,9 @@ public class DescriptionConverter {
if (config.getIdTokenEncryptedResponseEnc() != null) {
response.setIdTokenEncryptedResponseEnc(config.getIdTokenEncryptedResponseEnc());
}
if (config.getRequestUris() != null) {
response.setRequestUris(config.getRequestUris());
}
if (config.getTokenEndpointAuthSigningAlg() != null) {
response.setTokenEndpointAuthSigningAlg(config.getTokenEndpointAuthSigningAlg());
}

View file

@ -721,6 +721,7 @@ public class ClientPolicyBasicsTest extends AbstractKeycloakTest {
String clientSecret = "secret";
String cid = createClientByAdmin(clientId, (ClientRepresentation clientRep) -> {
clientRep.setSecret(clientSecret);
OIDCAdvancedConfigWrapper.fromClientRepresentation(clientRep).setRequestUris(Arrays.asList(TestApplicationResourceUrls.clientRequestUri()));
});
adminClient.realm(REALM_NAME).clients().get(cid).roles().create(RoleBuilder.create().name("sample-client-role").build());

View file

@ -531,4 +531,21 @@ public class OIDCClientRegistrationTest extends AbstractClientRegistrationTest {
assertTrue(realmDefaultClientScopes.equals(new HashSet<>(registeredDefaultClientScopes)));
}
@Test
public void testRequestUris() throws Exception {
OIDCClientRepresentation clientRep = null;
OIDCClientRepresentation response = null;
clientRep = createRep();
clientRep.setRequestUris(Arrays.asList("http://host/foo", "https://host2/bar"));
response = reg.oidc().create(clientRep);
Assert.assertNames(response.getRequestUris(), "http://host/foo", "https://host2/bar");
// Test Keycloak representation
ClientRepresentation kcClient = getClient(response.getClientId());
OIDCAdvancedConfigWrapper config = OIDCAdvancedConfigWrapper.fromClientRepresentation(kcClient);
Assert.assertNames(config.getRequestUris(), "http://host/foo", "https://host2/bar");
}
}

View file

@ -30,6 +30,7 @@ import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.authentication.authenticators.client.JWTClientAuthenticator;
import org.keycloak.common.Profile;
import org.keycloak.common.util.Time;
import org.keycloak.common.util.UriUtils;
import org.keycloak.events.Details;
import org.keycloak.events.EventType;
import org.keycloak.jose.jws.Algorithm;
@ -121,7 +122,10 @@ public class OIDCAdvancedRequestParamsTest extends AbstractTestRealmKeycloakTest
@Before
public void clientConfiguration() {
ClientManager.realm(adminClient.realm("test")).clientId("test-app").directAccessGrant(true);
ClientManager.realm(adminClient.realm("test")).clientId("test-app")
.directAccessGrant(true)
.setRequestUris(TestApplicationResourceUrls.clientRequestUri());
/*
* Configure the default client ID. Seems like OAuthClient is keeping the state of clientID
* For example: If some test case configure oauth.clientId("sample-public-client"), other tests
@ -786,6 +790,72 @@ public class OIDCAdvancedRequestParamsTest extends AbstractTestRealmKeycloakTest
assertTrue(appPage.isCurrent());
}
@Test
public void requestUriParamWithAllowedRequestUris() throws Exception {
oauth.stateParamHardcoded("mystate1");
String validRedirectUri = oauth.getRedirectUri();
TestOIDCEndpointsApplicationResource oidcClientEndpointsResource = testingClient.testApp().oidcClientEndpoints();
oidcClientEndpointsResource.setOIDCRequest("test", "test-app", validRedirectUri, "10", Algorithm.none.toString());
ClientManager.ClientManagerBuilder clientMgrBuilder = ClientManager.realm(adminClient.realm("test")).clientId("test-app");
oauth.requestUri(TestApplicationResourceUrls.clientRequestUri());
// Test with the relative allowed request_uri - should pass
String absoluteRequestUri = TestApplicationResourceUrls.clientRequestUri();
String requestUri = absoluteRequestUri.substring(UriUtils.getOrigin(absoluteRequestUri).length());
clientMgrBuilder.setRequestUris(requestUri);
oauth.openLoginForm();
Assert.assertFalse(errorPage.isCurrent());
loginPage.assertCurrent();
// Test with the relative and star at the end - should pass
requestUri = requestUri.replace("/get-oidc-request", "/*");
clientMgrBuilder.setRequestUris(requestUri);
oauth.openLoginForm();
Assert.assertFalse(errorPage.isCurrent());
loginPage.assertCurrent();
// Test absolute and wildcard at the end - should pass
requestUri = absoluteRequestUri.replace("/get-oidc-request", "/*");
clientMgrBuilder.setRequestUris(requestUri);
oauth.openLoginForm();
Assert.assertFalse(errorPage.isCurrent());
loginPage.assertCurrent();
// Test star only as wildcard - should pass
clientMgrBuilder.setRequestUris("*");
oauth.openLoginForm();
Assert.assertFalse(errorPage.isCurrent());
loginPage.assertCurrent();
// Test with multiple request_uris - should pass
clientMgrBuilder.setRequestUris("/foo", requestUri);
oauth.openLoginForm();
Assert.assertFalse(errorPage.isCurrent());
loginPage.assertCurrent();
// Test invalid request_uris - should fail
clientMgrBuilder.setRequestUris("/foo", requestUri.replace("/*", "/foo"));
oauth.openLoginForm();
errorPage.assertCurrent();
// Test with no request_uri set at all - should fail
clientMgrBuilder.setRequestUris();
oauth.openLoginForm();
errorPage.assertCurrent();
// Revert
clientMgrBuilder.setRequestUris(TestApplicationResourceUrls.clientRequestUri());
}
@Test
public void requestUriParamSigned() throws Exception {
oauth.stateParamHardcoded("mystate3");

View file

@ -152,6 +152,7 @@ public class OIDCWellKnownProviderTest extends AbstractKeycloakTest {
// Request and Request_Uri
Assert.assertTrue(oidcConfig.getRequestParameterSupported());
Assert.assertTrue(oidcConfig.getRequestUriParameterSupported());
Assert.assertTrue(oidcConfig.getRequireRequestUriRegistration());
// KEYCLOAK-7451 OAuth Authorization Server Metadata for Proof Key for Code Exchange
// PKCE support

View file

@ -2,11 +2,14 @@ package org.keycloak.testsuite.util;
import org.keycloak.admin.client.resource.ClientResource;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.OIDCConfigAttributes;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.ProtocolMapperRepresentation;
import org.keycloak.representations.idm.RoleRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedList;
@ -68,10 +71,11 @@ public class ClientManager {
clientResource.update(app);
}
public void directAccessGrant(Boolean enable) {
public ClientManagerBuilder directAccessGrant(Boolean enable) {
ClientRepresentation app = clientResource.toRepresentation();
app.setDirectAccessGrantsEnabled(enable);
clientResource.update(app);
return this;
}
public ClientManagerBuilder standardFlow(Boolean enable) {
@ -158,6 +162,13 @@ public class ClientManager {
clientResource.update(app);
}
// Set valid values of "request_uri" parameter
public void setRequestUris(String... requestUris) {
ClientRepresentation app = clientResource.toRepresentation();
OIDCAdvancedConfigWrapper.fromClientRepresentation(app).setRequestUris(Arrays.asList(requestUris));
clientResource.update(app);
}
public UserRepresentation getServiceAccountUser() {
return clientResource.getServiceAccountUser();
}

View file

@ -387,6 +387,8 @@ request-object-signature-alg=Request Object Signature Algorithm
request-object-signature-alg.tooltip=JWA algorithm, which client needs to use when sending OIDC request object specified by 'request' or 'request_uri' parameters. If set to 'any', Request object can be signed by any algorithm (including 'none' ).
request-object-required=Request Object Required
request-object-required.tooltip=Specifies if the client needs to provide a request object with their authorization requests, and what method they can use for this. If set to "not required", providing a request object is optional. In all other cases, providing a request object is mandatory. If set to "request", the request object must be provided by value. If set to "request_uri", the request object must be provided by reference. If set to "request or request_uri", either method can be used.
request-uris=Valid Request URIs
request-uris.tooltip=List of valid URIs, which can be used as values of 'request_uri' parameter during OpenID Connect authentication request. There is support for the same capabilities like for Valid Redirect URIs. For example wildcards or relative paths.
fine-saml-endpoint-conf=Fine Grain SAML Endpoint Configuration
fine-saml-endpoint-conf.tooltip=Expand this section to configure exact URLs for Assertion Consumer and Single Logout Service.
assertion-consumer-post-binding-url=Assertion Consumer Service POST Binding URL

View file

@ -1319,6 +1319,13 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro
$scope.backchannelLogoutRevokeOfflineSessions = false;
}
}
if ($scope.client.attributes["request.uris"] && $scope.client.attributes["request.uris"].length > 0) {
$scope.client.requestUris = $scope.client.attributes["request.uris"].split("##");
} else {
$scope.client.requestUris = [];
}
}
if (!$scope.create) {
@ -1456,6 +1463,9 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro
if ($scope.newWebOrigin && $scope.newWebOrigin.length > 0) {
return true;
}
if ($scope.newRequestUri && $scope.newRequestUri.length > 0) {
return true;
}
return false;
}
@ -1543,6 +1553,10 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro
$scope.changed = isChanged();
}, true);
$scope.$watch('newRequestUri', function() {
$scope.changed = isChanged();
}, true);
$scope.deleteWebOrigin = function(index) {
$scope.clientEdit.webOrigins.splice(index, 1);
}
@ -1550,6 +1564,13 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro
$scope.clientEdit.webOrigins.push($scope.newWebOrigin);
$scope.newWebOrigin = "";
}
$scope.deleteRequestUri = function(index) {
$scope.clientEdit.requestUris.splice(index, 1);
}
$scope.addRequestUri = function() {
$scope.clientEdit.requestUris.push($scope.newRequestUri);
$scope.newRequestUri = "";
}
$scope.deleteRedirectUri = function(index) {
$scope.clientEdit.redirectUris.splice(index, 1);
}
@ -1568,6 +1589,16 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro
$scope.addWebOrigin();
}
if ($scope.newRequestUri && $scope.newRequestUri.length > 0) {
$scope.addRequestUri();
}
if ($scope.clientEdit.requestUris && $scope.clientEdit.requestUris.length > 0) {
$scope.clientEdit.attributes["request.uris"] = $scope.clientEdit.requestUris.join("##");
} else {
$scope.clientEdit.attributes["request.uris"] = null;
}
delete $scope.clientEdit.requestUris;
if ($scope.samlServerSignature == true) {
$scope.clientEdit.attributes["saml.server.signature"] = "true";
} else {

View file

@ -518,6 +518,25 @@
</div>
<kc-tooltip>{{:: 'request-object-required.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group clearfix block" data-ng-show="protocol == 'openid-connect'">
<label class="col-md-2 control-label" for="newRequestUri">{{:: 'request-uris' | translate}}</label>
<div class="col-sm-6">
<div class="input-group" ng-repeat="(i, requestUri) in clientEdit.requestUris track by $index">
<input class="form-control" ng-model="clientEdit.requestUris[i]">
<div class="input-group-btn">
<button class="btn btn-default" type="button" data-ng-click="deleteRequestUri($index)"><span class="fa fa-minus"></span></button>
</div>
</div>
<div class="input-group">
<input class="form-control" ng-model="newRequestUri" id="newRequestUri">
<div class="input-group-btn">
<button class="btn btn-default" type="button" data-ng-click="newRequestUri.length > 0 && addRequestUri()"><span class="fa fa-plus"></span></button>
</div>
</div>
</div>
<kc-tooltip>{{:: 'request-uris.tooltip' | translate}}</kc-tooltip>
</div>
</fieldset>
<fieldset data-ng-show="protocol == 'openid-connect'">