[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:
Daniel Gozalo 2022-01-05 11:21:23 +01:00 committed by Marek Posolda
parent af9d840ec1
commit dad51773ea
23 changed files with 1483 additions and 2 deletions

View file

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

View file

@ -17,6 +17,8 @@
package org.keycloak.models; package org.keycloak.models;
import org.keycloak.rar.AuthorizationRequestContext;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.Stream; import java.util.stream.Stream;
@ -83,4 +85,6 @@ public interface ClientSessionContext {
<T> T getAttribute(String attribute, Class<T> clazz); <T> T getAttribute(String attribute, Class<T> clazz);
AuthorizationRequestContext getAuthorizationRequestContext();
} }

View file

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

View file

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

View file

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

View file

@ -59,6 +59,9 @@ import org.keycloak.protocol.oidc.mappers.OIDCAccessTokenMapper;
import org.keycloak.protocol.oidc.mappers.OIDCAccessTokenResponseMapper; import org.keycloak.protocol.oidc.mappers.OIDCAccessTokenResponseMapper;
import org.keycloak.protocol.oidc.mappers.OIDCIDTokenMapper; import org.keycloak.protocol.oidc.mappers.OIDCIDTokenMapper;
import org.keycloak.protocol.oidc.mappers.UserInfoTokenMapper; 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.AcrUtils;
import org.keycloak.protocol.oidc.utils.OIDCResponseType; import org.keycloak.protocol.oidc.utils.OIDCResponseType;
import org.keycloak.representations.AccessToken; import org.keycloak.representations.AccessToken;
@ -632,6 +635,38 @@ public class TokenManager {
return Stream.concat(parseScopeParameter(scopeParam).map(allOptionalScopes::get).filter(Objects::nonNull), return Stream.concat(parseScopeParameter(scopeParam).map(allOptionalScopes::get).filter(Objects::nonNull),
clientScopes).distinct(); 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) { public static boolean isValidScope(String scopes, ClientModel client) {
if (scopes == null) { if (scopes == null) {

View file

@ -27,6 +27,7 @@ import javax.ws.rs.core.Response;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import org.keycloak.OAuth2Constants; import org.keycloak.OAuth2Constants;
import org.keycloak.OAuthErrorException; import org.keycloak.OAuthErrorException;
import org.keycloak.common.Profile;
import org.keycloak.events.Details; import org.keycloak.events.Details;
import org.keycloak.events.Errors; import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder; import org.keycloak.events.EventBuilder;
@ -206,7 +207,13 @@ public class AuthorizationEndpointChecker {
} }
public void checkValidScope() throws AuthorizationCheckException { 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); ServicesLogger.LOGGER.invalidParameter(OIDCLoginProtocol.SCOPE_PARAM);
event.error(Errors.INVALID_REQUEST); event.error(Errors.INVALID_REQUEST);
throw new AuthorizationCheckException(Response.Status.BAD_REQUEST, OAuthErrorException.INVALID_SCOPE, "Invalid scopes: " + request.getScope()); throw new AuthorizationCheckException(Response.Status.BAD_REQUEST, OAuthErrorException.INVALID_SCOPE, "Invalid scopes: " + request.getScope());

View file

@ -17,6 +17,8 @@
package org.keycloak.protocol.oidc.endpoints.request; package org.keycloak.protocol.oidc.endpoints.request;
import org.keycloak.rar.AuthorizationRequestContext;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
@ -50,6 +52,8 @@ public class AuthorizationEndpointRequest {
String acr; String acr;
AuthorizationRequestContext authorizationRequestContext;
public String getAcr() { public String getAcr() {
return acr; return acr;
} }
@ -131,4 +135,8 @@ public class AuthorizationEndpointRequest {
public String getUiLocales() { public String getUiLocales() {
return uiLocales; return uiLocales;
} }
public AuthorizationRequestContext getAuthorizationRequestContext() {
return authorizationRequestContext;
}
} }

View file

@ -17,6 +17,7 @@
package org.keycloak.protocol.oidc.endpoints.request; package org.keycloak.protocol.oidc.endpoints.request;
import org.keycloak.common.Profile;
import org.keycloak.common.util.StreamUtil; import org.keycloak.common.util.StreamUtil;
import org.keycloak.connections.httpclient.HttpClientProvider; import org.keycloak.connections.httpclient.HttpClientProvider;
import org.keycloak.events.Errors; 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.OIDCConfigAttributes;
import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.par.endpoints.request.AuthzEndpointParParser; 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.protocol.oidc.utils.RedirectUtils;
import org.keycloak.services.ErrorPageException; import org.keycloak.services.ErrorPageException;
import org.keycloak.services.ServicesLogger; 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; return request;
} catch (Exception e) { } catch (Exception e) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -28,6 +28,7 @@ import java.util.stream.Stream;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import org.keycloak.OAuth2Constants; import org.keycloak.OAuth2Constants;
import org.keycloak.common.Profile;
import org.keycloak.models.AuthenticatedClientSessionModel; import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientModel; import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientScopeModel; import org.keycloak.models.ClientScopeModel;
@ -41,6 +42,10 @@ import org.keycloak.models.utils.RoleUtils;
import org.keycloak.protocol.ProtocolMapperUtils; import org.keycloak.protocol.ProtocolMapperUtils;
import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.TokenManager; 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; import org.keycloak.util.TokenUtil;
/** /**
@ -154,6 +159,14 @@ public class DefaultClientSessionContext implements ClientSessionContext {
@Override @Override
public String getScopeString() { 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 // Add both default and optional scopes to scope parameter. Don't add client itself
String scopeParam = getClientScopesStream() String scopeParam = getClientScopesStream()
.filter(((Predicate<ClientScopeModel>) ClientModel.class::isInstance).negate()) .filter(((Predicate<ClientScopeModel>) ClientModel.class::isInstance).negate())
@ -170,6 +183,22 @@ public class DefaultClientSessionContext implements ClientSessionContext {
return scopeParam; 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 @Override
public void setAttribute(String name, Object value) { public void setAttribute(String name, Object value) {
@ -183,6 +212,21 @@ public class DefaultClientSessionContext implements ClientSessionContext {
return clazz.cast(value); 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 // Loading data

View file

@ -0,0 +1 @@
org.keycloak.protocol.oidc.rar.parsers.ClientScopeAuthorizationRequestParserProviderFactory

View file

@ -25,4 +25,5 @@ org.keycloak.protocol.oidc.ext.OIDCExtSPI
org.keycloak.protocol.saml.preprocessor.SamlAuthenticationPreprocessorSpi org.keycloak.protocol.saml.preprocessor.SamlAuthenticationPreprocessorSpi
org.keycloak.encoding.ResourceEncodingSpi org.keycloak.encoding.ResourceEncodingSpi
org.keycloak.protocol.oidc.grants.ciba.channel.AuthenticationChannelSpi 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

View file

@ -22,6 +22,7 @@ import org.junit.Assert;
import org.junit.Rule; import org.junit.Rule;
import org.junit.Test; import org.junit.Test;
import org.keycloak.OAuth2Constants; import org.keycloak.OAuth2Constants;
import org.keycloak.admin.client.resource.ClientResource;
import org.keycloak.admin.client.resource.UserResource; import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.common.Profile; import org.keycloak.common.Profile;
import org.keycloak.common.util.Retry; 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.JWSInput;
import org.keycloak.jose.jws.JWSInputException; import org.keycloak.jose.jws.JWSInputException;
import org.keycloak.models.BrowserSecurityHeaders; import org.keycloak.models.BrowserSecurityHeaders;
import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.utils.SessionTimeoutHelper; import org.keycloak.models.utils.SessionTimeoutHelper;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.OIDCLoginProtocolService; import org.keycloak.protocol.oidc.OIDCLoginProtocolService;
import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.ClientScopeRepresentation;
import org.keycloak.representations.idm.EventRepresentation; import org.keycloak.representations.idm.EventRepresentation;
import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.AssertEvents; import org.keycloak.testsuite.AssertEvents;
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest; import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
import org.keycloak.testsuite.ProfileAssume;
import org.keycloak.testsuite.admin.ApiUtil; import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude; import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude;
import org.keycloak.testsuite.arquillian.annotation.DisableFeature; 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.console.page.AdminConsole;
import org.keycloak.testsuite.pages.AccountUpdateProfilePage; import org.keycloak.testsuite.pages.AccountUpdateProfilePage;
import org.keycloak.testsuite.pages.AppPage; 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.assertFalse;
import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertTrue; 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.admin.ApiUtil.findClientByClientId;
import static org.keycloak.testsuite.util.OAuthClient.AUTH_SERVER_ROOT; import static org.keycloak.testsuite.util.OAuthClient.AUTH_SERVER_ROOT;
import static org.keycloak.testsuite.util.OAuthClient.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();
}
} }

View file

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

View file

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

View file

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

View file

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