@auth0/auth0-mcp-server
Version:
Auth0 Model Context Protocol (MCP) Server (Beta) — A secure and extendable implementation of an MCP server that provides AI assistants with controlled access to the Auth0 Management API through natural language. This project is in beta and not intended fo
393 lines • 18.4 kB
JavaScript
import * as fs from 'fs';
import * as path from 'path';
import { randomBytes } from 'crypto';
import { log } from './logger.js';
import { fetchQuickstartSpec } from './quickstarts.js';
import { isFrameworkSupported, hasProjectMarker } from './onboarding.js';
import { getManagementClient } from './auth0-client.js';
import { writeCredentialsToEnv, parseEnvFile, detectExistingEnvFile, ensureGitignore, } from './credentials-writer.js';
import trackEvent from './analytics.js';
/**
* CDN spec placeholder for the session-cookie secret. We never populate its input
* (`sessionCookieSecret`) — the value is generated locally — so any sensitive var
* templated with this token is the session secret, regardless of its output name.
*/
const SESSION_SECRET_PLACEHOLDER = '%AUTH0_SECRET%';
/**
* Resolves Auth0 credentials for the given framework and writes them to the project's env file.
*
* Uses the CDN quickstart spec when available (spec-driven path). Falls back to hardcoded
* Auth0 variables when the framework is unsupported or the CDN fetch fails — no error is
* returned to the LLM in the fallback case.
*
* @param params - Credential resolution parameters (client_id, framework, project_path, etc.)
* @param config - Handler config containing the Auth0 domain
* @param token - Auth0 Management API access token
* @returns Result indicating success (with file metadata) or failure (with error message)
*/
const WEB_SERVED_SEGMENT_NAMES = new Set([
'public',
'dist',
'build',
'static',
'www',
'wwwroot',
'html',
'assets',
'out',
]);
// Detects whether a directory is likely web-served based on its final path segment.
// Absolute web server roots (/srv, /var/www, etc.) are hard-blocked by hasProjectMarker.
function isLikelyWebServedDirectory(resolvedDir) {
return WEB_SERVED_SEGMENT_NAMES.has(path.basename(resolvedDir).toLowerCase());
}
function validateProjectPath(projectPath) {
if (!path.isAbsolute(projectPath)) {
return 'project_path must be an absolute path';
}
if (projectPath.includes('..')) {
return 'project_path must not contain path traversal sequences';
}
if (!fs.existsSync(projectPath) || !fs.statSync(projectPath).isDirectory()) {
return 'project_path does not exist or is not a directory';
}
if (!hasProjectMarker(projectPath)) {
return 'project_path must not be a system or home directory';
}
return null;
}
export async function resolveAndWriteCredentials(params, config, token) {
const { client_id: clientId, framework, project_path: projectPath } = params;
// Validation: ensure projectPath is a valid directory with no path traversal
const projectPathError = validateProjectPath(projectPath);
if (projectPathError)
return { success: false, error: projectPathError };
const spec = await fetchQuickstartSpec(framework);
if (spec !== null && !spec.envSnippet) {
return {
success: true,
client_id: params.client_id,
keys_written: [],
generated_keys: [],
file_created: false,
message: 'No .env file needed for this framework.',
};
}
// For supported frameworks, a null spec means CDN failed with no cache — surface the error
if (spec === null && isFrameworkSupported(framework)) {
return {
success: false,
error: `Could not fetch quickstart spec for "${framework}". ` +
'Please check your network connection and try again.',
};
}
const resolutionPath = spec?.envSnippet ? 'spec' : 'fallback';
// After the guard above, the only remaining fallback case is an unsupported framework.
const fallbackReason = resolutionPath === 'fallback' ? 'unsupported' : undefined;
const resolved = spec?.envSnippet
? await buildSpecCredentials(params, spec.envSnippet, spec.defaultAppOrigin, config, token, spec.placeholders)
: await buildFallbackCredentials(params, config, token);
if (!resolved.success)
return resolved;
if (params.dry_run) {
return {
success: true,
client_id: clientId,
keys_written: Object.keys(resolved.credentialMap),
generated_keys: resolved.generated_keys,
file_created: false,
message: `Dry run: would write ${Object.keys(resolved.credentialMap).length} key(s) to ${resolved.envFilePath}. ` +
'Pass dry_run: false (or omit) to write.',
};
}
const guardError = checkWriteGuard(projectPath, Object.keys(resolved.credentialMap));
if (guardError)
return { success: false, error: guardError };
let credentialsInfo;
try {
credentialsInfo = await writeCredentialsToEnv(resolved.credentialMap, {
filePath: resolved.envFilePath,
allowedDir: projectPath,
});
}
catch (err) {
return { success: false, error: err.message ?? 'Failed to write credentials' };
}
log(`Credentials saved to: ${credentialsInfo.file_path}`);
updateWriteGuard(projectPath, credentialsInfo.keys_written, framework);
appendAuditLog(projectPath, framework, credentialsInfo);
ensureGitignore(projectPath, WRITE_GUARD_FILE);
ensureGitignore(projectPath, AUDIT_LOG_FILE);
const generatedKeys = resolved.generated_keys;
trackEvent.trackCredentialResolution(framework, resolutionPath, resolved.secret_generated, credentialsInfo.keys_written, fallbackReason);
const auditLogPath = path.join(projectPath, AUDIT_LOG_FILE);
const securityNotice = `Verify that ${path.basename(credentialsInfo.file_path)} is listed in .gitignore ` +
'before committing this project to version control.';
const baseMessage = generatedKeys.length > 0
? `Credentials saved securely to ${credentialsInfo.file_path}. ${generatedKeys.join(', ')} was generated automatically and saved to the file. You can rotate it at any time by replacing the value with a new 32-byte hex string.`
: `Credentials saved securely to ${credentialsInfo.file_path}`;
const webServedWarning = isLikelyWebServedDirectory(projectPath)
? ' Warning: project_path appears to be a web-served directory. Ensure your web server is configured to deny access to .env files to prevent credentials from being exposed over HTTP.'
: '';
return {
success: true,
client_id: clientId,
credentials_saved_to: credentialsInfo.file_path,
keys_written: credentialsInfo.keys_written,
generated_keys: generatedKeys,
file_created: credentialsInfo.file_created,
message: `${baseMessage} A write audit log is available at ${auditLogPath}. ${securityNotice}${webServedWarning}`,
};
}
/**
* Builds a credential map from the CDN quickstart spec's envSnippet.
*
* Resolves each entry's value template by substituting placeholders (e.g. %AUTH0_DOMAIN%)
* with actual values derived from the spec's placeholder→inputKey mapping.
*
* SPA frameworks (empty secretKeys) skip the Management API call entirely.
* The session-cookie secret is generated only when required and not already present in the
* existing env file. It is identified by the %AUTH0_SECRET% placeholder rather than a fixed
* output name, since that name varies by framework (AUTH0_SECRET for Next.js, SECRET for Express).
*/
async function buildSpecCredentials(params, envSnippet, defaultAppOrigin, config, token, placeholders) {
const { client_id: clientId, project_path: projectPath, base_url: baseUrl, callback_url: callbackUrl, port, } = params;
const fileName = envSnippet.fileName;
if (fileName !== path.basename(fileName) || fileName.includes('..')) {
return { success: false, error: 'Quickstart spec contained an invalid env file name' };
}
const envFilePath = detectExistingEnvFile(projectPath) ?? path.join(projectPath, fileName);
const varEntries = envSnippet.entries.filter((e) => e.type === 'var');
const secretKeys = varEntries.filter((e) => e.sensitive).map((e) => e.name);
let clientSecret;
if (secretKeys.length > 0) {
try {
const managementClient = await getManagementClient({ domain: config.domain, token });
const { data: application } = await managementClient.clients.get({ client_id: clientId });
clientSecret = application.client_secret;
}
catch (sdkError) {
let error = `Failed to retrieve application: ${sdkError.message || 'Unknown error'}`;
if (sdkError.statusCode === 404) {
error = `Application with client_id '${clientId}' not found.`;
}
else if (sdkError.statusCode === 401) {
error +=
'\nYour token may be expired or missing read:clients scope. Try running "npx @auth0/auth0-mcp-server init" to refresh your token.';
}
return { success: false, error };
}
}
let parsedBaseUrl;
if (baseUrl) {
try {
parsedBaseUrl = new URL(baseUrl);
}
catch {
return {
success: false,
error: `Invalid base_url "${baseUrl}". Expected a full URL including scheme, e.g. "http://localhost:3000".`,
};
}
}
const resolvedPort = port
? String(port)
: parsedBaseUrl
? parsedBaseUrl.port || String(defaultAppOrigin.port ?? 3000)
: String(defaultAppOrigin.port ?? 3000);
const inputValues = {
auth0Domain: config.domain,
auth0ClientId: clientId,
auth0ClientSecret: clientSecret,
port: resolvedPort,
appDomain: parsedBaseUrl
? parsedBaseUrl.hostname
: typeof defaultAppOrigin.domain === 'string'
? defaultAppOrigin.domain
: 'localhost',
appScheme: parsedBaseUrl
? parsedBaseUrl.protocol.replace(':', '')
: (defaultAppOrigin.scheme ?? 'http'),
};
if (callbackUrl) {
inputValues.callbackUrl = callbackUrl;
}
const placeholderMap = buildPlaceholderMap(placeholders, inputValues);
const existingEnv = parseEnvFile(envFilePath);
const credentialMap = {};
const generated_keys = [];
let secret_generated = false;
for (const entry of varEntries) {
// Session-cookie secret: detected by the %AUTH0_SECRET% placeholder, not the output
// name (which varies by framework), since we generate it locally rather than fetch it.
// Generate only if not already present in the env file.
if (entry.sensitive && entry.value.includes(SESSION_SECRET_PLACEHOLDER)) {
if (!existingEnv[entry.name]) {
credentialMap[entry.name] = randomBytes(32).toString('hex');
generated_keys.push(entry.name);
secret_generated = true;
}
continue;
}
const resolved = resolveEnvPlaceholders(entry.value, placeholderMap);
if (resolved && !resolved.includes('%')) {
credentialMap[entry.name] = resolved;
}
}
return { success: true, credentialMap, envFilePath, generated_keys, secret_generated };
}
/**
* Builds a map from placeholder token (e.g. "%AUTH0_DOMAIN%") to resolved value
* using the spec's placeholders definition and the resolved input values.
*/
function buildPlaceholderMap(placeholders, inputValues) {
const map = {};
for (const [token, definition] of Object.entries(placeholders)) {
if (typeof definition === 'object' && definition !== null && 'inputKey' in definition) {
const inputKey = definition.inputKey;
const value = inputValues[inputKey];
if (value)
map[token] = value;
}
}
return map;
}
/**
* Resolves all %PLACEHOLDER% tokens in an env-file template using a pre-built placeholder map,
* leaving unknown tokens in place (the caller rejects any value that still contains a `%`).
*/
function resolveEnvPlaceholders(template, placeholderMap) {
return template.replace(/%[A-Z0-9_]+%/g, (token) => placeholderMap[token] ?? token);
}
/**
* Builds a hardcoded credential map for unsupported frameworks or when the CDN is unavailable.
* Matches the tool's original behavior: AUTH0_DOMAIN, AUTH0_CLIENT_ID, AUTH0_CLIENT_SECRET, AUTH0_CALLBACK_URL.
*/
async function buildFallbackCredentials(params, config, token) {
const { client_id: clientId, project_path: projectPath, callback_url: callbackUrl } = params;
const envFilePath = detectExistingEnvFile(projectPath) ?? path.join(projectPath, '.env.local');
log(`Spec unavailable for framework "${params.framework}", falling back to hardcoded credentials`);
try {
const managementClient = await getManagementClient({ domain: config.domain, token });
const { data: application } = await managementClient.clients.get({ client_id: clientId });
const appData = application;
if (!appData.client_secret) {
return {
success: false,
error: `Application ${clientId} does not have a client_secret (may be a public client type)`,
};
}
const resolvedCallbackUrl = callbackUrl || appData.callbacks?.[0];
const credentialMap = {
AUTH0_DOMAIN: config.domain,
AUTH0_CLIENT_ID: clientId,
AUTH0_CLIENT_SECRET: appData.client_secret,
...(resolvedCallbackUrl ? { AUTH0_CALLBACK_URL: resolvedCallbackUrl } : {}),
};
return {
success: true,
credentialMap,
envFilePath,
generated_keys: [],
secret_generated: false,
};
}
catch (sdkError) {
let error = `Failed to retrieve application: ${sdkError.message || 'Unknown error'}`;
if (sdkError.statusCode === 404) {
error = `Application with client_id '${clientId}' not found.`;
}
else if (sdkError.statusCode === 401) {
error +=
'\nYour token may be expired or missing read:clients scope. Try running "npx @auth0/auth0-mcp-server init" to refresh your token.';
}
return { success: false, error };
}
}
// ── Write guard ──────────────────────────────────────────────────────────────
// Prevents accidental double-writes within a 30-second window by persisting the
// last-written keys and timestamp to .auth0-mcp-state.json in the project directory.
const WRITE_GUARD_FILE = '.auth0-mcp-state.json';
// 30 seconds: long enough to catch rapid double-invocations in a multi-step AI
// flow, short enough not to block an intentional re-run after a failed attempt.
const WRITE_GUARD_WINDOW_MS = 30 * 1000;
const MAX_WRITE_GUARD_SIZE_BYTES = 4096;
function checkWriteGuard(projectPath, incomingKeys) {
const statePath = path.join(projectPath, WRITE_GUARD_FILE);
if (!fs.existsSync(statePath))
return null;
try {
const guardStat = fs.statSync(statePath);
if (!guardStat.isFile() || guardStat.size > MAX_WRITE_GUARD_SIZE_BYTES)
return null;
const state = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
const elapsed = Date.now() - new Date(state.lastWrittenAt).getTime();
if (elapsed < WRITE_GUARD_WINDOW_MS) {
const overlap = incomingKeys.filter((k) => state.keysWritten.includes(k));
if (overlap.length > 0) {
const secondsAgo = Math.round(elapsed / 1000);
const secondsLeft = Math.ceil((WRITE_GUARD_WINDOW_MS - elapsed) / 1000);
return (`Credentials were already written to this project ${secondsAgo} second(s) ago ` +
`(keys: ${overlap.join(', ')}). Please wait ${secondsLeft} second(s) before trying again.`);
}
}
}
catch {
// Corrupt state file — allow the write to proceed
}
return null;
}
function updateWriteGuard(projectPath, keysWritten, framework) {
const statePath = path.join(projectPath, WRITE_GUARD_FILE);
const state = {
lastWrittenAt: new Date().toISOString(),
keysWritten,
framework,
};
try {
fs.writeFileSync(statePath, JSON.stringify(state, null, 2), 'utf-8');
}
catch (error) {
log(`Warning: could not save write guard state to ${WRITE_GUARD_FILE}: ${error}`);
}
}
// ── Audit log ────────────────────────────────────────────────────────────────
// Appends a timestamped entry to .auth0-mcp-writes.log on every successful write.
// Capped at MAX_AUDIT_LOG_LINES to prevent unbounded growth.
const AUDIT_LOG_FILE = '.auth0-mcp-writes.log';
// Cap log size
const MAX_AUDIT_LOG_LINES = 200;
function appendAuditLog(projectPath, framework, info) {
const logPath = path.join(projectPath, AUDIT_LOG_FILE);
const entry = `${new Date().toISOString()} | WRITE | framework=${framework} | ` +
`keys=${info.keys_written.join(',')} | file=${path.basename(info.file_path)}\n`;
try {
let lines = [];
if (fs.existsSync(logPath)) {
lines = fs.readFileSync(logPath, 'utf-8').split('\n').filter(Boolean);
}
if (lines.length >= MAX_AUDIT_LOG_LINES) {
// Keep only the most recent entries so the file stays bounded
lines = lines.slice(lines.length - (MAX_AUDIT_LOG_LINES - 1));
}
const content = (lines.length > 0 ? lines.join('\n') + '\n' : '') + entry;
const tmpPath = logPath + '.tmp';
try {
fs.writeFileSync(tmpPath, content, 'utf-8');
fs.renameSync(tmpPath, logPath);
}
catch (writeErr) {
try {
fs.unlinkSync(tmpPath);
}
catch {
/* ignore */
}
throw writeErr;
}
}
catch (error) {
log(`Warning: could not write to audit log ${AUDIT_LOG_FILE}: ${error}`);
}
}
//# sourceMappingURL=env-credentials.js.map