Skip to content

MSAL Python 1.10.0 #321

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 18 commits into from
Mar 8, 2021
Merged
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
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