From dd129bb4f1e41ff60fd1dc2248fb251c197a2190 Mon Sep 17 00:00:00 2001 From: Thomas Li Fredriksen Date: Tue, 7 Feb 2023 14:03:29 +0100 Subject: [PATCH 001/103] Added group-management to azuread --- oauthenticator/azuread.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/oauthenticator/azuread.py b/oauthenticator/azuread.py index d40d98be..4ebaab64 100644 --- a/oauthenticator/azuread.py +++ b/oauthenticator/azuread.py @@ -20,10 +20,11 @@ class AzureAdOAuthenticator(OAuthenticator): tenant_id = Unicode(config=True, help="The Azure Active Directory Tenant ID") user_auth_state_key = "user" + username_claim = "name" - @default("username_claim") - def _username_claim_default(self): - return "name" + user_groups_claim = Unicode( + "", config=True, help="Name of claim containing user group memberships" + ) @default('tenant_id') def _tenant_id_default(self): @@ -37,6 +38,12 @@ def _authorize_url_default(self): def _token_url_default(self): return f"https://login.microsoftonline.com/{self.tenant_id}/oauth2/token" + def build_auth_state_dict(self, token_info, user_info): + auth_state = super().build_auth_state_dict(token_info, user_info) + auth_state["groups"] = token_info.get(self.user_groups_claim) + + return auth_state + async def token_to_user(self, token_info): id_token = token_info['id_token'] decoded = jwt.decode( From 7b068a4df2b1641a7e80074cd13f0ad10669ac85 Mon Sep 17 00:00:00 2001 From: Thomas Li Fredriksen Date: Tue, 7 Feb 2023 14:33:24 +0100 Subject: [PATCH 002/103] Updated azuread. Updated azuread-tests --- oauthenticator/azuread.py | 15 ++++++++---- oauthenticator/tests/test_azuread.py | 35 +++++++++++++++++++++------- 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/oauthenticator/azuread.py b/oauthenticator/azuread.py index 4ebaab64..98c0d949 100644 --- a/oauthenticator/azuread.py +++ b/oauthenticator/azuread.py @@ -20,12 +20,15 @@ class AzureAdOAuthenticator(OAuthenticator): tenant_id = Unicode(config=True, help="The Azure Active Directory Tenant ID") user_auth_state_key = "user" - username_claim = "name" user_groups_claim = Unicode( "", config=True, help="Name of claim containing user group memberships" ) + @default("username_claim") + def _username_claim_default(self): + return "name" + @default('tenant_id') def _tenant_id_default(self): return os.environ.get('AAD_TENANT_ID', '') @@ -38,11 +41,13 @@ def _authorize_url_default(self): def _token_url_default(self): return f"https://login.microsoftonline.com/{self.tenant_id}/oauth2/token" - def build_auth_state_dict(self, token_info, user_info): - auth_state = super().build_auth_state_dict(token_info, user_info) - auth_state["groups"] = token_info.get(self.user_groups_claim) + async def update_auth_model(self, auth_model, **kwargs): + auth_model = await super().update_auth_model(auth_model, **kwargs) + + user_info = auth_model["auth_state"][self.user_auth_state_key] + auth_model["groups"] = user_info.get(self.user_groups_claim) - return auth_state + return auth_model async def token_to_user(self, token_info): id_token = token_info['id_token'] diff --git a/oauthenticator/tests/test_azuread.py b/oauthenticator/tests/test_azuread.py index d8c9ca77..ada5bd75 100644 --- a/oauthenticator/tests/test_azuread.py +++ b/oauthenticator/tests/test_azuread.py @@ -39,6 +39,16 @@ def user_model(tenant_id, client_id, name): "tid": tenant_id, "nonce": "123523", "aio": "Df2UVXL1ix!lMCWMSOJBcFatzcGfvFGhjKv8q5g0x732dR5MB5BisvGQO7YWByjd8iQDLq!eGbIDakyp5mnOrcdqHeYSnltepQmRp6AIZ8jY", + "groups": [ + "96000b2c-7333-4f6e-a2c3-e7608fa2d131", + "a992b3d5-1966-4af4-abed-6ef021417be4", + "ceb90a42-030f-44f1-a0c7-825b572a3b07", + ], + "grp": [ + "96000b2c-7333-4f6e-a2c3-e7608fa2d131", + "a992b3d5-1966-4af4-abed-6ef021417be4", + "ceb90a42-030f-44f1-a0c7-825b572a3b07", + ], }, os.urandom(5), ) @@ -61,26 +71,32 @@ def azure_client(client): @pytest.mark.parametrize( - 'username_claim', + 'username_claim, user_groups_claim, manage_groups', [ - None, - 'name', - 'oid', - 'preferred_username', + (None, None, False), + ('name', None, False), + ('oid', None, False), + ('preferred_username', None, False), + (None, None, True), + (None, "groups", True), + (None, "grp", True), ], ) -async def test_azuread(username_claim, azure_client): +async def test_azuread(username_claim, user_groups_claim, manage_groups, azure_client): cfg = Config() cfg.AzureAdOAuthenticator = Config( { "tenant_id": str(uuid.uuid1()), "client_id": str(uuid.uuid1()), "client_secret": str(uuid.uuid1()), + "manage_groups": manage_groups, } ) if username_claim: cfg.AzureAdOAuthenticator.username_claim = username_claim + if user_groups_claim: + cfg.AzureAdOAuthenticator.user_groups_claim = user_groups_claim authenticator = AzureAdOAuthenticator(config=cfg) @@ -93,8 +109,7 @@ async def test_azuread(username_claim, azure_client): ) user_info = await authenticator.authenticate(handler) - assert sorted(user_info) == ['auth_state', 'name'] - + assert sorted(user_info) == ['auth_state', 'groups', 'name'] auth_state = user_info['auth_state'] assert 'access_token' in auth_state assert 'user' in auth_state @@ -108,3 +123,7 @@ async def test_azuread(username_claim, azure_client): else: # The default AzureADOAuthenticator `username_claim` is "name" assert username == auth_state_user_info["name"] + + if user_groups_claim: + groups = user_info['groups'] + assert groups == auth_state_user_info[user_groups_claim] From 04ce8ce15be3a022a9097de204155b6e6b863661 Mon Sep 17 00:00:00 2001 From: Thomas Li Fredriksen Date: Sat, 10 Jun 2023 12:38:03 +0200 Subject: [PATCH 003/103] Removed `user_groups_claim` dynamic default Co-authored-by: Georgiana --- oauthenticator/azuread.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/oauthenticator/azuread.py b/oauthenticator/azuread.py index 98c0d949..2eaa89bb 100644 --- a/oauthenticator/azuread.py +++ b/oauthenticator/azuread.py @@ -25,13 +25,12 @@ class AzureAdOAuthenticator(OAuthenticator): "", config=True, help="Name of claim containing user group memberships" ) - @default("username_claim") - def _username_claim_default(self): - return "name" - @default('tenant_id') def _tenant_id_default(self): return os.environ.get('AAD_TENANT_ID', '') + @default('username_claim') + def _username_claim_default(self): + return 'name' @default("authorize_url") def _authorize_url_default(self): From fd7f878e98efc6882860728609fd0880ac8d7c3d Mon Sep 17 00:00:00 2001 From: Thomas Li Fredriksen Date: Sat, 10 Jun 2023 13:12:57 +0200 Subject: [PATCH 004/103] Ran pre-commit hooks --- oauthenticator/azuread.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/oauthenticator/azuread.py b/oauthenticator/azuread.py index 2eaa89bb..7ea37965 100644 --- a/oauthenticator/azuread.py +++ b/oauthenticator/azuread.py @@ -28,6 +28,7 @@ class AzureAdOAuthenticator(OAuthenticator): @default('tenant_id') def _tenant_id_default(self): return os.environ.get('AAD_TENANT_ID', '') + @default('username_claim') def _username_claim_default(self): return 'name' @@ -44,7 +45,8 @@ async def update_auth_model(self, auth_model, **kwargs): auth_model = await super().update_auth_model(auth_model, **kwargs) user_info = auth_model["auth_state"][self.user_auth_state_key] - auth_model["groups"] = user_info.get(self.user_groups_claim) + if self.user_groups_claim: + auth_model["groups"] = user_info.get(self.user_groups_claim) return auth_model From a2c5c1aa0184d3862c344f6a016444cd476ef470 Mon Sep 17 00:00:00 2001 From: Thomas Li Fredriksen Date: Sat, 10 Jun 2023 13:20:19 +0200 Subject: [PATCH 005/103] Updated docs --- .../providers/azuread.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/source/tutorials/provider-specific-setup/providers/azuread.md b/docs/source/tutorials/provider-specific-setup/providers/azuread.md index cc038fe8..e19309de 100644 --- a/docs/source/tutorials/provider-specific-setup/providers/azuread.md +++ b/docs/source/tutorials/provider-specific-setup/providers/azuread.md @@ -53,3 +53,22 @@ 1. See `run.sh` for an [example](https://github.com/jupyterhub/oauthenticator/tree/main/examples/azuread) 1. [Source Code](https://github.com/jupyterhub/oauthenticator/blob/HEAD/oauthenticator/azuread.py) + +## Loading user groups + +The `AzureAdOAuthenticator` can load the group-membership of users from the access token. +This is done by setting the `AzureAdOAuthenticator.groups_claim` to the name of the claim that contains the +group-membership. + +```python +import os +from oauthenticator.azuread import AzureAdOAuthenticator + +c.JupyterHub.authenticator_class = AzureAdOAuthenticator + +# {...} other settings (see above) + +c.AzureAdOAuthenticator.user_groups_claim = 'groups' +``` + +This requires Azure AD to be configured to include the group-membership in the access token. From 175d8d05de96a4aac34b8745ea1c4cef451573a6 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Sat, 8 Jul 2023 14:37:21 +0200 Subject: [PATCH 006/103] docs: update v16 changelog to capture missed change about allow_all --- docs/source/reference/changelog.md | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/docs/source/reference/changelog.md b/docs/source/reference/changelog.md index faa5abd2..ecc63095 100644 --- a/docs/source/reference/changelog.md +++ b/docs/source/reference/changelog.md @@ -35,22 +35,24 @@ and maintain its code and documentation. This release has several _breaking changes_ and _deprecations_ you should read through before upgrading. ```{note} -This changelog entry was updated to capture changes in 16.0.2, please upgrade -directly to 16.0.2 or higher. +This changelog entry has been updated to capture previously undocumented changes +and new changes in 16.0.2, please upgrade directly to 16.0.2 or higher. ``` #### Breaking changes - Support for Python 3.7 has been dropped, Python 3.8+ is now required. -- [All] Users are now authorized based on _either_ being part of +- [All] If no configuration allows a user, then users are no longer allowed by + default. The new config {attr}`.OAuthenticator.allow_all` can be configured + True to allow all users. +- [All] Users are now allowed based on _either_ being part of: {attr}`.OAuthenticator.admin_users`, {attr}`.OAuthenticator.allowed_users`, an - Authenticator specific allowed group/team/organization, or if by being part of - the existing users if new config {attr}`.OAuthenticator.allow_existing_users` - is True. -- [All] Existing users (listed via `/hub/admin`) will now only be allowed if - {attr}`.OAuthenticator.allow_existing_users` is True, while before this - version they were allowed if {attr}`.OAuthenticator.allowed_users` was + Authenticator specific config allowing a group/team/organization, or by being + an existing user if new config {attr}`.OAuthenticator.allow_existing_users` is configured. +- [All] Existing users (listed via `/hub/admin`) will now only be allowed if + {attr}`.OAuthenticator.allow_existing_users` is True, while before existing + users were allowed if {attr}`.OAuthenticator.allowed_users` was configured. - [Google] If {attr}`.GoogleOAuthenticator.admin_google_groups` is configured, users logging in not explicitly there or in {attr}`.OAuthenticator.admin_users` will get their admin status revoked. @@ -100,7 +102,7 @@ in issue [#634](https://github.com/jupyterhub/oauthenticator/issues/634). #### New features added -- [All] breaking: add allow_existing_users config [#631](https://github.com/jupyterhub/oauthenticator/pull/631) ([@consideRatio](https://github.com/consideRatio), [@minrk](https://github.com/minrk)) +- [All] breaking: add allow_existing_users config defaulting to False [#631](https://github.com/jupyterhub/oauthenticator/pull/631) ([@consideRatio](https://github.com/consideRatio), [@minrk](https://github.com/minrk)) - [All] breaking, add allow_all config defaulting to False (CILogon: require allowed_idps) [#625](https://github.com/jupyterhub/oauthenticator/pull/625) ([@consideRatio](https://github.com/consideRatio), [@GeorgianaElena](https://github.com/GeorgianaElena)) - [All] Add `http_request_kwargs` config option [#578](https://github.com/jupyterhub/oauthenticator/pull/578) ([@manics](https://github.com/manics), [@consideRatio](https://github.com/consideRatio), [@minrk](https://github.com/minrk)) From 9ff5c87c4784a1a84a246105ec4ed8206b9d86c4 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Sat, 8 Jul 2023 14:44:06 +0200 Subject: [PATCH 007/103] Add changelog for 16.0.3 --- docs/source/reference/changelog.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/source/reference/changelog.md b/docs/source/reference/changelog.md index ecc63095..f96ce694 100644 --- a/docs/source/reference/changelog.md +++ b/docs/source/reference/changelog.md @@ -8,6 +8,12 @@ command line for details. ## 16.0 +### 16.0.3 - 2023-07-08 + +#### Documentation improvements + +- docs: update v16 changelog to capture missed change about allow_all [#651](https://github.com/jupyterhub/oauthenticator/pull/651) ([@consideRatio](https://github.com/consideRatio)) + ### 16.0.2 - 2023-07-06 #### Bugs fixed From 5be6c45c33beed5335bb3cca3ebaaf2e16d09e99 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Sat, 8 Jul 2023 14:46:16 +0200 Subject: [PATCH 008/103] Bump to 16.0.3 --- oauthenticator/_version.py | 2 +- pyproject.toml | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/oauthenticator/_version.py b/oauthenticator/_version.py index 5a06cda5..c4dbbb16 100644 --- a/oauthenticator/_version.py +++ b/oauthenticator/_version.py @@ -1,7 +1,7 @@ # __version__ should be updated using tbump, based on configuration in # pyproject.toml, according to instructions in RELEASE.md. # -__version__ = "16.0.3.dev" +__version__ = "16.0.3" # version_info looks like (1, 2, 3, "dev") if __version__ is 1.2.3.dev version_info = tuple(int(p) if p.isdigit() else p for p in __version__.split(".")) diff --git a/pyproject.toml b/pyproject.toml index 3e179606..f8200eed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,7 +64,7 @@ omit = [ github_url = "https://github.com/jupyterhub/oauthenticator" [tool.tbump.version] -current = "16.0.3.dev" +current = "16.0.3" regex = ''' (?P\d+) \. diff --git a/setup.py b/setup.py index 3608e63b..c887250c 100644 --- a/setup.py +++ b/setup.py @@ -27,7 +27,7 @@ def run(self): setup_args = dict( name='oauthenticator', packages=find_packages(), - version="16.0.3.dev", + version="16.0.3", description="OAuthenticator: Authenticate JupyterHub users with common OAuth providers", long_description=open("README.md").read(), long_description_content_type="text/markdown", From 5d4e129e0e5dab1f8d4de6964a47f04e42b62edb Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Sat, 8 Jul 2023 14:46:28 +0200 Subject: [PATCH 009/103] Bump to 16.0.4.dev --- oauthenticator/_version.py | 2 +- pyproject.toml | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/oauthenticator/_version.py b/oauthenticator/_version.py index c4dbbb16..63b20ea8 100644 --- a/oauthenticator/_version.py +++ b/oauthenticator/_version.py @@ -1,7 +1,7 @@ # __version__ should be updated using tbump, based on configuration in # pyproject.toml, according to instructions in RELEASE.md. # -__version__ = "16.0.3" +__version__ = "16.0.4.dev" # version_info looks like (1, 2, 3, "dev") if __version__ is 1.2.3.dev version_info = tuple(int(p) if p.isdigit() else p for p in __version__.split(".")) diff --git a/pyproject.toml b/pyproject.toml index f8200eed..e11066c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,7 +64,7 @@ omit = [ github_url = "https://github.com/jupyterhub/oauthenticator" [tool.tbump.version] -current = "16.0.3" +current = "16.0.4.dev" regex = ''' (?P\d+) \. diff --git a/setup.py b/setup.py index c887250c..d108ea13 100644 --- a/setup.py +++ b/setup.py @@ -27,7 +27,7 @@ def run(self): setup_args = dict( name='oauthenticator', packages=find_packages(), - version="16.0.3", + version="16.0.4.dev", description="OAuthenticator: Authenticate JupyterHub users with common OAuth providers", long_description=open("README.md").read(), long_description_content_type="text/markdown", From 4b905824924bcc5cd65a39a65b7974042bff23c0 Mon Sep 17 00:00:00 2001 From: Steffen Schneider Date: Sun, 9 Jul 2023 04:56:48 +0200 Subject: [PATCH 010/103] Fix typo in authenticator class for google --- .../tutorials/provider-specific-setup/providers/google.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/tutorials/provider-specific-setup/providers/google.md b/docs/source/tutorials/provider-specific-setup/providers/google.md index fd5579b1..780b9f27 100644 --- a/docs/source/tutorials/provider-specific-setup/providers/google.md +++ b/docs/source/tutorials/provider-specific-setup/providers/google.md @@ -18,7 +18,7 @@ followed by `/hub/oauth_callback`. Your `jupyterhub_config.py` file should look something like this: ```python -c.JupyterHub.authenticator_class = "okpy" +c.JupyterHub.authenticator_class = "google" c.OAuthenticator.oauth_callback_url = "https://[your-domain]/hub/oauth_callback" c.OAuthenticator.client_id = "[your oauth2 application id]" c.OAuthenticator.client_secret = "[your oauth2 application secret]" From 9329f756b438d2c7cdebf14e6d95b1c7ec73b177 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 1 Aug 2023 08:35:42 +0000 Subject: [PATCH 011/103] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/asottile/pyupgrade: v3.8.0 → v3.10.1](https://github.com/asottile/pyupgrade/compare/v3.8.0...v3.10.1) - [github.com/psf/black: 23.3.0 → 23.7.0](https://github.com/psf/black/compare/23.3.0...23.7.0) - [github.com/pre-commit/mirrors-prettier: v3.0.0-alpha.9-for-vscode → v3.0.0](https://github.com/pre-commit/mirrors-prettier/compare/v3.0.0-alpha.9-for-vscode...v3.0.0) - [github.com/pycqa/flake8: 6.0.0 → 6.1.0](https://github.com/pycqa/flake8/compare/6.0.0...6.1.0) --- .pre-commit-config.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 22bb35a8..3407cebe 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ repos: # Autoformat: Python code, syntax patterns are modernized - repo: https://github.com/asottile/pyupgrade - rev: v3.8.0 + rev: v3.10.1 hooks: - id: pyupgrade args: @@ -34,13 +34,13 @@ repos: # Autoformat: Python code - repo: https://github.com/psf/black - rev: 23.3.0 + rev: 23.7.0 hooks: - id: black # Autoformat: markdown, yaml - repo: https://github.com/pre-commit/mirrors-prettier - rev: v3.0.0-alpha.9-for-vscode + rev: v3.0.0 hooks: - id: prettier @@ -64,7 +64,7 @@ repos: # Lint: Python code - repo: https://github.com/pycqa/flake8 - rev: "6.0.0" + rev: "6.1.0" hooks: - id: flake8 From 374ec0eecff7fc87807ed7165e2fa5988b442500 Mon Sep 17 00:00:00 2001 From: Matt Wiese Date: Thu, 3 Aug 2023 10:23:32 -0400 Subject: [PATCH 012/103] Add ORCID iD example configuration --- .../how-to/writing-an-oauthenticator.md | 7 ++-- .../providers/generic.md | 39 +++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/docs/source/how-to/writing-an-oauthenticator.md b/docs/source/how-to/writing-an-oauthenticator.md index 17e04c98..0540061d 100644 --- a/docs/source/how-to/writing-an-oauthenticator.md +++ b/docs/source/how-to/writing-an-oauthenticator.md @@ -4,6 +4,10 @@ There are two ways to write your own OAuthenticator. ## Using GenericOAuthenticator +```{note} +Before writing your own config, [](tutorials:provider-specific:generic) may already have an example for your service. +``` + The first and simplest is to use the `oauthenticator.generic.GenericOAuthenticator` class and configuration to set the necessary configuration variables. @@ -28,9 +32,6 @@ c.GenericOAuthenticator.token_url = 'url-retrieving-access-token-oauth-completio c.GenericOAuthenticator.username_claim = 'username-key-for-USERDATA-URL' ``` -Checkout [](tutorials:provider-specific:generic:moodle) and [](tutorials:provider-specific:generic:yandex) for how to configure -GenericOAuthenticator for Moodle and Yandex. - ## Writing your own OAuthenticator class If you want more advanced features and customization beyond the basics of OAuth, diff --git a/docs/source/tutorials/provider-specific-setup/providers/generic.md b/docs/source/tutorials/provider-specific-setup/providers/generic.md index 5506f927..90dc6d75 100644 --- a/docs/source/tutorials/provider-specific-setup/providers/generic.md +++ b/docs/source/tutorials/provider-specific-setup/providers/generic.md @@ -147,3 +147,42 @@ c.GenericOAuthenticator.authorize_url = "https://your-AWSCognito-domain/oauth2/a c.GenericOAuthenticator.token_url = "https://your-AWSCognito-domain/oauth2/token" c.GenericOAuthenticator.userdata_url = "https://your-AWSCognito-domain/oauth2/userInfo" ``` + +## Setup for ORCID iD + +```{note} +The `GenericOAuthenticator` will by default lowercase your username. For example, an ORCID iD of `0000-0002-9079-593X` will produce a JupyterHub username of `0000-0002-9079-593x`. +``` + +Follow the ORCID [API Tutorial](https://info.orcid.org/documentation/api-tutorials/api-tutorial-get-and-authenticated-orcid-id/) to create an application via the Developer Tools submenu after clicking on your name in the top right of the page. + +Edit your `jupyterhub_config.py` with the following: + +```python +c.JupyterHub.authenticator_class = "generic" + +# Fill these in with your values +c.GenericOAuthenticator.oauth_callback_url = "YOUR CALLBACK URL" +c.GenericOAuthenticator.client_id = "YOUR CLIENT ID" +c.GenericOAuthenticator.client_secret = "YOUR CLIENT SECRET" + +c.GenericOAuthenticator.login_service = "ORCID iD" # Text of login button +c.GenericOAuthenticator.authorize_url = "https://orcid.org/oauth/authorize" +c.GenericOAuthenticator.token_url = "https://orcid.org/oauth/token" +c.GenericOAuthenticator.scope = ["/authenticate", "openid"] +c.GenericOAuthenticator.userdata_url = "https://orcid.org/oauth/userinfo" +c.GenericOAuthenticator.username_claim = "sub" +``` + +The above `username_claim` value selects the ORCID iD from the JSON response as the individual's JupyterHub username. An example response is below: + +```json +{ + "sub": "0000-0002-2601-8132", + "name": "Credit Name", + "family_name": "Jones", + "given_name": "Tom" +} +``` + +Please refer to the [Authorization Code Flow](https://github.com/ORCID/ORCID-Source/blob/main/orcid-web/ORCID_AUTH_WITH_OPENID_CONNECT.md#authorization-code-flow) section of the ORCID documentation for more information. \ No newline at end of file From 3b5b603dc2a5308934193d990301ce8f6c434c5e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 3 Aug 2023 14:28:54 +0000 Subject: [PATCH 013/103] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../provider-specific-setup/providers/generic.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/source/tutorials/provider-specific-setup/providers/generic.md b/docs/source/tutorials/provider-specific-setup/providers/generic.md index 90dc6d75..49e51dc0 100644 --- a/docs/source/tutorials/provider-specific-setup/providers/generic.md +++ b/docs/source/tutorials/provider-specific-setup/providers/generic.md @@ -178,11 +178,11 @@ The above `username_claim` value selects the ORCID iD from the JSON response as ```json { - "sub": "0000-0002-2601-8132", - "name": "Credit Name", - "family_name": "Jones", - "given_name": "Tom" + "sub": "0000-0002-2601-8132", + "name": "Credit Name", + "family_name": "Jones", + "given_name": "Tom" } ``` -Please refer to the [Authorization Code Flow](https://github.com/ORCID/ORCID-Source/blob/main/orcid-web/ORCID_AUTH_WITH_OPENID_CONNECT.md#authorization-code-flow) section of the ORCID documentation for more information. \ No newline at end of file +Please refer to the [Authorization Code Flow](https://github.com/ORCID/ORCID-Source/blob/main/orcid-web/ORCID_AUTH_WITH_OPENID_CONNECT.md#authorization-code-flow) section of the ORCID documentation for more information. From 05c5f9c4b3f05239bf73ebb35f016bc5add1012b Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Fri, 11 Aug 2023 08:23:12 +0200 Subject: [PATCH 014/103] fix, google: don't include domain if hosted_domain has a single entry --- oauthenticator/google.py | 23 +++++++++++++--- oauthenticator/tests/test_google.py | 41 ++++++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/oauthenticator/google.py b/oauthenticator/google.py index ed9cf4c0..58d44ed0 100644 --- a/oauthenticator/google.py +++ b/oauthenticator/google.py @@ -108,19 +108,28 @@ def _userdata_url_default(self): Unicode(), config=True, help=""" - Restrict sign-in to a list of email domain names, such as - `["mycollege.edu"]`. + This config has two functions. + + 1. Restrict sign-in to a list of email domain names, such as + `["mycollege.edu"]` or `["college1.edu", "college2.edu"]`. + 2. If a single domain is specified, the username will be stripped. Note that users with email domains in this list must still be allowed via another config, such as `allow_all`, `allowed_users`, or `allowed_google_groups`. + + ```{warning} Disruptive config changes + Changing this config either to or from having a single entry is a + disruptive change as the same Google user will get a new username, + either without or with a domain name included. + ``` """, ) @default('hosted_domain') def _hosted_domain_from_env(self): domains = [] - for domain in os.environ.get('HOSTED_DOMAIN', '').split(';'): + for domain in os.environ.get('HOSTED_DOMAIN', '').lower().split(';'): if domain: # check falsy to avoid trailing separators # adding empty domains @@ -162,11 +171,19 @@ async def update_auth_model(self, auth_model): configured and the user isn't part of `admin_users`. Note that leaving it at None makes users able to retain an admin status while setting it to False makes it be revoked. + + Strips the domain from the username if `hosted_domain` is configured + with a single entry. """ user_info = auth_model["auth_state"][self.user_auth_state_key] user_email = user_info["email"] user_domain = user_info["domain"] = user_email.split("@")[1].lower() + if len(self.hosted_domain) == 1 and self.hosted_domain[0] == user_domain: + # unambiguous domain, use only base name + username = user_email.split('@')[0] + auth_model["name"] = username + user_groups = set() if self.allowed_google_groups or self.admin_google_groups: user_groups = user_info["google_groups"] = self._fetch_user_groups( diff --git a/oauthenticator/tests/test_google.py b/oauthenticator/tests/test_google.py index d9e24196..7a28388d 100644 --- a/oauthenticator/tests/test_google.py +++ b/oauthenticator/tests/test_google.py @@ -185,7 +185,12 @@ async def test_google( assert auth_model == None -async def test_hosted_domain(google_client): +async def test_hosted_domain_single_entry(google_client): + """ + Tests that sign in is restricted to the listed domain and that the username + represents the part before the `@domain.com` as expected when hosted_domain + contains a single entry. + """ c = Config() c.GoogleOAuthenticator.hosted_domain = ["In-Hosted-Domain.com"] c.GoogleOAuthenticator.allow_all = True @@ -195,6 +200,40 @@ async def test_hosted_domain(google_client): handler = google_client.handler_for_user(handled_user_model) auth_model = await authenticator.get_authenticated_user(handler, None) assert auth_model + assert auth_model["name"] == "user1" + + handled_user_model = user_model("user1@not-in-hosted-domain.com") + handler = google_client.handler_for_user(handled_user_model) + with raises(HTTPError) as exc: + await authenticator.get_authenticated_user(handler, None) + assert exc.value.status_code == 403 + + +async def test_hosted_domain_multiple_entries(google_client): + """ + Tests that sign in is restricted to the listed domains and that the username + represents the full email as expected when hosted_domain contains multiple + entries. + """ + c = Config() + c.GoogleOAuthenticator.hosted_domain = [ + "In-Hosted-Domain1.com", + "In-Hosted-Domain2.com", + ] + c.GoogleOAuthenticator.allow_all = True + authenticator = GoogleOAuthenticator(config=c) + + handled_user_model = user_model("user1@iN-hosteD-domaiN1.com") + handler = google_client.handler_for_user(handled_user_model) + auth_model = await authenticator.get_authenticated_user(handler, None) + assert auth_model + assert auth_model["name"] == "user1@in-hosted-domain1.com" + + handled_user_model = user_model("user2@iN-hosteD-domaiN2.com") + handler = google_client.handler_for_user(handled_user_model) + auth_model = await authenticator.get_authenticated_user(handler, None) + assert auth_model + assert auth_model["name"] == "user2@in-hosted-domain2.com" handled_user_model = user_model("user1@not-in-hosted-domain.com") handler = google_client.handler_for_user(handled_user_model) From 7471a4e6eee1d0dbfdd8e15d38c3a512efcc1992 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Fri, 11 Aug 2023 12:55:54 +0200 Subject: [PATCH 015/103] Apply suggestions from code review Co-authored-by: Min RK --- oauthenticator/google.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oauthenticator/google.py b/oauthenticator/google.py index 58d44ed0..581c810a 100644 --- a/oauthenticator/google.py +++ b/oauthenticator/google.py @@ -112,7 +112,7 @@ def _userdata_url_default(self): 1. Restrict sign-in to a list of email domain names, such as `["mycollege.edu"]` or `["college1.edu", "college2.edu"]`. - 2. If a single domain is specified, the username will be stripped. + 2. If a single domain is specified, the username will be stripped to exclude the `@domain` part. Note that users with email domains in this list must still be allowed via another config, such as `allow_all`, `allowed_users`, or From c96123bf8d29b3b8d2a2ee5c0f73c79794c8cc74 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Fri, 11 Aug 2023 09:04:53 +0200 Subject: [PATCH 016/103] Add changelog for 16.0.4 --- docs/source/reference/changelog.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/source/reference/changelog.md b/docs/source/reference/changelog.md index f96ce694..cfa3aa1a 100644 --- a/docs/source/reference/changelog.md +++ b/docs/source/reference/changelog.md @@ -8,6 +8,28 @@ command line for details. ## 16.0 +### 16.0.4 - 2023-08-11 + +([full changelog](https://github.com/jupyterhub/oauthenticator/compare/16.0.3...7937cd6a7f8c123a9b50cad37af3ff3a9a610e71)) + +#### Bugs fixed + +- [Google] Fix regression in v16 of no longer stripping username's domain if `hosted_domain` has a single entry [#661](https://github.com/jupyterhub/oauthenticator/pull/661) ([@consideratio](https://github.com/consideratio), [@minrk](https://github.com/minrk), [@taylorgibson](https://github.com/taylorgibson)) + +#### Documentation improvements + +- Add ORCID iD example configuration [#657](https://github.com/jupyterhub/oauthenticator/pull/657) ([@matthewwiese](https://github.com/matthewwiese), [@manics](https://github.com/manics)) +- Fix typo in authenticator class for google [#653](https://github.com/jupyterhub/oauthenticator/pull/653) ([@stes](https://github.com/stes), [@consideRatio](https://github.com/consideRatio)) + +#### Contributors to this release + +The following people contributed discussions, new ideas, code and documentation contributions, and review. +See [our definition of contributors](https://github-activity.readthedocs.io/en/latest/#how-does-this-tool-define-contributions-in-the-reports). + +([GitHub contributors page for this release](https://github.com/jupyterhub/oauthenticator/graphs/contributors?from=2023-07-08&to=2023-08-11&type=c)) + +@consideRatio ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Foauthenticator+involves%3AconsideRatio+updated%3A2023-07-08..2023-08-11&type=Issues)) | @manics ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Foauthenticator+involves%3Amanics+updated%3A2023-07-08..2023-08-11&type=Issues)) | @matthewwiese ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Foauthenticator+involves%3Amatthewwiese+updated%3A2023-07-08..2023-08-11&type=Issues)) | @minrk ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Foauthenticator+involves%3Aminrk+updated%3A2023-07-08..2023-08-11&type=Issues)) | @NickolausDS ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Foauthenticator+involves%3ANickolausDS+updated%3A2023-07-08..2023-08-11&type=Issues)) | @stes ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Foauthenticator+involves%3Astes+updated%3A2023-07-08..2023-08-11&type=Issues)) | @taylorgibson ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Foauthenticator+involves%3Ataylorgibson+updated%3A2023-07-08..2023-08-11&type=Issues)) + ### 16.0.3 - 2023-07-08 #### Documentation improvements From 7894a970fd7a2a0e5520d8f93939dd6f255f4fd8 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Fri, 11 Aug 2023 13:28:32 +0200 Subject: [PATCH 017/103] Bump to 16.0.4 --- oauthenticator/_version.py | 2 +- pyproject.toml | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/oauthenticator/_version.py b/oauthenticator/_version.py index 63b20ea8..1f99d495 100644 --- a/oauthenticator/_version.py +++ b/oauthenticator/_version.py @@ -1,7 +1,7 @@ # __version__ should be updated using tbump, based on configuration in # pyproject.toml, according to instructions in RELEASE.md. # -__version__ = "16.0.4.dev" +__version__ = "16.0.4" # version_info looks like (1, 2, 3, "dev") if __version__ is 1.2.3.dev version_info = tuple(int(p) if p.isdigit() else p for p in __version__.split(".")) diff --git a/pyproject.toml b/pyproject.toml index e11066c2..b51efd88 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,7 +64,7 @@ omit = [ github_url = "https://github.com/jupyterhub/oauthenticator" [tool.tbump.version] -current = "16.0.4.dev" +current = "16.0.4" regex = ''' (?P\d+) \. diff --git a/setup.py b/setup.py index d108ea13..f2404c6e 100644 --- a/setup.py +++ b/setup.py @@ -27,7 +27,7 @@ def run(self): setup_args = dict( name='oauthenticator', packages=find_packages(), - version="16.0.4.dev", + version="16.0.4", description="OAuthenticator: Authenticate JupyterHub users with common OAuth providers", long_description=open("README.md").read(), long_description_content_type="text/markdown", From d4e07ef50239bd8371843e877eee78c360677f9c Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Fri, 11 Aug 2023 13:29:06 +0200 Subject: [PATCH 018/103] Bump to 16.0.5.dev --- oauthenticator/_version.py | 2 +- pyproject.toml | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/oauthenticator/_version.py b/oauthenticator/_version.py index 1f99d495..91a63af7 100644 --- a/oauthenticator/_version.py +++ b/oauthenticator/_version.py @@ -1,7 +1,7 @@ # __version__ should be updated using tbump, based on configuration in # pyproject.toml, according to instructions in RELEASE.md. # -__version__ = "16.0.4" +__version__ = "16.0.5.dev" # version_info looks like (1, 2, 3, "dev") if __version__ is 1.2.3.dev version_info = tuple(int(p) if p.isdigit() else p for p in __version__.split(".")) diff --git a/pyproject.toml b/pyproject.toml index b51efd88..77750c8f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,7 +64,7 @@ omit = [ github_url = "https://github.com/jupyterhub/oauthenticator" [tool.tbump.version] -current = "16.0.4" +current = "16.0.5.dev" regex = ''' (?P\d+) \. diff --git a/setup.py b/setup.py index f2404c6e..43f10b15 100644 --- a/setup.py +++ b/setup.py @@ -27,7 +27,7 @@ def run(self): setup_args = dict( name='oauthenticator', packages=find_packages(), - version="16.0.4", + version="16.0.5.dev", description="OAuthenticator: Authenticate JupyterHub users with common OAuth providers", long_description=open("README.md").read(), long_description_content_type="text/markdown", From 780f9399645690d19ba406b1dade6c49cc1660c2 Mon Sep 17 00:00:00 2001 From: Min RK Date: Tue, 15 Aug 2023 09:24:14 +0200 Subject: [PATCH 019/103] handle auth_model is None in google, globus these both have pre-super checks, which means they don't inherit the short-circuit from the base class --- oauthenticator/globus.py | 5 +++++ oauthenticator/google.py | 5 +++++ oauthenticator/oauth2.py | 4 ++-- oauthenticator/tests/test_generic.py | 15 +++++++++++++++ oauthenticator/tests/test_globus.py | 15 +++++++++++++++ oauthenticator/tests/test_google.py | 15 +++++++++++++++ 6 files changed, 57 insertions(+), 2 deletions(-) diff --git a/oauthenticator/globus.py b/oauthenticator/globus.py index 4a447208..28ae5d2b 100644 --- a/oauthenticator/globus.py +++ b/oauthenticator/globus.py @@ -291,6 +291,11 @@ async def check_allowed(self, username, auth_model): Overrides the OAuthenticator.check_allowed to also allow users part of `allowed_globus_groups`. """ + # A workaround for JupyterHub < 5.0 described in + # https://github.com/jupyterhub/oauthenticator/issues/621 + if auth_model is None: + return True + # before considering allowing a username by being recognized in a list # of usernames or similar, we must ensure that the authenticated user is # from an allowed identity provider domain. diff --git a/oauthenticator/google.py b/oauthenticator/google.py index 581c810a..d2ceb704 100644 --- a/oauthenticator/google.py +++ b/oauthenticator/google.py @@ -207,6 +207,11 @@ async def check_allowed(self, username, auth_model): Overrides the OAuthenticator.check_allowed to also allow users part of `allowed_google_groups`. """ + # A workaround for JupyterHub < 5.0 described in + # https://github.com/jupyterhub/oauthenticator/issues/621 + if auth_model is None: + return True + # before considering allowing a username by being recognized in a list # of usernames or similar, we must ensure that the authenticated user # has a verified email and is part of hosted_domain if configured. diff --git a/oauthenticator/oauth2.py b/oauthenticator/oauth2.py index 5e96464a..ca4147f7 100644 --- a/oauthenticator/oauth2.py +++ b/oauthenticator/oauth2.py @@ -463,7 +463,7 @@ def _logout_redirect_url_default(self): config=True, help=""" Callback URL to use. - + When registering an OAuth2 application with an identity provider, this is typically called the redirect url. @@ -994,7 +994,7 @@ async def check_allowed(self, username, auth_model): method and return True when this method returns True or if a user is allowed via the additional config. """ - # A workaround for JupyterHub<=4.0.1, described in + # A workaround for JupyterHub < 5.0 described in # https://github.com/jupyterhub/oauthenticator/issues/621 if auth_model is None: return True diff --git a/oauthenticator/tests/test_generic.py b/oauthenticator/tests/test_generic.py index 35d11b42..93bede7a 100644 --- a/oauthenticator/tests/test_generic.py +++ b/oauthenticator/tests/test_generic.py @@ -238,3 +238,18 @@ async def test_generic_claim_groups_key_nested_strings( assert auth_model assert auth_model["admin"] + + +@mark.parametrize( + "name, allowed", + [ + ("allowed", True), + ("notallowed", False), + ], +) +async def test_check_allowed_no_auth_state(get_authenticator, name, allowed): + authenticator = get_authenticator(allowed_users={"allowed"}) + # allow check always gets called with no auth model during Hub startup + # these are previously-allowed users who should pass until subsequent + # this check is removed in JupyterHub 5 + assert await authenticator.check_allowed(name, None) diff --git a/oauthenticator/tests/test_globus.py b/oauthenticator/tests/test_globus.py index 7dbd1476..5e542e52 100644 --- a/oauthenticator/tests/test_globus.py +++ b/oauthenticator/tests/test_globus.py @@ -313,6 +313,21 @@ async def test_globus( assert auth_model == None +@mark.parametrize( + "name, allowed", + [ + ("allowed", True), + ("notallowed", False), + ], +) +async def test_check_allowed_no_auth_state(name, allowed): + authenticator = GlobusOAuthenticator(allowed_users={"allowed"}) + # allow check always gets called with no auth model during Hub startup + # these are previously-allowed users who should pass until subsequent + # this check is removed in JupyterHub 5 + assert await authenticator.check_allowed(name, None) + + async def test_globus_pre_spawn_start(mock_globus_user): authenticator = GlobusOAuthenticator() spawner = Mock() diff --git a/oauthenticator/tests/test_google.py b/oauthenticator/tests/test_google.py index 7a28388d..baf74ba7 100644 --- a/oauthenticator/tests/test_google.py +++ b/oauthenticator/tests/test_google.py @@ -209,6 +209,21 @@ async def test_hosted_domain_single_entry(google_client): assert exc.value.status_code == 403 +@mark.parametrize( + "name, allowed", + [ + ("allowed", True), + ("notallowed", False), + ], +) +async def test_check_allowed_no_auth_state(google_client, name, allowed): + authenticator = GoogleOAuthenticator(allowed_users={"allowed"}) + # allow check always gets called with no auth model during Hub startup + # these are previously-allowed users who should pass until subsequent + # this check is removed in JupyterHub 5 + assert await authenticator.check_allowed(name, None) + + async def test_hosted_domain_multiple_entries(google_client): """ Tests that sign in is restricted to the listed domains and that the username From dc2873a48969d0ba2acab897fa1ad46aced64fe2 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Tue, 15 Aug 2023 10:54:22 +0200 Subject: [PATCH 020/103] Add changelog for 16.0.5 --- docs/source/reference/changelog.md | 44 ++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/docs/source/reference/changelog.md b/docs/source/reference/changelog.md index cfa3aa1a..c0785e6a 100644 --- a/docs/source/reference/changelog.md +++ b/docs/source/reference/changelog.md @@ -8,7 +8,24 @@ command line for details. ## 16.0 -### 16.0.4 - 2023-08-11 +### [16.0.5] - 2023-08-15 + +([full changelog](https://github.com/jupyterhub/oauthenticator/compare/16.0.4...16.0.5)) + +#### Bugs fixed + +- [Google, Globus] handle auth_model is None in google, globus [#665](https://github.com/jupyterhub/oauthenticator/pull/665) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio)) + +#### Contributors to this release + +The following people contributed discussions, new ideas, code and documentation contributions, and review. +See [our definition of contributors](https://github-activity.readthedocs.io/en/latest/#how-does-this-tool-define-contributions-in-the-reports). + +([GitHub contributors page for this release](https://github.com/jupyterhub/oauthenticator/graphs/contributors?from=2023-08-11&to=2023-08-15&type=c)) + +@consideRatio ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Foauthenticator+involves%3AconsideRatio+updated%3A2023-08-11..2023-08-15&type=Issues)) | @minrk ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Foauthenticator+involves%3Aminrk+updated%3A2023-08-11..2023-08-15&type=Issues)) + +### [16.0.4] - 2023-08-11 ([full changelog](https://github.com/jupyterhub/oauthenticator/compare/16.0.3...7937cd6a7f8c123a9b50cad37af3ff3a9a610e71)) @@ -30,13 +47,13 @@ See [our definition of contributors](https://github-activity.readthedocs.io/en/l @consideRatio ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Foauthenticator+involves%3AconsideRatio+updated%3A2023-07-08..2023-08-11&type=Issues)) | @manics ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Foauthenticator+involves%3Amanics+updated%3A2023-07-08..2023-08-11&type=Issues)) | @matthewwiese ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Foauthenticator+involves%3Amatthewwiese+updated%3A2023-07-08..2023-08-11&type=Issues)) | @minrk ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Foauthenticator+involves%3Aminrk+updated%3A2023-07-08..2023-08-11&type=Issues)) | @NickolausDS ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Foauthenticator+involves%3ANickolausDS+updated%3A2023-07-08..2023-08-11&type=Issues)) | @stes ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Foauthenticator+involves%3Astes+updated%3A2023-07-08..2023-08-11&type=Issues)) | @taylorgibson ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Foauthenticator+involves%3Ataylorgibson+updated%3A2023-07-08..2023-08-11&type=Issues)) -### 16.0.3 - 2023-07-08 +### [16.0.3] - 2023-07-08 #### Documentation improvements - docs: update v16 changelog to capture missed change about allow_all [#651](https://github.com/jupyterhub/oauthenticator/pull/651) ([@consideRatio](https://github.com/consideRatio)) -### 16.0.2 - 2023-07-06 +### [16.0.2] - 2023-07-06 #### Bugs fixed @@ -46,7 +63,7 @@ See [our definition of contributors](https://github-activity.readthedocs.io/en/l - [Generic] Deprecate tls_verify in favor of validate_server_cert [#647](https://github.com/jupyterhub/oauthenticator/pull/647) ([@consideRatio](https://github.com/consideRatio)) -### 16.0.1 - 2023-07-05 +### [16.0.1] - 2023-07-05 #### Bugs fixed @@ -56,7 +73,7 @@ See [our definition of contributors](https://github-activity.readthedocs.io/en/l - docs: fix redirection config typo for getting-started [#642](https://github.com/jupyterhub/oauthenticator/pull/642) ([@consideRatio](https://github.com/consideRatio)) -### 16.0.0 - 2023-07-05 +### [16.0.0] - 2023-07-05 The project has been refactored greatly to make it easier to use, understand, and maintain its code and documentation. This release has several _breaking @@ -199,7 +216,7 @@ See [our definition of contributors](https://github-activity.readthedocs.io/en/l ## 15.0 -### 15.1.0 - 2022-09-08 +### [15.1.0] - 2022-09-08 #### New features added @@ -220,7 +237,7 @@ See [our definition of contributors](https://github-activity.readthedocs.io/en/l [@consideRatio](https://github.com/search?q=repo%3Ajupyterhub%2Foauthenticator+involves%3AconsideRatio+updated%3A2022-06-09..2022-09-08&type=Issues) | [@dingobar](https://github.com/search?q=repo%3Ajupyterhub%2Foauthenticator+involves%3Adingobar+updated%3A2022-06-09..2022-09-08&type=Issues) | [@drhagen](https://github.com/search?q=repo%3Ajupyterhub%2Foauthenticator+involves%3Adrhagen+updated%3A2022-06-09..2022-09-08&type=Issues) | [@GeorgianaElena](https://github.com/search?q=repo%3Ajupyterhub%2Foauthenticator+involves%3AGeorgianaElena+updated%3A2022-06-09..2022-09-08&type=Issues) | [@manics](https://github.com/search?q=repo%3Ajupyterhub%2Foauthenticator+involves%3Amanics+updated%3A2022-06-09..2022-09-08&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyterhub%2Foauthenticator+involves%3Aminrk+updated%3A2022-06-09..2022-09-08&type=Issues) | [@terrencegf](https://github.com/search?q=repo%3Ajupyterhub%2Foauthenticator+involves%3Aterrencegf+updated%3A2022-06-09..2022-09-08&type=Issues) | [@yuvipanda](https://github.com/search?q=repo%3Ajupyterhub%2Foauthenticator+involves%3Ayuvipanda+updated%3A2022-06-09..2022-09-08&type=Issues) -### 15.0.1 +### [15.0.1] - 2022-06-09 #### Bugs fixed @@ -234,7 +251,7 @@ See [our definition of contributors](https://github-activity.readthedocs.io/en/l [@consideRatio](https://github.com/search?q=repo%3Ajupyterhub%2Foauthenticator+involves%3AconsideRatio+updated%3A2022-06-03..2022-06-09&type=Issues) | [@GeorgianaElena](https://github.com/search?q=repo%3Ajupyterhub%2Foauthenticator+involves%3AGeorgianaElena+updated%3A2022-06-03..2022-06-09&type=Issues) | [@Marcalberga](https://github.com/search?q=repo%3Ajupyterhub%2Foauthenticator+involves%3AMarcalberga+updated%3A2022-06-03..2022-06-09&type=Issues) | [@welcome](https://github.com/search?q=repo%3Ajupyterhub%2Foauthenticator+involves%3Awelcome+updated%3A2022-06-03..2022-06-09&type=Issues) -### 15.0.0 +### [15.0.0] - 2022-06-03 If you are using AzureAD, MediaWiki, and CILogon authenticators, make sure to read about the breaking changes. @@ -700,7 +717,16 @@ It fixes handling of `gitlab_group_whitelist` when using GitLabOAuthenticator. - First release -[unreleased]: https://github.com/jupyterhub/oauthenticator/compare/14.2.0...HEAD +[unreleased]: https://github.com/jupyterhub/oauthenticator/compare/16.0.5...HEAD +[16.0.5]: https://github.com/jupyterhub/oauthenticator/compare/16.0.4...16.0.5 +[16.0.4]: https://github.com/jupyterhub/oauthenticator/compare/16.0.3...16.0.4 +[16.0.3]: https://github.com/jupyterhub/oauthenticator/compare/16.0.2...16.0.3 +[16.0.2]: https://github.com/jupyterhub/oauthenticator/compare/16.0.1...16.0.2 +[16.0.1]: https://github.com/jupyterhub/oauthenticator/compare/16.0.0...16.0.1 +[16.0.0]: https://github.com/jupyterhub/oauthenticator/compare/15.1.0...16.0.0 +[15.1.0]: https://github.com/jupyterhub/oauthenticator/compare/15.0.1...15.1.0 +[15.0.1]: https://github.com/jupyterhub/oauthenticator/compare/15.0.0...15.0.1 +[15.0.0]: https://github.com/jupyterhub/oauthenticator/compare/14.2.0...15.0.0 [14.2.0]: https://github.com/jupyterhub/oauthenticator/compare/14.1.0...14.2.0 [14.1.0]: https://github.com/jupyterhub/oauthenticator/compare/14.0.0...14.1.0 [14.0.0]: https://github.com/jupyterhub/oauthenticator/compare/0.13.0...14.0.0 From 8df638c646539cfecf54277074c5719b43c83c44 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Tue, 15 Aug 2023 13:58:11 +0200 Subject: [PATCH 021/103] Bump to 16.0.5 --- oauthenticator/_version.py | 2 +- pyproject.toml | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/oauthenticator/_version.py b/oauthenticator/_version.py index 91a63af7..280bfeaf 100644 --- a/oauthenticator/_version.py +++ b/oauthenticator/_version.py @@ -1,7 +1,7 @@ # __version__ should be updated using tbump, based on configuration in # pyproject.toml, according to instructions in RELEASE.md. # -__version__ = "16.0.5.dev" +__version__ = "16.0.5" # version_info looks like (1, 2, 3, "dev") if __version__ is 1.2.3.dev version_info = tuple(int(p) if p.isdigit() else p for p in __version__.split(".")) diff --git a/pyproject.toml b/pyproject.toml index 77750c8f..3c175cba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,7 +64,7 @@ omit = [ github_url = "https://github.com/jupyterhub/oauthenticator" [tool.tbump.version] -current = "16.0.5.dev" +current = "16.0.5" regex = ''' (?P\d+) \. diff --git a/setup.py b/setup.py index 43f10b15..5a76095e 100644 --- a/setup.py +++ b/setup.py @@ -27,7 +27,7 @@ def run(self): setup_args = dict( name='oauthenticator', packages=find_packages(), - version="16.0.5.dev", + version="16.0.5", description="OAuthenticator: Authenticate JupyterHub users with common OAuth providers", long_description=open("README.md").read(), long_description_content_type="text/markdown", From 43225f37cb42982c961e75072d5d562be4f6e878 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Tue, 15 Aug 2023 13:58:24 +0200 Subject: [PATCH 022/103] Bump to 16.0.6.dev --- oauthenticator/_version.py | 2 +- pyproject.toml | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/oauthenticator/_version.py b/oauthenticator/_version.py index 280bfeaf..08f3f949 100644 --- a/oauthenticator/_version.py +++ b/oauthenticator/_version.py @@ -1,7 +1,7 @@ # __version__ should be updated using tbump, based on configuration in # pyproject.toml, according to instructions in RELEASE.md. # -__version__ = "16.0.5" +__version__ = "16.0.6.dev" # version_info looks like (1, 2, 3, "dev") if __version__ is 1.2.3.dev version_info = tuple(int(p) if p.isdigit() else p for p in __version__.split(".")) diff --git a/pyproject.toml b/pyproject.toml index 3c175cba..97bd6ad1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,7 +64,7 @@ omit = [ github_url = "https://github.com/jupyterhub/oauthenticator" [tool.tbump.version] -current = "16.0.5" +current = "16.0.6.dev" regex = ''' (?P\d+) \. diff --git a/setup.py b/setup.py index 5a76095e..7246d525 100644 --- a/setup.py +++ b/setup.py @@ -27,7 +27,7 @@ def run(self): setup_args = dict( name='oauthenticator', packages=find_packages(), - version="16.0.5", + version="16.0.6.dev", description="OAuthenticator: Authenticate JupyterHub users with common OAuth providers", long_description=open("README.md").read(), long_description_content_type="text/markdown", From fd36e19388a4b4d3c9c79f0b5accf8b0b5b1a822 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Wed, 16 Aug 2023 04:45:19 +0200 Subject: [PATCH 023/103] Add test to ensure auth_state is JSON serializable (lists are, not sets) --- oauthenticator/tests/test_auth0.py | 2 ++ oauthenticator/tests/test_azuread.py | 2 ++ oauthenticator/tests/test_bitbucket.py | 2 ++ oauthenticator/tests/test_cilogon.py | 1 + oauthenticator/tests/test_generic.py | 2 ++ oauthenticator/tests/test_github.py | 1 + oauthenticator/tests/test_gitlab.py | 4 ++++ oauthenticator/tests/test_globus.py | 1 + oauthenticator/tests/test_google.py | 2 ++ oauthenticator/tests/test_mediawiki.py | 1 + oauthenticator/tests/test_okpy.py | 3 +++ oauthenticator/tests/test_openshift.py | 3 +++ 12 files changed, 24 insertions(+) diff --git a/oauthenticator/tests/test_auth0.py b/oauthenticator/tests/test_auth0.py index 4fd81437..e4ae3be9 100644 --- a/oauthenticator/tests/test_auth0.py +++ b/oauthenticator/tests/test_auth0.py @@ -1,3 +1,4 @@ +import json import logging from unittest.mock import Mock @@ -86,6 +87,7 @@ async def test_auth0( assert set(auth_model) == {"name", "admin", "auth_state"} assert auth_model["admin"] == expect_admin auth_state = auth_model["auth_state"] + assert json.dumps(auth_state) assert "access_token" in auth_state user_info = auth_state[authenticator.user_auth_state_key] assert user_info == handled_user_model diff --git a/oauthenticator/tests/test_azuread.py b/oauthenticator/tests/test_azuread.py index de41cdca..46d9395b 100644 --- a/oauthenticator/tests/test_azuread.py +++ b/oauthenticator/tests/test_azuread.py @@ -1,4 +1,5 @@ """test azure ad""" +import json import os import re import time @@ -132,6 +133,7 @@ async def test_azuread( assert set(auth_model) == {"name", "admin", "auth_state"} assert auth_model["admin"] == expect_admin auth_state = auth_model["auth_state"] + assert json.dumps(auth_state) assert "access_token" in auth_state user_info = auth_state[authenticator.user_auth_state_key] assert user_info["aud"] == authenticator.client_id diff --git a/oauthenticator/tests/test_bitbucket.py b/oauthenticator/tests/test_bitbucket.py index d6fe5deb..512e8030 100644 --- a/oauthenticator/tests/test_bitbucket.py +++ b/oauthenticator/tests/test_bitbucket.py @@ -1,3 +1,4 @@ +import json import logging from pytest import fixture, mark, raises @@ -103,6 +104,7 @@ async def test_bitbucket( assert set(auth_model) == {"name", "admin", "auth_state"} assert auth_model["admin"] == expect_admin auth_state = auth_model["auth_state"] + assert json.dumps(auth_state) assert "access_token" in auth_state user_info = auth_state[authenticator.user_auth_state_key] assert user_info == handled_user_model diff --git a/oauthenticator/tests/test_cilogon.py b/oauthenticator/tests/test_cilogon.py index 83b66f50..06f7bdc0 100644 --- a/oauthenticator/tests/test_cilogon.py +++ b/oauthenticator/tests/test_cilogon.py @@ -92,6 +92,7 @@ async def test_cilogon( assert set(auth_model) == {"name", "admin", "auth_state"} assert auth_model["admin"] == expect_admin auth_state = auth_model["auth_state"] + assert json.dumps(auth_state) assert "access_token" in auth_state assert "token_response" in auth_state user_info = auth_state[authenticator.user_auth_state_key] diff --git a/oauthenticator/tests/test_generic.py b/oauthenticator/tests/test_generic.py index 93bede7a..42dc7781 100644 --- a/oauthenticator/tests/test_generic.py +++ b/oauthenticator/tests/test_generic.py @@ -1,3 +1,4 @@ +import json from functools import partial from pytest import fixture, mark @@ -175,6 +176,7 @@ async def test_generic( assert set(auth_model) == {"name", "admin", "auth_state"} assert auth_model["admin"] == expect_admin auth_state = auth_model["auth_state"] + assert json.dumps(auth_state) assert "access_token" in auth_state assert "oauth_user" in auth_state assert "refresh_token" in auth_state diff --git a/oauthenticator/tests/test_github.py b/oauthenticator/tests/test_github.py index 55b91f48..35b64a46 100644 --- a/oauthenticator/tests/test_github.py +++ b/oauthenticator/tests/test_github.py @@ -90,6 +90,7 @@ async def test_github( assert set(auth_model) == {"name", "admin", "auth_state"} assert auth_model["admin"] == expect_admin auth_state = auth_model["auth_state"] + assert json.dumps(auth_state) assert "access_token" in auth_state user_info = auth_state[authenticator.user_auth_state_key] assert user_info == handled_user_model diff --git a/oauthenticator/tests/test_gitlab.py b/oauthenticator/tests/test_gitlab.py index e9e4ae22..f8d7ad3e 100644 --- a/oauthenticator/tests/test_gitlab.py +++ b/oauthenticator/tests/test_gitlab.py @@ -110,6 +110,7 @@ async def test_gitlab( assert set(auth_model) == {"name", "admin", "auth_state"} assert auth_model["admin"] == expect_admin auth_state = auth_model["auth_state"] + assert json.dumps(auth_state) assert "access_token" in auth_state user_info = auth_state[authenticator.user_auth_state_key] assert user_info == handled_user_model @@ -215,6 +216,7 @@ def _mocked_groups_api_paginated(username, page, urlinfo, response): handler = gitlab_client.handler_for_user(handled_user_model) auth_model = await authenticator.get_authenticated_user(handler, None) assert auth_model + assert json.dumps(auth_model["auth_state"]) handled_user_model = user_model("user-not-in-group") handler = gitlab_client.handler_for_user(handled_user_model) @@ -299,6 +301,7 @@ def mocked_projects_members_api(request): handler = gitlab_client.handler_for_user(developer_user_model) auth_model = await authenticator.get_authenticated_user(handler, None) assert auth_model + assert json.dumps(auth_model["auth_state"]) # Forbidden, project doesn't exist authenticator.allowed_project_ids = [0] @@ -311,6 +314,7 @@ def mocked_projects_members_api(request): handler = gitlab_client.handler_for_user(developer_user_model) auth_model = await authenticator.get_authenticated_user(handler, None) assert auth_model + assert json.dumps(auth_model["auth_state"]) @mark.parametrize( diff --git a/oauthenticator/tests/test_globus.py b/oauthenticator/tests/test_globus.py index 5e542e52..b43daa53 100644 --- a/oauthenticator/tests/test_globus.py +++ b/oauthenticator/tests/test_globus.py @@ -303,6 +303,7 @@ async def test_globus( assert set(auth_model) == {"name", "admin", "auth_state"} assert auth_model["admin"] == expect_admin auth_state = auth_model["auth_state"] + assert json.dumps(auth_state) assert "tokens" in auth_state assert "transfer.api.globus.org" in auth_state["tokens"] user_info = auth_state[authenticator.user_auth_state_key] diff --git a/oauthenticator/tests/test_google.py b/oauthenticator/tests/test_google.py index baf74ba7..8daac076 100644 --- a/oauthenticator/tests/test_google.py +++ b/oauthenticator/tests/test_google.py @@ -1,4 +1,5 @@ import hashlib +import json import logging import re from unittest import mock @@ -176,6 +177,7 @@ async def test_google( assert set(auth_model) == {"name", "admin", "auth_state"} assert auth_model["admin"] == expect_admin auth_state = auth_model["auth_state"] + assert json.dumps(auth_state) assert "access_token" in auth_state user_info = auth_state[authenticator.user_auth_state_key] assert auth_model["name"] == user_info[authenticator.username_claim] diff --git a/oauthenticator/tests/test_mediawiki.py b/oauthenticator/tests/test_mediawiki.py index ce149482..3510b74d 100644 --- a/oauthenticator/tests/test_mediawiki.py +++ b/oauthenticator/tests/test_mediawiki.py @@ -104,6 +104,7 @@ async def test_mediawiki( assert set(auth_model) == {"name", "admin", "auth_state"} assert auth_model["admin"] == expect_admin auth_state = auth_model["auth_state"] + assert json.dumps(auth_state) assert "ACCESS_TOKEN_KEY" in auth_state assert "ACCESS_TOKEN_SECRET" in auth_state user_info = auth_state[authenticator.user_auth_state_key] diff --git a/oauthenticator/tests/test_okpy.py b/oauthenticator/tests/test_okpy.py index 34320284..1d2d027b 100644 --- a/oauthenticator/tests/test_okpy.py +++ b/oauthenticator/tests/test_okpy.py @@ -1,3 +1,5 @@ +import json + from pytest import fixture, mark from traitlets.config import Config @@ -78,6 +80,7 @@ async def test_okpy( assert set(auth_model) == {"name", "admin", "auth_state"} assert auth_model["admin"] == expect_admin auth_state = auth_model["auth_state"] + assert json.dumps(auth_state) assert "access_token" in auth_state user_info = auth_state[authenticator.user_auth_state_key] assert user_info == handled_user_model diff --git a/oauthenticator/tests/test_openshift.py b/oauthenticator/tests/test_openshift.py index b66a6521..07a61423 100644 --- a/oauthenticator/tests/test_openshift.py +++ b/oauthenticator/tests/test_openshift.py @@ -1,3 +1,5 @@ +import json + import pytest from traitlets.config import Config @@ -157,6 +159,7 @@ async def test_openshift( assert auth_model["name"] == handled_user_model["metadata"]["name"] assert auth_model["admin"] == expect_admin auth_state = auth_model["auth_state"] + assert json.dumps(auth_state) assert "access_token" in auth_state user_info = auth_state[authenticator.user_auth_state_key] assert user_info == handled_user_model From 6bd08fbc4a03b6b793e3badde03107aec0b049be Mon Sep 17 00:00:00 2001 From: Min RK Date: Wed, 16 Aug 2023 11:55:35 +0200 Subject: [PATCH 024/103] cast group sets to lists for auth_state so they can be JSONable --- oauthenticator/bitbucket.py | 6 ++++-- oauthenticator/globus.py | 5 +++-- oauthenticator/google.py | 9 ++++----- oauthenticator/tests/test_globus.py | 2 +- oauthenticator/tests/test_google.py | 2 +- 5 files changed, 13 insertions(+), 11 deletions(-) diff --git a/oauthenticator/bitbucket.py b/oauthenticator/bitbucket.py index aa6b771e..0b3b8ecf 100644 --- a/oauthenticator/bitbucket.py +++ b/oauthenticator/bitbucket.py @@ -73,11 +73,13 @@ async def update_auth_model(self, auth_model): Fetch and store `user_teams` in auth state if `allowed_teams` is configured. """ + user_teams = set() if self.allowed_teams: access_token = auth_model["auth_state"]["token_response"]["access_token"] token_type = auth_model["auth_state"]["token_response"]["token_type"] user_teams = await self._fetch_user_teams(access_token, token_type) - auth_model["auth_state"]["user_teams"] = user_teams + # sets are not JSONable, cast to list for auth_state + auth_model["auth_state"]["user_teams"] = list(user_teams) return auth_model @@ -90,7 +92,7 @@ async def check_allowed(self, username, auth_model): return True if self.allowed_teams: - user_teams = auth_model["auth_state"]["user_teams"] + user_teams = set(auth_model["auth_state"].get("user_teams", [])) if any(user_teams & self.allowed_teams): return True diff --git a/oauthenticator/globus.py b/oauthenticator/globus.py index 28ae5d2b..d5e87519 100644 --- a/oauthenticator/globus.py +++ b/oauthenticator/globus.py @@ -313,7 +313,7 @@ async def check_allowed(self, username, auth_model): return True if self.allowed_globus_groups: - user_groups = auth_model["auth_state"]["globus_groups"] + user_groups = set(auth_model["auth_state"]["globus_groups"]) if any(user_groups & self.allowed_globus_groups): return True self.log.warning(f"{username} not in an allowed Globus Group") @@ -335,7 +335,8 @@ async def update_auth_model(self, auth_model): if self.allowed_globus_groups or self.admin_globus_groups: tokens = self.get_globus_tokens(auth_model["auth_state"]["token_response"]) user_groups = await self._fetch_users_groups(tokens) - auth_model["auth_state"]["globus_groups"] = user_groups + # sets are not JSONable, cast to list for auth_state + auth_model["auth_state"]["globus_groups"] = list(user_groups) if auth_model["admin"]: # auth_model["admin"] being True means the user was in admin_users diff --git a/oauthenticator/google.py b/oauthenticator/google.py index d2ceb704..6dd2e3ec 100644 --- a/oauthenticator/google.py +++ b/oauthenticator/google.py @@ -186,10 +186,9 @@ async def update_auth_model(self, auth_model): user_groups = set() if self.allowed_google_groups or self.admin_google_groups: - user_groups = user_info["google_groups"] = self._fetch_user_groups( - user_email, user_domain - ) - user_info["google_groups"] = user_groups + user_groups = self._fetch_user_groups(user_email, user_domain) + # sets are not JSONable, cast to list for auth_state + user_info["google_groups"] = list(user_groups) if auth_model["admin"]: # auth_model["admin"] being True means the user was in admin_users @@ -241,7 +240,7 @@ async def check_allowed(self, username, auth_model): return True if self.allowed_google_groups: - user_groups = user_info["google_groups"] + user_groups = set(user_info["google_groups"]) allowed_groups = self.allowed_google_groups.get(user_domain, set()) if any(user_groups & allowed_groups): return True diff --git a/oauthenticator/tests/test_globus.py b/oauthenticator/tests/test_globus.py index b43daa53..95ade113 100644 --- a/oauthenticator/tests/test_globus.py +++ b/oauthenticator/tests/test_globus.py @@ -309,7 +309,7 @@ async def test_globus( user_info = auth_state[authenticator.user_auth_state_key] assert auth_model["name"] == user_info[authenticator.username_claim] if authenticator.allowed_globus_groups or authenticator.admin_globus_groups: - assert auth_state["globus_groups"] == {"group1"} + assert auth_state["globus_groups"] == ["group1"] else: assert auth_model == None diff --git a/oauthenticator/tests/test_google.py b/oauthenticator/tests/test_google.py index 8daac076..7e23966c 100644 --- a/oauthenticator/tests/test_google.py +++ b/oauthenticator/tests/test_google.py @@ -182,7 +182,7 @@ async def test_google( user_info = auth_state[authenticator.user_auth_state_key] assert auth_model["name"] == user_info[authenticator.username_claim] if authenticator.allowed_google_groups or authenticator.admin_google_groups: - assert user_info["google_groups"] == {"group1"} + assert user_info["google_groups"] == ["group1"] else: assert auth_model == None From 224426d66fd187aff2613621d72094e0c0229dbf Mon Sep 17 00:00:00 2001 From: Min RK Date: Wed, 16 Aug 2023 11:57:26 +0200 Subject: [PATCH 025/103] fix set intersection tests `any` checks is any item inside the set is truthy, when we want to check if the set is non-empty. No results are likely to change in practice (only the set `{''}` would have a different result), but the logic was not correct in principle. --- oauthenticator/bitbucket.py | 2 +- oauthenticator/generic.py | 2 +- oauthenticator/globus.py | 4 ++-- oauthenticator/google.py | 4 ++-- oauthenticator/openshift.py | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/oauthenticator/bitbucket.py b/oauthenticator/bitbucket.py index 0b3b8ecf..8221bbfa 100644 --- a/oauthenticator/bitbucket.py +++ b/oauthenticator/bitbucket.py @@ -93,7 +93,7 @@ async def check_allowed(self, username, auth_model): if self.allowed_teams: user_teams = set(auth_model["auth_state"].get("user_teams", [])) - if any(user_teams & self.allowed_teams): + if user_teams & self.allowed_teams: return True # users should be explicitly allowed via config, otherwise they aren't diff --git a/oauthenticator/generic.py b/oauthenticator/generic.py index c0865b15..3f2987c8 100644 --- a/oauthenticator/generic.py +++ b/oauthenticator/generic.py @@ -159,7 +159,7 @@ async def update_auth_model(self, auth_model): # admin status should in this case be True or False, not None user_info = auth_model["auth_state"][self.user_auth_state_key] user_groups = self.get_user_groups(user_info) - auth_model["admin"] = any(user_groups & self.admin_groups) + auth_model["admin"] = bool(user_groups & self.admin_groups) return auth_model diff --git a/oauthenticator/globus.py b/oauthenticator/globus.py index d5e87519..1e19a80d 100644 --- a/oauthenticator/globus.py +++ b/oauthenticator/globus.py @@ -314,7 +314,7 @@ async def check_allowed(self, username, auth_model): if self.allowed_globus_groups: user_groups = set(auth_model["auth_state"]["globus_groups"]) - if any(user_groups & self.allowed_globus_groups): + if user_groups & self.allowed_globus_groups: return True self.log.warning(f"{username} not in an allowed Globus Group") @@ -344,7 +344,7 @@ async def update_auth_model(self, auth_model): if self.admin_globus_groups: # admin status should in this case be True or False, not None - auth_model["admin"] = any(user_groups & self.admin_globus_groups) + auth_model["admin"] = bool(user_groups & self.admin_globus_groups) return auth_model diff --git a/oauthenticator/google.py b/oauthenticator/google.py index 6dd2e3ec..a72686f2 100644 --- a/oauthenticator/google.py +++ b/oauthenticator/google.py @@ -197,7 +197,7 @@ async def update_auth_model(self, auth_model): if self.admin_google_groups: # admin status should in this case be True or False, not None admin_groups = self.admin_google_groups.get(user_domain, set()) - auth_model["admin"] = any(user_groups & admin_groups) + auth_model["admin"] = bool(user_groups & admin_groups) return auth_model @@ -242,7 +242,7 @@ async def check_allowed(self, username, auth_model): if self.allowed_google_groups: user_groups = set(user_info["google_groups"]) allowed_groups = self.allowed_google_groups.get(user_domain, set()) - if any(user_groups & allowed_groups): + if user_groups & allowed_groups: return True # users should be explicitly allowed via config, otherwise they aren't diff --git a/oauthenticator/openshift.py b/oauthenticator/openshift.py index df77bbda..d7c73fc6 100644 --- a/oauthenticator/openshift.py +++ b/oauthenticator/openshift.py @@ -161,7 +161,7 @@ async def update_auth_model(self, auth_model): # admin status should in this case be True or False, not None user_info = auth_model["auth_state"][self.user_auth_state_key] user_groups = set(user_info["groups"]) - auth_model["admin"] = any(user_groups & self.admin_groups) + auth_model["admin"] = bool(user_groups & self.admin_groups) return auth_model @@ -176,7 +176,7 @@ async def check_allowed(self, username, auth_model): if self.allowed_groups: user_info = auth_model["auth_state"][self.user_auth_state_key] user_groups = set(user_info["groups"]) - if any(user_groups & self.allowed_groups): + if user_groups & self.allowed_groups: return True # users should be explicitly allowed via config, otherwise they aren't From 7af6659a5d4c6b16ca6ebc2981c5d2223a559f19 Mon Sep 17 00:00:00 2001 From: Tim Collins <45351296+tico24@users.noreply.github.com> Date: Thu, 17 Aug 2023 07:30:19 +0100 Subject: [PATCH 026/103] docs: correct typo --- .../tutorials/provider-specific-setup/providers/github.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/tutorials/provider-specific-setup/providers/github.md b/docs/source/tutorials/provider-specific-setup/providers/github.md index c5f4f605..88f71a04 100644 --- a/docs/source/tutorials/provider-specific-setup/providers/github.md +++ b/docs/source/tutorials/provider-specific-setup/providers/github.md @@ -1,7 +1,7 @@ # GitHub Setup You need to have an GitHub OAuth application registered ahead of time, see -GitLab's official documentation about [registering an app]. +GitHub's official documentation about [registering an app]. [registering an app]: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app From 64d8f340acd7e4a1ac1b29cb24b888eb998e6503 Mon Sep 17 00:00:00 2001 From: Min RK Date: Thu, 17 Aug 2023 09:52:26 +0200 Subject: [PATCH 027/103] changelog for 16.0.6 --- docs/source/reference/changelog.md | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/docs/source/reference/changelog.md b/docs/source/reference/changelog.md index c0785e6a..7d99aa60 100644 --- a/docs/source/reference/changelog.md +++ b/docs/source/reference/changelog.md @@ -8,6 +8,28 @@ command line for details. ## 16.0 +### [16.0.6] - 2023-08-17 + +16.0.6 is a bugfix release, fixing a crash on startup when combining enable_auth_state with Google, Globus, or Bitbucket. +The group membership fields are lists, which were switched to sets in 16.0, but that is not allowed by JupyterHub's JSON serialization of auth_state. + +#### Bugs fixed + +- [Google, Globus, Bitbucket] Ensure auth_state is JSON serializable (lists are, not sets) [#668](https://github.com/jupyterhub/oauthenticator/pull/668) ([@consideRatio](https://github.com/consideRatio), [@minrk](https://github.com/minrk)) + +#### Documentation improvements + +- GitHub/GitLab typo [#669](https://github.com/jupyterhub/oauthenticator/pull/669) ([@tico24](https://github.com/tico24), [@consideRatio](https://github.com/consideRatio)) + +#### Contributors to this release + +The following people contributed discussions, new ideas, code and documentation contributions, and review. +See [our definition of contributors](https://github-activity.readthedocs.io/en/latest/#how-does-this-tool-define-contributions-in-the-reports). + +([GitHub contributors page for this release](https://github.com/jupyterhub/oauthenticator/graphs/contributors?from=2023-08-15&to=2023-08-17&type=c)) + +@consideRatio ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Foauthenticator+involves%3AconsideRatio+updated%3A2023-08-15..2023-08-17&type=Issues)) | @minrk ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Foauthenticator+involves%3Aminrk+updated%3A2023-08-15..2023-08-17&type=Issues)) | @tico24 ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Foauthenticator+involves%3Atico24+updated%3A2023-08-15..2023-08-17&type=Issues)) + ### [16.0.5] - 2023-08-15 ([full changelog](https://github.com/jupyterhub/oauthenticator/compare/16.0.4...16.0.5)) @@ -108,7 +130,7 @@ and new changes in 16.0.2, please upgrade directly to 16.0.2 or higher. configuration instead of List based configuration. It is still possible to set these with lists as as they are converted to sets automatically, but anyone reading and adding entries must now use set logic and not list logic. -- [Google] Authentication state's `google_groups` is now a set, not a list. +- [Google] Authentication state's `google_groups` is now a set, not a list. (reverted in 16.0.6 as JupyterHub's auth_state must be JSON-serializable and doesn't allow sets) - [CILogon] {attr}`.CILogonOAuthenticator.allowed_idps` is now required config, and `shown_idps`, `username_claim`, `additional_username_claims` were removed. - [Okpy] The public functions `OkpyOAuthenticator.get_auth_request` and @@ -717,7 +739,8 @@ It fixes handling of `gitlab_group_whitelist` when using GitLabOAuthenticator. - First release -[unreleased]: https://github.com/jupyterhub/oauthenticator/compare/16.0.5...HEAD +[unreleased]: https://github.com/jupyterhub/oauthenticator/compare/16.0.6...HEAD +[16.0.6]: https://github.com/jupyterhub/oauthenticator/compare/16.0.5...16.0.6 [16.0.5]: https://github.com/jupyterhub/oauthenticator/compare/16.0.4...16.0.5 [16.0.4]: https://github.com/jupyterhub/oauthenticator/compare/16.0.3...16.0.4 [16.0.3]: https://github.com/jupyterhub/oauthenticator/compare/16.0.2...16.0.3 From 1dbc034c067957ffa77ed1bded3e1472d00ca82b Mon Sep 17 00:00:00 2001 From: Min RK Date: Thu, 17 Aug 2023 11:21:55 +0200 Subject: [PATCH 028/103] Bump to 16.0.6 --- oauthenticator/_version.py | 2 +- pyproject.toml | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/oauthenticator/_version.py b/oauthenticator/_version.py index 08f3f949..38db5655 100644 --- a/oauthenticator/_version.py +++ b/oauthenticator/_version.py @@ -1,7 +1,7 @@ # __version__ should be updated using tbump, based on configuration in # pyproject.toml, according to instructions in RELEASE.md. # -__version__ = "16.0.6.dev" +__version__ = "16.0.6" # version_info looks like (1, 2, 3, "dev") if __version__ is 1.2.3.dev version_info = tuple(int(p) if p.isdigit() else p for p in __version__.split(".")) diff --git a/pyproject.toml b/pyproject.toml index 97bd6ad1..0549dbf5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,7 +64,7 @@ omit = [ github_url = "https://github.com/jupyterhub/oauthenticator" [tool.tbump.version] -current = "16.0.6.dev" +current = "16.0.6" regex = ''' (?P\d+) \. diff --git a/setup.py b/setup.py index 7246d525..dbc2c339 100644 --- a/setup.py +++ b/setup.py @@ -27,7 +27,7 @@ def run(self): setup_args = dict( name='oauthenticator', packages=find_packages(), - version="16.0.6.dev", + version="16.0.6", description="OAuthenticator: Authenticate JupyterHub users with common OAuth providers", long_description=open("README.md").read(), long_description_content_type="text/markdown", From 2669fc2a7c33c891ce2c4113c40762dcc72753a5 Mon Sep 17 00:00:00 2001 From: Min RK Date: Thu, 17 Aug 2023 11:22:09 +0200 Subject: [PATCH 029/103] Bump to 16.0.7.dev --- oauthenticator/_version.py | 2 +- pyproject.toml | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/oauthenticator/_version.py b/oauthenticator/_version.py index 38db5655..7d2fa004 100644 --- a/oauthenticator/_version.py +++ b/oauthenticator/_version.py @@ -1,7 +1,7 @@ # __version__ should be updated using tbump, based on configuration in # pyproject.toml, according to instructions in RELEASE.md. # -__version__ = "16.0.6" +__version__ = "16.0.7.dev" # version_info looks like (1, 2, 3, "dev") if __version__ is 1.2.3.dev version_info = tuple(int(p) if p.isdigit() else p for p in __version__.split(".")) diff --git a/pyproject.toml b/pyproject.toml index 0549dbf5..0887ab6c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,7 +64,7 @@ omit = [ github_url = "https://github.com/jupyterhub/oauthenticator" [tool.tbump.version] -current = "16.0.6" +current = "16.0.7.dev" regex = ''' (?P\d+) \. diff --git a/setup.py b/setup.py index dbc2c339..9c160174 100644 --- a/setup.py +++ b/setup.py @@ -27,7 +27,7 @@ def run(self): setup_args = dict( name='oauthenticator', packages=find_packages(), - version="16.0.6", + version="16.0.7.dev", description="OAuthenticator: Authenticate JupyterHub users with common OAuth providers", long_description=open("README.md").read(), long_description_content_type="text/markdown", From da243f117ea20a5bf41772fd4e179a0df7aef0bb Mon Sep 17 00:00:00 2001 From: John P Mayer Jr Date: Thu, 17 Aug 2023 22:16:06 -0400 Subject: [PATCH 030/103] Drop next_url from authorize_redirect state param The next_url is ONLY stored in the cookie which is set by the login handler and read/cleared by the callback handler. Tests added for the login handler, placeholders added for the callback handler. --- oauthenticator/oauth2.py | 46 ++++++++++++++------------ oauthenticator/tests/test_oauth2.py | 50 +++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 20 deletions(-) diff --git a/oauthenticator/oauth2.py b/oauthenticator/oauth2.py index ca4147f7..bc09dffc 100644 --- a/oauthenticator/oauth2.py +++ b/oauthenticator/oauth2.py @@ -70,13 +70,16 @@ def _OAUTH_ACCESS_TOKEN_URL(self): def _OAUTH_USERINFO_URL(self): return self.authenticator.userdata_url - def set_state_cookie(self, state): - self._set_cookie(STATE_COOKIE_NAME, state, expires_days=1, httponly=True) + def set_state_cookie(self, state_cookie_value): + self._set_cookie( + STATE_COOKIE_NAME, state_cookie_value, expires_days=1, httponly=True + ) - _state = None + def _generate_state_id(self): + return uuid.uuid4().hex - def get_state(self): - next_url = original_next_url = self.get_argument("next", None) + def _get_next_url(self): + next_url = self.get_argument("next", None) if next_url: # avoid browsers treating \ as / next_url = next_url.replace("\\", quote("\\")) @@ -86,23 +89,22 @@ def get_state(self): next_url = urlinfo._replace( scheme="", netloc="", path="/" + urlinfo.path.lstrip("/") ).geturl() - if next_url != original_next_url: - self.log.warning( - f"Ignoring next_url {original_next_url}, using {next_url}" - ) - if self._state is None: - self._state = _serialize_state( - {"state_id": uuid.uuid4().hex, "next_url": next_url} - ) - return self._state + return next_url def get(self): redirect_uri = self.authenticator.get_callback_url(self) token_params = self.authenticator.extra_authorize_params.copy() self.log.info(f"OAuth redirect: {redirect_uri}") - state = self.get_state() - self.set_state_cookie(state) - token_params["state"] = state + + state_id = self._generate_state_id() + next_url = self._get_next_url() + + cookie_state = _serialize_state({"state_id": state_id, "next_url": next_url}) + self.set_state_cookie(cookie_state) + + authorize_state = _serialize_state({"state_id": state_id}) + token_params["state"] = authorize_state + self.authorize_redirect( redirect_uri=redirect_uri, client_id=self.authenticator.client_id, @@ -147,8 +149,12 @@ def check_state(self): raise web.HTTPError(400, "OAuth state missing from cookies") if not url_state: raise web.HTTPError(400, "OAuth state missing from URL") - if cookie_state != url_state: - self.log.warning(f"OAuth state mismatch: {cookie_state} != {url_state}") + cookie_state_id = _deserialize_state(cookie_state).get('state_id') + url_state_id = _deserialize_state(url_state).get('state_id') + if cookie_state_id != url_state_id: + self.log.warning( + f"OAuth state mismatch: {cookie_state_id} != {url_state_id}" + ) raise web.HTTPError(400, "OAuth state mismatch") def check_error(self): @@ -187,7 +193,7 @@ def append_query_parameters(self, url, exclude=None): def get_next_url(self, user=None): """Get the redirect target from the state field""" - state = self.get_state_url() + state = self.get_state_cookie() if state: next_url = _deserialize_state(state).get("next_url") if next_url: diff --git a/oauthenticator/tests/test_oauth2.py b/oauthenticator/tests/test_oauth2.py index 06600e1b..e62727b9 100644 --- a/oauthenticator/tests/test_oauth2.py +++ b/oauthenticator/tests/test_oauth2.py @@ -8,6 +8,7 @@ from ..oauth2 import ( STATE_COOKIE_NAME, OAuthenticator, + OAuthLoginHandler, OAuthLogoutHandler, _deserialize_state, _serialize_state, @@ -26,6 +27,55 @@ async def test_serialize_state(): assert state2 == state1 +def test_login_states(): + login_url = "http://myhost/login" + login_request_uri = "http://myhost/login?next=/ABC" + authenticator = OAuthenticator() + login_handler = mock_handler( + OAuthLoginHandler, + uri=login_request_uri, + authenticator=authenticator, + login_url=login_url, + ) + + state_id = '66383228bb924e9bb8a8ff9e311b7966' + login_handler._generate_state_id = Mock(return_value=state_id) + + login_handler.set_state_cookie = Mock() + login_handler.authorize_redirect = Mock() + + login_handler.get() # no await, we've mocked the authorizer_redirect to NOT be async + + expected_cookie_value = _serialize_state( + { + 'state_id': state_id, + 'next_url': '/ABC', + } + ) + + login_handler.set_state_cookie.assert_called_once_with(expected_cookie_value) + + expected_state_param_value = _serialize_state( + { + 'state_id': state_id, + } + ) + + login_handler.authorize_redirect.assert_called_once() + assert ( + login_handler.authorize_redirect.call_args.kwargs['extra_params']['state'] + == expected_state_param_value + ) + + +def test_callback_check_states_match(): + raise NotImplementedError + + +def test_callback_check_states_nomatch(): + raise NotImplementedError + + async def test_custom_logout(monkeypatch): login_url = "http://myhost/login" authenticator = OAuthenticator() From 5f4974833afb014b4ec2a765aff31bb82eef3250 Mon Sep 17 00:00:00 2001 From: John P Mayer Jr Date: Fri, 18 Aug 2023 10:27:08 -0400 Subject: [PATCH 031/103] Added additional tests for checking state test_callback_check_states_match - asserts the redirect is called test_callback_check_states_nomatch - asserts the exception is raised --- oauthenticator/tests/mocks.py | 4 ++ oauthenticator/tests/test_oauth2.py | 76 +++++++++++++++++++++++------ 2 files changed, 65 insertions(+), 15 deletions(-) diff --git a/oauthenticator/tests/mocks.py b/oauthenticator/tests/mocks.py index b20af39e..83909eb2 100644 --- a/oauthenticator/tests/mocks.py +++ b/oauthenticator/tests/mocks.py @@ -248,6 +248,10 @@ def mock_handler(Handler, uri='https://hub.example.com', method='GET', **setting return handler +async def mock_login_user_coro(): + return True + + async def no_code_test(authenticator): """Run a test to exercise no code in the request""" handler = Mock(spec=web.RequestHandler) diff --git a/oauthenticator/tests/test_oauth2.py b/oauthenticator/tests/test_oauth2.py index e62727b9..4386e92c 100644 --- a/oauthenticator/tests/test_oauth2.py +++ b/oauthenticator/tests/test_oauth2.py @@ -2,18 +2,20 @@ import uuid from unittest.mock import Mock, PropertyMock -from pytest import mark +from pytest import mark, raises +from tornado.web import HTTPError from traitlets.config import Config from ..oauth2 import ( STATE_COOKIE_NAME, + OAuthCallbackHandler, OAuthenticator, OAuthLoginHandler, OAuthLogoutHandler, _deserialize_state, _serialize_state, ) -from .mocks import mock_handler +from .mocks import mock_handler, mock_login_user_coro async def test_serialize_state(): @@ -27,19 +29,20 @@ async def test_serialize_state(): assert state2 == state1 -def test_login_states(): - login_url = "http://myhost/login" - login_request_uri = "http://myhost/login?next=/ABC" +TEST_STATE_ID = '123' +TEST_NEXT_URL = '/ABC' + + +async def test_login_states(): + login_request_uri = f"http://myhost/login?next={TEST_NEXT_URL}" authenticator = OAuthenticator() login_handler = mock_handler( OAuthLoginHandler, uri=login_request_uri, authenticator=authenticator, - login_url=login_url, ) - state_id = '66383228bb924e9bb8a8ff9e311b7966' - login_handler._generate_state_id = Mock(return_value=state_id) + login_handler._generate_state_id = Mock(return_value=TEST_STATE_ID) login_handler.set_state_cookie = Mock() login_handler.authorize_redirect = Mock() @@ -48,8 +51,8 @@ def test_login_states(): expected_cookie_value = _serialize_state( { - 'state_id': state_id, - 'next_url': '/ABC', + 'state_id': TEST_STATE_ID, + 'next_url': TEST_NEXT_URL, } ) @@ -57,7 +60,7 @@ def test_login_states(): expected_state_param_value = _serialize_state( { - 'state_id': state_id, + 'state_id': TEST_STATE_ID, } ) @@ -68,12 +71,55 @@ def test_login_states(): ) -def test_callback_check_states_match(): - raise NotImplementedError +async def test_callback_check_states_match(monkeypatch): + url_state = _serialize_state({'state_id': TEST_STATE_ID}) + callback_request_uri = f"http://myhost/callback?code=123&state={url_state}" + + cookie_state = _serialize_state( + { + 'state_id': TEST_STATE_ID, + 'next_url': TEST_NEXT_URL, + } + ) + + authenticator = OAuthenticator() + callback_handler = mock_handler( + OAuthCallbackHandler, + uri=callback_request_uri, + authenticator=authenticator, + ) + + callback_handler.get_secure_cookie = Mock(return_value=cookie_state.encode('utf8')) + callback_handler.login_user = Mock(return_value=mock_login_user_coro()) + callback_handler.redirect = Mock() + + await callback_handler.get() + + callback_handler.redirect.assert_called_once_with('/ABC') + + +async def test_callback_check_states_nomatch(): + wrong_url_state = _serialize_state({'state_id': 'wr0ng'}) + callback_request_uri = f"http://myhost/callback?code=123&state={wrong_url_state}" + + cookie_state = _serialize_state( + { + 'state_id': TEST_STATE_ID, + 'next_url': TEST_NEXT_URL, + } + ) + + authenticator = OAuthenticator() + callback_handler = mock_handler( + OAuthCallbackHandler, + uri=callback_request_uri, + authenticator=authenticator, + ) + callback_handler.get_secure_cookie = Mock(return_value=cookie_state.encode('utf8')) -def test_callback_check_states_nomatch(): - raise NotImplementedError + with raises(HTTPError, match="OAuth state mismatch"): + await callback_handler.get() async def test_custom_logout(monkeypatch): From 0daeabd4d75b0882d2fc7f238416527b7fbcd462 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Mon, 21 Aug 2023 15:19:36 +0200 Subject: [PATCH 032/103] fix, google: admin_users should list final usernames The `hosted_domain` config can be used to strip a domain from a username. This commit fixes a bug where `admin_users` config was looked up before the domain had been stripped. The bug is presumed to be a regression introduced in 16.0.0. --- oauthenticator/google.py | 24 ++++++++++++++++++------ oauthenticator/tests/test_google.py | 11 ++++++++++- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/oauthenticator/google.py b/oauthenticator/google.py index a72686f2..49f506d0 100644 --- a/oauthenticator/google.py +++ b/oauthenticator/google.py @@ -162,6 +162,23 @@ def _cast_hosted_domain(self, proposal): """, ) + def user_info_to_username(self, user_info): + """ + Overrides the default implementation to conditionally also strip the + user email's domain name from the username based on the hosted_domain + configuration. The domain saved to user_info for use by authorization + logic. + """ + username = super().user_info_to_username(user_info) + user_email = user_info["email"] + user_domain = user_info["domain"] = user_email.split("@")[1].lower() + + if len(self.hosted_domain) == 1 and self.hosted_domain[0] == user_domain: + # unambiguous domain, use only base name + username = username.split("@")[0] + + return username + async def update_auth_model(self, auth_model): """ Fetch and store `google_groups` in auth state if `allowed_google_groups` @@ -177,12 +194,7 @@ async def update_auth_model(self, auth_model): """ user_info = auth_model["auth_state"][self.user_auth_state_key] user_email = user_info["email"] - user_domain = user_info["domain"] = user_email.split("@")[1].lower() - - if len(self.hosted_domain) == 1 and self.hosted_domain[0] == user_domain: - # unambiguous domain, use only base name - username = user_email.split('@')[0] - auth_model["name"] = username + user_domain = user_info["domain"] user_groups = set() if self.allowed_google_groups or self.admin_google_groups: diff --git a/oauthenticator/tests/test_google.py b/oauthenticator/tests/test_google.py index 7e23966c..28adeb0c 100644 --- a/oauthenticator/tests/test_google.py +++ b/oauthenticator/tests/test_google.py @@ -195,7 +195,8 @@ async def test_hosted_domain_single_entry(google_client): """ c = Config() c.GoogleOAuthenticator.hosted_domain = ["In-Hosted-Domain.com"] - c.GoogleOAuthenticator.allow_all = True + c.GoogleOAuthenticator.admin_users = {"user1"} + c.GoogleOAuthenticator.allowed_users = {"user2"} authenticator = GoogleOAuthenticator(config=c) handled_user_model = user_model("user1@iN-hosteD-domaiN.com") @@ -203,6 +204,14 @@ async def test_hosted_domain_single_entry(google_client): auth_model = await authenticator.get_authenticated_user(handler, None) assert auth_model assert auth_model["name"] == "user1" + assert auth_model["admin"] == True + + handled_user_model = user_model("user2@iN-hosteD-domaiN.com") + handler = google_client.handler_for_user(handled_user_model) + auth_model = await authenticator.get_authenticated_user(handler, None) + assert auth_model + assert auth_model["name"] == "user2" + assert auth_model["admin"] == None handled_user_model = user_model("user1@not-in-hosted-domain.com") handler = google_client.handler_for_user(handled_user_model) From f2c1f2dc89722ad6424fc6997807ab2c8bdcf7ef Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Mon, 21 Aug 2023 15:51:44 +0200 Subject: [PATCH 033/103] Add changelog for 16.0.7 --- docs/source/reference/changelog.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/docs/source/reference/changelog.md b/docs/source/reference/changelog.md index 7d99aa60..9ba93086 100644 --- a/docs/source/reference/changelog.md +++ b/docs/source/reference/changelog.md @@ -8,6 +8,21 @@ command line for details. ## 16.0 +### [16.0.7] - 2023-08-21 + +#### Bugs fixed + +- [Google] admin_users should like before v16 list final usernames [#673](https://github.com/jupyterhub/oauthenticator/pull/673) ([@consideRatio](https://github.com/consideRatio), [@jinserk](https://github.com/jinserk), [@GeorgianaElena](https://github.com/GeorgianaElena)) + +#### Contributors to this release + +The following people contributed discussions, new ideas, code and documentation contributions, and review. +See [our definition of contributors](https://github-activity.readthedocs.io/en/latest/#how-does-this-tool-define-contributions-in-the-reports). + +([GitHub contributors page for this release](https://github.com/jupyterhub/oauthenticator/graphs/contributors?from=2023-08-15&to=2023-08-17&type=c)) + +@consideRatio ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Foauthenticator+involves%3AconsideRatio+updated%3A2023-08-15..2023-08-21&type=Issues)) | @jinserk ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Foauthenticator+involves%3Ajinserk+updated%3A2023-08-15..2023-08-21&type=Issues)) | @GeorgianaElena ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Foauthenticator+involves%3AGeorgianaElena+updated%3A2023-08-15..2023-08-21&type=Issues)) + ### [16.0.6] - 2023-08-17 16.0.6 is a bugfix release, fixing a crash on startup when combining enable_auth_state with Google, Globus, or Bitbucket. @@ -739,7 +754,8 @@ It fixes handling of `gitlab_group_whitelist` when using GitLabOAuthenticator. - First release -[unreleased]: https://github.com/jupyterhub/oauthenticator/compare/16.0.6...HEAD +[unreleased]: https://github.com/jupyterhub/oauthenticator/compare/16.0.7...HEAD +[16.0.7]: https://github.com/jupyterhub/oauthenticator/compare/16.0.6...16.0.7 [16.0.6]: https://github.com/jupyterhub/oauthenticator/compare/16.0.5...16.0.6 [16.0.5]: https://github.com/jupyterhub/oauthenticator/compare/16.0.4...16.0.5 [16.0.4]: https://github.com/jupyterhub/oauthenticator/compare/16.0.3...16.0.4 From 3eadab3ecba70fffac1d12529957df7d4f35b54e Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Mon, 21 Aug 2023 17:06:39 +0200 Subject: [PATCH 034/103] Bump to 16.0.7 --- oauthenticator/_version.py | 2 +- pyproject.toml | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/oauthenticator/_version.py b/oauthenticator/_version.py index 7d2fa004..f4090760 100644 --- a/oauthenticator/_version.py +++ b/oauthenticator/_version.py @@ -1,7 +1,7 @@ # __version__ should be updated using tbump, based on configuration in # pyproject.toml, according to instructions in RELEASE.md. # -__version__ = "16.0.7.dev" +__version__ = "16.0.7" # version_info looks like (1, 2, 3, "dev") if __version__ is 1.2.3.dev version_info = tuple(int(p) if p.isdigit() else p for p in __version__.split(".")) diff --git a/pyproject.toml b/pyproject.toml index 0887ab6c..df585806 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,7 +64,7 @@ omit = [ github_url = "https://github.com/jupyterhub/oauthenticator" [tool.tbump.version] -current = "16.0.7.dev" +current = "16.0.7" regex = ''' (?P\d+) \. diff --git a/setup.py b/setup.py index 9c160174..d43a0870 100644 --- a/setup.py +++ b/setup.py @@ -27,7 +27,7 @@ def run(self): setup_args = dict( name='oauthenticator', packages=find_packages(), - version="16.0.7.dev", + version="16.0.7", description="OAuthenticator: Authenticate JupyterHub users with common OAuth providers", long_description=open("README.md").read(), long_description_content_type="text/markdown", From f38a2d45fc30ea8301084825b0e619959b447048 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Mon, 21 Aug 2023 17:07:09 +0200 Subject: [PATCH 035/103] Bump to 16.0.8.dev --- oauthenticator/_version.py | 2 +- pyproject.toml | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/oauthenticator/_version.py b/oauthenticator/_version.py index f4090760..f392c9fa 100644 --- a/oauthenticator/_version.py +++ b/oauthenticator/_version.py @@ -1,7 +1,7 @@ # __version__ should be updated using tbump, based on configuration in # pyproject.toml, according to instructions in RELEASE.md. # -__version__ = "16.0.7" +__version__ = "16.0.8.dev" # version_info looks like (1, 2, 3, "dev") if __version__ is 1.2.3.dev version_info = tuple(int(p) if p.isdigit() else p for p in __version__.split(".")) diff --git a/pyproject.toml b/pyproject.toml index df585806..79c19a1f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,7 +64,7 @@ omit = [ github_url = "https://github.com/jupyterhub/oauthenticator" [tool.tbump.version] -current = "16.0.7" +current = "16.0.8.dev" regex = ''' (?P\d+) \. diff --git a/setup.py b/setup.py index d43a0870..2e31f813 100644 --- a/setup.py +++ b/setup.py @@ -27,7 +27,7 @@ def run(self): setup_args = dict( name='oauthenticator', packages=find_packages(), - version="16.0.7", + version="16.0.8.dev", description="OAuthenticator: Authenticate JupyterHub users with common OAuth providers", long_description=open("README.md").read(), long_description_content_type="text/markdown", From ddb924174d470e219aff3dbcfc48ef519cbdbcac Mon Sep 17 00:00:00 2001 From: Jonathan Radas Date: Tue, 29 Aug 2023 13:25:14 +0200 Subject: [PATCH 036/103] fix f-string in basic auth --- oauthenticator/oauth2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oauthenticator/oauth2.py b/oauthenticator/oauth2.py index ca4147f7..fc93c042 100644 --- a/oauthenticator/oauth2.py +++ b/oauthenticator/oauth2.py @@ -735,7 +735,7 @@ def build_token_info_request_headers(self): if self.basic_auth: b64key = base64.b64encode( - bytes("{self.client_id}:{self.client_secret}", "utf8") + bytes(f"{self.client_id}:{self.client_secret}", "utf8") ) headers.update({"Authorization": f'Basic {b64key.decode("utf8")}'}) return headers From e5c22cb303cb094e37f08f66618a7441a3c246bf Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 5 Sep 2023 08:10:14 +0000 Subject: [PATCH 037/103] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/PyCQA/autoflake: v2.2.0 → v2.2.1](https://github.com/PyCQA/autoflake/compare/v2.2.0...v2.2.1) - [github.com/pre-commit/mirrors-prettier: v3.0.0 → v3.0.3](https://github.com/pre-commit/mirrors-prettier/compare/v3.0.0...v3.0.3) --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3407cebe..b121b74c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -19,7 +19,7 @@ repos: # Autoformat: Python code - repo: https://github.com/PyCQA/autoflake - rev: v2.2.0 + rev: v2.2.1 hooks: - id: autoflake # args ref: https://github.com/PyCQA/autoflake#advanced-usage @@ -40,7 +40,7 @@ repos: # Autoformat: markdown, yaml - repo: https://github.com/pre-commit/mirrors-prettier - rev: v3.0.0 + rev: v3.0.3 hooks: - id: prettier From e3b183ac375e03832489f9015a9bffcef87d61fa Mon Sep 17 00:00:00 2001 From: Micah Halter Date: Wed, 13 Sep 2023 12:57:02 -0400 Subject: [PATCH 038/103] docs: fix `"generic"` renamed to `"generic-oauth"` --- docs/source/how-to/writing-an-oauthenticator.md | 2 +- .../provider-specific-setup/providers/generic.md | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/source/how-to/writing-an-oauthenticator.md b/docs/source/how-to/writing-an-oauthenticator.md index 0540061d..c3288d5f 100644 --- a/docs/source/how-to/writing-an-oauthenticator.md +++ b/docs/source/how-to/writing-an-oauthenticator.md @@ -21,7 +21,7 @@ and configuration to set the necessary configuration variables. Example config: ```python -c.JupyterHub.authenticator_class = "generic" +c.JupyterHub.authenticator_class = "generic-oauth" c.GenericOAuthenticator.oauth_callback_url = 'https://{host}/hub/oauth_callback' c.GenericOAuthenticator.client_id = 'OAUTH-CLIENT-ID' diff --git a/docs/source/tutorials/provider-specific-setup/providers/generic.md b/docs/source/tutorials/provider-specific-setup/providers/generic.md index 49e51dc0..386116af 100644 --- a/docs/source/tutorials/provider-specific-setup/providers/generic.md +++ b/docs/source/tutorials/provider-specific-setup/providers/generic.md @@ -10,7 +10,7 @@ The GenericOAuthenticator can be configured to be used against an OpenID Connect (OIDC) based identity provider, and this is an example demonstrating that. ```python -c.JupyterHub.authenticator_class = "generic" +c.JupyterHub.authenticator_class = "generic-oauth" # OAuth2 application info # ----------------------- @@ -55,7 +55,7 @@ Moodle. Use the `GenericOAuthenticator` for Jupyterhub by editing your `jupyterhub_config.py` accordingly: ```python -c.JupyterHub.authenticator_class = "generic" +c.JupyterHub.authenticator_class = "generic-oauth" c.GenericOAuthenticator.oauth_callback_url = 'https://YOUR-JUPYTERHUB.com/hub/oauth_callback' c.GenericOAuthenticator.client_id = 'MOODLE-CLIENT-ID' @@ -80,7 +80,7 @@ Use the `GenericOAuthenticator` for Jupyterhub by editing your `jupyterhub_config.py` accordingly: ```python -c.JupyterHub.authenticator_class = "generic" +c.JupyterHub.authenticator_class = "generic-oauth" c.GenericOAuthenticator.client_id = 'NEXTCLOUD-CLIENT-ID' c.GenericOAuthenticator.client_secret = 'NEXTCLOUD-CLIENT-SECRET-KEY' @@ -109,7 +109,7 @@ Choose **Yandex.Passport API** in Permissions and check these options: Set the above settings in your `jupyterhub_config.py`: ```python -c.JupyterHub.authenticator_class = "generic" +c.JupyterHub.authenticator_class = "generic-oauth" c.OAuthenticator.oauth_callback_url = "https://[your-host]/hub/oauth_callback" c.OAuthenticator.client_id = "[your app ID]" c.OAuthenticator.client_secret = "[your app Password]" @@ -135,7 +135,7 @@ OAuth2 application. Set the above settings in your `jupyterhub_config.py`: ```python -c.JupyterHub.authenticator_class = "generic" +c.JupyterHub.authenticator_class = "generic-oauth" c.OAuthenticator.oauth_callback_url = "https://[your-host]/hub/oauth_callback" c.OAuthenticator.client_id = "[your oauth2 application id]" c.OAuthenticator.client_secret = "[your oauth2 application secret]" @@ -159,7 +159,7 @@ Follow the ORCID [API Tutorial](https://info.orcid.org/documentation/api-tutoria Edit your `jupyterhub_config.py` with the following: ```python -c.JupyterHub.authenticator_class = "generic" +c.JupyterHub.authenticator_class = "generic-oauth" # Fill these in with your values c.GenericOAuthenticator.oauth_callback_url = "YOUR CALLBACK URL" From 14e0f7eb9a3e5bcd2bf0587227cbf00ab1ba15a5 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Sat, 23 Sep 2023 17:31:55 +0200 Subject: [PATCH 039/103] cilogon: add allow_all sub-config for individual idps --- oauthenticator/cilogon.py | 50 ++++++++++++++-------- oauthenticator/schemas/cilogon-schema.yaml | 2 + 2 files changed, 34 insertions(+), 18 deletions(-) diff --git a/oauthenticator/cilogon.py b/oauthenticator/cilogon.py index 1b03cb59..8317e776 100644 --- a/oauthenticator/cilogon.py +++ b/oauthenticator/cilogon.py @@ -123,28 +123,36 @@ def _validate_scope(self, proposal): "action": "strip_idp_domain", "domain": "utoronto.ca", }, + "allow_all": True, }, - "https://github.com/login/oauth/authorize": { + "http://google.com/accounts/o8/id": { "username_derivation": { - "username_claim": "username", + "username_claim": "email", "action": "prefix", - "prefix": "github", + "prefix": "google", }, + "allowed_domains": ["uni.edu", "something.org"], }, - "http://google.com/accounts/o8/id": { + "https://github.com/login/oauth/authorize": { "username_derivation": { - "username_claim": "username", + "username_claim": "preferred_username", "action": "prefix", - "prefix": "google", + "prefix": "github", }, - "allowed_domains": ["uni.edu", "something.org"], + # allow_all or allowed_domains not specified for ths idp, + # this means that its users must be explicitly allowed + # with a config such as allowed_users or admin_users. }, } + c.Authenticator.admin_users = ["github-user1"] + c.Authenticator.allowed_users = ["github-user2"] - Where `username_derivation` defines: + This is a description of the configuration you can pass to + `allowed_idps`. + * `username_derivation`: string (required) * `username_claim`: string (required) - The claim in the `userinfo` response from which to get the + The claim in the `userinfo` response from which to define the JupyterHub username. Examples include: `eppn`, `email`. What keys are available will depend on the scopes requested. * `action`: string @@ -158,9 +166,13 @@ def _validate_scope(self, proposal): * `prefix`: string (required if action is prefix) The prefix which will be added at the beginning of the username followed by a semi-column ":", if the action is "prefix". - * `allowed_domains`: string - It defines which domains will be allowed to login using the - specific identity provider. + * `allow_all`: bool (defaults to False) + Configuring this allows all users authenticating with this identity + provider. + * `allowed_domains`: list of strings + Configuring this together with a `username_claim` that is an email + address enables users to be allowed if their `username_claim` ends + with `@` followed by a domain in this list. .. versionchanged:: 15.0 @@ -336,14 +348,20 @@ def _get_processed_username(self, username, user_info): async def check_allowed(self, username, auth_model): """ - Overrides the OAuthenticator.check_allowed to also allow users part of - an `allowed_domains` as configured under `allowed_idps`. + Overrides the OAuthenticator.check_allowed to also allow users based on + idp specific config `allow_all` and `allowed_domains` as configured + under `allowed_idps`. """ if await super().check_allowed(username, auth_model): return True user_info = auth_model["auth_state"][self.user_auth_state_key] user_idp = user_info["idp"] + + idp_allow_all = self.allowed_idps[user_idp].get("allow_all") + if idp_allow_all: + return True + idp_allowed_domains = self.allowed_idps[user_idp].get("allowed_domains") if idp_allowed_domains: unprocessed_username = self._user_info_to_unprocessed_username(user_info) @@ -351,10 +369,6 @@ async def check_allowed(self, username, auth_model): if user_domain in idp_allowed_domains: return True - message = f"Login with domain @{user_domain} is not allowed" - self.log.warning(message) - raise web.HTTPError(403, message) - # users should be explicitly allowed via config, otherwise they aren't return False diff --git a/oauthenticator/schemas/cilogon-schema.yaml b/oauthenticator/schemas/cilogon-schema.yaml index 713d2bd2..fb791599 100644 --- a/oauthenticator/schemas/cilogon-schema.yaml +++ b/oauthenticator/schemas/cilogon-schema.yaml @@ -7,6 +7,8 @@ additionalProperties: false required: - username_derivation properties: + allow_all: + type: boolean allowed_domains: type: array items: From 17cd81a79c38632439fb597a15a3eabc80eeffe6 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Sat, 23 Sep 2023 17:32:22 +0200 Subject: [PATCH 040/103] cilogon: test allow_all sub-config for individual idps --- oauthenticator/tests/test_cilogon.py | 393 +++++++++++++++++++++------ 1 file changed, 306 insertions(+), 87 deletions(-) diff --git a/oauthenticator/tests/test_cilogon.py b/oauthenticator/tests/test_cilogon.py index 06f7bdc0..c638a0e0 100644 --- a/oauthenticator/tests/test_cilogon.py +++ b/oauthenticator/tests/test_cilogon.py @@ -3,7 +3,6 @@ from jsonschema.exceptions import ValidationError from pytest import fixture, mark, raises -from tornado.web import HTTPError from traitlets.config import Config from traitlets.traitlets import TraitError @@ -102,6 +101,306 @@ async def test_cilogon( assert auth_model == None +@mark.parametrize( + "test_variation_id,idp_config,class_config,test_user_name,expect_allowed,expect_admin", + [ + # test of minimal idp specific config + ( + "1 - not allowed", + { + "username_derivation": { + "username_claim": "name", + }, + }, + {}, + "user1", + False, + False, + ), + # tests of allow_all + ( + "2 - allowed by allow_all", + { + "username_derivation": { + "username_claim": "name", + }, + "allow_all": True, + }, + {}, + "user1", + True, + None, + ), + ( + "3 - not allowed by allow_all", + { + "username_derivation": { + "username_claim": "name", + }, + "allow_all": True, + }, + {}, + "user1", + True, + None, + ), + # tests of allowed_domains + ( + "4 - allowed by allowed_domains", + { + "username_derivation": { + "username_claim": "email", + }, + "allowed_domains": ["allowed-domain.org"], + }, + {}, + "user1@allowed-domain.org", + True, + None, + ), + ( + "5 - not allowed by allowed_domains", + { + "username_derivation": { + "username_claim": "email", + }, + "allowed_domains": ["allowed-domain.org"], + }, + {}, + "user1@not-allowed-domain.org", + False, + None, + ), + ( + "6 - allowed by allowed_domains (domain stripping action involved)", + { + "username_derivation": { + "username_claim": "email", + "action": "strip_idp_domain", + "domain": "allowed-domain.org", + }, + "allowed_domains": ["allowed-domain.org"], + }, + {}, + "user1@allowed-domain.org", + True, + None, + ), + ( + "7 - not allowed by allowed_domains (domain stripping action involved)", + { + "username_derivation": { + "username_claim": "email", + "action": "strip_idp_domain", + "domain": "allowed-domain.org", + }, + "allowed_domains": ["allowed-domain.org"], + }, + {}, + "user1@not-allowed-domain.org", + False, + None, + ), + ( + "8 - allowed by allowed_domains (prefix action involved)", + { + "username_derivation": { + "username_claim": "email", + "action": "prefix", + "prefix": "some-prefix", + }, + "allowed_domains": ["allowed-domain.org"], + }, + {}, + "user1@allowed-domain.org", + True, + None, + ), + ( + "9 - not allowed by allowed_domains (prefix action involved)", + { + "username_derivation": { + "username_claim": "email", + "action": "prefix", + "prefix": "some-prefix", + }, + "allowed_domains": ["allowed-domain.org"], + }, + {}, + "user1@not-allowed-domain.org", + False, + None, + ), + # test of allowed_users and admin_users together with + # username_derivation actions to verify the final usernames is what + # matters when describing allowed_users and admin_users + ( + "10 - allowed by allowed_users (domain stripping action involved)", + { + "username_derivation": { + "username_claim": "email", + "action": "strip_idp_domain", + "domain": "domain.org", + }, + }, + { + "allowed_users": ["user1"], + }, + "user1@domain.org", + True, + None, + ), + ( + "11 - allowed by admin_users (domain stripping action involved)", + { + "username_derivation": { + "username_claim": "email", + "action": "strip_idp_domain", + "domain": "domain.org", + }, + }, + { + "admin_users": ["user1"], + }, + "user1@domain.org", + True, + True, + ), + ( + "12 - allowed by allowed_users (prefix action involved)", + { + "username_derivation": { + "username_claim": "email", + "action": "prefix", + "prefix": "some-prefix", + }, + }, + { + "allowed_users": ["some-prefix:user1@domain.org"], + }, + "user1@domain.org", + True, + None, + ), + ( + "13 - allowed by admin_users (prefix action involved)", + { + "username_derivation": { + "username_claim": "email", + "action": "prefix", + "prefix": "some-prefix", + }, + }, + { + "admin_users": ["some-prefix:user1@domain.org"], + }, + "user1@domain.org", + True, + True, + ), + ( + "14 - not allowed by allowed_users (domain stripping action involved)", + { + "username_derivation": { + "username_claim": "email", + "action": "strip_idp_domain", + "domain": "domain.org", + }, + }, + { + "allowed_users": ["user1@domain.org"], + }, + "user1@domain.org", + False, + None, + ), + ( + "15 - not allowed by admin_users (domain stripping action involved)", + { + "username_derivation": { + "username_claim": "email", + "action": "strip_idp_domain", + "domain": "domain.org", + }, + }, + { + "admin_users": ["user1@domain.org"], + }, + "user1@domain.org", + False, + None, + ), + ( + "16 - not allowed by allowed_users (prefix action involved)", + { + "username_derivation": { + "username_claim": "email", + "action": "prefix", + "prefix": "some-prefix", + }, + }, + { + "allowed_users": ["user1@domain.org"], + }, + "user1@domain.org", + False, + None, + ), + ( + "17 - not allowed by admin_users (prefix action involved)", + { + "username_derivation": { + "username_claim": "email", + "action": "prefix", + "prefix": "some-prefix", + }, + }, + { + "admin_users": ["user1@domain.org"], + }, + "user1@domain.org", + False, + None, + ), + ], +) +async def test_cilogon_allowed_idps( + cilogon_client, + test_variation_id, + idp_config, + class_config, + test_user_name, + expect_allowed, + expect_admin, +): + print(f"Running test variation id {test_variation_id}") + c = Config() + c.CILogonOAuthenticator = Config(class_config) + test_idp = "https://some-idp.com/login/oauth/authorize" + c.CILogonOAuthenticator.allowed_idps = { + test_idp: idp_config, + } + authenticator = CILogonOAuthenticator(config=c) + + username_claim = idp_config["username_derivation"]["username_claim"] + handled_user_model = user_model(test_user_name, username_claim, idp=test_idp) + handler = cilogon_client.handler_for_user(handled_user_model) + auth_model = await authenticator.get_authenticated_user(handler, None) + + if expect_allowed: + assert auth_model + assert set(auth_model) == {"name", "admin", "auth_state"} + assert auth_model["admin"] == expect_admin + auth_state = auth_model["auth_state"] + assert json.dumps(auth_state) + assert "access_token" in auth_state + assert "token_response" in auth_state + user_info = auth_state[authenticator.user_auth_state_key] + assert user_info == handled_user_model + else: + assert auth_model == None + + @mark.parametrize( "test_variation_id,class_config,expect_config,expect_loglevel,expect_message", [ @@ -302,6 +601,10 @@ async def test_config_scopes_validation(): async def test_allowed_idps_username_derivation_actions(cilogon_client): + """ + Tests all `allowed_idps[].username_derivation.action` config choices: + `strip_idp_domain`, `prefix`, and no action specified. + """ c = Config() c.CILogonOAuthenticator.allow_all = True c.CILogonOAuthenticator.allowed_idps = { @@ -316,7 +619,7 @@ async def test_allowed_idps_username_derivation_actions(cilogon_client): 'username_derivation': { 'username_claim': 'nickname', 'action': 'prefix', - 'prefix': 'idp', + 'prefix': 'some-prefix', }, }, 'https://no-action.example.com/login/oauth/authorize': { @@ -359,7 +662,7 @@ async def test_allowed_idps_username_derivation_actions(cilogon_client): ) auth_model = await authenticator.get_authenticated_user(handler, None) print(json.dumps(auth_model, sort_keys=True, indent=4)) - assert auth_model['name'] == 'idp:jtkirk' + assert auth_model['name'] == 'some-prefix:jtkirk' # Test no action handler = cilogon_client.handler_for_user( @@ -372,87 +675,3 @@ async def test_allowed_idps_username_derivation_actions(cilogon_client): auth_model = await authenticator.get_authenticated_user(handler, None) print(json.dumps(auth_model, sort_keys=True, indent=4)) assert auth_model['name'] == 'jtkirk' - - -async def test_not_allowed_domains_and_stripping(cilogon_client): - c = Config() - c.CILogonOAuthenticator.allowed_idps = { - 'https://some-idp.com/login/oauth/authorize': { - 'username_derivation': { - 'username_claim': 'email', - 'action': 'strip_idp_domain', - 'domain': 'uni.edu', - }, - 'allowed_domains': ['pink.org'], - }, - } - - authenticator = CILogonOAuthenticator(config=c) - - # Test stripping domain not allowed - handler = cilogon_client.handler_for_user( - user_model( - 'jtkirk@uni.edu', 'email', idp='https://some-idp.com/login/oauth/authorize' - ) - ) - - # The domain to be stripped isn't allowed, so it should fail - with raises(HTTPError): - await authenticator.get_authenticated_user(handler, None) - - -async def test_allowed_domains_and_stripping(cilogon_client): - c = Config() - c.CILogonOAuthenticator.allowed_idps = { - 'https://some-idp.com/login/oauth/authorize': { - 'username_derivation': { - 'username_claim': 'email', - 'action': 'strip_idp_domain', - 'domain': 'pink.org', - }, - 'allowed_domains': ['pink.org'], - }, - } - - authenticator = CILogonOAuthenticator(config=c) - - # Test stripping allowed domain - handler = cilogon_client.handler_for_user( - user_model( - 'jtkirk@pink.org', 'email', idp='https://some-idp.com/login/oauth/authorize' - ) - ) - auth_model = await authenticator.get_authenticated_user(handler, None) - assert auth_model['name'] == 'jtkirk' - - -async def test_allowed_domains_no_stripping(cilogon_client): - c = Config() - c.CILogonOAuthenticator.allowed_idps = { - 'https://some-idp.com/login/oauth/authorize': { - 'username_derivation': { - 'username_claim': 'email', - }, - 'allowed_domains': ['pink.org'], - }, - } - - authenticator = CILogonOAuthenticator(config=c) - - # Test login with user not part of allowed_domains - handler = cilogon_client.handler_for_user( - user_model( - 'jtkirk@uni.edu', 'email', idp='https://some-idp.com/login/oauth/authorize' - ) - ) - with raises(HTTPError): - auth_model = await authenticator.get_authenticated_user(handler, None) - - # Test login with part of allowed_domains - handler = cilogon_client.handler_for_user( - user_model( - 'jtkirk@pink.org', 'email', idp='https://some-idp.com/login/oauth/authorize' - ) - ) - auth_model = await authenticator.get_authenticated_user(handler, None) - assert auth_model['name'] == 'jtkirk@pink.org' From bd51ea0298d1ca09f159e1f1f8e5ea2b0072a964 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Sat, 23 Sep 2023 17:53:45 +0200 Subject: [PATCH 041/103] docs: cleanup redundant sphinx dependency --- docs/requirements.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index 492cd526..8c7cb177 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -6,7 +6,6 @@ autodoc-traits importlib_metadata>=3.6; python_version < '3.10' myst-parser -sphinx>=2 sphinx-autobuild sphinx-book-theme sphinx-copybutton From 2efc9bbaa5822eaac61445faac2d9a93760f9ce1 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Thu, 28 Sep 2023 15:17:00 +0200 Subject: [PATCH 042/103] Add changelog for 16.1.0 --- docs/source/reference/changelog.md | 40 +++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/docs/source/reference/changelog.md b/docs/source/reference/changelog.md index 9ba93086..ecc8d9b0 100644 --- a/docs/source/reference/changelog.md +++ b/docs/source/reference/changelog.md @@ -6,6 +6,35 @@ command line for details. ## [Unreleased] +## 16.1 + +### [16.1.0] - 2023-09-28 + +#### New features added + +- [CILogon] Add allow_all as a idp specific config [#684](https://github.com/jupyterhub/oauthenticator/pull/684) ([@consideRatio](https://github.com/consideRatio), [@GeorgianaElena](https://github.com/GeorgianaElena)) + +#### Enhancements made + +- Drop next_url from authorize_redirect state param [#671](https://github.com/jupyterhub/oauthenticator/pull/671) ([@johnpmayer](https://github.com/johnpmayer), [@consideRatio](https://github.com/consideRatio), [@minrk](https://github.com/minrk)) + +#### Bugs fixed + +- [All] Fix broken `basic_auth` functionality [#678](https://github.com/jupyterhub/oauthenticator/pull/678) ([@jorado](https://github.com/jorado), [@manics](https://github.com/manics)) + +#### Documentation improvements + +- docs: fix `"generic"` renamed to `"generic-oauth"` [#680](https://github.com/jupyterhub/oauthenticator/pull/680) ([@mehalter](https://github.com/mehalter), [@consideRatio](https://github.com/consideRatio)) + +#### Contributors to this release + +The following people contributed discussions, new ideas, code and documentation contributions, and review. +See [our definition of contributors](https://github-activity.readthedocs.io/en/latest/#how-does-this-tool-define-contributions-in-the-reports). + +([GitHub contributors page for this release](https://github.com/jupyterhub/oauthenticator/graphs/contributors?from=2023-08-21&to=2023-09-28&type=c)) + +@consideRatio ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Foauthenticator+involves%3AconsideRatio+updated%3A2023-08-21..2023-09-28&type=Issues)) | @GeorgianaElena ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Foauthenticator+involves%3AGeorgianaElena+updated%3A2023-08-21..2023-09-28&type=Issues)) | @johnpmayer ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Foauthenticator+involves%3Ajohnpmayer+updated%3A2023-08-21..2023-09-28&type=Issues)) | @jorado ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Foauthenticator+involves%3Ajorado+updated%3A2023-08-21..2023-09-28&type=Issues)) | @manics ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Foauthenticator+involves%3Amanics+updated%3A2023-08-21..2023-09-28&type=Issues)) | @mehalter ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Foauthenticator+involves%3Amehalter+updated%3A2023-08-21..2023-09-28&type=Issues)) | @minrk ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Foauthenticator+involves%3Aminrk+updated%3A2023-08-21..2023-09-28&type=Issues)) + ## 16.0 ### [16.0.7] - 2023-08-21 @@ -47,8 +76,6 @@ See [our definition of contributors](https://github-activity.readthedocs.io/en/l ### [16.0.5] - 2023-08-15 -([full changelog](https://github.com/jupyterhub/oauthenticator/compare/16.0.4...16.0.5)) - #### Bugs fixed - [Google, Globus] handle auth_model is None in google, globus [#665](https://github.com/jupyterhub/oauthenticator/pull/665) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio)) @@ -64,8 +91,6 @@ See [our definition of contributors](https://github-activity.readthedocs.io/en/l ### [16.0.4] - 2023-08-11 -([full changelog](https://github.com/jupyterhub/oauthenticator/compare/16.0.3...7937cd6a7f8c123a9b50cad37af3ff3a9a610e71)) - #### Bugs fixed - [Google] Fix regression in v16 of no longer stripping username's domain if `hosted_domain` has a single entry [#661](https://github.com/jupyterhub/oauthenticator/pull/661) ([@consideratio](https://github.com/consideratio), [@minrk](https://github.com/minrk), [@taylorgibson](https://github.com/taylorgibson)) @@ -391,8 +416,6 @@ read about the breaking changes. ### [14.0.0] - 2021-04-09 -([full changelog](https://github.com/jupyterhub/oauthenticator/compare/0.13.0...14.0.0)) - #### New features added - Support username_claim in Google OAuth [#401](https://github.com/jupyterhub/oauthenticator/pull/401) ([@dtaniwaki](https://github.com/dtaniwaki)) @@ -549,8 +572,6 @@ We don't plan to accept further contributions of new providers if they can be ac Rather, contributors are encouraged to provide example documentation for using new providers, or pull requests addressing gaps necessary to do so with the GenericOAuthenticator. -([full changelog](https://github.com/jupyterhub/oauthenticator/compare/0.10.0...ae199077a3a580cb849af17ceccfe8e498134ea3)) - #### Merged PRs - [AzureAD] Don't pass resource when requesting a token [#328](https://github.com/jupyterhub/oauthenticator/pull/328) ([@craigminihan](https://github.com/craigminihan)) @@ -754,7 +775,8 @@ It fixes handling of `gitlab_group_whitelist` when using GitLabOAuthenticator. - First release -[unreleased]: https://github.com/jupyterhub/oauthenticator/compare/16.0.7...HEAD +[unreleased]: https://github.com/jupyterhub/oauthenticator/compare/16.1.0...HEAD +[16.1.0]: https://github.com/jupyterhub/oauthenticator/compare/16.0.7...16.1.0 [16.0.7]: https://github.com/jupyterhub/oauthenticator/compare/16.0.6...16.0.7 [16.0.6]: https://github.com/jupyterhub/oauthenticator/compare/16.0.5...16.0.6 [16.0.5]: https://github.com/jupyterhub/oauthenticator/compare/16.0.4...16.0.5 From a90e4fac54125a10cff9973adac96f57a3618065 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Thu, 28 Sep 2023 18:51:41 +0200 Subject: [PATCH 043/103] Bump to 16.1.0 --- oauthenticator/_version.py | 2 +- pyproject.toml | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/oauthenticator/_version.py b/oauthenticator/_version.py index f392c9fa..160e57f1 100644 --- a/oauthenticator/_version.py +++ b/oauthenticator/_version.py @@ -1,7 +1,7 @@ # __version__ should be updated using tbump, based on configuration in # pyproject.toml, according to instructions in RELEASE.md. # -__version__ = "16.0.8.dev" +__version__ = "16.1.0" # version_info looks like (1, 2, 3, "dev") if __version__ is 1.2.3.dev version_info = tuple(int(p) if p.isdigit() else p for p in __version__.split(".")) diff --git a/pyproject.toml b/pyproject.toml index 79c19a1f..a839b9f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,7 +64,7 @@ omit = [ github_url = "https://github.com/jupyterhub/oauthenticator" [tool.tbump.version] -current = "16.0.8.dev" +current = "16.1.0" regex = ''' (?P\d+) \. diff --git a/setup.py b/setup.py index 2e31f813..9b9646da 100644 --- a/setup.py +++ b/setup.py @@ -27,7 +27,7 @@ def run(self): setup_args = dict( name='oauthenticator', packages=find_packages(), - version="16.0.8.dev", + version="16.1.0", description="OAuthenticator: Authenticate JupyterHub users with common OAuth providers", long_description=open("README.md").read(), long_description_content_type="text/markdown", From a7b66f214e2a3e8035ed339d6e2ea38c5db3a871 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Thu, 28 Sep 2023 18:52:04 +0200 Subject: [PATCH 044/103] Bump to 16.1.1.dev --- oauthenticator/_version.py | 2 +- pyproject.toml | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/oauthenticator/_version.py b/oauthenticator/_version.py index 160e57f1..13397b1e 100644 --- a/oauthenticator/_version.py +++ b/oauthenticator/_version.py @@ -1,7 +1,7 @@ # __version__ should be updated using tbump, based on configuration in # pyproject.toml, according to instructions in RELEASE.md. # -__version__ = "16.1.0" +__version__ = "16.1.1.dev" # version_info looks like (1, 2, 3, "dev") if __version__ is 1.2.3.dev version_info = tuple(int(p) if p.isdigit() else p for p in __version__.split(".")) diff --git a/pyproject.toml b/pyproject.toml index a839b9f7..b531a9a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,7 +64,7 @@ omit = [ github_url = "https://github.com/jupyterhub/oauthenticator" [tool.tbump.version] -current = "16.1.0" +current = "16.1.1.dev" regex = ''' (?P\d+) \. diff --git a/setup.py b/setup.py index 9b9646da..55daef5c 100644 --- a/setup.py +++ b/setup.py @@ -27,7 +27,7 @@ def run(self): setup_args = dict( name='oauthenticator', packages=find_packages(), - version="16.1.0", + version="16.1.1.dev", description="OAuthenticator: Authenticate JupyterHub users with common OAuth providers", long_description=open("README.md").read(), long_description_content_type="text/markdown", From 9b0139465945725e44675a3ed42b0d1f260bd567 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 1 Oct 2023 05:19:13 +0000 Subject: [PATCH 045/103] Bump actions/checkout from 3 to 4 Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 4. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v3...v4) --- updated-dependencies: - dependency-name: actions/checkout dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/release.yaml | 2 +- .github/workflows/test-docs.yaml | 2 +- .github/workflows/test.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index a3a7848d..0958a28a 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -33,7 +33,7 @@ jobs: id-token: write steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: actions/setup-python@v4 with: python-version: "3.11" diff --git a/.github/workflows/test-docs.yaml b/.github/workflows/test-docs.yaml index d52d8dde..87187eae 100644 --- a/.github/workflows/test-docs.yaml +++ b/.github/workflows/test-docs.yaml @@ -24,7 +24,7 @@ jobs: linkcheck: runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: actions/setup-python@v4 with: python-version: "3.10" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 08b9f8f0..fd9a5e58 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -43,7 +43,7 @@ jobs: oldest_dependencies: oldest_dependencies steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: actions/setup-python@v4 with: python-version: "${{ matrix.python }}" From 3cb16d3a578469167a4b02f17372c4db9a4228ab Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 3 Oct 2023 10:35:09 +0000 Subject: [PATCH 046/103] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/asottile/pyupgrade: v3.10.1 → v3.14.0](https://github.com/asottile/pyupgrade/compare/v3.10.1...v3.14.0) - [github.com/psf/black: 23.7.0 → 23.9.1](https://github.com/psf/black/compare/23.7.0...23.9.1) --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b121b74c..76246931 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ repos: # Autoformat: Python code, syntax patterns are modernized - repo: https://github.com/asottile/pyupgrade - rev: v3.10.1 + rev: v3.14.0 hooks: - id: pyupgrade args: @@ -34,7 +34,7 @@ repos: # Autoformat: Python code - repo: https://github.com/psf/black - rev: 23.7.0 + rev: 23.9.1 hooks: - id: black From ac728ac6bd17b8eb6e2803d2a64523cde3d40958 Mon Sep 17 00:00:00 2001 From: YuviPanda Date: Wed, 4 Oct 2023 22:30:15 -0700 Subject: [PATCH 047/103] Get rid of the okpy authenticator There is exactly one installation of okpy in the world - at https://okpy.org/. It's primarily used only by UC Berkeley, and this authenticator was used for a short period of time at one of the hubs run by UC Berkeley. However, it has *not* been used there for many years, and there are no ongoing plans for it to be used ever again. I can speak reasonably authoritatively on this, given I helped run those hubs from inception until very recently. Given there are not really any other okpy installations in the world, I think it's actually safe to remove this one. --- README.md | 2 +- docs/source/conf.py | 1 - .../provider-specific-setup/index.md | 1 - .../providers/globus.md | 2 +- .../provider-specific-setup/providers/okpy.md | 25 ----- oauthenticator/okpy.py | 49 ---------- oauthenticator/tests/test_okpy.py | 93 ------------------- setup.py | 2 - 8 files changed, 2 insertions(+), 173 deletions(-) delete mode 100644 docs/source/tutorials/provider-specific-setup/providers/okpy.md delete mode 100644 oauthenticator/okpy.py delete mode 100644 oauthenticator/tests/test_okpy.py diff --git a/README.md b/README.md index 1313b661..8735f126 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ plugged in and used with JupyterHub. The following authentication services are supported through their own authenticator: [Auth0](oauthenticator/auth0.py), [Azure AD](oauthenticator/azuread.py), [Bitbucket](oauthenticator/bitbucket.py), [CILogon](oauthenticator/cilogon.py), [FeiShu](https://github.com/tezignlab/jupyterhub_feishu_authenticator), [GitHub](oauthenticator/github.py), [GitLab](oauthenticator/gitlab.py), [Globus](oauthenticator/globus.py), -[Google](oauthenticator/google.py), [MediaWiki](oauthenticator/mediawiki.py), [Okpy](oauthenticator/okpy.py), +[Google](oauthenticator/google.py), [MediaWiki](oauthenticator/mediawiki.py), [OpenShift](oauthenticator/openshift.py). There is also a [GenericAuthenticator](oauthenticator/generic.py) diff --git a/docs/source/conf.py b/docs/source/conf.py index 4c87dc7e..3bf788e3 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -225,7 +225,6 @@ def setup(app): "api/gen/oauthenticator.gitlab": "reference/api/gen/oauthenticator.gitlab", "api/gen/oauthenticator.globus": "reference/api/gen/oauthenticator.globus", "api/gen/oauthenticator.google": "reference/api/gen/oauthenticator.google", - "api/gen/oauthenticator.okpy": "reference/api/gen/oauthenticator.okpy", "api/gen/oauthenticator.openshift": "reference/api/gen/oauthenticator.openshift", "api/gen/oauthenticator.mediawiki": "reference/api/gen/oauthenticator.mediawiki", # 2023-06-29 docs refresh diff --git a/docs/source/tutorials/provider-specific-setup/index.md b/docs/source/tutorials/provider-specific-setup/index.md index 8ababf53..3724293e 100644 --- a/docs/source/tutorials/provider-specific-setup/index.md +++ b/docs/source/tutorials/provider-specific-setup/index.md @@ -23,7 +23,6 @@ providers/gitlab.md providers/globus.md providers/google.md providers/mediawiki.md -providers/okpy.md providers/openshift.md providers/generic.md ``` diff --git a/docs/source/tutorials/provider-specific-setup/providers/globus.md b/docs/source/tutorials/provider-specific-setup/providers/globus.md index 91e5ec9a..b79a1400 100644 --- a/docs/source/tutorials/provider-specific-setup/providers/globus.md +++ b/docs/source/tutorials/provider-specific-setup/providers/globus.md @@ -12,7 +12,7 @@ When you register the application, make sure _Native App_ is unchecked and that Your `jupyterhub_config.py` file should look something like this: ```python -c.JupyterHub.authenticator_class = "okpy" +c.JupyterHub.authenticator_class = "globus" c.OAuthenticator.oauth_callback_url = "https://[your-domain]/hub/oauth_callback" c.OAuthenticator.client_id = "[your oauth2 application id]" c.OAuthenticator.client_secret = "[your oauth2 application secret]" diff --git a/docs/source/tutorials/provider-specific-setup/providers/okpy.md b/docs/source/tutorials/provider-specific-setup/providers/okpy.md deleted file mode 100644 index 9910350a..00000000 --- a/docs/source/tutorials/provider-specific-setup/providers/okpy.md +++ /dev/null @@ -1,25 +0,0 @@ -# OkpyAuthenticator - -[Okpy](https://github.com/okpy/ok-client) is an -auto-grading tool that is widely used in UC Berkeley EECS and Data -Science courses. This authenticator enhances its support for Jupyter -Notebook by enabling students to authenticate with the -[Hub](https://datahub.berkeley.edu/hub/login) first and saving relevant -user states to the `env` (the feature is redacted until a secure state -saving mechanism is developed). - -## JupyterHub configuration - -Your `jupyterhub_config.py` file should look something like this: - -```python -c.JupyterHub.authenticator_class = "okpy" -c.OAuthenticator.oauth_callback_url = "https://[your-domain]/hub/oauth_callback" -c.OAuthenticator.client_id = "[your oauth2 application id]" -c.OAuthenticator.client_secret = "[your oauth2 application secret]" -``` - -## Additional configuration - -OkpyOAuthenticator _does not_ expand OAuthenticator with additional config -options. diff --git a/oauthenticator/okpy.py b/oauthenticator/okpy.py deleted file mode 100644 index ca912fc3..00000000 --- a/oauthenticator/okpy.py +++ /dev/null @@ -1,49 +0,0 @@ -""" -A JupyterHub authenticator class for use with Okpy as an identity provider. -""" -import os - -from jupyterhub.auth import LocalAuthenticator -from tornado.auth import OAuth2Mixin -from traitlets import default - -from .oauth2 import OAuthenticator - - -class OkpyOAuthenticator(OAuthenticator, OAuth2Mixin): - user_auth_state_key = "okpy_user" - - @default("login_service") - def _login_service_default(self): - return os.environ.get("LOGIN_SERVICE", "OK") - - @default("authorize_url") - def _authorize_url_default(self): - return "https://okpy.org/oauth/authorize" - - @default("token_url") - def _token_url_default(self): - return "https://okpy.org/oauth/token" - - @default("userdata_url") - def _userdata_url_default(self): - return "https://okpy.org/api/v3/user" - - @default("scope") - def _default_scope(self): - return ["email"] - - @default("username_claim") - def _username_claim_default(self): - return "email" - - @default("userdata_params") - def _default_userdata_params(self): - # Otherwise all responses from the API are wrapped in - # an envelope that contains metadata about the response. - # ref: https://okpy.github.io/documentation/ok-api.html - return {"envelope": "false"} - - -class LocalOkpyOAuthenticator(LocalAuthenticator, OkpyOAuthenticator): - """A version that mixes in local system user creation""" diff --git a/oauthenticator/tests/test_okpy.py b/oauthenticator/tests/test_okpy.py deleted file mode 100644 index 1d2d027b..00000000 --- a/oauthenticator/tests/test_okpy.py +++ /dev/null @@ -1,93 +0,0 @@ -import json - -from pytest import fixture, mark -from traitlets.config import Config - -from ..okpy import OkpyOAuthenticator -from .mocks import no_code_test, setup_oauth_mock - - -def user_model(username): - """Return a user model""" - return { - 'name': username, - } - - -@fixture -def okpy_client(client): - setup_oauth_mock( - client, - host=['okpy.org'], - access_token_path='/oauth/token', - user_path='/api/v3/user', - token_type='Bearer', - ) - return client - - -@mark.parametrize( - "test_variation_id,class_config,expect_allowed,expect_admin", - [ - # no allow config tested - ("00", {}, False, None), - # allow config, individually tested - ("01", {"allow_all": True}, True, None), - ("02", {"allowed_users": {"user1"}}, True, None), - ("03", {"allowed_users": {"not-test-user"}}, False, None), - ("04", {"admin_users": {"user1"}}, True, True), - ("05", {"admin_users": {"not-test-user"}}, False, None), - # allow config, some combinations of two tested - ( - "10", - { - "allow_all": False, - "allowed_users": {"not-test-user"}, - }, - False, - None, - ), - ( - "11", - { - "admin_users": {"user1"}, - "allowed_users": {"not-test-user"}, - }, - True, - True, - ), - ], -) -async def test_okpy( - okpy_client, - test_variation_id, - class_config, - expect_allowed, - expect_admin, -): - print(f"Running test variation id {test_variation_id}") - c = Config() - c.OkpyOAuthenticator = Config(class_config) - c.OkpyOAuthenticator.username_claim = "name" - authenticator = OkpyOAuthenticator(config=c) - - handled_user_model = user_model("user1") - handler = okpy_client.handler_for_user(handled_user_model) - auth_model = await authenticator.get_authenticated_user(handler, None) - - if expect_allowed: - assert auth_model - assert set(auth_model) == {"name", "admin", "auth_state"} - assert auth_model["admin"] == expect_admin - auth_state = auth_model["auth_state"] - assert json.dumps(auth_state) - assert "access_token" in auth_state - user_info = auth_state[authenticator.user_auth_state_key] - assert user_info == handled_user_model - assert auth_model["name"] == user_info[authenticator.username_claim] - else: - assert auth_model == None - - -async def test_no_code(okpy_client): - await no_code_test(OkpyOAuthenticator()) diff --git a/setup.py b/setup.py index 55daef5c..06548436 100644 --- a/setup.py +++ b/setup.py @@ -60,8 +60,6 @@ def run(self): 'google = oauthenticator.google:GoogleOAuthenticator', 'local-google = oauthenticator.google:LocalGoogleOAuthenticator', 'mediawiki = oauthenticator.mediawiki:MWOAuthenticator', - 'okpy = oauthenticator.okpy:OkpyOAuthenticator', - 'local-okpy = oauthenticator.okpy:LocalOkpyOAuthenticator', 'openshift = oauthenticator.openshift:OpenShiftOAuthenticator', 'local-openshift = oauthenticator.openshift:LocalOpenShiftOAuthenticator', ], From 17a167673a7dea39ee3fabd098d2622e3113799d Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Mon, 16 Oct 2023 08:16:55 +0200 Subject: [PATCH 048/103] openshift: fix fetching of default openshift_auth_api_url --- oauthenticator/openshift.py | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/oauthenticator/openshift.py b/oauthenticator/openshift.py index d7c73fc6..ac231900 100644 --- a/oauthenticator/openshift.py +++ b/oauthenticator/openshift.py @@ -1,6 +1,7 @@ """ A JupyterHub authenticator class for use with OpenShift as an identity provider. """ +import concurrent.futures import json import os @@ -81,15 +82,25 @@ def _http_request_kwargs_default(self): def _openshift_auth_api_url_default(self): auth_info_url = f"{self.openshift_url}/.well-known/oauth-authorization-server" - # Makes a request like OAuthenticator.httpfetch would but non-async as - # this code run during startup when we can't yet use async - # functionality. - client = HTTPClient() - req = HTTPRequest(auth_info_url, **self.http_request_kwargs) - resp = client.fetch(req) - resp_json = json.loads(resp.body.decode("utf8", "replace")) - - return resp_json.get('issuer') + # This code run during startup when we can't yet use async + # functionality. Due to this, Tornado's HTTPClient instead of + # AsyncHTTPClient is used. With HTTPClient we can still re-use + # `http_request_args` specific to Tornado's HTTP clients. + # + # A dedicated thread is used for HTTPClient because of + # https://github.com/tornadoweb/tornado/issues/2325#issuecomment-375972739. + # + def fetch_auth_info(): + client = HTTPClient() + req = HTTPRequest(auth_info_url, **self.http_request_kwargs) + resp = client.fetch(req) + resp_json = json.loads(resp.body.decode("utf8", "replace")) + return resp_json + + with concurrent.futures.ThreadPoolExecutor() as executor: + future = executor.submit(fetch_auth_info) + return_value = future.result() + return return_value.get("issuer") @default("authorize_url") def _authorize_url_default(self): From 5d3523f4edca29f509bb7b08df9bac48fb350892 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Mon, 16 Oct 2023 09:38:10 +0200 Subject: [PATCH 049/103] Avoid possible use of more than one thread Co-authored-by: Min RK --- oauthenticator/openshift.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oauthenticator/openshift.py b/oauthenticator/openshift.py index ac231900..599f989f 100644 --- a/oauthenticator/openshift.py +++ b/oauthenticator/openshift.py @@ -97,7 +97,7 @@ def fetch_auth_info(): resp_json = json.loads(resp.body.decode("utf8", "replace")) return resp_json - with concurrent.futures.ThreadPoolExecutor() as executor: + with concurrent.futures.ThreadPoolExecutor(1) as executor: future = executor.submit(fetch_auth_info) return_value = future.result() return return_value.get("issuer") From f6cbe888bf6b5e68a45ff4606a913aa745abd67f Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Wed, 18 Oct 2023 15:28:13 +0200 Subject: [PATCH 050/103] Add changelog for 16.1.1 --- docs/source/reference/changelog.md | 34 ++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/docs/source/reference/changelog.md b/docs/source/reference/changelog.md index ecc8d9b0..079e1fad 100644 --- a/docs/source/reference/changelog.md +++ b/docs/source/reference/changelog.md @@ -8,6 +8,39 @@ command line for details. ## 16.1 +### [16.1.1] - 2023-10-18 + +([full changelog](https://github.com/jupyterhub/oauthenticator/compare/16.1.0...16.1.1)) + +```{note} +The OkpyOAuthenticator was removed in this patch release as its believed to have +no users. If you were an active user, please re-configure to use +GenericOAuthenticator [like described in this +comment](https://github.com/jupyterhub/oauthenticator/pull/691#issuecomment-1753681643) +and let us know in another comment. +``` + +#### Bugs fixed + +- [OpenShift] Fix fetching of default openshift_auth_api_url [#694](https://github.com/jupyterhub/oauthenticator/pull/694) ([@consideRatio](https://github.com/consideRatio), [@manics](https://github.com/manics), [@minrk](https://github.com/minrk)) + +#### Maintenance and upkeep improvements + +- [Okpy] Remove the authenticator as it is no longer used [#691](https://github.com/jupyterhub/oauthenticator/pull/691) ([@yuvipanda](https://github.com/yuvipanda), [@GeorgianaElena](https://github.com/GeorgianaElena), [@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio), [@manics](https://github.com/manics)) + +#### Continuous integration improvements + +- Bump actions/checkout from 3 to 4 [#687](https://github.com/jupyterhub/oauthenticator/pull/687) ([@consideRatio](https://github.com/consideRatio)) + +#### Contributors to this release + +The following people contributed discussions, new ideas, code and documentation contributions, and review. +See [our definition of contributors](https://github-activity.readthedocs.io/en/latest/#how-does-this-tool-define-contributions-in-the-reports). + +([GitHub contributors page for this release](https://github.com/jupyterhub/oauthenticator/graphs/contributors?from=2023-09-28&to=2023-10-18&type=c)) + +@consideRatio ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Foauthenticator+involves%3AconsideRatio+updated%3A2023-09-28..2023-10-18&type=Issues)) | @do-it-tim ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Foauthenticator+involves%3Ado-it-tim+updated%3A2023-09-28..2023-10-18&type=Issues)) | @GeorgianaElena ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Foauthenticator+involves%3AGeorgianaElena+updated%3A2023-09-28..2023-10-18&type=Issues)) | @manics ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Foauthenticator+involves%3Amanics+updated%3A2023-09-28..2023-10-18&type=Issues)) | @minrk ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Foauthenticator+involves%3Aminrk+updated%3A2023-09-28..2023-10-18&type=Issues)) | @yuvipanda ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Foauthenticator+involves%3Ayuvipanda+updated%3A2023-09-28..2023-10-18&type=Issues)) + ### [16.1.0] - 2023-09-28 #### New features added @@ -776,6 +809,7 @@ It fixes handling of `gitlab_group_whitelist` when using GitLabOAuthenticator. - First release [unreleased]: https://github.com/jupyterhub/oauthenticator/compare/16.1.0...HEAD +[16.1.1]: https://github.com/jupyterhub/oauthenticator/compare/16.1.0...16.1.1 [16.1.0]: https://github.com/jupyterhub/oauthenticator/compare/16.0.7...16.1.0 [16.0.7]: https://github.com/jupyterhub/oauthenticator/compare/16.0.6...16.0.7 [16.0.6]: https://github.com/jupyterhub/oauthenticator/compare/16.0.5...16.0.6 From 4aafbfef52f1cafcb378d4b905ff1310efa51273 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Wed, 18 Oct 2023 15:34:05 +0200 Subject: [PATCH 051/103] Bump to 16.1.1 --- oauthenticator/_version.py | 2 +- pyproject.toml | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/oauthenticator/_version.py b/oauthenticator/_version.py index 13397b1e..de001bf1 100644 --- a/oauthenticator/_version.py +++ b/oauthenticator/_version.py @@ -1,7 +1,7 @@ # __version__ should be updated using tbump, based on configuration in # pyproject.toml, according to instructions in RELEASE.md. # -__version__ = "16.1.1.dev" +__version__ = "16.1.1" # version_info looks like (1, 2, 3, "dev") if __version__ is 1.2.3.dev version_info = tuple(int(p) if p.isdigit() else p for p in __version__.split(".")) diff --git a/pyproject.toml b/pyproject.toml index b531a9a1..13edc44c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,7 +64,7 @@ omit = [ github_url = "https://github.com/jupyterhub/oauthenticator" [tool.tbump.version] -current = "16.1.1.dev" +current = "16.1.1" regex = ''' (?P\d+) \. diff --git a/setup.py b/setup.py index 06548436..12156b17 100644 --- a/setup.py +++ b/setup.py @@ -27,7 +27,7 @@ def run(self): setup_args = dict( name='oauthenticator', packages=find_packages(), - version="16.1.1.dev", + version="16.1.1", description="OAuthenticator: Authenticate JupyterHub users with common OAuth providers", long_description=open("README.md").read(), long_description_content_type="text/markdown", From 9ff045378dfb2235f79bfffdc3a3ce1148b2ac3f Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Wed, 18 Oct 2023 15:34:16 +0200 Subject: [PATCH 052/103] Bump to 16.1.2.dev --- oauthenticator/_version.py | 2 +- pyproject.toml | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/oauthenticator/_version.py b/oauthenticator/_version.py index de001bf1..c58e6f46 100644 --- a/oauthenticator/_version.py +++ b/oauthenticator/_version.py @@ -1,7 +1,7 @@ # __version__ should be updated using tbump, based on configuration in # pyproject.toml, according to instructions in RELEASE.md. # -__version__ = "16.1.1" +__version__ = "16.1.2.dev" # version_info looks like (1, 2, 3, "dev") if __version__ is 1.2.3.dev version_info = tuple(int(p) if p.isdigit() else p for p in __version__.split(".")) diff --git a/pyproject.toml b/pyproject.toml index 13edc44c..10b03642 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,7 +64,7 @@ omit = [ github_url = "https://github.com/jupyterhub/oauthenticator" [tool.tbump.version] -current = "16.1.1" +current = "16.1.2.dev" regex = ''' (?P\d+) \. diff --git a/setup.py b/setup.py index 12156b17..d4557f02 100644 --- a/setup.py +++ b/setup.py @@ -27,7 +27,7 @@ def run(self): setup_args = dict( name='oauthenticator', packages=find_packages(), - version="16.1.1", + version="16.1.2.dev", description="OAuthenticator: Authenticate JupyterHub users with common OAuth providers", long_description=open("README.md").read(), long_description_content_type="text/markdown", From b52cfa5622390005559e0fab512c946030b0f601 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 6 Nov 2023 20:27:46 +0000 Subject: [PATCH 053/103] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/asottile/pyupgrade: v3.14.0 → v3.15.0](https://github.com/asottile/pyupgrade/compare/v3.14.0...v3.15.0) - [github.com/psf/black: 23.9.1 → 23.10.1](https://github.com/psf/black/compare/23.9.1...23.10.1) - [github.com/pre-commit/pre-commit-hooks: v4.4.0 → v4.5.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.4.0...v4.5.0) --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 76246931..9a695ada 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ repos: # Autoformat: Python code, syntax patterns are modernized - repo: https://github.com/asottile/pyupgrade - rev: v3.14.0 + rev: v3.15.0 hooks: - id: pyupgrade args: @@ -34,7 +34,7 @@ repos: # Autoformat: Python code - repo: https://github.com/psf/black - rev: 23.9.1 + rev: 23.10.1 hooks: - id: black @@ -46,7 +46,7 @@ repos: # Misc... - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v4.5.0 # ref: https://github.com/pre-commit/pre-commit-hooks#hooks-available hooks: # Autoformat: Makes sure files end in a newline and only a newline. From 9fb749e0f3a9c6be66239428a923b4c8201624d6 Mon Sep 17 00:00:00 2001 From: James Hodgkinson Date: Mon, 20 Nov 2023 09:53:50 +1000 Subject: [PATCH 054/103] properly sending Bearer token types in OAuth2 --- oauthenticator/oauth2.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/oauthenticator/oauth2.py b/oauthenticator/oauth2.py index 0cf3c7df..01b4fe77 100644 --- a/oauthenticator/oauth2.py +++ b/oauthenticator/oauth2.py @@ -716,10 +716,17 @@ def build_userdata_request_headers(self, access_token, token_type): Builds and returns the headers to be used in the userdata request. Called by the :meth:`oauthenticator.OAuthenticator.token_to_user` """ + + # token_type is case-sensitive, but the headers are + if token_type.lower() == "bearer": + auth_token_type = "Bearer" + else: + auth_token_type = token_type + return { "Accept": "application/json", "User-Agent": "JupyterHub", - "Authorization": f"{token_type} {access_token}", + "Authorization": f"{auth_token_type} {access_token}", } def build_token_info_request_headers(self): From dee2609d44c0992b808ff1e0a5477221599e4ba4 Mon Sep 17 00:00:00 2001 From: James Hodgkinson Date: Tue, 21 Nov 2023 08:00:17 +1000 Subject: [PATCH 055/103] typo --- oauthenticator/oauth2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oauthenticator/oauth2.py b/oauthenticator/oauth2.py index 01b4fe77..9ba66ecf 100644 --- a/oauthenticator/oauth2.py +++ b/oauthenticator/oauth2.py @@ -717,7 +717,7 @@ def build_userdata_request_headers(self, access_token, token_type): Called by the :meth:`oauthenticator.OAuthenticator.token_to_user` """ - # token_type is case-sensitive, but the headers are + # token_type is case-insensitive, but the headers are case-sensitive if token_type.lower() == "bearer": auth_token_type = "Bearer" else: From e34a0a9ecaa8d274cf1aed7ec1988c108f96de2f Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Tue, 21 Nov 2023 19:59:16 +0100 Subject: [PATCH 056/103] ci: add test of python 3.12 --- .github/workflows/test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fd9a5e58..8b82e5c9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -38,6 +38,7 @@ jobs: - "3.9" - "3.10" - "3.11" + - "3.12" include: - python: "3.9" oldest_dependencies: oldest_dependencies From 5ced63127f26521b49fbce5405dbab6620495f54 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Tue, 21 Nov 2023 19:56:30 +0100 Subject: [PATCH 057/103] cilogon: add config to specify default idp under allowed_idps --- oauthenticator/cilogon.py | 29 ++++++++++++++++--- oauthenticator/tests/test_cilogon.py | 43 +++++++++++++++++++++++++++- 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/oauthenticator/cilogon.py b/oauthenticator/cilogon.py index 8317e776..10fba6d7 100644 --- a/oauthenticator/cilogon.py +++ b/oauthenticator/cilogon.py @@ -15,6 +15,24 @@ yaml = YAML(typ="safe", pure=True) +def _get_select_idp_param(allowed_idps): + """ + The "selected_idp" query parameter included when the user is redirected to + CILogon should be a comma separated string of idps to choose from, where the + first entry is pre-selected as the default choice. The ordering of the + remaining idps has no meaning. + """ + # pick the first idp marked as default, or fallback to the first idp + default_keys = [k for k, v in allowed_idps.items() if v.get("default")] + default_key = next(iter(default_keys), next(iter(allowed_idps))) + + # put the default idp first followed by the other idps + other_keys = [k for k, _ in allowed_idps.items() if k != default_key] + selected_idp = ",".join([default_key] + other_keys) + + return selected_idp + + class CILogonLoginHandler(OAuthLoginHandler): """See https://www.cilogon.org/oidc for general information.""" @@ -29,10 +47,9 @@ def authorize_redirect(self, *args, **kwargs): # include it, we then modify kwargs' extra_params dictionary extra_params = kwargs.setdefault('extra_params', {}) - # selected_idp should be a comma separated string - allowed_idps = ",".join(self.authenticator.allowed_idps.keys()) - extra_params["selected_idp"] = allowed_idps - + extra_params["selected_idp"] = _get_select_idp_param( + self.authenticator.allowed_idps + ) if self.authenticator.skin: extra_params["skin"] = self.authenticator.skin @@ -124,6 +141,7 @@ def _validate_scope(self, proposal): "domain": "utoronto.ca", }, "allow_all": True, + "default": True, }, "http://google.com/accounts/o8/id": { "username_derivation": { @@ -150,6 +168,9 @@ def _validate_scope(self, proposal): This is a description of the configuration you can pass to `allowed_idps`. + * `default`: bool (optional) + Determines the identity provider to be pre-selected in a list for + users arriving to CILogons login screen. * `username_derivation`: string (required) * `username_claim`: string (required) The claim in the `userinfo` response from which to define the diff --git a/oauthenticator/tests/test_cilogon.py b/oauthenticator/tests/test_cilogon.py index c638a0e0..673835a2 100644 --- a/oauthenticator/tests/test_cilogon.py +++ b/oauthenticator/tests/test_cilogon.py @@ -6,7 +6,7 @@ from traitlets.config import Config from traitlets.traitlets import TraitError -from ..cilogon import CILogonOAuthenticator +from ..cilogon import CILogonOAuthenticator, _get_select_idp_param from .mocks import setup_oauth_mock @@ -675,3 +675,44 @@ async def test_allowed_idps_username_derivation_actions(cilogon_client): auth_model = await authenticator.get_authenticated_user(handler, None) print(json.dumps(auth_model, sort_keys=True, indent=4)) assert auth_model['name'] == 'jtkirk' + + +@mark.parametrize( + "test_variation_id,allowed_idps,expected_return_value", + [ + ( + "default-specified", + { + 'https://example4.org': {}, + 'https://example3.org': {'default': False}, + 'https://example2.org': {'default': True}, + 'https://example1.org': {}, + }, + "https://example2.org,https://example4.org,https://example3.org,https://example1.org", + ), + ( + "no-truthy-default-specified", + { + 'https://example4.org': {}, + 'https://example3.org': {'default': False}, + 'https://example2.org': {}, + 'https://example1.org': {}, + }, + "https://example4.org,https://example3.org,https://example2.org,https://example1.org", + ), + ( + "no-default-specified-pick-first-entry", + { + 'https://example4.org': {}, + 'https://example3.org': {}, + 'https://example2.org': {}, + 'https://example1.org': {}, + }, + "https://example4.org,https://example3.org,https://example2.org,https://example1.org", + ), + ], +) +async def test__get_selected_idp_param( + test_variation_id, allowed_idps, expected_return_value +): + assert _get_select_idp_param(allowed_idps) == expected_return_value From 2069fd0d4fb81f198940523190c23ff192f9622d Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Tue, 21 Nov 2023 21:44:13 +0100 Subject: [PATCH 058/103] cilogon: allow fnmatch based expressions in allowed_domains --- oauthenticator/cilogon.py | 19 +++++++++++++++++-- oauthenticator/tests/test_cilogon.py | 13 +++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/oauthenticator/cilogon.py b/oauthenticator/cilogon.py index 8317e776..8403a1b0 100644 --- a/oauthenticator/cilogon.py +++ b/oauthenticator/cilogon.py @@ -2,6 +2,7 @@ A JupyterHub authenticator class for use with CILogon as an identity provider. """ import os +from fnmatch import fnmatch from urllib.parse import urlparse import jsonschema @@ -174,6 +175,11 @@ def _validate_scope(self, proposal): address enables users to be allowed if their `username_claim` ends with `@` followed by a domain in this list. + Use of wildcards `*` and a bit more is supported via Python's + `fnmatch` function since version 16.2. Setting `allowed_domains` to + `["jupyter.org", "*.jupyter.org"]` would for example allow users + with `jovyan@jupyter.org` or `jovyan@hub.jupyter.org` usernames. + .. versionchanged:: 15.0 Changed format from a list to a dictionary. @@ -366,8 +372,17 @@ async def check_allowed(self, username, auth_model): if idp_allowed_domains: unprocessed_username = self._user_info_to_unprocessed_username(user_info) user_domain = unprocessed_username.split("@", 1)[1].lower() - if user_domain in idp_allowed_domains: - return True + + for ad in idp_allowed_domains: + # fnmatch allow us to use wildcards like * and ?, but + # not the full regex. For simple domain matching this is + # good enough. If we were to use regexes instead, people + # will have to escape all their '.'s, and since that is + # actually going to match 'any character' it is a + # possible security hole. For details see + # https://docs.python.org/3/library/fnmatch.html. + if fnmatch(user_domain, ad): + return True # users should be explicitly allowed via config, otherwise they aren't return False diff --git a/oauthenticator/tests/test_cilogon.py b/oauthenticator/tests/test_cilogon.py index c638a0e0..3081dd22 100644 --- a/oauthenticator/tests/test_cilogon.py +++ b/oauthenticator/tests/test_cilogon.py @@ -231,6 +231,19 @@ async def test_cilogon( False, None, ), + ( + "A - allowed by allowed_domains via a wildcard", + { + "username_derivation": { + "username_claim": "email", + }, + "allowed_domains": ["allowed-domain.org", "*.allowed-domain.org"], + }, + {}, + "user1@sub.allowed-domain.org", + True, + None, + ), # test of allowed_users and admin_users together with # username_derivation actions to verify the final usernames is what # matters when describing allowed_users and admin_users From 4c869c0d8c2f66094cc519aa5f281ad38785f634 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Wed, 22 Nov 2023 15:53:10 +0100 Subject: [PATCH 059/103] cilogon: make allowed_domains list of strings lowered --- oauthenticator/cilogon.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/oauthenticator/cilogon.py b/oauthenticator/cilogon.py index d9fa7501..318fa451 100644 --- a/oauthenticator/cilogon.py +++ b/oauthenticator/cilogon.py @@ -236,6 +236,11 @@ def _validate_allowed_idps(self, proposal): "See https://cilogon.org/idplist for the list of EntityIDs of each IDP." ) + # Make allowed_domains lowercase + idp_config["allowed_domains"] = [ + ad.lower() for ad in idp_config.get("allowed_domains", []) + ] + return idps skin = Unicode( From 8a8dc72db4594162fe67235502abb05d4ee43d17 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Wed, 22 Nov 2023 16:06:03 +0100 Subject: [PATCH 060/103] cilogon: add idp config allowed_domains_claim --- oauthenticator/cilogon.py | 35 ++++++++++++++++++---- oauthenticator/schemas/cilogon-schema.yaml | 2 ++ oauthenticator/tests/test_cilogon.py | 16 +++++++++- 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/oauthenticator/cilogon.py b/oauthenticator/cilogon.py index 318fa451..b11739fc 100644 --- a/oauthenticator/cilogon.py +++ b/oauthenticator/cilogon.py @@ -192,15 +192,28 @@ def _validate_scope(self, proposal): Configuring this allows all users authenticating with this identity provider. * `allowed_domains`: list of strings - Configuring this together with a `username_claim` that is an email - address enables users to be allowed if their `username_claim` ends - with `@` followed by a domain in this list. + Allows users associated with a listed domain to sign in. Use of wildcards `*` and a bit more is supported via Python's `fnmatch` function since version 16.2. Setting `allowed_domains` to `["jupyter.org", "*.jupyter.org"]` would for example allow users with `jovyan@jupyter.org` or `jovyan@hub.jupyter.org` usernames. + The domain the user is associated with is based on the username by + default in version 16, but this can be reconfigured to be based on a + claim in the `userinfo` response via `allowed_domains_claim`. The + domain is treated case insensitive and can either be directly + specified by the claim's value or extracted from an email string. + * `allowed_domains_claim`: string (optional) + This configuration represents the claim in the `userinfo` response + to identify a domain that could allow a user to sign in via + `allowed_domains`. + + The claim can defaults to the username claim in version 16, but this + will change to "email" in version 17. + + .. versionadded:: 16.2 + .. versionchanged:: 15.0 Changed format from a list to a dictionary. @@ -396,8 +409,20 @@ async def check_allowed(self, username, auth_model): idp_allowed_domains = self.allowed_idps[user_idp].get("allowed_domains") if idp_allowed_domains: - unprocessed_username = self._user_info_to_unprocessed_username(user_info) - user_domain = unprocessed_username.split("@", 1)[1].lower() + idp_allowed_domains_claim = self.allowed_idps[user_idp].get( + "allowed_domains_claim" + ) + if idp_allowed_domains_claim: + raw_user_domain = user_info.get(idp_allowed_domains_claim) + if not raw_user_domain: + message = f"Configured allowed_domains_claim {idp_allowed_domains_claim} for {user_idp} was not found in the response {user_info.keys()}" + self.log.error(message) + raise web.HTTPError(500, message) + else: + raw_user_domain = self._user_info_to_unprocessed_username(user_info) + + # refine a domain from a string that possibly looks like an email + user_domain = raw_user_domain.split("@")[-1].lower() for ad in idp_allowed_domains: # fnmatch allow us to use wildcards like * and ?, but diff --git a/oauthenticator/schemas/cilogon-schema.yaml b/oauthenticator/schemas/cilogon-schema.yaml index fb791599..3b97ce53 100644 --- a/oauthenticator/schemas/cilogon-schema.yaml +++ b/oauthenticator/schemas/cilogon-schema.yaml @@ -13,6 +13,8 @@ properties: type: array items: type: string + allowed_domains_claim: + type: string username_derivation: type: object additionalProperties: false diff --git a/oauthenticator/tests/test_cilogon.py b/oauthenticator/tests/test_cilogon.py index b67b359a..d8f46c54 100644 --- a/oauthenticator/tests/test_cilogon.py +++ b/oauthenticator/tests/test_cilogon.py @@ -151,7 +151,7 @@ async def test_cilogon( "username_derivation": { "username_claim": "email", }, - "allowed_domains": ["allowed-domain.org"], + "allowed_domains": ["ALLOWED-domain.org"], }, {}, "user1@allowed-domain.org", @@ -244,6 +244,20 @@ async def test_cilogon( True, None, ), + ( + "B - allowed by allowed_domains and allowed_domains_claim", + { + "username_derivation": { + "username_claim": "email", + }, + "allowed_domains": ["allowed-domain.org", "*.allowed-domain.org"], + "allowed_domains_claim": "email", + }, + {}, + "user1@sub.allowed-domain.org", + True, + None, + ), # test of allowed_users and admin_users together with # username_derivation actions to verify the final usernames is what # matters when describing allowed_users and admin_users From 5cb3ed958d919552f910da5cf3b4463a54f65746 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Wed, 22 Nov 2023 18:54:32 +0100 Subject: [PATCH 061/103] Add changelog for 16.2.0 --- docs/source/reference/changelog.md | 32 +++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/docs/source/reference/changelog.md b/docs/source/reference/changelog.md index 079e1fad..04a9b363 100644 --- a/docs/source/reference/changelog.md +++ b/docs/source/reference/changelog.md @@ -6,6 +6,35 @@ command line for details. ## [Unreleased] +## 16.2 + +### [16.2.0] - 2023-11-23 + +([full changelog](https://github.com/jupyterhub/oauthenticator/compare/16.1.1...16.2.0)) + +#### New features added + +- [CILogon] Add idp config `allowed_domains_claim` for use with `allowed_domains` [#702](https://github.com/jupyterhub/oauthenticator/pull/702) ([@consideRatio](https://github.com/consideRatio), [@GeorgianaElena](https://github.com/GeorgianaElena)) +- [CILogon] allow fnmatch based expressions in `allowed_domains`, such as `*.jupyter.org` [#701](https://github.com/jupyterhub/oauthenticator/pull/701) ([@consideRatio](https://github.com/consideRatio), [@GeorgianaElena](https://github.com/GeorgianaElena), [@minrk](https://github.com/minrk)) +- [CILogon] add config to specify default idp under allowed_idps [#699](https://github.com/jupyterhub/oauthenticator/pull/699) ([@consideRatio](https://github.com/consideRatio), [@GeorgianaElena](https://github.com/GeorgianaElena)) + +#### Bugs fixed + +- [All] Correcting Bearer Authorization header [#698](https://github.com/jupyterhub/oauthenticator/pull/698) ([@yaleman](https://github.com/yaleman), [@GeorgianaElena](https://github.com/GeorgianaElena), [@manics](https://github.com/manics), [@consideRatio](https://github.com/consideRatio)) + +#### Continuous integration improvements + +- ci: add test of python 3.12 [#700](https://github.com/jupyterhub/oauthenticator/pull/700) ([@consideRatio](https://github.com/consideRatio)) + +#### Contributors to this release + +The following people contributed discussions, new ideas, code and documentation contributions, and review. +See [our definition of contributors](https://github-activity.readthedocs.io/en/latest/#how-does-this-tool-define-contributions-in-the-reports). + +([GitHub contributors page for this release](https://github.com/jupyterhub/oauthenticator/graphs/contributors?from=2023-10-18&to=2023-11-23&type=c)) + +@consideRatio ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Foauthenticator+involves%3AconsideRatio+updated%3A2023-10-18..2023-11-23&type=Issues)) | @GeorgianaElena ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Foauthenticator+involves%3AGeorgianaElena+updated%3A2023-10-18..2023-11-23&type=Issues)) | @manics ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Foauthenticator+involves%3Amanics+updated%3A2023-10-18..2023-11-23&type=Issues)) | @minrk ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Foauthenticator+involves%3Aminrk+updated%3A2023-10-18..2023-11-23&type=Issues)) | @yaleman ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Foauthenticator+involves%3Ayaleman+updated%3A2023-10-18..2023-11-23&type=Issues)) + ## 16.1 ### [16.1.1] - 2023-10-18 @@ -808,7 +837,8 @@ It fixes handling of `gitlab_group_whitelist` when using GitLabOAuthenticator. - First release -[unreleased]: https://github.com/jupyterhub/oauthenticator/compare/16.1.0...HEAD +[unreleased]: https://github.com/jupyterhub/oauthenticator/compare/16.2.0...HEAD +[16.2.0]: https://github.com/jupyterhub/oauthenticator/compare/16.1.1...16.2.0 [16.1.1]: https://github.com/jupyterhub/oauthenticator/compare/16.1.0...16.1.1 [16.1.0]: https://github.com/jupyterhub/oauthenticator/compare/16.0.7...16.1.0 [16.0.7]: https://github.com/jupyterhub/oauthenticator/compare/16.0.6...16.0.7 From 7fe4a45a7660242cd1e6529f43381688a0dbb444 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Thu, 23 Nov 2023 12:05:13 +0100 Subject: [PATCH 062/103] Bump to 16.2.0 --- oauthenticator/_version.py | 2 +- pyproject.toml | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/oauthenticator/_version.py b/oauthenticator/_version.py index c58e6f46..6c7d3afa 100644 --- a/oauthenticator/_version.py +++ b/oauthenticator/_version.py @@ -1,7 +1,7 @@ # __version__ should be updated using tbump, based on configuration in # pyproject.toml, according to instructions in RELEASE.md. # -__version__ = "16.1.2.dev" +__version__ = "16.2.0" # version_info looks like (1, 2, 3, "dev") if __version__ is 1.2.3.dev version_info = tuple(int(p) if p.isdigit() else p for p in __version__.split(".")) diff --git a/pyproject.toml b/pyproject.toml index 10b03642..07149f15 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,7 +64,7 @@ omit = [ github_url = "https://github.com/jupyterhub/oauthenticator" [tool.tbump.version] -current = "16.1.2.dev" +current = "16.2.0" regex = ''' (?P\d+) \. diff --git a/setup.py b/setup.py index d4557f02..72abfca7 100644 --- a/setup.py +++ b/setup.py @@ -27,7 +27,7 @@ def run(self): setup_args = dict( name='oauthenticator', packages=find_packages(), - version="16.1.2.dev", + version="16.2.0", description="OAuthenticator: Authenticate JupyterHub users with common OAuth providers", long_description=open("README.md").read(), long_description_content_type="text/markdown", From 0982eea53a96a7916ef7d92c63776b500b0b5a5b Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Thu, 23 Nov 2023 12:05:29 +0100 Subject: [PATCH 063/103] Bump to 16.2.1.dev --- oauthenticator/_version.py | 2 +- pyproject.toml | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/oauthenticator/_version.py b/oauthenticator/_version.py index 6c7d3afa..d4b00a76 100644 --- a/oauthenticator/_version.py +++ b/oauthenticator/_version.py @@ -1,7 +1,7 @@ # __version__ should be updated using tbump, based on configuration in # pyproject.toml, according to instructions in RELEASE.md. # -__version__ = "16.2.0" +__version__ = "16.2.1.dev" # version_info looks like (1, 2, 3, "dev") if __version__ is 1.2.3.dev version_info = tuple(int(p) if p.isdigit() else p for p in __version__.split(".")) diff --git a/pyproject.toml b/pyproject.toml index 07149f15..045f02df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,7 +64,7 @@ omit = [ github_url = "https://github.com/jupyterhub/oauthenticator" [tool.tbump.version] -current = "16.2.0" +current = "16.2.1.dev" regex = ''' (?P\d+) \. diff --git a/setup.py b/setup.py index 72abfca7..243ad048 100644 --- a/setup.py +++ b/setup.py @@ -27,7 +27,7 @@ def run(self): setup_args = dict( name='oauthenticator', packages=find_packages(), - version="16.2.0", + version="16.2.1.dev", description="OAuthenticator: Authenticate JupyterHub users with common OAuth providers", long_description=open("README.md").read(), long_description_content_type="text/markdown", From f9ca59c12991dd24532c876d71702bc724203826 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Mon, 27 Nov 2023 18:30:30 +0100 Subject: [PATCH 064/103] cilogon: fix missing schema entry for default under allowed_idps --- oauthenticator/schemas/cilogon-schema.yaml | 2 ++ oauthenticator/tests/test_cilogon.py | 1 + 2 files changed, 3 insertions(+) diff --git a/oauthenticator/schemas/cilogon-schema.yaml b/oauthenticator/schemas/cilogon-schema.yaml index 3b97ce53..2f8436ff 100644 --- a/oauthenticator/schemas/cilogon-schema.yaml +++ b/oauthenticator/schemas/cilogon-schema.yaml @@ -15,6 +15,8 @@ properties: type: string allowed_domains_claim: type: string + default: + type: boolean username_derivation: type: object additionalProperties: false diff --git a/oauthenticator/tests/test_cilogon.py b/oauthenticator/tests/test_cilogon.py index d8f46c54..4dac4927 100644 --- a/oauthenticator/tests/test_cilogon.py +++ b/oauthenticator/tests/test_cilogon.py @@ -636,6 +636,7 @@ async def test_allowed_idps_username_derivation_actions(cilogon_client): c.CILogonOAuthenticator.allow_all = True c.CILogonOAuthenticator.allowed_idps = { 'https://strip-idp-domain.example.com/login/oauth/authorize': { + 'default': True, 'username_derivation': { 'username_claim': 'email', 'action': 'strip_idp_domain', From 4eda6d3313209fff7144f2aeadf8527b345c61ed Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Mon, 27 Nov 2023 18:34:08 +0100 Subject: [PATCH 065/103] Add changelog for 16.2.1 --- docs/source/reference/changelog.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/source/reference/changelog.md b/docs/source/reference/changelog.md index 04a9b363..98fe7f14 100644 --- a/docs/source/reference/changelog.md +++ b/docs/source/reference/changelog.md @@ -8,6 +8,14 @@ command line for details. ## 16.2 +### [16.2.1] - 2023-11-27 + +([full changelog](https://github.com/jupyterhub/oauthenticator/compare/16.2.0...16.2.1)) + +#### Bugs fixed + +- [CILogon] Fix missing schema entry for default under allowed_idps [#704](https://github.com/jupyterhub/oauthenticator/pull/704) ([@consideRatio](https://github.com/consideRatio)) + ### [16.2.0] - 2023-11-23 ([full changelog](https://github.com/jupyterhub/oauthenticator/compare/16.1.1...16.2.0)) @@ -837,7 +845,8 @@ It fixes handling of `gitlab_group_whitelist` when using GitLabOAuthenticator. - First release -[unreleased]: https://github.com/jupyterhub/oauthenticator/compare/16.2.0...HEAD +[unreleased]: https://github.com/jupyterhub/oauthenticator/compare/16.2.1...HEAD +[16.2.1]: https://github.com/jupyterhub/oauthenticator/compare/16.2.0...16.2.1 [16.2.0]: https://github.com/jupyterhub/oauthenticator/compare/16.1.1...16.2.0 [16.1.1]: https://github.com/jupyterhub/oauthenticator/compare/16.1.0...16.1.1 [16.1.0]: https://github.com/jupyterhub/oauthenticator/compare/16.0.7...16.1.0 From c39cb665d1752295992c1287765c13d78438a054 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Mon, 27 Nov 2023 18:39:21 +0100 Subject: [PATCH 066/103] docs: omit writing full changelog at since we have link in header --- docs/source/reference/changelog.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/docs/source/reference/changelog.md b/docs/source/reference/changelog.md index 98fe7f14..543db60f 100644 --- a/docs/source/reference/changelog.md +++ b/docs/source/reference/changelog.md @@ -10,16 +10,12 @@ command line for details. ### [16.2.1] - 2023-11-27 -([full changelog](https://github.com/jupyterhub/oauthenticator/compare/16.2.0...16.2.1)) - #### Bugs fixed - [CILogon] Fix missing schema entry for default under allowed_idps [#704](https://github.com/jupyterhub/oauthenticator/pull/704) ([@consideRatio](https://github.com/consideRatio)) ### [16.2.0] - 2023-11-23 -([full changelog](https://github.com/jupyterhub/oauthenticator/compare/16.1.1...16.2.0)) - #### New features added - [CILogon] Add idp config `allowed_domains_claim` for use with `allowed_domains` [#702](https://github.com/jupyterhub/oauthenticator/pull/702) ([@consideRatio](https://github.com/consideRatio), [@GeorgianaElena](https://github.com/GeorgianaElena)) @@ -47,8 +43,6 @@ See [our definition of contributors](https://github-activity.readthedocs.io/en/l ### [16.1.1] - 2023-10-18 -([full changelog](https://github.com/jupyterhub/oauthenticator/compare/16.1.0...16.1.1)) - ```{note} The OkpyOAuthenticator was removed in this patch release as its believed to have no users. If you were an active user, please re-configure to use From 3db8afa6a54666dfddc758fe8f2e5fa58d4093ea Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Mon, 27 Nov 2023 18:39:50 +0100 Subject: [PATCH 067/103] Bump to 16.2.1 --- oauthenticator/_version.py | 2 +- pyproject.toml | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/oauthenticator/_version.py b/oauthenticator/_version.py index d4b00a76..ba5ae640 100644 --- a/oauthenticator/_version.py +++ b/oauthenticator/_version.py @@ -1,7 +1,7 @@ # __version__ should be updated using tbump, based on configuration in # pyproject.toml, according to instructions in RELEASE.md. # -__version__ = "16.2.1.dev" +__version__ = "16.2.1" # version_info looks like (1, 2, 3, "dev") if __version__ is 1.2.3.dev version_info = tuple(int(p) if p.isdigit() else p for p in __version__.split(".")) diff --git a/pyproject.toml b/pyproject.toml index 045f02df..914d588b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,7 +64,7 @@ omit = [ github_url = "https://github.com/jupyterhub/oauthenticator" [tool.tbump.version] -current = "16.2.1.dev" +current = "16.2.1" regex = ''' (?P\d+) \. diff --git a/setup.py b/setup.py index 243ad048..cd9a2d9a 100644 --- a/setup.py +++ b/setup.py @@ -27,7 +27,7 @@ def run(self): setup_args = dict( name='oauthenticator', packages=find_packages(), - version="16.2.1.dev", + version="16.2.1", description="OAuthenticator: Authenticate JupyterHub users with common OAuth providers", long_description=open("README.md").read(), long_description_content_type="text/markdown", From f3cc7393fdb38a2d56d374efaebe6b4d19b041cb Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Mon, 27 Nov 2023 18:40:16 +0100 Subject: [PATCH 068/103] Bump to 16.2.2.dev --- oauthenticator/_version.py | 2 +- pyproject.toml | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/oauthenticator/_version.py b/oauthenticator/_version.py index ba5ae640..01d20b3a 100644 --- a/oauthenticator/_version.py +++ b/oauthenticator/_version.py @@ -1,7 +1,7 @@ # __version__ should be updated using tbump, based on configuration in # pyproject.toml, according to instructions in RELEASE.md. # -__version__ = "16.2.1" +__version__ = "16.2.2.dev" # version_info looks like (1, 2, 3, "dev") if __version__ is 1.2.3.dev version_info = tuple(int(p) if p.isdigit() else p for p in __version__.split(".")) diff --git a/pyproject.toml b/pyproject.toml index 914d588b..ec038069 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,7 +64,7 @@ omit = [ github_url = "https://github.com/jupyterhub/oauthenticator" [tool.tbump.version] -current = "16.2.1" +current = "16.2.2.dev" regex = ''' (?P\d+) \. diff --git a/setup.py b/setup.py index cd9a2d9a..89b34df1 100644 --- a/setup.py +++ b/setup.py @@ -27,7 +27,7 @@ def run(self): setup_args = dict( name='oauthenticator', packages=find_packages(), - version="16.2.1", + version="16.2.2.dev", description="OAuthenticator: Authenticate JupyterHub users with common OAuth providers", long_description=open("README.md").read(), long_description_content_type="text/markdown", From c2fb51e082426e15dc78cf67bf7290cecca54c07 Mon Sep 17 00:00:00 2001 From: Min RK Date: Tue, 28 Nov 2023 12:41:14 +0100 Subject: [PATCH 069/103] clarify what claim_groups_key is used for and explicitly state that it's not related to JupyterHub groups --- oauthenticator/generic.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/oauthenticator/generic.py b/oauthenticator/generic.py index 3f2987c8..31945d6a 100644 --- a/oauthenticator/generic.py +++ b/oauthenticator/generic.py @@ -25,6 +25,10 @@ def _login_service_default(self): Can be a string key name (use periods for nested keys), or a callable that accepts the returned json (as a dict) and returns the groups list. + + This configures how group membership in the upstream provider is determined + for use by `allowed_groups`, `admin_groups`, etc. + It has no effect on its own, and is not related to users' _JupyterHub_ group membership. """, ) From 649841c23e7e625a8d981885534a9cbae2293964 Mon Sep 17 00:00:00 2001 From: Min RK Date: Wed, 29 Nov 2023 10:30:17 +0100 Subject: [PATCH 070/103] azuread: populate groups only if manage_groups is true allows user_groups_claim to have a default value --- .../provider-specific-setup/providers/azuread.md | 8 +++----- oauthenticator/azuread.py | 14 ++++++++++---- oauthenticator/tests/test_azuread.py | 12 ++++++++---- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/docs/source/tutorials/provider-specific-setup/providers/azuread.md b/docs/source/tutorials/provider-specific-setup/providers/azuread.md index 59c57e7e..09359d69 100644 --- a/docs/source/tutorials/provider-specific-setup/providers/azuread.md +++ b/docs/source/tutorials/provider-specific-setup/providers/azuread.md @@ -39,14 +39,12 @@ This is done by setting the `AzureAdOAuthenticator.groups_claim` to the name of group-membership. ```python -import os -from oauthenticator.azuread import AzureAdOAuthenticator - -c.JupyterHub.authenticator_class = AzureAdOAuthenticator +c.JupyterHub.authenticator_class = "azuread" # {...} other settings (see above) -c.AzureAdOAuthenticator.user_groups_claim = 'groups' +c.AzureAdOAuthenticator.manage_groups = True +c.AzureAdOAuthenticator.user_groups_claim = 'groups' # this is the default ``` This requires Azure AD to be configured to include the group-membership in the access token. diff --git a/oauthenticator/azuread.py b/oauthenticator/azuread.py index 2bbd201f..48359864 100644 --- a/oauthenticator/azuread.py +++ b/oauthenticator/azuread.py @@ -22,7 +22,13 @@ def _username_claim_default(self): return "name" user_groups_claim = Unicode( - "", config=True, help="Name of claim containing user group memberships" + "groups", + config=True, + help=""" + Name of claim containing user group memberships. + + Will populate JupyterHub groups if Authenticator.manage_groups is True. + """, ) tenant_id = Unicode( @@ -51,9 +57,9 @@ def _token_url_default(self): async def update_auth_model(self, auth_model, **kwargs): auth_model = await super().update_auth_model(auth_model, **kwargs) - user_info = auth_model["auth_state"][self.user_auth_state_key] - if self.user_groups_claim: - auth_model["groups"] = user_info.get(self.user_groups_claim) + if getattr(self, "manage_groups", False): + user_info = auth_model["auth_state"][self.user_auth_state_key] + auth_model["groups"] = user_info[self.user_groups_claim] return auth_model diff --git a/oauthenticator/tests/test_azuread.py b/oauthenticator/tests/test_azuread.py index 2b8322c1..aa974b29 100644 --- a/oauthenticator/tests/test_azuread.py +++ b/oauthenticator/tests/test_azuread.py @@ -117,13 +117,17 @@ def user_model(tenant_id, client_id, name): # test user_groups_claim ( "30", - {"allow_all": True, "user_groups_claim": "groups"}, + {"allow_all": True, "manage_groups": True}, True, None, ), ( "31", - {"allow_all": True, "user_groups_claim": "grp"}, + { + "allow_all": True, + "manage_groups": True, + "user_groups_claim": "grp", + }, True, None, ), @@ -155,7 +159,7 @@ async def test_azuread( if expect_allowed: assert auth_model expected_keys = {"name", "admin", "auth_state"} - if authenticator.user_groups_claim: + if authenticator.manage_groups: expected_keys.add("groups") assert set(auth_model) == expected_keys assert auth_model["admin"] == expect_admin @@ -165,7 +169,7 @@ async def test_azuread( user_info = auth_state[authenticator.user_auth_state_key] assert user_info["aud"] == authenticator.client_id assert auth_model["name"] == user_info[authenticator.username_claim] - if authenticator.user_groups_claim: + if authenticator.manage_groups: groups = auth_model['groups'] assert groups == user_info[authenticator.user_groups_claim] else: From e29012d49df8b91e2423f78ad0c649cc006da4d0 Mon Sep 17 00:00:00 2001 From: Min RK Date: Wed, 29 Nov 2023 10:38:21 +0100 Subject: [PATCH 071/103] skip manage_groups tests on too-old jupyterhub --- oauthenticator/tests/test_azuread.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/oauthenticator/tests/test_azuread.py b/oauthenticator/tests/test_azuread.py index aa974b29..b3537dd5 100644 --- a/oauthenticator/tests/test_azuread.py +++ b/oauthenticator/tests/test_azuread.py @@ -7,6 +7,7 @@ from unittest import mock import jwt +import pytest from pytest import fixture, mark from traitlets.config import Config @@ -147,6 +148,12 @@ async def test_azuread( c.AzureAdOAuthenticator.client_id = str(uuid.uuid1()) c.AzureAdOAuthenticator.client_secret = str(uuid.uuid1()) authenticator = AzureAdOAuthenticator(config=c) + manage_groups = False + if "manage_groups" in class_config: + if hasattr(authenticator, "manage_groups"): + manage_groups = authenticator.manage_groups + else: + pytest.skip("manage_groups requires jupyterhub 2.2") handled_user_model = user_model( tenant_id=authenticator.tenant_id, @@ -159,7 +166,7 @@ async def test_azuread( if expect_allowed: assert auth_model expected_keys = {"name", "admin", "auth_state"} - if authenticator.manage_groups: + if manage_groups: expected_keys.add("groups") assert set(auth_model) == expected_keys assert auth_model["admin"] == expect_admin @@ -169,7 +176,7 @@ async def test_azuread( user_info = auth_state[authenticator.user_auth_state_key] assert user_info["aud"] == authenticator.client_id assert auth_model["name"] == user_info[authenticator.username_claim] - if authenticator.manage_groups: + if manage_groups: groups = auth_model['groups'] assert groups == user_info[authenticator.user_groups_claim] else: From 5791da0bf06beed4541840c74d27c17c40ec7448 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 Jan 2024 05:06:18 +0000 Subject: [PATCH 072/103] Bump actions/setup-python from 4 to 5 Bumps [actions/setup-python](https://github.com/actions/setup-python) from 4 to 5. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/setup-python dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/release.yaml | 2 +- .github/workflows/test-docs.yaml | 2 +- .github/workflows/test.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 0958a28a..f119e391 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -34,7 +34,7 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: python-version: "3.11" diff --git a/.github/workflows/test-docs.yaml b/.github/workflows/test-docs.yaml index 87187eae..34aadda9 100644 --- a/.github/workflows/test-docs.yaml +++ b/.github/workflows/test-docs.yaml @@ -25,7 +25,7 @@ jobs: runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: python-version: "3.10" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8b82e5c9..341c5eb6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -45,7 +45,7 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: python-version: "${{ matrix.python }}" From ad459386517b2c72ee5e58da7ade69e5544e04a8 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 1 Jan 2024 20:22:56 +0000 Subject: [PATCH 073/103] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/pycqa/isort: 5.12.0 → 5.13.2](https://github.com/pycqa/isort/compare/5.12.0...5.13.2) - [github.com/psf/black: 23.10.1 → 23.12.1](https://github.com/psf/black/compare/23.10.1...23.12.1) - [github.com/pre-commit/mirrors-prettier: v3.0.3 → v4.0.0-alpha.8](https://github.com/pre-commit/mirrors-prettier/compare/v3.0.3...v4.0.0-alpha.8) --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9a695ada..5706f2ed 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -28,19 +28,19 @@ repos: # Autoformat: python imports - repo: https://github.com/pycqa/isort - rev: 5.12.0 + rev: 5.13.2 hooks: - id: isort # Autoformat: Python code - repo: https://github.com/psf/black - rev: 23.10.1 + rev: 23.12.1 hooks: - id: black # Autoformat: markdown, yaml - repo: https://github.com/pre-commit/mirrors-prettier - rev: v3.0.3 + rev: v4.0.0-alpha.8 hooks: - id: prettier From d5fb7c608886809034e46dd2a020b2ce20049216 Mon Sep 17 00:00:00 2001 From: Min RK Date: Mon, 8 Jan 2024 09:50:59 +0100 Subject: [PATCH 074/103] temporary pin for pytest-asyncio --- setup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 89b34df1..61fe2fab 100644 --- a/setup.py +++ b/setup.py @@ -102,7 +102,8 @@ def run(self): # dependencies above. 'test': [ 'pytest>=2.8', - 'pytest-asyncio', + # FIXME: unpin pytest-asyncio + 'pytest-asyncio>=0.17,<0.23', 'pytest-cov', 'requests-mock', # dependencies from azuread: From edbf67ad4bc8333975e0f75a7ed067f614907d84 Mon Sep 17 00:00:00 2001 From: YuviPanda Date: Wed, 17 Jan 2024 13:53:30 -0800 Subject: [PATCH 075/103] Move username_claim being callable to oauth2 autheneticator While trying to use Auth0 for authentication in one of our hubs, we discovered that the most useful username_claim (`sub`) produces usernames that look like `oauth2|cilogon|http://cilogon.org/servera/users/43431` (when using auth0 with CILogon). The last part of `sub` is generally whatever is passed on to auth0, so it's going to be different for different users. I had thought `username_claim` was a callable, but turns out that's only true for GenericOAuthenticator. I think it's pretty useful for every authenticator, so I've just moved that functionality out to the base class instead. I also added a test to verify it works. The test is in GenericOAuthenticator because it was the easiest place to put it, but it works across authenticators. This also means it is fully backwards compatible. --- oauthenticator/generic.py | 11 ----------- oauthenticator/oauth2.py | 22 +++++++++++++++------- oauthenticator/tests/test_generic.py | 24 ++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 18 deletions(-) diff --git a/oauthenticator/generic.py b/oauthenticator/generic.py index 31945d6a..4c7bb81c 100644 --- a/oauthenticator/generic.py +++ b/oauthenticator/generic.py @@ -113,17 +113,6 @@ def _default_http_client(self): """, ) - def user_info_to_username(self, user_info): - """ - Overrides OAuthenticator.user_info_to_username to support the - GenericOAuthenticator unique feature of allowing username_claim to be a - callable function. - """ - if callable(self.username_claim): - return self.username_claim(user_info) - else: - return super().user_info_to_username(user_info) - def get_user_groups(self, user_info): """ Returns a set of groups the user belongs to based on claim_groups_key diff --git a/oauthenticator/oauth2.py b/oauthenticator/oauth2.py index 9ba66ecf..1aa210c3 100644 --- a/oauthenticator/oauth2.py +++ b/oauthenticator/oauth2.py @@ -18,7 +18,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, Dict, List, Unicode, default +from traitlets import Any, Bool, Dict, List, Unicode, default, Union, Callable def guess_callback_uri(protocol, host, hub_server_url): @@ -376,14 +376,17 @@ def _token_url_default(self): def _userdata_url_default(self): return os.environ.get("OAUTH2_USERDATA_URL", "") - username_claim = Unicode( - "username", + username_claim = Union( + [Unicode(os.environ.get('OAUTH2_USERNAME_KEY', 'username')), Callable()], config=True, help=""" - The key to get the JupyterHub username from in the data response to the - request made to :attr:`userdata_url`. + When `userdata_url` returns a json response, the username will be taken + from this key. - Examples include: email, username, nickname + Can be a string key name or a callable that accepts the returned + userdata json (as a dict) and returns the username. The callable is + useful e.g. for extracting the username from a nested object in the + response or doing other post processing. What keys are available will depend on the scopes requested and the authenticator used. @@ -768,7 +771,12 @@ def user_info_to_username(self, user_info): Called by the :meth:`oauthenticator.OAuthenticator.authenticate` """ - username = user_info.get(self.username_claim, None) + + + if callable(self.username_claim): + username = self.username_claim(user_info) + else: + username = user_info.get(self.username_claim, None) if not username: message = (f"No {self.username_claim} found in {user_info}",) self.log.error(message) diff --git a/oauthenticator/tests/test_generic.py b/oauthenticator/tests/test_generic.py index 42dc7781..11d62755 100644 --- a/oauthenticator/tests/test_generic.py +++ b/oauthenticator/tests/test_generic.py @@ -12,6 +12,7 @@ def user_model(username, **kwargs): """Return a user model""" return { "username": username, + "sub": "oauth2|cilogon|http://cilogon.org/servera/users/43431", "scope": "basic", "groups": ["group1"], **kwargs, @@ -186,6 +187,29 @@ async def test_generic( else: assert auth_model == None +async def test_username_claim_callable( + get_authenticator, + generic_client, +): + c = Config() + c.GenericOAuthenticator = Config() + def username_claim(user_info): + username = user_info["sub"] + if username.startswith("oauth2|cilogon"): + cilogon_sub = username.rsplit("|", 1)[-1] + cilogon_sub_parts = cilogon_sub.split("/") + username = f"oauth2|cilogon|{cilogon_sub_parts[3]}|{cilogon_sub_parts[5]}" + return username + c.GenericOAuthenticator.username_claim = username_claim + c.GenericOAuthenticator.allow_all = True + authenticator = get_authenticator(config=c) + + handled_user_model = user_model("user1") + handler = generic_client.handler_for_user(handled_user_model) + auth_model = await authenticator.get_authenticated_user(handler, None) + + assert auth_model["name"] == "oauth2|cilogon|servera|43431" + async def test_generic_data(get_authenticator, generic_client): c = Config() From 4417ec88e2b739f3ac1452cf4ce4dc83636d69e0 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 17 Jan 2024 21:58:47 +0000 Subject: [PATCH 076/103] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- oauthenticator/oauth2.py | 3 +-- oauthenticator/tests/test_generic.py | 3 +++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/oauthenticator/oauth2.py b/oauthenticator/oauth2.py index 1aa210c3..cff6b032 100644 --- a/oauthenticator/oauth2.py +++ b/oauthenticator/oauth2.py @@ -18,7 +18,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, Dict, List, Unicode, default, Union, Callable +from traitlets import Any, Bool, Callable, Dict, List, Unicode, Union, default def guess_callback_uri(protocol, host, hub_server_url): @@ -772,7 +772,6 @@ def user_info_to_username(self, user_info): Called by the :meth:`oauthenticator.OAuthenticator.authenticate` """ - if callable(self.username_claim): username = self.username_claim(user_info) else: diff --git a/oauthenticator/tests/test_generic.py b/oauthenticator/tests/test_generic.py index 11d62755..c30fe23e 100644 --- a/oauthenticator/tests/test_generic.py +++ b/oauthenticator/tests/test_generic.py @@ -187,12 +187,14 @@ async def test_generic( else: assert auth_model == None + async def test_username_claim_callable( get_authenticator, generic_client, ): c = Config() c.GenericOAuthenticator = Config() + def username_claim(user_info): username = user_info["sub"] if username.startswith("oauth2|cilogon"): @@ -200,6 +202,7 @@ def username_claim(user_info): cilogon_sub_parts = cilogon_sub.split("/") username = f"oauth2|cilogon|{cilogon_sub_parts[3]}|{cilogon_sub_parts[5]}" return username + c.GenericOAuthenticator.username_claim = username_claim c.GenericOAuthenticator.allow_all = True authenticator = get_authenticator(config=c) From 77a43d11c8435f8d270e2f3a2114baf60723ef94 Mon Sep 17 00:00:00 2001 From: YuviPanda Date: Thu, 18 Jan 2024 14:03:00 -0800 Subject: [PATCH 077/103] Remove redefenition of `username_claim` in Generic --- oauthenticator/generic.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/oauthenticator/generic.py b/oauthenticator/generic.py index 4c7bb81c..4798280d 100644 --- a/oauthenticator/generic.py +++ b/oauthenticator/generic.py @@ -60,20 +60,6 @@ def _login_service_default(self): """, ) - username_claim = Union( - [Unicode(os.environ.get('OAUTH2_USERNAME_KEY', 'username')), Callable()], - config=True, - help=""" - When `userdata_url` returns a json response, the username will be taken - from this key. - - Can be a string key name or a callable that accepts the returned - userdata json (as a dict) and returns the username. The callable is - useful e.g. for extracting the username from a nested object in the - response. - """, - ) - @default("http_client") def _default_http_client(self): return AsyncHTTPClient( From 0bf24ac890ee0baa0757f2df9b493fff500af430 Mon Sep 17 00:00:00 2001 From: Simon Li Date: Mon, 22 Jan 2024 14:51:16 +0000 Subject: [PATCH 078/103] Require jupyterhub>=2.2 This means we can add support for `manage_groups` without checking the JupyterHub version. --- .github/workflows/test.yml | 4 ++-- requirements.txt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 341c5eb6..f83f7f29 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -56,8 +56,8 @@ jobs: - name: Downgrade to oldest dependencies if: matrix.oldest_dependencies != '' - # take any dependencies in requirements.txt such as jupyterhub>=1.2 and - # transform them to jupyterhub==1.2 so we can run tests with the + # take any dependencies in requirements.txt such as jupyterhub>=2.2 and + # transform them to jupyterhub==2.2 so we can run tests with the # earliest-supported versions run: | cat requirements.txt | grep '>=' | sed -e 's@>=@==@g' > oldest-requirements.txt diff --git a/requirements.txt b/requirements.txt index 74438405..5b3c81d3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ # jsonschema is used for validating authenticator configurations jsonschema -jupyterhub>=1.2 +jupyterhub>=2.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. From 561baf06aa507a52427bdf617eb0b1c8cf0ddea8 Mon Sep 17 00:00:00 2001 From: Ben Lewis Date: Tue, 28 Nov 2023 12:18:55 +0000 Subject: [PATCH 079/103] Support GenericOAuth managed groups --- oauthenticator/generic.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/oauthenticator/generic.py b/oauthenticator/generic.py index 31945d6a..43de2c2a 100644 --- a/oauthenticator/generic.py +++ b/oauthenticator/generic.py @@ -27,8 +27,8 @@ def _login_service_default(self): that accepts the returned json (as a dict) and returns the groups list. This configures how group membership in the upstream provider is determined - for use by `allowed_groups`, `admin_groups`, etc. - It has no effect on its own, and is not related to users' _JupyterHub_ group membership. + for use by `allowed_groups`, `admin_groups`, etc. If `manage_groups` is True, + this will also determine users' _JupyterHub_ group membership. """, ) @@ -153,16 +153,18 @@ async def update_auth_model(self, auth_model): Sets admin status to True or False if `admin_groups` is configured and the user isn't part of `admin_users` or `admin_groups`. Note that leaving it at None makes users able to retain an admin status while - setting it to False makes it be revoked. + setting it to False makes it be revoked. Also applies groups. """ + user_info = auth_model["auth_state"][self.user_auth_state_key] + user_groups = self.get_user_groups(user_info) + auth_model["groups"] = user_groups + if auth_model["admin"]: # auth_model["admin"] being True means the user was in admin_users return auth_model if self.admin_groups: # admin status should in this case be True or False, not None - user_info = auth_model["auth_state"][self.user_auth_state_key] - user_groups = self.get_user_groups(user_info) auth_model["admin"] = bool(user_groups & self.admin_groups) return auth_model From b3ed2b784cc7dbb12139d64cbac03f2a93a74afa Mon Sep 17 00:00:00 2001 From: Ben Lewis Date: Tue, 28 Nov 2023 22:51:25 +0000 Subject: [PATCH 080/103] Only populate groups if explicitly requested --- oauthenticator/generic.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/oauthenticator/generic.py b/oauthenticator/generic.py index 43de2c2a..14052d9e 100644 --- a/oauthenticator/generic.py +++ b/oauthenticator/generic.py @@ -153,11 +153,16 @@ async def update_auth_model(self, auth_model): Sets admin status to True or False if `admin_groups` is configured and the user isn't part of `admin_users` or `admin_groups`. Note that leaving it at None makes users able to retain an admin status while - setting it to False makes it be revoked. Also applies groups. + setting it to False makes it be revoked. + + Also populates groups if `manage_groups` is set. """ - user_info = auth_model["auth_state"][self.user_auth_state_key] - user_groups = self.get_user_groups(user_info) - auth_model["groups"] = user_groups + if self.manage_groups or self.admin_groups: + user_info = auth_model["auth_state"][self.user_auth_state_key] + user_groups = self.get_user_groups(user_info) + + if self.manage_groups: + auth_model["groups"] = user_groups if auth_model["admin"]: # auth_model["admin"] being True means the user was in admin_users From 648c623e181bd63f1b536ce611b1dac5996b0453 Mon Sep 17 00:00:00 2001 From: Min RK Date: Wed, 29 Nov 2023 12:10:54 +0100 Subject: [PATCH 081/103] handle manage_groups being unavailable before JupyterHub 2.2 --- oauthenticator/generic.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/oauthenticator/generic.py b/oauthenticator/generic.py index 14052d9e..2dce3fb3 100644 --- a/oauthenticator/generic.py +++ b/oauthenticator/generic.py @@ -157,11 +157,13 @@ async def update_auth_model(self, auth_model): Also populates groups if `manage_groups` is set. """ - if self.manage_groups or self.admin_groups: + # Authenticator.manage_groups is new in jupyterhub 2.2 + manage_groups = getattr(self, "manage_groups", False) + if manage_groups or self.admin_groups: user_info = auth_model["auth_state"][self.user_auth_state_key] user_groups = self.get_user_groups(user_info) - if self.manage_groups: + if manage_groups: auth_model["groups"] = user_groups if auth_model["admin"]: From 0b37825010398765df917b179b720cc320695d20 Mon Sep 17 00:00:00 2001 From: Min RK Date: Wed, 29 Nov 2023 12:19:05 +0100 Subject: [PATCH 082/103] [generic] test manage_groups --- oauthenticator/generic.py | 2 +- oauthenticator/tests/test_generic.py | 25 ++++++++++++++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/oauthenticator/generic.py b/oauthenticator/generic.py index 2dce3fb3..3b4542ae 100644 --- a/oauthenticator/generic.py +++ b/oauthenticator/generic.py @@ -164,7 +164,7 @@ async def update_auth_model(self, auth_model): user_groups = self.get_user_groups(user_info) if manage_groups: - auth_model["groups"] = user_groups + auth_model["groups"] = sorted(user_groups) if auth_model["admin"]: # auth_model["admin"] being True means the user was in admin_users diff --git a/oauthenticator/tests/test_generic.py b/oauthenticator/tests/test_generic.py index 42dc7781..f374d941 100644 --- a/oauthenticator/tests/test_generic.py +++ b/oauthenticator/tests/test_generic.py @@ -1,6 +1,7 @@ import json from functools import partial +import pytest from pytest import fixture, mark from traitlets.config import Config @@ -151,6 +152,15 @@ def get_authenticator(generic_client): False, False, ), + ( + "20", + { + "manage_groups": True, + "allow_all": True, + }, + True, + None, + ), ], ) async def test_generic( @@ -166,6 +176,13 @@ async def test_generic( c.GenericOAuthenticator = Config(class_config) c.GenericOAuthenticator.username_claim = "username" authenticator = get_authenticator(config=c) + manage_groups = False + if "manage_groups" in class_config: + try: + manage_groups = authenticator.manage_groups + except AttributeError: + pytest.skip("manage_groups requires jupyterhub 2.2") + 1 / 0 handled_user_model = user_model("user1") handler = generic_client.handler_for_user(handled_user_model) @@ -173,7 +190,10 @@ async def test_generic( if expect_allowed: assert auth_model - assert set(auth_model) == {"name", "admin", "auth_state"} + expected_keys = {"name", "admin", "auth_state"} + if manage_groups: + expected_keys.add("groups") + assert set(auth_model) == expected_keys assert auth_model["admin"] == expect_admin auth_state = auth_model["auth_state"] assert json.dumps(auth_state) @@ -183,6 +203,9 @@ async def test_generic( assert "scope" in auth_state user_info = auth_state[authenticator.user_auth_state_key] assert auth_model["name"] == user_info[authenticator.username_claim] + if manage_groups: + assert auth_model["groups"] == user_info[authenticator.claim_groups_key] + else: assert auth_model == None From f6dab1178dfa0e0a3a1d903ce40e6c5d51072b73 Mon Sep 17 00:00:00 2001 From: Ben Lewis Date: Mon, 22 Jan 2024 23:35:00 +0000 Subject: [PATCH 083/103] Assume jupyterhub >= 2.2 --- oauthenticator/generic.py | 6 ++---- oauthenticator/tests/test_generic.py | 7 +------ 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/oauthenticator/generic.py b/oauthenticator/generic.py index 3b4542ae..4dd88bb6 100644 --- a/oauthenticator/generic.py +++ b/oauthenticator/generic.py @@ -157,13 +157,11 @@ async def update_auth_model(self, auth_model): Also populates groups if `manage_groups` is set. """ - # Authenticator.manage_groups is new in jupyterhub 2.2 - manage_groups = getattr(self, "manage_groups", False) - if manage_groups or self.admin_groups: + if self.manage_groups or self.admin_groups: user_info = auth_model["auth_state"][self.user_auth_state_key] user_groups = self.get_user_groups(user_info) - if manage_groups: + if self.manage_groups: auth_model["groups"] = sorted(user_groups) if auth_model["admin"]: diff --git a/oauthenticator/tests/test_generic.py b/oauthenticator/tests/test_generic.py index f374d941..fcc96652 100644 --- a/oauthenticator/tests/test_generic.py +++ b/oauthenticator/tests/test_generic.py @@ -1,7 +1,6 @@ import json from functools import partial -import pytest from pytest import fixture, mark from traitlets.config import Config @@ -178,11 +177,7 @@ async def test_generic( authenticator = get_authenticator(config=c) manage_groups = False if "manage_groups" in class_config: - try: - manage_groups = authenticator.manage_groups - except AttributeError: - pytest.skip("manage_groups requires jupyterhub 2.2") - 1 / 0 + manage_groups = authenticator.manage_groups handled_user_model = user_model("user1") handler = generic_client.handler_for_user(handled_user_model) From 02fc0f9544a8d14629df955c95d60ab3e0637388 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 1 Feb 2024 05:52:14 +0000 Subject: [PATCH 084/103] Bump codecov/codecov-action from 3 to 4 Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 3 to 4. - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/v3...v4) --- updated-dependencies: - dependency-name: codecov/codecov-action dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f83f7f29..29c2538c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -69,4 +69,4 @@ jobs: pytest # GitHub action reference: https://github.com/codecov/codecov-action - - uses: codecov/codecov-action@v3 + - uses: codecov/codecov-action@v4 From eddcfd44ea13bfda700001d97aec7a2ec420dbf8 Mon Sep 17 00:00:00 2001 From: Ben Lewis Date: Fri, 8 Dec 2023 04:29:08 +0000 Subject: [PATCH 085/103] Add ID Token support --- oauthenticator/oauth2.py | 28 ++++++++++++++++++++++++++++ requirements.txt | 2 ++ 2 files changed, 30 insertions(+) diff --git a/oauthenticator/oauth2.py b/oauthenticator/oauth2.py index 9ba66ecf..47bcdcd0 100644 --- a/oauthenticator/oauth2.py +++ b/oauthenticator/oauth2.py @@ -5,6 +5,7 @@ """ import base64 import json +import jwt import os import uuid from urllib.parse import quote, urlencode, urlparse, urlunparse @@ -361,11 +362,16 @@ def _token_url_default(self): userdata_url = Unicode( config=True, + allow_none=True, help=""" The URL to where this authenticator makes a request to acquire user details with an access token received via a request to the :attr:`token_url`. + If this is explicitly set to None, this authenticator will attempt + to instead use an id token if one was provided by the + :attr:`token_url`. + For more context, see the `Protocol Flow section `_ in the OAuth2 standard document, specifically steps E-F. @@ -863,6 +869,8 @@ 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 `userdata_url` is None, checks for an `id_token` instead. + Args: token_info: the dictionary returned by the token request (exchanging the OAuth code for an Access Token) @@ -871,6 +879,26 @@ async def token_to_user(self, token_info): Called by the :meth:`oauthenticator.OAuthenticator.authenticate` """ + if self.userdata_url is None: + # 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 5b3c81d3..c88bbf16 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,5 @@ requests ruamel.yaml tornado traitlets +# PyJWT is used for parsing id tokens +pyjwt From 37c153eb2ea88aedd0b4b3613d9563ec573de5bd Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 2 Feb 2024 04:30:34 +0000 Subject: [PATCH 086/103] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- oauthenticator/oauth2.py | 18 ++++++++++++------ requirements.txt | 4 ++-- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/oauthenticator/oauth2.py b/oauthenticator/oauth2.py index 47bcdcd0..ab1fd513 100644 --- a/oauthenticator/oauth2.py +++ b/oauthenticator/oauth2.py @@ -5,11 +5,11 @@ """ import base64 import json -import jwt 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 @@ -885,7 +885,7 @@ async def token_to_user(self, token_info): if not id_token: raise web.HTTPError( 500, - f"An id token was not returned: {token_info}\nPlease configure authenticator.userdata_url" + 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 @@ -893,11 +893,17 @@ async def token_to_user(self, token_info): # 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)) + 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}") + 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 c88bbf16..23a7a413 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,11 @@ # jsonschema is used for validating authenticator configurations jsonschema jupyterhub>=2.2 +# PyJWT is used for parsing id tokens +pyjwt # 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. ruamel.yaml tornado traitlets -# PyJWT is used for parsing id tokens -pyjwt From 5c1a909888c6ba1baf82fb94d7964904070ec956 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 5 Feb 2024 20:26:49 +0000 Subject: [PATCH 087/103] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/psf/black: 23.12.1 → 24.1.1](https://github.com/psf/black/compare/23.12.1...24.1.1) - [github.com/pycqa/flake8: 6.1.0 → 7.0.0](https://github.com/pycqa/flake8/compare/6.1.0...7.0.0) --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5706f2ed..d297dab9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -34,7 +34,7 @@ repos: # Autoformat: Python code - repo: https://github.com/psf/black - rev: 23.12.1 + rev: 24.1.1 hooks: - id: black @@ -64,7 +64,7 @@ repos: # Lint: Python code - repo: https://github.com/pycqa/flake8 - rev: "6.1.0" + rev: "7.0.0" hooks: - id: flake8 From 968f82eea04e1b2d607f9389850c2f4aad285055 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 5 Feb 2024 20:28:39 +0000 Subject: [PATCH 088/103] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- docs/source/how-to/example-oauthenticator.py | 1 + examples/auth_state/jupyterhub_config.py | 1 + oauthenticator/auth0.py | 1 + oauthenticator/azuread.py | 1 + oauthenticator/bitbucket.py | 1 + oauthenticator/cilogon.py | 1 + oauthenticator/generic.py | 1 + oauthenticator/github.py | 1 + oauthenticator/gitlab.py | 1 + oauthenticator/globus.py | 1 + oauthenticator/google.py | 1 + oauthenticator/mediawiki.py | 1 + oauthenticator/oauth2.py | 1 + oauthenticator/openshift.py | 1 + oauthenticator/tests/conftest.py | 1 + oauthenticator/tests/mocks.py | 1 + oauthenticator/tests/test_azuread.py | 1 + 17 files changed, 17 insertions(+) diff --git a/docs/source/how-to/example-oauthenticator.py b/docs/source/how-to/example-oauthenticator.py index 4fd44336..c139ee14 100644 --- a/docs/source/how-to/example-oauthenticator.py +++ b/docs/source/how-to/example-oauthenticator.py @@ -1,6 +1,7 @@ """ Example OAuthenticator to use with My Service """ + from jupyterhub.auth import LocalAuthenticator from oauthenticator.oauth2 import OAuthenticator, OAuthLoginHandler diff --git a/examples/auth_state/jupyterhub_config.py b/examples/auth_state/jupyterhub_config.py index dbb143d3..2852361f 100644 --- a/examples/auth_state/jupyterhub_config.py +++ b/examples/auth_state/jupyterhub_config.py @@ -5,6 +5,7 @@ 2. pass select auth_state to Spawner via environment variables 3. enable auth_state via `JUPYTERHUB_CRYPT_KEY` and `enable_auth_state = True` """ + import os import pprint import warnings diff --git a/oauthenticator/auth0.py b/oauthenticator/auth0.py index 31b2fa27..b9fa2157 100644 --- a/oauthenticator/auth0.py +++ b/oauthenticator/auth0.py @@ -1,6 +1,7 @@ """ A JupyterHub authenticator class for use with Auth0 as an identity provider. """ + import os from jupyterhub.auth import LocalAuthenticator diff --git a/oauthenticator/azuread.py b/oauthenticator/azuread.py index 48359864..e4e6682b 100644 --- a/oauthenticator/azuread.py +++ b/oauthenticator/azuread.py @@ -1,6 +1,7 @@ """ A JupyterHub authenticator class for use with Azure AD as an identity provider. """ + import os import jwt diff --git a/oauthenticator/bitbucket.py b/oauthenticator/bitbucket.py index 8221bbfa..6a836f2e 100644 --- a/oauthenticator/bitbucket.py +++ b/oauthenticator/bitbucket.py @@ -1,6 +1,7 @@ """ A JupyterHub authenticator class for use with Bitbucket as an identity provider. """ + import os from jupyterhub.auth import LocalAuthenticator diff --git a/oauthenticator/cilogon.py b/oauthenticator/cilogon.py index b11739fc..bf08f14e 100644 --- a/oauthenticator/cilogon.py +++ b/oauthenticator/cilogon.py @@ -1,6 +1,7 @@ """ A JupyterHub authenticator class for use with CILogon as an identity provider. """ + import os from fnmatch import fnmatch from urllib.parse import urlparse diff --git a/oauthenticator/generic.py b/oauthenticator/generic.py index 4dd88bb6..237c7d54 100644 --- a/oauthenticator/generic.py +++ b/oauthenticator/generic.py @@ -1,6 +1,7 @@ """ A JupyterHub authenticator class for use with any OAuth2 based identity provider. """ + import os from functools import reduce diff --git a/oauthenticator/github.py b/oauthenticator/github.py index 29535e81..fcc63b3b 100644 --- a/oauthenticator/github.py +++ b/oauthenticator/github.py @@ -1,6 +1,7 @@ """ A JupyterHub authenticator class for use with GitHub as an identity provider. """ + import json import os import warnings diff --git a/oauthenticator/gitlab.py b/oauthenticator/gitlab.py index 1d32fe34..dde71b15 100644 --- a/oauthenticator/gitlab.py +++ b/oauthenticator/gitlab.py @@ -1,6 +1,7 @@ """ A JupyterHub authenticator class for use with GitLab as an identity provider. """ + import os import warnings from urllib.parse import quote diff --git a/oauthenticator/globus.py b/oauthenticator/globus.py index 1e19a80d..09066e85 100644 --- a/oauthenticator/globus.py +++ b/oauthenticator/globus.py @@ -1,6 +1,7 @@ """ A JupyterHub authenticator class for use with Globus as an identity provider. """ + import base64 import os import pickle diff --git a/oauthenticator/google.py b/oauthenticator/google.py index 49f506d0..0fad0152 100644 --- a/oauthenticator/google.py +++ b/oauthenticator/google.py @@ -1,6 +1,7 @@ """ A JupyterHub authenticator class for use with Google as an identity provider. """ + import os from jupyterhub.auth import LocalAuthenticator diff --git a/oauthenticator/mediawiki.py b/oauthenticator/mediawiki.py index ee3c46bd..04677f83 100644 --- a/oauthenticator/mediawiki.py +++ b/oauthenticator/mediawiki.py @@ -1,6 +1,7 @@ """ A JupyterHub authenticator class for use with MediaWiki as an identity provider. """ + import json import os from asyncio import wrap_future diff --git a/oauthenticator/oauth2.py b/oauthenticator/oauth2.py index 9ba66ecf..60edeb36 100644 --- a/oauthenticator/oauth2.py +++ b/oauthenticator/oauth2.py @@ -3,6 +3,7 @@ Founded based on work by Kyle Kelley (@rgbkrk) """ + import base64 import json import os diff --git a/oauthenticator/openshift.py b/oauthenticator/openshift.py index 599f989f..6b016b0d 100644 --- a/oauthenticator/openshift.py +++ b/oauthenticator/openshift.py @@ -1,6 +1,7 @@ """ A JupyterHub authenticator class for use with OpenShift as an identity provider. """ + import concurrent.futures import json import os diff --git a/oauthenticator/tests/conftest.py b/oauthenticator/tests/conftest.py index 30fe93e0..3ffbdf8c 100644 --- a/oauthenticator/tests/conftest.py +++ b/oauthenticator/tests/conftest.py @@ -1,4 +1,5 @@ """Py.Test fixtures""" + from pytest import fixture from tornado.httpclient import AsyncHTTPClient diff --git a/oauthenticator/tests/mocks.py b/oauthenticator/tests/mocks.py index 83909eb2..f59c537a 100644 --- a/oauthenticator/tests/mocks.py +++ b/oauthenticator/tests/mocks.py @@ -1,4 +1,5 @@ """Mocking utilities for testing""" + import json import os import re diff --git a/oauthenticator/tests/test_azuread.py b/oauthenticator/tests/test_azuread.py index b3537dd5..13be6b0d 100644 --- a/oauthenticator/tests/test_azuread.py +++ b/oauthenticator/tests/test_azuread.py @@ -1,4 +1,5 @@ """test azure ad""" + import json import os import re From 64783c2b5b32df3647554dc274330a418c7d4fcb Mon Sep 17 00:00:00 2001 From: Min RK Date: Fri, 9 Feb 2024 11:24:48 +0100 Subject: [PATCH 089/103] add dedicated doc on details of allowing access --- docs/source/index.md | 1 + docs/source/topic/allowing.md | 143 ++++++++++++++++++++++++++++++++++ oauthenticator/oauth2.py | 27 ++++++- 3 files changed, 167 insertions(+), 4 deletions(-) create mode 100644 docs/source/topic/allowing.md diff --git a/docs/source/index.md b/docs/source/index.md index e3663520..dbd98d2c 100644 --- a/docs/source/index.md +++ b/docs/source/index.md @@ -47,6 +47,7 @@ Topic guides go more in-depth on a particular topic. :maxdepth: 2 :caption: Topic guides +topic/allowing topic/extending ``` diff --git a/docs/source/topic/allowing.md b/docs/source/topic/allowing.md new file mode 100644 index 00000000..2af7e3c7 --- /dev/null +++ b/docs/source/topic/allowing.md @@ -0,0 +1,143 @@ +(allowing)= + +# Allowing access to your JupyterHub + +OAuthenticator is about deferring **authentication** to an external source, +assuming your users all have accounts _somewhere_. +But many of these sources (e.g. Google, GitHub) have _lots_ of users, and you don't want _all_ of them to be able to use your hub. +This is where **authorization** comes in. + +In OAuthenticator, authorization is represented via configuration options that start with `allow` or `block`. + +There are also lots of OAuth providers, and as a result, lots of ways to tell OAuthenticator who should be allowed to access your hub. + +## Default behavior: nobody is allowed! + +Assuming you have provided no `allow` configuration, the default behavior of OAuthenticator (starting with version 16) is to not allow any users unless explicitly authorized via _some_ `allow` configuration. +If you want anyone to be able to use your hub, you must specify at least one `allow` configuration. + +```{versionchanged} 16 +Prior to OAuthenticator 16, `allow_all` was _implied_ if `allowed_users` was not specified. +Starting from 16, `allow_all` can only be enabled explicitly. +``` + +## Allowing access + +There are several `allow_` configuration options, to grant access to users according to different rules. + +When you have only one `allow` configuration, the behavior is generally unambiguous: anyone allowed by the rule can login to the Hub, while anyone not explicitly allowed cannot login. +However, once you start adding additional `allow` configuration, there is some ambiguity in how multiple rules are combined. + +```{important} +Additional allow rules **can only grant access**, meaning they only _expand_ who has access to your hub. +Adding an `allow` rule cannot prevent access granted by another `allow` rule. +To block access, use `block` configuration. +``` + +That is, if a user is granted access by _any_ `allow` configuration, they are allowed. +An allow rule cannot _exclude_ access granted by another `allow` rule. + +An example: + +```python +c.GitHubOAuthenticator.allowed_users = {"mensah", "art"} +c.GitHubOAuthenticator.allowed_organizations = {"preservation"} +``` + +means that the users `mensah` and `art` are allowed, _and_ any member of the `preservation` organization are allowed. +Any user that doesn't meet any of the allow rules will not be allowed. + +| user | allowed | reason | +| ----- | ------- | ------------------------------------------------------- | +| art | True | in `allowed_users` | +| amena | True | member of `preservation` | +| tlacy | False | not in `allowed_users` and not member of `preservation` | + +### `allow_all` + +The first and simplest way to allow access is to any user who can successfully authenticate: + +```python +c.OAuthenticator.allow_all = True +``` + +This is appropriate when you use an authentication provider (e.g. an institutional single-sign-on provider), where everyone who has an account in the provider should have access to your Hub. +It may also be appropriate for unadvertised short-lived hubs, e.g. dedicated hubs for workshops that will be shutdown after a day, where you may decide it is acceptable to allow anyone who finds your hub to login. + +If `allow_all` is enabled, no other `allow` configuration will have any effect. + +```{seealso} +Configuration documentation for {attr}`.OAuthenticator.allow_all` +``` + +### `allowed_users` + +This is top-level JupyterHub configuration, shared by all Authenticators. +This specifies a list of users that are allowed by name. +This is the simplest authorization mechanism when you have a small group of users whose usernames you know: + +```python +c.OAuthenticator.allowed_users = {"mensah", "ratthi"} +``` + +If this is your only configuration, only these users will be allowed, no others. + +Note that any additional usernames in the deprecated `admin_users` configuration will also be added to the `allowed_users` set. + +```{seealso} +Configuration documentation for {attr}`.OAuthenticator.allowed_users` +``` + +### `allow_existing_users` + +JupyterHub can allow you to add and remove users while the Hub is running via the admin page. +If you add or remove users this way, they will be added to the JupyterHub database, but their ability to login will not be affected unless they are also granted access via an `allow` rule. + +To enable managing users via the admin panel, set + +```python +c.OAuthenticator.allow_existing_users = True +``` + +```{warning} +Enabling `allow_existing_users` means that _removing_ users from any explicit allow mechanisms will no longer revoke their access. +Once the user has been added to the database, the only way to revoke their access to the hub is to remove the user from JupyterHub entirely, via the admin page. +``` + +```{seealso} +Configuration documentation for {attr}`.OAuthenticator.allow_existing_users` +``` + +### provider-specific rules + +Each OAuthenticator provider may have its own provider-specific rules to allow groups of users access, such as: + +- {attr}`.CILogonOAuthenticator.allowed_idps` +- {attr}`.GitHubOAuthenticator.allowed_organizations` +- {attr}`.GitLabOAuthenticator.allowed_gitlab_groups` +- {attr}`.GlobusOAuthenticator.allowed_globus_groups` +- {attr}`.GoogleOAuthenticator.allowed_google_groups` + +## Blocking Access + +It's possible that you want to limit who has access to your Hub to less than all of the users granted access by your `allow` configuration. +`block` configuration always has higher priority than `allow` configuration, so if a user is explicitly allowed _and_ explicitly blocked, they will not be able to login. + +The only `block` configuration is the base Authenticators `block_users`, +a set of usernames that will not be allowed to login. + +### Revoking previously-allowed access + +Any users who have logged in previously will be present in the JupyterHub database. +Removing a user's login permissions (e.g. removing them from a GitLab project when using {attr}`.GitLabOAuthenticator.project_ids`) only prevents future logins; +it does not remove the user from the JupyterHub database. +This means that: + +1. any API tokens, that the user still has access to will continue to be valid, and can continue to be used, and +2. any still-valid browser sessions will continue to be logged in. + +```{important} +To fully remove a user's access to JupyterHub, +their login permission must be revoked _and_ their User fully deleted from the Hub, +e.g. via the admin page. +``` diff --git a/oauthenticator/oauth2.py b/oauthenticator/oauth2.py index 19ce762c..18d92afa 100644 --- a/oauthenticator/oauth2.py +++ b/oauthenticator/oauth2.py @@ -269,6 +269,8 @@ class OAuthenticator(Authenticator): help=""" Allow all authenticated users to login. + Overrides all other `allow` configuration. + .. versionadded:: 16.0 """, ) @@ -280,7 +282,7 @@ class OAuthenticator(Authenticator): Allow existing users to login. An existing user is a user in JupyterHub's database of users, and it - includes all users that has previously logged in. + includes all users that have previously logged in. .. warning:: @@ -291,9 +293,9 @@ class OAuthenticator(Authenticator): .. warning:: - When this is enabled and you are to remove access for one or more - users allowed via other config options, you must make sure that they - are not part of the database of users still. This can be tricky to do + When this is enabled and you wish to remove access for one or more + users previously allowed, you must make sure that they + are not removed from the jupyterhub database. This can be tricky to do if you stop allowing a group of externally managed users for example. With this enabled, JupyterHub admin users can visit `/hub/admin` or use @@ -1086,3 +1088,20 @@ def __init__(self, **kwargs): self._deprecated_oauth_trait, names=list(self._deprecated_oauth_aliases) ) super().__init__(**kwargs) + + +# patch allowed_users help string to match our definition +# base Authenticator class help string gives the wrong impression +# when combined with other allow options +OAuthenticator.class_traits()[ + "allowed_users" +].help = """ +Set of usernames that should be allowed to login. + +If unspecified, grants no access. + +At least one `allow` configuration must be specified +if any users are to have permission to access the Hub. + +Any users in `admin_users` will be added to this set. +""" From f26b4547af71d344f954a759a4074a31fda117c5 Mon Sep 17 00:00:00 2001 From: Min RK Date: Fri, 9 Feb 2024 12:20:17 +0100 Subject: [PATCH 090/103] add example for deploying with mock-oauth2-server useful for testing --- examples/mock-provider/README.md | 21 ++++++++++++++++ examples/mock-provider/jupyterhub_config.py | 27 +++++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 examples/mock-provider/README.md create mode 100644 examples/mock-provider/jupyterhub_config.py diff --git a/examples/mock-provider/README.md b/examples/mock-provider/README.md new file mode 100644 index 00000000..61adee27 --- /dev/null +++ b/examples/mock-provider/README.md @@ -0,0 +1,21 @@ +# Generic OAuth with mock provider + +This example uses [mock-oauth2-server][] to launch a standalone local OAuth2 provider and configures GenericOAuthenticator to use it. + +mock-auth2-server implements OpenID Connect (OIDC), and can be used to test GenericOAuthenticator configurations for use with OIDC providers without needing to register your application with a real OAuth provider. + +[mock-oauth2-server]: https://github.com/navikt/mock-oauth2-server + +To launch the oauth provider in a container: + +``` +docker run --rm -it -p 127.0.0.1:8080:8080 ghcr.io/navikt/mock-oauth2-server:2.1.1 +``` + +Then launch JupyterHub: + +``` +jupyterhub +``` + +When you login, you will be presented with a form allowing you to specify the username, and (optionally) any additional fields that should be present in the `userinfo` response. diff --git a/examples/mock-provider/jupyterhub_config.py b/examples/mock-provider/jupyterhub_config.py new file mode 100644 index 00000000..60c9f09a --- /dev/null +++ b/examples/mock-provider/jupyterhub_config.py @@ -0,0 +1,27 @@ +c = get_config() # noqa + +c.JupyterHub.authenticator_class = "generic-oauth" + +# assumes oauth provider run with: +# docker run --rm -it -p 127.0.0.1:8080:8080 ghcr.io/navikt/mock-oauth2-server:2.1.1 + +provider = "http://127.0.0.1:8080/default" +c.GenericOAuthenticator.authorize_url = f"{provider}/authorize" +c.GenericOAuthenticator.token_url = f"{provider}/token" +c.GenericOAuthenticator.userdata_url = f"{provider}/userinfo" +c.GenericOAuthenticator.scope = ["openid", "somescope", "otherscope"] + +# these are the defaults. They can be configured at http://localhost:8080/default/debugger +c.GenericOAuthenticator.client_id = "debugger" +c.GenericOAuthenticator.client_secret = "someSecret" + +# 'sub' is the first field in the login form +c.GenericOAuthenticator.username_claim = "sub" + +c.GenericOAuthenticator.allow_all = True +c.GenericOAuthenticator.admin_users = {"admin"} + +# demo boilerplate +c.JupyterHub.default_url = "/hub/home" +c.JupyterHub.spawner_class = "simple" +c.JupyterHub.ip = "127.0.0.1" From 9445181c7a40411ef3cd4d92d1582efbe9ed8b28 Mon Sep 17 00:00:00 2001 From: Min RK Date: Fri, 9 Feb 2024 14:58:07 +0100 Subject: [PATCH 091/103] nicer short links Co-authored-by: Erik Sundell --- examples/mock-provider/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/mock-provider/README.md b/examples/mock-provider/README.md index 61adee27..8e022e19 100644 --- a/examples/mock-provider/README.md +++ b/examples/mock-provider/README.md @@ -1,6 +1,6 @@ # Generic OAuth with mock provider -This example uses [mock-oauth2-server][] to launch a standalone local OAuth2 provider and configures GenericOAuthenticator to use it. +This example uses [mock-oauth2-server] to launch a standalone local OAuth2 provider and configures GenericOAuthenticator to use it. mock-auth2-server implements OpenID Connect (OIDC), and can be used to test GenericOAuthenticator configurations for use with OIDC providers without needing to register your application with a real OAuth provider. From 0399aa59c3ca81d36242bd901754f6cee5c9d7f6 Mon Sep 17 00:00:00 2001 From: Min RK Date: Fri, 9 Feb 2024 15:01:11 +0100 Subject: [PATCH 092/103] Apply suggestions from code review Co-authored-by: Simon Li --- docs/source/topic/allowing.md | 8 ++++---- oauthenticator/oauth2.py | 6 ++---- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/docs/source/topic/allowing.md b/docs/source/topic/allowing.md index 2af7e3c7..ba655392 100644 --- a/docs/source/topic/allowing.md +++ b/docs/source/topic/allowing.md @@ -13,7 +13,7 @@ There are also lots of OAuth providers, and as a result, lots of ways to tell OA ## Default behavior: nobody is allowed! -Assuming you have provided no `allow` configuration, the default behavior of OAuthenticator (starting with version 16) is to not allow any users unless explicitly authorized via _some_ `allow` configuration. +The default behavior of OAuthenticator (starting with version 16) is to block all users unless explicitly authorized via _some_ `allow` configuration. If you want anyone to be able to use your hub, you must specify at least one `allow` configuration. ```{versionchanged} 16 @@ -91,7 +91,7 @@ Configuration documentation for {attr}`.OAuthenticator.allowed_users` ### `allow_existing_users` JupyterHub can allow you to add and remove users while the Hub is running via the admin page. -If you add or remove users this way, they will be added to the JupyterHub database, but their ability to login will not be affected unless they are also granted access via an `allow` rule. +If you add or remove users this way, they will be added to the JupyterHub database, but they will not be able to login unless they are also granted access via an `allow` rule. To enable managing users via the admin panel, set @@ -121,7 +121,7 @@ Each OAuthenticator provider may have its own provider-specific rules to allow g ## Blocking Access It's possible that you want to limit who has access to your Hub to less than all of the users granted access by your `allow` configuration. -`block` configuration always has higher priority than `allow` configuration, so if a user is explicitly allowed _and_ explicitly blocked, they will not be able to login. +`block` configuration always has higher priority than `allow` configuration, so if a user is both allowed _and_ blocked, they will not be able to login. The only `block` configuration is the base Authenticators `block_users`, a set of usernames that will not be allowed to login. @@ -133,7 +133,7 @@ Removing a user's login permissions (e.g. removing them from a GitLab project wh it does not remove the user from the JupyterHub database. This means that: -1. any API tokens, that the user still has access to will continue to be valid, and can continue to be used, and +1. any API tokens that the user still has access to will continue to be valid, and can continue to be used 2. any still-valid browser sessions will continue to be logged in. ```{important} diff --git a/oauthenticator/oauth2.py b/oauthenticator/oauth2.py index 18d92afa..ecb4ce92 100644 --- a/oauthenticator/oauth2.py +++ b/oauthenticator/oauth2.py @@ -295,7 +295,7 @@ class OAuthenticator(Authenticator): When this is enabled and you wish to remove access for one or more users previously allowed, you must make sure that they - are not removed from the jupyterhub database. This can be tricky to do + are removed from the jupyterhub database. This can be tricky to do if you stop allowing a group of externally managed users for example. With this enabled, JupyterHub admin users can visit `/hub/admin` or use @@ -1098,9 +1098,7 @@ def __init__(self, **kwargs): ].help = """ Set of usernames that should be allowed to login. -If unspecified, grants no access. - -At least one `allow` configuration must be specified +If unspecified, grants no access. You must set at least one other `allow` configuration if any users are to have permission to access the Hub. Any users in `admin_users` will be added to this set. From 3ce840988c0a7c455302ad2f6521eca57b34120e Mon Sep 17 00:00:00 2001 From: Ben Lewis Date: Mon, 12 Feb 2024 06:08:21 +0000 Subject: [PATCH 093/103] Introduce "userdata_from_id_token" toggle --- oauthenticator/oauth2.py | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/oauthenticator/oauth2.py b/oauthenticator/oauth2.py index ab1fd513..85d895d7 100644 --- a/oauthenticator/oauth2.py +++ b/oauthenticator/oauth2.py @@ -360,21 +360,36 @@ 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 + `_ + in the OIDC Core standard document. + """, + ) + userdata_url = Unicode( config=True, - allow_none=True, help=""" The URL to where this authenticator makes a request to acquire user details with an access token received via a request to the :attr:`token_url`. - If this is explicitly set to None, this authenticator will attempt - to instead use an id token if one was provided by the - :attr:`token_url`. - For more context, see the `Protocol Flow section `_ in the OAuth2 standard document, specifically steps E-F. + + Incompatible with :attr:`userdata_from_id_token`. """, ) @@ -869,7 +884,8 @@ 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 `userdata_url` is None, checks for an `id_token` instead. + 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) @@ -879,8 +895,12 @@ async def token_to_user(self, token_info): Called by the :meth:`oauthenticator.OAuthenticator.authenticate` """ - if self.userdata_url is None: + 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( From 609911205f28e3187d8b7250e11144b85e995bba Mon Sep 17 00:00:00 2001 From: Min RK Date: Mon, 12 Feb 2024 09:42:57 +0100 Subject: [PATCH 094/103] try to simplify and clarify allow_existing_users docstring --- oauthenticator/oauth2.py | 26 +++++++++----------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/oauthenticator/oauth2.py b/oauthenticator/oauth2.py index ecb4ce92..425924c5 100644 --- a/oauthenticator/oauth2.py +++ b/oauthenticator/oauth2.py @@ -281,15 +281,19 @@ class OAuthenticator(Authenticator): help=""" Allow existing users to login. - An existing user is a user in JupyterHub's database of users, and it - includes all users that have previously logged in. + Enable this if you want to manage user access via the JupyterHub admin page (/hub/admin). + + With this enabled, all users present in the JupyterHub database are allowed to login. + This has the effect of any user who has _previously_ been allowed to login + via any means will continue to be allowed until the user is deleted via the /hub/admin page + or REST API. .. warning:: Before enabling this you should review the existing users in the JupyterHub admin panel at `/hub/admin`. You may find users existing - there because they have once been declared in config such as - `allowed_users` or once been allowed to sign in. + there because they have previously been declared in config such as + `allowed_users` or allowed to sign in. .. warning:: @@ -299,19 +303,7 @@ class OAuthenticator(Authenticator): if you stop allowing a group of externally managed users for example. With this enabled, JupyterHub admin users can visit `/hub/admin` or use - JupyterHub's REST API to add and remove users as a way to allow them - access. - - The username for existing users must match the normalized username - returned by the authenticator. When creating users, only lowercase - letters should be used unless `MWOAuthenticator` is used. - - .. note:: - - Allowing existing users is done by adding existing users on startup - and newly created users to the `allowed_users` set. Due to that, you - can't rely on this config to independently allow existing users if - you for example would reset `allowed_users` after startup. + JupyterHub's REST API to add and remove users to manage who can login. .. versionadded:: 16.0 From df77443d45b1ecf52c18ae2c944906a1f719366b Mon Sep 17 00:00:00 2001 From: Min RK Date: Mon, 12 Feb 2024 09:43:35 +0100 Subject: [PATCH 095/103] remove allowed_idps from example allow config it's not typical --- docs/source/topic/allowing.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/source/topic/allowing.md b/docs/source/topic/allowing.md index ba655392..81285032 100644 --- a/docs/source/topic/allowing.md +++ b/docs/source/topic/allowing.md @@ -112,7 +112,6 @@ Configuration documentation for {attr}`.OAuthenticator.allow_existing_users` Each OAuthenticator provider may have its own provider-specific rules to allow groups of users access, such as: -- {attr}`.CILogonOAuthenticator.allowed_idps` - {attr}`.GitHubOAuthenticator.allowed_organizations` - {attr}`.GitLabOAuthenticator.allowed_gitlab_groups` - {attr}`.GlobusOAuthenticator.allowed_globus_groups` From 2d5f9beabc00eef6620080877122e9979ea666ce Mon Sep 17 00:00:00 2001 From: Min RK Date: Mon, 12 Feb 2024 09:55:10 +0100 Subject: [PATCH 096/103] allow_all was only implied when _no_ allow config was specified --- docs/source/topic/allowing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/topic/allowing.md b/docs/source/topic/allowing.md index 81285032..2ca4f1c7 100644 --- a/docs/source/topic/allowing.md +++ b/docs/source/topic/allowing.md @@ -17,7 +17,7 @@ The default behavior of OAuthenticator (starting with version 16) is to block al If you want anyone to be able to use your hub, you must specify at least one `allow` configuration. ```{versionchanged} 16 -Prior to OAuthenticator 16, `allow_all` was _implied_ if `allowed_users` was not specified. +Prior to OAuthenticator 16, `allow_all` was _implied_ if no other `allow` configuration was specified. Starting from 16, `allow_all` can only be enabled explicitly. ``` From 4874818cf2fa4917ec650c44f347d711cf63e107 Mon Sep 17 00:00:00 2001 From: Min RK Date: Mon, 12 Feb 2024 10:06:41 +0100 Subject: [PATCH 097/103] Apply suggestions from code review Co-authored-by: Erik Sundell --- docs/source/topic/allowing.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/topic/allowing.md b/docs/source/topic/allowing.md index 2ca4f1c7..663abf56 100644 --- a/docs/source/topic/allowing.md +++ b/docs/source/topic/allowing.md @@ -108,7 +108,7 @@ Once the user has been added to the database, the only way to revoke their acces Configuration documentation for {attr}`.OAuthenticator.allow_existing_users` ``` -### provider-specific rules +### Provider-specific rules Each OAuthenticator provider may have its own provider-specific rules to allow groups of users access, such as: @@ -137,6 +137,6 @@ This means that: ```{important} To fully remove a user's access to JupyterHub, -their login permission must be revoked _and_ their User fully deleted from the Hub, +their login permission must be revoked _and_ their user fully deleted from the Hub, e.g. via the admin page. ``` From eb30a9bf83e7c2da01ac8b9eabbbcbc2e2f2e3ec Mon Sep 17 00:00:00 2001 From: Min RK Date: Mon, 12 Feb 2024 10:10:09 +0100 Subject: [PATCH 098/103] clarify impact of admin_users withotu specifying implementation detail --- docs/source/topic/allowing.md | 2 +- oauthenticator/oauth2.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/topic/allowing.md b/docs/source/topic/allowing.md index 663abf56..0d88b7ce 100644 --- a/docs/source/topic/allowing.md +++ b/docs/source/topic/allowing.md @@ -82,7 +82,7 @@ c.OAuthenticator.allowed_users = {"mensah", "ratthi"} If this is your only configuration, only these users will be allowed, no others. -Note that any additional usernames in the deprecated `admin_users` configuration will also be added to the `allowed_users` set. +Note that any additional usernames in the deprecated `admin_users` configuration will also be allowed to login. ```{seealso} Configuration documentation for {attr}`.OAuthenticator.allowed_users` diff --git a/oauthenticator/oauth2.py b/oauthenticator/oauth2.py index 425924c5..8296f342 100644 --- a/oauthenticator/oauth2.py +++ b/oauthenticator/oauth2.py @@ -1093,5 +1093,5 @@ def __init__(self, **kwargs): If unspecified, grants no access. You must set at least one other `allow` configuration if any users are to have permission to access the Hub. -Any users in `admin_users` will be added to this set. +Any usernames in `admin_users` will also be allowed to login. """ From 657b433a31bc48e796769e850893610c13e41912 Mon Sep 17 00:00:00 2001 From: Min RK Date: Mon, 12 Feb 2024 10:55:25 +0100 Subject: [PATCH 099/103] promote pyjwt requirement to top-level remove newly redundant extras --- requirements.txt | 3 ++- setup.py | 4 ---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/requirements.txt b/requirements.txt index 23a7a413..53cc9bc4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,8 @@ jsonschema jupyterhub>=2.2 # PyJWT is used for parsing id tokens -pyjwt +# 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 61fe2fab..8d84f41c 100644 --- a/setup.py +++ b/setup.py @@ -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': [ @@ -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', From a108d23abcc9bba19d4384e7f126851d074d34f9 Mon Sep 17 00:00:00 2001 From: Ben Lewis Date: Tue, 13 Feb 2024 05:57:50 +0000 Subject: [PATCH 100/103] Test id token Note, the current mock setup delivers different handlers (for ingesting sample users) depending on whether id tokens are intended to be supplied. The fixtures (for preparing a mock client and preconfigured authenticator) thus need specialisation depending on whether the id token is being used. However, the current mock also requires that tests individually manage the packaging of the id token when loading into the mock client's handler; the handler syntax is different between the two cases. This means that to test that JWT id tokens and JSON userinfo endpoints provide equivalent behaviour requires changes in both the test (in the preamble) and in fixtures (compared to unmodified tests). This has led to some code duplication, which would be a target for future refactoring. --- oauthenticator/tests/test_generic.py | 48 +++++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/oauthenticator/tests/test_generic.py b/oauthenticator/tests/test_generic.py index fcc96652..e1ed3004 100644 --- a/oauthenticator/tests/test_generic.py +++ b/oauthenticator/tests/test_generic.py @@ -1,4 +1,5 @@ import json +import jwt from functools import partial from pytest import fixture, mark @@ -18,6 +19,11 @@ def user_model(username, **kwargs): } +@fixture(params=[True, False]) +def userdata_from_id_token(request): + return request.param + + @fixture def generic_client(client): setup_oauth_mock( @@ -29,6 +35,18 @@ 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', @@ -37,6 +55,14 @@ def _get_authenticator(**kwargs): ) +def _get_authenticator_for_id_token(**kwargs): + return GenericOAuthenticator( + token_url='https://generic.horse/oauth/access_token', + userdata_from_id_token=True, + **kwargs, + ) + + @fixture def get_authenticator(generic_client): """ @@ -45,6 +71,17 @@ 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", [ @@ -163,24 +200,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: From 12695d55cfa69d422bf55416e40b06155264ad73 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 13 Feb 2024 06:18:51 +0000 Subject: [PATCH 101/103] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- oauthenticator/tests/test_generic.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/oauthenticator/tests/test_generic.py b/oauthenticator/tests/test_generic.py index e1ed3004..04b1e682 100644 --- a/oauthenticator/tests/test_generic.py +++ b/oauthenticator/tests/test_generic.py @@ -1,7 +1,7 @@ import json -import jwt from functools import partial +import jwt from pytest import fixture, mark from traitlets.config import Config @@ -77,8 +77,12 @@ 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 + ( + _get_authenticator_for_id_token + if userdata_from_id_token + else _get_authenticator + ), + http_client=generic_client, ) From e6e68fdfbaf8488a3e84d796d8d0d0a23fa9741f Mon Sep 17 00:00:00 2001 From: Min RK Date: Tue, 13 Feb 2024 10:07:37 +0100 Subject: [PATCH 102/103] move userdata_url / userdata_from_id_token conflict to config validation rather than if attribute is defined --- oauthenticator/oauth2.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/oauthenticator/oauth2.py b/oauthenticator/oauth2.py index 85d895d7..dd2df577 100644 --- a/oauthenticator/oauth2.py +++ b/oauthenticator/oauth2.py @@ -19,7 +19,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, Dict, List, Unicode, default +from traitlets import Any, Bool, Dict, List, Unicode, default, validate def guess_callback_uri(protocol, host, hub_server_url): @@ -397,6 +397,14 @@ def _token_url_default(self): 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 = Unicode( "username", config=True, @@ -897,10 +905,6 @@ async def token_to_user(self, token_info): """ 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( From 26ea6fab876451091c1ab3700ecb2dcd9f921796 Mon Sep 17 00:00:00 2001 From: Min RK Date: Tue, 13 Feb 2024 10:19:21 +0100 Subject: [PATCH 103/103] make sure client_id is set in test_generic needed for audience verification --- oauthenticator/tests/test_generic.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/oauthenticator/tests/test_generic.py b/oauthenticator/tests/test_generic.py index 04b1e682..af7d5c9e 100644 --- a/oauthenticator/tests/test_generic.py +++ b/oauthenticator/tests/test_generic.py @@ -8,20 +8,23 @@ 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, "scope": "basic", "groups": ["group1"], **kwargs, } -@fixture(params=[True, False]) +@fixture(params=["id_token", "userdata_url"]) def userdata_from_id_token(request): - return request.param + return request.param == "id_token" @fixture @@ -51,6 +54,7 @@ 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, ) @@ -59,6 +63,7 @@ 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, )