Repository: globus/globus-sdk-python

Issue:
Make it possible to cache JWK (external to SDK) and use it for decode_id_token
Low priority.

I realized while answering a listhost thread that even though `OAuthTokenResponse.decode_id_token` could be optimized (in theory) to use a cached copy of the JWK and therefore no network callout, we don't leave that avenue open.

Caching should not be done in the SDK for a couple of reasons:
- Unnecessary complexity and bloat
- Multithreaded and multiprocess SDK usage don't share client objects

We could simply expose some methods for (1) getting the JWK and (2) using that JWK with `decode_id_token`.
Following the model we used for TransferData submission_ids, it could be:
- `AuthClient.get_jwk()`
- `AuthClient.decode_id_token(jwk=string)`
with the default behavior for `decode_id_token()` obviously being what it is today.

Discussion:

Patch:
diff --git a/globus_sdk/auth/client_types/base.py b/globus_sdk/auth/client_types/base.py
index d822b969..9152d093 100644
--- a/globus_sdk/auth/client_types/base.py
+++ b/globus_sdk/auth/client_types/base.py
@@ -1,6 +1,9 @@
 import collections
+import json
 import logging
 
+import jwt
+
 from globus_sdk import exc
 from globus_sdk.auth.token_response import OAuthTokenResponse
 from globus_sdk.authorizers import NullAuthorizer
@@ -384,3 +387,43 @@ class AuthClient(BaseClient):
         """
         self.logger.info("Looking up OIDC-style Userinfo from Globus Auth")
         return self.get("/v2/oauth2/userinfo")
+
+    def get_openid_configuration(self):
+        """
+        Fetch the OpenID Connect configuration data from the well-known URI for Globus
+        Auth.
+        """
+        self.logger.info("Fetching OIDC Config")
+        return self.get("/.well-known/openid-configuration")
+
+    def get_jwk(self, openid_configuration=None, as_pem=False):
+        """
+        Fetch the Globus Auth JWK.
+
+        Returns either a dict or an RSA Public Key object depending on ``as_pem``.
+
+        :param openid_configuration: The OIDC config as a GlobusHTTPResponse or dict.
+            When not provided, it will be fetched automatically.
+        :type openid_configuration: dict or GlobusHTTPResponse
+        :param as_pem: Decode the JWK to an RSA PEM key, typically for JWT decoding
+        :type as_pem: bool
+        """
+        self.logger.info("Fetching JWK")
+        if not openid_configuration:
+            self.logger.debug("No OIDC Config provided, autofetching...")
+            openid_configuration = self.get_openid_configuration()
+
+        jwks_uri = openid_configuration["jwks_uri"]
+        self.logger.debug("jwks_uri=%s", jwks_uri)
+        jwk_data = self.get(jwks_uri).data
+        if not as_pem:
+            self.logger.debug("returning jwk data where as_pem=False")
+            return jwk_data
+        else:
+            self.logger.debug("JWK as_pem=True requested, decoding...")
+            # decode from JWK to an RSA PEM key for JWT decoding
+            jwk_as_pem = jwt.algorithms.RSAAlgorithm.from_jwk(
+                json.dumps(jwk_data["keys"][0])
+            )
+            self.logger.debug("JWK PEM decoding finished successfully")
+            return jwk_as_pem
diff --git a/globus_sdk/auth/token_response.py b/globus_sdk/auth/token_response.py
index 7ccef524..a41e80c3 100644
--- a/globus_sdk/auth/token_response.py
+++ b/globus_sdk/auth/token_response.py
@@ -3,8 +3,8 @@ import logging
 import time
 
 import jwt
-import requests
 
+from globus_sdk import exc
 from globus_sdk.response import GlobusHTTPResponse
 
 logger = logging.getLogger(__name__)
@@ -162,32 +162,46 @@ class OAuthTokenResponse(GlobusHTTPResponse):
         """
         return self._by_scopes
 
-    def decode_id_token(self):
+    def decode_id_token(self, openid_configuration=None, jwk=None):
         """
-        A parsed ID Token (OIDC) as a dict.
+        Parse the included ID Token (OIDC) as a dict and return it.
+
+        If you provide the `jwk`, you must also provide `openid_configuration`.
+
+        :param openid_configuration: The OIDC config as a GlobusHTTPResponse or dict.
+            When not provided, it will be fetched automatically.
+        :type openid_configuration: dict or GlobusHTTPResponse
+        :param jwk: The JWK as a cryptography public key object. When not provided, it
+            will be fetched and parsed automatically.
+        :type jwk: RSAPublicKey
         """
-        logger.info('Decoding ID Token "{}"'.format(self["id_token"]))
+        logger.info('Decoding ID Token "%s"', self["id_token"])
         auth_client = self._client
 
-        logger.debug("Fetch JWK Data: Start")
-        oidc_conf = auth_client.get("/.well-known/openid-configuration")
-        jwks_uri = oidc_conf["jwks_uri"]
-        signing_algos = oidc_conf["id_token_signing_alg_values_supported"]
-
-        # use the auth_client's decision on ssl_verify=yes/no
-        jwk_data = requests.get(jwks_uri, verify=auth_client._verify).json()
-        logger.debug("Fetch JWK Data: Complete")
-        # decode from JWK to an RSA PEM key for JWT decoding
-        jwk_as_pem = jwt.algorithms.RSAAlgorithm.from_jwk(
-            json.dumps(jwk_data["keys"][0])
-        )
+        if not openid_configuration:
+            if jwk:
+                raise exc.GlobusSDKUsageError(
+                    "passing jwk without openid configuration is not allowed"
+                )
+            logger.debug("No OIDC Config provided, autofetching...")
+            openid_configuration = auth_client.get_openid_configuration()
+
+        if not jwk:
+            logger.debug("No JWK provided, autofetching + decoding...")
+            jwk = auth_client.get_jwk(
+                openid_configuration=openid_configuration, as_pem=True
+            )
 
-        return jwt.decode(
+        logger.debug("final step: decode with JWK")
+        signing_algos = openid_configuration["id_token_signing_alg_values_supported"]
+        decoded = jwt.decode(
             self["id_token"],
-            jwk_as_pem,
+            jwk,
             algorithms=signing_algos,
             audience=auth_client.client_id,
         )
+        logger.debug("decode ID token finished successfully")
+        return decoded
 
     def __str__(self):
         # Make printing responses more convenient by only showing the
diff --git a/globus_sdk/base.py b/globus_sdk/base.py
index 1fa3c32f..515ec2f3 100644
--- a/globus_sdk/base.py
+++ b/globus_sdk/base.py
@@ -430,7 +430,12 @@ class BaseClient:
             )
             self.authorizer.set_authorization_header(rheaders)
 
-        url = slash_join(self.base_url, path)
+        # if a client is asked to make a request against a full URL, not just the path
+        # component, then do not resolve the path, simply pass it through as the URL
+        if path.startswith("https://") or path.startswith("http://"):
+            url = path
+        else:
+            url = slash_join(self.base_url, path)
         self.logger.debug(f"request will hit URL:{url}")
 
         # because a 401 can trigger retry, we need to wrap the retry-able thing


Test patch:
diff --git a/tests/functional/auth/test_id_token.py b/tests/functional/auth/test_id_token.py
new file mode 100644
index 00000000..9900f360
--- /dev/null
+++ b/tests/functional/auth/test_id_token.py
@@ -0,0 +1,128 @@
+import json
+
+import jwt
+import pytest
+
+import globus_sdk
+from tests.common import register_api_route
+
+OIDC_CONFIG = {
+    "issuer": "https://auth.globus.org",
+    "authorization_endpoint": "https://auth.globus.org/v2/oauth2/authorize",
+    "userinfo_endpoint": "https://auth.globus.org/v2/oauth2/userinfo",
+    "token_endpoint": "https://auth.globus.org/v2/oauth2/token",
+    "revocation_endpoint": "https://auth.globus.org/v2/oauth2/token/revoke",
+    "jwks_uri": "https://auth.globus.org/jwk.json",
+    "response_types_supported": ["code", "token", "token id_token", "id_token"],
+    "id_token_signing_alg_values_supported": ["RS512"],
+    "scopes_supported": ["openid", "email", "profile"],
+    "token_endpoint_auth_methods_supported": ["client_secret_basic"],
+    "claims_supported": [
+        "at_hash",
+        "aud",
+        "email",
+        "exp",
+        "name",
+        "nonce",
+        "preferred_username",
+        "iat",
+        "iss",
+        "sub",
+    ],
+    "subject_types_supported": ["public"],
+}
+
+JWK = {
+    "keys": [
+        {
+            "alg": "RS512",
+            "e": "AQAB",
+            "kty": "RSA",
+            "n": "73l27Yp7WT2c0Ve7EoGJ13AuKzg-GHU7Mpgx0JKa_hO04gAXSVXRadQy7gmdLLtAK8uBVcV0fHGgsBl4J92t-I7hayiJSLbgbX-sZhI_OfegeOLcSNB9poPS9w60XGqR9buYOW2x-KXXitsmyHXNmg_-1u0uqfKHu9pmST8dcjUYXTM5F3oJpQKeJlSH8daMlDks4xb9Y83EEFRv-ppY965-WTm2NW4pwLlbgGTWFvZ6YS6GTb-mfGwGuzStI0lKZ7dOFx9ryYQ4wSoUVHtIrypT-gbuaT90Z2SkwOH-GaEZJkudctBeGpieOsyC7P40UXpwgGNFy3xoWL4vHpnHmQ",  # noqa: E501
+            "use": "sig",
+        }
+    ]
+}
+JWK_PEM = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(JWK["keys"][0]))
+
+TOKEN_PAYLOAD = {
+    "access_token": "auth_access_token",
+    "scope": "profile email openid",
+    "expires_in": 172800,
+    "token_type": "Bearer",
+    "resource_server": "auth.globus.org",
+    "state": "_default",
+    "refresh_token": "auth_refresh_token",
+    "other_tokens": [
+        {
+            "access_token": "transfer_access_token",
+            "scope": "urn:globus:auth:scope:transfer.api.globus.org:all",
+            "expires_in": 172800,
+            "token_type": "Bearer",
+            "resource_server": "transfer.api.globus.org",
+            "state": "_default",
+            "refresh_token": "transfer_refresh",
+        }
+    ],
+    # this is a real ID token
+    # and since the JWK is real as well, it should decode correctly
+    "id_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzUxMiJ9.eyJzdWIiOiJjOGFhZDQzZS1kMjc0LTExZTUtYmY5OC04YjAyODk2Y2Y3ODIiLCJvcmdhbml6YXRpb24iOiJHbG9idXMiLCJuYW1lIjoiU3RlcGhlbiBSb3NlbiIsInByZWZlcnJlZF91c2VybmFtZSI6InNpcm9zZW4yQGdsb2J1c2lkLm9yZyIsImlkZW50aXR5X3Byb3ZpZGVyIjoiNDExNDM3NDMtZjNjOC00ZDYwLWJiZGItZWVlY2FiYTg1YmQ5IiwiaWRlbnRpdHlfcHJvdmlkZXJfZGlzcGxheV9uYW1lIjoiR2xvYnVzIElEIiwiZW1haWwiOiJzaXJvc2VuQHVjaGljYWdvLmVkdSIsImxhc3RfYXV0aGVudGljYXRpb24iOjE2MjE0ODEwMDYsImlkZW50aXR5X3NldCI6W3sic3ViIjoiYzhhYWQ0M2UtZDI3NC0xMWU1LWJmOTgtOGIwMjg5NmNmNzgyIiwib3JnYW5pemF0aW9uIjoiR2xvYnVzIiwibmFtZSI6IlN0ZXBoZW4gUm9zZW4iLCJ1c2VybmFtZSI6InNpcm9zZW4yQGdsb2J1c2lkLm9yZyIsImlkZW50aXR5X3Byb3ZpZGVyIjoiNDExNDM3NDMtZjNjOC00ZDYwLWJiZGItZWVlY2FiYTg1YmQ5IiwiaWRlbnRpdHlfcHJvdmlkZXJfZGlzcGxheV9uYW1lIjoiR2xvYnVzIElEIiwiZW1haWwiOiJzaXJvc2VuQHVjaGljYWdvLmVkdSIsImxhc3RfYXV0aGVudGljYXRpb24iOjE2MjE0ODEwMDZ9LHsic3ViIjoiYjZlMjI3ZTgtZGI1Mi0xMWU1LWI2ZmYtYzNiMWNjMjU5ZTBkIiwibmFtZSI6bnVsbCwidXNlcm5hbWUiOiJzaXJvc2VuK2JhZGVtYWlsQGdsb2J1cy5vcmciLCJpZGVudGl0eV9wcm92aWRlciI6IjkyN2Q3MjM4LWY5MTctNGViMi05YWNlLWM1MjNmYTliYTM0ZSIsImlkZW50aXR5X3Byb3ZpZGVyX2Rpc3BsYXlfbmFtZSI6Ikdsb2J1cyBTdGFmZiIsImVtYWlsIjoic2lyb3NlbitiYWRlbWFpbEBnbG9idXMub3JnIiwibGFzdF9hdXRoZW50aWNhdGlvbiI6bnVsbH0seyJzdWIiOiJmN2Y4OWQwYS1kYzllLTExZTUtYWRkMC1hM2NiZDFhNTU5YjMiLCJuYW1lIjpudWxsLCJ1c2VybmFtZSI6InNpcm9zZW4rYmFkZW1haWwyQGdsb2J1cy5vcmciLCJpZGVudGl0eV9wcm92aWRlciI6IjkyN2Q3MjM4LWY5MTctNGViMi05YWNlLWM1MjNmYTliYTM0ZSIsImlkZW50aXR5X3Byb3ZpZGVyX2Rpc3BsYXlfbmFtZSI6Ikdsb2J1cyBTdGFmZiIsImVtYWlsIjoic2lyb3NlbitiYWRlbWFpbDJAZ2xvYnVzLm9yZyIsImxhc3RfYXV0aGVudGljYXRpb24iOm51bGx9XSwiaXNzIjoiaHR0cHM6Ly9hdXRoLmdsb2J1cy5vcmciLCJhdWQiOiI3ZmI1OGUwMC04MzlkLTQ0ZTMtODA0Ny0xMGE1MDI2MTJkY2EiLCJleHAiOjE2MjE2NTM4MTEsImlhdCI6MTYyMTQ4MTAxMSwiYXRfaGFzaCI6IjFQdlVhbmNFdUxfc2cxV1BsNWx1TUVGR2tjTDZQaDh1cWdpVUZzejhkZUEifQ.CtfnFtfM32ICo0euHv9GnpVHFL1jWz0NriPTXAv6w08Ylk9JBJtmB3oMKNSO-1TGoWUPFDp9TFFk6N32VyF0hsVDtT5DT3t5oq0qfqbPrZA3R04HARW0xtcK_ejNDHBmj6wysey3EzjT764XTvcGOe63CKQ_RJm97ulVaseIT0Aet7AYo5tQuOiSOQ70xzL7Oax3W6TrWi3FIAA-PIMSrAJKbsG7imGOVkaIObG9a-X5yTOcrB4IG4Wat-pN_QiCiiOw_LDCF-r455PwalmnSGUugMYfsdL2k3UxqwOMLIppHnx5-UVAzj3mygj8eZTp6imjqxNMdakS3vhG8dtxbw",  # noqa: E501
+}
+
+
+@pytest.fixture
+def client():
+    # this client ID is the audience for the above id_token
+    # the client_id must match the audience in order for the decode to work (we pass it
+    # as the audience during decoding)
+    return globus_sdk.AuthClient(client_id="7fb58e00-839d-44e3-8047-10a502612dca")
+
+
+@pytest.fixture(autouse=True)
+def register_token_response():
+    register_api_route(
+        "auth", "/v2/oauth2/token", method="POST", body=json.dumps(TOKEN_PAYLOAD)
+    )
+
+
+@pytest.fixture
+def token_response(register_token_response, client):
+    return client.oauth2_token(
+        {
+            "grant_type": "authorization_code",
+            "code": "foo",
+            "redirect_uri": "https://bar.example.org/",
+        }
+    )
+
+
+def test_decode_id_token(token_response):
+    register_api_route(
+        "auth",
+        "/.well-known/openid-configuration",
+        method="GET",
+        body=json.dumps(OIDC_CONFIG),
+    )
+    register_api_route("auth", "/jwk.json", method="GET", body=json.dumps(JWK))
+
+    decoded = token_response.decode_id_token()
+    assert decoded["preferred_username"] == "sirosen2@globusid.org"
+
+
+def test_decode_id_token_with_saved_oidc_config(token_response):
+    register_api_route("auth", "/jwk.json", method="GET", body=json.dumps(JWK))
+
+    decoded = token_response.decode_id_token(openid_configuration=OIDC_CONFIG)
+    assert decoded["preferred_username"] == "sirosen2@globusid.org"
+
+
+def test_decode_id_token_with_saved_oidc_config_and_jwk(token_response):
+    decoded = token_response.decode_id_token(
+        openid_configuration=OIDC_CONFIG, jwk=JWK_PEM
+    )
+    assert decoded["preferred_username"] == "sirosen2@globusid.org"
+
+
+def test_invalid_decode_id_token_usage(token_response):
+    with pytest.raises(globus_sdk.exc.GlobusSDKUsageError):
+        token_response.decode_id_token(jwk=JWK_PEM)