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