Skip to content

Commit 04c18d8

Browse files
committed
Merge branch 'dev' into cloudshell-imds
2 parents 86d5976 + 0a62ead commit 04c18d8

File tree

13 files changed

+197
-64
lines changed

13 files changed

+197
-64
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: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,6 @@
1111
from threading import Lock
1212
import os
1313

14-
import requests
15-
1614
from .oauth2cli import Client, JwtAssertionCreator
1715
from .oauth2cli.oidc import decode_part
1816
from .authority import Authority
@@ -28,7 +26,7 @@
2826

2927

3028
# The __init__.py will import this. Not the other way around.
31-
__version__ = "1.16.0"
29+
__version__ = "1.17.0" # When releasing, also check and bump our dependencies's versions if needed
3230

3331
logger = logging.getLogger(__name__)
3432
CURRENT_USER = "Current User" # The value is subject to change
@@ -84,6 +82,10 @@ def _preferred_browser():
8482
if sys.platform != "linux": # On other platforms, we have no browser preference
8583
return None
8684
browser_path = "/usr/bin/microsoft-edge" # Use a full path owned by sys admin
85+
# Note: /usr/bin/microsoft-edge, /usr/bin/microsoft-edge-stable, etc.
86+
# are symlinks that point to the actual binaries which are found under
87+
# /opt/microsoft/msedge/msedge or /opt/microsoft/msedge-beta/msedge.
88+
# Either method can be used to detect an Edge installation.
8789
user_has_no_preference = "BROWSER" not in os.environ
8890
user_wont_mind_edge = "microsoft-edge" in os.environ.get("BROWSER", "") # Note:
8991
# BROWSER could contain "microsoft-edge" or "/path/to/microsoft-edge".
@@ -431,6 +433,8 @@ def __init__(
431433
if http_client:
432434
self.http_client = http_client
433435
else:
436+
import requests # Lazy load
437+
434438
self.http_client = requests.Session()
435439
self.http_client.verify = verify
436440
self.http_client.proxies = proxies

msal/authority.py

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,6 @@
55
from urlparse import urlparse
66
import logging
77

8-
# Historically some customers patched this module-wide requests instance.
9-
# We keep it here for now. They will be removed in next major release.
10-
import requests
11-
import requests as _requests
12-
138
from .exceptions import MsalServiceError
149

1510

@@ -27,7 +22,6 @@
2722
AZURE_CHINA,
2823
'login-us.microsoftonline.com',
2924
AZURE_US_GOVERNMENT,
30-
'login.microsoftonline.de',
3125
])
3226
WELL_KNOWN_B2C_HOSTS = [
3327
"b2clogin.com",
@@ -59,9 +53,10 @@ class Authority(object):
5953
_domains_without_user_realm_discovery = set([])
6054

6155
@property
62-
def http_client(self): # Obsolete. We will remove this in next major release.
63-
# A workaround: if module-wide requests is patched, we honor it.
64-
return self._http_client if requests is _requests else requests
56+
def http_client(self): # Obsolete. We will remove this eventually
57+
warnings.warn(
58+
"authority.http_client might be removed in MSAL Python 1.21+", DeprecationWarning)
59+
return self._http_client
6560

6661
def __init__(self, authority_url, http_client, validate_authority=True):
6762
"""Creates an authority instance, and also validates it.
@@ -84,7 +79,7 @@ def __init__(self, authority_url, http_client, validate_authority=True):
8479
payload = instance_discovery(
8580
"https://{}{}/oauth2/v2.0/authorize".format(
8681
self.instance, authority.path),
87-
self.http_client)
82+
self._http_client)
8883
if payload.get("error") == "invalid_instance":
8984
raise ValueError(
9085
"invalid_instance: "
@@ -104,12 +99,13 @@ def __init__(self, authority_url, http_client, validate_authority=True):
10499
try:
105100
openid_config = tenant_discovery(
106101
tenant_discovery_endpoint,
107-
self.http_client)
102+
self._http_client)
108103
except ValueError:
109104
raise ValueError(
110105
"Unable to get authority configuration for {}. "
111106
"Authority would typically be in a format of "
112-
"https://login.microsoftonline.com/your_tenant_name".format(
107+
"https://login.microsoftonline.com/your_tenant "
108+
"Also please double check your tenant name or GUID is correct.".format(
113109
authority_url))
114110
logger.debug("openid_config = %s", openid_config)
115111
self.authorization_endpoint = openid_config['authorization_endpoint']
@@ -123,7 +119,7 @@ def user_realm_discovery(self, username, correlation_id=None, response=None):
123119
# "federation_protocol", "cloud_audience_urn",
124120
# "federation_metadata_url", "federation_active_auth_url", etc.
125121
if self.instance not in self.__class__._domains_without_user_realm_discovery:
126-
resp = response or self.http_client.get(
122+
resp = response or self._http_client.get(
127123
"https://{netloc}/common/userrealm/{username}?api-version=1.0".format(
128124
netloc=self.instance, username=username),
129125
headers={'Accept': 'application/json',
@@ -170,7 +166,10 @@ def tenant_discovery(tenant_discovery_endpoint, http_client, **kwargs):
170166
if 400 <= resp.status_code < 500:
171167
# Nonexist tenant would hit this path
172168
# e.g. https://login.microsoftonline.com/nonexist_tenant/v2.0/.well-known/openid-configuration
173-
raise ValueError("OIDC Discovery endpoint rejects our request")
169+
raise ValueError(
170+
"OIDC Discovery endpoint rejects our request. Error: {}".format(
171+
resp.text # Expose it as-is b/c OIDC defines no error response format
172+
))
174173
# Transient network error would hit this path
175174
resp.raise_for_status()
176175
raise RuntimeError( # A fallback here, in case resp.raise_for_status() is no-op

msal/oauth2cli/assertion.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@
44
import uuid
55
import logging
66

7-
import jwt
8-
97

108
logger = logging.getLogger(__name__)
119

@@ -99,6 +97,7 @@ def create_normal_assertion(
9997
Parameters are defined in https://tools.ietf.org/html/rfc7523#section-3
10098
Key-value pairs in additional_claims will be added into payload as-is.
10199
"""
100+
import jwt # Lazy loading
102101
now = time.time()
103102
payload = {
104103
'aud': audience,

msal/oauth2cli/oauth2.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,6 @@
1717
import string
1818
import hashlib
1919

20-
import requests
21-
2220
from .authcode import AuthCodeReceiver as _AuthCodeReceiver
2321

2422
try:
@@ -159,6 +157,8 @@ def __init__(
159157
"when http_client is in use")
160158
self._http_client = http_client
161159
else:
160+
import requests # Lazy loading
161+
162162
self._http_client = requests.Session()
163163
self._http_client.verify = True if verify is None else verify
164164
self._http_client.proxies = proxies

msal/oauth2cli/oidc.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,11 @@ def decode_id_token(id_token, client_id=None, issuer=None, nonce=None, now=None)
4444
err = None # https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation
4545
_now = int(now or time.time())
4646
skew = 120 # 2 minutes
47+
TIME_SUGGESTION = "Make sure your computer's time and time zone are both correct."
4748
if _now + skew < decoded.get("nbf", _now - 1): # nbf is optional per JWT specs
4849
# This is not an ID token validation, but a JWT validation
4950
# https://tools.ietf.org/html/rfc7519#section-4.1.5
50-
err = "0. The ID token is not yet valid."
51+
err = "0. The ID token is not yet valid. " + TIME_SUGGESTION
5152
if issuer and issuer != decoded["iss"]:
5253
# https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse
5354
err = ('2. The Issuer Identifier for the OpenID Provider, "%s", '
@@ -68,7 +69,7 @@ def decode_id_token(id_token, client_id=None, issuer=None, nonce=None, now=None)
6869
# the TLS server validation MAY be used to validate the issuer
6970
# in place of checking the token signature.
7071
if _now - skew > decoded["exp"]:
71-
err = "9. The current time MUST be before the time represented by the exp Claim."
72+
err = "9. The ID token already expires. " + TIME_SUGGESTION
7273
if nonce and nonce != decoded.get("nonce"):
7374
err = ("11. Nonce must be the same value "
7475
"as the one that was sent in the Authentication Request.")

msal/wstrust_request.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,9 @@ def send_request(
4444
soap_action = Mex.ACTION_2005
4545
elif '/trust/13/usernamemixed' in endpoint_address:
4646
soap_action = Mex.ACTION_13
47-
assert soap_action in (Mex.ACTION_13, Mex.ACTION_2005), ( # A loose check here
48-
"Unsupported soap action: %s" % soap_action)
47+
if soap_action not in (Mex.ACTION_13, Mex.ACTION_2005):
48+
raise ValueError("Unsupported soap action: %s. "
49+
"Contact your administrator to check your ADFS's MEX settings." % soap_action)
4950
data = _build_rst(
5051
username, password, cloud_audience_urn, endpoint_address, soap_action)
5152
resp = http_client.post(endpoint_address, data=data, headers={

sample/interactive_sample.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@
5353
if not result:
5454
logging.info("No suitable token exists in cache. Let's get a new one from AAD.")
5555
print("A local browser window will be open for you to sign in. CTRL+C to cancel.")
56-
result = app.acquire_token_interactive(
56+
result = app.acquire_token_interactive( # Only works if your app is registered with redirect_uri as http://localhost
5757
config["scope"],
5858
login_hint=config.get("username"), # Optional.
5959
# If you know the username ahead of time, this parameter can pre-fill

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,<38',
79+
'cryptography>=0.6,<39',
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
#

tests/msaltest.py

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import getpass, logging, pprint, sys, msal
2+
3+
4+
def _input_boolean(message):
5+
return input(
6+
"{} (N/n/F/f or empty means False, otherwise it is True): ".format(message)
7+
) not in ('N', 'n', 'F', 'f', '')
8+
9+
def _input(message, default=None):
10+
return input(message.format(default=default)).strip() or default
11+
12+
def _select_options(
13+
options, header="Your options:", footer=" Your choice? ", option_renderer=str,
14+
accept_nonempty_string=False,
15+
):
16+
assert options, "options must not be empty"
17+
if header:
18+
print(header)
19+
for i, o in enumerate(options, start=1):
20+
print(" {}: {}".format(i, option_renderer(o)))
21+
if accept_nonempty_string:
22+
print(" Or you can just type in your input.")
23+
while True:
24+
raw_data = input(footer)
25+
try:
26+
choice = int(raw_data)
27+
if 1 <= choice <= len(options):
28+
return options[choice - 1]
29+
except ValueError:
30+
if raw_data and accept_nonempty_string:
31+
return raw_data
32+
33+
def _input_scopes():
34+
return _select_options([
35+
"https://graph.microsoft.com/.default",
36+
"https://management.azure.com/.default",
37+
"User.Read",
38+
"User.ReadBasic.All",
39+
],
40+
header="Select a scope (multiple scopes can only be input by manually typing them):",
41+
accept_nonempty_string=True,
42+
).split()
43+
44+
def _select_account(app):
45+
accounts = app.get_accounts()
46+
if accounts:
47+
return _select_options(
48+
accounts,
49+
option_renderer=lambda a: a["username"],
50+
header="Account(s) already signed in inside MSAL Python:",
51+
)
52+
else:
53+
print("No account available inside MSAL Python. Use other methods to acquire token first.")
54+
55+
def acquire_token_silent(app):
56+
"""acquire_token_silent() - with an account already signed into MSAL Python."""
57+
account = _select_account(app)
58+
if account:
59+
pprint.pprint(app.acquire_token_silent(
60+
_input_scopes(),
61+
account=account,
62+
force_refresh=_input_boolean("Bypass MSAL Python's token cache?"),
63+
))
64+
65+
def acquire_token_interactive(app):
66+
"""acquire_token_interactive() - User will be prompted if app opts to do select_account."""
67+
pprint.pprint(app.acquire_token_interactive(
68+
_input_scopes(),
69+
prompt="select_account" if _input_boolean("Select Account?") else None,
70+
login_hint=_input("login_hint: ") or None,
71+
))
72+
73+
def acquire_token_by_username_password(app):
74+
"""acquire_token_by_username_password() - See constraints here: https://docs.microsoft.com/en-us/azure/active-directory/develop/msal-authentication-flows#constraints-for-ropc"""
75+
pprint.pprint(app.acquire_token_by_username_password(
76+
_input("username: "), getpass.getpass("password: "), scopes=_input_scopes()))
77+
78+
_JWK1 = """{"kty":"RSA", "n":"2tNr73xwcj6lH7bqRZrFzgSLj7OeLfbn8216uOMDHuaZ6TEUBDN8Uz0ve8jAlKsP9CQFCSVoSNovdE-fs7c15MxEGHjDcNKLWonznximj8pDGZQjVdfK-7mG6P6z-lgVcLuYu5JcWU_PeEqIKg5llOaz-qeQ4LEDS4T1D2qWRGpAra4rJX1-kmrWmX_XIamq30C9EIO0gGuT4rc2hJBWQ-4-FnE1NXmy125wfT3NdotAJGq5lMIfhjfglDbJCwhc8Oe17ORjO3FsB5CLuBRpYmP7Nzn66lRY3Fe11Xz8AEBl3anKFSJcTvlMnFtu3EpD-eiaHfTgRBU7CztGQqVbiQ", "e":"AQAB"}"""
79+
SSH_CERT_DATA = {"token_type": "ssh-cert", "key_id": "key1", "req_cnf": _JWK1}
80+
SSH_CERT_SCOPE = ["https://pas.windows.net/CheckMyAccess/Linux/.default"]
81+
82+
def acquire_ssh_cert_silently(app):
83+
"""Acquire an SSH Cert silently- This typically only works with Azure CLI"""
84+
account = _select_account(app)
85+
if account:
86+
result = app.acquire_token_silent(
87+
SSH_CERT_SCOPE,
88+
account,
89+
data=SSH_CERT_DATA,
90+
force_refresh=_input_boolean("Bypass MSAL Python's token cache?"),
91+
)
92+
pprint.pprint(result)
93+
if result and result.get("token_type") != "ssh-cert":
94+
logging.error("Unable to acquire an ssh-cert.")
95+
96+
def acquire_ssh_cert_interactive(app):
97+
"""Acquire an SSH Cert interactively - This typically only works with Azure CLI"""
98+
result = app.acquire_token_interactive(
99+
SSH_CERT_SCOPE,
100+
prompt="select_account" if _input_boolean("Select Account?") else None,
101+
login_hint=_input("login_hint: ") or None,
102+
data=SSH_CERT_DATA,
103+
)
104+
pprint.pprint(result)
105+
if result.get("token_type") != "ssh-cert":
106+
logging.error("Unable to acquire an ssh-cert")
107+
108+
def remove_account(app):
109+
"""remove_account() - Invalidate account and/or token(s) from cache, so that acquire_token_silent() would be reset"""
110+
account = _select_account(app)
111+
if account:
112+
app.remove_account(account)
113+
print('Account "{}" and/or its token(s) are signed out from MSAL Python'.format(account["username"]))
114+
115+
def exit(_):
116+
"""Exit"""
117+
print("Bye")
118+
sys.exit()
119+
120+
def main():
121+
print("Welcome to the Msal Python Console Test App")
122+
chosen_app = _select_options([
123+
{"client_id": "04b07795-8ddb-461a-bbee-02f9e1bf7b46", "name": "Azure CLI"},
124+
{"client_id": "04f0c124-f2bc-4f59-8241-bf6df9866bbd", "name": "Visual Studio (Correctly configured for MSA-PT)"},
125+
],
126+
option_renderer=lambda a: a["name"],
127+
header="Impersonate this app (or you can type in the client_id of your own app)",
128+
accept_nonempty_string=True)
129+
app = msal.PublicClientApplication(
130+
chosen_app["client_id"] if isinstance(chosen_app, dict) else chosen_app,
131+
authority=_select_options([
132+
"https://login.microsoftonline.com/common",
133+
"https://login.microsoftonline.com/organizations",
134+
"https://login.microsoftonline.com/microsoft.onmicrosoft.com",
135+
"https://login.microsoftonline.com/msidlab4.onmicrosoft.com",
136+
"https://login.microsoftonline.com/consumers",
137+
], header="Input authority", accept_nonempty_string=True),
138+
)
139+
if _input_boolean("Enable MSAL Python's DEBUG log?"):
140+
logging.basicConfig(level=logging.DEBUG)
141+
while True:
142+
func = _select_options([
143+
acquire_token_silent,
144+
acquire_token_interactive,
145+
acquire_token_by_username_password,
146+
acquire_ssh_cert_silently,
147+
acquire_ssh_cert_interactive,
148+
remove_account,
149+
exit,
150+
], option_renderer=lambda f: f.__doc__, header="MSAL Python APIs:")
151+
try:
152+
func(app)
153+
except KeyboardInterrupt: # Useful for bailing out a stuck interactive flow
154+
print("Aborted")
155+
156+
if __name__ == "__main__":
157+
main()
158+

0 commit comments

Comments
 (0)