Skip to content

Commit fb9e9d9

Browse files
committed
Implemented standalone IndieAuth
Still needs more tests and docs. Closes #10, Closes #11, refs #2
1 parent 4429452 commit fb9e9d9

File tree

5 files changed

+226
-56
lines changed

5 files changed

+226
-56
lines changed

datasette_indieauth/__init__.py

+185-22
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,79 @@
11
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+
)
39
import httpx
10+
import itsdangerous
11+
from jinja2 import escape
412
import urllib
513

14+
DATASETTE_INDIEAUTH_STATE = "datasette-indieauth-state"
15+
DATASETTE_INDIEAUTH_COOKIE = "datasette-indieauth-cookie"
16+
617

718
async def indieauth(request, datasette):
819
return await indieauth_page(request, datasette)
920

1021

11-
async def indieauth_page(request, datasette, initial_status=200):
22+
async def indieauth_page(request, datasette, status=200, error=None):
1223
from datasette.utils.asgi import Response
1324

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)
1632

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)
1938

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)
2462
response.set_cookie(
25-
"ds_actor",
63+
"datasette_indieauth",
2664
datasette.sign(
2765
{
28-
"a": {
29-
"me": extra,
30-
"display": display_url(extra),
31-
}
66+
"v": verifier,
3267
},
33-
"actor",
68+
DATASETTE_INDIEAUTH_COOKIE,
3469
),
3570
)
3671
return response
37-
else:
38-
error = extra
39-
status = 403
4072

4173
return Response.html(
4274
await datasette.render_template(
4375
"indieauth.html",
4476
{
45-
"client_id": client_id,
46-
"redirect_uri": redirect_uri,
4777
"error": error,
4878
},
4979
request=request,
@@ -52,7 +82,138 @@ async def indieauth_page(request, datasette, initial_status=200):
5282
)
5383

5484

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):
56217
async with httpx.AsyncClient() as client:
57218
response = await client.post(
58219
"https://indieauth.com/auth",
@@ -80,6 +241,8 @@ async def verify_code(code, client_id, redirect_uri):
80241
def register_routes():
81242
return [
82243
(r"^/-/indieauth$", indieauth),
244+
(r"^/-/indieauth/done$", indieauth_done),
245+
(r"^/-/indieauth/indieauth-com-done$", indieauth_com_done),
83246
]
84247

85248

datasette_indieauth/templates/indieauth.html

+7-4
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@
66
<h1>Sign in with IndieAuth</h1>
77

88
{% if error %}<p class="message-error">{{ error }}</p>{% endif %}
9-
<form action="https://indieauth.com/auth" method="get">
9+
10+
<form action="{{ urls.path("/-/indieauth") }}" method="post">
1011
<p><input type="text" name="me">
11-
<input type="hidden" name="client_id" value="{{ client_id }}">
12-
<input type="hidden" name="redirect_uri" value="{{ redirect_uri }}">
12+
<input type="hidden" name="csrftoken" value="{{ csrftoken() }}">
1313
<input type="submit" value="Login">
1414
</p>
1515
</form>
16-
{% endblock %}
16+
17+
<p>More about <a href="https://indieauth.net/">IndieAuth</a>.</p>
18+
19+
{% endblock %}

datasette_indieauth/utils.py

+21-19
Original file line numberDiff line numberDiff line change
@@ -109,22 +109,21 @@ def parse_link_rels(html):
109109
async def discover_endpoints(url):
110110
authorization_endpoint = None
111111
token_endpoint = None
112+
chunk = None
112113
async with httpx.AsyncClient() as client:
113-
async with client.stream("GET", url) as response:
114-
# Check response.links first
115-
if "authorization_endpoint" in response.links and response.links[
116-
"authorization_endpoint"
117-
].get("url"):
118-
authorization_endpoint = response.links["authorization_endpoint"]["url"]
119-
if "token_endpoint" in response.links and response.links[
120-
"token_endpoint"
121-
].get("url"):
122-
token_endpoint = response.links["token_endpoint"]["url"]
123-
if authorization_endpoint and token_endpoint:
124-
# No need to consume any HTML at all
125-
return authorization_endpoint, token_endpoint
126-
# Just pull the first chunk - chunks are 64KB
127-
chunk = next(response.iter_text())
114+
response = await client.get(url)
115+
# Check response.links first
116+
if "authorization_endpoint" in response.links and response.links[
117+
"authorization_endpoint"
118+
].get("url"):
119+
authorization_endpoint = response.links["authorization_endpoint"]["url"]
120+
if "token_endpoint" in response.links and response.links["token_endpoint"].get(
121+
"url"
122+
):
123+
token_endpoint = response.links["token_endpoint"]["url"]
124+
if authorization_endpoint and token_endpoint:
125+
return authorization_endpoint, token_endpoint
126+
chunk = response.text
128127
rels = parse_link_rels(chunk)
129128
if authorization_endpoint is None:
130129
matches = [r["href"] for r in rels if r["rel"] == "authorization_endpoint"]
@@ -152,9 +151,7 @@ def challenge_verifier_pair(length=64):
152151
assert 43 <= length <= 128
153152
verifier = secrets.token_hex(length // 2)
154153
# challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))
155-
challenge = encode_challenge(
156-
hashlib.sha256(verifier.encode("ascii")).digest()
157-
)
154+
challenge = encode_challenge(hashlib.sha256(verifier.encode("ascii")).digest())
158155
return challenge, verifier
159156

160157

@@ -174,12 +171,17 @@ def build_authorization_url(
174171
client_id,
175172
redirect_uri,
176173
me,
174+
signing_function,
177175
scope=None,
178176
verifier_length=64
179177
):
180178
"Returns (URL, state, verifier)"
181179
challenge, verifier = challenge_verifier_pair(verifier_length)
182-
state = secrets.token_hex(16)
180+
state = signing_function(
181+
{
182+
"a": authorization_endpoint,
183+
}
184+
)
183185
args = {
184186
"response_type": "code",
185187
"client_id": client_id,

tests/test_indieauth.py

+6-6
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,15 @@ async def test_plugin_is_installed():
1919

2020

2121
@pytest.mark.asyncio
22-
async def test_auth_succeeds(httpx_mock):
22+
async def test_indieauth_com_succeeds(httpx_mock):
2323
httpx_mock.add_response(
2424
url="https://indieauth.com/auth", data=b"me=https://simonwillison.net/"
2525
)
2626
datasette = Datasette([], memory=True)
2727
app = datasette.app()
2828
async with httpx.AsyncClient(app=app) as client:
2929
response = await client.get(
30-
"http://localhost/-/indieauth?code=code&me=https://simonwillison.net/",
30+
"http://localhost/-/indieauth/indieauth-com-done?code=code&me=https://simonwillison.net/",
3131
allow_redirects=False,
3232
)
3333
# Should set a cookie
@@ -38,7 +38,7 @@ async def test_auth_succeeds(httpx_mock):
3838

3939

4040
@pytest.mark.asyncio
41-
async def test_auth_fails(httpx_mock):
41+
async def test_indieauth_com_fails(httpx_mock):
4242
httpx_mock.add_response(
4343
url="https://indieauth.com/auth",
4444
status_code=404,
@@ -48,7 +48,7 @@ async def test_auth_fails(httpx_mock):
4848
app = datasette.app()
4949
async with httpx.AsyncClient(app=app) as client:
5050
response = await client.get(
51-
"http://localhost/-/indieauth?code=code&me=example.com",
51+
"http://localhost/-/indieauth/indieauth-com-done?code=code&me=example.com",
5252
allow_redirects=False,
5353
)
5454
# Should return error
@@ -77,12 +77,12 @@ async def test_restrict_access(httpx_mock):
7777
for path in paths:
7878
response = await client.get("http://localhost{}".format(path))
7979
assert response.status_code == 403
80-
assert '<form action="https://indieauth.com/auth"' in response.text
80+
assert '<form action="/-/indieauth" method="post">' in response.text
8181
assert "simonwillison.net" not in response.text
8282

8383
# Now do the login and try again
8484
response2 = await client.get(
85-
"http://localhost/-/indieauth?code=code&me=example.com",
85+
"http://localhost/-/indieauth/indieauth-com-done?code=code&me=example.com",
8686
allow_redirects=False,
8787
)
8888
assert response2.status_code == 302

0 commit comments

Comments
 (0)