REST API for Amazon Web Service Credentials

A note about this example, it’s a little out of date. The modern version of the IdP allows much of the flow machinery and files to be embedded inside the extension jar to auto-register itself, but the example uses the old “manual” way of adding a flow to the system.

Most of Amazon's AWS services use a proprietary security model that relies on the use of either AWS-managed user accounts and password, or a model where external tokens using technologies like SAML or OAuth are exchanged for "temporary credentials" that are used to secure AWS API calls. One of the "unsecured" APIs in the AWS STS is called AssumeRoleWithSAML and is the core API for trading a SAML Response message issued by a trusted IdP for temporary credentials that operate in a particular AWS role. This is the API used internally by the AWS web console to allow federated access but anything can call it.

An AWS account can limit which roles can be assumed using assertions from a given IdP and can be qualified in limited fashion based on other contents of the assertion, but for the most part any assertion that's signed, still valid, not encrypted, and that contains the proper SAML Attribute is essentially usable to assume a role. (This, as an aside, makes it pretty important that you take care with including that Attribute because even if you didn't mean to include it, Amazon ignores almost all the core processing rules in SAML (e.g. Audience, Recipient, Destination) to control response and assertion targeting and acceptance.)

This page is a set of example code and configuration that was built to customize the IdP to introduce alternate behavior into the core SAML flows, turning it into a simple REST API for obtaining AWS temp credentials. It does this by hijacking the core behavior of the SSO flows so that once it prepares the Response message for an AWS login, it passes the message into the AssumeRoleWithSAML API and then dumps the resulting credentials to the client either as HTML or as JSON, instead of completing the flow by returning the SAML Response.

The advantage of this hackery is that makes it much easier to build a client or script that can authenticate to the IdP and get AWS credentials. The simplest alternative to this would be to have an ECP-capable client pull out the SAML Response and call AssumeRoleWithSAML itself. This accomplishes all of that internally within the IdP and doesn't require unsupported modifications to the IdP, only some Java code, configuration and scripting, and publically accessible and documented features.

Technical Summary

The basic trick is to leverage the fact that AWS relies on IdP-initiated SSO, so the sequence starts at the IdP using a proprietary query parameter interface anyway. The trick is to customize and extend that interface to support other parameters that can be recognized to trigger alternate behavior. In particular, it drives an HTTP Data Connector that invokes the AssumeRoleWithSAML API internally to the IdP and makes the resulting credentials available as a set of IdPAttribute objects that get rendered for the client.

The profile interceptor feature is the main tool for making all this work, and unlike most cases, this example makes use of the inbound and outbound injection points.

The inbound interceptor is used to detect and act on the parameters in the URL to disinguish a "normal" SSO request from a custom request. The result of the interceptor is captured in a custom Java class that is attached to the ProfileRequestContext tree that the software uses as the core information tracking and state management design.

The outbound interceptor is used to block the outgoing SAML Response and replace the output of the request with the result of a scripted execution of the Attribute Resolver to run the HTTP connector that trades the SAML Response for the Amazon credentials.

Preconditions

This example assumes you already have a working federated AWS integration with the proper trust and policy settings in AWS, and a means to resolve and include the appropriate custom Role and RoleSessionName SAML Attributes for AWS. Without all that, the AssumeRoleWithSAML call will simply fail.

API

The REST API is a set of addtional custom parameters supplied to the IdP's Unsolicited SSO endpoint: by embedding them inside the "target" parameter as a colon-separated string. The advantage of this ugly approach is that it avoids the need to customize the core inbound request processing step in the IdP. It's a simple matter to build a CGI script that can take a more friendly parameter syntax and construct the necessary URL.

  • action

    • MUST be set to AssumeRoleWithSAML

  • accountNumber

    • AWS account

  • role

    • AWS role ARN

  • ui

    • Set to 0/1/true/false to indicate whether the output should be HTML or JSON, used to force a UI for browser access

  • duration

    • XML Duration string controlling how long the credentials should be valid for

The result is either HTML or JSON, depending on the "ui" parameter.

Inbound Interceptor and Java Classes

There are two Java classes needed to extract and track the requested parameters, included inline here in an example package. The code is available for use under the Apache license and is unsupported. It was written for V3, and had to be ported to V4 with some minimal changes.

AmazonRequestContext.java
/* * Licensed to the University Corporation for Advanced Internet Development, * Inc. (UCAID) under one or more contributor license agreements. See the * NOTICE file distributed with this work for additional information regarding * copyright ownership. The UCAID licenses this file to You under the Apache * License, Version 2.0 (the "License"); you may not use this file except in * compliance with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.example.idp3.amazon; import java.util.HashMap; import java.util.Map; import javax.annotation.Nonnull; import javax.annotation.Nullable; import org.opensaml.messaging.context.BaseContext; import net.shibboleth.utilities.java.support.annotation.Duration; import net.shibboleth.utilities.java.support.annotation.constraint.Live; import net.shibboleth.utilities.java.support.annotation.constraint.NonNegative; import net.shibboleth.utilities.java.support.annotation.constraint.NonnullElements; import net.shibboleth.utilities.java.support.annotation.constraint.NotEmpty; import net.shibboleth.utilities.java.support.logic.Constraint; import net.shibboleth.utilities.java.support.primitive.StringSupport; import com.google.common.base.MoreObjects; /** * Context representing a custom request to reuse the SAML2 SSO flow to integrate with the AssumeRoleWithSAML API. */ public class AmazonRequestContext extends BaseContext { /** Profile action. */ @Nullable private String action; /** AWS account number. */ @Nullable private String accountNumber; /** AWS role name. */ @Nullable private String roleName; /** Token duration to use. */ @Nullable @Duration @NonNegative private Long duration; /** * Get the special action to execute. * * @return special profile action */ @Nullable @NotEmpty public String getAction() { return action; } /** * Set the special action to execute. * * @param act special profile action */ public void setAction(@Nullable @NotEmpty final String act) { action = StringSupport.trimOrNull(act); } /** * Get the AWS account number to use. * * @return AWS account number, if set */ @Nullable @NotEmpty public String getAccountNumber() { return accountNumber; } /** * Set the AWS account number to use. * * @param account AWS account number */ public void setAccountNumber(@Nullable @NotEmpty final String account) { accountNumber = StringSupport.trimOrNull(account); } /** * Get the AWS role name to assume. * * @return AWS role name, if set */ @Nullable @NotEmpty public String getRoleName() { return roleName; } /** * Set the AWS role name to assume. * * @param role role name */ public void setRoleName(@Nullable @NotEmpty final String role) { roleName = StringSupport.trimOrNull(role); } /** * Get the token duration to use. * * @return token duration, if set */ @Nullable @Duration @NonNegative public Long getDuration() { return duration; } /** * Set the token duration to use. * * @param dur token duration in milliseconds */ public void setDuration(@Nullable @Duration @NonNegative final Long dur) { if (dur != null) { Constraint.isGreaterThan(0, dur, "Duration must be greater than zero"); duration = dur; } else { duration = null; } } /** {@inheritDoc} */ @Override public String toString() { return MoreObjects.toStringHelper(this) .add("accountNumber", accountNumber) .add("roleName", roleName) .add("duration", duration) .toString(); } }
PopulateAmazonRequestContext.java
/* * Licensed to the University Corporation for Advanced Internet Development, * Inc. (UCAID) under one or more contributor license agreements. See the * NOTICE file distributed with this work for additional information regarding * copyright ownership. The UCAID licenses this file to You under the Apache * License, Version 2.0 (the "License"); you may not use this file except in * compliance with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.example.shibboleth.idp3.amazon; import javax.annotation.Nonnull; import javax.annotation.Nullable; import net.shibboleth.utilities.java.support.annotation.constraint.NotEmpty; import net.shibboleth.utilities.java.support.logic.ConstraintViolationException; import net.shibboleth.utilities.java.support.primitive.StringSupport; import net.shibboleth.utilities.java.support.xml.DOMTypeSupport; import org.opensaml.profile.action.AbstractProfileAction; import org.opensaml.profile.action.ActionSupport; import org.opensaml.profile.action.EventIds; import org.opensaml.profile.context.ProfileRequestContext; import org.opensaml.saml.common.SAMLObject; import org.opensaml.saml.common.binding.SAMLBindingSupport; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * Decodes an incoming Amazon AssumeRole request. */ public class PopulateAmazonRequestContext extends AbstractProfileAction<SAMLObject,SAMLObject> { /** Name of the query parameter carrying the action indicator: {@value} . */ @Nonnull @NotEmpty public static final String ACTION_PARAM = "action"; /** Name of the query parameter carrying the account number: {@value} . */ @Nonnull @NotEmpty public static final String ACCOUNTNUM_PARAM = "accountNumber"; /** Name of the query parameter carrying to role: {@value} . */ @Nonnull @NotEmpty public static final String ROLE_PARAM = "role"; /** Name of the query parameter carrying the token duration: {@value} . */ @Nonnull @NotEmpty public static final String DURATION_PARAM = "duration"; /** Name of the query parameter carrying the UI indicator: {@value} . */ @Nonnull @NotEmpty public static final String UI_PARAM = "ui"; /** Class logger. */ @Nonnull private final Logger log = LoggerFactory.getLogger(PopulateAmazonRequestContext.class); /** RelayState value. */ @Nullable private String relayState; /** {@inheritDoc} */ @Override protected boolean doPreExecute(@Nonnull final ProfileRequestContext<SAMLObject,SAMLObject> profileRequestContext) { if (!super.doPreExecute(profileRequestContext)) { return false; } relayState = StringSupport.trimOrNull( SAMLBindingSupport.getRelayState(profileRequestContext.getInboundMessageContext())); return relayState != null; } /** {@inheritDoc} */ @Override protected void doExecute(@Nonnull final ProfileRequestContext<SAMLObject,SAMLObject> profileRequestContext) { final String[] fields = relayState.split(":"); if (fields == null || fields.length % 2 == 1) { log.warn("{} RelayState was empty or contained an odd number of colon-delimited fields", getLogPrefix()); ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_MESSAGE); return; } boolean actionRecognized = false; final AmazonRequestContext amazonCtx = new AmazonRequestContext(); for (int i = 0; i < fields.length; i = i + 2) { final String p = StringSupport.trimOrNull(fields[i]); final String v = StringSupport.trimOrNull(fields[i+1]); if (p == null || v == null) { log.warn("{} Field was empty or null", getLogPrefix()); } if (ACTION_PARAM.equals(p)) { if ("AssumeRoleWithSAML".equals(v)) { actionRecognized = true; } else { log.warn("{} Unrecognized {} parameter: {}", getLogPrefix(), p, v); ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_MESSAGE); return; } amazonCtx.setAction(v); } else if (UI_PARAM.equals(p)) { profileRequestContext.setBrowserProfile(!("0".equals(v) || "false".equals(v))); } else if (ACCOUNTNUM_PARAM.equals(p)) { amazonCtx.setAccountNumber(v); } else if (ROLE_PARAM.equals(p)) { amazonCtx.setRoleName(v); } else if (DURATION_PARAM.equals(p)) { try { if (v.startsWith("P")) { amazonCtx.setDuration(DOMTypeSupport.durationToLong(v)); } else if (v.startsWith("-P")) { log.warn("{} Negative durations are not supported", getLogPrefix()); ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_MESSAGE); return; } else { amazonCtx.setDuration(Long.valueOf(v)); } } catch (final NumberFormatException|ConstraintViolationException e) { log.warn("{} Duration '{}' was invalid", getLogPrefix(), v, e); ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_MESSAGE); return; } } else { log.warn("{} Unrecognized parameter: {}", getLogPrefix(), p); } } // The AWS console chokes on RelayState. SAMLBindingSupport.setRelayState(profileRequestContext.getInboundMessageContext(), null); if (!actionRecognized) { log.debug("{} No recognized action, not attaching context to tree", getLogPrefix()); return; } if (amazonCtx.getAccountNumber() == null || amazonCtx.getRoleName() == null) { log.warn("{} Requires {} and {} parameters", getLogPrefix(), ACCOUNTNUM_PARAM, ROLE_PARAM); // Error handling will fail if the profile is set to non-browser. profileRequestContext.setBrowserProfile(true); ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_MESSAGE); return; } log.debug("{} Attaching AWS request context: {}", getLogPrefix(), amazonCtx); profileRequestContext.addSubcontext(amazonCtx); } }

With these two Java classes on the classpath, the simple inbound interceptor flow can be built and installed:

idp/flows/intercept/aws/inbound/inbound-flow.xml
<flow xmlns="http://www.springframework.org/schema/webflow" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.springframework.org/schema/webflow http://www.springframework.org/schema/webflow/spring-webflow.xsd" parent="intercept.abstract"> <!-- Rudimentary impediment to direct execution of subflow. --> <input name="calledAsSubflow" type="boolean" required="true" /> <action-state id="PopulateAmazonRequestContext"> <evaluate expression="PopulateAmazonRequestContext" /> <evaluate expression="'proceed'" /> <transition on="proceed" to="proceed" /> </action-state> <bean-import resource="inbound-beans.xml" /> </flow>
idp/flows/intercept/aws/inbound/inbound-beans.xml

Outbound Interceptor

On the other side of the flow, an outbound interceptor provides for a scripted invocation of the Attribute Resolver and termination of the request with a custom Event. To use the flow, the custom interceptor Event, "AWSCredentialsReturned", has to be added in the manner described in ProfileHandling, via conf/intercept/intercept-events-flow.xml

idp/flows/intercept/aws/outbound/outbound-flow.xml
idp/flows/intercept/aws/outbound/outbound-beans.xml

Interceptor Configuration and Enablement

The boilerplate part is defining the interceptors to the system and attaching them to the AWS service via an override.

conf/intercept/profile-intercept.xml
conf/relying-party.xml

Attribute Resolver Configuration

The heart of this trickery is the use of the Attribute Resolver to internally cause a side effect: asking it to resolve a set of four Attributes corresponding to the AWS credential being issued causes an HTTP connector to run that "resolves" the source data for those Attributes by POSTing the SAML Response from the IdP to the AssumeRoleWithSAML API and processing the results. Templates and scripting are used to construct the input data and process the output data. There are some details elided, such as wiring up an HTTP client bean to use, but that's covered in other documentation and examples.

The first file contains beans needed by the connector, and has to be loaded as an additional resource into the resolver.

Supporting beans needed by resolver
AttributeDefinitions and DataConnector

The response processing scriptlet is a Rhino Javascript file, and is NOT compatible with Nashorn, the more recent Java default engine. I don't have a Nashorn equivalent.

amazonAssumeRole.js (Rhino syntax)

Response View

Finally, the custom Event produced by the outbound flow is mapped to a custom "error" view template in conf/errors.xml, though of course it's not really an error, just a non-standard final outcome.

views/aws-credentials.vm