diff --git a/.github/workflows/pr_check.yml b/.github/workflows/pr_check.yml index e9674139..c38e2413 100644 --- a/.github/workflows/pr_check.yml +++ b/.github/workflows/pr_check.yml @@ -1,19 +1,37 @@ --- name: PR check -on: +on: # yamllint disable-line rule:truthy pull_request: permissions: contents: read jobs: - test: + check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + + - name: Merge base branch into PR branch + env: + BASE_REF: ${{ github.event.pull_request.base.ref }} + run: | + git config user.name "github-actions" + git config user.email "github-actions@github.com" + git fetch origin "$BASE_REF" + git merge "origin/$BASE_REF" --no-edit + - uses: actions/setup-python@v5 with: - python-version: "3.11" + python-version: '3.11' + + - uses: pre-commit/action@v3.0.1 + with: + extra_args: --all-files --show-diff-on-failure --color always + - run: pip install tox - run: tox diff --git a/.gitignore b/.gitignore index d352d3c0..505f42a0 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ dist .tox/ build/ __pycache__/ +/docs/superpowers/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..60909695 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,64 @@ +--- +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +default_language_version: + python: python3.11 +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-json + - id: check-merge-conflict + - id: check-added-large-files + args: [--maxkb=500] + - id: check-symlinks + - id: mixed-line-ending + args: [--fix=lf] + - id: detect-private-key + - id: detect-aws-credentials + args: [--allow-missing-credentials] + - id: no-commit-to-branch + - repo: https://github.com/adrienverge/yamllint.git + rev: v1.38.0 + hooks: + - id: yamllint + name: Lint YAML + args: [--format, parsable, --strict] + - repo: https://github.com/jumanjihouse/pre-commit-hook-yamlfmt + rev: 0.2.3 + hooks: + - id: yamlfmt + args: [--mapping, '2', --sequence, '4', --offset, '2'] + - repo: https://github.com/executablebooks/mdformat.git + rev: 1.0.0 + hooks: + - id: mdformat + name: Format markdown + - repo: https://github.com/pycqa/isort + rev: 8.0.1 + hooks: + - id: isort + name: isort (python) + args: [--profile, black, --filter-files] + - repo: https://github.com/psf/black + rev: 26.3.1 + hooks: + - id: black + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.19.1 + hooks: + - id: mypy + files: ^oktaawscli/ + additional_dependencies: + - types-requests + - boto3-stubs[sts,iam] + - repo: https://github.com/pylint-dev/pylint + rev: v4.0.5 + hooks: + - id: pylint + name: pylint (Python Linting) + files: ^(oktaawscli|tests)/ + args: [--rcfile=pylintrc, --output-format=colorized, --score=no] diff --git a/.travis.yml b/.travis.yml index 295a2d08..4899f124 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,9 +1,10 @@ +--- language: -- python + - python python: -- "3.6.5" + - 3.6.5 install: -- pip install -r requirements.txt -- pip install pylint + - pip install -r requirements.txt + - pip install pylint script: -- pylint --errors-only oktaawscli + - pylint --errors-only oktaawscli diff --git a/.yamllint.yml b/.yamllint.yml new file mode 100644 index 00000000..1a55e0d5 --- /dev/null +++ b/.yamllint.yml @@ -0,0 +1,8 @@ +--- +# https://yamllint.readthedocs.io/en/stable/configuration.html#extending-the-default-configuration + +extends: relaxed +rules: + line-length: + max: 120 + hyphens: disable diff --git a/CHANGELOG.md b/CHANGELOG.md index 75bd916d..e610d7ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,28 @@ # Changelog +## [0.4.15] 2026-05-16 + +### Added + +- `.pre-commit-config.yaml` with the org-standard hooks (universal pre-commit-hooks set, yamllint/yamlfmt, mdformat, isort, black, mypy, pylint) and a single consolidated `PR check` workflow that runs pre-commit then tox on every PR. +- Supporting configs: `.yamllint.yml`, `mypy.ini`, `pylintrc`. + +### Changed + +- Removed the unused `six` dependency from `requirements.txt`, `setup.py`, and `oktaawscli/aws_auth.py`. +- `setup.py` now imports `__version__` directly from `oktaawscli.version` instead of `exec`-ing the source file. +- Five legacy multi-line log strings in `aws_auth.py` and `okta_auth.py` are now single-line messages; the prior triple-quoted and backslash-continued forms were leaking embedded indentation into the user-visible output. Log content is otherwise unchanged. + ## [0.4.14] 2026-05-13 + ### Changed + - Version bump only. Republish of 0.4.13 — the 0.4.13 deploy build failed with HTTP 409 from CodeArtifact because that version had already been published from an earlier in-PR build. ## [0.4.13] 2026-05-11 + ### Changed + - Cross-process file locking and atomic-rename writes for `~/.aws/credentials`, `~/.okta-aws`, and `~/.okta-alias-info`. Multiple `okta-awscli` processes can now run in parallel against the same dotfiles without clobbering each other. - `OktaAuth.primary_auth` now acquires a 300-second lock around the Okta authentication flow. Parallel runs serialize through a single MFA prompt; the rest pick up the freshly-cached session and skip auth entirely. - Okta API error responses (`errorCode` dicts) surface as a one-line exit message with the error code and id, instead of `TypeError: string indices must be integers, not 'str'`. @@ -15,124 +32,171 @@ - Latent `NoSectionError` in `copy_to_default` (raised against a credentials file with a populated source profile but no pre-existing `[default]` section) is fixed. ### Added + - `oktaawscli/_locking.py` exposing `locked(path, timeout=...)` and `atomic_write(path)` primitives, plus `LOCK_TIMEOUT_SECONDS` (60s default) and `INTERACTIVE_LOCK_TIMEOUT_SECONDS` (300s for auth flow) constants. - `tox.ini` and a `tests/` unittest-based test suite covering the locking, atomic-write, merge-on-write, and rate-limit behaviors. - `filelock` runtime dependency. ## [0.4.8] 2024-05-02 + ### Changed + - Log exception when encountering unknown ClientError error while listing AWS account aliases. ## [0.4.5] 2023-03-10 + ### Changed + - Added handling of Okta authentication status for `MFA_ENROLL` and `LOCKED_OUT` - Added handling of unknown Okta authentication status - Formatted code with Python black ## [0.4.0] 2019-05-02 + ### Changed + - Added region override parameter for write_sts_token method - Export Profile usage message will not print if using account (-a) argument ## [0.3.6] 2018-12-07 + ### Changed + - Sorted role options by role name after sorting by account alias ## [0.3.5] 2018-09-28 + ### Changed + - Fixed exception that would break program when OKTA was configured with accounts that did not give OKTA permissions to login ## [0.3.4] 2018-09-18 + ### Changed: + - Fixed exception handling of missing credentials exception for Python 3 ## [0.3.3] 2018-09-12 + ### Added: + - Add parameter `-a, --account` to okta-awscli - - Filters and lists or chooses AWS roles for account - - Creates/updates Okta profile and AWS profile named from account + + - Filters and lists or chooses AWS roles for account + - Creates/updates Okta profile and AWS profile named from account - Add parameter `-w, --write-default` to okta-awscli - - When authenticating with AWS role, the STS credentials will be written to both the AWS account and default profiles + + - When authenticating with AWS role, the STS credentials will be written to both the AWS account and default profiles ### Changed: + - Fix input requirement of user credentials when Okta token is still valid ## [0.3.2] 2018-08-31 + ### Changed: + - Fix datetime parsing of expiration date for Okta token ## [0.3.1] 2018-08-23 + ### Changed: + - Better error handling for selection of roles ## [0.3.0] 2018-08-16 + ### Added: + - Select app specified by `app` field in config if `app` field exists + - Graciously reprompt for role index on bad selection + - Add export flag to print creds to console + - Add reset flag to reset fields in `~/.okta-aws` for current okta-profile + - Stores factor for default okta profiles + - Add usage message when storing credentials in `/.aws/credentials` + - Use system username if `username` not set in `~/.okta-aws` and no username given when prompted - Display account aliases when prompting for role selection - - create a `~/.okta-alias-info` file to store account aliases - - fetch account aliases to display in list of roles - - cache account aliases in `~/.okta-alias-info` along with time last updated - - refresh account alias if last updated over a week ago + + - create a `~/.okta-alias-info` file to store account aliases + - fetch account aliases to display in list of roles + - cache account aliases in `~/.okta-alias-info` along with time last updated + - refresh account alias if last updated over a week ago - Add config option `auto-write-profile` to `~/.okta-aws` - - if "True" and no `--profile` specified, will write aws creds to profile named for the account alias for the chosen role - - if account alias for the chosen role is unknown, will write to `default` aws profile - - modifies existing functionality if `--profile` specified - will write to the specified profile unless `--export` flag set - - if `--export` flag set, will not write aws creds, will only display to console - - defaults to "False" to maintain existing functionality if option not set + + - if "True" and no `--profile` specified, will write aws creds to profile named for the account alias for the chosen role + - if account alias for the chosen role is unknown, will write to `default` aws profile + - modifies existing functionality if `--profile` specified - will write to the specified profile unless `--export` flag set + - if `--export` flag set, will not write aws creds, will only display to console + - defaults to "False" to maintain existing functionality if option not set - Add config option `store-role` to `~/.okta-aws` - - if "False", will not store role upon selection for the chosen `okta-profile` - - Will use `role` is already defined for the chosen `okta-profile` - - defaults to "True" to maintain existing functionality if option not set + + - if "False", will not store role upon selection for the chosen `okta-profile` + - Will use `role` is already defined for the chosen `okta-profile` + - defaults to "True" to maintain existing functionality if option not set - Add config option `check-valid-creds` to `~/.okta-aws` - - if "False", will skip making sure credentials are valid and automatically get new credentials - - if "True", will refresh credentials only if `--profile` and `--force` are both specified - - Defaults to True to maintain existing behavior + + - if "False", will skip making sure credentials are valid and automatically get new credentials + - if "True", will refresh credentials only if `--profile` and `--force` are both specified + - Defaults to True to maintain existing behavior - Cache okta session id to avoid re-authenticating with Okta when switching token - - stores session id and expiration timestamp in `~/.okta-token` - - if session id is expired, will re-authenticate + + - stores session id and expiration timestamp in `~/.okta-token` + - if session id is expired, will re-authenticate - Add config option `session-duration` to `~/.okta-aws` - - takes in session duration in seconds - - to be valid, must be between 3600 and 43200 (1 hour to 12 hours) - - if invalid or not specified, defaults to 3600 (1 hour) + + - takes in session duration in seconds + - to be valid, must be between 3600 and 43200 (1 hour to 12 hours) + - if invalid or not specified, defaults to 3600 (1 hour) - Add config option `region` to `~/.okta-aws` - - specifies the region to access resources in - - defaults to `us-east-1` + + - specifies the region to access resources in + - defaults to `us-east-1` ### Changed: + - Exports `aws_security_token` variable as well in order to supportM with `boto` library calls - Update RESUME ## [0.2.3] 2018-07-21 + ### Added: + - Travis CI builds to run linting tests for branches and PRs. ### Fixed: + - Python3 Compatibility issues. ## [0.2.2] 2018-07-18 + ### Fixed: + - Python3 Compatibility. (#38) ## [0.2.1] 2018-02-14 + ### Fixed: + - Issue where secondary auth would fail when only a single factor is enrolled for the user. (#27) ## [0.2.0] 2018-02-11 + ### Added: + - Ability to store MFA factor choice in `~/.okta-aws`. (#3) - Flag to output the version. - Ability to store AWS Role choice in `~/.okta-aws`. (#4) @@ -141,36 +205,49 @@ - Support for caching credentials to use in other sessions. Thanks Justin! (#6, #7) ### Fixed: + - Issue #14. Fixed a bug where okta-awscli wasn't connecting to the STS API endpoint in us-gov-west-1 when trying to obtain credential for GovCloud. - Improved sorting in the app list to be more consistent. Thanks Justin! - Cleaned up README to improve clarity. Thanks Justin! ## [0.1.5] 2017-11-15 + ### Fixed: + - Issue #8. Another pass at trying to fix the MFA list. Factor chosen was being pulled from list which included unsupported factors. ## [0.1.4] 2017-08-27 + ### Added: + - This CHANGELOG! ### Fixed: + - Issue #1. Bug where MFA factor selected isn't always the one passed to Okta for verification. ## [0.1.3] 2017-08-17 + ### Added: + - Prompts for a username and password if omitted from `.okta-aws` ### Changed: + - Spelling fix - Change `--okta_profile` flag to be `--okta-profile` instead. ## [0.1.2] 2017-07-25 + ### Added: + - Support for flag to force new credentials. ### Changed + - Handles no profile provided. - Handles no awscli args provided (authenticate only). ## [0.1.1] 2017-07-25 + - Initial release. Updated for PyPi. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f6de75e9..ff90f51c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,6 +5,7 @@ Contributions are always welcome! We only ask that you follow the guidelines before before submitting your Pull Request. - Fork and then clone the repo: + ``` git clone git@github.com:your-username/okta-awscli.git ``` @@ -14,6 +15,7 @@ git clone git@github.com:your-username/okta-awscli.git It's highly recommended to use virtualenv! - Install the project from the repo, do not use the PyPi instructions in the README. + ``` pip install . ``` diff --git a/README.md b/README.md index efb6c898..0b3bbe15 100644 --- a/README.md +++ b/README.md @@ -19,31 +19,29 @@ See [AstroTools: New Engineer Setup - Amplify Okta AWS CLI](https://docs.google. - Okta Verify Push Support - Google Authenticator [Play Store](https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2) | [App Store](https://itunes.apple.com/us/app/google-authenticator/id388497605) - ## Unsupported Features - Per application MFA support - ## Usage `okta-awscli --profile ` + - Follow the prompts to enter MFA information (if required) and choose your AWS app and IAM role. - Multiple Okta profiles are supported, but if none are specified, then `default` will be used. - ### Examples `okta-awscli --profile cfer-dev` This command will simply output STS credentials to `cfer-dev` in your credentials file. - `okta-awscli --profile my-aws-account iam list-users` If no awscli commands are provided, then okta-awscli will simply output STS credentials to your credentials file, or console, depending on how `--profile` is set. Optional flags: + - `--profile` Sets your temporary credentials to a profile in `.aws/credentials`. If omitted, credentials will output to console. - `--export` Outputs credentials to console instead of writing to ~/.aws/credentials. - `--reset` Resets default values in ~/.okta-aws for the okta-profile being used. diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 00000000..d064f5fb --- /dev/null +++ b/mypy.ini @@ -0,0 +1,3 @@ +[mypy] +ignore_missing_imports = True +no_implicit_optional = False diff --git a/oktaawscli/__init__.py b/oktaawscli/__init__.py index b0e86c8d..0a50a4c2 100644 --- a/oktaawscli/__init__.py +++ b/oktaawscli/__init__.py @@ -1,2 +1,3 @@ -""" init """ +"""init""" + from .version import __version__ diff --git a/oktaawscli/_locking.py b/oktaawscli/_locking.py index e8fedaf3..34ba2bb3 100644 --- a/oktaawscli/_locking.py +++ b/oktaawscli/_locking.py @@ -1,4 +1,5 @@ """Cross-process advisory locking and atomic writes for dotfiles.""" + import os import tempfile from contextlib import contextmanager diff --git a/oktaawscli/aws_auth.py b/oktaawscli/aws_auth.py index d3964808..16166f56 100644 --- a/oktaawscli/aws_auth.py +++ b/oktaawscli/aws_auth.py @@ -1,15 +1,15 @@ -""" AWS authentication """ -# pylint: disable=C0325 -import os -import json +"""AWS authentication""" + import base64 -from datetime import datetime, date +import json +import os import xml.etree.ElementTree as ET from collections import namedtuple from configparser import ConfigParser +from datetime import date, datetime + import boto3 from botocore.exceptions import ClientError, NoCredentialsError, ProfileNotFound -import six from oktaawscli._locking import atomic_write, locked @@ -65,8 +65,7 @@ def choose_aws_role(self, assertion): return predefined_role else: self.logger.info( - """Predefined role, %s, not found in the list -of roles assigned to you.""" + "Predefined role, %s, not found in the list of roles assigned to you." % self.role ) self.logger.info("Please choose a role.") @@ -82,7 +81,7 @@ def choose_aws_role(self, assertion): for option in role_options: print(option) role_choice = int(input("Please select the AWS role: ")) - 1 - if role_choice >= 0 and role_choice < len(role_info): + if 0 <= role_choice < len(role_info): return role_info[role_choice] raise IndexError("Bad selection") except (SyntaxError, NameError, ValueError, IndexError): @@ -254,7 +253,9 @@ def __get_role_info(self, roles, assertion): current_date = date.today() alias_age = current_date - last_updated if alias_age.days >= 7 or alias is None: - self.logger.info("Refreshing cached alias for role %s" % role.role_arn) + self.logger.info( + "Refreshing cached alias for role %s" % role.role_arn + ) alias = self.__get_account_alias( role.role_arn, role.principal_arn, assertion ) @@ -303,7 +304,7 @@ def __get_account_alias(self, role_arn, principal_arn, assertion): saml_resp = sts.assume_role_with_saml( RoleArn=role_arn, PrincipalArn=principal_arn, SAMLAssertion=assertion ) - except ClientError as ex: + except ClientError: self.logger.warning( "Unable to assume role '%s', cannot get account alias", role_arn, diff --git a/oktaawscli/okta_auth.py b/oktaawscli/okta_auth.py index 4015f7ec..07a318dc 100644 --- a/oktaawscli/okta_auth.py +++ b/oktaawscli/okta_auth.py @@ -1,13 +1,13 @@ -""" Handles auth to Okta and returns SAML assertion """ -# pylint: disable=C0325,R0912,C1801 +"""Handles auth to Okta and returns SAML assertion""" + +import json import os import random import sys import time -import json from datetime import datetime -import requests +import requests from bs4 import BeautifulSoup as bs from oktaawscli._locking import INTERACTIVE_LOCK_TIMEOUT_SECONDS, atomic_write, locked @@ -69,7 +69,9 @@ def _run_authn_flow(self): "password": self.okta_auth_config.password_for(self.okta_profile), } # https://developer.okta.com/docs/reference/api/authn/ - resp_json = self._okta_json_request("POST", "/api/v1/authn", "_run_authn_flow", json=auth_data) + resp_json = self._okta_json_request( + "POST", "/api/v1/authn", "_run_authn_flow", json=auth_data + ) if "status" in resp_json: status = resp_json["status"] if status == "MFA_REQUIRED": @@ -80,14 +82,14 @@ def _run_authn_flow(self): return resp_json["sessionToken"] if status == "MFA_ENROLL": self.logger.warning( - """MFA not enrolled. Cannot continue. - Please enroll an MFA factor in the Okta Web UI first!""" + "MFA not enrolled. Cannot continue. " + "Please enroll an MFA factor in the Okta Web UI first!" ) sys.exit(2) if status == "LOCKED_OUT": self.logger.error( - """Account is locked. Cannot continue. - Please contact you administrator in order to unlock the account!""" + "Account is locked. Cannot continue. " + "Please contact you administrator in order to unlock the account!" ) sys.exit(1) self.logger.error(f"Unknown authentication status: {status}") @@ -133,8 +135,7 @@ def verify_mfa(self, factors_list, state_token): if self.factor == factor_provider: factor_choice = index self.logger.info( - "Using pre-selected factor choice \ - from ~/.okta-aws" + "Using pre-selected factor choice from ~/.okta-aws" ) break else: @@ -203,7 +204,9 @@ def get_session(self, session_token): """Gets a session cookie from a session token""" data = {"sessionToken": session_token} # https://developer.okta.com/docs/guides/ie-limitations/main/#sessions-apis - resp = self._okta_json_request("POST", "/api/v1/sessions", "get_session", json=data) + resp = self._okta_json_request( + "POST", "/api/v1/sessions", "get_session", json=data + ) self.cache_session_id(resp["id"], resp["expiresAt"]) return resp["id"] @@ -255,7 +258,11 @@ def check_for_desync(self, session_id): raw_resp.raise_for_status() return False except requests.HTTPError as e: - if e.response is None or e.response.status_code != 403 or "Invalid session" not in e.response.text: + if ( + e.response is None + or e.response.status_code != 403 + or "Invalid session" not in e.response.text + ): raise e message = "Okta session invalidated. Refreshing token now..." self.logger.error(message) @@ -273,11 +280,14 @@ def _okta_json_request(self, method, path, context, **kwargs): resp = requests.request(method, url, **kwargs) body = resp.json() if isinstance(body, dict) and body.get("errorCode") == "E0000047": - delay = OKTA_RATE_LIMIT_BACKOFF_BASE_SECONDS * (2 ** attempt) + delay = OKTA_RATE_LIMIT_BACKOFF_BASE_SECONDS * (2**attempt) delay += random.uniform(0, delay) self.logger.warning( "Okta rate-limited in %s; retrying in %.1fs (attempt %d/%d)", - context, delay, attempt + 1, MAX_OKTA_RATE_LIMIT_RETRIES, + context, + delay, + attempt + 1, + MAX_OKTA_RATE_LIMIT_RETRIES, ) time.sleep(delay) continue @@ -311,17 +321,16 @@ def get_apps(self, session_id): sid = "sid=%s" % session_id headers = {"Cookie": sid} # https://developer.okta.com/docs/api/openapi/okta-management/management/tag/UserResources/#tag/UserResources/operation/listAppLinks - resp = self._okta_json_request("GET", "/api/v1/users/me/appLinks", "get_apps", headers=headers) + resp = self._okta_json_request( + "GET", "/api/v1/users/me/appLinks", "get_apps", headers=headers + ) aws_apps = [] for app in resp: if app["appName"] == "amazon_aws": aws_apps.append(app) if not aws_apps: - self.logger.error( - "No AWS apps are available for your user. \ - Exiting." - ) + self.logger.error("No AWS apps are available for your user. Exiting.") sys.exit(1) aws_apps = sorted(aws_apps, key=lambda app: app["sortOrder"]) diff --git a/oktaawscli/okta_auth_config.py b/oktaawscli/okta_auth_config.py index bfea638e..5efc2755 100644 --- a/oktaawscli/okta_auth_config.py +++ b/oktaawscli/okta_auth_config.py @@ -1,4 +1,4 @@ -""" Config helper """ +"""Config helper""" import os from configparser import ConfigParser @@ -7,7 +7,7 @@ from oktaawscli._locking import atomic_write, locked try: - input = raw_input + input = raw_input # type: ignore[name-defined] # noqa: F821 # py2 compat except NameError: pass diff --git a/oktaawscli/okta_awscli.py b/oktaawscli/okta_awscli.py index cd736e7b..9a205aad 100644 --- a/oktaawscli/okta_awscli.py +++ b/oktaawscli/okta_awscli.py @@ -1,14 +1,17 @@ -""" Wrapper script for awscli which handles Okta auth """ -# pylint: disable=C0325,R0913,R0914 +"""Wrapper script for awscli which handles Okta auth""" + +import logging import os from subprocess import call -import logging + import click from filelock import Timeout -from oktaawscli.version import __version__ + +from oktaawscli.aws_auth import AwsAuth from oktaawscli.okta_auth import OktaAuth from oktaawscli.okta_auth_config import OktaAuthConfig -from oktaawscli.aws_auth import AwsAuth +from oktaawscli.version import __version__ + def get_credentials( okta_profile, @@ -149,7 +152,6 @@ def console_output(access_key_id, secret_access_key, session_token, verbose): return exports -# pylint: disable=R0913 @click.command() @click.option("-v", "--verbose", is_flag=True, help="Enables verbose mode") @click.option( @@ -164,9 +166,7 @@ def console_output(access_key_id, secret_access_key, session_token, verbose): help="Forces new STS credentials. \ Skips STS credentials validation.", ) -@click.option( - "--reset", is_flag=True, help="Resets default values in ~/.okta-aws" -) +@click.option("--reset", is_flag=True, help="Resets default values in ~/.okta-aws") @click.option( "-e", "--export", diff --git a/oktaawscli/version.py b/oktaawscli/version.py index e6d77079..fc920e7a 100644 --- a/oktaawscli/version.py +++ b/oktaawscli/version.py @@ -1,2 +1,3 @@ -""" version string """ -__version__ = "0.4.14" +"""version string""" + +__version__ = "0.4.15" diff --git a/pylintrc b/pylintrc new file mode 100644 index 00000000..321d2499 --- /dev/null +++ b/pylintrc @@ -0,0 +1,55 @@ +[MASTER] + +[MESSAGES CONTROL] +# F0401: Unable to import — handled by mypy/imports +# E0611: No name in module — false positives across boto3 etc. +# E1101: %s has no %r member — false positives on dynamic AWS clients +# W0212: Access to protected member — tests reach into mangled names intentionally +# W0703/W0718: Catching too general Exception +# R0901: Too many ancestors +# W0511: TODO/FIXME warnings +# W0231: __init__ from base class not called +# W0127: Self-assigning variable — `input = input` no-op at okta_auth.py:20 (legacy py2/3 carry-over; the actual `raw_input` shim is in okta_auth_config.py) +# C0209: consider-using-f-string — legacy % formatting is intentional +# R0911/R0912/R0913/R0914/R0915/R0917: too-many-* — CLI entry point + auth flows +# W0603: global statement — harmless if reintroduced +# W0622: Redefining builtins — `input/raw_input` compat shim +# W0613: Unused argument — CLI click signatures +# W1201/W1203: lazy logging format — legacy % style is fine +# R1705: no-else-return — readability preference +# C0301: line-too-long — handled by black +# R1716: chained-comparison — disabled defensively; no current violations after this PR's cleanup +# R1710: inconsistent-return-statements — explicit-None additions risk behavior drift +# R1722: consider-using-sys-exit — legacy `exit()` calls are intentional in this CLI; both reach the same termination path here +# W1514: unspecified-encoding — paths are dotfiles assumed to be UTF-8 +# R1732: consider-using-with — bare `open(path, "a").close()` touch idiom in __init__ paths +# C0415: import-outside-toplevel — tests use lazy imports for isolation +# C0116: missing-function-docstring — many test methods rely on class docstrings +# W0621: redefined-outer-name — tests reimport `mock` inside methods intentionally +# W0404: reimported — same as above +disable=F0401,E0611,E1101,W0212,W0703,W0718,R0901,W0511,W0231,W0127, + C0209,R0911,R0912,R0913,R0914,R0915,R0917,W0603,W0622,W0613,W1201,W1203, + R1705,C0301,R1716,R1710,R1722,W1514,R1732,C0415,C0116,W0621,W0404 + +[BASIC] +good-names=i,j,k,v,e,f,ex,bs,fd,p,cm,_ +function-rgx=[a-z_][a-z0-9_]{2,60}$ +method-rgx=[a-z_][a-z0-9_]{2,80}$ +variable-rgx=[a-z_][a-z0-9_]{2,40}$ + +[FORMAT] +max-line-length=120 +max-module-lines=1000 + +[DESIGN] +max-args=15 +max-locals=20 +max-statements=60 +max-attributes=15 +max-branches=15 + +[SIMILARITIES] +min-similarity-lines=6 +ignore-comments=yes +ignore-docstrings=yes +ignore-imports=yes diff --git a/requirements.txt b/requirements.txt index 1a3ffb21..650e1157 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,3 @@ bs4 boto3 ConfigParser filelock -six diff --git a/setup.py b/setup.py index 613e85c3..4847e384 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,6 @@ -from setuptools import setup, find_packages, os +from setuptools import find_packages, setup -here = os.path.abspath(os.path.dirname(__file__)) -exec(open(os.path.join(here, "oktaawscli/version.py")).read()) +from oktaawscli.version import __version__ setup( name="amplify-okta-awscli", @@ -17,5 +16,12 @@ "okta-awscli=oktaawscli.okta_awscli:main", ], }, - install_requires=["requests", "click", "bs4", "boto3", "ConfigParser", "six", "filelock"], + install_requires=[ + "requests", + "click", + "bs4", + "boto3", + "ConfigParser", + "filelock", + ], ) diff --git a/tests/test_locking.py b/tests/test_locking.py index dc971133..e54e5928 100644 --- a/tests/test_locking.py +++ b/tests/test_locking.py @@ -1,4 +1,5 @@ """Tests for oktaawscli._locking.""" + import multiprocessing import os import tempfile @@ -33,7 +34,10 @@ def _child_write_sts(home_dir, profile_name): reset=False, ) auth.write_sts_token( - profile_name, "AKIA_TEST", "secret_TEST", "session_TEST", + profile_name, + "AKIA_TEST", + "secret_TEST", + "session_TEST", ) @@ -119,6 +123,7 @@ def setUp(self): def _make_okta_auth(self): """Build a minimally-wired OktaAuth bypassing __init__ for unit tests.""" import logging + from oktaawscli.okta_auth import OktaAuth auth = OktaAuth.__new__(OktaAuth) @@ -137,6 +142,7 @@ def _make_okta_auth(self): def _make_aws_auth(self, profile): """Build a real AwsAuth pointed at the isolated $HOME.""" import logging + from oktaawscli.aws_auth import AwsAuth return AwsAuth( @@ -165,7 +171,10 @@ def test_acquires_lock_on_credentials_file(self): "oktaawscli.aws_auth.locked", wraps=locking_module.locked ) as mock_locked: auth.write_sts_token( - "test_profile", "AKIA_TEST", "secret_TEST", "session_TEST", + "test_profile", + "AKIA_TEST", + "secret_TEST", + "session_TEST", ) mock_locked.assert_called_once_with(auth.creds_file) @@ -400,10 +409,14 @@ def test_preserves_existing_token_when_writer_raises(self): token_path = os.path.join(self.tempdir, ".okta-token") with open(token_path, "w") as f: - f.write('{"session_id": "original", "expiration_date": "2099-01-01T00:00:00.000Z"}') + f.write( + '{"session_id": "original", "expiration_date": "2099-01-01T00:00:00.000Z"}' + ) auth = self._make_okta_auth() - with mock.patch("oktaawscli.okta_auth.json.dumps", side_effect=RuntimeError("boom")): + with mock.patch( + "oktaawscli.okta_auth.json.dumps", side_effect=RuntimeError("boom") + ): with self.assertRaises(RuntimeError): auth.cache_session_id("new_sess", "2099-01-01T00:00:00.000Z") @@ -451,7 +464,9 @@ def test_get_apps_exits_cleanly_on_error_response(self): mock_resp = mock.MagicMock() mock_resp.json.return_value = error_response mock_resp.status_code = 401 - with mock.patch("oktaawscli.okta_auth.requests.request", return_value=mock_resp): + with mock.patch( + "oktaawscli.okta_auth.requests.request", return_value=mock_resp + ): with self.assertRaises(SystemExit) as cm: auth.get_apps("stale_sid") self.assertEqual(cm.exception.code, 1) @@ -470,7 +485,9 @@ def test_get_session_exits_cleanly_on_error_response(self): mock_resp = mock.MagicMock() mock_resp.json.return_value = error_response mock_resp.status_code = 401 - with mock.patch("oktaawscli.okta_auth.requests.request", return_value=mock_resp): + with mock.patch( + "oktaawscli.okta_auth.requests.request", return_value=mock_resp + ): with self.assertRaises(SystemExit) as cm: auth.get_session("bad_session_token") self.assertEqual(cm.exception.code, 1) @@ -483,9 +500,11 @@ def test_fast_path_skips_lock_when_cached_session_valid(self): from unittest import mock auth = self._make_okta_auth() - with mock.patch.object(auth, "get_cached_session_id", return_value="cached_sid"), \ - mock.patch.object(auth, "check_for_desync", return_value=False), \ - mock.patch("oktaawscli.okta_auth.locked") as mock_locked: + with mock.patch.object( + auth, "get_cached_session_id", return_value="cached_sid" + ), mock.patch.object(auth, "check_for_desync", return_value=False), mock.patch( + "oktaawscli.okta_auth.locked" + ) as mock_locked: result = auth.primary_auth() self.assertEqual(result, "cached_sid") mock_locked.assert_not_called() @@ -505,13 +524,16 @@ def test_slow_path_acquires_lock_when_cache_is_empty(self): fake_resp.json.return_value = {"status": "SUCCESS", "sessionToken": "stoken"} fake_resp.status_code = 200 - with mock.patch.object(auth, "get_cached_session_id", return_value=None), \ - mock.patch.object(auth, "get_session", return_value="fresh_sid") as mock_get_session, \ - mock.patch( - "oktaawscli.okta_auth.locked", - wraps=locking_module.locked, - ) as mock_locked, \ - mock.patch("oktaawscli.okta_auth.requests.request", return_value=fake_resp): + with mock.patch.object( + auth, "get_cached_session_id", return_value=None + ), mock.patch.object( + auth, "get_session", return_value="fresh_sid" + ) as mock_get_session, mock.patch( + "oktaawscli.okta_auth.locked", + wraps=locking_module.locked, + ) as mock_locked, mock.patch( + "oktaawscli.okta_auth.requests.request", return_value=fake_resp + ): result = auth.primary_auth() self.assertEqual(result, "fresh_sid") @@ -529,14 +551,17 @@ def test_slow_path_uses_session_refreshed_by_peer_while_waiting(self): auth = self._make_okta_auth() with mock.patch.object( - auth, "get_cached_session_id", side_effect=[None, "peer_refreshed_sid"], - ) as mock_get_cached, \ - mock.patch.object(auth, "check_for_desync") as mock_desync, \ - mock.patch( - "oktaawscli.okta_auth.locked", - wraps=locking_module.locked, - ) as mock_locked, \ - mock.patch("oktaawscli.okta_auth.requests.request") as mock_post: + auth, + "get_cached_session_id", + side_effect=[None, "peer_refreshed_sid"], + ) as mock_get_cached, mock.patch.object( + auth, "check_for_desync" + ) as mock_desync, mock.patch( + "oktaawscli.okta_auth.locked", + wraps=locking_module.locked, + ) as mock_locked, mock.patch( + "oktaawscli.okta_auth.requests.request" + ) as mock_post: result = auth.primary_auth() self.assertEqual(result, "peer_refreshed_sid") @@ -591,8 +616,9 @@ def test_get_apps_retries_on_rate_limit_then_succeeds(self): self._success_apps_response(), ] - with mock.patch("oktaawscli.okta_auth.requests.request", side_effect=responses) as mock_get, \ - mock.patch("oktaawscli.okta_auth.time.sleep") as mock_sleep: + with mock.patch( + "oktaawscli.okta_auth.requests.request", side_effect=responses + ) as mock_get, mock.patch("oktaawscli.okta_auth.time.sleep") as mock_sleep: label, link = auth.get_apps("sid") self.assertEqual(label, "AWS Prod") @@ -607,15 +633,15 @@ def test_get_apps_exits_after_exhausting_retries(self): auth.app = "AWS Prod" with mock.patch( - "oktaawscli.okta_auth.requests.request", - return_value=self._rate_limit_response(), - ) as mock_get, \ - mock.patch("oktaawscli.okta_auth.time.sleep"): + "oktaawscli.okta_auth.requests.request", + return_value=self._rate_limit_response(), + ) as mock_get, mock.patch("oktaawscli.okta_auth.time.sleep"): with self.assertRaises(SystemExit) as cm: auth.get_apps("sid") self.assertEqual(cm.exception.code, 1) from oktaawscli.okta_auth import MAX_OKTA_RATE_LIMIT_RETRIES + self.assertEqual(mock_get.call_count, MAX_OKTA_RATE_LIMIT_RETRIES) def test_get_session_retries_on_rate_limit_then_succeeds(self): @@ -631,9 +657,11 @@ def test_get_session_retries_on_rate_limit_then_succeeds(self): responses = [self._rate_limit_response(), success] - with mock.patch("oktaawscli.okta_auth.requests.request", side_effect=responses) as mock_post, \ - mock.patch.object(auth, "cache_session_id"), \ - mock.patch("oktaawscli.okta_auth.time.sleep"): + with mock.patch( + "oktaawscli.okta_auth.requests.request", side_effect=responses + ) as mock_post, mock.patch.object(auth, "cache_session_id"), mock.patch( + "oktaawscli.okta_auth.time.sleep" + ): sid = auth.get_session("stoken") self.assertEqual(sid, "fresh_sid") @@ -653,10 +681,9 @@ def test_non_rate_limit_error_exits_without_retry(self): non_rate_limit_resp.status_code = 401 with mock.patch( - "oktaawscli.okta_auth.requests.request", - return_value=non_rate_limit_resp, - ) as mock_get, \ - mock.patch("oktaawscli.okta_auth.time.sleep") as mock_sleep: + "oktaawscli.okta_auth.requests.request", + return_value=non_rate_limit_resp, + ) as mock_get, mock.patch("oktaawscli.okta_auth.time.sleep") as mock_sleep: with self.assertRaises(SystemExit): auth.get_apps("sid")