oauthenticator 16.2.0__tar.gz → 16.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. {oauthenticator-16.2.0/oauthenticator.egg-info → oauthenticator-16.3.0}/PKG-INFO +4 -6
  2. {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/_version.py +1 -1
  3. {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/auth0.py +1 -0
  4. {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/azuread.py +20 -0
  5. {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/bitbucket.py +1 -0
  6. {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/cilogon.py +1 -0
  7. {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/generic.py +14 -27
  8. {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/github.py +1 -0
  9. {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/gitlab.py +1 -0
  10. {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/globus.py +1 -0
  11. {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/google.py +54 -26
  12. {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/mediawiki.py +1 -0
  13. {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/oauth2.py +102 -27
  14. {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/openshift.py +1 -0
  15. {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/schemas/cilogon-schema.yaml +2 -0
  16. {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/tests/conftest.py +1 -0
  17. {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/tests/mocks.py +2 -5
  18. {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/tests/test_azuread.py +43 -2
  19. {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/tests/test_cilogon.py +1 -0
  20. {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/tests/test_generic.py +98 -5
  21. {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/tests/test_google.py +67 -42
  22. {oauthenticator-16.2.0 → oauthenticator-16.3.0/oauthenticator.egg-info}/PKG-INFO +4 -6
  23. {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator.egg-info/requires.txt +3 -6
  24. {oauthenticator-16.2.0 → oauthenticator-16.3.0}/pyproject.toml +1 -1
  25. {oauthenticator-16.2.0 → oauthenticator-16.3.0}/requirements.txt +4 -1
  26. {oauthenticator-16.2.0 → oauthenticator-16.3.0}/setup.py +3 -6
  27. {oauthenticator-16.2.0 → oauthenticator-16.3.0}/CONTRIBUTING.md +0 -0
  28. {oauthenticator-16.2.0 → oauthenticator-16.3.0}/LICENSE +0 -0
  29. {oauthenticator-16.2.0 → oauthenticator-16.3.0}/MANIFEST.in +0 -0
  30. {oauthenticator-16.2.0 → oauthenticator-16.3.0}/README.md +0 -0
  31. {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/__init__.py +0 -0
  32. {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/tests/__init__.py +0 -0
  33. {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/tests/test_auth0.py +0 -0
  34. {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/tests/test_bitbucket.py +0 -0
  35. {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/tests/test_github.py +0 -0
  36. {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/tests/test_gitlab.py +0 -0
  37. {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/tests/test_globus.py +0 -0
  38. {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/tests/test_mediawiki.py +0 -0
  39. {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/tests/test_oauth2.py +0 -0
  40. {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/tests/test_openshift.py +0 -0
  41. {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator.egg-info/SOURCES.txt +0 -0
  42. {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator.egg-info/dependency_links.txt +0 -0
  43. {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator.egg-info/entry_points.txt +0 -0
  44. {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator.egg-info/top_level.txt +0 -0
  45. {oauthenticator-16.2.0 → oauthenticator-16.3.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: oauthenticator
3
- Version: 16.2.0
3
+ Version: 16.3.0
4
4
  Summary: OAuthenticator: Authenticate JupyterHub users with common OAuth providers
5
5
  Home-page: https://jupyter.org
6
6
  Author: Jupyter Development Team
@@ -19,13 +19,12 @@ Requires-Python: >=3.8
19
19
  Description-Content-Type: text/markdown
20
20
  License-File: LICENSE
21
21
  Requires-Dist: jsonschema
22
- Requires-Dist: jupyterhub>=1.2
22
+ Requires-Dist: jupyterhub>=2.2
23
+ Requires-Dist: pyjwt>=2
23
24
  Requires-Dist: requests
24
25
  Requires-Dist: ruamel.yaml
25
26
  Requires-Dist: tornado
26
27
  Requires-Dist: traitlets
27
- Provides-Extra: azuread
28
- Requires-Dist: pyjwt>=2; extra == "azuread"
29
28
  Provides-Extra: googlegroups
30
29
  Requires-Dist: google-api-python-client; extra == "googlegroups"
31
30
  Requires-Dist: google-auth-oauthlib; extra == "googlegroups"
@@ -33,10 +32,9 @@ Provides-Extra: mediawiki
33
32
  Requires-Dist: mwoauth>=0.3.8; extra == "mediawiki"
34
33
  Provides-Extra: test
35
34
  Requires-Dist: pytest>=2.8; extra == "test"
36
- Requires-Dist: pytest-asyncio; extra == "test"
35
+ Requires-Dist: pytest-asyncio<0.23,>=0.17; extra == "test"
37
36
  Requires-Dist: pytest-cov; extra == "test"
38
37
  Requires-Dist: requests-mock; extra == "test"
39
- Requires-Dist: pyjwt>=2; extra == "test"
40
38
  Requires-Dist: google-api-python-client; extra == "test"
41
39
  Requires-Dist: google-auth-oauthlib; extra == "test"
42
40
  Requires-Dist: mwoauth>=0.3.8; extra == "test"
@@ -1,7 +1,7 @@
1
1
  # __version__ should be updated using tbump, based on configuration in
2
2
  # pyproject.toml, according to instructions in RELEASE.md.
3
3
  #
4
- __version__ = "16.2.0"
4
+ __version__ = "16.3.0"
5
5
 
6
6
  # version_info looks like (1, 2, 3, "dev") if __version__ is 1.2.3.dev
7
7
  version_info = tuple(int(p) if p.isdigit() else p for p in __version__.split("."))
@@ -1,6 +1,7 @@
1
1
  """
2
2
  A JupyterHub authenticator class for use with Auth0 as an identity provider.
3
3
  """
4
+
4
5
  import os
5
6
 
6
7
  from jupyterhub.auth import LocalAuthenticator
@@ -1,6 +1,7 @@
1
1
  """
2
2
  A JupyterHub authenticator class for use with Azure AD as an identity provider.
3
3
  """
4
+
4
5
  import os
5
6
 
6
7
  import jwt
@@ -21,6 +22,16 @@ class AzureAdOAuthenticator(OAuthenticator):
21
22
  def _username_claim_default(self):
22
23
  return "name"
23
24
 
25
+ user_groups_claim = Unicode(
26
+ "groups",
27
+ config=True,
28
+ help="""
29
+ Name of claim containing user group memberships.
30
+
31
+ Will populate JupyterHub groups if Authenticator.manage_groups is True.
32
+ """,
33
+ )
34
+
24
35
  tenant_id = Unicode(
25
36
  config=True,
26
37
  help="""
@@ -44,6 +55,15 @@ class AzureAdOAuthenticator(OAuthenticator):
44
55
  def _token_url_default(self):
45
56
  return f"https://login.microsoftonline.com/{self.tenant_id}/oauth2/token"
46
57
 
58
+ async def update_auth_model(self, auth_model, **kwargs):
59
+ auth_model = await super().update_auth_model(auth_model, **kwargs)
60
+
61
+ if getattr(self, "manage_groups", False):
62
+ user_info = auth_model["auth_state"][self.user_auth_state_key]
63
+ auth_model["groups"] = user_info[self.user_groups_claim]
64
+
65
+ return auth_model
66
+
47
67
  async def token_to_user(self, token_info):
48
68
  id_token = token_info['id_token']
49
69
  decoded = jwt.decode(
@@ -1,6 +1,7 @@
1
1
  """
2
2
  A JupyterHub authenticator class for use with Bitbucket as an identity provider.
3
3
  """
4
+
4
5
  import os
5
6
 
6
7
  from jupyterhub.auth import LocalAuthenticator
@@ -1,6 +1,7 @@
1
1
  """
2
2
  A JupyterHub authenticator class for use with CILogon as an identity provider.
3
3
  """
4
+
4
5
  import os
5
6
  from fnmatch import fnmatch
6
7
  from urllib.parse import urlparse
@@ -1,6 +1,7 @@
1
1
  """
2
2
  A JupyterHub authenticator class for use with any OAuth2 based identity provider.
3
3
  """
4
+
4
5
  import os
5
6
  from functools import reduce
6
7
 
@@ -25,6 +26,10 @@ class GenericOAuthenticator(OAuthenticator):
25
26
 
26
27
  Can be a string key name (use periods for nested keys), or a callable
27
28
  that accepts the returned json (as a dict) and returns the groups list.
29
+
30
+ This configures how group membership in the upstream provider is determined
31
+ for use by `allowed_groups`, `admin_groups`, etc. If `manage_groups` is True,
32
+ this will also determine users' _JupyterHub_ group membership.
28
33
  """,
29
34
  )
30
35
 
@@ -56,20 +61,6 @@ class GenericOAuthenticator(OAuthenticator):
56
61
  """,
57
62
  )
58
63
 
59
- username_claim = Union(
60
- [Unicode(os.environ.get('OAUTH2_USERNAME_KEY', 'username')), Callable()],
61
- config=True,
62
- help="""
63
- When `userdata_url` returns a json response, the username will be taken
64
- from this key.
65
-
66
- Can be a string key name or a callable that accepts the returned
67
- userdata json (as a dict) and returns the username. The callable is
68
- useful e.g. for extracting the username from a nested object in the
69
- response.
70
- """,
71
- )
72
-
73
64
  @default("http_client")
74
65
  def _default_http_client(self):
75
66
  return AsyncHTTPClient(
@@ -109,17 +100,6 @@ class GenericOAuthenticator(OAuthenticator):
109
100
  """,
110
101
  )
111
102
 
112
- def user_info_to_username(self, user_info):
113
- """
114
- Overrides OAuthenticator.user_info_to_username to support the
115
- GenericOAuthenticator unique feature of allowing username_claim to be a
116
- callable function.
117
- """
118
- if callable(self.username_claim):
119
- return self.username_claim(user_info)
120
- else:
121
- return super().user_info_to_username(user_info)
122
-
123
103
  def get_user_groups(self, user_info):
124
104
  """
125
105
  Returns a set of groups the user belongs to based on claim_groups_key
@@ -150,15 +130,22 @@ class GenericOAuthenticator(OAuthenticator):
150
130
  the user isn't part of `admin_users` or `admin_groups`. Note that
151
131
  leaving it at None makes users able to retain an admin status while
152
132
  setting it to False makes it be revoked.
133
+
134
+ Also populates groups if `manage_groups` is set.
153
135
  """
136
+ if self.manage_groups or self.admin_groups:
137
+ user_info = auth_model["auth_state"][self.user_auth_state_key]
138
+ user_groups = self.get_user_groups(user_info)
139
+
140
+ if self.manage_groups:
141
+ auth_model["groups"] = sorted(user_groups)
142
+
154
143
  if auth_model["admin"]:
155
144
  # auth_model["admin"] being True means the user was in admin_users
156
145
  return auth_model
157
146
 
158
147
  if self.admin_groups:
159
148
  # admin status should in this case be True or False, not None
160
- user_info = auth_model["auth_state"][self.user_auth_state_key]
161
- user_groups = self.get_user_groups(user_info)
162
149
  auth_model["admin"] = bool(user_groups & self.admin_groups)
163
150
 
164
151
  return auth_model
@@ -1,6 +1,7 @@
1
1
  """
2
2
  A JupyterHub authenticator class for use with GitHub as an identity provider.
3
3
  """
4
+
4
5
  import json
5
6
  import os
6
7
  import warnings
@@ -1,6 +1,7 @@
1
1
  """
2
2
  A JupyterHub authenticator class for use with GitLab as an identity provider.
3
3
  """
4
+
4
5
  import os
5
6
  import warnings
6
7
  from urllib.parse import quote
@@ -1,6 +1,7 @@
1
1
  """
2
2
  A JupyterHub authenticator class for use with Globus as an identity provider.
3
3
  """
4
+
4
5
  import base64
5
6
  import os
6
7
  import pickle
@@ -1,6 +1,7 @@
1
1
  """
2
2
  A JupyterHub authenticator class for use with Google as an identity provider.
3
3
  """
4
+
4
5
  import os
5
6
 
6
7
  from jupyterhub.auth import LocalAuthenticator
@@ -110,19 +111,26 @@ class GoogleOAuthenticator(OAuthenticator, GoogleOAuth2Mixin):
110
111
  help="""
111
112
  This config has two functions.
112
113
 
113
- 1. Restrict sign-in to a list of email domain names, such as
114
- `["mycollege.edu"]` or `["college1.edu", "college2.edu"]`.
115
- 2. If a single domain is specified, the username will be stripped to exclude the `@domain` part.
114
+ 1. Restrict sign-in to users part of Google organizations/workspaces
115
+ managing domains, such as `["mycollege.edu"]` or `["college1.edu",
116
+ "college2.edu"]`.
117
+ 2. If a single domain is specified, usernames with that domain will be
118
+ stripped to exclude the `@domain` part.
116
119
 
117
- Note that users with email domains in this list must still be allowed
118
- via another config, such as `allow_all`, `allowed_users`, or
119
- `allowed_google_groups`.
120
+ Users not restricted by this configuration must still be explicitly
121
+ allowed by a configuration intended to allow users, like `allow_all`,
122
+ `allowed_users`, or `allowed_google_groups`.
123
+
124
+ .. warning::
125
+
126
+ Changing this config either to or from having a single entry is a
127
+ disruptive change as the same Google user will get a new username,
128
+ either without or with a domain name included.
129
+
130
+ .. versionchanged:: 16.1
120
131
 
121
- ```{warning} Disruptive config changes
122
- Changing this config either to or from having a single entry is a
123
- disruptive change as the same Google user will get a new username,
124
- either without or with a domain name included.
125
- ```
132
+ Now restricts sign-in based on the hd claim, not the domain in the
133
+ user's email.
126
134
  """,
127
135
  )
128
136
 
@@ -173,8 +181,19 @@ class GoogleOAuthenticator(OAuthenticator, GoogleOAuth2Mixin):
173
181
  user_email = user_info["email"]
174
182
  user_domain = user_info["domain"] = user_email.split("@")[1].lower()
175
183
 
176
- if len(self.hosted_domain) == 1 and self.hosted_domain[0] == user_domain:
177
- # unambiguous domain, use only base name
184
+ # NOTE: This is not an authorization check, it just about username
185
+ # derivation. Decoupling hosted_domain from this is considered in
186
+ # https://github.com/jupyterhub/oauthenticator/issues/733.
187
+ #
188
+ # NOTE: This code is written with without knowing for sure if the user
189
+ # email's domain could be different from the domain in hd, so we
190
+ # assume it could be even though it seems like it can't be. If a
191
+ # Google organization/workspace manages users in a "primary
192
+ # domain" and a "secondary domain", users with respective email
193
+ # domain have their hd field set respectively.
194
+ #
195
+ if len(self.hosted_domain) == 1 and user_domain == self.hosted_domain[0]:
196
+ # strip the domain in this situation
178
197
  username = username.split("@")[0]
179
198
 
180
199
  return username
@@ -213,6 +232,28 @@ class GoogleOAuthenticator(OAuthenticator, GoogleOAuth2Mixin):
213
232
 
214
233
  return auth_model
215
234
 
235
+ def check_blocked_users(self, username, auth_model):
236
+ """
237
+ Overrides `Authenticator.check_blocked_users` to not only block users in
238
+ `Authenticator.blocked_users`, but to also enforce
239
+ `GoogleOAuthenticator.hosted_domain` if its configured.
240
+
241
+ When hosted_domain is configured, users are required to be part of
242
+ listed Google organizations/workspaces.
243
+
244
+ Returns False if the user is blocked, otherwise True.
245
+ """
246
+ user_info = auth_model["auth_state"][self.user_auth_state_key]
247
+
248
+ # hd ref: https://developers.google.com/identity/openid-connect/openid-connect#id_token-hd
249
+ hd = user_info.get("hd", "")
250
+
251
+ if self.hosted_domain and hd not in self.hosted_domain:
252
+ self.log.warning(f"Blocked {username} with 'hd={hd}' not in hosted_domain")
253
+ return False
254
+
255
+ return super().check_blocked_users(username, auth_model)
256
+
216
257
  async def check_allowed(self, username, auth_model):
217
258
  """
218
259
  Overrides the OAuthenticator.check_allowed to also allow users part of
@@ -235,19 +276,6 @@ class GoogleOAuthenticator(OAuthenticator, GoogleOAuth2Mixin):
235
276
  self.log.warning(message)
236
277
  raise HTTPError(403, message)
237
278
 
238
- # NOTE: If hosted_domain is configured as ["a.com", "b.com"], and
239
- # allowed_google_groups is declared as {"a.com": {"a-group"}}, a
240
- # "b.com" user won't be authorized unless allowed in another way.
241
- #
242
- # This means that its not possible to allow all users of a given
243
- # domain if one wants to restrict another.
244
- #
245
- if self.hosted_domain:
246
- if user_domain not in self.hosted_domain:
247
- message = f"Login with domain @{user_domain} is not allowed"
248
- self.log.warning(message)
249
- raise HTTPError(403, message)
250
-
251
279
  if await super().check_allowed(username, auth_model):
252
280
  return True
253
281
 
@@ -1,6 +1,7 @@
1
1
  """
2
2
  A JupyterHub authenticator class for use with MediaWiki as an identity provider.
3
3
  """
4
+
4
5
  import json
5
6
  import os
6
7
  from asyncio import wrap_future
@@ -3,12 +3,14 @@ Base classes for use by OAuth2 based JupyterHub authenticator classes.
3
3
 
4
4
  Founded based on work by Kyle Kelley (@rgbkrk)
5
5
  """
6
+
6
7
  import base64
7
8
  import json
8
9
  import os
9
10
  import uuid
10
11
  from urllib.parse import quote, urlencode, urlparse, urlunparse
11
12
 
13
+ import jwt
12
14
  from jupyterhub.auth import Authenticator
13
15
  from jupyterhub.crypto import EncryptionUnavailable, InvalidToken, decrypt
14
16
  from jupyterhub.handlers import BaseHandler, LogoutHandler
@@ -18,7 +20,7 @@ from tornado.auth import OAuth2Mixin
18
20
  from tornado.httpclient import AsyncHTTPClient, HTTPClientError, HTTPRequest
19
21
  from tornado.httputil import url_concat
20
22
  from tornado.log import app_log
21
- from traitlets import Any, Bool, Dict, List, Unicode, default
23
+ from traitlets import Any, Bool, Callable, Dict, List, Unicode, Union, default, validate
22
24
 
23
25
 
24
26
  def guess_callback_uri(protocol, host, hub_server_url):
@@ -268,6 +270,8 @@ class OAuthenticator(Authenticator):
268
270
  help="""
269
271
  Allow all authenticated users to login.
270
272
 
273
+ Overrides all other `allow` configuration.
274
+
271
275
  .. versionadded:: 16.0
272
276
  """,
273
277
  )
@@ -278,37 +282,29 @@ class OAuthenticator(Authenticator):
278
282
  help="""
279
283
  Allow existing users to login.
280
284
 
281
- An existing user is a user in JupyterHub's database of users, and it
282
- includes all users that has previously logged in.
285
+ Enable this if you want to manage user access via the JupyterHub admin page (/hub/admin).
286
+
287
+ With this enabled, all users present in the JupyterHub database are allowed to login.
288
+ This has the effect of any user who has _previously_ been allowed to login
289
+ via any means will continue to be allowed until the user is deleted via the /hub/admin page
290
+ or REST API.
283
291
 
284
292
  .. warning::
285
293
 
286
294
  Before enabling this you should review the existing users in the
287
295
  JupyterHub admin panel at `/hub/admin`. You may find users existing
288
- there because they have once been declared in config such as
289
- `allowed_users` or once been allowed to sign in.
296
+ there because they have previously been declared in config such as
297
+ `allowed_users` or allowed to sign in.
290
298
 
291
299
  .. warning::
292
300
 
293
- When this is enabled and you are to remove access for one or more
294
- users allowed via other config options, you must make sure that they
295
- are not part of the database of users still. This can be tricky to do
301
+ When this is enabled and you wish to remove access for one or more
302
+ users previously allowed, you must make sure that they
303
+ are removed from the jupyterhub database. This can be tricky to do
296
304
  if you stop allowing a group of externally managed users for example.
297
305
 
298
306
  With this enabled, JupyterHub admin users can visit `/hub/admin` or use
299
- JupyterHub's REST API to add and remove users as a way to allow them
300
- access.
301
-
302
- The username for existing users must match the normalized username
303
- returned by the authenticator. When creating users, only lowercase
304
- letters should be used unless `MWOAuthenticator` is used.
305
-
306
- .. note::
307
-
308
- Allowing existing users is done by adding existing users on startup
309
- and newly created users to the `allowed_users` set. Due to that, you
310
- can't rely on this config to independently allow existing users if
311
- you for example would reset `allowed_users` after startup.
307
+ JupyterHub's REST API to add and remove users to manage who can login.
312
308
 
313
309
  .. versionadded:: 16.0
314
310
 
@@ -359,6 +355,24 @@ class OAuthenticator(Authenticator):
359
355
  def _token_url_default(self):
360
356
  return os.environ.get("OAUTH2_TOKEN_URL", "")
361
357
 
358
+ userdata_from_id_token = Bool(
359
+ False,
360
+ config=True,
361
+ help="""
362
+ Extract user details from an id token received via a request to
363
+ :attr:`token_url`, rather than making a follow-up request to the
364
+ userinfo endpoint :attr:`userdata_url`.
365
+
366
+ Should only be used if :attr:`token_url` uses HTTPS, to ensure
367
+ token authenticity.
368
+
369
+ For more context, see `Authentication using the Authorization
370
+ Code Flow
371
+ <https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth>`_
372
+ in the OIDC Core standard document.
373
+ """,
374
+ )
375
+
362
376
  userdata_url = Unicode(
363
377
  config=True,
364
378
  help="""
@@ -369,6 +383,8 @@ class OAuthenticator(Authenticator):
369
383
  For more context, see the `Protocol Flow section
370
384
  <https://www.rfc-editor.org/rfc/rfc6749#section-1.2>`_ in the OAuth2
371
385
  standard document, specifically steps E-F.
386
+
387
+ Incompatible with :attr:`userdata_from_id_token`.
372
388
  """,
373
389
  )
374
390
 
@@ -376,14 +392,25 @@ class OAuthenticator(Authenticator):
376
392
  def _userdata_url_default(self):
377
393
  return os.environ.get("OAUTH2_USERDATA_URL", "")
378
394
 
379
- username_claim = Unicode(
380
- "username",
395
+ @validate("userdata_url")
396
+ def _validate_userdata_url(self, proposal):
397
+ if proposal.value and self.userdata_from_id_token:
398
+ raise ValueError(
399
+ "Cannot specify both authenticator.userdata_url and authenticator.userdata_from_id_token."
400
+ )
401
+ return proposal.value
402
+
403
+ username_claim = Union(
404
+ [Unicode(os.environ.get('OAUTH2_USERNAME_KEY', 'username')), Callable()],
381
405
  config=True,
382
406
  help="""
383
- The key to get the JupyterHub username from in the data response to the
384
- request made to :attr:`userdata_url`.
407
+ When `userdata_url` returns a json response, the username will be taken
408
+ from this key.
385
409
 
386
- Examples include: email, username, nickname
410
+ Can be a string key name or a callable that accepts the returned
411
+ userdata json (as a dict) and returns the username. The callable is
412
+ useful e.g. for extracting the username from a nested object in the
413
+ response or doing other post processing.
387
414
 
388
415
  What keys are available will depend on the scopes requested and the
389
416
  authenticator used.
@@ -768,7 +795,11 @@ class OAuthenticator(Authenticator):
768
795
 
769
796
  Called by the :meth:`oauthenticator.OAuthenticator.authenticate`
770
797
  """
771
- username = user_info.get(self.username_claim, None)
798
+
799
+ if callable(self.username_claim):
800
+ username = self.username_claim(user_info)
801
+ else:
802
+ username = user_info.get(self.username_claim, None)
772
803
  if not username:
773
804
  message = (f"No {self.username_claim} found in {user_info}",)
774
805
  self.log.error(message)
@@ -863,6 +894,9 @@ class OAuthenticator(Authenticator):
863
894
  Determines who the logged-in user by sending a "GET" request to
864
895
  :data:`oauthenticator.OAuthenticator.userdata_url` using the `access_token`.
865
896
 
897
+ If :data:`oauthenticator.OAuthenticator.userdata_from_id_token` is set then
898
+ extracts the corresponding info from an `id_token` instead.
899
+
866
900
  Args:
867
901
  token_info: the dictionary returned by the token request (exchanging the OAuth code for an Access Token)
868
902
 
@@ -871,6 +905,32 @@ class OAuthenticator(Authenticator):
871
905
 
872
906
  Called by the :meth:`oauthenticator.OAuthenticator.authenticate`
873
907
  """
908
+ if self.userdata_from_id_token:
909
+ # Use id token instead of exchanging access token with userinfo endpoint.
910
+ id_token = token_info.get("id_token", None)
911
+ if not id_token:
912
+ raise web.HTTPError(
913
+ 500,
914
+ f"An id token was not returned: {token_info}\nPlease configure authenticator.userdata_url",
915
+ )
916
+ try:
917
+ # Here we parse the id token. Note that per OIDC spec (core v1.0 sect. 3.1.3.7.6) we can skip
918
+ # signature validation as the hub has obtained the tokens from the id provider directly (using
919
+ # https). Google suggests all token validation may be skipped assuming the provider is trusted.
920
+ # https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation
921
+ # https://developers.google.com/identity/openid-connect/openid-connect#obtainuserinfo
922
+ return jwt.decode(
923
+ id_token,
924
+ audience=self.client_id,
925
+ options=dict(
926
+ verify_signature=False, verify_aud=True, verify_exp=True
927
+ ),
928
+ )
929
+ except Exception as err:
930
+ raise web.HTTPError(
931
+ 500, f"Unable to decode id token: {id_token}\n{err}"
932
+ )
933
+
874
934
  access_token = token_info["access_token"]
875
935
  token_type = token_info["token_type"]
876
936
 
@@ -1078,3 +1138,18 @@ class OAuthenticator(Authenticator):
1078
1138
  self._deprecated_oauth_trait, names=list(self._deprecated_oauth_aliases)
1079
1139
  )
1080
1140
  super().__init__(**kwargs)
1141
+
1142
+
1143
+ # patch allowed_users help string to match our definition
1144
+ # base Authenticator class help string gives the wrong impression
1145
+ # when combined with other allow options
1146
+ OAuthenticator.class_traits()[
1147
+ "allowed_users"
1148
+ ].help = """
1149
+ Set of usernames that should be allowed to login.
1150
+
1151
+ If unspecified, grants no access. You must set at least one other `allow` configuration
1152
+ if any users are to have permission to access the Hub.
1153
+
1154
+ Any usernames in `admin_users` will also be allowed to login.
1155
+ """
@@ -1,6 +1,7 @@
1
1
  """
2
2
  A JupyterHub authenticator class for use with OpenShift as an identity provider.
3
3
  """
4
+
4
5
  import concurrent.futures
5
6
  import json
6
7
  import os
@@ -15,6 +15,8 @@ properties:
15
15
  type: string
16
16
  allowed_domains_claim:
17
17
  type: string
18
+ default:
19
+ type: boolean
18
20
  username_derivation:
19
21
  type: object
20
22
  additionalProperties: false
@@ -1,4 +1,5 @@
1
1
  """Py.Test fixtures"""
2
+
2
3
  from pytest import fixture
3
4
  from tornado.httpclient import AsyncHTTPClient
4
5
 
@@ -1,4 +1,5 @@
1
1
  """Mocking utilities for testing"""
2
+
2
3
  import json
3
4
  import os
4
5
  import re
@@ -103,7 +104,6 @@ def setup_oauth_mock(
103
104
  access_token_path,
104
105
  user_path=None,
105
106
  token_type='Bearer',
106
- token_request_style='post',
107
107
  ):
108
108
  """setup the mock client for OAuth
109
109
 
@@ -127,9 +127,6 @@ def setup_oauth_mock(
127
127
  token_type (str): the token_type field for the provider
128
128
  """
129
129
 
130
- if user_path is None and token_request_style != "jwt":
131
- raise TypeError("user_path is required unless token_request_style is jwt")
132
-
133
130
  client.oauth_codes = oauth_codes = {}
134
131
  client.access_tokens = access_tokens = {}
135
132
 
@@ -164,7 +161,7 @@ def setup_oauth_mock(
164
161
  'access_token': token,
165
162
  'token_type': token_type,
166
163
  }
167
- if token_request_style == 'jwt':
164
+ if 'id_token' in user:
168
165
  model['id_token'] = user['id_token']
169
166
  return model
170
167
 
@@ -1,4 +1,5 @@
1
1
  """test azure ad"""
2
+
2
3
  import json
3
4
  import os
4
5
  import re
@@ -7,6 +8,7 @@ import uuid
7
8
  from unittest import mock
8
9
 
9
10
  import jwt
11
+ import pytest
10
12
  from pytest import fixture, mark
11
13
  from traitlets.config import Config
12
14
 
@@ -20,7 +22,6 @@ def azure_client(client):
20
22
  client,
21
23
  host=['login.microsoftonline.com'],
22
24
  access_token_path=re.compile('^/[^/]+/oauth2/token$'),
23
- token_request_style='jwt',
24
25
  )
25
26
  return client
26
27
 
@@ -44,6 +45,17 @@ def user_model(tenant_id, client_id, name):
44
45
  "tid": tenant_id,
45
46
  "nonce": "123523",
46
47
  "aio": "Df2UVXL1ix!lMCWMSOJBcFatzcGfvFGhjKv8q5g0x732dR5MB5BisvGQO7YWByjd8iQDLq!eGbIDakyp5mnOrcdqHeYSnltepQmRp6AIZ8jY",
48
+ "groups": [
49
+ "96000b2c-7333-4f6e-a2c3-e7608fa2d131",
50
+ "a992b3d5-1966-4af4-abed-6ef021417be4",
51
+ "ceb90a42-030f-44f1-a0c7-825b572a3b07",
52
+ ],
53
+ # different from 'groups' for tests
54
+ "grp": [
55
+ "96000b2c-7333-4f6e-a2c3",
56
+ "a992b3d5-1966-4af4-abed",
57
+ "ceb90a42-030f-44f1-a0c7",
58
+ ],
47
59
  },
48
60
  os.urandom(5),
49
61
  )
@@ -103,6 +115,23 @@ def user_model(tenant_id, client_id, name):
103
115
  True,
104
116
  None,
105
117
  ),
118
+ # test user_groups_claim
119
+ (
120
+ "30",
121
+ {"allow_all": True, "manage_groups": True},
122
+ True,
123
+ None,
124
+ ),
125
+ (
126
+ "31",
127
+ {
128
+ "allow_all": True,
129
+ "manage_groups": True,
130
+ "user_groups_claim": "grp",
131
+ },
132
+ True,
133
+ None,
134
+ ),
106
135
  ],
107
136
  )
108
137
  async def test_azuread(
@@ -119,6 +148,12 @@ async def test_azuread(
119
148
  c.AzureAdOAuthenticator.client_id = str(uuid.uuid1())
120
149
  c.AzureAdOAuthenticator.client_secret = str(uuid.uuid1())
121
150
  authenticator = AzureAdOAuthenticator(config=c)
151
+ manage_groups = False
152
+ if "manage_groups" in class_config:
153
+ if hasattr(authenticator, "manage_groups"):
154
+ manage_groups = authenticator.manage_groups
155
+ else:
156
+ pytest.skip("manage_groups requires jupyterhub 2.2")
122
157
 
123
158
  handled_user_model = user_model(
124
159
  tenant_id=authenticator.tenant_id,
@@ -130,7 +165,10 @@ async def test_azuread(
130
165
 
131
166
  if expect_allowed:
132
167
  assert auth_model
133
- assert set(auth_model) == {"name", "admin", "auth_state"}
168
+ expected_keys = {"name", "admin", "auth_state"}
169
+ if manage_groups:
170
+ expected_keys.add("groups")
171
+ assert set(auth_model) == expected_keys
134
172
  assert auth_model["admin"] == expect_admin
135
173
  auth_state = auth_model["auth_state"]
136
174
  assert json.dumps(auth_state)
@@ -138,6 +176,9 @@ async def test_azuread(
138
176
  user_info = auth_state[authenticator.user_auth_state_key]
139
177
  assert user_info["aud"] == authenticator.client_id
140
178
  assert auth_model["name"] == user_info[authenticator.username_claim]
179
+ if manage_groups:
180
+ groups = auth_model['groups']
181
+ assert groups == user_info[authenticator.user_groups_claim]
141
182
  else:
142
183
  assert auth_model == None
143
184
 
@@ -636,6 +636,7 @@ async def test_allowed_idps_username_derivation_actions(cilogon_client):
636
636
  c.CILogonOAuthenticator.allow_all = True
637
637
  c.CILogonOAuthenticator.allowed_idps = {
638
638
  'https://strip-idp-domain.example.com/login/oauth/authorize': {
639
+ 'default': True,
639
640
  'username_derivation': {
640
641
  'username_claim': 'email',
641
642
  'action': 'strip_idp_domain',
@@ -1,23 +1,33 @@
1
1
  import json
2
2
  from functools import partial
3
3
 
4
+ import jwt
4
5
  from pytest import fixture, mark
5
6
  from traitlets.config import Config
6
7
 
7
8
  from ..generic import GenericOAuthenticator
8
9
  from .mocks import setup_oauth_mock
9
10
 
11
+ client_id = "jupyterhub-oauth-client"
12
+
10
13
 
11
14
  def user_model(username, **kwargs):
12
15
  """Return a user model"""
13
16
  return {
14
17
  "username": username,
18
+ "aud": client_id,
19
+ "sub": "oauth2|cilogon|http://cilogon.org/servera/users/43431",
15
20
  "scope": "basic",
16
21
  "groups": ["group1"],
17
22
  **kwargs,
18
23
  }
19
24
 
20
25
 
26
+ @fixture(params=["id_token", "userdata_url"])
27
+ def userdata_from_id_token(request):
28
+ return request.param == "id_token"
29
+
30
+
21
31
  @fixture
22
32
  def generic_client(client):
23
33
  setup_oauth_mock(
@@ -29,10 +39,31 @@ def generic_client(client):
29
39
  return client
30
40
 
31
41
 
42
+ @fixture
43
+ def generic_client_variant(client, userdata_from_id_token):
44
+ setup_oauth_mock(
45
+ client,
46
+ host='generic.horse',
47
+ access_token_path='/oauth/access_token',
48
+ user_path='/oauth/userinfo',
49
+ )
50
+ return client
51
+
52
+
32
53
  def _get_authenticator(**kwargs):
33
54
  return GenericOAuthenticator(
34
55
  token_url='https://generic.horse/oauth/access_token',
35
56
  userdata_url='https://generic.horse/oauth/userinfo',
57
+ client_id=client_id,
58
+ **kwargs,
59
+ )
60
+
61
+
62
+ def _get_authenticator_for_id_token(**kwargs):
63
+ return GenericOAuthenticator(
64
+ token_url='https://generic.horse/oauth/access_token',
65
+ userdata_from_id_token=True,
66
+ client_id=client_id,
36
67
  **kwargs,
37
68
  )
38
69
 
@@ -45,6 +76,21 @@ def get_authenticator(generic_client):
45
76
  return partial(_get_authenticator, http_client=generic_client)
46
77
 
47
78
 
79
+ @fixture
80
+ def get_authenticator_variant(generic_client, userdata_from_id_token):
81
+ """
82
+ http_client can't be configured, only passed as argument to the constructor.
83
+ """
84
+ return partial(
85
+ (
86
+ _get_authenticator_for_id_token
87
+ if userdata_from_id_token
88
+ else _get_authenticator
89
+ ),
90
+ http_client=generic_client,
91
+ )
92
+
93
+
48
94
  @mark.parametrize(
49
95
  "test_variation_id,class_config,expect_allowed,expect_admin",
50
96
  [
@@ -151,29 +197,47 @@ def get_authenticator(generic_client):
151
197
  False,
152
198
  False,
153
199
  ),
200
+ (
201
+ "20",
202
+ {
203
+ "manage_groups": True,
204
+ "allow_all": True,
205
+ },
206
+ True,
207
+ None,
208
+ ),
154
209
  ],
155
210
  )
156
211
  async def test_generic(
157
- get_authenticator,
158
- generic_client,
212
+ get_authenticator_variant,
213
+ generic_client_variant,
159
214
  test_variation_id,
160
215
  class_config,
161
216
  expect_allowed,
162
217
  expect_admin,
218
+ userdata_from_id_token,
163
219
  ):
164
220
  print(f"Running test variation id {test_variation_id}")
165
221
  c = Config()
166
222
  c.GenericOAuthenticator = Config(class_config)
167
223
  c.GenericOAuthenticator.username_claim = "username"
168
- authenticator = get_authenticator(config=c)
224
+ authenticator = get_authenticator_variant(config=c)
225
+ manage_groups = False
226
+ if "manage_groups" in class_config:
227
+ manage_groups = authenticator.manage_groups
169
228
 
170
229
  handled_user_model = user_model("user1")
171
- handler = generic_client.handler_for_user(handled_user_model)
230
+ if userdata_from_id_token:
231
+ handled_user_model = dict(id_token=jwt.encode(handled_user_model, key="foo"))
232
+ handler = generic_client_variant.handler_for_user(handled_user_model)
172
233
  auth_model = await authenticator.get_authenticated_user(handler, None)
173
234
 
174
235
  if expect_allowed:
175
236
  assert auth_model
176
- assert set(auth_model) == {"name", "admin", "auth_state"}
237
+ expected_keys = {"name", "admin", "auth_state"}
238
+ if manage_groups:
239
+ expected_keys.add("groups")
240
+ assert set(auth_model) == expected_keys
177
241
  assert auth_model["admin"] == expect_admin
178
242
  auth_state = auth_model["auth_state"]
179
243
  assert json.dumps(auth_state)
@@ -183,10 +247,39 @@ async def test_generic(
183
247
  assert "scope" in auth_state
184
248
  user_info = auth_state[authenticator.user_auth_state_key]
185
249
  assert auth_model["name"] == user_info[authenticator.username_claim]
250
+ if manage_groups:
251
+ assert auth_model["groups"] == user_info[authenticator.claim_groups_key]
252
+
186
253
  else:
187
254
  assert auth_model == None
188
255
 
189
256
 
257
+ async def test_username_claim_callable(
258
+ get_authenticator,
259
+ generic_client,
260
+ ):
261
+ c = Config()
262
+ c.GenericOAuthenticator = Config()
263
+
264
+ def username_claim(user_info):
265
+ username = user_info["sub"]
266
+ if username.startswith("oauth2|cilogon"):
267
+ cilogon_sub = username.rsplit("|", 1)[-1]
268
+ cilogon_sub_parts = cilogon_sub.split("/")
269
+ username = f"oauth2|cilogon|{cilogon_sub_parts[3]}|{cilogon_sub_parts[5]}"
270
+ return username
271
+
272
+ c.GenericOAuthenticator.username_claim = username_claim
273
+ c.GenericOAuthenticator.allow_all = True
274
+ authenticator = get_authenticator(config=c)
275
+
276
+ handled_user_model = user_model("user1")
277
+ handler = generic_client.handler_for_user(handled_user_model)
278
+ auth_model = await authenticator.get_authenticated_user(handler, None)
279
+
280
+ assert auth_model["name"] == "oauth2|cilogon|servera|43431"
281
+
282
+
190
283
  async def test_generic_data(get_authenticator, generic_client):
191
284
  c = Config()
192
285
  c.GenericOAuthenticator.allow_all = True
@@ -5,22 +5,23 @@ import re
5
5
  from unittest import mock
6
6
 
7
7
  from pytest import fixture, mark, raises
8
- from tornado.web import HTTPError
9
8
  from traitlets.config import Config
10
9
 
11
10
  from ..google import GoogleOAuthenticator
12
11
  from .mocks import setup_oauth_mock
13
12
 
14
13
 
15
- def user_model(email, username="user1"):
14
+ def user_model(email, username="user1", hd=None):
16
15
  """Return a user model"""
17
- return {
16
+ model = {
18
17
  'sub': hashlib.md5(email.encode()).hexdigest(),
19
18
  'email': email,
20
19
  'custom': username,
21
- 'hd': email.split('@')[1],
22
20
  'verified_email': True,
23
21
  }
22
+ if hd:
23
+ model['hd'] = hd
24
+ return model
24
25
 
25
26
 
26
27
  @fixture
@@ -187,37 +188,49 @@ async def test_google(
187
188
  assert auth_model == None
188
189
 
189
190
 
190
- async def test_hosted_domain_single_entry(google_client):
191
+ @mark.parametrize(
192
+ "test_variation_id,user_email,user_hd,expect_username,expect_allowed,expect_admin",
193
+ [
194
+ ("01", "user1@ok-hd.orG", "ok-hd.org", "user1", True, True),
195
+ ("02", "user2@ok-hd.orG", "ok-hd.org", "user2", True, None),
196
+ ("03", "blocked@ok-hd.org", "ok-hd.org", None, False, None),
197
+ ("04", "user2@ok-hd.org", "", None, False, None),
198
+ ("05", "user1@not-ok.org", "", None, False, None),
199
+ # Test variation 06 below isn't believed to be possible, but since we
200
+ # aren't sure this test clarifies what we expect to happen.
201
+ ("06", "user1@other.org", "ok-hd.org", "user1@other.org", True, None),
202
+ ],
203
+ )
204
+ async def test_hosted_domain_single_entry(
205
+ google_client,
206
+ test_variation_id,
207
+ user_email,
208
+ user_hd,
209
+ expect_username,
210
+ expect_allowed,
211
+ expect_admin,
212
+ ):
191
213
  """
192
214
  Tests that sign in is restricted to the listed domain and that the username
193
215
  represents the part before the `@domain.com` as expected when hosted_domain
194
216
  contains a single entry.
195
217
  """
196
218
  c = Config()
197
- c.GoogleOAuthenticator.hosted_domain = ["In-Hosted-Domain.com"]
219
+ c.GoogleOAuthenticator.hosted_domain = ["ok-hd.org"]
198
220
  c.GoogleOAuthenticator.admin_users = {"user1"}
199
- c.GoogleOAuthenticator.allowed_users = {"user2"}
221
+ c.GoogleOAuthenticator.allowed_users = {"user2", "blocked", "user1@other.org"}
222
+ c.GoogleOAuthenticator.blocked_users = {"blocked"}
200
223
  authenticator = GoogleOAuthenticator(config=c)
201
224
 
202
- handled_user_model = user_model("user1@iN-hosteD-domaiN.com")
225
+ handled_user_model = user_model(user_email, hd=user_hd)
203
226
  handler = google_client.handler_for_user(handled_user_model)
204
227
  auth_model = await authenticator.get_authenticated_user(handler, None)
205
- assert auth_model
206
- assert auth_model["name"] == "user1"
207
- assert auth_model["admin"] == True
208
-
209
- handled_user_model = user_model("user2@iN-hosteD-domaiN.com")
210
- handler = google_client.handler_for_user(handled_user_model)
211
- auth_model = await authenticator.get_authenticated_user(handler, None)
212
- assert auth_model
213
- assert auth_model["name"] == "user2"
214
- assert auth_model["admin"] == None
215
-
216
- handled_user_model = user_model("user1@not-in-hosted-domain.com")
217
- handler = google_client.handler_for_user(handled_user_model)
218
- with raises(HTTPError) as exc:
219
- await authenticator.get_authenticated_user(handler, None)
220
- assert exc.value.status_code == 403
228
+ if expect_allowed:
229
+ assert auth_model
230
+ assert auth_model["name"] == expect_username
231
+ assert auth_model["admin"] == expect_admin
232
+ else:
233
+ assert auth_model == None
221
234
 
222
235
 
223
236
  @mark.parametrize(
@@ -235,7 +248,27 @@ async def test_check_allowed_no_auth_state(google_client, name, allowed):
235
248
  assert await authenticator.check_allowed(name, None)
236
249
 
237
250
 
238
- async def test_hosted_domain_multiple_entries(google_client):
251
+ @mark.parametrize(
252
+ "test_variation_id,user_email,user_hd,expect_username,expect_allowed",
253
+ [
254
+ ("01", "user1@ok-hd1.orG", "ok-hd1.org", "user1@ok-hd1.org", True),
255
+ ("02", "user2@ok-hd2.orG", "ok-hd2.org", "user2@ok-hd2.org", True),
256
+ ("03", "blocked@ok-hd1.org", "ok-hd1.org", None, False),
257
+ ("04", "user3@ok-hd1.org", "", None, False),
258
+ ("05", "user1@not-ok.org", "", None, False),
259
+ # Test variation 06 below isn't believed to be possible, but since we
260
+ # aren't sure this test clarifies what we expect to happen.
261
+ ("06", "user1@other.org", "ok-hd1.org", "user1@other.org", True),
262
+ ],
263
+ )
264
+ async def test_hosted_domain_multiple_entries(
265
+ google_client,
266
+ test_variation_id,
267
+ user_email,
268
+ user_hd,
269
+ expect_username,
270
+ expect_allowed,
271
+ ):
239
272
  """
240
273
  Tests that sign in is restricted to the listed domains and that the username
241
274
  represents the full email as expected when hosted_domain contains multiple
@@ -243,29 +276,21 @@ async def test_hosted_domain_multiple_entries(google_client):
243
276
  """
244
277
  c = Config()
245
278
  c.GoogleOAuthenticator.hosted_domain = [
246
- "In-Hosted-Domain1.com",
247
- "In-Hosted-Domain2.com",
279
+ "ok-hd1.org",
280
+ "ok-hd2.ORG",
248
281
  ]
282
+ c.GoogleOAuthenticator.blocked_users = ["blocked@ok-hd1.org"]
249
283
  c.GoogleOAuthenticator.allow_all = True
250
284
  authenticator = GoogleOAuthenticator(config=c)
251
285
 
252
- handled_user_model = user_model("user1@iN-hosteD-domaiN1.com")
286
+ handled_user_model = user_model(user_email, hd=user_hd)
253
287
  handler = google_client.handler_for_user(handled_user_model)
254
288
  auth_model = await authenticator.get_authenticated_user(handler, None)
255
- assert auth_model
256
- assert auth_model["name"] == "user1@in-hosted-domain1.com"
257
-
258
- handled_user_model = user_model("user2@iN-hosteD-domaiN2.com")
259
- handler = google_client.handler_for_user(handled_user_model)
260
- auth_model = await authenticator.get_authenticated_user(handler, None)
261
- assert auth_model
262
- assert auth_model["name"] == "user2@in-hosted-domain2.com"
263
-
264
- handled_user_model = user_model("user1@not-in-hosted-domain.com")
265
- handler = google_client.handler_for_user(handled_user_model)
266
- with raises(HTTPError) as exc:
267
- await authenticator.get_authenticated_user(handler, None)
268
- assert exc.value.status_code == 403
289
+ if expect_allowed:
290
+ assert auth_model
291
+ assert auth_model["name"] == expect_username
292
+ else:
293
+ assert auth_model == None
269
294
 
270
295
 
271
296
  @mark.parametrize(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: oauthenticator
3
- Version: 16.2.0
3
+ Version: 16.3.0
4
4
  Summary: OAuthenticator: Authenticate JupyterHub users with common OAuth providers
5
5
  Home-page: https://jupyter.org
6
6
  Author: Jupyter Development Team
@@ -19,13 +19,12 @@ Requires-Python: >=3.8
19
19
  Description-Content-Type: text/markdown
20
20
  License-File: LICENSE
21
21
  Requires-Dist: jsonschema
22
- Requires-Dist: jupyterhub>=1.2
22
+ Requires-Dist: jupyterhub>=2.2
23
+ Requires-Dist: pyjwt>=2
23
24
  Requires-Dist: requests
24
25
  Requires-Dist: ruamel.yaml
25
26
  Requires-Dist: tornado
26
27
  Requires-Dist: traitlets
27
- Provides-Extra: azuread
28
- Requires-Dist: pyjwt>=2; extra == "azuread"
29
28
  Provides-Extra: googlegroups
30
29
  Requires-Dist: google-api-python-client; extra == "googlegroups"
31
30
  Requires-Dist: google-auth-oauthlib; extra == "googlegroups"
@@ -33,10 +32,9 @@ Provides-Extra: mediawiki
33
32
  Requires-Dist: mwoauth>=0.3.8; extra == "mediawiki"
34
33
  Provides-Extra: test
35
34
  Requires-Dist: pytest>=2.8; extra == "test"
36
- Requires-Dist: pytest-asyncio; extra == "test"
35
+ Requires-Dist: pytest-asyncio<0.23,>=0.17; extra == "test"
37
36
  Requires-Dist: pytest-cov; extra == "test"
38
37
  Requires-Dist: requests-mock; extra == "test"
39
- Requires-Dist: pyjwt>=2; extra == "test"
40
38
  Requires-Dist: google-api-python-client; extra == "test"
41
39
  Requires-Dist: google-auth-oauthlib; extra == "test"
42
40
  Requires-Dist: mwoauth>=0.3.8; extra == "test"
@@ -1,13 +1,11 @@
1
1
  jsonschema
2
- jupyterhub>=1.2
2
+ jupyterhub>=2.2
3
+ pyjwt>=2
3
4
  requests
4
5
  ruamel.yaml
5
6
  tornado
6
7
  traitlets
7
8
 
8
- [azuread]
9
- pyjwt>=2
10
-
11
9
  [googlegroups]
12
10
  google-api-python-client
13
11
  google-auth-oauthlib
@@ -17,10 +15,9 @@ mwoauth>=0.3.8
17
15
 
18
16
  [test]
19
17
  pytest>=2.8
20
- pytest-asyncio
18
+ pytest-asyncio<0.23,>=0.17
21
19
  pytest-cov
22
20
  requests-mock
23
- pyjwt>=2
24
21
  google-api-python-client
25
22
  google-auth-oauthlib
26
23
  mwoauth>=0.3.8
@@ -64,7 +64,7 @@ omit = [
64
64
  github_url = "https://github.com/jupyterhub/oauthenticator"
65
65
 
66
66
  [tool.tbump.version]
67
- current = "16.2.0"
67
+ current = "16.3.0"
68
68
  regex = '''
69
69
  (?P<major>\d+)
70
70
  \.
@@ -1,6 +1,9 @@
1
1
  # jsonschema is used for validating authenticator configurations
2
2
  jsonschema
3
- jupyterhub>=1.2
3
+ jupyterhub>=2.2
4
+ # PyJWT is used for parsing id tokens
5
+ # and azuread
6
+ pyjwt>=2
4
7
  # requests is already required by JupyterHub, but explicitly ask for it since we use it
5
8
  requests
6
9
  # ruamel.yaml is used to read and write .yaml files.
@@ -27,7 +27,7 @@ class bdist_egg_disabled(bdist_egg):
27
27
  setup_args = dict(
28
28
  name='oauthenticator',
29
29
  packages=find_packages(),
30
- version="16.2.0",
30
+ version="16.3.0",
31
31
  description="OAuthenticator: Authenticate JupyterHub users with common OAuth providers",
32
32
  long_description=open("README.md").read(),
33
33
  long_description_content_type="text/markdown",
@@ -88,8 +88,6 @@ with open('requirements.txt') as f:
88
88
 
89
89
 
90
90
  setup_args['extras_require'] = {
91
- # azuread is required for use of AzureADOAuthenticator
92
- 'azuread': ['pyjwt>=2'],
93
91
  # googlegroups is required for use of GoogleOAuthenticator configured with
94
92
  # either admin_google_groups and/or allowed_google_groups.
95
93
  'googlegroups': [
@@ -102,11 +100,10 @@ setup_args['extras_require'] = {
102
100
  # dependencies above.
103
101
  'test': [
104
102
  'pytest>=2.8',
105
- 'pytest-asyncio',
103
+ # FIXME: unpin pytest-asyncio
104
+ 'pytest-asyncio>=0.17,<0.23',
106
105
  'pytest-cov',
107
106
  'requests-mock',
108
- # dependencies from azuread:
109
- 'pyjwt>=2',
110
107
  # dependencies from googlegroups:
111
108
  'google-api-python-client',
112
109
  'google-auth-oauthlib',
File without changes