DEV Community

Cover image for Svelte + Firebase SSR + OAuth
Mateusz Piórowski
Mateusz Piórowski

Posted on

Svelte + Firebase SSR + OAuth

We are continuing our authorization journey, now diving into Firebase, the biggest BaaS out there. As with all the previous steps:

  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.

https://github.com/mpiorowski/svelte-auth

Why server-side, you might ask? Check out this Google Doc with a very nice explanation:

https://firebase.google.com/docs/auth/admin/manage-cookies

We are also trimming down some descriptions to make it more accessible. For an in-depth explanation, please refer to the first article in this series.

So let's start with the hardest part ... which isn't code but setting up Firebase and obtaining all the necessary keys. First, we need to log in to Google Cloud and create a new project:

https://console.cloud.google.com/

After that, we'll search for the Identity Providers service and enable it. This is the new version of Firebase Authentication.

On the right side, you'll find the Application setup details:

Application setup details

From there, we need to retrieve and store the apiKey and authDomain.

In this section, we will also add all the necessary providers, configure the Consent screen, and fill in the Client Id and Client Secret. Let's use Google as an example because we already have this data generated.

To obtain this data, we should navigate to the Apis & Services -> Credentials -> Create credentials -> OAuth client.

We fill in all the necessary information, including the Authorized redirect URIs, which in our case will be http://127.0.0.1:3000. Later, when we move to production, we will need to add our domain here.

After saving, you'll find our required secrets on the right side: Client Id and Client Secret.

As mentioned, it's quite a few steps. The last thing remaining is to generate access for our server-side.

We search for IAM & Admin -> Service accounts. Here is also already generated for us Firebase Admin SDK account:

Firebase Admin SDK

We enter it, navigate to Keys -> Add key -> Create new key -> JSON. This will generate and download a JSON key.

That's it! The most challenging part is finished. Now, let's dive into the coding. First let's save our secrets:

.env

PUBLIC_API_KEY='mock-api-key'
PUBLIC_AUTH_DOMAIN='mock-auth-domain.firebaseapp.com'
SERVICE_ACCOUNT='{
  "type": "service_account",
  "project_id": "mock-project-id",
  "private_key_id": "mock-private-key-id",
  "private_key": "-----BEGIN PRIVATE KEY-----\nMockPrivateKey\n-----END PRIVATE KEY-----\n",
  "client_email": "mock-client-email@mock-project-id.iam.gserviceaccount.com",
  "client_id": "mock-client-id",
  "auth_uri": "https://accounts.google.com/o/oauth2/auth",
  "token_uri": "https://oauth2.googleapis.com/token",
  "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
  "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/mock-client-email@mock-project-id.iam.gserviceaccount.com",
  "universe_domain": "mock-universe-domain"
}'
Enter fullscreen mode Exit fullscreen mode

Now let's set up connections to Firebase, and we'll need two separate connections for client and server.

lib/firebase_client.ts

import { PUBLIC_API_KEY, PUBLIC_AUTH_DOMAIN } from "$env/static/public";
import { initializeApp } from "firebase/app";
import { getAuth, setPersistence, type Persistence } from "firebase/auth";

export function getFirebaseClient():
    | { error: false; data: ReturnType<typeof getAuth> }
    | { error: true; msg: string } {
    try {
        const firebaseConfig = {
            apiKey: PUBLIC_API_KEY,
            authDomain: PUBLIC_AUTH_DOMAIN,
        };
        const app = initializeApp(firebaseConfig);
        const auth = getAuth(app);
        const persistance: Persistence = { type: "NONE" };
        void setPersistence(auth, persistance);
        return { error: false, data: auth };
    } catch (error) {
        console.error(error);
        return { error: true, msg: "Error initializing firebase client" };
    }
}
Enter fullscreen mode Exit fullscreen mode

We are setting persistence to NONE because we don't want it to keep any data. This will be used ONLY once, to authorize and send the token to the server.

lib/server/firebase_server.ts

import { SERVICE_ACCOUNT } from "$env/static/private";
import admin, { type ServiceAccount } from "firebase-admin";

export function getFirebaseServer():
    | { error: false; data: typeof admin }
    | { error: true; msg: string } {
    try {
        if (!admin.apps.length) {
            const serviceAccount = JSON.parse(SERVICE_ACCOUNT) as ServiceAccount;
            const cert = admin.credential.cert(serviceAccount);
            admin.initializeApp({ credential: cert });
        }
        return { error: false, data: admin };
    } catch (error) {
        console.error(error);
        return { error: true, msg: "Error initializing firebase server" };
    }
}
Enter fullscreen mode Exit fullscreen mode

As an additional level of security, we are storing this function in the /server folder, which by default will not allow its use on the client side.

routes/auth/+page.svelte

<script lang="ts">
    import { getFirebaseClient } from "$lib/firebase_client";
    import { signInWithPopup, GoogleAuthProvider } from "firebase/auth";

    let form: HTMLFormElement;
    async function login(): Promise<void> {
        try {
            const auth = getFirebaseClient();
            if (auth.error) {
                return alert("Error: " + auth.msg);
            }
            const cred = await signInWithPopup(auth.data, new GoogleAuthProvider());
            const token = await cred.user.getIdToken();
            await auth.data.signOut();
            const input = document.createElement("input");
            input.type = "hidden";
            input.name = "token";
            input.value = token;
            form.appendChild(input);
            form.submit();
        } catch (err) {
            console.error(err);
        }
    }
</script>

<form method="post" bind:this={form} />
<button on:click={login} class="border rounded p-2 mt-10 bg-gray-800 text-white hover:bg-gray-700">
    Login using Google
</button>
Enter fullscreen mode Exit fullscreen mode

We create a Firebase client, authorize it, and retrieve the idToken. Then, we send it to our server using SvelteKit Form Action. You might notice that immediately after authentication, we sign out the client. We don't want it to store any data.

Quick information: Google has a second function, signInWithRedirect, if you prefer it. However, it requires a bit more setup.

routes/auth/+page.server.ts

import { redirect } from "@sveltejs/kit";
import type { Actions } from "./$types";
import { getFirebaseServer } from "$lib/server/firebase_server";

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");
        }
        const admin = getFirebaseServer();
        if (admin.error) {
            throw redirect(303, "/auth");
        }

        // Expires in 5 days
        const expiresIn = 60 * 60 * 24 * 5;
        let sessionCookie: string;
        try {
            sessionCookie = await admin.data
                .auth()
                .createSessionCookie(token, { expiresIn: expiresIn * 1000 });
        } catch (error) {
            console.error(error);
            throw redirect(303, "/auth");
        }

        cookies.set("session", sessionCookie, {
            maxAge: expiresIn,
            path: "/",
            httpOnly: true,
            secure: true,
            sameSite: "lax",
        });

        throw redirect(303, "/");
    },
} satisfies Actions;
Enter fullscreen mode Exit fullscreen mode

So after we log in using the client, we retrieve the token and send it to our server. The server uses it to authorize against Firebase Servers, retrieves the session cookie, and sends it back to the client. This will be our primary token used for all future authentication.

Now, let's move to the main gateway of the application, hooks.server.ts. As a quick reminder, this file captures all traffic that passes through the app.

hooks.server.ts

import { redirect, type Handle } from "@sveltejs/kit";
import { building } from "$app/environment";
import { getFirebaseServer } from "$lib/server/firebase_server";
import type { DecodedIdToken } from "firebase-admin/lib/auth/token-verifier";

export const handle: Handle = async ({ event, resolve }) => {
    event.locals.id = "";
    event.locals.email = "";

    const isAuth: boolean = event.url.pathname === "/auth";
    if (isAuth || building) {
        event.cookies.set("session", "");
        return await resolve(event);
    }

    const session = event.cookies.get("session") ?? "";
    const admin = getFirebaseServer();
    if (admin.error) {
        throw redirect(303, "/auth");
    }
    let decodedClaims: DecodedIdToken;
    try {
        decodedClaims = await admin.data.auth().verifySessionCookie(session, false);
    } catch (error) {
        console.error(error);
        throw redirect(303, "/auth");
    }
    const { uid, email } = decodedClaims;
    event.locals.id = uid;
    event.locals.email = email ?? "";

    if (!event.locals.id) {
        throw redirect(303, "/auth");
    }

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

So, with every request or page navigation, we retrieve the session cookie and validate it against Firebase, all performed on the server.

Let's make typescript happy:

app.d.ts

// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
declare global {
    namespace App {
        // interface Error {}
        interface Locals {
            id: string;
            email: string;
        }
        // interface PageData {}
        // interface Platform {}
    }
}

export {};
Enter fullscreen mode Exit fullscreen mode

All that is left is to show the user data and allow the user to logout. Let's create a home page:

src/routes/+page.server.ts

import type { PageServerLoad } from './$types';

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

src/routes/+page.svelte

<script lang="ts">
    import type { PageData } from './$types';

    export let data: PageData;
</script>

<h1>Welcome to SvelteAuth</h1>

<button
    class="border rounded p-2 mt-10 mb-10 bg-gray-800 text-white hover:bg-gray-700"
    on:click={() => (window.location.href = '/auth')}
>
    Logout
</button>

<pre>
    {JSON.stringify(data, null, 2)}
</pre>
Enter fullscreen mode Exit fullscreen mode

Yes, to log out the user, all we need to do is navigate to the /auth URL. It works because on each request, the first thing hooks.server.ts does is clear everything. If it's the /auth page, it also clears out the cookies and redirects the user there. It's a very simple but also easily understandable flow.

And that's it! The OAuth flow with Firebase and Svelte is complete, done almost entirely on the server-side using session cookies.

As a bonus, when you log in using Firebase, you can check the Identity Providers tab in GCP. There, you can see all the users who tried to authorize.

Lastly, remember that the technology landscape is dynamic, so periodically revisit your implementation to adapt to changes in Firebase, SvelteKit, or relevant libraries to ensure your project remains secure and up-to-date.

Like always, a little bit of self-promotion :) Follow me on Twitter to receive new notifications. I'm working on promoting lesser-known technologies, with Svelte, Go and Rust being the main focuses.

Top comments (1)

Collapse
 
floriankluge profile image
Florian Kluge • Edited

I'm a bit stuck at the .env part of your guide

It says On the right side, you'll find the Application setup details:
From there, we need to retrieve and store the apiKey and authDomain
(can't find those on the Application setup details)

Your .env example is showing:
PUBLIC_API_KEY='mock-api-key'
PUBLIC_AUTH_DOMAIN='mock-auth-domain.firebaseapp.com'

I'm only able to get the Client Id and Client Secret from the oauth as shown in your guide. However then your .env example doesn't make use of Client Id and Client Secret.

Any help would be appreciated, thanks <3

Edit:
Figured it out: You can get apiKey and authDomain from your firebaseConfig in firebase.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.