diff --git a/docs/documentation/server_admin/topics/identity-broker/saml.adoc b/docs/documentation/server_admin/topics/identity-broker/saml.adoc index 33915d384e65..e4b4c9fa09ed 100644 --- a/docs/documentation/server_admin/topics/identity-broker/saml.adoc +++ b/docs/documentation/server_admin/topics/identity-broker/saml.adoc @@ -25,6 +25,9 @@ image:images/saml-add-identity-provider.png[Add Identity Provider] |Single Sign-On Service URL |The SAML endpoint that starts the authentication process. If your SAML IDP publishes an IDP entity descriptor, the value of this field is specified there. +|Artifact service URL +|The SAML artifact resolution endpoint. If your SAML IDP publishes an IDP entity descriptor, the value of this field is specified there. + |Single Logout Service URL |The SAML logout endpoint. If your SAML IDP publishes an IDP entity descriptor, the value of this field is specified there. @@ -46,6 +49,9 @@ image:images/saml-add-identity-provider.png[Add Identity Provider] |HTTP-POST Binding Response |Controls the SAML binding in response to any SAML requests sent by an external IDP. When *OFF*, {project_name} uses Redirect Binding. +|ARTIFACT Binding Response +|Controls the SAML binding in response to any SAML requests sent by an external IDP. When *OFF*, {project_name} evaluates the HTTP-POST Binding Response configuration. + |HTTP-POST Binding for AuthnRequest |Controls the SAML binding when requesting authentication from an external IDP. When *OFF*, {project_name} uses Redirect Binding. diff --git a/js/apps/admin-ui/cypress/e2e/partial_import_test.spec.ts b/js/apps/admin-ui/cypress/e2e/partial_import_test.spec.ts index 46247c2f0816..2ad43ee0c8f8 100644 --- a/js/apps/admin-ui/cypress/e2e/partial_import_test.spec.ts +++ b/js/apps/admin-ui/cypress/e2e/partial_import_test.spec.ts @@ -119,8 +119,8 @@ describe("Partial import test", () => { //clear button should be disabled if there is nothing in the dialog modal.clearButton().should("be.disabled"); - modal.textArea().type("{}", { force: true }); - modal.textArea().get(".view-lines").should("have.text", "{}"); + modal.textArea().type("test", { force: true }); + modal.textArea().get(".view-lines").should("have.text", "test"); modal.clearButton().should("not.be.disabled"); modal.clearButton().click(); modal.clickClearConfirmButton(); diff --git a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties index 0b6143444229..3acb98ba1c70 100644 --- a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties +++ b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties @@ -376,6 +376,7 @@ createAttributeError=Error\! User Profile configuration has not been saved {{err password=Password eventTypes.VERIFY_EMAIL.name=Verify email httpPostBindingResponseHelp=Indicates whether to respond to requests using HTTP-POST binding. If false, HTTP-REDIRECT binding will be used. +artifactBindingResponseHelp=Indicates whether to respond to requests using ARTIFACT binding. If false, the HTTP-POST binding configuration will be evaluated. mapperTypeHardcodedAttributeMapper=hardcoded-attribute-mapper eventTypes.IMPERSONATE.description=Impersonate forbidden_other=Forbidden, permissions needed\: @@ -1748,6 +1749,7 @@ idTokenSignatureAlgorithm=ID token signature algorithm displayHeaderHintHelp=A user-friendly name for the group that should be used when rendering a group of attributes in user-facing forms. Supports keys for localized values as well. For example\: ${profile.attribute.group.address}. providerInfo=Provider info ssoServiceUrl=Single Sign-On service URL +artifactResolutionServiceUrl=Artifact Resolution service URL inputHelperTextAfter=Helper text (under) the input field appliedByClients=Applied by the following clients createFlowHelp=You can create a top level flow within this from @@ -2075,6 +2077,7 @@ experimental=Experimental idTokenSignatureAlgorithmHelp=JWA algorithm used for signing ID tokens. deleteResourceConfirm=If you delete this resource, some permissions will be affected. httpPostBindingResponse=HTTP-POST binding response +artifactBindingResponse=ARTIFACT binding response tokenLifespan.inherited=Inherits from realm settings saveEvents=Save events issuer=Issuer @@ -2825,6 +2828,7 @@ clientUpdaterTrustedHosts=Trusted Hosts deleteSuccess=Attributes group deleted. attributesDropdown=Attributes dropdown ssoServiceUrlHelp=The Url that must be used to send authentication requests (SAML AuthnRequest). +artifactResolutionServiceUrlHelp=The Url that must be used to get SAML assertions from artifacts (SAML ArtifactResolve). copy=Copy credentialData=Data clientRolesConditionTooltip=Client roles, which will be checked during this condition evaluation. Condition evaluates to true if client has at least one client role with the name as the client roles specified in the configuration. diff --git a/js/apps/admin-ui/src/identity-providers/add/DescriptorSettings.tsx b/js/apps/admin-ui/src/identity-providers/add/DescriptorSettings.tsx index e4ab8951ecdc..f5829aa7ee78 100644 --- a/js/apps/admin-ui/src/identity-providers/add/DescriptorSettings.tsx +++ b/js/apps/admin-ui/src/identity-providers/add/DescriptorSettings.tsx @@ -70,6 +70,13 @@ const Fields = ({ readOnly }: DescriptorSettingsProps) => { readOnly={readOnly} rules={{ required: t("required") }} /> + { stringify /> + + { + protected String artifact; + protected String destination; + protected NameIDType issuer; + protected final List extensions = new LinkedList<>(); + + public SAML2ArtifactResolveRequestBuilder artifact(String artifact) { + this.artifact = artifact; + return this; + } + + public SAML2ArtifactResolveRequestBuilder destination(String destination) { + this.destination = destination; + return this; + } + + public SAML2ArtifactResolveRequestBuilder issuer(NameIDType issuer) { + this.issuer = issuer; + return this; + } + + public SAML2ArtifactResolveRequestBuilder issuer(String issuer) { + return issuer(SAML2NameIDBuilder.value(issuer).build()); + } + + @Override + public SAML2ArtifactResolveRequestBuilder addExtension(NodeGenerator extension) { + this.extensions.add(extension); + return this; + } + + public Document buildDocument() throws ProcessingException, ConfigurationException, ParsingException { + Document document = SAML2Request.convert(createArtifactResolveRequest()); + return document; + } + + public ArtifactResolveType createArtifactResolveRequest() throws ConfigurationException { + ArtifactResolveType lort = SAML2Request.createArtifactResolveRequest(issuer); + + lort.setIssuer(issuer); + + if (destination != null) { + lort.setDestination(URI.create(destination)); + } + + if (artifact != null) { + lort.setArtifact(artifact); + } + + if (!this.extensions.isEmpty()) { + ExtensionsType extensionsType = new ExtensionsType(); + for (NodeGenerator extension : this.extensions) { + extensionsType.addExtension(extension); + } + lort.setExtensions(extensionsType); + } + + return lort; + } +} \ No newline at end of file diff --git a/saml-core/src/main/java/org/keycloak/saml/processing/api/saml/v2/request/SAML2Request.java b/saml-core/src/main/java/org/keycloak/saml/processing/api/saml/v2/request/SAML2Request.java index 4790f62b789e..c99cc6674eaf 100755 --- a/saml-core/src/main/java/org/keycloak/saml/processing/api/saml/v2/request/SAML2Request.java +++ b/saml-core/src/main/java/org/keycloak/saml/processing/api/saml/v2/request/SAML2Request.java @@ -18,6 +18,7 @@ import org.keycloak.dom.saml.v2.SAML2Object; import org.keycloak.dom.saml.v2.assertion.NameIDType; +import org.keycloak.dom.saml.v2.protocol.ArtifactResolveType; import org.keycloak.dom.saml.v2.protocol.AuthnRequestType; import org.keycloak.dom.saml.v2.protocol.LogoutRequestType; import org.keycloak.dom.saml.v2.protocol.NameIDPolicyType; @@ -275,6 +276,22 @@ public static LogoutRequestType createLogoutRequest(NameIDType issuer) throws Co return lrt; } + /** + * Create a Artifact Resolve Request + * + * @param issuer + * + * @return + * + * @throws ConfigurationException + */ + public static ArtifactResolveType createArtifactResolveRequest(NameIDType issuer) { + ArtifactResolveType lrt = new ArtifactResolveType(IDGenerator.create("ID_"), XMLTimeUtil.getIssueInstant()); + + lrt.setIssuer(issuer); + + return lrt; + } /** * Return the DOM object * @@ -294,6 +311,8 @@ public static Document convert(RequestAbstractType rat) throws ProcessingExcepti writer.write((AuthnRequestType) rat); } else if (rat instanceof LogoutRequestType) { writer.write((LogoutRequestType) rat); + } else if (rat instanceof ArtifactResolveType) { + writer.write((ArtifactResolveType) rat); } return DocumentUtil.getDocument(new String(bos.toByteArray(), GeneralConstants.SAML_CHARSET)); diff --git a/saml-core/src/main/java/org/keycloak/saml/processing/api/saml/v2/response/SAML2Response.java b/saml-core/src/main/java/org/keycloak/saml/processing/api/saml/v2/response/SAML2Response.java index 1500512e03b7..7fc5924c1957 100755 --- a/saml-core/src/main/java/org/keycloak/saml/processing/api/saml/v2/response/SAML2Response.java +++ b/saml-core/src/main/java/org/keycloak/saml/processing/api/saml/v2/response/SAML2Response.java @@ -34,6 +34,7 @@ import org.keycloak.dom.saml.v2.assertion.SubjectConfirmationDataType; import org.keycloak.dom.saml.v2.assertion.SubjectConfirmationType; import org.keycloak.dom.saml.v2.assertion.SubjectType; +import org.keycloak.dom.saml.v2.protocol.ArtifactResponseType; import org.keycloak.dom.saml.v2.protocol.ResponseType; import org.keycloak.dom.saml.v2.protocol.StatusResponseType; import org.keycloak.saml.common.PicketLinkLogger; @@ -445,7 +446,10 @@ public static Document convert(StatusResponseType responseType) throws Processin SAMLResponseWriter writer = new SAMLResponseWriter(StaxUtil.getXMLStreamWriter(bos)); - if (responseType instanceof ResponseType) { + if (responseType instanceof ArtifactResponseType) { + ArtifactResponseType response = (ArtifactResponseType) responseType; + writer.write(response); + } else if (responseType instanceof ResponseType) { ResponseType response = (ResponseType) responseType; writer.write(response); } else { diff --git a/services/src/main/java/org/keycloak/broker/saml/SAMLEndpoint.java b/services/src/main/java/org/keycloak/broker/saml/SAMLEndpoint.java index 955229c4ce95..8b41ad7ad051 100755 --- a/services/src/main/java/org/keycloak/broker/saml/SAMLEndpoint.java +++ b/services/src/main/java/org/keycloak/broker/saml/SAMLEndpoint.java @@ -33,6 +33,7 @@ import org.keycloak.dom.saml.v2.assertion.SubjectConfirmationDataType; import org.keycloak.dom.saml.v2.assertion.SubjectConfirmationType; import org.keycloak.dom.saml.v2.assertion.SubjectType; +import org.keycloak.dom.saml.v2.protocol.ArtifactResponseType; import org.keycloak.dom.saml.v2.protocol.LogoutRequestType; import org.keycloak.dom.saml.v2.protocol.RequestAbstractType; import org.keycloak.dom.saml.v2.protocol.ResponseType; @@ -62,8 +63,10 @@ import org.keycloak.saml.common.constants.JBossSAMLConstants; import org.keycloak.saml.common.constants.JBossSAMLURIConstants; import org.keycloak.saml.common.exceptions.ConfigurationException; +import org.keycloak.saml.common.exceptions.ParsingException; import org.keycloak.saml.common.exceptions.ProcessingException; import org.keycloak.saml.common.util.DocumentUtil; +import org.keycloak.saml.processing.api.saml.v2.request.SAML2Request; import org.keycloak.saml.processing.core.saml.v2.common.SAMLDocumentHolder; import org.keycloak.saml.processing.core.saml.v2.constants.X500SAMLProfileConstants; import org.keycloak.saml.processing.core.saml.v2.util.AssertionUtil; @@ -118,6 +121,7 @@ import org.keycloak.services.util.CacheControlUtil; import org.keycloak.sessions.AuthenticationSessionModel; import org.keycloak.utils.StringUtil; +import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.NodeList; @@ -178,8 +182,12 @@ public Response getSPDescriptor() { @GET public Response redirectBinding(@QueryParam(GeneralConstants.SAML_REQUEST_KEY) String samlRequest, @QueryParam(GeneralConstants.SAML_RESPONSE_KEY) String samlResponse, + @QueryParam(GeneralConstants.SAML_ARTIFACT_KEY) String samlArt, @QueryParam(GeneralConstants.RELAY_STATE) String relayState) { - return new RedirectBinding().execute(samlRequest, samlResponse, relayState, null); + if (Objects.isNull(samlArt)) { + return new RedirectBinding().execute(samlRequest, samlResponse, null, relayState, null); + } + return new ArtifactBinding().execute(samlRequest, samlResponse, samlArt, relayState, null); } @@ -189,17 +197,21 @@ public Response redirectBinding(@QueryParam(GeneralConstants.SAML_REQUEST_KEY) S @Consumes(MediaType.APPLICATION_FORM_URLENCODED) public Response postBinding(@FormParam(GeneralConstants.SAML_REQUEST_KEY) String samlRequest, @FormParam(GeneralConstants.SAML_RESPONSE_KEY) String samlResponse, + @FormParam(GeneralConstants.SAML_ARTIFACT_KEY) String samlArt, @FormParam(GeneralConstants.RELAY_STATE) String relayState) { - return new PostBinding().execute(samlRequest, samlResponse, relayState, null); + if (Objects.isNull(samlArt)) { + return new PostBinding().execute(samlRequest, samlResponse, null, relayState, null); + } + return new ArtifactBinding().execute(samlRequest, samlResponse, samlArt, relayState, null); } @Path("clients/{client_id}") @GET - public Response redirectBinding(@QueryParam(GeneralConstants.SAML_REQUEST_KEY) String samlRequest, - @QueryParam(GeneralConstants.SAML_RESPONSE_KEY) String samlResponse, - @QueryParam(GeneralConstants.RELAY_STATE) String relayState, - @PathParam("client_id") String clientId) { - return new RedirectBinding().execute(samlRequest, samlResponse, relayState, clientId); + public Response redirectBindingIdpInitiated(@QueryParam(GeneralConstants.SAML_REQUEST_KEY) String samlRequest, + @QueryParam(GeneralConstants.SAML_RESPONSE_KEY) String samlResponse, + @QueryParam(GeneralConstants.RELAY_STATE) String relayState, + @PathParam("client_id") String clientId) { + return new RedirectBinding().execute(samlRequest, samlResponse, null, relayState, clientId); } @@ -208,11 +220,11 @@ public Response redirectBinding(@QueryParam(GeneralConstants.SAML_REQUEST_KEY) S @Path("clients/{client_id}") @POST @Consumes(MediaType.APPLICATION_FORM_URLENCODED) - public Response postBinding(@FormParam(GeneralConstants.SAML_REQUEST_KEY) String samlRequest, - @FormParam(GeneralConstants.SAML_RESPONSE_KEY) String samlResponse, - @FormParam(GeneralConstants.RELAY_STATE) String relayState, - @PathParam("client_id") String clientId) { - return new PostBinding().execute(samlRequest, samlResponse, relayState, clientId); + public Response postBindingIdpInitiated(@FormParam(GeneralConstants.SAML_REQUEST_KEY) String samlRequest, + @FormParam(GeneralConstants.SAML_RESPONSE_KEY) String samlResponse, + @FormParam(GeneralConstants.RELAY_STATE) String relayState, + @PathParam("client_id") String clientId) { + return new PostBinding().execute(samlRequest, samlResponse, null, relayState, clientId); } protected abstract class Binding { @@ -224,7 +236,7 @@ private boolean checkSsl() { } } - protected Response basicChecks(String samlRequest, String samlResponse) { + protected Response basicChecks(String samlRequest, String samlResponse, String samlArt) { if (!checkSsl()) { event.event(EventType.LOGIN); event.error(Errors.SSL_REQUIRED); @@ -236,7 +248,7 @@ protected Response basicChecks(String samlRequest, String samlResponse) { return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.REALM_NOT_ENABLED); } - if (samlRequest == null && samlResponse == null) { + if (samlRequest == null && samlResponse == null&& samlArt == null) { event.event(EventType.LOGIN); event.error(Errors.INVALID_REQUEST); return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST); @@ -280,11 +292,12 @@ protected KeyLocator getIDPKeyLocator() { return new HardcodedKeyLocator(keys); } - public Response execute(String samlRequest, String samlResponse, String relayState, String clientId) { + public Response execute(String samlRequest, String samlResponse, String samlArt, String relayState, String clientId) { event = new EventBuilder(realm, session, clientConnection); - Response response = basicChecks(samlRequest, samlResponse); + Response response = basicChecks(samlRequest, samlResponse, samlArt); if (response != null) return response; if (samlRequest != null) return handleSamlRequest(samlRequest, relayState); + if (samlArt != null) return handleSamlArt(samlArt, relayState, clientId); else return handleSamlResponse(samlResponse, relayState, clientId); } @@ -408,6 +421,73 @@ protected Response logoutRequest(LogoutRequestType request, String relayState) { } + protected Response handleSamlArt(String samlArt, String relayState, String clientId) { + try { + // execute the Resolve Artifact request + SAMLDocumentHolder samlDocumentHolder = provider.resolveArtifact(session, session.getContext().getUri(), realm, relayState, samlArt); + + // validate the type of the SAML object + if (!(samlDocumentHolder.getSamlObject() instanceof ArtifactResponseType artifactResponse)) { + logger.error("artifact binding failed: the SAML object is not an ArtifactResponse"); + event.event(EventType.IDENTITY_PROVIDER_RESPONSE); + event.detail(Details.REASON, Errors.INVALID_SAML_ARTIFACT_RESPONSE); + event.error(Errors.INVALID_REQUEST); + return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST); + } + + // validate the signature of the ArtifactResponse + if (config.isValidateSignature()) { + try { + verifySignature(GeneralConstants.SAML_RESPONSE_KEY, samlDocumentHolder); + } catch (VerificationException e) { + logger.error("artifact binding failed: the ArtifactResponse signature is invalid", e); + event.event(EventType.IDENTITY_PROVIDER_RESPONSE); + event.error(Errors.INVALID_SIGNATURE); + return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.IDENTITY_PROVIDER_INVALID_SIGNATURE); + } + } + + if (!(artifactResponse.getAny() instanceof ResponseType embeddedResponse)) { + logger.error("artifact binding failed: the embedded SAML object is not a Response"); + event.event(EventType.IDENTITY_PROVIDER_RESPONSE); + event.detail(Details.REASON, Errors.INVALID_SAML_ARTIFACT_RESPONSE); + event.error(Errors.INVALID_REQUEST); + return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST); + } + + // validate the destination of the embedded Response + if (isDestinationRequired() && embeddedResponse.getDestination() == null && containsUnencryptedSignature(samlDocumentHolder)) { + logger.error("artifact binding failed: the embedded Response does not contain a destination"); + event.event(EventType.IDENTITY_PROVIDER_RESPONSE); + event.detail(Details.REASON, Errors.MISSING_REQUIRED_DESTINATION); + event.error(Errors.INVALID_SAML_RESPONSE); + return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST); + } + if (!destinationValidator.validate(getExpectedDestination(config.getAlias(), clientId), embeddedResponse.getDestination())) { + logger.error("artifact binding failed: the embedded Response has an invalid destination"); + event.event(EventType.IDENTITY_PROVIDER_RESPONSE); + event.detail(Details.REASON, Errors.INVALID_DESTINATION); + event.error(Errors.INVALID_SAML_RESPONSE); + return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST); + } + + // convert the embedded SAML response to a base64 serialized string + Document embeddedResponseAsDoc = SAML2Request.convert(embeddedResponse); + String embeddedResponseAsString = DocumentUtil.getDocumentAsString(embeddedResponseAsDoc); + logger.debugf("embeddedResponseAsString %s", embeddedResponseAsString); + String embeddedResponseAsBase64 = PostBindingUtil.base64Encode(embeddedResponseAsString); + + // continue the flow with POST binding + return execute(null, embeddedResponseAsBase64, null, relayState, clientId); + } catch (IOException | ConfigurationException | ProcessingException | ParsingException e) { + logger.error("artifact binding failed", e); + event.event(EventType.IDENTITY_PROVIDER_RESPONSE); + event.detail(Details.REASON, Errors.INVALID_SAML_ARTIFACT_RESPONSE); + event.error(Errors.INVALID_REQUEST); + return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUEST); + } + } + private Consumer processLogout(AtomicReference ref) { return userSession -> { for(Iterator it = SamlSessionUtils.getSamlAuthenticationPreprocessorIterator(session); it.hasNext();) { @@ -494,10 +574,15 @@ protected Response handleLoginResponse(String samlResponse, SAMLDocumentHolder h return ErrorPage.error(session, authSession, Response.Status.BAD_REQUEST, Messages.INVALID_REQUESTER); } + // When artifact binding is used, the LoginResponse is embedded in the ArtifactResponse + // Therefore, the InResponseTo attribute of the LoginResponse cannot be validated + // Moreover, the LoginResponse is not signed + boolean isArtifactBinding = SamlProtocol.SAML_ARTIFACT_BINDING.equals(getBindingType()); + // Validate InResponseTo attribute: must match the generated request ID String expectedRequestId = authSession.getClientNote(SamlProtocol.SAML_REQUEST_ID_BROKER); final boolean inResponseToValidationSuccess = validateInResponseToAttribute(responseType, expectedRequestId); - if (!inResponseToValidationSuccess) + if (!isArtifactBinding && !inResponseToValidationSuccess) { event.event(EventType.IDENTITY_PROVIDER_RESPONSE); event.error(Errors.INVALID_SAML_RESPONSE); @@ -509,7 +594,7 @@ protected Response handleLoginResponse(String samlResponse, SAMLDocumentHolder h final boolean signatureNotValid = signed && config.isValidateSignature() && !AssertionUtil.isSignatureValid(assertionElement, getIDPKeyLocator()); final boolean hasNoSignatureWhenRequired = ! signed && config.isValidateSignature() && ! containsUnencryptedSignature(holder); - if (assertionSignatureNotExistsWhenRequired || signatureNotValid || hasNoSignatureWhenRequired) { + if (!isArtifactBinding && (assertionSignatureNotExistsWhenRequired || signatureNotValid || hasNoSignatureWhenRequired)) { logger.error("validation failed"); event.event(EventType.IDENTITY_PROVIDER_RESPONSE); event.error(Errors.INVALID_SIGNATURE); @@ -806,6 +891,43 @@ protected String getBindingType() { } + protected class ArtifactBinding extends Binding { + @Override + protected boolean containsUnencryptedSignature(SAMLDocumentHolder documentHolder) { + NodeList nl = documentHolder.getSamlDocument().getElementsByTagNameNS(XMLSignature.XMLNS, "Signature"); + return (nl != null && nl.getLength() > 0); + } + + @Override + protected void verifySignature(String key, SAMLDocumentHolder documentHolder) throws VerificationException { + if ((! containsUnencryptedSignature(documentHolder)) && (documentHolder.getSamlObject() instanceof ResponseType)) { + ResponseType responseType = (ResponseType) documentHolder.getSamlObject(); + List assertions = responseType.getAssertions(); + if (! assertions.isEmpty() ) { + // Only relax verification if the response is an authnresponse and contains (encrypted/plaintext) assertion. + // In that case, signature is validated on assertion element + return; + } + } + SamlProtocolUtils.verifyDocumentSignature(documentHolder.getSamlDocument(), getIDPKeyLocator()); + } + + @Override + protected SAMLDocumentHolder extractRequestDocument(String samlRequest) { + throw new UnsupportedOperationException("SAML request is not compliant with Artifact binding"); + } + @Override + protected SAMLDocumentHolder extractResponseDocument(String response) { + byte[] samlBytes = PostBindingUtil.base64Decode(response); + return SAMLRequestParser.parseResponseDocument(samlBytes); + } + + @Override + protected String getBindingType() { + return SamlProtocol.SAML_ARTIFACT_BINDING; + } + } + private String getX500Attribute(AssertionType assertion, X500SAMLProfileConstants attribute) { return getFirstMatchingAttribute(assertion, attribute::correspondsTo); } diff --git a/services/src/main/java/org/keycloak/broker/saml/SAMLIdentityProvider.java b/services/src/main/java/org/keycloak/broker/saml/SAMLIdentityProvider.java index f0705e85c197..d462d48cb862 100755 --- a/services/src/main/java/org/keycloak/broker/saml/SAMLIdentityProvider.java +++ b/services/src/main/java/org/keycloak/broker/saml/SAMLIdentityProvider.java @@ -16,6 +16,8 @@ */ package org.keycloak.broker.saml; +import jakarta.xml.soap.SOAPException; +import jakarta.xml.soap.SOAPMessage; import org.jboss.logging.Logger; import org.keycloak.broker.provider.AbstractIdentityProvider; import org.keycloak.broker.provider.AuthenticationRequest; @@ -36,6 +38,7 @@ import org.keycloak.dom.saml.v2.metadata.KeyDescriptorType; import org.keycloak.dom.saml.v2.metadata.KeyTypes; import org.keycloak.dom.saml.v2.metadata.LocalizedNameType; +import org.keycloak.dom.saml.v2.protocol.ArtifactResolveType; import org.keycloak.dom.saml.v2.protocol.AuthnRequestType; import org.keycloak.dom.saml.v2.protocol.LogoutRequestType; import org.keycloak.dom.saml.v2.protocol.ResponseType; @@ -46,7 +49,6 @@ import org.keycloak.models.IdentityProviderMapperModel; import org.keycloak.models.KeyManager; import org.keycloak.models.KeycloakSession; -import org.keycloak.models.ModelException; import org.keycloak.models.RealmModel; import org.keycloak.models.UserSessionModel; import org.keycloak.protocol.LoginProtocol; @@ -59,6 +61,8 @@ import org.keycloak.protocol.saml.mappers.SamlMetadataDescriptorUpdater; import org.keycloak.protocol.saml.preprocessor.SamlAuthenticationPreprocessor; import org.keycloak.protocol.saml.SAMLEncryptionAlgorithms; +import org.keycloak.protocol.saml.profile.util.Soap; +import org.keycloak.saml.SAML2ArtifactResolveRequestBuilder; import org.keycloak.saml.SAML2AuthnRequestBuilder; import org.keycloak.saml.SAML2LogoutRequestBuilder; import org.keycloak.saml.SAML2NameIDPolicyBuilder; @@ -69,10 +73,14 @@ import org.keycloak.saml.common.constants.GeneralConstants; import org.keycloak.saml.common.constants.JBossSAMLURIConstants; import org.keycloak.saml.common.exceptions.ConfigurationException; +import org.keycloak.saml.common.exceptions.ParsingException; +import org.keycloak.saml.common.exceptions.ProcessingException; import org.keycloak.saml.common.util.DocumentUtil; import org.keycloak.saml.common.util.StaxUtil; import org.keycloak.saml.processing.api.saml.v2.request.SAML2Request; +import org.keycloak.saml.processing.api.saml.v2.response.SAML2Response; import org.keycloak.saml.processing.api.saml.v2.sig.SAML2Signature; +import org.keycloak.saml.processing.core.saml.v2.common.SAMLDocumentHolder; import org.keycloak.saml.processing.core.saml.v2.writers.SAMLMetadataWriter; import org.keycloak.saml.processing.core.util.KeycloakKeySamlExtensionGenerator; import org.keycloak.saml.validators.DestinationValidator; @@ -138,7 +146,9 @@ public Response performLogin(AuthenticationRequest request) { String assertionConsumerServiceUrl = request.getRedirectUri(); - if (getConfig().isPostBindingResponse()) { + if (getConfig().isArtifactBindingResponse()) { + protocolBinding = JBossSAMLURIConstants.SAML_HTTP_ARTIFACT_BINDING.get(); + } else if (getConfig().isPostBindingResponse()) { protocolBinding = JBossSAMLURIConstants.SAML_HTTP_POST_BINDING.get(); } @@ -525,4 +535,65 @@ public boolean supportsLongStateParameter() { // SAML RelayState parameter has limits of 80 bytes per SAML specification return false; } + + public SAMLDocumentHolder resolveArtifact(KeycloakSession session, UriInfo uriInfo, RealmModel realm, String relayState, String samlArt) { + //get the URL of the artifact resolution service provided by the Identity Provider + String artifactResolutionServiceUrl = getConfig().getArtifactResolutionServiceUrl(); + if (artifactResolutionServiceUrl == null || artifactResolutionServiceUrl.trim().isEmpty()) { + throw new RuntimeException("Artifact Resolution Service URL is not configured for the Identity Provider."); + } + try { + // create the SAML Request object to resolve an artifact + ArtifactResolveType artifactResolveRequest = buildArtifactResolveRequest(uriInfo, realm, artifactResolutionServiceUrl, samlArt); + if (artifactResolveRequest.getDestination() != null) { + artifactResolutionServiceUrl = artifactResolveRequest.getDestination().toString(); + } + + // convert the SAML Request object to a SAML Document (DOM) + Document artifactResolveRequestAsDoc = SAML2Request.convert(artifactResolveRequest); + + // convert the SAML Document (DOM) to a SOAP Document (DOM) + Document soapRequestAsDoc = buildArtifactResolveBinding(session, relayState, realm) + .soapBinding(artifactResolveRequestAsDoc).getDocument(); + + // execute the SOAP request + SOAPMessage soapResponse = Soap.createMessage() + .addMimeHeader("SOAPAction", "http://www.oasis-open.org/committees/security") // MAY in SOAP binding spec + .addToBody(soapRequestAsDoc) + .call(artifactResolutionServiceUrl, session); + + // extract the SAML Response (DOM) from the SOAP response + Document artifactResolveResponseAsDoc = Soap.extractSoapMessage(soapResponse); + + // convert the SAML Response (DOM) to a SAML Response object and return it + return SAML2Response.getSAML2ObjectFromDocument(artifactResolveResponseAsDoc); + } catch (SOAPException | ConfigurationException | ProcessingException | ParsingException e) { + logger.warn("Unable to resolve a SAML artifact to: " + artifactResolutionServiceUrl, e); + throw new RuntimeException("Unable to resolve a SAML artifact to: " + artifactResolutionServiceUrl, e); + } + } + + protected ArtifactResolveType buildArtifactResolveRequest(UriInfo uriInfo, RealmModel realm, String artifactServiceUrl, String artifact, NodeGenerator... extensions) throws ConfigurationException { + SAML2ArtifactResolveRequestBuilder artifactResolveRequestBuilder = new SAML2ArtifactResolveRequestBuilder() + .issuer(getEntityId(uriInfo, realm)) + .destination(artifactServiceUrl) + .artifact(artifact); + ArtifactResolveType artifactResolveRequest = artifactResolveRequestBuilder.createArtifactResolveRequest(); + for (NodeGenerator extension : extensions) { + artifactResolveRequestBuilder.addExtension(extension); + } + return artifactResolveRequest; + } + + private JaxrsSAML2BindingBuilder buildArtifactResolveBinding(KeycloakSession session, String relayState, RealmModel realm) { + JaxrsSAML2BindingBuilder binding = new JaxrsSAML2BindingBuilder(session).relayState(relayState); + if (getConfig().isWantAuthnRequestsSigned()) { + KeyManager.ActiveRsaKey keys = session.keys().getActiveRsaKey(realm); + String keyName = getConfig().getXmlSigKeyInfoKeyNameTransformer().getKeyName(keys.getKid(), keys.getCertificate()); + binding.signWith(keyName, keys.getPrivateKey(), keys.getPublicKey(), keys.getCertificate()) + .signatureAlgorithm(getSignatureAlgorithm()) + .signDocument(); + } + return binding; + } } diff --git a/services/src/main/java/org/keycloak/broker/saml/SAMLIdentityProviderConfig.java b/services/src/main/java/org/keycloak/broker/saml/SAMLIdentityProviderConfig.java index 6768ca841f5e..18077a1df42f 100755 --- a/services/src/main/java/org/keycloak/broker/saml/SAMLIdentityProviderConfig.java +++ b/services/src/main/java/org/keycloak/broker/saml/SAMLIdentityProviderConfig.java @@ -44,11 +44,13 @@ public class SAMLIdentityProviderConfig extends IdentityProviderModel { public static final String POST_BINDING_AUTHN_REQUEST = "postBindingAuthnRequest"; public static final String POST_BINDING_LOGOUT = "postBindingLogout"; public static final String POST_BINDING_RESPONSE = "postBindingResponse"; + public static final String ARTIFACT_BINDING_RESPONSE = "artifactBindingResponse"; public static final String SIGNATURE_ALGORITHM = "signatureAlgorithm"; public static final String ENCRYPTION_ALGORITHM = "encryptionAlgorithm"; public static final String SIGNING_CERTIFICATE_KEY = "signingCertificate"; public static final String SINGLE_LOGOUT_SERVICE_URL = "singleLogoutServiceUrl"; public static final String SINGLE_SIGN_ON_SERVICE_URL = "singleSignOnServiceUrl"; + public static final String ARTIFACT_RESOLUTION_SERVICE_URL = "artifactResolutionServiceUrl"; public static final String VALIDATE_SIGNATURE = "validateSignature"; public static final String PRINCIPAL_TYPE = "principalType"; public static final String PRINCIPAL_ATTRIBUTE = "principalAttribute"; @@ -97,6 +99,14 @@ public void setSingleSignOnServiceUrl(String singleSignOnServiceUrl) { getConfig().put(SINGLE_SIGN_ON_SERVICE_URL, singleSignOnServiceUrl); } + public String getArtifactResolutionServiceUrl() { + return getConfig().get(ARTIFACT_RESOLUTION_SERVICE_URL); + } + + public void setArtifactResolutionServiceUrl(String artifactResolutionServiceUrl) { + getConfig().put(ARTIFACT_RESOLUTION_SERVICE_URL, artifactResolutionServiceUrl); + } + public String getSingleLogoutServiceUrl() { return getConfig().get(SINGLE_LOGOUT_SERVICE_URL); } @@ -260,6 +270,14 @@ public void setBackchannelSupported(boolean backchannel) { getConfig().put(BACKCHANNEL_SUPPORTED, String.valueOf(backchannel)); } + public boolean isArtifactBindingResponse() { + return Boolean.valueOf(getConfig().get(ARTIFACT_BINDING_RESPONSE)); + } + + public void setArtifactBindingResponse(boolean backchannel) { + getConfig().put(ARTIFACT_BINDING_RESPONSE, String.valueOf(backchannel)); + } + /** * Always returns non-{@code null} result. * @return Configured ransformer of {@link #DEFAULT_XML_KEY_INFO_KEY_NAME_TRANSFORMER} if not set. @@ -424,6 +442,9 @@ public void validate(RealmModel realm) { throw new IllegalArgumentException(USE_METADATA_DESCRIPTOR_URL + " needs a non-empty URL for " + METADATA_DESCRIPTOR_URL); } } + if (StringUtil.isNotBlank(getArtifactResolutionServiceUrl())) { + checkUrl(sslRequired, getArtifactResolutionServiceUrl(), ARTIFACT_RESOLUTION_SERVICE_URL); + } //transient name id format is not accepted together with principaltype SubjectnameId if (JBossSAMLURIConstants.NAMEID_FORMAT_TRANSIENT.get().equals(getNameIDPolicyFormat()) && SamlPrincipalType.SUBJECT == getPrincipalType()) throw new IllegalArgumentException("Can not have Transient NameID Policy Format together with SUBJECT Principal Type"); diff --git a/services/src/main/java/org/keycloak/broker/saml/SAMLIdentityProviderFactory.java b/services/src/main/java/org/keycloak/broker/saml/SAMLIdentityProviderFactory.java index b1a2582b1c30..b2e6c6009046 100755 --- a/services/src/main/java/org/keycloak/broker/saml/SAMLIdentityProviderFactory.java +++ b/services/src/main/java/org/keycloak/broker/saml/SAMLIdentityProviderFactory.java @@ -99,13 +99,23 @@ public Map parseConfig(KeycloakSession session, String config) { } } + String artifactResolutionServiceUrl = null; + boolean artifactBindingResponse = false; + for (EndpointType endpoint : idpDescriptor.getArtifactResolutionService()) { + if (endpoint.getBinding().toString().equals(JBossSAMLURIConstants.SAML_SOAP_BINDING.get())) { + artifactResolutionServiceUrl = endpoint.getLocation().toString(); + break; + } + } samlIdentityProviderConfig.setIdpEntityId(entityType.getEntityID()); samlIdentityProviderConfig.setSingleLogoutServiceUrl(singleLogoutServiceUrl); + samlIdentityProviderConfig.setArtifactResolutionServiceUrl(artifactResolutionServiceUrl); samlIdentityProviderConfig.setSingleSignOnServiceUrl(singleSignOnServiceUrl); samlIdentityProviderConfig.setWantAuthnRequestsSigned(idpDescriptor.isWantAuthnRequestsSigned()); samlIdentityProviderConfig.setAddExtensionsElementWithKeyInfo(false); samlIdentityProviderConfig.setValidateSignature(idpDescriptor.isWantAuthnRequestsSigned()); samlIdentityProviderConfig.setPostBindingResponse(postBindingResponse); + samlIdentityProviderConfig.setArtifactBindingResponse(artifactBindingResponse); samlIdentityProviderConfig.setPostBindingAuthnRequest(postBindingResponse); samlIdentityProviderConfig.setPostBindingLogout(postBindingLogout); samlIdentityProviderConfig.setLoginHint(false); diff --git a/services/src/main/java/org/keycloak/protocol/saml/SamlProtocol.java b/services/src/main/java/org/keycloak/protocol/saml/SamlProtocol.java index 819f573c9192..3733a5b6fb67 100755 --- a/services/src/main/java/org/keycloak/protocol/saml/SamlProtocol.java +++ b/services/src/main/java/org/keycloak/protocol/saml/SamlProtocol.java @@ -133,6 +133,7 @@ public class SamlProtocol implements LoginProtocol { public static final String SAML_BINDING = "saml_binding"; public static final String SAML_IDP_INITIATED_LOGIN = "saml_idp_initiated_login"; public static final String SAML_POST_BINDING = "post"; + public static final String SAML_ARTIFACT_BINDING = "artifact"; public static final String SAML_SOAP_BINDING = "soap"; public static final String SAML_REDIRECT_BINDING = "get"; public static final String SAML_REQUEST_ID = "SAML_REQUEST_ID"; diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/saml/ModifySamlResponseStepBuilder.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/saml/ModifySamlResponseStepBuilder.java index 509a08f31aca..af4237af89d4 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/saml/ModifySamlResponseStepBuilder.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/saml/ModifySamlResponseStepBuilder.java @@ -127,6 +127,10 @@ public ModifySamlResponseStepBuilder targetAttributeSamlResponse() { return targetAttribute(GeneralConstants.SAML_RESPONSE_KEY); } + public ModifySamlResponseStepBuilder targetAttributeSamlArtifact() { + return targetAttribute(GeneralConstants.SAML_ARTIFACT_KEY); + } + public URI targetUri() { return targetUri; } diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/saml/SamlBackchannelArtifactResolveReceiver.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/saml/SamlBackchannelArtifactResolveReceiver.java new file mode 100644 index 000000000000..20517c440617 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/saml/SamlBackchannelArtifactResolveReceiver.java @@ -0,0 +1,145 @@ +package org.keycloak.testsuite.util.saml; + + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import jakarta.ws.rs.core.HttpHeaders; +import org.jboss.logging.Logger; +import org.keycloak.common.util.KeyUtils; +import org.keycloak.dom.saml.v2.protocol.ArtifactResolveType; +import org.keycloak.dom.saml.v2.protocol.ArtifactResponseType; +import org.keycloak.dom.saml.v2.protocol.ResponseType; +import org.keycloak.protocol.saml.JaxrsSAML2BindingBuilder; +import org.keycloak.protocol.saml.SamlConfigAttributes; +import org.keycloak.protocol.saml.SamlProtocolUtils; +import org.keycloak.protocol.saml.profile.util.Soap; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.saml.SAML2LoginResponseBuilder; +import org.keycloak.saml.SignatureAlgorithm; +import org.keycloak.saml.common.util.DocumentUtil; +import org.keycloak.saml.processing.api.saml.v2.response.SAML2Response; +import org.keycloak.saml.processing.core.saml.v2.common.SAMLDocumentHolder; +import org.w3c.dom.Document; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.security.PrivateKey; +import java.security.PublicKey; + +public class SamlBackchannelArtifactResolveReceiver implements AutoCloseable { + + private static final Logger LOG = Logger.getLogger(SamlBackchannelArtifactResolveReceiver.class); + + private final HttpServer server; + private ArtifactResolveType artifactResolve; + private final String url; + private final ClientRepresentation samlClient; + private final PublicKey publicKey; + private final PrivateKey privateKey; + + public SamlBackchannelArtifactResolveReceiver(int port, ClientRepresentation samlClient, String publicKeyStr, String privateKeyStr) { + this.samlClient = samlClient; + publicKey = publicKeyStr == null ? null : org.keycloak.testsuite.util.KeyUtils.publicKeyFromString(publicKeyStr); + privateKey = privateKeyStr == null ? null : org.keycloak.testsuite.util.KeyUtils.privateKeyFromString(privateKeyStr); + try { + InetSocketAddress address = new InetSocketAddress(InetAddress.getByName("localhost"), port); + server = HttpServer.create(address, 0); + this.url = "http://" + address.getHostString() + ":" + port; + } catch (IOException e) { + throw new RuntimeException("Cannot create http server", e); + } + + server.createContext("/", new SamlBackchannelArtifactResolveHandler()); + server.setExecutor(null); + server.start(); + } + + public SamlBackchannelArtifactResolveReceiver(int port, ClientRepresentation samlClient) { + this(port, samlClient, null, null); + } + + public String getUrl() { + return url; + } + + public boolean isArtifactResolveReceived() { + return artifactResolve != null; + } + + public ArtifactResolveType getArtifactResolve() { + return artifactResolve; + } + + @Override + public void close() throws Exception { + server.stop(0); + } + + private class SamlBackchannelArtifactResolveHandler implements HttpHandler { + public void handle(HttpExchange t) throws IOException { + try { + t.getResponseHeaders().add(HttpHeaders.CONTENT_TYPE, "text/xml"); + t.sendResponseHeaders(200, 0); + + Document request = Soap.extractSoapMessage(t.getRequestBody()); + LOG.infof("Received ArtifactResolve: %s", DocumentUtil.asString(request)); + + SAMLDocumentHolder samlDoc = SAML2Response.getSAML2ObjectFromDocument(request); + if (!(samlDoc.getSamlObject() instanceof ArtifactResolveType)) { + throw new RuntimeException("SamlBackchannelArtifactResolveReceiver received a message that was not ArtifactResolveType"); + } + artifactResolve = (ArtifactResolveType) samlDoc.getSamlObject(); + + // create the login response + SAML2LoginResponseBuilder loginResponseBuilder = new SAML2LoginResponseBuilder(); + ResponseType loginResponse = loginResponseBuilder + .issuer(samlClient.getClientId()) + .requestIssuer(artifactResolve.getIssuer().getValue()) + .requestID(artifactResolve.getID()) + .buildModel(); + + Document loginResponseBuilderAsDoc = loginResponseBuilder.buildDocument(loginResponse); + + // bundle the login response in the Artifact Response + ArtifactResponseType artifactResponse = SamlProtocolUtils.buildArtifactResponse(loginResponseBuilderAsDoc); + artifactResponse.setInResponseTo(artifactResolve.getID()); + JaxrsSAML2BindingBuilder soapBinding = new JaxrsSAML2BindingBuilder(null); + if (requiresClientSignature(samlClient)) { + soapBinding.signatureAlgorithm(getSignatureAlgorithm(samlClient)) + .signWith(KeyUtils.createKeyId(privateKey), privateKey, publicKey, null) + .signDocument(); + } + Document artifactResponseAsDoc = SAML2Response.convert(artifactResponse); + Document soapDoc = soapBinding.soapBinding(artifactResponseAsDoc).getDocument(); + + LOG.infof("Sending ArtifactResponse: %s", DocumentUtil.asString(soapDoc)); + + // send login response + OutputStream os = t.getResponseBody(); + os.write(Soap.createMessage().addToBody(soapDoc).getBytes()); + os.close(); + } catch (Exception ex) { + t.sendResponseHeaders(500, 0); + } + } + } + + private SignatureAlgorithm getSignatureAlgorithm(ClientRepresentation client) { + String alg = client.getAttributes().get(SamlConfigAttributes.SAML_SIGNATURE_ALGORITHM); + if (alg != null) { + SignatureAlgorithm algorithm = SignatureAlgorithm.valueOf(alg); + if (algorithm != null) + return algorithm; + } + return SignatureAlgorithm.RSA_SHA256; + } + + public boolean requiresClientSignature(ClientRepresentation client) { + return "true".equals(client.getAttributes().get(SamlConfigAttributes.SAML_CLIENT_SIGNATURE_ATTRIBUTE)); + } +} + + diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/IdentityProviderTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/IdentityProviderTest.java index 86d6233971a5..fb8d0c4e24f1 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/IdentityProviderTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/IdentityProviderTest.java @@ -176,7 +176,7 @@ public void testCreateWithReservedCharacterForAlias() { Response response = realm.identityProviders().create(newIdentityProvider); Assert.assertEquals(400, response.getStatus()); } - + @Test public void testCreate() { IdentityProviderRepresentation newIdentityProvider = createRep("new-identity-provider", "oidc"); @@ -276,11 +276,11 @@ public void failCreateInvalidUrl() throws Exception { } } } - + @Test public void shouldFailWhenAliasHasSpaceDuringCreation() { IdentityProviderRepresentation newIdentityProvider = createRep("New Identity Provider", "oidc"); - + newIdentityProvider.getConfig().put(IdentityProviderModel.SYNC_MODE, "IMPORT"); newIdentityProvider.getConfig().put("clientId", "clientId"); newIdentityProvider.getConfig().put("clientSecret", "some secret value"); @@ -703,7 +703,7 @@ public void testNoExport() { @Test public void importShouldFailDueAliasWithSpace() { - + Map data = new HashMap<>(); data.put("providerId", "saml"); data.put("alias", "Alias With Space"); @@ -761,7 +761,7 @@ private void testSamlImport(String fileName) throws URISyntaxException, IOExcept assertEqual(rep, providers.get(0)); } - + @Test public void testSamlImportAndExportDisabled() throws URISyntaxException, IOException, ParsingException { @@ -784,7 +784,7 @@ public void testSamlImportAndExportDisabled() throws URISyntaxException, IOExcep IdentityProviderResource provider = realm.identityProviders().get("saml"); IdentityProviderRepresentation rep = provider.toRepresentation(); assertCreatedSamlIdp(rep, false); - + } @@ -1045,8 +1045,10 @@ private void assertSamlConfig(Map config) { "singleLogoutServiceUrl", "postBindingLogout", "postBindingResponse", + "artifactBindingResponse", "postBindingAuthnRequest", "singleSignOnServiceUrl", + "artifactResolutionServiceUrl", "wantAuthnRequestsSigned", "nameIDPolicyFormat", "signingCertificate", @@ -1057,7 +1059,9 @@ private void assertSamlConfig(Map config) { )); assertThat(config, hasEntry("validateSignature", "true")); assertThat(config, hasEntry("singleLogoutServiceUrl", "http://localhost:8080/auth/realms/master/protocol/saml")); + assertThat(config, hasEntry("artifactResolutionServiceUrl", "http://localhost:8080/auth/realms/master/protocol/saml/resolve")); assertThat(config, hasEntry("postBindingResponse", "true")); + assertThat(config, hasEntry("artifactBindingResponse", "false")); assertThat(config, hasEntry("postBindingAuthnRequest", "true")); assertThat(config, hasEntry("singleSignOnServiceUrl", "http://localhost:8080/auth/realms/master/protocol/saml")); assertThat(config, hasEntry("wantAuthnRequestsSigned", "true")); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcSamlBrokerArtifactBindingTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcSamlBrokerArtifactBindingTest.java new file mode 100644 index 000000000000..6ea838fed8cf --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcSamlBrokerArtifactBindingTest.java @@ -0,0 +1,41 @@ +package org.keycloak.testsuite.broker; + +import org.junit.Test; +import org.keycloak.admin.client.resource.RealmResource; +import org.keycloak.broker.saml.SAMLIdentityProviderConfig; +import org.keycloak.protocol.saml.SamlConfigAttributes; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.idm.IdentityProviderRepresentation; + +public final class KcSamlBrokerArtifactBindingTest extends AbstractInitializedBaseBrokerTest { + + @Override + protected BrokerConfiguration getBrokerConfiguration() { + return KcSamlBrokerConfiguration.INSTANCE; + } + + + @Test + public void testLogin() { + // configure artifact binding to the broker + IdentityProviderRepresentation idpRep = identityProviderResource.toRepresentation(); + String baseSamlUrl = idpRep.getConfig().get(SAMLIdentityProviderConfig.ARTIFACT_RESOLUTION_SERVICE_URL); + idpRep.getConfig().put(SAMLIdentityProviderConfig.ARTIFACT_RESOLUTION_SERVICE_URL, baseSamlUrl + "/resolve"); + idpRep.getConfig().put(SAMLIdentityProviderConfig.ARTIFACT_BINDING_RESPONSE, Boolean.TRUE.toString()); + identityProviderResource.update(idpRep); + + // configure artifact binding to the broker client + RealmResource providerRealm = realmsResouce().realm(bc.providerRealmName()); + ClientRepresentation brokerClient = providerRealm.clients().findByClientId(bc.getIDPClientIdInProviderRealm()).get(0); + brokerClient.getAttributes().put(SamlConfigAttributes.SAML_ARTIFACT_BINDING, Boolean.TRUE.toString()); + providerRealm.clients().get(brokerClient.getId()).update(brokerClient); + + // login using artifact binding + oauth.clientId("broker-app"); + loginPage.open(bc.consumerRealmName()); + logInWithBroker(bc); + updateAccountInformationPage.assertCurrent(); + updateAccountInformationPage.updateAccountInformation("f", "l"); + appPage.assertCurrent(); + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcSamlBrokerConfiguration.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcSamlBrokerConfiguration.java index ebaad04d597e..08acffee0d78 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcSamlBrokerConfiguration.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcSamlBrokerConfiguration.java @@ -222,6 +222,7 @@ public IdentityProviderRepresentation setUpIdentityProvider(IdentityProviderSync config.put(IdentityProviderModel.SYNC_MODE, syncMode.toString()); config.put(SINGLE_SIGN_ON_SERVICE_URL, getProviderRoot() + "/auth/realms/" + REALM_PROV_NAME + "/protocol/saml"); + config.put(ARTIFACT_RESOLUTION_SERVICE_URL, getProviderRoot() + "/auth/realms/" + REALM_PROV_NAME + "/protocol/saml"); config.put(SINGLE_LOGOUT_SERVICE_URL, getProviderRoot() + "/auth/realms/" + REALM_PROV_NAME + "/protocol/saml"); config.put(NAME_ID_POLICY_FORMAT, "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"); config.put(FORCE_AUTHN, "false"); @@ -231,6 +232,7 @@ public IdentityProviderRepresentation setUpIdentityProvider(IdentityProviderSync config.put(VALIDATE_SIGNATURE, "false"); config.put(WANT_AUTHN_REQUESTS_SIGNED, "false"); config.put(BACKCHANNEL_SUPPORTED, "false"); + config.put(ARTIFACT_BINDING_RESPONSE, "false"); return idp; } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/admin/KcAdmUpdateTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/admin/KcAdmUpdateTest.java index 002e3ec7e43b..f6fd3dd07969 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/admin/KcAdmUpdateTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/admin/KcAdmUpdateTest.java @@ -42,11 +42,13 @@ public void testUpdateIDPWithoutInternalId() throws IOException { .alias("idpAlias") .displayName("SAML") .setAttribute(SAMLIdentityProviderConfig.SINGLE_SIGN_ON_SERVICE_URL, "https://saml.idp/saml") + .setAttribute(SAMLIdentityProviderConfig.ARTIFACT_RESOLUTION_SERVICE_URL, "https://saml.idp/saml") .setAttribute(SAMLIdentityProviderConfig.SINGLE_LOGOUT_SERVICE_URL, "https://saml.idp/saml") .setAttribute(SAMLIdentityProviderConfig.NAME_ID_POLICY_FORMAT, "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress") .setAttribute(SAMLIdentityProviderConfig.POST_BINDING_RESPONSE, "false") .setAttribute(SAMLIdentityProviderConfig.POST_BINDING_AUTHN_REQUEST, "false") .setAttribute(SAMLIdentityProviderConfig.BACKCHANNEL_SUPPORTED, "false") + .setAttribute(SAMLIdentityProviderConfig.ARTIFACT_BINDING_RESPONSE, "false") .build(); try (Closeable ipc = new IdentityProviderCreator(realmResource, identityProvider)) { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/saml/BrokerTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/saml/BrokerTest.java index ffca2c78871b..df81555de43b 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/saml/BrokerTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/saml/BrokerTest.java @@ -52,6 +52,7 @@ import org.keycloak.testsuite.updaters.IdentityProviderCreator; import org.keycloak.testsuite.util.IdentityProviderBuilder; import org.keycloak.testsuite.util.SamlClientBuilder; + import java.io.IOException; import java.net.URI; import java.security.KeyPair; @@ -67,6 +68,7 @@ import org.hamcrest.Matchers; import org.junit.Assert; import org.junit.Test; +import org.keycloak.testsuite.util.saml.SamlBackchannelArtifactResolveReceiver; import org.w3c.dom.DOMException; import org.w3c.dom.Document; import org.w3c.dom.Element; @@ -75,10 +77,8 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.is; +import static org.junit.Assert.fail; import static org.keycloak.saml.SignatureAlgorithm.RSA_SHA1; -import static org.keycloak.testsuite.saml.AbstractSamlTest.REALM_NAME; -import static org.keycloak.testsuite.saml.AbstractSamlTest.SAML_ASSERTION_CONSUMER_URL_SALES_POST; -import static org.keycloak.testsuite.saml.AbstractSamlTest.SAML_CLIENT_ID_SALES_POST; import static org.keycloak.testsuite.util.Matchers.isSamlStatusResponse; import static org.keycloak.testsuite.util.SamlClient.Binding.POST; import static org.keycloak.testsuite.util.SamlClient.Binding.REDIRECT; @@ -95,11 +95,13 @@ private IdentityProviderRepresentation addIdentityProvider(String samlEndpoint) .alias(SAML_BROKER_ALIAS) .displayName("SAML") .setAttribute(SAMLIdentityProviderConfig.SINGLE_SIGN_ON_SERVICE_URL, samlEndpoint) + .setAttribute(SAMLIdentityProviderConfig.ARTIFACT_RESOLUTION_SERVICE_URL, samlEndpoint) .setAttribute(SAMLIdentityProviderConfig.SINGLE_LOGOUT_SERVICE_URL, samlEndpoint) .setAttribute(SAMLIdentityProviderConfig.NAME_ID_POLICY_FORMAT, JBossSAMLURIConstants.NAMEID_FORMAT_EMAIL.get()) .setAttribute(SAMLIdentityProviderConfig.POST_BINDING_RESPONSE, "false") .setAttribute(SAMLIdentityProviderConfig.POST_BINDING_AUTHN_REQUEST, "false") .setAttribute(SAMLIdentityProviderConfig.BACKCHANNEL_SUPPORTED, "false") + .setAttribute(SAMLIdentityProviderConfig.ARTIFACT_BINDING_RESPONSE, "false") .build(); return identityProvider; } @@ -446,4 +448,48 @@ private void assertExpired(XMLGregorianCalendar notBefore, XMLGregorianCalendar .execute(); } } + + @Test + public void testResolveArtifactBindingAsSp() { + RealmResource realm = adminClient.realm(REALM_NAME); + + try (SamlBackchannelArtifactResolveReceiver samlBackchannelArtifactResolveReceiver = new SamlBackchannelArtifactResolveReceiver( + 8082, + realm.clients().findByClientId(SAML_CLIENT_ID_SALES_POST).get(0) + )) { + + IdentityProviderRepresentation rep = addIdentityProvider("https://saml.idp/saml"); + rep.getConfig().put(SAMLIdentityProviderConfig.ARTIFACT_RESOLUTION_SERVICE_URL, samlBackchannelArtifactResolveReceiver.getUrl()); + rep.getConfig().put(SAMLIdentityProviderConfig.ARTIFACT_BINDING_RESPONSE, "true"); + + try (IdentityProviderCreator idp = new IdentityProviderCreator(realm, rep)) { + SamlClientBuilder samlClientBuilder = new SamlClientBuilder(); + + // trigger authentication + samlClientBuilder.authnRequest( + getAuthServerSamlEndpoint(REALM_NAME), + SAML_CLIENT_ID_SALES_POST, + SAML_ASSERTION_CONSUMER_URL_SALES_POST, + POST + ).setProtocolBinding(JBossSAMLURIConstants.SAML_HTTP_ARTIFACT_BINDING.getUri()).build(); + + // simulate login page interaction + samlClientBuilder.login().idp(SAML_BROKER_ALIAS).build(); + + // simulate IdP response (artifact as query param) + samlClientBuilder.processSamlResponse(REDIRECT) + .targetAttributeSamlArtifact() + .targetUri(getSamlBrokerUrl(REALM_NAME)) + .build(); + + // assert the authentication succeeded + samlClientBuilder.assertResponse(org.keycloak.testsuite.util.Matchers.statusCodeIsHC(Status.OK)); + + samlClientBuilder.execute(); + } + } catch (Exception ex) { + fail("unexpected error"); + } + } + } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/saml/LogoutTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/saml/LogoutTest.java index b1719f0c2cd7..d1cb51e180da 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/saml/LogoutTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/saml/LogoutTest.java @@ -86,6 +86,7 @@ public class LogoutTest extends AbstractSamlTest { private static final String NAME_QUALIFIER = "nameQualifier"; private static final String BROKER_SIGN_ON_SERVICE_URL = "https://saml.idp/saml"; + private static final String BROKER_SIGN_ON_ARTIFACT_SERVICE_URL = "https://saml.idp/saml"; private static final String BROKER_LOGOUT_SERVICE_URL = "https://saml.idp/SLO/saml"; private static final String BROKER_SERVICE_ID = "https://saml.idp/saml"; @@ -508,11 +509,13 @@ private IdentityProviderRepresentation addIdentityProvider() { .alias(SAML_BROKER_ALIAS) .displayName("SAML") .setAttribute(SAMLIdentityProviderConfig.SINGLE_SIGN_ON_SERVICE_URL, BROKER_SIGN_ON_SERVICE_URL) + .setAttribute(SAMLIdentityProviderConfig.ARTIFACT_RESOLUTION_SERVICE_URL, BROKER_SIGN_ON_ARTIFACT_SERVICE_URL) .setAttribute(SAMLIdentityProviderConfig.SINGLE_LOGOUT_SERVICE_URL, BROKER_LOGOUT_SERVICE_URL) .setAttribute(SAMLIdentityProviderConfig.NAME_ID_POLICY_FORMAT, "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress") .setAttribute(SAMLIdentityProviderConfig.POST_BINDING_RESPONSE, "false") .setAttribute(SAMLIdentityProviderConfig.POST_BINDING_AUTHN_REQUEST, "false") .setAttribute(SAMLIdentityProviderConfig.BACKCHANNEL_SUPPORTED, "false") + .setAttribute(SAMLIdentityProviderConfig.ARTIFACT_BINDING_RESPONSE, "false") .build(); return identityProvider; } diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/admin-test/saml-idp-metadata-disabled.xml b/testsuite/integration-arquillian/tests/base/src/test/resources/admin-test/saml-idp-metadata-disabled.xml index 963107282d69..bd7f4c088948 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/resources/admin-test/saml-idp-metadata-disabled.xml +++ b/testsuite/integration-arquillian/tests/base/src/test/resources/admin-test/saml-idp-metadata-disabled.xml @@ -34,5 +34,8 @@ + diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/admin-test/saml-idp-metadata-encryption-methods.xml b/testsuite/integration-arquillian/tests/base/src/test/resources/admin-test/saml-idp-metadata-encryption-methods.xml index d06d37efb927..464bdb4c4bb8 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/resources/admin-test/saml-idp-metadata-encryption-methods.xml +++ b/testsuite/integration-arquillian/tests/base/src/test/resources/admin-test/saml-idp-metadata-encryption-methods.xml @@ -36,5 +36,8 @@ urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress + diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/admin-test/saml-idp-metadata-two-signing-certs.xml b/testsuite/integration-arquillian/tests/base/src/test/resources/admin-test/saml-idp-metadata-two-signing-certs.xml index 74618cf035ca..c703144df837 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/resources/admin-test/saml-idp-metadata-two-signing-certs.xml +++ b/testsuite/integration-arquillian/tests/base/src/test/resources/admin-test/saml-idp-metadata-two-signing-certs.xml @@ -44,5 +44,8 @@ urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress + diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/admin-test/saml-idp-metadata.xml b/testsuite/integration-arquillian/tests/base/src/test/resources/admin-test/saml-idp-metadata.xml index a44e816a37a3..ec29ef2c42ca 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/resources/admin-test/saml-idp-metadata.xml +++ b/testsuite/integration-arquillian/tests/base/src/test/resources/admin-test/saml-idp-metadata.xml @@ -32,5 +32,8 @@ urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress +