diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index a3a7848d..f119e391 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -33,8 +33,8 @@ jobs: id-token: write steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 + - uses: actions/checkout@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 d52d8dde..34aadda9 100644 --- a/.github/workflows/test-docs.yaml +++ b/.github/workflows/test-docs.yaml @@ -24,8 +24,8 @@ jobs: linkcheck: runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: python-version: "3.10" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 08b9f8f0..29c2538c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -38,13 +38,14 @@ jobs: - "3.9" - "3.10" - "3.11" + - "3.12" include: - python: "3.9" oldest_dependencies: oldest_dependencies steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: python-version: "${{ matrix.python }}" @@ -55,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 @@ -68,4 +69,4 @@ jobs: pytest # GitHub action reference: https://github.com/codecov/codecov-action - - uses: codecov/codecov-action@v3 + - uses: codecov/codecov-action@v4 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 22bb35a8..d297dab9 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.15.0 hooks: - id: pyupgrade args: @@ -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 @@ -28,25 +28,25 @@ 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.3.0 + rev: 24.1.1 hooks: - id: black # Autoformat: markdown, yaml - repo: https://github.com/pre-commit/mirrors-prettier - rev: v3.0.0-alpha.9-for-vscode + rev: v4.0.0-alpha.8 hooks: - id: prettier # 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. @@ -64,7 +64,7 @@ repos: # Lint: Python code - repo: https://github.com/pycqa/flake8 - rev: "6.0.0" + rev: "7.0.0" hooks: - id: flake8 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/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 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/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/docs/source/how-to/writing-an-oauthenticator.md b/docs/source/how-to/writing-an-oauthenticator.md index 17e04c98..c3288d5f 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. @@ -17,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' @@ -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/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/reference/changelog.md b/docs/source/reference/changelog.md index faa5abd2..543db60f 100644 --- a/docs/source/reference/changelog.md +++ b/docs/source/reference/changelog.md @@ -6,9 +6,180 @@ command line for details. ## [Unreleased] +## 16.2 + +### [16.2.1] - 2023-11-27 + +#### 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 + +#### 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 + +```{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 + +- [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.2 - 2023-07-06 +### [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. +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 + +#### 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 + +#### 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 + +- 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 @@ -18,7 +189,7 @@ command line for details. - [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 @@ -28,29 +199,31 @@ command line for details. - 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 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. @@ -61,7 +234,7 @@ 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 @@ -100,7 +273,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)) @@ -169,7 +342,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 @@ -190,7 +363,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 @@ -204,7 +377,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. @@ -307,8 +480,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)) @@ -465,8 +636,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)) @@ -670,7 +839,22 @@ 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.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 +[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 +[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 diff --git a/docs/source/topic/allowing.md b/docs/source/topic/allowing.md new file mode 100644 index 00000000..0d88b7ce --- /dev/null +++ b/docs/source/topic/allowing.md @@ -0,0 +1,142 @@ +(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! + +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 +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. +``` + +## 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 allowed to login. + +```{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 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 + +```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}`.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 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. + +### 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 +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/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/azuread.md b/docs/source/tutorials/provider-specific-setup/providers/azuread.md index 7528159e..09359d69 100644 --- a/docs/source/tutorials/provider-specific-setup/providers/azuread.md +++ b/docs/source/tutorials/provider-specific-setup/providers/azuread.md @@ -31,3 +31,20 @@ AzureAdOAuthenticator expands OAuthenticator with the following config that may be relevant to read more about in the configuration reference: - {attr}`.AzureAdOAuthenticator.tenant_id` + +## 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 +c.JupyterHub.authenticator_class = "azuread" + +# {...} other settings (see above) + +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/docs/source/tutorials/provider-specific-setup/providers/generic.md b/docs/source/tutorials/provider-specific-setup/providers/generic.md index 5506f927..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]" @@ -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-oauth" + +# 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. 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 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/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]" 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/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/examples/mock-provider/README.md b/examples/mock-provider/README.md new file mode 100644 index 00000000..8e022e19 --- /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" diff --git a/oauthenticator/_version.py b/oauthenticator/_version.py index 5a06cda5..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.0.3.dev" +__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/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 399c704b..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 @@ -21,6 +22,16 @@ def _login_service_default(self): def _username_claim_default(self): return "name" + user_groups_claim = Unicode( + "groups", + config=True, + help=""" + Name of claim containing user group memberships. + + Will populate JupyterHub groups if Authenticator.manage_groups is True. + """, + ) + tenant_id = Unicode( config=True, help=""" @@ -44,6 +55,15 @@ def _authorize_url_default(self): def _token_url_default(self): return f"https://login.microsoftonline.com/{self.tenant_id}/oauth2/token" + async def update_auth_model(self, auth_model, **kwargs): + auth_model = await super().update_auth_model(auth_model, **kwargs) + + 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 + async def token_to_user(self, token_info): id_token = token_info['id_token'] decoded = jwt.decode( diff --git a/oauthenticator/bitbucket.py b/oauthenticator/bitbucket.py index aa6b771e..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 @@ -73,11 +74,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,8 +93,8 @@ async def check_allowed(self, username, auth_model): return True if self.allowed_teams: - user_teams = auth_model["auth_state"]["user_teams"] - if any(user_teams & self.allowed_teams): + user_teams = set(auth_model["auth_state"].get("user_teams", [])) + if user_teams & self.allowed_teams: return True # users should be explicitly allowed via config, otherwise they aren't diff --git a/oauthenticator/cilogon.py b/oauthenticator/cilogon.py index 1b03cb59..bf08f14e 100644 --- a/oauthenticator/cilogon.py +++ b/oauthenticator/cilogon.py @@ -1,7 +1,9 @@ """ 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 @@ -15,6 +17,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 +49,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 @@ -123,28 +142,40 @@ def _validate_scope(self, proposal): "action": "strip_idp_domain", "domain": "utoronto.ca", }, + "allow_all": True, + "default": 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`. + * `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 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 +189,31 @@ 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 + 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 @@ -197,6 +250,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( @@ -336,24 +394,47 @@ 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) - user_domain = unprocessed_username.split("@", 1)[1].lower() - 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) + 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 + # 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/generic.py b/oauthenticator/generic.py index c0865b15..2fd07be7 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 @@ -25,6 +26,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. If `manage_groups` is True, + this will also determine users' _JupyterHub_ group membership. """, ) @@ -56,20 +61,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( @@ -109,17 +100,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 @@ -150,16 +130,23 @@ async def update_auth_model(self, auth_model): 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 populates groups if `manage_groups` is set. """ + 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"] = sorted(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"] = any(user_groups & self.admin_groups) + auth_model["admin"] = bool(user_groups & self.admin_groups) return auth_model diff --git a/oauthenticator/github.py b/oauthenticator/github.py index e4c5c31f..79013676 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 4a447208..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 @@ -291,6 +292,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. @@ -308,8 +314,8 @@ async def check_allowed(self, username, auth_model): return True if self.allowed_globus_groups: - user_groups = auth_model["auth_state"]["globus_groups"] - if any(user_groups & self.allowed_globus_groups): + user_groups = set(auth_model["auth_state"]["globus_groups"]) + if user_groups & self.allowed_globus_groups: return True self.log.warning(f"{username} not in an allowed Globus Group") @@ -330,7 +336,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 @@ -338,7 +345,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 ed9cf4c0..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 @@ -108,19 +109,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 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 `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 @@ -153,6 +163,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` @@ -162,17 +189,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() + user_domain = user_info["domain"] 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 @@ -181,7 +210,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 @@ -190,6 +219,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. @@ -219,9 +253,9 @@ 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): + if user_groups & allowed_groups: return True # users should be explicitly allowed via config, otherwise they aren't 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 5e96464a..1c882b3e 100644 --- a/oauthenticator/oauth2.py +++ b/oauthenticator/oauth2.py @@ -3,12 +3,14 @@ Founded based on work by Kyle Kelley (@rgbkrk) """ + import base64 import json 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 @@ -18,7 +20,7 @@ from tornado.httpclient import AsyncHTTPClient, HTTPClientError, HTTPRequest from tornado.httputil import url_concat from tornado.log import app_log -from traitlets import Any, Bool, Dict, List, Unicode, default +from traitlets import Any, Bool, Callable, Dict, List, Unicode, Union, default, validate def guess_callback_uri(protocol, host, hub_server_url): @@ -70,13 +72,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 +91,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 +151,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 +195,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: @@ -262,6 +270,8 @@ class OAuthenticator(Authenticator): help=""" Allow all authenticated users to login. + Overrides all other `allow` configuration. + .. versionadded:: 16.0 """, ) @@ -272,37 +282,29 @@ 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 has 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:: - 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 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 - 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 @@ -353,6 +355,24 @@ def _authorize_url_default(self): def _token_url_default(self): return os.environ.get("OAUTH2_TOKEN_URL", "") + userdata_from_id_token = Bool( + False, + config=True, + help=""" + Extract user details from an id token received via a request to + :attr:`token_url`, rather than making a follow-up request to the + userinfo endpoint :attr:`userdata_url`. + + Should only be used if :attr:`token_url` uses HTTPS, to ensure + token authenticity. + + For more context, see `Authentication using the Authorization + Code Flow + `_ + in the OIDC Core standard document. + """, + ) + userdata_url = Unicode( config=True, help=""" @@ -363,6 +383,8 @@ def _token_url_default(self): For more context, see the `Protocol Flow section `_ in the OAuth2 standard document, specifically steps E-F. + + Incompatible with :attr:`userdata_from_id_token`. """, ) @@ -370,14 +392,25 @@ def _token_url_default(self): def _userdata_url_default(self): return os.environ.get("OAUTH2_USERDATA_URL", "") - username_claim = Unicode( - "username", + @validate("userdata_url") + def _validate_userdata_url(self, proposal): + if proposal.value and self.userdata_from_id_token: + raise ValueError( + "Cannot specify both authenticator.userdata_url and authenticator.userdata_from_id_token." + ) + return proposal.value + + username_claim = Union( + [Unicode(os.environ.get('OAUTH2_USERNAME_KEY', 'username')), Callable()], config=True, 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. @@ -463,7 +496,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. @@ -710,10 +743,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-insensitive, but the headers are case-sensitive + 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): @@ -735,7 +775,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 @@ -755,7 +795,11 @@ 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) @@ -850,6 +894,9 @@ async def token_to_user(self, token_info): Determines who the logged-in user by sending a "GET" request to :data:`oauthenticator.OAuthenticator.userdata_url` using the `access_token`. + If :data:`oauthenticator.OAuthenticator.userdata_from_id_token` is set then + extracts the corresponding info from an `id_token` instead. + Args: token_info: the dictionary returned by the token request (exchanging the OAuth code for an Access Token) @@ -858,6 +905,32 @@ async def token_to_user(self, token_info): Called by the :meth:`oauthenticator.OAuthenticator.authenticate` """ + if self.userdata_from_id_token: + # Use id token instead of exchanging access token with userinfo endpoint. + id_token = token_info.get("id_token", None) + if not id_token: + raise web.HTTPError( + 500, + f"An id token was not returned: {token_info}\nPlease configure authenticator.userdata_url", + ) + try: + # Here we parse the id token. Note that per OIDC spec (core v1.0 sect. 3.1.3.7.6) we can skip + # signature validation as the hub has obtained the tokens from the id provider directly (using + # https). Google suggests all token validation may be skipped assuming the provider is trusted. + # https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation + # https://developers.google.com/identity/openid-connect/openid-connect#obtainuserinfo + return jwt.decode( + id_token, + audience=self.client_id, + options=dict( + verify_signature=False, verify_aud=True, verify_exp=True + ), + ) + except Exception as err: + raise web.HTTPError( + 500, f"Unable to decode id token: {id_token}\n{err}" + ) + access_token = token_info["access_token"] token_type = token_info["token_type"] @@ -994,7 +1067,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 @@ -1065,3 +1138,18 @@ 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. You must set at least one other `allow` configuration +if any users are to have permission to access the Hub. + +Any usernames in `admin_users` will also be allowed to login. +""" 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/openshift.py b/oauthenticator/openshift.py index df77bbda..6b016b0d 100644 --- a/oauthenticator/openshift.py +++ b/oauthenticator/openshift.py @@ -1,6 +1,8 @@ """ A JupyterHub authenticator class for use with OpenShift as an identity provider. """ + +import concurrent.futures import json import os @@ -81,15 +83,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(1) 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): @@ -161,7 +173,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 +188,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 diff --git a/oauthenticator/schemas/cilogon-schema.yaml b/oauthenticator/schemas/cilogon-schema.yaml index 713d2bd2..2f8436ff 100644 --- a/oauthenticator/schemas/cilogon-schema.yaml +++ b/oauthenticator/schemas/cilogon-schema.yaml @@ -7,10 +7,16 @@ additionalProperties: false required: - username_derivation properties: + allow_all: + type: boolean allowed_domains: type: array items: type: string + allowed_domains_claim: + type: string + default: + type: boolean username_derivation: type: object additionalProperties: false 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 5226a56a..a2e674f0 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 @@ -255,6 +256,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_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..13be6b0d 100644 --- a/oauthenticator/tests/test_azuread.py +++ b/oauthenticator/tests/test_azuread.py @@ -1,4 +1,6 @@ """test azure ad""" + +import json import os import re import time @@ -6,6 +8,7 @@ from unittest import mock import jwt +import pytest from pytest import fixture, mark from traitlets.config import Config @@ -43,6 +46,17 @@ 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", + ], + # different from 'groups' for tests + "grp": [ + "96000b2c-7333-4f6e-a2c3", + "a992b3d5-1966-4af4-abed", + "ceb90a42-030f-44f1-a0c7", + ], }, os.urandom(5), ) @@ -102,6 +116,23 @@ def user_model(tenant_id, client_id, name): True, None, ), + # test user_groups_claim + ( + "30", + {"allow_all": True, "manage_groups": True}, + True, + None, + ), + ( + "31", + { + "allow_all": True, + "manage_groups": True, + "user_groups_claim": "grp", + }, + True, + None, + ), ], ) async def test_azuread( @@ -118,6 +149,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, @@ -129,13 +166,20 @@ async def test_azuread( 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) assert "access_token" in auth_state 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 manage_groups: + groups = auth_model['groups'] + assert groups == user_info[authenticator.user_groups_claim] else: assert auth_model == None 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..4dac4927 100644 --- a/oauthenticator/tests/test_cilogon.py +++ b/oauthenticator/tests/test_cilogon.py @@ -3,11 +3,10 @@ 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 -from ..cilogon import CILogonOAuthenticator +from ..cilogon import CILogonOAuthenticator, _get_select_idp_param from .mocks import setup_oauth_mock @@ -92,6 +91,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] @@ -101,6 +101,333 @@ 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, + ), + ( + "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, + ), + ( + "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 + ( + "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", [ @@ -301,10 +628,15 @@ 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 = { 'https://strip-idp-domain.example.com/login/oauth/authorize': { + 'default': True, 'username_derivation': { 'username_claim': 'email', 'action': 'strip_idp_domain', @@ -315,7 +647,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': { @@ -358,7 +690,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( @@ -373,85 +705,42 @@ async def test_allowed_idps_username_derivation_actions(cilogon_client): 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', +@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': {}, }, - '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', + "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': {}, }, - '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', + "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': {}, }, - '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' + "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 diff --git a/oauthenticator/tests/test_generic.py b/oauthenticator/tests/test_generic.py index 35d11b42..db781bc5 100644 --- a/oauthenticator/tests/test_generic.py +++ b/oauthenticator/tests/test_generic.py @@ -1,22 +1,33 @@ +import json from functools import partial +import jwt from pytest import fixture, mark from traitlets.config import Config from ..generic import GenericOAuthenticator from .mocks import setup_oauth_mock +client_id = "jupyterhub-oauth-client" + def user_model(username, **kwargs): """Return a user model""" return { "username": username, + "aud": client_id, + "sub": "oauth2|cilogon|http://cilogon.org/servera/users/43431", "scope": "basic", "groups": ["group1"], **kwargs, } +@fixture(params=["id_token", "userdata_url"]) +def userdata_from_id_token(request): + return request.param == "id_token" + + @fixture def generic_client(client): setup_oauth_mock( @@ -28,10 +39,32 @@ def generic_client(client): return client +@fixture +def generic_client_variant(client, userdata_from_id_token): + setup_oauth_mock( + client, + host='generic.horse', + access_token_path='/oauth/access_token', + user_path='/oauth/userinfo', + token_request_style='jwt' if userdata_from_id_token else 'post', + ) + return client + + def _get_authenticator(**kwargs): return GenericOAuthenticator( token_url='https://generic.horse/oauth/access_token', userdata_url='https://generic.horse/oauth/userinfo', + client_id=client_id, + **kwargs, + ) + + +def _get_authenticator_for_id_token(**kwargs): + return GenericOAuthenticator( + token_url='https://generic.horse/oauth/access_token', + userdata_from_id_token=True, + client_id=client_id, **kwargs, ) @@ -44,6 +77,21 @@ def get_authenticator(generic_client): return partial(_get_authenticator, http_client=generic_client) +@fixture +def get_authenticator_variant(generic_client, userdata_from_id_token): + """ + http_client can't be configured, only passed as argument to the constructor. + """ + return partial( + ( + _get_authenticator_for_id_token + if userdata_from_id_token + else _get_authenticator + ), + http_client=generic_client, + ) + + @mark.parametrize( "test_variation_id,class_config,expect_allowed,expect_admin", [ @@ -150,41 +198,89 @@ def get_authenticator(generic_client): False, False, ), + ( + "20", + { + "manage_groups": True, + "allow_all": True, + }, + True, + None, + ), ], ) 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: 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) assert "access_token" in auth_state assert "oauth_user" in auth_state assert "refresh_token" in auth_state 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 +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() c.GenericOAuthenticator.allow_all = True @@ -238,3 +334,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_github.py b/oauthenticator/tests/test_github.py index 8594580b..4ef24c4a 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 7dbd1476..95ade113 100644 --- a/oauthenticator/tests/test_globus.py +++ b/oauthenticator/tests/test_globus.py @@ -303,16 +303,32 @@ 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] 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 +@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 d9e24196..28adeb0c 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,25 +177,89 @@ 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] 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 -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 + c.GoogleOAuthenticator.admin_users = {"user1"} + c.GoogleOAuthenticator.allowed_users = {"user2"} authenticator = GoogleOAuthenticator(config=c) handled_user_model = user_model("user1@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"] == "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) + with raises(HTTPError) as exc: + await authenticator.get_authenticated_user(handler, None) + 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 + 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) 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_oauth2.py b/oauthenticator/tests/test_oauth2.py index 06600e1b..4386e92c 100644 --- a/oauthenticator/tests/test_oauth2.py +++ b/oauthenticator/tests/test_oauth2.py @@ -2,17 +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(): @@ -26,6 +29,99 @@ async def test_serialize_state(): assert state2 == state1 +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_handler._generate_state_id = Mock(return_value=TEST_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': TEST_STATE_ID, + 'next_url': TEST_NEXT_URL, + } + ) + + login_handler.set_state_cookie.assert_called_once_with(expected_cookie_value) + + expected_state_param_value = _serialize_state( + { + 'state_id': TEST_STATE_ID, + } + ) + + login_handler.authorize_redirect.assert_called_once() + assert ( + login_handler.authorize_redirect.call_args.kwargs['extra_params']['state'] + == expected_state_param_value + ) + + +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')) + + with raises(HTTPError, match="OAuth state mismatch"): + await callback_handler.get() + + async def test_custom_logout(monkeypatch): login_url = "http://myhost/login" authenticator = OAuthenticator() diff --git a/oauthenticator/tests/test_okpy.py b/oauthenticator/tests/test_okpy.py deleted file mode 100644 index 34320284..00000000 --- a/oauthenticator/tests/test_okpy.py +++ /dev/null @@ -1,90 +0,0 @@ -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 "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/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 diff --git a/pyproject.toml b/pyproject.toml index 3e179606..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.0.3.dev" +current = "16.2.2.dev" regex = ''' (?P\d+) \. diff --git a/requirements.txt b/requirements.txt index 74438405..53cc9bc4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,9 @@ # jsonschema is used for validating authenticator configurations jsonschema -jupyterhub>=1.2 +jupyterhub>=2.2 +# PyJWT is used for parsing id tokens +# and azuread +pyjwt>=2 # requests is already required by JupyterHub, but explicitly ask for it since we use it requests # ruamel.yaml is used to read and write .yaml files. diff --git a/setup.py b/setup.py index 3608e63b..8d84f41c 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.2.2.dev", description="OAuthenticator: Authenticate JupyterHub users with common OAuth providers", long_description=open("README.md").read(), long_description_content_type="text/markdown", @@ -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', ], @@ -90,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': [ @@ -104,11 +100,10 @@ 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: - 'pyjwt>=2', # dependencies from googlegroups: 'google-api-python-client', 'google-auth-oauthlib',