diff --git a/docs/auth.rst b/docs/auth.rst index 12f089645..196574589 100644 --- a/docs/auth.rst +++ b/docs/auth.rst @@ -83,19 +83,47 @@ Here's an example configuration file with the Github OAuth options: oauth2_secret="" oauth2_redirect_uri="http://localhost:5555/login" -Replace `` and `` with the actual Client ID and secret obtained from +Replace `` and `` 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="" + oauth2_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`. diff --git a/flower/views/auth.py b/flower/views/auth.py index 65103ddd6..d52805e0d 100644 --- a/flower/views/auth.py +++ b/flower/views/auth.py @@ -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' @@ -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( @@ -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') diff --git a/tests/unit/views/test_auth.py b/tests/unit/views/test_auth.py index 941ed4aa1..e86e137ed 100644 --- a/tests/unit/views/test_auth.py +++ b/tests/unit/views/test_auth.py @@ -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 @@ -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', ''))