Skip to content
Open
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
171 changes: 152 additions & 19 deletions src/auth.js
Original file line number Diff line number Diff line change
@@ -1,38 +1,160 @@
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
* @typedef {{ ok: false, error: string }} AuthErr
* @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<AuthResult>}
*/
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) {
Expand All @@ -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" };
}
}
18 changes: 14 additions & 4 deletions src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -75,20 +75,20 @@ 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 });
ws.close(4001, authResult.error);
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) {
Expand Down Expand Up @@ -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;
}
}
});

Expand Down
5 changes: 5 additions & 0 deletions src/validator.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
9 changes: 5 additions & 4 deletions tests/auth-extended.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,36 +6,37 @@ 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");
});

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");
});

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();
});

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();
});
Expand Down
Loading