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
4 changes: 4 additions & 0 deletions app/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { createContext } from 'react-router'

// Holds the authenticated user's ID for routes protected by middleware
export const userIdContext = createContext<string | null>(null)
39 changes: 39 additions & 0 deletions app/middleware.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { redirect, type MiddlewareFunction } from 'react-router'
import { userIdContext } from '#app/context.ts'
import { prisma } from '#app/utils/db.server.ts'
import { authSessionStorage } from '#app/utils/session.server.ts'

export const requireUserMiddleware: MiddlewareFunction = async ({
request,
context,
}) => {
const cookie = request.headers.get('cookie')
const session = await authSessionStorage.getSession(cookie)
const sessionId = session.get('sessionId') as string | undefined
if (!sessionId)
throw redirect(
`/login?redirectTo=${encodeURIComponent(new URL(request.url).pathname)}`,
)

const sessionRecord = await prisma.session.findUnique({
select: { userId: true, expirationDate: true },
where: { id: sessionId },
})

if (!sessionRecord || sessionRecord.expirationDate < new Date()) {
throw redirect(
`/login?redirectTo=${encodeURIComponent(new URL(request.url).pathname)}`,
)
}

context.set(userIdContext, sessionRecord.userId)
}

export const requireAnonymousMiddleware: MiddlewareFunction = async ({
request,
}) => {
const cookie = request.headers.get('cookie')
const session = await authSessionStorage.getSession(cookie)
const sessionId = session.get('sessionId') as string | undefined
Copy link

Choose a reason for hiding this comment

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

Bug: Inconsistent Session Key Causes Authentication Issues

The middleware functions in app/middleware.server.ts use a hardcoded 'sessionId' string for session retrieval. This is inconsistent with the sessionKey constant used throughout the rest of the authentication system, causing authentication failures and incorrect route protection.

Fix in Cursor Fix in Web

if (sessionId) throw redirect('/')
}
53 changes: 41 additions & 12 deletions app/routes/_auth+/auth.$provider.callback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { invariant } from '@epic-web/invariant'
import { faker } from '@faker-js/faker'
import { SetCookie } from '@mjackson/headers'
import { http } from 'msw'
import { RouterContextProvider } from 'react-router'
import { afterEach, expect, test } from 'vitest'
import { twoFAVerificationType } from '#app/routes/settings+/profile.two-factor.tsx'
import { getSessionExpirationDate, sessionKey } from '#app/utils/auth.server.ts'
Expand All @@ -25,9 +26,11 @@ afterEach(async () => {

test('a new user goes to onboarding', async () => {
const request = await setupRequest()
const response = await loader({ request, params: PARAMS, context: {} }).catch(
(e) => e,
)
const response = await loader({
request,
params: PARAMS,
context: new RouterContextProvider() as any,
}).catch((e) => e)
expect(response).toHaveRedirect('/onboarding/github')
})

Expand All @@ -39,9 +42,11 @@ test('when auth fails, send the user to login with a toast', async () => {
}),
)
const request = await setupRequest()
const response = await loader({ request, params: PARAMS, context: {} }).catch(
(e) => e,
)
const response = await loader({
request,
params: PARAMS,
context: new RouterContextProvider() as any,
}).catch((e) => e)
invariant(response instanceof Response, 'response should be a Response')
expect(response).toHaveRedirect('/login')
await expect(response).toSendToast(
Expand All @@ -60,7 +65,11 @@ test('when a user is logged in, it creates the connection', async () => {
sessionId: session.id,
code: githubUser.code,
})
const response = await loader({ request, params: PARAMS, context: {} })
const response = await loader({
request,
params: PARAMS,
context: new RouterContextProvider() as any,
})
expect(response).toHaveRedirect('/settings/profile/connections')
await expect(response).toSendToast(
expect.objectContaining({
Expand Down Expand Up @@ -96,7 +105,11 @@ test(`when a user is logged in and has already connected, it doesn't do anything
sessionId: session.id,
code: githubUser.code,
})
const response = await loader({ request, params: PARAMS, context: {} })
const response = await loader({
request,
params: PARAMS,
context: new RouterContextProvider() as any,
})
expect(response).toHaveRedirect('/settings/profile/connections')
await expect(response).toSendToast(
expect.objectContaining({
Expand All @@ -111,7 +124,11 @@ test('when a user exists with the same email, create connection and make session
const email = githubUser.primaryEmail.toLowerCase()
const { userId } = await setupUser({ ...createUser(), email })
const request = await setupRequest({ code: githubUser.code })
const response = await loader({ request, params: PARAMS, context: {} })
const response = await loader({
request,
params: PARAMS,
context: new RouterContextProvider() as any,
})

expect(response).toHaveRedirect('/')

Expand Down Expand Up @@ -155,7 +172,11 @@ test('gives an error if the account is already connected to another user', async
sessionId: session.id,
code: githubUser.code,
})
const response = await loader({ request, params: PARAMS, context: {} })
const response = await loader({
request,
params: PARAMS,
context: new RouterContextProvider() as any,
})
expect(response).toHaveRedirect('/settings/profile/connections')
await expect(response).toSendToast(
expect.objectContaining({
Expand All @@ -178,7 +199,11 @@ test('if a user is not logged in, but the connection exists, make a session', as
},
})
const request = await setupRequest({ code: githubUser.code })
const response = await loader({ request, params: PARAMS, context: {} })
const response = await loader({
request,
params: PARAMS,
context: new RouterContextProvider() as any,
})
expect(response).toHaveRedirect('/')
await expect(response).toHaveSessionForUser(userId)
})
Expand All @@ -202,7 +227,11 @@ test('if a user is not logged in, but the connection exists and they have enable
},
})
const request = await setupRequest({ code: githubUser.code })
const response = await loader({ request, params: PARAMS, context: {} })
const response = await loader({
request,
params: PARAMS,
context: new RouterContextProvider() as any,
})
const searchParams = new URLSearchParams({
type: twoFAVerificationType,
target: userId,
Expand Down
3 changes: 3 additions & 0 deletions app/routes/_auth+/login.server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { invariant } from '@epic-web/invariant'
import { redirect } from 'react-router'
import { safeRedirect } from 'remix-utils/safe-redirect'
import { requireAnonymousMiddleware } from '#app/middleware.server.ts'
import { twoFAVerificationType } from '#app/routes/settings+/profile.two-factor.tsx'
import { getUserId, sessionKey } from '#app/utils/auth.server.ts'
import { prisma } from '#app/utils/db.server.ts'
Expand All @@ -10,6 +11,8 @@ import { redirectWithToast } from '#app/utils/toast.server.ts'
import { verifySessionStorage } from '#app/utils/verification.server.ts'
import { getRedirectToUrl, type VerifyFunctionArgs } from './verify.server.ts'

export const middleware = [requireAnonymousMiddleware]

const verifiedTimeKey = 'verified-time'
const unverifiedSessionIdKey = 'unverified-session-id'
const rememberKey = 'remember'
Expand Down
4 changes: 1 addition & 3 deletions app/routes/_auth+/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import { Spacer } from '#app/components/spacer.tsx'
import { Icon } from '#app/components/ui/icon.tsx'
import { StatusButton } from '#app/components/ui/status-button.tsx'
import { login, requireAnonymous } from '#app/utils/auth.server.ts'
import { login } from '#app/utils/auth.server.ts'
import {
ProviderConnectionForm,
providerNames,
Expand All @@ -37,13 +37,11 @@
options: z.object({ challenge: z.string() }),
}) satisfies z.ZodType<{ options: PublicKeyCredentialRequestOptionsJSON }>

export async function loader({ request }: Route.LoaderArgs) {

Check warning on line 40 in app/routes/_auth+/login.tsx

View workflow job for this annotation

GitHub Actions / ⬣ ESLint

'request' is defined but never used. Allowed unused args must match /^_/u
await requireAnonymous(request)
return {}
}

export async function action({ request }: Route.ActionArgs) {
await requireAnonymous(request)
const formData = await request.formData()
await checkHoneypot(formData)
const submission = await parseWithZod(formData, {
Expand Down
3 changes: 3 additions & 0 deletions app/routes/_auth+/onboarding.server.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { invariant } from '@epic-web/invariant'
import { redirect } from 'react-router'
import { requireAnonymousMiddleware } from '#app/middleware.server.ts'
import { verifySessionStorage } from '#app/utils/verification.server.ts'
import { onboardingEmailSessionKey } from './onboarding.tsx'
import { type VerifyFunctionArgs } from './verify.server.ts'

export const middleware = [requireAnonymousMiddleware]

export async function handleVerification({ submission }: VerifyFunctionArgs) {
invariant(
submission.status === 'success',
Expand Down
2 changes: 0 additions & 2 deletions app/routes/_auth+/onboarding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import { Spacer } from '#app/components/spacer.tsx'
import { StatusButton } from '#app/components/ui/status-button.tsx'
import {
checkIsCommonPassword,
requireAnonymous,
sessionKey,
signup,
} from '#app/utils/auth.server.ts'
Expand Down Expand Up @@ -42,7 +41,6 @@ const SignupFormSchema = z
.and(PasswordAndConfirmPasswordSchema)

async function requireOnboardingEmail(request: Request) {
await requireAnonymous(request)
const verifySession = await verifySessionStorage.getSession(
request.headers.get('cookie'),
)
Expand Down
20 changes: 2 additions & 18 deletions app/routes/_auth+/onboarding_.$provider.server.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,3 @@
import { invariant } from '@epic-web/invariant'
import { redirect } from 'react-router'
import { verifySessionStorage } from '#app/utils/verification.server.ts'
import { onboardingEmailSessionKey } from './onboarding.tsx'
import { type VerifyFunctionArgs } from './verify.server.ts'
import { requireAnonymousMiddleware } from '#app/middleware.server.ts'

export async function handleVerification({ submission }: VerifyFunctionArgs) {
invariant(
submission.status === 'success',
'Submission should be successful by now',
)
const verifySession = await verifySessionStorage.getSession()
verifySession.set(onboardingEmailSessionKey, submission.value.target)
return redirect('/onboarding', {
headers: {
'set-cookie': await verifySessionStorage.commitSession(verifySession),
},
})
}
export const middleware = [requireAnonymousMiddleware]
7 changes: 1 addition & 6 deletions app/routes/_auth+/onboarding_.$provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,7 @@ import { z } from 'zod'
import { CheckboxField, ErrorList, Field } from '#app/components/forms.tsx'
import { Spacer } from '#app/components/spacer.tsx'
import { StatusButton } from '#app/components/ui/status-button.tsx'
import {
sessionKey,
signupWithConnection,
requireAnonymous,
} from '#app/utils/auth.server.ts'
import { sessionKey, signupWithConnection } from '#app/utils/auth.server.ts'
import { ProviderNameSchema } from '#app/utils/connections.tsx'
import { prisma } from '#app/utils/db.server.ts'
import { useIsPending } from '#app/utils/misc.tsx'
Expand Down Expand Up @@ -53,7 +49,6 @@ async function requireData({
request: Request
params: Params
}) {
await requireAnonymous(request)
const verifySession = await verifySessionStorage.getSession(
request.headers.get('cookie'),
)
Expand Down
3 changes: 3 additions & 0 deletions app/routes/_auth+/reset-password.server.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { invariant } from '@epic-web/invariant'
import { data, redirect } from 'react-router'
import { requireAnonymousMiddleware } from '#app/middleware.server.ts'
import { prisma } from '#app/utils/db.server.ts'
import { verifySessionStorage } from '#app/utils/verification.server.ts'
import { resetPasswordUsernameSessionKey } from './reset-password.tsx'
import { type VerifyFunctionArgs } from './verify.server.ts'

export const middleware = [requireAnonymousMiddleware]

export async function handleVerification({ submission }: VerifyFunctionArgs) {
invariant(
submission.status === 'success',
Expand Down
2 changes: 0 additions & 2 deletions app/routes/_auth+/reset-password.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { ErrorList, Field } from '#app/components/forms.tsx'
import { StatusButton } from '#app/components/ui/status-button.tsx'
import {
checkIsCommonPassword,
requireAnonymous,
resetUserPassword,
} from '#app/utils/auth.server.ts'
import { useIsPending } from '#app/utils/misc.tsx'
Expand All @@ -24,7 +23,6 @@ export const resetPasswordUsernameSessionKey = 'resetPasswordUsername'
const ResetPasswordSchema = PasswordAndConfirmPasswordSchema

async function requireResetPasswordUsername(request: Request) {
await requireAnonymous(request)
const verifySession = await verifySessionStorage.getSession(
request.headers.get('cookie'),
)
Expand Down
3 changes: 3 additions & 0 deletions app/routes/_auth+/signup.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { requireAnonymousMiddleware } from '#app/middleware.server.ts'

export const middleware = [requireAnonymousMiddleware]
2 changes: 0 additions & 2 deletions app/routes/_auth+/signup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx'
import { ErrorList, Field } from '#app/components/forms.tsx'
import { StatusButton } from '#app/components/ui/status-button.tsx'
import { requireAnonymous } from '#app/utils/auth.server.ts'
import {
ProviderConnectionForm,
providerNames,
Expand All @@ -29,8 +28,7 @@
email: EmailSchema,
})

export async function loader({ request }: Route.LoaderArgs) {

Check warning on line 31 in app/routes/_auth+/signup.tsx

View workflow job for this annotation

GitHub Actions / ⬣ ESLint

'request' is defined but never used. Allowed unused args must match /^_/u
await requireAnonymous(request)
return null
}

Expand Down
15 changes: 13 additions & 2 deletions app/routes/_seo+/sitemap[.]xml.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
import { generateSitemap } from '@nasa-gcn/remix-seo'
import { type ServerBuild } from 'react-router'
import {
type ServerBuild,
type RouterContextProvider,
createContext,
} from 'react-router'
import { getDomainUrl } from '#app/utils/misc.tsx'
import { type Route } from './+types/sitemap[.]xml.ts'
// recreate context key to match the one set in server getLoadContext
export const serverBuildContext = createContext<Promise<{
error: unknown
build: ServerBuild
}> | null>(null)
Copy link

Choose a reason for hiding this comment

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

Bug: Sitemap Route Fails to Access Server Build Context

The sitemap[.]xml.ts route defines its own serverBuildContext instead of importing the one created in server/index.ts. React Router contexts rely on object identity, so this separate context instance prevents the sitemap from accessing the serverBuild data set by the server.

Fix in Cursor Fix in Web


export async function loader({ request, context }: Route.LoaderArgs) {
const serverBuild = (await context.serverBuild) as { build: ServerBuild }
const serverBuild = (await (context as Readonly<RouterContextProvider>).get(
Copy link

Choose a reason for hiding this comment

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

Does this actually work?

I think the routes can accessed like this if I'm not mistaken:

const { routes } = await import('virtual:react-router/server-build')

The loadContext can be ditched completely.

serverBuildContext,
)) as { build: ServerBuild }

// TODO: This is typeerror is coming up since of the remix-run/server-runtime package. We might need to remove/update that one.
// @ts-expect-error
Copy link

Choose a reason for hiding this comment

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

Expand Down
3 changes: 3 additions & 0 deletions app/routes/settings+/profile.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { requireUserMiddleware } from '#app/middleware.server.ts'

export const middleware = [requireUserMiddleware]
9 changes: 5 additions & 4 deletions app/routes/settings+/profile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Link, Outlet, useMatches } from 'react-router'
import { z } from 'zod'
import { Spacer } from '#app/components/spacer.tsx'
import { Icon } from '#app/components/ui/icon.tsx'
import { requireUserId } from '#app/utils/auth.server.ts'
import { userIdContext } from '#app/context.ts'
import { prisma } from '#app/utils/db.server.ts'
import { cn } from '#app/utils/misc.tsx'
import { useUser } from '#app/utils/user.ts'
Expand All @@ -18,10 +18,11 @@ export const handle: BreadcrumbHandle & SEOHandle = {
getSitemapEntries: () => null,
}

export async function loader({ request }: Route.LoaderArgs) {
const userId = await requireUserId(request)
export async function loader({ context }: Route.LoaderArgs) {
const userId = context.get(userIdContext) as string | null
invariantResponse(Boolean(userId), 'Unauthorized', { status: 401 })
const user = await prisma.user.findUnique({
where: { id: userId },
where: { id: userId as string },
select: { username: true },
})
invariantResponse(user, 'User not found', { status: 404 })
Expand Down
2 changes: 1 addition & 1 deletion app/utils/cache.server.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import fs from 'node:fs'
import path from 'node:path'
import { DatabaseSync } from 'node:sqlite'
import {
cachified as baseCachified,
verboseReporter,
Expand All @@ -13,7 +14,6 @@ import {
} from '@epic-web/cachified'
import { remember } from '@epic-web/remember'
import { LRUCache } from 'lru-cache'
import { DatabaseSync } from 'node:sqlite'
import { z } from 'zod'
import { updatePrimaryCacheValue } from '#app/routes/admin+/cache_.sqlite.server.ts'
import { getInstanceInfo, getInstanceInfoSync } from './litefs.server.ts'
Expand Down
1 change: 0 additions & 1 deletion docs/database.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,6 @@ You've got a few options:
re-generating the migration after fixing the error.
3. If you do care about the data and don't have a backup, you can follow these
steps:

1. Comment out the
[`exec` section from `litefs.yml` file](https://github.com/epicweb-dev/epic-stack/blob/main/other/litefs.yml#L31-L37).

Expand Down
Loading
Loading