Skip to content

Commit ebaed99

Browse files
authored
Merge pull request #413 from MuckRock/jwt-get-create
2 parents 079e914 + 72028df commit ebaed99

3 files changed

Lines changed: 160 additions & 1 deletion

File tree

config/settings/base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -418,7 +418,7 @@
418418
"HTML_SELECT_CUTOFF": 20,
419419
"DEFAULT_AUTHENTICATION_CLASSES": (
420420
"rest_framework.authentication.SessionAuthentication",
421-
"rest_framework_simplejwt.authentication.JWTAuthentication",
421+
"documentcloud.core.authentication.SquareletJWTAuthentication",
422422
"documentcloud.core.authentication.ProcessingTokenAuthentication",
423423
),
424424
"DEFAULT_VERSIONING_CLASS": "documentcloud.core.versioning.QueryParameterVersioning",

documentcloud/core/authentication.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,17 @@
77

88
# Standard Library
99
import hmac
10+
import logging
11+
12+
# Third Party
13+
import requests
14+
from rest_framework_simplejwt.authentication import JWTAuthentication
15+
from rest_framework_simplejwt.settings import api_settings
16+
from squarelet_auth import settings as squarelet_settings
17+
from squarelet_auth.users.utils import squarelet_update_or_create
18+
from squarelet_auth.utils import squarelet_get
19+
20+
logger = logging.getLogger(__name__)
1021

1122

1223
class ProcessingTokenAuthentication(BaseAuthentication):
@@ -44,3 +55,59 @@ def authenticate_credentials(self, key):
4455

4556
def authenticate_header(self, request):
4657
return "processing-token"
58+
59+
60+
class SquareletJWTAuthentication(JWTAuthentication):
61+
"""JWT authentication that lazily provisions users from Squarelet.
62+
63+
Squarelet issues JWTs for users who may not yet have a mirrored ``User``
64+
row in DocumentCloud's database. That row is normally created on first
65+
interactive login, or via the asynchronous cache-invalidation webhook.
66+
Either of these can lose a race against an immediate API call, like when
67+
we're trying to fetch information about a user's add-ons in Klaxon.
68+
69+
When the user is missing locally we fetch their data from Squarelet
70+
synchronously, create the row inline, and retry, so the very first
71+
authenticated request is self-healing and the timing race is eliminated.
72+
73+
Provisioning is gated by ``SQUARELET_DISABLE_CREATE`` (via
74+
``squarelet_auth.settings.DISABLE_CREATE``), matching the webhook's
75+
``pull_data`` task: where creating users from Squarelet is disabled, an
76+
unknown user still 401s rather than being provisioned here.
77+
"""
78+
79+
def get_user(self, validated_token):
80+
try:
81+
return super().get_user(validated_token)
82+
except exceptions.AuthenticationFailed as exc:
83+
# Only provision when the token is valid but the user simply does
84+
# not exist locally yet. Genuinely invalid tokens (and any other
85+
# failures) must still surface as a 401. simplejwt wraps its detail
86+
# in a dict (``{"detail": ..., "code": ...}``) while plain DRF uses
87+
# an ``ErrorDetail`` string, so handle both shapes.
88+
if isinstance(exc.detail, dict):
89+
code = exc.detail.get("code")
90+
else:
91+
code = getattr(exc.detail, "code", None)
92+
if code != "user_not_found":
93+
raise
94+
95+
# Respect the same gate as the webhook's pull_data task: when
96+
# creating users from Squarelet is disabled, don't provision them
97+
# here either -- let the request 401.
98+
if squarelet_settings.DISABLE_CREATE:
99+
raise
100+
101+
uuid = validated_token[api_settings.USER_ID_CLAIM]
102+
logger.info("[JWT] Lazily provisioning user from Squarelet: %s", uuid)
103+
try:
104+
resp = squarelet_get(f"/api/users/{uuid}/")
105+
resp.raise_for_status()
106+
squarelet_update_or_create(uuid, resp.json())
107+
except requests.exceptions.RequestException:
108+
logger.exception("[JWT] Failed to fetch user from Squarelet: %s", uuid)
109+
# Re-raise the original auth failure so the request 401s
110+
raise exc
111+
112+
# Retry now that the user should exist locally
113+
return super().get_user(validated_token)

documentcloud/core/tests.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from django.db import transaction
55
from django.test import TestCase
66
from django.urls import reverse
7+
from rest_framework.exceptions import AuthenticationFailed
78

89
# Standard Library
910
import hashlib
@@ -14,8 +15,11 @@
1415

1516
# Third Party
1617
import pytest
18+
import requests
19+
from rest_framework_simplejwt.settings import api_settings
1720

1821
# DocumentCloud
22+
from documentcloud.core.authentication import SquareletJWTAuthentication
1923
from documentcloud.users.tests.factories import UserFactory
2024

2125

@@ -93,3 +97,91 @@ def test_invalid_signature(self):
9397
f"{user.mailkey}@uploads.documentcloud.org", sign=False
9498
)
9599
assert response.status_code == 403
100+
101+
102+
@pytest.mark.django_db()
103+
class TestSquareletJWTAuthentication:
104+
"""Tests for lazy user provisioning during JWT authentication"""
105+
106+
def token(self, user_uuid):
107+
"""Build a minimal validated token carrying the user's uuid claim"""
108+
return {api_settings.USER_ID_CLAIM: str(user_uuid)}
109+
110+
@mock.patch("documentcloud.core.authentication.squarelet_update_or_create")
111+
@mock.patch("documentcloud.core.authentication.squarelet_get")
112+
def test_existing_user(self, mock_get, mock_update):
113+
"""A user that already exists locally is returned without a callback"""
114+
user = UserFactory()
115+
auth = SquareletJWTAuthentication()
116+
117+
result = auth.get_user(self.token(user.uuid))
118+
119+
assert result == user
120+
mock_get.assert_not_called()
121+
mock_update.assert_not_called()
122+
123+
@mock.patch(
124+
"documentcloud.core.authentication.squarelet_settings.DISABLE_CREATE", False
125+
)
126+
@mock.patch("documentcloud.core.authentication.squarelet_update_or_create")
127+
@mock.patch("documentcloud.core.authentication.squarelet_get")
128+
def test_lazy_provision_missing_user(self, mock_get, mock_update):
129+
"""A missing user is fetched from Squarelet, created, and returned"""
130+
missing_uuid = uuid.uuid4()
131+
data = {"preferred_username": "newuser", "organizations": []}
132+
mock_get.return_value.json.return_value = data
133+
# Simulate squarelet_update_or_create creating the local mirror row
134+
mock_update.side_effect = lambda _uuid, _data: UserFactory(uuid=missing_uuid)
135+
auth = SquareletJWTAuthentication()
136+
137+
result = auth.get_user(self.token(missing_uuid))
138+
139+
assert result.uuid == missing_uuid
140+
mock_get.assert_called_once_with(f"/api/users/{missing_uuid}/")
141+
# The uuid comes off the JWT claim as a string, matching how the
142+
# webhook's pull_data task calls squarelet_update_or_create
143+
mock_update.assert_called_once_with(str(missing_uuid), data)
144+
145+
@mock.patch("documentcloud.core.authentication.squarelet_update_or_create")
146+
@mock.patch("documentcloud.core.authentication.squarelet_get")
147+
def test_invalid_token_not_provisioned(self, mock_get, mock_update):
148+
"""A token without a user claim must 401 without contacting Squarelet"""
149+
auth = SquareletJWTAuthentication()
150+
151+
with pytest.raises(AuthenticationFailed):
152+
auth.get_user({})
153+
154+
mock_get.assert_not_called()
155+
mock_update.assert_not_called()
156+
157+
@mock.patch(
158+
"documentcloud.core.authentication.squarelet_settings.DISABLE_CREATE", False
159+
)
160+
@mock.patch("documentcloud.core.authentication.squarelet_update_or_create")
161+
@mock.patch("documentcloud.core.authentication.squarelet_get")
162+
def test_squarelet_fetch_fails(self, mock_get, mock_update):
163+
"""If the Squarelet fetch fails, the request still 401s"""
164+
missing_uuid = uuid.uuid4()
165+
mock_get.side_effect = requests.exceptions.RequestException
166+
auth = SquareletJWTAuthentication()
167+
168+
with pytest.raises(AuthenticationFailed):
169+
auth.get_user(self.token(missing_uuid))
170+
171+
mock_update.assert_not_called()
172+
173+
@mock.patch(
174+
"documentcloud.core.authentication.squarelet_settings.DISABLE_CREATE", True
175+
)
176+
@mock.patch("documentcloud.core.authentication.squarelet_update_or_create")
177+
@mock.patch("documentcloud.core.authentication.squarelet_get")
178+
def test_disable_create_skips_provisioning(self, mock_get, mock_update):
179+
"""When SQUARELET_DISABLE_CREATE is set, missing users still 401"""
180+
missing_uuid = uuid.uuid4()
181+
auth = SquareletJWTAuthentication()
182+
183+
with pytest.raises(AuthenticationFailed):
184+
auth.get_user(self.token(missing_uuid))
185+
186+
mock_get.assert_not_called()
187+
mock_update.assert_not_called()

0 commit comments

Comments
 (0)