Experimenting with HashiCorp Vault plugins: Ephemeral access to AWS Console UI made easy

Moayad Ismail
HashiCorp Solutions Engineering Blog
10 min readFeb 26, 2024

--

Background

In the dynamic landscape of AWS cloud, managing IAM and user access effectively remains a critical yet often underestimated challenge. As someone who has worked with many clients over the years to bolster their AWS security, I’ve noticed a common trend: the risky reliance on over-privileged IAM users and long-lived API keys, significantly increasing the risk of unauthorized access.

Fortunately, solutions like AWS Identity Center and HashiCorp Vault’s AWS secrets engine offer powerful ways to automate IAM credential lifecycle management. But what about those times when temporary access into the AWS Console UI is required? It’s a necessity in scenarios like giving your support team brief access to Cloudwatch logs and metrics, or providing secured, short-term access to external consultants without the hassle of managing IAM users.

In this tutorial, you’ll learn how to grant just-in-time access to the AWS Console UI using any identity provider (IdP) brokered with Vault.

Solution overview

The Vault AWS secrets engine generates and manages temporary AWS credentials for secure service access. While it integrates seamlessly with AWS IAM to create short-lived, precise access credentials for AWS API operations, it originally didn’t support ephemeral AWS Console UI access. This functionality, however, can be extended within the Vault AWS secret engine plugin.

HashiCorp Vault plugins

Vault plugins are categorized as built-in or external. Built-in plugins, which are integral to Vault, are maintained by HashiCorp and included in the Vault installation. External plugins, crafted by the community, enhance Vault’s functionality without modifying its core, offering custom features and added flexibility. Our focus will be on adapting the built-in Vault AWS secrets engine plugin to develop an external plugin.

Assumptions:

  1. Go environment: You’re all set with a Go development setup, including the Go language and tools like go mod. You’re comfortable with Go basics and the command line.
  2. Familiar with Vault: HashiCorp Vault is up and running on your machine, version 1.15 or newer. You’ve played around with Vault, starting servers, and chatting with the API via CLI.
  3. AWS secrets engine: You are familiar with Vault’s AWS secrets engine, knowing how it dishes out dynamic AWS creds. You’re also familiar with setting up roles and configuring the engine.
  4. AWS access: You have an AWS account with the keys to create roles and set up policies. You’re comfortable setting those trust relationships and permissions for Vault to integrate with AWS IAM.

Architecture

To enable your users to access the AWS Management Console, Vault as the identity broker will perform the following steps:

  1. Authenticate the user to Vault using any of the supported authentication methods.
  2. Call the AWS Security Token Service (AWS STS) AssumeRole to obtain temporary security credentials for the user.
  3. A DurationSeconds parameter can also be added to the API call. This parameter specifies the duration of your role session, from 900 seconds (15 minutes) up to the maximum session duration setting for the role.
  4. Call the AWS federation endpoint and supply the temporary security credentials to request a sign-in token.
  5. Construct a URL for the console that includes the token which can include the SessionDuration HTTP parameter. This parameter specifies the duration of the console session, from 900 seconds (15 minutes) to 43200 seconds (12 hours).
  6. Return temporary security credentials and the sign-in URL.

User Authentication

HashiCorp Vault is an identity broker that allows integration against multiple different Identity Providers that support OIDC, SAML or Active Directory/LDAP and so many others, here is an example to setting up OIDC with Azure AD. This gives a lot of flexibility in integrations to granting access to the AWS Management Console.

AWS Secret Engine — sts:AssumeRole

In the diagram below it shows how the Vault AWS secrets engine works with the assumeRole method.

Current State: Vault utilizes sts:AssumeRole to return temporary IAM credentials to user

AWS Secret Engine — sts:AssumeRole + Sign-in URL

Additional steps will be added to the workflow to enable Vault to return the Sign-in URL.

Target State: in addition to temporary IAM credentials, return a Sign-in URL to the user

Since we are modifying an existing secret engine, much of the code is already available. I will provide a brief on where the changes are made to enable the capability of retrieving short lived AWS Console UI URLs.

Set up your development environment

You can follow along by cloning the code sample in this github repo. I’ve replicated the secrets engine from the HashiCorp Vault AWS secrets engine package, which can be found in the Vault github repository builtin/logical/aws path.

The file structure of the plugin should look like this. I’ve also highlighted which files will be modified:

Construct the Sign-in token URL from the temporary security credentials

There is example code demonstrating how to call the AWS sign-in endpoint. The secret_access_keys.go contains all functions that call the AWS APIs. Lets add a function that will perform this step.

import (
"encoding/json"
"io"
"log"
"net/http"
"net/url"
// ... existing imports
)


// .... Existing code


// genConsoleUrl generates the AWS console URL for the given credentials and session duration.
// It assumes that the credentials are obtained by assuming a role using the AWS Security Token Service (STS).
// The function first creates a federation struct containing the session ID, session key, and session token.
// It then marshals the struct into JSON and URL encodes it.
// Next, it calls the federation endpoint to retrieve a signin token by providing the necessary request parameters.
// The signin token is unmarshaled from the response body.
// Finally, the function builds the AWS console URL by appending the signin token to the federation endpoint.
// The generated URL can be used to directly access the AWS console with the assumed role.
func genConsoleUrl(creds *sts.AssumeRoleOutput, sessionDuration string) (string, error) {

federationEndpoint := "https://signin.aws.amazon.com/federation"

fed := federation{
SessionId: *creds.Credentials.AccessKeyId,
SessionKey: *creds.Credentials.SecretAccessKey,
SessionToken: *creds.Credentials.SessionToken,
}

jsonData, err := json.Marshal(fed)

encoded := url.QueryEscape(string(jsonData))

// call federation endpoint to retreive SigninToken
var requestParameters = "?Action=getSigninToken"
requestParameters += "&SessionDuration=" + sessionDuration
requestParameters += "&Session=" + encoded

getTokenLink := federationEndpoint + requestParameters

resp, err := http.Get(getTokenLink)

if err != nil {
return "error", err
}
defer resp.Body.Close()

body, err := io.ReadAll(resp.Body)
if err != nil {
return "error", err
}

var token signinToken

err = json.Unmarshal([]byte(body), &token)

if err != nil {
return "error", err
}

// build out the aws console url
requestParameters = "?Action=login"
requestParameters += "&Issuer=" + url.QueryEscape("Vault")
requestParameters += "&Destination=" + url.QueryEscape("https://console.aws.amazon.com/")
requestParameters += "&SigninToken=" + token.SigninToken

return federationEndpoint + requestParameters, nil

}

type federation struct {
SessionId string `json:"sessionId"`
SessionKey string `json:"sessionKey"`
SessionToken string `json:"sessionToken"`
}

type signinToken struct {
SigninToken string
}

Since we are using the AssumeRole authentication method in Vault, we need to update the assumeRole() function to accept additional parameters, these are consoleLogin (bool) and consoleDuration (string) to return the URL in its response when called:

func (b *backend) assumeRole(ctx context.Context, s logical.Storage,
displayName, roleName, roleArn, policy string, policyARNs []string,
iamGroups []string, lifeTimeInSeconds int64, roleSessionName string,
consoleLogin bool, consoleDuration string) (*logical.Response, error,)

We will use these parameters to check if the Vault role configuration has the consoleLogin option set to true. If it does, then we will call the genConsoleUrl() function we discussed earlier:

var console_url string

if consoleLogin {

console_url, err = genConsoleUrl(tokenResp, consoleDuration)
log.Println("console_url", console_url)
if err != nil {
return logical.ErrorResponse("Error assuming role: %s", err), err
}
}

The assumeRole() function returns a Response object which needs to be updated with the console login url details.

resp := b.Secret(secretAccessKeyType).Response(map[string]interface{}{
"access_key": *tokenResp.Credentials.AccessKeyId,
// .... Existing code
"console_login": console_url, //return the console_login url in the response
})

Update Vault Role fields to accept new configurations

A configuration field in the Vault role is required to specify if a URL should be returned or not in the response. These can be found in path_roles.go, which contains all the configuration paths for Vault roles in the AWS secrets engine.

Lets update the awsRoleEntry struct with additional configurations in console_login. If set to true it will return a URL and the console_duration for the configured session time.

type awsRoleEntry struct {
// .... Existing code
ConsoleLogin bool `json:"console_login"` // Option to generate AWS Console URL
ConsoleDuration string `json:"console_duration"` // TTL on console URL before expiring
}

func (r *awsRoleEntry) toResponseData() map[string]interface{} {
respData := map[string]interface{}{
// .... Existing code
"console_login": r.ConsoleLogin,
"console_duration": r.ConsoleDuration,
}

To update the roles/ path with the new fields, we can add the console_login and console_duration fields as follows:

func pathRoles(b *backend) *framework.Path {
return &framework.Path{
Pattern: "roles/" + framework.GenericNameWithAtRegex("name"),
DisplayAttrs: &framework.DisplayAttributes{
OperationPrefix: operationPrefixAWS,
OperationSuffix: "role",
},
Fields: map[string]*framework.FieldSchema{
"name": {
Type: framework.TypeString,
Description: "Name of the role",
DisplayAttrs: &framework.DisplayAttributes{
Name: "Role Name",
},
// .... Existing code
"console_login": {
Type: framework.TypeBool,
Description: "Generate URL for AWS Console access, can only be used when the credential_type is " + assumedRoleCred,
},
"console_duration": {
Type: framework.TypeString,
Description: "Sets TTL for AWS Console access, can only be used when the credential_type is " + assumedRoleCred,
},
},
Callbacks: map[logical.Operation]framework.OperationFunc{
logical.DeleteOperation: b.pathRolesDelete,
logical.ReadOperation: b.pathRolesRead,
logical.UpdateOperation: b.pathRolesWrite,
},
HelpSynopsis: pathRolesHelpSyn,
HelpDescription: pathRolesHelpDesc,
}
}

Update pathRolesWrite() function as follows, this will add the console_login and console_duration field values to the roleEntry object before being written to Vault storage.

 if consoleLogin, ok := d.GetOk("console_login"); ok {
roleEntry.ConsoleLogin = consoleLogin.(bool)
}

if consoleDuration, ok := d.GetOk("console_duration"); ok {
roleEntry.ConsoleDuration = consoleDuration.(string)
}

Update how credentials are generated

So far we have created the functions that will construct the URL and updated the fields in the Vault role to accept configuration options to generate the URL. Finally we need to update the path in the Vault AWS secrets engine that calls the AssumeRole() function we updated earlier.

The Vault path to generate the sts credentials for the role is:

<secret mount>/sts/<role name>

All paths for generating credentials in AWS secrets engine are found in path_users.go. The pathCredsRead() function calls the assumeRole() function we updated earlier. This function retrieves the stored configurations of the role and calls the assumeRole() function, passing required parameters. The function call should be updated as below (note two new parameters added — role.ConsoleLogin and role.ConsoleDuration):

return b.assumeRole(ctx, req.Storage, req.DisplayName, roleName, 
roleArn, role.PolicyDocument, role.PolicyArns, role.IAMGroups,
ttl, roleSessionName, role.ConsoleLogin, role.ConsoleDuration)

Build and test the Plugin

This section only shows how to test the plugin in dev mode. For instructions on how to build and register external plugins in production settings, you can follow guidance in the HashiCorp documentation.

I’ve added a Makefile in the repo that covers the steps to setup the environment and should be done in the specified order. You should have perquisite configurations on AWS IAM to make this work (AWS IAM user programmatic credentials for HashiCorp Vault root config, IAM policies and the IAM role you wish to test generating temporary credentials for).

For more details on the Vault AWS secrets engine and how to configure it, please see HashiCorp documentation.

Run the following commands:

build:
# build the plugin and store it in the build directory (build)
go build -o build/awsconsole cmd/aws/main.go

start:
# start the vault server in dev mode with the plugin directory set to the build directory
vault server -dev -dev-root-token-id=root -dev-listen-address=:8201 -dev-plugin-dir=./build/ &

check:
# check the plugin is installed
vault plugin list secret

enable:
# enable the external plugin
vault secrets enable -path=awsconsole awsconsole

config:
# configure the plugin with the root credentials
vault write awsconsole/config/root \
access_key=AKIAI44QH8DHBEXAMPLE \
secret_key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY \
region=us-west-1

role:
# create a vault role for the network admin,
# please have your AWS IAM role arn details to add here
vault write awsconsole/roles/network \
role_arns=arn:aws:iam::1111111111:role/network-admin-role \
credential_type=assumed_role console_login=true \
console_duration=1800

sts:
# create a short lived token for the network admin
vault write awsconsole/sts/network ttl=1h

Here are the outputs you should see to verify the plugin is registered and working:

If all is configured correctly, you should now be able to specify if a sign-in URL is returned when generating temporary credentials for the role, in this example network-admin-role AWS IAM role:

With console_login set to false

No sign-in URL is returned as expected.

With console_login set to true

Notice the returned sign-in URL, this can be used to access the AWS Management Console with permissions granted via the IAM policy attached to the role.

The URL should allow you access to the AWS Management Console. The network-admin-role has a networkadministrator policy attached to it, so a user should only see VPC related administrative tasks. If we visit the IAM service page, there are no permissions.

tip: notice the Maximum session duration configuration? The default is set to 1 hour, if the console_duration parameter in the vault role is set to above 3600 seconds, the operation will fail. The Maximum session duration configuration can be changed, for more information check here.

Summary

With simple code changes, we were able to expand the functionality of the Vault AWS secrets engine plugin to return a short lived URL, enabling users across many different types of identity providers to gain secured access to the AWS Management Console. This can support many scenarios where users find it easier and more productive to just access the AWS Console for information and other operational needs.

Resources

  1. GitHub repo with all the code updates covered in this blog.
  2. AWS AssumeRole API details
  3. Vault GitHub repository
  4. HashiCorp documentation for managing Vault plugins in production.
  5. Enabling custom identity broker access to the AWS console

--

--