From ef2db2649349b5447d8b86da6c75218f2cb68926 Mon Sep 17 00:00:00 2001 From: tim Date: Tue, 5 Mar 2024 11:15:56 +0000 Subject: [PATCH 1/9] add google auth --- src/python-fastui/fastui/auth/google.py | 215 ++++++++++++++++++++ src/python-fastui/tests/test_auth_google.py | 120 +++++++++++ 2 files changed, 335 insertions(+) create mode 100644 src/python-fastui/fastui/auth/google.py create mode 100644 src/python-fastui/tests/test_auth_google.py diff --git a/src/python-fastui/fastui/auth/google.py b/src/python-fastui/fastui/auth/google.py new file mode 100644 index 00000000..9602a400 --- /dev/null +++ b/src/python-fastui/fastui/auth/google.py @@ -0,0 +1,215 @@ +from contextlib import asynccontextmanager +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import TYPE_CHECKING, AsyncIterator, Dict, List, Optional, Tuple, Union, cast +from urllib.parse import urlencode + +import httpx +from pydantic import BaseModel, SecretStr, TypeAdapter + +if TYPE_CHECKING: + from fastapi import Request + from fastapi.responses import JSONResponse + + +@dataclass +class GoogleExchangeError: + error: str + error_description: Union[str, None] = None + + +@dataclass +class GoogleExchange: + access_token: str + token_type: str + scope: str + expires_in: int + refresh_token: Union[str, None] = None + + +google_exchange_type = TypeAdapter(Union[GoogleExchange, GoogleExchangeError]) + + +class GoogleUser(BaseModel): + id: str + email: Optional[str] = None + verified_email: Optional[bool] = None + name: Optional[str] = None + given_name: Optional[str] = None + family_name: Optional[str] = None + picture: Optional[str] = None + locale: Optional[str] = None + + +class GoogleAuthProvider: + def __init__( + self, + httpx_client: 'httpx.AsyncClient', + google_client_id: str, + google_client_secret: SecretStr, + redirect_uri: Union[str, None] = None, + scopes: Union[List[str], None] = None, + state_provider: Union['StateProvider', bool] = True, + exchange_cache_age: Union[timedelta, None] = timedelta(seconds=30), + ): + self._httpx_client = httpx_client + self._google_client_id = google_client_id + self._google_client_secret = google_client_secret + self._redirect_uri = redirect_uri + self._scopes = scopes or [ + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', + ] + self._state_provider = ( + state_provider if isinstance(state_provider, StateProvider) else StateProvider(google_client_secret) + ) + self._exchange_cache_age = exchange_cache_age + + @classmethod + @asynccontextmanager + async def create( + cls, + client_id: str, + client_secret: SecretStr, + redirect_uri: Union[str, None] = None, + state_provider: Union['StateProvider', bool] = True, + exchange_cache_age: Union[timedelta, None] = timedelta(seconds=10), + ) -> AsyncIterator['GoogleAuthProvider']: + async with httpx.AsyncClient() as client: + yield cls( + client, + client_id, + client_secret, + redirect_uri=redirect_uri, + state_provider=state_provider, + exchange_cache_age=exchange_cache_age, + ) + + async def authorization_url(self) -> str: + params = { + 'client_id': self._google_client_id, + 'response_type': 'code', + 'scope': ' '.join(self._scopes), + 'redirect_uri': self._redirect_uri, + 'access_type': 'offline', + 'prompt': 'consent', + } + if self._state_provider: + params['state'] = await self._state_provider.new_state() + return f'https://accounts.google.com/o/oauth2/v2/auth?{urlencode(params)}' + + async def exchange_code(self, code: str) -> GoogleExchange: + if self._exchange_cache_age: + cache_key = f'{code}' + if exchange := EXCHANGE_CACHE.get(cache_key, self._exchange_cache_age): + return exchange + else: + print('getting exchange code from google and setting cache') + exchange = await self._exchange_code(code) + EXCHANGE_CACHE.set(cache_key, exchange, self._exchange_cache_age) + return exchange + else: + return await self._exchange_code(code) + + async def _exchange_code(self, code: str) -> GoogleExchange: + params = { + 'client_id': self._google_client_id, + 'client_secret': self._google_client_secret.get_secret_value(), + 'code': code, + 'grant_type': 'authorization_code', + 'redirect_uri': self._redirect_uri, + } + r = await self._httpx_client.post( + 'https://oauth2.googleapis.com/token', + data=params, + headers={'Content-Type': 'application/x-www-form-urlencoded'}, + ) + r.raise_for_status() + exchange_response = google_exchange_type.validate_json(r.content) + if isinstance(exchange_response, GoogleExchangeError): + raise AuthError('Google OAuth error', code=exchange_response.error) + else: + return cast(GoogleExchange, exchange_response) + + async def refresh_access_token(self, refresh_token: str) -> GoogleExchange: + params = { + 'client_id': self._google_client_id, + 'client_secret': self._google_client_secret.get_secret_value(), + 'refresh_token': refresh_token, + 'grant_type': 'refresh_token', + } + response = await self._httpx_client.post( + 'https://oauth2.googleapis.com/token', + data=params, + headers={'Content-Type': 'application/x-www-form-urlencoded'}, + ) + response.raise_for_status() + exchange_response = google_exchange_type.validate_json(response.content) + if isinstance(exchange_response, GoogleExchangeError): + raise AuthError('Google OAuth error', code=exchange_response.error) + else: + new_access_token = cast(GoogleExchange, exchange_response) + return new_access_token + + async def get_google_user(self, exchange: GoogleExchange) -> GoogleUser: + headers = { + 'Authorization': f'Bearer {exchange.access_token}', + 'Accept': 'application/json', + } + user_response = await self._httpx_client.get('https://www.googleapis.com/oauth2/v1/userinfo', headers=headers) + user_response.raise_for_status() + return GoogleUser.model_validate_json(user_response.content) + + +class ExchangeCache: + def __init__(self): + self._cache: Dict[str, Tuple[datetime, GoogleExchange]] = {} + + def get(self, key: str, max_age: timedelta) -> Union[GoogleExchange, None]: + now = datetime.now() + if (value := self._cache.get(key)) and now - value[0] <= max_age: + return value[1] + + def set(self, key: str, value: GoogleExchange, max_age: timedelta) -> None: + print('setting cache') + self._cache[key] = (datetime.now(), value) + print('cache', self._cache) + + def _purge(self, max_age: timedelta) -> None: + now = datetime.now() + self._cache = {key: value for key, value in self._cache.items() if now - value[0] <= max_age} + + +EXCHANGE_CACHE = ExchangeCache() + + +class AuthError(RuntimeError): + def __init__(self, message: str, code: str): + super().__init__(message) + self.code = code + + @staticmethod + def fastapi_handle(_request: 'Request', e: 'AuthError') -> 'JSONResponse': + return JSONResponse({'detail': str(e)}, status_code=400) + + +class StateProvider: + def __init__(self, secret: SecretStr, max_age: timedelta = timedelta(minutes=5)): + self._secret = secret + self._max_age = max_age + + async def new_state(self) -> str: + import jwt + + data = {'created_at': datetime.now().isoformat()} + return jwt.encode(data, self._secret.get_secret_value(), algorithm='HS256') + + async def check_state(self, state: str) -> bool: + import jwt + + try: + d = jwt.decode(state, self._secret.get_secret_value(), algorithms=['HS256']) + created_at = datetime.fromisoformat(d['created_at']) + return datetime.now() - created_at <= self._max_age + except jwt.DecodeError: + return False diff --git a/src/python-fastui/tests/test_auth_google.py b/src/python-fastui/tests/test_auth_google.py new file mode 100644 index 00000000..7729d613 --- /dev/null +++ b/src/python-fastui/tests/test_auth_google.py @@ -0,0 +1,120 @@ +from datetime import datetime, timedelta +from typing import List, Optional + +import httpx +import pytest +from fastapi import FastAPI +from pydantic import SecretStr + +from fastui.auth.google import ( + AuthError, + GoogleAuthProvider, + GoogleExchange, + GoogleExchangeError, + GoogleUser, + EXCHANGE_CACHE, +) +from httpx import Request, Response + + +class MockTransport(httpx.AsyncBaseTransport): + async def handle_async_request(self, request: Request) -> Response: + url = str(request.url) + method = request.method + + if url == "https://oauth2.googleapis.com/token" and method == "POST": + print(request.read()) + if b"code=bad_code" in request.read(): + return Response(200, json={"error": "bad code"}) + + json_data = { + "access_token": "test_access_token", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "test_refresh_token", + "scope": "email profile", + } + return Response(200, json=json_data) + + elif url == "https://www.googleapis.com/oauth2/v1/userinfo" and method == "GET": + json_data = { + "id": "12345", + "email": "user@example.com", + "verified_email": True, + "name": "Test User", + "given_name": "Test", + "family_name": "User", + "picture": "https://example.com/avatar.png", + "locale": "en", + } + return Response(200, json=json_data) + + return Response(404, json={"error": "not found"}) + + +@pytest.fixture +async def mock_httpx_client() -> httpx.AsyncClient: + client = httpx.AsyncClient(transport=MockTransport()) + yield client + await client.aclose() + + +@pytest.fixture +async def google_auth_provider(mock_httpx_client: httpx.AsyncClient): + return GoogleAuthProvider( + httpx_client=mock_httpx_client, + google_client_id="google_client_id", + google_client_secret=SecretStr("google_client_secret"), + redirect_uri="https://example.com/callback", + scopes=["email", "profile"], + state_provider=False, + exchange_cache_age=timedelta(minutes=5), + ) + + +async def test_authorization_url(google_auth_provider: GoogleAuthProvider): + url = await google_auth_provider.authorization_url() + assert url.startswith("https://accounts.google.com/o/oauth2/v2/auth?") + + +async def test_exchange_code_success(google_auth_provider: GoogleAuthProvider): + exchange = await google_auth_provider.exchange_code("good_code") + assert isinstance(exchange, GoogleExchange) + assert exchange.access_token == "test_access_token" + assert exchange.token_type == "Bearer" + assert exchange.scope == "email profile" + assert exchange.refresh_token == "test_refresh_token" + + +async def test_exchange_code_error(google_auth_provider: GoogleAuthProvider): + with pytest.raises(AuthError): + await google_auth_provider.exchange_code("bad_code") + + +async def test_refresh_access_token(google_auth_provider: GoogleAuthProvider): + new_token = await google_auth_provider.refresh_access_token("good_refresh_token") + assert isinstance(new_token, GoogleExchange) + assert new_token.access_token == "test_access_token" + + +async def test_get_google_user(google_auth_provider: GoogleAuthProvider): + exchange = GoogleExchange( + access_token="good_access_token", + token_type="Bearer", + scope="email profile", + expires_in=3600, + refresh_token="good_refresh_token", + ) + user = await google_auth_provider.get_google_user(exchange) + assert isinstance(user, GoogleUser) + assert user.id == "12345" + assert user.email == "user@example.com" + + +async def test_exchange_cache( + google_auth_provider: GoogleAuthProvider, +): + EXCHANGE_CACHE._cache.clear() + assert len(EXCHANGE_CACHE._cache) == 0 + await google_auth_provider.exchange_code("good_code") + assert len(EXCHANGE_CACHE._cache) == 1 From f0211e5e5bace47766ac4fd30cec7cee1f7a6f55 Mon Sep 17 00:00:00 2001 From: tim Date: Tue, 5 Mar 2024 11:16:34 +0000 Subject: [PATCH 2/9] add google auth to package --- src/python-fastui/fastui/auth/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/python-fastui/fastui/auth/__init__.py b/src/python-fastui/fastui/auth/__init__.py index 89377824..0d5452a0 100644 --- a/src/python-fastui/fastui/auth/__init__.py +++ b/src/python-fastui/fastui/auth/__init__.py @@ -1,4 +1,5 @@ from .github import GitHubAuthProvider, GitHubEmail, GitHubExchange, GithubUser +from .google import GoogleAuthProvider, GoogleExchange, GoogleExchangeError, GoogleUser from .shared import AuthError, AuthRedirect, fastapi_auth_exception_handling __all__ = ( @@ -6,6 +7,10 @@ 'GitHubExchange', 'GithubUser', 'GitHubEmail', + 'GoogleAuthProvider', + 'GoogleExchange', + 'GoogleUser', + 'GoogleExchangeError', 'AuthError', 'AuthRedirect', 'fastapi_auth_exception_handling', From f6c2a43925e4053f7467913d6a4cf24e3d0231aa Mon Sep 17 00:00:00 2001 From: tim Date: Tue, 5 Mar 2024 11:20:19 +0000 Subject: [PATCH 3/9] add google ot auth endpoint / pages --- demo/auth.py | 69 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 67 insertions(+), 2 deletions(-) diff --git a/demo/auth.py b/demo/auth.py index 065d222b..2dc8fb99 100644 --- a/demo/auth.py +++ b/demo/auth.py @@ -9,7 +9,7 @@ from fastapi import APIRouter, Depends, Request from fastui import AnyComponent, FastUI from fastui import components as c -from fastui.auth import AuthRedirect, GitHubAuthProvider +from fastui.auth import AuthRedirect, GitHubAuthProvider, GoogleAuthProvider from fastui.events import AuthEvent, GoToEvent, PageEvent from fastui.forms import fastui_form from httpx import AsyncClient @@ -27,6 +27,13 @@ GITHUB_REDIRECT = os.getenv('GITHUB_REDIRECT') +GOOGLE_CLIENT_ID = os.getenv( + 'GOOGLE_CLIENT_ID', 'yourkey.apps.googleusercontent.com' +) +GOOGLE_CLIENT_SECRET = SecretStr(os.getenv('GOOGLE_CLIENT_SECRET', 'yoursecret')) +GOOGLE_REDIRECT_URI = os.getenv('GOOGLE_REDIRECT_URI', 'http://localhost:8000/auth/login/google/redirect') + + async def get_github_auth(request: Request) -> GitHubAuthProvider: client: AsyncClient = request.app.state.httpx_client return GitHubAuthProvider( @@ -38,7 +45,7 @@ async def get_github_auth(request: Request) -> GitHubAuthProvider: ) -LoginKind: TypeAlias = Literal['password', 'github'] +LoginKind: TypeAlias = Literal['password', 'github', 'google'] @router.get('/login/{kind}', response_model=FastUI, response_model_exclude_none=True) @@ -63,6 +70,11 @@ def auth_login( on_click=PageEvent(name='tab', push_path='/auth/login/github', context={'kind': 'github'}), active='/auth/login/github', ), + c.Link( + components=[c.Text(text='Google Login')], + on_click=PageEvent(name='tab', push_path='/auth/login/google', context={'kind': 'google'}), + active='/auth/login/google', + ), ], mode='tabs', class_name='+ mb-4', @@ -98,6 +110,13 @@ def auth_login_content(kind: LoginKind) -> list[AnyComponent]: c.Paragraph(text='(Credentials are stored in the browser via a JWT only)'), c.Button(text='Login with GitHub', on_click=GoToEvent(url='/auth/login/github/gen')), ] + case 'google': + return [ + c.Heading(text='Google Login', level=3), + c.Paragraph(text='Demo of Google authentication.'), + c.Paragraph(text='(Credentials are stored in the browser via a JWT only)'), + c.Button(text='Login with Google', on_click=GoToEvent(url='/auth/login/google/gen')), + ] case _: raise ValueError(f'Invalid kind {kind!r}') @@ -167,3 +186,49 @@ async def github_redirect( ) token = user.encode_token() return [c.FireEvent(event=AuthEvent(token=token, url='/auth/profile'))] + +async def get_google_auth(request: Request) -> GoogleAuthProvider: + client: AsyncClient = request.app.state.httpx_client + return GoogleAuthProvider( + httpx_client=client, + google_client_id=GOOGLE_CLIENT_ID, + google_client_secret=GOOGLE_CLIENT_SECRET, + redirect_uri=GOOGLE_REDIRECT_URI, + scopes=['https://www.googleapis.com/auth/userinfo.email', 'https://www.googleapis.com/auth/userinfo.profile'], + ) + + +@router.get('/login/google/gen', response_model=FastUI, response_model_exclude_none=True) +async def auth_google_gen(request: Request) -> list[AnyComponent]: + google_auth = await get_google_auth(request) + try: + # here we should use the refresh token to get a new access token but for the demo we don't store it + refresh_token = "fake_refresh_token" + exchange = await google_auth.refresh_access_token(refresh_token) + google_user = await google_auth.get_google_user(exchange) + user = User( + email=google_user.email, + extra={'google_user_info': google_user.dict()}, + ) + token = user.encode_token() + return [c.FireEvent(event=AuthEvent(token=token, url='/auth/profile'))] + except Exception as e: + auth_url = await google_auth.authorization_url() + return [c.FireEvent(event=GoToEvent(url=auth_url))] + + +@router.get('/login/google/redirect', response_model=FastUI, response_model_exclude_none=True) +async def google_redirect( + request: Request, + code: str, +) -> list[AnyComponent]: + google_auth = await get_google_auth(request) + exchange = await google_auth.exchange_code(code) + google_user = await google_auth.get_google_user(exchange) + user = User( + email=google_user.email, + extra={'google_user_info': google_user.dict()}, + ) + # here should store the refresh token somewhere but for the demo we don't store it + token = user.encode_token() + return [c.FireEvent(event=AuthEvent(token=token, url='/auth/profile'))] From fd2525c5f04657da403241f59c0f0e9f63be98f4 Mon Sep 17 00:00:00 2001 From: tim Date: Tue, 5 Mar 2024 11:33:07 +0000 Subject: [PATCH 4/9] remove print statements --- src/python-fastui/fastui/auth/google.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/python-fastui/fastui/auth/google.py b/src/python-fastui/fastui/auth/google.py index 9602a400..7b9d1170 100644 --- a/src/python-fastui/fastui/auth/google.py +++ b/src/python-fastui/fastui/auth/google.py @@ -104,7 +104,6 @@ async def exchange_code(self, code: str) -> GoogleExchange: if exchange := EXCHANGE_CACHE.get(cache_key, self._exchange_cache_age): return exchange else: - print('getting exchange code from google and setting cache') exchange = await self._exchange_code(code) EXCHANGE_CACHE.set(cache_key, exchange, self._exchange_cache_age) return exchange @@ -171,9 +170,7 @@ def get(self, key: str, max_age: timedelta) -> Union[GoogleExchange, None]: return value[1] def set(self, key: str, value: GoogleExchange, max_age: timedelta) -> None: - print('setting cache') self._cache[key] = (datetime.now(), value) - print('cache', self._cache) def _purge(self, max_age: timedelta) -> None: now = datetime.now() From d3111d0c72940a9b761f64ef5254f3a2f6ca00c1 Mon Sep 17 00:00:00 2001 From: tim Date: Tue, 5 Mar 2024 11:37:49 +0000 Subject: [PATCH 5/9] format tests --- src/python-fastui/tests/test_auth_google.py | 86 ++++++++++----------- 1 file changed, 41 insertions(+), 45 deletions(-) diff --git a/src/python-fastui/tests/test_auth_google.py b/src/python-fastui/tests/test_auth_google.py index 7729d613..1f6d672c 100644 --- a/src/python-fastui/tests/test_auth_google.py +++ b/src/python-fastui/tests/test_auth_google.py @@ -1,20 +1,16 @@ -from datetime import datetime, timedelta -from typing import List, Optional +from datetime import timedelta import httpx import pytest -from fastapi import FastAPI -from pydantic import SecretStr - from fastui.auth.google import ( + EXCHANGE_CACHE, AuthError, GoogleAuthProvider, GoogleExchange, - GoogleExchangeError, GoogleUser, - EXCHANGE_CACHE, ) from httpx import Request, Response +from pydantic import SecretStr class MockTransport(httpx.AsyncBaseTransport): @@ -22,34 +18,34 @@ async def handle_async_request(self, request: Request) -> Response: url = str(request.url) method = request.method - if url == "https://oauth2.googleapis.com/token" and method == "POST": + if url == 'https://oauth2.googleapis.com/token' and method == 'POST': print(request.read()) - if b"code=bad_code" in request.read(): - return Response(200, json={"error": "bad code"}) + if b'code=bad_code' in request.read(): + return Response(200, json={'error': 'bad code'}) json_data = { - "access_token": "test_access_token", - "token_type": "Bearer", - "expires_in": 3600, - "refresh_token": "test_refresh_token", - "scope": "email profile", + 'access_token': 'test_access_token', + 'token_type': 'Bearer', + 'expires_in': 3600, + 'refresh_token': 'test_refresh_token', + 'scope': 'email profile', } return Response(200, json=json_data) - elif url == "https://www.googleapis.com/oauth2/v1/userinfo" and method == "GET": + elif url == 'https://www.googleapis.com/oauth2/v1/userinfo' and method == 'GET': json_data = { - "id": "12345", - "email": "user@example.com", - "verified_email": True, - "name": "Test User", - "given_name": "Test", - "family_name": "User", - "picture": "https://example.com/avatar.png", - "locale": "en", + 'id': '12345', + 'email': 'user@example.com', + 'verified_email': True, + 'name': 'Test User', + 'given_name': 'Test', + 'family_name': 'User', + 'picture': 'https://example.com/avatar.png', + 'locale': 'en', } return Response(200, json=json_data) - return Response(404, json={"error": "not found"}) + return Response(404, json={'error': 'not found'}) @pytest.fixture @@ -63,10 +59,10 @@ async def mock_httpx_client() -> httpx.AsyncClient: async def google_auth_provider(mock_httpx_client: httpx.AsyncClient): return GoogleAuthProvider( httpx_client=mock_httpx_client, - google_client_id="google_client_id", - google_client_secret=SecretStr("google_client_secret"), - redirect_uri="https://example.com/callback", - scopes=["email", "profile"], + google_client_id='google_client_id', + google_client_secret=SecretStr('google_client_secret'), + redirect_uri='https://example.com/callback', + scopes=['email', 'profile'], state_provider=False, exchange_cache_age=timedelta(minutes=5), ) @@ -74,41 +70,41 @@ async def google_auth_provider(mock_httpx_client: httpx.AsyncClient): async def test_authorization_url(google_auth_provider: GoogleAuthProvider): url = await google_auth_provider.authorization_url() - assert url.startswith("https://accounts.google.com/o/oauth2/v2/auth?") + assert url.startswith('https://accounts.google.com/o/oauth2/v2/auth?') async def test_exchange_code_success(google_auth_provider: GoogleAuthProvider): - exchange = await google_auth_provider.exchange_code("good_code") + exchange = await google_auth_provider.exchange_code('good_code') assert isinstance(exchange, GoogleExchange) - assert exchange.access_token == "test_access_token" - assert exchange.token_type == "Bearer" - assert exchange.scope == "email profile" - assert exchange.refresh_token == "test_refresh_token" + assert exchange.access_token == 'test_access_token' + assert exchange.token_type == 'Bearer' + assert exchange.scope == 'email profile' + assert exchange.refresh_token == 'test_refresh_token' async def test_exchange_code_error(google_auth_provider: GoogleAuthProvider): with pytest.raises(AuthError): - await google_auth_provider.exchange_code("bad_code") + await google_auth_provider.exchange_code('bad_code') async def test_refresh_access_token(google_auth_provider: GoogleAuthProvider): - new_token = await google_auth_provider.refresh_access_token("good_refresh_token") + new_token = await google_auth_provider.refresh_access_token('good_refresh_token') assert isinstance(new_token, GoogleExchange) - assert new_token.access_token == "test_access_token" + assert new_token.access_token == 'test_access_token' async def test_get_google_user(google_auth_provider: GoogleAuthProvider): exchange = GoogleExchange( - access_token="good_access_token", - token_type="Bearer", - scope="email profile", + access_token='good_access_token', + token_type='Bearer', + scope='email profile', expires_in=3600, - refresh_token="good_refresh_token", + refresh_token='good_refresh_token', ) user = await google_auth_provider.get_google_user(exchange) assert isinstance(user, GoogleUser) - assert user.id == "12345" - assert user.email == "user@example.com" + assert user.id == '12345' + assert user.email == 'user@example.com' async def test_exchange_cache( @@ -116,5 +112,5 @@ async def test_exchange_cache( ): EXCHANGE_CACHE._cache.clear() assert len(EXCHANGE_CACHE._cache) == 0 - await google_auth_provider.exchange_code("good_code") + await google_auth_provider.exchange_code('good_code') assert len(EXCHANGE_CACHE._cache) == 1 From 375bec6b6a3e17e886f384fb0f8f363e07a85c43 Mon Sep 17 00:00:00 2001 From: tim Date: Tue, 5 Mar 2024 11:42:48 +0000 Subject: [PATCH 6/9] lint demo --- demo/auth.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/demo/auth.py b/demo/auth.py index 2dc8fb99..f3633717 100644 --- a/demo/auth.py +++ b/demo/auth.py @@ -27,9 +27,7 @@ GITHUB_REDIRECT = os.getenv('GITHUB_REDIRECT') -GOOGLE_CLIENT_ID = os.getenv( - 'GOOGLE_CLIENT_ID', 'yourkey.apps.googleusercontent.com' -) +GOOGLE_CLIENT_ID = os.getenv('GOOGLE_CLIENT_ID', 'yourkey.apps.googleusercontent.com') GOOGLE_CLIENT_SECRET = SecretStr(os.getenv('GOOGLE_CLIENT_SECRET', 'yoursecret')) GOOGLE_REDIRECT_URI = os.getenv('GOOGLE_REDIRECT_URI', 'http://localhost:8000/auth/login/google/redirect') @@ -187,6 +185,7 @@ async def github_redirect( token = user.encode_token() return [c.FireEvent(event=AuthEvent(token=token, url='/auth/profile'))] + async def get_google_auth(request: Request) -> GoogleAuthProvider: client: AsyncClient = request.app.state.httpx_client return GoogleAuthProvider( @@ -203,7 +202,7 @@ async def auth_google_gen(request: Request) -> list[AnyComponent]: google_auth = await get_google_auth(request) try: # here we should use the refresh token to get a new access token but for the demo we don't store it - refresh_token = "fake_refresh_token" + refresh_token = 'fake_refresh_token' exchange = await google_auth.refresh_access_token(refresh_token) google_user = await google_auth.get_google_user(exchange) user = User( @@ -212,7 +211,7 @@ async def auth_google_gen(request: Request) -> list[AnyComponent]: ) token = user.encode_token() return [c.FireEvent(event=AuthEvent(token=token, url='/auth/profile'))] - except Exception as e: + except Exception: auth_url = await google_auth.authorization_url() return [c.FireEvent(event=GoToEvent(url=auth_url))] From 55523d2b72e9ea78793f77aec9f4d22f87d97611 Mon Sep 17 00:00:00 2001 From: tim Date: Tue, 5 Mar 2024 11:56:30 +0000 Subject: [PATCH 7/9] actually gogle doesn't need state --- src/python-fastui/fastui/auth/google.py | 30 --------------------- src/python-fastui/tests/test_auth_google.py | 1 - 2 files changed, 31 deletions(-) diff --git a/src/python-fastui/fastui/auth/google.py b/src/python-fastui/fastui/auth/google.py index 7b9d1170..083cf454 100644 --- a/src/python-fastui/fastui/auth/google.py +++ b/src/python-fastui/fastui/auth/google.py @@ -49,7 +49,6 @@ def __init__( google_client_secret: SecretStr, redirect_uri: Union[str, None] = None, scopes: Union[List[str], None] = None, - state_provider: Union['StateProvider', bool] = True, exchange_cache_age: Union[timedelta, None] = timedelta(seconds=30), ): self._httpx_client = httpx_client @@ -60,9 +59,6 @@ def __init__( 'https://www.googleapis.com/auth/userinfo.email', 'https://www.googleapis.com/auth/userinfo.profile', ] - self._state_provider = ( - state_provider if isinstance(state_provider, StateProvider) else StateProvider(google_client_secret) - ) self._exchange_cache_age = exchange_cache_age @classmethod @@ -72,7 +68,6 @@ async def create( client_id: str, client_secret: SecretStr, redirect_uri: Union[str, None] = None, - state_provider: Union['StateProvider', bool] = True, exchange_cache_age: Union[timedelta, None] = timedelta(seconds=10), ) -> AsyncIterator['GoogleAuthProvider']: async with httpx.AsyncClient() as client: @@ -81,7 +76,6 @@ async def create( client_id, client_secret, redirect_uri=redirect_uri, - state_provider=state_provider, exchange_cache_age=exchange_cache_age, ) @@ -94,8 +88,6 @@ async def authorization_url(self) -> str: 'access_type': 'offline', 'prompt': 'consent', } - if self._state_provider: - params['state'] = await self._state_provider.new_state() return f'https://accounts.google.com/o/oauth2/v2/auth?{urlencode(params)}' async def exchange_code(self, code: str) -> GoogleExchange: @@ -188,25 +180,3 @@ def __init__(self, message: str, code: str): @staticmethod def fastapi_handle(_request: 'Request', e: 'AuthError') -> 'JSONResponse': return JSONResponse({'detail': str(e)}, status_code=400) - - -class StateProvider: - def __init__(self, secret: SecretStr, max_age: timedelta = timedelta(minutes=5)): - self._secret = secret - self._max_age = max_age - - async def new_state(self) -> str: - import jwt - - data = {'created_at': datetime.now().isoformat()} - return jwt.encode(data, self._secret.get_secret_value(), algorithm='HS256') - - async def check_state(self, state: str) -> bool: - import jwt - - try: - d = jwt.decode(state, self._secret.get_secret_value(), algorithms=['HS256']) - created_at = datetime.fromisoformat(d['created_at']) - return datetime.now() - created_at <= self._max_age - except jwt.DecodeError: - return False diff --git a/src/python-fastui/tests/test_auth_google.py b/src/python-fastui/tests/test_auth_google.py index 1f6d672c..2086b186 100644 --- a/src/python-fastui/tests/test_auth_google.py +++ b/src/python-fastui/tests/test_auth_google.py @@ -63,7 +63,6 @@ async def google_auth_provider(mock_httpx_client: httpx.AsyncClient): google_client_secret=SecretStr('google_client_secret'), redirect_uri='https://example.com/callback', scopes=['email', 'profile'], - state_provider=False, exchange_cache_age=timedelta(minutes=5), ) From 4ea543892797895f0b2e8ba361e1f46ceaa02d80 Mon Sep 17 00:00:00 2001 From: tim Date: Tue, 5 Mar 2024 12:12:52 +0000 Subject: [PATCH 8/9] move more stuff to be shared --- src/python-fastui/fastui/auth/github.py | 34 ++--------------- src/python-fastui/fastui/auth/google.py | 39 +++----------------- src/python-fastui/fastui/auth/shared.py | 41 ++++++++++++++++++++- src/python-fastui/tests/test_auth_google.py | 6 +-- 4 files changed, 50 insertions(+), 70 deletions(-) diff --git a/src/python-fastui/fastui/auth/github.py b/src/python-fastui/fastui/auth/github.py index e32d41dd..5bbcf26a 100644 --- a/src/python-fastui/fastui/auth/github.py +++ b/src/python-fastui/fastui/auth/github.py @@ -1,12 +1,12 @@ from contextlib import asynccontextmanager from dataclasses import dataclass from datetime import datetime, timedelta, timezone -from typing import TYPE_CHECKING, AsyncIterator, Dict, List, Tuple, Union, cast +from typing import TYPE_CHECKING, AsyncIterator, Dict, List, Union, cast from urllib.parse import urlencode from pydantic import BaseModel, SecretStr, TypeAdapter, field_validator -from .shared import AuthError +from .shared import AuthError, ExchangeCache, ExchangeData if TYPE_CHECKING: import httpx @@ -22,7 +22,7 @@ class GitHubExchangeError: @dataclass -class GitHubExchange: +class GitHubExchange(ExchangeData): access_token: str token_type: str scope: List[str] @@ -219,34 +219,6 @@ def _auth_headers(exchange: GitHubExchange) -> Dict[str, str]: } -class ExchangeCache: - def __init__(self): - self._data: Dict[str, Tuple[datetime, GitHubExchange]] = {} - - def get(self, key: str, max_age: timedelta) -> Union[GitHubExchange, None]: - self._purge(max_age) - if v := self._data.get(key): - return v[1] - - def set(self, key: str, value: GitHubExchange) -> None: - self._data[key] = (datetime.now(), value) - - def _purge(self, max_age: timedelta) -> None: - """ - Remove old items from the exchange cache - """ - min_timestamp = datetime.now() - max_age - to_remove = [k for k, (ts, _) in self._data.items() if ts < min_timestamp] - for k in to_remove: - del self._data[k] - - def __len__(self) -> int: - return len(self._data) - - def clear(self) -> None: - self._data.clear() - - # exchange cache is a singleton so instantiating a new GitHubAuthProvider reuse the same cache EXCHANGE_CACHE = ExchangeCache() diff --git a/src/python-fastui/fastui/auth/google.py b/src/python-fastui/fastui/auth/google.py index 083cf454..1b9265a2 100644 --- a/src/python-fastui/fastui/auth/google.py +++ b/src/python-fastui/fastui/auth/google.py @@ -1,15 +1,13 @@ from contextlib import asynccontextmanager from dataclasses import dataclass -from datetime import datetime, timedelta -from typing import TYPE_CHECKING, AsyncIterator, Dict, List, Optional, Tuple, Union, cast +from datetime import timedelta +from typing import AsyncIterator, List, Optional, Union, cast from urllib.parse import urlencode import httpx from pydantic import BaseModel, SecretStr, TypeAdapter -if TYPE_CHECKING: - from fastapi import Request - from fastapi.responses import JSONResponse +from .shared import AuthError, ExchangeCache, ExchangeData @dataclass @@ -19,7 +17,7 @@ class GoogleExchangeError: @dataclass -class GoogleExchange: +class GoogleExchange(ExchangeData): access_token: str token_type: str scope: str @@ -97,7 +95,7 @@ async def exchange_code(self, code: str) -> GoogleExchange: return exchange else: exchange = await self._exchange_code(code) - EXCHANGE_CACHE.set(cache_key, exchange, self._exchange_cache_age) + EXCHANGE_CACHE.set(key=cache_key, value=exchange) return exchange else: return await self._exchange_code(code) @@ -152,31 +150,4 @@ async def get_google_user(self, exchange: GoogleExchange) -> GoogleUser: return GoogleUser.model_validate_json(user_response.content) -class ExchangeCache: - def __init__(self): - self._cache: Dict[str, Tuple[datetime, GoogleExchange]] = {} - - def get(self, key: str, max_age: timedelta) -> Union[GoogleExchange, None]: - now = datetime.now() - if (value := self._cache.get(key)) and now - value[0] <= max_age: - return value[1] - - def set(self, key: str, value: GoogleExchange, max_age: timedelta) -> None: - self._cache[key] = (datetime.now(), value) - - def _purge(self, max_age: timedelta) -> None: - now = datetime.now() - self._cache = {key: value for key, value in self._cache.items() if now - value[0] <= max_age} - - EXCHANGE_CACHE = ExchangeCache() - - -class AuthError(RuntimeError): - def __init__(self, message: str, code: str): - super().__init__(message) - self.code = code - - @staticmethod - def fastapi_handle(_request: 'Request', e: 'AuthError') -> 'JSONResponse': - return JSONResponse({'detail': str(e)}, status_code=400) diff --git a/src/python-fastui/fastui/auth/shared.py b/src/python-fastui/fastui/auth/shared.py index 37abedc5..cf1e35e3 100644 --- a/src/python-fastui/fastui/auth/shared.py +++ b/src/python-fastui/fastui/auth/shared.py @@ -1,6 +1,7 @@ import json from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, List, Tuple, Union +from datetime import datetime, timedelta +from typing import TYPE_CHECKING, Dict, Generic, List, Tuple, TypeVar, Union from .. import AnyComponent, FastUI, events from .. import components as c @@ -8,7 +9,8 @@ if TYPE_CHECKING: from fastapi import FastAPI -__all__ = 'AuthError', 'AuthRedirect', 'fastapi_auth_exception_handling' + +__all__ = 'AuthError', 'AuthRedirect', 'fastapi_auth_exception_handling', 'ExchangeCache', 'ExchangeData' class AuthException(ABC, Exception): @@ -56,3 +58,38 @@ def fastapi_auth_exception_handling(app: 'FastAPI') -> None: def auth_exception_handler(_request: Request, e: AuthException) -> Response: status_code, body = e.response_data() return Response(body, media_type='application/json', status_code=status_code) + + +class ExchangeData: + pass + + +T = TypeVar('T', bound='ExchangeData') + + +class ExchangeCache(Generic[T]): + def __init__(self): + self._data: Dict[str, Tuple[datetime, T]] = {} + + def get(self, key: str, max_age: timedelta) -> Union[T, None]: + self._purge(max_age) + if v := self._data.get(key): + return v[1] + + def set(self, key: str, value: T) -> None: + self._data[key] = (datetime.now(), value) + + def _purge(self, max_age: timedelta) -> None: + """ + Remove old items from the exchange cache + """ + min_timestamp = datetime.now() - max_age + to_remove = [k for k, (ts, _) in self._data.items() if ts < min_timestamp] + for k in to_remove: + del self._data[k] + + def __len__(self) -> int: + return len(self._data) + + def clear(self) -> None: + self._data.clear() diff --git a/src/python-fastui/tests/test_auth_google.py b/src/python-fastui/tests/test_auth_google.py index 2086b186..0bdcac58 100644 --- a/src/python-fastui/tests/test_auth_google.py +++ b/src/python-fastui/tests/test_auth_google.py @@ -109,7 +109,7 @@ async def test_get_google_user(google_auth_provider: GoogleAuthProvider): async def test_exchange_cache( google_auth_provider: GoogleAuthProvider, ): - EXCHANGE_CACHE._cache.clear() - assert len(EXCHANGE_CACHE._cache) == 0 + EXCHANGE_CACHE.clear() + assert len(EXCHANGE_CACHE) == 0 await google_auth_provider.exchange_code('good_code') - assert len(EXCHANGE_CACHE._cache) == 1 + assert len(EXCHANGE_CACHE) == 1 From b6a73153d9a9c28a723d8b9c282d9d8782c73927 Mon Sep 17 00:00:00 2001 From: tim Date: Tue, 5 Mar 2024 12:32:38 +0000 Subject: [PATCH 9/9] more coverage --- src/python-fastui/tests/test_auth_google.py | 23 +++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/python-fastui/tests/test_auth_google.py b/src/python-fastui/tests/test_auth_google.py index 0bdcac58..53e2e1ee 100644 --- a/src/python-fastui/tests/test_auth_google.py +++ b/src/python-fastui/tests/test_auth_google.py @@ -67,6 +67,11 @@ async def google_auth_provider(mock_httpx_client: httpx.AsyncClient): ) +async def test_create(): + async with GoogleAuthProvider.create('foo', SecretStr('bar')) as provider: + assert isinstance(provider._httpx_client, httpx.AsyncClient) + + async def test_authorization_url(google_auth_provider: GoogleAuthProvider): url = await google_auth_provider.authorization_url() assert url.startswith('https://accounts.google.com/o/oauth2/v2/auth?') @@ -113,3 +118,21 @@ async def test_exchange_cache( assert len(EXCHANGE_CACHE) == 0 await google_auth_provider.exchange_code('good_code') assert len(EXCHANGE_CACHE) == 1 + await google_auth_provider.exchange_code('good_code') + assert len(EXCHANGE_CACHE) == 1 + + +async def test_exchange_no_cache(mock_httpx_client): + EXCHANGE_CACHE.clear() + provider = GoogleAuthProvider( + httpx_client=mock_httpx_client, + google_client_id='google_client_id', + google_client_secret=SecretStr('google_client_secret'), + redirect_uri='https://example.com/callback', + scopes=['email', 'profile'], + exchange_cache_age=None, + ) + await provider.exchange_code('good_code') + assert len(EXCHANGE_CACHE) == 0 + await provider.exchange_code('good_code') + assert len(EXCHANGE_CACHE) == 0