diff --git a/README.md b/README.md index 941e9b9..e82766a 100644 --- a/README.md +++ b/README.md @@ -45,11 +45,21 @@ Muse runs hooks with a cleared environment, so the API key is read from a file, ## Auth -The first session opens a browser login and writes `~/.supermemory-muse/credentials.json`. +The first session opens a browser login (OAuth with PKCE) and writes `~/.supermemory-muse/credentials.json`. Tokens refresh automatically. -If you already logged in through the Claude Code plugin, that key is reused from `~/.supermemory-claude/credentials.json`. +Log in, out, or check status explicitly from the plugin directory: -Or write the file yourself: +```bash +node hooks/lib/auth.js login # also migrates a saved API key to OAuth +node hooks/lib/auth.js logout # revokes the token, removes Muse credentials only +node hooks/lib/auth.js status +``` + +For a custom backend set `baseUrl` (project config) or `SUPERMEMORY_API_URL`; when that host does not start with `api.`, also set `SUPERMEMORY_MCP_URL`. + +A legacy API key saved by the Claude Code plugin in `~/.supermemory-claude/credentials.json` is still reused. Claude Code's OAuth tokens are not shared. + +Or use an API key: write the file yourself: ```json { "apiKey": "sm_..." } @@ -63,7 +73,7 @@ Get a key at [app.supermemory.ai](https://app.supermemory.ai). | --- | --- | | `/supermemory:index` | Index codebase architecture and patterns | | `/supermemory:status` | Auth, container tag, and API reachability | -| `/supermemory:logout` | Clear saved Muse credentials | +| `/supermemory:logout` | Revoke and clear saved Muse credentials | ## Configuration diff --git a/commands/logout.md b/commands/logout.md index b90cce0..083e993 100644 --- a/commands/logout.md +++ b/commands/logout.md @@ -2,6 +2,11 @@ description: Clear saved Supermemory credentials for Muse Code --- -Delete `~/.supermemory-muse/credentials.json` if it exists. Do not delete `~/.supermemory-claude/credentials.json` unless the user explicitly asks to log out of Claude Code too. +Log the user out of Supermemory for Muse Code. -Confirm the file is gone and tell the user the next Muse session will prompt them to log in again. +1. Locate this plugin's `hooks/lib/auth.js` (in the installed supermemory plugin directory, next to `.muse-plugin/plugin.json`) and run `node /hooks/lib/auth.js logout`. It revokes the OAuth refresh token and removes `~/.supermemory-muse/credentials.json`. Never print the file's contents or any token. +2. If the script cannot be found, delete `~/.supermemory-muse/credentials.json` instead. + +Do not delete `~/.supermemory-claude/credentials.json` unless the user explicitly asks to log out of Claude Code too. + +Confirm the file is gone and tell the user the next Muse session will open the browser login again (if `~/.supermemory-claude/credentials.json` holds a legacy API key, that key is reused instead). diff --git a/commands/status.md b/commands/status.md index fed20cf..02156e9 100644 --- a/commands/status.md +++ b/commands/status.md @@ -4,8 +4,8 @@ description: Show Supermemory authentication and connection status Report the user's Supermemory status for this Muse Code session. -1. Read `~/.supermemory-muse/credentials.json` (may not exist). Never print the full API key — show at most the first 6 and last 4 characters. Also check `~/.supermemory-claude/credentials.json` as a fallback. The key source is env `SUPERMEMORY_API_KEY` / `SUPERMEMORY_MUSE_API_KEY` when set, otherwise those files. -2. Probe real connectivity with the resolved key: +1. Determine the credential source. Env `SUPERMEMORY_MUSE_API_KEY` / `SUPERMEMORY_API_KEY` / `SUPERMEMORY_CC_API_KEY` and a project `apiKey` take precedence. Otherwise run `node /hooks/lib/auth.js status` from this plugin's directory (next to `.muse-plugin/plugin.json`); it prints only the auth type (`oauth`, `api_key` or `none`) and the OAuth client ID. If you read `~/.supermemory-muse/credentials.json` yourself, never print `accessToken`, `refreshToken` or a full API key — show at most the first 6 and last 4 characters of an API key. A legacy `{"apiKey":...}` in `~/.supermemory-claude/credentials.json` is used as a fallback; OAuth tokens in that file belong to Claude Code and are never used. +2. For an API key, probe real connectivity with it (skip this for OAuth; the MCP check below covers it): ``` curl -sS -o /dev/null -w '%{http_code}' -m 8 -X POST "${SUPERMEMORY_API_URL:-https://api.supermemory.ai}/v4/profile" \ @@ -18,4 +18,4 @@ Interpret loudly: `200` → reachable and the key works; `401`/`403` → reachab 3. Call the `whoAmI` MCP tool if the supermemory MCP server is connected. 4. Report: authenticated or not, key source, the active project container tag, API reachability, and MCP reachability. -If not authenticated, tell the user a new session will open the browser login automatically, or they can write `{"apiKey":"sm_..."}` to `~/.supermemory-muse/credentials.json`. +If not authenticated, tell the user to run `node /hooks/lib/auth.js login` (or start a new session, which opens the browser login automatically), or to write `{"apiKey":"sm_..."}` to `~/.supermemory-muse/credentials.json`. diff --git a/hooks/capture.js b/hooks/capture.js index 49ef6a5..3663105 100644 --- a/hooks/capture.js +++ b/hooks/capture.js @@ -5,7 +5,8 @@ const { getProjectName, } = require('./lib/container-tag'); const { loadProjectConfig } = require('./lib/project-config'); -const { loadSettings, getApiKey, getBaseUrl, debugLog } = require('./lib/settings'); +const { loadSettings, getAuthToken, getBaseUrl, debugLog } = require('./lib/settings'); +const { settleAuth } = require('./lib/auth'); const { readStdin, writeOutput } = require('./lib/stdin'); const { formatNewEntries, @@ -32,7 +33,7 @@ async function main() { let apiKey; try { - apiKey = getApiKey(cwd, projectConfig); + apiKey = await getAuthToken(cwd, projectConfig); } catch { writeOutput({ continue: true }); return; @@ -84,7 +85,8 @@ async function main() { } } -main().catch((err) => { +main().catch(async (err) => { console.error(`Supermemory fatal: ${err.message}`); + await settleAuth(); process.exit(1); }); diff --git a/hooks/lib/auth.js b/hooks/lib/auth.js index 1652072..9809eeb 100644 --- a/hooks/lib/auth.js +++ b/hooks/lib/auth.js @@ -1,10 +1,13 @@ const { execFile } = require('node:child_process'); -const http = require('node:http'); +const crypto = require('node:crypto'); const fs = require('node:fs'); +const http = require('node:http'); const path = require('node:path'); const { settingsDir, credentialsFile, + oauthClientFile, + authLockFile, claudeCredentialsFile, } = require('./paths'); @@ -16,11 +19,28 @@ const authErrorHtml = fs.readFileSync( path.join(__dirname, '../templates/auth-error.html'), 'utf-8', ); +// The browser only ever sees these fixed strings; error details stay in the +// terminal so a response body can never leak into the page. +function renderAuthError(error) { + const denied = error?.code === 'access_denied'; + return authErrorHtml + .replace('{{title}}', denied ? 'Authorization Denied' : 'Connection Failed') + .replace( + '{{message}}', + denied + ? 'You declined access, so Supermemory was not connected. Return to Muse Code and log in again to retry.' + : 'Supermemory could not finish signing you in. Return to Muse Code and log in again.', + ); +} -const AUTH_BASE_URL = - process.env.SUPERMEMORY_AUTH_URL || 'https://console.supermemory.ai/auth/connect'; -const AUTH_PORT = 19877; +const AUTH_PORT = 19881; const AUTH_TIMEOUT = 25000; +const REFRESH_TIMEOUT = 3000; +const REFRESH_REQUEST_TIMEOUT = 10000; +const REFRESH_SKEW = 60000; +const REDIRECT_URI = `http://127.0.0.1:${AUTH_PORT}/callback`; +const SCOPE = 'openid profile email offline_access'; +const LOGIN_COMMAND = `node "${path.join(__dirname, 'auth.js')}" login`; function execFileAsync(command, args) { return new Promise((resolve, reject) => { @@ -54,102 +74,593 @@ async function openUrl(url) { } function ensureDir() { - fs.mkdirSync(settingsDir(), { recursive: true, mode: 0o700 }); + const dir = settingsDir(); + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + try { + fs.chmodSync(dir, 0o700); + } catch {} } -function readCredentialFile(file) { +function readJson(file) { try { - if (!fs.existsSync(file)) return null; const data = JSON.parse(fs.readFileSync(file, 'utf-8')); - return data?.apiKey ? data : null; + return data && typeof data === 'object' && !Array.isArray(data) + ? data + : null; } catch { return null; } } +// The Claude Code plugin's file is only a legacy API key fallback: its OAuth +// tokens belong to a different client and refresh lock, so never use them. function loadCredentials() { - return ( - readCredentialFile(credentialsFile()) || - readCredentialFile(claudeCredentialsFile()) - ); + const own = readJson(credentialsFile()); + if (own?.type === 'oauth' || own?.apiKey) return own; + const claude = readJson(claudeCredentialsFile()); + if (claude && claude.type === undefined && claude.apiKey) { + return { apiKey: claude.apiKey, savedAt: claude.savedAt }; + } + return null; } -function saveCredentials(apiKey) { +function writeJson(file, data) { ensureDir(); - fs.writeFileSync( - credentialsFile(), - `${JSON.stringify({ apiKey, savedAt: new Date().toISOString() }, null, 2)}\n`, - { mode: 0o600 }, - ); + const temporary = `${file}.${crypto.randomUUID()}.tmp`; + try { + fs.writeFileSync(temporary, `${JSON.stringify(data, null, 2)}\n`, { + mode: 0o600, + flag: 'wx', + }); + fs.renameSync(temporary, file); + } finally { + fs.rmSync(temporary, { force: true }); + } } -function clearCredentials() { - try { - const file = credentialsFile(); - if (fs.existsSync(file)) fs.unlinkSync(file); - } catch {} +function saveCredentials(credentials) { + writeJson(credentialsFile(), { + ...(typeof credentials === 'string' + ? { apiKey: credentials } + : credentials), + savedAt: new Date().toISOString(), + }); } -function startAuthFlow() { - return new Promise((resolve, reject) => { - let resolved = false; - - const server = http.createServer((req, res) => { - const url = new URL(req.url, `http://localhost:${AUTH_PORT}`); - - if (url.pathname === '/callback') { - const apiKey = - url.searchParams.get('apikey') || url.searchParams.get('api_key'); - - if (apiKey?.startsWith('sm_')) { - saveCredentials(apiKey); - res.writeHead(200, { 'Content-Type': 'text/html' }); - res.end(authSuccessHtml); - resolved = true; - server.close(); - resolve(apiKey); - } else { - res.writeHead(400, { 'Content-Type': 'text/html' }); - res.end(authErrorHtml); - } - } else { - res.writeHead(404); - res.end('Not found'); +// Hooks and the MCP proxy are separate processes. Re-read credentials under +// the lock before spending a single-use refresh token. +function recoverAbandonedLock() { + const lockFile = authLockFile(); + let recovery; + let linked = false; + try { + const original = fs.statSync(lockFile); + // Only one process may reclaim a particular inode. Re-check the inode after + // linking: another process may already have replaced the abandoned lock. + recovery = `${lockFile}.${original.dev}.${original.ino}.recovery`; + fs.linkSync(lockFile, recovery); + linked = true; + const snapshot = fs.statSync(recovery); + if (snapshot.ino !== original.ino || snapshot.dev !== original.dev) return; + const owner = readJson(recovery); + let abandoned = false; + if (Number.isInteger(owner?.pid) && owner.pid > 0) { + try { + process.kill(owner.pid, 0); + } catch (error) { + abandoned = error.code === 'ESRCH'; } - }); + } else { + abandoned = Date.now() - snapshot.mtimeMs > AUTH_TIMEOUT * 2; + } + if (abandoned) { + const current = fs.statSync(lockFile); + if (current.ino === original.ino && current.dev === original.dev) { + fs.unlinkSync(lockFile); + } + } + } catch (error) { + if (!['ENOENT', 'EEXIST'].includes(error.code)) throw error; + } finally { + if (linked) fs.rmSync(recovery, { force: true }); + } +} - server.listen(AUTH_PORT, '127.0.0.1', () => { - const callbackUrl = `http://localhost:${AUTH_PORT}/callback`; - const authUrl = `${AUTH_BASE_URL}?callback=${encodeURIComponent(callbackUrl)}&client=muse_code`; - openUrl(authUrl).catch((error) => { - if (!resolved) { - server.close(); - reject(new Error(`Failed to open browser: ${error.message}`)); - } - }); - }); +async function withAuthLock(action, timeoutMs) { + ensureDir(); + const lockFile = authLockFile(); + const deadline = Date.now() + timeoutMs; + let descriptor; + while (descriptor === undefined) { + try { + descriptor = fs.openSync(lockFile, 'wx', 0o600); + } catch (error) { + if (error.code !== 'EEXIST') throw error; + recoverAbandonedLock(); + if (Date.now() >= deadline) + throw new Error('Supermemory authentication is busy. Try again.'); + await new Promise((resolve) => setTimeout(resolve, 50)); + } + } + try { + fs.writeFileSync(descriptor, JSON.stringify({ pid: process.pid })); + return await action(Math.max(1, deadline - Date.now())); + } finally { + fs.closeSync(descriptor); + fs.rmSync(lockFile, { force: true }); + } +} - server.on('error', (err) => { - if (!resolved) { - reject(new Error(`Failed to start auth server: ${err.message}`)); - } - }); +function secureUrl(value) { + const url = new URL(value); + const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname); + if ( + url.username || + url.password || + url.hash || + url.search || + (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) + ) { + throw new Error( + 'OAuth requires HTTPS (HTTP is allowed only for loopback development).', + ); + } + return url.toString().replace(/\/+$/, ''); +} - setTimeout(() => { - if (!resolved) { - server.close(); - reject(new Error('AUTH_TIMEOUT')); +function getOAuthConfig(baseUrl, mcpUrl) { + const apiBaseUrl = secureUrl(baseUrl); + const issuer = secureUrl( + process.env.SUPERMEMORY_OAUTH_ISSUER || `${apiBaseUrl}/api/auth`, + ); + return { apiBaseUrl, issuer, resource: secureUrl(mcpUrl) }; +} + +function sameConfig(credentials, config) { + return ['apiBaseUrl', 'issuer', 'resource'].every( + (key) => credentials?.[key] === config[key], + ); +} + +function issuerEndpoint(value, issuer) { + const endpoint = secureUrl(value); + if (new URL(endpoint).origin !== new URL(issuer).origin) { + throw new Error('OAuth endpoint does not belong to the configured issuer.'); + } + return endpoint; +} + +// RFC 8414: the well-known segment goes between the origin and the issuer path. +function discoveryUrl(issuer) { + const url = new URL(issuer); + return new URL( + `/.well-known/oauth-authorization-server${url.pathname.replace(/\/+$/, '')}`, + url.origin, + ); +} + +async function oauthRequest(url, options) { + const response = await fetch(url, { ...options, redirect: 'error' }); + const body = await response.json().catch(() => null); + const data = body && typeof body === 'object' ? body : {}; + if (!response.ok) { + // Never include response bodies or tokens in hook output. + const code = ['invalid_grant', 'invalid_client', 'access_denied'].includes( + data.error, + ) + ? data.error + : 'oauth_request_failed'; + throw Object.assign( + new Error( + `Supermemory authorization failed (${code}, HTTP ${response.status}).`, + ), + { code, status: response.status }, + ); + } + return data; +} + +async function registerClient(config, signal) { + const clientFile = oauthClientFile(); + const saved = readJson(clientFile); + if ( + sameConfig(saved, config) && + saved.clientId && + saved.redirectUri === REDIRECT_URI + ) + return saved; + const metadata = await oauthRequest(discoveryUrl(config.issuer), { signal }); + if (metadata.issuer !== config.issuer) + throw new Error('OAuth discovery returned an unexpected issuer.'); + const authorizationEndpoint = issuerEndpoint( + metadata.authorization_endpoint, + config.issuer, + ); + const tokenEndpoint = issuerEndpoint(metadata.token_endpoint, config.issuer); + const registrationEndpoint = issuerEndpoint( + metadata.registration_endpoint, + config.issuer, + ); + const revocationEndpoint = issuerEndpoint( + metadata.revocation_endpoint, + config.issuer, + ); + const registration = await oauthRequest(registrationEndpoint, { + method: 'POST', + signal, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + client_name: 'Supermemory for Muse Code', + client_uri: 'https://github.com/supermemoryai/muse-supermemory', + software_id: 'muse-supermemory', + software_version: require('../../.muse-plugin/plugin.json').version, + redirect_uris: [REDIRECT_URI], + token_endpoint_auth_method: 'none', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + scope: SCOPE, + }), + }); + if ( + typeof registration.client_id !== 'string' || + !registration.client_id || + registration.token_endpoint_auth_method !== 'none' + ) { + throw new Error('OAuth registration did not return a public client.'); + } + const client = { + ...config, + clientId: registration.client_id, + redirectUri: REDIRECT_URI, + authorizationEndpoint, + tokenEndpoint, + revocationEndpoint, + }; + signal.throwIfAborted(); + writeJson(clientFile, client); + return client; +} + +async function exchangeToken(client, parameters, signal, previous) { + const data = await oauthRequest( + issuerEndpoint(client.tokenEndpoint, client.issuer), + { + method: 'POST', + signal, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: client.clientId, + resource: client.resource, + ...parameters, + }), + }, + ); + const refreshToken = data.refresh_token ?? previous?.refreshToken; + if ( + typeof data.access_token !== 'string' || + !data.access_token || + typeof data.token_type !== 'string' || + data.token_type.toLowerCase() !== 'bearer' || + typeof data.expires_in !== 'number' || + !Number.isFinite(data.expires_in) || + data.expires_in <= 0 || + typeof refreshToken !== 'string' || + !refreshToken + ) { + throw new Error('OAuth returned an invalid token response.'); + } + signal.throwIfAborted(); + saveCredentials({ + type: 'oauth', + apiBaseUrl: client.apiBaseUrl, + issuer: client.issuer, + resource: client.resource, + clientId: client.clientId, + redirectUri: client.redirectUri, + authorizationEndpoint: client.authorizationEndpoint, + tokenEndpoint: client.tokenEndpoint, + revocationEndpoint: client.revocationEndpoint, + accessToken: data.access_token, + refreshToken, + expiresAt: Date.now() + data.expires_in * 1000, + }); + return data.access_token; +} + +function authRequired() { + return Object.assign(new Error('AUTH_REQUIRED'), { code: 'AUTH_REQUIRED' }); +} + +function ownOAuthCredentials(config) { + const credentials = readJson(credentialsFile()); + return credentials?.type === 'oauth' && sameConfig(credentials, config) + ? credentials + : null; +} + +// Never abort a refresh mid-flight: the server would rotate a refresh token we +// never stored, and the next refresh would fail with invalid_grant. +let pendingRefresh = null; + +function refreshCredentials(config) { + if (pendingRefresh) return pendingRefresh; + pendingRefresh = withAuthLock(async (remaining) => { + const credentials = ownOAuthCredentials(config); + if (!credentials) throw authRequired(); + if ( + credentials.accessToken && + credentials.expiresAt > Date.now() + REFRESH_SKEW + ) + return credentials.accessToken; + if (!credentials.refreshToken) throw authRequired(); + try { + return await exchangeToken( + credentials, + { + grant_type: 'refresh_token', + refresh_token: credentials.refreshToken, + }, + AbortSignal.timeout(remaining), + credentials, + ); + } catch (error) { + if (error.code === 'invalid_grant' || error.code === 'invalid_client') { + const registration = { ...credentials }; + delete registration.accessToken; + delete registration.refreshToken; + saveCredentials(registration); + if (error.code === 'invalid_client') + fs.rmSync(oauthClientFile(), { force: true }); + throw authRequired(); } - }, AUTH_TIMEOUT); + throw error; + } + }, REFRESH_REQUEST_TIMEOUT).finally(() => { + pendingRefresh = null; + }); + // Callers may stop waiting before the refresh settles. + pendingRefresh.catch(() => {}); + return pendingRefresh; +} + +// Bounds only how long the caller waits; the refresh itself keeps running. +function waitFor(promise, timeoutMs) { + let timer; + const expired = new Promise((_, reject) => { + timer = setTimeout(() => { + reject( + Object.assign( + new Error('Supermemory token refresh is still in progress.'), + { code: 'AUTH_PENDING', name: 'TimeoutError' }, + ), + ); + }, timeoutMs); }); + return Promise.race([promise, expired]).finally(() => clearTimeout(timer)); +} + +async function getAccessToken(config, timeoutMs = REFRESH_TIMEOUT) { + const saved = ownOAuthCredentials(config); + if (!saved) throw authRequired(); + if (saved.accessToken && saved.expiresAt > Date.now() + REFRESH_SKEW) + return saved.accessToken; + return waitFor(refreshCredentials(config), timeoutMs); +} + +// Resolves once any in-flight refresh has saved or failed, so a process that +// exits explicitly can let the rotated tokens land first. +async function settleAuth() { + while (pendingRefresh) { + await pendingRefresh.catch(() => {}); + } +} + +// Removes only Muse's credentials; the Claude Code plugin's file is untouched. +async function clearCredentials() { + return withAuthLock(async (remaining) => { + const credentials = readJson(credentialsFile()); + if (credentials?.type === 'oauth' && credentials.refreshToken) { + await oauthRequest( + issuerEndpoint(credentials.revocationEndpoint, credentials.issuer), + { + method: 'POST', + signal: AbortSignal.timeout(remaining), + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: credentials.clientId, + token: credentials.refreshToken, + token_type_hint: 'refresh_token', + }), + }, + ); + } + fs.rmSync(credentialsFile(), { force: true }); + }, REFRESH_TIMEOUT); +} + +async function startAuthFlow( + config, + { force = false, timeoutMs = AUTH_TIMEOUT } = {}, +) { + return withAuthLock(async (remaining) => { + const current = ownOAuthCredentials(config); + if ( + !force && + current?.accessToken && + current.expiresAt > Date.now() + REFRESH_SKEW + ) + return current.accessToken; + const controller = new AbortController(); + const timer = setTimeout( + () => controller.abort(new Error('AUTH_TIMEOUT')), + remaining, + ); + let server; + try { + const client = await registerClient(config, controller.signal); + const state = crypto.randomBytes(32).toString('base64url'); + const verifier = crypto.randomBytes(32).toString('base64url'); + const authorizationUrl = new URL( + issuerEndpoint(client.authorizationEndpoint, client.issuer), + ); + authorizationUrl.search = new URLSearchParams({ + client_id: client.clientId, + redirect_uri: REDIRECT_URI, + response_type: 'code', + scope: SCOPE, + resource: client.resource, + code_challenge: crypto + .createHash('sha256') + .update(verifier) + .digest('base64url'), + code_challenge_method: 'S256', + state, + prompt: 'consent', + }).toString(); + return await new Promise((resolve, reject) => { + let exchanging = false; + controller.signal.addEventListener( + 'abort', + () => reject(controller.signal.reason), + { once: true }, + ); + server = http.createServer(async (req, res) => { + res.setHeader('Cache-Control', 'no-store'); + res.setHeader('Referrer-Policy', 'no-referrer'); + let url; + try { + url = new URL(req.url, REDIRECT_URI); + } catch { + res.writeHead(400).end(); + return; + } + if (req.method !== 'GET' || url.pathname !== '/callback') { + res.writeHead(404).end(); + return; + } + const receivedState = Buffer.from( + url.searchParams.get('state') || '', + ); + if ( + receivedState.length !== state.length || + !crypto.timingSafeEqual(receivedState, Buffer.from(state)) || + (url.searchParams.has('iss') && + url.searchParams.get('iss') !== client.issuer) + ) { + res.writeHead(400).end('Invalid authorization state.'); + return; + } + if (exchanging) { + res.writeHead(409).end(); + return; + } + exchanging = true; + try { + if (url.searchParams.has('error')) + throw Object.assign(new Error('Authorization was denied.'), { + code: 'access_denied', + }); + const code = url.searchParams.get('code'); + if (!code) throw new Error('Authorization code missing.'); + const token = await exchangeToken( + client, + { + grant_type: 'authorization_code', + code, + code_verifier: verifier, + redirect_uri: REDIRECT_URI, + }, + controller.signal, + ); + res + .writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }) + .end(authSuccessHtml, () => resolve(token)); + } catch (error) { + res + .writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }) + .end(renderAuthError(error)); + if (error.code === 'invalid_client' && !controller.signal.aborted) + fs.rmSync(oauthClientFile(), { force: true }); + reject(error); + } + }); + server.on('error', () => + reject( + new Error( + 'Cannot open the authentication callback port. Retry after other logins finish.', + ), + ), + ); + server.listen(AUTH_PORT, '127.0.0.1', () => { + openUrl(authorizationUrl).catch(() => + reject( + new Error( + 'Failed to open the browser. Retry login from an interactive terminal.', + ), + ), + ); + }); + }); + } finally { + clearTimeout(timer); + controller.abort(); + server?.close(); + server?.closeAllConnections(); + } + }, timeoutMs); } module.exports = { - AUTH_BASE_URL, + LOGIN_COMMAND, credentialsFile, loadCredentials, saveCredentials, clearCredentials, + getOAuthConfig, + getAccessToken, + settleAuth, startAuthFlow, openUrl, }; + +// Status must never print credentials. +if (require.main === module) { + (async () => { + const { getBaseUrl, getMcpUrl } = require('./settings'); + const command = process.argv[2]; + const cwd = process.cwd(); + if (command === 'login') { + await startAuthFlow(getOAuthConfig(getBaseUrl(cwd), getMcpUrl(cwd)), { + force: true, + timeoutMs: 5 * 60 * 1000, + }); + console.log('Supermemory connected through OAuth.'); + } else if (command === 'logout') { + await clearCredentials(); + console.log('Supermemory disconnected.'); + } else if (command === 'status') { + const credentials = loadCredentials(); + console.log( + JSON.stringify( + { + authentication: + credentials?.type === 'oauth' + ? 'oauth' + : credentials?.apiKey + ? 'api_key' + : 'none', + clientId: + credentials?.type === 'oauth' ? credentials.clientId : undefined, + }, + null, + 2, + ), + ); + } else { + throw new Error('Usage: node auth.js '); + } + })().catch((error) => { + console.error(error.message); + process.exitCode = 1; + }); +} diff --git a/hooks/lib/error-helpers.js b/hooks/lib/error-helpers.js index 1937797..e67dd79 100644 --- a/hooks/lib/error-helpers.js +++ b/hooks/lib/error-helpers.js @@ -1,3 +1,5 @@ +const path = require('node:path'); + function getUserFriendlyError(err) { const status = err?.status; @@ -12,7 +14,7 @@ function getUserFriendlyError(err) { return 'Bad request — your API key or request format may be invalid. Check your key at https://console.supermemory.ai'; } if (status === 401) { - return 'Authentication failed — your API key may be expired or revoked. Re-authenticate or check https://console.supermemory.ai'; + return `Authentication failed — your credentials may be expired or revoked. Log in again with: node "${path.join(__dirname, 'auth.js')}" login, or check your configured API key.`; } if (status === 403) { return 'Permission denied — this feature may require a different Supermemory plan. Check https://supermemory.ai/pricing'; diff --git a/hooks/lib/paths.js b/hooks/lib/paths.js index a31d97c..db07ca8 100644 --- a/hooks/lib/paths.js +++ b/hooks/lib/paths.js @@ -16,6 +16,8 @@ module.exports = { settingsDir, settingsFile: () => path.join(settingsDir(), 'settings.json'), credentialsFile: () => path.join(settingsDir(), 'credentials.json'), + oauthClientFile: () => path.join(settingsDir(), 'oauth-client.json'), + authLockFile: () => path.join(settingsDir(), 'auth.lock'), lastSessionFile: () => path.join(settingsDir(), 'last-session.json'), claudeCredentialsFile, }; diff --git a/hooks/lib/settings.js b/hooks/lib/settings.js index c5466ae..f2941f6 100644 --- a/hooks/lib/settings.js +++ b/hooks/lib/settings.js @@ -1,5 +1,5 @@ const fs = require('node:fs'); -const { loadCredentials } = require('./auth'); +const { loadCredentials, getAccessToken, getOAuthConfig } = require('./auth'); const { loadProjectConfig } = require('./project-config'); const { settingsDir, settingsFile } = require('./paths'); @@ -35,7 +35,8 @@ function envApiKey() { ); } -function getApiKey(cwd, projectConfig) { +// Resolve the bearer per request: OAuth access tokens rotate during a session. +async function getAuthToken(cwd, projectConfig, timeoutMs) { const fromEnv = envApiKey(); if (fromEnv) return fromEnv; @@ -43,9 +44,18 @@ function getApiKey(cwd, projectConfig) { if (projectConfig?.apiKey) return projectConfig.apiKey; const credentials = loadCredentials(); + if (credentials?.type === 'oauth') { + return getAccessToken( + getOAuthConfig( + getBaseUrl(cwd, projectConfig), + getMcpUrl(cwd, projectConfig), + ), + timeoutMs, + ); + } if (credentials?.apiKey) return credentials.apiKey; - throw new Error('NO_API_KEY'); + throw Object.assign(new Error('AUTH_REQUIRED'), { code: 'AUTH_REQUIRED' }); } function normalizeBaseUrl(baseUrl) { @@ -71,6 +81,23 @@ function getBaseUrl(cwd, projectConfig) { return normalized; } +function getMcpUrl(cwd, projectConfig) { + if (process.env.SUPERMEMORY_MCP_URL) { + const configured = normalizeBaseUrl(process.env.SUPERMEMORY_MCP_URL); + if (!configured) throw new Error('Invalid SUPERMEMORY_MCP_URL'); + return configured; + } + const api = new URL(getBaseUrl(cwd, projectConfig)); + if (api.hostname.startsWith('api.')) { + api.hostname = api.hostname.replace(/^api\./, 'mcp.'); + api.pathname = '/mcp'; + api.search = ''; + api.hash = ''; + return api.toString(); + } + throw new Error('Set SUPERMEMORY_MCP_URL for a custom Supermemory backend.'); +} + function debugLog(settings, message, data) { if (!settings.debug) return; const timestamp = new Date().toISOString(); @@ -94,8 +121,9 @@ module.exports = { settingsFile, DEFAULT_SETTINGS, loadSettings, - getApiKey, + getAuthToken, getBaseUrl, + getMcpUrl, debugLog, getRecallConfig, }; diff --git a/hooks/recall.js b/hooks/recall.js index 1a754ed..57771e7 100644 --- a/hooks/recall.js +++ b/hooks/recall.js @@ -8,7 +8,7 @@ const { getUserFriendlyError } = require('./lib/error-helpers'); const { loadProjectConfig } = require('./lib/project-config'); const { loadSettings, - getApiKey, + getAuthToken, getBaseUrl, debugLog, getRecallConfig, @@ -117,9 +117,10 @@ async function main() { } const projectConfig = loadProjectConfig(cwd); + const requestStarted = Date.now(); let apiKey; try { - apiKey = getApiKey(cwd, projectConfig); + apiKey = await getAuthToken(cwd, projectConfig, 1000); } catch { writeOutput({ continue: true, suppressOutput: true }); return; @@ -131,7 +132,9 @@ async function main() { apiKey, containerTag, prompt.slice(0, MAX_QUERY_LENGTH), - { timeoutMs: SEARCH_TIMEOUT_MS }, + { + timeoutMs: Math.max(1, SEARCH_TIMEOUT_MS - (Date.now() - requestStarted)), + }, ); const results = (response?.searchResults?.results || []) diff --git a/hooks/session-start.js b/hooks/session-start.js index 69dfff4..49213d8 100644 --- a/hooks/session-start.js +++ b/hooks/session-start.js @@ -1,10 +1,22 @@ const { getProfile } = require('./lib/api'); const { getContainerTag, getProjectName } = require('./lib/container-tag'); const { loadProjectConfig } = require('./lib/project-config'); -const { loadSettings, getApiKey, getBaseUrl, debugLog } = require('./lib/settings'); +const { + loadSettings, + getAuthToken, + getBaseUrl, + getMcpUrl, + debugLog, +} = require('./lib/settings'); const { BRAND, bold, gray } = require('./lib/colors'); const { readStdin, writeOutput } = require('./lib/stdin'); -const { startAuthFlow, AUTH_BASE_URL } = require('./lib/auth'); +const { + LOGIN_COMMAND, + credentialsFile, + getOAuthConfig, + settleAuth, + startAuthFlow, +} = require('./lib/auth'); const { getUserFriendlyError } = require('./lib/error-helpers'); const { lastSessionFile } = require('./lib/last-session'); const fs = require('node:fs'); @@ -71,18 +83,26 @@ async function main() { debugLog(settings, 'SessionStart', { cwd, projectName, containerTag }); + const authStartedAt = Date.now(); let apiKey; try { - apiKey = getApiKey(cwd, projectConfig); - } catch { + apiKey = await getAuthToken(cwd, projectConfig); + } catch (error) { + if (error.code !== 'AUTH_REQUIRED') throw error; try { - apiKey = await startAuthFlow(); + apiKey = await startAuthFlow( + getOAuthConfig( + getBaseUrl(cwd, projectConfig), + getMcpUrl(cwd, projectConfig), + ), + { timeoutMs: Math.max(1, 25000 - (Date.now() - authStartedAt)) }, + ); } catch (authErr) { output( ` -${authErr.message === 'AUTH_TIMEOUT' ? 'Authentication timed out. Please complete login in the browser window.' : 'Authentication failed.'} -If the browser did not open, visit: ${AUTH_BASE_URL} -Or write an API key to ~/.supermemory-muse/credentials.json as {"apiKey":"sm_..."}. +${authErr.message === 'AUTH_TIMEOUT' ? 'Authentication timed out. Start a new session to retry login.' : 'Authentication failed. Start a new session to retry login.'} +For an explicit login, run: ${LOGIN_COMMAND} +Or write an API key to ${credentialsFile()} as {"apiKey":"sm_..."}. `, [], ); @@ -142,7 +162,8 @@ Session will continue without memory context. } } -main().catch((err) => { +main().catch(async (err) => { console.error(`Supermemory fatal: ${err.message}`); + await settleAuth(); process.exit(1); }); diff --git a/hooks/templates/auth-error.html b/hooks/templates/auth-error.html index 7ce44cc..c0a1a26 100644 --- a/hooks/templates/auth-error.html +++ b/hooks/templates/auth-error.html @@ -20,7 +20,7 @@ -

Login failed

-

No API key was returned. Close this tab and try again from Muse Code.

+

{{title}}

+

{{message}}

diff --git a/mcp/proxy.js b/mcp/proxy.js index 2677562..aa21a95 100644 --- a/mcp/proxy.js +++ b/mcp/proxy.js @@ -1,10 +1,12 @@ #!/usr/bin/env node const readline = require('node:readline'); const { getContainerTag } = require('../hooks/lib/container-tag'); -const { getApiKey } = require('../hooks/lib/settings'); - -const MCP_URL = - process.env.SUPERMEMORY_MCP_URL || 'https://mcp.supermemory.ai/mcp'; +const { + LOGIN_COMMAND, + credentialsFile, + settleAuth, +} = require('../hooks/lib/auth'); +const { getAuthToken, getMcpUrl } = require('../hooks/lib/settings'); const REQUEST_TIMEOUT_MS = 30000; const REPO_SCOPED_TOOLS = new Set([ @@ -66,7 +68,7 @@ function emitSseData(text) { } } -async function forward(message, apiKey) { +async function forward(message, apiKey, mcpUrl) { const headers = { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', @@ -74,7 +76,7 @@ async function forward(message, apiKey) { }; if (sessionId) headers['Mcp-Session-Id'] = sessionId; - const response = await fetch(MCP_URL, { + const response = await fetch(mcpUrl, { method: 'POST', headers, body: JSON.stringify(message), @@ -108,14 +110,7 @@ async function forward(message, apiKey) { async function main() { const cwd = process.cwd(); - let apiKey = null; - let keyError = null; let repoContainerTag = null; - try { - apiKey = getApiKey(cwd); - } catch (err) { - keyError = err; - } try { repoContainerTag = getContainerTag(cwd); } catch { @@ -135,25 +130,31 @@ async function main() { } queue = queue.then(async () => { - if (keyError) { - sendError( - message.id, - -32001, - 'Supermemory is not authenticated. Start a Muse Code session with the supermemory plugin to log in, or write {"apiKey":"sm_..."} to ~/.supermemory-muse/credentials.json.', - ); - return; - } try { + // Re-read on every message: the SessionStart hook may finish login + // after this process starts, and OAuth tokens expire during sessions. + const apiKey = await getAuthToken(cwd); + const mcpUrl = getMcpUrl(cwd); injectRepoContainerTag(message, repoContainerTag); - await forward(message, apiKey); + await forward(message, apiKey, mcpUrl); } catch (err) { + if (err.code === 'AUTH_REQUIRED') { + sessionId = null; + sendError( + message.id, + -32001, + `Supermemory is not authenticated. Run ${LOGIN_COMMAND} or start a new Muse Code session to log in, or write {"apiKey":"sm_..."} to ${credentialsFile()}.`, + ); + return; + } sendError(message.id, -32000, `Supermemory MCP proxy error: ${err.message}`); } }); }); rl.on('close', () => { - queue.then(() => process.exit(0)); + // Let a refresh that is still rotating tokens save them before exiting. + queue.then(settleAuth).then(() => process.exit(0)); }); }