← back to Designer Wallcoverings
mailers/cc-api/oauth-setup.js
102 lines
#!/usr/bin/env node
/**
* Constant Contact v3 — one-time OAuth setup helper.
*
* Confidential auth-code flow (uses the client secret; no PKCE needed).
* Mints the long-lived CC_REFRESH_TOKEN that cc-client.js then auto-refreshes.
*
* Env required: CC_CLIENT_ID, CC_CLIENT_SECRET (route via the `secrets` skill)
* Env optional: CC_REDIRECT_URI (default below — MUST match the URI registered
* in the Constant Contact app exactly)
*
* Usage:
* node oauth-setup.js authorize # prints the URL Steve clicks
* node oauth-setup.js token "<code>" # exchanges the ?code=... for tokens
* node oauth-setup.js token "<full redirected URL>" # also accepts the whole URL
*/
const crypto = require('crypto');
const path = require('path');
// Auto-load the saved creds (CC_CLIENT_ID/SECRET) from the gitignored local .env.
try { require('dotenv').config({ path: path.join(__dirname, '.env') }); } catch { /* dotenv optional */ }
const AUTHORIZE_URL = 'https://authz.constantcontact.com/oauth2/default/v1/authorize';
const TOKEN_URL = 'https://authz.constantcontact.com/oauth2/default/v1/token';
const REDIRECT_URI = process.env.CC_REDIRECT_URI || 'https://localhost';
// contact_data = read/manage lists (set recipients via API); campaign_data = create/send; offline_access = refresh token.
const SCOPE = process.env.CC_SCOPE || 'contact_data campaign_data offline_access';
function need(name) {
const v = process.env[name];
if (!v) {
console.error(`Missing env ${name}. Set CC_CLIENT_ID + CC_CLIENT_SECRET (route via /secrets), then re-run.`);
process.exit(1);
}
return v;
}
function buildAuthorizeUrl() {
const clientId = need('CC_CLIENT_ID');
const state = crypto.randomBytes(8).toString('hex');
const u = new URL(AUTHORIZE_URL);
u.searchParams.set('client_id', clientId);
u.searchParams.set('redirect_uri', REDIRECT_URI);
u.searchParams.set('response_type', 'code');
u.searchParams.set('scope', SCOPE);
u.searchParams.set('state', state);
return u.toString();
}
function extractCode(arg) {
if (!arg) { console.error('Pass the ?code=... value (or the full redirected URL).'); process.exit(1); }
if (arg.includes('code=')) {
try { return new URL(arg).searchParams.get('code'); }
catch { const m = arg.match(/code=([^&\s]+)/); return m && m[1]; }
}
return arg.trim();
}
async function exchangeCode(code) {
const clientId = need('CC_CLIENT_ID');
const clientSecret = need('CC_CLIENT_SECRET');
const basic = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
const body = new URLSearchParams({
code,
redirect_uri: REDIRECT_URI,
grant_type: 'authorization_code',
});
console.error('POST', TOKEN_URL, '(authorization_code grant)');
const res = await fetch(TOKEN_URL, {
method: 'POST',
headers: { Authorization: `Basic ${basic}`, 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
const json = await res.json().catch(() => ({}));
if (!res.ok) {
console.error(`Token exchange failed (${res.status}):`, JSON.stringify(json));
process.exit(1);
}
return json;
}
(async () => {
const cmd = process.argv[2];
if (cmd === 'authorize') {
console.log('\n=== Open this URL in your browser, log into Constant Contact, and Allow: ===\n');
console.log(buildAuthorizeUrl());
console.log(`\nAfter approving you land on ${REDIRECT_URI}/?code=...&state=...`);
console.log('(a "can\'t connect" page is fine — the code is in the address bar). Copy that and run:');
console.log(' node oauth-setup.js token "<paste the code or the whole URL>"\n');
} else if (cmd === 'token') {
const code = extractCode(process.argv[3]);
const tok = await exchangeCode(code);
console.log('\n=== SUCCESS — tokens minted ===');
console.log('access_token (short-lived):', (tok.access_token || '').slice(0, 12) + '…');
console.log('refresh_token (save this!) :', tok.refresh_token);
console.log('\nRoute this via the secrets skill so cc-client.js can auto-refresh:');
console.log(' CC_REFRESH_TOKEN=' + tok.refresh_token);
} else {
console.error('Usage: node oauth-setup.js authorize | token "<code-or-url>"');
process.exit(1);
}
})();