From c0f360161eb4cf5ab26ed8d55a71771e82a7c70a Mon Sep 17 00:00:00 2001 From: Min RK Date: Wed, 18 Dec 2019 10:43:10 +0100 Subject: [PATCH 01/19] move authenticate_url, access_token_url to OAuthenticator base class eliminates the need to create LoginHandlers with mixins --- oauthenticator/oauth2.py | 81 +++++++++++++++++++++++++--------------- 1 file changed, 50 insertions(+), 31 deletions(-) diff --git a/oauthenticator/oauth2.py b/oauthenticator/oauth2.py index dc50e10e..515ab3f0 100644 --- a/oauthenticator/oauth2.py +++ b/oauthenticator/oauth2.py @@ -11,6 +11,7 @@ import uuid from tornado import web +from tornado.auth import OAuth2Mixin from tornado.log import app_log from jupyterhub.handlers import BaseHandler @@ -22,23 +23,17 @@ def guess_callback_uri(protocol, host, hub_server_url): return '{proto}://{host}{path}'.format( - proto=protocol, - host=host, - path=url_path_join( - hub_server_url, - 'oauth_callback' - ) + proto=protocol, host=host, path=url_path_join(hub_server_url, 'oauth_callback') ) + STATE_COOKIE_NAME = 'oauthenticator-state' def _serialize_state(state): """Serialize OAuth state to a base64 string after passing through JSON""" json_state = json.dumps(state) - return base64.urlsafe_b64encode( - json_state.encode('utf8') - ).decode('ascii') + return base64.urlsafe_b64encode(json_state.encode('utf8')).decode('ascii') def _deserialize_state(b64_state): @@ -57,18 +52,27 @@ def _deserialize_state(b64_state): return {} -class OAuthLoginHandler(BaseHandler): +class OAuthLoginHandler(OAuth2Mixin, BaseHandler): """Base class for OAuth login handler Typically subclasses will need """ + # these URLs are part of the OAuth2Mixin API + # get them from the Authenticator object + @property + def _OAUTH_AUTHORIZE_URL(self): + return self.authenticator.authorize_url + + @property + def _OAUTH_ACCESS_TOKEN_URL(self): + return self.authenticator.access_token_url + def set_state_cookie(self, state): - self.set_secure_cookie(STATE_COOKIE_NAME, - state, expires_days=1, httponly=True, - ) + self.set_secure_cookie(STATE_COOKIE_NAME, state, expires_days=1, httponly=True) _state = None + def get_state(self): next_url = original_next_url = self.get_argument('next', None) if next_url: @@ -78,21 +82,16 @@ def get_state(self): # force absolute path redirect urlinfo = urlparse(next_url) next_url = urlinfo._replace( - scheme='', - netloc='', - path='/' + urlinfo.path.lstrip('/'), + scheme='', netloc='', path='/' + urlinfo.path.lstrip('/') ).geturl() if next_url != original_next_url: self.log.warning( - "Ignoring next_url %r, using %r", - original_next_url, - next_url, + "Ignoring next_url %r, using %r", original_next_url, next_url ) if self._state is None: - self._state = _serialize_state({ - 'state_id': uuid.uuid4().hex, - 'next_url': next_url, - }) + self._state = _serialize_state( + {'state_id': uuid.uuid4().hex, 'next_url': next_url} + ) return self._state def get(self): @@ -105,7 +104,8 @@ def get(self): client_id=self.authenticator.client_id, scope=self.authenticator.scope, extra_params={'state': state}, - response_type='code') + response_type='code', + ) class OAuthCallbackHandler(BaseHandler): @@ -119,7 +119,9 @@ def get_state_cookie(self): To be compared with the value in redirect URL """ if self._state_cookie is None: - self._state_cookie = (self.get_secure_cookie(STATE_COOKIE_NAME) or b'').decode('utf8', 'replace') + self._state_cookie = ( + self.get_secure_cookie(STATE_COOKIE_NAME) or b'' + ).decode('utf8', 'replace') self.clear_cookie(STATE_COOKIE_NAME) return self._state_cookie @@ -221,11 +223,23 @@ class OAuthenticator(Authenticator): authenticate (method takes one arg - the request handler handling the oauth callback) """ - scope = List(Unicode(), config=True, + authenticate_url = Unicode( + "must-be-set", config=True, help="""The authenticate url for initiating oauth""" + ) + + access_token_url = Unicode( + "must-be-set", + config=True, + help="""The url retrieving an access token at the completion of oauth""", + ) + + scope = List( + Unicode(), + config=True, help="""The OAuth scopes to request. See the OAuth documentation of your OAuth provider for options. For GitHub in particular, you can see github_scopes.md in this repo. - """ + """, ) login_service = 'override in subclass' @@ -233,11 +247,12 @@ class OAuthenticator(Authenticator): os.getenv('OAUTH_CALLBACK_URL', ''), config=True, help="""Callback URL to use. - Typically `https://{host}/hub/oauth_callback`""" + Typically `https://{host}/hub/oauth_callback`""", ) client_id_env = '' client_id = Unicode(config=True) + def _client_id_default(self): if self.client_id_env: client_id = os.getenv(self.client_id_env, '') @@ -247,6 +262,7 @@ def _client_id_default(self): client_secret_env = '' client_secret = Unicode(config=True) + def _client_secret_default(self): if self.client_secret_env: client_secret = os.getenv(self.client_secret_env, '') @@ -256,6 +272,7 @@ def _client_secret_default(self): validate_server_cert_env = 'OAUTH_TLS_VERIFY' validate_server_cert = Bool(config=True) + def _validate_server_cert_default(self): env_value = os.getenv(self.validate_server_cert_env, '') if env_value == '0': @@ -268,7 +285,7 @@ def login_url(self, base_url): login_handler = "Specify login handler class in subclass" callback_handler = OAuthCallbackHandler - + def get_callback_url(self, handler=None): """Get my OAuth redirect URL @@ -280,10 +297,12 @@ def get_callback_url(self, handler=None): return guess_callback_uri( handler.request.protocol, handler.request.host, - handler.hub.server.base_url + handler.hub.server.base_url, ) else: - raise ValueError("Specify callback oauth_callback_url or give me a handler to guess with") + raise ValueError( + "Specify callback oauth_callback_url or give me a handler to guess with" + ) def get_handlers(self, app): return [ From 443d39a484875067f9c9d021af99bbb03176fa41 Mon Sep 17 00:00:00 2001 From: Min RK Date: Wed, 18 Dec 2019 10:49:07 +0100 Subject: [PATCH 02/19] adopt mixin-less setup in github --- oauthenticator/github.py | 142 +++++++++++++++++++++++++-------------- 1 file changed, 91 insertions(+), 51 deletions(-) diff --git a/oauthenticator/github.py b/oauthenticator/github.py index 57236ff4..86416212 100644 --- a/oauthenticator/github.py +++ b/oauthenticator/github.py @@ -11,6 +11,7 @@ import os import re import string +import warnings from tornado.auth import OAuth2Mixin from tornado import web @@ -20,39 +21,18 @@ from jupyterhub.auth import LocalAuthenticator -from traitlets import List, Set, Unicode +from traitlets import List, Set, Unicode, default from .common import next_page_from_links from .oauth2 import OAuthLoginHandler, OAuthenticator -# Support github.com and github enterprise installations -GITHUB_HOST = os.environ.get('GITHUB_HOST') or 'github.com' -if GITHUB_HOST == 'github.com': - GITHUB_API = 'api.github.com' -else: - GITHUB_API = '%s/api/v3' % GITHUB_HOST - -# Support github enterprise installations with both http and https -GITHUB_HTTP = os.environ.get('GITHUB_HTTP') -if GITHUB_HTTP: - GITHUB_PROTOCOL = 'http' -else: - GITHUB_PROTOCOL = 'https' def _api_headers(access_token): - return {"Accept": "application/json", - "User-Agent": "JupyterHub", - "Authorization": "token {}".format(access_token) - } - - -class GitHubMixin(OAuth2Mixin): - _OAUTH_AUTHORIZE_URL = "%s://%s/login/oauth/authorize" % (GITHUB_PROTOCOL, GITHUB_HOST) - _OAUTH_ACCESS_TOKEN_URL = "%s://%s/login/oauth/access_token" % (GITHUB_PROTOCOL, GITHUB_HOST) - - -class GitHubLoginHandler(OAuthLoginHandler, GitHubMixin): - pass + return { + "Accept": "application/json", + "User-Agent": "JupyterHub", + "Authorization": "token {}".format(access_token), + } class GitHubOAuthenticator(OAuthenticator): @@ -63,12 +43,66 @@ class GitHubOAuthenticator(OAuthenticator): login_service = "GitHub" + github_url = Unicode("https://github.com", config=True) + + @default("github_url") + def _github_url_default(self): + github_url = os.environ.get("GITHUB_URL") + if not github_url: + # fallback on older GITHUB_HOST config, + # treated the same as GITHUB_URL + host = os.environ.get("GITHUB_HOST") + if host: + if os.environ.get("GITHUB_HTTP"): + protocol = "http" + warnings.warn( + 'Use of GITHUB_HOST with GITHUB_HTTP might be deprecated in the future. ' + 'Use GITHUB_URL=http://{} to set host and protocol together.'.format( + host + ), + PendingDeprecationWarning, + ) + else: + protocol = "https" + github_url = "{}://{}".format(protocol, host) + + if github_url: + if '://' not in github_url: + # ensure protocol is included, assume https if missing + github_url = 'https://' + github_url + + return github_url + else: + # nothing specified, this is the true default + github_url = "https://github.com" + + # ensure no trailing slash + return github_url.rstrip("/") + + github_api = Unicode("https://api.github.com", config=True) + + @default("github_api") + def _github_api_default(self): + if self.github_url == "https://github.com": + return "https://api.github.com" + else: + return self.github_url + "/api/v3" + + @default("authorize_url") + def _authorize_url_default(self): + return "%s/login/oauth/authorize" % (self.github_url) + + @default("access_token_url") + def _access_token_url_default(self): + return "%s/login/oauth/access_token" % (self.github_url) + # deprecated names github_client_id = Unicode(config=True, help="DEPRECATED") def _github_client_id_changed(self, name, old, new): self.log.warning("github_client_id is deprecated, use client_id") self.client_id = new + github_client_secret = Unicode(config=True, help="DEPRECATED") def _github_client_secret_changed(self, name, old, new): @@ -77,11 +111,9 @@ def _github_client_secret_changed(self, name, old, new): client_id_env = 'GITHUB_CLIENT_ID' client_secret_env = 'GITHUB_CLIENT_SECRET' - login_handler = GitHubLoginHandler github_organization_whitelist = Set( - config=True, - help="Automatically whitelist members of selected organizations", + config=True, help="Automatically whitelist members of selected organizations" ) async def authenticate(self, handler, data=None): @@ -98,19 +130,17 @@ async def authenticate(self, handler, data=None): # GitHub specifies a POST request yet requires URL parameters params = dict( - client_id=self.client_id, - client_secret=self.client_secret, - code=code + client_id=self.client_id, client_secret=self.client_secret, code=code ) - url = url_concat("%s://%s/login/oauth/access_token" % (GITHUB_PROTOCOL, GITHUB_HOST), - params) + url = url_concat(self.access_token_url, params) - req = HTTPRequest(url, - method="POST", - headers={"Accept": "application/json"}, - body='' # Body is required for a POST... - ) + req = HTTPRequest( + url, + method="POST", + headers={"Accept": "application/json"}, + body='', # Body is required for a POST... + ) resp = await http_client.fetch(req) resp_json = json.loads(resp.body.decode('utf8', 'replace')) @@ -118,18 +148,19 @@ async def authenticate(self, handler, data=None): if 'access_token' in resp_json: access_token = resp_json['access_token'] elif 'error_description' in resp_json: - raise HTTPError(403, + raise HTTPError( + 403, "An access token was not returned: {}".format( - resp_json['error_description'])) + resp_json['error_description'] + ), + ) else: - raise HTTPError(500, - "Bad response: %s".format(resp)) + raise HTTPError(500, "Bad response: %s".format(resp)) # Determine who the logged in user is - req = HTTPRequest("%s://%s/user" % (GITHUB_PROTOCOL, GITHUB_API), - method="GET", - headers=_api_headers(access_token) - ) + req = HTTPRequest( + self.github_api + "/user", method="GET", headers=_api_headers(access_token) + ) resp = await http_client.fetch(req) resp_json = json.loads(resp.body.decode('utf8', 'replace')) @@ -141,7 +172,9 @@ async def authenticate(self, handler, data=None): # This check is performed here, as it requires `access_token`. if self.github_organization_whitelist: for org in self.github_organization_whitelist: - user_in_org = await self._check_organization_whitelist(org, username, access_token) + user_in_org = await self._check_organization_whitelist( + org, username, access_token + ) if user_in_org: break else: # User not found in member list for any organisation @@ -171,9 +204,15 @@ async def _check_organization_whitelist(self, org, username, access_token): # With empty scope (even if authenticated by an org member), this # will only await public org members. You want 'read:org' in order # to be able to iterate through all members. - check_membership_url = "%s://%s/orgs/%s/members/%s" % (GITHUB_PROTOCOL, GITHUB_API, org, username) + check_membership_url = "%s/orgs/%s/members/%s" % ( + self.github_api, + org, + username, + ) req = HTTPRequest(check_membership_url, method="GET", headers=headers) - self.log.debug("Checking GitHub organization membership: %s in %s?", username, org) + self.log.debug( + "Checking GitHub organization membership: %s in %s?", username, org + ) resp = await http_client.fetch(req, raise_error=False) print(resp) if resp.code == 204: @@ -198,4 +237,5 @@ async def _check_organization_whitelist(self, org, username, access_token): class LocalGitHubOAuthenticator(LocalAuthenticator, GitHubOAuthenticator): """A version that mixes in local system user creation""" + pass From 4e766dc492a6e97ebe89d6e8541c849f9142bf7c Mon Sep 17 00:00:00 2001 From: Min RK Date: Wed, 18 Dec 2019 10:59:54 +0100 Subject: [PATCH 03/19] adopt mixin-less setup in gitlab --- oauthenticator/gitlab.py | 174 ++++++++++++++++------------ oauthenticator/tests/test_gitlab.py | 4 +- 2 files changed, 103 insertions(+), 75 deletions(-) diff --git a/oauthenticator/gitlab.py b/oauthenticator/gitlab.py index 726c2d22..a6d3e04c 100644 --- a/oauthenticator/gitlab.py +++ b/oauthenticator/gitlab.py @@ -21,51 +21,17 @@ from jupyterhub.auth import LocalAuthenticator -from traitlets import Set +from traitlets import Set, CUnicode, Unicode, default from .oauth2 import OAuthLoginHandler, OAuthenticator -GITLAB_URL = os.getenv('GITLAB_URL') -GITLAB_HOST = os.getenv('GITLAB_HOST') - -if not GITLAB_URL and GITLAB_HOST: - warnings.warn('Use of GITLAB_HOST might be deprecated in the future. ' - 'Rename GITLAB_HOST environemnt variable to GITLAB_URL.', - PendingDeprecationWarning) - if GITLAB_HOST.startswith('https://') or GITLAB_HOST.startswith('http://'): - GITLAB_URL = GITLAB_HOST - else: - # Hides common mistake of users which set the GITLAB_HOST - # without a protocol specification. - GITLAB_URL = 'https://{0}'.format(GITLAB_HOST) - warnings.warn('The https:// prefix has been added to GITLAB_HOST.' - 'Set GITLAB_URL="{0}" instead.'.format(GITLAB_URL)) - -# Support gitlab.com and gitlab community edition installations -if not GITLAB_URL: - GITLAB_URL = 'https://gitlab.com' - -# Use only GITLAB_URL in the code bellow. -del GITLAB_HOST - -GITLAB_API_VERSION = os.environ.get('GITLAB_API_VERSION') or '4' -GITLAB_API = '%s/api/v%s' % (GITLAB_URL, GITLAB_API_VERSION) - def _api_headers(access_token): - return {"Accept": "application/json", - "User-Agent": "JupyterHub", - "Authorization": "Bearer {}".format(access_token) - } - - -class GitLabMixin(OAuth2Mixin): - _OAUTH_AUTHORIZE_URL = "%s/oauth/authorize" % GITLAB_URL - _OAUTH_ACCESS_TOKEN_URL = "%s/oauth/access_token" % GITLAB_URL - - -class GitLabLoginHandler(OAuthLoginHandler, GitLabMixin): - pass + return { + "Accept": "application/json", + "User-Agent": "JupyterHub", + "Authorization": "Bearer {}".format(access_token), + } class GitLabOAuthenticator(OAuthenticator): @@ -77,11 +43,60 @@ class GitLabOAuthenticator(OAuthenticator): client_id_env = 'GITLAB_CLIENT_ID' client_secret_env = 'GITLAB_CLIENT_SECRET' - login_handler = GitLabLoginHandler + + gitlab_url = Unicode("https://gitlab.com", config=True) + + @default("gitlab_url") + def _default_gitlab_url(self): + """get default gitlab url from env""" + gitlab_url = os.getenv('GITLAB_URL') + gitlab_host = os.getenv('GITLAB_HOST') + + if not gitlab_url and gitlab_host: + warnings.warn( + 'Use of GITLAB_HOST might be deprecated in the future. ' + 'Rename GITLAB_HOST environment variable to GITLAB_URL.', + PendingDeprecationWarning, + ) + if gitlab_host.startswith(('https:', 'http:')): + gitlab_url = gitlab_host + else: + # Hides common mistake of users which set the GITLAB_HOST + # without a protocol specification. + gitlab_url = 'https://{0}'.format(gitlab_host) + warnings.warn( + 'The https:// prefix has been added to GITLAB_HOST.' + 'Set GITLAB_URL="{0}" instead.'.format(gitlab_host) + ) + + # default to gitlab.com + if not gitlab_url: + gitlab_url = 'https://gitlab.com' + + return gitlab_url + + gitlab_api_version = CUnicode('4', config=True) + + @default('gitlab_api_version') + def _gitlab_api_version_default(self): + return os.environ.get('GITLAB_API_VERSION') or '4' + + gitlab_api = Unicode(config=True) + + @default("gitlab_api") + def _default_gitlab_api(self): + return '%s/api/v%s' % (self.gitlab_url, self.gitlab_api_version) + + @default("authorize_url") + def _authorize_url_default(self): + return "%s/oauth/authorize" % self.gitlab_url + + @default("access_token_url") + def _access_token_url_default(self): + return "%s/oauth/access_token" % self.gitlab_url gitlab_group_whitelist = Set( - config=True, - help="Automatically whitelist members of selected groups", + config=True, help="Automatically whitelist members of selected groups" ) gitlab_project_id_whitelist = Set( config=True, @@ -110,15 +125,15 @@ async def authenticate(self, handler, data=None): validate_server_cert = self.validate_server_cert - url = url_concat("%s/oauth/token" % GITLAB_URL, - params) + url = url_concat("%s/oauth/token" % self.gitlab_url, params) - req = HTTPRequest(url, - method="POST", - headers={"Accept": "application/json"}, - validate_cert=validate_server_cert, - body='' # Body is required for a POST... - ) + req = HTTPRequest( + url, + method="POST", + headers={"Accept": "application/json"}, + validate_cert=validate_server_cert, + body='', # Body is required for a POST... + ) resp = await http_client.fetch(req) resp_json = json.loads(resp.body.decode('utf8', 'replace')) @@ -131,11 +146,12 @@ async def authenticate(self, handler, data=None): self.member_api_variant = 'all/' if self.gitlab_version >= [12, 4] else '' # Determine who the logged in user is - req = HTTPRequest("%s/user" % GITLAB_API, - method="GET", - validate_cert=validate_server_cert, - headers=_api_headers(access_token) - ) + req = HTTPRequest( + "%s/user" % self.gitlab_api, + method="GET", + validate_cert=validate_server_cert, + headers=_api_headers(access_token), + ) resp = await http_client.fetch(req) resp_json = json.loads(resp.body.decode('utf8', 'replace')) @@ -155,56 +171,67 @@ async def authenticate(self, handler, data=None): # We skip project_id check if user is in whitelisted group. if self.gitlab_project_id_whitelist and not user_in_group: is_project_id_specified = True - user_in_project = await self._check_project_id_whitelist(user_id, access_token) + user_in_project = await self._check_project_id_whitelist( + user_id, access_token + ) no_config_specified = not (is_group_specified or is_project_id_specified) - if (is_group_specified and user_in_group) or \ - (is_project_id_specified and user_in_project) or \ - no_config_specified: + if ( + (is_group_specified and user_in_group) + or (is_project_id_specified and user_in_project) + or no_config_specified + ): return { 'name': username, - 'auth_state': { - 'access_token': access_token, - 'gitlab_user': resp_json, - } + 'auth_state': {'access_token': access_token, 'gitlab_user': resp_json}, } else: self.log.warning("%s not in group or project whitelist", username) return None async def _get_gitlab_version(self, access_token): - url = '%s/version' % GITLAB_API - req = HTTPRequest(url, - method="GET", - headers=_api_headers(access_token), - validate_cert=self.validate_server_cert) + url = '%s/version' % self.gitlab_api + req = HTTPRequest( + url, + method="GET", + headers=_api_headers(access_token), + validate_cert=self.validate_server_cert, + ) resp = await AsyncHTTPClient().fetch(req, raise_error=True) resp_json = json.loads(resp.body.decode('utf8', 'replace')) version_strings = resp_json['version'].split('-')[0].split('.')[:3] version_ints = list(map(int, version_strings)) return version_ints - async def _check_group_whitelist(self, user_id, access_token): http_client = AsyncHTTPClient() headers = _api_headers(access_token) # Check if user is a member of any group in the whitelist for group in map(url_escape, self.gitlab_group_whitelist): - url = "%s/groups/%s/members/%s%d" % (GITLAB_API, group, self.member_api_variant, user_id) + url = "%s/groups/%s/members/%s%d" % ( + self.gitlab_api, + group, + self.member_api_variant, + user_id, + ) req = HTTPRequest(url, method="GET", headers=headers) resp = await http_client.fetch(req, raise_error=False) if resp.code == 200: return True # user _is_ in group return False - async def _check_project_id_whitelist(self, user_id, access_token): http_client = AsyncHTTPClient() headers = _api_headers(access_token) # Check if user has developer access to any project in the whitelist for project in self.gitlab_project_id_whitelist: - url = "%s/projects/%s/members/%s%d" % (GITLAB_API, project, self.member_api_variant, user_id) + url = "%s/projects/%s/members/%s%d" % ( + self.gitlab_api, + project, + self.member_api_variant, + user_id, + ) req = HTTPRequest(url, method="GET", headers=headers) resp = await http_client.fetch(req, raise_error=False) @@ -222,4 +249,5 @@ async def _check_project_id_whitelist(self, user_id, access_token): class LocalGitLabOAuthenticator(LocalAuthenticator, GitLabOAuthenticator): """A version that mixes in local system user creation""" + pass diff --git a/oauthenticator/tests/test_gitlab.py b/oauthenticator/tests/test_gitlab.py index 03cc94c5..635c98d2 100644 --- a/oauthenticator/tests/test_gitlab.py +++ b/oauthenticator/tests/test_gitlab.py @@ -9,11 +9,11 @@ from tornado.httputil import HTTPHeaders from pytest import fixture, mark -from ..gitlab import GitLabOAuthenticator, GITLAB_API_VERSION +from ..gitlab import GitLabOAuthenticator from .mocks import setup_oauth_mock -API_ENDPOINT = '/api/v%s' % (GITLAB_API_VERSION,) +API_ENDPOINT = '/api/v%s' % (GitLabOAuthenticator().gitlab_api_version) def user_model(username, id=1, is_admin=False): From b219f96af11d7eb9333db4d122f887333f37d030 Mon Sep 17 00:00:00 2001 From: Min RK Date: Wed, 18 Dec 2019 11:48:43 +0100 Subject: [PATCH 04/19] adopt mixin-less setup in google --- oauthenticator/google.py | 92 ++++++++++++++++------------- oauthenticator/tests/test_google.py | 23 ++------ 2 files changed, 55 insertions(+), 60 deletions(-) diff --git a/oauthenticator/google.py b/oauthenticator/google.py index 53c1a92e..cbf803b8 100644 --- a/oauthenticator/google.py +++ b/oauthenticator/google.py @@ -6,8 +6,10 @@ import os import json +import urllib.parse from tornado import gen +from tornado.httpclient import AsyncHTTPClient from tornado.auth import GoogleOAuth2Mixin from tornado.web import HTTPError @@ -19,31 +21,27 @@ from .oauth2 import OAuthLoginHandler, OAuthCallbackHandler, OAuthenticator -class GoogleLoginHandler(OAuthLoginHandler, GoogleOAuth2Mixin): - '''An OAuthLoginHandler that provides scope to GoogleOAuth2Mixin's - authorize_redirect.''' - @property - def scope(self): - return self.authenticator.scope - - -class GoogleOAuthHandler(OAuthCallbackHandler, GoogleOAuth2Mixin): - pass - - class GoogleOAuthenticator(OAuthenticator, GoogleOAuth2Mixin): - - login_handler = GoogleLoginHandler - callback_handler = GoogleOAuthHandler - @default('scope') def _scope_default(self): return ['openid', 'email'] + @default("authorize_url") + def _authorize_url_default(self): + return "https://accounts.google.com/o/oauth2/v2/auth" + + @default("access_token_url") + def _access_token_url_default(self): + return "https://www.googleapis.com/oauth2/v4/token" + + user_info_url = Unicode( + "https://www.googleapis.com/oauth2/v1/userinfo", config=True + ) + hosted_domain = List( Unicode(), config=True, - help="""List of domains used to restrict sign-in, e.g. mycollege.edu""" + help="""List of domains used to restrict sign-in, e.g. mycollege.edu""", ) @default('hosted_domain') @@ -71,25 +69,35 @@ def _cast_hosted_domain(self, proposal): login_service = Unicode( os.environ.get('LOGIN_SERVICE', 'Google'), config=True, - help="""Google Apps hosted domain string, e.g. My College""" + help="""Google Apps hosted domain string, e.g. My College""", ) async def authenticate(self, handler, data=None): code = handler.get_argument("code") - handler.settings['google_oauth'] = { - 'key': self.client_id, - 'secret': self.client_secret, - 'scope': self.scope, - } - user = await handler.get_authenticated_user( - redirect_uri=self.get_callback_url(handler), - code=code) - access_token = str(user['access_token']) + body = urllib.parse.urlencode( + dict( + code=code, + redirect_uri=self.get_callback_url(handler), + client_id=self.client_id, + client_secret=self.client_secret, + grant_type="authorization_code", + ) + ) + + http_client = AsyncHTTPClient() + + response = await http_client.fetch( + self.access_token_url, + method="POST", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + body=body, + ) - http_client = handler.get_auth_http_client() + user = json.loads(response.body.decode("utf-8", "replace")) + access_token = str(user['access_token']) response = await http_client.fetch( - self._OAUTH_USERINFO_URL + '?access_token=' + access_token + self.user_info_url + '?access_token=' + access_token ) if not response: @@ -102,18 +110,21 @@ async def authenticate(self, handler, data=None): if not bodyjs['verified_email']: self.log.warning("Google OAuth unverified email attempt: %s", user_email) - raise HTTPError(403, - "Google email {} not verified".format(user_email) - ) + raise HTTPError(403, "Google email {} not verified".format(user_email)) if self.hosted_domain: if ( - user_email_domain not in self.hosted_domain or - bodyjs['hd'] not in self.hosted_domain + user_email_domain not in self.hosted_domain + or bodyjs['hd'] not in self.hosted_domain ): - self.log.warning("Google OAuth unauthorized domain attempt: %s", user_email) - raise HTTPError(403, - "Google account domain @{} not authorized.".format(user_email_domain) + self.log.warning( + "Google OAuth unauthorized domain attempt: %s", user_email + ) + raise HTTPError( + 403, + "Google account domain @{} not authorized.".format( + user_email_domain + ), ) if len(self.hosted_domain) == 1: # unambiguous domain, use only base name @@ -121,12 +132,11 @@ async def authenticate(self, handler, data=None): return { 'name': username, - 'auth_state': { - 'access_token': access_token, - 'google_user': bodyjs, - } + 'auth_state': {'access_token': access_token, 'google_user': bodyjs}, } + class LocalGoogleOAuthenticator(LocalAuthenticator, GoogleOAuthenticator): """A version that mixes in local system user creation""" + pass diff --git a/oauthenticator/tests/test_google.py b/oauthenticator/tests/test_google.py index 8e6bccc4..503d6934 100644 --- a/oauthenticator/tests/test_google.py +++ b/oauthenticator/tests/test_google.py @@ -4,39 +4,24 @@ from pytest import fixture, mark, raises from tornado.web import Application, HTTPError -from ..google import GoogleOAuthenticator, GoogleOAuthHandler +from ..google import GoogleOAuthenticator from .mocks import setup_oauth_mock def user_model(email): """Return a user model""" - return { - 'email': email, - 'hd': email.split('@')[1], - 'verified_email': True - } + return {'email': email, 'hd': email.split('@')[1], 'verified_email': True} @fixture def google_client(client): - setup_oauth_mock(client, + setup_oauth_mock( + client, host=['accounts.google.com', 'www.googleapis.com'], access_token_path=re.compile('^(/o/oauth2/token|/oauth2/v4/token)$'), user_path='/oauth2/v1/userinfo', ) - original_handler_for_user = client.handler_for_user - # testing Google is harder because it invokes methods inherited from tornado - # classes - def handler_for_user(user): - mock_handler = original_handler_for_user(user) - mock_handler.request.connection = Mock() - real_handler = GoogleOAuthHandler( - application=Application(hub=mock_handler.hub), - request=mock_handler.request, - ) - return real_handler - client.handler_for_user = handler_for_user return client From ac712301aa2d79c96c1676191c4d315c747c1a24 Mon Sep 17 00:00:00 2001 From: Min RK Date: Wed, 18 Dec 2019 11:57:32 +0100 Subject: [PATCH 05/19] adopt mixin-less setup in bitbucket --- oauthenticator/bitbucket.py | 91 +++++++++++++++++-------------------- 1 file changed, 42 insertions(+), 49 deletions(-) diff --git a/oauthenticator/bitbucket.py b/oauthenticator/bitbucket.py index 7c3dd870..cc7257b3 100644 --- a/oauthenticator/bitbucket.py +++ b/oauthenticator/bitbucket.py @@ -13,25 +13,17 @@ from jupyterhub.auth import LocalAuthenticator -from traitlets import Set +from traitlets import Set, default from .oauth2 import OAuthLoginHandler, OAuthenticator def _api_headers(access_token): - return {"Accept": "application/json", - "User-Agent": "JupyterHub", - "Authorization": "Bearer {}".format(access_token) - } - - -class BitbucketMixin(OAuth2Mixin): - _OAUTH_AUTHORIZE_URL = "https://bitbucket.org/site/oauth2/authorize" - _OAUTH_ACCESS_TOKEN_URL = "https://bitbucket.org/site/oauth2/access_token" - - -class BitbucketLoginHandler(OAuthLoginHandler, BitbucketMixin): - pass + return { + "Accept": "application/json", + "User-Agent": "JupyterHub", + "Authorization": "Bearer {}".format(access_token), + } class BitbucketOAuthenticator(OAuthenticator): @@ -39,20 +31,26 @@ class BitbucketOAuthenticator(OAuthenticator): login_service = "Bitbucket" client_id_env = 'BITBUCKET_CLIENT_ID' client_secret_env = 'BITBUCKET_CLIENT_SECRET' - login_handler = BitbucketLoginHandler + + @default("authorize_url") + def _authorize_url_default(self): + return "https://bitbucket.org/site/oauth2/authorize" + + @default("access_token_url") + def _access_token_url_default(self): + return "https://bitbucket.org/site/oauth2/access_token" team_whitelist = Set( - config=True, - help="Automatically whitelist members of selected teams", + config=True, help="Automatically whitelist members of selected teams" ) bitbucket_team_whitelist = team_whitelist - - headers = {"Accept": "application/json", - "User-Agent": "JupyterHub", - "Authorization": "Bearer {}" - } + headers = { + "Accept": "application/json", + "User-Agent": "JupyterHub", + "Authorization": "Bearer {}", + } async def authenticate(self, handler, data=None): code = handler.get_argument("code") @@ -67,31 +65,29 @@ async def authenticate(self, handler, data=None): redirect_uri=self.get_callback_url(handler), ) - url = url_concat( - "https://bitbucket.org/site/oauth2/access_token", params) - self.log.info(url) + url = url_concat("https://bitbucket.org/site/oauth2/access_token", params) - bb_header = {"Content-Type": - "application/x-www-form-urlencoded;charset=utf-8"} - req = HTTPRequest(url, - method="POST", - auth_username=self.client_id, - auth_password=self.client_secret, - body=urllib.parse.urlencode(params).encode('utf-8'), - headers=bb_header - ) + bb_header = {"Content-Type": "application/x-www-form-urlencoded;charset=utf-8"} + req = HTTPRequest( + url, + method="POST", + auth_username=self.client_id, + auth_password=self.client_secret, + body=urllib.parse.urlencode(params).encode('utf-8'), + headers=bb_header, + ) resp = await http_client.fetch(req) resp_json = json.loads(resp.body.decode('utf8', 'replace')) access_token = resp_json['access_token'] - # Determine who the logged in user is - req = HTTPRequest("https://api.bitbucket.org/2.0/user", - method="GET", - headers=_api_headers(access_token) - ) + req = HTTPRequest( + "https://api.bitbucket.org/2.0/user", + method="GET", + headers=_api_headers(access_token), + ) resp = await http_client.fetch(req) resp_json = json.loads(resp.body.decode('utf8', 'replace')) @@ -107,10 +103,7 @@ async def authenticate(self, handler, data=None): return { 'name': username, - 'auth_state': { - 'access_token': access_token, - 'bitbucket_user': resp_json, - } + 'auth_state': {'access_token': access_token, 'bitbucket_user': resp_json}, } async def _check_team_whitelist(self, username, access_token): @@ -118,23 +111,23 @@ async def _check_team_whitelist(self, username, access_token): headers = _api_headers(access_token) # We verify the team membership by calling teams endpoint. - next_page = url_concat("https://api.bitbucket.org/2.0/teams", - {'role': 'member'}) + next_page = url_concat( + "https://api.bitbucket.org/2.0/teams", {'role': 'member'} + ) while next_page: req = HTTPRequest(next_page, method="GET", headers=headers) resp = await http_client.fetch(req) resp_json = json.loads(resp.body.decode('utf8', 'replace')) next_page = resp_json.get('next', None) - user_teams = \ - set([entry["username"] for entry in resp_json["values"]]) + user_teams = set([entry["username"] for entry in resp_json["values"]]) # check if any of the organizations seen thus far are in whitelist if len(self.bitbucket_team_whitelist & user_teams) > 0: return True return False -class LocalBitbucketOAuthenticator(LocalAuthenticator, - BitbucketOAuthenticator): +class LocalBitbucketOAuthenticator(LocalAuthenticator, BitbucketOAuthenticator): """A version that mixes in local system user creation""" + pass From 1be7e507abaa6c0eef80ad4bd247176c4191735f Mon Sep 17 00:00:00 2001 From: Min RK Date: Wed, 18 Dec 2019 12:56:40 +0100 Subject: [PATCH 06/19] adopt mixin-less in auth0 --- oauthenticator/auth0.py | 69 +++++++++++++++++------------- oauthenticator/tests/test_auth0.py | 19 ++++---- 2 files changed, 48 insertions(+), 40 deletions(-) diff --git a/oauthenticator/auth0.py b/oauthenticator/auth0.py index 37b822bc..c0bac25f 100644 --- a/oauthenticator/auth0.py +++ b/oauthenticator/auth0.py @@ -36,26 +36,35 @@ from tornado import web from tornado.httpclient import HTTPRequest, AsyncHTTPClient +from traitlets import Unicode, default + from jupyterhub.auth import LocalAuthenticator from .oauth2 import OAuthLoginHandler, OAuthenticator -AUTH0_SUBDOMAIN = os.getenv('AUTH0_SUBDOMAIN') -class Auth0Mixin(OAuth2Mixin): - _OAUTH_AUTHORIZE_URL = "https://%s.auth0.com/authorize" % AUTH0_SUBDOMAIN - _OAUTH_ACCESS_TOKEN_URL = "https://%s.auth0.com/oauth/token" % AUTH0_SUBDOMAIN +class Auth0OAuthenticator(OAuthenticator): + login_service = "Auth0" -class Auth0LoginHandler(OAuthLoginHandler, Auth0Mixin): - pass + auth0_subdomain = Unicode(config=True) + @default("auth0_subdomain") + def _auth0_subdomain_default(self): + subdomain = os.getenv("AUTH0_SUBDOMAIN") + if not subdomain: + raise ValueError( + "Please specify $AUTH0_SUBDOMAIN env or %s.auth0_subdomain config" + % self.__class__.__name__ + ) -class Auth0OAuthenticator(OAuthenticator): + @default("authorize_url") + def _authorize_url_default(self): + return "https://%s.auth0.com/authorize" % self.auth0_subdomain - login_service = "Auth0" - - login_handler = Auth0LoginHandler + @default("access_token_url") + def _access_token_url_default(self): + return "https://%s.auth0.com/oauth/token" % self.auth0_subdomain async def authenticate(self, handler, data=None): code = handler.get_argument("code") @@ -66,16 +75,17 @@ async def authenticate(self, handler, data=None): 'grant_type': 'authorization_code', 'client_id': self.client_id, 'client_secret': self.client_secret, - 'code':code, - 'redirect_uri': self.get_callback_url(handler) + 'code': code, + 'redirect_uri': self.get_callback_url(handler), } - url = "https://%s.auth0.com/oauth/token" % AUTH0_SUBDOMAIN + url = self.access_token_url - req = HTTPRequest(url, - method="POST", - headers={"Content-Type": "application/json"}, - body=json.dumps(params) - ) + req = HTTPRequest( + url, + method="POST", + headers={"Content-Type": "application/json"}, + body=json.dumps(params), + ) resp = await http_client.fetch(req) resp_json = json.loads(resp.body.decode('utf8', 'replace')) @@ -83,28 +93,27 @@ async def authenticate(self, handler, data=None): access_token = resp_json['access_token'] # Determine who the logged in user is - headers={"Accept": "application/json", - "User-Agent": "JupyterHub", - "Authorization": "Bearer {}".format(access_token) + headers = { + "Accept": "application/json", + "User-Agent": "JupyterHub", + "Authorization": "Bearer {}".format(access_token), } - req = HTTPRequest("https://%s.auth0.com/userinfo" % AUTH0_SUBDOMAIN, - method="GET", - headers=headers - ) + req = HTTPRequest( + "https://%s.auth0.com/userinfo" % self.auth0_subdomain, + method="GET", + headers=headers, + ) resp = await http_client.fetch(req) resp_json = json.loads(resp.body.decode('utf8', 'replace')) return { 'name': resp_json["email"], - 'auth_state': { - 'access_token': access_token, - 'auth0_user': resp_json, - } + 'auth_state': {'access_token': access_token, 'auth0_user': resp_json}, } class LocalAuth0OAuthenticator(LocalAuthenticator, Auth0OAuthenticator): """A version that mixes in local system user creation""" - pass + pass diff --git a/oauthenticator/tests/test_auth0.py b/oauthenticator/tests/test_auth0.py index e0d026f7..1577cf56 100644 --- a/oauthenticator/tests/test_auth0.py +++ b/oauthenticator/tests/test_auth0.py @@ -3,22 +3,22 @@ from pytest import fixture, mark -with patch.dict(os.environ, AUTH0_SUBDOMAIN='jupyterhub-test'): - from ..auth0 import Auth0OAuthenticator, AUTH0_SUBDOMAIN - +from ..auth0 import Auth0OAuthenticator from .mocks import setup_oauth_mock +auth0_subdomain = "jupyterhub-test" + def user_model(username): """Return a user model""" - return { - 'email': username, - } + return {'email': username} + @fixture def auth0_client(client): - setup_oauth_mock(client, - host='%s.auth0.com' % AUTH0_SUBDOMAIN, + setup_oauth_mock( + client, + host='%s.auth0.com' % auth0_subdomain, access_token_path='/oauth/token', user_path='/userinfo', token_request_style='json', @@ -27,7 +27,7 @@ def auth0_client(client): async def test_auth0(auth0_client): - authenticator = Auth0OAuthenticator() + authenticator = Auth0OAuthenticator(auth0_subdomain=auth0_subdomain) handler = auth0_client.handler_for_user(user_model('kaylee@serenity.now')) user_info = await authenticator.authenticate(handler) assert sorted(user_info) == ['auth_state', 'name'] @@ -36,4 +36,3 @@ async def test_auth0(auth0_client): auth_state = user_info['auth_state'] assert 'access_token' in auth_state assert 'auth0_user' in auth_state - From 982eaf1f917022b71ae4c4756d5ce3f908785697 Mon Sep 17 00:00:00 2001 From: Min RK Date: Wed, 18 Dec 2019 13:18:07 +0100 Subject: [PATCH 07/19] promote generic userdata_url to base class --- oauthenticator/generic.py | 107 +++++++++++++++----------------------- oauthenticator/oauth2.py | 25 +++++++-- 2 files changed, 64 insertions(+), 68 deletions(-) diff --git a/oauthenticator/generic.py b/oauthenticator/generic.py index fcb34310..0118cc4d 100644 --- a/oauthenticator/generic.py +++ b/oauthenticator/generic.py @@ -16,49 +16,29 @@ from jupyterhub.auth import LocalAuthenticator -from traitlets import Unicode, Dict, Bool, Union +from traitlets import Unicode, Dict, Bool, Union, default, observe from .traitlets import Callable from .oauth2 import OAuthLoginHandler, OAuthenticator -class GenericEnvMixin(OAuth2Mixin): - _OAUTH_ACCESS_TOKEN_URL = os.environ.get('OAUTH2_TOKEN_URL', '') - _OAUTH_AUTHORIZE_URL = os.environ.get('OAUTH2_AUTHORIZE_URL', '') - - -class GenericLoginHandler(OAuthLoginHandler, GenericEnvMixin): - pass - - class GenericOAuthenticator(OAuthenticator): - login_service = Unicode( - "GenericOAuth2", - config=True - ) + login_service = Unicode("OAuth 2.0", config=True) - login_handler = GenericLoginHandler + token_url = Unicode(config=True, help="DEPRECATED. Use access_token_url") - userdata_url = Unicode( - os.environ.get('OAUTH2_USERDATA_URL', ''), - config=True, - help="Userdata url to get user data login information" - ) - token_url = Unicode( - os.environ.get('OAUTH2_TOKEN_URL', ''), - config=True, - help="Access token endpoint URL" - ) - extra_params = Dict( - help="Extra parameters for first POST request" - ).tag(config=True) + @observe("token_url") + def _token_url_changed(self, change): + self.log.warning( + "GenericOAuthenticator.token_url is deprecated. Use OAuthenticator.access_token_url" + ) + self.access_token_url = change.new + + extra_params = Dict(help="Extra parameters for first POST request").tag(config=True) username_key = Union( - [ - Unicode(os.environ.get('OAUTH2_USERNAME_KEY', 'username')), - Callable() - ], + [Unicode(os.environ.get('OAUTH2_USERNAME_KEY', 'username')), Callable()], config=True, help=""" Userdata username key from returned json for USERDATA_URL. @@ -67,7 +47,7 @@ class GenericOAuthenticator(OAuthenticator): json (as a dict) and returns the username. The callable is useful e.g. for extracting the username from a nested object in the response. - """ + """, ) userdata_params = Dict( @@ -77,24 +57,24 @@ class GenericOAuthenticator(OAuthenticator): userdata_method = Unicode( os.environ.get('OAUTH2_USERDATA_METHOD', 'GET'), config=True, - help="Userdata method to get user data login information" + help="Userdata method to get user data login information", ) userdata_token_method = Unicode( os.environ.get('OAUTH2_USERDATA_REQUEST_TYPE', 'header'), config=True, - help="Method for sending access token in userdata request. Supported methods: header, url. Default: header" + help="Method for sending access token in userdata request. Supported methods: header, url. Default: header", ) tls_verify = Bool( os.environ.get('OAUTH2_TLS_VERIFY', 'True').lower() in {'true', '1'}, config=True, - help="Disable TLS verification on http request" + help="Disable TLS verification on http request", ) basic_auth = Bool( os.environ.get('OAUTH2_BASIC_AUTH', 'True').lower() in {'true', '1'}, config=True, - help="Disable basic authentication for access token request" + help="Disable basic authentication for access token request", ) async def authenticate(self, handler, data=None): @@ -105,35 +85,30 @@ async def authenticate(self, handler, data=None): params = dict( redirect_uri=self.get_callback_url(handler), code=code, - grant_type='authorization_code' + grant_type='authorization_code', ) params.update(self.extra_params) - if self.token_url: - url = self.token_url + if self.access_token_url: + url = self.access_token_url else: - raise ValueError("Please set the OAUTH2_TOKEN_URL environment variable") + raise ValueError("Please set the $OAUTH2_TOKEN_URL environment variable") - headers = { - "Accept": "application/json", - "User-Agent": "JupyterHub" - } + headers = {"Accept": "application/json", "User-Agent": "JupyterHub"} if self.basic_auth: b64key = base64.b64encode( - bytes( - "{}:{}".format(self.client_id, self.client_secret), - "utf8" - ) + bytes("{}:{}".format(self.client_id, self.client_secret), "utf8") ) headers.update({"Authorization": "Basic {}".format(b64key.decode("utf8"))}) - req = HTTPRequest(url, - method="POST", - headers=headers, - validate_cert=self.tls_verify, - body=urllib.parse.urlencode(params) # Body is required for a POST... - ) + req = HTTPRequest( + url, + method="POST", + headers=headers, + validate_cert=self.tls_verify, + body=urllib.parse.urlencode(params), + ) resp = await http_client.fetch(req) @@ -143,14 +118,14 @@ async def authenticate(self, handler, data=None): refresh_token = resp_json.get('refresh_token', None) token_type = resp_json['token_type'] scope = resp_json.get('scope', '') - if (isinstance(scope, str)): + if isinstance(scope, str): scope = scope.split(' ') # Determine who the logged in user is headers = { "Accept": "application/json", "User-Agent": "JupyterHub", - "Authorization": "{} {}".format(token_type, access_token) + "Authorization": "{} {}".format(token_type, access_token), } if self.userdata_url: url = url_concat(self.userdata_url, self.userdata_params) @@ -160,11 +135,12 @@ async def authenticate(self, handler, data=None): if self.userdata_token_method == "url": url = url_concat(self.userdata_url, dict(access_token=access_token)) - req = HTTPRequest(url, - method=self.userdata_method, - headers=headers, - validate_cert=self.tls_verify, - ) + req = HTTPRequest( + url, + method=self.userdata_method, + headers=headers, + validate_cert=self.tls_verify, + ) resp = await http_client.fetch(req) resp_json = json.loads(resp.body.decode('utf8', 'replace')) @@ -173,7 +149,9 @@ async def authenticate(self, handler, data=None): else: name = resp_json.get(self.username_key) if not name: - self.log.error("OAuth user contains no key %s: %s", self.username_key, resp_json) + self.log.error( + "OAuth user contains no key %s: %s", self.username_key, resp_json + ) return return { @@ -183,11 +161,12 @@ async def authenticate(self, handler, data=None): 'refresh_token': refresh_token, 'oauth_user': resp_json, 'scope': scope, - } + }, } class LocalGenericOAuthenticator(LocalAuthenticator, GenericOAuthenticator): """A version that mixes in local system user creation""" + pass diff --git a/oauthenticator/oauth2.py b/oauthenticator/oauth2.py index 515ab3f0..094a81da 100644 --- a/oauthenticator/oauth2.py +++ b/oauthenticator/oauth2.py @@ -18,7 +18,7 @@ from jupyterhub.auth import Authenticator from jupyterhub.utils import url_path_join -from traitlets import Unicode, Bool, List +from traitlets import Unicode, Bool, List, default def guess_callback_uri(protocol, host, hub_server_url): @@ -68,6 +68,10 @@ def _OAUTH_AUTHORIZE_URL(self): def _OAUTH_ACCESS_TOKEN_URL(self): return self.authenticator.access_token_url + @property + def _OAUTH_USERINFO_URL(self): + return self.authenticator.userdata_url + def set_state_cookie(self, state): self.set_secure_cookie(STATE_COOKIE_NAME, state, expires_days=1, httponly=True) @@ -223,15 +227,28 @@ class OAuthenticator(Authenticator): authenticate (method takes one arg - the request handler handling the oauth callback) """ - authenticate_url = Unicode( - "must-be-set", config=True, help="""The authenticate url for initiating oauth""" + authorize_url = Unicode( + config=True, help="""The authenticate url for initiating oauth""" ) + @default("authorize_url") + def _authorize_url_default(self): + return os.environ.get("OAUTH2_AUTHORIZE_URL", "") access_token_url = Unicode( - "must-be-set", config=True, help="""The url retrieving an access token at the completion of oauth""", ) + @default("access_token_url") + def _access_token_url_default(self): + return os.environ.get("OAUTH2_TOKEN_URL", "") + + userdata_url = Unicode( + config=True, + help="""The url for retrieving user data with a completed access token""", + ) + @default("userdata_url") + def _userdata_url_default(self): + return os.environ.get("OAUTH2_USERDATA_URL", "") scope = List( Unicode(), From b9cc59f906e3ba12209a4dc374c653b344eabfc5 Mon Sep 17 00:00:00 2001 From: Min RK Date: Wed, 18 Dec 2019 13:22:26 +0100 Subject: [PATCH 08/19] adopt mixin-less globus --- oauthenticator/globus.py | 87 +++++++++++++++++++++------------------- 1 file changed, 45 insertions(+), 42 deletions(-) diff --git a/oauthenticator/globus.py b/oauthenticator/globus.py index fc57bfd2..64f7a74a 100644 --- a/oauthenticator/globus.py +++ b/oauthenticator/globus.py @@ -9,27 +9,22 @@ from tornado.auth import OAuth2Mixin from tornado.web import HTTPError -from traitlets import List, Unicode, Bool +from traitlets import List, Unicode, Bool, default + from jupyterhub.handlers import LogoutHandler from jupyterhub.auth import LocalAuthenticator from jupyterhub.utils import url_path_join -from .oauth2 import OAuthLoginHandler, OAuthenticator +from .oauth2 import OAuthenticator try: import globus_sdk except: - raise ImportError('globus_sdk is not installed, please see ' - '"globus-requirements.txt" for using Globus oauth.') - - -class GlobusMixin(OAuth2Mixin): - _OAUTH_AUTHORIZE_URL = 'https://auth.globus.org/v2/oauth2/authorize' - - -class GlobusLoginHandler(OAuthLoginHandler, GlobusMixin): - pass + raise ImportError( + 'globus_sdk is not installed, please see ' + '"globus-requirements.txt" for using Globus oauth.' + ) class GlobusLogoutHandler(LogoutHandler): @@ -39,6 +34,7 @@ class GlobusLogoutHandler(LogoutHandler): provider in addition to clearing the session with Jupyterhub, otherwise only the Jupyterhub session is cleared. """ + async def get(self): if self.authenticator.logout_redirect_url: await self.default_handle_logout() @@ -49,14 +45,17 @@ async def get(self): async def handle_logout(self): if self.current_user and self.authenticator.revoke_tokens_on_logout: - await self.clear_tokens(self.current_user) + await self.clear_tokens(self.current_user) async def clear_tokens(self, user): state = await user.get_auth_state() if state: self.authenticator.revoke_service_tokens(state.get('tokens')) - self.log.info('Logout: Revoked tokens for user "{}" services: {}' - .format(user.name, ','.join(state['tokens'].keys()))) + self.log.info( + 'Logout: Revoked tokens for user "{}" services: {}'.format( + user.name, ','.join(state['tokens'].keys()) + ) + ) state['tokens'] = '' await user.save_auth_state(state) @@ -66,13 +65,18 @@ class GlobusOAuthenticator(OAuthenticator): transfer tokens to the spawner. """ login_service = 'Globus' - login_handler = GlobusLoginHandler logout_handler = GlobusLogoutHandler - identity_provider = Unicode(help="""Restrict which institution a user + @default("authorize_url") + def _authorize_url_default(self): + return "https://auth.globus.org/v2/oauth2/authorize" + + identity_provider = Unicode( + help="""Restrict which institution a user can use to login (GlobusID, University of Hogwarts, etc.). This should be set in the app at developers.globus.org, but this acts as an additional - check to prevent unnecessary account creation.""").tag(config=True) + check to prevent unnecessary account creation.""" + ).tag(config=True) def _identity_provider_default(self): return os.getenv('IDENTITY_PROVIDER', 'globusid.org') @@ -89,7 +93,7 @@ def _scope_default(self): return [ 'openid', 'profile', - 'urn:globus:auth:scope:transfer.api.globus.org:all' + 'urn:globus:auth:scope:transfer.api.globus.org:all', ] allow_refresh_tokens = Bool( @@ -102,14 +106,15 @@ def _scope_default(self): def _allow_refresh_tokens_default(self): return True - globus_local_endpoint = Unicode(help="""If Jupyterhub is also a Globus - endpoint, its endpoint id can be specified here.""").tag(config=True) + globus_local_endpoint = Unicode( + help="""If Jupyterhub is also a Globus + endpoint, its endpoint id can be specified here.""" + ).tag(config=True) def _globus_local_endpoint_default(self): return os.getenv('GLOBUS_LOCAL_ENDPOINT', '') - logout_redirect_url = \ - Unicode(help="""URL for logging out.""").tag(config=True) + logout_redirect_url = Unicode(help="""URL for logging out.""").tag(config=True) def _logout_redirect_url_default(self): return os.getenv('LOGOUT_REDIRECT_URL', '') @@ -128,19 +133,14 @@ async def pre_spawn_start(self, user, spawner): This will allow users to create a transfer client: globus-sdk-python.readthedocs.io/en/stable/tutorial/#tutorial-step4 """ - spawner.environment['GLOBUS_LOCAL_ENDPOINT'] = \ - self.globus_local_endpoint + spawner.environment['GLOBUS_LOCAL_ENDPOINT'] = self.globus_local_endpoint state = await user.get_auth_state() if state: - globus_data = base64.b64encode( - pickle.dumps(state) - ) + globus_data = base64.b64encode(pickle.dumps(state)) spawner.environment['GLOBUS_DATA'] = globus_data.decode('utf-8') def globus_portal_client(self): - return globus_sdk.ConfidentialAppAuthClient( - self.client_id, - self.client_secret) + return globus_sdk.ConfidentialAppAuthClient(self.client_id, self.client_secret) async def authenticate(self, handler, data=None): """ @@ -155,7 +155,7 @@ async def authenticate(self, handler, data=None): client.oauth2_start_flow( redirect_uri, requested_scopes=' '.join(self.scope), - refresh_tokens=self.allow_refresh_tokens + refresh_tokens=self.allow_refresh_tokens, ) # Doing the code for token for id_token exchange tokens = client.oauth2_exchange_code_for_tokens(code) @@ -171,18 +171,19 @@ async def authenticate(self, handler, data=None): ' account at {}.'.format( self.identity_provider, self.identity_provider, - 'globus.org/app/account' - ) + 'globus.org/app/account', + ), ) return { 'name': username, 'auth_state': { 'client_id': self.client_id, 'tokens': { - tok: v for tok, v in tokens.by_resource_server.items() + tok: v + for tok, v in tokens.by_resource_server.items() if tok not in self.exclude_tokens }, - } + }, } def revoke_service_tokens(self, services): @@ -203,12 +204,13 @@ def get_callback_url(self, handler=None): Getting the configured callback url """ if self.oauth_callback_url is None: - raise HTTPError(500, - 'No callback url provided. ' - 'Please configure by adding ' - 'c.GlobusOAuthenticator.oauth_callback_url ' - 'to the config' - ) + raise HTTPError( + 500, + 'No callback url provided. ' + 'Please configure by adding ' + 'c.GlobusOAuthenticator.oauth_callback_url ' + 'to the config', + ) return self.oauth_callback_url def logout_url(self, base_url): @@ -220,4 +222,5 @@ def get_handlers(self, app): class LocalGlobusOAuthenticator(LocalAuthenticator, GlobusOAuthenticator): """A version that mixes in local system user creation""" + pass From da14b4ed692204bdf89db23ca47cd6430464c9d5 Mon Sep 17 00:00:00 2001 From: Min RK Date: Wed, 18 Dec 2019 13:30:44 +0100 Subject: [PATCH 09/19] mixin-less in azure AD --- oauthenticator/azuread.py | 35 ++++++++++++----------------------- 1 file changed, 12 insertions(+), 23 deletions(-) diff --git a/oauthenticator/azuread.py b/oauthenticator/azuread.py index 477dc30f..fe9ff523 100644 --- a/oauthenticator/azuread.py +++ b/oauthenticator/azuread.py @@ -19,24 +19,6 @@ from .oauth2 import OAuthLoginHandler, OAuthenticator -def azure_token_url_for(tentant): - return 'https://login.microsoftonline.com/{0}/oauth2/token'.format(tentant) - - -def azure_authorize_url_for(tentant): - return 'https://login.microsoftonline.com/{0}/oauth2/authorize'.format( - tentant) - - -class AzureAdMixin(OAuth2Mixin): - tenant_id = os.environ.get('AAD_TENANT_ID', '') - _OAUTH_ACCESS_TOKEN_URL = azure_token_url_for(tenant_id) - _OAUTH_AUTHORIZE_URL = azure_authorize_url_for(tenant_id) - - -class AzureAdLoginHandler(OAuthLoginHandler, AzureAdMixin): - pass - class AzureAdOAuthenticator(OAuthenticator): login_service = Unicode( @@ -45,19 +27,26 @@ class AzureAdOAuthenticator(OAuthenticator): help="""Azure AD domain name string, e.g. My College""" ) - login_handler = AzureAdLoginHandler - - tenant_id = Unicode(config=True) - username_claim = Unicode(config=True) + tenant_id = Unicode(config=True, help="The Azure Active Directory Tenant ID") @default('tenant_id') def _tenant_id_default(self): return os.environ.get('AAD_TENANT_ID', '') + username_claim = Unicode(config=True) + @default('username_claim') def _username_claim_default(self): return 'name' + @default("authorize_url") + def _authorize_url_default(self): + return 'https://login.microsoftonline.com/{0}/oauth2/authorize'.format(self.tenant_id) + + @default("access_token_url") + def _access_token_url_default(self): + return 'https://login.microsoftonline.com/{0}/oauth2/token'.format(self.tenant_id) + async def authenticate(self, handler, data=None): code = handler.get_argument("code") http_client = AsyncHTTPClient() @@ -73,7 +62,7 @@ async def authenticate(self, handler, data=None): data = urllib.parse.urlencode( params, doseq=True, encoding='utf-8', safe='=') - url = azure_token_url_for(self.tenant_id) + url = self.access_token_url headers = { 'Content-Type': From e85cb5bccf23fb3d68a51553a04505275798432a Mon Sep 17 00:00:00 2001 From: Min RK Date: Wed, 18 Dec 2019 13:34:33 +0100 Subject: [PATCH 10/19] mixin-less cilogon --- oauthenticator/cilogon.py | 76 ++++++++++++++++++++------------------- 1 file changed, 40 insertions(+), 36 deletions(-) diff --git a/oauthenticator/cilogon.py b/oauthenticator/cilogon.py index 8fa663d4..7df53c43 100644 --- a/oauthenticator/cilogon.py +++ b/oauthenticator/cilogon.py @@ -22,21 +22,14 @@ from tornado.httputil import url_concat from tornado.httpclient import HTTPRequest, AsyncHTTPClient -from traitlets import Unicode, List, Bool, validate +from traitlets import Unicode, List, Bool, default, validate from jupyterhub.auth import LocalAuthenticator from .oauth2 import OAuthLoginHandler, OAuthenticator -CILOGON_HOST = os.environ.get('CILOGON_HOST') or 'cilogon.org' - -class CILogonMixin(OAuth2Mixin): - _OAUTH_AUTHORIZE_URL = "https://%s/authorize" % CILOGON_HOST - _OAUTH_TOKEN_URL = "https://%s/oauth2/token" % CILOGON_HOST - - -class CILogonLoginHandler(OAuthLoginHandler, CILogonMixin): +class CILogonLoginHandler(OAuthLoginHandler): """See http://www.cilogon.org/oidc for general information.""" def authorize_redirect(self, *args, **kwargs): @@ -57,14 +50,26 @@ class CILogonOAuthenticator(OAuthenticator): client_secret_env = 'CILOGON_CLIENT_SECRET' login_handler = CILogonLoginHandler - scope = List(Unicode(), default_value=['openid', 'email', 'org.cilogon.userinfo'], - config=True, - help="""The OAuth scopes to request. + cilogon_host = Unicode(os.environ.get("CILOGON_HOST") or "cilogon.org", config=True) + + @default("authorize_url") + def _authorize_url_default(self): + return "https://%s/authorize" % self.cilogon_host + + @default("access_token_url") + def _access_token_url(self): + return "https://%s/oauth2/token" % self.cilogon_host + + scope = List( + Unicode(), + default_value=['openid', 'email', 'org.cilogon.userinfo'], + config=True, + help="""The OAuth scopes to request. See cilogon_scope.md for details. At least 'openid' is required. """, - ) + ) @validate('scope') def _validate_scope(self, proposal): @@ -119,7 +124,7 @@ def _validate_scope(self, proposal): This is useful for linked identities where not all of them return the primary username_claim. - """ + """, ) async def authenticate(self, handler, data=None): @@ -132,10 +137,7 @@ async def authenticate(self, handler, data=None): # Exchange the OAuth code for a CILogon Access Token # See: http://www.cilogon.org/oidc - headers = { - "Accept": "application/json", - "User-Agent": "JupyterHub", - } + headers = {"Accept": "application/json", "User-Agent": "JupyterHub"} params = dict( client_id=self.client_id, @@ -145,13 +147,9 @@ async def authenticate(self, handler, data=None): grant_type='authorization_code', ) - url = url_concat("https://%s/oauth2/token" % CILOGON_HOST, params) + url = url_concat(self.access_token_url, params) - req = HTTPRequest(url, - headers=headers, - method="POST", - body='' - ) + req = HTTPRequest(url, headers=headers, method="POST", body='') resp = await http_client.fetch(req) token_response = json.loads(resp.body.decode('utf8', 'replace')) @@ -159,10 +157,10 @@ async def authenticate(self, handler, data=None): self.log.info("Access token acquired.") # Determine who the logged in user is params = dict(access_token=access_token) - req = HTTPRequest(url_concat("https://%s/oauth2/userinfo" % - CILOGON_HOST, params), - headers=headers - ) + req = HTTPRequest( + url_concat("https://%s/oauth2/userinfo" % self.cilogon_host, params), + headers=headers, + ) resp = await http_client.fetch(req) resp_json = json.loads(resp.body.decode('utf8', 'replace')) @@ -176,21 +174,26 @@ async def authenticate(self, handler, data=None): break if not username: if len(claimlist) < 2: - self.log.error("Username claim %s not found in response: %s", - self.username_claim, sorted(resp_json.keys()) - ) + self.log.error( + "Username claim %s not found in response: %s", + self.username_claim, + sorted(resp_json.keys()), + ) else: - self.log.error("No username claim from %r in response: %s", - claimlist, sorted(resp_json.keys())) + self.log.error( + "No username claim from %r in response: %s", + claimlist, + sorted(resp_json.keys()), + ) raise web.HTTPError(500, "Failed to get username from CILogon") if self.idp_whitelist: gotten_name, gotten_idp = username.split('@') if gotten_idp not in self.idp_whitelist: self.log.error( - "Trying to login from not whitelisted domain %s", gotten_idp) - raise web.HTTPError( - 500, "Trying to login from not whitelisted domain") + "Trying to login from not whitelisted domain %s", gotten_idp + ) + raise web.HTTPError(500, "Trying to login from not whitelisted domain") if len(self.idp_whitelist) == 1 and self.strip_idp_domain: username = gotten_name userdict = {"name": username} @@ -210,4 +213,5 @@ async def authenticate(self, handler, data=None): class LocalCILogonOAuthenticator(LocalAuthenticator, CILogonOAuthenticator): """A version that mixes in local system user creation""" + pass From b468b2c001fb64a4ed997d9dacf3a1912da5e129 Mon Sep 17 00:00:00 2001 From: Min RK Date: Wed, 18 Dec 2019 13:37:01 +0100 Subject: [PATCH 11/19] mixin-less okpy --- oauthenticator/okpy.py | 77 ++++++++++++++++++++++-------------------- 1 file changed, 40 insertions(+), 37 deletions(-) diff --git a/oauthenticator/okpy.py b/oauthenticator/okpy.py index d1968590..ed4bfb6d 100644 --- a/oauthenticator/okpy.py +++ b/oauthenticator/okpy.py @@ -12,58 +12,62 @@ from jupyterhub.auth import LocalAuthenticator -from .oauth2 import OAuthLoginHandler, OAuthenticator +from .oauth2 import OAuthenticator -OKPY_USER_URL = "https://okpy.org/api/v3/user" -OKPY_ACCESS_TOKEN_URL = "https://okpy.org/oauth/token" -OKPY_AUTHORIZE_URL = "https://okpy.org/oauth/authorize" +class OkpyOAuthenticator(OAuthenticator, OAuth2Mixin): + login_service = "OK" -class OkpyMixin(OAuth2Mixin): - _OAUTH_ACCESS_TOKEN_URL = OKPY_ACCESS_TOKEN_URL - _OAUTH_AUTHORIZE_URL = OKPY_AUTHORIZE_URL - + @default("authorize_url") + def _authorize_url_default(self): + return "https://okpy.org/oauth/authorize" -class OkpyLoginHandler(OAuthLoginHandler, OkpyMixin): - pass + @default("access_token_url") + def _access_token_url_default(self): + return "https://okpy.org/oauth/token" + @default("userdata_url") + def _userdata_url_default(self): + return "https://okpy.org/api/v3/user" -class OkpyOAuthenticator(OAuthenticator, OAuth2Mixin): - login_service = "Okpy" - login_handler = OkpyLoginHandler - @default('scope') def _default_scope(self): return ['email'] def get_auth_request(self, code): params = dict( - redirect_uri = self.oauth_callback_url, - code = code, - grant_type = 'authorization_code' + redirect_uri=self.oauth_callback_url, + code=code, + grant_type='authorization_code', ) - b64key = a2b_base64("{}:{}".format(self.client_id, self.client_secret)).decode('ascii') - url = url_concat(OKPY_ACCESS_TOKEN_URL, params) - req = HTTPRequest(url, - method = "POST", - headers = { "Accept": "application/json", - "User-Agent": "JupyterHub", - "Authorization": "Basic {}".format(b64key), - }, - body = '' # Body is required for a POST... + b64key = a2b_base64("{}:{}".format(self.client_id, self.client_secret)).decode( + 'ascii' + ) + url = url_concat(self.access_token_url, params) + req = HTTPRequest( + url, + method="POST", + headers={ + "Accept": "application/json", + "User-Agent": "JupyterHub", + "Authorization": "Basic {}".format(b64key), + }, + body='', # Body is required for a POST... ) return req def get_user_info_request(self, access_token): - headers = {"Accept": "application/json", - "User-Agent": "JupyterHub", - "Authorization": "Bearer {}".format(access_token)} - params = {"envelope" : "false"} - url = url_concat(OKPY_USER_URL, params) - req = HTTPRequest(url, method = "GET", headers = headers) + headers = { + "Accept": "application/json", + "User-Agent": "JupyterHub", + "Authorization": "Bearer {}".format(access_token), + } + params = {"envelope": "false"} + url = url_concat(self.userdata_url, params) + req = HTTPRequest(url, method="GET", headers=headers) return req - async def authenticate(self, handler, data = None): + async def authenticate(self, handler, data=None): code = handler.get_argument("code", False) if not code: raise web.HTTPError(400, "Authentication Cancelled.") @@ -80,12 +84,11 @@ async def authenticate(self, handler, data = None): # TODO: preserve state in auth_state when JupyterHub supports encrypted auth_state return { 'name': user['email'], - 'auth_state': { - 'access_token': access_token, - 'okpy_user': user, - } + 'auth_state': {'access_token': access_token, 'okpy_user': user}, } + class LocalOkpyOAuthenticator(LocalAuthenticator, OkpyOAuthenticator): """A version that mixes in local system user creation""" + pass From 9d8249756fed8591dcda53d4deb2f071bb2a8cf1 Mon Sep 17 00:00:00 2001 From: Min RK Date: Wed, 18 Dec 2019 13:53:31 +0100 Subject: [PATCH 12/19] mixin-less openshift --- oauthenticator/openshift.py | 89 ++++++++++++++++++++--------------- oauthenticator/tests/mocks.py | 4 +- 2 files changed, 54 insertions(+), 39 deletions(-) diff --git a/oauthenticator/openshift.py b/oauthenticator/openshift.py index 28576c3a..92d2768c 100644 --- a/oauthenticator/openshift.py +++ b/oauthenticator/openshift.py @@ -13,37 +13,50 @@ from tornado.httputil import url_concat from tornado.httpclient import HTTPRequest, AsyncHTTPClient +from traitlets import Bool, Unicode, default from jupyterhub.auth import LocalAuthenticator -from .oauth2 import OAuthLoginHandler, OAuthenticator +from .oauth2 import OAuthenticator -OPENSHIFT_URL = os.environ.get('OPENSHIFT_URL') or 'https://localhost:8443' -OPENSHIFT_AUTH_API_URL = os.environ.get('OPENSHIFT_AUTH_API_URL') or OPENSHIFT_URL -OPENSHIFT_REST_API_URL = os.environ.get('OPENSHIFT_REST_API_URL') or OPENSHIFT_URL -class OpenShiftMixin(OAuth2Mixin): - _OAUTH_AUTHORIZE_URL = "%s/oauth/authorize" % OPENSHIFT_AUTH_API_URL - _OAUTH_ACCESS_TOKEN_URL = "%s/oauth/token" % OPENSHIFT_AUTH_API_URL +class OpenShiftOAuthenticator(OAuthenticator): + login_service = "OpenShift" -class OpenShiftLoginHandler(OAuthLoginHandler, OpenShiftMixin): - # This allows `Service Accounts as OAuth Clients` scenario - # https://docs.openshift.org/latest/architecture/additional_concepts/authentication.html#service-accounts-as-oauth-clients - @property - def scope(self): - return self.authenticator.scope + scope = ['user:info'] + openshift_url = Unicode( + os.environ.get('OPENSHIFT_URL') or 'https://localhost:8443', config=True + ) -class OpenShiftOAuthenticator(OAuthenticator): + openshift_auth_api_url = Unicode(config=True) - login_service = "OpenShift" + validate_cert = Bool( + True, config=True, help="Set to False to disable certificate validation" + ) - login_handler = OpenShiftLoginHandler + @default("openshift_auth_api_url") + def _openshift_auth_api_url_default(self): + return self.openshift_url - scope = ['user:info'] + openshift_rest_api_url = Unicode(config=True) - users_rest_api_path = '/apis/user.openshift.io/v1/users/~' + @default("openshift_rest_api_url") + def _openshift_rest_api_url_default(self): + return self.openshift_url + + @default("authorize_url") + def _authorize_url_default(self): + return "%s/oauth/authorize" % self.openshift_auth_api_url + + @default("access_token_url") + def _access_token_url_default(self): + return "%s/oauth/token" % self.openshift_auth_api_url + + @default("userdata_url") + def _userdata_url_default(self): + return "%s/apis/user.openshift.io/v1/users/~" % self.openshift_rest_api_url async def authenticate(self, handler, data=None): code = handler.get_argument("code") @@ -58,17 +71,18 @@ async def authenticate(self, handler, data=None): client_id=self.client_id, client_secret=self.client_secret, grant_type="authorization_code", - code=code + code=code, ) - url = url_concat(self.login_handler._OAUTH_ACCESS_TOKEN_URL, params) + url = url_concat(self.access_token_url, params) - req = HTTPRequest(url, - method="POST", - validate_cert=False, - headers={"Accept": "application/json"}, - body='' # Body is required for a POST... - ) + req = HTTPRequest( + url, + method="POST", + validate_cert=self.validate_cert, + headers={"Accept": "application/json"}, + body='', # Body is required for a POST... + ) resp = await http_client.fetch(req) @@ -77,29 +91,30 @@ async def authenticate(self, handler, data=None): access_token = resp_json['access_token'] # Determine who the logged in user is - headers={"Accept": "application/json", - "User-Agent": "JupyterHub", - "Authorization": "Bearer {}".format(access_token) + headers = { + "Accept": "application/json", + "User-Agent": "JupyterHub", + "Authorization": "Bearer {}".format(access_token), } - req = HTTPRequest("%s%s" % (OPENSHIFT_REST_API_URL, self.users_rest_api_path), - method="GET", - validate_cert=False, - headers=headers) + req = HTTPRequest( + self.userdata_url, + method="GET", + validate_cert=self.validate_cert, + headers=headers, + ) resp = await http_client.fetch(req) resp_json = json.loads(resp.body.decode('utf8', 'replace')) return { 'name': resp_json['metadata']['name'], - 'auth_state': { - 'access_token': access_token, - 'openshift_user': resp_json, - } + 'auth_state': {'access_token': access_token, 'openshift_user': resp_json}, } class LocalOpenShiftOAuthenticator(LocalAuthenticator, OpenShiftOAuthenticator): """A version that mixes in local system user creation""" + pass diff --git a/oauthenticator/tests/mocks.py b/oauthenticator/tests/mocks.py index 1567a8d7..ac39b754 100644 --- a/oauthenticator/tests/mocks.py +++ b/oauthenticator/tests/mocks.py @@ -127,7 +127,7 @@ def access_token(request): Checks code and allocates a new token. Replies with JSON model for the token. """ - assert request.method == 'POST' + assert request.method == 'POST', request.method if token_request_style == 'json': body = request.body.decode('utf8') try: @@ -165,7 +165,7 @@ def access_token(request): } def get_user(request): - assert request.method == 'GET' + assert request.method == 'GET', request.method auth_header = request.headers.get('Authorization') if auth_header: token = auth_header.split(None, 1)[1] From 6f627320600c5805cce27bc78f738099384c2d18 Mon Sep 17 00:00:00 2001 From: Min RK Date: Wed, 18 Dec 2019 13:27:21 +0100 Subject: [PATCH 13/19] remove new-since-last-release providers these can all be configured via GenericOAuthenticator since they were merged, replace them with informative import errors --- oauthenticator/awscognito.py | 185 +----------------------- oauthenticator/azureadb2c.py | 111 +------------- oauthenticator/tests/test_awscognito.py | 41 ------ oauthenticator/tests/test_yandex.py | 40 ----- oauthenticator/yandex.py | 110 +------------- setup.py | 3 - 6 files changed, 3 insertions(+), 487 deletions(-) delete mode 100644 oauthenticator/tests/test_awscognito.py delete mode 100644 oauthenticator/tests/test_yandex.py diff --git a/oauthenticator/awscognito.py b/oauthenticator/awscognito.py index f507277f..e120b5bc 100644 --- a/oauthenticator/awscognito.py +++ b/oauthenticator/awscognito.py @@ -1,184 +1 @@ -""" -Custom Authenticator to use AWSCognito with JupyterHub - -Derived using the Globus and Generic OAuthenticator implementations as examples. - -The following environment variables may be used for configuration: - - AWSCOGNITO_DOMAIN - Your AWSCognito domain, either AWS provided or custom - AWSCOGNITO_USERNAME_KEY - Your username key, you can use preferred_username, username, email - OAUTH_CLIENT_ID - Your client id - OAUTH_CLIENT_SECRET - Your client secret - OAUTH_CALLBACK_URL - Your callback handler URL - OAUTH_LOGOUT_REDIRECT_URL - Your logout redirect URL - -Additionally, if you are concerned about your secrets being exposed by -an env dump(I know I am!) you can set the client_secret, client_id and -oauth_callback_url directly on the config for Auth0OAuthenticator. - -One instance of this could be adding the following to your jupyterhub_config.py : - - c.AWSCognitoAuthenticator.client_id = 'YOUR_CLIENT_ID' - c.AWSCognitoAuthenticator.client_secret = 'YOUR_CLIENT_SECRET' - c.AWSCognitoAuthenticator.oauth_callback_url = 'YOUR_CALLBACK_URL' - c.AWSCognitoAuthenticator.username_key = 'YOUR_USERNAME_KEY' - c.AWSCognitoAuthenticator.oauth_logout_redirect_url = 'YOUR_LOGOUT_REDIRECT_URL' - -If you are using the environment variable config, all you should need to -do is define them in the environment then add the following line to -jupyterhub_config.py : - - c.JupyterHub.authenticator_class = 'oauthenticator.awscognito.AWSCognitoAuthenticator' - -""" - -import json -import os -import base64 -import urllib - -from tornado.auth import OAuth2Mixin -from tornado import gen - -from tornado.httpclient import HTTPRequest, AsyncHTTPClient -from tornado.httputil import url_concat - -from jupyterhub.handlers import LogoutHandler -from jupyterhub.auth import LocalAuthenticator - -from traitlets import Unicode - -from .oauth2 import OAuthLoginHandler, OAuthenticator - -AWSCOGNITO_DOMAIN = os.getenv('AWSCOGNITO_DOMAIN') - - -class AWSCognitoMixin(OAuth2Mixin): - _OAUTH_AUTHORIZE_URL = "https://%s/oauth2/authorize" % AWSCOGNITO_DOMAIN - _OAUTH_ACCESS_TOKEN_URL = "https://%s/oauth2/token" % AWSCOGNITO_DOMAIN - -class AWSCognitoLoginHandler(OAuthLoginHandler, AWSCognitoMixin): - pass - - -class AWSCognitoLogoutHandler(LogoutHandler): - """ - Handle custom logout URLs and token revocation. If a custom logout url - is specified, the 'logout' button will log the user out of that identity - provider in addition to clearing the session with Jupyterhub, otherwise - only the Jupyterhub session is cleared. - """ - async def render_logout_page(self): - params = dict( - client_id=self.authenticator.client_id, - logout_uri=self.authenticator.oauth_logout_redirect_url - ) - url = url_concat(self.authenticator.oidc_logout_url, params) - self.log.debug("Redirecting to AWSCognito logout: {0}".format(url)) - self.redirect(url, permanent=False) - - -class AWSCognitoAuthenticator(OAuthenticator): - - login_service = 'AWSCognito' - login_handler = AWSCognitoLoginHandler - - oidc_userdata_url = "https://%s/oauth2/userInfo" % AWSCOGNITO_DOMAIN - oidc_token_url = "https://%s/oauth2/token" % AWSCOGNITO_DOMAIN - oidc_logout_url = "https://%s/logout" % AWSCOGNITO_DOMAIN - - username_key = Unicode( - os.environ.get('AWSCOGNITO_USERNAME_KEY', 'username'), - config=True, - help="Userdata username key from returned json for USERDATA_URL" - ) - - oauth_logout_redirect_url = Unicode( - os.environ.get('OAUTH_LOGOUT_REDIRECT_URL', ''), - config=True, - help="Logout redirect URL to be shown after IdP logout" - ) - - @gen.coroutine - def authenticate(self, handler, data=None): - code = handler.get_argument("code") - # TODO: Configure the curl_httpclient for tornado - http_client = AsyncHTTPClient() - - params = dict( - redirect_uri=self.get_callback_url(handler), - code=code, - grant_type='authorization_code' - ) - - if self.oidc_token_url: - url = self.oidc_token_url - else: - raise ValueError("Please set the OAUTH2_TOKEN_URL environment variable") - - headers = { - "Accept": "application/json", - "User-Agent": "JupyterHub", - "Content-Type": "application/x-www-form-urlencoded" - } - b64key = base64.b64encode( - bytes( - "{}:{}".format(self.client_id, self.client_secret), - "utf8" - ) - ) - headers.update({"Authorization": "Basic {}".format(b64key.decode("utf8"))}) - - req = HTTPRequest(url, - method="POST", - headers=headers, - validate_cert=True, - body=urllib.parse.urlencode(params) # Body is required for a POST... - ) - - resp = yield http_client.fetch(req) - - resp_json = json.loads(resp.body.decode('utf8', 'replace')) - - access_token = resp_json['access_token'] - token_type = resp_json['token_type'] - - # Determine who the logged in user is - headers = { - "Accept": "application/json", - "User-Agent": "JupyterHub", - "Authorization": "{} {}".format(token_type, access_token) - } - if self.oidc_userdata_url: - url = self.oidc_userdata_url - else: - raise ValueError("Please set the OAUTH2_USERDATA_URL environment variable") - - req = HTTPRequest(url, - method='GET', - headers=headers, - validate_cert=True, - ) - resp = yield http_client.fetch(req) - resp_json = json.loads(resp.body.decode('utf8', 'replace')) - - if not resp_json.get(self.username_key): - self.log.error("OAuth user contains no key %s: %s", self.username_key, resp_json) - return - - return { - 'name': resp_json.get(self.username_key), - 'auth_state': { - 'access_token': access_token, - 'awscognito_user': resp_json, - } - } - - def get_handlers(self, app): - return super().get_handlers(app) + [(r'/logout', AWSCognitoLogoutHandler)] - - -class LocalAWSCognitoAuthenticator(LocalAuthenticator, AWSCognitoAuthenticator): - - """A version that mixes in local system user creation""" - pass +raise ImportError("AWS Cognito can be configured via GenericOAuthenticator") diff --git a/oauthenticator/azureadb2c.py b/oauthenticator/azureadb2c.py index af3cbdf4..07ca83ff 100644 --- a/oauthenticator/azureadb2c.py +++ b/oauthenticator/azureadb2c.py @@ -1,110 +1 @@ -""" -Custom Authenticator to use Azure AD B2C with JupyterHub - -""" - -import json -import jwt -import os -import urllib -import hashlib -from urllib.parse import quote - -from tornado.auth import OAuth2Mixin -from tornado.log import app_log -from tornado.httpclient import HTTPRequest, AsyncHTTPClient - -from jupyterhub.auth import LocalAuthenticator - -from traitlets import Unicode, default - -from oauth2 import OAuthLoginHandler, OAuthenticator - - -def azure_token_url(): - return os.environ.get('OAUTH_ACCESS_TOKEN_URL', '') - - -def azure_authorize_url(): - return os.environ.get('OAUTH_AUTHORIZE_URL', '') + '&scope=' + quote(os.environ.get('OAUTH_SCOPE', '')) - - -class AzureAdB2CMixin(OAuth2Mixin): - _OAUTH_ACCESS_TOKEN_URL = azure_token_url() - _OAUTH_AUTHORIZE_URL = azure_authorize_url() - - -class AzureAdB2CLoginHandler(OAuthLoginHandler, AzureAdB2CMixin): - pass - - -class AzureAdB2COAuthenticator(OAuthenticator): - login_service = Unicode( - os.environ.get('AAD_LOGIN_SERVICE_NAME', 'Azure AD B2C'), - config=True, - help="Tenant") - - login_handler = AzureAdB2CLoginHandler - - username_claim = Unicode( - os.environ.get('AAD_USERNAME_CLAIM', 'upn'), - config=True, - help="Tenant") - - @default('username_claim') - def _username_claim_default(self): - return 'upn' - - - async def authenticate(self, handler, data=None): - code = handler.get_argument("code") - http_client = AsyncHTTPClient() - - params = dict( - client_id=self.client_id, - client_secret=self.client_secret, - grant_type='authorization_code', - code=code, - resource=self.client_id, - redirect_uri=self.get_callback_url(handler)) - - data = urllib.parse.urlencode( - params, doseq=True, encoding='utf-8', safe='=') - - url = azure_token_url() - - headers = { - 'Content-Type': - 'application/x-www-form-urlencoded; ; charset=UTF-8"' - } - req = HTTPRequest( - url, - method="POST", - headers=headers, - body=data # Body is required for a POST... - ) - - resp = await http_client.fetch(req) - resp_json = json.loads(resp.body.decode('utf8', 'replace')) - - app_log.info("Response %s", resp_json) - access_token = resp_json['access_token'] - - id_token = resp_json['id_token'] - decoded = jwt.decode(id_token, verify=False) - - #userdict = {"name": self.get_normalizedUserIdFromUPN(decoded[self.username_claim])} - userdict = {"name": decoded[self.username_claim]} - - - userdict["auth_state"] = auth_state = {} - auth_state['access_token'] = access_token - # results in a decoded JWT for the user data - auth_state['user'] = decoded - - return userdict - - -class LocalAzureAdB2COAuthenticator(LocalAuthenticator, AzureAdB2COAuthenticator): - """A version that mixes in local system user creation""" - pass +raise ImportError("Azure AD B2C can be configured via GenericOAuthenticator") diff --git a/oauthenticator/tests/test_awscognito.py b/oauthenticator/tests/test_awscognito.py deleted file mode 100644 index b383d2f1..00000000 --- a/oauthenticator/tests/test_awscognito.py +++ /dev/null @@ -1,41 +0,0 @@ -import os -from unittest.mock import patch - -from pytest import fixture - -with patch.dict(os.environ, AWSCOGNITO_DOMAIN='jupyterhub-test.auth.us-west-1.amazoncognito.com'): - from ..awscognito import AWSCognitoAuthenticator, AWSCOGNITO_DOMAIN - -from .mocks import setup_oauth_mock - - -def user_model(username): - """Return a user model""" - return { - 'username': username, - 'scope': 'basic', - } - -def Authenticator(): - return AWSCognitoAuthenticator() - -@fixture -def awscognito_client(client): - setup_oauth_mock(client, - host=AWSCOGNITO_DOMAIN, - access_token_path='/oauth2/token', - user_path='/oauth2/userInfo' - ) - return client - - -async def test_awscognito(awscognito_client): - authenticator = Authenticator() - handler = awscognito_client.handler_for_user(user_model('foo')) - user_info = await authenticator.authenticate(handler) - assert sorted(user_info) == ['auth_state', 'name'] - name = user_info['name'] - assert name == 'foo' - auth_state = user_info['auth_state'] - assert 'access_token' in auth_state - assert 'awscognito_user' in auth_state diff --git a/oauthenticator/tests/test_yandex.py b/oauthenticator/tests/test_yandex.py deleted file mode 100644 index 61ecfcc7..00000000 --- a/oauthenticator/tests/test_yandex.py +++ /dev/null @@ -1,40 +0,0 @@ -from pytest import fixture, mark - -from ..yandex import YandexPassportOAuthenticator - -from .mocks import setup_oauth_mock - - -def user_model(username): - """Return a user model""" - return { - 'email': 'platon@yandex', - 'id': 777, - 'login': username, - 'name': 'Platon Shchukhin', - } - - -@fixture -def yandex_client(client): - setup_oauth_mock( - client, - host=['oauth.yandex.ru', 'login.yandex.ru'], - access_token_path='/token', - user_path='/info', - token_type='token', - ) - return client - - -@mark.gen_test -def test_yandex(yandex_client): - authenticator = YandexPassportOAuthenticator() - handler = yandex_client.handler_for_user(user_model('kidig')) - user_info = yield authenticator.authenticate(handler) - assert sorted(user_info) == ['auth_state', 'name'] - name = user_info['name'] - assert name == 'kidig' - auth_state = user_info['auth_state'] - assert 'access_token' in auth_state - assert 'yandex_user' in auth_state diff --git a/oauthenticator/yandex.py b/oauthenticator/yandex.py index 2ccf1122..28ceccc5 100644 --- a/oauthenticator/yandex.py +++ b/oauthenticator/yandex.py @@ -1,109 +1 @@ -""" -Custom Authenticator to use Yandex.Passport OAuth with JupyterHub - -Created by Dmitry Gerasimenko (@kidig) -""" - - -import json -import os -import urllib.parse - -from jupyterhub.auth import LocalAuthenticator -from tornado import gen -from tornado.auth import OAuth2Mixin -from tornado.httpclient import HTTPRequest, AsyncHTTPClient, HTTPError -from traitlets import Unicode - -from .oauth2 import OAuthLoginHandler, OAuthenticator - -YANDEX_OAUTH_HOST = os.environ.get('YANDEX_OAUTH_HOST', 'https://oauth.yandex.ru') -YANDEX_LOGIN_HOST = os.environ.get('YANDEX_LOGIN_HOST', 'https://login.yandex.ru') - - -def _api_headers(access_token): - return { - "Accept": "application/json", - "User-Agent": "JupyterHub", - "Authorization": "OAuth {}".format(access_token), - } - - -class YandexPassportMixin(OAuth2Mixin): - _OAUTH_AUTHORIZE_URL = "%s/authorize" % YANDEX_OAUTH_HOST - _OAUTH_ACCESS_TOKEN_URL = "%s/token" % YANDEX_OAUTH_HOST - - -class YandexPassportLoginHandler(OAuthLoginHandler, YandexPassportMixin): - pass - - -class YandexPassportOAuthenticator(OAuthenticator): - client_id_env = "YANDEX_PASSPORT_CLIENT_ID" - client_secret_env = "YANDEX_PASSPORT_CLIENT_SECRET" - login_handler = YandexPassportLoginHandler - - login_service = Unicode( - os.environ.get('LOGIN_SERVICE', 'Yandex.Passport'), - config=True, - help="""Yandex Service string, e.g. Yandex""" - ) - - @gen.coroutine - def authenticate(self, handler, data=None): - code = handler.get_argument("code", False) - - if not code: - raise HTTPError(400, "oauth_callback made without a token") - - http_client = AsyncHTTPClient() - - # Exchange the OAuth code for a YandexPassport Access Token - # - post_params = dict( - client_id=self.client_id, - client_secret=self.client_secret, - code=code, - grant_type='authorization_code', - ) - - req = HTTPRequest( - "%s/token" % YANDEX_OAUTH_HOST, - method="POST", - headers={ - "Accept": "application/json", - - }, - body=urllib.parse.urlencode(post_params).encode('utf-8'), - ) - - resp = yield http_client.fetch(req) - resp_json = json.loads(resp.body.decode('utf8', 'replace')) - - access_token = resp_json['access_token'] - - # Determine who the logged in user is - req = HTTPRequest("%s/info" % YANDEX_LOGIN_HOST, - method="GET", - headers=_api_headers(access_token)) - - resp = yield http_client.fetch(req) - user_info = json.loads(resp.body.decode('utf8', 'replace')) - - username = user_info['login'] - - if not username: - return None - - return { - 'name': username, - 'auth_state': { - 'access_token': access_token, - 'yandex_user': user_info - } - } - - -class LocalYandexPassportOAuthenticator(LocalAuthenticator, YandexPassportOAuthenticator): - """A version that mixes in local system user creation""" - pass +raise ImportError("Yandex can be configured via GenericOAuthenticator") diff --git a/setup.py b/setup.py index 7f818e29..3e2f5e7c 100644 --- a/setup.py +++ b/setup.py @@ -86,9 +86,6 @@ def run(self): 'openshift = oauthenticator.openshift:OpenShiftOAuthenticator', 'local-openshift = oauthenticator.openshift:LocalOpenShiftOAuthenticator', - - 'awscognito = oauthenticator.awscognito:AWSCognitoAuthenticator', - 'local-awscognito = oauthenticator.awscognito:LocalAWSCognitoAuthenticator', ], }, classifiers = [ From 4e27c50ec2e3006374c29d52be9a7382a3f5c2a5 Mon Sep 17 00:00:00 2001 From: Min RK Date: Wed, 18 Dec 2019 14:02:21 +0100 Subject: [PATCH 14/19] replace docs referencing never-released authenticators with appropriate Generic config --- docs/source/getting-started.rst | 22 +++++++++++++--------- examples/azureadb2c/config.py | 22 ---------------------- examples/azureadb2c/run.sh | 6 ------ 3 files changed, 13 insertions(+), 37 deletions(-) delete mode 100644 examples/azureadb2c/config.py delete mode 100644 examples/azureadb2c/run.sh diff --git a/docs/source/getting-started.rst b/docs/source/getting-started.rst index ccbe75f1..39e58138 100644 --- a/docs/source/getting-started.rst +++ b/docs/source/getting-started.rst @@ -19,7 +19,6 @@ OAuthenticator currently supports the following authentication services: - `Auth0 `__ - `Azure AD <#azure-ad-setup>`__ -- `Azure AD B2C <#azure-ad-b2c-setup>`__ - `Bitbucket `__ - `CILogon `__ - `GitHub <#github-setup>`__ @@ -507,8 +506,7 @@ Use the ``GenericOAuthenticator`` for Jupyterhub by editing your .. code:: python - from oauthenticator.generic import GenericOAuthenticator - c.JupyterHub.authenticator_class = GenericOAuthenticator + c.JupyterHub.authenticator_class = "generic" c.GenericOAuthenticator.oauth_callback_url = 'http://YOUR-JUPYTERHUB.com/hub/oauth_callback' c.GenericOAuthenticator.client_id = 'MOODLE-CLIENT-ID' @@ -542,12 +540,18 @@ Choose **Yandex.Passport API** in Permissions and check these options: Set the above settings in your ``jupyterhub_config.py``: -\```python c.JupyterHub.authenticator_class = -‘oauthenticator.yandex.YandexPassportOAuthenticator’ -c.YandexPassportOAuthenticator.oauth_callback_url = -‘https://[your-host]/hub/oauth_callback’ -c.YandexPassportOAuthenticator.client_id = ‘[your app ID]’ -c.YandexPassportOAuthenticator.client_secret = ‘[your app Password]’ +.. code:: python + + c.JupyterHub.authenticator_class = "generic" + c.OAuthenticator.oauth_callback_url = "https://[your-host]/hub/oauth_callback" + c.OAuthenticator.client_id = "[your app ID]"" + c.OAuthenticator.client_secret = "[your app Password]" + + c.GenericOAuthenticator.login_service = "Yandex.Passport" + c.GenericOAuthenticator.username_key = "login" + c.GenericOAuthenticator.authorize_url = "https://oauth.yandex.ru/authorize" + c.GenericOAuthenticator.token_url = "https://oauth.yandex.ru/token" + c.GenericOAuthenticator.userdata_url = "https://login.yandex.ru/info" .. |PyPI| image:: https://img.shields.io/pypi/v/oauthenticator.svg :target: https://pypi.python.org/pypi/oauthenticator diff --git a/examples/azureadb2c/config.py b/examples/azureadb2c/config.py deleted file mode 100644 index c20e1857..00000000 --- a/examples/azureadb2c/config.py +++ /dev/null @@ -1,22 +0,0 @@ -import sys -import os - -#load from local path -#sys.path.insert(1, '/home/linkcd/github/oauthenticator/oauthenticator') -#from azureadb2c import AzureAdB2COAuthenticator, LocalAzureAdB2COAuthenticator - -#load from package -from oauthenticator.azureadb2c import AzureAdB2COAuthenticator, LocalAzureAdB2COAuthenticator -c.JupyterHub.authenticator_class = LocalAzureAdB2COAuthenticator - -c.Application.log_level = 'DEBUG' - - -c.AzureAdB2COAuthenticator.oauth_callback_url = 'http://localhost:8000/hub/oauth_callback' -c.AzureAdB2COAuthenticator.client_id = 'YOUR_VALUE' -c.AzureAdB2COAuthenticator.client_secret = 'YOUR_VALUE' - -c.Authenticator.delete_invalid_users = True - -c.LocalAzureAdB2COAuthenticator.add_user_cmd = ['adduser', '-q', '--gecos', '""', '--disabled-password', '--force-badname'] -c.LocalAzureAdB2COAuthenticator.create_system_users = True diff --git a/examples/azureadb2c/run.sh b/examples/azureadb2c/run.sh deleted file mode 100644 index 13fe95d9..00000000 --- a/examples/azureadb2c/run.sh +++ /dev/null @@ -1,6 +0,0 @@ -export AAD_LOGIN_SERVICE_NAME='Azure AD B2C' -export OAUTH_ACCESS_TOKEN_URL='https://login.microsoftonline.com/dnvglb2cprod.onmicrosoft.com/oauth2/v2.0/token?p=B2C_1A_SignInWithADFSIdp_EmailAsString' -export OAUTH_AUTHORIZE_URL='https://login.microsoftonline.com/dnvglb2cprod.onmicrosoft.com/oauth2/v2.0/authorize?p=B2C_1A_SignInWithADFSIdp_EmailAsString' -export OAUTH_SCOPE='openid https://dnvglb2cprod.onmicrosoft.com/83054ebf-1d7b-43f5-82ad-b2bde84d7b75/user_impersonation' - -jupyterhub -f ./config.py --log-level=DEBUG From ee98e67529be871da616719a99f68764d84e1926 Mon Sep 17 00:00:00 2001 From: Min RK Date: Wed, 18 Dec 2019 14:04:50 +0100 Subject: [PATCH 15/19] azuread b2c is the same as azuread with different config, no need for separate Authenticator --- docs/source/getting-started.rst | 30 ------------------------------ oauthenticator/azureadb2c.py | 2 +- 2 files changed, 1 insertion(+), 31 deletions(-) diff --git a/docs/source/getting-started.rst b/docs/source/getting-started.rst index 39e58138..070a3eb4 100644 --- a/docs/source/getting-started.rst +++ b/docs/source/getting-started.rst @@ -179,36 +179,6 @@ See ``run.sh`` for an `example <./examples/azuread/>`__ - `Source Code `__ -Azure AD B2C Setup ------------------- - -.. _prereqs-1: - -*Prereqs*: -~~~~~~~~~~ - -- Requires: **``PyJWT>=1.5.3``** - -:: - - > pip3 install PyJWT - -- BE SURE TO SET THE **``OAUTH_ACCESS_TOKEN_URL``, - ``OAUTH_AUTHORIZE_URL`` and ``OAUTH_SCOPE``** environment variables - -:: - - > export OAUTH_ACCESS_TOKEN_URL='https://login.microsoftonline.com/YOUR_TENANT.onmicrosoft.com/oauth2/v2.0/token?p=YOUR_POLICY_NAME' - > export OAUTH_AUTHORIZE_URL='https://login.microsoftonline.com/YOUR_TENANT.onmicrosoft.com/oauth2/v2.0/authorize?p=YOUR_POLICY_NAME' - > export OAUTH_SCOPE='openid YOUR_RESOURCE' - -Sample code -~~~~~~~~~~~ - -The sample code can be found at `examples -folder <./examples/azureadb2c/>`__ \* See ``run.sh`` for setting up -environment variables. \* See ``config.py`` for setting up such as -client id/secret and add_user_cmd. Source code ~~~~~~~~~~~ diff --git a/oauthenticator/azureadb2c.py b/oauthenticator/azureadb2c.py index 07ca83ff..8a1b87d0 100644 --- a/oauthenticator/azureadb2c.py +++ b/oauthenticator/azureadb2c.py @@ -1 +1 @@ -raise ImportError("Azure AD B2C can be configured via GenericOAuthenticator") +raise ImportError("Azure AD B2C can be configured via AzureAdOAuthenticator") From b70742c80b789d49ebb79251f8d8ca7a4873b82f Mon Sep 17 00:00:00 2001 From: Min RK Date: Wed, 18 Dec 2019 14:11:37 +0100 Subject: [PATCH 16/19] call it token_url GenericOAuthenticator defined it already, let's use that --- oauthenticator/auth0.py | 6 +++--- oauthenticator/azuread.py | 7 +++---- oauthenticator/bitbucket.py | 4 ++-- oauthenticator/cilogon.py | 6 +++--- oauthenticator/generic.py | 13 ++----------- oauthenticator/github.py | 12 ++++-------- oauthenticator/gitlab.py | 7 ++----- oauthenticator/google.py | 6 +++--- oauthenticator/mediawiki.py | 2 -- oauthenticator/oauth2.py | 8 ++++---- oauthenticator/okpy.py | 6 +++--- oauthenticator/openshift.py | 6 +++--- 12 files changed, 32 insertions(+), 51 deletions(-) diff --git a/oauthenticator/auth0.py b/oauthenticator/auth0.py index c0bac25f..ce3f7fbb 100644 --- a/oauthenticator/auth0.py +++ b/oauthenticator/auth0.py @@ -62,8 +62,8 @@ def _auth0_subdomain_default(self): def _authorize_url_default(self): return "https://%s.auth0.com/authorize" % self.auth0_subdomain - @default("access_token_url") - def _access_token_url_default(self): + @default("token_url") + def _token_url_default(self): return "https://%s.auth0.com/oauth/token" % self.auth0_subdomain async def authenticate(self, handler, data=None): @@ -78,7 +78,7 @@ async def authenticate(self, handler, data=None): 'code': code, 'redirect_uri': self.get_callback_url(handler), } - url = self.access_token_url + url = self.token_url req = HTTPRequest( url, diff --git a/oauthenticator/azuread.py b/oauthenticator/azuread.py index fe9ff523..5cb26577 100644 --- a/oauthenticator/azuread.py +++ b/oauthenticator/azuread.py @@ -1,6 +1,5 @@ """ Custom Authenticator to use Azure AD with JupyterHub - """ import json @@ -43,8 +42,8 @@ def _username_claim_default(self): def _authorize_url_default(self): return 'https://login.microsoftonline.com/{0}/oauth2/authorize'.format(self.tenant_id) - @default("access_token_url") - def _access_token_url_default(self): + @default("token_url") + def _token_url_default(self): return 'https://login.microsoftonline.com/{0}/oauth2/token'.format(self.tenant_id) async def authenticate(self, handler, data=None): @@ -62,7 +61,7 @@ async def authenticate(self, handler, data=None): data = urllib.parse.urlencode( params, doseq=True, encoding='utf-8', safe='=') - url = self.access_token_url + url = self.token_url headers = { 'Content-Type': diff --git a/oauthenticator/bitbucket.py b/oauthenticator/bitbucket.py index cc7257b3..04db87f2 100644 --- a/oauthenticator/bitbucket.py +++ b/oauthenticator/bitbucket.py @@ -36,8 +36,8 @@ class BitbucketOAuthenticator(OAuthenticator): def _authorize_url_default(self): return "https://bitbucket.org/site/oauth2/authorize" - @default("access_token_url") - def _access_token_url_default(self): + @default("token_url") + def _token_url_default(self): return "https://bitbucket.org/site/oauth2/access_token" team_whitelist = Set( diff --git a/oauthenticator/cilogon.py b/oauthenticator/cilogon.py index 7df53c43..ae88bf94 100644 --- a/oauthenticator/cilogon.py +++ b/oauthenticator/cilogon.py @@ -56,8 +56,8 @@ class CILogonOAuthenticator(OAuthenticator): def _authorize_url_default(self): return "https://%s/authorize" % self.cilogon_host - @default("access_token_url") - def _access_token_url(self): + @default("token_url") + def _token_url(self): return "https://%s/oauth2/token" % self.cilogon_host scope = List( @@ -147,7 +147,7 @@ async def authenticate(self, handler, data=None): grant_type='authorization_code', ) - url = url_concat(self.access_token_url, params) + url = url_concat(self.token_url, params) req = HTTPRequest(url, headers=headers, method="POST", body='') diff --git a/oauthenticator/generic.py b/oauthenticator/generic.py index 0118cc4d..f404fdce 100644 --- a/oauthenticator/generic.py +++ b/oauthenticator/generic.py @@ -26,15 +26,6 @@ class GenericOAuthenticator(OAuthenticator): login_service = Unicode("OAuth 2.0", config=True) - token_url = Unicode(config=True, help="DEPRECATED. Use access_token_url") - - @observe("token_url") - def _token_url_changed(self, change): - self.log.warning( - "GenericOAuthenticator.token_url is deprecated. Use OAuthenticator.access_token_url" - ) - self.access_token_url = change.new - extra_params = Dict(help="Extra parameters for first POST request").tag(config=True) username_key = Union( @@ -89,8 +80,8 @@ async def authenticate(self, handler, data=None): ) params.update(self.extra_params) - if self.access_token_url: - url = self.access_token_url + if self.token_url: + url = self.token_url else: raise ValueError("Please set the $OAUTH2_TOKEN_URL environment variable") diff --git a/oauthenticator/github.py b/oauthenticator/github.py index 86416212..648469c3 100644 --- a/oauthenticator/github.py +++ b/oauthenticator/github.py @@ -1,9 +1,5 @@ """ -Custom Authenticator to use GitHub OAuth with JupyterHub - -Most of the code c/o Kyle Kelley (@rgbkrk) - -Extended use of GH attributes by Adam Thornton (athornton@lsst.org) +Authenticator to use GitHub OAuth with JupyterHub """ @@ -92,8 +88,8 @@ def _github_api_default(self): def _authorize_url_default(self): return "%s/login/oauth/authorize" % (self.github_url) - @default("access_token_url") - def _access_token_url_default(self): + @default("token_url") + def _token_url_default(self): return "%s/login/oauth/access_token" % (self.github_url) # deprecated names @@ -133,7 +129,7 @@ async def authenticate(self, handler, data=None): client_id=self.client_id, client_secret=self.client_secret, code=code ) - url = url_concat(self.access_token_url, params) + url = url_concat(self.token_url, params) req = HTTPRequest( url, diff --git a/oauthenticator/gitlab.py b/oauthenticator/gitlab.py index a6d3e04c..c45d0ef3 100644 --- a/oauthenticator/gitlab.py +++ b/oauthenticator/gitlab.py @@ -1,8 +1,5 @@ """ Custom Authenticator to use GitLab OAuth with JupyterHub - -Modified for GitLab by Laszlo Dobos (@dobos) -based on the GitHub plugin by Kyle Kelley (@rgbkrk) """ @@ -91,8 +88,8 @@ def _default_gitlab_api(self): def _authorize_url_default(self): return "%s/oauth/authorize" % self.gitlab_url - @default("access_token_url") - def _access_token_url_default(self): + @default("token_url") + def _token_url_default(self): return "%s/oauth/access_token" % self.gitlab_url gitlab_group_whitelist = Set( diff --git a/oauthenticator/google.py b/oauthenticator/google.py index cbf803b8..05230748 100644 --- a/oauthenticator/google.py +++ b/oauthenticator/google.py @@ -30,8 +30,8 @@ def _scope_default(self): def _authorize_url_default(self): return "https://accounts.google.com/o/oauth2/v2/auth" - @default("access_token_url") - def _access_token_url_default(self): + @default("token_url") + def _token_url_default(self): return "https://www.googleapis.com/oauth2/v4/token" user_info_url = Unicode( @@ -87,7 +87,7 @@ async def authenticate(self, handler, data=None): http_client = AsyncHTTPClient() response = await http_client.fetch( - self.access_token_url, + self.token_url, method="POST", headers={"Content-Type": "application/x-www-form-urlencoded"}, body=body, diff --git a/oauthenticator/mediawiki.py b/oauthenticator/mediawiki.py index df854217..d7728d91 100644 --- a/oauthenticator/mediawiki.py +++ b/oauthenticator/mediawiki.py @@ -1,8 +1,6 @@ """ Custom Authenticator to use MediaWiki OAuth with JupyterHub -Most of the code c/o Yuvi Panda (@yuvipanda) - Requires `mwoauth` package. """ diff --git a/oauthenticator/oauth2.py b/oauthenticator/oauth2.py index 094a81da..51513b02 100644 --- a/oauthenticator/oauth2.py +++ b/oauthenticator/oauth2.py @@ -66,7 +66,7 @@ def _OAUTH_AUTHORIZE_URL(self): @property def _OAUTH_ACCESS_TOKEN_URL(self): - return self.authenticator.access_token_url + return self.authenticator.token_url @property def _OAUTH_USERINFO_URL(self): @@ -234,12 +234,12 @@ class OAuthenticator(Authenticator): def _authorize_url_default(self): return os.environ.get("OAUTH2_AUTHORIZE_URL", "") - access_token_url = Unicode( + token_url = Unicode( config=True, help="""The url retrieving an access token at the completion of oauth""", ) - @default("access_token_url") - def _access_token_url_default(self): + @default("token_url") + def _token_url_default(self): return os.environ.get("OAUTH2_TOKEN_URL", "") userdata_url = Unicode( diff --git a/oauthenticator/okpy.py b/oauthenticator/okpy.py index ed4bfb6d..0127d8b2 100644 --- a/oauthenticator/okpy.py +++ b/oauthenticator/okpy.py @@ -22,8 +22,8 @@ class OkpyOAuthenticator(OAuthenticator, OAuth2Mixin): def _authorize_url_default(self): return "https://okpy.org/oauth/authorize" - @default("access_token_url") - def _access_token_url_default(self): + @default("token_url") + def _token_url_default(self): return "https://okpy.org/oauth/token" @default("userdata_url") @@ -43,7 +43,7 @@ def get_auth_request(self, code): b64key = a2b_base64("{}:{}".format(self.client_id, self.client_secret)).decode( 'ascii' ) - url = url_concat(self.access_token_url, params) + url = url_concat(self.token_url, params) req = HTTPRequest( url, method="POST", diff --git a/oauthenticator/openshift.py b/oauthenticator/openshift.py index 92d2768c..c50a84fc 100644 --- a/oauthenticator/openshift.py +++ b/oauthenticator/openshift.py @@ -50,8 +50,8 @@ def _openshift_rest_api_url_default(self): def _authorize_url_default(self): return "%s/oauth/authorize" % self.openshift_auth_api_url - @default("access_token_url") - def _access_token_url_default(self): + @default("token_url") + def _token_url_default(self): return "%s/oauth/token" % self.openshift_auth_api_url @default("userdata_url") @@ -74,7 +74,7 @@ async def authenticate(self, handler, data=None): code=code, ) - url = url_concat(self.access_token_url, params) + url = url_concat(self.token_url, params) req = HTTPRequest( url, From b64f8d162fa183d6c61969daeca173e9a7554395 Mon Sep 17 00:00:00 2001 From: Min RK Date: Wed, 18 Dec 2019 14:11:42 +0100 Subject: [PATCH 17/19] black config --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 pyproject.toml diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..0097e9f6 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,2 @@ +[tool.black] +skip-string-normalization = true From 3b63294d1564baf5f0c6e6088b78e312e589d5b7 Mon Sep 17 00:00:00 2001 From: Min RK Date: Thu, 19 Dec 2019 08:13:43 +0100 Subject: [PATCH 18/19] make sure we actually use the new login handler class --- oauthenticator/oauth2.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/oauthenticator/oauth2.py b/oauthenticator/oauth2.py index 51513b02..e814d9dd 100644 --- a/oauthenticator/oauth2.py +++ b/oauthenticator/oauth2.py @@ -223,10 +223,11 @@ class OAuthenticator(Authenticator): Subclasses must override: login_service (string identifying the service provider) - login_handler (likely a subclass of OAuthLoginHandler) authenticate (method takes one arg - the request handler handling the oauth callback) """ + login_handler = OAuthLoginHandler + authorize_url = Unicode( config=True, help="""The authenticate url for initiating oauth""" ) From c601fee35c2a9de23ffb18dfa92eedaeeb3b56b4 Mon Sep 17 00:00:00 2001 From: Min RK Date: Thu, 23 Jan 2020 15:54:26 +0100 Subject: [PATCH 19/19] consolidate base login_handler, callback_handler definitions --- oauthenticator/oauth2.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/oauthenticator/oauth2.py b/oauthenticator/oauth2.py index e814d9dd..4813c2bf 100644 --- a/oauthenticator/oauth2.py +++ b/oauthenticator/oauth2.py @@ -227,6 +227,7 @@ class OAuthenticator(Authenticator): """ login_handler = OAuthLoginHandler + callback_handler = OAuthCallbackHandler authorize_url = Unicode( config=True, help="""The authenticate url for initiating oauth""" @@ -301,8 +302,6 @@ def _validate_server_cert_default(self): def login_url(self, base_url): return url_path_join(base_url, 'oauth_login') - login_handler = "Specify login handler class in subclass" - callback_handler = OAuthCallbackHandler def get_callback_url(self, handler=None): """Get my OAuth redirect URL