[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
This commit is contained in:
parent
af9d840ec1
commit
dad51773ea
23 changed files with 1483 additions and 2 deletions
|
@ -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 <a href="mailto:dgozalob@redhat.com">Daniel Gozalo</a>
|
||||
* @see {@link <a href="https://datatracker.ietf.org/doc/html/draft-ietf-oauth-rar#section-2">Request parameter "authorization_details"</a>}
|
||||
*/
|
||||
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<String> locations;
|
||||
@JsonProperty("actions")
|
||||
private List<String> actions;
|
||||
@JsonProperty("datatypes")
|
||||
private List<String> datatypes;
|
||||
@JsonProperty("identifier")
|
||||
private String identifier;
|
||||
@JsonProperty("privileges")
|
||||
private List<String> privileges;
|
||||
|
||||
private final Map<String, Object> customData = new HashMap<>();
|
||||
|
||||
public String getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public void setType(String type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
public List<String> getLocations() {
|
||||
return locations;
|
||||
}
|
||||
|
||||
public void setLocations(List<String> locations) {
|
||||
this.locations = locations;
|
||||
}
|
||||
|
||||
public List<String> getActions() {
|
||||
return actions;
|
||||
}
|
||||
|
||||
public void setActions(List<String> actions) {
|
||||
this.actions = actions;
|
||||
}
|
||||
|
||||
public List<String> getDatatypes() {
|
||||
return datatypes;
|
||||
}
|
||||
|
||||
public void setDatatypes(List<String> datatypes) {
|
||||
this.datatypes = datatypes;
|
||||
}
|
||||
|
||||
public String getIdentifier() {
|
||||
return identifier;
|
||||
}
|
||||
|
||||
public void setIdentifier(String identifier) {
|
||||
this.identifier = identifier;
|
||||
}
|
||||
|
||||
public List<String> getPrivileges() {
|
||||
return privileges;
|
||||
}
|
||||
|
||||
public void setPrivileges(List<String> privileges) {
|
||||
this.privileges = privileges;
|
||||
}
|
||||
|
||||
@JsonAnyGetter
|
||||
public Map<String, Object> 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<String> accessList = (List<String>) 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);
|
||||
}
|
||||
}
|
|
@ -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> T getAttribute(String attribute, Class<T> clazz);
|
||||
|
||||
AuthorizationRequestContext getAuthorizationRequestContext();
|
||||
|
||||
}
|
||||
|
|
|
@ -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 <a href="mailto:dgozalob@redhat.com">Daniel Gozalo</a>
|
||||
*/
|
||||
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 +
|
||||
'}';
|
||||
}
|
||||
}
|
|
@ -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 <a href="mailto:dgozalob@redhat.com">Daniel Gozalo</a>
|
||||
* @see {@link <a href="https://datatracker.ietf.org/doc/html/draft-ietf-oauth-rar">Rich Authorization Requests</a>}
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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<AuthorizationDetails> authorizationDetailEntries;
|
||||
|
||||
public AuthorizationRequestContext(List<AuthorizationDetails> authorizationDetailEntries) {
|
||||
this.authorizationDetailEntries = authorizationDetailEntries;
|
||||
}
|
||||
|
||||
public List<AuthorizationDetails> getAuthorizationDetailEntries() {
|
||||
return authorizationDetailEntries;
|
||||
}
|
||||
|
||||
public void setAuthorizationDetailEntries(List<AuthorizationDetails> authorizationDetailEntries) {
|
||||
this.authorizationDetailEntries = authorizationDetailEntries;
|
||||
}
|
||||
}
|
|
@ -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 <a href="mailto:dgozalob@redhat.com">Daniel Gozalo</a>
|
||||
*/
|
||||
public enum AuthorizationRequestSource {
|
||||
SCOPE,
|
||||
AUTHORIZATION_DETAILS
|
||||
}
|
|
@ -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<String> requestedScopes = TokenManager.parseScopeParameter(scopes).collect(Collectors.toSet());
|
||||
Set<String> 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) {
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 <a href="mailto:dgozalob@redhat.com">Daniel Gozalo</a>
|
||||
*/
|
||||
public interface AuthorizationRequestParserProvider extends Provider {
|
||||
|
||||
AuthorizationRequestContext parseScopes(String scopeParam);
|
||||
|
||||
}
|
|
@ -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 <a href="mailto:dgozalob@redhat.com">Daniel Gozalo</a>
|
||||
*/
|
||||
public interface AuthorizationRequestParserProviderFactory extends ProviderFactory<AuthorizationRequestParserProvider> { }
|
|
@ -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 <a href="mailto:dgozalob@redhat.com">Daniel Gozalo</a>
|
||||
*/
|
||||
public class AuthorizationRequestParserSpi implements Spi {
|
||||
@Override
|
||||
public boolean isInternal() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "authorization-request-parser";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<? extends Provider> getProviderClass() {
|
||||
return AuthorizationRequestParserProvider.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<? extends ProviderFactory> getProviderFactoryClass() {
|
||||
return AuthorizationRequestParserProviderFactory.class;
|
||||
}
|
||||
}
|
|
@ -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 <a href="mailto:dgozalob@redhat.com">Daniel Gozalo</a>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
|
@ -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 <a href="mailto:dgozalob@redhat.com">Daniel Gozalo</a>
|
||||
*/
|
||||
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.
|
||||
* <p>
|
||||
* 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<IntermediaryScopeRepresentation> 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<IntermediaryScopeRepresentation> 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> 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<IntermediaryScopeRepresentation> getMatchingClientScope(String requestScope, Collection<ClientScopeModel> 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() {
|
||||
|
||||
}
|
||||
}
|
|
@ -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 <a href="mailto:dgozalob@redhat.com">Daniel Gozalo</a>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
|
@ -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<ClientScopeModel>) 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
|
||||
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
org.keycloak.protocol.oidc.rar.parsers.ClientScopeAuthorizationRequestParserProviderFactory
|
|
@ -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
|
||||
org.keycloak.protocol.oidc.grants.ciba.resolvers.CIBALoginUserResolverSpi
|
||||
org.keycloak.protocol.oidc.rar.AuthorizationRequestParserSpi
|
|
@ -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<String, String>() {{
|
||||
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();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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 <a href="mailto:dgozalob@redhat.com">Daniel Gozalo</a>
|
||||
*/
|
||||
@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<RoleRepresentation> 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<RoleRepresentation> 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<String, String>() {{
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -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 <a href="mailto:dgozalob@redhat.com">Daniel Gozalo</a>
|
||||
*/
|
||||
@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<AuthorizationRequestContextHolder.AuthorizationRequestHolder> 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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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 <a href="mailto:dgozalob@redhat.com">Daniel Gozalo</a>
|
||||
*/
|
||||
public class AuthorizationRequestContextHolder {
|
||||
|
||||
private List<AuthorizationRequestHolder> authorizationRequestHolders;
|
||||
|
||||
public AuthorizationRequestContextHolder() {
|
||||
}
|
||||
|
||||
public AuthorizationRequestContextHolder(List<AuthorizationRequestHolder> authorizationRequestHolders) {
|
||||
this.authorizationRequestHolders = authorizationRequestHolders;
|
||||
}
|
||||
|
||||
public List<AuthorizationRequestHolder> getAuthorizationRequestHolders() {
|
||||
return authorizationRequestHolders;
|
||||
}
|
||||
|
||||
public void setAuthorizationRequestHolders(List<AuthorizationRequestHolder> 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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 <a href="mailto:dgozalob@redhat.com">Daniel Gozalo</a>
|
||||
*/
|
||||
@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<ClientScopeRepresentation> defScopes = testApp.getDefaultClientScopes();
|
||||
oauth.openLoginForm();
|
||||
oauth.scope("openid");
|
||||
oauth.doLogin("rar-test", "password");
|
||||
events.expectLogin()
|
||||
.user(userId)
|
||||
.assertEvent();
|
||||
AuthorizationRequestContextHolder contextHolder = fetchAuthorizationRequestContextHolder();
|
||||
List<AuthorizationRequestContextHolder.AuthorizationRequestHolder> 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<ClientScopeRepresentation> defScopes = testApp.getDefaultClientScopes();
|
||||
oauth.openLoginForm();
|
||||
oauth.scope("openid static-scope");
|
||||
oauth.doLogin("rar-test", "password");
|
||||
events.expectLogin()
|
||||
.user(userId)
|
||||
.assertEvent();
|
||||
|
||||
AuthorizationRequestContextHolder contextHolder = fetchAuthorizationRequestContextHolder();
|
||||
List<AuthorizationRequestContextHolder.AuthorizationRequestHolder> 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<ClientScopeRepresentation> 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<AuthorizationRequestContextHolder.AuthorizationRequestHolder> 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<AuthorizationRequestContextHolder.AuthorizationRequestHolder> 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<String, String>() {{
|
||||
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);
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue