1
1
from __future__ import annotations as _annotations
2
2
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
4
8
5
- from fastapi import APIRouter , Depends , Header
9
+ from fastapi import APIRouter , Depends , Request
6
10
from fastui import AnyComponent , FastUI
7
11
from fastui import components as c
12
+ from fastui .auth import GitHubAuthProvider
8
13
from fastui .events import AuthEvent , GoToEvent , PageEvent
9
14
from fastui .forms import fastui_form
15
+ from httpx import AsyncClient
10
16
from pydantic import BaseModel , EmailStr , Field , SecretStr
11
17
12
- from . import db
18
+ from .auth_user import User
13
19
from .shared import demo_page
14
20
15
21
router = APIRouter ()
16
22
17
23
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
+ )
25
36
26
37
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 ]:
29
47
if user is None :
30
48
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 ),
36
69
),
37
- c .Heading (text = 'Login' ),
38
- c .ModelForm (model = LoginForm , submit_url = '/api/auth/login' ),
39
70
title = 'Authentication' ,
40
71
)
41
72
else :
42
73
return [c .FireEvent (event = GoToEvent (url = '/auth/profile' ))]
43
74
44
75
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
+
45
105
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
+ )
47
109
password : SecretStr = Field (
48
110
title = 'Password' ,
49
111
description = 'Enter whatever value you like, password is not checked' ,
@@ -53,19 +115,21 @@ class LoginForm(BaseModel):
53
115
54
116
@router .post ('/login' , response_model = FastUI , response_model_exclude_none = True )
55
117
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 ()
57
120
return [c .FireEvent (event = AuthEvent (token = token , url = '/auth/profile' ))]
58
121
59
122
60
123
@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 ]:
62
125
if user is None :
63
126
return [c .FireEvent (event = GoToEvent (url = '/auth/login' ))]
64
127
else :
65
- active_count = await db .count_users ()
66
128
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 } ".' ),
68
130
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 )),
69
133
c .Form (
70
134
submit_url = '/api/auth/logout' ,
71
135
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
77
141
78
142
79
143
@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