Skip to content

Commit bc13dd4

Browse files
authored
Merge pull request #471 from AzureAD/release-1.18.0b1
MSAL Python 1.18.0b1
2 parents eff8a1b + ea18829 commit bc13dd4

File tree

9 files changed

+404
-12
lines changed

9 files changed

+404
-12
lines changed

.github/workflows/python-package.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ jobs:
2626
runs-on: ubuntu-latest
2727
strategy:
2828
matrix:
29-
python-version: [2.7, 3.5, 3.6, 3.7, 3.8, 3.9]
29+
python-version: [2.7, 3.5, 3.6, 3.7, 3.8, 3.9, "3.10", "3.11.0-alpha.5"]
3030

3131
steps:
3232
- uses: actions/checkout@v2

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ Quick links:
1818

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

21-
[![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/)
21+
[![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/)
2222

2323
## Installation
2424

msal/application.py

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,14 @@
2121
import msal.telemetry
2222
from .region import _detect_region
2323
from .throttled_http_client import ThrottledHttpClient
24+
from .cloudshell import _is_running_in_cloud_shell
2425

2526

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

2930
logger = logging.getLogger(__name__)
30-
31+
_AUTHORITY_TYPE_CLOUDSHELL = "CLOUDSHELL"
3132

3233
def extract_certs(public_cert_content):
3334
# Parses raw public certificate file contents and returns a list of strings
@@ -636,6 +637,7 @@ def initiate_auth_code_flow(
636637
domain_hint=None, # type: Optional[str]
637638
claims_challenge=None,
638639
max_age=None,
640+
response_mode=None, # type: Optional[str]
639641
):
640642
"""Initiate an auth code flow.
641643
@@ -677,6 +679,20 @@ def initiate_auth_code_flow(
677679
678680
New in version 1.15.
679681
682+
:param str response_mode:
683+
OPTIONAL. Specifies the method with which response parameters should be returned.
684+
The default value is equivalent to ``query``, which is still secure enough in MSAL Python
685+
(because MSAL Python does not transfer tokens via query parameter in the first place).
686+
For even better security, we recommend using the value ``form_post``.
687+
In "form_post" mode, response parameters
688+
will be encoded as HTML form values that are transmitted via the HTTP POST method and
689+
encoded in the body using the application/x-www-form-urlencoded format.
690+
Valid values can be either "form_post" for HTTP POST to callback URI or
691+
"query" (the default) for HTTP GET with parameters encoded in query string.
692+
More information on possible values
693+
`here <https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html#ResponseModes>`
694+
and `here <https://openid.net/specs/oauth-v2-form-post-response-mode-1_0.html#FormPostResponseMode>`
695+
680696
:return:
681697
The auth code flow. It is a dict in this form::
682698
@@ -707,6 +723,7 @@ def initiate_auth_code_flow(
707723
claims=_merge_claims_challenge_and_capabilities(
708724
self._client_capabilities, claims_challenge),
709725
max_age=max_age,
726+
response_mode=response_mode,
710727
)
711728
flow["claims_challenge"] = claims_challenge
712729
return flow
@@ -970,6 +987,10 @@ def get_accounts(self, username=None):
970987
return accounts
971988

972989
def _find_msal_accounts(self, environment):
990+
interested_authority_types = [
991+
TokenCache.AuthorityType.ADFS, TokenCache.AuthorityType.MSSTS]
992+
if _is_running_in_cloud_shell():
993+
interested_authority_types.append(_AUTHORITY_TYPE_CLOUDSHELL)
973994
grouped_accounts = {
974995
a.get("home_account_id"): # Grouped by home tenant's id
975996
{ # These are minimal amount of non-tenant-specific account info
@@ -985,8 +1006,7 @@ def _find_msal_accounts(self, environment):
9851006
for a in self.token_cache.find(
9861007
TokenCache.CredentialType.ACCOUNT,
9871008
query={"environment": environment})
988-
if a["authority_type"] in (
989-
TokenCache.AuthorityType.ADFS, TokenCache.AuthorityType.MSSTS)
1009+
if a["authority_type"] in interested_authority_types
9901010
}
9911011
return list(grouped_accounts.values())
9921012

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

1069+
def _acquire_token_by_cloud_shell(self, scopes, data=None):
1070+
from .cloudshell import _obtain_token
1071+
response = _obtain_token(
1072+
self.http_client, scopes, client_id=self.client_id, data=data)
1073+
if "error" not in response:
1074+
self.token_cache.add(dict(
1075+
client_id=self.client_id,
1076+
scope=response["scope"].split() if "scope" in response else scopes,
1077+
token_endpoint=self.authority.token_endpoint,
1078+
response=response.copy(),
1079+
data=data or {},
1080+
authority_type=_AUTHORITY_TYPE_CLOUDSHELL,
1081+
))
1082+
return response
1083+
10491084
def acquire_token_silent(
10501085
self,
10511086
scopes, # type: List[str]
@@ -1179,6 +1214,7 @@ def _acquire_token_silent_from_cache_and_possibly_refresh_it(
11791214
authority, # This can be different than self.authority
11801215
force_refresh=False, # type: Optional[boolean]
11811216
claims_challenge=None,
1217+
correlation_id=None,
11821218
**kwargs):
11831219
access_token_from_cache = None
11841220
if not (force_refresh or claims_challenge): # Bypass AT when desired or using claims
@@ -1217,9 +1253,13 @@ def _acquire_token_silent_from_cache_and_possibly_refresh_it(
12171253
refresh_reason = msal.telemetry.FORCE_REFRESH # TODO: It could also mean claims_challenge
12181254
assert refresh_reason, "It should have been established at this point"
12191255
try:
1256+
if account and account.get("authority_type") == _AUTHORITY_TYPE_CLOUDSHELL:
1257+
return self._acquire_token_by_cloud_shell(
1258+
scopes, data=kwargs.get("data"))
12201259
result = _clean_up(self._acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family(
12211260
authority, self._decorate_scope(scopes), account,
12221261
refresh_reason=refresh_reason, claims_challenge=claims_challenge,
1262+
correlation_id=correlation_id,
12231263
**kwargs))
12241264
if (result and "error" not in result) or (not access_token_from_cache):
12251265
return result
@@ -1558,6 +1598,9 @@ def acquire_token_interactive(
15581598
- A dict containing an "error" key, when token refresh failed.
15591599
"""
15601600
self._validate_ssh_cert_input_data(kwargs.get("data", {}))
1601+
if _is_running_in_cloud_shell() and prompt == "none":
1602+
return self._acquire_token_by_cloud_shell(
1603+
scopes, data=kwargs.pop("data", {}))
15611604
claims = _merge_claims_challenge_and_capabilities(
15621605
self._client_capabilities, claims_challenge)
15631606
telemetry_context = self._build_telemetry_context(
@@ -1659,6 +1702,11 @@ def acquire_token_for_client(self, scopes, claims_challenge=None, **kwargs):
16591702
- an error response would contain "error" and usually "error_description".
16601703
"""
16611704
# TBD: force_refresh behavior
1705+
if self.authority.tenant.lower() in ["common", "organizations"]:
1706+
warnings.warn(
1707+
"Using /common or /organizations authority "
1708+
"in acquire_token_for_client() is unreliable. "
1709+
"Please use a specific tenant instead.", DeprecationWarning)
16621710
self._validate_ssh_cert_input_data(kwargs.get("data", {}))
16631711
telemetry_context = self._build_telemetry_context(
16641712
self.ACQUIRE_TOKEN_FOR_CLIENT_ID)

msal/cloudshell.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# All rights reserved.
3+
#
4+
# This code is licensed under the MIT License.
5+
6+
"""This module wraps Cloud Shell's IMDS-like interface inside an OAuth2-like helper"""
7+
import base64
8+
import json
9+
import logging
10+
import os
11+
import time
12+
try: # Python 2
13+
from urlparse import urlparse
14+
except: # Python 3
15+
from urllib.parse import urlparse
16+
from .oauth2cli.oidc import decode_part
17+
18+
19+
logger = logging.getLogger(__name__)
20+
21+
22+
def _is_running_in_cloud_shell():
23+
return os.environ.get("AZUREPS_HOST_ENVIRONMENT", "").startswith("cloud-shell")
24+
25+
26+
def _scope_to_resource(scope): # This is an experimental reasonable-effort approach
27+
cloud_shell_supported_audiences = [
28+
"https://analysis.windows.net/powerbi/api", # Came from https://msazure.visualstudio.com/One/_git/compute-CloudShell?path=/src/images/agent/env/envconfig.PROD.json
29+
"https://pas.windows.net/CheckMyAccess/Linux/.default", # Cloud Shell accepts it as-is
30+
]
31+
for a in cloud_shell_supported_audiences:
32+
if scope.startswith(a):
33+
return a
34+
u = urlparse(scope)
35+
if u.scheme:
36+
return "{}://{}".format(u.scheme, u.netloc)
37+
return scope # There is no much else we can do here
38+
39+
40+
def _obtain_token(http_client, scopes, client_id=None, data=None):
41+
resp = http_client.post(
42+
"http://localhost:50342/oauth2/token",
43+
data=dict(
44+
data or {},
45+
resource=" ".join(map(_scope_to_resource, scopes))),
46+
headers={"Metadata": "true"},
47+
)
48+
if resp.status_code >= 300:
49+
logger.debug("Cloud Shell IMDS error: %s", resp.text)
50+
cs_error = json.loads(resp.text).get("error", {})
51+
return {k: v for k, v in {
52+
"error": cs_error.get("code"),
53+
"error_description": cs_error.get("message"),
54+
}.items() if v}
55+
imds_payload = json.loads(resp.text)
56+
BEARER = "Bearer"
57+
oauth2_response = {
58+
"access_token": imds_payload["access_token"],
59+
"expires_in": int(imds_payload["expires_in"]),
60+
"token_type": imds_payload.get("token_type", BEARER),
61+
}
62+
expected_token_type = (data or {}).get("token_type", BEARER)
63+
if oauth2_response["token_type"] != expected_token_type:
64+
return { # Generate a normal error (rather than an intrusive exception)
65+
"error": "broker_error",
66+
"error_description": "token_type {} is not supported by this version of Azure Portal".format(
67+
expected_token_type),
68+
}
69+
parts = imds_payload["access_token"].split(".")
70+
71+
# The following default values are useful in SSH Cert scenario
72+
client_info = { # Default value, in case the real value will be unavailable
73+
"uid": "user",
74+
"utid": "cloudshell",
75+
}
76+
now = time.time()
77+
preferred_username = "currentuser@cloudshell"
78+
oauth2_response["id_token_claims"] = { # First 5 claims are required per OIDC
79+
"iss": "cloudshell",
80+
"sub": "user",
81+
"aud": client_id,
82+
"exp": now + 3600,
83+
"iat": now,
84+
"preferred_username": preferred_username, # Useful as MSAL account's username
85+
}
86+
87+
if len(parts) == 3: # Probably a JWT. Use it to derive client_info and id token.
88+
try:
89+
# Data defined in https://docs.microsoft.com/en-us/azure/active-directory/develop/access-tokens#payload-claims
90+
jwt_payload = json.loads(decode_part(parts[1]))
91+
client_info = {
92+
# Mimic a real home_account_id,
93+
# so that this pseudo account and a real account would interop.
94+
"uid": jwt_payload.get("oid", "user"),
95+
"utid": jwt_payload.get("tid", "cloudshell"),
96+
}
97+
oauth2_response["id_token_claims"] = {
98+
"iss": jwt_payload["iss"],
99+
"sub": jwt_payload["sub"], # Could use oid instead
100+
"aud": client_id,
101+
"exp": jwt_payload["exp"],
102+
"iat": jwt_payload["iat"],
103+
"preferred_username": jwt_payload.get("preferred_username") # V2
104+
or jwt_payload.get("unique_name") # V1
105+
or preferred_username,
106+
}
107+
except ValueError:
108+
logger.debug("Unable to decode jwt payload: %s", parts[1])
109+
oauth2_response["client_info"] = base64.b64encode(
110+
# Mimic a client_info, so that MSAL would create an account
111+
json.dumps(client_info).encode("utf-8")).decode("utf-8")
112+
oauth2_response["id_token_claims"]["tid"] = client_info["utid"] # TBD
113+
114+
## Note: Decided to not surface resource back as scope,
115+
## because they would cause the downstream OAuth2 code path to
116+
## cache the token with a different scope and won't hit them later.
117+
#if imds_payload.get("resource"):
118+
# oauth2_response["scope"] = imds_payload["resource"]
119+
if imds_payload.get("refresh_token"):
120+
oauth2_response["refresh_token"] = imds_payload["refresh_token"]
121+
return oauth2_response
122+

msal/token_cache.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ def wipe(dictionary, sensitive_fields): # Masks sensitive info
113113
return self.__add(event, now=now)
114114
finally:
115115
wipe(event.get("response", {}), ( # These claims were useful during __add()
116+
"id_token_claims", # Provided by broker
116117
"access_token", "refresh_token", "id_token", "username"))
117118
wipe(event, ["username"]) # Needed for federated ROPC
118119
logger.debug("event=%s", json.dumps(
@@ -150,7 +151,8 @@ def __add(self, event, now=None):
150151
id_token = response.get("id_token")
151152
id_token_claims = (
152153
decode_id_token(id_token, client_id=event["client_id"])
153-
if id_token else {})
154+
if id_token
155+
else response.get("id_token_claims", {})) # Broker would provide id_token_claims
154156
client_info, home_account_id = self.__parse_account(response, id_token_claims)
155157

156158
target = ' '.join(event.get("scope") or []) # Per schema, we don't sort it
@@ -195,9 +197,10 @@ def __add(self, event, now=None):
195197
or data.get("username") # Falls back to ROPC username
196198
or event.get("username") # Falls back to Federated ROPC username
197199
or "", # The schema does not like null
198-
"authority_type":
200+
"authority_type": event.get(
201+
"authority_type", # Honor caller's choice of authority_type
199202
self.AuthorityType.ADFS if realm == "adfs"
200-
else self.AuthorityType.MSSTS,
203+
else self.AuthorityType.MSSTS),
201204
# "client_info": response.get("client_info"), # Optional
202205
}
203206
self.modify(self.CredentialType.ACCOUNT, account, account)

setup.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
'Programming Language :: Python :: 3.7',
6464
'Programming Language :: Python :: 3.8',
6565
'Programming Language :: Python :: 3.9',
66+
'Programming Language :: Python :: 3.10',
6667
'License :: OSI Approved :: MIT License',
6768
'Operating System :: OS Independent',
6869
],
@@ -75,7 +76,7 @@
7576
'requests>=2.0.0,<3',
7677
'PyJWT[crypto]>=1.0.0,<3',
7778

78-
'cryptography>=0.6,<39',
79+
'cryptography>=0.6,<40',
7980
# load_pem_private_key() is available since 0.6
8081
# https://github.com/pyca/cryptography/blob/master/CHANGELOG.rst#06---2014-09-29
8182
#

0 commit comments

Comments
 (0)