DEV Community

Cover image for Svelte + PocketBase + OAuth = 90 lines of code
Mateusz Piórowski
Mateusz Piórowski

Posted on • Updated on

Svelte + PocketBase + OAuth = 90 lines of code

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 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
  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

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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' });
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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);
    }
Enter fullscreen mode Exit fullscreen mode

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');
    }
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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 {};
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 /.
  3. hooks.server.ts intercepts this request, extracts the cookie, authorizes it, generates a new cookie, and redirects to the / URL with the updated cookie.
  4. 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 to receive notifications. I'm also working on promoting lesser-known technologies, with Svelte, Rust, and Go being some of the main focuses.

Top comments (2)

Collapse
 
milaabl profile image
milaabl

Great article, Mateusz! 👍 Would love to read an article on Rust from you. 🤓

Collapse
 
mpiorowski profile image
Mateusz Piórowski • Edited

Have something in mind, first want to start with the front part :) Happy that You liked it!

For sure will prep an article with a web api app connected to sqlite. With Rust, it is soooooo fast :D