Svelte + PocketBase + OAuth = 90 lines of code
Mateusz Pi贸rowski
Posted on August 17, 2023
So it's time for my first series. The idea is to show how easy, fast and secure it is to set up a working OAuth2 flow.
We will be authorizing against some of the biggest BaaS providers like Firebase and Supabase, as well as some built-in solutions like Ory and Auth.js.
Here are some rules to make it stand out a little bit:
- We'll use SvelteKit as the main framework, because we all know it's the best.
- We'll aim to achieve minimal lines of code.
- We'll implement main authorization check on the server-side to enhance security.
- Our starting point from which we'll begin counting lines of code, is this Tailwind Setup
- To make it harder, we'll set the max printWidth to 80 characters, because true coders thrive on it.
All the code is available here:
https://github.com/mpiorowski/svelte-auth
The first article will also be the longest, as it will involve creating and explaining numerous concepts that will be utilized in the upcoming ones.
So, who is the lucky one to be the first one? Who will lay the foundation for all the future examples? We are launching this series with what I believe to be the best choice.
What? SQLite? For production? You must be joking, right?
Some time ago, I thought exactly the same. The funny thing is, PocketBase was also the thing that changed my mind. I will not dive into it, I'll just drop this video that explains why:
https://www.youtube.com/watch?v=yTicYJDT1zE
Alright, let's start some coding. Just for future reference, I'll be using 'LOC' to indicate 'lines of code,' and 'PB' will stand for 'PocketBase'.
First we need to install PB:
pnpm i pocketbase
That count as 0 LOC.
Then we need to create a login screen:
/src/routes/auth/+page.svelte
For those who are new to SvelteKit, the +page.svelte
file is used for automatic file-based routing. What we've just created will be displayed at the /auth
URL.
<script lang="ts">
import PocketBase from 'pocketbase';
const pb = new PocketBase('http://localhost:8080');
async function login(form: HTMLFormElement) {
try {
await pb.collection('users').authWithOAuth2({ provider: 'github' });
form.token.value = pb.authStore.token;
form.submit();
} catch (err) {
console.error(err);
}
}
</script>
<form method="post" on:submit|preventDefault={(e) => login(e.currentTarget)}>
<input name="token" type="hidden" />
<button
class="border rounded p-2 mt-10 bg-gray-800 text-white hover:bg-gray-700"
>
Login using Github
</button>
</form>
I am even adding line breaks, so 24 LOC.
I believe this part is fairly self-explanatory. We're establishing a connection to PocketBase, and then upon clicking the button, we're initializing the OAuth2 flow. It's important to note that this is the sole location where we'll initiate the client-side PocketBase connection, and we're using it exclusively for getting the token.
await pb.collection('users').authWithOAuth2({ provider: 'github' });
This method initializes a one-off realtime subscription and will open a popup window with the OAuth2 vendor page to authenticate. After success we are retriving the token and submiting it using the form.
But submitting it where?
Time for the first great SvelteKit feature, Form Actions. It's an amazing, built-in way to handle forms, which forces you to separate logic and view. So we need a server-side file to handle that.
/src/routes/auth/+page.server.ts
import { redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
export const actions = {
default: async ({ request, cookies }) => {
const form = await request.formData();
const token = form.get('token');
if (!token || typeof token !== 'string') {
throw redirect(303, '/auth');
}
cookies.set('pb_auth', JSON.stringify({ token: token }));
throw redirect(303, '/');
}
} satisfies Actions;
14 LOC
We are getting the token from request, validating it and setting it as a temporary cookie. After that we are redirecting to the /
.
...
So it's a temporary cookie...
...
But we are going to the /
page...
...
And where the hell is the authorization...
...
Something doesn't add up, right?
...
It's time for the next great SvelteKit feature (who doubted it's amazingness?), Hooks.
This is a special place, where ALL REQUEST pass through. Not only api, but also every page navigation.
So, what is happening? Let's break it down:
- We were previously on the
/auth
page, completed the OAuth flow, retrieved the token, and send it to server. - Server sets it up as cookie and then move to
/
. - Now, BEFORE we proceed to
/
, we pass through this hook:
src/hooks.server.ts
import { redirect, type Handle } from '@sveltejs/kit';
import PocketBase from 'pocketbase';
import { building } from '$app/environment';
export const handle: Handle = async ({ event, resolve }) => {
event.locals.id = '';
event.locals.email = '';
event.locals.pb = new PocketBase('http://localhost:8080');
const isAuth: boolean = event.url.pathname === '/auth';
if (isAuth || building) {
event.cookies.set('pb_auth', '');
return await resolve(event);
}
const pb_auth = event.request.headers.get('cookie') ?? '';
event.locals.pb.authStore.loadFromCookie(pb_auth);
if (!event.locals.pb.authStore.isValid) {
console.log('Session expired');
throw redirect(303, '/auth');
}
try {
const auth = await event.locals.pb
.collection('users')
.authRefresh<{ id: string; email: string }>();
event.locals.id = auth.record.id;
event.locals.email = auth.record.email;
} catch (_) {
throw redirect(303, '/auth');
}
if (!event.locals.id) {
throw redirect(303, '/auth');
}
const response = await resolve(event);
const cookie = event.locals.pb.authStore.exportToCookie({ sameSite: 'lax' });
response.headers.append('set-cookie', cookie);
return response;
};
41 LOC
That's our bread and butter, our work horse. Let's split it up:
event.locals.id = '';
event.locals.email = '';
event.locals.pb = new PocketBase('http://localhost:8080');
locals
act as a global storage for all the server actions.
So on EVERY request/navigation, we are clearing the user info and creating new PB connection.
const isAuth: boolean = event.url.pathname === '/auth';
if (isAuth || building) {
event.cookies.set('pb_auth', '');
return await resolve(event);
}
And if we are currently going to /auth
url, we are clearing the cookie and resolving the hook = going to the page. The building
variable is needed for Cloudflare Pages.
You might notice that when navigating to the /auth
URL, everything is consistently cleared. I can hear people saying that it's a bad practice and that we should check if user is authenticated and then redirect him to /
. However, in my view, this approach only adds unnecessary complexity. Let's face it, how often do people manually visit a page with /auth
? Who bookmarks a login form? This straightforward flow is justified by its simplicity. Moreover, there's an additional benefit we'll discuss later.
const pb_auth = event.request.headers.get('cookie') ?? '';
event.locals.pb.authStore.loadFromCookie(pb_auth);
if (!event.locals.pb.authStore.isValid) {
console.log('Session expired');
throw redirect(303, '/auth');
}
try {
const auth = await event.locals.pb
.collection('users')
.authRefresh<{ id: string; email: string }>();
event.locals.id = auth.record.id;
event.locals.email = auth.record.email;
} catch (e) {
throw redirect(303, '/auth');
}
if (!event.locals.id) {
throw redirect(303, '/auth');
}
In this section, we're retrieving the cookie and with it authorizing against PB, refreshing the token, fetching user data, and storing it. Side note, authRefresh
generates a new token with each call.
Additionaly we're implementing a final verification check, as an added layer of security.
const response = await resolve(event);
const cookie = event.locals.pb.authStore.exportToCookie({ sameSite: 'lax' });
response.headers.append('set-cookie', cookie);
return response;
Arriving at this point indicates that we are now authorized.
All that is left is to create THE cookie that will be used in all the subsequent requests for authorization.
That's all for the big file.
Next we need to make TypeScript happy by typing the global interface:
src/app.d.ts
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
import PocketBase from 'pocketbase';
declare global {
namespace App {
// interface Error {}
interface Locals {
pb: PocketBase;
id: string;
email: string;
}
// interface PageData {}
// interface Platform {}
}
}
export {};
We added 5 LOC to the existing file.
There is only one thing left, logout. Do you recall when I mentioned that the streamlined flow has an extra advantage?
src/routes/+page.svelte
<button
class="border rounded p-2 mt-10 bg-gray-800 text-white hover:bg-gray-700"
on:click={() => (window.location.href = '/auth')}
>
Logout
</button>
6 LOC
Yes, to logout, all we really need is to go to /auth
. It's important to note that we can't achieve this using the Svelte goto('/auth')
function, as it doesn't effectively clear up the cookies.
And we're finished!
Quick recap of everything:
- On the
/auth
page, we initiate the OAuth2 flow from the client side, acquire the token, and send it to the server. - Server sets it up as temp cookie and redirect to
/
. -
hooks.server.ts
intercepts this request, extracts the cookie, authorizes it, generates a new cookie, and redirects to the/
URL with the updated cookie. - Each subsequent request passes through the hooks layer, with the previously established cookie for authentication.
And that's all. 90 LOC.
I hope this will be helpful for some of you :)
More authentication solutions are coming soon, but before that, I plan to provide a bonus guide on setting up PocketBase with Svelte on the same server using Docker and Nginx as a proxy.
This stack, particularly considering PocketBase's ability to work without remote SQL due to SQLite, offers incredible performance. You can do thousands of request, and you won't even notice it.
End
Hope you enjoyed it!
Like always, a little bit of self-promotion :)
Follow me on Github and Twitter to receive new notifications. I'm working on promoting lesser-known technologies, with Svelte, Go and Rust being the main focuses.
I am also a creator of GoFast, the ultimate foundation for building modern web apps with the power of Golang and SvelteKit / Next.js. Hop in if you are interested :)
Posted on August 17, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.