Skip to content

Commit db74e80

Browse files
authored
Merge pull request #601 from AzureAD/release-1.24.1
MSAL Python 1.24.1
2 parents edb6c0b + 4a88b63 commit db74e80

File tree

5 files changed

+115
-71
lines changed

5 files changed

+115
-71
lines changed

tests/msaltest.py renamed to msal/__main__.py

Lines changed: 62 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,19 @@
1+
# It is currently shipped inside msal library.
2+
# Pros: It is always available wherever msal is installed.
3+
# Cons: Its 3rd-party dependencies (if any) may become msal's dependency.
4+
"""MSAL Python Tester
5+
6+
Usage 1: Run it on the fly.
7+
python -m msal
8+
9+
Usage 2: Build an all-in-one executable file for bug bash.
10+
shiv -e msal.__main__._main -o msaltest-on-os-name.pyz .
11+
Note: We choose to not define a console script to avoid name conflict.
12+
"""
113
import base64, getpass, json, logging, sys, msal
214

3-
4-
AZURE_CLI = "04b07795-8ddb-461a-bbee-02f9e1bf7b46"
5-
VISUAL_STUDIO = "04f0c124-f2bc-4f59-8241-bf6df9866bbd"
15+
_AZURE_CLI = "04b07795-8ddb-461a-bbee-02f9e1bf7b46"
16+
_VISUAL_STUDIO = "04f0c124-f2bc-4f59-8241-bf6df9866bbd"
617

718
def print_json(blob):
819
print(json.dumps(blob, indent=2, sort_keys=True))
@@ -61,7 +72,7 @@ def _select_account(app):
6172
else:
6273
print("No account available inside MSAL Python. Use other methods to acquire token first.")
6374

64-
def acquire_token_silent(app):
75+
def _acquire_token_silent(app):
6576
"""acquire_token_silent() - with an account already signed into MSAL Python."""
6677
account = _select_account(app)
6778
if account:
@@ -71,95 +82,94 @@ def acquire_token_silent(app):
7182
force_refresh=_input_boolean("Bypass MSAL Python's token cache?"),
7283
))
7384

74-
def _acquire_token_interactive(app, scopes, data=None):
85+
def _acquire_token_interactive(app, scopes=None, data=None):
86+
"""acquire_token_interactive() - User will be prompted if app opts to do select_account."""
87+
scopes = scopes or _input_scopes() # Let user input scope param before less important prompt and login_hint
7588
prompt = _select_options([
7689
{"value": None, "description": "Unspecified. Proceed silently with a default account (if any), fallback to prompt."},
7790
{"value": "none", "description": "none. Proceed silently with a default account (if any), or error out."},
7891
{"value": "select_account", "description": "select_account. Prompt with an account picker."},
7992
],
8093
option_renderer=lambda o: o["description"],
8194
header="Prompt behavior?")["value"]
82-
raw_login_hint = _select_options(
83-
# login_hint is unnecessary when prompt=select_account,
84-
# but we still let tester input login_hint, just for testing purpose.
85-
[None] + [a["username"] for a in app.get_accounts()],
86-
header="login_hint? (If you have multiple signed-in sessions in browser/broker, and you specify a login_hint to match one of them, you will bypass the account picker.)",
87-
accept_nonempty_string=True,
88-
)
89-
login_hint = raw_login_hint["username"] if isinstance(raw_login_hint, dict) else raw_login_hint
95+
if prompt == "select_account":
96+
login_hint = None # login_hint is unnecessary when prompt=select_account
97+
else:
98+
raw_login_hint = _select_options(
99+
[None] + [a["username"] for a in app.get_accounts()],
100+
header="login_hint? (If you have multiple signed-in sessions in browser/broker, and you specify a login_hint to match one of them, you will bypass the account picker.)",
101+
accept_nonempty_string=True,
102+
)
103+
login_hint = raw_login_hint["username"] if isinstance(raw_login_hint, dict) else raw_login_hint
90104
result = app.acquire_token_interactive(
91105
scopes,
92106
parent_window_handle=app.CONSOLE_WINDOW_HANDLE, # This test app is a console app
93107
enable_msa_passthrough=app.client_id in [ # Apps are expected to set this right
94-
AZURE_CLI, VISUAL_STUDIO,
108+
_AZURE_CLI, _VISUAL_STUDIO,
95109
], # Here this test app mimics the setting for some known MSA-PT apps
96-
prompt=prompt, login_hint=login_hint, data=data or {})
110+
prompt=prompt, login_hint=login_hint, data=data or {},
111+
)
97112
if login_hint and "id_token_claims" in result:
98113
signed_in_user = result.get("id_token_claims", {}).get("preferred_username")
99114
if signed_in_user != login_hint:
100115
logging.warning('Signed-in user "%s" does not match login_hint', signed_in_user)
116+
print_json(result)
101117
return result
102118

103-
def acquire_token_interactive(app):
104-
"""acquire_token_interactive() - User will be prompted if app opts to do select_account."""
105-
print_json(_acquire_token_interactive(app, _input_scopes()))
106-
107-
def acquire_token_by_username_password(app):
119+
def _acquire_token_by_username_password(app):
108120
"""acquire_token_by_username_password() - See constraints here: https://docs.microsoft.com/en-us/azure/active-directory/develop/msal-authentication-flows#constraints-for-ropc"""
109121
print_json(app.acquire_token_by_username_password(
110122
_input("username: "), getpass.getpass("password: "), scopes=_input_scopes()))
111123

112124
_JWK1 = """{"kty":"RSA", "n":"2tNr73xwcj6lH7bqRZrFzgSLj7OeLfbn8216uOMDHuaZ6TEUBDN8Uz0ve8jAlKsP9CQFCSVoSNovdE-fs7c15MxEGHjDcNKLWonznximj8pDGZQjVdfK-7mG6P6z-lgVcLuYu5JcWU_PeEqIKg5llOaz-qeQ4LEDS4T1D2qWRGpAra4rJX1-kmrWmX_XIamq30C9EIO0gGuT4rc2hJBWQ-4-FnE1NXmy125wfT3NdotAJGq5lMIfhjfglDbJCwhc8Oe17ORjO3FsB5CLuBRpYmP7Nzn66lRY3Fe11Xz8AEBl3anKFSJcTvlMnFtu3EpD-eiaHfTgRBU7CztGQqVbiQ", "e":"AQAB"}"""
113-
SSH_CERT_DATA = {"token_type": "ssh-cert", "key_id": "key1", "req_cnf": _JWK1}
114-
SSH_CERT_SCOPE = ["https://pas.windows.net/CheckMyAccess/Linux/.default"]
125+
_SSH_CERT_DATA = {"token_type": "ssh-cert", "key_id": "key1", "req_cnf": _JWK1}
126+
_SSH_CERT_SCOPE = ["https://pas.windows.net/CheckMyAccess/Linux/.default"]
115127

116-
def acquire_ssh_cert_silently(app):
128+
def _acquire_ssh_cert_silently(app):
117129
"""Acquire an SSH Cert silently- This typically only works with Azure CLI"""
118130
account = _select_account(app)
119131
if account:
120132
result = app.acquire_token_silent(
121-
SSH_CERT_SCOPE,
133+
_SSH_CERT_SCOPE,
122134
account,
123-
data=SSH_CERT_DATA,
135+
data=_SSH_CERT_DATA,
124136
force_refresh=_input_boolean("Bypass MSAL Python's token cache?"),
125137
)
126138
print_json(result)
127139
if result and result.get("token_type") != "ssh-cert":
128140
logging.error("Unable to acquire an ssh-cert.")
129141

130-
def acquire_ssh_cert_interactive(app):
142+
def _acquire_ssh_cert_interactive(app):
131143
"""Acquire an SSH Cert interactively - This typically only works with Azure CLI"""
132-
result = _acquire_token_interactive(app, SSH_CERT_SCOPE, data=SSH_CERT_DATA)
133-
print_json(result)
144+
result = _acquire_token_interactive(app, scopes=_SSH_CERT_SCOPE, data=_SSH_CERT_DATA)
134145
if result.get("token_type") != "ssh-cert":
135146
logging.error("Unable to acquire an ssh-cert")
136147

137-
POP_KEY_ID = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA-AAAAAAAA' # Fake key with a certain format and length
138-
RAW_REQ_CNF = json.dumps({"kid": POP_KEY_ID, "xms_ksl": "sw"})
139-
POP_DATA = { # Sampled from Azure CLI's plugin connectedk8s
148+
_POP_KEY_ID = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA-AAAAAAAA' # Fake key with a certain format and length
149+
_RAW_REQ_CNF = json.dumps({"kid": _POP_KEY_ID, "xms_ksl": "sw"})
150+
_POP_DATA = { # Sampled from Azure CLI's plugin connectedk8s
140151
'token_type': 'pop',
141-
'key_id': POP_KEY_ID,
142-
"req_cnf": base64.urlsafe_b64encode(RAW_REQ_CNF.encode('utf-8')).decode('utf-8').rstrip('='),
143-
# Note: Sending RAW_REQ_CNF without base64 encoding would result in an http 500 error
152+
'key_id': _POP_KEY_ID,
153+
"req_cnf": base64.urlsafe_b64encode(_RAW_REQ_CNF.encode('utf-8')).decode('utf-8').rstrip('='),
154+
# Note: Sending _RAW_REQ_CNF without base64 encoding would result in an http 500 error
144155
} # See also https://github.com/Azure/azure-cli-extensions/blob/main/src/connectedk8s/azext_connectedk8s/_clientproxyutils.py#L86-L92
145156

146-
def acquire_pop_token_interactive(app):
157+
def _acquire_pop_token_interactive(app):
147158
"""Acquire a POP token interactively - This typically only works with Azure CLI"""
148159
POP_SCOPE = ['6256c85f-0aad-4d50-b960-e6e9b21efe35/.default'] # KAP 1P Server App Scope, obtained from https://github.com/Azure/azure-cli-extensions/pull/4468/files#diff-a47efa3186c7eb4f1176e07d0b858ead0bf4a58bfd51e448ee3607a5b4ef47f6R116
149-
result = _acquire_token_interactive(app, POP_SCOPE, data=POP_DATA)
160+
result = _acquire_token_interactive(app, scopes=POP_SCOPE, data=_POP_DATA)
150161
print_json(result)
151162
if result.get("token_type") != "pop":
152163
logging.error("Unable to acquire a pop token")
153164

154-
155-
def remove_account(app):
165+
def _remove_account(app):
156166
"""remove_account() - Invalidate account and/or token(s) from cache, so that acquire_token_silent() would be reset"""
157167
account = _select_account(app)
158168
if account:
159169
app.remove_account(account)
160170
print('Account "{}" and/or its token(s) are signed out from MSAL Python'.format(account["username"]))
161171

162-
def exit(app):
172+
def _exit(app):
163173
"""Exit"""
164174
bug_link = (
165175
"https://identitydivision.visualstudio.com/Engineering/_queries/query/79b3a352-a775-406f-87cd-a487c382a8ed/"
@@ -169,11 +179,11 @@ def exit(app):
169179
print("Bye. If you found a bug, please report it here: {}".format(bug_link))
170180
sys.exit()
171181

172-
def main():
173-
print("Welcome to the Msal Python {} Tester\n".format(msal.__version__))
182+
def _main():
183+
print("Welcome to the Msal Python {} Tester (Experimental)\n".format(msal.__version__))
174184
chosen_app = _select_options([
175-
{"client_id": AZURE_CLI, "name": "Azure CLI (Correctly configured for MSA-PT)"},
176-
{"client_id": VISUAL_STUDIO, "name": "Visual Studio (Correctly configured for MSA-PT)"},
185+
{"client_id": _AZURE_CLI, "name": "Azure CLI (Correctly configured for MSA-PT)"},
186+
{"client_id": _VISUAL_STUDIO, "name": "Visual Studio (Correctly configured for MSA-PT)"},
177187
{"client_id": "95de633a-083e-42f5-b444-a4295d8e9314", "name": "Whiteboard Services (Non MSA-PT app. Accepts AAD & MSA accounts.)"},
178188
],
179189
option_renderer=lambda a: a["name"],
@@ -201,14 +211,14 @@ def main():
201211
logging.basicConfig(level=logging.DEBUG)
202212
while True:
203213
func = _select_options([
204-
acquire_token_silent,
205-
acquire_token_interactive,
206-
acquire_token_by_username_password,
207-
acquire_ssh_cert_silently,
208-
acquire_ssh_cert_interactive,
209-
acquire_pop_token_interactive,
210-
remove_account,
211-
exit,
214+
_acquire_token_silent,
215+
_acquire_token_interactive,
216+
_acquire_token_by_username_password,
217+
_acquire_ssh_cert_silently,
218+
_acquire_ssh_cert_interactive,
219+
_acquire_pop_token_interactive,
220+
_remove_account,
221+
_exit,
212222
], option_renderer=lambda f: f.__doc__, header="MSAL Python APIs:")
213223
try:
214224
func(app)
@@ -218,5 +228,5 @@ def main():
218228
print("Aborted")
219229

220230
if __name__ == "__main__":
221-
main()
231+
_main()
222232

msal/application.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525

2626

2727
# The __init__.py will import this. Not the other way around.
28-
__version__ = "1.24.0" # When releasing, also check and bump our dependencies's versions if needed
28+
__version__ = "1.24.1" # When releasing, also check and bump our dependencies's versions if needed
2929

3030
logger = logging.getLogger(__name__)
3131
_AUTHORITY_TYPE_CLOUDSHELL = "CLOUDSHELL"

msal/oauth2cli/authcode.py

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,12 @@
1515
try: # Python 3
1616
from http.server import HTTPServer, BaseHTTPRequestHandler
1717
from urllib.parse import urlparse, parse_qs, urlencode
18+
from html import escape
1819
except ImportError: # Fall back to Python 2
1920
from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler
2021
from urlparse import urlparse, parse_qs
2122
from urllib import urlencode
23+
from cgi import escape
2224

2325

2426
logger = logging.getLogger(__name__)
@@ -77,25 +79,42 @@ def _qs2kv(qs):
7779
for k, v in qs.items()}
7880

7981

82+
def _is_html(text):
83+
return text.startswith("<") # Good enough for our purpose
84+
85+
86+
def _escape(key_value_pairs):
87+
return {k: escape(v) for k, v in key_value_pairs.items()}
88+
89+
8090
class _AuthCodeHandler(BaseHTTPRequestHandler):
8191
def do_GET(self):
8292
# For flexibility, we choose to not check self.path matching redirect_uri
8393
#assert self.path.startswith('/THE_PATH_REGISTERED_BY_THE_APP')
8494
qs = parse_qs(urlparse(self.path).query)
8595
if qs.get('code') or qs.get("error"): # So, it is an auth response
86-
self.server.auth_response = _qs2kv(qs)
87-
logger.debug("Got auth response: %s", self.server.auth_response)
88-
template = (self.server.success_template
89-
if "code" in qs else self.server.error_template)
90-
self._send_full_response(
91-
template.safe_substitute(**self.server.auth_response))
92-
# NOTE: Don't do self.server.shutdown() here. It'll halt the server.
96+
auth_response = _qs2kv(qs)
97+
logger.debug("Got auth response: %s", auth_response)
98+
if self.server.auth_state and self.server.auth_state != auth_response.get("state"):
99+
# OAuth2 successful and error responses contain state when it was used
100+
# https://www.rfc-editor.org/rfc/rfc6749#section-4.2.2.1
101+
self._send_full_response("State mismatch") # Possibly an attack
102+
else:
103+
template = (self.server.success_template
104+
if "code" in qs else self.server.error_template)
105+
if _is_html(template.template):
106+
safe_data = _escape(auth_response) # Foiling an XSS attack
107+
else:
108+
safe_data = auth_response
109+
self._send_full_response(template.safe_substitute(**safe_data))
110+
self.server.auth_response = auth_response # Set it now, after the response is likely sent
93111
else:
94112
self._send_full_response(self.server.welcome_page)
113+
# NOTE: Don't do self.server.shutdown() here. It'll halt the server.
95114

96115
def _send_full_response(self, body, is_ok=True):
97116
self.send_response(200 if is_ok else 400)
98-
content_type = 'text/html' if body.startswith('<') else 'text/plain'
117+
content_type = 'text/html' if _is_html(body) else 'text/plain'
99118
self.send_header('Content-type', content_type)
100119
self.end_headers()
101120
self.wfile.write(body.encode("utf-8"))
@@ -281,16 +300,14 @@ def _get_auth_response(self, result, auth_uri=None, timeout=None, state=None,
281300

282301
self._server.timeout = timeout # Otherwise its handle_timeout() won't work
283302
self._server.auth_response = {} # Shared with _AuthCodeHandler
303+
self._server.auth_state = state # So handler will check it before sending response
284304
while not self._closing: # Otherwise, the handle_request() attempt
285305
# would yield noisy ValueError trace
286306
# Derived from
287307
# https://docs.python.org/2/library/basehttpserver.html#more-examples
288308
self._server.handle_request()
289309
if self._server.auth_response:
290-
if state and state != self._server.auth_response.get("state"):
291-
logger.debug("State mismatch. Ignoring this noise.")
292-
else:
293-
break
310+
break
294311
result.update(self._server.auth_response) # Return via writable result param
295312

296313
def close(self):
@@ -318,6 +335,7 @@ def __exit__(self, exc_type, exc_val, exc_tb):
318335
default="https://login.microsoftonline.com/common/oauth2/v2.0/authorize")
319336
p.add_argument('client_id', help="The client_id of your application")
320337
p.add_argument('--port', type=int, default=0, help="The port in redirect_uri")
338+
p.add_argument('--timeout', type=int, default=60, help="Timeout value, in second")
321339
p.add_argument('--host', default="127.0.0.1", help="The host of redirect_uri")
322340
p.add_argument('--scope', default=None, help="The scope list")
323341
args = parser.parse_args()
@@ -331,8 +349,8 @@ def __exit__(self, exc_type, exc_val, exc_tb):
331349
auth_uri=flow["auth_uri"],
332350
welcome_template=
333351
"<a href='$auth_uri'>Sign In</a>, or <a href='$abort_uri'>Abort</a",
334-
error_template="Oh no. $error",
352+
error_template="<html>Oh no. $error</html>",
335353
success_template="Oh yeah. Got $code",
336-
timeout=60,
354+
timeout=args.timeout,
337355
state=flow["state"], # Optional
338356
), indent=4))

msal/oauth2cli/oauth2.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -666,7 +666,7 @@ def _obtain_token_by_browser(
666666
**(auth_params or {}))
667667
auth_response = auth_code_receiver.get_auth_response(
668668
auth_uri=flow["auth_uri"],
669-
state=flow["state"], # Optional but we choose to do it upfront
669+
state=flow["state"], # So receiver can check it early
670670
timeout=timeout,
671671
welcome_template=welcome_template,
672672
success_template=success_template,

tests/test_authcode.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
import socket
33
import sys
44

5+
import requests
6+
57
from msal.oauth2cli.authcode import AuthCodeReceiver
68

79

@@ -17,10 +19,24 @@ def test_setup_at_a_ephemeral_port_and_teardown(self):
1719
self.assertNotEqual(port, receiver.get_port())
1820

1921
def test_no_two_concurrent_receivers_can_listen_on_same_port(self):
20-
port = 12345 # Assuming this port is available
21-
with AuthCodeReceiver(port=port) as receiver:
22+
with AuthCodeReceiver() as receiver:
2223
expected_error = OSError if sys.version_info[0] > 2 else socket.error
2324
with self.assertRaises(expected_error):
24-
with AuthCodeReceiver(port=port) as receiver2:
25+
with AuthCodeReceiver(port=receiver.get_port()):
2526
pass
2627

28+
def test_template_should_escape_input(self):
29+
with AuthCodeReceiver() as receiver:
30+
receiver._scheduled_actions = [( # Injection happens here when the port is known
31+
1, # Delay it until the receiver is activated by get_auth_response()
32+
lambda: self.assertEqual(
33+
"<html>&lt;tag&gt;foo&lt;/tag&gt;</html>",
34+
requests.get("http://localhost:{}?error=<tag>foo</tag>".format(
35+
receiver.get_port())).text,
36+
"Unsafe data in HTML should be escaped",
37+
))]
38+
receiver.get_auth_response( # Starts server and hang until timeout
39+
timeout=3,
40+
error_template="<html>$error</html>",
41+
)
42+

0 commit comments

Comments
 (0)