11from __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
610from fastui import AnyComponent , FastUI
711from fastui import components as c
12+ from fastui .auth import GitHubAuthProvider
813from fastui .events import AuthEvent , GoToEvent , PageEvent
914from fastui .forms import fastui_form
15+ from httpx import AsyncClient
1016from pydantic import BaseModel , EmailStr , Field , SecretStr
1117
12- from . import db
18+ from .auth_user import User
1319from .shared import demo_page
1420
1521router = 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+
45105class 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 )
55117async 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' ))]
0 commit comments