Add LinkedIn OAuth connect with portal-saved app credentials.
Deploy Beta / unit-tests (push) Successful in 12s
Deploy Beta / docker (push) Successful in 17s
Deploy Beta / deploy-beta (push) Successful in 1m41s

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.
This commit is contained in:
2026-08-11 07:52:10 -05:00
parent 2643ba7a96
commit 44d300271a
12 changed files with 517 additions and 26 deletions
+1 -2
View File
@@ -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) ---
+7 -1
View File
@@ -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")
@@ -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"],
},
),
]
+23
View File
@@ -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)
+188
View File
@@ -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
+76 -1
View File
@@ -27,7 +27,7 @@
<span class="platform-icon li">in</span>
<div>
<strong>LinkedIn</strong>
<p>Personal or organization page. Tokens expire — reconnect when prompted.</p>
<p>Connect via OAuth. Tokens expire (~2 months) — re-authorize when prompted.</p>
</div>
</a>
</div>
@@ -43,6 +43,55 @@
<li>Reference: <a href="{{ connect_meta.docs_url }}" target="_blank" rel="noopener">platform docs</a></li>
{% endif %}
</ol>
{% if connect_meta.oauth and connect_platform == 'linkedin' %}
<div class="field" style="margin:12px 0 16px;padding:12px;border:1px dashed #cbd5e1;border-radius:8px;background:#f8fafc">
<label for="linkedin-callback-url" style="display:block;margin-bottom:6px">
Authorized redirect URL — paste into LinkedIn Auth tab
</label>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:stretch">
<input id="linkedin-callback-url" type="text" readonly
value="{{ linkedin_callback_url }}"
onclick="this.select()"
style="flex:1;min-width:220px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px">
<button type="button" class="btn btn-ghost btn-sm" id="copy-linkedin-callback">Copy</button>
</div>
<p class="muted" style="margin:8px 0 0;font-size:13px">
Must match exactly (including http/https and trailing slash).
</p>
</div>
<form method="post" class="form-grid" style="margin-bottom:16px">
{% csrf_token %}
<input type="hidden" name="action" value="save_linkedin_app">
<div class="field">
<label for="id_client_id">Client ID</label>
<input id="id_client_id" name="client_id" required
value="{{ linkedin_client_id }}"
placeholder="From LinkedIn Auth tab"
autocomplete="off">
</div>
<div class="field">
<label for="id_client_secret">Primary Client Secret</label>
<input id="id_client_secret" name="client_secret" type="password"
{% if not linkedin_secret_saved %}required{% endif %}
placeholder="{% if linkedin_secret_saved %}Leave blank to keep saved secret{% else %}From LinkedIn Auth tab{% endif %}"
autocomplete="new-password">
{% if linkedin_secret_saved %}
<p class="muted" style="margin:6px 0 0;font-size:13px">Secret already saved (encrypted). Enter a new value only to replace it.</p>
{% endif %}
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn btn-ghost" type="submit">Save app credentials</button>
{% if linkedin_oauth_ready %}
<a class="btn btn-primary" href="{% url 'social:linkedin_oauth_start' %}">Connect with LinkedIn</a>
{% else %}
<button class="btn btn-primary" type="button" disabled title="Save Client ID and Client Secret first">Connect with LinkedIn</button>
{% endif %}
<a class="btn btn-ghost" href="{% url 'social:account_list' %}">Cancel</a>
</div>
</form>
{% else %}
<form method="post" class="form-grid">
{% csrf_token %}
<input type="hidden" name="action" value="connect">
@@ -65,6 +114,7 @@
<a class="btn btn-ghost" href="{% url 'social:account_list' %}">Cancel</a>
</div>
</form>
{% endif %}
</div>
{% endif %}
</div>
@@ -103,7 +153,11 @@
</td>
<td>
<div style="display:flex;gap:8px;flex-wrap:wrap">
{% if account.platform == 'linkedin' %}
<a class="btn btn-ghost btn-sm" href="{% url 'social:linkedin_oauth_start' %}">Re-authorize</a>
{% else %}
<a class="btn btn-ghost btn-sm" href="{% url 'social:account_list' %}?connect={{ account.platform }}">Update tokens</a>
{% endif %}
{% if account.is_active %}
<form method="post" style="display:inline">
{% csrf_token %}
@@ -155,4 +209,25 @@
document.getElementById('connect-form')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
</script>
{% endif %}
{% if connect_platform == 'linkedin' %}
<script>
(function () {
const btn = document.getElementById('copy-linkedin-callback');
const input = document.getElementById('linkedin-callback-url');
if (!btn || !input) return;
btn.addEventListener('click', async () => {
const value = input.value || '';
try {
await navigator.clipboard.writeText(value);
const prev = btn.textContent;
btn.textContent = 'Copied';
setTimeout(() => { btn.textContent = prev; }, 1500);
} catch (_err) {
input.select();
document.execCommand('copy');
}
});
})();
</script>
{% endif %}
{% endblock %}
+10
View File
@@ -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("<uuid:pk>/", views.post_detail, name="post_detail"),
+153 -15
View File
@@ -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 <a href="https://developers.facebook.com/async/registration" '
'target="_blank" rel="noopener">Meta developer registration</a> '
"(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 '
'<a href="https://www.linkedin.com/developers/apps" target="_blank" '
'rel="noopener">LinkedIn Developer Portal</a>.'
),
"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):