Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Passkeys #1

Merged
merged 6 commits into from
Oct 13, 2023
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
112 changes: 73 additions & 39 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,54 +1,88 @@
<div align="center">
<h1 align="center"><a href="https://www.epicweb.dev/epic-stack">The Epic Stack 🚀</a></h1>
<strong align="center">
Ditch analysis paralysis and start shipping Epic Web apps.
</strong>
<p>
This is an opinionated project starter and reference that allows teams to
ship their ideas to production faster and on a more stable foundation based
on the experience of <a href="https://kentcdodds.com">Kent C. Dodds</a> and
<a href="https://github.com/epicweb-dev/epic-stack/graphs/contributors">contributors</a>.
</p>
</div>
# [Epic Stack](https://github.com/epicweb-dev/epic-stack) with [remix-auth-webauthn](https://github.com/alexanderson1993/remix-auth-webauthn) and [remix-auth](https://github.com/sergiodxa/remix-auth)

```sh
npx create-remix@latest --install --init-script --git-init --template epicweb-dev/epic-stack
```
<img width="1286" alt="Screenshot epic stack with passkey" src="passkey.png">

[![The Epic Stack](https://github-production-user-asset-6210df.s3.amazonaws.com/1500684/246885449-1b00286c-aa3d-44b2-9ef2-04f694eb3592.png)](https://www.epicweb.dev/epic-stack)
This demonstrates how to use
[remix-auth-webauthn](https://github.com/alexanderson1993/remix-auth-webauthn)
and [remix-auth](https://github.com/sergiodxa/remix-auth) with the
[Epic Stack](https://github.com/epicweb-dev/epic-stack).

[The Epic Stack](https://www.epicweb.dev/epic-stack)
This example show you how you can create a new account with a passkey and login
to your account with a passkey.

<hr />
To check out the changes, check [the git commit history](). The important parts
are:

## Watch Kent's Introduction to The Epic Stack
1. Creation of a new model called Authenticator. It will contain the required
information to retrieve the passkey. `prisma/scheam.prisma`

[![screenshot of a YouTube video](https://github-production-user-asset-6210df.s3.amazonaws.com/1500684/242088051-6beafa78-41c6-47e1-b999-08d3d3e5cb57.png)](https://www.youtube.com/watch?v=yMK5SVRASxM)
2. Creation of a new provider called `webauthn.server.ts`. This provider
implements the WebAuthnStrategy from `remix-auth-webauthn`. The most
important part is in the `verify` function. This function is used to register
a new passkey and to authenticate an existing passkey. Upon registration, we
create a new `Authenticator` entry in the database and link it to the user.

["The Epic Stack" by Kent C. Dodds at #RemixConf 2023 💿](https://www.youtube.com/watch?v=yMK5SVRASxM)
3. The onboarding process is a little bit different with `passkey` and has to be
modify. The start of the onboarding is the same as before, you verify your
email and then you access the onboarding routes where you can create your
account.

## Docs
To register your passkey, i added a link to a new page called
`routes/_auth+/onboarding_.passkey.tsx`, the user can switch from using a
password or a passkey.

[Read the docs](https://github.com/epicweb-dev/epic-stack/blob/main/docs)
(please 🙏).
The loader for this file is a little bit complicated, we first have to call

## Support
```ts
await authenticator.authenticate(WEBAUTHN_PROVIDER_NAME, request)
```

- 🆘 Join the
[discussion on GitHub](https://github.com/epicweb-dev/epic-stack/discussions)
and the [KCD Community on Discord](https://kcd.im/discord).
- 💡 Create an
[idea discussion](https://github.com/epicweb-dev/epic-stack/discussions/new?category=ideas)
for suggestions.
- 🐛 Open a [GitHub issue](https://github.com/epicweb-dev/epic-stack/issues) to
report a bug.
But this call will throw and we need to catch it. It will contain a Response
object of the following type `WebAuthnOptionsResponse`. Those information are
required to start the passkey process, so we return them from our loader.
(line 71 to 85)

## Branding
In the user interface, we are going to use that data when the user submit the
form with its username and name. If the formData is valid (for example the
username is available), then on the onSubmit event we will call a function
named `handleFormSubmit` from `remix-auth-webauthn` like this :

Want to talk about the Epic Stack in a blog post or talk? Great! Here are some
assets you can use in your material:
[EpicWeb.dev/brand](https://epicweb.dev/brand)
```ts
const [form, fields] = useForm({
...
onValidate({ formData }) {
const result = parse(formData, { schema: SignupPasskeyFormSchema })
return result
},
onSubmit(event) {
handleFormSubmit(data, 'registration')(event)
},
shouldRevalidate: 'onBlur',
})
```

## Thanks
The `handleFormSubmit` will trigger the passkey process and upon success
register your user.

You rock 🪨
To finish the process, we need to save all the important part in the
database.

In the `action` function if the form is valid we create a new session with
the function `signupWithWebauthn` (see `app/utils/auth.server.ts`) and then
call `authenticator.authenticate()`.

4. The login process is very similar to the previous one. The `loader` is very
similar with the one for registration.

To start the authentication process, we are re-using the
`ProviderConnectionForm` which create a login button for each connection.
Like for the registration, we need to use `handleFormSubmit` from
`remix-auth-webauthn` when we submit the form. The `action` for this form is
located in the route `auth.passkey.ts` where we will call
`authenticator.authenticate()`.

This example is following the readme of
[remix-auth-webauthn](https://github.com/alexanderson1993/remix-auth-webauthn).

Thanks to [alexanderson1993](https://github.com/alexanderson1993) and
[kentcdodds](https://github.com/kentcdodds) for the help.
70 changes: 70 additions & 0 deletions app/routes/_auth+/auth.passkey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { redirect, type DataFunctionArgs } from '@remix-run/node'
import {
authenticator,
getSessionExpirationDate,
} from '#app/utils/auth.server.ts'
import {
WEBAUTHN_PROVIDER_NAME,
providerLabels,
} from '#app/utils/connections.tsx'
import { prisma } from '#app/utils/db.server.ts'
import { combineHeaders } from '#app/utils/misc.tsx'
import {
destroyRedirectToHeader,
getRedirectCookieValue,
} from '#app/utils/redirect-cookie.server.ts'
import { redirectWithToast } from '#app/utils/toast.server.ts'
import { handleNewSession } from './login.tsx'

const destroyRedirectTo = { 'set-cookie': destroyRedirectToHeader }

export async function loader() {
return redirect('/login')
}

export async function action({ request }: DataFunctionArgs) {
const label = providerLabels[WEBAUTHN_PROVIDER_NAME]
const redirectTo = getRedirectCookieValue(request)
const authResult = await authenticator
.authenticate(WEBAUTHN_PROVIDER_NAME, request, { throwOnError: true })
.then(
data => ({ success: true, data }) as const,
error => ({ success: false, error }) as const,
)
if (!authResult.success) {
console.error(authResult.error)
throw await redirectWithToast(
'/login',
{
title: 'Auth Failed',
description: `There was an error authenticating with ${label}.`,
type: 'error',
},
{ headers: destroyRedirectTo },
)
}

return makeSession({ request, userId: authResult.data.id, redirectTo })
}

async function makeSession(
{
request,
userId,
redirectTo,
}: { request: Request; userId: string; redirectTo?: string | null },
responseInit?: ResponseInit,
) {
redirectTo ??= '/'
const session = await prisma.session.create({
select: { id: true, expirationDate: true, userId: true },
data: {
expirationDate: getSessionExpirationDate(),
userId,
},
})
return handleNewSession(
{ request, session, redirectTo, remember: true },
{ headers: combineHeaders(responseInit?.headers, destroyRedirectTo) },
)
}
32 changes: 30 additions & 2 deletions app/routes/_auth+/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,17 @@ import {
type DataFunctionArgs,
type MetaFunction,
} from '@remix-run/node'
import { Form, Link, useActionData, useSearchParams } from '@remix-run/react'
import {
Form,
Link,
useActionData,
useLoaderData,
useSearchParams,
} from '@remix-run/react'
import {
handleFormSubmit,
type WebAuthnOptionsResponse,
} from 'remix-auth-webauthn'
import { safeRedirect } from 'remix-utils'
import { z } from 'zod'
import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx'
Expand All @@ -15,13 +25,15 @@ import { Spacer } from '#app/components/spacer.tsx'
import { StatusButton } from '#app/components/ui/status-button.tsx'
import { twoFAVerificationType } from '#app/routes/settings+/profile.two-factor.tsx'
import {
authenticator,
getUserId,
login,
requireAnonymous,
sessionKey,
} from '#app/utils/auth.server.ts'
import {
ProviderConnectionForm,
WEBAUTHN_PROVIDER_NAME,
providerNames,
} from '#app/utils/connections.tsx'
import { prisma } from '#app/utils/db.server.ts'
Expand Down Expand Up @@ -189,7 +201,15 @@ const LoginFormSchema = z.object({

export async function loader({ request }: DataFunctionArgs) {
await requireAnonymous(request)
return json({})

try {
await authenticator.authenticate(WEBAUTHN_PROVIDER_NAME, request)
} catch (response) {
if (response instanceof Response && response.status === 200) {
return response
}
throw response
}
}

export async function action({ request }: DataFunctionArgs) {
Expand Down Expand Up @@ -221,6 +241,7 @@ export async function action({ request }: DataFunctionArgs) {
delete submission.value?.password
return json({ status: 'idle', submission } as const)
}

if (!submission.value?.session) {
return json({ status: 'error', submission } as const, { status: 400 })
}
Expand All @@ -236,6 +257,7 @@ export async function action({ request }: DataFunctionArgs) {
}

export default function LoginPage() {
const options = useLoaderData<WebAuthnOptionsResponse>()
const actionData = useActionData<typeof action>()
const isPending = useIsPending()
const [searchParams] = useSearchParams()
Expand Down Expand Up @@ -328,6 +350,12 @@ export default function LoginPage() {
type="Login"
providerName={providerName}
redirectTo={redirectTo}
onSubmit={(e) => {
if (providerName === WEBAUTHN_PROVIDER_NAME) {
handleFormSubmit(options)(e)
}
}
}
/>
</li>
))}
Expand Down
38 changes: 36 additions & 2 deletions app/routes/_auth+/onboarding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,28 @@ import {
} from '@remix-run/node'
import {
Form,
Link,
useActionData,
useLoaderData,
useSearchParams,
} from '@remix-run/react'
import {
handleFormSubmit,
type WebAuthnOptionsResponse,
} from 'remix-auth-webauthn'
import { safeRedirect } from 'remix-utils'
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 { requireAnonymous, sessionKey, signup } from '#app/utils/auth.server.ts'
import {
authenticator,
requireAnonymous,
sessionKey,
signup,
} from '#app/utils/auth.server.ts'
import { redirectWithConfetti } from '#app/utils/confetti.server.ts'
import { WEBAUTHN_PROVIDER_NAME } from '#app/utils/connections.tsx'
import { prisma } from '#app/utils/db.server.ts'
import { invariant, useIsPending } from '#app/utils/misc.tsx'
import { authSessionStorage } from '#app/utils/session.server.ts'
Expand Down Expand Up @@ -56,8 +67,23 @@ async function requireOnboardingEmail(request: Request) {
}
return email
}

export async function loader({ request }: DataFunctionArgs) {
const email = await requireOnboardingEmail(request)
try {
await authenticator.authenticate(WEBAUTHN_PROVIDER_NAME, request)
} catch (response) {
if (response instanceof Response && response.status === 200) {
const cloneResponse = response.clone()
const data = (await cloneResponse.json()) as any

return new Response(JSON.stringify({ ...data, email }), {
status: cloneResponse.status,
headers: cloneResponse.headers,
})
}
throw response
}
return json({ email })
}

Expand Down Expand Up @@ -133,7 +159,7 @@ export const meta: MetaFunction = () => {
}

export default function SignupRoute() {
const data = useLoaderData<typeof loader>()
const data = useLoaderData<WebAuthnOptionsResponse & { email: string }>()
const actionData = useActionData<typeof action>()
const isPending = useIsPending()
const [searchParams] = useSearchParams()
Expand All @@ -158,12 +184,20 @@ export default function SignupRoute() {
<p className="text-body-md text-muted-foreground">
Please enter your details.
</p>
<Link to="/onboarding/passkey" className="text-sm text-blue-500">Register with a passkey</Link>
</div>
<Spacer size="xs" />
<Form
method="POST"
className="mx-auto min-w-[368px] max-w-sm"
{...form.props}
onSubmit={async event => {
const submitButton = (event as any).nativeEvent.submitter
if (submitButton.value === 'registration') {
const result = await handleFormSubmit(data, 'registration')(event)
return result
}
}}
>
<Field
labelProps={{ htmlFor: fields.username.id, children: 'Username' }}
Expand Down
Loading