TikTok V5

This commit is contained in:
2026-07-11 22:04:38 +02:00
parent 4fc7d332eb
commit 438b3dff7b
28 changed files with 368 additions and 65 deletions

View File

@@ -76,6 +76,7 @@ const {
getTiktokTokens,
refreshTiktokTokens,
saveTiktokTokens,
assertExpectedTiktokAccount,
} = require('./lib/tiktok');
const app = express();
@@ -1188,7 +1189,8 @@ const getTiktokEnv = () => {
const clientKey = (process.env.TIKTOK_CLIENT_KEY || '').trim();
const clientSecret = (process.env.TIKTOK_CLIENT_SECRET || '').trim();
const redirectUri = (process.env.TIKTOK_REDIRECT_URI || `${process.env.SITE_URL || ''}/api/tiktok/callback`).trim();
return { clientKey, clientSecret, redirectUri };
const expectedOpenId = (process.env.TIKTOK_EXPECTED_OPEN_ID || '').trim();
return { clientKey, clientSecret, redirectUri, expectedOpenId };
};
const TIKTOK_STATE_COOKIE = 'tiktok_oauth_state';
@@ -1223,7 +1225,7 @@ app.get('/api/tiktok/connect', (request, response) => {
const authUrl = new URL('https://www.tiktok.com/v2/auth/authorize/');
authUrl.searchParams.set('client_key', clientKey);
authUrl.searchParams.set('scope', 'user.info.basic,video.publish,video.upload');
authUrl.searchParams.set('scope', 'user.info.basic,user.info.stats,video.publish,video.upload,video.list');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('redirect_uri', redirectUri);
authUrl.searchParams.set('state', oauthState);
@@ -1259,6 +1261,7 @@ app.get('/api/tiktok/callback', async (request, response) => {
try {
const tokens = await exchangeTiktokCode({ code, clientKey, clientSecret, redirectUri });
assertExpectedTiktokAccount(tokens.open_id, getTiktokEnv().expectedOpenId);
await saveTiktokTokens(db, tokens);
sendTiktokText(response, 200, 'TikTok account connected. You can close this tab.');
} catch (err) {
@@ -1267,8 +1270,7 @@ app.get('/api/tiktok/callback', async (request, response) => {
}
});
// Token handoff for Hermes Agent: always returns a valid access token,
// refreshing it automatically when it expires within the buffer window.
// Access tokens stay server-side. Hermes must use the protected upload routes.
const TIKTOK_REFRESH_BUFFER_MS = 5 * 60 * 1000;
app.get('/api/tiktok/token', async (request, response) => {
@@ -1283,40 +1285,10 @@ app.get('/api/tiktok/token', async (request, response) => {
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid or missing admin key.' });
}
try {
let tokens = await getTiktokTokens(db);
if (!tokens) {
return response.status(404).json({
code: 'NOT_CONNECTED',
message: 'No TikTok account connected. Visit /api/tiktok/connect first.',
});
}
const expiresAt = new Date(tokens.access_token_expires_at).getTime();
if (expiresAt - Date.now() < TIKTOK_REFRESH_BUFFER_MS) {
const { clientKey, clientSecret } = getTiktokEnv();
if (!clientKey || !clientSecret) {
return response.status(500).json({
code: 'SERVER_ERROR',
message: 'TikTok client credentials are not configured.',
});
}
tokens = await refreshTiktokTokens(db, { clientKey, clientSecret });
}
response.json({
access_token: tokens.access_token,
open_id: tokens.open_id,
scope: tokens.scope,
expires_at: tokens.access_token_expires_at,
});
} catch (error) {
console.error('TikTok token endpoint error', error);
response.status(error.status || 502).json({
code: error.code || 'PROVIDER_ERROR',
message: error.message || 'Failed to provide TikTok token.',
});
}
response.status(410).json({
code: 'TOKEN_HANDOFF_DISABLED',
message: 'TikTok tokens are server-managed. Use the GreenLens TikTok upload routes.',
});
});
app.get('/api/tiktok/status', async (request, response) => {
@@ -1325,19 +1297,46 @@ app.get('/api/tiktok/status', async (request, response) => {
}
const tokens = await getTiktokTokens(db);
const { expectedOpenId } = getTiktokEnv();
if (!tokens) {
return response.json({ connected: false });
return response.json({ connected: false, brand: 'greenlens', accountMatch: null, requiresReconnect: true });
}
const refreshExpiresAt = tokens.refresh_token_expires_at
? new Date(tokens.refresh_token_expires_at).getTime()
: null;
const accountMatch = expectedOpenId ? tokens.open_id === expectedOpenId : null;
const openIdHash = tokens.open_id
? crypto.createHash('sha256').update(tokens.open_id).digest('hex').slice(0, 12)
: null;
response.json({
connected: true,
openId: tokens.open_id,
brand: 'greenlens',
openIdHash,
accountMatch,
scope: tokens.scope,
accessTokenExpiresAt: tokens.access_token_expires_at,
refreshTokenExpiresAt: tokens.refresh_token_expires_at,
requiresReconnect: refreshExpiresAt !== null && (!Number.isFinite(refreshExpiresAt) || refreshExpiresAt <= Date.now()),
});
});
// ─── TikTok Upload Helpers ─────────────────────────────────────────────────
const assertConfiguredTiktokAccount = (tokens) => {
assertExpectedTiktokAccount(tokens?.open_id, getTiktokEnv().expectedOpenId);
return tokens;
};
const refreshLiveTiktokTokens = async () => {
const { clientKey, clientSecret } = getTiktokEnv();
if (!clientKey || !clientSecret) {
const error = new Error('TikTok client credentials are not configured.');
error.status = 500;
throw error;
}
return assertConfiguredTiktokAccount(await refreshTiktokTokens(db, { clientKey, clientSecret }));
};
const getLiveTiktokAccessToken = async () => {
const tokens = await getTiktokTokens(db);
if (!tokens) {
@@ -1348,16 +1347,10 @@ const getLiveTiktokAccessToken = async () => {
const expiresAt = new Date(tokens.access_token_expires_at).getTime();
if (expiresAt - Date.now() < TIKTOK_REFRESH_BUFFER_MS) {
const { clientKey, clientSecret } = getTiktokEnv();
if (!clientKey || !clientSecret) {
const error = new Error('TikTok client credentials are not configured.');
error.status = 500;
throw error;
}
return refreshTiktokTokens(db, { clientKey, clientSecret });
return refreshLiveTiktokTokens();
}
return tokens;
return assertConfiguredTiktokAccount(tokens);
};
const tiktokApi = async (url, options = {}) => {
@@ -1373,14 +1366,22 @@ const tiktokApi = async (url, options = {}) => {
},
};
const res = await fetch(url, fetchOptions);
const text = await res.text();
let data;
try {
data = JSON.parse(text);
} catch {
data = { raw: text };
const readTiktokResponse = async (res) => {
const text = await res.text();
try {
return { res, data: JSON.parse(text) };
} catch {
return { res, data: { raw: text } };
}
};
let result = await readTiktokResponse(await fetch(url, fetchOptions));
if (result.res.status === 401 && result.data?.error?.code === 'access_token_invalid') {
const refreshed = await refreshLiveTiktokTokens();
fetchOptions.headers.Authorization = `Bearer ${refreshed.access_token}`;
result = await readTiktokResponse(await fetch(url, fetchOptions));
}
const { res, data } = result;
if (!res.ok || data?.error?.code !== 'ok') {
const message = data?.error?.message || data?.raw || `TikTok API error: ${res.status}`;

View File

@@ -1,11 +1,13 @@
const { get, run } = require('./postgres');
const TIKTOK_ACCOUNT_KEY = 'hermes-agent';
const TIKTOK_ACCOUNT_KEY = 'greenlens';
const LEGACY_TIKTOK_ACCOUNT_KEY = 'hermes-agent';
let refreshInFlight = null;
const saveTiktokTokens = async (db, tokens) => {
const now = Date.now();
const accessExpiresAt = new Date(now + Number(tokens.expires_in || 0) * 1000);
const refreshExpiresAt = tokens.refresh_expires_in
const refreshExpiresAt = tokens.refresh_expires_in !== undefined && tokens.refresh_expires_in !== null
? new Date(now + Number(tokens.refresh_expires_in) * 1000)
: null;
@@ -19,7 +21,7 @@ const saveTiktokTokens = async (db, tokens) => {
refresh_token = EXCLUDED.refresh_token,
scope = EXCLUDED.scope,
access_token_expires_at = EXCLUDED.access_token_expires_at,
refresh_token_expires_at = EXCLUDED.refresh_token_expires_at,
refresh_token_expires_at = COALESCE(EXCLUDED.refresh_token_expires_at, tiktok_tokens.refresh_token_expires_at),
updated_at = CURRENT_TIMESTAMP`,
[
TIKTOK_ACCOUNT_KEY,
@@ -33,8 +35,23 @@ const saveTiktokTokens = async (db, tokens) => {
);
};
const getTiktokTokens = async (db) =>
get(db, `SELECT * FROM tiktok_tokens WHERE account_key = ?`, [TIKTOK_ACCOUNT_KEY]);
const getTiktokTokens = async (db) => {
const current = await get(db, `SELECT * FROM tiktok_tokens WHERE account_key = ?`, [TIKTOK_ACCOUNT_KEY]);
if (current) return current;
// Preserve an existing production connection when upgrading from the old,
// generic key. Future writes use the brand-specific key.
const legacy = await get(db, `SELECT * FROM tiktok_tokens WHERE account_key = ?`, [LEGACY_TIKTOK_ACCOUNT_KEY]);
if (!legacy) return null;
await saveTiktokTokens(db, {
...legacy,
expires_in: Math.max(0, Math.floor((new Date(legacy.access_token_expires_at).getTime() - Date.now()) / 1000)),
refresh_expires_in: legacy.refresh_token_expires_at
? Math.max(0, Math.floor((new Date(legacy.refresh_token_expires_at).getTime() - Date.now()) / 1000))
: undefined,
});
return get(db, `SELECT * FROM tiktok_tokens WHERE account_key = ?`, [TIKTOK_ACCOUNT_KEY]);
};
const exchangeCodeForTokens = async ({ code, clientKey, clientSecret, redirectUri }) => {
const response = await fetch('https://open.tiktokapis.com/v2/oauth/token/', {
@@ -59,7 +76,7 @@ const exchangeCodeForTokens = async ({ code, clientKey, clientSecret, redirectUr
return data;
};
const refreshTiktokTokens = async (db, { clientKey, clientSecret }) => {
const performTiktokRefresh = async (db, { clientKey, clientSecret }) => {
const existing = await getTiktokTokens(db);
if (!existing) {
const error = new Error('No TikTok account connected.');
@@ -67,6 +84,16 @@ const refreshTiktokTokens = async (db, { clientKey, clientSecret }) => {
throw error;
}
const refreshExpiresAt = existing.refresh_token_expires_at
? new Date(existing.refresh_token_expires_at).getTime()
: null;
if (refreshExpiresAt !== null && (!Number.isFinite(refreshExpiresAt) || refreshExpiresAt <= Date.now())) {
const error = new Error('TikTok authorization has expired. Reconnect the GreenLens TikTok account.');
error.status = 401;
error.code = 'TIKTOK_RECONNECT_REQUIRED';
throw error;
}
const response = await fetch('https://open.tiktokapis.com/v2/oauth/token/', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
@@ -80,9 +107,11 @@ const refreshTiktokTokens = async (db, { clientKey, clientSecret }) => {
const data = await response.json();
if (!response.ok || data.error) {
const message = data.error_description || data.error || 'TikTok token refresh failed.';
const providerCode = typeof data.error === 'string' ? data.error : data.error?.code;
const message = data.error_description || data.error?.message || providerCode || 'TikTok token refresh failed.';
const error = new Error(message);
error.status = 502;
error.status = providerCode === 'invalid_grant' ? 401 : 502;
error.code = providerCode === 'invalid_grant' ? 'TIKTOK_RECONNECT_REQUIRED' : 'TIKTOK_REFRESH_FAILED';
throw error;
}
@@ -90,8 +119,34 @@ const refreshTiktokTokens = async (db, { clientKey, clientSecret }) => {
return getTiktokTokens(db);
};
const refreshTiktokTokens = async (db, credentials) => {
if (!refreshInFlight) {
refreshInFlight = performTiktokRefresh(db, credentials).finally(() => {
refreshInFlight = null;
});
}
return refreshInFlight;
};
const assertExpectedTiktokAccount = (openId, expectedOpenId) => {
const expected = String(expectedOpenId || '').trim();
if (!expected) {
const error = new Error('TIKTOK_EXPECTED_OPEN_ID is not configured for GreenLens.');
error.status = 500;
error.code = 'TIKTOK_ACCOUNT_NOT_CONFIGURED';
throw error;
}
if (String(openId || '').trim() !== expected) {
const error = new Error('Connected TikTok account does not match the configured GreenLens account.');
error.status = 409;
error.code = 'TIKTOK_ACCOUNT_MISMATCH';
throw error;
}
};
module.exports = {
exchangeCodeForTokens,
assertExpectedTiktokAccount,
getTiktokTokens,
refreshTiktokTokens,
saveTiktokTokens,

100
server/test/tiktok.test.js Normal file
View File

@@ -0,0 +1,100 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { assertExpectedTiktokAccount, refreshTiktokTokens } = require('../lib/tiktok');
test('accepts the configured TikTok account', () => {
assert.doesNotThrow(() => assertExpectedTiktokAccount('greenlens-id', 'greenlens-id'));
});
test('rejects deployments without an expected GreenLens account', () => {
assert.throws(
() => assertExpectedTiktokAccount('greenlens-id', ''),
(error) => error.code === 'TIKTOK_ACCOUNT_NOT_CONFIGURED' && error.status === 500,
);
});
test('rejects a TikTok account that belongs to another brand', () => {
assert.throws(
() => assertExpectedTiktokAccount('qrmaster-id', 'greenlens-id'),
(error) => error.code === 'TIKTOK_ACCOUNT_MISMATCH' && error.status === 409,
);
});
test('expired refresh tokens require reconnecting before contacting TikTok', async () => {
const row = {
account_key: 'greenlens',
open_id: 'greenlens-id',
access_token: 'expired-access',
refresh_token: 'expired-refresh',
access_token_expires_at: new Date(Date.now() - 60_000),
refresh_token_expires_at: new Date(Date.now() - 1_000),
};
const db = {
query: async (sql, params) => ({ rows: sql.startsWith('SELECT') && params[0] === 'greenlens' ? [row] : [] }),
};
await assert.rejects(
refreshTiktokTokens(db, { clientKey: 'client', clientSecret: 'secret' }),
(error) => error.code === 'TIKTOK_RECONNECT_REQUIRED' && error.status === 401,
);
});
test('concurrent refreshes share one provider request and persist rotated tokens', async () => {
let row = {
account_key: 'greenlens',
open_id: 'greenlens-id',
access_token: 'old-access',
refresh_token: 'old-refresh',
scope: 'video.upload',
access_token_expires_at: new Date(Date.now() - 1_000),
refresh_token_expires_at: new Date(Date.now() + 60_000),
};
const db = {
query: async (sql, params) => {
if (sql.startsWith('SELECT')) return { rows: params[0] === 'greenlens' ? [row] : [] };
if (sql.startsWith('INSERT')) {
row = {
...row,
account_key: params[0],
open_id: params[1],
access_token: params[2],
refresh_token: params[3],
scope: params[4],
access_token_expires_at: params[5],
refresh_token_expires_at: params[6],
};
}
return { rows: [], rowCount: 1 };
},
};
const originalFetch = global.fetch;
let providerCalls = 0;
global.fetch = async () => {
providerCalls += 1;
await new Promise((resolve) => setTimeout(resolve, 10));
return {
ok: true,
json: async () => ({
open_id: 'greenlens-id',
access_token: 'new-access',
refresh_token: 'rotated-refresh',
scope: 'video.upload',
expires_in: 86_400,
refresh_expires_in: 31_536_000,
}),
};
};
try {
const [first, second] = await Promise.all([
refreshTiktokTokens(db, { clientKey: 'client', clientSecret: 'secret' }),
refreshTiktokTokens(db, { clientKey: 'client', clientSecret: 'secret' }),
]);
assert.equal(providerCalls, 1);
assert.equal(first.access_token, 'new-access');
assert.equal(second.refresh_token, 'rotated-refresh');
} finally {
global.fetch = originalFetch;
}
});