From b180ccc4ef73acb6b66a759d5581ac3962815cfd Mon Sep 17 00:00:00 2001 From: "Nickolaus D. Saint" Date: Tue, 28 Jan 2020 08:44:01 -0800 Subject: [PATCH] Streamline Globus requirements The Globus OAuthenticator currently requires a custom dependency in order to authenticate users. The Globus SDK was originally chosen for its convenience functions in generating URLs and automatically structuring tokens. However, it's not necessary and can be replaced with generic components included in this repo. With these changes, the globus_sdk can also be removed from the Zero to Jupyterhub 'hub' dependencies. While the globus_sdk is extremely useful for transferring data within the Single User Server, in the hub it is only used for the OAuthenticator. --- docs/source/getting-started.rst | 47 +++--- oauthenticator/globus.py | 172 ++++++++++++--------- oauthenticator/tests/test_globus.py | 230 ++++++++++++++++++---------- setup.py | 1 - test-requirements.txt | 1 - 5 files changed, 272 insertions(+), 179 deletions(-) diff --git a/docs/source/getting-started.rst b/docs/source/getting-started.rst index 070a3eb4..0b5b893d 100644 --- a/docs/source/getting-started.rst +++ b/docs/source/getting-started.rst @@ -388,12 +388,11 @@ Set the above settings in your ``jupyterhub_config``: .. code:: python # Tell JupyterHub to create system accounts - from oauthenticator.globus import LocalGlobusOAuthenticator - c.JupyterHub.authenticator_class = LocalGlobusOAuthenticator - c.LocalGlobusOAuthenticator.enable_auth_state = True - c.LocalGlobusOAuthenticator.oauth_callback_url = 'https://[your-host]/hub/oauth_callback' - c.LocalGlobusOAuthenticator.client_id = '[your app client id]' - c.LocalGlobusOAuthenticator.client_secret = '[your app client secret]' + from oauthenticator.globus import GlobusOAuthenticator + c.JupyterHub.authenticator_class = GlobusOAuthenticator + c.GlobusOAuthenticator.oauth_callback_url = 'https://[your-host]/hub/oauth_callback' + c.GlobusOAuthenticator.client_id = '[your app client id]' + c.GlobusOAuthenticator.client_secret = '[your app client secret]' Alternatively you can set env variables for the following: ``OAUTH_CALLBACK_URL``, ``OAUTH_CLIENT_ID``, and @@ -406,13 +405,6 @@ settings related to User Identity, Transfer, and additional security. User Identity ~~~~~~~~~~~~~ -By default, all users are restricted to their *Globus IDs* -(example@globusid.org) with the default Jupyterhub config: - -.. code:: python - - c.GlobusOAuthenticator.identity_provider = 'globusid.org' - If you want to use a *Linked Identity* such as ``malcolm@universityofindependence.edu``, go to your `App Developer page `__ and set *Required Identity @@ -421,12 +413,22 @@ in the config: .. code:: python - c.GlobusOAuthenticator.identity_provider = 'universityofindependence.edu' + c.GlobusOAuthenticator.identity_provider = 'uchicago.edu' + +**Pitfall**: Don't set 'Required Identity Provider' on pre-existing apps! +Previous user login consents will be tied to the identity users initially used +to login, and will continue to be tied to that identity after changing this +setting. Create a new Globus App with your preferred 'Required Identity Provider' +to avoid this problem. Globus Scopes and Transfer ~~~~~~~~~~~~~~~~~~~~~~~~~~ -The default configuration will automatically setup user environments +The following shows how to get tokens into user Notebooks. `You can see how users +use tokens here `__. +If you want a demonstration, you can visit `The Jupyter Globus Demo Server `__. + +The default server configuration will automatically setup user environments with tokens, allowing them to start up python notebooks and initiate Globus Transfers. If you want to transfer data onto your JupyterHub server, it’s suggested you install `Globus Connect @@ -436,21 +438,20 @@ other behavior, you can modify the defaults below: .. code:: python - # Allow Refresh Tokens in user notebooks. Disallow these for increased security, - # allow them for better usability. - c.LocalGlobusOAuthenticator.allow_refresh_tokens = True + # Allow saving user tokens to the database + c.GlobusOAuthenticator.enable_auth_state = True # Default scopes are below if unspecified. Add a custom transfer server if you have one. - c.LocalGlobusOAuthenticator.scope = ['openid', 'profile', 'urn:globus:auth:scope:transfer.api.globus.org:all'] + c.GlobusOAuthenticator.scope = ['openid', 'profile', 'urn:globus:auth:scope:transfer.api.globus.org:all'] # Default tokens excluded from being passed into the spawner environment - c.LocalGlobusOAuthenticator.exclude_tokens = ['auth.globus.org'] + c.GlobusOAuthenticator.exclude_tokens = ['auth.globus.org'] # If the JupyterHub server is an endpoint, for convenience the endpoint id can be # set here. It will show up in the notebook kernel for all users as 'GLOBUS_LOCAL_ENDPOINT'. - c.LocalGlobusOAuthenticator.globus_local_endpoint = '' + c.GlobusOAuthenticator.globus_local_endpoint = '' # Set a custom logout URL for your identity provider - c.LocalGlobusOAuthenticator.logout_redirect_url = 'https://auth.globus.org/v2/web/logout' + c.GlobusOAuthenticator.logout_redirect_url = 'https://globus.org/logout' # For added security, revoke all service tokens when users logout. (Note: users must start # a new server to get fresh tokens, logging out does not shut it down by default) - c.LocalGlobusOAuthenticator.revoke_tokens_on_logout = False + c.GlobusOAuthenticator.revoke_tokens_on_logout = False If you only want to authenticate users with their Globus IDs but don’t want to allow them to do transfers, you can remove diff --git a/oauthenticator/globus.py b/oauthenticator/globus.py index 860b39da..58243756 100644 --- a/oauthenticator/globus.py +++ b/oauthenticator/globus.py @@ -3,30 +3,22 @@ """ import os import pickle +import json import base64 +import urllib -from tornado import web -from tornado.auth import OAuth2Mixin from tornado.web import HTTPError +from tornado.httpclient import HTTPRequest, AsyncHTTPClient 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 jupyterhub.auth import LocalAuthenticator from .oauth2 import OAuthenticator -try: - import globus_sdk -except: - raise ImportError( - 'globus_sdk is not installed, please run ' - '`pip install oauthenticator[globus]` for using Globus oauth.' - ) - - class GlobusLogoutHandler(LogoutHandler): """ Handle custom logout URLs and token revocation. If a custom logout url @@ -36,27 +28,36 @@ class GlobusLogoutHandler(LogoutHandler): """ async def get(self): + # Ensure self.handle_logout() is called before self.default_handle_logout() + # If default_handle_logout() is called first, the user session is popped and + # it's not longer possible to call get_auth_state() to revoke tokens. + # See https://github.com/jupyterhub/jupyterhub/blob/master/jupyterhub/handlers/login.py # noqa + await self.handle_logout() + await self.default_handle_logout() if self.authenticator.logout_redirect_url: - await self.default_handle_logout() - await self.handle_logout() + # super().get() will attempt to render a logout page. Make sure we + # return after the redirect to avoid exceptions. self.redirect(self.authenticator.logout_redirect_url) - else: - await super().get() + return + await super().get() async def handle_logout(self): + """Overridden method for custom logout functionality. Should be called by + Jupyterhub on logout just before destroying the users session to log them out.""" if self.current_user and self.authenticator.revoke_tokens_on_logout: await self.clear_tokens(self.current_user) async def clear_tokens(self, user): + """Revoke and clear user tokens from the database""" state = await user.get_auth_state() if state: - self.authenticator.revoke_service_tokens(state.get('tokens')) + await self.authenticator.revoke_service_tokens(state.get('tokens')) self.log.info( 'Logout: Revoked tokens for user "{}" services: {}'.format( user.name, ','.join(state['tokens'].keys()) ) ) - state['tokens'] = '' + state['tokens'] = {} await user.save_auth_state(state) @@ -67,10 +68,26 @@ class GlobusOAuthenticator(OAuthenticator): login_service = 'Globus' logout_handler = GlobusLogoutHandler + @default("userdata_url") + def _userdata_url_default(self): + return "https://auth.globus.org/v2/oauth2/userinfo" + @default("authorize_url") def _authorize_url_default(self): return "https://auth.globus.org/v2/oauth2/authorize" + @default("revocation_url") + def _revocation_url_default(self): + return "https://auth.globus.org/v2/oauth2/token/revoke" + + revocation_url = Unicode( + help="Globus URL to revoke live tokens." + ).tag(config=True) + + @default("token_url") + def _token_url_default(self): + return "https://auth.globus.org/v2/oauth2/token" + identity_provider = Unicode( help="""Restrict which institution a user can use to login (GlobusID, University of Hogwarts, etc.). This should @@ -79,7 +96,7 @@ def _authorize_url_default(self): ).tag(config=True) def _identity_provider_default(self): - return os.getenv('IDENTITY_PROVIDER', 'globusid.org') + return os.getenv('IDENTITY_PROVIDER', '') exclude_tokens = List( help="""Exclude tokens from being passed into user environments @@ -96,16 +113,6 @@ def _scope_default(self): 'urn:globus:auth:scope:transfer.api.globus.org:all', ] - allow_refresh_tokens = Bool( - help="""Allow users to have Refresh Tokens. If Refresh Tokens are not - allowed, users must use regular Access Tokens which will expire after - a set time. Set to False for increased security, True for increased - convenience.""" - ).tag(config=True) - - 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.""" @@ -139,31 +146,35 @@ async def pre_spawn_start(self, user, spawner): 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) - async def authenticate(self, handler, data=None): """ Authenticate with globus.org. Usernames (and therefore Jupyterhub accounts) will correspond to a Globus User ID, so foouser@globusid.org will have the 'foouser' account in Jupyterhub. """ - code = handler.get_argument("code") - redirect_uri = self.get_callback_url(self) - - client = self.globus_portal_client() - client.oauth2_start_flow( - redirect_uri, - requested_scopes=' '.join(self.scope), - refresh_tokens=self.allow_refresh_tokens, + # Complete login and exchange the code for tokens. + http_client = AsyncHTTPClient() + params = dict( + redirect_uri=self.get_callback_url(handler), + code=handler.get_argument("code"), + grant_type='authorization_code', + ) + req = HTTPRequest(self.token_url, method="POST", + headers=self.get_client_credential_headers(), + body=urllib.parse.urlencode(params), ) - # Doing the code for token for id_token exchange - tokens = client.oauth2_exchange_code_for_tokens(code) - id_token = tokens.decode_id_token(client) + token_response = await http_client.fetch(req) + token_json = json.loads(token_response.body.decode('utf8', 'replace')) + + # Fetch user info at Globus's oauth2/userinfo/ HTTP endpoint to get the username + user_headers = self.get_default_headers() + user_headers['Authorization'] = 'Bearer {}'.format(token_json['access_token']) + req = HTTPRequest(self.userdata_url, method='GET', headers=user_headers) + user_resp = await http_client.fetch(req) + user_json = json.loads(user_resp.body.decode('utf8', 'replace')) # It's possible for identity provider domains to be namespaced # https://docs.globus.org/api/auth/specification/#identity_provider_namespaces # noqa - username, domain = id_token.get('preferred_username').split('@', 1) - + username, domain = user_json.get('preferred_username').split('@', 1) if self.identity_provider and domain != self.identity_provider: raise HTTPError( 403, @@ -174,19 +185,49 @@ async def authenticate(self, handler, data=None): 'globus.org/app/account', ), ) + + # Each token should have these attributes. Resource server is optional, + # and likely won't be present. + token_attrs = ['expires_in', 'resource_server', 'scope', + 'token_type', 'refresh_token', 'access_token'] + # The Auth Token is a bit special, it comes back at the top level with the + # id token. The id token has some useful information in it, but nothing that + # can't be retrieved with an Auth token. + # Repackage the Auth token into a dict that looks like the other tokens + auth_token_dict = {attr_name: token_json.get(attr_name) for attr_name in token_attrs} + # Make sure only the essentials make it into tokens. Other items, such as 'state' are + # not needed after authentication and can be discarded. + other_tokens = [{attr_name: token_dict.get(attr_name) for attr_name in token_attrs} + for token_dict in token_json['other_tokens']] + tokens = other_tokens + [auth_token_dict] + # historically, tokens have been organized by resource server for convenience. + # If multiple scopes are requested from the same resource server, they will be + # combined into a single token from Globus Auth. + by_resource_server = { + token_dict['resource_server']: token_dict + for token_dict in tokens + if token_dict['resource_server'] not in self.exclude_tokens + } return { 'name': username, 'auth_state': { 'client_id': self.client_id, - 'tokens': { - tok: v - for tok, v in tokens.by_resource_server.items() - if tok not in self.exclude_tokens - }, + 'tokens': by_resource_server, }, } - def revoke_service_tokens(self, services): + def get_default_headers(self): + return {"Accept": "application/json", "User-Agent": "JupyterHub"} + + def get_client_credential_headers(self): + headers = self.get_default_headers() + b64key = base64.b64encode( + bytes("{}:{}".format(self.client_id, self.client_secret), "utf8") + ) + headers["Authorization"] = "Basic {}".format(b64key.decode("utf8")) + return headers + + async def revoke_service_tokens(self, services): """Revoke live Globus access and refresh tokens. Revoking inert or non-existent tokens does nothing. Services are defined by dicts returned by tokens.by_resource_server, for example: @@ -194,24 +235,17 @@ def revoke_service_tokens(self, services): ... } """ - client = self.globus_portal_client() - for service_data in services.values(): - client.oauth2_revoke_token(service_data['access_token']) - client.oauth2_revoke_token(service_data['refresh_token']) - - 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', - ) - return self.oauth_callback_url + access_tokens = [token_dict.get('access_token') for token_dict in services.values()] + refresh_tokens = [token_dict.get('refresh_token') for token_dict in services.values()] + all_tokens = [tok for tok in access_tokens + refresh_tokens if tok is not None] + http_client = AsyncHTTPClient() + for token in all_tokens: + req = HTTPRequest(self.revocation_url, + method="POST", + headers=self.get_client_credential_headers(), + body=urllib.parse.urlencode({'token': token}), + ) + await http_client.fetch(req) def logout_url(self, base_url): return url_path_join(base_url, 'logout') diff --git a/oauthenticator/tests/test_globus.py b/oauthenticator/tests/test_globus.py index c6a578eb..53eac951 100644 --- a/oauthenticator/tests/test_globus.py +++ b/oauthenticator/tests/test_globus.py @@ -1,64 +1,119 @@ -from pytest import fixture, mark, raises -from tornado import web, gen +from io import BytesIO +import json +from urllib.parse import parse_qs +from pytest import fixture, raises +from tornado import web +from tornado.httpclient import HTTPResponse + from unittest.mock import Mock -from globus_sdk import ConfidentialAppAuthClient from ..globus import GlobusOAuthenticator, GlobusLogoutHandler -from .mocks import setup_oauth_mock, no_code_test, mock_handler +from .mocks import setup_oauth_mock, mock_handler def user_model(username): """Return a user model""" return { - 'login': username, + 'preferred_username': username, } -@fixture -def mock_globus_sdk(monkeypatch): - """Mock the globus_sdk request for 'oauth2_exchange_code_for_tokens', and - mock some of the items within the returned 'Tokens' class. """ - - class Tokens: +def revoke_token_request_handler(request): + assert request.method == 'POST', request.method + auth_header = request.headers.get('Authorization') + if auth_header: + resp = BytesIO(json.dumps({'active': False}).encode('utf8')) + return HTTPResponse(request=request, code=200, buffer=resp) + else: + return HTTPResponse(request=request, code=401) - by_resource_server = { - 'transfer.api.globus.org': {'access_token': 'TRANSFER_TOKEN'}, - 'auth.globus.org': {'access_token': 'AUTH_TOKEN'} - } - id_token = {'preferred_username': 'wash@globusid.org'} +@fixture +def mock_globus_token_response(): + return { + 'access_token': 'de48bedc44b79937f7aa67', + 'id_token': 'ClRha2UgbXkgbG92ZSwgdGFrZSBteSBsYW5kClRha2UgbWUgd2hlcmUgSS' + 'BjYW5ub3Qgc3RhbmQKSSBkb24ndCBjYXJlIGNhdXNlIEknbSBzdGlsbCBm' + 'cmVlCllvdSBjYW4ndCB0YWtlIHRoZSBza3kgZnJvbSBtZQpUYWtlIG1lIG' + '91dCwgdG8gdGhlIGJsYWNrClRlbGwgJ2VtIEkgYWluJ3QgY29taW5nIGJh' + 'Y2sKQnVybiB0aGUgbGFuZCBhbmQgYm9pbCB0aGUgc2VhCllvdSBjYW4ndC' + 'B0YWtlIHRoZSBza3kgZnJvbSBtZQpUaGVyZSdzIG5vIHBsYWNlIEkgY2Fu' + 'IGJlClNpbmNlIEkgZm91bmQgc2VyZW5pdHkKWW91IGNhbid0IHRha2UgdG' + 'hlIHNreSBmcm9tIG1lCg==', + 'expires_in': 172800, + 'resource_server': 'auth.globus.org', + 'token_type': 'Bearer', + 'state': '5a5929fa3c0210042c2fbb455e1e39d0', + 'other_tokens': [{ + 'access_token': 'fceb9836f9b6d1ae7d', + 'expires_in': 172800, + 'resource_server': 'transfer.api.globus.org', + 'token_type': 'Bearer', + 'state': '5a5929fa3c0210042c2fbb455e1e39d0', + 'scope': 'urn:globus:auth:scope:transfer.api.globus.org:all'}], + 'scope': 'profile openid'} - def decode_id_token(self, client): - return self.id_token - tokens = Tokens() - monkeypatch.setattr( - ConfidentialAppAuthClient, - 'oauth2_exchange_code_for_tokens', - lambda self, code: tokens - ) - return tokens +@fixture +def globus_tokens_by_resource_server(mock_globus_token_response): + token_attrs = ['expires_in', 'resource_server', 'scope', + 'token_type', 'refresh_token', 'access_token'] + auth_token_dict = {attr_name: mock_globus_token_response.get(attr_name) for attr_name in + token_attrs} + other_tokens = [{attr_name: token_dict.get(attr_name) for attr_name in token_attrs} + for token_dict in mock_globus_token_response['other_tokens']] + tokens = other_tokens + [auth_token_dict] + return {token_dict['resource_server']: token_dict for token_dict in tokens} + + +def set_extended_token_response(client, host, access_token_path, new_token_response): + """The default client fixture does a nice job of checking the access code + response while returning tokens in the oauth2 spec, but Globus returns + a bunch of other tokens, including an id_token. We want to make sure we + capture the full Globus token response. This will attach the dict + new_token_response to the built-in test response if it returns successfully""" + # Find the existing endpoint, function pair in client.hosts + url, func = next(filter(lambda host: host[0]==access_token_path, + client.hosts[host])) + # Wrap the built-in token response with our custom response, but only if + # it returns successfully with an access token! + def custom_token_response(request): + response = func(request) + if response.get('access_token'): + # The original access_token is checked, + new_token_response['access_token'] = response['access_token'] + return new_token_response + else: + return response + + # Return all existing paths with the addition of our custom wrapped handler. + hosts = filter(lambda chost: chost[0] != access_token_path, client.hosts[host]) + client.add_host(host, [(url, custom_token_response)] + list(hosts)) + return client @fixture -def globus_client(client): +def globus_client(client, mock_globus_token_response): setup_oauth_mock( client, - host=['auth.globus.org'], + host='auth.globus.org', access_token_path='/v2/oauth2/token', - user_path='/userinfo', + user_path='/v2/oauth2/userinfo', token_type='bearer', + token_request_style='post', ) + set_extended_token_response(client, 'auth.globus.org', '/v2/oauth2/token', + mock_globus_token_response) return client @fixture -def mock_globus_user(mock_globus_sdk): +def mock_globus_user(globus_tokens_by_resource_server): class User: name = 'Wash' - state = {'tokens': mock_globus_sdk.by_resource_server} + state = {'tokens': globus_tokens_by_resource_server} async def get_auth_state(self): return self.state @@ -68,9 +123,9 @@ async def save_auth_state(self, state): return User() -async def test_globus(globus_client, mock_globus_sdk): +async def test_globus(globus_client): authenticator = GlobusOAuthenticator() - handler = globus_client.handler_for_user(user_model('wash')) + handler = globus_client.handler_for_user(user_model('wash@uflightacademy.edu')) data = await authenticator.authenticate(handler) assert data['name'] == 'wash' tokens = list(data['auth_state']['tokens'].keys()) @@ -84,70 +139,83 @@ async def test_globus_pre_spawn_start(mock_globus_user): await authenticator.pre_spawn_start(mock_globus_user, spawner) assert 'GLOBUS_DATA' in spawner.environment - -async def test_allow_refresh_tokens(globus_client, mock_globus_sdk, monkeypatch): +def test_globus_defaults(): authenticator = GlobusOAuthenticator() - # Sanity check, this field should be set to True - assert authenticator.allow_refresh_tokens is True - authenticator.allow_refresh_tokens = False - monkeypatch.setattr( - ConfidentialAppAuthClient, - 'oauth2_start_flow', - Mock() - ) - handler = globus_client.handler_for_user(user_model('wash')) - await authenticator.authenticate(handler) - ConfidentialAppAuthClient.oauth2_start_flow.assert_called_with( - authenticator.get_callback_url(None), - requested_scopes=' '.join(authenticator.scope), - refresh_tokens=False - ) + assert all('https://auth.globus.org' in url for url in [ + authenticator.userdata_url, + authenticator.authorize_url, + authenticator.revocation_url, + authenticator.token_url, + ]) + assert authenticator.scope == [ + 'openid', 'profile', 'urn:globus:auth:scope:transfer.api.globus.org:all' + ] -async def test_restricted_domain(globus_client, mock_globus_sdk): - mock_globus_sdk.id_token = {'preferred_username': 'wash@serenity.com'} +async def test_restricted_domain(globus_client): authenticator = GlobusOAuthenticator() authenticator.identity_provider = 'alliance.gov' - handler = globus_client.handler_for_user(user_model('wash')) + handler = globus_client.handler_for_user(user_model('wash@uflightacademy.edu')) with raises(web.HTTPError) as exc: await authenticator.authenticate(handler) assert exc.value.status_code == 403 -async def test_namespaced_domain(globus_client, mock_globus_sdk): - mock_globus_sdk.id_token = {'preferred_username': - 'wash@legitshipping.com@serenity.com'} +async def test_namespaced_domain(globus_client): authenticator = GlobusOAuthenticator() # Allow any idp authenticator.identity_provider = '' - handler = globus_client.handler_for_user(user_model('wash')) + um = user_model('wash@legitshipping.com@serenity.com') + handler = globus_client.handler_for_user(um) data = await authenticator.authenticate(handler) assert data['name'] == 'wash' -async def test_token_exclusion(globus_client, mock_globus_sdk): +async def test_token_exclusion(globus_client): authenticator = GlobusOAuthenticator() authenticator.exclude_tokens = [ 'transfer.api.globus.org', 'auth.globus.org' ] - handler = globus_client.handler_for_user(user_model('wash')) + handler = globus_client.handler_for_user(user_model('wash@uflightacademy.edu')) data = await authenticator.authenticate(handler) assert data['name'] == 'wash' assert list(data['auth_state']['tokens'].keys()) == [] -def test_revoke_tokens(monkeypatch): - monkeypatch.setattr( - ConfidentialAppAuthClient, - 'oauth2_revoke_token', - Mock() - ) +async def test_revoke_tokens(globus_client, mock_globus_user): + + # Wrap the revocation host to 'revoke' tokens by setting them in user auth + # state. This way, we can get feedback to tell if the token was actually + # sent to our 'host' + def tok_revoke(request): + resp = revoke_token_request_handler(request) + token = parse_qs(request.body.decode('utf8'))['token'][0] + for token_dict in mock_globus_user.state['tokens'].values(): + if token_dict['access_token'] == token: + token_dict['access_token'] = 'token_revoked' + if token_dict['refresh_token'] == token: + token_dict['refresh_token'] = 'token_revoked' + return resp + # Add the token revocation endpoint. It's the only revocation endpoint we need. + globus_client.add_host('auth.globus.org', [('/v2/oauth2/token/revoke', + tok_revoke)]) + # Add refresh tokens to ensure those get revoked too. + mock_globus_user.state['tokens']['auth.globus.org']['refresh_token'] = \ + 'my_active_auth_refresh_token' + mock_globus_user.state['tokens']['transfer.api.globus.org']['refresh_token'] = \ + 'my_active_transfer_refresh_token' + + # Revoke the tokens! authenticator = GlobusOAuthenticator() - service = {'transfer.api.globus.org': {'access_token': 'foo', - 'refresh_token': 'bar'}} - authenticator.revoke_service_tokens(service) - assert ConfidentialAppAuthClient.oauth2_revoke_token.called + await authenticator.revoke_service_tokens(mock_globus_user.state['tokens']) + + # Check tokens were properly revoked. + user_tokens = mock_globus_user.state['tokens'] + assert user_tokens['auth.globus.org']['access_token'] == 'token_revoked' + assert user_tokens['auth.globus.org']['access_token'] == 'token_revoked' + assert user_tokens['transfer.api.globus.org']['access_token'] == 'token_revoked' + assert user_tokens['transfer.api.globus.org']['access_token'] == 'token_revoked' async def test_custom_logout(monkeypatch, mock_globus_user): @@ -155,11 +223,7 @@ async def test_custom_logout(monkeypatch, mock_globus_user): authenticator = GlobusOAuthenticator() logout_handler = mock_handler(GlobusLogoutHandler, authenticator=authenticator) - monkeypatch.setattr( - web.RequestHandler, - 'redirect', - Mock() - ) + monkeypatch.setattr(web.RequestHandler, 'redirect', Mock()) logout_handler.clear_login_cookie = Mock() logout_handler.get_current_user = Mock(return_value=mock_globus_user) logout_handler._jupyterhub_user = mock_globus_user @@ -177,26 +241,22 @@ async def test_custom_logout(monkeypatch, mock_globus_user): assert logout_handler.clear_login_cookie.called -async def test_logout_revokes_tokens(monkeypatch, mock_globus_user): - +async def test_logout_revokes_tokens(globus_client, monkeypatch, mock_globus_user): + globus_client.add_host('auth.globus.org', [('/v2/oauth2/token/revoke', + revoke_token_request_handler)]) authenticator = GlobusOAuthenticator() - logout_handler = mock_handler(GlobusLogoutHandler, - authenticator=authenticator) - monkeypatch.setattr( - web.RequestHandler, - 'redirect', - Mock() - ) + logout_handler = mock_handler(GlobusLogoutHandler, authenticator=authenticator) + + # Setup + monkeypatch.setattr(web.RequestHandler, 'redirect', Mock()) logout_handler.get_current_user = Mock(return_value=mock_globus_user) logout_handler._jupyterhub_user = mock_globus_user monkeypatch.setitem(logout_handler.settings, 'statsd', Mock()) monkeypatch.setitem(logout_handler.settings, 'login_url', '') logout_handler.clear_login_cookie = Mock() - authenticator.revoke_service_tokens = Mock() authenticator.revoke_tokens_on_logout = True await logout_handler.get() - assert authenticator.revoke_service_tokens.called auth_state = await mock_globus_user.get_auth_state() - assert auth_state == {'tokens': ''} + assert auth_state == {'tokens': {}} diff --git a/setup.py b/setup.py index f45aa70b..4533dfe0 100644 --- a/setup.py +++ b/setup.py @@ -113,7 +113,6 @@ def run(self): setup_args['extras_require'] = { 'googlegroups': ['google-api-python-client==1.7.11', 'google-auth-oauthlib==0.4.1'], - 'globus': ['globus_sdk[jwt]>=1.0.0,<2.0.0'] } def main(): diff --git a/test-requirements.txt b/test-requirements.txt index d1578a9a..14afbc3f 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,5 +1,4 @@ -r ./requirements.txt -.[globus] .[googlegroups] codecov flake8