Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion docs/auth.rst
Original file line number Diff line number Diff line change
Expand Up @@ -83,19 +83,47 @@ Here's an example configuration file with the Github OAuth options:
oauth2_secret="<your_client_secret>"
oauth2_redirect_uri="http://localhost:5555/login"

Replace `<your_client_id>` and `<your_client_secret>` with the actual Client ID and secret obtained from
Replace `<your_client_id>` and `<your_client_secret>` with the actual Client ID and secret obtained from
the Github Settings.

(Optional) The custom GitHub Domain can be adjusted using the ``FLOWER_GITHUB_OAUTH_DOMAIN`` environment variable.

See `GitHub OAuth API`_ docs for more info.

.. _Github Settings: https://github.com/settings/applications/new
.. _GitHub OAuth API: https://developer.github.com/v3/oauth/

.. _github-device-auth:

GitHub Device Auth (CLI)
~~~~~~~~~~~~~~~~~~~~~~~~

``GithubLoginHandler`` also supports the `GitHub Device Authorization Grant`_ (RFC 8628)
for CLI / browserless environments. No additional ``auth_provider`` is needed — the same
``GithubLoginHandler`` configuration is used:

.. code-block:: python

auth_provider="flower.views.auth.GithubLoginHandler"
auth=".*@example.com"
oauth2_key="<your_client_id>"
oauth2_secret="<your_client_secret>"
oauth2_redirect_uri="http://localhost:5555/login"

**Prerequisites:** Enable *Device Flow* in your GitHub OAuth App settings.

See `GitHub Device Authorization Grant`_ and `GitHub Device Flow API`_ docs for more info.

.. _GitHub Device Authorization Grant: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#device-flow
.. _GitHub Device Flow API: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#response-parameters


.. _okta-oauth:

Okta OAuth
----------


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

Expand Down
52 changes: 52 additions & 0 deletions flower/views/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ class GithubLoginHandler(BaseHandler, tornado.auth.OAuth2Mixin):
"FLOWER_GITHUB_OAUTH_DOMAIN", "github.com")
_OAUTH_AUTHORIZE_URL = f'https://{_OAUTH_DOMAIN}/login/oauth/authorize'
_OAUTH_ACCESS_TOKEN_URL = f'https://{_OAUTH_DOMAIN}/login/oauth/access_token'
_OAUTH_DEVICE_CODE_URL = f'https://{_OAUTH_DOMAIN}/login/device/code'
_OAUTH_NO_CALLBACKS = False
_OAUTH_SETTINGS_KEY = 'oauth'

Expand All @@ -115,7 +116,29 @@ async def get_authenticated_user(self, redirect_uri, code):

return json.loads(response.body.decode('utf-8'))

async def post(self):
body = urlencode({
'client_id': self.settings[self._OAUTH_SETTINGS_KEY]['key'],
'scope': 'user:email',
})
response = await self.get_auth_http_client().fetch(
self._OAUTH_DEVICE_CODE_URL,
method='POST',
headers={'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json'},
body=body,
)
if response.error:
raise tornado.web.HTTPError(502, 'GitHub device code request failed')
self.set_header('Content-Type', 'application/json')
self.write(response.body)

async def get(self):
device_code = self.get_argument('device_code', None)
if device_code:
await self._device_poll(device_code)
return

redirect_uri = self.settings[self._OAUTH_SETTINGS_KEY]['redirect_uri']
if self.get_argument('code', False):
user = await self.get_authenticated_user(
Expand All @@ -132,6 +155,35 @@ async def get(self):
extra_params={'approval_prompt': ''}
)

async def _device_poll(self, device_code):
body = urlencode({
'client_id': self.settings[self._OAUTH_SETTINGS_KEY]['key'],
'device_code': device_code,
'grant_type': 'urn:ietf:params:oauth:grant-type:device_code',
})
response = await self.get_auth_http_client().fetch(
self._OAUTH_ACCESS_TOKEN_URL,
method='POST',
headers={'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json'},
body=body,
)
if response.error:
raise tornado.web.HTTPError(502, 'GitHub token poll failed')

data = json.loads(response.body.decode('utf-8'))
error = data.get('error')
if error in ('authorization_pending', 'slow_down'):
# User has not yet approved; caller should retry after `interval`.
self.set_status(202)
self.set_header('Content-Type', 'application/json')
self.write(response.body)
return
if error:
raise tornado.web.HTTPError(403, f'GitHub device auth error: {error}')

await self._on_auth(data)

async def _on_auth(self, user):
if not user:
raise tornado.web.HTTPError(500, 'OAuth authentication failed')
Expand Down
69 changes: 68 additions & 1 deletion tests/unit/views/test_auth.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import json
from unittest.mock import AsyncMock, MagicMock, patch

from flower.views.auth import authenticate, validate_auth_option
from tests.unit import AsyncHTTPTestCase

Expand Down Expand Up @@ -59,4 +62,68 @@ def test_authenticate_wildcard_email(self):
self.assertTrue(authenticate("one.*@example.com", "one.two@example.com"))
self.assertFalse(authenticate(".*@example.com", "attacker@example.com.attacker.com"))
self.assertFalse(authenticate(".*@corp.example.com", "attacker@corpZexample.com"))
self.assertFalse(authenticate(".*@corp\.example\.com", "attacker@corpZexample.com"))
self.assertFalse(authenticate(".*@corp\\.example\\.com", "attacker@corpZexample.com"))


_OAUTH_SETTINGS = {
'key': 'test-client-id',
'secret': 'test-client-secret',
'redirect_uri': 'http://localhost:5555/login',
}


class GithubLoginHandlerDeviceFlowTests(AsyncHTTPTestCase):
def setUp(self):
super().setUp()
self._app.settings['oauth'] = _OAUTH_SETTINGS

def test_post_returns_device_code_json(self):
client = MagicMock()
client.fetch = AsyncMock(return_value=MagicMock(
error=None,
body=json.dumps({'user_code': 'ABCD-1234'}).encode(),
))
with self.mock_option('auth_provider', 'flower.views.auth.GithubLoginHandler'), \
self.mock_option('auth', '.*@example.com'), \
patch('flower.views.auth.GithubLoginHandler.get_auth_http_client', return_value=client):
r = self.fetch('/login', method='POST', body='')
self.assertEqual(200, r.code)
self.assertEqual('ABCD-1234', json.loads(r.body)['user_code'])

def test_get_authorization_pending_returns_202(self):
client = MagicMock()
client.fetch = AsyncMock(return_value=MagicMock(
error=None,
body=json.dumps({'error': 'authorization_pending'}).encode(),
))
with self.mock_option('auth_provider', 'flower.views.auth.GithubLoginHandler'), \
self.mock_option('auth', '.*@example.com'), \
patch('flower.views.auth.GithubLoginHandler.get_auth_http_client', return_value=client):
r = self.fetch('/login?device_code=dev123')
self.assertEqual(202, r.code)

def test_get_device_auth_error_returns_403(self):
client = MagicMock()
client.fetch = AsyncMock(return_value=MagicMock(
error=None,
body=json.dumps({'error': 'expired_token'}).encode(),
))
with self.mock_option('auth_provider', 'flower.views.auth.GithubLoginHandler'), \
self.mock_option('auth', '.*@example.com'), \
patch('flower.views.auth.GithubLoginHandler.get_auth_http_client', return_value=client):
r = self.fetch('/login?device_code=dev123')
self.assertEqual(403, r.code)

def test_get_successful_auth_sets_cookie_and_redirects(self):
client = MagicMock()
client.fetch = AsyncMock(side_effect=[
MagicMock(error=None, body=json.dumps({'access_token': 'tok123'}).encode()),
MagicMock(error=None, body=json.dumps(
[{'email': 'user@example.com', 'verified': True}]).encode()),
])
with self.mock_option('auth_provider', 'flower.views.auth.GithubLoginHandler'), \
self.mock_option('auth', '.*@example.com'), \
patch('flower.views.auth.GithubLoginHandler.get_auth_http_client', return_value=client):
r = self.fetch('/login?device_code=dev123', follow_redirects=False)
self.assertEqual(302, r.code)
self.assertIn('user', r.headers.get('Set-Cookie', ''))
Loading