Skip to content

Commit c4caae3

Browse files
Merge pull request #1 from apolloio/oauth
Switch auth to OAuth-only via mcp.apollo.io
2 parents d6f0ede + 1647756 commit c4caae3

4 files changed

Lines changed: 192 additions & 38 deletions

File tree

src/api.js

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,24 @@
1-
import { readFileSync, existsSync } from 'fs';
2-
import { join } from 'path';
3-
import { homedir } from 'os';
1+
import { loadCredentials } from './credentials.js';
42

5-
const CREDENTIALS_PATH = join(homedir(), '.config', 'apollo', 'credentials');
63
const BASE_URL = 'https://api.apollo.io/api/v1';
74

8-
function getApiKey() {
9-
if (process.env.APOLLO_API_KEY) return process.env.APOLLO_API_KEY;
10-
11-
if (existsSync(CREDENTIALS_PATH)) {
12-
return readFileSync(CREDENTIALS_PATH, 'utf8').trim();
5+
function getAuthHeaders() {
6+
const creds = loadCredentials();
7+
if (!creds) {
8+
console.error('Not logged in. Run: apollo auth login');
9+
process.exit(1);
1310
}
14-
15-
console.error('No API key found. Run: apollo auth login <api-key>');
16-
process.exit(1);
11+
return { 'Authorization': `Bearer ${creds.access_token}` };
1712
}
1813

1914
export async function apolloRequest(path, body = {}) {
20-
const apiKey = getApiKey();
21-
2215
const res = await fetch(`${BASE_URL}${path}`, {
2316
method: 'POST',
2417
headers: {
2518
'Content-Type': 'application/json',
2619
'Cache-Control': 'no-cache',
27-
'X-Api-Key': apiKey,
20+
'User-Agent': 'apollo-io-cli/1.0',
21+
...getAuthHeaders(),
2822
},
2923
body: JSON.stringify(body),
3024
});

src/commands/auth.js

Lines changed: 28 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,49 @@
1-
import { mkdirSync, writeFileSync, unlinkSync, existsSync, readFileSync } from 'fs';
2-
import { join, dirname } from 'path';
3-
import { homedir } from 'os';
4-
5-
const CREDENTIALS_PATH = join(homedir(), '.config', 'apollo', 'credentials');
1+
import { saveOAuthCredentials, clearCredentials, loadCredentials } from '../credentials.js';
2+
import { oauthLogin, revokeToken } from '../oauth.js';
63

74
export function registerAuth(program) {
85
const auth = program.command('auth').description('Manage Apollo.io credentials');
96

107
auth
11-
.command('login <api-key>')
12-
.description('Save an API key to ~/.config/apollo/credentials')
13-
.action((apiKey) => {
14-
mkdirSync(dirname(CREDENTIALS_PATH), { recursive: true });
15-
writeFileSync(CREDENTIALS_PATH, apiKey, { mode: 0o600 });
16-
console.log(`Saved API key to ${CREDENTIALS_PATH}`);
8+
.command('login')
9+
.description('Log in to Apollo.io via browser')
10+
.action(async () => {
11+
try {
12+
const tokens = await oauthLogin();
13+
saveOAuthCredentials(tokens);
14+
console.log('Successfully logged in.');
15+
process.exit(0);
16+
} catch (err) {
17+
console.error(`Login failed: ${err.message}`);
18+
process.exit(1);
19+
}
1720
});
1821

1922
auth
2023
.command('logout')
2124
.description('Remove saved credentials')
22-
.action(() => {
23-
if (existsSync(CREDENTIALS_PATH)) {
24-
unlinkSync(CREDENTIALS_PATH);
25-
console.log('Credentials removed.');
26-
} else {
25+
.action(async () => {
26+
const creds = loadCredentials();
27+
if (!creds) {
2728
console.log('No credentials found.');
29+
return;
30+
}
31+
if (creds.type === 'oauth' && creds.access_token) {
32+
await revokeToken(creds.access_token, creds.client_id).catch(() => {});
2833
}
34+
clearCredentials();
35+
console.log('Logged out.');
2936
});
3037

3138
auth
3239
.command('whoami')
33-
.description('Show which API key is active')
40+
.description('Show current credentials')
3441
.action(() => {
35-
if (process.env.APOLLO_API_KEY) {
36-
console.log(`APOLLO_API_KEY env var (${process.env.APOLLO_API_KEY.slice(0, 8)}...)`);
37-
} else if (existsSync(CREDENTIALS_PATH)) {
38-
const key = readFileSync(CREDENTIALS_PATH, 'utf8').trim();
39-
console.log(`~/.config/apollo/credentials (${key.slice(0, 8)}...)`);
42+
const creds = loadCredentials();
43+
if (!creds) {
44+
console.log('Not logged in.');
4045
} else {
41-
console.log('No credentials found.');
46+
console.log('Logged in via OAuth.');
4247
}
4348
});
4449
}

src/credentials.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { mkdirSync, writeFileSync, readFileSync, existsSync, unlinkSync } from 'fs';
2+
import { join, dirname } from 'path';
3+
import { homedir } from 'os';
4+
5+
export const CREDENTIALS_PATH = join(homedir(), '.config', 'apollo', 'credentials');
6+
7+
export function saveOAuthCredentials({ clientId, access_token, refresh_token, expires_in }) {
8+
mkdirSync(dirname(CREDENTIALS_PATH), { recursive: true });
9+
writeFileSync(CREDENTIALS_PATH, JSON.stringify({
10+
type: 'oauth',
11+
access_token,
12+
refresh_token,
13+
client_id: clientId,
14+
expires_at: expires_in ? Date.now() + expires_in * 1000 : null,
15+
}), { mode: 0o600 });
16+
}
17+
18+
export function loadCredentials() {
19+
if (!existsSync(CREDENTIALS_PATH)) return null;
20+
return JSON.parse(readFileSync(CREDENTIALS_PATH, 'utf8'));
21+
}
22+
23+
export function clearCredentials() {
24+
if (existsSync(CREDENTIALS_PATH)) unlinkSync(CREDENTIALS_PATH);
25+
}

src/oauth.js

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import http from 'http';
2+
import crypto from 'crypto';
3+
import { execSync } from 'child_process';
4+
5+
const APOLLO_BASE = 'https://api.apollo.io';
6+
const APOLLO_MCP_BASE = 'https://mcp.apollo.io';
7+
const REDIRECT_PORT = 3421;
8+
const REDIRECT_URI = `http://localhost:${REDIRECT_PORT}/callback`;
9+
const SCOPES = 'people_bulk_match organizations_bulk_enrich organizations_enrich people_match mixed_people_search mixed_people_api_search mixed_companies_search contact_write contact_update contacts_search contact_read account_write account_update emailer_campaigns_search emailer_campaigns_add_contact_ids emailer_campaigns_remove_or_stop_contact_ids email_accounts_list read_user_profile';
10+
const TIMEOUT_MS = 5 * 60 * 1000;
11+
12+
function generatePKCE() {
13+
const verifier = crypto.randomBytes(32).toString('base64url');
14+
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
15+
return { verifier, challenge };
16+
}
17+
18+
async function registerClient() {
19+
const res = await fetch(`${APOLLO_MCP_BASE}/api/v1/oauth/applications/register_oauth_client`, {
20+
method: 'POST',
21+
headers: { 'Content-Type': 'application/json', 'User-Agent': 'Apollo-MCP/1.0' },
22+
body: JSON.stringify({
23+
client_name: 'Apollo CLI',
24+
redirect_uris: [REDIRECT_URI],
25+
token_endpoint_auth_method: 'none',
26+
grant_types: ['authorization_code'],
27+
response_types: ['code'],
28+
scope: SCOPES,
29+
}),
30+
});
31+
if (!res.ok) throw new Error(`Client registration failed: ${await res.text()}`);
32+
const data = await res.json();
33+
return data.client_id;
34+
}
35+
36+
async function exchangeCode(code, clientId, verifier) {
37+
const res = await fetch(`${APOLLO_MCP_BASE}/api/v1/oauth/token`, {
38+
method: 'POST',
39+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
40+
body: new URLSearchParams({
41+
grant_type: 'authorization_code',
42+
code,
43+
client_id: clientId,
44+
redirect_uri: REDIRECT_URI,
45+
code_verifier: verifier,
46+
}),
47+
});
48+
if (!res.ok) throw new Error(`Token exchange failed: ${await res.text()}`);
49+
return res.json();
50+
}
51+
52+
export async function revokeToken(accessToken, clientId) {
53+
await fetch(`${APOLLO_MCP_BASE}/api/v1/oauth/revoke`, {
54+
method: 'POST',
55+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
56+
body: new URLSearchParams({ token: accessToken, client_id: clientId }),
57+
});
58+
}
59+
60+
function openBrowser(url) {
61+
if (process.platform === 'darwin') execSync(`open "${url}"`);
62+
else if (process.platform === 'win32') execSync(`start "" "${url}"`);
63+
else execSync(`xdg-open "${url}"`);
64+
}
65+
66+
export async function oauthLogin() {
67+
const clientId = await registerClient();
68+
const { verifier, challenge } = generatePKCE();
69+
const state = crypto.randomBytes(16).toString('hex');
70+
71+
const params = new URLSearchParams({
72+
client_id: clientId,
73+
redirect_uri: REDIRECT_URI,
74+
response_type: 'code',
75+
scope: SCOPES,
76+
code_challenge: challenge,
77+
code_challenge_method: 'S256',
78+
state,
79+
});
80+
81+
const authUrl = `${APOLLO_MCP_BASE}/mcp/oauth_metadata/redirect_to_authorize?${params}`;
82+
83+
return new Promise((resolve, reject) => {
84+
let settled = false;
85+
86+
const done = (fn) => {
87+
if (settled) return;
88+
settled = true;
89+
server.close();
90+
fn();
91+
};
92+
93+
const server = http.createServer(async (req, res) => {
94+
const url = new URL(req.url, `http://localhost:${REDIRECT_PORT}`);
95+
if (url.pathname !== '/callback') return;
96+
97+
const code = url.searchParams.get('code');
98+
const returnedState = url.searchParams.get('state');
99+
const error = url.searchParams.get('error');
100+
101+
res.writeHead(200, { 'Content-Type': 'text/html' });
102+
res.end('<html><body><h2>Authorization complete. You can close this tab.</h2></body></html>');
103+
104+
if (error) return done(() => reject(new Error(`Authorization denied: ${error}`)));
105+
if (returnedState !== state) return done(() => reject(new Error('State mismatch — possible CSRF attack')));
106+
if (!code) return done(() => reject(new Error('No authorization code received')));
107+
108+
try {
109+
const tokens = await exchangeCode(code, clientId, verifier);
110+
done(() => resolve({ clientId, ...tokens }));
111+
} catch (err) {
112+
done(() => reject(err));
113+
}
114+
});
115+
116+
server.on('error', (err) => done(() => reject(err)));
117+
118+
server.listen(REDIRECT_PORT, () => {
119+
console.log('Opening browser for Apollo login...');
120+
try {
121+
openBrowser(authUrl);
122+
} catch {
123+
console.log(`\nCould not open browser automatically. Visit:\n${authUrl}\n`);
124+
}
125+
console.log('Waiting for authorization...');
126+
});
127+
128+
setTimeout(() => done(() => reject(new Error('Authorization timed out'))), TIMEOUT_MS);
129+
});
130+
}

0 commit comments

Comments
 (0)