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/.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 diff --git a/CHANGELOG.md b/CHANGELOG.md index f3aa3fe..10007f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,198 @@ # Changelog +## 2.0.0 (January 25, 2024) + +V2 GA Release 🎉🎉 + +### 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` | + +### 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. + +``` +# In $/.aws/config +[default] + # presumes OKTA_AWSCLI_* env vars are set + credential_process = okta-aws-cli m2m --format process-credentials +``` + +### 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 "--" arguments terminator as another command. +$ okta-aws-cli web \ + --org-domain test.okta.com \ + --oidc-client-id 0oa5wyqjk6Wm148fE1d7 \ + --exec -- aws ec2 describe-instances +``` + +### 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 +`$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 + +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". +``` + +### 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 an alternate open command can be specified. + +``` +# Use macOS open to open browser in Chrome incognito mode +$ okta-aws-cli web \ + --org-domain test.okta.com \ + --oidc-client-id 0oa5wyqjk6Wm148fE1d7 \ + --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-command "/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --profile-directory=\"Profile\ 1\"" +``` + +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. + +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.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. + +## 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 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) + +Execute a subcommand directly from `okta-aws-cli` + +## 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) + +## 2.0.0-beta.0 (September 29, 2023) + +`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. + ## 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 944cb0b..9c1519f 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,29 @@ # 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. +**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` -> 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: ```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 +36,89 @@ 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 + + - [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 Command](#m2m-command) + - [M2M Command Requirements](#m2m-command-requirements) + - [Configuration](#configuration) + - [Global settings](#global-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) + - [Operation](#operation) + - [Comparison](#comparison) + - [Development](#development) + - [Contributing](#contributing) + - [References](#references) + +## 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. 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 -## Requirements +```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 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 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 +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 Command Requirements + +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 @@ -70,6 +133,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 +154,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 +172,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. @@ -139,45 +192,132 @@ 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. -## 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 Command -## 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/) +***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.*** -``` -$ brew install okta-aws-cli +```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-m2m-access" +# export OKTA_AWSCLI_KEY_ID="kid-rock" +# export OKTA_AWSCLI_PRIVATE_KEY="... long string with new lines ..." +# export OKTA_AWSCLI_AUTHZ_ID="aus8w23r13NvyUwln1d7" + +$ 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. +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 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. -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 -``` +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 Command Requirements + +M2M is an integration of: + +- [Okta API service app](https://developer.okta.com/docs/guides/implement-oauth-for-okta-serviceapp/main/) +- 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) + +#### 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 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 + +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 +[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-m2m-access`, but if it isn't the CLI flag +`--custom-scope` argument trains the CLI for the scope to use. + +#### Okta Access Policy + +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. + +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-m2m-acces` 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 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. 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 **NOTE**: If your AWS IAM IdP is in a non-commercial region, such as GovCloud, the environmental variable @@ -191,55 +331,69 @@ 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 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_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, 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` | +| (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`. 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` | +| 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 + +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 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` | +| 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 This is the "Allowed Web SSO Client" value from the "Sign On" settings of an [AWS Account @@ -250,7 +404,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/) @@ -259,27 +413,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 +export OKTA_AWSCLI_ORG_DOMAIN=test.okta.com +export OKTA_AWSCLI_OIDC_CLIENT_ID=0oa5wyqjk6Wm148fE1d7 ``` -### `.env` file variables example +#### `.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 +#### 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 ``` @@ -287,18 +441,31 @@ $ 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 command settings + +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** 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` | + ### Friendly IdP and Role menu labels 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 @@ -314,8 +481,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` @@ -329,10 +496,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 @@ -345,8 +510,8 @@ awscli: Marketing Development ? Choose a Role: [Use arrows to move, type to filter] -> Prod Admin - Prod Ops +> Admin + Ops ``` #### Debug okta.yaml @@ -385,6 +550,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 @@ -396,14 +600,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 @@ -432,16 +639,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 @@ -453,7 +656,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. @@ -479,6 +682,135 @@ 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` + +### 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 +``` + +### 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 +`$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. + +``` +# 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 \ + --oidc-client-id 0oa5wyqjk6Wm148fE1d7 \ + --write-aws-credentials \ + --all-profiles + +? Choose an IdP: AWS Account Federation +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". +``` + +### Alternative open browser command + +`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. + +$ 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" +``` + +#### 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 @@ -516,7 +848,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 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..c21ca82 --- /dev/null +++ b/cmd/root/debug/debug.go @@ -0,0 +1,49 @@ +/* + * 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" +) + +// NewDebugCommand Sets up the debug cobra sub command +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..6a7df7f --- /dev/null +++ b/cmd/root/m2m/m2m.go @@ -0,0 +1,95 @@ +/* + * 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 ( + "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", + Value: "", + 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", + Value: "", + Usage: "Custom Scope", + EnvVar: config.CustomScopeEnvVar, + }, + { + Name: config.AuthzIDFlag, + Short: "u", + Value: "", + Usage: "Custom Authorization Server ID", + EnvVar: config.AuthzIDEnvVar, + }, + } + 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 +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 = cliFlag.CheckRequiredFlags(requiredFlags) + if err != nil { + return err + } + + m2ma, err := m2mauth.NewM2MAuthentication(config) + if err != nil { + return err + } + return m2ma.EstablishIAMCredentials() + }, + } + + cliFlag.MakeFlagBindings(cmd, flags, false) + + return cmd +} diff --git a/cmd/root/root.go b/cmd/root/root.go index 662ab41..473f58a 100644 --- a/cmd/root/root.go +++ b/cmd/root/root.go @@ -17,245 +17,198 @@ 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" +var ( + flags []cliFlag.Flag + rootCmd *cobra.Command ) -type flag struct { - name string - short string - value interface{} - usage string - envVar string -} - -var flags []flag - func init() { var awsCredentialsFilename string if home, err := os.UserHomeDir(); err == nil { 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, - }, - { - name: config.AWSIAMIdPFlag, - short: "i", - value: "", - usage: "Preset IAM Identity Provider ARN", - envVar: config.AWSIAMIdPEnvVar, - }, + flags = []cliFlag.Flag{ { - name: config.AWSIAMRoleFlag, - short: "r", - value: "", - usage: "Preset IAM Role ARN", - envVar: config.AWSIAMRoleEnvVar, + Name: config.OrgDomainFlag, + Short: "o", + Value: "", + Usage: "Okta Org Domain", + EnvVar: config.OktaOrgDomainEnvVar, }, { - name: config.SessionDurationFlag, - short: "s", - value: "", - usage: "Session duration for role.", - envVar: config.AWSSessionDurationEnvVar, + Name: config.OIDCClientIDFlag, + Short: "c", + Value: "", + Usage: "OIDC Client ID - web: OIDC native application, m2m: API service application", + EnvVar: config.OktaOIDCClientIDEnvVar, }, { - name: config.ProfileFlag, - short: "p", - value: "", - usage: "AWS Profile", - envVar: config.ProfileEnvVar, + Name: config.AWSIAMRoleFlag, + Short: "r", + Value: "", + Usage: "Preset IAM Role ARN", + EnvVar: config.AWSIAMRoleEnvVar, }, { - name: config.FormatFlag, - short: "f", - value: "", - usage: "Output format. [env-var|aws-credentials]", - envVar: config.FormatEnvVar, + Name: config.SessionDurationFlag, + Short: "s", + Value: "", + Usage: "Session duration for role.", + EnvVar: config.AWSSessionDurationEnvVar, }, { - name: config.QRCodeFlag, - short: "q", - value: false, - usage: "Print QR Code of activation URL", - envVar: config.QRCodeEnvVar, + Name: config.ProfileFlag, + Short: "p", + Value: "", + Usage: "AWS Profile", + EnvVar: config.ProfileEnvVar, }, { - 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.FormatFlag, + Short: "f", + Value: "", + Usage: "Output format. [env-var|aws-credentials|process-credentials]", + EnvVar: config.FormatEnvVar, }, { - name: config.OpenBrowserFlag, - short: "b", - value: false, - usage: "Automatically open the activation URL with the system web browser", - envVar: config.OpenBrowserEnvVar, + 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.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.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.ExecFlag, + Short: "j", + Value: false, + Usage: "Execute any shell commands after the '--' CLI arguments termination", + EnvVar: config.ExecEnvVar, }, } + + rootCmd = NewRootCommand() + webCmd := web.NewWebCommand() + rootCmd.AddCommand(webCmd) + m2mCmd := m2m.NewM2MCommand() + rootCmd.AddCommand(m2mCmd) + debugCfgCmd := debugCmd.NewDebugCommand() + rootCmd.AddCommand(debugCfgCmd) } -func buildRootCommand() *cobra.Command { +// NewRootCommand Sets up the root 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.`, + Short: "Okta federated identity for AWS CLI", + Long: `Okta federated identity for AWS CLI - 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 - } - - 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.`, + CompletionOptions: cobra.CompletionOptions{ + DisableDefaultCmd: true, }, } - // 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" || arg == "--version" || arg == "-v" { + 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..7df3b96 --- /dev/null +++ b/cmd/root/web/web.go @@ -0,0 +1,101 @@ +/* + * 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" + cliFlag "github.com/okta/okta-aws-cli/internal/flag" + "github.com/okta/okta-aws-cli/internal/webssoauth" +) + +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, + }, + { + 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", + Value: false, + Usage: "Collect all profiles for a given IdP (implies aws-credentials file output format)", + EnvVar: config.AllProfilesEnvVar, + }, + } + requiredFlags = []interface{}{"org-domain", "oidc-client-id"} +) + +// NewWebCommand Sets up the web cobra sub command +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 = cliFlag.CheckRequiredFlags(requiredFlags) + if err != nil { + return err + } + + wsa, err := webssoauth.NewWebSSOAuthentication(config) + if err != nil { + return err + } + return wsa.EstablishIAMCredentials() + }, + } + + cliFlag.MakeFlagBindings(cmd, flags, false) + + return cmd +} diff --git a/go.mod b/go.mod index d0c6bd5..fbf234a 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,12 @@ module github.com/okta/okta-aws-cli -go 1.19 +go 1.21 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 @@ -15,12 +16,16 @@ require ( github.com/spf13/viper v1.14.0 github.com/stretchr/testify v1.8.2 github.com/tidwall/pretty v1.2.0 - golang.org/x/net v0.17.0 + golang.org/x/net v0.7.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 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 19c31e0..61df305 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= @@ -123,6 +125,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= @@ -150,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= @@ -187,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= @@ -234,6 +241,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= @@ -299,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= @@ -523,9 +532,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/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/aws/aws.go b/internal/aws/aws.go index c7cde96..6d3abd0 100644 --- a/internal/aws/aws.go +++ b/internal/aws/aws.go @@ -16,9 +16,72 @@ package aws -// Credential Convenience representation of an AWS credential. -type Credential struct { +import ( + "encoding/json" + "time" +) + +// 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 +} + +// 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 +} + +// 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 { + 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"` +} + +// MarshalJSON ensure Expiration date time is formatted RFC 3339 format. +func (c *ProcessCredential) MarshalJSON() ([]byte, error) { + type Alias ProcessCredential + 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/sessiontoken/sessiontoken_test.go b/internal/aws/aws_test.go similarity index 62% rename from internal/sessiontoken/sessiontoken_test.go rename to internal/aws/aws_test.go index 07c81a8..32c336b 100644 --- a/internal/sessiontoken/sessiontoken_test.go +++ b/internal/aws/aws_test.go @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022-Present, Okta, Inc. + * 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. @@ -14,21 +14,22 @@ * limitations under the License. */ -package sessiontoken +package aws import ( - "net/http" + "encoding/json" "testing" + "time" - "github.com/okta/okta-aws-cli/internal/config" "github.com/stretchr/testify/require" ) -func TestEstablishToken(t *testing.T) { - config := &config.Config{} - _ = config.SetOrgDomain("example.okta.com") - _ = config.SetHTTPClient(http.DefaultClient) - _, err := NewSessionToken(config) - // config is not set so this should error - require.Error(t, err) +func TestCredentialJSON(t *testing.T) { + hbtGo := time.Date(2009, time.November, 10, 23, 0, 0, 0, time.UTC) + c := ProcessCredential{ + 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 0f3776f..65da9fe 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,15 +30,30 @@ 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 = "1.2.2" + Version = "2.0.0-beta.6" // AWSCredentialsFormat format const AWSCredentialsFormat = "aws-credentials" // EnvVarFormat format const EnvVarFormat = "env-var" - + // ProcessCredentialsFormat format const + ProcessCredentialsFormat = "process-credentials" + // NoopFormat format const + NoopFormat = "noop" + + // AllProfilesFlag cli flag const + AllProfilesFlag = "all-profiles" + // AuthzIDFlag cli flag const + AuthzIDFlag = "authz-id" // AWSAcctFedAppIDFlag cli flag const AWSAcctFedAppIDFlag = "aws-acct-fed-app-id" // AWSCredentialsFlag cli flag const @@ -46,20 +62,30 @@ const ( AWSIAMIdPFlag = "aws-iam-idp" // AWSIAMRoleFlag cli flag const AWSIAMRoleFlag = "aws-iam-role" + // CustomScopeFlag 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" + // ExecFlag cli flag const + ExecFlag = "exec" // FormatFlag cli flag const FormatFlag = "format" // OIDCClientIDFlag cli flag 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 + PrivateKeyFlag = "private-key" + // PrivateKeyFileFlag cli flag const + PrivateKeyFileFlag = "private-key-file" + // KeyIDFlag cli flag const + KeyIDFlag = "key-id" // ProfileFlag cli flag const ProfileFlag = "profile" // QRCodeFlag cli flag const @@ -75,6 +101,10 @@ 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 AWSCredentialsEnvVar = "OKTA_AWSCLI_AWS_CREDENTIALS" // AWSIAMIdPEnvVar env var const @@ -83,34 +113,50 @@ 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" + // ExecEnvVar env var const + ExecEnvVar = "OKTA_AWSCLI_EXEC" // 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" + OktaOIDCClientIDEnvVar = "OKTA_AWSCLI_OIDC_CLIENT_ID" + // OldOktaOIDCClientIDEnvVar env var const + OldOktaOIDCClientIDEnvVar = "OKTA_OIDC_CLIENT_ID" // OktaOrgDomainEnvVar env var const - OktaOrgDomainEnvVar = "OKTA_ORG_DOMAIN" + OktaOrgDomainEnvVar = "OKTA_AWSCLI_ORG_DOMAIN" + // OldOktaOrgDomainEnvVar env var const + OldOktaOrgDomainEnvVar = "OKTA_ORG_DOMAIN" // OktaAWSAccountFederationAppIDEnvVar env var const - OktaAWSAccountFederationAppIDEnvVar = "OKTA_AWS_ACCOUNT_FEDERATION_APP_ID" + 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" + // OpenBrowserCommandEnvVar env var 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 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,93 +169,122 @@ 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"` +} + +// 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 +// 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 + allProfiles bool + authzID string + awsCredentials string awsIAMIdP string awsIAMRole string awsSessionDuration int64 - format string - profile string - qrCode bool - awsCredentials string - writeAWSCredentials bool - openBrowser bool + cacheAccessToken bool + customScope string debug bool debugAPICalls bool - debugConfig bool - legacyAWSVariables bool + exec bool expiryAWSVariables bool - cacheAccessToken bool + fedAppID string + format string 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"` + keyID string + legacyAWSVariables bool + oidcAppID string + openBrowser bool + openBrowserCommand string + orgDomain string + privateKey string + privateKeyFile string + profile string + qrCode bool + writeAWSCredentials bool + clock Clock } // Attributes config construction type Attributes struct { - OrgDomain string - OIDCAppID string - FedAppID string + AllProfiles bool + AuthzID string + AWSCredentials string AWSIAMIdP string AWSIAMRole string AWSSessionDuration int64 + CacheAccessToken bool + CustomScope string + Debug bool + DebugAPICalls bool + Exec bool + ExpiryAWSVariables bool + FedAppID string Format string + KeyID string + LegacyAWSVariables bool + OIDCAppID string + OpenBrowser bool + OpenBrowserCommand string + OrgDomain string + PrivateKey string + PrivateKeyFile 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 } - 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{ - fedAppID: attrs.FedAppID, + allProfiles: attrs.AllProfiles, + 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, + exec: attrs.Exec, + fedAppID: attrs.FedAppID, format: attrs.Format, + legacyAWSVariables: attrs.LegacyAWSVariables, + openBrowser: attrs.OpenBrowser, + openBrowserCommand: attrs.OpenBrowserCommand, + privateKey: attrs.PrivateKey, + privateKeyFile: attrs.PrivateKeyFile, + keyID: attrs.KeyID, 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) if err != nil { @@ -231,18 +306,22 @@ func NewConfig(attrs Attributes) (*Config, error) { if err != nil { return nil, err } + cfg.clock = &realClock{} return cfg, nil } func readConfig() (Attributes, error) { attrs := Attributes{ + AllProfiles: viper.GetBool(AllProfilesFlag), + 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), - DebugConfig: viper.GetBool(DebugConfigFlag), + Exec: viper.GetBool(ExecFlag), FedAppID: viper.GetString(AWSAcctFedAppIDFlag), Format: viper.GetString(FormatFlag), LegacyAWSVariables: viper.GetBool(LegacyAWSVariablesFlag), @@ -250,7 +329,11 @@ 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), + PrivateKeyFile: viper.GetString(PrivateKeyFileFlag), + KeyID: viper.GetString(KeyIDFlag), Profile: viper.GetString(ProfileFlag), QRCode: viper.GetBool(QRCodeFlag), WriteAWSCredentials: viper.GetBool(WriteAWSCredentialsFlag), @@ -273,12 +356,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)) } @@ -288,6 +381,24 @@ func readConfig() (Attributes, error) { if !attrs.QRCode { attrs.QRCode = viper.GetBool(downCase(QRCodeEnvVar)) } + 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)) + } + if attrs.CustomScope == "" { + attrs.CustomScope = viper.GetString(downCase(CustomScopeEnvVar)) + } + 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 @@ -301,7 +412,7 @@ func readConfig() (Attributes, error) { // 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) + 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") { @@ -310,14 +421,14 @@ func readConfig() (Attributes, error) { // 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) + 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]) // 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) + 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 } @@ -334,9 +445,18 @@ 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)) } + 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)) } @@ -352,6 +472,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 } @@ -360,42 +483,47 @@ func downCase(s string) string { return strings.ToLower(s) } -// OrgDomain -- -func (c *Config) OrgDomain() string { - return c.orgDomain +// AllProfiles -- +func (c *Config) AllProfiles() bool { + return c.allProfiles } -// SetOrgDomain -- -func (c *Config) SetOrgDomain(domain string) error { - if domain == "" { - return NewValidationError(OrgDomainMsg, CannotBeBlankErrMsg) - } - c.orgDomain = domain +// SetAllProfiles -- +func (c *Config) SetAllProfiles(allProfiles bool) error { + c.allProfiles = allProfiles return nil } -// OIDCAppID -- -func (c *Config) OIDCAppID() string { - return c.oidcAppID +// AuthzID -- +func (c *Config) AuthzID() string { + return c.authzID } -// SetOIDCAppID -- -func (c *Config) SetOIDCAppID(appID string) error { - if appID == "" { - return NewValidationError("OIDC App ID", CannotBeBlankErrMsg) - } - c.oidcAppID = appID +// SetAuthzID -- +func (c *Config) SetAuthzID(authzID string) error { + c.authzID = authzID return nil } -// FedAppID -- -func (c *Config) FedAppID() string { - return c.fedAppID +// AWSCredentials -- +func (c *Config) AWSCredentials() string { + return c.awsCredentials } -// SetFedAppID -- -func (c *Config) SetFedAppID(appID string) error { - c.fedAppID = appID +// SetAWSCredentials -- +func (c *Config) SetAWSCredentials(credentials string) error { + c.awsCredentials = credentials + return nil +} + +// WriteAWSCredentials -- +func (c *Config) WriteAWSCredentials() bool { + return c.writeAWSCredentials +} + +// SetWriteAWSCredentials -- +func (c *Config) SetWriteAWSCredentials(writeCredentials bool) error { + c.writeAWSCredentials = writeCredentials return nil } @@ -428,13 +556,97 @@ 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 } +// CacheAccessToken -- +func (c *Config) CacheAccessToken() bool { + return c.cacheAccessToken +} + +// SetCacheAccessToken -- +func (c *Config) SetCacheAccessToken(cacheAccessToken bool) error { + c.cacheAccessToken = cacheAccessToken + 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 +} + +// SetCustomScope -- +func (c *Config) SetCustomScope(customScope string) error { + c.customScope = customScope + return nil +} + +// Debug -- +func (c *Config) Debug() bool { + return c.debug +} + +// SetDebug -- +func (c *Config) SetDebug(debug bool) error { + c.debug = debug + return nil +} + +// DebugAPICalls -- +func (c *Config) DebugAPICalls() bool { + return c.debugAPICalls +} + +// SetDebugAPICalls -- +func (c *Config) SetDebugAPICalls(debugAPICalls bool) error { + c.debugAPICalls = debugAPICalls + 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 +} + +// SetExpiryAWSVariables -- +func (c *Config) SetExpiryAWSVariables(expiryAWSVariables bool) error { + c.expiryAWSVariables = expiryAWSVariables + return nil +} + +// FedAppID -- +func (c *Config) FedAppID() string { + return c.fedAppID +} + +// SetFedAppID -- +func (c *Config) SetFedAppID(appID string) error { + c.fedAppID = appID + return nil +} + // Format -- func (c *Config) Format() string { return c.format @@ -446,47 +658,36 @@ func (c *Config) SetFormat(format string) error { 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 +// HTTPClient -- +func (c *Config) HTTPClient() *http.Client { + return c.httpClient } -// SetQRCode -- -func (c *Config) SetQRCode(qrCode bool) error { - c.qrCode = qrCode +// SetHTTPClient -- +func (c *Config) SetHTTPClient(client *http.Client) error { + c.httpClient = client return nil } -// AWSCredentials -- -func (c *Config) AWSCredentials() string { - return c.awsCredentials +// LegacyAWSVariables -- +func (c *Config) LegacyAWSVariables() bool { + return c.legacyAWSVariables } -// SetAWSCredentials -- -func (c *Config) SetAWSCredentials(credentials string) error { - c.awsCredentials = credentials +// SetLegacyAWSVariables -- +func (c *Config) SetLegacyAWSVariables(legacyAWSVariables bool) error { + c.legacyAWSVariables = legacyAWSVariables return nil } -// WriteAWSCredentials -- -func (c *Config) WriteAWSCredentials() bool { - return c.writeAWSCredentials +// OIDCAppID -- +func (c *Config) OIDCAppID() string { + return c.oidcAppID } -// SetWriteAWSCredentials -- -func (c *Config) SetWriteAWSCredentials(writeCredentials bool) error { - c.writeAWSCredentials = writeCredentials +// SetOIDCAppID -- +func (c *Config) SetOIDCAppID(appID string) error { + c.oidcAppID = appID return nil } @@ -501,80 +702,80 @@ func (c *Config) SetOpenBrowser(openBrowser bool) error { return nil } -// Debug -- -func (c *Config) Debug() bool { - return c.debug +// OpenBrowserCommand -- +func (c *Config) OpenBrowserCommand() string { + return c.openBrowserCommand } -// SetDebug -- -func (c *Config) SetDebug(debug bool) error { - c.debug = debug +// SetOpenBrowserCommand -- +func (c *Config) SetOpenBrowserCommand(openBrowserCommand string) error { + c.openBrowserCommand = openBrowserCommand return nil } -// DebugAPICalls -- -func (c *Config) DebugAPICalls() bool { - return c.debugAPICalls +// OrgDomain -- +func (c *Config) OrgDomain() string { + return c.orgDomain } -// SetDebugAPICalls -- -func (c *Config) SetDebugAPICalls(debugAPICalls bool) error { - c.debugAPICalls = debugAPICalls +// SetOrgDomain -- +func (c *Config) SetOrgDomain(domain string) error { + c.orgDomain = domain return nil } -// DebugConfig -- -func (c *Config) DebugConfig() bool { - return c.debugConfig +// PrivateKey -- +func (c *Config) PrivateKey() string { + return c.privateKey } -// SetDebugConfig -- -func (c *Config) SetDebugConfig(debugConfig bool) error { - c.debugConfig = debugConfig +// SetPrivateKey -- +func (c *Config) SetPrivateKey(privateKey string) error { + c.privateKey = privateKey return nil } -// LegacyAWSVariables -- -func (c *Config) LegacyAWSVariables() bool { - return c.legacyAWSVariables +// PrivateKeyFile -- +func (c *Config) PrivateKeyFile() string { + return c.privateKeyFile } -// SetLegacyAWSVariables -- -func (c *Config) SetLegacyAWSVariables(legacyAWSVariables bool) error { - c.legacyAWSVariables = legacyAWSVariables +// SetPrivateKeyFile -- +func (c *Config) SetPrivateKeyFile(privateKeyFile string) error { + c.privateKeyFile = privateKeyFile return nil } -// ExpiryAWSVariables -- -func (c *Config) ExpiryAWSVariables() bool { - return c.expiryAWSVariables +// KeyID -- +func (c *Config) KeyID() string { + return c.keyID } -// SetExpiryAWSVariables -- -func (c *Config) SetExpiryAWSVariables(expiryAWSVariables bool) error { - c.expiryAWSVariables = expiryAWSVariables +// SetKeyID -- +func (c *Config) SetKeyID(keyID string) error { + c.keyID = keyID return nil } -// CacheAccessToken -- -func (c *Config) CacheAccessToken() bool { - return c.cacheAccessToken +// Profile -- +func (c *Config) Profile() string { + return c.profile } -// SetCacheAccessToken -- -func (c *Config) SetCacheAccessToken(cacheAccessToken bool) error { - c.cacheAccessToken = cacheAccessToken +// SetProfile -- +func (c *Config) SetProfile(profile string) error { + c.profile = profile return nil } -// HTTPClient -- -func (c *Config) HTTPClient() *http.Client { - return c.httpClient +// QRCode -- +func (c *Config) QRCode() bool { + return c.qrCode } -// SetHTTPClient -- -func (c *Config) SetHTTPClient(client *http.Client) error { - c.httpClient = client +// SetQRCode -- +func (c *Config) SetQRCode(qrCode bool) error { + c.qrCode = qrCode return nil } @@ -726,3 +927,12 @@ awscli: fmt.Fprintf(os.Stderr, "okta.yaml is OK\n") 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/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/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/exec/exec.go b/internal/exec/exec.go new file mode 100644 index 0000000..8872678 --- /dev/null +++ b/internal/exec/exec.go @@ -0,0 +1,98 @@ +/* + * 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" + "github.com/okta/okta-aws-cli/internal/utils" +) + +// 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(cc *oaws.CredentialContainer) 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"] = 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 { + 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, utils.PassThroughStringNewLineFMT, ee.Stderr) + } + if err != nil { + return err + } + + fmt.Printf("%s", string(out)) + return nil +} diff --git a/internal/flag/flag.go b/internal/flag/flag.go new file mode 100644 index 0000000..1317f8f --- /dev/null +++ b/internal/flag/flag.go @@ -0,0 +1,151 @@ +/* + * 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" + +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 + 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)) + } + } +} + +// CheckRequiredFlags Checks if flags in the list are all set in Viper +func CheckRequiredFlags(flags []interface{}) error { + unsetFlags := []string{} + for _, f := range flags { + 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 { + return fmt.Errorf("missing flags:\n%s", strings.Join(unsetFlags, "\n")) + } + 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 new file mode 100644 index 0000000..42f872c --- /dev/null +++ b/internal/m2mauth/m2mauth.go @@ -0,0 +1,268 @@ +/* + * 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" + "os" + "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/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" + "gopkg.in/square/go-jose.v2" + "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 +} + +// NewM2MAuthentication New M2M Authentication constructor +func NewM2MAuthentication(cfg *config.Config) (*M2MAuthentication, error) { + // need to set our config defaults + if cfg.CustomScope() == "" { + _ = cfg.SetCustomScope(DefaultScope) + } + 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: cfg, + } + return &m, nil +} + +// 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 { + at, err := m.accessToken() + if err != nil { + return err + } + + cc, err := m.awsAssumeRoleWithWebIdentity(at) + if err != nil { + return err + } + + err = output.RenderAWSCredential(m.config, cc) + if err != nil { + return err + } + + if m.config.Exec() { + exe, _ := exec.NewExec() + if err := exe.Run(cc); err != nil { + return err + } + } + + return nil +} + +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 { + return + } + + 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 + } + + cc = &oaws.CredentialContainer{ + AccessKeyID: *svcResp.Credentials.AccessKeyId, + SecretAccessKey: *svcResp.Credentials.SecretAccessKey, + SessionToken: *svcResp.Credentials.SessionToken, + Expiration: svcResp.Credentials.Expiration, + } + + return cc, nil +} + +func (m *M2MAuthentication) createKeySigner() (jose.Signer, error) { + signerOptions := (&jose.SignerOptions{}).WithHeader("kid", m.config.KeyID()) + 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 value") + } + + 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() + req, err := http.NewRequest("POST", tokenRequestURL, tokenRequestBuff) + if err != nil { + return nil, err + } + 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 + } + 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+okta.AccessTokenErrorFormat, 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..2096b6d --- /dev/null +++ b/internal/m2mauth/m2mauth_test.go @@ -0,0 +1,119 @@ +/* + * 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" + "regexp" + "testing" + + "github.com/okta/okta-aws-cli/internal/config" + "github.com/okta/okta-aws-cli/internal/testutils" + "github.com/stretchr/testify/require" +) + +func TestMain(m *testing.M) { + var reset func() + reset = testutils.OsSetEnvIfBlank("OKTA_AWSCLI_ORG_DOMAIN", testutils.TestDomainName) + defer reset() + 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_AWSCLI_AUTHZ_ID", "aus8w23r13NvyUwln1d7") + defer reset() + reset = testutils.OsSetEnvIfBlank("OKTA_AWSCLI_CUSTOM_SCOPE", "okta-m2m-access") + defer reset() + 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 = testutils.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()) +} + +// 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-m2m-access", 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_AWSCLI_ORG_DOMAIN"), + OIDCAppID: os.Getenv("OKTA_AWSCLI_OIDC_CLIENT_ID"), + AWSIAMRole: os.Getenv("OKTA_AWSCLI_IAM_ROLE"), + 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"), + } + 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/internal/okta/accesstoken.go b/internal/okta/accesstoken.go new file mode 100644 index 0000000..5916ecc --- /dev/null +++ b/internal/okta/accesstoken.go @@ -0,0 +1,33 @@ +/* + * 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 + +// 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 { + 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/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/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/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/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/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/output/aws_credentials_file.go b/internal/output/aws_credentials_file.go index f01d583..646d57a 100644 --- a/internal/output/aws_credentials_file.go +++ b/internal/output/aws_credentials_file.go @@ -27,8 +27,9 @@ 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" + "github.com/okta/okta-aws-cli/internal/utils" ) const ( @@ -64,8 +65,8 @@ func ensureConfigExists(filename string, profile string) error { return nil } -func saveProfile(filename, profile string, awsCreds *aws.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 } @@ -79,7 +80,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, cfc *oaws.CredsFileCredential, legacyVars, expiryVars bool, expiry string) (config *ini.File, err error) { config, err = ini.Load(filename) if err != nil { return @@ -90,7 +91,10 @@ func updateConfig(filename, profile string, awsCreds *aws.Credential, legacyVars return } - builder := dynamicstruct.ExtendStruct(aws.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"`) @@ -98,16 +102,17 @@ func updateConfig(filename, profile string, awsCreds *aws.Credential, legacyVars 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) @@ -156,12 +161,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 } @@ -184,15 +183,21 @@ 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 (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 e.writeConfig(c, ac) + return a.writeConfig(c, cfc) } - return e.appendConfig(c, ac) + return a.appendConfig(c, cfc) } -func (e *AWSCredentialsFile) appendConfig(c *config.Config, ac *aws.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 @@ -201,22 +206,27 @@ func (e *AWSCredentialsFile) appendConfig(c *config.Config, ac *aws.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(), ac.AccessKeyID, ac.SecretAccessKey, ac.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, ac.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...) @@ -227,21 +237,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, ac *aws.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, ac, 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 eea86f3..efc1138 100644 --- a/internal/output/envvar.go +++ b/internal/output/envvar.go @@ -18,9 +18,10 @@ package output import ( "fmt" + "os" "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 +39,26 @@ 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, 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", 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", 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", ac.SessionToken) + fmt.Printf("setx AWS_SECURITY_TOKEN %s\n", evc.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", 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", ac.SessionToken) + fmt.Printf("export AWS_SECURITY_TOKEN=%s\n", evc.SessionToken) } } diff --git a/internal/output/noop.go b/internal/output/noop.go new file mode 100644 index 0000000..8581488 --- /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, cc *oaws.CredentialContainer) error { + // no-op + return nil +} diff --git a/internal/output/output.go b/internal/output/output.go index d72c07c..a78f6d0 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -17,11 +17,30 @@ package output import ( - "github.com/okta/okta-aws-cli/internal/aws" + "time" + + 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, cc *oaws.CredentialContainer) error +} + +// RenderAWSCredential Renders the credentials in the prescribed format. +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) + case config.ProcessCredentialsFormat: + o = NewProcessCredentials() + case config.NoopFormat: + o = NewNoopCredentials() + default: + o = NewEnvVar(cfg.LegacyAWSVariables()) + } + return o.Output(cfg, cc) } diff --git a/internal/output/process_credentials.go b/internal/output/process_credentials.go new file mode 100644 index 0000000..028cd9e --- /dev/null +++ b/internal/output/process_credentials.go @@ -0,0 +1,56 @@ +/* + * 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" + + oaws "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, 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 { + 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..673e122 --- /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.ProcessCredential{} + 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/testutils/testutils.go b/internal/testutils/testutils.go new file mode 100644 index 0000000..3a8204a --- /dev/null +++ b/internal/testutils/testutils.go @@ -0,0 +1,195 @@ +/* + * 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" + "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 ( + // 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_AWSCLI_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) + + // %s/example.okta.com/test.dne-okta.com/ + i.Response.Body = strings.ReplaceAll(i.Response.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 +} + +// 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/utils/utils.go b/internal/utils/utils.go new file mode 100644 index 0000000..742e83b --- /dev/null +++ b/internal/utils/utils.go @@ -0,0 +1,43 @@ +/* + * 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" + // 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" + // 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/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 56% rename from internal/sessiontoken/sessiontoken.go rename to internal/webssoauth/webssoauth.go index 9bb1c8e..e92ede5 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" @@ -27,40 +27,43 @@ import ( "net/http" "net/url" "os" + osexec "os/exec" "os/user" "path/filepath" + "regexp" "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/google/shlex" "github.com/mdp/qrterminal" 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" + "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" ) 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" @@ -73,6 +76,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 { @@ -82,57 +87,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 } -// 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 { - 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"` -} - -// 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 @@ -148,51 +118,60 @@ var stderrIsOutAskOpt = func(options *survey.AskOptions) error { return nil } -// NewSessionToken Creates a new session token. -func NewSessionToken(config *config.Config) (token *SessionToken, err error) { - if err != nil { - return nil, err - } - token = &SessionToken{ - config: config, +// NewWebSSOAuthentication New Web SSO Authentication constructor +func NewWebSSOAuthentication(cfg *config.Config) (token *WebSSOAuthentication, err error) { + token = &WebSSOAuthentication{ + 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()) + } + } + + // 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 } -// EstablishToken Template method of the steps to establish an AWS session -// token. -func (s *SessionToken) EstablishToken() error { - clientID := s.config.OIDCAppID() - var at *accessToken - var apps []*oktaApplication +// EstablishIAMCredentials Steps to establish an AWS session token. +func (w *WebSSOAuthentication) EstablishIAMCredentials() error { + clientID := w.config.OIDCAppID() + var at *okta.AccessToken + 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() if err != nil { return err } - s.promptAuthentication(deviceAuth) + w.promptAuthentication(deviceAuth) - at, err = s.fetchAccessToken(clientID, deviceAuth) + at, err = w.accessToken(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 @@ -201,7 +180,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 @@ -227,63 +206,87 @@ 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 // 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 { - 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 s.config.Debug() { - fmt.Fprintf(os.Stderr, " found IdP ARN %q having friendly label %q\n", arn, label) + 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 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, " in okta.yaml awscli.idps map:\n") - for arn, label := range oktaConfig.AWSCLI.IDPS { - fmt.Fprintf(os.Stderr, " %q: %q\n", arn, label) + } + // treat ARN values as regexps + for arnRegexp, label := range idps { + if ok, _ := regexp.MatchString(arnRegexp, arn); ok { + return label } } - return alternative + + 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 idps { + w.consolePrint(arnLabelPrintFmt, arn, label) + } + } + return alt } -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() + var configIDPs map[string]string + oktaConfig, err := w.config.OktaConfig() + if err == nil { + configIDPs = oktaConfig.AWSCLI.IDPS + } for i, app := range apps { - choiceLabel := s.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 s.config.AWSIAMIdP() == app.Settings.App.IdentityProviderARN { - idpData := idpTemplateData{ - IDP: choiceLabel, + if w.config.AWSIAMIdP() == app.Settings.App.IdentityProviderARN { + 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 } @@ -296,7 +299,7 @@ func (s *SessionToken) selectFedApp(apps []*oktaApplication) (string, error) { 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) } @@ -307,132 +310,183 @@ func (s *SessionToken) selectFedApp(apps []*oktaApplication) (string, error) { return idps[selected].ID, nil } -func (s *SessionToken) establishTokenWithFedAppID(clientID, fedAppID string, at *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) - if err != nil { - return err - } + if !w.config.AllProfiles() { + iar, err := w.promptForIdpAndRole(idpRolesMap) + if err != nil { + return err + } - ac, err := s.fetchAWSCredentialWithSAMLRole(iar, assertion) - if err != nil { - return err - } + cc, err := w.awsAssumeRoleWithSAML(iar, assertion) + if err != nil { + return err + } - err = s.renderCredential(ac) - if err != nil { - return err - } + err = output.RenderAWSCredential(w.config, cc) + if err != nil { + return err + } - return 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 + } + } -// renderCredential Renders the credentials in the prescribed format. -func (s *SessionToken) renderCredential(ac *oaws.Credential) error { - var o output.Outputter - switch s.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) - default: - o = output.NewEnvVar(s.config.LegacyAWSVariables()) - fmt.Fprintf(os.Stderr, "\n") } - return o.Output(s.config, ac) + return nil } -// 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 (s *SessionToken) fetchAWSCredentialWithSAMLRole(iar *idpAndRole, assertion string) (credential *oaws.Credential, err error) { - awsCfg := aws.NewConfig().WithHTTPClient(s.config.HTTPClient()) +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 { - return nil, err + return } 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), } svcResp, err := svc.AssumeRoleWithSAML(input) if err != nil { - return nil, err + return } - credential = &oaws.Credential{ + cc = &oaws.CredentialContainer{ AccessKeyID: *svcResp.Credentials.AccessKeyId, SecretAccessKey: *svcResp.Credentials.SecretAccessKey, SessionToken: *svcResp.Credentials.SessionToken, + Expiration: svcResp.Credentials.Expiration, + } + if !w.config.AllProfiles() && w.config.Profile() != "" { + cc.Profile = w.config.Profile() + return cc, nil + } + + 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 + } + sessCopy := sess.Copy(&aws.Config{ + Credentials: credentials.NewStaticCredentials( + cc.AccessKeyID, + cc.SecretAccessKey, + cc.SessionToken, + ), + }) + if p, err := w.fetchAWSAccountAlias(sessCopy); err != nil { + org := "org" + fmt.Fprintf(os.Stderr, "unable to determine account alias, setting alias name to %q\n", org) + profileName = org + } else { + profileName = p } - return credential, nil + cc.Profile = fmt.Sprintf("%s-%s-%s", profileName, idpName, roleName) + + return cc, nil } // 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 { - 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 s.config.Debug() { - fmt.Fprintf(os.Stderr, " found Role ARN %q having friendly label %q\n", arn, label) + 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 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, " in okta.yaml awscli.roles map:\n") - for arn, label := range oktaConfig.AWSCLI.ROLES { - fmt.Fprintf(os.Stderr, " %q: %q\n", arn, label) + } + + // 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 roles { + w.consolePrint(arnLabelPrintFmt, arn, label) } } return arn } // 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, err := w.config.OktaConfig() + var configRoles map[string]string + if err == nil { + configRoles = oktaConfig.AWSCLI.ROLES + } - 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, configRoles) 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 } promptRoles := []string{} labelsARNs := map[string]string{} for _, arn := range roleARNs { - roleLabel := s.choiceFriendlyLabelRole(arn, oktaConfig) + roleLabel := w.choiceFriendlyLabelRole(arn, configRoles) promptRoles = append(promptRoles, roleLabel) labelsARNs[roleLabel] = arn } @@ -458,23 +512,26 @@ 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) { + var configIDPs map[string]string + if oktaConfig, cErr := w.config.OktaConfig(); cErr == nil { + configIDPs = oktaConfig.AWSCLI.IDPS + } 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, idpARN, configIDPs) idpData := idpTemplateData{ IDP: idpLabel, } @@ -489,7 +546,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, arn, configIDPs) idpChoices[idpLabel] = arn idpChoiceLabels[i] = idpLabel } @@ -513,18 +570,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 } @@ -539,7 +596,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 @@ -573,18 +630,19 @@ 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 (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 { 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 := s.config.HTTPClient().Do(req) + resp, err := w.config.HTTPClient().Do(req) if err != nil { return assertion, err } @@ -603,8 +661,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 (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}, @@ -622,11 +680,12 @@ 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(userAgent, agent.NewUserAgent(config.Version).String()) + 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.XOktaAWSCLIWebOperation) - resp, err := s.config.HTTPClient().Do(req) + resp, err := w.config.HTTPClient().Do(req) if err != nil { return nil, err } @@ -637,16 +696,16 @@ 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) } - 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 = &accessToken{} + token = &okta.AccessToken{} err = json.NewDecoder(resp.Body).Decode(token) if err != nil { return nil, err @@ -656,16 +715,16 @@ func (s *SessionToken) fetchSSOWebToken(clientID, awsFedAppID string, at *access } // 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) if _, err := buf.Read(qrBuf); err == nil { - qrCode = fmt.Sprintf("%s\n", qrBuf) + qrCode = fmt.Sprintf(utils.PassThroughStringNewLineFMT, qrBuf) } } @@ -675,16 +734,35 @@ func (s *SessionToken) promptAuthentication(da *deviceAuthorization) { ` openMsg := "Open" - if s.config.OpenBrowser() { - openMsg = "System web browser will open" + if w.config.OpenBrowser() { + openMsg = "Web browser will open" } - fmt.Fprintf(os.Stderr, prompt, openMsg, qrCode, da.VerificationURIComplete) + w.consolePrint(prompt, openMsg, qrCode, da.VerificationURIComplete) - if s.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 err != nil { + 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 { - 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) } } } @@ -693,8 +771,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 *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 } @@ -708,11 +786,12 @@ 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(userAgent, agent.NewUserAgent(config.Version).String()) + req.Header.Add(accept, utils.ApplicationJSON) + req.Header.Add(utils.ContentType, utils.ApplicationJSON) + 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 := s.config.HTTPClient().Do(req) + resp, err := w.config.HTTPClient().Do(req) if resp.StatusCode == http.StatusForbidden { return nil, err } @@ -723,13 +802,13 @@ func (s *SessionToken) listFedApps(clientID string, at *accessToken) (apps []*ok 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 @@ -747,18 +826,20 @@ func (s *SessionToken) listFedApps(clientID string, at *accessToken) (apps []*ok return } -// fetchAccessToken see: +// accessToken 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 (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) if err != nil { return nil, err } - req.Header.Add(accept, applicationJSON) - req.Header.Add(contentType, applicationXWwwForm) - req.Header.Add(userAgent, agent.NewUserAgent(config.Version).String()) + 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.XOktaAWSCLIWebOperation) var bodyBytes []byte @@ -773,7 +854,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)) @@ -804,7 +885,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 @@ -815,8 +896,9 @@ 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() (*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}, "scope": {"openid okta.apps.sso okta.apps.read"}, @@ -826,11 +908,12 @@ 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(userAgent, agent.NewUserAgent(config.Version).String()) + 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.XOktaAWSCLIWebOperation) - resp, err := s.config.HTTPClient().Do(req) + resp, err := w.config.HTTPClient().Do(req) if err != nil { return nil, err } @@ -838,12 +921,12 @@ 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) } - var da deviceAuthorization + var da okta.DeviceAuthorization err = json.NewDecoder(resp.Body).Decode(&da) if err != nil { return nil, err @@ -911,38 +994,32 @@ 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 } -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 } - req.Header.Add(accept, applicationJSON) - req.Header.Add(userAgent, agent.NewUserAgent(config.Version).String()) + req.Header.Add(accept, utils.ApplicationJSON) + req.Header.Add(utils.UserAgentHeader, config.UserAgentValue) + req.Header.Add(utils.XOktaAWSCLIOperationHeader, utils.XOktaAWSCLIWebOperation) - 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 @@ -958,7 +1035,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 (w *WebSSOAuthentication) cachedAccessToken() (at *okta.AccessToken) { homeDir, err := os.UserHomeDir() if err != nil { return @@ -969,7 +1046,7 @@ func (s *SessionToken) cachedAccessToken() (at *accessToken) { return } - _at := accessToken{} + _at := okta.AccessToken{} err = json.Unmarshal(atJSON, &_at) if err != nil { return @@ -989,8 +1066,8 @@ 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) { - if !s.config.CacheAccessToken() { +func (w *WebSSOAuthentication) cacheAccessToken(at *okta.AccessToken) { + if !w.config.CacheAccessToken() { return } @@ -1017,3 +1094,57 @@ func (s *SessionToken) cacheAccessToken(at *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...) +} + +// 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 +} + +func splitArgs(args string) ([]string, error) { + return shlex.Split(args) +} diff --git a/internal/webssoauth/webssoauth_test.go b/internal/webssoauth/webssoauth_test.go new file mode 100644 index 0000000..49c89a9 --- /dev/null +++ b/internal/webssoauth/webssoauth_test.go @@ -0,0 +1,267 @@ +/* + * 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 webssoauth + +import ( + "net/http" + "os" + "reflect" + "testing" + + "github.com/okta/okta-aws-cli/internal/config" + "github.com/okta/okta-aws-cli/internal/testutils" + "github.com/stretchr/testify/require" +) + +func TestMain(m *testing.M) { + var reset func() + reset = testutils.OsSetEnvIfBlank("OKTA_AWSCLI_ORG_DOMAIN", testutils.TestDomainName) + defer reset() + reset = testutils.OsSetEnvIfBlank("OKTA_AWSCLI_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_AWSCLI_ORG_DOMAIN"), + OIDCAppID: os.Getenv("OKTA_AWSCLI_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 +} + +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) + } + }) + } +} + +// 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) + } + }) + } +} diff --git a/test/fixtures/vcr/TestM2MAuthAccessToken.yaml b/test/fixtures/vcr/TestM2MAuthAccessToken.yaml new file mode 100644 index 0000000..c7d95c7 --- /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-m2m-access + 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.eyJ2ZXIiOjEsImp0aSI6IkFULktJNVd6YzBQYUFZZC14YjB5UXJXX0NHZUhoZnNNdUtrZGxUVkZlbXhQblEiLCJpc3MiOiJodHRwczovL21tb25kcmFnb24tYXdzLWNsaS0wMC5va3RhcHJldmlldy5jb20vb2F1dGgyL2F1czh3MjNyMTNOdnlVd2xuMWQ3IiwiYXVkIjoiaHR0cHM6Ly9va3RhLWF3cy1jbGktYXV0aG9yaXplciIsImlhdCI6MTY5NTk1MDE5MywiZXhwIjoxNjk1OTUzNzkzLCJjaWQiOiIwb2FhNGh0ZzcyVE5ya1REcjFkNyIsInNjcCI6WyJva3RhLW0ybS1hY2Nlc3MiXSwic3ViIjoiMG9hYTRodGc3MlROcmtURHIxZDcifQ.OAs2_oZtpDnZdy4jTcjXA8D9VTFibiJeXZ3YfrntaTr6CRNQdKtWCoJHR4yPQvTEU8IjHW2uZA635ozdubhRfYd0Hv688bQHHyR8xZOC-BfyXA1xmVph1ei4HNwAZQpG-4VL1L99KvuvEh0FjtwZpcZx5_B_0Ag7LoQTVhj2nfPrjLYctIzVYRzIE29dVmDefqgJ5gG6FcK4GthMJBMK2H_mMiJQHDtlBGX7GXb7Hkt9FfA5J0HcMxq9x7yKJM6S_fTePXJwFMOBwQJX4zZa1jstDT0squxgPMh14asko8_dFXe9lC8NVrixbhs6TkiOEKGvma1u0V8yAS0qC5jvbg","scope":"okta-m2m-access"}' + headers: + Content-Type: + - application/json + Date: + - 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: 504.687465ms 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: [] 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