Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[All] Add userdata_from_id_token as alternative to userdata_url #725

Merged
merged 9 commits into from
Feb 13, 2024
54 changes: 54 additions & 0 deletions oauthenticator/oauth2.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
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
Expand Down Expand Up @@ -359,6 +360,24 @@ def _authorize_url_default(self):
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="""
Expand All @@ -369,6 +388,8 @@ def _token_url_default(self):
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`.
""",
)

Expand Down Expand Up @@ -863,6 +884,9 @@ async def token_to_user(self, token_info):
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)

Expand All @@ -871,6 +895,36 @@ async def token_to_user(self, token_info):

Called by the :meth:`oauthenticator.OAuthenticator.authenticate`
"""
if self.userdata_from_id_token:
# Use id token instead of exchanging access token with userinfo endpoint.
if self.userdata_url:
raise ValueError(
"Cannot specify both authenticator.userdata_url and authenticator.userdata_from_id_token."
)
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"]

Expand Down
3 changes: 3 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
4 changes: 0 additions & 4 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,6 @@ def run(self):


setup_args['extras_require'] = {
# azuread is required for use of AzureADOAuthenticator
'azuread': ['pyjwt>=2'],
minrk marked this conversation as resolved.
Show resolved Hide resolved
# googlegroups is required for use of GoogleOAuthenticator configured with
# either admin_google_groups and/or allowed_google_groups.
'googlegroups': [
Expand All @@ -106,8 +104,6 @@ def run(self):
'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',
Expand Down