import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync, statSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
import { signInWithToken, initFirebase, getCurrentUserId, configureFirebase, signOutCurrentUser } from './firebase-client.js';
import { validateExchangeUrl } from './exchange-url.js';
interface McpConfig {
apiKey: string;
firebaseProjectId?: string; // Legacy field (kept for backward compatibility)
exchangeTokenUrl?: string;
allowUntrustedExchangeUrl?: boolean;
firebase?: {
apiKey?: string;
authDomain?: string;
projectId?: string;
storageBucket?: string;
messagingSenderId?: string;
appId?: string;
};
}
interface TokenCache {
customToken: string;
expiresAt: number;
userId: string;
}
interface AuthenticateOptions {
forceRefresh?: boolean;
}
const CONFIG_DIR = join(homedir(), '.ice-puzzle-mcp');
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
const TOKEN_CACHE_FILE = join(CONFIG_DIR, '.token-cache.json');
const TOKEN_EXCHANGE_TIMEOUT_MS = 10000;
const FIREBASE_INIT_FETCH_TIMEOUT_MS = 10000;
const SECURE_DIR_MODE = 0o700;
const SECURE_FILE_MODE = 0o600;
// Default exchange token URL
export const DEFAULT_FIREBASE_PROJECT_ID = 'ice-puzzle-game';
export const DEFAULT_EXCHANGE_URL =
process.env.ICE_PUZZLE_EXCHANGE_TOKEN_URL ||
`https://us-central1-${DEFAULT_FIREBASE_PROJECT_ID}.cloudfunctions.net/exchangeToken`;
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
function normalizeOptionalString(value: unknown): string | undefined {
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
if (typeof value === 'number' && Number.isFinite(value)) {
return String(value);
}
return undefined;
}
function normalizeOptionalBoolean(value: unknown): boolean | undefined {
if (value === undefined || value === null) {
return undefined;
}
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (normalized === 'true') return true;
if (normalized === 'false') return false;
}
return undefined;
}
function resolveFirebaseProjectId(config: McpConfig): string {
if (isNonEmptyString(config.firebase?.projectId)) {
return config.firebase.projectId.trim();
}
if (isNonEmptyString(config.firebaseProjectId)) {
return config.firebaseProjectId.trim();
}
if (isNonEmptyString(process.env.ICE_PUZZLE_FIREBASE_PROJECT_ID)) {
return process.env.ICE_PUZZLE_FIREBASE_PROJECT_ID.trim();
}
return DEFAULT_FIREBASE_PROJECT_ID;
}
function normalizeHostedFirebaseConfig(payload: unknown): NonNullable<McpConfig['firebase']> | null {
if (!payload || typeof payload !== 'object') {
return null;
}
const data = payload as Record<string, unknown>;
const apiKey = normalizeOptionalString(data.apiKey);
const authDomain = normalizeOptionalString(data.authDomain);
const projectId = normalizeOptionalString(data.projectId);
const appId = normalizeOptionalString(data.appId);
const storageBucket = normalizeOptionalString(data.storageBucket);
const messagingSenderId = normalizeOptionalString(data.messagingSenderId);
if (!apiKey || !authDomain || !projectId || !appId) {
return null;
}
return {
apiKey,
authDomain,
projectId,
appId,
...(storageBucket ? { storageBucket } : {}),
...(messagingSenderId ? { messagingSenderId } : {}),
};
}
async function fetchHostedFirebaseConfig(projectId: string): Promise<NonNullable<McpConfig['firebase']> | null> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FIREBASE_INIT_FETCH_TIMEOUT_MS);
const url = `https://${projectId}.web.app/__/firebase/init.json`;
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) {
return null;
}
const payload = await response.json().catch(() => null);
return normalizeHostedFirebaseConfig(payload);
} catch {
return null;
} finally {
clearTimeout(timeout);
}
}
function ensureConfigDir(): void {
if (!existsSync(CONFIG_DIR)) {
mkdirSync(CONFIG_DIR, { recursive: true, mode: SECURE_DIR_MODE });
}
try {
const mode = statSync(CONFIG_DIR).mode & 0o777;
if ((mode & 0o077) !== 0 || mode !== SECURE_DIR_MODE) {
chmodSync(CONFIG_DIR, SECURE_DIR_MODE);
}
} catch {
// best effort
}
}
function normalizeConfig(raw: unknown): McpConfig | null {
if (!raw || typeof raw !== 'object') {
return null;
}
const data = raw as Record<string, unknown>;
if (!isNonEmptyString(data.apiKey) || !data.apiKey.startsWith('ipk_')) {
return null;
}
const config: McpConfig = {
apiKey: data.apiKey.trim(),
};
if (isNonEmptyString(data.exchangeTokenUrl)) {
config.exchangeTokenUrl = data.exchangeTokenUrl.trim();
}
const allowUntrustedExchangeUrl = normalizeOptionalBoolean(data.allowUntrustedExchangeUrl);
if (allowUntrustedExchangeUrl !== undefined) {
config.allowUntrustedExchangeUrl = allowUntrustedExchangeUrl;
}
if (isNonEmptyString(data.firebaseProjectId)) {
config.firebaseProjectId = data.firebaseProjectId.trim();
}
if (data.firebase && typeof data.firebase === 'object') {
const firebaseRaw = data.firebase as Record<string, unknown>;
config.firebase = {
...(isNonEmptyString(firebaseRaw.apiKey) ? { apiKey: firebaseRaw.apiKey.trim() } : {}),
...(isNonEmptyString(firebaseRaw.authDomain) ? { authDomain: firebaseRaw.authDomain.trim() } : {}),
...(isNonEmptyString(firebaseRaw.projectId) ? { projectId: firebaseRaw.projectId.trim() } : {}),
...(isNonEmptyString(firebaseRaw.storageBucket) ? { storageBucket: firebaseRaw.storageBucket.trim() } : {}),
...(isNonEmptyString(firebaseRaw.messagingSenderId)
? { messagingSenderId: firebaseRaw.messagingSenderId.trim() }
: {}),
...(isNonEmptyString(firebaseRaw.appId) ? { appId: firebaseRaw.appId.trim() } : {}),
};
}
// Backward compatibility for older config files.
if (!config.firebase?.projectId && config.firebaseProjectId) {
config.firebase = {
...(config.firebase || {}),
projectId: config.firebaseProjectId,
};
}
return config;
}
/**
* Read the MCP configuration from disk.
*/
export function readConfig(): McpConfig | null {
if (!existsSync(CONFIG_FILE)) {
return null;
}
try {
try {
const mode = statSync(CONFIG_FILE).mode & 0o777;
if ((mode & 0o077) !== 0 || mode !== SECURE_FILE_MODE) {
chmodSync(CONFIG_FILE, SECURE_FILE_MODE);
}
} catch {
// best effort
}
const content = readFileSync(CONFIG_FILE, 'utf-8');
const parsed = JSON.parse(content);
return normalizeConfig(parsed);
} catch {
return null;
}
}
/**
* Write configuration to disk.
*/
export function writeConfig(config: McpConfig): void {
ensureConfigDir();
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: SECURE_FILE_MODE });
chmodSync(CONFIG_FILE, SECURE_FILE_MODE);
}
/**
* Read cached token from disk.
*/
function readTokenCache(): TokenCache | null {
if (!existsSync(TOKEN_CACHE_FILE)) return null;
try {
try {
const mode = statSync(TOKEN_CACHE_FILE).mode & 0o777;
if ((mode & 0o077) !== 0 || mode !== SECURE_FILE_MODE) {
chmodSync(TOKEN_CACHE_FILE, SECURE_FILE_MODE);
}
} catch {
// best effort
}
const content = readFileSync(TOKEN_CACHE_FILE, 'utf-8');
const cache = JSON.parse(content) as TokenCache;
if (!cache || typeof cache !== 'object') return null;
if (!isNonEmptyString(cache.customToken) || !isNonEmptyString(cache.userId) || typeof cache.expiresAt !== 'number') {
return null;
}
// Check if token is still valid (with 5 min buffer)
if (cache.expiresAt > Date.now() + 300000) {
return cache;
}
return null; // Expired
} catch {
return null;
}
}
/**
* Write token cache to disk.
*/
function writeTokenCache(cache: TokenCache): void {
ensureConfigDir();
writeFileSync(TOKEN_CACHE_FILE, JSON.stringify(cache), { mode: SECURE_FILE_MODE });
chmodSync(TOKEN_CACHE_FILE, SECURE_FILE_MODE);
}
async function postExchangeRequest(url: string, apiKey: string): Promise<Response> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), TOKEN_EXCHANGE_TIMEOUT_MS);
try {
return await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ apiKey }),
signal: controller.signal,
});
} finally {
clearTimeout(timeout);
}
}
async function parseExchangeErrorMessage(response: Response): Promise<string> {
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
const errorBody = await response.json().catch(() => null);
const message = errorBody && typeof errorBody === 'object'
? (errorBody as { error?: unknown }).error
: undefined;
if (typeof message === 'string' && message.trim().length > 0) {
return message;
}
}
const text = await response.text().catch(() => '');
if (typeof text === 'string' && text.trim().length > 0) {
return text.trim();
}
return `HTTP ${response.status}`;
}
function normalizeExchangeError(error: unknown): Error {
if (error instanceof Error && error.name === 'AbortError') {
return new Error(`Token exchange timed out after ${TOKEN_EXCHANGE_TIMEOUT_MS}ms.`);
}
if (error instanceof Error) {
return error;
}
return new Error('Token exchange failed due to an unknown error.');
}
function isRetryableExchangeError(error: Error): boolean {
const message = error.message.toLowerCase();
return (
message.includes('server error')
|| message.includes('timed out')
|| message.includes('network')
|| message.includes('fetch failed')
|| message.includes('socket')
|| message.includes('econn')
|| message.includes('enotfound')
|| message.includes('eai_again')
);
}
function parseExchangeSuccess(payload: unknown): { customToken: string; expiresIn: number; userId: string } {
if (!payload || typeof payload !== 'object') {
throw new Error('Token exchange returned an invalid response payload.');
}
const response = payload as Record<string, unknown>;
const customToken = typeof response.customToken === 'string' ? response.customToken.trim() : '';
const userId = typeof response.userId === 'string' ? response.userId.trim() : '';
const expiresIn = Number(response.expiresIn);
if (!customToken) {
throw new Error('Token exchange response is missing customToken.');
}
if (!userId) {
throw new Error('Token exchange response is missing userId.');
}
if (!Number.isFinite(expiresIn) || expiresIn <= 0) {
throw new Error('Token exchange response has an invalid expiresIn value.');
}
return {
customToken,
userId,
expiresIn: Math.floor(expiresIn),
};
}
/**
* Sleep helper for retry backoff.
*/
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Exchange an API key for a Firebase custom token via the Cloud Function.
* Retries transient failures (network errors, 5xx) with exponential backoff.
*/
async function exchangeApiKey(
apiKey: string,
exchangeUrl: string | undefined,
options: { allowUntrustedExchangeUrl?: boolean } = {},
): Promise<{
customToken: string;
expiresIn: number;
userId: string;
}> {
const validation = validateExchangeUrl(exchangeUrl || DEFAULT_EXCHANGE_URL, {
allowUntrustedHttpsHost:
options.allowUntrustedExchangeUrl === true
|| process.env.ICE_PUZZLE_ALLOW_UNTRUSTED_EXCHANGE_URL === '1',
});
if (!validation.valid || !validation.normalizedUrl) {
throw new Error(validation.error || 'Invalid exchange URL.');
}
const url = validation.normalizedUrl;
const MAX_RETRIES = 2; // 3 total attempts
const RETRY_DELAYS = [1000, 2000]; // ms
let lastError: Error | null = null;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
const response = await postExchangeRequest(url, apiKey);
if (!response.ok) {
const errorMsg = await parseExchangeErrorMessage(response);
// Non-retryable errors
if (response.status === 401) {
throw new Error(`Authentication failed: ${errorMsg}. Check your API key in ~/.ice-puzzle-mcp/config.json`);
}
if (response.status === 429) {
throw new Error(`Rate limited: ${errorMsg}. Please wait and try again.`);
}
// Retryable server errors (5xx)
if (response.status >= 500 && response.status <= 599) {
throw new Error(`Server error ${response.status}: ${errorMsg}`);
}
// Other errors are not retried
throw new Error(`Token exchange failed: ${errorMsg}`);
}
const payload = await response.json().catch(() => null);
return parseExchangeSuccess(payload);
} catch (error) {
lastError = normalizeExchangeError(error);
// Don't retry auth failures or rate limits
if (lastError.message.includes('Authentication failed') || lastError.message.includes('Rate limited')) {
throw lastError;
}
// Don't retry non-server errors on last attempt
if (attempt === MAX_RETRIES) {
throw lastError;
}
if (!isRetryableExchangeError(lastError)) {
throw lastError;
}
// Wait before retrying
await sleep(RETRY_DELAYS[attempt]);
}
}
// Should never reach here, but TypeScript needs it
throw lastError || new Error('Token exchange failed after retries');
}
/**
* In-flight authentication promise to prevent concurrent token exchanges.
*/
let authInProgress: Promise<{ success: true; userId: string } | { success: false; error: string }> | null = null;
/**
* Authenticate the MCP client. Handles the full flow:
* 1. Read API key from config
* 2. Check token cache
* 3. Exchange API key for custom token if needed
* 4. Sign into Firebase
*
* Returns the user ID on success, or an error message string on failure.
*/
export async function authenticate(options: AuthenticateOptions = {}): Promise<{ success: true; userId: string } | { success: false; error: string }> {
const forceRefresh = options.forceRefresh === true;
const config = readConfig();
if (!config) {
return {
success: false,
error: 'No API key configured. Run `npx ice-puzzle-mcp setup` or create ~/.ice-puzzle-mcp/config.json with { "apiKey": "ipk_..." }',
};
}
configureFirebase({
...(config.firebase?.apiKey ? { apiKey: config.firebase.apiKey } : {}),
...(config.firebase?.authDomain ? { authDomain: config.firebase.authDomain } : {}),
...(config.firebase?.projectId ? { projectId: config.firebase.projectId } : {}),
...(config.firebase?.storageBucket ? { storageBucket: config.firebase.storageBucket } : {}),
...(config.firebase?.messagingSenderId ? { messagingSenderId: config.firebase.messagingSenderId } : {}),
...(config.firebase?.appId ? { appId: config.firebase.appId } : {}),
});
let firebaseReady = initFirebase();
if (!firebaseReady) {
const hostedFirebaseConfig = await fetchHostedFirebaseConfig(resolveFirebaseProjectId(config));
if (hostedFirebaseConfig) {
configureFirebase(hostedFirebaseConfig);
firebaseReady = initFirebase();
}
}
if (!firebaseReady) {
return {
success: false,
error:
'Firebase config missing. Set ICE_PUZZLE_FIREBASE_* env vars or add a "firebase" block in ~/.ice-puzzle-mcp/config.json.',
};
}
// Try cached token first unless a force refresh is requested.
// We intentionally require a fresh/valid custom-token cache so API key revocation
// can take effect after token expiry, even if Firebase still has a stale session.
if (!forceRefresh) {
const cached = readTokenCache();
if (cached) {
const existingUserId = getCurrentUserId();
if (existingUserId && existingUserId === cached.userId) {
return { success: true, userId: existingUserId };
}
try {
initFirebase();
const signedInUserId = await signInWithToken(cached.customToken);
if (signedInUserId !== cached.userId) {
throw new Error('Cached token user does not match expected user.');
}
return { success: true, userId: signedInUserId };
} catch {
// Token expired or invalid, continue to exchange
}
}
}
// If another call is already exchanging the token, wait for it
if (authInProgress) {
return await authInProgress;
}
// Exchange API key for new token
const exchangePromise = (async (): Promise<{ success: true; userId: string } | { success: false; error: string }> => {
try {
await signOutCurrentUser().catch(() => undefined);
const result = await exchangeApiKey(config.apiKey, config.exchangeTokenUrl, {
allowUntrustedExchangeUrl: config.allowUntrustedExchangeUrl === true,
});
// Cache the token
writeTokenCache({
customToken: result.customToken,
expiresAt: Date.now() + result.expiresIn * 1000,
userId: result.userId,
});
// Initialize Firebase and sign in
initFirebase();
const signedInUserId = await signInWithToken(result.customToken);
if (signedInUserId !== result.userId) {
throw new Error('Exchange token user does not match expected user.');
}
return { success: true, userId: signedInUserId };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Authentication failed.',
};
} finally {
authInProgress = null;
}
})();
authInProgress = exchangePromise;
return await exchangePromise;
}
/**
* Check if the user is currently authenticated.
*/
export function isAuthenticated(): boolean {
return getCurrentUserId() !== null;
}
/**
* Get an auth status message for tool responses.
*/
export function getAuthStatusMessage(): string {
if (isAuthenticated()) {
return `Authenticated as ${getCurrentUserId()}`;
}
return 'Not authenticated. Run `npx ice-puzzle-mcp setup` to connect your account. Tools work locally without auth.';
}