Skip to content

Commit 9f9766a

Browse files
Refactor: Use react-router middleware for auth checks
This commit refactors authentication checks to use react-router's middleware system. This centralizes auth logic and improves code organization. Co-authored-by: me <[email protected]>
1 parent a1b3aa4 commit 9f9766a

File tree

15 files changed

+140
-159
lines changed

15 files changed

+140
-159
lines changed

app/context.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { createContext } from 'react-router'
2+
3+
// Holds the authenticated user's ID for routes protected by middleware
4+
export const userIdContext = createContext<string | null>(null)
5+

app/middleware.server.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { redirect, type MiddlewareFunction } from 'react-router'
2+
import { prisma } from '#app/utils/db.server.ts'
3+
import { authSessionStorage } from '#app/utils/session.server.ts'
4+
import { userIdContext } from '#app/context.ts'
5+
6+
export const requireUserMiddleware: MiddlewareFunction = async ({
7+
request,
8+
context,
9+
}) => {
10+
const cookie = request.headers.get('cookie')
11+
const session = await authSessionStorage.getSession(cookie)
12+
const sessionId = session.get('sessionId') as string | undefined
13+
if (!sessionId) throw redirect(`/login?redirectTo=${encodeURIComponent(new URL(request.url).pathname)}`)
14+
15+
const sessionRecord = await prisma.session.findUnique({
16+
select: { userId: true, expirationDate: true },
17+
where: { id: sessionId },
18+
})
19+
20+
if (!sessionRecord || sessionRecord.expirationDate < new Date()) {
21+
throw redirect(`/login?redirectTo=${encodeURIComponent(new URL(request.url).pathname)}`)
22+
}
23+
24+
context.set(userIdContext, sessionRecord.userId)
25+
}
26+
27+
export const requireAnonymousMiddleware: MiddlewareFunction = async ({
28+
request,
29+
}) => {
30+
const cookie = request.headers.get('cookie')
31+
const session = await authSessionStorage.getSession(cookie)
32+
const sessionId = session.get('sessionId') as string | undefined
33+
if (sessionId) throw redirect('/')
34+
}
35+

app/routes/_auth+/auth.$provider.callback.test.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { faker } from '@faker-js/faker'
33
import { SetCookie } from '@mjackson/headers'
44
import { http } from 'msw'
55
import { afterEach, expect, test } from 'vitest'
6+
import { RouterContextProvider } from 'react-router'
67
import { twoFAVerificationType } from '#app/routes/settings+/profile.two-factor.tsx'
78
import { getSessionExpirationDate, sessionKey } from '#app/utils/auth.server.ts'
89
import { GITHUB_PROVIDER_NAME } from '#app/utils/connections.tsx'
@@ -25,7 +26,7 @@ afterEach(async () => {
2526

2627
test('a new user goes to onboarding', async () => {
2728
const request = await setupRequest()
28-
const response = await loader({ request, params: PARAMS, context: {} }).catch(
29+
const response = await loader({ request, params: PARAMS, context: new RouterContextProvider() as any }).catch(
2930
(e) => e,
3031
)
3132
expect(response).toHaveRedirect('/onboarding/github')
@@ -39,7 +40,7 @@ test('when auth fails, send the user to login with a toast', async () => {
3940
}),
4041
)
4142
const request = await setupRequest()
42-
const response = await loader({ request, params: PARAMS, context: {} }).catch(
43+
const response = await loader({ request, params: PARAMS, context: new RouterContextProvider() as any }).catch(
4344
(e) => e,
4445
)
4546
invariant(response instanceof Response, 'response should be a Response')
@@ -60,7 +61,7 @@ test('when a user is logged in, it creates the connection', async () => {
6061
sessionId: session.id,
6162
code: githubUser.code,
6263
})
63-
const response = await loader({ request, params: PARAMS, context: {} })
64+
const response = await loader({ request, params: PARAMS, context: new RouterContextProvider() as any })
6465
expect(response).toHaveRedirect('/settings/profile/connections')
6566
await expect(response).toSendToast(
6667
expect.objectContaining({
@@ -96,7 +97,7 @@ test(`when a user is logged in and has already connected, it doesn't do anything
9697
sessionId: session.id,
9798
code: githubUser.code,
9899
})
99-
const response = await loader({ request, params: PARAMS, context: {} })
100+
const response = await loader({ request, params: PARAMS, context: new RouterContextProvider() as any })
100101
expect(response).toHaveRedirect('/settings/profile/connections')
101102
await expect(response).toSendToast(
102103
expect.objectContaining({
@@ -111,7 +112,7 @@ test('when a user exists with the same email, create connection and make session
111112
const email = githubUser.primaryEmail.toLowerCase()
112113
const { userId } = await setupUser({ ...createUser(), email })
113114
const request = await setupRequest({ code: githubUser.code })
114-
const response = await loader({ request, params: PARAMS, context: {} })
115+
const response = await loader({ request, params: PARAMS, context: new RouterContextProvider() as any })
115116

116117
expect(response).toHaveRedirect('/')
117118

@@ -155,7 +156,7 @@ test('gives an error if the account is already connected to another user', async
155156
sessionId: session.id,
156157
code: githubUser.code,
157158
})
158-
const response = await loader({ request, params: PARAMS, context: {} })
159+
const response = await loader({ request, params: PARAMS, context: new RouterContextProvider() as any })
159160
expect(response).toHaveRedirect('/settings/profile/connections')
160161
await expect(response).toSendToast(
161162
expect.objectContaining({
@@ -178,7 +179,7 @@ test('if a user is not logged in, but the connection exists, make a session', as
178179
},
179180
})
180181
const request = await setupRequest({ code: githubUser.code })
181-
const response = await loader({ request, params: PARAMS, context: {} })
182+
const response = await loader({ request, params: PARAMS, context: new RouterContextProvider() as any })
182183
expect(response).toHaveRedirect('/')
183184
await expect(response).toHaveSessionForUser(userId)
184185
})
@@ -202,7 +203,7 @@ test('if a user is not logged in, but the connection exists and they have enable
202203
},
203204
})
204205
const request = await setupRequest({ code: githubUser.code })
205-
const response = await loader({ request, params: PARAMS, context: {} })
206+
const response = await loader({ request, params: PARAMS, context: new RouterContextProvider() as any })
206207
const searchParams = new URLSearchParams({
207208
type: twoFAVerificationType,
208209
target: userId,

app/routes/_auth+/login.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ import { CheckboxField, ErrorList, Field } from '#app/components/forms.tsx'
1111
import { Spacer } from '#app/components/spacer.tsx'
1212
import { Icon } from '#app/components/ui/icon.tsx'
1313
import { StatusButton } from '#app/components/ui/status-button.tsx'
14-
import { login, requireAnonymous } from '#app/utils/auth.server.ts'
14+
import { login } from '#app/utils/auth.server.ts'
15+
export const unstable_middleware = [
16+
(await import('#app/middleware.server.ts')).requireAnonymousMiddleware,
17+
]
1518
import {
1619
ProviderConnectionForm,
1720
providerNames,
@@ -38,12 +41,10 @@ const AuthenticationOptionsSchema = z.object({
3841
}) satisfies z.ZodType<{ options: PublicKeyCredentialRequestOptionsJSON }>
3942

4043
export async function loader({ request }: Route.LoaderArgs) {
41-
await requireAnonymous(request)
4244
return {}
4345
}
4446

4547
export async function action({ request }: Route.ActionArgs) {
46-
await requireAnonymous(request)
4748
const formData = await request.formData()
4849
await checkHoneypot(formData)
4950
const submission = await parseWithZod(formData, {

app/routes/_auth+/onboarding.tsx

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,10 @@ import { z } from 'zod'
77
import { CheckboxField, ErrorList, Field } from '#app/components/forms.tsx'
88
import { Spacer } from '#app/components/spacer.tsx'
99
import { StatusButton } from '#app/components/ui/status-button.tsx'
10-
import {
11-
checkIsCommonPassword,
12-
requireAnonymous,
13-
sessionKey,
14-
signup,
15-
} from '#app/utils/auth.server.ts'
10+
import { checkIsCommonPassword, sessionKey, signup } from '#app/utils/auth.server.ts'
11+
export const unstable_middleware = [
12+
(await import('#app/middleware.server.ts')).requireAnonymousMiddleware,
13+
]
1614
import { prisma } from '#app/utils/db.server.ts'
1715
import { checkHoneypot } from '#app/utils/honeypot.server.ts'
1816
import { useIsPending } from '#app/utils/misc.tsx'
@@ -42,7 +40,6 @@ const SignupFormSchema = z
4240
.and(PasswordAndConfirmPasswordSchema)
4341

4442
async function requireOnboardingEmail(request: Request) {
45-
await requireAnonymous(request)
4643
const verifySession = await verifySessionStorage.getSession(
4744
request.headers.get('cookie'),
4845
)

app/routes/_auth+/onboarding_.$provider.tsx

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,10 @@ import { z } from 'zod'
1717
import { CheckboxField, ErrorList, Field } from '#app/components/forms.tsx'
1818
import { Spacer } from '#app/components/spacer.tsx'
1919
import { StatusButton } from '#app/components/ui/status-button.tsx'
20-
import {
21-
sessionKey,
22-
signupWithConnection,
23-
requireAnonymous,
24-
} from '#app/utils/auth.server.ts'
20+
import { sessionKey, signupWithConnection } from '#app/utils/auth.server.ts'
21+
export const unstable_middleware = [
22+
(await import('#app/middleware.server.ts')).requireAnonymousMiddleware,
23+
]
2524
import { ProviderNameSchema } from '#app/utils/connections.tsx'
2625
import { prisma } from '#app/utils/db.server.ts'
2726
import { useIsPending } from '#app/utils/misc.tsx'
@@ -53,7 +52,6 @@ async function requireData({
5352
request: Request
5453
params: Params
5554
}) {
56-
await requireAnonymous(request)
5755
const verifySession = await verifySessionStorage.getSession(
5856
request.headers.get('cookie'),
5957
)

app/routes/_auth+/reset-password.tsx

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,10 @@ import { data, redirect, Form } from 'react-router'
55
import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx'
66
import { ErrorList, Field } from '#app/components/forms.tsx'
77
import { StatusButton } from '#app/components/ui/status-button.tsx'
8-
import {
9-
checkIsCommonPassword,
10-
requireAnonymous,
11-
resetUserPassword,
12-
} from '#app/utils/auth.server.ts'
8+
import { checkIsCommonPassword, resetUserPassword } from '#app/utils/auth.server.ts'
9+
export const unstable_middleware = [
10+
(await import('#app/middleware.server.ts')).requireAnonymousMiddleware,
11+
]
1312
import { useIsPending } from '#app/utils/misc.tsx'
1413
import { PasswordAndConfirmPasswordSchema } from '#app/utils/user-validation.ts'
1514
import { verifySessionStorage } from '#app/utils/verification.server.ts'
@@ -24,7 +23,6 @@ export const resetPasswordUsernameSessionKey = 'resetPasswordUsername'
2423
const ResetPasswordSchema = PasswordAndConfirmPasswordSchema
2524

2625
async function requireResetPasswordUsername(request: Request) {
27-
await requireAnonymous(request)
2826
const verifySession = await verifySessionStorage.getSession(
2927
request.headers.get('cookie'),
3028
)

app/routes/_auth+/signup.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import { z } from 'zod'
88
import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx'
99
import { ErrorList, Field } from '#app/components/forms.tsx'
1010
import { StatusButton } from '#app/components/ui/status-button.tsx'
11-
import { requireAnonymous } from '#app/utils/auth.server.ts'
11+
export const unstable_middleware = [
12+
(await import('#app/middleware.server.ts')).requireAnonymousMiddleware,
13+
]
1214
import {
1315
ProviderConnectionForm,
1416
providerNames,
@@ -30,7 +32,6 @@ const SignupSchema = z.object({
3032
})
3133

3234
export async function loader({ request }: Route.LoaderArgs) {
33-
await requireAnonymous(request)
3435
return null
3536
}
3637

app/routes/_seo+/sitemap[.]xml.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { generateSitemap } from '@nasa-gcn/remix-seo'
2-
import { type ServerBuild } from 'react-router'
2+
import { type ServerBuild, RouterContextProvider, createContext } from 'react-router'
33
import { getDomainUrl } from '#app/utils/misc.tsx'
44
import { type Route } from './+types/sitemap[.]xml.ts'
5+
// recreate context key to match the one set in server getLoadContext
6+
export const serverBuildContext = createContext<Promise<{ error: unknown; build: ServerBuild }> | null>(null)
57

68
export async function loader({ request, context }: Route.LoaderArgs) {
7-
const serverBuild = (await context.serverBuild) as { build: ServerBuild }
9+
const serverBuild = (await (context as Readonly<RouterContextProvider>).get(serverBuildContext)) as { build: ServerBuild }
810

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

app/routes/settings+/profile.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,16 @@ import { Link, Outlet, useMatches } from 'react-router'
44
import { z } from 'zod'
55
import { Spacer } from '#app/components/spacer.tsx'
66
import { Icon } from '#app/components/ui/icon.tsx'
7-
import { requireUserId } from '#app/utils/auth.server.ts'
7+
import { userIdContext } from '#app/context.ts'
88
import { prisma } from '#app/utils/db.server.ts'
99
import { cn } from '#app/utils/misc.tsx'
1010
import { useUser } from '#app/utils/user.ts'
1111
import { type Route } from './+types/profile.ts'
1212

13+
export const unstable_middleware = [
14+
(await import('#app/middleware.server.ts')).requireUserMiddleware,
15+
]
16+
1317
export const BreadcrumbHandle = z.object({ breadcrumb: z.any() })
1418
export type BreadcrumbHandle = z.infer<typeof BreadcrumbHandle>
1519

@@ -18,10 +22,11 @@ export const handle: BreadcrumbHandle & SEOHandle = {
1822
getSitemapEntries: () => null,
1923
}
2024

21-
export async function loader({ request }: Route.LoaderArgs) {
22-
const userId = await requireUserId(request)
25+
export async function loader({ context }: Route.LoaderArgs) {
26+
const userId = context.get(userIdContext) as string | null
27+
invariantResponse(Boolean(userId), 'Unauthorized', { status: 401 })
2328
const user = await prisma.user.findUnique({
24-
where: { id: userId },
29+
where: { id: userId as string },
2530
select: { username: true },
2631
})
2732
invariantResponse(user, 'User not found', { status: 404 })

0 commit comments

Comments
 (0)