Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add integrated windows authentication support under public clients #652

Open
wants to merge 4 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 77 additions & 4 deletions msal/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from .oauth2cli import Client, JwtAssertionCreator
from .oauth2cli.oidc import decode_part
from .authority import Authority, WORLD_WIDE
from .mex import send_request as mex_send_request
from .mex import send_request as mex_send_request, send_request_iwa as mex_send_request_iwa
from .wstrust_request import send_request as wst_send_request
from .wstrust_response import *
from .token_cache import TokenCache, _get_username, _GRANT_TYPE_BROKER
Expand Down Expand Up @@ -172,6 +172,7 @@ class ClientApplication(object):
ACQUIRE_TOKEN_FOR_CLIENT_ID = "730"
ACQUIRE_TOKEN_BY_AUTHORIZATION_CODE_ID = "832"
ACQUIRE_TOKEN_INTERACTIVE = "169"
ACQUIRE_TOKEN_INTEGRATED_WINDOWS_AUTH_ID = "870"
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure how this number is mapped. I just added a random number for IWA btw.

GET_ACCOUNTS_ID = "902"
REMOVE_ACCOUNT_ID = "903"

Expand All @@ -187,6 +188,7 @@ class ClientApplication(object):
"You can enable broker by following these instructions. "
"https://msal-python.readthedocs.io/en/latest/#publicclientapplication")


def __init__(
self, client_id,
client_credential=None, authority=None, validate_authority=True,
Expand Down Expand Up @@ -1711,11 +1713,10 @@ def _acquire_token_by_username_password_federated(
wstrust_endpoint.get("action"), self.http_client)
if not ("token" in wstrust_result and "type" in wstrust_result):
raise RuntimeError("Unsuccessful RSTR. %s" % wstrust_result)
GRANT_TYPE_SAML1_1 = 'urn:ietf:params:oauth:grant-type:saml1_1-bearer'
grant_type = {
SAML_TOKEN_TYPE_V1: GRANT_TYPE_SAML1_1,
SAML_TOKEN_TYPE_V1: self.client.GRANT_TYPE_SAML1_1,
SAML_TOKEN_TYPE_V2: self.client.GRANT_TYPE_SAML2,
WSS_SAML_TOKEN_PROFILE_V1_1: GRANT_TYPE_SAML1_1,
WSS_SAML_TOKEN_PROFILE_V1_1: self.client.GRANT_TYPE_SAML1_1,
WSS_SAML_TOKEN_PROFILE_V2: self.client.GRANT_TYPE_SAML2
}.get(wstrust_result.get("type"))
if not grant_type:
Expand Down Expand Up @@ -2114,6 +2115,78 @@ def acquire_token_by_device_flow(self, flow, claims_challenge=None, **kwargs):
telemetry_context.update_telemetry(response)
return response

def acquire_token_integrated_windows_auth(self, username, scopes="openid", **kwargs):
"""Gets a token for a given resource via Integrated Windows Authentication (IWA).

:param str username: Typically a UPN in the form of an email address.
:param str scopes: Scopes requested to access a protected API (a resource).

:return: A dict representing the json response from AAD:

- A successful response would contain "access_token" key,
- an error response would contain "error" and usually "error_description".
"""
telemetry_context = self._build_telemetry_context(
self.ACQUIRE_TOKEN_INTEGRATED_WINDOWS_AUTH_ID)
headers = telemetry_context.generate_headers()
user_realm_result = self.authority.user_realm_discovery(
username,
correlation_id=headers[msal.telemetry.CLIENT_REQUEST_ID]
)
if user_realm_result.get("account_type") != "Federated":
raise ValueError("Server returned an unknown account type: %s" % user_realm_result.get("account_type"))
response = _clean_up(self._acquire_token_by_iwa_federated(user_realm_result, username, scopes, **kwargs))
if response is None: # Either ADFS or not federated
raise ValueError("Integrated Windows Authentication failed for this user: %s", username)
if "access_token" in response:
response[self._TOKEN_SOURCE] = self._TOKEN_SOURCE_IDP
telemetry_context.update_telemetry(response)
return response

def _acquire_token_by_iwa_federated(
self, user_realm_result, username, scopes="openid", **kwargs):
wstrust_endpoint = {}
if user_realm_result.get("federation_metadata_url"):
mex_endpoint = user_realm_result.get("federation_metadata_url")
logger.debug(
"Attempting mex at: %(mex_endpoint)s",
{"mex_endpoint": mex_endpoint})
wstrust_endpoint = mex_send_request_iwa(mex_endpoint, self.http_client)
if wstrust_endpoint is None:
raise ValueError("Unable to find wstrust endpoint from MEX. "
"This typically happens when attempting MSA accounts. "
"More details available here. "
"https://github.com/AzureAD/microsoft-authentication-library-for-python/wiki/Username-Password-Authentication")
logger.debug("wstrust_endpoint = %s", wstrust_endpoint)
wstrust_result = wst_send_request(
None, None,
user_realm_result.get("cloud_audience_urn", "urn:federation:MicrosoftOnline"),
wstrust_endpoint.get("address",
# Fallback to an AAD supplied endpoint
user_realm_result.get("federation_active_auth_url")),
wstrust_endpoint.get("action"), self.http_client)
if not ("token" in wstrust_result and "type" in wstrust_result):
raise RuntimeError("Unsuccessful RSTR. %s" % wstrust_result)
grant_type = {
SAML_TOKEN_TYPE_V1: self.client.GRANT_TYPE_SAML1_1,
SAML_TOKEN_TYPE_V2: self.client.GRANT_TYPE_SAML2,
WSS_SAML_TOKEN_PROFILE_V1_1: self.client.GRANT_TYPE_SAML1_1,
WSS_SAML_TOKEN_PROFILE_V2: self.client.GRANT_TYPE_SAML2
}.get(wstrust_result.get("type"))
if not grant_type:
raise RuntimeError(
"RSTR returned unknown token type: %s", wstrust_result.get("type"))
self.client.grant_assertion_encoders.setdefault( # Register a non-standard type
grant_type, self.client.encode_saml_assertion)
return self.client.obtain_token_by_assertion(
wstrust_result["token"], grant_type, scope=scopes,
on_obtaining_tokens=lambda event: self.token_cache.add(dict(
event,
environment=self.authority.instance,
username=username, # Useful in case IDT contains no such info
iwa=True
)),
**kwargs)

class ConfidentialClientApplication(ClientApplication): # server-side web app
"""Same as :func:`ClientApplication.__init__`,
Expand Down
18 changes: 18 additions & 0 deletions msal/mex.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,16 @@ def send_request(mex_endpoint, http_client, **kwargs):
"Malformed MEX document: %s, %s", mex_resp.status_code, mex_resp.text)
raise

def send_request_iwa(mex_endpoint, http_client, **kwargs):
mex_resp = http_client.get(mex_endpoint, **kwargs)
mex_resp.raise_for_status()
try:
return Mex(mex_resp.text).get_wstrust_iwa_endpoint()
except ET.ParseError:
logger.exception(
"Malformed MEX document: %s, %s", mex_resp.status_code, mex_resp.text)
raise


class Mex(object):

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

def get_wstrust_iwa_endpoint(self):
"""Returns {"address": "https://...", "action": "the soapAction value"}"""
endpoints = self._get_endpoints(
self._get_bindings(), self._get_iwa_policy_ids())
for e in endpoints:
if e["action"] == self.ACTION_13:
return e # Historically, we prefer ACTION_13 a.k.a. WsTrust13
return endpoints[0] if endpoints else None
def get_wstrust_username_password_endpoint(self):
"""Returns {"address": "https://...", "action": "the soapAction value"}"""
endpoints = self._get_endpoints(
Expand Down
1 change: 1 addition & 0 deletions msal/oauth2cli/oauth2.py
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,7 @@ class Client(BaseClient): # We choose to implement all 4 grants in 1 class
"DEVICE_CODE": "device_code",
}
DEVICE_FLOW_RETRIABLE_ERRORS = ("authorization_pending", "slow_down")
GRANT_TYPE_SAML1_1 = 'urn:ietf:params:oauth:grant-type:saml1_1-bearer'
GRANT_TYPE_SAML2 = "urn:ietf:params:oauth:grant-type:saml2-bearer" # RFC7522
GRANT_TYPE_JWT = "urn:ietf:params:oauth:grant-type:jwt-bearer" # RFC7523
grant_assertion_encoders = {GRANT_TYPE_SAML2: BaseClient.encode_saml_assertion}
Expand Down
4 changes: 2 additions & 2 deletions msal/token_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,8 +209,8 @@ def __add(self, event, now=None):
# Only use decode_id_token() when necessary, it contains time-sensitive validation
decode_id_token(id_token, client_id=event["client_id"]) if id_token else {})
client_info, home_account_id = self.__parse_account(response, id_token_claims)

target = ' '.join(sorted(event.get("scope") or [])) # Schema should have required sorting
iwa = event.get("iwa", False) # Integrated Windows Authentication

with self._lock:
now = int(time.time() if now is None else now)
Expand Down Expand Up @@ -243,7 +243,7 @@ def __add(self, event, now=None):
at["refresh_on"] = str(now + refresh_in) # Schema wants a string
self.modify(self.CredentialType.ACCESS_TOKEN, at, at)

if client_info and not event.get("skip_account_creation"):
if (client_info or iwa) and not event.get("skip_account_creation"):
Copy link
Author

@shajia-deshaw shajia-deshaw Jan 24, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The access token returned by IWA did not meet the criteria to be added as an ACCOUNT credential type in the TokenCache i.e. it didn't have client_info in the response. It was being added as a ACCESS_TOKEN credential type. The cache test in the e2e tests were failing because of it since the get_accounts() call didn't return anything and it assumed there was nothing being added to the cache. Hence, I added a special case of iwa to be added to the ACCOUNT credential type.

The other alternative is to add a method for get_access_tokens similar to get_accounts and use it to decide if we need to acquire new tokens silently for the items in cache.

account = {
"home_account_id": home_account_id,
"environment": environment,
Expand Down
46 changes: 31 additions & 15 deletions msal/wstrust_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
def send_request(
username, password, cloud_audience_urn, endpoint_address, soap_action, http_client,
**kwargs):
iwa = username is None and password is None
if not endpoint_address:
raise ValueError("WsTrust endpoint address can not be empty")
if soap_action is None:
Expand All @@ -49,10 +50,18 @@ def send_request(
"Contact your administrator to check your ADFS's MEX settings." % soap_action)
data = _build_rst(
username, password, cloud_audience_urn, endpoint_address, soap_action)
resp = http_client.post(endpoint_address, data=data, headers={
if iwa:
# Make request kerberized
from requests_kerberos import HTTPKerberosAuth, DISABLED
resp = http_client.post(endpoint_address, data=data, headers={
'Content-type':'application/soap+xml; charset=utf-8',
'SOAPAction': soap_action,
}, **kwargs)
}, auth=HTTPKerberosAuth(mutual_authentication=DISABLED), allow_redirects=True)
else:
resp = http_client.post(endpoint_address, data=data, headers={
'Content-type':'application/soap+xml; charset=utf-8',
'SOAPAction': soap_action,
}, **kwargs)
if resp.status_code >= 400:
logger.debug("Unsuccessful WsTrust request receives: %s", resp.text)
# It turns out ADFS uses 5xx status code even with client-side incorrect password error
Expand All @@ -76,16 +85,11 @@ def wsu_time_format(datetime_obj):


def _build_rst(username, password, cloud_audience_urn, endpoint_address, soap_action):
iwa = username is None and password is None
now = datetime.utcnow()
return """<s:Envelope xmlns:s='{s}' xmlns:wsa='{wsa}' xmlns:wsu='{wsu}'>
<s:Header>
<wsa:Action s:mustUnderstand='1'>{soap_action}</wsa:Action>
<wsa:MessageID>urn:uuid:{message_id}</wsa:MessageID>
<wsa:ReplyTo>
<wsa:Address>http://www.w3.org/2005/08/addressing/anonymous</wsa:Address>
</wsa:ReplyTo>
<wsa:To s:mustUnderstand='1'>{endpoint_address}</wsa:To>

_security_header = ""
if not iwa:
_security_header = """
<wsse:Security s:mustUnderstand='1'
xmlns:wsse='http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd'>
<wsu:Timestamp wsu:Id='_0'>
Expand All @@ -97,7 +101,21 @@ def _build_rst(username, password, cloud_audience_urn, endpoint_address, soap_ac
<wsse:Password>{password}</wsse:Password>
</wsse:UsernameToken>
</wsse:Security>

""".format(
username = username,
password = escape_password(password),
time_now=wsu_time_format(now),
time_expire=wsu_time_format(now + timedelta(minutes=10)),
)
return """<s:Envelope xmlns:s='{s}' xmlns:wsa='{wsa}' xmlns:wsu='{wsu}'>
<s:Header>
<wsa:Action s:mustUnderstand='1'>{soap_action}</wsa:Action>
<wsa:MessageID>urn:uuid:{message_id}</wsa:MessageID>
<wsa:ReplyTo>
<wsa:Address>http://www.w3.org/2005/08/addressing/anonymous</wsa:Address>
</wsa:ReplyTo>
<wsa:To s:mustUnderstand='1'>{endpoint_address}</wsa:To>
{security_header}
</s:Header>
<s:Body>
<wst:RequestSecurityToken xmlns:wst='{wst}'>
Expand All @@ -114,9 +132,6 @@ def _build_rst(username, password, cloud_audience_urn, endpoint_address, soap_ac
s=Mex.NS["s"], wsu=Mex.NS["wsu"], wsa=Mex.NS["wsa10"],
soap_action=soap_action, message_id=str(uuid.uuid4()),
endpoint_address=endpoint_address,
time_now=wsu_time_format(now),
time_expire=wsu_time_format(now + timedelta(minutes=10)),
username=username, password=escape_password(password),
wst=Mex.NS["wst"] if soap_action == Mex.ACTION_13 else Mex.NS["wst2005"],
applies_to=cloud_audience_urn,
key_type='http://docs.oasis-open.org/ws-sx/ws-trust/200512/Bearer'
Expand All @@ -125,5 +140,6 @@ def _build_rst(username, password, cloud_audience_urn, endpoint_address, soap_ac
request_type='http://docs.oasis-open.org/ws-sx/ws-trust/200512/Issue'
if soap_action == Mex.ACTION_13 else
'http://schemas.xmlsoap.org/ws/2005/02/trust/Issue',
security_header=_security_header
)

1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ python-dotenv>=0.21,<2

pytest-benchmark>=4,<5
perf_baseline>=0.1,<0.2
requests-kerberos==0.14.0

88 changes: 88 additions & 0 deletions sample/integrated_windows_authentication_sample.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""
The configuration file would look like this:

{
"authority": "https://login.microsoftonline.com/organizations",
"client_id": "your_client_id",
"username": "your_username@your_tenant.com",
"scope": ["User.ReadBasic.All"],
// You can find the other permission names from this document
// https://docs.microsoft.com/en-us/graph/permissions-reference
"endpoint": "https://graph.microsoft.com/v1.0/users"
// You can find more Microsoft Graph API endpoints from Graph Explorer
// https://developer.microsoft.com/en-us/graph/graph-explorer
}

You can then run this sample with a JSON configuration file:

python sample.py parameters.json
"""

import sys # For simplicity, we'll read config file from 1st CLI param sys.argv[1]
import json
import logging
import time

import requests
import msal
from msal.token_cache import TokenCache


# Optional logging
# logging.basicConfig(level=logging.DEBUG) # Enable DEBUG log for entire script
# logging.getLogger("msal").setLevel(logging.INFO) # Optionally disable MSAL DEBUG logs

config = json.load(open(sys.argv[1]))

# If for whatever reason you plan to recreate same ClientApplication periodically,
# you shall create one global token cache and reuse it by each ClientApplication
global_token_cache = msal.TokenCache() # The TokenCache() is in-memory.
# See more options in https://msal-python.readthedocs.io/en/latest/#tokencache

# Create a preferably long-lived app instance, to avoid the overhead of app creation
global_app = msal.PublicClientApplication(
config["client_id"], authority=config["authority"],
client_credential=config.get("client_secret"),
token_cache=global_token_cache, # Let this app (re)use an existing token cache.
# If absent, ClientApplication will create its own empty token cache
)


def acquire_and_use_token():
# The pattern to acquire a token looks like this.
result = None

# Firstly, check the cache to see if this end user has signed in before
accounts = global_app.get_accounts(username=config["username"])
if accounts:
print("Account(s) exists in cache, probably with token too. Let's try.")
result = global_app.acquire_token_silent(config["scope"], account=accounts[0])

if not result:
print("No suitable token exists in cache. Let's get a new one from AAD.")
# See this page for constraints of Username Password Flow.
# https://github.com/AzureAD/microsoft-authentication-library-for-python/wiki/Username-Password-Authentication
result = global_app.acquire_token_integrated_windows_auth(
config["username"], scopes=config["scope"])

if "access_token" in result:
print("Token was obtained from:", result["token_source"]) # Since MSAL 1.25
# Calling graph using the access token
graph_data = requests.get( # Use token to call downstream service
config["endpoint"],
headers={'Authorization': 'Bearer ' + result['access_token']},).json()
print("Graph API call result: %s" % json.dumps(graph_data, indent=2))
else:
print("Token acquisition failed") # Examine result["error_description"] etc. to diagnose error
print(result)
if 65001 in result.get("error_codes", []): # Not mean to be coded programatically, but...
raise RuntimeError(
"AAD requires user consent for U/P flow to succeed. "
"Run acquire_token_interactive() instead.")


while True: # Here we mimic a long-lived daemon
acquire_and_use_token()
print("Press Ctrl-C to stop.")
time.sleep(5) # Let's say your app would run a workload every X minutes.

Loading