diff --git a/.env.example b/.env.example index ff6ce24..6af7622 100644 --- a/.env.example +++ b/.env.example @@ -68,10 +68,9 @@ PCM_RETURN_ADDRESS= # PCM_RETURN_ZIP= # Social +# LinkedIn Client ID / Secret are entered in Portal → Social accounts (not env). META_APP_ID= META_APP_SECRET= -LINKEDIN_CLIENT_ID= -LINKEDIN_CLIENT_SECRET= # Generate: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" SOCIAL_TOKEN_ENCRYPTION_KEY= diff --git a/.env.prod.example b/.env.prod.example index 6899694..278f749 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -72,11 +72,11 @@ PCM_RETURN_ADDRESS='{"firstName":"Monica","lastName":"Dhillon","address":"replac # CLICK2MAIL_API_KEY= # POSTGRID_API_KEY= -# Social (native) +# Social (native OAuth) +# LinkedIn Client ID/Secret: Portal → Social accounts (not env). +# LinkedIn Auth redirect (prod): https://YOUR_DOMAIN/portal/social/accounts/linkedin/callback/ META_APP_ID= META_APP_SECRET= -LINKEDIN_CLIENT_ID= -LINKEDIN_CLIENT_SECRET= SOCIAL_TOKEN_ENCRYPTION_KEY=replace-with-fernet-key # Ollama for social drafting (reachable from app hosts) diff --git a/docker-compose.yml b/docker-compose.yml index 23a35bd..3565892 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -58,6 +58,9 @@ services: PCM_RETURN_CITY: ${PCM_RETURN_CITY:-} PCM_RETURN_STATE: ${PCM_RETURN_STATE:-} PCM_RETURN_ZIP: ${PCM_RETURN_ZIP:-} + META_APP_ID: ${META_APP_ID:-} + META_APP_SECRET: ${META_APP_SECRET:-} + SOCIAL_TOKEN_ENCRYPTION_KEY: ${SOCIAL_TOKEN_ENCRYPTION_KEY:-} depends_on: db: condition: service_healthy @@ -95,6 +98,9 @@ services: PCM_RETURN_CITY: ${PCM_RETURN_CITY:-} PCM_RETURN_STATE: ${PCM_RETURN_STATE:-} PCM_RETURN_ZIP: ${PCM_RETURN_ZIP:-} + META_APP_ID: ${META_APP_ID:-} + META_APP_SECRET: ${META_APP_SECRET:-} + SOCIAL_TOKEN_ENCRYPTION_KEY: ${SOCIAL_TOKEN_ENCRYPTION_KEY:-} depends_on: db: condition: service_healthy diff --git a/docs/monica-site-design.md b/docs/monica-site-design.md index 7ffd097..1a47ae2 100644 --- a/docs/monica-site-design.md +++ b/docs/monica-site-design.md @@ -478,8 +478,7 @@ PCM_RETURN_ADDRESS={...} # Social (native) META_APP_ID=... META_APP_SECRET=... -LINKEDIN_CLIENT_ID=... -LINKEDIN_CLIENT_SECRET=... +# LinkedIn Client ID/Secret: Portal → Social accounts (DB), not env SOCIAL_TOKEN_ENCRYPTION_KEY=... # Analytics (reuse Tianji) diff --git a/site/monica_site/settings/base.py b/site/monica_site/settings/base.py index 7a3095f..80fa676 100644 --- a/site/monica_site/settings/base.py +++ b/site/monica_site/settings/base.py @@ -302,8 +302,7 @@ POSTGRID_API_KEY = env("POSTGRID_API_KEY", "") # --- Social (native APIs) --- META_APP_ID = env("META_APP_ID", "") META_APP_SECRET = env("META_APP_SECRET", "") -LINKEDIN_CLIENT_ID = env("LINKEDIN_CLIENT_ID", "") -LINKEDIN_CLIENT_SECRET = env("LINKEDIN_CLIENT_SECRET", "") +# LinkedIn Client ID/Secret live in SocialAppCredentials (portal UI), not env. SOCIAL_TOKEN_ENCRYPTION_KEY = env("SOCIAL_TOKEN_ENCRYPTION_KEY", "") # --- Ollama (social post drafting) --- diff --git a/site/social/admin.py b/site/social/admin.py index 52ad8e1..341a0e4 100644 --- a/site/social/admin.py +++ b/site/social/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from social.models import SocialAccount, SocialPost, SocialPostTarget +from social.models import SocialAccount, SocialAppCredentials, SocialPost, SocialPostTarget class SocialPostTargetInline(admin.TabularInline): @@ -8,6 +8,12 @@ class SocialPostTargetInline(admin.TabularInline): extra = 0 +@admin.register(SocialAppCredentials) +class SocialAppCredentialsAdmin(admin.ModelAdmin): + list_display = ("platform", "client_id", "updated_at") + readonly_fields = ("encrypted_client_secret", "created_at", "updated_at") + + @admin.register(SocialAccount) class SocialAccountAdmin(admin.ModelAdmin): list_display = ("label", "platform", "is_active", "external_id") diff --git a/site/social/migrations/0002_social_app_credentials.py b/site/social/migrations/0002_social_app_credentials.py new file mode 100644 index 0000000..a5afa87 --- /dev/null +++ b/site/social/migrations/0002_social_app_credentials.py @@ -0,0 +1,48 @@ +# Generated manually for SocialAppCredentials + +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("social", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="SocialAppCredentials", + fields=[ + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "platform", + models.CharField( + choices=[ + ("facebook", "Facebook"), + ("instagram", "Instagram"), + ("linkedin", "LinkedIn"), + ], + max_length=16, + unique=True, + ), + ), + ("client_id", models.CharField(blank=True, max_length=255)), + ("encrypted_client_secret", models.TextField(blank=True)), + ], + options={ + "verbose_name_plural": "social app credentials", + "ordering": ["platform"], + }, + ), + ] diff --git a/site/social/models.py b/site/social/models.py index ddd1e4c..2b62649 100644 --- a/site/social/models.py +++ b/site/social/models.py @@ -10,6 +10,29 @@ class Platform(models.TextChoices): LINKEDIN = "linkedin", "LinkedIn" +class SocialAppCredentials(UUIDPrimaryKeyModel, TimeStampedModel): + """ + Developer-app OAuth credentials entered in the portal (not env). + + client_secret is Fernet-encrypted. One row per platform. + """ + + platform = models.CharField(max_length=16, choices=Platform.choices, unique=True) + client_id = models.CharField(max_length=255, blank=True) + encrypted_client_secret = models.TextField(blank=True) + + class Meta: + verbose_name_plural = "social app credentials" + ordering = ["platform"] + + def __str__(self) -> str: + return f"{self.platform} app credentials" + + @property + def is_configured(self) -> bool: + return bool(self.client_id and self.encrypted_client_secret) + + class SocialAccount(UUIDPrimaryKeyModel, TimeStampedModel): platform = models.CharField(max_length=16, choices=Platform.choices) label = models.CharField(max_length=120) diff --git a/site/social/oauth_linkedin.py b/site/social/oauth_linkedin.py new file mode 100644 index 0000000..ad3aa53 --- /dev/null +++ b/site/social/oauth_linkedin.py @@ -0,0 +1,188 @@ +"""LinkedIn 3-legged OAuth helpers (Authorization Code flow).""" + +from __future__ import annotations + +import logging +from datetime import timedelta +from urllib.parse import urlencode + +import requests +from django.conf import settings +from django.urls import reverse +from django.utils import timezone + +from social.crypto import decrypt_tokens +from social.models import Platform, SocialAppCredentials + +logger = logging.getLogger(__name__) + +AUTHORIZE_URL = "https://www.linkedin.com/oauth/v2/authorization" +TOKEN_URL = "https://www.linkedin.com/oauth/v2/accessToken" +USERINFO_URL = "https://api.linkedin.com/v2/userinfo" +ME_URL = "https://api.linkedin.com/v2/me" + +# openid/profile/email: Sign In with LinkedIn (OpenID Connect) +# w_member_social: Share on LinkedIn product (required to post as the member) +DEFAULT_SCOPES = ("openid", "profile", "email", "w_member_social") + + +class LinkedInOAuthError(RuntimeError): + """Raised when LinkedIn OAuth or profile lookup fails.""" + + +def get_app_credentials() -> SocialAppCredentials | None: + return SocialAppCredentials.objects.filter(platform=Platform.LINKEDIN).first() + + +def load_client_credentials() -> tuple[str, str]: + """Return (client_id, client_secret) from portal-saved credentials.""" + app = get_app_credentials() + if not app or not app.client_id or not app.encrypted_client_secret: + raise LinkedInOAuthError( + "LinkedIn app credentials are not saved yet. " + "Enter Client ID and Client Secret on the Connect LinkedIn form." + ) + try: + secret = decrypt_tokens(app.encrypted_client_secret) + except ValueError as exc: + raise LinkedInOAuthError( + "Could not decrypt LinkedIn client secret. " + "Re-enter the Client Secret on the Connect LinkedIn form." + ) from exc + if not secret: + raise LinkedInOAuthError("LinkedIn client secret is empty — save it again.") + return app.client_id.strip(), secret.strip() + + +def credentials_configured() -> bool: + app = get_app_credentials() + return bool(app and app.is_configured) + + +def redirect_uri() -> str: + """Absolute callback URL — must match Authorized redirect URLs in LinkedIn Auth tab.""" + base = (settings.PUBLIC_SITE_URL or "").rstrip("/") + if not base: + raise LinkedInOAuthError( + "PUBLIC_SITE_URL is not set; cannot build LinkedIn OAuth redirect URI." + ) + return f"{base}{reverse('social:linkedin_oauth_callback')}" + + +def authorization_url(*, state: str, scopes: tuple[str, ...] = DEFAULT_SCOPES) -> str: + client_id, _secret = load_client_credentials() + params = { + "response_type": "code", + "client_id": client_id, + "redirect_uri": redirect_uri(), + "state": state, + "scope": " ".join(scopes), + } + return f"{AUTHORIZE_URL}?{urlencode(params)}" + + +def exchange_code(code: str) -> dict: + """Exchange authorization code for access token payload.""" + client_id, client_secret = load_client_credentials() + response = requests.post( + TOKEN_URL, + data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri(), + "client_id": client_id, + "client_secret": client_secret, + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=30, + ) + if response.status_code >= 400: + logger.warning( + "LinkedIn token exchange failed: %s %s", + response.status_code, + response.text[:500], + ) + raise LinkedInOAuthError( + f"LinkedIn token exchange failed ({response.status_code}). " + "Check client ID/secret, redirect URI, and that Share on LinkedIn is enabled." + ) + payload = response.json() + if not payload.get("access_token"): + raise LinkedInOAuthError("LinkedIn token response missing access_token.") + return payload + + +def fetch_member_profile(access_token: str) -> dict: + """ + Resolve member display name + person URN. + + Prefers OpenID userinfo (`sub`); falls back to legacy /v2/me. + """ + headers = {"Authorization": f"Bearer {access_token}"} + userinfo = requests.get(USERINFO_URL, headers=headers, timeout=30) + if userinfo.status_code == 200: + data = userinfo.json() + sub = (data.get("sub") or "").strip() + if sub: + name = ( + (data.get("name") or "").strip() + or " ".join( + p + for p in ( + (data.get("given_name") or "").strip(), + (data.get("family_name") or "").strip(), + ) + if p + ) + or "LinkedIn member" + ) + return { + "author_urn": f"urn:li:person:{sub}", + "label": name, + "email": (data.get("email") or "").strip(), + } + + me = requests.get( + ME_URL, + headers={ + **headers, + "X-Restli-Protocol-Version": "2.0.0", + }, + timeout=30, + ) + if me.status_code >= 400: + logger.warning( + "LinkedIn profile lookup failed: userinfo=%s me=%s %s", + userinfo.status_code, + me.status_code, + me.text[:500], + ) + raise LinkedInOAuthError( + "Could not load LinkedIn profile. Ensure Sign In with LinkedIn " + "(openid/profile) is approved on the app." + ) + data = me.json() + person_id = (data.get("id") or "").strip() + if not person_id: + raise LinkedInOAuthError("LinkedIn /v2/me response missing id.") + localized = data.get("localizedFirstName") or "" + last = data.get("localizedLastName") or "" + label = f"{localized} {last}".strip() or "LinkedIn member" + return {"author_urn": f"urn:li:person:{person_id}", "label": label, "email": ""} + + +def token_blob_from_oauth(token_payload: dict, *, author_urn: str) -> dict: + expires_in = int(token_payload.get("expires_in") or 0) + blob: dict = { + "access_token": token_payload["access_token"], + "author_urn": author_urn, + "token_type": token_payload.get("token_type") or "Bearer", + "scope": token_payload.get("scope") or "", + } + if token_payload.get("refresh_token"): + blob["refresh_token"] = token_payload["refresh_token"] + if expires_in: + blob["expires_at"] = ( + timezone.now() + timedelta(seconds=expires_in) + ).isoformat() + return blob diff --git a/site/social/templates/social/account_list.html b/site/social/templates/social/account_list.html index 6a9ebbe..a71e058 100644 --- a/site/social/templates/social/account_list.html +++ b/site/social/templates/social/account_list.html @@ -27,7 +27,7 @@
Personal or organization page. Tokens expire — reconnect when prompted.
+Connect via OAuth. Tokens expire (~2 months) — re-authorize when prompted.
+ Must match exactly (including http/https and trailing slash). +
+