diff --git a/server/linkedinAuth.js b/server/linkedinAuth.js new file mode 100644 index 0000000..da64fc7 --- /dev/null +++ b/server/linkedinAuth.js @@ -0,0 +1,510 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + * + * Simple local server to authenticate with LinkedIn and obtain a Company Page Access Token. + * Run with: node linkedinAuth.js + * Make sure to set LINKEDIN_CLIENT_ID and LINKEDIN_CLIENT_SECRET environment variables. + */ + +import http from "http"; +import { URL } from "url"; + +const PORT = 3001; // Different port from Facebook auth server +const REDIRECT_URI = `http://localhost:${PORT}/callback`; + +/** + * Creates the LinkedIn OAuth authorization URL. + * @param {string} clientId - The LinkedIn Client ID. + * @returns {string} The authorization URL. + */ +const getAuthUrl = (clientId) => { + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: REDIRECT_URI, + // LinkedIn requires OpenID Connect scopes as base, plus organization permission + scope: "openid profile email w_organization_social", + response_type: "code", + state: "linkedin-auth-state", // CSRF protection + }); + return `https://www.linkedin.com/oauth/v2/authorization?${params.toString()}`; +}; + +/** + * Exchanges an authorization code for an access token. + * @param {string} code - The authorization code from LinkedIn. + * @param {string} clientId - The LinkedIn Client ID. + * @param {string} clientSecret - The LinkedIn Client Secret. + * @returns {Promise<{access_token: string, expires_in?: number}>} The access token response. + */ +const exchangeCodeForToken = async (code, clientId, clientSecret) => { + const params = new URLSearchParams({ + grant_type: "authorization_code", + code: code, + redirect_uri: REDIRECT_URI, + client_id: clientId, + client_secret: clientSecret, + }); + + const response = await fetch("https://www.linkedin.com/oauth/v2/accessToken", { + body: params.toString(), + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + method: "POST", + }); + return await response.json(); +}; + +/** + * Gets the authenticated user's profile information. + * @param {string} accessToken - The access token. + * @returns {Promise} The user profile. + */ +const getUserProfile = async (accessToken) => { + const response = await fetch( + "https://api.linkedin.com/v2/userinfo", + { + headers: { + "Authorization": `Bearer ${accessToken}`, + }, + }, + ); + return await response.json(); +}; + +/** + * Gets the organizations/companies the user manages. + * @param {string} accessToken - The access token. + * @returns {Promise} Array of organizations. + */ +const getUserOrganizations = async (accessToken) => { + // First, get the user's profile to get their ID + const profile = await getUserProfile(accessToken); + + if (!profile.sub) { + return []; + } + + // Get organizations using the Organization API + // Note: This requires the organization to be associated with your app + const response = await fetch( + `https://api.linkedin.com/v2/organizationalEntityAcls?q=roleAssignee&role=ADMINISTRATOR&state=APPROVED`, + { + headers: { + "Authorization": `Bearer ${accessToken}`, + }, + }, + ); + + const data = await response.json(); + + if (data.elements && data.elements.length > 0) { + // Get organization details for each + const orgDetails = []; + for (const element of data.elements) { + const orgId = element.organizationalTarget?.split(":")[1]; + if (orgId) { + try { + const orgResponse = await fetch( + `https://api.linkedin.com/v2/organizations/${orgId}`, + { + headers: { + "Authorization": `Bearer ${accessToken}`, + }, + }, + ); + const orgData = await orgResponse.json(); + orgDetails.push({ + id: orgId, + name: orgData.localizedName || orgData.name || `Organization ${orgId}`, + accessToken: accessToken, // Same token works for organization + }); + } catch (error) { + // Skip if we can't get org details + console.error(`Failed to get org details for ${orgId}:`, error); + } + } + } + return orgDetails; + } + + return []; +}; + +/** + * Sends an HTML response. + * @param {http.ServerResponse} res - The HTTP response object. + * @param {number} statusCode - The HTTP status code. + * @param {string} html - The HTML content to send. + */ +const sendHtml = (res, statusCode, html) => { + res.writeHead(statusCode, { "Content-Type": "text/html" }); + res.end(html); +}; + +const clientId = process.env.LINKEDIN_CLIENT_ID; +const clientSecret = process.env.LINKEDIN_CLIENT_SECRET; + +if (!clientId || !clientSecret) { + console.error( + "Error: LINKEDIN_CLIENT_ID and LINKEDIN_CLIENT_SECRET environment variables must be set.", + ); + console.error( + "Example: LINKEDIN_CLIENT_ID=your_client_id LINKEDIN_CLIENT_SECRET=your_secret node linkedinAuth.js", + ); + process.exit(1); +} + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url, `http://localhost:${PORT}`); + + // Root route - show auth link + if (url.pathname === "/") { + const authUrl = getAuthUrl(clientId); + const html = ` + + + + LinkedIn Company Page Token Generator + + + +
+

šŸ” LinkedIn Company Page Token Generator

+

Click the button below to authenticate with LinkedIn and get your Company Page Access Token.

+ Authenticate with LinkedIn +
+ Note: Make sure you're an administrator of the LinkedIn Company Page you want to post to. +
+
+ āš ļø Important: Your LinkedIn app must be associated with the Company Page. This requires: +
    +
  • The Company Page super admin must approve the app association
  • +
  • Your app must have "Sign In with LinkedIn using OpenID Connect" enabled in Products
  • +
  • The w_organization_social permission requires App Review approval
  • +
  • Business verification may be required
  • +
+

Note: If you get an invalid_scope_error, make sure OpenID Connect is enabled in your app settings.

+
+
+ + + `; + return sendHtml(res, 200, html); + } + + // Callback route - handle OAuth callback + if (url.pathname === "/callback") { + const code = url.searchParams.get("code"); + const error = url.searchParams.get("error"); + const errorDescription = url.searchParams.get("error_description"); + + if (error) { + const html = ` + + + + Authentication Error + + + +
+

āŒ Authentication Error

+
+

Error: ${error}

+

${errorDescription || ""}

+
+

Try again

+
+ + + `; + return sendHtml(res, 400, html); + } + + if (!code) { + return sendHtml( + res, + 400, + "

Error

No authorization code received.

Try again", + ); + } + + try { + // Step 1: Exchange code for access token + const tokenResponse = await exchangeCodeForToken(code, clientId, clientSecret); + + if (tokenResponse.error) { + throw new Error( + tokenResponse.error_description || tokenResponse.error || "Failed to exchange code for token", + ); + } + + const accessToken = tokenResponse.access_token; + const expiresIn = tokenResponse.expires_in; + + // Step 2: Get user's organizations + const organizations = await getUserOrganizations(accessToken); + + if (organizations.length === 0) { + return sendHtml( + res, + 200, + ` + + + + No Organizations Found + + + +
+

āš ļø No Organizations Found

+

You don't have administrator access to any LinkedIn Company Pages, or your app isn't associated with any pages.

+
+

Troubleshooting:

+
    +
  • Make sure you're an administrator of the Company Page
  • +
  • Ensure your LinkedIn app is associated with the Company Page (requires super admin approval)
  • +
  • Check that your app has been approved for the w_organization_social permission
  • +
  • Verify your app is in Live mode if required
  • +
+
+

Try again

+
+ + + `, + ); + } + + // Display results + const orgsHtml = organizations + .map( + (org) => ` +
+

${org.name}

+

Organization ID: ${org.id}

+

Access Token:

+ +

Expires in: ${expiresIn ? `${Math.floor(expiresIn / 86400)} days` : "Check token expiration"}

+
+ `, + ) + .join(""); + + const html = ` + + + + Success! Your Organization Tokens + + + +
+

āœ… Success!

+
+

Your Organization Access Tokens:

+

Copy these tokens and add them to your environment variables. Use the Access Token for the organization you want to post to.

+
+ ${orgsHtml} +
+

āš ļø Important:

+
    +
  • Store these tokens securely (like your other API credentials)
  • +
  • LinkedIn access tokens typically expire after 60 days
  • +
  • Add the token to your environment variables as LINKEDIN_ACCESS_TOKEN
  • +
  • You'll also need the Organization ID as LINKEDIN_ORG_ID
  • +
  • Make sure your app is associated with the Company Page before posting
  • +
+
+

Start over

+
+ + + `; + + return sendHtml(res, 200, html); + } catch (error) { + const html = ` + + + + Error + + + +
+

āŒ Error

+
+

Error: ${error.message}

+
+

Try again

+
+ + + `; + return sendHtml(res, 500, html); + } + } + + // 404 + sendHtml(res, 404, "

Not Found

Go home

"); +}); + +server.listen(PORT, () => { + console.log(`\nšŸš€ LinkedIn Auth Server running at http://localhost:${PORT}`); + console.log(`\nšŸ“‹ Make sure you've set:`); + console.log(` - LINKEDIN_CLIENT_ID`); + console.log(` - LINKEDIN_CLIENT_SECRET`); + console.log(`\nšŸ”— Open http://localhost:${PORT} in your browser to start!\n`); +}); + diff --git a/server/package.json b/server/package.json index 71eb77a..0910e2b 100644 --- a/server/package.json +++ b/server/package.json @@ -10,7 +10,8 @@ "build": "tsx ./getDocs.ts && tsc", "start": "op run --env-file=./prod.env -- node ./prod/index.js", "test": "echo 'No tests yet' && exit 0", - "facebookAuth": "op run --env-file=./prod.env -- node facebookAuth.js" + "facebookAuth": "op run --env-file=./prod.env -- node facebookAuth.js", + "linkedinAuth": "op run --env-file=./prod.env -- node linkedinAuth.js" }, "keywords": [], "author": "", diff --git a/server/prod.env b/server/prod.env index 7ff4411..4e46978 100644 --- a/server/prod.env +++ b/server/prod.env @@ -18,4 +18,6 @@ SANCTION_WEBHOOK="op://Environment Variables - Naomi/Hikari/sanction_webhook" FACEBOOK_PAGE_TOKEN="op://Environment Variables - Naomi/Hikari/facebook page token" FACEBOOK_APP_ID="op://Environment Variables - Naomi/Hikari/facebook app id" FACEBOOK_APP_SECRET="op://Environment Variables - Naomi/Hikari/facebook app secret" -FACEBOOK_PAGE_ID="op://Environment Variables - Naomi/Hikari/facebook page id" \ No newline at end of file +FACEBOOK_PAGE_ID="op://Environment Variables - Naomi/Hikari/facebook page id" +LINKEDIN_CLIENT_ID="op://Environment Variables - Naomi/Hikari/linkedin client id" +LINKEDIN_CLIENT_SECRET="op://Environment Variables - Naomi/Hikari/linkedin client secret" \ No newline at end of file diff --git a/server/src/modules/announceOnBluesky.ts b/server/src/modules/announceOnBluesky.ts index 7852379..d8473d6 100644 --- a/server/src/modules/announceOnBluesky.ts +++ b/server/src/modules/announceOnBluesky.ts @@ -11,6 +11,7 @@ import { AtpAgent } from "@atproto/api"; * @param content - The main body of the announcement. * @returns A message indicating the success or failure of the operation. */ +// eslint-disable-next-line max-lines-per-function, max-statements -- This is a big function. export const announceOnBluesky = async( content: Array, ): Promise => { @@ -39,19 +40,35 @@ export const announceOnBluesky = async( if (typeof blueskyRequest === "string") { return `Failed to send initial post to Bluesky. ${blueskyRequest}`; } - let { uri } = blueskyRequest; + const rootUri = blueskyRequest.uri; + const rootCid = blueskyRequest.cid; + let parentUri = rootUri; + let parentCid = rootCid; for (const post of restOfPosts) { // eslint-disable-next-line no-await-in-loop -- We need to do this sequentially. const blueskyResponse = await agent.post({ - replyTo: uri, - text: post, + reply: { + parent: { + cid: parentCid, + uri: parentUri, + }, + root: { + cid: rootCid, + uri: rootUri, + }, + }, + text: post, + }).catch((error: unknown) => { + return error instanceof Error + ? error.message + : String(error); }); - if (typeof blueskyResponse !== "string") { - const { uri: replyUri } = blueskyResponse; - uri = replyUri; + if (typeof blueskyResponse === "string") { + failedReplies.push(post); continue; } - failedReplies.push(post); + parentUri = blueskyResponse.uri; + parentCid = blueskyResponse.cid; } return `Successfully sent initial post to Bluesky. ${failedReplies.length > 0 ? `Failed to send ${failedReplies.length.toString()} replies: ${failedReplies.join(", ")}`