Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 30 additions & 8 deletions src/lib/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { execFile } = require('node:child_process');
const { randomBytes } = require('node:crypto');

const authSuccessHtml = require('../templates/auth-success.html');
const authErrorHtml = require('../templates/auth-error.html');
Expand All @@ -11,9 +12,9 @@ const SETTINGS_DIR = path.join(os.homedir(), '.supermemory-claude');
const CREDENTIALS_FILE = path.join(SETTINGS_DIR, 'credentials.json');

const AUTH_BASE_URL =
process.env.SUPERMEMORY_AUTH_URL || 'https://app.supermemory.ai/auth/connect';
const AUTH_PORT = 19876;
const AUTH_TIMEOUT = 25000;
process.env.SUPERMEMORY_AUTH_URL ||
'https://console.supermemory.ai/auth/agent-connect';
const AUTH_TIMEOUT = Number(process.env.SUPERMEMORY_AUTH_TIMEOUT) || 60000;

function ensureDir() {
if (!fs.existsSync(SETTINGS_DIR)) {
Expand Down Expand Up @@ -64,11 +65,19 @@ function openBrowser(url) {
function startAuthFlow() {
return new Promise((resolve, reject) => {
let resolved = false;
const stateToken = randomBytes(16).toString('hex');

const server = http.createServer((req, res) => {
const url = new URL(req.url, `http://localhost:${AUTH_PORT}`);
const url = new URL(req.url, 'http://localhost');

if (url.pathname === '/callback') {
const callbackState = url.searchParams.get('state');
if (callbackState !== stateToken) {
res.writeHead(403, { 'Content-Type': 'text/html' });
res.end(authErrorHtml);
return;
}

const apiKey =
url.searchParams.get('apikey') || url.searchParams.get('api_key');

Expand All @@ -77,6 +86,7 @@ function startAuthFlow() {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(authSuccessHtml);
resolved = true;
clearTimeout(timer);
server.close();
resolve(apiKey);
} else {
Expand All @@ -89,19 +99,31 @@ function startAuthFlow() {
}
});

server.listen(AUTH_PORT, '127.0.0.1', () => {
const callbackUrl = `http://localhost:${AUTH_PORT}/callback`;
const authUrl = `${AUTH_BASE_URL}?callback=${encodeURIComponent(callbackUrl)}&client=claude_code`;
// Listen on an ephemeral port; embed state token in callback URL so the
// console redirects it back and the CSRF check passes.
server.listen(0, '127.0.0.1', () => {
const { port } = server.address();
const callbackUrl = `http://localhost:${port}/callback?state=${stateToken}`;
const params = new URLSearchParams({
callback: callbackUrl,
client: 'claude-code',
hostname: os.hostname(),
os: `${process.platform}-${os.arch()}`,
cwd: process.cwd(),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium -- cwd and hostname leak local machine metadata in the browser URL.

hostname, cwd, and os are sent as query parameters on a GET request opened in the user's browser. This means they can end up in browser history, proxy logs, server access logs, and referrer headers. cwd is especially sensitive -- it often contains internal repo names, customer names, or usernames (e.g. /home/jdoe/acme-corp/secret-project).

Consider whether these params are strictly necessary. If they are needed server-side, sending them in a POST from the local server (rather than embedding in the browser URL) would avoid the exposure.

cli_version: '1.0.0',
});
const authUrl = `${AUTH_BASE_URL}?${params.toString()}`;
openBrowser(authUrl);
Comment on lines +104 to 116
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium -- the manual-visit fallback in context-hook.js:38 is now broken.

When openBrowser() fails (headless Linux, SSH sessions, missing xdg-open), the error message in context-hook.js tells users to visit AUTH_BASE_URL directly. But the new flow requires the ephemeral port and state token in the callback URL -- visiting the bare AUTH_BASE_URL cannot complete the local callback handshake.

The fallback at context-hook.js:38:

If the browser did not open, visit: ${AUTH_BASE_URL}

needs to be updated to include the full authUrl (with callback + state params), or startAuthFlow needs to surface the constructed URL so the caller can display it.

});

server.on('error', (err) => {
if (!resolved) {
clearTimeout(timer);
reject(new Error(`Failed to start auth server: ${err.message}`));
}
});

setTimeout(() => {
const timer = setTimeout(() => {
if (!resolved) {
server.close();
reject(new Error('AUTH_TIMEOUT'));
Expand Down
Loading