diff --git a/MANIFEST.in b/MANIFEST.in index b0b0248..84296b8 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,5 @@ include CONTRIBUTING.rst include CHANGELOG.rst include LICENSE -include README.rst \ No newline at end of file +include PLUGINS.rst +include README.rst diff --git a/PLUGINS.rst b/PLUGINS.rst new file mode 100644 index 0000000..a56db2c --- /dev/null +++ b/PLUGINS.rst @@ -0,0 +1,50 @@ +======================================== +AWS Process Credential Providers Plug-in +======================================== + +.. image:: https://travis-ci.org/awslabs/awsprocesscreds.svg?branch=master + :target: https://travis-ci.org/awslabs/awsprocesscreds + + +This document covers what is is to be a SAML provider plug-in. + +Generally, a plug-in refers to any class registered to the entry point group +'saml_form_authenticators' and which also conforms to the SAMLAuthenticator +interface. See Requirements for more constraints. + +Example: + + entry_points={ + 'saml_form_authenticators': [ + 'example = plugin.example:ExampleFormsBasedAuthenticator', + } + +For reference, the file setup.py in this project registers both default +providers as plug-ins. + + +General Plug-in Overview +------------------------ + +At runtime, all registered plug-in names retrieved using pkg_resources will be +matched against the user supplied value for -p (--provider). An exact match +will instatiate that class, no match will throw an unspupported error. + +Inheritance from SAMLAuthenticator is not required. + + +Requirements +------------ + +Generally this assume an installed module. + +* Plug-in has an entry point registered under group 'saml_form_authenticators' +* Class implements the awsprocesscreds.saml:SAMLAuthenticator specification + + +Futher Information +------------------ + +Both of the SAML authenticators shipped with the product utilize the plug-in +loading process. If you are looking at how to implement one to support your +own business requirements then it is suggested to review both those classes. diff --git a/README.rst b/README.rst index 1af65d6..d0add5c 100644 --- a/README.rst +++ b/README.rst @@ -41,8 +41,8 @@ arguments: * ``-e / --endpoint`` - Your SAML idp endpoint. * ``-u / --username`` - Your SAML username. -* ``-p / --provider`` - The name of your SAML provider. Currently okta and - adfs are supported. +* ``-p / --provider`` - The name of your SAML provider plug-in. Default + support includes 'okta' and 'adfs', both form-based auth. * ``-a / --role-arn``- The role arn you wish to assume. Your SAML provider must be configured to give you access to this arn. @@ -73,6 +73,15 @@ Example adfs configuration:: .. _AWS CLI Config docs: http://docs.aws.amazon.com/cli/latest/topic/config-vars.html#cli-aws-help-config-vars +Plug-in Support +--------------- + +In cases where you have your own SAML requirements, there is a provided plug-in +architecture to supplement the the default okta and adfs implementations. + +See the PLUGINS documentation in the root of this project. + + Custom Providers ---------------- diff --git a/awsprocesscreds/cli.py b/awsprocesscreds/cli.py index ca6c98d..41e9874 100644 --- a/awsprocesscreds/cli.py +++ b/awsprocesscreds/cli.py @@ -26,10 +26,11 @@ def saml(argv=None, prompter=getpass.getpass, client_creator=None, help='Your SAML username.' ) parser.add_argument( - '-p', '--provider', required=True, choices=['okta', 'adfs'], + '-p', '--provider', required=True, help=( - 'The name of your SAML provider. Currently okta and adfs ' - 'form-based auth is supported.' + 'The name of your SAML provider plug-in. Default ' + 'support includes \'okta\' and \'adfs\', both ' + 'form-based auth. ' ) ) parser.add_argument( diff --git a/awsprocesscreds/saml.py b/awsprocesscreds/saml.py index 16ed432..32483a1 100644 --- a/awsprocesscreds/saml.py +++ b/awsprocesscreds/saml.py @@ -8,6 +8,7 @@ import six import requests +import pkg_resources import botocore from botocore.client import Config from botocore.compat import urlsplit @@ -306,12 +307,6 @@ def error(self, message): class SAMLCredentialFetcher(CachedCredentialFetcher): - SAML_FORM_AUTHENTICATORS = { - 'okta': OktaAuthenticator, - 'adfs': ADFSFormsBasedAuthenticator - - } - def __init__(self, client_creator, provider_name, saml_config, role_selector=_role_selector, password_prompter=getpass.getpass, cache=None, @@ -321,7 +316,8 @@ def __init__(self, client_creator, provider_name, saml_config, self._role_selector = role_selector self._config = saml_config self._provider_name = provider_name - authenticator_cls = self.SAML_FORM_AUTHENTICATORS.get(provider_name) + authenticators = self.get_form_authenticators() + authenticator_cls = authenticators.get(provider_name) if authenticator_cls is None: raise ValueError('Unsupported SAML provider: %s' % provider_name) self._authenticator = authenticator_cls(password_prompter) @@ -334,6 +330,14 @@ def __init__(self, client_creator, provider_name, saml_config, self._stored_cache_key = None self._expiry_window_seconds = expiry_window_seconds + def get_form_authenticators(self): + authenticators = {} + for entry_point in pkg_resources.iter_entry_points( + 'saml_form_authenticators'): + authenticators[ + entry_point.name] = entry_point.load() + return authenticators + @property def _cache_key(self): if self._stored_cache_key is None: diff --git a/setup.py b/setup.py index 1720358..448749e 100644 --- a/setup.py +++ b/setup.py @@ -40,8 +40,10 @@ def find_version(*file_paths): keywords='aws credentials', entry_points={ 'console_scripts': [ - 'awsprocesscreds-saml = awsprocesscreds.cli:saml' - ] + 'awsprocesscreds-saml = awsprocesscreds.cli:saml'], + 'saml_form_authenticators': [ + 'adfs = awsprocesscreds.saml:ADFSFormsBasedAuthenticator', + 'okta = awsprocesscreds.saml:OktaAuthenticator'] }, classifiers=( 'Development Status :: 2 - Pre-Alpha', diff --git a/tests/functional/test_saml.py b/tests/functional/test_saml.py index dcaf836..e8a9920 100644 --- a/tests/functional/test_saml.py +++ b/tests/functional/test_saml.py @@ -3,13 +3,14 @@ import logging import xml.dom.minidom import base64 - +import pkg_resources import requests import pytest from tests import create_assertion from awsprocesscreds.cli import saml, PrettyPrinterLogHandler from awsprocesscreds.saml import SAMLCredentialFetcher +from awsprocesscreds.saml import OktaAuthenticator, ADFSFormsBasedAuthenticator @pytest.fixture @@ -22,8 +23,27 @@ def argv(): ] +@pytest.fixture +def mock_pkg_resources(monkeypatch): + class EntryPoint: + def __init__(self, name, load): + self.name = name + self._load = load + + def load(self): + return self._load + + def stub_iter(*args, **kwargs): + return [ + EntryPoint('okta', OktaAuthenticator), + EntryPoint('adfs', ADFSFormsBasedAuthenticator), + ] + + monkeypatch.setattr(pkg_resources, "iter_entry_points", stub_iter) + + def test_cli(mock_requests_session, argv, prompter, assertion, client_creator, - capsys, cache_dir): + capsys, cache_dir, mock_pkg_resources): session_token = {'sessionToken': 'spam'} token_response = mock.Mock( spec=requests.Response, status_code=200, text=json.dumps(session_token) @@ -54,7 +74,7 @@ def test_cli(mock_requests_session, argv, prompter, assertion, client_creator, def test_no_cache(mock_requests_session, argv, prompter, assertion, - client_creator, capsys, cache_dir): + client_creator, capsys, cache_dir, mock_pkg_resources): session_token = {'sessionToken': 'spam'} token_response = mock.Mock( spec=requests.Response, status_code=200, text=json.dumps(session_token) @@ -91,7 +111,7 @@ def test_no_cache(mock_requests_session, argv, prompter, assertion, def test_verbose(mock_requests_session, argv, prompter, assertion, - client_creator, cache_dir): + client_creator, cache_dir, mock_pkg_resources): session_token = {'sessionToken': 'spam'} token_response = mock.Mock( spec=requests.Response, status_code=200, text=json.dumps(session_token) @@ -122,7 +142,8 @@ def test_verbose(mock_requests_session, argv, prompter, assertion, def test_log_handler_parses_assertion(mock_requests_session, argv, prompter, - client_creator, cache_dir, caplog): + client_creator, cache_dir, caplog, + mock_pkg_resources): session_token = {'sessionToken': 'spam'} token_response = mock.Mock( spec=requests.Response, status_code=200, text=json.dumps(session_token) @@ -159,7 +180,8 @@ def test_log_handler_parses_assertion(mock_requests_session, argv, prompter, def test_log_handler_parses_dict(mock_requests_session, argv, prompter, - client_creator, cache_dir, caplog): + client_creator, cache_dir, caplog, + mock_pkg_resources): session_token = {'sessionToken': 'spam'} token_response = mock.Mock( spec=requests.Response, status_code=200, text=json.dumps(session_token) @@ -202,7 +224,8 @@ def test_log_handler_parses_dict(mock_requests_session, argv, prompter, assert expected_log in caplog.record_tuples -def test_unsupported_saml_auth_type(client_creator, prompter): +def test_unsupported_saml_auth_type(client_creator, prompter, + mock_pkg_resources): invalid_config = { 'saml_authentication_type': 'unsupported', 'saml_provider': 'okta', @@ -235,8 +258,9 @@ def test_unsupported_saml_provider(client_creator, prompter): ) -def test_prompter_only_called_once(client_creator, prompter, assertion, - mock_requests_session): +def test_prompter_only_called_once(client_creator, prompter, + assertion, mock_requests_session, + mock_pkg_resources): session_token = {'sessionToken': 'spam'} token_response = mock.Mock( spec=requests.Response, status_code=200, text=json.dumps(session_token) @@ -257,6 +281,7 @@ def test_prompter_only_called_once(client_creator, prompter, assertion, 'saml_username': 'monty', 'role_arn': 'arn:aws:iam::123456789012:role/monty' } + fetcher = SAMLCredentialFetcher( client_creator=client_creator, saml_config=config, diff --git a/tests/unit/test_saml.py b/tests/unit/test_saml.py index db2e218..32ef312 100644 --- a/tests/unit/test_saml.py +++ b/tests/unit/test_saml.py @@ -7,6 +7,7 @@ from dateutil.tz import tzlocal import pytest import requests +import pkg_resources from awsprocesscreds.saml import ADFSFormsBasedAuthenticator from awsprocesscreds.saml import FormParser @@ -23,6 +24,20 @@ def mock_requests_session(): return mock.Mock(spec=requests.Session) +@pytest.fixture +def mock_fake_pkg_resources(monkeypatch, mock_authenticator): + provider_name = 'myprovider' + authenticator_cls = mock.Mock(return_value=mock_authenticator) + + def stub_iter(*args, **kwargs): + return [type('', (object,), + {'name': provider_name, + 'load': lambda self: authenticator_cls})()] + + monkeypatch.setattr(pkg_resources, "iter_entry_points", + stub_iter) + + @pytest.fixture def generic_auth(prompter, mock_requests_session): return GenericFormsBasedAuthenticator(prompter, mock_requests_session) @@ -89,16 +104,11 @@ def cache(): @pytest.fixture def fetcher(generic_config, client_creator, prompter, mock_authenticator, - cache): - provider_name = 'myprovider' - authenticator_cls = mock.Mock(return_value=mock_authenticator) + cache, mock_fake_pkg_resources): - class MockSAMLFetcher(SAMLCredentialFetcher): - SAML_FORM_AUTHENTICATORS = { - provider_name: authenticator_cls - } + provider_name = 'myprovider' - saml_fetcher = MockSAMLFetcher( + saml_fetcher = SAMLCredentialFetcher( client_creator=client_creator, provider_name=provider_name, saml_config=generic_config,