Skip to content

MSAL Python 1.18.0b1 #471

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 15 commits into from
May 19, 2022
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
2 changes: 1 addition & 1 deletion .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [2.7, 3.5, 3.6, 3.7, 3.8, 3.9]
python-version: [2.7, 3.5, 3.6, 3.7, 3.8, 3.9, "3.10", "3.11.0-alpha.5"]

steps:
- uses: actions/checkout@v2
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Quick links:

Click on the following thumbnail to visit a large map with clickable links to proper samples.

[![Map effect won't work inside github's markdown file, so we have to use a thumbnail here to lure audience to a real static website](docs/thumbnail.png)](https://msal-python.readthedocs.io/en/latest/)
[![Map effect won't work inside github's markdown file, so we have to use a thumbnail here to lure audience to a real static website](https://raw.githubusercontent.com/AzureAD/microsoft-authentication-library-for-python/dev/docs/thumbnail.png)](https://msal-python.readthedocs.io/en/latest/)

## Installation

Expand Down
56 changes: 52 additions & 4 deletions msal/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,14 @@
import msal.telemetry
from .region import _detect_region
from .throttled_http_client import ThrottledHttpClient
from .cloudshell import _is_running_in_cloud_shell


# The __init__.py will import this. Not the other way around.
__version__ = "1.17.0" # When releasing, also check and bump our dependencies's versions if needed
__version__ = "1.18.0b1" # When releasing, also check and bump our dependencies's versions if needed

logger = logging.getLogger(__name__)

_AUTHORITY_TYPE_CLOUDSHELL = "CLOUDSHELL"

def extract_certs(public_cert_content):
# Parses raw public certificate file contents and returns a list of strings
Expand Down Expand Up @@ -636,6 +637,7 @@ def initiate_auth_code_flow(
domain_hint=None, # type: Optional[str]
claims_challenge=None,
max_age=None,
response_mode=None, # type: Optional[str]
):
"""Initiate an auth code flow.

Expand Down Expand Up @@ -677,6 +679,20 @@ def initiate_auth_code_flow(

New in version 1.15.

:param str response_mode:
OPTIONAL. Specifies the method with which response parameters should be returned.
The default value is equivalent to ``query``, which is still secure enough in MSAL Python
(because MSAL Python does not transfer tokens via query parameter in the first place).
For even better security, we recommend using the value ``form_post``.
In "form_post" mode, response parameters
will be encoded as HTML form values that are transmitted via the HTTP POST method and
encoded in the body using the application/x-www-form-urlencoded format.
Valid values can be either "form_post" for HTTP POST to callback URI or
"query" (the default) for HTTP GET with parameters encoded in query string.
More information on possible values
`here <https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html#ResponseModes>`
and `here <https://openid.net/specs/oauth-v2-form-post-response-mode-1_0.html#FormPostResponseMode>`

:return:
The auth code flow. It is a dict in this form::

Expand Down Expand Up @@ -707,6 +723,7 @@ def initiate_auth_code_flow(
claims=_merge_claims_challenge_and_capabilities(
self._client_capabilities, claims_challenge),
max_age=max_age,
response_mode=response_mode,
)
flow["claims_challenge"] = claims_challenge
return flow
Expand Down Expand Up @@ -970,6 +987,10 @@ def get_accounts(self, username=None):
return accounts

def _find_msal_accounts(self, environment):
interested_authority_types = [
TokenCache.AuthorityType.ADFS, TokenCache.AuthorityType.MSSTS]
if _is_running_in_cloud_shell():
interested_authority_types.append(_AUTHORITY_TYPE_CLOUDSHELL)
grouped_accounts = {
a.get("home_account_id"): # Grouped by home tenant's id
{ # These are minimal amount of non-tenant-specific account info
Expand All @@ -985,8 +1006,7 @@ def _find_msal_accounts(self, environment):
for a in self.token_cache.find(
TokenCache.CredentialType.ACCOUNT,
query={"environment": environment})
if a["authority_type"] in (
TokenCache.AuthorityType.ADFS, TokenCache.AuthorityType.MSSTS)
if a["authority_type"] in interested_authority_types
}
return list(grouped_accounts.values())

Expand Down Expand Up @@ -1046,6 +1066,21 @@ def _forget_me(self, home_account):
TokenCache.CredentialType.ACCOUNT, query=owned_by_home_account):
self.token_cache.remove_account(a)

def _acquire_token_by_cloud_shell(self, scopes, data=None):
from .cloudshell import _obtain_token
response = _obtain_token(
self.http_client, scopes, client_id=self.client_id, data=data)
if "error" not in response:
self.token_cache.add(dict(
client_id=self.client_id,
scope=response["scope"].split() if "scope" in response else scopes,
token_endpoint=self.authority.token_endpoint,
response=response.copy(),
data=data or {},
authority_type=_AUTHORITY_TYPE_CLOUDSHELL,
))
return response

def acquire_token_silent(
self,
scopes, # type: List[str]
Expand Down Expand Up @@ -1179,6 +1214,7 @@ def _acquire_token_silent_from_cache_and_possibly_refresh_it(
authority, # This can be different than self.authority
force_refresh=False, # type: Optional[boolean]
claims_challenge=None,
correlation_id=None,
**kwargs):
access_token_from_cache = None
if not (force_refresh or claims_challenge): # Bypass AT when desired or using claims
Expand Down Expand Up @@ -1217,9 +1253,13 @@ def _acquire_token_silent_from_cache_and_possibly_refresh_it(
refresh_reason = msal.telemetry.FORCE_REFRESH # TODO: It could also mean claims_challenge
assert refresh_reason, "It should have been established at this point"
try:
if account and account.get("authority_type") == _AUTHORITY_TYPE_CLOUDSHELL:
return self._acquire_token_by_cloud_shell(
scopes, data=kwargs.get("data"))
result = _clean_up(self._acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family(
authority, self._decorate_scope(scopes), account,
refresh_reason=refresh_reason, claims_challenge=claims_challenge,
correlation_id=correlation_id,
**kwargs))
if (result and "error" not in result) or (not access_token_from_cache):
return result
Expand Down Expand Up @@ -1558,6 +1598,9 @@ def acquire_token_interactive(
- A dict containing an "error" key, when token refresh failed.
"""
self._validate_ssh_cert_input_data(kwargs.get("data", {}))
if _is_running_in_cloud_shell() and prompt == "none":
return self._acquire_token_by_cloud_shell(
scopes, data=kwargs.pop("data", {}))
claims = _merge_claims_challenge_and_capabilities(
self._client_capabilities, claims_challenge)
telemetry_context = self._build_telemetry_context(
Expand Down Expand Up @@ -1659,6 +1702,11 @@ def acquire_token_for_client(self, scopes, claims_challenge=None, **kwargs):
- an error response would contain "error" and usually "error_description".
"""
# TBD: force_refresh behavior
if self.authority.tenant.lower() in ["common", "organizations"]:
warnings.warn(
"Using /common or /organizations authority "
"in acquire_token_for_client() is unreliable. "
"Please use a specific tenant instead.", DeprecationWarning)
self._validate_ssh_cert_input_data(kwargs.get("data", {}))
telemetry_context = self._build_telemetry_context(
self.ACQUIRE_TOKEN_FOR_CLIENT_ID)
Expand Down
122 changes: 122 additions & 0 deletions msal/cloudshell.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# Copyright (c) Microsoft Corporation.
# All rights reserved.
#
# This code is licensed under the MIT License.

"""This module wraps Cloud Shell's IMDS-like interface inside an OAuth2-like helper"""
import base64
import json
import logging
import os
import time
try: # Python 2
from urlparse import urlparse
except: # Python 3
from urllib.parse import urlparse
from .oauth2cli.oidc import decode_part


logger = logging.getLogger(__name__)


def _is_running_in_cloud_shell():
return os.environ.get("AZUREPS_HOST_ENVIRONMENT", "").startswith("cloud-shell")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just as an FYI, Azure CLI uses another env var ACC_CLOUD to detect if it is run in Cloud Shell:

https://github.com/Azure/azure-cli/blob/f0b5572c4ccafb383de08beb509045145fdc871f/src/azure-cli-core/azure/cli/core/util.py#L688

def in_cloud_console():
    return os.environ.get('ACC_CLOUD', None)
$ env | grep ACC_CLOUD
ACC_CLOUD=PROD

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I dug this out from my chat messages history.

10/19/2021 11:54 AM
Ray: ... Az CLI and MSAL would need to detect whether they are currently running inside Cloud Shell. I confirmed with Robin that the recommended way is to use AZUREPS_HOST_ENVIRONMENT env var. I am just letting you know, to keep this env var in the future. Otherwise, its removal would become a breaking change for us. :-)

Edwin: I have previously advised folks to look for the ACC_CLOUD variable. Either should be OK

Ray: The "problem" of ACC_CLOUD is its content seems to vary by design, therefore the consumers would have to detect its presence, without any keyword in its value to "double check". I would prefer that "AZUREPS_HOST_ENVIRONMENT=cloud-shell/1.0" because we can then use some "value.startswith('cloud-shell')" logic just to be sure.
Either way, we just need your blessing to say "yes, that would become a formal contract that would last forever".

Edwin: ACC_CLOUD is a different value per-cloud (Public, Fairfax, etc). Other than that it is static. You can use the AZUREPS one if you like



def _scope_to_resource(scope): # This is an experimental reasonable-effort approach
cloud_shell_supported_audiences = [
"https://analysis.windows.net/powerbi/api", # Came from https://msazure.visualstudio.com/One/_git/compute-CloudShell?path=/src/images/agent/env/envconfig.PROD.json
"https://pas.windows.net/CheckMyAccess/Linux/.default", # Cloud Shell accepts it as-is
]
for a in cloud_shell_supported_audiences:
if scope.startswith(a):
return a
u = urlparse(scope)
if u.scheme:
return "{}://{}".format(u.scheme, u.netloc)
return scope # There is no much else we can do here


def _obtain_token(http_client, scopes, client_id=None, data=None):
resp = http_client.post(
"http://localhost:50342/oauth2/token",
data=dict(
data or {},
resource=" ".join(map(_scope_to_resource, scopes))),
headers={"Metadata": "true"},
)
if resp.status_code >= 300:
logger.debug("Cloud Shell IMDS error: %s", resp.text)
cs_error = json.loads(resp.text).get("error", {})
return {k: v for k, v in {
"error": cs_error.get("code"),
"error_description": cs_error.get("message"),
}.items() if v}
imds_payload = json.loads(resp.text)
BEARER = "Bearer"
oauth2_response = {
"access_token": imds_payload["access_token"],
"expires_in": int(imds_payload["expires_in"]),
"token_type": imds_payload.get("token_type", BEARER),
}
expected_token_type = (data or {}).get("token_type", BEARER)
if oauth2_response["token_type"] != expected_token_type:
return { # Generate a normal error (rather than an intrusive exception)
"error": "broker_error",
"error_description": "token_type {} is not supported by this version of Azure Portal".format(
expected_token_type),
}
parts = imds_payload["access_token"].split(".")

# The following default values are useful in SSH Cert scenario
client_info = { # Default value, in case the real value will be unavailable
"uid": "user",
"utid": "cloudshell",
}
now = time.time()
preferred_username = "currentuser@cloudshell"
oauth2_response["id_token_claims"] = { # First 5 claims are required per OIDC
"iss": "cloudshell",
"sub": "user",
"aud": client_id,
"exp": now + 3600,
"iat": now,
"preferred_username": preferred_username, # Useful as MSAL account's username
}

if len(parts) == 3: # Probably a JWT. Use it to derive client_info and id token.
try:
# Data defined in https://docs.microsoft.com/en-us/azure/active-directory/develop/access-tokens#payload-claims
jwt_payload = json.loads(decode_part(parts[1]))
client_info = {
# Mimic a real home_account_id,
# so that this pseudo account and a real account would interop.
"uid": jwt_payload.get("oid", "user"),
"utid": jwt_payload.get("tid", "cloudshell"),
}
oauth2_response["id_token_claims"] = {
"iss": jwt_payload["iss"],
"sub": jwt_payload["sub"], # Could use oid instead
"aud": client_id,
"exp": jwt_payload["exp"],
"iat": jwt_payload["iat"],
"preferred_username": jwt_payload.get("preferred_username") # V2
or jwt_payload.get("unique_name") # V1
or preferred_username,
}
except ValueError:
logger.debug("Unable to decode jwt payload: %s", parts[1])
oauth2_response["client_info"] = base64.b64encode(
# Mimic a client_info, so that MSAL would create an account
json.dumps(client_info).encode("utf-8")).decode("utf-8")
oauth2_response["id_token_claims"]["tid"] = client_info["utid"] # TBD

## Note: Decided to not surface resource back as scope,
## because they would cause the downstream OAuth2 code path to
## cache the token with a different scope and won't hit them later.
#if imds_payload.get("resource"):
# oauth2_response["scope"] = imds_payload["resource"]
if imds_payload.get("refresh_token"):
oauth2_response["refresh_token"] = imds_payload["refresh_token"]
return oauth2_response

9 changes: 6 additions & 3 deletions msal/token_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ def wipe(dictionary, sensitive_fields): # Masks sensitive info
return self.__add(event, now=now)
finally:
wipe(event.get("response", {}), ( # These claims were useful during __add()
"id_token_claims", # Provided by broker
"access_token", "refresh_token", "id_token", "username"))
wipe(event, ["username"]) # Needed for federated ROPC
logger.debug("event=%s", json.dumps(
Expand Down Expand Up @@ -150,7 +151,8 @@ def __add(self, event, now=None):
id_token = response.get("id_token")
id_token_claims = (
decode_id_token(id_token, client_id=event["client_id"])
if id_token else {})
if id_token
else response.get("id_token_claims", {})) # Broker would provide id_token_claims
client_info, home_account_id = self.__parse_account(response, id_token_claims)

target = ' '.join(event.get("scope") or []) # Per schema, we don't sort it
Expand Down Expand Up @@ -195,9 +197,10 @@ def __add(self, event, now=None):
or data.get("username") # Falls back to ROPC username
or event.get("username") # Falls back to Federated ROPC username
or "", # The schema does not like null
"authority_type":
"authority_type": event.get(
"authority_type", # Honor caller's choice of authority_type
self.AuthorityType.ADFS if realm == "adfs"
else self.AuthorityType.MSSTS,
else self.AuthorityType.MSSTS),
# "client_info": response.get("client_info"), # Optional
}
self.modify(self.CredentialType.ACCOUNT, account, account)
Expand Down
3 changes: 2 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
'Programming Language :: Python :: 3.7',
'Programming Language :: Python :: 3.8',
'Programming Language :: Python :: 3.9',
'Programming Language :: Python :: 3.10',
'License :: OSI Approved :: MIT License',
'Operating System :: OS Independent',
],
Expand All @@ -75,7 +76,7 @@
'requests>=2.0.0,<3',
'PyJWT[crypto]>=1.0.0,<3',

'cryptography>=0.6,<39',
'cryptography>=0.6,<40',
# load_pem_private_key() is available since 0.6
# https://github.com/pyca/cryptography/blob/master/CHANGELOG.rst#06---2014-09-29
#
Expand Down
Loading