Skip to content

Commit 1707a6b

Browse files
authored
Extend PAT authentication support to all /api/v2/ endpoints (mozilla#4081)
The PersonalAccessTokenAuthentication class raised AuthenticationFailed when no Bearer header was present, preventing it from coexisting with session auth on the same view. Changed it to return None per DRF convention, allowing the next authenticator in the chain to try. Added PAT and session auth as DEFAULT_AUTHENTICATION_CLASSES in REST_FRAMEWORK settings so all API endpoints accept Bearer tokens alongside session cookies. PretranslationView retains its explicit token-only authentication.
1 parent 1641d49 commit 1707a6b

4 files changed

Lines changed: 81 additions & 6 deletions

File tree

pontoon/api/authentication.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@ def authenticate(self, request):
1818
auth_header = request.headers.get("Authorization")
1919

2020
if not auth_header or not auth_header.startswith("Bearer "):
21-
raise AuthenticationFailed(
22-
{"detail": "Missing or invalid Authorization header."}
23-
)
21+
# Return None to let DRF try the next authenticator (e.g. session auth).
22+
# Raising AuthenticationFailed here would block all non-Bearer requests.
23+
return None
2424

2525
try:
2626
token_id, unhashed_token = auth_header.split(" ")[1].split("_")

pontoon/api/tests/test_authentication.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,17 @@ def test_authenticate_missing_authorization_header():
3838
auth = PersonalAccessTokenAuthentication()
3939
request = type("Request", (), {"headers": {}})
4040

41-
with pytest.raises(AuthenticationFailed) as excinfo:
42-
auth.authenticate(request)
43-
assert excinfo.value.detail["detail"] == "Missing or invalid Authorization header."
41+
result = auth.authenticate(request)
42+
assert result is None
43+
44+
45+
@pytest.mark.django_db
46+
def test_authenticate_non_bearer_authorization_header():
47+
auth = PersonalAccessTokenAuthentication()
48+
request = type("Request", (), {"headers": {"Authorization": "Basic dXNlcjpwYXNz"}})
49+
50+
result = auth.authenticate(request)
51+
assert result is None
4452

4553

4654
@pytest.mark.django_db

pontoon/api/tests/test_views.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1835,3 +1835,66 @@ def test_pretranslation_tm(member):
18351835
)
18361836

18371837
assert response.status_code == 400
1838+
1839+
1840+
@pytest.mark.django_db
1841+
def test_pat_auth_on_locales_endpoint(member):
1842+
"""PAT authentication works on non-pretranslation endpoints."""
1843+
token = PersonalAccessToken.objects.create(
1844+
user=member.user,
1845+
name="Test Token PAT Locales",
1846+
token_hash="hashed_token",
1847+
expires_at=now() + timedelta(days=1),
1848+
)
1849+
token_id = token.id
1850+
token_unhashed = "unhashed-token"
1851+
token.token_hash = make_password(token_unhashed)
1852+
token.save()
1853+
1854+
response = APIClient().get(
1855+
"/api/v2/locales/",
1856+
HTTP_ACCEPT="application/json",
1857+
headers={"Authorization": f"Bearer {token_id}_{token_unhashed}"},
1858+
)
1859+
1860+
assert response.status_code == 200
1861+
1862+
1863+
@pytest.mark.django_db
1864+
def test_session_auth_still_works_on_user_actions(member):
1865+
"""Session authentication continues to work after PAT auth is added to defaults."""
1866+
client = APIClient()
1867+
client.force_authenticate(user=member.user)
1868+
1869+
project = ProjectFactory(slug="test-session-project")
1870+
date = now().strftime("%Y-%m-%d")
1871+
1872+
response = client.get(
1873+
f"/api/v2/user-actions/{date}/project/{project.slug}/",
1874+
HTTP_ACCEPT="application/json",
1875+
)
1876+
1877+
assert response.status_code == 200
1878+
1879+
1880+
@pytest.mark.django_db
1881+
def test_expired_pat_rejected_on_non_pretranslation_endpoint(member):
1882+
"""Expired PAT returns 403 on non-pretranslation endpoints."""
1883+
token = PersonalAccessToken.objects.create(
1884+
user=member.user,
1885+
name="Test Token Expired",
1886+
token_hash="hashed_token",
1887+
expires_at=now() - timedelta(days=1),
1888+
)
1889+
token_id = token.id
1890+
token_unhashed = "unhashed-token"
1891+
token.token_hash = make_password(token_unhashed)
1892+
token.save()
1893+
1894+
response = APIClient().get(
1895+
"/api/v2/locales/",
1896+
HTTP_ACCEPT="application/json",
1897+
headers={"Authorization": f"Bearer {token_id}_{token_unhashed}"},
1898+
)
1899+
1900+
assert response.status_code == 403

pontoon/settings/base.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1270,6 +1270,10 @@ def account_username(user):
12701270
TBX_DESCRIPTION = os.environ.get("TBX_DESCRIPTION", "Terms localized in Pontoon")
12711271

12721272
REST_FRAMEWORK = {
1273+
"DEFAULT_AUTHENTICATION_CLASSES": [
1274+
"pontoon.api.authentication.PersonalAccessTokenAuthentication",
1275+
"rest_framework.authentication.SessionAuthentication",
1276+
],
12731277
"DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"],
12741278
"DEFAULT_PAGINATION_CLASS": "pontoon.api.pagination.DynamicPageNumberPagination",
12751279
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",

0 commit comments

Comments
 (0)