├── .codeclimate.yml ├── .travis.yml ├── LICENSE ├── README.md ├── aws-session ├── fixtures ├── 3x3-24-bup.bmp ├── 3x3-24-tdn.bmp ├── 3x3-32-bup.bmp ├── 3x3.png ├── qr-trimmed.txt ├── qr.png └── qr.txt └── tests /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | plugins: 3 | fixme: 4 | enabled: true 5 | shellcheck: 6 | enabled: true 7 | exclude_patterns: 8 | - "tests/" 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: bash 2 | os: 3 | - linux 4 | - osx 5 | script: 6 | - ./tests 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2017 Karsten Sperling 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # aws-session 2 | 3 | Manage shell sessions with temporary AWS credentials 4 | 5 | [![Build Status](https://travis-ci.org/ksperling/aws-session.svg?branch=master)](https://travis-ci.org/ksperling/aws-session) 6 | [![Code Climate](https://codeclimate.com/github/ksperling/aws-session/badges/gpa.svg)](https://codeclimate.com/github/ksperling/aws-session) 7 | 8 | ## Overview 9 | 10 | `aws-session` works in concert with the [Amazon AWS CLI](https://aws.amazon.com/cli/) to provide an interactive shell session in which temporary AWS credentials are available to other tools (including the AWS CLI itself). The primary use case for this is accessing AWS services through an IAM account that has [Multi-Factor Authentication](https://aws.amazon.com/iam/details/mfa/) enabled. 11 | 12 | In supported shells (`bash`, `zsh`) it also enhances the shell prompt to display the remaining life time of the temporary credentials, and overloads the `aws` command with a shell function that will refresh the credentials first if necessary before delegating to the actual AWS CLI. A refresh can also be triggered manually via the shell function `aws-session-refresh`. 13 | 14 | ## Installation 15 | 16 | On OS X, the easiest way to install `aws-session` is via [Homebrew](https://brew.sh/): `brew install ksperling/tap/aws-session`. 17 | 18 | Otherwise a manual install is straightforward: just [download the script](https://raw.githubusercontent.com/ksperling/aws-session/master/aws-session) and place it somewhere on your PATH, e.g. in `/usr/local/bin`. 19 | 20 | Before using `aws-session`, you must have the [AWS CLI](https://aws.amazon.com/cli/) installed (through whatever [method](http://docs.aws.amazon.com/cli/latest/userguide/installing.html) you choose) on your PATH and configured (through `aws configure` or manually). 21 | 22 | ## Usage 23 | 24 | Simply invoking `aws-session` will run an interactive shell, as will `aws-session shell`. A single command can be executed non-interactively via `aws-session exec COMMAND ...`, e.g. `aws-session exec aws s3 ls`. In either case `aws-session` makes the temporary credentials available in the environment variables 25 | 26 | * `AWS_ACCESS_KEY_ID` 27 | * `AWS_SECRET_ACCESS_KEY` 28 | * `AWS_SESSION_TOKEN` (also copied into `AWS_SECURITY_TOKEN`) 29 | 30 | The behavior of `aws-session` can be controlled by a number of command line options or environment variables; generally there is a direct correspondence between the two, and passing a command line option will in fact set the environment variable internally, so that it becomes the default in the `aws-session` sub-shell. Where it makes sense these will be the same environment variables that the AWS CLI and other AWS tools use. 31 | 32 | ### Credentials and profile 33 | 34 | To use a different profile from `~/.aws/credentials`, pass `--profile PROFILE` or set `AWS_DEFAULT_PROFILE`. In either case the profile will be passed into the sub-shell environment. Within the sub-shell the profile will generally only affect settings like the region and output format, as the temporary credentials themselves are provided directly via the environment variables listed above. 35 | 36 | Note that it is possible to supply the (non-MFA) access key and secret to `aws-session` itself in the `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables, though this should rarely be needed. In the sub-shell these will be replaced with the temporary credentials obtained by `aws-session`, but a copy of the "parent" credentials will be stashed in the environment under a different name to enable `aws-session` to obtain new temporary credentials on demand. 37 | 38 | ### MFA Activation 39 | 40 | Using the command `aws-session provision-mfa` a virtual MFA device can be created and activated from the command line. The QR Code used to seed the MFA device is rendered in the console itself. This is supported "out of the box" on OS X; on other platforms `convert` / ImageMagick must be installed. A [sample IAM Policy](#IAM-Policies) to allow users to provision their own MFA device via this command is provided below. 41 | 42 | ### Other options 43 | 44 | The MFA token code for shell, exec, and refresh action can be set directly via the `--code CODE` option or the `AWS_SESSION_MFA_CODE` environment variable. 45 | 46 | The desired validity period of the temporary credentials can be specified via the `--session-duration SECONDS` option or the `AWS_SESSION_DURATION` environment variable. The default is to lets AWS itself decide, and depends on the type of credentials being used. For an IAM user, the AWS default is 12 hours. For security reasons, a shorter duration (e.g. 1 or 2 hours) is recommended. 47 | 48 | Debug output can be enabled via `--debug` or `AWS_SESSION_DEBUG=1`. 49 | 50 | Use of ANSI color codes in the dynamic shell prompt can be enabled or disabled via `AWS_SESSION_COLORS=0/1`, the default is to auto-detect color support via `tput colors`. 51 | 52 | ## IAM Policies 53 | 54 | The following IAM policy represents the minimal permissions required for a user to use temporary STS credentials via `aws-session`: 55 | 56 | ``` 57 | { "Version": "2012-10-17", 58 | "Statement": [ { 59 | "Action": "sts:GetCallerIdentity", 60 | "Resource": "*", 61 | "Effect": "Allow" 62 | }, { 63 | "Action": "iam:ListMfaDevices", 64 | "Resource": "arn:aws:iam::*:user/${aws:username}", 65 | "Effect": "Allow" 66 | }, { 67 | "Action": "sts:GetSessionToken", 68 | "Resource": "*", 69 | "Condition": { "Bool": { "aws:SecureTransport": "true" } }, 70 | "Effect": "Allow" 71 | } ] 72 | } 73 | ``` 74 | 75 | The following policy can be used to allow users to create a virtual MFA device for themselves using `aws-session provision-mfa`. Note that AWS itself prevents an MFA device from being deleted without deactivating it first. This policy intentionally only allows deletion but not deactivation. 76 | 77 | ``` 78 | { "Version": "2012-10-17", 79 | "Statement": [ { 80 | "Action": "sts:GetCallerIdentity", 81 | "Resource": "*", 82 | "Effect": "Allow" 83 | }, { 84 | "Action": [ "iam:ListMfaDevices", "iam:CreateVirtualMfaDevice", "iam:DeleteVirtualMfaDevice", "iam:EnableMfaDevice" ], 85 | "Resource": [ "arn:aws:iam::*:user/${aws:username}", "arn:aws:iam::*:mfa/${aws:username}" ], 86 | "Condition": { "Bool": { "aws:SecureTransport": "true" } }, 87 | "Effect": "Allow" 88 | } ] 89 | } 90 | ``` 91 | -------------------------------------------------------------------------------- /aws-session: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # aws-session: Run a shell environment with AWS MFA credentials 3 | version=HEAD 4 | set -o pipefail 5 | 6 | function main() { 7 | have_cmd aws || fail 2 "Command 'aws' unavailable, ensure aws-cli is installed" 8 | local usage="Usage: ${0##*/} [--profile PROFILE] [--code CODE] [--session-duration SECONDS] [shell|exec|provision-mfa]" 9 | 10 | while [[ $# -gt 0 && "$1" =~ ^- ]]; do 11 | case "$1" in 12 | --profile) 13 | optarg_required "$@" 14 | export AWS_DEFAULT_PROFILE="$2" 15 | shift 2 16 | ;; 17 | --session-duration) 18 | optarg_required "$@" 19 | [[ "$2" =~ ^[0-9]+$ ]] || fail 1 "Option $1 argument must be a number in seconds" 20 | export AWS_SESSION_DURATION="$2" 21 | shift 2 22 | ;; 23 | --code) 24 | optarg_required "$@" 25 | export AWS_SESSION_MFA_CODE="$2" 26 | shift 2 27 | ;; 28 | --debug) export AWS_SESSION_DEBUG=1; shift;; 29 | --version) version; exit 0;; 30 | --help) echo "$usage 31 | Options: 32 | --profile PROFILE set profile from ~/.aws, see AWS_DEFAULT_PROFILE 33 | --code CODE set the MFA code directly (non-interactive) 34 | --session-duration SECONDS requested session validity (default 12 hours) 35 | --debug enable debug output 36 | --version display version information 37 | --help display this help text 38 | 39 | Commands: 40 | shell run an interactive shell (default) 41 | exec COMMAND [ARGS ...] run the specified command 42 | provision-mfa configure a new virtual MFA device 43 | 44 | Environment variables: 45 | AWS_DEFAULT_PROFILE default value for --profile 46 | AWS_SESSION_MFA_CODE default value for --code 47 | AWS_SESSION_DURATION default value for --session-duration 48 | AWS_SESSION_DEBUG 1/0 default value for --debug 49 | AWS_SESSION_COLOR 1/0 to enable/disable colored prompt"; exit 0;; 50 | --) shift; break;; 51 | *) fail 1 "$usage";; 52 | esac 53 | done 54 | 55 | local cmd=shell; if [[ $# -gt 0 ]]; then cmd="$1"; shift; fi 56 | case "$cmd" in 57 | exec|shell|refresh) "session_${cmd}" "$@";; 58 | provision-mfa) provision_mfa "$@";; 59 | *) fail 1 "$usage";; 60 | esac 61 | } 62 | 63 | function version() { 64 | # shellcheck disable=SC2155 65 | local cli="$(command aws --version 2>&1)" # ignore exit code 66 | if [[ "$cli" =~ ^aws-cli/([0-9]+)\.([0-9]+)\. ]]; then 67 | echo "aws-session/${version} ${cli}" 68 | local maj="${BASH_REMATCH[1]}" min="${BASH_REMATCH[2]}" 69 | if [[ "$maj" -lt 1 || "$maj" -eq 1 && "$min" -lt 11 ]]; then 70 | info "Warning: aws-session may not work correctly with aws-cli < 1.11" 71 | fi 72 | else 73 | echo "aws-session/${version} aws-cli/unknown" 74 | fi 75 | } 76 | 77 | function session_exec() { # cmd args ... 78 | [[ $# -ge 1 ]] || fail 1 "Usage: ${0##*/} exec COMMAND [ARGS...]" 79 | establish_mfa_session || return $? 80 | exec "$@" 81 | } 82 | 83 | function session_shell() { 84 | [[ $# -eq 0 ]] || fail 1 "Usage: ${0##*/} shell" 85 | 86 | # Export some information that our shell functions use 87 | export AWS_SESSION; call AWS_SESSION=normalize "$0" 88 | if [[ -z "$AWS_SESSION_COLOR" ]]; then 89 | export AWS_SESSION_COLOR=0; local colors 90 | colors="$(tput colors 2>/dev/null)" && [[ "$colors" -ge 8 ]] && AWS_SESSION_COLOR=1 91 | fi 92 | 93 | # If AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY already come from the environment 94 | # we need to preserve their values, as we will need them when we want to do a refresh. 95 | if [[ -n "$AWS_ACCESS_KEY_ID" && -n "$AWS_SECRET_ACCESS_KEY" ]]; then 96 | debug "Preserving AWS credentials from environment ($AWS_ACCESS_KEY_ID)" 97 | export AWS_SESSION_PARENT_KEY="$AWS_ACCESS_KEY_ID" 98 | export AWS_SESSION_PARENT_SECRET="$AWS_SECRET_ACCESS_KEY" 99 | export AWS_SESSION_PARENT_TOKEN="${AWS_SESSION_TOKEN:-$AWS_SECURITY_TOKEN}" 100 | else 101 | unset AWS_SESSION_PARENT_{KEY,SECRET,TOKEN} 102 | fi 103 | 104 | establish_mfa_session || return $? 105 | 106 | # Exec an interactive shell, with advanced support for bash and zsh 107 | local shell="${SHELL##*/}"; [[ "$shell" != sh && "$SHELL" = "$BASH" ]] && shell=bash 108 | case "$shell" in 109 | bash) 110 | # bash --rcfile lets us load custom bashrc. Note this doesn't work when running bash as 'sh' 111 | exec "$SHELL" --rcfile <(generate_bashrc) -i 112 | ;; 113 | zsh) 114 | # zsh doesn't have anything like '--rcfile', so we have to hook the entire ZDOTDIR 115 | local dotdir="$HOME/.aws-session" 116 | if ( umask 077 && mkdir -p "$dotdir" 2>/dev/null && generate_zdotdir "$dotdir" ); then 117 | export AWS_SESSION_ZDOTREAL="${ZDOTDIR:-$HOME}" 118 | export AWS_SESSION_ZDOTDIR="$dotdir" ZDOTDIR="$dotdir" 119 | else 120 | info "Warning: Unable to create ~/.aws-session, advanced zsh features will be unavailable" 121 | fi 122 | exec "$SHELL" -i 123 | ;; 124 | *) 125 | info "Note: $SHELL does not appear to be a supported shell (bash, zsh), advanced features will be unavailable" 126 | exec "$SHELL" -i 127 | ;; 128 | esac 129 | } 130 | 131 | function session_refresh() { 132 | # Clear out the session credentials first, we need to use the original credentials to get 133 | # the new token. Note that the AWS CLI doesn't like empty values in these variables; if a 134 | # value doesn't apply it needs to be unset rather than just set to an empty string. 135 | if [[ -n "$AWS_SESSION_PARENT_KEY" && -n "$AWS_SESSION_PARENT_SECRET" ]]; then 136 | export AWS_ACCESS_KEY_ID="$AWS_SESSION_PARENT_KEY" 137 | export AWS_SECRET_ACCESS_KEY="$AWS_SESSION_PARENT_SECRET" 138 | if [[ -n "$AWS_SESSION_PARENT_TOKEN" ]]; then 139 | export AWS_SESSION_TOKEN="$AWS_SESSION_PARENT_TOKEN" 140 | else 141 | unset AWS_SESSION_TOKEN 142 | fi 143 | debug "Restored AWS credentials in environment ($AWS_ACCESS_KEY_ID)" 144 | else 145 | unset AWS_{ACCESS_KEY_ID,SECRET_ACCESS_KEY,SESSION_TOKEN} 146 | fi 147 | unset AWS_SECURITY_TOKEN 148 | establish_mfa_session || return $? 149 | 150 | echo "export AWS_ACCESS_KEY_ID='$AWS_ACCESS_KEY_ID' 151 | export AWS_SECRET_ACCESS_KEY='$AWS_SECRET_ACCESS_KEY' 152 | export AWS_SESSION_TOKEN='$AWS_SESSION_TOKEN' 153 | export AWS_SESSION_EXPIRATION='$AWS_SESSION_EXPIRATION' 154 | export AWS_SECURITY_TOKEN=\"\$AWS_SESSION_TOKEN\" # legacy name" 155 | } 156 | 157 | function establish_mfa_session() { 158 | local user mfa code opts=() expires 159 | 160 | if [[ -z "$AWS_SESSION_MFADEVICE" ]]; then 161 | call user=aws_iam_username || return $? 162 | debug "IAM username: $user" 163 | call mfa=aws_mfa_lookup "$user" || return $? 164 | [[ -n "$mfa" ]] || fail 10 "IAM user '$user' has no MFA device configured" 165 | export AWS_SESSION_MFADEVICE="$mfa" # retain for sub-shell refreshes 166 | debug "MFA device: $mfa" 167 | else 168 | mfa="$AWS_SESSION_MFADEVICE" 169 | debug "MFA device: $mfa (cached)" 170 | fi 171 | 172 | if [ -z "$AWS_SESSION_MFA_CODE" ]; then 173 | call code=prompt_mfa_code || return $? 174 | else 175 | code="$AWS_SESSION_MFA_CODE" 176 | unset AWS_SESSION_MFA_CODE 177 | fi 178 | 179 | [[ "${AWS_SESSION_DURATION:-0}" -gt 0 ]] && opts+=(--duration-seconds "$AWS_SESSION_DURATION") 180 | aws_query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken,Expiration]' \ 181 | sts get-session-token --serial-number "$mfa" --token-code "$code" "${opts[@]}" || return $? 182 | 183 | IFS=$'\t' read -r AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN expires <<<"$REPLY" 184 | call AWS_SESSION_EXPIRATION=parse_utciso "$expires" || oops "unable to parse iso datetime '$expires'" 185 | debug "Session token expiration: $expires" 186 | 187 | export AWS_{ACCESS_KEY_ID,SECRET_ACCESS_KEY,SESSION_TOKEN,SESSION_EXPIRATION} 188 | export AWS_SECURITY_TOKEN="$AWS_SESSION_TOKEN" # legacy name 189 | } 190 | 191 | function provision_mfa() { 192 | ( local user mfa code1 code2 193 | call user=aws_iam_username || return $? 194 | debug "IAM username: $user" 195 | call mfa=aws_mfa_lookup "$user" || return $? 196 | if [[ -n "$mfa" ]]; then 197 | debug "MFA device: $mfa" 198 | fail 10 "IAM user '$user' already has an MFA device configured" 199 | fi 200 | 201 | trap 'test -n "$mfa" && aws_mfa_delete "$mfa"; unset mfa' exit 202 | call mfa=aws_mfa_create "$user" || return $? 203 | 204 | info "Import the QR code into your authenticator app, then enter two consecutive codes" 205 | call code1=prompt_mfa_code "the 1st" 206 | call code2=prompt_mfa_code "the 2nd" 207 | 208 | debug "Activating MFA device $mfa" 209 | aws iam enable-mfa-device --user-name "$user" --serial-number "$mfa" \ 210 | --authentication-code-1 "$code1" --authentication-code-2 "$code2" || return $? 211 | 212 | info "Virtual MFA device $mfa activated successfully for user '$user'" 213 | unset mfa # activation successful, don't delete 214 | ) 215 | } 216 | 217 | function prompt_mfa_code() { # [description] ->REPLY 218 | unset REPLY 219 | until [[ "$REPLY" =~ ^[0-9]{6}$ ]]; do 220 | [[ -n "$REPLY" ]] && info "Not a valid code: $REPLY" 221 | read -re -p "Please enter ${1:-your} 6 digit MFA code: " || return $? 222 | done 223 | } 224 | 225 | function aws_mfa_create_internal() { # name 226 | # The AWS CLI insists on writing the QR code PNG to a file, and check that the 227 | # path refers to a writable directory. We want it to write to a pipe (fd 3) 228 | # instead to avoid writing the MFA parameters to disk. We then swap FD 3<->1, 229 | # pipe the PNG image through the rendering pipeline, and then swap the FDs back. 230 | { ( local td; call td=mktmpdir mfa && \ 231 | trap 'rm -rf -- "$td"' exit && \ 232 | ln -sf /dev/fd/3 "${td}/png" && \ 233 | aws iam create-virtual-mfa-device \ 234 | --virtual-mfa-device-name "$1" --bootstrap-method QRCodePNG --outfile "${td}/png" \ 235 | --output text --query VirtualMFADevice.SerialNumber 236 | ) 4>&1 1>&3 3>&4 4>&- | img_png_qr_ansi; } 4>&1 1>&3 3>&4 4>&- || return "${PIPESTATUS[0]}" 237 | } 238 | 239 | function aws_mfa_create() { # name ->REPLY 240 | { REPLY="$(aws_mfa_create_internal "$1")"; } 3>&1 || return $? 241 | debug "Created MFA device $REPLY" 242 | } 243 | 244 | function aws_mfa_delete() { # arn 245 | debug "Deleting MFA device $1" 246 | aws iam delete-virtual-mfa-device --serial "$1" 247 | } 248 | 249 | function aws_mfa_lookup() { # username ->REPLY 250 | aws_query 'MFADevices[*].SerialNumber' iam list-mfa-devices --user-name "$1" 251 | } 252 | 253 | function aws_iam_username() { # ->REPLY 254 | aws_query Arn sts get-caller-identity || return $? 255 | if [[ "$REPLY" =~ ^arn:aws:iam::[0-9]+:user/(.*)$ ]]; then 256 | REPLY="${BASH_REMATCH[1]}" 257 | else 258 | debug "ARN '$REPLY' is not an IAM user" 259 | return 1 260 | fi 261 | } 262 | 263 | function aws_query { # query args... ->REPLY 264 | REPLY="$(aws "${@:2}" --output text --query "$1")" 265 | } 266 | 267 | function aws() { # args ... 268 | debug "+aws $*" 269 | command aws "$@" 270 | } 271 | 272 | function debug() { # message 273 | [[ "${AWS_SESSION_DEBUG:-0}" -le 0 ]] || info "$@" 274 | } 275 | 276 | 277 | function generate_common_rc() { 278 | echo $'# aws-session / common 279 | function aws-session-valid() { 280 | [[ "$(date +%s)" -lt "${AWS_SESSION_EXPIRATION:-0}" ]] 281 | } 282 | function aws-session-refresh() { 283 | local env; env="$("${AWS_SESSION:-aws-session}" "$@" refresh)" || return $?; eval "$env" 284 | } 285 | function aws-session-prompt() { 286 | local rc="$?" # preserve exit code for other prompt hooks 287 | # For bash we could just echo our pre-prompt (with -n), but in zsh the output 288 | # would just get overwritten by the actual prompt. Modify PS1 instead. 289 | if [[ "${PS1:0:4}" = "(aws" ]]; then # tedious but portable 290 | local i=8 n="${#PS1}" 291 | while [[ i < n && "${PS1:$i:1}" != ")" ]]; do let i++; done 292 | while [[ i < n && "${PS1:$i:1}" != " " ]]; do let i++; done 293 | let i++; PS1="${PS1:$i}" 294 | fi 295 | local exp sym=+ clr= rst= e=$\'\\e\'; (( exp = AWS_SESSION_EXPIRATION - $(date +%s) )) 296 | if [[ "${AWS_SESSION_COLOR:-0}" -gt 0 && "$exp" -lt 300 ]]; then 297 | clr="${e}[$(( exp >= 60 ? 33 : 31 ))m" rst="${e}[39m" 298 | [[ -n "$BASH_VERSION" ]] && clr="\\[$clr\\]" rst="\\[$rst\\]" 299 | fi 300 | if (( exp >= 6000 )); then exp="$(( exp / 3600 ))h" 301 | elif (( exp >= 100 )); then exp="$(( exp / 60 ))m" 302 | elif (( exp > 0 )); then exp="${exp}s" 303 | else exp=exp sym=\'!\'; fi 304 | [[ "${#exp}" -ge 3 ]] || exp="0$exp" 305 | PS1="(aws${sym}${clr}${exp}${rst}) $PS1" 306 | return "$rc" 307 | } 308 | function aws() { 309 | aws-session-valid || aws-session-refresh && command aws "$@" 310 | }' 311 | } 312 | 313 | function generate_bashrc() { 314 | generate_common_rc 315 | echo $' # aws-session / bashrc 316 | [[ -f ~/.bashrc ]] && source ~/.bashrc 317 | PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND;}aws-session-prompt"' 318 | } 319 | 320 | function generate_zdotdir() { # dir 321 | debug "Generating zsh environment in $1" 322 | # See http://zsh.sourceforge.net/Doc/Release/Files.html#Startup_002fShutdown-Files 323 | { echo -n $'# aws-session / zshenv 324 | if [ -f "$AWS_SESSION_ZDOTREAL/.zshenv" ]; then 325 | ZDOTDIR="$AWS_SESSION_ZDOTREAL" 326 | source "$ZDOTDIR/.zshenv" 327 | ZDOTDIR="$AWS_SESSION_ZDOTDIR" 328 | fi 329 | '; } >"$1/.zshenv" 330 | { generate_common_rc 331 | echo -n $'# aws-session / zshrc 332 | precmd_functions+=(aws-session-prompt) 333 | 334 | if [ -f "$AWS_SESSION_ZDOTREAL/.zshrc" ]; then 335 | ZDOTDIR="$AWS_SESSION_ZDOTREAL" 336 | source "$ZDOTDIR/.zshrc" 337 | ZDOTDIR="$AWS_SESSION_ZDOTDIR" 338 | fi 339 | '; } >"$1/.zshrc" 340 | } 341 | 342 | 343 | function img_png_qr_ansi() { 344 | img_parse_png | img_trim_qr | img_ansi 345 | } 346 | 347 | function img_trim_qr() { 348 | # Skip leading blank lines and find the start of the positioning pattern. The 349 | # positioning pattern is 7 modules wide (and high), this can be used to work 350 | # out the scale of pixels per module. Finally scale down and trim to the minimum 351 | # required quiet zone (4 modules) 352 | read -r || return $? # no input 353 | until [[ "$REPLY" =~ \# ]]; do read -r || break; done 354 | if ! [[ "$REPLY" =~ ^(\ *)(\#+)(.*\#+)\ *$ ]]; then 355 | info "Invalid QR code" 356 | return 4 357 | fi 358 | local l1="${#BASH_REMATCH[1]}" l2="${#BASH_REMATCH[2]}" l3="${#BASH_REMATCH[3]}" ll="${#REPLY}" 359 | local scale trim len; (( scale = l2 / 7, trim = l1 - 4*scale, len = l2 + l3 + 8*scale )) 360 | if (( scale == 0 )) || (( trim < 0 || trim + len > ll || len % scale != 0 )); then 361 | info "Invalid QR code" 362 | return 4 363 | fi 364 | { # shellcheck disable=SC2155 365 | local quiet="$(printf "% ${len}s")" line=0 366 | echo -e "${quiet}\n${quiet}\n${quiet}\n${quiet}" 367 | while true; do 368 | [[ "$REPLY" =~ \# ]] && (( line++ % scale == 0 )) && echo "${REPLY:$trim:$len}" 369 | read -r || break 370 | done 371 | echo -e "${quiet}\n${quiet}\n${quiet}\n${quiet}" 372 | } | img_scale_x "$scale" 2 373 | } 374 | 375 | function img_parse_png() { 376 | if have_cmd convert; then 377 | img_parse_png_convert 378 | elif have_cmd sips && [[ "$(uname -s)" = Darwin ]]; then 379 | img_parse_png_sips 380 | else 381 | info "No PNG parser available, try installing ImageMagick" 382 | return 2 383 | fi 384 | } 385 | 386 | function img_parse_png_convert() { 387 | # Convert to uncompressed PBM (a text-based format) and post-process 388 | convert png:- -compress none pbm:- 2>/dev/null | img_parse_pbm 389 | } 390 | 391 | function img_parse_png_sips() { 392 | # OS X ships with an image conversion utility called 'sips'. Unfortunately 393 | # it refuses to read/write pipes, so temporary files are used. The simplest 394 | # output format supported by sips is BMP. 395 | ( local td; call td=mktmpdir sips && \ 396 | trap 'rm -rf -- "$td"' exit && \ 397 | cat >"${td}/i.png" && \ 398 | sips --setProperty format bmp "${td}/i.png" --out "${td}/o.bmp" >/dev/null && \ 399 | img_parse_bmp <"${td}/o.bmp" 400 | ) 401 | } 402 | 403 | function img_parse_pbm() { 404 | local fmt iw ih b=; read -r fmt || return 7; read -r iw ih 405 | if [[ "$fmt" = P1 && "$iw" -gt 0 && "$ih" -gt 0 ]]; then 406 | tr -cd 01 | while IFS= read -rn "$iw"; do echo "$REPLY"; done | tr 01 ' #' 407 | else 408 | info "Unsupported PBM format ($fmt)" 409 | return 3 410 | fi 411 | } 412 | 413 | function img_parse_bmp() { 414 | if [[ "$(echo -n ABCD | hexdump -e '1/4 "%x"')" != "44434241" ]]; then 415 | info "BMP decoding is only supported on little-endian systems" 416 | return 2 417 | fi 418 | local b; b=($(read_binary 0 54 '1/2 "%u" 4/4 " %u" 2/4 " %d" 2/2 " %u" 6/4 " %u"')) || return $? 419 | 420 | # This would look tidier (( magic=b[0], ofs=b[3]-54, iw=b[5], ih=b[6], bcp=b[7], bpp=b[8], cmp=b[9] )) 421 | # but triggers a segfault in bash 4.2: https://lists.gnu.org/archive/html/bug-bash/2016-02/msg00086.html 422 | local magic="${b[0]}" ofs="$((b[3]-54))" iw="${b[5]}" ih="${b[6]}" bcp="${b[7]}" bpp="${b[8]}" cmp="${b[9]}" tdn=0 423 | (( ih < 0 )) && (( tdn = 1, ih = -ih )) # negative height indicates origin in upper-left corner 424 | 425 | if (( magic == 19778 && bcp == 1 && bpp == 24 && cmp == 0 )); then # uncompressed RGB 426 | local run pad len; (( run = iw*3, pad = (iw*4 - run) % 4, len = (run + pad) * ih )) # rows are padded to 4 bytes 427 | read_binary "$ofs" "$len" "$run/1 \"%02x\" $pad/1 \"P\" \"\\n\"" \ 428 | | tr -d P | img_scale_x 6 | tr '0-9a-f' '# ' | img_reorder_topdown "$tdn" 429 | 430 | elif (( magic == 19778 && bcp == 1 && bpp == 32 && ( cmp == 0 || cmp == 3) )); then # uncompressed/bitfields RGBA 431 | # Note that this just assumes RGBA format rather than actually looking at the bit masks in the header. 432 | # Also note that we're reading each pixel as 4 bytes rather than 1 dword so that the hexdump output is in RGBA 433 | # order, so that img_scale_x will use the R channel -- reading as a dword would result in ARGB -> A being used). 434 | read_binary "$ofs" $((iw*ih*4)) "$((iw*4))/1 \"%02x\" \"\\n\"" \ 435 | | img_scale_x 8 | tr '0-9a-f' '# ' | img_reorder_topdown "$tdn" 436 | 437 | else 438 | info "Unsupported BMP format (${b[*]})" 439 | return 3 440 | fi 441 | } 442 | 443 | function img_reorder_topdown() { # topdown 444 | if [[ "$1" -gt 0 ]]; then cat; else sed '1!G;h;$!d'; fi 445 | } 446 | 447 | function img_scale_x() { # down [up] 448 | local dn="$1" up="${2:-1}" pat="\(.\)" rpl="\1" 449 | while (( dn-- > 1 )); do pat="${pat}."; done 450 | while (( up-- > 1 )); do rpl="${rpl}\1"; done 451 | sed "s/${pat}/$rpl/g" 452 | } 453 | 454 | function img_ansi() { 455 | # Render using ANSI background colors to avoid unwanted gaps between "pixels" 456 | sed $'s/^/\e[47;107m/;s/\\(##*\\)/\e[40m\\1\e[47;107m/g;s/$/\e[0m/;y/#/ /' 457 | } 458 | 459 | 460 | function read_binary() { # skip bytes format 461 | # 'hexdump' itself appears to use buffered I/O so will read more than the 462 | # required number of bytes from stdin. Piping through 'dd' fixes this. 463 | # bs=1 gets inefficient very quickly, so read as much as possible using 464 | # a large block size, and handle the remainder and byte-based skipping 465 | # separately. 466 | { local bs=512 blk rem; (( blk = $2 / bs, rem = $2 % bs )) 467 | { [[ "$1" == 0 && "$rem" == 0 ]] || dd bs=1 skip="$1" count="$rem" 2>/dev/null; } && \ 468 | { [[ "$blk" == 0 ]] || dd bs="$bs" count="$blk" 2>/dev/null; } 469 | } | hexdump -v -e "$3" 470 | } 471 | 472 | function parse_utciso() { # '%Y-%m-%dT%H:%M:%SZ' -> REPLY 473 | # Validate format first, GNU date is very slack with what it accepts 474 | REPLY=; [[ "$1" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]] || return 1 475 | REPLY="$(date -j -u -f '%Y-%m-%dT%H:%M:%SZ' "$1" '+%s' 2>/dev/null)" \ 476 | || REPLY="$(date -u --date="$1" '+%s')" 477 | } 478 | 479 | function normalize() { # path ->REPLY 480 | local dir base 481 | call dir=dirnm "$1" 482 | call base=basenm "$1" 483 | REPLY="$(cd "$dir" && echo "$PWD")/$base" 484 | } 485 | 486 | function basenm() { # path ->REPLY 487 | if [[ "$1" =~ ([^/]+)/*$ ]]; then REPLY="${BASH_REMATCH[1]}" 488 | elif [[ "$1" =~ / ]]; then REPLY=/ 489 | else REPLY=; fi 490 | } 491 | 492 | function dirnm() { # path ->REPLY 493 | if [[ "$1" =~ ^(.*[^/])/+[^/]+/*$ ]]; then REPLY="${BASH_REMATCH[1]}" 494 | elif [[ "$1" =~ ^/ ]]; then REPLY=/ 495 | else REPLY=.; fi 496 | } 497 | 498 | function mktmpdir() { # [namepart] ->REPLY 499 | # Avoid -t due to differences between Linux and OS X (http://stackoverflow.com/a/31397073/2365113) 500 | REPLY="$(mktemp -d "${TMPDIR:-/tmp}/${1:-tmp}.XXXXXXXX")" 501 | } 502 | 503 | function optarg_required() { # "$@" 504 | [[ $# -ge 2 && ! "$2" =~ ^- ]] || fail 1 "Option $1 requires an argument" 505 | } 506 | 507 | function have_cmd() { # command 508 | hash "$1" 2>/dev/null 509 | } 510 | 511 | function oops() { # message 512 | fail 99 "Internal error: $1" 513 | } 514 | 515 | function fail() { # code message 516 | echo "$2" >&2 517 | exit "$1" 518 | } 519 | 520 | function info() { # message 521 | echo "$1" >&2 || true 522 | } 523 | 524 | function call() { # var=func [args ...] 525 | unset REPLY; "${1#*=}" "${@:2}"; eval "${1%%=*}=\$REPLY; return $?" 526 | } 527 | 528 | [[ "${BASH_SOURCE[0]}" = "$0" ]] && main "$@" 529 | -------------------------------------------------------------------------------- /fixtures/3x3-24-bup.bmp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ksperling/aws-session/d26361af2fe23a75b2c34ec3350bf3f080910bf9/fixtures/3x3-24-bup.bmp -------------------------------------------------------------------------------- /fixtures/3x3-24-tdn.bmp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ksperling/aws-session/d26361af2fe23a75b2c34ec3350bf3f080910bf9/fixtures/3x3-24-tdn.bmp -------------------------------------------------------------------------------- /fixtures/3x3-32-bup.bmp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ksperling/aws-session/d26361af2fe23a75b2c34ec3350bf3f080910bf9/fixtures/3x3-32-bup.bmp -------------------------------------------------------------------------------- /fixtures/3x3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ksperling/aws-session/d26361af2fe23a75b2c34ec3350bf3f080910bf9/fixtures/3x3.png -------------------------------------------------------------------------------- /fixtures/qr-trimmed.txt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ############## ########## ############## 6 | ## ## #### #### ## ## 7 | ## ###### ## ###### ## ###### ## 8 | ## ###### ## ## #### ## ###### ## 9 | ## ###### ## ## ## ## ###### ## 10 | ## ## ## ## ## ## 11 | ############## ## ## ## ############## 12 | ######## 13 | ###### #### ################## #### 14 | #### #### ## #### 15 | ## #### ## ## ## ## ######## 16 | #### ## ## ## ## ## ## 17 | ## #### ## ## #### ## 18 | ## ## ###### ###### 19 | ############## ###### ###### ## 20 | ## ## ######## ###### #### 21 | ## ###### ## ###################### 22 | ## ###### ## ## 23 | ## ###### ## ## ## ## ###### 24 | ## ## #### ## ## ## 25 | ############## ## ## ## ###### 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /fixtures/qr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ksperling/aws-session/d26361af2fe23a75b2c34ec3350bf3f080910bf9/fixtures/qr.png -------------------------------------------------------------------------------- /fixtures/qr.txt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | ##################### ############### ##################### 14 | ##################### ############### ##################### 15 | ##################### ############### ##################### 16 | ### ### ###### ###### ### ### 17 | ### ### ###### ###### ### ### 18 | ### ### ###### ###### ### ### 19 | ### ######### ### ######### ### ######### ### 20 | ### ######### ### ######### ### ######### ### 21 | ### ######### ### ######### ### ######### ### 22 | ### ######### ### ### ###### ### ######### ### 23 | ### ######### ### ### ###### ### ######### ### 24 | ### ######### ### ### ###### ### ######### ### 25 | ### ######### ### ### ### ### ######### ### 26 | ### ######### ### ### ### ### ######### ### 27 | ### ######### ### ### ### ### ######### ### 28 | ### ### ### ### ### ### 29 | ### ### ### ### ### ### 30 | ### ### ### ### ### ### 31 | ##################### ### ### ### ##################### 32 | ##################### ### ### ### ##################### 33 | ##################### ### ### ### ##################### 34 | ############ 35 | ############ 36 | ############ 37 | ######### ###### ########################### ###### 38 | ######### ###### ########################### ###### 39 | ######### ###### ########################### ###### 40 | ###### ###### ### ###### 41 | ###### ###### ### ###### 42 | ###### ###### ### ###### 43 | ### ###### ### ### ### ### ############ 44 | ### ###### ### ### ### ### ############ 45 | ### ###### ### ### ### ### ############ 46 | ###### ### ### ### ### ### ### 47 | ###### ### ### ### ### ### ### 48 | ###### ### ### ### ### ### ### 49 | ### ###### ### ### ###### ### 50 | ### ###### ### ### ###### ### 51 | ### ###### ### ### ###### ### 52 | ### ### ######### ######### 53 | ### ### ######### ######### 54 | ### ### ######### ######### 55 | ##################### ######### ######### ### 56 | ##################### ######### ######### ### 57 | ##################### ######### ######### ### 58 | ### ### ############ ######### ###### 59 | ### ### ############ ######### ###### 60 | ### ### ############ ######### ###### 61 | ### ######### ### ################################# 62 | ### ######### ### ################################# 63 | ### ######### ### ################################# 64 | ### ######### ### ### 65 | ### ######### ### ### 66 | ### ######### ### ### 67 | ### ######### ### ### ### ### ######### 68 | ### ######### ### ### ### ### ######### 69 | ### ######### ### ### ### ### ######### 70 | ### ### ###### ### ### ### 71 | ### ### ###### ### ### ### 72 | ### ### ###### ### ### ### 73 | ##################### ### ### ### ######### 74 | ##################### ### ### ### ######### 75 | ##################### ### ### ### ######### 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /tests: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd "$(dirname "$0")" 3 | 4 | TEST_TOTAL=0 TEST_FAIL=0 TEST_SKIP=0 5 | 6 | function test_record() { # message 7 | if [[ $? = 0 ]]; then 8 | (( TEST_TOTAL++ )) 9 | echo -n . 10 | else 11 | (( TEST_TOTAL++, TEST_FAIL++ )) 12 | echo $'x\n'"$1" 13 | fi 14 | } 15 | 16 | function test_skip() { # count message 17 | (( TEST_TOTAL += "$1", TEST_SKIP += "$1" )) 18 | echo $'\n'"skipping $1 tests: $2" 19 | return 1 20 | } 21 | 22 | function expect_equal() { # message expected actual 23 | [[ "$3" = "$2" ]]; test_record "$1: expected '$2' but got '$3'" 24 | } 25 | 26 | function expect_not_equal() { # message unexpected actual 27 | [[ "$3" != "$2" ]]; test_record "$1: unexpectedly got '$3'" 28 | } 29 | 30 | ################################################################################################### 31 | 32 | source "./aws-session" 33 | 34 | echo "System information:" 35 | echo -n "- uname: " && uname -a 36 | echo "- bash: $BASH_VERSION" 37 | have_cmd convert && echo -n "- convert: " && convert --version | head -1 38 | have_cmd sips && echo -n "- sips: " && sips --help | head -1 39 | 40 | 41 | ################################################################################################### 42 | 43 | ### call 44 | 45 | function _reply_return() { REPLY="$1"; return $2; } 46 | function _reply_return_indirect { call REPLY=_reply_return "($1)" "$(( $2 + 1))"; } 47 | function test_call() { 48 | local foo=prev value=' " result * `date`' 49 | call foo=_reply_return "$value" 23 50 | expect_equal "return code" 23 $? 51 | expect_equal "return value" "$value" "$foo" 52 | 53 | call foo=_reply_return_indirect "text" 42 54 | expect_equal "return code" 43 $? 55 | expect_equal "return value" "(text)" "$foo" 56 | } 57 | test_call 58 | 59 | ### basenm / dirnm 60 | 61 | function test_basename_dirname() { 62 | local actual expected actual_rc expected_rc 63 | expected="$(command basename "$1")"; expected_rc=$? 64 | call actual=basenm "$1"; actual_rc=$? 65 | expect_equal "basename '$1' output" "$expected" "$actual" 66 | expect_equal "basename '$1' rc" "$expected_rc" "$actual_rc" 67 | 68 | expected="$(command dirname "$1")"; expected_rc=$? 69 | call actual=dirnm "$1"; actual_rc=$? 70 | expect_equal "dirname '$1' output" "$expected" "$actual" 71 | expect_equal "dirname '$1' rc" "$expected_rc" "$actual_rc" 72 | } 73 | 74 | for t in /aaa/bbb/ / // /aaa aaa aaa/ aaa/xxx aaa// a//b//c// ' a // b // c //' ''; do 75 | test_basename_dirname "$t" 76 | done 77 | 78 | ### normalize 79 | 80 | function test_normalize() { # path expected 81 | local actual rc; call actual=normalize "$1"; rc=$? 82 | expect_equal "normalize($1) rc" 0 "$rc" 83 | expect_equal "normalize($1)" "$2" "$actual" 84 | } 85 | 86 | test_normalize ./aws-session "$PWD/aws-session" 87 | test_normalize fixtures/../aws-session "$PWD/aws-session" 88 | 89 | ### mktmpdir 90 | 91 | mktmpdir test 92 | expect_equal "mktmpdir rc" 0 $? 93 | [[ -d "$REPLY" ]]; test_record "mktmpdir did not return a directory" 94 | 95 | function mktemp() { return 100; } 96 | mktmpdir test 97 | expect_equal "mktmpdir rc [mktemp failure]" 100 $? 98 | unset -f mktemp 99 | 100 | ### parse_utciso 101 | 102 | function test_parse_utciso() { 103 | parse_utciso "$1" 104 | expect_equal "parse_utciso($1) return code" "$2" $? 105 | expect_equal "parse_utciso($1) result" "$3" "$REPLY" 106 | } 107 | test_parse_utciso "1970-01-01T00:00:00Z" 0 0 108 | test_parse_utciso "2005-03-18T01:58:31Z" 0 1111111111 109 | test_parse_utciso "" 1 110 | test_parse_utciso "yesterday" 1 111 | 112 | ### read_binary 113 | 114 | expect_equal "read_binary ..." "BC-F" "$(echo ABCDEFG | { read_binary 1 2 '1/1 "%c"'; echo -n "-"; read_binary 2 1 '1/1 "%c"'; })" 115 | 116 | ### img_reorder_topdown 117 | 118 | expect_equal "img_reorder_topdown(0)" $'c\nb\na' "$(echo $'a\nb\nc' | img_reorder_topdown 0)" 119 | expect_equal "img_reorder_topdown(1)" $'a\nb\nc' "$(echo $'a\nb\nc' | img_reorder_topdown 1)" 120 | 121 | ### img_scale_x 122 | 123 | expect_equal "img_scale_x(2)" 'abc' "$(echo 'aabbcc' | img_scale_x 2)" 124 | expect_equal "img_scale_x(3)" '# #' "$(echo '### ###' | img_scale_x 3)" 125 | expect_equal "img_scale_x(3, 2)" '## ##' "$(echo '### ###' | img_scale_x 3 2)" 126 | 127 | ### img_parse_bmp 128 | 129 | expect_equal "img_parse_bmp(3x3-24-tdn.bmp)" $'# \n # \n## ' "$(img_parse_bmp /dev/null 146 | expect_equal "img_parse_png_sips rc [mktemp failure]" 100 $? 147 | unset -f mktemp 148 | fi 149 | 150 | ### img_trim_qr 151 | 152 | expect_equal "img_trim_qr(qr.txt)" "$(cat fixtures/qr-trimmed.txt)" "$(img_trim_qr "$outfile" && echo "mock-arn" 162 | } 163 | 164 | aws_mfa_create mock >/dev/null 2>/dev/null 165 | expect_equal "aws_mfa_create(mock) rc" 0 $? 166 | expect_equal "aws_mfa_create(mock) REPLY" mock-arn "$REPLY" 167 | expect_equal "aws_mfa_create(mock) output" "$(img_ansi /dev/null 2>/dev/null 171 | expect_equal "aws_mfa_create(mock) rc [mktemp failure]" 100 $? 172 | 173 | unset -f aws mktemp 174 | fi 175 | 176 | 177 | ################################################################################################### 178 | 179 | echo $'\n'"Total tests: ${TEST_TOTAL} skipped: ${TEST_SKIP} failed: ${TEST_FAIL}" 180 | [[ "$TEST_FAIL" -eq 0 ]] 181 | --------------------------------------------------------------------------------