Skip to content

Commit ad4034c

Browse files
authored
Merge pull request #758 from consideRatio/pr/groups-tests
Various fixes for allowed_groups and admin_groups
2 parents d2aac2d + 9e5520d commit ad4034c

File tree

18 files changed

+615
-126
lines changed

18 files changed

+615
-126
lines changed

docs/source/tutorials/general-setup.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,9 @@ projects' authenticator classes.
6060
- {attr}`.OAuthenticator.allow_all`
6161
- {attr}`.OAuthenticator.allow_existing_users`
6262
- {attr}`.OAuthenticator.allowed_users`
63+
- {attr}`.OAuthenticator.allowed_groups`
6364
- {attr}`.OAuthenticator.admin_users`
65+
- {attr}`.OAuthenticator.admin_groups`
6466

6567
Your authenticator class may have unique config, so in the end it can look
6668
something like this:

docs/source/tutorials/provider-specific-setup/providers/azuread.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,16 +35,14 @@ be relevant to read more about in the configuration reference:
3535
## Loading user groups
3636

3737
The `AzureAdOAuthenticator` can load the group-membership of users from the access token.
38-
This is done by setting the `AzureAdOAuthenticator.groups_claim` to the name of the claim that contains the
39-
group-membership.
4038

4139
```python
4240
c.JupyterHub.authenticator_class = "azuread"
4341

4442
# {...} other settings (see above)
4543

4644
c.AzureAdOAuthenticator.manage_groups = True
47-
c.AzureAdOAuthenticator.user_groups_claim = 'groups' # this is the default
45+
c.AzureAdOAuthenticator.auth_state_groups_key = "user.groups" # this is the default
4846
```
4947

5048
This requires Azure AD to be configured to include the group-membership in the access token.

docs/source/tutorials/provider-specific-setup/providers/generic.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ c.GenericOAuthenticator.userdata_url = "https://accounts.example.com/auth/realms
3535
#
3636
c.GenericOAuthenticator.scope = ["openid", "email", "groups"]
3737
c.GenericOAuthenticator.username_claim = "email"
38-
c.GenericOAuthenticator.claim_groups_key = "groups"
38+
c.GenericOAuthenticator.auth_state_groups_key = "oauth_user.groups"
3939

4040
# Authorization
4141
# -------------

oauthenticator/azuread.py

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,26 @@ def _username_claim_default(self):
2323
return "name"
2424

2525
user_groups_claim = Unicode(
26-
"groups",
26+
"",
2727
config=True,
2828
help="""
29-
Name of claim containing user group memberships.
29+
.. deprecated:: 17.0
3030
31-
Will populate JupyterHub groups if Authenticator.manage_groups is True.
31+
Use :attr:`auth_state_groups_key` instead.
3232
""",
3333
)
3434

35+
@default('auth_state_groups_key')
36+
def _auth_state_groups_key_default(self):
37+
key = "user.groups"
38+
if self.user_groups_claim:
39+
key = f"{self.user_auth_state_key}.{self.user_groups_claim}"
40+
cls = self.__class__.__name__
41+
self.log.warning(
42+
f"{cls}.user_groups_claim is deprecated in OAuthenticator 17. Use {cls}.auth_state_groups_key = {key!r}"
43+
)
44+
return key
45+
3546
tenant_id = Unicode(
3647
config=True,
3748
help="""
@@ -55,15 +66,6 @@ def _authorize_url_default(self):
5566
def _token_url_default(self):
5667
return f"https://login.microsoftonline.com/{self.tenant_id}/oauth2/token"
5768

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-
6769
async def token_to_user(self, token_info):
6870
id_token = token_info['id_token']
6971
decoded = jwt.decode(

oauthenticator/globus.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -342,7 +342,7 @@ async def update_auth_model(self, auth_model):
342342
to False makes it be revoked.
343343
"""
344344
user_groups = set()
345-
if self.allowed_globus_groups or self.admin_globus_groups:
345+
if self.allowed_globus_groups or self.admin_globus_groups or self.manage_groups:
346346
tokens = self.get_globus_tokens(auth_model["auth_state"]["token_response"])
347347
user_groups = await self._fetch_users_groups(tokens)
348348
# sets are not JSONable, cast to list for auth_state

oauthenticator/oauth2.py

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1103,7 +1103,7 @@ def build_auth_state_dict(self, token_info, user_info):
11031103
Called by the :meth:`oauthenticator.OAuthenticator.authenticate`
11041104
11051105
.. versionchanged:: 17.0
1106-
This method be async.
1106+
This method may be async.
11071107
"""
11081108

11091109
# We know for sure the `access_token` key exists, oterwise we would have errored out already
@@ -1133,6 +1133,8 @@ async def get_user_groups(self, auth_state: dict):
11331133
Returns a set of groups the user belongs to based on auth_state_groups_key
11341134
and provided auth_state.
11351135
1136+
Only called when :attr:`manage_groups` is True.
1137+
11361138
- If auth_state_groups_key is a callable, it returns the list of groups directly.
11371139
Callable may be async.
11381140
- If auth_state_groups_key is a nested dictionary key like
@@ -1168,11 +1170,23 @@ async def update_auth_model(self, auth_model):
11681170
- `name`: the normalized username
11691171
- `admin`: the admin status (True/False/None), where None means it
11701172
should be unchanged.
1171-
- `auth_state`: the dictionary of of auth state
1172-
returned by :meth:`oauthenticator.OAuthenticator.build_auth_state_dict`
1173+
- `auth_state`: the auth state dictionary,
1174+
returned by :meth:`oauthenticator.OAuthenticator.build_auth_state_dict`
11731175
11741176
Called by the :meth:`oauthenticator.OAuthenticator.authenticate`
11751177
"""
1178+
# NOTE: this base implementation should _not_ be updated to do anything
1179+
# subclasses should have full control without calling super()
1180+
return auth_model
1181+
1182+
async def _apply_managed_groups(self, auth_model):
1183+
"""Applies managed_groups logic
1184+
1185+
Called after `update_auth_model` to populate the `groups` field.
1186+
Only called if `manage_groups` is True.
1187+
1188+
The public method for subclasses to override is `.get_user_groups`.
1189+
"""
11761190
if self.manage_groups:
11771191
auth_state = auth_model["auth_state"]
11781192
user_groups = self.get_user_groups(auth_state)
@@ -1244,7 +1258,10 @@ async def authenticate(self, handler, data=None, **kwargs):
12441258

12451259
# update the auth_model with info to later authorize the user in
12461260
# check_allowed, such as admin status and group memberships
1247-
return await self.update_auth_model(auth_model)
1261+
auth_model = await self.update_auth_model(auth_model)
1262+
if self.manage_groups:
1263+
auth_model = await self._apply_managed_groups(auth_model)
1264+
return auth_model
12481265

12491266
async def check_allowed(self, username, auth_model):
12501267
"""
@@ -1289,12 +1306,8 @@ async def check_allowed(self, username, auth_model):
12891306
return True
12901307

12911308
# allow users who are members of allowed_groups
1292-
if self.manage_groups and self.allowed_groups:
1293-
auth_state = auth_model["auth_state"]
1294-
user_groups = self.get_user_groups(auth_state)
1295-
if isawaitable(user_groups):
1296-
user_groups = await user_groups
1297-
if any(user_groups & self.allowed_groups):
1309+
if self.manage_groups and self.allowed_groups and auth_model.get("groups"):
1310+
if set(auth_model["groups"]) & self.allowed_groups:
12981311
return True
12991312

13001313
# users should be explicitly allowed via config, otherwise they aren't

oauthenticator/openshift.py

Lines changed: 5 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,18 @@
88

99
from jupyterhub.auth import LocalAuthenticator
1010
from tornado.httpclient import HTTPClient, HTTPRequest
11-
from traitlets import Bool, Set, Unicode, default
11+
from traitlets import Bool, Unicode, default
1212

1313
from oauthenticator.oauth2 import OAuthenticator
1414

1515

1616
class OpenShiftOAuthenticator(OAuthenticator):
1717
user_auth_state_key = "openshift_user"
1818

19+
@default("auth_state_groups_key")
20+
def _auth_state_groups_key_default(self):
21+
return "openshift_user.groups"
22+
1923
@default("scope")
2024
def _scope_default(self):
2125
return ["user:info"]
@@ -45,24 +49,6 @@ def _http_request_kwargs_default(self):
4549
""",
4650
)
4751

48-
allowed_groups = Set(
49-
config=True,
50-
help="""
51-
Allow members of selected OpenShift groups to sign in.
52-
""",
53-
)
54-
55-
admin_groups = Set(
56-
config=True,
57-
help="""
58-
Allow members of selected OpenShift groups to sign in and consider them
59-
as JupyterHub admins.
60-
61-
If this is set and a user isn't part of one of these groups or listed in
62-
`admin_users`, a user signing in will have their admin status revoked.
63-
""",
64-
)
65-
6652
openshift_auth_api_url = Unicode(
6753
config=True,
6854
help="""
@@ -158,42 +144,6 @@ def user_info_to_username(self, user_info):
158144
"""
159145
return user_info['metadata']['name']
160146

161-
async def update_auth_model(self, auth_model):
162-
"""
163-
Sets admin status to True or False if `admin_groups` is configured and
164-
the user isn't part of `admin_users`. Note that leaving it at None makes
165-
users able to retain an admin status while setting it to False makes it
166-
be revoked.
167-
"""
168-
if auth_model["admin"]:
169-
# auth_model["admin"] being True means the user was in admin_users
170-
return auth_model
171-
172-
if self.admin_groups:
173-
# admin status should in this case be True or False, not None
174-
user_info = auth_model["auth_state"][self.user_auth_state_key]
175-
user_groups = set(user_info["groups"])
176-
auth_model["admin"] = bool(user_groups & self.admin_groups)
177-
178-
return auth_model
179-
180-
async def check_allowed(self, username, auth_model):
181-
"""
182-
Overrides OAuthenticator.check_allowed to also allow users part of
183-
`allowed_groups`.
184-
"""
185-
if await super().check_allowed(username, auth_model):
186-
return True
187-
188-
if self.allowed_groups:
189-
user_info = auth_model["auth_state"][self.user_auth_state_key]
190-
user_groups = set(user_info["groups"])
191-
if user_groups & self.allowed_groups:
192-
return True
193-
194-
# users should be explicitly allowed via config, otherwise they aren't
195-
return False
196-
197147

198148
class LocalOpenShiftOAuthenticator(LocalAuthenticator, OpenShiftOAuthenticator):
199149
"""A version that mixes in local system user creation"""

oauthenticator/tests/test_auth0.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ def user_model():
2929
return {
3030
"email": "[email protected]",
3131
"name": "user1",
32+
"groups": ["group1"],
3233
}
3334

3435

@@ -62,6 +63,47 @@ def user_model():
6263
True,
6364
True,
6465
),
66+
# common tests with allowed_groups and manage_groups
67+
(
68+
"20",
69+
{
70+
"allowed_groups": {"group1"},
71+
"auth_state_groups_key": "auth0_user.groups",
72+
"manage_groups": True,
73+
},
74+
True,
75+
None,
76+
),
77+
(
78+
"21",
79+
{
80+
"allowed_groups": {"test-user-not-in-group"},
81+
"auth_state_groups_key": "auth0_user.groups",
82+
"manage_groups": True,
83+
},
84+
False,
85+
None,
86+
),
87+
(
88+
"22",
89+
{
90+
"admin_groups": {"group1"},
91+
"auth_state_groups_key": "auth0_user.groups",
92+
"manage_groups": True,
93+
},
94+
True,
95+
True,
96+
),
97+
(
98+
"23",
99+
{
100+
"admin_groups": {"test-user-not-in-group"},
101+
"auth_state_groups_key": "auth0_user.groups",
102+
"manage_groups": True,
103+
},
104+
False,
105+
False,
106+
),
65107
],
66108
)
67109
async def test_auth0(
@@ -84,7 +126,10 @@ async def test_auth0(
84126

85127
if expect_allowed:
86128
assert auth_model
87-
assert set(auth_model) == {"name", "admin", "auth_state"}
129+
if authenticator.manage_groups:
130+
assert set(auth_model) == {"name", "admin", "auth_state", "groups"}
131+
else:
132+
assert set(auth_model) == {"name", "admin", "auth_state"}
88133
assert auth_model["admin"] == expect_admin
89134
auth_state = auth_model["auth_state"]
90135
assert json.dumps(auth_state)

0 commit comments

Comments
 (0)