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
+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):