Svelte + Firebase SSR + OAuth
Published: 2023-09-28 21:23:28

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](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.

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/e0sd6l3c0u2vbxwdodqh.png)

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/hqjzldajjdbw26ef1jr2.png)

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**
```bash
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"
}'
```
Now let's set up connections to Firebase, and we'll need two separate connections for **client** and **server**. 

**lib/firebase_client.ts**
```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" };
    }
}
```

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**
```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" };
    }
}
```

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

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**
```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;
```

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**
```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);
};
```

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**
```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 {};
```

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**
```ts
import type { PageServerLoad } from './$types';

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

**src/routes/+page.svelte**
```ts
<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>
```

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](https://twitter.com/mapiorowski) to receive new notifications. I'm working on promoting lesser-known technologies, with Svelte, Go and Rust being the main focuses.