diff --git a/src/auth.js b/src/auth.js index a1ffeb0..cc53fa5 100644 --- a/src/auth.js +++ b/src/auth.js @@ -1,4 +1,7 @@ import jwt from "jsonwebtoken"; +import https from "node:https"; +import http from "node:http"; +import crypto from "node:crypto"; /** * @typedef {{ ok: true, clientId: string }} AuthOk @@ -6,33 +9,152 @@ import jwt from "jsonwebtoken"; * @typedef {AuthOk | AuthErr} AuthResult */ +let jwksCache = null; +let lastFetchTime = 0; +let fetchPromise = null; + +/** + * Fetches JWKS from the given URI, supporting both http:// and https://. + */ +async function fetchJwks(uri) { + const transport = uri.startsWith("https://") ? https : http; + return new Promise((resolve, reject) => { + transport.get(uri, (res) => { + if (res.statusCode !== 200) { + reject(new Error(`Failed to fetch JWKS: ${res.statusCode}`)); + return; + } + let data = ""; + res.on("data", (chunk) => (data += chunk)); + res.on("end", () => { + try { + resolve(JSON.parse(data)); + } catch { + reject(new Error("Failed to parse JWKS")); + } + }); + }).on("error", reject); + }); +} + +/** + * Gets JWKS from cache or fetches it. + */ +async function getJwks(uri) { + const ttl = Number(process.env.AUTH_JWKS_CACHE_TTL || 600000); + const now = Date.now(); + + if (jwksCache && now - lastFetchTime < ttl) { + return jwksCache; + } + + // If already fetching, wait for that promise + if (fetchPromise) { + // If we have a stale cache, we could return it immediately, + // but the requirements say "refreshed in the background". + // For simplicity and correctness (especially on first load), + // we'll await the current fetch if it's the only way to get data. + if (!jwksCache) { + return fetchPromise; + } + // Background refresh: return stale cache and let fetch happen + return jwksCache; + } + + fetchPromise = fetchJwks(uri).then(data => { + jwksCache = data; + lastFetchTime = Date.now(); + fetchPromise = null; + return jwksCache; + }).catch(err => { + fetchPromise = null; + if (jwksCache) return jwksCache; // Fallback to stale on error + throw err; + }); + + return jwksCache || fetchPromise; +} + +/** + * Converts a JWK to a Node.js KeyObject. + */ +function jwkToKeyObject(jwk) { + try { + return crypto.createPublicKey({ format: "jwk", key: jwk }); + } catch { + return null; + } +} + /** * Verifies a WebSocket connection token and resolves the client identity. * - * When `AUTH_SECRET` is not set, all connections are accepted as anonymous. - * When `AUTH_SECRET` is set, the token must be a valid, non-expired JWT signed - * with that secret. The client ID is resolved from the `sub` claim first, - * then `clientId`, falling back to `"anonymous"`. - * * @param {string | null | undefined} token - JWT passed by the connecting client. - * @returns {AuthResult} On success `ok` is `true` and `clientId` identifies the - * client. On failure `ok` is `false` and `error` describes the reason. - * - * @example - * const result = verifyConnection(req.query.token); - * if (!result.ok) { - * socket.close(4001, result.error); - * } + * @returns {Promise} */ -export function verifyConnection(token) { +export async function verifyConnection(token) { const secret = process.env.AUTH_SECRET; - if (!secret) { - // Security fix: fail closed when auth secret is missing to prevent bypass - return { ok: false, error: "Server misconfiguration: AUTH_SECRET is missing" }; + const jwksUri = process.env.AUTH_JWKS_URI; + + if (!secret && !jwksUri) { + return { ok: false, error: "Server misconfiguration: AUTH_SECRET or AUTH_JWKS_URI is missing" }; + } + + if (!token) { + return { ok: false, error: "Authentication token is missing" }; + } + + // Reject alg:none tokens explicitly before any library call + const decoded = jwt.decode(token, { complete: true }); + if (!decoded) { + return { ok: false, error: "Invalid token" }; + } + const alg = decoded.header && decoded.header.alg; + if (!alg || alg.toLowerCase() === "none") { + return { ok: false, error: "Invalid token: unsupported algorithm" }; + } + + const clockSkewMs = Number(process.env.AUTH_CLOCK_SKEW_MS || 30000); + const issuer = process.env.AUTH_ISSUER; + const audience = process.env.AUTH_AUDIENCE; + + // Pre-validate issuer claim before calling jwt.verify so that issuer errors + // take priority over audience errors (library checks audience first). + if (issuer) { + const tokenIss = decoded.payload && decoded.payload.iss; + if (tokenIss !== issuer) { + return { ok: false, error: `jwt issuer invalid. expected: ${issuer}` }; + } } + const options = { + algorithms: ["HS256", "RS256", "ES256"], + clockTolerance: clockSkewMs / 1000, + }; + + if (issuer) options.issuer = issuer; + if (audience) options.audience = audience; + try { - const payload = jwt.verify(token, secret); + let key; + if (jwksUri) { + const jwks = await getJwks(jwksUri); + if (!decoded.header.kid) { + return { ok: false, error: "Invalid token: missing kid header" }; + } + const jwk = jwks.keys.find(k => k.kid === decoded.header.kid); + if (!jwk) { + return { ok: false, error: "Invalid token: unknown kid" }; + } + key = jwkToKeyObject(jwk); + if (!key) { + return { ok: false, error: "Invalid token: unsupported key format" }; + } + } else { + key = secret; + } + + const payload = jwt.verify(token, key, options); return { ok: true, clientId: payload.sub ?? payload.clientId ?? "anonymous" }; } catch (err) { if (err instanceof jwt.TokenExpiredError) { @@ -41,7 +163,18 @@ export function verifyConnection(token) { if (err instanceof jwt.NotBeforeError) { return { ok: false, error: "Token not yet valid" }; } - // JsonWebTokenError and any other jwt error + if (err.name === "JsonWebTokenError") { + // Surface issuer/audience error messages directly from the library + if (err.message.startsWith("jwt issuer invalid") || + err.message.startsWith("jwt audience invalid")) { + return { ok: false, error: err.message }; + } + // "jwt signature is required" means alg:none slipped through or no sig + if (err.message === "jwt signature is required") { + return { ok: false, error: "Invalid token: unsupported algorithm" }; + } + return { ok: false, error: "Invalid token" }; + } return { ok: false, error: "Invalid token" }; } } diff --git a/src/server.js b/src/server.js index b68c4b1..0acefc8 100644 --- a/src/server.js +++ b/src/server.js @@ -44,7 +44,7 @@ export function createServer({ port, heartbeatMs, maxPayloadBytes, connRateLimit this.isAlive = true; } - wss.on("connection", (ws, req) => { + wss.on("connection", async (ws, req) => { const clientId = uuid(); ws.isAlive = true; @@ -75,7 +75,7 @@ export function createServer({ port, heartbeatMs, maxPayloadBytes, connRateLimit } const token = url.searchParams.get("token"); - const authResult = verifyConnection(token); + const authResult = await verifyConnection(token); if (!authResult.ok) { logger.warn("Authentication failed", { clientId, reason: authResult.error }); @@ -83,12 +83,12 @@ export function createServer({ port, heartbeatMs, maxPayloadBytes, connRateLimit return; } - const actualClientId = authResult.clientId ?? clientId; + let actualClientId = authResult.clientId ?? clientId; logger.info("Client connected", { clientId: actualClientId, ip }); ws.on("pong", heartbeat); - ws.on("message", (raw) => { + ws.on("message", async (raw) => { const validation = validateMessage(raw.toString()); if (!validation.ok) { @@ -122,6 +122,16 @@ export function createServer({ port, heartbeatMs, maxPayloadBytes, connRateLimit } break; } + case "token_refresh": { + const result = await verifyConnection(msg.token); + if (result.ok) { + actualClientId = result.clientId; + ws.send(JSON.stringify({ type: "token_refresh_ok" })); + } else { + ws.send(JSON.stringify({ type: "error", payload: { message: result.error } })); + } + break; + } } }); diff --git a/src/validator.js b/src/validator.js index 0cd41ed..8153e8c 100644 --- a/src/validator.js +++ b/src/validator.js @@ -31,12 +31,17 @@ const messageSchema = z.discriminatedUnion("type", [ type: z.literal("leave_room"), ...leaveRoomSchema.shape, }), + z.object({ + type: z.literal("token_refresh"), + token: z.string().min(1), + }), ]); const MESSAGE_SIZE_LIMITS = { location_update: 512, join_room: 256, leave_room: 256, + token_refresh: 2048, }; export function validateMessage(raw) { diff --git a/tests/auth-extended.test.js b/tests/auth-extended.test.js index 590b50d..6a2cbe4 100644 --- a/tests/auth-extended.test.js +++ b/tests/auth-extended.test.js @@ -6,12 +6,13 @@ const SECRET = "test-secret-17"; describe("auth extended", () => { beforeEach(() => { process.env.AUTH_SECRET = SECRET; + process.env.AUTH_CLOCK_SKEW_MS = "0"; }); it("accepts a valid JWT using sub claim", async () => { const token = jwt.sign({ sub: "user-123" }, SECRET); const { verifyConnection } = await import("../src/auth.js?ext1"); - const result = verifyConnection(token); + const result = await verifyConnection(token); expect(result.ok).toBe(true); expect(result.clientId).toBe("user-123"); }); @@ -19,7 +20,7 @@ describe("auth extended", () => { it("accepts a valid JWT using clientId claim when sub is absent", async () => { const token = jwt.sign({ clientId: "device-abc" }, SECRET); const { verifyConnection } = await import("../src/auth.js?ext2"); - const result = verifyConnection(token); + const result = await verifyConnection(token); expect(result.ok).toBe(true); expect(result.clientId).toBe("device-abc"); }); @@ -27,7 +28,7 @@ describe("auth extended", () => { it("rejects an expired JWT", async () => { const token = jwt.sign({ sub: "user-456" }, SECRET, { expiresIn: -1 }); const { verifyConnection } = await import("../src/auth.js?ext3"); - const result = verifyConnection(token); + const result = await verifyConnection(token); expect(result.ok).toBe(false); expect(result.error).toBeTruthy(); }); @@ -35,7 +36,7 @@ describe("auth extended", () => { it("rejects a JWT signed with the wrong secret", async () => { const token = jwt.sign({ sub: "user-789" }, "wrong-secret"); const { verifyConnection } = await import("../src/auth.js?ext4"); - const result = verifyConnection(token); + const result = await verifyConnection(token); expect(result.ok).toBe(false); expect(result.error).toBeTruthy(); }); diff --git a/tests/auth-hardening.test.js b/tests/auth-hardening.test.js new file mode 100644 index 0000000..7864f30 --- /dev/null +++ b/tests/auth-hardening.test.js @@ -0,0 +1,113 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import jwt from "jsonwebtoken"; +import http from "node:http"; +import crypto from "node:crypto"; +import { verifyConnection } from "../src/auth.js"; + +const SECRET = "test-secret-hardening"; + +describe("JWT Hardening", () => { + beforeEach(() => { + process.env.AUTH_SECRET = SECRET; + process.env.AUTH_ISSUER = "test-issuer"; + process.env.AUTH_AUDIENCE = "test-audience"; + process.env.AUTH_CLOCK_SKEW_MS = "30000"; + // Ensure we don't have leftover JWKS URI + delete process.env.AUTH_JWKS_URI; + }); + + afterEach(() => { + delete process.env.AUTH_SECRET; + delete process.env.AUTH_ISSUER; + delete process.env.AUTH_AUDIENCE; + delete process.env.AUTH_CLOCK_SKEW_MS; + delete process.env.AUTH_JWKS_URI; + }); + + it("rejects token with invalid iss claim", async () => { + const token = jwt.sign({ sub: "user-1", iss: "wrong-issuer" }, SECRET); + const result = await verifyConnection(token); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/jwt issuer invalid/i); + }); + + it("rejects token with invalid aud claim", async () => { + const token = jwt.sign({ sub: "user-1", iss: "test-issuer", aud: "wrong-audience" }, SECRET); + const result = await verifyConnection(token); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/jwt audience invalid/i); + }); + + it("rejects token with nbf 31 seconds in the future", async () => { + const now = Math.floor(Date.now() / 1000); + const token = jwt.sign({ sub: "user-1", iss: "test-issuer", aud: "test-audience", nbf: now + 31 }, SECRET); + const result = await verifyConnection(token); + expect(result.ok).toBe(false); + expect(result.error).toBe("Token not yet valid"); + }); + + it("accepts token with exp 29 seconds in the past due to skew", async () => { + const now = Math.floor(Date.now() / 1000); + const token = jwt.sign({ sub: "user-1", iss: "test-issuer", aud: "test-audience", exp: now - 29 }, SECRET); + const result = await verifyConnection(token); + expect(result.ok).toBe(true); + }); + + it("rejects token with alg none", async () => { + const payload = { sub: "user-1", iss: "test-issuer", aud: "test-audience" }; + const header = { alg: "none", typ: "JWT" }; + const badToken = Buffer.from(JSON.stringify(header)).toString("base64url") + "." + + Buffer.from(JSON.stringify(payload)).toString("base64url") + "."; + + const result = await verifyConnection(badToken); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/unsupported algorithm|invalid algorithm/i); + }); + + describe("JWKS Support", () => { + let jwksServer; + let jwksPort; + const { publicKey, privateKey } = crypto.generateKeyPairSync("rsa", { + modulusLength: 2048, + }); + const jwk = publicKey.export({ format: "jwk" }); + jwk.kid = "test-kid"; + jwk.alg = "RS256"; + jwk.use = "sig"; + + beforeEach(async () => { + jwksServer = http.createServer((req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ keys: [jwk] })); + }); + await new Promise(resolve => jwksServer.listen(0, "127.0.0.1", resolve)); + jwksPort = jwksServer.address().port; + process.env.AUTH_JWKS_URI = `http://127.0.0.1:${jwksPort}/jwks`; + delete process.env.AUTH_SECRET; + }); + + afterEach(async () => { + await new Promise(resolve => jwksServer.close(resolve)); + }); + + it("verifies token using JWKS", async () => { + const token = jwt.sign({ sub: "user-jwks", iss: "test-issuer", aud: "test-audience" }, privateKey, { + algorithm: "RS256", + keyid: "test-kid", + }); + const result = await verifyConnection(token); + expect(result.ok).toBe(true); + expect(result.clientId).toBe("user-jwks"); + }); + + it("rejects token with unknown kid", async () => { + const token = jwt.sign({ sub: "user-jwks", iss: "test-issuer", aud: "test-audience" }, privateKey, { + algorithm: "RS256", + keyid: "unknown-kid", + }); + const result = await verifyConnection(token); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/unknown kid/i); + }); + }); +}); diff --git a/tests/auth.test.js b/tests/auth.test.js index 9d40f76..25f5ed3 100644 --- a/tests/auth.test.js +++ b/tests/auth.test.js @@ -4,7 +4,7 @@ describe("auth", () => { it("rejects connection when AUTH_SECRET is not configured", async () => { process.env.AUTH_SECRET = ""; const { verifyConnection } = await import("../src/auth.js"); - const result = verifyConnection(null); + const result = await verifyConnection(null); expect(result.ok).toBe(false); expect(result.error).toMatch(/AUTH_SECRET|misconfiguration/i); }); @@ -12,7 +12,7 @@ describe("auth", () => { it("rejects a missing token when secret is set", async () => { process.env.AUTH_SECRET = "test-secret"; const { verifyConnection } = await import("../src/auth.js?2"); - const result = verifyConnection(null); + const result = await verifyConnection(null); expect(result.ok).toBe(false); expect(result.error).toBeTruthy(); }); @@ -20,7 +20,7 @@ describe("auth", () => { it("rejects an invalid token when secret is set", async () => { process.env.AUTH_SECRET = "test-secret"; const { verifyConnection } = await import("../src/auth.js?3"); - const result = verifyConnection("bad-token"); + const result = await verifyConnection("bad-token"); expect(result.ok).toBe(false); expect(result.error).toBeTruthy(); }); diff --git a/tests/refresh.test.js b/tests/refresh.test.js new file mode 100644 index 0000000..eb2659c --- /dev/null +++ b/tests/refresh.test.js @@ -0,0 +1,83 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import WebSocket from "ws"; +import jwt from "jsonwebtoken"; +import { createServer } from "../src/server.js"; + +const TEST_SECRET = "test-secret-refresh"; + +function makeToken(clientId, expiresIn = 60) { + return jwt.sign({ sub: clientId }, TEST_SECRET, { expiresIn }); +} + +function connect(port, token) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(`ws://localhost:${port}/?token=${token}`); + ws.once("open", () => resolve(ws)); + ws.once("error", reject); + }); +} + +function nextMessage(ws) { + return new Promise((resolve) => { + ws.once("message", (data) => resolve(JSON.parse(data.toString()))); + }); +} + +describe("Token Refresh", () => { + let server; + let port; + + beforeEach(() => { + process.env.AUTH_SECRET = TEST_SECRET; + server = createServer({ port: 0 }); + port = server.wss.address().port; + }); + + afterEach(async () => { + for (const client of server.wss.clients) { + client.terminate(); + } + await new Promise((resolve) => server.wss.close(resolve)); + delete process.env.AUTH_SECRET; + }); + + it("successfully refreshes token", async () => { + const ws = await connect(port, makeToken("client-1")); + const newToken = makeToken("client-1", 120); + + ws.send(JSON.stringify({ type: "token_refresh", token: newToken })); + const msg = await nextMessage(ws); + + expect(msg.type).toBe("token_refresh_ok"); + ws.close(); + }); + + it("updates identity on refresh", async () => { + const ws = await connect(port, makeToken("client-old")); + const newToken = makeToken("client-new"); + + ws.send(JSON.stringify({ type: "token_refresh", token: newToken })); + await nextMessage(ws); + + // Join a room and see if it uses the new identity + ws.send(JSON.stringify({ type: "join_room", roomId: "room-1" })); + const msg = await nextMessage(ws); + expect(msg.type).toBe("room_joined"); + + expect(server.rooms.getClientRooms("client-new")).toContain("room-1"); + expect(server.rooms.getClientRooms("client-old")).not.toContain("room-1"); + + ws.close(); + }); + + it("returns error for invalid refresh token", async () => { + const ws = await connect(port, makeToken("client-1")); + ws.send(JSON.stringify({ type: "token_refresh", token: "invalid-token" })); + const msg = await nextMessage(ws); + + expect(msg.type).toBe("error"); + expect(msg.payload.message).toBeTruthy(); + expect(ws.readyState).toBe(WebSocket.OPEN); + ws.close(); + }); +});