mirror of
https://github.com/simonw/datasette.git
synced 2026-10-08 02:17:01 +02:00
Return 401 for invalid or expired bearer tokens
Invalid dstok_ tokens - bad signature, malformed payload, expired, or presented while allow_signed_tokens is off - previously degraded the request to anonymous, so clients saw a 403 permission error or worse, a 200 with anonymous-visible data. Token handlers can now raise TokenInvalid for tokens they recognize but reject; Datasette responds with 401, the canonical JSON error body and a WWW-Authenticate: Bearer error="invalid_token" header, even when a valid cookie is also present. Bearer tokens no registered handler recognizes are still ignored, so authentication plugins with their own token formats keep working. TokenInvalid is exported from the datasette package for use by plugin token handlers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
This commit is contained in:
parent
b2cdc81d34
commit
aaaffe45b8
11 changed files with 214 additions and 45 deletions
|
|
@ -202,8 +202,8 @@ async def test_insert_rows(ds_write, return_rows):
|
|||
"/data/docs/-/insert",
|
||||
{"rows": [{"title": "Test"} for i in range(10)]},
|
||||
"bad_token",
|
||||
403,
|
||||
["Permission denied"],
|
||||
401,
|
||||
["Invalid token signature"],
|
||||
),
|
||||
(
|
||||
"/data/docs/-/insert",
|
||||
|
|
@ -410,12 +410,13 @@ async def test_insert_or_upsert_row_errors(
|
|||
},
|
||||
)
|
||||
|
||||
actor_response = (
|
||||
await ds_write.client.get("/-/actor.json", headers=kwargs["headers"])
|
||||
).json()
|
||||
assert set((actor_response["actor"] or {}).get("_r", {}).get("a") or []) == set(
|
||||
token_permissions
|
||||
)
|
||||
if special_case != "bad_token":
|
||||
actor_response = (
|
||||
await ds_write.client.get("/-/actor.json", headers=kwargs["headers"])
|
||||
).json()
|
||||
assert set((actor_response["actor"] or {}).get("_r", {}).get("a") or []) == set(
|
||||
token_permissions
|
||||
)
|
||||
|
||||
if special_case == "invalid_json":
|
||||
del kwargs["json"]
|
||||
|
|
|
|||
|
|
@ -236,7 +236,9 @@ def test_auth_create_token(
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_create_token_not_allowed_for_tokens(ds_client):
|
||||
ds_tok = ds_client.ds.sign({"a": "test", "token": "dstok"}, "token")
|
||||
ds_tok = ds_client.ds.sign(
|
||||
{"a": "test", "token": "dstok", "t": int(time.time())}, "token"
|
||||
)
|
||||
response = await ds_client.get(
|
||||
"/-/create-token",
|
||||
headers={"Authorization": "Bearer dstok_{}".format(ds_tok)},
|
||||
|
|
@ -304,8 +306,16 @@ async def test_auth_with_dstok_token(ds_client, scenario, should_work):
|
|||
assert actor["token"] == "dstok"
|
||||
if scenario != "valid_unlimited_token":
|
||||
assert isinstance(actor["token_expires"], int)
|
||||
else:
|
||||
elif scenario == "no_token":
|
||||
# No credentials presented - request proceeds as anonymous
|
||||
assert response.json() == {"ok": True, "actor": None}
|
||||
else:
|
||||
# Invalid credentials presented - hard 401
|
||||
assert response.status_code == 401
|
||||
data = response.json()
|
||||
assert data["ok"] is False
|
||||
assert data["status"] == 401
|
||||
assert response.headers["www-authenticate"].startswith("Bearer")
|
||||
finally:
|
||||
ds_client.ds._settings["allow_signed_tokens"] = True
|
||||
|
||||
|
|
@ -339,8 +349,9 @@ def test_cli_create_token(app_client, expires):
|
|||
expected_actor["token_expires"] = details["t"] + expires
|
||||
assert response.json == {"ok": True, "actor": expected_actor}
|
||||
else:
|
||||
expected_actor = None
|
||||
assert response.json == {"ok": True, "actor": expected_actor}
|
||||
# Expired token - hard 401
|
||||
assert response.status == 401
|
||||
assert response.json["ok"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ https://github.com/simonw/datasette/issues - 1.0 API consistency
|
|||
"""
|
||||
|
||||
import pytest
|
||||
import time
|
||||
from datasette.app import Datasette
|
||||
from datasette.utils import sqlite3
|
||||
|
||||
|
|
@ -395,3 +396,90 @@ async def test_row_delete_write_failure_is_400(tmp_path_factory):
|
|||
assert "deletes are blocked" in data["error"]
|
||||
finally:
|
||||
ds.close()
|
||||
|
||||
|
||||
# Invalid bearer tokens must produce 401, not silent anonymous access
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expired_token_returns_401(ds_error_shape):
|
||||
token = "dstok_{}".format(
|
||||
ds_error_shape.sign(
|
||||
{"a": "root", "t": int(time.time()) - 2000, "d": 1000},
|
||||
namespace="token",
|
||||
)
|
||||
)
|
||||
response = await ds_error_shape.client.get(
|
||||
"/-/actor.json", headers={"Authorization": "Bearer {}".format(token)}
|
||||
)
|
||||
data = assert_canonical_error(response, 401)
|
||||
assert "expired" in data["error"].lower()
|
||||
assert response.headers["www-authenticate"].startswith("Bearer")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bad_signature_token_returns_401(ds_error_shape):
|
||||
response = await ds_error_shape.client.get(
|
||||
"/-/actor.json", headers={"Authorization": "Bearer dstok_garbage"}
|
||||
)
|
||||
data = assert_canonical_error(response, 401)
|
||||
assert response.headers["www-authenticate"].startswith("Bearer")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unrecognized_token_prefix_stays_anonymous(ds_error_shape):
|
||||
# No registered handler claims this token - it might belong to a
|
||||
# plugin's actor_from_request hook, so it must not hard-fail
|
||||
response = await ds_error_shape.client.get(
|
||||
"/-/actor.json", headers={"Authorization": "Bearer sometoken_abc"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"ok": True, "actor": None}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_valid_token_still_authenticates(ds_error_shape):
|
||||
token = "dstok_{}".format(
|
||||
ds_error_shape.sign(
|
||||
{"a": "root", "t": int(time.time())},
|
||||
namespace="token",
|
||||
)
|
||||
)
|
||||
response = await ds_error_shape.client.get(
|
||||
"/-/actor.json", headers={"Authorization": "Bearer {}".format(token)}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["actor"]["id"] == "root"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bad_token_beats_valid_cookie(ds_error_shape):
|
||||
# A malformed Authorization header is a hard error even if a valid
|
||||
# ds_actor cookie is also present
|
||||
response = await ds_error_shape.client.get(
|
||||
"/-/actor.json",
|
||||
headers={"Authorization": "Bearer dstok_garbage"},
|
||||
cookies={"ds_actor": ds_error_shape.client.actor_cookie({"id": "root"})},
|
||||
)
|
||||
assert_canonical_error(response, 401)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_when_signed_tokens_disabled_returns_401(tmp_path_factory):
|
||||
db_directory = tmp_path_factory.mktemp("dbs")
|
||||
db_path = str(db_directory / "data.db")
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.execute("vacuum")
|
||||
conn.close()
|
||||
ds = Datasette([db_path], settings={"allow_signed_tokens": False})
|
||||
try:
|
||||
token = "dstok_{}".format(
|
||||
ds.sign({"a": "root", "t": int(time.time())}, namespace="token")
|
||||
)
|
||||
response = await ds.client.get(
|
||||
"/-/actor.json", headers={"Authorization": "Bearer {}".format(token)}
|
||||
)
|
||||
data = assert_canonical_error(response, 401)
|
||||
assert "not enabled" in data["error"]
|
||||
finally:
|
||||
ds.close()
|
||||
|
|
|
|||
|
|
@ -5,7 +5,12 @@ Tests for the register_token_handler plugin hook.
|
|||
from datasette.app import Datasette
|
||||
from datasette.hookspecs import hookimpl
|
||||
from datasette.plugins import pm
|
||||
from datasette.tokens import TokenHandler, TokenRestrictions, SignedTokenHandler
|
||||
from datasette.tokens import (
|
||||
TokenHandler,
|
||||
TokenInvalid,
|
||||
TokenRestrictions,
|
||||
SignedTokenHandler,
|
||||
)
|
||||
import pytest
|
||||
|
||||
|
||||
|
|
@ -66,10 +71,10 @@ async def test_verify_token_unknown_returns_none(datasette):
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_token_bad_signature_returns_none(datasette):
|
||||
"""verify_token() should return None for tokens with bad signatures."""
|
||||
result = await datasette.verify_token("dstok_tampered_data_here")
|
||||
assert result is None
|
||||
async def test_verify_token_bad_signature_raises(datasette):
|
||||
"""verify_token() should raise TokenInvalid for tokens with bad signatures."""
|
||||
with pytest.raises(TokenInvalid):
|
||||
await datasette.verify_token("dstok_tampered_data_here")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -334,5 +339,6 @@ async def test_signed_tokens_disabled():
|
|||
ds = Datasette(settings={"allow_signed_tokens": False})
|
||||
with pytest.raises(ValueError, match="Signed tokens are not enabled"):
|
||||
await ds.create_token("test_actor", handler="signed")
|
||||
# verify_token should return None rather than raising
|
||||
assert await ds.verify_token("dstok_anything") is None
|
||||
# verify_token should raise TokenInvalid for a dstok_ token
|
||||
with pytest.raises(TokenInvalid, match="not enabled"):
|
||||
await ds.verify_token("dstok_anything")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue