Skip to content

Commit 49ce6c5

Browse files
committed
Merge remote-tracking branch 'oauth2cli/dev' into dev
2 parents 4404525 + 97c2585 commit 49ce6c5

File tree

3 files changed

+160
-3
lines changed

3 files changed

+160
-3
lines changed

msal/oauth2cli/oauth2.py

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33

44
import json
55
try:
6-
from urllib.parse import urlencode, parse_qs, quote_plus
6+
from urllib.parse import urlencode, parse_qs, quote_plus, urlparse
77
except ImportError:
8-
from urlparse import parse_qs
8+
from urlparse import parse_qs, urlparse
99
from urllib import urlencode, quote_plus
1010
import logging
1111
import warnings
@@ -19,6 +19,13 @@
1919

2020
import requests
2121

22+
from .authcode import AuthCodeReceiver as _AuthCodeReceiver
23+
24+
try:
25+
PermissionError # Available in Python 3
26+
except:
27+
from socket import error as PermissionError # Workaround for Python 2
28+
2229

2330
string_types = (str,) if sys.version_info[0] >= 3 else (basestring, )
2431

@@ -259,6 +266,11 @@ def _stringify(self, sequence):
259266
return sequence # as-is
260267

261268

269+
def _scope_set(scope):
270+
assert scope is None or isinstance(scope, (list, set, tuple))
271+
return set(scope) if scope else set([])
272+
273+
262274
def _generate_pkce_code_verifier(length=43):
263275
assert 43 <= length <= 128
264276
verifier = "".join( # https://tools.ietf.org/html/rfc7636#section-4.1
@@ -488,10 +500,12 @@ def obtain_token_by_auth_code_flow(
488500
The same dict returned by :func:`~initiate_auth_code_flow()`.
489501
:param dict auth_response:
490502
A dict based on query string received from auth server.
503+
491504
:param scope:
492505
You don't usually need to use scope parameter here.
493506
Some Identity Provider allows you to provide
494507
a subset of what you specified during :func:`~initiate_auth_code_flow`.
508+
:type scope: collections.Iterable[str]
495509
496510
:return:
497511
* A dict containing "access_token" and/or "id_token", among others,
@@ -554,6 +568,82 @@ def authorize(): # A controller in a web app
554568
return error
555569
raise ValueError('auth_response must contain either "code" or "error"')
556570

571+
def obtain_token_by_browser(
572+
# Name influenced by RFC 8252: "native apps should (use) ... user's browser"
573+
self,
574+
scope=None,
575+
extra_scope_to_consent=None,
576+
redirect_uri=None,
577+
timeout=None,
578+
welcome_template=None,
579+
success_template=None,
580+
auth_params=None,
581+
**kwargs):
582+
"""A native app can use this method to obtain token via a local browser.
583+
584+
Internally, it implements PKCE to mitigate the auth code interception attack.
585+
586+
:param scope: A list of scopes that you would like to obtain token for.
587+
:type scope: collections.Iterable[str]
588+
589+
:param extra_scope_to_consent:
590+
Some IdP allows you to include more scopes for end user to consent.
591+
The access token returned by this method will NOT include those scopes,
592+
but the refresh token would record those extra consent,
593+
so that your future :func:`~obtain_token_by_refresh_token()` call
594+
would be able to obtain token for those additional scopes, silently.
595+
:type scope: collections.Iterable[str]
596+
597+
:param string redirect_uri:
598+
The redirect_uri to be sent via auth request to Identity Provider (IdP),
599+
to indicate where an auth response would come back to.
600+
Such as ``http://127.0.0.1:0`` (default) or ``http://localhost:1234``.
601+
602+
If port 0 is specified, this method will choose a system-allocated port,
603+
then the actual redirect_uri will contain that port.
604+
To use this behavior, your IdP would need to accept such dynamic port.
605+
606+
Per HTTP convention, if port number is absent, it would mean port 80,
607+
although you probably want to specify port 0 in this context.
608+
609+
:param dict auth_params:
610+
These parameters will be sent to authorization_endpoint.
611+
612+
:param int timeout: In seconds. None means wait indefinitely.
613+
:return: Same as :func:`~obtain_token_by_auth_code_flow()`
614+
"""
615+
_redirect_uri = urlparse(redirect_uri or "http://127.0.0.1:0")
616+
if not _redirect_uri.hostname:
617+
raise ValueError("redirect_uri should contain hostname")
618+
if _redirect_uri.scheme == "https":
619+
raise ValueError("Our local loopback server will not use https")
620+
listen_port = _redirect_uri.port if _redirect_uri.port is not None else 80
621+
# This implementation allows port-less redirect_uri to mean port 80
622+
try:
623+
with _AuthCodeReceiver(port=listen_port) as receiver:
624+
flow = self.initiate_auth_code_flow(
625+
redirect_uri="http://{host}:{port}".format(
626+
host=_redirect_uri.hostname, port=receiver.get_port(),
627+
) if _redirect_uri.port is not None else "http://{host}".format(
628+
host=_redirect_uri.hostname
629+
), # This implementation uses port-less redirect_uri as-is
630+
scope=_scope_set(scope) | _scope_set(extra_scope_to_consent),
631+
**(auth_params or {}))
632+
auth_response = receiver.get_auth_response(
633+
auth_uri=flow["auth_uri"],
634+
state=flow["state"], # Optional but we choose to do it upfront
635+
timeout=timeout,
636+
welcome_template=welcome_template,
637+
success_template=success_template,
638+
)
639+
except PermissionError:
640+
if 0 < listen_port < 1024:
641+
self.logger.error(
642+
"Can't listen on port %s. You may try port 0." % listen_port)
643+
raise
644+
return self.obtain_token_by_auth_code_flow(
645+
flow, auth_response, scope=scope, **kwargs)
646+
557647
@staticmethod
558648
def parse_auth_response(params, state=None):
559649
"""Parse the authorization response being redirected back.

msal/oauth2cli/oidc.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ def decode_id_token(id_token, client_id=None, issuer=None, nonce=None, now=None)
6060
err = "3. The aud (audience) Claim must contain this client's client_id."
6161
# Per specs:
6262
# 6. If the ID Token is received via direct communication between
63-
# the Client and the Token Endpoint (which it is in this flow),
63+
# the Client and the Token Endpoint (which it is during _obtain_token()),
6464
# the TLS server validation MAY be used to validate the issuer
6565
# in place of checking the token signature.
6666
if _now > decoded["exp"]:
@@ -193,3 +193,50 @@ def obtain_token_by_auth_code_flow(self, auth_code_flow, auth_response, **kwargs
193193
(nonce_in_id_token, expected_hash))
194194
return result
195195

196+
def obtain_token_by_browser(
197+
self,
198+
display=None,
199+
prompt=None,
200+
max_age=None,
201+
ui_locales=None,
202+
id_token_hint=None, # It is relevant,
203+
# because this library exposes raw ID token
204+
login_hint=None,
205+
acr_values=None,
206+
**kwargs):
207+
"""A native app can use this method to obtain token via a local browser.
208+
209+
Internally, it implements nonce to mitigate replay attack.
210+
It also implements PKCE to mitigate the auth code interception attack.
211+
212+
:param string display: Defined in
213+
`OIDC <https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest>`_.
214+
:param string prompt: Defined in
215+
`OIDC <https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest>`_.
216+
:param int max_age: Defined in
217+
`OIDC <https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest>`_.
218+
:param string ui_locales: Defined in
219+
`OIDC <https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest>`_.
220+
:param string id_token_hint: Defined in
221+
`OIDC <https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest>`_.
222+
:param string login_hint: Defined in
223+
`OIDC <https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest>`_.
224+
:param string acr_values: Defined in
225+
`OIDC <https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest>`_.
226+
227+
See :func:`oauth2.Client.obtain_token_by_browser` in parent class
228+
for descriptions on other parameters and return value.
229+
"""
230+
filtered_params = {k:v for k, v in dict(
231+
prompt=prompt,
232+
display=display,
233+
max_age=max_age,
234+
ui_locales=ui_locales,
235+
id_token_hint=id_token_hint,
236+
login_hint=login_hint,
237+
acr_values=acr_values,
238+
).items() if v is not None} # Filter out None values
239+
return super(Client, self).obtain_token_by_browser(
240+
auth_params=dict(kwargs.pop("auth_params", {}), **filtered_params),
241+
**kwargs)
242+

tests/test_client.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ def test_auth_code_flow(self):
161161
flow = self.client.initiate_auth_code_flow(
162162
redirect_uri="http://localhost:%d" % receiver.get_port(),
163163
scope=CONFIG.get("scope"),
164+
login_hint=CONFIG.get("username"), # To skip the account selector
164165
)
165166
auth_response = receiver.get_auth_response(
166167
auth_uri=flow["auth_uri"],
@@ -195,6 +196,25 @@ def test_auth_code_flow_error_response(self):
195196
{"state": "s", "error": "foo", "error_uri": "bar", "access_token": "fake"}),
196197
"We should not leak malicious input into our output")
197198

199+
@unittest.skipUnless(
200+
"authorization_endpoint" in CONFIG.get("openid_configuration", {}),
201+
"authorization_endpoint missing")
202+
def test_obtain_token_by_browser(self):
203+
result = self.client.obtain_token_by_browser(
204+
scope=CONFIG.get("scope"),
205+
redirect_uri=CONFIG.get("redirect_uri"),
206+
welcome_template="""<html><body>
207+
authorization_endpoint = {a}, client_id = {i}
208+
<a href="$auth_uri">Sign In</a> or <a href="$abort_uri">Abort</a>
209+
</body></html>""".format(
210+
a=CONFIG["openid_configuration"]["authorization_endpoint"],
211+
i=CONFIG.get("client_id")),
212+
success_template="<strong>Done. You can close this window now.</strong>",
213+
login_hint=CONFIG.get("username"), # To skip the account selector
214+
timeout=60,
215+
)
216+
self.assertLoosely(result, lambda: self.assertIn('access_token', result))
217+
198218
@unittest.skipUnless(
199219
CONFIG.get("openid_configuration", {}).get("device_authorization_endpoint"),
200220
"device_authorization_endpoint is missing")

0 commit comments

Comments
 (0)