Skip to content

Commit 21a04ee

Browse files
committed
feat: add integrated windows authentication support under public clients
1 parent 7db6c2c commit 21a04ee

File tree

3 files changed

+123
-16
lines changed

3 files changed

+123
-16
lines changed

msal/application.py

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from .oauth2cli import Client, JwtAssertionCreator
1212
from .oauth2cli.oidc import decode_part
1313
from .authority import Authority, WORLD_WIDE
14-
from .mex import send_request as mex_send_request
14+
from .mex import send_request as mex_send_request, send_request_iwa as mex_send_request_iwa
1515
from .wstrust_request import send_request as wst_send_request
1616
from .wstrust_response import *
1717
from .token_cache import TokenCache, _get_username, _GRANT_TYPE_BROKER
@@ -222,6 +222,7 @@ class ClientApplication(object):
222222
ACQUIRE_TOKEN_FOR_CLIENT_ID = "730"
223223
ACQUIRE_TOKEN_BY_AUTHORIZATION_CODE_ID = "832"
224224
ACQUIRE_TOKEN_INTERACTIVE = "169"
225+
ACQUIRE_TOKEN_INTEGRATED_WINDOWS_AUTH_ID = "870"
225226
GET_ACCOUNTS_ID = "902"
226227
REMOVE_ACCOUNT_ID = "903"
227228

@@ -2334,6 +2335,78 @@ def acquire_token_by_device_flow(self, flow, claims_challenge=None, **kwargs):
23342335
telemetry_context.update_telemetry(response)
23352336
return response
23362337

2338+
def acquire_token_integrated_windows_auth(self, username, scopes="openid", **kwargs):
2339+
"""Gets a token for a given resource via Integrated Windows Authentication (IWA).
2340+
2341+
:param str username: Typically a UPN in the form of an email address.
2342+
:param str scopes: Scopes requested to access a protected API (a resource).
2343+
2344+
:return: A dict representing the json response from AAD:
2345+
2346+
- A successful response would contain "access_token" key,
2347+
- an error response would contain "error" and usually "error_description".
2348+
"""
2349+
telemetry_context = self._build_telemetry_context(
2350+
self.ACQUIRE_TOKEN_INTEGRATED_WINDOWS_AUTH_ID)
2351+
headers = telemetry_context.generate_headers()
2352+
user_realm_result = self.authority.user_realm_discovery(
2353+
username,
2354+
correlation_id=headers[msal.telemetry.CLIENT_REQUEST_ID]
2355+
)
2356+
if user_realm_result.get("account_type") != "Federated":
2357+
raise ValueError("Server returned an unknown account type: %s" % user_realm_result.get("account_type"))
2358+
response = _clean_up(self._acquire_token_by_iwa_federated(user_realm_result, username, scopes, **kwargs))
2359+
if response is None: # Either ADFS or not federated
2360+
raise ValueError("Integrated Windows Authentication failed for this user: %s", username)
2361+
if "access_token" in response:
2362+
response[self._TOKEN_SOURCE] = self._TOKEN_SOURCE_IDP
2363+
telemetry_context.update_telemetry(response)
2364+
return response
2365+
2366+
def _acquire_token_by_iwa_federated(
2367+
self, user_realm_result, username, scopes="openid", **kwargs):
2368+
wstrust_endpoint = {}
2369+
if user_realm_result.get("federation_metadata_url"):
2370+
mex_endpoint = user_realm_result.get("federation_metadata_url")
2371+
logger.debug(
2372+
"Attempting mex at: %(mex_endpoint)s",
2373+
{"mex_endpoint": mex_endpoint})
2374+
wstrust_endpoint = mex_send_request_iwa(mex_endpoint, self.http_client)
2375+
if wstrust_endpoint is None:
2376+
raise ValueError("Unable to find wstrust endpoint from MEX. "
2377+
"This typically happens when attempting MSA accounts. "
2378+
"More details available here. "
2379+
"https://github.com/AzureAD/microsoft-authentication-library-for-python/wiki/Username-Password-Authentication")
2380+
logger.debug("wstrust_endpoint = %s", wstrust_endpoint)
2381+
wstrust_result = wst_send_request(
2382+
None, None,
2383+
user_realm_result.get("cloud_audience_urn", "urn:federation:MicrosoftOnline"),
2384+
wstrust_endpoint.get("address",
2385+
# Fallback to an AAD supplied endpoint
2386+
user_realm_result.get("federation_active_auth_url")),
2387+
wstrust_endpoint.get("action"), self.http_client)
2388+
if not ("token" in wstrust_result and "type" in wstrust_result):
2389+
raise RuntimeError("Unsuccessful RSTR. %s" % wstrust_result)
2390+
GRANT_TYPE_SAML1_1 = 'urn:ietf:params:oauth:grant-type:saml1_1-bearer'
2391+
grant_type = {
2392+
SAML_TOKEN_TYPE_V1: GRANT_TYPE_SAML1_1,
2393+
SAML_TOKEN_TYPE_V2: self.client.GRANT_TYPE_SAML2,
2394+
WSS_SAML_TOKEN_PROFILE_V1_1: GRANT_TYPE_SAML1_1,
2395+
WSS_SAML_TOKEN_PROFILE_V2: self.client.GRANT_TYPE_SAML2
2396+
}.get(wstrust_result.get("type"))
2397+
if not grant_type:
2398+
raise RuntimeError(
2399+
"RSTR returned unknown token type: %s", wstrust_result.get("type"))
2400+
self.client.grant_assertion_encoders.setdefault( # Register a non-standard type
2401+
grant_type, self.client.encode_saml_assertion)
2402+
return self.client.obtain_token_by_assertion(
2403+
wstrust_result["token"], grant_type, scope=scopes,
2404+
on_obtaining_tokens=lambda event: self.token_cache.add(dict(
2405+
event,
2406+
environment=self.authority.instance,
2407+
username=username, # Useful in case IDT contains no such info
2408+
)),
2409+
**kwargs)
23372410

23382411
class ConfidentialClientApplication(ClientApplication): # server-side web app
23392412
"""Same as :func:`ClientApplication.__init__`,

msal/mex.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,16 @@ def send_request(mex_endpoint, http_client, **kwargs):
5353
"Malformed MEX document: %s, %s", mex_resp.status_code, mex_resp.text)
5454
raise
5555

56+
def send_request_iwa(mex_endpoint, http_client, **kwargs):
57+
mex_resp = http_client.get(mex_endpoint, **kwargs)
58+
mex_resp.raise_for_status()
59+
try:
60+
return Mex(mex_resp.text).get_wstrust_iwa_endpoint()
61+
except ET.ParseError:
62+
logger.exception(
63+
"Malformed MEX document: %s, %s", mex_resp.status_code, mex_resp.text)
64+
raise
65+
5666

5767
class Mex(object):
5868

@@ -126,6 +136,14 @@ def _get_endpoints(self, bindings, policy_ids):
126136
{"address": address.text, "action": binding["action"]})
127137
return endpoints
128138

139+
def get_wstrust_iwa_endpoint(self):
140+
"""Returns {"address": "https://...", "action": "the soapAction value"}"""
141+
endpoints = self._get_endpoints(
142+
self._get_bindings(), self._get_iwa_policy_ids())
143+
for e in endpoints:
144+
if e["action"] == self.ACTION_13:
145+
return e # Historically, we prefer ACTION_13 a.k.a. WsTrust13
146+
return endpoints[0] if endpoints else None
129147
def get_wstrust_username_password_endpoint(self):
130148
"""Returns {"address": "https://...", "action": "the soapAction value"}"""
131149
endpoints = self._get_endpoints(

msal/wstrust_request.py

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
def send_request(
3838
username, password, cloud_audience_urn, endpoint_address, soap_action, http_client,
3939
**kwargs):
40+
iwa = username is None and password is None
4041
if not endpoint_address:
4142
raise ValueError("WsTrust endpoint address can not be empty")
4243
if soap_action is None:
@@ -49,10 +50,18 @@ def send_request(
4950
"Contact your administrator to check your ADFS's MEX settings." % soap_action)
5051
data = _build_rst(
5152
username, password, cloud_audience_urn, endpoint_address, soap_action)
52-
resp = http_client.post(endpoint_address, data=data, headers={
53+
if iwa:
54+
# Make request kerberized
55+
from requests_kerberos import HTTPKerberosAuth, DISABLED
56+
resp = http_client.post(endpoint_address, data=data, headers={
5357
'Content-type':'application/soap+xml; charset=utf-8',
5458
'SOAPAction': soap_action,
55-
}, **kwargs)
59+
}, auth=HTTPKerberosAuth(mutual_authentication=DISABLED), allow_redirects=True)
60+
else:
61+
resp = http_client.post(endpoint_address, data=data, headers={
62+
'Content-type':'application/soap+xml; charset=utf-8',
63+
'SOAPAction': soap_action,
64+
}, **kwargs)
5665
if resp.status_code >= 400:
5766
logger.debug("Unsuccessful WsTrust request receives: %s", resp.text)
5867
# It turns out ADFS uses 5xx status code even with client-side incorrect password error
@@ -76,16 +85,11 @@ def wsu_time_format(datetime_obj):
7685

7786

7887
def _build_rst(username, password, cloud_audience_urn, endpoint_address, soap_action):
88+
iwa = username is None and password is None
7989
now = datetime.utcnow()
80-
return """<s:Envelope xmlns:s='{s}' xmlns:wsa='{wsa}' xmlns:wsu='{wsu}'>
81-
<s:Header>
82-
<wsa:Action s:mustUnderstand='1'>{soap_action}</wsa:Action>
83-
<wsa:MessageID>urn:uuid:{message_id}</wsa:MessageID>
84-
<wsa:ReplyTo>
85-
<wsa:Address>http://www.w3.org/2005/08/addressing/anonymous</wsa:Address>
86-
</wsa:ReplyTo>
87-
<wsa:To s:mustUnderstand='1'>{endpoint_address}</wsa:To>
88-
90+
_security_header = ""
91+
if not iwa:
92+
_security_header = """
8993
<wsse:Security s:mustUnderstand='1'
9094
xmlns:wsse='http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd'>
9195
<wsu:Timestamp wsu:Id='_0'>
@@ -97,7 +101,21 @@ def _build_rst(username, password, cloud_audience_urn, endpoint_address, soap_ac
97101
<wsse:Password>{password}</wsse:Password>
98102
</wsse:UsernameToken>
99103
</wsse:Security>
100-
104+
""".format(
105+
username = username,
106+
password = escape_password(password),
107+
time_now=wsu_time_format(now),
108+
time_expire=wsu_time_format(now + timedelta(minutes=10)),
109+
)
110+
return """<s:Envelope xmlns:s='{s}' xmlns:wsa='{wsa}' xmlns:wsu='{wsu}'>
111+
<s:Header>
112+
<wsa:Action s:mustUnderstand='1'>{soap_action}</wsa:Action>
113+
<wsa:MessageID>urn:uuid:{message_id}</wsa:MessageID>
114+
<wsa:ReplyTo>
115+
<wsa:Address>http://www.w3.org/2005/08/addressing/anonymous</wsa:Address>
116+
</wsa:ReplyTo>
117+
<wsa:To s:mustUnderstand='1'>{endpoint_address}</wsa:To>
118+
{security_header}
101119
</s:Header>
102120
<s:Body>
103121
<wst:RequestSecurityToken xmlns:wst='{wst}'>
@@ -114,9 +132,6 @@ def _build_rst(username, password, cloud_audience_urn, endpoint_address, soap_ac
114132
s=Mex.NS["s"], wsu=Mex.NS["wsu"], wsa=Mex.NS["wsa10"],
115133
soap_action=soap_action, message_id=str(uuid.uuid4()),
116134
endpoint_address=endpoint_address,
117-
time_now=wsu_time_format(now),
118-
time_expire=wsu_time_format(now + timedelta(minutes=10)),
119-
username=username, password=escape_password(password),
120135
wst=Mex.NS["wst"] if soap_action == Mex.ACTION_13 else Mex.NS["wst2005"],
121136
applies_to=cloud_audience_urn,
122137
key_type='http://docs.oasis-open.org/ws-sx/ws-trust/200512/Bearer'
@@ -125,5 +140,6 @@ def _build_rst(username, password, cloud_audience_urn, endpoint_address, soap_ac
125140
request_type='http://docs.oasis-open.org/ws-sx/ws-trust/200512/Issue'
126141
if soap_action == Mex.ACTION_13 else
127142
'http://schemas.xmlsoap.org/ws/2005/02/trust/Issue',
143+
security_header=_security_header
128144
)
129145

0 commit comments

Comments
 (0)