feat(aws): add assume-role.sh with MFA support and session caching

- MFA token prompting with auto-detection
- Session caching to ~/.aws/cli/cache (5-min buffer)
- Cross-account support with external-id
- Eval-friendly output for shell sourcing
- AWS CLI profile-aware
- Configurable session duration (up to 12h)
- Comprehensive README with usage examples
This commit is contained in:
Greg Hendrickson
2026-02-03 18:01:40 +00:00
parent d0fa7d9197
commit bcb0973cdb
2 changed files with 331 additions and 0 deletions

78
aws/README.md Normal file
View File

@@ -0,0 +1,78 @@
# AWS Scripts
AWS CLI helper scripts for common operations.
## assume-role.sh
Production-ready role assumption with MFA support and session caching.
### Features
- **MFA Support**: Prompts for TOTP code, auto-detects MFA device
- **Session Caching**: Avoids re-authentication within session duration
- **Cross-Account**: Supports external-id for cross-account roles
- **Profile-Aware**: Works with AWS CLI named profiles
- **Eval-Friendly**: Output designed for `eval` or `source`
### Usage
```bash
# Basic - source to set env vars in current shell
source assume-role.sh arn:aws:iam::123456789012:role/AdminRole
# Eval alternative
eval "$(./assume-role.sh arn:aws:iam::123456789012:role/AdminRole)"
# With MFA (will prompt for code)
source assume-role.sh arn:aws:iam::123456789012:role/AdminRole \
--mfa-serial arn:aws:iam::123456789012:mfa/myuser
# Cross-account with external-id
source assume-role.sh arn:aws:iam::987654321098:role/CrossAccountRole \
--external-id MyExternalId123
# Extended session (up to 12 hours)
source assume-role.sh arn:aws:iam::123456789012:role/AdminRole \
--duration 43200
# With specific profile and region
source assume-role.sh arn:aws:iam::123456789012:role/AdminRole \
--profile production \
--region us-west-2
```
### Options
| Option | Description |
|--------|-------------|
| `-m, --mfa-serial` | MFA device ARN (auto-detected if not specified) |
| `-e, --external-id` | External ID for cross-account trust |
| `-d, --duration` | Session duration in seconds (default: 3600) |
| `-s, --session-name` | Session name identifier |
| `-p, --profile` | AWS CLI profile for source credentials |
| `-r, --region` | AWS region |
| `-c, --no-cache` | Disable session caching |
| `-v, --verbose` | Verbose output |
### Session Caching
Credentials are cached in `~/.aws/cli/cache/` and reused if more than 5 minutes remain before expiration. Use `--no-cache` to force fresh credentials.
### Requirements
- AWS CLI v2
- jq
- Valid AWS credentials (profile or environment)
### Environment Variables Set
After sourcing, these variables are exported:
```bash
AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY
AWS_SESSION_TOKEN
AWS_CREDENTIAL_EXPIRATION
```
`AWS_PROFILE` is unset to prevent conflicts with temporary credentials.

253
aws/assume-role.sh Executable file
View File

@@ -0,0 +1,253 @@
#!/usr/bin/env bash
#
# assume-role.sh - AWS role assumption with MFA support and session caching
#
# Features:
# - MFA token prompting
# - Session caching (avoids re-auth within session duration)
# - Cross-account support with external-id
# - Environment variable export or eval-friendly output
# - AWS CLI profile-aware
#
# Usage:
# source assume-role.sh <role-arn> [options]
# eval "$(assume-role.sh <role-arn> [options])"
#
# Options:
# -m, --mfa-serial <arn> MFA device ARN (auto-detected if not specified)
# -e, --external-id <id> External ID for cross-account roles
# -d, --duration <seconds> Session duration (default: 3600, max: 43200)
# -s, --session-name <name> Session name (default: assumed-role-session)
# -p, --profile <profile> AWS CLI profile for source credentials
# -r, --region <region> AWS region
# -c, --no-cache Disable session caching
# -v, --verbose Verbose output
# -h, --help Show this help
set -euo pipefail
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Defaults
DURATION=3600
SESSION_NAME="assumed-role-session-$$"
CACHE_DIR="${HOME}/.aws/cli/cache"
USE_CACHE=true
VERBOSE=false
# Log functions
log_info() { [[ "$VERBOSE" == "true" ]] && echo -e "${BLUE}[INFO]${NC} $*" >&2 || true; }
log_success() { echo -e "${GREEN}[OK]${NC} $*" >&2; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $*" >&2; }
log_error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
usage() {
grep '^#' "$0" | grep -v '#!/' | cut -c 3-
exit 0
}
# Parse arguments
parse_args() {
while [[ $# -gt 0 ]]; do
case $1 in
-m|--mfa-serial) MFA_SERIAL="$2"; shift 2 ;;
-e|--external-id) EXTERNAL_ID="$2"; shift 2 ;;
-d|--duration) DURATION="$2"; shift 2 ;;
-s|--session-name) SESSION_NAME="$2"; shift 2 ;;
-p|--profile) AWS_PROFILE="$2"; shift 2 ;;
-r|--region) AWS_REGION="$2"; shift 2 ;;
-c|--no-cache) USE_CACHE=false; shift ;;
-v|--verbose) VERBOSE=true; shift ;;
-h|--help) usage ;;
-*) log_error "Unknown option: $1"; exit 1 ;;
*)
if [[ -z "${ROLE_ARN:-}" ]]; then
ROLE_ARN="$1"
else
log_error "Unexpected argument: $1"
exit 1
fi
shift
;;
esac
done
if [[ -z "${ROLE_ARN:-}" ]]; then
log_error "Role ARN is required"
echo "Usage: assume-role.sh <role-arn> [options]" >&2
exit 1
fi
}
# Generate cache key based on role and MFA
get_cache_key() {
local key="${ROLE_ARN}:${MFA_SERIAL:-none}:${EXTERNAL_ID:-none}"
echo "$key" | sha256sum | cut -c1-16
}
# Check for valid cached credentials
get_cached_credentials() {
[[ "$USE_CACHE" != "true" ]] && return 1
local cache_key
cache_key=$(get_cache_key)
local cache_file="${CACHE_DIR}/assume-role-${cache_key}.json"
if [[ -f "$cache_file" ]]; then
local expiration
expiration=$(jq -r '.Credentials.Expiration' "$cache_file" 2>/dev/null || echo "")
if [[ -n "$expiration" ]]; then
local exp_epoch now_epoch
exp_epoch=$(date -d "$expiration" +%s 2>/dev/null || date -j -f "%Y-%m-%dT%H:%M:%S" "${expiration%+*}" +%s 2>/dev/null || echo 0)
now_epoch=$(date +%s)
# Use cached creds if more than 5 minutes remain
if (( exp_epoch - now_epoch > 300 )); then
log_info "Using cached credentials (expires: $expiration)"
cat "$cache_file"
return 0
fi
fi
fi
return 1
}
# Save credentials to cache
save_cached_credentials() {
[[ "$USE_CACHE" != "true" ]] && return 0
local credentials="$1"
local cache_key
cache_key=$(get_cache_key)
mkdir -p "$CACHE_DIR"
chmod 700 "$CACHE_DIR"
local cache_file="${CACHE_DIR}/assume-role-${cache_key}.json"
echo "$credentials" > "$cache_file"
chmod 600 "$cache_file"
log_info "Credentials cached to $cache_file"
}
# Auto-detect MFA device
detect_mfa_device() {
if [[ -n "${MFA_SERIAL:-}" ]]; then
return 0
fi
log_info "Detecting MFA device..."
local profile_args=()
[[ -n "${AWS_PROFILE:-}" ]] && profile_args+=(--profile "$AWS_PROFILE")
local mfa_devices
mfa_devices=$(aws iam list-mfa-devices "${profile_args[@]}" --query 'MFADevices[0].SerialNumber' --output text 2>/dev/null || echo "None")
if [[ "$mfa_devices" != "None" && -n "$mfa_devices" ]]; then
MFA_SERIAL="$mfa_devices"
log_info "Detected MFA device: $MFA_SERIAL"
fi
}
# Prompt for MFA token
get_mfa_token() {
local token
echo -n "Enter MFA code for ${MFA_SERIAL}: " >&2
read -r token
echo "$token"
}
# Assume the role
assume_role() {
local profile_args=()
local assume_args=()
[[ -n "${AWS_PROFILE:-}" ]] && profile_args+=(--profile "$AWS_PROFILE")
[[ -n "${AWS_REGION:-}" ]] && profile_args+=(--region "$AWS_REGION")
assume_args+=(--role-arn "$ROLE_ARN")
assume_args+=(--role-session-name "$SESSION_NAME")
assume_args+=(--duration-seconds "$DURATION")
if [[ -n "${EXTERNAL_ID:-}" ]]; then
assume_args+=(--external-id "$EXTERNAL_ID")
fi
if [[ -n "${MFA_SERIAL:-}" ]]; then
local mfa_token
mfa_token=$(get_mfa_token)
assume_args+=(--serial-number "$MFA_SERIAL")
assume_args+=(--token-code "$mfa_token")
fi
log_info "Assuming role: $ROLE_ARN"
local result
if ! result=$(aws sts assume-role "${profile_args[@]}" "${assume_args[@]}" --output json 2>&1); then
log_error "Failed to assume role: $result"
exit 1
fi
echo "$result"
}
# Export credentials as environment variables
export_credentials() {
local credentials="$1"
local access_key secret_key session_token expiration
access_key=$(echo "$credentials" | jq -r '.Credentials.AccessKeyId')
secret_key=$(echo "$credentials" | jq -r '.Credentials.SecretAccessKey')
session_token=$(echo "$credentials" | jq -r '.Credentials.SessionToken')
expiration=$(echo "$credentials" | jq -r '.Credentials.Expiration')
# Output for eval or sourcing
cat <<EOF
export AWS_ACCESS_KEY_ID='${access_key}'
export AWS_SECRET_ACCESS_KEY='${secret_key}'
export AWS_SESSION_TOKEN='${session_token}'
export AWS_CREDENTIAL_EXPIRATION='${expiration}'
unset AWS_PROFILE
EOF
log_success "Role assumed: $ROLE_ARN"
log_info "Session expires: $expiration"
}
# Main
main() {
parse_args "$@"
# Check for required tools
command -v aws >/dev/null 2>&1 || { log_error "AWS CLI not found"; exit 1; }
command -v jq >/dev/null 2>&1 || { log_error "jq not found"; exit 1; }
# Try cache first
local credentials
if credentials=$(get_cached_credentials); then
export_credentials "$credentials"
return 0
fi
# Detect MFA if needed
detect_mfa_device
# Assume role
credentials=$(assume_role)
# Cache for future use
save_cached_credentials "$credentials"
# Export
export_credentials "$credentials"
}
main "$@"