Initial client policies integration for SAML
Closes #26654 Signed-off-by: rmartinc <rmartinc@redhat.com>
This commit is contained in:
parent
65448ff8c3
commit
82af0b6af6
26 changed files with 1532 additions and 21 deletions
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
@ -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");
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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:
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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.");
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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.");
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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": {}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
|
@ -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();
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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));
|
||||||
|
|
Loading…
Reference in a new issue