Skip to content

Commit 5b135b2

Browse files
authored
Merge pull request #428 from AzureAD/release-1.16.0
Release 1.16.0
2 parents 8573a16 + a7ec5b4 commit 5b135b2

File tree

7 files changed

+206
-132
lines changed

7 files changed

+206
-132
lines changed

msal/application.py

Lines changed: 69 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626

2727

2828
# The __init__.py will import this. Not the other way around.
29-
__version__ = "1.15.0"
29+
__version__ = "1.16.0"
3030

3131
logger = logging.getLogger(__name__)
3232

@@ -170,6 +170,7 @@ def __init__(
170170
# This way, it holds the same positional param place for PCA,
171171
# when we would eventually want to add this feature to PCA in future.
172172
exclude_scopes=None,
173+
http_cache=None,
173174
):
174175
"""Create an instance of application.
175176
@@ -285,7 +286,8 @@ def __init__(
285286
which you will later provide via one of the acquire-token request.
286287
287288
:param str azure_region:
288-
Added since MSAL Python 1.12.0.
289+
AAD provides regional endpoints for apps to opt in
290+
to keep their traffic remain inside that region.
289291
290292
As of 2021 May, regional service is only available for
291293
``acquire_token_for_client()`` sent by any of the following scenarios::
@@ -302,9 +304,7 @@ def __init__(
302304
303305
4. An app which already onboard to the region's allow-list.
304306
305-
MSAL's default value is None, which means region behavior remains off.
306-
If enabled, the `acquire_token_for_client()`-relevant traffic
307-
would remain inside that region.
307+
This parameter defaults to None, which means region behavior remains off.
308308
309309
App developer can opt in to a regional endpoint,
310310
by provide its region name, such as "westus", "eastus2".
@@ -330,12 +330,69 @@ def __init__(
330330
or provide a custom http_client which has a short timeout.
331331
That way, the latency would be under your control,
332332
but still less performant than opting out of region feature.
333+
334+
New in version 1.12.0.
335+
333336
:param list[str] exclude_scopes: (optional)
334337
Historically MSAL hardcodes `offline_access` scope,
335338
which would allow your app to have prolonged access to user's data.
336339
If that is unnecessary or undesirable for your app,
337340
now you can use this parameter to supply an exclusion list of scopes,
338341
such as ``exclude_scopes = ["offline_access"]``.
342+
343+
:param dict http_cache:
344+
MSAL has long been caching tokens in the ``token_cache``.
345+
Recently, MSAL also introduced a concept of ``http_cache``,
346+
by automatically caching some finite amount of non-token http responses,
347+
so that *long-lived*
348+
``PublicClientApplication`` and ``ConfidentialClientApplication``
349+
would be more performant and responsive in some situations.
350+
351+
This ``http_cache`` parameter accepts any dict-like object.
352+
If not provided, MSAL will use an in-memory dict.
353+
354+
If your app is a command-line app (CLI),
355+
you would want to persist your http_cache across different CLI runs.
356+
The following recipe shows a way to do so::
357+
358+
# Just add the following lines at the beginning of your CLI script
359+
import sys, atexit, pickle
360+
http_cache_filename = sys.argv[0] + ".http_cache"
361+
try:
362+
with open(http_cache_filename, "rb") as f:
363+
persisted_http_cache = pickle.load(f) # Take a snapshot
364+
except (
365+
IOError, # A non-exist http cache file
366+
pickle.UnpicklingError, # A corrupted http cache file
367+
EOFError, # An empty http cache file
368+
AttributeError, ImportError, IndexError, # Other corruption
369+
):
370+
persisted_http_cache = {} # Recover by starting afresh
371+
atexit.register(lambda: pickle.dump(
372+
# When exit, flush it back to the file.
373+
# It may occasionally overwrite another process's concurrent write,
374+
# but that is fine. Subsequent runs will reach eventual consistency.
375+
persisted_http_cache, open(http_cache_file, "wb")))
376+
377+
# And then you can implement your app as you normally would
378+
app = msal.PublicClientApplication(
379+
"your_client_id",
380+
...,
381+
http_cache=persisted_http_cache, # Utilize persisted_http_cache
382+
...,
383+
#token_cache=..., # You may combine the old token_cache trick
384+
# Please refer to token_cache recipe at
385+
# https://msal-python.readthedocs.io/en/latest/#msal.SerializableTokenCache
386+
)
387+
app.acquire_token_interactive(["your", "scope"], ...)
388+
389+
Content inside ``http_cache`` are cheap to obtain.
390+
There is no need to share them among different apps.
391+
392+
Content inside ``http_cache`` will contain no tokens nor
393+
Personally Identifiable Information (PII). Encryption is unnecessary.
394+
395+
New in version 1.16.0.
339396
"""
340397
self.client_id = client_id
341398
self.client_credential = client_credential
@@ -370,7 +427,7 @@ def __init__(
370427
self.http_client.mount("https://", a)
371428
self.http_client = ThrottledHttpClient(
372429
self.http_client,
373-
{} # Hard code an in-memory cache, for now
430+
{} if http_cache is None else http_cache, # Default to an in-memory dict
374431
)
375432

376433
self.app_name = app_name
@@ -437,17 +494,18 @@ def _build_telemetry_context(
437494
correlation_id=correlation_id, refresh_reason=refresh_reason)
438495

439496
def _get_regional_authority(self, central_authority):
440-
is_region_specified = bool(self._region_configured
441-
and self._region_configured != self.ATTEMPT_REGION_DISCOVERY)
442497
self._region_detected = self._region_detected or _detect_region(
443498
self.http_client if self._region_configured is not None else None)
444-
if (is_region_specified and self._region_configured != self._region_detected):
499+
if (self._region_configured != self.ATTEMPT_REGION_DISCOVERY
500+
and self._region_configured != self._region_detected):
445501
logger.warning('Region configured ({}) != region detected ({})'.format(
446502
repr(self._region_configured), repr(self._region_detected)))
447503
region_to_use = (
448-
self._region_configured if is_region_specified else self._region_detected)
504+
self._region_detected
505+
if self._region_configured == self.ATTEMPT_REGION_DISCOVERY
506+
else self._region_configured) # It will retain the None i.e. opted out
507+
logger.debug('Region to be used: {}'.format(repr(region_to_use)))
449508
if region_to_use:
450-
logger.info('Region to be used: {}'.format(repr(region_to_use)))
451509
regional_host = ("{}.r.login.microsoftonline.com".format(region_to_use)
452510
if central_authority.instance in (
453511
# The list came from https://github.com/AzureAD/microsoft-authentication-library-for-python/pull/358/files#r629400328

msal/oauth2cli/authcode.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"""
88
import logging
99
import socket
10+
import sys
1011
from string import Template
1112
import threading
1213
import time
@@ -103,7 +104,17 @@ def log_message(self, format, *args):
103104
logger.debug(format, *args) # To override the default log-to-stderr behavior
104105

105106

106-
class _AuthCodeHttpServer(HTTPServer):
107+
class _AuthCodeHttpServer(HTTPServer, object):
108+
def __init__(self, server_address, *args, **kwargs):
109+
_, port = server_address
110+
if port and (sys.platform == "win32" or is_wsl()):
111+
# The default allow_reuse_address is True. It works fine on non-Windows.
112+
# On Windows, it undesirably allows multiple servers listening on same port,
113+
# yet the second server would not receive any incoming request.
114+
# So, we need to turn it off.
115+
self.allow_reuse_address = False
116+
super(_AuthCodeHttpServer, self).__init__(server_address, *args, **kwargs)
117+
107118
def handle_timeout(self):
108119
# It will be triggered when no request comes in self.timeout seconds.
109120
# See https://docs.python.org/3/library/socketserver.html#socketserver.BaseServer.handle_timeout
@@ -119,7 +130,7 @@ class _AuthCodeHttpServer6(_AuthCodeHttpServer):
119130

120131
class AuthCodeReceiver(object):
121132
# This class has (rather than is) an _AuthCodeHttpServer, so it does not leak API
122-
def __init__(self, port=None):
133+
def __init__(self, port=None, scheduled_actions=None):
123134
"""Create a Receiver waiting for incoming auth response.
124135
125136
:param port:
@@ -128,6 +139,12 @@ def __init__(self, port=None):
128139
If your Identity Provider supports dynamic port, you can use port=0 here.
129140
Port 0 means to use an arbitrary unused port, per this official example:
130141
https://docs.python.org/2.7/library/socketserver.html#asynchronous-mixins
142+
143+
:param scheduled_actions:
144+
For example, if the input is
145+
``[(10, lambda: print("Got stuck during sign in? Call 800-000-0000"))]``
146+
then the receiver would call that lambda function after
147+
waiting the response for 10 seconds.
131148
"""
132149
address = "127.0.0.1" # Hardcode, for now, Not sure what to expose, yet.
133150
# Per RFC 8252 (https://tools.ietf.org/html/rfc8252#section-8.3):
@@ -141,6 +158,7 @@ def __init__(self, port=None):
141158
# When this server physically listens to a specific IP (as it should),
142159
# you will still be able to specify your redirect_uri using either
143160
# IP (e.g. 127.0.0.1) or localhost, whichever matches your registration.
161+
self._scheduled_actions = sorted(scheduled_actions or []) # Make a copy
144162
Server = _AuthCodeHttpServer6 if ":" in address else _AuthCodeHttpServer
145163
# TODO: But, it would treat "localhost" or "" as IPv4.
146164
# If pressed, we might just expose a family parameter to caller.
@@ -215,6 +233,10 @@ def get_auth_response(self, timeout=None, **kwargs):
215233
time.sleep(1) # Short detection interval to make happy path responsive
216234
if not t.is_alive(): # Then the thread has finished its job and exited
217235
break
236+
while (self._scheduled_actions
237+
and time.time() - begin > self._scheduled_actions[0][0]):
238+
_, callback = self._scheduled_actions.pop(0)
239+
callback()
218240
return result or None
219241

220242
def _get_auth_response(self, result, auth_uri=None, timeout=None, state=None,

msal/oauth2cli/oauth2.py

Lines changed: 55 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33

44
import json
55
try:
6-
from urllib.parse import urlencode, parse_qs, quote_plus, urlparse
6+
from urllib.parse import urlencode, parse_qs, quote_plus, urlparse, urlunparse
77
except ImportError:
8-
from urlparse import parse_qs, urlparse
8+
from urlparse import parse_qs, urlparse, urlunparse
99
from urllib import urlencode, quote_plus
1010
import logging
1111
import warnings
@@ -573,16 +573,8 @@ def authorize(): # A controller in a web app
573573
def obtain_token_by_browser(
574574
# Name influenced by RFC 8252: "native apps should (use) ... user's browser"
575575
self,
576-
scope=None,
577-
extra_scope_to_consent=None,
578576
redirect_uri=None,
579-
timeout=None,
580-
welcome_template=None,
581-
success_template=None,
582-
error_template=None,
583-
auth_params=None,
584-
auth_uri_callback=None,
585-
browser_name=None,
577+
auth_code_receiver=None,
586578
**kwargs):
587579
"""A native app can use this method to obtain token via a local browser.
588580
@@ -625,38 +617,64 @@ def obtain_token_by_browser(
625617
626618
:return: Same as :func:`~obtain_token_by_auth_code_flow()`
627619
"""
620+
if auth_code_receiver: # Then caller already knows the listen port
621+
return self._obtain_token_by_browser( # Use all input param as-is
622+
auth_code_receiver, redirect_uri=redirect_uri, **kwargs)
623+
# Otherwise we will listen on _redirect_uri.port
628624
_redirect_uri = urlparse(redirect_uri or "http://127.0.0.1:0")
629625
if not _redirect_uri.hostname:
630626
raise ValueError("redirect_uri should contain hostname")
631-
if _redirect_uri.scheme == "https":
632-
raise ValueError("Our local loopback server will not use https")
633-
listen_port = _redirect_uri.port if _redirect_uri.port is not None else 80
634-
# This implementation allows port-less redirect_uri to mean port 80
627+
listen_port = ( # Conventionally, port-less uri would mean port 80
628+
80 if _redirect_uri.port is None else _redirect_uri.port)
635629
try:
636630
with _AuthCodeReceiver(port=listen_port) as receiver:
637-
flow = self.initiate_auth_code_flow(
638-
redirect_uri="http://{host}:{port}".format(
639-
host=_redirect_uri.hostname, port=receiver.get_port(),
640-
) if _redirect_uri.port is not None else "http://{host}".format(
641-
host=_redirect_uri.hostname
642-
), # This implementation uses port-less redirect_uri as-is
643-
scope=_scope_set(scope) | _scope_set(extra_scope_to_consent),
644-
**(auth_params or {}))
645-
auth_response = receiver.get_auth_response(
646-
auth_uri=flow["auth_uri"],
647-
state=flow["state"], # Optional but we choose to do it upfront
648-
timeout=timeout,
649-
welcome_template=welcome_template,
650-
success_template=success_template,
651-
error_template=error_template,
652-
auth_uri_callback=auth_uri_callback,
653-
browser_name=browser_name,
654-
)
631+
uri = redirect_uri if _redirect_uri.port != 0 else urlunparse((
632+
_redirect_uri.scheme,
633+
"{}:{}".format(_redirect_uri.hostname, receiver.get_port()),
634+
_redirect_uri.path,
635+
_redirect_uri.params,
636+
_redirect_uri.query,
637+
_redirect_uri.fragment,
638+
)) # It could be slightly different than raw redirect_uri
639+
self.logger.debug("Using {} as redirect_uri".format(uri))
640+
return self._obtain_token_by_browser(
641+
receiver, redirect_uri=uri, **kwargs)
655642
except PermissionError:
656-
if 0 < listen_port < 1024:
657-
self.logger.error(
658-
"Can't listen on port %s. You may try port 0." % listen_port)
659-
raise
643+
raise ValueError(
644+
"Can't listen on port %s. You may try port 0." % listen_port)
645+
646+
def _obtain_token_by_browser(
647+
self,
648+
auth_code_receiver,
649+
scope=None,
650+
extra_scope_to_consent=None,
651+
redirect_uri=None,
652+
timeout=None,
653+
welcome_template=None,
654+
success_template=None,
655+
error_template=None,
656+
auth_params=None,
657+
auth_uri_callback=None,
658+
browser_name=None,
659+
**kwargs):
660+
# Internally, it calls self.initiate_auth_code_flow() and
661+
# self.obtain_token_by_auth_code_flow().
662+
#
663+
# Parameters are documented in public method obtain_token_by_browser().
664+
flow = self.initiate_auth_code_flow(
665+
redirect_uri=redirect_uri,
666+
scope=_scope_set(scope) | _scope_set(extra_scope_to_consent),
667+
**(auth_params or {}))
668+
auth_response = auth_code_receiver.get_auth_response(
669+
auth_uri=flow["auth_uri"],
670+
state=flow["state"], # Optional but we choose to do it upfront
671+
timeout=timeout,
672+
welcome_template=welcome_template,
673+
success_template=success_template,
674+
error_template=error_template,
675+
auth_uri_callback=auth_uri_callback,
676+
browser_name=browser_name,
677+
)
660678
return self.obtain_token_by_auth_code_flow(
661679
flow, auth_response, scope=scope, **kwargs)
662680

msal/region.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55

66

77
def _detect_region(http_client=None):
8+
region = os.environ.get("REGION_NAME", "").replace(" ", "").lower() # e.g. westus2
9+
if region:
10+
return region
811
if http_client:
912
return _detect_region_of_azure_vm(http_client) # It could hang for minutes
1013
return None

0 commit comments

Comments
 (0)