1#------------------------------------------------------------------------------ 2# 3# Copyright (c) Microsoft Corporation. 4# All rights reserved. 5# 6# This code is licensed under the MIT License. 7# 8# Permission is hereby granted, free of charge, to any person obtaining a copy 9# of this software and associated documentation files(the "Software"), to deal 10# in the Software without restriction, including without limitation the rights 11# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell 12# copies of the Software, and to permit persons to whom the Software is 13# furnished to do so, subject to the following conditions : 14# 15# The above copyright notice and this permission notice shall be included in 16# all copies or substantial portions of the Software. 17# 18# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE 21# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 24# THE SOFTWARE. 25# 26#------------------------------------------------------------------------------ 27 28#Note, this module does not appear being used anywhere 29 30import re 31 32import requests 33 34from . import util 35from . import log 36 37from .constants import HttpError 38 39AUTHORIZATION_URI = 'authorization_uri' 40RESOURCE = 'resource' 41WWW_AUTHENTICATE_HEADER = 'www-authenticate' 42 43# pylint: disable=anomalous-backslash-in-string,too-few-public-methods 44 45class AuthenticationParameters(object): 46 47 def __init__(self, authorization_uri, resource): 48 49 self.authorization_uri = authorization_uri 50 self.resource = resource 51 52 53# The 401 challenge is a standard defined in RFC6750, which is based in part on RFC2617. 54# The challenge has the following form. 55# WWW-Authenticate : Bearer 56# authorization_uri="https://login.microsoftonline.com/mytenant.com/oauth2/authorize", 57# Resource_id="00000002-0000-0000-c000-000000000000" 58 59# This regex is used to validate the structure of the challenge header. 60# Match whole structure: ^\s*Bearer\s+([^,\s="]+?)="([^"]*?)"\s*(,\s*([^,\s="]+?)="([^"]*?)"\s*)*$ 61# ^ Start at the beginning of the string. 62# \s*Bearer\s+ Match 'Bearer' surrounded by one or more amount of whitespace. 63# ([^,\s="]+?) This captures the key which is composed of any characters except 64# comma, whitespace or a quotes. 65# = Match the = sign. 66# "([^"]*?)" Captures the value can be any number of non quote characters. 67# At this point only the first key value pair as been captured. 68# \s* There can be any amount of white space after the first key value pair. 69# ( Start a capture group to retrieve the rest of the key value 70# pairs that are separated by commas. 71# \s* There can be any amount of whitespace before the comma. 72# , There must be a comma. 73# \s* There can be any amount of whitespace after the comma. 74# (([^,\s="]+?) This will capture the key that comes after the comma. It's made 75# of a series of any character except comma, whitespace or quotes. 76# = Match the equal sign between the key and value. 77# " Match the opening quote of the value. 78# ([^"]*?) This will capture the value which can be any number of non 79# quote characters. 80# " Match the values closing quote. 81# \s* There can be any amount of whitespace before the next comma. 82# )* Close the capture group for key value pairs. There can be any 83# number of these. 84# $ The rest of the string can be whitespace but nothing else up to 85# the end of the string. 86# 87 88# This regex checks the structure of the whole challenge header. The complete 89# header needs to be checked for validity before we can be certain that 90# we will succeed in pulling out the individual parts. 91bearer_challenge_structure_validation = re.compile( 92 """^\s*Bearer\s+([^,\s="]+?)="([^"]*?)"\s*(,\s*([^,\s="]+?)="([^"]*?)"\s*)*$""") 93# This regex pulls out the key and value from the very first pair. 94first_key_value_pair_regex = re.compile("""^\s*Bearer\s+([^,\s="]+?)="([^"]*?)"\s*""") 95 96# This regex is used to pull out all of the key value pairs after the first one. 97# All of these begin with a comma. 98all_other_key_value_pair_regex = re.compile("""(?:,\s*([^,\s="]+?)="([^"]*?)"\s*)""") 99 100 101def parse_challenge(challenge): 102 103 if not bearer_challenge_structure_validation.search(challenge): 104 raise ValueError("The challenge is not parseable as an RFC6750 OAuth2 challenge") 105 106 challenge_parameters = {} 107 match = first_key_value_pair_regex.search(challenge) 108 if match: 109 challenge_parameters[match.group(1)] = match.group(2) 110 111 for match in all_other_key_value_pair_regex.finditer(challenge): 112 challenge_parameters[match.group(1)] = match.group(2) 113 114 return challenge_parameters 115 116def create_authentication_parameters_from_header(challenge): 117 challenge_parameters = parse_challenge(challenge) 118 authorization_uri = challenge_parameters.get(AUTHORIZATION_URI) 119 120 if not authorization_uri: 121 raise ValueError("Could not find 'authorization_uri' in challenge header.") 122 123 resource = challenge_parameters.get(RESOURCE) 124 return AuthenticationParameters(authorization_uri, resource) 125 126def create_authentication_parameters_from_response(response): 127 128 if response is None: 129 raise AttributeError('Missing required parameter: response') 130 131 if not hasattr(response, 'status_code') or not response.status_code: 132 raise AttributeError('The response parameter does not have the expected HTTP status_code field') 133 134 if not hasattr(response, 'headers') or not response.headers: 135 raise AttributeError('There were no headers found in the response.') 136 137 if response.status_code != HttpError.UNAUTHORIZED: 138 raise ValueError('The response status code does not correspond to an OAuth challenge. ' 139 'The statusCode is expected to be 401 but is: {}'.format(response.status_code)) 140 141 challenge = response.headers.get(WWW_AUTHENTICATE_HEADER) 142 if not challenge: 143 raise ValueError("The response does not contain a WWW-Authenticate header that can be " 144 "used to determine the authority_uri and resource.") 145 146 return create_authentication_parameters_from_header(challenge) 147 148def validate_url_object(url): 149 if not url or not hasattr(url, 'geturl'): 150 raise AttributeError('Parameter is of wrong type: url') 151 152def create_authentication_parameters_from_url(url, correlation_id=None): 153 154 if isinstance(url, str): 155 challenge_url = url 156 else: 157 validate_url_object(url) 158 challenge_url = url.geturl() 159 160 log_context = log.create_log_context(correlation_id) 161 logger = log.Logger('AuthenticationParameters', log_context) 162 163 logger.debug( 164 "Attempting to retrieve authentication parameters from: {}".format(challenge_url) 165 ) 166 167 class _options(object): 168 _call_context = {'log_context': log_context} 169 170 options = util.create_request_options(_options()) 171 try: 172 response = requests.get(challenge_url, headers=options['headers']) 173 except Exception: 174 logger.info("Authentication parameters http get failed.") 175 raise 176 177 try: 178 return create_authentication_parameters_from_response(response) 179 except Exception: 180 logger.info("Unable to parse response in to authentication parameters.") 181 raise 182