/** * @copyright nhcarrigan * @license Naomi's Public License * @author Naomi Carrigan * * Simple local server to authenticate with Threads (via Meta/Facebook) and obtain an Access Token. * Run with: node threadsAuth.js * Make sure to set THREADS_APP_ID and THREADS_APP_SECRET environment variables. * * Note: You need an Instagram Business Account linked to your Threads profile. * The OAuth flow goes through Facebook's endpoints (Meta's unified platform) but uses * Threads-specific app credentials. */ import http from "http"; import { URL } from "url"; const PORT = 3001; // Different port from Facebook auth // Threads API requires HTTPS for OAuth redirects // For local development, use ngrok: ngrok http 3001 // Then set THREADS_REDIRECT_URI to your ngrok HTTPS URL const REDIRECT_URI =`https://local3001.nhcarrigan.com/callback`; /** * Creates the Threads OAuth authorization URL. * Threads uses its own OAuth endpoint: threads.net/oauth/authorize * @param {string} appId - The Threads App ID. * @returns {string} The authorization URL. */ const getAuthUrl = (appId) => { const params = new URLSearchParams({ client_id: appId, redirect_uri: REDIRECT_URI, scope: "threads_basic,threads_content_publish", response_type: "code", }); return `https://threads.net/oauth/authorize?${params.toString()}`; }; /** * Exchanges an authorization code for an access token. * Threads uses its own token endpoint: graph.threads.net/oauth/access_token * @param {string} code - The authorization code from Threads. * @param {string} appId - The Threads App ID. * @param {string} appSecret - The Threads App Secret. * @returns {Promise<{access_token: string, user_id?: number}>} The access token response. */ const exchangeCodeForToken = async (code, appId, appSecret) => { const params = new URLSearchParams({ client_id: appId, client_secret: appSecret, redirect_uri: REDIRECT_URI, code: code, grant_type: "authorization_code", }); const response = await fetch( `https://graph.threads.net/oauth/access_token`, { body: params, method: "POST", }, ); return await response.json(); }; /** * Exchanges a short-lived token for a long-lived token. * @param {string} shortLivedToken - The short-lived access token. * @param {string} appId - The Threads App ID. * @param {string} appSecret - The Threads App Secret. * @returns {Promise<{access_token: string, expires_in?: number}>} The long-lived token response. */ const exchangeForLongLivedToken = async (shortLivedToken, appId, appSecret) => { const params = new URLSearchParams({ grant_type: "fb_exchange_token", client_id: appId, client_secret: appSecret, fb_exchange_token: shortLivedToken, }); const response = await fetch( `https://graph.facebook.com/v21.0/oauth/access_token?${params.toString()}`, ); return await response.json(); }; /** * Gets the user's Instagram Business Accounts. * @param {string} accessToken - The user access token. * @returns {Promise} Array of Instagram Business Accounts. */ const getInstagramAccounts = async (accessToken) => { const response = await fetch( `https://graph.facebook.com/v21.0/me/accounts?fields=instagram_business_account&access_token=${accessToken}`, ); const data = await response.json(); const accounts = []; if (data.data) { for (const page of data.data) { if (page.instagram_business_account) { const igAccountResponse = await fetch( `https://graph.facebook.com/v21.0/${page.instagram_business_account.id}?fields=id,username,threads_profile&access_token=${accessToken}`, ); const igAccount = await igAccountResponse.json(); if (igAccount.threads_profile) { accounts.push({ instagramAccountId: igAccount.id, username: igAccount.username, threadsProfileId: igAccount.threads_profile.id, }); } } } } return accounts; }; /** * 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); }; /** * Sends a JSON response. * @param {http.ServerResponse} res - The HTTP response object. * @param {number} statusCode - The HTTP status code. * @param {object} data - The JSON data to send. */ const sendJson = (res, statusCode, data) => { res.writeHead(statusCode, { "Content-Type": "application/json" }); res.end(JSON.stringify(data, null, 2)); }; const appId = process.env.THREADS_APP_ID?.trim(); const appSecret = process.env.THREADS_APP_SECRET?.trim(); if (!appId || !appSecret) { console.error( "Error: THREADS_APP_ID and THREADS_APP_SECRET environment variables must be set.", ); console.error( "Example: THREADS_APP_ID=your_app_id THREADS_APP_SECRET=your_secret node threadsAuth.js", ); process.exit(1); } // Validate App ID format (should be numeric) if (!/^\d+$/.test(appId)) { console.error( `Error: THREADS_APP_ID does not appear to be valid. Got: "${appId}"`, ); console.error( "App ID should be a numeric string. Make sure you're using 'op run' to resolve 1Password references.", ); console.error( "Run: pnpm threadsAuth (or: op run --env-file=./prod.env -- node threadsAuth.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(appId); const html = ` Threads Token Generator

šŸ” Threads Token Generator

Click the button below to authenticate with Meta/Facebook and get your Threads Access Token.

Authenticate with Meta
Note: You need:
  • An Instagram Business Account
  • A Threads profile linked to that Instagram account
  • Admin access to a Facebook Page connected to your Instagram Business Account
āš ļø Important: Your Threads app must have:
  • Threads API product added
  • threads_basic and threads_content_publish permissions approved
  • Valid OAuth Redirect URI: ${REDIRECT_URI}
${REDIRECT_URI.startsWith("http://") ? `
šŸ”’ HTTPS Required: Threads API requires HTTPS for OAuth redirects!
  • Install cloudflared: brew install cloudflared or download from cloudflare.com
  • Run: cloudflared tunnel --url http://localhost:${PORT}
  • Copy the HTTPS URL (e.g., https://abc123.trycloudflare.com)
  • Set environment variable: THREADS_REDIRECT_URI=https://abc123.trycloudflare.com/callback
  • Add the HTTPS URL to your Threads app's Valid OAuth Redirect URIs
  • Restart this server
` : ""}
`; return sendHtml(res, 200, html); } // Callback route - handle OAuth callback if (url.pathname === "/callback") { // Threads appends #_ to the redirect URI - strip it from the URL let code = url.searchParams.get("code"); const error = url.searchParams.get("error"); const errorReason = url.searchParams.get("error_reason"); const errorDescription = url.searchParams.get("error_description"); // Debug: Log the full callback URL to see what Threads is sending console.log(`\nšŸ” Callback received:`); console.log(` Full URL: ${url.href}`); console.log(` Expected redirect URI: ${REDIRECT_URI}`); console.log(` Error: ${error || "none"}`); console.log(` Error reason: ${errorReason || "none"}`); console.log(` Error description: ${errorDescription || "none"}\n`); // If code is in the hash (after #_), extract it if (!code && url.hash) { const hashParams = new URLSearchParams(url.hash.substring(1)); code = hashParams.get("code"); } if (error) { const html = ` Authentication Error

āŒ Authentication Error

Error: ${error}

Error Reason: ${errorReason || "N/A"}

Error Description: ${errorDescription || "N/A"}

Full Callback URL: ${url.href}

Expected Redirect URI: ${REDIRECT_URI}

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, appId, appSecret); if (tokenResponse.error_type || tokenResponse.error_message) { throw new Error( tokenResponse.error_message || "Failed to exchange code for token", ); } if (!tokenResponse.access_token) { throw new Error( "No access token received. Response: " + JSON.stringify(tokenResponse), ); } const accessToken = tokenResponse.access_token; const userId = tokenResponse.user_id; // Step 2: Get Instagram Business Account ID // The user_id from Threads token exchange is the Instagram Business Account ID // We can also verify this by calling the Threads API const accounts = []; if (userId) { // Try to get account info from Threads API try { const accountInfoResponse = await fetch( `https://graph.threads.net/v1.0/${userId}?fields=id,username&access_token=${accessToken}`, ); if (accountInfoResponse.ok) { const accountInfo = await accountInfoResponse.json(); accounts.push({ instagramAccountId: userId.toString(), username: accountInfo.username || "unknown", threadsProfileId: userId.toString(), // Threads Profile ID is same as Instagram Business Account ID }); } else { // Fallback: use the user_id as Instagram Business Account ID accounts.push({ instagramAccountId: userId.toString(), username: "unknown", threadsProfileId: userId.toString(), }); } } catch (err) { // Fallback: use the user_id as Instagram Business Account ID accounts.push({ instagramAccountId: userId.toString(), username: "unknown", threadsProfileId: userId.toString(), }); } } if (accounts.length === 0) { return sendHtml( res, 200, ` No Threads Accounts Found

āš ļø No Threads Accounts Found

You don't have access to any Instagram Business Accounts with Threads profiles, or your Facebook Page isn't connected to an Instagram Business Account.

Try again

`, ); } // Display results const accountsHtml = accounts .map( (account) => `

@${account.username}

Instagram Business Account ID: ${account.instagramAccountId}

Threads Profile ID: ${account.threadsProfileId}

Access Token:

Note: Threads access tokens are short-lived. You may need to refresh them periodically.

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

āœ… Success!

Your Threads Access Tokens:

Copy these values and add them to your environment variables.

${accountsHtml}

āš ļø Important:

  • Store these tokens securely (like your other API credentials)
  • Add the access token to your environment variables as THREADS_ACCESS_TOKEN
  • Add the Instagram Business Account ID as THREADS_INSTAGRAM_ACCOUNT_ID
  • Add the Threads Profile ID as THREADS_PROFILE_ID (usually same as Instagram Account ID)
  • Threads tokens are short-lived and may need to be refreshed periodically

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šŸš€ Threads Auth Server running at http://localhost:${PORT}`); console.log(`\nšŸ“‹ Make sure you've set:`); console.log(` - THREADS_APP_ID`); console.log(` - THREADS_APP_SECRET`); if (REDIRECT_URI.startsWith("http://")) { console.log(`\nšŸ”’ HTTPS REQUIRED: Threads API requires HTTPS for OAuth redirects!`); console.log(`\n Current redirect URI: ${REDIRECT_URI}`); console.log(`\n To fix:`); console.log(` 1. Install cloudflared: brew install cloudflared`); console.log(` 2. Run: cloudflared tunnel --url http://localhost:${PORT}`); console.log(` 3. Copy the HTTPS URL (e.g., https://abc123.trycloudflare.com)`); console.log(` 4. Set: THREADS_REDIRECT_URI=https://abc123.trycloudflare.com/callback`); console.log(` 5. Add the HTTPS URL to your Threads app's Valid OAuth Redirect URIs`); console.log(` 6. Restart this server`); } else { console.log(`\nāœ… Using HTTPS redirect URI: ${REDIRECT_URI}`); } console.log(`\nšŸ”— Open http://localhost:${PORT} in your browser to start!`); console.log(`\nāš ļø Make sure your Threads app has:`); console.log(` - Threads API product added`); console.log(` - threads_basic and threads_content_publish permissions`); console.log(` - OAuth Redirect URI: ${REDIRECT_URI}`); console.log(` - Client OAuth Login: ON`); console.log(` - Web OAuth Login: ON`); console.log(`\nšŸ’” Note: OAuth flow uses Threads-specific endpoints`); console.log(`\nšŸ” Debug info:`); console.log(` - Redirect URI: ${REDIRECT_URI}`); console.log(` - URL-encoded: ${encodeURIComponent(REDIRECT_URI)}`); console.log(` - Make sure this EXACTLY matches what's in your Threads app settings\n`); });