From 67618224cfe8138cbb4f37157314d4fc73c71372 Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Fri, 9 Feb 2024 09:45:48 +0000 Subject: [PATCH 01/12] working on GitHub auth --- demo/auth.py | 108 ++++++++- demo/db.py | 12 +- demo/shared.py | 2 +- src/npm-fastui-bootstrap/src/index.tsx | 2 +- src/npm-fastui-prebuilt/src/main.scss | 6 + src/python-fastui/fastui/auth/__init__.py | 3 + src/python-fastui/fastui/auth/github.py | 257 ++++++++++++++++++++++ 7 files changed, 375 insertions(+), 15 deletions(-) create mode 100644 src/python-fastui/fastui/auth/__init__.py create mode 100644 src/python-fastui/fastui/auth/github.py diff --git a/demo/auth.py b/demo/auth.py index b9ba8da5..7d6b9840 100644 --- a/demo/auth.py +++ b/demo/auth.py @@ -1,13 +1,16 @@ from __future__ import annotations as _annotations -from typing import Annotated +from typing import Annotated, Literal, TypeAlias -from fastapi import APIRouter, Depends, Header +from fastapi import APIRouter, Depends, Header, Request from fastui import AnyComponent, FastUI from fastui import components as c +from fastui.auth import AuthError, GitHubAuthProvider from fastui.events import AuthEvent, GoToEvent, PageEvent from fastui.forms import fastui_form +from httpx import AsyncClient from pydantic import BaseModel, EmailStr, Field, SecretStr +from pydantic_settings import BaseSettings from . import db from .shared import demo_page @@ -24,24 +27,90 @@ async def get_user(authorization: Annotated[str, Header()] = '') -> db.User | No return await db.get_user(token) -@router.get('/login', response_model=FastUI, response_model_exclude_none=True) -def auth_login(user: Annotated[str | None, Depends(get_user)]) -> list[AnyComponent]: +class GitHubAuthSettings(BaseSettings): + github_client_id: str = '9eddf87b27f71f52194a' + github_client_secret: SecretStr + + +github_settings = GitHubAuthSettings() + + +async def get_github_auth(request: Request) -> GitHubAuthProvider: + client: AsyncClient = request.app.state.httpx_client + return GitHubAuthProvider( + httpx_client=client, + github_client_id=github_settings.github_client_id, + github_client_secret=github_settings.github_client_secret, + ) + + +LoginKind: TypeAlias = Literal['password', 'github'] + + +@router.get('/login/{kind}', response_model=FastUI, response_model_exclude_none=True) +async def auth_login( + kind: LoginKind, + user: Annotated[str | None, Depends(get_user)], + github_auth: Annotated[GitHubAuthProvider, Depends(get_github_auth)], +) -> list[AnyComponent]: if user is None: return demo_page( - c.Paragraph( - text=( - 'This is a very simple demo of authentication, ' - 'here you can "login" with any email address and password.' - ) + c.LinkList( + links=[ + c.Link( + components=[c.Text(text='Password Login')], + on_click=PageEvent(name='tab', push_path='/auth/login/password', context={'kind': 'password'}), + active='/auth/login/password', + ), + c.Link( + components=[c.Text(text='GitHub Login')], + on_click=PageEvent(name='tab', push_path='/auth/login/github', context={'kind': 'github'}), + active='/auth/login/github', + ), + ], + mode='tabs', + class_name='+ mb-4', + ), + c.ServerLoad( + path='/auth/login/content/{kind}', + load_trigger=PageEvent(name='tab'), + components=await auth_login_content(kind, github_auth), ), - c.Heading(text='Login'), - c.ModelForm(model=LoginForm, submit_url='/api/auth/login'), title='Authentication', ) else: return [c.FireEvent(event=GoToEvent(url='/auth/profile'))] +@router.get('/login/content/{kind}', response_model=FastUI, response_model_exclude_none=True) +async def auth_login_content( + kind: LoginKind, github_auth: Annotated[GitHubAuthProvider, Depends(get_github_auth)] +) -> list[AnyComponent]: + match kind: + case 'password': + return [ + c.Heading(text='Password Login', level=3), + c.Paragraph( + text=( + 'This is a very simple demo of password authentication, ' + 'here you can "login" with any email address and password.' + ) + ), + c.Paragraph(text='(Passwords are not saved and email address are deleted after around an hour.)'), + c.ModelForm(model=LoginForm, submit_url='/api/auth/login'), + ] + case 'github': + auth_url = await github_auth.authorization_url() + return [ + c.Heading(text='GitHub Login', level=3), + c.Paragraph(text='Demo of GitHub authentication.'), + c.Paragraph(text='(Credentials are deleted after around an hour.)'), + c.Button(text='Login with GitHub', on_click=GoToEvent(url=auth_url)), + ] + case _: + raise ValueError(f'Invalid kind {kind!r}') + + class LoginForm(BaseModel): email: EmailStr = Field(title='Email Address', description='Enter whatever value you like') password: SecretStr = Field( @@ -80,4 +149,19 @@ async def profile(user: Annotated[db.User | None, Depends(get_user)]) -> list[An async def logout_form_post(user: Annotated[db.User | None, Depends(get_user)]) -> list[AnyComponent]: if user is not None: await db.delete_user(user) - return [c.FireEvent(event=AuthEvent(token=False, url='/auth/login'))] + return [c.FireEvent(event=AuthEvent(token=False, url='/auth/login/password'))] + + +@router.get('/login/github/redirect', response_model=FastUI, response_model_exclude_none=True) +async def github_redirect( + code: str, + state: str | None, + github_auth: Annotated[GitHubAuthProvider, Depends(get_github_auth)], +) -> list[AnyComponent]: + try: + exchange = await github_auth.exchange_code(code, state) + except AuthError as e: + return [c.Text(text=f'Error: {e}')] + user_info = await github_auth.get_github_user(exchange) + token = await db.create_user(user_info.email or user_info.username) + return [c.FireEvent(event=AuthEvent(token=token, url='/auth/profile'))] diff --git a/demo/db.py b/demo/db.py index c3932518..6b08b116 100644 --- a/demo/db.py +++ b/demo/db.py @@ -5,6 +5,7 @@ from datetime import datetime import libsql_client +from libsql_client import LibsqlError @dataclass @@ -26,7 +27,16 @@ async def create_user(email: str) -> str: async with _connect() as conn: await _delete_old_users(conn) token = secrets.token_hex() - await conn.execute('insert into users (token, email) values (?, ?)', (token, email)) + try: + await conn.execute('insert into users (token, email) values (?, ?)', (token, email)) + except LibsqlError as e: + if e.code == 'SQLITE_CONSTRAINT_UNIQUE': + # update the last_active time + await conn.execute('update users set last_active = current_timestamp where email = ?', (email,)) + rs = await conn.execute('select token from users where email = ?', (email,)) + token = rs.rows[0][0] + else: + raise return token diff --git a/demo/shared.py b/demo/shared.py index 7b7263c7..8f3f12f1 100644 --- a/demo/shared.py +++ b/demo/shared.py @@ -24,7 +24,7 @@ def demo_page(*components: AnyComponent, title: str | None = None) -> list[AnyCo ), c.Link( components=[c.Text(text='Auth')], - on_click=GoToEvent(url='/auth/login'), + on_click=GoToEvent(url='/auth/login/password'), active='startswith:/auth', ), c.Link( diff --git a/src/npm-fastui-bootstrap/src/index.tsx b/src/npm-fastui-bootstrap/src/index.tsx index b2fef51f..b394a3c5 100644 --- a/src/npm-fastui-bootstrap/src/index.tsx +++ b/src/npm-fastui-bootstrap/src/index.tsx @@ -148,7 +148,7 @@ export const classNameGenerator: ClassNameGenerator = ({ if (props.statusCode === 502) { return 'm-3 text-muted' } else { - return 'alert alert-danger m-3' + return 'error-alert alert alert-danger m-3' } } } diff --git a/src/npm-fastui-prebuilt/src/main.scss b/src/npm-fastui-prebuilt/src/main.scss index 07cbace6..b099f9c8 100644 --- a/src/npm-fastui-prebuilt/src/main.scss +++ b/src/npm-fastui-prebuilt/src/main.scss @@ -116,3 +116,9 @@ h6 { box-shadow: 0 2.5em 0 0; } } + +// make sure alerts aren't hidden behind the navbar +.error-alert { + position: relative; + top: 60px; +} diff --git a/src/python-fastui/fastui/auth/__init__.py b/src/python-fastui/fastui/auth/__init__.py new file mode 100644 index 00000000..23f60222 --- /dev/null +++ b/src/python-fastui/fastui/auth/__init__.py @@ -0,0 +1,3 @@ +from .github import AuthError, GitHubAuthProvider + +__all__ = 'GitHubAuthProvider', 'AuthError' diff --git a/src/python-fastui/fastui/auth/github.py b/src/python-fastui/fastui/auth/github.py new file mode 100644 index 00000000..215fca0a --- /dev/null +++ b/src/python-fastui/fastui/auth/github.py @@ -0,0 +1,257 @@ +from __future__ import annotations + +import secrets +import tempfile +from abc import ABC, abstractmethod +from contextlib import asynccontextmanager +from dataclasses import dataclass +from datetime import datetime, timedelta +from pathlib import Path +from typing import TYPE_CHECKING, AsyncIterator, cast +from urllib.parse import urlencode + +import httpx +from pydantic import BaseModel, SecretStr, TypeAdapter, field_validator + +if TYPE_CHECKING: + from fastapi import Request + from fastapi.responses import JSONResponse + + +class GitHubAuthProvider: + """ + For details see https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps. + """ + + def __init__( + self, + httpx_client: httpx.AsyncClient, + github_client_id: str, + github_client_secret: SecretStr, + *, + redirect_uri: str | None = None, + state_provider: AbstractStateProvider | bool = True, + exchange_cache_age: timedelta | None = timedelta(seconds=10), + ): + self._httpx_client = httpx_client + self._github_client_id = github_client_id + self._github_client_secret = github_client_secret + self._redirect_uri = redirect_uri + if state_provider is True: + self._state_provider = _get_default_state_provider() + elif state_provider is False: + self._state_provider = None + else: + self._state_provider = state_provider + # cache exchange responses, see `exchange_code` for details + self._exchange_cache_age = exchange_cache_age + self._exchange_cache: dict[str, tuple[datetime, GitHubExchangeOk]] = {} + + @classmethod + @asynccontextmanager + async def create( + cls, + client_id: str, + client_secret: SecretStr, + *, + redirect_uri: str | None = None, + state_provider: AbstractStateProvider | bool = True, + exchange_cache_age: timedelta | None = timedelta(seconds=10), + ) -> AsyncIterator[GitHubAuthProvider]: + """ + Async context manager to create a GitHubAuth instance with a new `httpx.AsyncClient`. + """ + 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._github_client_id} + if self._redirect_uri: + params['redirect_uri'] = self._redirect_uri + if self._state_provider: + params['state'] = await self._state_provider.create_store_state() + return f'https://github.com/login/oauth/authorize?{urlencode(params)}' + + async def exchange_code(self, code: str, state: str | None = None) -> GitHubExchangeOk: + """ + Exchange a code for an access token. + + If `self._exchange_cache_age` is not `None` (the default), responses are cached for the given duration, to + work around issues with React often sending the same request multiple times in development mode. + """ + if self._exchange_cache_age: + cache_key = f'{code}:{state}' + now = datetime.now() + min_timestamp = now - self._exchange_cache_age + # remove anything older than the cache age + self._exchange_cache = {k: v for k, v in self._exchange_cache.items() if v[0] < min_timestamp} + + if cache_value := self._exchange_cache.get(cache_key): + return cache_value[1] + else: + exchange = await self._exchange_code(code, state) + self._exchange_cache[cache_key] = (now, exchange) + return exchange + else: + return await self._exchange_code(code, state) + + async def _exchange_code(self, code: str, state: str | None = None) -> GitHubExchangeOk: + if self._state_provider and state is not None: + if not await self._state_provider.check_state(state): + raise AuthError('Invalid GitHub auth state', code='invalid_state') + + params = { + 'client_id': self._github_client_id, + 'client_secret': self._github_client_secret.get_secret_value(), + 'code': code, + } + if self._redirect_uri: + params['redirect_uri'] = self._redirect_uri + + r = await self._httpx_client.post( + 'https://github.com/login/oauth/access_token', + params=params, + headers={'Accept': 'application/json'}, + ) + r.raise_for_status() + exchange_response = github_exchange_type.validate_json(r.content) + if isinstance(exchange_response, GitHubExchangeError): + if exchange_response.error == 'bad_verification_code': + raise AuthError('Invalid GitHub verification code', code=exchange_response.error) + else: + raise RuntimeError(f'Unexpected response from GitHub access token exchange: {r.text}') + else: + return cast(GitHubExchangeOk, exchange_response) + + async def get_github_user(self, exchange: GitHubExchangeOk) -> GithubUser: + """ + See https://docs.github.com/en/rest/users/users?apiVersion=2022-11-28#get-the-authenticated-user + """ + headers = { + 'Authorization': f'Bearer {exchange.access_token}', + 'Accept': 'application/vnd.github+json', + } + + user_response = await self._httpx_client.get('https://api.github.com/user', headers=headers) + user_response.raise_for_status() + return GithubUser.model_validate_json(user_response.content) + + +@dataclass +class GitHubExchangeError: + error: str + error_description: str | None = None + + +@dataclass +class GitHubExchangeOk: + access_token: str + token_type: str + scope: list[str] + + @field_validator('scope', mode='before') + def check_scope(cls, v: str) -> list[str]: + return [s for s in v.split(',') if s] + + +github_exchange_type = TypeAdapter(GitHubExchangeOk | GitHubExchangeError) + + +class GithubUser(BaseModel): + login: str + name: str | None + email: str | None + avatar_url: str + created_at: datetime + updated_at: datetime + public_repos: int + public_gists: int + followers: int + following: int + company: str | None + blog: str | None + location: str | None + hireable: bool | None + bio: str | None + twitter_username: str | None = None + + +class AuthError(RuntimeError): + # TODO if we add other providers, this should become shared + + def __init__(self, message: str, *, code: str): + super().__init__(message) + self.code = code + + @staticmethod + def fastapi_handle(_request: Request, e: AuthError) -> JSONResponse: + from fastapi.responses import JSONResponse + + return JSONResponse({'detail': str(e)}, status_code=401) + + +class AbstractStateProvider(ABC): + """ + This class is used to store and validate the state parameter used in the GitHub OAuth flow. + + You can override this class to implement a persistent state provider. + """ + + # TODO if we add other providers, this might become shared? + + async def create_store_state(self) -> str: + state = secrets.token_urlsafe() + await self.store_state(state) + return state + + @abstractmethod + async def store_state(self, state: str) -> None: + pass + + @abstractmethod + async def check_state(self, state: str) -> bool: + pass + + +class TmpFileStateProvider(AbstractStateProvider): + """ + This is a simple state provider for the GitHub OAuth flow which uses a file in the system's temporary directory. + """ + + def __init__(self): + self._path = Path(tempfile.gettempdir()) / 'fastui_github_auth_states.txt' + + async def store_state(self, state: str) -> None: + with self._path.open('a') as f: + f.write(f'{state}\n') + + async def check_state(self, state: str) -> bool: + remaining_lines = [] + found = False + for line in self._path.read_text().splitlines(): + if line == state: + found = True + else: + remaining_lines.append(line) + + if found: + self._path.write_text('\n'.join(remaining_lines) + '\n') + return found + + +# we have a global so creating new instances of the auth object will reuse the same state provider +_DEFAULT_STATE_PROVIDER: AbstractStateProvider | None = None + + +def _get_default_state_provider() -> AbstractStateProvider: + global _DEFAULT_STATE_PROVIDER + if _DEFAULT_STATE_PROVIDER is None: + _DEFAULT_STATE_PROVIDER = TmpFileStateProvider() + return _DEFAULT_STATE_PROVIDER From 538bd7749d794c39c0977028caf1605e68d40f67 Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Fri, 9 Feb 2024 10:49:36 +0000 Subject: [PATCH 02/12] add tests for github authentication --- demo/tests.py | 25 ++- src/python-fastui/fastui/auth/github.py | 15 +- src/python-fastui/tests/test_auth_github.py | 193 ++++++++++++++++++++ 3 files changed, 218 insertions(+), 15 deletions(-) create mode 100644 src/python-fastui/tests/test_auth_github.py diff --git a/demo/tests.py b/demo/tests.py index a6c02161..0677a332 100644 --- a/demo/tests.py +++ b/demo/tests.py @@ -6,17 +6,21 @@ from . import app -client = TestClient(app) +@pytest.fixture +def client(): + with TestClient(app) as test_client: + yield test_client -def test_index(): + +def test_index(client: TestClient): r = client.get('/') assert r.status_code == 200, r.text assert r.text.startswith('\n') assert r.headers.get('content-type') == 'text/html; charset=utf-8' -def test_api_root(): +def test_api_root(client: TestClient): r = client.get('/api/') assert r.status_code == 200 data = r.json() @@ -52,16 +56,17 @@ def get_menu_links(): """ This is pretty cursory, we just go through the menu and load each page. """ - r = client.get('/api/') - assert r.status_code == 200 - data = r.json() - for link in data[1]['links']: - url = link['onClick']['url'] - yield pytest.param(f'/api{url}', id=url) + with TestClient(app) as client: + r = client.get('/api/') + assert r.status_code == 200 + data = r.json() + for link in data[1]['links']: + url = link['onClick']['url'] + yield pytest.param(f'/api{url}', id=url) @pytest.mark.parametrize('url', get_menu_links()) -def test_menu_links(url: str): +def test_menu_links(client: TestClient, url: str): r = client.get(url) assert r.status_code == 200 data = r.json() diff --git a/src/python-fastui/fastui/auth/github.py b/src/python-fastui/fastui/auth/github.py index 215fca0a..d5a4d54d 100644 --- a/src/python-fastui/fastui/auth/github.py +++ b/src/python-fastui/fastui/auth/github.py @@ -91,7 +91,7 @@ async def exchange_code(self, code: str, state: str | None = None) -> GitHubExch now = datetime.now() min_timestamp = now - self._exchange_cache_age # remove anything older than the cache age - self._exchange_cache = {k: v for k, v in self._exchange_cache.items() if v[0] < min_timestamp} + self._exchange_cache = {k: v for k, v in self._exchange_cache.items() if v[0] > min_timestamp} if cache_value := self._exchange_cache.get(cache_key): return cache_value[1] @@ -103,8 +103,10 @@ async def exchange_code(self, code: str, state: str | None = None) -> GitHubExch return await self._exchange_code(code, state) async def _exchange_code(self, code: str, state: str | None = None) -> GitHubExchangeOk: - if self._state_provider and state is not None: - if not await self._state_provider.check_state(state): + if self._state_provider: + if state is None: + raise AuthError('Missing GitHub auth state', code='missing_state') + elif not await self._state_provider.check_state(state): raise AuthError('Invalid GitHub auth state', code='invalid_state') params = { @@ -225,14 +227,17 @@ class TmpFileStateProvider(AbstractStateProvider): This is a simple state provider for the GitHub OAuth flow which uses a file in the system's temporary directory. """ - def __init__(self): - self._path = Path(tempfile.gettempdir()) / 'fastui_github_auth_states.txt' + def __init__(self, path: Path | None = None): + self._path = path or Path(tempfile.gettempdir()) / 'fastui_github_auth_states.txt' async def store_state(self, state: str) -> None: with self._path.open('a') as f: f.write(f'{state}\n') async def check_state(self, state: str) -> bool: + if not self._path.exists(): + return False + remaining_lines = [] found = False for line in self._path.read_text().splitlines(): diff --git a/src/python-fastui/tests/test_auth_github.py b/src/python-fastui/tests/test_auth_github.py new file mode 100644 index 00000000..6b6f8e80 --- /dev/null +++ b/src/python-fastui/tests/test_auth_github.py @@ -0,0 +1,193 @@ +from pathlib import Path + +import httpx +import pytest +from fastapi import FastAPI +from fastui.auth import AuthError, GitHubAuthProvider +from fastui.auth.github import TmpFileStateProvider +from pydantic import SecretStr + + +@pytest.fixture +def github_requests() -> list[str]: + return [] + + +@pytest.fixture +def fake_github_app(github_requests: list[str]) -> FastAPI: + app = FastAPI() + + @app.post('/login/oauth/access_token') + async def access_token(code: str, client_id: str, client_secret: str): + github_requests.append(f'/login/oauth/access_token code={code}') + assert client_id == '1234' + assert client_secret == 'secret' + if code == 'good_user': + return {'access_token': 'good_token_user', 'token_type': 'bearer', 'scope': 'user'} + elif code == 'good': + return {'access_token': 'good_token', 'token_type': 'bearer', 'scope': ''} + elif code == 'bad_expected': + return {'error': 'bad_verification_code'} + else: + return {'error': 'bad_code'} + + @app.get('/user') + async def user(): + github_requests.append('/user') + return { + 'login': 'test_user', + 'name': 'Test User', + 'email': 'test@example.com', + 'avatar_url': 'https://example.com/avatar.png', + 'created_at': '2022-01-01T00:00:00Z', + 'updated_at': '2022-01-01T00:00:00Z', + 'public_repos': 0, + 'public_gists': 0, + 'followers': 0, + 'following': 0, + 'company': None, + 'blog': None, + 'location': None, + 'hireable': None, + 'bio': None, + } + + return app + + +@pytest.fixture +async def github_auth_provider(fake_github_app: FastAPI): + async with httpx.AsyncClient(app=fake_github_app) as client: + yield GitHubAuthProvider( + httpx_client=client, + github_client_id='1234', + github_client_secret=SecretStr('secret'), + state_provider=False, + exchange_cache_age=None, + ) + + +async def test_get_auth_url(github_auth_provider: GitHubAuthProvider): + url = await github_auth_provider.authorization_url() + # no state here + assert url == 'https://github.com/login/oauth/authorize?client_id=1234' + + +async def test_exchange_ok(github_auth_provider: GitHubAuthProvider, github_requests: list[str]): + assert github_requests == [] + exchange = await github_auth_provider.exchange_code('good') + assert exchange.access_token == 'good_token' + assert exchange.token_type == 'bearer' + assert exchange.scope == [] + assert github_requests == ['/login/oauth/access_token code=good'] + + +async def test_exchange_ok_user(github_auth_provider: GitHubAuthProvider): + exchange = await github_auth_provider.exchange_code('good_user') + assert exchange.access_token == 'good_token_user' + assert exchange.token_type == 'bearer' + assert exchange.scope == ['user'] + + +async def test_exchange_bad_expected(github_auth_provider: GitHubAuthProvider): + with pytest.raises(AuthError, match='^Invalid GitHub verification code'): + await github_auth_provider.exchange_code('bad_expected') + + +async def test_exchange_bad_unexpected(github_auth_provider: GitHubAuthProvider): + with pytest.raises(RuntimeError, match='^Unexpected response from GitHub access token exchange'): + await github_auth_provider.exchange_code('unknown') + + +@pytest.fixture +async def github_auth_provider_state(fake_github_app: FastAPI, tmp_path: Path): + async with httpx.AsyncClient(app=fake_github_app) as client: + yield GitHubAuthProvider( + httpx_client=client, + github_client_id='1234', + github_client_secret=SecretStr('secret'), + state_provider=TmpFileStateProvider(tmp_path / 'github_state.txt'), + ) + + +async def test_exchange_no_state(github_auth_provider_state: GitHubAuthProvider): + with pytest.raises(AuthError, match='^Missing GitHub auth state'): + await github_auth_provider_state.exchange_code('good') + + +async def test_exchange_bad_state(github_auth_provider_state: GitHubAuthProvider): + with pytest.raises(AuthError, match='^Invalid GitHub auth state'): + await github_auth_provider_state.exchange_code('good', 'bad_state') + + +async def test_exchange_good_state(github_auth_provider_state: GitHubAuthProvider, tmp_path: Path): + url = await github_auth_provider_state.authorization_url() + assert url.startswith('https://github.com/login/oauth/authorize?client_id=1234&state=') + state = url.rsplit('=', 1)[-1] + + state_path = tmp_path / 'github_state.txt' + assert state_path.read_text() == f'{state}\n' + + exchange = await github_auth_provider_state.exchange_code('good', state) + assert exchange.access_token == 'good_token' + + # state should be cleared + assert state_path.read_text() == '\n' + + +async def test_exchange_bad_state_file_exists(github_auth_provider_state: GitHubAuthProvider, tmp_path: Path): + url = await github_auth_provider_state.authorization_url() + assert url.startswith('https://github.com/login/oauth/authorize?client_id=1234&state=') + state = url.rsplit('=', 1)[-1] + + state_path = tmp_path / 'github_state.txt' + assert state_path.read_text() == f'{state}\n' + + with pytest.raises(AuthError, match='^Invalid GitHub auth state'): + await github_auth_provider_state.exchange_code('good', 'bad_state') + + # state still there + assert state_path.read_text() == f'{state}\n' + + +async def test_exchange_ok_repeat(github_auth_provider: GitHubAuthProvider, github_requests: list[str]): + assert github_requests == [] + exchange = await github_auth_provider.exchange_code('good') + assert exchange.access_token == 'good_token' + assert exchange.token_type == 'bearer' + assert exchange.scope == [] + assert github_requests == ['/login/oauth/access_token code=good'] + + exchange = await github_auth_provider.exchange_code('good') + assert exchange.access_token == 'good_token' + + assert github_requests == ['/login/oauth/access_token code=good', '/login/oauth/access_token code=good'] + + +async def test_exchange_ok_repeat_cached(fake_github_app: FastAPI, github_requests: list[str]): + async with httpx.AsyncClient(app=fake_github_app) as client: + github_auth_provider = GitHubAuthProvider( + httpx_client=client, + github_client_id='1234', + github_client_secret=SecretStr('secret'), + state_provider=False, + ) + assert github_requests == [] + await github_auth_provider.exchange_code('good') + assert github_requests == ['/login/oauth/access_token code=good'] + await github_auth_provider.exchange_code('good') + assert github_requests == ['/login/oauth/access_token code=good'] # no repeat request to github + await github_auth_provider.exchange_code('good_user') + assert github_requests == ['/login/oauth/access_token code=good', '/login/oauth/access_token code=good_user'] + + +async def test_get_github_user(github_auth_provider: GitHubAuthProvider, github_requests: list[str]): + assert github_requests == [] + exchange = await github_auth_provider.exchange_code('good') + assert github_requests == ['/login/oauth/access_token code=good'] + user = await github_auth_provider.get_github_user(exchange) + assert user.login == 'test_user' + assert user.name == 'Test User' + assert user.email == 'test@example.com' + + assert github_requests == ['/login/oauth/access_token code=good', '/user'] From 4c803745c37f9a62f8a56055bb851381f6792ba7 Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Fri, 9 Feb 2024 10:55:15 +0000 Subject: [PATCH 03/12] test create --- src/python-fastui/tests/test_auth_github.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/python-fastui/tests/test_auth_github.py b/src/python-fastui/tests/test_auth_github.py index 6b6f8e80..f66b9eda 100644 --- a/src/python-fastui/tests/test_auth_github.py +++ b/src/python-fastui/tests/test_auth_github.py @@ -191,3 +191,8 @@ async def test_get_github_user(github_auth_provider: GitHubAuthProvider, github_ assert user.email == 'test@example.com' assert github_requests == ['/login/oauth/access_token code=good', '/user'] + + +async def test_create(): + async with GitHubAuthProvider.create('foo', SecretStr('bar')) as provider: + assert isinstance(provider._httpx_client, httpx.AsyncClient) From a680d6bdbfc717374734b5853f44f66adf7b7006 Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Fri, 9 Feb 2024 11:09:41 +0000 Subject: [PATCH 04/12] fix for older python --- pyproject.toml | 12 +++ src/python-fastui/fastui/auth/github.py | 60 +++++------ src/python-fastui/tests/test_auth_github.py | 112 +++++++++++++------- 3 files changed, 112 insertions(+), 72 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index cae01196..d4518502 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,3 +26,15 @@ omit = [ "src/python-fastui/fastui/__main__.py", "src/python-fastui/fastui/generate_typescript.py", ] + +[tool.coverage.report] +precision = 2 +exclude_lines = [ + 'pragma: no cover', + 'raise NotImplementedError', + 'if TYPE_CHECKING:', + 'if typing.TYPE_CHECKING:', + '@overload', + '@typing.overload', + '\(Protocol\):$', +] diff --git a/src/python-fastui/fastui/auth/github.py b/src/python-fastui/fastui/auth/github.py index d5a4d54d..7b06fe6d 100644 --- a/src/python-fastui/fastui/auth/github.py +++ b/src/python-fastui/fastui/auth/github.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import secrets import tempfile from abc import ABC, abstractmethod @@ -7,7 +5,7 @@ from dataclasses import dataclass from datetime import datetime, timedelta from pathlib import Path -from typing import TYPE_CHECKING, AsyncIterator, cast +from typing import TYPE_CHECKING, AsyncIterator, List, Union, cast from urllib.parse import urlencode import httpx @@ -29,9 +27,9 @@ def __init__( github_client_id: str, github_client_secret: SecretStr, *, - redirect_uri: str | None = None, - state_provider: AbstractStateProvider | bool = True, - exchange_cache_age: timedelta | None = timedelta(seconds=10), + redirect_uri: Union[str, None] = None, + state_provider: Union['AbstractStateProvider', bool] = True, + exchange_cache_age: Union[timedelta, None] = timedelta(seconds=10), ): self._httpx_client = httpx_client self._github_client_id = github_client_id @@ -54,10 +52,10 @@ async def create( client_id: str, client_secret: SecretStr, *, - redirect_uri: str | None = None, - state_provider: AbstractStateProvider | bool = True, - exchange_cache_age: timedelta | None = timedelta(seconds=10), - ) -> AsyncIterator[GitHubAuthProvider]: + redirect_uri: Union[str, None] = None, + state_provider: Union['AbstractStateProvider', bool] = True, + exchange_cache_age: Union[timedelta, None] = timedelta(seconds=10), + ) -> AsyncIterator['GitHubAuthProvider']: """ Async context manager to create a GitHubAuth instance with a new `httpx.AsyncClient`. """ @@ -79,7 +77,7 @@ async def authorization_url(self) -> str: params['state'] = await self._state_provider.create_store_state() return f'https://github.com/login/oauth/authorize?{urlencode(params)}' - async def exchange_code(self, code: str, state: str | None = None) -> GitHubExchangeOk: + async def exchange_code(self, code: str, state: Union[str, None] = None) -> 'GitHubExchangeOk': """ Exchange a code for an access token. @@ -102,7 +100,7 @@ async def exchange_code(self, code: str, state: str | None = None) -> GitHubExch else: return await self._exchange_code(code, state) - async def _exchange_code(self, code: str, state: str | None = None) -> GitHubExchangeOk: + async def _exchange_code(self, code: str, state: Union[str, None] = None) -> 'GitHubExchangeOk': if self._state_provider: if state is None: raise AuthError('Missing GitHub auth state', code='missing_state') @@ -132,7 +130,7 @@ async def _exchange_code(self, code: str, state: str | None = None) -> GitHubExc else: return cast(GitHubExchangeOk, exchange_response) - async def get_github_user(self, exchange: GitHubExchangeOk) -> GithubUser: + async def get_github_user(self, exchange: 'GitHubExchangeOk') -> 'GithubUser': """ See https://docs.github.com/en/rest/users/users?apiVersion=2022-11-28#get-the-authenticated-user """ @@ -149,27 +147,27 @@ async def get_github_user(self, exchange: GitHubExchangeOk) -> GithubUser: @dataclass class GitHubExchangeError: error: str - error_description: str | None = None + error_description: Union[str, None] = None @dataclass class GitHubExchangeOk: access_token: str token_type: str - scope: list[str] + scope: List[str] @field_validator('scope', mode='before') - def check_scope(cls, v: str) -> list[str]: + def check_scope(cls, v: str) -> List[str]: return [s for s in v.split(',') if s] -github_exchange_type = TypeAdapter(GitHubExchangeOk | GitHubExchangeError) +github_exchange_type = TypeAdapter(Union[GitHubExchangeOk, GitHubExchangeError]) class GithubUser(BaseModel): login: str - name: str | None - email: str | None + name: Union[str, None] + email: Union[str, None] avatar_url: str created_at: datetime updated_at: datetime @@ -177,12 +175,12 @@ class GithubUser(BaseModel): public_gists: int followers: int following: int - company: str | None - blog: str | None - location: str | None - hireable: bool | None - bio: str | None - twitter_username: str | None = None + company: Union[str, None] + blog: Union[str, None] + location: Union[str, None] + hireable: Union[bool, None] + bio: Union[str, None] + twitter_username: Union[str, None] = None class AuthError(RuntimeError): @@ -193,10 +191,10 @@ def __init__(self, message: str, *, code: str): self.code = code @staticmethod - def fastapi_handle(_request: Request, e: AuthError) -> JSONResponse: + def fastapi_handle(_request: 'Request', e: 'AuthError') -> 'JSONResponse': from fastapi.responses import JSONResponse - return JSONResponse({'detail': str(e)}, status_code=401) + return JSONResponse({'detail': str(e)}, status_code=400) class AbstractStateProvider(ABC): @@ -215,11 +213,11 @@ async def create_store_state(self) -> str: @abstractmethod async def store_state(self, state: str) -> None: - pass + raise NotImplementedError @abstractmethod async def check_state(self, state: str) -> bool: - pass + raise NotImplementedError class TmpFileStateProvider(AbstractStateProvider): @@ -227,7 +225,7 @@ class TmpFileStateProvider(AbstractStateProvider): This is a simple state provider for the GitHub OAuth flow which uses a file in the system's temporary directory. """ - def __init__(self, path: Path | None = None): + def __init__(self, path: Union[Path, None] = None): self._path = path or Path(tempfile.gettempdir()) / 'fastui_github_auth_states.txt' async def store_state(self, state: str) -> None: @@ -252,7 +250,7 @@ async def check_state(self, state: str) -> bool: # we have a global so creating new instances of the auth object will reuse the same state provider -_DEFAULT_STATE_PROVIDER: AbstractStateProvider | None = None +_DEFAULT_STATE_PROVIDER: Union[AbstractStateProvider, None] = None def _get_default_state_provider() -> AbstractStateProvider: diff --git a/src/python-fastui/tests/test_auth_github.py b/src/python-fastui/tests/test_auth_github.py index f66b9eda..00820386 100644 --- a/src/python-fastui/tests/test_auth_github.py +++ b/src/python-fastui/tests/test_auth_github.py @@ -1,4 +1,5 @@ from pathlib import Path +from typing import List, Optional import httpx import pytest @@ -9,17 +10,20 @@ @pytest.fixture -def github_requests() -> list[str]: +def github_requests() -> List[str]: return [] @pytest.fixture -def fake_github_app(github_requests: list[str]) -> FastAPI: +def fake_github_app(github_requests: List[str]) -> FastAPI: app = FastAPI() @app.post('/login/oauth/access_token') - async def access_token(code: str, client_id: str, client_secret: str): - github_requests.append(f'/login/oauth/access_token code={code}') + async def access_token(code: str, client_id: str, client_secret: str, redirect_uri: Optional[str] = None): + r = f'/login/oauth/access_token code={code}' + if redirect_uri: + r += f' redirect_uri={redirect_uri}' + github_requests.append(r) assert client_id == '1234' assert client_secret == 'secret' if code == 'good_user': @@ -56,15 +60,20 @@ async def user(): @pytest.fixture -async def github_auth_provider(fake_github_app: FastAPI): +async def httpx_client(fake_github_app: FastAPI): async with httpx.AsyncClient(app=fake_github_app) as client: - yield GitHubAuthProvider( - httpx_client=client, - github_client_id='1234', - github_client_secret=SecretStr('secret'), - state_provider=False, - exchange_cache_age=None, - ) + yield client + + +@pytest.fixture +async def github_auth_provider(fake_github_app: FastAPI, httpx_client: httpx.AsyncClient): + return GitHubAuthProvider( + httpx_client=httpx_client, + github_client_id='1234', + github_client_secret=SecretStr('secret'), + state_provider=False, + exchange_cache_age=None, + ) async def test_get_auth_url(github_auth_provider: GitHubAuthProvider): @@ -73,7 +82,7 @@ async def test_get_auth_url(github_auth_provider: GitHubAuthProvider): assert url == 'https://github.com/login/oauth/authorize?client_id=1234' -async def test_exchange_ok(github_auth_provider: GitHubAuthProvider, github_requests: list[str]): +async def test_exchange_ok(github_auth_provider: GitHubAuthProvider, github_requests: List[str]): assert github_requests == [] exchange = await github_auth_provider.exchange_code('good') assert exchange.access_token == 'good_token' @@ -90,9 +99,13 @@ async def test_exchange_ok_user(github_auth_provider: GitHubAuthProvider): async def test_exchange_bad_expected(github_auth_provider: GitHubAuthProvider): - with pytest.raises(AuthError, match='^Invalid GitHub verification code'): + with pytest.raises(AuthError, match='^Invalid GitHub verification code') as exc_info: await github_auth_provider.exchange_code('bad_expected') + # request argument is ignored + r = AuthError.fastapi_handle(object(), exc_info.value) + assert r.status_code == 400 + async def test_exchange_bad_unexpected(github_auth_provider: GitHubAuthProvider): with pytest.raises(RuntimeError, match='^Unexpected response from GitHub access token exchange'): @@ -100,14 +113,13 @@ async def test_exchange_bad_unexpected(github_auth_provider: GitHubAuthProvider) @pytest.fixture -async def github_auth_provider_state(fake_github_app: FastAPI, tmp_path: Path): - async with httpx.AsyncClient(app=fake_github_app) as client: - yield GitHubAuthProvider( - httpx_client=client, - github_client_id='1234', - github_client_secret=SecretStr('secret'), - state_provider=TmpFileStateProvider(tmp_path / 'github_state.txt'), - ) +async def github_auth_provider_state(fake_github_app: FastAPI, httpx_client: httpx.AsyncClient, tmp_path: Path): + return GitHubAuthProvider( + httpx_client=httpx_client, + github_client_id='1234', + github_client_secret=SecretStr('secret'), + state_provider=TmpFileStateProvider(tmp_path / 'github_state.txt'), + ) async def test_exchange_no_state(github_auth_provider_state: GitHubAuthProvider): @@ -150,7 +162,7 @@ async def test_exchange_bad_state_file_exists(github_auth_provider_state: GitHub assert state_path.read_text() == f'{state}\n' -async def test_exchange_ok_repeat(github_auth_provider: GitHubAuthProvider, github_requests: list[str]): +async def test_exchange_ok_repeat(github_auth_provider: GitHubAuthProvider, github_requests: List[str]): assert github_requests == [] exchange = await github_auth_provider.exchange_code('good') assert exchange.access_token == 'good_token' @@ -164,24 +176,42 @@ async def test_exchange_ok_repeat(github_auth_provider: GitHubAuthProvider, gith assert github_requests == ['/login/oauth/access_token code=good', '/login/oauth/access_token code=good'] -async def test_exchange_ok_repeat_cached(fake_github_app: FastAPI, github_requests: list[str]): - async with httpx.AsyncClient(app=fake_github_app) as client: - github_auth_provider = GitHubAuthProvider( - httpx_client=client, - github_client_id='1234', - github_client_secret=SecretStr('secret'), - state_provider=False, - ) - assert github_requests == [] - await github_auth_provider.exchange_code('good') - assert github_requests == ['/login/oauth/access_token code=good'] - await github_auth_provider.exchange_code('good') - assert github_requests == ['/login/oauth/access_token code=good'] # no repeat request to github - await github_auth_provider.exchange_code('good_user') - assert github_requests == ['/login/oauth/access_token code=good', '/login/oauth/access_token code=good_user'] - - -async def test_get_github_user(github_auth_provider: GitHubAuthProvider, github_requests: list[str]): +async def test_exchange_ok_repeat_cached( + fake_github_app: FastAPI, httpx_client: httpx.AsyncClient, github_requests: List[str] +): + github_auth_provider = GitHubAuthProvider( + httpx_client=httpx_client, + github_client_id='1234', + github_client_secret=SecretStr('secret'), + state_provider=False, + ) + assert github_requests == [] + await github_auth_provider.exchange_code('good') + assert github_requests == ['/login/oauth/access_token code=good'] + await github_auth_provider.exchange_code('good') + assert github_requests == ['/login/oauth/access_token code=good'] # no repeat request to github + await github_auth_provider.exchange_code('good_user') + assert github_requests == ['/login/oauth/access_token code=good', '/login/oauth/access_token code=good_user'] + + +async def test_exchange_redirect_url( + fake_github_app: FastAPI, httpx_client: httpx.AsyncClient, github_requests: List[str] +): + github_auth_provider = GitHubAuthProvider( + httpx_client=httpx_client, + github_client_id='1234', + github_client_secret=SecretStr('secret'), + redirect_uri='/callback', + state_provider=False, + ) + url = await github_auth_provider.authorization_url() + assert url == 'https://github.com/login/oauth/authorize?client_id=1234&redirect_uri=%2Fcallback' + exchange = await github_auth_provider.exchange_code('good') + assert exchange.access_token == 'good_token' + assert github_requests == ['/login/oauth/access_token code=good redirect_uri=/callback'] + + +async def test_get_github_user(github_auth_provider: GitHubAuthProvider, github_requests: List[str]): assert github_requests == [] exchange = await github_auth_provider.exchange_code('good') assert github_requests == ['/login/oauth/access_token code=good'] From 67e46ba2c55705c8163a8e9fe018c14be430fbc3 Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Fri, 9 Feb 2024 11:19:37 +0000 Subject: [PATCH 05/12] add pydantic-settings dependency --- src/python-fastui/requirements/render.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/src/python-fastui/requirements/render.txt b/src/python-fastui/requirements/render.txt index 683fa919..fa2eda0f 100644 --- a/src/python-fastui/requirements/render.txt +++ b/src/python-fastui/requirements/render.txt @@ -4,3 +4,4 @@ src/python-fastui uvicorn[standard] httpx libsql-client +pydantic_settings From bd4fc6441e25e8843b2c9336675212795f80d007 Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Fri, 9 Feb 2024 11:25:58 +0000 Subject: [PATCH 06/12] remove pydantic-settings --- demo/auth.py | 14 +++++--------- src/python-fastui/requirements/render.txt | 1 - 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/demo/auth.py b/demo/auth.py index 7d6b9840..bde76aa8 100644 --- a/demo/auth.py +++ b/demo/auth.py @@ -1,5 +1,6 @@ from __future__ import annotations as _annotations +import os from typing import Annotated, Literal, TypeAlias from fastapi import APIRouter, Depends, Header, Request @@ -10,7 +11,6 @@ from fastui.forms import fastui_form from httpx import AsyncClient from pydantic import BaseModel, EmailStr, Field, SecretStr -from pydantic_settings import BaseSettings from . import db from .shared import demo_page @@ -27,20 +27,16 @@ async def get_user(authorization: Annotated[str, Header()] = '') -> db.User | No return await db.get_user(token) -class GitHubAuthSettings(BaseSettings): - github_client_id: str = '9eddf87b27f71f52194a' - github_client_secret: SecretStr - - -github_settings = GitHubAuthSettings() +# this will give an error when making requests to GitHub, but at least the app will run +GITHUB_CLIENT_SECRET = SecretStr(os.getenv('GITHUB_CLIENT_SECRET', 'dummy-secret')) async def get_github_auth(request: Request) -> GitHubAuthProvider: client: AsyncClient = request.app.state.httpx_client return GitHubAuthProvider( httpx_client=client, - github_client_id=github_settings.github_client_id, - github_client_secret=github_settings.github_client_secret, + github_client_id='9eddf87b27f71f52194a', + github_client_secret=GITHUB_CLIENT_SECRET, ) diff --git a/src/python-fastui/requirements/render.txt b/src/python-fastui/requirements/render.txt index fa2eda0f..683fa919 100644 --- a/src/python-fastui/requirements/render.txt +++ b/src/python-fastui/requirements/render.txt @@ -4,4 +4,3 @@ src/python-fastui uvicorn[standard] httpx libsql-client -pydantic_settings From 27512f7cbd131b5c23059b1290748771fff6c6b4 Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Fri, 9 Feb 2024 11:40:17 +0000 Subject: [PATCH 07/12] add get_github_user_emails --- src/python-fastui/fastui/auth/github.py | 120 ++++++++++++-------- src/python-fastui/tests/test_auth_github.py | 22 +++- 2 files changed, 92 insertions(+), 50 deletions(-) diff --git a/src/python-fastui/fastui/auth/github.py b/src/python-fastui/fastui/auth/github.py index 7b06fe6d..2bdba89f 100644 --- a/src/python-fastui/fastui/auth/github.py +++ b/src/python-fastui/fastui/auth/github.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from datetime import datetime, timedelta from pathlib import Path -from typing import TYPE_CHECKING, AsyncIterator, List, Union, cast +from typing import TYPE_CHECKING, AsyncIterator, Dict, List, Union, cast from urllib.parse import urlencode import httpx @@ -16,6 +16,55 @@ from fastapi.responses import JSONResponse +@dataclass +class GitHubExchangeError: + error: str + error_description: Union[str, None] = None + + +@dataclass +class GitHubExchange: + access_token: str + token_type: str + scope: List[str] + + @field_validator('scope', mode='before') + def check_scope(cls, v: str) -> List[str]: + return [s for s in v.split(',') if s] + + +github_exchange_type = TypeAdapter(Union[GitHubExchange, GitHubExchangeError]) + + +class GithubUser(BaseModel): + login: str + name: Union[str, None] + email: Union[str, None] + avatar_url: str + created_at: datetime + updated_at: datetime + public_repos: int + public_gists: int + followers: int + following: int + company: Union[str, None] + blog: Union[str, None] + location: Union[str, None] + hireable: Union[bool, None] + bio: Union[str, None] + twitter_username: Union[str, None] = None + + +class GitHubEmail(BaseModel): + email: str + primary: bool + verified: bool + visibility: Union[str, None] + + +github_emails_ta = TypeAdapter(List[GitHubEmail]) + + class GitHubAuthProvider: """ For details see https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps. @@ -43,7 +92,7 @@ def __init__( self._state_provider = state_provider # cache exchange responses, see `exchange_code` for details self._exchange_cache_age = exchange_cache_age - self._exchange_cache: dict[str, tuple[datetime, GitHubExchangeOk]] = {} + self._exchange_cache: dict[str, tuple[datetime, GitHubExchange]] = {} @classmethod @asynccontextmanager @@ -77,7 +126,7 @@ async def authorization_url(self) -> str: params['state'] = await self._state_provider.create_store_state() return f'https://github.com/login/oauth/authorize?{urlencode(params)}' - async def exchange_code(self, code: str, state: Union[str, None] = None) -> 'GitHubExchangeOk': + async def exchange_code(self, code: str, state: Union[str, None] = None) -> GitHubExchange: """ Exchange a code for an access token. @@ -100,7 +149,7 @@ async def exchange_code(self, code: str, state: Union[str, None] = None) -> 'Git else: return await self._exchange_code(code, state) - async def _exchange_code(self, code: str, state: Union[str, None] = None) -> 'GitHubExchangeOk': + async def _exchange_code(self, code: str, state: Union[str, None] = None) -> GitHubExchange: if self._state_provider: if state is None: raise AuthError('Missing GitHub auth state', code='missing_state') @@ -128,59 +177,32 @@ async def _exchange_code(self, code: str, state: Union[str, None] = None) -> 'Gi else: raise RuntimeError(f'Unexpected response from GitHub access token exchange: {r.text}') else: - return cast(GitHubExchangeOk, exchange_response) + return cast(GitHubExchange, exchange_response) - async def get_github_user(self, exchange: 'GitHubExchangeOk') -> 'GithubUser': + async def get_github_user(self, exchange: GitHubExchange) -> GithubUser: """ - See https://docs.github.com/en/rest/users/users?apiVersion=2022-11-28#get-the-authenticated-user + See https://docs.github.com/en/rest/users/users#get-the-authenticated-user """ - headers = { - 'Authorization': f'Bearer {exchange.access_token}', - 'Accept': 'application/vnd.github+json', - } - + headers = self._auth_headers(exchange) user_response = await self._httpx_client.get('https://api.github.com/user', headers=headers) user_response.raise_for_status() return GithubUser.model_validate_json(user_response.content) + async def get_github_user_emails(self, exchange: GitHubExchange) -> List[GitHubEmail]: + """ + See https://docs.github.com/en/rest/users/emails + """ + headers = self._auth_headers(exchange) + emails_response = await self._httpx_client.get('https://api.github.com/user/emails', headers=headers) + emails_response.raise_for_status() + return github_emails_ta.validate_json(emails_response.content) -@dataclass -class GitHubExchangeError: - error: str - error_description: Union[str, None] = None - - -@dataclass -class GitHubExchangeOk: - access_token: str - token_type: str - scope: List[str] - - @field_validator('scope', mode='before') - def check_scope(cls, v: str) -> List[str]: - return [s for s in v.split(',') if s] - - -github_exchange_type = TypeAdapter(Union[GitHubExchangeOk, GitHubExchangeError]) - - -class GithubUser(BaseModel): - login: str - name: Union[str, None] - email: Union[str, None] - avatar_url: str - created_at: datetime - updated_at: datetime - public_repos: int - public_gists: int - followers: int - following: int - company: Union[str, None] - blog: Union[str, None] - location: Union[str, None] - hireable: Union[bool, None] - bio: Union[str, None] - twitter_username: Union[str, None] = None + @staticmethod + def _auth_headers(exchange: GitHubExchange) -> Dict[str, str]: + return { + 'Authorization': f'Bearer {exchange.access_token}', + 'Accept': 'application/vnd.github+json', + } class AuthError(RuntimeError): diff --git a/src/python-fastui/tests/test_auth_github.py b/src/python-fastui/tests/test_auth_github.py index 00820386..3c205dad 100644 --- a/src/python-fastui/tests/test_auth_github.py +++ b/src/python-fastui/tests/test_auth_github.py @@ -5,7 +5,7 @@ import pytest from fastapi import FastAPI from fastui.auth import AuthError, GitHubAuthProvider -from fastui.auth.github import TmpFileStateProvider +from fastui.auth.github import GitHubEmail, TmpFileStateProvider from pydantic import SecretStr @@ -56,6 +56,14 @@ async def user(): 'bio': None, } + @app.get('/user/emails') + async def user_emails(): + github_requests.append('/user/emails') + return [ + {'email': 'foo@example.com', 'primary': False, 'verified': True, 'visibility': None}, + {'email': 'bar@example.com', 'primary': True, 'verified': True, 'visibility': 'public'}, + ] + return app @@ -223,6 +231,18 @@ async def test_get_github_user(github_auth_provider: GitHubAuthProvider, github_ assert github_requests == ['/login/oauth/access_token code=good', '/user'] +async def test_get_github_user_emails(github_auth_provider: GitHubAuthProvider, github_requests: List[str]): + assert github_requests == [] + exchange = await github_auth_provider.exchange_code('good') + assert github_requests == ['/login/oauth/access_token code=good'] + emails = await github_auth_provider.get_github_user_emails(exchange) + assert emails == [ + GitHubEmail(email='foo@example.com', primary=False, verified=True, visibility=None), + GitHubEmail(email='bar@example.com', primary=True, verified=True, visibility='public'), + ] + assert github_requests == ['/login/oauth/access_token code=good', '/user/emails'] + + async def test_create(): async with GitHubAuthProvider.create('foo', SecretStr('bar')) as provider: assert isinstance(provider._httpx_client, httpx.AsyncClient) From 36bf7c639d4a7978f5c8f4a774fb8c53fdfa4ef7 Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Fri, 9 Feb 2024 17:04:58 +0000 Subject: [PATCH 08/12] simplify state provider, remove db --- demo/__init__.py | 4 +- demo/auth.py | 53 ++++---- demo/auth_user.py | 38 ++++++ demo/db.py | 83 ------------ src/python-fastui/fastui/auth/github.py | 133 +++++++++----------- src/python-fastui/tests/test_auth_github.py | 25 +--- 6 files changed, 132 insertions(+), 204 deletions(-) create mode 100644 demo/auth_user.py delete mode 100644 demo/db.py diff --git a/demo/__init__.py b/demo/__init__.py index 616139d2..b93b1828 100644 --- a/demo/__init__.py +++ b/demo/__init__.py @@ -6,12 +6,12 @@ from fastapi import FastAPI from fastapi.responses import HTMLResponse, PlainTextResponse from fastui import prebuilt_html +from fastui.auth import AuthError from fastui.dev import dev_fastapi_app from httpx import AsyncClient from .auth import router as auth_router from .components_list import router as components_router -from .db import create_db from .forms import router as forms_router from .main import router as main_router from .sse import router as sse_router @@ -20,7 +20,6 @@ @asynccontextmanager async def lifespan(app_: FastAPI): - await create_db() async with AsyncClient() as client: app_.state.httpx_client = client yield @@ -33,6 +32,7 @@ async def lifespan(app_: FastAPI): else: app = FastAPI(lifespan=lifespan) +app.exception_handler(AuthError)(AuthError.fastapi_handle) app.include_router(components_router, prefix='/api/components') app.include_router(sse_router, prefix='/api/components') app.include_router(table_router, prefix='/api/table') diff --git a/demo/auth.py b/demo/auth.py index bde76aa8..bf20659b 100644 --- a/demo/auth.py +++ b/demo/auth.py @@ -1,32 +1,26 @@ from __future__ import annotations as _annotations +import asyncio +import json import os +from dataclasses import asdict from typing import Annotated, Literal, TypeAlias -from fastapi import APIRouter, Depends, Header, Request +from fastapi import APIRouter, Depends, Request from fastui import AnyComponent, FastUI from fastui import components as c -from fastui.auth import AuthError, GitHubAuthProvider +from fastui.auth import GitHubAuthProvider from fastui.events import AuthEvent, GoToEvent, PageEvent from fastui.forms import fastui_form from httpx import AsyncClient from pydantic import BaseModel, EmailStr, Field, SecretStr -from . import db +from .auth_user import User from .shared import demo_page router = APIRouter() -async def get_user(authorization: Annotated[str, Header()] = '') -> db.User | None: - try: - token = authorization.split(' ', 1)[1] - except IndexError: - return None - else: - return await db.get_user(token) - - # this will give an error when making requests to GitHub, but at least the app will run GITHUB_CLIENT_SECRET = SecretStr(os.getenv('GITHUB_CLIENT_SECRET', 'dummy-secret')) @@ -37,6 +31,7 @@ async def get_github_auth(request: Request) -> GitHubAuthProvider: httpx_client=client, github_client_id='9eddf87b27f71f52194a', github_client_secret=GITHUB_CLIENT_SECRET, + scopes=['user:email'], ) @@ -46,7 +41,7 @@ async def get_github_auth(request: Request) -> GitHubAuthProvider: @router.get('/login/{kind}', response_model=FastUI, response_model_exclude_none=True) async def auth_login( kind: LoginKind, - user: Annotated[str | None, Depends(get_user)], + user: Annotated[User | None, Depends(User.from_request)], github_auth: Annotated[GitHubAuthProvider, Depends(get_github_auth)], ) -> list[AnyComponent]: if user is None: @@ -118,19 +113,21 @@ class LoginForm(BaseModel): @router.post('/login', response_model=FastUI, response_model_exclude_none=True) async def login_form_post(form: Annotated[LoginForm, fastui_form(LoginForm)]) -> list[AnyComponent]: - token = await db.create_user(form.email) + user = User(email=form.email, extra={}) + token = user.encode_token() return [c.FireEvent(event=AuthEvent(token=token, url='/auth/profile'))] @router.get('/profile', response_model=FastUI, response_model_exclude_none=True) -async def profile(user: Annotated[db.User | None, Depends(get_user)]) -> list[AnyComponent]: +async def profile(user: Annotated[User | None, Depends(User.from_request)]) -> list[AnyComponent]: if user is None: return [c.FireEvent(event=GoToEvent(url='/auth/login'))] else: - active_count = await db.count_users() return demo_page( - c.Paragraph(text=f'You are logged in as "{user.email}", {active_count} active users right now.'), + c.Paragraph(text=f'You are logged in as "{user.email}".'), c.Button(text='Logout', on_click=PageEvent(name='submit-form')), + c.Heading(text='User Data:', level=3), + c.Code(language='json', text=json.dumps(asdict(user), indent=2)), c.Form( submit_url='/api/auth/logout', form_fields=[c.FormFieldInput(name='test', title='', initial='data', html_type='hidden')], @@ -142,9 +139,7 @@ async def profile(user: Annotated[db.User | None, Depends(get_user)]) -> list[An @router.post('/logout', response_model=FastUI, response_model_exclude_none=True) -async def logout_form_post(user: Annotated[db.User | None, Depends(get_user)]) -> list[AnyComponent]: - if user is not None: - await db.delete_user(user) +async def logout_form_post() -> list[AnyComponent]: return [c.FireEvent(event=AuthEvent(token=False, url='/auth/login/password'))] @@ -154,10 +149,16 @@ async def github_redirect( state: str | None, github_auth: Annotated[GitHubAuthProvider, Depends(get_github_auth)], ) -> list[AnyComponent]: - try: - exchange = await github_auth.exchange_code(code, state) - except AuthError as e: - return [c.Text(text=f'Error: {e}')] - user_info = await github_auth.get_github_user(exchange) - token = await db.create_user(user_info.email or user_info.username) + exchange = await github_auth.exchange_code(code, state) + user_info, emails = await asyncio.gather( + github_auth.get_github_user(exchange), github_auth.get_github_user_emails(exchange) + ) + user = User( + email=next((e.email for e in emails if e.primary and e.verified), None), + extra={ + 'github_user_info': user_info.model_dump(), + 'github_emails': [e.model_dump() for e in emails], + }, + ) + token = user.encode_token() return [c.FireEvent(event=AuthEvent(token=token, url='/auth/profile'))] diff --git a/demo/auth_user.py b/demo/auth_user.py new file mode 100644 index 00000000..e837d7f9 --- /dev/null +++ b/demo/auth_user.py @@ -0,0 +1,38 @@ +import json +from dataclasses import asdict, dataclass +from datetime import datetime +from typing import Annotated, Any, Self + +import jwt +from fastapi import Header, HTTPException + +JWT_SECRET = 'secret' + + +@dataclass +class User: + email: str | None + extra: dict[str, Any] + + def encode_token(self) -> str: + return jwt.encode(asdict(self), JWT_SECRET, algorithm='HS256', json_encoder=CustomJsonEncoder) + + @classmethod + async def from_request(cls, authorization: Annotated[str, Header()] = '') -> Self | None: + try: + token = authorization.split(' ', 1)[1] + except IndexError: + return None + + try: + return cls(**jwt.decode(token, JWT_SECRET, algorithms=['HS256'])) + except jwt.DecodeError: + raise HTTPException(status_code=401, detail='Invalid token') + + +class CustomJsonEncoder(json.JSONEncoder): + def default(self, obj: Any) -> Any: + if isinstance(obj, datetime): + return obj.isoformat() + else: + return super().default(obj) diff --git a/demo/db.py b/demo/db.py deleted file mode 100644 index 6b08b116..00000000 --- a/demo/db.py +++ /dev/null @@ -1,83 +0,0 @@ -import os -import secrets -from contextlib import asynccontextmanager -from dataclasses import dataclass -from datetime import datetime - -import libsql_client -from libsql_client import LibsqlError - - -@dataclass -class User: - token: str - email: str - last_active: datetime - - -async def get_user(token: str) -> User | None: - async with _connect() as conn: - rs = await conn.execute('select * from users where token = ?', (token,)) - if rs.rows: - await conn.execute('update users set last_active = current_timestamp where token = ?', (token,)) - return User(*rs.rows[0]) - - -async def create_user(email: str) -> str: - async with _connect() as conn: - await _delete_old_users(conn) - token = secrets.token_hex() - try: - await conn.execute('insert into users (token, email) values (?, ?)', (token, email)) - except LibsqlError as e: - if e.code == 'SQLITE_CONSTRAINT_UNIQUE': - # update the last_active time - await conn.execute('update users set last_active = current_timestamp where email = ?', (email,)) - rs = await conn.execute('select token from users where email = ?', (email,)) - token = rs.rows[0][0] - else: - raise - return token - - -async def delete_user(user: User) -> None: - async with _connect() as conn: - await conn.execute('delete from users where token = ?', (user.token,)) - - -async def count_users() -> int: - async with _connect() as conn: - await _delete_old_users(conn) - rs = await conn.execute('select count(*) from users') - return rs.rows[0][0] - - -async def create_db() -> None: - async with _connect() as conn: - rs = await conn.execute("select 1 from sqlite_master where type='table' and name='users'") - if not rs.rows: - await conn.execute(SCHEMA) - - -SCHEMA = """ -create table if not exists users ( - token varchar(255) primary key, - email varchar(255) not null unique, - last_active timestamp not null default current_timestamp -); -""" - - -async def _delete_old_users(conn: libsql_client.Client) -> None: - await conn.execute('delete from users where last_active < datetime(current_timestamp, "-1 hour")') - - -@asynccontextmanager -async def _connect() -> libsql_client.Client: - auth_token = os.getenv('SQLITE_AUTH_TOKEN') - if auth_token: - url = 'libsql://fastui-samuelcolvin.turso.io' - else: - url = 'file:users.db' - async with libsql_client.create_client(url, auth_token=auth_token) as conn: - yield conn diff --git a/src/python-fastui/fastui/auth/github.py b/src/python-fastui/fastui/auth/github.py index 2bdba89f..cae3cfd7 100644 --- a/src/python-fastui/fastui/auth/github.py +++ b/src/python-fastui/fastui/auth/github.py @@ -1,14 +1,11 @@ -import secrets -import tempfile -from abc import ABC, abstractmethod from contextlib import asynccontextmanager from dataclasses import dataclass from datetime import datetime, timedelta -from pathlib import Path -from typing import TYPE_CHECKING, AsyncIterator, Dict, List, Union, cast +from typing import TYPE_CHECKING, AsyncIterator, Dict, List, Tuple, Union, cast from urllib.parse import urlencode import httpx +import jwt from pydantic import BaseModel, SecretStr, TypeAdapter, field_validator if TYPE_CHECKING: @@ -77,22 +74,23 @@ def __init__( github_client_secret: SecretStr, *, redirect_uri: Union[str, None] = None, - state_provider: Union['AbstractStateProvider', bool] = True, - exchange_cache_age: Union[timedelta, None] = timedelta(seconds=10), + 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._github_client_id = github_client_id self._github_client_secret = github_client_secret self._redirect_uri = redirect_uri + self._scopes = scopes if state_provider is True: - self._state_provider = _get_default_state_provider() + self._state_provider = StateProvider(github_client_secret) elif state_provider is False: self._state_provider = None else: self._state_provider = state_provider # cache exchange responses, see `exchange_code` for details self._exchange_cache_age = exchange_cache_age - self._exchange_cache: dict[str, tuple[datetime, GitHubExchange]] = {} @classmethod @asynccontextmanager @@ -102,7 +100,7 @@ async def create( client_secret: SecretStr, *, redirect_uri: Union[str, None] = None, - state_provider: Union['AbstractStateProvider', bool] = True, + state_provider: Union['StateProvider', bool] = True, exchange_cache_age: Union[timedelta, None] = timedelta(seconds=10), ) -> AsyncIterator['GitHubAuthProvider']: """ @@ -119,32 +117,32 @@ async def create( ) async def authorization_url(self) -> str: + """ + See https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#1-request-a-users-github-identity + """ params = {'client_id': self._github_client_id} if self._redirect_uri: params['redirect_uri'] = self._redirect_uri + if self._scopes: + params['scope'] = ' '.join(self._scopes) if self._state_provider: - params['state'] = await self._state_provider.create_store_state() + params['state'] = await self._state_provider.new_state() return f'https://github.com/login/oauth/authorize?{urlencode(params)}' async def exchange_code(self, code: str, state: Union[str, None] = None) -> GitHubExchange: """ Exchange a code for an access token. - If `self._exchange_cache_age` is not `None` (the default), responses are cached for the given duration, to + If `self._exchange_cache_age` is not `None` (the default), responses are cached for the given duration to work around issues with React often sending the same request multiple times in development mode. """ if self._exchange_cache_age: cache_key = f'{code}:{state}' - now = datetime.now() - min_timestamp = now - self._exchange_cache_age - # remove anything older than the cache age - self._exchange_cache = {k: v for k, v in self._exchange_cache.items() if v[0] > min_timestamp} - - if cache_value := self._exchange_cache.get(cache_key): - return cache_value[1] + if exchange := EXCHANGE_CACHE.get(cache_key, self._exchange_cache_age): + return exchange else: exchange = await self._exchange_code(code, state) - self._exchange_cache[cache_key] = (now, exchange) + EXCHANGE_CACHE.set(cache_key, exchange) return exchange else: return await self._exchange_code(code, state) @@ -205,8 +203,34 @@ def _auth_headers(exchange: GitHubExchange) -> Dict[str, str]: } +class ExchangeCache: + def __init__(self): + self._cache: Dict[str, Tuple[datetime, GitHubExchange]] = {} + + def get(self, key: str, max_age: timedelta) -> Union[GitHubExchange, None]: + self._purge(max_age) + if v := self._cache.get(key): + return v[1] + + def set(self, key: str, value: GitHubExchange) -> None: + self._cache[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._cache.items() if ts < min_timestamp] + for k in to_remove: + del self._cache[k] + + +# exchange cache is a singleton so instantiating a new GitHubAuthProvider reuse the same cache +EXCHANGE_CACHE = ExchangeCache() + + class AuthError(RuntimeError): - # TODO if we add other providers, this should become shared + # TODO if we add other providers, this should be shared def __init__(self, message: str, *, code: str): super().__init__(message) @@ -219,64 +243,25 @@ def fastapi_handle(_request: 'Request', e: 'AuthError') -> 'JSONResponse': return JSONResponse({'detail': str(e)}, status_code=400) -class AbstractStateProvider(ABC): +class StateProvider: """ - This class is used to store and validate the state parameter used in the GitHub OAuth flow. - - You can override this class to implement a persistent state provider. + This is a simple state provider for the GitHub OAuth flow which uses a JWT to create an unguessable state. """ - # TODO if we add other providers, this might become shared? + # TODO if we add other providers, this could be shared - async def create_store_state(self) -> str: - state = secrets.token_urlsafe() - await self.store_state(state) - return state + def __init__(self, secret: SecretStr, max_age: timedelta = timedelta(minutes=5)): + self._secret = secret + self._max_age = max_age - @abstractmethod - async def store_state(self, state: str) -> None: - raise NotImplementedError + async def new_state(self) -> str: + data = {'created_at': datetime.now().isoformat()} + return jwt.encode(data, self._secret.get_secret_value(), algorithm='HS256') - @abstractmethod async def check_state(self, state: str) -> bool: - raise NotImplementedError - - -class TmpFileStateProvider(AbstractStateProvider): - """ - This is a simple state provider for the GitHub OAuth flow which uses a file in the system's temporary directory. - """ - - def __init__(self, path: Union[Path, None] = None): - self._path = path or Path(tempfile.gettempdir()) / 'fastui_github_auth_states.txt' - - async def store_state(self, state: str) -> None: - with self._path.open('a') as f: - f.write(f'{state}\n') - - async def check_state(self, state: str) -> bool: - if not self._path.exists(): + try: + d = jwt.decode(state, self._secret.get_secret_value(), algorithms=['HS256']) + except jwt.DecodeError: return False - - remaining_lines = [] - found = False - for line in self._path.read_text().splitlines(): - if line == state: - found = True - else: - remaining_lines.append(line) - - if found: - self._path.write_text('\n'.join(remaining_lines) + '\n') - return found - - -# we have a global so creating new instances of the auth object will reuse the same state provider -_DEFAULT_STATE_PROVIDER: Union[AbstractStateProvider, None] = None - - -def _get_default_state_provider() -> AbstractStateProvider: - global _DEFAULT_STATE_PROVIDER - if _DEFAULT_STATE_PROVIDER is None: - _DEFAULT_STATE_PROVIDER = TmpFileStateProvider() - return _DEFAULT_STATE_PROVIDER + else: + return datetime.fromisoformat(d['created_at']) > datetime.now() - self._max_age diff --git a/src/python-fastui/tests/test_auth_github.py b/src/python-fastui/tests/test_auth_github.py index 3c205dad..1723eed0 100644 --- a/src/python-fastui/tests/test_auth_github.py +++ b/src/python-fastui/tests/test_auth_github.py @@ -1,11 +1,10 @@ -from pathlib import Path from typing import List, Optional import httpx import pytest from fastapi import FastAPI from fastui.auth import AuthError, GitHubAuthProvider -from fastui.auth.github import GitHubEmail, TmpFileStateProvider +from fastui.auth.github import GitHubEmail from pydantic import SecretStr @@ -121,12 +120,12 @@ async def test_exchange_bad_unexpected(github_auth_provider: GitHubAuthProvider) @pytest.fixture -async def github_auth_provider_state(fake_github_app: FastAPI, httpx_client: httpx.AsyncClient, tmp_path: Path): +async def github_auth_provider_state(fake_github_app: FastAPI, httpx_client: httpx.AsyncClient): return GitHubAuthProvider( httpx_client=httpx_client, github_client_id='1234', github_client_secret=SecretStr('secret'), - state_provider=TmpFileStateProvider(tmp_path / 'github_state.txt'), + state_provider=True, ) @@ -140,35 +139,22 @@ async def test_exchange_bad_state(github_auth_provider_state: GitHubAuthProvider await github_auth_provider_state.exchange_code('good', 'bad_state') -async def test_exchange_good_state(github_auth_provider_state: GitHubAuthProvider, tmp_path: Path): +async def test_exchange_good_state(github_auth_provider_state: GitHubAuthProvider): url = await github_auth_provider_state.authorization_url() assert url.startswith('https://github.com/login/oauth/authorize?client_id=1234&state=') state = url.rsplit('=', 1)[-1] - state_path = tmp_path / 'github_state.txt' - assert state_path.read_text() == f'{state}\n' - exchange = await github_auth_provider_state.exchange_code('good', state) assert exchange.access_token == 'good_token' - # state should be cleared - assert state_path.read_text() == '\n' - -async def test_exchange_bad_state_file_exists(github_auth_provider_state: GitHubAuthProvider, tmp_path: Path): +async def test_exchange_bad_state_file_exists(github_auth_provider_state: GitHubAuthProvider): url = await github_auth_provider_state.authorization_url() assert url.startswith('https://github.com/login/oauth/authorize?client_id=1234&state=') - state = url.rsplit('=', 1)[-1] - - state_path = tmp_path / 'github_state.txt' - assert state_path.read_text() == f'{state}\n' with pytest.raises(AuthError, match='^Invalid GitHub auth state'): await github_auth_provider_state.exchange_code('good', 'bad_state') - # state still there - assert state_path.read_text() == f'{state}\n' - async def test_exchange_ok_repeat(github_auth_provider: GitHubAuthProvider, github_requests: List[str]): assert github_requests == [] @@ -211,6 +197,7 @@ async def test_exchange_redirect_url( github_client_secret=SecretStr('secret'), redirect_uri='/callback', state_provider=False, + exchange_cache_age=None, ) url = await github_auth_provider.authorization_url() assert url == 'https://github.com/login/oauth/authorize?client_id=1234&redirect_uri=%2Fcallback' From c2f9b96d6b043d0b1ef78769b5332f80e265e72e Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Fri, 9 Feb 2024 17:48:21 +0000 Subject: [PATCH 09/12] fix autocomplete, fix component unloaded --- demo/auth.py | 4 +++- src/npm-fastui/src/components/FireEvent.tsx | 13 +++++-------- src/npm-fastui/src/components/FormField.tsx | 3 ++- src/npm-fastui/src/components/ServerLoad.tsx | 10 +++++++--- src/npm-fastui/src/models.d.ts | 1 + src/python-fastui/fastui/auth/github.py | 12 ++++++++++++ src/python-fastui/fastui/components/forms.py | 1 + src/python-fastui/fastui/json_schema.py | 1 + 8 files changed, 32 insertions(+), 13 deletions(-) diff --git a/demo/auth.py b/demo/auth.py index bf20659b..24b0cb91 100644 --- a/demo/auth.py +++ b/demo/auth.py @@ -103,7 +103,9 @@ async def auth_login_content( class LoginForm(BaseModel): - email: EmailStr = Field(title='Email Address', description='Enter whatever value you like') + email: EmailStr = Field( + title='Email Address', description='Enter whatever value you like', json_schema_extra={'autocomplete': 'email'} + ) password: SecretStr = Field( title='Password', description='Enter whatever value you like, password is not checked', diff --git a/src/npm-fastui/src/components/FireEvent.tsx b/src/npm-fastui/src/components/FireEvent.tsx index 5949c122..7b4aa23c 100644 --- a/src/npm-fastui/src/components/FireEvent.tsx +++ b/src/npm-fastui/src/components/FireEvent.tsx @@ -1,4 +1,4 @@ -import { FC, useEffect, useRef } from 'react' +import { FC, useEffect } from 'react' import type { FireEvent } from '../models' @@ -6,15 +6,12 @@ import { useFireEvent } from '../events' export const FireEventComp: FC = ({ event, message }) => { const { fireEvent } = useFireEvent() - const fireEventRef = useRef(fireEvent) useEffect(() => { - fireEventRef.current = fireEvent - }, [fireEvent]) - - useEffect(() => { - fireEventRef.current(event) - }, [event, fireEventRef]) + // debounce the event so changes to fireEvent (from location changes) don't trigger the event many times + const clear = setTimeout(() => fireEvent(event), 50) + return () => clearTimeout(clear) + }, [fireEvent, event]) return <>{message} } diff --git a/src/npm-fastui/src/components/FormField.tsx b/src/npm-fastui/src/components/FormField.tsx index 0e67959b..1676eb1d 100644 --- a/src/npm-fastui/src/components/FormField.tsx +++ b/src/npm-fastui/src/components/FormField.tsx @@ -24,7 +24,7 @@ interface FormFieldInputProps extends FormFieldInput { } export const FormFieldInputComp: FC = (props) => { - const { name, placeholder, required, htmlType, locked } = props + const { name, placeholder, required, htmlType, locked, autocomplete } = props return (
@@ -38,6 +38,7 @@ export const FormFieldInputComp: FC = (props) => { required={required} disabled={locked} placeholder={placeholder} + autoComplete={autocomplete} aria-describedby={descId(props)} /> diff --git a/src/npm-fastui/src/components/ServerLoad.tsx b/src/npm-fastui/src/components/ServerLoad.tsx index 062c821b..d8730502 100644 --- a/src/npm-fastui/src/components/ServerLoad.tsx +++ b/src/npm-fastui/src/components/ServerLoad.tsx @@ -52,8 +52,12 @@ export const ServerLoadFetch: FC<{ path: string; devReload?: number }> = ({ path useEffect(() => { setTransitioning(true) - const promise = request({ url, expectedStatus: [200, 404] }) - promise.then(([status, data]) => { + let componentUnloaded = false + request({ url, expectedStatus: [200, 404] }).then(([status, data]) => { + if (componentUnloaded) { + setTransitioning(false) + return + } if (status === 200) { setComponentProps(data as FastProps[]) // if there's a fragment, scroll to that ID once the page is loaded @@ -73,7 +77,7 @@ export const ServerLoadFetch: FC<{ path: string; devReload?: number }> = ({ path }) return () => { - promise.then(() => null) + componentUnloaded = true } }, [url, path, request, devReload]) diff --git a/src/npm-fastui/src/models.d.ts b/src/npm-fastui/src/models.d.ts index 461118fe..a7a97bdf 100644 --- a/src/npm-fastui/src/models.d.ts +++ b/src/npm-fastui/src/models.d.ts @@ -339,6 +339,7 @@ export interface FormFieldInput { htmlType?: 'text' | 'date' | 'datetime-local' | 'time' | 'email' | 'url' | 'number' | 'password' | 'hidden' initial?: string | number placeholder?: string + autocomplete?: string type: 'FormFieldInput' } export interface FormFieldTextarea { diff --git a/src/python-fastui/fastui/auth/github.py b/src/python-fastui/fastui/auth/github.py index cae3cfd7..ef5a68c4 100644 --- a/src/python-fastui/fastui/auth/github.py +++ b/src/python-fastui/fastui/auth/github.py @@ -78,6 +78,18 @@ def __init__( state_provider: Union['StateProvider', bool] = True, exchange_cache_age: Union[timedelta, None] = timedelta(seconds=30), ): + """ + Arguments: + httpx_client: An instance of `httpx.AsyncClient` to use for making requests to GitHub. + github_client_id: The client ID of the GitHub OAuth app. + github_client_secret: The client secret of the GitHub OAuth app. + redirect_uri: The URL in your app where users will be sent after authorization, if custom + scopes: See https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps#available-scopes + state_provider: If `True`, use a `StateProvider` to generate and validate state parameters for the OAuth + flow, you can also provide an instance directly. + exchange_cache_age: If not `None`, + responses from the access token exchange are cached for the given duration. + """ self._httpx_client = httpx_client self._github_client_id = github_client_id self._github_client_secret = github_client_secret diff --git a/src/python-fastui/fastui/components/forms.py b/src/python-fastui/fastui/components/forms.py index 539fb6b5..042c27d9 100644 --- a/src/python-fastui/fastui/components/forms.py +++ b/src/python-fastui/fastui/components/forms.py @@ -31,6 +31,7 @@ class FormFieldInput(BaseFormField): html_type: InputHtmlType = pydantic.Field(default='text', serialization_alias='htmlType') initial: _t.Union[str, float, None] = None placeholder: _t.Union[str, None] = None + autocomplete: _t.Union[str, None] = None type: _t.Literal['FormFieldInput'] = 'FormFieldInput' diff --git a/src/python-fastui/fastui/json_schema.py b/src/python-fastui/fastui/json_schema.py index eb209e6f..e0ee88da 100644 --- a/src/python-fastui/fastui/json_schema.py +++ b/src/python-fastui/fastui/json_schema.py @@ -195,6 +195,7 @@ def json_schema_field_to_field( html_type=input_html_type(schema), required=required, initial=schema.get('default'), + autocomplete=schema.get('autocomplete'), description=schema.get('description'), ) From b0829985cf7708f86ad14f24f815b71d09123a8d Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Fri, 9 Feb 2024 17:57:40 +0000 Subject: [PATCH 10/12] make PyJWT optional, install it --- src/python-fastui/fastui/auth/github.py | 11 ++++++++--- src/python-fastui/requirements/render.txt | 2 +- src/python-fastui/requirements/test.in | 3 +-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/python-fastui/fastui/auth/github.py b/src/python-fastui/fastui/auth/github.py index ef5a68c4..d53ba69f 100644 --- a/src/python-fastui/fastui/auth/github.py +++ b/src/python-fastui/fastui/auth/github.py @@ -4,11 +4,10 @@ from typing import TYPE_CHECKING, AsyncIterator, Dict, List, Tuple, Union, cast from urllib.parse import urlencode -import httpx -import jwt from pydantic import BaseModel, SecretStr, TypeAdapter, field_validator if TYPE_CHECKING: + import httpx from fastapi import Request from fastapi.responses import JSONResponse @@ -69,7 +68,7 @@ class GitHubAuthProvider: def __init__( self, - httpx_client: httpx.AsyncClient, + httpx_client: 'httpx.AsyncClient', github_client_id: str, github_client_secret: SecretStr, *, @@ -118,6 +117,8 @@ async def create( """ Async context manager to create a GitHubAuth instance with a new `httpx.AsyncClient`. """ + import httpx + async with httpx.AsyncClient() as client: yield cls( client, @@ -267,10 +268,14 @@ def __init__(self, secret: SecretStr, max_age: timedelta = timedelta(minutes=5)) 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']) except jwt.DecodeError: diff --git a/src/python-fastui/requirements/render.txt b/src/python-fastui/requirements/render.txt index 683fa919..3d1fa143 100644 --- a/src/python-fastui/requirements/render.txt +++ b/src/python-fastui/requirements/render.txt @@ -3,4 +3,4 @@ src/python-fastui uvicorn[standard] httpx -libsql-client +PyJWT diff --git a/src/python-fastui/requirements/test.in b/src/python-fastui/requirements/test.in index ba6ee03c..5928ebed 100644 --- a/src/python-fastui/requirements/test.in +++ b/src/python-fastui/requirements/test.in @@ -4,5 +4,4 @@ pytest-pretty dirty-equals pytest-asyncio httpx -# libsql-client is used by demo -libsql-client +PyJWT From 687ac7889b4c4aa2687efb9493ff4b916589afc6 Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Fri, 9 Feb 2024 18:06:11 +0000 Subject: [PATCH 11/12] update deps and demo wording --- demo/auth.py | 4 ++-- demo/main.py | 4 ++++ src/python-fastui/requirements/test.txt | 25 ++----------------------- 3 files changed, 8 insertions(+), 25 deletions(-) diff --git a/demo/auth.py b/demo/auth.py index 24b0cb91..cd41a656 100644 --- a/demo/auth.py +++ b/demo/auth.py @@ -87,7 +87,7 @@ async def auth_login_content( 'here you can "login" with any email address and password.' ) ), - c.Paragraph(text='(Passwords are not saved and email address are deleted after around an hour.)'), + c.Paragraph(text='(Passwords are not saved and email stored in the browser via a JWT)'), c.ModelForm(model=LoginForm, submit_url='/api/auth/login'), ] case 'github': @@ -95,7 +95,7 @@ async def auth_login_content( return [ c.Heading(text='GitHub Login', level=3), c.Paragraph(text='Demo of GitHub authentication.'), - c.Paragraph(text='(Credentials are deleted after around an hour.)'), + c.Paragraph(text='(Credentials are stored in the browser via a JWT)'), c.Button(text='Login with GitHub', on_click=GoToEvent(url=auth_url)), ] case _: diff --git a/demo/main.py b/demo/main.py index 1671b10d..cdba7e22 100644 --- a/demo/main.py +++ b/demo/main.py @@ -37,6 +37,10 @@ def api_index() -> list[AnyComponent]: * `Table` — See [cities table](/table/cities) and [users table](/table/users) * `Pagination` — See the bottom of the [cities table](/table/cities) * `ModelForm` — See [forms](/forms/login) + +Authentication is supported via: +* token based authentication — see [here](/auth/login/password) for an example of password authentication +* GitHub OAuth — see [here](/auth/login/github) for an example of GitHub OAuth login """ return demo_page(c.Markdown(text=markdown)) diff --git a/src/python-fastui/requirements/test.txt b/src/python-fastui/requirements/test.txt index 12d55713..2d8e26df 100644 --- a/src/python-fastui/requirements/test.txt +++ b/src/python-fastui/requirements/test.txt @@ -4,16 +4,10 @@ # # pip-compile --constraint=src/python-fastui/requirements/lint.txt --constraint=src/python-fastui/requirements/pyproject.txt --output-file=src/python-fastui/requirements/test.txt --strip-extras src/python-fastui/requirements/test.in # -aiohttp==3.9.3 - # via libsql-client -aiosignal==1.3.1 - # via aiohttp anyio==4.2.0 # via # -c src/python-fastui/requirements/pyproject.txt # httpx -attrs==23.2.0 - # via aiohttp certifi==2024.2.2 # via # httpcore @@ -22,10 +16,6 @@ coverage==7.4.1 # via -r src/python-fastui/requirements/test.in dirty-equals==0.7.1.post0 # via -r src/python-fastui/requirements/test.in -frozenlist==1.4.1 - # via - # aiohttp - # aiosignal h11==0.14.0 # via httpcore httpcore==1.0.2 @@ -37,25 +27,20 @@ idna==3.6 # -c src/python-fastui/requirements/pyproject.txt # anyio # httpx - # yarl iniconfig==2.0.0 # via pytest -libsql-client==0.3.0 - # via -r src/python-fastui/requirements/test.in markdown-it-py==3.0.0 # via rich mdurl==0.1.2 # via markdown-it-py -multidict==6.0.5 - # via - # aiohttp - # yarl packaging==23.2 # via pytest pluggy==1.4.0 # via pytest pygments==2.17.2 # via rich +pyjwt==2.8.0 + # via -r src/python-fastui/requirements/test.in pytest==7.4.4 # via # -r src/python-fastui/requirements/test.in @@ -74,9 +59,3 @@ sniffio==1.3.0 # -c src/python-fastui/requirements/pyproject.txt # anyio # httpx -typing-extensions==4.9.0 - # via - # -c src/python-fastui/requirements/pyproject.txt - # libsql-client -yarl==1.9.4 - # via aiohttp From 0d50902a1c1add08ef09694727b9e82b7d68983f Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Fri, 9 Feb 2024 18:09:55 +0000 Subject: [PATCH 12/12] uprev frontend --- src/npm-fastui-bootstrap/package.json | 4 ++-- src/npm-fastui-prebuilt/package.json | 2 +- src/npm-fastui/package.json | 2 +- src/python-fastui/fastui/__init__.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/npm-fastui-bootstrap/package.json b/src/npm-fastui-bootstrap/package.json index f48496e0..817b42f5 100644 --- a/src/npm-fastui-bootstrap/package.json +++ b/src/npm-fastui-bootstrap/package.json @@ -1,6 +1,6 @@ { "name": "@pydantic/fastui-bootstrap", - "version": "0.0.15", + "version": "0.0.16", "description": "Boostrap renderer for FastUI", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -29,6 +29,6 @@ "sass": "^1.69.5" }, "peerDependencies": { - "@pydantic/fastui": "0.0.15" + "@pydantic/fastui": "0.0.16" } } diff --git a/src/npm-fastui-prebuilt/package.json b/src/npm-fastui-prebuilt/package.json index bd5342b6..00ef82f5 100644 --- a/src/npm-fastui-prebuilt/package.json +++ b/src/npm-fastui-prebuilt/package.json @@ -1,6 +1,6 @@ { "name": "@pydantic/fastui-prebuilt", - "version": "0.0.15", + "version": "0.0.16", "description": "Pre-built files for FastUI", "main": "dist/index.html", "type": "module", diff --git a/src/npm-fastui/package.json b/src/npm-fastui/package.json index bfafd41d..9979788c 100644 --- a/src/npm-fastui/package.json +++ b/src/npm-fastui/package.json @@ -1,6 +1,6 @@ { "name": "@pydantic/fastui", - "version": "0.0.15", + "version": "0.0.16", "description": "Build better UIs faster.", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/python-fastui/fastui/__init__.py b/src/python-fastui/fastui/__init__.py index 8ace8814..44069064 100644 --- a/src/python-fastui/fastui/__init__.py +++ b/src/python-fastui/fastui/__init__.py @@ -23,7 +23,7 @@ def coerce_to_list(cls, v): return [v] -_PREBUILT_VERSION = '0.0.15' +_PREBUILT_VERSION = '0.0.16' _PREBUILT_CDN_URL = f'https://cdn.jsdelivr.net/npm/@pydantic/fastui-prebuilt@{_PREBUILT_VERSION}/dist/assets'