Repository: globus/globus-sdk-python

Issue:
Make requested_scopes accept (in all contexts) an iterable of strings
Right now, we're requiring that people give us a space-delimited string.

Many people have pointed out that it's awkward / uncomfortable.
Wouldn't it be great if we could take tuples and lists? Yes, yes it would.

This shouldn't be too hard.

Discussion:

Patch:
diff --git a/docs/examples/native_app.rst b/docs/examples/native_app.rst
index 27b0b239..9f39b8ec 100644
--- a/docs/examples/native_app.rst
+++ b/docs/examples/native_app.rst
@@ -1,3 +1,5 @@
+.. _examples_native_app_login:
+
 Native App Login
 ----------------
 
diff --git a/docs/examples/three_legged_oauth.rst b/docs/examples/three_legged_oauth.rst
index 5a98e408..ca36ef65 100644
--- a/docs/examples/three_legged_oauth.rst
+++ b/docs/examples/three_legged_oauth.rst
@@ -1,3 +1,5 @@
+.. _examples_three_legged_oauth_login:
+
 Three Legged OAuth with Flask
 -----------------------------
 
diff --git a/globus_sdk/auth/client_types/confidential_client.py b/globus_sdk/auth/client_types/confidential_client.py
index 6126ada7..fa920c42 100644
--- a/globus_sdk/auth/client_types/confidential_client.py
+++ b/globus_sdk/auth/client_types/confidential_client.py
@@ -1,4 +1,6 @@
 import logging
+import six
+
 from globus_sdk.base import merge_params
 from globus_sdk.authorizers import BasicAuthorizer
 from globus_sdk.auth.oauth2_constants import DEFAULT_REQUESTED_SCOPES
@@ -72,6 +74,9 @@ class ConfidentialAppAuthClient(AuthClient):
         """
         self.logger.info('Fetching token(s) using client credentials')
         requested_scopes = requested_scopes or DEFAULT_REQUESTED_SCOPES
+        # convert scopes iterable to string immediately on load
+        if not isinstance(requested_scopes, six.string_types):
+            requested_scopes = " ".join(requested_scopes)
 
         return self.oauth2_token({
             'grant_type': 'client_credentials',
@@ -81,12 +86,43 @@ class ConfidentialAppAuthClient(AuthClient):
             self, redirect_uri, requested_scopes=None,
             state='_default', refresh_tokens=False):
         """
-        Starts an Authorization Code OAuth2 flow by instantiating a
+        Starts or resumes an Authorization Code OAuth2 flow.
+
+        Under the hood, this is done by instantiating a
         :class:`GlobusAuthorizationCodeFlowManager
         <globus_sdk.auth.GlobusAuthorizationCodeFlowManager>`
 
-        All of the parameters to this method are passed to that class's
-        initializer verbatim.
+        **Parameters**
+
+            ``redirect_uri`` (*string*)
+              The page that users should be directed to after authenticating at
+              the authorize URL. Required.
+
+            ``requested_scopes`` (*iterable* or *string*)
+              The scopes on the token(s) being requested, as a space-separated
+              string or an iterable of strings. Defaults to ``openid profile
+              email urn:globus:auth:scope:transfer.api.globus.org:all``
+
+            ``state`` (*string*)
+              This is a way of your application passing information back to
+              itself in the course of the OAuth flow. Because the user will
+              navigate away from your application to complete the flow, this
+              parameter lets you pass an arbitrary string from the starting
+              page to the ``redirect_uri``
+
+            ``refresh_tokens`` (*bool*)
+              When True, request refresh tokens in addition to access tokens
+
+        **Examples**
+
+        You can see an example of this flow :ref:`in the usage examples
+        <examples_three_legged_oauth_login>`
+
+        **External Documentation**
+
+        The Authorization Code Grant flow is described
+        `in the Globus Auth Specification \
+        <https://docs.globus.org/api/auth/developer-guide/#obtaining-authorization>`_
         """
         self.logger.info('Starting OAuth2 Authorization Code Grant Flow')
         self.current_oauth2_flow_manager = GlobusAuthorizationCodeFlowManager(
diff --git a/globus_sdk/auth/client_types/native_client.py b/globus_sdk/auth/client_types/native_client.py
index 818269ab..20f55c58 100644
--- a/globus_sdk/auth/client_types/native_client.py
+++ b/globus_sdk/auth/client_types/native_client.py
@@ -40,14 +40,59 @@ class NativeAppAuthClient(AuthClient):
             state='_default', verifier=None, refresh_tokens=False,
             prefill_named_grant=None):
         """
-        Starts a Native App OAuth2 flow by instantiating a
+        Starts a Native App OAuth2 flow.
+
+        This is done internally by instantiating a
         :class:`GlobusNativeAppFlowManager
         <globus_sdk.auth.GlobusNativeAppFlowManager>`
 
-        All of the parameters to this method are passed to that class's
-        initializer verbatim.
+        While the flow is in progress, the ``NativeAppAuthClient`` becomes
+        non thread-safe as temporary state is stored during the flow.
+
+        **Parameters**
+
+            ``requested_scopes`` (*iterable* or *string*)
+              The scopes on the token(s) being requested, as a space-separated
+              string or iterable of strings. Defaults to ``openid profile email
+              urn:globus:auth:scope:transfer.api.globus.org:all``
+
+            ``redirect_uri`` (*string*)
+              The page that users should be directed to after authenticating at
+              the authorize URL. Defaults to
+              'https://auth.globus.org/v2/web/auth-code', which displays the
+              resulting ``auth_code`` for users to copy-paste back into your
+              application (and thereby be passed back to the
+              ``GlobusNativeAppFlowManager``)
+
+            ``state`` (*string*)
+              Typically is not meaningful in the Native App Grant flow, but you
+              may have a specialized use case for it. The ``redirect_uri`` page
+              will have this included in a query parameter, so you can use it
+              to pass information to that page. It defaults to the string
+              '_default'
+
+            ``verifier`` (*string*)
+              A secret used for the Native App flow. It will by default be a
+              freshly generated random string, known only to this
+              ``GlobusNativeAppFlowManager`` instance
+
+            ``refresh_tokens`` (*bool*)
+              When True, request refresh tokens in addition to access tokens
+
+            ``prefill_named_grant`` (*string*)
+              Optionally prefill the named grant label on the consent page
+
+        **Examples**
+
+        You can see an example of this flow :ref:`in the usage examples
+        <examples_native_app_login>`
+
+        **External Documentation**
 
-        #notthreadsafe
+        The Globus Auth specification for Native App grants details the
+        modifications to the Authorization Code grant flow as
+        `The PKCE Security Protocol \
+        <https://docs.globus.org/api/auth/developer-guide/#pkce>`_
         """
         self.logger.info('Starting Native App Grant Flow')
         self.current_oauth2_flow_manager = GlobusNativeAppFlowManager(
diff --git a/globus_sdk/auth/oauth2_authorization_code.py b/globus_sdk/auth/oauth2_authorization_code.py
index df5141a0..8a9b312b 100644
--- a/globus_sdk/auth/oauth2_authorization_code.py
+++ b/globus_sdk/auth/oauth2_authorization_code.py
@@ -1,4 +1,5 @@
 import logging
+import six
 from six.moves.urllib.parse import urlencode
 
 from globus_sdk.base import slash_join
@@ -34,9 +35,9 @@ class GlobusAuthorizationCodeFlowManager(GlobusOAuthFlowManager):
           The page that users should be directed to after authenticating at the
           authorize URL. Required.
 
-        ``requested_scopes`` (*string*)
+        ``requested_scopes`` (*iterable* or *string*)
           The scopes on the token(s) being requested, as a space-separated
-          string. Defaults to ``openid profile email
+          string or an iterable of strings. Defaults to ``openid profile email
           urn:globus:auth:scope:transfer.api.globus.org:all``
 
         ``state`` (*string*)
@@ -55,6 +56,9 @@ class GlobusAuthorizationCodeFlowManager(GlobusOAuthFlowManager):
                  refresh_tokens=False):
         # default to the default requested scopes
         self.requested_scopes = requested_scopes or DEFAULT_REQUESTED_SCOPES
+        # convert scopes iterable to string immediately on load
+        if not isinstance(self.requested_scopes, six.string_types):
+            self.requested_scopes = " ".join(self.requested_scopes)
 
         # store the remaining parameters directly, with no transformation
         self.client_id = auth_client.client_id
diff --git a/globus_sdk/auth/oauth2_constants.py b/globus_sdk/auth/oauth2_constants.py
index e92902eb..fb3fce16 100644
--- a/globus_sdk/auth/oauth2_constants.py
+++ b/globus_sdk/auth/oauth2_constants.py
@@ -2,5 +2,5 @@ __all__ = ['DEFAULT_REQUESTED_SCOPES']
 
 
 DEFAULT_REQUESTED_SCOPES = (
-    'openid profile email '
+    'openid', 'profile', 'email',
     'urn:globus:auth:scope:transfer.api.globus.org:all')
diff --git a/globus_sdk/auth/oauth2_native_app.py b/globus_sdk/auth/oauth2_native_app.py
index af646d6d..f42b38dc 100644
--- a/globus_sdk/auth/oauth2_native_app.py
+++ b/globus_sdk/auth/oauth2_native_app.py
@@ -3,6 +3,7 @@ import hashlib
 import base64
 import re
 import os
+import six
 from six.moves.urllib.parse import urlencode
 
 from globus_sdk.base import slash_join
@@ -72,9 +73,9 @@ class GlobusNativeAppFlowManager(GlobusOAuthFlowManager):
           used to extract default values for the flow, and also to make calls
           to the Auth service. This SHOULD be a ``NativeAppAuthClient``
 
-        ``requested_scopes`` (*string*)
+        ``requested_scopes`` (*iterable* or *string*)
           The scopes on the token(s) being requested, as a space-separated
-          string. Defaults to ``openid profile email
+          string or iterable of strings. Defaults to ``openid profile email
           urn:globus:auth:scope:transfer.api.globus.org:all``
 
         ``redirect_uri`` (*string*)
@@ -99,7 +100,7 @@ class GlobusNativeAppFlowManager(GlobusOAuthFlowManager):
         ``refresh_tokens`` (*bool*)
           When True, request refresh tokens in addition to access tokens
 
-          ``prefill_named_grant`` (*string*)
+        ``prefill_named_grant`` (*string*)
           Optionally prefill the named grant label on the consent page
     """
 
@@ -119,6 +120,9 @@ class GlobusNativeAppFlowManager(GlobusOAuthFlowManager):
 
         # default to the default requested scopes
         self.requested_scopes = requested_scopes or DEFAULT_REQUESTED_SCOPES
+        # convert scopes iterable to string immediately on load
+        if not isinstance(self.requested_scopes, six.string_types):
+            self.requested_scopes = " ".join(self.requested_scopes)
 
         # default to `/v2/web/auth-code` on whatever environment we're looking
         # at -- most typically it will be `https://auth.globus.org/`


Test patch:
diff --git a/tests/integration/test_auth_client_flow.py b/tests/integration/test_auth_client_flow.py
index c6e8ffa3..4ca019d8 100644
--- a/tests/integration/test_auth_client_flow.py
+++ b/tests/integration/test_auth_client_flow.py
@@ -27,7 +27,8 @@ class AuthClientIntegrationTests(CapturedIOTestCase):
                          "client_id=" + ac.client_id,
                          "redirect_uri=" +
                          quote_plus(ac.base_url + "v2/web/auth-code"),
-                         "scope=" + quote_plus(DEFAULT_REQUESTED_SCOPES),
+                         "scope=" + quote_plus(
+                             " ".join(DEFAULT_REQUESTED_SCOPES)),
                          "state=" + "_default",
                          "response_type=" + "code",
                          "code_challenge=" +
@@ -76,7 +77,8 @@ class AuthClientIntegrationTests(CapturedIOTestCase):
         expected_vals = [ac.base_url + "v2/oauth2/authorize?",
                          "client_id=" + ac.client_id,
                          "redirect_uri=" + "uri",
-                         "scope=" + quote_plus(DEFAULT_REQUESTED_SCOPES),
+                         "scope=" + quote_plus(
+                             " ".join(DEFAULT_REQUESTED_SCOPES)),
                          "state=" + "_default",
                          "response_type=" + "code",
                          "access_type=" + "online"]
diff --git a/tests/integration/test_confidential_client_flow.py b/tests/integration/test_confidential_client_flow.py
index e7039e44..d713c741 100644
--- a/tests/integration/test_confidential_client_flow.py
+++ b/tests/integration/test_confidential_client_flow.py
@@ -31,7 +31,8 @@ class ConfidentialAppAuthClientIntegrationTests(CapturedIOTestCase):
         flow = self.cac.oauth2_start_flow("uri")
         self.assertIsInstance(flow, GlobusAuthorizationCodeFlowManager)
         self.assertEqual(flow.redirect_uri, "uri")
-        self.assertEqual(flow.requested_scopes, DEFAULT_REQUESTED_SCOPES)
+        self.assertEqual(flow.requested_scopes,
+                         " ".join(DEFAULT_REQUESTED_SCOPES))
         self.assertEqual(flow.state, "_default")
         self.assertFalse(flow.refresh_tokens)
 
@@ -40,7 +41,8 @@ class ConfidentialAppAuthClientIntegrationTests(CapturedIOTestCase):
         expected_vals = [self.cac.base_url + "v2/oauth2/authorize?",
                          "client_id=" + self.cac.client_id,
                          "redirect_uri=" + "uri",
-                         "scope=" + quote_plus(DEFAULT_REQUESTED_SCOPES),
+                         "scope=" + quote_plus(
+                             " ".join(DEFAULT_REQUESTED_SCOPES)),
                          "state=" + "_default",
                          "access_type=" + "online"]
         for val in expected_vals:
diff --git a/tests/integration/test_native_client_flow.py b/tests/integration/test_native_client_flow.py
index c07a8c40..6d7d42a2 100644
--- a/tests/integration/test_native_client_flow.py
+++ b/tests/integration/test_native_client_flow.py
@@ -31,7 +31,8 @@ class NativeAppAuthClientIntegrationTests(CapturedIOTestCase):
         self.assertIsInstance(flow, GlobusNativeAppFlowManager)
         self.assertEqual(flow.redirect_uri,
                          self.nac.base_url + "v2/web/auth-code")
-        self.assertEqual(flow.requested_scopes, DEFAULT_REQUESTED_SCOPES)
+        self.assertEqual(flow.requested_scopes,
+                         " ".join(DEFAULT_REQUESTED_SCOPES))
         self.assertEqual(flow.state, "_default")
         self.assertFalse(flow.refresh_tokens)
 
@@ -41,7 +42,8 @@ class NativeAppAuthClientIntegrationTests(CapturedIOTestCase):
                          "client_id=" + self.nac.client_id,
                          "redirect_uri=" +
                          quote_plus(self.nac.base_url + "v2/web/auth-code"),
-                         "scope=" + quote_plus(DEFAULT_REQUESTED_SCOPES),
+                         "scope=" + quote_plus(
+                             " ".join(DEFAULT_REQUESTED_SCOPES)),
                          "state=" + "_default",
                          "code_challenge=" + quote_plus(flow.challenge),
                          "access_type=" + "online"]
diff --git a/tests/unit/test_confidential_client.py b/tests/unit/test_confidential_client.py
index 3346ba96..a6b1059d 100644
--- a/tests/unit/test_confidential_client.py
+++ b/tests/unit/test_confidential_client.py
@@ -1,3 +1,8 @@
+try:
+    import mock
+except ImportError:
+    from unittest import mock
+
 import globus_sdk
 from tests.framework import CapturedIOTestCase, get_client_data
 from globus_sdk.exc import GlobusAPIError
@@ -28,7 +33,14 @@ class ConfidentialAppAuthClientTests(CapturedIOTestCase):
         Confirm tokens allow use of userinfo for the client
         Returns access_token for testing
         """
-        token_res = self.cac.oauth2_client_credentials_tokens()
+        with mock.patch.object(self.cac, 'oauth2_token',
+                               side_effect=self.cac.oauth2_token) as m:
+            token_res = self.cac.oauth2_client_credentials_tokens(
+                requested_scopes="openid profile")
+            m.assert_called_once_with(
+                {"grant_type": "client_credentials",
+                 "scope": "openid profile"})
+
         # validate results
         self.assertIn("access_token", token_res)
         self.assertIn("expires_in", token_res)
diff --git a/tests/unit/test_oauth2_authorization_code.py b/tests/unit/test_oauth2_authorization_code.py
index 5c531719..5c634b91 100644
--- a/tests/unit/test_oauth2_authorization_code.py
+++ b/tests/unit/test_oauth2_authorization_code.py
@@ -18,10 +18,16 @@ class GlobusAuthorizationCodeFlowManagerTests(CapturedIOTestCase):
         self.ac = mock.Mock()
         self.ac.client_id = "client_id"
         self.ac.base_url = "base_url/"
-        self.flow_manager = globus_sdk.auth.GlobusNativeAppFlowManager(
+        self.flow_manager = globus_sdk.auth.GlobusAuthorizationCodeFlowManager(
             self.ac, requested_scopes="scopes", redirect_uri="uri",
             state="state")
 
+    def test_init_handles_iterable_scopes(self):
+        flow_manager = globus_sdk.auth.GlobusAuthorizationCodeFlowManager(
+            self.ac, requested_scopes=["scope1", "scope2"], redirect_uri="uri",
+            state="state")
+        self.assertEquals(flow_manager.requested_scopes, "scope1 scope2")
+
     def test_get_authorize_url(self):
         """
         Creates an authorize url, confirms results match object values
@@ -55,9 +61,7 @@ class GlobusAuthorizationCodeFlowManagerTests(CapturedIOTestCase):
         auth_code = "code"
         self.flow_manager.exchange_code_for_tokens(auth_code)
 
-        expected = {"client_id": self.ac.client_id,
-                    "grant_type": "authorization_code",
+        expected = {"grant_type": "authorization_code",
                     "code": auth_code.encode("utf-8"),
-                    "code_verifier": self.flow_manager.verifier,
                     "redirect_uri": self.flow_manager.redirect_uri}
         self.ac.oauth2_token.assert_called_with(expected)
diff --git a/tests/unit/test_oauth2_native_app.py b/tests/unit/test_oauth2_native_app.py
index fb060c90..0782c1ae 100644
--- a/tests/unit/test_oauth2_native_app.py
+++ b/tests/unit/test_oauth2_native_app.py
@@ -26,6 +26,13 @@ class GlobusNativeAppFlowManagerTests(CapturedIOTestCase):
             self.ac, requested_scopes="scopes", redirect_uri="uri",
             state="state")
 
+    def test_init_handles_iterable_scopes(self):
+        flow_manager = globus_sdk.auth.GlobusNativeAppFlowManager(
+            self.ac, requested_scopes=set(("scope1", "scope2")),
+            redirect_uri="uri", state="state")
+        self.assertIn(flow_manager.requested_scopes, ("scope1 scope2",
+                                                      "scope2 scope1"))
+
     def test_make_native_app_challenge(self):
         """
         Makes native app challenge with and without verifier,