From 8b43d408728a2418a544b6e203a738e590a86f47 Mon Sep 17 00:00:00 2001 From: Mukesh-M-Lohar Date: Sun, 28 Jun 2026 04:49:46 +0000 Subject: [PATCH 1/4] feat: add GitHub Device Authorization Grant (CLI auth) to GithubLoginHandler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /login: requests device_code + user_code from GitHub (RFC 8628) - GET /login?device_code=…: polls for token; 202 while pending, 302 on success - Existing web OAuth flow (GET /login, GET /login?code=…) unchanged - Added _OAUTH_DEVICE_CODE_URL and _device_poll() to GithubLoginHandler - 4 new unit tests covering device flow branches - docs: document device auth subsection and FLOWER_GITHUB_OAUTH_DOMAIN env var --- docs/auth.rst | 30 ++++++++++- flower/views/auth.py | 67 ++++++++++++++++++++++++ tests/unit/views/test_auth.py | 95 ++++++++++++++++++++++++++++++++++- 3 files changed, 190 insertions(+), 2 deletions(-) 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..914ccabf3 100644 --- a/flower/views/auth.py +++ b/flower/views/auth.py @@ -87,11 +87,25 @@ def __new__(cls, *args, **kwargs): class GithubLoginHandler(BaseHandler, tornado.auth.OAuth2Mixin): + """GitHub OAuth handler supporting both web (browser) and device (CLI) flows. + + Web OAuth flow (existing behaviour): + - ``GET /login`` → redirect to GitHub authorize page + - ``GET /login?code=…`` → exchange code for token, set cookie + + Device Authorization Grant (CLI / browserless, RFC 8628): + - ``POST /login`` → request device+user code from GitHub, + return JSON with ``user_code`` and + ``verification_uri`` for the CLI to display + - ``GET /login?device_code=…`` → poll; returns 202 while pending, + 302 on success + """ _OAUTH_DOMAIN = os.getenv( "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 +129,30 @@ async def get_authenticated_user(self, redirect_uri, code): return json.loads(response.body.decode('utf-8')) + async def post(self): + """Device flow step 1: request a device + user code from GitHub.""" + 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 +169,36 @@ async def get(self): extra_params={'approval_prompt': ''} ) + async def _device_poll(self, device_code): + """Device flow step 2: poll GitHub for an access token.""" + 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..04c0f6db1 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,94 @@ 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")) + + +def _mock_response(body, error=None): + """Build a minimal mock that looks like a tornado HTTPResponse.""" + resp = MagicMock() + resp.error = error + resp.body = json.dumps(body).encode() + return resp + + +class GithubLoginHandlerDeviceFlowTests(AsyncHTTPTestCase): + _OAUTH_SETTINGS = { + 'key': 'test-client-id', + 'secret': 'test-client-secret', + 'redirect_uri': 'http://localhost:5555/login', + } + + def _make_http_client(self, response): + client = MagicMock() + client.fetch = AsyncMock(return_value=response) + return client + + # ------------------------------------------------------------------ + # POST /login – step 1: request device code + # ------------------------------------------------------------------ + def test_post_returns_device_code_json(self): + device_payload = { + 'device_code': 'dev123', + 'user_code': 'ABCD-1234', + 'verification_uri': 'https://github.com/login/device', + 'interval': 5, + 'expires_in': 900, + } + with self.mock_option('auth_provider', 'flower.views.auth.GithubLoginHandler'), \ + self.mock_option('auth', '.*@example.com'): + resp = _mock_response(device_payload) + with patch( + 'flower.views.auth.GithubLoginHandler.get_auth_http_client', + return_value=self._make_http_client(resp), + ), patch.dict(self.get_app().settings, {'oauth': self._OAUTH_SETTINGS}): + r = self.fetch('/login', method='POST', body='') + self.assertEqual(200, r.code) + data = json.loads(r.body) + self.assertEqual('ABCD-1234', data['user_code']) + + # ------------------------------------------------------------------ + # GET /login?device_code=… – step 2: poll + # ------------------------------------------------------------------ + def test_get_authorization_pending_returns_202(self): + pending = {'error': 'authorization_pending', 'error_description': 'pending'} + with self.mock_option('auth_provider', 'flower.views.auth.GithubLoginHandler'), \ + self.mock_option('auth', '.*@example.com'): + resp = _mock_response(pending) + with patch( + 'flower.views.auth.GithubLoginHandler.get_auth_http_client', + return_value=self._make_http_client(resp), + ), patch.dict(self.get_app().settings, {'oauth': self._OAUTH_SETTINGS}): + r = self.fetch('/login?device_code=dev123') + self.assertEqual(202, r.code) + + def test_get_device_auth_error_returns_403(self): + expired = {'error': 'expired_token', 'error_description': 'Token expired'} + with self.mock_option('auth_provider', 'flower.views.auth.GithubLoginHandler'), \ + self.mock_option('auth', '.*@example.com'): + resp = _mock_response(expired) + with patch( + 'flower.views.auth.GithubLoginHandler.get_auth_http_client', + return_value=self._make_http_client(resp), + ), patch.dict(self.get_app().settings, {'oauth': self._OAUTH_SETTINGS}): + r = self.fetch('/login?device_code=dev123') + self.assertEqual(403, r.code) + + def test_get_successful_auth_sets_cookie_and_redirects(self): + token_resp = {'access_token': 'tok123', 'token_type': 'bearer'} + emails_resp = [{'email': 'user@example.com', 'verified': True, 'primary': True}] + with self.mock_option('auth_provider', 'flower.views.auth.GithubLoginHandler'), \ + self.mock_option('auth', '.*@example.com'): + # First fetch call → access token; second → email list + client = MagicMock() + client.fetch = AsyncMock(side_effect=[ + _mock_response(token_resp), + _mock_response(emails_resp), + ]) + with patch( + 'flower.views.auth.GithubLoginHandler.get_auth_http_client', + return_value=client, + ), patch.dict(self.get_app().settings, {'oauth': self._OAUTH_SETTINGS}): + r = self.fetch('/login?device_code=dev123', follow_redirects=False) + self.assertEqual(302, r.code) + self.assertIn('user', r.headers.get('Set-Cookie', '')) From d01e96c93ab063b9850be2e5330b889ff8971efb Mon Sep 17 00:00:00 2001 From: Mukesh-M-Lohar Date: Sun, 28 Jun 2026 04:53:20 +0000 Subject: [PATCH 2/4] style: remove docstrings --- flower/views/auth.py | 15 --------------- tests/unit/views/test_auth.py | 1 - 2 files changed, 16 deletions(-) diff --git a/flower/views/auth.py b/flower/views/auth.py index 914ccabf3..d52805e0d 100644 --- a/flower/views/auth.py +++ b/flower/views/auth.py @@ -87,19 +87,6 @@ def __new__(cls, *args, **kwargs): class GithubLoginHandler(BaseHandler, tornado.auth.OAuth2Mixin): - """GitHub OAuth handler supporting both web (browser) and device (CLI) flows. - - Web OAuth flow (existing behaviour): - - ``GET /login`` → redirect to GitHub authorize page - - ``GET /login?code=…`` → exchange code for token, set cookie - - Device Authorization Grant (CLI / browserless, RFC 8628): - - ``POST /login`` → request device+user code from GitHub, - return JSON with ``user_code`` and - ``verification_uri`` for the CLI to display - - ``GET /login?device_code=…`` → poll; returns 202 while pending, - 302 on success - """ _OAUTH_DOMAIN = os.getenv( "FLOWER_GITHUB_OAUTH_DOMAIN", "github.com") @@ -130,7 +117,6 @@ async def get_authenticated_user(self, redirect_uri, code): return json.loads(response.body.decode('utf-8')) async def post(self): - """Device flow step 1: request a device + user code from GitHub.""" body = urlencode({ 'client_id': self.settings[self._OAUTH_SETTINGS_KEY]['key'], 'scope': 'user:email', @@ -170,7 +156,6 @@ async def get(self): ) async def _device_poll(self, device_code): - """Device flow step 2: poll GitHub for an access token.""" body = urlencode({ 'client_id': self.settings[self._OAUTH_SETTINGS_KEY]['key'], 'device_code': device_code, diff --git a/tests/unit/views/test_auth.py b/tests/unit/views/test_auth.py index 04c0f6db1..6ae49ee6d 100644 --- a/tests/unit/views/test_auth.py +++ b/tests/unit/views/test_auth.py @@ -66,7 +66,6 @@ def test_authenticate_wildcard_email(self): def _mock_response(body, error=None): - """Build a minimal mock that looks like a tornado HTTPResponse.""" resp = MagicMock() resp.error = error resp.body = json.dumps(body).encode() From b078b72b6658f672a2c30de30b2dd509cafc04b9 Mon Sep 17 00:00:00 2001 From: Mukesh-M-Lohar Date: Sun, 28 Jun 2026 04:56:02 +0000 Subject: [PATCH 3/4] style: flatten device flow tests to match AuthTests style --- tests/unit/views/test_auth.py | 123 ++++++++++++++-------------------- 1 file changed, 49 insertions(+), 74 deletions(-) diff --git a/tests/unit/views/test_auth.py b/tests/unit/views/test_auth.py index 6ae49ee6d..26dca38e6 100644 --- a/tests/unit/views/test_auth.py +++ b/tests/unit/views/test_auth.py @@ -65,90 +65,65 @@ def test_authenticate_wildcard_email(self): self.assertFalse(authenticate(".*@corp\\.example\\.com", "attacker@corpZexample.com")) -def _mock_response(body, error=None): - resp = MagicMock() - resp.error = error - resp.body = json.dumps(body).encode() - return resp +_OAUTH_SETTINGS = { + 'key': 'test-client-id', + 'secret': 'test-client-secret', + 'redirect_uri': 'http://localhost:5555/login', +} class GithubLoginHandlerDeviceFlowTests(AsyncHTTPTestCase): - _OAUTH_SETTINGS = { - 'key': 'test-client-id', - 'secret': 'test-client-secret', - 'redirect_uri': 'http://localhost:5555/login', - } - - def _make_http_client(self, response): - client = MagicMock() - client.fetch = AsyncMock(return_value=response) - return client - - # ------------------------------------------------------------------ - # POST /login – step 1: request device code - # ------------------------------------------------------------------ def test_post_returns_device_code_json(self): - device_payload = { - 'device_code': 'dev123', - 'user_code': 'ABCD-1234', - 'verification_uri': 'https://github.com/login/device', - 'interval': 5, - 'expires_in': 900, - } + 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'): - resp = _mock_response(device_payload) - with patch( - 'flower.views.auth.GithubLoginHandler.get_auth_http_client', - return_value=self._make_http_client(resp), - ), patch.dict(self.get_app().settings, {'oauth': self._OAUTH_SETTINGS}): - r = self.fetch('/login', method='POST', body='') - self.assertEqual(200, r.code) - data = json.loads(r.body) - self.assertEqual('ABCD-1234', data['user_code']) - - # ------------------------------------------------------------------ - # GET /login?device_code=… – step 2: poll - # ------------------------------------------------------------------ + self.mock_option('auth', '.*@example.com'), \ + patch('flower.views.auth.GithubLoginHandler.get_auth_http_client', return_value=client), \ + patch.dict(self.get_app().settings, {'oauth': _OAUTH_SETTINGS}): + 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): - pending = {'error': 'authorization_pending', 'error_description': 'pending'} + 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'): - resp = _mock_response(pending) - with patch( - 'flower.views.auth.GithubLoginHandler.get_auth_http_client', - return_value=self._make_http_client(resp), - ), patch.dict(self.get_app().settings, {'oauth': self._OAUTH_SETTINGS}): - r = self.fetch('/login?device_code=dev123') - self.assertEqual(202, r.code) + self.mock_option('auth', '.*@example.com'), \ + patch('flower.views.auth.GithubLoginHandler.get_auth_http_client', return_value=client), \ + patch.dict(self.get_app().settings, {'oauth': _OAUTH_SETTINGS}): + r = self.fetch('/login?device_code=dev123') + self.assertEqual(202, r.code) def test_get_device_auth_error_returns_403(self): - expired = {'error': 'expired_token', 'error_description': 'Token expired'} + 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'): - resp = _mock_response(expired) - with patch( - 'flower.views.auth.GithubLoginHandler.get_auth_http_client', - return_value=self._make_http_client(resp), - ), patch.dict(self.get_app().settings, {'oauth': self._OAUTH_SETTINGS}): - r = self.fetch('/login?device_code=dev123') - self.assertEqual(403, r.code) + self.mock_option('auth', '.*@example.com'), \ + patch('flower.views.auth.GithubLoginHandler.get_auth_http_client', return_value=client), \ + patch.dict(self.get_app().settings, {'oauth': _OAUTH_SETTINGS}): + r = self.fetch('/login?device_code=dev123') + self.assertEqual(403, r.code) def test_get_successful_auth_sets_cookie_and_redirects(self): - token_resp = {'access_token': 'tok123', 'token_type': 'bearer'} - emails_resp = [{'email': 'user@example.com', 'verified': True, 'primary': True}] + 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'): - # First fetch call → access token; second → email list - client = MagicMock() - client.fetch = AsyncMock(side_effect=[ - _mock_response(token_resp), - _mock_response(emails_resp), - ]) - with patch( - 'flower.views.auth.GithubLoginHandler.get_auth_http_client', - return_value=client, - ), patch.dict(self.get_app().settings, {'oauth': self._OAUTH_SETTINGS}): - r = self.fetch('/login?device_code=dev123', follow_redirects=False) - self.assertEqual(302, r.code) - self.assertIn('user', r.headers.get('Set-Cookie', '')) + self.mock_option('auth', '.*@example.com'), \ + patch('flower.views.auth.GithubLoginHandler.get_auth_http_client', return_value=client), \ + patch.dict(self.get_app().settings, {'oauth': _OAUTH_SETTINGS}): + r = self.fetch('/login?device_code=dev123', follow_redirects=False) + self.assertEqual(302, r.code) + self.assertIn('user', r.headers.get('Set-Cookie', '')) From 431f16b735b3e6589b2f243af4fe27f4adc42d10 Mon Sep 17 00:00:00 2001 From: Mukesh-M-Lohar Date: Sun, 28 Jun 2026 05:05:11 +0000 Subject: [PATCH 4/4] fix: patch self._app.settings in device flow tests to fix KeyError: 'oauth' --- tests/unit/views/test_auth.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/unit/views/test_auth.py b/tests/unit/views/test_auth.py index 26dca38e6..e86e137ed 100644 --- a/tests/unit/views/test_auth.py +++ b/tests/unit/views/test_auth.py @@ -73,6 +73,10 @@ def test_authenticate_wildcard_email(self): 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( @@ -81,8 +85,7 @@ def test_post_returns_device_code_json(self): )) 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), \ - patch.dict(self.get_app().settings, {'oauth': _OAUTH_SETTINGS}): + 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']) @@ -95,8 +98,7 @@ def test_get_authorization_pending_returns_202(self): )) 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), \ - patch.dict(self.get_app().settings, {'oauth': _OAUTH_SETTINGS}): + patch('flower.views.auth.GithubLoginHandler.get_auth_http_client', return_value=client): r = self.fetch('/login?device_code=dev123') self.assertEqual(202, r.code) @@ -108,8 +110,7 @@ def test_get_device_auth_error_returns_403(self): )) 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), \ - patch.dict(self.get_app().settings, {'oauth': _OAUTH_SETTINGS}): + patch('flower.views.auth.GithubLoginHandler.get_auth_http_client', return_value=client): r = self.fetch('/login?device_code=dev123') self.assertEqual(403, r.code) @@ -122,8 +123,7 @@ def test_get_successful_auth_sets_cookie_and_redirects(self): ]) 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), \ - patch.dict(self.get_app().settings, {'oauth': _OAUTH_SETTINGS}): + 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', ''))