Skip to content

Commit 8a4cdea

Browse files
authored
Merge pull request #412 from AzureAD/release-1.15.0
Release 1.15.0
2 parents be55e2b + 7d1c16d commit 8a4cdea

File tree

8 files changed

+215
-25
lines changed

8 files changed

+215
-25
lines changed

msal/application.py

Lines changed: 69 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import requests
1515

1616
from .oauth2cli import Client, JwtAssertionCreator
17+
from .oauth2cli.oidc import decode_part
1718
from .authority import Authority
1819
from .mex import send_request as mex_send_request
1920
from .wstrust_request import send_request as wst_send_request
@@ -25,7 +26,7 @@
2526

2627

2728
# The __init__.py will import this. Not the other way around.
28-
__version__ = "1.14.0"
29+
__version__ = "1.15.0"
2930

3031
logger = logging.getLogger(__name__)
3132

@@ -111,6 +112,36 @@ def _preferred_browser():
111112
return None
112113

113114

115+
class _ClientWithCcsRoutingInfo(Client):
116+
117+
def initiate_auth_code_flow(self, **kwargs):
118+
if kwargs.get("login_hint"): # eSTS could have utilized this as-is, but nope
119+
kwargs["X-AnchorMailbox"] = "UPN:%s" % kwargs["login_hint"]
120+
return super(_ClientWithCcsRoutingInfo, self).initiate_auth_code_flow(
121+
client_info=1, # To be used as CSS Routing info
122+
**kwargs)
123+
124+
def obtain_token_by_auth_code_flow(
125+
self, auth_code_flow, auth_response, **kwargs):
126+
# Note: the obtain_token_by_browser() is also covered by this
127+
assert isinstance(auth_code_flow, dict) and isinstance(auth_response, dict)
128+
headers = kwargs.pop("headers", {})
129+
client_info = json.loads(
130+
decode_part(auth_response["client_info"])
131+
) if auth_response.get("client_info") else {}
132+
if "uid" in client_info and "utid" in client_info:
133+
# Note: The value of X-AnchorMailbox is also case-insensitive
134+
headers["X-AnchorMailbox"] = "Oid:{uid}@{utid}".format(**client_info)
135+
return super(_ClientWithCcsRoutingInfo, self).obtain_token_by_auth_code_flow(
136+
auth_code_flow, auth_response, headers=headers, **kwargs)
137+
138+
def obtain_token_by_username_password(self, username, password, **kwargs):
139+
headers = kwargs.pop("headers", {})
140+
headers["X-AnchorMailbox"] = "upn:{}".format(username)
141+
return super(_ClientWithCcsRoutingInfo, self).obtain_token_by_username_password(
142+
username, password, headers=headers, **kwargs)
143+
144+
114145
class ClientApplication(object):
115146

116147
ACQUIRE_TOKEN_SILENT_ID = "84"
@@ -174,7 +205,7 @@ def __init__(
174205
you may try use only the leaf cert (in PEM/str format) instead.
175206
176207
*Added in version 1.13.0*:
177-
It can also be a completly pre-signed assertion that you've assembled yourself.
208+
It can also be a completely pre-signed assertion that you've assembled yourself.
178209
Simply pass a container containing only the key "client_assertion", like this::
179210
180211
{
@@ -481,7 +512,7 @@ def _build_client(self, client_credential, authority, skip_regional_client=False
481512
authority.device_authorization_endpoint or
482513
urljoin(authority.token_endpoint, "devicecode"),
483514
}
484-
central_client = Client(
515+
central_client = _ClientWithCcsRoutingInfo(
485516
central_configuration,
486517
self.client_id,
487518
http_client=self.http_client,
@@ -506,7 +537,7 @@ def _build_client(self, client_credential, authority, skip_regional_client=False
506537
regional_authority.device_authorization_endpoint or
507538
urljoin(regional_authority.token_endpoint, "devicecode"),
508539
}
509-
regional_client = Client(
540+
regional_client = _ClientWithCcsRoutingInfo(
510541
regional_configuration,
511542
self.client_id,
512543
http_client=self.http_client,
@@ -529,6 +560,7 @@ def initiate_auth_code_flow(
529560
login_hint=None, # type: Optional[str]
530561
domain_hint=None, # type: Optional[str]
531562
claims_challenge=None,
563+
max_age=None,
532564
):
533565
"""Initiate an auth code flow.
534566
@@ -559,6 +591,17 @@ def initiate_auth_code_flow(
559591
`here <https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow#request-an-authorization-code>`_ and
560592
`here <https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-oapx/86fb452d-e34a-494e-ac61-e526e263b6d8>`_.
561593
594+
:param int max_age:
595+
OPTIONAL. Maximum Authentication Age.
596+
Specifies the allowable elapsed time in seconds
597+
since the last time the End-User was actively authenticated.
598+
If the elapsed time is greater than this value,
599+
Microsoft identity platform will actively re-authenticate the End-User.
600+
601+
MSAL Python will also automatically validate the auth_time in ID token.
602+
603+
New in version 1.15.
604+
562605
:return:
563606
The auth code flow. It is a dict in this form::
564607
@@ -577,7 +620,7 @@ def initiate_auth_code_flow(
577620
3. and then relay this dict and subsequent auth response to
578621
:func:`~acquire_token_by_auth_code_flow()`.
579622
"""
580-
client = Client(
623+
client = _ClientWithCcsRoutingInfo(
581624
{"authorization_endpoint": self.authority.authorization_endpoint},
582625
self.client_id,
583626
http_client=self.http_client)
@@ -588,6 +631,7 @@ def initiate_auth_code_flow(
588631
domain_hint=domain_hint,
589632
claims=_merge_claims_challenge_and_capabilities(
590633
self._client_capabilities, claims_challenge),
634+
max_age=max_age,
591635
)
592636
flow["claims_challenge"] = claims_challenge
593637
return flow
@@ -654,7 +698,7 @@ def get_authorization_request_url(
654698
self.http_client
655699
) if authority else self.authority
656700

657-
client = Client(
701+
client = _ClientWithCcsRoutingInfo(
658702
{"authorization_endpoint": the_authority.authorization_endpoint},
659703
self.client_id,
660704
http_client=self.http_client)
@@ -1178,6 +1222,10 @@ def _acquire_token_silent_by_finding_specific_refresh_token(
11781222
key=lambda e: int(e.get("last_modification_time", "0")),
11791223
reverse=True):
11801224
logger.debug("Cache attempts an RT")
1225+
headers = telemetry_context.generate_headers()
1226+
if "home_account_id" in query: # Then use it as CCS Routing info
1227+
headers["X-AnchorMailbox"] = "Oid:{}".format( # case-insensitive value
1228+
query["home_account_id"].replace(".", "@"))
11811229
response = client.obtain_token_by_refresh_token(
11821230
entry, rt_getter=lambda token_item: token_item["secret"],
11831231
on_removing_rt=lambda rt_item: None, # Disable RT removal,
@@ -1189,7 +1237,7 @@ def _acquire_token_silent_by_finding_specific_refresh_token(
11891237
skip_account_creation=True, # To honor a concurrent remove_account()
11901238
)),
11911239
scope=scopes,
1192-
headers=telemetry_context.generate_headers(),
1240+
headers=headers,
11931241
data=dict(
11941242
kwargs.pop("data", {}),
11951243
claims=_merge_claims_challenge_and_capabilities(
@@ -1370,6 +1418,7 @@ def acquire_token_interactive(
13701418
timeout=None,
13711419
port=None,
13721420
extra_scopes_to_consent=None,
1421+
max_age=None,
13731422
**kwargs):
13741423
"""Acquire token interactively i.e. via a local browser.
13751424
@@ -1415,6 +1464,17 @@ def acquire_token_interactive(
14151464
in the same interaction, but for which you won't get back a
14161465
token for in this particular operation.
14171466
1467+
:param int max_age:
1468+
OPTIONAL. Maximum Authentication Age.
1469+
Specifies the allowable elapsed time in seconds
1470+
since the last time the End-User was actively authenticated.
1471+
If the elapsed time is greater than this value,
1472+
Microsoft identity platform will actively re-authenticate the End-User.
1473+
1474+
MSAL Python will also automatically validate the auth_time in ID token.
1475+
1476+
New in version 1.15.
1477+
14181478
:return:
14191479
- A dict containing no "error" key,
14201480
and typically contains an "access_token" key.
@@ -1433,6 +1493,7 @@ def acquire_token_interactive(
14331493
port=port or 0),
14341494
prompt=prompt,
14351495
login_hint=login_hint,
1496+
max_age=max_age,
14361497
timeout=timeout,
14371498
auth_params={
14381499
"claims": claims,
@@ -1581,6 +1642,7 @@ def acquire_token_on_behalf_of(self, user_assertion, scopes, claims_challenge=No
15811642
claims=_merge_claims_challenge_and_capabilities(
15821643
self._client_capabilities, claims_challenge)),
15831644
headers=telemetry_context.generate_headers(),
1645+
# TBD: Expose a login_hint (or ccs_routing_hint) param for web app
15841646
**kwargs))
15851647
telemetry_context.update_telemetry(response)
15861648
return response

msal/oauth2cli/authcode.py

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
import logging
99
import socket
1010
from string import Template
11+
import threading
12+
import time
1113

1214
try: # Python 3
1315
from http.server import HTTPServer, BaseHTTPRequestHandler
@@ -143,17 +145,14 @@ def __init__(self, port=None):
143145
# TODO: But, it would treat "localhost" or "" as IPv4.
144146
# If pressed, we might just expose a family parameter to caller.
145147
self._server = Server((address, port or 0), _AuthCodeHandler)
148+
self._closing = False
146149

147150
def get_port(self):
148151
"""The port this server actually listening to"""
149152
# https://docs.python.org/2.7/library/socketserver.html#SocketServer.BaseServer.server_address
150153
return self._server.server_address[1]
151154

152-
def get_auth_response(self, auth_uri=None, timeout=None, state=None,
153-
welcome_template=None, success_template=None, error_template=None,
154-
auth_uri_callback=None,
155-
browser_name=None,
156-
):
155+
def get_auth_response(self, timeout=None, **kwargs):
157156
"""Wait and return the auth response. Raise RuntimeError when timeout.
158157
159158
:param str auth_uri:
@@ -192,6 +191,37 @@ def get_auth_response(self, auth_uri=None, timeout=None, state=None,
192191
and https://openid.net/specs/openid-connect-core-1_0.html#AuthResponse
193192
Returns None when the state was mismatched, or when timeout occurred.
194193
"""
194+
# Historically, the _get_auth_response() uses HTTPServer.handle_request(),
195+
# because its handle-and-retry logic is conceptually as easy as a while loop.
196+
# Also, handle_request() honors server.timeout setting, and CTRL+C simply works.
197+
# All those are true when running on Linux.
198+
#
199+
# However, the behaviors on Windows turns out to be different.
200+
# A socket server waiting for request would freeze the current thread.
201+
# Neither timeout nor CTRL+C would work. End user would have to do CTRL+BREAK.
202+
# https://stackoverflow.com/questions/1364173/stopping-python-using-ctrlc
203+
#
204+
# The solution would need to somehow put the http server into its own thread.
205+
# This could be done by the pattern of ``http.server.test()`` which internally
206+
# use ``ThreadingHTTPServer.serve_forever()`` (only available in Python 3.7).
207+
# Or create our own thread to wrap the HTTPServer.handle_request() inside.
208+
result = {} # A mutable object to be filled with thread's return value
209+
t = threading.Thread(
210+
target=self._get_auth_response, args=(result,), kwargs=kwargs)
211+
t.daemon = True # So that it won't prevent the main thread from exiting
212+
t.start()
213+
begin = time.time()
214+
while (time.time() - begin < timeout) if timeout else True:
215+
time.sleep(1) # Short detection interval to make happy path responsive
216+
if not t.is_alive(): # Then the thread has finished its job and exited
217+
break
218+
return result or None
219+
220+
def _get_auth_response(self, result, auth_uri=None, timeout=None, state=None,
221+
welcome_template=None, success_template=None, error_template=None,
222+
auth_uri_callback=None,
223+
browser_name=None,
224+
):
195225
welcome_uri = "http://localhost:{p}".format(p=self.get_port())
196226
abort_uri = "{loc}?error=abort".format(loc=welcome_uri)
197227
logger.debug("Abort by visit %s", abort_uri)
@@ -229,7 +259,8 @@ def get_auth_response(self, auth_uri=None, timeout=None, state=None,
229259

230260
self._server.timeout = timeout # Otherwise its handle_timeout() won't work
231261
self._server.auth_response = {} # Shared with _AuthCodeHandler
232-
while True:
262+
while not self._closing: # Otherwise, the handle_request() attempt
263+
# would yield noisy ValueError trace
233264
# Derived from
234265
# https://docs.python.org/2/library/basehttpserver.html#more-examples
235266
self._server.handle_request()
@@ -238,10 +269,11 @@ def get_auth_response(self, auth_uri=None, timeout=None, state=None,
238269
logger.debug("State mismatch. Ignoring this noise.")
239270
else:
240271
break
241-
return self._server.auth_response
272+
result.update(self._server.auth_response) # Return via writable result param
242273

243274
def close(self):
244275
"""Either call this eventually; or use the entire class as context manager"""
276+
self._closing = True
245277
self._server.server_close()
246278

247279
def __enter__(self):

msal/oauth2cli/oauth2.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ def _obtain_token( # The verb "obtain" is influenced by OAUTH2 RFC 6749
199199
_data["client_assertion"] = encoder(
200200
self.client_assertion() # Do lazy on-the-fly computation
201201
if callable(self.client_assertion) else self.client_assertion
202-
) # The type is bytes, which is preferrable. See also:
202+
) # The type is bytes, which is preferable. See also:
203203
# https://github.com/psf/requests/issues/4503#issuecomment-455001070
204204

205205
_data.update(self.default_body) # It may contain authen parameters

msal/oauth2cli/oidc.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ def decode_id_token(id_token, client_id=None, issuer=None, nonce=None, now=None)
4242
"""
4343
decoded = json.loads(decode_part(id_token.split('.')[1]))
4444
err = None # https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation
45-
_now = now or time.time()
45+
_now = int(now or time.time())
4646
skew = 120 # 2 minutes
4747
if _now + skew < decoded.get("nbf", _now - 1): # nbf is optional per JWT specs
4848
# This is not an ID token validation, but a JWT validation
@@ -67,14 +67,14 @@ def decode_id_token(id_token, client_id=None, issuer=None, nonce=None, now=None)
6767
# the Client and the Token Endpoint (which it is during _obtain_token()),
6868
# the TLS server validation MAY be used to validate the issuer
6969
# in place of checking the token signature.
70-
if _now > decoded["exp"]:
70+
if _now - skew > decoded["exp"]:
7171
err = "9. The current time MUST be before the time represented by the exp Claim."
7272
if nonce and nonce != decoded.get("nonce"):
7373
err = ("11. Nonce must be the same value "
7474
"as the one that was sent in the Authentication Request.")
7575
if err:
76-
raise RuntimeError("%s The id_token was: %s" % (
77-
err, json.dumps(decoded, indent=2)))
76+
raise RuntimeError("%s Current epoch = %s. The id_token was: %s" % (
77+
err, _now, json.dumps(decoded, indent=2)))
7878
return decoded
7979

8080

@@ -187,6 +187,8 @@ def initiate_auth_code_flow(
187187
flow = super(Client, self).initiate_auth_code_flow(
188188
scope=_scope, nonce=_nonce_hash(nonce), **kwargs)
189189
flow["nonce"] = nonce
190+
if kwargs.get("max_age") is not None:
191+
flow["max_age"] = kwargs["max_age"]
190192
return flow
191193

192194
def obtain_token_by_auth_code_flow(self, auth_code_flow, auth_response, **kwargs):
@@ -208,6 +210,26 @@ def obtain_token_by_auth_code_flow(self, auth_code_flow, auth_response, **kwargs
208210
raise RuntimeError(
209211
'The nonce in id token ("%s") should match our nonce ("%s")' %
210212
(nonce_in_id_token, expected_hash))
213+
214+
if auth_code_flow.get("max_age") is not None:
215+
auth_time = result.get("id_token_claims", {}).get("auth_time")
216+
if not auth_time:
217+
raise RuntimeError(
218+
"13. max_age was requested, ID token should contain auth_time")
219+
now = int(time.time())
220+
skew = 120 # 2 minutes. Hardcoded, for now
221+
if now - skew > auth_time + auth_code_flow["max_age"]:
222+
raise RuntimeError(
223+
"13. auth_time ({auth_time}) was requested, "
224+
"by using max_age ({max_age}) parameter, "
225+
"and now ({now}) too much time has elasped "
226+
"since last end-user authentication. "
227+
"The ID token was: {id_token}".format(
228+
auth_time=auth_time,
229+
max_age=auth_code_flow["max_age"],
230+
now=now,
231+
id_token=json.dumps(result["id_token_claims"], indent=2),
232+
))
211233
return result
212234

213235
def obtain_token_by_browser(

msal/throttled_http_client.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,16 +100,17 @@ def __init__(self, http_client, http_cache):
100100
# acquire_token_silent(..., force_refresh=True) pattern.
101101
str(kwargs.get("params")) + str(kwargs.get("data"))),
102102
),
103-
expires_in=lambda result=None, data=None, **ignored:
103+
expires_in=lambda result=None, kwargs=None, **ignored:
104104
60
105105
if result.status_code == 400
106106
# Here we choose to cache exact HTTP 400 errors only (rather than 4xx)
107107
# because they are the ones defined in OAuth2
108108
# (https://datatracker.ietf.org/doc/html/rfc6749#section-5.2)
109109
# Other 4xx errors might have different requirements e.g.
110110
# "407 Proxy auth required" would need a key including http headers.
111-
and not( # Exclude Device Flow cause its retry is expected and regulated
112-
isinstance(data, dict) and data.get("grant_type") == DEVICE_AUTH_GRANT
111+
and not( # Exclude Device Flow whose retry is expected and regulated
112+
isinstance(kwargs.get("data"), dict)
113+
and kwargs["data"].get("grant_type") == DEVICE_AUTH_GRANT
113114
)
114115
and "retry-after" not in set( # Leave it to the Retry-After decorator
115116
h.lower() for h in getattr(result, "headers", {}).keys())

0 commit comments

Comments
 (0)