From 52704e4e2e4c93cb70a3deecd2986126c9c4ecbb Mon Sep 17 00:00:00 2001 From: Brett Cannon Date: Mon, 24 Oct 2016 12:01:19 -0700 Subject: [PATCH 1/5] Add adal as a dependency --- requirements.txt | 4 +++- setup.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 366535d..15be43b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,3 @@ -msrest>=0.4.4,<0.5.0 +msrest>=0.4.4 +adal>=0.4.0 +keyring>=5.6 diff --git a/setup.py b/setup.py index 57a5bf5..8304807 100644 --- a/setup.py +++ b/setup.py @@ -49,5 +49,7 @@ 'License :: OSI Approved :: MIT License', 'Topic :: Software Development'], install_requires=[ - "msrest>=0.4.4"], + "msrest>=0.4.4", + "adal>=0.4.0", + "keyring>=5.6"], ) From bb137799a1d10f2ee11c5a66967f55eb4c31f64b Mon Sep 17 00:00:00 2001 From: Brett Cannon Date: Mon, 24 Oct 2016 12:01:32 -0700 Subject: [PATCH 2/5] Add adal-based auth for username/password --- msrestazure/azure_active_directory.py | 55 +++++++++++++++++++++- test/unittest_auth.py | 68 +++++++++++++++++++++++---- 2 files changed, 112 insertions(+), 11 deletions(-) diff --git a/msrestazure/azure_active_directory.py b/msrestazure/azure_active_directory.py index cda6808..5acc537 100644 --- a/msrestazure/azure_active_directory.py +++ b/msrestazure/azure_active_directory.py @@ -28,10 +28,11 @@ import re import time try: - from urlparse import urlparse, parse_qs + from urlparse import urljoin, urlparse, parse_qs except ImportError: - from urllib.parse import urlparse, parse_qs + from urllib.parse import urljoin, urlparse, parse_qs +import adal import keyring from oauthlib.oauth2 import BackendApplicationClient, LegacyApplicationClient from oauthlib.oauth2.rfc6749.errors import ( @@ -42,6 +43,7 @@ from requests import RequestException import requests_oauthlib as oauth +import msrest.authentication from msrest.authentication import OAuthTokenAuthentication from msrest.exceptions import TokenExpiredError as Expired from msrest.exceptions import ( @@ -525,3 +527,52 @@ def set_token(self, response_url): raise_with_traceback(AuthenticationError, "", err) else: self.token = token + + +# Constants related to AAD-based authentication methods. +_TOKEN_ENTRY_TOKEN_TYPE = 'tokenType' +_ACCESS_TOKEN = 'accessToken' + + +class AdalAuthentication(msrest.authentication.Authentication): + + """Base class for adal-derived authentication.""" + + def __init__(self, client_id="04b07795-8ddb-461a-bbee-02f9e1bf7b46", + tenant="common", + auth_endpoint="https://login.microsoftonline.com", + resource="https://management.core.windows.net/"): + """Handle details common to adal.""" + super(AdalAuthentication, self).__init__() + self.client_id = client_id # Default value is xplat client ID. + self.authority = urljoin(auth_endpoint, tenant) + self.resource = resource + + # @abc.abstractmethod if Python 2 wasn't supported. + def acquire_token(self, context): + """Override with code that returns an adal.acquire_*() call.""" + raise NotImplementedError + + def signed_session(self): + """Return a signed session.""" + session = super(AdalAuthentication, self).signed_session() + context = adal.AuthenticationContext(self.authority) + token_entry = self.acquire_token(context) + header = " ".join([token_entry[_TOKEN_ENTRY_TOKEN_TYPE], + token_entry[_ACCESS_TOKEN]]) + session.headers['Authorization'] = header + return session + + +class AdalUserPassCredentials(AdalAuthentication): + + """Authenticate with AAD using a username and password.""" + + def __init__(self, username, password, **kwargs): + super(AdalUserPassCredentials, self).__init__(**kwargs) + self.username = username + self.password = password + + def acquire_token(self, context): + return context.acquire_token_with_username_password( + self.resource, self.username, self.password, self.client_id) diff --git a/test/unittest_auth.py b/test/unittest_auth.py index 363b4b8..7128fbe 100644 --- a/test/unittest_auth.py +++ b/test/unittest_auth.py @@ -1,6 +1,6 @@ #-------------------------------------------------------------------------- # -# Copyright (c) Microsoft Corporation. All rights reserved. +# Copyright (c) Microsoft Corporation. All rights reserved. # # The MIT License (MIT) # @@ -31,6 +31,10 @@ from unittest import mock except ImportError: import mock +try: + from urlparse import urljoin +except ImportError: + from urllib.parse import urljoin from requests_oauthlib import OAuth2Session import oauthlib @@ -54,7 +58,7 @@ class TestInteractiveCredentials(unittest.TestCase): def setUp(self): self.cfg = AzureConfiguration("https://my_service.com") return super(TestInteractiveCredentials, self).setUp() - + def test_http(self): test_uri = "http://my_service.com" @@ -282,8 +286,8 @@ def test_service_principal(self): session = mock.create_autospec(OAuth2Session) with mock.patch.object( ServicePrincipalCredentials, '_setup_session', return_value=session): - - creds = ServicePrincipalCredentials("client_id", "secret", + + creds = ServicePrincipalCredentials("client_id", "secret", verify=False, tenant="private") session.fetch_token.assert_called_with( @@ -294,7 +298,7 @@ def test_service_principal(self): with mock.patch.object( ServicePrincipalCredentials, '_setup_session', return_value=session): - + creds = ServicePrincipalCredentials("client_id", "secret", china=True, verify=False, tenant="private") @@ -336,8 +340,8 @@ def test_user_pass_credentials(self): session = mock.create_autospec(OAuth2Session) with mock.patch.object( UserPassCredentials, '_setup_session', return_value=session): - - creds = UserPassCredentials("my_username", "my_password", + + creds = UserPassCredentials("my_username", "my_password", verify=False, tenant="private", resource='resource') session.fetch_token.assert_called_with( @@ -347,7 +351,7 @@ def test_user_pass_credentials(self): with mock.patch.object( UserPassCredentials, '_setup_session', return_value=session): - + creds = UserPassCredentials("my_username", "my_password", client_id="client_id", verify=False, tenant="private", china=True) @@ -357,5 +361,51 @@ def test_user_pass_credentials(self): password='my_password', resource='https://management.core.chinacloudapi.cn/', verify=False) +class TestAdalAuthentication(unittest.TestCase): + + """Test authentication using adal.""" + + def test_base_init(self): + # Test azure_active_directory.AdalAuthentication.__init__(). + endpoint = "https://localhost" + tenant = "test-tenant" + auth = azure_active_directory.AdalAuthentication(auth_endpoint=endpoint, + tenant=tenant) + self.assertEqual(auth.authority, urljoin(endpoint, tenant)) + + @mock.patch("adal.AuthenticationContext") + def test_signed_session(self, AuthMock): + # Test azure_active_directory.AdalAuthentication.signed_session(). + sentinel = object() + AuthMock.return_value = sentinel + access_token = "access-token" + token_type = "token-type" + token_data = {azure_active_directory._TOKEN_ENTRY_TOKEN_TYPE: token_type, + azure_active_directory._ACCESS_TOKEN: access_token} + class FakeAuth(azure_active_directory.AdalAuthentication): + def acquire_token(self, context): + self.context = context + return token_data + + auth =FakeAuth() + token = auth.signed_session() + self.assertEqual(AuthMock.call_args, mock.call(auth.authority)) + self.assertEqual(token.headers["Authorization"], + " ".join([token_type, access_token])) + self.assertIs(auth.context, sentinel) + + def test_username_password(self): + # Test azure_active_directory.AdaluserPassCredentials. + username = 'msrestazure-test' + password = 'password' + context = mock.Mock() + auth = azure_active_directory.AdalUserPassCredentials(username, + password) + token = auth.acquire_token(context) + acquire_call = mock.call.acquire_token_with_username_password( + auth.resource, username, password, auth.client_id) + self.assertEqual(context.method_calls, [acquire_call]) + + if __name__ == '__main__': - unittest.main() \ No newline at end of file + unittest.main() From 23e5d23cb5ffe9dc9508c700dc9de1a18911fd81 Mon Sep 17 00:00:00 2001 From: Brett Cannon Date: Thu, 27 Oct 2016 15:33:44 -0700 Subject: [PATCH 3/5] Ignore .pyc files --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 496c4b1..433b024 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ __pycache__ +*.pyc venv* msrestazure.egg-info .tox From d9cc10b5aa21b3aedce9a3f63c856f30126f4d65 Mon Sep 17 00:00:00 2001 From: Brett Cannon Date: Thu, 27 Oct 2016 15:33:58 -0700 Subject: [PATCH 4/5] Don't provide a default client ID --- msrestazure/azure_active_directory.py | 9 +++++---- test/unittest_auth.py | 14 +++++++++----- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/msrestazure/azure_active_directory.py b/msrestazure/azure_active_directory.py index 5acc537..f3d4697 100644 --- a/msrestazure/azure_active_directory.py +++ b/msrestazure/azure_active_directory.py @@ -532,19 +532,20 @@ def set_token(self, response_url): # Constants related to AAD-based authentication methods. _TOKEN_ENTRY_TOKEN_TYPE = 'tokenType' _ACCESS_TOKEN = 'accessToken' +XPLAT_APP_ID = "04b07795-8ddb-461a-bbee-02f9e1bf7b46" class AdalAuthentication(msrest.authentication.Authentication): """Base class for adal-derived authentication.""" - def __init__(self, client_id="04b07795-8ddb-461a-bbee-02f9e1bf7b46", + def __init__(self, client_id, tenant="common", auth_endpoint="https://login.microsoftonline.com", resource="https://management.core.windows.net/"): """Handle details common to adal.""" super(AdalAuthentication, self).__init__() - self.client_id = client_id # Default value is xplat client ID. + self.client_id = client_id self.authority = urljoin(auth_endpoint, tenant) self.resource = resource @@ -568,8 +569,8 @@ class AdalUserPassCredentials(AdalAuthentication): """Authenticate with AAD using a username and password.""" - def __init__(self, username, password, **kwargs): - super(AdalUserPassCredentials, self).__init__(**kwargs) + def __init__(self, username, password, client_id, **kwargs): + super(AdalUserPassCredentials, self).__init__(client_id, **kwargs) self.username = username self.password = password diff --git a/test/unittest_auth.py b/test/unittest_auth.py index 7128fbe..9972c19 100644 --- a/test/unittest_auth.py +++ b/test/unittest_auth.py @@ -369,8 +369,10 @@ def test_base_init(self): # Test azure_active_directory.AdalAuthentication.__init__(). endpoint = "https://localhost" tenant = "test-tenant" - auth = azure_active_directory.AdalAuthentication(auth_endpoint=endpoint, - tenant=tenant) + auth = azure_active_directory.AdalAuthentication( + azure_active_directory.XPLAT_APP_ID, + auth_endpoint=endpoint, + tenant=tenant) self.assertEqual(auth.authority, urljoin(endpoint, tenant)) @mock.patch("adal.AuthenticationContext") @@ -387,7 +389,7 @@ def acquire_token(self, context): self.context = context return token_data - auth =FakeAuth() + auth =FakeAuth(azure_active_directory.XPLAT_APP_ID) token = auth.signed_session() self.assertEqual(AuthMock.call_args, mock.call(auth.authority)) self.assertEqual(token.headers["Authorization"], @@ -399,8 +401,10 @@ def test_username_password(self): username = 'msrestazure-test' password = 'password' context = mock.Mock() - auth = azure_active_directory.AdalUserPassCredentials(username, - password) + auth = azure_active_directory.AdalUserPassCredentials( + username, + password, + azure_active_directory.XPLAT_APP_ID) token = auth.acquire_token(context) acquire_call = mock.call.acquire_token_with_username_password( auth.resource, username, password, auth.client_id) From a3afc12dec500ee36648d3a6dab10a3842f39118 Mon Sep 17 00:00:00 2001 From: Brett Cannon Date: Thu, 27 Oct 2016 15:49:20 -0700 Subject: [PATCH 5/5] Acquire the adal token in the __init__() call to make auth failures more eager --- msrestazure/azure_active_directory.py | 10 +++++----- test/unittest_auth.py | 17 +++++++++++++---- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/msrestazure/azure_active_directory.py b/msrestazure/azure_active_directory.py index f3d4697..c7e53ed 100644 --- a/msrestazure/azure_active_directory.py +++ b/msrestazure/azure_active_directory.py @@ -548,6 +548,8 @@ def __init__(self, client_id, self.client_id = client_id self.authority = urljoin(auth_endpoint, tenant) self.resource = resource + context = adal.AuthenticationContext(self.authority) + self.token = self.acquire_token(context) # @abc.abstractmethod if Python 2 wasn't supported. def acquire_token(self, context): @@ -557,10 +559,8 @@ def acquire_token(self, context): def signed_session(self): """Return a signed session.""" session = super(AdalAuthentication, self).signed_session() - context = adal.AuthenticationContext(self.authority) - token_entry = self.acquire_token(context) - header = " ".join([token_entry[_TOKEN_ENTRY_TOKEN_TYPE], - token_entry[_ACCESS_TOKEN]]) + header = " ".join([self.token[_TOKEN_ENTRY_TOKEN_TYPE], + self.token[_ACCESS_TOKEN]]) session.headers['Authorization'] = header return session @@ -570,9 +570,9 @@ class AdalUserPassCredentials(AdalAuthentication): """Authenticate with AAD using a username and password.""" def __init__(self, username, password, client_id, **kwargs): - super(AdalUserPassCredentials, self).__init__(client_id, **kwargs) self.username = username self.password = password + super(AdalUserPassCredentials, self).__init__(client_id, **kwargs) def acquire_token(self, context): return context.acquire_token_with_username_password( diff --git a/test/unittest_auth.py b/test/unittest_auth.py index 9972c19..5b4ec8a 100644 --- a/test/unittest_auth.py +++ b/test/unittest_auth.py @@ -369,11 +369,18 @@ def test_base_init(self): # Test azure_active_directory.AdalAuthentication.__init__(). endpoint = "https://localhost" tenant = "test-tenant" - auth = azure_active_directory.AdalAuthentication( + token_data = object() # Sentinel. + class FakeAuth(azure_active_directory.AdalAuthentication): + def acquire_token(self, context): + self.context = context + return token_data + + auth = FakeAuth( azure_active_directory.XPLAT_APP_ID, auth_endpoint=endpoint, tenant=tenant) self.assertEqual(auth.authority, urljoin(endpoint, tenant)) + self.assertIs(auth.token, token_data) @mock.patch("adal.AuthenticationContext") def test_signed_session(self, AuthMock): @@ -396,16 +403,18 @@ def acquire_token(self, context): " ".join([token_type, access_token])) self.assertIs(auth.context, sentinel) - def test_username_password(self): - # Test azure_active_directory.AdaluserPassCredentials. + @mock.patch("adal.AuthenticationContext") + def test_username_password(self, mocked_context): + # Test azure_active_directory.AdaluserPassCredentials.__init__() calls + # context.acquire_token_with_username_password(). username = 'msrestazure-test' password = 'password' context = mock.Mock() + mocked_context.return_value = context auth = azure_active_directory.AdalUserPassCredentials( username, password, azure_active_directory.XPLAT_APP_ID) - token = auth.acquire_token(context) acquire_call = mock.call.acquire_token_with_username_password( auth.resource, username, password, auth.client_id) self.assertEqual(context.method_calls, [acquire_call])