mcp-memory-libsql
by spences10
Verified
import fs from 'fs/promises';
import path from 'path';
import { AccountError, TokenStatus, TokenRenewalResult } from './types.js';
import { GoogleOAuthClient } from './oauth.js';
import logger from '../../utils/logger.js';
/**
* Manages OAuth token operations.
* Focuses on basic token storage, retrieval, and refresh.
* Auth issues are handled via 401 responses rather than pre-validation.
*/
export class TokenManager {
private readonly credentialsPath: string;
private oauthClient?: GoogleOAuthClient;
private readonly TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000; // 5 minutes buffer
constructor(oauthClient?: GoogleOAuthClient) {
// Use the mounted config directory for persistent storage
this.credentialsPath = path.resolve('/app/config/credentials');
this.oauthClient = oauthClient;
}
setOAuthClient(client: GoogleOAuthClient) {
this.oauthClient = client;
}
private getTokenPath(email: string): string {
const sanitizedEmail = email.replace(/[^a-zA-Z0-9]/g, '-');
return path.join(this.credentialsPath, `${sanitizedEmail}.token.json`);
}
async saveToken(email: string, tokenData: any): Promise<void> {
logger.info(`Saving token for account: ${email}`);
try {
// Ensure base credentials directory exists
await fs.mkdir(this.credentialsPath, { recursive: true });
const tokenPath = this.getTokenPath(email);
await fs.writeFile(tokenPath, JSON.stringify(tokenData, null, 2));
logger.debug(`Token saved successfully at: ${tokenPath}`);
} catch (error) {
throw new AccountError(
'Failed to save token',
'TOKEN_SAVE_ERROR',
'Please ensure the credentials directory is writable'
);
}
}
async loadToken(email: string): Promise<any> {
logger.debug(`Loading token for account: ${email}`);
try {
const tokenPath = this.getTokenPath(email);
const data = await fs.readFile(tokenPath, 'utf-8');
return JSON.parse(data);
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
// File doesn't exist - return null to trigger OAuth flow
return null;
}
throw new AccountError(
'Failed to load token',
'TOKEN_LOAD_ERROR',
'Please ensure the token file exists and is readable'
);
}
}
async deleteToken(email: string): Promise<void> {
logger.info(`Deleting token for account: ${email}`);
try {
const tokenPath = this.getTokenPath(email);
await fs.unlink(tokenPath);
logger.debug('Token file deleted successfully');
} catch (error) {
if (error instanceof Error && 'code' in error && error.code !== 'ENOENT') {
throw new AccountError(
'Failed to delete token',
'TOKEN_DELETE_ERROR',
'Please ensure you have permission to delete the token file'
);
}
}
}
/**
* Basic token validation - just checks if token exists and isn't expired.
* No scope validation - auth issues handled via 401 responses.
*/
/**
* Attempts to automatically renew a token if it's expired or near expiry
* Returns the renewal result and new token if successful
*/
async autoRenewToken(email: string): Promise<TokenRenewalResult> {
logger.debug(`Attempting auto-renewal for account: ${email}`);
try {
const token = await this.loadToken(email);
if (!token) {
return {
success: false,
status: 'NO_TOKEN',
reason: 'No token found'
};
}
if (!token.expiry_date) {
return {
success: false,
status: 'INVALID',
reason: 'Invalid token format'
};
}
// Check if token is expired or will expire soon
const now = Date.now();
if (token.expiry_date <= now + this.TOKEN_EXPIRY_BUFFER_MS) {
if (!token.refresh_token || !this.oauthClient) {
return {
success: false,
status: 'REFRESH_FAILED',
reason: 'No refresh token or OAuth client available'
};
}
try {
// Attempt to refresh the token
const newToken = await this.oauthClient.refreshToken(token.refresh_token);
await this.saveToken(email, newToken);
logger.info('Token refreshed successfully');
return {
success: true,
status: 'REFRESHED',
token: newToken
};
} catch (error) {
// Check if the error indicates an invalid/revoked refresh token
const errorMessage = error instanceof Error ? error.message.toLowerCase() : '';
const isRefreshTokenInvalid =
errorMessage.includes('invalid_grant') ||
errorMessage.includes('token has been revoked') ||
errorMessage.includes('token not found');
if (!isRefreshTokenInvalid) {
// If it's not a refresh token issue, try one more time
try {
logger.warn('First refresh attempt failed, trying once more');
const newToken = await this.oauthClient.refreshToken(token.refresh_token);
await this.saveToken(email, newToken);
logger.info('Token refreshed successfully on second attempt');
return {
success: true,
status: 'REFRESHED',
token: newToken
};
} catch (secondError) {
logger.error('Both refresh attempts failed, but refresh token may still be valid');
return {
success: false,
status: 'REFRESH_FAILED',
reason: 'Token refresh failed, temporary error',
canRetry: true
};
}
}
// Refresh token is invalid, need full reauth
logger.error('Refresh token is invalid or revoked');
return {
success: false,
status: 'REFRESH_FAILED',
reason: 'Refresh token is invalid or revoked',
canRetry: false
};
}
}
// Token is still valid
return {
success: true,
status: 'VALID',
token
};
} catch (error) {
logger.error('Token auto-renewal error', error as Error);
return {
success: false,
status: 'ERROR',
reason: 'Token auto-renewal failed'
};
}
}
async validateToken(email: string, skipValidationForNew: boolean = false): Promise<TokenStatus> {
logger.debug(`Validating token for account: ${email}`);
try {
const token = await this.loadToken(email);
if (!token) {
logger.debug('No token found');
return {
valid: false,
status: 'NO_TOKEN',
reason: 'No token found'
};
}
// Skip validation if this is a new account setup
if (skipValidationForNew) {
logger.debug('Skipping validation for new account setup');
return {
valid: true,
status: 'VALID',
token
};
}
if (!token.expiry_date) {
logger.debug('Token missing expiry date');
return {
valid: false,
status: 'INVALID',
reason: 'Invalid token format'
};
}
if (token.expiry_date < Date.now()) {
logger.debug('Token has expired, attempting refresh');
if (token.refresh_token && this.oauthClient) {
try {
const newToken = await this.oauthClient.refreshToken(token.refresh_token);
await this.saveToken(email, newToken);
logger.info('Token refreshed successfully');
return {
valid: true,
status: 'REFRESHED',
token: newToken,
requiredScopes: newToken.scope ? newToken.scope.split(' ') : undefined
};
} catch (error) {
logger.error('Token refresh failed', error as Error);
return {
valid: false,
status: 'REFRESH_FAILED',
reason: 'Token refresh failed'
};
}
}
logger.debug('No refresh token available');
return {
valid: false,
status: 'EXPIRED',
reason: 'Token expired and no refresh token available'
};
}
logger.debug('Token is valid');
return {
valid: true,
status: 'VALID',
token,
requiredScopes: token.scope ? token.scope.split(' ') : undefined
};
} catch (error) {
logger.error('Token validation error', error as Error);
return {
valid: false,
status: 'ERROR',
reason: 'Token validation failed'
};
}
}
}