From dad51773ea391717eceaef2f947bf0c6460f37d2 Mon Sep 17 00:00:00 2001 From: Daniel Gozalo Date: Wed, 5 Jan 2022 11:21:23 +0100 Subject: [PATCH] [fixes #9223] - Create an internal representation of RAR that also handles Static and Dynamic Client Scopes Parse scopes to RAR representation and validate them against the requested scopes in the AuthorizationEndpointChecker Parse scopes as RAR representation and add the created context on the different cache models in order to store the state and make it available for mappers in the ClientSessionContext Create a new AuthorizationRequestSpi to provide different implementations for either dynamic scopes or RAR requests parsing Move the AuthorizationRequest objects to server-spi Add the AuthorizationRequestContext property to the MapAuthenticationSessionEntity and configure MapAuthenticationSessionAdapter to access it Remove the AuthorizationRequestContext object from the cache adapters and entities and instead recalculate the RAR representations from scopes every time Refactor the way we parse dynamic scopes and put everything behind the DYNAMIC_SCOPES feature flag Added a login test and added a function to get the requested client scopes, including the dynamic one, behind a feature flag Add a new filter to the Access Token dynamic scopes to avoid adding scopes that are not permitted for a user Add tests around Dynamic Scopes: replaying existing tests while enabling the DYNAMIC_SCOPES feature and adding a few more Test how the server genereates the AuthorizationDetails object Fix formatting, move classes to better packages and fix parent test class by making it Abstract Match Dynamic scopes to Optional scopes only and fix tests Avoid running these tests on remote auth servers --- ...uthorizationDetailsJSONRepresentation.java | 152 +++++++++++ .../keycloak/models/ClientSessionContext.java | 4 + .../keycloak/rar/AuthorizationDetails.java | 94 +++++++ .../rar/AuthorizationRequestContext.java | 49 ++++ .../rar/AuthorizationRequestSource.java | 25 ++ .../keycloak/protocol/oidc/TokenManager.java | 35 +++ .../AuthorizationEndpointChecker.java | 9 +- .../request/AuthorizationEndpointRequest.java | 8 + ...izationEndpointRequestParserProcessor.java | 15 ++ .../AuthorizationRequestParserProvider.java | 29 +++ ...orizationRequestParserProviderFactory.java | 24 ++ .../rar/AuthorizationRequestParserSpi.java | 46 ++++ .../IntermediaryScopeRepresentation.java | 74 ++++++ ...ClientScopeAuthorizationRequestParser.java | 150 +++++++++++ ...orizationRequestParserProviderFactory.java | 57 ++++ .../util/DefaultClientSessionContext.java | 44 ++++ ....AuthorizationRequestParserProviderFactory | 1 + .../services/org.keycloak.provider.Spi | 3 +- .../keycloak/testsuite/forms/LoginTest.java | 34 +++ .../testsuite/oidc/OIDCDynamicScopeTest.java | 243 ++++++++++++++++++ .../testsuite/rar/AbstractRARParserTest.java | 110 ++++++++ .../AuthorizationRequestContextHolder.java | 109 ++++++++ .../rar/DynamicScopesRARParseTest.java | 170 ++++++++++++ 23 files changed, 1483 insertions(+), 2 deletions(-) create mode 100644 core/src/main/java/org/keycloak/representations/AuthorizationDetailsJSONRepresentation.java create mode 100644 server-spi/src/main/java/org/keycloak/rar/AuthorizationDetails.java create mode 100644 server-spi/src/main/java/org/keycloak/rar/AuthorizationRequestContext.java create mode 100644 server-spi/src/main/java/org/keycloak/rar/AuthorizationRequestSource.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/rar/AuthorizationRequestParserProvider.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/rar/AuthorizationRequestParserProviderFactory.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/rar/AuthorizationRequestParserSpi.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/rar/model/IntermediaryScopeRepresentation.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/rar/parsers/ClientScopeAuthorizationRequestParser.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/rar/parsers/ClientScopeAuthorizationRequestParserProviderFactory.java create mode 100644 services/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.rar.AuthorizationRequestParserProviderFactory create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/OIDCDynamicScopeTest.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/rar/AbstractRARParserTest.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/rar/AuthorizationRequestContextHolder.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/rar/DynamicScopesRARParseTest.java diff --git a/core/src/main/java/org/keycloak/representations/AuthorizationDetailsJSONRepresentation.java b/core/src/main/java/org/keycloak/representations/AuthorizationDetailsJSONRepresentation.java new file mode 100644 index 0000000000..de8a96344a --- /dev/null +++ b/core/src/main/java/org/keycloak/representations/AuthorizationDetailsJSONRepresentation.java @@ -0,0 +1,152 @@ +/* + * Copyright 2022 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.representations; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.io.Serializable; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * The JSON representation of a Rich Authorization Request's "authorization_details" object. + * + * @author Daniel Gozalo + * @see {@link Request parameter "authorization_details"} + */ +public class AuthorizationDetailsJSONRepresentation implements Serializable { + + // The internal Keycloak's type for static scopes as a RAR request object + public static final String STATIC_SCOPE_RAR_TYPE = "https://keycloak.org/auth-type/static-oauth2-scope"; + + // The internal Keycloak's type for dynamic scopes as a RAR request object + public static final String DYNAMIC_SCOPE_RAR_TYPE = "https://keycloak.org/auth-type/dynamic-oauth2-scope"; + + @JsonProperty("type") + private String type; + @JsonProperty("locations") + private List locations; + @JsonProperty("actions") + private List actions; + @JsonProperty("datatypes") + private List datatypes; + @JsonProperty("identifier") + private String identifier; + @JsonProperty("privileges") + private List privileges; + + private final Map customData = new HashMap<>(); + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public List getLocations() { + return locations; + } + + public void setLocations(List locations) { + this.locations = locations; + } + + public List getActions() { + return actions; + } + + public void setActions(List actions) { + this.actions = actions; + } + + public List getDatatypes() { + return datatypes; + } + + public void setDatatypes(List datatypes) { + this.datatypes = datatypes; + } + + public String getIdentifier() { + return identifier; + } + + public void setIdentifier(String identifier) { + this.identifier = identifier; + } + + public List getPrivileges() { + return privileges; + } + + public void setPrivileges(List privileges) { + this.privileges = privileges; + } + + @JsonAnyGetter + public Map getCustomData() { + return customData; + } + + @JsonAnySetter + public void setCustomData(String key, Object value) { + this.customData.put(key, value); + } + + @Override + public String toString() { + return "AuthorizationDetailsJSONRepresentation{" + + "type='" + type + '\'' + + ", locations=" + locations + + ", actions=" + actions + + ", datatypes=" + datatypes + + ", identifier='" + identifier + '\'' + + ", privileges=" + privileges + + ", customData=" + customData + + '}'; + } + + public String getScopeNameFromCustomData() { + if (this.getType().equalsIgnoreCase(DYNAMIC_SCOPE_RAR_TYPE) || this.getType().equalsIgnoreCase(STATIC_SCOPE_RAR_TYPE)) { + List accessList = (List) this.customData.get("access"); + if (accessList.isEmpty()) { + throw new RuntimeException("A RAR Scope representation should never have an empty access property"); + } + return accessList.get(0); + } + return null; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + AuthorizationDetailsJSONRepresentation that = (AuthorizationDetailsJSONRepresentation) o; + return Objects.equals(type, that.type) && Objects.equals(locations, that.locations) && Objects.equals(actions, that.actions) && Objects.equals(datatypes, that.datatypes) && Objects.equals(identifier, that.identifier) && Objects.equals(privileges, that.privileges) && Objects.equals(customData, that.customData); + } + + @Override + public int hashCode() { + return Objects.hash(type, locations, actions, datatypes, identifier, privileges, customData); + } +} diff --git a/server-spi/src/main/java/org/keycloak/models/ClientSessionContext.java b/server-spi/src/main/java/org/keycloak/models/ClientSessionContext.java index c14c4c27cb..c0d60472f2 100644 --- a/server-spi/src/main/java/org/keycloak/models/ClientSessionContext.java +++ b/server-spi/src/main/java/org/keycloak/models/ClientSessionContext.java @@ -17,6 +17,8 @@ package org.keycloak.models; +import org.keycloak.rar.AuthorizationRequestContext; + import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -83,4 +85,6 @@ public interface ClientSessionContext { T getAttribute(String attribute, Class clazz); + AuthorizationRequestContext getAuthorizationRequestContext(); + } diff --git a/server-spi/src/main/java/org/keycloak/rar/AuthorizationDetails.java b/server-spi/src/main/java/org/keycloak/rar/AuthorizationDetails.java new file mode 100644 index 0000000000..d085f4d034 --- /dev/null +++ b/server-spi/src/main/java/org/keycloak/rar/AuthorizationDetails.java @@ -0,0 +1,94 @@ +/* + * Copyright 2022 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.rar; + +import org.keycloak.models.ClientScopeModel; +import org.keycloak.representations.AuthorizationDetailsJSONRepresentation; + +import java.io.Serializable; +import java.util.Objects; + +/** + * The internal Keycloak representation of a Rich Authorization Request authorization_details object, together with + * some extra metadata to make it easier to work with this data in other parts of the codebase. + * + * The {@link AuthorizationRequestSource} is needed as OAuth scopes are also parsed into AuthorizationDetails + * to standardize the way authorization data is managed in Keycloak. Scopes parsed as AuthorizationDetails will need + * to be treated as normal OAuth scopes in places like TokenMappers and included in the "scopes" JWT claim as such. + * + * @author Daniel Gozalo + */ +public class AuthorizationDetails implements Serializable { + + private ClientScopeModel clientScope; + + private AuthorizationRequestSource source; + + private AuthorizationDetailsJSONRepresentation authorizationDetails; + + public AuthorizationDetails(ClientScopeModel clientScope, AuthorizationRequestSource source, AuthorizationDetailsJSONRepresentation authorizationDetails) { + this.clientScope = clientScope; + this.source = source; + this.authorizationDetails = authorizationDetails; + } + + public ClientScopeModel getClientScope() { + return clientScope; + } + + public void setClientScope(ClientScopeModel clientScope) { + this.clientScope = clientScope; + } + + public AuthorizationRequestSource getSource() { + return source; + } + + public void setSource(AuthorizationRequestSource source) { + this.source = source; + } + + public AuthorizationDetailsJSONRepresentation getAuthorizationDetails() { + return authorizationDetails; + } + + public void setAuthorizationDetails(AuthorizationDetailsJSONRepresentation authorizationDetails) { + this.authorizationDetails = authorizationDetails; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + AuthorizationDetails that = (AuthorizationDetails) o; + return Objects.equals(clientScope, that.clientScope) && source == that.source && Objects.equals(authorizationDetails, that.authorizationDetails); + } + + @Override + public int hashCode() { + return Objects.hash(clientScope, source, authorizationDetails); + } + + @Override + public String toString() { + return "AuthorizationDetails{" + + "clientScope=" + clientScope + + ", source=" + source + + ", authorizationDetails=" + authorizationDetails + + '}'; + } +} diff --git a/server-spi/src/main/java/org/keycloak/rar/AuthorizationRequestContext.java b/server-spi/src/main/java/org/keycloak/rar/AuthorizationRequestContext.java new file mode 100644 index 0000000000..e53348f41e --- /dev/null +++ b/server-spi/src/main/java/org/keycloak/rar/AuthorizationRequestContext.java @@ -0,0 +1,49 @@ +/* + * Copyright 2022 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.rar; + +import java.util.List; + +/** + * This context object will contain all parsed Rich Authorization Request objects, together with the internal representation + * that Keycloak is going to use for Scopes. + * + * @author Daniel Gozalo + * @see {@link Rich Authorization Requests} + *

+ * These {@link AuthorizationDetails} objects will become a standard way to store OAuth authorization information to be used + * for different purposes such as TokenMappers, Consents etc. + *

+ * This context will never be stored in a database or cached, and it will instead be generated every time it's needed to avoid + * straining the cache replication mechanisms as it may get significantly big. + */ +public class AuthorizationRequestContext { + + List authorizationDetailEntries; + + public AuthorizationRequestContext(List authorizationDetailEntries) { + this.authorizationDetailEntries = authorizationDetailEntries; + } + + public List getAuthorizationDetailEntries() { + return authorizationDetailEntries; + } + + public void setAuthorizationDetailEntries(List authorizationDetailEntries) { + this.authorizationDetailEntries = authorizationDetailEntries; + } +} diff --git a/server-spi/src/main/java/org/keycloak/rar/AuthorizationRequestSource.java b/server-spi/src/main/java/org/keycloak/rar/AuthorizationRequestSource.java new file mode 100644 index 0000000000..ac32c24370 --- /dev/null +++ b/server-spi/src/main/java/org/keycloak/rar/AuthorizationRequestSource.java @@ -0,0 +1,25 @@ +/* + * Copyright 2022 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.rar; + +/** + * @author Daniel Gozalo + */ +public enum AuthorizationRequestSource { + SCOPE, + AUTHORIZATION_DETAILS +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java index 616156caaa..2d9588dd03 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java @@ -59,6 +59,9 @@ import org.keycloak.protocol.oidc.mappers.OIDCAccessTokenMapper; import org.keycloak.protocol.oidc.mappers.OIDCAccessTokenResponseMapper; import org.keycloak.protocol.oidc.mappers.OIDCIDTokenMapper; import org.keycloak.protocol.oidc.mappers.UserInfoTokenMapper; +import org.keycloak.rar.AuthorizationDetails; +import org.keycloak.representations.AuthorizationDetailsJSONRepresentation; +import org.keycloak.rar.AuthorizationRequestContext; import org.keycloak.protocol.oidc.utils.AcrUtils; import org.keycloak.protocol.oidc.utils.OIDCResponseType; import org.keycloak.representations.AccessToken; @@ -632,6 +635,38 @@ public class TokenManager { return Stream.concat(parseScopeParameter(scopeParam).map(allOptionalScopes::get).filter(Objects::nonNull), clientScopes).distinct(); } + + /** + * Check that all the ClientScopes that have been parsed into authorization_resources are actually in the requested scopes + * otherwise, the scope wasn't parsed correctly + * @param scopes + * @param authorizationRequestContext + * @param client + * @return + */ + public static boolean isValidScope(String scopes, AuthorizationRequestContext authorizationRequestContext, ClientModel client) { + if (authorizationRequestContext.getAuthorizationDetailEntries() == null || authorizationRequestContext.getAuthorizationDetailEntries().isEmpty()) { + return false; + } + Collection requestedScopes = TokenManager.parseScopeParameter(scopes).collect(Collectors.toSet()); + Set rarScopes = authorizationRequestContext.getAuthorizationDetailEntries() + .stream() + .map(AuthorizationDetails::getAuthorizationDetails) + .map(AuthorizationDetailsJSONRepresentation::getScopeNameFromCustomData) + .collect(Collectors.toSet()); + + if (TokenUtil.isOIDCRequest(scopes)) { + requestedScopes.remove(OAuth2Constants.SCOPE_OPENID); + } + + for (String requestedScope : requestedScopes) { + // We keep the check to the getDynamicClientScope for the OpenshiftSAClientAdapter + if (!rarScopes.contains(requestedScope) && client.getDynamicClientScope(requestedScope) == null) { + return false; + } + } + return true; + } public static boolean isValidScope(String scopes, ClientModel client) { if (scopes == null) { 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 4843b1bf4f..fed1196dd5 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 @@ -27,6 +27,7 @@ import javax.ws.rs.core.Response; import org.jboss.logging.Logger; import org.keycloak.OAuth2Constants; import org.keycloak.OAuthErrorException; +import org.keycloak.common.Profile; import org.keycloak.events.Details; import org.keycloak.events.Errors; import org.keycloak.events.EventBuilder; @@ -206,7 +207,13 @@ public class AuthorizationEndpointChecker { } public void checkValidScope() throws AuthorizationCheckException { - if (!TokenManager.isValidScope(request.getScope(), client)) { + boolean validScopes; + if (Profile.isFeatureEnabled(Profile.Feature.DYNAMIC_SCOPES)) { + validScopes = TokenManager.isValidScope(request.getScope(), request.getAuthorizationRequestContext(), client); + } else { + validScopes = TokenManager.isValidScope(request.getScope(), client); + } + if (!validScopes) { ServicesLogger.LOGGER.invalidParameter(OIDCLoginProtocol.SCOPE_PARAM); event.error(Errors.INVALID_REQUEST); throw new AuthorizationCheckException(Response.Status.BAD_REQUEST, OAuthErrorException.INVALID_SCOPE, "Invalid scopes: " + request.getScope()); diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthorizationEndpointRequest.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthorizationEndpointRequest.java index 248a20c433..dc60e0c7ef 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthorizationEndpointRequest.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthorizationEndpointRequest.java @@ -17,6 +17,8 @@ package org.keycloak.protocol.oidc.endpoints.request; +import org.keycloak.rar.AuthorizationRequestContext; + import java.util.HashMap; import java.util.Map; @@ -50,6 +52,8 @@ public class AuthorizationEndpointRequest { String acr; + AuthorizationRequestContext authorizationRequestContext; + public String getAcr() { return acr; } @@ -131,4 +135,8 @@ public class AuthorizationEndpointRequest { public String getUiLocales() { return uiLocales; } + + public AuthorizationRequestContext getAuthorizationRequestContext() { + return authorizationRequestContext; + } } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthorizationEndpointRequestParserProcessor.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthorizationEndpointRequestParserProcessor.java index 585196e88e..1ff14f1f37 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthorizationEndpointRequestParserProcessor.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthorizationEndpointRequestParserProcessor.java @@ -17,6 +17,7 @@ package org.keycloak.protocol.oidc.endpoints.request; +import org.keycloak.common.Profile; import org.keycloak.common.util.StreamUtil; import org.keycloak.connections.httpclient.HttpClientProvider; import org.keycloak.events.Errors; @@ -27,6 +28,8 @@ import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper; import org.keycloak.protocol.oidc.OIDCConfigAttributes; import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.par.endpoints.request.AuthzEndpointParParser; +import org.keycloak.protocol.oidc.rar.AuthorizationRequestParserProvider; +import org.keycloak.protocol.oidc.rar.parsers.ClientScopeAuthorizationRequestParserProviderFactory; import org.keycloak.protocol.oidc.utils.RedirectUtils; import org.keycloak.services.ErrorPageException; import org.keycloak.services.ServicesLogger; @@ -96,6 +99,18 @@ public class AuthorizationEndpointRequestParserProcessor { } } + if (Profile.isFeatureEnabled(Profile.Feature.DYNAMIC_SCOPES)) { + AuthorizationRequestParserProvider clientScopeParser = session.getProvider(AuthorizationRequestParserProvider.class, + ClientScopeAuthorizationRequestParserProviderFactory.CLIENT_SCOPE_PARSER_ID); + + if (clientScopeParser == null) { + throw new RuntimeException(String.format("No provider found for authorization requests parser %1s", + ClientScopeAuthorizationRequestParserProviderFactory.CLIENT_SCOPE_PARSER_ID)); + } + + request.authorizationRequestContext = clientScopeParser.parseScopes(request.getScope()); + } + return request; } catch (Exception e) { diff --git a/services/src/main/java/org/keycloak/protocol/oidc/rar/AuthorizationRequestParserProvider.java b/services/src/main/java/org/keycloak/protocol/oidc/rar/AuthorizationRequestParserProvider.java new file mode 100644 index 0000000000..d738e331ac --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/rar/AuthorizationRequestParserProvider.java @@ -0,0 +1,29 @@ +/* + * Copyright 2022 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.protocol.oidc.rar; + +import org.keycloak.provider.Provider; +import org.keycloak.rar.AuthorizationRequestContext; + +/** + * @author Daniel Gozalo + */ +public interface AuthorizationRequestParserProvider extends Provider { + + AuthorizationRequestContext parseScopes(String scopeParam); + +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/rar/AuthorizationRequestParserProviderFactory.java b/services/src/main/java/org/keycloak/protocol/oidc/rar/AuthorizationRequestParserProviderFactory.java new file mode 100644 index 0000000000..eb2be6617e --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/rar/AuthorizationRequestParserProviderFactory.java @@ -0,0 +1,24 @@ +/* + * Copyright 2022 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.protocol.oidc.rar; + +import org.keycloak.provider.ProviderFactory; + +/** + * @author Daniel Gozalo + */ +public interface AuthorizationRequestParserProviderFactory extends ProviderFactory { } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/rar/AuthorizationRequestParserSpi.java b/services/src/main/java/org/keycloak/protocol/oidc/rar/AuthorizationRequestParserSpi.java new file mode 100644 index 0000000000..2a43b9ff45 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/rar/AuthorizationRequestParserSpi.java @@ -0,0 +1,46 @@ +/* + * Copyright 2022 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.protocol.oidc.rar; + +import org.keycloak.provider.Provider; +import org.keycloak.provider.ProviderFactory; +import org.keycloak.provider.Spi; + +/** + * @author Daniel Gozalo + */ +public class AuthorizationRequestParserSpi implements Spi { + @Override + public boolean isInternal() { + return true; + } + + @Override + public String getName() { + return "authorization-request-parser"; + } + + @Override + public Class getProviderClass() { + return AuthorizationRequestParserProvider.class; + } + + @Override + public Class getProviderFactoryClass() { + return AuthorizationRequestParserProviderFactory.class; + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/rar/model/IntermediaryScopeRepresentation.java b/services/src/main/java/org/keycloak/protocol/oidc/rar/model/IntermediaryScopeRepresentation.java new file mode 100644 index 0000000000..c2b74b3724 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/rar/model/IntermediaryScopeRepresentation.java @@ -0,0 +1,74 @@ +/* + * Copyright 2022 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.protocol.oidc.rar.model; + +import org.keycloak.models.ClientScopeModel; + +import java.util.Objects; + +/** + * @author Daniel Gozalo + */ +public class IntermediaryScopeRepresentation { + final private ClientScopeModel scope; + final private String requestedScopeString; + final private String parameter; + final private boolean isDynamic; + + public IntermediaryScopeRepresentation(ClientScopeModel scope, String parameter, String requestedScopeString) { + this.scope = scope; + this.parameter = parameter; + this.isDynamic = scope.isDynamicScope(); + this.requestedScopeString = requestedScopeString; + } + + public IntermediaryScopeRepresentation(ClientScopeModel scope) { + this.scope = scope; + this.isDynamic = false; + this.parameter = null; + this.requestedScopeString = scope.getName(); + } + + public ClientScopeModel getScope() { + return scope; + } + + public String getParameter() { + return parameter; + } + + public boolean isDynamic() { + return isDynamic; + } + + public String getRequestedScopeString() { + return requestedScopeString; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + IntermediaryScopeRepresentation that = (IntermediaryScopeRepresentation) o; + return isDynamic == that.isDynamic && Objects.equals(scope.getName(), that.scope.getName()) && Objects.equals(parameter, that.parameter); + } + + @Override + public int hashCode() { + return Objects.hash(scope.getName(), parameter, isDynamic); + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/rar/parsers/ClientScopeAuthorizationRequestParser.java b/services/src/main/java/org/keycloak/protocol/oidc/rar/parsers/ClientScopeAuthorizationRequestParser.java new file mode 100644 index 0000000000..0a007e45b4 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/rar/parsers/ClientScopeAuthorizationRequestParser.java @@ -0,0 +1,150 @@ +/* + * Copyright 2022 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.protocol.oidc.rar.parsers; + +import org.keycloak.models.ClientModel; +import org.keycloak.models.ClientScopeModel; +import org.keycloak.protocol.oidc.TokenManager; +import org.keycloak.protocol.oidc.rar.AuthorizationRequestParserProvider; +import org.keycloak.rar.AuthorizationRequestContext; +import org.keycloak.protocol.oidc.rar.model.IntermediaryScopeRepresentation; +import org.keycloak.rar.AuthorizationDetails; +import org.keycloak.representations.AuthorizationDetailsJSONRepresentation; +import org.keycloak.rar.AuthorizationRequestSource; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.function.Predicate; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import static org.keycloak.representations.AuthorizationDetailsJSONRepresentation.DYNAMIC_SCOPE_RAR_TYPE; +import static org.keycloak.representations.AuthorizationDetailsJSONRepresentation.STATIC_SCOPE_RAR_TYPE; + +/** + * @author Daniel Gozalo + */ +public class ClientScopeAuthorizationRequestParser implements AuthorizationRequestParserProvider { + + /** + * This parser will be created on a per-request basis. When the adapter is created, the request's client is passed + * as a parameter + */ + private final ClientModel client; + + public ClientScopeAuthorizationRequestParser(ClientModel client) { + this.client = client; + } + + /** + * Creates a {@link AuthorizationRequestContext} with a list of {@link AuthorizationDetails} that will be parsed from + * the provided OAuth scopes that have been requested in a given Auth request, together with default client scopes. + *

+ * Dynamic scopes will also be parsed with the extracted parameter, so it can be used later + * + * @param scopeParam the OAuth scope param for the current request + * @return see description + */ + @Override + public AuthorizationRequestContext parseScopes(String scopeParam) { + // Process all the default ClientScopeModels for the current client, and maps them to the DynamicScopeRepresentation to make use of a HashSet + Set clientScopeModelSet = this.client.getClientScopes(true).values().stream() + .filter(clientScopeModel -> !clientScopeModel.isDynamicScope()) // not strictly needed as Dynamic Scopes are going to be Optional scopes for now + .map(IntermediaryScopeRepresentation::new) + .collect(Collectors.toSet()); + + Set intermediaryScopeRepresentations = new HashSet<>(); + if (scopeParam != null) { + // Go through the parsed requested scopes and attempt to match them against the optional scopes list + intermediaryScopeRepresentations = TokenManager.parseScopeParameter(scopeParam).collect(Collectors.toSet()).stream() + .map((String requestScope) -> getMatchingClientScope(requestScope, this.client.getClientScopes(false).values())) + .filter(Optional::isPresent) + .map(Optional::get) + .collect(Collectors.toSet()); + } + + // merge both sets, avoiding duplicates + intermediaryScopeRepresentations.addAll(clientScopeModelSet); + + // Map the intermediary scope representations into the final AuthorizationDetails representation to be included into the RAR context + List authorizationDetails = intermediaryScopeRepresentations.stream() + .map(this::buildAuthorizationDetailsJSONRepresentation) + .collect(Collectors.toList()); + + return new AuthorizationRequestContext(authorizationDetails); + + } + + /** + * From a {@link IntermediaryScopeRepresentation}, create an {@link AuthorizationDetails} object that serves as the representation of a + * ClientScope inside a Rich Authorization Request object + * + * @param intermediaryScopeRepresentation the intermediary scope representation to be included into the RAR request object + * @return see description + */ + private AuthorizationDetails buildAuthorizationDetailsJSONRepresentation(IntermediaryScopeRepresentation intermediaryScopeRepresentation) { + AuthorizationDetailsJSONRepresentation representation = new AuthorizationDetailsJSONRepresentation(); + representation.setCustomData("access", Collections.singletonList(intermediaryScopeRepresentation.getRequestedScopeString())); + representation.setType(STATIC_SCOPE_RAR_TYPE); + if (intermediaryScopeRepresentation.isDynamic() && intermediaryScopeRepresentation.getParameter() != null) { + representation.setType(DYNAMIC_SCOPE_RAR_TYPE); + representation.setCustomData("scope_parameter", intermediaryScopeRepresentation.getParameter()); + } + return new AuthorizationDetails(intermediaryScopeRepresentation.getScope(), AuthorizationRequestSource.SCOPE, representation); + } + + /** + * Gets one of the requested OAuth scopes and obtains the list of all the optional client scope models for the current client and searches whether + * there is a match. + * Dynamic scopes are matching using the registered Regexp, while static scopes are matched by name. + * It returns an Optional of a {@link IntermediaryScopeRepresentation} with either a static scope datra, a dynamic scope data or an empty Optional + * if there was no match for the regexp. + * + * @param requestScope one of the requested OAuth scopes + * @return see description + */ + private Optional getMatchingClientScope(String requestScope, Collection optionalScopes) { + for (ClientScopeModel clientScopeModel : optionalScopes) { + if (clientScopeModel.isDynamicScope()) { + // The regexp has been stored without a capture group to simplify how it's shown to the user, need to transform it now + // to capture the parameter value + Pattern p = Pattern.compile(clientScopeModel.getDynamicScopeRegexp().replace("*", "(.*)")); + Matcher m = p.matcher(requestScope); + if (m.matches()) { + return Optional.of(new IntermediaryScopeRepresentation(clientScopeModel, m.group(1), requestScope)); + } + } else { + if (requestScope.equalsIgnoreCase(clientScopeModel.getName())) { + return Optional.of(new IntermediaryScopeRepresentation(clientScopeModel)); + } + } + } + // Nothing matched, returning an empty Optional to avoid working with Nulls + return Optional.empty(); + } + + @Override + public void close() { + + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/rar/parsers/ClientScopeAuthorizationRequestParserProviderFactory.java b/services/src/main/java/org/keycloak/protocol/oidc/rar/parsers/ClientScopeAuthorizationRequestParserProviderFactory.java new file mode 100644 index 0000000000..304fa165be --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/rar/parsers/ClientScopeAuthorizationRequestParserProviderFactory.java @@ -0,0 +1,57 @@ +/* + * Copyright 2022 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.protocol.oidc.rar.parsers; + +import org.keycloak.Config; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.protocol.oidc.rar.AuthorizationRequestParserProvider; +import org.keycloak.protocol.oidc.rar.AuthorizationRequestParserProviderFactory; +import org.keycloak.protocol.oidc.rar.parsers.ClientScopeAuthorizationRequestParser; + +/** + * @author Daniel Gozalo + */ +public class ClientScopeAuthorizationRequestParserProviderFactory implements AuthorizationRequestParserProviderFactory { + + public static final String CLIENT_SCOPE_PARSER_ID = "client-scope"; + + @Override + public AuthorizationRequestParserProvider create(KeycloakSession session) { + return new ClientScopeAuthorizationRequestParser(session.getContext().getClient()); + } + + @Override + public void init(Config.Scope config) { + + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + + } + + @Override + public void close() { + + } + + @Override + public String getId() { + return CLIENT_SCOPE_PARSER_ID; + } +} diff --git a/services/src/main/java/org/keycloak/services/util/DefaultClientSessionContext.java b/services/src/main/java/org/keycloak/services/util/DefaultClientSessionContext.java index b961b5d7c2..44df62e833 100644 --- a/services/src/main/java/org/keycloak/services/util/DefaultClientSessionContext.java +++ b/services/src/main/java/org/keycloak/services/util/DefaultClientSessionContext.java @@ -28,6 +28,7 @@ import java.util.stream.Stream; import org.jboss.logging.Logger; import org.keycloak.OAuth2Constants; +import org.keycloak.common.Profile; import org.keycloak.models.AuthenticatedClientSessionModel; import org.keycloak.models.ClientModel; import org.keycloak.models.ClientScopeModel; @@ -41,6 +42,10 @@ import org.keycloak.models.utils.RoleUtils; import org.keycloak.protocol.ProtocolMapperUtils; import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.TokenManager; +import org.keycloak.protocol.oidc.rar.AuthorizationRequestParserProvider; +import org.keycloak.protocol.oidc.rar.parsers.ClientScopeAuthorizationRequestParserProviderFactory; +import org.keycloak.rar.AuthorizationRequestContext; +import org.keycloak.rar.AuthorizationRequestSource; import org.keycloak.util.TokenUtil; /** @@ -154,6 +159,14 @@ public class DefaultClientSessionContext implements ClientSessionContext { @Override public String getScopeString() { + if (Profile.isFeatureEnabled(Profile.Feature.DYNAMIC_SCOPES)) { + String scopeParam = buildScopesStringFromAuthorizationRequest(); + String scopeSent = clientSession.getNote(OAuth2Constants.SCOPE); + if (TokenUtil.isOIDCRequest(scopeSent)) { + scopeParam = TokenUtil.attachOIDCScope(scopeParam); + } + return scopeParam; + } // Add both default and optional scopes to scope parameter. Don't add client itself String scopeParam = getClientScopesStream() .filter(((Predicate) ClientModel.class::isInstance).negate()) @@ -170,6 +183,22 @@ public class DefaultClientSessionContext implements ClientSessionContext { return scopeParam; } + /** + * Get all the scopes from the {@link AuthorizationRequestContext} by filtering entries by Source and by whether + * they should be included in tokens or not. + * Then return the scope name from the data stored in the RAR object representation. + * + * @return see description + */ + private String buildScopesStringFromAuthorizationRequest() { + return this.getAuthorizationRequestContext().getAuthorizationDetailEntries().stream() + .filter(authorizationDetails -> authorizationDetails.getSource().equals(AuthorizationRequestSource.SCOPE)) + .filter(authorizationDetails -> authorizationDetails.getClientScope().isIncludeInTokenScope()) + .filter(authorizationDetails -> isClientScopePermittedForUser(authorizationDetails.getClientScope())) + .map(authorizationDetails -> authorizationDetails.getAuthorizationDetails().getScopeNameFromCustomData()) + .collect(Collectors.joining(" ")); + } + @Override public void setAttribute(String name, Object value) { @@ -183,6 +212,21 @@ public class DefaultClientSessionContext implements ClientSessionContext { return clazz.cast(value); } + @Override + public AuthorizationRequestContext getAuthorizationRequestContext() { + if (!Profile.isFeatureEnabled(Profile.Feature.DYNAMIC_SCOPES)) { + throw new RuntimeException("The Dynamic Scopes feature is not enabled and the AuthorizationRequestContext hasn't been generated"); + } + AuthorizationRequestParserProvider clientScopeParser = session.getProvider(AuthorizationRequestParserProvider.class, + ClientScopeAuthorizationRequestParserProviderFactory.CLIENT_SCOPE_PARSER_ID); + + if (clientScopeParser == null) { + throw new RuntimeException(String.format("No provider found for authorization requests parser %1s", + ClientScopeAuthorizationRequestParserProviderFactory.CLIENT_SCOPE_PARSER_ID)); + } + + return clientScopeParser.parseScopes(clientSession.getNote(OAuth2Constants.SCOPE)); + } // Loading data diff --git a/services/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.rar.AuthorizationRequestParserProviderFactory b/services/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.rar.AuthorizationRequestParserProviderFactory new file mode 100644 index 0000000000..76a651e70a --- /dev/null +++ b/services/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.rar.AuthorizationRequestParserProviderFactory @@ -0,0 +1 @@ +org.keycloak.protocol.oidc.rar.parsers.ClientScopeAuthorizationRequestParserProviderFactory \ No newline at end of file diff --git a/services/src/main/resources/META-INF/services/org.keycloak.provider.Spi b/services/src/main/resources/META-INF/services/org.keycloak.provider.Spi index 88a33c6cec..703057b3e9 100755 --- a/services/src/main/resources/META-INF/services/org.keycloak.provider.Spi +++ b/services/src/main/resources/META-INF/services/org.keycloak.provider.Spi @@ -25,4 +25,5 @@ org.keycloak.protocol.oidc.ext.OIDCExtSPI org.keycloak.protocol.saml.preprocessor.SamlAuthenticationPreprocessorSpi org.keycloak.encoding.ResourceEncodingSpi org.keycloak.protocol.oidc.grants.ciba.channel.AuthenticationChannelSpi -org.keycloak.protocol.oidc.grants.ciba.resolvers.CIBALoginUserResolverSpi \ No newline at end of file +org.keycloak.protocol.oidc.grants.ciba.resolvers.CIBALoginUserResolverSpi +org.keycloak.protocol.oidc.rar.AuthorizationRequestParserSpi \ No newline at end of file diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/LoginTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/LoginTest.java index 7e4bd441a6..e21663475e 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/LoginTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/LoginTest.java @@ -22,6 +22,7 @@ import org.junit.Assert; import org.junit.Rule; import org.junit.Test; import org.keycloak.OAuth2Constants; +import org.keycloak.admin.client.resource.ClientResource; import org.keycloak.admin.client.resource.UserResource; import org.keycloak.common.Profile; import org.keycloak.common.util.Retry; @@ -32,17 +33,22 @@ import org.keycloak.events.EventType; import org.keycloak.jose.jws.JWSInput; import org.keycloak.jose.jws.JWSInputException; import org.keycloak.models.BrowserSecurityHeaders; +import org.keycloak.models.ClientScopeModel; import org.keycloak.models.utils.SessionTimeoutHelper; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.OIDCLoginProtocolService; import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.idm.ClientScopeRepresentation; import org.keycloak.representations.idm.EventRepresentation; import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.testsuite.AssertEvents; import org.keycloak.testsuite.AbstractTestRealmKeycloakTest; +import org.keycloak.testsuite.ProfileAssume; import org.keycloak.testsuite.admin.ApiUtil; import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude; import org.keycloak.testsuite.arquillian.annotation.DisableFeature; +import org.keycloak.testsuite.arquillian.annotation.EnableFeature; import org.keycloak.testsuite.console.page.AdminConsole; import org.keycloak.testsuite.pages.AccountUpdateProfilePage; import org.keycloak.testsuite.pages.AppPage; @@ -81,6 +87,8 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertTrue; +import static org.keycloak.common.Profile.Feature.AUTHORIZATION; +import static org.keycloak.common.Profile.Feature.DYNAMIC_SCOPES; import static org.keycloak.testsuite.admin.ApiUtil.findClientByClientId; import static org.keycloak.testsuite.util.OAuthClient.AUTH_SERVER_ROOT; import static org.keycloak.testsuite.util.OAuthClient.SERVER_ROOT; @@ -954,4 +962,30 @@ public class LoginTest extends AbstractTestRealmKeycloakTest { } } + @Test + @EnableFeature(value = Profile.Feature.DYNAMIC_SCOPES, skipRestart = true) + public void loginSuccessfulWithDynamicScope() { + ProfileAssume.assumeFeatureEnabled(DYNAMIC_SCOPES); + ClientScopeRepresentation clientScope = new ClientScopeRepresentation(); + clientScope.setName("dynamic"); + clientScope.setAttributes(new HashMap() {{ + put(ClientScopeModel.IS_DYNAMIC_SCOPE, "true"); + put(ClientScopeModel.DYNAMIC_SCOPE_REGEXP, "dynamic:*"); + }}); + clientScope.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); + Response response = testRealm().clientScopes().create(clientScope); + String scopeId = ApiUtil.getCreatedId(response); + getCleanup().addClientScopeId(scopeId); + response.close(); + + ClientResource testApp = ApiUtil.findClientByClientId(testRealm(), "test-app"); + ClientRepresentation testAppRep = testApp.toRepresentation(); + testApp.update(testAppRep); + testApp.addOptionalClientScope(scopeId); + + oauth.scope("dynamic:scope"); + oauth.doLogin("login@test.com", "password"); + events.expectLogin().user(userId).assertEvent(); + } + } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/OIDCDynamicScopeTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/OIDCDynamicScopeTest.java new file mode 100644 index 0000000000..f273689e69 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/OIDCDynamicScopeTest.java @@ -0,0 +1,243 @@ +/* + * Copyright 2022 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package org.keycloak.testsuite.oidc; + +import org.junit.Before; +import org.junit.Test; +import org.keycloak.admin.client.resource.ClientResource; +import org.keycloak.common.Profile; +import org.keycloak.events.Details; +import org.keycloak.models.ClientScopeModel; +import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.idm.ClientScopeRepresentation; +import org.keycloak.representations.idm.EventRepresentation; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.representations.idm.RoleRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.testsuite.Assert; +import org.keycloak.testsuite.ProfileAssume; +import org.keycloak.testsuite.admin.ApiUtil; +import org.keycloak.testsuite.arquillian.annotation.EnableFeature; +import org.keycloak.testsuite.util.UserBuilder; + +import javax.ws.rs.core.Response; +import java.util.HashMap; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import static org.keycloak.common.Profile.Feature.DYNAMIC_SCOPES; + + +/** + * Extend another tests class {@link OIDCScopeTest} in order to repeat all the tests but with DYNAMIC_SCOPES enabled + * to make sure that retro compatibility is maintained when the feature is enabled. + * + * @author Daniel Gozalo + */ +@EnableFeature(value = Profile.Feature.DYNAMIC_SCOPES, skipRestart = true) +public class OIDCDynamicScopeTest extends OIDCScopeTest { + + private static String userId = KeycloakModelUtils.generateId(); + + @Override + public void configureTestRealm(RealmRepresentation testRealm) { + super.configureTestRealm(testRealm); + UserRepresentation user = UserBuilder.create() + .id(userId) + .username("johnDynamic") + .enabled(true) + .email("johnDynamic@scopes.xyz") + .firstName("John") + .lastName("Dynamic") + .password("password") + .addRoles("dynamic-scope-role") + .build(); + testRealm.getUsers().add(user); + + user = UserBuilder.create() + .username("JohnNormal") + .enabled(true) + .password("password") + .addRoles("role-1") + .build(); + testRealm.getUsers().add(user); + + // Add sample realm roles + RoleRepresentation dynamicScopeRole = new RoleRepresentation(); + dynamicScopeRole.setName("dynamic-scope-role"); + testRealm.getRoles().getRealm().add(dynamicScopeRole); + } + + @Before + public void assertDynamicScopesFeatureEnabled() { + ProfileAssume.assumeFeatureEnabled(DYNAMIC_SCOPES); + } + + @Override + public void testBuiltinOptionalScopes() throws Exception { + super.testBuiltinOptionalScopes(); + } + + @Override + public void testRemoveScopes() throws Exception { + super.testRemoveScopes(); + } + + @Override + public void testClientScopesPermissions() { + super.testClientScopesPermissions(); + } + + @Override + public void testClientDisplayedOnConsentScreen() throws Exception { + super.testClientDisplayedOnConsentScreen(); + } + + @Override + public void testClientDisplayedOnConsentScreenWithEmptyConsentText() throws Exception { + super.testClientDisplayedOnConsentScreenWithEmptyConsentText(); + } + + @Override + public void testOptionalScopesWithConsentRequired() throws Exception { + super.testOptionalScopesWithConsentRequired(); + } + + @Override + public void testRefreshTokenWithConsentRequired() { + super.testRefreshTokenWithConsentRequired(); + } + + @Override + public void testTwoRefreshTokensWithDifferentScopes() { + super.testTwoRefreshTokensWithDifferentScopes(); + } + + @Test + public void testGetAccessTokenWithDynamicScope() { + Response response = createDynamicScope("dynamic"); + String scopeId = ApiUtil.getCreatedId(response); + getCleanup().addClientScopeId(scopeId); + response.close(); + + ClientResource testApp = ApiUtil.findClientByClientId(testRealm(), "test-app"); + ClientRepresentation testAppRep = testApp.toRepresentation(); + testApp.update(testAppRep); + testApp.addOptionalClientScope(scopeId); + + oauth.scope("dynamic:scope"); + testLoginAndClientScopesPermissions("johnNormal", "dynamic:scope", "role-1"); + + //cleanup + testApp.removeOptionalClientScope(scopeId); + } + + @Test + public void testGetAccessTokenWithDynamicScopeWithPermittedRoleScope() { + Response response = createDynamicScope("dynamic"); + String scopeId = ApiUtil.getCreatedId(response); + getCleanup().addClientScopeId(scopeId); + response.close(); + + List dynamicScopeRoleList = testRealm().roles().list().stream() + .filter(roleRepresentation -> "dynamic-scope-role".equalsIgnoreCase(roleRepresentation.getName())) + .collect(Collectors.toList()); + + testRealm().clientScopes().get(scopeId).getScopeMappings().realmLevel().add(dynamicScopeRoleList); + + ClientResource testApp = ApiUtil.findClientByClientId(testRealm(), "test-app"); + ClientRepresentation testAppRep = testApp.toRepresentation(); + testApp.update(testAppRep); + testApp.addOptionalClientScope(scopeId); + + oauth.scope("dynamic:scope"); + testLoginAndClientScopesPermissions("johnDynamic", "dynamic:scope", "dynamic-scope-role"); + + //cleanup + testApp.removeOptionalClientScope(scopeId); + } + + @Test + public void testGetAccessTokenMissingRoleScopedDynamicScope() { + Response response = createDynamicScope("dynamic"); + String scopeId = ApiUtil.getCreatedId(response); + getCleanup().addClientScopeId(scopeId); + response.close(); + + List dynamicScopeRoleList = testRealm().roles().list().stream() + .filter(roleRepresentation -> "dynamic-scope-role".equalsIgnoreCase(roleRepresentation.getName())) + .collect(Collectors.toList()); + + testRealm().clientScopes().get(scopeId).getScopeMappings().realmLevel().add(dynamicScopeRoleList); + + ClientResource testApp = ApiUtil.findClientByClientId(testRealm(), "test-app"); + ClientRepresentation testAppRep = testApp.toRepresentation(); + testApp.update(testAppRep); + testApp.addOptionalClientScope(scopeId); + + oauth.scope("dynamic:scope"); + // almost the same test as before, but now with a user that doesn't have the Role scoped dynamic scope attached + testLoginAndClientScopesPermissions("johnNormal", "", "role-1"); + + //cleanup + testApp.removeOptionalClientScope(scopeId); + } + + + private Response createDynamicScope(String scopeName) { + ClientScopeRepresentation clientScope = new ClientScopeRepresentation(); + clientScope.setName(scopeName); + clientScope.setAttributes(new HashMap() {{ + put(ClientScopeModel.IS_DYNAMIC_SCOPE, "true"); + put(ClientScopeModel.DYNAMIC_SCOPE_REGEXP, String.format("%1s:*", scopeName)); + }}); + clientScope.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); + return testRealm().clientScopes().create(clientScope); + } + + /** + * Copying the same method from {@link OIDCScopeTest} to avoid a change in that test class to affect this one + * + * @param username + * @param expectedRoleScopes + * @param expectedRoles + */ + private void testLoginAndClientScopesPermissions(String username, String expectedRoleScopes, String... expectedRoles) { + String userId = ApiUtil.findUserByUsername(testRealm(), username).getId(); + + oauth.openLoginForm(); + oauth.doLogin(username, "password"); + EventRepresentation loginEvent = events.expectLogin() + .user(userId) + .assertEvent(); + + Tokens tokens = sendTokenRequest(loginEvent, userId, "openid email profile " + expectedRoleScopes, "test-app"); + Assert.assertNames(tokens.accessToken.getRealmAccess().getRoles(), expectedRoles); + + oauth.doLogout(tokens.refreshToken, "password"); + events.expectLogout(tokens.idToken.getSessionState()) + .client("test-app") + .user(userId) + .removeDetail(Details.REDIRECT_URI).assertEvent(); + } + + +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/rar/AbstractRARParserTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/rar/AbstractRARParserTest.java new file mode 100644 index 0000000000..5ce7dad399 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/rar/AbstractRARParserTest.java @@ -0,0 +1,110 @@ +/* + * Copyright 2022 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package org.keycloak.testsuite.rar; + +import org.junit.Before; +import org.junit.Rule; +import org.keycloak.common.Profile; +import org.keycloak.models.AuthenticatedClientSessionModel; +import org.keycloak.models.ClientModel; +import org.keycloak.models.ClientSessionContext; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.UserSessionModel; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.services.util.DefaultClientSessionContext; +import org.keycloak.testsuite.AbstractTestRealmKeycloakTest; +import org.keycloak.testsuite.AssertEvents; +import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude; +import org.keycloak.testsuite.arquillian.annotation.EnableFeature; +import org.keycloak.testsuite.util.ClientManager; +import org.keycloak.testsuite.util.RealmBuilder; +import org.keycloak.testsuite.util.UserBuilder; + +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +import static org.junit.Assert.assertNotNull; + + +/** + * An abstract class that prepares the environment to test Dynamic Scopes (And RAR in the future) + * + * @author Daniel Gozalo + */ +@EnableFeature(value = Profile.Feature.DYNAMIC_SCOPES, skipRestart = true) +public abstract class AbstractRARParserTest extends AbstractTestRealmKeycloakTest { + + @Rule + public AssertEvents events = new AssertEvents(this); + + protected static String userId; + + @Override + public void configureTestRealm(RealmRepresentation testRealm) { + UserRepresentation user = UserBuilder.create() + .id(UUID.randomUUID().toString()) + .username("rar-test") + .email("rar@test.com") + .enabled(true) + .password("password") + .build(); + userId = user.getId(); + + RealmBuilder.edit(testRealm) + .user(user); + } + + @Before + public void clientConfiguration() { + ClientManager.realm(adminClient.realm("test")).clientId("test-app").directAccessGrant(true); + oauth.clientId("test-app"); + oauth.scope(null); + oauth.maxAge(null); + } + + /** + * Fetch the {@link org.keycloak.rar.AuthorizationRequestContext} for the current Client session from the server + * then create a local representation of the data to avoid an infinite recursion when trying to serialize the + * ClientScopeModel object. + * + * @return the {@link AuthorizationRequestContextHolder} local testsuite representation of the Authorization Request Context + * with all the parsed authorization_detail objects. + */ + protected AuthorizationRequestContextHolder fetchAuthorizationRequestContextHolder() { + AuthorizationRequestContextHolder authorizationRequestContextHolder = testingClient.server("test").fetch(session -> { + final RealmModel realm = session.realms().getRealmByName("test"); + final UserModel user = session.users().getUserById(realm, userId); + final UserSessionModel userSession = session.sessions().getUserSessionsStream(realm, user).findFirst().get(); + final ClientModel client = realm.getClientByClientId("test-app"); + String clientUUID = client.getId(); + AuthenticatedClientSessionModel clientSession = userSession.getAuthenticatedClientSessionByClient(clientUUID); + ClientSessionContext clientSessionContext = DefaultClientSessionContext.fromClientSessionScopeParameter(clientSession, session); + session.getContext().setClient(client); + List authorizationRequestHolders = clientSessionContext.getAuthorizationRequestContext().getAuthorizationDetailEntries().stream() + .map(AuthorizationRequestContextHolder.AuthorizationRequestHolder::new) + .collect(Collectors.toList()); + return new AuthorizationRequestContextHolder(authorizationRequestHolders); + }, AuthorizationRequestContextHolder.class); + assertNotNull("the fetched AuthorizationRequestContext can't be null", authorizationRequestContextHolder); + return authorizationRequestContextHolder; + } + +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/rar/AuthorizationRequestContextHolder.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/rar/AuthorizationRequestContextHolder.java new file mode 100644 index 0000000000..c637cb675f --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/rar/AuthorizationRequestContextHolder.java @@ -0,0 +1,109 @@ +/* + * Copyright 2022 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package org.keycloak.testsuite.rar; + +import org.keycloak.models.utils.ModelToRepresentation; +import org.keycloak.rar.AuthorizationDetails; +import org.keycloak.representations.AuthorizationDetailsJSONRepresentation; +import org.keycloak.rar.AuthorizationRequestSource; +import org.keycloak.representations.idm.ClientScopeRepresentation; + +import java.util.List; +import java.util.Objects; + +/** + * The local testsuite representation of a {@link org.keycloak.rar.AuthorizationRequestContext} server object + * + * @author Daniel Gozalo + */ +public class AuthorizationRequestContextHolder { + + private List authorizationRequestHolders; + + public AuthorizationRequestContextHolder() { + } + + public AuthorizationRequestContextHolder(List authorizationRequestHolders) { + this.authorizationRequestHolders = authorizationRequestHolders; + } + + public List getAuthorizationRequestHolders() { + return authorizationRequestHolders; + } + + public void setAuthorizationRequestHolders(List authorizationRequestHolders) { + this.authorizationRequestHolders = authorizationRequestHolders; + } + + /** + * The local testsuite representation of a {@link AuthorizationDetails} server object + */ + public static class AuthorizationRequestHolder { + + private ClientScopeRepresentation clientScopeRepresentation; + private AuthorizationRequestSource source; + private AuthorizationDetailsJSONRepresentation authorizationDetails; + + public AuthorizationRequestHolder() { + + } + + public AuthorizationRequestHolder(AuthorizationDetails authorizationDetails) { + this.clientScopeRepresentation = ModelToRepresentation.toRepresentation(authorizationDetails.getClientScope()); + this.source = authorizationDetails.getSource(); + this.authorizationDetails = authorizationDetails.getAuthorizationDetails(); + } + + public ClientScopeRepresentation getClientScopeRepresentation() { + return clientScopeRepresentation; + } + + public AuthorizationRequestSource getSource() { + return source; + } + + public AuthorizationDetailsJSONRepresentation getAuthorizationDetails() { + return authorizationDetails; + } + + public void setClientScopeRepresentation(ClientScopeRepresentation clientScopeRepresentation) { + this.clientScopeRepresentation = clientScopeRepresentation; + } + + public void setSource(AuthorizationRequestSource source) { + this.source = source; + } + + public void setAuthorizationDetails(AuthorizationDetailsJSONRepresentation authorizationDetails) { + this.authorizationDetails = authorizationDetails; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + AuthorizationRequestHolder that = (AuthorizationRequestHolder) o; + return Objects.equals(clientScopeRepresentation, that.clientScopeRepresentation) && source == that.source && Objects.equals(authorizationDetails, that.authorizationDetails); + } + + @Override + public int hashCode() { + return Objects.hash(clientScopeRepresentation, source, authorizationDetails); + } + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/rar/DynamicScopesRARParseTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/rar/DynamicScopesRARParseTest.java new file mode 100644 index 0000000000..bbbdacb3b7 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/rar/DynamicScopesRARParseTest.java @@ -0,0 +1,170 @@ +/* + * Copyright 2022 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package org.keycloak.testsuite.rar; + +import org.junit.Assert; +import org.junit.Ignore; +import org.junit.Test; +import org.keycloak.admin.client.resource.ClientResource; +import org.keycloak.common.Profile; +import org.keycloak.models.ClientScopeModel; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.representations.AuthorizationDetailsJSONRepresentation; +import org.keycloak.rar.AuthorizationRequestSource; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.idm.ClientScopeRepresentation; +import org.keycloak.testsuite.admin.ApiUtil; +import org.keycloak.testsuite.arquillian.annotation.EnableFeature; + +import javax.ws.rs.core.Response; +import java.util.HashMap; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import static org.junit.Assert.assertEquals; + +/** + * @author Daniel Gozalo + */ +@EnableFeature(value = Profile.Feature.DYNAMIC_SCOPES, skipRestart = true) +public class DynamicScopesRARParseTest extends AbstractRARParserTest { + + @Test + @Ignore("ignored until we figure out why it fails on Quarkus and Wildfly") + public void generatedAuthorizationRequestsShouldMatchDefaultScopes() { + ClientResource testApp = ApiUtil.findClientByClientId(testRealm(), "test-app"); + List defScopes = testApp.getDefaultClientScopes(); + oauth.openLoginForm(); + oauth.scope("openid"); + oauth.doLogin("rar-test", "password"); + events.expectLogin() + .user(userId) + .assertEvent(); + AuthorizationRequestContextHolder contextHolder = fetchAuthorizationRequestContextHolder(); + List authorizationRequestHolders = contextHolder.getAuthorizationRequestHolders().stream() + .filter(authorizationRequestHolder -> authorizationRequestHolder.getSource().equals(AuthorizationRequestSource.SCOPE)) + .collect(Collectors.toList()); + + assertEquals(defScopes.size(), authorizationRequestHolders.size()); + + assertEquals(defScopes.stream().map(ClientScopeRepresentation::getName).collect(Collectors.toSet()), + authorizationRequestHolders.stream().map(authorizationRequestHolder -> authorizationRequestHolder.getAuthorizationDetails().getScopeNameFromCustomData()) + .collect(Collectors.toSet())); + + Assert.assertTrue(authorizationRequestHolders.stream() + .map(AuthorizationRequestContextHolder.AuthorizationRequestHolder::getAuthorizationDetails) + .allMatch(rep -> rep.getType().equalsIgnoreCase(AuthorizationDetailsJSONRepresentation.STATIC_SCOPE_RAR_TYPE))); + } + + @Test + @Ignore("ignored until we figure out why it fails on Quarkus and Wildfly") + public void generatedAuthorizationRequestsShouldMatchRequestedAndDefaultScopes() { + Response response = createScope("static-scope", false); + String scopeId = ApiUtil.getCreatedId(response); + getCleanup().addClientScopeId(scopeId); + response.close(); + + ClientResource testApp = ApiUtil.findClientByClientId(testRealm(), "test-app"); + ClientRepresentation testAppRep = testApp.toRepresentation(); + testApp.update(testAppRep); + testApp.addDefaultClientScope(scopeId); + + List defScopes = testApp.getDefaultClientScopes(); + oauth.openLoginForm(); + oauth.scope("openid static-scope"); + oauth.doLogin("rar-test", "password"); + events.expectLogin() + .user(userId) + .assertEvent(); + + AuthorizationRequestContextHolder contextHolder = fetchAuthorizationRequestContextHolder(); + List authorizationRequestHolders = contextHolder.getAuthorizationRequestHolders().stream() + .filter(authorizationRequestHolder -> authorizationRequestHolder.getSource().equals(AuthorizationRequestSource.SCOPE)) + .collect(Collectors.toList()); + + assertEquals(defScopes.size(), authorizationRequestHolders.size()); + + assertEquals(defScopes.stream().map(ClientScopeRepresentation::getName).collect(Collectors.toSet()), + authorizationRequestHolders.stream().map(authorizationRequestHolder -> authorizationRequestHolder.getAuthorizationDetails().getScopeNameFromCustomData()) + .collect(Collectors.toSet())); + + Assert.assertTrue(authorizationRequestHolders.stream() + .map(AuthorizationRequestContextHolder.AuthorizationRequestHolder::getAuthorizationDetails) + .allMatch(rep -> rep.getType().equalsIgnoreCase(AuthorizationDetailsJSONRepresentation.STATIC_SCOPE_RAR_TYPE))); + + testApp.removeOptionalClientScope(scopeId); + } + + @Test + @Ignore("ignored until we figure out why it fails on Quarkus and Wildfly") + public void generatedAuthorizationRequestsShouldMatchRequestedDynamicAndDefaultScopes() { + Response response = createScope("dynamic-scope", true); + String scopeId = ApiUtil.getCreatedId(response); + getCleanup().addClientScopeId(scopeId); + response.close(); + + ClientResource testApp = ApiUtil.findClientByClientId(testRealm(), "test-app"); + ClientRepresentation testAppRep = testApp.toRepresentation(); + testApp.update(testAppRep); + testApp.addOptionalClientScope(scopeId); + + List defScopes = testApp.getDefaultClientScopes(); + oauth.openLoginForm(); + oauth.scope("openid dynamic-scope:param"); + oauth.doLogin("rar-test", "password"); + events.expectLogin() + .user(userId) + .assertEvent(); + + AuthorizationRequestContextHolder contextHolder = fetchAuthorizationRequestContextHolder(); + List authorizationRequestHolders = contextHolder.getAuthorizationRequestHolders().stream() + .filter(authorizationRequestHolder -> authorizationRequestHolder.getSource().equals(AuthorizationRequestSource.SCOPE)) + .collect(Collectors.toList()); + + assertEquals(defScopes.size(), authorizationRequestHolders.size() - 1); + + Assert.assertFalse(authorizationRequestHolders.stream() + .map(AuthorizationRequestContextHolder.AuthorizationRequestHolder::getAuthorizationDetails) + .allMatch(rep -> rep.getType().equalsIgnoreCase(AuthorizationDetailsJSONRepresentation.STATIC_SCOPE_RAR_TYPE))); + + Optional authorizationRequestContextHolderOpt = authorizationRequestHolders.stream() + .filter(authorizationRequestHolder -> authorizationRequestHolder.getAuthorizationDetails().getType().equalsIgnoreCase(AuthorizationDetailsJSONRepresentation.DYNAMIC_SCOPE_RAR_TYPE)) + .findAny(); + + Assert.assertTrue(authorizationRequestContextHolderOpt.isPresent()); + AuthorizationRequestContextHolder.AuthorizationRequestHolder authorizationRequestHolder = authorizationRequestContextHolderOpt.get(); + Assert.assertTrue(authorizationRequestHolder.getAuthorizationDetails().getScopeNameFromCustomData().equalsIgnoreCase("dynamic-scope:param")); + Assert.assertTrue(authorizationRequestHolder.getAuthorizationDetails().getCustomData().get("scope_parameter").equals("param")); + + testApp.removeOptionalClientScope(scopeId); + } + + private Response createScope(String scopeName, boolean dynamic) { + ClientScopeRepresentation clientScope = new ClientScopeRepresentation(); + clientScope.setName(scopeName); + if (dynamic) { + clientScope.setAttributes(new HashMap() {{ + put(ClientScopeModel.IS_DYNAMIC_SCOPE, "true"); + put(ClientScopeModel.DYNAMIC_SCOPE_REGEXP, String.format("%1s:*", scopeName)); + }}); + } + clientScope.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); + return testRealm().clientScopes().create(clientScope); + } +}