From d9ccd9299afeed1202f14498675cadb2fea08359 Mon Sep 17 00:00:00 2001 From: Mike Mondragon Date: Fri, 8 Sep 2023 16:08:34 -0700 Subject: [PATCH 01/42] README driven design --- README.md | 430 +++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 298 insertions(+), 132 deletions(-) diff --git a/README.md b/README.md index e25730d..ac8b9a3 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,25 @@ # okta-aws-cli -Okta authentication in support of AWS CLI operation. The `okta-aws-cli` CLI is -native to the Okta Identity Engine and its authentication flows. The CLI is not -compatible with Okta Classic orgs. - -The Okta AWS Federation application is SAML based and the Okta AWS CLI interacts -with AWS IAM using -[AssumeRoleWithSAML](https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRoleWithSAML.html). -Okta does not have an OIDC based AWS Federation application at this time. - -`okta-aws-cli` handles authentication through Okta and token exchange with AWS -STS to collect a proper IAM role for the AWS CLI operator. The resulting -output is a set made up of `Access Key ID`, `Secret Access Key`, and `Session -Token` of [AWS -credentials](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-files.html) -for the AWS CLI. The Okta AWS CLI expresses the AWS credentials as either -[environment -variables](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-envvars.html) -or appended to an AWS CLI [credentials -file](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-files.html). -The `Session Token` has an expiry of 60 minutes. +`okta-aws-cli` is a CLI program allowing Okta to act as an identity provider and +retrieve AWS IAM temporary credentials for use in AWS CLI, AWS SDKs, and other +tools accessing the AWS API. It has two modes of operation: `web` - combined +human and device authorization; and `m2m` - headless authorization. +`okta-aws-cli web` is native to the Okta Identity Engine and its authentication +and device authorization flows. `okta-aws-cli web` is not compatible with Okta +Classic orgs. `okta-aws-cli m2m` makes use of public/private authorization and +OIDC. + +Example `okta-aws-cli` in *default* `web` mode with environment variables +output: ```shell -# *nix, export statements + # *nix, export statements $ okta-aws-cli export AWS_ACCESS_KEY_ID=ASIAUJHVCS6UQC52NOL7 export AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY export AWS_SESSION_TOKEN=AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5T... -# *nix, eval export ENV vars into current shell + # *nix, eval export ENV vars into current shell $ eval `okta-aws-cli` && aws s3 ls 2018-04-04 11:56:00 test-bucket 2021-06-10 12:47:11 mah-bucket @@ -41,21 +32,80 @@ SETX AWS_SESSION_TOKEN AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5T... ``` -* [Requirements](#requirements) -* [Recommendations](#recommendations) -* [Installation](#installation) -* [Configuration](#configuration) -* [Operation](#operation) -* Comparison - * [Nike gimme-aws-creds](#nike-gimme-aws-creds) - * [Versent saml2aws](#versent-saml2aws) -* [Development](#development) -* [Contributing](#contributing) -* [References](#references) +The result of both the `web` and `m2m` operations is to secure and emit [IAM temporary +credentials](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_temp_use-resources.html). +The credentials have three different output formats that are chosen by the user: +[environment +variables](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-envvars.html), +AWS [credentials +file](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-files.html), +or JSON [process +credentials](https://docs.aws.amazon.com/sdkref/latest/guide/feature-process-credentials.html) +format. + +## Table of Contents + + - [Web Mode](#web-mode) + - [Web Mode Requirements](#web-mode-requirements) + - [Multiple AWS environments](#multiple-aws-environments) + - [Non-Admin Users](#non-admin-users) + - [M2M mode](#m2m-mode) + - [M2M Mode Requirements](#m2m-mode-requirements) + - [Configuration](#configuration) + - [Global settings](#global-settings) + - [Web mode settings](#web-mode-settings) + - [M2M mode settings](#m2m-mode-settings) + - [Friendly IdP and Role menu labels](#friendly-idp-and-role-menu-labels) + - [Installation](#installation) + - [Recommendations](#recommendations) + - [Operation](#operation) + - [Comparison](#comparison) + - [Development](#development) + - [Contributing](#contributing) + - [References](#references) + +## Web Mode + +```shell +$ okta-aws-cli web +export AWS_ACCESS_KEY_ID=ASIAUJHVCS6UQC52NOL7 +export AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY +export AWS_SESSION_TOKEN=AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5T... +``` + +Web mode is the original human oriented device authorization mode. The user +executes `okta-aws-cli web` and a web browser is opened to complete the device +authorization at the Okta web site. After that the human returns to the CLI they +select an identity provider and a role from that IdP. -## Requirements +Web mode is an integration that pairs an Okta [OIDC Native +Application](https://developer.okta.com/blog/2021/11/12/native-sso) with an +[Okta AWS Federation integration +application](https://www.okta.com/integrations/aws-account-federation/). In turn +the Okta AWS Fed app is itself paired with an [AWS IAM identity +provider](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_create.html). +The Okta AWS Fed app is SAML based and the Okta AWS CLI interacts with AWS IAM +using +[AssumeRoleWithSAML](https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRoleWithSAML.html). -The Okta AWS CLI requires an OIE organization and an [OIDC Native +`okta-aws-cli web` handles authentication through Okta and presents a SAML +assertion to AWS STS to collect a proper IAM role for the AWS CLI operator. The +resulting output is a set made up of `Access Key ID`, `Secret Access Key`, and +`Session Token` of [AWS +credentials](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-files.html) +for the AWS CLI. The Okta AWS CLI expresses the AWS credentials as [environment +variables](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-envvars.html), +or appended to an AWS CLI [credentials +file](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-files.html), +or emits JSON in [process +credentials](https://docs.aws.amazon.com/sdkref/latest/guide/feature-process-credentials.html) +format. + +The `Session Token` has a default expiry of 60 minutes. + +### Web Mode Requirements + +For web mode the Okta AWS CLI requires an OIE organization and an [OIDC Native Application](https://developer.okta.com/blog/2021/11/12/native-sso) paired with an [Okta AWS Federation integration application](https://www.okta.com/integrations/aws-account-federation/). The @@ -70,6 +120,8 @@ at `Applications > [the OIDC app] > General Settings > Grant type`. be supported by a single OIDC application, the OIDC app must have the `okta.apps.read` grant. Apps read and other application grants are configured at `Applications > [the OIDC app] > Okta API Scopes` in the Okta Admin UI. + *NOTE*: the Okta Management API only supports the `okta.apps.read` grant for + admin users at this time (see ["Non-Admin Users"](#non-admin-users)). The pairing with the AWS Federation Application is achieved in the Fed app's Sign On Settings. These settings are in the Okta Admin UI at `Applications > [the @@ -89,10 +141,10 @@ URL below. Then follow the directions in that wizard. `https://saml-doc.okta.com/SAML_Docs/How-to-Configure-SAML-2.0-for-Amazon-Web-Service.html?baseAdminUrl=https://[ADMIN_DOMAIN]&app=amazon_aws&instanceId=[CLIENT_ID]` -### Multiple AWS environments +#### Multiple AWS environments **NOTE**: Multiple AWS environments works correctly without extra configuration -for Admin users. See ["Non-Admin Users"](#non-admin-users) for extra +for admin users. See ["Non-Admin Users"](#non-admin-users) for extra configuration needed for non-admin users. To support multiple AWS environments, associate additional AWS Federation @@ -107,19 +159,7 @@ up this kind of configuration. * Fed App #2 is linked to an IdP and Role dedicated to ec2 operations * Fed App #3 is oriented for an administrator is comprised of an IdP and Role with many different permissions -#### Example select from multiple IdPs - -![select IdP](./doc/example-select-idp.png) - -#### Example select from multiple Roles - -![select Role](./doc/example-select-role.png) - -#### Example creds consumed for S3 operations - -![conclusion](./doc/example-conclusion.png) - -### Non-Admin Users +#### Non-Admin Users Multiple AWS environments requires extra configuration for non-admin users. Follow these steps to support non-admin users. @@ -143,45 +183,110 @@ It is on our feature backlog to get support into the Okta API to allow the multiple AWS Fed apps feature into okta-aws-cli without needing this work around using a custom admin role. -## Recommendations - -We recommend that the AWS Federation Application and OIDC native application -have equivalent policies if not share the same policy. If the AWS Federation -app has more stringent assurance requirements than the OIDC app a `400 Bad -Request` API error is likely to occur. +## M2M mode -## Installation - -### Binaries - -Binary releases for combinations of operating systems and architectures are -posted to the [okta-aws-cli -releases](https://github.com/okta/okta-aws-cli/releases) section in Github. Each -release includes CHANGELOG notes for that release. - -### OSX/Homebrew - -okta-aws-cli is distributed to OSX via [homebrew](https://brew.sh/) - -``` -$ brew install okta-aws-cli +```shell +$ okta-aws-cli m2m +export AWS_ACCESS_KEY_ID=ASIAUJHVCS6UQC52NOL7 +export AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY +export AWS_SESSION_TOKEN=AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5T... ``` -### Local build/install - -See [Development](#development) section. - -TL;DR run directly from source -``` -$ go run cmd/okta-aws-cli/main.go --help -``` +M2M mode is headless machine to machine authorization. The operator executes +`okta-aws-cli m2m` which has access to a private key whose public key is +registered in an Okta API service application. `okta-aws-cli m2m` signs a +request for an Okta access token that is associated with the Okta service +application. Given the Okta custom authorization server returns an access token, +the access token is presented to AWS STS using +[AssumeRoleWithWebIdentity](https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRoleWithWebIdentity.html). +AWS and Okta communicate directly by OIDC protocol to confirm authorization for +IAM credentials. -TL;DR build from source, installed into golang bin directory -``` -$ make build -``` +Given access is granted by AWS the result of `okta-aws-cli m2m` is a set made up +of `Access Key ID`, `Secret Access Key`, and `Session Token` of [AWS +credentials](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-files.html) +for the AWS CLI. The Okta AWS CLI expresses the AWS credentials as [environment +variables](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-envvars.html), +or appended to an AWS CLI [credentials +file](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-files.html), +or emits JSON in [process +credentials](https://docs.aws.amazon.com/sdkref/latest/guide/feature-process-credentials.html) +format. + +The `Session Token` has a default expiry of 60 minutes. + +### M2M Mode Requirements + +M2M is an integration of: + +- [Okta API service app](https://developer.okta.com/docs/guides/implement-oauth-for-okta-serviceapp/main/) +- [Okta custom authorization server](https://developer.okta.com/docs/guides/customize-authz-server/main/) with a custom scope +- [Okta access policy](https://developer.okta.com/docs/guides/configure-access-policy/main/) associated with the service app and have rule(s) for the client credentials flow +- [AWS IAM OpenID Connect (OIDC) identity provider](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_create_oidc.html) + +#### Okta Service Application + +The Okta API services app requires "Public key / Private key" for "Client +authentication". This is set at `Applications > [the API services app] > General +Settings > Client Credentials`. In the "Public Keys" section select "Add Key" +then either add your own public key in JWKS format, or have Okta generate a new +key pair and copy either JWKS or PEM formatted private key. Where ever the +private key resides it needs to be available to `okta-aws-cli m2m` at runtime; +for instance injected as an environment variable from a secrets manager / vault. + +#### Okta Custom Authorization Server + +Okta custom authorization servers are available in Developer Edition orgs. For +production orgs the Workforce Identity SKU "API Access Management" needs to be +[requested for the +org](https://developer.okta.com/docs/guides/customize-authz-server/main/#about-the-authorization-server) +if it doesn't already have the feature enabled. + +Custom authorization servers are set in the Admin UI at `Security > API` and +select "Add Authorization Server". The "Audience" value needs to be set to +something URL-like, for instance "https://my-project-name". Audience will be +referenced later in the AWS IAM OIDC Identity Provider settings. + +#### Okta Custom Scope + +A [custom Okta +scope](https://support.okta.com/help/s/article/Creating-a-Scope-for-an-Authorization-Server-in-Okta) +needs to be set on the authorization server. This is at `Security > API > [the +authorization server] > Scopes` and choose "Add Scope". `okta-aws-cli` will +assume the scope is named `okta-aws-cli`, but if it isn't the CLI flag +`--custom-scope` argument trains the CLI for the scope to use. + +#### Okta Access Policy + +On the custom authorization server panel select "Access Policies" this is at +`Security > API > [the authorization server] > Access Policies`. Then select +"Add New Access Policy", give it a name and description. Also, select "Assign +to" > "The following clients" and assign to the established Okta service app. +Save the policy. + +On the new access policy select "Add rule" and give it a descriptive name, for +instance "Client Credentials Client acting on behalf of itself". Give the rule +the parameters "IF Grant type is" / "Client acting on behalf of itself" and +select "Client Credentials". Then "AND User is", assign your user(s) preference. +Finally, "AND Scopes requested" / "The following scopes", choose the custom +scope created. The CLI defaults to custom scope named `okta-aws-cli` otherwise +the `--custom-scope` CLI flag is required at runtime specify the name. Save the +rule. + +#### AWS IAM OIDC IdP + +From the AWS Console, in the IAM panel, select "Identity providers". Then click +"Add provider". In the add provider form select "OpenID Connect". Set the +"Provider URL" to the issuer URL from the Okta API Authorization Servers list +for your custom authorization server (example: https://[your +org].okta.com/oauth2/[custom auth server id]). Set the "Audience" value the +"Audience" value established during the Okta custom authorization server set up. + +After the IdP is created note it's ARN value and assign IAM Roles to the IdP. +Also note those Role values. ## Configuration +### Global settings **NOTE**: If your AWS IAM IdP is in a non-commercial region, such as GovCloud, the environmental variable @@ -195,55 +300,68 @@ domain](https://developer.okta.com/docs/guides/find-your-domain/main/), and the client ID of the [OIDC Native Application](https://developer.okta.com/blog/2021/11/12/native-sso). -If the OIDC Native App doesn't also have the `okta.apps.read` grant the client -ID of the [Okta AWS -Federation](https://www.okta.com/integrations/aws-account-federation/) -integration application is also required. - An optional output format value can be configured. Default output format is as [environment variables](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-envvars.html) that can be used for the AWS CLI configuration. Output can also be expressed as -[credential file +[AWS credential file values](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-files.html) -for AWS CLI configuration. +or JSON [process +credentials](https://docs.aws.amazon.com/sdkref/latest/guide/feature-process-credentials.html) +format. Configuration can be done with command line flags, environment variables, an `.env` file, or a combination of the three. The first value found in that evaluation order takes precedent. -Also see the CLI's online help `$ okta-aws-cli --help` - -| Name | ENV var and .env file value | Command line flag | Description | -|-------|-----------------------------|-------------------|-------------| -| Okta Org Domain (**required**) | `OKTA_ORG_DOMAIN` | `--org-domain [value]` | Full domain hostname of the Okta org e.g. `test.okta.com` | -| OIDC Client ID (**required**) | `OKTA_OIDC_CLIENT_ID` | `--oidc-client-id [value]` | See [Allowed Web SSO Client](#allowed-web-sso-client) | -| Okta AWS Account Federation integration app ID (optional) | `OKTA_AWS_ACCOUNT_FEDERATION_APP_ID` | `--aws-acct-fed-app-id [value]` | See [AWS Account Federation integration app](#aws-account-federation-integration-app). This value is only required if the OIDC app doesn't have the `okta.apps.read` grant for whatever reason | -| Preselect the AWS IAM Identity Provider ARN (optional) | `OKTA_AWSCLI_IAM_IDP` | `--aws-iam-idp [value]` | Preselects the IdP list to this preferred IAM Identity Provider. If there are other IdPs available they will not be listed. | -| Preselects the AWS IAM Role ARN to assume (optional) | `OKTA_AWSCLI_IAM_ROLE` | `--aws-iam-role [value]` | Preselects the role list to this preferred IAM role for the given IAM Identity Provider. If there are other Roles available they will not be listed. | -| AWS Session Duration (optional) | `OKTA_AWSCLI_SESSION_DURATION` | `--session-duration [value]` | The lifetime, in seconds, of the AWS credentials. Must be between 60 and 43200. | -| Output format (optional) | `OKTA_AWSCLI_FORMAT` | `--format [value]` | Default is `env-var`. Options: `env-var` for output to environment variables, `aws-credentials` for output to AWS credentials file | -| Profile (optional) | `OKTA_AWSCLI_PROFILE` | `--profile [value]` | Default is `default` | -| Display QR Code (optional) | `OKTA_AWSCLI_QR_CODE=true` | `--qr-code` | `true` if flag is present | -| Automatically open the activation URL with the system web browser (optional) | `OKTA_AWSCLI_OPEN_BROWSER=true` | `--open-browser` | `true` if flag is present | -| Cache Okta access token at `$HOME/.okta/awscli-access-token.json` to reduce need to open device authorization URL | `OKTA_AWSCLI_CACHE_ACCESS_TOKEN=true` | `--cache-access-token` | `true` if flag is present | -| Alternate AWS credentials file path (optional) | `OKTA_AWSCLI_AWS_CREDENTIALS` | `--aws-credentials` | Path to alternative credentials file other than AWS CLI default | -| (Over)write the given profile to the AWS credentials file (optional). WARNING: When enabled, overwriting can inadvertently remove dangling comments and extraneous formatting from the creds file. | `OKTA_AWSCLI_WRITE_AWS_CREDENTIALS=true` | `--write-aws-credentials` | `true` if flag is present | -| Emit deprecated AWS variable `aws_security_token` with duplicated value from `aws_session_token` | `OKTA_AWSCLI_LEGACY_AWS_VARIABLES=true` | `--legacy-aws-variables` | `true` if flag is present | -| Emit expiry timestamp `x_security_token_expires` in RFC3339 format for the session/security token (AWS credentials file only) | `OKTA_AWSCLI_EXPIRY_AWS_VARIABLES=true` | `--expiry-aws-variables` | `true` if flag is present | -| Print operational information to the screen for debugging purposes | `OKTA_AWSCLI_DEBUG=true` | `--debug` | `true` if flag is present | -| Verbosely print all API calls/responses to the screen | `OKTA_AWSCLI_DEBUG_API_CALLS=true` | `--debug-api-calls` | `true` if flag is present | -| HTTP/HTTPS Proxy support | `HTTP_PROXY` or `HTTPS_PROXY` | n/a | HTTP/HTTPS URL of proxy service (based on golang [net/http/httpproxy](https://pkg.go.dev/golang.org/x/net/http/httpproxy) package) | -| Debug okta.yaml config file and exit | `OKTA_AWSCLI_DEBUG_CONFIG=true` | `--debug-config` | `true` if flag is present | - **NOTE**: If [`AWS_REGION`](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-envvars.html) is set in the `.env` file it will be promoted into the okta-aws-cli runtime if it isn't also already set as an ENV VAR. This will allow operators making use of -an `.env` file to have proper AWS API behavior in spefific regions, for instance +an `.env` file to have proper AWS API behavior in specific regions, for instance in US govcloud and other non-North America regions. -### Allowed Web SSO Client +Also see the CLI's online help `$ okta-aws-cli --help` + +These global settings are all optional: + +| Name | Description | Command line flag | ENV var and .env file value | +|-----|-----|-----|-----| +| AWS Session Duration | The lifetime, in seconds, of the AWS credentials. Must be between 60 and 43200. | `--session-duration [value]` | `OKTA_AWSCLI_SESSION_DURATION` | +| Output format | Default is `env-var`. Options: `env-var` for output to environment variables, `aws-credentials` for output to AWS credentials file, `process-credentials` for credentials as JSON | `--format [value]` | `OKTA_AWSCLI_FORMAT` | +| Profile | Default is `default` | `--profile [value]` | `OKTA_AWSCLI_PROFILE` | +| Cache Okta access token at `$HOME/.okta/awscli-access-token.json` to reduce need to open device authorization URL | `true` if flag is present | `--cache-access-token` | `OKTA_AWSCLI_CACHE_ACCESS_TOKEN=true` | +| Alternate AWS credentials file path | Path to alternative credentials file other than AWS CLI default | `--aws-credentials` | `OKTA_AWSCLI_AWS_CREDENTIALS` | +| (Over)write the given profile to the AWS credentials file. WARNING: When enabled, overwriting can inadvertently remove dangling comments and extraneous formatting from the creds file. | `true` if flag is present | `--write-aws-credentials` | `OKTA_AWSCLI_WRITE_AWS_CREDENTIALS=true` | +| Emit deprecated AWS variable `aws_security_token` with duplicated value from `aws_session_token` | `true` if flag is present | `--legacy-aws-variables` | `OKTA_AWSCLI_LEGACY_AWS_VARIABLES=true` | +| Emit expiry timestamp `x_security_token_expires` in RFC3339 format for the session/security token (AWS credentials file only) | `true` if flag is present | `--expiry-aws-variables` | `OKTA_AWSCLI_EXPIRY_AWS_VARIABLES=true` | +| Print operational information to the screen for debugging purposes | `true` if flag is present | `--debug` | `OKTA_AWSCLI_DEBUG=true` | +| Verbosely print all API calls/responses to the screen | `true` if flag is present | `--debug-api-calls` | `OKTA_AWSCLI_DEBUG_API_CALLS=true` | +| HTTP/HTTPS Proxy support | HTTP/HTTPS URL of proxy service (based on golang [net/http/httpproxy](https://pkg.go.dev/golang.org/x/net/http/httpproxy) package) | n/a | `HTTP_PROXY` or `HTTPS_PROXY` | +| Debug okta.yaml config file and exit | `true` if flag is present | `--debug-config` | `OKTA_AWSCLI_DEBUG_CONFIG=true` | + + +### Web mode settings + +If the OIDC Native App doesn't also have the `okta.apps.read` grant the client +ID of the [Okta AWS +Federation](https://www.okta.com/integrations/aws-account-federation/) +integration application is also required. + +| Name | Description | Command line flag | ENV var and .env file value | +|-----|-----|-----|-----| +| Okta Org Domain (**required**) | Full host and domain name of the Okta org e.g. `test.okta.com` or the custom domain value | `--org-domain [value]` | `OKTA_ORG_DOMAIN` | +| OIDC Client ID (**required**) | The OIDC native application / [Allowed Web SSO Client ID](#allowed-web-sso-client-id) | `--oidc-client-id [value]` | `OKTA_OIDC_CLIENT_ID` | +| Okta AWS Account Federation integration app ID (optional) | See [AWS Account Federation integration app](#aws-account-federation-integration-app). This value is only required if the OIDC app doesn't have the `okta.apps.read` grant for whatever reason | `--aws-acct-fed-app-id [value]` | `OKTA_AWS_ACCOUNT_FEDERATION_APP_ID` | +| Preselect the AWS IAM Identity Provider ARN (optional) | Preselects the IdP list to this preferred IAM Identity Provider. If there are other IdPs available they will not be listed. | `--aws-iam-idp [value]` | `OKTA_AWSCLI_IAM_IDP` | + +| Preselects the AWS IAM Role ARN to assume (optional) | Preselects the role list to this preferred IAM role for the given IAM Identity Provider. If there are other Roles available they will not be listed. | `--aws-iam-role [value]` | `OKTA_AWSCLI_IAM_ROLE` | +| Display QR Code (optional) | `true` if flag is present | `--qr-code` | `OKTA_AWSCLI_QR_CODE=true` | +| Automatically open the activation URL with the system web browser (optional) | `true` if flag is present | `--open-browser` | `OKTA_AWSCLI_OPEN_BROWSER=true` | + + + +#### Allowed Web SSO Client ID This is the "Allowed Web SSO Client" value from the "Sign On" settings of an [AWS Account @@ -254,7 +372,7 @@ is the identifier of the client is Okta app acting as the IdP for AWS. Example: `0oa5wyqjk6Wm148fE1d7` -### AWS Account Federation integration app +#### AWS Account Federation integration app ID for the [AWS Account Federation"](https://www.okta.com/integrations/aws-account-federation/) @@ -263,27 +381,27 @@ integration app. Example: `0oa9x1rifa2H6Q5d8325` -### Environment variables example +#### Environment variables example ```shell export OKTA_ORG_DOMAIN=test.okta.com export OKTA_OIDC_CLIENT_ID=0oa5wyqjk6Wm148fE1d7 ``` -### `.env` file variables example +#### `.env` file variables example ``` OKTA_ORG_DOMAIN=test.okta.com OKTA_OIDC_CLIENT_ID=0oa5wyqjk6Wm148fE1d7 ``` -### Command line flags example +#### Command line flags example -#### OIDC client has `okta.apps.read` grant +##### OIDC client has `okta.apps.read` grant ```shell -$ okta-aws-cli --org-domain test.okta.com \ +$ okta-aws-cli web --org-domain test.okta.com \ --oidc-client-id 0oa5wyqjk6Wm148fE1d7 ``` @@ -291,11 +409,21 @@ $ okta-aws-cli --org-domain test.okta.com \ ```shell -$ okta-aws-cli --org-domain test.okta.com \ +$ okta-aws-cli web --org-domain test.okta.com \ --oidc-client-id 0oa5wyqjk6Wm148fE1d7 \ --aws-acct-fed-app-id 0oa9x1rifa2H6Q5d8325 ``` +### M2M mode settings + +| Name | Description | Command line flag | ENV var and .env file value | +|-----|-----|-----|-----| +| Okta Org Domain (**required**) | Full host and domain name of the Okta org e.g. `test.okta.com` or the custom domain value | `--org-domain [value]` | `OKTA_ORG_DOMAIN` | +| OIDC Client ID (**required**) | The API services app ID | `--oidc-client-id [value]` | `OKTA_OIDC_CLIENT_ID` | +| AWS IAM Role ARN (required) | Preselects the role list to this preferred IAM role for the given IAM Identity Provider. | `--aws-iam-role [value]` | `OKTA_AWSCLI_IAM_ROLE` | +| Private Key (**required**) | PEM or JWKS format private key whose public key is stored on the service app | `--private-key [value]` | `OKTA_AWSCLI_PRIVATE_KEY` | +| Custom scope name (optional) | The custom scope established in the custom authorization server. Default `okta-aws-cli` | `--custom-scope [value]` | `OKTA_AWSCLI_CUSTOM_SCOPE` | + ### Friendly IdP and Role menu labels When the operator has many AWS Federation apps listing the AWS IAM IdP ARNs can @@ -389,6 +517,45 @@ If any of the checks fail a warning and diagnostic message is given. okta-aws-cli will exit once the debug config operation is complete. It is not intended to be run with other flags. +## Installation + +### Binaries + +Binary releases for combinations of operating systems and architectures are +posted to the [okta-aws-cli +releases](https://github.com/okta/okta-aws-cli/releases) section in Github. Each +release includes CHANGELOG notes for that release. + +### OSX/Homebrew + +okta-aws-cli is distributed to OSX via [homebrew](https://brew.sh/) + +``` +$ brew install okta-aws-cli +``` + +### Local build/install + +See [Development](#development) section. + +TL;DR run directly from source +``` +$ go run cmd/okta-aws-cli/main.go --help +``` + +TL;DR build from source, installed into golang bin directory +``` +$ make build +``` + +## Recommendations + +We recommend that the AWS Federation Application and OIDC native application +have equivalent policies if not share the same policy. If the AWS Federation +app has more stringent assurance requirements than the OIDC app a `400 Bad +Request` API error is likely to occur. + + ## Operation The behavior of the Okta AWS CLI is to be friendly for shell input and @@ -400,14 +567,17 @@ This allows for the command's results to be `eval`'d into the current shell as ### Plain usage +**NOTE**: The default sub command is `web` and it can be left off as a command argument. + **NOTE**: example assumes other Okta AWS CLI configuration values have already been set by ENV variables or `.env` file. **NOTE**: output will be in `setx` statements if the runtime is Windows. **NOTE**: okta-aws-cli only needs to be called the first time to gather AWS -creds. Then called again once those creds have expired. It does not need to be -called every time before each actual AWS CLI invocation. +creds. Then called again once those creds have expired. _It does not need to be +called every time before each actual AWS CLI invocation._ + ```shell $ okta-aws-cli @@ -436,16 +606,12 @@ $ aws s3 ls set by ENV variables or `.env` file. ```shell -$ eval `okta-aws-cli` && aws s3 ls +$ eval `okta-aws-cli web` && aws s3 ls 2018-04-04 11:56:00 test-bucket 2021-06-10 12:47:11 mah-bucket $ eval `okta-aws-cli` -$ aws s3 ls -2018-04-04 11:56:00 test-bucket -2021-06-10 12:47:11 mah-bucket - $ aws s3 ls 2018-04-04 11:56:00 test-bucket 2021-06-10 12:47:11 mah-bucket @@ -457,7 +623,7 @@ $ aws s3 ls set by ENV variables or `.env` file. ```shell -$ okta-aws-cli --profile test --format aws-credentials && \ +$ okta-aws-cli web --profile test --format aws-credentials && \ aws --profile test s3 ls Open the following URL to begin Okta device authorization for the AWS CLI. @@ -520,7 +686,7 @@ Okta org domain name, and OIDC app id. The Okta CLI is CLI flag and environment variable oriented and its default output is as environment variables. It can also write to AWS credentials file. -The default writing option is an apped operation and can be explicitly set to +The default writing option is an append operation and can be explicitly set to overwrite previous values for a profile with the `--write-aws-credentials` flag. ### Versent saml2aws From 8d5ddbd451f65319117a52dd879519d7298fb769 Mon Sep 17 00:00:00 2001 From: Mike Mondragon Date: Fri, 22 Sep 2023 10:54:40 -0700 Subject: [PATCH 02/42] Refactor to subcommands: web, m2m, debug. Stub out m2m subcommand. --- README.md | 78 +++--- cmd/okta-aws-cli/main.go | 3 +- cmd/root/debug/debug.go | 48 ++++ cmd/root/m2m/m2m.go | 78 ++++++ cmd/root/root.go | 296 +++++++++------------ cmd/root/web/web.go | 91 +++++++ internal/config/config.go | 536 ++++++++++++++++++++------------------ internal/config/errors.go | 21 -- internal/flag/flag.go | 111 ++++++++ 9 files changed, 770 insertions(+), 492 deletions(-) create mode 100644 cmd/root/debug/debug.go create mode 100644 cmd/root/m2m/m2m.go create mode 100644 cmd/root/web/web.go delete mode 100644 internal/config/errors.go create mode 100644 internal/flag/flag.go diff --git a/README.md b/README.md index ac8b9a3..5965800 100644 --- a/README.md +++ b/README.md @@ -2,15 +2,15 @@ `okta-aws-cli` is a CLI program allowing Okta to act as an identity provider and retrieve AWS IAM temporary credentials for use in AWS CLI, AWS SDKs, and other -tools accessing the AWS API. It has two modes of operation: `web` - combined -human and device authorization; and `m2m` - headless authorization. +tools accessing the AWS API. There are two primary commands of operation: `web` - +combined human and device authorization; and `m2m` - headless authorization. `okta-aws-cli web` is native to the Okta Identity Engine and its authentication and device authorization flows. `okta-aws-cli web` is not compatible with Okta Classic orgs. `okta-aws-cli m2m` makes use of public/private authorization and OIDC. -Example `okta-aws-cli` in *default* `web` mode with environment variables -output: +Example `okta-aws-cli` `web` command with environment variables (when command is +missing *defaults* to `web`) output: ```shell # *nix, export statements @@ -45,16 +45,17 @@ format. ## Table of Contents - - [Web Mode](#web-mode) - - [Web Mode Requirements](#web-mode-requirements) + - [Commands](#commands) + - [Web Command](#web-command) + - [Web Command Requirements](#web-command-requirements) - [Multiple AWS environments](#multiple-aws-environments) - [Non-Admin Users](#non-admin-users) - - [M2M mode](#m2m-mode) - - [M2M Mode Requirements](#m2m-mode-requirements) + - [M2M Command](#m2m-command) + - [M2M Command Requirements](#m2m-command-requirements) - [Configuration](#configuration) - [Global settings](#global-settings) - - [Web mode settings](#web-mode-settings) - - [M2M mode settings](#m2m-mode-settings) + - [Web command settings](#web-command-settings) + - [M2M command settings](#m2m-command-settings) - [Friendly IdP and Role menu labels](#friendly-idp-and-role-menu-labels) - [Installation](#installation) - [Recommendations](#recommendations) @@ -64,7 +65,15 @@ format. - [Contributing](#contributing) - [References](#references) -## Web Mode +## Commands + +| Command | Description | +|-----|-----| +| web | Human oriented retrieval of temporary IAM credentials through Okta authentication and device authorization. Note: if `okta-aws-cli` is not given a command it defaults to this original `web` command. | +| m2m | Machine/headless oriented retrieval of temporary IAM credentials through Okta authentication with a private key. | +| debug | Debug okta.yaml config file and exit. | + +## Web Command ```shell $ okta-aws-cli web @@ -73,12 +82,12 @@ export AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY export AWS_SESSION_TOKEN=AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5T... ``` -Web mode is the original human oriented device authorization mode. The user +Web command is the original human oriented device authorization mode. The user executes `okta-aws-cli web` and a web browser is opened to complete the device authorization at the Okta web site. After that the human returns to the CLI they select an identity provider and a role from that IdP. -Web mode is an integration that pairs an Okta [OIDC Native +Web command is an integration that pairs an Okta [OIDC Native Application](https://developer.okta.com/blog/2021/11/12/native-sso) with an [Okta AWS Federation integration application](https://www.okta.com/integrations/aws-account-federation/). In turn @@ -103,9 +112,9 @@ format. The `Session Token` has a default expiry of 60 minutes. -### Web Mode Requirements +### Web Command Requirements -For web mode the Okta AWS CLI requires an OIE organization and an [OIDC Native +For web command the Okta AWS CLI requires an OIE organization and an [OIDC Native Application](https://developer.okta.com/blog/2021/11/12/native-sso) paired with an [Okta AWS Federation integration application](https://www.okta.com/integrations/aws-account-federation/). The @@ -183,7 +192,7 @@ It is on our feature backlog to get support into the Okta API to allow the multiple AWS Fed apps feature into okta-aws-cli without needing this work around using a custom admin role. -## M2M mode +## M2M Command ```shell $ okta-aws-cli m2m @@ -192,7 +201,7 @@ export AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY export AWS_SESSION_TOKEN=AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5T... ``` -M2M mode is headless machine to machine authorization. The operator executes +M2M command is headless machine to machine authorization. The operator executes `okta-aws-cli m2m` which has access to a private key whose public key is registered in an Okta API service application. `okta-aws-cli m2m` signs a request for an Okta access token that is associated with the Okta service @@ -215,7 +224,7 @@ format. The `Session Token` has a default expiry of 60 minutes. -### M2M Mode Requirements +### M2M Command Requirements M2M is an integration of: @@ -323,10 +332,13 @@ in US govcloud and other non-North America regions. Also see the CLI's online help `$ okta-aws-cli --help` -These global settings are all optional: +These global settings are optional unless marked otherwise: | Name | Description | Command line flag | ENV var and .env file value | |-----|-----|-----|-----| +| Okta Org Domain (**required**) | Full host and domain name of the Okta org e.g. `test.okta.com` or the custom domain value | `--org-domain [value]` | `OKTA_ORG_DOMAIN` | +| OIDC Client ID (**required**) | For `web` the OIDC native application / [Allowed Web SSO Client ID](#allowed-web-sso-client-id), for `m2m` the API services app ID | `--oidc-client-id [value]` | `OKTA_OIDC_CLIENT_ID` | +| AWS IAM Role ARN (**optional** for `web`, **required** for `m2m`) | For web preselects the role list to this preferred IAM role for the given IAM Identity Provider. For `m2m` | `--aws-iam-role [value]` | `OKTA_AWSCLI_IAM_ROLE` | | AWS Session Duration | The lifetime, in seconds, of the AWS credentials. Must be between 60 and 43200. | `--session-duration [value]` | `OKTA_AWSCLI_SESSION_DURATION` | | Output format | Default is `env-var`. Options: `env-var` for output to environment variables, `aws-credentials` for output to AWS credentials file, `process-credentials` for credentials as JSON | `--format [value]` | `OKTA_AWSCLI_FORMAT` | | Profile | Default is `default` | `--profile [value]` | `OKTA_AWSCLI_PROFILE` | @@ -338,28 +350,23 @@ These global settings are all optional: | Print operational information to the screen for debugging purposes | `true` if flag is present | `--debug` | `OKTA_AWSCLI_DEBUG=true` | | Verbosely print all API calls/responses to the screen | `true` if flag is present | `--debug-api-calls` | `OKTA_AWSCLI_DEBUG_API_CALLS=true` | | HTTP/HTTPS Proxy support | HTTP/HTTPS URL of proxy service (based on golang [net/http/httpproxy](https://pkg.go.dev/golang.org/x/net/http/httpproxy) package) | n/a | `HTTP_PROXY` or `HTTPS_PROXY` | -| Debug okta.yaml config file and exit | `true` if flag is present | `--debug-config` | `OKTA_AWSCLI_DEBUG_CONFIG=true` | -### Web mode settings +### Web command settings If the OIDC Native App doesn't also have the `okta.apps.read` grant the client ID of the [Okta AWS Federation](https://www.okta.com/integrations/aws-account-federation/) integration application is also required. +These settings are all optional: + | Name | Description | Command line flag | ENV var and .env file value | |-----|-----|-----|-----| -| Okta Org Domain (**required**) | Full host and domain name of the Okta org e.g. `test.okta.com` or the custom domain value | `--org-domain [value]` | `OKTA_ORG_DOMAIN` | -| OIDC Client ID (**required**) | The OIDC native application / [Allowed Web SSO Client ID](#allowed-web-sso-client-id) | `--oidc-client-id [value]` | `OKTA_OIDC_CLIENT_ID` | -| Okta AWS Account Federation integration app ID (optional) | See [AWS Account Federation integration app](#aws-account-federation-integration-app). This value is only required if the OIDC app doesn't have the `okta.apps.read` grant for whatever reason | `--aws-acct-fed-app-id [value]` | `OKTA_AWS_ACCOUNT_FEDERATION_APP_ID` | -| Preselect the AWS IAM Identity Provider ARN (optional) | Preselects the IdP list to this preferred IAM Identity Provider. If there are other IdPs available they will not be listed. | `--aws-iam-idp [value]` | `OKTA_AWSCLI_IAM_IDP` | - -| Preselects the AWS IAM Role ARN to assume (optional) | Preselects the role list to this preferred IAM role for the given IAM Identity Provider. If there are other Roles available they will not be listed. | `--aws-iam-role [value]` | `OKTA_AWSCLI_IAM_ROLE` | -| Display QR Code (optional) | `true` if flag is present | `--qr-code` | `OKTA_AWSCLI_QR_CODE=true` | -| Automatically open the activation URL with the system web browser (optional) | `true` if flag is present | `--open-browser` | `OKTA_AWSCLI_OPEN_BROWSER=true` | - - +| Okta AWS Account Federation integration app ID | See [AWS Account Federation integration app](#aws-account-federation-integration-app). This value is only required if the OIDC app doesn't have the `okta.apps.read` grant for whatever reason | `--aws-acct-fed-app-id [value]` | `OKTA_AWS_ACCOUNT_FEDERATION_APP_ID` | +| AWS IAM Identity Provider ARN | Preselects the IdP list to this preferred IAM Identity Provider. If there are other IdPs available they will not be listed. | `--aws-iam-idp [value]` | `OKTA_AWSCLI_IAM_IDP` | +| Display QR Code | `true` if flag is present | `--qr-code` | `OKTA_AWSCLI_QR_CODE=true` | +| Automatically open the activation URL with the system web browser | `true` if flag is present | `--open-browser` | `OKTA_AWSCLI_OPEN_BROWSER=true` | #### Allowed Web SSO Client ID @@ -414,15 +421,14 @@ $ okta-aws-cli web --org-domain test.okta.com \ --aws-acct-fed-app-id 0oa9x1rifa2H6Q5d8325 ``` -### M2M mode settings +### M2M command settings + +These settings are optional unless marked otherwise: | Name | Description | Command line flag | ENV var and .env file value | |-----|-----|-----|-----| -| Okta Org Domain (**required**) | Full host and domain name of the Okta org e.g. `test.okta.com` or the custom domain value | `--org-domain [value]` | `OKTA_ORG_DOMAIN` | -| OIDC Client ID (**required**) | The API services app ID | `--oidc-client-id [value]` | `OKTA_OIDC_CLIENT_ID` | -| AWS IAM Role ARN (required) | Preselects the role list to this preferred IAM role for the given IAM Identity Provider. | `--aws-iam-role [value]` | `OKTA_AWSCLI_IAM_ROLE` | | Private Key (**required**) | PEM or JWKS format private key whose public key is stored on the service app | `--private-key [value]` | `OKTA_AWSCLI_PRIVATE_KEY` | -| Custom scope name (optional) | The custom scope established in the custom authorization server. Default `okta-aws-cli` | `--custom-scope [value]` | `OKTA_AWSCLI_CUSTOM_SCOPE` | +| Custom scope name | The custom scope established in the custom authorization server. Default `okta-aws-cli` | `--custom-scope [value]` | `OKTA_AWSCLI_CUSTOM_SCOPE` | ### Friendly IdP and Role menu labels diff --git a/cmd/okta-aws-cli/main.go b/cmd/okta-aws-cli/main.go index e518e74..aa6ea6a 100644 --- a/cmd/okta-aws-cli/main.go +++ b/cmd/okta-aws-cli/main.go @@ -21,5 +21,6 @@ import ( ) func main() { - root.Execute() + defaultCommand := "web" + root.Execute(defaultCommand) } diff --git a/cmd/root/debug/debug.go b/cmd/root/debug/debug.go new file mode 100644 index 0000000..fedf5c8 --- /dev/null +++ b/cmd/root/debug/debug.go @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2023-Present, Okta, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package debug + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/okta/okta-aws-cli/internal/config" +) + +func NewDebugCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "debug", + Short: "Simple debug of okta.yaml and exit", + RunE: func(cmd *cobra.Command, args []string) error { + config, err := config.EvaluateSettings() + if err != nil { + return err + } + err = config.RunConfigChecks() + // NOTE: still print out the done message, even if there was an error it will get printed as well + fmt.Fprintf(os.Stderr, "debugging okta-aws-cli config $HOME/.okta/okta.yaml is complete\n") + if err != nil { + return err + } + return nil + }, + } + + return cmd +} diff --git a/cmd/root/m2m/m2m.go b/cmd/root/m2m/m2m.go new file mode 100644 index 0000000..0126346 --- /dev/null +++ b/cmd/root/m2m/m2m.go @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2023-Present, Okta, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package m2m + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/okta/okta-aws-cli/internal/config" + "github.com/okta/okta-aws-cli/internal/flag" + cliFlag "github.com/okta/okta-aws-cli/internal/flag" +) + +var ( + flags = []cliFlag.Flag{ + { + Name: config.PrivateKeyFlag, + Short: "k", + Value: "", + Usage: "Private Key", + EnvVar: config.PrivateKeyEnvVar, + }, + { + Name: config.CustomScopeFlag, + Short: "m", + Value: "okta-aws-cli", + Usage: "Custom Scope", + EnvVar: config.CustomScopeEnvVar, + }, + } + requiredFlags = []string{"org-domain", "oidc-client-id", "aws-iam-role", "private-key"} +) + +func NewM2MCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "m2m", + Short: "Machine to machine / headless authorization", + RunE: func(cmd *cobra.Command, args []string) error { + config, err := config.EvaluateSettings() + if err != nil { + return err + } + err = flag.CheckRequiredFlags(requiredFlags, cmd) + if err != nil { + return err + } + + fmt.Fprintf(os.Stderr, "WIP - m2m, get to work!\n") + fmt.Fprintf(os.Stderr, "Okta Org Domain: %s\n", config.OrgDomain()) + fmt.Fprintf(os.Stderr, "OIDC Client ID: %s\n", config.OIDCAppID()) + fmt.Fprintf(os.Stderr, "IAM Role ARN: %s\n", config.AWSIAMRole()) + fmt.Fprintf(os.Stderr, "Private Key: %s\n", config.PrivateKey()) + fmt.Fprintf(os.Stderr, "Custom Scope: %s\n", config.CustomScope()) + + return nil + }, + } + + cliFlag.MakeFlagBindings(cmd, flags, false) + + return cmd +} diff --git a/cmd/root/root.go b/cmd/root/root.go index 662ab41..74763ec 100644 --- a/cmd/root/root.go +++ b/cmd/root/root.go @@ -17,33 +17,28 @@ package root import ( - "errors" "fmt" "os" "path/filepath" - "strings" "github.com/spf13/cobra" - "github.com/spf13/viper" + debugCmd "github.com/okta/okta-aws-cli/cmd/root/debug" + "github.com/okta/okta-aws-cli/cmd/root/m2m" + "github.com/okta/okta-aws-cli/cmd/root/web" "github.com/okta/okta-aws-cli/internal/ansi" "github.com/okta/okta-aws-cli/internal/config" - "github.com/okta/okta-aws-cli/internal/sessiontoken" + cliFlag "github.com/okta/okta-aws-cli/internal/flag" ) const ( dotEnvFilename = ".env" ) -type flag struct { - name string - short string - value interface{} - usage string - envVar string -} - -var flags []flag +var ( + flags []cliFlag.Flag + rootCmd *cobra.Command +) func init() { var awsCredentialsFilename string @@ -51,211 +46,162 @@ func init() { awsCredentialsFilename = filepath.Join(home, ".aws", "credentials") } - flags = []flag{ - { - name: config.OrgDomainFlag, - short: "o", - value: "", - usage: "Okta Org Domain", - envVar: config.OktaOrgDomainEnvVar, - }, - { - name: config.OIDCClientIDFlag, - short: "c", - value: "", - usage: "OIDC Client ID", - envVar: config.OktaOIDCClientIDEnvVar, - }, - { - name: config.AWSAcctFedAppIDFlag, - short: "a", - value: "", - usage: "AWS Account Federation app ID", - envVar: config.OktaAWSAccountFederationAppIDEnvVar, - }, + flags = []cliFlag.Flag{ { - name: config.AWSIAMIdPFlag, - short: "i", - value: "", - usage: "Preset IAM Identity Provider ARN", - envVar: config.AWSIAMIdPEnvVar, + Name: config.OrgDomainFlag, + Short: "o", + Value: "", + Usage: "Okta Org Domain", + EnvVar: config.OktaOrgDomainEnvVar, }, { - name: config.AWSIAMRoleFlag, - short: "r", - value: "", - usage: "Preset IAM Role ARN", - envVar: config.AWSIAMRoleEnvVar, + Name: config.OIDCClientIDFlag, + Short: "c", + Value: "", + Usage: "OIDC Client ID - web: OIDC native application, m2m: API service application", + EnvVar: config.OktaOIDCClientIDEnvVar, }, { - name: config.SessionDurationFlag, - short: "s", - value: "", - usage: "Session duration for role.", - envVar: config.AWSSessionDurationEnvVar, + Name: config.AWSIAMRoleFlag, + Short: "r", + Value: "", + Usage: "Preset IAM Role ARN", + EnvVar: config.AWSIAMRoleEnvVar, }, { - name: config.ProfileFlag, - short: "p", - value: "", - usage: "AWS Profile", - envVar: config.ProfileEnvVar, + Name: config.SessionDurationFlag, + Short: "s", + Value: "", + Usage: "Session duration for role.", + EnvVar: config.AWSSessionDurationEnvVar, }, { - name: config.FormatFlag, - short: "f", - value: "", - usage: "Output format. [env-var|aws-credentials]", - envVar: config.FormatEnvVar, + Name: config.ProfileFlag, + Short: "p", + Value: "", + Usage: "AWS Profile", + EnvVar: config.ProfileEnvVar, }, { - name: config.QRCodeFlag, - short: "q", - value: false, - usage: "Print QR Code of activation URL", - envVar: config.QRCodeEnvVar, + Name: config.FormatFlag, + Short: "f", + Value: "", + Usage: "Output format. [env-var|aws-credentials]", + EnvVar: config.FormatEnvVar, }, { - name: config.AWSCredentialsFlag, - short: "w", - value: awsCredentialsFilename, - usage: fmt.Sprintf("Path to AWS credentials file, only valid with format %q", config.AWSCredentialsFormat), - envVar: config.AWSCredentialsEnvVar, + Name: config.AWSCredentialsFlag, + Short: "w", + Value: awsCredentialsFilename, + Usage: fmt.Sprintf("Path to AWS credentials file, only valid with format %q", config.AWSCredentialsFormat), + EnvVar: config.AWSCredentialsEnvVar, }, { - name: config.OpenBrowserFlag, - short: "b", - value: false, - usage: "Automatically open the activation URL with the system web browser", - envVar: config.OpenBrowserEnvVar, + Name: config.WriteAWSCredentialsFlag, + Short: "z", + Value: false, + Usage: fmt.Sprintf("Write the created/updated profile to the %q file. WARNING: This can inadvertently remove dangling comments and extraneous formatting from the creds file.", awsCredentialsFilename), + EnvVar: config.WriteAWSCredentialsEnvVar, }, { - name: config.WriteAWSCredentialsFlag, - short: "z", - value: false, - usage: fmt.Sprintf("Write the created/updated profile to the %q file. WARNING: This can inadvertently remove dangling comments and extraneous formatting from the creds file.", awsCredentialsFilename), - envVar: config.WriteAWSCredentialsEnvVar, + Name: config.LegacyAWSVariablesFlag, + Short: "l", + Value: false, + Usage: "Emit deprecated AWS Security Token value. WARNING: AWS CLI deprecated this value in November 2014 and is no longer documented", + EnvVar: config.LegacyAWSVariablesEnvVar, }, { - name: config.LegacyAWSVariablesFlag, - short: "l", - value: false, - usage: "Emit deprecated AWS Security Token value. WARNING: AWS CLI deprecated this value in November 2014 and is no longer documented", - envVar: config.LegacyAWSVariablesEnvVar, + Name: config.ExpiryAWSVariablesFlag, + Short: "x", + Value: false, + Usage: "Emit x_security_token_expires value in profile block of AWS credentials file", + EnvVar: config.ExpiryAWSVariablesEnvVar, }, { - name: config.ExpiryAWSVariablesFlag, - short: "x", - value: false, - usage: "Emit x_security_token_expires value in profile block of AWS credentials file", - envVar: config.ExpiryAWSVariablesEnvVar, + Name: config.CacheAccessTokenFlag, + Short: "e", + Value: false, + Usage: "Cache Okta access token to reduce need for opening grant URL", + EnvVar: config.CacheAccessTokenEnvVar, }, { - name: config.CacheAccessTokenFlag, - short: "e", - value: false, - usage: "Cache Okta access token to reduce need for opening grant URL", - envVar: config.CacheAccessTokenEnvVar, + Name: config.DebugFlag, + Short: "g", + Value: false, + Usage: "Print operational information to the screen for debugging purposes", + EnvVar: config.DebugEnvVar, }, { - name: config.DebugFlag, - short: "g", - value: false, - usage: "Print operational information to the screen for debugging purposes", - envVar: config.DebugEnvVar, - }, - { - name: config.DebugAPICallsFlag, - short: "d", - value: false, - usage: "Verbosely print all API calls/responses to the screen", - envVar: config.DebugAPICallsEnvVar, - }, - { - name: config.DebugConfigFlag, - short: "k", - value: false, - usage: "Inspect current okta.yaml configuration and exit", - envVar: config.DebugConfigEnvVar, + Name: config.DebugAPICallsFlag, + Short: "d", + Value: false, + Usage: "Verbosely print all API calls/responses to the screen", + EnvVar: config.DebugAPICallsEnvVar, }, } + + rootCmd = NewRootCommand() + webCmd := web.NewWebCommand() + rootCmd.AddCommand(webCmd) + m2mCmd := m2m.NewM2MCommand() + rootCmd.AddCommand(m2mCmd) + debugCfgCmd := debugCmd.NewDebugCommand() + rootCmd.AddCommand(debugCfgCmd) } -func buildRootCommand() *cobra.Command { +func NewRootCommand() *cobra.Command { cmd := &cobra.Command{ Version: config.Version, Use: "okta-aws-cli", - Short: "okta-aws-cli - Okta federated identity for AWS CLI", - Long: `okta-aws-cli - Okta federated identity for AWS CLI - -Okta authentication for federated identity providers in support of AWS CLI. -okta-aws-cli handles authentication to the IdP and token exchange with AWS STS -to collect a proper IAM role for the AWS CLI operator.`, - - RunE: func(cmd *cobra.Command, args []string) error { - config, err := config.CreateConfig() - if err == nil && config.DebugConfig() { - checkErr := config.RunConfigChecks() - fmt.Fprintf(os.Stderr, "debugging okta-aws-cli config $HOME/.okta/okta.yaml is complete\n") - return checkErr - } - if err != nil { - return err - } + Short: "Okta federated identity for AWS CLI", + Long: `Okta federated identity for AWS CLI - st, err := sessiontoken.NewSessionToken(config) - if err != nil { - return err - } - return st.EstablishToken() - }, +Okta authentication in support of AWS CLI. okta-aws-cli handles authentication +with Okta and token exchange with AWS STS to collect temporary IAM credentials +associated with a given IAM Role for the AWS CLI operator.`, } - // bind env vars - for _, f := range flags { - _ = viper.BindEnv(f.envVar, f.name) - } - // bind env vars via dotenv if it exists - path, _ := os.Getwd() - dotEnv := filepath.Join(path, dotEnvFilename) - if _, err := os.Stat(dotEnv); err == nil || !errors.Is(err, os.ErrNotExist) { - viper.AddConfigPath(path) - viper.SetConfigName(dotEnvFilename) - viper.SetConfigType("dotenv") + cmd.SetUsageTemplate(resourceUsageTemplate()) + cliFlag.MakeFlagBindings(cmd, flags, true) - _ = viper.ReadInConfig() + return cmd +} - // After viper reads in the dotenv file check if AWS_REGION is set - // there. The value will be keyed by lower case name. If it is, set - // AWS_REGION as an ENV VAR if it hasn't already been. - awsRegionEnvVar := "AWS_REGION" - vipAwsRegion := viper.GetString(strings.ToLower(awsRegionEnvVar)) - if vipAwsRegion != "" && os.Getenv(awsRegionEnvVar) == "" { - _ = os.Setenv(awsRegionEnvVar, vipAwsRegion) +// Execute executes the root command +func Execute(defaultCommand string) { + // cmdFound is used to determine if we were called without a subcommand + // argument, and if so, treat the default command as if it was called as a + // sub command. + var cmdFound bool + + // If the sub command is registered we don't need to alias the default + // command with an append below. + for _, cmd := range rootCmd.Commands() { + for _, arg := range os.Args[1:] { + if cmd.Name() == arg { + cmdFound = true + break + } } } - viper.AutomaticEnv() - // bind cli flags - for _, f := range flags { - 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) + // Also, consider the command found if our args is just a bare help so help + // for both sub commands is printed. + if len(os.Args) == 1 { + cmdFound = true + } + if len(os.Args) >= 2 { + if arg := os.Args[1]; arg == "--help" || arg == "-h" || arg == "help" { + cmdFound = true } - - _ = viper.BindPFlag(f.name, cmd.PersistentFlags().Lookup(f.name)) + } + if !cmdFound { + args := append([]string{defaultCommand}, os.Args[1:]...) + rootCmd.SetArgs(args) } - cmd.SetUsageTemplate(resourceUsageTemplate()) - return cmd -} - -// Execute executes the root command -func Execute() { - cmd := buildRootCommand() - if err := cmd.Execute(); err != nil { + // Get to work ... + if err := rootCmd.Execute(); err != nil { os.Exit(1) } } diff --git a/cmd/root/web/web.go b/cmd/root/web/web.go new file mode 100644 index 0000000..5b237cd --- /dev/null +++ b/cmd/root/web/web.go @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2023-Present, Okta, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package web + +import ( + "github.com/spf13/cobra" + + "github.com/okta/okta-aws-cli/internal/config" + "github.com/okta/okta-aws-cli/internal/flag" + cliFlag "github.com/okta/okta-aws-cli/internal/flag" + "github.com/okta/okta-aws-cli/internal/sessiontoken" +) + +const ( + dotEnvFilename = ".env" +) + +var ( + flags = []cliFlag.Flag{ + { + Name: config.AWSAcctFedAppIDFlag, + Short: "a", + Value: "", + Usage: "AWS Account Federation app ID", + EnvVar: config.OktaAWSAccountFederationAppIDEnvVar, + }, + { + Name: config.AWSIAMIdPFlag, + Short: "i", + Value: "", + Usage: "Preset IAM Identity Provider ARN", + EnvVar: config.AWSIAMIdPEnvVar, + }, + { + Name: config.QRCodeFlag, + Short: "q", + Value: false, + Usage: "Print QR Code of activation URL", + EnvVar: config.QRCodeEnvVar, + }, + { + Name: config.OpenBrowserFlag, + Short: "b", + Value: false, + Usage: "Automatically open the activation URL with the system web browser", + EnvVar: config.OpenBrowserEnvVar, + }, + } + requiredFlags = []string{"org-domain", "oidc-client-id"} +) + +func NewWebCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "web", + Short: "Human oriented authentication and device authorization", + RunE: func(cmd *cobra.Command, args []string) error { + config, err := config.EvaluateSettings() + if err != nil { + return err + } + err = flag.CheckRequiredFlags(requiredFlags, cmd) + if err != nil { + return err + } + + st, err := sessiontoken.NewSessionToken(config) + if err != nil { + return err + } + return st.EstablishToken() + }, + } + + cliFlag.MakeFlagBindings(cmd, flags, false) + + return cmd +} diff --git a/internal/config/config.go b/internal/config/config.go index 0f3776f..dac529f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -46,12 +46,12 @@ const ( AWSIAMIdPFlag = "aws-iam-idp" // AWSIAMRoleFlag cli flag const AWSIAMRoleFlag = "aws-iam-role" + // CustomScope cli flag const + CustomScopeFlag = "custom-scope" // DebugFlag cli flag const DebugFlag = "debug" // DebugAPICallsFlag cli flag const DebugAPICallsFlag = "debug-api-calls" - // DebugConfigFlag cli flag const - DebugConfigFlag = "debug-config" // FormatFlag cli flag const FormatFlag = "format" // OIDCClientIDFlag cli flag const @@ -60,6 +60,8 @@ const ( OpenBrowserFlag = "open-browser" // OrgDomainFlag cli flag const OrgDomainFlag = "org-domain" + // PrivateKeyFlag cli flag const + PrivateKeyFlag = "private-key" // ProfileFlag cli flag const ProfileFlag = "profile" // QRCodeFlag cli flag const @@ -83,8 +85,20 @@ const ( AWSIAMRoleEnvVar = "OKTA_AWSCLI_IAM_ROLE" // AWSSessionDurationEnvVar env var const AWSSessionDurationEnvVar = "OKTA_AWSCLI_SESSION_DURATION" + // CacheAccessTokenEnvVar env var const + CacheAccessTokenEnvVar = "OKTA_AWSCLI_CACHE_ACCESS_TOKEN" + // CustomScopeEnvVar env var const + CustomScopeEnvVar = "OKTA_AWSCLI_CUSTOM_SCOPE" + // DebugEnvVar env var const + DebugEnvVar = "OKTA_AWSCLI_DEBUG" + // DebugAPICallsEnvVar env var const + DebugAPICallsEnvVar = "OKTA_AWSCLI_DEBUG_API_CALLS" + // ExpiryAWSVariablesEnvVar env var const + ExpiryAWSVariablesEnvVar = "OKTA_AWSCLI_EXPIRY_AWS_VARIABLES" // FormatEnvVar env var const FormatEnvVar = "OKTA_AWSCLI_FORMAT" + // LegacyAWSVariablesEnvVar env var const + LegacyAWSVariablesEnvVar = "OKTA_AWSCLI_LEGACY_AWS_VARIABLES" // OktaOIDCClientIDEnvVar env var const OktaOIDCClientIDEnvVar = "OKTA_OIDC_CLIENT_ID" // OktaOrgDomainEnvVar env var const @@ -93,24 +107,14 @@ const ( OktaAWSAccountFederationAppIDEnvVar = "OKTA_AWS_ACCOUNT_FEDERATION_APP_ID" // OpenBrowserEnvVar env var const OpenBrowserEnvVar = "OKTA_AWSCLI_OPEN_BROWSER" + // PrivateKeyEnvVar env var const + PrivateKeyEnvVar = "OKTA_AWSCLI_PRIVATE_KEY" // ProfileEnvVar env var const ProfileEnvVar = "OKTA_AWSCLI_PROFILE" // QRCodeEnvVar env var const QRCodeEnvVar = "OKTA_AWSCLI_QR_CODE" // WriteAWSCredentialsEnvVar env var const WriteAWSCredentialsEnvVar = "OKTA_AWSCLI_WRITE_AWS_CREDENTIALS" - // DebugEnvVar env var const - DebugEnvVar = "OKTA_AWSCLI_DEBUG" - // DebugAPICallsEnvVar env var const - DebugAPICallsEnvVar = "OKTA_AWSCLI_DEBUG_API_CALLS" - // DebugConfigEnvVar env var const - DebugConfigEnvVar = "OKTA_AWSCLI_DEBUG_CONFIG" - // LegacyAWSVariablesEnvVar env var const - LegacyAWSVariablesEnvVar = "OKTA_AWSCLI_LEGACY_AWS_VARIABLES" - // ExpiryAWSVariablesEnvVar env var const - ExpiryAWSVariablesEnvVar = "OKTA_AWSCLI_EXPIRY_AWS_VARIABLES" - // CacheAccessTokenEnvVar env var const - CacheAccessTokenEnvVar = "OKTA_AWSCLI_CACHE_ACCESS_TOKEN" // CannotBeBlankErrMsg error message const CannotBeBlankErrMsg = "cannot be blank" @@ -123,64 +127,71 @@ const ( OktaYaml = "okta.yaml" ) +// OktaYamlConfig represents config settings from $HOME/.okta/okta.yaml +type OktaYamlConfig struct { + AWSCLI struct { + IDPS map[string]string `yaml:"idps"` + ROLES map[string]string `yaml:"roles"` + } `yaml:"awscli"` +} + // Config A config object for the CLI +// +// External consumers of Config use its setters and getters to interact with the +// underlying data values encapsulated on the Attribute. This allows Config to +// control data access, be concerned with evaluation, validation, and not +// allowing direct access to values as is done on structs in the generic case. type Config struct { - orgDomain string - oidcAppID string - fedAppID string + awsCredentials string awsIAMIdP string awsIAMRole string awsSessionDuration int64 + cacheAccessToken bool + customScope string + debug bool + debugAPICalls bool + expiryAWSVariables bool + fedAppID string format string + httpClient *http.Client + legacyAWSVariables bool + oidcAppID string + openBrowser bool + orgDomain string + privateKey string profile string qrCode bool - awsCredentials string writeAWSCredentials bool - openBrowser bool +} + +// attributes config construction +type attributes struct { + awsCredentials string + awsIAMIdP string + awsIAMRole string + awsSessionDuration int64 + cacheAccessToken bool + customScope string debug bool debugAPICalls bool - debugConfig bool - legacyAWSVariables bool expiryAWSVariables bool - cacheAccessToken bool - httpClient *http.Client -} - -// OktaYamlConfig represents config settings from $HOME/.okta/okta.yaml -type OktaYamlConfig struct { - AWSCLI struct { - IDPS map[string]string `yaml:"idps"` - ROLES map[string]string `yaml:"roles"` - } `yaml:"awscli"` + fedAppID string + format string + legacyAWSVariables bool + oidcAppID string + openBrowser bool + orgDomain string + privateKey string + profile string + qrCode bool + writeAWSCredentials bool } -// Attributes config construction -type Attributes struct { - OrgDomain string - OIDCAppID string - FedAppID string - AWSIAMIdP string - AWSIAMRole string - AWSSessionDuration int64 - Format string - Profile string - QRCode bool - AWSCredentials string - WriteAWSCredentials bool - OpenBrowser bool - Debug bool - DebugAPICalls bool - DebugConfig bool - LegacyAWSVariables bool - ExpiryAWSVariables bool - CacheAccessToken bool -} - -// CreateConfig Creates a new config gathering values in this order of precedence: +// EvaluateSettings Returns a new config gathering values in this order of precedence: // 1. CLI flags // 2. ENV variables // 3. .env file -func CreateConfig() (*Config, error) { +func EvaluateSettings() (*Config, error) { cfgAttrs, err := readConfig() if err != nil { return nil, err @@ -189,37 +200,35 @@ func CreateConfig() (*Config, error) { } // NewConfig create config from attributes -func NewConfig(attrs Attributes) (*Config, error) { +func NewConfig(attrs attributes) (*Config, error) { var err error cfg := &Config{ - fedAppID: attrs.FedAppID, - awsIAMIdP: attrs.AWSIAMIdP, - awsIAMRole: attrs.AWSIAMRole, - format: attrs.Format, - profile: attrs.Profile, - qrCode: attrs.QRCode, - awsCredentials: attrs.AWSCredentials, - writeAWSCredentials: attrs.WriteAWSCredentials, - openBrowser: attrs.OpenBrowser, - debug: attrs.Debug, - debugAPICalls: attrs.DebugAPICalls, - debugConfig: attrs.DebugConfig, - legacyAWSVariables: attrs.LegacyAWSVariables, - expiryAWSVariables: attrs.ExpiryAWSVariables, - cacheAccessToken: attrs.CacheAccessToken, - } - if attrs.DebugConfig { - return cfg, nil - } - err = cfg.SetOrgDomain(attrs.OrgDomain) + awsCredentials: attrs.awsCredentials, + awsIAMIdP: attrs.awsIAMIdP, + awsIAMRole: attrs.awsIAMRole, + cacheAccessToken: attrs.cacheAccessToken, + customScope: attrs.customScope, + debug: attrs.debug, + debugAPICalls: attrs.debugAPICalls, + expiryAWSVariables: attrs.expiryAWSVariables, + fedAppID: attrs.fedAppID, + format: attrs.format, + legacyAWSVariables: attrs.legacyAWSVariables, + openBrowser: attrs.openBrowser, + privateKey: attrs.privateKey, + profile: attrs.profile, + qrCode: attrs.qrCode, + writeAWSCredentials: attrs.writeAWSCredentials, + } + err = cfg.SetOrgDomain(attrs.orgDomain) if err != nil { return nil, err } - err = cfg.SetOIDCAppID(attrs.OIDCAppID) + err = cfg.SetOIDCAppID(attrs.oidcAppID) if err != nil { return nil, err } - err = cfg.SetAWSSessionDuration(attrs.AWSSessionDuration) + err = cfg.SetAWSSessionDuration(attrs.awsSessionDuration) if err != nil { return nil, err } @@ -234,123 +243,130 @@ func NewConfig(attrs Attributes) (*Config, error) { return cfg, nil } -func readConfig() (Attributes, error) { - attrs := Attributes{ - AWSCredentials: viper.GetString(AWSCredentialsFlag), - AWSIAMIdP: viper.GetString(AWSIAMIdPFlag), - AWSIAMRole: viper.GetString(AWSIAMRoleFlag), - AWSSessionDuration: viper.GetInt64(SessionDurationFlag), - Debug: viper.GetBool(DebugFlag), - DebugAPICalls: viper.GetBool(DebugAPICallsFlag), - DebugConfig: viper.GetBool(DebugConfigFlag), - FedAppID: viper.GetString(AWSAcctFedAppIDFlag), - Format: viper.GetString(FormatFlag), - LegacyAWSVariables: viper.GetBool(LegacyAWSVariablesFlag), - ExpiryAWSVariables: viper.GetBool(ExpiryAWSVariablesFlag), - CacheAccessToken: viper.GetBool(CacheAccessTokenFlag), - OIDCAppID: viper.GetString(OIDCClientIDFlag), - OpenBrowser: viper.GetBool(OpenBrowserFlag), - OrgDomain: viper.GetString(OrgDomainFlag), - Profile: viper.GetString(ProfileFlag), - QRCode: viper.GetBool(QRCodeFlag), - WriteAWSCredentials: viper.GetBool(WriteAWSCredentialsFlag), - } - if attrs.Format == "" { - attrs.Format = EnvVarFormat +func readConfig() (attributes, error) { + attrs := attributes{ + awsCredentials: viper.GetString(AWSCredentialsFlag), + awsIAMIdP: viper.GetString(AWSIAMIdPFlag), + awsIAMRole: viper.GetString(AWSIAMRoleFlag), + awsSessionDuration: viper.GetInt64(SessionDurationFlag), + customScope: viper.GetString(CustomScopeFlag), + debug: viper.GetBool(DebugFlag), + debugAPICalls: viper.GetBool(DebugAPICallsFlag), + fedAppID: viper.GetString(AWSAcctFedAppIDFlag), + format: viper.GetString(FormatFlag), + legacyAWSVariables: viper.GetBool(LegacyAWSVariablesFlag), + expiryAWSVariables: viper.GetBool(ExpiryAWSVariablesFlag), + cacheAccessToken: viper.GetBool(CacheAccessTokenFlag), + oidcAppID: viper.GetString(OIDCClientIDFlag), + openBrowser: viper.GetBool(OpenBrowserFlag), + orgDomain: viper.GetString(OrgDomainFlag), + privateKey: viper.GetString(PrivateKeyFlag), + profile: viper.GetString(ProfileFlag), + qrCode: viper.GetBool(QRCodeFlag), + writeAWSCredentials: viper.GetBool(WriteAWSCredentialsFlag), + } + if attrs.format == "" { + attrs.format = EnvVarFormat } // mimic AWS CLI behavior, if profile value is not set by flag check // the ENV VAR, else set to "default" - if attrs.Profile == "" { - attrs.Profile = viper.GetString(downCase(ProfileEnvVar)) + if attrs.profile == "" { + attrs.profile = viper.GetString(downCase(ProfileEnvVar)) } - if attrs.Profile == "" { - attrs.Profile = "default" + if attrs.profile == "" { + attrs.profile = "default" } // Viper binds ENV VARs to a lower snake version, set the configs with them // if they haven't already been set by cli flag binding. - if attrs.OrgDomain == "" { - attrs.OrgDomain = viper.GetString(downCase(OktaOrgDomainEnvVar)) + if attrs.orgDomain == "" { + attrs.orgDomain = viper.GetString(downCase(OktaOrgDomainEnvVar)) + } + if attrs.oidcAppID == "" { + attrs.oidcAppID = viper.GetString(downCase(OktaOIDCClientIDEnvVar)) + } + if attrs.fedAppID == "" { + attrs.fedAppID = viper.GetString(downCase(OktaAWSAccountFederationAppIDEnvVar)) } - if attrs.OIDCAppID == "" { - attrs.OIDCAppID = viper.GetString(downCase(OktaOIDCClientIDEnvVar)) + if attrs.awsIAMIdP == "" { + attrs.awsIAMIdP = viper.GetString(downCase(AWSIAMIdPEnvVar)) } - if attrs.FedAppID == "" { - attrs.FedAppID = viper.GetString(downCase(OktaAWSAccountFederationAppIDEnvVar)) + if attrs.awsIAMRole == "" { + attrs.awsIAMRole = viper.GetString(downCase(AWSIAMRoleEnvVar)) } - if attrs.AWSIAMIdP == "" { - attrs.AWSIAMIdP = viper.GetString(downCase(AWSIAMIdPEnvVar)) + if !attrs.qrCode { + attrs.qrCode = viper.GetBool(downCase(QRCodeEnvVar)) } - if attrs.AWSIAMRole == "" { - attrs.AWSIAMRole = viper.GetString(downCase(AWSIAMRoleEnvVar)) + if attrs.privateKey == "" { + attrs.privateKey = viper.GetString(downCase(PrivateKeyEnvVar)) } - if !attrs.QRCode { - attrs.QRCode = viper.GetBool(downCase(QRCodeEnvVar)) + if attrs.customScope == "" { + attrs.customScope = viper.GetString(downCase(CustomScopeEnvVar)) } // if session duration is 0, inspect the ENV VAR for a value, else set // a default of 3600 - if attrs.AWSSessionDuration == 0 { - attrs.AWSSessionDuration = viper.GetInt64(downCase(AWSSessionDurationEnvVar)) + if attrs.awsSessionDuration == 0 { + attrs.awsSessionDuration = viper.GetInt64(downCase(AWSSessionDurationEnvVar)) } - if attrs.AWSSessionDuration == 0 { - attrs.AWSSessionDuration = 3600 + if attrs.awsSessionDuration == 0 { + attrs.awsSessionDuration = 3600 } // correct org domain if it's in admin form - orgDomain := strings.Replace(attrs.OrgDomain, "-admin", "", -1) - if orgDomain != attrs.OrgDomain { - fmt.Printf("WARNING: proactively correcting org domain %q to non-admin form %q.\n\n", attrs.OrgDomain, orgDomain) - attrs.OrgDomain = orgDomain + orgDomain := strings.Replace(attrs.orgDomain, "-admin", "", -1) + if orgDomain != attrs.orgDomain { + fmt.Fprintf(os.Stderr, "WARNING: proactively correcting org domain %q to non-admin form %q.\n\n", attrs.orgDomain, orgDomain) + attrs.orgDomain = orgDomain } - if strings.HasPrefix(attrs.OrgDomain, "http") { - u, err := url.Parse(attrs.OrgDomain) + if strings.HasPrefix(attrs.orgDomain, "http") { + u, err := url.Parse(attrs.orgDomain) // try to help correct org domain value if parsing occurs correctly, // else let the CLI error out else where if err == nil { orgDomain = u.Hostname() - fmt.Printf("WARNING: proactively correcting URL format org domain %q value to hostname only form %q.\n\n", attrs.OrgDomain, orgDomain) - attrs.OrgDomain = orgDomain + fmt.Fprintf(os.Stderr, "WARNING: proactively correcting URL format org domain %q value to hostname only form %q.\n\n", attrs.orgDomain, orgDomain) + attrs.orgDomain = orgDomain } } - if strings.HasSuffix(attrs.OrgDomain, "/") { - orgDomain = string([]byte(attrs.OrgDomain)[0 : len(attrs.OrgDomain)-1]) + if strings.HasSuffix(attrs.orgDomain, "/") { + orgDomain = string([]byte(attrs.orgDomain)[0 : len(attrs.orgDomain)-1]) // try to help correct malformed org domain value - fmt.Printf("WARNING: proactively correcting malformed org domain %q value to hostname only form %q.\n\n", attrs.OrgDomain, orgDomain) - attrs.OrgDomain = orgDomain + fmt.Fprintf(os.Stderr, "WARNING: proactively correcting malformed org domain %q value to hostname only form %q.\n\n", attrs.orgDomain, orgDomain) + attrs.orgDomain = orgDomain } // There is always a default aws credentials path set in root.go's init // function so overwrite the config value if the operator is attempting to // set it by ENV VAR value. if viper.GetString(downCase(AWSCredentialsEnvVar)) != "" { - attrs.AWSCredentials = viper.GetString(downCase(AWSCredentialsEnvVar)) + attrs.awsCredentials = viper.GetString(downCase(AWSCredentialsEnvVar)) } - if !attrs.WriteAWSCredentials { - attrs.WriteAWSCredentials = viper.GetBool(downCase(WriteAWSCredentialsEnvVar)) + if !attrs.writeAWSCredentials { + attrs.writeAWSCredentials = viper.GetBool(downCase(WriteAWSCredentialsEnvVar)) } - if attrs.WriteAWSCredentials { + if attrs.writeAWSCredentials { // writing aws creds option implies "aws-credentials" format - attrs.Format = AWSCredentialsFormat + attrs.format = AWSCredentialsFormat } - if !attrs.OpenBrowser { - attrs.OpenBrowser = viper.GetBool(downCase(OpenBrowserEnvVar)) + if !attrs.openBrowser { + attrs.openBrowser = viper.GetBool(downCase(OpenBrowserEnvVar)) } - if !attrs.Debug { - attrs.Debug = viper.GetBool(downCase(DebugEnvVar)) + if !attrs.debug { + attrs.debug = viper.GetBool(downCase(DebugEnvVar)) } - if !attrs.DebugAPICalls { - attrs.DebugAPICalls = viper.GetBool(downCase(DebugAPICallsEnvVar)) + if !attrs.debugAPICalls { + attrs.debugAPICalls = viper.GetBool(downCase(DebugAPICallsEnvVar)) } - if !attrs.LegacyAWSVariables { - attrs.LegacyAWSVariables = viper.GetBool(downCase(LegacyAWSVariablesEnvVar)) + if !attrs.legacyAWSVariables { + attrs.legacyAWSVariables = viper.GetBool(downCase(LegacyAWSVariablesEnvVar)) } - if !attrs.ExpiryAWSVariables { - attrs.ExpiryAWSVariables = viper.GetBool(downCase(ExpiryAWSVariablesEnvVar)) + if !attrs.expiryAWSVariables { + attrs.expiryAWSVariables = viper.GetBool(downCase(ExpiryAWSVariablesEnvVar)) } - if !attrs.CacheAccessToken { - attrs.CacheAccessToken = viper.GetBool(downCase(CacheAccessTokenEnvVar)) + if !attrs.cacheAccessToken { + attrs.cacheAccessToken = viper.GetBool(downCase(CacheAccessTokenEnvVar)) } return attrs, nil } @@ -360,42 +376,25 @@ func downCase(s string) string { return strings.ToLower(s) } -// OrgDomain -- -func (c *Config) OrgDomain() string { - return c.orgDomain -} - -// SetOrgDomain -- -func (c *Config) SetOrgDomain(domain string) error { - if domain == "" { - return NewValidationError(OrgDomainMsg, CannotBeBlankErrMsg) - } - c.orgDomain = domain - return nil -} - -// OIDCAppID -- -func (c *Config) OIDCAppID() string { - return c.oidcAppID +// AWSCredentials -- +func (c *Config) AWSCredentials() string { + return c.awsCredentials } -// SetOIDCAppID -- -func (c *Config) SetOIDCAppID(appID string) error { - if appID == "" { - return NewValidationError("OIDC App ID", CannotBeBlankErrMsg) - } - c.oidcAppID = appID +// SetAWSCredentials -- +func (c *Config) SetAWSCredentials(credentials string) error { + c.awsCredentials = credentials return nil } -// FedAppID -- -func (c *Config) FedAppID() string { - return c.fedAppID +// WriteAWSCredentials -- +func (c *Config) WriteAWSCredentials() bool { + return c.writeAWSCredentials } -// SetFedAppID -- -func (c *Config) SetFedAppID(appID string) error { - c.fedAppID = appID +// SetWriteAWSCredentials -- +func (c *Config) SetWriteAWSCredentials(writeCredentials bool) error { + c.writeAWSCredentials = writeCredentials return nil } @@ -428,109 +427,95 @@ func (c *Config) AWSSessionDuration() int64 { // SetAWSSessionDuration -- func (c *Config) SetAWSSessionDuration(duration int64) error { - if duration < 60 || duration > 43200 { - return NewValidationError("AWS Session Duration", "must be between 60 and 43200") - } c.awsSessionDuration = duration return nil } -// Format -- -func (c *Config) Format() string { - return c.format +// CacheAccessToken -- +func (c *Config) CacheAccessToken() bool { + return c.cacheAccessToken } -// SetFormat -- -func (c *Config) SetFormat(format string) error { - c.format = format +// SetCacheAccessToken -- +func (c *Config) SetCacheAccessToken(cacheAccessToken bool) error { + c.cacheAccessToken = cacheAccessToken return nil } -// Profile -- -func (c *Config) Profile() string { - return c.profile +// CustomScope -- +func (c *Config) CustomScope() string { + return c.customScope } -// SetProfile -- -func (c *Config) SetProfile(profile string) error { - c.profile = profile +// SetCustomScope -- +func (c *Config) SetCustomScope(customScope string) error { + c.customScope = customScope return nil } -// QRCode -- -func (c *Config) QRCode() bool { - return c.qrCode -} - -// SetQRCode -- -func (c *Config) SetQRCode(qrCode bool) error { - c.qrCode = qrCode - return nil -} - -// AWSCredentials -- -func (c *Config) AWSCredentials() string { - return c.awsCredentials +// Debug -- +func (c *Config) Debug() bool { + return c.debug } -// SetAWSCredentials -- -func (c *Config) SetAWSCredentials(credentials string) error { - c.awsCredentials = credentials +// SetDebug -- +func (c *Config) SetDebug(debug bool) error { + c.debug = debug return nil } -// WriteAWSCredentials -- -func (c *Config) WriteAWSCredentials() bool { - return c.writeAWSCredentials +// DebugAPICalls -- +func (c *Config) DebugAPICalls() bool { + return c.debugAPICalls } -// SetWriteAWSCredentials -- -func (c *Config) SetWriteAWSCredentials(writeCredentials bool) error { - c.writeAWSCredentials = writeCredentials +// SetDebugAPICalls -- +func (c *Config) SetDebugAPICalls(debugAPICalls bool) error { + c.debugAPICalls = debugAPICalls return nil } -// OpenBrowser -- -func (c *Config) OpenBrowser() bool { - return c.openBrowser +// ExpiryAWSVariables -- +func (c *Config) ExpiryAWSVariables() bool { + return c.expiryAWSVariables } -// SetOpenBrowser -- -func (c *Config) SetOpenBrowser(openBrowser bool) error { - c.openBrowser = openBrowser +// SetExpiryAWSVariables -- +func (c *Config) SetExpiryAWSVariables(expiryAWSVariables bool) error { + c.expiryAWSVariables = expiryAWSVariables return nil } -// Debug -- -func (c *Config) Debug() bool { - return c.debug +// FedAppID -- +func (c *Config) FedAppID() string { + return c.fedAppID } -// SetDebug -- -func (c *Config) SetDebug(debug bool) error { - c.debug = debug +// SetFedAppID -- +func (c *Config) SetFedAppID(appID string) error { + c.fedAppID = appID return nil } -// DebugAPICalls -- -func (c *Config) DebugAPICalls() bool { - return c.debugAPICalls +// Format -- +func (c *Config) Format() string { + return c.format } -// SetDebugAPICalls -- -func (c *Config) SetDebugAPICalls(debugAPICalls bool) error { - c.debugAPICalls = debugAPICalls +// SetFormat -- +func (c *Config) SetFormat(format string) error { + c.format = format return nil } -// DebugConfig -- -func (c *Config) DebugConfig() bool { - return c.debugConfig +// HTTPClient -- +func (c *Config) HTTPClient() *http.Client { + return c.httpClient } -// SetDebugConfig -- -func (c *Config) SetDebugConfig(debugConfig bool) error { - c.debugConfig = debugConfig +// SetHTTPClient -- +func (c *Config) SetHTTPClient(client *http.Client) error { + c.httpClient = client return nil } @@ -545,36 +530,69 @@ func (c *Config) SetLegacyAWSVariables(legacyAWSVariables bool) error { return nil } -// ExpiryAWSVariables -- -func (c *Config) ExpiryAWSVariables() bool { - return c.expiryAWSVariables +// OIDCAppID -- +func (c *Config) OIDCAppID() string { + return c.oidcAppID } -// SetExpiryAWSVariables -- -func (c *Config) SetExpiryAWSVariables(expiryAWSVariables bool) error { - c.expiryAWSVariables = expiryAWSVariables +// SetOIDCAppID -- +func (c *Config) SetOIDCAppID(appID string) error { + c.oidcAppID = appID return nil } -// CacheAccessToken -- -func (c *Config) CacheAccessToken() bool { - return c.cacheAccessToken +// OpenBrowser -- +func (c *Config) OpenBrowser() bool { + return c.openBrowser } -// SetCacheAccessToken -- -func (c *Config) SetCacheAccessToken(cacheAccessToken bool) error { - c.cacheAccessToken = cacheAccessToken +// SetOpenBrowser -- +func (c *Config) SetOpenBrowser(openBrowser bool) error { + c.openBrowser = openBrowser return nil } -// HTTPClient -- -func (c *Config) HTTPClient() *http.Client { - return c.httpClient +// OrgDomain -- +func (c *Config) OrgDomain() string { + return c.orgDomain } -// SetHTTPClient -- -func (c *Config) SetHTTPClient(client *http.Client) error { - c.httpClient = client +// SetOrgDomain -- +func (c *Config) SetOrgDomain(domain string) error { + c.orgDomain = domain + return nil +} + +// PrivateKey -- +func (c *Config) PrivateKey() string { + return c.privateKey +} + +// SetPrivateKey -- +func (c *Config) SetPrivateKey(privateKey string) error { + c.privateKey = privateKey + return nil +} + +// Profile -- +func (c *Config) Profile() string { + return c.profile +} + +// SetProfile -- +func (c *Config) SetProfile(profile string) error { + c.profile = profile + return nil +} + +// QRCode -- +func (c *Config) QRCode() bool { + return c.qrCode +} + +// SetQRCode -- +func (c *Config) SetQRCode(qrCode bool) error { + c.qrCode = qrCode return nil } diff --git a/internal/config/errors.go b/internal/config/errors.go deleted file mode 100644 index 3d2e740..0000000 --- a/internal/config/errors.go +++ /dev/null @@ -1,21 +0,0 @@ -package config - -import "fmt" - -// ValidationError -- -type ValidationError struct { - Field string - Message string -} - -// NewValidationError -- -func NewValidationError(field, msg string) *ValidationError { - return &ValidationError{ - Field: field, - Message: msg, - } -} - -func (e *ValidationError) Error() string { - return fmt.Sprintf(`ValidationError: Field="%s" Message="%s"`, e.Field, e.Message) -} diff --git a/internal/flag/flag.go b/internal/flag/flag.go new file mode 100644 index 0000000..a8a9d4d --- /dev/null +++ b/internal/flag/flag.go @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2022-Present, Okta, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package flag + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +const ( + dotEnvFilename = ".env" +) + +type Flag struct { + Name string + Short string + Value interface{} + Usage string + EnvVar string +} + +// MakeFlagBindings Bind flags to the command setting them by hard flags from +// the CLI, .env values, and environment variable values. Make the flags +// persistent for the command that needs to propagate them to subcommands; for +// instance the global flags on the root command. +// +// https://github.com/spf13/cobra/blob/main/site/content/user_guide.md#working-with-flags +func MakeFlagBindings(cmd *cobra.Command, flags []Flag, persistent bool) { + // bind env vars + for _, f := range flags { + _ = viper.BindEnv(f.EnvVar, f.Name) + } + + // bind env vars via dotenv if it exists + path, _ := os.Getwd() + dotEnv := filepath.Join(path, dotEnvFilename) + if _, err := os.Stat(dotEnv); err == nil || !errors.Is(err, os.ErrNotExist) { + viper.AddConfigPath(path) + viper.SetConfigName(dotEnvFilename) + viper.SetConfigType("dotenv") + + _ = viper.ReadInConfig() + + // After viper reads in the dotenv file check if AWS_REGION is set + // there. The value will be keyed by lower case name. If it is, set + // AWS_REGION as an ENV VAR if it hasn't already been. + awsRegionEnvVar := "AWS_REGION" + vipAwsRegion := viper.GetString(strings.ToLower(awsRegionEnvVar)) + if vipAwsRegion != "" && os.Getenv(awsRegionEnvVar) == "" { + _ = os.Setenv(awsRegionEnvVar, vipAwsRegion) + } + } + viper.AutomaticEnv() + + // bind cli flags + for _, f := range flags { + if val, ok := f.Value.(string); ok { + if persistent { + cmd.PersistentFlags().StringP(f.Name, f.Short, val, f.Usage) + } else { + cmd.Flags().StringP(f.Name, f.Short, val, f.Usage) + } + } + if val, ok := f.Value.(bool); ok { + if persistent { + cmd.PersistentFlags().BoolP(f.Name, f.Short, val, f.Usage) + } else { + cmd.Flags().BoolP(f.Name, f.Short, val, f.Usage) + } + } + + if persistent { + _ = viper.BindPFlag(f.Name, cmd.PersistentFlags().Lookup(f.Name)) + } else { + _ = viper.BindPFlag(f.Name, cmd.Flags().Lookup(f.Name)) + } + } +} + +func CheckRequiredFlags(flags []string, cmd *cobra.Command) error { + unsetFlags := []string{} + for _, f := range flags { + if !viper.GetViper().IsSet(f) { + unsetFlags = append(unsetFlags, fmt.Sprintf(" --%s", f)) + } + } + if len(unsetFlags) > 0 { + return fmt.Errorf("missing flags:\n%s", strings.Join(unsetFlags, "\n")) + } + return nil +} From ac0924f4b90108f677a575c7e4e5ba4e96ca9873 Mon Sep 17 00:00:00 2001 From: Mike Mondragon Date: Fri, 22 Sep 2023 11:25:47 -0700 Subject: [PATCH 03/42] `make qc` -> QC'd the code. --- cmd/root/debug/debug.go | 1 + cmd/root/m2m/m2m.go | 4 ++-- cmd/root/root.go | 5 +---- cmd/root/web/web.go | 8 ++------ internal/config/config.go | 2 +- internal/flag/flag.go | 4 +++- internal/sessiontoken/sessiontoken.go | 10 ++++++---- 7 files changed, 16 insertions(+), 18 deletions(-) diff --git a/cmd/root/debug/debug.go b/cmd/root/debug/debug.go index fedf5c8..c21ca82 100644 --- a/cmd/root/debug/debug.go +++ b/cmd/root/debug/debug.go @@ -25,6 +25,7 @@ import ( "github.com/okta/okta-aws-cli/internal/config" ) +// NewDebugCommand Sets up the debug cobra sub command func NewDebugCommand() *cobra.Command { cmd := &cobra.Command{ Use: "debug", diff --git a/cmd/root/m2m/m2m.go b/cmd/root/m2m/m2m.go index 0126346..f83f98c 100644 --- a/cmd/root/m2m/m2m.go +++ b/cmd/root/m2m/m2m.go @@ -23,7 +23,6 @@ import ( "github.com/spf13/cobra" "github.com/okta/okta-aws-cli/internal/config" - "github.com/okta/okta-aws-cli/internal/flag" cliFlag "github.com/okta/okta-aws-cli/internal/flag" ) @@ -47,6 +46,7 @@ var ( requiredFlags = []string{"org-domain", "oidc-client-id", "aws-iam-role", "private-key"} ) +// NewM2MCommand Sets up the m2m cobra sub command func NewM2MCommand() *cobra.Command { cmd := &cobra.Command{ Use: "m2m", @@ -56,7 +56,7 @@ func NewM2MCommand() *cobra.Command { if err != nil { return err } - err = flag.CheckRequiredFlags(requiredFlags, cmd) + err = cliFlag.CheckRequiredFlags(requiredFlags) if err != nil { return err } diff --git a/cmd/root/root.go b/cmd/root/root.go index 74763ec..45e4090 100644 --- a/cmd/root/root.go +++ b/cmd/root/root.go @@ -31,10 +31,6 @@ import ( cliFlag "github.com/okta/okta-aws-cli/internal/flag" ) -const ( - dotEnvFilename = ".env" -) - var ( flags []cliFlag.Flag rootCmd *cobra.Command @@ -149,6 +145,7 @@ func init() { rootCmd.AddCommand(debugCfgCmd) } +// NewRootCommand Sets up the root cobra command func NewRootCommand() *cobra.Command { cmd := &cobra.Command{ Version: config.Version, diff --git a/cmd/root/web/web.go b/cmd/root/web/web.go index 5b237cd..80d3150 100644 --- a/cmd/root/web/web.go +++ b/cmd/root/web/web.go @@ -20,15 +20,10 @@ import ( "github.com/spf13/cobra" "github.com/okta/okta-aws-cli/internal/config" - "github.com/okta/okta-aws-cli/internal/flag" cliFlag "github.com/okta/okta-aws-cli/internal/flag" "github.com/okta/okta-aws-cli/internal/sessiontoken" ) -const ( - dotEnvFilename = ".env" -) - var ( flags = []cliFlag.Flag{ { @@ -63,6 +58,7 @@ var ( requiredFlags = []string{"org-domain", "oidc-client-id"} ) +// NewWebCommand Sets up the web cobra sub command func NewWebCommand() *cobra.Command { cmd := &cobra.Command{ Use: "web", @@ -72,7 +68,7 @@ func NewWebCommand() *cobra.Command { if err != nil { return err } - err = flag.CheckRequiredFlags(requiredFlags, cmd) + err = cliFlag.CheckRequiredFlags(requiredFlags) if err != nil { return err } diff --git a/internal/config/config.go b/internal/config/config.go index dac529f..0ff910c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -46,7 +46,7 @@ const ( AWSIAMIdPFlag = "aws-iam-idp" // AWSIAMRoleFlag cli flag const AWSIAMRoleFlag = "aws-iam-role" - // CustomScope cli flag const + // CustomScopeFlag cli flag const CustomScopeFlag = "custom-scope" // DebugFlag cli flag const DebugFlag = "debug" diff --git a/internal/flag/flag.go b/internal/flag/flag.go index a8a9d4d..e8b7e64 100644 --- a/internal/flag/flag.go +++ b/internal/flag/flag.go @@ -31,6 +31,7 @@ const ( dotEnvFilename = ".env" ) +// Flag Convenience struct for Viper flag parameters type Flag struct { Name string Short string @@ -97,7 +98,8 @@ func MakeFlagBindings(cmd *cobra.Command, flags []Flag, persistent bool) { } } -func CheckRequiredFlags(flags []string, cmd *cobra.Command) error { +// CheckRequiredFlags Checks if flags in the list are all set in Viper +func CheckRequiredFlags(flags []string) error { unsetFlags := []string{} for _, f := range flags { if !viper.GetViper().IsSet(f) { diff --git a/internal/sessiontoken/sessiontoken.go b/internal/sessiontoken/sessiontoken.go index 9bb1c8e..83787e8 100644 --- a/internal/sessiontoken/sessiontoken.go +++ b/internal/sessiontoken/sessiontoken.go @@ -73,6 +73,8 @@ const ( roleSelectedTemplate = ` {{color "default+hb"}}Role: {{color "reset"}}{{color "cyan"}}{{ .Role }}{{color "reset"}}` dotOktaDir = ".okta" tokenFileName = "awscli-access-token.json" + arnLabelPrintFmt = " %q: %q\n" + arnPrintFmt = " %q\n" ) type idpTemplateData struct { @@ -256,10 +258,10 @@ func (s *SessionToken) choiceFriendlyLabelIDP(alternative string, oktaConfig *co return label } else if s.config.Debug() { fmt.Fprintf(os.Stderr, " did not find friendly label for IdP ARN\n") - fmt.Fprintf(os.Stderr, " %q\n", arn) + fmt.Fprintf(os.Stderr, arnPrintFmt, arn) fmt.Fprintf(os.Stderr, " in okta.yaml awscli.idps map:\n") for arn, label := range oktaConfig.AWSCLI.IDPS { - fmt.Fprintf(os.Stderr, " %q: %q\n", arn, label) + fmt.Fprintf(os.Stderr, arnLabelPrintFmt, arn, label) } } return alternative @@ -399,10 +401,10 @@ func (s *SessionToken) choiceFriendlyLabelRole(arn string, oktaConfig *config.Ok return label } else if s.config.Debug() { fmt.Fprintf(os.Stderr, " did not find friendly label for Role ARN\n") - fmt.Fprintf(os.Stderr, " %q\n", arn) + fmt.Fprintf(os.Stderr, arnPrintFmt, arn) fmt.Fprintf(os.Stderr, " in okta.yaml awscli.roles map:\n") for arn, label := range oktaConfig.AWSCLI.ROLES { - fmt.Fprintf(os.Stderr, " %q: %q\n", arn, label) + fmt.Fprintf(os.Stderr, arnLabelPrintFmt, arn, label) } } return arn From b5e01de6b7271580396a1f39b3a73d9023bfc239 Mon Sep 17 00:00:00 2001 From: Mike Mondragon Date: Fri, 22 Sep 2023 16:55:31 -0700 Subject: [PATCH 04/42] M2M auth access token request --- README.md | 2 + cmd/root/m2m/m2m.go | 33 +- cmd/root/web/web.go | 7 + go.mod | 4 + go.sum | 6 + internal/config/config.go | 309 +++++++++++------- internal/m2mauth/m2mauth.go | 177 ++++++++++ internal/m2mauth/m2mauth_test.go | 155 +++++++++ internal/okta/accesstoken.go | 30 ++ internal/okta/apierror.go | 23 ++ internal/okta/client_assertion_claims.go | 31 ++ internal/okta/okta.go | 25 ++ internal/sessiontoken/sessiontoken.go | 79 ++--- internal/testutils/testutils.go | 157 +++++++++ internal/utils/utils.go | 26 ++ test/fixtures/vcr/TestM2MAuthAccessToken.yaml | 42 +++ .../vcr/TestM2MAuthMakeClientAssertion.yaml | 3 + 17 files changed, 925 insertions(+), 184 deletions(-) create mode 100644 internal/m2mauth/m2mauth.go create mode 100644 internal/m2mauth/m2mauth_test.go create mode 100644 internal/okta/accesstoken.go create mode 100644 internal/okta/apierror.go create mode 100644 internal/okta/client_assertion_claims.go create mode 100644 internal/okta/okta.go create mode 100644 internal/testutils/testutils.go create mode 100644 internal/utils/utils.go create mode 100644 test/fixtures/vcr/TestM2MAuthAccessToken.yaml create mode 100644 test/fixtures/vcr/TestM2MAuthMakeClientAssertion.yaml diff --git a/README.md b/README.md index 5965800..74bb66b 100644 --- a/README.md +++ b/README.md @@ -427,6 +427,8 @@ These settings are optional unless marked otherwise: | Name | Description | Command line flag | ENV var and .env file value | |-----|-----|-----|-----| +| Custom Authorization Server ID (**required**) | The ID of the Okta custom authorization server | `--authz-id [value]` | `OKTA_AUTHZ_ID` | +| Key ID (kid) (**required**) | The ID of the key stored in the service app | `--key-id [value]` | `OKTA_AWSCLI_KEY_ID` | | Private Key (**required**) | PEM or JWKS format private key whose public key is stored on the service app | `--private-key [value]` | `OKTA_AWSCLI_PRIVATE_KEY` | | Custom scope name | The custom scope established in the custom authorization server. Default `okta-aws-cli` | `--custom-scope [value]` | `OKTA_AWSCLI_CUSTOM_SCOPE` | diff --git a/cmd/root/m2m/m2m.go b/cmd/root/m2m/m2m.go index f83f98c..23bff67 100644 --- a/cmd/root/m2m/m2m.go +++ b/cmd/root/m2m/m2m.go @@ -17,17 +17,22 @@ package m2m import ( - "fmt" - "os" - "github.com/spf13/cobra" "github.com/okta/okta-aws-cli/internal/config" cliFlag "github.com/okta/okta-aws-cli/internal/flag" + "github.com/okta/okta-aws-cli/internal/m2mauth" ) var ( flags = []cliFlag.Flag{ + { + Name: config.KeyIDFlag, + Short: "i", + Value: "", + Usage: "Key ID", + EnvVar: config.KeyIDEnvVar, + }, { Name: config.PrivateKeyFlag, Short: "k", @@ -42,8 +47,15 @@ var ( Usage: "Custom Scope", EnvVar: config.CustomScopeEnvVar, }, + { + Name: config.AuthzIDFlag, + Short: "u", + Value: "", + Usage: "Custom Authorization Server ID", + EnvVar: config.AuthzIDEnvVar, + }, } - requiredFlags = []string{"org-domain", "oidc-client-id", "aws-iam-role", "private-key"} + requiredFlags = []string{"org-domain", "oidc-client-id", "aws-iam-role", "key-id", "private-key", "authz-id"} ) // NewM2MCommand Sets up the m2m cobra sub command @@ -61,14 +73,11 @@ func NewM2MCommand() *cobra.Command { return err } - fmt.Fprintf(os.Stderr, "WIP - m2m, get to work!\n") - fmt.Fprintf(os.Stderr, "Okta Org Domain: %s\n", config.OrgDomain()) - fmt.Fprintf(os.Stderr, "OIDC Client ID: %s\n", config.OIDCAppID()) - fmt.Fprintf(os.Stderr, "IAM Role ARN: %s\n", config.AWSIAMRole()) - fmt.Fprintf(os.Stderr, "Private Key: %s\n", config.PrivateKey()) - fmt.Fprintf(os.Stderr, "Custom Scope: %s\n", config.CustomScope()) - - return nil + m2mAuth, err := m2mauth.NewM2MAuthentication(config) + if err != nil { + return err + } + return m2mAuth.EstablishIAMCredentials() }, } diff --git a/cmd/root/web/web.go b/cmd/root/web/web.go index 80d3150..5316f08 100644 --- a/cmd/root/web/web.go +++ b/cmd/root/web/web.go @@ -73,6 +73,13 @@ func NewWebCommand() *cobra.Command { return err } + // TODO refactor the naming convention + // webAuth, err := webauth.NewWebSSOAuthentication(config) + // if err != nil { + // return err + // } + // return webAuth.EstablishIAMCredentials() + st, err := sessiontoken.NewSessionToken(config) if err != nil { return err diff --git a/go.mod b/go.mod index ca0bbe3..8371208 100644 --- a/go.mod +++ b/go.mod @@ -17,10 +17,14 @@ require ( github.com/tidwall/pretty v1.2.0 golang.org/x/net v0.7.0 golang.org/x/sys v0.5.0 + gopkg.in/dnaeon/go-vcr.v3 v3.1.2 gopkg.in/ini.v1 v1.67.0 + gopkg.in/square/go-jose.v2 v2.6.0 gopkg.in/yaml.v2 v2.4.0 ) +require golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e // indirect + require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect diff --git a/go.sum b/go.sum index f9b9dfa..482007f 100644 --- a/go.sum +++ b/go.sum @@ -234,6 +234,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e h1:T8NU3HyQ8ClP4SEE+KbFlg6n0NhuTsN4MyznaarGsZM= +golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -523,9 +525,13 @@ google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlba 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/dnaeon/go-vcr.v3 v3.1.2 h1:F1smfXBqQqwpVifDfUBQG6zzaGjzT+EnVZakrOdr5wA= +gopkg.in/dnaeon/go-vcr.v3 v3.1.2/go.mod h1:2IMOnnlx9I6u9x+YBsM3tAMx6AlOxnJ0pWxQAzZ79Ag= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI= +gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= diff --git a/internal/config/config.go b/internal/config/config.go index 0ff910c..9413bae 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -38,6 +38,8 @@ const ( // EnvVarFormat format const EnvVarFormat = "env-var" + // AuthzIDFlag cli flag const + AuthzIDFlag = "authz-id" // AWSAcctFedAppIDFlag cli flag const AWSAcctFedAppIDFlag = "aws-acct-fed-app-id" // AWSCredentialsFlag cli flag const @@ -62,6 +64,8 @@ const ( OrgDomainFlag = "org-domain" // PrivateKeyFlag cli flag const PrivateKeyFlag = "private-key" + // KeyIDFlag cli flag const + KeyIDFlag = "key-id" // ProfileFlag cli flag const ProfileFlag = "profile" // QRCodeFlag cli flag const @@ -77,6 +81,8 @@ const ( // CacheAccessTokenFlag cli flag const CacheAccessTokenFlag = "cache-access-token" + // AuthzIDEnvVar env var const + AuthzIDEnvVar = "OKTA_AUTHZ_ID" // AWSCredentialsEnvVar env var const AWSCredentialsEnvVar = "OKTA_AWSCLI_AWS_CREDENTIALS" // AWSIAMIdPEnvVar env var const @@ -109,6 +115,8 @@ const ( OpenBrowserEnvVar = "OKTA_AWSCLI_OPEN_BROWSER" // PrivateKeyEnvVar env var const PrivateKeyEnvVar = "OKTA_AWSCLI_PRIVATE_KEY" + // KeyIDEnvVar env var const + KeyIDEnvVar = "OKTA_AWSCLI_KEY_ID" // ProfileEnvVar env var const ProfileEnvVar = "OKTA_AWSCLI_PROFILE" // QRCodeEnvVar env var const @@ -135,6 +143,11 @@ type OktaYamlConfig struct { } `yaml:"awscli"` } +// Clock interface to abstract time operations +type Clock interface { + Now() time.Time +} + // Config A config object for the CLI // // External consumers of Config use its setters and getters to interact with the @@ -142,6 +155,7 @@ type OktaYamlConfig struct { // control data access, be concerned with evaluation, validation, and not // allowing direct access to values as is done on structs in the generic case. type Config struct { + authzID string awsCredentials string awsIAMIdP string awsIAMRole string @@ -154,6 +168,7 @@ type Config struct { fedAppID string format string httpClient *http.Client + keyID string legacyAWSVariables bool oidcAppID string openBrowser bool @@ -162,29 +177,32 @@ type Config struct { profile string qrCode bool writeAWSCredentials bool -} - -// attributes config construction -type attributes struct { - awsCredentials string - awsIAMIdP string - awsIAMRole string - awsSessionDuration int64 - cacheAccessToken bool - customScope string - debug bool - debugAPICalls bool - expiryAWSVariables bool - fedAppID string - format string - legacyAWSVariables bool - oidcAppID string - openBrowser bool - orgDomain string - privateKey string - profile string - qrCode bool - writeAWSCredentials bool + clock Clock +} + +// Attributes config construction +type Attributes struct { + AuthzID string + AWSCredentials string + AWSIAMIdP string + AWSIAMRole string + AWSSessionDuration int64 + CacheAccessToken bool + CustomScope string + Debug bool + DebugAPICalls bool + ExpiryAWSVariables bool + FedAppID string + Format string + KeyID string + LegacyAWSVariables bool + OIDCAppID string + OpenBrowser bool + OrgDomain string + PrivateKey string + Profile string + QRCode bool + WriteAWSCredentials bool } // EvaluateSettings Returns a new config gathering values in this order of precedence: @@ -196,39 +214,41 @@ func EvaluateSettings() (*Config, error) { if err != nil { return nil, err } - return NewConfig(cfgAttrs) + return NewConfig(&cfgAttrs) } // NewConfig create config from attributes -func NewConfig(attrs attributes) (*Config, error) { +func NewConfig(attrs *Attributes) (*Config, error) { var err error cfg := &Config{ - awsCredentials: attrs.awsCredentials, - awsIAMIdP: attrs.awsIAMIdP, - awsIAMRole: attrs.awsIAMRole, - cacheAccessToken: attrs.cacheAccessToken, - customScope: attrs.customScope, - debug: attrs.debug, - debugAPICalls: attrs.debugAPICalls, - expiryAWSVariables: attrs.expiryAWSVariables, - fedAppID: attrs.fedAppID, - format: attrs.format, - legacyAWSVariables: attrs.legacyAWSVariables, - openBrowser: attrs.openBrowser, - privateKey: attrs.privateKey, - profile: attrs.profile, - qrCode: attrs.qrCode, - writeAWSCredentials: attrs.writeAWSCredentials, - } - err = cfg.SetOrgDomain(attrs.orgDomain) + authzID: attrs.AuthzID, + awsCredentials: attrs.AWSCredentials, + awsIAMIdP: attrs.AWSIAMIdP, + awsIAMRole: attrs.AWSIAMRole, + cacheAccessToken: attrs.CacheAccessToken, + customScope: attrs.CustomScope, + debug: attrs.Debug, + debugAPICalls: attrs.DebugAPICalls, + expiryAWSVariables: attrs.ExpiryAWSVariables, + fedAppID: attrs.FedAppID, + format: attrs.Format, + legacyAWSVariables: attrs.LegacyAWSVariables, + openBrowser: attrs.OpenBrowser, + privateKey: attrs.PrivateKey, + keyID: attrs.KeyID, + profile: attrs.Profile, + qrCode: attrs.QRCode, + writeAWSCredentials: attrs.WriteAWSCredentials, + } + err = cfg.SetOrgDomain(attrs.OrgDomain) if err != nil { return nil, err } - err = cfg.SetOIDCAppID(attrs.oidcAppID) + err = cfg.SetOIDCAppID(attrs.OIDCAppID) if err != nil { return nil, err } - err = cfg.SetAWSSessionDuration(attrs.awsSessionDuration) + err = cfg.SetAWSSessionDuration(attrs.AWSSessionDuration) if err != nil { return nil, err } @@ -240,133 +260,142 @@ func NewConfig(attrs attributes) (*Config, error) { if err != nil { return nil, err } + cfg.clock = &realClock{} return cfg, nil } -func readConfig() (attributes, error) { - attrs := attributes{ - awsCredentials: viper.GetString(AWSCredentialsFlag), - awsIAMIdP: viper.GetString(AWSIAMIdPFlag), - awsIAMRole: viper.GetString(AWSIAMRoleFlag), - awsSessionDuration: viper.GetInt64(SessionDurationFlag), - customScope: viper.GetString(CustomScopeFlag), - debug: viper.GetBool(DebugFlag), - debugAPICalls: viper.GetBool(DebugAPICallsFlag), - fedAppID: viper.GetString(AWSAcctFedAppIDFlag), - format: viper.GetString(FormatFlag), - legacyAWSVariables: viper.GetBool(LegacyAWSVariablesFlag), - expiryAWSVariables: viper.GetBool(ExpiryAWSVariablesFlag), - cacheAccessToken: viper.GetBool(CacheAccessTokenFlag), - oidcAppID: viper.GetString(OIDCClientIDFlag), - openBrowser: viper.GetBool(OpenBrowserFlag), - orgDomain: viper.GetString(OrgDomainFlag), - privateKey: viper.GetString(PrivateKeyFlag), - profile: viper.GetString(ProfileFlag), - qrCode: viper.GetBool(QRCodeFlag), - writeAWSCredentials: viper.GetBool(WriteAWSCredentialsFlag), - } - if attrs.format == "" { - attrs.format = EnvVarFormat +func readConfig() (Attributes, error) { + attrs := Attributes{ + AuthzID: viper.GetString(AuthzIDFlag), + AWSCredentials: viper.GetString(AWSCredentialsFlag), + AWSIAMIdP: viper.GetString(AWSIAMIdPFlag), + AWSIAMRole: viper.GetString(AWSIAMRoleFlag), + AWSSessionDuration: viper.GetInt64(SessionDurationFlag), + CustomScope: viper.GetString(CustomScopeFlag), + Debug: viper.GetBool(DebugFlag), + DebugAPICalls: viper.GetBool(DebugAPICallsFlag), + FedAppID: viper.GetString(AWSAcctFedAppIDFlag), + Format: viper.GetString(FormatFlag), + LegacyAWSVariables: viper.GetBool(LegacyAWSVariablesFlag), + ExpiryAWSVariables: viper.GetBool(ExpiryAWSVariablesFlag), + CacheAccessToken: viper.GetBool(CacheAccessTokenFlag), + OIDCAppID: viper.GetString(OIDCClientIDFlag), + OpenBrowser: viper.GetBool(OpenBrowserFlag), + OrgDomain: viper.GetString(OrgDomainFlag), + PrivateKey: viper.GetString(PrivateKeyFlag), + KeyID: viper.GetString(KeyIDFlag), + Profile: viper.GetString(ProfileFlag), + QRCode: viper.GetBool(QRCodeFlag), + WriteAWSCredentials: viper.GetBool(WriteAWSCredentialsFlag), + } + if attrs.Format == "" { + attrs.Format = EnvVarFormat } // mimic AWS CLI behavior, if profile value is not set by flag check // the ENV VAR, else set to "default" - if attrs.profile == "" { - attrs.profile = viper.GetString(downCase(ProfileEnvVar)) + if attrs.Profile == "" { + attrs.Profile = viper.GetString(downCase(ProfileEnvVar)) } - if attrs.profile == "" { - attrs.profile = "default" + if attrs.Profile == "" { + attrs.Profile = "default" } // Viper binds ENV VARs to a lower snake version, set the configs with them // if they haven't already been set by cli flag binding. - if attrs.orgDomain == "" { - attrs.orgDomain = viper.GetString(downCase(OktaOrgDomainEnvVar)) + if attrs.OrgDomain == "" { + attrs.OrgDomain = viper.GetString(downCase(OktaOrgDomainEnvVar)) + } + if attrs.OIDCAppID == "" { + attrs.OIDCAppID = viper.GetString(downCase(OktaOIDCClientIDEnvVar)) } - if attrs.oidcAppID == "" { - attrs.oidcAppID = viper.GetString(downCase(OktaOIDCClientIDEnvVar)) + if attrs.FedAppID == "" { + attrs.FedAppID = viper.GetString(downCase(OktaAWSAccountFederationAppIDEnvVar)) } - if attrs.fedAppID == "" { - attrs.fedAppID = viper.GetString(downCase(OktaAWSAccountFederationAppIDEnvVar)) + if attrs.AWSIAMIdP == "" { + attrs.AWSIAMIdP = viper.GetString(downCase(AWSIAMIdPEnvVar)) } - if attrs.awsIAMIdP == "" { - attrs.awsIAMIdP = viper.GetString(downCase(AWSIAMIdPEnvVar)) + if attrs.AWSIAMRole == "" { + attrs.AWSIAMRole = viper.GetString(downCase(AWSIAMRoleEnvVar)) } - if attrs.awsIAMRole == "" { - attrs.awsIAMRole = viper.GetString(downCase(AWSIAMRoleEnvVar)) + if !attrs.QRCode { + attrs.QRCode = viper.GetBool(downCase(QRCodeEnvVar)) } - if !attrs.qrCode { - attrs.qrCode = viper.GetBool(downCase(QRCodeEnvVar)) + if attrs.PrivateKey == "" { + attrs.PrivateKey = viper.GetString(downCase(PrivateKeyEnvVar)) } - if attrs.privateKey == "" { - attrs.privateKey = viper.GetString(downCase(PrivateKeyEnvVar)) + if attrs.KeyID == "" { + attrs.KeyID = viper.GetString(downCase(KeyIDEnvVar)) } - if attrs.customScope == "" { - attrs.customScope = viper.GetString(downCase(CustomScopeEnvVar)) + if attrs.CustomScope == "" { + attrs.CustomScope = viper.GetString(downCase(CustomScopeEnvVar)) + } + if attrs.AuthzID == "" { + attrs.AuthzID = viper.GetString(downCase(AuthzIDEnvVar)) } // if session duration is 0, inspect the ENV VAR for a value, else set // a default of 3600 - if attrs.awsSessionDuration == 0 { - attrs.awsSessionDuration = viper.GetInt64(downCase(AWSSessionDurationEnvVar)) + if attrs.AWSSessionDuration == 0 { + attrs.AWSSessionDuration = viper.GetInt64(downCase(AWSSessionDurationEnvVar)) } - if attrs.awsSessionDuration == 0 { - attrs.awsSessionDuration = 3600 + if attrs.AWSSessionDuration == 0 { + attrs.AWSSessionDuration = 3600 } // correct org domain if it's in admin form - orgDomain := strings.Replace(attrs.orgDomain, "-admin", "", -1) - if orgDomain != attrs.orgDomain { - fmt.Fprintf(os.Stderr, "WARNING: proactively correcting org domain %q to non-admin form %q.\n\n", attrs.orgDomain, orgDomain) - attrs.orgDomain = orgDomain + orgDomain := strings.Replace(attrs.OrgDomain, "-admin", "", -1) + if orgDomain != attrs.OrgDomain { + fmt.Fprintf(os.Stderr, "WARNING: proactively correcting org domain %q to non-admin form %q.\n\n", attrs.OrgDomain, orgDomain) + attrs.OrgDomain = orgDomain } - if strings.HasPrefix(attrs.orgDomain, "http") { - u, err := url.Parse(attrs.orgDomain) + if strings.HasPrefix(attrs.OrgDomain, "http") { + u, err := url.Parse(attrs.OrgDomain) // try to help correct org domain value if parsing occurs correctly, // else let the CLI error out else where if err == nil { orgDomain = u.Hostname() - fmt.Fprintf(os.Stderr, "WARNING: proactively correcting URL format org domain %q value to hostname only form %q.\n\n", attrs.orgDomain, orgDomain) - attrs.orgDomain = orgDomain + fmt.Fprintf(os.Stderr, "WARNING: proactively correcting URL format org domain %q value to hostname only form %q.\n\n", attrs.OrgDomain, orgDomain) + attrs.OrgDomain = orgDomain } } - if strings.HasSuffix(attrs.orgDomain, "/") { - orgDomain = string([]byte(attrs.orgDomain)[0 : len(attrs.orgDomain)-1]) + if strings.HasSuffix(attrs.OrgDomain, "/") { + orgDomain = string([]byte(attrs.OrgDomain)[0 : len(attrs.OrgDomain)-1]) // try to help correct malformed org domain value - fmt.Fprintf(os.Stderr, "WARNING: proactively correcting malformed org domain %q value to hostname only form %q.\n\n", attrs.orgDomain, orgDomain) - attrs.orgDomain = orgDomain + fmt.Fprintf(os.Stderr, "WARNING: proactively correcting malformed org domain %q value to hostname only form %q.\n\n", attrs.OrgDomain, orgDomain) + attrs.OrgDomain = orgDomain } // There is always a default aws credentials path set in root.go's init // function so overwrite the config value if the operator is attempting to // set it by ENV VAR value. if viper.GetString(downCase(AWSCredentialsEnvVar)) != "" { - attrs.awsCredentials = viper.GetString(downCase(AWSCredentialsEnvVar)) + attrs.AWSCredentials = viper.GetString(downCase(AWSCredentialsEnvVar)) } - if !attrs.writeAWSCredentials { - attrs.writeAWSCredentials = viper.GetBool(downCase(WriteAWSCredentialsEnvVar)) + if !attrs.WriteAWSCredentials { + attrs.WriteAWSCredentials = viper.GetBool(downCase(WriteAWSCredentialsEnvVar)) } - if attrs.writeAWSCredentials { + if attrs.WriteAWSCredentials { // writing aws creds option implies "aws-credentials" format - attrs.format = AWSCredentialsFormat + attrs.Format = AWSCredentialsFormat } - if !attrs.openBrowser { - attrs.openBrowser = viper.GetBool(downCase(OpenBrowserEnvVar)) + if !attrs.OpenBrowser { + attrs.OpenBrowser = viper.GetBool(downCase(OpenBrowserEnvVar)) } - if !attrs.debug { - attrs.debug = viper.GetBool(downCase(DebugEnvVar)) + if !attrs.Debug { + attrs.Debug = viper.GetBool(downCase(DebugEnvVar)) } - if !attrs.debugAPICalls { - attrs.debugAPICalls = viper.GetBool(downCase(DebugAPICallsEnvVar)) + if !attrs.DebugAPICalls { + attrs.DebugAPICalls = viper.GetBool(downCase(DebugAPICallsEnvVar)) } - if !attrs.legacyAWSVariables { - attrs.legacyAWSVariables = viper.GetBool(downCase(LegacyAWSVariablesEnvVar)) + if !attrs.LegacyAWSVariables { + attrs.LegacyAWSVariables = viper.GetBool(downCase(LegacyAWSVariablesEnvVar)) } - if !attrs.expiryAWSVariables { - attrs.expiryAWSVariables = viper.GetBool(downCase(ExpiryAWSVariablesEnvVar)) + if !attrs.ExpiryAWSVariables { + attrs.ExpiryAWSVariables = viper.GetBool(downCase(ExpiryAWSVariablesEnvVar)) } - if !attrs.cacheAccessToken { - attrs.cacheAccessToken = viper.GetBool(downCase(CacheAccessTokenEnvVar)) + if !attrs.CacheAccessToken { + attrs.CacheAccessToken = viper.GetBool(downCase(CacheAccessTokenEnvVar)) } return attrs, nil } @@ -376,6 +405,17 @@ func downCase(s string) string { return strings.ToLower(s) } +// AuthzID -- +func (c *Config) AuthzID() string { + return c.authzID +} + +// SetAuthzID -- +func (c *Config) SetAuthzID(authzID string) error { + c.authzID = authzID + return nil +} + // AWSCredentials -- func (c *Config) AWSCredentials() string { return c.awsCredentials @@ -442,6 +482,16 @@ func (c *Config) SetCacheAccessToken(cacheAccessToken bool) error { return nil } +// Clock -- +func (c *Config) Clock() Clock { + return c.clock +} + +// SetClock -- +func (c *Config) SetClock(clock Clock) { + c.clock = clock +} + // CustomScope -- func (c *Config) CustomScope() string { return c.customScope @@ -574,6 +624,17 @@ func (c *Config) SetPrivateKey(privateKey string) error { return nil } +// KeyID -- +func (c *Config) KeyID() string { + return c.keyID +} + +// SetKeyID -- +func (c *Config) SetKeyID(keyID string) error { + c.keyID = keyID + return nil +} + // Profile -- func (c *Config) Profile() string { return c.profile @@ -744,3 +805,7 @@ awscli: fmt.Fprintf(os.Stderr, "okta.yaml is OK\n") return nil } + +type realClock struct{} + +func (realClock) Now() time.Time { return time.Now() } diff --git a/internal/m2mauth/m2mauth.go b/internal/m2mauth/m2mauth.go new file mode 100644 index 0000000..0d991a0 --- /dev/null +++ b/internal/m2mauth/m2mauth.go @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2023-Present, Okta, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package m2mauth + +import ( + "crypto/ecdsa" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/okta/okta-aws-cli/internal/config" + "github.com/okta/okta-aws-cli/internal/okta" + "github.com/okta/okta-aws-cli/internal/utils" + "gopkg.in/square/go-jose.v2" + "gopkg.in/square/go-jose.v2/jwt" +) + +// M2MAuthentication Object structure for headless authentication +type M2MAuthentication struct { + config *config.Config +} + +// NewM2MAuthentication New M2M Authenticator constructor +func NewM2MAuthentication(config *config.Config) (*M2MAuthentication, error) { + m := M2MAuthentication{ + config: config, + } + return &m, nil +} + +// EstablishIAMCredentials Full operation to fetch temporary IAM credentials and +// output them to preferred format. +func (m *M2MAuthentication) EstablishIAMCredentials() error { + _, err := m.AccessToken() + if err != nil { + return err + } + // WIP + // out, err := m.AssumeRole(at) (*sts.AssumeRoleWithWebIdentityOutput, error) { + // err = m.OutputCredentials(out) + return nil +} + +func (m *M2MAuthentication) createKeySigner() (jose.Signer, error) { + signerOptions := (&jose.SignerOptions{}).WithHeader("kid", m.config.KeyID()) + priv := []byte(strings.ReplaceAll(m.config.PrivateKey(), `\n`, "\n")) + + privPem, _ := pem.Decode(priv) + if privPem == nil { + return nil, errors.New("invalid private key") + } + + if privPem.Type == "RSA PRIVATE KEY" { + parsedKey, err := x509.ParsePKCS1PrivateKey(privPem.Bytes) + if err != nil { + return nil, err + } + return jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: parsedKey}, signerOptions) + } + if privPem.Type == "PRIVATE KEY" { + parsedKey, err := x509.ParsePKCS8PrivateKey(privPem.Bytes) + if err != nil { + return nil, err + } + var alg jose.SignatureAlgorithm + switch parsedKey.(type) { + case *rsa.PrivateKey: + alg = jose.RS256 + case *ecdsa.PrivateKey: + alg = jose.ES256 // TODO handle ES384 or ES512 ? + default: + // TODO are either of these also valid? + // ed25519.PrivateKey: + // *ecdh.PrivateKey + return nil, fmt.Errorf("private key %q is unknown pkcs#8 format type", privPem.Type) + } + return jose.NewSigner(jose.SigningKey{Algorithm: alg, Key: parsedKey}, signerOptions) + } + + return nil, fmt.Errorf("private key %q is not pkcs#1 or pkcs#8 format", privPem.Type) +} + +func (m *M2MAuthentication) makeClientAssertion() (string, error) { + privateKeySinger, err := m.createKeySigner() + if err != nil { + return "", err + } + + tokenRequestURL := fmt.Sprintf(okta.CustomAuthzV1TokenEndpointFormat, m.config.OrgDomain(), m.config.AuthzID()) + now := m.config.Clock().Now() + claims := okta.ClientAssertionClaims{ + Subject: m.config.OIDCAppID(), + IssuedAt: jwt.NewNumericDate(now), + Expiry: jwt.NewNumericDate(now.Add(time.Hour * time.Duration(1))), + Issuer: m.config.OIDCAppID(), + Audience: tokenRequestURL, + } + + jwtBuilder := jwt.Signed(privateKeySinger).Claims(claims) + return jwtBuilder.CompactSerialize() +} + +// AccessToken Takes okta-aws-cli private key and presents a client_credentials +// flow assertion to /oauth2/{authzServerID}/v1/token to gather an access token. +func (m *M2MAuthentication) AccessToken() (*okta.AccessToken, error) { + clientAssertion, err := m.makeClientAssertion() + if err != nil { + return nil, err + } + + var tokenRequestBuff io.ReadWriter + query := url.Values{} + tokenRequestURL := fmt.Sprintf(okta.CustomAuthzV1TokenEndpointFormat, m.config.OrgDomain(), m.config.AuthzID()) + + query.Add("grant_type", "client_credentials") + query.Add("scope", m.config.CustomScope()) + query.Add("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer") + query.Add("client_assertion", clientAssertion) + tokenRequestURL += "?" + query.Encode() + tokenRequest, err := http.NewRequest("POST", tokenRequestURL, tokenRequestBuff) + if err != nil { + return nil, err + } + + tokenRequest.Header.Add("Accept", utils.ApplicationJSON) + tokenRequest.Header.Add(utils.ContentType, utils.ApplicationXFORM) + resp, err := m.config.HTTPClient().Do(tokenRequest) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + baseErrStr := "fetching access token received API response %q" + if err != nil { + return nil, fmt.Errorf(baseErrStr, resp.Status) + } + + var apiErr okta.APIError + err = json.NewDecoder(resp.Body).Decode(&apiErr) + if err != nil { + return nil, fmt.Errorf(baseErrStr, resp.Status) + } + + return nil, fmt.Errorf(baseErrStr+", error: %q, description: %q", resp.Status, apiErr.Error, apiErr.ErrorDescription) + } + + token := &okta.AccessToken{} + err = json.NewDecoder(resp.Body).Decode(token) + if err != nil { + return nil, err + } + + return token, nil +} diff --git a/internal/m2mauth/m2mauth_test.go b/internal/m2mauth/m2mauth_test.go new file mode 100644 index 0000000..dd945df --- /dev/null +++ b/internal/m2mauth/m2mauth_test.go @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2023-Present, Okta, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package m2mauth + +import ( + "net/http" + "os" + "path" + "regexp" + "testing" + + "github.com/okta/okta-aws-cli/internal/config" + "github.com/okta/okta-aws-cli/internal/testutils" + "github.com/stretchr/testify/require" + "gopkg.in/dnaeon/go-vcr.v3/recorder" +) + +func TestMain(m *testing.M) { + var reset func() + reset = osSetEnvIfBlank("OKTA_ORG_DOMAIN", testutils.TestDomainName) + defer reset() + reset = osSetEnvIfBlank("OKTA_OIDC_CLIENT_ID", "0oaa4htg72TNrkTDr1d7") + defer reset() + reset = osSetEnvIfBlank("OKTA_AWSCLI_IAM_ROLE", "arn:aws:iam::123:role/RickRollNeverGonnaGiveYouUp") + defer reset() + reset = osSetEnvIfBlank("OKTA_AUTHZ_ID", "aus8w23r13NvyUwln1d7") + defer reset() + reset = osSetEnvIfBlank("OKTA_AWSCLI_CUSTOM_SCOPE", "okta-aws-cli") + defer reset() + reset = osSetEnvIfBlank("OKTA_AWSCLI_KEY_ID", "kid-rock") + defer reset() + + // NOTE: Okta Security this is just some random PK to unit test the client + // assertion generator in this app. PK was created with + // `openssl genrsa 512 | pbcopy` + reset = osSetEnvIfBlank("OKTA_AWSCLI_PRIVATE_KEY", ` +-----BEGIN PRIVATE KEY----- +MIIBVQIBADANBgkqhkiG9w0BAQEFAASCAT8wggE7AgEAAkEAzAZ73GY6TbcC0cQS +LQ+GfIkZxeTJjkW8+pdg0zmcGs4ZByZqp7oP02TbZ0UyLFHe8Eqik5rXR98mts5e +TuG2BwIDAQABAkEAmG2jrjdGCffYCGYnejjmLjaz5bCXkU6y8LmWIlkhMrg/F7uH +/yjmN3Hcj06F4b2DRczIIxWHpZVeFaqxvitZ6QIhAPlxhYIIpx4h+mf7cPXOlCZc +QDRqIa+pp3JH3Pgrz8mzAiEA0WNZP8acq251xTl2i+OrstH0o3YeYUmASv8bmyNs +0F0CIALSAsVunZ0cmz0zvZo55LjuUBeHn6vhyi/jmh8AN9A7AiEAoNtM1iTTeROb +4A7cFm2qGu8WnHkCr8SSjYrb/1vAnXUCIFgT6wGO6AFjQAahQlpVnqpppP9F8eSd +qrebTIkNMM8u +-----END PRIVATE KEY-----`) + defer reset() + + os.Exit(m.Run()) +} + +func osSetEnvIfBlank(key, value string) func() { + if os.Getenv(key) != "" { + return func() {} + } + _ = os.Setenv(key, value) + return func() { + _ = os.Unsetenv(key) + } +} + +func TestM2MAuthEstablishIAMCredentials(t *testing.T) { + t.Skip("TODO") +} + +// TestM2MAuthMakeClientAssertion Tests the private make client assertion method +// on m2mauth +func TestM2MAuthMakeClientAssertion(t *testing.T) { + config, teardownTest := setupTest(t) + config.SetClock(testutils.NewTestClock()) + defer teardownTest(t) + + m, err := NewM2MAuthentication(config) + require.NoError(t, err) + _, err = m.makeClientAssertion() + require.NoError(t, err) +} + +func TestM2MAuthAccessToken(t *testing.T) { + config, teardownTest := setupTest(t) + defer teardownTest(t) + + m, err := NewM2MAuthentication(config) + require.NoError(t, err) + + at, err := m.AccessToken() + require.NoError(t, err) + require.NotNil(t, at) + + require.Equal(t, "Bearer", at.TokenType) + require.Equal(t, int64(3600), at.ExpiresIn) + require.Equal(t, "okta-aws-cli", at.Scope) + require.Regexp(t, regexp.MustCompile("^eyJ"), at.AccessToken) +} + +func setupTest(t *testing.T) (*config.Config, func(t *testing.T)) { + attrs := &config.Attributes{ + OrgDomain: os.Getenv("OKTA_ORG_DOMAIN"), + OIDCAppID: os.Getenv("OKTA_OIDC_CLIENT_ID"), + AWSIAMRole: os.Getenv("OKTA_AWSCLI_IAM_ROLE"), + AuthzID: os.Getenv("OKTA_AUTHZ_ID"), + CustomScope: os.Getenv("OKTA_AWSCLI_CUSTOM_SCOPE"), + KeyID: os.Getenv("OKTA_AWSCLI_KEY_ID"), + PrivateKey: os.Getenv("OKTA_AWSCLI_PRIVATE_KEY"), + } + config, err := config.NewConfig(attrs) + require.NoError(t, err) + + rt := config.HTTPClient().Transport + vcr, err := newVCRRecorder(t, rt) + require.NoError(t, err) + rt = http.RoundTripper(vcr) + config.HTTPClient().Transport = rt + + tearDown := func(t *testing.T) { + err := vcr.Stop() + require.NoError(t, err) + } + + return config, tearDown +} + +func newVCRRecorder(t *testing.T, transport http.RoundTripper) (rec *recorder.Recorder, err error) { + dir, _ := os.Getwd() + vcrFixturesHome := path.Join(dir, "../../test/fixtures/vcr") + cassettesPath := path.Join(vcrFixturesHome, t.Name()) + rec, err = recorder.NewWithOptions(&recorder.Options{ + CassetteName: cassettesPath, + Mode: recorder.ModeRecordOnce, + SkipRequestLatency: true, // skip how vcr will mimic the real request latency that it can record allowing for fast playback + RealTransport: transport, + }) + if err != nil { + return + } + + rec.SetMatcher(testutils.VCROktaAPIRequestMatcher) + rec.AddHook(testutils.VCROktaAPIRequestHook, recorder.AfterCaptureHook) + + return +} diff --git a/internal/okta/accesstoken.go b/internal/okta/accesstoken.go new file mode 100644 index 0000000..af26db4 --- /dev/null +++ b/internal/okta/accesstoken.go @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2023-Present, Okta, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package okta + +// AccessToken Encapsulates an Okta access token +// https://developer.okta.com/docs/reference/api/oidc/#token +type AccessToken struct { + AccessToken string `json:"access_token,omitempty"` + IDToken string `json:"id_token,omitempty"` + TokenType string `json:"token_type,omitempty"` + Scope string `json:"scope,omitempty"` + ExpiresIn int64 `json:"expires_in,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + DeviceSecret string `json:"device_secret,omitempty"` + Expiry string `json:"expiry"` +} diff --git a/internal/okta/apierror.go b/internal/okta/apierror.go new file mode 100644 index 0000000..337e9ed --- /dev/null +++ b/internal/okta/apierror.go @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2023-Present, Okta, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package okta + +// APIError Wrapper for Okta API error +type APIError struct { + Error string `json:"error,omitempty"` + ErrorDescription string `json:"error_description,omitempty"` +} diff --git a/internal/okta/client_assertion_claims.go b/internal/okta/client_assertion_claims.go new file mode 100644 index 0000000..c006d09 --- /dev/null +++ b/internal/okta/client_assertion_claims.go @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2023-Present, Okta, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package okta + +import ( + "gopkg.in/square/go-jose.v2/jwt" +) + +// ClientAssertionClaims Okta Client Assertion Claims model +type ClientAssertionClaims struct { + Issuer string `json:"iss,omitempty"` + Subject string `json:"sub,omitempty"` + Audience string `json:"aud,omitempty"` + Expiry *jwt.NumericDate `json:"exp,omitempty"` + IssuedAt *jwt.NumericDate `json:"iat,omitempty"` + ID string `json:"jti,omitempty"` +} diff --git a/internal/okta/okta.go b/internal/okta/okta.go new file mode 100644 index 0000000..5669e44 --- /dev/null +++ b/internal/okta/okta.go @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023-Present, Okta, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package okta + +const ( + // OAuthV1TokenEndpointFormat sprintf format string for base oauth server token endpoint + OAuthV1TokenEndpointFormat = "https://%s/oauth2/v1/token" + + // CustomAuthzV1TokenEndpointFormat sprintf format string for custom oauth server token endpoint + CustomAuthzV1TokenEndpointFormat = "https://%s/oauth2/%s/v1/token" +) diff --git a/internal/sessiontoken/sessiontoken.go b/internal/sessiontoken/sessiontoken.go index 83787e8..99cc00e 100644 --- a/internal/sessiontoken/sessiontoken.go +++ b/internal/sessiontoken/sessiontoken.go @@ -47,20 +47,18 @@ import ( oaws "github.com/okta/okta-aws-cli/internal/aws" boff "github.com/okta/okta-aws-cli/internal/backoff" "github.com/okta/okta-aws-cli/internal/config" + "github.com/okta/okta-aws-cli/internal/okta" "github.com/okta/okta-aws-cli/internal/output" + "github.com/okta/okta-aws-cli/internal/utils" ) const ( amazonAWS = "amazon_aws" accept = "Accept" - applicationJSON = "application/json" - applicationXWwwForm = "application/x-www-form-urlencoded" - contentType = "Content-Type" userAgent = "User-Agent" nameKey = "name" saml2Attribute = "saml2:attribute" samlAttributesRole = "https://aws.amazon.com/SAML/Attributes/Role" - oauthV1TokenEndpointFmt = "https://%s/oauth2/v1/token" askIDPError = "error asking for IdP selection: %w" noRoleError = "provider %q has no roles to choose from" noIDPsError = "no IdPs to choose from" @@ -90,19 +88,6 @@ type SessionToken struct { fedAppAlreadySelected bool } -// accessToken Encapsulates an Okta access token -// https://developer.okta.com/docs/reference/api/oidc/#token -type accessToken struct { - AccessToken string `json:"access_token,omitempty"` - IDToken string `json:"id_token,omitempty"` - TokenType string `json:"token_type,omitempty"` - Scope string `json:"scope,omitempty"` - ExpiresIn int64 `json:"expires_in,omitempty"` - RefreshToken string `json:"refresh_token,omitempty"` - DeviceSecret string `json:"device_secret,omitempty"` - Expiry string `json:"expiry"` -} - // deviceAuthorization Encapsulates Okta API result to // /oauth2/v1/device/authorize call type deviceAuthorization struct { @@ -129,12 +114,6 @@ type oktaApplication struct { } `json:"settings"` } -// apiError Wrapper for Okta API error -type apiError struct { - Error string `json:"error,omitempty"` - ErrorDescription string `json:"error_description,omitempty"` -} - // idpAndRole IdP and role pairs type idpAndRole struct { idp string @@ -168,7 +147,7 @@ func NewSessionToken(config *config.Config) (token *SessionToken, err error) { // token. func (s *SessionToken) EstablishToken() error { clientID := s.config.OIDCAppID() - var at *accessToken + var at *okta.AccessToken var apps []*oktaApplication var err error at = s.cachedAccessToken() @@ -309,7 +288,7 @@ func (s *SessionToken) selectFedApp(apps []*oktaApplication) (string, error) { return idps[selected].ID, nil } -func (s *SessionToken) establishTokenWithFedAppID(clientID, fedAppID string, at *accessToken) error { +func (s *SessionToken) establishTokenWithFedAppID(clientID, fedAppID string, at *okta.AccessToken) error { at, err := s.fetchSSOWebToken(clientID, fedAppID, at) if err != nil { return err @@ -575,7 +554,7 @@ func (s *SessionToken) extractIDPAndRolesMapFromAssertion(encoded string) (irmap } // fetchSAMLAssertion Gets the SAML assertion from Okta API /login/token/sso -func (s *SessionToken) fetchSAMLAssertion(at *accessToken) (assertion string, err error) { +func (s *SessionToken) fetchSAMLAssertion(at *okta.AccessToken) (assertion string, err error) { params := url.Values{"token": {at.AccessToken}} apiURL := fmt.Sprintf("https://%s/login/token/sso?%s", s.config.OrgDomain(), params.Encode()) @@ -605,8 +584,8 @@ func (s *SessionToken) fetchSAMLAssertion(at *accessToken) (assertion string, er // fetchSSOWebToken see: // https://developer.okta.com/docs/reference/api/oidc/#token -func (s *SessionToken) fetchSSOWebToken(clientID, awsFedAppID string, at *accessToken) (token *accessToken, err error) { - apiURL := fmt.Sprintf(oauthV1TokenEndpointFmt, s.config.OrgDomain()) +func (s *SessionToken) fetchSSOWebToken(clientID, awsFedAppID string, at *okta.AccessToken) (token *okta.AccessToken, err error) { + apiURL := fmt.Sprintf(okta.OAuthV1TokenEndpointFormat, s.config.OrgDomain()) data := url.Values{ "client_id": {clientID}, @@ -624,8 +603,8 @@ func (s *SessionToken) fetchSSOWebToken(clientID, awsFedAppID string, at *access if err != nil { return nil, err } - req.Header.Add(accept, applicationJSON) - req.Header.Add(contentType, applicationXWwwForm) + req.Header.Add(accept, utils.ApplicationJSON) + req.Header.Add(utils.ContentType, utils.ApplicationXFORM) req.Header.Add(userAgent, agent.NewUserAgent(config.Version).String()) resp, err := s.config.HTTPClient().Do(req) @@ -639,7 +618,7 @@ func (s *SessionToken) fetchSSOWebToken(clientID, awsFedAppID string, at *access return nil, fmt.Errorf(baseErrStr, resp.Status) } - var apiErr apiError + var apiErr okta.APIError err = json.NewDecoder(resp.Body).Decode(&apiErr) if err != nil { return nil, fmt.Errorf(baseErrStr, resp.Status) @@ -648,7 +627,7 @@ func (s *SessionToken) fetchSSOWebToken(clientID, awsFedAppID string, at *access return nil, fmt.Errorf(baseErrStr+", error: %q, description: %q", resp.Status, apiErr.Error, apiErr.ErrorDescription) } - token = &accessToken{} + token = &okta.AccessToken{} err = json.NewDecoder(resp.Body).Decode(token) if err != nil { return nil, err @@ -695,7 +674,7 @@ func (s *SessionToken) promptAuthentication(da *deviceAuthorization) { // after getting anything other than a 403 on /api/v1/apps will be wrapped as as // an error that is related having multiple fed apps available. Requires // assoicated OIDC app has been granted okta.apps.read to its scope. -func (s *SessionToken) listFedApps(clientID string, at *accessToken) (apps []*oktaApplication, err error) { +func (s *SessionToken) listFedApps(clientID string, at *okta.AccessToken) (apps []*oktaApplication, err error) { apiURL, err := url.Parse(fmt.Sprintf("https://%s/api/v1/apps", s.config.OrgDomain())) if err != nil { return nil, err @@ -710,8 +689,8 @@ func (s *SessionToken) listFedApps(clientID string, at *accessToken) (apps []*ok return nil, err } - req.Header.Add(accept, applicationJSON) - req.Header.Add(contentType, applicationJSON) + req.Header.Add(accept, utils.ApplicationJSON) + req.Header.Add(utils.ContentType, utils.ApplicationJSON) req.Header.Add(userAgent, agent.NewUserAgent(config.Version).String()) req.Header.Add("Authorization", fmt.Sprintf("%s %s", at.TokenType, at.AccessToken)) resp, err := s.config.HTTPClient().Do(req) @@ -751,15 +730,15 @@ func (s *SessionToken) listFedApps(clientID string, at *accessToken) (apps []*ok // fetchAccessToken see: // https://developer.okta.com/docs/reference/api/oidc/#token -func (s *SessionToken) fetchAccessToken(clientID string, deviceAuth *deviceAuthorization) (at *accessToken, err error) { - apiURL := fmt.Sprintf(oauthV1TokenEndpointFmt, s.config.OrgDomain()) +func (s *SessionToken) fetchAccessToken(clientID string, deviceAuth *deviceAuthorization) (at *okta.AccessToken, err error) { + apiURL := fmt.Sprintf(okta.OAuthV1TokenEndpointFormat, s.config.OrgDomain()) req, err := http.NewRequest(http.MethodPost, apiURL, nil) if err != nil { return nil, err } - req.Header.Add(accept, applicationJSON) - req.Header.Add(contentType, applicationXWwwForm) + req.Header.Add(accept, utils.ApplicationJSON) + req.Header.Add(utils.ContentType, utils.ApplicationXFORM) req.Header.Add(userAgent, agent.NewUserAgent(config.Version).String()) var bodyBytes []byte @@ -806,7 +785,7 @@ func (s *SessionToken) fetchAccessToken(clientID string, deviceAuth *deviceAutho return nil, err } - at = &accessToken{} + at = &okta.AccessToken{} err = json.NewDecoder(bytes.NewReader(bodyBytes)).Decode(at) if err != nil { return nil, err @@ -828,8 +807,8 @@ func (s *SessionToken) authorize(clientID string) (*deviceAuthorization, error) if err != nil { return nil, err } - req.Header.Add(accept, applicationJSON) - req.Header.Add(contentType, applicationXWwwForm) + req.Header.Add(accept, utils.ApplicationJSON) + req.Header.Add(utils.ContentType, utils.ApplicationXFORM) req.Header.Add(userAgent, agent.NewUserAgent(config.Version).String()) resp, err := s.config.HTTPClient().Do(req) @@ -840,8 +819,8 @@ func (s *SessionToken) authorize(clientID string) (*deviceAuthorization, error) return nil, fmt.Errorf("authorize received API response %q", resp.Status) } - ct := resp.Header.Get(contentType) - if !strings.Contains(ct, applicationJSON) { + ct := resp.Header.Get(utils.ContentType) + if !strings.Contains(ct, utils.ApplicationJSON) { return nil, fmt.Errorf("authorize non-JSON API response content type %q", ct) } @@ -913,8 +892,8 @@ func findSAMLRoleAttibute(n *html.Node) (node *html.Node, found bool) { return nil, false } -func apiErr(bodyBytes []byte) (ae *apiError, err error) { - ae = &apiError{} +func apiErr(bodyBytes []byte) (ae *okta.APIError, err error) { + ae = &okta.APIError{} err = json.NewDecoder(bytes.NewReader(bodyBytes)).Decode(ae) return } @@ -934,7 +913,7 @@ func (s *SessionToken) isClassicOrg() bool { if err != nil { return false } - req.Header.Add(accept, applicationJSON) + req.Header.Add(accept, utils.ApplicationJSON) req.Header.Add(userAgent, agent.NewUserAgent(config.Version).String()) resp, err := s.config.HTTPClient().Do(req) @@ -960,7 +939,7 @@ func (s *SessionToken) isClassicOrg() bool { // cachedAccessToken will returned the cached access token if it exists and is // not expired. -func (s *SessionToken) cachedAccessToken() (at *accessToken) { +func (s *SessionToken) cachedAccessToken() (at *okta.AccessToken) { homeDir, err := os.UserHomeDir() if err != nil { return @@ -971,7 +950,7 @@ func (s *SessionToken) cachedAccessToken() (at *accessToken) { return } - _at := accessToken{} + _at := okta.AccessToken{} err = json.Unmarshal(atJSON, &_at) if err != nil { return @@ -991,7 +970,7 @@ func (s *SessionToken) cachedAccessToken() (at *accessToken) { // cacheAccessToken will cache the access token for later use if enabled. Silent // if fails. -func (s *SessionToken) cacheAccessToken(at *accessToken) { +func (s *SessionToken) cacheAccessToken(at *okta.AccessToken) { if !s.config.CacheAccessToken() { return } diff --git a/internal/testutils/testutils.go b/internal/testutils/testutils.go new file mode 100644 index 0000000..d9e4926 --- /dev/null +++ b/internal/testutils/testutils.go @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2023-Present, Okta, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package testutils + +import ( + "bytes" + "encoding/json" + "io" + "log" + "net/http" + "os" + "reflect" + "regexp" + "strings" + "time" + + "github.com/okta/okta-aws-cli/internal/config" + "github.com/okta/okta-aws-cli/internal/utils" + "gopkg.in/dnaeon/go-vcr.v3/cassette" +) + +const ( + // TestDomainName Fake domain name for tests / recordings + TestDomainName = "test.dne-okta.com" + // ClientAssertionNameValueRE client assertion regular expression format + ClientAssertionNameValueRE = "client_assertion=[^&]+" + // ClientAssertionNameValueValue client asserver name and value url encoded format + ClientAssertionNameValueValue = "client_assertion=abc123" +) + +// TestClock Is a test clock of the Clock interface +type TestClock struct{} + +// Now The test clock's now +func (TestClock) Now() time.Time { return time.Date(2009, time.November, 10, 23, 0, 0, 0, time.UTC) } + +// NewTestClock New test clock constructor +func NewTestClock() config.Clock { + return &TestClock{} +} + +// VCROktaAPIRequestHook Modifies VCR recordings. +func VCROktaAPIRequestHook(i *cassette.Interaction) error { + // need to scrub Okta org strings and rewrite as test.dne-okta.com so that + // HTTP requests that escape VCR are bad. + + // test.dne-okta.com + vcrHostname := TestDomainName + // example.okta.com + orgHostname := os.Getenv("OKTA_ORG_DOMAIN") + + // save disk space, clean up what gets written to disk + i.Request.Headers.Del("User-Agent") + deleteResponseHeaders := []string{ + "Cache-Control", + "Content-Security-Policy", + "Content-Security-Policy-Report-Only", + "duration", + "Expect-Ct", + "Expires", + "P3p", + "Pragma", + "Public-Key-Pins-Report-Only", + "Server", + "Set-Cookie", + "Strict-Transport-Security", + "Vary", + } + for _, header := range deleteResponseHeaders { + i.Response.Headers.Del(header) + } + for name := range i.Response.Headers { + // delete all X-headers + if strings.HasPrefix(name, "X-") { + i.Response.Headers.Del(name) + continue + } + } + + // scrub client assertion out of token requests + m := regexp.MustCompile(ClientAssertionNameValueRE) + i.Request.URL = m.ReplaceAllString(i.Request.URL, ClientAssertionNameValueValue) + + // %s/example.okta.com/test.dne-okta.com/ + i.Request.Host = strings.ReplaceAll(i.Request.Host, orgHostname, vcrHostname) + + // %s/example.okta.com/test.dne-okta.com/ + i.Request.URL = strings.ReplaceAll(i.Request.URL, orgHostname, vcrHostname) + + // %s/example.okta.com/test.dne-okta.com/ + i.Request.Body = strings.ReplaceAll(i.Request.Body, orgHostname, vcrHostname) + + return nil +} + +// VCROktaAPIRequestMatcher Defines how VCR will match requests to responses. +func VCROktaAPIRequestMatcher(r *http.Request, i cassette.Request) bool { + // scrub access token for lookup + if r.URL.RawQuery != "" { + m := regexp.MustCompile(ClientAssertionNameValueRE) + r.URL.RawQuery = m.ReplaceAllString(r.URL.RawQuery, ClientAssertionNameValueValue) + } + // scrub host for lookup + r.URL.Host = TestDomainName + + // Default matcher compares method and URL only + if !cassette.DefaultMatcher(r, i) { + return false + } + // TODO: there might be header information we could inspect to make this more precise + if r.Body == nil { + return true + } + + var b bytes.Buffer + if _, err := b.ReadFrom(r.Body); err != nil { + log.Printf("[DEBUG] Failed to read request body from cassette: %v", err) + return false + } + r.Body = io.NopCloser(&b) + reqBody := b.String() + // If body matches identically, we are done + if reqBody == i.Body { + return true + } + + // JSON might be the same, but reordered. Try parsing json and comparing + contentType := r.Header.Get(utils.ContentType) + if strings.Contains(contentType, utils.ApplicationJSON) { + var reqJSON, cassetteJSON interface{} + if err := json.Unmarshal([]byte(reqBody), &reqJSON); err != nil { + log.Printf("[DEBUG] Failed to unmarshall request json: %v", err) + return false + } + if err := json.Unmarshal([]byte(i.Body), &cassetteJSON); err != nil { + log.Printf("[DEBUG] Failed to unmarshall cassette json: %v", err) + return false + } + return reflect.DeepEqual(reqJSON, cassetteJSON) + } + + return true +} diff --git a/internal/utils/utils.go b/internal/utils/utils.go new file mode 100644 index 0000000..65147dd --- /dev/null +++ b/internal/utils/utils.go @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023-Present, Okta, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package utils + +const ( + // ContentType http header content type + ContentType = "Content-Type" + // ApplicationJSON content value for json + ApplicationJSON = "application/json" + // ApplicationXFORM content type value for web form + ApplicationXFORM = "application/x-www-form-urlencoded" +) diff --git a/test/fixtures/vcr/TestM2MAuthAccessToken.yaml b/test/fixtures/vcr/TestM2MAuthAccessToken.yaml new file mode 100644 index 0000000..99864b5 --- /dev/null +++ b/test/fixtures/vcr/TestM2MAuthAccessToken.yaml @@ -0,0 +1,42 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + transfer_encoding: [] + trailer: {} + host: test.dne-okta.com + remote_addr: "" + request_uri: "" + body: "" + form: {} + headers: + Accept: + - application/json + Content-Type: + - application/x-www-form-urlencoded + url: https://test.dne-okta.com/oauth2/aus8w23r13NvyUwln1d7/v1/token?client_assertion=abc123&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer&grant_type=client_credentials&scope=okta-aws-cli + method: POST + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + transfer_encoding: [] + trailer: {} + content_length: -1 + uncompressed: false + body: '{"token_type":"Bearer","expires_in":3600,"access_token":"eyJraWQiOiJjRnZ0bHRBbzF1cVhMT0ZLOGxFTVA2a3czd25pVVRHMVFSckhvQXBNTkF3IiwiYWxnIjoiUlMyNTYifQ.eyJ2ZXIiOjEsImp0aSI6IkFULjYxZTdVRU1oUGZWUkQ0LU51Wm9TbUsxSDJ2VGpjbXp2Njl4ZFd0VXpBVkkiLCJpc3MiOiJodHRwczovL21tb25kcmFnb24tYXdzLWNsaS0wMC5va3RhcHJldmlldy5jb20vb2F1dGgyL2F1czh3MjNyMTNOdnlVd2xuMWQ3IiwiYXVkIjoiaHR0cHM6Ly9va3RhLWF3cy1jbGktYXV0aG9yaXplciIsImlhdCI6MTY5NTc2NzAzNCwiZXhwIjoxNjk1NzcwNjM0LCJjaWQiOiIwb2FhNGh0ZzcyVE5ya1REcjFkNyIsInNjcCI6WyJva3RhLWF3cy1jbGkiXSwic3ViIjoiMG9hYTRodGc3MlROcmtURHIxZDcifQ.jE6sEw1acXo_pccQUaOXrT4uQ0KI9fLYKHsh23aCsXPBrdfaVYe_yEPdZM7GWg3VYpG9VQVo-I26IKb88Nqnxw11ABMIIglHXlUx0AJHHPZP7PXi8p91y0WG7lDoU2seiX9ce8DXX83R831qLSbQImUOKOz9aNemmvSzwPvDPnjnWNQq_Dmn_MDFiaS4cqMcWB_d_SFVAFVoa-ZC-Rli0kZ63-0ZAtmyv8unHAd1eLCyq3eikeFKXRuSKaAlAgdix2OUHnC9IL_gym9xiZDXDqASmKOqRdIcJ6Q0vn8ujvKwcO_LYPAZkkfkVkDeMEvm_ee43jgcPNF-xdmLJ3YnWg","scope":"okta-aws-cli"}' + headers: + Content-Type: + - application/json + Date: + - Tue, 26 Sep 2023 22:23:54 GMT + Report-To: + - '{"group":"csp","max_age":31536000,"endpoints":[{"url":"https://oktacsp.report-uri.com/a/t/g"}],"include_subdomains":true}' + status: 200 OK + code: 200 + duration: 620.35651ms diff --git a/test/fixtures/vcr/TestM2MAuthMakeClientAssertion.yaml b/test/fixtures/vcr/TestM2MAuthMakeClientAssertion.yaml new file mode 100644 index 0000000..2797c38 --- /dev/null +++ b/test/fixtures/vcr/TestM2MAuthMakeClientAssertion.yaml @@ -0,0 +1,3 @@ +--- +version: 2 +interactions: [] From c500fb4f6192fa3abd524ed993799d2fbb2bfc1d Mon Sep 17 00:00:00 2001 From: Mike Mondragon Date: Wed, 27 Sep 2023 14:31:15 -0700 Subject: [PATCH 05/42] Session token is better described as a domain of Web SSO authentication. --- cmd/root/web/web.go | 6 +- internal/m2mauth/m2mauth.go | 7 +- internal/okta/application.go | 32 +++ internal/okta/device_authorization.go | 28 +++ internal/okta/organization.go | 25 ++ .../multiple_fed_apps_error.go | 2 +- .../webssoauth.go} | 236 ++++++++---------- .../webssoauth_test.go} | 4 +- 8 files changed, 203 insertions(+), 137 deletions(-) create mode 100644 internal/okta/application.go create mode 100644 internal/okta/device_authorization.go create mode 100644 internal/okta/organization.go rename internal/{sessiontoken => webssoauth}/multiple_fed_apps_error.go (97%) rename internal/{sessiontoken/sessiontoken.go => webssoauth/webssoauth.go} (77%) rename internal/{sessiontoken/sessiontoken_test.go => webssoauth/webssoauth_test.go} (93%) diff --git a/cmd/root/web/web.go b/cmd/root/web/web.go index 5316f08..3e209ec 100644 --- a/cmd/root/web/web.go +++ b/cmd/root/web/web.go @@ -21,7 +21,7 @@ import ( "github.com/okta/okta-aws-cli/internal/config" cliFlag "github.com/okta/okta-aws-cli/internal/flag" - "github.com/okta/okta-aws-cli/internal/sessiontoken" + "github.com/okta/okta-aws-cli/internal/webssoauth" ) var ( @@ -80,11 +80,11 @@ func NewWebCommand() *cobra.Command { // } // return webAuth.EstablishIAMCredentials() - st, err := sessiontoken.NewSessionToken(config) + st, err := webssoauth.NewWebSSOAuthentication(config) if err != nil { return err } - return st.EstablishToken() + return st.EstablishIAMCredentials() }, } diff --git a/internal/m2mauth/m2mauth.go b/internal/m2mauth/m2mauth.go index 0d991a0..edc28b5 100644 --- a/internal/m2mauth/m2mauth.go +++ b/internal/m2mauth/m2mauth.go @@ -42,7 +42,7 @@ type M2MAuthentication struct { config *config.Config } -// NewM2MAuthentication New M2M Authenticator constructor +// NewM2MAuthentication New M2M Authentication constructor func NewM2MAuthentication(config *config.Config) (*M2MAuthentication, error) { m := M2MAuthentication{ config: config, @@ -52,6 +52,11 @@ func NewM2MAuthentication(config *config.Config) (*M2MAuthentication, error) { // EstablishIAMCredentials Full operation to fetch temporary IAM credentials and // output them to preferred format. +// +// The overall API interactions are as follows: +// +// - CLI requests access token from custom authz server at /oauth2/{authzID}/v1/token +// - CLI presents access token to AWS STS for temporary AWS IAM creds func (m *M2MAuthentication) EstablishIAMCredentials() error { _, err := m.AccessToken() if err != nil { diff --git a/internal/okta/application.go b/internal/okta/application.go new file mode 100644 index 0000000..103e376 --- /dev/null +++ b/internal/okta/application.go @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023-Present, Okta, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package okta + +// Application Okta API application object +// See: https://developer.okta.com/docs/reference/api/apps/#application-object +type Application struct { + ID string `json:"id"` + Label string `json:"label"` + Name string `json:"name"` + Status string `json:"status"` + Settings struct { + App struct { + IdentityProviderARN string `json:"identityProviderArn"` + WebSSOClientID string `json:"webSSOAllowedClient"` + } `json:"app"` + } `json:"settings"` +} diff --git a/internal/okta/device_authorization.go b/internal/okta/device_authorization.go new file mode 100644 index 0000000..7f82dc5 --- /dev/null +++ b/internal/okta/device_authorization.go @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023-Present, Okta, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package okta + +// 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"` + VerificationURI string `json:"verification_uri,omitempty"` + VerificationURIComplete string `json:"verification_uri_complete,omitempty"` + ExpiresIn int `json:"expires_in,omitempty"` + Interval int `json:"interval,omitempty"` +} diff --git a/internal/okta/organization.go b/internal/okta/organization.go new file mode 100644 index 0000000..a08fc0b --- /dev/null +++ b/internal/okta/organization.go @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023-Present, Okta, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package okta + +// Organization The well known Okta organization at GET /.well-known/okta-organization +type Organization struct { + ID string `json:"id"` + Pipeline string `json:"pipeline"` + Links interface{} `json:"_links,omitempty"` + Settings interface{} `json:"settings,omitempty"` +} diff --git a/internal/sessiontoken/multiple_fed_apps_error.go b/internal/webssoauth/multiple_fed_apps_error.go similarity index 97% rename from internal/sessiontoken/multiple_fed_apps_error.go rename to internal/webssoauth/multiple_fed_apps_error.go index 70e8697..6897dcf 100644 --- a/internal/sessiontoken/multiple_fed_apps_error.go +++ b/internal/webssoauth/multiple_fed_apps_error.go @@ -14,7 +14,7 @@ * limitations under the License. */ -package sessiontoken +package webssoauth import "fmt" diff --git a/internal/sessiontoken/sessiontoken.go b/internal/webssoauth/webssoauth.go similarity index 77% rename from internal/sessiontoken/sessiontoken.go rename to internal/webssoauth/webssoauth.go index 99cc00e..c46c9ed 100644 --- a/internal/sessiontoken/sessiontoken.go +++ b/internal/webssoauth/webssoauth.go @@ -14,7 +14,7 @@ * limitations under the License. */ -package sessiontoken +package webssoauth import ( "bytes" @@ -82,38 +82,22 @@ type roleTemplateData struct { Role string } -// SessionToken Encapsulates the work of getting an AWS Session Token -type SessionToken struct { +// WebSSOAuthentication Encapsulates the work of getting temporary IAM +// credentials through Okta's Web SSO authentication with an Okta AWS Federation +// Application. +// +// The overall API interactions are as follows: +// - CLI starts device authorization at /oauth2/v1/device/authorize +// - CLI polls for access token from device auth at /oauth2/v1/token +// - Access token granted by Okta once user is authorized +// +// - CLI presents access token to Okta AWS Fed app for a SAML assertion at /login/token/sso +// - CLI presents SAML assertion to AWS STS for temporary AWS IAM creds +type WebSSOAuthentication struct { config *config.Config fedAppAlreadySelected bool } -// 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"` - VerificationURI string `json:"verification_uri,omitempty"` - VerificationURIComplete string `json:"verification_uri_complete,omitempty"` - ExpiresIn int `json:"expires_in,omitempty"` - Interval int `json:"interval,omitempty"` -} - -// oktaApplication Okta API application object -// See: https://developer.okta.com/docs/reference/api/apps/#application-object -type oktaApplication struct { - ID string `json:"id"` - Label string `json:"label"` - Name string `json:"name"` - Status string `json:"status"` - Settings struct { - App struct { - IdentityProviderARN string `json:"identityProviderArn"` - WebSSOClientID string `json:"webSSOAllowedClient"` - } `json:"app"` - } `json:"settings"` -} - // idpAndRole IdP and role pairs type idpAndRole struct { idp string @@ -129,12 +113,12 @@ var stderrIsOutAskOpt = func(options *survey.AskOptions) error { return nil } -// NewSessionToken Creates a new session token. -func NewSessionToken(config *config.Config) (token *SessionToken, err error) { +// NewWebSSOAuthentication New Web SSO Authentication constructor +func NewWebSSOAuthentication(config *config.Config) (token *WebSSOAuthentication, err error) { if err != nil { return nil, err } - token = &SessionToken{ + token = &WebSSOAuthentication{ config: config, } if token.isClassicOrg() { @@ -143,37 +127,36 @@ func NewSessionToken(config *config.Config) (token *SessionToken, err error) { return token, nil } -// EstablishToken Template method of the steps to establish an AWS session -// token. -func (s *SessionToken) EstablishToken() error { - clientID := s.config.OIDCAppID() +// EstablishIAMCredentials Steps to establish an AWS session token. +func (w *WebSSOAuthentication) EstablishIAMCredentials() error { + clientID := w.config.OIDCAppID() var at *okta.AccessToken - var apps []*oktaApplication + var apps []*okta.Application var err error - at = s.cachedAccessToken() + at = w.cachedAccessToken() - // If there is a cached token, and it isn't expired, but the API 401s redo - // the authorize step. + // If there is a cached okta access token, and it isn't expired, but the API + // 401s redo the authorize step. for attempt := 1; attempt <= 2; attempt++ { err = nil if at == nil { - deviceAuth, err := s.authorize(clientID) + deviceAuth, err := w.authorize(clientID) if err != nil { return err } - s.promptAuthentication(deviceAuth) + w.promptAuthentication(deviceAuth) - at, err = s.fetchAccessToken(clientID, deviceAuth) + at, err = w.fetchAccessToken(clientID, deviceAuth) if err != nil { return err } at.Expiry = time.Now().Add(time.Duration(at.ExpiresIn) * time.Second).Format(time.RFC3339) - s.cacheAccessToken(at) + w.cacheAccessToken(at) } - if s.config.FedAppID() != "" { + if w.config.FedAppID() != "" { // Alternate path when operator knows their AWS Fed app ID - err = s.establishTokenWithFedAppID(clientID, s.config.FedAppID(), at) + err = w.establishTokenWithFedAppID(clientID, w.config.FedAppID(), at) if at != nil && err != nil { // possible bad cached access token, retry at = nil @@ -182,7 +165,7 @@ func (s *SessionToken) EstablishToken() error { return err } - apps, err = s.listFedApps(clientID, at) + apps, err = w.listFedApps(clientID, at) if at != nil && err != nil { // possible bad cached access token, retry at = nil @@ -212,30 +195,30 @@ AWS Federation App with --aws-acct-fed-app-id FED_APP_ID // Here, we do want to prompt for selection of the Fed App. // If the app is making use of "Role value pattern" on AWS settings we // won't get the real ARN until we establish the web sso token. - s.fedAppAlreadySelected = true - fedAppID, err = s.selectFedApp(apps) + w.fedAppAlreadySelected = true + fedAppID, err = w.selectFedApp(apps) if err != nil { return err } } - return s.establishTokenWithFedAppID(clientID, fedAppID, at) + return w.establishTokenWithFedAppID(clientID, fedAppID, at) } // choiceFriendlyLabelIDP returns a friendly choice for pretty printing IDP // labels. alternative value is the default value to return if a friendly // determination can not be made. -func (s *SessionToken) choiceFriendlyLabelIDP(alternative string, oktaConfig *config.OktaYamlConfig, arn string) string { +func (w *WebSSOAuthentication) choiceFriendlyLabelIDP(alternative string, oktaConfig *config.OktaYamlConfig, arn string) string { if oktaConfig == nil { return alternative } if label, ok := oktaConfig.AWSCLI.IDPS[arn]; ok { - if s.config.Debug() { + if w.config.Debug() { fmt.Fprintf(os.Stderr, " found IdP ARN %q having friendly label %q\n", arn, label) } return label - } else if s.config.Debug() { + } else if w.config.Debug() { fmt.Fprintf(os.Stderr, " did not find friendly label for IdP ARN\n") fmt.Fprintf(os.Stderr, arnPrintFmt, arn) fmt.Fprintf(os.Stderr, " in okta.yaml awscli.idps map:\n") @@ -246,17 +229,17 @@ func (s *SessionToken) choiceFriendlyLabelIDP(alternative string, oktaConfig *co return alternative } -func (s *SessionToken) selectFedApp(apps []*oktaApplication) (string, error) { - idps := make(map[string]*oktaApplication) +func (w *WebSSOAuthentication) selectFedApp(apps []*okta.Application) (string, error) { + idps := make(map[string]*okta.Application) choices := make([]string, len(apps)) var selected string - oktaConfig, _ := s.config.OktaConfig() + oktaConfig, _ := w.config.OktaConfig() for i, app := range apps { - choiceLabel := s.choiceFriendlyLabelIDP(app.Label, oktaConfig, app.Settings.App.IdentityProviderARN) + choiceLabel := w.choiceFriendlyLabelIDP(app.Label, oktaConfig, app.Settings.App.IdentityProviderARN) // when OKTA_AWSCLI_IAM_IDP / --aws-iam-idp is set - if s.config.AWSIAMIdP() == app.Settings.App.IdentityProviderARN { + if w.config.AWSIAMIdP() == app.Settings.App.IdentityProviderARN { idpData := idpTemplateData{ IDP: choiceLabel, } @@ -288,33 +271,33 @@ func (s *SessionToken) selectFedApp(apps []*oktaApplication) (string, error) { return idps[selected].ID, nil } -func (s *SessionToken) establishTokenWithFedAppID(clientID, fedAppID string, at *okta.AccessToken) error { - at, err := s.fetchSSOWebToken(clientID, fedAppID, at) +func (w *WebSSOAuthentication) establishTokenWithFedAppID(clientID, fedAppID string, at *okta.AccessToken) error { + at, err := w.fetchSSOWebToken(clientID, fedAppID, at) if err != nil { return err } - assertion, err := s.fetchSAMLAssertion(at) + assertion, err := w.fetchSAMLAssertion(at) if err != nil { return err } - idpRolesMap, err := s.extractIDPAndRolesMapFromAssertion(assertion) + idpRolesMap, err := w.extractIDPAndRolesMapFromAssertion(assertion) if err != nil { return err } - iar, err := s.promptForIdpAndRole(idpRolesMap) + iar, err := w.promptForIdpAndRole(idpRolesMap) if err != nil { return err } - ac, err := s.fetchAWSCredentialWithSAMLRole(iar, assertion) + ac, err := w.fetchAWSCredentialWithSAMLRole(iar, assertion) if err != nil { return err } - err = s.renderCredential(ac) + err = w.renderCredential(ac) if err != nil { return err } @@ -323,31 +306,31 @@ func (s *SessionToken) establishTokenWithFedAppID(clientID, fedAppID string, at } // renderCredential Renders the credentials in the prescribed format. -func (s *SessionToken) renderCredential(ac *oaws.Credential) error { +func (w *WebSSOAuthentication) renderCredential(ac *oaws.Credential) error { var o output.Outputter - switch s.config.Format() { + switch w.config.Format() { case config.AWSCredentialsFormat: - expiry := time.Now().Add(time.Duration(s.config.AWSSessionDuration()) * time.Second).Format(time.RFC3339) - o = output.NewAWSCredentialsFile(s.config.LegacyAWSVariables(), s.config.ExpiryAWSVariables(), expiry) + expiry := time.Now().Add(time.Duration(w.config.AWSSessionDuration()) * time.Second).Format(time.RFC3339) + o = output.NewAWSCredentialsFile(w.config.LegacyAWSVariables(), w.config.ExpiryAWSVariables(), expiry) default: - o = output.NewEnvVar(s.config.LegacyAWSVariables()) + o = output.NewEnvVar(w.config.LegacyAWSVariables()) fmt.Fprintf(os.Stderr, "\n") } - return o.Output(s.config, ac) + return o.Output(w.config, ac) } // fetchAWSCredentialWithSAMLRole Get AWS Credentials with an STS Assume Role With SAML AWS // API call. -func (s *SessionToken) fetchAWSCredentialWithSAMLRole(iar *idpAndRole, assertion string) (credential *oaws.Credential, err error) { - awsCfg := aws.NewConfig().WithHTTPClient(s.config.HTTPClient()) +func (w *WebSSOAuthentication) fetchAWSCredentialWithSAMLRole(iar *idpAndRole, assertion string) (credential *oaws.Credential, err error) { + awsCfg := aws.NewConfig().WithHTTPClient(w.config.HTTPClient()) sess, err := session.NewSession(awsCfg) if err != nil { return nil, err } svc := sts.New(sess) input := &sts.AssumeRoleWithSAMLInput{ - DurationSeconds: aws.Int64(s.config.AWSSessionDuration()), + DurationSeconds: aws.Int64(w.config.AWSSessionDuration()), PrincipalArn: aws.String(iar.idp), RoleArn: aws.String(iar.role), SAMLAssertion: aws.String(assertion), @@ -368,17 +351,17 @@ func (s *SessionToken) fetchAWSCredentialWithSAMLRole(iar *idpAndRole, assertion // choiceFriendlyLabelRole returns a friendly choice for pretty printing Role // labels. The ARN default value to return if a friendly determination can not // be made. -func (s *SessionToken) choiceFriendlyLabelRole(arn string, oktaConfig *config.OktaYamlConfig) string { +func (w *WebSSOAuthentication) choiceFriendlyLabelRole(arn string, oktaConfig *config.OktaYamlConfig) string { if oktaConfig == nil { return arn } if label, ok := oktaConfig.AWSCLI.ROLES[arn]; ok { - if s.config.Debug() { + if w.config.Debug() { fmt.Fprintf(os.Stderr, " found Role ARN %q having friendly label %q\n", arn, label) } return label - } else if s.config.Debug() { + } else if w.config.Debug() { fmt.Fprintf(os.Stderr, " did not find friendly label for Role ARN\n") fmt.Fprintf(os.Stderr, arnPrintFmt, arn) fmt.Fprintf(os.Stderr, " in okta.yaml awscli.roles map:\n") @@ -390,15 +373,15 @@ func (s *SessionToken) choiceFriendlyLabelRole(arn string, oktaConfig *config.Ok } // promptForRole prompt operator for the AWS Role ARN given a slice of Role ARNs -func (s *SessionToken) promptForRole(idp string, roleARNs []string) (roleARN string, err error) { - oktaConfig, _ := s.config.OktaConfig() +func (w *WebSSOAuthentication) promptForRole(idp string, roleARNs []string) (roleARN string, err error) { + oktaConfig, _ := w.config.OktaConfig() - if len(roleARNs) == 1 || s.config.AWSIAMRole() != "" { - roleARN = s.config.AWSIAMRole() + if len(roleARNs) == 1 || w.config.AWSIAMRole() != "" { + roleARN = w.config.AWSIAMRole() if len(roleARNs) == 1 { roleARN = roleARNs[0] } - roleLabel := s.choiceFriendlyLabelRole(roleARN, oktaConfig) + roleLabel := w.choiceFriendlyLabelRole(roleARN, oktaConfig) roleData := roleTemplateData{ Role: roleLabel, } @@ -413,7 +396,7 @@ func (s *SessionToken) promptForRole(idp string, roleARNs []string) (roleARN str promptRoles := []string{} labelsARNs := map[string]string{} for _, arn := range roleARNs { - roleLabel := s.choiceFriendlyLabelRole(arn, oktaConfig) + roleLabel := w.choiceFriendlyLabelRole(arn, oktaConfig) promptRoles = append(promptRoles, roleLabel) labelsARNs[roleLabel] = arn } @@ -439,23 +422,23 @@ func (s *SessionToken) promptForRole(idp string, roleARNs []string) (roleARN str // promptForIDP prompt operator for the AWS IdP ARN given a slice of IdP ARNs. // If the fedApp has already been selected via an ask one survey we don't need // to pretty print out the IdP name again. -func (s *SessionToken) promptForIDP(idpARNs []string) (idpARN string, err error) { - oktaConfig, _ := s.config.OktaConfig() +func (w *WebSSOAuthentication) promptForIDP(idpARNs []string) (idpARN string, err error) { + oktaConfig, _ := w.config.OktaConfig() if len(idpARNs) == 0 { return idpARN, errors.New(noIDPsError) } - if len(idpARNs) == 1 || s.config.AWSIAMIdP() != "" { - idpARN = s.config.AWSIAMIdP() + if len(idpARNs) == 1 || w.config.AWSIAMIdP() != "" { + idpARN = w.config.AWSIAMIdP() if len(idpARNs) == 1 { idpARN = idpARNs[0] } - if s.fedAppAlreadySelected { + if w.fedAppAlreadySelected { return idpARN, nil } - idpLabel := s.choiceFriendlyLabelIDP(idpARN, oktaConfig, idpARN) + idpLabel := w.choiceFriendlyLabelIDP(idpARN, oktaConfig, idpARN) idpData := idpTemplateData{ IDP: idpLabel, } @@ -470,7 +453,7 @@ func (s *SessionToken) promptForIDP(idpARNs []string) (idpARN string, err error) idpChoices := make(map[string]string, len(idpARNs)) idpChoiceLabels := make([]string, len(idpARNs)) for i, arn := range idpARNs { - idpLabel := s.choiceFriendlyLabelIDP(arn, oktaConfig, arn) + idpLabel := w.choiceFriendlyLabelIDP(arn, oktaConfig, arn) idpChoices[idpLabel] = arn idpChoiceLabels[i] = idpLabel } @@ -494,18 +477,18 @@ func (s *SessionToken) promptForIDP(idpARNs []string) (idpARN string, err error) // promptForIdpAndRole UX to prompt operator for the AWS role whose credentials // will be utilized. -func (s *SessionToken) promptForIdpAndRole(idpRoles map[string][]string) (iar *idpAndRole, err error) { +func (w *WebSSOAuthentication) promptForIdpAndRole(idpRoles map[string][]string) (iar *idpAndRole, err error) { idps := make([]string, 0, len(idpRoles)) for idp := range idpRoles { idps = append(idps, idp) } - idp, err := s.promptForIDP(idps) + idp, err := w.promptForIDP(idps) if err != nil { return nil, err } roles := idpRoles[idp] - role, err := s.promptForRole(idp, roles) + role, err := w.promptForRole(idp, roles) if err != nil { return nil, err } @@ -520,7 +503,7 @@ func (s *SessionToken) promptForIdpAndRole(idpRoles map[string][]string) (iar *i // extractIDPAndRolesMapFromAssertion 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) extractIDPAndRolesMapFromAssertion(encoded string) (irmap map[string][]string, err error) { +func (w *WebSSOAuthentication) extractIDPAndRolesMapFromAssertion(encoded string) (irmap map[string][]string, err error) { assertion, err := base64.StdEncoding.DecodeString(encoded) if err != nil { return nil, err @@ -554,9 +537,9 @@ func (s *SessionToken) extractIDPAndRolesMapFromAssertion(encoded string) (irmap } // fetchSAMLAssertion Gets the SAML assertion from Okta API /login/token/sso -func (s *SessionToken) fetchSAMLAssertion(at *okta.AccessToken) (assertion string, err error) { +func (w *WebSSOAuthentication) fetchSAMLAssertion(at *okta.AccessToken) (assertion string, err error) { params := url.Values{"token": {at.AccessToken}} - apiURL := fmt.Sprintf("https://%s/login/token/sso?%s", s.config.OrgDomain(), params.Encode()) + apiURL := fmt.Sprintf("https://%s/login/token/sso?%s", w.config.OrgDomain(), params.Encode()) req, err := http.NewRequest(http.MethodGet, apiURL, nil) if err != nil { @@ -565,7 +548,7 @@ func (s *SessionToken) fetchSAMLAssertion(at *okta.AccessToken) (assertion strin req.Header.Add(accept, "text/html") req.Header.Add(userAgent, agent.NewUserAgent(config.Version).String()) - resp, err := s.config.HTTPClient().Do(req) + resp, err := w.config.HTTPClient().Do(req) if err != nil { return assertion, err } @@ -584,8 +567,8 @@ func (s *SessionToken) fetchSAMLAssertion(at *okta.AccessToken) (assertion strin // fetchSSOWebToken see: // https://developer.okta.com/docs/reference/api/oidc/#token -func (s *SessionToken) fetchSSOWebToken(clientID, awsFedAppID string, at *okta.AccessToken) (token *okta.AccessToken, err error) { - apiURL := fmt.Sprintf(okta.OAuthV1TokenEndpointFormat, s.config.OrgDomain()) +func (w *WebSSOAuthentication) fetchSSOWebToken(clientID, awsFedAppID string, at *okta.AccessToken) (token *okta.AccessToken, err error) { + apiURL := fmt.Sprintf(okta.OAuthV1TokenEndpointFormat, w.config.OrgDomain()) data := url.Values{ "client_id": {clientID}, @@ -607,7 +590,7 @@ func (s *SessionToken) fetchSSOWebToken(clientID, awsFedAppID string, at *okta.A req.Header.Add(utils.ContentType, utils.ApplicationXFORM) req.Header.Add(userAgent, agent.NewUserAgent(config.Version).String()) - resp, err := s.config.HTTPClient().Do(req) + resp, err := w.config.HTTPClient().Do(req) if err != nil { return nil, err } @@ -637,11 +620,11 @@ func (s *SessionToken) fetchSSOWebToken(clientID, awsFedAppID string, at *okta.A } // promptAuthentication UX to display activation URL and code. -func (s *SessionToken) promptAuthentication(da *deviceAuthorization) { +func (w *WebSSOAuthentication) promptAuthentication(da *okta.DeviceAuthorization) { var qrBuf []byte qrCode := "" - if s.config.QRCode() { + if w.config.QRCode() { qrBuf = make([]byte, 4096) buf := bytes.NewBufferString("") qrterminal.GenerateHalfBlock(da.VerificationURIComplete, qrterminal.L, buf) @@ -656,13 +639,13 @@ func (s *SessionToken) promptAuthentication(da *deviceAuthorization) { ` openMsg := "Open" - if s.config.OpenBrowser() { + if w.config.OpenBrowser() { openMsg = "System web browser will open" } fmt.Fprintf(os.Stderr, prompt, openMsg, qrCode, da.VerificationURIComplete) - if s.config.OpenBrowser() { + if w.config.OpenBrowser() { brwsr.Stdout = os.Stderr if err := brwsr.OpenURL(da.VerificationURIComplete); err != nil { fmt.Fprintf(os.Stderr, "Failed to open activation URL with system browser: %v\n", err) @@ -674,8 +657,8 @@ func (s *SessionToken) promptAuthentication(da *deviceAuthorization) { // after getting anything other than a 403 on /api/v1/apps will be wrapped as as // an error that is related having multiple fed apps available. Requires // assoicated OIDC app has been granted okta.apps.read to its scope. -func (s *SessionToken) listFedApps(clientID string, at *okta.AccessToken) (apps []*oktaApplication, err error) { - apiURL, err := url.Parse(fmt.Sprintf("https://%s/api/v1/apps", s.config.OrgDomain())) +func (w *WebSSOAuthentication) listFedApps(clientID string, at *okta.AccessToken) (apps []*okta.Application, err error) { + apiURL, err := url.Parse(fmt.Sprintf("https://%s/api/v1/apps", w.config.OrgDomain())) if err != nil { return nil, err } @@ -693,7 +676,7 @@ func (s *SessionToken) listFedApps(clientID string, at *okta.AccessToken) (apps req.Header.Add(utils.ContentType, utils.ApplicationJSON) req.Header.Add(userAgent, agent.NewUserAgent(config.Version).String()) req.Header.Add("Authorization", fmt.Sprintf("%s %s", at.TokenType, at.AccessToken)) - resp, err := s.config.HTTPClient().Do(req) + resp, err := w.config.HTTPClient().Do(req) if resp.StatusCode == http.StatusForbidden { return nil, err } @@ -704,13 +687,13 @@ func (s *SessionToken) listFedApps(clientID string, at *okta.AccessToken) (apps return nil, newMultipleFedAppsError(err) } - var oktaApps []oktaApplication + var oktaApps []okta.Application err = json.NewDecoder(resp.Body).Decode(&oktaApps) if err != nil { return nil, newMultipleFedAppsError(err) } - apps = make([]*oktaApplication, 0) + apps = make([]*okta.Application, 0) for i, app := range oktaApps { if app.Name != amazonAWS { continue @@ -730,8 +713,8 @@ func (s *SessionToken) listFedApps(clientID string, at *okta.AccessToken) (apps // fetchAccessToken see: // https://developer.okta.com/docs/reference/api/oidc/#token -func (s *SessionToken) fetchAccessToken(clientID string, deviceAuth *deviceAuthorization) (at *okta.AccessToken, err error) { - apiURL := fmt.Sprintf(okta.OAuthV1TokenEndpointFormat, s.config.OrgDomain()) +func (w *WebSSOAuthentication) fetchAccessToken(clientID string, deviceAuth *okta.DeviceAuthorization) (at *okta.AccessToken, err error) { + apiURL := fmt.Sprintf(okta.OAuthV1TokenEndpointFormat, w.config.OrgDomain()) req, err := http.NewRequest(http.MethodPost, apiURL, nil) if err != nil { @@ -754,7 +737,7 @@ func (s *SessionToken) fetchAccessToken(clientID string, deviceAuth *deviceAutho body := strings.NewReader(data.Encode()) req.Body = io.NopCloser(body) - resp, err := s.config.HTTPClient().Do(req) + resp, err := w.config.HTTPClient().Do(req) bodyBytes, _ = io.ReadAll(resp.Body) if err != nil { return backoff.Permanent(fmt.Errorf("fetching access token polling received API err %w", err)) @@ -796,8 +779,8 @@ func (s *SessionToken) fetchAccessToken(clientID string, deviceAuth *deviceAutho // authorize see: // https://developer.okta.com/docs/reference/api/oidc/#device-authorize -func (s *SessionToken) authorize(clientID string) (*deviceAuthorization, error) { - apiURL := fmt.Sprintf("https://%s/oauth2/v1/device/authorize", s.config.OrgDomain()) +func (w *WebSSOAuthentication) authorize(clientID string) (*okta.DeviceAuthorization, error) { + apiURL := fmt.Sprintf("https://%s/oauth2/v1/device/authorize", w.config.OrgDomain()) data := url.Values{ "client_id": {clientID}, "scope": {"openid okta.apps.sso okta.apps.read"}, @@ -811,7 +794,7 @@ func (s *SessionToken) authorize(clientID string) (*deviceAuthorization, error) req.Header.Add(utils.ContentType, utils.ApplicationXFORM) req.Header.Add(userAgent, agent.NewUserAgent(config.Version).String()) - resp, err := s.config.HTTPClient().Do(req) + resp, err := w.config.HTTPClient().Do(req) if err != nil { return nil, err } @@ -824,7 +807,7 @@ func (s *SessionToken) authorize(clientID string) (*deviceAuthorization, error) return nil, fmt.Errorf("authorize non-JSON API response content type %q", ct) } - var da deviceAuthorization + var da okta.DeviceAuthorization err = json.NewDecoder(resp.Body).Decode(&da) if err != nil { return nil, err @@ -898,17 +881,10 @@ func apiErr(bodyBytes []byte) (ae *okta.APIError, err error) { return } -type oktaOrganization struct { - ID string `json:"id"` - Pipeline string `json:"pipeline"` - Links interface{} `json:"_links,omitempty"` - Settings interface{} `json:"settings,omitempty"` -} - // isClassicOrg Conduct simple check of well known endpoint to determine if the // org is a classic org. Will soft fail on errors. -func (s *SessionToken) isClassicOrg() bool { - apiURL := fmt.Sprintf("https://%s/.well-known/okta-organization", s.config.OrgDomain()) +func (w *WebSSOAuthentication) isClassicOrg() bool { + apiURL := fmt.Sprintf("https://%s/.well-known/okta-organization", w.config.OrgDomain()) req, err := http.NewRequest(http.MethodGet, apiURL, nil) if err != nil { return false @@ -916,14 +892,14 @@ func (s *SessionToken) isClassicOrg() bool { req.Header.Add(accept, utils.ApplicationJSON) req.Header.Add(userAgent, agent.NewUserAgent(config.Version).String()) - resp, err := s.config.HTTPClient().Do(req) + resp, err := w.config.HTTPClient().Do(req) if err != nil { return false } if resp.StatusCode != http.StatusOK { return false } - org := &oktaOrganization{} + org := &okta.Organization{} err = json.NewDecoder(resp.Body).Decode(org) if err != nil { return false @@ -939,7 +915,7 @@ func (s *SessionToken) isClassicOrg() bool { // cachedAccessToken will returned the cached access token if it exists and is // not expired. -func (s *SessionToken) cachedAccessToken() (at *okta.AccessToken) { +func (w *WebSSOAuthentication) cachedAccessToken() (at *okta.AccessToken) { homeDir, err := os.UserHomeDir() if err != nil { return @@ -970,8 +946,8 @@ func (s *SessionToken) cachedAccessToken() (at *okta.AccessToken) { // cacheAccessToken will cache the access token for later use if enabled. Silent // if fails. -func (s *SessionToken) cacheAccessToken(at *okta.AccessToken) { - if !s.config.CacheAccessToken() { +func (w *WebSSOAuthentication) cacheAccessToken(at *okta.AccessToken) { + if !w.config.CacheAccessToken() { return } diff --git a/internal/sessiontoken/sessiontoken_test.go b/internal/webssoauth/webssoauth_test.go similarity index 93% rename from internal/sessiontoken/sessiontoken_test.go rename to internal/webssoauth/webssoauth_test.go index 07c81a8..d2b5063 100644 --- a/internal/sessiontoken/sessiontoken_test.go +++ b/internal/webssoauth/webssoauth_test.go @@ -14,7 +14,7 @@ * limitations under the License. */ -package sessiontoken +package webssoauth import ( "net/http" @@ -28,7 +28,7 @@ func TestEstablishToken(t *testing.T) { config := &config.Config{} _ = config.SetOrgDomain("example.okta.com") _ = config.SetHTTPClient(http.DefaultClient) - _, err := NewSessionToken(config) + _, err := NewWebSSOAuthentication(config) // config is not set so this should error require.Error(t, err) } From f41d14caf338a062c3f6cdfe6737fb1a5e23b39a Mon Sep 17 00:00:00 2001 From: Mike Mondragon Date: Wed, 27 Sep 2023 16:04:49 -0700 Subject: [PATCH 06/42] VCR tests for websso is classic org, device auth, and access token requests. --- internal/m2mauth/m2mauth_test.go | 52 +----- internal/testutils/testutils.go | 38 ++++ internal/webssoauth/webssoauth.go | 12 +- internal/webssoauth/webssoauth_test.go | 79 +++++++- .../vcr/TestWebSSOAuthAccessToken.yaml | 171 ++++++++++++++++++ .../fixtures/vcr/TestWebSSOAuthAuthorize.yaml | 79 ++++++++ .../vcr/TestWebSSOAuthFetchAccessToken.yaml | 169 +++++++++++++++++ .../vcr/TestWebSSOAuthIsClassicOrg.yaml | 73 ++++++++ 8 files changed, 617 insertions(+), 56 deletions(-) create mode 100644 test/fixtures/vcr/TestWebSSOAuthAccessToken.yaml create mode 100644 test/fixtures/vcr/TestWebSSOAuthAuthorize.yaml create mode 100644 test/fixtures/vcr/TestWebSSOAuthFetchAccessToken.yaml create mode 100644 test/fixtures/vcr/TestWebSSOAuthIsClassicOrg.yaml diff --git a/internal/m2mauth/m2mauth_test.go b/internal/m2mauth/m2mauth_test.go index dd945df..8687f12 100644 --- a/internal/m2mauth/m2mauth_test.go +++ b/internal/m2mauth/m2mauth_test.go @@ -19,35 +19,33 @@ package m2mauth import ( "net/http" "os" - "path" "regexp" "testing" "github.com/okta/okta-aws-cli/internal/config" "github.com/okta/okta-aws-cli/internal/testutils" "github.com/stretchr/testify/require" - "gopkg.in/dnaeon/go-vcr.v3/recorder" ) func TestMain(m *testing.M) { var reset func() - reset = osSetEnvIfBlank("OKTA_ORG_DOMAIN", testutils.TestDomainName) + reset = testutils.OsSetEnvIfBlank("OKTA_ORG_DOMAIN", testutils.TestDomainName) defer reset() - reset = osSetEnvIfBlank("OKTA_OIDC_CLIENT_ID", "0oaa4htg72TNrkTDr1d7") + reset = testutils.OsSetEnvIfBlank("OKTA_OIDC_CLIENT_ID", "0oaa4htg72TNrkTDr1d7") defer reset() - reset = osSetEnvIfBlank("OKTA_AWSCLI_IAM_ROLE", "arn:aws:iam::123:role/RickRollNeverGonnaGiveYouUp") + reset = testutils.OsSetEnvIfBlank("OKTA_AWSCLI_IAM_ROLE", "arn:aws:iam::123:role/RickRollNeverGonnaGiveYouUp") defer reset() - reset = osSetEnvIfBlank("OKTA_AUTHZ_ID", "aus8w23r13NvyUwln1d7") + reset = testutils.OsSetEnvIfBlank("OKTA_AUTHZ_ID", "aus8w23r13NvyUwln1d7") defer reset() - reset = osSetEnvIfBlank("OKTA_AWSCLI_CUSTOM_SCOPE", "okta-aws-cli") + reset = testutils.OsSetEnvIfBlank("OKTA_AWSCLI_CUSTOM_SCOPE", "okta-aws-cli") defer reset() - reset = osSetEnvIfBlank("OKTA_AWSCLI_KEY_ID", "kid-rock") + reset = testutils.OsSetEnvIfBlank("OKTA_AWSCLI_KEY_ID", "kid-rock") defer reset() // NOTE: Okta Security this is just some random PK to unit test the client // assertion generator in this app. PK was created with // `openssl genrsa 512 | pbcopy` - reset = osSetEnvIfBlank("OKTA_AWSCLI_PRIVATE_KEY", ` + reset = testutils.OsSetEnvIfBlank("OKTA_AWSCLI_PRIVATE_KEY", ` -----BEGIN PRIVATE KEY----- MIIBVQIBADANBgkqhkiG9w0BAQEFAASCAT8wggE7AgEAAkEAzAZ73GY6TbcC0cQS LQ+GfIkZxeTJjkW8+pdg0zmcGs4ZByZqp7oP02TbZ0UyLFHe8Eqik5rXR98mts5e @@ -63,20 +61,6 @@ qrebTIkNMM8u os.Exit(m.Run()) } -func osSetEnvIfBlank(key, value string) func() { - if os.Getenv(key) != "" { - return func() {} - } - _ = os.Setenv(key, value) - return func() { - _ = os.Unsetenv(key) - } -} - -func TestM2MAuthEstablishIAMCredentials(t *testing.T) { - t.Skip("TODO") -} - // TestM2MAuthMakeClientAssertion Tests the private make client assertion method // on m2mauth func TestM2MAuthMakeClientAssertion(t *testing.T) { @@ -121,7 +105,7 @@ func setupTest(t *testing.T) (*config.Config, func(t *testing.T)) { require.NoError(t, err) rt := config.HTTPClient().Transport - vcr, err := newVCRRecorder(t, rt) + vcr, err := testutils.NewVCRRecorder(t, rt) require.NoError(t, err) rt = http.RoundTripper(vcr) config.HTTPClient().Transport = rt @@ -133,23 +117,3 @@ func setupTest(t *testing.T) (*config.Config, func(t *testing.T)) { return config, tearDown } - -func newVCRRecorder(t *testing.T, transport http.RoundTripper) (rec *recorder.Recorder, err error) { - dir, _ := os.Getwd() - vcrFixturesHome := path.Join(dir, "../../test/fixtures/vcr") - cassettesPath := path.Join(vcrFixturesHome, t.Name()) - rec, err = recorder.NewWithOptions(&recorder.Options{ - CassetteName: cassettesPath, - Mode: recorder.ModeRecordOnce, - SkipRequestLatency: true, // skip how vcr will mimic the real request latency that it can record allowing for fast playback - RealTransport: transport, - }) - if err != nil { - return - } - - rec.SetMatcher(testutils.VCROktaAPIRequestMatcher) - rec.AddHook(testutils.VCROktaAPIRequestHook, recorder.AfterCaptureHook) - - return -} diff --git a/internal/testutils/testutils.go b/internal/testutils/testutils.go index d9e4926..6c6a138 100644 --- a/internal/testutils/testutils.go +++ b/internal/testutils/testutils.go @@ -23,14 +23,17 @@ import ( "log" "net/http" "os" + "path" "reflect" "regexp" "strings" + "testing" "time" "github.com/okta/okta-aws-cli/internal/config" "github.com/okta/okta-aws-cli/internal/utils" "gopkg.in/dnaeon/go-vcr.v3/cassette" + "gopkg.in/dnaeon/go-vcr.v3/recorder" ) const ( @@ -104,6 +107,9 @@ func VCROktaAPIRequestHook(i *cassette.Interaction) error { // %s/example.okta.com/test.dne-okta.com/ i.Request.Body = strings.ReplaceAll(i.Request.Body, orgHostname, vcrHostname) + // %s/example.okta.com/test.dne-okta.com/ + i.Response.Body = strings.ReplaceAll(i.Response.Body, orgHostname, vcrHostname) + return nil } @@ -155,3 +161,35 @@ func VCROktaAPIRequestMatcher(r *http.Request, i cassette.Request) bool { return true } + +// NewVCRRecorder New VCR recording settings +func NewVCRRecorder(t *testing.T, transport http.RoundTripper) (rec *recorder.Recorder, err error) { + dir, _ := os.Getwd() + vcrFixturesHome := path.Join(dir, "../../test/fixtures/vcr") + cassettesPath := path.Join(vcrFixturesHome, t.Name()) + rec, err = recorder.NewWithOptions(&recorder.Options{ + CassetteName: cassettesPath, + Mode: recorder.ModeRecordOnce, + SkipRequestLatency: true, // skip how vcr will mimic the real request latency that it can record allowing for fast playback + RealTransport: transport, + }) + if err != nil { + return + } + + rec.SetMatcher(VCROktaAPIRequestMatcher) + rec.AddHook(VCROktaAPIRequestHook, recorder.AfterCaptureHook) + + return +} + +// OsSetEnvIfBlank Set env var if its blank and return a clearing function +func OsSetEnvIfBlank(key, value string) func() { + if os.Getenv(key) != "" { + return func() {} + } + _ = os.Setenv(key, value) + return func() { + _ = os.Unsetenv(key) + } +} diff --git a/internal/webssoauth/webssoauth.go b/internal/webssoauth/webssoauth.go index c46c9ed..ac37693 100644 --- a/internal/webssoauth/webssoauth.go +++ b/internal/webssoauth/webssoauth.go @@ -140,14 +140,14 @@ func (w *WebSSOAuthentication) EstablishIAMCredentials() error { for attempt := 1; attempt <= 2; attempt++ { err = nil if at == nil { - deviceAuth, err := w.authorize(clientID) + deviceAuth, err := w.authorize() if err != nil { return err } w.promptAuthentication(deviceAuth) - at, err = w.fetchAccessToken(clientID, deviceAuth) + at, err = w.AccessToken(deviceAuth) if err != nil { return err } @@ -711,9 +711,10 @@ func (w *WebSSOAuthentication) listFedApps(clientID string, at *okta.AccessToken return } -// fetchAccessToken see: +// AccessToken see: // https://developer.okta.com/docs/reference/api/oidc/#token -func (w *WebSSOAuthentication) fetchAccessToken(clientID string, deviceAuth *okta.DeviceAuthorization) (at *okta.AccessToken, err error) { +func (w *WebSSOAuthentication) AccessToken(deviceAuth *okta.DeviceAuthorization) (at *okta.AccessToken, err error) { + clientID := w.config.OIDCAppID() apiURL := fmt.Sprintf(okta.OAuthV1TokenEndpointFormat, w.config.OrgDomain()) req, err := http.NewRequest(http.MethodPost, apiURL, nil) @@ -779,7 +780,8 @@ func (w *WebSSOAuthentication) fetchAccessToken(clientID string, deviceAuth *okt // authorize see: // https://developer.okta.com/docs/reference/api/oidc/#device-authorize -func (w *WebSSOAuthentication) authorize(clientID string) (*okta.DeviceAuthorization, error) { +func (w *WebSSOAuthentication) authorize() (*okta.DeviceAuthorization, error) { + clientID := w.config.OIDCAppID() apiURL := fmt.Sprintf("https://%s/oauth2/v1/device/authorize", w.config.OrgDomain()) data := url.Values{ "client_id": {clientID}, diff --git a/internal/webssoauth/webssoauth_test.go b/internal/webssoauth/webssoauth_test.go index d2b5063..379206a 100644 --- a/internal/webssoauth/webssoauth_test.go +++ b/internal/webssoauth/webssoauth_test.go @@ -18,17 +18,82 @@ package webssoauth import ( "net/http" + "os" "testing" "github.com/okta/okta-aws-cli/internal/config" + "github.com/okta/okta-aws-cli/internal/testutils" "github.com/stretchr/testify/require" ) -func TestEstablishToken(t *testing.T) { - config := &config.Config{} - _ = config.SetOrgDomain("example.okta.com") - _ = config.SetHTTPClient(http.DefaultClient) - _, err := NewWebSSOAuthentication(config) - // config is not set so this should error - require.Error(t, err) +func TestMain(m *testing.M) { + var reset func() + reset = testutils.OsSetEnvIfBlank("OKTA_ORG_DOMAIN", testutils.TestDomainName) + defer reset() + reset = testutils.OsSetEnvIfBlank("OKTA_OIDC_CLIENT_ID", "0oa4x34ogyC1i1krJ1d7") + defer reset() + + os.Exit(m.Run()) +} + +func TestWebSSOAuthIsClassicOrg(t *testing.T) { + config, teardownTest := setupTest(t) + defer teardownTest(t) + + w, err := NewWebSSOAuthentication(config) + require.NoError(t, err) + isClassic := w.isClassicOrg() + require.False(t, isClassic) +} + +func TestWebSSOAuthAuthorize(t *testing.T) { + config, teardownTest := setupTest(t) + defer teardownTest(t) + + w, err := NewWebSSOAuthentication(config) + require.NoError(t, err) + da, err := w.authorize() + require.NoError(t, err) + require.Equal(t, da.ExpiresIn, 600) + require.Equal(t, da.Interval, 5) + + require.NoError(t, err) +} + +func TestWebSSOAuthAccessToken(t *testing.T) { + config, teardownTest := setupTest(t) + defer teardownTest(t) + + w, err := NewWebSSOAuthentication(config) + require.NoError(t, err) + da, err := w.authorize() + require.NoError(t, err) + at, err := w.AccessToken(da) + require.NoError(t, err) + require.Equal(t, at.ExpiresIn, int64(3600)) + require.Equal(t, at.TokenType, "Bearer") + + require.NoError(t, err) +} + +func setupTest(t *testing.T) (*config.Config, func(t *testing.T)) { + attrs := &config.Attributes{ + OrgDomain: os.Getenv("OKTA_ORG_DOMAIN"), + OIDCAppID: os.Getenv("OKTA_OIDC_CLIENT_ID"), + } + config, err := config.NewConfig(attrs) + require.NoError(t, err) + + rt := config.HTTPClient().Transport + vcr, err := testutils.NewVCRRecorder(t, rt) + require.NoError(t, err) + rt = http.RoundTripper(vcr) + config.HTTPClient().Transport = rt + + tearDown := func(t *testing.T) { + err := vcr.Stop() + require.NoError(t, err) + } + + return config, tearDown } diff --git a/test/fixtures/vcr/TestWebSSOAuthAccessToken.yaml b/test/fixtures/vcr/TestWebSSOAuthAccessToken.yaml new file mode 100644 index 0000000..49b1f67 --- /dev/null +++ b/test/fixtures/vcr/TestWebSSOAuthAccessToken.yaml @@ -0,0 +1,171 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + transfer_encoding: [] + trailer: {} + host: test.dne-okta.com + remote_addr: "" + request_uri: "" + body: "" + form: {} + headers: + Accept: + - application/json + url: https://test.dne-okta.com/.well-known/okta-organization + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + transfer_encoding: [] + trailer: {} + content_length: -1 + uncompressed: false + body: '{"id":"00o4wzh294nJMXp5P1d7","cell":"op3","_links":{"organization":{"href":"https://test.dne-okta.com"}},"pipeline":"idx","settings":{"analyticsCollectionEnabled":false,"bugReportingEnabled":true,"omEnabled":false,"pssoEnabled":false}}' + headers: + Content-Type: + - application/json + Date: + - Wed, 27 Sep 2023 22:58:43 GMT + status: 200 OK + code: 200 + duration: 251.578087ms + - id: 1 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 72 + transfer_encoding: [] + trailer: {} + host: test.dne-okta.com + remote_addr: "" + request_uri: "" + body: client_id=0oa4x34ogyC1i1krJ1d7&scope=openid+okta.apps.sso+okta.apps.read + form: + client_id: + - 0oa4x34ogyC1i1krJ1d7 + scope: + - openid okta.apps.sso okta.apps.read + headers: + Accept: + - application/json + Content-Type: + - application/x-www-form-urlencoded + url: https://test.dne-okta.com/oauth2/v1/device/authorize + method: POST + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + transfer_encoding: [] + trailer: {} + content_length: -1 + uncompressed: false + body: '{"device_code":"9c713a7b-026d-4c3a-9fb0-e91bae83741b","user_code":"NNBJWNSJ","verification_uri":"https://test.dne-okta.com/activate","verification_uri_complete":"https://test.dne-okta.com/activate?user_code=NNBJWNSJ","expires_in":600,"interval":5}' + headers: + Content-Type: + - application/json + Date: + - Wed, 27 Sep 2023 22:58:43 GMT + Report-To: + - '{"group":"csp","max_age":31536000,"endpoints":[{"url":"https://oktacsp.report-uri.com/a/t/g"}],"include_subdomains":true}' + status: 200 OK + code: 200 + duration: 132.333388ms + - id: 2 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + transfer_encoding: [] + trailer: {} + host: test.dne-okta.com + remote_addr: "" + request_uri: "" + body: client_id=0oa4x34ogyC1i1krJ1d7&device_code=9c713a7b-026d-4c3a-9fb0-e91bae83741b&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code + form: + client_id: + - 0oa4x34ogyC1i1krJ1d7 + device_code: + - 9c713a7b-026d-4c3a-9fb0-e91bae83741b + grant_type: + - urn:ietf:params:oauth:grant-type:device_code + headers: + Accept: + - application/json + Content-Type: + - application/x-www-form-urlencoded + url: https://test.dne-okta.com/oauth2/v1/token + method: POST + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + transfer_encoding: [] + trailer: {} + content_length: -1 + uncompressed: false + body: '{"error":"authorization_pending","error_description":"The device authorization is pending. Please try again later."}' + headers: + Content-Type: + - application/json + Date: + - Wed, 27 Sep 2023 22:58:43 GMT + Report-To: + - '{"group":"csp","max_age":31536000,"endpoints":[{"url":"https://oktacsp.report-uri.com/a/t/g"}],"include_subdomains":true}' + status: 400 Bad Request + code: 400 + duration: 74.627903ms + - id: 3 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + transfer_encoding: [] + trailer: {} + host: test.dne-okta.com + remote_addr: "" + request_uri: "" + body: client_id=0oa4x34ogyC1i1krJ1d7&device_code=9c713a7b-026d-4c3a-9fb0-e91bae83741b&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code + form: + client_id: + - 0oa4x34ogyC1i1krJ1d7 + device_code: + - 9c713a7b-026d-4c3a-9fb0-e91bae83741b + grant_type: + - urn:ietf:params:oauth:grant-type:device_code + headers: + Accept: + - application/json + Content-Type: + - application/x-www-form-urlencoded + url: https://test.dne-okta.com/oauth2/v1/token + method: POST + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + transfer_encoding: [] + trailer: {} + content_length: -1 + uncompressed: false + body: '{"token_type":"Bearer","expires_in":3600,"access_token":"eyJraWQiOiJpUXd1OGt6RExHMUU5TFQ0NkhjNEdsbDdZRFE2Sk10a2JyTGh6VHVtcENzIiwiYWxnIjoiUlMyNTYifQ.eyJ2ZXIiOjEsImp0aSI6IkFULkM2Mnp3OG1TRkZzWkpETVFaMURZMWJieDdGZHFYVHI0enhBNk9xcjQwOUUiLCJpc3MiOiJodHRwczovL21tb25kcmFnb24tYXdzLWNsaS0wMC5va3RhcHJldmlldy5jb20iLCJhdWQiOiJodHRwczovL21tb25kcmFnb24tYXdzLWNsaS0wMC5va3RhcHJldmlldy5jb20iLCJzdWIiOiJtaWtlLm1vbmRyYWdvbkBva3RhLmNvbSIsImlhdCI6MTY5NTg1NTU0OCwiZXhwIjoxNjk1ODU5MTQ4LCJjaWQiOiIwb2E0eDM0b2d5QzFpMWtySjFkNyIsInVpZCI6IjAwdTR3emgyY25EVXQxYXV3MWQ3Iiwic2NwIjpbIm9wZW5pZCIsIm9rdGEuYXBwcy5zc28iLCJva3RhLmFwcHMucmVhZCJdLCJhdXRoX3RpbWUiOjE2OTU4NTUwMDh9.nAMWB68btwGKmWSZSm_VRzqbc6QIaMQ-HHZVRH0hK0yuyVxK1Vref9RwciEyeDEEJgOmRxBv080tB0HIAp-5B-b_DGUyg5D8ZuovsuMJa-g7ajC_vBLpkdLWn5geeCPXq26JXkHbuLzc2Zf0XQRiTPGDrLvOfM7hVkH4g-ZyvKcQCUBk4OHn0iMosaoZJ3nzxXcfPhFb5V0Baf3tlPD7viKYlkNNM8rWMR2uP_a9XWyJyfkJSPHuTDqG-ZrUfC7FYztp_uivs_MDjZk246396aJRskF2KESY-wtFVZwFq-MTlNHoDpk0POY1CqwbcDDAtVz5cBoGPyI-BrmoJrzIIA","scope":"openid okta.apps.sso okta.apps.read","id_token":"eyJraWQiOiJ4TUdYV3ZfY1prTjViR1pyM2FUVW8yWnRHdGhyeVlhSmdwLUE1VWNLaE1VIiwiYWxnIjoiUlMyNTYifQ.eyJzdWIiOiIwMHU0d3poMmNuRFV0MWF1dzFkNyIsInZlciI6MSwiaXNzIjoiaHR0cHM6Ly9tbW9uZHJhZ29uLWF3cy1jbGktMDAub2t0YXByZXZpZXcuY29tIiwiYXVkIjoiMG9hNHgzNG9neUMxaTFrckoxZDciLCJpYXQiOjE2OTU4NTU1NDgsImV4cCI6MTY5NTg1OTE0OCwianRpIjoiSUQuWV9CWEgwQ2RIM3hVNkxHclRlYW93ekkxN0lPTGlRX2lwU3NUaE1sUUlrRSIsImFtciI6WyJzd2siLCJtZmEiLCJwd2QiXSwiaWRwIjoiMDBvNHd6aDI5NG5KTVhwNVAxZDciLCJzaWQiOiJpZHhVeThhWUR0TVFBVzhncGtmdTNuOGxRIiwiYXV0aF90aW1lIjoxNjk1ODU1MDA4LCJhdF9oYXNoIjoiTXVUR0tIUHJQeVV1bGlRSnBSX1BFUSJ9.a_UHSCX7_kejRMxxzGWJeh2fSpd6yGKZVIpsQ3J6JNVRUg70j8Wvik6nIXXq9e6j3vMZo_xWacJtpAE0rwy98Zltiq17mpVfRem7WAJXu99KdX8VD29yI-l_64fxhqYqmwm-GsTzApo5Yjm7WFqpH-GRGyCiNp9B854cL4rI-okqiAZfgQuY6mNBCKVWL_AwcX4ycC0acdaV1eETNIbidhSwOa1OHzUu59xx_d5Ihivhe4iJFOCoNedhTs7uzJUsGXX1a3-_4465JEK18l17XWUwUV8KxU6e23o-MpDoNu3imFK3Wl68dZYB8FtDHBD3iBwVdgK2dWaWxFD_OMxP9w"}' + headers: + Content-Type: + - application/json + Date: + - Wed, 27 Sep 2023 22:59:08 GMT + Report-To: + - '{"group":"csp","max_age":31536000,"endpoints":[{"url":"https://oktacsp.report-uri.com/a/t/g"}],"include_subdomains":true}' + status: 200 OK + code: 200 + duration: 466.924928ms diff --git a/test/fixtures/vcr/TestWebSSOAuthAuthorize.yaml b/test/fixtures/vcr/TestWebSSOAuthAuthorize.yaml new file mode 100644 index 0000000..29e9766 --- /dev/null +++ b/test/fixtures/vcr/TestWebSSOAuthAuthorize.yaml @@ -0,0 +1,79 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + transfer_encoding: [] + trailer: {} + host: test.dne-okta.com + remote_addr: "" + request_uri: "" + body: "" + form: {} + headers: + Accept: + - application/json + url: https://test.dne-okta.com/.well-known/okta-organization + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + transfer_encoding: [] + trailer: {} + content_length: -1 + uncompressed: false + body: '{"id":"00o4wzh294nJMXp5P1d7","cell":"op3","_links":{"organization":{"href":"https://test.dne-okta.com"}},"pipeline":"idx","settings":{"analyticsCollectionEnabled":false,"bugReportingEnabled":true,"omEnabled":false,"pssoEnabled":false}}' + headers: + Content-Type: + - application/json + Date: + - Wed, 27 Sep 2023 22:39:38 GMT + status: 200 OK + code: 200 + duration: 275.63154ms + - id: 1 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 72 + transfer_encoding: [] + trailer: {} + host: test.dne-okta.com + remote_addr: "" + request_uri: "" + body: client_id=0oa4x34ogyC1i1krJ1d7&scope=openid+okta.apps.sso+okta.apps.read + form: + client_id: + - 0oa4x34ogyC1i1krJ1d7 + scope: + - openid okta.apps.sso okta.apps.read + headers: + Accept: + - application/json + Content-Type: + - application/x-www-form-urlencoded + url: https://test.dne-okta.com/oauth2/v1/device/authorize + method: POST + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + transfer_encoding: [] + trailer: {} + content_length: -1 + uncompressed: false + body: '{"device_code":"f8233539-ed2d-4d0d-8a58-342e36320712","user_code":"QXNMSLWF","verification_uri":"https://test.dne-okta.com/activate","verification_uri_complete":"https://test.dne-okta.com/activate?user_code=QXNMSLWF","expires_in":600,"interval":5}' + headers: + Content-Type: + - application/json + Date: + - Wed, 27 Sep 2023 22:39:38 GMT + status: 200 OK + code: 200 + duration: 318.782199ms diff --git a/test/fixtures/vcr/TestWebSSOAuthFetchAccessToken.yaml b/test/fixtures/vcr/TestWebSSOAuthFetchAccessToken.yaml new file mode 100644 index 0000000..24669c6 --- /dev/null +++ b/test/fixtures/vcr/TestWebSSOAuthFetchAccessToken.yaml @@ -0,0 +1,169 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + transfer_encoding: [] + trailer: {} + host: test.dne-okta.com + remote_addr: "" + request_uri: "" + body: "" + form: {} + headers: + Accept: + - application/json + url: https://test.dne-okta.com/.well-known/okta-organization + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + transfer_encoding: [] + trailer: {} + content_length: -1 + uncompressed: false + body: '{"id":"00o4wzh294nJMXp5P1d7","cell":"op3","_links":{"organization":{"href":"https://test.dne-okta.com"}},"pipeline":"idx","settings":{"analyticsCollectionEnabled":false,"bugReportingEnabled":true,"omEnabled":false,"pssoEnabled":false}}' + headers: + Content-Type: + - application/json + Date: + - Wed, 27 Sep 2023 22:49:34 GMT + status: 200 OK + code: 200 + duration: 227.850238ms + - id: 1 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 72 + transfer_encoding: [] + trailer: {} + host: test.dne-okta.com + remote_addr: "" + request_uri: "" + body: client_id=0oa4x34ogyC1i1krJ1d7&scope=openid+okta.apps.sso+okta.apps.read + form: + client_id: + - 0oa4x34ogyC1i1krJ1d7 + scope: + - openid okta.apps.sso okta.apps.read + headers: + Accept: + - application/json + Content-Type: + - application/x-www-form-urlencoded + url: https://test.dne-okta.com/oauth2/v1/device/authorize + method: POST + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + transfer_encoding: [] + trailer: {} + content_length: -1 + uncompressed: false + body: '{"device_code":"a7f96bdf-344f-4492-b219-429046c58135","user_code":"LXHMPFWX","verification_uri":"https://test.dne-okta.com/activate","verification_uri_complete":"https://test.dne-okta.com/activate?user_code=LXHMPFWX","expires_in":600,"interval":5}' + headers: + Content-Type: + - application/json + Date: + - Wed, 27 Sep 2023 22:49:34 GMT + Report-To: + - '{"group":"csp","max_age":31536000,"endpoints":[{"url":"https://oktacsp.report-uri.com/a/t/g"}],"include_subdomains":true}' + status: 200 OK + code: 200 + duration: 328.57361ms + - id: 2 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + transfer_encoding: [] + trailer: {} + host: test.dne-okta.com + remote_addr: "" + request_uri: "" + body: client_id=0oa4x34ogyC1i1krJ1d7&device_code=a7f96bdf-344f-4492-b219-429046c58135&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code + form: + client_id: + - 0oa4x34ogyC1i1krJ1d7 + device_code: + - a7f96bdf-344f-4492-b219-429046c58135 + grant_type: + - urn:ietf:params:oauth:grant-type:device_code + headers: + Accept: + - application/json + Content-Type: + - application/x-www-form-urlencoded + url: https://test.dne-okta.com/oauth2/v1/token + method: POST + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + transfer_encoding: [] + trailer: {} + content_length: -1 + uncompressed: false + body: '{"error":"authorization_pending","error_description":"The device authorization is pending. Please try again later."}' + headers: + Content-Type: + - application/json + Date: + - Wed, 27 Sep 2023 22:49:35 GMT + status: 400 Bad Request + code: 400 + duration: 71.680534ms + - id: 3 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + transfer_encoding: [] + trailer: {} + host: test.dne-okta.com + remote_addr: "" + request_uri: "" + body: client_id=0oa4x34ogyC1i1krJ1d7&device_code=a7f96bdf-344f-4492-b219-429046c58135&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code + form: + client_id: + - 0oa4x34ogyC1i1krJ1d7 + device_code: + - a7f96bdf-344f-4492-b219-429046c58135 + grant_type: + - urn:ietf:params:oauth:grant-type:device_code + headers: + Accept: + - application/json + Content-Type: + - application/x-www-form-urlencoded + url: https://test.dne-okta.com/oauth2/v1/token + method: POST + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + transfer_encoding: [] + trailer: {} + content_length: -1 + uncompressed: false + body: '{"token_type":"Bearer","expires_in":3600,"access_token":"eyJraWQiOiJpUXd1OGt6RExHMUU5TFQ0NkhjNEdsbDdZRFE2Sk10a2JyTGh6VHVtcENzIiwiYWxnIjoiUlMyNTYifQ.eyJ2ZXIiOjEsImp0aSI6IkFULjJBT1FsbmdfVC1TZWp3bmU5ZnNlQ3RnU3otZHBfTlNCby1qLXFlNmZ2ZW8iLCJpc3MiOiJodHRwczovL21tb25kcmFnb24tYXdzLWNsaS0wMC5va3RhcHJldmlldy5jb20iLCJhdWQiOiJodHRwczovL21tb25kcmFnb24tYXdzLWNsaS0wMC5va3RhcHJldmlldy5jb20iLCJzdWIiOiJtaWtlLm1vbmRyYWdvbkBva3RhLmNvbSIsImlhdCI6MTY5NTg1NTAxMCwiZXhwIjoxNjk1ODU4NjEwLCJjaWQiOiIwb2E0eDM0b2d5QzFpMWtySjFkNyIsInVpZCI6IjAwdTR3emgyY25EVXQxYXV3MWQ3Iiwic2NwIjpbIm9wZW5pZCIsIm9rdGEuYXBwcy5zc28iLCJva3RhLmFwcHMucmVhZCJdLCJhdXRoX3RpbWUiOjE2OTU4NTUwMDh9.bqACc-L9wmKj7vQQLkFII1lXzWb1DhuzWyWv6OhWRPZgNgpNDg-BsKrgNGaxhA5pMR6fxiOOdsz1Npk4xguXyEyh2miig54x5CsRpq1v3qYzQ42GW6hvFkLOzKaBsNp8Y_wsFR9knvGw28T_NQOxMnxUNHcoc94GatIl2ACZ9O4bZtWy86jGci4C58W-dUP6amNfRUXHtiH7tkPefgJKDWBTXubpPWgeYqRW1ZB85EmR29ueiAacRalrnFBlomk7_mLe-qLCqWvWK3XetWQj7mw_7UWl00juaX2Yb1_TBGJSan_Z-Jb2CnFP9-1Fz-RIhcFRZNS6RP36AJykyeME9w","scope":"openid okta.apps.sso okta.apps.read","id_token":"eyJraWQiOiJ4TUdYV3ZfY1prTjViR1pyM2FUVW8yWnRHdGhyeVlhSmdwLUE1VWNLaE1VIiwiYWxnIjoiUlMyNTYifQ.eyJzdWIiOiIwMHU0d3poMmNuRFV0MWF1dzFkNyIsInZlciI6MSwiaXNzIjoiaHR0cHM6Ly9tbW9uZHJhZ29uLWF3cy1jbGktMDAub2t0YXByZXZpZXcuY29tIiwiYXVkIjoiMG9hNHgzNG9neUMxaTFrckoxZDciLCJpYXQiOjE2OTU4NTUwMTAsImV4cCI6MTY5NTg1ODYxMCwianRpIjoiSUQuSmF1RjNQWHZWOXFPR2JLX2RLU2l3dDliVGZJeDZNU2IyRXIzdU5UMTN2USIsImFtciI6WyJzd2siLCJtZmEiLCJwd2QiXSwiaWRwIjoiMDBvNHd6aDI5NG5KTVhwNVAxZDciLCJzaWQiOiJpZHhVeThhWUR0TVFBVzhncGtmdTNuOGxRIiwiYXV0aF90aW1lIjoxNjk1ODU1MDA4LCJhdF9oYXNoIjoiQlRTcnVIeEtaVUJIS2wwMDZGbkROZyJ9.k3Yc-9rxt80oJYK_vabGSKYGSBu7aFg04X1tXnHsfwe_BKT0ej7JuVg9q_iH3XzrQHTLy7-meW1OgL5wPBBNdwk46GfZkQho3uOZ-v3M2PKQ4OtTuiox-hMDi_49SH4BkzGTxnuu1IQXlwQ4BxvwjtJsegelqDxaXHuTVNijHdUEDvOPaf0vl7EeP6P9kXImMfkpKB6R448N-E6wEK2UYFOmJSzF2ShgvF-JCwu_OcNsdaFrd28qbfMgcE7pYK_5Z79y7_cT2fSaDH96MXOr5yC6rEmMDc4NkQCh15LLEr0a-HVHtSLTz52Q0RUWR_WpgLBakYnJ4JUykKDz57WsfQ"}' + headers: + Content-Type: + - application/json + Date: + - Wed, 27 Sep 2023 22:50:10 GMT + Report-To: + - '{"group":"csp","max_age":31536000,"endpoints":[{"url":"https://oktacsp.report-uri.com/a/t/g"}],"include_subdomains":true}' + status: 200 OK + code: 200 + duration: 702.720399ms diff --git a/test/fixtures/vcr/TestWebSSOAuthIsClassicOrg.yaml b/test/fixtures/vcr/TestWebSSOAuthIsClassicOrg.yaml new file mode 100644 index 0000000..4baf097 --- /dev/null +++ b/test/fixtures/vcr/TestWebSSOAuthIsClassicOrg.yaml @@ -0,0 +1,73 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + transfer_encoding: [] + trailer: {} + host: test.dne-okta.com + remote_addr: "" + request_uri: "" + body: "" + form: {} + headers: + Accept: + - application/json + url: https://test.dne-okta.com/.well-known/okta-organization + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + transfer_encoding: [] + trailer: {} + content_length: -1 + uncompressed: false + body: '{"id":"00o4wzh294nJMXp5P1d7","cell":"op3","_links":{"organization":{"href":"https://test.dne-okta.com"}},"pipeline":"idx","settings":{"analyticsCollectionEnabled":false,"bugReportingEnabled":true,"omEnabled":false,"pssoEnabled":false}}' + headers: + Content-Type: + - application/json + Date: + - Wed, 27 Sep 2023 22:43:45 GMT + status: 200 OK + code: 200 + duration: 266.477539ms + - id: 1 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + transfer_encoding: [] + trailer: {} + host: test.dne-okta.com + remote_addr: "" + request_uri: "" + body: "" + form: {} + headers: + Accept: + - application/json + url: https://test.dne-okta.com/.well-known/okta-organization + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + transfer_encoding: [] + trailer: {} + content_length: -1 + uncompressed: false + body: '{"id":"00o4wzh294nJMXp5P1d7","cell":"op3","_links":{"organization":{"href":"https://test.dne-okta.com"}},"pipeline":"idx","settings":{"analyticsCollectionEnabled":false,"bugReportingEnabled":true,"omEnabled":false,"pssoEnabled":false}}' + headers: + Content-Type: + - application/json + Date: + - Wed, 27 Sep 2023 22:43:45 GMT + status: 200 OK + code: 200 + duration: 67.481478ms From 371ab6328dce23d8ab08b59ea6eab6b150044c53 Mon Sep 17 00:00:00 2001 From: Mike Mondragon Date: Wed, 27 Sep 2023 18:17:23 -0700 Subject: [PATCH 07/42] Fetching IAM creds with assume role with web identity. --- .env.example | 6 +-- README.md | 20 ++++++---- cmd/root/m2m/m2m.go | 5 ++- cmd/root/web/web.go | 11 +----- internal/config/config.go | 10 ++--- internal/flag/flag.go | 28 +++++++++++++- internal/m2mauth/m2mauth.go | 52 +++++++++++++++++++++++--- internal/m2mauth/m2mauth_test.go | 14 +++---- internal/output/output.go | 20 ++++++++++ internal/testutils/testutils.go | 2 +- internal/webssoauth/webssoauth.go | 29 ++++---------- internal/webssoauth/webssoauth_test.go | 10 ++--- 12 files changed, 138 insertions(+), 69 deletions(-) diff --git a/.env.example b/.env.example index 68bea3e..53422d8 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ -OKTA_ORG_DOMAIN= -OKTA_OIDC_CLIENT_ID= -OKTA_AWS_ACCOUNT_FEDERATION_APP_ID= +OKTA_AWSCLI_ORG_DOMAIN= +OKTA_AWSCLI_OIDC_CLIENT_ID= +OKTA_AWSCLI_AWS_ACCOUNT_FEDERATION_APP_ID= OKTA_AWSCLI_IAM_IDP= OKTA_AWSCLI_IAM_ROLE= diff --git a/README.md b/README.md index 74bb66b..587e345 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # okta-aws-cli +**NOTE**: *Some environment variable names changed with the v2.0.0 release of +`okta-aws-cli`; double check your existing named variables in the [configuration +documentation](#configuration).* + `okta-aws-cli` is a CLI program allowing Okta to act as an identity provider and retrieve AWS IAM temporary credentials for use in AWS CLI, AWS SDKs, and other tools accessing the AWS API. There are two primary commands of operation: `web` - @@ -336,8 +340,8 @@ These global settings are optional unless marked otherwise: | Name | Description | Command line flag | ENV var and .env file value | |-----|-----|-----|-----| -| Okta Org Domain (**required**) | Full host and domain name of the Okta org e.g. `test.okta.com` or the custom domain value | `--org-domain [value]` | `OKTA_ORG_DOMAIN` | -| OIDC Client ID (**required**) | For `web` the OIDC native application / [Allowed Web SSO Client ID](#allowed-web-sso-client-id), for `m2m` the API services app ID | `--oidc-client-id [value]` | `OKTA_OIDC_CLIENT_ID` | +| Okta Org Domain (**required**) | Full host and domain name of the Okta org e.g. `test.okta.com` or the custom domain value | `--org-domain [value]` | `OKTA_AWSCLI_ORG_DOMAIN` | +| OIDC Client ID (**required**) | For `web` the OIDC native application / [Allowed Web SSO Client ID](#allowed-web-sso-client-id), for `m2m` the API services app ID | `--oidc-client-id [value]` | `OKTA_AWSCLI_OIDC_CLIENT_ID` | | AWS IAM Role ARN (**optional** for `web`, **required** for `m2m`) | For web preselects the role list to this preferred IAM role for the given IAM Identity Provider. For `m2m` | `--aws-iam-role [value]` | `OKTA_AWSCLI_IAM_ROLE` | | AWS Session Duration | The lifetime, in seconds, of the AWS credentials. Must be between 60 and 43200. | `--session-duration [value]` | `OKTA_AWSCLI_SESSION_DURATION` | | Output format | Default is `env-var`. Options: `env-var` for output to environment variables, `aws-credentials` for output to AWS credentials file, `process-credentials` for credentials as JSON | `--format [value]` | `OKTA_AWSCLI_FORMAT` | @@ -363,7 +367,7 @@ These settings are all optional: | Name | Description | Command line flag | ENV var and .env file value | |-----|-----|-----|-----| -| Okta AWS Account Federation integration app ID | See [AWS Account Federation integration app](#aws-account-federation-integration-app). This value is only required if the OIDC app doesn't have the `okta.apps.read` grant for whatever reason | `--aws-acct-fed-app-id [value]` | `OKTA_AWS_ACCOUNT_FEDERATION_APP_ID` | +| Okta AWS Account Federation integration app ID | See [AWS Account Federation integration app](#aws-account-federation-integration-app). This value is only required if the OIDC app doesn't have the `okta.apps.read` grant for whatever reason | `--aws-acct-fed-app-id [value]` | `OKTA_AWSCLI_AWS_ACCOUNT_FEDERATION_APP_ID` | | AWS IAM Identity Provider ARN | Preselects the IdP list to this preferred IAM Identity Provider. If there are other IdPs available they will not be listed. | `--aws-iam-idp [value]` | `OKTA_AWSCLI_IAM_IDP` | | Display QR Code | `true` if flag is present | `--qr-code` | `OKTA_AWSCLI_QR_CODE=true` | | Automatically open the activation URL with the system web browser | `true` if flag is present | `--open-browser` | `OKTA_AWSCLI_OPEN_BROWSER=true` | @@ -391,15 +395,15 @@ Example: `0oa9x1rifa2H6Q5d8325` #### Environment variables example ```shell -export OKTA_ORG_DOMAIN=test.okta.com -export OKTA_OIDC_CLIENT_ID=0oa5wyqjk6Wm148fE1d7 +export OKTA_AWSCLI_ORG_DOMAIN=test.okta.com +export OKTA_AWSCLI_OIDC_CLIENT_ID=0oa5wyqjk6Wm148fE1d7 ``` #### `.env` file variables example ``` -OKTA_ORG_DOMAIN=test.okta.com -OKTA_OIDC_CLIENT_ID=0oa5wyqjk6Wm148fE1d7 +OKTA_AWSCLI_ORG_DOMAIN=test.okta.com +OKTA_AWSCLI_OIDC_CLIENT_ID=0oa5wyqjk6Wm148fE1d7 ``` #### Command line flags example @@ -427,7 +431,7 @@ These settings are optional unless marked otherwise: | Name | Description | Command line flag | ENV var and .env file value | |-----|-----|-----|-----| -| Custom Authorization Server ID (**required**) | The ID of the Okta custom authorization server | `--authz-id [value]` | `OKTA_AUTHZ_ID` | +| Custom Authorization Server ID (**required**) | The ID of the Okta custom authorization server | `--authz-id [value]` | `OKTA_AWSCLI_AUTHZ_ID` | | Key ID (kid) (**required**) | The ID of the key stored in the service app | `--key-id [value]` | `OKTA_AWSCLI_KEY_ID` | | Private Key (**required**) | PEM or JWKS format private key whose public key is stored on the service app | `--private-key [value]` | `OKTA_AWSCLI_PRIVATE_KEY` | | Custom scope name | The custom scope established in the custom authorization server. Default `okta-aws-cli` | `--custom-scope [value]` | `OKTA_AWSCLI_CUSTOM_SCOPE` | diff --git a/cmd/root/m2m/m2m.go b/cmd/root/m2m/m2m.go index 23bff67..864e23d 100644 --- a/cmd/root/m2m/m2m.go +++ b/cmd/root/m2m/m2m.go @@ -68,16 +68,17 @@ func NewM2MCommand() *cobra.Command { if err != nil { return err } + err = cliFlag.CheckRequiredFlags(requiredFlags) if err != nil { return err } - m2mAuth, err := m2mauth.NewM2MAuthentication(config) + m2ma, err := m2mauth.NewM2MAuthentication(config) if err != nil { return err } - return m2mAuth.EstablishIAMCredentials() + return m2ma.EstablishIAMCredentials() }, } diff --git a/cmd/root/web/web.go b/cmd/root/web/web.go index 3e209ec..015ea33 100644 --- a/cmd/root/web/web.go +++ b/cmd/root/web/web.go @@ -73,18 +73,11 @@ func NewWebCommand() *cobra.Command { return err } - // TODO refactor the naming convention - // webAuth, err := webauth.NewWebSSOAuthentication(config) - // if err != nil { - // return err - // } - // return webAuth.EstablishIAMCredentials() - - st, err := webssoauth.NewWebSSOAuthentication(config) + wsa, err := webssoauth.NewWebSSOAuthentication(config) if err != nil { return err } - return st.EstablishIAMCredentials() + return wsa.EstablishIAMCredentials() }, } diff --git a/internal/config/config.go b/internal/config/config.go index 9413bae..844c69b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -31,7 +31,7 @@ import ( const ( // Version app version - Version = "1.2.2" + Version = "2.0.0" // AWSCredentialsFormat format const AWSCredentialsFormat = "aws-credentials" @@ -82,7 +82,7 @@ const ( CacheAccessTokenFlag = "cache-access-token" // AuthzIDEnvVar env var const - AuthzIDEnvVar = "OKTA_AUTHZ_ID" + AuthzIDEnvVar = "OKTA_AWSCLI_AUTHZ_ID" // AWSCredentialsEnvVar env var const AWSCredentialsEnvVar = "OKTA_AWSCLI_AWS_CREDENTIALS" // AWSIAMIdPEnvVar env var const @@ -106,11 +106,11 @@ const ( // LegacyAWSVariablesEnvVar env var const LegacyAWSVariablesEnvVar = "OKTA_AWSCLI_LEGACY_AWS_VARIABLES" // OktaOIDCClientIDEnvVar env var const - OktaOIDCClientIDEnvVar = "OKTA_OIDC_CLIENT_ID" + OktaOIDCClientIDEnvVar = "OKTA_AWSCLI_OIDC_CLIENT_ID" // OktaOrgDomainEnvVar env var const - OktaOrgDomainEnvVar = "OKTA_ORG_DOMAIN" + OktaOrgDomainEnvVar = "OKTA_AWSCLI_ORG_DOMAIN" // OktaAWSAccountFederationAppIDEnvVar env var const - OktaAWSAccountFederationAppIDEnvVar = "OKTA_AWS_ACCOUNT_FEDERATION_APP_ID" + OktaAWSAccountFederationAppIDEnvVar = "OKTA_AWSCLI_AWS_ACCOUNT_FEDERATION_APP_ID" // OpenBrowserEnvVar env var const OpenBrowserEnvVar = "OKTA_AWSCLI_OPEN_BROWSER" // PrivateKeyEnvVar env var const diff --git a/internal/flag/flag.go b/internal/flag/flag.go index e8b7e64..40654ae 100644 --- a/internal/flag/flag.go +++ b/internal/flag/flag.go @@ -31,6 +31,21 @@ const ( dotEnvFilename = ".env" ) +var ( + altFlagNames map[string]string +) + +func init() { + altFlagNames = map[string]string{ + "org-domain": "okta_awscli_org_domain", + "oidc-client-id": "okta_awscli_oidc_client_id", + "aws-iam-role": "okta_awscli_iam_role", + "key-id": "okta_awscli_key_id", + "private-key": "okta_awscli_private_key", + "authz-id": "okta_awscli_authz_id", + } +} + // Flag Convenience struct for Viper flag parameters type Flag struct { Name string @@ -102,7 +117,8 @@ func MakeFlagBindings(cmd *cobra.Command, flags []Flag, persistent bool) { func CheckRequiredFlags(flags []string) error { unsetFlags := []string{} for _, f := range flags { - if !viper.GetViper().IsSet(f) { + altName := altFlagName(f) + if !viper.GetViper().IsSet(f) && !viper.GetViper().IsSet(altName) { unsetFlags = append(unsetFlags, fmt.Sprintf(" --%s", f)) } } @@ -111,3 +127,13 @@ func CheckRequiredFlags(flags []string) error { } return nil } + +// altFlagName Helper function for looking up viper values as it key CLI flag +// and ENV VAR name items differently For example as a CLI flag the PK key name +// would be h"private-key" and "okta_awscli_private_key" as an ENV VAR. +func altFlagName(name string) string { + if alt, ok := altFlagNames[name]; ok { + return alt + } + return name +} diff --git a/internal/m2mauth/m2mauth.go b/internal/m2mauth/m2mauth.go index edc28b5..1e882dd 100644 --- a/internal/m2mauth/m2mauth.go +++ b/internal/m2mauth/m2mauth.go @@ -30,8 +30,13 @@ import ( "strings" "time" + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/sts" + oaws "github.com/okta/okta-aws-cli/internal/aws" "github.com/okta/okta-aws-cli/internal/config" "github.com/okta/okta-aws-cli/internal/okta" + "github.com/okta/okta-aws-cli/internal/output" "github.com/okta/okta-aws-cli/internal/utils" "gopkg.in/square/go-jose.v2" "gopkg.in/square/go-jose.v2/jwt" @@ -58,16 +63,51 @@ func NewM2MAuthentication(config *config.Config) (*M2MAuthentication, error) { // - CLI requests access token from custom authz server at /oauth2/{authzID}/v1/token // - CLI presents access token to AWS STS for temporary AWS IAM creds func (m *M2MAuthentication) EstablishIAMCredentials() error { - _, err := m.AccessToken() + at, err := m.accessToken() if err != nil { return err } - // WIP - // out, err := m.AssumeRole(at) (*sts.AssumeRoleWithWebIdentityOutput, error) { - // err = m.OutputCredentials(out) + + cred, err := m.awsAssumeRoleWithWebIdentity(at) + if err != nil { + return err + } + + err = output.RenderAWSCredential(m.config, cred) + if err != nil { + return err + } + return nil } +func (m *M2MAuthentication) awsAssumeRoleWithWebIdentity(at *okta.AccessToken) (credential *oaws.Credential, err error) { + awsCfg := aws.NewConfig().WithHTTPClient(m.config.HTTPClient()) + sess, err := session.NewSession(awsCfg) + if err != nil { + return nil, err + } + + svc := sts.New(sess) + input := &sts.AssumeRoleWithWebIdentityInput{ + DurationSeconds: aws.Int64(m.config.AWSSessionDuration()), + RoleArn: aws.String(m.config.AWSIAMRole()), + RoleSessionName: aws.String("okta-aws-cli"), + WebIdentityToken: &at.AccessToken, + } + svcResp, err := svc.AssumeRoleWithWebIdentity(input) + if err != nil { + return nil, err + } + + credential = &oaws.Credential{ + AccessKeyID: *svcResp.Credentials.AccessKeyId, + SecretAccessKey: *svcResp.Credentials.SecretAccessKey, + SessionToken: *svcResp.Credentials.SessionToken, + } + return credential, nil +} + func (m *M2MAuthentication) createKeySigner() (jose.Signer, error) { signerOptions := (&jose.SignerOptions{}).WithHeader("kid", m.config.KeyID()) priv := []byte(strings.ReplaceAll(m.config.PrivateKey(), `\n`, "\n")) @@ -127,9 +167,9 @@ func (m *M2MAuthentication) makeClientAssertion() (string, error) { return jwtBuilder.CompactSerialize() } -// AccessToken Takes okta-aws-cli private key and presents a client_credentials +// accessToken Takes okta-aws-cli private key and presents a client_credentials // flow assertion to /oauth2/{authzServerID}/v1/token to gather an access token. -func (m *M2MAuthentication) AccessToken() (*okta.AccessToken, error) { +func (m *M2MAuthentication) accessToken() (*okta.AccessToken, error) { clientAssertion, err := m.makeClientAssertion() if err != nil { return nil, err diff --git a/internal/m2mauth/m2mauth_test.go b/internal/m2mauth/m2mauth_test.go index 8687f12..aea9398 100644 --- a/internal/m2mauth/m2mauth_test.go +++ b/internal/m2mauth/m2mauth_test.go @@ -29,13 +29,13 @@ import ( func TestMain(m *testing.M) { var reset func() - reset = testutils.OsSetEnvIfBlank("OKTA_ORG_DOMAIN", testutils.TestDomainName) + reset = testutils.OsSetEnvIfBlank("OKTA_AWSCLI_ORG_DOMAIN", testutils.TestDomainName) defer reset() - reset = testutils.OsSetEnvIfBlank("OKTA_OIDC_CLIENT_ID", "0oaa4htg72TNrkTDr1d7") + reset = testutils.OsSetEnvIfBlank("OKTA_AWSCLI_OIDC_CLIENT_ID", "0oaa4htg72TNrkTDr1d7") defer reset() reset = testutils.OsSetEnvIfBlank("OKTA_AWSCLI_IAM_ROLE", "arn:aws:iam::123:role/RickRollNeverGonnaGiveYouUp") defer reset() - reset = testutils.OsSetEnvIfBlank("OKTA_AUTHZ_ID", "aus8w23r13NvyUwln1d7") + reset = testutils.OsSetEnvIfBlank("OKTA_AWSCLI_AUTHZ_ID", "aus8w23r13NvyUwln1d7") defer reset() reset = testutils.OsSetEnvIfBlank("OKTA_AWSCLI_CUSTOM_SCOPE", "okta-aws-cli") defer reset() @@ -81,7 +81,7 @@ func TestM2MAuthAccessToken(t *testing.T) { m, err := NewM2MAuthentication(config) require.NoError(t, err) - at, err := m.AccessToken() + at, err := m.accessToken() require.NoError(t, err) require.NotNil(t, at) @@ -93,10 +93,10 @@ func TestM2MAuthAccessToken(t *testing.T) { func setupTest(t *testing.T) (*config.Config, func(t *testing.T)) { attrs := &config.Attributes{ - OrgDomain: os.Getenv("OKTA_ORG_DOMAIN"), - OIDCAppID: os.Getenv("OKTA_OIDC_CLIENT_ID"), + OrgDomain: os.Getenv("OKTA_AWSCLI_ORG_DOMAIN"), + OIDCAppID: os.Getenv("OKTA_AWSCLI_OIDC_CLIENT_ID"), AWSIAMRole: os.Getenv("OKTA_AWSCLI_IAM_ROLE"), - AuthzID: os.Getenv("OKTA_AUTHZ_ID"), + AuthzID: os.Getenv("OKTA_AWSCLI_AUTHZ_ID"), CustomScope: os.Getenv("OKTA_AWSCLI_CUSTOM_SCOPE"), KeyID: os.Getenv("OKTA_AWSCLI_KEY_ID"), PrivateKey: os.Getenv("OKTA_AWSCLI_PRIVATE_KEY"), diff --git a/internal/output/output.go b/internal/output/output.go index d72c07c..7009b04 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -17,7 +17,12 @@ package output import ( + "fmt" + "os" + "time" + "github.com/okta/okta-aws-cli/internal/aws" + oaws "github.com/okta/okta-aws-cli/internal/aws" "github.com/okta/okta-aws-cli/internal/config" ) @@ -25,3 +30,18 @@ import ( type Outputter interface { Output(c *config.Config, ac *aws.Credential) error } + +// RenderAWSCredential Renders the credentials in the prescribed format. +func RenderAWSCredential(cfg *config.Config, ac *oaws.Credential) error { + var o Outputter + switch cfg.Format() { + case config.AWSCredentialsFormat: + expiry := time.Now().Add(time.Duration(cfg.AWSSessionDuration()) * time.Second).Format(time.RFC3339) + o = NewAWSCredentialsFile(cfg.LegacyAWSVariables(), cfg.ExpiryAWSVariables(), expiry) + default: + o = NewEnvVar(cfg.LegacyAWSVariables()) + fmt.Fprintf(os.Stderr, "\n") + } + + return o.Output(cfg, ac) +} diff --git a/internal/testutils/testutils.go b/internal/testutils/testutils.go index 6c6a138..3a8204a 100644 --- a/internal/testutils/testutils.go +++ b/internal/testutils/testutils.go @@ -64,7 +64,7 @@ func VCROktaAPIRequestHook(i *cassette.Interaction) error { // test.dne-okta.com vcrHostname := TestDomainName // example.okta.com - orgHostname := os.Getenv("OKTA_ORG_DOMAIN") + orgHostname := os.Getenv("OKTA_AWSCLI_ORG_DOMAIN") // save disk space, clean up what gets written to disk i.Request.Headers.Del("User-Agent") diff --git a/internal/webssoauth/webssoauth.go b/internal/webssoauth/webssoauth.go index ac37693..97c5f5d 100644 --- a/internal/webssoauth/webssoauth.go +++ b/internal/webssoauth/webssoauth.go @@ -147,7 +147,7 @@ func (w *WebSSOAuthentication) EstablishIAMCredentials() error { w.promptAuthentication(deviceAuth) - at, err = w.AccessToken(deviceAuth) + at, err = w.accessToken(deviceAuth) if err != nil { return err } @@ -292,12 +292,12 @@ func (w *WebSSOAuthentication) establishTokenWithFedAppID(clientID, fedAppID str return err } - ac, err := w.fetchAWSCredentialWithSAMLRole(iar, assertion) + cred, err := w.awsAssumeRoleWithSAML(iar, assertion) if err != nil { return err } - err = w.renderCredential(ac) + err = output.RenderAWSCredential(w.config, cred) if err != nil { return err } @@ -305,24 +305,9 @@ func (w *WebSSOAuthentication) establishTokenWithFedAppID(clientID, fedAppID str return nil } -// renderCredential Renders the credentials in the prescribed format. -func (w *WebSSOAuthentication) renderCredential(ac *oaws.Credential) error { - var o output.Outputter - switch w.config.Format() { - case config.AWSCredentialsFormat: - expiry := time.Now().Add(time.Duration(w.config.AWSSessionDuration()) * time.Second).Format(time.RFC3339) - o = output.NewAWSCredentialsFile(w.config.LegacyAWSVariables(), w.config.ExpiryAWSVariables(), expiry) - default: - o = output.NewEnvVar(w.config.LegacyAWSVariables()) - fmt.Fprintf(os.Stderr, "\n") - } - - return o.Output(w.config, ac) -} - -// fetchAWSCredentialWithSAMLRole Get AWS Credentials with an STS Assume Role With SAML AWS +// awsAssumeRoleWithSAML Get AWS Credentials with an STS Assume Role With SAML AWS // API call. -func (w *WebSSOAuthentication) fetchAWSCredentialWithSAMLRole(iar *idpAndRole, assertion string) (credential *oaws.Credential, err error) { +func (w *WebSSOAuthentication) awsAssumeRoleWithSAML(iar *idpAndRole, assertion string) (credential *oaws.Credential, err error) { awsCfg := aws.NewConfig().WithHTTPClient(w.config.HTTPClient()) sess, err := session.NewSession(awsCfg) if err != nil { @@ -711,9 +696,9 @@ func (w *WebSSOAuthentication) listFedApps(clientID string, at *okta.AccessToken return } -// AccessToken see: +// accessToken see: // https://developer.okta.com/docs/reference/api/oidc/#token -func (w *WebSSOAuthentication) AccessToken(deviceAuth *okta.DeviceAuthorization) (at *okta.AccessToken, err error) { +func (w *WebSSOAuthentication) accessToken(deviceAuth *okta.DeviceAuthorization) (at *okta.AccessToken, err error) { clientID := w.config.OIDCAppID() apiURL := fmt.Sprintf(okta.OAuthV1TokenEndpointFormat, w.config.OrgDomain()) diff --git a/internal/webssoauth/webssoauth_test.go b/internal/webssoauth/webssoauth_test.go index 379206a..cdd3bb7 100644 --- a/internal/webssoauth/webssoauth_test.go +++ b/internal/webssoauth/webssoauth_test.go @@ -28,9 +28,9 @@ import ( func TestMain(m *testing.M) { var reset func() - reset = testutils.OsSetEnvIfBlank("OKTA_ORG_DOMAIN", testutils.TestDomainName) + reset = testutils.OsSetEnvIfBlank("OKTA_AWSCLI_ORG_DOMAIN", testutils.TestDomainName) defer reset() - reset = testutils.OsSetEnvIfBlank("OKTA_OIDC_CLIENT_ID", "0oa4x34ogyC1i1krJ1d7") + reset = testutils.OsSetEnvIfBlank("OKTA_AWSCLI_OIDC_CLIENT_ID", "0oa4x34ogyC1i1krJ1d7") defer reset() os.Exit(m.Run()) @@ -68,7 +68,7 @@ func TestWebSSOAuthAccessToken(t *testing.T) { require.NoError(t, err) da, err := w.authorize() require.NoError(t, err) - at, err := w.AccessToken(da) + at, err := w.accessToken(da) require.NoError(t, err) require.Equal(t, at.ExpiresIn, int64(3600)) require.Equal(t, at.TokenType, "Bearer") @@ -78,8 +78,8 @@ func TestWebSSOAuthAccessToken(t *testing.T) { func setupTest(t *testing.T) (*config.Config, func(t *testing.T)) { attrs := &config.Attributes{ - OrgDomain: os.Getenv("OKTA_ORG_DOMAIN"), - OIDCAppID: os.Getenv("OKTA_OIDC_CLIENT_ID"), + OrgDomain: os.Getenv("OKTA_AWSCLI_ORG_DOMAIN"), + OIDCAppID: os.Getenv("OKTA_AWSCLI_OIDC_CLIENT_ID"), } config, err := config.NewConfig(attrs) require.NoError(t, err) From 4d25572ac59dc2774171ecc7cfad017c274a8bb5 Mon Sep 17 00:00:00 2001 From: Mike Mondragon Date: Thu, 28 Sep 2023 15:55:22 -0700 Subject: [PATCH 08/42] Disable cobra's default completion command. --- cmd/root/root.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cmd/root/root.go b/cmd/root/root.go index 45e4090..ae68a38 100644 --- a/cmd/root/root.go +++ b/cmd/root/root.go @@ -156,6 +156,9 @@ func NewRootCommand() *cobra.Command { Okta authentication in support of AWS CLI. okta-aws-cli handles authentication with Okta and token exchange with AWS STS to collect temporary IAM credentials associated with a given IAM Role for the AWS CLI operator.`, + CompletionOptions: cobra.CompletionOptions{ + DisableDefaultCmd: true, + }, } cmd.SetUsageTemplate(resourceUsageTemplate()) From a495d959f66975990b38ce35e85dc8e39024b2c0 Mon Sep 17 00:00:00 2001 From: Mike Mondragon Date: Thu, 28 Sep 2023 16:41:31 -0700 Subject: [PATCH 09/42] MVP for v2.0.0-beta.0, which is Custom Authz Server Oriented --- CHANGELOG.md | 96 +++++++++++++++++++++++++++++++++++++++ README.md | 17 +++++-- internal/config/config.go | 2 +- 3 files changed, 111 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3aa3fe..3ca7cef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,101 @@ # Changelog +## 2.0.0 (TBD) + +NOTE: These are the expected 2.0.0 release items; see 2.0.0-beta.X notes for +incremental changes during beta development + +### (completed) New commands + +`okta-aws-cli`'s functions are encapsulated as (sub)commands e.g. `$ okta-aws-cli [sub-command]` + +| Command | Description | +|-----|-----| +| `web` | Human oriented retrieval of temporary IAM credentials through Okta authentication and device authorization. Note: if `okta-aws-cli` is not given a command it defaults to this original `web` command. | +| `m2m` | Machine/headless oriented retrieval of temporary IAM credentials through Okta authentication with a private key. | +| `debug` | Debug okta.yaml config file and exit. | + +### (completed) Environment variable name changes + +A small number of environment variable names have been renamed to be consistent +in the naming convention for `okta-aws-cli` specific names. + +| old name | new name | +|----------|----------| +| `OKTA_ORG_DOMAIN` | `OKTA_AWSCLI_ORG_DOMAIN` | +| `OKTA_OIDC_CLIENT_ID` | `OKTA_AWSCLI_OIDC_CLIENT_ID` | +| `OKTA_AWS_ACCOUNT_FEDERATION_APP_ID` | `OKTA_AWSCLI_AWS_ACCOUNT_FEDERATION_APP_ID` | + +### (expected) Credentials output as JSON + +Emits IAM temporary credentials as JSON in [process +credentials](https://docs.aws.amazon.com/sdkref/latest/guide/feature-process-credentials.html) +format. + +### (expected) Secondary command exec + +Instead of scripting and/or eval'ing `okta-aws-cli` into a shell and then +running another command have `okta-aws-cli` run the command directly passing +along the IAM credentials as environment variables. + +``` +# CLI exec's anything after the double dash "--" as another command. +$ okta-aws-cli web \ + --org-domain test.okta.com \ + --oidc-client-id 0oa5wyqjk6Wm148fE1d7 \ + --exec -- aws ec2 describe-instances +``` + +### (expected) Alternate web browser open command + +The `web` command will open the system's default web browser when the +`--open-browser` flag is present. It is convenient to have the browser open on a +separate profile. If the command to open the browser is known for the host +system and alternate open command can be specified. + +``` +# Use macOS open to open browser in Chrome incognito mode on macOS +$ okta-aws-cli web \ + --org-domain test.okta.com \ + --oidc-client-id 0oa5wyqjk6Wm148fE1d7 \ + --open-browser \ + --open-browser-command "open -na 'Google Chrome' --args -incognito" +``` + +``` +# Open browser in Chrome "Profile 1" on macOS calling the Chrome executable directly +$ okta-aws-cli web \ + --org-domain test.okta.com \ + --oidc-client-id 0oa5wyqjk6Wm148fE1d7 \ + --open-browser \ + --open-browser-command "/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --args --profile-directory='Profile 1'" +``` + +## 2.0.0-beta.0 (September 29, 2023) + +### New commands + +`okta-aws-cli`'s functions are encapsulated as (sub)commands e.g. `$ okta-aws-cli [sub-command]` + +| Command | Description | +|-----|-----| +| `web` | Human oriented retrieval of temporary IAM credentials through Okta authentication and device authorization. Note: if `okta-aws-cli` is not given a command it defaults to this original `web` command. | +| `m2m` | Machine/headless oriented retrieval of temporary IAM credentials through Okta authentication with a private key. | +| `debug` | Debug okta.yaml config file and exit. | + +### Environment variable name changes + +A small number of environment variable names have been renamed to be consistent +in the naming convention for `okta-aws-cli` specific names. + +| old name | new name | +|----------|----------| +| `OKTA_ORG_DOMAIN` | `OKTA_AWSCLI_ORG_DOMAIN` | +| `OKTA_OIDC_CLIENT_ID` | `OKTA_AWSCLI_OIDC_CLIENT_ID` | +| `OKTA_AWS_ACCOUNT_FEDERATION_APP_ID` | `OKTA_AWSCLI_AWS_ACCOUNT_FEDERATION_APP_ID` | + + + ## 1.2.2 (August 30, 2023) * Ensure evaluation of CLI flag for profile is in the same order as the other flags [#124](https://github.com/okta/okta-aws-cli/pull/124) diff --git a/README.md b/README.md index 587e345..47c4cc3 100644 --- a/README.md +++ b/README.md @@ -73,9 +73,9 @@ format. | Command | Description | |-----|-----| -| web | Human oriented retrieval of temporary IAM credentials through Okta authentication and device authorization. Note: if `okta-aws-cli` is not given a command it defaults to this original `web` command. | -| m2m | Machine/headless oriented retrieval of temporary IAM credentials through Okta authentication with a private key. | -| debug | Debug okta.yaml config file and exit. | +| `web` | Human oriented retrieval of temporary IAM credentials through Okta authentication and device authorization. Note: if `okta-aws-cli` is not given a command it defaults to this original `web` command. | +| `m2m` | Machine/headless oriented retrieval of temporary IAM credentials through Okta authentication with a private key. | +| `debug` | Debug okta.yaml config file and exit. | ## Web Command @@ -199,6 +199,17 @@ around using a custom admin role. ## M2M Command ```shell +# This example presumes its arguments are set as environment variables such as +# one may find in a headless CI environment. +# e.g. +# export OKTA_AWSCLI_ORG_DOMAIN="test.oka.com" +# export OKTA_AWSCLI_OIDC_CLIENT_ID="0oaa4htg72TNrkTDr1d7" +# export OKTA_AWSCLI_IAM_ROLE="arn:aws:iam::1234:role/Circle-CI-ops +# export OKTA_AWSCLI_CUSTOM_SCOPE="okta-aws-cli" +# export OKTA_AWSCLI_KEY_ID="kid-rock" +# export OKTA_AWSCLI_PRIVATE_KEY="... long string ..." +# export OKTA_AWSCLI_AUTHZ_ID="aus8w23r13NvyUwln1d7" + $ okta-aws-cli m2m export AWS_ACCESS_KEY_ID=ASIAUJHVCS6UQC52NOL7 export AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY diff --git a/internal/config/config.go b/internal/config/config.go index 844c69b..f5487c0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -31,7 +31,7 @@ import ( const ( // Version app version - Version = "2.0.0" + Version = "2.0.0-beta.0" // AWSCredentialsFormat format const AWSCredentialsFormat = "aws-credentials" From cd11cf2396ef0764b80f88e1aae1c6501db5bdbd Mon Sep 17 00:00:00 2001 From: Mike Mondragon Date: Thu, 28 Sep 2023 17:55:21 -0700 Subject: [PATCH 10/42] Also works with the "default" authorization server. --- README.md | 34 ++++++++++++++++++++-------------- cmd/root/m2m/m2m.go | 4 ++-- internal/m2mauth/m2mauth.go | 8 ++++++++ 3 files changed, 30 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 47c4cc3..7a8fa49 100644 --- a/README.md +++ b/README.md @@ -205,7 +205,7 @@ around using a custom admin role. # export OKTA_AWSCLI_ORG_DOMAIN="test.oka.com" # export OKTA_AWSCLI_OIDC_CLIENT_ID="0oaa4htg72TNrkTDr1d7" # export OKTA_AWSCLI_IAM_ROLE="arn:aws:iam::1234:role/Circle-CI-ops -# export OKTA_AWSCLI_CUSTOM_SCOPE="okta-aws-cli" +# export OKTA_AWSCLI_CUSTOM_SCOPE="okta-m2m-access" # export OKTA_AWSCLI_KEY_ID="kid-rock" # export OKTA_AWSCLI_PRIVATE_KEY="... long string ..." # export OKTA_AWSCLI_AUTHZ_ID="aus8w23r13NvyUwln1d7" @@ -220,8 +220,8 @@ M2M command is headless machine to machine authorization. The operator executes `okta-aws-cli m2m` which has access to a private key whose public key is registered in an Okta API service application. `okta-aws-cli m2m` signs a request for an Okta access token that is associated with the Okta service -application. Given the Okta custom authorization server returns an access token, -the access token is presented to AWS STS using +application. Given the Okta authorization (default or custom) server returns an +access token, the access token is presented to AWS STS using [AssumeRoleWithWebIdentity](https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRoleWithWebIdentity.html). AWS and Okta communicate directly by OIDC protocol to confirm authorization for IAM credentials. @@ -244,7 +244,7 @@ The `Session Token` has a default expiry of 60 minutes. M2M is an integration of: - [Okta API service app](https://developer.okta.com/docs/guides/implement-oauth-for-okta-serviceapp/main/) -- [Okta custom authorization server](https://developer.okta.com/docs/guides/customize-authz-server/main/) with a custom scope +- Okta default or a [custom](https://developer.okta.com/docs/guides/customize-authz-server/main/) authorization server with a custom scope - [Okta access policy](https://developer.okta.com/docs/guides/configure-access-policy/main/) associated with the service app and have rule(s) for the client credentials flow - [AWS IAM OpenID Connect (OIDC) identity provider](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_create_oidc.html) @@ -258,7 +258,10 @@ key pair and copy either JWKS or PEM formatted private key. Where ever the private key resides it needs to be available to `okta-aws-cli m2m` at runtime; for instance injected as an environment variable from a secrets manager / vault. -#### Okta Custom Authorization Server +#### Okta Authorization Server + +Follow these steps for a custom authorization server, `okta-aws-cli` will +utilize the default authorization server otherwise. Okta custom authorization servers are available in Developer Edition orgs. For production orgs the Workforce Identity SKU "API Access Management" needs to be @@ -282,8 +285,8 @@ assume the scope is named `okta-aws-cli`, but if it isn't the CLI flag #### Okta Access Policy -On the custom authorization server panel select "Access Policies" this is at -`Security > API > [the authorization server] > Access Policies`. Then select +On the authorization server panel select "Access Policies" this is at +`Security > API > [the server] > Access Policies`. Then select "Add New Access Policy", give it a name and description. Also, select "Assign to" > "The following clients" and assign to the established Okta service app. Save the policy. @@ -293,7 +296,7 @@ instance "Client Credentials Client acting on behalf of itself". Give the rule the parameters "IF Grant type is" / "Client acting on behalf of itself" and select "Client Credentials". Then "AND User is", assign your user(s) preference. Finally, "AND Scopes requested" / "The following scopes", choose the custom -scope created. The CLI defaults to custom scope named `okta-aws-cli` otherwise +scope created. The CLI defaults to custom scope named `okta-m2m-acces` otherwise the `--custom-scope` CLI flag is required at runtime specify the name. Save the rule. @@ -303,11 +306,14 @@ From the AWS Console, in the IAM panel, select "Identity providers". Then click "Add provider". In the add provider form select "OpenID Connect". Set the "Provider URL" to the issuer URL from the Okta API Authorization Servers list for your custom authorization server (example: https://[your -org].okta.com/oauth2/[custom auth server id]). Set the "Audience" value the -"Audience" value established during the Okta custom authorization server set up. +org].okta.com/oauth2/[custom auth server id or default]). Set the "Audience" +value the "Audience" value listed for the authorization server in the Okta +panel. -After the IdP is created note it's ARN value and assign IAM Roles to the IdP. -Also note those Role values. +After the IdP is created note it's ARN value. Any IAM roles that need to be +associated with the IdP need to have a trust relationship established on the +role of the `sts:AssumeRoleWithWebIdentity` action type. Also not the ARNs of +these roles for later use. ## Configuration ### Global settings @@ -442,10 +448,10 @@ These settings are optional unless marked otherwise: | Name | Description | Command line flag | ENV var and .env file value | |-----|-----|-----|-----| -| Custom Authorization Server ID (**required**) | The ID of the Okta custom authorization server | `--authz-id [value]` | `OKTA_AWSCLI_AUTHZ_ID` | | Key ID (kid) (**required**) | The ID of the key stored in the service app | `--key-id [value]` | `OKTA_AWSCLI_KEY_ID` | | Private Key (**required**) | PEM or JWKS format private key whose public key is stored on the service app | `--private-key [value]` | `OKTA_AWSCLI_PRIVATE_KEY` | -| Custom scope name | The custom scope established in the custom authorization server. Default `okta-aws-cli` | `--custom-scope [value]` | `OKTA_AWSCLI_CUSTOM_SCOPE` | +| Authorization Server ID | The ID of the Okta authorization server, set ID for a custom authorization server, will use default otherwise. Default `default` | `--authz-id [value]` | `OKTA_AWSCLI_AUTHZ_ID` | +| Custom scope name | The custom scope established in the custom authorization server. Default `okta-m2m-access` | `--custom-scope [value]` | `OKTA_AWSCLI_CUSTOM_SCOPE` | ### Friendly IdP and Role menu labels diff --git a/cmd/root/m2m/m2m.go b/cmd/root/m2m/m2m.go index 864e23d..eeab36a 100644 --- a/cmd/root/m2m/m2m.go +++ b/cmd/root/m2m/m2m.go @@ -43,7 +43,7 @@ var ( { Name: config.CustomScopeFlag, Short: "m", - Value: "okta-aws-cli", + Value: "", Usage: "Custom Scope", EnvVar: config.CustomScopeEnvVar, }, @@ -55,7 +55,7 @@ var ( EnvVar: config.AuthzIDEnvVar, }, } - requiredFlags = []string{"org-domain", "oidc-client-id", "aws-iam-role", "key-id", "private-key", "authz-id"} + requiredFlags = []string{"org-domain", "oidc-client-id", "aws-iam-role", "key-id", "private-key"} ) // NewM2MCommand Sets up the m2m cobra sub command diff --git a/internal/m2mauth/m2mauth.go b/internal/m2mauth/m2mauth.go index 1e882dd..70b7be5 100644 --- a/internal/m2mauth/m2mauth.go +++ b/internal/m2mauth/m2mauth.go @@ -49,6 +49,14 @@ type M2MAuthentication struct { // NewM2MAuthentication New M2M Authentication constructor func NewM2MAuthentication(config *config.Config) (*M2MAuthentication, error) { + // need to set our config defaults + if config.CustomScope() == "" { + config.SetCustomScope("okta-m2m-access") + } + if config.AuthzID() == "" { + config.SetAuthzID("default") + } + m := M2MAuthentication{ config: config, } From 5ab08d1cbc46e28fe578238c9326d5bef969c97e Mon Sep 17 00:00:00 2001 From: Mike Mondragon Date: Thu, 28 Sep 2023 18:03:09 -0700 Subject: [PATCH 11/42] qc --- go.sum | 2 ++ internal/config/net.go | 7 +++++-- internal/flag/flag.go | 8 ++------ internal/m2mauth/m2mauth.go | 13 ++++++++++--- internal/okta/accesstoken.go | 3 +++ internal/output/output.go | 3 +-- internal/webssoauth/webssoauth.go | 2 +- 7 files changed, 24 insertions(+), 14 deletions(-) diff --git a/go.sum b/go.sum index 482007f..bd5bd7e 100644 --- a/go.sum +++ b/go.sum @@ -430,6 +430,8 @@ golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= +golang.org/x/tools v0.1.12 h1:VveCTK38A2rkS8ZqFY25HIDFscX5X9OoEhJd3quQmXU= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/config/net.go b/internal/config/net.go index 6522c7b..d051ce3 100644 --- a/internal/config/net.go +++ b/internal/config/net.go @@ -27,13 +27,16 @@ import ( "time" ) +// PrependDebug debug logline label +const PrependDebug = "[DEBUG] " + func debugRequest(req *http.Request) { if req == nil { return } reqData, err := httputil.DumpRequest(req, true) if err == nil { - log.Printf("[DEBUG] "+logReqMsg, req.RequestURI, prettyPrintJSONLines(reqData)) + log.Printf(PrependDebug+logReqMsg, req.RequestURI, prettyPrintJSONLines(reqData)) } else { log.Printf("[ERROR] %s API Request error: %#v", req.RequestURI, err) } @@ -45,7 +48,7 @@ func debugResponse(resp *http.Response) { } respData, err := httputil.DumpResponse(resp, true) if err == nil { - log.Printf("[DEBUG] "+logRespMsg, resp.Request.RequestURI, prettyPrintJSONLines(respData)) + log.Printf(PrependDebug+logRespMsg, resp.Request.RequestURI, prettyPrintJSONLines(respData)) } else { log.Printf("[ERROR] %s API Response error: %#v", resp.Request.RequestURI, err) } diff --git a/internal/flag/flag.go b/internal/flag/flag.go index 40654ae..183278d 100644 --- a/internal/flag/flag.go +++ b/internal/flag/flag.go @@ -27,13 +27,9 @@ import ( "github.com/spf13/viper" ) -const ( - dotEnvFilename = ".env" -) +const dotEnvFilename = ".env" -var ( - altFlagNames map[string]string -) +var altFlagNames map[string]string func init() { altFlagNames = map[string]string{ diff --git a/internal/m2mauth/m2mauth.go b/internal/m2mauth/m2mauth.go index 70b7be5..439e4f4 100644 --- a/internal/m2mauth/m2mauth.go +++ b/internal/m2mauth/m2mauth.go @@ -42,6 +42,13 @@ import ( "gopkg.in/square/go-jose.v2/jwt" ) +const ( + // DefaultScope The default scope value + DefaultScope = "okta-m2m-access" + // DefaultAuthzID The default authorization server id + DefaultAuthzID = "default" +) + // M2MAuthentication Object structure for headless authentication type M2MAuthentication struct { config *config.Config @@ -51,10 +58,10 @@ type M2MAuthentication struct { func NewM2MAuthentication(config *config.Config) (*M2MAuthentication, error) { // need to set our config defaults if config.CustomScope() == "" { - config.SetCustomScope("okta-m2m-access") + _ = config.SetCustomScope(DefaultScope) } if config.AuthzID() == "" { - config.SetAuthzID("default") + _ = config.SetAuthzID(DefaultAuthzID) } m := M2MAuthentication{ @@ -217,7 +224,7 @@ func (m *M2MAuthentication) accessToken() (*okta.AccessToken, error) { return nil, fmt.Errorf(baseErrStr, resp.Status) } - return nil, fmt.Errorf(baseErrStr+", error: %q, description: %q", resp.Status, apiErr.Error, apiErr.ErrorDescription) + return nil, fmt.Errorf(baseErrStr+okta.AccessTokenErrorFormat, resp.Status, apiErr.Error, apiErr.ErrorDescription) } token := &okta.AccessToken{} diff --git a/internal/okta/accesstoken.go b/internal/okta/accesstoken.go index af26db4..5916ecc 100644 --- a/internal/okta/accesstoken.go +++ b/internal/okta/accesstoken.go @@ -16,6 +16,9 @@ package okta +// AccessTokenErrorFormat string format for access token error +const AccessTokenErrorFormat = ", error: %q, description: %q" + // AccessToken Encapsulates an Okta access token // https://developer.okta.com/docs/reference/api/oidc/#token type AccessToken struct { diff --git a/internal/output/output.go b/internal/output/output.go index 7009b04..0f773fe 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -22,7 +22,6 @@ import ( "time" "github.com/okta/okta-aws-cli/internal/aws" - oaws "github.com/okta/okta-aws-cli/internal/aws" "github.com/okta/okta-aws-cli/internal/config" ) @@ -32,7 +31,7 @@ type Outputter interface { } // RenderAWSCredential Renders the credentials in the prescribed format. -func RenderAWSCredential(cfg *config.Config, ac *oaws.Credential) error { +func RenderAWSCredential(cfg *config.Config, ac *aws.Credential) error { var o Outputter switch cfg.Format() { case config.AWSCredentialsFormat: diff --git a/internal/webssoauth/webssoauth.go b/internal/webssoauth/webssoauth.go index 97c5f5d..d4e530c 100644 --- a/internal/webssoauth/webssoauth.go +++ b/internal/webssoauth/webssoauth.go @@ -592,7 +592,7 @@ func (w *WebSSOAuthentication) fetchSSOWebToken(clientID, awsFedAppID string, at return nil, fmt.Errorf(baseErrStr, resp.Status) } - return nil, fmt.Errorf(baseErrStr+", error: %q, description: %q", resp.Status, apiErr.Error, apiErr.ErrorDescription) + return nil, fmt.Errorf(baseErrStr+okta.AccessTokenErrorFormat, resp.Status, apiErr.Error, apiErr.ErrorDescription) } token = &okta.AccessToken{} From 34dfdc01d1c5dfe6d6eda31a950ea3d31d999dde Mon Sep 17 00:00:00 2001 From: Mike Mondragon Date: Thu, 28 Sep 2023 18:17:42 -0700 Subject: [PATCH 12/42] Tidy up tests --- internal/m2mauth/m2mauth_test.go | 4 ++-- test/fixtures/vcr/TestM2MAuthAccessToken.yaml | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/m2mauth/m2mauth_test.go b/internal/m2mauth/m2mauth_test.go index aea9398..2096b6d 100644 --- a/internal/m2mauth/m2mauth_test.go +++ b/internal/m2mauth/m2mauth_test.go @@ -37,7 +37,7 @@ func TestMain(m *testing.M) { defer reset() reset = testutils.OsSetEnvIfBlank("OKTA_AWSCLI_AUTHZ_ID", "aus8w23r13NvyUwln1d7") defer reset() - reset = testutils.OsSetEnvIfBlank("OKTA_AWSCLI_CUSTOM_SCOPE", "okta-aws-cli") + reset = testutils.OsSetEnvIfBlank("OKTA_AWSCLI_CUSTOM_SCOPE", "okta-m2m-access") defer reset() reset = testutils.OsSetEnvIfBlank("OKTA_AWSCLI_KEY_ID", "kid-rock") defer reset() @@ -87,7 +87,7 @@ func TestM2MAuthAccessToken(t *testing.T) { require.Equal(t, "Bearer", at.TokenType) require.Equal(t, int64(3600), at.ExpiresIn) - require.Equal(t, "okta-aws-cli", at.Scope) + require.Equal(t, "okta-m2m-access", at.Scope) require.Regexp(t, regexp.MustCompile("^eyJ"), at.AccessToken) } diff --git a/test/fixtures/vcr/TestM2MAuthAccessToken.yaml b/test/fixtures/vcr/TestM2MAuthAccessToken.yaml index 99864b5..c7d95c7 100644 --- a/test/fixtures/vcr/TestM2MAuthAccessToken.yaml +++ b/test/fixtures/vcr/TestM2MAuthAccessToken.yaml @@ -19,7 +19,7 @@ interactions: - application/json Content-Type: - application/x-www-form-urlencoded - url: https://test.dne-okta.com/oauth2/aus8w23r13NvyUwln1d7/v1/token?client_assertion=abc123&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer&grant_type=client_credentials&scope=okta-aws-cli + url: https://test.dne-okta.com/oauth2/aus8w23r13NvyUwln1d7/v1/token?client_assertion=abc123&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer&grant_type=client_credentials&scope=okta-m2m-access method: POST response: proto: HTTP/2.0 @@ -29,14 +29,14 @@ interactions: trailer: {} content_length: -1 uncompressed: false - body: '{"token_type":"Bearer","expires_in":3600,"access_token":"eyJraWQiOiJjRnZ0bHRBbzF1cVhMT0ZLOGxFTVA2a3czd25pVVRHMVFSckhvQXBNTkF3IiwiYWxnIjoiUlMyNTYifQ.eyJ2ZXIiOjEsImp0aSI6IkFULjYxZTdVRU1oUGZWUkQ0LU51Wm9TbUsxSDJ2VGpjbXp2Njl4ZFd0VXpBVkkiLCJpc3MiOiJodHRwczovL21tb25kcmFnb24tYXdzLWNsaS0wMC5va3RhcHJldmlldy5jb20vb2F1dGgyL2F1czh3MjNyMTNOdnlVd2xuMWQ3IiwiYXVkIjoiaHR0cHM6Ly9va3RhLWF3cy1jbGktYXV0aG9yaXplciIsImlhdCI6MTY5NTc2NzAzNCwiZXhwIjoxNjk1NzcwNjM0LCJjaWQiOiIwb2FhNGh0ZzcyVE5ya1REcjFkNyIsInNjcCI6WyJva3RhLWF3cy1jbGkiXSwic3ViIjoiMG9hYTRodGc3MlROcmtURHIxZDcifQ.jE6sEw1acXo_pccQUaOXrT4uQ0KI9fLYKHsh23aCsXPBrdfaVYe_yEPdZM7GWg3VYpG9VQVo-I26IKb88Nqnxw11ABMIIglHXlUx0AJHHPZP7PXi8p91y0WG7lDoU2seiX9ce8DXX83R831qLSbQImUOKOz9aNemmvSzwPvDPnjnWNQq_Dmn_MDFiaS4cqMcWB_d_SFVAFVoa-ZC-Rli0kZ63-0ZAtmyv8unHAd1eLCyq3eikeFKXRuSKaAlAgdix2OUHnC9IL_gym9xiZDXDqASmKOqRdIcJ6Q0vn8ujvKwcO_LYPAZkkfkVkDeMEvm_ee43jgcPNF-xdmLJ3YnWg","scope":"okta-aws-cli"}' + body: '{"token_type":"Bearer","expires_in":3600,"access_token":"eyJraWQiOiJjRnZ0bHRBbzF1cVhMT0ZLOGxFTVA2a3czd25pVVRHMVFSckhvQXBNTkF3IiwiYWxnIjoiUlMyNTYifQ.eyJ2ZXIiOjEsImp0aSI6IkFULktJNVd6YzBQYUFZZC14YjB5UXJXX0NHZUhoZnNNdUtrZGxUVkZlbXhQblEiLCJpc3MiOiJodHRwczovL21tb25kcmFnb24tYXdzLWNsaS0wMC5va3RhcHJldmlldy5jb20vb2F1dGgyL2F1czh3MjNyMTNOdnlVd2xuMWQ3IiwiYXVkIjoiaHR0cHM6Ly9va3RhLWF3cy1jbGktYXV0aG9yaXplciIsImlhdCI6MTY5NTk1MDE5MywiZXhwIjoxNjk1OTUzNzkzLCJjaWQiOiIwb2FhNGh0ZzcyVE5ya1REcjFkNyIsInNjcCI6WyJva3RhLW0ybS1hY2Nlc3MiXSwic3ViIjoiMG9hYTRodGc3MlROcmtURHIxZDcifQ.OAs2_oZtpDnZdy4jTcjXA8D9VTFibiJeXZ3YfrntaTr6CRNQdKtWCoJHR4yPQvTEU8IjHW2uZA635ozdubhRfYd0Hv688bQHHyR8xZOC-BfyXA1xmVph1ei4HNwAZQpG-4VL1L99KvuvEh0FjtwZpcZx5_B_0Ag7LoQTVhj2nfPrjLYctIzVYRzIE29dVmDefqgJ5gG6FcK4GthMJBMK2H_mMiJQHDtlBGX7GXb7Hkt9FfA5J0HcMxq9x7yKJM6S_fTePXJwFMOBwQJX4zZa1jstDT0squxgPMh14asko8_dFXe9lC8NVrixbhs6TkiOEKGvma1u0V8yAS0qC5jvbg","scope":"okta-m2m-access"}' headers: Content-Type: - application/json Date: - - Tue, 26 Sep 2023 22:23:54 GMT + - Fri, 29 Sep 2023 01:16:34 GMT Report-To: - '{"group":"csp","max_age":31536000,"endpoints":[{"url":"https://oktacsp.report-uri.com/a/t/g"}],"include_subdomains":true}' status: 200 OK code: 200 - duration: 620.35651ms + duration: 504.687465ms From 3e9459489abc6d4bca946bbc61c44d7a97209ac2 Mon Sep 17 00:00:00 2001 From: Mike Mondragon Date: Thu, 28 Sep 2023 18:20:40 -0700 Subject: [PATCH 13/42] Update release.yml to allow builds off m2m_feature branch --- .github/workflows/release.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4b9d965..0b78992 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,7 +1,9 @@ name: release on: push: - # branches: [ master ] + branches: + - master + - m2m_feature tags: - 'v*' jobs: From feb27424bfd79828a3106bc3765113cc1fa45694 Mon Sep 17 00:00:00 2001 From: Mike Mondragon Date: Fri, 29 Sep 2023 09:46:00 -0700 Subject: [PATCH 14/42] clean up instructions --- CHANGELOG.md | 2 -- README.md | 27 ++++++++++++++------------- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ca7cef..07ef10e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -94,8 +94,6 @@ in the naming convention for `okta-aws-cli` specific names. | `OKTA_OIDC_CLIENT_ID` | `OKTA_AWSCLI_OIDC_CLIENT_ID` | | `OKTA_AWS_ACCOUNT_FEDERATION_APP_ID` | `OKTA_AWSCLI_AWS_ACCOUNT_FEDERATION_APP_ID` | - - ## 1.2.2 (August 30, 2023) * Ensure evaluation of CLI flag for profile is in the same order as the other flags [#124](https://github.com/okta/okta-aws-cli/pull/124) diff --git a/README.md b/README.md index 7a8fa49..caafac4 100644 --- a/README.md +++ b/README.md @@ -204,10 +204,10 @@ around using a custom admin role. # e.g. # export OKTA_AWSCLI_ORG_DOMAIN="test.oka.com" # export OKTA_AWSCLI_OIDC_CLIENT_ID="0oaa4htg72TNrkTDr1d7" -# export OKTA_AWSCLI_IAM_ROLE="arn:aws:iam::1234:role/Circle-CI-ops +# export OKTA_AWSCLI_IAM_ROLE="arn:aws:iam::1234:role/Circle-CI-ops" # export OKTA_AWSCLI_CUSTOM_SCOPE="okta-m2m-access" # export OKTA_AWSCLI_KEY_ID="kid-rock" -# export OKTA_AWSCLI_PRIVATE_KEY="... long string ..." +# export OKTA_AWSCLI_PRIVATE_KEY="... long string with new lines ..." # export OKTA_AWSCLI_AUTHZ_ID="aus8w23r13NvyUwln1d7" $ okta-aws-cli m2m @@ -244,7 +244,7 @@ The `Session Token` has a default expiry of 60 minutes. M2M is an integration of: - [Okta API service app](https://developer.okta.com/docs/guides/implement-oauth-for-okta-serviceapp/main/) -- Okta default or a [custom](https://developer.okta.com/docs/guides/customize-authz-server/main/) authorization server with a custom scope +- Okta default or [custom](https://developer.okta.com/docs/guides/customize-authz-server/main/) authorization server with a custom scope - [Okta access policy](https://developer.okta.com/docs/guides/configure-access-policy/main/) associated with the service app and have rule(s) for the client credentials flow - [AWS IAM OpenID Connect (OIDC) identity provider](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_create_oidc.html) @@ -254,9 +254,9 @@ The Okta API services app requires "Public key / Private key" for "Client authentication". This is set at `Applications > [the API services app] > General Settings > Client Credentials`. In the "Public Keys" section select "Add Key" then either add your own public key in JWKS format, or have Okta generate a new -key pair and copy either JWKS or PEM formatted private key. Where ever the -private key resides it needs to be available to `okta-aws-cli m2m` at runtime; -for instance injected as an environment variable from a secrets manager / vault. +key pair and copy the PEM formatted private key. Where ever the private key +resides it needs to be available to `okta-aws-cli m2m` at runtime; for instance +injected as an environment variable from a secrets manager / vault. #### Okta Authorization Server @@ -280,7 +280,7 @@ A [custom Okta scope](https://support.okta.com/help/s/article/Creating-a-Scope-for-an-Authorization-Server-in-Okta) needs to be set on the authorization server. This is at `Security > API > [the authorization server] > Scopes` and choose "Add Scope". `okta-aws-cli` will -assume the scope is named `okta-aws-cli`, but if it isn't the CLI flag +assume the scope is named `okta-m2m-access`, but if it isn't the CLI flag `--custom-scope` argument trains the CLI for the scope to use. #### Okta Access Policy @@ -305,15 +305,16 @@ rule. From the AWS Console, in the IAM panel, select "Identity providers". Then click "Add provider". In the add provider form select "OpenID Connect". Set the "Provider URL" to the issuer URL from the Okta API Authorization Servers list -for your custom authorization server (example: https://[your -org].okta.com/oauth2/[custom auth server id or default]). Set the "Audience" -value the "Audience" value listed for the authorization server in the Okta +for your custom authorization server (example: `https://[your +org].okta.com/oauth2/[custom auth server id or 'default']`). Set the "Audience" +value from the "Audience" value listed for the authorization server in the Okta panel. After the IdP is created note it's ARN value. Any IAM roles that need to be associated with the IdP need to have a trust relationship established on the -role of the `sts:AssumeRoleWithWebIdentity` action type. Also not the ARNs of -these roles for later use. +role of the `sts:AssumeRoleWithWebIdentity` action type. This setting is on the +trust relationship tab when viewing a specific role in the AWS Console. Also +note the ARNs of these roles for later use. ## Configuration ### Global settings @@ -449,7 +450,7 @@ These settings are optional unless marked otherwise: | Name | Description | Command line flag | ENV var and .env file value | |-----|-----|-----|-----| | Key ID (kid) (**required**) | The ID of the key stored in the service app | `--key-id [value]` | `OKTA_AWSCLI_KEY_ID` | -| Private Key (**required**) | PEM or JWKS format private key whose public key is stored on the service app | `--private-key [value]` | `OKTA_AWSCLI_PRIVATE_KEY` | +| Private Key (**required**) | PEM (pkcs#1 or pkcs#9) private key whose public key is stored on the service app | `--private-key [value]` | `OKTA_AWSCLI_PRIVATE_KEY` | | Authorization Server ID | The ID of the Okta authorization server, set ID for a custom authorization server, will use default otherwise. Default `default` | `--authz-id [value]` | `OKTA_AWSCLI_AUTHZ_ID` | | Custom scope name | The custom scope established in the custom authorization server. Default `okta-m2m-access` | `--custom-scope [value]` | `OKTA_AWSCLI_CUSTOM_SCOPE` | From 88ac9f5c290bfa5dec66938a18e20e3628455d50 Mon Sep 17 00:00:00 2001 From: Mike Mondragon Date: Fri, 29 Sep 2023 11:42:17 -0700 Subject: [PATCH 15/42] Adjust CHANGELOG notes --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07ef10e..eb9f5d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,10 +51,10 @@ $ okta-aws-cli web \ The `web` command will open the system's default web browser when the `--open-browser` flag is present. It is convenient to have the browser open on a separate profile. If the command to open the browser is known for the host -system and alternate open command can be specified. +system an alternate open command can be specified. ``` -# Use macOS open to open browser in Chrome incognito mode on macOS +# Use macOS open to open browser in Chrome incognito mode $ okta-aws-cli web \ --org-domain test.okta.com \ --oidc-client-id 0oa5wyqjk6Wm148fE1d7 \ From 5dbf79fd99cea3fb1c4c9fd6fdd25481737db63d Mon Sep 17 00:00:00 2001 From: Mike Mondragon Date: Fri, 29 Sep 2023 14:42:17 -0700 Subject: [PATCH 16/42] Adjust User-Agent values/calculation --- internal/agent/agent.go | 47 ------------------------------- internal/config/config.go | 8 ++++++ internal/m2mauth/m2mauth.go | 11 ++++---- internal/utils/utils.go | 8 ++++++ internal/webssoauth/webssoauth.go | 20 +++++++------ 5 files changed, 34 insertions(+), 60 deletions(-) delete mode 100644 internal/agent/agent.go diff --git a/internal/agent/agent.go b/internal/agent/agent.go deleted file mode 100644 index ba5e75a..0000000 --- a/internal/agent/agent.go +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright (c) 2022-Present, Okta, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package agent - -import "runtime" - -// UserAgent Helper for a stylized User Agent header in HTTP requests -type UserAgent struct { - goVersion string - osName string - osVersion string - appVersion string -} - -// NewUserAgent Create an new User Agent -func NewUserAgent(version string) UserAgent { - ua := UserAgent{ - goVersion: runtime.Version(), - osName: runtime.GOOS, - osVersion: runtime.GOARCH, - appVersion: version, - } - - return ua -} - -func (ua UserAgent) String() string { - userAgentString := "okta-aws-cli/" + ua.appVersion + " " - userAgentString += "golang/" + ua.goVersion + " " - userAgentString += ua.osName + "/" + ua.osVersion - - return userAgentString -} diff --git a/internal/config/config.go b/internal/config/config.go index f5487c0..7dfeb66 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -22,6 +22,7 @@ import ( "net/url" "os" "path/filepath" + "runtime" "strings" "time" @@ -29,6 +30,13 @@ import ( "gopkg.in/yaml.v2" ) +// UserAgentValue the user agent value +var UserAgentValue string + +func init() { + UserAgentValue = fmt.Sprintf("okta-aws-cli/%s (%s; %s; %s)", Version, runtime.Version(), runtime.GOOS, runtime.GOARCH) +} + const ( // Version app version Version = "2.0.0-beta.0" diff --git a/internal/m2mauth/m2mauth.go b/internal/m2mauth/m2mauth.go index 439e4f4..f0e2d55 100644 --- a/internal/m2mauth/m2mauth.go +++ b/internal/m2mauth/m2mauth.go @@ -199,14 +199,15 @@ func (m *M2MAuthentication) accessToken() (*okta.AccessToken, error) { query.Add("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer") query.Add("client_assertion", clientAssertion) tokenRequestURL += "?" + query.Encode() - tokenRequest, err := http.NewRequest("POST", tokenRequestURL, tokenRequestBuff) + req, err := http.NewRequest("POST", tokenRequestURL, tokenRequestBuff) if err != nil { return nil, err } - - tokenRequest.Header.Add("Accept", utils.ApplicationJSON) - tokenRequest.Header.Add(utils.ContentType, utils.ApplicationXFORM) - resp, err := m.config.HTTPClient().Do(tokenRequest) + req.Header.Add("Accept", utils.ApplicationJSON) + req.Header.Add(utils.ContentType, utils.ApplicationXFORM) + req.Header.Add(utils.UserAgentHeader, config.UserAgentValue) + req.Header.Add(utils.XOktaAWSCLIOperationHeader, utils.XOktaAWSCLIM2MOperation) + resp, err := m.config.HTTPClient().Do(req) if err != nil { return nil, err } diff --git a/internal/utils/utils.go b/internal/utils/utils.go index 65147dd..dab36aa 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -23,4 +23,12 @@ const ( ApplicationJSON = "application/json" // ApplicationXFORM content type value for web form ApplicationXFORM = "application/x-www-form-urlencoded" + // UserAgentHeader user agent header + UserAgentHeader = "User-Agent" + // XOktaAWSCLIOperationHeader the okta aws cli header + XOktaAWSCLIOperationHeader = "X-Okta-Aws-Cli-Operation" + // XOktaAWSCLIWebOperation web op value for the x okta aws cli header + XOktaAWSCLIWebOperation = "web" + // XOktaAWSCLIM2MOperation m2m op value for the x okta aws cli header + XOktaAWSCLIM2MOperation = "m2m" ) diff --git a/internal/webssoauth/webssoauth.go b/internal/webssoauth/webssoauth.go index d4e530c..7675c08 100644 --- a/internal/webssoauth/webssoauth.go +++ b/internal/webssoauth/webssoauth.go @@ -43,7 +43,6 @@ import ( brwsr "github.com/pkg/browser" "golang.org/x/net/html" - "github.com/okta/okta-aws-cli/internal/agent" oaws "github.com/okta/okta-aws-cli/internal/aws" boff "github.com/okta/okta-aws-cli/internal/backoff" "github.com/okta/okta-aws-cli/internal/config" @@ -55,7 +54,6 @@ import ( const ( amazonAWS = "amazon_aws" accept = "Accept" - userAgent = "User-Agent" nameKey = "name" saml2Attribute = "saml2:attribute" samlAttributesRole = "https://aws.amazon.com/SAML/Attributes/Role" @@ -531,7 +529,8 @@ func (w *WebSSOAuthentication) fetchSAMLAssertion(at *okta.AccessToken) (asserti return assertion, err } req.Header.Add(accept, "text/html") - req.Header.Add(userAgent, agent.NewUserAgent(config.Version).String()) + req.Header.Add(utils.UserAgentHeader, config.UserAgentValue) + req.Header.Add(utils.XOktaAWSCLIOperationHeader, utils.XOktaAWSCLIWebOperation) resp, err := w.config.HTTPClient().Do(req) if err != nil { @@ -573,7 +572,8 @@ func (w *WebSSOAuthentication) fetchSSOWebToken(clientID, awsFedAppID string, at } req.Header.Add(accept, utils.ApplicationJSON) req.Header.Add(utils.ContentType, utils.ApplicationXFORM) - req.Header.Add(userAgent, agent.NewUserAgent(config.Version).String()) + req.Header.Add(utils.UserAgentHeader, config.UserAgentValue) + req.Header.Add(utils.XOktaAWSCLIOperationHeader, utils.XOktaAWSCLIWebOperation) resp, err := w.config.HTTPClient().Do(req) if err != nil { @@ -659,7 +659,8 @@ func (w *WebSSOAuthentication) listFedApps(clientID string, at *okta.AccessToken req.Header.Add(accept, utils.ApplicationJSON) req.Header.Add(utils.ContentType, utils.ApplicationJSON) - req.Header.Add(userAgent, agent.NewUserAgent(config.Version).String()) + req.Header.Add(utils.UserAgentHeader, config.UserAgentValue) + req.Header.Add(utils.XOktaAWSCLIOperationHeader, utils.XOktaAWSCLIWebOperation) req.Header.Add("Authorization", fmt.Sprintf("%s %s", at.TokenType, at.AccessToken)) resp, err := w.config.HTTPClient().Do(req) if resp.StatusCode == http.StatusForbidden { @@ -708,7 +709,8 @@ func (w *WebSSOAuthentication) accessToken(deviceAuth *okta.DeviceAuthorization) } req.Header.Add(accept, utils.ApplicationJSON) req.Header.Add(utils.ContentType, utils.ApplicationXFORM) - req.Header.Add(userAgent, agent.NewUserAgent(config.Version).String()) + req.Header.Add(utils.UserAgentHeader, config.UserAgentValue) + req.Header.Add(utils.XOktaAWSCLIOperationHeader, utils.XOktaAWSCLIWebOperation) var bodyBytes []byte @@ -779,7 +781,8 @@ func (w *WebSSOAuthentication) authorize() (*okta.DeviceAuthorization, error) { } req.Header.Add(accept, utils.ApplicationJSON) req.Header.Add(utils.ContentType, utils.ApplicationXFORM) - req.Header.Add(userAgent, agent.NewUserAgent(config.Version).String()) + req.Header.Add(utils.UserAgentHeader, config.UserAgentValue) + req.Header.Add(utils.XOktaAWSCLIOperationHeader, utils.XOktaAWSCLIWebOperation) resp, err := w.config.HTTPClient().Do(req) if err != nil { @@ -877,7 +880,8 @@ func (w *WebSSOAuthentication) isClassicOrg() bool { return false } req.Header.Add(accept, utils.ApplicationJSON) - req.Header.Add(userAgent, agent.NewUserAgent(config.Version).String()) + req.Header.Add(utils.UserAgentHeader, config.UserAgentValue) + req.Header.Add(utils.XOktaAWSCLIOperationHeader, utils.XOktaAWSCLIWebOperation) resp, err := w.config.HTTPClient().Do(req) if err != nil { From 772dd94de0dc5158985febc07ed02a72347f43fe Mon Sep 17 00:00:00 2001 From: Mike Mondragon Date: Mon, 2 Oct 2023 15:09:00 -0700 Subject: [PATCH 17/42] AWS CLI Process credential provider JSON output. Feature request from #110 and #119 being added into the v2 release. --- CHANGELOG.md | 13 +++- README.md | 17 +++++ cmd/root/root.go | 2 +- internal/aws/aws.go | 34 +++++++++- internal/aws/aws_test.go | 35 ++++++++++ internal/config/config.go | 9 ++- internal/m2mauth/m2mauth.go | 2 + internal/output/output.go | 2 + internal/output/process_credentials.go | 50 ++++++++++++++ internal/output/process_credentials_test.go | 45 +++++++++++++ internal/webssoauth/webssoauth.go | 73 +++++++++++++-------- 11 files changed, 247 insertions(+), 35 deletions(-) create mode 100644 internal/aws/aws_test.go create mode 100644 internal/output/process_credentials.go create mode 100644 internal/output/process_credentials_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index eb9f5d6..a647cfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ in the naming convention for `okta-aws-cli` specific names. | `OKTA_OIDC_CLIENT_ID` | `OKTA_AWSCLI_OIDC_CLIENT_ID` | | `OKTA_AWS_ACCOUNT_FEDERATION_APP_ID` | `OKTA_AWSCLI_AWS_ACCOUNT_FEDERATION_APP_ID` | -### (expected) Credentials output as JSON +### (Completed) Process credential provider output as JSON Emits IAM temporary credentials as JSON in [process credentials](https://docs.aws.amazon.com/sdkref/latest/guide/feature-process-credentials.html) @@ -71,6 +71,17 @@ $ okta-aws-cli web \ --open-browser-command "/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --args --profile-directory='Profile 1'" ``` +## 2.0.0-beta.1 (October 2, 2023) + +Support for AWS CLI [process credential provider](https://docs.aws.amazon.com/sdkref/latest/guide/feature-process-credentials.html) + +``` +# $/.aws/config +[default] +# presumes OKTA_AWSCLI_* env vars are set +credential_process = okta-aws-cli m2m --format process-credentials +``` + ## 2.0.0-beta.0 (September 29, 2023) ### New commands diff --git a/README.md b/README.md index caafac4..8cee78e 100644 --- a/README.md +++ b/README.md @@ -679,6 +679,23 @@ aws --profile example s3 ls Unable to parse config file: /home/user/.aws/credentials ``` +### Process credentials provider + +`okta-aws-cli` supports JSON output for the AWS CLI [credential process +argument](https://docs.aws.amazon.com/sdkref/latest/guide/feature-process-credentials.html). +Add this line to the `default` section of `$/.aws/config`. First m2m example +presumes `m2m` arguments are in `OKTA_AWSCLI_*` environment variables, AWS CLI +passes those through. Second web example has args spelled out directly in the +credential process values. + +M2M example: + +`credential_process = okta-aws-cli m2m --format process-credentials` + +Web example: + +`credential_process = okta-aws-cli web --format process-credentials --oidc-client-id abc --org-domain test.okat.com --aws-iam-idp arn:aws:iam::123:saml-provider/my-idp --aws-iam-role arn:aws:iam::294719231913:role/s3 --open-browser` + ### Help ```shell diff --git a/cmd/root/root.go b/cmd/root/root.go index ae68a38..bb60b15 100644 --- a/cmd/root/root.go +++ b/cmd/root/root.go @@ -82,7 +82,7 @@ func init() { Name: config.FormatFlag, Short: "f", Value: "", - Usage: "Output format. [env-var|aws-credentials]", + Usage: "Output format. [env-var|aws-credentials|process-credentials]", EnvVar: config.FormatEnvVar, }, { diff --git a/internal/aws/aws.go b/internal/aws/aws.go index c7cde96..69e950a 100644 --- a/internal/aws/aws.go +++ b/internal/aws/aws.go @@ -16,9 +16,37 @@ package aws +import ( + "encoding/json" + "time" +) + // Credential Convenience representation of an AWS credential. type Credential struct { - AccessKeyID string `ini:"aws_access_key_id"` - SecretAccessKey string `ini:"aws_secret_access_key"` - SessionToken string `ini:"aws_session_token"` + // NOTE JSON process credentials format uses same came cased names as this struct + AccessKeyID string `ini:"aws_access_key_id" json:"AccessKeyId,omitempty"` + SecretAccessKey string `ini:"aws_secret_access_key" json:"SecretAccessKey,omitempty"` + SessionToken string `ini:"aws_session_token" json:"SessionToken,omitempty"` + Version int `ini:"aws_version" json:"Version,omitempty"` + Expiration *time.Time `ini:"aws_expiration" json:"Expiration,omitempty"` +} + +// MarshalJSON ensure Expiration date time is formatted RFC 3339 format. +func (c *Credential) MarshalJSON() ([]byte, error) { + type Alias Credential + var exp string + if c.Expiration != nil { + exp = c.Expiration.Format(time.RFC3339) + } + + obj := &struct { + *Alias + Expiration string `json:"Expiration"` + }{ + Alias: (*Alias)(c), + } + if exp != "" { + obj.Expiration = exp + } + return json.Marshal(obj) } diff --git a/internal/aws/aws_test.go b/internal/aws/aws_test.go new file mode 100644 index 0000000..eb68a8f --- /dev/null +++ b/internal/aws/aws_test.go @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2023-Present, Okta, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package aws + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestCredentialJSON(t *testing.T) { + hbtGo := time.Date(2009, time.November, 10, 23, 0, 0, 0, time.UTC) + c := Credential{ + Expiration: &hbtGo, + } + credStr, err := json.Marshal(c) + require.NoError(t, err) + require.Equal(t, `{"Expiration":"2009-11-10T23:00:00Z"}`, string(credStr)) +} diff --git a/internal/config/config.go b/internal/config/config.go index 7dfeb66..00cfc23 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -39,12 +39,14 @@ func init() { const ( // Version app version - Version = "2.0.0-beta.0" + Version = "2.0.0-beta.1" // AWSCredentialsFormat format const AWSCredentialsFormat = "aws-credentials" // EnvVarFormat format const EnvVarFormat = "env-var" + // ProcessCredentialsFormat format const + ProcessCredentialsFormat = "process-credentials" // AuthzIDFlag cli flag const AuthzIDFlag = "authz-id" @@ -814,6 +816,11 @@ awscli: return nil } +// IsProcessCredentialsFormat is our format process credentials? +func (c *Config) IsProcessCredentialsFormat() bool { + return c.format == ProcessCredentialsFormat +} + type realClock struct{} func (realClock) Now() time.Time { return time.Now() } diff --git a/internal/m2mauth/m2mauth.go b/internal/m2mauth/m2mauth.go index f0e2d55..f269bbd 100644 --- a/internal/m2mauth/m2mauth.go +++ b/internal/m2mauth/m2mauth.go @@ -119,7 +119,9 @@ func (m *M2MAuthentication) awsAssumeRoleWithWebIdentity(at *okta.AccessToken) ( AccessKeyID: *svcResp.Credentials.AccessKeyId, SecretAccessKey: *svcResp.Credentials.SecretAccessKey, SessionToken: *svcResp.Credentials.SessionToken, + Expiration: svcResp.Credentials.Expiration, } + return credential, nil } diff --git a/internal/output/output.go b/internal/output/output.go index 0f773fe..ec8b561 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -37,6 +37,8 @@ func RenderAWSCredential(cfg *config.Config, ac *aws.Credential) error { case config.AWSCredentialsFormat: expiry := time.Now().Add(time.Duration(cfg.AWSSessionDuration()) * time.Second).Format(time.RFC3339) o = NewAWSCredentialsFile(cfg.LegacyAWSVariables(), cfg.ExpiryAWSVariables(), expiry) + case config.ProcessCredentialsFormat: + o = NewProcessCredentials() default: o = NewEnvVar(cfg.LegacyAWSVariables()) fmt.Fprintf(os.Stderr, "\n") diff --git a/internal/output/process_credentials.go b/internal/output/process_credentials.go new file mode 100644 index 0000000..a9f1053 --- /dev/null +++ b/internal/output/process_credentials.go @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2023-Present, Okta, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +package output + +import ( + "encoding/json" + "fmt" + + "github.com/okta/okta-aws-cli/internal/aws" + "github.com/okta/okta-aws-cli/internal/config" +) + +// ProcessCredentials AWS CLI Process Credentials output formatter +// https://docs.aws.amazon.com/sdkref/latest/guide/feature-process-credentials.html +type ProcessCredentials struct{} + +// NewProcessCredentials Creates a new ProcessCredentials +func NewProcessCredentials() *ProcessCredentials { + return &ProcessCredentials{} +} + +// Output Satisfies the Outputter interface and outputs AWS credentials as JSON +// to STDOUT +func (p *ProcessCredentials) Output(c *config.Config, ac *aws.Credential) error { + // See AWS docs: "Note As of this writing, the Version key must be set to 1. + // This might increment over time as the structure evolves." + ac.Version = 1 + + credJSON, err := json.MarshalIndent(ac, "", " ") + if err != nil { + return err + } + + fmt.Printf("%s", credJSON) + return nil +} diff --git a/internal/output/process_credentials_test.go b/internal/output/process_credentials_test.go new file mode 100644 index 0000000..af4a943 --- /dev/null +++ b/internal/output/process_credentials_test.go @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2023-Present, Okta, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package output + +import ( + "encoding/json" + "testing" + "time" + + "github.com/okta/okta-aws-cli/internal/aws" + "github.com/stretchr/testify/require" +) + +func TestProcessCredentials(t *testing.T) { + credsJSON := ` +{ + "Version": 1, + "AccessKeyId": "an AWS access key", + "SecretAccessKey": "your AWS secret access key", + "SessionToken": "the AWS session token for temporary credentials", + "Expiration": "2009-11-10T23:00:00Z" +}` + result := aws.Credential{} + err := json.Unmarshal([]byte(credsJSON), &result) + require.NoError(t, err) + require.Equal(t, "an AWS access key", result.AccessKeyID) + require.Equal(t, "your AWS secret access key", result.SecretAccessKey) + require.Equal(t, "the AWS session token for temporary credentials", result.SessionToken) + when := time.Time(time.Date(2009, time.November, 10, 23, 0, 0, 0, time.UTC)) + require.Equal(t, &when, result.Expiration) +} diff --git a/internal/webssoauth/webssoauth.go b/internal/webssoauth/webssoauth.go index 7675c08..931cc6c 100644 --- a/internal/webssoauth/webssoauth.go +++ b/internal/webssoauth/webssoauth.go @@ -112,15 +112,17 @@ var stderrIsOutAskOpt = func(options *survey.AskOptions) error { } // NewWebSSOAuthentication New Web SSO Authentication constructor -func NewWebSSOAuthentication(config *config.Config) (token *WebSSOAuthentication, err error) { - if err != nil { - return nil, err - } +func NewWebSSOAuthentication(cfg *config.Config) (token *WebSSOAuthentication, err error) { token = &WebSSOAuthentication{ - config: config, + config: cfg, } if token.isClassicOrg() { - return nil, fmt.Errorf("%q is a Classic org, okta-aws-cli is an-OIE only tool", config.OrgDomain()) + return nil, fmt.Errorf("%q is a Classic org, okta-aws-cli is an-OIE only tool", cfg.OrgDomain()) + } + if cfg.IsProcessCredentialsFormat() { + if cfg.AWSIAMIdP() == "" || cfg.AWSIAMRole() == "" || !cfg.OpenBrowser() { + return nil, fmt.Errorf("arguments --%s , --%s , and --%s must be set for %q format", config.AWSIAMIdPFlag, config.AWSIAMRoleFlag, config.OpenBrowserFlag, cfg.Format()) + } } return token, nil } @@ -213,15 +215,15 @@ func (w *WebSSOAuthentication) choiceFriendlyLabelIDP(alternative string, oktaCo if label, ok := oktaConfig.AWSCLI.IDPS[arn]; ok { if w.config.Debug() { - fmt.Fprintf(os.Stderr, " found IdP ARN %q having friendly label %q\n", arn, label) + w.consolePrint(" found IdP ARN %q having friendly label %q\n", arn, label) } return label } else if w.config.Debug() { - fmt.Fprintf(os.Stderr, " did not find friendly label for IdP ARN\n") - fmt.Fprintf(os.Stderr, arnPrintFmt, arn) - fmt.Fprintf(os.Stderr, " in okta.yaml awscli.idps map:\n") + w.consolePrint(" did not find friendly label for IdP ARN\n") + w.consolePrint(arnPrintFmt, arn) + w.consolePrint(" in okta.yaml awscli.idps map:\n") for arn, label := range oktaConfig.AWSCLI.IDPS { - fmt.Fprintf(os.Stderr, arnLabelPrintFmt, arn, label) + w.consolePrint(arnLabelPrintFmt, arn, label) } } return alternative @@ -238,14 +240,16 @@ func (w *WebSSOAuthentication) selectFedApp(apps []*okta.Application) (string, e // when OKTA_AWSCLI_IAM_IDP / --aws-iam-idp is set if w.config.AWSIAMIdP() == app.Settings.App.IdentityProviderARN { - idpData := idpTemplateData{ - IDP: choiceLabel, + if !w.config.IsProcessCredentialsFormat() { + idpData := idpTemplateData{ + IDP: choiceLabel, + } + rich, _, err := core.RunTemplate(idpSelectedTemplate, idpData) + if err != nil { + return "", err + } + fmt.Fprintln(os.Stderr, rich) } - rich, _, err := core.RunTemplate(idpSelectedTemplate, idpData) - if err != nil { - return "", err - } - fmt.Fprintln(os.Stderr, rich) return app.ID, nil } @@ -327,6 +331,7 @@ func (w *WebSSOAuthentication) awsAssumeRoleWithSAML(iar *idpAndRole, assertion AccessKeyID: *svcResp.Credentials.AccessKeyId, SecretAccessKey: *svcResp.Credentials.SecretAccessKey, SessionToken: *svcResp.Credentials.SessionToken, + Expiration: svcResp.Credentials.Expiration, } return credential, nil } @@ -341,15 +346,15 @@ func (w *WebSSOAuthentication) choiceFriendlyLabelRole(arn string, oktaConfig *c if label, ok := oktaConfig.AWSCLI.ROLES[arn]; ok { if w.config.Debug() { - fmt.Fprintf(os.Stderr, " found Role ARN %q having friendly label %q\n", arn, label) + w.consolePrint(" found Role ARN %q having friendly label %q\n", arn, label) } return label } else if w.config.Debug() { - fmt.Fprintf(os.Stderr, " did not find friendly label for Role ARN\n") - fmt.Fprintf(os.Stderr, arnPrintFmt, arn) - fmt.Fprintf(os.Stderr, " in okta.yaml awscli.roles map:\n") + w.consolePrint(" did not find friendly label for Role ARN\n") + w.consolePrint(arnPrintFmt, arn) + w.consolePrint(" in okta.yaml awscli.roles map:\n") for arn, label := range oktaConfig.AWSCLI.ROLES { - fmt.Fprintf(os.Stderr, arnLabelPrintFmt, arn, label) + w.consolePrint(arnLabelPrintFmt, arn, label) } } return arn @@ -368,11 +373,13 @@ func (w *WebSSOAuthentication) promptForRole(idp string, roleARNs []string) (rol roleData := roleTemplateData{ Role: roleLabel, } - rich, _, err := core.RunTemplate(roleSelectedTemplate, roleData) - if err != nil { - return "", err + if !w.config.IsProcessCredentialsFormat() { + rich, _, err := core.RunTemplate(roleSelectedTemplate, roleData) + if err != nil { + return "", err + } + fmt.Fprintln(os.Stderr, rich) } - fmt.Fprintln(os.Stderr, rich) return roleARN, nil } @@ -628,12 +635,12 @@ func (w *WebSSOAuthentication) promptAuthentication(da *okta.DeviceAuthorization openMsg = "System web browser will open" } - fmt.Fprintf(os.Stderr, prompt, openMsg, qrCode, da.VerificationURIComplete) + w.consolePrint(prompt, openMsg, qrCode, da.VerificationURIComplete) if w.config.OpenBrowser() { brwsr.Stdout = os.Stderr if err := brwsr.OpenURL(da.VerificationURIComplete); err != nil { - fmt.Fprintf(os.Stderr, "Failed to open activation URL with system browser: %v\n", err) + w.consolePrint("Failed to open activation URL with system browser: %v\n", err) } } } @@ -965,3 +972,11 @@ func (w *WebSSOAuthentication) cacheAccessToken(at *okta.AccessToken) { configPath := filepath.Join(cUser.HomeDir, dotOktaDir, tokenFileName) _ = os.WriteFile(configPath, atJSON, 0o600) } + +func (w *WebSSOAuthentication) consolePrint(format string, a ...any) { + if w.config.IsProcessCredentialsFormat() { + return + } + + fmt.Fprintf(os.Stderr, format, a...) +} From ff7650f90ab3c92f24d889624a057ee51bb88bdb Mon Sep 17 00:00:00 2001 From: Mike Mondragon Date: Mon, 2 Oct 2023 15:23:24 -0700 Subject: [PATCH 18/42] Stop flogging those requesting support for non-standard options that required backporting from other CLIs ... Closes #142 --- README.md | 4 ++-- internal/aws/aws.go | 1 - internal/output/aws_credentials_file.go | 6 ------ 3 files changed, 2 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 8cee78e..489a314 100644 --- a/README.md +++ b/README.md @@ -367,8 +367,8 @@ These global settings are optional unless marked otherwise: | Cache Okta access token at `$HOME/.okta/awscli-access-token.json` to reduce need to open device authorization URL | `true` if flag is present | `--cache-access-token` | `OKTA_AWSCLI_CACHE_ACCESS_TOKEN=true` | | Alternate AWS credentials file path | Path to alternative credentials file other than AWS CLI default | `--aws-credentials` | `OKTA_AWSCLI_AWS_CREDENTIALS` | | (Over)write the given profile to the AWS credentials file. WARNING: When enabled, overwriting can inadvertently remove dangling comments and extraneous formatting from the creds file. | `true` if flag is present | `--write-aws-credentials` | `OKTA_AWSCLI_WRITE_AWS_CREDENTIALS=true` | -| Emit deprecated AWS variable `aws_security_token` with duplicated value from `aws_session_token` | `true` if flag is present | `--legacy-aws-variables` | `OKTA_AWSCLI_LEGACY_AWS_VARIABLES=true` | -| Emit expiry timestamp `x_security_token_expires` in RFC3339 format for the session/security token (AWS credentials file only) | `true` if flag is present | `--expiry-aws-variables` | `OKTA_AWSCLI_EXPIRY_AWS_VARIABLES=true` | +| Emit deprecated AWS variable `aws_security_token` with duplicated value from `aws_session_token`. AWS CLI removed any reference and documentation for `aws_security_token` in November 2014. | `true` if flag is present | `--legacy-aws-variables` | `OKTA_AWSCLI_LEGACY_AWS_VARIABLES=true` | +| Emit expiry timestamp `x_security_token_expires` in RFC3339 format for the session/security token (AWS credentials file only). This is a non-standard profile variable. | `true` if flag is present | `--expiry-aws-variables` | `OKTA_AWSCLI_EXPIRY_AWS_VARIABLES=true` | | Print operational information to the screen for debugging purposes | `true` if flag is present | `--debug` | `OKTA_AWSCLI_DEBUG=true` | | Verbosely print all API calls/responses to the screen | `true` if flag is present | `--debug-api-calls` | `OKTA_AWSCLI_DEBUG_API_CALLS=true` | | HTTP/HTTPS Proxy support | HTTP/HTTPS URL of proxy service (based on golang [net/http/httpproxy](https://pkg.go.dev/golang.org/x/net/http/httpproxy) package) | n/a | `HTTP_PROXY` or `HTTPS_PROXY` | diff --git a/internal/aws/aws.go b/internal/aws/aws.go index 69e950a..c1d0af2 100644 --- a/internal/aws/aws.go +++ b/internal/aws/aws.go @@ -23,7 +23,6 @@ import ( // Credential Convenience representation of an AWS credential. type Credential struct { - // NOTE JSON process credentials format uses same came cased names as this struct AccessKeyID string `ini:"aws_access_key_id" json:"AccessKeyId,omitempty"` SecretAccessKey string `ini:"aws_secret_access_key" json:"SecretAccessKey,omitempty"` SessionToken string `ini:"aws_session_token" json:"SessionToken,omitempty"` diff --git a/internal/output/aws_credentials_file.go b/internal/output/aws_credentials_file.go index f01d583..b8647e4 100644 --- a/internal/output/aws_credentials_file.go +++ b/internal/output/aws_credentials_file.go @@ -156,12 +156,6 @@ func updateINI(config *ini.File, profile string, legacyVars bool, expiryVars boo if len(comments) > 0 { fmt.Fprintf(os.Stderr, "WARNING: Commented out %q profile keys \"%s\". Uncomment if third party tools use these values.\n", profile, strings.Join(comments, "\", \"")) } - if legacyVars { - fmt.Fprintf(os.Stderr, "WARNING: %q includes legacy variable \"aws_security_token\". Update tools making use of this deprecated value.\n", profile) - } - if expiryVars { - fmt.Fprintf(os.Stderr, "WARNING: %q includes 3rd party variable \"x_security_token_expires\".\n", profile) - } return config, nil } From 9722e542b00e207cc0326a8d57c42e8b0e59d481 Mon Sep 17 00:00:00 2001 From: Mike Mondragon Date: Wed, 4 Oct 2023 22:45:01 -0700 Subject: [PATCH 19/42] Add 'exec' subcommand to avoid writing credentials to disk or injecting into the shell Closes #135 --- CHANGELOG.md | 14 +++- README.md | 42 ++++++++++- cmd/root/root.go | 7 ++ internal/config/config.go | 24 ++++++ internal/exec/exec.go | 97 +++++++++++++++++++++++++ internal/m2mauth/m2mauth.go | 27 +++++-- internal/output/aws_credentials_file.go | 24 +++--- internal/output/envvar.go | 20 ++--- internal/output/noop.go | 36 +++++++++ internal/output/output.go | 10 ++- internal/output/process_credentials.go | 8 +- internal/webssoauth/webssoauth.go | 30 ++++++-- 12 files changed, 293 insertions(+), 46 deletions(-) create mode 100644 internal/exec/exec.go create mode 100644 internal/output/noop.go diff --git a/CHANGELOG.md b/CHANGELOG.md index a647cfd..de95935 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,14 +32,14 @@ Emits IAM temporary credentials as JSON in [process credentials](https://docs.aws.amazon.com/sdkref/latest/guide/feature-process-credentials.html) format. -### (expected) Secondary command exec +### (Complete) Execute follow-on command Instead of scripting and/or eval'ing `okta-aws-cli` into a shell and then running another command have `okta-aws-cli` run the command directly passing along the IAM credentials as environment variables. ``` -# CLI exec's anything after the double dash "--" as another command. +# CLI exec's anything after the double dash "--" arguments terminator as another command. $ okta-aws-cli web \ --org-domain test.okta.com \ --oidc-client-id 0oa5wyqjk6Wm148fE1d7 \ @@ -71,6 +71,16 @@ $ okta-aws-cli web \ --open-browser-command "/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --args --profile-directory='Profile 1'" ``` +## 2.0.0-beta.2 (October 5, 2023) + +Execute a subcommand directly from `okta-aws-cli` + +``` +$ okta-aws-cli m2m --format noop --exec -- aws s3 ls s3://example + PRE aaa/ +2023-03-08 16:01:01 4 a.log +``` + ## 2.0.0-beta.1 (October 2, 2023) Support for AWS CLI [process credential provider](https://docs.aws.amazon.com/sdkref/latest/guide/feature-process-credentials.html) diff --git a/README.md b/README.md index 489a314..e9a8494 100644 --- a/README.md +++ b/README.md @@ -362,7 +362,7 @@ These global settings are optional unless marked otherwise: | OIDC Client ID (**required**) | For `web` the OIDC native application / [Allowed Web SSO Client ID](#allowed-web-sso-client-id), for `m2m` the API services app ID | `--oidc-client-id [value]` | `OKTA_AWSCLI_OIDC_CLIENT_ID` | | AWS IAM Role ARN (**optional** for `web`, **required** for `m2m`) | For web preselects the role list to this preferred IAM role for the given IAM Identity Provider. For `m2m` | `--aws-iam-role [value]` | `OKTA_AWSCLI_IAM_ROLE` | | AWS Session Duration | The lifetime, in seconds, of the AWS credentials. Must be between 60 and 43200. | `--session-duration [value]` | `OKTA_AWSCLI_SESSION_DURATION` | -| Output format | Default is `env-var`. Options: `env-var` for output to environment variables, `aws-credentials` for output to AWS credentials file, `process-credentials` for credentials as JSON | `--format [value]` | `OKTA_AWSCLI_FORMAT` | +| Output format | Default is `env-var`. Options: `env-var` for output to environment variables, `aws-credentials` for output to AWS credentials file, `process-credentials` for credentials as JSON, or `noop` for no output which can be useful with `--exec` | `--format [value]` | `OKTA_AWSCLI_FORMAT` | | Profile | Default is `default` | `--profile [value]` | `OKTA_AWSCLI_PROFILE` | | Cache Okta access token at `$HOME/.okta/awscli-access-token.json` to reduce need to open device authorization URL | `true` if flag is present | `--cache-access-token` | `OKTA_AWSCLI_CACHE_ACCESS_TOKEN=true` | | Alternate AWS credentials file path | Path to alternative credentials file other than AWS CLI default | `--aws-credentials` | `OKTA_AWSCLI_AWS_CREDENTIALS` | @@ -372,6 +372,7 @@ These global settings are optional unless marked otherwise: | Print operational information to the screen for debugging purposes | `true` if flag is present | `--debug` | `OKTA_AWSCLI_DEBUG=true` | | Verbosely print all API calls/responses to the screen | `true` if flag is present | `--debug-api-calls` | `OKTA_AWSCLI_DEBUG_API_CALLS=true` | | HTTP/HTTPS Proxy support | HTTP/HTTPS URL of proxy service (based on golang [net/http/httpproxy](https://pkg.go.dev/golang.org/x/net/http/httpproxy) package) | n/a | `HTTP_PROXY` or `HTTPS_PROXY` | +| Execute arguments after CLI arg terminator `--` as a separate process. Process will be executed with AWS cred values as AWS env vars `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`. | `true` if flag is present | `--exec` | `OKTA_AWSCLI_EXEC=true` | ### Web command settings @@ -696,6 +697,45 @@ Web example: `credential_process = okta-aws-cli web --format process-credentials --oidc-client-id abc --org-domain test.okat.com --aws-iam-idp arn:aws:iam::123:saml-provider/my-idp --aws-iam-role arn:aws:iam::294719231913:role/s3 --open-browser` +### Execute follow-on process + +`okta-aws-cli` can execute a process after it has collected credentials. It will +do so with any existing env vars prefaced by `AWS_` such as `AWS_REGION` and +also append the env vars for the new AWS credentials `AWS_ACCESS_KEY_ID`, +`AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`. Use `noop` format so `aws-aws-cli` +doesn't emit credentials to stdeout itself but passes them to the process it +executes. The output from the process will be printed to the screen properly to +STDOUT, and also STDERR if the process also writes to STDERR. + +Example 1 + +``` +$ okta-aws-cli m2m --format noop --exec -- printenv +AWS_REGION=us-east-1 +AWS_ACCESS_KEY_ID=ASIAUJHVCS6UYRTRTSQE +AWS_SECRET_ACCESS_KEY=TmvLOM/doSWfmIMK... +AWS_SESSION_TOKEN=FwoGZXIvYXdzEF8aDKrf... +``` + +Example 2 + +``` +$ okta-aws-cli m2m --format noop --exec -- aws s3 ls s3://example + PRE aaa/ +2023-03-08 16:01:01 4 a.log +``` + +Example 3 (process had error and also writes to STDERR) + +``` +$ okta-aws-cli m2m --format noop --exec -- aws s3 mb s3://no-access-example +error running process +aws s3 mb s3://yz-nomad-og +make_bucket failed: s3://no-access-example An error occurred (AccessDenied) when calling the CreateBucket operation: Access Denied + +Error: exit status 1 +``` + ### Help ```shell diff --git a/cmd/root/root.go b/cmd/root/root.go index bb60b15..57f9f6d 100644 --- a/cmd/root/root.go +++ b/cmd/root/root.go @@ -134,6 +134,13 @@ func init() { Usage: "Verbosely print all API calls/responses to the screen", EnvVar: config.DebugAPICallsEnvVar, }, + { + Name: config.ExecFlag, + Short: "j", + Value: false, + Usage: "Execute any shell commands after the '--' CLI arguments termination", + EnvVar: config.ExecEnvVar, + }, } rootCmd = NewRootCommand() diff --git a/internal/config/config.go b/internal/config/config.go index 00cfc23..913b533 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -47,6 +47,8 @@ const ( EnvVarFormat = "env-var" // ProcessCredentialsFormat format const ProcessCredentialsFormat = "process-credentials" + // NoopFormat format const + NoopFormat = "noop" // AuthzIDFlag cli flag const AuthzIDFlag = "authz-id" @@ -64,6 +66,8 @@ const ( DebugFlag = "debug" // DebugAPICallsFlag cli flag const DebugAPICallsFlag = "debug-api-calls" + // ExecFlag cli flag const + ExecFlag = "exec" // FormatFlag cli flag const FormatFlag = "format" // OIDCClientIDFlag cli flag const @@ -111,6 +115,8 @@ const ( DebugAPICallsEnvVar = "OKTA_AWSCLI_DEBUG_API_CALLS" // ExpiryAWSVariablesEnvVar env var const ExpiryAWSVariablesEnvVar = "OKTA_AWSCLI_EXPIRY_AWS_VARIABLES" + // ExecEnvVar env var const + ExecEnvVar = "OKTA_AWSCLI_EXEC" // FormatEnvVar env var const FormatEnvVar = "OKTA_AWSCLI_FORMAT" // LegacyAWSVariablesEnvVar env var const @@ -174,6 +180,7 @@ type Config struct { customScope string debug bool debugAPICalls bool + exec bool expiryAWSVariables bool fedAppID string format string @@ -201,6 +208,7 @@ type Attributes struct { CustomScope string Debug bool DebugAPICalls bool + Exec bool ExpiryAWSVariables bool FedAppID string Format string @@ -240,6 +248,7 @@ func NewConfig(attrs *Attributes) (*Config, error) { debug: attrs.Debug, debugAPICalls: attrs.DebugAPICalls, expiryAWSVariables: attrs.ExpiryAWSVariables, + exec: attrs.Exec, fedAppID: attrs.FedAppID, format: attrs.Format, legacyAWSVariables: attrs.LegacyAWSVariables, @@ -284,6 +293,7 @@ func readConfig() (Attributes, error) { CustomScope: viper.GetString(CustomScopeFlag), Debug: viper.GetBool(DebugFlag), DebugAPICalls: viper.GetBool(DebugAPICallsFlag), + Exec: viper.GetBool(ExecFlag), FedAppID: viper.GetString(AWSAcctFedAppIDFlag), Format: viper.GetString(FormatFlag), LegacyAWSVariables: viper.GetBool(LegacyAWSVariablesFlag), @@ -407,6 +417,9 @@ func readConfig() (Attributes, error) { if !attrs.CacheAccessToken { attrs.CacheAccessToken = viper.GetBool(downCase(CacheAccessTokenEnvVar)) } + if !attrs.Exec { + attrs.Exec = viper.GetBool(downCase(ExecEnvVar)) + } return attrs, nil } @@ -535,6 +548,17 @@ func (c *Config) SetDebugAPICalls(debugAPICalls bool) error { return nil } +// Exec -- +func (c *Config) Exec() bool { + return c.exec +} + +// SetExec -- +func (c *Config) SetExec(exec bool) error { + c.exec = exec + return nil +} + // ExpiryAWSVariables -- func (c *Config) ExpiryAWSVariables() bool { return c.expiryAWSVariables diff --git a/internal/exec/exec.go b/internal/exec/exec.go new file mode 100644 index 0000000..b8759a2 --- /dev/null +++ b/internal/exec/exec.go @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2023-Present, Okta, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package exec + +import ( + "fmt" + "os" + osexec "os/exec" + "strings" + + oaws "github.com/okta/okta-aws-cli/internal/aws" +) + +// Exec is a executor / a process runner +type Exec struct { + name string + args []string +} + +// NewExec Create a new executor +func NewExec() (*Exec, error) { + args := []string{} + foundArgs := false + for _, arg := range os.Args { + if arg == "--" { + foundArgs = true + continue + } + if !foundArgs { + continue + } + + args = append(args, arg) + } + + if len(args) < 1 { + return nil, fmt.Errorf("there must be at least one additional argument after the '--' CLI argument terminator") + } + + name := args[0] + args = args[1:] + ex := &Exec{ + name: name, + args: args, + } + + return ex, nil +} + +// Run Run the executor +func (e *Exec) Run(oc *oaws.Credential) error { + pairs := map[string]string{} + // pre-populate pairs with any existing env var starting with "AWS_" + for _, kv := range os.Environ() { + pair := strings.SplitN(kv, "=", 2) + k := pair[0] + if strings.HasPrefix(k, "AWS_") { + pairs[k] = pair[1] + } + } + // add creds env var names to pairs + pairs["AWS_ACCESS_KEY_ID"] = oc.AccessKeyID + pairs["AWS_SECRET_ACCESS_KEY"] = oc.SecretAccessKey + pairs["AWS_SESSION_TOKEN"] = oc.SessionToken + + cmd := osexec.Command(e.name, e.args...) + for k, v := range pairs { + cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", k, v)) + } + + out, err := cmd.Output() + if ee, ok := err.(*osexec.ExitError); ok { + fmt.Fprintf(os.Stderr, "error running process\n") + fmt.Fprintf(os.Stderr, "%s %s\n", e.name, strings.Join(e.args, " ")) + fmt.Fprintf(os.Stderr, "%s\n", ee.Stderr) + } + if err != nil { + return err + } + + fmt.Printf("%s", string(out)) + return nil +} diff --git a/internal/m2mauth/m2mauth.go b/internal/m2mauth/m2mauth.go index f269bbd..f3079ca 100644 --- a/internal/m2mauth/m2mauth.go +++ b/internal/m2mauth/m2mauth.go @@ -35,6 +35,7 @@ import ( "github.com/aws/aws-sdk-go/service/sts" oaws "github.com/okta/okta-aws-cli/internal/aws" "github.com/okta/okta-aws-cli/internal/config" + "github.com/okta/okta-aws-cli/internal/exec" "github.com/okta/okta-aws-cli/internal/okta" "github.com/okta/okta-aws-cli/internal/output" "github.com/okta/okta-aws-cli/internal/utils" @@ -55,17 +56,24 @@ type M2MAuthentication struct { } // NewM2MAuthentication New M2M Authentication constructor -func NewM2MAuthentication(config *config.Config) (*M2MAuthentication, error) { +func NewM2MAuthentication(cfg *config.Config) (*M2MAuthentication, error) { // need to set our config defaults - if config.CustomScope() == "" { - _ = config.SetCustomScope(DefaultScope) + if cfg.CustomScope() == "" { + _ = cfg.SetCustomScope(DefaultScope) } - if config.AuthzID() == "" { - _ = config.SetAuthzID(DefaultAuthzID) + if cfg.AuthzID() == "" { + _ = cfg.SetAuthzID(DefaultAuthzID) + } + + // Check if exec arg is present and that there are args for it before doing any work + if cfg.Exec() { + if _, err := exec.NewExec(); err != nil { + return nil, err + } } m := M2MAuthentication{ - config: config, + config: cfg, } return &m, nil } @@ -93,6 +101,13 @@ func (m *M2MAuthentication) EstablishIAMCredentials() error { return err } + if m.config.Exec() { + exe, _ := exec.NewExec() + if err := exe.Run(cred); err != nil { + return err + } + } + return nil } diff --git a/internal/output/aws_credentials_file.go b/internal/output/aws_credentials_file.go index b8647e4..c54ecaa 100644 --- a/internal/output/aws_credentials_file.go +++ b/internal/output/aws_credentials_file.go @@ -27,7 +27,7 @@ import ( "github.com/pkg/errors" "gopkg.in/ini.v1" - "github.com/okta/okta-aws-cli/internal/aws" + oaws "github.com/okta/okta-aws-cli/internal/aws" "github.com/okta/okta-aws-cli/internal/config" ) @@ -64,7 +64,7 @@ func ensureConfigExists(filename string, profile string) error { return nil } -func saveProfile(filename, profile string, awsCreds *aws.Credential, legacyVars, expiryVars bool, expiry string) error { +func saveProfile(filename, profile string, awsCreds *oaws.Credential, legacyVars, expiryVars bool, expiry string) error { config, err := updateConfig(filename, profile, awsCreds, legacyVars, expiryVars, expiry) if err != nil { return err @@ -79,7 +79,7 @@ func saveProfile(filename, profile string, awsCreds *aws.Credential, legacyVars, return nil } -func updateConfig(filename, profile string, awsCreds *aws.Credential, legacyVars, expiryVars bool, expiry string) (config *ini.File, err error) { +func updateConfig(filename, profile string, awsCreds *oaws.Credential, legacyVars, expiryVars bool, expiry string) (config *ini.File, err error) { config, err = ini.Load(filename) if err != nil { return @@ -90,7 +90,7 @@ func updateConfig(filename, profile string, awsCreds *aws.Credential, legacyVars return } - builder := dynamicstruct.ExtendStruct(aws.Credential{}) + builder := dynamicstruct.ExtendStruct(oaws.Credential{}) if expiryVars { builder.AddField(ExpirationField, "", `ini:"x_security_token_expires"`) @@ -178,15 +178,15 @@ func NewAWSCredentialsFile(legacyVars bool, expiryVars bool, expiry string) *AWS // Output Satisfies the Outputter interface and appends AWS credentials to // credentials file. -func (e *AWSCredentialsFile) Output(c *config.Config, ac *aws.Credential) error { +func (e *AWSCredentialsFile) Output(c *config.Config, oc *oaws.Credential) error { if c.WriteAWSCredentials() { - return e.writeConfig(c, ac) + return e.writeConfig(c, oc) } - return e.appendConfig(c, ac) + return e.appendConfig(c, oc) } -func (e *AWSCredentialsFile) appendConfig(c *config.Config, ac *aws.Credential) error { +func (e *AWSCredentialsFile) appendConfig(c *config.Config, oc *oaws.Credential) error { f, err := os.OpenFile(c.AWSCredentials(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) if err != nil { return err @@ -201,11 +201,11 @@ aws_access_key_id = %s aws_secret_access_key = %s aws_session_token = %s ` - credArgs := []interface{}{c.Profile(), ac.AccessKeyID, ac.SecretAccessKey, ac.SessionToken} + credArgs := []interface{}{c.Profile(), oc.AccessKeyID, oc.SecretAccessKey, oc.SessionToken} if e.LegacyAWSVariables { creds = fmt.Sprintf("%saws_security_token = %%s\n", creds) - credArgs = append(credArgs, ac.SessionToken) + credArgs = append(credArgs, oc.SessionToken) } if e.ExpiryAWSVariables { @@ -226,7 +226,7 @@ aws_session_token = %s return nil } -func (e *AWSCredentialsFile) writeConfig(c *config.Config, ac *aws.Credential) error { +func (e *AWSCredentialsFile) writeConfig(c *config.Config, oc *oaws.Credential) error { filename := c.AWSCredentials() profile := c.Profile() @@ -235,7 +235,7 @@ func (e *AWSCredentialsFile) writeConfig(c *config.Config, ac *aws.Credential) e return err } - return saveProfile(filename, profile, ac, e.LegacyAWSVariables, e.ExpiryAWSVariables, e.Expiry) + return saveProfile(filename, profile, oc, e.LegacyAWSVariables, e.ExpiryAWSVariables, e.Expiry) } func contains(ignore []string, name string) bool { diff --git a/internal/output/envvar.go b/internal/output/envvar.go index eea86f3..b82ce69 100644 --- a/internal/output/envvar.go +++ b/internal/output/envvar.go @@ -20,7 +20,7 @@ import ( "fmt" "runtime" - "github.com/okta/okta-aws-cli/internal/aws" + oaws "github.com/okta/okta-aws-cli/internal/aws" "github.com/okta/okta-aws-cli/internal/config" ) @@ -38,20 +38,20 @@ func NewEnvVar(legacyVars bool) *EnvVar { // Output Satisfies the Outputter interface and outputs AWS credentials as shell // export statements to STDOUT -func (e *EnvVar) Output(c *config.Config, ac *aws.Credential) error { +func (e *EnvVar) Output(c *config.Config, oc *oaws.Credential) error { if runtime.GOOS == "windows" { - fmt.Printf("setx AWS_ACCESS_KEY_ID %s\n", ac.AccessKeyID) - fmt.Printf("setx AWS_SECRET_ACCESS_KEY %s\n", ac.SecretAccessKey) - fmt.Printf("setx AWS_SESSION_TOKEN %s\n", ac.SessionToken) + fmt.Printf("setx AWS_ACCESS_KEY_ID %s\n", oc.AccessKeyID) + fmt.Printf("setx AWS_SECRET_ACCESS_KEY %s\n", oc.SecretAccessKey) + fmt.Printf("setx AWS_SESSION_TOKEN %s\n", oc.SessionToken) if e.LegacyAWSVariables { - fmt.Printf("setx AWS_SECURITY_TOKEN %s\n", ac.SessionToken) + fmt.Printf("setx AWS_SECURITY_TOKEN %s\n", oc.SessionToken) } } else { - fmt.Printf("export AWS_ACCESS_KEY_ID=%s\n", ac.AccessKeyID) - fmt.Printf("export AWS_SECRET_ACCESS_KEY=%s\n", ac.SecretAccessKey) - fmt.Printf("export AWS_SESSION_TOKEN=%s\n", ac.SessionToken) + fmt.Printf("export AWS_ACCESS_KEY_ID=%s\n", oc.AccessKeyID) + fmt.Printf("export AWS_SECRET_ACCESS_KEY=%s\n", oc.SecretAccessKey) + fmt.Printf("export AWS_SESSION_TOKEN=%s\n", oc.SessionToken) if e.LegacyAWSVariables { - fmt.Printf("export AWS_SECURITY_TOKEN=%s\n", ac.SessionToken) + fmt.Printf("export AWS_SECURITY_TOKEN=%s\n", oc.SessionToken) } } diff --git a/internal/output/noop.go b/internal/output/noop.go new file mode 100644 index 0000000..134506a --- /dev/null +++ b/internal/output/noop.go @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2023-Present, Okta, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +package output + +import ( + oaws "github.com/okta/okta-aws-cli/internal/aws" + "github.com/okta/okta-aws-cli/internal/config" +) + +// NoopCredentials Don't output credentials +type NoopCredentials struct{} + +// NewNoopCredentials Creates a new NoopCredentials +func NewNoopCredentials() *NoopCredentials { + return &NoopCredentials{} +} + +// Output Satisfies the Outputter interface and outputs nothing +func (n *NoopCredentials) Output(c *config.Config, oc *oaws.Credential) error { + // no-op + return nil +} diff --git a/internal/output/output.go b/internal/output/output.go index ec8b561..09506e7 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -21,17 +21,17 @@ import ( "os" "time" - "github.com/okta/okta-aws-cli/internal/aws" + oaws "github.com/okta/okta-aws-cli/internal/aws" "github.com/okta/okta-aws-cli/internal/config" ) // Outputter Interface to output AWS credentials in different formats. type Outputter interface { - Output(c *config.Config, ac *aws.Credential) error + Output(c *config.Config, oc *oaws.Credential) error } // RenderAWSCredential Renders the credentials in the prescribed format. -func RenderAWSCredential(cfg *config.Config, ac *aws.Credential) error { +func RenderAWSCredential(cfg *config.Config, oc *oaws.Credential) error { var o Outputter switch cfg.Format() { case config.AWSCredentialsFormat: @@ -39,10 +39,12 @@ func RenderAWSCredential(cfg *config.Config, ac *aws.Credential) error { o = NewAWSCredentialsFile(cfg.LegacyAWSVariables(), cfg.ExpiryAWSVariables(), expiry) case config.ProcessCredentialsFormat: o = NewProcessCredentials() + case config.NoopFormat: + o = NewNoopCredentials() default: o = NewEnvVar(cfg.LegacyAWSVariables()) fmt.Fprintf(os.Stderr, "\n") } - return o.Output(cfg, ac) + return o.Output(cfg, oc) } diff --git a/internal/output/process_credentials.go b/internal/output/process_credentials.go index a9f1053..d496721 100644 --- a/internal/output/process_credentials.go +++ b/internal/output/process_credentials.go @@ -20,7 +20,7 @@ import ( "encoding/json" "fmt" - "github.com/okta/okta-aws-cli/internal/aws" + oaws "github.com/okta/okta-aws-cli/internal/aws" "github.com/okta/okta-aws-cli/internal/config" ) @@ -35,12 +35,12 @@ func NewProcessCredentials() *ProcessCredentials { // Output Satisfies the Outputter interface and outputs AWS credentials as JSON // to STDOUT -func (p *ProcessCredentials) Output(c *config.Config, ac *aws.Credential) error { +func (p *ProcessCredentials) Output(c *config.Config, oc *oaws.Credential) error { // See AWS docs: "Note As of this writing, the Version key must be set to 1. // This might increment over time as the structure evolves." - ac.Version = 1 + oc.Version = 1 - credJSON, err := json.MarshalIndent(ac, "", " ") + credJSON, err := json.MarshalIndent(oc, "", " ") if err != nil { return err } diff --git a/internal/webssoauth/webssoauth.go b/internal/webssoauth/webssoauth.go index 931cc6c..7b4cf34 100644 --- a/internal/webssoauth/webssoauth.go +++ b/internal/webssoauth/webssoauth.go @@ -46,6 +46,7 @@ import ( oaws "github.com/okta/okta-aws-cli/internal/aws" boff "github.com/okta/okta-aws-cli/internal/backoff" "github.com/okta/okta-aws-cli/internal/config" + "github.com/okta/okta-aws-cli/internal/exec" "github.com/okta/okta-aws-cli/internal/okta" "github.com/okta/okta-aws-cli/internal/output" "github.com/okta/okta-aws-cli/internal/utils" @@ -124,6 +125,14 @@ func NewWebSSOAuthentication(cfg *config.Config) (token *WebSSOAuthentication, e return nil, fmt.Errorf("arguments --%s , --%s , and --%s must be set for %q format", config.AWSIAMIdPFlag, config.AWSIAMRoleFlag, config.OpenBrowserFlag, cfg.Format()) } } + + // Check if exec arg is present and that there are args for it before doing any work + if cfg.Exec() { + if _, err := exec.NewExec(); err != nil { + return nil, err + } + } + return token, nil } @@ -294,26 +303,33 @@ func (w *WebSSOAuthentication) establishTokenWithFedAppID(clientID, fedAppID str return err } - cred, err := w.awsAssumeRoleWithSAML(iar, assertion) + oc, err := w.awsAssumeRoleWithSAML(iar, assertion) if err != nil { return err } - err = output.RenderAWSCredential(w.config, cred) + err = output.RenderAWSCredential(w.config, oc) if err != nil { return err } + if w.config.Exec() { + exe, _ := exec.NewExec() + if err := exe.Run(oc); err != nil { + return err + } + } + return nil } // awsAssumeRoleWithSAML Get AWS Credentials with an STS Assume Role With SAML AWS // API call. -func (w *WebSSOAuthentication) awsAssumeRoleWithSAML(iar *idpAndRole, assertion string) (credential *oaws.Credential, err error) { +func (w *WebSSOAuthentication) awsAssumeRoleWithSAML(iar *idpAndRole, assertion string) (oc *oaws.Credential, err error) { awsCfg := aws.NewConfig().WithHTTPClient(w.config.HTTPClient()) sess, err := session.NewSession(awsCfg) if err != nil { - return nil, err + return } svc := sts.New(sess) input := &sts.AssumeRoleWithSAMLInput{ @@ -324,16 +340,16 @@ func (w *WebSSOAuthentication) awsAssumeRoleWithSAML(iar *idpAndRole, assertion } svcResp, err := svc.AssumeRoleWithSAML(input) if err != nil { - return nil, err + return } - credential = &oaws.Credential{ + oc = &oaws.Credential{ AccessKeyID: *svcResp.Credentials.AccessKeyId, SecretAccessKey: *svcResp.Credentials.SecretAccessKey, SessionToken: *svcResp.Credentials.SessionToken, Expiration: svcResp.Credentials.Expiration, } - return credential, nil + return oc, nil } // choiceFriendlyLabelRole returns a friendly choice for pretty printing Role From 36c0373f57e58e0e907a36749be800a471898275 Mon Sep 17 00:00:00 2001 From: Mike Mondragon Date: Thu, 5 Oct 2023 13:07:18 -0700 Subject: [PATCH 20/42] Writing the aws creds file became broken with all the other v2 work. Discussed in issue #114 --- internal/aws/aws.go | 19 ++++++++++++------- internal/aws/aws_test.go | 2 +- internal/exec/exec.go | 3 ++- internal/m2mauth/m2mauth.go | 17 ++++++++--------- internal/output/aws_credentials_file.go | 3 ++- internal/output/envvar.go | 3 ++- internal/output/noop.go | 3 ++- internal/output/output.go | 7 ++++--- internal/output/process_credentials.go | 11 ++++++++--- internal/output/process_credentials_test.go | 2 +- internal/utils/utils.go | 2 ++ internal/webssoauth/webssoauth.go | 11 +++++------ 12 files changed, 49 insertions(+), 34 deletions(-) diff --git a/internal/aws/aws.go b/internal/aws/aws.go index c1d0af2..be04925 100644 --- a/internal/aws/aws.go +++ b/internal/aws/aws.go @@ -23,16 +23,21 @@ import ( // Credential Convenience representation of an AWS credential. type Credential struct { - AccessKeyID string `ini:"aws_access_key_id" json:"AccessKeyId,omitempty"` - SecretAccessKey string `ini:"aws_secret_access_key" json:"SecretAccessKey,omitempty"` - SessionToken string `ini:"aws_session_token" json:"SessionToken,omitempty"` - Version int `ini:"aws_version" json:"Version,omitempty"` - Expiration *time.Time `ini:"aws_expiration" json:"Expiration,omitempty"` + AccessKeyID string `ini:"aws_access_key_id" json:"AccessKeyId,omitempty"` + SecretAccessKey string `ini:"aws_secret_access_key" json:"SecretAccessKey,omitempty"` + SessionToken string `ini:"aws_session_token" json:"SessionToken,omitempty"` +} + +// ProcessCredential Convenience representation of an AWS credential used for process credential format. +type ProcessCredential struct { + Credential + Version int `json:"Version,omitempty"` + Expiration *time.Time `json:"Expiration,omitempty"` } // MarshalJSON ensure Expiration date time is formatted RFC 3339 format. -func (c *Credential) MarshalJSON() ([]byte, error) { - type Alias Credential +func (c *ProcessCredential) MarshalJSON() ([]byte, error) { + type Alias ProcessCredential var exp string if c.Expiration != nil { exp = c.Expiration.Format(time.RFC3339) diff --git a/internal/aws/aws_test.go b/internal/aws/aws_test.go index eb68a8f..32c336b 100644 --- a/internal/aws/aws_test.go +++ b/internal/aws/aws_test.go @@ -26,7 +26,7 @@ import ( func TestCredentialJSON(t *testing.T) { hbtGo := time.Date(2009, time.November, 10, 23, 0, 0, 0, time.UTC) - c := Credential{ + c := ProcessCredential{ Expiration: &hbtGo, } credStr, err := json.Marshal(c) diff --git a/internal/exec/exec.go b/internal/exec/exec.go index b8759a2..abf42a3 100644 --- a/internal/exec/exec.go +++ b/internal/exec/exec.go @@ -23,6 +23,7 @@ import ( "strings" oaws "github.com/okta/okta-aws-cli/internal/aws" + "github.com/okta/okta-aws-cli/internal/utils" ) // Exec is a executor / a process runner @@ -86,7 +87,7 @@ func (e *Exec) Run(oc *oaws.Credential) error { if ee, ok := err.(*osexec.ExitError); ok { fmt.Fprintf(os.Stderr, "error running process\n") fmt.Fprintf(os.Stderr, "%s %s\n", e.name, strings.Join(e.args, " ")) - fmt.Fprintf(os.Stderr, "%s\n", ee.Stderr) + fmt.Fprintf(os.Stderr, utils.PassThroughStringNewLineFMT, ee.Stderr) } if err != nil { return err diff --git a/internal/m2mauth/m2mauth.go b/internal/m2mauth/m2mauth.go index f3079ca..299322c 100644 --- a/internal/m2mauth/m2mauth.go +++ b/internal/m2mauth/m2mauth.go @@ -91,19 +91,19 @@ func (m *M2MAuthentication) EstablishIAMCredentials() error { return err } - cred, err := m.awsAssumeRoleWithWebIdentity(at) + oc, ac, err := m.awsAssumeRoleWithWebIdentity(at) if err != nil { return err } - err = output.RenderAWSCredential(m.config, cred) + err = output.RenderAWSCredential(m.config, oc, ac) if err != nil { return err } if m.config.Exec() { exe, _ := exec.NewExec() - if err := exe.Run(cred); err != nil { + if err := exe.Run(oc); err != nil { return err } } @@ -111,11 +111,11 @@ func (m *M2MAuthentication) EstablishIAMCredentials() error { return nil } -func (m *M2MAuthentication) awsAssumeRoleWithWebIdentity(at *okta.AccessToken) (credential *oaws.Credential, err error) { +func (m *M2MAuthentication) awsAssumeRoleWithWebIdentity(at *okta.AccessToken) (oc *oaws.Credential, ac *sts.Credentials, err error) { awsCfg := aws.NewConfig().WithHTTPClient(m.config.HTTPClient()) sess, err := session.NewSession(awsCfg) if err != nil { - return nil, err + return } svc := sts.New(sess) @@ -127,17 +127,16 @@ func (m *M2MAuthentication) awsAssumeRoleWithWebIdentity(at *okta.AccessToken) ( } svcResp, err := svc.AssumeRoleWithWebIdentity(input) if err != nil { - return nil, err + return } - credential = &oaws.Credential{ + oc = &oaws.Credential{ AccessKeyID: *svcResp.Credentials.AccessKeyId, SecretAccessKey: *svcResp.Credentials.SecretAccessKey, SessionToken: *svcResp.Credentials.SessionToken, - Expiration: svcResp.Credentials.Expiration, } - return credential, nil + return oc, svcResp.Credentials, nil } func (m *M2MAuthentication) createKeySigner() (jose.Signer, error) { diff --git a/internal/output/aws_credentials_file.go b/internal/output/aws_credentials_file.go index c54ecaa..1d18e77 100644 --- a/internal/output/aws_credentials_file.go +++ b/internal/output/aws_credentials_file.go @@ -23,6 +23,7 @@ import ( "reflect" "strings" + "github.com/aws/aws-sdk-go/service/sts" dynamicstruct "github.com/ompluscator/dynamic-struct" "github.com/pkg/errors" "gopkg.in/ini.v1" @@ -178,7 +179,7 @@ func NewAWSCredentialsFile(legacyVars bool, expiryVars bool, expiry string) *AWS // Output Satisfies the Outputter interface and appends AWS credentials to // credentials file. -func (e *AWSCredentialsFile) Output(c *config.Config, oc *oaws.Credential) error { +func (e *AWSCredentialsFile) Output(c *config.Config, oc *oaws.Credential, ac *sts.Credentials) error { if c.WriteAWSCredentials() { return e.writeConfig(c, oc) } diff --git a/internal/output/envvar.go b/internal/output/envvar.go index b82ce69..2d66d69 100644 --- a/internal/output/envvar.go +++ b/internal/output/envvar.go @@ -20,6 +20,7 @@ import ( "fmt" "runtime" + "github.com/aws/aws-sdk-go/service/sts" oaws "github.com/okta/okta-aws-cli/internal/aws" "github.com/okta/okta-aws-cli/internal/config" ) @@ -38,7 +39,7 @@ func NewEnvVar(legacyVars bool) *EnvVar { // Output Satisfies the Outputter interface and outputs AWS credentials as shell // export statements to STDOUT -func (e *EnvVar) Output(c *config.Config, oc *oaws.Credential) error { +func (e *EnvVar) Output(c *config.Config, oc *oaws.Credential, ac *sts.Credentials) error { if runtime.GOOS == "windows" { fmt.Printf("setx AWS_ACCESS_KEY_ID %s\n", oc.AccessKeyID) fmt.Printf("setx AWS_SECRET_ACCESS_KEY %s\n", oc.SecretAccessKey) diff --git a/internal/output/noop.go b/internal/output/noop.go index 134506a..a230d2d 100644 --- a/internal/output/noop.go +++ b/internal/output/noop.go @@ -17,6 +17,7 @@ package output import ( + "github.com/aws/aws-sdk-go/service/sts" oaws "github.com/okta/okta-aws-cli/internal/aws" "github.com/okta/okta-aws-cli/internal/config" ) @@ -30,7 +31,7 @@ func NewNoopCredentials() *NoopCredentials { } // Output Satisfies the Outputter interface and outputs nothing -func (n *NoopCredentials) Output(c *config.Config, oc *oaws.Credential) error { +func (n *NoopCredentials) Output(c *config.Config, oc *oaws.Credential, ac *sts.Credentials) error { // no-op return nil } diff --git a/internal/output/output.go b/internal/output/output.go index 09506e7..b6b9a43 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -21,17 +21,18 @@ import ( "os" "time" + "github.com/aws/aws-sdk-go/service/sts" oaws "github.com/okta/okta-aws-cli/internal/aws" "github.com/okta/okta-aws-cli/internal/config" ) // Outputter Interface to output AWS credentials in different formats. type Outputter interface { - Output(c *config.Config, oc *oaws.Credential) error + Output(c *config.Config, oc *oaws.Credential, ac *sts.Credentials) error } // RenderAWSCredential Renders the credentials in the prescribed format. -func RenderAWSCredential(cfg *config.Config, oc *oaws.Credential) error { +func RenderAWSCredential(cfg *config.Config, oc *oaws.Credential, ac *sts.Credentials) error { var o Outputter switch cfg.Format() { case config.AWSCredentialsFormat: @@ -46,5 +47,5 @@ func RenderAWSCredential(cfg *config.Config, oc *oaws.Credential) error { fmt.Fprintf(os.Stderr, "\n") } - return o.Output(cfg, oc) + return o.Output(cfg, oc, ac) } diff --git a/internal/output/process_credentials.go b/internal/output/process_credentials.go index d496721..32c2622 100644 --- a/internal/output/process_credentials.go +++ b/internal/output/process_credentials.go @@ -20,6 +20,7 @@ import ( "encoding/json" "fmt" + "github.com/aws/aws-sdk-go/service/sts" oaws "github.com/okta/okta-aws-cli/internal/aws" "github.com/okta/okta-aws-cli/internal/config" ) @@ -35,12 +36,16 @@ func NewProcessCredentials() *ProcessCredentials { // Output Satisfies the Outputter interface and outputs AWS credentials as JSON // to STDOUT -func (p *ProcessCredentials) Output(c *config.Config, oc *oaws.Credential) error { +func (p *ProcessCredentials) Output(c *config.Config, oc *oaws.Credential, ac *sts.Credentials) error { // See AWS docs: "Note As of this writing, the Version key must be set to 1. // This might increment over time as the structure evolves." - oc.Version = 1 + poc := &oaws.ProcessCredential{ + Credential: *oc, + Expiration: ac.Expiration, + Version: 1, + } - credJSON, err := json.MarshalIndent(oc, "", " ") + credJSON, err := json.MarshalIndent(poc, "", " ") if err != nil { return err } diff --git a/internal/output/process_credentials_test.go b/internal/output/process_credentials_test.go index af4a943..673e122 100644 --- a/internal/output/process_credentials_test.go +++ b/internal/output/process_credentials_test.go @@ -34,7 +34,7 @@ func TestProcessCredentials(t *testing.T) { "SessionToken": "the AWS session token for temporary credentials", "Expiration": "2009-11-10T23:00:00Z" }` - result := aws.Credential{} + result := aws.ProcessCredential{} err := json.Unmarshal([]byte(credsJSON), &result) require.NoError(t, err) require.Equal(t, "an AWS access key", result.AccessKeyID) diff --git a/internal/utils/utils.go b/internal/utils/utils.go index dab36aa..70d96b8 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -31,4 +31,6 @@ const ( XOktaAWSCLIWebOperation = "web" // XOktaAWSCLIM2MOperation m2m op value for the x okta aws cli header XOktaAWSCLIM2MOperation = "m2m" + // PassThroughStringNewLineFMT string formatter to make lint happy + PassThroughStringNewLineFMT = "%s\n" ) diff --git a/internal/webssoauth/webssoauth.go b/internal/webssoauth/webssoauth.go index 7b4cf34..943a0ea 100644 --- a/internal/webssoauth/webssoauth.go +++ b/internal/webssoauth/webssoauth.go @@ -303,12 +303,12 @@ func (w *WebSSOAuthentication) establishTokenWithFedAppID(clientID, fedAppID str return err } - oc, err := w.awsAssumeRoleWithSAML(iar, assertion) + oc, ac, err := w.awsAssumeRoleWithSAML(iar, assertion) if err != nil { return err } - err = output.RenderAWSCredential(w.config, oc) + err = output.RenderAWSCredential(w.config, oc, ac) if err != nil { return err } @@ -325,7 +325,7 @@ func (w *WebSSOAuthentication) establishTokenWithFedAppID(clientID, fedAppID str // awsAssumeRoleWithSAML Get AWS Credentials with an STS Assume Role With SAML AWS // API call. -func (w *WebSSOAuthentication) awsAssumeRoleWithSAML(iar *idpAndRole, assertion string) (oc *oaws.Credential, err error) { +func (w *WebSSOAuthentication) awsAssumeRoleWithSAML(iar *idpAndRole, assertion string) (oc *oaws.Credential, ac *sts.Credentials, err error) { awsCfg := aws.NewConfig().WithHTTPClient(w.config.HTTPClient()) sess, err := session.NewSession(awsCfg) if err != nil { @@ -347,9 +347,8 @@ func (w *WebSSOAuthentication) awsAssumeRoleWithSAML(iar *idpAndRole, assertion AccessKeyID: *svcResp.Credentials.AccessKeyId, SecretAccessKey: *svcResp.Credentials.SecretAccessKey, SessionToken: *svcResp.Credentials.SessionToken, - Expiration: svcResp.Credentials.Expiration, } - return oc, nil + return oc, svcResp.Credentials, nil } // choiceFriendlyLabelRole returns a friendly choice for pretty printing Role @@ -637,7 +636,7 @@ func (w *WebSSOAuthentication) promptAuthentication(da *okta.DeviceAuthorization buf := bytes.NewBufferString("") qrterminal.GenerateHalfBlock(da.VerificationURIComplete, qrterminal.L, buf) if _, err := buf.Read(qrBuf); err == nil { - qrCode = fmt.Sprintf("%s\n", qrBuf) + qrCode = fmt.Sprintf(utils.PassThroughStringNewLineFMT, qrBuf) } } From 47a50b23cf3e3e442844f9e266b0476f68f92bec Mon Sep 17 00:00:00 2001 From: Mike Mondragon Date: Thu, 5 Oct 2023 13:20:13 -0700 Subject: [PATCH 21/42] bump version --- internal/config/config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/config/config.go b/internal/config/config.go index 913b533..d7dfc5a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -39,7 +39,7 @@ func init() { const ( // Version app version - Version = "2.0.0-beta.1" + Version = "2.0.0-beta.2" // AWSCredentialsFormat format const AWSCredentialsFormat = "aws-credentials" From 59ce79f5d7775e344a4a413e1a272359687ae8ff Mon Sep 17 00:00:00 2001 From: Mike Mondragon Date: Mon, 9 Oct 2023 11:11:15 -0700 Subject: [PATCH 22/42] Silently support old ENV VAR names/values for `OKTA_ORG_DOMAIN`, `OKTA_OIDC_CLIENT_ID`, `OKTA_AWS_ACCOUNT_FEDERATION_APP_ID` --- internal/config/config.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/internal/config/config.go b/internal/config/config.go index d7dfc5a..f2d4967 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -123,10 +123,16 @@ const ( LegacyAWSVariablesEnvVar = "OKTA_AWSCLI_LEGACY_AWS_VARIABLES" // OktaOIDCClientIDEnvVar env var const OktaOIDCClientIDEnvVar = "OKTA_AWSCLI_OIDC_CLIENT_ID" + // OldOktaOIDCClientIDEnvVar env var const + OldOktaOIDCClientIDEnvVar = "OKTA_OIDC_CLIENT_ID" // OktaOrgDomainEnvVar env var const OktaOrgDomainEnvVar = "OKTA_AWSCLI_ORG_DOMAIN" + // OldOktaOrgDomainEnvVar env var const + OldOktaOrgDomainEnvVar = "OKTA_ORG_DOMAIN" // OktaAWSAccountFederationAppIDEnvVar env var const OktaAWSAccountFederationAppIDEnvVar = "OKTA_AWSCLI_AWS_ACCOUNT_FEDERATION_APP_ID" + // OldOktaAWSAccountFederationAppIDEnvVar env var const + OldOktaAWSAccountFederationAppIDEnvVar = "OKTA_AWS_ACCOUNT_FEDERATION_APP_ID" // OpenBrowserEnvVar env var const OpenBrowserEnvVar = "OKTA_AWSCLI_OPEN_BROWSER" // PrivateKeyEnvVar env var const @@ -326,12 +332,22 @@ func readConfig() (Attributes, error) { if attrs.OrgDomain == "" { attrs.OrgDomain = viper.GetString(downCase(OktaOrgDomainEnvVar)) } + if attrs.OrgDomain == "" { + // legacy support OKTA_ORG_DOMAIN + attrs.OrgDomain = viper.GetString(downCase(OldOktaOrgDomainEnvVar)) + } if attrs.OIDCAppID == "" { attrs.OIDCAppID = viper.GetString(downCase(OktaOIDCClientIDEnvVar)) } + if attrs.OIDCAppID == "" { + attrs.OIDCAppID = viper.GetString(downCase(OldOktaOIDCClientIDEnvVar)) + } if attrs.FedAppID == "" { attrs.FedAppID = viper.GetString(downCase(OktaAWSAccountFederationAppIDEnvVar)) } + if attrs.FedAppID == "" { + attrs.FedAppID = viper.GetString(downCase(OldOktaAWSAccountFederationAppIDEnvVar)) + } if attrs.AWSIAMIdP == "" { attrs.AWSIAMIdP = viper.GetString(downCase(AWSIAMIdPEnvVar)) } From 0f0fa1a5e935f6ffa94a46bcc9e50ae59cbb8ae6 Mon Sep 17 00:00:00 2001 From: Mike Mondragon Date: Tue, 10 Oct 2023 14:16:20 -0700 Subject: [PATCH 23/42] For `web` command gather all roles for an IdP such that they can all be appended or updated in an AWS credentials file. Implementation based @daniel-sampliner work in #94 Closes #94 --- cmd/root/web/web.go | 7 ++ internal/aws/aws.go | 71 +++++++++-- internal/config/config.go | 26 ++++ internal/exec/exec.go | 8 +- internal/m2mauth/m2mauth.go | 13 +- internal/output/aws_credentials_file.go | 57 +++++---- internal/output/aws_credentials_file_test.go | 4 +- internal/output/envvar.go | 20 +-- internal/output/noop.go | 3 +- internal/output/output.go | 32 ++++- internal/output/process_credentials.go | 13 +- internal/utils/utils.go | 7 ++ internal/webssoauth/webssoauth.go | 122 ++++++++++++++++--- 13 files changed, 295 insertions(+), 88 deletions(-) diff --git a/cmd/root/web/web.go b/cmd/root/web/web.go index 015ea33..d1ce7b3 100644 --- a/cmd/root/web/web.go +++ b/cmd/root/web/web.go @@ -54,6 +54,13 @@ var ( Usage: "Automatically open the activation URL with the system web browser", EnvVar: config.OpenBrowserEnvVar, }, + { + Name: config.AllProfilesFlag, + Short: "k", + Value: false, + Usage: "Collect all profiles for a given IdP (implies aws-credentials file output format)", + EnvVar: config.AllProfilesEnvVar, + }, } requiredFlags = []string{"org-domain", "oidc-client-id"} ) diff --git a/internal/aws/aws.go b/internal/aws/aws.go index be04925..da8d1b2 100644 --- a/internal/aws/aws.go +++ b/internal/aws/aws.go @@ -21,20 +21,67 @@ import ( "time" ) -// Credential Convenience representation of an AWS credential. -type Credential struct { - AccessKeyID string `ini:"aws_access_key_id" json:"AccessKeyId,omitempty"` - SecretAccessKey string `ini:"aws_secret_access_key" json:"SecretAccessKey,omitempty"` - SessionToken string `ini:"aws_session_token" json:"SessionToken,omitempty"` +// Credential Interface to represent AWS credentials in different formats. +type Credential interface { + // Trivial function to allow concrete structs to be represented by this + // interface. + IsCredential() bool } -// ProcessCredential Convenience representation of an AWS credential used for process credential format. +// CredentialContainer denormalized struct of all the values can be presented in +// the different credentials formats +type CredentialContainer struct { + AccessKeyID string + SecretAccessKey string + SessionToken string + Expiration *time.Time + Version int + Profile string +} + +// EnvVarCredential representation of an AWS credential for environment +// variables +type EnvVarCredential struct { + AccessKeyID string + SecretAccessKey string + SessionToken string +} + +// IsCredential env var credential is a credential +func (e *EnvVarCredential) IsCredential() bool { return true } + +// CredsFileCredential representation of an AWS credential for the AWS +// credentials file +type CredsFileCredential struct { + AccessKeyID string `ini:"aws_access_key_id"` + SecretAccessKey string `ini:"aws_secret_access_key"` + SessionToken string `ini:"aws_session_token"` + + profile string +} + +// IsCredential creds file credential is a credential +func (c *CredsFileCredential) IsCredential() bool { return true } + +// SetProfile sets the profile name associated with this AWS credential. +func (c *CredsFileCredential) SetProfile(p string) { c.profile = p } + +// Profile returns the profile name associated with this AWS credential. +func (c CredsFileCredential) Profile() string { return c.profile } + +// ProcessCredential Convenience representation of an AWS credential used for +// process credential format. type ProcessCredential struct { - Credential - Version int `json:"Version,omitempty"` - Expiration *time.Time `json:"Expiration,omitempty"` + AccessKeyID string `json:"AccessKeyId,omitempty"` + SecretAccessKey string `json:"SecretAccessKey,omitempty"` + SessionToken string `json:"SessionToken,omitempty"` + Expiration *time.Time `json:"Expiration,omitempty"` + Version int `json:"Version,omitempty"` } +// IsCredential process credential is a credential +func (c *ProcessCredential) IsCredential() bool { return true } + // MarshalJSON ensure Expiration date time is formatted RFC 3339 format. func (c *ProcessCredential) MarshalJSON() ([]byte, error) { type Alias ProcessCredential @@ -54,3 +101,9 @@ func (c *ProcessCredential) MarshalJSON() ([]byte, error) { } return json.Marshal(obj) } + +// NoopCredential Convenience representation for not printing credentials +type NoopCredential struct{} + +// IsCredential noop credential is a credential +func (n *NoopCredential) IsCredential() bool { return true } diff --git a/internal/config/config.go b/internal/config/config.go index f2d4967..02d5feb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -50,6 +50,8 @@ const ( // NoopFormat format const NoopFormat = "noop" + // AllProfilesFlag cli flag const + AllProfilesFlag = "all-profiles" // AuthzIDFlag cli flag const AuthzIDFlag = "authz-id" // AWSAcctFedAppIDFlag cli flag const @@ -95,6 +97,8 @@ const ( // CacheAccessTokenFlag cli flag const CacheAccessTokenFlag = "cache-access-token" + // AllProfilesEnvVar env var const + AllProfilesEnvVar = "OKTA_AWSCLI_ALL_PROFILES" // AuthzIDEnvVar env var const AuthzIDEnvVar = "OKTA_AWSCLI_AUTHZ_ID" // AWSCredentialsEnvVar env var const @@ -177,6 +181,7 @@ type Clock interface { // control data access, be concerned with evaluation, validation, and not // allowing direct access to values as is done on structs in the generic case. type Config struct { + allProfiles bool authzID string awsCredentials string awsIAMIdP string @@ -205,6 +210,7 @@ type Config struct { // Attributes config construction type Attributes struct { + AllProfiles bool AuthzID string AWSCredentials string AWSIAMIdP string @@ -245,6 +251,7 @@ func EvaluateSettings() (*Config, error) { func NewConfig(attrs *Attributes) (*Config, error) { var err error cfg := &Config{ + allProfiles: attrs.AllProfiles, authzID: attrs.AuthzID, awsCredentials: attrs.AWSCredentials, awsIAMIdP: attrs.AWSIAMIdP, @@ -291,6 +298,7 @@ func NewConfig(attrs *Attributes) (*Config, error) { func readConfig() (Attributes, error) { attrs := Attributes{ + AllProfiles: viper.GetBool(AllProfilesFlag), AuthzID: viper.GetString(AuthzIDFlag), AWSCredentials: viper.GetString(AWSCredentialsFlag), AWSIAMIdP: viper.GetString(AWSIAMIdPFlag), @@ -369,6 +377,9 @@ func readConfig() (Attributes, error) { if attrs.AuthzID == "" { attrs.AuthzID = viper.GetString(downCase(AuthzIDEnvVar)) } + if !attrs.AllProfiles { + attrs.AllProfiles = viper.GetBool(downCase(AllProfilesEnvVar)) + } // if session duration is 0, inspect the ENV VAR for a value, else set // a default of 3600 @@ -415,6 +426,10 @@ func readConfig() (Attributes, error) { // writing aws creds option implies "aws-credentials" format attrs.Format = AWSCredentialsFormat } + if attrs.AllProfiles { + // writing all aws profiles option implies "aws-credentials" format + attrs.Format = AWSCredentialsFormat + } if !attrs.OpenBrowser { attrs.OpenBrowser = viper.GetBool(downCase(OpenBrowserEnvVar)) } @@ -444,6 +459,17 @@ func downCase(s string) string { return strings.ToLower(s) } +// AllProfiles -- +func (c *Config) AllProfiles() bool { + return c.allProfiles +} + +// SetAllProfiles -- +func (c *Config) SetAllProfiles(allProfiles bool) error { + c.allProfiles = allProfiles + return nil +} + // AuthzID -- func (c *Config) AuthzID() string { return c.authzID diff --git a/internal/exec/exec.go b/internal/exec/exec.go index abf42a3..8872678 100644 --- a/internal/exec/exec.go +++ b/internal/exec/exec.go @@ -63,7 +63,7 @@ func NewExec() (*Exec, error) { } // Run Run the executor -func (e *Exec) Run(oc *oaws.Credential) error { +func (e *Exec) Run(cc *oaws.CredentialContainer) error { pairs := map[string]string{} // pre-populate pairs with any existing env var starting with "AWS_" for _, kv := range os.Environ() { @@ -74,9 +74,9 @@ func (e *Exec) Run(oc *oaws.Credential) error { } } // add creds env var names to pairs - pairs["AWS_ACCESS_KEY_ID"] = oc.AccessKeyID - pairs["AWS_SECRET_ACCESS_KEY"] = oc.SecretAccessKey - pairs["AWS_SESSION_TOKEN"] = oc.SessionToken + pairs["AWS_ACCESS_KEY_ID"] = cc.AccessKeyID + pairs["AWS_SECRET_ACCESS_KEY"] = cc.SecretAccessKey + pairs["AWS_SESSION_TOKEN"] = cc.SessionToken cmd := osexec.Command(e.name, e.args...) for k, v := range pairs { diff --git a/internal/m2mauth/m2mauth.go b/internal/m2mauth/m2mauth.go index 299322c..31512eb 100644 --- a/internal/m2mauth/m2mauth.go +++ b/internal/m2mauth/m2mauth.go @@ -91,19 +91,19 @@ func (m *M2MAuthentication) EstablishIAMCredentials() error { return err } - oc, ac, err := m.awsAssumeRoleWithWebIdentity(at) + cc, err := m.awsAssumeRoleWithWebIdentity(at) if err != nil { return err } - err = output.RenderAWSCredential(m.config, oc, ac) + err = output.RenderAWSCredential(m.config, cc) if err != nil { return err } if m.config.Exec() { exe, _ := exec.NewExec() - if err := exe.Run(oc); err != nil { + if err := exe.Run(cc); err != nil { return err } } @@ -111,7 +111,7 @@ func (m *M2MAuthentication) EstablishIAMCredentials() error { return nil } -func (m *M2MAuthentication) awsAssumeRoleWithWebIdentity(at *okta.AccessToken) (oc *oaws.Credential, ac *sts.Credentials, err error) { +func (m *M2MAuthentication) awsAssumeRoleWithWebIdentity(at *okta.AccessToken) (cc *oaws.CredentialContainer, err error) { awsCfg := aws.NewConfig().WithHTTPClient(m.config.HTTPClient()) sess, err := session.NewSession(awsCfg) if err != nil { @@ -130,13 +130,14 @@ func (m *M2MAuthentication) awsAssumeRoleWithWebIdentity(at *okta.AccessToken) ( return } - oc = &oaws.Credential{ + cc = &oaws.CredentialContainer{ AccessKeyID: *svcResp.Credentials.AccessKeyId, SecretAccessKey: *svcResp.Credentials.SecretAccessKey, SessionToken: *svcResp.Credentials.SessionToken, + Expiration: svcResp.Credentials.Expiration, } - return oc, svcResp.Credentials, nil + return cc, nil } func (m *M2MAuthentication) createKeySigner() (jose.Signer, error) { diff --git a/internal/output/aws_credentials_file.go b/internal/output/aws_credentials_file.go index 1d18e77..a1a79f4 100644 --- a/internal/output/aws_credentials_file.go +++ b/internal/output/aws_credentials_file.go @@ -23,13 +23,13 @@ import ( "reflect" "strings" - "github.com/aws/aws-sdk-go/service/sts" dynamicstruct "github.com/ompluscator/dynamic-struct" "github.com/pkg/errors" "gopkg.in/ini.v1" oaws "github.com/okta/okta-aws-cli/internal/aws" "github.com/okta/okta-aws-cli/internal/config" + "github.com/okta/okta-aws-cli/internal/utils" ) const ( @@ -65,8 +65,8 @@ func ensureConfigExists(filename string, profile string) error { return nil } -func saveProfile(filename, profile string, awsCreds *oaws.Credential, legacyVars, expiryVars bool, expiry string) error { - config, err := updateConfig(filename, profile, awsCreds, legacyVars, expiryVars, expiry) +func saveProfile(filename, profile string, cfc *oaws.CredsFileCredential, legacyVars, expiryVars bool, expiry string) error { + config, err := updateConfig(filename, profile, cfc, legacyVars, expiryVars, expiry) if err != nil { return err } @@ -80,7 +80,7 @@ func saveProfile(filename, profile string, awsCreds *oaws.Credential, legacyVars return nil } -func updateConfig(filename, profile string, awsCreds *oaws.Credential, legacyVars, expiryVars bool, expiry string) (config *ini.File, err error) { +func updateConfig(filename, profile string, cfc *oaws.CredsFileCredential, legacyVars, expiryVars bool, expiry string) (config *ini.File, err error) { config, err = ini.Load(filename) if err != nil { return @@ -91,7 +91,10 @@ func updateConfig(filename, profile string, awsCreds *oaws.Credential, legacyVar return } - builder := dynamicstruct.ExtendStruct(oaws.Credential{}) + builder := dynamicstruct.NewStruct(). + AddField(utils.AccessKeyID, "", `ini:"aws_access_key_id"`). + AddField(utils.SecretAccessKey, "", `ini:"aws_secret_access_key"`). + AddField(utils.SessionToken, "", `ini:"aws_session_token"`) if expiryVars { builder.AddField(ExpirationField, "", `ini:"x_security_token_expires"`) @@ -99,16 +102,17 @@ func updateConfig(filename, profile string, awsCreds *oaws.Credential, legacyVar if legacyVars { builder.AddField(SecurityTokenField, "", `ini:"aws_security_token"`) } + instance := builder.Build().New() - reflect.ValueOf(instance).Elem().FieldByName("AccessKeyID").SetString(awsCreds.AccessKeyID) - reflect.ValueOf(instance).Elem().FieldByName("SecretAccessKey").SetString(awsCreds.SecretAccessKey) - reflect.ValueOf(instance).Elem().FieldByName("SessionToken").SetString(awsCreds.SessionToken) + reflect.ValueOf(instance).Elem().FieldByName(utils.AccessKeyID).SetString(cfc.AccessKeyID) + reflect.ValueOf(instance).Elem().FieldByName(utils.SecretAccessKey).SetString(cfc.SecretAccessKey) + reflect.ValueOf(instance).Elem().FieldByName(utils.SessionToken).SetString(cfc.SessionToken) if expiryVars { reflect.ValueOf(instance).Elem().FieldByName(ExpirationField).SetString(expiry) } if legacyVars { - reflect.ValueOf(instance).Elem().FieldByName(SecurityTokenField).SetString(awsCreds.SessionToken) + reflect.ValueOf(instance).Elem().FieldByName(SecurityTokenField).SetString(cfc.SessionToken) } err = iniProfile.ReflectFrom(instance) @@ -179,15 +183,16 @@ func NewAWSCredentialsFile(legacyVars bool, expiryVars bool, expiry string) *AWS // Output Satisfies the Outputter interface and appends AWS credentials to // credentials file. -func (e *AWSCredentialsFile) Output(c *config.Config, oc *oaws.Credential, ac *sts.Credentials) error { +func (a *AWSCredentialsFile) Output(c *config.Config, oc oaws.Credential) error { + cfc := oc.(*oaws.CredsFileCredential) if c.WriteAWSCredentials() { - return e.writeConfig(c, oc) + return a.writeConfig(c, cfc) } - return e.appendConfig(c, oc) + return a.appendConfig(c, cfc) } -func (e *AWSCredentialsFile) appendConfig(c *config.Config, oc *oaws.Credential) error { +func (a *AWSCredentialsFile) appendConfig(c *config.Config, cfc *oaws.CredsFileCredential) error { f, err := os.OpenFile(c.AWSCredentials(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) if err != nil { return err @@ -196,22 +201,27 @@ func (e *AWSCredentialsFile) appendConfig(c *config.Config, oc *oaws.Credential) _ = f.Close() }() + profile := cfc.Profile() + if profile == "" { + profile = c.Profile() + } + creds := ` [%s] aws_access_key_id = %s aws_secret_access_key = %s aws_session_token = %s ` - credArgs := []interface{}{c.Profile(), oc.AccessKeyID, oc.SecretAccessKey, oc.SessionToken} + credArgs := []interface{}{profile, cfc.AccessKeyID, cfc.SecretAccessKey, cfc.SessionToken} - if e.LegacyAWSVariables { + if a.LegacyAWSVariables { creds = fmt.Sprintf("%saws_security_token = %%s\n", creds) - credArgs = append(credArgs, oc.SessionToken) + credArgs = append(credArgs, cfc.SessionToken) } - if e.ExpiryAWSVariables { + if a.ExpiryAWSVariables { creds = fmt.Sprintf("%sx_security_token_expires = %%s\n", creds) - credArgs = append(credArgs, e.Expiry) + credArgs = append(credArgs, a.Expiry) } creds = fmt.Sprintf(creds, credArgs...) @@ -222,21 +232,24 @@ aws_session_token = %s } _ = f.Sync() - fmt.Fprintf(os.Stderr, "Appended profile %q to %s\n", c.Profile(), c.AWSCredentials()) + fmt.Fprintf(os.Stderr, "Appended profile %q to %s\n", profile, c.AWSCredentials()) return nil } -func (e *AWSCredentialsFile) writeConfig(c *config.Config, oc *oaws.Credential) error { +func (a *AWSCredentialsFile) writeConfig(c *config.Config, cfc *oaws.CredsFileCredential) error { filename := c.AWSCredentials() - profile := c.Profile() + profile := cfc.Profile() + if profile == "" { + profile = c.Profile() + } err := ensureConfigExists(filename, profile) if err != nil { return err } - return saveProfile(filename, profile, oc, e.LegacyAWSVariables, e.ExpiryAWSVariables, e.Expiry) + return saveProfile(filename, profile, cfc, a.LegacyAWSVariables, a.ExpiryAWSVariables, a.Expiry) } func contains(ignore []string, name string) bool { diff --git a/internal/output/aws_credentials_file_test.go b/internal/output/aws_credentials_file_test.go index 791fc50..f2498cf 100644 --- a/internal/output/aws_credentials_file_test.go +++ b/internal/output/aws_credentials_file_test.go @@ -53,12 +53,12 @@ func TestINIFormatCredentialsContent(t *testing.T) { err = f.Close() assert.NoError(t, err) - awsCreds := &aws.Credential{ + cfc := &aws.CredsFileCredential{ AccessKeyID: "d", SecretAccessKey: "e", SessionToken: "f", } - config, err := updateConfig(filename, "test", awsCreds, false, false, "") + config, err := updateConfig(filename, "test", cfc, false, false, "") assert.NoError(t, err) err = config.SaveTo(filename) diff --git a/internal/output/envvar.go b/internal/output/envvar.go index 2d66d69..04d98ab 100644 --- a/internal/output/envvar.go +++ b/internal/output/envvar.go @@ -20,7 +20,6 @@ import ( "fmt" "runtime" - "github.com/aws/aws-sdk-go/service/sts" oaws "github.com/okta/okta-aws-cli/internal/aws" "github.com/okta/okta-aws-cli/internal/config" ) @@ -39,20 +38,21 @@ func NewEnvVar(legacyVars bool) *EnvVar { // Output Satisfies the Outputter interface and outputs AWS credentials as shell // export statements to STDOUT -func (e *EnvVar) Output(c *config.Config, oc *oaws.Credential, ac *sts.Credentials) error { +func (e *EnvVar) Output(c *config.Config, oc oaws.Credential) error { + evc := oc.(*oaws.EnvVarCredential) if runtime.GOOS == "windows" { - fmt.Printf("setx AWS_ACCESS_KEY_ID %s\n", oc.AccessKeyID) - fmt.Printf("setx AWS_SECRET_ACCESS_KEY %s\n", oc.SecretAccessKey) - fmt.Printf("setx AWS_SESSION_TOKEN %s\n", oc.SessionToken) + fmt.Printf("setx AWS_ACCESS_KEY_ID %s\n", evc.AccessKeyID) + fmt.Printf("setx AWS_SECRET_ACCESS_KEY %s\n", evc.SecretAccessKey) + fmt.Printf("setx AWS_SESSION_TOKEN %s\n", evc.SessionToken) if e.LegacyAWSVariables { - fmt.Printf("setx AWS_SECURITY_TOKEN %s\n", oc.SessionToken) + fmt.Printf("setx AWS_SECURITY_TOKEN %s\n", evc.SessionToken) } } else { - fmt.Printf("export AWS_ACCESS_KEY_ID=%s\n", oc.AccessKeyID) - fmt.Printf("export AWS_SECRET_ACCESS_KEY=%s\n", oc.SecretAccessKey) - fmt.Printf("export AWS_SESSION_TOKEN=%s\n", oc.SessionToken) + fmt.Printf("export AWS_ACCESS_KEY_ID=%s\n", evc.AccessKeyID) + fmt.Printf("export AWS_SECRET_ACCESS_KEY=%s\n", evc.SecretAccessKey) + fmt.Printf("export AWS_SESSION_TOKEN=%s\n", evc.SessionToken) if e.LegacyAWSVariables { - fmt.Printf("export AWS_SECURITY_TOKEN=%s\n", oc.SessionToken) + fmt.Printf("export AWS_SECURITY_TOKEN=%s\n", evc.SessionToken) } } diff --git a/internal/output/noop.go b/internal/output/noop.go index a230d2d..53c6c13 100644 --- a/internal/output/noop.go +++ b/internal/output/noop.go @@ -17,7 +17,6 @@ package output import ( - "github.com/aws/aws-sdk-go/service/sts" oaws "github.com/okta/okta-aws-cli/internal/aws" "github.com/okta/okta-aws-cli/internal/config" ) @@ -31,7 +30,7 @@ func NewNoopCredentials() *NoopCredentials { } // Output Satisfies the Outputter interface and outputs nothing -func (n *NoopCredentials) Output(c *config.Config, oc *oaws.Credential, ac *sts.Credentials) error { +func (n *NoopCredentials) Output(c *config.Config, oc oaws.Credential) error { // no-op return nil } diff --git a/internal/output/output.go b/internal/output/output.go index b6b9a43..94b13c5 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -21,31 +21,53 @@ import ( "os" "time" - "github.com/aws/aws-sdk-go/service/sts" oaws "github.com/okta/okta-aws-cli/internal/aws" "github.com/okta/okta-aws-cli/internal/config" ) // Outputter Interface to output AWS credentials in different formats. type Outputter interface { - Output(c *config.Config, oc *oaws.Credential, ac *sts.Credentials) error + Output(c *config.Config, oc oaws.Credential) error } // RenderAWSCredential Renders the credentials in the prescribed format. -func RenderAWSCredential(cfg *config.Config, oc *oaws.Credential, ac *sts.Credentials) error { +func RenderAWSCredential(cfg *config.Config, cc *oaws.CredentialContainer) error { var o Outputter switch cfg.Format() { case config.AWSCredentialsFormat: expiry := time.Now().Add(time.Duration(cfg.AWSSessionDuration()) * time.Second).Format(time.RFC3339) o = NewAWSCredentialsFile(cfg.LegacyAWSVariables(), cfg.ExpiryAWSVariables(), expiry) + cfc := &oaws.CredsFileCredential{ + AccessKeyID: cc.AccessKeyID, + SecretAccessKey: cc.SecretAccessKey, + SessionToken: cc.SessionToken, + } + cfc.SetProfile(cc.Profile) + return o.Output(cfg, cfc) case config.ProcessCredentialsFormat: o = NewProcessCredentials() + pc := &oaws.ProcessCredential{ + AccessKeyID: cc.AccessKeyID, + SecretAccessKey: cc.SecretAccessKey, + SessionToken: cc.SessionToken, + Expiration: cc.Expiration, + // See AWS docs: "Note As of this writing, the Version key must be set to 1. + // This might increment over time as the structure evolves." + Version: 1, + } + return o.Output(cfg, pc) case config.NoopFormat: o = NewNoopCredentials() + nc := &oaws.NoopCredential{} + return o.Output(cfg, nc) default: o = NewEnvVar(cfg.LegacyAWSVariables()) fmt.Fprintf(os.Stderr, "\n") + evc := &oaws.EnvVarCredential{ + AccessKeyID: cc.AccessKeyID, + SecretAccessKey: cc.SecretAccessKey, + SessionToken: cc.SessionToken, + } + return o.Output(cfg, evc) } - - return o.Output(cfg, oc, ac) } diff --git a/internal/output/process_credentials.go b/internal/output/process_credentials.go index 32c2622..5dae6bb 100644 --- a/internal/output/process_credentials.go +++ b/internal/output/process_credentials.go @@ -20,7 +20,6 @@ import ( "encoding/json" "fmt" - "github.com/aws/aws-sdk-go/service/sts" oaws "github.com/okta/okta-aws-cli/internal/aws" "github.com/okta/okta-aws-cli/internal/config" ) @@ -36,16 +35,10 @@ func NewProcessCredentials() *ProcessCredentials { // Output Satisfies the Outputter interface and outputs AWS credentials as JSON // to STDOUT -func (p *ProcessCredentials) Output(c *config.Config, oc *oaws.Credential, ac *sts.Credentials) error { - // See AWS docs: "Note As of this writing, the Version key must be set to 1. - // This might increment over time as the structure evolves." - poc := &oaws.ProcessCredential{ - Credential: *oc, - Expiration: ac.Expiration, - Version: 1, - } +func (p *ProcessCredentials) Output(c *config.Config, oc oaws.Credential) error { + pc := oc.(*oaws.ProcessCredential) - credJSON, err := json.MarshalIndent(poc, "", " ") + credJSON, err := json.MarshalIndent(pc, "", " ") if err != nil { return err } diff --git a/internal/utils/utils.go b/internal/utils/utils.go index 70d96b8..742e83b 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -33,4 +33,11 @@ const ( XOktaAWSCLIM2MOperation = "m2m" // PassThroughStringNewLineFMT string formatter to make lint happy PassThroughStringNewLineFMT = "%s\n" + + // AccessKeyID AWS creds access key ID + AccessKeyID = "AccessKeyID" + // SecretAccessKey AWS creds secret access key + SecretAccessKey = "SecretAccessKey" + // SessionToken AWS creds session tokne + SessionToken = "SessionToken" ) diff --git a/internal/webssoauth/webssoauth.go b/internal/webssoauth/webssoauth.go index 943a0ea..e8daba0 100644 --- a/internal/webssoauth/webssoauth.go +++ b/internal/webssoauth/webssoauth.go @@ -30,13 +30,16 @@ import ( "os/user" "path/filepath" "strings" + "sync" "time" "github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2/core" "github.com/AlecAivazis/survey/v2/terminal" "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/credentials" "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/iam" "github.com/aws/aws-sdk-go/service/sts" "github.com/cenkalti/backoff/v4" "github.com/mdp/qrterminal" @@ -298,26 +301,41 @@ func (w *WebSSOAuthentication) establishTokenWithFedAppID(clientID, fedAppID str return err } - iar, err := w.promptForIdpAndRole(idpRolesMap) - if err != nil { - return err - } + if !w.config.AllProfiles() { + iar, err := w.promptForIdpAndRole(idpRolesMap) + if err != nil { + return err + } - oc, ac, err := w.awsAssumeRoleWithSAML(iar, assertion) - if err != nil { - return err - } + cc, err := w.awsAssumeRoleWithSAML(iar, assertion) + if err != nil { + return err + } - err = output.RenderAWSCredential(w.config, oc, ac) - if err != nil { - return err - } + err = output.RenderAWSCredential(w.config, cc) + if err != nil { + return err + } - if w.config.Exec() { - exe, _ := exec.NewExec() - if err := exe.Run(oc); err != nil { + if w.config.Exec() { + exe, _ := exec.NewExec() + if err := exe.Run(cc); err != nil { + return err + } + } + } else { + ccch := w.fetchAllAWSCredentialsWithSAMLRole(idpRolesMap, assertion) + if err != nil { return err } + for cc := range ccch { + err = output.RenderAWSCredential(w.config, cc) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to render credential %s: %s\n", cc.Profile, err) + continue + } + } + } return nil @@ -325,7 +343,7 @@ func (w *WebSSOAuthentication) establishTokenWithFedAppID(clientID, fedAppID str // awsAssumeRoleWithSAML Get AWS Credentials with an STS Assume Role With SAML AWS // API call. -func (w *WebSSOAuthentication) awsAssumeRoleWithSAML(iar *idpAndRole, assertion string) (oc *oaws.Credential, ac *sts.Credentials, err error) { +func (w *WebSSOAuthentication) awsAssumeRoleWithSAML(iar *idpAndRole, assertion string) (cc *oaws.CredentialContainer, err error) { awsCfg := aws.NewConfig().WithHTTPClient(w.config.HTTPClient()) sess, err := session.NewSession(awsCfg) if err != nil { @@ -343,12 +361,38 @@ func (w *WebSSOAuthentication) awsAssumeRoleWithSAML(iar *idpAndRole, assertion return } - oc = &oaws.Credential{ + cc = &oaws.CredentialContainer{ AccessKeyID: *svcResp.Credentials.AccessKeyId, SecretAccessKey: *svcResp.Credentials.SecretAccessKey, SessionToken: *svcResp.Credentials.SessionToken, + Expiration: svcResp.Credentials.Expiration, + } + if w.config.Profile() != "" { + cc.Profile = w.config.Profile() + return cc, nil + } + + var profileName string + var roleName string + if _, after, found := strings.Cut(iar.role, "/"); found { + roleName = "-" + after + } + sessCopy := sess.Copy(&aws.Config{ + Credentials: credentials.NewStaticCredentials( + cc.AccessKeyID, + cc.SecretAccessKey, + cc.SessionToken, + ), + }) + if p, err := w.fetchAWSAccountAlias(sessCopy); err != nil { + fmt.Fprintf(os.Stderr, "unable to determine account alias, setting profile name to %q\n", iar.idp) + profileName = iar.idp + } else { + profileName = p } - return oc, svcResp.Credentials, nil + cc.Profile = fmt.Sprintf("%s%s", profileName, roleName) + + return cc, nil } // choiceFriendlyLabelRole returns a friendly choice for pretty printing Role @@ -995,3 +1039,45 @@ func (w *WebSSOAuthentication) consolePrint(format string, a ...any) { fmt.Fprintf(os.Stderr, format, a...) } + +// fetchAllAWSCredentialsWithSAMLRole Gets all AWS Credentials with an STS Assume Role with SAML AWS API call. +func (w *WebSSOAuthentication) fetchAllAWSCredentialsWithSAMLRole(idpRolesMap map[string][]string, assertion string) <-chan *oaws.CredentialContainer { + ccch := make(chan *oaws.CredentialContainer) + var wg sync.WaitGroup + + for idp, roles := range idpRolesMap { + for _, role := range roles { + iar := &idpAndRole{idp, role} + wg.Add(1) + go func() { + defer wg.Done() + cc, err := w.awsAssumeRoleWithSAML(iar, assertion) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to fetch AWS creds IdP %q, and Role %q, error:\n%+v\n", iar.idp, iar.role, err) + return + } + ccch <- cc + }() + } + } + + go func() { + wg.Wait() + close(ccch) + }() + + return ccch +} + +func (w *WebSSOAuthentication) fetchAWSAccountAlias(sess *session.Session) (string, error) { + svc := iam.New(sess) + input := &iam.ListAccountAliasesInput{} + svcResp, err := svc.ListAccountAliases(input) + if err != nil { + return "", err + } + if len(svcResp.AccountAliases) < 1 { + return "", fmt.Errorf("no alias configured for account") + } + return *svcResp.AccountAliases[0], nil +} From 914ae1b58d3fb3f0dce3af6066c8cbba550f4c79 Mon Sep 17 00:00:00 2001 From: Mike Mondragon Date: Tue, 10 Oct 2023 17:18:59 -0700 Subject: [PATCH 24/42] Update docs --- CHANGELOG.md | 28 ++++++++++++++++++++++++++++ README.md | 31 +++++++++++++++++++++++++++---- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de95935..91cfd8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,29 @@ $ okta-aws-cli web \ --exec -- aws ec2 describe-instances ``` +### (Complete) Collect all roles for an AWS Fed App (IdP) at once + +`okta-aws-cli web` will collect all available AWS IAM Roles for a given Okta AWS +Federation app (IdP) at once. This is a feature specific to writing the +`$HOME/.aws/credentials` file. Roles will be AWS account alias name (if STS list +aliases is available on the given role) then `-` then abbreviated role name. + + +``` +# AWS account alias "myorg", given IdP associated with "AWS Account Federation" +# and an app associated with two roles. + +$ okta-aws-cli web \ + --org-domain test.okta.com \ + --oidc-client-id 0oa5wyqjk6Wm148fE1d7 \ + --write-aws-credentials \ + --all-profiles + +? Choose an IdP: AWS Account Federation +Updated profile "myorg-S3-read" in credentials file "/Users/me/.aws/credentials". +Updated profile "myorg-S3-write" in credentials file "/Users/me/.aws/credentials". +``` + ### (expected) Alternate web browser open command The `web` command will open the system's default web browser when the @@ -71,6 +94,11 @@ $ okta-aws-cli web \ --open-browser-command "/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --args --profile-directory='Profile 1'" ``` +## 2.0.0-beta.3 (October 10, 2023) + +`okta-aws-cli web` can collect all roles to an AWS credentials file for a given +AWS Federation App (IdP) in one invocation of the CLI. + ## 2.0.0-beta.2 (October 5, 2023) Execute a subcommand directly from `okta-aws-cli` diff --git a/README.md b/README.md index e9a8494..cd2955c 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,12 @@ documentation](#configuration).* `okta-aws-cli` is a CLI program allowing Okta to act as an identity provider and retrieve AWS IAM temporary credentials for use in AWS CLI, AWS SDKs, and other -tools accessing the AWS API. There are two primary commands of operation: `web` - -combined human and device authorization; and `m2m` - headless authorization. +tools accessing the AWS API. There are two primary commands of operation: `web` +- combined human and device authorization; and `m2m` - headless authorization. `okta-aws-cli web` is native to the Okta Identity Engine and its authentication and device authorization flows. `okta-aws-cli web` is not compatible with Okta -Classic orgs. `okta-aws-cli m2m` makes use of public/private authorization and -OIDC. +Classic orgs. `okta-aws-cli m2m` makes use of private key (OAuth2) +authorization and OIDC. Example `okta-aws-cli` `web` command with environment variables (when command is missing *defaults* to `web`) output: @@ -390,6 +390,7 @@ These settings are all optional: | AWS IAM Identity Provider ARN | Preselects the IdP list to this preferred IAM Identity Provider. If there are other IdPs available they will not be listed. | `--aws-iam-idp [value]` | `OKTA_AWSCLI_IAM_IDP` | | Display QR Code | `true` if flag is present | `--qr-code` | `OKTA_AWSCLI_QR_CODE=true` | | Automatically open the activation URL with the system web browser | `true` if flag is present | `--open-browser` | `OKTA_AWSCLI_OPEN_BROWSER=true` | +| Gather all profiles for a given IdP (implies aws-credentials file output format)) | `true` if flag is present | `--all-profiles` | `OKTA_AWSCLI_OPEN_BROWSER=true` | #### Allowed Web SSO Client ID @@ -736,6 +737,28 @@ make_bucket failed: s3://no-access-example An error occurred (AccessDenied) when Error: exit status 1 ``` +### Collect all roles for an AWS Fed App (IdP) at once + +`okta-aws-cli web` will collect all available AWS IAM Roles for a given Okta AWS +Federation app (IdP) at once. This is a feature specific to writing the +`$HOME/.aws/credentials` file. Roles will be AWS account alias name (if STS list +aliases is available on the given role) then `-` then abbreviated role name. + +``` +# AWS account alias "myorg", given IdP associated with "AWS Account Federation" +# and an app associated with two roles. + +$ okta-aws-cli web \ + --org-domain test.okta.com \ + --oidc-client-id 0oa5wyqjk6Wm148fE1d7 \ + --write-aws-credentials \ + --all-profiles + +? Choose an IdP: AWS Account Federation +Updated profile "myorg-S3-read" in credentials file "/Users/me/.aws/credentials". +Updated profile "myorg-S3-write" in credentials file "/Users/me/.aws/credentials". +``` + ### Help ```shell From b382cf43ea689ae25209501c4403b6ff8bc5ad61 Mon Sep 17 00:00:00 2001 From: Mike Mondragon Date: Tue, 10 Oct 2023 17:23:29 -0700 Subject: [PATCH 25/42] Prep 2.0.0-beta.3 release --- internal/config/config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/config/config.go b/internal/config/config.go index 02d5feb..6b877f2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -39,7 +39,7 @@ func init() { const ( // Version app version - Version = "2.0.0-beta.2" + Version = "2.0.0-beta.3" // AWSCredentialsFormat format const AWSCredentialsFormat = "aws-credentials" From 6050a837c287e69b4815905b5e8ce4b3d4474515 Mon Sep 17 00:00:00 2001 From: Mike Mondragon Date: Wed, 11 Oct 2023 16:18:16 -0700 Subject: [PATCH 26/42] Factor out Credential interface and pass around a CredentialContainer. --- internal/aws/aws.go | 22 ------------------ internal/output/aws_credentials_file.go | 9 +++++-- internal/output/envvar.go | 10 ++++++-- internal/output/noop.go | 2 +- internal/output/output.go | 31 ++----------------------- internal/output/process_credentials.go | 12 ++++++++-- 6 files changed, 28 insertions(+), 58 deletions(-) diff --git a/internal/aws/aws.go b/internal/aws/aws.go index da8d1b2..6d3abd0 100644 --- a/internal/aws/aws.go +++ b/internal/aws/aws.go @@ -21,13 +21,6 @@ import ( "time" ) -// Credential Interface to represent AWS credentials in different formats. -type Credential interface { - // Trivial function to allow concrete structs to be represented by this - // interface. - IsCredential() bool -} - // CredentialContainer denormalized struct of all the values can be presented in // the different credentials formats type CredentialContainer struct { @@ -47,9 +40,6 @@ type EnvVarCredential struct { SessionToken string } -// IsCredential env var credential is a credential -func (e *EnvVarCredential) IsCredential() bool { return true } - // CredsFileCredential representation of an AWS credential for the AWS // credentials file type CredsFileCredential struct { @@ -60,9 +50,6 @@ type CredsFileCredential struct { profile string } -// IsCredential creds file credential is a credential -func (c *CredsFileCredential) IsCredential() bool { return true } - // SetProfile sets the profile name associated with this AWS credential. func (c *CredsFileCredential) SetProfile(p string) { c.profile = p } @@ -79,9 +66,6 @@ type ProcessCredential struct { Version int `json:"Version,omitempty"` } -// IsCredential process credential is a credential -func (c *ProcessCredential) IsCredential() bool { return true } - // MarshalJSON ensure Expiration date time is formatted RFC 3339 format. func (c *ProcessCredential) MarshalJSON() ([]byte, error) { type Alias ProcessCredential @@ -101,9 +85,3 @@ func (c *ProcessCredential) MarshalJSON() ([]byte, error) { } return json.Marshal(obj) } - -// NoopCredential Convenience representation for not printing credentials -type NoopCredential struct{} - -// IsCredential noop credential is a credential -func (n *NoopCredential) IsCredential() bool { return true } diff --git a/internal/output/aws_credentials_file.go b/internal/output/aws_credentials_file.go index a1a79f4..646d57a 100644 --- a/internal/output/aws_credentials_file.go +++ b/internal/output/aws_credentials_file.go @@ -183,8 +183,13 @@ func NewAWSCredentialsFile(legacyVars bool, expiryVars bool, expiry string) *AWS // Output Satisfies the Outputter interface and appends AWS credentials to // credentials file. -func (a *AWSCredentialsFile) Output(c *config.Config, oc oaws.Credential) error { - cfc := oc.(*oaws.CredsFileCredential) +func (a *AWSCredentialsFile) Output(c *config.Config, cc *oaws.CredentialContainer) error { + cfc := &oaws.CredsFileCredential{ + AccessKeyID: cc.AccessKeyID, + SecretAccessKey: cc.SecretAccessKey, + SessionToken: cc.SessionToken, + } + cfc.SetProfile(cc.Profile) if c.WriteAWSCredentials() { return a.writeConfig(c, cfc) } diff --git a/internal/output/envvar.go b/internal/output/envvar.go index 04d98ab..efc1138 100644 --- a/internal/output/envvar.go +++ b/internal/output/envvar.go @@ -18,6 +18,7 @@ package output import ( "fmt" + "os" "runtime" oaws "github.com/okta/okta-aws-cli/internal/aws" @@ -38,8 +39,13 @@ func NewEnvVar(legacyVars bool) *EnvVar { // Output Satisfies the Outputter interface and outputs AWS credentials as shell // export statements to STDOUT -func (e *EnvVar) Output(c *config.Config, oc oaws.Credential) error { - evc := oc.(*oaws.EnvVarCredential) +func (e *EnvVar) Output(c *config.Config, cc *oaws.CredentialContainer) error { + evc := &oaws.EnvVarCredential{ + AccessKeyID: cc.AccessKeyID, + SecretAccessKey: cc.SecretAccessKey, + SessionToken: cc.SessionToken, + } + fmt.Fprintf(os.Stderr, "\n") if runtime.GOOS == "windows" { fmt.Printf("setx AWS_ACCESS_KEY_ID %s\n", evc.AccessKeyID) fmt.Printf("setx AWS_SECRET_ACCESS_KEY %s\n", evc.SecretAccessKey) diff --git a/internal/output/noop.go b/internal/output/noop.go index 53c6c13..8581488 100644 --- a/internal/output/noop.go +++ b/internal/output/noop.go @@ -30,7 +30,7 @@ func NewNoopCredentials() *NoopCredentials { } // Output Satisfies the Outputter interface and outputs nothing -func (n *NoopCredentials) Output(c *config.Config, oc oaws.Credential) error { +func (n *NoopCredentials) Output(c *config.Config, cc *oaws.CredentialContainer) error { // no-op return nil } diff --git a/internal/output/output.go b/internal/output/output.go index 94b13c5..a78f6d0 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -17,8 +17,6 @@ package output import ( - "fmt" - "os" "time" oaws "github.com/okta/okta-aws-cli/internal/aws" @@ -27,7 +25,7 @@ import ( // Outputter Interface to output AWS credentials in different formats. type Outputter interface { - Output(c *config.Config, oc oaws.Credential) error + Output(c *config.Config, cc *oaws.CredentialContainer) error } // RenderAWSCredential Renders the credentials in the prescribed format. @@ -37,37 +35,12 @@ func RenderAWSCredential(cfg *config.Config, cc *oaws.CredentialContainer) error case config.AWSCredentialsFormat: expiry := time.Now().Add(time.Duration(cfg.AWSSessionDuration()) * time.Second).Format(time.RFC3339) o = NewAWSCredentialsFile(cfg.LegacyAWSVariables(), cfg.ExpiryAWSVariables(), expiry) - cfc := &oaws.CredsFileCredential{ - AccessKeyID: cc.AccessKeyID, - SecretAccessKey: cc.SecretAccessKey, - SessionToken: cc.SessionToken, - } - cfc.SetProfile(cc.Profile) - return o.Output(cfg, cfc) case config.ProcessCredentialsFormat: o = NewProcessCredentials() - pc := &oaws.ProcessCredential{ - AccessKeyID: cc.AccessKeyID, - SecretAccessKey: cc.SecretAccessKey, - SessionToken: cc.SessionToken, - Expiration: cc.Expiration, - // See AWS docs: "Note As of this writing, the Version key must be set to 1. - // This might increment over time as the structure evolves." - Version: 1, - } - return o.Output(cfg, pc) case config.NoopFormat: o = NewNoopCredentials() - nc := &oaws.NoopCredential{} - return o.Output(cfg, nc) default: o = NewEnvVar(cfg.LegacyAWSVariables()) - fmt.Fprintf(os.Stderr, "\n") - evc := &oaws.EnvVarCredential{ - AccessKeyID: cc.AccessKeyID, - SecretAccessKey: cc.SecretAccessKey, - SessionToken: cc.SessionToken, - } - return o.Output(cfg, evc) } + return o.Output(cfg, cc) } diff --git a/internal/output/process_credentials.go b/internal/output/process_credentials.go index 5dae6bb..028cd9e 100644 --- a/internal/output/process_credentials.go +++ b/internal/output/process_credentials.go @@ -35,8 +35,16 @@ func NewProcessCredentials() *ProcessCredentials { // Output Satisfies the Outputter interface and outputs AWS credentials as JSON // to STDOUT -func (p *ProcessCredentials) Output(c *config.Config, oc oaws.Credential) error { - pc := oc.(*oaws.ProcessCredential) +func (p *ProcessCredentials) Output(c *config.Config, cc *oaws.CredentialContainer) error { + pc := &oaws.ProcessCredential{ + AccessKeyID: cc.AccessKeyID, + SecretAccessKey: cc.SecretAccessKey, + SessionToken: cc.SessionToken, + Expiration: cc.Expiration, + // See AWS docs: "Note As of this writing, the Version key must be set to 1. + // This might increment over time as the structure evolves." + Version: 1, + } credJSON, err := json.MarshalIndent(pc, "", " ") if err != nil { From 87e2ce06a9a294bc680ee569b517cd86762c29ce Mon Sep 17 00:00:00 2001 From: Mike Mondragon Date: Wed, 11 Oct 2023 17:02:09 -0700 Subject: [PATCH 27/42] Improve All Profiles implementation. --- CHANGELOG.md | 4 ++-- README.md | 13 ++++++++----- internal/webssoauth/webssoauth.go | 26 +++++++++++++++++++------- 3 files changed, 29 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91cfd8d..31bb289 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,8 +65,8 @@ $ okta-aws-cli web \ --all-profiles ? Choose an IdP: AWS Account Federation -Updated profile "myorg-S3-read" in credentials file "/Users/me/.aws/credentials". -Updated profile "myorg-S3-write" in credentials file "/Users/me/.aws/credentials". +Updated profile "dev-org-s3-write" in credentials file "/Users/me/.aws/credentials". +Updated profile "prod-org-Admin-containers" in credentials file "/Users/me/.aws/credentials". ``` ### (expected) Alternate web browser open command diff --git a/README.md b/README.md index cd2955c..25ed029 100644 --- a/README.md +++ b/README.md @@ -390,7 +390,7 @@ These settings are all optional: | AWS IAM Identity Provider ARN | Preselects the IdP list to this preferred IAM Identity Provider. If there are other IdPs available they will not be listed. | `--aws-iam-idp [value]` | `OKTA_AWSCLI_IAM_IDP` | | Display QR Code | `true` if flag is present | `--qr-code` | `OKTA_AWSCLI_QR_CODE=true` | | Automatically open the activation URL with the system web browser | `true` if flag is present | `--open-browser` | `OKTA_AWSCLI_OPEN_BROWSER=true` | -| Gather all profiles for a given IdP (implies aws-credentials file output format)) | `true` if flag is present | `--all-profiles` | `OKTA_AWSCLI_OPEN_BROWSER=true` | +| Gather all profiles for all IdPs and Roles associated with an AWS Fed App (implies aws-credentials file output format)) | `true` if flag is present | `--all-profiles` | `OKTA_AWSCLI_OPEN_BROWSER=true` | #### Allowed Web SSO Client ID @@ -745,8 +745,9 @@ Federation app (IdP) at once. This is a feature specific to writing the aliases is available on the given role) then `-` then abbreviated role name. ``` -# AWS account alias "myorg", given IdP associated with "AWS Account Federation" -# and an app associated with two roles. +# Two Okta "AWS Account Federation" apps, one on AWS alias "prod-org", the other +# on alias "dev-org". Includes short name for the actual IAM IdPs and IAM Roles +# on the Fed App. $ okta-aws-cli web \ --org-domain test.okta.com \ @@ -755,8 +756,10 @@ $ okta-aws-cli web \ --all-profiles ? Choose an IdP: AWS Account Federation -Updated profile "myorg-S3-read" in credentials file "/Users/me/.aws/credentials". -Updated profile "myorg-S3-write" in credentials file "/Users/me/.aws/credentials". +Updated profile "dev-org-s3ops-read" in credentials file "/Users/me/.aws/credentials". +Updated profile "dev-org-s3ops-write" in credentials file "/Users/me/.aws/credentials". +Updated profile "prod-org-containerops-ec2-full" in credentials file "/Users/me/.aws/credentials". +Updated profile "prod-org-containerops-eks-full" in credentials file "/Users/me/.aws/credentials". ``` ### Help diff --git a/internal/webssoauth/webssoauth.go b/internal/webssoauth/webssoauth.go index e8daba0..0b4a275 100644 --- a/internal/webssoauth/webssoauth.go +++ b/internal/webssoauth/webssoauth.go @@ -203,6 +203,16 @@ AWS Federation App with --aws-acct-fed-app-id FED_APP_ID if len(apps) == 1 { // only one app, we don't need to prompt selection of idp / fed app fedAppID = apps[0].ID + } else if w.config.AllProfiles() { + // special case, we're going to run the table and get all profiles for all apps + errArr := []error{} + for _, app := range apps { + if err = w.establishTokenWithFedAppID(clientID, app.ID, at); err != nil { + errArr = append(errArr, err) + } + } + + return errors.Join(errArr...) } else { // Here, we do want to prompt for selection of the Fed App. // If the app is making use of "Role value pattern" on AWS settings we @@ -367,15 +377,17 @@ func (w *WebSSOAuthentication) awsAssumeRoleWithSAML(iar *idpAndRole, assertion SessionToken: *svcResp.Credentials.SessionToken, Expiration: svcResp.Credentials.Expiration, } - if w.config.Profile() != "" { + if !w.config.AllProfiles() && w.config.Profile() != "" { cc.Profile = w.config.Profile() return cc, nil } - var profileName string - var roleName string + var profileName, idpName, roleName string + if _, after, found := strings.Cut(iar.idp, "/"); found { + idpName = after + } if _, after, found := strings.Cut(iar.role, "/"); found { - roleName = "-" + after + roleName = after } sessCopy := sess.Copy(&aws.Config{ Credentials: credentials.NewStaticCredentials( @@ -385,12 +397,12 @@ func (w *WebSSOAuthentication) awsAssumeRoleWithSAML(iar *idpAndRole, assertion ), }) if p, err := w.fetchAWSAccountAlias(sessCopy); err != nil { - fmt.Fprintf(os.Stderr, "unable to determine account alias, setting profile name to %q\n", iar.idp) - profileName = iar.idp + fmt.Fprintf(os.Stderr, "unable to determine account alias, setting alias name to %q\n", "org") + profileName = "org" } else { profileName = p } - cc.Profile = fmt.Sprintf("%s%s", profileName, roleName) + cc.Profile = fmt.Sprintf("%s-%s-%s", profileName, idpName, roleName) return cc, nil } From cc51343cb1faba1f2149c2bd9c3d04926a58df91 Mon Sep 17 00:00:00 2001 From: Mike Mondragon Date: Thu, 12 Oct 2023 14:37:51 -0700 Subject: [PATCH 28/42] Customized open browser command. --- CHANGELOG.md | 67 ++++++++++---------------- README.md | 19 ++++++++ cmd/root/web/web.go | 7 +++ go.mod | 5 +- go.sum | 2 + internal/config/config.go | 26 +++++++++- internal/webssoauth/webssoauth.go | 30 +++++++++++- internal/webssoauth/webssoauth_test.go | 47 ++++++++++++++++++ 8 files changed, 158 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31bb289..a7d5c1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,13 +26,20 @@ in the naming convention for `okta-aws-cli` specific names. | `OKTA_OIDC_CLIENT_ID` | `OKTA_AWSCLI_OIDC_CLIENT_ID` | | `OKTA_AWS_ACCOUNT_FEDERATION_APP_ID` | `OKTA_AWSCLI_AWS_ACCOUNT_FEDERATION_APP_ID` | -### (Completed) Process credential provider output as JSON +### (completed) Process credential provider output as JSON Emits IAM temporary credentials as JSON in [process credentials](https://docs.aws.amazon.com/sdkref/latest/guide/feature-process-credentials.html) format. -### (Complete) Execute follow-on command +``` +# In $/.aws/config +[default] + # presumes OKTA_AWSCLI_* env vars are set + credential_process = okta-aws-cli m2m --format process-credentials +``` + +### (completed) Execute follow-on command Instead of scripting and/or eval'ing `okta-aws-cli` into a shell and then running another command have `okta-aws-cli` run the command directly passing @@ -46,7 +53,7 @@ $ okta-aws-cli web \ --exec -- aws ec2 describe-instances ``` -### (Complete) Collect all roles for an AWS Fed App (IdP) at once +### (completed) Collect all roles for an AWS Fed App (IdP) at once `okta-aws-cli web` will collect all available AWS IAM Roles for a given Okta AWS Federation app (IdP) at once. This is a feature specific to writing the @@ -64,12 +71,17 @@ $ okta-aws-cli web \ --write-aws-credentials \ --all-profiles -? Choose an IdP: AWS Account Federation -Updated profile "dev-org-s3-write" in credentials file "/Users/me/.aws/credentials". -Updated profile "prod-org-Admin-containers" in credentials file "/Users/me/.aws/credentials". +Web browser will open the following URL to begin Okta device authorization for the AWS CLI + +https://test.okta.com/activate?user_code=QHDMVQTZ + +Updated profile "devorg-idp1-role1" in credentials file "/Users/me/.aws/credentials". +Updated profile "devorg-idp1-role2" in credentials file "/Users/me/.aws/credentials". +Updated profile "devorg-idp2-role1" in credentials file "/Users/me/.aws/credentials". +Updated profile "prodorg-idp1-role1" in credentials file "/Users/me/.aws/credentials". ``` -### (expected) Alternate web browser open command +### (completed) Alternate web browser open command The `web` command will open the system's default web browser when the `--open-browser` flag is present. It is convenient to have the browser open on a @@ -81,8 +93,7 @@ system an alternate open command can be specified. $ okta-aws-cli web \ --org-domain test.okta.com \ --oidc-client-id 0oa5wyqjk6Wm148fE1d7 \ - --open-browser \ - --open-browser-command "open -na 'Google Chrome' --args -incognito" + --open-browser-command "open -na \"Google\ Chrome\" --args --incognito" ``` ``` @@ -90,10 +101,13 @@ $ okta-aws-cli web \ $ okta-aws-cli web \ --org-domain test.okta.com \ --oidc-client-id 0oa5wyqjk6Wm148fE1d7 \ - --open-browser \ - --open-browser-command "/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --args --profile-directory='Profile 1'" + --open-browser-command "/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --profile-directory=\"Profile\ 1\"" ``` +## 2.0.0-beta.4 (October 12, 2023) + +`okta-aws-cli web` can have it's open browser command customized. + ## 2.0.0-beta.3 (October 10, 2023) `okta-aws-cli web` can collect all roles to an AWS credentials file for a given @@ -103,46 +117,17 @@ AWS Federation App (IdP) in one invocation of the CLI. Execute a subcommand directly from `okta-aws-cli` -``` -$ okta-aws-cli m2m --format noop --exec -- aws s3 ls s3://example - PRE aaa/ -2023-03-08 16:01:01 4 a.log -``` - ## 2.0.0-beta.1 (October 2, 2023) Support for AWS CLI [process credential provider](https://docs.aws.amazon.com/sdkref/latest/guide/feature-process-credentials.html) -``` -# $/.aws/config -[default] -# presumes OKTA_AWSCLI_* env vars are set -credential_process = okta-aws-cli m2m --format process-credentials -``` - ## 2.0.0-beta.0 (September 29, 2023) -### New commands - -`okta-aws-cli`'s functions are encapsulated as (sub)commands e.g. `$ okta-aws-cli [sub-command]` - -| Command | Description | -|-----|-----| -| `web` | Human oriented retrieval of temporary IAM credentials through Okta authentication and device authorization. Note: if `okta-aws-cli` is not given a command it defaults to this original `web` command. | -| `m2m` | Machine/headless oriented retrieval of temporary IAM credentials through Okta authentication with a private key. | -| `debug` | Debug okta.yaml config file and exit. | - -### Environment variable name changes +`okta-aws-cli`'s functions are encapsulated as (sub)commands `web`, `m2m`, `debug` A small number of environment variable names have been renamed to be consistent in the naming convention for `okta-aws-cli` specific names. -| old name | new name | -|----------|----------| -| `OKTA_ORG_DOMAIN` | `OKTA_AWSCLI_ORG_DOMAIN` | -| `OKTA_OIDC_CLIENT_ID` | `OKTA_AWSCLI_OIDC_CLIENT_ID` | -| `OKTA_AWS_ACCOUNT_FEDERATION_APP_ID` | `OKTA_AWSCLI_AWS_ACCOUNT_FEDERATION_APP_ID` | - ## 1.2.2 (August 30, 2023) * Ensure evaluation of CLI flag for profile is in the same order as the other flags [#124](https://github.com/okta/okta-aws-cli/pull/124) diff --git a/README.md b/README.md index 25ed029..334410a 100644 --- a/README.md +++ b/README.md @@ -390,6 +390,7 @@ These settings are all optional: | AWS IAM Identity Provider ARN | Preselects the IdP list to this preferred IAM Identity Provider. If there are other IdPs available they will not be listed. | `--aws-iam-idp [value]` | `OKTA_AWSCLI_IAM_IDP` | | Display QR Code | `true` if flag is present | `--qr-code` | `OKTA_AWSCLI_QR_CODE=true` | | Automatically open the activation URL with the system web browser | `true` if flag is present | `--open-browser` | `OKTA_AWSCLI_OPEN_BROWSER=true` | +| Automatically open the activation URL with the given web browser command | Shell escaped browser command | `--open-browser-command [command]` | `OKTA_AWSCLI_OPEN_BROWSER_COMMAND` | | Gather all profiles for all IdPs and Roles associated with an AWS Fed App (implies aws-credentials file output format)) | `true` if flag is present | `--all-profiles` | `OKTA_AWSCLI_OPEN_BROWSER=true` | #### Allowed Web SSO Client ID @@ -762,6 +763,24 @@ Updated profile "prod-org-containerops-ec2-full" in credentials file "/Users/me/ Updated profile "prod-org-containerops-eks-full" in credentials file "/Users/me/.aws/credentials". ``` +### Alternative open browser command + +`okta-aws-cli web` can have it's open browser command customized. + +``` +# OSX examples, the device authorization URL is appended to the browser args. + +$ okta-aws-cli web \ + --oidc-client-id abc \ + --org-domain test.okta.com \ + --open-browser-command "/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --profile-directory=\"Profile\ 1\"" + +$ okta-aws-cli web \ + --oidc-client-id abc \ + --org-domain test.okta.com \ + --open-browser-command "open -na \"Google\ Chrome\" --args --incognito" +``` + ### Help ```shell diff --git a/cmd/root/web/web.go b/cmd/root/web/web.go index d1ce7b3..24fbf01 100644 --- a/cmd/root/web/web.go +++ b/cmd/root/web/web.go @@ -54,6 +54,13 @@ var ( Usage: "Automatically open the activation URL with the system web browser", EnvVar: config.OpenBrowserEnvVar, }, + { + Name: config.OpenBrowserCommandFlag, + Short: "m", + Value: "", + Usage: "Automatically open the activation URL with the given web browser command", + EnvVar: config.OpenBrowserCommandEnvVar, + }, { Name: config.AllProfilesFlag, Short: "k", diff --git a/go.mod b/go.mod index 8371208..1f2a2f6 100644 --- a/go.mod +++ b/go.mod @@ -23,7 +23,10 @@ require ( gopkg.in/yaml.v2 v2.4.0 ) -require golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e // indirect +require ( + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect + golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e // indirect +) require ( github.com/davecgh/go-spew v1.1.1 // indirect diff --git a/go.sum b/go.sum index bd5bd7e..af6a05d 100644 --- a/go.sum +++ b/go.sum @@ -123,6 +123,8 @@ github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLe github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= diff --git a/internal/config/config.go b/internal/config/config.go index 6b877f2..f3ee145 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -39,7 +39,7 @@ func init() { const ( // Version app version - Version = "2.0.0-beta.3" + Version = "2.0.0-beta.4" // AWSCredentialsFormat format const AWSCredentialsFormat = "aws-credentials" @@ -76,6 +76,8 @@ const ( OIDCClientIDFlag = "oidc-client-id" // OpenBrowserFlag cli flag const OpenBrowserFlag = "open-browser" + // OpenBrowserCommandFlag cli flag const + OpenBrowserCommandFlag = "open-browser-command" // OrgDomainFlag cli flag const OrgDomainFlag = "org-domain" // PrivateKeyFlag cli flag const @@ -139,6 +141,8 @@ const ( OldOktaAWSAccountFederationAppIDEnvVar = "OKTA_AWS_ACCOUNT_FEDERATION_APP_ID" // OpenBrowserEnvVar env var const OpenBrowserEnvVar = "OKTA_AWSCLI_OPEN_BROWSER" + // OpenBrowserCommandEnvVar env var const + OpenBrowserCommandEnvVar = "OKTA_AWSCLI_OPEN_BROWSER_COMMAND" // PrivateKeyEnvVar env var const PrivateKeyEnvVar = "OKTA_AWSCLI_PRIVATE_KEY" // KeyIDEnvVar env var const @@ -200,6 +204,7 @@ type Config struct { legacyAWSVariables bool oidcAppID string openBrowser bool + openBrowserCommand string orgDomain string privateKey string profile string @@ -228,6 +233,7 @@ type Attributes struct { LegacyAWSVariables bool OIDCAppID string OpenBrowser bool + OpenBrowserCommand string OrgDomain string PrivateKey string Profile string @@ -266,6 +272,7 @@ func NewConfig(attrs *Attributes) (*Config, error) { format: attrs.Format, legacyAWSVariables: attrs.LegacyAWSVariables, openBrowser: attrs.OpenBrowser, + openBrowserCommand: attrs.OpenBrowserCommand, privateKey: attrs.PrivateKey, keyID: attrs.KeyID, profile: attrs.Profile, @@ -315,6 +322,7 @@ func readConfig() (Attributes, error) { CacheAccessToken: viper.GetBool(CacheAccessTokenFlag), OIDCAppID: viper.GetString(OIDCClientIDFlag), OpenBrowser: viper.GetBool(OpenBrowserFlag), + OpenBrowserCommand: viper.GetString(OpenBrowserCommandFlag), OrgDomain: viper.GetString(OrgDomainFlag), PrivateKey: viper.GetString(PrivateKeyFlag), KeyID: viper.GetString(KeyIDFlag), @@ -433,6 +441,11 @@ func readConfig() (Attributes, error) { if !attrs.OpenBrowser { attrs.OpenBrowser = viper.GetBool(downCase(OpenBrowserEnvVar)) } + if attrs.OpenBrowserCommand == "" { + // open browser command implies open browser + attrs.OpenBrowser = true + attrs.OpenBrowserCommand = viper.GetString(downCase(OpenBrowserCommandEnvVar)) + } if !attrs.Debug { attrs.Debug = viper.GetBool(downCase(DebugEnvVar)) } @@ -678,6 +691,17 @@ func (c *Config) SetOpenBrowser(openBrowser bool) error { return nil } +// OpenBrowserCommand -- +func (c *Config) OpenBrowserCommand() string { + return c.openBrowserCommand +} + +// SetOpenBrowserCommand -- +func (c *Config) SetOpenBrowserCommand(openBrowserCommand string) error { + c.openBrowserCommand = openBrowserCommand + return nil +} + // OrgDomain -- func (c *Config) OrgDomain() string { return c.orgDomain diff --git a/internal/webssoauth/webssoauth.go b/internal/webssoauth/webssoauth.go index 0b4a275..231fb1e 100644 --- a/internal/webssoauth/webssoauth.go +++ b/internal/webssoauth/webssoauth.go @@ -27,6 +27,7 @@ import ( "net/http" "net/url" "os" + osexec "os/exec" "os/user" "path/filepath" "strings" @@ -42,6 +43,7 @@ import ( "github.com/aws/aws-sdk-go/service/iam" "github.com/aws/aws-sdk-go/service/sts" "github.com/cenkalti/backoff/v4" + "github.com/google/shlex" "github.com/mdp/qrterminal" brwsr "github.com/pkg/browser" "golang.org/x/net/html" @@ -703,12 +705,32 @@ func (w *WebSSOAuthentication) promptAuthentication(da *okta.DeviceAuthorization ` openMsg := "Open" if w.config.OpenBrowser() { - openMsg = "System web browser will open" + openMsg = "Web browser will open" } w.consolePrint(prompt, openMsg, qrCode, da.VerificationURIComplete) - if w.config.OpenBrowser() { + if w.config.OpenBrowserCommand() != "" { + bCmd := w.config.OpenBrowserCommand() + if bCmd != "" { + bArgs, err := splitArgs(bCmd) + if err != nil { + w.consolePrint("Browser command %q is invalid: %v\n", bCmd, err) + return + } + bArgs = append(bArgs, da.VerificationURIComplete) + cmd := osexec.Command(bArgs[0], bArgs[1:]...) + out, err := cmd.Output() + if _, ok := err.(*osexec.ExitError); ok { + w.consolePrint("Failed to open activation URL with given browser: %v\n", err) + w.consolePrint(" %s\n", strings.Join(bArgs, " ")) + } + if len(out) > 0 { + w.consolePrint("browser output:\n%s\n", string(out)) + } + } + + } else if w.config.OpenBrowser() { brwsr.Stdout = os.Stderr if err := brwsr.OpenURL(da.VerificationURIComplete); err != nil { w.consolePrint("Failed to open activation URL with system browser: %v\n", err) @@ -1093,3 +1115,7 @@ func (w *WebSSOAuthentication) fetchAWSAccountAlias(sess *session.Session) (stri } return *svcResp.AccountAliases[0], nil } + +func splitArgs(args string) ([]string, error) { + return shlex.Split(args) +} diff --git a/internal/webssoauth/webssoauth_test.go b/internal/webssoauth/webssoauth_test.go index cdd3bb7..fd3b936 100644 --- a/internal/webssoauth/webssoauth_test.go +++ b/internal/webssoauth/webssoauth_test.go @@ -19,6 +19,7 @@ package webssoauth import ( "net/http" "os" + "reflect" "testing" "github.com/okta/okta-aws-cli/internal/config" @@ -97,3 +98,49 @@ func setupTest(t *testing.T) (*config.Config, func(t *testing.T)) { return config, tearDown } + +func TestOpenBrowserCommandSplitArgs(t *testing.T) { + testCases := []struct { + name string + command string + expected []string + }{ + { + name: "osx open", + command: `open`, + expected: []string{"open"}, + }, + { + name: "osx open named app google chrome in incognito mode", + command: `open -na "Google Chrome" --args --incognito`, + expected: []string{ + "open", + "-na", + "Google Chrome", + "--args", + "--incognito"}, + }, + { + name: "osx open named app google chrome in incognito mode", + command: `/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --profile-directory=\"Person\ 1\"`, + expected: []string{ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + `--profile-directory="Person 1"`, + }, + }, + } + t.Parallel() + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result, err := splitArgs(tc.command) + if err != nil { + t.Errorf("didn't expect error for command %q: %+v", tc.command, err) + return + } + equal := reflect.DeepEqual(result, tc.expected) + if !equal { + t.Errorf("expected %+v to equal %+v", tc.expected, result) + } + }) + } +} From f69f2026116730efc06d56e6b19afaa97f3756e7 Mon Sep 17 00:00:00 2001 From: Mike Mondragon Date: Thu, 12 Oct 2023 15:04:34 -0700 Subject: [PATCH 29/42] qc'd! --- go.mod | 2 +- internal/webssoauth/webssoauth.go | 5 +++-- internal/webssoauth/webssoauth_test.go | 3 ++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 1f2a2f6..9deb431 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/okta/okta-aws-cli -go 1.19 +go 1.21 require ( github.com/AlecAivazis/survey/v2 v2.3.6 diff --git a/internal/webssoauth/webssoauth.go b/internal/webssoauth/webssoauth.go index 231fb1e..92ee3a3 100644 --- a/internal/webssoauth/webssoauth.go +++ b/internal/webssoauth/webssoauth.go @@ -399,8 +399,9 @@ func (w *WebSSOAuthentication) awsAssumeRoleWithSAML(iar *idpAndRole, assertion ), }) if p, err := w.fetchAWSAccountAlias(sessCopy); err != nil { - fmt.Fprintf(os.Stderr, "unable to determine account alias, setting alias name to %q\n", "org") - profileName = "org" + org := "org" + fmt.Fprintf(os.Stderr, "unable to determine account alias, setting alias name to %q\n", org) + profileName = org } else { profileName = p } diff --git a/internal/webssoauth/webssoauth_test.go b/internal/webssoauth/webssoauth_test.go index fd3b936..4a695fb 100644 --- a/internal/webssoauth/webssoauth_test.go +++ b/internal/webssoauth/webssoauth_test.go @@ -118,7 +118,8 @@ func TestOpenBrowserCommandSplitArgs(t *testing.T) { "-na", "Google Chrome", "--args", - "--incognito"}, + "--incognito", + }, }, { name: "osx open named app google chrome in incognito mode", From 4272e84c91e49076d071b0dea3137508122da0da Mon Sep 17 00:00:00 2001 From: Mike Mondragon Date: Thu, 12 Oct 2023 15:07:56 -0700 Subject: [PATCH 30/42] bump go version in GH actions --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cc880a8..67f1a0c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: - name: Setup Go uses: actions/setup-go@6edd4406fa81c3da01a34fa6f6343087c207a568 with: - go-version: 1.19 + go-version: 1.21 - name: Setup Go Tools run: make tools diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0b78992..c36011c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: name: Set up Go uses: actions/setup-go@6edd4406fa81c3da01a34fa6f6343087c207a568 #v3.5.0 with: - go-version: 1.19 + go-version: 1.21 - name: Import GPG key id: import_gpg From 09e43ead31708b096f9dcc997ba8086157d10da4 Mon Sep 17 00:00:00 2001 From: Mike Mondragon Date: Fri, 13 Oct 2023 11:13:46 -0700 Subject: [PATCH 31/42] ARN values used for friendly labels can be regular expressions. Closes #123 --- CHANGELOG.md | 47 ++++++++-- README.md | 23 +++-- internal/config/config.go | 2 +- internal/webssoauth/webssoauth.go | 70 ++++++++++----- internal/webssoauth/webssoauth_test.go | 120 +++++++++++++++++++++++++ 5 files changed, 224 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7d5c1e..a04a14b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,10 +53,10 @@ $ okta-aws-cli web \ --exec -- aws ec2 describe-instances ``` -### (completed) Collect all roles for an AWS Fed App (IdP) at once +### (completed) Collect all roles for all AWS Fed Apps (IdP) at once -`okta-aws-cli web` will collect all available AWS IAM Roles for a given Okta AWS -Federation app (IdP) at once. This is a feature specific to writing the +`okta-aws-cli web` will collect all available AWS IAM Roles for all Okta AWS +Federation apps (IdP) at once. This is a feature specific to writing the `$HOME/.aws/credentials` file. Roles will be AWS account alias name (if STS list aliases is available on the given role) then `-` then abbreviated role name. @@ -104,14 +104,51 @@ $ okta-aws-cli web \ --open-browser-command "/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --profile-directory=\"Profile\ 1\"" ``` +### (completed) Friendly label matching with regular expressions + +Friendly label matching for IdPs and Roles with `$HOME/.okta/okta.yaml` file can +be regular expressions. + +Example: your organization uses the same role naming convention across many +different AWS accounts: + +```yaml +--- +awscli: + idps: + "arn:aws:iam::123456789012:saml-provider/company-okta-idp": "Data Production" + "arn:aws:iam::012345678901:saml-provider/company-okta-idp": "Data Development" + "arn:aws:iam::901234567890:saml-provider/company-okta-idp": "Marketing Production" + "arn:aws:iam::890123456789:saml-provider/company-okta-idp": "Marketing Development" + roles: + "arn:aws:iam::.*:role/admin": "Admin" + "arn:aws:iam::.*:role/operator": "Ops" +``` + +``` +? Choose an IdP: +> Data Production + Data Development + Marketing Production + Marketing Development + +? Choose a Role: [Use arrows to move, type to filter] +> Admin + Ops +``` + +## 2.0.0-beta.5 (October 13, 2023) + +Friendly label matching for IdPs and Roles with `$HOME/.okta/okta.yaml` file can be regular expressions. + ## 2.0.0-beta.4 (October 12, 2023) `okta-aws-cli web` can have it's open browser command customized. ## 2.0.0-beta.3 (October 10, 2023) -`okta-aws-cli web` can collect all roles to an AWS credentials file for a given -AWS Federation App (IdP) in one invocation of the CLI. +`okta-aws-cli web` can collect all roles for all AWS Federation Apps (IdP) to an +AWS credentials file in one invocation of the CLI. ## 2.0.0-beta.2 (October 5, 2023) diff --git a/README.md b/README.md index 334410a..40deccd 100644 --- a/README.md +++ b/README.md @@ -463,7 +463,8 @@ When the operator has many AWS Federation apps listing the AWS IAM IdP ARNs can make it hard to read the list. The same can be said if an IdP has many IAM Role ARNs associated with it. To make this easier to manage the operator can create an Okta config file in YAML format at `$HOME/.okta/okta.yaml` that allows them -to set a map of alias labels for the ARN values. +to set a map of alias labels for the ARN values. Then ARN values for both IdPs +and Roles can also be evaluated are regular expressions (see example below). **NOTE**: The Okta language SDKs have standardized on using `$HOME/.okta/okta.yaml` as a configuration file and location. We will continue @@ -479,8 +480,8 @@ that practice with read-only friendly okta-aws-cli application values. Fed App 4 Label ? Choose a Role: [Use arrows to move, type to filter] -> Admin (arn:aws:iam::123456789012:role/admin) - Ops +> arn:aws:iam::123456789012:role/admin + arn:aws:iam::123456789012:role/ops ``` #### Example `$HOME/.okta/okta.yaml` @@ -494,10 +495,8 @@ awscli: "arn:aws:iam::901234567890:saml-provider/company-okta-idp": "Marketing Production" "arn:aws:iam::890123456789:saml-provider/company-okta-idp": "Marketing Development" roles: - "arn:aws:iam::123456789012:role/admin": "Prod Admin" - "arn:aws:iam::123456789012:role/operator": "Prod Ops" - "arn:aws:iam::012345678901:role/admin": "Dev Admin" - "arn:aws:iam::012345678901:role/operator": "Dev Ops" + "arn:aws:iam::.*:role/admin": "Admin" + "arn:aws:iam::.*:role/operator": "Ops" ``` #### After @@ -510,8 +509,8 @@ awscli: Marketing Development ? Choose a Role: [Use arrows to move, type to filter] -> Prod Admin - Prod Ops +> Admin + Ops ``` #### Debug okta.yaml @@ -738,10 +737,10 @@ make_bucket failed: s3://no-access-example An error occurred (AccessDenied) when Error: exit status 1 ``` -### Collect all roles for an AWS Fed App (IdP) at once +### Collect all roles for all AWS Fed Apps (IdP) at once -`okta-aws-cli web` will collect all available AWS IAM Roles for a given Okta AWS -Federation app (IdP) at once. This is a feature specific to writing the +`okta-aws-cli web` will collect all available AWS IAM Roles for all Okta AWS +Federation apps (IdP) at once. This is a feature specific to writing the `$HOME/.aws/credentials` file. Roles will be AWS account alias name (if STS list aliases is available on the given role) then `-` then abbreviated role name. diff --git a/internal/config/config.go b/internal/config/config.go index f3ee145..12ac0da 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -39,7 +39,7 @@ func init() { const ( // Version app version - Version = "2.0.0-beta.4" + Version = "2.0.0-beta.5" // AWSCredentialsFormat format const AWSCredentialsFormat = "aws-credentials" diff --git a/internal/webssoauth/webssoauth.go b/internal/webssoauth/webssoauth.go index 92ee3a3..9e0cc9d 100644 --- a/internal/webssoauth/webssoauth.go +++ b/internal/webssoauth/webssoauth.go @@ -30,6 +30,7 @@ import ( osexec "os/exec" "os/user" "path/filepath" + "regexp" "strings" "sync" "time" @@ -232,35 +233,47 @@ AWS Federation App with --aws-acct-fed-app-id FED_APP_ID // choiceFriendlyLabelIDP returns a friendly choice for pretty printing IDP // labels. alternative value is the default value to return if a friendly // determination can not be made. -func (w *WebSSOAuthentication) choiceFriendlyLabelIDP(alternative string, oktaConfig *config.OktaYamlConfig, arn string) string { - if oktaConfig == nil { - return alternative +func (w *WebSSOAuthentication) choiceFriendlyLabelIDP(alt, arn string, idps map[string]string) string { + if idps == nil { + return alt } - if label, ok := oktaConfig.AWSCLI.IDPS[arn]; ok { + if label, ok := idps[arn]; ok { if w.config.Debug() { w.consolePrint(" found IdP ARN %q having friendly label %q\n", arn, label) } return label - } else if w.config.Debug() { + } + // treat ARN values as regexps + for arnRegexp, label := range idps { + if ok, _ := regexp.MatchString(arnRegexp, arn); ok { + return label + } + } + + if w.config.Debug() { w.consolePrint(" did not find friendly label for IdP ARN\n") w.consolePrint(arnPrintFmt, arn) w.consolePrint(" in okta.yaml awscli.idps map:\n") - for arn, label := range oktaConfig.AWSCLI.IDPS { + for arn, label := range idps { w.consolePrint(arnLabelPrintFmt, arn, label) } } - return alternative + return alt } func (w *WebSSOAuthentication) selectFedApp(apps []*okta.Application) (string, error) { idps := make(map[string]*okta.Application) choices := make([]string, len(apps)) var selected string - oktaConfig, _ := w.config.OktaConfig() + var configIDPs map[string]string + oktaConfig, err := w.config.OktaConfig() + if err == nil { + configIDPs = oktaConfig.AWSCLI.IDPS + } for i, app := range apps { - choiceLabel := w.choiceFriendlyLabelIDP(app.Label, oktaConfig, app.Settings.App.IdentityProviderARN) + choiceLabel := w.choiceFriendlyLabelIDP(app.Label, app.Settings.App.IdentityProviderARN, configIDPs) // when OKTA_AWSCLI_IAM_IDP / --aws-iam-idp is set if w.config.AWSIAMIdP() == app.Settings.App.IdentityProviderARN { @@ -286,7 +299,7 @@ func (w *WebSSOAuthentication) selectFedApp(apps []*okta.Application) (string, e Message: chooseIDP, Options: choices, } - err := survey.AskOne(prompt, &selected, survey.WithValidator(survey.Required), stderrIsOutAskOpt) + err = survey.AskOne(prompt, &selected, survey.WithValidator(survey.Required), stderrIsOutAskOpt) if err != nil { return "", fmt.Errorf(askIDPError, err) } @@ -413,21 +426,30 @@ func (w *WebSSOAuthentication) awsAssumeRoleWithSAML(iar *idpAndRole, assertion // choiceFriendlyLabelRole returns a friendly choice for pretty printing Role // labels. The ARN default value to return if a friendly determination can not // be made. -func (w *WebSSOAuthentication) choiceFriendlyLabelRole(arn string, oktaConfig *config.OktaYamlConfig) string { - if oktaConfig == nil { +func (w *WebSSOAuthentication) choiceFriendlyLabelRole(arn string, roles map[string]string) string { + if roles == nil { return arn } - if label, ok := oktaConfig.AWSCLI.ROLES[arn]; ok { + if label, ok := roles[arn]; ok { if w.config.Debug() { w.consolePrint(" found Role ARN %q having friendly label %q\n", arn, label) } return label - } else if w.config.Debug() { + } + + // treat ARN values as regexps + for arnRegexp, label := range roles { + if ok, _ := regexp.MatchString(arnRegexp, arn); ok { + return label + } + } + + if w.config.Debug() { w.consolePrint(" did not find friendly label for Role ARN\n") w.consolePrint(arnPrintFmt, arn) w.consolePrint(" in okta.yaml awscli.roles map:\n") - for arn, label := range oktaConfig.AWSCLI.ROLES { + for arn, label := range roles { w.consolePrint(arnLabelPrintFmt, arn, label) } } @@ -436,14 +458,18 @@ func (w *WebSSOAuthentication) choiceFriendlyLabelRole(arn string, oktaConfig *c // promptForRole prompt operator for the AWS Role ARN given a slice of Role ARNs func (w *WebSSOAuthentication) promptForRole(idp string, roleARNs []string) (roleARN string, err error) { - oktaConfig, _ := w.config.OktaConfig() + oktaConfig, err := w.config.OktaConfig() + var configRoles map[string]string + if err == nil { + configRoles = oktaConfig.AWSCLI.ROLES + } if len(roleARNs) == 1 || w.config.AWSIAMRole() != "" { roleARN = w.config.AWSIAMRole() if len(roleARNs) == 1 { roleARN = roleARNs[0] } - roleLabel := w.choiceFriendlyLabelRole(roleARN, oktaConfig) + roleLabel := w.choiceFriendlyLabelRole(roleARN, configRoles) roleData := roleTemplateData{ Role: roleLabel, } @@ -460,7 +486,7 @@ func (w *WebSSOAuthentication) promptForRole(idp string, roleARNs []string) (rol promptRoles := []string{} labelsARNs := map[string]string{} for _, arn := range roleARNs { - roleLabel := w.choiceFriendlyLabelRole(arn, oktaConfig) + roleLabel := w.choiceFriendlyLabelRole(arn, configRoles) promptRoles = append(promptRoles, roleLabel) labelsARNs[roleLabel] = arn } @@ -488,6 +514,10 @@ func (w *WebSSOAuthentication) promptForRole(idp string, roleARNs []string) (rol // to pretty print out the IdP name again. func (w *WebSSOAuthentication) promptForIDP(idpARNs []string) (idpARN string, err error) { oktaConfig, _ := w.config.OktaConfig() + var configIDPs map[string]string + if err == nil { + configIDPs = oktaConfig.AWSCLI.IDPS + } if len(idpARNs) == 0 { return idpARN, errors.New(noIDPsError) @@ -502,7 +532,7 @@ func (w *WebSSOAuthentication) promptForIDP(idpARNs []string) (idpARN string, er return idpARN, nil } - idpLabel := w.choiceFriendlyLabelIDP(idpARN, oktaConfig, idpARN) + idpLabel := w.choiceFriendlyLabelIDP(idpARN, idpARN, configIDPs) idpData := idpTemplateData{ IDP: idpLabel, } @@ -517,7 +547,7 @@ func (w *WebSSOAuthentication) promptForIDP(idpARNs []string) (idpARN string, er idpChoices := make(map[string]string, len(idpARNs)) idpChoiceLabels := make([]string, len(idpARNs)) for i, arn := range idpARNs { - idpLabel := w.choiceFriendlyLabelIDP(arn, oktaConfig, arn) + idpLabel := w.choiceFriendlyLabelIDP(arn, arn, configIDPs) idpChoices[idpLabel] = arn idpChoiceLabels[i] = idpLabel } diff --git a/internal/webssoauth/webssoauth_test.go b/internal/webssoauth/webssoauth_test.go index 4a695fb..49c89a9 100644 --- a/internal/webssoauth/webssoauth_test.go +++ b/internal/webssoauth/webssoauth_test.go @@ -145,3 +145,123 @@ func TestOpenBrowserCommandSplitArgs(t *testing.T) { }) } } + +// choiceFriendlyLabelIDP(alt, arn string, idps *map[string]string) string { +func TestChoiceFriendlyLabelIDP(t *testing.T) { + config, teardownTest := setupTest(t) + defer teardownTest(t) + + w, err := NewWebSSOAuthentication(config) + require.NoError(t, err) + + testCases := []struct { + name string + alt string + arn string + idps map[string]string + expected string + }{ + { + name: "Okta app label", + alt: "My AWS Fed App", + arn: "arn:aws:iam::123:saml-provider/myidp", + idps: map[string]string{}, + expected: "My AWS Fed App", + }, + { + name: "nil map", + alt: "alternate", + arn: "arn", + idps: nil, + expected: "alternate", + }, + { + name: "friendly label", + alt: "alternate", + arn: "arn:aws:iam::123:saml-provider/myidp", + idps: map[string]string{ + "arn:aws:iam::123:saml-provider/youridp": "Your IdP", + "arn:aws:iam::123:saml-provider/myidp": "My IdP", + "arn:aws:iam::.*:saml-provider/aidp": "A IdP", + }, + expected: "My IdP", + }, + { + name: "regexp friendly label", + alt: "alternate", + arn: "arn:aws:iam::789:saml-provider/aidp", + idps: map[string]string{ + "arn:aws:iam::123:saml-provider/youridp": "YourIdP", + "arn:aws:iam::123:saml-provider/myidp": "My IdP", + "arn:aws:iam::.*:saml-provider/aidp": "A IdP", + }, + expected: "A IdP", + }, + } + t.Parallel() + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := w.choiceFriendlyLabelIDP(tc.alt, tc.arn, tc.idps) + if result != tc.expected { + t.Errorf("expected %q, got %q", tc.expected, result) + } + }) + } +} + +func TestChoiceFriendlyLabelRole(t *testing.T) { + config, teardownTest := setupTest(t) + defer teardownTest(t) + + w, err := NewWebSSOAuthentication(config) + require.NoError(t, err) + + testCases := []struct { + name string + arn string + roles map[string]string + expected string + }{ + { + name: "arn", + arn: "arn:aws:iam::123:role/rickrole", + roles: map[string]string{}, + expected: "arn:aws:iam::123:role/rickrole", + }, + { + name: "nil map", + arn: "arn:aws:iam::123:role/rickrole", + roles: nil, + expected: "arn:aws:iam::123:role/rickrole", + }, + { + name: "friendly label", + arn: "arn:aws:iam::123:role/rickrole", + roles: map[string]string{ + "arn:aws:iam::123:role/rocknrole": "Rock N Role", + "arn:aws:iam::123:role/rickrole": "Rick Role", + "arn:aws:iam::.*:role/never": "Never Gonna Give You Up", + }, + expected: "Rick Role", + }, + { + name: "regexp friendly label", + arn: "arn:aws:iam::789:role/never", + roles: map[string]string{ + "arn:aws:iam::123:role/rocknrole": "Rock N Role", + "arn:aws:iam::123:role/rickrole": "Rick Role", + "arn:aws:iam::.*:role/never": "Never Gonna Give You Up", + }, + expected: "Never Gonna Give You Up", + }, + } + t.Parallel() + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := w.choiceFriendlyLabelRole(tc.arn, tc.roles) + if result != tc.expected { + t.Errorf("expected %q, got %q", tc.expected, result) + } + }) + } +} From 41a1b8b5295570e1539269875dd68bc793df4b0d Mon Sep 17 00:00:00 2001 From: Mike Mondragon Date: Tue, 17 Oct 2023 09:07:33 -0700 Subject: [PATCH 32/42] formatting --- README.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 40deccd..a6b385a 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,14 @@ `okta-aws-cli`; double check your existing named variables in the [configuration documentation](#configuration).* -`okta-aws-cli` is a CLI program allowing Okta to act as an identity provider and -retrieve AWS IAM temporary credentials for use in AWS CLI, AWS SDKs, and other -tools accessing the AWS API. There are two primary commands of operation: `web` -- combined human and device authorization; and `m2m` - headless authorization. -`okta-aws-cli web` is native to the Okta Identity Engine and its authentication -and device authorization flows. `okta-aws-cli web` is not compatible with Okta -Classic orgs. `okta-aws-cli m2m` makes use of private key (OAuth2) -authorization and OIDC. +`okta-aws-cli` is a CLI program allowing Okta to act as an identity provider +and retrieve AWS IAM temporary credentials for use in AWS CLI, AWS SDKs, and +other tools accessing the AWS API. There are two primary commands of operation: +`web` -> combined human and device authorization; and `m3m` -> headless +authorization. `okta-aws-cli web` is native to the Okta Identity Engine and +its authentication and device authorization flows. `okta-aws-cli web` is not +compatible with Okta Classic orgs. `okta-aws-cli m2m` makes use of private key +(OAuth2) authorization and OIDC. Example `okta-aws-cli` `web` command with environment variables (when command is missing *defaults* to `web`) output: From c207ba2e908a2d620f7db34a63b25e879b849d97 Mon Sep 17 00:00:00 2001 From: Mark Lee Date: Fri, 27 Oct 2023 11:34:27 -0700 Subject: [PATCH 33/42] fix(cli): allow running --version without a subcommand --- cmd/root/root.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/root/root.go b/cmd/root/root.go index 57f9f6d..473f58a 100644 --- a/cmd/root/root.go +++ b/cmd/root/root.go @@ -198,7 +198,7 @@ func Execute(defaultCommand string) { cmdFound = true } if len(os.Args) >= 2 { - if arg := os.Args[1]; arg == "--help" || arg == "-h" || arg == "help" { + if arg := os.Args[1]; arg == "--help" || arg == "-h" || arg == "help" || arg == "--version" || arg == "-v" { cmdFound = true } } From 3561c6f30d4ce51e5dfeabbb80bcf9c491356dfd Mon Sep 17 00:00:00 2001 From: Mike Mondragon Date: Thu, 2 Nov 2023 11:23:58 -0700 Subject: [PATCH 34/42] Fix error when flawed okta.yaml can cause a panic if error not checked. --- internal/webssoauth/webssoauth.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/webssoauth/webssoauth.go b/internal/webssoauth/webssoauth.go index 9e0cc9d..6f58366 100644 --- a/internal/webssoauth/webssoauth.go +++ b/internal/webssoauth/webssoauth.go @@ -513,9 +513,8 @@ func (w *WebSSOAuthentication) promptForRole(idp string, roleARNs []string) (rol // If the fedApp has already been selected via an ask one survey we don't need // to pretty print out the IdP name again. func (w *WebSSOAuthentication) promptForIDP(idpARNs []string) (idpARN string, err error) { - oktaConfig, _ := w.config.OktaConfig() var configIDPs map[string]string - if err == nil { + if oktaConfig, cErr := w.config.OktaConfig(); cErr == nil { configIDPs = oktaConfig.AWSCLI.IDPS } From 1990996e6e22899614f0edf2126193ea344c29b1 Mon Sep 17 00:00:00 2001 From: Mike Mondragon Date: Thu, 2 Nov 2023 14:22:26 -0700 Subject: [PATCH 35/42] Add `--private-key-file` arg for m2m to designate a file to read the PK from. Closes #151 --- README.md | 3 ++- cmd/root/m2m/m2m.go | 11 +++++++++-- cmd/root/web/web.go | 2 +- internal/config/config.go | 22 ++++++++++++++++++++++ internal/flag/flag.go | 24 ++++++++++++++++++++---- internal/m2mauth/m2mauth.go | 17 +++++++++++++++-- 6 files changed, 69 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index a6b385a..6522141 100644 --- a/README.md +++ b/README.md @@ -453,7 +453,8 @@ These settings are optional unless marked otherwise: | Name | Description | Command line flag | ENV var and .env file value | |-----|-----|-----|-----| | Key ID (kid) (**required**) | The ID of the key stored in the service app | `--key-id [value]` | `OKTA_AWSCLI_KEY_ID` | -| Private Key (**required**) | PEM (pkcs#1 or pkcs#9) private key whose public key is stored on the service app | `--private-key [value]` | `OKTA_AWSCLI_PRIVATE_KEY` | +| Private Key (**required** in lieu of private key file) | PEM (pkcs#1 or pkcs#9) private key whose public key is stored on the service app | `--private-key [value]` | `OKTA_AWSCLI_PRIVATE_KEY` | +| Private Key File (**required** in lieu of private key) | File holding PEM (pkcs#1 or pkcs#9) private key whose public key is stored on the service app | `--private-key-file [value]` | `OKTA_AWSCLI_PRIVATE_KEY_FILE` | | Authorization Server ID | The ID of the Okta authorization server, set ID for a custom authorization server, will use default otherwise. Default `default` | `--authz-id [value]` | `OKTA_AWSCLI_AUTHZ_ID` | | Custom scope name | The custom scope established in the custom authorization server. Default `okta-m2m-access` | `--custom-scope [value]` | `OKTA_AWSCLI_CUSTOM_SCOPE` | diff --git a/cmd/root/m2m/m2m.go b/cmd/root/m2m/m2m.go index eeab36a..6a7df7f 100644 --- a/cmd/root/m2m/m2m.go +++ b/cmd/root/m2m/m2m.go @@ -37,9 +37,16 @@ var ( Name: config.PrivateKeyFlag, Short: "k", Value: "", - Usage: "Private Key", + Usage: "Private Key (string value)", EnvVar: config.PrivateKeyEnvVar, }, + { + Name: config.PrivateKeyFileFlag, + Short: "b", + Value: "", + Usage: "Private Key File", + EnvVar: config.PrivateKeyFileEnvVar, + }, { Name: config.CustomScopeFlag, Short: "m", @@ -55,7 +62,7 @@ var ( EnvVar: config.AuthzIDEnvVar, }, } - requiredFlags = []string{"org-domain", "oidc-client-id", "aws-iam-role", "key-id", "private-key"} + requiredFlags = []interface{}{"org-domain", "oidc-client-id", "aws-iam-role", "key-id", []string{"private-key", "private-key-file"}} ) // NewM2MCommand Sets up the m2m cobra sub command diff --git a/cmd/root/web/web.go b/cmd/root/web/web.go index 24fbf01..7df3b96 100644 --- a/cmd/root/web/web.go +++ b/cmd/root/web/web.go @@ -69,7 +69,7 @@ var ( EnvVar: config.AllProfilesEnvVar, }, } - requiredFlags = []string{"org-domain", "oidc-client-id"} + requiredFlags = []interface{}{"org-domain", "oidc-client-id"} ) // NewWebCommand Sets up the web cobra sub command diff --git a/internal/config/config.go b/internal/config/config.go index 12ac0da..1de0a66 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -82,6 +82,8 @@ const ( OrgDomainFlag = "org-domain" // PrivateKeyFlag cli flag const PrivateKeyFlag = "private-key" + // PrivateKeyFileFlag cli flag const + PrivateKeyFileFlag = "private-key-file" // KeyIDFlag cli flag const KeyIDFlag = "key-id" // ProfileFlag cli flag const @@ -145,6 +147,8 @@ const ( OpenBrowserCommandEnvVar = "OKTA_AWSCLI_OPEN_BROWSER_COMMAND" // PrivateKeyEnvVar env var const PrivateKeyEnvVar = "OKTA_AWSCLI_PRIVATE_KEY" + // PrivateKeyFileEnvVar env var const + PrivateKeyFileEnvVar = "OKTA_AWSCLI_PRIVATE_KEY_FILE" // KeyIDEnvVar env var const KeyIDEnvVar = "OKTA_AWSCLI_KEY_ID" // ProfileEnvVar env var const @@ -207,6 +211,7 @@ type Config struct { openBrowserCommand string orgDomain string privateKey string + privateKeyFile string profile string qrCode bool writeAWSCredentials bool @@ -236,6 +241,7 @@ type Attributes struct { OpenBrowserCommand string OrgDomain string PrivateKey string + PrivateKeyFile string Profile string QRCode bool WriteAWSCredentials bool @@ -274,6 +280,7 @@ func NewConfig(attrs *Attributes) (*Config, error) { openBrowser: attrs.OpenBrowser, openBrowserCommand: attrs.OpenBrowserCommand, privateKey: attrs.PrivateKey, + privateKeyFile: attrs.PrivateKeyFile, keyID: attrs.KeyID, profile: attrs.Profile, qrCode: attrs.QRCode, @@ -325,6 +332,7 @@ func readConfig() (Attributes, error) { OpenBrowserCommand: viper.GetString(OpenBrowserCommandFlag), OrgDomain: viper.GetString(OrgDomainFlag), PrivateKey: viper.GetString(PrivateKeyFlag), + PrivateKeyFile: viper.GetString(PrivateKeyFileFlag), KeyID: viper.GetString(KeyIDFlag), Profile: viper.GetString(ProfileFlag), QRCode: viper.GetBool(QRCodeFlag), @@ -376,6 +384,9 @@ func readConfig() (Attributes, error) { if attrs.PrivateKey == "" { attrs.PrivateKey = viper.GetString(downCase(PrivateKeyEnvVar)) } + if attrs.PrivateKeyFile == "" { + attrs.PrivateKeyFile = viper.GetString(downCase(PrivateKeyFileEnvVar)) + } if attrs.KeyID == "" { attrs.KeyID = viper.GetString(downCase(KeyIDEnvVar)) } @@ -724,6 +735,17 @@ func (c *Config) SetPrivateKey(privateKey string) error { return nil } +// PrivateKeyFile -- +func (c *Config) PrivateKeyFile() string { + return c.privateKeyFile +} + +// SetPrivateKeyFile -- +func (c *Config) SetPrivateKeyFile(privateKeyFile string) error { + c.privateKeyFile = privateKeyFile + return nil +} + // KeyID -- func (c *Config) KeyID() string { return c.keyID diff --git a/internal/flag/flag.go b/internal/flag/flag.go index 183278d..1317f8f 100644 --- a/internal/flag/flag.go +++ b/internal/flag/flag.go @@ -110,12 +110,28 @@ func MakeFlagBindings(cmd *cobra.Command, flags []Flag, persistent bool) { } // CheckRequiredFlags Checks if flags in the list are all set in Viper -func CheckRequiredFlags(flags []string) error { +func CheckRequiredFlags(flags []interface{}) error { unsetFlags := []string{} for _, f := range flags { - altName := altFlagName(f) - if !viper.GetViper().IsSet(f) && !viper.GetViper().IsSet(altName) { - unsetFlags = append(unsetFlags, fmt.Sprintf(" --%s", f)) + if arr, ok := f.([]string); ok { + found := false + for _, flag := range arr { + altName := altFlagName(flag) + if viper.GetViper().IsSet(flag) || viper.GetViper().IsSet(altName) { + found = true + } + } + + if !found { + unsetFlags = append(unsetFlags, fmt.Sprintf(" --(%s)", strings.Join(arr, " or "))) + } + + continue + } + flag := f.(string) + altName := altFlagName(flag) + if !viper.GetViper().IsSet(flag) && !viper.GetViper().IsSet(altName) { + unsetFlags = append(unsetFlags, fmt.Sprintf(" --%s", flag)) } } if len(unsetFlags) > 0 { diff --git a/internal/m2mauth/m2mauth.go b/internal/m2mauth/m2mauth.go index 31512eb..42f872c 100644 --- a/internal/m2mauth/m2mauth.go +++ b/internal/m2mauth/m2mauth.go @@ -27,6 +27,7 @@ import ( "io" "net/http" "net/url" + "os" "strings" "time" @@ -142,11 +143,23 @@ func (m *M2MAuthentication) awsAssumeRoleWithWebIdentity(at *okta.AccessToken) ( func (m *M2MAuthentication) createKeySigner() (jose.Signer, error) { signerOptions := (&jose.SignerOptions{}).WithHeader("kid", m.config.KeyID()) - priv := []byte(strings.ReplaceAll(m.config.PrivateKey(), `\n`, "\n")) + var priv []byte + switch { + case m.config.PrivateKey() != "": + priv = []byte(strings.ReplaceAll(m.config.PrivateKey(), `\n`, "\n")) + case m.config.PrivateKeyFile() != "": + var err error + priv, err = os.ReadFile(m.config.PrivateKeyFile()) + if err != nil { + return nil, err + } + default: + return nil, errors.New("either private key or private key file is a required m2m argument") + } privPem, _ := pem.Decode(priv) if privPem == nil { - return nil, errors.New("invalid private key") + return nil, errors.New("invalid private key value") } if privPem.Type == "RSA PRIVATE KEY" { From a10827be478837cd4558cdd3b148906cf588e6e7 Mon Sep 17 00:00:00 2001 From: Mike Mondragon Date: Thu, 2 Nov 2023 14:30:15 -0700 Subject: [PATCH 36/42] Prep v2.0.0-beta.6 release --- CHANGELOG.md | 6 ++++++ internal/config/config.go | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a04a14b..bd63dde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -137,6 +137,12 @@ awscli: Ops ``` +## 2.0.0-beta.6 (November 2, 2023) + +* New m2m flag `--private-key-file` read private key from file +* Bug fix panic when okta.yaml is not established (it doesn't have to be established either) +* Bug fix allowing `--version` w/o sub command [#150](https://github.com/okta/okta-aws-cli/pull/150), thanks [@malept](https://github.com/malept)! + ## 2.0.0-beta.5 (October 13, 2023) Friendly label matching for IdPs and Roles with `$HOME/.okta/okta.yaml` file can be regular expressions. diff --git a/internal/config/config.go b/internal/config/config.go index 1de0a66..65da9fe 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -39,7 +39,7 @@ func init() { const ( // Version app version - Version = "2.0.0-beta.5" + Version = "2.0.0-beta.6" // AWSCredentialsFormat format const AWSCredentialsFormat = "aws-credentials" From e74230c01791fb19fccad6db8b386f2b0629c66e Mon Sep 17 00:00:00 2001 From: Mike Mondragon Date: Tue, 21 Nov 2023 10:05:46 -0800 Subject: [PATCH 37/42] Update README.md Remove confusing feature declaration. --- README.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/README.md b/README.md index 6522141..8f06b88 100644 --- a/README.md +++ b/README.md @@ -192,10 +192,6 @@ group in step 2. The "Admin" button will be visible on the Okta dashboard of non-admin users but they will receive a 403 if they attempt to open the Admin UI. -It is on our feature backlog to get support into the Okta API to allow the -multiple AWS Fed apps feature into okta-aws-cli without needing this work -around using a custom admin role. - ## M2M Command ```shell From b573dc71dd286ad96e22a58ab430241395bf061a Mon Sep 17 00:00:00 2001 From: Mike Mondragon Date: Wed, 24 Jan 2024 13:49:55 -0800 Subject: [PATCH 38/42] Don't swallow the whole open browser command error --- internal/webssoauth/webssoauth.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/webssoauth/webssoauth.go b/internal/webssoauth/webssoauth.go index 6f58366..e92ede5 100644 --- a/internal/webssoauth/webssoauth.go +++ b/internal/webssoauth/webssoauth.go @@ -751,7 +751,7 @@ func (w *WebSSOAuthentication) promptAuthentication(da *okta.DeviceAuthorization bArgs = append(bArgs, da.VerificationURIComplete) cmd := osexec.Command(bArgs[0], bArgs[1:]...) out, err := cmd.Output() - if _, ok := err.(*osexec.ExitError); ok { + if err != nil { w.consolePrint("Failed to open activation URL with given browser: %v\n", err) w.consolePrint(" %s\n", strings.Join(bArgs, " ")) } @@ -759,7 +759,6 @@ func (w *WebSSOAuthentication) promptAuthentication(da *okta.DeviceAuthorization w.consolePrint("browser output:\n%s\n", string(out)) } } - } else if w.config.OpenBrowser() { brwsr.Stdout = os.Stderr if err := brwsr.OpenURL(da.VerificationURIComplete); err != nil { From 6d8f939167d9af2f166abb407d2b8225f5b3a4ad Mon Sep 17 00:00:00 2001 From: Mike Mondragon Date: Thu, 25 Jan 2024 10:02:33 -0800 Subject: [PATCH 39/42] prep v2 GA release --- CHANGELOG.md | 42 ++++++++++++++++++++++++++++++++---------- README.md | 30 ++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd63dde..10007f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,10 @@ # Changelog -## 2.0.0 (TBD) +## 2.0.0 (January 25, 2024) -NOTE: These are the expected 2.0.0 release items; see 2.0.0-beta.X notes for -incremental changes during beta development +V2 GA Release 🎉🎉 -### (completed) New commands +### New commands `okta-aws-cli`'s functions are encapsulated as (sub)commands e.g. `$ okta-aws-cli [sub-command]` @@ -15,7 +14,7 @@ incremental changes during beta development | `m2m` | Machine/headless oriented retrieval of temporary IAM credentials through Okta authentication with a private key. | | `debug` | Debug okta.yaml config file and exit. | -### (completed) Environment variable name changes +### Environment variable name changes A small number of environment variable names have been renamed to be consistent in the naming convention for `okta-aws-cli` specific names. @@ -26,7 +25,7 @@ in the naming convention for `okta-aws-cli` specific names. | `OKTA_OIDC_CLIENT_ID` | `OKTA_AWSCLI_OIDC_CLIENT_ID` | | `OKTA_AWS_ACCOUNT_FEDERATION_APP_ID` | `OKTA_AWSCLI_AWS_ACCOUNT_FEDERATION_APP_ID` | -### (completed) Process credential provider output as JSON +### Process credential provider output as JSON Emits IAM temporary credentials as JSON in [process credentials](https://docs.aws.amazon.com/sdkref/latest/guide/feature-process-credentials.html) @@ -39,7 +38,7 @@ format. credential_process = okta-aws-cli m2m --format process-credentials ``` -### (completed) Execute follow-on command +### Execute follow-on command Instead of scripting and/or eval'ing `okta-aws-cli` into a shell and then running another command have `okta-aws-cli` run the command directly passing @@ -53,7 +52,7 @@ $ okta-aws-cli web \ --exec -- aws ec2 describe-instances ``` -### (completed) Collect all roles for all AWS Fed Apps (IdP) at once +### Collect all roles for all AWS Fed Apps (IdP) at once `okta-aws-cli web` will collect all available AWS IAM Roles for all Okta AWS Federation apps (IdP) at once. This is a feature specific to writing the @@ -81,7 +80,7 @@ Updated profile "devorg-idp2-role1" in credentials file "/Users/me/.aws/credenti Updated profile "prodorg-idp1-role1" in credentials file "/Users/me/.aws/credentials". ``` -### (completed) Alternate web browser open command +### Alternate web browser open command The `web` command will open the system's default web browser when the `--open-browser` flag is present. It is convenient to have the browser open on a @@ -104,7 +103,30 @@ $ okta-aws-cli web \ --open-browser-command "/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --profile-directory=\"Profile\ 1\"" ``` -### (completed) Friendly label matching with regular expressions +Windows examples +``` +> okta-aws-cli web \ + --oidc-client-id abc \ + --org-domain test.okta.com \ + --open-browser-command "cmd.exe /C start msedge" + +> okta-aws-cli web \ + --oidc-client-id abc \ + --org-domain test.okta.com \ + --open-browser-command "cmd.exe /C start chrome" + +> okta-aws-cli web \ + --oidc-client-id abc \ + --org-domain test.okta.com \ + --open-browser-command "cmd.exe /C start chrome --incognito" + +> okta-aws-cli web \ + --oidc-client-id abc \ + --org-domain test.okta.com \ + --open-browser-command "cmd.exe /C start chrome --profile-directory=\"Profile\ 1\"" +``` + +### Friendly label matching with regular expressions Friendly label matching for IdPs and Roles with `$HOME/.okta/okta.yaml` file can be regular expressions. diff --git a/README.md b/README.md index 8f06b88..34bf1c6 100644 --- a/README.md +++ b/README.md @@ -763,6 +763,7 @@ Updated profile "prod-org-containerops-eks-full" in credentials file "/Users/me/ `okta-aws-cli web` can have it's open browser command customized. +#### OSX / MacBook ``` # OSX examples, the device authorization URL is appended to the browser args. @@ -777,6 +778,35 @@ $ okta-aws-cli web \ --open-browser-command "open -na \"Google\ Chrome\" --args --incognito" ``` +#### Windows + +``` +REM Windows examples, the device authorization URL is appended to the browser +REM args using cmd.exe with the run command flag /C used to spawn the browser +REM that is installed on the host OS e.g. medge, chrome, firefox . Additional +REM arguments can be passed on to the browser command that are valid for it. + +> okta-aws-cli web \ + --oidc-client-id abc \ + --org-domain test.okta.com \ + --open-browser-command "cmd.exe /C start msedge" + +> okta-aws-cli web \ + --oidc-client-id abc \ + --org-domain test.okta.com \ + --open-browser-command "cmd.exe /C start chrome" + +> okta-aws-cli web \ + --oidc-client-id abc \ + --org-domain test.okta.com \ + --open-browser-command "cmd.exe /C start chrome --incognito" + +> okta-aws-cli web \ + --oidc-client-id abc \ + --org-domain test.okta.com \ + --open-browser-command "cmd.exe /C start chrome --profile-directory=\"Profile\ 1\"" +``` + ### Help ```shell From a5afaa5babc180930990bf3091044395c3356c31 Mon Sep 17 00:00:00 2001 From: Mike Mondragon Date: Thu, 25 Jan 2024 10:09:53 -0800 Subject: [PATCH 40/42] go mod tidy --- go.mod | 6 ++---- go.sum | 7 +++++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 9deb431..07b7dad 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ 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/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/logrusorgru/aurora v2.0.3+incompatible github.com/mattn/go-isatty v0.0.16 github.com/mdp/qrterminal v1.0.1 @@ -23,10 +24,7 @@ require ( gopkg.in/yaml.v2 v2.4.0 ) -require ( - github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect - golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e // indirect -) +require golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e // indirect require ( github.com/davecgh/go-spew v1.1.1 // indirect diff --git a/go.sum b/go.sum index af6a05d..b20ef21 100644 --- a/go.sum +++ b/go.sum @@ -67,6 +67,7 @@ github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5y github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE= +github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= @@ -109,6 +110,7 @@ github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= @@ -152,9 +154,11 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 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/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= @@ -189,6 +193,7 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/afero v1.9.4 h1:Sd43wM1IWz/s1aVXdOBkjJvuP8UdyqioeE4AmM0QsBs= github.com/spf13/afero v1.9.4/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y= @@ -432,8 +437,6 @@ golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= -golang.org/x/tools v0.1.12 h1:VveCTK38A2rkS8ZqFY25HIDFscX5X9OoEhJd3quQmXU= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= From c9fb91262d0cbeb6c1ac15f2b74d29bd0bfa8856 Mon Sep 17 00:00:00 2001 From: Mike Mondragon Date: Thu, 25 Jan 2024 10:22:53 -0800 Subject: [PATCH 41/42] Yell that m2m is not for human use. --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 34bf1c6..9c1519f 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ format. | Command | Description | |-----|-----| | `web` | Human oriented retrieval of temporary IAM credentials through Okta authentication and device authorization. Note: if `okta-aws-cli` is not given a command it defaults to this original `web` command. | -| `m2m` | Machine/headless oriented retrieval of temporary IAM credentials through Okta authentication with a private key. | +| `m2m` | Machine/headless oriented retrieval of temporary IAM credentials through Okta authentication with a private key. IMPORTANT! This a not a feature intended for a human use case. Be sure to use industry state of the art secrets management techniques with the private key. | | `debug` | Debug okta.yaml config file and exit. | ## Web Command @@ -194,6 +194,10 @@ they will receive a 403 if they attempt to open the Admin UI. ## M2M Command +***IMPORTANT! This a not a feature intended for a human use case. Be sure to +use industry state of the art secrets management techniques with the private +key.*** + ```shell # This example presumes its arguments are set as environment variables such as # one may find in a headless CI environment. From b1d1e62acbe852496b61f11a273afdfc4d75e098 Mon Sep 17 00:00:00 2001 From: Mike Mondragon Date: Thu, 25 Jan 2024 10:25:43 -0800 Subject: [PATCH 42/42] go mod tidy --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index c4dceb6..fbf234a 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,7 @@ require ( github.com/stretchr/testify v1.8.2 github.com/tidwall/pretty v1.2.0 golang.org/x/net v0.7.0 - golang.org/x/sys v0.5.0 + golang.org/x/sys v0.13.0 gopkg.in/dnaeon/go-vcr.v3 v3.1.2 gopkg.in/ini.v1 v1.67.0 gopkg.in/square/go-jose.v2 v2.6.0 diff --git a/go.sum b/go.sum index 13292af..61df305 100644 --- a/go.sum +++ b/go.sum @@ -308,8 +308,8 @@ golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= -golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=