mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
277 lines
8 KiB
TypeScript
277 lines
8 KiB
TypeScript
import { Router } from 'express';
|
|
import { AuthService } from '../services/auth-service.js';
|
|
import { exec } from 'child_process';
|
|
import { promisify } from 'util';
|
|
|
|
const execAsync = promisify(exec);
|
|
|
|
interface AuthRoutesConfig {
|
|
authService: AuthService;
|
|
enableSSHKeys?: boolean;
|
|
disallowUserPassword?: boolean;
|
|
noAuth?: boolean;
|
|
allowLocalBypass?: boolean;
|
|
}
|
|
|
|
export function createAuthRoutes(config: AuthRoutesConfig): Router {
|
|
const router = Router();
|
|
const { authService } = config;
|
|
|
|
/**
|
|
* Create authentication challenge for SSH key auth
|
|
* POST /api/auth/challenge
|
|
*/
|
|
router.post('/challenge', async (req, res) => {
|
|
try {
|
|
const { userId } = req.body;
|
|
|
|
if (!userId) {
|
|
return res.status(400).json({ error: 'User ID is required' });
|
|
}
|
|
|
|
// Check if user exists
|
|
const userExists = await authService.userExists(userId);
|
|
if (!userExists) {
|
|
return res.status(404).json({ error: 'User not found' });
|
|
}
|
|
|
|
// Create challenge
|
|
const challenge = authService.createChallenge(userId);
|
|
|
|
res.json({
|
|
challengeId: challenge.challengeId,
|
|
challenge: challenge.challenge,
|
|
expiresAt: Date.now() + 5 * 60 * 1000, // 5 minutes
|
|
});
|
|
} catch (error) {
|
|
console.error('Error creating auth challenge:', error);
|
|
res.status(500).json({ error: 'Failed to create authentication challenge' });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Authenticate with SSH key
|
|
* POST /api/auth/ssh-key
|
|
*/
|
|
router.post('/ssh-key', async (req, res) => {
|
|
try {
|
|
const { challengeId, publicKey, signature } = req.body;
|
|
|
|
if (!challengeId || !publicKey || !signature) {
|
|
return res.status(400).json({
|
|
error: 'Challenge ID, public key, and signature are required',
|
|
});
|
|
}
|
|
|
|
const result = await authService.authenticateWithSSHKey({
|
|
challengeId,
|
|
publicKey,
|
|
signature,
|
|
});
|
|
|
|
if (result.success) {
|
|
res.json({
|
|
success: true,
|
|
token: result.token,
|
|
userId: result.userId,
|
|
authMethod: 'ssh-key',
|
|
});
|
|
} else {
|
|
res.status(401).json({
|
|
success: false,
|
|
error: result.error,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('Error authenticating with SSH key:', error);
|
|
res.status(500).json({ error: 'SSH key authentication failed' });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Authenticate with password
|
|
* POST /api/auth/password
|
|
*/
|
|
router.post('/password', async (req, res) => {
|
|
try {
|
|
const { userId, password } = req.body;
|
|
|
|
if (!userId || !password) {
|
|
return res.status(400).json({
|
|
error: 'User ID and password are required',
|
|
});
|
|
}
|
|
|
|
const result = await authService.authenticateWithPassword(userId, password);
|
|
|
|
if (result.success) {
|
|
res.json({
|
|
success: true,
|
|
token: result.token,
|
|
userId: result.userId,
|
|
authMethod: 'password',
|
|
});
|
|
} else {
|
|
res.status(401).json({
|
|
success: false,
|
|
error: result.error,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('Error authenticating with password:', error);
|
|
res.status(500).json({ error: 'Password authentication failed' });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Verify current authentication status
|
|
* GET /api/auth/verify
|
|
*/
|
|
router.get('/verify', (req, res) => {
|
|
try {
|
|
const authHeader = req.headers.authorization;
|
|
|
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
return res.status(401).json({ valid: false, error: 'No token provided' });
|
|
}
|
|
|
|
const token = authHeader.slice(7);
|
|
const verification = authService.verifyToken(token);
|
|
|
|
if (verification.valid) {
|
|
res.json({
|
|
valid: true,
|
|
userId: verification.userId,
|
|
});
|
|
} else {
|
|
res.status(401).json({
|
|
valid: false,
|
|
error: 'Invalid or expired token',
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('Error verifying token:', error);
|
|
res.status(500).json({ error: 'Token verification failed' });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Get current system user (for initial auth)
|
|
* GET /api/auth/current-user
|
|
*/
|
|
router.get('/current-user', (req, res) => {
|
|
try {
|
|
const currentUser = authService.getCurrentUser();
|
|
res.json({ userId: currentUser });
|
|
} catch (error) {
|
|
console.error('Error getting current user:', error);
|
|
res.status(500).json({ error: 'Failed to get current user' });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Get authentication configuration
|
|
* GET /api/auth/config
|
|
*/
|
|
router.get('/config', (req, res) => {
|
|
try {
|
|
// Check if this is a local request and local bypass is enabled
|
|
const clientIp = req.ip || req.socket.remoteAddress || '';
|
|
const localIPs = ['127.0.0.1', '::1', '::ffff:127.0.0.1', 'localhost'];
|
|
const isLocalRequest =
|
|
localIPs.includes(clientIp) && !req.headers['x-forwarded-for'] && !req.headers['x-real-ip'];
|
|
|
|
// If local bypass is enabled and this is a local request, report as noAuth
|
|
const effectiveNoAuth = config.noAuth || (config.allowLocalBypass && isLocalRequest);
|
|
|
|
res.json({
|
|
enableSSHKeys: config.enableSSHKeys || false,
|
|
disallowUserPassword: config.disallowUserPassword || false,
|
|
noAuth: effectiveNoAuth,
|
|
});
|
|
} catch (error) {
|
|
console.error('Error getting auth config:', error);
|
|
res.status(500).json({ error: 'Failed to get auth config' });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Get user avatar (macOS only)
|
|
* GET /api/auth/avatar/:userId
|
|
*/
|
|
router.get('/avatar/:userId', async (req, res) => {
|
|
try {
|
|
const { userId } = req.params;
|
|
|
|
// Check if we're on macOS
|
|
if (process.platform !== 'darwin') {
|
|
return res.json({ avatar: null, platform: process.platform });
|
|
}
|
|
|
|
// Try to get user's JPEGPhoto from Directory Services
|
|
try {
|
|
const { stdout } = await execAsync(`dscl . -read /Users/${userId} JPEGPhoto`);
|
|
|
|
// Check if JPEGPhoto exists and extract the hex data
|
|
if (stdout.includes('JPEGPhoto:')) {
|
|
const lines = stdout.split('\n');
|
|
const hexLines = lines
|
|
.slice(1)
|
|
.filter((line) => line.trim() && !line.startsWith('dsAttrTypeNative'));
|
|
|
|
if (hexLines.length > 0) {
|
|
// Join all hex lines and remove spaces
|
|
const hexData = hexLines.join('').replace(/\s/g, '');
|
|
|
|
// Convert hex to base64
|
|
const buffer = Buffer.from(hexData, 'hex');
|
|
const base64 = buffer.toString('base64');
|
|
|
|
return res.json({
|
|
avatar: `data:image/jpeg;base64,${base64}`,
|
|
platform: 'darwin',
|
|
source: 'dscl',
|
|
});
|
|
}
|
|
}
|
|
} catch (_dsclError) {
|
|
console.log('No JPEGPhoto found for user, trying Picture attribute');
|
|
}
|
|
|
|
// Fallback: try Picture attribute (file path)
|
|
try {
|
|
const { stdout } = await execAsync(`dscl . -read /Users/${userId} Picture`);
|
|
if (stdout.includes('Picture:')) {
|
|
const picturePath = stdout.split('Picture:')[1].trim();
|
|
if (picturePath && picturePath !== 'Picture:') {
|
|
return res.json({
|
|
avatar: picturePath,
|
|
platform: 'darwin',
|
|
source: 'picture_path',
|
|
});
|
|
}
|
|
}
|
|
} catch (_pictureError) {
|
|
console.log('No Picture attribute found for user');
|
|
}
|
|
|
|
// No avatar found
|
|
res.json({ avatar: null, platform: 'darwin' });
|
|
} catch (error) {
|
|
console.error('Error getting user avatar:', error);
|
|
res.status(500).json({ error: 'Failed to get user avatar' });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Logout (invalidate token - client-side only for now)
|
|
* POST /api/auth/logout
|
|
*/
|
|
router.post('/logout', (req, res) => {
|
|
// For JWT tokens, logout is primarily client-side (remove token)
|
|
// In the future, we could implement token blacklisting
|
|
res.json({ success: true, message: 'Logged out successfully' });
|
|
});
|
|
|
|
return router;
|
|
}
|