Skip to content

Commit 5b42f87

Browse files
committed
feat: add integrated windows authentication support under public clients
1 parent c131b9b commit 5b42f87

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
@@ -14,7 +14,7 @@
1414
from .oauth2cli import Client, JwtAssertionCreator
1515
from .oauth2cli.oidc import decode_part
1616
from .authority import Authority, WORLD_WIDE
17-
from .mex import send_request as mex_send_request
17+
from .mex import send_request as mex_send_request, send_request_iwa as mex_send_request_iwa
1818
from .wstrust_request import send_request as wst_send_request
1919
from .wstrust_response import *
2020
from .token_cache import TokenCache, _get_username, _GRANT_TYPE_BROKER
@@ -172,6 +172,7 @@ class ClientApplication(object):
172172
ACQUIRE_TOKEN_FOR_CLIENT_ID = "730"
173173
ACQUIRE_TOKEN_BY_AUTHORIZATION_CODE_ID = "832"
174174
ACQUIRE_TOKEN_INTERACTIVE = "169"
175+
ACQUIRE_TOKEN_INTEGRATED_WINDOWS_AUTH_ID = "870"
175176
GET_ACCOUNTS_ID = "902"
176177
REMOVE_ACCOUNT_ID = "903"
177178

@@ -2114,6 +2115,78 @@ def acquire_token_by_device_flow(self, flow, claims_challenge=None, **kwargs):
21142115
telemetry_context.update_telemetry(response)
21152116
return response
21162117

2118+
def acquire_token_integrated_windows_auth(self, username, scopes="openid", **kwargs):
2119+
"""Gets a token for a given resource via Integrated Windows Authentication (IWA).
2120+
2121+
:param str username: Typically a UPN in the form of an email address.
2122+
:param str scopes: Scopes requested to access a protected API (a resource).
2123+
2124+
:return: A dict representing the json response from AAD:
2125+
2126+
- A successful response would contain "access_token" key,
2127+
- an error response would contain "error" and usually "error_description".
2128+
"""
2129+
telemetry_context = self._build_telemetry_context(
2130+
self.ACQUIRE_TOKEN_INTEGRATED_WINDOWS_AUTH_ID)
2131+
headers = telemetry_context.generate_headers()
2132+
user_realm_result = self.authority.user_realm_discovery(
2133+
username,
2134+
correlation_id=headers[msal.telemetry.CLIENT_REQUEST_ID]
2135+
)
2136+
if user_realm_result.get("account_type") != "Federated":
2137+
raise ValueError("Server returned an unknown account type: %s" % user_realm_result.get("account_type"))
2138+
response = _clean_up(self._acquire_token_by_iwa_federated(user_realm_result, username, scopes, **kwargs))
2139+
if response is None: # Either ADFS or not federated
2140+
raise ValueError("Integrated Windows Authentication failed for this user: %s", username)
2141+
if "access_token" in response:
2142+
response[self._TOKEN_SOURCE] = self._TOKEN_SOURCE_IDP
2143+
telemetry_context.update_telemetry(response)
2144+
return response
2145+
2146+
def _acquire_token_by_iwa_federated(
2147+
self, user_realm_result, username, scopes="openid", **kwargs):
2148+
wstrust_endpoint = {}
2149+
if user_realm_result.get("federation_metadata_url"):
2150+
mex_endpoint = user_realm_result.get("federation_metadata_url")
2151+
logger.debug(
2152+
"Attempting mex at: %(mex_endpoint)s",
2153+
{"mex_endpoint": mex_endpoint})
2154+
wstrust_endpoint = mex_send_request_iwa(mex_endpoint, self.http_client)
2155+
if wstrust_endpoint is None:
2156+
raise ValueError("Unable to find wstrust endpoint from MEX. "
2157+
"This typically happens when attempting MSA accounts. "
2158+
"More details available here. "
2159+
"https://github.com/AzureAD/microsoft-authentication-library-for-python/wiki/Username-Password-Authentication")
2160+
logger.debug("wstrust_endpoint = %s", wstrust_endpoint)
2161+
wstrust_result = wst_send_request(
2162+
None, None,
2163+
user_realm_result.get("cloud_audience_urn", "urn:federation:MicrosoftOnline"),
2164+
wstrust_endpoint.get("address",
2165+
# Fallback to an AAD supplied endpoint
2166+
user_realm_result.get("federation_active_auth_url")),
2167+
wstrust_endpoint.get("action"), self.http_client)
2168+
if not ("token" in wstrust_result and "type" in wstrust_result):
2169+
raise RuntimeError("Unsuccessful RSTR. %s" % wstrust_result)
2170+
GRANT_TYPE_SAML1_1 = 'urn:ietf:params:oauth:grant-type:saml1_1-bearer'
2171+
grant_type = {
2172+
SAML_TOKEN_TYPE_V1: GRANT_TYPE_SAML1_1,
2173+
SAML_TOKEN_TYPE_V2: self.client.GRANT_TYPE_SAML2,
2174+
WSS_SAML_TOKEN_PROFILE_V1_1: GRANT_TYPE_SAML1_1,
2175+
WSS_SAML_TOKEN_PROFILE_V2: self.client.GRANT_TYPE_SAML2
2176+
}.get(wstrust_result.get("type"))
2177+
if not grant_type:
2178+
raise RuntimeError(
2179+
"RSTR returned unknown token type: %s", wstrust_result.get("type"))
2180+
self.client.grant_assertion_encoders.setdefault( # Register a non-standard type
2181+
grant_type, self.client.encode_saml_assertion)
2182+
return self.client.obtain_token_by_assertion(
2183+
wstrust_result["token"], grant_type, scope=scopes,
2184+
on_obtaining_tokens=lambda event: self.token_cache.add(dict(
2185+
event,
2186+
environment=self.authority.instance,
2187+
username=username, # Useful in case IDT contains no such info
2188+
)),
2189+
**kwargs)
21172190

21182191
class ConfidentialClientApplication(ClientApplication): # server-side web app
21192192
"""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)