Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
2 changes: 1 addition & 1 deletion packages/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"rrule": "^2.7.2",
"saslprep": "^1.0.3",
"socket.io": "^4.7.5",
"supertokens-node": "^20.0.5",
"supertokens-node": "^23.0.1",
"tslib": "^2.4.0"
},
"devDependencies": {
Expand Down
72 changes: 61 additions & 11 deletions packages/backend/src/__tests__/helpers/mock.setup.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import { Handler, Response } from "express";
import { Handler, NextFunction, Response } from "express";
import { GoogleApis } from "googleapis";
import mergeWith, { default as mockMergeWith } from "lodash.mergewith";
import { randomUUID } from "node:crypto";
import { SessionRequest } from "supertokens-node/framework/express";
import { BaseResponse } from "supertokens-node/lib/build/framework";
import { SessionContainerInterface } from "supertokens-node/lib/build/recipe/session/types";
import {
ExpressRequest,
ExpressResponse,
} from "supertokens-node/lib/build/framework/express/framework";
import {
APIOptions,
SessionContainerInterface,
VerifySessionOptions,
} from "supertokens-node/lib/build/recipe/session/types";
import { UserContext } from "supertokens-node/lib/build/types";
import { createMockCalendarListEntry as mockCalendarListCreate } from "@core/__tests__/helpers/gcal.factory";
import { gSchema$CalendarListEntry } from "@core/types/gcal";
import { UserMetadata } from "@core/types/user.types";
Expand Down Expand Up @@ -49,12 +57,12 @@ function mockGoogleapis() {
function mockSuperToken() {
const userMetadata = new Map<string, UserMetadata>();

function verifySession() {
return (
req: SessionRequest,
_res: Response & BaseResponse,
next?: (err?: unknown) => void,
) => {
function verifySession(input: {
verifySessionOptions?: VerifySessionOptions;
options: APIOptions;
userContext: UserContext;
}) {
return (req: SessionRequest, _res: Response, next?: NextFunction) => {
try {
const cookies = (req.headers.cookie?.split(";") ?? [])?.reduce(
(items, item) => {
Expand Down Expand Up @@ -98,10 +106,13 @@ function mockSuperToken() {
},
} as SessionContainerInterface;

return next ? next() : undefined;
return next?.();
}

throw new Error("invalid superToken session");
if (input?.verifySessionOptions?.sessionRequired) {
console.log("Invalid session detected in mock");
throw new Error("invalid superToken session");
}
} catch (error) {
if (next) {
next(error);
Expand Down Expand Up @@ -163,6 +174,45 @@ function mockSuperToken() {
return mergeWith(userMetadataModule, { default: userMetadataModule });
},
);

mockModule(
"supertokens-node/lib/build/recipe/session/recipe",
(
session: typeof import("supertokens-node/lib/build/recipe/session/recipe"),
) => {
const getInstanceOrThrowError =
session.default.getInstanceOrThrowError.bind(session.default);

const sessionModule = mergeWith(session, {
default: mergeWith(session.default, {
getInstanceOrThrowError: jest.fn(() => {
const instance = getInstanceOrThrowError();

return mergeWith(instance, {
apiImpl: mergeWith(instance.apiImpl, {
verifySession: jest.fn(
async (input: {
verifySessionOptions: VerifySessionOptions | undefined;
options: APIOptions;
userContext: UserContext;
}) => {
const req = input.options.req as ExpressRequest;
const res = input.options.res as ExpressResponse;

verifySession(input)(req.original, res.original);

return Promise.resolve(req.original.session);
},
),
}),
});
}),
}),
});

return sessionModule;
},
);
}

function mockWinstonLogger() {
Expand Down
20 changes: 20 additions & 0 deletions packages/backend/src/common/middleware/supertokens.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,14 @@ import {
PORT_DEFAULT_BACKEND,
PORT_DEFAULT_WEB,
} from "@core/constants/core.constants";
import { Status } from "@core/errors/status.codes";
import { Logger } from "@core/logger/winston.logger";
import { ENV } from "@backend/common/constants/env.constants";
import { SupertokensAccessTokenPayload } from "@backend/common/types/supertokens.types";
import { webSocketServer } from "@backend/servers/websocket/websocket.server";

const logger = Logger("app:supertokens.middleware");

export const initSupertokens = () => {
SuperTokens.init({
appInfo: {
Expand All @@ -32,6 +36,18 @@ export const initSupertokens = () => {
recipeList: [
Dashboard.init(),
Session.init({
errorHandlers: {
onTryRefreshToken: async (message, _request, response) => {
logger.warn(
`Session expired: ${message}. User tried to refresh the session.`,
);

response.setStatusCode(Status.UNAUTHORIZED);
response.sendJSONResponse({
error: "Session expired. Please log in again.",
});
},
},
override: {
apis(originalImplementation) {
return {
Expand Down Expand Up @@ -60,6 +76,10 @@ export const initSupertokens = () => {

webSocketServer.handleUserRefreshToken(socketId!);

logger.debug(
`Session refreshed for user ${data.sub} client.`,
);

return session;
},
);
Expand Down
56 changes: 53 additions & 3 deletions packages/backend/src/servers/websocket/websocket.server.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import { Request } from "express";
import { NextFunction, Request, Response } from "express";
import { Server as HttpServer } from "node:http";
import { ExtendedError, Server as SocketIOServer } from "socket.io";
import { verifySession } from "supertokens-node/recipe/session/framework/express";
import { SessionRequest } from "supertokens-node/framework/express";
import {
ExpressRequest,
ExpressResponse,
} from "supertokens-node/lib/build/framework/express/framework";
import SessionError from "supertokens-node/lib/build/recipe/session/error";
import SessionRecipe from "supertokens-node/lib/build/recipe/session/recipe";
import { makeDefaultUserContextFromAPI } from "supertokens-node/lib/build/utils";
import {
EVENT_CHANGED,
EVENT_CHANGE_PROCESSED,
Expand All @@ -16,6 +23,7 @@ import {
USER_REFRESH_TOKEN,
USER_SIGN_OUT,
} from "@core/constants/websocket.constants";
import { Status } from "@core/errors/status.codes";
import { Logger } from "@core/logger/winston.logger";
import { UserMetadata } from "@core/types/user.types";
import {
Expand Down Expand Up @@ -197,6 +205,48 @@ class WebSocketServer {
return this.notifyClient(socketId!, event, ...payload);
}

/**
* verifySession
*
* We are manually verifying the session here
* to prevent the default supertokens behavior
* of attempting to refresh the session if it is expired internally
* since the socket's session might be stale.
* We offload the refresh mechanism to the client.
*/
private async verifySession(
req: SessionRequest,
res: Response,
next: NextFunction,
) {
try {
const request = new ExpressRequest(req);
const response = new ExpressResponse(res);
const userContext = makeDefaultUserContextFromAPI(request);
const sessionRecipe = SessionRecipe.getInstanceOrThrowError();
const session = await sessionRecipe.verifySession(
{ sessionRequired: true },
request,
response,
userContext,
);

Object.assign(req, { session });

next();
} catch (err) {
const error = err as SessionError;

logger.error(error.message, error);

res.writeHead(Status.UNAUTHORIZED, {
"Content-Type": "application/json",
});

res.end(JSON.stringify({ type: error.type, message: "Invalid Session" }));
}
}

init(server: HttpServer) {
this.wsServer = new SocketIOServer<
ClientToServerEvents,
Expand All @@ -205,7 +255,7 @@ class WebSocketServer {
SocketData
>(server, { cors: { origin: ENV.ORIGINS_ALLOWED, credentials: true } });

this.wsServer.engine.use(verifySession());
this.wsServer.engine.use(this.verifySession.bind(this));

this.wsServer.engine.generateId = this.generateId.bind(this);

Expand Down
20 changes: 8 additions & 12 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -4892,11 +4892,6 @@ cookie-signature@1.0.6:
resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"
integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==

cookie@0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.0.tgz#beb437e7022b3b6d49019d088665303ebe9c14ba"
integrity sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==

cookie@0.7.1:
version "0.7.1"
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.1.tgz#2f73c42142d5d5cf71310a74fc4ae61670e5dbc9"
Expand All @@ -4907,7 +4902,7 @@ cookie@^0.4.2:
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.2.tgz#0e41f24de5ecf317947c82fc789e06a884824432"
integrity sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==

cookie@~0.7.2:
cookie@^0.7.2, cookie@~0.7.2:
version "0.7.2"
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7"
integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==
Expand Down Expand Up @@ -10735,7 +10730,7 @@ serve-static@1.16.2:
parseurl "~1.3.3"
send "0.19.0"

set-cookie-parser@^2.4.6:
set-cookie-parser@^2.4.6, set-cookie-parser@^2.6.0:
version "2.7.1"
resolved "https://registry.yarnpkg.com/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz#3016f150072202dfbe90fadee053573cc89d2943"
integrity sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==
Expand Down Expand Up @@ -11383,14 +11378,14 @@ supertokens-js-override@0.0.4, supertokens-js-override@^0.0.4:
resolved "https://registry.yarnpkg.com/supertokens-js-override/-/supertokens-js-override-0.0.4.tgz#9af583fbc5e1f0195dbb358c4fcf75f44c76dc09"
integrity sha512-r0JFBjkMIdep3Lbk3JA+MpnpuOtw4RSyrlRAbrzMcxwiYco3GFWl/daimQZ5b1forOiUODpOlXbSOljP/oyurg==

supertokens-node@^20.0.5:
version "20.1.7"
resolved "https://registry.yarnpkg.com/supertokens-node/-/supertokens-node-20.1.7.tgz#d06511de0891b22d499fb219519864aef29706d9"
integrity sha512-Ol3LhYksxBBpmmzx8MX9sPSaaVI4x58gMpHbdy7wlzwpC3TM44HIEmAMD++ig3oOUNSvppqQVDkAcCFNafpl1w==
supertokens-node@^23.0.1:
version "23.0.1"
resolved "https://registry.yarnpkg.com/supertokens-node/-/supertokens-node-23.0.1.tgz#adc12cef47a0c1af1eddfe84db49ae8ff73af855"
integrity sha512-cCuY9Y5Mj93Pg1ktbqilouWgAoQWniQauftB4Ef6rfOchogx13XTo1pNP14zezn2rSf7WIPb9iaZb5zif6TKtQ==
dependencies:
buffer "^6.0.3"
content-type "^1.0.5"
cookie "0.4.0"
cookie "^0.7.2"
cross-fetch "^3.1.6"
debug "^4.3.3"
jose "^4.13.1"
Expand All @@ -11399,6 +11394,7 @@ supertokens-node@^20.0.5:
pako "^2.1.0"
pkce-challenge "^3.0.0"
process "^0.11.10"
set-cookie-parser "^2.6.0"
supertokens-js-override "^0.0.4"
tldts "^6.1.48"
twilio "^4.19.3"
Expand Down