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.
- {oauthenticator-16.2.0/oauthenticator.egg-info → oauthenticator-16.3.0}/PKG-INFO +4 -6
- {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/_version.py +1 -1
- {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/auth0.py +1 -0
- {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/azuread.py +20 -0
- {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/bitbucket.py +1 -0
- {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/cilogon.py +1 -0
- {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/generic.py +14 -27
- {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/github.py +1 -0
- {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/gitlab.py +1 -0
- {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/globus.py +1 -0
- {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/google.py +54 -26
- {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/mediawiki.py +1 -0
- {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/oauth2.py +102 -27
- {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/openshift.py +1 -0
- {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/schemas/cilogon-schema.yaml +2 -0
- {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/tests/conftest.py +1 -0
- {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/tests/mocks.py +2 -5
- {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/tests/test_azuread.py +43 -2
- {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/tests/test_cilogon.py +1 -0
- {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/tests/test_generic.py +98 -5
- {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/tests/test_google.py +67 -42
- {oauthenticator-16.2.0 → oauthenticator-16.3.0/oauthenticator.egg-info}/PKG-INFO +4 -6
- {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator.egg-info/requires.txt +3 -6
- {oauthenticator-16.2.0 → oauthenticator-16.3.0}/pyproject.toml +1 -1
- {oauthenticator-16.2.0 → oauthenticator-16.3.0}/requirements.txt +4 -1
- {oauthenticator-16.2.0 → oauthenticator-16.3.0}/setup.py +3 -6
- {oauthenticator-16.2.0 → oauthenticator-16.3.0}/CONTRIBUTING.md +0 -0
- {oauthenticator-16.2.0 → oauthenticator-16.3.0}/LICENSE +0 -0
- {oauthenticator-16.2.0 → oauthenticator-16.3.0}/MANIFEST.in +0 -0
- {oauthenticator-16.2.0 → oauthenticator-16.3.0}/README.md +0 -0
- {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/__init__.py +0 -0
- {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/tests/__init__.py +0 -0
- {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/tests/test_auth0.py +0 -0
- {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/tests/test_bitbucket.py +0 -0
- {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/tests/test_github.py +0 -0
- {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/tests/test_gitlab.py +0 -0
- {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/tests/test_globus.py +0 -0
- {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/tests/test_mediawiki.py +0 -0
- {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/tests/test_oauth2.py +0 -0
- {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator/tests/test_openshift.py +0 -0
- {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator.egg-info/SOURCES.txt +0 -0
- {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator.egg-info/dependency_links.txt +0 -0
- {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator.egg-info/entry_points.txt +0 -0
- {oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator.egg-info/top_level.txt +0 -0
- {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.
|
|
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>=
|
|
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.
|
|
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 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 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 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
|
|
114
|
-
`["mycollege.edu"]` or `["college1.edu",
|
|
115
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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
|
-
|
|
122
|
-
|
|
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
|
-
|
|
177
|
-
|
|
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
|
|
|
@@ -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
|
-
|
|
282
|
-
|
|
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
|
|
289
|
-
`allowed_users` or
|
|
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
|
|
294
|
-
users allowed
|
|
295
|
-
are
|
|
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
|
|
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
|
-
|
|
380
|
-
|
|
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
|
-
|
|
384
|
-
|
|
407
|
+
When `userdata_url` returns a json response, the username will be taken
|
|
408
|
+
from this key.
|
|
385
409
|
|
|
386
|
-
|
|
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
|
-
|
|
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,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
|
|
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
|
-
|
|
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
|
-
|
|
158
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = ["
|
|
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(
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
247
|
-
"
|
|
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(
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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.
|
|
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>=
|
|
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>=
|
|
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
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
# jsonschema is used for validating authenticator configurations
|
|
2
2
|
jsonschema
|
|
3
|
-
jupyterhub>=
|
|
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.
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{oauthenticator-16.2.0 → oauthenticator-16.3.0}/oauthenticator.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|