Skip to content

Commit 7013dab

Browse files
authored
GitHub auth provider (#174)
1 parent 8be1d9e commit 7013dab

File tree

26 files changed

+738
-158
lines changed

26 files changed

+738
-158
lines changed

demo/__init__.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@
66
from fastapi import FastAPI
77
from fastapi.responses import HTMLResponse, PlainTextResponse
88
from fastui import prebuilt_html
9+
from fastui.auth import AuthError
910
from fastui.dev import dev_fastapi_app
1011
from httpx import AsyncClient
1112

1213
from .auth import router as auth_router
1314
from .components_list import router as components_router
14-
from .db import create_db
1515
from .forms import router as forms_router
1616
from .main import router as main_router
1717
from .sse import router as sse_router
@@ -20,7 +20,6 @@
2020

2121
@asynccontextmanager
2222
async def lifespan(app_: FastAPI):
23-
await create_db()
2423
async with AsyncClient() as client:
2524
app_.state.httpx_client = client
2625
yield
@@ -33,6 +32,7 @@ async def lifespan(app_: FastAPI):
3332
else:
3433
app = FastAPI(lifespan=lifespan)
3534

35+
app.exception_handler(AuthError)(AuthError.fastapi_handle)
3636
app.include_router(components_router, prefix='/api/components')
3737
app.include_router(sse_router, prefix='/api/components')
3838
app.include_router(table_router, prefix='/api/table')

demo/auth.py

+111-28
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,111 @@
11
from __future__ import annotations as _annotations
22

3-
from typing import Annotated
3+
import asyncio
4+
import json
5+
import os
6+
from dataclasses import asdict
7+
from typing import Annotated, Literal, TypeAlias
48

5-
from fastapi import APIRouter, Depends, Header
9+
from fastapi import APIRouter, Depends, Request
610
from fastui import AnyComponent, FastUI
711
from fastui import components as c
12+
from fastui.auth import GitHubAuthProvider
813
from fastui.events import AuthEvent, GoToEvent, PageEvent
914
from fastui.forms import fastui_form
15+
from httpx import AsyncClient
1016
from pydantic import BaseModel, EmailStr, Field, SecretStr
1117

12-
from . import db
18+
from .auth_user import User
1319
from .shared import demo_page
1420

1521
router = APIRouter()
1622

1723

18-
async def get_user(authorization: Annotated[str, Header()] = '') -> db.User | None:
19-
try:
20-
token = authorization.split(' ', 1)[1]
21-
except IndexError:
22-
return None
23-
else:
24-
return await db.get_user(token)
24+
# this will give an error when making requests to GitHub, but at least the app will run
25+
GITHUB_CLIENT_SECRET = SecretStr(os.getenv('GITHUB_CLIENT_SECRET', 'dummy-secret'))
26+
27+
28+
async def get_github_auth(request: Request) -> GitHubAuthProvider:
29+
client: AsyncClient = request.app.state.httpx_client
30+
return GitHubAuthProvider(
31+
httpx_client=client,
32+
github_client_id='9eddf87b27f71f52194a',
33+
github_client_secret=GITHUB_CLIENT_SECRET,
34+
scopes=['user:email'],
35+
)
2536

2637

27-
@router.get('/login', response_model=FastUI, response_model_exclude_none=True)
28-
def auth_login(user: Annotated[str | None, Depends(get_user)]) -> list[AnyComponent]:
38+
LoginKind: TypeAlias = Literal['password', 'github']
39+
40+
41+
@router.get('/login/{kind}', response_model=FastUI, response_model_exclude_none=True)
42+
async def auth_login(
43+
kind: LoginKind,
44+
user: Annotated[User | None, Depends(User.from_request)],
45+
github_auth: Annotated[GitHubAuthProvider, Depends(get_github_auth)],
46+
) -> list[AnyComponent]:
2947
if user is None:
3048
return demo_page(
31-
c.Paragraph(
32-
text=(
33-
'This is a very simple demo of authentication, '
34-
'here you can "login" with any email address and password.'
35-
)
49+
c.LinkList(
50+
links=[
51+
c.Link(
52+
components=[c.Text(text='Password Login')],
53+
on_click=PageEvent(name='tab', push_path='/auth/login/password', context={'kind': 'password'}),
54+
active='/auth/login/password',
55+
),
56+
c.Link(
57+
components=[c.Text(text='GitHub Login')],
58+
on_click=PageEvent(name='tab', push_path='/auth/login/github', context={'kind': 'github'}),
59+
active='/auth/login/github',
60+
),
61+
],
62+
mode='tabs',
63+
class_name='+ mb-4',
64+
),
65+
c.ServerLoad(
66+
path='/auth/login/content/{kind}',
67+
load_trigger=PageEvent(name='tab'),
68+
components=await auth_login_content(kind, github_auth),
3669
),
37-
c.Heading(text='Login'),
38-
c.ModelForm(model=LoginForm, submit_url='/api/auth/login'),
3970
title='Authentication',
4071
)
4172
else:
4273
return [c.FireEvent(event=GoToEvent(url='/auth/profile'))]
4374

4475

76+
@router.get('/login/content/{kind}', response_model=FastUI, response_model_exclude_none=True)
77+
async def auth_login_content(
78+
kind: LoginKind, github_auth: Annotated[GitHubAuthProvider, Depends(get_github_auth)]
79+
) -> list[AnyComponent]:
80+
match kind:
81+
case 'password':
82+
return [
83+
c.Heading(text='Password Login', level=3),
84+
c.Paragraph(
85+
text=(
86+
'This is a very simple demo of password authentication, '
87+
'here you can "login" with any email address and password.'
88+
)
89+
),
90+
c.Paragraph(text='(Passwords are not saved and email stored in the browser via a JWT)'),
91+
c.ModelForm(model=LoginForm, submit_url='/api/auth/login'),
92+
]
93+
case 'github':
94+
auth_url = await github_auth.authorization_url()
95+
return [
96+
c.Heading(text='GitHub Login', level=3),
97+
c.Paragraph(text='Demo of GitHub authentication.'),
98+
c.Paragraph(text='(Credentials are stored in the browser via a JWT)'),
99+
c.Button(text='Login with GitHub', on_click=GoToEvent(url=auth_url)),
100+
]
101+
case _:
102+
raise ValueError(f'Invalid kind {kind!r}')
103+
104+
45105
class LoginForm(BaseModel):
46-
email: EmailStr = Field(title='Email Address', description='Enter whatever value you like')
106+
email: EmailStr = Field(
107+
title='Email Address', description='Enter whatever value you like', json_schema_extra={'autocomplete': 'email'}
108+
)
47109
password: SecretStr = Field(
48110
title='Password',
49111
description='Enter whatever value you like, password is not checked',
@@ -53,19 +115,21 @@ class LoginForm(BaseModel):
53115

54116
@router.post('/login', response_model=FastUI, response_model_exclude_none=True)
55117
async def login_form_post(form: Annotated[LoginForm, fastui_form(LoginForm)]) -> list[AnyComponent]:
56-
token = await db.create_user(form.email)
118+
user = User(email=form.email, extra={})
119+
token = user.encode_token()
57120
return [c.FireEvent(event=AuthEvent(token=token, url='/auth/profile'))]
58121

59122

60123
@router.get('/profile', response_model=FastUI, response_model_exclude_none=True)
61-
async def profile(user: Annotated[db.User | None, Depends(get_user)]) -> list[AnyComponent]:
124+
async def profile(user: Annotated[User | None, Depends(User.from_request)]) -> list[AnyComponent]:
62125
if user is None:
63126
return [c.FireEvent(event=GoToEvent(url='/auth/login'))]
64127
else:
65-
active_count = await db.count_users()
66128
return demo_page(
67-
c.Paragraph(text=f'You are logged in as "{user.email}", {active_count} active users right now.'),
129+
c.Paragraph(text=f'You are logged in as "{user.email}".'),
68130
c.Button(text='Logout', on_click=PageEvent(name='submit-form')),
131+
c.Heading(text='User Data:', level=3),
132+
c.Code(language='json', text=json.dumps(asdict(user), indent=2)),
69133
c.Form(
70134
submit_url='/api/auth/logout',
71135
form_fields=[c.FormFieldInput(name='test', title='', initial='data', html_type='hidden')],
@@ -77,7 +141,26 @@ async def profile(user: Annotated[db.User | None, Depends(get_user)]) -> list[An
77141

78142

79143
@router.post('/logout', response_model=FastUI, response_model_exclude_none=True)
80-
async def logout_form_post(user: Annotated[db.User | None, Depends(get_user)]) -> list[AnyComponent]:
81-
if user is not None:
82-
await db.delete_user(user)
83-
return [c.FireEvent(event=AuthEvent(token=False, url='/auth/login'))]
144+
async def logout_form_post() -> list[AnyComponent]:
145+
return [c.FireEvent(event=AuthEvent(token=False, url='/auth/login/password'))]
146+
147+
148+
@router.get('/login/github/redirect', response_model=FastUI, response_model_exclude_none=True)
149+
async def github_redirect(
150+
code: str,
151+
state: str | None,
152+
github_auth: Annotated[GitHubAuthProvider, Depends(get_github_auth)],
153+
) -> list[AnyComponent]:
154+
exchange = await github_auth.exchange_code(code, state)
155+
user_info, emails = await asyncio.gather(
156+
github_auth.get_github_user(exchange), github_auth.get_github_user_emails(exchange)
157+
)
158+
user = User(
159+
email=next((e.email for e in emails if e.primary and e.verified), None),
160+
extra={
161+
'github_user_info': user_info.model_dump(),
162+
'github_emails': [e.model_dump() for e in emails],
163+
},
164+
)
165+
token = user.encode_token()
166+
return [c.FireEvent(event=AuthEvent(token=token, url='/auth/profile'))]

demo/auth_user.py

+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import json
2+
from dataclasses import asdict, dataclass
3+
from datetime import datetime
4+
from typing import Annotated, Any, Self
5+
6+
import jwt
7+
from fastapi import Header, HTTPException
8+
9+
JWT_SECRET = 'secret'
10+
11+
12+
@dataclass
13+
class User:
14+
email: str | None
15+
extra: dict[str, Any]
16+
17+
def encode_token(self) -> str:
18+
return jwt.encode(asdict(self), JWT_SECRET, algorithm='HS256', json_encoder=CustomJsonEncoder)
19+
20+
@classmethod
21+
async def from_request(cls, authorization: Annotated[str, Header()] = '') -> Self | None:
22+
try:
23+
token = authorization.split(' ', 1)[1]
24+
except IndexError:
25+
return None
26+
27+
try:
28+
return cls(**jwt.decode(token, JWT_SECRET, algorithms=['HS256']))
29+
except jwt.DecodeError:
30+
raise HTTPException(status_code=401, detail='Invalid token')
31+
32+
33+
class CustomJsonEncoder(json.JSONEncoder):
34+
def default(self, obj: Any) -> Any:
35+
if isinstance(obj, datetime):
36+
return obj.isoformat()
37+
else:
38+
return super().default(obj)

demo/db.py

-73
This file was deleted.

demo/main.py

+4
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ def api_index() -> list[AnyComponent]:
3737
* `Table` — See [cities table](/table/cities) and [users table](/table/users)
3838
* `Pagination` — See the bottom of the [cities table](/table/cities)
3939
* `ModelForm` — See [forms](/forms/login)
40+
41+
Authentication is supported via:
42+
* token based authentication — see [here](/auth/login/password) for an example of password authentication
43+
* GitHub OAuth — see [here](/auth/login/github) for an example of GitHub OAuth login
4044
"""
4145
return demo_page(c.Markdown(text=markdown))
4246

demo/shared.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ def demo_page(*components: AnyComponent, title: str | None = None) -> list[AnyCo
2424
),
2525
c.Link(
2626
components=[c.Text(text='Auth')],
27-
on_click=GoToEvent(url='/auth/login'),
27+
on_click=GoToEvent(url='/auth/login/password'),
2828
active='startswith:/auth',
2929
),
3030
c.Link(

0 commit comments

Comments
 (0)