jay-code
Version:
Streamlined AI CLI orchestration engine with mathematical rigor and enterprise-grade reliability
447 lines (380 loc) • 11.6 kB
text/typescript
/**
* Authentication and authorization for MCP
*/
import type { MCPAuthConfig, MCPSession } from '../utils/types.js';
import type { ILogger } from '../core/logger.js';
import type { MCPError } from '../utils/errors.js';
import { createHash, timingSafeEqual } from 'node:crypto';
export interface IAuthManager {
authenticate(credentials: unknown): Promise<AuthResult>;
authorize(session: MCPSession, permission: string): boolean;
validateToken(token: string): Promise<TokenValidation>;
generateToken(userId: string, permissions: string[]): Promise<string>;
revokeToken(token: string): Promise<void>;
}
export interface AuthResult {
success: boolean;
user?: string;
permissions?: string[];
token?: string;
error?: string;
}
export interface TokenValidation {
valid: boolean;
user?: string;
permissions?: string[];
expiresAt?: Date;
error?: string;
}
/**
* Authentication manager implementation
*/
export class AuthManager implements IAuthManager {
private revokedTokens = new Set<string>();
private tokenStore = new Map<
string,
{
user: string;
permissions: string[];
createdAt: Date;
expiresAt: Date;
}
>();
constructor(
private config: MCPAuthConfig,
private logger: ILogger,
) {
// Start token cleanup timer
if (config.enabled) {
setInterval(() => {
this.cleanupExpiredTokens();
}, 300000); // Clean up every 5 minutes
}
}
async authenticate(credentials: unknown): Promise<AuthResult> {
if (!this.config.enabled) {
return {
success: true,
user: 'anonymous',
permissions: ['*'],
};
}
this.logger.debug('Authenticating credentials', {
method: this.config.method,
hasCredentials: !!credentials,
});
try {
switch (this.config.method) {
case 'token':
return await this.authenticateToken(credentials);
case 'basic':
return await this.authenticateBasic(credentials);
case 'oauth':
return await this.authenticateOAuth(credentials);
default:
return {
success: false,
error: `Unsupported authentication method: ${this.config.method}`,
};
}
} catch (error) {
this.logger.error('Authentication error', error);
return {
success: false,
error:
error instanceof Error
? error instanceof Error
? error.message
: String(error)
: 'Authentication failed',
};
}
}
authorize(session: MCPSession, permission: string): boolean {
if (!this.config.enabled || !session.authenticated) {
return !this.config.enabled; // If auth disabled, allow all
}
const permissions = session.authData?.permissions || [];
// Check for wildcard permission
if (permissions.includes('*')) {
return true;
}
// Check for exact permission match
if (permissions.includes(permission)) {
return true;
}
// Check for prefix-based permissions (e.g., "tools.*" matches "tools.list")
for (const perm of permissions) {
if (perm.endsWith('*') && permission.startsWith(perm.slice(0, -1))) {
return true;
}
}
this.logger.warn('Authorization denied', {
sessionId: session.id,
user: session.authData?.user,
permission,
userPermissions: permissions,
});
return false;
}
async validateToken(token: string): Promise<TokenValidation> {
if (this.revokedTokens.has(token)) {
return {
valid: false,
error: 'Token has been revoked',
};
}
const tokenData = this.tokenStore.get(token);
if (!tokenData) {
return {
valid: false,
error: 'Invalid token',
};
}
if (tokenData.expiresAt < new Date()) {
this.tokenStore.delete(token);
return {
valid: false,
error: 'Token has expired',
};
}
return {
valid: true,
user: tokenData.user,
permissions: tokenData.permissions,
expiresAt: tokenData.expiresAt,
};
}
async generateToken(userId: string, permissions: string[]): Promise<string> {
const token = this.createSecureToken();
const now = new Date();
const expiresAt = new Date(now.getTime() + (this.config.sessionTimeout || 3600000));
this.tokenStore.set(token, {
user: userId,
permissions,
createdAt: now,
expiresAt,
});
this.logger.info('Token generated', {
userId,
permissions,
expiresAt,
});
return token;
}
async revokeToken(token: string): Promise<void> {
this.revokedTokens.add(token);
this.tokenStore.delete(token);
this.logger.info('Token revoked', { token: token.substring(0, 8) + '...' });
}
private async authenticateToken(credentials: unknown): Promise<AuthResult> {
const token = this.extractToken(credentials);
if (!token) {
return {
success: false,
error: 'Token not provided',
};
}
// Check if it's a stored token (generated by us)
const validation = await this.validateToken(token);
if (validation.valid) {
return {
success: true,
user: validation.user!,
permissions: validation.permissions!,
token,
};
}
// Check against configured static tokens
if (this.config.tokens && this.config.tokens.length > 0) {
const isValid = this.config.tokens.some((validToken) => {
return this.timingSafeEqual(token, validToken);
});
if (isValid) {
return {
success: true,
user: 'token-user',
permissions: ['*'], // Static tokens get all permissions
token,
};
}
}
return {
success: false,
error: 'Invalid token',
};
}
private async authenticateBasic(credentials: unknown): Promise<AuthResult> {
const { username, password } = this.extractBasicAuth(credentials);
if (!username || !password) {
return {
success: false,
error: 'Username and password required',
};
}
if (!this.config.users || this.config.users.length === 0) {
return {
success: false,
error: 'No users configured',
};
}
const user = this.config.users.find((u) => u.username === username);
if (!user) {
return {
success: false,
error: 'Invalid username or password',
};
}
// Verify password
const isValidPassword = this.verifyPassword(password, user.password);
if (!isValidPassword) {
return {
success: false,
error: 'Invalid username or password',
};
}
// Generate a session token
const token = await this.generateToken(username, user.permissions);
return {
success: true,
user: username,
permissions: user.permissions,
token,
};
}
private async authenticateOAuth(credentials: unknown): Promise<AuthResult> {
// TODO: Implement OAuth authentication
// This would typically involve:
// 1. Validating JWT tokens
// 2. Checking token expiration
// 3. Extracting user info and permissions from token claims
this.logger.warn('OAuth authentication not yet implemented');
return {
success: false,
error: 'OAuth authentication not implemented',
};
}
private extractToken(credentials: unknown): string | null {
if (typeof credentials === 'string') {
return credentials;
}
if (typeof credentials === 'object' && credentials !== null) {
const creds = credentials as Record<string, unknown>;
if (typeof creds.token === 'string') {
return creds.token;
}
if (typeof creds.authorization === 'string') {
const match = creds.authorization.match(/^Bearer\s+(.+)$/i);
return match ? match[1] : null;
}
}
return null;
}
private extractBasicAuth(credentials: unknown): { username?: string; password?: string } {
if (typeof credentials === 'object' && credentials !== null) {
const creds = credentials as Record<string, unknown>;
if (typeof creds.username === 'string' && typeof creds.password === 'string') {
return {
username: creds.username,
password: creds.password,
};
}
if (typeof creds.authorization === 'string') {
const match = creds.authorization.match(/^Basic\s+(.+)$/i);
if (match) {
try {
const decoded = atob(match[1]);
const colonIndex = decoded.indexOf(':');
if (colonIndex >= 0) {
return {
username: decoded.substring(0, colonIndex),
password: decoded.substring(colonIndex + 1),
};
}
} catch {
// Invalid base64
}
}
}
}
return {};
}
private verifyPassword(providedPassword: string, storedPassword: string): boolean {
// For now, using simple hash comparison
// In production, use proper password hashing like bcrypt
const hashedProvided = this.hashPassword(providedPassword);
const hashedStored = this.hashPassword(storedPassword);
return this.timingSafeEqual(hashedProvided, hashedStored);
}
private hashPassword(password: string): string {
return createHash('sha256').update(password).digest('hex');
}
private timingSafeEqual(a: string, b: string): boolean {
const encoder = new TextEncoder();
const bufferA = encoder.encode(a);
const bufferB = encoder.encode(b);
if (bufferA.length !== bufferB.length) {
return false;
}
return timingSafeEqual(bufferA, bufferB);
}
private createSecureToken(): string {
// Generate a secure random token
const timestamp = Date.now().toString(36);
const random1 = Math.random().toString(36).substring(2, 15);
const random2 = Math.random().toString(36).substring(2, 15);
const hash = createHash('sha256')
.update(`${timestamp}${random1}${random2}`)
.digest('hex')
.substring(0, 32);
return `mcp_${timestamp}_${hash}`;
}
private cleanupExpiredTokens(): void {
const now = new Date();
let cleaned = 0;
for (const [token, data] of this.tokenStore.entries()) {
if (data.expiresAt < now) {
this.tokenStore.delete(token);
cleaned++;
}
}
if (cleaned > 0) {
this.logger.debug('Cleaned up expired tokens', { count: cleaned });
}
}
}
/**
* Permission constants for common operations
*/
export const Permissions = {
// System operations
SYSTEM_INFO: 'system.info',
SYSTEM_HEALTH: 'system.health',
SYSTEM_METRICS: 'system.metrics',
// Tool operations
TOOLS_LIST: 'tools.list',
TOOLS_INVOKE: 'tools.invoke',
TOOLS_DESCRIBE: 'tools.describe',
// Agent operations
AGENTS_LIST: 'agents.list',
AGENTS_SPAWN: 'agents.spawn',
AGENTS_TERMINATE: 'agents.terminate',
AGENTS_INFO: 'agents.info',
// Task operations
TASKS_LIST: 'tasks.list',
TASKS_CREATE: 'tasks.create',
TASKS_CANCEL: 'tasks.cancel',
TASKS_STATUS: 'tasks.status',
// Memory operations
MEMORY_READ: 'memory.read',
MEMORY_WRITE: 'memory.write',
MEMORY_QUERY: 'memory.query',
MEMORY_DELETE: 'memory.delete',
// Administrative operations
ADMIN_CONFIG: 'admin.config',
ADMIN_LOGS: 'admin.logs',
ADMIN_SESSIONS: 'admin.sessions',
// Wildcard permission
ALL: '*',
} as const;
export type Permission = (typeof Permissions)[keyof typeof Permissions];