Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support handling LogoutResponse from SAML idP #56316

Merged
merged 27 commits into from
Jun 29, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
a7187ca
Initial working version. No tests yet
ywangd May 6, 2020
3180c86
Refactor and add tests
ywangd May 7, 2020
e6d4d5a
fix copy/paste text
ywangd May 7, 2020
3ef50a1
Add requestId to logout response for kibana to pass it back
ywangd May 8, 2020
c5633e5
checkstyle
ywangd May 8, 2020
c1ea969
Support LogoutResponse with both HTTP-Redirect and HTTP-Post bindings
ywangd May 11, 2020
bcaf87d
Rename and checkstyle
ywangd May 11, 2020
824b560
Forbidden API
ywangd May 11, 2020
41494c4
Address feedback
ywangd May 18, 2020
c0e4089
Address feedback for removing auto-detect of redirect and post
ywangd May 18, 2020
48c27d6
Fix test convention
ywangd May 18, 2020
17903a3
checkstyle
ywangd May 18, 2020
e297fa5
Update x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/s…
ywangd May 27, 2020
c22674d
Tidy up a bit due to latest change in master
ywangd May 27, 2020
a56bbcc
Fix randomize of signer
ywangd May 27, 2020
f2ed7df
Address feedback
ywangd May 28, 2020
04abf77
Merge remote-tracking branch 'origin/master' into es-43264-saml-post-…
ywangd May 28, 2020
2b85c07
Update x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/s…
ywangd May 29, 2020
a9b890a
Update x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/s…
ywangd May 29, 2020
99704d2
Update x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/s…
ywangd May 29, 2020
88862ab
Address feedback
ywangd May 29, 2020
5128737
Add one more test for non-null realm field
ywangd May 29, 2020
19a0afc
Merge remote-tracking branch 'origin/master' into es-43264-saml-post-…
ywangd May 29, 2020
c244cb6
Merge remote-tracking branch 'origin/master' into es-43264-saml-post-…
ywangd May 29, 2020
75abd7b
Address feedback
ywangd Jun 1, 2020
b82275d
Merge remote-tracking branch 'origin/master' into es-43264-saml-post-…
ywangd Jun 1, 2020
f6b4fcb
Merge remote-tracking branch 'origin/master' into es-43264-saml-post-…
ywangd Jun 28, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
package org.elasticsearch.xpack.core.security.action.saml;

import org.elasticsearch.action.ActionType;

/**
* ActionType for completing SAML LogoutResponse
*/
public final class SamlCompleteLogoutAction extends ActionType<SamlCompleteLogoutResponse> {

public static final String NAME = "cluster:admin/xpack/security/saml/complete_logout";
public static final SamlCompleteLogoutAction INSTANCE = new SamlCompleteLogoutAction();

private SamlCompleteLogoutAction() {
super(NAME, SamlCompleteLogoutResponse::new);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
package org.elasticsearch.xpack.core.security.action.saml;

import org.elasticsearch.action.ActionRequest;
import org.elasticsearch.action.ActionRequestValidationException;
import org.elasticsearch.common.Nullable;
import org.elasticsearch.common.Strings;
import org.elasticsearch.common.io.stream.StreamInput;

import java.io.IOException;
import java.util.List;

import static org.elasticsearch.action.ValidateActions.addValidationError;

/**
* Represents a request to complete SAML LogoutResponse
*/
public final class SamlCompleteLogoutRequest extends ActionRequest {

@Nullable
private String queryString;
@Nullable
private String content;
private List<String> validRequestIds;
private String realm;

public SamlCompleteLogoutRequest(StreamInput in) throws IOException {
super(in);
}

public SamlCompleteLogoutRequest() {
}

@Override
public ActionRequestValidationException validate() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could we also validate that the realm is not an empty string here so that we fail explicitly here instead of in the Transport action's doExecute ?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added. Also removed the @Nullable annotation from the realm field.

ActionRequestValidationException validationException = null;
if (Strings.hasText(realm) == false) {
validationException = addValidationError("realm may not be empty", validationException);
}
if (Strings.hasText(queryString) == false && Strings.hasText(content) == false) {
validationException = addValidationError("queryString and content may not both be empty", validationException);
}
if (Strings.hasText(queryString) && Strings.hasText(content)) {
validationException = addValidationError("queryString and content may not both present", validationException);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is our expectation for how a WebApp should behave if it gets a LogoutResponse over a HTTP-POST binding that also has URL parameters?

I think the rule for Kibana is:

  • If the http method is GET send the queryString to Elasticsearch, and ignore (or reject) any body (which is very unlikely to exist)
  • if the http method is POST ignore (or reject) the query parameters, and send the body to Elasticsearch.

Is that our intent?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A good question. To be honest, I haven't really considered this thoroughly. I think your suggestion makes sense because the code proceeds as HTTP-POST, e.g. base64 encode, no deflate and XML signature, when content is present. This basically says content implies POST, which in turn means queryString implies GET.

I would personally prefer to ignore the body for GET and query string for POST since the spec does not explicitly forbid them.

}
return validationException;
}

public String getQueryString() {
return queryString;
}

public void setQueryString(String queryString) {
this.queryString = queryString;
}

public String getContent() {
return content;
}

public void setContent(String content) {
this.content = content;
}

public List<String> getValidRequestIds() {
return validRequestIds;
}

public void setValidRequestIds(List<String> validRequestIds) {
this.validRequestIds = validRequestIds;
}

public String getRealm() {
return realm;
}

public void setRealm(String realm) {
this.realm = realm;
}

public boolean isHttpRedirect() {
return queryString != null;
}

public String getPayload() {
return isHttpRedirect() ? queryString : content;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
package org.elasticsearch.xpack.core.security.action.saml;

import org.elasticsearch.action.ActionResponse;
import org.elasticsearch.common.io.stream.StreamInput;
import org.elasticsearch.common.io.stream.StreamOutput;

import java.io.IOException;

/**
* A response to complete the LogoutResponse from idP
*/
public final class SamlCompleteLogoutResponse extends ActionResponse {

public SamlCompleteLogoutResponse(StreamInput in) throws IOException {
super(in);
}

public SamlCompleteLogoutResponse() {
}

@Override
public void writeTo(StreamOutput out) throws IOException {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,24 +16,32 @@
*/
public final class SamlLogoutResponse extends ActionResponse {

private String redirectUrl;
private final String requestId;
private final String redirectUrl;

public SamlLogoutResponse(StreamInput in) throws IOException {
super(in);
requestId = in.readString();
redirectUrl = in.readString();
}

public SamlLogoutResponse(String redirectUrl) {
public SamlLogoutResponse(String requestId, String redirectUrl) {
this.requestId = requestId;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we need handle BWC here since this response never goes across nodes. The only consumer is Kibana (or other external system that integrates with ES).

this.redirectUrl = redirectUrl;
}

public String getRequestId() {
return requestId;
}

public String getRedirectUrl() {
return redirectUrl;
}

@Override
public void writeTo(StreamOutput out) throws IOException {
out.writeString(requestId);
out.writeString(redirectUrl);
}

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,4 @@ public void writeTo(StreamOutput out) throws IOException {
out.writeString(redirectUrl);
}

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

package org.elasticsearch.xpack.core.security.action.saml;

import org.elasticsearch.action.ActionRequestValidationException;
import org.elasticsearch.test.ESTestCase;

import static org.hamcrest.Matchers.containsString;

public class SamlCompleteLogoutRequestTests extends ESTestCase {

public void testValidateFailsWhenQueryAndBodyBothNotExist() {
final SamlCompleteLogoutRequest samlCompleteLogoutRequest = new SamlCompleteLogoutRequest();
samlCompleteLogoutRequest.setRealm("realm");
final ActionRequestValidationException validationException = samlCompleteLogoutRequest.validate();
assertThat(validationException.getMessage(), containsString("queryString and content may not both be empty"));
}

public void testValidateFailsWhenQueryAndBodyBothSet() {
final SamlCompleteLogoutRequest samlCompleteLogoutRequest = new SamlCompleteLogoutRequest();
samlCompleteLogoutRequest.setRealm("realm");
samlCompleteLogoutRequest.setQueryString("queryString");
samlCompleteLogoutRequest.setContent("content");
final ActionRequestValidationException validationException = samlCompleteLogoutRequest.validate();
assertThat(validationException.getMessage(), containsString("queryString and content may not both present"));
}

public void testValidateFailsWhenRealmIsNotSet() {
final SamlCompleteLogoutRequest samlCompleteLogoutRequest = new SamlCompleteLogoutRequest();
samlCompleteLogoutRequest.setQueryString("queryString");
final ActionRequestValidationException validationException = samlCompleteLogoutRequest.validate();
assertThat(validationException.getMessage(), containsString("realm may not be empty"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@
import org.elasticsearch.xpack.core.security.action.saml.SamlAuthenticateAction;
import org.elasticsearch.xpack.core.security.action.saml.SamlInvalidateSessionAction;
import org.elasticsearch.xpack.core.security.action.saml.SamlLogoutAction;
import org.elasticsearch.xpack.core.security.action.saml.SamlCompleteLogoutAction;
import org.elasticsearch.xpack.core.security.action.saml.SamlPrepareAuthenticationAction;
import org.elasticsearch.xpack.core.security.action.token.CreateTokenAction;
import org.elasticsearch.xpack.core.security.action.token.InvalidateTokenAction;
Expand Down Expand Up @@ -167,6 +168,7 @@
import org.elasticsearch.xpack.security.action.saml.TransportSamlAuthenticateAction;
import org.elasticsearch.xpack.security.action.saml.TransportSamlInvalidateSessionAction;
import org.elasticsearch.xpack.security.action.saml.TransportSamlLogoutAction;
import org.elasticsearch.xpack.security.action.saml.TransportSamlCompleteLogoutAction;
import org.elasticsearch.xpack.security.action.saml.TransportSamlPrepareAuthenticationAction;
import org.elasticsearch.xpack.security.action.token.TransportCreateTokenAction;
import org.elasticsearch.xpack.security.action.token.TransportInvalidateTokenAction;
Expand Down Expand Up @@ -233,6 +235,7 @@
import org.elasticsearch.xpack.security.rest.action.saml.RestSamlAuthenticateAction;
import org.elasticsearch.xpack.security.rest.action.saml.RestSamlInvalidateSessionAction;
import org.elasticsearch.xpack.security.rest.action.saml.RestSamlLogoutAction;
import org.elasticsearch.xpack.security.rest.action.saml.RestSamlCompleteLogoutAction;
import org.elasticsearch.xpack.security.rest.action.saml.RestSamlPrepareAuthenticationAction;
import org.elasticsearch.xpack.security.rest.action.user.RestChangePasswordAction;
import org.elasticsearch.xpack.security.rest.action.user.RestDeleteUserAction;
Expand Down Expand Up @@ -751,6 +754,7 @@ public void onIndexModule(IndexModule module) {
new ActionHandler<>(SamlAuthenticateAction.INSTANCE, TransportSamlAuthenticateAction.class),
new ActionHandler<>(SamlLogoutAction.INSTANCE, TransportSamlLogoutAction.class),
new ActionHandler<>(SamlInvalidateSessionAction.INSTANCE, TransportSamlInvalidateSessionAction.class),
new ActionHandler<>(SamlCompleteLogoutAction.INSTANCE, TransportSamlCompleteLogoutAction.class),
new ActionHandler<>(OpenIdConnectPrepareAuthenticationAction.INSTANCE,
TransportOpenIdConnectPrepareAuthenticationAction.class),
new ActionHandler<>(OpenIdConnectAuthenticateAction.INSTANCE, TransportOpenIdConnectAuthenticateAction.class),
Expand Down Expand Up @@ -808,6 +812,7 @@ public List<RestHandler> getRestHandlers(Settings settings, RestController restC
new RestSamlAuthenticateAction(settings, getLicenseState()),
new RestSamlLogoutAction(settings, getLicenseState()),
new RestSamlInvalidateSessionAction(settings, getLicenseState()),
new RestSamlCompleteLogoutAction(settings, getLicenseState()),
new RestOpenIdConnectPrepareAuthenticationAction(settings, getLicenseState()),
new RestOpenIdConnectAuthenticateAction(settings, getLicenseState()),
new RestOpenIdConnectLogoutAction(settings, getLicenseState()),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
package org.elasticsearch.xpack.security.action.saml;

import org.elasticsearch.action.ActionListener;
import org.elasticsearch.action.support.ActionFilters;
import org.elasticsearch.action.support.HandledTransportAction;
import org.elasticsearch.common.inject.Inject;
import org.elasticsearch.tasks.Task;
import org.elasticsearch.transport.TransportService;
import org.elasticsearch.xpack.core.security.action.saml.SamlCompleteLogoutAction;
import org.elasticsearch.xpack.core.security.action.saml.SamlCompleteLogoutRequest;
import org.elasticsearch.xpack.core.security.action.saml.SamlCompleteLogoutResponse;
import org.elasticsearch.xpack.security.authc.Realms;
import org.elasticsearch.xpack.security.authc.saml.SamlLogoutResponseHandler;
import org.elasticsearch.xpack.security.authc.saml.SamlRealm;
import org.elasticsearch.xpack.security.authc.saml.SamlUtils;

import java.util.List;

import static org.elasticsearch.xpack.security.authc.saml.SamlRealm.findSamlRealms;

/**
* Transport action responsible for completing SAML LogoutResponse
*/
public final class TransportSamlCompleteLogoutAction extends HandledTransportAction<SamlCompleteLogoutRequest, SamlCompleteLogoutResponse> {

private final Realms realms;

@Inject
public TransportSamlCompleteLogoutAction(TransportService transportService, ActionFilters actionFilters, Realms realms) {
super(SamlCompleteLogoutAction.NAME, transportService, actionFilters, SamlCompleteLogoutRequest::new);
this.realms = realms;
}

@Override
protected void doExecute(Task task, SamlCompleteLogoutRequest request, ActionListener<SamlCompleteLogoutResponse> listener) {
List<SamlRealm> realms = findSamlRealms(this.realms, request.getRealm(), null);
if (realms.isEmpty()) {
listener.onFailure(SamlUtils.samlException("Cannot find any matching realm with name [{}]", request.getRealm()));
} else if (realms.size() > 1) {
listener.onFailure(SamlUtils.samlException("Found multiple matching realms [{}] with name [{}]", realms, request.getRealm()));
} else {
processLogoutResponse(realms.get(0), request, listener);
}
}

private void processLogoutResponse(SamlRealm samlRealm, SamlCompleteLogoutRequest request,
ActionListener<SamlCompleteLogoutResponse> listener) {

final SamlLogoutResponseHandler logoutResponseHandler = samlRealm.getLogoutResponseHandler();
try {
logoutResponseHandler.handle(request.isHttpRedirect(), request.getPayload(), request.getValidRequestIds());
listener.onResponse(new SamlCompleteLogoutResponse());
} catch (Exception e) {
listener.onFailure(e);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,10 @@ private SamlLogoutResponse buildResponse(Authentication authentication, Map<Stri
final String session = getMetadataString(tokenMetadata, SamlRealm.TOKEN_METADATA_SESSION);
final LogoutRequest logout = realm.buildLogoutRequest(nameId.asXml(), session);
if (logout == null) {
return new SamlLogoutResponse((String)null);
return new SamlLogoutResponse(null, null);
}
final String uri = new SamlRedirect(logout, realm.getSigningConfiguration()).getRedirectUrl();
return new SamlLogoutResponse(uri);
return new SamlLogoutResponse(logout.getID(), uri);
}

private String getMetadataString(Map<String, Object> metadata, String key) {
Expand Down
Loading