Skip to content

Commit

Permalink
Merge pull request #725 from benjimin/idtoken
Browse files Browse the repository at this point in the history
Add userdata_from_id_token as alternative to userdata_url
  • Loading branch information
minrk authored Feb 13, 2024
2 parents 8dfe4f4 + 18f2d19 commit d7e0756
Show file tree
Hide file tree
Showing 4 changed files with 115 additions and 9 deletions.
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'],
# 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

0 comments on commit d7e0756

Please sign in to comment.