Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

GitHub auth provider #174

Merged
merged 12 commits into from
Feb 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions demo/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -20,7 +20,6 @@

@asynccontextmanager
async def lifespan(app_: FastAPI):
await create_db()
async with AsyncClient() as client:
app_.state.httpx_client = client
yield
Expand All @@ -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')
Expand Down
139 changes: 111 additions & 28 deletions demo/auth.py
Original file line number Diff line number Diff line change
@@ -1,49 +1,111 @@
from __future__ import annotations as _annotations

from typing import Annotated
import asyncio
import json
import os
from dataclasses import asdict
from typing import Annotated, Literal, TypeAlias

from fastapi import APIRouter, Depends, Header
from fastapi import APIRouter, Depends, Request
from fastui import AnyComponent, FastUI
from fastui import components as c
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'))


async def get_github_auth(request: Request) -> GitHubAuthProvider:
client: AsyncClient = request.app.state.httpx_client
return GitHubAuthProvider(
httpx_client=client,
github_client_id='9eddf87b27f71f52194a',
github_client_secret=GITHUB_CLIENT_SECRET,
scopes=['user:email'],
)


@router.get('/login', response_model=FastUI, response_model_exclude_none=True)
def auth_login(user: Annotated[str | None, Depends(get_user)]) -> list[AnyComponent]:
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[User | None, Depends(User.from_request)],
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 stored in the browser via a JWT)'),
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 stored in the browser via a JWT)'),
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')
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',
Expand All @@ -53,19 +115,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')],
Expand All @@ -77,7 +141,26 @@ 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)
return [c.FireEvent(event=AuthEvent(token=False, url='/auth/login'))]
async def logout_form_post() -> list[AnyComponent]:
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]:
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'))]
38 changes: 38 additions & 0 deletions demo/auth_user.py
Original file line number Diff line number Diff line change
@@ -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)
73 changes: 0 additions & 73 deletions demo/db.py

This file was deleted.

4 changes: 4 additions & 0 deletions demo/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
2 changes: 1 addition & 1 deletion demo/shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading