Skip to content

Release 1.16.0 #428

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

Merged
merged 23 commits into from
Oct 29, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
06c9cff
Merge pull request #414 from AzureAD/bumping-cryptography-upper-bound
rayluo Oct 1, 2021
3062770
Merge branch 'release-1.15.0' into dev
rayluo Oct 1, 2021
62752ad
Expose http_cache parameter, with its docs and recipe.
rayluo Jul 8, 2021
fcf34a2
Merge pull request #407 from AzureAD/http-cache-parameter
rayluo Oct 15, 2021
db104d3
obtain_token_by_browser(..., auth_code_receiver=...)
rayluo Aug 14, 2021
68ef992
Merge branch 'expose-auth-code-receiver' into dev
rayluo Aug 15, 2021
45499ff
Merge remote-tracking branch 'oauth2cli_github/dev' into auth-code-re…
rayluo Oct 16, 2021
1f4ddfe
AuthCodeReceiver supports scheduled_actions now
rayluo Aug 17, 2021
6622313
Merge branch 'auth-code-receiver-scheduled-actions' into dev
rayluo Aug 18, 2021
3e2a0be
Merge remote-tracking branch 'oauth2cli/dev' into auth-code-receiver
rayluo Oct 21, 2021
0322ac7
Adding unit test cases for AuthCodeReceiver
rayluo Aug 18, 2021
dd51799
Disable allow_reuse_address when on Windows
rayluo Aug 20, 2021
ef87c00
Backport to Python 2
rayluo Aug 23, 2021
16a9a34
Merge branch 'auth-code-receiver-and-ports' into dev
rayluo Aug 24, 2021
64141ca
Merge remote-tracking branch 'oauth2cli/dev' into auth-code-receiver
rayluo Oct 27, 2021
f839dc3
Adjusts the path
rayluo Oct 27, 2021
a596b51
Merge pull request #427 from AzureAD/auth-code-receiver
rayluo Oct 28, 2021
b1ef3b9
tests/authcode.py has long been obsolete
rayluo Oct 27, 2021
24694af
Merge branch 'clean-up' into dev
rayluo Oct 28, 2021
c04e6ea
Re-enable REGION env var detection
rayluo Oct 6, 2021
56e4b01
Change Regional Endpoint to require opt-in
rayluo Oct 22, 2021
20eed4a
Merge pull request #425 from AzureAD/region-env-var
rayluo Oct 28, 2021
a7ec5b4
MSAL Python 1.16.0
rayluo Oct 29, 2021
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
80 changes: 69 additions & 11 deletions msal/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@


# The __init__.py will import this. Not the other way around.
__version__ = "1.15.0"
__version__ = "1.16.0"

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -170,6 +170,7 @@ def __init__(
# This way, it holds the same positional param place for PCA,
# when we would eventually want to add this feature to PCA in future.
exclude_scopes=None,
http_cache=None,
):
"""Create an instance of application.

Expand Down Expand Up @@ -285,7 +286,8 @@ def __init__(
which you will later provide via one of the acquire-token request.

:param str azure_region:
Added since MSAL Python 1.12.0.
AAD provides regional endpoints for apps to opt in
to keep their traffic remain inside that region.

As of 2021 May, regional service is only available for
``acquire_token_for_client()`` sent by any of the following scenarios::
Expand All @@ -302,9 +304,7 @@ def __init__(

4. An app which already onboard to the region's allow-list.

MSAL's default value is None, which means region behavior remains off.
If enabled, the `acquire_token_for_client()`-relevant traffic
would remain inside that region.
This parameter defaults to None, which means region behavior remains off.

App developer can opt in to a regional endpoint,
by provide its region name, such as "westus", "eastus2".
Expand All @@ -330,12 +330,69 @@ def __init__(
or provide a custom http_client which has a short timeout.
That way, the latency would be under your control,
but still less performant than opting out of region feature.

New in version 1.12.0.

:param list[str] exclude_scopes: (optional)
Historically MSAL hardcodes `offline_access` scope,
which would allow your app to have prolonged access to user's data.
If that is unnecessary or undesirable for your app,
now you can use this parameter to supply an exclusion list of scopes,
such as ``exclude_scopes = ["offline_access"]``.

:param dict http_cache:
MSAL has long been caching tokens in the ``token_cache``.
Recently, MSAL also introduced a concept of ``http_cache``,
by automatically caching some finite amount of non-token http responses,
so that *long-lived*
``PublicClientApplication`` and ``ConfidentialClientApplication``
would be more performant and responsive in some situations.

This ``http_cache`` parameter accepts any dict-like object.
If not provided, MSAL will use an in-memory dict.

If your app is a command-line app (CLI),
you would want to persist your http_cache across different CLI runs.
The following recipe shows a way to do so::

# Just add the following lines at the beginning of your CLI script
import sys, atexit, pickle
http_cache_filename = sys.argv[0] + ".http_cache"
try:
with open(http_cache_filename, "rb") as f:
persisted_http_cache = pickle.load(f) # Take a snapshot
except (
IOError, # A non-exist http cache file
pickle.UnpicklingError, # A corrupted http cache file
EOFError, # An empty http cache file
AttributeError, ImportError, IndexError, # Other corruption
):
persisted_http_cache = {} # Recover by starting afresh
atexit.register(lambda: pickle.dump(
# When exit, flush it back to the file.
# It may occasionally overwrite another process's concurrent write,
# but that is fine. Subsequent runs will reach eventual consistency.
persisted_http_cache, open(http_cache_file, "wb")))

# And then you can implement your app as you normally would
app = msal.PublicClientApplication(
"your_client_id",
...,
http_cache=persisted_http_cache, # Utilize persisted_http_cache
...,
#token_cache=..., # You may combine the old token_cache trick
# Please refer to token_cache recipe at
# https://msal-python.readthedocs.io/en/latest/#msal.SerializableTokenCache
)
app.acquire_token_interactive(["your", "scope"], ...)

Content inside ``http_cache`` are cheap to obtain.
There is no need to share them among different apps.

Content inside ``http_cache`` will contain no tokens nor
Personally Identifiable Information (PII). Encryption is unnecessary.

New in version 1.16.0.
"""
self.client_id = client_id
self.client_credential = client_credential
Expand Down Expand Up @@ -370,7 +427,7 @@ def __init__(
self.http_client.mount("https://", a)
self.http_client = ThrottledHttpClient(
self.http_client,
{} # Hard code an in-memory cache, for now
{} if http_cache is None else http_cache, # Default to an in-memory dict
)

self.app_name = app_name
Expand Down Expand Up @@ -437,17 +494,18 @@ def _build_telemetry_context(
correlation_id=correlation_id, refresh_reason=refresh_reason)

def _get_regional_authority(self, central_authority):
is_region_specified = bool(self._region_configured
and self._region_configured != self.ATTEMPT_REGION_DISCOVERY)
self._region_detected = self._region_detected or _detect_region(
self.http_client if self._region_configured is not None else None)
if (is_region_specified and self._region_configured != self._region_detected):
if (self._region_configured != self.ATTEMPT_REGION_DISCOVERY
and self._region_configured != self._region_detected):
logger.warning('Region configured ({}) != region detected ({})'.format(
repr(self._region_configured), repr(self._region_detected)))
region_to_use = (
self._region_configured if is_region_specified else self._region_detected)
self._region_detected
if self._region_configured == self.ATTEMPT_REGION_DISCOVERY
else self._region_configured) # It will retain the None i.e. opted out
logger.debug('Region to be used: {}'.format(repr(region_to_use)))
if region_to_use:
logger.info('Region to be used: {}'.format(repr(region_to_use)))
regional_host = ("{}.r.login.microsoftonline.com".format(region_to_use)
if central_authority.instance in (
# The list came from https://github.com/AzureAD/microsoft-authentication-library-for-python/pull/358/files#r629400328
Expand Down
26 changes: 24 additions & 2 deletions msal/oauth2cli/authcode.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"""
import logging
import socket
import sys
from string import Template
import threading
import time
Expand Down Expand Up @@ -103,7 +104,17 @@ def log_message(self, format, *args):
logger.debug(format, *args) # To override the default log-to-stderr behavior


class _AuthCodeHttpServer(HTTPServer):
class _AuthCodeHttpServer(HTTPServer, object):
def __init__(self, server_address, *args, **kwargs):
_, port = server_address
if port and (sys.platform == "win32" or is_wsl()):
# The default allow_reuse_address is True. It works fine on non-Windows.
# On Windows, it undesirably allows multiple servers listening on same port,
# yet the second server would not receive any incoming request.
# So, we need to turn it off.
self.allow_reuse_address = False
super(_AuthCodeHttpServer, self).__init__(server_address, *args, **kwargs)

def handle_timeout(self):
# It will be triggered when no request comes in self.timeout seconds.
# See https://docs.python.org/3/library/socketserver.html#socketserver.BaseServer.handle_timeout
Expand All @@ -119,7 +130,7 @@ class _AuthCodeHttpServer6(_AuthCodeHttpServer):

class AuthCodeReceiver(object):
# This class has (rather than is) an _AuthCodeHttpServer, so it does not leak API
def __init__(self, port=None):
def __init__(self, port=None, scheduled_actions=None):
"""Create a Receiver waiting for incoming auth response.

:param port:
Expand All @@ -128,6 +139,12 @@ def __init__(self, port=None):
If your Identity Provider supports dynamic port, you can use port=0 here.
Port 0 means to use an arbitrary unused port, per this official example:
https://docs.python.org/2.7/library/socketserver.html#asynchronous-mixins

:param scheduled_actions:
For example, if the input is
``[(10, lambda: print("Got stuck during sign in? Call 800-000-0000"))]``
then the receiver would call that lambda function after
waiting the response for 10 seconds.
"""
address = "127.0.0.1" # Hardcode, for now, Not sure what to expose, yet.
# Per RFC 8252 (https://tools.ietf.org/html/rfc8252#section-8.3):
Expand All @@ -141,6 +158,7 @@ def __init__(self, port=None):
# When this server physically listens to a specific IP (as it should),
# you will still be able to specify your redirect_uri using either
# IP (e.g. 127.0.0.1) or localhost, whichever matches your registration.
self._scheduled_actions = sorted(scheduled_actions or []) # Make a copy
Server = _AuthCodeHttpServer6 if ":" in address else _AuthCodeHttpServer
# TODO: But, it would treat "localhost" or "" as IPv4.
# If pressed, we might just expose a family parameter to caller.
Expand Down Expand Up @@ -215,6 +233,10 @@ def get_auth_response(self, timeout=None, **kwargs):
time.sleep(1) # Short detection interval to make happy path responsive
if not t.is_alive(): # Then the thread has finished its job and exited
break
while (self._scheduled_actions
and time.time() - begin > self._scheduled_actions[0][0]):
_, callback = self._scheduled_actions.pop(0)
callback()
return result or None

def _get_auth_response(self, result, auth_uri=None, timeout=None, state=None,
Expand Down
92 changes: 55 additions & 37 deletions msal/oauth2cli/oauth2.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@

import json
try:
from urllib.parse import urlencode, parse_qs, quote_plus, urlparse
from urllib.parse import urlencode, parse_qs, quote_plus, urlparse, urlunparse
except ImportError:
from urlparse import parse_qs, urlparse
from urlparse import parse_qs, urlparse, urlunparse
from urllib import urlencode, quote_plus
import logging
import warnings
Expand Down Expand Up @@ -573,16 +573,8 @@ def authorize(): # A controller in a web app
def obtain_token_by_browser(
# Name influenced by RFC 8252: "native apps should (use) ... user's browser"
self,
scope=None,
extra_scope_to_consent=None,
redirect_uri=None,
timeout=None,
welcome_template=None,
success_template=None,
error_template=None,
auth_params=None,
auth_uri_callback=None,
browser_name=None,
auth_code_receiver=None,
**kwargs):
"""A native app can use this method to obtain token via a local browser.

Expand Down Expand Up @@ -625,38 +617,64 @@ def obtain_token_by_browser(

:return: Same as :func:`~obtain_token_by_auth_code_flow()`
"""
if auth_code_receiver: # Then caller already knows the listen port
return self._obtain_token_by_browser( # Use all input param as-is
auth_code_receiver, redirect_uri=redirect_uri, **kwargs)
# Otherwise we will listen on _redirect_uri.port
_redirect_uri = urlparse(redirect_uri or "http://127.0.0.1:0")
if not _redirect_uri.hostname:
raise ValueError("redirect_uri should contain hostname")
if _redirect_uri.scheme == "https":
raise ValueError("Our local loopback server will not use https")
listen_port = _redirect_uri.port if _redirect_uri.port is not None else 80
# This implementation allows port-less redirect_uri to mean port 80
listen_port = ( # Conventionally, port-less uri would mean port 80
80 if _redirect_uri.port is None else _redirect_uri.port)
try:
with _AuthCodeReceiver(port=listen_port) as receiver:
flow = self.initiate_auth_code_flow(
redirect_uri="http://{host}:{port}".format(
host=_redirect_uri.hostname, port=receiver.get_port(),
) if _redirect_uri.port is not None else "http://{host}".format(
host=_redirect_uri.hostname
), # This implementation uses port-less redirect_uri as-is
scope=_scope_set(scope) | _scope_set(extra_scope_to_consent),
**(auth_params or {}))
auth_response = receiver.get_auth_response(
auth_uri=flow["auth_uri"],
state=flow["state"], # Optional but we choose to do it upfront
timeout=timeout,
welcome_template=welcome_template,
success_template=success_template,
error_template=error_template,
auth_uri_callback=auth_uri_callback,
browser_name=browser_name,
)
uri = redirect_uri if _redirect_uri.port != 0 else urlunparse((
_redirect_uri.scheme,
"{}:{}".format(_redirect_uri.hostname, receiver.get_port()),
_redirect_uri.path,
_redirect_uri.params,
_redirect_uri.query,
_redirect_uri.fragment,
)) # It could be slightly different than raw redirect_uri
self.logger.debug("Using {} as redirect_uri".format(uri))
return self._obtain_token_by_browser(
receiver, redirect_uri=uri, **kwargs)
except PermissionError:
if 0 < listen_port < 1024:
self.logger.error(
"Can't listen on port %s. You may try port 0." % listen_port)
raise
raise ValueError(
"Can't listen on port %s. You may try port 0." % listen_port)

def _obtain_token_by_browser(
self,
auth_code_receiver,
scope=None,
extra_scope_to_consent=None,
redirect_uri=None,
timeout=None,
welcome_template=None,
success_template=None,
error_template=None,
auth_params=None,
auth_uri_callback=None,
browser_name=None,
**kwargs):
# Internally, it calls self.initiate_auth_code_flow() and
# self.obtain_token_by_auth_code_flow().
#
# Parameters are documented in public method obtain_token_by_browser().
flow = self.initiate_auth_code_flow(
redirect_uri=redirect_uri,
scope=_scope_set(scope) | _scope_set(extra_scope_to_consent),
**(auth_params or {}))
auth_response = auth_code_receiver.get_auth_response(
auth_uri=flow["auth_uri"],
state=flow["state"], # Optional but we choose to do it upfront
timeout=timeout,
welcome_template=welcome_template,
success_template=success_template,
error_template=error_template,
auth_uri_callback=auth_uri_callback,
browser_name=browser_name,
)
return self.obtain_token_by_auth_code_flow(
flow, auth_response, scope=scope, **kwargs)

Expand Down
3 changes: 3 additions & 0 deletions msal/region.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@


def _detect_region(http_client=None):
region = os.environ.get("REGION_NAME", "").replace(" ", "").lower() # e.g. westus2
if region:
return region
if http_client:
return _detect_region_of_azure_vm(http_client) # It could hang for minutes
return None
Expand Down
Loading