diff --git a/adal.pyproj b/adal.pyproj index 4b5903d3..ad558b5f 100644 --- a/adal.pyproj +++ b/adal.pyproj @@ -83,6 +83,11 @@ Code + + + + + Code @@ -105,6 +110,7 @@ + diff --git a/adal/__init__.py b/adal/__init__.py index a6de258e..83774909 100644 --- a/adal/__init__.py +++ b/adal/__init__.py @@ -25,14 +25,18 @@ # #------------------------------------------------------------------------------ -__version__ = '0.2.0' +__version__ = '1.0.0rc1' from .authentication_context import AuthenticationContext from .token_cache import TokenCache -from .log import LOGGING_LEVEL, set_logging_options, get_logging_options +from .log import (LOGGING_LEVEL, + set_logging_options, + get_logging_options, + ADAL_LOGGER_NAME) +from .adal_error import AdalError # to avoid "No handler found" warnings. import logging -logging.getLogger(log.ADAL_LOGGER_NAME).addHandler(logging.NullHandler()) +logging.getLogger(ADAL_LOGGER_NAME).addHandler(logging.NullHandler()) diff --git a/adal/adal_error.py b/adal/adal_error.py index a5c2c7cd..180ebccd 100644 --- a/adal/adal_error.py +++ b/adal/adal_error.py @@ -1,4 +1,31 @@ -class AdalError(Exception): +#------------------------------------------------------------------------------ +# +# Copyright (c) Microsoft Corporation. +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +#------------------------------------------------------------------------------ + +class AdalError(Exception): def __init__(self, error_msg, error_response=None): super(AdalError, self).__init__(error_msg) self.error_response = error_response diff --git a/adal/argument.py b/adal/argument.py index 47509d73..f2df1830 100644 --- a/adal/argument.py +++ b/adal/argument.py @@ -24,15 +24,21 @@ # THE SOFTWARE. # #------------------------------------------------------------------------------ +import sys from .constants import OAuth2DeviceCodeResponseParameters def validate_string_param(value, name): - if not value: raise ValueError("The {0} parameter is required".format(name)) - if not isinstance(value, str): + result = True + if sys.version_info.major < 3: + result = isinstance(value, basestring) + else: + result = isinstance(value, str) + + if not result: raise TypeError("The {0} parameter must be of type str".format(name)) def validate_user_code_info(user_code_info): diff --git a/adal/authentication_context.py b/adal/authentication_context.py index 2885744a..ca2a4289 100644 --- a/adal/authentication_context.py +++ b/adal/authentication_context.py @@ -25,8 +25,9 @@ # #------------------------------------------------------------------------------ -from .authority import Authority +import threading +from .authority import Authority from . import argument from .code_request import CodeRequest from .token_request import TokenRequest @@ -34,23 +35,46 @@ from . import log from .constants import OAuth2DeviceCodeResponseParameters - GLOBAL_ADAL_OPTIONS = {} class AuthenticationContext(object): - + ''' + Retrieves authentication tokens from Azure Active Directory. + For usages, check out the "sample" folder under + https://github.com/AzureAD/azure-activedirectory-library-for-python + ''' def __init__(self, authority, validate_authority=None, cache=None): - + ''' + Creates a new AuthenticationContext object. By default the authority + will be checked against a list of known Azure Active Directory authorities. + If the authority is not recognized as one of these well known authorities + then token acquisition will fail. This behavior can be turned off via the + validate_authority parameter below. + Args: + authority (str): + A URL that identifies a token authority. + validate_authority (bool, optional): + Turns authority validation on or off. This parameter default to true. + cache (TokenCache, optional): + Sets the token cache used by this AuthenticationContext instance. + If this parameter is not set, then a default is used. Cache instances + is only used by that instance of the AuthenticationContext and are not + shared unless it has been manually passed during the construction of + other AuthenticationContexts. + Returns: + A new AuthenticationContext object + ''' validate = validate_authority if not validate_authority: validate = True self.authority = Authority(authority, validate) self._oauth2client = None - self._correlation_id = None + self.correlation_id = None self._call_context = {'options': GLOBAL_ADAL_OPTIONS} self._token_requests_with_user_code = {} self.cache = cache or TokenCache() + self._lock = threading.RLock() @property def options(self): @@ -61,12 +85,24 @@ def options(self, val): self._call_context['options'] = val def _acquire_token(self, token_func): - self._call_context['log_context'] = log.create_log_context(self._correlation_id) + self._call_context['log_context'] = log.create_log_context(self.correlation_id) self.authority.validate(self._call_context) token = token_func(self) return token def acquire_token(self, resource, user_id, client_id): + ''' + Gets a token for a given resource via cached tokens. + Args: + resource (str): + A URI that identifies the resource for which the token is valid. + user_id (str): + The username of the user on behalf this application is authenticating. + client_id (str): + The OAuth client id of the calling application. + Returns: + dict: with several keys, include "accessToken" and "refreshToken" + ''' argument.validate_string_param(resource, 'resource') argument.validate_string_param(client_id, 'client_id') @@ -79,6 +115,20 @@ def token_func(self): return token def acquire_token_with_username_password(self, resource, username, password, client_id): + ''' + Gets a token for a given resource via user credentails. + Args: + resource (str): + A URI that identifies the resource for which the token is valid. + username (str): + The username of the user on behalf this application is authenticating. + password (str): + The password of the user named in the username parameter. + client_id (str): + The OAuth client id of the calling application. + Returns: + dict: with several keys, include "accessToken" and "refreshToken" + ''' argument.validate_string_param(resource, 'resource') argument.validate_string_param(username, 'username') argument.validate_string_param(password, 'password') @@ -93,6 +143,18 @@ def token_func(self): return token def acquire_token_with_client_credentials(self, resource, client_id, client_secret): + ''' + Gets a token for a given resource via client credentials. + Args: + resource (str): + A URI that identifies the resource for which the token is valid. + client_id (str): + The OAuth client id of the calling application. + client_secret (str): + The OAuth client secret of the calling application. + Returns: + dict: with several keys, include "accessToken" + ''' argument.validate_string_param(resource, 'resource') argument.validate_string_param(client_id, 'client_id') argument.validate_string_param(client_secret, 'client_secret') @@ -105,8 +167,29 @@ def token_func(self): token = self._acquire_token(token_func) return token - def acquire_token_with_authorization_code(self, authorization_code, redirect_uri, resource, client_id, client_secret): - + def acquire_token_with_authorization_code( + self, + authorization_code, + redirect_uri, + resource, + client_id, + client_secret): + ''' + Gets a token for a given resource via auhtorization code for a server app. + Args: + authorization_code (str): + An authorization code returned from a client. + redirect_uri (str): + he redirect uri that was used in the authorize call. + resource (str): + A URI that identifies the resource for which the token is valid. + client_id (str): + The OAuth client id of the calling application. + client_secret (str): + The OAuth client secret of the calling application. + Returns: + dict: with several keys, include "accessToken" and "refreshToken" + ''' argument.validate_string_param(authorization_code, 'authorization_code') argument.validate_string_param(redirect_uri, 'redirect_uri') argument.validate_string_param(resource, 'resource') @@ -114,14 +197,36 @@ def acquire_token_with_authorization_code(self, authorization_code, redirect_uri argument.validate_string_param(client_secret, 'client_secret') def token_func(self): - token_request = TokenRequest(self._call_context, self, client_id, resource, redirect_uri) - token = token_request.get_token_with_authorization_code(authorization_code, client_secret) + token_request = TokenRequest( + self._call_context, + self, + client_id, + resource, + redirect_uri) + token = token_request.get_token_with_authorization_code( + authorization_code, + client_secret) return token token = self._acquire_token(token_func) return token - def acquire_token_with_refresh_token(self, refresh_token, client_id, client_secret, resource): + def acquire_token_with_refresh_token(self, refresh_token, client_id, resource, client_secret=None): + ''' + Gets a token for a given resource via refresh tokens + Args: + refresh_token (str): + A refresh token returned in a tokne response from a previous invocation + of acquireToken. + client_id (str): + The OAuth client id of the calling application. + resource (str): + A URI that identifies the resource for which the token is valid. + client_secret (str, optional): + The OAuth client secret of the calling application. + Returns: + dict: with several keys, include "accessToken" and "refreshToken" + ''' argument.validate_string_param(refresh_token, 'refresh_token') argument.validate_string_param(client_id, 'client_id') argument.validate_string_param(resource, 'resource') @@ -134,6 +239,20 @@ def token_func(self): return token def acquire_token_with_client_certificate(self, resource, client_id, certificate, thumbprint): + ''' + Gets a token for a given resource via certificate credentials + Args: + resource (str): + A URI that identifies the resource for which the token is valid. + client_id (str): + The OAuth client id of the calling application. + certificate (str): + A PEM encoded certificate private key. + thumbprint (str): + hex encoded thumbprint of the certificate. + Returns: + dict: with several keys, include "accessToken". + ''' argument.validate_string_param(resource, 'resource') argument.validate_string_param(client_id, 'client_id') argument.validate_string_param(certificate, 'certificate') @@ -148,31 +267,75 @@ def token_func(self): return token def acquire_user_code(self, resource, client_id, language=None): - self._call_context['log_context'] = log.create_log_context(self._correlation_id) + ''' + Gets the user code info which contains user_code, device_code for authenticating + user on device. + Args: + resource (str): + A URI that identifies the resource for which the device_code and + user_code is valid for. + client_id (str): + The OAuth client id of the calling application. + language (str): + The language code specifying how the message should be localized to. + Returns: + dict: contains code and uri for users to login through browser. + ''' + self._call_context['log_context'] = log.create_log_context(self.correlation_id) self.authority.validate(self._call_context) code_request = CodeRequest(self._call_context, self, client_id, resource) code = code_request.get_user_code_info(language) return code def acquire_token_with_device_code(self, resource, user_code_info, client_id): - self._call_context['log_context'] = log.create_log_context(self._correlation_id) + ''' + Gets a new access token using via a device code. + Args: + resource (str): + A URI that identifies the resource for which the token is valid. + user_code_info (dict): + The code info from the invocation of "acquire_user_code" + client_id (str): + The OAuth client id of the calling application. + Returns: + dict: with several keys, include "accessToken" and "refreshToken" + ''' + self._call_context['log_context'] = log.create_log_context(self.correlation_id) def token_func(self): token_request = TokenRequest(self._call_context, self, client_id, resource) - self._token_requests_with_user_code[user_code_info[OAuth2DeviceCodeResponseParameters.DEVICE_CODE]] = token_request + + key = user_code_info[OAuth2DeviceCodeResponseParameters.DEVICE_CODE] + with self._lock: + self._token_requests_with_user_code[key] = token_request + token = token_request.get_token_with_device_code(user_code_info) + + with self._lock: + self._token_requests_with_user_code.pop(key, None) + return token token = self._acquire_token(token_func) return token def cancel_request_to_get_token_with_device_code(self, user_code_info): + ''' + Cancels the polling request to get token with device code. + Args: + user_code_info (dict): + The code info from the invocation of "acquire_user_code" + Returns: + None + ''' argument.validate_user_code_info(user_code_info) key = user_code_info[OAuth2DeviceCodeResponseParameters.DEVICE_CODE] - request = self._token_requests_with_user_code.get(key) - if not request: - raise ValueError('No acquire_token_with_device_code existed to be cancelled') + with self._lock: + request = self._token_requests_with_user_code.get(key) + + if not request: + raise ValueError('No acquire_token_with_device_code existed to be cancelled') - request.cancel_token_request_with_device_code() - self._token_requests_with_user_code.pop(key, None) + request.cancel_token_request_with_device_code() + self._token_requests_with_user_code.pop(key, None) diff --git a/adal/authority.py b/adal/authority.py index eb6919cd..098a59a6 100644 --- a/adal/authority.py +++ b/adal/authority.py @@ -25,16 +25,15 @@ # #------------------------------------------------------------------------------ -import requests - try: from urllib.parse import quote from urllib.parse import urlparse - except ImportError: from urllib import quote from urlparse import urlparse +import requests + from .constants import AADConstants from .adal_error import AdalError from . import log @@ -114,8 +113,8 @@ def _perform_dynamic_instance_discovery(self): try: resp = requests.get(discovery_endpoint.geturl(), headers=get_options['headers']) util.log_return_correlation_id(self._log, operation, resp) - except Exception as exp: - self._log.error("{0} request failed".format(operation), exp) + except Exception: + self._log.info("{0} request failed".format(operation)) raise if not util.is_http_success(resp.status_code): @@ -125,17 +124,17 @@ def _perform_dynamic_instance_discovery(self): return_error_string += " and server response: {0}".format(resp.text) try: error_response = resp.json() - except: + except ValueError: pass - raise AdalError(self._log.create_error(return_error_string), error_response) + raise AdalError(return_error_string, error_response) else: discovery_resp = resp.json() if discovery_resp.get('tenant_discovery_endpoint'): return discovery_resp['tenant_discovery_endpoint'] else: - raise AdalError(self._log.create_error('Failed to parse instance discovery response')) + raise AdalError('Failed to parse instance discovery response') def _validate_via_instance_discovery(self): valid = self._perform_static_instance_discovery() diff --git a/adal/cache_driver.py b/adal/cache_driver.py index feda4535..8887e91d 100644 --- a/adal/cache_driver.py +++ b/adal/cache_driver.py @@ -1,10 +1,38 @@ -import base64 +#------------------------------------------------------------------------------ +# +# Copyright (c) Microsoft Corporation. +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +#------------------------------------------------------------------------------ + +import base64 import copy import hashlib import json from datetime import datetime, timedelta from dateutil import parser +from .adal_error import AdalError from .constants import TokenResponseFields, Misc from . import log @@ -12,10 +40,10 @@ # pylint: disable=W0212 def _create_token_hash(token): - m = hashlib.sha256() - m.update(token.encode('utf8')) # TODO: what is the default encoding - hash = base64.b64encode(m.digest()) - return hash + hash_object = hashlib.sha256() + hash_object.update(token.encode('utf8')) + token_hash = base64.b64encode(hash_object.digest()) + return token_hash def _create_token_id_message(entry): access_token_hash = _create_token_hash(entry[TokenResponseFields.ACCESS_TOKEN]) @@ -65,7 +93,9 @@ def _load_single_entry_from_cache(self, query): potential_entries = self._get_potential_entries(query) if potential_entries: resource_tenant_specific_entries = [ - x for x in potential_entries if x[TokenResponseFields.RESOURCE] == self._resource and x[TokenResponseFields._AUTHORITY] == self._authority] + x for x in potential_entries + if x[TokenResponseFields.RESOURCE] == self._resource and + x[TokenResponseFields._AUTHORITY] == self._authority] if not resource_tenant_specific_entries: self._log.debug('No resource specific cache entries found.') @@ -82,7 +112,7 @@ def _load_single_entry_from_cache(self, query): return_val = resource_tenant_specific_entries[0] is_resource_tenant_specific = True else: - raise ValueError('More than one token matches the criteria. The result is ambiguous.') + raise AdalError('More than one token matches the criteria. The result is ambiguous.') if return_val: self._log.debug( @@ -119,7 +149,7 @@ def _acquire_new_token_from_mrrt(self, entry): return new_entry def _refresh_entry_if_necessary(self, entry, is_resource_specific): - expiry_date = parser.parse(entry[TokenResponseFields.EXPIRES_ON]) #get clear on local time and time saving + expiry_date = parser.parse(entry[TokenResponseFields.EXPIRES_ON]) now = datetime.now(expiry_date.tzinfo) # Add some buffer in to the time comparison to account for clock skew or latency. @@ -197,4 +227,3 @@ def add(self, entry): self._argument_entry_with_cached_metadata(entry) self._update_refresh_tokens(entry) self._cache.add([entry]) - diff --git a/adal/code_request.py b/adal/code_request.py index 316c673b..14926797 100644 --- a/adal/code_request.py +++ b/adal/code_request.py @@ -1,4 +1,31 @@ -from . import constants +#------------------------------------------------------------------------------ +# +# Copyright (c) Microsoft Corporation. +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +#------------------------------------------------------------------------------ + +from . import constants from . import log from . import oauth2_client diff --git a/adal/log.py b/adal/log.py index e715f3a1..fd5e77e8 100644 --- a/adal/log.py +++ b/adal/log.py @@ -54,7 +54,7 @@ class LOGGING_LEVEL: def create_log_context(correlation_id=None): return {'correlation_id' : correlation_id or str(uuid.uuid4())} -def set_logging_options(options={}): +def set_logging_options(options=None): ''' To set level: {'level': adal.log.LOGGING_LEVEL.DEBUG} To add console log: { 'handler': logging.StreamHandler()} diff --git a/adal/mex.py b/adal/mex.py index f2e9a393..181a0210 100644 --- a/adal/mex.py +++ b/adal/mex.py @@ -26,25 +26,22 @@ #------------------------------------------------------------------------------ import random -import requests - -from . import log -from . import util -from . import xmlutil try: - from urllib.parse import quote, unquote - from urllib.parse import urlparse, urlsplit - + from urllib.parse import urlparse except ImportError: - from urllib import quote, unquote - from urlparse import urlparse, urlsplit + from urlparse import urlparse try: from xml.etree import cElementTree as ET except ImportError: from xml.etree import ElementTree as ET +import requests + +from . import log +from . import util +from . import xmlutil from .constants import XmlNamespaces from .constants import MexNamespaces from .adal_error import AdalError @@ -63,43 +60,38 @@ def __init__(self, call_context, url): self._log.debug("Mex created with url: {0}".format(self._url)) def discover(self): - self._log.debug("Retrieving mex at: {0}".format(self._url)) options = util.create_request_options(self, {'headers': {'Content-Type': 'application/soap+xml'}}) + resp = None try: operation = "Mex Get" resp = requests.get(self._url, headers=options['headers']) util.log_return_correlation_id(self._log, operation, resp) + except Exception: + self._log.info("{0} request failed".format(operation)) + raise - if not util.is_http_success(resp.status_code): - return_error_string = "{0} request returned http error: {1}".format(operation, resp.status_code) - error_response = "" - if resp.text: - return_error_string += " and server response: {0}".format(resp.text) - try: - error_response = resp.json() - except: - pass - - raise AdalError(self._log.create_error(return_error_string), error_response) - - else: + if not util.is_http_success(resp.status_code): + return_error_string = "{0} request returned http error: {1}".format(operation, resp.status_code) + error_response = "" + if resp.text: + return_error_string += " and server response: {0}".format(resp.text) try: - self._mex_doc = resp.text - #options = {'errorHandler':self._log.error} - self._dom = ET.fromstring(self._mex_doc) - self._parents = {c:p for p in self._dom.iter() for c in p} - self._parse() - except AdalError as exp: - self._log.error('Failed to parse mex response in to DOM', exp) - raise - return - return - - except Exception as exp: - self._log.error("{0} request failed".format(operation), exp) - raise + error_response = resp.json() + except ValueError: + pass + raise AdalError(return_error_string, error_response) + else: + try: + self._mex_doc = resp.text + #options = {'errorHandler':self._log.error} + self._dom = ET.fromstring(self._mex_doc) + self._parents = {c:p for p in self._dom.iter() for c in p} + self._parse() + except Exception: + self._log.info('Failed to parse mex response in to DOM') + raise def _check_policy(self, policy_node): policy_id = policy_node.attrib["{{{}}}Id".format(XmlNamespaces.namespaces['wsu'])] @@ -130,9 +122,9 @@ def _select_username_password_polices(self): for node in username_token_nodes: policy_node = self._parents[self._parents[self._parents[self._parents[self._parents[self._parents[self._parents[node]]]]]]] - id = self._check_policy(policy_node) - if id: - id_ref = '#' + id + policy_id = self._check_policy(policy_node) + if policy_id: + id_ref = '#' + policy_id policies[id_ref] = {id:id_ref} return policies if policies else None @@ -143,7 +135,7 @@ def _check_soap_action_and_transport(self, binding_node): soap_transport = "" name = binding_node.get('name') - soap_transport_attributes = [] + soap_transport_attributes = "" soap_action_attributes = xmlutil.xpath_find(binding_node, MexNamespaces.SOAP_ACTION_XPATH)[0].attrib['soapAction'] if soap_action_attributes: @@ -178,8 +170,8 @@ def _get_matching_bindings(self, policies): return bindings if bindings else None - def _url_is_secure(self, endpoint_url): - + @staticmethod + def _url_is_secure(endpoint_url): parsed = urlparse(endpoint_url) return parsed.scheme == 'https' @@ -201,7 +193,7 @@ def _get_ports_for_policy_bindings(self, bindings, policies): raise self._log.create_error("No address nodes on port") address = xmlutil.find_element_text(address_node) - if self._url_is_secure(address): + if Mex._url_is_secure(address): binding_policy['url'] = address else: self._log.warn("Skipping insecure endpoint: {0}".format(address)) @@ -220,15 +212,15 @@ def _parse(self): policies = self._select_username_password_polices() if not policies: - raise AdalError(self._log.create_error("No matching policies.")) + raise AdalError("No matching policies.") bindings = self._get_matching_bindings(policies) if not bindings: - raise AdalError(self._log.create_error("No matching bindings.")) + raise AdalError("No matching bindings.") self._get_ports_for_policy_bindings(bindings, policies) self._select_single_matching_policy(policies) if not self._url: - raise AdalError(self._log.create_error("No ws-trust endpoints match requirements.")) + raise AdalError("No ws-trust endpoints match requirements.") diff --git a/adal/oauth2_client.py b/adal/oauth2_client.py index ddf438d3..87339d6f 100644 --- a/adal/oauth2_client.py +++ b/adal/oauth2_client.py @@ -27,11 +27,10 @@ from datetime import datetime, timedelta import math -import uuid -import requests import re import json import time +import uuid try: from urllib.parse import urlencode @@ -40,6 +39,8 @@ from urllib import urlencode from urlparse import urlparse +import requests + from . import log from . import util from .constants import OAuth2, TokenResponseFields, IdTokenFields @@ -86,7 +87,8 @@ def _parse_optional_ints(self, obj, keys): try: obj[key] = int(obj[key]) except ValueError: - raise self._log.create_error("{0} could not be parsed as an int".format(key)) + self._log.info("{0} could not be parsed as an int".format(key)) + raise except KeyError: # if the key isn't present we can just continue pass @@ -94,7 +96,7 @@ def _parse_optional_ints(self, obj, keys): @classmethod def _crack_jwt(cls, jwt_token): - id_token_parts_reg = "^([^\.\s]*)\.([^\.\s]+)\.([^\.\s]*)$" + id_token_parts_reg = r"^([^\.\s]*)\.([^\.\s]+)\.([^\.\s]*)$" matches = re.search(id_token_parts_reg, jwt_token) if not matches or len(matches.groups()) < 3: raise ValueError('The token was not parsable.') @@ -107,7 +109,8 @@ def _crack_jwt(cls, jwt_token): return cracked_token - def _get_user_id(self, id_token): + @staticmethod + def _get_user_id(id_token): user_id = None is_displayable = False @@ -132,10 +135,11 @@ def _get_user_id(self, id_token): return user_id_vals - def _extract_token_values(self, id_token): + @staticmethod + def _extract_token_values(id_token): extracted_values = {} extracted_values = map_fields(id_token, OAuth2.IdTokenMap) - extracted_values.update(self._get_user_id(id_token)) + extracted_values.update(OAuth2Client._get_user_id(id_token)) return extracted_values def _parse_id_token(self, encoded_token): @@ -147,18 +151,17 @@ def _parse_id_token(self, encoded_token): id_token = None try: b64_id_token = cracked_token['JWSPayload'] - b64_decoded = util.base64_urlsafe_decode(str(b64_id_token)) + b64_decoded = util.base64_urlsafe_decode(b64_id_token) if not b64_decoded: self._log.warn('The returned id_token could not be base64 url safe decoded.') return id_token = json.loads(b64_decoded.decode()) - - except Exception as exp: - self._log.warn("The returned id_token could not be decoded: {0}".format(exp)) + except ValueError: + self._log.info("The returned id_token could not be decoded: {0}".format(encoded_token)) raise - return self._extract_token_values(id_token) + return OAuth2Client._extract_token_values(id_token) def _validate_token_response(self, body): @@ -167,8 +170,10 @@ def _validate_token_response(self, body): try: wire_response = json.loads(body) - except Exception: - raise ValueError('The token response returned from the server is unparseable as JSON') + except ValueError: + self._log.info( + 'The token response from the server is unparseable as JSON:' + body) + raise int_keys = [ OAuth2.ResponseParameters.EXPIRES_ON, @@ -210,8 +215,9 @@ def _validate_device_code_response(self, body): try: wire_response = json.loads(body) - except Exception: - raise ValueError('The device code response returned from the server is unparseable as JSON') + except ValueError: + self._log.info('The device code response returned from the server is unparseable as JSON:') + raise int_keys = [ OAuth2.DeviceCodeResponseParameters.EXPIRES_IN, @@ -221,13 +227,13 @@ def _validate_device_code_response(self, body): self._parse_optional_ints(wire_response, int_keys) if not wire_response.get(OAuth2.DeviceCodeResponseParameters.EXPIRES_IN): - raise self._log.create_error('wire_response is missing expires_in') + raise AdalError('wire_response is missing expires_in', wire_response) if not wire_response.get(OAuth2.DeviceCodeResponseParameters.DEVICE_CODE): - raise self._log.create_error('wire_response is missing device_code') + raise AdalError('wire_response is missing device_code', wire_response) if not wire_response.get(OAuth2.DeviceCodeResponseParameters.USER_CODE): - raise self._log.create_error('wire_response is missing user_code') + raise AdalError('wire_response is missing user_code', wire_response) #skip field naming tweak, becasue names from wire are python style already return wire_response @@ -237,8 +243,8 @@ def _handle_get_token_response(self, body): token_response = None try: token_response = self._validate_token_response(body) - except Exception as exp: - self._log.error("Error validating get token response", exp) + except Exception: + self._log.info("Error validating get token response '{}'".format(body)) raise return token_response @@ -248,8 +254,8 @@ def _handle_get_device_code_response(self, body): device_code_response = None try: device_code_response = self._validate_device_code_response(body) - except Exception as exp: - self._log.error('Error validating get user vcode response', exp) + except Exception: + self._log.info("Error validating get user code response '{}'".format(body)) raise return device_code_response @@ -262,66 +268,67 @@ def get_token(self, oauth_parameters): post_options = util.create_request_options(self, {'headers' : {'content-type': 'application/x-www-form-urlencoded'}}) operation = "Get Token" + resp = None try: resp = requests.post(token_url.geturl(), data=url_encoded_token_request, headers=post_options['headers']) util.log_return_correlation_id(self._log, operation, resp) - - if util.is_http_success(resp.status_code): - token = self._handle_get_token_response(resp.text) - return token - else: - return_error_string = "{0} request returned http error: {1}".format(operation, resp.status_code) - error_response = "" - if resp.text: - return_error_string += " and server response: {0}".format(resp.text) - try: - error_response = resp.json() - except: - pass - - raise AdalError(self._log.create_error(return_error_string), error_response) - - except Exception as exp: - self._log.error("{0} request failed".format(operation), exp) + except Exception: + self._log.info("{0} request failed".format(operation)) raise + if util.is_http_success(resp.status_code): + token = self._handle_get_token_response(resp.text) + return token + else: + return_error_string = "{0} request returned http error: {1}".format(operation, resp.status_code) + error_response = "" + if resp.text: + return_error_string += " and server response: {0}".format(resp.text) + try: + error_response = resp.json() + except ValueError: + pass + raise AdalError(return_error_string, error_response) + def get_user_code_info(self, oauth_parameters): device_code_url = self._create_device_code_url() url_encoded_code_request = urlencode(oauth_parameters) post_options = util.create_request_options(self, {'headers' : {'content-type': 'application/x-www-form-urlencoded'}}) operation = "Get Device Code" - + resp = None try: resp = requests.post(device_code_url.geturl(), data=url_encoded_code_request, headers=post_options['headers']) util.log_return_correlation_id(self._log, operation, resp) - - if util.is_http_success(resp.status_code): - code = self._handle_get_device_code_response(resp.text) - return code - else: - return_error_string = "{0} request returned http error: {1}".format(operation, resp.status_code) - error_response = "" - if resp.text: - return_error_string += " and server response: {0}".format(resp.text) - try: - error_response = resp.json() - except: - pass - - raise AdalError(self._log.create_error(return_error_string), error_response) - - except Exception as exp: - self._log.error("{0} request failed".format(operation), exp) + except Exception: + self._log.info("{} request failed".format(operation)) raise + if util.is_http_success(resp.status_code): + code = self._handle_get_device_code_response(resp.text) + return code + else: + return_error_string = "{} request returned http error: {}".format(operation, resp.status_code) + error_response = "" + if resp.text: + return_error_string += " and server response: {}".format(resp.text) + try: + error_response = resp.json() + except ValueError: + pass + + raise AdalError(return_error_string, error_response) + def get_token_with_polling(self, oauth_parameters, refresh_internal, expires_in): token_response = {} token_url = self._create_token_url() url_encoded_code_request = urlencode(oauth_parameters) - post_options = util.create_request_options(self, {'headers' : {'content-type': 'application/x-www-form-urlencoded'}}) + post_options = util.create_request_options( + self, + {'headers' : {'content-type': 'application/x-www-form-urlencoded'}}) + operation = "Get token with device code" max_times_for_retry = math.floor(expires_in/refresh_internal) @@ -329,7 +336,10 @@ def get_token_with_polling(self, oauth_parameters, refresh_internal, expires_in) if self._cancel_polling_request: raise AdalError('Polling_Request_Cancelled') - resp = requests.post(token_url.geturl(), data=url_encoded_code_request, headers=post_options['headers']) + resp = requests.post( + token_url.geturl(), + data=url_encoded_code_request, headers=post_options['headers']) + util.log_return_correlation_id(self._log, operation, resp) wire_response = {} @@ -342,12 +352,13 @@ def get_token_with_polling(self, oauth_parameters, refresh_internal, expires_in) time.sleep(refresh_internal) continue else: - raise ValueError(error) + raise AdalError('Unexpected polling state {}'.format(error), + wire_response) else: try: token_response = self._validate_token_response(resp.text) - except Exception as exp: - self._log.error("Error validating get token response", exp) + except Exception: + self._log.info("Error validating get token response '{}'".format(resp.text)) raise return token_response diff --git a/adal/self_signed_jwt.py b/adal/self_signed_jwt.py index bb409ab4..ee49aabe 100644 --- a/adal/self_signed_jwt.py +++ b/adal/self_signed_jwt.py @@ -35,13 +35,12 @@ from .constants import Jwt from .log import Logger -from . import util class SelfSignedJwt(object): NumCharIn128BitHexString = 128/8*2 numCharIn160BitHexString = 160/8*2 - ThumbprintRegEx = "^[a-f\d]*$" + ThumbprintRegEx = r"^[a-f\d]*$" def __init__(self, call_context, authority, client_id): self._log = Logger('SelfSignedJwt', call_context['log_context']) @@ -51,19 +50,22 @@ def __init__(self, call_context, authority, client_id): self._token_endpoint = authority.token_endpoint self._client_id = client_id - def _get_date_now(self): + @staticmethod + def _get_date_now(): return datetime.datetime.now() - def _get_new_jwt_id(self): + @staticmethod + def _get_new_jwt_id(): return str(uuid.uuid4()) - def _create_x5t_value(self, thumbprint): - hex = binascii.a2b_hex(thumbprint) - b64_str = base64.urlsafe_b64encode(hex).decode() + @staticmethod + def _create_x5t_value(thumbprint): + hex_val = binascii.a2b_hex(thumbprint) + b64_str = base64.urlsafe_b64encode(hex_val).decode() return b64_str def _create_header(self, thumbprint): - x5t = self._create_x5t_value(thumbprint) + x5t = SelfSignedJwt._create_x5t_value(thumbprint) header = {'typ':'JWT', 'alg':'RS256', 'x5t':x5t} self._log.debug("Creating self signed JWT header. x5t: {0}".format(x5t)) @@ -72,7 +74,7 @@ def _create_header(self, thumbprint): def _create_payload(self): - now = self._get_date_now() + now = SelfSignedJwt._get_date_now() minutes = datetime.timedelta(0, 0, 0, 0, Jwt.SELF_SIGNED_JWT_LIFETIME) expires = now + minutes @@ -84,7 +86,7 @@ def _create_payload(self): jwt_payload[Jwt.SUBJECT] = self._client_id jwt_payload[Jwt.NOT_BEFORE] = int(time.mktime(now.timetuple())) jwt_payload[Jwt.EXPIRES_ON] = int(time.mktime(expires.timetuple())) - jwt_payload[Jwt.JWT_ID] = self._get_new_jwt_id() + jwt_payload[Jwt.JWT_ID] = SelfSignedJwt._get_new_jwt_id() return jwt_payload @@ -100,11 +102,12 @@ def _raise_on_invalid_thumbprint(self, thumbprint): raise self._log.create_error("The thumbprint does not match a known format") def _sign_jwt(self, header, payload, certificate): - encoded_jwt = self._encode_jwt(payload, certificate, header) + encoded_jwt = SelfSignedJwt._encode_jwt(payload, certificate, header) self._raise_on_invalid_jwt_signature(encoded_jwt) return encoded_jwt - def _encode_jwt(self, payload, certificate, header): + @staticmethod + def _encode_jwt(payload, certificate, header): return jwt.encode(payload, certificate, algorithm='RS256', headers=header).decode() def _reduce_thumbprint(self, thumbprint): diff --git a/adal/token_cache.py b/adal/token_cache.py index b24bd7b2..6a46f6c7 100644 --- a/adal/token_cache.py +++ b/adal/token_cache.py @@ -1,4 +1,32 @@ -import json +#------------------------------------------------------------------------------ +# +# Copyright (c) Microsoft Corporation. +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +#------------------------------------------------------------------------------ + +import json +import threading from .constants import TokenResponseFields @@ -32,48 +60,54 @@ def __eq__(self, other): def __ne__(self, other): return not self == other -#TODO: ensure thread safety class TokenCache(object): def __init__(self, state=None): self._cache = {} + self._lock = threading.RLock() if state: self.deserialize(state) self.has_state_changed = False def find(self, query): - entries = self._query_cache( - query.get(TokenResponseFields.IS_MRRT), - query.get(TokenResponseFields.USER_ID), - query.get(TokenResponseFields._CLIENT_ID)) - return entries + with self._lock: + entries = self._query_cache( + query.get(TokenResponseFields.IS_MRRT), + query.get(TokenResponseFields.USER_ID), + query.get(TokenResponseFields._CLIENT_ID)) + return entries def remove(self, entries): - for e in entries: - key = TokenCache._get_cache_key(e) - self._cache.pop(key) - self.has_state_changed = True + with self._lock: + for e in entries: + key = TokenCache._get_cache_key(e) + self._cache.pop(key) + self.has_state_changed = True def add(self, entries): - for e in entries: - key = TokenCache._get_cache_key(e) - self._cache[key] = e - self.has_state_changed = True + with self._lock: + for e in entries: + key = TokenCache._get_cache_key(e) + self._cache[key] = e + self.has_state_changed = True def serialize(self): - state = json.dumps(list(self._cache.values())) - return state + with self._lock: + state = json.dumps(list(self._cache.values())) + return state def deserialize(self, state): - self._cache.clear() - if state: - tokens = json.loads(state) - for t in tokens: - key = self._get_cache_key(t) - self._cache[key] = t + with self._lock: + self._cache.clear() + if state: + tokens = json.loads(state) + for t in tokens: + key = self._get_cache_key(t) + self._cache[key] = t def read_items(self): '''output list of tuples in (key, authentication-result)''' - return self._cache.items() + with self._lock: + return self._cache.items() @staticmethod def _get_cache_key(entry): @@ -87,6 +121,7 @@ def _query_cache(self, is_mrrt, user_id, client_id): matches = [] for k in self._cache: v = self._cache[k] + #None value will be taken as wildcard match if (is_mrrt is None or is_mrrt == v.get(TokenResponseFields.IS_MRRT)) and \ (user_id is None or _string_cmp(user_id, v.get(TokenResponseFields.USER_ID))) and \ (client_id is None or _string_cmp(client_id, v.get(TokenResponseFields._CLIENT_ID))): diff --git a/adal/token_request.py b/adal/token_request.py index 9f106eb5..58c7db41 100644 --- a/adal/token_request.py +++ b/adal/token_request.py @@ -25,7 +25,6 @@ # #------------------------------------------------------------------------------ -from functools import partial from base64 import b64encode from . import constants @@ -64,11 +63,16 @@ def __init__(self, call_context, authentication_context, client_id, resource, re self._client_id = client_id self._redirect_uri = redirect_uri - # This should be set at the beginning of get_token + self._cache_driver = None + + # should be set at the beginning of get_token # functions that have a user_id self._user_id = None self._user_realm = None + # should be set when acquire token using device flow + self._polling_client = None + def _create_user_realm_request(self, username): return user_realm.UserRealm(self._call_context, username, self._authentication_context.authority.url) @@ -130,9 +134,9 @@ def _create_oauth_parameters(self, grant_type): oauth_parameters[OAUTH2_PARAMETERS.GRANT_TYPE] = grant_type if (OAUTH2_GRANT_TYPE.AUTHORIZATION_CODE != grant_type and - OAUTH2_GRANT_TYPE.CLIENT_CREDENTIALS != grant_type and - OAUTH2_GRANT_TYPE.REFRESH_TOKEN != grant_type and - OAUTH2_GRANT_TYPE.DEVICE_CODE != grant_type): + OAUTH2_GRANT_TYPE.CLIENT_CREDENTIALS != grant_type and + OAUTH2_GRANT_TYPE.REFRESH_TOKEN != grant_type and + OAUTH2_GRANT_TYPE.DEVICE_CODE != grant_type): oauth_parameters[OAUTH2_PARAMETERS.SCOPE] = OAUTH2_SCOPE.OPENID @@ -183,10 +187,12 @@ def _perform_wstrust_exchange(self, wstrust_endpoint, username, password): wstrust_response = wstrust.acquire_token(username, password) return wstrust_response except AdalError as exp: - error_msg = exp.error_msg - if not error_msg: - error_msg = "Unsuccessful RSTR.\n\terror code: {0}\n\tfaultMessage: {1}".format(exp.error_response.error_code, exp.error_response.fault_message) - self._log.create_error(error_msg) + error_msg = str(exp) + if exp.error_response: + err_template = "Unsuccessful RSTR.\n\terror code: {0}\n\tfaultMessage: {1}" + error_msg = (err_template.format(exp.error_response.error_code, + exp.error_response.fault_message)) + self._log.info(error_msg) raise def _perform_username_password_for_access_token_exchange(self, wstrust_endpoint, username, password): @@ -198,12 +204,16 @@ def _get_token_username_password_federated(self, username, password): self._log.debug("Acquiring token with username password for federated user") if not self._user_realm.federation_metadata_url: - self._log.warn("Unable to retrieve federationMetadataUrl from AAD. Attempting fallback to AAD supplied endpoint.") + self._log.warn("Unable to retrieve federationMetadataUrl from AAD. " + + "Attempting fallback to AAD supplied endpoint.") if not self._user_realm.federation_active_auth_url: - raise AdalError('AAD did not return a WSTrust endpoint. Unable to proceed.') + raise AdalError('AAD did not return a WSTrust endpoint. Unable to proceed.') - token = self._perform_username_password_for_access_token_exchange(self._user_realm.federation_active_auth_url, username, password) + token = self._perform_username_password_for_access_token_exchange( + self._user_realm.federation_active_auth_url, + username, + password) return token else: mex_endpoint = self._user_realm.federation_metadata_url @@ -214,11 +224,13 @@ def _get_token_username_password_federated(self, username, password): try: mex_instance.discover() wstrust_endpoint = mex_instance.username_password_url - except: - self._log.warn("MEX exchange failed. Attempting fallback to AAD supplied endpoint.") + except Exception: + warn_template = ("MEX exchange failed for {}. " + + "Attempting fallback to AAD supplied endpoint.") + self._log.warn(warn_template.format(mex_endpoint)) wstrust_endpoint = self._user_realm.federation_active_auth_url if not wstrust_endpoint: - raise AdalError('AAD did not return a WSTrust endpoint. Unable to proceed.') + raise AdalError('AAD did not return a WSTrust endpoint. Unable to proceed.') token = self._perform_username_password_for_access_token_exchange(wstrust_endpoint, username, password) return token @@ -230,7 +242,7 @@ def get_token_with_username_password(self, username, password): token = self._find_token_from_cache() if token: return token - except Exception as exp: + except AdalError as exp: self._log.warn('Attempt to look for token in cache resulted in Error: {}'.format(exp), True) self._user_realm = self._create_user_realm_request(username) @@ -242,10 +254,11 @@ def get_token_with_username_password(self, username, password): elif self._user_realm.account_type == ACCOUNT_TYPE['Federated']: token = self._get_token_username_password_federated(username, password) else: - raise AdalError(self._log.create_error("Server returned an unknown AccountType: {0}".format(self._user_realm.account_type))) + raise AdalError( + "Server returned an unknown AccountType: {0}".format(self._user_realm.account_type)) self._log.debug("Successfully retrieved token from authority.") - except Exception as exp: - self._log.warn("get_token_func returned with err".format(exp)) + except Exception: + self._log.info("get_token_func returned with error") raise self._cache_driver.add(token) @@ -257,7 +270,7 @@ def get_token_with_client_credentials(self, client_secret): token = self._find_token_from_cache() if token: return token - except Exception as exp: + except AdalError as exp: self._log.warn('Attempt to look for token in cache resulted in Error: {}'.format(exp), True) oauth_parameters = self._create_oauth_parameters(OAUTH2_GRANT_TYPE.CLIENT_CREDENTIALS) @@ -276,7 +289,6 @@ def get_token_with_authorization_code(self, authorization_code, client_secret): oauth_parameters[OAUTH2_PARAMETERS.CLIENT_SECRET] = client_secret token = self._oauth_get_token(oauth_parameters) - self._cache_driver.add(token) return token def _get_token_with_refresh_token(self, refresh_token, resource, client_secret): @@ -329,7 +341,7 @@ def get_token_with_certificate(self, certificate, thumbprint): token = self._find_token_from_cache() if token: return token - except Exception as exp: + except AdalError as exp: self._log.warn('Attempt to look for token in cache resulted in Error: {}'.format(exp), True) token = self._oauth_get_token(oauth_parameters) diff --git a/adal/user_realm.py b/adal/user_realm.py index 57624eda..2afa666e 100644 --- a/adal/user_realm.py +++ b/adal/user_realm.py @@ -25,7 +25,6 @@ # #------------------------------------------------------------------------------ import json -import requests try: from urllib.parse import quote, urlencode @@ -34,6 +33,8 @@ from urllib import quote, urlencode from urlparse import urlunparse +import requests + from . import constants from . import log from . import util @@ -61,17 +62,18 @@ def __init__(self, call_context, user_principle, authority_url): def _get_user_realm_url(self): - user_realm_url = list(util.copy_url(self._authority_url)) + url_components = list(util.copy_url(self._authority_url)) url_encoded_user = quote(self._user_principle, safe='~()*!.\'') - user_realm_url[2] = '/' + USER_REALM_PATH_TEMPLATE.replace('', url_encoded_user) + url_components[2] = '/' + USER_REALM_PATH_TEMPLATE.replace('', url_encoded_user) user_realm_query = {'api-version':self.api_version} - user_realm_url[4] = urlencode(user_realm_query) - user_realm_url = util.copy_url(urlunparse(user_realm_url)) + url_components[4] = urlencode(user_realm_query) + user_realm_url = util.copy_url(urlunparse(url_components)) return user_realm_url - def _validate_constant_value(self, constants, value, case_sensitive=False): + @staticmethod + def _validate_constant_value(value_dic, value, case_sensitive=False): if not value: return False @@ -79,13 +81,15 @@ def _validate_constant_value(self, constants, value, case_sensitive=False): if not case_sensitive: value = value.lower() - return value if value in constants.values() else False + return value if value in value_dic.values() else False - def _validate_account_type(self, type): - return self._validate_constant_value(ACCOUNT_TYPE, type) + @staticmethod + def _validate_account_type(account_type): + return UserRealm._validate_constant_value(ACCOUNT_TYPE, account_type) - def _validate_federation_protocol(self, protocol): - return self._validate_constant_value(FEDERATION_PROTOCOL_TYPE, protocol) + @staticmethod + def _validate_federation_protocol(protocol): + return UserRealm._validate_constant_value(FEDERATION_PROTOCOL_TYPE, protocol) def _log_parsed_response(self): @@ -102,19 +106,22 @@ def _parse_discovery_response(self, body): response = None try: response = json.loads(body) - except Exception as exp: - raise AdalError(self._log.create_error('Parsing realm discovery response JSON failed: {0}'.format(body))) + except ValueError: + error_template = ("Parsing realm discovery response JSON failed " + + "for body: '{}'") + self._log.info(error_template.format(body)) + raise - account_type = self._validate_account_type(response['account_type']) + account_type = UserRealm._validate_account_type(response['account_type']) if not account_type: - raise AdalError(self._log.create_error('Cannot parse account_type: {0}'.format(account_type))) + raise AdalError('Cannot parse account_type: {0}'.format(account_type)) self.account_type = account_type if self.account_type == ACCOUNT_TYPE['Federated']: - protocol = self._validate_federation_protocol(response['federation_protocol']) + protocol = UserRealm._validate_federation_protocol(response['federation_protocol']) if not protocol: - raise AdalError(self._log.create_error('Cannot parse federation protocol: {0}'.format(protocol))) + raise AdalError('Cannot parse federation protocol: {0}'.format(protocol)) self.federation_protocol = protocol self.federation_metadata_url = response['federation_metadata_url'] @@ -139,10 +146,10 @@ def discover(self): return_error_string += " and server response: {0}".format(resp.text) try: error_response = resp.json() - except: + except ValueError: pass - raise AdalError(self._log.create_error(return_error_string), error_response) + raise AdalError(return_error_string, error_response) else: self._parse_discovery_response(resp.text) diff --git a/adal/util.py b/adal/util.py index d35bc3df..19e3177e 100644 --- a/adal/util.py +++ b/adal/util.py @@ -28,18 +28,15 @@ import sys import platform import base64 - -from .constants import AdalIdParameters -import adal - try: - from urllib.parse import urlparse - except ImportError: - from urlparse import urlparse +import adal + +from .constants import AdalIdParameters + def is_http_success(status_code): return status_code >= 200 and status_code < 300 @@ -76,7 +73,9 @@ def create_request_options(self, *options): def log_return_correlation_id(log, operation_message, response): if response and response.headers and response.headers.get('client-request-id'): - log.info("{0} Server returned this correlation_id: {1}".format(operation_message, response.headers['client-request-id'])) + log.info("{0} Server returned this correlation_id: {1}".format( + operation_message, + response.headers['client-request-id'])) def copy_url(url_source): if hasattr(url_source, 'geturl'): @@ -88,6 +87,5 @@ def copy_url(url_source): # the string needs to be correctly padded before decoding. def base64_urlsafe_decode(b64string): b64string += '=' * (4 - ((len(b64string) % 4))) - - return base64.urlsafe_b64decode(b64string) + return base64.urlsafe_b64decode(b64string.encode('utf-8')) diff --git a/adal/wstrust_request.py b/adal/wstrust_request.py index b2152ff4..981c6d1d 100644 --- a/adal/wstrust_request.py +++ b/adal/wstrust_request.py @@ -25,14 +25,15 @@ # #------------------------------------------------------------------------------ -import requests import uuid -import time from datetime import datetime, timedelta +import requests + from . import log from . import util from . import wstrust_response +from .adal_error import AdalError class WSTrustRequest(object): @@ -41,15 +42,17 @@ def __init__(self, call_context, watrust_endpoint_url, applies_to): self._call_context = call_context self._wstrust_endpoint_url = watrust_endpoint_url self._applies_to = applies_to - - def _build_soap_message_credentials(self, username, password): + + @staticmethod + def _build_soap_message_credentials(username, password): username_token_xml = "\ {0}\ {1}\ ".format(username, password) return username_token_xml - def _build_security_header(self, username, password): + @staticmethod + def _build_security_header(username, password): time_now = datetime.utcnow() expire_time = time_now + timedelta(minutes=10) @@ -62,7 +65,7 @@ def _build_security_header(self, username, password): {0}\ {1}\ {2}".format(time_now_str, expire_time_str, - self._build_soap_message_credentials(username, password)) + WSTrustRequest._build_soap_message_credentials(username, password)) return security_header_xml def _build_rst(self, username, password): @@ -89,7 +92,7 @@ def _build_rst(self, username, password): http://docs.oasis-open.org/ws-sx/ws-trust/200512/Issue\ \ \ - ".format(message_id, self._wstrust_endpoint_url, self._build_security_header(username, password), self._applies_to) + ".format(message_id, self._wstrust_endpoint_url, WSTrustRequest._build_security_header(username, password), self._applies_to) return rst @@ -108,25 +111,20 @@ def acquire_token(self, username, password): self._log.debug("Sending RST to: {0}\n{1}".format(self._wstrust_endpoint_url, rst)) operation = "WS-Trust RST" - try: - resp = requests.post(self._wstrust_endpoint_url, headers=options['headers'], data=rst, allow_redirects=True) - - util.log_return_correlation_id(self._log, operation, resp) - - if not util.is_http_success(resp.status_code): - return_error_string = "{0} request returned http error: {1}".format(operation, resp.status_code) - error_response = "" - if resp.text: - return_error_string += " and server response: {0}".format(resp.text) - try: - error_response = resp.json() - except: - pass - - raise WsTokenRequestError(self._log.create_error(return_error_string), error_response) - else: - self._handle_rstr(resp.text) - - except Exception as exp: - self._log.error("{0} request failed".format(operation), exp) - raise + resp = requests.post(self._wstrust_endpoint_url, headers=options['headers'], data=rst, allow_redirects=True) + + util.log_return_correlation_id(self._log, operation, resp) + + if not util.is_http_success(resp.status_code): + return_error_string = "{0} request returned http error: {1}".format(operation, resp.status_code) + error_response = "" + if resp.text: + return_error_string += " and server response: {0}".format(resp.text) + try: + error_response = resp.json() + except ValueError: + pass + + raise AdalError(return_error_string, error_response) + else: + self._handle_rstr(resp.text) diff --git a/adal/wstrust_response.py b/adal/wstrust_response.py index 01385ed0..8ff3c865 100644 --- a/adal/wstrust_response.py +++ b/adal/wstrust_response.py @@ -32,6 +32,7 @@ from . import xmlutil from . import log +from .adal_error import AdalError class WSTrustResponse(object): @@ -142,28 +143,21 @@ def _parse_token(self): raise self._log.create_error("Unable to find any tokens in RSTR.") def parse(self): - if not self._response: - raise self._log.create_error("Received empty RSTR response body.") + raise AdalError("Received empty RSTR response body.") try: - try: - self._dom = ET.fromstring(self._response) - self._parents = {c:p for p in self._dom.iter() for c in p} - - except Exception as exp: - raise self._log.create_error("Failed to parse RSTR in to DOM", exp) - + self._dom = ET.fromstring(self._response) + self._parents = {c:p for p in self._dom.iter() for c in p} error_found = self._parse_error() - if error_found: - str_error_code = self.error_code if self.error_code else 'NONE' - str_fault_message = self.fault_message if self.fault_message else 'NONE' - raise self._log.create_error('Server returned error in RSTR - ErrorCode: {0} : FaultMessage: {1}'.format(str_error_code, str_fault_message)) - + str_error_code = self.error_code or 'NONE' + str_fault_message = self.fault_message or 'NONE' + error_template = 'Server returned error in RSTR - ErrorCode: {} : FaultMessage: {}' + raise AdalError(error_template.format(str_error_code, str_fault_message)) self._parse_token() - - except Exception as exp: + finally: + self._log.info("Failed to parse RSTR in to DOM") self._dom = None self._parents = None - raise + diff --git a/pylintrc b/pylintrc index 38c8f9a9..b2a3cc16 100644 --- a/pylintrc +++ b/pylintrc @@ -1,6 +1,5 @@ [MASTER] -profile=no ignore=.svn persistent=yes cache-size=500 @@ -32,7 +31,6 @@ disable=C0111,C0321,C0303,C0301,W0105,W0142,W0404,W0704,I0011,R0921 # Available formats are text, parseable, colorized, msvs (Visual Studio) and html output-format=msvs -include-ids=yes files-output=no reports=yes @@ -43,16 +41,11 @@ reports=yes # (R0004). evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) -# Add a comment according to your evaluation note. This is used by the global -# evaluation report (R0004). -comment=no - #enable-report= #disable-report= [BASIC] -required-attributes= no-docstring-rgx=__.*__ # Regular expression which should only match correct module names @@ -68,7 +61,7 @@ class-rgx=[a-zA-Z0-9_]+$ function-rgx=[a-zA-Z_][a-zA-Z0-9_]{2,30}$ # Regular expression which should only match correct method names -method-rgx=[a-z_][a-zA-Z0-9_]{2,50}$ +method-rgx=[a-z_][a-zA-Z0-9_]{2,60}$ # Regular expression which should only match correct instance attribute names attr-rgx=[a-z_][a-z0-9_]{1,30}$ diff --git a/sample/certificate_credentials_sample.py b/sample/certificate_credentials_sample.py new file mode 100644 index 00000000..e962fbf0 --- /dev/null +++ b/sample/certificate_credentials_sample.py @@ -0,0 +1,60 @@ +import json +import logging +import os +import sys +import adal + +def turn_on_logging(): + handler = logging.StreamHandler() + adal.set_logging_options({ + 'level': adal.LOGGING_LEVEL.DEBUG, + 'handler': handler + }) + +def get_private_key(filename): + with open(filename, 'r') as pem_file: + private_pem = pem_file.read() + return private_pem + +# +# You can provide account information by using a JSON file. Either +# through a command line argument, 'python sample.js parameters.json', or +# specifying in an environment variable of ADAL_SAMPLE_PARAMETERS_FILE. +# privateKeyFile must contain a PEM encoded cert with private key. +# thumbprint must be the thumbprint of the privateKeyFile. +# { +# "tenant" : "naturalcauses.onmicrosoft.com", +# "authorityHostUrl" : "https://login.microsoftonline.com", +# "clientId" : "d6835713-b745-48d1-bb62-7a8248477d35", +# "thumbprint" : 'C15DEA8656ADDF67BE8031D85EBDDC5AD6C436E1', +# "privateKeyFile" : 'ncwebCTKey.pem' +# } +parameters_file = (sys.argv[1] if len(sys.argv) == 2 else + os.environ.get('ADAL_SAMPLE_PARAMETERS_FILE')) +sample_parameters = {} +if parameters_file: + with open(parameters_file, 'r') as f: + parameters = f.read() + sample_parameters = json.loads(parameters) +else: + raise ValueError('Please provide parameter file with account information.') + + +authority_url = (sample_parameters['authorityHostUrl'] + '/' + + sample_parameters['tenant']) +RESOURCE = '00000002-0000-0000-c000-000000000000' + +#uncomment for verbose logging +#turn_on_logging() + +context = adal.AuthenticationContext(authority_url) +key = get_private_key(sample_parameters['privateKeyFile']) + +token = context.acquire_token_with_client_certificate( + RESOURCE, + sample_parameters['clientId'], + key, + sample_parameters['thumbprint']) + +print('Here is the token:') +print(json.dumps(token, indent=2)) diff --git a/sample/client_credentials_sample.py b/sample/client_credentials_sample.py new file mode 100644 index 00000000..2e3f826f --- /dev/null +++ b/sample/client_credentials_sample.py @@ -0,0 +1,49 @@ +import json +import logging +import os +import sys +import adal + +def turn_on_logging(): + handler = logging.StreamHandler() + adal.set_logging_options({ + 'level': adal.LOGGING_LEVEL.DEBUG, + 'handler': handler + }) + +# You can provide account information by using a JSON file. Either +# through a command line argument, 'python sample.js parameters.json', or +# specifying in an environment variable of ADAL_SAMPLE_PARAMETERS_FILE. +# { +# "tenant" : "rrandallaad1.onmicrosoft.com", +# "authorityHostUrl" : "https://login.microsoftonline.com", +# "clientId" : "624ac9bd-4c1c-4687-aec8-b56a8991cfb3", +# "clientSecret" : "verySecret="" +# } + +parameters_file = (sys.argv[1] if len(sys.argv) == 2 else + os.environ.get('ADAL_SAMPLE_PARAMETERS_FILE')) + +if parameters_file: + with open(parameters_file, 'r') as f: + parameters = f.read() + sample_parameters = json.loads(parameters) +else: + raise ValueError('Please provide parameter file with account information.') + +authority_url = (sample_parameters['authorityHostUrl'] + '/' + + sample_parameters['tenant']) +RESOURCE = '00000002-0000-0000-c000-000000000000' + +#uncomment for verbose log +#turn_on_logging() + +context = adal.AuthenticationContext(authority_url) + +token = context.acquire_token_with_client_credentials( + RESOURCE, + sample_parameters['clientId'], + sample_parameters['clientSecret']) + +print('Here is the token:') +print(json.dumps(token, indent=2)) diff --git a/sample/device_code_sample.py b/sample/device_code_sample.py new file mode 100644 index 00000000..0bcad368 --- /dev/null +++ b/sample/device_code_sample.py @@ -0,0 +1,62 @@ +import json +import logging +import os +import sys +import adal + +def turn_on_logging(): + handler = logging.StreamHandler() + adal.set_logging_options({ + 'level': adal.LOGGING_LEVEL.DEBUG, + 'handler': handler + }) + +# You can provide account information by using a JSON file +# with the same parameters as the sampleParameters variable below. Either +# through a command line argument, 'python sample.js parameters.json', or +# specifying in an environment variable of ADAL_SAMPLE_PARAMETERS_FILE. +# { +# "tenant" : "rrandallaad1.onmicrosoft.com", +# "authorityHostUrl" : "https://login.microsoftonline.com", +# "clientId" : "", +# "anothertenant" : "bar.onmicrosoft.com" +# } + +parameters_file = (sys.argv[1] if len(sys.argv) == 2 else + os.environ.get('ADAL_SAMPLE_PARAMETERS_FILE')) + +if parameters_file: + with open(parameters_file, 'r') as f: + parameters = f.read() + sample_parameters = json.loads(parameters) +else: + raise ValueError('Please provide parameter file with account information.') + + +authority_host_url = sample_parameters['authorityHostUrl'] +authority_url = authority_host_url + '/' + sample_parameters['tenant'] +clientid = sample_parameters['clientid'] +RESOURCE = '00000002-0000-0000-c000-000000000000' + +#uncomment for verbose logging +#turn_on_logging() + +context = adal.AuthenticationContext(authority_url) +code = context.acquire_user_code(RESOURCE, clientid) +print(code['message']) +token = context.acquire_token_with_device_code(RESOURCE, code, clientid) + +print('Here is the token from "{}":'.format(authority_url)) +print(json.dumps(token, indent=2)) + +#try cross tenant token refreshing +another_tenant = sample_parameters.get('anothertenant') +if another_tenant: + authority_url = authority_host_url + '/' + another_tenant + #reuse existing cache which has the tokens acquired early on + existing_cache = context.cache + context = adal.AuthenticationContext(authority_url, cache=existing_cache) + token = context.acquire_token(RESOURCE, token['userId'], clientid) + print('Here is the token from "{}":'.format(authority_url)) + print(json.dumps(token, indent=2)) + diff --git a/sample/refresh_token_sample.py b/sample/refresh_token_sample.py new file mode 100644 index 00000000..ff417789 --- /dev/null +++ b/sample/refresh_token_sample.py @@ -0,0 +1,60 @@ +import json +import logging +import os +import sys +import adal + +def turn_on_logging(): + handler = logging.StreamHandler() + adal.set_logging_options({ + 'level': adal.LOGGING_LEVEL.DEBUG, + 'handler': handler + }) + +# You can override the account information by using a JSON file. Either +# through a command line argument, 'python sample.js parameters.json', or +# specifying in an environment variable of ADAL_SAMPLE_PARAMETERS_FILE. +# { +# "tenant" : "rrandallaad1.onmicrosoft.com", +# "authorityHostUrl" : "https://login.microsoftonline.com", +# "clientId" : "624ac9bd-4c1c-4687-aec8-b56a8991cfb3", +# "username" : "user1", +# "password" : "verySecurePassword" +# } + +parameters_file = (sys.argv[1] if len(sys.argv) == 2 else + os.environ.get('ADAL_SAMPLE_PARAMETERS_FILE')) + +if parameters_file: + with open(parameters_file, 'r') as f: + parameters = f.read() + sample_parameters = json.loads(parameters) +else: + raise ValueError('Please provide parameter file with account information.') + +authority_url = (sample_parameters['authorityHostUrl'] + '/' + + sample_parameters['tenant']) +RESOURCE = '00000002-0000-0000-c000-000000000000' + +#uncomment for verbose log +#turn_on_logging() + +context = adal.AuthenticationContext(authority_url) + +token = context.acquire_token_with_username_password( + RESOURCE, + sample_parameters['username'], + sample_parameters['password'], + sample_parameters['clientid']) + +print('Here is the token') +print(json.dumps(token, indent=2)) + +refresh_token = token['refreshToken'] +token = context.acquire_token_with_refresh_token( + refresh_token, + sample_parameters['clientid'], + RESOURCE) + +print('Here is the token acquired from the refreshing token') +print(json.dumps(token, indent=2)) diff --git a/sample/website_sample.py b/sample/website_sample.py new file mode 100644 index 00000000..5187c0c8 --- /dev/null +++ b/sample/website_sample.py @@ -0,0 +1,135 @@ +try: + from http import server as httpserver + from http import cookies as Cookie +except ImportError: + import SimpleHTTPServer as httpserver + import Cookie as Cookie + +try: + import socketserver +except ImportError: + import SocketServer as socketserver + +try: + from urllib.parse import urlparse, parse_qs +except ImportError: + from urlparse import urlparse, parse_qs + +import json +import os +import random +import string +import sys + +from adal import AuthenticationContext + +# You can provide account information by using a JSON file. Either +# through a command line argument, 'python sample.js parameters.json', or +# specifying in an environment variable of ADAL_SAMPLE_PARAMETERS_FILE. +# { +# "tenant" : "rrandallaad1.onmicrosoft.com", +# "authorityHostUrl" : "https://login.microsoftonline.com", +# "clientId" : "624ac9bd-4c1c-4687-aec8-b56a8991cfb3", +# "clientSecret" : "verySecret="" +# } + +parameters_file = (sys.argv[1] if len(sys.argv) == 2 else + os.environ.get('ADAL_SAMPLE_PARAMETERS_FILE')) + +if parameters_file: + with open(parameters_file, 'r') as f: + parameters = f.read() + sample_parameters = json.loads(parameters) +else: + raise ValueError('Please provide parameter file with account information.') + +PORT = 8088 +TEMPLATE_AUTHZ_URL = ('https://login.windows.net/{}/oauth2/authorize?'+ + 'response_type=code&client_id={}&redirect_uri={}&'+ + 'state={}&resource={}') +RESOURCE = '00000002-0000-0000-c000-000000000000' #Graph Resource +REDIRECT_URI = 'http://localhost:{}/getAToken'.format(PORT) + +authority_url = (sample_parameters['authorityHostUrl'] + '/' + + sample_parameters['tenant']) + +class OAuth2RequestHandler(httpserver.SimpleHTTPRequestHandler): + def do_GET(self): + if self.path == '/': + self.send_response(307) + login_url = 'http://localhost:{}/login'.format(PORT) + self.send_header('Location', login_url) + self.end_headers() + elif self.path == '/login': + auth_state = (''.join(random.SystemRandom() + .choice(string.ascii_uppercase + string.digits) + for _ in range(48))) + cookie = Cookie.SimpleCookie() + cookie['auth_state'] = auth_state + authorization_url = TEMPLATE_AUTHZ_URL.format( + sample_parameters['tenant'], + sample_parameters['clientId'], + REDIRECT_URI, + auth_state, + RESOURCE) + self.send_response(307) + self.send_header('Set-Cookie', cookie.output(header='')) + self.send_header('Location', authorization_url) + self.end_headers() + elif self.path.startswith('/getAToken'): + message = None + is_ok = True + try: + token_response = self._acquire_token() + message = 'response: ' + json.dumps(token_response) + #Later, if the access token is expired it can be refreshed. + auth_context = AuthenticationContext(authority_url) + token_response = auth_context.acquire_token_with_refresh_token( + token_response['refreshToken'], + sample_parameters['clientId'], + sample_parameters['clientSecret'], + RESOURCE) + message = (message + '*** And here is the refresh response:' + + json.dumps(token_response)) + except ValueError as exp: + message = str(exp) + is_ok = False + self._send_response(message, is_ok) + + def _acquire_token(self): + parsed = urlparse(self.path) + code = parse_qs(parsed.query)['code'][0] + state = parse_qs(parsed.query)['state'][0] + cookie = Cookie.SimpleCookie(self.headers["Cookie"]) + if state != cookie['auth_state'].value: + raise ValueError('state does not match') + auth_context = AuthenticationContext(authority_url) + token = auth_context.acquire_token_with_authorization_code( + code, + REDIRECT_URI, + RESOURCE, + sample_parameters['clientId'], + sample_parameters['clientSecret']) + return token + + def _send_response(self, message, is_ok=True): + self.send_response(200 if is_ok else 400) + self.send_header('Content-type', 'text/html') + self.end_headers() + + if is_ok: + #todo, pretty format token response in json + message_template = ('Succeeded' + + '

{}

') + else: + message_template = ('Failed' + + '

{}

') + + output = message_template.format(message) + self.wfile.write(output.encode()) + +httpd = socketserver.TCPServer(('', PORT), OAuth2RequestHandler) + +print('serving at port', PORT) +httpd.serve_forever() + diff --git a/setup.py b/setup.py index 1b700720..a5a31503 100644 --- a/setup.py +++ b/setup.py @@ -44,8 +44,10 @@ setup( name='adal', - version='0.2.0', - description='The ADAL for Python library makes it easy for python application to authenticate to Azure Active Directory (AAD) in order to access AAD protected web resources.', + version='1.0.0rc1', #note, same string exists in __init__.py + description=('The ADAL for Python library makes it easy for python ' + + 'application to authenticate to Azure Active Directory ' + + '(AAD) in order to access AAD protected web resources.'), license='MIT', author='Microsoft Corporation', author_email='nugetaad@microsoft.com',