← back to Allnewsdaily
scripts/short/youtube-auth.mjs
265 lines
#!/usr/bin/env node
// youtube-auth.js — OAuth 2.0 loopback flow for the allnewsdaily daily-Short pipeline (TK-11342, STAGE 4).
// Node v26, raw fetch, NO googleapis dependency.
//
// Flow:
// 1. Read YOUTUBE_CLIENT_ID / YOUTUBE_CLIENT_SECRET from ~/Projects/secrets-manager/.env
// 2. Start a localhost server on http://localhost:9964/oauth2callback
// 3. PRINT the consent URL for Steve to open
// 4. Catch the ?code=, exchange for tokens
// 5. SAVE refresh_token to ~/Projects/allnewsdaily/.env as YOUTUBE_REFRESH_TOKEN=
// 6. channels.list?mine=true&part=snippet → PRINT the authorized channel title
// 7. On redirect_uri_mismatch, print a clear fix instruction
//
// Flags:
// --print-url-only Build + print the consent URL and exit (no server, for self-test)
//
// Usage: node scripts/short/youtube-auth.js (Ctrl-C to abort the wait)
import http from 'node:http';
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
const SECRETS_ENV = join(homedir(), 'Projects', 'secrets-manager', '.env');
const APP_ENV = join(homedir(), 'Projects', 'allnewsdaily', '.env');
const PORT = 9964;
const REDIRECT_URI = `http://localhost:${PORT}/oauth2callback`;
const SCOPES = [
'https://www.googleapis.com/auth/youtube.upload', // resumable video insert
'https://www.googleapis.com/auth/youtube.force-ssl', // update privacy (delete-canary auto-unlist), thumbnails, read
];
const AUTH_ENDPOINT = 'https://accounts.google.com/o/oauth2/v2/auth';
const TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token';
const CHANNELS_ENDPOINT =
'https://www.googleapis.com/youtube/v3/channels?mine=true&part=snippet';
// --- tiny .env parser (no dotenv dependency) -------------------------------
function parseEnv(path) {
const out = {};
if (!existsSync(path)) return out;
const txt = readFileSync(path, 'utf8');
for (const raw of txt.split('\n')) {
const line = raw.trim();
if (!line || line.startsWith('#')) continue;
const eq = line.indexOf('=');
if (eq === -1) continue;
const key = line.slice(0, eq).trim();
let val = line.slice(eq + 1).trim();
if (
(val.startsWith('"') && val.endsWith('"')) ||
(val.startsWith("'") && val.endsWith("'"))
) {
val = val.slice(1, -1);
}
out[key] = val;
}
return out;
}
// --- upsert a KEY=value into an .env file, preserving the rest -------------
function upsertEnv(path, key, value) {
let lines = [];
if (existsSync(path)) {
lines = readFileSync(path, 'utf8').split('\n');
}
const idx = lines.findIndex((l) => l.trim().startsWith(`${key}=`));
const newLine = `${key}=${value}`;
if (idx >= 0) {
lines[idx] = newLine;
} else {
// keep a trailing newline tidy
if (lines.length && lines[lines.length - 1].trim() === '') {
lines.splice(lines.length - 1, 0, newLine);
} else {
lines.push(newLine);
}
}
let out = lines.join('\n');
if (!out.endsWith('\n')) out += '\n';
writeFileSync(path, out, { mode: 0o600 });
}
function loadCreds() {
const env = parseEnv(SECRETS_ENV);
const clientId = env.YOUTUBE_CLIENT_ID || process.env.YOUTUBE_CLIENT_ID;
const clientSecret =
env.YOUTUBE_CLIENT_SECRET || process.env.YOUTUBE_CLIENT_SECRET;
if (!clientId || !clientSecret) {
console.error(
`\n[youtube-auth] FATAL: YOUTUBE_CLIENT_ID / YOUTUBE_CLIENT_SECRET not found in ${SECRETS_ENV}\n` +
`Route them via the secrets skill first.\n`
);
process.exit(1);
}
return { clientId, clientSecret };
}
function buildConsentUrl(clientId) {
const p = new URLSearchParams({
client_id: clientId,
redirect_uri: REDIRECT_URI,
response_type: 'code',
scope: SCOPES.join(' '),
access_type: 'offline', // ask for a refresh_token
prompt: 'consent', // force a refresh_token even on re-auth
include_granted_scopes: 'true',
});
return `${AUTH_ENDPOINT}?${p.toString()}`;
}
async function exchangeCodeForTokens(code, clientId, clientSecret) {
const body = new URLSearchParams({
code,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: REDIRECT_URI,
grant_type: 'authorization_code',
});
const res = await fetch(TOKEN_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) {
if (json.error === 'redirect_uri_mismatch') {
console.error(
`\n[youtube-auth] redirect_uri_mismatch.\n` +
`The OAuth client is a "Web application" type and does NOT trust this redirect.\n` +
`FIX: In Google Cloud Console → APIs & Services → Credentials → your OAuth 2.0 Client ID,\n` +
` add this EXACT authorized redirect URI:\n\n` +
` ${REDIRECT_URI}\n\n` +
` then re-run this script. (A "Desktop app" client type auto-trusts loopback and needs no registration.)\n`
);
} else {
console.error(
`\n[youtube-auth] Token exchange failed (${res.status}): ${json.error || ''} ${json.error_description || ''}\n`
);
}
throw new Error(json.error || `token exchange HTTP ${res.status}`);
}
return json; // { access_token, refresh_token, expires_in, scope, token_type }
}
async function getChannelTitle(accessToken) {
const res = await fetch(CHANNELS_ENDPOINT, {
headers: { Authorization: `Bearer ${accessToken}` },
});
const json = await res.json().catch(() => ({}));
if (!res.ok) {
console.error(
`[youtube-auth] channels.list failed (${res.status}): ${JSON.stringify(json).slice(0, 300)}`
);
return null;
}
const item = json.items && json.items[0];
return item ? item.snippet.title : null;
}
async function main() {
const printUrlOnly = process.argv.includes('--print-url-only');
const { clientId, clientSecret } = loadCreds();
const consentUrl = buildConsentUrl(clientId);
if (printUrlOnly) {
console.log('\n[youtube-auth] --print-url-only — consent URL:\n');
console.log(consentUrl + '\n');
console.log(
`redirect_uri : ${REDIRECT_URI}\n` +
`scopes : ${SCOPES.join(' , ')}\n` +
`(no server started; exiting cleanly)\n`
);
return;
}
// Start the loopback server, then print the URL.
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://localhost:${PORT}`);
if (url.pathname !== '/oauth2callback') {
res.writeHead(404).end('Not found');
return;
}
const err = url.searchParams.get('error');
const code = url.searchParams.get('code');
if (err) {
res.writeHead(400, { 'Content-Type': 'text/plain' }).end(
`OAuth error: ${err}. You can close this tab.`
);
console.error(`\n[youtube-auth] Consent returned error: ${err}\n`);
server.close();
process.exit(1);
}
if (!code) {
res.writeHead(400).end('Missing ?code');
return;
}
try {
const tokens = await exchangeCodeForTokens(code, clientId, clientSecret);
if (!tokens.refresh_token) {
res.writeHead(200, { 'Content-Type': 'text/plain' }).end(
'Authorized, but Google returned NO refresh_token. Revoke access at ' +
'myaccount.google.com/permissions and re-run. You can close this tab.'
);
console.error(
'\n[youtube-auth] No refresh_token returned. Revoke the app at ' +
'https://myaccount.google.com/permissions and re-run (prompt=consent is set).\n'
);
server.close();
process.exit(1);
}
upsertEnv(APP_ENV, 'YOUTUBE_REFRESH_TOKEN', tokens.refresh_token);
const title = await getChannelTitle(tokens.access_token);
res.writeHead(200, { 'Content-Type': 'text/plain' }).end(
`Authorized${title ? ' as: ' + title : ''}. Refresh token saved. You can close this tab.`
);
console.log(`\n[youtube-auth] SUCCESS.`);
console.log(` refresh_token saved → ${APP_ENV} (YOUTUBE_REFRESH_TOKEN=)`);
console.log(
` NOTE: ${APP_ENV} is gitignored. Also route YOUTUBE_REFRESH_TOKEN via the \`secrets\` skill so it fans out to the registry.`
);
if (title) console.log(` Authorized YouTube channel: "${title}"`);
else console.log(` (channels.list returned no channel title)`);
server.close();
process.exit(0);
} catch (e) {
res.writeHead(500, { 'Content-Type': 'text/plain' }).end(
`Token exchange failed: ${e.message}. See terminal. You can close this tab.`
);
server.close();
process.exit(1);
}
});
server.listen(PORT, () => {
console.log(`\n[youtube-auth] Loopback server listening on ${REDIRECT_URI}`);
console.log(`\n>>> Open this URL in a browser and grant access:\n`);
console.log(consentUrl + '\n');
console.log('Waiting for the OAuth redirect… (Ctrl-C to abort)\n');
});
server.on('error', (e) => {
if (e.code === 'EADDRINUSE') {
console.error(
`\n[youtube-auth] Port ${PORT} is already in use. Close the other process and retry.\n`
);
} else {
console.error(`\n[youtube-auth] Server error: ${e.message}\n`);
}
process.exit(1);
});
process.on('SIGINT', () => {
console.log('\n[youtube-auth] Aborted (Ctrl-C). No tokens exchanged.');
server.close();
process.exit(0);
});
}
main().catch((e) => {
console.error(`[youtube-auth] Fatal: ${e.message}`);
process.exit(1);
});