Svelte + PocketBase + OAuth = 90 lines of code
Tags: svelte, oauth, typescript, webdev
Published: 2023-08-17 08:21:14

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:

1. We'll use [SvelteKit](https://kit.svelte.dev/) as the main framework, because we all know it's the best.
2. We'll aim to achieve minimal lines of code.
3. We'll implement main authorization check on the server-side to enhance security.
4. Our starting point from which we'll begin counting lines of code, is this [Tailwind Setup](https://tailwindcss.com/docs/guides/sveltekit)
5. 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.

**[PocketBase](https://pocketbase.io/)**

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.

```ts
<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.

```ts
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](https://kit.svelte.dev/docs/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**
```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](https://kit.svelte.dev/docs/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**
```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:
```ts
	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.

```ts
	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.

```ts
	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.

```ts
	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**
```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**
```ts
<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:

1. On the `/auth` page, we initiate the OAuth2 flow from the client side, acquire the token, and send it to the server.
2. Server sets it up as temp cookie and redirect to `/`.
2. `hooks.server.ts` intercepts this request, extracts the cookie, authorizes it, generates a new cookie, and redirects to the `/` URL with the updated cookie.
3. 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. 

[Follow me on Twitter](https://twitter.com/mapiorowski)  to receive notifications. I'm also working on promoting lesser-known technologies, with Svelte, Rust, and Go being some of the main focuses.