diff --git a/oauthenticator/generic.py b/oauthenticator/generic.py index 2fd07be7..20282ee9 100644 --- a/oauthenticator/generic.py +++ b/oauthenticator/generic.py @@ -18,48 +18,6 @@ class GenericOAuthenticator(OAuthenticator): def _login_service_default(self): return os.environ.get("LOGIN_SERVICE", "OAuth 2.0") - claim_groups_key = Union( - [Unicode(os.environ.get('OAUTH2_GROUPS_KEY', 'groups')), Callable()], - config=True, - help=""" - Userdata groups claim key from returned json for USERDATA_URL. - - Can be a string key name (use periods for nested keys), or a callable - that accepts the returned json (as a dict) and returns the groups list. - - This configures how group membership in the upstream provider is determined - for use by `allowed_groups`, `admin_groups`, etc. If `manage_groups` is True, - this will also determine users' _JupyterHub_ group membership. - """, - ) - - allowed_groups = Set( - Unicode(), - config=True, - help=""" - Allow members of selected groups to sign in. - - When configuring this you may need to configure `claim_groups_key` as - well as it determines the key in the `userdata_url` response that is - assumed to list the groups a user is a member of. - """, - ) - - admin_groups = Set( - Unicode(), - config=True, - help=""" - Allow members of selected groups to sign in and consider them as - JupyterHub admins. - - If this is set and a user isn't part of one of these groups or listed in - `admin_users`, a user signing in will have their admin status revoked. - - When configuring this you may need to configure `claim_groups_key` as - well as it determines the key in the `userdata_url` response that is - assumed to list the groups a user is a member of. - """, - ) @default("http_client") def _default_http_client(self): @@ -100,72 +58,6 @@ def _default_http_client(self): """, ) - def get_user_groups(self, user_info): - """ - Returns a set of groups the user belongs to based on claim_groups_key - and provided user_info. - - - If claim_groups_key is a callable, it is meant to return the groups - directly. - - If claim_groups_key is a nested dictionary key like - "permissions.groups", this function returns - user_info["permissions"]["groups"]. - - Note that this method is introduced by GenericOAuthenticator and not - present in the base class. - """ - if callable(self.claim_groups_key): - return set(self.claim_groups_key(user_info)) - try: - return set(reduce(dict.get, self.claim_groups_key.split("."), user_info)) - except TypeError: - self.log.error( - f"The claim_groups_key {self.claim_groups_key} does not exist in the user token" - ) - return set() - - async def update_auth_model(self, auth_model): - """ - Sets admin status to True or False if `admin_groups` is configured and - the user isn't part of `admin_users` or `admin_groups`. Note that - leaving it at None makes users able to retain an admin status while - setting it to False makes it be revoked. - - Also populates groups if `manage_groups` is set. - """ - if self.manage_groups or self.admin_groups: - user_info = auth_model["auth_state"][self.user_auth_state_key] - user_groups = self.get_user_groups(user_info) - - if self.manage_groups: - auth_model["groups"] = sorted(user_groups) - - if auth_model["admin"]: - # auth_model["admin"] being True means the user was in admin_users - return auth_model - - if self.admin_groups: - # admin status should in this case be True or False, not None - auth_model["admin"] = bool(user_groups & self.admin_groups) - - return auth_model - - async def check_allowed(self, username, auth_model): - """ - Overrides the OAuthenticator.check_allowed to also allow users part of - `allowed_groups`. - """ - if await super().check_allowed(username, auth_model): - return True - - if self.allowed_groups: - user_info = auth_model["auth_state"][self.user_auth_state_key] - user_groups = self.get_user_groups(user_info) - if any(user_groups & self.allowed_groups): - return True - - # users should be explicitly allowed via config, otherwise they aren't - return False class LocalGenericOAuthenticator(LocalAuthenticator, GenericOAuthenticator): diff --git a/oauthenticator/oauth2.py b/oauthenticator/oauth2.py index 793643a0..5a6f1d46 100644 --- a/oauthenticator/oauth2.py +++ b/oauthenticator/oauth2.py @@ -7,6 +7,7 @@ import base64 import json import os +from functools import reduce import uuid from urllib.parse import quote, urlencode, urlparse, urlunparse @@ -20,7 +21,7 @@ from tornado.httpclient import AsyncHTTPClient, HTTPClientError, HTTPRequest from tornado.httputil import url_concat from tornado.log import app_log -from traitlets import Any, Bool, Callable, Dict, List, Unicode, Union, default, validate +from traitlets import Any, Bool, Callable, Dict, List, Unicode, Union, Set, default, validate def guess_callback_uri(protocol, host, hub_server_url): @@ -264,6 +265,49 @@ class OAuthenticator(Authenticator): """, ) + claim_groups_key = Union( + [Unicode(os.environ.get('OAUTH2_GROUPS_KEY', 'groups')), Callable()], + config=True, + help=""" + Userdata groups claim key from returned json for USERDATA_URL. + + Can be a string key name (use periods for nested keys), or a callable + that accepts the returned json (as a dict) and returns the groups list. + + This configures how group membership in the upstream provider is determined + for use by `allowed_groups`, `admin_groups`, etc. If `manage_groups` is True, + this will also determine users' _JupyterHub_ group membership. + """, + ) + + allowed_groups = Set( + Unicode(), + config=True, + help=""" + Allow members of selected groups to sign in. + + When configuring this you may need to configure `claim_groups_key` as + well as it determines the key in the `userdata_url` response that is + assumed to list the groups a user is a member of. + """, + ) + + admin_groups = Set( + Unicode(), + config=True, + help=""" + Allow members of selected groups to sign in and consider them as + JupyterHub admins. + + If this is set and a user isn't part of one of these groups or listed in + `admin_users`, a user signing in will have their admin status revoked. + + When configuring this you may need to configure `claim_groups_key` as + well as it determines the key in the `userdata_url` response that is + assumed to list the groups a user is a member of. + """, + ) + allow_all = Bool( False, config=True, @@ -995,11 +1039,42 @@ def build_auth_state_dict(self, token_info, user_info): self.user_auth_state_key: user_info, } + def get_user_groups(self, user_info): + """ + Returns a set of groups the user belongs to based on claim_groups_key + and provided user_info. + + - If claim_groups_key is a callable, it is meant to return the groups + directly. + - If claim_groups_key is a nested dictionary key like + "permissions.groups", this function returns + user_info["permissions"]["groups"]. + + Note that this method is introduced by GenericOAuthenticator and not + present in the base class. + """ + if callable(self.claim_groups_key): + return set(self.claim_groups_key(user_info)) + try: + return set(reduce(dict.get, self.claim_groups_key.split("."), user_info)) + except TypeError: + self.log.error( + f"The claim_groups_key {self.claim_groups_key} does not exist in the user token" + ) + return set() + async def update_auth_model(self, auth_model): """ Updates and returns the `auth_model` dict. - Should be overridden to collect information required for check_allowed. + Sets admin status to True or False if `admin_groups` is configured and + the user isn't part of `admin_users` or `admin_groups`. Note that + leaving it at None makes users able to retain an admin status while + setting it to False makes it be revoked. + + Also populates groups if `manage_groups` is set. + + Should be overridden to collect additional information required for check_allowed. Args: auth_model - the auth model dictionary, containing: - `name`: the normalized username @@ -1010,6 +1085,21 @@ async def update_auth_model(self, auth_model): Called by the :meth:`oauthenticator.OAuthenticator.authenticate` """ + if self.manage_groups or self.admin_groups: + user_info = auth_model["auth_state"][self.user_auth_state_key] + user_groups = self.get_user_groups(user_info) + + if self.manage_groups: + auth_model["groups"] = sorted(user_groups) + + if auth_model["admin"]: + # auth_model["admin"] being True means the user was in admin_users + return auth_model + + if self.admin_groups: + # admin status should in this case be True or False, not None + auth_model["admin"] = bool(user_groups & self.admin_groups) + return auth_model async def authenticate(self, handler, data=None, **kwargs): @@ -1087,6 +1177,13 @@ async def check_allowed(self, username, auth_model): if username in self.allowed_users: return True + # Allow users who are part of `allowed_groups` + if self.allowed_groups: + user_info = auth_model["auth_state"][self.user_auth_state_key] + user_groups = self.get_user_groups(user_info) + if any(user_groups & self.allowed_groups): + return True + # users should be explicitly allowed via config, otherwise they aren't return False