Files
hikari/server/threadsAuth.js
naomi 922dee415a
Node.js CI / CI (push) Successful in 44s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m25s
feat: more automated announcements (#8)
### Explanation

Makes my life so much easier.

### Issue

_No response_

### Attestations

- [x] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/)
- [x] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/).
- [x] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/).

### Dependencies

- [x] I have pinned the dependencies to a specific patch version.

### Style

- [x] I have run the linter and resolved any errors.
- [x] My pull request uses an appropriate title, matching the conventional commit standards.
- [x] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request.

### Tests

- [ ] My contribution adds new code, and I have added tests to cover it.
- [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes.
- [ ] All new and existing tests pass locally with my changes.
- [ ] Code coverage remains at or above the configured threshold.

### Documentation

_No response_

### Versioning

Minor - My pull request introduces a new non-breaking feature.

Reviewed-on: #8
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-01-08 18:07:28 -08:00

605 lines
21 KiB
JavaScript

/**
* @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>} 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 = `
<!DOCTYPE html>
<html>
<head>
<title>Threads Token Generator</title>
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
max-width: 800px;
margin: 50px auto;
padding: 20px;
background: #f5f5f5;
}
.container {
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
h1 {
color: #000;
margin-top: 0;
}
.button {
display: inline-block;
background: #000;
color: white;
padding: 12px 24px;
text-decoration: none;
border-radius: 6px;
font-weight: 600;
margin-top: 20px;
}
.button:hover {
background: #333;
}
.info {
background: #e3f2fd;
padding: 15px;
border-radius: 6px;
margin-top: 20px;
border-left: 4px solid #1877f2;
}
.warning {
background: #fff3e0;
padding: 15px;
border-radius: 6px;
margin-top: 20px;
border-left: 4px solid #ff9800;
}
</style>
</head>
<body>
<div class="container">
<h1>🔐 Threads Token Generator</h1>
<p>Click the button below to authenticate with Meta/Facebook and get your Threads Access Token.</p>
<a href="${authUrl}" class="button">Authenticate with Meta</a>
<div class="info">
<strong>Note:</strong> You need:
<ul>
<li>An Instagram Business Account</li>
<li>A Threads profile linked to that Instagram account</li>
<li>Admin access to a Facebook Page connected to your Instagram Business Account</li>
</ul>
</div>
<div class="warning">
<strong>⚠️ Important:</strong> Your Threads app must have:
<ul>
<li>Threads API product added</li>
<li><code>threads_basic</code> and <code>threads_content_publish</code> permissions approved</li>
<li>Valid OAuth Redirect URI: <code>${REDIRECT_URI}</code></li>
</ul>
</div>
${REDIRECT_URI.startsWith("http://") ? `
<div class="warning" style="background: #ffebee; border-left-color: #d32f2f;">
<strong>🔒 HTTPS Required:</strong> Threads API requires HTTPS for OAuth redirects!
<ul>
<li>Install cloudflared: <code>brew install cloudflared</code> or download from <a href="https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/installation/" target="_blank">cloudflare.com</a></li>
<li>Run: <code>cloudflared tunnel --url http://localhost:${PORT}</code></li>
<li>Copy the HTTPS URL (e.g., https://abc123.trycloudflare.com)</li>
<li>Set environment variable: <code>THREADS_REDIRECT_URI=https://abc123.trycloudflare.com/callback</code></li>
<li>Add the HTTPS URL to your Threads app's Valid OAuth Redirect URIs</li>
<li>Restart this server</li>
</ul>
</div>
` : ""}
</div>
</body>
</html>
`;
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 = `
<!DOCTYPE html>
<html>
<head>
<title>Authentication Error</title>
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
max-width: 800px;
margin: 50px auto;
padding: 20px;
background: #f5f5f5;
}
.container {
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.error {
color: #d32f2f;
background: #ffebee;
padding: 15px;
border-radius: 6px;
border-left: 4px solid #d32f2f;
}
</style>
</head>
<body>
<div class="container">
<h1>❌ Authentication Error</h1>
<div class="error">
<p><strong>Error:</strong> ${error}</p>
<p><strong>Error Reason:</strong> ${errorReason || "N/A"}</p>
<p><strong>Error Description:</strong> ${errorDescription || "N/A"}</p>
<p><strong>Full Callback URL:</strong> <code style="word-break: break-all;">${url.href}</code></p>
<p><strong>Expected Redirect URI:</strong> <code>${REDIRECT_URI}</code></p>
</div>
<p><a href="/">Try again</a></p>
</div>
</body>
</html>
`;
return sendHtml(res, 400, html);
}
if (!code) {
return sendHtml(
res,
400,
"<h1>Error</h1><p>No authorization code received.</p><a href='/'>Try again</a>",
);
}
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,
`
<!DOCTYPE html>
<html>
<head>
<title>No Threads Accounts Found</title>
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
max-width: 800px;
margin: 50px auto;
padding: 20px;
background: #f5f5f5;
}
.container {
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
</style>
</head>
<body>
<div class="container">
<h1>⚠️ No Threads Accounts Found</h1>
<p>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.</p>
<p><a href="/">Try again</a></p>
</div>
</body>
</html>
`,
);
}
// Display results
const accountsHtml = accounts
.map(
(account) => `
<div style="background: #f5f5f5; padding: 15px; margin: 10px 0; border-radius: 6px;">
<h3>@${account.username}</h3>
<p><strong>Instagram Business Account ID:</strong> <code>${account.instagramAccountId}</code></p>
<p><strong>Threads Profile ID:</strong> <code>${account.threadsProfileId}</code></p>
<p><strong>Access Token:</strong></p>
<textarea readonly style="width: 100%; padding: 10px; font-family: monospace; border: 1px solid #ddd; border-radius: 4px; background: white;" rows="3">${accessToken}</textarea>
<p><strong>Note:</strong> Threads access tokens are short-lived. You may need to refresh them periodically.</p>
</div>
`,
)
.join("");
const html = `
<!DOCTYPE html>
<html>
<head>
<title>Success! Your Threads Tokens</title>
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
max-width: 900px;
margin: 50px auto;
padding: 20px;
background: #f5f5f5;
}
.container {
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.success {
background: #e8f5e9;
padding: 15px;
border-radius: 6px;
border-left: 4px solid #4caf50;
margin-bottom: 20px;
}
h1 {
color: #4caf50;
margin-top: 0;
}
code {
background: #f5f5f5;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
}
.warning {
background: #fff3e0;
padding: 15px;
border-radius: 6px;
border-left: 4px solid #ff9800;
margin-top: 20px;
}
</style>
</head>
<body>
<div class="container">
<h1>✅ Success!</h1>
<div class="success">
<p><strong>Your Threads Access Tokens:</strong></p>
<p>Copy these values and add them to your environment variables.</p>
</div>
${accountsHtml}
<div class="warning">
<p><strong>⚠️ Important:</strong></p>
<ul>
<li>Store these tokens securely (like your other API credentials)</li>
<li>Add the access token to your environment variables as <code>THREADS_ACCESS_TOKEN</code></li>
<li>Add the Instagram Business Account ID as <code>THREADS_INSTAGRAM_ACCOUNT_ID</code></li>
<li>Add the Threads Profile ID as <code>THREADS_PROFILE_ID</code> (usually same as Instagram Account ID)</li>
<li>Threads tokens are short-lived and may need to be refreshed periodically</li>
</ul>
</div>
<p><a href="/">Start over</a></p>
</div>
</body>
</html>
`;
return sendHtml(res, 200, html);
} catch (error) {
const html = `
<!DOCTYPE html>
<html>
<head>
<title>Error</title>
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
max-width: 800px;
margin: 50px auto;
padding: 20px;
background: #f5f5f5;
}
.container {
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.error {
color: #d32f2f;
background: #ffebee;
padding: 15px;
border-radius: 6px;
border-left: 4px solid #d32f2f;
}
</style>
</head>
<body>
<div class="container">
<h1>❌ Error</h1>
<div class="error">
<p><strong>Error:</strong> ${error.message}</p>
</div>
<p><a href="/">Try again</a></p>
</div>
</body>
</html>
`;
return sendHtml(res, 500, html);
}
}
// 404
sendHtml(res, 404, "<h1>Not Found</h1><p><a href='/'>Go home</a></p>");
});
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`);
});