How to use passkeys with the Lucia authentication framework

Toby Hobson - Oct 1 - - Dev Community

In this tutorial we'll use the Lucia framework for our SvelteKit authentication. We'll extend Lucia to support passkey authentication.

Note: This is the second instalment in my SvelteKit authentication series. Please read the first instalment as this post builds on the previous work.

Check out the full SvelteKit + Passkeys tutorial on my blog.

Add the libraries

We'll use Lucia for session management, and Prisma + SQLite for persistence.

pnpm add -D lucia
pnpm add -D prisma
pnpm add -D @lucia-auth/adapter-prisma
Enter fullscreen mode Exit fullscreen mode

Setup the database

Initialise Prisma:

pnpm dlx prisma init --datasource-provider sqlite
Enter fullscreen mode Exit fullscreen mode

The prisma init script should have created a prisma directory in your application route. It should also have updated your .env file with a DATABASE_URL key:

DATABASE_URL="file:./dev.db"
PUBLIC_PASSLOCK_TENANCY_ID="..."
PUBLIC_PASSLOCK_CLIENT_ID="..."
PASSLOCK_API_KEY="..."
Enter fullscreen mode Exit fullscreen mode

Next, define the database schema by editing prisma/schema.prisma:

// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

model User {
  id         String  @id @default(uuid())
  email      String  @unique
  givenName  String?
  familyName String?
  sessions   Session[]
}

model Session {
  id        String  @id @default(uuid())
  userId    String
  expiresAt DateTime
  user      User    @relation(references: [id], fields: [userId], onDelete: Cascade)
}
Enter fullscreen mode Exit fullscreen mode

Run the initial migration:

pnpm dlx prisma migrate dev --name init
Enter fullscreen mode Exit fullscreen mode

User registration

Previously we registered a passkey on the users device. We'll now create a user in our database and link the passkey to the local user. Update the registration form action:

// src/routes/register/+page.server.ts
import type { Actions } from './$types'
import { PasslockError, TokenVerifier } from '@passlock/sveltekit'
import { PUBLIC_PASSLOCK_TENANCY_ID } from '$env/static/public'
import { PASSLOCK_API_KEY } from '$env/static/private'
import { PrismaClient } from '@prisma/client'
import { error, redirect } from '@sveltejs/kit'

const client = new PrismaClient()

const tokenVerifier = new TokenVerifier({
  tenancyId: PUBLIC_PASSLOCK_TENANCY_ID, 
  apiKey: PASSLOCK_API_KEY
})

export const actions: Actions = {
  default: async ({ request }) => {
    const formData = await request.formData()
    const token = formData.get('token') as string
    const user = await tokenVerifier.exchangeToken(token)

    if (!PasslockError.isError(user)) {
      // FIXME JUST FOR DEVELOPMENT TESTING!!!
      await client.user.deleteMany()

      await client.user.create({ 
        data: {
          id: user.sub,
          email: user.email as string,
          givenName: user.givenName,
          familyName: user.familyName
        }
      })

      redirect(302, '/login')
    } else {
      console.error(user)
      error(500, user.message)
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Try to register a user

You can now try to register a user account and associated passkey. Navigate to the /register page. The client side template and form action will together:

  1. Register a passkey on the users device
  2. Register the public key component in your Passlock vault
  3. Create a local Lucia user and link the passkey to that account

Important: Make sure you don't already have a user registered in your Passlock console. Otherwise Passlock will generate an error telling you that a passkey is already registered to that email address.

If all goes well, you should be redirected to the login page.

Authenticate a user

Previously, we prompted the user to authenticate with their passkey and verified the token is authentic in our form action. We'll build on that work to:

  1. Lookup a local user using the sub property
  2. Create a Lucia session for that user
  3. Invalidate any other user sessions

We'll update the login form action to hook into Lucia, but before we do that, let's setup Lucia itself:

// src/lib/server/auth.ts
import { Lucia } from "lucia"
import { dev } from "$app/environment"
import { PrismaAdapter } from "@lucia-auth/adapter-prisma"
import { PrismaClient } from "@prisma/client"

const client = new PrismaClient()
const adapter = new PrismaAdapter(client.session, client.user)

export const lucia = new Lucia(adapter, {
  sessionCookie: {
    attributes: {
      // set to `true` when using HTTPS
      secure: !dev
    }
  }
})

declare module "lucia" {
  interface Register {
    Lucia: typeof lucia
  }
}
Enter fullscreen mode Exit fullscreen mode

Next, update the login action:

// src/routes/login/+page.server.ts
import type { Actions } from './$types'
import { PasslockError, TokenVerifier } from '@passlock/sveltekit'
import { PUBLIC_PASSLOCK_TENANCY_ID } from '$env/static/public'
import { PASSLOCK_API_KEY } from '$env/static/private'
import { lucia } from '$lib/server/auth'
import { error, redirect } from '@sveltejs/kit'

const tokenVerifier = new TokenVerifier({
  tenancyId: PUBLIC_PASSLOCK_TENANCY_ID, 
  apiKey: PASSLOCK_API_KEY
})

export const actions: Actions = {
  default: async ({ request, cookies }) => {
    const formData = await request.formData()
    const token = formData.get('token') as string
    const user = await tokenVerifier.exchangeToken(token)

    if (!PasslockError.isError(user)) {
      await lucia.invalidateUserSessions(user.sub)
      const session = await lucia.createSession(user.sub, {})
      const sessionCookie = lucia.createSessionCookie(session.id)

      cookies.set(
        lucia.sessionCookieName, 
        sessionCookie.value, 
        { path: '/', ...sessionCookie.attributes }
      )

      redirect(302, '/')
    } else {
      console.error(user)
      error(500, user.message)
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Try logging in

Visit the /login page and try to login using the same email address you used for registration. If all goes well, you should be redirected back to the home page.

Now check your browser dev tools. You should see a new cookie named auth_session with a long, cryptographically random token.

Finally, we need to protect the routes to ensure that only authenticated users can access them...

Protect the routes

Now that we can authenticate users, we want to protect access to certain routes. We can build quite sophisticated authorization policies, but for now we'll just protect a single route, allowing access to any authenticated user. We'll do this using SvelteKit hooks. Our hook will:

  1. Check for a lucia session cookie
  2. Lookup the associated session and user
  3. Attach the session and user to the app locals
  4. Reject any unauthenticated request to a protected route

Type the locals

As we'll be using app locals with Typescript, let's type it:

// src/app.d.ts
declare global {
  namespace App {
    interface Locals {
      user: import("lucia").User | null
      session: import("lucia").Session | null
    }
  }
}

export {}
Enter fullscreen mode Exit fullscreen mode

Attach the user to the request

We'll check for the auth_session cookie, lookup the associated session and user and attach them to the request via app locals:

// src/hooks.server.ts
import { lucia } from "$lib/server/auth"
import { type Handle } from "@sveltejs/kit"

export const handle: Handle = async ({ event, resolve }) => {
  const sessionId = event.cookies.get(lucia.sessionCookieName)

  // if no sessionId we can assume there is no session or user
  const { session, user } = sessionId ? 
    await lucia.validateSession(sessionId) : 
    { session: null, user: null }

  if (session && session.fresh) {
    // update the session cookie
    const sessionCookie = lucia.createSessionCookie(session.id)
    event.cookies.set(sessionCookie.name, sessionCookie.value, {
      path: ".",
      ...sessionCookie.attributes
    })
  }

  if (!session) {
    const sessionCookie = lucia.createBlankSessionCookie()
    event.cookies.set(sessionCookie.name, sessionCookie.value, {
      path: "/",
      ...sessionCookie.attributes
    })
  }

  // attach the user and session to the request
  event.locals.user = user
  event.locals.session = session

  return resolve(event)
}
Enter fullscreen mode Exit fullscreen mode

Try the status route

At the moment we haven't protected any routes, we simply attach a user to the request. Lets test it. Create a new /status route:

<!-- src/routes/status/+page.svelte -->
<script lang="ts">
  import type { PageData } from './$types'

  export let data: PageData
</script>

userId: {data.user?.id}
Enter fullscreen mode Exit fullscreen mode
// src/routes/status/+page.server.ts
import type { PageServerLoad } from './$types'

export const load = (async ({ locals }) => {
  return { user: locals.user }
}) satisfies PageServerLoad
Enter fullscreen mode Exit fullscreen mode

Login using your passkey, then visit the /status page. If all goes well, you should see your userId displayed. Now clear your cookies and visit the status page again. userId should be undefined.

Protect the route

There are many better ways of doing this, but let's go for a simple approach. Update the hooks and check the route id. If routeId === /status and the user is not authenticated we'll redirect them to the login page:

// src/hooks.server.ts
import { lucia } from "$lib/server/auth"
import { redirect, type Handle } from "@sveltejs/kit"

export const handle: Handle = async ({ event, resolve }) => {
  const sessionId = event.cookies.get(lucia.sessionCookieName)

  const { session, user } = sessionId ? 
    await lucia.validateSession(sessionId) : 
    { session: null, user: null }

  if (session && session.fresh) {
    const sessionCookie = lucia.createSessionCookie(session.id)
    event.cookies.set(sessionCookie.name, sessionCookie.value, {
      path: ".",
      ...sessionCookie.attributes
    })
  }

  if (!session) {
    const sessionCookie = lucia.createBlankSessionCookie()
    event.cookies.set(sessionCookie.name, sessionCookie.value, {
      path: ".",
      ...sessionCookie.attributes
    })
  }

  event.locals.user = user
  event.locals.session = session

  if (event.route.id === '/status' && !event.locals.user) {
    redirect(302, '/login')
  }

  return resolve(event)
}
Enter fullscreen mode Exit fullscreen mode

Try to access the protected route

Clear your cookies and visit /status. You should be redirected to the login page.

Sign out

Clearing cookies all the time is pretty annoying, so lets add a /logout route:

<!-- src/routes/logout/+page.svelte -->
<script lang="ts">
  import type { PageData } from './$types'

  export let data: PageData
</script>

<form method="post">
  <button type="submit">Logout</button>
</form>
Enter fullscreen mode Exit fullscreen mode
// src/routes/logout/+page.server.ts
import type { Actions } from './$types'
import { lucia } from '$lib/server/auth'
import { redirect } from '@sveltejs/kit'

export const actions: Actions = {
  default: async ({ locals, cookies }) => {
    if (locals.session) {
      await lucia.invalidateSession(locals.session.id)
      cookies.delete(lucia.sessionCookieName, { path: '/' })
      redirect(302, '/login')
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Try signing out

Navigate to the /logout page and sign out. You should be redirected to the login page. If you check your browser cookies, you should see that the auth_session cookie has been removed.

Summary

We wired up passkey registration with local account registration. We used the sub field, which represents a user in the Passlock system, as the local user id. However we could have generated a local user id and added a column passlock_sub to the user table.

Get the code

The final SvelteKit app is available in a GitHub repo. Clone the repo and check out the tutorial/pt-2 tag.

Next steps

At this point we have a functional app but it's still rough around the edges. Passkey registration and authentication seems slow, and the app appears to hang. We'll deal with that in the next instalment in this series, improving passkey performance

. . .
Terabox Video Player