mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-14 18:18:08 +00:00
feat(pat): authenticate personal access tokens
handle_auth resolves a dgpt_pat_ bearer against the database instead of decoding it as a JWT, for both the Flask and the ASGI routes. Scopes and the resource filter always come from the token row, and the claims that mark a PAT are stripped from decoded JWTs so a session token cannot pose as one. ASGI routes reject tokens unless they name the scope that admits them, and /api/user/me reports what the calling token may do.
This commit is contained in:
1 parent
22ecc0aee3
commit
174130607a
8 files changed
+604
-3
No files matched your search
@@ -114,3 +114,45 @@ def test_json_error_shape():
|
||||
response = asgi_auth.json_error("Forbidden", 403)
|
||||
assert response.status_code == 403
|
||||
assert json.loads(response.body) == {"success": False, "message": "Forbidden"}
|
||||
|
||||
|
||||
_PAT_CLAIMS = {
|
||||
"sub": "alice",
|
||||
"auth_method": "pat",
|
||||
"pat_id": "t1",
|
||||
"scopes": ["chat:run"],
|
||||
"resource_filter": {},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
class TestPersonalAccessTokens:
|
||||
async def test_routes_reject_tokens_unless_they_name_a_scope(self):
|
||||
with patch.object(asgi_auth, "handle_auth", return_value=dict(_PAT_CLAIMS)):
|
||||
decoded, error = await asgi_auth.authenticate(_request())
|
||||
assert decoded is None
|
||||
assert error.status_code == 403
|
||||
assert json.loads(error.body)["error"] == "insufficient_scope"
|
||||
|
||||
async def test_token_with_the_route_scope_passes(self):
|
||||
with patch.object(asgi_auth, "handle_auth", return_value=dict(_PAT_CLAIMS)):
|
||||
decoded, error = await asgi_auth.authenticate(_request(), pat_scope="chat:run")
|
||||
assert error is None
|
||||
assert decoded["sub"] == "alice"
|
||||
|
||||
async def test_token_without_the_route_scope_is_refused(self):
|
||||
claims = dict(_PAT_CLAIMS, scopes=["agents:read"])
|
||||
with patch.object(asgi_auth, "handle_auth", return_value=claims):
|
||||
decoded, error = await asgi_auth.authenticate(_request(), pat_scope="chat:run")
|
||||
assert decoded is None
|
||||
assert error.status_code == 403
|
||||
|
||||
async def test_session_denylist_is_not_consulted_for_tokens(self, monkeypatch):
|
||||
monkeypatch.setattr(asgi_auth.settings, "AUTH_TYPE", "oidc")
|
||||
with patch.object(asgi_auth, "handle_auth", return_value=dict(_PAT_CLAIMS)), patch.object(
|
||||
asgi_auth, "oidc_session_denied", return_value=True
|
||||
) as denied:
|
||||
decoded, error = await asgi_auth.authenticate(_request(), pat_scope="chat:run")
|
||||
denied.assert_not_called()
|
||||
assert error is None and decoded is not None
|
||||
@@ -0,0 +1,275 @@
|
||||
"""Unit tests for docsgpt/api/pat/tokens.py and the PAT branch of handle_auth."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from docsgpt.api.pat import tokens
|
||||
|
||||
|
||||
def _request(authorization=None, ip="10.1.1.1"):
|
||||
request = Mock()
|
||||
request.headers = {"Authorization": authorization} if authorization else {}
|
||||
request.remote_addr = ip
|
||||
return request
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _db(row):
|
||||
repo = Mock()
|
||||
repo.find_active_by_hash.return_value = row
|
||||
|
||||
@contextmanager
|
||||
def _conn():
|
||||
yield Mock()
|
||||
|
||||
with patch.object(tokens, "db_readonly", _conn), patch.object(
|
||||
tokens, "db_session", _conn
|
||||
), patch.object(tokens, "PersonalAccessTokensRepository", return_value=repo):
|
||||
yield repo
|
||||
|
||||
|
||||
_ROW = {
|
||||
"id": "11111111-1111-1111-1111-111111111111",
|
||||
"user_id": "alice",
|
||||
"name": "ci",
|
||||
"scopes": ["agents:write", "chat:run"],
|
||||
"resource_filter": {"agents": ["22222222-2222-2222-2222-222222222222"]},
|
||||
"last_used_at": None,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestTokenFormat:
|
||||
def test_generate_returns_prefixed_secret_hash_and_display_prefix(self):
|
||||
token, token_hash, display = tokens.generate_token()
|
||||
assert token.startswith("dgpt_pat_")
|
||||
assert len(token) > 40
|
||||
assert token_hash == tokens.hash_token(token)
|
||||
assert len(token_hash) == 64
|
||||
assert token.startswith(display) and len(display) == len("dgpt_pat_") + 6
|
||||
|
||||
def test_tokens_are_unique(self):
|
||||
assert tokens.generate_token()[0] != tokens.generate_token()[0]
|
||||
|
||||
def test_redact_never_returns_the_secret(self):
|
||||
token, _, display = tokens.generate_token()
|
||||
assert tokens.redact(token) == display + "…"
|
||||
assert tokens.redact("some-agent-key") == "some…"
|
||||
assert tokens.redact(None) == ""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value,expected",
|
||||
[("dgpt_pat_abc", True), ("eyJhbGciOi", False), ("", False), (None, False)],
|
||||
)
|
||||
def test_looks_like_pat(self, value, expected):
|
||||
assert tokens.looks_like_pat(value) is expected
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestScopes:
|
||||
def test_write_implies_read(self):
|
||||
assert tokens.expand_scopes(["agents:write"]) == {"agents:write", "agents:read"}
|
||||
|
||||
def test_standalone_scopes_imply_nothing(self):
|
||||
assert tokens.expand_scopes(["agents:keys", "chat:run"]) == {"agents:keys", "chat:run"}
|
||||
|
||||
def test_normalize_sorts_and_dedupes(self):
|
||||
assert tokens.normalize_scopes(["sources:read", "agents:read", "sources:read"]) == [
|
||||
"agents:read",
|
||||
"sources:read",
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize("raw", [None, [], "agents:read", ["admin:all"], [1], ["agents:read", "x"]])
|
||||
def test_normalize_rejects_bad_input(self, raw):
|
||||
with pytest.raises(ValueError):
|
||||
tokens.normalize_scopes(raw)
|
||||
|
||||
def test_no_admin_scope_exists(self):
|
||||
assert not any(s.startswith("admin") for s in tokens.SCOPES)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestResourceFilter:
|
||||
UUID_A = "22222222-2222-2222-2222-222222222222"
|
||||
|
||||
def test_empty_is_unrestricted(self):
|
||||
assert tokens.normalize_resource_filter(None, ["agents:read"]) == {}
|
||||
assert tokens.normalize_resource_filter({}, ["agents:read"]) == {}
|
||||
|
||||
def test_canonicalizes_and_dedupes_ids(self):
|
||||
out = tokens.normalize_resource_filter(
|
||||
{"agents": [self.UUID_A.upper(), self.UUID_A]}, ["agents:read"]
|
||||
)
|
||||
assert out == {"agents": [self.UUID_A]}
|
||||
|
||||
def test_chat_scope_allows_agent_and_source_restrictions(self):
|
||||
out = tokens.normalize_resource_filter(
|
||||
{"agents": [self.UUID_A], "sources": [self.UUID_A]}, ["chat:run"]
|
||||
)
|
||||
assert set(out) == {"agents", "sources"}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw,scopes",
|
||||
[
|
||||
("agents", ["agents:read"]),
|
||||
({"conversations": [UUID_A]}, ["conversations:read"]),
|
||||
({"sources": [UUID_A]}, ["agents:read"]),
|
||||
({"agents": []}, ["agents:read"]),
|
||||
({"agents": "all"}, ["agents:read"]),
|
||||
({"agents": ["not-a-uuid"]}, ["agents:read"]),
|
||||
({"agents": [UUID_A] * 201}, ["agents:read"]),
|
||||
],
|
||||
)
|
||||
def test_rejects_bad_filters(self, raw, scopes):
|
||||
with pytest.raises(ValueError):
|
||||
tokens.normalize_resource_filter(raw, scopes)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestExpiryPolicy:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _policy(self, monkeypatch):
|
||||
monkeypatch.setattr(tokens.settings, "PAT_DEFAULT_LIFETIME_DAYS", 90)
|
||||
monkeypatch.setattr(tokens.settings, "PAT_MAX_LIFETIME_DAYS", 365)
|
||||
monkeypatch.setattr(tokens.settings, "PAT_ALLOW_NON_EXPIRING", False)
|
||||
|
||||
def _days(self, expires_at):
|
||||
return round((expires_at - datetime.now(timezone.utc)) / timedelta(days=1))
|
||||
|
||||
def test_default_lifetime(self):
|
||||
assert self._days(tokens.resolve_expiry(None)) == 90
|
||||
|
||||
def test_explicit_lifetime(self):
|
||||
assert self._days(tokens.resolve_expiry(7)) == 7
|
||||
|
||||
def test_max_lifetime_enforced(self):
|
||||
assert self._days(tokens.resolve_expiry(365)) == 365
|
||||
with pytest.raises(ValueError):
|
||||
tokens.resolve_expiry(366)
|
||||
|
||||
def test_non_expiring_refused_unless_operator_allows(self, monkeypatch):
|
||||
with pytest.raises(ValueError, match="disabled"):
|
||||
tokens.resolve_expiry(0)
|
||||
monkeypatch.setattr(tokens.settings, "PAT_ALLOW_NON_EXPIRING", True)
|
||||
assert tokens.resolve_expiry(0) is None
|
||||
|
||||
@pytest.mark.parametrize("raw", [-1, "30", 1.5, True])
|
||||
def test_rejects_bad_values(self, raw):
|
||||
with pytest.raises(ValueError):
|
||||
tokens.resolve_expiry(raw)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestAuthenticatePat:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _enabled(self, monkeypatch):
|
||||
monkeypatch.setattr(tokens.settings, "PAT_ENABLED", True)
|
||||
monkeypatch.setattr(tokens.settings, "AUTH_TYPE", "oidc")
|
||||
|
||||
def test_valid_token_yields_claims_from_the_row(self):
|
||||
with _db(dict(_ROW)) as repo:
|
||||
claims = tokens.authenticate_pat("dgpt_pat_secret", _request())
|
||||
repo.find_active_by_hash.assert_called_once_with(tokens.hash_token("dgpt_pat_secret"))
|
||||
assert claims == {
|
||||
"sub": "alice",
|
||||
"auth_method": "pat",
|
||||
"pat_id": _ROW["id"],
|
||||
"pat_name": "ci",
|
||||
"scopes": ["agents:read", "agents:write", "chat:run"],
|
||||
"resource_filter": _ROW["resource_filter"],
|
||||
}
|
||||
repo.touch_last_used.assert_called_once()
|
||||
assert repo.touch_last_used.call_args.args[:2] == (_ROW["id"], "10.1.1.1")
|
||||
|
||||
def test_unknown_token_is_invalid(self):
|
||||
with _db(None):
|
||||
assert tokens.authenticate_pat("dgpt_pat_x", _request())["error"] == "invalid_token"
|
||||
|
||||
def test_lookup_failure_fails_closed(self):
|
||||
with _db(dict(_ROW)) as repo:
|
||||
repo.find_active_by_hash.side_effect = RuntimeError("db down")
|
||||
assert tokens.authenticate_pat("dgpt_pat_x", _request())["error"] == "invalid_token"
|
||||
|
||||
def test_usage_write_failure_never_fails_the_request(self):
|
||||
with _db(dict(_ROW)) as repo:
|
||||
repo.touch_last_used.side_effect = RuntimeError("db down")
|
||||
assert tokens.authenticate_pat("dgpt_pat_x", _request())["sub"] == "alice"
|
||||
|
||||
def test_recent_usage_skips_the_write(self):
|
||||
row = dict(_ROW, last_used_at=datetime.now(timezone.utc).isoformat())
|
||||
with _db(row) as repo:
|
||||
tokens.authenticate_pat("dgpt_pat_x", _request())
|
||||
repo.touch_last_used.assert_not_called()
|
||||
|
||||
@pytest.mark.parametrize("auth_type", ["simple_jwt", "session_jwt"])
|
||||
def test_rejected_where_there_is_no_stable_user_identity(self, monkeypatch, auth_type):
|
||||
monkeypatch.setattr(tokens.settings, "AUTH_TYPE", auth_type)
|
||||
with _db(dict(_ROW)) as repo:
|
||||
assert tokens.authenticate_pat("dgpt_pat_x", _request())["error"] == "invalid_token"
|
||||
repo.find_active_by_hash.assert_not_called()
|
||||
|
||||
def test_rejected_when_disabled(self, monkeypatch):
|
||||
monkeypatch.setattr(tokens.settings, "PAT_ENABLED", False)
|
||||
with _db(dict(_ROW)):
|
||||
assert tokens.authenticate_pat("dgpt_pat_x", _request())["error"] == "invalid_token"
|
||||
|
||||
def test_works_with_auth_disabled(self, monkeypatch):
|
||||
monkeypatch.setattr(tokens.settings, "AUTH_TYPE", None)
|
||||
with _db(dict(_ROW)):
|
||||
assert tokens.authenticate_pat("dgpt_pat_x", _request())["sub"] == "alice"
|
||||
|
||||
def test_starlette_request_ip(self):
|
||||
request = Mock(spec=["headers", "client"])
|
||||
request.client.host = "10.9.9.9"
|
||||
with _db(dict(_ROW)) as repo:
|
||||
tokens.authenticate_pat("dgpt_pat_x", request)
|
||||
assert repo.touch_last_used.call_args.args[1] == "10.9.9.9"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestHandleAuthPatBranch:
|
||||
def test_pat_bearer_goes_to_the_pat_verifier_in_any_auth_mode(self):
|
||||
from docsgpt import auth
|
||||
|
||||
for auth_type in (None, "oidc", "simple_jwt"):
|
||||
with patch.object(auth.settings, "AUTH_TYPE", auth_type), patch(
|
||||
"docsgpt.api.pat.tokens.authenticate_pat", return_value={"sub": "alice"}
|
||||
) as verifier:
|
||||
request = _request("Bearer dgpt_pat_secret")
|
||||
assert auth.handle_auth(request) == {"sub": "alice"}
|
||||
verifier.assert_called_once_with("dgpt_pat_secret", request)
|
||||
|
||||
def test_bearer_scheme_is_case_insensitive(self):
|
||||
from docsgpt import auth
|
||||
|
||||
with patch("docsgpt.api.pat.tokens.authenticate_pat", return_value={"sub": "a"}) as verifier:
|
||||
auth.handle_auth(_request("bearer dgpt_pat_secret"))
|
||||
verifier.assert_called_once()
|
||||
|
||||
def test_jwt_cannot_smuggle_pat_claims(self):
|
||||
from jose import jwt
|
||||
|
||||
from docsgpt import auth
|
||||
|
||||
forged = jwt.encode(
|
||||
{
|
||||
"sub": "mallory",
|
||||
"auth_method": "pat",
|
||||
"scopes": ["agents:write"],
|
||||
"resource_filter": {},
|
||||
"pat_id": "x",
|
||||
"pat_name": "x",
|
||||
},
|
||||
"secret",
|
||||
algorithm="HS256",
|
||||
)
|
||||
with patch.object(auth.settings, "AUTH_TYPE", "simple_jwt"), patch.object(
|
||||
auth.settings, "JWT_SECRET_KEY", "secret"
|
||||
):
|
||||
decoded = auth.handle_auth(_request(f"Bearer {forged}"))
|
||||
assert decoded == {"sub": "mallory"}
|
||||
Reference in new issue
Block a user