Repository: jupyterhub/oauthenticator
Pull request #725 (2024-02-02 04:30:25)

Add userdata_from_id_token as alternative to userdata_url
This PR adds support for extracting claims from the _id token_, instead of by exchanging the _access token_ with the _userinfo endpoint_. Addresses #487.

This is needed because some claims may only be available via the id token and not via the userinfo endpoint, for example, _AWS Cognito_ only provides the group membership claim via the id token (i.e., needed to complete support for #706).

For background, OAuth2 was a standard for obtaining an access token ("authorisation not authentication") and OIDC extended this in two ways: by standardising a userinfo endpoint and by accompanying the access token with an id token. (Note this is only the core of OIDC; _complete_ OIDC support also entails discovery mechanisms, etc.) To date, oauthenticator has always discarded the id token that it receives, and relied on the userinfo endpoint for performing authentication.

This PR requires the feature to be explicitly enabled by the admin, without introducing any additional config attribs. It uses a library which is already used [elsewhere](https://github.com/search?q=org%3Ajupyterhub+pyjwt&type=code) by JupyterHub.


Patch:
diff --git a/oauthenticator/oauth2.py b/oauthenticator/oauth2.py
index 8296f34..1c882b3 100644
--- a/oauthenticator/oauth2.py
+++ b/oauthenticator/oauth2.py
@@ -10,6 +10,7 @@ import os
 import uuid
 from urllib.parse import quote, urlencode, urlparse, urlunparse
 
+import jwt
 from jupyterhub.auth import Authenticator
 from jupyterhub.crypto import EncryptionUnavailable, InvalidToken, decrypt
 from jupyterhub.handlers import BaseHandler, LogoutHandler
@@ -19,7 +20,7 @@ from tornado.auth import OAuth2Mixin
 from tornado.httpclient import AsyncHTTPClient, HTTPClientError, HTTPRequest
 from tornado.httputil import url_concat
 from tornado.log import app_log
-from traitlets import Any, Bool, Callable, Dict, List, Unicode, Union, default
+from traitlets import Any, Bool, Callable, Dict, List, Unicode, Union, default, validate
 
 
 def guess_callback_uri(protocol, host, hub_server_url):
@@ -354,6 +355,24 @@ class OAuthenticator(Authenticator):
     def _token_url_default(self):
         return os.environ.get("OAUTH2_TOKEN_URL", "")
 
+    userdata_from_id_token = Bool(
+        False,
+        config=True,
+        help="""
+        Extract user details from an id token received via a request to
+        :attr:`token_url`, rather than making a follow-up request to the
+        userinfo endpoint :attr:`userdata_url`.
+
+        Should only be used if :attr:`token_url` uses HTTPS, to ensure
+        token authenticity.
+
+        For more context, see `Authentication using the Authorization
+        Code Flow
+        <https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth>`_
+        in the OIDC Core standard document.
+        """,
+    )
+
     userdata_url = Unicode(
         config=True,
         help="""
@@ -364,6 +383,8 @@ class OAuthenticator(Authenticator):
         For more context, see the `Protocol Flow section
         <https://www.rfc-editor.org/rfc/rfc6749#section-1.2>`_ in the OAuth2
         standard document, specifically steps E-F.
+
+        Incompatible with :attr:`userdata_from_id_token`.
         """,
     )
 
@@ -371,6 +392,14 @@ class OAuthenticator(Authenticator):
     def _userdata_url_default(self):
         return os.environ.get("OAUTH2_USERDATA_URL", "")
 
+    @validate("userdata_url")
+    def _validate_userdata_url(self, proposal):
+        if proposal.value and self.userdata_from_id_token:
+            raise ValueError(
+                "Cannot specify both authenticator.userdata_url and authenticator.userdata_from_id_token."
+            )
+        return proposal.value
+
     username_claim = Union(
         [Unicode(os.environ.get('OAUTH2_USERNAME_KEY', 'username')), Callable()],
         config=True,
@@ -865,6 +894,9 @@ class OAuthenticator(Authenticator):
         Determines who the logged-in user by sending a "GET" request to
         :data:`oauthenticator.OAuthenticator.userdata_url` using the `access_token`.
 
+        If :data:`oauthenticator.OAuthenticator.userdata_from_id_token` is set then
+        extracts the corresponding info from an `id_token` instead.
+
         Args:
             token_info: the dictionary returned by the token request (exchanging the OAuth code for an Access Token)
 
@@ -873,6 +905,32 @@ class OAuthenticator(Authenticator):
 
         Called by the :meth:`oauthenticator.OAuthenticator.authenticate`
         """
+        if self.userdata_from_id_token:
+            # Use id token instead of exchanging access token with userinfo endpoint.
+            id_token = token_info.get("id_token", None)
+            if not id_token:
+                raise web.HTTPError(
+                    500,
+                    f"An id token was not returned: {token_info}\nPlease configure authenticator.userdata_url",
+                )
+            try:
+                # Here we parse the id token. Note that per OIDC spec (core v1.0 sect. 3.1.3.7.6) we can skip
+                # signature validation as the hub has obtained the tokens from the id provider directly (using
+                # https). Google suggests all token validation may be skipped assuming the provider is trusted.
+                # https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation
+                # https://developers.google.com/identity/openid-connect/openid-connect#obtainuserinfo
+                return jwt.decode(
+                    id_token,
+                    audience=self.client_id,
+                    options=dict(
+                        verify_signature=False, verify_aud=True, verify_exp=True
+                    ),
+                )
+            except Exception as err:
+                raise web.HTTPError(
+                    500, f"Unable to decode id token: {id_token}\n{err}"
+                )
+
         access_token = token_info["access_token"]
         token_type = token_info["token_type"]
 
diff --git a/requirements.txt b/requirements.txt
index 5b3c81d..53cc9bc 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,6 +1,9 @@
 # jsonschema is used for validating authenticator configurations
 jsonschema
 jupyterhub>=2.2
+# PyJWT is used for parsing id tokens
+# and azuread
+pyjwt>=2
 # requests is already required by JupyterHub, but explicitly ask for it since we use it
 requests
 # ruamel.yaml is used to read and write .yaml files.
diff --git a/setup.py b/setup.py
index 61fe2fa..8d84f41 100644
--- a/setup.py
+++ b/setup.py
@@ -88,8 +88,6 @@ with open('requirements.txt') as f:
 
 
 setup_args['extras_require'] = {
-    # azuread is required for use of AzureADOAuthenticator
-    'azuread': ['pyjwt>=2'],
     # googlegroups is required for use of GoogleOAuthenticator configured with
     # either admin_google_groups and/or allowed_google_groups.
     'googlegroups': [
@@ -106,8 +104,6 @@ setup_args['extras_require'] = {
         'pytest-asyncio>=0.17,<0.23',
         'pytest-cov',
         'requests-mock',
-        # dependencies from azuread:
-        'pyjwt>=2',
         # dependencies from googlegroups:
         'google-api-python-client',
         'google-auth-oauthlib',


Test patch:
diff --git a/oauthenticator/tests/test_generic.py b/oauthenticator/tests/test_generic.py
index 6ab5474..db781bc 100644
--- a/oauthenticator/tests/test_generic.py
+++ b/oauthenticator/tests/test_generic.py
@@ -1,17 +1,21 @@
 import json
 from functools import partial
 
+import jwt
 from pytest import fixture, mark
 from traitlets.config import Config
 
 from ..generic import GenericOAuthenticator
 from .mocks import setup_oauth_mock
 
+client_id = "jupyterhub-oauth-client"
+
 
 def user_model(username, **kwargs):
     """Return a user model"""
     return {
         "username": username,
+        "aud": client_id,
         "sub": "oauth2|cilogon|http://cilogon.org/servera/users/43431",
         "scope": "basic",
         "groups": ["group1"],
@@ -19,6 +23,11 @@ def user_model(username, **kwargs):
     }
 
 
+@fixture(params=["id_token", "userdata_url"])
+def userdata_from_id_token(request):
+    return request.param == "id_token"
+
+
 @fixture
 def generic_client(client):
     setup_oauth_mock(
@@ -30,10 +39,32 @@ def generic_client(client):
     return client
 
 
+@fixture
+def generic_client_variant(client, userdata_from_id_token):
+    setup_oauth_mock(
+        client,
+        host='generic.horse',
+        access_token_path='/oauth/access_token',
+        user_path='/oauth/userinfo',
+        token_request_style='jwt' if userdata_from_id_token else 'post',
+    )
+    return client
+
+
 def _get_authenticator(**kwargs):
     return GenericOAuthenticator(
         token_url='https://generic.horse/oauth/access_token',
         userdata_url='https://generic.horse/oauth/userinfo',
+        client_id=client_id,
+        **kwargs,
+    )
+
+
+def _get_authenticator_for_id_token(**kwargs):
+    return GenericOAuthenticator(
+        token_url='https://generic.horse/oauth/access_token',
+        userdata_from_id_token=True,
+        client_id=client_id,
         **kwargs,
     )
 
@@ -46,6 +77,21 @@ def get_authenticator(generic_client):
     return partial(_get_authenticator, http_client=generic_client)
 
 
+@fixture
+def get_authenticator_variant(generic_client, userdata_from_id_token):
+    """
+    http_client can't be configured, only passed as argument to the constructor.
+    """
+    return partial(
+        (
+            _get_authenticator_for_id_token
+            if userdata_from_id_token
+            else _get_authenticator
+        ),
+        http_client=generic_client,
+    )
+
+
 @mark.parametrize(
     "test_variation_id,class_config,expect_allowed,expect_admin",
     [
@@ -164,24 +210,27 @@ def get_authenticator(generic_client):
     ],
 )
 async def test_generic(
-    get_authenticator,
-    generic_client,
+    get_authenticator_variant,
+    generic_client_variant,
     test_variation_id,
     class_config,
     expect_allowed,
     expect_admin,
+    userdata_from_id_token,
 ):
     print(f"Running test variation id {test_variation_id}")
     c = Config()
     c.GenericOAuthenticator = Config(class_config)
     c.GenericOAuthenticator.username_claim = "username"
-    authenticator = get_authenticator(config=c)
+    authenticator = get_authenticator_variant(config=c)
     manage_groups = False
     if "manage_groups" in class_config:
         manage_groups = authenticator.manage_groups
 
     handled_user_model = user_model("user1")
-    handler = generic_client.handler_for_user(handled_user_model)
+    if userdata_from_id_token:
+        handled_user_model = dict(id_token=jwt.encode(handled_user_model, key="foo"))
+    handler = generic_client_variant.handler_for_user(handled_user_model)
     auth_model = await authenticator.get_authenticated_user(handler, None)
 
     if expect_allowed: