Skip to content

Commit 72a7250

Browse files
authored
Merge pull request #309 from AzureAD/release-1.9.0
Release 1.9.0
2 parents 82f9f0c + 2616d89 commit 72a7250

File tree

9 files changed

+287
-137
lines changed

9 files changed

+287
-137
lines changed

.github/workflows/python-package.yml

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# This workflow will install Python dependencies, run tests and lint with a variety of Python versions
2+
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
3+
4+
name: CI/CD
5+
6+
on:
7+
push:
8+
pull_request:
9+
branches: [ dev ]
10+
11+
jobs:
12+
ci:
13+
env:
14+
# Fake a TRAVIS env so that the pre-existing test cases would behave like before
15+
TRAVIS: true
16+
LAB_APP_CLIENT_ID: ${{ secrets.LAB_APP_CLIENT_ID }}
17+
LAB_APP_CLIENT_SECRET: ${{ secrets.LAB_APP_CLIENT_SECRET }}
18+
LAB_OBO_CLIENT_SECRET: ${{ secrets.LAB_OBO_CLIENT_SECRET }}
19+
LAB_OBO_CONFIDENTIAL_CLIENT_ID: ${{ secrets.LAB_OBO_CONFIDENTIAL_CLIENT_ID }}
20+
LAB_OBO_PUBLIC_CLIENT_ID: ${{ secrets.LAB_OBO_PUBLIC_CLIENT_ID }}
21+
22+
# Derived from https://docs.github.com/en/actions/guides/building-and-testing-python#starting-with-the-python-workflow-template
23+
runs-on: ubuntu-latest
24+
strategy:
25+
matrix:
26+
python-version: [2.7, 3.5, 3.6, 3.7, 3.8, 3.9]
27+
28+
steps:
29+
- uses: actions/checkout@v2
30+
- name: Set up Python ${{ matrix.python-version }}
31+
uses: actions/setup-python@v2
32+
with:
33+
python-version: ${{ matrix.python-version }}
34+
35+
# Derived from https://github.com/actions/cache/blob/main/examples.md#using-pip-to-get-cache-location
36+
# However, a before-and-after test shows no improvement in this repo,
37+
# possibly because the bottlenect was not in downloading those small python deps.
38+
- name: Get pip cache dir from pip 20.1+
39+
id: pip-cache
40+
run: |
41+
echo "::set-output name=dir::$(pip cache dir)"
42+
- name: pip cache
43+
uses: actions/cache@v2
44+
with:
45+
path: ${{ steps.pip-cache.outputs.dir }}
46+
key: ${{ runner.os }}-py${{ matrix.python-version }}-pip-${{ hashFiles('**/setup.py', '**/requirements.txt') }}
47+
48+
- name: Install dependencies
49+
run: |
50+
python -m pip install --upgrade pip
51+
python -m pip install flake8 pytest
52+
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
53+
- name: Lint with flake8
54+
run: |
55+
# stop the build if there are Python syntax errors or undefined names
56+
#flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
57+
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
58+
#flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
59+
- name: Test with pytest
60+
run: |
61+
pytest
62+
63+
cd:
64+
needs: ci
65+
if: github.event_name == 'push' && (startsWith(github.ref, 'refs/tags') || github.ref == 'refs/heads/main')
66+
runs-on: ubuntu-latest
67+
steps:
68+
- uses: actions/checkout@v2
69+
- name: Set up Python 3.9
70+
uses: actions/setup-python@v2
71+
with:
72+
python-version: 3.9
73+
- name: Build a package for release
74+
run: |
75+
python -m pip install build --user
76+
python -m build --sdist --wheel --outdir dist/ .
77+
- name: Publish to TestPyPI
78+
uses: pypa/[email protected]
79+
if: github.ref == 'refs/heads/main'
80+
with:
81+
user: __token__
82+
password: ${{ secrets.TEST_PYPI_API_TOKEN }}
83+
repository_url: https://test.pypi.org/legacy/
84+
- name: Publish to PyPI
85+
if: startsWith(github.ref, 'refs/tags')
86+
uses: pypa/[email protected]
87+
with:
88+
user: __token__
89+
password: ${{ secrets.PYPI_API_TOKEN }}

msal/application.py

Lines changed: 40 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121

2222

2323
# The __init__.py will import this. Not the other way around.
24-
__version__ = "1.8.0"
24+
__version__ = "1.9.0"
2525

2626
logger = logging.getLogger(__name__)
2727

@@ -56,7 +56,9 @@ def decorate_scope(
5656
CLIENT_CURRENT_TELEMETRY = 'x-client-current-telemetry'
5757

5858
def _get_new_correlation_id():
59-
return str(uuid.uuid4())
59+
correlation_id = str(uuid.uuid4())
60+
logger.debug("Generates correlation_id: %s", correlation_id)
61+
return correlation_id
6062

6163

6264
def _build_current_telemetry_request_header(public_api_id, force_refresh=False):
@@ -439,16 +441,20 @@ def get_authorization_request_url(
439441
{"authorization_endpoint": the_authority.authorization_endpoint},
440442
self.client_id,
441443
http_client=self.http_client)
442-
return client.build_auth_request_uri(
443-
response_type=response_type,
444-
redirect_uri=redirect_uri, state=state, login_hint=login_hint,
445-
prompt=prompt,
446-
scope=decorate_scope(scopes, self.client_id),
447-
nonce=nonce,
448-
domain_hint=domain_hint,
449-
claims=_merge_claims_challenge_and_capabilities(
450-
self._client_capabilities, claims_challenge),
451-
)
444+
warnings.warn(
445+
"Change your get_authorization_request_url() "
446+
"to initiate_auth_code_flow()", DeprecationWarning)
447+
with warnings.catch_warnings(record=True):
448+
return client.build_auth_request_uri(
449+
response_type=response_type,
450+
redirect_uri=redirect_uri, state=state, login_hint=login_hint,
451+
prompt=prompt,
452+
scope=decorate_scope(scopes, self.client_id),
453+
nonce=nonce,
454+
domain_hint=domain_hint,
455+
claims=_merge_claims_challenge_and_capabilities(
456+
self._client_capabilities, claims_challenge),
457+
)
452458

453459
def acquire_token_by_auth_code_flow(
454460
self, auth_code_flow, auth_response, scopes=None, **kwargs):
@@ -570,20 +576,24 @@ def acquire_token_by_authorization_code(
570576
# really empty.
571577
assert isinstance(scopes, list), "Invalid parameter type"
572578
self._validate_ssh_cert_input_data(kwargs.get("data", {}))
573-
return self.client.obtain_token_by_authorization_code(
574-
code, redirect_uri=redirect_uri,
575-
scope=decorate_scope(scopes, self.client_id),
576-
headers={
577-
CLIENT_REQUEST_ID: _get_new_correlation_id(),
578-
CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header(
579-
self.ACQUIRE_TOKEN_BY_AUTHORIZATION_CODE_ID),
580-
},
581-
data=dict(
582-
kwargs.pop("data", {}),
583-
claims=_merge_claims_challenge_and_capabilities(
584-
self._client_capabilities, claims_challenge)),
585-
nonce=nonce,
586-
**kwargs)
579+
warnings.warn(
580+
"Change your acquire_token_by_authorization_code() "
581+
"to acquire_token_by_auth_code_flow()", DeprecationWarning)
582+
with warnings.catch_warnings(record=True):
583+
return self.client.obtain_token_by_authorization_code(
584+
code, redirect_uri=redirect_uri,
585+
scope=decorate_scope(scopes, self.client_id),
586+
headers={
587+
CLIENT_REQUEST_ID: _get_new_correlation_id(),
588+
CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header(
589+
self.ACQUIRE_TOKEN_BY_AUTHORIZATION_CODE_ID),
590+
},
591+
data=dict(
592+
kwargs.pop("data", {}),
593+
claims=_merge_claims_challenge_and_capabilities(
594+
self._client_capabilities, claims_challenge)),
595+
nonce=nonce,
596+
**kwargs)
587597

588598
def get_accounts(self, username=None):
589599
"""Get a list of accounts which previously signed in, i.e. exists in cache.
@@ -942,7 +952,7 @@ def _validate_ssh_cert_input_data(self, data):
942952
"you must include a string parameter named 'key_id' "
943953
"which identifies the key in the 'req_cnf' argument.")
944954

945-
def acquire_token_by_refresh_token(self, refresh_token, scopes):
955+
def acquire_token_by_refresh_token(self, refresh_token, scopes, **kwargs):
946956
"""Acquire token(s) based on a refresh token (RT) obtained from elsewhere.
947957
948958
You use this method only when you have old RTs from elsewhere,
@@ -965,6 +975,7 @@ def acquire_token_by_refresh_token(self, refresh_token, scopes):
965975
* A dict contains "error" and some other keys, when error happened.
966976
* A dict contains no "error" key means migration was successful.
967977
"""
978+
self._validate_ssh_cert_input_data(kwargs.get("data", {}))
968979
return self.client.obtain_token_by_refresh_token(
969980
refresh_token,
970981
scope=decorate_scope(scopes, self.client_id),
@@ -976,7 +987,7 @@ def acquire_token_by_refresh_token(self, refresh_token, scopes):
976987
rt_getter=lambda rt: rt,
977988
on_updating_rt=False,
978989
on_removing_rt=lambda rt_item: None, # No OP
979-
)
990+
**kwargs)
980991

981992

982993
class PublicClientApplication(ClientApplication): # browser app or mobile app
@@ -1233,6 +1244,7 @@ def acquire_token_for_client(self, scopes, claims_challenge=None, **kwargs):
12331244
- an error response would contain "error" and usually "error_description".
12341245
"""
12351246
# TBD: force_refresh behavior
1247+
self._validate_ssh_cert_input_data(kwargs.get("data", {}))
12361248
return self.client.obtain_token_for_client(
12371249
scope=scopes, # This grant flow requires no scope decoration
12381250
headers={
@@ -1294,4 +1306,3 @@ def acquire_token_on_behalf_of(self, user_assertion, scopes, claims_challenge=No
12941306
self.ACQUIRE_TOKEN_ON_BEHALF_OF_ID),
12951307
},
12961308
**kwargs)
1297-

msal/oauth2cli/assertion.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,15 @@
99

1010
logger = logging.getLogger(__name__)
1111

12+
13+
def _str2bytes(raw):
14+
# A conversion based on duck-typing rather than six.text_type
15+
try: # Assuming it is a string
16+
return raw.encode(encoding="utf-8")
17+
except: # Otherwise we treat it as bytes and return it as-is
18+
return raw
19+
20+
1221
class AssertionCreator(object):
1322
def create_normal_assertion(
1423
self, audience, issuer, subject, expires_at=None, expires_in=600,
@@ -103,8 +112,9 @@ def create_normal_assertion(
103112
payload['nbf'] = not_before
104113
payload.update(additional_claims or {})
105114
try:
106-
return jwt.encode(
115+
str_or_bytes = jwt.encode( # PyJWT 1 returns bytes, PyJWT 2 returns str
107116
payload, self.key, algorithm=self.algorithm, headers=self.headers)
117+
return _str2bytes(str_or_bytes) # We normalize them into bytes
108118
except:
109119
if self.algorithm.startswith("RS") or self.algorithm.starswith("ES"):
110120
logger.exception(

msal/oauth2cli/authcode.py

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -33,19 +33,9 @@ def obtain_auth_code(listen_port, auth_uri=None): # Historically only used in t
3333
).get("code")
3434

3535

36-
def _browse(auth_uri):
36+
def _browse(auth_uri): # throws ImportError, possibly webbrowser.Error in future
3737
import webbrowser # Lazy import. Some distro may not have this.
38-
controller = webbrowser.get() # Get a default controller
39-
# Some Linux Distro does not setup default browser properly,
40-
# so we try to explicitly use some popular browser, if we found any.
41-
for browser in ["chrome", "firefox", "safari", "windows-default"]:
42-
try:
43-
controller = webbrowser.get(browser)
44-
break
45-
except webbrowser.Error:
46-
pass # This browser is not installed. Try next one.
47-
logger.info("Please open a browser on THIS device to visit: %s" % auth_uri)
48-
controller.open(auth_uri)
38+
return webbrowser.open(auth_uri) # Use default browser. Customizable by $BROWSER
4939

5040

5141
def _qs2kv(qs):
@@ -130,14 +120,16 @@ def get_port(self):
130120
return self._server.server_address[1]
131121

132122
def get_auth_response(self, auth_uri=None, timeout=None, state=None,
133-
welcome_template=None, success_template=None, error_template=None):
134-
"""Wait and return the auth response, or None when timeout.
123+
welcome_template=None, success_template=None, error_template=None,
124+
auth_uri_callback=None,
125+
):
126+
"""Wait and return the auth response. Raise RuntimeError when timeout.
135127
136128
:param str auth_uri:
137129
If provided, this function will try to open a local browser.
138130
:param int timeout: In seconds. None means wait indefinitely.
139131
:param str state:
140-
You may provide the state you used in auth_url,
132+
You may provide the state you used in auth_uri,
141133
then we will use it to validate incoming response.
142134
:param str welcome_template:
143135
If provided, your end user will see it instead of the auth_uri.
@@ -152,6 +144,10 @@ def get_auth_response(self, auth_uri=None, timeout=None, state=None,
152144
The page will be displayed when authentication encountered error.
153145
Placeholders can be any of these:
154146
https://tools.ietf.org/html/rfc6749#section-5.2
147+
:param callable auth_uri_callback:
148+
A function with the shape of lambda auth_uri: ...
149+
When a browser was unable to be launch, this function will be called,
150+
so that the app could tell user to manually visit the auth_uri.
155151
:return:
156152
The auth response of the first leg of Auth Code flow,
157153
typically {"code": "...", "state": "..."} or {"error": "...", ...}
@@ -164,8 +160,31 @@ def get_auth_response(self, auth_uri=None, timeout=None, state=None,
164160
logger.debug("Abort by visit %s", abort_uri)
165161
self._server.welcome_page = Template(welcome_template or "").safe_substitute(
166162
auth_uri=auth_uri, abort_uri=abort_uri)
167-
if auth_uri:
168-
_browse(welcome_uri if welcome_template else auth_uri)
163+
if auth_uri: # Now attempt to open a local browser to visit it
164+
_uri = welcome_uri if welcome_template else auth_uri
165+
logger.info("Open a browser on this device to visit: %s" % _uri)
166+
browser_opened = False
167+
try:
168+
browser_opened = _browse(_uri)
169+
except: # Had to use broad except, because the potential
170+
# webbrowser.Error is purposely undefined outside of _browse().
171+
# Absorb and proceed. Because browser could be manually run elsewhere.
172+
logger.exception("_browse(...) unsuccessful")
173+
if not browser_opened:
174+
if not auth_uri_callback:
175+
logger.warning(
176+
"Found no browser in current environment. "
177+
"If this program is being run inside a container "
178+
"which has access to host network "
179+
"(i.e. started by `docker run --net=host -it ...`), "
180+
"you can use browser on host to visit the following link. "
181+
"Otherwise, this auth attempt would either timeout "
182+
"(current timeout setting is {timeout}) "
183+
"or be aborted by CTRL+C. Auth URI: {auth_uri}".format(
184+
auth_uri=_uri, timeout=timeout))
185+
else: # Then it is the auth_uri_callback()'s job to inform the user
186+
auth_uri_callback(_uri)
187+
169188
self._server.success_template = Template(success_template or
170189
"Authentication completed. You can close this window now.")
171190
self._server.error_template = Template(error_template or

msal/oauth2cli/oauth2.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,8 @@ def __init__(
9999
client_secret (str): Triggers HTTP AUTH for Confidential Client
100100
client_assertion (bytes, callable):
101101
The client assertion to authenticate this client, per RFC 7521.
102-
It can be a raw SAML2 assertion (this method will encode it for you),
103-
or a raw JWT assertion.
102+
It can be a raw SAML2 assertion (we will base64 encode it for you),
103+
or a raw JWT assertion in bytes (which we will relay to http layer).
104104
It can also be a callable (recommended),
105105
so that we will do lazy creation of an assertion.
106106
client_assertion_type (str):
@@ -198,7 +198,9 @@ def _obtain_token( # The verb "obtain" is influenced by OAUTH2 RFC 6749
198198
self.default_body["client_assertion_type"], lambda a: a)
199199
_data["client_assertion"] = encoder(
200200
self.client_assertion() # Do lazy on-the-fly computation
201-
if callable(self.client_assertion) else self.client_assertion)
201+
if callable(self.client_assertion) else self.client_assertion
202+
) # The type is bytes, which is preferrable. See also:
203+
# https://github.com/psf/requests/issues/4503#issuecomment-455001070
202204

203205
_data.update(self.default_body) # It may contain authen parameters
204206
_data.update(data or {}) # So the content in data param prevails
@@ -578,6 +580,7 @@ def obtain_token_by_browser(
578580
welcome_template=None,
579581
success_template=None,
580582
auth_params=None,
583+
auth_uri_callback=None,
581584
**kwargs):
582585
"""A native app can use this method to obtain token via a local browser.
583586
@@ -635,6 +638,7 @@ def obtain_token_by_browser(
635638
timeout=timeout,
636639
welcome_template=welcome_template,
637640
success_template=success_template,
641+
auth_uri_callback=auth_uri_callback,
638642
)
639643
except PermissionError:
640644
if 0 < listen_port < 1024:

0 commit comments

Comments
 (0)