blob: 4e734a77824b30ce774c0f3592667b0acf1f53ef [file] [log] [blame]
// Copyright 2009 Google Inc.
//
// 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 com.google.enterprise.adaptor.secmgr.modules;
import static com.google.enterprise.adaptor.secmgr.saml.OpenSamlUtil.initializeLocalEntity;
import static com.google.enterprise.adaptor.secmgr.saml.OpenSamlUtil.initializePeerEntity;
import static com.google.enterprise.adaptor.secmgr.saml.OpenSamlUtil.makeAction;
import static com.google.enterprise.adaptor.secmgr.saml.OpenSamlUtil.makeArtifactResolve;
import static com.google.enterprise.adaptor.secmgr.saml.OpenSamlUtil.makeAuthnRequest;
import static com.google.enterprise.adaptor.secmgr.saml.OpenSamlUtil.makeAuthzDecisionQuery;
import static com.google.enterprise.adaptor.secmgr.saml.OpenSamlUtil.makeSamlMessageContext;
import static com.google.enterprise.adaptor.secmgr.saml.OpenSamlUtil.makeSubject;
import static com.google.enterprise.adaptor.secmgr.saml.OpenSamlUtil.runDecoder;
import static com.google.enterprise.adaptor.secmgr.saml.OpenSamlUtil.runEncoder;
import static org.opensaml.common.xml.SAMLConstants.SAML20P_NS;
import static org.opensaml.common.xml.SAMLConstants.SAML2_REDIRECT_BINDING_URI;
import static org.opensaml.common.xml.SAMLConstants.SAML2_SOAP11_BINDING_URI;
import com.google.common.base.Preconditions;
import com.google.common.collect.Maps;
import com.google.enterprise.adaptor.secmgr.common.AuthzStatus;
import com.google.enterprise.adaptor.secmgr.http.HttpClientInterface;
import com.google.enterprise.adaptor.secmgr.http.HttpExchange;
import com.google.enterprise.adaptor.secmgr.saml.HTTPSOAP11MultiContextDecoder;
import com.google.enterprise.adaptor.secmgr.saml.HTTPSOAP11MultiContextEncoder;
import com.google.enterprise.adaptor.secmgr.saml.HttpExchangeToInTransport;
import com.google.enterprise.adaptor.secmgr.saml.HttpExchangeToOutTransport;
import com.google.enterprise.adaptor.secmgr.saml.SamlLogUtil;
import org.joda.time.DateTime;
import org.opensaml.common.SAMLObject;
import org.opensaml.common.binding.SAMLMessageContext;
import org.opensaml.common.xml.SAMLConstants;
import org.opensaml.saml2.binding.decoding.HTTPSOAP11Decoder;
import org.opensaml.saml2.binding.encoding.HTTPRedirectDeflateEncoder;
import org.opensaml.saml2.binding.encoding.HTTPSOAP11Encoder;
import org.opensaml.saml2.core.Action;
import org.opensaml.saml2.core.ArtifactResolve;
import org.opensaml.saml2.core.ArtifactResponse;
import org.opensaml.saml2.core.Assertion;
import org.opensaml.saml2.core.AuthnRequest;
import org.opensaml.saml2.core.AuthzDecisionQuery;
import org.opensaml.saml2.core.AuthzDecisionStatement;
import org.opensaml.saml2.core.DecisionTypeEnumeration;
import org.opensaml.saml2.core.NameID;
import org.opensaml.saml2.core.Response;
import org.opensaml.saml2.core.Statement;
import org.opensaml.saml2.core.StatusCode;
import org.opensaml.saml2.metadata.ArtifactResolutionService;
import org.opensaml.saml2.metadata.AssertionConsumerService;
import org.opensaml.saml2.metadata.AuthzService;
import org.opensaml.saml2.metadata.EntityDescriptor;
import org.opensaml.saml2.metadata.SPSSODescriptor;
import org.opensaml.saml2.metadata.SingleSignOnService;
import org.opensaml.util.URLBuilder;
import org.opensaml.ws.message.encoder.MessageEncoder;
import org.opensaml.ws.message.encoder.MessageEncodingException;
import org.opensaml.ws.transport.http.HTTPInTransport;
import org.opensaml.ws.transport.http.HTTPOutTransport;
import org.opensaml.xml.security.credential.Credential;
import org.opensaml.xml.util.Pair;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URL;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.logging.Logger;
import javax.annotation.concurrent.GuardedBy;
import javax.annotation.concurrent.ThreadSafe;
/**
* A library implementing most of the functionality of a SAML service provider.
* This library knows how to send an authentication request via the redirect
* binding, and to receive a response via either artifact or POST binding.
*/
@ThreadSafe
public class SamlClient {
private static final Logger LOGGER = Logger.getLogger(SamlClient.class.getName());
private final EntityDescriptor localEntity;
private final EntityDescriptor peerEntity;
private final String providerName;
private final Credential signingCredential;
private final HttpClientInterface httpClient;
private final int timeout;
@GuardedBy("requestIdLock")
private String requestId;
private static final Object requestIdLock = new Object();
/**
* Create an instance of the client library.
*
* @param localEntity Metadata for the the local entity (the service provider
* using this library).
* @param peerEntity Metadata for the peer entity (the SAML IdP).
* @param providerName Descriptive name of the service provider.
* @param signingCredential A credential to use for signing the outgoing
* request. May be null if signing isn't needed.
* @param httpClient An HTTP client to use for resolving any artifact returned
* from the IdP. May be null if the artifact binding isn't being used.
*/
public SamlClient(EntityDescriptor localEntity, EntityDescriptor peerEntity,
String providerName, Credential signingCredential, HttpClientInterface httpClient) {
this.localEntity = localEntity;
this.peerEntity = peerEntity;
this.providerName = providerName;
this.signingCredential = signingCredential;
this.httpClient = httpClient;
timeout = -1;
}
/**
* Create an instance of the client library.
*
* @param localEntity Metadata for the the local entity (the service provider
* using this library).
* @param peerEntity Metadata for the peer entity (the SAML IdP).
* @param providerName Descriptive name of the service provider.
* @param signingCredential A credential to use for signing the outgoing
* request. May be null if signing isn't needed.
* @param httpClient An HTTP client to use for resolving any artifact returned
* from the IdP. May be null if the artifact binding isn't being
* @param timeout The http socket timeout value in milliseconds.
* -1 means to use the httpClient default timeout.
*/
public SamlClient(EntityDescriptor localEntity, EntityDescriptor peerEntity,
String providerName, Credential signingCredential, HttpClientInterface httpClient,
int timeout) {
this.localEntity = localEntity;
this.peerEntity = peerEntity;
this.providerName = providerName;
this.signingCredential = signingCredential;
this.httpClient = httpClient;
this.timeout = timeout;
}
/**
* Get the metadata for this client's local entity.
*
* @return The entity descriptor for the local entity.
*/
public EntityDescriptor getLocalEntity() {
return localEntity;
}
/**
* Get the metadata for this client's peer entity.
*
* @return The entity descriptor for the peer entity.
*/
public EntityDescriptor getPeerEntity() {
return peerEntity;
}
/**
* Get the message ID of the most recent request.
*
* @return The message ID of the most recent request, never null.
*/
public String getRequestId() {
synchronized (requestIdLock) {
Preconditions.checkNotNull(requestId);
return requestId;
}
}
/**
* Get the metadata for this client's local POST assertion consumer service.
*
* @return The assertion consumer service descriptor.
*/
public AssertionConsumerService getPostAssertionConsumerService() {
return getAssertionConsumerService(SAMLConstants.SAML2_POST_BINDING_URI);
}
/**
* Get the metadata for this client's local ARTIFACT assertion consumer service.
*
* @return The assertion consumer service descriptor.
*/
public AssertionConsumerService getArtifactAssertionConsumerService() {
return getAssertionConsumerService(SAMLConstants.SAML2_ARTIFACT_BINDING_URI);
}
private AssertionConsumerService getAssertionConsumerService(String binding) {
for (AssertionConsumerService acs :
localEntity.getSPSSODescriptor(SAML20P_NS).getAssertionConsumerServices()) {
if (binding.equals(acs.getBinding())) {
return acs;
}
}
throw new IllegalArgumentException("No assertion consumer with binding " + binding);
}
/**
* Send an AuthnRequest message to the IdP via the redirect protocol.
*
* @param response The HTTP response message that will be filled with the encoded redirect.
* @throws IOException if errors occur during the encoding.
*/
public void sendAuthnRequest(HTTPOutTransport outTransport)
throws IOException {
SAMLMessageContext<SAMLObject, AuthnRequest, NameID> context = makeSamlMessageContext();
SPSSODescriptor sp = localEntity.getSPSSODescriptor(SAML20P_NS);
initializeLocalEntity(context, localEntity, sp);
initializePeerEntity(context, peerEntity, peerEntity.getIDPSSODescriptor(SAML20P_NS),
SingleSignOnService.DEFAULT_ELEMENT_NAME,
SAML2_REDIRECT_BINDING_URI);
// Generate the request
AuthnRequest authnRequest =
makeAuthnRequest(context.getOutboundMessageIssuer(), new DateTime());
authnRequest.setProviderName(providerName);
authnRequest.setIsPassive(false);
if (signingCredential != null) {
authnRequest.setAssertionConsumerServiceURL(
sp.getDefaultAssertionConsumerService().getLocation());
authnRequest.setProtocolBinding(
sp.getDefaultAssertionConsumerService().getBinding());
// Must sign the message in order for ACS URL to be trusted by peer.
context.setOutboundSAMLMessageSigningCredential(signingCredential);
}
authnRequest.setDestination(context.getPeerEntityEndpoint().getLocation());
context.setOutboundSAMLMessage(authnRequest);
// Remember the request ID for later.
synchronized (requestIdLock) {
requestId = authnRequest.getID();
}
// Not needed:
//context.setRelayState();
// Send the request via redirect to the user agent
//ServletBase.initResponse(response);
context.setOutboundMessageTransport(outTransport);
runEncoder(new RedirectEncoder(), context);
}
/**
* Decode a SAML response sent via the artifact binding.
*
* @param request The HTTP request containing the artifact.
* @return The decoded response, or null if there was an error decoding the message. In
* this case, a log file entry is generated, so the caller doesn't need to know the
* details of the failure.
* @throws IOException for various errors related to session and metadata, or for the
* artifact resolution interchange.
*/
public Response decodeArtifactResponse(URI requestUri, HTTPInTransport inTransport)
throws IOException {
// The OpenSAML HTTPArtifactDecoder isn't implemented, so we must manually decode the
// artifact.
String query = requestUri.getQuery();
String artifact = null;
for (String kvPair : query.split("&")) {
String[] kv = kvPair.split("=", 2);
if (kv.length != 2) {
continue;
}
if (!"SAMLart".equals(kv[0])) {
continue;
}
artifact = kv[1];
break;
}
if (artifact == null) {
LOGGER.warning("No artifact in message");
return null;
}
SAMLObject message = resolveArtifact(inTransport, artifact);
if (!(message instanceof Response)) {
LOGGER.warning("Unable to resolve artifact");
}
return (Response) message;
}
/**
* Resolve a SAML artifact.
*
* @param session The authentication session.
* @param request The HTTP request containing the artifact, to identify the artifact
* resolution service to use.
* @param artifact The artifact to resolve.
* @return The SAML object that the artifact resolves to.
* @throws IOException
*/
private SAMLObject resolveArtifact(HTTPInTransport inTransport,
String artifact)
throws IOException {
// Establish the SAML message context.
SAMLMessageContext<ArtifactResponse, ArtifactResolve, NameID> context =
makeSamlMessageContext();
initializeLocalEntity(context, localEntity, localEntity.getSPSSODescriptor(SAML20P_NS));
initializePeerEntity(context, peerEntity, peerEntity.getIDPSSODescriptor(SAML20P_NS),
ArtifactResolutionService.DEFAULT_ELEMENT_NAME,
SAML2_SOAP11_BINDING_URI);
// Generate the request.
context.setOutboundSAMLMessage(
makeArtifactResolve(localEntity.getEntityID(), new DateTime(), artifact));
// Encode the request.
HttpExchange exchange =
httpClient.postExchange(new URL(context.getPeerEntityEndpoint().getLocation()), null);
try {
HttpExchangeToOutTransport out = new HttpExchangeToOutTransport(exchange);
try {
context.setOutboundMessageTransport(out);
context.setRelayState(inTransport.getHeaderValue("RelayState"));
runEncoder(new HTTPSOAP11Encoder(), context);
} finally {
out.finish();
}
} finally {
exchange.close();
}
if (timeout != -1) {
exchange.setTimeout(timeout);
}
// Do HTTP exchange.
int status = exchange.exchange();
if (status != HttpURLConnection.HTTP_OK) {
LOGGER.warning("Incorrect HTTP status: " + status);
return null;
}
// Decode the response.
context.setInboundMessageTransport(new HttpExchangeToInTransport(exchange));
try {
runDecoder(new HTTPSOAP11Decoder(), context);
} catch (IOException e) {
LOGGER.warning("IOException: " + e.getMessage());
return null;
}
// Return the decoded response.
ArtifactResponse artifactResponse = context.getInboundSAMLMessage();
Preconditions.checkNotNull(artifactResponse, "Decoded SAML response is null");
return artifactResponse.getMessage();
}
/**
* Send a single SAML-standard authorization request.
*
* @param urlString The URL for which access is being authorized.
* @param username The username to test for access.
* @return The authorization status.
* @throws IOException if there are any I/O errors during authorization.
*/
public AuthzStatus sendAuthzRequest(String urlString, String username)
throws IOException {
Preconditions.checkNotNull(urlString);
Preconditions.checkNotNull(username);
SAMLMessageContext<Response, AuthzDecisionQuery, NameID> context = makeAuthzContext();
HttpExchange exchange = makeAuthzExchange(context);
try {
HttpExchangeToOutTransport out = new HttpExchangeToOutTransport(exchange);
setupAuthzQuery(context, urlString, username, new DateTime(), out, new HTTPSOAP11Encoder());
out.finish();
// Do HTTP exchange
int status = exchange.exchange();
if (!isGoodHttpStatus(status)) {
throw new IOException("Incorrect HTTP status: " + status);
}
// Decode the response
HttpExchangeToInTransport in = new HttpExchangeToInTransport(exchange);
context.setInboundMessageTransport(in);
runDecoder(new HTTPSOAP11Decoder(), context);
} finally {
exchange.close();
}
DecodedAuthzResponse response = decodeAuthzResponse(context.getInboundSAMLMessage(), username);
if (response == null) {
return AuthzStatus.INDETERMINATE;
}
if (!urlString.equals(response.resource)) {
throw new IOException("Wrong resource received (expected '" + urlString
+ "'): '" + response.resource + "'");
}
return response.status;
}
/**
* Send a (nonstandard) multiple SAML authorization request.
*
* @param urlStrings The URLs for which access is being authorized.
* @param username The username to test for access.
* @return The authorization responses.
* @throws IOException if there are any I/O errors during authorization.
*/
public AuthzResult sendMultiAuthzRequest(Collection<String> urlStrings,
String username)
throws IOException {
Preconditions.checkNotNull(urlStrings);
Preconditions.checkNotNull(username);
if (urlStrings.isEmpty()) {
return AuthzResult.makeIndeterminate(urlStrings);
}
// Establish the SAML message context.
SAMLMessageContext<Response, AuthzDecisionQuery, NameID> context = makeAuthzContext();
HTTPSOAP11MultiContextEncoder encoder = new HTTPSOAP11MultiContextEncoder();
HttpExchange exchange = makeAuthzExchange(context);
try {
HttpExchangeToOutTransport out = new HttpExchangeToOutTransport(exchange);
DateTime now = new DateTime();
for (String urlString : urlStrings) {
setupAuthzQuery(context, urlString, username, now, out, encoder);
}
try {
encoder.finish();
} catch (MessageEncodingException e) {
throw new IOException(e);
}
out.finish();
// Do HTTP exchange
int status = exchange.exchange();
if (!isGoodHttpStatus(status)) {
throw new IOException("Incorrect HTTP status: " + status);
}
// Decode the responses
HttpExchangeToInTransport in = new HttpExchangeToInTransport(exchange);
context.setInboundMessageTransport(in);
HTTPSOAP11MultiContextDecoder decoder = new HTTPSOAP11MultiContextDecoder();
AuthzResult.Builder builder = AuthzResult.builder(urlStrings);
while (true) {
try {
runDecoder(decoder, context);
} catch (IndexOutOfBoundsException e) {
// normal indication that there are no more messages to decode
break;
}
DecodedAuthzResponse response
= decodeAuthzResponse(context.getInboundSAMLMessage(), username);
if (response != null) {
if (urlStrings.contains(response.resource)) {
builder.put(response.resource, response.status);
} else {
LOGGER.warning("Unknown resource received: '" + response.resource + "'");
}
}
}
return builder.build();
} finally {
exchange.close();
}
}
private SAMLMessageContext<Response, AuthzDecisionQuery, NameID> makeAuthzContext() {
// Establish the SAML message context.
SAMLMessageContext<Response, AuthzDecisionQuery, NameID> context = makeSamlMessageContext();
initializeLocalEntity(context, localEntity);
initializePeerEntity(context, peerEntity,
peerEntity.getPDPDescriptor(SAMLConstants.SAML20P_NS),
AuthzService.DEFAULT_ELEMENT_NAME,
SAML2_SOAP11_BINDING_URI);
return context;
}
private HttpExchange makeAuthzExchange(
SAMLMessageContext<Response, AuthzDecisionQuery, NameID> context)
throws IOException {
return httpClient.postExchange(new URL(context.getPeerEntityEndpoint().getLocation()), null);
}
private void setupAuthzQuery(SAMLMessageContext<Response, AuthzDecisionQuery, NameID> context,
String urlString, String username, DateTime instant, HTTPOutTransport out,
MessageEncoder encoder)
throws IOException {
AuthzDecisionQuery query
= makeAuthzDecisionQuery(
context.getOutboundMessageIssuer(),
instant,
makeSubject(username),
urlString,
makeAction(Action.HTTP_GET_ACTION, Action.GHPP_NS_URI));
LOGGER.info(SamlLogUtil.xmlMessage("AuthzDecisionQuery", query));
context.setOutboundSAMLMessage(query);
context.setOutboundMessageTransport(out);
runEncoder(encoder, context);
}
private DecodedAuthzResponse decodeAuthzResponse(Response response, String username)
throws IOException {
LOGGER.info(SamlLogUtil.xmlMessage("response", response));
String statusValue = response.getStatus().getStatusCode().getValue();
if (!StatusCode.SUCCESS_URI.equals(statusValue)) {
LOGGER.info("Unsuccessful response received: " + statusValue);
return null;
}
List<Assertion> assertions = response.getAssertions();
if (assertions.size() != 1) {
LOGGER.warning("Wrong number of assertions received (expected 1): " + assertions.size());
return null;
}
Assertion assertion = assertions.get(0);
String responseUsername = assertion.getSubject().getNameID().getValue();
if (!username.equals(responseUsername)) {
LOGGER.warning("Wrong username received (expected '" + username + "'): '"
+ responseUsername + "'");
return null;
}
List<Statement> statements = assertion.getStatements();
if (statements.size() != 1) {
LOGGER.warning("Wrong number of statements received (expected 1): " + statements.size());
return null;
}
Statement statement = statements.get(0);
AuthzDecisionStatement authzDecisionStatement = AuthzDecisionStatement.class.cast(statement);
return new DecodedAuthzResponse(
authzDecisionStatement.getResource(),
mapDecision(authzDecisionStatement.getDecision()));
}
private static final class DecodedAuthzResponse {
public final String resource;
public final AuthzStatus status;
public DecodedAuthzResponse(String resource, AuthzStatus status) {
this.resource = resource;
this.status = status;
}
}
private AuthzStatus mapDecision(DecisionTypeEnumeration decision) {
if (decision == DecisionTypeEnumeration.PERMIT) {
return AuthzStatus.PERMIT;
} else if (decision == DecisionTypeEnumeration.DENY) {
return AuthzStatus.DENY;
} else {
return AuthzStatus.INDETERMINATE;
}
}
/**
* A tweaked redirect encoder that preserves query parameters from the endpoint URL.
*/
private static final class RedirectEncoder extends HTTPRedirectDeflateEncoder {
RedirectEncoder() {
super();
}
@Override
protected String buildRedirectURL(@SuppressWarnings("rawtypes") SAMLMessageContext context,
String endpointUrl, String message)
throws MessageEncodingException {
String encodedUrl = super.buildRedirectURL(context, endpointUrl, message);
// Get the query parameters from the endpoint URL.
List<Pair<String, String>> endpointParams = new URLBuilder(endpointUrl).getQueryParams();
if (endpointParams.isEmpty()) {
// If none, we're finished.
return encodedUrl;
}
URLBuilder builder = new URLBuilder(encodedUrl);
List<Pair<String, String>> samlParams = builder.getQueryParams();
// Merge the endpoint params with the SAML params.
Map<String, String> params = Maps.newHashMap();
for (Pair<String, String> entry : endpointParams) {
params.put(entry.getFirst(), entry.getSecond());
}
for (Pair<String, String> entry : samlParams) {
params.put(entry.getFirst(), entry.getSecond());
}
// Copy the merged params back into the result.
samlParams.clear();
for (Map.Entry<String, String> entry : params.entrySet()) {
samlParams.add(new Pair<String, String>(entry.getKey(), entry.getValue()));
}
return builder.buildURL();
}
}
public static boolean isGoodHttpStatus(int status) {
return status == HttpURLConnection.HTTP_OK
|| status == HttpURLConnection.HTTP_PARTIAL;
}
}