1+ import base64
2+ import datetime
3+ import hashlib
14import json
25import os
6+ import random
37import re
8+ import string
49import uuid
510from urllib .parse import urlencode
611
712import tornado .auth
813import tornado .gen
914import tornado .web
1015from celery .utils .imports import instantiate
16+ from flower .utils import strtobool
1117from tornado .options import options
1218
1319from ..views import BaseHandler
@@ -253,12 +259,30 @@ async def _on_auth(self, user):
253259
254260
255261class OktaLoginHandler (BaseHandler , tornado .auth .OAuth2Mixin ):
256- _OAUTH_NO_CALLBACKS = False
257- _OAUTH_SETTINGS_KEY = 'oauth'
258262
259263 @property
260264 def base_url (self ):
261- return os .environ .get ('FLOWER_OAUTH2_OKTA_BASE_URL' )
265+ return self .application .options .oauth2_okta_base_url
266+
267+ @property
268+ def _use_pkce (self ):
269+ return self .application .options .oauth2_okta_enable_pkce
270+
271+ @property
272+ def _okta_login_timeout_seconds (self ):
273+ return self .application .options .oauth2_okta_login_timeout
274+
275+ @property
276+ def _client_id (self ):
277+ return self .application .options .oauth2_key
278+
279+ @property
280+ def _client_secret (self ):
281+ return self .application .options .oauth2_secret
282+
283+ @property
284+ def _redirect_uri (self ):
285+ return self .application .options .oauth2_redirect_uri
262286
263287 @property
264288 def _OAUTH_AUTHORIZE_URL (self ):
@@ -268,19 +292,31 @@ def _OAUTH_AUTHORIZE_URL(self):
268292 def _OAUTH_ACCESS_TOKEN_URL (self ):
269293 return f"{ self .base_url } /v1/token"
270294
295+ @property
296+ def _oauth_okta_scope (self ):
297+ return self .application .options .oauth2_okta_scope .split ()
298+
271299 @property
272300 def _OAUTH_USER_INFO_URL (self ):
273301 return f"{ self .base_url } /v1/userinfo"
274302
275- async def get_access_token (self , redirect_uri , code ):
276- body = urlencode ( {
303+ async def _get_tokens (self , redirect_uri , code , pkce_code_verifier ):
304+ url_params = {
277305 "redirect_uri" : redirect_uri ,
278306 "code" : code ,
279- "client_id" : self .settings [self ._OAUTH_SETTINGS_KEY ]['key' ],
280- "client_secret" : self .settings [self ._OAUTH_SETTINGS_KEY ]['secret' ],
307+ "client_id" : self ._client_id ,
281308 "grant_type" : "authorization_code" ,
282- })
309+ }
310+
311+ if self ._client_secret :
312+ # though not recommended for this application,
313+ # it's possible to not use a client secret when PKCE is enabled
314+ url_params ["client_secret" ] = self ._client_secret
315+
316+ if pkce_code_verifier :
317+ url_params ["code_verifier" ] = pkce_code_verifier
283318
319+ body = urlencode (url_params )
284320 response = await self .get_auth_http_client ().fetch (
285321 self ._OAUTH_ACCESS_TOKEN_URL ,
286322 method = "POST" ,
@@ -292,46 +328,105 @@ async def get_access_token(self, redirect_uri, code):
292328
293329 return json .loads (response .body .decode ('utf-8' ))
294330
295- async def get (self ):
296- redirect_uri = self .settings [self ._OAUTH_SETTINGS_KEY ]['redirect_uri' ]
297- if self .get_argument ('code' , False ):
298- expected_state = (self .get_secure_cookie ('oauth_state' ) or b'' ).decode ('utf-8' )
299- returned_state = self .get_argument ('state' )
300-
301- if returned_state is None or returned_state != expected_state :
302- raise tornado .auth .AuthError (
303- 'OAuth authenticator error: State tokens do not match' )
304-
305- access_token_response = await self .get_access_token (
306- redirect_uri = redirect_uri ,
307- code = self .get_argument ('code' ),
308- )
309- await self ._on_auth (access_token_response )
310- else :
311- state = str (uuid .uuid4 ())
312- self .set_secure_cookie ("oauth_state" , state )
313- self .authorize_redirect (
314- redirect_uri = redirect_uri ,
315- client_id = self .settings [self ._OAUTH_SETTINGS_KEY ]['key' ],
316- scope = ['openid email' ],
317- response_type = 'code' ,
318- extra_params = {'state' : state }
331+ @staticmethod
332+ def _make_pkce_code_and_challenge ():
333+ rand = random .SystemRandom ()
334+ code_verifier = "" .join (rand .choices (string .ascii_letters + string .digits , k = 128 ))
335+ code_verifier_hash = hashlib .sha256 (code_verifier .encode ()).digest ()
336+ code_challenge = base64 .urlsafe_b64encode (code_verifier_hash ).decode ().rstrip ("=" )
337+ return code_verifier , code_challenge
338+
339+ def _compare_state (self ):
340+ expected_state = (self .get_secure_cookie ("oauth_state" ) or b"" ).decode ("utf-8" )
341+ returned_state = self .get_argument ("state" )
342+ if returned_state is None or returned_state != expected_state :
343+ self ._clear_oauth_cookies ()
344+ raise tornado .auth .AuthError (
345+ "OAuth authenticator error: State tokens do not match" )
346+
347+ async def _handle_redirect (self ):
348+ """
349+ Handle when user is redirected back from OKTA
350+ """
351+ pkce_code_verifier = (self .get_secure_cookie ("oauth_pkce_code" ) or b"" ).decode ("utf-8" )
352+ self ._compare_state ()
353+ self ._clear_oauth_cookies ()
354+
355+ if self ._use_pkce and not pkce_code_verifier :
356+ raise tornado .auth .AuthError (
357+ "OAuth authenticator error: PKCE code verifier was not set"
319358 )
320359
321- async def _on_auth (self , access_token_response ):
322- if not access_token_response :
323- raise tornado .web .HTTPError (500 , 'OAuth authentication failed' )
324- access_token = access_token_response ['access_token' ]
360+ tokens_response = await self ._get_tokens (
361+ redirect_uri = self ._redirect_uri ,
362+ code = self .get_argument ('code' ),
363+ pkce_code_verifier = pkce_code_verifier ,
364+ )
365+ await self ._on_auth (tokens_response )
366+
367+ def _set_short_lived_secure_cookie (self , name , value , ** kwargs ):
368+ """
369+ set a signed cookie that expires after self._okta_login_timeout_seconds
370+ :param name: name of the cookie
371+ :param value: value of the cookie
372+ :param kwargs: kwargs to pass into self.set_secure_cookie
373+ :return: None
374+ """
375+ expires = (
376+ datetime .datetime .now ()
377+ + datetime .timedelta (seconds = self ._okta_login_timeout_seconds )
378+ )
379+ return self .set_secure_cookie (
380+ name ,
381+ value ,
382+ expires_days = None ,
383+ httponly = True ,
384+ expires = expires ,
385+ )
325386
326- response = await self .get_auth_http_client ().fetch (
327- self ._OAUTH_USER_INFO_URL ,
328- headers = {'Authorization' : 'Bearer ' + access_token ,
329- 'User-agent' : 'Tornado auth' })
387+ async def _do_redirect (self ):
388+ """
389+ Redirect user to OKTA
390+ """
391+ state = str (uuid .uuid4 ())
392+ self ._set_short_lived_secure_cookie ("oauth_state" , state )
393+
394+ extra_params = {"state" : state }
395+
396+ if self ._use_pkce :
397+ code , code_challenge = self ._make_pkce_code_and_challenge ()
398+ self ._set_short_lived_secure_cookie ("oauth_pkce_code" , code )
399+ extra_params .update ({
400+ "code_challenge" : code_challenge ,
401+ "code_challenge_method" : "S256" ,
402+ })
403+
404+ self .authorize_redirect (
405+ redirect_uri = self ._redirect_uri ,
406+ client_id = self ._client_id ,
407+ scope = self ._oauth_okta_scope ,
408+ response_type = "code" ,
409+ extra_params = extra_params
410+ )
330411
331- decoded_body = json .loads (response .body .decode ('utf-8' ))
332- email = (decoded_body .get ('email' ) or '' ).strip ()
412+ async def _handle_oauth_error (self , error , description ):
413+ self ._compare_state ()
414+ self ._clear_oauth_cookies ()
415+ raise tornado .web .HTTPError (403 , f"OAuth failed with this error: { error } , { description } " )
416+
417+ async def _user_passes_test (self , user_payload ):
418+ """
419+ You can override this to perform your own user testing logic
420+ raise a tornado.web.HTTPError if test fails (usually HTTP 403)
421+ return the username or email address of the user if test passes
422+
423+ :param user_payload: a dictionary generated by decoding
424+ the response body from OKTA's OIDC userinfo endpoint
425+ :return: user's email address
426+ """
427+ email = (user_payload .get ('email' ) or '' ).strip ()
333428 email_verified = (
334- decoded_body .get ('email_verified' ) and
429+ user_payload .get ('email_verified' ) and
335430 authenticate (self .application .options .auth , email )
336431 )
337432
@@ -342,9 +437,54 @@ async def _on_auth(self, access_token_response):
342437 )
343438 raise tornado .web .HTTPError (403 , message )
344439
345- self .set_secure_cookie ("user" , str (email ))
346- self .clear_cookie ('oauth_state' )
440+ return email
441+
442+ async def get (self ):
443+ if self .get_argument ("code" , False ):
444+ await self ._handle_redirect ()
445+ elif self .get_argument ("error" , False ):
446+ await self ._handle_oauth_error (
447+ error = self .get_argument ("error" ),
448+ description = self .get_argument ("error_description" , "" )
449+ )
450+ else :
451+ await self ._do_redirect ()
452+
453+ def _clear_oauth_cookies (self ):
454+ self .clear_cookie ("oauth_state" )
455+ if self ._use_pkce :
456+ self .clear_cookie ("oauth_pkce_code" )
457+
458+ async def _get_userinfo (self , tokens_response ):
459+ """
460+ Returns the user information in a dictionary.
461+ The default implementation takes the "access_token" in the token_response,
462+ and send it to OKTA's userinfo endpoint, and return the decoded response in a dictionary.
463+
464+ You can override this to use the "id_token" and avoid sending the extra request to userinfo endpoint
465+ however, in that case you must validate the signature, audience and issuer of the token yourself.
466+
467+ :param tokens_response: response object from OKTA's token endpoint
468+ :return: a dictionary containing user information
469+ """
470+ access_token = tokens_response ["access_token" ]
471+ response = await self .get_auth_http_client ().fetch (
472+ self ._OAUTH_USER_INFO_URL ,
473+ headers = {
474+ "Authorization" : f"Bearer { access_token } " ,
475+ "User-agent" : "Tornado auth"
476+ }
477+ )
478+
479+ return json .loads (response .body .decode ("utf-8" ))
480+
481+ async def _on_auth (self , tokens_response ):
482+ if not tokens_response :
483+ raise tornado .web .HTTPError (500 , "OAuth authentication failed" )
347484
485+ userinfo = await self ._get_userinfo (tokens_response )
486+ user = await self ._user_passes_test (userinfo )
487+ self .set_secure_cookie ("user" , str (user ))
348488 next_ = self .get_argument ('next' , self .application .options .url_prefix or '/' )
349489 if self .application .options .url_prefix and next_ [0 ] != '/' :
350490 next_ = '/' + next_
0 commit comments