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
60 changes: 59 additions & 1 deletion oauthenticator/oauth2.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,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 All @@ -19,7 +20,7 @@
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):
Expand Down Expand Up @@ -354,6 +355,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 @@ -364,13 +383,23 @@ 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`.
""",
)

@default("userdata_url")
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,
Expand Down Expand Up @@ -865,6 +894,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 @@ -873,6 +905,32 @@ 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.
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
57 changes: 53 additions & 4 deletions oauthenticator/tests/test_generic.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,33 @@
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"],
**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(
Expand All @@ -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,
)

Expand All @@ -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",
[
Expand Down Expand Up @@ -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:
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