Skip to content
Merged
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
33 changes: 32 additions & 1 deletion app/entry.server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ import * as Sentry from '@sentry/remix'
import chalk from 'chalk'
import { isbot } from 'isbot'
import { renderToPipeableStream } from 'react-dom/server'
import { getSessionRenewal } from './utils/auth.server.ts'
import { init as initCron } from './utils/cron.server.ts'
import { getEnv, init as initEnv } from './utils/env.server.ts'
import { getInstanceInfo } from './utils/litefs.server.ts'
import { NonceProvider } from './utils/nonce-provider.ts'
import { authSessionStorage } from './utils/session.server.ts'
import { makeTimings } from './utils/timing.server.ts'

const ABORT_DELAY = 5000
Expand All @@ -25,6 +27,26 @@ void initCron()

type DocRequestArgs = Parameters<HandleDocumentRequestFunction>

/**
* Handle session renewal by adding the appropriate cookie headers
*/
async function handleSessionRenewal(request: Request, headers: Headers) {
const sessionRenewal = getSessionRenewal(request)
if (sessionRenewal) {
// Create a new session storage instance with the renewed session
const authSession = await authSessionStorage.getSession()
authSession.set('sessionId', sessionRenewal.sessionId)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Session Renewal Creates New Session, Losing Data

The handleSessionRenewal function creates a new, empty session rather than retrieving the existing one from the request. This can cause other session data to be lost during renewal. Also, the sessionId string is hardcoded instead of using the sessionKey constant, which is inconsistent with other session handling.

Fix in Cursor Fix in Web


// Commit the session with the new expiration date
const cookieHeader = await authSessionStorage.commitSession(authSession, {
expires: sessionRenewal.expirationDate,
})

// Add the session cookie to the response headers
headers.append('set-cookie', cookieHeader)
}
}

export default async function handleRequest(...args: DocRequestArgs) {
const [
request,
Expand All @@ -43,6 +65,9 @@ export default async function handleRequest(...args: DocRequestArgs) {
responseHeaders.append('Document-Policy', 'js-profiling')
}

// Handle session renewal for document requests
await handleSessionRenewal(request, responseHeaders)

const callbackName = isbot(request.headers.get('user-agent'))
? 'onAllReady'
: 'onShellReady'
Expand Down Expand Up @@ -85,13 +110,19 @@ export default async function handleRequest(...args: DocRequestArgs) {
})
}

export async function handleDataRequest(response: Response) {
export async function handleDataRequest(
response: Response,
{ request }: LoaderFunctionArgs | ActionFunctionArgs,
) {
const { currentInstance, primaryInstance } = await getInstanceInfo()
response.headers.set('fly-region', process.env.FLY_REGION ?? 'unknown')
response.headers.set('fly-app', process.env.FLY_APP_NAME ?? 'unknown')
response.headers.set('fly-primary-instance', primaryInstance)
response.headers.set('fly-instance', currentInstance)

// Handle session renewal for data requests
await handleSessionRenewal(request, response.headers)

return response
}

Expand Down
2 changes: 1 addition & 1 deletion app/routes/_app+/users+/$username.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ test('The user profile when logged in as self', async () => {
const session = await prisma.session.create({
select: { id: true },
data: {
expirationDate: getSessionExpirationDate(),
expirationDate: getSessionExpirationDate({ isRenewal: false }),
userId: user.id,
},
})
Expand Down
106 changes: 100 additions & 6 deletions app/utils/auth.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,29 +6,123 @@ import { prisma } from './db.server.ts'
import { combineHeaders } from './misc.tsx'
import { authSessionStorage } from './session.server.ts'

export const SESSION_EXPIRATION_TIME = 1000 * 60 * 60 * 24 * 30
export const getSessionExpirationDate = () =>
new Date(Date.now() + SESSION_EXPIRATION_TIME)
// Session expiration constants
export const SESSION_IDLE_TIMEOUT = 1000 * 60 * 60 * 24 * 14 // 14 days
export const SESSION_ABSOLUTE_MAX_LIFETIME = 1000 * 60 * 60 * 24 * 90 // 90 days

export const getSessionExpirationDate = ({
isRenewal,
originalCreatedAt,
}: {
isRenewal: boolean
originalCreatedAt?: Date
}) => {
if (isRenewal && originalCreatedAt) {
// For renewals, calculate the maximum possible expiration date
// based on the original session creation time
const maxPossibleExpiration = new Date(
originalCreatedAt.getTime() + SESSION_ABSOLUTE_MAX_LIFETIME,
)
const idleTimeoutExpiration = new Date(Date.now() + SESSION_IDLE_TIMEOUT)

// Return the earlier of the two to enforce absolute maximum lifetime
return maxPossibleExpiration < idleTimeoutExpiration
? maxPossibleExpiration
: idleTimeoutExpiration
} else {
// For new sessions, use absolute max lifetime
return new Date(Date.now() + SESSION_ABSOLUTE_MAX_LIFETIME)
}
}

export const sessionKey = 'sessionId'

// WeakMap to associate session renewals with requests
const sessionRenewalMap = new WeakMap<
Request,
{ sessionId: string; expirationDate: Date }
>()

/**
* Get session renewal info for a request if it exists
*/
export function getSessionRenewal(request: Request) {
return sessionRenewalMap.get(request)
}

export async function getUserId(request: Request) {
const authSession = await authSessionStorage.getSession(
request.headers.get('cookie'),
)
const sessionId = authSession.get(sessionKey)
if (!sessionId) return null

const session = await prisma.session.findUnique({
select: { user: { select: { id: true } } },
select: {
id: true,
expirationDate: true,
createdAt: true,
user: { select: { id: true } },
},
where: { id: sessionId, expirationDate: { gt: new Date() } },
})

if (!session?.user) {
throw redirect('/', {
headers: {
'set-cookie': await authSessionStorage.destroySession(authSession),
},
})
}

// Check if session needs renewal (within 7 days of expiration)
const sevenDaysFromNow = new Date(Date.now() + 1000 * 60 * 60 * 24 * 7)
const needsRenewal = session.expirationDate < sevenDaysFromNow

// Check if session has reached absolute max lifetime
const absoluteMaxExpiration = new Date(
session.createdAt.getTime() + SESSION_ABSOLUTE_MAX_LIFETIME,
)
const hasReachedMaxLifetime = new Date() >= absoluteMaxExpiration

if (hasReachedMaxLifetime) {
// Session has reached absolute max lifetime, force logout
await prisma.session.delete({ where: { id: sessionId } })
throw redirect('/', {
headers: {
'set-cookie': await authSessionStorage.destroySession(authSession),
},
})
}

if (needsRenewal) {
// Create new session with rotated ID and extended expiration
// Pass the original creation time to enforce absolute maximum lifetime
const newSession = await prisma.session.create({
data: {
expirationDate: getSessionExpirationDate({
isRenewal: true,
originalCreatedAt: session.createdAt,
}),
userId: session.user.id,
// Preserve the original creation time to enforce absolute maximum lifetime
createdAt: session.createdAt,
},
})

// Update the session in the cookie
authSession.set(sessionKey, newSession.id)

// Delete the old session
await prisma.session.delete({ where: { id: sessionId } })

// Store the new session info in WeakMap for entry.server.tsx to handle
sessionRenewalMap.set(request, {
sessionId: newSession.id,
expirationDate: newSession.expirationDate,
})
}

return session.user.id
}

Expand Down Expand Up @@ -71,7 +165,7 @@ export async function login({
const session = await prisma.session.create({
select: { id: true, expirationDate: true, userId: true },
data: {
expirationDate: getSessionExpirationDate(),
expirationDate: getSessionExpirationDate({ isRenewal: false }),
userId: user.id,
},
})
Expand Down Expand Up @@ -113,7 +207,7 @@ export async function signup({

const session = await prisma.session.create({
data: {
expirationDate: getSessionExpirationDate(),
expirationDate: getSessionExpirationDate({ isRenewal: false }),
user: {
create: {
phoneNumber: phoneNumber,
Expand Down
2 changes: 1 addition & 1 deletion tests/playwright-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export const test = base.extend<{
userId = user.id
const session = await prisma.session.create({
data: {
expirationDate: getSessionExpirationDate(),
expirationDate: getSessionExpirationDate({ isRenewal: false }),
userId: user.id,
},
select: { id: true },
Expand Down