Skip to content

Commit

Permalink
Remove IndieAuth.com fallback, closes #14
Browse files Browse the repository at this point in the history
  • Loading branch information
simonw committed Nov 18, 2020
1 parent ce873e1 commit bddccb8
Show file tree
Hide file tree
Showing 3 changed files with 60 additions and 149 deletions.
18 changes: 14 additions & 4 deletions README.md
Expand Up @@ -5,7 +5,7 @@
[![Tests](https://github.com/simonw/datasette-indieauth/workflows/Test/badge.svg)](https://github.com/simonw/datasette-indieauth/actions?query=workflow%3ATest)
[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/simonw/datasette-indieauth/blob/main/LICENSE)

Datasette authentication using [IndieAuth](https://indieauth.net/) and [RelMeAuth](http://microformats.org/wiki/RelMeAuth).
Datasette authentication using [IndieAuth](https://indieauth.net/).

## Demo

Expand All @@ -19,11 +19,19 @@ Install this plugin in the same environment as Datasette.

## Usage

Ensure you have a website with a domain that supports IndieAuth or RelMeAuth.
Ensure you have a website with a domain that supports IndieAuth or RelMeAuth. The easiest way to do that is to add the following HTML to your homepage, linking to your personal GitHub profile:

Visit `/-/indieauth` to begin the sign-in progress.
```html
<link href="https://github.com/simonw" rel="me">
<link rel="authorization_endpoint" href="https://indieauth.com/auth">
```
Your GitHub profile needs to link back to your website, to prove that your GitHub account should be a valid identifier for that page.

Now visit `/-/indieauth` on your Datasette instance to begin the sign-in progress.

When a user signs in using IndieAuth they will be recieve a signed `ds_actor` cookie identifying them as an actor that looks like this:
## Actor

When a user signs in using IndieAuth they will be recieve a signed `ds_actor` cookie identifying them as an [actor](https://docs.datasette.io/en/stable/authentication.html#actors) that looks like this:

```json
{
Expand All @@ -32,6 +40,8 @@ When a user signs in using IndieAuth they will be recieve a signed `ds_actor` co
}
```

If the IndieAuth server returned additional `"profile"` fields those will be merged into the actor. You can visit `/-/actor` on your Datasette instance to see the full actor you are currently signed in as.

## Restricting access with the restrict_access plugin configuration

You can use [Datasette's permissions system](https://docs.datasette.io/en/stable/authentication.html#permissions) to control permissions of authenticated users - by default, an authenticated user will be able to perform the same actions as an unauthenticated user.
Expand Down
117 changes: 27 additions & 90 deletions datasette_indieauth/__init__.py
Expand Up @@ -32,44 +32,39 @@ async def indieauth_page(request, datasette, status=200, error=None):
me = canonicalize_url(me)

if not me or not verify_profile_url(me):
datasette.add_message(
request, "Invalid IndieAuth identifier", message_type=datasette.ERROR
return await indieauth_page(
request,
datasette,
error="Invalid IndieAuth identifier",
)
return Response.redirect(urls.login)

# Start the auth process
authorization_endpoint, token_endpoint = await discover_endpoints(me)
if not authorization_endpoint:
# Redirect to IndieAuth.com as a fallback
# TODO: Only do this if rel=me detected
# TODO: make this a configurable preference
indieauth_url = "https://indieauth.com/auth?" + urllib.parse.urlencode(
{
"me": me,
"client_id": urls.client_id,
"redirect_uri": urls.indie_auth_com_redirect_uri,
}
)
return Response.redirect(indieauth_url)
else:
authorization_url, state, verifier = build_authorization_url(
authorization_endpoint=authorization_endpoint,
client_id=urls.client_id,
redirect_uri=urls.redirect_uri,
me=me,
signing_function=lambda x: datasette.sign(x, DATASETTE_INDIEAUTH_STATE),
)
response = Response.redirect(authorization_url)
response.set_cookie(
"ds_indieauth",
datasette.sign(
{
"v": verifier,
},
DATASETTE_INDIEAUTH_COOKIE,
),
return await indieauth_page(
request,
datasette,
error="Invalid IndieAuth identifier - no authorization_endpoint found",
)
return response

authorization_url, state, verifier = build_authorization_url(
authorization_endpoint=authorization_endpoint,
client_id=urls.client_id,
redirect_uri=urls.redirect_uri,
me=me,
signing_function=lambda x: datasette.sign(x, DATASETTE_INDIEAUTH_STATE),
)
response = Response.redirect(authorization_url)
response.set_cookie(
"ds_indieauth",
datasette.sign(
{
"v": verifier,
},
DATASETTE_INDIEAUTH_COOKIE,
),
)
return response

return Response.html(
await datasette.render_template(
Expand Down Expand Up @@ -166,35 +161,6 @@ async def indieauth_done(request, datasette):
)


async def indieauth_com_done(request, datasette):
from datasette.utils.asgi import Response

urls = Urls(request, datasette)

if not (request.args.get("code") and request.args.get("me")):
return Response.html("?code= and ?me= are required")
ok, extra = await verify_indieauth_com_code(
request.args["code"], urls.client_id, urls.indie_auth_com_redirect_uri
)
if ok:
response = Response.redirect(datasette.urls.instance())
response.set_cookie(
"ds_actor",
datasette.sign(
{
"a": {
"me": extra,
"display": display_url(extra),
}
},
"actor",
),
)
return response
else:
return Response.html(escape(extra), status=403)


class Urls:
def __init__(self, request, datasette):
self.request = request
Expand All @@ -215,41 +181,12 @@ def client_id(self):
def redirect_uri(self):
return self.absolute("/-/indieauth/done")

@property
def indie_auth_com_redirect_uri(self):
return self.absolute("/-/indieauth/indieauth-com-done")


async def verify_indieauth_com_code(code, client_id, redirect_uri):
async with httpx.AsyncClient() as client:
response = await client.post(
"https://indieauth.com/auth",
data={
"code": code,
"client_id": client_id,
"redirect_uri": redirect_uri,
},
)
if response.status_code == 200:
# me=https%3A%2F%2Fsimonwillison.net%2F&scope
bits = dict(urllib.parse.parse_qsl(response.text))
if "me" in bits:
return True, bits["me"]
else:
return False, "Server did not return me="
else:
bits = dict(urllib.parse.parse_qsl(response.text))
return False, bits.get("error_description") or "{} error".format(
response.status_code
)


@hookimpl
def register_routes():
return [
(r"^/-/indieauth$", indieauth),
(r"^/-/indieauth/done$", indieauth_done),
(r"^/-/indieauth/indieauth-com-done$", indieauth_com_done),
]


Expand Down
74 changes: 19 additions & 55 deletions tests/test_indieauth.py
Expand Up @@ -22,48 +22,7 @@ async def test_plugin_is_installed():


@pytest.mark.asyncio
async def test_indieauth_com_succeeds(httpx_mock):
httpx_mock.add_response(
url="https://indieauth.com/auth", data=b"me=https://simonwillison.net/"
)
datasette = Datasette([], memory=True)
app = datasette.app()
async with httpx.AsyncClient(app=app) as client:
response = await client.get(
"http://localhost/-/indieauth/indieauth-com-done?code=code&me=https://simonwillison.net/",
allow_redirects=False,
)
# Should set a cookie
assert response.status_code == 302
assert datasette.unsign(response.cookies["ds_actor"], "actor") == {
"a": {"me": "https://simonwillison.net/", "display": "simonwillison.net"}
}


@pytest.mark.asyncio
async def test_indieauth_com_fails(httpx_mock):
httpx_mock.add_response(
url="https://indieauth.com/auth",
status_code=404,
data=b"error_description=An+error+of+some+sort",
)
datasette = Datasette([], memory=True)
app = datasette.app()
async with httpx.AsyncClient(app=app) as client:
response = await client.get(
"http://localhost/-/indieauth/indieauth-com-done?code=code&me=example.com",
allow_redirects=False,
)
# Should return error
assert response.status_code == 403
assert "An error of some sort" in response.text


@pytest.mark.asyncio
async def test_restrict_access(httpx_mock):
httpx_mock.add_response(
url="https://indieauth.com/auth", data=b"me=https://simonwillison.net/"
)
async def test_restrict_access():
datasette = Datasette(
[],
memory=True,
Expand All @@ -74,7 +33,7 @@ async def test_restrict_access(httpx_mock):
},
)
app = datasette.app()
paths = ("/", "/:memory:", "/-/metadata")
paths = ("/-/actor.json", "/", "/:memory:", "/-/metadata")
async with httpx.AsyncClient(app=app) as client:
# All pages should 403 and show login form
for path in paths:
Expand All @@ -83,20 +42,25 @@ async def test_restrict_access(httpx_mock):
assert '<form action="/-/indieauth" method="post">' in response.text
assert "simonwillison.net" not in response.text

# Now do the login and try again
response2 = await client.get(
"http://localhost/-/indieauth/indieauth-com-done?code=code&me=example.com",
allow_redirects=False,
)
assert response2.status_code == 302
ds_actor = response2.cookies["ds_actor"]
# Everything should 200 now
# Now try with a signed ds_actor cookie - everything should 200
cookies = {
"ds_actor": datasette.sign(
{
"a": {
"me": "https://simonwillison.net/",
"display": "simonwillison.net",
}
},
"actor",
)
}
for path in paths:
response = await client.get(
"http://localhost{}".format(path), cookies={"ds_actor": ds_actor}
response2 = await client.get(
"http://localhost{}".format(path),
cookies=cookies,
)
assert response.status_code == 200
assert "simonwillison.net" in response.text
assert response2.status_code == 200
assert "simonwillison.net" in response2.text


@pytest.mark.asyncio
Expand Down

0 comments on commit bddccb8

Please sign in to comment.