diff --git a/aws/README.md b/aws/README.md new file mode 100644 index 0000000..8bfc1ce --- /dev/null +++ b/aws/README.md @@ -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. diff --git a/aws/assume-role.sh b/aws/assume-role.sh new file mode 100755 index 0000000..a87602c --- /dev/null +++ b/aws/assume-role.sh @@ -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 [options] +# eval "$(assume-role.sh [options])" +# +# Options: +# -m, --mfa-serial MFA device ARN (auto-detected if not specified) +# -e, --external-id External ID for cross-account roles +# -d, --duration Session duration (default: 3600, max: 43200) +# -s, --session-name Session name (default: assumed-role-session) +# -p, --profile AWS CLI profile for source credentials +# -r, --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 [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 </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 "$@"