diff --git a/bridges/punchd-bridge/gateway/src/auth/tidecloak.ts b/bridges/punchd-bridge/gateway/src/auth/tidecloak.ts index d98c1a5..bf43da8 100644 --- a/bridges/punchd-bridge/gateway/src/auth/tidecloak.ts +++ b/bridges/punchd-bridge/gateway/src/auth/tidecloak.ts @@ -1,9 +1,10 @@ /** - * TideCloak JWT verification using local JWKS. - * Adapted from tcp-bridge pattern. + * TideCloak JWT verification using local JWKS with remote fallback. + * Tries the local JWKS first (from config), and falls back to fetching + * the JWKS from the TideCloak server if the local key doesn't match. */ -import { jwtVerify, createLocalJWKSet, JWTPayload } from "jose"; +import { jwtVerify, createLocalJWKSet, createRemoteJWKSet, JWTPayload } from "jose"; import type { TidecloakConfig } from "../config.js"; export interface TidecloakAuth { @@ -14,10 +15,12 @@ export function createTidecloakAuth( config: TidecloakConfig, extraIssuers?: string[] ): TidecloakAuth { - const JWKS = createLocalJWKSet(config.jwk); + const localJWKS = createLocalJWKSet(config.jwk); const baseUrl = config["auth-server-url"].replace(/\/$/, ""); const primaryIssuer = `${baseUrl}/realms/${config.realm}`; + const jwksUrl = new URL(`${primaryIssuer}/protocol/openid-connect/certs`); + const remoteJWKS = createRemoteJWKSet(jwksUrl); // Accept tokens from both the local and public TideCloak URLs const validIssuers = [primaryIssuer]; @@ -29,14 +32,27 @@ export function createTidecloakAuth( } console.log("[Gateway] TideCloak JWKS loaded successfully"); + console.log(`[Gateway] Remote JWKS fallback: ${jwksUrl}`); console.log(`[Gateway] Valid issuers: ${validIssuers.join(", ")}`); return { async verifyToken(token: string): Promise { try { - const { payload } = await jwtVerify(token, JWKS, { - issuer: validIssuers, - }); + // Try local JWKS first, fall back to remote if key not found + let payload: JWTPayload; + try { + ({ payload } = await jwtVerify(token, localJWKS, { + issuer: validIssuers, + })); + } catch (localErr: any) { + if (localErr?.code === "ERR_JWKS_NO_MATCHING_KEY") { + ({ payload } = await jwtVerify(token, remoteJWKS, { + issuer: validIssuers, + })); + } else { + throw localErr; + } + } if (payload.azp !== config.resource) { console.log( diff --git a/bridges/punchd-bridge/gateway/src/proxy/http-proxy.ts b/bridges/punchd-bridge/gateway/src/proxy/http-proxy.ts index 2720663..c9a1a03 100644 --- a/bridges/punchd-bridge/gateway/src/proxy/http-proxy.ts +++ b/bridges/punchd-bridge/gateway/src/proxy/http-proxy.ts @@ -26,7 +26,7 @@ import { } from "https"; import { createHmac, randomBytes } from "crypto"; import { readFileSync, realpathSync } from "fs"; -import { join, resolve } from "path"; +import { join, resolve, sep } from "path"; import type { TidecloakAuth } from "../auth/tidecloak.js"; import type { TidecloakConfig } from "../config.js"; import { @@ -118,7 +118,7 @@ function serveFile( const resolved = resolve(PUBLIC_DIR, filename); // Prevent path traversal and symlink escape — real path must be inside PUBLIC_DIR const realPath = realpathSync(resolved); - if (!realPath.startsWith(PUBLIC_DIR + "/")) { + if (!realPath.startsWith(PUBLIC_DIR + sep)) { res.writeHead(403, { "Content-Type": "text/plain" }); res.end("Forbidden"); return; @@ -141,7 +141,7 @@ function serveBinaryFile( try { const resolved = resolve(PUBLIC_DIR, filename); const realPath = realpathSync(resolved); - if (!realPath.startsWith(PUBLIC_DIR + "/")) { + if (!realPath.startsWith(PUBLIC_DIR + sep)) { res.writeHead(403, { "Content-Type": "text/plain" }); res.end("Forbidden"); return; diff --git a/signal-server/src/index.ts b/signal-server/src/index.ts index 0e6b64c..5bde181 100644 --- a/signal-server/src/index.ts +++ b/signal-server/src/index.ts @@ -1,7 +1,7 @@ import { createServer as createHttpServer, request as httpRequest } from "http"; import { createServer as createHttpsServer, request as httpsRequest } from "https"; import { WebSocketServer, WebSocket } from "ws"; -import { jwtVerify, createLocalJWKSet, JWTPayload } from "jose"; +import { jwtVerify, createLocalJWKSet, createRemoteJWKSet, JWTPayload } from "jose"; import { readFileSync, existsSync } from "fs"; import { join } from "path"; import { timingSafeEqual, createHmac, randomBytes } from "crypto"; @@ -75,6 +75,7 @@ interface TidecloakConfig { let tcConfig: TidecloakConfig | null = null; let JWKS: ReturnType | null = null; +let remoteJWKS: ReturnType | null = null; function loadConfig(): boolean { try { @@ -104,7 +105,11 @@ function loadConfig(): boolean { } JWKS = createLocalJWKSet(tcConfig.jwk); + const baseUrl = tcConfig["auth-server-url"].replace(/\/$/, ""); + const jwksUrl = new URL(`${baseUrl}/realms/${tcConfig.realm}/protocol/openid-connect/certs`); + remoteJWKS = createRemoteJWKSet(jwksUrl); console.log("[Signal] JWKS loaded successfully"); + console.log(`[Signal] Remote JWKS fallback: ${jwksUrl}`); return true; } catch (err) { console.error("[Signal] Failed to load config:", err); @@ -120,7 +125,17 @@ async function verifyToken(token: string): Promise { ? `${tcConfig["auth-server-url"]}realms/${tcConfig.realm}` : `${tcConfig["auth-server-url"]}/realms/${tcConfig.realm}`; - const { payload } = await jwtVerify(token, JWKS, { issuer }); + // Try local JWKS first, fall back to remote if key not found + let payload: JWTPayload; + try { + ({ payload } = await jwtVerify(token, JWKS, { issuer })); + } catch (localErr: any) { + if (localErr?.code === "ERR_JWKS_NO_MATCHING_KEY" && remoteJWKS) { + ({ payload } = await jwtVerify(token, remoteJWKS, { issuer })); + } else { + throw localErr; + } + } if (payload.azp !== tcConfig.resource) { console.log(`[Signal] AZP mismatch: expected ${tcConfig.resource}, got ${payload.azp}`); @@ -742,22 +757,10 @@ async function handleRegister(ws: WebSocket, msg: SignalMessage, clientIp: strin gatewayWebSockets.add(ws); safeSend(ws, { type: "registered", role: "gateway", id: msg.id }); } else if (msg.role === "client") { - // Client registration requires a valid JWT from KeyleSSH - if (!msg.token) { - safeSend(ws, { type: "error", message: "Authentication required" }); - ws.close(4001, "Missing token"); - return; - } - - const payload = await verifyToken(msg.token); - if (!payload) { - safeSend(ws, { type: "error", message: "Invalid or expired token" }); - ws.close(4002, "Invalid token"); - return; - } - - console.log(`[Signal] Client authenticated: ${payload.sub || "unknown"} (${msg.id})`); - registry.registerClient(msg.id, ws, msg.token); + // Signal server is a dumb relay — gateway handles auth. + // Just pass the token through so the gateway can validate it. + console.log(`[Signal] Client registered: ${msg.id}`); + registry.registerClient(msg.id, ws, msg.token || ""); registry.updateClientReflexive(msg.id, clientIp); safeSend(ws, { type: "registered", role: "client", id: msg.id });