diff --git a/datasette/actor_auth_cookie.py b/datasette/actor_auth_cookie.py
index a2aa6889..15ecd331 100644
--- a/datasette/actor_auth_cookie.py
+++ b/datasette/actor_auth_cookie.py
@@ -1,5 +1,7 @@
from datasette import hookimpl
from itsdangerous import BadSignature
+import baseconv
+import time
@hookimpl
@@ -7,6 +9,15 @@ def actor_from_request(datasette, request):
if "ds_actor" not in request.cookies:
return None
try:
- return datasette.unsign(request.cookies["ds_actor"], "actor")
+ decoded = datasette.unsign(request.cookies["ds_actor"], "actor")
+ # If it has "e" and "a" keys process the "e" expiry
+ if not isinstance(decoded, dict) or "a" not in decoded:
+ return None
+ expires_at = decoded.get("e")
+ if expires_at:
+ timestamp = int(baseconv.base62.decode(expires_at))
+ if time.time() > timestamp:
+ return None
+ return decoded["a"]
except BadSignature:
return None
diff --git a/datasette/views/special.py b/datasette/views/special.py
index 7f4284a1..dc6a25dc 100644
--- a/datasette/views/special.py
+++ b/datasette/views/special.py
@@ -62,7 +62,9 @@ class AuthTokenView(BaseView):
if secrets.compare_digest(token, self.ds._root_token):
self.ds._root_token = None
response = Response.redirect("/")
- response.set_cookie("ds_actor", self.ds.sign({"id": "root"}, "actor"))
+ response.set_cookie(
+ "ds_actor", self.ds.sign({"a": {"id": "root"}}, "actor")
+ )
return response
else:
return Response("Invalid token", status=403)
diff --git a/docs/authentication.rst b/docs/authentication.rst
index f511e373..9b66132a 100644
--- a/docs/authentication.rst
+++ b/docs/authentication.rst
@@ -336,11 +336,55 @@ Authentication plugins can set signed ``ds_actor`` cookies themselves like so:
.. code-block:: python
response = Response.redirect("/")
- response.set_cookie("ds_actor", datasette.sign({"id": "cleopaws"}, "actor"))
- return response
+ response.set_cookie("ds_actor", datasette.sign({
+ "a": {
+ "id": "cleopaws"
+ }
+ }, "actor"))
Note that you need to pass ``"actor"`` as the namespace to :ref:`datasette_sign`.
+The shape of data encoded in the cookie is as follows::
+
+ {
+ "a": {... actor ...}
+ }
+
+.. _authentication_ds_actor_expiry:
+
+Including an expiry time
+------------------------
+
+``ds_actor`` cookies can optionally include a signed expiry timestamp, after which the cookies will no longer be valid. Authentication plugins may chose to use this mechanism to limit the lifetime of the cookie. For example, if a plugin implements single-sign-on against another source it may decide to set short-lived cookies so that if the user is removed from the SSO system their existing Datasette cookies will stop working shortly afterwards.
+
+To include an expiry, add a ``"e"`` key to the cookie value containing a `base62-encoded integer `__ representing the timestamp when the cookie should expire. For example, here's how to set a cookie that expires after 24 hours:
+
+.. code-block:: python
+
+ import time
+ import baseconv
+
+ expires_at = int(time.time()) + (24 * 60 * 60)
+
+ response = Response.redirect("/")
+ response.set_cookie("ds_actor", datasette.sign({
+ "a": {
+ "id": "cleopaws"
+ },
+ "e": baseconv.base62.encode(expires_at),
+ }, "actor"))
+
+The resulting cookie will encode data that looks something like this:
+
+.. code-block:: json
+
+ {
+ "a": {
+ "id": "cleopaws"
+ },
+ "e": "1jjSji"
+ }
+
.. _permissions:
Built-in permissions
diff --git a/docs/internals.rst b/docs/internals.rst
index 7978e3d7..d75544e1 100644
--- a/docs/internals.rst
+++ b/docs/internals.rst
@@ -153,12 +153,12 @@ To set cookies on the response, use the ``response.set_cookie(...)`` method. The
samesite="lax",
):
-You can use this with :ref:`datasette.sign() ` to set signed cookies. Here's how you would set the ``ds_actor`` cookie for use with Datasette :ref:`authentication `:
+You can use this with :ref:`datasette.sign() ` to set signed cookies. Here's how you would set the :ref:`ds_actor cookie ` for use with Datasette :ref:`authentication `:
.. code-block:: python
response = Response.redirect("/")
- response.set_cookie("ds_actor", datasette.sign({"id": "cleopaws"}, "actor"))
+ response.set_cookie("ds_actor", datasette.sign({"a": {"id": "cleopaws"}}, "actor"))
return response
.. _internals_datasette:
diff --git a/setup.py b/setup.py
index 678a022f..45af0253 100644
--- a/setup.py
+++ b/setup.py
@@ -57,6 +57,7 @@ setup(
"PyYAML~=5.3",
"mergedeep>=1.1.1,<1.4.0",
"itsdangerous~=1.1",
+ "python-baseconv==1.2.2",
],
entry_points="""
[console_scripts]
diff --git a/tests/fixtures.py b/tests/fixtures.py
index 1eb1bb6e..a846999b 100644
--- a/tests/fixtures.py
+++ b/tests/fixtures.py
@@ -109,6 +109,9 @@ class TestClient:
def __init__(self, asgi_app):
self.asgi_app = asgi_app
+ def actor_cookie(self, actor):
+ return self.ds.sign({"a": actor}, "actor")
+
@async_to_sync
async def get(
self, path, allow_redirects=True, redirect_count=0, method="GET", cookies=None
diff --git a/tests/test_auth.py b/tests/test_auth.py
index 0e5563a3..5e847445 100644
--- a/tests/test_auth.py
+++ b/tests/test_auth.py
@@ -1,4 +1,7 @@
from .fixtures import app_client
+import baseconv
+import pytest
+import time
def test_auth_token(app_client):
@@ -8,7 +11,9 @@ def test_auth_token(app_client):
response = app_client.get(path, allow_redirects=False,)
assert 302 == response.status
assert "/" == response.headers["Location"]
- assert {"id": "root"} == app_client.ds.unsign(response.cookies["ds_actor"], "actor")
+ assert {"a": {"id": "root"}} == app_client.ds.unsign(
+ response.cookies["ds_actor"], "actor"
+ )
# Check that a second with same token fails
assert app_client.ds._root_token is None
assert 403 == app_client.get(path, allow_redirects=False,).status
@@ -16,6 +21,18 @@ def test_auth_token(app_client):
def test_actor_cookie(app_client):
"A valid actor cookie sets request.scope['actor']"
- cookie = app_client.ds.sign({"id": "test"}, "actor")
+ cookie = app_client.actor_cookie({"id": "test"})
response = app_client.get("/", cookies={"ds_actor": cookie})
assert {"id": "test"} == app_client.ds._last_request.scope["actor"]
+
+
+@pytest.mark.parametrize(
+ "offset,expected", [((24 * 60 * 60), {"id": "test"}), (-(24 * 60 * 60), None),]
+)
+def test_actor_cookie_that_expires(app_client, offset, expected):
+ expires_at = int(time.time()) + offset
+ cookie = app_client.ds.sign(
+ {"a": {"id": "test"}, "e": baseconv.base62.encode(expires_at)}, "actor"
+ )
+ response = app_client.get("/", cookies={"ds_actor": cookie})
+ assert expected == app_client.ds._last_request.scope["actor"]
diff --git a/tests/test_canned_write.py b/tests/test_canned_write.py
index dc3fba3f..4257806e 100644
--- a/tests/test_canned_write.py
+++ b/tests/test_canned_write.py
@@ -55,7 +55,7 @@ def test_custom_success_message(canned_write_client):
response = canned_write_client.post(
"/data/delete_name",
{"rowid": 1},
- cookies={"ds_actor": canned_write_client.ds.sign({"id": "root"}, "actor")},
+ cookies={"ds_actor": canned_write_client.actor_cookie({"id": "root"})},
allow_redirects=False,
csrftoken_from=True,
)
@@ -116,7 +116,7 @@ def test_canned_query_permissions_on_database_page(canned_write_client):
# With auth shows four
response = canned_write_client.get(
"/data.json",
- cookies={"ds_actor": canned_write_client.ds.sign({"id": "root"}, "actor")},
+ cookies={"ds_actor": canned_write_client.actor_cookie({"id": "root"})},
)
assert 200 == response.status
assert [
@@ -132,6 +132,6 @@ def test_canned_query_permissions_on_database_page(canned_write_client):
def test_canned_query_permissions(canned_write_client):
assert 403 == canned_write_client.get("/data/delete_name").status
assert 200 == canned_write_client.get("/data/update_name").status
- cookies = {"ds_actor": canned_write_client.ds.sign({"id": "root"}, "actor")}
+ cookies = {"ds_actor": canned_write_client.actor_cookie({"id": "root"})}
assert 200 == canned_write_client.get("/data/delete_name", cookies=cookies).status
assert 200 == canned_write_client.get("/data/update_name", cookies=cookies).status
diff --git a/tests/test_permissions.py b/tests/test_permissions.py
index 477b8160..1be9529a 100644
--- a/tests/test_permissions.py
+++ b/tests/test_permissions.py
@@ -21,7 +21,7 @@ def test_view_instance(allow, expected_anon, expected_auth):
# Should be no padlock
assert "Datasette 🔒
" not in anon_response.text
auth_response = client.get(
- path, cookies={"ds_actor": client.ds.sign({"id": "root"}, "actor")},
+ path, cookies={"ds_actor": client.actor_cookie({"id": "root"})},
)
assert expected_auth == auth_response.status
# Check for the padlock
@@ -48,7 +48,7 @@ def test_view_database(allow, expected_anon, expected_auth):
# Should be no padlock
assert ">fixtures 🔒" not in anon_response.text
auth_response = client.get(
- path, cookies={"ds_actor": client.ds.sign({"id": "root"}, "actor")},
+ path, cookies={"ds_actor": client.actor_cookie({"id": "root"})},
)
assert expected_auth == auth_response.status
if (
@@ -69,7 +69,7 @@ def test_database_list_respects_view_database():
assert 'data' in anon_response.text
assert 'fixtures' not in anon_response.text
auth_response = client.get(
- "/", cookies={"ds_actor": client.ds.sign({"id": "root"}, "actor")},
+ "/", cookies={"ds_actor": client.actor_cookie({"id": "root"})},
)
assert 'data' in auth_response.text
assert 'fixtures 🔒' in auth_response.text
@@ -100,7 +100,7 @@ def test_database_list_respects_view_table():
for html_fragment in html_fragments:
assert html_fragment not in anon_response_text
auth_response_text = client.get(
- "/", cookies={"ds_actor": client.ds.sign({"id": "root"}, "actor")},
+ "/", cookies={"ds_actor": client.actor_cookie({"id": "root"})},
).text
for html_fragment in html_fragments:
assert html_fragment in auth_response_text
@@ -127,7 +127,7 @@ def test_view_table(allow, expected_anon, expected_auth):
assert ">compound_three_primary_keys 🔒" not in anon_response.text
auth_response = client.get(
"/fixtures/compound_three_primary_keys",
- cookies={"ds_actor": client.ds.sign({"id": "root"}, "actor")},
+ cookies={"ds_actor": client.actor_cookie({"id": "root"})},
)
assert expected_auth == auth_response.status
if allow and expected_anon == 403 and expected_auth == 200:
@@ -156,7 +156,7 @@ def test_table_list_respects_view_table():
for html_fragment in html_fragments:
assert html_fragment not in anon_response.text
auth_response = client.get(
- "/fixtures", cookies={"ds_actor": client.ds.sign({"id": "root"}, "actor")}
+ "/fixtures", cookies={"ds_actor": client.actor_cookie({"id": "root"})}
)
for html_fragment in html_fragments:
assert html_fragment in auth_response.text
@@ -180,7 +180,7 @@ def test_view_query(allow, expected_anon, expected_auth):
# Should be no padlock
assert ">fixtures 🔒" not in anon_response.text
auth_response = client.get(
- "/fixtures/q", cookies={"ds_actor": client.ds.sign({"id": "root"}, "actor")}
+ "/fixtures/q", cookies={"ds_actor": client.actor_cookie({"id": "root"})}
)
assert expected_auth == auth_response.status
if allow and expected_anon == 403 and expected_auth == 200:
@@ -206,7 +206,7 @@ def test_execute_sql(metadata):
assert 403 == client.get("/fixtures/facet_cities?_where=id=3").status
# But for logged in user all of these should work:
- cookies = {"ds_actor": client.ds.sign({"id": "root"}, "actor")}
+ cookies = {"ds_actor": client.actor_cookie({"id": "root"})}
response_text = client.get("/fixtures", cookies=cookies).text
assert form_fragment in response_text
assert 200 == client.get("/fixtures?sql=select+1", cookies=cookies).status
@@ -231,7 +231,7 @@ def test_query_list_respects_view_query():
assert html_fragment not in anon_response.text
assert '"/fixtures/q"' not in anon_response.text
auth_response = client.get(
- "/fixtures", cookies={"ds_actor": client.ds.sign({"id": "root"}, "actor")}
+ "/fixtures", cookies={"ds_actor": client.actor_cookie({"id": "root"})}
)
assert html_fragment in auth_response.text
@@ -290,7 +290,7 @@ def test_permissions_debug(app_client):
app_client.ds._permission_checks.clear()
assert 403 == app_client.get("/-/permissions").status
# With the cookie it should work
- cookie = app_client.ds.sign({"id": "root"}, "actor")
+ cookie = app_client.actor_cookie({"id": "root"})
response = app_client.get("/-/permissions", cookies={"ds_actor": cookie})
# Should show one failure and one success
soup = Soup(response.body, "html.parser")