Repository: dowjones/tokendito (language: python)

Issue:
Clean dependencies

`--aws-profile` doesn't honor `default`
`tokendito --aws-profile default ...` doesn't create `default` profile. It takes the role profile instead. So if the role arn is `role_arn = arn:aws:iam::1234:role/foo-admin`, then the profile is `foo-admin` instead of `default` in `.aws/config`


Discussion / hints:

Fix patch:
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 4b7fba3c..8d5af5c9 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -1,5 +1,3 @@
-# Pull Request Template
-
 ## Description
 <!--- Describe your changes in detail -->
 
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index 821c956c..90dd9e73 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -4,12 +4,11 @@ on:
   push:
     branches:
       - main
-      - master
       - development/2.0.0
   pull_request:
     branches:
       - main
-      - master
+      - feature/2.0.0
     paths-ignore:
       - '**.md'
       - '**.rst'
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index c928450a..123dc728 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -3,13 +3,11 @@ on:
   push:
     branches:
       - main
-      - master
       - development/2.0.0
     tags:
       - '[0-9]+.[0-9]+.[0-9]+'
   pull_request:
     branches:
-      - master
       - main
       - development/2.0.0
 
@@ -30,7 +28,7 @@ jobs:
         pip install setuptools wheel twine
     - name: Build
       run: |
-        python setup.py sdist bdist_wheel --universal
+        python setup.py sdist bdist_wheel
         twine check dist/*
       env:
         DEVBUILD: 1
@@ -57,7 +55,7 @@ jobs:
         pip install setuptools wheel twine
     - name: Build
       run: |
-        python setup.py sdist bdist_wheel --universal
+        python setup.py sdist bdist_wheel
         twine check dist/*
     - name: Publish distribution package to PyPI
       if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
diff --git a/.github/workflows/woke.yml b/.github/workflows/woke.yml
index e512c80c..b17651bf 100644
--- a/.github/workflows/woke.yml
+++ b/.github/workflows/woke.yml
@@ -3,11 +3,9 @@ on:
   push:
     branches:
       - main
-      - master
   pull_request:
     branches:
       - main
-      - master
       - development/2.0.0
 
 jobs:
diff --git a/AUTHORS.md b/AUTHORS.md
new file mode 100644
index 00000000..ee3b7b80
--- /dev/null
+++ b/AUTHORS.md
@@ -0,0 +1,12 @@
+Tokendito is written and maintained by Cloud Security and Engineering at
+Dow Jones, and various contributors:
+
+## Dow Jones Cloud Security and Engineering
+* Sydney Sweeney 
+* Nico Halpern 
+## Patches and more 
+* Kuber Kaul 
+* Steve Stevenson 
+* Roman Sluzhynskyy 
+* Scott Rahner 
+* Basant Singh
diff --git a/AUTHORS.rst b/AUTHORS.rst
deleted file mode 100644
index 29284e8e..00000000
--- a/AUTHORS.rst
+++ /dev/null
@@ -1,17 +0,0 @@
-Tokendito is written and maintained by Cloud Security and Engineering at Dow Jones, and
-various contributors:
-
-Dow Jones Cloud Security and Engineering
-```````````````````````
-
-- Sydney Sweeney
-- Nico Halpern
-- Lars Joergensen
-
-Patches and more
-```````````````````````
-- Kuber Kaul
-- Steve Stevenson
-- Roman Sluzhynskyy
-- Scott Rahner
-- Basant Singh
\ No newline at end of file
diff --git a/MANIFEST.in b/MANIFEST.in
index 8c4a0c68..af98e49f 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,1 +1,1 @@
-include MANIFEST.in README.rst requirements.txt requirements-dev.txt
\ No newline at end of file
+include MANIFEST.in README.md requirements.txt requirements-dev.txt
diff --git a/README.md b/README.md
new file mode 100644
index 00000000..f4a0fb0d
--- /dev/null
+++ b/README.md
@@ -0,0 +1,61 @@
+<p align="center">
+  <img src="https://raw.githubusercontent.com/dowjones/tokendito/main/docs/tokendito.png"/>
+</p>
+
+## Generate temporary AWS credentials via Okta.
+
+[![image](https://github.com/dowjones/tokendito/workflows/Lint%20and%20Test/badge.svg)](https://github.com/dowjones/tokendito/actions)
+[![image](https://img.shields.io/badge/python-3.7%2C%203.8%2C%203.9%2C%203.10%2C%203.11-blueviolet)](https://pypi.org/project/tokendito/)
+[![image](https://github.com/dowjones/tokendito/workflows/Woke/badge.svg)](https://github.com/dowjones/tokendito/actions)
+[![image](https://img.shields.io/badge/license-Apache%202.0-ff69b4)](https://github.com/dowjones/tokendito/blob/main/LICENSE.txt)
+[![image](https://img.shields.io/badge/OS-Mac%2C%20Windows%2C%20Linux-9cf)](https://github.com/dowjones/tokendito/)
+[![image](https://coveralls.io/repos/github/dowjones/tokendito/badge.svg)](https://coveralls.io/github/dowjones/tokendito)
+
+#
+
+![image](https://raw.githubusercontent.com/dowjones/tokendito/main/docs/tokendito-scaled.gif)
+
+Use `tokendito` to generate temporary AWS credentials via Okta for
+programmatic authentication to AWS. Tokendito signs you into Okta and
+uses your existing AWS integration to broker a SAML assertion into
+your AWS accounts, returning
+[STS](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_temp.html)
+tokens into your local `~/.aws/credentials` file.
+
+## What's new
+With the release of tokendito 2.0, many changes and fixes. It is a breaking release: your configuration needs to be updated, the command-line arguments have changed, and support for python < 3.7 has been removed.
+The following changes are part of this release:
+- Set the config file to be platform dependent, and follow the XDG standard.
+- Extend configuration capabilities.
+- Modernize output.
+- Change mfa method from strict match to partial match. 
+- Mask secrets from output logs.
+- Automatically discover AWS URLs.
+- Fix authentication with DUO.
+- Add support for setting loglevel via ini file and env vars.
+- Add support for Python 3.9 and 3.10.
+- And many fixes.
+
+Consult [additional notes](docs/README.md) for how to use tokendito. 
+
+## Requirements
+
+-   Python 3.7+
+-   AWS account(s) federated with Okta
+
+Tokendito is compatible with python 3, and can be installed with either
+pip or pip3.
+
+## Getting started
+
+1.  Install (via PyPi): `pip install tokendito`
+2.  Run `tokendito --configure`.
+3.  Run `tokendito`.
+
+**NOTE**: Advanced users may shorten the `tokendito` interaction to a [single
+command](docs/README.md#single-command-usage).
+
+Have multiple Okta tiles to switch between? View our [multi-tile
+guide](docs/README.md#multi-tile-guide).
+
+### Tips, tricks, troubleshooting, examples, and more docs are [here](docs/README.md)! Also, [contributions are welcome](docs/CONTRIBUTING.md)!
diff --git a/README.rst b/README.rst
deleted file mode 100644
index 19f56fc1..00000000
--- a/README.rst
+++ /dev/null
@@ -1,59 +0,0 @@
-.. image:: https://raw.githubusercontent.com/dowjones/tokendito/master/docs/tokendito.png
-   :align: center
-
-Generate temporary AWS credentials via Okta.
-
-.. image:: https://github.com/dowjones/tokendito/workflows/Lint%20and%20Test/badge.svg
-    :target: https://github.com/dowjones/tokendito/actions
-
-.. image:: https://img.shields.io/badge/python-2.7%2C%203.5%2C%203.6%2C%203.7%2C%203.8-blueviolet
-    :target: https://pypi.org/project/tokendito/
-
-.. image:: https://github.com/dowjones/tokendito/workflows/Woke/badge.svg
-    :target: https://github.com/dowjones/tokendito/actions
-
-.. image:: https://img.shields.io/badge/license-Apache%202.0-ff69b4
-    :target: https://github.com/dowjones/tokendito/blob/master/LICENSE.txt
-
-.. image:: https://img.shields.io/badge/OS-Mac%2C%20Windows%2C%20Linux-9cf
-    :target: https://github.com/dowjones/tokendito/
-
-.. image:: https://coveralls.io/repos/github/dowjones/tokendito/badge.svg
-    :target: https://coveralls.io/github/dowjones/tokendito
-
-|
-|
-
-.. image:: https://raw.githubusercontent.com/dowjones/tokendito/master/docs/tokendito-scaled.gif
-
-
-**WARNING: Python2.7 and Python3.5 support will be dropped in a near time, pin tokendito version to current if you want to keep it working in automation.**
-
-NOTE: Advanced users may shorten the tokendito interaction to a `single command <https://github.com/dowjones/tokendito/tree/master/docs#single-command-usage>`_.
-
-.. _STS: https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_temp.html
-
-Use tokendito to generate temporary AWS credentials via Okta for programmatic authentication to AWS. Tokendito signs you in to Okta and uses your existing AWS integration to broker your SAML assertion into your AWS accounts, returning STS_ tokens into your local ``~/.aws/credentials`` file.
-
-Requirements
-------------
-
-* Python 2.7.10+
-* Your AWS account is federated in Okta
-
-tokendito is compatible with both python 2 and 3, and can be installed with either pip or pip3.
-
-Getting started
----------------
-
-#. Install (via PyPi): ``pip install tokendito``
-
-#. Run ``tokendito --configure``.
-
-#. Run ``tokendito``.
-
-Have multiple Okta tiles to switch between? View our `multi-tile guide <https://github.com/dowjones/tokendito/tree/master/docs#multi-tile-guide>`_.
-
-=====================================================================================================================================================================================================================================================
-Tips, tricks, troubleshooting, examples, and more docs are `here <https://github.com/dowjones/tokendito/blob/master/docs/README.rst>`_! Also, `contributions are welcome <https://github.com/dowjones/tokendito/blob/master/docs/CONTRIBUTING.rst>`_!
-=====================================================================================================================================================================================================================================================
diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md
new file mode 100644
index 00000000..bf335369
--- /dev/null
+++ b/docs/CONTRIBUTING.md
@@ -0,0 +1,27 @@
+# Contributing
+
+Ideally create a topic branch for every separate change you make. Each
+logical change should be its own separate pull request. For example:
+
+1.  Fork the repository.
+2.  Create a branch (`git checkout -b feature/my-new-feature`,
+    `git checkout -b fix/my-fix`)
+3.  Add and commit your changes to your fork
+    (`git commit -m 'Added some feature'`)
+4.  Push to your fork (`git push origin feature/my-new-feature`)
+5.  Create new Pull Request from your fork onto the tokendito repo
+6.  Assign the PR to a team member.
+
+See [GitHub Flow](https://guides.github.com/introduction/flow/)
+
+## Git commit messages
+
+-   Separate subject from body with a blank line
+-   Limit the subject line to 50 characters
+-   Capitalize the subject line
+-   Do not end the subject line with a period
+-   Use the imperative mood in the subject line
+-   Wrap the body at 72 characters
+-   Use the body to explain what and why vs. how
+
+See best practices on [commit messages](https://chris.beams.io/posts/git-commit/)
diff --git a/docs/CONTRIBUTING.rst b/docs/CONTRIBUTING.rst
deleted file mode 100644
index 83755c0a..00000000
--- a/docs/CONTRIBUTING.rst
+++ /dev/null
@@ -1,29 +0,0 @@
-============
-Contributing
-============
-
-Ideally create a topic branch for every separate change you make. Each logical change should be its own separate pull request. For example:
-
-
-#. Fork the repository.
-#. Create a  branch (\ ``git checkout -b feature/my-new-feature``\ , ``git checkout -b fix/my-fix``\ )
-#. Add and commit your changes to your fork (\ ``git commit -m 'Added some feature'``\ )
-#. Push to your fork (\ ``git push origin feature/my-new-feature``\ )
-#. Create new Pull Request from your fork onto the tokendito repo
-#. Assign the PR to a team member.
-
-See `GitHub Flow <https://guides.github.com/introduction/flow/>`_
-
-Git commit messages
-^^^^^^^^^^^^^^^^^^^
-
-
-* Separate subject from body with a blank line
-* Limit the subject line to 50 characters
-* Capitalize the subject line
-* Do not end the subject line with a period
-* Use the imperative mood in the subject line
-* Wrap the body at 72 characters
-* Use the body to explain what and why vs. how
-
-See best practices on `commit messages <https://chris.beams.io/posts/git-commit/>`_
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 00000000..367d8693
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,170 @@
+## Table of Contents
+
+* [Usage Examples](#usage-examples)
+    * [Default usage](#default-usage)
+    * [Multi-tile-Guide](#multi-tile-guide) 
+    * [Single-command usage](#single-command-usage)
+* [Additional Usage Reference](#additional-usage=reference)
+    * [Supported MFA Options](#supported-mfa-options)
+    * [To upgrade](#to-upgrade)
+    * [Installing from github](#installing-from-github) 
+* [Troubleshooting](#troubleshooting)
+* [Design and Limitations](#design-and-limitations)
+* [Configuration settings and precedence](#configuration-settings-and-precedence)
+
+
+
+# Usage Examples  
+
+## Default usage
+
+Configure your profile by running tokendito with the `--configure` flag, or by populating your `tokendito.ini` file as [here](tokendito.ini.md).
+
+Then execute: `tokendito` in your command line.
+
+## Multi-tile Guide
+
+If you have multiple AWS-type Okta tiles assigned to you, please update
+your local [tokendito.ini](tokendito.ini.md) file with the links to
+your AWS tiles in Okta. You can get the link to your tile by right
+clicking on the tile in Okta and selecting \"Copy Link URL.\" This file
+supports multiple profiles, in case there is a need to connect with
+different Okta Orgs and tiles. tokendito can access the profiles by
+name, by passing in the `--profile` parameter.
+
+ex: `tokendito --profile my_prod_okta_tile`
+
+Without specifying a specific profile, tokendito will look for a default
+profile within that file.
+
+## Single-command usage
+
+tokendito accepts all of the necessary parameters to be able to generate
+your STS tokens with a single command. There are a couple of ways to do
+this!
+
+You can just pass in your information at runtime:
+
+``` sh
+tokendito --username prod_service_user@company.com \
+--role-arn arn:aws:iam::123456789000:role/dowjones-hammer-engineer \
+--mfa push \
+--okta-aws-tile https://acme.oktapreview.com/home/amazon_aws/b07384d113edec49eaa6/123 \
+```
+
+Or you can put your parameters into a single [profile](tokendito.ini.md) and reference that profile.
+
+``` txt
+[engineer]
+okta_aws_tile = https://acme.oktapreview.com/home/amazon_aws/b07384d113edec49eaa6/123
+okta_username = jane.doe@acme.com
+mfa = push
+role_arn = arn:aws:iam::123456789000:role/engineer
+```
+
+And execute:
+
+``` sh
+tokendito --profile engineer
+```
+
+Regarding the Okta password, we are fans of automation but do not
+recommend passing in the password to tokendito via plaintext or storing
+it in your environment locally.
+
+
+# Additional Usage Reference
+
+``` txt
+usage: tokendito [-h] [--version] [--configure] [--username OKTA_USERNAME] [--password OKTA_PASSWORD] [--profile USER_CONFIG_PROFILE] [--config-file USER_CONFIG_FILE]
+                 [--loglevel {DEBUG,INFO,WARN,ERROR}] [--log-output-file USER_LOG_OUTPUT_FILE] [--aws-config-file AWS_CONFIG_FILE] [--aws-output AWS_OUTPUT]
+                 [--aws-profile AWS_PROFILE] [--aws-region AWS_REGION] [--aws-role-arn AWS_ROLE_ARN] [--aws-shared-credentials-file AWS_SHARED_CREDENTIALS_FILE]
+                 [--okta-org OKTA_ORG | --okta-tile OKTA_TILE] [--okta-mfa OKTA_MFA] [--okta-mfa-response OKTA_MFA_RESPONSE] [--quiet]
+
+Gets a STS token to use with the AWS CLI and SDK.
+
+options:
+  -h, --help            show this help message and exit
+  --version             Displays version and exit
+  --configure           Prompt user for configuration parameters
+  --username OKTA_USERNAME
+                        username to login to Okta. You can also use the OKTA_USERNAME environment variable.
+  --password OKTA_PASSWORD
+                        password to login to Okta. You can also user the OKTA_PASSWORD environment variable.
+  --profile USER_CONFIG_PROFILE
+                        Tokendito configuration profile to use.
+  --config-file USER_CONFIG_FILE
+                        Use an alternative configuration file. Defaults to ~/.local/share/tokendito/tokendito.ini 
+  --loglevel {DEBUG,INFO,WARN,ERROR}, -l {DEBUG,INFO,WARN,ERROR}
+                        [DEBUG|INFO|WARN|ERROR], default loglevel is WARNING.
+  --log-output-file USER_LOG_OUTPUT_FILE
+                        Optional file to log output to.
+  --aws-config-file AWS_CONFIG_FILE
+                        AWS Configuration file to write to.
+  --aws-output AWS_OUTPUT
+                        Sets the output type for the AWS profile.
+  --aws-profile AWS_PROFILE
+                        AWS profile to save as in the credentials file.
+  --aws-region AWS_REGION
+                        Sets the region for the AWS profile.
+  --aws-role-arn AWS_ROLE_ARN
+                        Sets the IAM role.
+  --aws-shared-credentials-file AWS_SHARED_CREDENTIALS_FILE
+                        AWS credentials file to write to.
+  --okta-org OKTA_ORG   Set the Okta Org base URL. This enables role auto-discovery
+  --okta-tile OKTA_TILE
+                        Okta tile URL to use.
+  --okta-mfa OKTA_MFA   Sets the MFA method
+  --okta-mfa-response OKTA_MFA_RESPONSE
+                        Sets the MFA response to a challenge
+  --quiet               Suppress output```
+```
+
+## Supported MFA Options
+
+-   Native Okta factors (push, call, sms, TOTP) except Biometrics (FIDO
+    webauthn)
+-   Google Authenticator TOTP
+-   Duo (push, call, sms, TOTP)
+
+## To upgrade
+
+`pip install --upgrade tokendito`
+
+## Installing from github
+
+`pip install git+ssh://git@github.com/dowjones/tokendito.git@<version>`
+
+For instance,
+`pip install git+ssh://git@github.com/dowjones/tokendito.git@2.0.0`
+
+# Troubleshooting
+
+Configuration issues with tokendito can usually be addressed by
+validating your environment\'s AWS configuration profile(s) located at:
+
+[\$HOME/.aws/config](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-files.html)
+
+[\$HOME/.aws/credentials](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-files.html)
+
+[tokendito.ini](tokendito.ini.md)
+
+# Design and Limitations
+
+-   This tool does not cache and reuse Okta session IDs
+
+
+# Configuration settings and precedence
+
+Tokendito uses credentials and configuration settings located in
+multiple places, such as the system or user environment variables, local
+configuration files, or explicitly declared on the command line as a
+parameter. Certain locations take precedence over others. The AWS CLI
+credentials and configuration settings take precedence in the following
+order:
+
+1)  Command line options -- Overrides settings in any other location. You can specify \--username, \--role-arn, \--okta-aws-tile, and \--mfa as parameters on the command line.
+1)  Environment variables -- You can store values in your system\'s environment variables. It overrides the configuration file.
+3)  User configuration file -- The user configuration file is updated when you run the command tokendito \--configure. tokendito uses [platformdirs](https://github.com/platformdirs/platformdirs) to store user configuration in the [tokendito.ini](tokendito.ini.md)file. This file can contain the credential details for the default profile and any named profiles.
+
+[Pull requests welcome](CONTRIBUTING.md)!
diff --git a/docs/README.rst b/docs/README.rst
deleted file mode 100644
index daf67ea5..00000000
--- a/docs/README.rst
+++ /dev/null
@@ -1,150 +0,0 @@
-==========
-More Docs!
-==========
-
-.. contents:: Table of contents
-.. section-numbering::
-
-Usage Examples:
----------------
-
-Default usage
-"""""""""""""
-Configure your profile by running tokendito with the ``--configure`` flag, or by populating your ``$HOME/.aws/okta_auth`` according to the `example <okta_auth.example>`_ .
-
-Then execute: ``tokendito`` in your command line.
-
-
-Multi-tile Guide!
-"""""""""""""""""
-If you have multiple AWS-type Okta tiles assigned to you, please update your local `$HOME/.aws/okta_auth <okta_auth.example>`_ with the links to your AWS tiles in Okta. You can get the link to your tile by right clicking on the tile in Okta and selecting "Copy Link URL."
-This file supports multiple profiles, in case there is a need to connect with different Okta Orgs and tiles. tokendito can access the profiles by name, by passing in the ``--okta-profile`` parameter.
-
-ex:
-``tokendito --okta-profile my_prod_okta_tile``
-
-Without specifying a specific profile, tokendito will look for a default profile within that file.
-
-
-Single-command usage
-""""""""""""""""""""
-tokendito accepts all of the necessary parameters to be able to generate your STS tokens with a single command. There are a couple of ways to do this!
-
-You can just pass in your information at runtime:
-
-.. code-block:: sh
-
-    tokendito --username prod_service_user@company.com \
-    --role-arn arn:aws:iam::123456789000:role/dowjones-hammer-engineer \
-    --mfa-method push \
-    --okta-aws-app-url https://acme.oktapreview.com/home/amazon_aws/b07384d113edec49eaa6/123 \
-
-
-Or you can put your parameters into a single `profile <okta_auth.example>`_ in ``$HOME/.aws/okta_auth`` and reference that profile.
-
-.. code-block:: txt
-
-    [hammer-engineer]
-    okta_aws_app_url = https://acme.oktapreview.com/home/amazon_aws/b07384d113edec49eaa6/123
-    okta_username = jane.doe@acme.com
-    mfa_method = push
-    role_arn = arn:aws:iam::123456789000:role/dowjones-hammer-engineer
-
-
-And execute:
-
-.. code-block:: sh
-
-    tokendito -op hammer-engineer
-
-Regarding the Okta password, we are fans of automation but do not recommend passing in the password to tokendito via plaintext or storing it in your environment locally.
-
-Additional Usage Reference
---------------------------
-
-.. code-block:: txt
-
-    usage: tokendito  [-h] [--version] [--configure] [--username USERNAME]
-                      [--password PASSWORD] [--config-file CONFIG_FILE]
-                      [--okta-aws-app-url OKTA_AWS_APP_URL]
-                      [--okta-profile OKTA_PROFILE] [--aws-region AWS_REGION]
-                      [--aws-output AWS_OUTPUT] [--aws-profile AWS_PROFILE]
-                      [--mfa-method MFA_METHOD] [--mfa-response MFA_RESPONSE]
-                      [--role-arn ROLE_ARN] [--output-file OUTPUT_FILE]
-                      [--loglevel {DEBUG,INFO,WARN,ERROR}]
-
-    Gets a STS token to use with the AWS CLI
-
-    optional arguments:
-      -h, --help            show this help message and exit
-      --version, -v         Displays version and exit
-      --configure, -c       Prompt user for configuration parameters
-      --username USERNAME, -u USERNAME
-                            username to login to Okta. You can also use the
-                            OKTA_USERNAME environment variable.
-      --password PASSWORD, -p PASSWORD
-                            password to login to Okta. You can also user the
-                            OKTA_PASSWORD environment variable.
-      --config-file CONFIG_FILE, -C CONFIG_FILE
-                            Use an alternative configuration file
-      --okta-aws-app-url OKTA_AWS_APP_URL, -ou OKTA_AWS_APP_URL
-                            Okta App URL to use.
-      --okta-profile OKTA_PROFILE, -op OKTA_PROFILE
-                            Okta configuration profile to use.
-      --aws-region AWS_REGION, -r AWS_REGION
-                            Sets the AWS region for the profile
-      --aws-output AWS_OUTPUT, -ao AWS_OUTPUT
-                            Sets the AWS output type for the profile
-      --aws-profile AWS_PROFILE, -ap AWS_PROFILE
-                            Override AWS profile to save as in the credentials
-                            file.
-      --mfa-method MFA_METHOD, -mm MFA_METHOD
-                            Sets the MFA method
-      --mfa-response MFA_RESPONSE, -mr MFA_RESPONSE
-                            Sets the MFA response to a challenge
-      --role-arn ROLE_ARN, -R ROLE_ARN
-                            Sets the IAM role
-      --output-file OUTPUT_FILE, -o OUTPUT_FILE
-                            Log output to filename
-      --loglevel {DEBUG,INFO,WARN,ERROR}, -l {DEBUG,INFO,WARN,ERROR}
-                            [DEBUG|INFO|WARN|ERROR], default loglevel is WARNING.
-                            Note: DEBUG level may display credentials
-
-
-Supported MFA Options:
-""""""""""""""""""""""
-- Native Okta factors (push, call, sms, TOTP) *except Biometrics (FIDO webauthn)*
-- Google Authenticator TOTP
-- Duo (push, call, sms, TOTP) NOTE: These methods are currently *not* pre-configurable in tokendito settings and have to be selected during runtime.
-
-
-To upgrade:
-"""""""""""
-``pip install --upgrade tokendito``
-
-
-Installing from github:
-"""""""""""""""""""""""
-
-``pip install git+ssh://git@github.com/dowjones/tokendito.git@<version>``
-
-For instance, ``pip install git+ssh://git@github.com/dowjones/tokendito.git@1.0.1``
-
-Troubleshooting:
-----------------
-
-Configuration issues with tokendito can usually be addressed by validating your environment's AWS configuration profile(s) located at:
-
-`$HOME/.aws/config <https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-files.html>`_
-
-`$HOME/.aws/credentials <https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-files.html>`_
-
-`$HOME/.aws/okta_auth <okta_auth.example>`_
-
-
-Design & Limitations
---------------------
-
-* This tool does not cache and reuse Okta session IDs
-
-`Pull requests welcome <CONTRIBUTING.rst>`_!
diff --git a/docs/okta_auth.example b/docs/okta_auth.example
deleted file mode 100644
index b6ec8e83..00000000
--- a/docs/okta_auth.example
+++ /dev/null
@@ -1,11 +0,0 @@
-[default]
-okta_aws_app_url = https://acme.oktapreview.com/home/amazon_aws/b07384d113edec49eaa6/123
-okta_username = jane.doe@acme.com
-mfa_method = push
-
-[my_prod_okta_tile]
-okta_aws_app_url = https://acme.okta.com/home/amazon_aws/b07384d113edec49f00d/272?fromHome=true
-
-[my_dev_okta_tile]
-okta_aws_app_url = https://acme.oktapreview.com/home/amazon_aws/b07384d113edec49eaa6/123
-okta_username = jane.doe@acme.com
\ No newline at end of file
diff --git a/docs/tokendito.ini.md b/docs/tokendito.ini.md
new file mode 100644
index 00000000..06d91597
--- /dev/null
+++ b/docs/tokendito.ini.md
@@ -0,0 +1,15 @@
+tokendito uses [platformdirs](https://github.com/platformdirs/platformdirs) to manage the location of the user configuration file tokendito.ini. That file may contain different configuration for different profiles, as:
+```
+[default]
+okta_org = https://acme.okta.com/
+okta_username = jane.doe@acme.com
+mfa = push
+
+[my_prod_okta_tile]
+okta_tile = https://acme.okta.com/home/amazon_aws/b07384d113edec49f00d/272?fromHome=true
+
+[my_dev_okta_tile]
+okta_tile = https://acme.oktapreview.com/home/amazon_aws/b07384d113edec49eaa6/123
+okta_username = jane.doe@acme.com
+```
+to select a given profile, use the `--profile $name` option, otherwise the default profile will be selected. In the above tokendito.ini file, using `--profile my_dev_okta_tile` would select the configuration values for the [my_dev_okta_tile] profile.
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 00000000..9d131ffa
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,14 @@
+[tool.black]
+line-length = 100
+target-version = ['py37', 'py38', 'py39', 'py310', 'py311']
+
+[tool.coverage.report]
+exclude_lines = [
+    "break",
+    "except KeyboardInterrupt:",
+    "if __name__ == .__main__.:",
+    "if __package__ is None:",
+    "logger.debug",
+    "pragma: no cover",
+    "print..Invalid input, try again...",
+    ]
diff --git a/requirements-dev.txt b/requirements-dev.txt
index ab5ced87..6b2d778d 100755
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -1,16 +1,16 @@
 -r ./requirements.txt
-black==20.8b1; python_version >= '3.6'
+black>=22.1.0
 coveragepy-lcov; python_version >= '3.8'
-docutils<0.16,>=0.10
-flake8
-flake8-black; python_version >= '3.6'
-flake8-colors
+docutils
+flake8>=5.0.0
+flake8-black
 flake8-docstrings
-flake8-import-order>=0.9
+flake8-import-order
+moto
 pep8-naming
-pexpect>=4.6.0
-pydocstyle==3.0.0
-pylint>=1.8.4
+pexpect
+pydocstyle
+pylint
 pyotp
 pyroma
 pytest
diff --git a/requirements.txt b/requirements.txt
index aaf9df9e..7a106b8f 100755
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,10 +1,4 @@
-boto3>=1.9.0
-requests>=2.19.0
-configparser>=3.5.0
-future>=0.16.0
-pyOpenSSL>=18.0.0
-cryptography==3.3.2; python_version == '2.7'
 beautifulsoup4>=4.6.0
-lxml>=4.3.0
-pytz==2021.1
-tzlocal==2.1
\ No newline at end of file
+botocore>=1.12.36
+platformdirs>=2.5.4
+requests>=2.19.0
diff --git a/setup.py b/setup.py
index fc54fb07..2de0da67 100755
--- a/setup.py
+++ b/setup.py
@@ -13,15 +13,18 @@
 
 here = os.path.abspath(os.path.dirname(__file__))
 
-with open(os.path.join(here, "README.rst"), encoding=sys.stdin.encoding) as f:
+with open(os.path.join(here, "README.md"), encoding=sys.stdin.encoding) as f:
     long_description = f.read()
 
 with open("requirements.txt") as f:
     required = f.read().splitlines()
 
 about = {}
-with open(os.path.join(here, "tokendito", "__version__.py"), "r") as f:
-    exec(f.read(), about)
+with open(os.path.join(here, "tokendito", "__init__.py")) as f:
+    for line in f:
+        if line.startswith("__"):
+            split_line = line.strip().split(" = ")
+            about[split_line[0]] = "".join(split_line[1:]).replace('"', "")
 
 if "DEVBUILD" in os.environ:
     now = datetime.datetime.now()
@@ -45,22 +48,19 @@
         "Intended Audience :: End Users/Desktop",
         "Intended Audience :: Developers",
         "Intended Audience :: System Administrators",
-        "Programming Language :: Python :: 2",
-        "Programming Language :: Python :: 2.7",
-        "Programming Language :: Python :: 3",
-        "Programming Language :: Python :: 3.5",
-        "Programming Language :: Python :: 3.6",
         "Programming Language :: Python :: 3.7",
         "Programming Language :: Python :: 3.8",
+        "Programming Language :: Python :: 3.9",
+        "Programming Language :: Python :: 3.10",
+        "Programming Language :: Python :: 3.11",
     ],
     keywords=["okta", "aws", "sts"],
     packages=find_packages(exclude=["contrib", "docs", "tests", ".tox"]),
-    python_requires=">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*",
+    python_requires=">=3.7",
     license=about["__license__"],
     zip_safe=False,
     install_requires=[required],
     entry_points={
         "console_scripts": ["tokendito=tokendito.__main__:main"],
     },
-    # $ pip install -e . [dev,test]
 )
diff --git a/tokendito/__init__.py b/tokendito/__init__.py
index 885ff6f0..6ba5bfa3 100644
--- a/tokendito/__init__.py
+++ b/tokendito/__init__.py
@@ -1,4 +1,129 @@
 # vim: set filetype=python ts=4 sw=4
 # -*- coding: utf-8 -*-
 """tokendito module initialization."""
-__all__ = []
+import json
+import os
+from os.path import expanduser
+import sys
+
+from platformdirs import user_config_dir
+
+__version__ = "2.0.0"
+__title__ = "tokendito"
+__description__ = "Get AWS STS tokens from Okta SSO"
+__long_description_content_type__ = "text/markdown"
+__url__ = "https://github.com/dowjones/tokendito"
+__author__ = "tokendito"
+__author_email__ = "tokendito@dowjones.com"
+__license__ = "Apache 2.0"
+
+
+class Config(object):
+    """Creates configuration variables for the application."""
+
+    # Instantiated objects can get Class defaults with get_defaults()
+    _defaults = dict(
+        user=dict(
+            config_dir=user_config_dir(appname=__title__, appauthor=False),
+            config_file=os.path.join(
+                user_config_dir(appname=__title__, appauthor=False), f"{__title__}.ini"
+            ),
+            config_profile="default",
+            encoding=sys.stdin.encoding,
+            loglevel="INFO",
+            log_output_file="",
+            mask_items=[],
+            quiet=False,
+        ),
+        aws=dict(
+            config_file=os.path.join(expanduser("~"), ".aws", "config"),
+            shared_credentials_file=os.path.join(expanduser("~"), ".aws", "credentials"),
+            output="json",
+            profile=None,
+            region="us-east-1",
+            role_arn=None,
+        ),
+        okta=dict(
+            username="",
+            password="",
+            mfa=None,
+            mfa_response=None,
+            tile=None,
+            org=None,
+        ),
+    )
+
+    def __init__(self, **kwargs):
+        """Create Config instance.
+
+        :param **kwargs: Keyword arguments. The argument key must be exist in the class,
+        and the value must be a dictionary. If no arguments are passed, an object with
+        defaults values is created.
+        :raises AttributeError: raised if the keyword passed does not exist.
+        :raises KeyError: raised if the value passed for a keyword is not a dictionary.
+        :raises ValueError:  raised if the configuration value does not exist in the object.
+        """
+        self.aws = dict()
+        self.user = dict()
+        self.okta = dict()
+
+        if kwargs:
+            # Argument validation
+            self._check_constraints(**kwargs)
+            # We create an object that contains only the values passed in.
+            for (key, val) in kwargs.items():
+                self.__dict__[key].update(val)
+        else:
+            self.set_defaults()
+
+    def __repr__(self):
+        """Provide a reusable representation of the object."""
+        return json.dumps(self.__dict__, sort_keys=True, indent=4)
+
+    def __str__(self):
+        """Provide a string representation of the object."""
+        return f"{json.dumps(self.__dict__, sort_keys=True)}"
+
+    def __eq__(self, other):
+        """Test equality with another object, using the JSON representation."""
+        return repr(other) == repr(self)
+
+    def update(self, other):
+        """Copy values from another Config object."""
+        self._check_constraints(**other.__dict__)
+        for key in other.__dict__.keys():
+            self.__dict__[key].update(other.__dict__[key])
+        return self
+
+    def _check_constraints(self, **kwargs):
+        """Ensure dictionaries that are part of the object are valid.
+
+        :param self: Implicit reference to object.
+        :param kwargs: dictionaries to check.
+        :return: boolean on success, or raises an error
+        :raises AttributeError: raised if the keyword passed does not exist.
+        :raises KeyError: raised if the value passed for a keyword is not a dictionary.
+        :raises ValueError: raised if the configuration value does not exist in the object.
+        """
+        for (key, val) in kwargs.items():
+            # Guard against improper initialization.
+            if key not in self._defaults:
+                raise AttributeError(f"'{type(self).__name__}' object has no attribute '{key}'")
+            if type(val) is not dict:
+                raise KeyError(f"'{key}' must be a {type(dict())}, not '{type(val)}'")
+            for subkey in val.keys():
+                if subkey not in self._defaults[key]:
+                    raise ValueError(f"'{subkey}' not available for assignment to key '{key}'")
+        return True
+
+    def set_defaults(self):
+        """Update the object to default settings."""
+        for key in self._defaults.keys():
+            setattr(self, key, self._defaults[key])
+
+    def get_defaults(self):
+        """Retrieve default settings."""
+        return getattr(self, "_defaults")
+
+
+config = Config()
diff --git a/tokendito/__main__.py b/tokendito/__main__.py
index 65716d70..68fa0fba 100755
--- a/tokendito/__main__.py
+++ b/tokendito/__main__.py
@@ -2,15 +2,8 @@
 # vim: set filetype=python ts=4 sw=4
 # -*- coding: utf-8 -*-
 """tokendito module entry point."""
-
-from __future__ import absolute_import, division, print_function, unicode_literals
-
 import sys
 
-from future import standard_library
-
-standard_library.install_aliases()
-
 
 def main(args=None):  # needed for console script
     """Packge entry point."""
diff --git a/tokendito/__version__.py b/tokendito/__version__.py
deleted file mode 100644
index f913ede6..00000000
--- a/tokendito/__version__.py
+++ /dev/null
@@ -1,11 +0,0 @@
-# vim: set filetype=python ts=4 sw=4
-# -*- coding: utf-8 -*-
-"""tokendito version."""
-__version__ = "1.3.1"
-__title__ = "tokendito"
-__description__ = "Get AWS STS tokens from Okta SSO"
-__long_description_content_type__ = "text/x-rst"
-__url__ = "https://github.com/dowjones/tokendito"
-__author__ = "tokendito"
-__author_email__ = "tokendito@dowjones.com"
-__license__ = "Apache 2.0"
diff --git a/tokendito/aws.py b/tokendito/aws.py
new file mode 100644
index 00000000..9cecac68
--- /dev/null
+++ b/tokendito/aws.py
@@ -0,0 +1,222 @@
+# vim: set filetype=python ts=4 sw=4
+# -*- coding: utf-8 -*-
+"""This module handles the all aws workflow operations.
+
+Tasks include:
+1. Aws Authentication with SAML
+2. Updating the AWS Credentials
+3. Updating the AWS Config
+
+"""
+import codecs
+import logging
+import sys
+
+from botocore import UNSIGNED
+from botocore.client import Config
+from botocore.exceptions import ClientError
+import botocore.session
+import requests
+from tokendito import user
+
+
+logger = logging.getLogger(__name__)
+
+
+def get_regions(profile=None):
+    """Get avaliable regions from botocore.
+
+    :return: List of available regions.
+    """
+    regions = []
+    try:
+        session = botocore.session.get_session(env_vars=profile)
+        regions = session.get_available_regions("sts")
+        logger.debug(f"Found AWS regions: {regions}")
+    except Exception:
+        pass
+    return regions
+
+
+def get_output_types():
+    """Provide available output types.
+
+    Currently, this cannot be done dynamically.
+    :return: List of available output types.
+    """
+    return ["json", "text", "csv", "yaml", "yaml-stream"]
+
+
+def authenticate_to_roles(secret_session_token, urls, cookies=None):
+    """Authenticate AWS user with saml.
+
+    :param secret_session_token: secret session token
+    :param urls: list of tuples or tuple, with tiles info
+    :param cookies: html cookies
+    :return: response text
+
+    """
+    payload = {"onetimetoken": secret_session_token}
+    url_list = [urls] if isinstance(urls, tuple) else urls
+    responses = []
+    tile_count = len(url_list)
+    plural = ""
+    if tile_count > 1:
+        plural = "s"
+
+    logger.info(f"Discovering roles in {tile_count} tile{plural}.")
+    for url, label in url_list:
+        try:
+            logger.debug(f"Performing role discovery in {url}")
+
+            response = requests.get(url, params=payload, cookies=cookies)
+            response.raise_for_status()
+            saml_response_string = response.text
+        except Exception as err:
+            logger.error(f"There was an error with the call to {url}: {err}")
+            sys.exit(1)
+
+        saml_xml = user.validate_saml_response(saml_response_string)
+        responses.append((url, saml_response_string, saml_xml, label))
+
+    return responses
+
+
+def assume_role(role_arn, provider_arn, saml):
+    """Return AssumeRoleWithSaml API response.
+
+    :param role_arn: IAM role arn to assume
+    :param provider_arn: ARN of saml-provider resource
+    :param saml: decoded saml response from okta
+    :return: AssumeRoleWithSaml API response
+
+    """
+    default_error = "Unable to assume role {}: {}"
+
+    encoded_xml = codecs.encode(saml.encode("utf-8"), "base64")
+    assume_role_response = None
+    # Attempt to assume a role with the following durations:
+    # 12h, 8h, 6h, 4h, 2h, 1h, 30m, 15m
+    session_times = [43200, 28800, 21600, 14400, 7200, 3600, 1800, 900, "exit"]
+    for duration in session_times:
+        if duration == "exit":
+            logger.error(
+                default_error.format(
+                    role_arn,
+                    f"IAM role session time is not within set: {session_times[:-1]}",
+                )
+            )
+            sys.exit(2)
+
+        assume_role_response = handle_assume_role(
+            role_arn, provider_arn, encoded_xml, duration, default_error
+        )
+        if "Credentials" in assume_role_response:
+            break
+
+    return assume_role_response
+
+
+def handle_assume_role(role_arn, provider_arn, encoded_xml, duration, default_error):
+    """Handle assume role with saml.
+
+    :param role_arn: IAM role arn to assume
+    :param provider_arn: ARN of saml-provider resource
+    :param saml: decoded saml response from okta
+    :return: AssumeRoleWithSaml API responses
+    """
+    logger.debug(f"Attempting session time [{duration}]")
+    try:
+        session = botocore.session.get_session()
+        client = session.create_client("sts", config=Config(signature_version=UNSIGNED))
+        assume_role_response = client.assume_role_with_saml(
+            RoleArn=role_arn,
+            PrincipalArn=provider_arn,
+            SAMLAssertion=encoded_xml.decode(),
+            DurationSeconds=duration,
+        )
+        # Client Exceptions
+    except ClientError as error:
+        if error.response["Error"]["Code"] == "ValidationError":
+            logger.debug(
+                f"AssumeRoleWithSaml failed with {error.response['Error']['Code']} "
+                f"for duration {duration}"
+            )
+            assume_role_response = "continue"
+        elif error.response["Error"]["Code"] == "AccessDenied":
+            errmsg = f"Error assuming intermediate {provider_arn} SAML role"
+            logger.error(errmsg)
+            sys.exit(2)
+        else:
+            logger.error(default_error.format(role_arn, str(error)))
+            sys.exit(1)
+        # Service Exceptions
+    except Exception as error:
+        logger.error(default_error.format(role_arn, str(error)))
+        sys.exit(1)
+
+    return assume_role_response
+
+
+def assert_credentials(role_response={}):
+    """Validate the temporary AWS credentials.
+
+    :param assume_role_response: dictionary with response. It should contain the credentials to AWS.
+    :return: Dictionary with identity object
+
+    """
+    logger.debug("Validate the temporary AWS credentials")
+
+    try:
+        aws_access_key = role_response["Credentials"]["AccessKeyId"]
+        aws_secret_key = role_response["Credentials"]["SecretAccessKey"]
+        aws_session_token = role_response["Credentials"]["SessionToken"]
+    except (KeyError, TypeError):
+        logger.error("SAML Response did not contain credentials")
+        sys.exit(1)
+
+    identity = {}
+    try:
+        session = botocore.session.get_session()
+
+        client = session.create_client(
+            "sts",
+            aws_access_key_id=aws_access_key,
+            aws_secret_access_key=aws_secret_key,
+            aws_session_token=aws_session_token,
+        )
+        identity = client.get_caller_identity()
+        logger.debug(f"Logged on with role ARN: {identity['Arn']}")
+    except Exception as auth_error:
+        logger.error(f"There was an error authenticating your keys with AWS: {auth_error}")
+        sys.exit(1)
+    return identity
+
+
+def select_assumeable_role(tiles):
+    """Select the role to perform the AssumeRoleWithSaml.
+
+    :param tiles: tiles metadata, list of tuples
+    :return: tuple with AWS AssumeRoleWithSaml response and role name
+    """
+    authenticated_tiles = {}
+    for url, saml_response, saml, label in tiles:
+        roles_and_providers = user.extract_arns(saml)
+        authenticated_tiles[url] = {
+            "roles": list(roles_and_providers.keys()),
+            "saml": saml,
+            "saml_response_string": saml_response,
+            "roles_and_providers": roles_and_providers,
+            "label": label,
+        }
+
+    role_arn, _id = user.select_role_arn(authenticated_tiles)
+    role_name = role_arn.split("/")[-1]
+
+    assume_role_response = assume_role(
+        role_arn,
+        authenticated_tiles[_id]["roles_and_providers"][role_arn],
+        authenticated_tiles[_id]["saml"],
+    )
+
+    return (assume_role_response, role_name)
diff --git a/tokendito/aws_helpers.py b/tokendito/aws_helpers.py
deleted file mode 100644
index 458c7574..00000000
--- a/tokendito/aws_helpers.py
+++ /dev/null
@@ -1,202 +0,0 @@
-# vim: set filetype=python ts=4 sw=4
-# -*- coding: utf-8 -*-
-"""This module handles the all aws workflow operations.
-
-Tasks include:
-1. Aws Authentication with SAML
-2. Updating the AWS Credentials
-3. Updating the AWS Config
-
-"""
-from __future__ import absolute_import, division, print_function, unicode_literals
-
-import codecs
-import logging
-import sys
-
-import boto3
-from botocore import UNSIGNED
-from botocore.client import Config
-from botocore.exceptions import ClientError
-from future import standard_library
-import requests
-from tokendito import helpers
-
-
-standard_library.install_aliases()
-
-
-def authenticate_to_roles(secret_session_token, url):
-    """Authenticate AWS user with saml.
-
-    :param secret_session_token: secret session token
-    :param url: url of the AWS account
-    :return: response text
-
-    """
-    payload = {"onetimetoken": secret_session_token}
-    logging.debug("Authenticate AWS user with SAML URL [{}:{}]".format(url, payload))
-    try:
-        response = requests.get(url, params=payload)
-        saml_response_string = response.text
-        if response.status_code == 400 or response.status_code == 401:
-            errmsg = "Invalid Credentials."
-            logging.critical(
-                "{}\nExiting with code:{}".format(errmsg, response.status_code)
-            )
-            sys.exit(2)
-        elif response.status_code == 404:
-            errmsg = "Invalid Okta application URL. Please verify your configuration."
-            logging.critical("{}".format(errmsg))
-            sys.exit(2)
-        elif response.status_code >= 500 and response.status_code < 504:
-            errmsg = "Unable to establish connection with Okta. Verify Okta Org URL and try again."
-            logging.critical(
-                "{}\nExiting with code:{}".format(errmsg, response.status_code)
-            )
-            sys.exit(2)
-        elif response.status_code != 200:
-            logging.critical("Exiting with code:{}".format(response.status_code))
-            logging.debug(saml_response_string)
-            sys.exit(2)
-
-    except Exception as error:
-        errmsg = "Okta auth failed:\n{}".format(error)
-        logging.critical(errmsg)
-        sys.exit(1)
-
-    saml_xml = helpers.validate_saml_response(saml_response_string)
-
-    return saml_response_string, saml_xml
-
-
-def assume_role(role_arn, provider_arn, saml):
-    """Return AssumeRoleWithSaml API response.
-
-    :param role_arn: IAM role arn to assume
-    :param provider_arn: ARN of saml-provider resource
-    :param saml: decoded saml response from okta
-    :return: AssumeRoleWithSaml API response
-
-    """
-    default_error = (
-        "\nUnable to assume role with the following details:\n"
-        "- Role ARN: {}\n"
-        "- Error: {}\n"
-    )
-
-    encoded_xml = codecs.encode(saml.encode("utf-8"), "base64")
-
-    # Attempt to assume a role with the following durations:
-    # 12h, 8h, 6h, 4h, 2h, 1h, 30m, 15m
-    session_times = [43200, 28800, 21600, 14400, 7200, 3600, 1800, 900, "exit"]
-    for duration in session_times:
-        if duration == "exit":
-            logging.critical(
-                default_error.format(
-                    role_arn,
-                    "IAM role session time is not within set: {}".format(
-                        session_times[:-1]
-                    ),
-                )
-            )
-            sys.exit(2)
-
-        assume_role_response = handle_assume_role(
-            role_arn, provider_arn, encoded_xml, duration, default_error
-        )
-        if "Credentials" in assume_role_response:
-            break
-
-    return assume_role_response
-
-
-def handle_assume_role(role_arn, provider_arn, encoded_xml, duration, default_error):
-    """Handle assume role with saml.
-
-    :param role_arn: IAM role arn to assume
-    :param provider_arn: ARN of saml-provider resource
-    :param saml: decoded saml response from okta
-    :return: AssumeRoleWithSaml API responses
-    """
-    logging.debug("Attempting session time [{}]".format(duration))
-    client = boto3.client("sts", config=Config(signature_version=UNSIGNED))
-    try:
-        assume_role_response = client.assume_role_with_saml(
-            RoleArn=role_arn,
-            PrincipalArn=provider_arn,
-            SAMLAssertion=encoded_xml.decode(),
-            DurationSeconds=duration,
-        )
-        # Client Exceptions
-    except ClientError as error:
-        if error.response["Error"]["Code"] == "ValidationError":
-            logging.info(
-                "AssumeRoleWithSaml failed with {} for duration {}".format(
-                    error.response["Error"]["Code"], duration
-                )
-            )
-            assume_role_response = "continue"
-        elif error.response["Error"]["Code"] == "AccessDenied":
-            errmsg = "Error assuming intermediate {} SAML role".format(provider_arn)
-            logging.critical(errmsg)
-            sys.exit(2)
-        else:
-            logging.critical(default_error.format(role_arn, str(error)))
-            sys.exit(1)
-        # Service Exceptions
-    except Exception as error:
-        logging.critical(default_error.format(role_arn, str(error)))
-        sys.exit(1)
-
-    return assume_role_response
-
-
-def ensure_keys_work(assume_role_response):
-    """Validate the temporary AWS credentials.
-
-    :param aws_access_key: AWS access key
-    :param aws_secret_key: AWS secret key
-    :param aws_session_token: AWS session token
-    :return:
-
-    """
-    logging.debug("Validate the temporary AWS credentials")
-
-    aws_access_key = assume_role_response["Credentials"]["AccessKeyId"]
-    aws_secret_key = assume_role_response["Credentials"]["SecretAccessKey"]
-    aws_session_token = assume_role_response["Credentials"]["SessionToken"]
-
-    try:
-        client = boto3.client(
-            "sts",
-            aws_access_key_id=aws_access_key,
-            aws_secret_access_key=aws_secret_key,
-            aws_session_token=aws_session_token,
-        )
-        client.get_caller_identity()
-    except Exception as auth_error:
-        logging.critical(
-            "There was an error authenticating your keys with AWS: {}".format(
-                auth_error
-            )
-        )
-        sys.exit(1)
-
-
-def select_assumeable_role(saml_response_string, saml):
-    """Select the role to perform the AssumeRoleWithSaml.
-
-    :param saml_response_string response from Okta with saml data:
-    :param saml decoded saml response from Okta:
-    :return AWS AssumeRoleWithSaml response, role name:
-    """
-    roles_and_providers = helpers.extract_arns(saml)
-    role_arn = helpers.select_role_arn(
-        list(roles_and_providers.keys()), saml, saml_response_string
-    )
-    role_name = role_arn.split("/")[-1]
-
-    assume_role_response = assume_role(role_arn, roles_and_providers[role_arn], saml)
-
-    return assume_role_response, role_name
diff --git a/tokendito/duo_helpers.py b/tokendito/duo.py
similarity index 62%
rename from tokendito/duo_helpers.py
rename to tokendito/duo.py
index d9106328..e7638250 100644
--- a/tokendito/duo_helpers.py
+++ b/tokendito/duo.py
@@ -1,8 +1,6 @@
 # vim: set filetype=python ts=4 sw=4
 # -*- coding: utf-8 -*-
 """This module handles Duo operations."""
-from __future__ import absolute_import, division, print_function, unicode_literals
-
 import json
 import logging
 import sys
@@ -10,12 +8,12 @@
 from urllib.parse import unquote, urlparse
 
 from bs4 import BeautifulSoup
-from future import standard_library
 import requests
-from tokendito import helpers
-from tokendito import settings
+from tokendito import config
+from tokendito import user
+
 
-standard_library.install_aliases()
+logger = logging.getLogger(__name__)
 
 
 def prepare_duo_info(selected_okta_factor):
@@ -25,17 +23,15 @@ def prepare_duo_info(selected_okta_factor):
     :return duo_info: dict of parameters for Duo
     """
     duo_info = {}
-    okta_factor = selected_okta_factor["_embedded"]["factor"]["_embedded"][
-        "verification"
-    ]
+    okta_factor = selected_okta_factor["_embedded"]["factor"]["_embedded"]["verification"]
     duo_info["okta_factor"] = okta_factor
     duo_info["factor_id"] = selected_okta_factor["_embedded"]["factor"]["id"]
 
     duo_info["state_token"] = selected_okta_factor["stateToken"]
     duo_info["okta_callback_url"] = okta_factor["_links"]["complete"]["href"]
     duo_info["tx"] = okta_factor["signature"].split(":")[0]
-    duo_info["app_sig"] = okta_factor["signature"].split(":")[1]
-    duo_info["parent"] = "{}/signin/verify/duo/web".format(settings.okta_org)
+    duo_info["tile_sig"] = okta_factor["signature"].split(":")[1]
+    duo_info["parent"] = f"{config.okta['org']}/signin/verify/duo/web"
     duo_info["host"] = okta_factor["host"]
     duo_info["sid"] = ""
 
@@ -45,7 +41,7 @@ def prepare_duo_info(selected_okta_factor):
     return duo_info
 
 
-def duo_api_post(url, params={}, headers={}, payload={}):
+def duo_api_post(url, params=None, headers=None, payload=None):
     """Error handling and response parsing wrapper for Duo POSTs.
 
     :param url: The URL being connected to.
@@ -55,33 +51,23 @@ def duo_api_post(url, params={}, headers={}, payload={}):
     :return response: Response to the API request.
     """
     try:
-        response = requests.request(
-            "POST", url, params=params, headers=headers, data=payload
-        )
+        response = requests.request("POST", url, params=params, headers=headers, data=payload)
     except Exception as request_issue:
-        logging.error(
-            "There was an error connecting to the Duo API: \n{}".format(request_issue)
-        )
+        logger.error(f"There was an error connecting to the Duo API: {request_issue}")
         sys.exit(1)
 
     json_message = None
     try:
         json_message = response.json()
     except ValueError:
-        logging.debug("Non-json response from Duo API: \n{}".format(response))
+        logger.debug(f"Non-json response from Duo API: {response}")
 
     if response.status_code != 200:
-        logging.critical(
-            "Your Duo authentication has failed with status {}.".format(
-                response.status_code
-            )
-        )
+        logger.error(f"Your Duo authentication has failed with status {response.status_code}.")
         if json_message and json_message["stat"].lower() != "ok":
-            logging.critical(
-                "\n{}, {}".format(response.status_code, json_message["message"])
-            )
+            logger.error(f"{response.status_code}, {json_message['message']}")
         else:
-            logging.critical(
+            logger.error(
                 "Please re-run the program with parameter"
                 ' "--loglevel debug" to see more information.'
             )
@@ -99,24 +85,18 @@ def get_duo_sid(duo_info):
     :return: duo_info with added SID.
     :return: duo_auth_response, contains html content listing available factors.
     """
-    params = helpers.prepare_payload(
-        tx=duo_info["tx"], v=duo_info["version"], parent=duo_info["parent"]
-    )
+    params = {"tx": duo_info["tx"], "v": duo_info["version"], "parent": duo_info["parent"]}
 
-    url = "https://{}/frame/web/v1/auth".format(duo_info["host"])
-    logging.info(
-        "Calling Duo {} with params {}".format(urlparse(url).path, params.keys())
-    )
+    url = f"https://{duo_info['host']}/frame/web/v1/auth"
+    logger.debug(f"Calling Duo {urlparse(url).path} with params {params.keys()}")
     duo_auth_response = duo_api_post(url, params=params)
 
     try:
-        duo_auth_redirect = urlparse("{}".format(unquote(duo_auth_response.url))).query
+        duo_auth_redirect = urlparse(f"{unquote(duo_auth_response.url)}").query
         duo_info["sid"] = duo_auth_redirect.strip("sid=")
     except Exception as sid_error:
-        logging.error(
-            "There was an error getting your SID."
-            "Please try again. \n{}".format(sid_error)
-        )
+        logger.error(f"There was an error getting your SID. Please try again: {sid_error}")
+        sys.exit(2)
 
     return duo_info, duo_auth_response
 
@@ -134,20 +114,18 @@ def get_duo_devices(duo_auth):
     """
     soup = BeautifulSoup(duo_auth.content, "html.parser")
 
-    device_soup = soup.find("select", {"name": "device"}).findAll("option")
-    devices = ["{} - {}".format(d["value"], d.text) for d in device_soup]
+    device_soup = soup.find("select", {"name": "device"}).findAll("option")  # type: ignore
+    devices = [f"{d['value']} - {d.text}" for d in device_soup]
     if not devices:
-        logging.error("Please configure devices for your Duo MFA and retry.")
+        logger.error("Please configure devices for your Duo MFA and retry.")
         sys.exit(2)
 
     factor_options = []
     for device in devices:
         options = soup.find("fieldset", {"data-device-index": device.split(" - ")[0]})
-        factors = options.findAll("input", {"name": "factor"})
+        factors = options.findAll("input", {"name": "factor"})  # type: ignore (PEP 561)
         for factor in factors:
-            factor_option = {}
-            factor_option["device"] = device
-            factor_option["factor"] = factor["value"]
+            factor_option = {"device": device, "factor": factor["value"]}
             factor_options.append(factor_option)
     return factor_options
 
@@ -163,23 +141,15 @@ def parse_duo_mfa_challenge(mfa_challenge):
         mfa_status = mfa_challenge["stat"]
         txid = mfa_challenge["response"]["txid"]
     except ValueError as value_error:
-        logging.error(
-            "The Duo API returned a non-json response: \n{}".format(value_error)
-        )
+        logger.error(f"The Duo API returned a non-json response: {value_error}")
         sys.exit(1)
     except KeyError as key_error:
-        logging.error(
-            "The Duo API response is missing a required parameter: \n{}".format(
-                key_error
-            )
-        )
-        print(json.dumps(mfa_challenge))
+        logger.error(f"The Duo API response is missing a required parameter: {key_error}")
+        logger.debug(json.dumps(mfa_challenge))
         sys.exit(1)
 
     if mfa_status == "fail":
-        logging.error(
-            "Your Duo authentication has failed: \n{}".format(mfa_challenge["message"])
-        )
+        logger.error(f"Your Duo authentication has failed: {mfa_challenge['message']}")
         sys.exit(1)
     return txid
 
@@ -195,23 +165,23 @@ def duo_mfa_challenge(duo_info, mfa_option, passcode):
     :param mfa_option: the user's selected second factor.
     :return txid: Duo transaction ID used to track this auth attempt.
     """
-    url = "https://{}/frame/prompt".format(duo_info["host"])
+    url = f"https://{duo_info['host']}/frame/prompt"
     device = mfa_option["device"].split(" - ")[0]
-    mfa_data = helpers.prepare_payload(
-        factor=mfa_option["factor"],
-        device=device,
-        sid=duo_info["sid"],
-        out_of_date=False,
-        days_out_of_date=0,
-        days_to_block=None,
-    )
-    mfa_data["async"] = True  # async is a reserved keyword
+    mfa_data = {
+        "factor": mfa_option["factor"],
+        "device": device,
+        "sid": duo_info["sid"],
+        "out_of_date": False,
+        "days_out_of_date": 0,
+        "days_to_block": None,
+        "async": True,
+    }
     if passcode:
         mfa_data["passcode"] = passcode
     mfa_challenge = duo_api_post(url, payload=mfa_data)
     txid = parse_duo_mfa_challenge(mfa_challenge)
 
-    logging.debug("Sent MFA Challenge and obtained Duo transaction ID.")
+    logger.debug("Sent MFA Challenge and obtained Duo transaction ID.")
     return txid
 
 
@@ -224,11 +194,7 @@ def get_mfa_response(mfa_result):
     try:
         verify_mfa = mfa_result.json()["response"]
     except Exception as parse_error:
-        logging.error(
-            "There was an error parsing the mfa challenge result: \n{}".format(
-                parse_error
-            )
-        )
+        logger.error(f"There was an error parsing the mfa challenge result: {parse_error}")
         sys.exit(1)
     return verify_mfa
 
@@ -243,16 +209,16 @@ def parse_challenge(verify_mfa, challenge_result):
     challenge_reason = None
 
     if "status" in verify_mfa:
-        print(verify_mfa["status"])
+        user.print(f"{verify_mfa['status']}")
 
     if "reason" in verify_mfa:
         challenge_reason = verify_mfa["reason"]
 
     if "result" in verify_mfa:
-        logging.info("Result received: {}".format(verify_mfa["result"]))
+        logger.debug(f"Result received: {verify_mfa['result']}")
         challenge_result = verify_mfa["result"].lower()
 
-    logging.debug("Challenge result is {}".format(challenge_result))
+    logger.debug(f"Challenge result is {challenge_result}")
     return challenge_result, challenge_reason
 
 
@@ -266,34 +232,26 @@ def duo_mfa_verify(duo_info, txid):
     :param mfa_option: the user's selected second factor.
     :return txid: Duo transaction ID used to track this auth attempt.
     """
-    url = "https://{}/frame/status".format(duo_info["host"])
-    challenged_mfa = helpers.prepare_payload(txid=txid, sid=duo_info["sid"])
+    url = f"https://{duo_info['host']}/frame/status"
+    challenged_mfa = {"txid": txid, "sid": duo_info["sid"]}
     challenge_result = None
 
     while True:
-        logging.debug("Waiting for MFA challenge response")
+        logger.debug("Waiting for MFA challenge response")
         mfa_result = duo_api_post(url, payload=challenged_mfa)
         verify_mfa = get_mfa_response(mfa_result)
-        challenge_result, challenge_reason = parse_challenge(
-            verify_mfa, challenge_result
-        )
+        challenge_result, challenge_reason = parse_challenge(verify_mfa, challenge_result)
 
         if challenge_result is None:
             continue
         elif challenge_result == "success":
-            logging.debug("Successful MFA challenge received")
+            logger.debug("Successful MFA challenge received")
             break
         elif challenge_result == "failure":
-            logging.critical(
-                "MFA challenge has failed:"
-                " {}. Please try again.".format(challenge_reason)
-            )
+            logger.error(f"MFA challenge has failed: {challenge_reason}. Please try again.")
             sys.exit(2)
         else:
-            logging.debug(
-                "MFA challenge result: {}"
-                "Reason: {}\n\n".format(challenge_result, challenge_reason)
-            )
+            logger.debug(f"MFA challenge result: {challenge_result}. Reason: {challenge_reason}")
         time.sleep(1)
 
     return verify_mfa
@@ -309,24 +267,19 @@ def duo_factor_callback(duo_info, verify_mfa):
     :param verify_mfa: verified mfa challenge response from status api.
     :return sig_response: required to sign final Duo callback request.
     """
-    factor_callback_url = "https://{}{}".format(
-        duo_info["host"], verify_mfa["result_url"]
-    )
-    factor_callback = duo_api_post(
-        factor_callback_url, payload={"sid": duo_info["sid"]}
-    )
+    factor_callback_url = f"https://{duo_info['host']}{verify_mfa['result_url']}"
+    factor_callback = duo_api_post(factor_callback_url, payload={"sid": duo_info["sid"]})
 
     try:
-        sig_response = "{}:{}".format(
-            factor_callback.json()["response"]["cookie"], duo_info["app_sig"]
-        )
+        sig_response = f"{factor_callback.json()['response']['cookie']}:{duo_info['tile_sig']}"
     except Exception as sig_error:
-        logging.error(
-            "There was an error getting your"
-            " application signature from Duo: \n{}".format(json.dumps(sig_error))
+        logger.error(
+            "There was an error getting your application signature "
+            f"from Duo: {json.dumps(sig_error)}"
         )
+        sys.exit(2)
 
-    logging.debug("Completed factor callback.")
+    logger.debug("Completed factor callback.")
     return sig_response
 
 
@@ -340,8 +293,8 @@ def set_passcode(mfa_option):
     """
     passcode = None
     if mfa_option["factor"].lower() == "passcode":
-        print("Type your TOTP and press Enter")
-        passcode = helpers.get_input()
+        user.print("Type your TOTP and press Enter:")
+        passcode = user.get_input()
     return passcode
 
 
@@ -359,21 +312,17 @@ def authenticate_duo(selected_okta_factor):
     try:
         duo_info = prepare_duo_info(selected_okta_factor)
     except KeyError as missing_key:
-        logging.error(
-            "There was an issue parsing the Okta factor."
-            " Please try again. \n{}".format(missing_key)
-        )
+        logger.error(f"There was an issue parsing the Okta factor. Please try again: {missing_key}")
         sys.exit(1)
-
     # Collect devices, factors, auth params for Duo
     duo_info, duo_auth_response = get_duo_sid(duo_info)
     factor_options = get_duo_devices(duo_auth_response)
-    mfa_index = helpers.select_preferred_mfa_index(
+    mfa_index = user.select_preferred_mfa_index(
         factor_options, factor_key="factor", subfactor_key="device"
     )
 
     mfa_option = factor_options[mfa_index]
-    logging.debug("Selected MFA is [{}]".format(mfa_option))
+    logger.debug(f"Selected MFA is [{mfa_option}]")
     passcode = set_passcode(mfa_option)
 
     txid = duo_mfa_challenge(duo_info, mfa_option, passcode)
@@ -383,13 +332,11 @@ def authenticate_duo(selected_okta_factor):
     sig_response = duo_factor_callback(duo_info, verify_mfa)
 
     # Prepare for Okta callback
-    payload = helpers.prepare_payload(
-        id=duo_info["factor_id"],
-        sig_response=sig_response,
-        stateToken=duo_info["state_token"],
-    )
-    headers = {}
-    headers["content-type"] = "application/json"
-    headers["accept"] = "application/json"
+    payload = {
+        "id": duo_info["factor_id"],
+        "sig_response": sig_response,
+        "stateToken": duo_info["state_token"],
+    }
+    headers = {"content-type": "application/json", "accept": "application/json"}
 
     return payload, headers, duo_info["okta_callback_url"]
diff --git a/tokendito/helpers.py b/tokendito/helpers.py
deleted file mode 100644
index 28a01178..00000000
--- a/tokendito/helpers.py
+++ /dev/null
@@ -1,896 +0,0 @@
-# vim: set filetype=python ts=4 sw=4
-# -*- coding: utf-8 -*-
-"""Helper module for AWS and Okta configuration, management and data flow."""
-from __future__ import absolute_import, division, print_function, unicode_literals
-
-import argparse
-from builtins import (  # noqa: F401
-    ascii,
-    bytes,
-    chr,
-    dict,
-    filter,
-    hex,
-    input,
-    int,
-    list,
-    map,
-    next,
-    object,
-    oct,
-    open,
-    pow,
-    range,
-    round,
-    str,
-    super,
-    zip,
-)
-import codecs
-import configparser
-import getpass
-import json
-import logging
-import os
-import platform
-import re
-import sys
-from urllib.parse import urlparse
-
-from botocore import __version__ as __botocore_version__
-from bs4 import __version__ as __bs4_version__
-from bs4 import BeautifulSoup
-from future import standard_library
-import pytz
-import requests
-from tokendito import settings
-from tokendito.__version__ import __version__
-from tzlocal import get_localzone
-
-
-standard_library.install_aliases()
-
-
-def setup(args):
-    """Parse command line arguments.
-
-    :return: args parse object
-    """
-    parser = argparse.ArgumentParser(
-        prog="tokendito", description="Gets a STS token to use with the AWS CLI"
-    )
-    parser.add_argument(
-        "--version", "-v", action="store_true", help="Displays version and exit"
-    )
-    parser.add_argument(
-        "--configure",
-        "-c",
-        action="store_true",
-        help="Prompt user for " "configuration parameters",
-    )
-    parser.add_argument(
-        "--username",
-        "-u",
-        type=to_unicode,
-        dest="okta_username",
-        help="username to login to Okta. You can "
-        "also use the OKTA_USERNAME environment variable.",
-    )
-    parser.add_argument(
-        "--password",
-        "-p",
-        type=to_unicode,
-        dest="okta_password",
-        help="password to login to Okta. You "
-        "can also user the OKTA_PASSWORD environment variable.",
-    )
-    parser.add_argument(
-        "--config-file",
-        "-C",
-        type=to_unicode,
-        default=settings.config_file,
-        help="Use an alternative configuration file",
-    )
-    parser.add_argument(
-        "--okta-aws-app-url", "-ou", type=to_unicode, help="Okta App URL to use."
-    )
-    parser.add_argument(
-        "--okta-profile",
-        "-op",
-        type=to_unicode,
-        default=settings.okta_profile,
-        help="Okta configuration profile to use.",
-    )
-    parser.add_argument(
-        "--aws-region",
-        "-r",
-        type=to_unicode,
-        help="Sets the AWS region for the profile",
-    )
-    parser.add_argument(
-        "--aws-output",
-        "-ao",
-        type=to_unicode,
-        help="Sets the AWS output type for the profile",
-    )
-    parser.add_argument(
-        "--aws-profile",
-        "-ap",
-        type=to_unicode,
-        help="Override AWS profile to save as in the credentials file.",
-    )
-    parser.add_argument(
-        "--mfa-method", "-mm", type=to_unicode, help="Sets the MFA method"
-    )
-    parser.add_argument(
-        "--mfa-response",
-        "-mr",
-        type=to_unicode,
-        help="Sets the MFA response to a challenge",
-    )
-    parser.add_argument("--role-arn", "-R", type=to_unicode, help="Sets the IAM role")
-    parser.add_argument(
-        "--output-file", "-o", type=to_unicode, help="Log output to filename"
-    )
-    parser.add_argument(
-        "--loglevel",
-        "-l",
-        type=lambda s: s.upper(),
-        default="WARNING",
-        choices=["DEBUG", "INFO", "WARN", "ERROR"],
-        help="[DEBUG|INFO|WARN|ERROR], default loglevel is WARNING."
-        " Note: DEBUG level may display credentials",
-    )
-
-    parsed_args = parser.parse_args(args)
-    set_logging(parsed_args)
-    logging.debug("Parse command line arguments [{}]".format(parsed_args))
-
-    return parsed_args
-
-
-def utc_to_local(utc_dt):
-    """Convert UTC time into local time.
-
-    :param:utc_str:datetime
-    :return:local_time:string
-    """
-    local_time = utc_dt.replace(tzinfo=pytz.utc).astimezone(tz=get_localzone())
-    local_time = local_time.strftime("%Y-%m-%d %H:%M:%S %Z")
-
-    return local_time
-
-
-def to_unicode(bytestring):
-    """Convert a string into a Unicode compliant object.
-
-    The `unicode()` method is only available in Python 2. Python 3
-    generates a `NameError`, and the same string is returned unmodified.
-
-    :param bytestring:
-    :return: unicode-compliant string
-    """
-    if type(bytestring) == bytes:
-        bytestring = bytestring.decode(settings.encoding)
-    unicode_string = bytestring
-    try:
-        unicode_string = unicode(bytestring, settings.encoding)
-    except (NameError, TypeError):
-        # If a TypeError is raised, we are in Python 3, this is a no-op.
-        pass
-    return unicode_string
-
-
-def create_directory(dir_name):
-    """Create directories on the local machine."""
-    if os.path.isdir(dir_name) is False:
-        try:
-            os.mkdir(dir_name)
-        except OSError as error:
-            logging.error(
-                "Cannot continue creating directory '{}': {}".format(
-                    settings.config_dir, error.strerror
-                )
-            )
-            sys.exit(1)
-
-
-def set_okta_username():
-    """Set okta username in a constant settings variable.
-
-    :return: okta_username
-
-    """
-    logging.debug("Set okta username in a constant settings variable.")
-
-    if settings.okta_username == "":
-        okta_username = input("Username: ")
-        setattr(settings, "okta_username", to_unicode(okta_username))
-        logging.debug("username set to {} interactively".format(settings.okta_username))
-
-    return settings.okta_username
-
-
-def set_okta_password():
-    """Set okta password in a constant settings variable.
-
-    :param args: command line arguments
-    :return: okta_password
-
-    """
-    logging.debug("Set okta password in a constant settings variable.")
-
-    while settings.okta_password == "":
-        okta_password = getpass.getpass()
-        setattr(settings, "okta_password", to_unicode(okta_password))
-
-    logging.debug("password set interactively")
-    return settings.okta_password
-
-
-def set_logging(args):
-    """Set logging level.
-
-    :param args: Arguments provided by a user
-    :return:
-
-    """
-    logger = logging.getLogger()
-    logger.setLevel(args.loglevel)
-    log_level_int = getattr(logging, args.loglevel)
-
-    # increment boto logs to not print api keys
-    logging.getLogger("botocore").setLevel(log_level_int + 10)
-
-    log_format = (
-        "%(levelname)s " "[%(filename)s:%(funcName)s():%(lineno)i]: %(message)s"
-    )
-    date_format = "%m/%d/%Y %I:%M:%S %p"
-
-    formatter = logging.Formatter(log_format, date_format)
-
-    if args.output_file:
-        handler = logging.FileHandler(args.output_file)
-    else:
-        handler = logging.StreamHandler()
-
-    handler.setFormatter(formatter)
-    logger.addHandler(handler)
-
-
-def select_role_arn(role_arns, saml_xml, saml_response_string):
-    """Select the role user wants to pick.
-
-    :param role_arns: IAM roles ARN list assigned for the user
-    :param saml_xml: Decoded saml response from Okta
-    :param saml_response_string: http response from saml assertion to AWS
-    :return: User input index selected by the user, the arn of selected role
-
-    """
-    logging.debug("Select the role user wants to pick [{}]".format(role_arns))
-
-    role_names = dict((role.split("/")[-1], role) for role in role_arns)
-    roles = [role.split("/")[-1] for role in role_arns]
-
-    if roles.count(settings.aws_profile) > 1:
-        logging.error(
-            "There are multiple matches for the profile selected, "
-            "please use the --role-arn option to select one"
-        )
-        sys.exit(2)
-
-    if settings.aws_profile in role_names.keys():
-        selected_role = role_names[settings.aws_profile]
-        logging.debug(
-            "Using aws_profile env var for role: [{}]".format(settings.aws_profile)
-        )
-    elif settings.role_arn is None:
-        selected_role = prompt_role_choices(role_arns, saml_xml, saml_response_string)
-    elif settings.role_arn in role_arns:
-        selected_role = settings.role_arn
-    else:
-        logging.error(
-            "User provided rolename does not exist [{}]".format(settings.role_arn)
-        )
-        sys.exit(2)
-
-    logging.debug("Selected role: [{}]".format(selected_role))
-
-    return selected_role
-
-
-def factor_type_info(factor_type, mfa_option):
-    """Get factor info from okta reply.
-
-    :param factor_type: mfa_method
-    :param mfa_option: mfa_option
-    :return: info about mfa_method
-    """
-    logging.debug("Choose factor info depending on factor type.")
-    factor_info = "Not Presented"
-
-    if factor_type in ["token", "token:software:totp", "token:hardware"]:
-        factor_info = mfa_option.get("profile").get("credentialId")
-    elif factor_type == "push":
-        factor_info = mfa_option.get("profile").get("name")
-    elif factor_type == "sms" or factor_type == "call":
-        factor_info = mfa_option.get("profile").get("phoneNumber")
-    elif factor_type == "webauthn":
-        factor_info = mfa_option.get("profile").get("authenticatorName")
-    elif factor_type in ["web", "u2f", "token:hotp"]:
-        factor_info = mfa_option.get("vendorName")
-    elif factor_type == "question":
-        factor_info = mfa_option.get("profile").get("question")
-    elif factor_type == "email":
-        factor_info = mfa_option.get("profile").get("email")
-
-    return factor_info
-
-
-def mfa_option_info(mfa_option):
-    """Build an optional string with the MFA factor information.
-
-    :param mfa_option: dictionary with a single MFA response.
-    :return: pre-formatted string with MFA factor info if available, None
-             otherwise.
-    """
-    logging.debug("Building info for: {}".format(json.dumps(mfa_option)))
-
-    if "factorType" in mfa_option:
-        factor_type = mfa_option["factorType"]
-        factor_info = factor_type_info(factor_type, mfa_option)
-
-    if not factor_info:
-        factor_info = "Not Presented"
-    return factor_info
-
-
-def select_preferred_mfa_index(
-    mfa_options, factor_key="provider", subfactor_key="factorType"
-):
-    """Show all the MFA options to the users.
-
-    :param mfa_options: List of available MFA options
-    :return: MFA option selected index by the user from the output
-    """
-    logging.debug("Show all the MFA options to the users.")
-    logging.debug(json.dumps(mfa_options))
-    print("\nSelect your preferred MFA method and press Enter:")
-
-    longest_index = len(str(len(mfa_options)))
-    longest_factor_name = max([len(d[factor_key]) for d in mfa_options])
-    longest_subfactor_name = max([len(d[subfactor_key]) for d in mfa_options])
-    factor_info_indent = max([len(mfa_option_info(d)) for d in mfa_options])
-
-    for (i, mfa_option) in enumerate(mfa_options):
-        factor_id = mfa_option.get("id", "Not presented")
-        factor_info = mfa_option_info(mfa_option)
-        mfa_method = mfa_option.get(subfactor_key, "Not presented")
-        provider = mfa_option.get(factor_key, "Not presented")
-        print(
-            "[{: >{}}]  {: <{}}  {: <{}} {: <{}} Id: {}".format(
-                i,
-                longest_index,
-                provider,
-                longest_factor_name,
-                mfa_method,
-                longest_subfactor_name,
-                factor_info,
-                factor_info_indent,
-                factor_id,
-            ),
-        )
-
-    user_input = collect_integer(len(mfa_options))
-
-    return user_input
-
-
-def prompt_role_choices(role_arns, saml_xml, saml_response_string):
-    """Ask user to select role.
-
-    :param role_arns: IAM Role list
-    :return: user input of AWS Role
-    """
-    if len(role_arns) == 1:
-        account_id = role_arns[0].split(":")[4]
-        alias_table = {account_id: account_id}
-    else:
-        alias_table = get_account_aliases(saml_xml, saml_response_string)
-
-    logging.debug("Ask user to select role")
-    print("Please select one of the following:\n")
-
-    longest_alias = max([len(d) for d in alias_table.values()])
-    longest_index = len(str(len(role_arns)))
-    sorted_role_arns = sorted(role_arns)
-
-    for (i, arn) in enumerate(sorted_role_arns):
-        padding_index = longest_index - len(str(i))
-        account_alias = alias_table[arn.split(":")[4]]
-        print(
-            "[{}] {}{: <{}}    {}".format(
-                i, padding_index * " ", account_alias, longest_alias, arn
-            )
-        )
-
-    user_input = collect_integer(len(role_arns))
-    selected_role = sorted_role_arns[user_input]
-    logging.debug("Selected role [{}]".format(user_input))
-
-    return selected_role
-
-
-def print_selected_role(profile_name, expiration_time):
-    """Print details about how to assume role.
-
-    :param profile_name: AWS profile name
-    :param expiration_time: Credentials expiration time
-    :return:
-
-    """
-    expiration_time_local = utc_to_local(expiration_time)
-    msg = (
-        "\nGenerated profile '{}' in {}.\n"
-        "\nUse profile to authenticate to AWS:\n\t"
-        "aws --profile '{}' sts get-caller-identity"
-        "\nOR\n\t"
-        "export AWS_PROFILE='{}'\n\n"
-        "Credentials are valid until {} ({})."
-    ).format(
-        profile_name,
-        settings.aws_shared_credentials_file,
-        profile_name,
-        profile_name,
-        expiration_time,
-        expiration_time_local,
-    )
-
-    return print(msg)
-
-
-def extract_arns(saml):
-    """Extract arns from SAML decoded xml.
-
-    :param saml: results saml decoded
-    :return: Principle ARNs, Role ARNs
-    """
-    logging.debug("Decode response string as a SAML decoded value.")
-
-    soup = BeautifulSoup(saml, "xml")
-    arns = soup.find_all(text=re.compile("arn:aws:iam::"))
-    if len(arns) == 0:
-        logging.error("No IAM roles found in SAML response.")
-        logging.debug(arns)
-        sys.exit(2)
-
-    roles_and_providers = {i.split(",")[1]: i.split(",")[0] for i in arns}
-
-    logging.debug("Collected ARNs: {}".format(json.dumps(roles_and_providers)))
-
-    return roles_and_providers
-
-
-def validate_saml_response(html):
-    """Parse html to validate that saml a saml response was returned."""
-    soup = BeautifulSoup(html, "html.parser")
-
-    xml = None
-    for elem in soup.find_all("input", attrs={"name": "SAMLResponse"}):
-        saml_base64 = elem.get("value")
-        xml = codecs.decode(saml_base64.encode("ascii"), "base64").decode("utf-8")
-
-    if xml is None:
-        logging.error(
-            "Invalid data detected in SAML response."
-            " View the response with the DEBUG loglevel."
-        )
-        logging.debug(html)
-        sys.exit(1)
-
-    return xml
-
-
-def validate_okta_aws_app_url(input_url=None):
-    """Validate whether a given URL is a valid AWS app URL in Okta.
-
-    :param input_url: string
-    :return: bool. True if valid, False otherwise
-    """
-    logging.debug("Will try to match '{}' to a valid URL".format(input_url))
-
-    url = urlparse(input_url)
-    # Here, we could also check url.netloc against r'.*\.okta(preview)?\.com$'
-    # but Okta allows the usage of custome URLs such as login.acme.com
-    if (
-        url.scheme == "https"
-        and re.match(r"^/home/amazon_aws/\w{20}/\d{3}$", url.path) is not None
-    ):
-        return True
-
-    logging.debug("{} does not look like a valid match.".format(url))
-    return False
-
-
-def get_account_aliases(saml_xml, saml_response_string):
-    """Parse AWS SAML page for account aliases.
-
-    :param saml_xml: Decoded saml response from Okta
-    :param saml_response_string response from Okta with saml data:
-    :return: mapping table of account ids to their aliases
-    """
-    soup = BeautifulSoup(saml_response_string, "html.parser")
-    url = soup.find("form").get("action")
-
-    encoded_xml = codecs.encode(saml_xml.encode("utf-8"), "base64")
-    aws_response = None
-    try:
-        aws_response = requests.Session().post(url, data={"SAMLResponse": encoded_xml})
-    except Exception as request_error:
-        logging.error(
-            "There was an error retrieving the AWS SAML page: \n{}".format(
-                request_error
-            )
-        )
-        logging.debug(json.dumps(aws_response))
-        sys.exit(1)
-
-    if "Account: " not in aws_response.text:
-        logging.error("There were no accounts returned in the AWS SAML page.")
-        logging.debug(json.dumps(aws_response.text))
-        sys.exit(2)
-
-    soup = BeautifulSoup(aws_response.text, "html.parser")
-    account_names = soup.find_all(text=re.compile("Account:"))
-    alias_table = {
-        str(i.split(" ")[-1]).strip("()"): i.split(" ")[1] for i in account_names
-    }
-
-    return alias_table
-
-
-def display_version():
-    """Print program version and exit."""
-    python_version = platform.python_version()
-    (system, _, release, _, _, _) = platform.uname()
-    print(
-        "tokendito/{} Python/{} {}/{} botocore/{} bs4/{} requests/{}".format(
-            __version__,
-            python_version,
-            system,
-            release,
-            __botocore_version__,
-            __bs4_version__,
-            requests.__version__,
-        )
-    )
-
-
-def process_ini_file(file, profile):
-    """Process options from a ConfigParser ini file.
-
-    :param file: filename
-    :param profile: profile to read
-    :return: None
-    """
-    config = configparser.ConfigParser(default_section=settings.okta_profile)
-    if config.read(file) == []:
-        return
-
-    try:
-        for (key, val) in config.items(profile):
-            if hasattr(settings, key):
-                logging.debug("Set option {}={} from ini file".format(key, val))
-                setattr(settings, key, val)
-    except configparser.Error as err:
-        logging.error("Could not load profile '{}': {}".format(profile, str(err)))
-        sys.exit(2)
-
-
-def process_arguments(args):
-    """Process command-line arguments.
-
-    :param args: argparse object
-    :return: None
-    """
-    for (key, val) in vars(args).items():
-        if hasattr(settings, key) and val is not None:
-            logging.debug("Set option {}={} from command line".format(key, val))
-            setattr(settings, key, val)
-
-
-def process_environment():
-    """Process environment variables.
-
-    :return: None
-    """
-    for (key, val) in os.environ.items():
-        key = key.lower()
-        if hasattr(settings, key):
-            logging.debug("Set option {}={} from environment".format(key, val))
-            setattr(settings, key, os.getenv(key.upper()))
-
-
-def process_okta_aws_app_url():
-    """Process Okta app url.
-
-    :param app_url: string with okta tile URL.
-    :return: None.
-    """
-    if not validate_okta_aws_app_url(settings.okta_aws_app_url):
-        logging.error(
-            "Okta Application URL not found, or invalid. Please check "
-            "your configuration and try again."
-        )
-        sys.exit(2)
-
-    url = urlparse(settings.okta_aws_app_url)
-    okta_org = "{}://{}".format(url.scheme, url.netloc)
-    okta_aws_app_url = "{}{}".format(okta_org, url.path)
-    setattr(settings, "okta_org", okta_org)
-    setattr(settings, "okta_aws_app_url", okta_aws_app_url)
-
-
-def user_configuration_input():
-    """Obtain user input for the user.
-
-    :return: (okta app url, organization username)
-    """
-    logging.debug("Obtain user input for the user.")
-    url = ""
-    username = ""
-    config_details = []
-    message = {
-        "app_url": "\nOkta App URL. E.g https://acme.okta.com/home/"
-        "amazon_aws/b07384d113edec49eaa6/123\n[none]: ",
-        "username": "\nOrganization username. E.g jane.doe@acme.com" "\n[none]: ",
-    }
-
-    while url == "":
-        user_data = to_unicode(input(message["app_url"]))
-        user_data = user_data.strip()
-        if validate_okta_aws_app_url(user_data):
-            url = user_data
-        else:
-            print("Invalid input, try again.")
-    config_details.append(url)
-
-    while username == "":
-        user_data = to_unicode(input(message["username"]))
-        user_data = user_data.strip()
-        if user_data != "":
-            username = user_data
-        else:
-            print("Invalid input, try again.")
-    config_details.append(username)
-
-    return (config_details[0], config_details[1])
-
-
-def update_configuration(okta_file, profile):
-    """Update okta configuration file on local system.
-
-    :param okta_file: Default configuration system file
-    :param profile: profile of the okta user
-    :return:
-    """
-    logging.debug("Update okta configuration file on local system.")
-
-    config = configparser.RawConfigParser()
-
-    create_directory(settings.config_dir)
-
-    if os.path.isfile(okta_file):
-        logging.debug("Read Okta config [{} {}]".format(okta_file, profile))
-        config.read(okta_file, encoding=settings.encoding)
-    if not config.has_section(profile):
-        config.add_section(profile)
-        logging.debug("Add section to Okta config [{}]".format(profile))
-
-    (app_url, username) = user_configuration_input()
-
-    url = urlparse(app_url.strip())
-    okta_username = username.strip()
-
-    okta_aws_app_url = "{}://{}{}".format(url.scheme, url.netloc, url.path)
-
-    config.set(profile, "okta_aws_app_url", okta_aws_app_url)
-    config.set(profile, "okta_username", okta_username)
-    logging.debug("Config Okta [{}]".format(config))
-
-    with open(okta_file, "w+", encoding=settings.encoding) as file:
-        config.write(file)
-        logging.debug("Write new section Okta config [{} {}]".format(okta_file, config))
-
-
-def set_local_credentials(assume_role_response, role_name, aws_region, aws_output):
-    """Write to local files to insert credentials.
-
-    :param assume_role_response AWS AssumeRoleWithSaml response:
-    :param role_name the name of the assumed role, used for local profile:
-    :param aws_region configured region for aws credential profile:
-    :param aws output configured datatype for aws cli output:
-    """
-    expiration_time = assume_role_response["Credentials"]["Expiration"]
-    aws_access_key = assume_role_response["Credentials"]["AccessKeyId"]
-    aws_secret_key = assume_role_response["Credentials"]["SecretAccessKey"]
-    aws_session_token = assume_role_response["Credentials"]["SessionToken"]
-
-    if settings.aws_profile is not None:
-        role_name = settings.aws_profile
-
-    update_aws_credentials(role_name, aws_access_key, aws_secret_key, aws_session_token)
-    update_aws_config(role_name, aws_output, aws_region)
-
-    print_selected_role(role_name, expiration_time)
-
-
-def update_aws_credentials(profile, aws_access_key, aws_secret_key, aws_session_token):
-    """Update AWS credentials in ~/.aws/credentials default file.
-
-    :param profile: AWS profile name
-    :param aws_access_key: AWS access key
-    :param aws_secret_key: AWS secret access key
-    :param aws_session_token: Session token
-    """
-    cred_file = settings.aws_shared_credentials_file
-    cred_dir = os.path.dirname(cred_file)
-    logging.debug("Update AWS credentials in: [{}]".format(cred_file))
-
-    create_directory(cred_dir)
-
-    config = configparser.RawConfigParser()
-    if os.path.isfile(cred_file):
-        config.read(cred_file, encoding=settings.encoding)
-    if not config.has_section(profile):
-        config.add_section(profile)
-    config.set(profile, "aws_access_key_id", aws_access_key)
-    config.set(profile, "aws_secret_access_key", aws_secret_key)
-    config.set(profile, "aws_session_token", aws_session_token)
-    with open(cred_file, "w+", encoding=settings.encoding) as file:
-        config.write(file)
-
-
-def update_aws_config(profile, output, region):
-    """Update AWS config file in ~/.aws/config file.
-
-    :param profile: tokendito profile
-    :param output: aws output
-    :param region: aws region
-    :return:
-
-    """
-    config_file = settings.aws_config_file
-    config_dir = os.path.dirname(config_file)
-    logging.debug("Update AWS config to file: [{}]".format(config_file))
-
-    create_directory(config_dir)
-
-    # Prepend the word profile the the profile name
-    profile = "profile {}".format(profile)
-    config = configparser.RawConfigParser()
-    if os.path.isfile(config_file):
-        config.read(config_file, encoding=settings.encoding)
-    if not config.has_section(profile):
-        config.add_section(profile)
-    config.set(profile, "output", output)
-    config.set(profile, "region", region)
-
-    with open(config_file, "w+", encoding=settings.encoding) as file:
-        config.write(file)
-
-
-def check_within_range(user_input, valid_range):
-    """Validate the user input is within the range of the presented menu.
-
-    :param user_input: integer-validated user input.
-    :param valid_range: the valid range presented on the user's menu.
-    :return range_validation: true or false
-    """
-    range_validation = False
-    if int(user_input) in range(0, valid_range):
-        range_validation = True
-    else:
-        logging.debug("Valid range is {}".format(valid_range))
-        logging.error("Value is not in within the selection range.")
-    return range_validation
-
-
-def check_integer(value):
-    """Validate integer.
-
-    :param value: value to be validated.
-    :return: True when the number is a positive integer, false otherwise.
-    """
-    integer_validation = False
-    if str(value).isdigit():
-        integer_validation = True
-    else:
-        logging.error("Please enter a valid integer.")
-
-    return integer_validation
-
-
-def validate_input(value, valid_range):
-    """Validate user input is an integer and within menu range.
-
-    :param value: user input
-    :param valid_range: valid range based on how many menu options available to user.
-    """
-    integer_validation = check_integer(value)
-    if integer_validation and valid_range:
-        integer_validation = check_within_range(value, valid_range)
-    return integer_validation
-
-
-def get_input(prompt="-> "):
-    """Collect user input for TOTP.
-
-    :return user_input: raw from user.
-    """
-    user_input = to_unicode(input(prompt))
-    logging.debug("User input [{}]".format(user_input))
-
-    return user_input
-
-
-def collect_integer(valid_range):
-    """Collect input from user.
-
-    Prompt the user for input. Validate it and cast to integer.
-
-    :param valid_range: number of menu options available to user.
-    :return user_input: validated, casted integer from user.
-    """
-    user_input = None
-    while True:
-        user_input = get_input()
-        valid_input = validate_input(user_input, valid_range)
-        logging.debug("User input validation status is {}".format(valid_input))
-        if valid_input:
-            user_input = int(user_input)
-            break
-    return user_input
-
-
-def prepare_payload(**kwargs):
-    """Prepare payload for the HTTP request header.
-
-    :param kwargs: parameters to get together
-    :return: payload for the http header
-    """
-    logging.debug("Prepare payload")
-
-    payload_dict = {}
-    if kwargs is not None:
-        for key, value in list(kwargs.items()):
-            payload_dict[key] = value
-
-            if key != "password":
-                logging.debug("Prepare payload [{} {}]".format(key, value))
-
-    return payload_dict
-
-
-def process_options(args):
-    """Collect all user-specific credentials and config params."""
-    if args.version:
-        display_version()
-        sys.exit(0)
-
-    if args.configure:
-        update_configuration(args.config_file, args.okta_profile)
-        sys.exit(0)
-
-    # 1: read ini file (if it exists)
-    process_ini_file(args.config_file, args.okta_profile)
-    # 2: override with args
-    process_arguments(args)
-    # 3: override with ENV
-    process_environment()
-
-    process_okta_aws_app_url()
-    # Set username and password for Okta Authentication
-    logging.debug("Set Okta credentials.")
-    set_okta_username()
-    set_okta_password()
diff --git a/tokendito/okta.py b/tokendito/okta.py
new file mode 100644
index 00000000..c6686e0b
--- /dev/null
+++ b/tokendito/okta.py
@@ -0,0 +1,318 @@
+# vim: set filetype=python ts=4 sw=4
+# -*- coding: utf-8 -*-
+"""
+This module handles the all Okta operations.
+
+1. Okta authentication
+2. Update Okta Config File
+
+"""
+import json
+import logging
+import sys
+import time
+
+
+import requests
+from tokendito import config
+from tokendito import duo
+from tokendito import user
+
+
+logger = logging.getLogger(__name__)
+
+_status_dict = dict(
+    E0000004="Authentication failed",
+    E0000047="API call exceeded rate limit due to too many requests",
+    PASSWORD_EXPIRED="Your password has expired",
+    LOCKED_OUT="Your account is locked out",
+)
+
+
+def api_wrapper(url, payload, headers=None):
+    """Okta MFA authentication.
+
+    :param url: url to call
+    :param payload: JSON Payload
+    :param headers: Headers of the request
+    :return: Dictionary with authentication response
+    """
+    logger.debug(f"url is {url}")
+    try:
+        response = requests.request("POST", url, data=json.dumps(payload), headers=headers)
+        response.raise_for_status()
+    except Exception as err:
+        logger.error(f"There was an error with the call to {url}: {err}")
+        sys.exit(1)
+
+    logger.debug(f"Response is {response}")
+
+    try:
+        ret = response.json()
+    except ValueError as e:
+        logger.error(
+            f"{type(e).__name__} - Failed to parse response\n"
+            f"URL: {url}\n"
+            f"Status: {response.status_code}\n"
+            f"Content: {response.content}\n"
+        )
+        sys.exit(1)
+
+    if "errorCode" in ret:
+        api_error_code_parser(ret["errorCode"])
+        sys.exit(1)
+
+    return ret
+
+
+def api_error_code_parser(status=None):
+    """Status code parsing.
+
+    param status: Response status
+    return message: status message
+    """
+    if status and status in _status_dict.keys():
+        message = f"Okta auth failed: {_status_dict[status]}"
+    else:
+        message = f"Okta auth failed: {status}. Please verify your settings and try again."
+    logger.error(message)
+    logger.debug(f"Parsing error [{message}] ")
+    return message
+
+
+def user_session_token(primary_auth, headers):
+    """Get session_token.
+
+    param headers: Headers of the request
+    param primary_auth: Primary authentication
+    return session_token: Session Token from JSON response
+    """
+    status = None
+    try:
+        status = primary_auth.get("status", None)
+    except AttributeError:
+        pass
+
+    if status == "SUCCESS" and "sessionToken" in primary_auth:
+        session_token = primary_auth.get("sessionToken")
+    elif status == "MFA_REQUIRED":
+        session_token = user_mfa_challenge(headers, primary_auth)
+    else:
+        logger.debug(f"Error parsing response: {json.dumps(primary_auth)}")
+        logger.error("Okta auth failed: unknown status.")
+        sys.exit(1)
+
+    user.add_sensitive_value_to_be_masked(session_token)
+
+    return session_token
+
+
+def authenticate_user(config):
+    """Authenticate user with okta credential.
+
+    :param config: Config object
+    :return: MFA session with options
+    """
+    headers = {"content-type": "application/json", "accept": "application/json"}
+    payload = {"username": config.okta["username"], "password": config.okta["password"]}
+
+    logger.debug("Authenticate user to Okta")
+    logger.debug(f"Sending {headers}, {payload} to {config.okta['org']}")
+    primary_auth = api_wrapper(f"{config.okta['org']}/api/v1/authn", payload, headers)
+
+    session_token = user_session_token(primary_auth, headers)
+    logger.info("User has been succesfully authenticated.")
+    return session_token
+
+
+def mfa_provider_type(
+    mfa_provider,
+    selected_factor,
+    mfa_challenge_url,
+    primary_auth,
+    selected_mfa_option,
+    headers,
+    payload,
+):
+    """Receive session key.
+
+    :param mfa_provider: MFA provider
+    :param selected_factor: Selected MFA factor
+    :param mfa_challenge_url: MFA challenge url
+    :param primary_auth: Primary authentication
+    :param selected_mfa_option: Selected MFA option
+    :return: session_key
+
+    """
+    mfa_verify = dict()
+    if mfa_provider == "duo":
+        payload, headers, callback_url = duo.authenticate_duo(selected_factor)
+        duo.duo_api_post(callback_url, payload=payload)
+        mfa_verify = api_wrapper(mfa_challenge_url, payload, headers)
+    elif mfa_provider == "okta" or mfa_provider == "google":
+        mfa_verify = user_mfa_options(
+            selected_mfa_option, headers, mfa_challenge_url, payload, primary_auth
+        )
+    else:
+        logger.error(
+            f"Sorry, the MFA provider '{mfa_provider}' is not yet supported."
+            " Please retry with another option."
+        )
+        exit(1)
+    return mfa_verify["sessionToken"]
+
+
+def user_mfa_index(preset_mfa, available_mfas, mfa_options):
+    """Get mfa index in request.
+
+    :param preset_mfa: preset mfa from settings
+    :param available_mfas: available mfa ids
+    :param mfa_options: available mfas
+    """
+    indices = []
+    # Gets the index number from each preset MFA in the list of avaliable ones.
+    if preset_mfa:
+        logger.debug(f"Get mfa from {available_mfas}.")
+        indices = [i for i, elem in enumerate(available_mfas) if preset_mfa in elem]
+
+    mfa_index = None
+    if len(indices) == 0:
+        logger.debug(f"No matches with {preset_mfa}, going to get user input")
+        mfa_index = user.select_preferred_mfa_index(mfa_options)
+    elif len(indices) == 1:
+        logger.debug(f"One match: {preset_mfa} in {indices}")
+        mfa_index = indices[0]
+    else:
+        logger.error(
+            f"{preset_mfa} is not unique in {available_mfas}. Please check your configuration."
+        )
+        sys.exit(1)
+
+    return mfa_index
+
+
+def user_mfa_challenge(headers, primary_auth):
+    """Handle user mfa challenges.
+
+    :param headers: headers what needs to be sent to api
+    :param primary_auth: primary authentication
+    :return: Okta MFA Session token after the successful entry of the code
+    """
+    logger.debug("Handle user MFA challenges")
+    try:
+        mfa_options = primary_auth["_embedded"]["factors"]
+    except KeyError as error:
+        logger.error(f"There was a wrong response structure: \n{error}")
+        sys.exit(1)
+
+    preset_mfa = config.okta["mfa"]
+
+    # This creates a list where each elements looks like provider_factor_id.
+    # For example, OKTA_push_9yi4bKJNH2WEWQ0x8, GOOGLE_token:software:totp_9yi4bKJNH2WEWQ
+    available_mfas = [f"{d['provider']}_{d['factorType']}_{d['id']}" for d in mfa_options]
+
+    mfa_index = user_mfa_index(preset_mfa, available_mfas, mfa_options)
+
+    # time to challenge the mfa option
+    selected_mfa_option = mfa_options[mfa_index]
+    logger.debug(f"Selected MFA is [{selected_mfa_option}]")
+
+    mfa_challenge_url = selected_mfa_option["_links"]["verify"]["href"]
+
+    payload = {
+        "stateToken": primary_auth["stateToken"],
+        "factorType": selected_mfa_option["factorType"],
+        "provider": selected_mfa_option["provider"],
+        "profile": selected_mfa_option["profile"],
+    }
+    selected_factor = api_wrapper(mfa_challenge_url, payload, headers)
+
+    mfa_provider = selected_factor["_embedded"]["factor"]["provider"].lower()
+    logger.debug(f"MFA Challenge URL: [{mfa_challenge_url}] headers: {headers}")
+    mfa_session_token = mfa_provider_type(
+        mfa_provider,
+        selected_factor,
+        mfa_challenge_url,
+        primary_auth,
+        selected_mfa_option,
+        headers,
+        payload,
+    )
+
+    return mfa_session_token
+
+
+def user_mfa_options(selected_mfa_option, headers, mfa_challenge_url, payload, primary_auth):
+    """Handle user mfa options.
+
+    :param selected_mfa_option: Selected MFA option (SMS, push, etc)
+    :param headers: headers
+    :param mfa_challenge_url: MFA challenge URL
+    :param payload: payload
+    :param primary_auth: Primary authentication method
+    :return: payload data
+
+    """
+    logger.debug("Handle user MFA options")
+
+    logger.debug(f"User MFA options selected: [{selected_mfa_option['factorType']}]")
+    if selected_mfa_option["factorType"] == "push":
+        return push_approval(headers, mfa_challenge_url, payload)
+
+    if config.okta["mfa_response"] is None:
+        logger.debug("Getting verification code from user.")
+        config.okta["mfa_response"] = user.get_input("Enter your verification code:")
+        user.add_sensitive_value_to_be_masked(config.okta["mfa_response"])
+
+    # time to verify the mfa
+    payload = {"stateToken": primary_auth["stateToken"], "passCode": config.okta["mfa_response"]}
+    mfa_verify = api_wrapper(mfa_challenge_url, payload, headers)
+    if "sessionToken" in mfa_verify:
+        user.add_sensitive_value_to_be_masked(mfa_verify["sessionToken"])
+    logger.debug(f"mfa_verify [{json.dumps(mfa_verify)}]")
+
+    return mfa_verify
+
+
+def push_approval(headers, mfa_challenge_url, payload):
+    """Handle push approval from the user.
+
+    :param headers: HTTP headers sent to API call
+    :param mfa_challenge_url: MFA challenge url
+    :param payload: payload which needs to be sent
+    :return: Session Token if succeeded or terminates if user wait goes 5 min
+
+    """
+    logger.debug(
+        f"Handle push approval from the user headers:{headers} challenge_url:{mfa_challenge_url}"
+    )
+
+    user.print("Waiting for an approval from the device...")
+    mfa_status = "WAITING"
+    mfa_verify = {}
+    while mfa_status == "WAITING":
+        mfa_verify = api_wrapper(mfa_challenge_url, payload, headers)
+
+        logger.debug(f"MFA Response:\n{json.dumps(mfa_verify)}")
+
+        if "factorResult" in mfa_verify:
+            mfa_status = mfa_verify["factorResult"]
+        elif "status" in mfa_verify and mfa_verify["status"] == "SUCCESS":
+            break
+        else:
+            logger.error("There was an error getting your MFA status.")
+            logger.debug(f"{mfa_verify}")
+            if "status" in mfa_verify:
+                logger.error(f"Exiting due to error: {mfa_verify['status']}")
+            sys.exit(1)
+
+        if mfa_status == "REJECTED":
+            logger.error("The Okta Verify push has been denied. Please retry later.")
+            sys.exit(2)
+        elif mfa_status == "TIMEOUT":
+            logger.error("Device approval window has expired.")
+            sys.exit(2)
+
+        time.sleep(1)
+
+    return mfa_verify
diff --git a/tokendito/okta_helpers.py b/tokendito/okta_helpers.py
deleted file mode 100644
index f7868d07..00000000
--- a/tokendito/okta_helpers.py
+++ /dev/null
@@ -1,333 +0,0 @@
-# vim: set filetype=python ts=4 sw=4
-# -*- coding: utf-8 -*-
-"""
-This module handles the all Okta operations.
-
-1. Okta authentication
-2. Update Okta Config File
-
-"""
-from __future__ import absolute_import, division, print_function, unicode_literals
-
-import json
-import logging
-import sys
-import time
-
-
-from future import standard_library
-import requests
-from tokendito import duo_helpers
-from tokendito import helpers
-from tokendito import settings
-
-
-standard_library.install_aliases()
-
-
-def okta_verify_api_method(mfa_challenge_url, payload, headers=None):
-    """Okta MFA authentication.
-
-    :param mfa_challenge_url: MFA challenge url
-    :param payload: JSON Payload
-    :param headers: Headers of the request
-    :return: Okta authentication response
-    """
-    try:
-        if headers:
-            response = requests.request(
-                "POST", mfa_challenge_url, data=json.dumps(payload), headers=headers
-            )
-        else:
-            response = requests.request("POST", mfa_challenge_url, data=payload)
-    except Exception as request_error:
-        logging.error(
-            "There was an error connecting to Okta: \n{}".format(request_error)
-        )
-        sys.exit(1)
-
-    logging.debug("Okta authentication response: \n{}".format(response))
-    ret = dict()
-    try:
-        ret = response.json()
-    except ValueError:
-        logging.error("Received type of response: {}".format(type(response.text)))
-        sys.exit(1)
-
-    if "errorCode" in ret:
-        login_error_code_parser(ret["errorCode"], settings.okta_status_dict)
-        sys.exit(1)
-
-    return ret
-
-
-def login_error_code_parser(status=None, status_dict=settings.okta_status_dict):
-    """Status code parsing.
-
-    param status: Response status
-    return message: status message
-    """
-    if status in status_dict.keys():
-        message = "Okta auth failed: {}".format(status_dict[status])
-    else:
-        message = (
-            "Okta auth failed: {}. Please verify your settings and try again.".format(
-                status
-            )
-        )
-    logging.error(message)
-    logging.debug("Parsing error [{}] ".format(message))
-    return message
-
-
-def user_session_token(primary_auth, headers):
-    """Get session_token.
-
-    param headers: Headers of the request
-    param primary_auth: Primary authentication
-    return session_token: Session Token from JSON response
-    """
-    status = None
-    if primary_auth.get("errorCode"):
-        status = primary_auth.get("errorCode")
-    else:
-        status = primary_auth.get("status")
-
-    if status == "SUCCESS":
-        session_token = primary_auth.get("sessionToken")
-    elif status == "MFA_REQUIRED":
-        session_token = user_mfa_challenge(headers, primary_auth)
-    elif status is None:
-        logging.debug("Error parsing response: {}".format(json.dumps(primary_auth)))
-        logging.error("Okta auth failed: unknown status")
-        sys.exit(1)
-    else:
-        login_error_code_parser(status, settings.okta_status_dict)
-        sys.exit(1)
-    return session_token
-
-
-def authenticate_user(okta_url, okta_username, okta_password):
-    """Authenticate user with okta credential.
-
-    :param okta_url: company specific URL of the okta
-    :param okta_username: okta username
-    :param okta_password: okta password
-    :return: MFA session options
-
-    """
-    logging.debug(
-        "Authenticate user with okta credential [{} user {}]".format(
-            okta_url, okta_username
-        )
-    )
-    headers = {"content-type": "application/json", "accept": "application/json"}
-    payload = helpers.prepare_payload(username=okta_username, password=okta_password)
-
-    primary_auth = okta_verify_api_method(
-        "{}/api/v1/authn".format(okta_url), payload, headers
-    )
-    logging.debug("Authenticate Okta header [{}] ".format(headers))
-
-    session_token = user_session_token(primary_auth, headers)
-    logging.info("User has been succesfully authenticated.")
-    return session_token
-
-
-def mfa_provider_type(
-    mfa_provider,
-    selected_factor,
-    mfa_challenge_url,
-    primary_auth,
-    selected_mfa_option,
-    headers,
-    payload,
-):
-    """Receive session key.
-
-    :param mfa_provider: MFA provider
-    :param selected_factor: Selected MFA factor
-    :param mfa_challenge_url: MFA challenge url
-    :param primary_auth: Primary authentication
-    :param selected_mfa_option: Selected MFA option
-    :return: session_key
-
-    """
-    if mfa_provider == "duo":
-        payload, headers, callback_url = duo_helpers.authenticate_duo(selected_factor)
-        okta_verify_api_method(callback_url, payload)
-        payload.pop("id", "sig_response")
-        mfa_verify = okta_verify_api_method(mfa_challenge_url, payload, headers)
-    elif mfa_provider == "okta" or mfa_provider == "google":
-        mfa_verify = user_mfa_options(
-            selected_mfa_option, headers, mfa_challenge_url, payload, primary_auth
-        )
-    else:
-        logging.error(
-            "Sorry, the MFA provider '{}' is not yet supported."
-            " Please retry with another option.".format(mfa_provider)
-        )
-        exit(1)
-    return mfa_verify["sessionToken"]
-
-
-def user_mfa_index(preset_mfa, available_mfas, mfa_options):
-    """Get mfa method index in request.
-
-    :param preset_mfa: preset mfa method from settings
-    :param available_mfas: available mfa method ids
-    :param mfa_options: available mfa methods
-    """
-    logging.debug("Get mfa method index in request.")
-    if preset_mfa is not None and preset_mfa in available_mfas:
-        mfa_index = available_mfas.index(preset_mfa)
-    else:
-        mfa_index = helpers.select_preferred_mfa_index(mfa_options)
-
-    return mfa_index
-
-
-def user_mfa_challenge(headers, primary_auth):
-    """Handle user mfa challenges.
-
-    :param headers: headers what needs to be sent to api
-    :param primary_auth: primary authentication
-    :return: Okta MFA Session token after the successful entry of the code
-    """
-    logging.debug("Handle user MFA challenges")
-    try:
-        mfa_options = primary_auth["_embedded"]["factors"]
-    except KeyError as error:
-        logging.error("There was a wrong response structure: \n{}".format(error))
-        sys.exit(1)
-
-    preset_mfa = settings.mfa_method
-
-    available_mfas = [d["factorType"] for d in mfa_options]
-
-    if available_mfas.count(preset_mfa) > 1:
-        mfa_method = settings.mfa_method
-        mfa_index = available_mfas.index(preset_mfa)
-        provider = mfa_options[mfa_index]["provider"]
-        mfa_id = mfa_options[mfa_index]["id"]
-
-        logging.warning(
-            "\n\nMore than one method found with {}.\n"
-            "Defaulting to {} - {} - Id: {}.\n"
-            "This functionality will be deprecated in"
-            "the next major release.\n".format(mfa_method, provider, mfa_method, mfa_id)
-        )
-
-    mfa_index = user_mfa_index(preset_mfa, available_mfas, mfa_options)
-
-    # time to challenge the mfa option
-    selected_mfa_option = mfa_options[mfa_index]
-    logging.debug("Selected MFA is [{}]".format(selected_mfa_option))
-
-    mfa_challenge_url = selected_mfa_option["_links"]["verify"]["href"]
-
-    payload = helpers.prepare_payload(
-        stateToken=primary_auth["stateToken"],
-        factorType=selected_mfa_option["factorType"],
-        provider=selected_mfa_option["provider"],
-        profile=selected_mfa_option["profile"],
-    )
-    selected_factor = okta_verify_api_method(mfa_challenge_url, payload, headers)
-
-    mfa_provider = selected_factor["_embedded"]["factor"]["provider"].lower()
-    logging.debug(
-        "MFA Challenge URL: [{}] headers: {}".format(mfa_challenge_url, headers)
-    )
-    mfa_session_token = mfa_provider_type(
-        mfa_provider,
-        selected_factor,
-        mfa_challenge_url,
-        primary_auth,
-        selected_mfa_option,
-        headers,
-        payload,
-    )
-
-    return mfa_session_token
-
-
-def user_mfa_options(
-    selected_mfa_option, headers, mfa_challenge_url, payload, primary_auth
-):
-    """Handle user mfa options.
-
-    :param selected_mfa_option: Selected MFA option (SMS, push, etc)
-    :param headers: headers
-    :param mfa_challenge_url: MFA challenge URL
-    :param payload: payload
-    :param primary_auth: Primary authentication method
-    :return: payload data
-
-    """
-    logging.debug("Handle user MFA options")
-
-    logging.debug(
-        "User MFA options selected: [{}]".format(selected_mfa_option["factorType"])
-    )
-    if selected_mfa_option["factorType"] == "push":
-        return push_approval(headers, mfa_challenge_url, payload)
-
-    if settings.mfa_response is None:
-        logging.debug("Getting verification code from user.")
-        print("Type verification code and press Enter")
-        settings.mfa_response = helpers.get_input()
-
-    # time to verify the mfa method
-    payload = helpers.prepare_payload(
-        stateToken=primary_auth["stateToken"], passCode=settings.mfa_response
-    )
-    mfa_verify = okta_verify_api_method(mfa_challenge_url, payload, headers)
-    logging.debug("mfa_verify [{}]".format(json.dumps(mfa_verify)))
-
-    return mfa_verify
-
-
-def push_approval(headers, mfa_challenge_url, payload):
-    """Handle push approval from the user.
-
-    :param headers: HTTP headers sent to API call
-    :param mfa_challenge_url: MFA challenge url
-    :param payload: payload which needs to be sent
-    :return: Session Token if succeeded or terminates if user wait goes 5 min
-
-    """
-    logging.debug(
-        "Handle push approval from the user [{}] [{}]".format(
-            headers, mfa_challenge_url
-        )
-    )
-
-    print("Waiting for an approval from device...")
-    mfa_status = "WAITING"
-
-    while mfa_status == "WAITING":
-        mfa_verify = okta_verify_api_method(mfa_challenge_url, payload, headers)
-
-        logging.debug("MFA Response:\n{}".format(json.dumps(mfa_verify)))
-
-        if "factorResult" in mfa_verify:
-            mfa_status = mfa_verify["factorResult"]
-        elif mfa_verify["status"] == "SUCCESS":
-            break
-        else:
-            logging.error("There was an error getting your MFA status.")
-            logging.debug(mfa_verify)
-            if "status" in mfa_verify:
-                logging.error("Exiting due to error: {}".format(mfa_verify["status"]))
-            sys.exit(1)
-
-        if mfa_status == "REJECTED":
-            logging.error("The Okta Verify push has been denied. Please retry later.")
-            sys.exit(2)
-        elif mfa_status == "TIMEOUT":
-            logging.error("Device approval window has expired.")
-            sys.exit(2)
-
-        time.sleep(2)
-
-    return mfa_verify
diff --git a/tokendito/settings.py b/tokendito/settings.py
deleted file mode 100644
index 3cbe0da7..00000000
--- a/tokendito/settings.py
+++ /dev/null
@@ -1,35 +0,0 @@
-# vim: set filetype=python ts=4 sw=4
-# -*- coding: utf-8 -*-
-"""This module is responsible for initialisation of global variables."""
-from __future__ import absolute_import, division, print_function, unicode_literals
-
-from os.path import expanduser
-import sys
-
-from future import standard_library
-
-standard_library.install_aliases()
-
-
-config_dir = expanduser("~") + "/.aws"
-config_file = config_dir + "/okta_auth"
-aws_config_file = config_dir + "/config"
-aws_shared_credentials_file = config_dir + "/credentials"
-aws_output = "json"
-aws_profile = None
-aws_region = "us-east-1"
-encoding = sys.stdin.encoding
-mfa_method = None
-mfa_response = None
-okta_aws_app_url = None
-okta_status_dict = {
-    "E0000004": "Authentication failed",
-    "E0000047": "API call exceeded rate limit due to too many requests",
-    "PASSWORD_EXPIRED": "Your password is expired",
-    "LOCKED_OUT": "Your account is locked out",
-}
-okta_org = None
-okta_password = ""
-okta_profile = "default"
-okta_username = ""
-role_arn = None
diff --git a/tokendito/tokendito.py b/tokendito/tokendito.py
index 93004ff0..26c04fbb 100755
--- a/tokendito/tokendito.py
+++ b/tokendito/tokendito.py
@@ -2,14 +2,8 @@
 # vim: set filetype=python ts=4 sw=4
 # -*- coding: utf-8 -*-
 """tokendito cli entry point."""
-from __future__ import absolute_import, division, print_function, unicode_literals
-
 import sys
 
-from future import standard_library
-
-standard_library.install_aliases()
-
 
 def main(args=None):  # needed for console script
     """Packge entry point."""
diff --git a/tokendito/tool.py b/tokendito/tool.py
index 6a9a4141..30f52492 100644
--- a/tokendito/tool.py
+++ b/tokendito/tool.py
@@ -1,46 +1,75 @@
 # vim: set filetype=python ts=4 sw=4
 # -*- coding: utf-8 -*-
 """This module retrieves AWS credentials after authenticating with Okta."""
-from __future__ import absolute_import, division, print_function, unicode_literals
-
 import logging
+import sys
+
+from tokendito import aws
+from tokendito import config
+from tokendito import okta
+from tokendito import user
 
-from future import standard_library
-from tokendito import aws_helpers
-from tokendito import helpers
-from tokendito import okta_helpers
-from tokendito import settings
 
-standard_library.install_aliases()
+logger = logging.getLogger(__name__)
 
 
 def cli(args):
     """Tokendito retrieves AWS credentials after authenticating with Okta."""
+    args = user.parse_cli_args(args)
+
+    # Early logging, in case the user requests debugging via env/CLI
+    user.setup_early_logging(args)
+
     # Set some required initial values
-    args = helpers.setup(args)
+    user.process_options(args)
 
-    logging.debug("tokendito retrieves AWS credentials after authenticating with Okta.")
+    # Late logging (default)
+    user.setup_logging(config.user)
 
-    # Collect and organize user specific information
-    helpers.process_options(args)
+    # Validate configuration
+    message = user.validate_configuration(config)
+    if message:
+        quiet_msg = ""
+        if config.user["quiet"] is not False:
+            quiet_msg = " to run in quiet mode"
+        logger.error(
+            f"Could not validate configuration{quiet_msg}: {'. '.join(message)}. "
+            "Please check your settings, and try again."
+        )
+        sys.exit(1)
 
     # Authenticate okta and AWS also use assumerole to assign the role
-    logging.debug("Authenticate user with Okta and AWS.")
+    session_token = okta.authenticate_user(config)
 
-    secret_session_token = okta_helpers.authenticate_user(
-        settings.okta_org, settings.okta_username, settings.okta_password
-    )
+    session_cookies = None
 
-    saml_response_string, saml_xml = aws_helpers.authenticate_to_roles(
-        secret_session_token, settings.okta_aws_app_url
-    )
+    if config.okta["tile"]:
+        tile_label = ""
+        config.okta["tile"] = (config.okta["tile"], tile_label)
+    else:
+        session_cookies = user.request_cookies(config.okta["org"], session_token)
+        config.okta["tile"] = user.discover_tile(config.okta["org"], session_cookies)
 
-    assume_role_response, role_name = aws_helpers.select_assumeable_role(
-        saml_response_string, saml_xml
+    auth_tiles = aws.authenticate_to_roles(
+        session_token, config.okta["tile"], cookies=session_cookies
     )
 
-    aws_helpers.ensure_keys_work(assume_role_response)
+    (role_response, role_name) = aws.select_assumeable_role(auth_tiles)
+
+    identity = aws.assert_credentials(role_response=role_response)
+    if "Arn" not in identity and "UserId" not in identity:
+        logger.error(
+            f"There was an error retrieving and verifying AWS credentials: {role_response}"
+        )
+        sys.exit(1)
 
-    helpers.set_local_credentials(
-        assume_role_response, role_name, settings.aws_region, settings.aws_output
+    user.set_role_name(config, role_name)
+
+    user.set_local_credentials(
+        response=role_response,
+        role=config.aws["profile"],
+        region=config.aws["region"],
+        output=config.aws["output"],
     )
+
+    user.display_selected_role(profile_name=config.aws["profile"], role_response=role_response)
diff --git a/tokendito/user.py b/tokendito/user.py
new file mode 100644
index 00000000..d3014b2d
--- /dev/null
+++ b/tokendito/user.py
@@ -0,0 +1,1248 @@
+# vim: set filetype=python ts=4 sw=4
+# -*- coding: utf-8 -*-
+"""Helper module for AWS and Okta configuration, management and data flow."""
+import argparse
+import builtins
+import codecs
+import configparser
+from datetime import timezone
+import getpass
+import json
+import logging
+import os
+from pathlib import Path
+from pkgutil import iter_modules
+import platform
+import re
+import sys
+from urllib.parse import urlparse
+
+from botocore import __version__ as __botocore_version__
+from bs4 import __version__ as __bs4_version__  # type: ignore (bs4 does not have PEP 561 support)
+from bs4 import BeautifulSoup
+import requests
+from tokendito import __version__
+from tokendito import aws
+from tokendito import Config
+from tokendito import config as config
+
+# Unfortunately, readline is only available in non-Windows systems. There is no substitution.
+try:
+    import readline  # noqa: F401
+except ModuleNotFoundError:
+    pass
+
+logger = logging.getLogger(__name__)
+
+
+mask_items = []
+
+
+class MaskLoggerSecret(logging.Filter):
+    """Masks secrets in logger messages."""
+
+    def __init__(self):
+        """Initialize filter."""
+        logging.Filter.__init__(self)
+
+    def filter(self, record):
+        """Apply filter on logger messages."""
+        for secret in mask_items:
+            if not isinstance(secret, str):
+                secret = str(secret)
+            record.msg = record.msg.replace(secret, "*****")
+        return True
+
+
+def parse_cli_args(args):
+    """Parse command line arguments.
+
+    :return: args parse object
+    """
+    parser = argparse.ArgumentParser(
+        prog="tokendito", description="Gets a STS token to use with the AWS CLI and SDK."
+    )
+    parser.add_argument("--version", action="store_true", help="Displays version and exit")
+    parser.add_argument(
+        "--configure",
+        action="store_true",
+        help="Prompt user for configuration parameters",
+    )
+    parser.add_argument(
+        "--username",
+        dest="okta_username",
+        help="username to login to Okta. You can "
+        "also use the OKTA_USERNAME environment variable.",
+    )
+    parser.add_argument(
+        "--password",
+        dest="okta_password",
+        help="password to login to Okta. You "
+        "can also user the OKTA_PASSWORD environment variable.",
+    )
+    parser.add_argument(
+        "--profile",
+        dest="user_config_profile",
+        default=config.user["config_profile"],
+        help="Tokendito configuration profile to use.",
+    )
+    parser.add_argument(
+        "--config-file",
+        dest="user_config_file",
+        default=config.user["config_file"],
+        help=f"Use an alternative configuration file. Defaults to {config.user['config_file']}",
+    )
+    parser.add_argument(
+        "--loglevel",
+        "-l",
+        type=lambda s: s.upper(),
+        dest="user_loglevel",
+        choices=["DEBUG", "INFO", "WARN", "ERROR"],
+        help="[DEBUG|INFO|WARN|ERROR], default loglevel is WARNING.",
+    )
+    parser.add_argument(
+        "--log-output-file",
+        dest="user_log_output_file",
+        help="Optional file to log output to.",
+    )
+    parser.add_argument("--aws-config-file", help="AWS Configuration file to write to.")
+    parser.add_argument(
+        "--aws-output",
+        help="Sets the output type for the AWS profile.",
+    )
+    parser.add_argument(
+        "--aws-profile",
+        help="AWS profile to save as in the credentials file.",
+    )
+    parser.add_argument(
+        "--aws-region",
+        help="Sets the region for the AWS profile.",
+    )
+    parser.add_argument("--aws-role-arn", help="Sets the IAM role.")
+    parser.add_argument("--aws-shared-credentials-file", help="AWS credentials file to write to.")
+
+    okta_me_group = parser.add_mutually_exclusive_group()
+    okta_me_group.add_argument(
+        "--okta-org",
+        dest="okta_org",
+        help="Set the Okta Org base URL. This enables role auto-discovery",
+    )
+    okta_me_group.add_argument(
+        "--okta-tile",
+        help="Okta tile URL to use.",
+    )
+    parser.add_argument("--okta-mfa", help="Sets the MFA method")
+    parser.add_argument(
+        "--okta-mfa-response",
+        help="Sets the MFA response to a challenge",
+    )
+    parser.add_argument(
+        "--quiet",
+        dest="user_quiet",
+        action="store_true",
+        default=False,
+        help="Suppress output",
+    )
+
+    parsed_args = parser.parse_args(args)
+
+    return parsed_args
+
+
+def utc_to_local(utc_dt):
+    """Convert UTC time into local time.
+
+    :param:utc_str:datetime
+    :return:local_time:string
+    """
+    try:
+        local_time = utc_dt.replace(tzinfo=timezone.utc).astimezone(tz=None)
+        local_time = local_time.strftime("%Y-%m-%d %H:%M:%S %Z")
+    except TypeError as err:
+        logger.error(f"Could not convert time: {err}")
+        sys.exit(1)
+    return local_time
+
+
+def create_directory(dir_name):
+    """Create directories on the local machine."""
+    if os.path.isdir(dir_name) is False:
+        try:
+            os.mkdir(dir_name)
+        except OSError as error:
+            logger.error(
+                f"Cannot continue creating directory: {config.user['config_dir']}: {error.strerror}"
+            )
+            sys.exit(1)
+
+
+def get_submodule_names():
+    """Inspect the current module and find any submodules.
+
+    :return: List of submodule names
+
+    """
+    package = Path(__file__).resolve(strict=True)
+    submodules = [x.name for x in iter_modules([str(package.parent)])]
+    return submodules
+
+
+def setup_early_logging(args):
+    """Do a best-effort attempt to enable early logging.
+
+    :param args: list of arguments to parse
+    :return: dict with values set
+    """
+    # Get some sane defaults
+    early_logging = config.get_defaults()["user"].copy()
+
+    if "TOKENDITO_USER_LOGLEVEL" in os.environ:
+        early_logging["loglevel"] = os.environ["TOKENDITO_USER_LOGLEVEL"]
+    if "TOKENDITO_USER_LOG_OUTPUT_FILE" in os.environ:
+        early_logging["log_output_file"] = os.environ["TOKENDITO_USER_LOG_OUTPUT_FILE"]
+
+    if "user_loglevel" in args and args.user_loglevel:
+        early_logging["loglevel"] = args.user_loglevel
+    if "user_log_output_file" in args and args.user_log_output_file:
+        early_logging["log_output_file"] = args.user_log_output_file
+
+    setup_logging(early_logging)
+    return early_logging
+
+
+def setup_logging(conf):
+    """Set logging level.
+
+    :param conf: dictionary with config
+    :return: loglevel name
+    """
+    root_logger = logging.getLogger()
+    formatter = logging.Formatter(
+        fmt="%(asctime)s %(levelname)s |%(name)s %(funcName)s():%(lineno)i| %(message)s"
+    )
+    handler = logging.StreamHandler()
+
+    if "log_output_file" in conf and conf["log_output_file"]:
+        handler = logging.FileHandler(conf["log_output_file"])
+    handler.setFormatter(formatter)
+
+    # Set a reasonable default logging format.
+    root_logger.handlers.clear()
+    root_logger.addHandler(handler)
+    root_logger.addFilter(MaskLoggerSecret())
+
+    # Pre-create a log handler for each submodule
+    # with the same format and level. Settings are
+    # inherited from the root logger.
+    submodules = [f"tokendito.{x}" for x in get_submodule_names()]
+    if "loglevel" in conf:
+        conf["loglevel"] = conf["loglevel"].upper()
+        for submodule in submodules:
+            submodule_logger = logging.getLogger(submodule)
+            submodule_logger.addFilter(MaskLoggerSecret())
+            try:
+                submodule_logger.setLevel(conf["loglevel"])
+            except ValueError as err:
+                root_logger.setLevel(config.get_defaults()["user"]["loglevel"])
+                submodule_logger.warning(f"{err}. Plese check your configuration and try again.")
+                break
+    loglevel = logging.getLogger(submodules[0]).getEffectiveLevel()
+    return loglevel
+
+
+def print(args):
+    """Print only if not in quiet mode. Does not affect logging."""
+    if config.user["quiet"] is not True:
+        builtins.print(args)
+    return args
+
+
+def select_role_arn(authenticated_tiles):
+    """Select the role user wants to pick.
+
+    :param: authenticated_tiles, mapping of authenticated tiles metadata, dict
+    :return: user role and associated url, tuple
+    """
+    selected_role = None
+
+    for url, tile in authenticated_tiles.items():
+        logger.debug(f"Select the role user wants to pick [{tile['roles']}]")
+        role_names = dict((role.split("/")[-1], role) for role in tile["roles"])
+        roles = [role.split("/")[-1] for role in tile["roles"]]
+
+        if roles.count(config.aws["profile"]) > 1:
+            logger.error(
+                "There are multiple matches for the profile selected, "
+                "please use the --role-arn option to select one"
+            )
+            sys.exit(2)
+
+        if config.aws["profile"] in role_names.keys():
+            selected_role = (role_names[config.aws["profile"]], url)
+            logger.debug(f"Using aws_profile env var for role: [{config.aws['profile']}]")
+            break
+        elif config.aws["role_arn"] in tile["roles"]:
+            selected_role = (config.aws["role_arn"], url)
+            break
+
+    if selected_role is None:
+        if config.aws["role_arn"] is None:
+            selected_role = prompt_role_choices(authenticated_tiles)
+        else:
+            logger.error(f"User provided rolename does not exist [{config.aws['role_arn']}]")
+            sys.exit(2)
+
+    logger.debug(f"Selected role: [{selected_role}]")
+
+    return selected_role
+
+
+def factor_type_info(factor_type, mfa_option):
+    """Get factor info from okta reply.
+
+    :param factor_type: mfa
+    :param mfa_option: mfa_option
+    :return: info about mfa
+    """
+    logger.debug("Choose factor info depending on factor type.")
+    factor_info = "Not Presented"
+
+    if factor_type in ["token", "token:software:totp", "token:hardware"]:
+        factor_info = mfa_option.get("profile").get("credentialId")
+    elif factor_type == "push":
+        factor_info = mfa_option.get("profile").get("name")
+    elif factor_type == "sms" or factor_type == "call":
+        factor_info = mfa_option.get("profile").get("phoneNumber")
+    elif factor_type == "webauthn":
+        factor_info = mfa_option.get("profile").get("authenticatorName")
+    elif factor_type in ["web", "u2f", "token:hotp"]:
+        factor_info = mfa_option.get("vendorName")
+    elif factor_type == "question":
+        factor_info = mfa_option.get("profile").get("question")
+    elif factor_type == "email":
+        factor_info = mfa_option.get("profile").get("email")
+
+    return factor_info
+
+
+def mfa_option_info(mfa_option):
+    """Build an optional string with the MFA factor information.
+
+    :param mfa_option: dictionary with a single MFA response.
+    :return: pre-formatted string with MFA factor info if available, None
+             otherwise.
+    """
+    logger.debug(f"Building info for: {json.dumps(mfa_option)}")
+    factor_info = "Not Presented"
+    if "factorType" in mfa_option:
+        factor_type = mfa_option["factorType"]
+        factor_info = factor_type_info(factor_type, mfa_option)
+
+    return factor_info
+
+
+def select_preferred_mfa_index(mfa_options, factor_key="provider", subfactor_key="factorType"):
+    """Show all the MFA options to the users.
+
+    :param mfa_options: List of available MFA options
+    :return: MFA option selected index by the user from the output
+    """
+    logger.debug("Show all the MFA options to the users.")
+    logger.debug(json.dumps(mfa_options))
+    print("\nSelect your preferred MFA method and press Enter:")
+
+    longest_index = len(str(len(mfa_options)))
+    longest_factor_name = max([len(d[factor_key]) for d in mfa_options])
+    longest_subfactor_name = max([len(d[subfactor_key]) for d in mfa_options])
+    factor_info_indent = max([len(mfa_option_info(d)) for d in mfa_options])
+
+    for (i, mfa_option) in enumerate(mfa_options):
+        factor_id = mfa_option.get("id", "Not presented")
+        factor_info = mfa_option_info(mfa_option)
+        mfa = mfa_option.get(subfactor_key, "Not presented")
+        provider = mfa_option.get(factor_key, "Not presented")
+        print(
+            f"[{i: >{longest_index}}]  "
+            f"{provider: <{longest_factor_name}}  "
+            f"{mfa: <{longest_subfactor_name}} "
+            f"{factor_info: <{factor_info_indent}} "
+            f"Id: {factor_id}"
+        )
+
+    user_input = collect_integer(len(mfa_options))
+
+    return user_input
+
+
+def prompt_role_choices(aut_tiles):
+    """Ask user to select role.
+
+    :param aut_tiles: mapping of authenticated tiles metadata, dict
+    :return: user's role and associated url, tuple
+    """
+    aliases_mapping = []
+
+    for url, tile in aut_tiles.items():
+        logger.debug(f"Getting aliases for {url}")
+        alias_table = get_account_aliases(tile["saml"], tile["saml_response_string"])
+
+        for role in tile["roles"]:
+            if alias_table:
+                aliases_mapping.append((tile["label"], alias_table[role.split(":")[4]], role, url))
+            else:
+                logger.debug(f"There were no labels in {url}. Using account ID")
+                aliases_mapping.append((tile["label"], role.split(":")[4], role, url))
+
+    logger.debug("Ask user to select role")
+    print("\nPlease select one of the following:")
+
+    longest_alias = max(len(i[1]) for i in aliases_mapping)
+    longest_index = len(str(len(aliases_mapping)))
+    aliases_mapping = sorted(aliases_mapping)
+    print_label = ""
+
+    for i, data in enumerate(aliases_mapping):
+        label, alias, role, _ = data
+        padding_index = longest_index - len(str(i))
+        if print_label != label:
+            print_label = label
+            print(f"\n{label}:")
+
+        print(f"[{i}] {padding_index * ' '}" f"{alias: <{longest_alias}}  {role}")
+
+    user_input = collect_integer(len(aliases_mapping))
+    selected_role = (aliases_mapping[user_input][2], aliases_mapping[user_input][3])
+    logger.debug(f"Selected role [{user_input}]")
+
+    return selected_role
+
+
+def display_selected_role(profile_name="", role_response={}):
+    """Print details about how to assume role.
+
+    :param profile_name: AWS profile name
+    :param role_response: Assume Role response dict
+    :return: message displayed.
+
+    """
+    try:
+        expiration_time = role_response["Credentials"]["Expiration"]
+    except (KeyError, TypeError) as err:
+        logger.error(f"Could not retrieve expiration time: {err}")
+        sys.exit(1)
+
+    expiration_time_local = utc_to_local(expiration_time)
+    msg = (
+        f"\nGenerated profile '{profile_name}' in "
+        f"{config.aws['shared_credentials_file']}.\n"
+        "\nUse profile to authenticate to AWS:\n\t"
+        f"aws --profile '{profile_name}' sts get-caller-identity"
+        "\nOR\n\t"
+        f"export AWS_PROFILE='{profile_name}'\n\n"
+        f"Credentials are valid until {expiration_time} ({expiration_time_local})."
+    )
+
+    print(msg)
+    return msg
+
+
+def extract_arns(saml):
+    """Extract arns from SAML decoded xml.
+
+    :param saml: results saml decoded
+    :return: Dict of Role and Provider ARNs
+    """
+    logger.debug("Decode response string as a SAML decoded value.")
+
+    arn_regex = ">(arn:aws:iam::.*?,arn:aws:iam::.*?)<"
+
+    # find all provider and role pairs.
+    arns = re.findall(arn_regex, saml)
+
+    if len(arns) == 0:
+        logger.error("No IAM roles found in SAML response.")
+        logger.debug(arns)
+        sys.exit(2)
+
+    # stuff into dict, role is dict key.
+    roles_and_providers = {i.split(",")[1]: i.split(",")[0] for i in arns}
+
+    logger.debug(f"Collected ARNs: {json.dumps(roles_and_providers)}")
+
+    return roles_and_providers
+
+
+def validate_saml_response(html):
+    """Parse html to validate that saml a saml response was returned."""
+    soup = BeautifulSoup(html, "html.parser")
+
+    xml = None
+    for elem in soup.find_all("input", attrs={"name": "SAMLResponse"}):
+        saml_base64 = elem.get("value")
+        xml = codecs.decode(saml_base64.encode("ascii"), "base64").decode("utf-8")
+
+    if xml is None:
+        logger.error(
+            "Invalid data detected in SAML response. View the response with the DEBUG loglevel."
+        )
+        logger.debug(html)
+        sys.exit(1)
+
+    return xml
+
+
+def validate_okta_org(input_url=None):
+    """Validate whether a given URL is a valid AWS Org URL in Okta.
+
+    :param input_url: string
+    :return: bool. True if valid, False otherwise
+    """
+    logger.debug(f"Will try to match '{input_url}' to a valid URL")
+
+    url = urlparse(input_url)
+    logger.debug(f"URL parsed as {url}")
+    if (
+        url.scheme == "https"
+        and (url.path == "" or url.path == "/")
+        and url.params == ""
+        and url.query == ""
+        and url.fragment == ""
+    ):
+        return True
+
+    logger.debug(f"{url} does not look like a valid match.")
+    return False
+
+
+def validate_okta_tile(input_url=None):
+    """Validate whether a given URL is a valid AWS tile URL in Okta.
+
+    :param input_url: string
+    :return: bool. True if valid, False otherwise
+    """
+    logger.debug(f"Will try to match '{input_url}' to a valid URL")
+
+    url = urlparse(input_url)
+    logger.debug(f"URL parsed as {url}")
+    # Here, we could also check url.netloc against r'.*\.okta(preview)?\.com$'
+    # but Okta allows the usage of custome URLs such as login.acme.com
+    if (
+        url.scheme == "https"
+        and re.match(r"^/home/amazon_aws/\w{20}/\d{3}$", str(url.path)) is not None
+    ):
+        return True
+
+    logger.debug(f"{url} does not look like a valid match.")
+    return False
+
+
+def get_account_aliases(saml_xml, saml_response_string):
+    """Parse AWS SAML page for account aliases.
+
+    :param saml_xml: Decoded saml response from Okta
+    :param saml_response_string response from Okta with saml data:
+    :return: mapping table of account ids to their aliases
+    """
+    soup = BeautifulSoup(saml_response_string, "html.parser")
+    form = soup.find("form")
+    action = form.get("action")  # type: ignore (bs4 does not have PEP 561 support)
+    url = str(action)
+
+    encoded_xml = codecs.encode(saml_xml.encode("utf-8"), "base64")
+    aws_response = None
+    try:
+        aws_response = requests.Session().post(url, data={"SAMLResponse": encoded_xml})
+    except Exception as request_error:
+        logger.error(f"There was an error retrieving the AWS SAML page: \n{request_error}")
+        logger.debug(json.dumps(aws_response))
+        sys.exit(1)
+
+    if "Account: " not in aws_response.text:
+        logger.debug("No labels found")
+        logger.debug(json.dumps(aws_response.text))
+        return None
+
+    soup = BeautifulSoup(aws_response.text, "html.parser")
+    account_names = soup.find_all(text=re.compile("Account:"))
+    alias_table = {str(i.split(" ")[-1]).strip("()"): i.split(" ")[1] for i in account_names}
+
+    return alias_table
+
+
+def display_version():
+    """Print program version and exit."""
+    python_version = platform.python_version()
+    (system, _, release, _, _, _) = platform.uname()
+    logger.debug(f"Display version: {__version__}")
+    print(
+        f"tokendito/{__version__} "
+        f"Python/{python_version} "
+        f"{system}/{release} "
+        f"botocore/{__botocore_version__} "
+        f"bs4/{__bs4_version__} "
+        f"requests/{requests.__version__}"
+    )
+
+
+def add_sensitive_value_to_be_masked(value, key=None):
+    """Add value to be masked from the logs."""
+    """If a key is passed only add it if the key refers to a secret element."""
+    sensitive_keys = ("password", "mfa_response", "sessionToken")
+    if key is None or key in sensitive_keys:
+        mask_items.append(value)
+
+
+def process_ini_file(file, profile):
+    """Process options from a ConfigParser ini file.
+
+    :param file: filename
+    :param profile: profile to read
+    :return: Config object with configuration values
+    """
+    res = dict()
+    pattern = re.compile(r"^(.*?)_(.*)")
+
+    ini = configparser.ConfigParser(default_section=config.user["config_profile"])
+    # Here, group(1) is the dictionary key, and group(2) the configuration element
+    try:
+        ini.read(file)
+        for (key, val) in ini.items(profile):
+            match = re.search(pattern, key.lower())
+            if match:
+                if match.group(1) not in res:
+                    res[match.group(1)] = dict()
+                res[match.group(1)][match.group(2)] = val
+                add_sensitive_value_to_be_masked(val, match.group(2))
+    except configparser.Error as err:
+        logger.error(f"Could not load profile '{profile}': {str(err)}")
+        sys.exit(2)
+    logger.debug(f"Found ini directives: {res}")
+
+    try:
+        config_ini = Config(**res)
+
+    except (AttributeError, KeyError, ValueError) as err:
+        logger.error(
+            f"The configuration file {file} in [{profile}] is incorrect: {err}"
+            ". Please check your settings and try again."
+        )
+        sys.exit(1)
+    return config_ini
+
+
+def process_arguments(args):
+    """Process command-line arguments.
+
+    :param args: argparse object
+    :return: Config object with configuration values
+    """
+    res = dict()
+    pattern = re.compile(r"^(.*?)_(.*)")
+
+    for (key, val) in vars(args).items():
+        match = re.search(pattern, key.lower())
+        if match:
+            if match.group(1) not in get_submodule_names():
+                continue
+            if match.group(1) not in res:
+                res[match.group(1)] = dict()
+            if val:
+                res[match.group(1)][match.group(2)] = val
+                add_sensitive_value_to_be_masked(val, match.group(2))
+    logger.debug(f"Found arguments: {res}")
+
+    try:
+        config_args = Config(**res)
+
+    except (AttributeError, KeyError, ValueError) as err:
+        logger.error(
+            f"Command line arguments not correct: {err}"
+            ". This should not happen, please contact the package maintainers."
+        )
+        sys.exit(1)
+    return config_args
+
+
+def process_environment(prefix="tokendito"):
+    """Process environment variables.
+
+    :return: Config object with configuration values.
+    """
+    res = dict()
+    pattern = re.compile(rf"^({prefix})_(.*?)_(.*)")
+    # Here, group(1) is the prefix variable, group(2) is the dictionary key,
+    # and group(3) the configuration element.
+    for (key, val) in os.environ.items():
+        match = re.search(pattern, key.lower())
+        if match:
+            if match.group(2) not in res:
+                res[match.group(2)] = dict()
+            if val:
+                res[match.group(2)][match.group(3)] = val
+                add_sensitive_value_to_be_masked(val, match.group(3))
+    logger.debug(f"Found environment variables: {res}")
+
+    try:
+        config_env = Config(**res)
+
+    except (AttributeError, KeyError, ValueError) as err:
+        logger.error(
+            f"The environment variables are incorrectly set: {err}"
+            ". Please check your settings and try again."
+        )
+        sys.exit(1)
+    return config_env
+
+
+def process_interactive_input(config, skip_password=False):
+    """
+    Request input interactively interactively for elements that are not proesent.
+
+    :param config: Config object with some values set.
+    :param skip_password: Whether or not ask the user for a password.
+    :returns: Config object with necessary values set.
+    """
+    # Return quickly if the user attempts to run in quiet (non-interactive) mode.
+    if config.user["quiet"] is True:
+        logger.debug(f"Skipping interactive config: quiet mode is {config.user['quiet']}")
+        return config
+
+    # Reuse interactive config. It will only request the portions needed.
+    try:
+        details = get_interactive_config(
+            tile=config.okta["tile"],
+            org=config.okta["org"],
+            username=config.okta["username"],
+        )
+    except (AttributeError, KeyError, ValueError) as err:
+        logger.error(f"Interactive arguments are not correct: {err}")
+        sys.exit(1)
+
+    # Create a dict that can be passed to Config later
+    res = dict(okta=dict())
+    # Copy the values set by get_interactive_config
+    if "okta_tile" in details:
+        res["okta"]["tile"] = details["okta_tile"]
+    if "okta_org" in details:
+        res["okta"]["org"] = details["okta_org"]
+    if "okta_username" in details:
+        res["okta"]["username"] = details["okta_username"]
+
+    if ("password" not in config.okta or config.okta["password"] == "") and not skip_password:
+        logger.debug("No password set, will try to get one interactively")
+        res["okta"]["password"] = get_password()
+        add_sensitive_value_to_be_masked(res["okta"]["password"])
+
+    config_int = Config(**res)
+    logger.debug(f"Interactive configuration is: {config_int}")
+    config.update(config_int)
+    return config_int
+
+
+def get_interactive_config(tile=None, org=None, username=""):
+    """Obtain user input from the user.
+
+    :return: dictionary with values
+    """
+    logger.debug("Obtain user input for the user.")
+    details = {}
+
+    # We need either one of these two:
+    while not validate_okta_org(org) and not validate_okta_tile(tile):
+        print("\n\nPlease enter either your Organization URL, a tileURL, or both.")
+        org = get_org()
+        tile = get_tile()
+
+    while username == "":
+        username = get_username()
+
+    if org is not None:
+        details["okta_org"] = org
+    if tile is not None:
+        details["okta_tile"] = tile
+    details["okta_username"] = username
+
+    logger.debug(f"Details: {details}")
+    return details
+
+
+def get_base_url(urlstring):
+    """
+    Extract base url from string.
+
+    :param urlstring: url string
+    :returns: base URL
+    """
+    url = urlparse(urlstring)
+    baseurl = f"{url.scheme}://{url.netloc}"
+    return baseurl
+
+
+def get_org():
+    """Get Org URL from user.
+
+    :return: string with sanitized value, or the empty string.
+    """
+    message = "Okta Org URL. E.g. https://acme.okta.com/: "
+    res = ""
+
+    while res == "":
+        user_data = get_input(prompt=message)
+        user_data = user_data.strip()
+        if user_data == "":
+            break
+        if not user_data.startswith("https://"):
+            user_data = f"https://{user_data}"
+        if validate_okta_org(user_data):
+            res = user_data
+        else:
+            print("Invalid input, try again.")
+    logger.debug(f"Org URL is: {res}")
+    return res
+
+
+def get_tile():
+    """Get tile URL from user.
+
+    :return: string with sanitized value, or the empty string.
+    """
+    message = (
+        "Okta tile URL. E.g. https://acme.okta.com/home/" "amazon_aws/b07384d113edec49eaa6/123: "
+    )
+    res = ""
+
+    while res == "":
+        user_data = get_input(prompt=message)
+        user_data = user_data.strip()
+        if user_data == "":
+            break
+        if not user_data.startswith("https://"):
+            user_data = f"https://{user_data}"
+        if validate_okta_tile(user_data):
+            res = user_data
+        else:
+            print("Invalid input, try again.")
+    logger.debug(f"App URL is: {res}")
+    return res
+
+
+def get_username():
+    """Get username from user.
+
+    :return: string with sanitized value.
+    """
+    message = "Organization username. E.g. jane.doe@acme.com: "
+    res = ""
+    while res == "":
+        user_data = get_input(prompt=message)
+        user_data = user_data.strip()
+        if user_data != "":
+            res = user_data
+        else:
+            print("Invalid input, try again.")
+    logger.debug(f"Username is {res}")
+    return res
+
+
+def get_password():
+    """Set okta password interactively.
+
+    :param args: command line arguments
+    :return: okta_password
+
+    """
+    res = ""
+    logger.debug("Set password.")
+
+    while res == "":
+        password = getpass.getpass()
+        res = password
+        logger.debug("password set interactively")
+    return res
+
+
+def set_role_name(config_obj, name):
+    """Set AWS Role alias name based on user preferences.
+
+    :param config: Config object.
+    :param name: Role name. Defaults to the string "default"
+    :return: Config object.
+    """
+    if name is None or name == "":
+        name = "default"
+    if config_obj.aws["profile"] is None:
+        config_obj.aws["profile"] = str(name)
+
+    return config_obj
+
+
+def update_configuration(config):
+    """Update configuration file on local system.
+
+    :param ini_file: Configuration file
+    :param profile: profile in which to write.
+    :return: None
+    """
+    logger.debug("Update configuration file on local system.")
+    ini_file = config.user["config_file"]
+    profile = config.user["config_profile"]
+
+    contents = {}
+    # Copy relevant parts of the configuration into an dictionary that
+    # will be written out to disk
+    if "org" in config.okta and config.okta["org"] is not None:
+        contents["okta_org"] = config.okta["org"]
+    if "tile" in config.okta and config.okta["tile"] is not None:
+        contents["okta_tile"] = config.okta["tile"]
+    if "mfa" in config.okta and config.okta["mfa"] is not None:
+        contents["okta_mfa"] = config.okta["mfa"]
+    if "username" in config.okta and config.okta["username"] != "":
+        contents["okta_username"] = config.okta["username"]
+    logger.debug(f"Adding {contents} to config file.")
+    update_ini(profile=profile, ini_file=ini_file, **contents)
+    logger.info(f"Updated {ini_file} with profile {profile}")
+
+
+def set_local_credentials(response={}, role="default", region="us-east-1", output="json"):
+    """Write to local files to insert credentials.
+
+    :param response: AWS AssumeRoleWithSaml response
+    :param role: the name of the assumed role, used for local profile
+    :param region: configured region for aws credential profile
+    :param output: configured datatype for aws cli output
+    :return: Role name on a successful call.
+    """
+    try:
+        aws_access_key_id = response["Credentials"]["AccessKeyId"]
+        aws_secret_access_key = response["Credentials"]["SecretAccessKey"]
+        aws_session_token = response["Credentials"]["SessionToken"]
+    except KeyError as err:
+        logger.error(f"Could not retrieve crendentials: {err}")
+        sys.exit(1)
+
+    update_ini(
+        profile=role,
+        ini_file=config.aws["shared_credentials_file"],
+        aws_access_key_id=aws_access_key_id,
+        aws_secret_access_key=aws_secret_access_key,
+        aws_session_token=aws_session_token,
+    )
+
+    update_ini(
+        profile=f"profile {role}",
+        ini_file=config.aws["config_file"],
+        output=output,
+        region=region,
+    )
+
+    return role
+
+
+def update_ini(profile="", ini_file="", **kwargs):
+    """Update a generic INI file.
+
+    :param profile: Profile name
+    :param ini_file: File to write to.
+    :param **kwargs: key/value pairs to write to the ini file
+    :return: ConfigParser object written
+    """
+    ini_dir = os.path.dirname(ini_file)
+    logger.debug(f"Updating: '{ini_file}'")
+
+    create_directory(ini_dir)
+
+    ini = configparser.RawConfigParser()
+    try:
+        ini.read(ini_file, encoding=config.user["encoding"])
+        logger.debug(f"Parsed '{ini_file}'")
+    except (configparser.Error, OSError) as err:
+        logger.error(f"Failed to read '{ini_file}': {err}")
+        sys.exit(1)
+
+    if not ini.has_section(profile):
+        ini.add_section(profile)
+
+    for key, value in kwargs.items():
+        ini.set(profile, key, value)
+
+    try:
+        with open(ini_file, "w+", encoding=config.user["encoding"]) as file:
+            ini.write(file)
+        logger.debug(f"Wrote {len(kwargs.items())} keys to '{ini_file}'")
+    except (configparser.Error, OSError) as err:
+        logger.error(f"Failed to write to '{ini_file}': {err}")
+        sys.exit(1)
+    return ini
+
+
+def check_within_range(user_input, valid_range):
+    """Validate the user input is within the range of the presented menu.
+
+    :param user_input: integer-validated user input.
+    :param valid_range: the valid range presented on the user's menu.
+    :return range_validation: true or false
+    """
+    range_validation = False
+    if int(user_input) in range(0, valid_range):
+        range_validation = True
+    else:
+        logger.debug(f"Valid range is {valid_range}")
+        logger.error("Value is not in within the selection range.")
+    return range_validation
+
+
+def check_integer(value):
+    """Validate integer.
+
+    :param value: value to be validated.
+    :return: True when the number is a positive integer, false otherwise.
+    """
+    integer_validation = False
+    if str(value).isdigit():
+        integer_validation = True
+    else:
+        logger.error("Please enter a valid integer.")
+
+    return integer_validation
+
+
+def validate_input(value, valid_range):
+    """Validate user input is an integer and within menu range.
+
+    :param value: user input
+    :param valid_range: valid range based on how many menu options available to user.
+    """
+    integer_validation = check_integer(value)
+    if integer_validation and valid_range:
+        integer_validation = check_within_range(value, valid_range)
+    return integer_validation
+
+
+def get_input(prompt="-> "):
+    """Collect user input for TOTP.
+
+    :param prompt: optional string with prompt.
+    :return user_input: raw from user.
+    """
+    user_input = input(f"{prompt}")
+    logger.debug(f"User input: {user_input}")
+
+    return user_input
+
+
+def collect_integer(valid_range=0):
+    """Collect input from user.
+
+    Prompt the user for input. Validate it and cast to integer.
+
+    :param valid_range: number of menu options available to user.
+    :return user_input: validated, casted integer from user.
+    """
+    user_input = None
+    while True:
+        user_input = get_input()
+        valid_input = validate_input(user_input, valid_range)
+        logger.debug(f"User input validation status is {valid_input}")
+        if valid_input:
+            user_input = int(user_input)
+            break
+    return user_input
+
+
+def process_options(args):
+    """Collect all user-specific credentials and config params."""
+    if args.version:
+        display_version()
+        sys.exit(0)
+
+    # 1: read ini file (if it exists)
+    config_ini = Config()
+    if not args.configure:
+        config_ini = process_ini_file(args.user_config_file, args.user_config_profile)
+
+    # 2: override with ENV
+    config_env = process_environment()
+
+    # 3: override with args
+    config_args = process_arguments(args)
+
+    config.update(config_ini)
+    config.update(config_env)
+    config.update(config_args)
+
+    # 4: Get missing data from the user, if necessary
+    config_int = process_interactive_input(config, args.configure)
+    config.update(config_int)
+
+    sanitize_config_values(config)
+    logger.debug(f"Final configuration is {config}")
+
+    if args.configure:
+        update_configuration(config)
+        sys.exit(0)
+
+
+def validate_basic_configuration(config):
+    """Ensure that basic configuration values are sane.
+
+    :param config: Config element with final configuration.
+    :return: message with validation issues.
+    """
+    message = []
+    if not config.okta["username"] or config.okta["username"] == "":
+        message.append("Username not set")
+    if not config.okta["password"] or config.okta["password"] == "":
+        message.append("Password not set")
+    if not config.okta["org"] and not config.okta["tile"]:
+        message.append("Either Okta Org or tile URL must be defined")
+    if config.okta["tile"] and not validate_okta_tile(config.okta["tile"]):
+        message.append(f"Tile URL {config.okta['tile']} is not valid")
+    if config.okta["org"] and not validate_okta_org(config.okta["org"]):
+        message.append(f"Org URL {config.okta['org']} is not valid")
+    if (
+        config.okta["org"]
+        and config.okta["tile"]
+        and not config.okta["tile"].startswith(config.okta["org"])
+    ):
+        message.append(
+            f"Org URL {config.okta['org']} and Tile URL"
+            f" {config.okta['tile']} must be in the same domain"
+        )
+
+    return message
+
+
+def validate_quiet_configuration(config):
+    """Ensure that minimum configuration settings for running quietly are met.
+
+    This is kept separately from validate_basic_configuration to avoid complexity
+    and avoid testability. These functions should always be used together.
+
+    :param config: Config element with final configuration.
+    :return: message with validation issues.
+    """
+    message = []
+    if "quiet" in config.user and config.user["quiet"] is not False:
+        if not config.aws["role_arn"]:
+            message.append("AWS role ARN not set")
+        if not config.okta["mfa"]:
+            message.append("MFA Method not set")
+        if not config.okta["mfa_response"] and config.okta["mfa"] != "push":
+            message.append("MFA Response not set")
+
+    return message
+
+
+def validate_configuration(config):
+    """
+    Ensure that configuration settings are appropriate before contacting the Okta endpoint.
+
+    :param config: Config element with final configuration.
+    :return: message with validation issues.
+    """
+    messages = validate_basic_configuration(config) + validate_quiet_configuration(config)
+    return messages
+
+
+def sanitize_config_values(config):
+    """Adjust values that may need to be corrected.
+
+    :param config: Config object to adjust
+    :returns: modified object.
+    """
+    if config.okta["tile"]:
+        base_url = get_base_url(config.okta["tile"])
+        config.okta["org"] = base_url
+
+    if config.aws["output"] not in aws.get_output_types():
+        config.aws["output"] = config.get_defaults()["aws"]["output"]
+        logger.warning(f"AWS Output reset to {config.aws['output']}")
+
+    if config.aws["region"] not in aws.get_regions():
+        config.aws["region"] = config.get_defaults()["aws"]["region"]
+        logger.warning(f"AWS Region reset to {config.aws['region']}")
+
+    return config
+
+
+def request_cookies(url, session_token):
+    """
+    Request session cookie.
+
+    :param url: okta org url, str
+    :param session_token: session token, str
+    :returns: cookies object
+    """
+    url = f"{url}/api/v1/sessions"
+    data = json.dumps({"sessionToken": f"{session_token}"})
+
+    response_with_cookie = make_request(method="POST", url=url, data=data)
+    sess_id = response_with_cookie.json()["id"]
+    add_sensitive_value_to_be_masked(sess_id)
+
+    cookies = response_with_cookie.cookies
+    cookies.update({"sid": f"{sess_id}"})
+    logger.debug(f"Session cookies: {cookies}")
+
+    return cookies
+
+
+def discover_tile(url, cookies):
+    """
+    Discover aws tile url on user's okta dashboard.
+
+    :param url: okta org url
+    :param cookies: HTML cookies
+    :returns: aws tile url. str
+    """
+    url = f"{url}/api/v1/users/me/home/tabs"
+    params = {
+        "type": "all",
+        "expand": ["items", "items.resource"],
+    }
+    logger.debug(f"Performing auto-discovery on {url}.")
+    response_with_tabs = make_request(method="GET", url=url, cookies=cookies, params=params)
+    tabs = response_with_tabs.json()
+
+    aws_tiles = []
+    for tab in tabs:
+        for tile in tab["_embedded"]["items"]:
+            if "amazon_aws" in tile["_embedded"]["resource"]["linkUrl"]:
+                aws_tiles.append(tile["_embedded"]["resource"])
+
+    if not aws_tiles:
+        logger.error("AWS tile url not found please set url and try again")
+        sys.exit(2)
+
+    tile = (
+        {(url["linkUrl"], url["label"]) for url in aws_tiles}
+        if len(aws_tiles) > 1
+        else (aws_tiles[0]["linkUrl"], aws_tiles[0]["label"])
+    )
+    logger.debug(f"Discovered {len(tile)} URLs.")
+
+    return tile
+
+
+def make_request(method, url, headers=None, **kwargs):
+    """
+    Wrap 'requests.request' and perform response checks.
+
+    :param method: request method
+    :param url: request URL
+    :param headers: request headers
+    :param kwargs: additional parameters passed to request
+    :returns: response object
+    """
+    if headers is None:
+        headers = {"content-type": "application/json", "accept": "application/json"}
+
+    response = requests.request(method=method, url=url, headers=headers, **kwargs)
+
+    if response.status_code != 200:
+        logger.error(
+            f"Your {method} request failed with status_code {response.status_code}.\n"
+            f"{response.content}\n"
+        )
+
+    return response
diff --git a/tox.ini b/tox.ini
index bb230c4a..8c38383c 100755
--- a/tox.ini
+++ b/tox.ini
@@ -1,24 +1,26 @@
 [tox]
 skipsdist = true
-envlist = lint, py{27,35,36,37,38}, auth, coverage
+envlist = lint, py{37,38,39,310,311}, auth, coverage
 
 [testenv]
 deps = -r requirements-dev.txt
+passenv = TERM
 commands =
-    py.test --cov=tokendito --cov-append -v -rA -k 'unit' -s tests/
-    py.test --cov=tokendito --cov-append -v -rA -k 'functional and not credentials' -s tests/
+    coverage erase
+    pytest --cov=tokendito --cov-append -v -ra -k 'unit' -s tests/ --
+    pytest --cov=tokendito --cov-append -v -ra -k 'functional and not credentials' -s tests/ --
 
 [testenv:lint]
 skip_install = true
 commands =
-    flake8
-    pyroma --min=10 .
+    flake8 --
+    pyroma --min=10 . --
 
 [testenv:auth]
 skip_install = true
-passenv = OKTA_* AWS_* ROLE_* MFA_*
+passenv = TOKENDITO_*
 commands =
-    py.test --cov=tokendito --cov-append -v -rA -k 'functional and credentials' -s tests/ {posargs}
+    pytest --cov=tokendito --cov-append -v -rA -k 'functional and (config or credentials)' -s tests/ {posargs}
 
 [testenv:coverage]
 skip_install = true
@@ -28,26 +30,21 @@ commands =
 
 [gh-actions]
 python =
-    2.7: py27
-    3.5: py35
-    3.6: py36
     3.7: py37
     3.8: py38
+    3.9: py39
+    3.10: py310
+    3.11: py311
 
 [flake8]
 max-line-length = 100
 max-complexity = 8
-exclude = .git, __pycache__, .tox, build/, venv/
+exclude = .git, __pycache__, .tox, build/, .venv/, venv/
 extend-ignore = E203, W503
 import-order-style = google
 application-import-names = flake8
-format = ${cyan}%(path)s${reset}:${yellow_bold}%(row)d${reset}:${green_bold}%(col)d${reset}: ${red_bold}%(code)s${reset} %(text)s
+format = %(cyan)s%(path)s%(reset)s:%(bold)s%(yellow)s%(row)d%(reset)s:%(bold)s%(green)s%(col)d%(reset)s: %(bold)s%(red)s%(code)s%(reset)s %(text)s
 
 [pytest]
 env =
     PIP_DISABLE_PIP_VERSION_CHECK=1
-    PYTHONWARNINGS=ignore:DEPRECATION, ignore::UserWarning, ignore::DeprecationWarning
-filterwarnings =
-    ignore::DeprecationWarning
-    ignore::UserWarning
-    ignore:DEPRECATION


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index a0a8ecf7..41c4e86f 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -2,13 +2,12 @@ name: Lint and Test
 on:
   push:
     branches:
-      - master
       - main
       - development/2.0.0
   pull_request:
     branches:
-      - master
       - main
+      - development/2.0.0
 
 jobs:
   lint:
@@ -16,9 +15,9 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - name: Checkout
-        uses: actions/checkout@v2
+        uses: actions/checkout@v3
       - name: Set up Python
-        uses: actions/setup-python@v2
+        uses: actions/setup-python@v4
         with:
           python-version: '3.x'
       - name: Install dependencies
@@ -35,12 +34,12 @@ jobs:
     strategy:
       matrix:
         os: [ubuntu-latest, windows-latest, macos-latest]
-        python-version: [2.7, 3.5, 3.6, 3.7, 3.8]
+        python-version: ['3.7', '3.8', '3.9', '3.10', '3.11']
     steps:
     - name: Checkout
-      uses: actions/checkout@v2
+      uses: actions/checkout@v3
     - name: Set up Python ${{ matrix.python-version }}
-      uses: actions/setup-python@v2
+      uses: actions/setup-python@v4
       with:
         python-version: ${{ matrix.python-version }}
     - name: Install dependencies
@@ -50,10 +49,10 @@ jobs:
     - name: Test with tox
       run: tox
     - name: Convert coverage to LCOV format
-      if: matrix.python-version == '3.8'
+      if: matrix.python-version == '3.10'
       run: tox -e coverage
     - name: Upload coverage in Parallel
-      if: matrix.python-version == '3.8'
+      if: matrix.python-version == '3.10'
       uses: coverallsapp/github-action@master
       with:
         github-token: ${{ secrets.github_token }}
@@ -71,21 +70,21 @@ jobs:
       - name: Set up Python
         uses: actions/setup-python@v2
         with:
-          python-version: '3.x'
+          python-version: '3.11'
       - name: Install dependencies
         run: |
           python -m pip install --upgrade pip
           pip install tox
       - name: Run Authenticated tests
         run: |
-          tox -e auth
+          tox -e py311,auth
         env:
-          OKTA_USERNAME: ${{ secrets.TOXTEST_OKTA_USERNAME }}
-          OKTA_PASSWORD: ${{ secrets.TOXTEST_OKTA_PASSWORD }}
-          MFA_METHOD: ${{ secrets.TOXTEST_MFA_METHOD }}
-          MFA_RESPONSE: ${{ secrets.TOXTEST_MFA_RESPONSE }}
-          OKTA_AWS_APP_URL: ${{ secrets.TOXTEST_OKTA_AWS_APP_URL }}
-          ROLE_ARN: ${{ secrets.TOXTEST_ROLE_ARN }}
+          TOKENDITO_OKTA_USERNAME: ${{ secrets.TOXTEST_OKTA_USERNAME }}
+          TOKENDITO_OKTA_PASSWORD: ${{ secrets.TOXTEST_OKTA_PASSWORD }}
+          TOKENDITO_OKTA_MFA: ${{ secrets.TOXTEST_MFA }}
+          TOKENDITO_OKTA_MFA_RESPONSE: ${{ secrets.TOXTEST_MFA_RESPONSE }}
+          TOKENDITO_OKTA_TILE: ${{ secrets.TOXTEST_OKTA_TILE }}
+          TOKENDITO_AWS_ROLE_ARN: ${{ secrets.TOXTEST_ROLE_ARN }}
       - name: Generate Coverage reports
         run: |
           tox -e coverage
diff --git a/tests/README.md b/tests/README.md
new file mode 100644
index 00000000..f2be0a4e
--- /dev/null
+++ b/tests/README.md
@@ -0,0 +1,59 @@
+---
+title: Testing
+---
+
+To run basic tests, execute:
+
+`py.test -v -rA -k 'unit' -s tests`. This will run unit tests, and skip
+functional (end to end) testing.
+
+To run end to end tests, use `py.test -v -rA -k 'functional' -s tests`
+instead. Several other arguments can be provided so that the tool can
+run in non-interactive mode. Currently, config file, arguments, and
+environment variables (mix and match) are supported. The syntax is the
+same as for `tokendito`.
+
+If all of username, password, mfa, tile url, and role ARN are passed to
+`py.test`, then two other tests are kicked off. The first will execute
+`tokendito` and try to obtain STS tokens the same way that a normal user
+would. The second will run `sts get-caller-identity` and validate the
+credentials.
+
+# Example 1
+
+``` sh
+py.test -v -rA -s tests --config-file=/tmp/my-tokendito-config.ini
+```
+
+Where the config file has valid configuration items for the tool.
+
+# Example 2
+
+``` sh
+py.test -v -rA -k 'functional' -s tests \
+  --username=jane.doe@mycompany.com \
+  --password=mysecretpass \
+  --okta-mfa=push \
+  --okta-tile='https://acme.oktapreview.com/home/amazon_aws/b07384d113edec49eaa6/123' \
+  --aws-role-arn=arn:aws:iam::123456789000:role/dowjones-hammer-engineer
+```
+
+This triggers the tests `test_generate_credentials` and
+`test_aws_credentials` that are normally skipped.
+
+# Example 3
+
+``` sh
+TOKENDITO_OKTA_MFA_METHOD=push py.test -v -rA -k 'functional' -s tests --username=...
+```
+
+This shows how to mix environment variables with `py.test` and
+arguments.
+
+# Example 4
+
+``` sh
+TOKENDITO_OKTA_PASSWORD='mysecretpass' tox -e auth -- --username='jane.doe@mycompany.com'
+```
+
+This shows how to pass credentials through Tox.
diff --git a/tests/README.rst b/tests/README.rst
deleted file mode 100644
index 49febcdb..00000000
--- a/tests/README.rst
+++ /dev/null
@@ -1,59 +0,0 @@
-=======
-Testing
-=======
-
-To run basic tests, execute:
-
-``py.test -v -rA -k 'unit' -s tests``. This will run unit tests, and skip functional (end to end)
-testing.
-
-To run end to end tests, use ``py.test -v -rA -k 'functional' -s tests`` instead. Several
-other arguments can be provided so that the tool can run in non-interactive mode. Currently,
-config file, arguments, and environment variables (mix and match) are supported. The syntax is
-the same as for ``tokendito``.
-
-If all of username, password, mfa method, app url, and role ARN are passed to ``py.test``, then
-two other tests are kicked off. The first will execute ``tokendito`` and try to obtain STS
-tokens the same way that a normal user would. The second will run ``sts get-caller-identity``
-and validate the credentials.
-
-Example 1
-----------
-.. code-block:: sh
-
-  py.test -v -rA -s tests --config-file=/tmp/my-tokendito-config.ini
-
-Where the config file has valid configuration items for the tool.
-
-Example 2
----------
-
-.. code-block:: sh
-
-  py.test -v -rA -k 'functional' -s tests \
-    --username=jane.doe@mycompany.com \
-    --password=mysecretpass \
-    --mfa-method=push \
-    --okta-aws-app-url='https://acme.oktapreview.com/home/amazon_aws/b07384d113edec49eaa6/123' \
-    --role-arn=arn:aws:iam::123456789000:role/dowjones-hammer-engineer
-
-This triggers the tests ``test_generate_credentials`` and ``test_aws_credentials`` that are
-normally skipped.
-
-Example 3
----------
-
-.. code-block:: sh
-
-  MFA_METHOD=push py.test -v -rA -k 'functional' -s tests --username=...
-
-This shows how to mix environment variables with ``py.test`` and arguments.
-
-Example 4
----------
-
-.. code-block:: sh
-
-   OKTA_PASSWORD='mysecretpass' tox -e auth -- --okta-username='jane.doe@mycompany.com'
-
-This shows how to pass credentials through Tox.
diff --git a/tests/conftest.py b/tests/conftest.py
index ac69b181..3360262e 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,25 +1,21 @@
 # vim: set filetype=python ts=4 sw=4
 # -*- coding: utf-8 -*-
 """pytest configuration, hooks, and global fixtures."""
-from __future__ import absolute_import, division, print_function, unicode_literals
-
-from future import standard_library
-
-standard_library.install_aliases()
 
 
 def pytest_addoption(parser):
     """Add command-line options for running functional tests with credentials."""
     parser.addoption("--username", default="", help="username to login to Okta")
     parser.addoption("--password", default="", help="password to login to Okta.")
-    parser.addoption("--okta-aws-app-url", default=None, help="Okta App URL to use.")
-    parser.addoption("--mfa-method", default=None, help="Sets the MFA method")
+    parser.addoption("--okta-tile", default=None, help="Okta tile URL to use.")
+    parser.addoption("--okta-mfa", default=None, help="Sets the MFA method")
     parser.addoption(
-        "--mfa-response", default=None, help="Sets the MFA response to a challenge"
+        "--okta-mfa-response", default=None, help="Sets the MFA response to a challenge"
     )
-    parser.addoption("--role-arn", default=None, help="Sets the IAM role")
+    parser.addoption("--aws-role-arn", default=None, help="Sets the IAM role")
     parser.addoption(
         "--config-file",
         default="/dev/null",
         help="Sets an optional config file to read from",
     )
+    parser.addoption("--aws-profile", default="pytest", help="Sets the AWS profile name")
diff --git a/tests/functional_test.py b/tests/functional_test.py
index aa21e7b4..13a83b78 100644
--- a/tests/functional_test.py
+++ b/tests/functional_test.py
@@ -1,42 +1,18 @@
 # vim: set filetype=python ts=4 sw=4
 # -*- coding: utf-8 -*-
 """Functional tests, and local fixtures."""
-from __future__ import absolute_import, division, print_function, unicode_literals
-
-from builtins import (  # noqa: F401
-    ascii,
-    bytes,
-    chr,
-    dict,
-    filter,
-    hex,
-    input,
-    int,
-    list,
-    map,
-    next,
-    object,
-    oct,
-    open,
-    pow,
-    range,
-    round,
-    str,
-    super,
-    zip,
-)
 import datetime
+import os
 from os import environ, path
 import re
 import subprocess
 import sys
 import time
 
-from future import standard_library
+
 import pytest
 
 sys.path.insert(0, path.dirname(path.dirname(path.abspath(__file__))))
-standard_library.install_aliases()
 
 
 def string_decode(bytestring):
@@ -53,7 +29,7 @@ def string_decode(bytestring):
     try:
         decoded_string = bytestring.decode("utf-8")
     except (NameError, TypeError):
-        # If a TypeError is raised, we are in Python 3, this is a no-op.
+        # If a TypeError is raised, this is a no-op.
         pass
 
     return decoded_string
@@ -79,14 +55,14 @@ def run_process(proc):
 @pytest.fixture
 def package_regex():
     """Get compiled package regex."""
-    version_regex = re.compile(r"^\S+/(?P<version>.*?)\s+.*$")
+    version_regex = re.compile(r"^\S+/(?P<version>\d.\d.\d)\D+")
     return version_regex
 
 
 @pytest.fixture
 def package_version():
     """Run test with access to the Tokendito package."""
-    from tokendito.__version__ import __version__ as tokendito_version
+    from tokendito import __version__ as tokendito_version
 
     return tokendito_version
 
@@ -97,11 +73,12 @@ def custom_args(request):
     options = [
         "--username",
         "--password",
-        "--okta-aws-app-url",
-        "--mfa-method",
-        "--mfa-response",
-        "--role-arn",
+        "--okta-tile",
+        "--okta-mfa",
+        "--okta-mfa-response",
+        "--aws-role-arn",
         "--config-file",
+        "--aws-profile",
     ]
     arg_list = []
     # pytest does not have a method for listing options, so we have look them up.
@@ -111,12 +88,17 @@ def custom_args(request):
     return arg_list
 
 
+@pytest.fixture(scope="session")
+def config_file(tmp_path_factory):
+    """Generate a path for a temporary ini file that multiple tests can share."""
+    path = tmp_path_factory.mktemp("pytest") / "pytest.ini"
+    return path
+
+
 @pytest.mark.run("first")
 def test_package_uninstall():
     """Uninstall tokendito if it is already installed."""
-    proc = run_process(
-        [sys.executable, "-m", "pip", "uninstall", "-q", "-q", "-y", "tokendito"]
-    )
+    proc = run_process([sys.executable, "-m", "pip", "uninstall", "-q", "-q", "-y", "tokendito"])
     assert not proc["stderr"]
     assert proc["exit_status"] == 0
 
@@ -147,97 +129,188 @@ def test_package_exists():
 )
 def test_version(package_version, package_regex, runnable):
     """Check if the package version is the same when running in different ways."""
+    local_version = None
     proc = run_process(runnable)
     assert not proc["stderr"]
     assert proc["exit_status"] == 0
     match = re.match(package_regex, proc["stdout"])
-    local_version = match.group("version")
+    if match:
+        local_version = match.group("version")
     assert package_version == local_version
 
 
-@pytest.mark.skipif(
-    sys.version_info[:2] < (3, 5),
-    reason="PyOTP Not supported in Python 2.x",
-)
+def test_parameter_collection(monkeypatch, tmpdir):
+    """Ensure that the order of arguments has the correct behavior."""
+    from argparse import Namespace
+    from tokendito import user, Config
+
+    config = Config()
+    data = "[default]\n"
+    data += "okta_username = pytest_ini\n"
+    data += "aws_region = pytest_ini\n"
+    data += "okta_org = pytest_ini\n"
+    path = tmpdir.mkdir("pytest").join("pytest.ini")
+    path.write(data)
+    config_ini = user.process_ini_file(path, "default")
+
+    env = {
+        "TOKENDITO_OKTA_ORG": "pytest_env",
+        "okta_tile": "https://acme.okta.org/_env",
+    }
+    monkeypatch.setattr(os, "environ", env)
+    config_env = user.process_environment()
+
+    args = {
+        "okta_username": "pytest_arg",
+        "okta_tile": "https://acme.okta.org/_arg",
+    }
+    config_arg = user.process_arguments(Namespace(**args))
+    config.update(config_ini)
+    config.update(config_env)
+    config.update(config_arg)
+    assert config.aws["region"] == "pytest_ini"
+    assert config.okta["org"] == "pytest_env"
+    assert config.okta["username"] == "pytest_arg"
+    assert config.okta["tile"] == "https://acme.okta.org/_arg"
+
+
+def test_quiet_failure():
+    """Ensure we exit without all the necessary arguments."""
+    args = ["--quiet"]
+    executable = [sys.executable, "-m", "tokendito"]
+    runnable = executable + args
+    proc = run_process(runnable)
+    assert proc["exit_status"] == 1
+    assert "Could not validate configuration to run in quiet mode" in proc["stderr"]
+
+
+def test_generate_config(custom_args, config_file):
+    """Test writing to a config file."""
+    from tokendito import user, Config
+
+    pytest_cfg = Config()
+    tool_args = user.parse_cli_args(custom_args)
+    config_arg = user.process_arguments(tool_args)
+    pytest_cfg.update(config_arg)
+
+    if (
+        pytest_cfg.okta["tile"] is None
+        or pytest_cfg.okta["mfa"] is None
+        or not pytest_cfg.okta["username"]
+    ):
+        pytest_cfg.okta["tile"] = "https://pytest/home/amazon_aws/0123456789abcdef0123/456"
+        pytest_cfg.okta["mfa"] = "push"
+        pytest_cfg.okta["username"] = "pytest"
+
+    # Rebuild argument list
+    args = [
+        "--configure",
+        "--config-file",
+        f"{config_file}",
+        "--okta-tile",
+        f"{pytest_cfg.okta['tile']}",
+        "--okta-mfa",
+        f"{pytest_cfg.okta['mfa']}",
+        "--username",
+        f"{pytest_cfg.okta['username']}",
+    ]
+    executable = [sys.executable, "-m", "tokendito"]
+    runnable = executable + args
+    proc = run_process(runnable)
+    assert proc["exit_status"] == 0
+
+
 @pytest.mark.run("second-to-last")
-def test_generate_credentials(custom_args):
+def test_generate_credentials(custom_args, config_file):
     """Run the tool and generate credentials."""
-    from tokendito import helpers, settings
+    from tokendito import user, config
     import pyotp
 
     # Emulate helpers.process_options() bypassing interactive portions.
-    tool_args = helpers.setup(custom_args)
-    helpers.process_ini_file(tool_args.config_file, "default")
-    helpers.process_arguments(tool_args)
-    helpers.process_environment()
+    tool_args = user.parse_cli_args(custom_args)
+    config_ini = user.process_ini_file(config_file, "default")
+    config_env = user.process_environment()
+    config_arg = user.process_arguments(tool_args)
+
+    config.update(config_ini)
+    config.update(config_env)
+    config.update(config_arg)
 
     if (
-        settings.role_arn is None
-        or settings.okta_aws_app_url is None
-        or settings.mfa_method is None
-        or not settings.okta_username
-        or not settings.okta_password
+        config.aws["role_arn"] is None
+        or config.okta["tile"] is None
+        or config.okta["mfa"] is None
+        or not config.okta["username"]
+        or not config.okta["password"]
     ):
         pytest.skip("Not enough arguments collected to execute non-interactively.")
 
     # If a token response is present and is not in the usual 6-digit format,
     # assume it is a MFA seed and create a valid response from it.
     if (
-        settings.mfa_response is not None
-        and re.match("[0-9]{6}", settings.mfa_response) is None
+        config.okta["mfa_response"] is not None
+        and re.match("[0-9]{6}", config.okta["mfa_response"]) is None
     ):
-        totp = pyotp.TOTP(settings.mfa_response, interval=30)
+        totp = pyotp.TOTP(config.okta["mfa_response"], interval=30)
         # If there are a few seconds left on the TOTP timer, wait until the next round.
-        time_remaining = (
-            totp.interval - datetime.datetime.now().timestamp() % totp.interval
-        )
+        time_remaining = totp.interval - datetime.datetime.now().timestamp() % totp.interval
         if time_remaining < 5:
             time.sleep(1 + time_remaining)
-        settings.mfa_response = totp.now()
+        config.okta["mfa_response"] = totp.now()
         # Update the environment variable that has been modified, if it exists
         # as this may be passed down to a subprocess.
-        if "MFA_RESPONSE" in environ:
-            environ["MFA_RESPONSE"] = settings.mfa_response
+        if "TOKENDITO_OKTA_MFA_RESPONSE" in environ:
+            environ["TOKENDITO_OKTA_MFA_RESPONSE"] = config.okta["mfa_response"]
 
     # Rebuild argument list
     args = [
-        "--role-arn",
-        "{}".format(settings.role_arn),
-        "--okta-aws-app-url",
-        "{}".format(settings.okta_aws_app_url),
-        "--mfa-method",
-        "{}".format(settings.mfa_method),
-        "--mfa-response",
-        "{}".format(settings.mfa_response),
+        "--aws-role-arn",
+        f"{config.aws['role_arn']}",
+        "--aws-profile",
+        f"{config.aws['profile']}",
+        "--okta-tile",
+        f"{config.okta['tile']}",
+        "--okta-mfa",
+        f"{config.okta['mfa']}",
+        "--okta-mfa-response",
+        f"{config.okta['mfa_response']}",
         "--username",
-        "{}".format(settings.okta_username),
+        f"{config.okta['username']}",
         "--password",
-        "{}".format(settings.okta_password),
+        f"{config.okta['password']}",
+        "--loglevel",
+        "DEBUG",
     ]
     # run as a local module, as we can't guarantee that the binary is installed.
     executable = [sys.executable, "-m", "tokendito"]
     runnable = executable + args
 
     proc = run_process(runnable)
-    assert not proc["stderr"]
+    assert "'password': '*****'" in proc["stderr"]
+    assert f"{config.okta['password']}" not in proc["stderr"]
+    assert f"{config.okta['mfa_response']}" not in proc["stderr"]
+    assert '"sessionToken": "*****"' in proc["stderr"]
     assert proc["exit_status"] == 0
 
 
 @pytest.mark.run("last")
 def test_aws_credentials(custom_args):
     """Run the AWS cli to verify whether credentials work."""
-    from tokendito import helpers, settings
+    from tokendito import user, config
 
     # Emulate helpers.process_options() bypassing interactive portions.
-    tool_args = helpers.setup(custom_args)
-    helpers.process_ini_file(tool_args.config_file, "default")
-    helpers.process_arguments(tool_args)
-    helpers.process_environment()
-
-    if not settings.role_arn:
+    tool_args = user.parse_cli_args(custom_args)
+    config_ini = user.process_ini_file(tool_args.user_config_file, "default")
+    config_env = user.process_environment()
+    config_arg = user.process_arguments(tool_args)
+    config.update(config_ini)
+    config.update(config_env)
+    config.update(config_arg)
+
+    if not config.aws["role_arn"]:
         pytest.skip("No AWS profile defined, test will be skipped.")
-    profile = settings.role_arn.split("/")[-1]
-    runnable = ["aws", "--profile", profile, "sts", "get-caller-identity"]
+
+    runnable = ["aws", "--profile", config.aws["profile"], "sts", "get-caller-identity"]
     proc = run_process(runnable)
     assert not proc["stderr"]
     assert proc["exit_status"] == 0
diff --git a/tests/okta_response_simulation.py b/tests/okta_response_simulation.py
index 6bbcf06c..c204f9db 100644
--- a/tests/okta_response_simulation.py
+++ b/tests/okta_response_simulation.py
@@ -7,12 +7,12 @@
     "_embedded": {
         "user": {
             "profile": {
-                "login": "Token.Dito@acme.org",
+                "login": "First.Last@acme.org",
             },
         },
         "factors": [
             {
-                "id": "opfrar9yi4bKJNH2WEWQ0x8",
+                "id": "opfrar9yi4bKJNH2WEW",
                 "factorType": "push",
                 "provider": "OKTA",
                 "profile": {"name": "Redmi 6 Pro"},
@@ -21,13 +21,25 @@
                 "id": "FfdskljfdsS1ljUT0r8",
                 "factorType": "token:software:totp",
                 "provider": "GOOGLE",
-                "profile": {"credentialId": "Token.Dito@acme.org"},
+                "profile": {"credentialId": "First.Last@acme.org"},
             },
             {
                 "id": "fdsfsd6ewREr8",
                 "factorType": "token:software:totp",
                 "provider": "OKTA",
-                "profile": {"credentialId": "Token.Dito@acme.org"},
+                "profile": {"credentialId": "First.Last@acme.org"},
+            },
+            {
+                "id": "fdsfsd6ewREr0",
+                "factorType": "pytest_dupe",
+                "provider": "GOOGLE",
+                "profile": {"credentialId": "First.Last@acme.org"},
+            },
+            {
+                "id": "fdsfsd6ewREr1",
+                "factorType": "pytest_dupe",
+                "provider": "OKTA",
+                "profile": {"credentialId": "First.Last@acme.org"},
             },
         ],
     },
diff --git a/tests/unit_test.py b/tests/unit_test.py
index f7927869..3148af1c 100644
--- a/tests/unit_test.py
+++ b/tests/unit_test.py
@@ -1,106 +1,17 @@
 # vim: set filetype=python ts=4 sw=4
 # -*- coding: utf-8 -*-
 """Unit tests, and local fixtures."""
-from __future__ import absolute_import, division, print_function, unicode_literals
-
-from builtins import (  # noqa: F401
-    ascii,
-    bytes,
-    chr,
-    dict,
-    filter,
-    hex,
-    input,
-    int,
-    list,
-    map,
-    next,
-    object,
-    oct,
-    open,
-    pow,
-    range,
-    round,
-    str,
-    super,
-    zip,
-)
 from datetime import datetime
 import os
 import sys
+from unittest.mock import Mock
 
-from future import standard_library
 import pytest
 import requests_mock
 import semver
-from tokendito.settings import okta_status_dict
 
 
 sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
-standard_library.install_aliases()
-
-
-@pytest.fixture
-def valid_settings():
-    """Return a dict with valid settings for the tokendito.settings module."""
-    from tokendito import settings
-
-    builtins_and_methods = [
-        "__builtins__",
-        "__cached__",
-        "__doc__",
-        "__file__",
-        "__loader__",
-        "__name__",
-        "__package__",
-        "__spec__",
-        "absolute_import",
-        "ascii",
-        "bytes",
-        "chr",
-        "dict",
-        "division",
-        "encoding",
-        "filter",
-        "hex",
-        "input",
-        "int",
-        "list",
-        "map",
-        "next",
-        "object",
-        "oct",
-        "open",
-        "pow",
-        "print_function",
-        "range",
-        "role_arn",
-        "round",
-        "standard_library",
-        "str",
-        "super",
-        "sys",
-        "unicode_literals",
-        "zip",
-    ]
-
-    settings_keys = dir(settings)
-    unmatched_keys = list(set(settings_keys) - set(builtins_and_methods))
-
-    valid_keys = {str(key): key + "_pytest_patched" for key in unmatched_keys}
-    return valid_keys
-
-
-@pytest.fixture
-def invalid_settings():
-    """Return a dict with invalid settings for the tokendito.settings module."""
-    invalid_keys = {
-        "okta": "okta_pytest_patched",
-        "okta_deadbeef": "okta_deadbeef_pytest_patched",
-        "aws_deadbeef": "aws_deadbeef_pytest_patched",
-        "pytest_bad_value": "pytest_bad_value_pytest_patched",
-    }
-    return invalid_keys
 
 
 @pytest.fixture
@@ -136,8 +47,7 @@ def test_import_location():
     import tokendito
 
     local_path = os.path.realpath(
-        os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
-        + "/tokendito/__init__.py"
+        os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + "/tokendito/__init__.py"
     )
     imported_path = os.path.realpath(tokendito.__file__)
     assert imported_path.startswith(local_path)
@@ -145,83 +55,161 @@ def test_import_location():
 
 def test_semver_version():
     """Ensure the package version is semver compliant."""
-    from tokendito.__version__ import __version__ as version
+    from tokendito import __version__ as version
 
     assert semver.VersionInfo.parse(version)
 
 
-def test__version__var_names():
-    """Ensure variables follow the __varname__ convention."""
-    from tokendito import __version__
-
-    for item in vars(__version__):
-        assert item.startswith("__")
-        assert item.endswith("__")
-
+def test_get_username(mocker):
+    """Test whether data sent is the same as data returned."""
+    from tokendito import user
 
-@pytest.mark.parametrize("string", [r"raw_string", "unicode_string", r"byte_string"])
-def test_to_unicode(string):
-    """Test whether to_unicode returns unicode strings."""
-    from tokendito import helpers
+    mocker.patch("tokendito.user.input", return_value="pytest_patched")
+    val = user.get_username()
 
-    new_str = helpers.to_unicode(string)
-    assert isinstance(new_str, str)
+    assert val == "pytest_patched"
 
 
-def test_set_okta_username(monkeypatch):
+def test_get_password(mocker):
     """Test whether data sent is the same as data returned."""
-    from tokendito import helpers, settings
+    from tokendito import user
 
-    monkeypatch.setattr("tokendito.helpers.input", lambda _: "pytest_patched")
-    val = helpers.set_okta_username()
+    mocker.patch("getpass.getpass", return_value="pytest_patched")
+    val = user.get_password()
 
     assert val == "pytest_patched"
-    assert settings.okta_username == "pytest_patched"
 
 
-def test_set_okta_password(monkeypatch):
-    """Test whether data sent is the same as data returned."""
-    from tokendito import helpers, settings
-    import getpass
+def test_setup_logging():
+    """Test logging setup."""
+    from tokendito import user
+    import logging
 
-    monkeypatch.setattr(getpass, "getpass", lambda: "pytest_patched")
-    val = helpers.set_okta_password()
+    # test that a default level is set on a bad level
+    ret = user.setup_logging({"loglevel": "pytest"})
+    assert ret == logging.INFO
 
-    assert val == "pytest_patched"
-    assert settings.okta_password == "pytest_patched"
+    # test that a correct level is set
+    ret = user.setup_logging({"loglevel": "debug"})
+    assert ret == logging.DEBUG
+
+
+def test_setup_early_logging(monkeypatch, tmpdir):
+    """Test early logging."""
+    from tokendito import user
+    from argparse import Namespace
+
+    path = tmpdir.mkdir("pytest")
+    logfile = f"{path}/pytest_log"
+    # test that known values are set correctly
+    args = {"user_loglevel": "debug", "user_log_output_file": logfile}
+    ret = user.setup_early_logging(Namespace(**args))
+    assert "loglevel" in ret
+    assert "log_output_file" in ret
+
+    # test that unknown bad values are ignored
+    args = {"pytest_bad": "pytest"}
+    ret = user.setup_early_logging(Namespace(**args))
+    assert "pytest_bad" not in ret
+
+    # test that known values are set correctly, and bad ones ignored
+    valid_keys = dict(
+        TOKENDITO_USER_LOGLEVEL="debug",
+        TOKENDITO_USER_LOG_OUTPUT_FILE=logfile,
+    )
+    invalid_keys = dict(TOKENDITO_USER_PYTEST_EXPECTED_FAILURE="pytest_expected_failure")
+
+    monkeypatch.setattr(os, "environ", {**valid_keys, **invalid_keys})
+    ret = user.setup_early_logging([])
+    assert "loglevel" in ret
+    assert "log_output_file" in ret
+    assert "TOKENDITO_USER_PYTEST_EXPECTED_FAILURE" not in ret
+
+
+def test_get_interactive_config(mocker):
+    """Test if interactive configuration is collected correctly."""
+    from tokendito import user
+
+    # test that all values return correctly
+    ret = user.get_interactive_config(
+        tile="https://pytest/pytest", org="https://pytest", username="pytest"
+    )
+    assert (
+        ret["okta_username"] == "pytest"
+        and ret["okta_org"] == "https://pytest"
+        and ret["okta_tile"] == "https://pytest/pytest"
+    )
+
+    # test that interactive values are handled correctly
+    mocker.patch("tokendito.user.get_org", return_value="https://pytest")
+    mocker.patch("tokendito.user.get_tile", return_value="https://pytest")
+    ret = user.get_interactive_config(tile=None, org=None, username="pytest")
+    assert ret["okta_username"] == "pytest" and ret["okta_org"] == "https://pytest"
+
+    # test that a username is collected
+    mocker.patch("tokendito.user.get_username", return_value="pytests")
+    ret = user.get_interactive_config(
+        tile="https://pytest/pytest", org="https://pytest/", username=""
+    )
+    assert ret["okta_username"] == "pytests"
+
+
+@pytest.mark.parametrize("value,expected", [("00", 0), ("01", 1), ("5", 5)])
+def test_collect_integer(mocker, value, expected):
+    """Test whether integers from the user are retrieved."""
+    from tokendito import user
+
+    mocker.patch("tokendito.user.input", return_value=value)
+    assert user.collect_integer(10) == expected
 
 
 @pytest.mark.parametrize(
     "url,expected",
     [
-        ("pytest_deadbeef", False),
-        ("http://acme.org/", False),
-        ("https://acme.okta.org/app/UserHome", False),
-        ("http://login.acme.org/home/amazon_aws/0123456789abcdef0123/456", False),
-        ("https://login.acme.org/home/amazon_aws/0123456789abcdef0123/456", True),
+        ("", ""),
+        ("https://acme.okta.org", "https://acme.okta.org"),
+        ("acme.okta.org", "https://acme.okta.org"),
+    ],
+)
+def test_get_org(mocker, url, expected):
+    """Test Org URL."""
+    from tokendito import user
+
+    mocker.patch("tokendito.user.input", return_value=url)
+    assert user.get_org() == expected
+
+
+@pytest.mark.parametrize(
+    "url,expected",
+    [
+        ("", ""),
         (
             "https://acme.okta.org/home/amazon_aws/0123456789abcdef0123/456?fromHome=true",
-            True,
+            "https://acme.okta.org/home/amazon_aws/0123456789abcdef0123/456?fromHome=true",
+        ),
+        (
+            "acme.okta.org/home/amazon_aws/0123456789abcdef0123/456?fromHome=true",
+            "https://acme.okta.org/home/amazon_aws/0123456789abcdef0123/456?fromHome=true",
         ),
     ],
 )
-def test_validate_okta_aws_app_url(url, expected):
-    """Test whether the Okta URL is parsed correctly."""
-    from tokendito import helpers
+def test_get_tile(mocker, url, expected):
+    """Test get tile URL."""
+    from tokendito import user
 
-    assert helpers.validate_okta_aws_app_url(input_url=url) is expected
+    mocker.patch("tokendito.user.input", return_value=url)
+    assert user.get_tile() == expected
 
 
 @pytest.mark.parametrize(
     "test,limit,expected",
     [(0, 10, True), (5, 10, True), (10, 10, False), (-1, 10, False), (1, 0, False)],
 )
-def test_check_within_range(mocker, test, limit, expected):
+def test_check_within_range(test, limit, expected):
     """Test whether a given number is in the range 0 >= num < limit."""
-    from tokendito import helpers
+    from tokendito import user
 
-    mocker.patch("logging.error")
-    assert helpers.check_within_range(test, limit) is expected
+    assert user.check_within_range(test, limit) is expected
 
 
 @pytest.mark.parametrize(
@@ -240,160 +228,308 @@ def test_check_within_range(mocker, test, limit, expected):
         (None, False),
     ],
 )
-def test_check_integer(value, expected, mocker):
+def test_check_integer(value, expected):
     """Test whether the integer testing function works within boundaries."""
-    from tokendito import helpers
+    from tokendito import user
 
-    mocker.patch("logging.error")
-    assert helpers.check_integer(value) is expected
+    assert user.check_integer(value) is expected
 
 
 @pytest.mark.parametrize(
     "test,limit,expected", [(1, 10, True), (-1, 10, False), ("pytest", 10, False)]
 )
-def test_validate_input(mocker, test, limit, expected):
+def test_validate_input(test, limit, expected):
     """Check if a given input is within the 0 >= num < limit range."""
-    from tokendito import helpers
+    from tokendito import user
 
-    mocker.patch("logging.error")
-    assert helpers.validate_input(test, limit) is expected
+    assert user.validate_input(test, limit) is expected
 
 
-def test_get_input(monkeypatch):
+def test_get_input(mocker):
     """Check if provided input is return unmodified."""
-    from tokendito import helpers
+    from tokendito import user
 
-    monkeypatch.setattr("tokendito.helpers.input", lambda _: "pytest_patched")
-    assert helpers.get_input() == "pytest_patched"
+    mocker.patch("tokendito.user.input", return_value="pytest_patched")
+    assert user.get_input() == "pytest_patched"
 
 
-@pytest.mark.parametrize("value,expected", [("00", 0), ("01", 1), ("5", 5)])
-def test_collect_integer(monkeypatch, value, expected):
-    """Check if a given digit or series of digits are properly casted to int."""
-    from tokendito import helpers
+def test_update_ini(tmpdir):
+    """Ensure ini files are updated correctly."""
+    from tokendito import user
 
-    monkeypatch.setattr("tokendito.helpers.input", lambda _: value)
-    assert helpers.collect_integer(10) == expected
+    path = tmpdir.mkdir("pytest")
+    ini_file = f"{path}/update_ini"
+    profile = "pytest"
+    args = {"key_pytest_1": "val_pytest_1", "key_pytest_2": "val_pytest_2"}
+    ret = user.update_ini(profile=profile, ini_file=ini_file, **args)
+    assert ret.get(profile, "key_pytest_1") == "val_pytest_1"
+    assert ret.get(profile, "key_pytest_2") == "val_pytest_2"
 
 
-def test_utc_to_local():
-    """Check if passed utc datestamp becomes local one."""
-    import pytz
-    from tokendito import helpers
-    from tzlocal import get_localzone
+def test_validate_saml_response():
+    """Test for failures on SAML response."""
+    from tokendito import user
 
-    utc = datetime.now(pytz.utc)
-    local_time = utc.replace(tzinfo=pytz.utc).astimezone(tz=get_localzone())
-    local_time = local_time.strftime("%Y-%m-%d %H:%M:%S %Z")
-
-    assert helpers.utc_to_local(utc) == local_time
+    with pytest.raises(SystemExit) as err:
+        user.validate_saml_response("")
+    assert err.value.code == 1
 
 
-def test_prepare_payload():
-    """Check if values passed return in a dictionary."""
-    from tokendito import helpers
+def test_assert_credentials():
+    """Test whether getting credentials works as expeted."""
+    from moto import mock_sts
+    from tokendito import aws
 
-    assert helpers.prepare_payload(pytest_key="pytest_val") == {
-        "pytest_key": "pytest_val"
+    with pytest.raises(SystemExit) as err:
+        aws.assert_credentials({})
+    assert err.value.code == 1
+
+    saml_response = {
+        "Credentials": {
+            "AccessKeyId": "pytest",
+            "SecretAccessKey": "pytest",
+            "SessionToken": "pytest",
+        }
     }
-    assert helpers.prepare_payload(pytest_key=None) == {"pytest_key": None}
-    assert helpers.prepare_payload(
-        pytest_key1="pytest_val1", pytest_key2="pytest_val2"
-    ) == {
-        "pytest_key1": "pytest_val1",
-        "pytest_key2": "pytest_val2",
+    with mock_sts():
+        ret = aws.assert_credentials(role_response=saml_response)
+        assert "Arn" in ret and "UserId" in ret
+
+
+def test_set_local_credentials(tmpdir):
+    """Test setting credentials."""
+    from tokendito import user
+
+    # evaluate that we exit on a bad role
+    with pytest.raises(SystemExit) as err:
+        user.set_local_credentials(response={}, role="pytest", region="pytest", output="pytest")
+    assert err.value.code == 1
+
+    # evaluate that we succeed on a working role
+    response = {
+        "Credentials": {
+            "AccessKeyId": "pytest",
+            "SecretAccessKey": "pytest",
+            "SessionToken": "pytest",
+        }
     }
 
+    path = tmpdir.mkdir("pytest")
+    user.config.aws["shared_credentials_file"] = f"{path}/pytest_credentials"
+    user.config.aws["config_file"] = f"{path}/pytest_config"
+    ret = user.set_local_credentials(response=response, role="default")
+    assert ret == "default"
 
-def test_set_passcode(monkeypatch):
-    """Check if numerical passcode can handle leading zero values."""
-    from tokendito import duo_helpers
 
-    monkeypatch.setattr("tokendito.helpers.input", lambda _: "0123456")
-    assert duo_helpers.set_passcode({"factor": "passcode"}) == "0123456"
+def test_add_sensitive_value_to_be_masked():
+    """Test adding some values only adds whatas expected."""
+    from tokendito import user
 
+    user.add_sensitive_value_to_be_masked("should be added")
+    user.add_sensitive_value_to_be_masked("should be added2")
+    user.add_sensitive_value_to_be_masked("should be added3", "password")
+    user.add_sensitive_value_to_be_masked("should not be added", "public")
+    assert (
+        "should be added" in user.mask_items
+        and "should be added2" in user.mask_items
+        and "should be added3" in user.mask_items
+        and len(user.mask_items) == 3
+    )
 
-def test_process_environment(monkeypatch, valid_settings, invalid_settings):
-    """Test whether environment variables are set in settings.*."""
-    from tokendito import helpers, settings
 
-    # ENV standard is uppercase
-    valid_keys = {key.upper(): val for (key, val) in valid_settings.items()}
-    invalid_keys = {key.upper(): val for (key, val) in invalid_settings.items()}
+def test_logger_mask(caplog):
+    """Test that masking data in loggger works as expected."""
+    from tokendito import user
+    import logging
 
-    # Python 2.7 does not support {**dict1, **dict2} for concatenation
-    env_keys = valid_keys.copy()
-    env_keys.update(invalid_keys)
+    logger = logging.getLogger(__name__)
+    logger.addFilter(user.MaskLoggerSecret())
+    user.add_sensitive_value_to_be_masked("supersecret")
+    user.add_sensitive_value_to_be_masked("another secret", "sessionToken")
+    with caplog.at_level(logging.DEBUG):
+        logger.debug("This should be displayed, but not: supersecret")
+        logger.debug("another secret")
+    assert (
+        "supersecret" not in caplog.text
+        and "another secret" not in caplog.text
+        and "This should be displayed" in caplog.text
+    )
 
-    monkeypatch.setattr(os, "environ", env_keys)
-    helpers.process_environment()
 
-    for key in valid_settings:
-        assert getattr(settings, key) == valid_settings[key]
+def test_display_selected_role():
+    """Test that role is printed correctly."""
+    from datetime import timezone
+    from tokendito import user
 
-    for key in invalid_settings:
-        assert getattr(settings, key, "not_found") == "not_found"
+    now = datetime.now()
+    utcnow = now.replace(tzinfo=timezone.utc)
 
+    ret = user.display_selected_role("pytest", {"Credentials": {"Expiration": utcnow}})
+    assert ret is not None and "pytest" in ret
 
-def test_process_arguments(valid_settings, invalid_settings):
-    """Test whether arguments are correctly set in settings.*."""
-    from tokendito import helpers, settings
-    from argparse import Namespace
+    with pytest.raises(SystemExit) as err:
+        ret = user.display_selected_role("pytest", {"pytest": {}})
+    assert err.value.code == 1
 
-    # Python 2.7 does not support {**dict1, **dict2} for concatenation
-    args = valid_settings.copy()
-    args.update(invalid_settings)
+    assert ret is not None and "pytest" in ret
 
-    helpers.process_arguments(Namespace(**args))
 
-    for key_name in valid_settings:
-        assert getattr(settings, key_name) == valid_settings[key_name]
+@pytest.mark.parametrize(
+    "url,expected",
+    [
+        ("http://acme.org/", False),
+        ("https://acme.okta.org/app/UserHome", False),
+        ("http://login.acme.org/home/amazon_aws/0123456789abcdef0123/456", False),
+        ("https://login.acme.org/?abc=def", False),
+        ("acme.okta.org", False),
+        ("https://acme.okta.org/", True),
+    ],
+)
+def test_validate_org(url, expected):
+    """Test whether the Okta Org URL is parsed correctly."""
+    from tokendito import user
 
-    for key_name in invalid_settings:
-        assert getattr(settings, key_name, "not_found") == "not_found"
+    assert user.validate_okta_org(input_url=url) is expected
 
 
-@pytest.mark.skipif(
-    sys.version_info[:2] == (3, 5),
-    reason="ConfigParser bug, see https://bugs.python.org/issue29623",
+@pytest.mark.parametrize(
+    "url,expected",
+    [
+        ("pytest_deadbeef", False),
+        ("http://acme.org/", False),
+        ("https://acme.okta.org/app/UserHome", False),
+        ("http://login.acme.org/home/amazon_aws/0123456789abcdef0123/456", False),
+        ("https://login.acme.org/home/amazon_aws/0123456789abcdef0123/456", True),
+        (
+            "https://acme.okta.org/home/amazon_aws/0123456789abcdef0123/456?fromHome=true",
+            True,
+        ),
+    ],
 )
-def test_process_ini_file(tmpdir, valid_settings, invalid_settings, mocker):
-    """Test whether ini config elements are correctly set in settings.*."""
-    from tokendito import helpers, settings
-
-    # Create a mock config file
-    data = "[default]\nokta_username = pytest\n\n[pytest]\n"
-    data += "".join("{} = {}\n".format(key, val) for key, val in valid_settings.items())
-    data += "".join(
-        "{} = {}\n".format(key, val) for key, val in invalid_settings.items()
+def test_validate_tile(url, expected):
+    """Test whether the Okta tile URL is parsed correctly."""
+    from tokendito import user
+
+    assert user.validate_okta_tile(input_url=url) is expected
+
+
+def test_utc_to_local():
+    """Check if passed utc datestamp becomes local one."""
+    from tokendito import user
+    from datetime import timezone
+
+    utc = datetime.utcnow()
+    local_time = utc.replace(tzinfo=timezone.utc).astimezone(tz=None)
+    local_time = local_time.strftime("%Y-%m-%d %H:%M:%S %Z")
+
+    assert user.utc_to_local(utc) == local_time
+
+    with pytest.raises(SystemExit) as err:
+        user.utc_to_local("pytest")
+    assert err.value.code == 1
+
+
+def test_set_passcode(mocker):
+    """Check if numerical passcode can handle leading zero values."""
+    from tokendito import duo
+
+    mocker.patch("tokendito.user.input", return_value="0123456")
+    assert duo.set_passcode({"factor": "passcode"}) == "0123456"
+
+
+def test_process_environment(monkeypatch):
+    """Test whether environment variables are interpreted correctly."""
+    from tokendito import user
+
+    valid_keys = dict(
+        TOKENDITO_USER_CONFIG_PROFILE="pytest",
+        TOKENDITO_OKTA_USERNAME="pytest",
+        TOKENDITO_AWS_PROFILE="pytest",
+    )
+    invalid_keys = dict(TOKENDITO_USER_PYTEST_EXPECTED_FAILURE="pytest_expected_failure")
+
+    monkeypatch.setattr(os, "environ", valid_keys)
+    ret = user.process_environment()
+    assert (ret.okta["username"] == "pytest") is True
+
+    with pytest.raises(SystemExit) as err:
+        monkeypatch.setattr(os, "environ", invalid_keys)
+        ret = user.process_environment()
+        assert err.value.code == 2
+
+
+def test_process_arguments():
+    """Test whether arguments are set correctly."""
+    from tokendito import user
+    from argparse import Namespace
+
+    valid_settings = dict(okta_username="pytest", okta_password="pytest_password")
+    invalid_settings = dict(pytest_expected_failure="pytest_failure")
+    args = {**valid_settings, **invalid_settings}
+    ret = user.process_arguments(Namespace(**args))
+
+    # Make sure that the arguments we passed are interpreted
+    assert ret.okta["username"] == "pytest"
+    assert ret.okta["password"] == "pytest_password"
+    # Make sure that incorrect arguments are not passed down to the Config object.
+    assert "pytest" not in ret.__dict__
+
+
+def test_update_configuration(tmpdir):
+    """Test writing and reading to a configuration file."""
+    from tokendito import user, Config
+
+    path = tmpdir.mkdir("pytest").join("pytest_tokendito.ini")
+    pytest_config = Config(
+        okta={
+            "username": "pytest",
+            "tile": "https://acme.okta.org/home/amazon_aws/0123456789abcdef0123/456",
+            "org": "https://acme.okta.org/",
+            "mfa": "pytest",
+        },
+        user={"config_file": path, "config_profile": "pytest"},
     )
-    data += "\n[pytest_end]\n"
-    data += "".join(
-        "{} = {}\n".format(key, val) for key, val in invalid_settings.items()
+
+    # Write out a config file via configure() and ensure it's functional
+    user.update_configuration(pytest_config)
+    ret = user.process_ini_file(path, "pytest")
+    assert ret.okta["username"] == "pytest"
+    assert ret.okta["tile"] == "https://acme.okta.org/home/amazon_aws/0123456789abcdef0123/456"
+    assert ret.okta["org"] == "https://acme.okta.org/"
+    assert ret.okta["mfa"] == "pytest"
+
+
+def test_process_ini_file(tmpdir):
+    """Test whether ini config elements are set correctly.
+
+    All this testing is in the same function as they share an ini file.
+    """
+    from tokendito import user
+
+    valid_settings = dict(
+        okta_password="pytest_password",
+        okta_username="pytest",
     )
+    invalid_settings = dict(user_pytest_expected_failure="pytest")
 
-    # Python 3.7 supports patching builtins.open(), which gives us the ability
-    # to bypass file creation with:
-    # mocker.patch('builtins.open', mocker.mock_open(read_data=data), create=True)
-    # There is no (easy) way to achieve the same on earlier versions, so we create
-    # an actual file instead. tmpdir keeps the last 3 files/dirs behind for inspection
+    # Write out a config file and esure it's functional
     path = tmpdir.mkdir("pytest").join("pytest_tokendito.ini")
-    path.write(data)
+    user.update_ini("pytest", path, **valid_settings)
+    ret = user.process_ini_file(path, "pytest")
+    assert ret.okta["username"] == "pytest"
+    assert ret.okta["password"] == "pytest_password"
 
     # Ensure we fail if the section is not found
+    user.update_ini("pytest", path, **valid_settings)
     with pytest.raises(SystemExit) as err:
-        mocker.patch("logging.error")
-        helpers.process_ini_file(path, "pytest_expected_failure")
-        # assert err.type == SystemExit
-        assert err.value.code == 2
+        user.process_ini_file(path, "pytest_expected_failure")
+    assert err.value.code == 2
 
-    helpers.process_ini_file(path, "pytest")
-    # Test that correct options are set
-    for key_name in valid_settings:
-        assert getattr(settings, key_name) == valid_settings[key_name]
-    # Test that incorrect options aren't set
-    for key_name in invalid_settings:
-        assert getattr(settings, key_name, "not_found") == "not_found"
+    # Ensure we fail if there's a bad element
+    user.update_ini("pytest", path, **invalid_settings)
+    with pytest.raises(SystemExit) as err:
+        user.process_ini_file(path, "pytest")
+    assert err.value.code == 1
 
 
 @pytest.mark.parametrize(
@@ -414,21 +550,21 @@ def test_user_session_token(
     mfa_availability,
 ):
     """Test whether function return key on specific status."""
-    from tokendito.okta_helpers import user_session_token
+    from tokendito.okta import user_session_token
 
     primary_auth = sample_json_response[mfa_availability]
 
-    mocker.patch(
-        "tokendito.okta_helpers.user_mfa_challenge", return_value=session_token
-    )
+    mocker.patch("tokendito.okta.user_mfa_challenge", return_value=session_token)
     assert user_session_token(primary_auth, sample_headers) == expected
+    with pytest.raises(SystemExit) as err:
+        assert user_session_token(None, sample_headers) == err
 
 
-def test_bad_user_session_token(sample_json_response, sample_headers, mocker):
+def test_bad_user_session_token(mocker, sample_json_response, sample_headers):
     """Test whether function behave accordingly."""
-    from tokendito.okta_helpers import user_session_token
+    from tokendito.okta import user_session_token
 
-    mocker.patch("tokendito.okta_helpers.login_error_code_parser", return_value=None)
+    mocker.patch("tokendito.okta.api_error_code_parser", return_value=None)
     okta_response_statuses = ["okta_response_error", "okta_response_empty"]
 
     for response in okta_response_statuses:
@@ -451,7 +587,7 @@ def test_mfa_provider_type(
     sample_headers,
 ):
     """Test whether function return key on specific MFA provider."""
-    from tokendito.okta_helpers import mfa_provider_type
+    from tokendito.okta import mfa_provider_type
 
     payload = {"x": "y", "t": "z"}
     callback_url = "https://www.acme.org"
@@ -462,13 +598,12 @@ def test_mfa_provider_type(
 
     mfa_verify = {"sessionToken": session_token}
     mocker.patch(
-        "tokendito.duo_helpers.authenticate_duo",
+        "tokendito.duo.authenticate_duo",
         return_value=(payload, sample_headers, callback_url),
     )
-    mocker.patch(
-        "tokendito.okta_helpers.okta_verify_api_method", return_value=mfa_verify
-    )
-    mocker.patch("tokendito.okta_helpers.user_mfa_options", return_value=mfa_verify)
+    mocker.patch("tokendito.okta.api_wrapper", return_value=mfa_verify)
+    mocker.patch("tokendito.okta.user_mfa_options", return_value=mfa_verify)
+    mocker.patch("tokendito.duo.duo_api_post")
     assert (
         mfa_provider_type(
             mfa_provider,
@@ -479,13 +614,13 @@ def test_mfa_provider_type(
             sample_headers,
             payload,
         )
-        == session_token
+        == expected
     )
 
 
 def test_bad_mfa_provider_type(mocker, sample_headers):
     """Test whether function return key on specific MFA provider."""
-    from tokendito.okta_helpers import mfa_provider_type
+    from tokendito.okta import mfa_provider_type
 
     payload = {"x": "y", "t": "z"}
     callback_url = "https://www.acme.org"
@@ -494,16 +629,14 @@ def test_bad_mfa_provider_type(mocker, sample_headers):
     primary_auth = 1
     selected_factor = 1
 
-    mfa_verify = {"sessionToken": "123"}
+    mfa_verify = {"sessionToken": "pytest_session_token"}
     mfa_bad_provider = "bad_provider"
     mocker.patch(
-        "tokendito.duo_helpers.authenticate_duo",
+        "tokendito.duo.authenticate_duo",
         return_value=(payload, sample_headers, callback_url),
     )
-    mocker.patch(
-        "tokendito.okta_helpers.okta_verify_api_method", return_value=mfa_verify
-    )
-    mocker.patch("tokendito.okta_helpers.user_mfa_options", return_value=mfa_verify)
+    mocker.patch("tokendito.okta.api_wrapper", return_value=mfa_verify)
+    mocker.patch("tokendito.okta.user_mfa_options", return_value=mfa_verify)
 
     with pytest.raises(SystemExit) as error:
         assert (
@@ -520,42 +653,48 @@ def test_bad_mfa_provider_type(mocker, sample_headers):
         )
 
 
-def test_okta_verify_api_method():
+def test_api_wrapper():
     """Test whether verify_api_method returns the correct data."""
-    from tokendito.okta_helpers import okta_verify_api_method
+    from tokendito.okta import api_wrapper
 
     url = "https://acme.org"
     with requests_mock.Mocker() as m:
         data = {"response": "ok"}
         m.post(url, json=data, status_code=200)
-        assert okta_verify_api_method(url, data) == data
+        assert api_wrapper(url, data) == data
+
+    with pytest.raises(SystemExit) as error, requests_mock.Mocker() as m:
+        data = None
+        m.post(url, json=data, status_code=200)
+        assert api_wrapper(url, data) == error
+
+    with pytest.raises(SystemExit) as error, requests_mock.Mocker() as m:
+        data = {"response": "ok", "errorCode": "0xdeadbeef"}
+        m.post(url, json=data, status_code=200)
+        assert api_wrapper(url, data) == error
 
     with pytest.raises(SystemExit) as error, requests_mock.Mocker() as m:
         data = "pytest_bad_datatype"
         m.post(url, text=data, status_code=403)
-        assert okta_verify_api_method(url, data) == error
+        assert api_wrapper(url, data) == error
 
     with pytest.raises(SystemExit) as error, requests_mock.Mocker() as m:
         data = {"response": "incorrect", "errorCode": "0xdeadbeef"}
         m.post(url, json=data, status_code=403)
-        assert okta_verify_api_method("http://acme.org", data) == error
+        assert api_wrapper("http://acme.org", data) == error
 
 
-def test_login_error_code_parser(mocker):
+def test_api_error_code_parser():
     """Test whether message on specific status equal."""
-    from tokendito.okta_helpers import login_error_code_parser
+    from tokendito.okta import api_error_code_parser, _status_dict
 
-    mocker.patch("logging.error")
-    for key, value in okta_status_dict.items():
-        assert (
-            login_error_code_parser(key, okta_status_dict)
-            == "Okta auth failed: " + value
-        )
+    okta_status_dict = _status_dict
+
+    for (key, value) in okta_status_dict.items():
+        assert api_error_code_parser(key) == "Okta auth failed: " + value
     unexpected_key = "UNEXPECTED_KEY"
-    value = "Okta auth failed: {}. Please verify your settings and try again.".format(
-        unexpected_key
-    )
-    assert login_error_code_parser(unexpected_key, okta_status_dict) == value
+    value = f"Okta auth failed: {unexpected_key}. Please verify your settings and try again."
+    assert api_error_code_parser(unexpected_key) == value
 
 
 @pytest.mark.parametrize(
@@ -579,7 +718,7 @@ def test_login_error_code_parser(mocker):
 )
 def test_mfa_option_info(factor_type, output):
     """Test whether the function returns the correct answer to a specific input."""
-    from tokendito.helpers import mfa_option_info
+    from tokendito.user import mfa_option_info
 
     mfa_option = {
         "factorType": factor_type,
@@ -596,62 +735,101 @@ def test_mfa_option_info(factor_type, output):
     assert mfa_option_info(mfa_option) == output
 
 
-@pytest.mark.parametrize("preset_mfa, output", [("push", 0), (None, 1)])
+@pytest.mark.parametrize(
+    "preset_mfa, output",
+    [("push", 0), (None, 1), ("0xdeadbeef", 1), ("opfrar9yi4bKJNH2WEW", 0), ("pytest_dupe", 1)],
+)
 def test_user_mfa_index(preset_mfa, output, mocker, sample_json_response):
-    """Test whether the function returns correct mfa method index."""
-    from tokendito.okta_helpers import user_mfa_index
+    """Test whether the function returns correct mfa index."""
+    from tokendito.okta import user_mfa_index
 
     primary_auth = sample_json_response["okta_response_mfa"]
 
     mfa_options = primary_auth["_embedded"]["factors"]
-    available_mfas = [d["factorType"] for d in mfa_options]
-    mocker.patch("tokendito.helpers.select_preferred_mfa_index", return_value=1)
+    available_mfas = [f"{d['provider']}_{d['factorType']}_{d['id']}" for d in mfa_options]
+    mocker.patch("tokendito.user.select_preferred_mfa_index", return_value=1)
 
-    assert user_mfa_index(preset_mfa, available_mfas, mfa_options) == output
+    if preset_mfa == "pytest_dupe":
+        with pytest.raises(SystemExit) as err:
+            user_mfa_index(preset_mfa, available_mfas, mfa_options)
+        assert err.value.code == output
+    else:
+        assert user_mfa_index(preset_mfa, available_mfas, mfa_options) == output
 
 
 def test_select_preferred_mfa_index(mocker, sample_json_response):
     """Test whether the function returns index entered by user."""
-    from tokendito.helpers import select_preferred_mfa_index
+    from tokendito.user import select_preferred_mfa_index
 
     primary_auth = sample_json_response
     mfa_options = primary_auth["okta_response_mfa"]["_embedded"]["factors"]
     for output in mfa_options:
-        mocker.patch("tokendito.helpers.collect_integer", return_value=output)
+        mocker.patch("tokendito.user.collect_integer", return_value=output)
         assert select_preferred_mfa_index(mfa_options) == output
 
 
 @pytest.mark.parametrize(
     "email",
     [
-        ("Token.Dito@acme.org"),
+        ("First.Last@acme.org"),
     ],
 )
 def test_select_preferred_mfa_index_output(email, capsys, mocker, sample_json_response):
     """Test whether the function gives correct output."""
-    from tokendito.helpers import select_preferred_mfa_index
+    from tokendito.user import select_preferred_mfa_index
+    from tokendito import config
 
+    # For this test, ensure that quiet is never true
+    config.user["quiet"] = False
     primary_auth = sample_json_response
     mfa_options = primary_auth["okta_response_mfa"]["_embedded"]["factors"]
 
     correct_output = (
         "\nSelect your preferred MFA method and press Enter:\n"
-        "[0]  OKTA    push                Redmi 6 Pro         Id: opfrar9yi4bKJNH2WEWQ0x8\n"
-        "[1]  GOOGLE  token:software:totp {0} Id: FfdskljfdsS1ljUT0r8\n"
-        "[2]  OKTA    token:software:totp {0} Id: fdsfsd6ewREr8\n".format(email)
+        "[0]  OKTA    push                Redmi 6 Pro         Id: opfrar9yi4bKJNH2WEW\n"
+        f"[1]  GOOGLE  token:software:totp {email} Id: FfdskljfdsS1ljUT0r8\n"
+        f"[2]  OKTA    token:software:totp {email} Id: fdsfsd6ewREr8\n"
+        f"[3]  GOOGLE  pytest_dupe         Not Presented       Id: fdsfsd6ewREr0\n"
+        f"[4]  OKTA    pytest_dupe         Not Presented       Id: fdsfsd6ewREr1\n"
     )
 
-    mocker.patch("tokendito.helpers.collect_integer", return_value=1)
+    mocker.patch("tokendito.user.collect_integer", return_value=1)
     select_preferred_mfa_index(mfa_options)
     captured = capsys.readouterr()
     assert captured.out == correct_output
 
 
-def test_bad_with_no_mfa_methods_user_mfa_challenge(
-    sample_headers, sample_json_response
-):
-    """Test whether okta response has mfa methods."""
-    from tokendito.okta_helpers import user_mfa_challenge
+def test_user_mfa_options(sample_headers, sample_json_response, mocker):
+    """Test handling of mfa options."""
+    from tokendito.okta import user_mfa_options
+
+    selected_mfa_option = {"factorType": "push"}
+    primary_auth = sample_json_response["okta_response_no_mfa"]
+    payload = {"x": "y", "t": "z"}
+    mfa_challenge_url = "https://pytest"
+
+    # Test for push_approval returning the correct value
+    mocker.patch("tokendito.okta.push_approval", return_value=primary_auth)
+    ret = user_mfa_options(
+        selected_mfa_option, sample_headers, mfa_challenge_url, payload, primary_auth
+    )
+    assert ret == primary_auth
+
+    # Test that selecting software token returns a session token
+    selected_mfa_option = {"factorType": "token:software:totp"}
+    primary_auth["stateToken"] = "pytest"
+    mfa_verify = {"sessionToken": "pytest"}
+    mocker.patch("tokendito.user.get_input", return_value="012345")
+    mocker.patch("tokendito.okta.api_wrapper", return_value=mfa_verify)
+    ret = user_mfa_options(
+        selected_mfa_option, sample_headers, mfa_challenge_url, payload, primary_auth
+    )
+    assert ret == mfa_verify
+
+
+def test_user_mfa_challenge_with_no_mfas(sample_headers, sample_json_response):
+    """Test whether okta response has mfas."""
+    from tokendito.okta import user_mfa_challenge
 
     primary_auth = sample_json_response["okta_response_no_auth_methods"]
 
@@ -660,76 +838,482 @@ def test_bad_with_no_mfa_methods_user_mfa_challenge(
 
 
 @pytest.mark.parametrize(
-    "aws_profile, role_arn, selected_role",
+    "wrapper_return,expected",
     [
-        ("token", None, "arn:aws:iam::123456789012:role/token"),
-        (
-            "dito",
-            None,
-            "arn:aws:iam::124356789012:role/dito",
-        ),
-        (
-            None,
-            "arn:aws:iam::124356789012:role/dito",
-            "arn:aws:iam::124356789012:role/dito",
-        ),
-        (
-            None,
-            "arn:aws:iam::123456789012:role/token",
-            "arn:aws:iam::123456789012:role/token",
-        ),
+        ({"factorResult": "REJECTED", "status": "FAILED"}, 2),
+        ({"factorResult": "TIMEOUT", "status": "FAILED"}, 2),
+        ({"status": "FAILED"}, 1),
+        ({}, 1),
+        ({"factorResult": "SUCCESS", "status": "SUCCESS"}, 0),
     ],
 )
-def test_good_select_role_arn(
-    mocker, monkeypatch, aws_profile, role_arn, selected_role
-):
-    """Test which role does the user has chosen."""
-    from tokendito.helpers import select_role_arn
+def test_push_approval(mocker, sample_headers, wrapper_return, expected):
+    """Test push approval."""
+    from tokendito import okta
+
+    challenge_url = "https://pytest/api/v1/authn/factors/factorid/verify"
+
+    mocker.patch("tokendito.okta.api_wrapper", return_value=wrapper_return)
 
-    saml_xml = "x"
-    saml_response_string = "y"
+    if "status" in wrapper_return and wrapper_return["status"] == "SUCCESS":
+        ret = okta.push_approval(sample_headers, challenge_url, None)
+        assert ret["status"] == "SUCCESS" and ret["factorResult"] == "SUCCESS"
+    else:
+        with pytest.raises(SystemExit) as err:
+            okta.push_approval(sample_headers, challenge_url, None)
+        assert err.value.code == expected
+
+
+@pytest.mark.parametrize(
+    "selected_role",
+    [
+        "arn:aws:iam::123456789012:role/pytest_role_1",
+        "arn:aws:iam::124356789012:role/pytest_role_2",
+    ],
+)
+def test_correct_role_selection(mocker, selected_role):
+    """Test which role does the user has chosen."""
+    from tokendito.user import select_role_arn
 
     role_arns = [
-        "arn:aws:iam::123456789012:role/token",
-        "arn:aws:iam::124356789012:role/dito",
+        "arn:aws:iam::123456789012:role/pytest",
+        "arn:aws:iam::124356789012:role/pytest",
     ]
-    monkeypatch.setattr("tokendito.settings.aws_profile", aws_profile)
-    mocker.patch("tokendito.helpers.prompt_role_choices", return_value=selected_role)
-    assert select_role_arn(role_arns, saml_xml, saml_response_string) == selected_role
 
+    authenticated_tiles = {"url": {"roles": role_arns}}
 
-def test_repeated_line_select_role_arn(monkeypatch):
-    """Test behaviour repeated role."""
-    from tokendito.helpers import select_role_arn
+    mocker.patch("tokendito.user.prompt_role_choices", return_value=selected_role)
+    assert select_role_arn(authenticated_tiles) == selected_role
 
-    saml_xml = "x"
-    saml_response_string = "y"
+
+def test_repeated_line_select_role_arn():
+    """Ensure that duplicate roles trigger an error."""
+    import tokendito
+    from tokendito.user import select_role_arn
+
+    tokendito.config.aws["profile"] = "pytest"
 
     role_arns = [
-        "arn:aws:iam::123456789012:role/token",
-        "arn:aws:iam::123456789012:role/token",
+        "arn:aws:iam::123456789012:role/pytest",
+        "arn:aws:iam::123456789012:role/pytest",
     ]
-    monkeypatch.setattr("tokendito.settings.aws_profile", "token")
+
+    authenticated_tiles = {"url": {"roles": role_arns}}
 
     with pytest.raises(SystemExit) as error:
-        assert select_role_arn(role_arns, saml_xml, saml_response_string) == error
+        assert select_role_arn(authenticated_tiles) == error
 
 
-def test_bad_select_role_arn(monkeypatch):
-    """Test behaviour wrong aws_profile and role_arn."""
-    from tokendito.helpers import select_role_arn
+def test_incorrect_role_arn():
+    """Ensure that incorrectly selected options trigger an error."""
+    import tokendito
+    from tokendito.user import select_role_arn
 
-    saml_xml = "x"
-    saml_response_string = "y"
+    tokendito.config.aws["profile"] = "pytest_failure"
+    tokendito.config.aws["role_arn"] = "pytest_failure"
 
     role_arns = [
-        "arn:aws:iam::123456789012:role/token",
-        "arn:aws:iam::124356789012:role/dito",
+        "arn:aws:iam::123456789012:role/pytest",
+        "arn:aws:iam::124356789012:role/pytest",
     ]
-    monkeypatch.setattr("tokendito.settings.aws_profile", "wrong_response")
-    monkeypatch.setattr(
-        "tokendito.settings.role_arn",
-        "arn:aws:iam::123456789012:role/wrong_response",
-    )
+
+    authenticated_tiles = {"url": {"roles": role_arns}}
+
+    with pytest.raises(SystemExit) as error:
+        assert select_role_arn(authenticated_tiles) == error
+
+
+def test_prepare_duo_info():
+    """Test behaviour empty return duo info."""
+    from tokendito.duo import prepare_duo_info
+    from tokendito import config
+
+    selected_okta_factor = {
+        "_embedded": {
+            "factor": {
+                "_embedded": {
+                    "verification": {
+                        "_links": {
+                            "complete": {"href": "http://test.okta.href"},
+                            "script": {"href": "python-v3.7"},
+                        },
+                        "signature": "fdsafdsa:fdsfdfds:fdsfdsfds",
+                        "host": "test_host",
+                    }
+                },
+                "id": 1234,
+            }
+        },
+        "stateToken": 12345,
+    }
+    okta_factor = selected_okta_factor["_embedded"]["factor"]["_embedded"]["verification"]
+    expected_duo_info = {
+        "okta_factor": okta_factor,
+        "factor_id": 1234,
+        "state_token": 12345,
+        "okta_callback_url": "http://test.okta.href",
+        "tx": "fdsafdsa",
+        "tile_sig": "fdsfdfds",
+        "parent": f"{config.okta['org']}/signin/verify/duo/web",
+        "host": "test_host",
+        "sid": "",
+        "version": "3.7",
+    }
+    assert prepare_duo_info(selected_okta_factor) == expected_duo_info
+
+
+def test_get_duo_sid(mocker):
+    """Check if got sid correct."""
+    from tokendito import config
+    from tokendito.duo import get_duo_sid
+
+    test_duo_info = {
+        "okta_factor": "okta_factor",
+        "factor_id": 1234,
+        "state_token": 12345,
+        "okta_callback_url": "http://test.okta.href",
+        "tx": "fdsafdsa",
+        "tile_sig": "fdsfdfds",
+        "parent": f"{config.okta['org']}/signin/verify/duo/web",
+        "host": "test_host",
+        "sid": "",
+        "version": "3.7",
+    }
+
+    test_url = "http://test.token.dito?sid=testval"
+    duo_api_response = Mock()
+    duo_api_response.url = test_url
+
+    mocker.patch("tokendito.duo.duo_api_post", return_value=duo_api_response)
+
+    duo_sid_info, duo_auth_response = get_duo_sid(test_duo_info)
+
+    assert duo_sid_info["sid"] == "testval"
+    assert duo_auth_response.url == test_url
+
+
+@pytest.mark.parametrize("status_code", [(400), (401), (404), (500), (503)])
+def test_authenticate_to_roles(status_code, monkeypatch):
+    """Test if function return correct response."""
+    from tokendito.aws import authenticate_to_roles
+    import requests
+
+    mock_get = {"status_code": status_code, "text": "response"}
+    monkeypatch.setattr(requests, "get", mock_get)
     with pytest.raises(SystemExit) as error:
-        assert select_role_arn(role_arns, saml_xml, saml_response_string) == error
+        assert authenticate_to_roles("secret_session_token", [("http://test.url.com", "")]) == error
+
+
+def test_get_mfa_response():
+    """Test if mfa verify correctly."""
+    from tokendito.duo import get_mfa_response
+
+    mfa_result = Mock()
+    mfa_result.json = Mock(return_value={"response": "test_response"})
+
+    assert get_mfa_response(mfa_result) == "test_response"
+
+
+def test_config_object():
+    """Test proper initialization of the Config object."""
+    import json
+    from tokendito import Config
+
+    # Test for invalid assignments to the object
+    with pytest.raises(AttributeError):
+        pytest_config = Config(pytest_attribute={})
+
+    with pytest.raises(KeyError):
+        pytest_config = Config(aws="pytest")
+
+    with pytest.raises(ValueError):
+        pytest_config = Config(aws={"pytest": "pytest"})
+
+    # Test whether repr can be reused to create an object
+    pytest_config = Config()
+    args = json.loads(repr(pytest_config))
+    pytest_config_2 = Config(**args)
+    assert (pytest_config == pytest_config_2) is True
+
+    # Test if passing arguments results in an object with new values
+    pytest_config_aws = Config(aws={"profile": "pytest_aws"})
+    pytest_config_okta = Config(okta={"username": "pytest_username"})
+    pytest_config_mixed = Config(
+        user={"config_profile": "pytest_user"}, okta={"password": "pytest_password"}
+    )
+    assert (pytest_config == pytest_config_aws) is False
+
+    # Check that an update copies the values correctly
+    pytest_config.update(pytest_config_aws)
+    assert pytest_config.aws["profile"] == "pytest_aws"
+
+    # Check that an update does not overwrite all values
+    pytest_config.update(pytest_config_okta)
+    assert pytest_config.aws["profile"] == "pytest_aws"
+
+    # Check that an update overwrites matching values only
+    pytest_config.update(pytest_config_mixed)
+    assert pytest_config.okta["username"] == "pytest_username"
+    assert pytest_config.okta["password"] == "pytest_password"
+    assert pytest_config.user["config_profile"] == "pytest_user"
+
+    # Check that default values from the original object are kept
+    assert pytest_config.get_defaults()["aws"]["region"] == pytest_config.aws["region"]
+
+
+def test_loglevel_collected_from_env(monkeypatch):
+    """Ensure that the loglevel collected from env vars."""
+    from argparse import Namespace
+    import logging
+    from tokendito import user
+
+    args = {
+        "okta_username": "pytest_arg",
+        "okta_tile": "https://acme.okta.org/_arg",
+        "version": None,
+        "configure": False,
+        "user_config_file": None,
+        "user_config_profile": None,
+    }
+
+    monkeypatch.setenv("TOKENDITO_USER_LOGLEVEL", "DEBUG")
+    monkeypatch.setattr(user, "parse_cli_args", lambda *x: Namespace(**args))
+    ret = user.setup_early_logging(args)["loglevel"]
+    val = logging.getLevelName(ret)
+
+    assert val == logging.DEBUG
+
+
+def test_create_directory(tmpdir):
+    """Test dir creation."""
+    from tokendito import user
+
+    path = tmpdir.mkdir("pytest")
+    testdir = f"{path}/pytest"
+
+    ret = user.create_directory(testdir)
+    assert ret is None
+
+    with pytest.raises(SystemExit) as err:
+        user.create_directory(__file__)
+        assert err.value.code == 1
+
+
+def test_get_submodules_names(mocker):
+    """Test whether submodules are retrieves correctly."""
+    from tokendito import user
+
+    ret = user.get_submodule_names()
+    assert "__main__" in ret
+
+
+def test_process_interactive_input(mocker):
+    """Test interactive input processor."""
+    from tokendito import user, Config
+
+    # Check that a good object retrieves an interactive password
+    mocker.patch("getpass.getpass", return_value="pytest_password")
+
+    pytest_config = Config()
+    pytest_config.okta["tile"] = "https://pytest/tile"
+    pytest_config.okta["org"] = "https://pytest/"
+    pytest_config.okta["username"] = "pytest"
+    ret = user.process_interactive_input(pytest_config)
+    pytest_config.update(ret)
+    assert pytest_config.okta["password"] == "pytest_password"
+
+    # Check that quiet mode does not retrieve a username
+    pytest_config.user["quiet"] = True
+    pytest_config.okta["username"] = ""
+    ret = user.process_interactive_input(pytest_config)
+    pytest_config.update(ret)
+    assert pytest_config.okta["username"] == ""
+
+    # Check that a bad object raises an exception
+    with pytest.raises(AttributeError) as error:
+        assert user.process_interactive_input({"pytest": "pytest"}) == error
+
+
+@pytest.mark.parametrize(
+    "value,submit,expected",
+    [
+        ("pytest", None, "pytest"),
+        ("pytest", "deadbeef", "pytest"),
+        ("pytest", 0xDEADBEEF, "pytest"),
+        (None, None, "default"),
+        (None, "", "default"),
+        (None, 0xDEADBEEF, str(0xDEADBEEF)),
+    ],
+)
+def test_set_role_name(value, submit, expected):
+    """Test setting the AWS Role (profile) name."""
+    from tokendito import user, Config
+
+    pytest_config = Config(aws=dict(profile=value))
+
+    ret = user.set_role_name(pytest_config, submit)
+    assert ret.aws["profile"] == expected
+
+
+@pytest.mark.parametrize(
+    "config,expected",
+    [
+        (
+            {"okta": {"username": "", "password": "", "org": None, "tile": None}},
+            [
+                "Username not set",
+                "Password not set",
+                "Either Okta Org or tile URL must be defined",
+            ],
+        ),
+        (
+            {
+                "okta": {
+                    "username": "pytest",
+                    "password": "pytest",
+                    "org": "https://acme.okta.org",
+                    "tile": None,
+                }
+            },
+            [],
+        ),
+        (
+            {
+                "okta": {
+                    "username": "pytest",
+                    "password": "pytest",
+                    "org": "https://acme.okta.org",
+                    "tile": "https://badurl_pytest.org",
+                }
+            },
+            [
+                "Tile URL https://badurl_pytest.org is not valid",
+                "Org URL https://acme.okta.org and Tile URL "
+                "https://badurl_pytest.org must be in the same domain",
+            ],
+        ),
+        (
+            {
+                "okta": {
+                    "username": "pytest",
+                    "password": "pytest",
+                    "org": "https://acme.okta.org",
+                    "tile": "https://acme.okta.org/home/amazon_aws/"
+                    "0123456789abcdef0123/456?fromHome=true",
+                }
+            },
+            [],
+        ),
+        (
+            {
+                "okta": {
+                    "username": "pytest",
+                    "password": "pytest",
+                    "org": "https://acme.okta.com/",
+                    "tile": "https://acme.okta.org/home/amazon_aws/"
+                    "0123456789abcdef0123/456?fromHome=true",
+                }
+            },
+            [
+                "Org URL https://acme.okta.com/ and Tile URL "
+                "https://acme.okta.org/home/amazon_aws/"
+                "0123456789abcdef0123/456?fromHome=true must be in the same domain"
+            ],
+        ),
+        (
+            {
+                "okta": {
+                    "username": "pytest",
+                    "password": "pytest",
+                    "org": "pytest_deadbeef",
+                    "tile": None,
+                }
+            },
+            ["Org URL pytest_deadbeef is not valid"],
+        ),
+        (
+            {
+                "okta": {
+                    "username": "pytest",
+                    "password": "pytest",
+                    "org": "https://acme.okta.org",
+                    "tile": None,
+                },
+                "user": {"quiet": False},
+            },
+            [],
+        ),
+        (
+            {
+                "user": {"quiet": True},
+                "okta": {
+                    "username": "pytest",
+                    "password": "pytest",
+                    "org": "https://acme.okta.org",
+                    "tile": None,
+                    "mfa": "push",
+                    "mfa_response": None,
+                },
+                "aws": {
+                    "role_arn": None,
+                },
+            },
+            ["AWS role ARN not set"],
+        ),
+        (
+            {
+                "user": {"quiet": True},
+                "okta": {
+                    "username": "pytest",
+                    "password": "pytest",
+                    "org": "https://acme.okta.org",
+                    "tile": None,
+                    "mfa": None,
+                    "mfa_response": None,
+                },
+                "aws": {
+                    "role_arn": "arn:aws:iam::123456789000:role/test-role",
+                },
+            },
+            ["MFA Method not set", "MFA Response not set"],
+        ),
+    ],
+)
+def test_validate_configuration(config, expected):
+    """Test configuration validator."""
+    from tokendito import user, Config
+
+    pytest_config = Config(**config)
+    assert user.validate_configuration(pytest_config) == expected
+
+
+def test_sanitize_config_values():
+    """Test configuration sanitizer method."""
+    from tokendito import user, Config
+
+    pytest_config = Config(
+        aws=dict(output="pytest", region="pytest"),
+        okta=dict(tile="https://pytest_org", org="https://pytest_bar/"),
+    )
+    ret = user.sanitize_config_values(pytest_config)
+    assert ret.aws["region"] == pytest_config.get_defaults()["aws"]["region"]
+    assert ret.aws["output"] == pytest_config.get_defaults()["aws"]["output"]
+    assert ret.okta["tile"].startswith(ret.okta["org"])
+
+
+def test_get_regions():
+    """Test retrieval of available AWS regions."""
+    from tokendito import aws
+
+    ret = aws.get_regions(profile="pytest")
+    assert ret == []
+    ret = aws.get_regions()
+    assert "us-east-1" in ret
+
+
+def test_get_output_types():
+    """Test getting AWS output types."""
+    from tokendito import aws
+
+    ret = aws.get_output_types()
+    assert "json" in ret