Split OAuth credentials for member center and send engine

This commit is contained in:
warrenchen 2026-07-07 13:28:02 +09:00
parent a6249e8854
commit 555462c71a
4 changed files with 273 additions and 59 deletions

View File

@ -0,0 +1,62 @@
# Generated by Codex on 2026-07-07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("base", "0012_remove_newsletter_template_settings"),
]
operations = [
migrations.AlterField(
model_name="newslettersystemsettings",
name="member_center_oauth_client_id",
field=models.CharField(
blank=True,
help_text="Member Center OAuth client_id for usage=tenant_api.",
max_length=255,
verbose_name="OAuth Client ID",
),
),
migrations.AlterField(
model_name="newslettersystemsettings",
name="member_center_oauth_client_secret",
field=models.TextField(
blank=True,
help_text="Member Center OAuth client_secret for usage=tenant_api.",
verbose_name="OAuth Client Secret",
),
),
migrations.AlterField(
model_name="newslettersystemsettings",
name="member_center_oauth_token_path",
field=models.CharField(
blank=True,
default="/oauth/token",
help_text="Shared Member Center OAuth token endpoint path for tenant_api and send_api clients.",
max_length=255,
verbose_name="OAuth Token Path",
),
),
migrations.AddField(
model_name="newslettersystemsettings",
name="send_engine_oauth_client_id",
field=models.CharField(
blank=True,
help_text="Member Center OAuth client_id for usage=send_api. Uses the Member Center OAuth Token Path.",
max_length=255,
verbose_name="Send Engine OAuth Client ID",
),
),
migrations.AddField(
model_name="newslettersystemsettings",
name="send_engine_oauth_client_secret",
field=models.TextField(
blank=True,
help_text="Member Center OAuth client_secret for usage=send_api. Uses the Member Center OAuth Token Path.",
verbose_name="Send Engine OAuth Client Secret",
),
),
]

View File

@ -208,10 +208,20 @@ class NewsletterSystemSettings(BaseGenericSetting):
max_length=255, max_length=255,
blank=True, blank=True,
default="/oauth/token", default="/oauth/token",
help_text=_("Shared Member Center OAuth token endpoint path for tenant_api and send_api clients."),
verbose_name=_("OAuth Token Path"), verbose_name=_("OAuth Token Path"),
) )
member_center_oauth_client_id = models.CharField(max_length=255, blank=True, verbose_name=_("OAuth Client ID")) member_center_oauth_client_id = models.CharField(
member_center_oauth_client_secret = models.TextField(blank=True, verbose_name=_("OAuth Client Secret")) max_length=255,
blank=True,
help_text=_("Member Center OAuth client_id for usage=tenant_api."),
verbose_name=_("OAuth Client ID"),
)
member_center_oauth_client_secret = models.TextField(
blank=True,
help_text=_("Member Center OAuth client_secret for usage=tenant_api."),
verbose_name=_("OAuth Client Secret"),
)
member_center_oauth_scope = models.CharField( member_center_oauth_scope = models.CharField(
max_length=255, max_length=255,
blank=True, blank=True,
@ -236,6 +246,17 @@ class NewsletterSystemSettings(BaseGenericSetting):
default="/api/send-jobs", default="/api/send-jobs",
verbose_name=_("Send Jobs Path"), verbose_name=_("Send Jobs Path"),
) )
send_engine_oauth_client_id = models.CharField(
max_length=255,
blank=True,
help_text=_("Member Center OAuth client_id for usage=send_api. Uses the Member Center OAuth Token Path."),
verbose_name=_("Send Engine OAuth Client ID"),
)
send_engine_oauth_client_secret = models.TextField(
blank=True,
help_text=_("Member Center OAuth client_secret for usage=send_api. Uses the Member Center OAuth Token Path."),
verbose_name=_("Send Engine OAuth Client Secret"),
)
send_engine_oauth_scope = models.CharField(max_length=255, blank=True, verbose_name=_("Send Engine OAuth Scope")) send_engine_oauth_scope = models.CharField(max_length=255, blank=True, verbose_name=_("Send Engine OAuth Scope"))
send_engine_timeout_seconds = models.PositiveIntegerField(default=10, verbose_name=_("Send Engine Timeout Seconds")) send_engine_timeout_seconds = models.PositiveIntegerField(default=10, verbose_name=_("Send Engine Timeout Seconds"))
send_engine_retry_interval_seconds = models.PositiveIntegerField(default=300, verbose_name=_("Retry Interval Seconds")) send_engine_retry_interval_seconds = models.PositiveIntegerField(default=300, verbose_name=_("Retry Interval Seconds"))
@ -279,8 +300,6 @@ class NewsletterSystemSettings(BaseGenericSetting):
"member_center_oauth_client_secret", "member_center_oauth_client_secret",
widget=django_forms.PasswordInput(render_value=False), widget=django_forms.PasswordInput(render_value=False),
), ),
FieldPanel("member_center_oauth_scope"),
FieldPanel("member_center_oauth_audience"),
FieldPanel("member_center_one_click_unsubscribe_path"), FieldPanel("member_center_one_click_unsubscribe_path"),
FieldPanel("member_center_tenant_id"), FieldPanel("member_center_tenant_id"),
FieldPanel("member_center_list_id"), FieldPanel("member_center_list_id"),
@ -292,7 +311,11 @@ class NewsletterSystemSettings(BaseGenericSetting):
[ [
FieldPanel("send_engine_base_url"), FieldPanel("send_engine_base_url"),
FieldPanel("send_engine_send_jobs_path"), FieldPanel("send_engine_send_jobs_path"),
FieldPanel("send_engine_oauth_scope"), FieldPanel("send_engine_oauth_client_id"),
FieldPanel(
"send_engine_oauth_client_secret",
widget=django_forms.PasswordInput(render_value=False),
),
FieldPanel("send_engine_timeout_seconds"), FieldPanel("send_engine_timeout_seconds"),
FieldPanel("send_engine_retry_interval_seconds"), FieldPanel("send_engine_retry_interval_seconds"),
FieldPanel("send_engine_retry_max_attempts"), FieldPanel("send_engine_retry_max_attempts"),
@ -323,12 +346,23 @@ class NewsletterSystemSettings(BaseGenericSetting):
verbose_name = _("Newsletter System Settings") verbose_name = _("Newsletter System Settings")
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
previous = None
if self.pk:
previous = type(self).objects.filter(pk=self.pk).only(
"member_center_oauth_client_secret",
"send_engine_oauth_client_secret",
).first()
if self.pk and not self.member_center_oauth_client_secret: if self.pk and not self.member_center_oauth_client_secret:
previous = type(self).objects.filter(pk=self.pk).only("member_center_oauth_client_secret").first()
self.member_center_oauth_client_secret = previous.member_center_oauth_client_secret if previous else "" self.member_center_oauth_client_secret = previous.member_center_oauth_client_secret if previous else ""
elif self.member_center_oauth_client_secret: elif self.member_center_oauth_client_secret:
self.member_center_oauth_client_secret = encrypt_text(self.member_center_oauth_client_secret) self.member_center_oauth_client_secret = encrypt_text(self.member_center_oauth_client_secret)
if self.pk and not self.send_engine_oauth_client_secret:
self.send_engine_oauth_client_secret = previous.send_engine_oauth_client_secret if previous else ""
elif self.send_engine_oauth_client_secret:
self.send_engine_oauth_client_secret = encrypt_text(self.send_engine_oauth_client_secret)
super().save(*args, **kwargs) super().save(*args, **kwargs)

View File

@ -41,25 +41,35 @@ class APIResult:
error: str = "" error: str = ""
def _decrypt_config_secret(value: str) -> str:
encrypted_secret = (value or "").strip()
if not encrypted_secret:
return ""
try:
return decrypt_text(encrypted_secret)
except Exception:
return encrypted_secret
class MemberCenterClient: class MemberCenterClient:
def __init__(self, config: NewsletterSystemSettings): def __init__(self, config: NewsletterSystemSettings):
self.config = config self.config = config
self._oauth_token_cache: dict[str, tuple[str, int]] = {} self._oauth_token_cache: dict[str, tuple[str, int]] = {}
def subscribe(self, payload: dict) -> APIResult: def subscribe(self, payload: dict) -> APIResult:
return self._post(self.config.member_center_subscribe_path, payload) return self._post(self.config.member_center_subscribe_path, payload, use_oauth=True)
def confirm(self, token: str) -> APIResult: def confirm(self, token: str) -> APIResult:
return self._get(self.config.member_center_confirm_path, {"token": token}) return self._get(self.config.member_center_confirm_path, {"token": token})
def request_unsubscribe_token(self, payload: dict) -> APIResult: def request_unsubscribe_token(self, payload: dict) -> APIResult:
return self._post(self.config.member_center_unsubscribe_token_path, payload) return self._post(self.config.member_center_unsubscribe_token_path, payload, use_oauth=True)
def unsubscribe(self, payload: dict) -> APIResult: def unsubscribe(self, payload: dict) -> APIResult:
return self._post(self.config.member_center_unsubscribe_path, payload) return self._post(self.config.member_center_unsubscribe_path, payload)
def one_click_unsubscribe(self, payload: dict) -> APIResult: def one_click_unsubscribe(self, payload: dict) -> APIResult:
return self._post(self.config.member_center_one_click_unsubscribe_path, payload) return self._post(self.config.member_center_one_click_unsubscribe_path, payload, use_oauth=True)
def list_subscriptions(self, list_id: str) -> APIResult: def list_subscriptions(self, list_id: str) -> APIResult:
auth_headers, auth_error = self._auth_headers() auth_headers, auth_error = self._auth_headers()
@ -71,50 +81,74 @@ class MemberCenterClient:
headers=auth_headers, headers=auth_headers,
) )
def _get(self, path: str, query: dict, headers: dict | None = None) -> APIResult: def _get(self, path: str, query: dict, headers: dict | None = None, use_oauth: bool = False) -> APIResult:
base_url = (self.config.member_center_base_url or "").strip() base_url = (self.config.member_center_base_url or "").strip()
if not base_url: if not base_url:
return APIResult(ok=False, status=0, data={}, error="member_center_base_url is empty") return APIResult(ok=False, status=0, data={}, error="member_center_base_url is empty")
merged_headers = dict(headers or {})
if use_oauth:
auth_headers, auth_error = self._optional_auth_headers()
if auth_error:
return APIResult(ok=False, status=0, data={}, error=auth_error)
merged_headers.update(auth_headers)
endpoint = urljoin(f"{base_url.rstrip('/')}/", path.lstrip("/")) endpoint = urljoin(f"{base_url.rstrip('/')}/", path.lstrip("/"))
if query: if query:
endpoint = f"{endpoint}?{urlencode(query)}" endpoint = f"{endpoint}?{urlencode(query)}"
request = Request( request = Request(
endpoint, endpoint,
headers={"Accept": "application/json", **(headers or {})}, headers={"Accept": "application/json", **merged_headers},
method="GET", method="GET",
) )
timeout = max(1, int(self.config.member_center_timeout_seconds or 10)) timeout = max(1, int(self.config.member_center_timeout_seconds or 10))
return self._send(request, timeout=timeout) return self._send(request, timeout=timeout)
def _post(self, path: str, payload: dict, headers: dict | None = None) -> APIResult: def _post(self, path: str, payload: dict, headers: dict | None = None, use_oauth: bool = False) -> APIResult:
base_url = (self.config.member_center_base_url or "").strip() base_url = (self.config.member_center_base_url or "").strip()
if not base_url: if not base_url:
return APIResult(ok=False, status=0, data={}, error="member_center_base_url is empty") return APIResult(ok=False, status=0, data={}, error="member_center_base_url is empty")
merged_headers = dict(headers or {})
if use_oauth:
auth_headers, auth_error = self._optional_auth_headers()
if auth_error:
return APIResult(ok=False, status=0, data={}, error=auth_error)
merged_headers.update(auth_headers)
endpoint = urljoin(f"{base_url.rstrip('/')}/", path.lstrip("/")) endpoint = urljoin(f"{base_url.rstrip('/')}/", path.lstrip("/"))
body = json.dumps(payload).encode("utf-8") body = json.dumps(payload).encode("utf-8")
request = Request( request = Request(
endpoint, endpoint,
data=body, data=body,
headers={"Content-Type": "application/json", **(headers or {})}, headers={"Content-Type": "application/json", **merged_headers},
method="POST", method="POST",
) )
timeout = max(1, int(self.config.member_center_timeout_seconds or 10)) timeout = max(1, int(self.config.member_center_timeout_seconds or 10))
return self._send(request, timeout=timeout) return self._send(request, timeout=timeout)
def _oauth_credentials_configured(self) -> bool:
return bool(
(self.config.member_center_oauth_client_id or "").strip()
and (self.config.member_center_oauth_client_secret or "").strip()
)
def _optional_auth_headers(self) -> tuple[dict, str]:
if not self._oauth_credentials_configured():
return {}, ""
return self._auth_headers()
def _auth_headers(self) -> tuple[dict, str]: def _auth_headers(self) -> tuple[dict, str]:
scope = (self.config.member_center_oauth_scope or "").strip() or "newsletter:list.read" token, error = self._get_oauth_access_token()
token, error = self._get_oauth_access_token(required_scope=scope)
if not token: if not token:
return {}, error or "member center oauth token is empty" return {}, error or "member center oauth token is empty"
return {"Authorization": f"Bearer {token}"}, "" return {"Authorization": f"Bearer {token}"}, ""
def _get_oauth_access_token(self, required_scope: str) -> tuple[str, str]: def _get_oauth_access_token(self) -> tuple[str, str]:
scope = (required_scope or "").strip() cache_key = "member_center"
now = int(time.time()) now = int(time.time())
cached = self._oauth_token_cache.get(scope) cached = self._oauth_token_cache.get(cache_key)
if cached and now < cached[1] - 30: if cached and now < cached[1] - 30:
return cached[0], "" return cached[0], ""
@ -125,27 +159,15 @@ class MemberCenterClient:
token_path = (self.config.member_center_oauth_token_path or "/oauth/token").strip() token_path = (self.config.member_center_oauth_token_path or "/oauth/token").strip()
token_url = urljoin(f"{base_url.rstrip('/')}/", token_path.lstrip("/")) token_url = urljoin(f"{base_url.rstrip('/')}/", token_path.lstrip("/"))
client_id = (self.config.member_center_oauth_client_id or "").strip() client_id = (self.config.member_center_oauth_client_id or "").strip()
encrypted_secret = (self.config.member_center_oauth_client_secret or "").strip() client_secret = _decrypt_config_secret(self.config.member_center_oauth_client_secret)
client_secret = ""
if encrypted_secret:
try:
client_secret = decrypt_text(encrypted_secret)
except Exception:
client_secret = encrypted_secret
if not client_id or not client_secret: if not client_id or not client_secret:
return "", "member center oauth client_id/client_secret is empty" return "", "member center oauth client_id/client_secret is empty"
body = urlencode( body = urlencode(
{ {
k: v
for k, v in {
"grant_type": "client_credentials", "grant_type": "client_credentials",
"client_id": client_id, "client_id": client_id,
"client_secret": client_secret, "client_secret": client_secret,
"scope": scope,
"audience": (self.config.member_center_oauth_audience or "").strip(),
}.items()
if v
} }
).encode("utf-8") ).encode("utf-8")
request = Request( request = Request(
@ -163,7 +185,7 @@ class MemberCenterClient:
if not access_token: if not access_token:
return "", "oauth token response missing access_token" return "", "oauth token response missing access_token"
expires_in = int(result.data.get("expires_in") or 300) expires_in = int(result.data.get("expires_in") or 300)
self._oauth_token_cache[scope] = (access_token, int(time.time()) + max(60, expires_in)) self._oauth_token_cache[cache_key] = (access_token, int(time.time()) + max(60, expires_in))
return access_token, "" return access_token, ""
def _send(self, request: Request, timeout: int) -> APIResult: def _send(self, request: Request, timeout: int) -> APIResult:
@ -473,13 +495,18 @@ class SendEngineClient:
return {"Authorization": f"Bearer {token}"}, "" return {"Authorization": f"Bearer {token}"}, ""
def _get_member_center_access_token(self, required_scope: str = "") -> tuple[str, str]: def _get_member_center_access_token(self, required_scope: str = "") -> tuple[str, str]:
scope = ( client_id = (
(required_scope or "").strip() (getattr(self.config, "send_engine_oauth_client_id", "") or "").strip()
or (self.config.send_engine_oauth_scope or "").strip() or (self.config.member_center_oauth_client_id or "").strip()
or "newsletter:send.write"
) )
encrypted_secret = (
(getattr(self.config, "send_engine_oauth_client_secret", "") or "").strip()
or (self.config.member_center_oauth_client_secret or "").strip()
)
client_secret = _decrypt_config_secret(encrypted_secret)
cache_key = client_id or "send_engine"
now = int(time.time()) now = int(time.time())
cached = self._oauth_token_cache.get(scope) cached = self._oauth_token_cache.get(cache_key)
if cached and now < cached[1] - 30: if cached and now < cached[1] - 30:
return cached[0], "" return cached[0], ""
@ -489,27 +516,13 @@ class SendEngineClient:
token_path = (self.config.member_center_oauth_token_path or "/oauth/token").strip() token_path = (self.config.member_center_oauth_token_path or "/oauth/token").strip()
token_url = urljoin(f"{member_center_base_url.rstrip('/')}/", token_path.lstrip("/")) token_url = urljoin(f"{member_center_base_url.rstrip('/')}/", token_path.lstrip("/"))
client_id = (self.config.member_center_oauth_client_id or "").strip()
encrypted_secret = (self.config.member_center_oauth_client_secret or "").strip()
client_secret = ""
if encrypted_secret:
try:
client_secret = decrypt_text(encrypted_secret)
except Exception:
client_secret = encrypted_secret
if not client_id or not client_secret: if not client_id or not client_secret:
return ("", "member center oauth client_id/client_secret is empty") return ("", "send engine oauth client_id/client_secret is empty")
body = urlencode( body = urlencode(
{ {
k: v
for k, v in {
"grant_type": "client_credentials", "grant_type": "client_credentials",
"client_id": client_id, "client_id": client_id,
"client_secret": client_secret, "client_secret": client_secret,
"scope": scope,
"audience": (self.config.member_center_oauth_audience or "").strip(),
}.items()
if v
} }
).encode("utf-8") ).encode("utf-8")
request = Request( request = Request(
@ -528,7 +541,7 @@ class SendEngineClient:
return "", "oauth token response missing access_token" return "", "oauth token response missing access_token"
expires_in = int(result.data.get("expires_in") or 300) expires_in = int(result.data.get("expires_in") or 300)
self._oauth_token_cache[scope] = (access_token, int(time.time()) + max(60, expires_in)) self._oauth_token_cache[cache_key] = (access_token, int(time.time()) + max(60, expires_in))
return access_token, "" return access_token, ""
def _send(self, request: Request, timeout: int) -> APIResult: def _send(self, request: Request, timeout: int) -> APIResult:

View File

@ -1,11 +1,15 @@
from datetime import date from datetime import date
from types import SimpleNamespace from types import SimpleNamespace
from urllib.parse import parse_qs
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from .newsletter import ( from .newsletter import (
APIResult,
MemberCenterClient,
SendEngineClient,
extract_token, extract_token,
generate_one_click_token, generate_one_click_token,
build_weekly_news_body_html, build_weekly_news_body_html,
@ -18,6 +22,107 @@ from .security import decrypt_text, encrypt_text
class NewsletterTemplateTests(TestCase): class NewsletterTemplateTests(TestCase):
def _member_center_config(self, **overrides):
defaults = {
"member_center_base_url": "https://member.example.com",
"member_center_subscribe_path": "/newsletter/subscribe",
"member_center_confirm_path": "/newsletter/confirm",
"member_center_unsubscribe_token_path": "/newsletter/unsubscribe-token",
"member_center_unsubscribe_path": "/newsletter/unsubscribe",
"member_center_one_click_unsubscribe_path": "/api/subscriptions/unsubscribe",
"member_center_oauth_client_id": "",
"member_center_oauth_client_secret": "",
"member_center_oauth_token_path": "/oauth/token",
"member_center_oauth_scope": "newsletter:list.read",
"member_center_oauth_audience": "",
"send_engine_oauth_client_id": "",
"send_engine_oauth_client_secret": "",
"send_engine_timeout_seconds": 10,
"member_center_timeout_seconds": 10,
}
defaults.update(overrides)
return SimpleNamespace(**defaults)
def test_member_center_write_calls_include_bearer_when_oauth_is_configured(self):
client = MemberCenterClient(
self._member_center_config(
member_center_oauth_client_id="cms-client",
member_center_oauth_client_secret="cms-secret",
)
)
with (
patch.object(client, "_get_oauth_access_token", return_value=("access-token", "")) as token_call,
patch.object(client, "_send", return_value=APIResult(ok=True, status=200, data={})) as send,
):
result = client.subscribe({"email": "demo@example.com"})
self.assertTrue(result.ok)
token_call.assert_called_once_with()
request = send.call_args.args[0]
self.assertEqual(request.get_header("Authorization"), "Bearer access-token")
def test_member_center_token_request_uses_client_credentials_without_scope_or_audience(self):
client = MemberCenterClient(
self._member_center_config(
member_center_oauth_client_id="tenant-client",
member_center_oauth_client_secret="tenant-secret",
member_center_oauth_scope="newsletter:subscriptions.write",
member_center_oauth_audience="member_center_api",
)
)
with patch.object(client, "_send", return_value=APIResult(ok=True, status=200, data={"access_token": "token"})) as send:
token, error = client._get_oauth_access_token()
self.assertEqual(token, "token")
self.assertEqual(error, "")
request = send.call_args.args[0]
body = parse_qs(request.data.decode("utf-8"))
self.assertEqual(body["grant_type"], ["client_credentials"])
self.assertEqual(body["client_id"], ["tenant-client"])
self.assertEqual(body["client_secret"], ["tenant-secret"])
self.assertNotIn("scope", body)
self.assertNotIn("audience", body)
def test_send_engine_token_request_uses_send_engine_oauth_client_without_scope_or_audience(self):
client = SendEngineClient(
self._member_center_config(
member_center_oauth_client_id="tenant-client",
member_center_oauth_client_secret="tenant-secret",
send_engine_oauth_client_id="send-client",
send_engine_oauth_client_secret="send-secret",
member_center_oauth_audience="send_engine_api",
)
)
with patch.object(client, "_send", return_value=APIResult(ok=True, status=200, data={"access_token": "token"})) as send:
token, error = client._get_member_center_access_token(required_scope="newsletter:send.write")
self.assertEqual(token, "token")
self.assertEqual(error, "")
request = send.call_args.args[0]
body = parse_qs(request.data.decode("utf-8"))
self.assertEqual(body["grant_type"], ["client_credentials"])
self.assertEqual(body["client_id"], ["send-client"])
self.assertEqual(body["client_secret"], ["send-secret"])
self.assertNotIn("scope", body)
self.assertNotIn("audience", body)
def test_member_center_write_calls_remain_unauthenticated_without_oauth_config(self):
client = MemberCenterClient(self._member_center_config())
with (
patch.object(client, "_get_oauth_access_token") as token_call,
patch.object(client, "_send", return_value=APIResult(ok=True, status=200, data={})) as send,
):
result = client.unsubscribe({"token": "unsubscribe-token"})
self.assertTrue(result.ok)
token_call.assert_not_called()
request = send.call_args.args[0]
self.assertIsNone(request.get_header("Authorization"))
def test_render_placeholders_replaces_known_keys(self): def test_render_placeholders_replaces_known_keys(self):
template = "confirm={{confirm_url}} email={{email}} token={{token}}" template = "confirm={{confirm_url}} email={{email}} token={{token}}"
rendered = render_placeholders( rendered = render_placeholders(