forked from jupyterhub/oauthenticator
-
Notifications
You must be signed in to change notification settings - Fork 0
/
globus.py
343 lines (298 loc) · 13.5 KB
/
globus.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
"""
Custom Authenticator to use Globus OAuth2 with JupyterHub
"""
import base64
import os
import pickle
import urllib
from jupyterhub.auth import LocalAuthenticator
from tornado.httpclient import HTTPRequest
from tornado.web import HTTPError
from traitlets import Bool
from traitlets import default
from traitlets import List
from traitlets import Set
from traitlets import Unicode
from .oauth2 import OAuthenticator
from .oauth2 import OAuthLogoutHandler
class GlobusLogoutHandler(OAuthLogoutHandler):
"""
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 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:
# 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)
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."""
await super().handle_logout()
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:
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'] = {}
await user.save_auth_state(state)
class GlobusOAuthenticator(OAuthenticator):
"""The Globus OAuthenticator handles both authorization and passing
transfer tokens to the spawner."""
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"
globus_groups_url = Unicode(help="Globus URL to get list of user's Groups.").tag(
config=True
)
@default("globus_groups_url")
def _globus_groups_url_default(self):
return "https://groups.api.globus.org/v2/groups/my_groups"
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)
def _identity_provider_default(self):
return os.getenv('IDENTITY_PROVIDER', '')
username_from_email = Bool(
help="""Create username from email address, not preferred username. If
an identity provider is specified, email address must be from the same
domain. Email scope will be set automatically."""
).tag(config=True)
@default("username_from_email")
def _username_from_email_default(self):
return False
exclude_tokens = List(
help="""Exclude tokens from being passed into user environments
when they start notebooks, Terminals, etc."""
).tag(config=True)
def _exclude_tokens_default(self):
return ['auth.globus.org', 'groups.api.globus.org']
def _scope_default(self):
scopes = [
'openid',
'profile',
'urn:globus:auth:scope:transfer.api.globus.org:all',
]
if self.allowed_globus_groups or self.admin_globus_groups:
scopes.append(
'urn:globus:auth:scope:groups.api.globus.org:view_my_groups_and_memberships'
)
if self.username_from_email:
scopes.append('email')
return scopes
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', '')
revoke_tokens_on_logout = Bool(
help="""Revoke tokens so they cannot be used again. Single-user servers
MUST be restarted after logout in order to get a fresh working set of
tokens."""
).tag(config=True)
def _revoke_tokens_on_logout_default(self):
return False
allowed_globus_groups = Set(
help="""Allow members of defined Globus Groups to access JupyterHub. Users in an
admin Globus Group are also automatically allowed. Groups are specified with their UUIDs. Setting this will
add the Globus Groups scope."""
).tag(config=True)
admin_globus_groups = Set(
help="""Set members of defined Globus Groups as JupyterHub admin users.
These users are automatically allowed to login to JupyterHub. Groups are specified with
their UUIDs. Setting this will add the Globus Groups scope."""
).tag(config=True)
async def pre_spawn_start(self, user, spawner):
"""Add tokens to the spawner whenever the spawner starts a notebook.
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
state = await user.get_auth_state()
if state:
globus_data = base64.b64encode(pickle.dumps(state))
spawner.environment['GLOBUS_DATA'] = globus_data.decode('utf-8')
async def authenticate(self, handler, data=None):
"""
Authenticate with globus.org. Usernames (and therefore Jupyterhub
accounts) will correspond to a Globus User ID, so [email protected]
will have the 'foouser' account in Jupyterhub.
"""
# Complete login and exchange the code for tokens.
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),
)
token_json = await self.fetch(req)
# 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 self.fetch(req)
username = self.get_username(user_resp)
# 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
}
user_info = {
'name': username,
'auth_state': {
'client_id': self.client_id,
'tokens': by_resource_server,
},
}
use_globus_groups = False
user_allowed = False
if self.allowed_globus_groups or self.admin_globus_groups:
# If any of these configurations are set, user must be in the allowed or admin Globus Group
use_globus_groups = True
user_group_ids = set()
# Get Groups access token, may not be in dict headed to auth state
for token_dict in tokens:
if token_dict['resource_server'] == 'groups.api.globus.org':
groups_token = token_dict['access_token']
# Get list of user's Groups
groups_headers = self.get_default_headers()
groups_headers['Authorization'] = 'Bearer {}'.format(groups_token)
req = HTTPRequest(
self.globus_groups_url, method='GET', headers=groups_headers
)
groups_resp = await self.fetch(req)
# Build set of Group IDs
for group in groups_resp:
user_group_ids.add(group['id'])
if user_group_ids & self.allowed_globus_groups:
user_allowed = True
if self.admin_globus_groups:
# Admin users are being managed via Globus Groups
# Default to False
user_info['admin'] = False
if user_group_ids & self.admin_globus_groups:
# User is an admin, admins allowed by default
user_allowed = user_info['admin'] = True
if user_allowed or not use_globus_groups:
return user_info
else:
self.log.warning('{} not in an allowed Globus Group'.format(username))
return None
def get_username(self, user_data):
# It's possible for identity provider domains to be namespaced
# https://docs.globus.org/api/auth/specification/#identity_provider_namespaces # noqa
username_field = 'preferred_username'
if self.username_from_email:
username_field = 'email'
username, domain = user_data.get(username_field).split('@', 1)
if self.identity_provider and domain != self.identity_provider:
raise HTTPError(
403,
'This site is restricted to {} accounts. Please link your {}'
' account at {}.'.format(
self.identity_provider,
self.identity_provider,
'globus.org/app/account',
),
)
return username
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:
services = { 'transfer.api.globus.org': {'access_token': 'token'}, ...
<Additional services>...
}
"""
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]
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 self.fetch(req)
class LocalGlobusOAuthenticator(LocalAuthenticator, GlobusOAuthenticator):
"""A version that mixes in local system user creation"""
pass