Initial client policies integration for SAML

Closes #26654

Signed-off-by: rmartinc <rmartinc@redhat.com>
This commit is contained in:
rmartinc 2024-01-30 12:18:24 +01:00 committed by Marek Posolda
parent 65448ff8c3
commit 82af0b6af6
26 changed files with 1532 additions and 21 deletions

View file

@ -52,6 +52,8 @@ public enum ClientPolicyEvent {
DEVICE_TOKEN_REQUEST, DEVICE_TOKEN_REQUEST,
DEVICE_TOKEN_RESPONSE, DEVICE_TOKEN_RESPONSE,
RESOURCE_OWNER_PASSWORD_CREDENTIALS_REQUEST, RESOURCE_OWNER_PASSWORD_CREDENTIALS_REQUEST,
RESOURCE_OWNER_PASSWORD_CREDENTIALS_RESPONSE RESOURCE_OWNER_PASSWORD_CREDENTIALS_RESPONSE,
SAML_AUTHN_REQUEST,
SAML_LOGOUT_REQUEST,
} }

View file

@ -96,6 +96,10 @@ import org.keycloak.saml.processing.web.util.RedirectBindingUtil;
import org.keycloak.saml.validators.DestinationValidator; import org.keycloak.saml.validators.DestinationValidator;
import org.keycloak.services.ErrorPage; import org.keycloak.services.ErrorPage;
import org.keycloak.services.Urls; import org.keycloak.services.Urls;
import org.keycloak.services.clientpolicy.ClientPolicyContext;
import org.keycloak.services.clientpolicy.ClientPolicyException;
import org.keycloak.services.clientpolicy.context.SamlAuthnRequestContext;
import org.keycloak.services.clientpolicy.context.SamlLogoutRequestContext;
import org.keycloak.services.managers.AuthenticationManager; import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.managers.RealmManager; import org.keycloak.services.managers.RealmManager;
import org.keycloak.services.messages.Messages; import org.keycloak.services.messages.Messages;
@ -260,6 +264,16 @@ public class SamlService extends AuthorizationEndpointBase {
return response; return response;
} }
protected Response triggerSamlEvent(ClientPolicyContext ctx) {
try {
session.clientPolicy().triggerOnEvent(ctx);
} catch (ClientPolicyException cpe) {
logger.warnf("Error in client policies processing the request: %s - %s", cpe.getError(), cpe.getErrorDetail());
return error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST);
}
return null;
}
protected Response handleSamlRequest(String samlRequest, String relayState) { protected Response handleSamlRequest(String samlRequest, String relayState) {
SAMLDocumentHolder documentHolder = extractRequestDocument(samlRequest); SAMLDocumentHolder documentHolder = extractRequestDocument(samlRequest);
if (documentHolder == null) { if (documentHolder == null) {
@ -318,9 +332,17 @@ public class SamlService extends AuthorizationEndpointBase {
if (samlObject instanceof AuthnRequestType) { if (samlObject instanceof AuthnRequestType) {
// Get the SAML Request Message // Get the SAML Request Message
AuthnRequestType authn = (AuthnRequestType) samlObject; AuthnRequestType authn = (AuthnRequestType) samlObject;
Response response = triggerSamlEvent(new SamlAuthnRequestContext(authn, client, getBindingType()));
if (response != null) {
return response;
}
return loginRequest(relayState, authn, client); return loginRequest(relayState, authn, client);
} else if (samlObject instanceof LogoutRequestType) { } else if (samlObject instanceof LogoutRequestType) {
LogoutRequestType logout = (LogoutRequestType) samlObject; LogoutRequestType logout = (LogoutRequestType) samlObject;
Response response = triggerSamlEvent(new SamlLogoutRequestContext(logout, client, getBindingType()));
if (response != null) {
return response;
}
return logoutRequest(logout, client, relayState); return logoutRequest(logout, client, relayState);
} else { } else {
throw new IllegalStateException("Invalid SAML object"); throw new IllegalStateException("Invalid SAML object");

View file

@ -0,0 +1,123 @@
/*
* Copyright 2024 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.services.clientpolicy.condition;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.representations.idm.ClientPolicyConditionConfigurationRepresentation;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.services.clientpolicy.ClientPolicyContext;
import org.keycloak.services.clientpolicy.ClientPolicyException;
import org.keycloak.services.clientpolicy.ClientPolicyVote;
import org.keycloak.services.clientpolicy.context.ClientCRUDContext;
/**
*
* @author rmartinc
*/
public class ClientProtocolCondition extends AbstractClientPolicyConditionProvider<ClientProtocolCondition.Configuration> {
public static class Configuration extends ClientPolicyConditionConfigurationRepresentation {
protected String protocol;
public Configuration() {
protocol = null;
}
public Configuration(String protocol) {
this.protocol = protocol;
}
public String getProtocol() {
return protocol;
}
public void setProtocol(String protocol) {
this.protocol = protocol;
}
}
public ClientProtocolCondition(KeycloakSession session) {
super(session);
}
@Override
public String getProviderId() {
return ClientAccessTypeConditionFactory.PROVIDER_ID;
}
@Override
public Class<Configuration> getConditionConfigurationClass() {
return Configuration.class;
}
@Override
public ClientPolicyVote applyPolicy(ClientPolicyContext context) throws ClientPolicyException {
switch (context.getEvent()) {
case AUTHORIZATION_REQUEST:
case TOKEN_REQUEST:
case TOKEN_RESPONSE:
case SERVICE_ACCOUNT_TOKEN_REQUEST:
case SERVICE_ACCOUNT_TOKEN_RESPONSE:
case TOKEN_REFRESH:
case TOKEN_REFRESH_RESPONSE:
case TOKEN_REVOKE:
case TOKEN_INTROSPECT:
case USERINFO_REQUEST:
case LOGOUT_REQUEST:
case UPDATE:
case UPDATED:
case REGISTERED:
case SAML_AUTHN_REQUEST:
case SAML_LOGOUT_REQUEST:
if (isCorrectProtocolFromContext()) {
return ClientPolicyVote.YES;
}
return ClientPolicyVote.NO;
case REGISTER:
if (isCorrectProtocolFromRepresentation((ClientCRUDContext)context)) {
return ClientPolicyVote.YES;
}
return ClientPolicyVote.NO;
default:
return ClientPolicyVote.ABSTAIN;
}
}
public boolean isCorrectProtocolFromContext() {
ClientModel client = session.getContext().getClient();
if (client != null) {
String protocol = client.getProtocol();
if (protocol != null) {
return protocol.equals(configuration.getProtocol());
}
}
return false;
}
public boolean isCorrectProtocolFromRepresentation(ClientCRUDContext context) {
ClientRepresentation clientRep = context.getProposedClientRepresentation();
if (clientRep != null) {
String protocol = clientRep.getProtocol();
if (protocol != null) {
return protocol.equals(configuration.getProtocol());
}
}
return false;
}
}

View file

@ -0,0 +1,84 @@
/*
* Copyright 2024 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.services.clientpolicy.condition;
import java.util.LinkedList;
import java.util.List;
import org.keycloak.Config;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.protocol.LoginProtocol;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.provider.ProviderConfigurationBuilder;
/**
*
* @author rmartinc
*/
public class ClientProtocolConditionFactory implements ClientPolicyConditionProviderFactory {
public static final String PROVIDER_ID = "client-type";
private List<String> loginProtocols;
@Override
public ClientPolicyConditionProvider create(KeycloakSession session) {
return new ClientProtocolCondition(session);
}
@Override
public void postInit(KeycloakSessionFactory factory) {
try (KeycloakSession session = factory.create()) {
loginProtocols = new LinkedList<>(session.listProviderIds(LoginProtocol.class));
}
}
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public String getHelpText() {
return "Condition that uses the client's protocol (OpenID Connect, SAML) to determine whether the policy is applied.";
}
@Override
public void init(Config.Scope config) {
// no-op
}
@Override
public void close() {
// no-op
}
@Override
public List<ProviderConfigProperty> getConfigProperties() {
return ProviderConfigurationBuilder.create()
.property()
.name("protocol")
.type(ProviderConfigProperty.LIST_TYPE)
.options(loginProtocols)
.defaultValue(loginProtocols.iterator().next())
.label("Client protocol")
.helpText("What client login protocol the condition will apply on.")
.add()
.build();
}
}

View file

@ -91,6 +91,8 @@ public class ClientRolesCondition extends AbstractClientPolicyConditionProvider<
case PUSHED_AUTHORIZATION_REQUEST: case PUSHED_AUTHORIZATION_REQUEST:
case REGISTERED: case REGISTERED:
case UPDATED: case UPDATED:
case SAML_AUTHN_REQUEST:
case SAML_LOGOUT_REQUEST:
if (isRolesMatched(session.getContext().getClient())) return ClientPolicyVote.YES; if (isRolesMatched(session.getContext().getClient())) return ClientPolicyVote.YES;
return ClientPolicyVote.NO; return ClientPolicyVote.NO;
default: default:

View file

@ -73,6 +73,8 @@ public class ClientUpdaterContextCondition extends AbstractClientPolicyCondition
switch (context.getEvent()) { switch (context.getEvent()) {
case REGISTER: case REGISTER:
case UPDATE: case UPDATE:
case REGISTERED:
case UPDATED:
if (isAuthMethodMatched((ClientCRUDContext)context)) return ClientPolicyVote.YES; if (isAuthMethodMatched((ClientCRUDContext)context)) return ClientPolicyVote.YES;
return ClientPolicyVote.NO; return ClientPolicyVote.NO;
default: default:

View file

@ -33,10 +33,14 @@ import org.keycloak.services.clientpolicy.ClientPolicyContext;
import org.keycloak.services.clientpolicy.ClientPolicyException; import org.keycloak.services.clientpolicy.ClientPolicyException;
import org.keycloak.services.clientpolicy.ClientPolicyVote; import org.keycloak.services.clientpolicy.ClientPolicyVote;
import org.keycloak.services.clientpolicy.context.AdminClientRegisterContext; import org.keycloak.services.clientpolicy.context.AdminClientRegisterContext;
import org.keycloak.services.clientpolicy.context.AdminClientRegisteredContext;
import org.keycloak.services.clientpolicy.context.AdminClientUpdateContext; import org.keycloak.services.clientpolicy.context.AdminClientUpdateContext;
import org.keycloak.services.clientpolicy.context.AdminClientUpdatedContext;
import org.keycloak.services.clientpolicy.context.ClientCRUDContext; import org.keycloak.services.clientpolicy.context.ClientCRUDContext;
import org.keycloak.services.clientpolicy.context.DynamicClientRegisterContext; import org.keycloak.services.clientpolicy.context.DynamicClientRegisterContext;
import org.keycloak.services.clientpolicy.context.DynamicClientRegisteredContext;
import org.keycloak.services.clientpolicy.context.DynamicClientUpdateContext; import org.keycloak.services.clientpolicy.context.DynamicClientUpdateContext;
import org.keycloak.services.clientpolicy.context.DynamicClientUpdatedContext;
/** /**
* @author <a href="mailto:takashi.norimatsu.ws@hitachi.com">Takashi Norimatsu</a> * @author <a href="mailto:takashi.norimatsu.ws@hitachi.com">Takashi Norimatsu</a>
@ -76,17 +80,19 @@ public class ClientUpdaterSourceGroupsCondition extends AbstractClientPolicyCond
public ClientPolicyVote applyPolicy(ClientPolicyContext context) throws ClientPolicyException { public ClientPolicyVote applyPolicy(ClientPolicyContext context) throws ClientPolicyException {
switch (context.getEvent()) { switch (context.getEvent()) {
case REGISTER: case REGISTER:
if (context instanceof AdminClientRegisterContext) { case REGISTERED:
if (context instanceof AdminClientRegisterContext || context instanceof AdminClientRegisteredContext) {
return getVoteForGroupsMatched(((ClientCRUDContext)context).getAuthenticatedUser()); return getVoteForGroupsMatched(((ClientCRUDContext)context).getAuthenticatedUser());
} else if (context instanceof DynamicClientRegisterContext) { } else if (context instanceof DynamicClientRegisterContext || context instanceof DynamicClientRegisteredContext) {
return getVoteForGroupsMatched(((ClientCRUDContext)context).getToken()); return getVoteForGroupsMatched(((ClientCRUDContext)context).getToken());
} else { } else {
throw new ClientPolicyException(OAuthErrorException.SERVER_ERROR, "unexpected context type."); throw new ClientPolicyException(OAuthErrorException.SERVER_ERROR, "unexpected context type.");
} }
case UPDATE: case UPDATE:
if (context instanceof AdminClientUpdateContext) { case UPDATED:
if (context instanceof AdminClientUpdateContext || context instanceof AdminClientUpdatedContext) {
return getVoteForGroupsMatched(((ClientCRUDContext)context).getAuthenticatedUser()); return getVoteForGroupsMatched(((ClientCRUDContext)context).getAuthenticatedUser());
} else if (context instanceof DynamicClientUpdateContext) { } else if (context instanceof DynamicClientUpdateContext || context instanceof DynamicClientUpdatedContext) {
return getVoteForGroupsMatched(((ClientCRUDContext)context).getToken()); return getVoteForGroupsMatched(((ClientCRUDContext)context).getToken());
} else { } else {
throw new ClientPolicyException(OAuthErrorException.SERVER_ERROR, "unexpected context type."); throw new ClientPolicyException(OAuthErrorException.SERVER_ERROR, "unexpected context type.");

View file

@ -73,6 +73,8 @@ public class ClientUpdaterSourceHostsCondition extends AbstractClientPolicyCondi
switch (context.getEvent()) { switch (context.getEvent()) {
case REGISTER: case REGISTER:
case UPDATE: case UPDATE:
case REGISTERED:
case UPDATED:
if (isHostMatched()) return ClientPolicyVote.YES; if (isHostMatched()) return ClientPolicyVote.YES;
return ClientPolicyVote.NO; return ClientPolicyVote.NO;
default: default:

View file

@ -35,10 +35,14 @@ import org.keycloak.services.clientpolicy.ClientPolicyContext;
import org.keycloak.services.clientpolicy.ClientPolicyException; import org.keycloak.services.clientpolicy.ClientPolicyException;
import org.keycloak.services.clientpolicy.ClientPolicyVote; import org.keycloak.services.clientpolicy.ClientPolicyVote;
import org.keycloak.services.clientpolicy.context.AdminClientRegisterContext; import org.keycloak.services.clientpolicy.context.AdminClientRegisterContext;
import org.keycloak.services.clientpolicy.context.AdminClientRegisteredContext;
import org.keycloak.services.clientpolicy.context.AdminClientUpdateContext; import org.keycloak.services.clientpolicy.context.AdminClientUpdateContext;
import org.keycloak.services.clientpolicy.context.AdminClientUpdatedContext;
import org.keycloak.services.clientpolicy.context.ClientCRUDContext; import org.keycloak.services.clientpolicy.context.ClientCRUDContext;
import org.keycloak.services.clientpolicy.context.DynamicClientRegisterContext; import org.keycloak.services.clientpolicy.context.DynamicClientRegisterContext;
import org.keycloak.services.clientpolicy.context.DynamicClientRegisteredContext;
import org.keycloak.services.clientpolicy.context.DynamicClientUpdateContext; import org.keycloak.services.clientpolicy.context.DynamicClientUpdateContext;
import org.keycloak.services.clientpolicy.context.DynamicClientUpdatedContext;
/** /**
@ -79,17 +83,20 @@ public class ClientUpdaterSourceRolesCondition extends AbstractClientPolicyCondi
public ClientPolicyVote applyPolicy(ClientPolicyContext context) throws ClientPolicyException { public ClientPolicyVote applyPolicy(ClientPolicyContext context) throws ClientPolicyException {
switch (context.getEvent()) { switch (context.getEvent()) {
case REGISTER: case REGISTER:
if (context instanceof AdminClientRegisterContext) { case REGISTERED:
if (context instanceof AdminClientRegisterContext || context instanceof AdminClientRegisteredContext) {
return getVoteForRolesMatched(((ClientCRUDContext)context).getAuthenticatedUser()); return getVoteForRolesMatched(((ClientCRUDContext)context).getAuthenticatedUser());
} else if (context instanceof DynamicClientRegisterContext) { } else if (context instanceof DynamicClientRegisterContext || context instanceof DynamicClientRegisteredContext) {
return getVoteForRolesMatched(((ClientCRUDContext)context).getToken()); return getVoteForRolesMatched(((ClientCRUDContext)context).getToken());
} else { } else {
throw new ClientPolicyException(OAuthErrorException.SERVER_ERROR, "unexpected context type."); throw new ClientPolicyException(OAuthErrorException.SERVER_ERROR, "unexpected context type.");
} }
case UPDATE: case UPDATE:
if (context instanceof AdminClientUpdateContext) { case UPDATED:
if (context instanceof AdminClientUpdateContext || context instanceof AdminClientUpdatedContext) {
return getVoteForRolesMatched(((ClientCRUDContext)context).getAuthenticatedUser()); return getVoteForRolesMatched(((ClientCRUDContext)context).getAuthenticatedUser());
} else if (context instanceof DynamicClientUpdateContext) { } else if (context instanceof DynamicClientUpdateContext || context instanceof DynamicClientUpdatedContext) {
return getVoteForRolesMatched(((ClientCRUDContext)context).getToken()); return getVoteForRolesMatched(((ClientCRUDContext)context).getToken());
} else { } else {
throw new ClientPolicyException(OAuthErrorException.SERVER_ERROR, "unexpected context type."); throw new ClientPolicyException(OAuthErrorException.SERVER_ERROR, "unexpected context type.");

View file

@ -0,0 +1,74 @@
/*
* Copyright 2024 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.services.clientpolicy.context;
import org.keycloak.models.ClientModel;
import org.keycloak.services.clientpolicy.ClientPolicyContext;
import org.keycloak.services.clientpolicy.ClientPolicyEvent;
/**
* <p>Abstract saml request context for any SAML request received. The context
* will have the type object received, the client model and binding type
* the client used to connect.</p>
*
* @author rmartinc
* @param <T> The saml request type
*/
public abstract class AbstractSamlRequestContext<T> implements ClientPolicyContext {
protected final T request;
protected final ClientModel client;
protected final String protocolBinding;
public AbstractSamlRequestContext(final T request, final ClientModel client, final String protocolBinding) {
this.request = request;
this.client = client;
this.protocolBinding = protocolBinding;
}
/**
* {@inheritDoc}
*/
@Override
public abstract ClientPolicyEvent getEvent();
/**
* Getter for the SAML request received.
* @return The SAML request type
*/
public T getRequest() {
return request;
}
/**
* Getter for the client model doing the request.
* @return The client model
*/
public ClientModel getClient() {
return client;
}
/**
* Getter for the protocol binding type that is processing the request.
* @return The keycloak protocol binding type.
*/
public String getProtocolBinding() {
return protocolBinding;
}
}

View file

@ -0,0 +1,42 @@
/*
* Copyright 2024 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.services.clientpolicy.context;
import org.keycloak.dom.saml.v2.protocol.AuthnRequestType;
import org.keycloak.models.ClientModel;
import org.keycloak.services.clientpolicy.ClientPolicyEvent;
/**
* <p>Context for the saml authn request.</p>
*
* @author rmartinc
*/
public class SamlAuthnRequestContext extends AbstractSamlRequestContext<AuthnRequestType> {
public SamlAuthnRequestContext(final AuthnRequestType request, final ClientModel client, final String protocolBinding) {
super(request, client, protocolBinding);
}
/**
* {@inheritDoc}
*/
@Override
public ClientPolicyEvent getEvent() {
return ClientPolicyEvent.SAML_AUTHN_REQUEST;
}
}

View file

@ -0,0 +1,42 @@
/*
* Copyright 2024 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.services.clientpolicy.context;
import org.keycloak.dom.saml.v2.protocol.LogoutRequestType;
import org.keycloak.models.ClientModel;
import org.keycloak.services.clientpolicy.ClientPolicyEvent;
/**
* <p>Context for the saml logout request.</p>
*
* @author rmartinc
*/
public class SamlLogoutRequestContext extends AbstractSamlRequestContext<LogoutRequestType> {
public SamlLogoutRequestContext(final LogoutRequestType request, final ClientModel client, final String protocolBinding) {
super(request, client, protocolBinding);
}
/**
* {@inheritDoc}
*/
@Override
public ClientPolicyEvent getEvent() {
return ClientPolicyEvent.SAML_LOGOUT_REQUEST;
}
}

View file

@ -0,0 +1,106 @@
/*
* Copyright 2024 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.services.clientpolicy.executor;
import java.net.URI;
import org.keycloak.OAuthErrorException;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.protocol.saml.SamlClient;
import org.keycloak.protocol.saml.SamlProtocol;
import org.keycloak.representations.idm.ClientPolicyExecutorConfigurationRepresentation;
import org.keycloak.saml.common.constants.JBossSAMLURIConstants;
import org.keycloak.services.clientpolicy.ClientPolicyContext;
import org.keycloak.services.clientpolicy.ClientPolicyException;
import org.keycloak.services.clientpolicy.context.AdminClientRegisteredContext;
import org.keycloak.services.clientpolicy.context.AdminClientUpdatedContext;
import org.keycloak.services.clientpolicy.context.SamlAuthnRequestContext;
import org.keycloak.services.clientpolicy.context.SamlLogoutRequestContext;
/**
*
* @author rmartinc
*/
public class SamlAvoidRedirectBindingExecutor implements ClientPolicyExecutorProvider<ClientPolicyExecutorConfigurationRepresentation> {
public SamlAvoidRedirectBindingExecutor(KeycloakSession session) {
}
@Override
public String getProviderId() {
return SamlAvoidRedirectBindingExecutorFactory.PROVIDER_ID;
}
@Override
public void executeOnEvent(ClientPolicyContext context) throws ClientPolicyException {
switch (context.getEvent()) {
case REGISTERED -> {
confirmPostBindingIsForced(((AdminClientRegisteredContext)context).getTargetClient());
}
case UPDATED -> {
confirmPostBindingIsForced(((AdminClientUpdatedContext)context).getTargetClient());
}
case SAML_AUTHN_REQUEST -> {
confirmRedirectBindingIsNotUsed((SamlAuthnRequestContext) context);
}
case SAML_LOGOUT_REQUEST -> {
confirmRedirectBindingIsNotUsed((SamlLogoutRequestContext) context);
}
}
}
private void confirmPostBindingIsForced(ClientModel client) throws ClientPolicyException {
if (SamlProtocol.LOGIN_PROTOCOL.equals(client.getProtocol())) {
SamlClient samlClient = new SamlClient(client);
if (!samlClient.forcePostBinding()) {
throw new ClientPolicyException(OAuthErrorException.INVALID_CLIENT_METADATA, "Force POST binding is not enabled");
}
}
}
private void confirmRedirectBindingIsNotUsed(SamlAuthnRequestContext context) throws ClientPolicyException {
SamlClient samlClient = new SamlClient(context.getClient());
if (samlClient.forcePostBinding()) {
return;
}
URI requestedBinding = context.getRequest().getProtocolBinding();
if (requestedBinding == null) {
// no request binding explicitly requested so using the one used by the request
if (context.getProtocolBinding().equals(SamlProtocol.SAML_REDIRECT_BINDING)) {
throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "REDIRECT binding is used for the login request and it is not allowed.");
}
} else {
// explicit request binding request check it's not redirect or artifact+redirect
if (JBossSAMLURIConstants.SAML_HTTP_REDIRECT_BINDING.get().equals(requestedBinding.toString())
|| (JBossSAMLURIConstants.SAML_HTTP_ARTIFACT_BINDING.get().equals(requestedBinding.toString())
&& context.getProtocolBinding().equals(SamlProtocol.SAML_REDIRECT_BINDING))) {
throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "REDIRECT binding is used for the login request and it is not allowed.");
}
}
}
private void confirmRedirectBindingIsNotUsed(SamlLogoutRequestContext context) throws ClientPolicyException {
SamlClient samlClient = new SamlClient(context.getClient());
if (samlClient.forcePostBinding()) {
return;
}
if (context.getProtocolBinding().equals(SamlProtocol.SAML_REDIRECT_BINDING)) {
throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "REDIRECT binding is used for the logout request and it is not allowed.");
}
}
}

View file

@ -0,0 +1,71 @@
/*
* Copyright 2024 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.services.clientpolicy.executor;
import java.util.List;
import org.keycloak.Config;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.provider.ProviderConfigurationBuilder;
/**
* <p>Executor factory for SAML client that ensures REDIRECT is not used for responses
* and forces POST binding configuration option in the client creation/update.</p>
*
* @author rmartinc
*/
public class SamlAvoidRedirectBindingExecutorFactory implements ClientPolicyExecutorProviderFactory {
public static final String PROVIDER_ID = "saml-avoid-redirect";
@Override
public ClientPolicyExecutorProvider create(KeycloakSession session) {
return new SamlAvoidRedirectBindingExecutor(session);
}
@Override
public void init(Config.Scope config) {
// no-op
}
@Override
public void postInit(KeycloakSessionFactory factory) {
// no-op
}
@Override
public void close() {
// no-op
}
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public String getHelpText() {
return "Executor to avoid the REDIRECT binding in a SAML client.";
}
@Override
public List<ProviderConfigProperty> getConfigProperties() {
return ProviderConfigurationBuilder.create().build();
}
}

View file

@ -0,0 +1,203 @@
/*
* Copyright 2024 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.services.clientpolicy.executor;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.net.URI;
import java.util.Collection;
import java.util.Map;
import org.keycloak.OAuthErrorException;
import org.keycloak.dom.saml.v2.protocol.AuthnRequestType;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.protocol.oidc.utils.RedirectUtils;
import org.keycloak.protocol.saml.SamlProtocol;
import org.keycloak.representations.idm.ClientPolicyExecutorConfigurationRepresentation;
import org.keycloak.services.clientpolicy.ClientPolicyContext;
import org.keycloak.services.clientpolicy.ClientPolicyException;
import org.keycloak.services.clientpolicy.context.AdminClientRegisteredContext;
import org.keycloak.services.clientpolicy.context.AdminClientUpdatedContext;
import org.keycloak.services.clientpolicy.context.SamlAuthnRequestContext;
import org.keycloak.services.clientpolicy.context.SamlLogoutRequestContext;
import org.keycloak.utils.StringUtil;
/**
*
* @author rmartinc
*/
public class SamlSecureClientUrisExecutor implements ClientPolicyExecutorProvider<SamlSecureClientUrisExecutor.Configuration> {
private final KeycloakSession session;
private Configuration config;
public static class Configuration extends ClientPolicyExecutorConfigurationRepresentation {
@JsonProperty("allow-wildcard-redirects")
protected boolean allowWildcardRedirects;
public Configuration() {
this(false);
}
public Configuration(boolean allowWildcardRedirects) {
this.allowWildcardRedirects = allowWildcardRedirects;
}
public boolean isAllowWildcardResirects() {
return allowWildcardRedirects;
}
public void setAllowWildcardResirects(boolean allowWildcardRedirects) {
this.allowWildcardRedirects = allowWildcardRedirects;
}
}
public SamlSecureClientUrisExecutor(KeycloakSession session) {
this.session = session;
}
@Override
public Class<Configuration> getExecutorConfigurationClass() {
return Configuration.class;
}
@Override
public void setupConfiguration(Configuration config) {
this.config = config;
}
@Override
public String getProviderId() {
return SamlSecureClientUrisExecutorFactory.PROVIDER_ID;
}
@Override
public void executeOnEvent(ClientPolicyContext context) throws ClientPolicyException {
switch (context.getEvent()) {
case REGISTERED -> {
confirmSecureUris(((AdminClientRegisteredContext)context).getTargetClient());
}
case UPDATED -> {
confirmSecureUris(((AdminClientUpdatedContext)context).getTargetClient());
}
case SAML_AUTHN_REQUEST -> {
confirmLoginRedirectUri((SamlAuthnRequestContext) context);
}
case SAML_LOGOUT_REQUEST -> {
confirmLogoutRedirectUri((SamlLogoutRequestContext) context);
}
}
}
private void confirmLoginRedirectUri(SamlAuthnRequestContext context) throws ClientPolicyException {
AuthnRequestType request = context.getRequest();
URI uri = request.getAssertionConsumerServiceURL();
if (uri != null) {
confirmSecureUri(uri.toString(), "AssertionConsumerServiceURL", OAuthErrorException.INVALID_REQUEST);
} else {
// use configuration for login, check URLs for login are all secure
ClientModel client = context.getClient();
confirmSecureUri(client.getManagementUrl(),
"Master SAML Processing URL", OAuthErrorException.INVALID_REQUEST);
confirmSecureUri(client.getAttribute(SamlProtocol.SAML_ASSERTION_CONSUMER_URL_POST_ATTRIBUTE),
"Assertion Consumer Service POST Binding URL", OAuthErrorException.INVALID_REQUEST);
confirmSecureUri(client.getAttribute(SamlProtocol.SAML_ASSERTION_CONSUMER_URL_REDIRECT_ATTRIBUTE),
"Assertion Consumer Service Redirect Binding URL", OAuthErrorException.INVALID_REQUEST);
confirmSecureUri(client.getAttribute(SamlProtocol.SAML_ASSERTION_CONSUMER_URL_ARTIFACT_ATTRIBUTE),
"Artifact Binding URL", OAuthErrorException.INVALID_REQUEST);
}
}
private void confirmLogoutRedirectUri(SamlLogoutRequestContext context) throws ClientPolicyException {
// check logout URLs are all secure
ClientModel client = context.getClient();
confirmSecureUri(client.getManagementUrl(),
"Master SAML Processing URL", OAuthErrorException.INVALID_REQUEST);
confirmSecureUri(client.getAttribute(SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_POST_ATTRIBUTE),
"Logout Service POST Binding URL", OAuthErrorException.INVALID_REQUEST);
confirmSecureUri(client.getAttribute(SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_ARTIFACT_ATTRIBUTE),
"Logout Service ARTIFACT Binding URL", OAuthErrorException.INVALID_REQUEST);
confirmSecureUri(client.getAttribute(SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_REDIRECT_ATTRIBUTE),
"Logout Service Redirect Binding URL", OAuthErrorException.INVALID_REQUEST);
confirmSecureUri(client.getAttribute(SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_SOAP_ATTRIBUTE),
"Logout Service SOAP Binding URL", OAuthErrorException.INVALID_REQUEST);
}
private void confirmSecureUri(String uri, String uriType) throws ClientPolicyException {
confirmSecureUri(uri, uriType, OAuthErrorException.INVALID_CLIENT_METADATA);
}
private void confirmSecureUri(String uri, String uriType, String error) throws ClientPolicyException {
if (StringUtil.isBlank(uri)) {
return;
}
if (!uri.startsWith("https:")) { // make this configurable? (allowed schemes...)
throw new ClientPolicyException(error, "Non secure scheme for " + uriType);
}
}
private void confirmNoWildcard(String uri, String uriType) throws ClientPolicyException {
if (uri.endsWith("*") && !uri.contains("?") && !uri.contains("#")) {
throw new ClientPolicyException(OAuthErrorException.INVALID_CLIENT_METADATA,
"Unsecure wildcard redirect " + uri + " for " + uriType);
}
}
private void confirmRedirectUris(Collection<String> uris, String uriType) throws ClientPolicyException {
if (uris == null) {
return;
}
for (String uri : uris) {
confirmSecureUri(uri, uriType);
if (!config.isAllowWildcardResirects()) {
confirmNoWildcard(uri, uriType);
}
}
}
private void confirmSecureUris(ClientModel client) throws ClientPolicyException {
if (!SamlProtocol.LOGIN_PROTOCOL.equals(client.getProtocol())) {
return;
}
confirmSecureUri(client.getRootUrl(), "Root URL");
confirmSecureUri(client.getManagementUrl(), "Master SAML Processing URL");
confirmSecureUri(client.getBaseUrl(), "Home URL");
confirmRedirectUris(RedirectUtils.resolveValidRedirects(session, client.getRootUrl(), client.getRedirectUris()),
"Valid redirect URIs");
Map<String, String> attrs = Map.of(
SamlProtocol.SAML_ASSERTION_CONSUMER_URL_POST_ATTRIBUTE, "Assertion Consumer Service POST Binding URL",
SamlProtocol.SAML_ASSERTION_CONSUMER_URL_REDIRECT_ATTRIBUTE, "Assertion Consumer Service Redirect Binding URL",
SamlProtocol.SAML_ASSERTION_CONSUMER_URL_ARTIFACT_ATTRIBUTE, "Artifact Binding URL",
SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_POST_ATTRIBUTE, "Logout Service POST Binding URL",
SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_ARTIFACT_ATTRIBUTE, "Logout Service ARTIFACT Binding URL",
SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_REDIRECT_ATTRIBUTE, "Logout Service Redirect Binding URL",
SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_SOAP_ATTRIBUTE, "Logout Service SOAP Binding URL",
SamlProtocol.SAML_ARTIFACT_RESOLUTION_SERVICE_URL_ATTRIBUTE, "Artifact Resolution Service"
);
if (client.getAttributes() != null) {
for (Map.Entry<String, String> attr : attrs.entrySet()) {
confirmSecureUri(client.getAttribute(attr.getKey()), attr.getValue());
}
}
}
}

View file

@ -0,0 +1,81 @@
/*
* Copyright 2024 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.services.clientpolicy.executor;
import java.util.List;
import org.keycloak.Config;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.provider.ProviderConfigurationBuilder;
/**
* <p>Executor factory that enforces that all URLs configured in a SAML client
* are secure (https). It also enforces that no wildcard valid redirect URIs
* are configured on update/creation (wildcards can be allowed via
* <em>allow-wildcard-redirects</em> configuration property).</p>
*
* @author rmartinc
*/
public class SamlSecureClientUrisExecutorFactory implements ClientPolicyExecutorProviderFactory {
public static final String PROVIDER_ID = "saml-secure-client-uris";
@Override
public ClientPolicyExecutorProvider create(KeycloakSession session) {
return new SamlSecureClientUrisExecutor(session);
}
@Override
public void init(Config.Scope config) {
// no-op
}
@Override
public void postInit(KeycloakSessionFactory factory) {
// no-op
}
@Override
public void close() {
// no-op
}
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public String getHelpText() {
return "Executor that enforces all URLs defined in the SAML client are https (TLS enabled). "
+ "It also enforces that wildcard redirect URIs are not used (configurable).";
}
@Override
public List<ProviderConfigProperty> getConfigProperties() {
return ProviderConfigurationBuilder.create()
.property()
.name("allow-wildcard-redirects")
.type(ProviderConfigProperty.BOOLEAN_TYPE)
.label("Allow wildcard valid redirect URIs")
.helpText("Whether wildcard valid redirect URIs are allowed to be configured in the client.")
.add()
.build();
}
}

View file

@ -0,0 +1,84 @@
/*
* Copyright 2024 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.services.clientpolicy.executor;
import org.keycloak.OAuthErrorException;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.protocol.saml.SamlClient;
import org.keycloak.protocol.saml.SamlProtocol;
import org.keycloak.representations.idm.ClientPolicyExecutorConfigurationRepresentation;
import org.keycloak.services.clientpolicy.ClientPolicyContext;
import org.keycloak.services.clientpolicy.ClientPolicyException;
import org.keycloak.services.clientpolicy.context.AdminClientRegisteredContext;
import org.keycloak.services.clientpolicy.context.AdminClientUpdatedContext;
import org.keycloak.services.clientpolicy.context.SamlAuthnRequestContext;
import org.keycloak.services.clientpolicy.context.SamlLogoutRequestContext;
/**
*
* @author rmartinc
*/
public class SamlSignatureEnforcerExecutor implements ClientPolicyExecutorProvider<ClientPolicyExecutorConfigurationRepresentation> {
public SamlSignatureEnforcerExecutor(KeycloakSession session) {
}
@Override
public void executeOnEvent(ClientPolicyContext context) throws ClientPolicyException {
switch (context.getEvent()) {
case REGISTERED -> {
confirmSignaturesAreForcedRegister(((AdminClientRegisteredContext)context).getTargetClient());
}
case UPDATED -> {
confirmSignaturesAreForcedRegister(((AdminClientUpdatedContext)context).getTargetClient());
}
case SAML_AUTHN_REQUEST -> {
confirmSignaturesAreForced(((SamlAuthnRequestContext) context).getClient(), OAuthErrorException.INVALID_REQUEST);
}
case SAML_LOGOUT_REQUEST -> {
confirmSignaturesAreForced(((SamlLogoutRequestContext) context).getClient(), OAuthErrorException.INVALID_REQUEST);
}
}
}
@Override
public String getProviderId() {
return SamlSignatureEnforcerExecutorFactory.PROVIDER_ID;
}
private boolean signaturesAreForced(boolean clientSignature, boolean serverSignature, boolean assertionSignature) {
// ensure client is signed and server or asertion is signed
return clientSignature && (serverSignature || assertionSignature);
}
private void confirmSignaturesAreForcedRegister(ClientModel client) throws ClientPolicyException {
if (SamlProtocol.LOGIN_PROTOCOL.equals(client.getProtocol())) {
confirmSignaturesAreForced(client, OAuthErrorException.INVALID_CLIENT_METADATA);
}
}
private void confirmSignaturesAreForced(ClientModel client, String error) throws ClientPolicyException {
SamlClient samlClient = new SamlClient(client);
if (!signaturesAreForced(samlClient.requiresClientSignature(), samlClient.requiresRealmSignature(),
samlClient.requiresAssertionSignature())) {
throw new ClientPolicyException(error,
"Signatures not ensured for the client. Ensure Client signature required and Sign documents or Sign assertions are ON");
}
}
}

View file

@ -0,0 +1,71 @@
/*
* Copyright 2024 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.services.clientpolicy.executor;
import java.util.List;
import org.keycloak.Config;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.provider.ProviderConfigurationBuilder;
/**
* <p>Policy executor that enforces client and server (full document or
* assertion) signature is ON.</p>
*
* @author rmartinc
*/
public class SamlSignatureEnforcerExecutorFactory implements ClientPolicyExecutorProviderFactory {
public static final String PROVIDER_ID = "saml-signature-enforcer";
@Override
public ClientPolicyExecutorProvider create(KeycloakSession session) {
return new SamlSignatureEnforcerExecutor(session);
}
@Override
public void init(Config.Scope config) {
// no-op
}
@Override
public void postInit(KeycloakSessionFactory factory) {
// no-op
}
@Override
public void close() {
// no-op
}
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public String getHelpText() {
return "Executor to enforce that signatures are used in a SAML client.";
}
@Override
public List<ProviderConfigProperty> getConfigProperties() {
return ProviderConfigurationBuilder.create().build();
}
}

View file

@ -6,3 +6,4 @@ org.keycloak.services.clientpolicy.condition.ClientUpdaterSourceHostsConditionFa
org.keycloak.services.clientpolicy.condition.ClientUpdaterSourceGroupsConditionFactory org.keycloak.services.clientpolicy.condition.ClientUpdaterSourceGroupsConditionFactory
org.keycloak.services.clientpolicy.condition.ClientUpdaterSourceRolesConditionFactory org.keycloak.services.clientpolicy.condition.ClientUpdaterSourceRolesConditionFactory
org.keycloak.services.clientpolicy.condition.AnyClientConditionFactory org.keycloak.services.clientpolicy.condition.AnyClientConditionFactory
org.keycloak.services.clientpolicy.condition.ClientProtocolConditionFactory

View file

@ -25,3 +25,6 @@ org.keycloak.services.clientpolicy.executor.SecureParContentsExecutorFactory
org.keycloak.services.clientpolicy.executor.DPoPBindEnforcerExecutorFactory org.keycloak.services.clientpolicy.executor.DPoPBindEnforcerExecutorFactory
org.keycloak.services.clientpolicy.executor.UseLightweightAccessTokenExecutorFactory org.keycloak.services.clientpolicy.executor.UseLightweightAccessTokenExecutorFactory
org.keycloak.services.clientpolicy.executor.SecureRedirectUrisEnforcerExecutorFactory org.keycloak.services.clientpolicy.executor.SecureRedirectUrisEnforcerExecutorFactory
org.keycloak.services.clientpolicy.executor.SamlSecureClientUrisExecutorFactory
org.keycloak.services.clientpolicy.executor.SamlAvoidRedirectBindingExecutorFactory
org.keycloak.services.clientpolicy.executor.SamlSignatureEnforcerExecutorFactory

View file

@ -376,6 +376,24 @@
} }
} }
] ]
},
{
"name": "saml-security-profile",
"description": "Client profile that enforces SAML clients to be secure.",
"executors": [
{
"executor": "saml-secure-client-uris",
"configuration": {}
},
{
"executor": "saml-signature-enforcer",
"configuration": {}
},
{
"executor": "saml-avoid-redirect",
"configuration": {}
}
]
} }
] ]
} }

View file

@ -200,10 +200,13 @@ public class SamlClient {
try { try {
BaseSAML2BindingBuilder binding = new BaseSAML2BindingBuilder(); BaseSAML2BindingBuilder binding = new BaseSAML2BindingBuilder();
if (privateKeyStr != null && publicKeyStr != null) { if (privateKeyStr != null && (publicKeyStr != null || certificateStr != null)) {
PrivateKey privateKey = org.keycloak.testsuite.util.KeyUtils.privateKeyFromString(privateKeyStr); PrivateKey privateKey = org.keycloak.testsuite.util.KeyUtils.privateKeyFromString(privateKeyStr);
PublicKey publicKey = org.keycloak.testsuite.util.KeyUtils.publicKeyFromString(publicKeyStr); PublicKey publicKey = publicKeyStr != null? org.keycloak.testsuite.util.KeyUtils.publicKeyFromString(publicKeyStr) : null;
X509Certificate cert = org.keycloak.common.util.PemUtils.decodeCertificate(certificateStr); X509Certificate cert = org.keycloak.common.util.PemUtils.decodeCertificate(certificateStr);
if (publicKey == null) {
publicKey = cert.getPublicKey();
}
binding binding
.signatureAlgorithm(SignatureAlgorithm.RSA_SHA256) .signatureAlgorithm(SignatureAlgorithm.RSA_SHA256)
.signWith(KeyUtils.createKeyId(privateKey), privateKey, publicKey, cert) .signWith(KeyUtils.createKeyId(privateKey), privateKey, publicKey, cert)
@ -329,10 +332,13 @@ public class SamlClient {
public HttpUriRequest createSamlSignedRequest(URI samlEndpoint, String relayState, Document samlRequest, String privateKeyStr, String publicKeyStr, String certificateStr) { public HttpUriRequest createSamlSignedRequest(URI samlEndpoint, String relayState, Document samlRequest, String privateKeyStr, String publicKeyStr, String certificateStr) {
try { try {
BaseSAML2BindingBuilder binding = new BaseSAML2BindingBuilder().relayState(relayState); BaseSAML2BindingBuilder binding = new BaseSAML2BindingBuilder().relayState(relayState);
if (privateKeyStr != null && publicKeyStr != null) { if (privateKeyStr != null && (publicKeyStr != null || certificateStr != null)) {
PrivateKey privateKey = org.keycloak.testsuite.util.KeyUtils.privateKeyFromString(privateKeyStr); PrivateKey privateKey = org.keycloak.testsuite.util.KeyUtils.privateKeyFromString(privateKeyStr);
PublicKey publicKey = org.keycloak.testsuite.util.KeyUtils.publicKeyFromString(publicKeyStr); PublicKey publicKey = publicKeyStr != null? org.keycloak.testsuite.util.KeyUtils.publicKeyFromString(publicKeyStr) : null;
X509Certificate cert = org.keycloak.common.util.PemUtils.decodeCertificate(certificateStr); X509Certificate cert = org.keycloak.common.util.PemUtils.decodeCertificate(certificateStr);
if (publicKey == null) {
publicKey = cert.getPublicKey();
}
binding.signatureAlgorithm(SignatureAlgorithm.RSA_SHA256) binding.signatureAlgorithm(SignatureAlgorithm.RSA_SHA256)
.signWith(KeyUtils.createKeyId(privateKey), privateKey, publicKey, cert) .signWith(KeyUtils.createKeyId(privateKey), privateKey, publicKey, cert)
.signDocument(); .signDocument();

View file

@ -204,6 +204,7 @@ public abstract class AbstractClientPoliciesTest extends AbstractKeycloakTest {
protected static final String FAPI2_MESSAGE_SIGNING_PROFILE_NAME = "fapi-2-message-signing"; protected static final String FAPI2_MESSAGE_SIGNING_PROFILE_NAME = "fapi-2-message-signing";
protected static final String OAUTH2_1_CONFIDENTIAL_CLIENT_PROFILE_NAME = "oauth-2-1-for-confidential-client"; protected static final String OAUTH2_1_CONFIDENTIAL_CLIENT_PROFILE_NAME = "oauth-2-1-for-confidential-client";
protected static final String OAUTH2_1_PUBLIC_CLIENT_PROFILE_NAME = "oauth-2-1-for-public-client"; protected static final String OAUTH2_1_PUBLIC_CLIENT_PROFILE_NAME = "oauth-2-1-for-public-client";
protected static final String SAML_SECURITY_PROFILE_NAME = "saml-security-profile";
protected static final String ERR_MSG_MISSING_NONCE = "Missing parameter: nonce"; protected static final String ERR_MSG_MISSING_NONCE = "Missing parameter: nonce";
protected static final String ERR_MSG_MISSING_STATE = "Missing parameter: state"; protected static final String ERR_MSG_MISSING_STATE = "Missing parameter: state";
@ -338,7 +339,7 @@ public abstract class AbstractClientPoliciesTest extends AbstractKeycloakTest {
ClientProfilesRepresentation actualProfilesRep = getProfilesWithGlobals(); ClientProfilesRepresentation actualProfilesRep = getProfilesWithGlobals();
// same profiles // same profiles
assertExpectedProfiles(actualProfilesRep, Arrays.asList(FAPI1_BASELINE_PROFILE_NAME, FAPI1_ADVANCED_PROFILE_NAME, FAPI_CIBA_PROFILE_NAME, FAPI2_SECURITY_PROFILE_NAME, FAPI2_MESSAGE_SIGNING_PROFILE_NAME, OAUTH2_1_CONFIDENTIAL_CLIENT_PROFILE_NAME, OAUTH2_1_PUBLIC_CLIENT_PROFILE_NAME), Arrays.asList("ordinal-test-profile", "lack-of-builtin-field-test-profile")); assertExpectedProfiles(actualProfilesRep, Arrays.asList(FAPI1_BASELINE_PROFILE_NAME, FAPI1_ADVANCED_PROFILE_NAME, FAPI_CIBA_PROFILE_NAME, FAPI2_SECURITY_PROFILE_NAME, FAPI2_MESSAGE_SIGNING_PROFILE_NAME, OAUTH2_1_CONFIDENTIAL_CLIENT_PROFILE_NAME, OAUTH2_1_PUBLIC_CLIENT_PROFILE_NAME, SAML_SECURITY_PROFILE_NAME), Arrays.asList("ordinal-test-profile", "lack-of-builtin-field-test-profile"));
// each profile - fapi-1-baseline // each profile - fapi-1-baseline
ClientProfileRepresentation actualProfileRep = getProfileRepresentation(actualProfilesRep, FAPI1_BASELINE_PROFILE_NAME, true); ClientProfileRepresentation actualProfileRep = getProfileRepresentation(actualProfilesRep, FAPI1_BASELINE_PROFILE_NAME, true);

View file

@ -84,7 +84,7 @@ public class ClientPoliciesLoadUpdateTest extends AbstractClientPoliciesTest {
ClientProfilesRepresentation actualProfilesRep = getProfilesWithGlobals(); ClientProfilesRepresentation actualProfilesRep = getProfilesWithGlobals();
// same profiles // same profiles
assertExpectedProfiles(actualProfilesRep, Arrays.asList(FAPI1_BASELINE_PROFILE_NAME, FAPI1_ADVANCED_PROFILE_NAME, FAPI_CIBA_PROFILE_NAME, FAPI2_SECURITY_PROFILE_NAME, FAPI2_MESSAGE_SIGNING_PROFILE_NAME, OAUTH2_1_CONFIDENTIAL_CLIENT_PROFILE_NAME, OAUTH2_1_PUBLIC_CLIENT_PROFILE_NAME), Collections.emptyList()); assertExpectedProfiles(actualProfilesRep, Arrays.asList(FAPI1_BASELINE_PROFILE_NAME, FAPI1_ADVANCED_PROFILE_NAME, FAPI_CIBA_PROFILE_NAME, FAPI2_SECURITY_PROFILE_NAME, FAPI2_MESSAGE_SIGNING_PROFILE_NAME, OAUTH2_1_CONFIDENTIAL_CLIENT_PROFILE_NAME, OAUTH2_1_PUBLIC_CLIENT_PROFILE_NAME, SAML_SECURITY_PROFILE_NAME), Collections.emptyList());
// each profile - fapi-1-baseline // each profile - fapi-1-baseline
ClientProfileRepresentation actualProfileRep = getProfileRepresentation(actualProfilesRep, FAPI1_BASELINE_PROFILE_NAME, true); ClientProfileRepresentation actualProfileRep = getProfileRepresentation(actualProfilesRep, FAPI1_BASELINE_PROFILE_NAME, true);

View file

@ -0,0 +1,458 @@
/*
* Copyright 2023 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.client.policies;
import jakarta.ws.rs.ClientErrorException;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriBuilder;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.function.Function;
import org.apache.http.util.EntityUtils;
import org.hamcrest.MatcherAssert;
import org.hamcrest.Matchers;
import org.junit.Assert;
import org.junit.Test;
import org.keycloak.OAuthErrorException;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.models.Constants;
import org.keycloak.protocol.saml.SamlConfigAttributes;
import org.keycloak.protocol.saml.SamlProtocol;
import org.keycloak.representations.idm.ClientPoliciesRepresentation;
import org.keycloak.representations.idm.ClientPolicyExecutorConfigurationRepresentation;
import org.keycloak.representations.idm.ClientPolicyRepresentation;
import org.keycloak.representations.idm.ClientProfileRepresentation;
import org.keycloak.representations.idm.ClientProfilesRepresentation;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.KeysMetadataRepresentation;
import org.keycloak.representations.idm.KeysMetadataRepresentation.KeyMetadataRepresentation;
import org.keycloak.representations.idm.OAuth2ErrorRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.saml.common.constants.JBossSAMLURIConstants;
import org.keycloak.saml.processing.core.saml.v2.common.SAMLDocumentHolder;
import org.keycloak.services.clientpolicy.condition.ClientProtocolCondition;
import org.keycloak.services.clientpolicy.condition.ClientProtocolConditionFactory;
import org.keycloak.services.clientpolicy.executor.SamlAvoidRedirectBindingExecutorFactory;
import org.keycloak.services.clientpolicy.executor.SamlSecureClientUrisExecutor;
import org.keycloak.services.clientpolicy.executor.SamlSecureClientUrisExecutorFactory;
import org.keycloak.services.clientpolicy.executor.SamlSignatureEnforcerExecutorFactory;
import org.keycloak.services.resources.RealmsResource;
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
import org.keycloak.testsuite.util.ClientPoliciesUtil;
import org.keycloak.testsuite.util.SamlClient;
import org.keycloak.testsuite.util.SamlClientBuilder;
/**
*
* @author rmartinc
*/
public class SamlClientPoliciesExecutorTest extends AbstractTestRealmKeycloakTest {
private static final String PROFILE_POLICY_NAME = "test";
private static final String PRIVATE_KEY = "MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7qZGaTn+o0pWr"
+ "MS09ZVWiOPY1tKzqC+Zuvj9j4C46oNQOi1iSM0CRhhk9UUimYltnNsKoJzduk5hS"
+ "02/0rYhPGwH1AENUQzpmHyBii1u3Ywi6rwb+wPY7EsDzSF7lmwiDAlVJtVhuif+J"
+ "UFNlmP29wWFVaaM1JAK7wTgiOSoxeFqQPBwL4d+kjKm4S9LV6pOs798WEwvBJKtB"
+ "bve/ZvQHmpbt3ftbBegaXQJUzsxr7xqo+DKI6RPAprn0v0W8pGHiMQ/WoTtan5ka"
+ "R3LFSShtx3lX1WVNnT+0wBZcLEkQzaB1R6prwYlSQ6JaydjUqstInrgU2WUVwXJi"
+ "MNR0ulB7AgMBAAECggEANfV/5DqGAmjqmBq/w1OL1+VBBhg5T+K0E0uotnMTV9A+"
+ "qR/wC7mo6y7/ut3QYecOGRNpzSfZjHXr6oTZQCVVeElvup6kvWnHNO3mRe+EI6ra"
+ "K7N/82hQZJPz3wAEKUj2nZTiKRt3nfEYBMeP8zqWWyVrcz+4qeL81jesiEqfkzFl"
+ "gefsbBwYz7odNHyvYOYklrudKpfQQDff9zKEpPh9ou6TP5cHyPXNxN2HxSKjAsxN"
+ "OO/zxrtJeuP1pLMhLJQZuQyAuckdkAy4Cv8K8x+r6/8PxIFjz1+6+I4uolnsSpS7"
+ "+upOK+866XUIE6h6hlLTEp+XXds+/LFZaT+B/vBTwQKBgQDKCxNuwrRAD+JifB0t"
+ "hvnew8kwW48dT0ZM2m3Fw85Vey2hVStZoR6tYuxtoXLBpJCjGpeFhxm1BPV38SNr"
+ "78W98NduS57uU5iueByKY/twDws/AFSHpsLyMiWJBBwH8VRm0U4GmFxBlNCrU3TP"
+ "ddJxAgy5XYSP+LG7LXr0jZ1oSQKBgQDtx1OhAR14urfnuQgsmH9G6bEXFmR71uL8"
+ "OJBU0n/AB8bPQbisDRPcSkdF7KgQI5hJE+5+8aERmAZ1B//5wbZlmR/lmd+V/c/6"
+ "BAxJkQicjVG5EhgliM73z4jm/85pYfkN9IgbZlB7vCVKfKWKSIwJ/pY039WN+2t4"
+ "BsenhJ2aowKBgBnJCBXeq3pxjIbdKCwjSchwXEDbrowjDenJBrFyp+ao7c3lPL8X"
+ "nP6r3ViwfiDQi9UFE8lq0JEVrO49zDN+SlJPZm8hH4tzB81cbugKkpBemyTTOfaG"
+ "BeM7Gyc9awZoekkU9UxKLZwBDhCPehzwAIeDp3QQx1ZIewZUa5jCahBhAoGAd/Yg"
+ "UxJk9Av/zIClhxpI3FX6alN5zqDTU7yV1LV+jjteKiJWMTdH1dQDsVt8TugmZHgR"
+ "0ynEwUOZvmGS20bH5uoiFYxUKTAsRU7VhCgP2CvUFzLxy74B7TRfNWvJj5FGPawp"
+ "Hum3oTWC+tl4CxQe0swGrBZhf4hg5+VDxVg6y1ECgYEAns+tbdeBJWV/r3Rh3e0C"
+ "LetywsgNG02aIuZpIlT4VWKV5cIIq6d20C6I/EKhAZ0E56D8xX6xVmUBcY6Qv1zd"
+ "7yOVwITM3P64G+ZtPkm3m3w7XRnaIEUHuwFWpdMPjfdipjelq0ltbRQMOiqYAQYR"
+ "jfH4O0lZc8bo2TVGeQHpyg4=";
private static final String CERTIFICATE = "MIICyDCCAbCgAwIBAgIJAIiYjotcPTEkMA0GCSqGSIb3DQEBCwUAMBExDzANBgNV"
+ "BAMTBmNsaWVudDAgFw0yNDAxMjYxMzM5MjNaGA8yMDc4MTAyOTEzMzkyM1owETEP"
+ "MA0GA1UEAxMGY2xpZW50MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA"
+ "u6mRmk5/qNKVqzEtPWVVojj2NbSs6gvmbr4/Y+AuOqDUDotYkjNAkYYZPVFIpmJb"
+ "ZzbCqCc3bpOYUtNv9K2ITxsB9QBDVEM6Zh8gYotbt2MIuq8G/sD2OxLA80he5ZsI"
+ "gwJVSbVYbon/iVBTZZj9vcFhVWmjNSQCu8E4IjkqMXhakDwcC+HfpIypuEvS1eqT"
+ "rO/fFhMLwSSrQW73v2b0B5qW7d37WwXoGl0CVM7Ma+8aqPgyiOkTwKa59L9FvKRh"
+ "4jEP1qE7Wp+ZGkdyxUkobcd5V9VlTZ0/tMAWXCxJEM2gdUeqa8GJUkOiWsnY1KrL"
+ "SJ64FNllFcFyYjDUdLpQewIDAQABoyEwHzAdBgNVHQ4EFgQUplMyjmtmloAy8sTA"
+ "CENFZugti98wDQYJKoZIhvcNAQELBQADggEBACXxwe1HJ0j56SgGueNSzfoUXwI4"
+ "a0XUN73I3zuXOwBoSqJr7X17B0ZDrHAb+k1WOz1iIz6OA2Bi1p8rtYqn/rLAdCbQ"
+ "fatlSzVrVkxc689LEOFiN9eGlfBpqX/VllY9DPzmMoPLa1v0Ya/AXIQlyURbe3Ve"
+ "PHdhS8lScQi239FtSq1pKlRRzBsfTNwD7MbgY2kGPSKBqe9TuYqYTjc4r0XmjVO2"
+ "ZI3mUuNOSpBrH2YY5umutjH4ZTJstzf82kp1m+/wsNM46ZvV4DCHxNUESONzZteW"
+ "+9OgpVwAt9ltqlX6qFxq04S0pAA2AyLnDvMuIUgtdNn7jFCwqYCePnDWJfY=";
@Override
public void configureTestRealm(RealmRepresentation testRealm) {
// no-op
}
@Test
public void testSamlSecureClientUrisExecutor() throws Exception {
createClientProfileAndPolicyToTest(SamlSecureClientUrisExecutorFactory.PROVIDER_ID, null);
final RealmResource realm = testRealm();
String clientId = null;
try {
final ClientRepresentation client = createSecureClient("test-saml-client");
// test creation fails for non-https urls
testSamlSecureClient(client, c -> realm.clients().create(c));
// create it
testClientOperation(client, null, c -> realm.clients().create(c));
clientId = realm.clients().findByClientId(client.getClientId()).iterator().next().getId();
client.setId(clientId);
// test update fails for non-https urls
testSamlSecureClient(client, c -> updateClient(realm, c));
// test wildcard redirects are not valid
testRedirectUrisWildcard(client, c -> updateClient(realm, c));
// test a login with https is valid
testAuthenticationPostSuccess(client);
// test a login with http is invalid
testAuthenticationPostError(client, "http://client.keycloak.org/saml/");
} finally {
removeClientProfileAndPolicyToTest();
if (clientId != null) {
realm.clients().get(clientId).remove();
}
}
}
@Test
public void testSamlAvoidRedirectBindingExecutor() throws Exception {
String clientId = null;
final RealmResource realm = testRealm();
try {
final ClientRepresentation client = createSecureClient("test-saml-client");
client.getAttributes().put(SamlConfigAttributes.SAML_FORCE_POST_BINDING, Boolean.FALSE.toString());
// create a client without the executor enabled
testClientOperation(client, null, c -> realm.clients().create(c));
clientId = realm.clients().findByClientId(client.getClientId()).iterator().next().getId();
// enable the policy and check redirect is not allowed
createClientProfileAndPolicyToTest(SamlAvoidRedirectBindingExecutorFactory.PROVIDER_ID, null);
testAuthenticationRedirectPostError(client);
realm.clients().get(clientId).remove();
// test creation without signature fails
testClientOperation(client, "Force POST binding is not enabled", c -> realm.clients().create(c));
// put force post binding
client.getAttributes().put(SamlConfigAttributes.SAML_FORCE_POST_BINDING, Boolean.TRUE.toString());
testClientOperation(client, null, c -> realm.clients().create(c));
// update without post binding fails
clientId = realm.clients().findByClientId(client.getClientId()).iterator().next().getId();
client.setId(clientId);
client.getAttributes().put(SamlConfigAttributes.SAML_FORCE_POST_BINDING, Boolean.FALSE.toString());
testClientOperation(client, "Force POST binding is not enabled", c -> updateClient(realm, client));
// with force works OK
client.getAttributes().put(SamlConfigAttributes.SAML_FORCE_POST_BINDING, Boolean.TRUE.toString());
testClientOperation(client, null, c -> updateClient(realm, client));
// test a REDIRECT is forced to POST OK
testAuthenticationRedirectPostSuccess(client);
} finally {
removeClientProfileAndPolicyToTest();
if (clientId != null) {
realm.clients().get(clientId).remove();
}
}
}
@Test
public void testSamlSignatureEnforcerExecutor() throws Exception {
String clientId = null;
final RealmResource realm = testRealm();
try {
final ClientRepresentation client = createSecureClient("test-saml-client");
client.getAttributes().put(SamlConfigAttributes.SAML_CLIENT_SIGNATURE_ATTRIBUTE, Boolean.FALSE.toString());
// create a client without client signature
testClientOperation(client, null, c -> realm.clients().create(c));
clientId = realm.clients().findByClientId(client.getClientId()).iterator().next().getId();
// enable the policy and check login without signature is not allowed
createClientProfileAndPolicyToTest(SamlSignatureEnforcerExecutorFactory.PROVIDER_ID, null);
testAuthenticationPostError(client, client.getAdminUrl());
realm.clients().get(clientId).remove();
// test creation
testSamlAttributeOperation(client, "Signatures not ensured for the client.",
SamlConfigAttributes.SAML_CLIENT_SIGNATURE_ATTRIBUTE,
Boolean.FALSE.toString(), Boolean.TRUE.toString(), c -> realm.clients().create(c));
testSamlAttributeOperation(client, "Signatures not ensured for the client.",
SamlConfigAttributes.SAML_SERVER_SIGNATURE,
Boolean.FALSE.toString(), Boolean.FALSE.toString(), c -> realm.clients().create(c));
testSamlAttributeOperation(client, "Signatures not ensured for the client.",
SamlConfigAttributes.SAML_ASSERTION_SIGNATURE,
Boolean.FALSE.toString(), Boolean.FALSE.toString(), c -> realm.clients().create(c));
testSamlAttributeOperation(client, null,
SamlConfigAttributes.SAML_SERVER_SIGNATURE,
Boolean.TRUE.toString(), Boolean.TRUE.toString(), c -> realm.clients().create(c));
clientId = realm.clients().findByClientId(client.getClientId()).iterator().next().getId();
client.setId(clientId);
// test update
testSamlAttributeOperation(client, "Signatures not ensured for the client.",
SamlConfigAttributes.SAML_CLIENT_SIGNATURE_ATTRIBUTE,
Boolean.FALSE.toString(), Boolean.TRUE.toString(), c -> updateClient(realm, c));
testSamlAttributeOperation(client, "Signatures not ensured for the client.",
SamlConfigAttributes.SAML_SERVER_SIGNATURE,
Boolean.FALSE.toString(), Boolean.FALSE.toString(), c -> updateClient(realm, c));
testSamlAttributeOperation(client, "Signatures not ensured for the client.",
SamlConfigAttributes.SAML_ASSERTION_SIGNATURE,
Boolean.FALSE.toString(), Boolean.FALSE.toString(), c -> updateClient(realm, c));
// test login is OK with signatures
client.getAttributes().put(SamlConfigAttributes.SAML_CLIENT_SIGNATURE_ATTRIBUTE, Boolean.TRUE.toString());
client.getAttributes().put(SamlConfigAttributes.SAML_SERVER_SIGNATURE, Boolean.TRUE.toString());
client.getAttributes().put(SamlConfigAttributes.SAML_ASSERTION_SIGNATURE, Boolean.FALSE.toString());
client.getAttributes().put(SamlConfigAttributes.SAML_SIGNING_CERTIFICATE_ATTRIBUTE, CERTIFICATE);
testClientOperation(client, null, c -> updateClient(realm, c));
testAuthenticationPostSignatureSuccess(realm, client, PRIVATE_KEY, CERTIFICATE);
} finally {
removeClientProfileAndPolicyToTest();
if (clientId != null) {
realm.clients().get(clientId).remove();
}
}
}
private Response updateClient(RealmResource realm, ClientRepresentation client) {
try {
realm.clients().get(client.getId()).update(client);
return null;
} catch (ClientErrorException e) {
return e.getResponse();
}
}
private void testAuthenticationPostSuccess(ClientRepresentation client) {
new SamlClientBuilder()
.authnRequest(RealmsResource.protocolUrl(UriBuilder.fromUri(getAuthServerRoot())).build(TEST_REALM_NAME, SamlProtocol.LOGIN_PROTOCOL),
client.getClientId(), client.getAdminUrl(), SamlClient.Binding.POST)
.build()
.login().user("test-user@localhost", "password").build()
.execute(hr -> {
try {
SAMLDocumentHolder doc = SamlClient.Binding.POST.extractResponse(hr);
MatcherAssert.assertThat(doc.getSamlObject(),
org.keycloak.testsuite.util.Matchers.isSamlStatusResponse(JBossSAMLURIConstants.STATUS_SUCCESS));
} catch (IOException ex) {
throw new IllegalStateException(ex);
}
});
}
private void testAuthenticationPostSignatureSuccess(RealmResource realm, ClientRepresentation client, String privateKey, String certificate) {
KeysMetadataRepresentation keysMetadata = realm.keys().getKeyMetadata();
String kid = keysMetadata.getActive().get(Constants.DEFAULT_SIGNATURE_ALGORITHM);
KeyMetadataRepresentation keyMetadata = keysMetadata.getKeys().stream()
.filter(k -> kid.equals(k.getKid())).findAny().orElse(null);
Assert.assertNotNull(keyMetadata);
new SamlClientBuilder()
.authnRequest(RealmsResource.protocolUrl(UriBuilder.fromUri(getAuthServerRoot())).build(TEST_REALM_NAME, SamlProtocol.LOGIN_PROTOCOL),
client.getClientId(), client.getAdminUrl(), SamlClient.Binding.POST)
.signWith(privateKey, null, certificate)
.build()
.login().user("test-user@localhost", "password").build()
.execute(hr -> {
try {
SAMLDocumentHolder doc = SamlClient.Binding.POST.extractResponse(hr, keyMetadata.getPublicKey());
MatcherAssert.assertThat(doc.getSamlObject(),
org.keycloak.testsuite.util.Matchers.isSamlStatusResponse(JBossSAMLURIConstants.STATUS_SUCCESS));
} catch (IOException ex) {
throw new IllegalStateException(ex);
}
});
}
private void testAuthenticationPostError(ClientRepresentation client, String assertionUrl) {
new SamlClientBuilder()
.authnRequest(RealmsResource.protocolUrl(UriBuilder.fromUri(getAuthServerRoot())).build(TEST_REALM_NAME, SamlProtocol.LOGIN_PROTOCOL),
client.getClientId(), assertionUrl, SamlClient.Binding.POST)
.build()
.executeAndTransform(response -> {
Assert.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusLine().getStatusCode());
MatcherAssert.assertThat(EntityUtils.toString(response.getEntity(), "UTF-8"),
Matchers.containsString("Invalid Request"));
return null;
});
}
private void testAuthenticationRedirectPostSuccess(ClientRepresentation client) {
new SamlClientBuilder()
.authnRequest(RealmsResource.protocolUrl(UriBuilder.fromUri(getAuthServerRoot())).build(TEST_REALM_NAME, SamlProtocol.LOGIN_PROTOCOL),
client.getClientId(), client.getAdminUrl(), SamlClient.Binding.REDIRECT)
.build()
.login().user("test-user@localhost", "password").build()
.execute(hr -> {
try {
SAMLDocumentHolder doc = SamlClient.Binding.POST.extractResponse(hr);
MatcherAssert.assertThat(doc.getSamlObject(),
org.keycloak.testsuite.util.Matchers.isSamlStatusResponse(JBossSAMLURIConstants.STATUS_SUCCESS));
} catch (IOException ex) {
throw new IllegalStateException(ex);
}
});
}
private void testAuthenticationRedirectPostError(ClientRepresentation client) {
new SamlClientBuilder()
.authnRequest(RealmsResource.protocolUrl(UriBuilder.fromUri(getAuthServerRoot())).build(TEST_REALM_NAME, SamlProtocol.LOGIN_PROTOCOL),
client.getClientId(), client.getAdminUrl(), SamlClient.Binding.REDIRECT)
.build()
.executeAndTransform(response -> {
Assert.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusLine().getStatusCode());
MatcherAssert.assertThat(EntityUtils.toString(response.getEntity(), "UTF-8"),
Matchers.containsString("Invalid Request"));
return null;
});
}
private void testClientOperation(ClientRepresentation client, String errorPrefix, Function<ClientRepresentation, Response> operation) {
try (Response response = operation.apply(client)) {
if (errorPrefix == null) {
if (response != null) {
// create returns 201, update returns null
Assert.assertEquals(Response.Status.CREATED.getStatusCode(), response.getStatus());
}
} else {
Assert.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatus());
OAuth2ErrorRepresentation error = response.readEntity(OAuth2ErrorRepresentation.class);
Assert.assertEquals(OAuthErrorException.INVALID_CLIENT_METADATA, error.getError());
MatcherAssert.assertThat(error.getErrorDescription(), Matchers.startsWith(errorPrefix));
}
}
}
private void testSamlAttributeOperation(ClientRepresentation client, String error, String attr,
String beforeValue, String afterValue, Function<ClientRepresentation, Response> operation) {
client.getAttributes().put(attr, beforeValue);
testClientOperation(client, error, operation);
client.getAttributes().put(attr, afterValue);
}
private void testSamlSecureClientAttribute(ClientRepresentation client,
String attr, Function<ClientRepresentation, Response> operation) {
testSamlAttributeOperation(client, "Non secure scheme for ", attr, "http://client.keycloak.org/saml/",
"https://client.keycloak.org/saml/", operation);
}
private void testRedirectUrisWildcard(ClientRepresentation client, Function<ClientRepresentation, Response> operation)
throws Exception {
// wildcards allowed
createClientProfileAndPolicyToTest(SamlSecureClientUrisExecutorFactory.PROVIDER_ID,
new SamlSecureClientUrisExecutor.Configuration(true));
client.getRedirectUris().add("https://client.keycloak.org/saml/*");
testClientOperation(client, null, operation);
// wildcards disallowed
createClientProfileAndPolicyToTest(SamlSecureClientUrisExecutorFactory.PROVIDER_ID,
new SamlSecureClientUrisExecutor.Configuration(false));
testClientOperation(client, "Unsecure wildcard redirect ", operation);
client.getRedirectUris().remove("https://client.keycloak.org/saml/*");
testClientOperation(client, null, operation);
}
private void testSamlSecureClient(ClientRepresentation client, Function<ClientRepresentation, Response> operation) {
// rootUrl
client.setRootUrl("http://client.keycloak.org/saml/");
testClientOperation(client, "Non secure scheme for ", operation);
client.setRootUrl("https://client.keycloak.org/saml/");
// baseUrl
client.setBaseUrl("http://client.keycloak.org/saml/");
testClientOperation(client, "Non secure scheme for ", operation);
client.setBaseUrl("https://client.keycloak.org/saml/");
// adminUrl
client.setAdminUrl("http://client.keycloak.org/saml/");
testClientOperation(client, "Non secure scheme for ", operation);
client.setAdminUrl("https://client.keycloak.org/saml/");
// redirect URIs
client.getRedirectUris().add("http://client.keycloak.org/saml/");
testClientOperation(client, "Non secure scheme for ", operation);
client.getRedirectUris().remove("http://client.keycloak.org/saml/");
// test saml specific protocol urls
testSamlSecureClientAttribute(client, SamlProtocol.SAML_ASSERTION_CONSUMER_URL_POST_ATTRIBUTE, operation);
testSamlSecureClientAttribute(client, SamlProtocol.SAML_ASSERTION_CONSUMER_URL_REDIRECT_ATTRIBUTE, operation);
testSamlSecureClientAttribute(client, SamlProtocol.SAML_ASSERTION_CONSUMER_URL_ARTIFACT_ATTRIBUTE, operation);
testSamlSecureClientAttribute(client, SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_POST_ATTRIBUTE, operation);
testSamlSecureClientAttribute(client, SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_ARTIFACT_ATTRIBUTE, operation);
testSamlSecureClientAttribute(client, SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_REDIRECT_ATTRIBUTE, operation);
testSamlSecureClientAttribute(client, SamlProtocol.SAML_SINGLE_LOGOUT_SERVICE_URL_SOAP_ATTRIBUTE, operation);
testSamlSecureClientAttribute(client, SamlProtocol.SAML_ARTIFACT_RESOLUTION_SERVICE_URL_ATTRIBUTE, operation);
}
private void createClientProfileAndPolicyToTest(String executorId, ClientPolicyExecutorConfigurationRepresentation config) throws Exception {
RealmResource realm = testRealm();
ClientProfileRepresentation profile = new ClientPoliciesUtil.ClientProfileBuilder()
.createProfile(PROFILE_POLICY_NAME, "The profile to test")
.addExecutor(executorId, config)
.toRepresentation();
ClientProfilesRepresentation profiles = new ClientPoliciesUtil.ClientProfilesBuilder()
.addProfile(profile)
.toRepresentation();
realm.clientPoliciesProfilesResource().updateProfiles(profiles);
ClientPolicyRepresentation policy = new ClientPoliciesUtil.ClientPolicyBuilder()
.createPolicy(PROFILE_POLICY_NAME, "The policy to test.", Boolean.TRUE)
.addCondition(ClientProtocolConditionFactory.PROVIDER_ID, new ClientProtocolCondition.Configuration(SamlProtocol.LOGIN_PROTOCOL))
.addProfile(PROFILE_POLICY_NAME)
.toRepresentation();
ClientPoliciesRepresentation policies = new ClientPoliciesUtil.ClientPoliciesBuilder()
.addPolicy(policy)
.toRepresentation();
realm.clientPoliciesPoliciesResource().updatePolicies(policies);
}
private void removeClientProfileAndPolicyToTest() {
ClientProfilesRepresentation profiles = new ClientPoliciesUtil.ClientProfilesBuilder().toRepresentation();
adminClient.realm(TEST_REALM_NAME).clientPoliciesProfilesResource().updateProfiles(profiles);
ClientPoliciesRepresentation policies = new ClientPoliciesUtil.ClientPoliciesBuilder().toRepresentation();
adminClient.realm(TEST_REALM_NAME).clientPoliciesPoliciesResource().updatePolicies(policies);
}
private ClientRepresentation createSecureClient(String clientId) {
ClientRepresentation client = new ClientRepresentation();
client.setName(clientId);
client.setClientId(clientId);
client.setProtocol(SamlProtocol.LOGIN_PROTOCOL);
client.setRootUrl("https://client.keycloak.org/saml/");
client.setAdminUrl("https://client.keycloak.org/saml/");
client.setBaseUrl("https://client.keycloak.org/saml/");
List<String> redirectUris = new ArrayList<>();
redirectUris.add("https://client.keycloak.org/saml/");
client.setRedirectUris(redirectUris);
client.setAttributes(new HashMap<>());
client.getAttributes().put(SamlConfigAttributes.SAML_CLIENT_SIGNATURE_ATTRIBUTE, Boolean.FALSE.toString());
return client;
}
}

View file

@ -21,7 +21,6 @@ import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.keycloak.admin.client.resource.ClientResource;
import org.keycloak.common.util.Base64Url; import org.keycloak.common.util.Base64Url;
import org.keycloak.crypto.AsymmetricSignatureSignerContext; import org.keycloak.crypto.AsymmetricSignatureSignerContext;
import org.keycloak.crypto.ECDSASignatureSignerContext; import org.keycloak.crypto.ECDSASignatureSignerContext;
@ -34,7 +33,6 @@ import org.keycloak.jose.jwk.ECPublicJWK;
import org.keycloak.jose.jwk.JWK; import org.keycloak.jose.jwk.JWK;
import org.keycloak.jose.jwk.RSAPublicJWK; import org.keycloak.jose.jwk.RSAPublicJWK;
import org.keycloak.jose.jws.JWSHeader; import org.keycloak.jose.jws.JWSHeader;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.grants.ciba.clientpolicy.executor.SecureCibaAuthenticationRequestSigningAlgorithmExecutor; import org.keycloak.protocol.oidc.grants.ciba.clientpolicy.executor.SecureCibaAuthenticationRequestSigningAlgorithmExecutor;
import org.keycloak.representations.dpop.DPoP; import org.keycloak.representations.dpop.DPoP;
import org.keycloak.representations.idm.ClientPoliciesRepresentation; import org.keycloak.representations.idm.ClientPoliciesRepresentation;
@ -45,7 +43,6 @@ import org.keycloak.representations.idm.ClientPolicyExecutorRepresentation;
import org.keycloak.representations.idm.ClientPolicyRepresentation; import org.keycloak.representations.idm.ClientPolicyRepresentation;
import org.keycloak.representations.idm.ClientProfileRepresentation; import org.keycloak.representations.idm.ClientProfileRepresentation;
import org.keycloak.representations.idm.ClientProfilesRepresentation; import org.keycloak.representations.idm.ClientProfilesRepresentation;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.services.clientpolicy.ClientPolicyEvent; import org.keycloak.services.clientpolicy.ClientPolicyEvent;
import org.keycloak.services.clientpolicy.condition.ClientAccessTypeCondition; import org.keycloak.services.clientpolicy.condition.ClientAccessTypeCondition;
import org.keycloak.services.clientpolicy.condition.ClientRolesCondition; import org.keycloak.services.clientpolicy.condition.ClientRolesCondition;
@ -336,6 +333,9 @@ public final class ClientPoliciesUtil {
} }
public ClientPolicyBuilder addCondition(String providerId, ClientPolicyConditionConfigurationRepresentation config) throws Exception { public ClientPolicyBuilder addCondition(String providerId, ClientPolicyConditionConfigurationRepresentation config) throws Exception {
if (config == null) {
config = new ClientPolicyConditionConfigurationRepresentation();
}
ClientPolicyConditionRepresentation condition = new ClientPolicyConditionRepresentation(); ClientPolicyConditionRepresentation condition = new ClientPolicyConditionRepresentation();
condition.setConditionProviderId(providerId); condition.setConditionProviderId(providerId);
condition.setConfiguration(JsonSerialization.mapper.readValue(JsonSerialization.mapper.writeValueAsBytes(config), JsonNode.class)); condition.setConfiguration(JsonSerialization.mapper.readValue(JsonSerialization.mapper.writeValueAsBytes(config), JsonNode.class));