Skip to content

Commit

Permalink
Initial working version
Browse files Browse the repository at this point in the history
  • Loading branch information
simonw committed Nov 15, 2020
0 parents commit c55ba4d
Show file tree
Hide file tree
Showing 8 changed files with 318 additions and 0 deletions.
58 changes: 58 additions & 0 deletions .github/workflows/publish.yml
@@ -0,0 +1,58 @@
name: Publish Python Package

on:
release:
types: [created]

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.6, 3.7, 3.8, 3.9]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- uses: actions/cache@v2
name: Configure pip caching
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Install dependencies
run: |
pip install -e '.[test]'
- name: Run tests
run: |
pytest
deploy:
runs-on: ubuntu-latest
needs: [test]
steps:
- uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: '3.9'
- uses: actions/cache@v2
name: Configure pip caching
with:
path: ~/.cache/pip
key: ${{ runner.os }}-publish-pip-${{ hashFiles('**/setup.py') }}
restore-keys: |
${{ runner.os }}-publish-pip-
- name: Install dependencies
run: |
pip install setuptools wheel twine
- name: Publish
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
run: |
python setup.py sdist bdist_wheel
twine upload dist/*
30 changes: 30 additions & 0 deletions .github/workflows/test.yml
@@ -0,0 +1,30 @@
name: Test

on: [push]

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.6, 3.7, 3.8, 3.9]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- uses: actions/cache@v2
name: Configure pip caching
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Install dependencies
run: |
pip install -e '.[test]'
- name: Run tests
run: |
pytest
10 changes: 10 additions & 0 deletions .gitignore
@@ -0,0 +1,10 @@
.venv
__pycache__/
*.py[cod]
*$py.class
venv
.eggs
.pytest_cache
*.egg-info
.DS_Store
.vscode
42 changes: 42 additions & 0 deletions README.md
@@ -0,0 +1,42 @@
# datasette-indieauth

[![PyPI](https://img.shields.io/pypi/v/datasette-indieauth.svg)](https://pypi.org/project/datasette-indieauth/)
[![Changelog](https://img.shields.io/github/v/release/simonw/datasette-indieauth?include_prereleases&label=changelog)](https://github.com/simonw/datasette-indieauth/releases)
[![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)

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

This initial version depends on [IndieAuth.com](https://indieauth.com/).

## Installation

Install this plugin in the same environment as Datasette.

$ datasette install datasette-indieauth

## Usage

Ensure you have a website with a domain that supports IndieAuth or RelMeAuth.

Visit `/-/indieauth` to begin the sign-in progress.

## Development

To set up this plugin locally, first checkout the code. Then create a new virtual environment:

cd datasette-indieauth
python3 -mvenv venv
source venv/bin/activate

Or if you are using `pipenv`:

pipenv shell

Now install the dependencies and tests:

pip install -e '.[test]'

To run the tests:

pytest
79 changes: 79 additions & 0 deletions datasette_indieauth/__init__.py
@@ -0,0 +1,79 @@
from datasette import hookimpl
from datasette.utils.asgi import Response
import httpx
import urllib


async def indieauth(request, datasette):
client_id = datasette.absolute_url(request, datasette.urls.instance())
redirect_uri = datasette.absolute_url(request, request.path)

if request.args.get("code") and request.args.get("me"):
ok, extra = await verify_code(request.args["code"], client_id, redirect_uri)
if ok:
response = Response.redirect(datasette.urls.instance())
response.set_cookie(
"ds_actor",
datasette.sign(
{
"a": {
"me": extra,
"display": extra,
}
},
"actor",
),
)
return response
else:
return Response.text(extra, status=403)

return Response.html(
await datasette.render_template(
"indieauth.html",
{
"client_id": client_id,
"redirect_uri": redirect_uri,
},
request=request,
)
)


async def verify_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:
return False, "{}: {}".format(response.status_code, response.text)


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


@hookimpl
def menu_links(datasette, actor):
if not actor:
return [
{
"href": datasette.urls.path("/-/indieauth"),
"label": "Sign in IndieAuth",
},
]
14 changes: 14 additions & 0 deletions datasette_indieauth/templates/indieauth.html
@@ -0,0 +1,14 @@
{% extends "base.html" %}

{% block title %}Sign in with IndieAuth{% endblock %}

{% block content %}
<h1>Sign in with IndieAuth</h1>
<form action="https://indieauth.com/auth" method="get">
<p><input type="text" name="me">
<input type="hidden" name="client_id" value="{{ client_id }}">
<input type="hidden" name="redirect_uri" value="{{ redirect_uri }}">
<input type="submit" value="Login">
</p>
</form>
{% endblock %}
36 changes: 36 additions & 0 deletions setup.py
@@ -0,0 +1,36 @@
from setuptools import setup
import os

VERSION = "0.1a0"


def get_long_description():
with open(
os.path.join(os.path.dirname(os.path.abspath(__file__)), "README.md"),
encoding="utf8",
) as fp:
return fp.read()


setup(
name="datasette-indieauth",
description="Datasette authentication using IndieAuth and RelMeAuth",
long_description=get_long_description(),
long_description_content_type="text/markdown",
author="Simon Willison",
url="https://github.com/simonw/datasette-indieauth",
project_urls={
"Issues": "https://github.com/simonw/datasette-indieauth/issues",
"CI": "https://github.com/simonw/datasette-indieauth/actions",
"Changelog": "https://github.com/simonw/datasette-indieauth/releases",
},
license="Apache License, Version 2.0",
version=VERSION,
packages=["datasette_indieauth"],
entry_points={"datasette": ["indieauth = datasette_indieauth"]},
install_requires=["datasette"],
extras_require={"test": ["pytest", "pytest-asyncio", "httpx", "pytest-httpx"]},
tests_require=["datasette-indieauth[test]"],
package_data={"datasette_indieauth": ["templates/*.html"]},
python_requires=">=3.6",
)
49 changes: 49 additions & 0 deletions tests/test_indieauth.py
@@ -0,0 +1,49 @@
from datasette.app import Datasette
import pytest
import httpx


@pytest.fixture
def non_mocked_hosts():
return ["localhost"]


@pytest.mark.asyncio
async def test_plugin_is_installed():
app = Datasette([], memory=True).app()
async with httpx.AsyncClient(app=app) as client:
response = await client.get("http://localhost/-/plugins.json")
assert 200 == response.status_code
installed_plugins = {p["name"] for p in response.json()}
assert "datasette-indieauth" in installed_plugins


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


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

0 comments on commit c55ba4d

Please sign in to comment.