From e0ebeb787d3d6f746c1695574c40d4f7b8af5b8a Mon Sep 17 00:00:00 2001 From: Wilson Rivera Date: Wed, 10 Dec 2025 17:22:21 -0500 Subject: [PATCH 1/2] feat: validate session cookie --- controlplane/src/core/auth-utils.ts | 34 +- controlplane/src/core/build-server.ts | 2 +- controlplane/src/core/controllers/auth.ts | 529 +++++++++--------- .../core/services/WebSessionAuthenticator.ts | 21 + 4 files changed, 321 insertions(+), 265 deletions(-) diff --git a/controlplane/src/core/auth-utils.ts b/controlplane/src/core/auth-utils.ts index c7abdb62f2..f13b441159 100644 --- a/controlplane/src/core/auth-utils.ts +++ b/controlplane/src/core/auth-utils.ts @@ -4,6 +4,7 @@ import axios from 'axios'; import { eq } from 'drizzle-orm'; import { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb'; +import { addSeconds } from 'date-fns'; import { PKCECodeChallenge, UserInfoEndpointResponse, UserSession } from '../types/index.js'; import * as schema from '../db/schema.js'; import { sessions } from '../db/schema.js'; @@ -38,7 +39,7 @@ export type AuthUtilsOptions = { }; }; -const tokenExpirationWindowSkew = 60 * 5; +const tokenExpirationWindowSkew = 60 * 5; // 5 minutes const pkceMaxAgeSec = 60 * 15; // 15 minutes const pkceCodeAlgorithm = 'S256'; const scope = 'openid profile email'; @@ -298,6 +299,18 @@ export default class AuthUtils { }; } + public static isSessionExpired(session: { createdAt: Date; updatedAt: Date | null; expiresAt: Date }): boolean { + const now = new Date(); + if (session.expiresAt <= now) { + // Session reached end-of-life + return true; + } + + const sessionLastUpdatedOrCreation = session.updatedAt ?? session.createdAt; + const sessionExpiresAt = addSeconds(sessionLastUpdatedOrCreation, DEFAULT_SESSION_MAX_AGE_SEC); + return sessionExpiresAt <= now; + } + /** * renewSession renews the user session if the access token is expired. * If the refresh token is expired, an error is thrown. @@ -327,30 +340,27 @@ export default class AuthUtils { throw new AuthenticationError(EnumStatusCode.ERROR_NOT_AUTHENTICATED, 'Refresh token expired'); } - // The session expiration is relative to the creation time - const baseMs = userSession.createdAt.getTime(); - const expiresAtMs = baseMs + DEFAULT_SESSION_MAX_AGE_SEC * 1000; - const sessionExpiresDate = new Date(expiresAtMs); - const remainingSeconds = Math.max(0, Math.floor((expiresAtMs - Date.now()) / 1000)); - - if (remainingSeconds <= 0) { + // The session expiration is relative + if (AuthUtils.isSessionExpired(userSession)) { // Absolute session lifetime has elapsed; do not renew. throw new AuthenticationError(EnumStatusCode.ERROR_NOT_AUTHENTICATED, 'Session expired'); } // Refresh the access token with the refresh token // The method will throw an error if the request fails + const now = new Date(); const { accessToken, refreshToken, idToken } = await this.refreshToken(userSession.refreshToken); // Update active session + const expiresAt = addSeconds(now, DEFAULT_SESSION_MAX_AGE_SEC); const updatedSessions = await this.db .update(sessions) .set({ accessToken, refreshToken, - expiresAt: sessionExpiresDate, + expiresAt, idToken, - updatedAt: new Date(), + updatedAt: now, }) .where(eq(sessions.id, sessionId)) .returning() @@ -363,7 +373,7 @@ export default class AuthUtils { const newUserSession = updatedSessions[0]; const jwt = await encrypt({ - maxAgeInSeconds: remainingSeconds, + maxAgeInSeconds: DEFAULT_SESSION_MAX_AGE_SEC, token: { iss: userSession.userId, sessionId: newUserSession.id, @@ -372,7 +382,7 @@ export default class AuthUtils { }); // Update the session cookie - this.createSessionCookie(res, jwt, sessionExpiresDate); + this.createSessionCookie(res, jwt, expiresAt); return newUserSession; } diff --git a/controlplane/src/core/build-server.ts b/controlplane/src/core/build-server.ts index 9bb8db3688..a26f9e744f 100644 --- a/controlplane/src/core/build-server.ts +++ b/controlplane/src/core/build-server.ts @@ -246,7 +246,7 @@ export default async function build(opts: BuildConfig) { const apiKeyAuth = new ApiKeyAuthenticator(fastify.db, organizationRepository); const userRepo = new UserRepository(logger, fastify.db); const apiKeyRepository = new ApiKeyRepository(fastify.db); - const webAuth = new WebSessionAuthenticator(opts.auth.secret, userRepo); + const webAuth = new WebSessionAuthenticator(fastify.db, opts.auth.secret, userRepo); const graphKeyAuth = new GraphApiTokenAuthenticator(opts.auth.secret); const accessTokenAuth = new AccessTokenAuthenticator(organizationRepository, authUtils); const authenticator = new Authentication(webAuth, apiKeyAuth, accessTokenAuth, graphKeyAuth, organizationRepository); diff --git a/controlplane/src/core/controllers/auth.ts b/controlplane/src/core/controllers/auth.ts index 1dd52acc75..0cf861ee6d 100644 --- a/controlplane/src/core/controllers/auth.ts +++ b/controlplane/src/core/controllers/auth.ts @@ -1,4 +1,4 @@ -import { FastifyPluginCallback } from 'fastify'; +import { FastifyPluginCallback, FastifyReply, FastifyRequest } from 'fastify'; import fp from 'fastify-plugin'; import { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; import { and, eq, sql } from 'drizzle-orm'; @@ -39,10 +39,42 @@ export type AuthControllerOptions = { defaultBillingPlanId?: string; }; +type SessionRequest = FastifyRequest; +type SessionReply = FastifyReply; +type LogoutRequest = FastifyRequest; +type LogoutReply = FastifyReply; + +type CallbackRequest = FastifyRequest<{ + Querystring: { + code: string; + code_verifier: string; + redirectURL?: string; + ssoSlug?: string; + }; +}>; +type CallbackReply = FastifyReply; + +type LoginRequest = FastifyRequest<{ + Querystring: { + redirectURL?: string; + provider?: string; + sso?: string; + }; +}>; +type LoginReply = FastifyReply; + +type SignupRequest = FastifyRequest<{ + Querystring: { + redirectURL?: string; + provider?: string; + }; +}>; +type SignupReply = FastifyReply; + const plugin: FastifyPluginCallback = function Auth(fastify, opts, done) { const keycloakUserInfoCache = lru(1000, 15_000); - fastify.get('/session', async (req, res) => { + fastify.get('/session', async (req: SessionRequest, res: SessionReply) => { try { const userSession = await opts.authUtils.renewSession(req, res); let userInfoData = keycloakUserInfoCache.get(userSession.accessToken); @@ -91,7 +123,7 @@ const plugin: FastifyPluginCallback = function Auth(fasti } }); - fastify.get('/logout', async (req, res) => { + fastify.get('/logout', async (req: LogoutRequest, res: LogoutReply) => { // Will throw an error if the token is invalid or expired const { sessionId } = await opts.authUtils.parseUserSessionCookie(req); @@ -112,292 +144,287 @@ const plugin: FastifyPluginCallback = function Auth(fasti opts.authUtils.logout(res, userSessions[0].idToken); }); - fastify.get<{ Querystring: { code: string; code_verifier: string; redirectURL?: string; ssoSlug?: string } }>( - '/callback', - async (req, res) => { - try { - const redirectURL = req.query?.redirectURL; - const ssoSlug = req.query?.ssoSlug; - const { accessToken, refreshToken, idToken } = await opts.authUtils.handleAuthCallbackRequest(req); - - // decodeJWT will throw an error if the token is invalid or expired - const accessTokenPayload = decodeJWT(accessToken); - - // Clear the PKCE cookie - opts.authUtils.clearCookie(res, opts.pkce.cookieName); - // Clear the sso cookie - opts.authUtils.clearCookie(res, cosmoIdpHintCookieName); - - const sessionExpiresIn = DEFAULT_SESSION_MAX_AGE_SEC; - const sessionExpiresDate = new Date(Date.now() + 1000 * sessionExpiresIn); - - const userId = accessTokenPayload.sub!; - const userEmail = accessTokenPayload.email!; - const firstName = accessTokenPayload.given_name || ''; - const lastName = accessTokenPayload.family_name || ''; - - const insertedSession = await opts.db.transaction(async (tx) => { - // Upsert the user - await tx - .insert(users) - .values({ - id: userId, + fastify.get('/callback', async (req: CallbackRequest, res: CallbackReply) => { + try { + const redirectURL = req.query?.redirectURL; + const ssoSlug = req.query?.ssoSlug; + const { accessToken, refreshToken, idToken } = await opts.authUtils.handleAuthCallbackRequest(req); + + // decodeJWT will throw an error if the token is invalid or expired + const accessTokenPayload = decodeJWT(accessToken); + + // Clear the PKCE cookie + opts.authUtils.clearCookie(res, opts.pkce.cookieName); + // Clear the sso cookie + opts.authUtils.clearCookie(res, cosmoIdpHintCookieName); + + const sessionExpiresIn = DEFAULT_SESSION_MAX_AGE_SEC; + const sessionExpiresDate = new Date(Date.now() + 1000 * sessionExpiresIn); + + const userId = accessTokenPayload.sub!; + const userEmail = accessTokenPayload.email!; + const firstName = accessTokenPayload.given_name || ''; + const lastName = accessTokenPayload.family_name || ''; + + const insertedSession = await opts.db.transaction(async (tx) => { + // Upsert the user + await tx + .insert(users) + .values({ + id: userId, + email: accessTokenPayload.email, + }) + .onConflictDoUpdate({ + target: users.id, + // Update the fields when the user already exists + set: { email: accessTokenPayload.email, - }) - .onConflictDoUpdate({ - target: users.id, - // Update the fields when the user already exists - set: { - email: accessTokenPayload.email, - }, - }) - .execute(); - - if (accessTokenPayload.groups && accessTokenPayload.groups.length > 0) { - const keycloakOrgs = new Set(accessTokenPayload.groups.map((grp) => grp.split('/')[1])); - const orgRepo = new OrganizationRepository(req.log, tx, opts.defaultBillingPlanId); - const orgGroupRepo = new OrganizationGroupRepository(tx); - - // delete all the org member roles - for (const slug of keycloakOrgs) { - const dbOrg = await orgRepo.bySlug(slug); - - if (!dbOrg) { - continue; - } - - const orgMember = await orgRepo.getOrganizationMember({ organizationID: dbOrg.id, userID: userId }); - if (!orgMember) { - continue; - } - - await tx - .delete(schema.organizationGroupMembers) - .where(eq(schema.organizationGroupMembers.organizationMemberId, orgMember.orgMemberID)); + }, + }) + .execute(); + + if (accessTokenPayload.groups && accessTokenPayload.groups.length > 0) { + const keycloakOrgs = new Set(accessTokenPayload.groups.map((grp) => grp.split('/')[1])); + const orgRepo = new OrganizationRepository(req.log, tx, opts.defaultBillingPlanId); + const orgGroupRepo = new OrganizationGroupRepository(tx); + + // delete all the org member roles + for (const slug of keycloakOrgs) { + const dbOrg = await orgRepo.bySlug(slug); + + if (!dbOrg) { + continue; + } + + const orgMember = await orgRepo.getOrganizationMember({ organizationID: dbOrg.id, userID: userId }); + if (!orgMember) { + continue; + } + + await tx + .delete(schema.organizationGroupMembers) + .where(eq(schema.organizationGroupMembers.organizationMemberId, orgMember.orgMemberID)); + } + + // upserting the members into the orgs and inserting their roles. + for (const kcGroup of accessTokenPayload.groups) { + const slug = kcGroup.split('/')[1]; + const dbOrg = await orgRepo.bySlug(slug); + if (!dbOrg) { + continue; } - // upserting the members into the orgs and inserting their roles. - for (const kcGroup of accessTokenPayload.groups) { - const slug = kcGroup.split('/')[1]; - const dbOrg = await orgRepo.bySlug(slug); - if (!dbOrg) { - continue; - } - - const insertedMember = await tx - .insert(organizationsMembers) - .values({ + const insertedMember = await tx + .insert(organizationsMembers) + .values({ + userId, + organizationId: dbOrg.id, + }) + .onConflictDoUpdate({ + target: [organizationsMembers.userId, organizationsMembers.organizationId], + // Update the fields only when the org member already exists + set: { userId, organizationId: dbOrg.id, - }) - .onConflictDoUpdate({ - target: [organizationsMembers.userId, organizationsMembers.organizationId], - // Update the fields only when the org member already exists - set: { - userId, - organizationId: dbOrg.id, - }, - }) - .returning() - .execute(); - - const groupName = kcGroup.split('/')?.[2]; - if (!groupName) { - continue; - } - - const orgGroup = await orgGroupRepo.byName({ - organizationId: dbOrg.id, - name: groupName, - }); - - if (!orgGroup) { - // The group doesn't exist for the organization, instead of failing, we'll just skip the group - continue; - } - - await orgGroupRepo.addUserToGroup({ - organizationMemberId: insertedMember[0].id, - groupId: orgGroup.groupId, - }); + }, + }) + .returning() + .execute(); + + const groupName = kcGroup.split('/')?.[2]; + if (!groupName) { + continue; } + + const orgGroup = await orgGroupRepo.byName({ + organizationId: dbOrg.id, + name: groupName, + }); + + if (!orgGroup) { + // The group doesn't exist for the organization, instead of failing, we'll just skip the group + continue; + } + + await orgGroupRepo.addUserToGroup({ + organizationMemberId: insertedMember[0].id, + groupId: orgGroup.groupId, + }); } + } - // If there is already a session for this user, update it. - // Otherwise, insert a new session. Because we use an Idp like keycloak, - // we can assume that the user will have only one session per client at a time. - const insertedSessions = await tx - .insert(sessions) - .values({ - userId, + // If there is already a session for this user, update it. + // Otherwise, insert a new session. Because we use an Idp like keycloak, + // we can assume that the user will have only one session per client at a time. + const insertedSessions = await tx + .insert(sessions) + .values({ + userId, + idToken, + accessToken, + refreshToken, + expiresAt: sessionExpiresDate, + }) + .onConflictDoUpdate({ + target: sessions.userId, + // Update the fields when the session already exists + set: { idToken, accessToken, refreshToken, expiresAt: sessionExpiresDate, - }) - .onConflictDoUpdate({ - target: sessions.userId, - // Update the fields when the session already exists - set: { - idToken, - accessToken, - refreshToken, - expiresAt: sessionExpiresDate, - updatedAt: new Date(), - }, - }) - .returning({ - id: sessions.id, - userId: sessions.userId, - }) - .execute(); - - return insertedSessions[0]; - }); + updatedAt: new Date(), + }, + }) + .returning({ + id: sessions.id, + userId: sessions.userId, + }) + .execute(); + + return insertedSessions[0]; + }); - const orgs = await opts.db.transaction(async (tx) => { - const advisoryLockRows = await tx.execute( - sql`select pg_try_advisory_xact_lock(hashtext(${userId})) as acquired`, - ); + const orgs = await opts.db.transaction(async (tx) => { + const advisoryLockRows = await tx.execute( + sql`select pg_try_advisory_xact_lock(hashtext(${userId})) as acquired`, + ); - if (!advisoryLockRows?.[0]?.acquired) { - // We need to identify when we failed to acquire the lock because another request already acquired it - return -1; - } + if (!advisoryLockRows?.[0]?.acquired) { + // We need to identify when we failed to acquire the lock because another request already acquired it + return -1; + } - const orgRepo = new OrganizationRepository(req.log, tx, opts.defaultBillingPlanId); + const orgRepo = new OrganizationRepository(req.log, tx, opts.defaultBillingPlanId); - // Check if the user is already a member of at least one organization - const existingMemberships = await tx - .select({ one: sql`1`.as('one') }) - .from(organizationsMembers) - .where(and(eq(organizationsMembers.userId, userId), eq(organizationsMembers.active, true))) - .limit(1) - .execute(); + // Check if the user is already a member of at least one organization + const existingMemberships = await tx + .select({ one: sql`1`.as('one') }) + .from(organizationsMembers) + .where(and(eq(organizationsMembers.userId, userId), eq(organizationsMembers.active, true))) + .limit(1) + .execute(); - if (existingMemberships.length > 0) { - return existingMemberships.length; - } + if (existingMemberships.length > 0) { + return existingMemberships.length; + } - // Authenticate on Keycloak and create the organization group - await opts.keycloakClient.authenticateClient(); + // Authenticate on Keycloak and create the organization group + await opts.keycloakClient.authenticateClient(); - const organizationSlug = uid(8); - const [kcRootGroupId, kcCreatedGroups] = await opts.keycloakClient.seedGroup({ - userID: userId, - organizationSlug, - realm: opts.keycloakRealm, - }); - - // Create the new organization and add the user as a member of the organization - const insertedOrg = await orgRepo.createOrganization({ - organizationName: userEmail.split('@')[0], - organizationSlug, - ownerID: userId, - kcGroupId: kcRootGroupId, - }); + const organizationSlug = uid(8); + const [kcRootGroupId, kcCreatedGroups] = await opts.keycloakClient.seedGroup({ + userID: userId, + organizationSlug, + realm: opts.keycloakRealm, + }); - const orgMember = await orgRepo.addOrganizationMember({ - organizationID: insertedOrg.id, - userID: userId, - }); + // Create the new organization and add the user as a member of the organization + const insertedOrg = await orgRepo.createOrganization({ + organizationName: userEmail.split('@')[0], + organizationSlug, + ownerID: userId, + kcGroupId: kcRootGroupId, + }); - // Create the organization groups - const orgGroupRepo = new OrganizationGroupRepository(tx); + const orgMember = await orgRepo.addOrganizationMember({ + organizationID: insertedOrg.id, + userID: userId, + }); - await orgGroupRepo.importKeycloakGroups({ - organizationId: insertedOrg.id, - kcGroups: kcCreatedGroups, - }); + // Create the organization groups + const orgGroupRepo = new OrganizationGroupRepository(tx); - const orgAdminGroup = await orgGroupRepo.byName({ - organizationId: insertedOrg.id, - name: 'admin', - }); + await orgGroupRepo.importKeycloakGroups({ + organizationId: insertedOrg.id, + kcGroups: kcCreatedGroups, + }); - if (orgAdminGroup) { - await orgGroupRepo.addUserToGroup({ - organizationMemberId: orgMember.id, - groupId: orgAdminGroup.groupId, - }); - } + const orgAdminGroup = await orgGroupRepo.byName({ + organizationId: insertedOrg.id, + name: 'admin', + }); - // Create the default namespace for the organization - const namespaceRepo = new NamespaceRepository(tx, insertedOrg.id); - const ns = await namespaceRepo.create({ - name: DefaultNamespace, - createdBy: userId, + if (orgAdminGroup) { + await orgGroupRepo.addUserToGroup({ + organizationMemberId: orgMember.id, + groupId: orgAdminGroup.groupId, }); + } - if (!ns) { - throw new Error(`Could not create ${DefaultNamespace} namespace`); - } - - // We return an empty even when we just created the organization, that way we can still send the - // user registered webhook and prompt the user to migrate - return 0; + // Create the default namespace for the organization + const namespaceRepo = new NamespaceRepository(tx, insertedOrg.id); + const ns = await namespaceRepo.create({ + name: DefaultNamespace, + createdBy: userId, }); - if (orgs === -1) { - // We failed to acquire the lock, so we need to retry the request - await res.code(429).send('Slow down'); - return; + if (!ns) { + throw new Error(`Could not create ${DefaultNamespace} namespace`); } - if (orgs === 0) { - // Send a notification to the platform that a new user has been created - opts.platformWebhooks.send(PlatformEventName.USER_REGISTER_SUCCESS, { - user_id: userId, - user_email: userEmail, - user_first_name: firstName, - user_last_name: lastName, - }); - } + // We return an empty even when we just created the organization, that way we can still send the + // user registered webhook and prompt the user to migrate + return 0; + }); + + if (orgs === -1) { + // We failed to acquire the lock, so we need to retry the request + await res.code(429).send('Slow down'); + return; + } - // Create a JWT token containing the session id and user id. - const jwt = await encrypt({ - maxAgeInSeconds: sessionExpiresIn, - token: { - iss: userId, - sessionId: insertedSession.id, - }, - secret: opts.jwtSecret, + if (orgs === 0) { + // Send a notification to the platform that a new user has been created + opts.platformWebhooks.send(PlatformEventName.USER_REGISTER_SUCCESS, { + user_id: userId, + user_email: userEmail, + user_first_name: firstName, + user_last_name: lastName, }); + } - // Set the session cookie. The cookie value is encrypted. - opts.authUtils.createSessionCookie(res, jwt, sessionExpiresDate); - if (ssoSlug) { - // Set the sso cookie. - opts.authUtils.createSsoCookie(res, ssoSlug); - } - if (redirectURL) { - if (redirectURL.startsWith(opts.webBaseUrl)) { - res.redirect(redirectURL); - } else { - res.redirect(opts.webBaseUrl); - } - } else if (orgs === 0) { - res.redirect(opts.webBaseUrl + '?migrate=true'); + // Create a JWT token containing the session id and user id. + const jwt = await encrypt({ + maxAgeInSeconds: sessionExpiresIn, + token: { + iss: userId, + sessionId: insertedSession.id, + }, + secret: opts.jwtSecret, + }); + + // Set the session cookie. The cookie value is encrypted. + opts.authUtils.createSessionCookie(res, jwt, sessionExpiresDate); + if (ssoSlug) { + // Set the sso cookie. + opts.authUtils.createSsoCookie(res, ssoSlug); + } + if (redirectURL) { + if (redirectURL.startsWith(opts.webBaseUrl)) { + res.redirect(redirectURL); } else { res.redirect(opts.webBaseUrl); } - } catch (err: any) { - if (err instanceof AuthenticationError) { - req.log.debug(err); - } else { - req.log.error(err); - } - - req.log.debug('Redirecting to home due to error in /callback route'); - + } else if (orgs === 0) { + res.redirect(opts.webBaseUrl + '?migrate=true'); + } else { res.redirect(opts.webBaseUrl); } - }, - ); + } catch (err: any) { + if (err instanceof AuthenticationError) { + req.log.debug(err); + } else { + req.log.error(err); + } + + req.log.debug('Redirecting to home due to error in /callback route'); + + res.redirect(opts.webBaseUrl); + } + }); - fastify.get<{ - Querystring: { redirectURL?: string; provider?: string; sso?: string }; - }>('/login', async (req, res) => { + fastify.get('/login', async (req: LoginRequest, res: LoginReply) => { const redirectURL = req.query?.redirectURL; const provider = req.query?.provider; const sso = req.query?.sso; @@ -412,9 +439,7 @@ const plugin: FastifyPluginCallback = function Auth(fasti res.redirect(authorizationUrl); }); - fastify.get<{ - Querystring: { redirectURL?: string; provider?: string }; - }>('/signup', async (req, res) => { + fastify.get('/signup', async (req: SignupRequest, res: SignupReply) => { const redirectURL = req.query?.redirectURL; const provider = req.query?.provider; const { authorizationUrl, pkceCookie } = await opts.authUtils.handleLoginRequest({ diff --git a/controlplane/src/core/services/WebSessionAuthenticator.ts b/controlplane/src/core/services/WebSessionAuthenticator.ts index 54c30013d1..7f299b89ff 100644 --- a/controlplane/src/core/services/WebSessionAuthenticator.ts +++ b/controlplane/src/core/services/WebSessionAuthenticator.ts @@ -1,7 +1,11 @@ import cookie from 'cookie'; +import { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; +import { eq } from 'drizzle-orm'; import { UserSession } from '../../types/index.js'; import { decrypt, userSessionCookieName } from '../crypto/jwt.js'; import { UserRepository } from '../repositories/UserRepository.js'; +import * as schema from '../../db/schema.js'; +import AuthUtils from '../auth-utils.js'; export const OrganizationSlugHeader = 'cosmo-org-slug'; @@ -14,6 +18,7 @@ export type WebAuthAuthContext = { export default class WebSessionAuthenticator { constructor( + private db: PostgresJsDatabase, private jwtSecret: string, private userRepository: UserRepository, ) {} @@ -40,6 +45,22 @@ export default class WebSessionAuthenticator { throw new Error('Missing user id in JWT'); } + // Ensure that the session is still valid + if (!decryptedJwt.sessionId) { + throw new Error('Missing session id in JWT'); + } + + const existingSessions = await this.db + .select() + .from(schema.sessions) + .where(eq(schema.sessions.id, decryptedJwt.sessionId)) + .limit(1) + .execute(); + + if (existingSessions.length !== 1 || AuthUtils.isSessionExpired(existingSessions[0])) { + throw new Error('Invalid or expired session'); + } + const organizationSlug = headers.get(OrganizationSlugHeader); if (!organizationSlug) { throw new Error('Missing organization slug header'); From f2ee2c962c35500ccd7e297c975b27334af78ad3 Mon Sep 17 00:00:00 2001 From: Wilson Rivera Date: Wed, 10 Dec 2025 18:36:21 -0500 Subject: [PATCH 2/2] chore: validate that the session really belongs to the user --- controlplane/src/core/services/WebSessionAuthenticator.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/controlplane/src/core/services/WebSessionAuthenticator.ts b/controlplane/src/core/services/WebSessionAuthenticator.ts index 7f299b89ff..3a49787a40 100644 --- a/controlplane/src/core/services/WebSessionAuthenticator.ts +++ b/controlplane/src/core/services/WebSessionAuthenticator.ts @@ -57,7 +57,11 @@ export default class WebSessionAuthenticator { .limit(1) .execute(); - if (existingSessions.length !== 1 || AuthUtils.isSessionExpired(existingSessions[0])) { + if ( + existingSessions.length !== 1 || + existingSessions[0].userId.toLowerCase() !== decryptedJwt.iss?.toLowerCase() || + AuthUtils.isSessionExpired(existingSessions[0]) + ) { throw new Error('Invalid or expired session'); }