Skip to content

Commit 2349f5d

Browse files
committed
Merge branch 'federated' into dev
2 parents 6e2e5e3 + 82e8eda commit 2349f5d

File tree

11 files changed

+3314
-2
lines changed

11 files changed

+3314
-2
lines changed

msal/application.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,19 @@
44
except: # Python 3
55
from urllib.parse import urljoin
66
import logging
7+
from base64 import b64encode
78

89
from oauth2cli import Client
910
from .authority import Authority
1011
from .assertion import create_jwt_assertion
12+
import mex
13+
import wstrust_request
14+
from .wstrust_response import SAML_TOKEN_TYPE_V1, SAML_TOKEN_TYPE_V2
1115
from .token_cache import TokenCache
1216

1317

18+
logger = logging.getLogger(__name__)
19+
1420
def decorate_scope(
1521
scope, client_id,
1622
policy=None, # obsolete
@@ -268,9 +274,40 @@ class PublicClientApplication(ClientApplication): # browser app or mobile app
268274
def acquire_token_with_username_password(
269275
self, username, password, scope=None, **kwargs):
270276
"""Gets a token for a given resource via user credentails."""
277+
scope = decorate_scope(scope, self.client_id)
278+
if not self.authority.is_adfs:
279+
user_realm_result = self.authority.user_realm_discovery(username)
280+
if user_realm_result.get("account_type") == "Federated":
281+
return self._acquire_token_with_username_password_federated(
282+
user_realm_result, username, password, scope=scope, **kwargs)
271283
return self.client.obtain_token_with_username_password(
272-
username, password,
273-
scope=decorate_scope(scope, self.client_id), **kwargs)
284+
username, password, scope=scope, **kwargs)
285+
286+
def _acquire_token_with_username_password_federated(
287+
self, user_realm_result, username, password, scope=None, **kwargs):
288+
wstrust_endpoint = {}
289+
if user_realm_result.get("federation_metadata_url"):
290+
wstrust_endpoint = mex.send_request(
291+
user_realm_result["federation_metadata_url"])
292+
logger.debug("wstrust_endpoint = %s", wstrust_endpoint)
293+
wstrust_result = wstrust_request.send_request(
294+
username, password, user_realm_result.get("cloud_audience_urn"),
295+
wstrust_endpoint.get("address",
296+
# Fallback to an AAD supplied endpoint
297+
user_realm_result.get("federation_active_auth_url")),
298+
wstrust_endpoint.get("action"), **kwargs)
299+
if not ("token" in wstrust_result and "type" in wstrust_result):
300+
raise RuntimeError("Unsuccessful RSTR. %s" % wstrust_result)
301+
grant_type = {
302+
SAML_TOKEN_TYPE_V1: 'urn:ietf:params:oauth:grant-type:saml1_1-bearer',
303+
SAML_TOKEN_TYPE_V2: self.client.GRANT_TYPE_SAML2,
304+
}.get(wstrust_result.get("type"))
305+
if not grant_type:
306+
raise RuntimeError(
307+
"RSTR returned unknown token type: %s", wstrust_result.get("type"))
308+
return self.client.obtain_token_with_assertion(
309+
b64encode(wstrust_result["token"]),
310+
grant_type=grant_type, scope=scope, **kwargs)
274311

275312
def acquire_token(
276313
self,

msal/authority.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,18 @@ def __init__(self, authority_url, validate_authority=True):
4141
self.authorization_endpoint = openid_config['authorization_endpoint']
4242
self.token_endpoint = openid_config['token_endpoint']
4343
_, _, self.tenant = canonicalize(self.token_endpoint) # Usually a GUID
44+
self.is_adfs = self.tenant.lower() == 'adfs'
45+
46+
def user_realm_discovery(self, username, **kwargs):
47+
resp = requests.get(
48+
"https://{netloc}/common/userrealm/{username}?api-version=1.0".format(
49+
netloc=self.instance, username=username),
50+
headers={'Accept':'application/json'}, **kwargs)
51+
resp.raise_for_status()
52+
return resp.json()
53+
# It will typically contain "ver", "account_type",
54+
# "federation_protocol", "cloud_audience_urn",
55+
# "federation_metadata_url", "federation_active_auth_url", etc.
4456

4557
def canonicalize(url):
4658
# Returns (canonicalized_url, netloc, tenant). Raises ValueError on errors.

msal/mex.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
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+
try:
29+
from urllib.parse import urlparse
30+
except:
31+
from urlparse import urlparse
32+
try:
33+
from xml.etree import cElementTree as ET
34+
except ImportError:
35+
from xml.etree import ElementTree as ET
36+
37+
import requests
38+
39+
40+
def _xpath_of_root(route_to_leaf):
41+
# Construct an xpath suitable to find a root node which has a specified leaf
42+
return '/'.join(route_to_leaf + ['..'] * (len(route_to_leaf)-1))
43+
44+
def send_request(mex_endpoint, **kwargs):
45+
mex_document = requests.get(
46+
mex_endpoint, headers={'Content-Type': 'application/soap+xml'},
47+
**kwargs).text
48+
return Mex(mex_document).get_wstrust_username_password_endpoint()
49+
50+
51+
class Mex(object):
52+
53+
NS = { # Also used by wstrust_*.py
54+
'wsdl': 'http://schemas.xmlsoap.org/wsdl/',
55+
'sp': 'http://docs.oasis-open.org/ws-sx/ws-securitypolicy/200702',
56+
'sp2005': 'http://schemas.xmlsoap.org/ws/2005/07/securitypolicy',
57+
'wsu': 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd',
58+
'wsa': 'http://www.w3.org/2005/08/addressing', # Duplicate?
59+
'wsa10': 'http://www.w3.org/2005/08/addressing',
60+
'http': 'http://schemas.microsoft.com/ws/06/2004/policy/http',
61+
'soap12': 'http://schemas.xmlsoap.org/wsdl/soap12/',
62+
'wsp': 'http://schemas.xmlsoap.org/ws/2004/09/policy',
63+
's': 'http://www.w3.org/2003/05/soap-envelope',
64+
'wst': 'http://docs.oasis-open.org/ws-sx/ws-trust/200512',
65+
'trust': "http://docs.oasis-open.org/ws-sx/ws-trust/200512", # Duplicate?
66+
'saml': "urn:oasis:names:tc:SAML:1.0:assertion",
67+
'wst2005': 'http://schemas.xmlsoap.org/ws/2005/02/trust', # was named "t"
68+
}
69+
ACTION_13 = 'http://docs.oasis-open.org/ws-sx/ws-trust/200512/RST/Issue'
70+
ACTION_2005 = 'http://schemas.xmlsoap.org/ws/2005/02/trust/RST/Issue'
71+
72+
def __init__(self, mex_document):
73+
self.dom = ET.fromstring(mex_document)
74+
75+
def _get_policy_ids(self, components_to_leaf, binding_xpath):
76+
id_attr = '{%s}Id' % self.NS['wsu']
77+
return set(["#{}".format(policy.get(id_attr))
78+
for policy in self.dom.findall(_xpath_of_root(components_to_leaf), self.NS)
79+
# If we did not find any binding, this is potentially bad.
80+
if policy.find(binding_xpath, self.NS) is not None])
81+
82+
def _get_username_password_policy_ids(self):
83+
path = ['wsp:Policy', 'wsp:ExactlyOne', 'wsp:All',
84+
'sp:SignedEncryptedSupportingTokens', 'wsp:Policy',
85+
'sp:UsernameToken', 'wsp:Policy', 'sp:WssUsernameToken10']
86+
policies = self._get_policy_ids(path, './/sp:TransportBinding')
87+
path2005 = ['wsp:Policy', 'wsp:ExactlyOne', 'wsp:All',
88+
'sp2005:SignedSupportingTokens', 'wsp:Policy',
89+
'sp2005:UsernameToken', 'wsp:Policy', 'sp2005:WssUsernameToken10']
90+
policies.update(self._get_policy_ids(path2005, './/sp2005:TransportBinding'))
91+
return policies
92+
93+
def _get_iwa_policy_ids(self):
94+
return self._get_policy_ids(
95+
['wsp:Policy', 'wsp:ExactlyOne', 'wsp:All', 'http:NegotiateAuthentication'],
96+
'.//sp2005:TransportBinding')
97+
98+
def _get_bindings(self):
99+
bindings = {} # {binding_name: {"policy_uri": "...", "version": "..."}}
100+
for binding in self.dom.findall("wsdl:binding", self.NS):
101+
if (binding.find('soap12:binding', self.NS).get("transport") !=
102+
'http://schemas.xmlsoap.org/soap/http'):
103+
continue
104+
action = binding.find(
105+
'wsdl:operation/soap12:operation', self.NS).get("soapAction")
106+
for pr in binding.findall("wsp:PolicyReference", self.NS):
107+
bindings[binding.get("name")] = {
108+
"policy_uri": pr.get("URI"), "action": action}
109+
return bindings
110+
111+
def _get_endpoints(self, bindings, policy_ids):
112+
endpoints = []
113+
for port in self.dom.findall('wsdl:service/wsdl:port', self.NS):
114+
binding_name = port.get("binding").split(':')[-1] # Should have 2 parts
115+
binding = bindings.get(binding_name)
116+
if binding and binding["policy_uri"] in policy_ids:
117+
address = port.find('wsa10:EndpointReference/wsa10:Address', self.NS)
118+
if address is not None and address.text.lower().startswith("https://"):
119+
endpoints.append(
120+
{"address": address.text, "action": binding["action"]})
121+
return endpoints
122+
123+
def get_wstrust_username_password_endpoint(self):
124+
"""Returns {"address": "https://...", "action": "the soapAction value"}"""
125+
endpoints = self._get_endpoints(
126+
self._get_bindings(), self._get_username_password_policy_ids())
127+
for e in endpoints:
128+
if e["action"] == self.ACTION_13:
129+
return e # Historically, we prefer ACTION_13 a.k.a. WsTrust13
130+
return endpoints[0] if endpoints else None
131+

msal/wstrust_request.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
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+
import uuid
29+
from datetime import datetime, timedelta
30+
import re
31+
import logging
32+
33+
import requests
34+
35+
from .mex import Mex
36+
import wstrust_response
37+
38+
39+
logger = logging.getLogger(__file__)
40+
41+
def send_request(
42+
username, password, cloud_audience_urn, endpoint_address, soap_action,
43+
**kwargs):
44+
if not endpoint_address:
45+
raise ValueError("WsTrust endpoint address can not be empty")
46+
if soap_action is None:
47+
wstrust2005_regex = r'[/trust]?[2005][/usernamemixed]?'
48+
wstrust13_regex = r'[/trust]?[13][/usernamemixed]?'
49+
if re.search(wstrust2005_regex, endpoint_address):
50+
soap_action = Mex.ACTION_2005
51+
elif re.search(wstrust13_regex, endpoint_address):
52+
soap_action = Mex.ACTION_13
53+
assert soap_action in (Mex.ACTION_13, Mex.ACTION_2005) # A loose check here
54+
data = _build_rst(
55+
username, password, cloud_audience_urn, endpoint_address, soap_action)
56+
resp = requests.post(endpoint_address, data=data, headers={
57+
'Content-type':'application/soap+xml; charset=utf-8',
58+
'SOAPAction': soap_action,
59+
}, **kwargs)
60+
if resp.status_code >= 400:
61+
logger.debug("Unsuccessful WsTrust request receives: %s", resp.text)
62+
# It turns out ADFS uses 5xx status code even with client-side incorrect password error
63+
# resp.raise_for_status()
64+
return wstrust_response.parse_response(resp.text)
65+
66+
def escape_password(password):
67+
return (password.replace('&', '&').replace('"', '"')
68+
.replace("'", ''') # the only one not provided by cgi.escape(s, True)
69+
.replace('<', '&lt;').replace('>', '&gt;'))
70+
71+
def wsu_time_format(datetime_obj):
72+
# WsTrust (http://docs.oasis-open.org/ws-sx/ws-trust/v1.4/ws-trust.html)
73+
# does not seem to define timestamp format, but we see YYYY-mm-ddTHH:MM:SSZ
74+
# here (https://www.ibm.com/developerworks/websphere/library/techarticles/1003_chades/1003_chades.html)
75+
# It avoids the uncertainty of the optional ".ssssss" in datetime.isoformat()
76+
# https://docs.python.org/2/library/datetime.html#datetime.datetime.isoformat
77+
return datetime_obj.strftime('%Y-%m-%dT%H:%M:%SZ')
78+
79+
def _build_rst(username, password, cloud_audience_urn, endpoint_address, soap_action):
80+
now = datetime.utcnow()
81+
return """<s:Envelope xmlns:s='{s}' xmlns:wsa='{wsa}' xmlns:wsu='{wsu}'>
82+
<s:Header>
83+
<wsa:Action s:mustUnderstand='1'>{soap_action}</wsa:Action>
84+
<wsa:messageID>urn:uuid:{message_id}</wsa:messageID>
85+
<wsa:ReplyTo>
86+
<wsa:Address>http://www.w3.org/2005/08/addressing/anonymous</wsa:Address>
87+
</wsa:ReplyTo>
88+
<wsa:To s:mustUnderstand='1'>{endpoint_address}</wsa:To>
89+
90+
<wsse:Security s:mustUnderstand='1'
91+
xmlns:wsse='http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd'>
92+
<wsu:Timestamp wsu:Id='_0'>
93+
<wsu:Created>{time_now}</wsu:Created>
94+
<wsu:Expires>{time_expire}</wsu:Expires>
95+
</wsu:Timestamp>
96+
<wsse:UsernameToken wsu:Id='ADALUsernameToken'>
97+
<wsse:Username>{username}</wsse:Username>
98+
<wsse:Password>{password}</wsse:Password>
99+
</wsse:UsernameToken>
100+
</wsse:Security>
101+
102+
</s:Header>
103+
<s:Body>
104+
<wst:RequestSecurityToken xmlns:wst='{wst}'>
105+
<wsp:AppliesTo xmlns:wsp='http://schemas.xmlsoap.org/ws/2004/09/policy'>
106+
<wsa:EndpointReference>
107+
<wsa:Address>{applies_to}</wsa:Address>
108+
</wsa:EndpointReference>
109+
</wsp:AppliesTo>
110+
<wst:KeyType>{key_type}</wst:KeyType>
111+
<wst:RequestType>{request_type}</wst:RequestType>
112+
</wst:RequestSecurityToken>
113+
</s:Body>
114+
</s:Envelope>""".format(
115+
s=Mex.NS["s"], wsu=Mex.NS["wsu"], wsa=Mex.NS["wsa10"],
116+
soap_action=soap_action, message_id=str(uuid.uuid4()),
117+
endpoint_address=endpoint_address,
118+
time_now=wsu_time_format(now),
119+
time_expire=wsu_time_format(now + timedelta(minutes=10)),
120+
username=username, password=escape_password(password),
121+
wst=Mex.NS["wst"] if soap_action == Mex.ACTION_13 else Mex.NS["wst2005"],
122+
applies_to=cloud_audience_urn,
123+
key_type='http://docs.oasis-open.org/ws-sx/ws-trust/200512/Bearer'
124+
if soap_action == Mex.ACTION_13 else
125+
'http://schemas.xmlsoap.org/ws/2005/05/identity/NoProofKey',
126+
request_type='http://docs.oasis-open.org/ws-sx/ws-trust/200512/Issue'
127+
if soap_action == Mex.ACTION_13 else
128+
'http://schemas.xmlsoap.org/ws/2005/02/trust/Issue',
129+
)
130+

0 commit comments

Comments
 (0)