Skip to content

Commit

Permalink
Merge pull request #321 from AzureAD/release-1.10.0
Browse files Browse the repository at this point in the history
MSAL Python 1.10.0
  • Loading branch information
rayluo authored Mar 8, 2021
2 parents 72a7250 + 896fbed commit 3b9b6aa
Show file tree
Hide file tree
Showing 16 changed files with 213 additions and 90 deletions.
3 changes: 3 additions & 0 deletions .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ on:
pull_request:
branches: [ dev ]

# This guards against unknown PR until a community member vet it and label it.
types: [ labeled ]

jobs:
ci:
env:
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ src/build

# Virtual Environments
/env*

.venv/
docs/_build/
# Visual Studio Files
/.vs/*
/tests/.vs/*
Expand Down
14 changes: 10 additions & 4 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#
from datetime import date
import os
import sys
sys.path.insert(0, os.path.abspath('..'))
Expand All @@ -20,7 +21,7 @@
# -- Project information -----------------------------------------------------

project = u'MSAL Python'
copyright = u'2018, Microsoft'
copyright = u'{0}, Microsoft'.format(date.today().year)
author = u'Microsoft'

# The short X.Y version
Expand Down Expand Up @@ -77,13 +78,18 @@
# a list of builtin themes.
#
# html_theme = 'alabaster'
html_theme = 'sphinx_rtd_theme'
html_theme = 'furo'

# Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the
# documentation.
#
# html_theme_options = {}
html_theme_options = {
"light_css_variables": {
"font-stack": "'Segoe UI', SegoeUI, 'Helvetica Neue', Helvetica, Arial, sans-serif",
"font-stack--monospace": "SFMono-Regular, Consolas, 'Liberation Mono', Menlo, Courier, monospace",
},
}

# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
Expand Down Expand Up @@ -176,4 +182,4 @@
epub_exclude_files = ['search.html']


# -- Extension configuration -------------------------------------------------
# -- Extension configuration -------------------------------------------------
50 changes: 14 additions & 36 deletions docs/index.rst
Original file line number Diff line number Diff line change
@@ -1,17 +1,13 @@
.. MSAL Python documentation master file, created by
sphinx-quickstart on Tue Dec 18 10:53:22 2018.
You can adapt this file completely to your liking, but it should at least
contain the root `toctree` directive.
.. This file is also inspired by
https://pythonhosted.org/an_example_pypi_project/sphinx.html#full-code-example
Welcome to MSAL Python's documentation!
=======================================
MSAL Python documentation
=========================

.. toctree::
:maxdepth: 2
:caption: Contents:
:hidden:

MSAL Documentation <https://docs.microsoft.com/en-au/azure/active-directory/develop/msal-authentication-flows>
GitHub Repository <https://github.com/AzureAD/microsoft-authentication-library-for-python>

You can find high level conceptual documentations in the project
`README <https://github.com/AzureAD/microsoft-authentication-library-for-python>`_
Expand All @@ -22,9 +18,8 @@ and

The documentation hosted here is for API Reference.


PublicClientApplication and ConfidentialClientApplication
=========================================================
API
===

MSAL proposes a clean separation between
`public client applications and confidential client applications
Expand All @@ -35,31 +30,22 @@ with different methods for different authentication scenarios.

PublicClientApplication
-----------------------

.. autoclass:: msal.PublicClientApplication
:members:
:inherited-members:

ConfidentialClientApplication
-----------------------------
.. autoclass:: msal.ConfidentialClientApplication
:members:


Shared Methods
--------------
Both PublicClientApplication and ConfidentialClientApplication
have following methods inherited from their base class.
You typically do not need to initiate this base class, though.

.. autoclass:: msal.ClientApplication
.. autoclass:: msal.ConfidentialClientApplication
:members:

.. automethod:: __init__

:inherited-members:

TokenCache
==========
----------

One of the parameter accepted by
One of the parameters accepted by
both `PublicClientApplication` and `ConfidentialClientApplication`
is the `TokenCache`.

Expand All @@ -71,11 +57,3 @@ See `SerializableTokenCache` for example.

.. autoclass:: msal.SerializableTokenCache
:members:


Indices and tables
==================

* :ref:`genindex`
* :ref:`search`

2 changes: 2 additions & 0 deletions docs/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
furo
-r ../requirements.txt
74 changes: 50 additions & 24 deletions msal/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@


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

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -100,6 +100,12 @@ def _str2bytes(raw):
return raw


def _clean_up(result):
if isinstance(result, dict):
result.pop("refresh_in", None) # MSAL handled refresh_in, customers need not
return result


class ClientApplication(object):

ACQUIRE_TOKEN_SILENT_ID = "84"
Expand Down Expand Up @@ -507,7 +513,7 @@ def authorize(): # A controller in a web app
return redirect(url_for("index"))
"""
self._validate_ssh_cert_input_data(kwargs.get("data", {}))
return self.client.obtain_token_by_auth_code_flow(
return _clean_up(self.client.obtain_token_by_auth_code_flow(
auth_code_flow,
auth_response,
scope=decorate_scope(scopes, self.client_id) if scopes else None,
Expand All @@ -521,7 +527,7 @@ def authorize(): # A controller in a web app
claims=_merge_claims_challenge_and_capabilities(
self._client_capabilities,
auth_code_flow.pop("claims_challenge", None))),
**kwargs)
**kwargs))

def acquire_token_by_authorization_code(
self,
Expand Down Expand Up @@ -580,7 +586,7 @@ def acquire_token_by_authorization_code(
"Change your acquire_token_by_authorization_code() "
"to acquire_token_by_auth_code_flow()", DeprecationWarning)
with warnings.catch_warnings(record=True):
return self.client.obtain_token_by_authorization_code(
return _clean_up(self.client.obtain_token_by_authorization_code(
code, redirect_uri=redirect_uri,
scope=decorate_scope(scopes, self.client_id),
headers={
Expand All @@ -593,7 +599,7 @@ def acquire_token_by_authorization_code(
claims=_merge_claims_challenge_and_capabilities(
self._client_capabilities, claims_challenge)),
nonce=nonce,
**kwargs)
**kwargs))

def get_accounts(self, username=None):
"""Get a list of accounts which previously signed in, i.e. exists in cache.
Expand Down Expand Up @@ -822,6 +828,7 @@ def _acquire_token_silent_from_cache_and_possibly_refresh_it(
force_refresh=False, # type: Optional[boolean]
claims_challenge=None,
**kwargs):
access_token_from_cache = None
if not (force_refresh or claims_challenge): # Bypass AT when desired or using claims
query={
"client_id": self.client_id,
Expand All @@ -839,17 +846,27 @@ def _acquire_token_silent_from_cache_and_possibly_refresh_it(
now = time.time()
for entry in matches:
expires_in = int(entry["expires_on"]) - now
if expires_in < 5*60:
if expires_in < 5*60: # Then consider it expired
continue # Removal is not necessary, it will be overwritten
logger.debug("Cache hit an AT")
return { # Mimic a real response
access_token_from_cache = { # Mimic a real response
"access_token": entry["secret"],
"token_type": entry.get("token_type", "Bearer"),
"expires_in": int(expires_in), # OAuth2 specs defines it as int
}
return self._acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family(
if "refresh_on" in entry and int(entry["refresh_on"]) < now: # aging
break # With a fallback in hand, we break here to go refresh
return access_token_from_cache # It is still good as new
try:
result = self._acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family(
authority, decorate_scope(scopes, self.client_id), account,
force_refresh=force_refresh, claims_challenge=claims_challenge, **kwargs)
result = _clean_up(result)
if (result and "error" not in result) or (not access_token_from_cache):
return result
except: # The exact HTTP exception is transportation-layer dependent
logger.exception("Refresh token failed") # Potential AAD outage?
return access_token_from_cache

def _acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family(
self, authority, scopes, account, **kwargs):
Expand Down Expand Up @@ -907,11 +924,17 @@ def _acquire_token_silent_by_finding_specific_refresh_token(
client = self._build_client(self.client_credential, authority)

response = None # A distinguishable value to mean cache is empty
for entry in matches:
for entry in sorted( # Since unfit RTs would not be aggressively removed,
# we start from newer RTs which are more likely fit.
matches,
key=lambda e: int(e.get("last_modification_time", "0")),
reverse=True):
logger.debug("Cache attempts an RT")
response = client.obtain_token_by_refresh_token(
entry, rt_getter=lambda token_item: token_item["secret"],
on_removing_rt=rt_remover or self.token_cache.remove_rt,
on_removing_rt=lambda rt_item: None, # Disable RT removal,
# because an invalid_grant could be caused by new MFA policy,
# the RT could still be useful for other MFA-less scope or tenant
on_obtaining_tokens=lambda event: self.token_cache.add(dict(
event,
environment=authority.instance,
Expand Down Expand Up @@ -976,7 +999,7 @@ def acquire_token_by_refresh_token(self, refresh_token, scopes, **kwargs):
* A dict contains no "error" key means migration was successful.
"""
self._validate_ssh_cert_input_data(kwargs.get("data", {}))
return self.client.obtain_token_by_refresh_token(
return _clean_up(self.client.obtain_token_by_refresh_token(
refresh_token,
scope=decorate_scope(scopes, self.client_id),
headers={
Expand All @@ -987,7 +1010,7 @@ def acquire_token_by_refresh_token(self, refresh_token, scopes, **kwargs):
rt_getter=lambda rt: rt,
on_updating_rt=False,
on_removing_rt=lambda rt_item: None, # No OP
**kwargs)
**kwargs))


class PublicClientApplication(ClientApplication): # browser app or mobile app
Expand All @@ -1013,6 +1036,9 @@ def acquire_token_interactive(
**kwargs):
"""Acquire token interactively i.e. via a local browser.
Prerequisite: In Azure Portal, configure the Redirect URI of your
"Mobile and Desktop application" as ``http://localhost``.
:param list scope:
It is a list of case-sensitive strings.
:param str prompt:
Expand Down Expand Up @@ -1061,7 +1087,7 @@ def acquire_token_interactive(
self._validate_ssh_cert_input_data(kwargs.get("data", {}))
claims = _merge_claims_challenge_and_capabilities(
self._client_capabilities, claims_challenge)
return self.client.obtain_token_by_browser(
return _clean_up(self.client.obtain_token_by_browser(
scope=decorate_scope(scopes, self.client_id) if scopes else None,
extra_scope_to_consent=extra_scopes_to_consent,
redirect_uri="http://localhost:{port}".format(
Expand All @@ -1080,7 +1106,7 @@ def acquire_token_interactive(
CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header(
self.ACQUIRE_TOKEN_INTERACTIVE),
},
**kwargs)
**kwargs))

def initiate_device_flow(self, scopes=None, **kwargs):
"""Initiate a Device Flow instance,
Expand Down Expand Up @@ -1123,7 +1149,7 @@ def acquire_token_by_device_flow(self, flow, claims_challenge=None, **kwargs):
- A successful response would contain "access_token" key,
- an error response would contain "error" and usually "error_description".
"""
return self.client.obtain_token_by_device_flow(
return _clean_up(self.client.obtain_token_by_device_flow(
flow,
data=dict(
kwargs.pop("data", {}),
Expand All @@ -1139,7 +1165,7 @@ def acquire_token_by_device_flow(self, flow, claims_challenge=None, **kwargs):
CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header(
self.ACQUIRE_TOKEN_BY_DEVICE_FLOW_ID),
},
**kwargs)
**kwargs))

def acquire_token_by_username_password(
self, username, password, scopes, claims_challenge=None, **kwargs):
Expand Down Expand Up @@ -1177,15 +1203,15 @@ def acquire_token_by_username_password(
user_realm_result = self.authority.user_realm_discovery(
username, correlation_id=headers[CLIENT_REQUEST_ID])
if user_realm_result.get("account_type") == "Federated":
return self._acquire_token_by_username_password_federated(
return _clean_up(self._acquire_token_by_username_password_federated(
user_realm_result, username, password, scopes=scopes,
data=data,
headers=headers, **kwargs)
return self.client.obtain_token_by_username_password(
headers=headers, **kwargs))
return _clean_up(self.client.obtain_token_by_username_password(
username, password, scope=scopes,
headers=headers,
data=data,
**kwargs)
**kwargs))

def _acquire_token_by_username_password_federated(
self, user_realm_result, username, password, scopes=None, **kwargs):
Expand Down Expand Up @@ -1245,7 +1271,7 @@ def acquire_token_for_client(self, scopes, claims_challenge=None, **kwargs):
"""
# TBD: force_refresh behavior
self._validate_ssh_cert_input_data(kwargs.get("data", {}))
return self.client.obtain_token_for_client(
return _clean_up(self.client.obtain_token_for_client(
scope=scopes, # This grant flow requires no scope decoration
headers={
CLIENT_REQUEST_ID: _get_new_correlation_id(),
Expand All @@ -1256,7 +1282,7 @@ def acquire_token_for_client(self, scopes, claims_challenge=None, **kwargs):
kwargs.pop("data", {}),
claims=_merge_claims_challenge_and_capabilities(
self._client_capabilities, claims_challenge)),
**kwargs)
**kwargs))

def acquire_token_on_behalf_of(self, user_assertion, scopes, claims_challenge=None, **kwargs):
"""Acquires token using on-behalf-of (OBO) flow.
Expand Down Expand Up @@ -1286,7 +1312,7 @@ def acquire_token_on_behalf_of(self, user_assertion, scopes, claims_challenge=No
"""
# The implementation is NOT based on Token Exchange
# https://tools.ietf.org/html/draft-ietf-oauth-token-exchange-16
return self.client.obtain_token_by_assertion( # bases on assertion RFC 7521
return _clean_up(self.client.obtain_token_by_assertion( # bases on assertion RFC 7521
user_assertion,
self.client.GRANT_TYPE_JWT, # IDTs and AAD ATs are all JWTs
scope=decorate_scope(scopes, self.client_id), # Decoration is used for:
Expand All @@ -1305,4 +1331,4 @@ def acquire_token_on_behalf_of(self, user_assertion, scopes, claims_challenge=No
CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header(
self.ACQUIRE_TOKEN_ON_BEHALF_OF_ID),
},
**kwargs)
**kwargs))
Loading

0 comments on commit 3b9b6aa

Please sign in to comment.