Svelte + Firebase SSR + OAuth
Mateusz Piórowski
Posted on September 28, 2023
We are continuing our authorization journey, now diving into Firebase, the biggest BaaS out there. As with all the previous steps:
- 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.
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
:
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:
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"
}'
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" };
}
}
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" };
}
}
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>
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;
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);
};
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 {};
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;
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>
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.
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 September 28, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.