UNPKG

open-collaboration-server

Version:

Open Collaboration Server implementation, part of the Open Collaboration Tools project

440 lines (412 loc) 18.6 kB
// ****************************************************************************** // Copyright 2024 TypeFox GmbH // This program and the accompanying materials are made available under the // terms of the MIT License, which is available in the project root. // ****************************************************************************** import { inject, injectable, multiInject } from 'inversify'; import * as http from 'node:http'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; import { Server } from 'socket.io'; import express from 'express'; import { SocketIoChannel, TransportChannel } from './channel.js'; import { PeerFactory } from './peer.js'; import { RoomJoinInfo, RoomManager, isRoomClaim } from './room-manager.js'; import { UserManager } from './user-manager.js'; import { CredentialsManager } from './credentials-manager.js'; import { User } from './types.js'; import { CreateRoomResponse, InfoMessage, JoinRoomInitialResponse, JoinRoomPollResponse, JoinRoomResponse, ProtocolServerMetaData, LoginInitialResponse, LoginValidateResponse, LoginPollResponse } from 'open-collaboration-protocol'; import { AuthEndpoint } from './auth-endpoints/auth-endpoint.js'; import { Logger } from './utils/logging.js'; import { VERSION } from 'open-collaboration-protocol'; import { Configuration } from './utils/configuration.js'; import { PeerManager } from './peer-manager.js'; import cookieParser from 'cookie-parser'; // resolves __filename export const getLocalFilename = (referenceUrl: string | URL) => { return fileURLToPath(referenceUrl); }; // resolves __dirname export const getLocalDirectory = (referenceUrl: string | URL) => { return path.dirname(getLocalFilename(referenceUrl)); }; export interface CollaborationServerOptions { port: number; hostname: string; } @injectable() export class CollaborationServer { @inject(RoomManager) protected readonly roomManager: RoomManager; @inject(UserManager) protected readonly userManager: UserManager; @inject(CredentialsManager) protected readonly credentials: CredentialsManager; @inject(PeerFactory) protected readonly peerFactory: PeerFactory; @inject(PeerManager) protected readonly peerManager: PeerManager; @inject(Logger) protected logger: Logger; @inject(Configuration) protected configuration: Configuration; @multiInject(AuthEndpoint) protected readonly authEndpoints: AuthEndpoint[]; startServer(opts: CollaborationServerOptions): void { this.logger.debug('Starting Open Collaboration Server ...'); const app = this.setupApiRoute(); const httpServer = http.createServer(app); // const wsServer = new ws.Server({ // path: '/websocket', // server: httpServer // }); // wsServer.on('connection', async (socket, req) => { // try { // const query = req.url?.split('?')[1] ?? ''; // const headers = query.split('&').reduce((acc, cur) => { // const [key, value] = cur.split('='); // if (typeof key === 'string' && typeof value === 'string') { // acc[decodeURIComponent(key.trim())] = decodeURIComponent(value.trim()); // } // return acc; // }, {} as Record<string, string>); // await this.connectChannel(headers, new WebSocketChannel(socket)); // } catch (error) { // socket.close(undefined, 'Failed to join room'); // this.logger.error('Web socket connection failed', error); // } // }); const io = new Server(httpServer, { cors: { origin: '*', methods: ['GET', 'POST'] } }); io.on('connection', async socket => { const headers = socket.request.headers as Record<string, string>; try { await this.connectChannel(headers, new SocketIoChannel(socket)); } catch (error) { socket.disconnect(true); this.logger.error('Socket IO connection failed', error); } }); httpServer.listen(Number(opts.port), String(opts.hostname)); for (const authEndpoint of this.authEndpoints) { if (authEndpoint.shouldActivate()) { authEndpoint.onStart(app, String(opts.hostname), Number(opts.port)); authEndpoint.onDidAuthenticate(async event => { try { await this.credentials.confirmUser(event.token, event.userInfo); } catch (err) { this.logger.error('Failed to confirm user', err); throw new Error('Failed to confirm user'); } }); } } this.logger.info(`Open Collaboration Server listening on ${opts.hostname}:${opts.port}`); } protected async connectChannel(headers: Record<string, string | undefined>, channel: TransportChannel): Promise<void> { const jwt = headers['x-oct-jwt']; if (!jwt) { throw this.logger.createErrorAndLog('No JWT auth token set'); } const publicKey = headers['x-oct-public-key']; if (!publicKey) { throw this.logger.createErrorAndLog('No encryption key set'); } let compression = headers['x-oct-compression']?.split(','); if (compression === undefined || compression.length === 0) { compression = ['none']; } const client = headers['x-oct-client'] ?? 'unknown'; const roomClaim = await this.credentials.verifyJwt(jwt, isRoomClaim); const existingPeer = this.peerManager.getPeer(jwt); if (existingPeer) { // If a peer with the same JWT already exists, we just update the channel // This indicates that a client has reconnected existingPeer.channel.transport = channel; } else { const peer = this.peerFactory({ jwt, user: roomClaim.user, host: roomClaim.host ?? false, channel, client, publicKey, supportedCompression: compression }); this.peerManager.register(peer); await this.roomManager.join(peer, roomClaim.room); } } protected async getUserFromAuth(req: express.Request): Promise<User | undefined> { const auth = (req.headers['x-oct-jwt'] ?? req.cookies?.['oct-jwt']) as string; try { const user = await this.credentials.getUser(auth); return user; } catch { return undefined; } } protected setupApiRoute(): express.Express { const app = express(); app.use(express.json()); app.use(cookieParser()); const allowedOrigins = this.configuration.getValue('oct-cors-allowed-origins')?.split(',') ?? '*'; app.use((req, res, next) => { if(req.headers?.origin && allowedOrigins !== '*' && !allowedOrigins.includes(req.headers.origin)) { res.status(403); res.send('Origin not allowed'); return; } res.header('Access-Control-Allow-Origin', req.headers.origin ?? '*'); res.header('Access-Control-Allow-Credentials', 'true'); res.header('Access-Control-Allow-Headers', '*'); next(); }); app.use(async (req, res, next) => { if (req.method === 'POST' && req.url.startsWith('/api/session/')) { const user = await this.getUserFromAuth(req); if (!user) { res.status(403); res.send('Forbidden resource'); } else { next(); } } else { next(); } }); app.use(express.static(path.resolve(getLocalDirectory(import.meta.url), '../src/static'))); const loginPageUrlConfig = this.configuration.getValue('oct-login-page-url') ?? ''; app.post('/api/login/initial', async (req, res) => { try { const token = await this.credentials.startAuth(); let loginPage: string; try { const loginPageURL = new URL(loginPageUrlConfig); loginPageURL.searchParams.set('token', token); loginPage = loginPageURL.toString(); } catch (_error) { loginPage = `/login.html?token=${encodeURIComponent(token)}`; } // Ensure that we don't send inactive auth providers to the client const activeAuthProviders = this.authEndpoints.filter(e => e.shouldActivate()); const result: LoginInitialResponse = { pollToken: token, auth: { loginPageUrl: loginPage, providers: activeAuthProviders.map(endpoint => endpoint.getProtocolProvider()), defaultSuccessUrl: this.configuration.getValue('oct-login-success-url') ?? '' } }; res.status(200); res.send(result); } catch (error) { this.logger.error('Error occurred during login', error); res.status(400); res.send('Failed to login'); } }); app.post('/api/login/validate', async (req, res) => { const user = await this.getUserFromAuth(req); const result: LoginValidateResponse = { valid: !!user }; res.status(200); res.send(result); }); // for preflight requests app.options('/api/login/poll/:token', (req, res) => { res.header('Access-Control-Allow-Headers', 'content-type'); res.header('Access-Control-Allow-Methods', 'POST'); res.send(); }); app.post('/api/login/poll/:token', async (req, res) => { try { const authTimeoutResponse: InfoMessage = { code: 'AuthTimeout', params: [], message: 'Authentication timed out' }; const token = req.params.token; const delayedAuth = await this.credentials.getAuth(token); if (!delayedAuth) { res.status(400); res.send(authTimeoutResponse); return; } const sendToken = (token: string) => { const result: LoginPollResponse = { loginToken: token }; res.status(200); res.send(result); }; const addCookieHeader = (token: string) => { res.cookie('oct-jwt', token, { maxAge: 365 * 24 * 60 * 60 * 1000, // 1 year httpOnly: true, secure: true, sameSite: 'none' }); res.header('Access-Control-Allow-Credentials', 'true'); res.header('Access-Control-Allow-Origin', req.headers.origin ?? '*'); }; if (delayedAuth.jwt) { req.query?.useCookie && addCookieHeader(delayedAuth.jwt); sendToken(delayedAuth.jwt); delayedAuth.dispose(); } else { const end = async (value?: string | Error | undefined) => { clearTimeout(timeout); update.dispose(); failure.dispose(); if (value === undefined) { // No content res.status(204); res.send({}); } else if (typeof value === 'string') { req.query?.useCookie && addCookieHeader(value); sendToken(value); } else { res.status(400); res.send(authTimeoutResponse); delayedAuth.dispose(); } }; const timeout = setTimeout(() => { end(undefined); }, 30_000); const update = delayedAuth.onUpdate(jwt => end(jwt)); const failure = delayedAuth.onFail(err => end(err)); } } catch (error) { this.logger.error('Error occurred during login token confirmation', error); res.status(500); res.send({ code: 'AuthInternalError', params: [], message: 'Internal authentication server error' }); } }); app.get('/api/meta', async (_, res) => { const data: ProtocolServerMetaData = { owner: this.configuration.getValue('oct-server-owner') ?? 'Unknown', version: VERSION, transports: [ // 'websocket', 'socket.io' ], }; res.send(data); }); // only required for when using cookie based authentication app.get('/api/logout', async (req, res) => { const user = await this.getUserFromAuth(req); if (user) { res.clearCookie('oct-jwt', {sameSite: 'none', secure: true, httpOnly: true}); res.status(200); res.send('Logged out'); } else { res.status(400); res.send('no auth token cookie set or user for token not found'); } }); app.post('/api/session/join/:room', async (req, res) => { try { const roomId = req.params.room; const user = await this.getUserFromAuth(req); const room = this.roomManager.getRoomById(roomId); if (!room) { this.logger.warn(`User tried joining non-existing room with id '${roomId}'`); res.status(404); const roomNotFound: InfoMessage = { code: 'RoomNotFound', params: [], message: 'Room not found' }; res.send(roomNotFound); return; } const result = await this.roomManager.requestJoin(room, user!); res.status(200); const response: JoinRoomInitialResponse = { pollToken: result, roomId: roomId }; res.send(response); } catch (error) { this.logger.error('Error occurred while joining a room', error); res.status(500); res.send('An internal server error occurred'); } }); app.post('/api/session/poll/:token', async (req, res) => { try { const joinToken = req.params.token; const poll = this.roomManager.pollJoin(joinToken); if (!poll) { res.status(404); const joinNotFound: InfoMessage = { code: 'JoinRequestNotFound', params: [], message: 'Join request not found' }; res.send(joinNotFound); return; } if (poll.result) { res.status(200); res.send(poll.result); if (JoinRoomPollResponse.is(poll.result)) { poll.result = undefined; } // Don't dispose the result here, as it might be used for polling // It will be disposed after 5 minutes anyway return; } const end = async (value?: JoinRoomResponse | RoomJoinInfo) => { clearTimeout(timeout); update.dispose(); if (value === undefined) { // No content res.status(204); res.send({}); } else { res.status(200); res.send(value); if ('failure' in value) { poll.result = undefined; } } }; const timeout = setTimeout(() => { end(undefined); }, 30_000); const update = poll.onUpdate(response => end(response)); } catch (error) { this.logger.error('Error occurred while joining a room', error); res.status(500); res.send('An internal server error occurred'); } }); app.post('/api/session/create', async (req, res) => { try { const user = await this.getUserFromAuth(req); const room = await this.roomManager.prepareRoom(user!); const response: CreateRoomResponse = { roomId: room.id, roomToken: room.jwt }; res.send(response); } catch (error) { this.logger.error('Error occurred when creating a room', error); res.status(400); res.send('Failed to create room'); } }); return app; } }