← back to Shopify Oauth Catcher

server.js

136 lines

// Shopify OAuth catcher — exchanges install codes for shpat_ Admin API tokens.
//
// Flow:
//   1. Steve sets the partner-app redirect URL to https://builds.agentabrams.com/shopify-oauth/callback
//   2. Steve clicks GET /install?shop=designer-laboratory-sandbox.myshopify.com
//      → we redirect to Shopify's authorize URL with our scopes
//   3. Steve approves on shopify.com
//   4. Shopify redirects browser to /callback?code=...&shop=...&hmac=...
//   5. We exchange code for access_token via POST /admin/oauth/access_token
//   6. Save token via secrets-manager CLI as SHOPIFY_<SHOP>_TOKEN

const express = require('express');
const crypto = require('crypto');
const { execFileSync } = require('child_process');
const fs = require('fs');
const PORT = process.env.PORT || 9914;
const SCOPES = process.env.SHOPIFY_SCOPES ||
  'read_content,write_content,read_products,write_products,read_themes,write_themes,read_files,write_files,read_metaobjects,write_metaobjects';

// Load creds from secrets-manager
function readEnv(key) {
  try {
    const m = fs.readFileSync(process.env.HOME + '/Projects/secrets-manager/.env', 'utf8').match(new RegExp('^' + key + '=(.*)$', 'm'));
    return m ? m[1].replace(/^["']|["']$/g, '').trim() : null;
  } catch { return null; }
}
const CLIENT_ID     = readEnv('SHOPIFY_DW_APP_CLIENT_ID');
const CLIENT_SECRET = readEnv('SHOPIFY_DW_APP_CLIENT_SECRET');
const REDIRECT_URI  = process.env.REDIRECT_URI || 'https://builds.agentabrams.com/shopify-oauth/callback';

if (!CLIENT_ID || !CLIENT_SECRET) {
  console.error('[fatal] missing SHOPIFY_DW_APP_CLIENT_ID or _SECRET in ~/Projects/secrets-manager/.env');
  process.exit(2);
}

const app = express();

// Defense-in-depth: 404 any request for editor/snapshot detritus before it
// can reach a static handler or be served by a misconfigured proxy.
const SNAPSHOT_RE = /\.(bak|orig|rej|swp)$|\.bak\.|\.pre-|~$/i;
app.use((req, res, next) => {
  if (SNAPSHOT_RE.test(req.path)) return res.status(404).send('not found');
  next();
});

const states = new Map(); // state → {shop, ts}

app.get('/install', (req, res) => {
  const shop = req.query.shop || 'designer-laboratory-sandbox.myshopify.com';
  if (!/^[\w-]+\.myshopify\.com$/.test(shop)) return res.status(400).send('invalid shop');
  const state = crypto.randomBytes(16).toString('hex');
  states.set(state, { shop, ts: Date.now() });
  // Cleanup old states (older than 10 min)
  for (const [k, v] of states.entries()) if (Date.now() - v.ts > 600_000) states.delete(k);
  const url = `https://${shop}/admin/oauth/authorize` +
    `?client_id=${CLIENT_ID}` +
    `&scope=${encodeURIComponent(SCOPES)}` +
    `&redirect_uri=${encodeURIComponent(REDIRECT_URI)}` +
    `&state=${state}`;
  console.log(`[install] redirecting ${shop} → Shopify OAuth (state=${state.slice(0,8)})`);
  res.redirect(url);
});

app.get('/callback', async (req, res) => {
  const { code, shop, state, hmac } = req.query;
  if (!code || !shop || !state) return res.status(400).send('missing params');
  if (!states.has(state)) return res.status(400).send('unknown state — install link expired or replayed');
  const stateRec = states.get(state);
  states.delete(state);
  if (stateRec.shop !== shop) return res.status(400).send('shop mismatch');

  // Verify HMAC (drop `hmac` and `signature` from query, sort, sign with client_secret)
  const params = { ...req.query };
  delete params.hmac;
  delete params.signature;
  const message = Object.keys(params).sort().map(k => `${k}=${params[k]}`).join('&');
  const expected = crypto.createHmac('sha256', CLIENT_SECRET).update(message).digest('hex');
  if (expected !== hmac) {
    console.warn(`[callback] HMAC mismatch — got=${hmac} expected=${expected}`);
    return res.status(400).send('HMAC validation failed');
  }

  // Exchange code → access token
  try {
    const resp = await fetch(`https://${shop}/admin/oauth/access_token`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ client_id: CLIENT_ID, client_secret: CLIENT_SECRET, code }),
    });
    const j = await resp.json();
    if (!resp.ok || !j.access_token) {
      console.error('[callback] token exchange failed:', j);
      return res.status(500).send('token exchange failed: ' + JSON.stringify(j));
    }
    const token = j.access_token;
    const scope = j.scope || '';
    const last4 = token.slice(-4);
    console.log(`[callback] got access_token (${last4}) scopes=[${scope}]`);

    // Save via secrets-manager — pick a name based on shop handle
    const shopHandle = shop.replace('.myshopify.com', '').replace(/-/g, '_').toUpperCase();
    const keyName = `SHOPIFY_${shopHandle}_ADMIN_TOKEN`;
    try {
      const out = execFileSync('node',
        [process.env.HOME + '/Projects/secrets-manager/cli.js', 'add', keyName, token],
        { encoding: 'utf8' });
      console.log('[saved]', out.split('\n').slice(0, 5).join('\n'));
    } catch (e) {
      console.error('[save] secrets-manager add failed — writing to fallback file');
      fs.writeFileSync(`/tmp/${keyName}.txt`, token, { mode: 0o600 });
    }

    res.send(`<!doctype html><meta charset=utf-8>
<title>OAuth complete</title>
<style>body{font-family:-apple-system,sans-serif;max-width:600px;margin:60px auto;padding:0 20px;background:#0a0905;color:#fff;line-height:1.5}
h1{color:#C9A14B}code{background:#1a1612;padding:2px 6px;border-radius:3px;color:#C9A14B}</style>
<h1>✓ Token captured</h1>
<p>Saved as <code>${keyName}</code> in <code>/secrets</code>. Last4: <code>${last4}</code>.</p>
<p>Granted scopes:<br><code>${scope}</code></p>
<p>You can close this tab.</p>`);
  } catch (e) {
    console.error('[callback] exchange error:', e);
    res.status(500).send('error: ' + e.message);
  }
});

app.get('/', (_req, res) => res.send('<a href="/install">Install on designer-laboratory-sandbox</a>'));
app.get('/health', (_req, res) => res.json({ ok: true, port: PORT, redirect: REDIRECT_URI }));

app.listen(PORT, '0.0.0.0', () => {
  console.log(`[shopify-oauth-catcher] listening on http://127.0.0.1:${PORT}`);
  console.log(`  REDIRECT_URI = ${REDIRECT_URI}`);
  console.log(`  CLIENT_ID    = ${CLIENT_ID.slice(0, 8)}…${CLIENT_ID.slice(-4)}`);
  console.log(`  scopes       = ${SCOPES}`);
});