From dc50190dc3a091d984a309d27a089909469c1194 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 13 Aug 2025 17:30:59 -0700 Subject: [PATCH] Handle token --- ...{hybridClientServer.ts => hybridServer.ts} | 12 +- server/index.ts | 2 +- server/lib/index.ts | 1 + server/lib/remoteProxy.ts | 2 + server/lib/remoteTraefikConfig.ts | 4 +- server/lib/tokenManager.ts | 209 ++++++++++++++++++ server/routers/ws/client.ts | 85 +------ 7 files changed, 232 insertions(+), 83 deletions(-) rename server/{hybridClientServer.ts => hybridServer.ts} (91%) create mode 100644 server/lib/tokenManager.ts diff --git a/server/hybridClientServer.ts b/server/hybridServer.ts similarity index 91% rename from server/hybridClientServer.ts rename to server/hybridServer.ts index 074fcd2e..7ce7efd7 100644 --- a/server/hybridClientServer.ts +++ b/server/hybridServer.ts @@ -3,10 +3,11 @@ import express from "express"; import { parse } from "url"; import logger from "@server/logger"; import config from "@server/lib/config"; -import { WebSocketClient, createWebSocketClient } from "./routers/ws/client"; +import { createWebSocketClient } from "./routers/ws/client"; import { addPeer, deletePeer } from "./routers/gerbil/peers"; import { db, exitNodes } from "./db"; import { TraefikConfigManager } from "./lib/remoteTraefikConfig"; +import { tokenManager } from "./lib/tokenManager"; export async function createHybridClientServer() { const monitor = new TraefikConfigManager(); @@ -21,11 +22,14 @@ export async function createHybridClientServer() { throw new Error("Hybrid configuration is not defined"); } + // Start the token manager + await tokenManager.start(); + + const token = await tokenManager.getToken(); + // Create client const client = createWebSocketClient( - "remoteExitNode", // or 'olm' - config.getRawConfig().hybrid!.id!, - config.getRawConfig().hybrid!.secret!, + token, config.getRawConfig().hybrid!.endpoint!, { reconnectInterval: 5000, diff --git a/server/index.ts b/server/index.ts index 42f85da6..7fd328c2 100644 --- a/server/index.ts +++ b/server/index.ts @@ -7,7 +7,7 @@ import { createNextServer } from "./nextServer"; import { createInternalServer } from "./internalServer"; import { ApiKey, ApiKeyOrg, Session, User, UserOrg } from "@server/db"; import { createIntegrationApiServer } from "./integrationApiServer"; -import { createHybridClientServer } from "./hybridClientServer"; +import { createHybridClientServer } from "./privateHybridServer.js"; import config from "@server/lib/config"; async function startServers() { diff --git a/server/lib/index.ts b/server/lib/index.ts index 9d2cfb1f..7705e0af 100644 --- a/server/lib/index.ts +++ b/server/lib/index.ts @@ -1 +1,2 @@ export * from "./response"; +export { tokenManager, TokenManager } from "./tokenManager"; diff --git a/server/lib/remoteProxy.ts b/server/lib/remoteProxy.ts index 080c3bd3..e53f53f6 100644 --- a/server/lib/remoteProxy.ts +++ b/server/lib/remoteProxy.ts @@ -5,6 +5,7 @@ import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import config from "@server/lib/config"; +import { tokenManager } from "./tokenManager"; /** * Proxy function that forwards requests to the remote cloud server @@ -28,6 +29,7 @@ export const proxyToRemote = async ( data: req.body, headers: { 'Content-Type': 'application/json', + ...(await tokenManager.getAuthHeader()).headers }, params: req.query, timeout: 30000, // 30 second timeout diff --git a/server/lib/remoteTraefikConfig.ts b/server/lib/remoteTraefikConfig.ts index 08b2ab98..2e8ff529 100644 --- a/server/lib/remoteTraefikConfig.ts +++ b/server/lib/remoteTraefikConfig.ts @@ -5,6 +5,7 @@ import logger from "@server/logger"; import * as yaml from "js-yaml"; import axios from "axios"; import { db, exitNodes } from "@server/db"; +import { tokenManager } from "./tokenManager"; export class TraefikConfigManager { private intervalId: NodeJS.Timeout | null = null; @@ -162,7 +163,8 @@ export class TraefikConfigManager { } | null> { try { const resp = await axios.get( - `${config.getRawConfig().hybrid?.endpoint}/get-traefik-config` + `${config.getRawConfig().hybrid?.endpoint}/traefik-config`, + await tokenManager.getAuthHeader() ); if (resp.status !== 200) { diff --git a/server/lib/tokenManager.ts b/server/lib/tokenManager.ts new file mode 100644 index 00000000..040dc609 --- /dev/null +++ b/server/lib/tokenManager.ts @@ -0,0 +1,209 @@ +import axios from "axios"; +import config from "@server/lib/config"; +import logger from "@server/logger"; + +export interface TokenResponse { + success: boolean; + message?: string; + data: { + token: string; + }; +} + +/** + * Token Manager - Handles automatic token refresh for hybrid server authentication + * + * Usage throughout the application: + * ```typescript + * import { tokenManager } from "@server/lib/tokenManager"; + * + * // Get the current valid token + * const token = await tokenManager.getToken(); + * + * // Force refresh if needed + * await tokenManager.refreshToken(); + * ``` + * + * The token manager automatically refreshes tokens every 24 hours by default + * and is started once in the privateHybridServer.ts file. + */ + +export class TokenManager { + private token: string | null = null; + private refreshInterval: NodeJS.Timeout | null = null; + private isRefreshing: boolean = false; + private refreshIntervalMs: number; + + constructor(refreshIntervalMs: number = 24 * 60 * 60 * 1000) { + // Default to 24 hours + this.refreshIntervalMs = refreshIntervalMs; + } + + /** + * Start the token manager - gets initial token and sets up refresh interval + */ + async start(): Promise { + try { + await this.refreshToken(); + this.setupRefreshInterval(); + logger.info("Token manager started successfully"); + } catch (error) { + logger.error("Failed to start token manager:", error); + throw error; + } + } + + /** + * Stop the token manager and clear refresh interval + */ + stop(): void { + if (this.refreshInterval) { + clearInterval(this.refreshInterval); + this.refreshInterval = null; + } + logger.info("Token manager stopped"); + } + + /** + * Get the current valid token + */ + async getToken(): Promise { + if (!this.token) { + if (this.isRefreshing) { + // Wait for current refresh to complete + await this.waitForRefresh(); + } else { + await this.refreshToken(); + } + } + + if (!this.token) { + throw new Error("No valid token available"); + } + + return this.token; + } + + async getAuthHeader() { + return { + headers: { + Authorization: `Bearer ${await this.getToken()}` + } + }; + } + + /** + * Force refresh the token + */ + async refreshToken(): Promise { + if (this.isRefreshing) { + await this.waitForRefresh(); + return; + } + + this.isRefreshing = true; + + try { + const hybridConfig = config.getRawConfig().hybrid; + + if ( + !hybridConfig?.id || + !hybridConfig?.secret || + !hybridConfig?.endpoint + ) { + throw new Error("Hybrid configuration is not defined"); + } + + const tokenEndpoint = `${hybridConfig.endpoint}/api/v1/auth/remoteExitNode/get-token`; + + const tokenData = { + remoteExitNodeId: hybridConfig.id, + secret: hybridConfig.secret + }; + + logger.debug("Requesting new token from server"); + + const response = await axios.post( + tokenEndpoint, + tokenData, + { + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": "x-csrf-protection" + }, + timeout: 10000 // 10 second timeout + } + ); + + if (!response.data.success) { + throw new Error( + `Failed to get token: ${response.data.message}` + ); + } + + if (!response.data.data.token) { + throw new Error("Received empty token from server"); + } + + this.token = response.data.data.token; + logger.debug("Token refreshed successfully"); + } catch (error) { + logger.error("Failed to refresh token:", error); + + if (axios.isAxiosError(error)) { + if (error.response) { + throw new Error( + `Failed to get token with status code: ${error.response.status}` + ); + } else if (error.request) { + throw new Error( + "Failed to request new token: No response received" + ); + } else { + throw new Error( + `Failed to request new token: ${error.message}` + ); + } + } else { + throw new Error(`Failed to get token: ${error}`); + } + } finally { + this.isRefreshing = false; + } + } + + /** + * Set up automatic token refresh interval + */ + private setupRefreshInterval(): void { + if (this.refreshInterval) { + clearInterval(this.refreshInterval); + } + + this.refreshInterval = setInterval(async () => { + try { + logger.debug("Auto-refreshing token"); + await this.refreshToken(); + } catch (error) { + logger.error("Failed to auto-refresh token:", error); + } + }, this.refreshIntervalMs); + } + + /** + * Wait for current refresh operation to complete + */ + private async waitForRefresh(): Promise { + return new Promise((resolve) => { + const checkInterval = setInterval(() => { + if (!this.isRefreshing) { + clearInterval(checkInterval); + resolve(); + } + }, 100); + }); + } +} + +// Export a singleton instance for use throughout the application +export const tokenManager = new TokenManager(); diff --git a/server/routers/ws/client.ts b/server/routers/ws/client.ts index 2cd5cfd7..3f1fbf54 100644 --- a/server/routers/ws/client.ts +++ b/server/routers/ws/client.ts @@ -14,14 +14,6 @@ export interface WSMessage { data: any; } -export interface TokenResponse { - success: boolean; - message?: string; - data: { - token: string; - }; -} - export type MessageHandler = (message: WSMessage) => void; export interface ClientOptions { @@ -33,45 +25,32 @@ export interface ClientOptions { export class WebSocketClient extends EventEmitter { private conn: WebSocket | null = null; - private config: Config; private baseURL: string; private handlers: Map = new Map(); private reconnectInterval: number; private isConnected: boolean = false; private pingInterval: number; private pingTimeout: number; - private clientType: string; private shouldReconnect: boolean = true; private reconnectTimer: NodeJS.Timeout | null = null; private pingTimer: NodeJS.Timeout | null = null; private pingTimeoutTimer: NodeJS.Timeout | null = null; + private token: string; constructor( - clientType: string, - id: string, - secret: string, + token: string, endpoint: string, options: ClientOptions = {} ) { super(); - this.clientType = clientType; - this.config = { - id, - secret, - endpoint - }; - + this.token = token; this.baseURL = options.baseURL || endpoint; this.reconnectInterval = options.reconnectInterval || 3000; this.pingInterval = options.pingInterval || 30000; this.pingTimeout = options.pingTimeout || 10000; } - public getConfig(): Config { - return this.config; - } - public async connect(): Promise { this.shouldReconnect = true; await this.connectWithRetry(); @@ -161,48 +140,6 @@ export class WebSocketClient extends EventEmitter { return this.isConnected; } - private async getToken(): Promise { - const baseURL = new URL(this.baseURL); - const tokenEndpoint = `${baseURL.origin}/api/v1/auth/${this.clientType}/get-token`; - - const tokenData = this.clientType === 'newt' - ? { newtId: this.config.id, secret: this.config.secret } - : { olmId: this.config.id, secret: this.config.secret }; - - try { - const response = await axios.post(tokenEndpoint, tokenData, { - headers: { - 'Content-Type': 'application/json', - 'X-CSRF-Token': 'x-csrf-protection' - }, - timeout: 10000 // 10 second timeout - }); - - if (!response.data.success) { - throw new Error(`Failed to get token: ${response.data.message}`); - } - - if (!response.data.data.token) { - throw new Error('Received empty token from server'); - } - - console.debug(`Received token: ${response.data.data.token}`); - return response.data.data.token; - } catch (error) { - if (axios.isAxiosError(error)) { - if (error.response) { - throw new Error(`Failed to get token with status code: ${error.response.status}`); - } else if (error.request) { - throw new Error('Failed to request new token: No response received'); - } else { - throw new Error(`Failed to request new token: ${error.message}`); - } - } else { - throw new Error(`Failed to get token: ${error}`); - } - } - } - private async connectWithRetry(): Promise { while (this.shouldReconnect) { try { @@ -221,18 +158,14 @@ export class WebSocketClient extends EventEmitter { } private async establishConnection(): Promise { - // Get token for authentication - const token = await this.getToken(); - this.emit('tokenUpdate', token); - // Parse the base URL to determine protocol and hostname const baseURL = new URL(this.baseURL); const wsProtocol = baseURL.protocol === 'https:' ? 'wss' : 'ws'; const wsURL = new URL(`${wsProtocol}://${baseURL.host}/api/v1/ws`); // Add token and client type to query parameters - wsURL.searchParams.set('token', token); - wsURL.searchParams.set('clientType', this.clientType); + wsURL.searchParams.set('token', this.token); + wsURL.searchParams.set('clientType', "remoteExitNode"); return new Promise((resolve, reject) => { const conn = new WebSocket(wsURL.toString()); @@ -330,13 +263,11 @@ export class WebSocketClient extends EventEmitter { // Factory function for easier instantiation export function createWebSocketClient( - clientType: string, - id: string, - secret: string, - endpoint: string, + token: string, + endpoint: string, options?: ClientOptions ): WebSocketClient { - return new WebSocketClient(clientType, id, secret, endpoint, options); + return new WebSocketClient(token, endpoint, options); } export default WebSocketClient; \ No newline at end of file