This tutorial forms the second part of my SvelteKit series. If you haven't already done so, please read the first installment. In this post I'll show you how to protect your routes using user accounts, sessions and hooks.
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
Setup the database
Initialise Prisma:
pnpm dlx prisma init --datasource-provider sqlite
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="..."
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)
}
Run the initial migration:
pnpm dlx prisma migrate dev --name init
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 { Passlock, 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 result = await tokenVerifier.exchangeToken(token)
if (Passlock.isPrincipal(result)) {
// FIXME JUST FOR DEVELOPMENT TESTING!!!
await client.user.deleteMany()
// create a user in the local db
await client.user.create({
data: {
id: result.sub,
email: result.email as string,
givenName: result.givenName,
familyName: result.familyName
}
})
redirect(302, '/login')
} else {
console.error(result)
error(500, result.message)
}
}
}
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:
- Register a passkey on the users device
- Register the public key component in your Passlock vault
- 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.
User authentication
Previously, we prompted the user to authenticate with their passkey, then verified the token in our form action. We'll build on that work to:
- Lookup a local user using the
sub
property - Create a Lucia session for that user
- Invalidate any other user sessions
We'll update the login action to hook into Lucia, but before we do that, let's setup Lucia itself. Create a file at src/lib/server/auth.ts
:
// 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
}
}
Next, update the login action:
// src/routes/login/+page.server.ts
import type { Actions } from './$types'
import { Passlock, 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 result = await tokenVerifier.exchangeToken(token)
if (Passlock.isPrincipal(result)) {
// delete other active sessions
await lucia.invalidateUserSessions(result.sub)
const session = await lucia.createSession(result.sub, {})
const sessionCookie = lucia.createSessionCookie(session.id)
// set a cookie representing the new session
cookies.set(lucia.sessionCookieName, sessionCookie.value, {
path: '/',
...sessionCookie.attributes
})
redirect(302, '/')
} else {
console.error(result)
error(500, result.message)
}
}
}
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:
- Check for a lucia session cookie
- Lookup the associated session and user
- Attach the session and user to the app locals
- 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 {}
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) {
// refreshed session, update the cookie
const sessionCookie = lucia.createSessionCookie(session.id)
event.cookies.set(sessionCookie.name, sessionCookie.value, {
path: '/',
...sessionCookie.attributes
})
}
if (!session) {
// effectively delete the session cookie
// by overwriting it with a blank value
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)
}
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}
// src/routes/status/+page.server.ts
import type { PageServerLoad } from './$types'
export const load = (async ({ locals }) => {
return { user: locals.user }
}) satisfies PageServerLoad
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
// protect the /status route
if (event.route.id === '/status' && !event.locals.user) {
redirect(302, '/login')
}
return resolve(event)
}
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>
// 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')
}
}
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.
Instead of using the passlock sub
we could insead have used Passlock's authId
(authenticator id) as this represents the passkey itself. However as you'll later discover, there are some benefits to referencing sub
as it allows us to easily add social login to our SvelteKit apps.
Top comments (0)