-
Notifications
You must be signed in to change notification settings - Fork 367
Expand file tree
/
Copy pathoauth2.py
More file actions
1589 lines (1299 loc) · 56.8 KB
/
oauth2.py
File metadata and controls
1589 lines (1299 loc) · 56.8 KB
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
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
Base classes for use by OAuth2 based JupyterHub authenticator classes.
Founded based on work by Kyle Kelley (@rgbkrk)
"""
import base64
import hashlib
import json
import os
import secrets
import uuid
from functools import reduce
from inspect import isawaitable
from urllib.parse import quote, urlencode, urlparse, urlunparse
import jwt
from jupyterhub.auth import Authenticator
from jupyterhub.handlers import BaseHandler, LogoutHandler
from jupyterhub.utils import url_path_join
from tornado import web
from tornado.auth import OAuth2Mixin
from tornado.httpclient import AsyncHTTPClient, HTTPClientError, HTTPRequest
from tornado.httputil import url_concat
from tornado.log import app_log
from traitlets import (
Any,
Bool,
Callable,
Dict,
List,
Set,
Unicode,
Union,
default,
observe,
validate,
)
def guess_callback_uri(protocol, host, hub_server_url):
return f'{protocol}://{host}{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")
def _deserialize_state(b64_state):
"""Deserialize OAuth state as serialized in _serialize_state"""
if isinstance(b64_state, str):
b64_state = b64_state.encode("ascii")
try:
json_state = base64.urlsafe_b64decode(b64_state).decode("utf8")
except ValueError:
app_log.error(f"Failed to b64-decode state: {b64_state}")
return {}
try:
return json.loads(json_state)
except ValueError:
app_log.error(f"Failed to json-decode state: {json_state}")
return {}
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.token_url
@property
def _OAUTH_USERINFO_URL(self):
return self.authenticator.userdata_url
def set_state_cookie(self, state_cookie_value):
self._set_cookie(
STATE_COOKIE_NAME, state_cookie_value, expires_days=1, httponly=True
)
def _generate_pkce_params(self):
# https://datatracker.ietf.org/doc/html/rfc7636#section-4
# It is recommended that the output of the random number generator creates
# a 32-octet sequence which is base64url-encoded to produce a 43-octet URL
# safe string to use as the code verifier.
code_verifier = secrets.token_urlsafe(32)
code_challenge = hashlib.sha256(code_verifier.encode("utf-8")).digest()
code_challenge_base64 = (
base64.urlsafe_b64encode(code_challenge).decode("utf-8").rstrip("=")
)
return code_verifier, code_challenge_base64
def _generate_state_id(self):
return uuid.uuid4().hex
def _get_next_url(self):
next_url = self.get_argument("next", None)
if next_url:
# avoid browsers treating \ as /
next_url = next_url.replace("\\", quote("\\"))
# disallow hostname-having urls,
# force absolute path redirect
urlinfo = urlparse(next_url)
next_url = urlinfo._replace(
scheme="", netloc="", path="/" + urlinfo.path.lstrip("/")
).geturl()
return next_url
def get(self):
redirect_uri = self.authenticator.get_callback_url(self)
token_params = self.authenticator.extra_authorize_params.copy()
self.log.info(f"OAuth redirect: {redirect_uri}")
state_id = self._generate_state_id()
next_url = self._get_next_url()
state = {"state_id": state_id, "next_url": next_url}
if self.authenticator.enable_pkce:
code_verifier, code_challenge = self._generate_pkce_params()
state["code_verifier"] = code_verifier
token_params["code_challenge"] = code_challenge
token_params["code_challenge_method"] = "S256"
cookie_state = _serialize_state(state)
self.set_state_cookie(cookie_state)
authorize_state = _serialize_state({"state_id": state_id})
token_params["state"] = authorize_state
self.authorize_redirect(
redirect_uri=redirect_uri,
client_id=self.authenticator.client_id,
scope=self.authenticator.scope,
extra_params=token_params,
response_type="code",
)
class OAuthCallbackHandler(BaseHandler):
"""Basic handler for OAuth callback. Calls authenticator to verify username."""
_state_cookie = None
def get_state_cookie(self):
"""Get OAuth state from cookies
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.clear_cookie(STATE_COOKIE_NAME)
return self._state_cookie
def get_state_url(self):
"""Get OAuth state from URL parameters
to be compared with the value in cookies
"""
return self.get_argument("state")
def check_state(self):
"""Verify OAuth state
compare value in cookie with redirect url param
"""
cookie_state = self.get_state_cookie()
url_state = self.get_state_url()
if not cookie_state:
raise web.HTTPError(400, "OAuth state missing from cookies")
if not url_state:
raise web.HTTPError(400, "OAuth state missing from URL")
cookie_state_id = _deserialize_state(cookie_state).get('state_id')
url_state_id = _deserialize_state(url_state).get('state_id')
if cookie_state_id != url_state_id:
self.log.warning(
f"OAuth state mismatch: {cookie_state_id} != {url_state_id}"
)
raise web.HTTPError(400, "OAuth state mismatch")
def check_error(self):
"""Check the OAuth code"""
error = self.get_argument("error", False)
if error:
message = self.get_argument("error_description", error)
raise web.HTTPError(400, f"OAuth error: {message}")
def check_code(self):
"""Check the OAuth code"""
if not self.get_argument("code", False):
raise web.HTTPError(400, "OAuth callback made without a code")
def check_arguments(self):
"""Validate the arguments of the redirect
Default:
- check for oauth-standard error, error_description arguments
- check that there's a code
- check that state matches
"""
self.check_error()
self.check_code()
self.check_state()
def append_query_parameters(self, url, exclude=None):
"""JupyterHub 1.2 appends query parameters by default in get_next_url
This is not appropriate for oauth callback handlers, where params are oauth state, code, etc.
Override the method used to append parameters to next_url to not preserve any parameters
"""
return url
def get_next_url(self, user=None):
"""Get the redirect target from the state field"""
state = self.get_state_cookie()
if state:
next_url = _deserialize_state(state).get("next_url")
if next_url:
return next_url
# JupyterHub 0.8 adds default .get_next_url for a fallback
if hasattr(BaseHandler, "get_next_url"):
return super().get_next_url(user)
return url_path_join(self.hub.server.base_url, "home")
async def get(self):
self.check_arguments()
user = await self.login_user()
if user is None:
raise web.HTTPError(403, self.authenticator.custom_403_message)
self.redirect(self.get_next_url(user))
class OAuthLogoutHandler(LogoutHandler):
async def handle_logout(self):
self.clear_cookie(STATE_COOKIE_NAME)
async def render_logout_page(self):
if self.authenticator.logout_redirect_url:
self.redirect(self.authenticator.logout_redirect_url)
return
return await super().render_logout_page()
class OAuthenticator(Authenticator):
"""
Base class for OAuthenticators.
Subclasses should, in an increasing level of customization:
- Override the constant `user_auth_state_key`
- Override various config's default values, such as
`authorize_url`, `token_url`, `userdata_url`, and `login_service`.
- Override various methods called by :meth:`authenticate`, which
subclasses should not override.
- Override handler classes such as `login_handler`, `callback_handler`, and
`logout_handler`.
"""
login_handler = OAuthLoginHandler
callback_handler = OAuthCallbackHandler
logout_handler = OAuthLogoutHandler
# user_auth_state_key represents the name of the key in the `auth_state`
# dictionary that user info will be saved
user_auth_state_key = "oauth_user"
login_service = Unicode(
"OAuth 2.0",
config=True,
help="""
Name of the login service or identity provider that this authenticator
is using to authenticate users.
This config influences the text on a button shown to unauthenticated
users before they click it to login, assuming :attr:`auto_login` isn't
configured True.
The login button's text will be "Login with <login_service>".
""",
)
allow_all = Bool(
False,
config=True,
help="""
Allow all authenticated users to login.
Overrides all other `allow` configuration.
.. versionadded:: 16.0
""",
)
allow_existing_users = Bool(
False,
config=True,
help="""
Allow existing users to login.
Enable this if you want to manage user access via the JupyterHub admin page (/hub/admin).
With this enabled, all users present in the JupyterHub database are allowed to login.
This has the effect of any user who has _previously_ been allowed to login
via any means will continue to be allowed until the user is deleted via the /hub/admin page
or REST API.
.. warning::
Before enabling this you should review the existing users in the
JupyterHub admin panel at `/hub/admin`. You may find users existing
there because they have previously been declared in config such as
`allowed_users` or allowed to sign in.
.. warning::
When this is enabled and you wish to remove access for one or more
users previously allowed, you must make sure that they
are removed from the jupyterhub database. This can be tricky to do
if you stop allowing a group of externally managed users for example.
With this enabled, JupyterHub admin users can visit `/hub/admin` or use
JupyterHub's REST API to add and remove users to manage who can login.
.. versionadded:: 16.0
.. versionchanged:: 16.0
Before this config was available, the default behavior was to allow
existing users if `allowed_users` was configured with one or more
user.
""",
)
allowed_groups = Set(
Unicode(),
config=True,
help="""
Allow members of selected JupyterHub groups to log in.
Requires :attr:`manage_groups` to also be `True`.
Typically also requires :attr:`auth_state_groups_key` to be configured to populate the JupyterHub groups.
This option is *independent* of other configuration such as :attr:`.GitLabOAuthenticator.allowed_gitlab_groups`,
which do not populate the *JupyterHub* groups,
and do not require :attr:`manage_groups` to be True.
.. versionadded:: 17
Previously available only on :class:`.GenericOAuthenticator`
""",
)
admin_groups = Set(
Unicode(),
config=True,
help="""
Allow members of selected groups to sign in and consider them as
JupyterHub admins.
If this is set and a user isn't part of one of these groups or listed in
:attr:`admin_users`, a user signing in will have their admin status revoked.
Requires :attr:`manage_groups` to also be `True`.
.. versionadded:: 17
Previously available only on :class:`.GenericOAuthenticator`
""",
)
auth_state_groups_key = Union(
[Unicode(), Callable()],
config=True,
help="""
Determine groups this user belongs based on contents of auth_state.
Can be a string key name (use periods for nested keys), or a callable
that accepts the auth state (as a dict) and returns the groups list.
Callables may be async.
Requires :attr:`manage_groups` to also be `True`.
.. versionadded:: 17.0
Previously available as :attr:`.GenericOAuthenticator.claim_groups_key`
""",
)
modify_auth_state_hook = Callable(
config=True,
default_value=None,
allow_none=True,
help="""
Callable to modify `auth_state`
Will be called with the Authenticator instance and the existing auth_state dictionary
and must return the new auth_state dictionary::
auth_state = [await] modify_auth_state_hook(authenticator, auth_state)
This hook is called *before* populating group membership,
so can be used to make additional requests to populate additional fields
which may then be consumed by :attr:`auth_state_groups_key` to populate groups.
This hook may be async.
.. versionadded: 17.0
""",
)
@observe("allowed_groups", "admin_groups", "auth_state_groups_key")
def _requires_manage_groups(self, change):
"""
Validate that group management keys are only set when manage_groups is also True
"""
if change.new:
if not self.manage_groups:
raise ValueError(
f'{change.owner.__class__.__name__}.{change.name} requires {change.owner.__class__.__name__}.manage_groups to also be set'
)
authorize_url = Unicode(
config=True,
help="""
The URL to where the user is to be redirected initially based on the
OAuth2 protocol. The user will be redirected back with an
`authorization grant code`_ after authenticating successfully with the
identity provider.
.. _authorization grant code: https://www.rfc-editor.org/rfc/rfc6749#section-1.3.1
For more context, see the `Protocol Flow section
<https://www.rfc-editor.org/rfc/rfc6749#section-1.2>`_ in the OAuth2
standard document, specifically steps A-B.
""",
)
@default("authorize_url")
def _authorize_url_default(self):
return os.environ.get("OAUTH2_AUTHORIZE_URL", "")
token_url = Unicode(
config=True,
help="""
The URL to where this authenticator makes a request to acquire an
`access token`_ based on the authorization code received by the user
returning from the :attr:`authorize_url`.
.. _access token: https://www.rfc-editor.org/rfc/rfc6749#section-1.4
For more context, see the `Protocol Flow section
<https://www.rfc-editor.org/rfc/rfc6749#section-1.2>`_ in the OAuth2
standard document, specifically steps C-D.
""",
)
@default("token_url")
def _token_url_default(self):
return os.environ.get("OAUTH2_TOKEN_URL", "")
userdata_from_id_token = Bool(
False,
config=True,
help="""
Extract user details from an id token received via a request to
:attr:`token_url`, rather than making a follow-up request to the
userinfo endpoint :attr:`userdata_url`.
Should only be used if :attr:`token_url` uses HTTPS, to ensure
token authenticity.
For more context, see `Authentication using the Authorization
Code Flow
<https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth>`_
in the OIDC Core standard document.
""",
)
userdata_url = Unicode(
config=True,
help="""
The URL to where this authenticator makes a request to acquire user
details with an access token received via a request to the
:attr:`token_url`.
For more context, see the `Protocol Flow section
<https://www.rfc-editor.org/rfc/rfc6749#section-1.2>`_ in the OAuth2
standard document, specifically steps E-F.
Incompatible with :attr:`userdata_from_id_token`.
""",
)
@default("userdata_url")
def _userdata_url_default(self):
return os.environ.get("OAUTH2_USERDATA_URL", "")
@validate("userdata_url")
def _validate_userdata_url(self, proposal):
if proposal.value and self.userdata_from_id_token:
raise ValueError(
"Cannot specify both authenticator.userdata_url and authenticator.userdata_from_id_token."
)
return proposal.value
username_claim = Union(
[Unicode(os.environ.get('OAUTH2_USERNAME_KEY', 'username')), Callable()],
config=True,
help="""
When `userdata_url` returns a json response, the username will be taken
from this key.
Can be a string key name or a callable that accepts the returned
userdata 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 or doing other post processing.
What keys are available will depend on the scopes requested and the
authenticator used.
""",
)
# Enable refresh_pre_spawn by default if self.enable_auth_state
@default("refresh_pre_spawn")
def _refresh_pre_spawn_default(self):
if self.enable_auth_state:
return True
return False
refresh_user_hook = Callable(
config=True,
default_value=None,
allow_none=True,
help="""
Hook for refreshing user auth info.
If given, allows overriding the `refresh_user` behavior.
Will be called as::
refreshed = await refresh_user_hook(authenticator, user, auth_state)
`refresh_user_hook` _may_ be async.
where `refreshed` can be:
- True (no change)
- False (require new login)
- auth_model (dict - the new auth model, if anything should be changed)
- None (proceed with default refresh_user behavior -
allows overriding refresh_user behavior for _some_ users)
.. versionadded:: 17.3
""",
)
logout_redirect_url = Unicode(
config=True,
help="""
When configured, users are not presented with the JupyterHub logout
page, but instead redirected to this destination.
""",
)
@default("logout_redirect_url")
def _logout_redirect_url_default(self):
return os.getenv("OAUTH_LOGOUT_REDIRECT_URL", "")
# Originally a GenericOAuthenticator only trait
userdata_params = Dict(
config=True,
help="""
Userdata params to get user data login information.
""",
)
# Originally a GenericOAuthenticator only trait
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.
""",
)
# Originally a GenericOAuthenticator only trait
token_params = Dict(
config=True,
help="""
Extra parameters for first POST request exchanging the OAuth code for an
Access Token
""",
)
custom_403_message = Unicode(
"Sorry, you are not currently authorized to use this hub. Please contact the hub administrator.",
config=True,
help="""
The message to be shown when user was not allowed
""",
)
scope = List(
Unicode(),
config=True,
help="""
The OAuth scopes to request.
See the OAuth documentation of your OAuth provider for options.
""",
)
allowed_scopes = List(
Unicode(),
config=True,
help="""
Allow users who have been granted *all* these scopes to log in.
We request all the scopes listed in the 'scope' config, but only a
subset of these may be granted by the authorization server. This may
happen if the user does not have permissions to access a requested
scope, or has chosen to not give consent for a particular scope. If the
scopes listed in this config are not granted, the user will not be
allowed to log in.
The granted scopes will be part of the access token (fetched from self.token_url).
See https://datatracker.ietf.org/doc/html/rfc6749#section-3.3 for more
information.
See the OAuth documentation of your OAuth provider for various options.
""",
)
@validate('allowed_scopes')
def _allowed_scopes_validation(self, proposal):
# allowed scopes must be a subset of requested scopes
if set(proposal.value) - set(self.scope):
raise ValueError(
f"Allowed scopes must be a subset of requested scopes. {self.scope} is requested but {proposal.value} is allowed"
)
return proposal.value
extra_authorize_params = Dict(
config=True,
help="""
Extra GET params to send along with the initial OAuth request to the
OAuth provider.
""",
)
oauth_callback_url = Unicode(
os.getenv("OAUTH_CALLBACK_URL", ""),
config=True,
help="""
Callback URL to use.
When registering an OAuth2 application with an identity provider, this
is typically called the redirect url.
Should very likely be set to `https://[your-domain]/hub/oauth_callback`.
""",
)
# Originally a GenericOAuthenticator only trait
basic_auth = Bool(
os.environ.get("OAUTH2_BASIC_AUTH", "False").lower() in {"true", "1"},
config=True,
help="""
Whether or to use HTTP Basic authentication instead of form based
authentication in requests to :attr:`token_url`.
When using HTTP Basic authentication, a HTTP header is set with the
:attr:`client_id` and :attr:`client_secret` encoded in it.
When using form based authentication, the `client_id` and
`client_secret` is put in the HTTP POST request's body.
.. versionchanged:: 16.0.0
This configuration now toggles between HTTP Basic authentication and
form based authentication when working against the `token_url`.
Previously when this was configured True, both would be used contrary
to a recommendation in `OAuth 2.0 documentation
<https://www.rfc-editor.org/rfc/rfc6749#section-2.3.1>`_.
.. versionchanged:: 16.0.2
The default value for this configuration for GenericOAuthenticator
changed from True to False.
""",
)
enable_pkce = Bool(
True,
config=True,
help="""
Enable Proof Key for Code Exchange (PKCE) for the OAuth2 authorization code flow.
For more information, see `RFC 7636 <https://datatracker.ietf.org/doc/html/rfc7636>`_.
PKCE can be used even if the authorization server does not support it. According to
`section 3.1 of RFC 6749 <https://www.rfc-editor.org/rfc/rfc6749#section-3.1>`_:
The authorization server MUST ignore unrecognized request parameters.
Additionally, `section 5 of RFC 7636 <https://datatracker.ietf.org/doc/html/rfc7636#section-5>`_ states:
As the OAuth 2.0 [RFC6749] server responses are unchanged by this
specification, client implementations of this specification do not
need to know if the server has implemented this specification or not
and SHOULD send the additional parameters as defined in Section 4 to
all servers.
Note that S256 is the only code challenge method supported. As per `section 4.2 of RFC 6749
<https://www.rfc-editor.org/rfc/rfc6749#section-3.1>`_:
If the client is capable of using "S256", it MUST use "S256", as
"S256" is Mandatory To Implement (MTI) on the server.
""",
)
client_id_env = ""
client_id = Unicode(
config=True,
help="""
The client id of the OAuth2 application registered with the identity
provider.
""",
)
def _client_id_default(self):
if self.client_id_env:
client_id = os.getenv(self.client_id_env, "")
if client_id:
return client_id
return os.getenv("OAUTH_CLIENT_ID", "")
client_secret_env = ""
client_secret = Unicode(
config=True,
help="""
The client secret of the OAuth2 application registered with the identity
provider.
""",
)
def _client_secret_default(self):
if self.client_secret_env:
client_secret = os.getenv(self.client_secret_env, "")
if client_secret:
return client_secret
return os.getenv("OAUTH_CLIENT_SECRET", "")
validate_server_cert_env = "OAUTH_TLS_VERIFY"
validate_server_cert = Bool(
config=True,
help="""
Determines if certificates are validated.
Only set this to False if you feel confident it will not be a security
concern.
""",
)
def _validate_server_cert_default(self):
env_value = os.getenv(self.validate_server_cert_env, "")
if env_value == "0":
return False
else:
return True
http_request_kwargs = Dict(
config=True,
help="""
Extra default kwargs passed to all HTTPRequests.
.. code-block:: python
# Example: send requests through a proxy
c.OAuthenticator.http_request_kwargs = {
"proxy_host": "proxy.example.com",
"proxy_port": 8080,
}
# Example: validate against certain root certificates
c.OAuthenticator.http_request_kwargs = {
"ca_certs": "/path/to/a.crt",
}
See :external:py:class:`tornado.httpclient.HTTPRequest` for all kwargs
options you can pass. Note that the HTTP client making these requests is
:external:py:class:`tornado.httpclient.AsyncHTTPClient`.
""",
)
http_client = Any()
@default("http_client")
def _default_http_client(self):
return AsyncHTTPClient()
async def fetch(self, req, label="fetching", parse_json=True, **kwargs):
"""Wrapper for http requests
logs error responses, parses successful JSON responses
Args:
req: tornado HTTPRequest
label (str): label describing what is happening,
used in log message when the request fails.
parse_json (bool): whether to parse the response as JSON
**kwargs: remaining keyword args
passed to underlying `client.fetch(req, **kwargs)`
Returns:
parsed JSON response if `parse_json=True`, else `tornado.HTTPResponse`
"""
try:
resp = await self.http_client.fetch(req, **kwargs)
except HTTPClientError as e:
if e.response:
# Log failed response message for debugging purposes
message = e.response.body.decode("utf8", "replace")
try:
# guess json, reformat for readability
json_message = json.loads(message)
except ValueError:
# not json
pass
else:
# reformat json log message for readability
message = json.dumps(json_message, sort_keys=True, indent=1)
else:
# didn't get a response, e.g. connection error
message = str(e)
# log url without query params
url = urlunparse(urlparse(req.url)._replace(query=""))
app_log.error(f"Error {label} {e.code} {req.method} {url}: {message}")
raise e
else:
if parse_json:
if resp.body:
return json.loads(resp.body.decode("utf8", "replace"))
else:
# empty body is None
return None
else:
return resp
async def httpfetch(
self, url, label="fetching", parse_json=True, raise_error=True, **kwargs
):
"""Wrapper for creating and fetching http requests
Includes http_request_kwargs in request kwargs
logs error responses, parses successful JSON responses
Args:
url (str): url to fetch
label (str): label describing what is happening,
used in log message when the request fails.
parse_json (bool): whether to parse the response as JSON
raise_error (bool): whether to raise an exception on HTTP errors
**kwargs: remaining keyword args
passed to underlying `tornado.HTTPRequest`, overrides
`http_request_kwargs`
Returns:
parsed JSON response if `parse_json=True`, else `tornado.HTTPResponse`
"""
request_kwargs = self.http_request_kwargs.copy()
request_kwargs.update(kwargs)
req = HTTPRequest(url, **request_kwargs)
return await self.fetch(
req, label=label, parse_json=parse_json, raise_error=raise_error
)
def add_user(self, user):
"""
Overrides `Authenticator.add_user`, a hook called for all users in the
database on startup and for each user being created.
The purpose of the override is to implement the `allow_existing_users`
config by adding users to the `allowed_users` set only if
`allow_existing_users` is truthy. The overridden behavior is to do it if
`allowed_users` is truthy.
The implementation is adjusted from JupyterHub 4.0.1:
https://github.com/jupyterhub/jupyterhub/blob/4.0.1/jupyterhub/auth.py#L625-L648
"""
if not self.validate_username(user.name):
raise ValueError("Invalid username: %s" % user.name)
if not self.allow_all and self.allow_existing_users:
self.allowed_users.add(user.name)
def login_url(self, base_url):
return url_path_join(base_url, "oauth_login")
def logout_url(self, base_url):
return url_path_join(base_url, "logout")
def get_callback_url(self, handler=None):
"""Get my OAuth redirect URL
Either from config or guess based on the current request.
"""
if self.oauth_callback_url:
return self.oauth_callback_url
elif handler:
return guess_callback_uri(
handler.request.protocol,
handler.request.host,
handler.hub.server.base_url,
)
else:
raise ValueError(
"Specify callback oauth_callback_url or give me a handler to guess with"
)
def get_handlers(self, app):
return [
(r"/oauth_login", self.login_handler),
(r"/oauth_callback", self.callback_handler),
(r"/logout", self.logout_handler),
]
def build_userdata_request_headers(self, access_token, token_type):
"""
Builds and returns the headers to be used in the userdata request.
Called by :meth:`.token_to_user`.
"""
# token_type is case-insensitive, but the headers are case-sensitive
if token_type.lower() == "bearer":
auth_token_type = "Bearer"
else:
auth_token_type = token_type
return {
"Accept": "application/json",
"User-Agent": "JupyterHub",
"Authorization": f"{auth_token_type} {access_token}",
}
def build_token_info_request_headers(self):
"""
Builds and returns the headers to be used in the access token request.
Called by :meth:`.get_token_info`.
The Content-Type header is specified by the OAuth 2.0 RFC in
https://www.rfc-editor.org/rfc/rfc6749#section-4.1.3. utf-8 is also
required according to https://www.rfc-editor.org/rfc/rfc6749#appendix-B,
and that can be specified with a Content-Type directive according to
https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type#directives.
"""
headers = {
"Accept": "application/json",
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
"User-Agent": "JupyterHub",
}
if self.basic_auth:
b64key = base64.b64encode(
bytes(f"{self.client_id}:{self.client_secret}", "utf8")
)
headers.update({"Authorization": f'Basic {b64key.decode("utf8")}'})
return headers
def user_info_to_username(self, user_info):
"""
Gets the self.username_claim key's value from the user_info dictionary.
Should be overridden by the authenticators for which the hub username cannot
be extracted this way and needs extra processing.
Args:
user_info: the dictionary returned by the userdata request
Returns:
user_info["self.username_claim"] or raises an error if such value isn't found.