Skip to content

Commit ba11040

Browse files
authored
Merge branch 'epicweb-dev:main' into main
2 parents 20bf7c7 + e1ffe59 commit ba11040

File tree

26 files changed

+1296
-115
lines changed

26 files changed

+1296
-115
lines changed

.vscode/remix.code-snippets

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,4 +77,4 @@
7777
"}",
7878
],
7979
},
80-
}
80+
}

app/components/ui/button.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import * as React from 'react'
55
import { cn } from '#app/utils/misc.tsx'
66

77
const buttonVariants = cva(
8-
'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors outline-none focus-visible:ring-2 focus-within:ring-2 ring-ring ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
8+
'inline-flex items-center justify-center rounded-md text-sm font-medium outline-none ring-ring ring-offset-2 ring-offset-background transition-colors focus-within:ring-2 focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50',
99
{
1010
variants: {
1111
variant: {

app/entry.server.tsx

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { PassThrough } from 'node:stream'
2+
import { styleText } from 'node:util'
3+
import { contentSecurity } from '@nichtsam/helmet/content'
24
import { createReadableStreamFromReadable } from '@react-router/node'
3-
45
import * as Sentry from '@sentry/node'
5-
import chalk from 'chalk'
66
import { isbot } from 'isbot'
77
import { renderToPipeableStream } from 'react-dom/server'
88
import {
@@ -21,6 +21,8 @@ export const streamTimeout = 5000
2121
init()
2222
global.ENV = getEnv()
2323

24+
const MODE = process.env.NODE_ENV ?? 'development'
25+
2426
type DocRequestArgs = Parameters<HandleDocumentRequestFunction>
2527

2628
export default async function handleRequest(...args: DocRequestArgs) {
@@ -65,6 +67,33 @@ export default async function handleRequest(...args: DocRequestArgs) {
6567
const body = new PassThrough()
6668
responseHeaders.set('Content-Type', 'text/html')
6769
responseHeaders.append('Server-Timing', timings.toString())
70+
71+
contentSecurity(responseHeaders, {
72+
crossOriginEmbedderPolicy: false,
73+
contentSecurityPolicy: {
74+
// NOTE: Remove reportOnly when you're ready to enforce this CSP
75+
reportOnly: true,
76+
directives: {
77+
fetch: {
78+
'connect-src': [
79+
MODE === 'development' ? 'ws:' : undefined,
80+
process.env.SENTRY_DSN ? '*.sentry.io' : undefined,
81+
"'self'",
82+
],
83+
'font-src': ["'self'"],
84+
'frame-src': ["'self'"],
85+
'img-src': ["'self'", 'data:'],
86+
'script-src': [
87+
"'strict-dynamic'",
88+
"'self'",
89+
`'nonce-${nonce}'`,
90+
],
91+
'script-src-attr': [`'nonce-${nonce}'`],
92+
},
93+
},
94+
},
95+
})
96+
6897
resolve(
6998
new Response(createReadableStreamFromReadable(body), {
7099
headers: responseHeaders,
@@ -107,7 +136,7 @@ export function handleError(
107136
return
108137
}
109138
if (error instanceof Error) {
110-
console.error(chalk.red(error.stack))
139+
console.error(styleText('red', String(error.stack)))
111140
void Sentry.captureException(error)
112141
} else {
113142
console.error(error)

app/routes/_auth+/login.tsx

Lines changed: 106 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,23 @@
11
import { getFormProps, getInputProps, useForm } from '@conform-to/react'
22
import { getZodConstraint, parseWithZod } from '@conform-to/zod'
33
import { type SEOHandle } from '@nasa-gcn/remix-seo'
4-
import { data, Form, Link, useSearchParams } from 'react-router'
4+
import { startAuthentication } from '@simplewebauthn/browser'
5+
import { useOptimistic, useState, useTransition } from 'react'
6+
import { data, Form, Link, useNavigate, useSearchParams } from 'react-router'
57
import { HoneypotInputs } from 'remix-utils/honeypot/react'
68
import { z } from 'zod'
79
import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx'
810
import { CheckboxField, ErrorList, Field } from '#app/components/forms.tsx'
911
import { Spacer } from '#app/components/spacer.tsx'
12+
import { Icon } from '#app/components/ui/icon.tsx'
1013
import { StatusButton } from '#app/components/ui/status-button.tsx'
1114
import { login, requireAnonymous } from '#app/utils/auth.server.ts'
1215
import {
1316
ProviderConnectionForm,
1417
providerNames,
1518
} from '#app/utils/connections.tsx'
1619
import { checkHoneypot } from '#app/utils/honeypot.server.ts'
17-
import { useIsPending } from '#app/utils/misc.tsx'
20+
import { getErrorMessage, useIsPending } from '#app/utils/misc.tsx'
1821
import { PasswordSchema, UsernameSchema } from '#app/utils/user-validation.ts'
1922
import { type Route } from './+types/login.ts'
2023
import { handleNewSession } from './login.server.ts'
@@ -30,6 +33,10 @@ const LoginFormSchema = z.object({
3033
remember: z.boolean().optional(),
3134
})
3235

36+
const AuthenticationOptionsSchema = z.object({
37+
options: z.object({ challenge: z.string() }),
38+
}) satisfies z.ZodType<{ options: PublicKeyCredentialRequestOptionsJSON }>
39+
3340
export async function loader({ request }: Route.LoaderArgs) {
3441
await requireAnonymous(request)
3542
return {}
@@ -165,7 +172,15 @@ export default function LoginPage({ actionData }: Route.ComponentProps) {
165172
</StatusButton>
166173
</div>
167174
</Form>
168-
<ul className="mt-5 flex flex-col gap-5 border-b-2 border-t-2 border-border py-3">
175+
<hr className="my-4" />
176+
<div className="flex flex-col gap-5">
177+
<PasskeyLogin
178+
redirectTo={redirectTo}
179+
remember={fields.remember.value === 'on'}
180+
/>
181+
</div>
182+
<hr className="my-4" />
183+
<ul className="flex flex-col gap-5">
169184
{providerNames.map((providerName) => (
170185
<li key={providerName}>
171186
<ProviderConnectionForm
@@ -195,6 +210,94 @@ export default function LoginPage({ actionData }: Route.ComponentProps) {
195210
)
196211
}
197212

213+
const VerificationResponseSchema = z.discriminatedUnion('status', [
214+
z.object({
215+
status: z.literal('success'),
216+
location: z.string(),
217+
}),
218+
z.object({
219+
status: z.literal('error'),
220+
error: z.string(),
221+
}),
222+
])
223+
224+
function PasskeyLogin({
225+
redirectTo,
226+
remember,
227+
}: {
228+
redirectTo: string | null
229+
remember: boolean
230+
}) {
231+
const [isPending] = useTransition()
232+
const [error, setError] = useState<string | null>(null)
233+
const [passkeyMessage, setPasskeyMessage] = useOptimistic<string | null>(
234+
'Login with a passkey',
235+
)
236+
const navigate = useNavigate()
237+
238+
async function handlePasskeyLogin() {
239+
try {
240+
setPasskeyMessage('Generating Authentication Options')
241+
// Get authentication options from the server
242+
const optionsResponse = await fetch('/webauthn/authentication')
243+
const json = await optionsResponse.json()
244+
const { options } = AuthenticationOptionsSchema.parse(json)
245+
246+
setPasskeyMessage('Requesting your authorization')
247+
const authResponse = await startAuthentication({ optionsJSON: options })
248+
setPasskeyMessage('Verifying your passkey')
249+
250+
// Verify the authentication with the server
251+
const verificationResponse = await fetch('/webauthn/authentication', {
252+
method: 'POST',
253+
headers: { 'Content-Type': 'application/json' },
254+
body: JSON.stringify({ authResponse, remember, redirectTo }),
255+
})
256+
257+
const verificationJson = await verificationResponse.json().catch(() => ({
258+
status: 'error',
259+
error: 'Unknown error',
260+
}))
261+
262+
const parsedResult =
263+
VerificationResponseSchema.safeParse(verificationJson)
264+
if (!parsedResult.success) {
265+
throw new Error(parsedResult.error.message)
266+
} else if (parsedResult.data.status === 'error') {
267+
throw new Error(parsedResult.data.error)
268+
}
269+
const { location } = parsedResult.data
270+
271+
setPasskeyMessage("You're logged in! Navigating...")
272+
await navigate(location ?? '/')
273+
} catch (e) {
274+
const errorMessage = getErrorMessage(e)
275+
setError(`Failed to authenticate with passkey: ${errorMessage}`)
276+
}
277+
}
278+
279+
return (
280+
<form action={handlePasskeyLogin}>
281+
<StatusButton
282+
id="passkey-login-button"
283+
aria-describedby="passkey-login-button-error"
284+
className="w-full"
285+
status={isPending ? 'pending' : error ? 'error' : 'idle'}
286+
type="submit"
287+
disabled={isPending}
288+
>
289+
<span className="inline-flex items-center gap-1.5">
290+
<Icon name="passkey" />
291+
<span>{passkeyMessage}</span>
292+
</span>
293+
</StatusButton>
294+
<div className="mt-2">
295+
<ErrorList errors={[error]} id="passkey-login-button-error" />
296+
</div>
297+
</form>
298+
)
299+
}
300+
198301
export const meta: Route.MetaFunction = () => {
199302
return [{ title: 'Login to Epic Notes' }]
200303
}

app/routes/_auth+/signup.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ 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'
1112
import {
1213
ProviderConnectionForm,
1314
providerNames,
@@ -28,6 +29,11 @@ const SignupSchema = z.object({
2829
email: EmailSchema,
2930
})
3031

32+
export async function loader({ request }: Route.LoaderArgs) {
33+
await requireAnonymous(request)
34+
return null
35+
}
36+
3137
export async function action({ request }: Route.ActionArgs) {
3238
const formData = await request.formData()
3339

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import {
2+
generateAuthenticationOptions,
3+
verifyAuthenticationResponse,
4+
} from '@simplewebauthn/server'
5+
import { getSessionExpirationDate } from '#app/utils/auth.server.ts'
6+
import { prisma } from '#app/utils/db.server.ts'
7+
import { handleNewSession } from '../login.server.ts'
8+
import { type Route } from './+types/authentication.ts'
9+
import {
10+
PasskeyLoginBodySchema,
11+
getWebAuthnConfig,
12+
passkeyCookie,
13+
} from './utils.server.ts'
14+
15+
export async function loader({ request }: Route.LoaderArgs) {
16+
const config = getWebAuthnConfig(request)
17+
const options = await generateAuthenticationOptions({
18+
rpID: config.rpID,
19+
userVerification: 'preferred',
20+
})
21+
22+
const cookieHeader = await passkeyCookie.serialize({
23+
challenge: options.challenge,
24+
})
25+
26+
return Response.json({ options }, { headers: { 'Set-Cookie': cookieHeader } })
27+
}
28+
29+
export async function action({ request }: Route.ActionArgs) {
30+
const cookieHeader = request.headers.get('Cookie')
31+
const cookie = await passkeyCookie.parse(cookieHeader)
32+
const deletePasskeyCookie = await passkeyCookie.serialize('', { maxAge: 0 })
33+
try {
34+
if (!cookie?.challenge) {
35+
throw new Error('Authentication challenge not found')
36+
}
37+
38+
const body = await request.json()
39+
const result = PasskeyLoginBodySchema.safeParse(body)
40+
if (!result.success) {
41+
throw new Error('Invalid authentication response')
42+
}
43+
const { authResponse, remember, redirectTo } = result.data
44+
45+
const passkey = await prisma.passkey.findUnique({
46+
where: { id: authResponse.id },
47+
include: { user: true },
48+
})
49+
if (!passkey) {
50+
throw new Error('Passkey not found')
51+
}
52+
53+
const config = getWebAuthnConfig(request)
54+
55+
const verification = await verifyAuthenticationResponse({
56+
response: authResponse,
57+
expectedChallenge: cookie.challenge,
58+
expectedOrigin: config.origin,
59+
expectedRPID: config.rpID,
60+
credential: {
61+
id: authResponse.id,
62+
publicKey: passkey.publicKey,
63+
counter: Number(passkey.counter),
64+
},
65+
})
66+
67+
if (!verification.verified) {
68+
throw new Error('Authentication verification failed')
69+
}
70+
71+
// Update the authenticator's counter in the DB to the newest count
72+
await prisma.passkey.update({
73+
where: { id: passkey.id },
74+
data: { counter: BigInt(verification.authenticationInfo.newCounter) },
75+
})
76+
77+
const session = await prisma.session.create({
78+
select: { id: true, expirationDate: true, userId: true },
79+
data: {
80+
expirationDate: getSessionExpirationDate(),
81+
userId: passkey.userId,
82+
},
83+
})
84+
85+
const response = await handleNewSession(
86+
{
87+
request,
88+
session,
89+
remember,
90+
redirectTo: redirectTo ?? undefined,
91+
},
92+
{ headers: { 'Set-Cookie': deletePasskeyCookie } },
93+
)
94+
95+
return Response.json(
96+
{
97+
status: 'success',
98+
location: response.headers.get('Location'),
99+
},
100+
{ headers: response.headers },
101+
)
102+
} catch (error) {
103+
if (error instanceof Response) throw error
104+
105+
return Response.json(
106+
{
107+
status: 'error',
108+
error: error instanceof Error ? error.message : 'Verification failed',
109+
} as const,
110+
{ status: 400, headers: { 'Set-Cookie': deletePasskeyCookie } },
111+
)
112+
}
113+
}

0 commit comments

Comments
 (0)