ideas-generator/server/routes/chat.js
DJP 013f57fe60 Implement hybrid Azure AD SSO + Password authentication system
 Backend Implementation:
- Add Azure AD JWT token validation middleware
- Create hybrid authentication system supporting both Azure AD and password auth
- Implement auto-provisioning for new Azure AD users
- Add admin controls to toggle password authentication
- Update all API routes to use hybrid authentication
- Add database fields for authentication (password, lastLoginAt)
- Create comprehensive auth routes with validation endpoints

 Frontend Implementation:
- Install and configure Azure MSAL browser library
- Create Azure AD authentication service with popup/redirect support
- Build hybrid authentication service managing both auth methods
- Update Login.vue with modern dual-authentication UI
- Implement dynamic password auth toggle based on admin settings
- Update App.vue for proper session management and validation
- Modify API service to handle both token types

 Security Features:
- Azure AD tenant validation (Oliver Agency)
- Role-based access control with auto-admin assignment
- JWT token validation for both auth methods
- Automatic user provisioning with proper defaults
- Session validation and automatic logout on token expiry

 Admin Features:
- Toggle password authentication on/off
- Manage users from both authentication methods
- Full role and agent access control
- Azure AD user auto-provisioning as regular users

 Configuration:
- Azure AD: Tenant e519c2e6-bc6d-4fdf-8d9c-923c2f002385
- Client ID: 9079054c-9620-4757-a256-23413042f1ef
- Development redirect URI support
- Fallback password authentication for testing

🔧 Technical Stack:
- Azure MSAL Browser & Node libraries
- JWT token validation and hybrid middleware
- Database schema updates with migrations
- Vue.js integration with MSAL
- Express.js hybrid authentication routes

🚀 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-09 16:14:02 -04:00

493 lines
No EOL
16 KiB
JavaScript

const express = require('express');
const multer = require('multer');
const { v4: uuidv4 } = require('uuid');
const { Assistant, Conversation, Message, User } = require('../models');
const openaiService = require('../utils/openai');
const responsesService = require('../utils/responses');
const { chatLimiter } = require('../middleware/rateLimiter');
const { hybridAuthenticate } = require('../middleware/azureAuth');
const router = express.Router();
// Configure multer for file uploads (store in memory)
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 10 * 1024 * 1024, // 10MB max file size
},
fileFilter: (req, file, cb) => {
// Accept images, PDFs, and text files
const allowedTypes = [
'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp',
'application/pdf', 'text/plain', 'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
];
if (allowedTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Invalid file type. Only images, PDFs, and text files are allowed.'));
}
}
});
router.use(chatLimiter);
// Helper function to generate title for first conversation
async function generateTitleIfNeeded(conversation, userMessage, assistantResponse, agentName) {
try {
// Check if this conversation needs a title (first exchange)
const messageCount = await Message.count({
where: { conversationId: conversation.id }
});
if (messageCount === 2 && !conversation.title) {
console.log(`Generating title for conversation ${conversation.id}`);
const title = await openaiService.generateConversationTitle(
userMessage,
assistantResponse,
agentName
);
await conversation.update({ title });
console.log(`Updated conversation ${conversation.id} with title: "${title}"`);
}
} catch (error) {
console.error('Error generating conversation title:', error);
// Don't throw - title generation failure shouldn't break chat
}
}
router.post('/completions', hybridAuthenticate, upload.array('files', 5), async (req, res, next) => {
try {
// Handle form data parsing when files are present
let messages, assistantKey, conversationId, userId, stream;
if (req.files && req.files.length > 0) {
// Parse JSON fields from multipart form data
console.log('Processing request with files:', req.files.length);
console.log('Form data fields:', Object.keys(req.body));
messages = req.body.messages ? JSON.parse(req.body.messages) : undefined;
assistantKey = req.body.assistantKey || 'creator-bot-push-the-boundaries-of-technology';
conversationId = req.body.conversationId && req.body.conversationId !== 'null' ? req.body.conversationId : null;
userId = req.body.userId;
stream = req.body.stream === 'true';
console.log('Parsed data:', { assistantKey, conversationId, userId, stream, messageCount: messages?.length });
} else {
// Use regular JSON body when no files
({
messages,
assistantKey = 'creator-bot-push-the-boundaries-of-technology',
conversationId,
userId,
stream = false
} = req.body);
}
// Ensure we have a valid UUID for userId
if (!userId || typeof userId !== 'string' || !userId.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)) {
userId = uuidv4(); // Generate a proper UUID
console.log(`Generated new userId: ${userId}`);
}
// Ensure user exists or create one
let user = await User.findByPk(userId);
if (!user) {
user = await User.create({
id: userId,
email: `user-${userId}@temp.com`,
name: `User ${userId.substring(0, 8)}`,
preferences: { theme: 'light', notifications: true, defaultAssistant: assistantKey }
});
console.log(`Created new user: ${user.id}`);
}
if (!messages || !Array.isArray(messages) || messages.length === 0) {
return res.status(400).json({
error: 'Validation Error',
message: 'Messages array is required and cannot be empty'
});
}
const agent = await Assistant.findOne({
where: { key: assistantKey, isActive: true }
});
if (!agent) {
return res.status(404).json({
error: 'Agent Not Found',
message: `Agent with key '${assistantKey}' not found or inactive`
});
}
let conversation;
let conversationHistory = [];
if (conversationId) {
conversation = await Conversation.findByPk(conversationId);
if (!conversation) {
return res.status(404).json({
error: 'Conversation Not Found',
message: 'Specified conversation not found'
});
}
// Load existing conversation history for context
const existingMessages = await Message.findAll({
where: { conversationId },
order: [['createdAt', 'ASC']]
});
conversationHistory = existingMessages.map(msg => ({
role: msg.role,
content: msg.content
}));
console.log(`Loaded ${conversationHistory.length} existing messages for conversation ${conversationId}`);
} else {
conversation = await Conversation.create({
id: uuidv4(),
userId,
assistantId: agent.id,
status: 'active',
lastMessageAt: new Date(),
});
}
// Build complete message history: system prompt + conversation history + new messages
const formattedMessages = [
openaiService.buildSystemMessage(agent.systemPrompt),
...conversationHistory,
...openaiService.formatMessagesForAPI(messages)
];
// Handle file uploads for responses agents
let processedFiles = [];
if (req.files && req.files.length > 0 && agent.agentType === 'responses') {
try {
console.log(`Processing ${req.files.length} files for Responses API`);
processedFiles = await responsesService.processFiles(req.files);
} catch (error) {
console.error('File processing error:', error);
return res.status(400).json({
error: 'File Processing Error',
message: error.message
});
}
} else if (req.files && req.files.length > 0 && agent.agentType === 'chat') {
return res.status(400).json({
error: 'File Upload Not Supported',
message: 'File uploads are only supported for responses agents'
});
}
console.log(`Total messages for API: ${formattedMessages.length} (${conversationHistory.length} history + ${messages.length} new + 1 system)`);
console.log(`Agent type: ${agent.agentType} (${agent.agentType === 'responses' ? 'Responses API' : 'Chat Completions API'})`);
console.log(`Agent tools config:`, {
agentType: agent.agentType,
webSearchEnabled: agent.webSearchEnabled,
fileSearchEnabled: agent.fileSearchEnabled,
codeInterpreterEnabled: agent.codeInterpreterEnabled
});
console.log(`Processed files: ${processedFiles.length}`);
if (stream) {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('Access-Control-Allow-Origin', '*');
let fullResponse = '';
if (agent.agentType === 'responses') {
// Use Responses API for streaming
const userInput = messages[messages.length - 1].content;
await responsesService.createStreamingResponse(
userInput,
agent,
conversation.id,
userId,
(data) => {
if (data.content) {
fullResponse += data.content;
res.write(`data: ${JSON.stringify({ content: data.content, done: false })}\n\n`);
}
if (data.done) {
fullResponse = data.fullResponse || fullResponse;
}
},
{ attachments: processedFiles }
);
} else {
// Use Chat Completions API for streaming
const streamResponse = await openaiService.createStreamingResponse(
formattedMessages,
{
model: agent.model,
temperature: agent.temperature,
maxTokens: agent.maxTokens,
reasoningEffort: agent.reasoningEffort,
}
);
for await (const chunk of streamResponse) {
const delta = chunk.choices[0]?.delta?.content || '';
if (delta) {
fullResponse += delta;
res.write(`data: ${JSON.stringify({ content: delta, done: false })}\n\n`);
}
}
}
await Message.create({
conversationId: conversation.id,
role: 'user',
content: messages[messages.length - 1].content,
});
await Message.create({
conversationId: conversation.id,
role: 'assistant',
content: fullResponse,
model: agent.model,
metadata: {
assistantKey,
agentType: agent.agentType,
toolsEnabled: agent.agentType === 'responses' ? {
webSearch: agent.webSearchEnabled,
fileSearch: agent.fileSearchEnabled,
codeInterpreter: agent.codeInterpreterEnabled
} : {}
},
});
await conversation.update({ lastMessageAt: new Date() });
// Generate title for first conversation
await generateTitleIfNeeded(
conversation,
messages[messages.length - 1].content,
fullResponse,
agent.name
);
res.write(`data: ${JSON.stringify({
content: '',
done: true,
conversationId: conversation.id,
totalTokens: null
})}\n\n`);
res.end();
} else {
let response;
let assistantResponse;
if (agent.agentType === 'responses') {
// Use Responses API for non-streaming
const userInput = messages[messages.length - 1].content;
response = await responsesService.createResponse(
userInput,
agent,
conversation.id,
userId,
{ attachments: processedFiles }
);
// Extract text from Responses API format
const messageOutput = response.output?.find(item => item.type === 'message');
const textContent = messageOutput?.content?.find(content => content.type === 'output_text');
assistantResponse = textContent?.text || response.output_text || '';
} else {
// Use Chat Completions API for non-streaming
response = await openaiService.createResponse(
formattedMessages,
{
model: agent.model,
temperature: agent.temperature,
maxTokens: agent.maxTokens,
reasoningEffort: agent.reasoningEffort,
}
);
assistantResponse = response.choices[0].message.content;
}
await Message.create({
conversationId: conversation.id,
role: 'user',
content: messages[messages.length - 1].content,
});
await Message.create({
conversationId: conversation.id,
role: 'assistant',
content: assistantResponse,
model: agent.model,
tokenCount: response.usage?.output_tokens || response.usage?.completion_tokens,
finishReason: response.status === 'completed' ? 'stop' : response.status,
metadata: {
assistantKey,
agentType: agent.agentType,
responseId: agent.agentType === 'responses' ? response.id : undefined,
toolsEnabled: agent.agentType === 'responses' ? {
webSearch: agent.webSearchEnabled,
fileSearch: agent.fileSearchEnabled,
codeInterpreter: agent.codeInterpreterEnabled
} : {}
},
});
await conversation.update({ lastMessageAt: new Date() });
// Generate title for first conversation
await generateTitleIfNeeded(
conversation,
messages[messages.length - 1].content,
assistantResponse,
agent.name
);
res.json({
response: assistantResponse,
conversationId: conversation.id,
usage: response.usage,
model: agent.model,
finishReason: agent.agentType === 'responses' ? response.status : response.choices[0]?.finish_reason,
});
}
} catch (error) {
console.error('Chat completion error:', error);
if (error.code === 'insufficient_quota') {
return res.status(402).json({
error: 'Quota Exceeded',
message: 'OpenAI API quota exceeded. Please check your billing.'
});
}
if (error.code === 'rate_limit_exceeded') {
return res.status(429).json({
error: 'Rate Limit Exceeded',
message: 'OpenAI API rate limit exceeded. Please try again later.'
});
}
error.code = 'OPENAI_API_ERROR';
next(error);
}
});
router.get('/conversations', async (req, res, next) => {
try {
const { userId, limit = 20, offset = 0 } = req.query;
const whereClause = {};
if (userId) {
whereClause.userId = userId;
}
const conversations = await Conversation.findAll({
where: { ...whereClause, status: 'active' },
include: [
{
model: Assistant,
as: 'assistant',
attributes: ['key', 'name', 'description']
}
],
order: [['lastMessageAt', 'DESC']],
limit: parseInt(limit),
offset: parseInt(offset),
});
res.json({
conversations: conversations.map(conv => ({
id: conv.id,
title: conv.title,
lastMessageAt: conv.lastMessageAt,
createdAt: conv.createdAt,
assistant: {
key: conv.assistant.key,
name: conv.assistant.name,
description: conv.assistant.description
}
})),
total: conversations.length
});
} catch (error) {
next(error);
}
});
router.get('/conversations/:conversationId/messages', async (req, res, next) => {
try {
const { conversationId } = req.params;
const { limit = 50, offset = 0 } = req.query;
const conversation = await Conversation.findByPk(conversationId);
if (!conversation) {
return res.status(404).json({
error: 'Conversation Not Found',
message: 'Specified conversation not found'
});
}
const messages = await Message.findAll({
where: { conversationId },
order: [['createdAt', 'ASC']],
limit: parseInt(limit),
offset: parseInt(offset),
});
res.json({
messages: messages.map(msg => ({
id: msg.id,
role: msg.role,
content: msg.content,
createdAt: msg.createdAt,
metadata: msg.metadata,
})),
conversationId,
total: messages.length,
});
} catch (error) {
next(error);
}
});
router.delete('/conversations/:conversationId', async (req, res, next) => {
try {
const { conversationId } = req.params;
const conversation = await Conversation.findByPk(conversationId);
if (!conversation) {
return res.status(404).json({
error: 'Conversation Not Found',
message: 'Specified conversation not found'
});
}
// Soft delete - change status to 'deleted' but preserve data
await conversation.update({
status: 'deleted',
updatedAt: new Date()
});
res.json({
message: 'Conversation deleted successfully',
conversationId: conversation.id
});
} catch (error) {
console.error('Error deleting conversation:', error);
next(error);
}
});
module.exports = router;