From 44d300271a8ef741588c3a1587174235a6da0c7a Mon Sep 17 00:00:00 2001 From: Ryan Westfall Date: Tue, 11 Aug 2026 07:52:10 -0500 Subject: [PATCH] Add LinkedIn OAuth connect with portal-saved app credentials. Replace manual token paste with Authorization Code flow; store Client ID/secret in the DB from the social accounts UI and surface a copyable callback URL for LinkedIn Auth setup. --- .env.example | 3 +- .env.prod.example | 6 +- docker-compose.yml | 6 + docs/monica-site-design.md | 3 +- site/monica_site/settings/base.py | 3 +- site/social/admin.py | 8 +- .../migrations/0002_social_app_credentials.py | 48 +++++ site/social/models.py | 23 +++ site/social/oauth_linkedin.py | 188 ++++++++++++++++++ .../social/templates/social/account_list.html | 77 ++++++- site/social/urls.py | 10 + site/social/views.py | 168 ++++++++++++++-- 12 files changed, 517 insertions(+), 26 deletions(-) create mode 100644 site/social/migrations/0002_social_app_credentials.py create mode 100644 site/social/oauth_linkedin.py 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 @@ in
LinkedIn -

Personal or organization page. Tokens expire — reconnect when prompted.

+

Connect via OAuth. Tokens expire (~2 months) — re-authorize when prompted.

@@ -43,6 +43,55 @@
  • Reference: platform docs
  • {% endif %} + + {% if connect_meta.oauth and connect_platform == 'linkedin' %} +
    + +
    + + +
    +

    + Must match exactly (including http/https and trailing slash). +

    +
    + +
    + {% csrf_token %} + +
    + + +
    +
    + + + {% if linkedin_secret_saved %} +

    Secret already saved (encrypted). Enter a new value only to replace it.

    + {% endif %} +
    +
    + + {% if linkedin_oauth_ready %} + Connect with LinkedIn + {% else %} + + {% endif %} + Cancel +
    +
    + {% else %}
    {% csrf_token %} @@ -65,6 +114,7 @@ Cancel
    + {% endif %} {% endif %} @@ -103,7 +153,11 @@
    + {% if account.platform == 'linkedin' %} + Re-authorize + {% else %} Update tokens + {% endif %} {% if account.is_active %}
    {% csrf_token %} @@ -155,4 +209,25 @@ document.getElementById('connect-form')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); {% endif %} +{% if connect_platform == 'linkedin' %} + +{% endif %} {% endblock %} diff --git a/site/social/urls.py b/site/social/urls.py index dae722f..9f61a3f 100644 --- a/site/social/urls.py +++ b/site/social/urls.py @@ -7,6 +7,16 @@ app_name = "social" urlpatterns = [ path("", views.post_list, name="post_list"), path("accounts/", views.account_list, name="account_list"), + path( + "accounts/linkedin/start/", + views.linkedin_oauth_start, + name="linkedin_oauth_start", + ), + path( + "accounts/linkedin/callback/", + views.linkedin_oauth_callback, + name="linkedin_oauth_callback", + ), path("compose/", views.composer, name="composer"), path("api/generate/", views.api_generate, name="api_generate"), path("/", views.post_detail, name="post_detail"), diff --git a/site/social/views.py b/site/social/views.py index 2760b0c..b665108 100644 --- a/site/social/views.py +++ b/site/social/views.py @@ -1,15 +1,34 @@ import json +import secrets from django.contrib import messages from django.contrib.auth.decorators import login_required from django.shortcuts import get_object_or_404, redirect, render from django.http import JsonResponse +from django.urls import reverse from django.utils import timezone -from django.views.decorators.http import require_http_methods, require_POST +from django.utils.safestring import mark_safe +from django.views.decorators.http import require_GET, require_http_methods, require_POST from messaging.services import parse_scheduled_for from social.crypto import encrypt_tokens -from social.models import Platform, SocialAccount, SocialPost, SocialPostTarget +from social.models import ( + Platform, + SocialAccount, + SocialAppCredentials, + SocialPost, + SocialPostTarget, +) +from social.oauth_linkedin import ( + LinkedInOAuthError, + authorization_url, + credentials_configured, + exchange_code, + fetch_member_profile, + get_app_credentials, + redirect_uri as linkedin_redirect_uri, + token_blob_from_oauth, +) from social.ollama import OllamaError, generate_social_post from social.tasks import publish_social_post @@ -17,6 +36,11 @@ CONNECT_INSTRUCTIONS = { Platform.FACEBOOK: { "title": "Connect Facebook Page", "steps": [ + mark_safe( + 'Go to Meta developer registration ' + "(opens in a new tab) and fill out the information." + ), "Open Meta for Developers → your app → Tools → Graph API Explorer.", "Select your app, then Get Page Access Token for the Page you manage.", "Grant pages_manage_posts and pages_read_engagement (and pages_show_list).", @@ -49,21 +73,24 @@ CONNECT_INSTRUCTIONS = { }, Platform.LINKEDIN: { "title": "Connect LinkedIn", + "oauth": True, "steps": [ - "Create a LinkedIn Developer app and add the Share on LinkedIn / Marketing products.", - "Generate a member or organization access token with w_member_social " - "(or w_organization_social for company pages).", - "Find your author URN: person URN looks like urn:li:person:XXXX; " - "organization URN like urn:li:organization:XXXX.", - "Paste the access token and author URN below. LinkedIn tokens expire — " - "reconnect when publishing fails with auth errors.", - ], - "docs_url": "https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/ugc-post-api", - "fields": [ - ("label", "Display name", "Monica Dhillon"), - ("external_id", "Author URN", "urn:li:person:…"), - ("access_token", "Access token", ""), + mark_safe( + 'Create a LinkedIn app in the ' + 'LinkedIn Developer Portal.' + ), + "Products tab — request “Sign In with LinkedIn using OpenID Connect” " + "and “Share on LinkedIn”.", + "Auth tab — under Authorized redirect URLs, paste the callback URL " + "shown in the box below (exact match required).", + "Auth tab — copy Client ID and Primary Client Secret into the fields below, " + "then Save app credentials.", + "Click Connect with LinkedIn to authorize. Access tokens last ~2 months; " + "use Re-authorize when publishing fails with auth errors.", ], + "docs_url": "https://learn.microsoft.com/en-us/linkedin/shared/authentication/authorization-code-flow", + "fields": [], }, } @@ -105,6 +132,30 @@ def account_list(request): account.save(update_fields=["is_active", "updated_at"]) messages.success(request, f"Reactivated {account.label}.") return redirect("social:account_list") + if action == "save_linkedin_app": + client_id = (request.POST.get("client_id") or "").strip() + client_secret = (request.POST.get("client_secret") or "").strip() + if not client_id: + messages.error(request, "Client ID is required.") + return redirect(f"{request.path}?connect=linkedin") + app, _created = SocialAppCredentials.objects.get_or_create( + platform=Platform.LINKEDIN + ) + app.client_id = client_id + if client_secret: + app.encrypted_client_secret = encrypt_tokens(client_secret) + elif not app.encrypted_client_secret: + messages.error( + request, + "Client Secret is required the first time you save LinkedIn app credentials.", + ) + return redirect(f"{request.path}?connect=linkedin") + app.save() + messages.success( + request, + "LinkedIn app credentials saved. You can Connect with LinkedIn now.", + ) + return redirect(f"{request.path}?connect=linkedin") platform = (request.POST.get("platform") or "").strip() if platform not in Platform.values: @@ -141,6 +192,14 @@ def account_list(request): messages.success(request, f"{verb} {account.get_platform_display()} · {account.label}.") return redirect("social:account_list") + linkedin_app = get_app_credentials() + linkedin_oauth_ready = credentials_configured() + linkedin_callback = "" + try: + linkedin_callback = linkedin_redirect_uri() + except LinkedInOAuthError: + linkedin_callback = "(set PUBLIC_SITE_URL, then restart)" + return render( request, "social/account_list.html", @@ -149,10 +208,89 @@ def account_list(request): "connect_platform": connect_platform, "connect_meta": CONNECT_INSTRUCTIONS.get(connect_platform), "platforms": Platform, + "linkedin_oauth_ready": linkedin_oauth_ready, + "linkedin_callback_url": linkedin_callback, + "linkedin_client_id": (linkedin_app.client_id if linkedin_app else ""), + "linkedin_secret_saved": bool( + linkedin_app and linkedin_app.encrypted_client_secret + ), }, ) +@login_required +@require_GET +def linkedin_oauth_start(request): + """Redirect browser to LinkedIn consent screen.""" + if not credentials_configured(): + messages.error( + request, + "Save LinkedIn Client ID and Client Secret on the Connect LinkedIn form first.", + ) + return redirect(f"{reverse('social:account_list')}?connect=linkedin") + state = secrets.token_urlsafe(24) + request.session["linkedin_oauth_state"] = state + try: + return redirect(authorization_url(state=state)) + except LinkedInOAuthError as exc: + messages.error(request, str(exc)) + return redirect(f"{reverse('social:account_list')}?connect=linkedin") + + +@login_required +@require_GET +def linkedin_oauth_callback(request): + """Handle LinkedIn redirect: exchange code, store encrypted SocialAccount.""" + error = (request.GET.get("error") or "").strip() + if error: + desc = (request.GET.get("error_description") or error).strip() + messages.error(request, f"LinkedIn authorization denied: {desc}") + return redirect("social:account_list") + + state = (request.GET.get("state") or "").strip() + expected = request.session.pop("linkedin_oauth_state", None) + if not state or not expected or state != expected: + messages.error(request, "LinkedIn OAuth state mismatch — try Connect again.") + return redirect("social:account_list") + + code = (request.GET.get("code") or "").strip() + if not code: + messages.error(request, "LinkedIn did not return an authorization code.") + return redirect("social:account_list") + + try: + token_payload = exchange_code(code) + profile = fetch_member_profile(token_payload["access_token"]) + author_urn = profile["author_urn"] + blob = token_blob_from_oauth(token_payload, author_urn=author_urn) + account, created = SocialAccount.objects.update_or_create( + platform=Platform.LINKEDIN, + external_id=author_urn, + defaults={ + "label": profile["label"], + "encrypted_tokens": encrypt_tokens(json.dumps(blob)), + "is_active": True, + "owner": request.user, + }, + ) + except LinkedInOAuthError as exc: + messages.error(request, str(exc)) + return redirect("social:account_list") + except Exception: + messages.error( + request, + "Unexpected error saving LinkedIn account. Check app logs.", + ) + raise + + verb = "Connected" if created else "Re-authorized" + messages.success( + request, + f"{verb} LinkedIn · {account.label}. You can post from the composer.", + ) + return redirect("social:account_list") + + @login_required @require_http_methods(["GET", "POST"]) def composer(request):