1
1
from datasette import hookimpl
2
- from .utils import display_url
2
+ from .utils import (
3
+ build_authorization_url ,
4
+ canonicalize_url ,
5
+ discover_endpoints ,
6
+ display_url ,
7
+ verify_profile_url ,
8
+ )
3
9
import httpx
10
+ import itsdangerous
11
+ from jinja2 import escape
4
12
import urllib
5
13
14
+ DATASETTE_INDIEAUTH_STATE = "datasette-indieauth-state"
15
+ DATASETTE_INDIEAUTH_COOKIE = "datasette-indieauth-cookie"
16
+
6
17
7
18
async def indieauth (request , datasette ):
8
19
return await indieauth_page (request , datasette )
9
20
10
21
11
- async def indieauth_page (request , datasette , initial_status = 200 ):
22
+ async def indieauth_page (request , datasette , status = 200 , error = None ):
12
23
from datasette .utils .asgi import Response
13
24
14
- client_id = datasette .absolute_url (request , datasette .urls .instance ())
15
- redirect_uri = datasette .absolute_url (request , request .path )
25
+ urls = Urls (request , datasette )
26
+
27
+ if request .method == "POST" :
28
+ post = await request .post_vars ()
29
+ me = post .get ("me" )
30
+ if me :
31
+ me = canonicalize_url (me )
16
32
17
- error = None
18
- status = initial_status
33
+ if not me or not verify_profile_url (me ):
34
+ datasette .add_message (
35
+ request , "Invalid IndieAuth identifier" , message_type = datasette .ERROR
36
+ )
37
+ return Response .redirect (urls .login )
19
38
20
- if request .args .get ("code" ) and request .args .get ("me" ):
21
- ok , extra = await verify_code (request .args ["code" ], client_id , redirect_uri )
22
- if ok :
23
- response = Response .redirect (datasette .urls .instance ())
39
+ # Start the auth process
40
+ authorization_endpoint , token_endpoint = await discover_endpoints (me )
41
+ if not authorization_endpoint :
42
+ # Redirect to IndieAuth.com as a fallback
43
+ # TODO: Only do this if rel=me detected
44
+ # TODO: make this a configurable preference
45
+ indieauth_url = "https://indieauth.com/auth?" + urllib .parse .urlencode (
46
+ {
47
+ "me" : me ,
48
+ "client_id" : urls .client_id ,
49
+ "redirect_uri" : urls .indie_auth_com_redirect_uri ,
50
+ }
51
+ )
52
+ return Response .redirect (indieauth_url )
53
+ else :
54
+ authorization_url , state , verifier = build_authorization_url (
55
+ authorization_endpoint = authorization_endpoint ,
56
+ client_id = urls .client_id ,
57
+ redirect_uri = urls .redirect_uri ,
58
+ me = me ,
59
+ signing_function = lambda x : datasette .sign (x , DATASETTE_INDIEAUTH_STATE ),
60
+ )
61
+ response = Response .redirect (authorization_url )
24
62
response .set_cookie (
25
- "ds_actor " ,
63
+ "datasette_indieauth " ,
26
64
datasette .sign (
27
65
{
28
- "a" : {
29
- "me" : extra ,
30
- "display" : display_url (extra ),
31
- }
66
+ "v" : verifier ,
32
67
},
33
- "actor" ,
68
+ DATASETTE_INDIEAUTH_COOKIE ,
34
69
),
35
70
)
36
71
return response
37
- else :
38
- error = extra
39
- status = 403
40
72
41
73
return Response .html (
42
74
await datasette .render_template (
43
75
"indieauth.html" ,
44
76
{
45
- "client_id" : client_id ,
46
- "redirect_uri" : redirect_uri ,
47
77
"error" : error ,
48
78
},
49
79
request = request ,
@@ -52,7 +82,138 @@ async def indieauth_page(request, datasette, initial_status=200):
52
82
)
53
83
54
84
55
- async def verify_code (code , client_id , redirect_uri ):
85
+ async def indieauth_done (request , datasette ):
86
+ from datasette .utils .asgi import Response
87
+
88
+ state = request .args .get ("state" )
89
+ code = request .args .get ("code" )
90
+ try :
91
+ state_bits = datasette .unsign (state , DATASETTE_INDIEAUTH_STATE )
92
+ except itsdangerous .BadSignature :
93
+ return await indieauth_page (
94
+ request , datasette , error = "Invalid state returned by authorization server"
95
+ )
96
+ authorization_endpoint = state_bits ["a" ]
97
+
98
+ client_id = datasette .absolute_url (request , datasette .urls .instance ())
99
+ redirect_path = datasette .urls .path ("/-/indieauth/done" )
100
+ redirect_uri = datasette .absolute_url (request , redirect_path )
101
+
102
+ # code_verifier should be in a signed cookie
103
+ code_verifier = None
104
+ if "datasette_indieauth" in request .cookies :
105
+ try :
106
+ cookie_bits = datasette .unsign (
107
+ request .cookies ["datasette_indieauth" ], DATASETTE_INDIEAUTH_COOKIE
108
+ )
109
+ code_verifier = cookie_bits ["v" ]
110
+ except itsdangerous .BadSignature :
111
+ pass
112
+ if not code_verifier :
113
+ return await indieauth_page (
114
+ request , datasette , error = "Invalid datasette_indieauth cookie"
115
+ )
116
+
117
+ data = {
118
+ "grant_type" : "authorization_code" ,
119
+ "code" : code ,
120
+ "client_id" : client_id ,
121
+ "redirect_uri" : redirect_uri ,
122
+ "code_verifier" : code_verifier ,
123
+ }
124
+ async with httpx .AsyncClient () as client :
125
+ response = await client .post (authorization_endpoint , data = data )
126
+
127
+ if response .status_code == 200 :
128
+ info = response .json ()
129
+ if "me" not in info :
130
+ return await indieauth_page (
131
+ request ,
132
+ datasette ,
133
+ error = "Invalid authorization_code response from authorization server" ,
134
+ )
135
+ me = info ["me" ]
136
+ actor = {
137
+ "me" : me ,
138
+ "display" : display_url (me ),
139
+ }
140
+ if "scope" in info :
141
+ actor ["indieauth_scope" ] = info ["scope" ]
142
+
143
+ if "profile" in info and isinstance (info ["profile" , dict ]):
144
+ actor .update (info ["profile" ])
145
+ response = Response .redirect (datasette .urls .instance ())
146
+ response .set_cookie (
147
+ "ds_actor" ,
148
+ datasette .sign (
149
+ {"a" : actor },
150
+ "actor" ,
151
+ ),
152
+ )
153
+ return response
154
+ else :
155
+ return await indieauth_page (
156
+ request ,
157
+ datasette ,
158
+ error = "Invalid authorization_code response from authorization server" ,
159
+ )
160
+
161
+
162
+ async def indieauth_com_done (request , datasette ):
163
+ from datasette .utils .asgi import Response
164
+
165
+ urls = Urls (request , datasette )
166
+
167
+ if not (request .args .get ("code" ) and request .args .get ("me" )):
168
+ return Response .html ("?code= and ?me= are required" )
169
+ ok , extra = await verify_indieauth_com_code (
170
+ request .args ["code" ], urls .client_id , urls .indie_auth_com_redirect_uri
171
+ )
172
+ if ok :
173
+ response = Response .redirect (datasette .urls .instance ())
174
+ response .set_cookie (
175
+ "ds_actor" ,
176
+ datasette .sign (
177
+ {
178
+ "a" : {
179
+ "me" : extra ,
180
+ "display" : display_url (extra ),
181
+ }
182
+ },
183
+ "actor" ,
184
+ ),
185
+ )
186
+ return response
187
+ else :
188
+ return Response .html (escape (extra ), status = 403 )
189
+
190
+
191
+ class Urls :
192
+ def __init__ (self , request , datasette ):
193
+ self .request = request
194
+ self .datasette = datasette
195
+
196
+ def absolute (self , path ):
197
+ return self .datasette .absolute_url (self .request , self .datasette .urls .path (path ))
198
+
199
+ @property
200
+ def login (self ):
201
+ return self .absolute ("/-/indieauth" )
202
+
203
+ @property
204
+ def client_id (self ):
205
+ return self .absolute (self .datasette .urls .instance ())
206
+
207
+ @property
208
+ def redirect_uri (self ):
209
+ return self .absolute ("/-/indieauth/done" )
210
+
211
+ @property
212
+ def indie_auth_com_redirect_uri (self ):
213
+ return self .absolute ("/-/indieauth/indieauth-com-done" )
214
+
215
+
216
+ async def verify_indieauth_com_code (code , client_id , redirect_uri ):
56
217
async with httpx .AsyncClient () as client :
57
218
response = await client .post (
58
219
"https://indieauth.com/auth" ,
@@ -80,6 +241,8 @@ async def verify_code(code, client_id, redirect_uri):
80
241
def register_routes ():
81
242
return [
82
243
(r"^/-/indieauth$" , indieauth ),
244
+ (r"^/-/indieauth/done$" , indieauth_done ),
245
+ (r"^/-/indieauth/indieauth-com-done$" , indieauth_com_done ),
83
246
]
84
247
85
248
0 commit comments