Skip to content

Commit d5710cd

Browse files
authored
Add support to fetch from authenticated token lists (#2754)
1 parent a4428ab commit d5710cd

File tree

3 files changed

+221
-2
lines changed

3 files changed

+221
-2
lines changed
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Generated by Django 5.2.5 on 2025-12-18 15:25
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('tokens', '0014_tokennotvalid'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='tokenlist',
15+
name='auth_key',
16+
field=models.CharField(blank=True, help_text="Authentication key name. Usage depends on auth_type: For 'api_key': header name (e.g., 'x-api-key'). For 'bearer': header name (e.g., 'Authorization'). For 'query': query parameter name (e.g., 'api_key'). For 'basic': leave empty. For 'none': leave empty.", max_length=100),
17+
),
18+
migrations.AddField(
19+
model_name='tokenlist',
20+
name='auth_type',
21+
field=models.CharField(choices=[('none', 'None'), ('api_key', 'API Key (Header)'), ('bearer', 'Bearer Token'), ('basic', 'Basic Auth'), ('query', 'Query Param')], default='none', max_length=20),
22+
),
23+
migrations.AddField(
24+
model_name='tokenlist',
25+
name='auth_value',
26+
field=models.TextField(blank=True, help_text="Authentication credentials. Usage depends on auth_type: For 'api_key': the API key value. For 'bearer': the bearer token value (without 'Bearer ' prefix). For 'basic': the full 'Basic <base64>' value (e.g., 'Basic dXNlcjpwYXNz'). For 'query': the query parameter value. For 'none': leave empty."),
27+
),
28+
]

safe_transaction_service/tokens/models.py

Lines changed: 93 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import os
33
from json import JSONDecodeError
44
from typing import Optional, TypedDict
5-
from urllib.parse import urljoin
5+
from urllib.parse import parse_qsl, urlencode, urljoin, urlparse, urlunparse
66

77
from django.conf import settings
88
from django.core.exceptions import ValidationError
@@ -341,12 +341,103 @@ class TokenList(models.Model):
341341
url = models.URLField(unique=True)
342342
description = models.CharField(max_length=200)
343343

344+
auth_type = models.CharField(
345+
max_length=20,
346+
choices=[
347+
("none", "None"),
348+
("api_key", "API Key (Header)"),
349+
("bearer", "Bearer Token"),
350+
("basic", "Basic Auth"),
351+
("query", "Query Param"),
352+
],
353+
default="none",
354+
)
355+
356+
auth_key = models.CharField(
357+
max_length=100,
358+
blank=True,
359+
help_text=(
360+
"Authentication key name. Usage depends on auth_type: "
361+
"For 'api_key': header name (e.g., 'x-api-key'). "
362+
"For 'bearer': header name (e.g., 'Authorization'). "
363+
"For 'query': query parameter name (e.g., 'api_key'). "
364+
"For 'basic': leave empty. "
365+
"For 'none': leave empty."
366+
),
367+
)
368+
369+
auth_value = models.TextField(
370+
blank=True,
371+
help_text=(
372+
"Authentication credentials. Usage depends on auth_type: "
373+
"For 'api_key': the API key value. "
374+
"For 'bearer': the bearer token value (without 'Bearer ' prefix). "
375+
"For 'basic': the full 'Basic <base64>' value (e.g., 'Basic dXNlcjpwYXNz'). "
376+
"For 'query': the query parameter value. "
377+
"For 'none': leave empty."
378+
),
379+
)
380+
344381
def __str__(self):
345382
return f"{self.description} token list"
346383

384+
def clean(self):
385+
super().clean()
386+
387+
if self.auth_type == "none":
388+
if self.auth_key or self.auth_value:
389+
raise ValidationError(
390+
"auth_key and auth_value must be empty when auth_type is 'none'"
391+
)
392+
393+
elif self.auth_type in {"bearer", "api_key", "query"}:
394+
if not self.auth_key:
395+
raise ValidationError(
396+
{"auth_key": "auth_key is required for this auth_type"}
397+
)
398+
if not self.auth_value:
399+
raise ValidationError(
400+
{"auth_value": "auth_value is required for this auth_type"}
401+
)
402+
403+
elif self.auth_type == "basic":
404+
if not self.auth_value:
405+
raise ValidationError(
406+
{"auth_value": "auth_value is required for basic auth"}
407+
)
408+
409+
def _build_authenticated_request(self) -> tuple[str, dict]:
410+
"""
411+
Builds the URL and headers with authentication based on the configured auth_type.
412+
Supports bearer tokens, API keys (header-based), basic auth, and query parameters.
413+
414+
:return: Tuple of (authenticated_url, headers_dict)
415+
"""
416+
url = self.url
417+
headers: dict[str, str] = {}
418+
419+
if self.auth_type == "bearer":
420+
headers[self.auth_key] = f"Bearer {self.auth_value}"
421+
422+
elif self.auth_type == "api_key":
423+
headers[self.auth_key] = self.auth_value
424+
425+
elif self.auth_type == "basic":
426+
headers["Authorization"] = self.auth_value
427+
428+
elif self.auth_type == "query":
429+
parsed = urlparse(url)
430+
query_params = parse_qsl(parsed.query, keep_blank_values=True)
431+
query_params.append((self.auth_key, self.auth_value))
432+
433+
url = urlunparse(parsed._replace(query=urlencode(query_params)))
434+
435+
return url, headers
436+
347437
def get_tokens(self) -> list[TokenListToken]:
348438
try:
349-
response = requests.get(self.url, timeout=5)
439+
url, headers = self._build_authenticated_request()
440+
response = requests.get(url, headers=headers, timeout=5)
350441
if response.ok:
351442
tokens = response.json().get("tokens", [])
352443
if not tokens:

safe_transaction_service/tokens/tests/test_models.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,3 +259,103 @@ def test_get_tokens(self, requests_get: MagicMock):
259259

260260
requests_get.return_value.json.return_value = token_list_mock
261261
self.assertEqual(len(token_list.get_tokens()), 35)
262+
263+
@mock.patch("requests.get")
264+
def test_get_tokens_with_auth_none(self, requests_get: MagicMock):
265+
token_list = TokenListFactory(
266+
url="https://example.com/tokens.json",
267+
auth_type="none",
268+
)
269+
270+
requests_get.return_value.ok = True
271+
requests_get.return_value.json.return_value = token_list_mock
272+
273+
tokens = token_list.get_tokens()
274+
275+
self.assertEqual(len(tokens), 35)
276+
requests_get.assert_called_once_with(
277+
"https://example.com/tokens.json", headers={}, timeout=5
278+
)
279+
280+
@mock.patch("requests.get")
281+
def test_get_tokens_with_auth_bearer(self, requests_get: MagicMock):
282+
token_list = TokenListFactory(
283+
url="https://example.com/tokens.json",
284+
auth_type="bearer",
285+
auth_key="Authorization",
286+
auth_value="my-secret-token",
287+
)
288+
289+
requests_get.return_value.ok = True
290+
requests_get.return_value.json.return_value = token_list_mock
291+
292+
tokens = token_list.get_tokens()
293+
294+
self.assertEqual(len(tokens), 35)
295+
requests_get.assert_called_once_with(
296+
"https://example.com/tokens.json",
297+
headers={"Authorization": "Bearer my-secret-token"},
298+
timeout=5,
299+
)
300+
301+
@mock.patch("requests.get")
302+
def test_get_tokens_with_auth_api_key(self, requests_get: MagicMock):
303+
token_list = TokenListFactory(
304+
url="https://example.com/tokens.json",
305+
auth_type="api_key",
306+
auth_key="X-API-Key",
307+
auth_value="my-api-key-value",
308+
)
309+
310+
requests_get.return_value.ok = True
311+
requests_get.return_value.json.return_value = token_list_mock
312+
313+
tokens = token_list.get_tokens()
314+
315+
self.assertEqual(len(tokens), 35)
316+
requests_get.assert_called_once_with(
317+
"https://example.com/tokens.json",
318+
headers={"X-API-Key": "my-api-key-value"},
319+
timeout=5,
320+
)
321+
322+
@mock.patch("requests.get")
323+
def test_get_tokens_with_auth_basic(self, requests_get: MagicMock):
324+
token_list = TokenListFactory(
325+
url="https://example.com/tokens.json",
326+
auth_type="basic",
327+
auth_value="Basic dXNlcm5hbWU6cGFzc3dvcmQ=",
328+
)
329+
330+
requests_get.return_value.ok = True
331+
requests_get.return_value.json.return_value = token_list_mock
332+
333+
tokens = token_list.get_tokens()
334+
335+
self.assertEqual(len(tokens), 35)
336+
requests_get.assert_called_once_with(
337+
"https://example.com/tokens.json",
338+
headers={"Authorization": "Basic dXNlcm5hbWU6cGFzc3dvcmQ="},
339+
timeout=5,
340+
)
341+
342+
@mock.patch("requests.get")
343+
def test_get_tokens_with_auth_query(self, requests_get: MagicMock):
344+
token_list = TokenListFactory(
345+
url="https://example.com/tokens.json",
346+
auth_type="query",
347+
auth_key="api_key",
348+
auth_value="my-query-param-key",
349+
)
350+
351+
requests_get.return_value.ok = True
352+
requests_get.return_value.json.return_value = token_list_mock
353+
354+
tokens = token_list.get_tokens()
355+
356+
self.assertEqual(len(tokens), 35)
357+
requests_get.assert_called_once_with(
358+
"https://example.com/tokens.json?api_key=my-query-param-key",
359+
headers={},
360+
timeout=5,
361+
)

0 commit comments

Comments
 (0)