UNPKG

open-collaboration-protocol

Version:

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

426 lines (393 loc) 16 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 { Encryption } from './messaging/encryption.js'; import { MessageTransportProvider } from './transport/transport.js'; import { ProtocolBroadcastConnection, createConnection } from './connection.js'; import * as semver from 'semver'; import * as types from './types.js'; import { SEM_VERSION, compatibleVersions } from './utils/version.js'; import { ServerError } from './utils/errors.js'; import { Info } from './utils/info.js'; export type Fetch = (url: string, options?: FetchRequestOptions) => Promise<FetchResponse>; export interface ConnectionProviderOptions { url: string; userToken?: string; client?: string; protocolVersion?: string; fetch: Fetch; /** * Client specific handler function to handle authentication. * * @param token The token supplied by the server to identify the authentication process. * @param authenticationMetadata The authentication metadata supplied by the server. * @returns Whether or not the authentication was successfully started. * `false` can be returned in case the browser fails to open the URL or the user does not supply a user name. * In that case the authentication process will be cancelled. */ authenticationHandler: (token: string, authenticationMetadata: types.AuthMetadata) => Promise<boolean>; transports: MessageTransportProvider[]; useCookieAuth?: boolean; } export interface FetchRequestOptions { method?: string; headers?: Record<string, string>; signal?: AbortSignal | null; credentials?: 'include' | 'same-origin' | 'omit'; body?: string } export interface FetchResponse { status?: number; ok: boolean; json(): Promise<any>; text(): Promise<string>; } export interface LoginOptions { abortSignal?: AbortSignal; reporter?: ResponseReporter; } export interface JoinRoomOptions { roomId: string; reporter?: ResponseReporter; abortSignal?: AbortSignal; } export interface CreateRoomOptions { reporter?: ResponseReporter; abortSignal?: AbortSignal; } export type ResponseReporter = (info: Info) => void; export class ConnectionProvider { private options: ConnectionProviderOptions; private fetch: Fetch; private protocolVersion: semver.SemVer; constructor(options: ConnectionProviderOptions) { this.options = options; this.fetch = options.fetch; this.userAuthToken = options.userToken; if (options.protocolVersion) { const parsed = semver.parse(options.protocolVersion); if (!parsed) { throw new Error('Invalid protocol version provided: ' + options.protocolVersion); } this.protocolVersion = parsed; } else { this.protocolVersion = SEM_VERSION; } } protected userAuthToken?: string; protected roomAuthToken?: string; get authToken(): string | undefined { return this.userAuthToken; } protected getUrl(path: string): string { // Remove trailing slashes from the base URL let url = this.options.url; if (url.endsWith('/')) { url = url.slice(0, -1); } if (path.startsWith('/')) { path = path.slice(1); } return `${url}/${path}`; } /** * @returns the auth token if the authentication or undefined when using cookie based authentication */ async login(options: LoginOptions): Promise<string> { options.reporter?.({ code: 'PerformingLogin', params: [], message: 'Performing login' }); const loginResponse = await this.fetch(this.getUrl('/api/login/initial'), { signal: options.abortSignal, method: 'POST' }); if (!loginResponse.ok) { throw new Error('Failed to get login URL'); } const loginBody = await loginResponse.json(); if (!types.LoginInitialResponse.is(loginBody)) { throw new Error('Invalid login response'); } const confirmToken = loginBody.pollToken; const url = loginBody.auth.loginPageUrl; const fullUrl = url?.startsWith('/') ? this.getUrl(url) : url; const authController = new AbortController(); const abortSignal = this.mergeAbortSignals(options.abortSignal, authController.signal); this.options.authenticationHandler(confirmToken, { ...loginBody.auth, loginPageUrl: fullUrl, }).then(success => { if (!success) { // If we failed to run the authentication process, abort the polling // This could be due to failing to open the URL or invalid login data authController.abort(); } }, () => authController.abort()); const authToken = await this.pollLogin(confirmToken, { ...options, abortSignal }); this.userAuthToken = authToken; return authToken; } private readonly cookieAuthPollOptions: Partial<FetchRequestOptions> = { credentials: 'include' }; private async pollLogin(confirmToken: string, options: LoginOptions): Promise<string> { while (true) { const confirmResponse = await this.fetch(this.getUrl(`/api/login/poll/${confirmToken}${this.options.useCookieAuth ? '?useCookie=true' : ''}`), { signal: options.abortSignal, method: 'POST', ...(this.options.useCookieAuth ? this.cookieAuthPollOptions : {}), }); if (confirmResponse.ok) { try { const confirmBody = await confirmResponse.json(); if (types.LoginPollResponse.is(confirmBody) && confirmBody.loginToken) { return confirmBody.loginToken; } } catch { // No token yet, keep polling } } else { throw await this.readError(confirmResponse); } } } /** * only neccessary for cookie based authentication to delete the cookie. * If not using cookie based authentication, just deletes the JWT. * Please ensure yourself it is not saved in local storage or similar. */ async logout(): Promise<void> { this.userAuthToken = undefined; if (this.options.useCookieAuth) { const logoutResponse = await this.fetch(this.getUrl('/api/logout'), { credentials: 'include' }); if (!logoutResponse.ok) { throw new Error('Failed to logout'); } } } async ensureCompatibility(): Promise<void> { const metadata = await this.getMetaData(); const serverVersion = semver.parse(metadata.version); if (!serverVersion) { throw new ServerError({ code: 'InvalidServerVersion', message: 'Invalid protocol version returned by server: ' + metadata.version, params: [metadata.version] }); } if (!compatibleVersions(serverVersion, this.protocolVersion)) { throw new ServerError({ code: 'IncompatibleProtocolVersions', message: `Incompatible protocol versions: client ${this.protocolVersion.format()}, server ${serverVersion.format()}`, params: [this.protocolVersion.format(), serverVersion.format()] }); } } async validate(): Promise<boolean> { if (this.userAuthToken || this.options.useCookieAuth) { try { const validateResponse = await this.fetch(this.getUrl('/api/login/validate'), { method: 'POST', headers: this.getAuthHeader(), credentials: this.options.useCookieAuth ? 'include' : 'omit' }); const body = await validateResponse.json(); if (types.LoginValidateResponse.is(body)) { return body.valid; } } catch { return false; } } return false; } async createRoom(options: CreateRoomOptions): Promise<types.CreateRoomResponse> { await this.ensureCompatibility(); const valid = await this.validate(); let loginToken: string | undefined; if (!valid) { loginToken = await this.login({ abortSignal: options.abortSignal, reporter: options.reporter }); } options.reporter?.({ code: 'AwaitingServerResponse', params: [], message: 'Awaiting server response' }); const response = await this.fetch(this.getUrl('/api/session/create'), { method: 'POST', signal: options.abortSignal, headers: this.getAuthHeader(), credentials: this.options.useCookieAuth ? 'include' : 'omit' }); if (!response.ok) { throw await this.readError(response); } const body = await response.json(); if (!types.CreateRoomResponse.is(body)) { throw new Error('Invalid create room response'); } return types.CreateRoomResponse.create(body.roomId, body.roomToken, loginToken); } async joinRoom(options: JoinRoomOptions): Promise<types.JoinRoomResponse> { await this.ensureCompatibility(); const valid = await this.validate(); let loginToken: string | undefined; if (!valid) { loginToken = await this.login({ abortSignal: options.abortSignal, reporter: options.reporter }); } options.reporter?.({ code: 'AwaitingServerResponse', params: [], message: 'Awaiting server response' }); const response = await this.fetch(this.getUrl(`/api/session/join/${options.roomId}`), { method: 'POST', signal: options.abortSignal, headers: this.getAuthHeader(), credentials: this.options.useCookieAuth ? 'include' : 'omit' }); if (!response.ok) { throw await this.readError(response); } const body = await response.json(); if (!types.JoinRoomInitialResponse.is(body)) { throw new Error('Invalid join room response'); } const joinToken = body.pollToken; const joinRoomResponse = await this.pollJoin(joinToken, options); return { loginToken, roomId: joinRoomResponse.roomId, roomToken: joinRoomResponse.roomToken, workspace: joinRoomResponse.workspace, host: joinRoomResponse.host }; } async pollJoin(joinToken: string, options: JoinRoomOptions): Promise<types.JoinRoomResponse> { while (true) { const response = await this.fetch(this.getUrl(`/api/session/poll/${joinToken}`), { method: 'POST', signal: options.abortSignal, headers: this.getAuthHeader(), credentials: this.options.useCookieAuth ? 'include' : 'omit' }); if (response.ok) { const body = await response.json(); if (types.JoinRoomPollResponse.is(body)) { // No token yet, report status if (body.failure) { throw new ServerError({ code: body.code, params: body.params, message: body.message }); } else { // Keep polling options.reporter?.(body); } } else if (types.JoinRoomResponse.is(body)) { return body; } else { throw new Error('Received invalid join room poll response'); } } else { // Something went wrong throw await this.readError(response); } } } private async readError(response: FetchResponse): Promise<Error> { try { const text = await response.text(); try { const body = JSON.parse(text); if (Info.is(body)) { return new ServerError(body); } else { return new Error(text); } } catch { return new Error(text); } } catch (_error) { return new Error('Unknown error'); } } async connect(roomAuthToken: string, host?: types.Peer): Promise<ProtocolBroadcastConnection> { const metadata = await this.getMetaData(); const transportIndex = this.findFitting(metadata.transports, this.options.transports.map(t => t.id)); const transportProvider = this.options.transports[transportIndex]; const keyPair = await Encryption.generateKeyPair(); const transport = transportProvider.createTransport(this.options.url, { 'x-oct-jwt': roomAuthToken, 'x-oct-public-key': keyPair.publicKey, 'x-oct-client': this.options.client ?? 'Unknown OCT JS Client', 'x-oct-compression': 'gzip' }); const connection = createConnection( { privateKey: keyPair.privateKey, transport, host } ); return connection; } private async getMetaData(): Promise<types.ProtocolServerMetaData> { const response = await this.fetch(this.getUrl('/api/meta')); if (!response.ok) { throw new Error('Failed to fetch metadata'); } return await response.json(); } private findFitting(available: string[], desired: string[]): number { const availableSet = new Set(available); for (let i = 0; i < desired.length; i++) { if (availableSet.has(desired[i])) { return i; } } return -1; } private mergeAbortSignals(...signals: Array<AbortSignal | undefined>): AbortSignal { const controller = new AbortController(); for (const signal of signals) { if (signal) { if (signal.aborted) { // If already aborted, just return that signal return signal; } signal.addEventListener('abort', () => { controller.abort(); }); } } return controller.signal; } private getAuthHeader(): Record<string, string> { if (this.options.useCookieAuth) { return {}; } else if (this.userAuthToken) { return { 'x-oct-jwt': this.userAuthToken }; } else { throw new Error('No authentication token available'); } } }