Skip to content

Commit

Permalink
Merge pull request #195 from AzureAD/release-1.3.0
Browse files Browse the repository at this point in the history
MSAL Python 1.3.0
  • Loading branch information
rayluo authored May 15, 2020
2 parents 6bade9f + c789546 commit 3d24f53
Show file tree
Hide file tree
Showing 15 changed files with 688 additions and 255 deletions.
20 changes: 12 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
# Microsoft Authentication Library (MSAL) for Python


| `dev` branch | Reference Docs
|---------------|---------------
[![Build status](https://api.travis-ci.org/AzureAD/microsoft-authentication-library-for-python.svg?branch=dev)](https://travis-ci.org/AzureAD/microsoft-authentication-library-for-python) | [![Documentation Status](https://readthedocs.org/projects/msal-python/badge/?version=latest)](https://msal-python.readthedocs.io/en/latest/?badge=latest)
| `dev` branch | Reference Docs | # of Downloads
|---------------|---------------|----------------|
[![Build status](https://api.travis-ci.org/AzureAD/microsoft-authentication-library-for-python.svg?branch=dev)](https://travis-ci.org/AzureAD/microsoft-authentication-library-for-python) | [![Documentation Status](https://readthedocs.org/projects/msal-python/badge/?version=latest)](https://msal-python.readthedocs.io/en/latest/?badge=latest) | [![Download monthly](https://pepy.tech/badge/msal/month)](https://pypistats.org/packages/msal)

The Microsoft Authentication Library for Python enables applications to integrate with the [Microsoft identity platform](https://aka.ms/aaddevv2). It allows you to sign in users or apps with Microsoft identities ([Azure AD](https://azure.microsoft.com/services/active-directory/), [Microsoft Accounts](https://account.microsoft.com) and [Azure AD B2C](https://azure.microsoft.com/services/active-directory-b2c/) accounts) and obtain tokens to call Microsoft APIs such as [Microsoft Graph](https://graph.microsoft.io/) or your own APIs registered with the Microsoft identity platform. It is built using industry standard OAuth2 and OpenID Connect protocols

Expand Down Expand Up @@ -35,6 +35,11 @@ Before using MSAL Python (or any MSAL SDKs, for that matter), you will have to
[register your application with the Microsoft identity platform](https://docs.microsoft.com/azure/active-directory/develop/quickstart-v2-register-an-app).

Acquiring tokens with MSAL Python follows this 3-step pattern.
(Note: That is the high level conceptual pattern.
There will be some variations for different flows. They are demonstrated in
[runnable samples hosted right in this repo](https://github.com/AzureAD/microsoft-authentication-library-for-python/tree/dev/sample).
)


1. MSAL proposes a clean separation between
[public client applications, and confidential client applications](https://tools.ietf.org/html/rfc6749#section-2.1).
Expand All @@ -43,7 +48,9 @@ Acquiring tokens with MSAL Python follows this 3-step pattern.

```python
from msal import PublicClientApplication
app = PublicClientApplication("your_client_id", authority="...")
app = PublicClientApplication(
"your_client_id",
"authority": "https://login.microsoftonline.com/Enter_the_Tenant_Name_Here")
```

Later, each time you would want an access token, you start by:
Expand All @@ -67,7 +74,7 @@ Acquiring tokens with MSAL Python follows this 3-step pattern.
# Assuming the end user chose this one
chosen = accounts[0]
# Now let's try to find a token in cache for this account
result = app.acquire_token_silent(config["scope"], account=chosen)
result = app.acquire_token_silent(["your_scope"], account=chosen)
```

3. Either there is no suitable token in the cache, or you chose to skip the previous step,
Expand All @@ -86,9 +93,6 @@ Acquiring tokens with MSAL Python follows this 3-step pattern.
print(result.get("correlation_id")) # You may need this when reporting a bug
```

That is the high level pattern. There will be some variations for different flows. They are demonstrated in
[samples hosted right in this repo](https://github.com/AzureAD/microsoft-authentication-library-for-python/tree/dev/sample).

Refer the [Wiki](https://github.com/AzureAD/microsoft-authentication-library-for-python/wiki) pages for more details on the MSAL Python functionality and usage.

## Migrating from ADAL
Expand Down
118 changes: 92 additions & 26 deletions msal/application.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import functools
import json
import time
try: # Python 2
from urlparse import urljoin
Expand All @@ -19,7 +21,7 @@


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

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -54,11 +56,11 @@ def decorate_scope(
CLIENT_CURRENT_TELEMETRY = 'x-client-current-telemetry'

def _get_new_correlation_id():
return str(uuid.uuid4())
return str(uuid.uuid4())


def _build_current_telemetry_request_header(public_api_id, force_refresh=False):
return "1|{},{}|".format(public_api_id, "1" if force_refresh else "0")
return "1|{},{}|".format(public_api_id, "1" if force_refresh else "0")


def extract_certs(public_cert_content):
Expand Down Expand Up @@ -92,12 +94,14 @@ def __init__(
self, client_id,
client_credential=None, authority=None, validate_authority=True,
token_cache=None,
http_client=None,
verify=True, proxies=None, timeout=None,
client_claims=None, app_name=None, app_version=None):
"""Create an instance of application.
:param client_id: Your app has a client_id after you register it on AAD.
:param client_credential:
:param str client_id: Your app has a client_id after you register it on AAD.
:param str client_credential:
For :class:`PublicClientApplication`, you simply use `None` here.
For :class:`ConfidentialClientApplication`,
it can be a string containing client secret,
Expand All @@ -114,6 +118,17 @@ def __init__(
which will be sent through 'x5c' JWT header only for
subject name and issuer authentication to support cert auto rolls.
Per `specs <https://tools.ietf.org/html/rfc7515#section-4.1.6>`_,
"the certificate containing
the public key corresponding to the key used to digitally sign the
JWS MUST be the first certificate. This MAY be followed by
additional certificates, with each subsequent certificate being the
one used to certify the previous one."
However, your certificate's issuer may use a different order.
So, if your attempt ends up with an error AADSTS700027 -
"The provided signature value did not match the expected signature value",
you may try use only the leaf cert (in PEM/str format) instead.
:param dict client_claims:
*Added in version 0.5.0*:
It is a dictionary of extra claims that would be signed by
Expand All @@ -139,18 +154,24 @@ def __init__(
:param TokenCache cache:
Sets the token cache used by this ClientApplication instance.
By default, an in-memory cache will be created and used.
:param http_client: (optional)
Your implementation of abstract class HttpClient <msal.oauth2cli.http.http_client>
Defaults to a requests session instance
:param verify: (optional)
It will be passed to the
`verify parameter in the underlying requests library
<http://docs.python-requests.org/en/v2.9.1/user/advanced/#ssl-cert-verification>`_
This does not apply if you have chosen to pass your own Http client
:param proxies: (optional)
It will be passed to the
`proxies parameter in the underlying requests library
<http://docs.python-requests.org/en/v2.9.1/user/advanced/#proxies>`_
This does not apply if you have chosen to pass your own Http client
:param timeout: (optional)
It will be passed to the
`timeout parameter in the underlying requests library
<http://docs.python-requests.org/en/v2.9.1/user/advanced/#timeouts>`_
This does not apply if you have chosen to pass your own Http client
:param app_name: (optional)
You can provide your application name for Microsoft telemetry purposes.
Default value is None, means it will not be passed to Microsoft.
Expand All @@ -161,14 +182,21 @@ def __init__(
self.client_id = client_id
self.client_credential = client_credential
self.client_claims = client_claims
self.verify = verify
self.proxies = proxies
self.timeout = timeout
if http_client:
self.http_client = http_client
else:
self.http_client = requests.Session()
self.http_client.verify = verify
self.http_client.proxies = proxies
# Requests, does not support session - wide timeout
# But you can patch that (https://github.com/psf/requests/issues/3341):
self.http_client.request = functools.partial(
self.http_client.request, timeout=timeout)
self.app_name = app_name
self.app_version = app_version
self.authority = Authority(
authority or "https://login.microsoftonline.com/common/",
validate_authority, verify=verify, proxies=proxies, timeout=timeout)
self.http_client, validate_authority=validate_authority)
# Here the self.authority is not the same type as authority in input
self.token_cache = token_cache or TokenCache()
self.client = self._build_client(client_credential, self.authority)
Expand Down Expand Up @@ -211,14 +239,14 @@ def _build_client(self, client_credential, authority):
return Client(
server_configuration,
self.client_id,
http_client=self.http_client,
default_headers=default_headers,
default_body=default_body,
client_assertion=client_assertion,
client_assertion_type=client_assertion_type,
on_obtaining_tokens=self.token_cache.add,
on_removing_rt=self.token_cache.remove_rt,
on_updating_rt=self.token_cache.update_rt,
verify=self.verify, proxies=self.proxies, timeout=self.timeout)
on_updating_rt=self.token_cache.update_rt)

def get_authorization_request_url(
self,
Expand All @@ -230,6 +258,7 @@ def get_authorization_request_url(
response_type="code", # Can be "token" if you use Implicit Grant
prompt=None,
nonce=None,
domain_hint=None, # type: Optional[str]
**kwargs):
"""Constructs a URL for you to start a Authorization Code Grant.
Expand All @@ -251,6 +280,12 @@ def get_authorization_request_url(
:param nonce:
A cryptographically random value used to mitigate replay attacks. See also
`OIDC specs <https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest>`_.
:param domain_hint:
Can be one of "consumers" or "organizations" or your tenant domain "contoso.com".
If included, it will skip the email-based discovery process that user goes
through on the sign-in page, leading to a slightly more streamlined user experience.
https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow#request-an-authorization-code
https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-oapx/86fb452d-e34a-494e-ac61-e526e263b6d8
:return: The authorization url as a string.
"""
""" # TBD: this would only be meaningful in a new acquire_token_interactive()
Expand All @@ -269,18 +304,20 @@ def get_authorization_request_url(
# Multi-tenant app can use new authority on demand
the_authority = Authority(
authority,
verify=self.verify, proxies=self.proxies, timeout=self.timeout,
self.http_client
) if authority else self.authority

client = Client(
{"authorization_endpoint": the_authority.authorization_endpoint},
self.client_id)
self.client_id,
http_client=self.http_client)
return client.build_auth_request_uri(
response_type=response_type,
redirect_uri=redirect_uri, state=state, login_hint=login_hint,
prompt=prompt,
scope=decorate_scope(scopes, self.client_id),
nonce=nonce,
domain_hint=domain_hint,
)

def acquire_token_by_authorization_code(
Expand Down Expand Up @@ -379,13 +416,12 @@ def _find_msal_accounts(self, environment):

def _get_authority_aliases(self, instance):
if not self.authority_groups:
resp = requests.get(
resp = self.http_client.get(
"https://login.microsoftonline.com/common/discovery/instance?api-version=1.1&authorization_endpoint=https://login.microsoftonline.com/common/oauth2/authorize",
headers={'Accept': 'application/json'},
verify=self.verify, proxies=self.proxies, timeout=self.timeout)
headers={'Accept': 'application/json'})
resp.raise_for_status()
self.authority_groups = [
set(group['aliases']) for group in resp.json()['metadata']]
set(group['aliases']) for group in json.loads(resp.text)['metadata']]
for group in self.authority_groups:
if instance in group:
return [alias for alias in group if alias != instance]
Expand Down Expand Up @@ -504,7 +540,7 @@ def acquire_token_silent_with_error(
warnings.warn("We haven't decided how/if this method will accept authority parameter")
# the_authority = Authority(
# authority,
# verify=self.verify, proxies=self.proxies, timeout=self.timeout,
# self.http_client,
# ) if authority else self.authority
result = self._acquire_token_silent_from_cache_and_possibly_refresh_it(
scopes, account, self.authority, force_refresh=force_refresh,
Expand All @@ -516,8 +552,8 @@ def acquire_token_silent_with_error(
for alias in self._get_authority_aliases(self.authority.instance):
the_authority = Authority(
"https://" + alias + "/" + self.authority.tenant,
validate_authority=False,
verify=self.verify, proxies=self.proxies, timeout=self.timeout)
self.http_client,
validate_authority=False)
result = self._acquire_token_silent_from_cache_and_possibly_refresh_it(
scopes, account, the_authority, force_refresh=force_refresh,
correlation_id=correlation_id,
Expand Down Expand Up @@ -597,16 +633,18 @@ def _acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family(
**kwargs)
if at and "error" not in at:
return at
last_resp = None
if app_metadata.get("family_id"): # Meaning this app belongs to this family
at = self._acquire_token_silent_by_finding_specific_refresh_token(
last_resp = at = self._acquire_token_silent_by_finding_specific_refresh_token(
authority, scopes, dict(query, family_id=app_metadata["family_id"]),
**kwargs)
if at and "error" not in at:
return at
# Either this app is an orphan, so we will naturally use its own RT;
# or all attempts above have failed, so we fall back to non-foci behavior.
return self._acquire_token_silent_by_finding_specific_refresh_token(
authority, scopes, dict(query, client_id=self.client_id), **kwargs)
authority, scopes, dict(query, client_id=self.client_id),
**kwargs) or last_resp

def _get_app_metadata(self, environment):
apps = self.token_cache.find( # Use find(), rather than token_cache.get(...)
Expand Down Expand Up @@ -662,6 +700,36 @@ def _validate_ssh_cert_input_data(self, data):
"you must include a string parameter named 'key_id' "
"which identifies the key in the 'req_cnf' argument.")

def acquire_token_by_refresh_token(self, refresh_token, scopes):
"""Acquire token(s) based on a refresh token (RT) obtained from elsewhere.
You use this method only when you have old RTs from elsewhere,
and now you want to migrate them into MSAL.
Calling this method results in new tokens automatically storing into MSAL.
You do NOT need to use this method if you are already using MSAL.
MSAL maintains RT automatically inside its token cache,
and an access token can be retrieved
when you call :func:`~acquire_token_silent`.
:param str refresh_token: The old refresh token, as a string.
:param list scopes:
The scopes associate with this old RT.
Each scope needs to be in the Microsoft identity platform (v2) format.
See `Scopes not resources <https://docs.microsoft.com/en-us/azure/active-directory/develop/migrate-python-adal-msal#scopes-not-resources>`_.
:return:
* A dict contains "error" and some other keys, when error happened.
* A dict contains no "error" key means migration was successful.
"""
return self.client.obtain_token_by_refresh_token(
refresh_token,
decorate_scope(scopes, self.client_id),
rt_getter=lambda rt: rt,
on_updating_rt=False,
)


class PublicClientApplication(ClientApplication): # browser app or mobile app

Expand Down Expand Up @@ -760,13 +828,11 @@ def acquire_token_by_username_password(

def _acquire_token_by_username_password_federated(
self, user_realm_result, username, password, scopes=None, **kwargs):
verify = kwargs.pop("verify", self.verify)
proxies = kwargs.pop("proxies", self.proxies)
wstrust_endpoint = {}
if user_realm_result.get("federation_metadata_url"):
wstrust_endpoint = mex_send_request(
user_realm_result["federation_metadata_url"],
verify=verify, proxies=proxies)
self.http_client)
if wstrust_endpoint is None:
raise ValueError("Unable to find wstrust endpoint from MEX. "
"This typically happens when attempting MSA accounts. "
Expand All @@ -778,7 +844,7 @@ def _acquire_token_by_username_password_federated(
wstrust_endpoint.get("address",
# Fallback to an AAD supplied endpoint
user_realm_result.get("federation_active_auth_url")),
wstrust_endpoint.get("action"), verify=verify, proxies=proxies)
wstrust_endpoint.get("action"), self.http_client)
if not ("token" in wstrust_result and "type" in wstrust_result):
raise RuntimeError("Unsuccessful RSTR. %s" % wstrust_result)
GRANT_TYPE_SAML1_1 = 'urn:ietf:params:oauth:grant-type:saml1_1-bearer'
Expand Down
Loading

0 comments on commit 3d24f53

Please sign in to comment.