Skip to content
Closed
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
1 change: 1 addition & 0 deletions frontend/src/components/auth/Auth.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const Auth = () => {
setauth_check(true);
} else {
localStorage.removeItem("token");
window.dispatchEvent(new Event("piperchat:auth-token"));
setToken(null);
setauth_check(false);
}
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/components/settings/SettingsDialog.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ export default function SettingsDialog({ triggerClassName, icon: Icon }) {
}
if (data.token) {
localStorage.setItem("token", data.token);
window.dispatchEvent(new Event("piperchat:auth-token"));
}
if (data.notification_preferences) {
dispatch(set_notification_preferences(data.notification_preferences));
Expand Down Expand Up @@ -232,6 +233,7 @@ export default function SettingsDialog({ triggerClassName, icon: Icon }) {
}

localStorage.setItem("token", data.token);
window.dispatchEvent(new Event("piperchat:auth-token"));
const decoded = jwt(data.token);

// Update Redux with NEW data - use finalProfilePic if file was uploaded, otherwise use decoded value
Expand Down
46 changes: 41 additions & 5 deletions frontend/src/components/socket/Socket.jsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,47 @@
import socketIO from "socket.io-client";
import { SOCKET_URL } from "../../config";

function getAuthToken() {
if (typeof window === "undefined") {
return "";
}

return localStorage.getItem("token") || "";
}

const socketOptions = {
transports: ["websocket", "polling"],
withCredentials: true,
autoConnect: Boolean(getAuthToken()),
auth: (callback) => {
callback({ token: getAuthToken() });
},
};

const socket = SOCKET_URL
? socketIO(SOCKET_URL, {
transports: ["websocket", "polling"],
withCredentials: true,
})
: socketIO();
? socketIO(SOCKET_URL, socketOptions)
: socketIO(socketOptions);

function refreshSocketAuth() {
const token = getAuthToken();
socket.auth = { token };

if (!token) {
socket.disconnect();
return;
}

if (socket.connected) {
socket.disconnect().connect();
return;
}

socket.connect();
}

if (typeof window !== "undefined") {
window.addEventListener("storage", refreshSocketAuth);
window.addEventListener("piperchat:auth-token", refreshSocketAuth);
}

export default socket;
1 change: 1 addition & 0 deletions frontend/src/lib/logout.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export function logout() {
localStorage.clear();
window.dispatchEvent(new Event("piperchat:auth-token"));
window.location.replace("/");
}
1 change: 1 addition & 0 deletions server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"dev": "nodemon src/index.js",
"test:auth:unit": "node scripts/run-auth-jwt-unit.mjs",
"test:auth": "node scripts/run-auth-integration.mjs",
"test:socket:auth": "node scripts/run-socket-auth-unit.mjs",
"gmail:oauth-setup": "node scripts/gmail-oauth-setup.mjs"
},
"author": "shunnu",
Expand Down
53 changes: 53 additions & 0 deletions server/scripts/run-socket-auth-unit.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import assert from "node:assert/strict";

process.env.ACCESS_TOKEN =
process.env.ACCESS_TOKEN ||
"socket-auth-unit-secret-minimum-length-32-characters";

const jwt = await import("jsonwebtoken");
const { isSocketUserClaimAllowed, verifySocketToken } = await import(
"../src/socket/index.js"
);

const token = jwt.default.sign(
{
id: "user-123",
email: "user@example.com",
},
process.env.ACCESS_TOKEN,
);

const verified = verifySocketToken(token);
assert.equal(verified.userId, "user-123");
assert.equal(verified.decoded.email, "user@example.com");

assert.throws(
() => verifySocketToken(""),
/Socket authentication token is required/,
);
assert.throws(
() => verifySocketToken("not-a-valid-jwt"),
/jwt malformed|invalid token/,
);

const tokenWithoutUserId = jwt.default.sign(
{ email: "missing-id@example.com" },
process.env.ACCESS_TOKEN,
);
assert.throws(
() => verifySocketToken(tokenWithoutUserId),
/missing a user id/,
);

const socket = {
data: {
authenticated_user_id: "user-123",
},
};

assert.equal(isSocketUserClaimAllowed(socket, "user-123"), true);
assert.equal(isSocketUserClaimAllowed(socket, "victim-user"), false);
assert.equal(isSocketUserClaimAllowed(socket, ""), false);
assert.equal(isSocketUserClaimAllowed({ data: {} }, "user-123"), false);

console.log("socket auth unit checks: passed");
78 changes: 77 additions & 1 deletion server/src/socket/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import User from "../models/User.js";
import config from "../config/index.js";
import { buildServerTypingEvent } from "../lib/typingEvents.js";
import jwt from "jsonwebtoken";

const onlineUsers = new Map();

Expand Down Expand Up @@ -62,7 +64,69 @@ function setUserOffline(io, userId, socketId) {
onlineUsers.set(normalizedUserId, activeSockets);
}

function extractSocketToken(socket) {
const authToken = socket.handshake?.auth?.token;
if (typeof authToken === "string" && authToken.trim()) {
return authToken.trim();
}

const headerToken = socket.handshake?.headers?.["x-auth-token"];
if (typeof headerToken === "string" && headerToken.trim()) {
return headerToken.trim();
}

const authorization = socket.handshake?.headers?.authorization;
if (typeof authorization === "string") {
const [scheme, token] = authorization.split(" ");
if (scheme?.toLowerCase() === "bearer" && token?.trim()) {
return token.trim();
}
}

return "";
}

function verifySocketToken(token) {
if (!token) {
throw new Error("Socket authentication token is required");
}

const decoded = jwt.verify(token, config.ACCESS_TOKEN);
const userId = decoded?.id;

if (!userId) {
throw new Error("Socket authentication token is missing a user id");
}

return {
decoded,
userId: String(userId),
};
}

function isSocketUserClaimAllowed(socket, userId) {
const claimedUserId = String(userId || "");
return Boolean(
claimedUserId &&
socket.data?.authenticated_user_id &&
claimedUserId === socket.data.authenticated_user_id,
);
}

function authenticateSocket(socket, next) {
try {
const { decoded, userId } = verifySocketToken(extractSocketToken(socket));
socket.data.auth_user = decoded;
socket.data.authenticated_user_id = userId;
next();
} catch {
next(new Error("Socket authentication failed"));
}
}

function attachSocketHandlers(io) {
io.use(authenticateSocket);

io.on("connection", (socket) => {
socket.on("channelCreated", (data) => {
io.emit("newChannel", data);
Expand All @@ -73,6 +137,13 @@ function attachSocketHandlers(io) {
socket.on("get_userid", (user_id) => {
const normalizedUserId = String(user_id);

if (!isSocketUserClaimAllowed(socket, normalizedUserId)) {
socket.emit("socket_auth_error", {
message: "Authenticated socket user does not match requested user",
});
return;
}

if (socket.data.user_id === normalizedUserId) {
socket.join(normalizedUserId);
emitPresenceSnapshot(socket);
Expand Down Expand Up @@ -237,4 +308,9 @@ function attachSocketHandlers(io) {
});
}

export { attachSocketHandlers };
export {
attachSocketHandlers,
authenticateSocket,
isSocketUserClaimAllowed,
verifySocketToken,
};