Skip to content

Commit 4eb8490

Browse files
author
Aaron
committed
add pkce and add some security, update doc
1 parent d689888 commit 4eb8490

File tree

3 files changed

+217
-49
lines changed

3 files changed

+217
-49
lines changed

docs/auth.rst

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,14 +97,33 @@ Okta OAuth
9797
----------
9898

9999
Flower also supports Okta OAuth. Before getting started, you need to register Flower in `Okta`_.
100-
Okta OAuth is activated by setting :ref:`auth_provider` option to `flower.views.auth.OktaLoginHandler`.
100+
Okta OAuth is activated by setting :ref:`auth_provider` option to `flower.views.auth.OktaLoginHandler`.
101101

102-
Okta OAuth requires `oauth2_key`, `oauth2_secret` and `oauth2_redirect_uri` options which should be obtained from Okta.
103-
Okta OAuth also uses `FLOWER_OAUTH2_OKTA_BASE_URL` environment variable.
102+
103+
1. `oauth2_okta_base_url` should be set to the authorization server, for example:
104+
105+
.. code-block:: text
106+
107+
https://example.okta.com/oauth2/default
108+
109+
for more info see: `Okta authorization servers`_
110+
2. `oauth2_key` should be set to the client ID of an Okta app
111+
3. `oauth2_secret` should be set to the client secret of an Okta app, this can be optional if PKCE is enabled,
112+
however, it's strongly recommended to always use client secret authentication.
113+
4. `oauth2_redirect_uri` should be set to the login page of the Flower server,
114+
this also need to be configured in Okta apps' `Sign-in redirect URIs`, for example:
115+
116+
.. code-block:: text
117+
118+
https://flower.example.com/login
119+
120+
5. (Optional) `oauth2_okta_enable_pkce` whether to enable PKCE, default is `false`
121+
6. (Optional) `oauth2_okta_login_timeout` user must complete sign in within this duration, in seconds. default is 300
104122

105123
See Okta `Okta OAuth API`_ docs for more info.
106124

107-
.. _Okta: https://developer.okta.com/docs/guides/add-an-external-idp/openidconnect/main/
125+
.. _Okta: https://help.okta.com/en-us/content/topics/apps/apps_app_integration_wizard_oidc.htm
126+
.. _Okta authorization servers: https://developer.okta.com/docs/concepts/auth-servers/
108127
.. _Okta OAuth API: https://developer.okta.com/docs/reference/api/oidc/
109128

110129
.. _gitlab-oauth:

flower/options.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@
2727
help="OAuth2 secret (requires --auth)")
2828
define("oauth2_redirect_uri", type=str, default=None,
2929
help="OAuth2 redirect uri (requires --auth)")
30+
define("oauth2_okta_base_url", type=str, default=None,
31+
help="Base URL for Okta auth (requires --auth)")
32+
define("oauth2_okta_enable_pkce", type=bool, default=False,
33+
help="Use PKCE for Okta auth (requires --auth)")
34+
define("oauth2_okta_scope", type=str, default="openid email",
35+
help="Scope for Okta auth, should be a space separated string (requires --auth)")
36+
define("oauth2_okta_login_timeout", type=int, default=300,
37+
help="Okta authentication timeout, in seconds, "
38+
"user must complete authentication within this duration (requires --auth)")
3039
define("max_workers", type=int, default=5000,
3140
help="maximum number of workers to keep in memory")
3241
define("max_tasks", type=int, default=100000,

flower/views/auth.py

Lines changed: 185 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
1+
import base64
2+
import datetime
3+
import hashlib
14
import json
25
import os
6+
import random
37
import re
8+
import string
49
import uuid
510
from urllib.parse import urlencode
611

712
import tornado.auth
813
import tornado.gen
914
import tornado.web
1015
from celery.utils.imports import instantiate
16+
from flower.utils import strtobool
1117
from tornado.options import options
1218

1319
from ..views import BaseHandler
@@ -253,12 +259,30 @@ async def _on_auth(self, user):
253259

254260

255261
class 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

Comments
 (0)