Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 23 additions & 7 deletions bridges/punchd-bridge/gateway/src/auth/tidecloak.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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];
Expand All @@ -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<JWTPayload | null> {
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(
Expand Down
6 changes: 3 additions & 3 deletions bridges/punchd-bridge/gateway/src/proxy/http-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
39 changes: 21 additions & 18 deletions signal-server/src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -75,6 +75,7 @@ interface TidecloakConfig {

let tcConfig: TidecloakConfig | null = null;
let JWKS: ReturnType<typeof createLocalJWKSet> | null = null;
let remoteJWKS: ReturnType<typeof createRemoteJWKSet> | null = null;

function loadConfig(): boolean {
try {
Expand Down Expand Up @@ -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);
Expand All @@ -120,7 +125,17 @@ async function verifyToken(token: string): Promise<JWTPayload | null> {
? `${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}`);
Expand Down Expand Up @@ -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 });

Expand Down