← 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);
});