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