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
109 changes: 106 additions & 3 deletions app/routes/_auth+/login.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
import { getFormProps, getInputProps, useForm } from '@conform-to/react'
import { getZodConstraint, parseWithZod } from '@conform-to/zod'
import { type SEOHandle } from '@nasa-gcn/remix-seo'
import { data, Form, Link, useSearchParams } from 'react-router'
import { startAuthentication } from '@simplewebauthn/browser'
import { useOptimistic, useState, useTransition } from 'react'
import { data, Form, Link, useNavigate, useSearchParams } from 'react-router'
import { HoneypotInputs } from 'remix-utils/honeypot/react'
import { z } from 'zod'
import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx'
import { CheckboxField, ErrorList, Field } from '#app/components/forms.tsx'
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 {
ProviderConnectionForm,
providerNames,
} from '#app/utils/connections.tsx'
import { checkHoneypot } from '#app/utils/honeypot.server.ts'
import { useIsPending } from '#app/utils/misc.tsx'
import { getErrorMessage, useIsPending } from '#app/utils/misc.tsx'
import { PasswordSchema, UsernameSchema } from '#app/utils/user-validation.ts'
import { type Route } from './+types/login.ts'
import { handleNewSession } from './login.server.ts'
Expand All @@ -30,6 +33,10 @@ const LoginFormSchema = z.object({
remember: z.boolean().optional(),
})

const AuthenticationOptionsSchema = z.object({
options: z.object({ challenge: z.string() }),
}) satisfies z.ZodType<{ options: PublicKeyCredentialRequestOptionsJSON }>

export async function loader({ request }: Route.LoaderArgs) {
await requireAnonymous(request)
return {}
Expand Down Expand Up @@ -165,7 +172,15 @@ export default function LoginPage({ actionData }: Route.ComponentProps) {
</StatusButton>
</div>
</Form>
<ul className="mt-5 flex flex-col gap-5 border-b-2 border-t-2 border-border py-3">
<hr className="my-4" />
<div className="flex flex-col gap-5">
<PasskeyLogin
redirectTo={redirectTo}
remember={fields.remember.value === 'on'}
/>
</div>
<hr className="my-4" />
<ul className="flex flex-col gap-5">
{providerNames.map((providerName) => (
<li key={providerName}>
<ProviderConnectionForm
Expand Down Expand Up @@ -195,6 +210,94 @@ export default function LoginPage({ actionData }: Route.ComponentProps) {
)
}

const VerificationResponseSchema = z.discriminatedUnion('status', [
z.object({
status: z.literal('success'),
location: z.string(),
}),
z.object({
status: z.literal('error'),
error: z.string(),
}),
])

function PasskeyLogin({
redirectTo,
remember,
}: {
redirectTo: string | null
remember: boolean
}) {
const [isPending] = useTransition()
const [error, setError] = useState<string | null>(null)
const [passkeyMessage, setPasskeyMessage] = useOptimistic<string | null>(
'Login with a passkey',
)
const navigate = useNavigate()

async function handlePasskeyLogin() {
try {
setPasskeyMessage('Generating Authentication Options')
// Get authentication options from the server
const optionsResponse = await fetch('/webauthn/authentication')
const json = await optionsResponse.json()
const { options } = AuthenticationOptionsSchema.parse(json)

setPasskeyMessage('Requesting your authorization')
const authResponse = await startAuthentication({ optionsJSON: options })
setPasskeyMessage('Verifying your passkey')

// Verify the authentication with the server
const verificationResponse = await fetch('/webauthn/authentication', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ authResponse, remember, redirectTo }),
})

const verificationJson = await verificationResponse.json().catch(() => ({
status: 'error',
error: 'Unknown error',
}))

const parsedResult =
VerificationResponseSchema.safeParse(verificationJson)
if (!parsedResult.success) {
throw new Error(parsedResult.error.message)
} else if (parsedResult.data.status === 'error') {
throw new Error(parsedResult.data.error)
}
const { location } = parsedResult.data

setPasskeyMessage("You're logged in! Navigating...")
await navigate(location ?? '/')
} catch (e) {
const errorMessage = getErrorMessage(e)
setError(`Failed to authenticate with passkey: ${errorMessage}`)
}
}

return (
<form action={handlePasskeyLogin}>
<StatusButton
id="passkey-login-button"
aria-describedby="passkey-login-button-error"
className="w-full"
status={isPending ? 'pending' : error ? 'error' : 'idle'}
type="submit"
disabled={isPending}
>
<span className="inline-flex items-center gap-1.5">
<Icon name="passkey" />
<span>{passkeyMessage}</span>
</span>
</StatusButton>
<div className="mt-2">
<ErrorList errors={[error]} id="passkey-login-button-error" />
</div>
</form>
)
}

export const meta: Route.MetaFunction = () => {
return [{ title: 'Login to Epic Notes' }]
}
Expand Down
113 changes: 113 additions & 0 deletions app/routes/_auth+/webauthn+/authentication.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import {
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from '@simplewebauthn/server'
import { getSessionExpirationDate } from '#app/utils/auth.server.ts'
import { prisma } from '#app/utils/db.server.ts'
import { handleNewSession } from '../login.server.ts'
import { type Route } from './+types/authentication.ts'
import {
PasskeyLoginBodySchema,
getWebAuthnConfig,
passkeyCookie,
} from './utils.server.ts'

export async function loader({ request }: Route.LoaderArgs) {
const config = getWebAuthnConfig(request)
const options = await generateAuthenticationOptions({
rpID: config.rpID,
userVerification: 'preferred',
})

const cookieHeader = await passkeyCookie.serialize({
challenge: options.challenge,
})

return Response.json({ options }, { headers: { 'Set-Cookie': cookieHeader } })
}

export async function action({ request }: Route.ActionArgs) {
const cookieHeader = request.headers.get('Cookie')
const cookie = await passkeyCookie.parse(cookieHeader)
const deletePasskeyCookie = await passkeyCookie.serialize('', { maxAge: 0 })
try {
if (!cookie?.challenge) {
throw new Error('Authentication challenge not found')
}

const body = await request.json()
const result = PasskeyLoginBodySchema.safeParse(body)
if (!result.success) {
throw new Error('Invalid authentication response')
}
const { authResponse, remember, redirectTo } = result.data

const passkey = await prisma.passkey.findUnique({
where: { id: authResponse.id },
include: { user: true },
})
if (!passkey) {
throw new Error('Passkey not found')
}

const config = getWebAuthnConfig(request)

const verification = await verifyAuthenticationResponse({
response: authResponse,
expectedChallenge: cookie.challenge,
expectedOrigin: config.origin,
expectedRPID: config.rpID,
credential: {
id: authResponse.id,
publicKey: passkey.publicKey,
counter: Number(passkey.counter),
},
})

if (!verification.verified) {
throw new Error('Authentication verification failed')
}

// Update the authenticator's counter in the DB to the newest count
await prisma.passkey.update({
where: { id: passkey.id },
data: { counter: BigInt(verification.authenticationInfo.newCounter) },
})

const session = await prisma.session.create({
select: { id: true, expirationDate: true, userId: true },
data: {
expirationDate: getSessionExpirationDate(),
userId: passkey.userId,
},
})

const response = await handleNewSession(
{
request,
session,
remember,
redirectTo: redirectTo ?? undefined,
},
{ headers: { 'Set-Cookie': deletePasskeyCookie } },
)

return Response.json(
{
status: 'success',
location: response.headers.get('Location'),
},
{ headers: response.headers },
)
} catch (error) {
if (error instanceof Response) throw error

return Response.json(
{
status: 'error',
error: error instanceof Error ? error.message : 'Verification failed',
} as const,
{ status: 400, headers: { 'Set-Cookie': deletePasskeyCookie } },
)
}
}
136 changes: 136 additions & 0 deletions app/routes/_auth+/webauthn+/registration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import {
generateRegistrationOptions,
verifyRegistrationResponse,
} from '@simplewebauthn/server'
import { requireUserId } from '#app/utils/auth.server.ts'
import { prisma } from '#app/utils/db.server.ts'
import { getDomainUrl, getErrorMessage } from '#app/utils/misc.tsx'
import { type Route } from './+types/registration.ts'
import {
PasskeyCookieSchema,
RegistrationResponseSchema,
passkeyCookie,
getWebAuthnConfig,
} from './utils.server.ts'

export async function loader({ request }: Route.LoaderArgs) {
const userId = await requireUserId(request)
const passkeys = await prisma.passkey.findMany({
where: { userId },
select: { id: true },
})
const user = await prisma.user.findUniqueOrThrow({
where: { id: userId },
select: { email: true, name: true, username: true },
})

const config = getWebAuthnConfig(request)
const options = await generateRegistrationOptions({
rpName: config.rpName,
rpID: config.rpID,
userName: user.username,
userID: new TextEncoder().encode(userId),
userDisplayName: user.name ?? user.email,
attestationType: 'none',
excludeCredentials: passkeys,
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred',
},
})

return Response.json(
{ options },
{
headers: {
'Set-Cookie': await passkeyCookie.serialize(
PasskeyCookieSchema.parse({
challenge: options.challenge,
userId: options.user.id,
}),
),
},
},
)
}

export async function action({ request }: Route.ActionArgs) {
try {
const userId = await requireUserId(request)

const body = await request.json()
const result = RegistrationResponseSchema.safeParse(body)
if (!result.success) {
throw new Error('Invalid registration response')
}

const data = result.data

// Get challenge from cookie
const passkeyCookieData = await passkeyCookie.parse(
request.headers.get('Cookie'),
)
const parsedPasskeyCookieData =
PasskeyCookieSchema.safeParse(passkeyCookieData)
if (!parsedPasskeyCookieData.success) {
throw new Error('No challenge found')
}
const { challenge, userId: webauthnUserId } = parsedPasskeyCookieData.data

const domain = new URL(getDomainUrl(request)).hostname
const rpID = domain
const origin = getDomainUrl(request)

const verification = await verifyRegistrationResponse({
response: data,
expectedChallenge: challenge,
expectedOrigin: origin,
expectedRPID: rpID,
requireUserVerification: true,
})

const { verified, registrationInfo } = verification
if (!verified || !registrationInfo) {
throw new Error('Registration verification failed')
}
const { credential, credentialDeviceType, credentialBackedUp, aaguid } =
registrationInfo

const existingPasskey = await prisma.passkey.findUnique({
where: { id: credential.id },
select: { id: true },
})

if (existingPasskey) {
throw new Error('This passkey has already been registered')
}

// Create new passkey in database
await prisma.passkey.create({
data: {
id: credential.id,
aaguid,
publicKey: Buffer.from(credential.publicKey),
userId,
webauthnUserId,
counter: credential.counter,
deviceType: credentialDeviceType,
backedUp: credentialBackedUp,
transports: credential.transports?.join(','),
},
})

return Response.json({ status: 'success' } as const, {
headers: {
'Set-Cookie': await passkeyCookie.serialize('', { maxAge: 0 }),
},
})
} catch (error) {
if (error instanceof Response) throw error

return Response.json(
{ status: 'error', error: getErrorMessage(error) } as const,
{ status: 400 },
)
}
}
Loading