Repository: simonw/datasette-auth-tokens (language: python)

Issue:
Manage tokens mode: UI for creating database-backed tokens with restricted permissions
I need this for Datasette Cloud. This would be a database-backed alternative to the new default signed token UI added in Datasette 1.0:

<img width="619" alt="image" src="https://github.com/simonw/datasette-auth-tokens/assets/9599/75414d7c-22ee-4478-8477-b581701114a4">



Discussion / hints:
Here's the ambitious plan for this feature:

- Users can create tokens using a UI similar to the default one, but those tokens are stored in a database table
- Token permissions are stored in the database, probably as JSON to match the format shown here: https://docs.datasette.io/en/1.0a2/authentication.html#restricting-the-actions-that-a-token-can-perform
- This means tokens can be revoked and audited
- Auditing: tokens store a created date and a last-used date. The last-used date is only accurate to the nearest 60s (or 5m perhaps), to avoid having to write to the database every time a token is authenticated (we only write if it hasn't had a write in the last 60s). This means we can see which tokens are actively being used.
- Token permissions can be edited after the token has been created. This means users can issue broad-scoped tokens and then reduce permissions on them once they figure out what they need.

The most ambitious version:
- Token permission checks are also logged on a short-term basis (maybe just 10m or so) such that the UI can reflect which permissions a token actually needs to get its job done.

That last feature is my dream feature for API tokens, but I don't know how feasible it is to implement.
First attempt at schema design:
```sql
CREATE TABLE _datasette_auth_tokens (
   id INTEGER PRIMARY KEY,
   secret TEXT,
   permissions TEXT,
   actor_id TEXT,
   created_timestamp INTEGER,
   last_used_timestamp INTEGER
);
```
I'm going to turn this mode on with a plugin configuration option:
```yaml
plugins:
  datasette-auth-tokens:
    manage_tokens: true
```
Where `manage_tokens` = "you should manage the tokens".
Updated schema:
```sql
CREATE TABLE _datasette_auth_tokens (
   id INTEGER PRIMARY KEY,
   secret TEXT,
   description TEXT,
   permissions TEXT,
   actor_id TEXT,
   created_timestamp INTEGER,
   last_used_timestamp INTEGER,
   expires_after_seconds INTEGER
);
```
Now has `expires_after_seconds` for storing e.g. 3600 to have the token expire after an hour.

And `description` for an optional note the user can specify describing what the token is being used for - useful for auditing.

Fix patch:
diff --git a/README.md b/README.md
index 0fd47e7..e2ad92a 100644
--- a/README.md
+++ b/README.md
@@ -118,9 +118,34 @@ You can now run authenticated API queries like this:
     $ curl http://127.0.0.1:8001/:memory:/show_version.json?_shape=array&_auth_token=this-is-the-secret-token
     [{"sqlite_version()": "3.31.1"}]
 
-## Tokens from your database
+## Managed tokens mode
 
-As an alternative (or in addition) to the hard-coded list of tokens you can store tokens in a database table and configure the plugin to access them using a SQL query.
+`datasette-auth-tokens` provides a managed tokens mode, where tokens are stored in a SQLite database table and the plugin provides an interface for creating and revoking tokens.
+
+To turn this mode on, add `"manage_tokens": true` to your plugin configuration in `metadata.json`:
+
+```json
+{
+    "plugins": {
+        "datasette-auth-tokens": {
+            "manage_tokens": true
+        }
+    }
+}
+```
+This will add a "Create API token" option to the Datasette menu.
+
+Tokens that are created will be kept in a new `_datasette_auth_tokens` table.
+
+Navigating to that table page will provide the option to view and revoke tokens.
+
+When you create a new token a signed token string will be presented to you. You need to store this, as it is not stored directly in the database table and can only be retrieved once.
+
+## Custom tokens from your database
+
+If you decide not to use managed tokens mode, you can instead configure `datasette-auth-tokens` to use tokens that are stored in your own custom database tables.
+
+You can do this by configuring a custom SQL query that will execute to test if an incoming token is valid.
 
 Your query needs to take a `:token_id` parameter and return at least two columns: one called `token_secret` and one called `actor_*` - usually `actor_id`. Further `actor_` prefixed columns can be returned to provide more details for the authenticated actor.
 
@@ -169,7 +194,7 @@ To configure this, use a `"query"` block in your plugin configuration like this:
 ```
 The `"sql"` key here contains the SQL query. The `"database"` key has the name of the attached database file that the query should be executed against - in this case it would execute against `tokens.db`.
 
-### Securing your tokens
+### Securing your custom tokens
 
 Anyone with access to your Datasette instance can use it to read the `token_secret` column in your tokens table. This probably isn't what you want!
 
diff --git a/datasette_auth_tokens/__init__.py b/datasette_auth_tokens/__init__.py
index 39864da..2bb8267 100644
--- a/datasette_auth_tokens/__init__.py
+++ b/datasette_auth_tokens/__init__.py
@@ -1,11 +1,83 @@
-from datasette import hookimpl
+from datasette import hookimpl, Forbidden
+import json
 import secrets
+import time
+from markupsafe import Markup
+from .views import create_api_token, check_permission, tokens_index, token_details
+
+CREATE_TABLES_SQL = """
+CREATE TABLE _datasette_auth_tokens (
+    id INTEGER PRIMARY KEY,
+    token_status TEXT DEFAULT 'L', -- [L]ive, [R]evoked, [E]xpired
+    description TEXT,
+    actor_id TEXT,
+    permissions TEXT,
+    created_timestamp INTEGER,
+    last_used_timestamp INTEGER,
+    expires_after_seconds INTEGER,
+    secret_version INTEGER DEFAULT 0
+);
+"""
+
+TOKEN_STATUSES = {
+    "L": "Live",
+    "R": "Revoked",
+    "E": "Expired",
+}
+
+
+@hookimpl
+def table_actions(datasette, actor, database, table):
+    if actor and table == "_datasette_auth_tokens":
+        return menu_links(datasette, actor)
+
+
+@hookimpl
+def menu_links(datasette, actor):
+    if not actor:
+        return
+    try:
+        check_permission(datasette, actor)
+    except Forbidden:
+        return
+    return [
+        {
+            "href": datasette.urls.path("/-/api/tokens/create"),
+            "label": "Create API token",
+        }
+    ]
+
+
+@hookimpl
+def startup(datasette):
+    config = _config(datasette)
+    if not config.get("manage_tokens"):
+        return
+
+    async def inner():
+        db = datasette.get_database()
+        if "_datasette_auth_tokens" not in await db.table_names():
+            await db.execute_write(CREATE_TABLES_SQL)
+
+    return inner
+
+
+@hookimpl
+def register_routes(datasette):
+    config = _config(datasette)
+    if not config.get("manage_tokens"):
+        return
+    return [
+        (r"^/-/api/tokens/create$", create_api_token),
+        (r"^/-/api/tokens$", tokens_index),
+        (r"^/-/api/tokens/(?P<id>\d+)$", token_details),
+    ]
 
 
 @hookimpl
 def actor_from_request(datasette, request):
     async def inner():
-        config = datasette.plugin_config("datasette-auth-tokens") or {}
+        config = _config(datasette)
         allowed_tokens = config.get("tokens") or []
         query_param = config.get("param")
         authorization = request.headers.get("authorization")
@@ -21,6 +93,10 @@ async def inner():
                 return None
         else:
             return None
+
+        if config.get("manage_tokens"):
+            return await _actor_from_managed(datasette, incoming_token)
+
         # First try hard-coded tokens in the list
         for token in allowed_tokens:
             if secrets.compare_digest(token["token"], incoming_token):
@@ -51,3 +127,74 @@ async def inner():
                 }
 
     return inner
+
+
+def _config(datasette):
+    return datasette.plugin_config("datasette-auth-tokens") or {}
+
+
+async def _actor_from_managed(datasette, incoming_token):
+    db = datasette.get_database()
+    if not incoming_token.startswith("dsatok_"):
+        return None
+    incoming_token = incoming_token[len("dsatok_") :]
+    token_id = datasette.unsign(incoming_token, "dsatok")
+    results = await db.execute(
+        "select * from _datasette_auth_tokens where id=:token_id",
+        {"token_id": token_id},
+    )
+    row = results.first()
+    if not row:
+        return None
+
+    actor = {"id": row["actor_id"], "token": "dsatok"}
+    permissions = json.loads(row["permissions"])
+    if permissions:
+        actor["_r"] = permissions
+
+    # Is token revoked?
+    if row["token_status"] == "R":
+        return None
+
+    # Expired?
+    if row["token_status"] == "E":
+        return None
+
+    # Also expire if it just hit expiry
+    if (
+        row["expires_after_seconds"]
+        and (row["created_timestamp"] + row["expires_after_seconds"]) < time.time()
+    ):
+        await db.execute_write(
+            "update _datasette_auth_tokens set token_status='E' where id=:token_id",
+            {"token_id": token_id},
+        )
+        return None
+
+    # Update last_used_timestamp if more than 60 seconds old
+    if row["last_used_timestamp"] is None or (
+        row["last_used_timestamp"] < (time.time() - 60)
+    ):
+        await db.execute_write(
+            "update _datasette_auth_tokens set last_used_timestamp=:now where id=:token_id",
+            {"now": int(time.time()), "token_id": token_id},
+        )
+
+    return actor
+
+
+@hookimpl
+def render_cell(value, column, table, row):
+    if table != "_datasette_auth_tokens":
+        return None
+    if column.endswith("_timestamp"):
+        return value and time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(value))
+    if column != "token_status":
+        return None
+    return Markup(
+        ('<strong>{status}</strong><br><a href="/-/api/tokens/{id}">{link}</a>').format(
+            status=TOKEN_STATUSES.get(value, value),
+            id=row["id"],
+            link="edit / revoke" if value == "L" else "view",
+        )
+    )
diff --git a/datasette_auth_tokens/templates/create_api_token.html b/datasette_auth_tokens/templates/create_api_token.html
new file mode 100644
index 0000000..c01ac4d
--- /dev/null
+++ b/datasette_auth_tokens/templates/create_api_token.html
@@ -0,0 +1,169 @@
+{% extends "base.html" %}
+
+{% block title %}Create an API token{% endblock %}
+
+{% block extra_head %}
+<style type="text/css">
+#restrict-permissions label {
+  display: inline;
+  width: 90%;
+}
+</style>
+{% endblock %}
+
+{% block content %}
+
+<h1>Create an API token</h1>
+
+<p>This token will allow API access with the same abilities as your current user, <strong>{{ request.actor.id }}</strong></p>
+
+{% if token %}
+  <div>
+    <h2>Your API token</h2>
+    <form>
+      <input type="text" class="copyable" style="width: 40%" value="{{ token }}">
+      <span class="copy-link-wrapper"></span>
+    </form>
+    <!--- show token in a <details> -->
+    <details style="margin-top: 1em">
+      <summary>Token details</summary>
+      <pre>{{ token_bits|tojson(4) }}</pre>
+    </details>
+  </div>
+  <h2>Create another token</h2>
+{% endif %}
+
+{% if errors %}
+  {% for error in errors %}
+    <p class="message-error">{{ error }}</p>
+  {% endfor %}
+{% endif %}
+
+<form action="{{ urls.path('-/api/tokens/create') }}" method="post">
+  <div>
+    <div style="margin-bottom: 0.5em">
+      <input type="text" name="description" placeholder="Optional token description" style="width: 40%">
+    </div>
+    <div class="select-wrapper" style="width: unset">
+      <select name="expire_type">
+        <option value="">Token never expires</option>
+        <option value="minutes">Expires after X minutes</option>
+        <option value="hours">Expires after X hours</option>
+        <option value="days">Expires after X days</option>
+      </select>
+    </div>
+    <input type="text" name="expire_duration" style="width: 10%">
+    <input type="hidden" name="csrftoken" value="{{ csrftoken() }}">
+    <input type="submit" value="Create token">
+
+    <p style="margin-top: 1em" id="token-summary"></p>
+
+    <h2>All databases and tables</h2>
+    <ul>
+      {% for permission in all_permissions %}
+        <li><label><input type="checkbox" name="all:{{ permission }}"> {{ permission }}</label></li>
+      {% endfor %}
+    </ul>
+
+    {% for database in database_with_tables %}
+      <h2>All tables in "{{ database.name }}"</h2>
+      <ul>
+        {% for permission in database_permissions %}
+          <li><label><input type="checkbox" name="database:{{ database.encoded }}:{{ permission }}"> {{ permission }}</label></li>
+        {% endfor %}
+      </ul>
+    {% endfor %}
+    <h2>Specific tables</h2>
+    {% for database in database_with_tables %}
+      {% for table in database.tables %}
+        <h3>{{ database.name }}: {{ table.name }}</h3>
+        <ul>
+          {% for permission in resource_permissions %}
+            <li><label><input type="checkbox" name="resource:{{ database.encoded }}:{{ table.encoded }}:{{ permission }}"> {{ permission }}</label></li>
+          {% endfor %}
+        </ul>
+      {% endfor %}
+    {% endfor %}
+
+</form>
+</div>
+
+<script>
+var expireDuration = document.querySelector('input[name="expire_duration"]');
+expireDuration.style.display = 'none';
+var expireType = document.querySelector('select[name="expire_type"]');
+function showHideExpireDuration() {
+  if (expireType.value) {
+    expireDuration.style.display = 'inline';
+    expireDuration.setAttribute("placeholder", expireType.value.replace("Expires after X ", ""));
+  } else {
+    expireDuration.style.display = 'none';
+  }
+}
+
+function updateTokenSummary() {
+  // Get all selected checkbox values on page
+  var allChecked = document.querySelectorAll('input[type="checkbox"]:checked');
+  var checkedValues = [];
+  for (var i = 0; i < allChecked.length; i++) {
+    checkedValues.push(humanPermission(allChecked[i].name));
+  }
+  var tokenSummary = document.querySelector('#token-summary');
+  if (checkedValues.length == 0) {
+    tokenSummary.innerHTML = 'Token will have all of the permissions of your current user';
+  } else {
+    // Build a <ul> of checkedValues
+    let html = ['<ul style="margin-top: 0.5em">'];
+    checkedValues.forEach(function(value) {
+      html.push('<li style="list-style-type: disc; margin-left: 1em;">' + value + '</li>');
+    });
+    html.push('</ul>');
+    tokenSummary.innerHTML = 'Token will be restricted to: ' + html.join('');
+  }
+}
+updateTokenSummary();
+
+function humanPermission(permission) {
+  // database:messages:view-database becomes "messages database: view-database"
+  // resource:messages:t2:view-table becomes "messages/t2 table: view-table"
+  var parts = permission.split(':');
+  var type = parts[0];
+  var db = parts[1];
+  var action = parts[parts.length - 1];
+  if (type == 'all') {
+    return 'all databases and tables: ' + action;
+  } else if (type == 'database') {
+    return db + ' database: ' + action;
+  } else if (type == 'resource') {
+    var table = parts[2];
+    return db + '/' + table + ' table: ' + action;
+  }
+}
+
+// On any input change on page, update token summary
+document.addEventListener('change', updateTokenSummary);
+
+showHideExpireDuration();
+expireType.addEventListener('change', showHideExpireDuration);
+var copyInput = document.querySelector(".copyable");
+if (copyInput) {
+  var wrapper = document.querySelector(".copy-link-wrapper");
+  var button = document.createElement("button");
+  button.className = "copyable-copy-button";
+  button.setAttribute("type", "button");
+  button.innerHTML = "Copy to clipboard";
+  button.onclick = (ev) => {
+    ev.preventDefault();
+    copyInput.select();
+    document.execCommand("copy");
+    button.innerHTML = "Copied!";
+    setTimeout(() => {
+        button.innerHTML = "Copy to clipboard";
+    }, 1500);
+  };
+  wrapper.appendChild(button);
+  wrapper.insertAdjacentElement("afterbegin", button);
+}
+</script>
+
+{% endblock %}
diff --git a/datasette_auth_tokens/templates/token_details.html b/datasette_auth_tokens/templates/token_details.html
new file mode 100644
index 0000000..152b0e6
--- /dev/null
+++ b/datasette_auth_tokens/templates/token_details.html
@@ -0,0 +1,46 @@
+{% extends "base.html" %}
+
+{% block title %}API token{% if token.description %}: {{ token.description }}{% endif %}{% endblock %}
+
+{% block extra_head %}
+<style type="text/css">
+dt {
+    font-weight: bold;
+}
+dd {
+    margin-left: 2em;
+}
+</style>
+{% endblock %}
+
+{% block content %}
+
+<h1>API token: {{ token.id }}</h1>
+
+<dl>
+    {% if token.description %}
+        <dt>Description</dt>
+        <dd>{{ token.description }}</dd>
+    {% endif %}
+    <dt>Token status</dt>
+    <dd>{{ token_status }}</dd>
+    <dt>Actor</dt>
+    <dd>{{ token.actor_id }}</dd>
+    <dt>Created</dt>
+    <dd>{{ timestamp(token.created_timestamp) }}</dd>
+    <dt>Last used</dt>
+    <dd>{{ timestamp(token.last_used_timestamp) }}</dd>
+    <dt>Restrictions</dt>
+    <dd><pre>{{ restrictions }}</pre></dd>
+</dl>
+
+{% if token_status == "Live" %}
+<form action="" method="POST">
+    <p>
+        <input type="hidden" name="csrftoken" value="{{ csrftoken() }}">
+        <input type="submit" name="revoke" value="Revoke token">
+    </p>
+</form>
+{% endif %}
+
+{% endblock %}
diff --git a/datasette_auth_tokens/views.py b/datasette_auth_tokens/views.py
new file mode 100644
index 0000000..641323a
--- /dev/null
+++ b/datasette_auth_tokens/views.py
@@ -0,0 +1,208 @@
+from datasette import Forbidden, Response, NotFound
+from datasette.utils import (
+    tilde_encode,
+    tilde_decode,
+)
+import datetime
+import json
+import time
+
+
+async def create_api_token(request, datasette):
+    check_permission(datasette, request.actor)
+    if request.method == "GET":
+        return Response.html(
+            await datasette.render_template(
+                "create_api_token.html",
+                await _shared(datasette, request),
+                request=request,
+            )
+        )
+    elif request.method == "POST":
+        post = await request.post_vars()
+        errors = []
+        expires_after = None
+        if post.get("expire_type"):
+            duration_string = post.get("expire_duration")
+            if (
+                not duration_string
+                or not duration_string.isdigit()
+                or not int(duration_string) > 0
+            ):
+                errors.append("Invalid expire duration")
+            else:
+                unit = post["expire_type"]
+                if unit == "minutes":
+                    expires_after = int(duration_string) * 60
+                elif unit == "hours":
+                    expires_after = int(duration_string) * 60 * 60
+                elif unit == "days":
+                    expires_after = int(duration_string) * 60 * 60 * 24
+                else:
+                    errors.append("Invalid expire duration unit")
+
+        # Are there any restrictions?
+        restrict_all = []
+        restrict_database = {}
+        restrict_resource = {}
+
+        for key in post:
+            if key.startswith("all:") and key.count(":") == 1:
+                restrict_all.append(key.split(":")[1])
+            elif key.startswith("database:") and key.count(":") == 2:
+                bits = key.split(":")
+                database = tilde_decode(bits[1])
+                action = bits[2]
+                restrict_database.setdefault(database, []).append(action)
+            elif key.startswith("resource:") and key.count(":") == 3:
+                bits = key.split(":")
+                database = tilde_decode(bits[1])
+                resource = tilde_decode(bits[2])
+                action = bits[3]
+                restrict_resource.setdefault(database, {}).setdefault(
+                    resource, []
+                ).append(action)
+
+        # Reuse Datasette signed tokens mechanism to create parts of the token
+        throwaway_signed_token = datasette.create_token(
+            request.actor["id"],
+            expires_after=expires_after,
+            restrict_all=restrict_all,
+            restrict_database=restrict_database,
+            restrict_resource=restrict_resource,
+        )
+        token_bits = datasette.unsign(
+            throwaway_signed_token[len("dstok_") :], namespace="token"
+        )
+        permissions = token_bits.get("_r") or None
+
+        db = datasette.get_database()
+        cursor = await db.execute_write(
+            """
+            insert into _datasette_auth_tokens
+            (secret_version, description, permissions, actor_id, created_timestamp, expires_after_seconds)
+            values
+            (:secret_version, :description, :permissions, :actor_id, :created_timestamp, :expires_after_seconds)
+        """,
+            {
+                "secret_version": 0,
+                "permissions": json.dumps(permissions),
+                "description": post.get("description") or None,
+                "actor_id": request.actor["id"],
+                "created_timestamp": int(time.time()),
+                "expires_after_seconds": expires_after,
+            },
+        )
+        token = "dsatok_{}".format(datasette.sign(cursor.lastrowid, "dsatok"))
+
+        context = await _shared(datasette, request)
+        context.update({"errors": errors, "token": token, "token_bits": token_bits})
+        return Response.html(
+            await datasette.render_template(
+                "create_api_token.html", context, request=request
+            )
+        )
+    else:
+        raise Forbidden("Invalid method")
+
+
+def check_permission(datasette, actor):
+    if not actor:
+        raise Forbidden("You must be logged in to create a token")
+    if not actor.get("id"):
+        raise Forbidden(
+            "You must be logged in as an actor with an ID to create a token"
+        )
+    if actor.get("token"):
+        raise Forbidden(
+            "Token authentication cannot be used to create additional tokens"
+        )
+
+
+async def _shared(datasette, request):
+    check_permission(datasette, request.actor)
+    # Build list of databases and tables the user has permission to view
+    database_with_tables = []
+    for database in datasette.databases.values():
+        if database.name in ("_internal", "_memory"):
+            continue
+        if not await datasette.permission_allowed(
+            request.actor, "view-database", database.name
+        ):
+            continue
+        hidden_tables = await database.hidden_table_names()
+        tables = []
+        for table in await database.table_names():
+            if table in hidden_tables:
+                continue
+            if not await datasette.permission_allowed(
+                request.actor,
+                "view-table",
+                resource=(database.name, table),
+            ):
+                continue
+            tables.append({"name": table, "encoded": tilde_encode(table)})
+        database_with_tables.append(
+            {
+                "name": database.name,
+                "encoded": tilde_encode(database.name),
+                "tables": tables,
+            }
+        )
+    return {
+        "actor": request.actor,
+        "all_permissions": datasette.permissions.keys(),
+        "database_permissions": [
+            key for key, value in datasette.permissions.items() if value.takes_database
+        ],
+        "resource_permissions": [
+            key for key, value in datasette.permissions.items() if value.takes_resource
+        ],
+        "database_with_tables": database_with_tables,
+    }
+
+
+async def tokens_index():
+    return Response.text("TODO")
+
+
+async def token_details(request, datasette):
+    from . import TOKEN_STATUSES
+
+    id = request.url_vars["id"]
+    db = datasette.get_database()
+    row = (
+        await db.execute("select * from _datasette_auth_tokens where id = ?", (id,))
+    ).first()
+    if row is None:
+        raise NotFound("Token not found")
+    if request.method == "POST":
+        post_vars = await request.post_vars()
+        if post_vars.get("revoke"):
+            await db.execute_write(
+                "update _datasette_auth_tokens set token_status = 'R' where id = ?",
+                (id,),
+            )
+        return Response.redirect(request.path)
+
+    restrictions = "None"
+    permissions = json.loads(row["permissions"])
+    if permissions:
+        restrictions = json.dumps(permissions, indent=2)
+
+    return Response.html(
+        await datasette.render_template(
+            "token_details.html",
+            {
+                "token": row,
+                "token_status": TOKEN_STATUSES.get(
+                    row["token_status"], row["token_status"]
+                ),
+                "timestamp": lambda ts: ts
+                and datetime.datetime.fromtimestamp(ts).isoformat()
+                or "None",
+                "restrictions": restrictions,
+            },
+            request=request,
+        )
+    )
diff --git a/setup.py b/setup.py
index 0299ac9..6bf92fb 100644
--- a/setup.py
+++ b/setup.py
@@ -29,8 +29,9 @@ def get_long_description():
     packages=["datasette_auth_tokens"],
     entry_points={"datasette": ["auth_tokens = datasette_auth_tokens"]},
     install_requires=[
-        "datasette>=0.44",
+        "datasette>=1.0a2",
+        "sqlite-utils",
     ],
     extras_require={"test": ["pytest", "pytest-asyncio", "httpx", "sqlite-utils"]},
-    tests_require=["datasette-auth-tokens[test]"],
+    package_data={"datasette_auth_tokens": ["templates/*.html"]},
 )


Test patch:
diff --git a/tests/test_managed_tokens.py b/tests/test_managed_tokens.py
new file mode 100644
index 0000000..abb9088
--- /dev/null
+++ b/tests/test_managed_tokens.py
@@ -0,0 +1,111 @@
+from datasette.app import Datasette
+import pytest
+import pytest_asyncio
+import sqlite_utils
+import time
+
+
+@pytest_asyncio.fixture
+async def ds_managed(tmp_path_factory):
+    db_directory = tmp_path_factory.mktemp("dbs")
+    db_path = db_directory / "demo.db"
+    sqlite_utils.Database(db_path)["foo"].insert({"bar": 1})
+    return Datasette(
+        [db_path],
+        metadata={
+            "plugins": {
+                "datasette-auth-tokens": {
+                    "manage_tokens": True,
+                    "param": "_auth_token",
+                }
+            },
+        },
+    )
+
+
+@pytest.mark.parametrize("status", ("live", "revoked", "expired"))
+@pytest.mark.asyncio
+async def test_live_revoked_expired_tokens(ds_managed, status):
+    token_id, token = await _create_token(ds_managed)
+    expected_actor = {"id": "root", "token": "dsatok"}
+    if status in ("revoked", "expired"):
+        expected_actor = None
+    if status == "revoked":
+        await ds_managed.get_database().execute_write(
+            "update _datasette_auth_tokens set token_status = 'R' where id=:id",
+            {"id": token_id},
+        )
+    elif status == "expired":
+        # Expire it by setting the created_timestamp and expires_after_seconds
+        await ds_managed.get_database().execute_write(
+            "update _datasette_auth_tokens set created_timestamp = :created, expires_after_seconds = 60 where id=:id",
+            {"id": token_id, "created": time.time() - 120},
+        )
+    actor_response = await ds_managed.client.get(
+        "/-/actor.json", headers={"Authorization": "Bearer {}".format(token)}
+    )
+    assert actor_response.status_code == 200
+    assert actor_response.json() == {"actor": expected_actor}
+
+
+async def _create_token(ds_managed):
+    root_cookie = ds_managed.sign({"a": {"id": "root"}}, "actor")
+    create_page = await ds_managed.client.get(
+        "/-/api/tokens/create", cookies={"ds_actor": root_cookie}
+    )
+    ds_csrftoken = create_page.cookies["ds_csrftoken"]
+    post_fields = {}
+    post_fields["csrftoken"] = ds_csrftoken
+    response = await ds_managed.client.post(
+        "/-/api/tokens/create",
+        data=post_fields,
+        cookies={"ds_actor": root_cookie, "ds_csrftoken": ds_csrftoken},
+    )
+    assert response.status_code == 200
+    api_token = response.text.split('class="copyable" style="width: 40%" value="')[
+        1
+    ].split('"')[0]
+    # Decode token to find token ID
+    token_id = ds_managed.unsign(api_token.split("dsatok_")[1], namespace="dsatok")
+    return token_id, api_token
+
+
+@pytest.mark.parametrize(
+    "post_fields,expected_actor",
+    [
+        ({}, {"id": "root", "token": "dsatok"}),
+        (
+            {"resource:demo:foo:view-table": "1"},
+            {"id": "root", "token": "dsatok", "_r": {"r": {"demo": {"foo": ["vt"]}}}},
+        ),
+    ],
+)
+@pytest.mark.asyncio
+async def test_create_token(ds_managed, post_fields, expected_actor):
+    # TODO: switch to ds_managed.client.actor_cookie after next Datasette release
+    cookie = ds_managed.sign({"a": {"id": "root"}}, "actor")
+    # Load initial create token page
+    create_page = await ds_managed.client.get(
+        "/-/api/tokens/create", cookies={"ds_actor": cookie}
+    )
+    assert create_page.status_code == 200
+    # Extract ds_csrftoken
+    ds_csrftoken = create_page.cookies["ds_csrftoken"]
+    # Use that to create the token
+    post_fields["csrftoken"] = ds_csrftoken
+    response = await ds_managed.client.post(
+        "/-/api/tokens/create",
+        data=post_fields,
+        cookies={"ds_actor": cookie, "ds_csrftoken": ds_csrftoken},
+    )
+    assert response.status_code == 200
+    api_token = response.text.split('class="copyable" style="width: 40%" value="')[
+        1
+    ].split('"')[0]
+    assert api_token
+    # Now try using it to request /-/actor.json
+    response = await ds_managed.client.get(
+        "/-/actor.json", headers={"Authorization": "Bearer {}".format(api_token)}
+    )
+    assert response.status_code == 200
+    assert response.json()["actor"] == expected_actor