diff --git a/innovedus_cms/base/migrations/0013_send_engine_oauth_client_credentials.py b/innovedus_cms/base/migrations/0013_send_engine_oauth_client_credentials.py new file mode 100644 index 0000000..e37384d --- /dev/null +++ b/innovedus_cms/base/migrations/0013_send_engine_oauth_client_credentials.py @@ -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", + ), + ), + ] diff --git a/innovedus_cms/base/models.py b/innovedus_cms/base/models.py index 653f40f..ebb1398 100644 --- a/innovedus_cms/base/models.py +++ b/innovedus_cms/base/models.py @@ -208,10 +208,20 @@ class NewsletterSystemSettings(BaseGenericSetting): max_length=255, blank=True, default="/oauth/token", + help_text=_("Shared Member Center OAuth token endpoint path for tenant_api and send_api clients."), 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_secret = models.TextField(blank=True, verbose_name=_("OAuth Client Secret")) + member_center_oauth_client_id = models.CharField( + 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( max_length=255, blank=True, @@ -236,6 +246,17 @@ class NewsletterSystemSettings(BaseGenericSetting): default="/api/send-jobs", 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_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")) @@ -279,8 +300,6 @@ class NewsletterSystemSettings(BaseGenericSetting): "member_center_oauth_client_secret", 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_tenant_id"), FieldPanel("member_center_list_id"), @@ -292,7 +311,11 @@ class NewsletterSystemSettings(BaseGenericSetting): [ FieldPanel("send_engine_base_url"), 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_retry_interval_seconds"), FieldPanel("send_engine_retry_max_attempts"), @@ -323,12 +346,23 @@ class NewsletterSystemSettings(BaseGenericSetting): verbose_name = _("Newsletter System Settings") 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: - 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 "" elif 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) diff --git a/innovedus_cms/base/newsletter.py b/innovedus_cms/base/newsletter.py index d5786f1..9e59308 100644 --- a/innovedus_cms/base/newsletter.py +++ b/innovedus_cms/base/newsletter.py @@ -41,25 +41,35 @@ class APIResult: 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: def __init__(self, config: NewsletterSystemSettings): self.config = config self._oauth_token_cache: dict[str, tuple[str, int]] = {} 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: return self._get(self.config.member_center_confirm_path, {"token": token}) 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: return self._post(self.config.member_center_unsubscribe_path, payload) 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: auth_headers, auth_error = self._auth_headers() @@ -71,50 +81,74 @@ class MemberCenterClient: 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() if not base_url: 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("/")) if query: endpoint = f"{endpoint}?{urlencode(query)}" request = Request( endpoint, - headers={"Accept": "application/json", **(headers or {})}, + headers={"Accept": "application/json", **merged_headers}, method="GET", ) timeout = max(1, int(self.config.member_center_timeout_seconds or 10)) 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() if not base_url: 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("/")) body = json.dumps(payload).encode("utf-8") request = Request( endpoint, data=body, - headers={"Content-Type": "application/json", **(headers or {})}, + headers={"Content-Type": "application/json", **merged_headers}, method="POST", ) timeout = max(1, int(self.config.member_center_timeout_seconds or 10)) 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]: - scope = (self.config.member_center_oauth_scope or "").strip() or "newsletter:list.read" - token, error = self._get_oauth_access_token(required_scope=scope) + token, error = self._get_oauth_access_token() if not token: return {}, error or "member center oauth token is empty" return {"Authorization": f"Bearer {token}"}, "" - def _get_oauth_access_token(self, required_scope: str) -> tuple[str, str]: - scope = (required_scope or "").strip() + def _get_oauth_access_token(self) -> tuple[str, str]: + cache_key = "member_center" 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: return cached[0], "" @@ -125,27 +159,15 @@ class MemberCenterClient: token_path = (self.config.member_center_oauth_token_path or "/oauth/token").strip() token_url = urljoin(f"{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 + client_secret = _decrypt_config_secret(self.config.member_center_oauth_client_secret) if not client_id or not client_secret: return "", "member center oauth client_id/client_secret is empty" body = urlencode( { - k: v - for k, v in { - "grant_type": "client_credentials", - "client_id": client_id, - "client_secret": client_secret, - "scope": scope, - "audience": (self.config.member_center_oauth_audience or "").strip(), - }.items() - if v + "grant_type": "client_credentials", + "client_id": client_id, + "client_secret": client_secret, } ).encode("utf-8") request = Request( @@ -163,7 +185,7 @@ class MemberCenterClient: if not access_token: return "", "oauth token response missing access_token" 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, "" def _send(self, request: Request, timeout: int) -> APIResult: @@ -473,13 +495,18 @@ class SendEngineClient: return {"Authorization": f"Bearer {token}"}, "" def _get_member_center_access_token(self, required_scope: str = "") -> tuple[str, str]: - scope = ( - (required_scope or "").strip() - or (self.config.send_engine_oauth_scope or "").strip() - or "newsletter:send.write" + client_id = ( + (getattr(self.config, "send_engine_oauth_client_id", "") or "").strip() + or (self.config.member_center_oauth_client_id or "").strip() ) + 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()) - cached = self._oauth_token_cache.get(scope) + cached = self._oauth_token_cache.get(cache_key) if cached and now < cached[1] - 30: return cached[0], "" @@ -489,27 +516,13 @@ class SendEngineClient: 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("/")) - 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: - return ("", "member center oauth client_id/client_secret is empty") + return ("", "send engine oauth client_id/client_secret is empty") body = urlencode( { - k: v - for k, v in { - "grant_type": "client_credentials", - "client_id": client_id, - "client_secret": client_secret, - "scope": scope, - "audience": (self.config.member_center_oauth_audience or "").strip(), - }.items() - if v + "grant_type": "client_credentials", + "client_id": client_id, + "client_secret": client_secret, } ).encode("utf-8") request = Request( @@ -528,7 +541,7 @@ class SendEngineClient: return "", "oauth token response missing access_token" 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, "" def _send(self, request: Request, timeout: int) -> APIResult: diff --git a/innovedus_cms/base/tests.py b/innovedus_cms/base/tests.py index 668edb2..f03d731 100644 --- a/innovedus_cms/base/tests.py +++ b/innovedus_cms/base/tests.py @@ -1,11 +1,15 @@ from datetime import date from types import SimpleNamespace +from urllib.parse import parse_qs from unittest.mock import Mock, patch from django.test import TestCase from django.urls import reverse from .newsletter import ( + APIResult, + MemberCenterClient, + SendEngineClient, extract_token, generate_one_click_token, build_weekly_news_body_html, @@ -18,6 +22,107 @@ from .security import decrypt_text, encrypt_text 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): template = "confirm={{confirm_url}} email={{email}} token={{token}}" rendered = render_placeholders(