From 213a6569e0634a2dc1d70f56194e6e2fec2ea4e0 Mon Sep 17 00:00:00 2001 From: Mike Mondragon Date: Fri, 23 Sep 2022 13:52:14 -0700 Subject: [PATCH] UX clean up and QR code printing. --- .env.example | 5 + .gitignore | 2 + README.md | 23 +++-- cmd/root/root.go | 70 +++++++++++++- go.mod | 16 +++- go.sum | 48 +++++++++- pkg/ansi/ansi.go | 159 +++++++++++++++++++++++++++++++ pkg/ansi/init.go | 7 ++ pkg/ansi/init_windows.go | 21 ++++ pkg/config/config.go | 11 ++- pkg/sessiontoken/sessiontoken.go | 156 ++++++++++++++++++++---------- 11 files changed, 451 insertions(+), 67 deletions(-) create mode 100644 .env.example create mode 100644 pkg/ansi/ansi.go create mode 100644 pkg/ansi/init.go create mode 100644 pkg/ansi/init_windows.go diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9f0fd37 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +OKTA_ORG_DOMAIN= +OKTA_OIDC_CLIENT_ID= +OKTA_AWS_ACCOUNT_FEDERATION_APP_ID= +AWS_IAM_IDP= +AWS_IAM_ROLE= diff --git a/.gitignore b/.gitignore index 84cbdf7..ce47fcc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ dist/ .vscode +.env* +!.env.example diff --git a/README.md b/README.md index 521c545..95ea8b1 100644 --- a/README.md +++ b/README.md @@ -36,8 +36,11 @@ Configuration can be done with environment variables, `.env` file, or command li | Okta Org Domain | OKTA_ORG_DOMAIN | OKTA_ORG_DOMAIN | --org-domain value | Full domain hostname of the Okta org e.g. `test.okta.com` | | OIDC Client ID | OKTA_OIDC_CLIENT_ID | OKTA_OIDC_CLIENT_ID | --oidc-client-id value | See [Allowed Web SSO Client](#allowed-web-sso-client) | | Okta AWS Account Federation integration app ID | OKTA_AWS_ACCOUNT_FEDERATION_APP_ID | OKTA_AWS_ACCOUNT_FEDERATION_APP_ID | --aws-acct-fed-app-id value | See [AWS Account Federation integration app](#aws-account-federation-integration-app) | +| AWS IAM Identity Provider ARN | AWS_IAM_IDP | AWS_IAM_IDP | --aws-iam-idp | The preferred IAM Identity Provider. If there are multiple IdPs available from AWS and this value does not match then a menu of choices will be rendered. | +| AWS IAM Role ARN to assume | AWS_IAM_ROLE | AWS_IAM_ROLE | --aws-iam-role | The preferred IAM role for the given IAM Identity Provider | | Output format | FORMAT | FORMAT | --format value | Default is `env-var`. `cred-file` is also allowed | | Profile | PROFILE | PROFILE | --profile value | Default is `default` | +| Display QR Code | QR_CODE | QR_CODE | --qr-code | `yes` if flag is present | #### Allowed Web SSO Client @@ -99,21 +102,17 @@ variables or `.env` file. ```shell $ okta-aws-cli -Initiate authentication for an AWS CLI by opening the following URL. -Enter the given activation code when prompted. +Open the following URL (copy and paste or scan QR code) and enter the activation +code to begin Okta device authorization for the AWS CLI. -Activation URL: https://test.oktapreview.com/activate -Activation code: HTCQSJLW +[QR CODE] -You have 2 available AWS IAM roles: -Choice 1 - IdP “arn:aws:iam::294719231913:saml-provider/Okta_DevEx_Test” - AWS Role “arn:aws:iam::294719231913:role/Test_Role_S3_Read” -Choice 2 - IDP “arn:aws:iam::294719231913:saml-provider/Okta_DevEx_Other” - AWS Role “arn:aws:iam::294719231913:role/Test-s3-full-read-write” +https://test-org.okta.com/activate -Enter Your choice: 1 +Activation code: ZNQZQXQQ + +? Choose an IdP: arn:aws:iam::123456789012:saml-provider/My_IdP +? Choose a Role: arn:aws:iam::456789012345:role/My_Role export AWS_ACCESS_KEY_ID=ASIAUJHVCS6UQC52NOL7 export AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY diff --git a/cmd/root/root.go b/cmd/root/root.go index 0fb8bb4..d50ce3e 100644 --- a/cmd/root/root.go +++ b/cmd/root/root.go @@ -22,6 +22,7 @@ import ( "github.com/spf13/cobra" + "github.com/okta/okta-aws-cli/pkg/ansi" "github.com/okta/okta-aws-cli/pkg/config" "github.com/okta/okta-aws-cli/pkg/sessiontoken" ) @@ -29,7 +30,7 @@ import ( type flag struct { name string short string - value string + value interface{} usage string } @@ -58,6 +59,24 @@ var flags = []flag{ value: "env-var", usage: "Output format", }, + { + name: "aws-iam-idp", + short: "i", + value: "", + usage: "IAM Identity Provider ARN", + }, + { + name: "aws-iam-role", + short: "r", + value: "", + usage: "IAM Role ARN", + }, + { + name: "qr-code", + short: "q", + value: false, + usage: "Print QR Code", + }, { name: "profile", short: "p", @@ -84,9 +103,16 @@ to collect a proper IAM role for the AWS CLI operator.`, } for _, f := range flags { - cmd.Flags().StringP(f.name, f.short, f.value, f.usage) + if val, ok := f.value.(string); ok { + cmd.PersistentFlags().StringP(f.name, f.short, val, f.usage) + } + if val, ok := f.value.(bool); ok { + cmd.PersistentFlags().BoolP(f.name, f.short, val, f.usage) + } } + cmd.SetUsageTemplate(resourceUsageTemplate()) + return cmd } @@ -97,6 +123,9 @@ func Execute(c *config.Config) { c.OverrideIfSet(cmd, "oidc-client-id") c.OverrideIfSet(cmd, "aws-acct-fed-app-id") c.OverrideIfSet(cmd, "format") + c.OverrideIfSet(cmd, "aws-iam-idp") + c.OverrideIfSet(cmd, "aws-iam-role") + c.OverrideIfSet(cmd, "qr-code") c.OverrideIfSet(cmd, "profile") if err := c.CheckConfig(); err != nil { @@ -106,7 +135,42 @@ func Execute(c *config.Config) { } if err := cmd.Execute(); err != nil { - fmt.Fprintf(os.Stderr, "okta-aws-cli experienced the following error '%s'", err) + fmt.Fprintf(os.Stderr, "okta-aws-cli experienced the following error '%s'\n", err) os.Exit(1) } } + +func resourceUsageTemplate() string { + return fmt.Sprintf(`%s:{{if .Runnable}} + {{.UseLine}}{{end}}{{if .HasAvailableSubCommands}} + {{.CommandPath}} [command]{{end}}{{if gt (len .Aliases) 0}} + +%s + {{.NameAndAliases}}{{end}}{{if .HasExample}} + +%s +{{.Example}}{{end}}{{if .HasAvailableSubCommands}} + +%s{{range .Commands}}{{if (or .IsAvailableCommand (eq .Name "help"))}} + {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}} + +%s +{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}} + +%s +{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}} + +%s{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}} + {{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}} + +Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}} +`, + ansi.Faint("Usage:"), + ansi.Faint("Aliases:"), + ansi.Faint("Examples:"), + ansi.Faint("Available Commands:"), + ansi.Faint("Flags:"), + ansi.Faint("Global Flags:"), + ansi.Faint("Additional help topics:"), + ) +} diff --git a/go.mod b/go.mod index a3e7d9c..39514ea 100644 --- a/go.mod +++ b/go.mod @@ -3,20 +3,34 @@ module github.com/okta/okta-aws-cli go 1.19 require ( + github.com/AlecAivazis/survey/v2 v2.3.6 github.com/aws/aws-sdk-go v1.44.94 github.com/cenkalti/backoff/v4 v4.1.3 github.com/joeshaw/envdecode v0.0.0-20200121155833-099f1fc765bd github.com/joho/godotenv v1.4.0 + github.com/logrusorgru/aurora v2.0.3+incompatible + github.com/mattn/go-isatty v0.0.16 + github.com/mdp/qrterminal v1.0.1 github.com/spf13/cobra v1.5.0 - github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.8.0 + github.com/tidwall/pretty v1.2.0 golang.org/x/net v0.0.0-20220907135653-1e95f45603a7 + golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab ) require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/inconshreveable/mousetrap v1.0.1 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect + github.com/kr/pretty v0.1.0 // indirect + github.com/mattn/go-colorable v0.1.12 // indirect + github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect + golang.org/x/text v0.3.7 // indirect + gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + rsc.io/qr v0.2.0 // indirect ) diff --git a/go.sum b/go.sum index 58a032e..5329e42 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,19 @@ +github.com/AlecAivazis/survey/v2 v2.3.6 h1:NvTuVHISgTHEHeBFqt6BHOe4Ny/NwGZr7w+F8S9ziyw= +github.com/AlecAivazis/survey/v2 v2.3.6/go.mod h1:4AuI9b7RjAR+G7v9+C4YSlX/YL3K3cWNXgWXOhllqvI= +github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= +github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= github.com/aws/aws-sdk-go v1.44.94 h1:hDqJSv03ZVvqT448gUE63JEIHKx++vKLoDkiZxbNmIk= github.com/aws/aws-sdk-go v1.44.94/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo= github.com/cenkalti/backoff/v4 v4.1.3 h1:cFAlzYUlVYDysBEH2T5hyJZMh3+5+WCBvSnK6Q8UtC4= github.com/cenkalti/backoff/v4 v4.1.3/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= +github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= +github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc= github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= @@ -17,6 +25,27 @@ github.com/joeshaw/envdecode v0.0.0-20200121155833-099f1fc765bd h1:nIzoSW6OhhppW github.com/joeshaw/envdecode v0.0.0-20200121155833-099f1fc765bd/go.mod h1:MEQrHur0g8VplbLOv5vXmDzacSaH9Z7XhcgsSh1xciU= github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg= github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8= +github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mdp/qrterminal v1.0.1 h1:07+fzVDlPuBlXS8tB0ktTAyf+Lp1j2+2zK3fBOL5b7c= +github.com/mdp/qrterminal v1.0.1/go.mod h1:Z33WhxQe9B6CdW37HaVqcRKzP+kByF3q/qLxOGe12xQ= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= +github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -27,22 +56,39 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220907135653-1e95f45603a7 h1:1WGATo9HAhkWMbfyuVU0tEFP88OIkUvwaHFveQPvzCQ= golang.org/x/net v0.0.0-20220907135653-1e95f45603a7/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab h1:2QkjZIsXupsJbJIdSjjUOgWK3aEtzyuh2mPt3l/CkeU= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= diff --git a/pkg/ansi/ansi.go b/pkg/ansi/ansi.go new file mode 100644 index 0000000..5267dbb --- /dev/null +++ b/pkg/ansi/ansi.go @@ -0,0 +1,159 @@ +package ansi + +import ( + "fmt" + "os" + + "github.com/logrusorgru/aurora" + "github.com/mattn/go-isatty" + "github.com/tidwall/pretty" +) + +var darkTerminalStyle = &pretty.Style{ + Key: [2]string{"\x1B[34m", "\x1B[0m"}, + String: [2]string{"\x1B[30m", "\x1B[0m"}, + Number: [2]string{"\x1B[94m", "\x1B[0m"}, + True: [2]string{"\x1B[35m", "\x1B[0m"}, + False: [2]string{"\x1B[35m", "\x1B[0m"}, + Null: [2]string{"\x1B[31m", "\x1B[0m"}, +} + +// ForceColors forces the use of colors and other ANSI sequences. +var ForceColors = false + +// DisableColors disables all colors and other ANSI sequences. +var DisableColors = false + +// EnvironmentOverrideColors overs coloring based on `CLICOLOR` and +// `CLICOLOR_FORCE`. Cf. https://bixense.com/clicolors/ +var EnvironmentOverrideColors = true + +var color = Color() + +// Bold returns bolded text if the writer supports colors +func Bold(text string) string { + return color.Sprintf(color.Bold(text)) +} + +// Color returns an aurora.Aurora instance with colors enabled or disabled +// depending on whether the writer supports colors. +func Color() aurora.Aurora { + return aurora.NewAurora(shouldUseColors()) +} + +// ColorizeJSON returns a colorized version of the input JSON, if the writer +// supports colors. +func ColorizeJSON(json string, darkStyle bool) string { + if !shouldUseColors() { + return json + } + + style := (*pretty.Style)(nil) + if darkStyle { + style = darkTerminalStyle + } + + return string(pretty.Color([]byte(json), style)) +} + +// ColorizeStatus returns a colorized number for HTTP status code +func ColorizeStatus(status int) aurora.Value { + switch { + case status >= 500: + return color.Red(status).Bold() + case status >= 300: + return color.Yellow(status).Bold() + default: + return color.Green(status).Bold() + } +} + +// Faint returns slightly offset color text if the writer supports it +func Faint(text string) string { + return color.Sprintf(color.Faint(text)) +} + +// Italic returns italicized text if the writer supports it. +func Italic(text string) string { + return color.Sprintf(color.Italic(text)) +} + +// Red returns text colored red +func Red(text string) string { + return color.Sprintf(color.Red(text)) +} + +// BrightRed returns text colored bright red +func BrightRed(text string) string { + return color.Sprintf(color.BrightRed(text)) +} + +// Green returns text colored green +func Green(text string) string { + return color.Sprintf(color.Green(text)) +} + +// Yellow returns text colored yellow +func Yellow(text string) string { + return color.Sprintf(color.Yellow(text)) +} + +// BrightYellow returns text colored bright yellow +func BrightYellow(text string) string { + return color.Sprintf(color.BrightYellow(text)) +} + +// Blue returns text colored blue +func Blue(text string) string { + return color.Sprintf(color.Blue(text)) +} + +// Magenta returns text colored magenta +func Magenta(text string) string { + return color.Sprintf(color.Magenta(text)) +} + +// Cyan returns text colored cyan +func Cyan(text string) string { + return color.Sprintf(color.BrightCyan(text)) +} + +// Linkify returns an ANSI escape sequence with an hyperlink, if the writer +// supports colors. +func Linkify(text, url string) string { + if !shouldUseColors() { + return text + } + + // See https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda + // for more information about this escape sequence. + return fmt.Sprintf("\x1b]8;;%s\x1b\\%s\x1b]8;;\x1b\\", url, text) +} + +// StrikeThrough returns struck though text if the writer supports colors +func StrikeThrough(text string) string { + return color.Sprintf(color.StrikeThrough(text)) +} + +func shouldUseColors() bool { + useColors := ForceColors || isOutputTerminal() + + if EnvironmentOverrideColors { + force, ok := os.LookupEnv("CLICOLOR_FORCE") + + switch { + case ok && force != "0": + useColors = true + case ok && force == "0": + useColors = false + case os.Getenv("CLICOLOR") == "0": + useColors = false + } + } + + return useColors && !DisableColors +} + +func isOutputTerminal() bool { + return isatty.IsTerminal(os.Stdout.Fd()) +} diff --git a/pkg/ansi/init.go b/pkg/ansi/init.go new file mode 100644 index 0000000..034691c --- /dev/null +++ b/pkg/ansi/init.go @@ -0,0 +1,7 @@ +// +build !windows + +package ansi + +// InitConsole initializes any platform-specific aspect of the terminal. +// This method will run for all except Windows. +func InitConsole() {} diff --git a/pkg/ansi/init_windows.go b/pkg/ansi/init_windows.go new file mode 100644 index 0000000..bcf4f3c --- /dev/null +++ b/pkg/ansi/init_windows.go @@ -0,0 +1,21 @@ +package ansi + +import ( + "golang.org/x/sys/windows" +) + +// InitConsole configures the standard output and error streams +// on Windows systems. This is necessary to enable colored and ANSI output. +// This is the Windows implementation of ansi/init.go. +func InitConsole() { + setWindowsConsoleMode(windows.Stdout, windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING) + setWindowsConsoleMode(windows.Stderr, windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING) +} + +func setWindowsConsoleMode(handle windows.Handle, flags uint32) { + var mode uint32 + // set the console mode if not already there: + if err := windows.GetConsoleMode(handle, &mode); err == nil { + _ = windows.SetConsoleMode(handle, mode|flags) + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 7f9fb8e..0b41cd8 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -38,8 +38,11 @@ type Config struct { OrgDomain string `env:"OKTA_ORG_DOMAIN"` OIDCAppID string `env:"OKTA_OIDC_CLIENT_ID"` FedAppID string `env:"OKTA_AWS_ACCOUNT_FEDERATION_APP_ID"` + AWSIAMIdP string `env:"AWS_IAM_IDP"` + AWSIAMRole string `env:"AWS_IAM_ROLE"` Format string `env:"FORMAT,default=env-var"` Profile string `env:"PROFILE,default=default"` + QRCode bool `env:"QR_CODE"` HTTPClient *http.Client } @@ -81,7 +84,7 @@ func (c *Config) OverrideIfSet(cmd *cobra.Command, name string) { } val := flag.Value.String() - switch val { + switch name { case "org-domain": c.OrgDomain = val case "oidc-client-id": @@ -90,6 +93,12 @@ func (c *Config) OverrideIfSet(cmd *cobra.Command, name string) { c.FedAppID = val case "format": c.Format = val + case "aws-iam-idp": + c.AWSIAMIdP = val + case "aws-iam-role": + c.AWSIAMRole = val + case "qr-code": + c.QRCode = (val == "true") case "profile": c.Profile = val } diff --git a/pkg/sessiontoken/sessiontoken.go b/pkg/sessiontoken/sessiontoken.go index 4de2e0c..5fa9ac6 100644 --- a/pkg/sessiontoken/sessiontoken.go +++ b/pkg/sessiontoken/sessiontoken.go @@ -17,7 +17,6 @@ package sessiontoken import ( - "bufio" "bytes" "context" "encoding/base64" @@ -28,13 +27,15 @@ import ( "net/http" "net/url" "os" - "strconv" "strings" + "github.com/AlecAivazis/survey/v2" + "github.com/AlecAivazis/survey/v2/terminal" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/sts" "github.com/cenkalti/backoff/v4" + "github.com/mdp/qrterminal" "golang.org/x/net/html" "github.com/okta/okta-aws-cli/pkg/agent" @@ -60,9 +61,9 @@ type AuthToken struct { // DeviceAuthorization Encapsulates Okta API result to // /oauth2/v1/device/authorize call type DeviceAuthorization struct { - UserCode string `json:"user_code,omitempty"` - DeviceCode string `json:"device_code,omitempty"` - VericationURI string `json:"verification_uri,omitempty"` + UserCode string `json:"user_code,omitempty"` + DeviceCode string `json:"device_code,omitempty"` + VerificationURI string `json:"verification_uri,omitempty"` } type apiError struct { @@ -70,6 +71,12 @@ type apiError struct { ErrorDescription string `json:"error_description,omitempty"` } +// IDPAndRole IdP and role pairs +type IDPAndRole struct { + idp string + role string +} + // NewSessionToken Creates a new session token. func NewSessionToken(config *config.Config) *SessionToken { return &SessionToken{ @@ -102,23 +109,22 @@ func (s *SessionToken) EstablishToken() error { return err } - roles, err := s.GetRolesFromAssertion(assertion) + idpRolesMap, err := s.GetIDPRolesMapFromAssertion(assertion) if err != nil { return err } - role, err := s.PromptForRoleChoice(roles) + idpAndRole, err := s.PromptForIdpAndRole(idpRolesMap) if err != nil { return err } - ac, err := s.GetAWSCredential(role, assertion) + ac, err := s.GetAWSCredential(idpAndRole, assertion) if err != nil { return err } s.RenderCredential(ac) - return nil } @@ -129,13 +135,14 @@ func (s *SessionToken) RenderCredential(ac *oaws.Credential) { default: o = output.NewEnvVar() } + + fmt.Fprintf(os.Stderr, "\n") o.Output(s.config, ac) } // GetAWSCredential Get AWS Credentials with an STS Assume Role With SAML AWS // API call. -func (s *SessionToken) GetAWSCredential(role, assertion string) (*oaws.Credential, error) { - idpRole := strings.Split(role, ",") +func (s *SessionToken) GetAWSCredential(idpAndRole *IDPAndRole, assertion string) (*oaws.Credential, error) { sess, err := session.NewSession() if err != nil { return nil, err @@ -143,8 +150,8 @@ func (s *SessionToken) GetAWSCredential(role, assertion string) (*oaws.Credentia svc := sts.New(sess) input := &sts.AssumeRoleWithSAMLInput{ DurationSeconds: aws.Int64(3600), - RoleArn: aws.String(idpRole[1]), - PrincipalArn: aws.String(idpRole[0]), + PrincipalArn: aws.String(idpAndRole.idp), + RoleArn: aws.String(idpAndRole.role), SAMLAssertion: aws.String(assertion), } svcResp, err := svc.AssumeRoleWithSAML(input) @@ -159,43 +166,68 @@ func (s *SessionToken) GetAWSCredential(role, assertion string) (*oaws.Credentia }, nil } -// PromptForRoleChoice UX to prompt operator for the AWS role whose credentials +// PromptForIdpAndRole UX to prompt operator for the AWS role whose credentials // will be utilized. -func (s *SessionToken) PromptForRoleChoice(roles []string) (string, error) { - if len(roles) == 0 { - return "", errors.New("no roles to choose from") - } - fmt.Fprintf(os.Stderr, "You have %d available AWS IAM roles\n", len(roles)) - for i, role := range roles { - idpRole := strings.Split(role, ",") - out := `Choice %d - IdP %q - AWS Role %q -` - fmt.Fprintf(os.Stderr, out, i+1, idpRole[0], idpRole[1]) +func (s *SessionToken) PromptForIdpAndRole(idpRoles map[string][]string) (*IDPAndRole, error) { + result := &IDPAndRole{} + + idps := make([]string, 0, len(idpRoles)) + for idp := range idpRoles { + idps = append(idps, idp) } - fmt.Fprintf(os.Stderr, "\nEnter your choice: ") - reader := bufio.NewReader(os.Stdin) - choice, err := reader.ReadString('\n') - if err != nil { - return "", err + + if len(idps) == 0 { + return result, errors.New("no IdPs to choose from") } - choice = strings.ReplaceAll(choice, "\n", "") - num, err := strconv.Atoi(choice) - if err != nil { - return "", err + + var idp string + prompt := &survey.Select{ + Message: "Choose an IdP:", + Options: idps, + Default: s.config.AWSIAMIdP, } - if num < 1 || num > len(roles) { - return "", fmt.Errorf("invalid choice %d, valid values are 1 to %d", num, len(roles)) + stderrIsOutAskOpt := func(options *survey.AskOptions) error { + options.Stdio = terminal.Stdio{ + In: os.Stdin, + Out: os.Stderr, + Err: os.Stderr, + } + return nil } - fmt.Fprintf(os.Stderr, "\n") - return roles[num-1], nil + + survey.AskOne(prompt, &idp, survey.WithValidator(survey.Required), stderrIsOutAskOpt) + if idp == "" { + return result, errors.New("failed to select IdP value") + } + + roles := idpRoles[idp] + if len(roles) == 0 { + return result, fmt.Errorf("IdP %q has no roles to choose from", idp) + } + + var role string + // survey for role + prompt = &survey.Select{ + Message: "Choose a Role:", + Options: roles, + Default: s.config.AWSIAMRole, + } + survey.AskOne(prompt, &role, survey.WithValidator(survey.Required), stderrIsOutAskOpt) + if role == "" { + return result, fmt.Errorf("no roles chosen for IdP %q", idp) + } + + result.idp = idp + result.role = role + return result, nil } -// GetRolesFromAssertion Get AWS Roles from SAML assertion. -func (s *SessionToken) GetRolesFromAssertion(encoded string) ([]string, error) { - result := []string{} +// GetIDPRolesMapFromAssertion Get AWS IdP and Roles from SAML assertion. Result +// a map string string slice keyed by the IdP ARN value and slice of ARN role +// values. +func (s *SessionToken) GetIDPRolesMapFromAssertion(encoded string) (map[string][]string, error) { + result := make(map[string][]string) assertion, err := base64.StdEncoding.DecodeString(encoded) if err != nil { return result, err @@ -206,7 +238,23 @@ func (s *SessionToken) GetRolesFromAssertion(encoded string) ([]string, error) { } if role, ok := findSAMLRoleAttibute(doc); ok { - result = findSAMLRoleValues(role) + pairs := findSAMLIdPRoleValues(role) + for _, pair := range pairs { + idpRole := strings.Split(pair, ",") + idp := idpRole[0] + if _, found := result[idp]; !found { + result[idp] = []string{} + } + + if len(idpRole) == 1 { + continue + } + + roles := result[idp] + role := idpRole[1] + roles = append(roles, role) + result[idp] = roles + } } return result, nil } @@ -287,15 +335,25 @@ func (s *SessionToken) GetSSOToken(at *AuthToken) (*AuthToken, error) { // PromptAuthentication UX to display activation URL and code. func (s *SessionToken) PromptAuthentication(da *DeviceAuthorization) { - prompt := `Initiate authentication for an AWS CLI by opening the following URL. -Enter the given activation code when prompted. + verificationURL := fmt.Sprintf("%s?user_code=%s", da.VerificationURI, da.UserCode) + var qrBuf []byte + qrCode := "" + + if s.config.QRCode { + qrBuf = make([]byte, 4096) + buf := bytes.NewBufferString("") + qrterminal.GenerateHalfBlock(verificationURL, qrterminal.L, buf) + buf.Read(qrBuf) + qrCode = fmt.Sprintf("%s\n", qrBuf) + } + + prompt := `Open the following URL to begin Okta device authorization for the AWS CLI. -Activation URL: %s -Activation code: %s +%s%s ` - fmt.Fprintf(os.Stderr, prompt, da.VericationURI, da.UserCode) + fmt.Fprintf(os.Stderr, prompt, qrCode, verificationURL) } func apiErr(bodyBytes []byte) (*apiError, error) { @@ -442,7 +500,7 @@ func findSAMLResponse(n *html.Node) (string, bool) { return "", false } -func findSAMLRoleValues(n *html.Node) []string { +func findSAMLIdPRoleValues(n *html.Node) []string { result := []string{} if n == nil { return result