|
3 | 3 |
|
4 | 4 | import json
|
5 | 5 | try:
|
6 |
| - from urllib.parse import urlencode, parse_qs, quote_plus |
| 6 | + from urllib.parse import urlencode, parse_qs, quote_plus, urlparse |
7 | 7 | except ImportError:
|
8 |
| - from urlparse import parse_qs |
| 8 | + from urlparse import parse_qs, urlparse |
9 | 9 | from urllib import urlencode, quote_plus
|
10 | 10 | import logging
|
11 | 11 | import warnings
|
|
19 | 19 |
|
20 | 20 | import requests
|
21 | 21 |
|
| 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 | + |
22 | 29 |
|
23 | 30 | string_types = (str,) if sys.version_info[0] >= 3 else (basestring, )
|
24 | 31 |
|
@@ -259,6 +266,11 @@ def _stringify(self, sequence):
|
259 | 266 | return sequence # as-is
|
260 | 267 |
|
261 | 268 |
|
| 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 | + |
262 | 274 | def _generate_pkce_code_verifier(length=43):
|
263 | 275 | assert 43 <= length <= 128
|
264 | 276 | verifier = "".join( # https://tools.ietf.org/html/rfc7636#section-4.1
|
@@ -488,10 +500,12 @@ def obtain_token_by_auth_code_flow(
|
488 | 500 | The same dict returned by :func:`~initiate_auth_code_flow()`.
|
489 | 501 | :param dict auth_response:
|
490 | 502 | A dict based on query string received from auth server.
|
| 503 | +
|
491 | 504 | :param scope:
|
492 | 505 | You don't usually need to use scope parameter here.
|
493 | 506 | Some Identity Provider allows you to provide
|
494 | 507 | a subset of what you specified during :func:`~initiate_auth_code_flow`.
|
| 508 | + :type scope: collections.Iterable[str] |
495 | 509 |
|
496 | 510 | :return:
|
497 | 511 | * A dict containing "access_token" and/or "id_token", among others,
|
@@ -554,6 +568,82 @@ def authorize(): # A controller in a web app
|
554 | 568 | return error
|
555 | 569 | raise ValueError('auth_response must contain either "code" or "error"')
|
556 | 570 |
|
| 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 | + |
557 | 647 | @staticmethod
|
558 | 648 | def parse_auth_response(params, state=None):
|
559 | 649 | """Parse the authorization response being redirected back.
|
|
0 commit comments