NgSysV2-3.5: A Serious Svelte InfoSys: A Client-Server Version
MartinJ
Posted on November 25, 2024
This post series is indexed at NgateSystems.com. You'll find a super-useful keyword search facility there too.
Last reviewed: Nov '24
1. Introduction
Post 3.3 delivered some bad news - the Firestore auth
object isn't available server-side. This has the following consequences:
Server-side database code must use the Firestore Admin API. This is because Firestore Client API code fails when
auth
isn't available to make calls subject to database "rules" that referenceauth
. By contrast, Admin API calls don't care about database rules. If you dropped the rules, client API calls would work server-side, but this would leave your database open to cyber attack (you've been working into your live Firestore database ever since you started using your local VSCode terminal - think about it).Server-side code that uses data items such as
userName
anduserEmail
derived fromauth
must find another way of getting this information.
This post describes how you overcome these problems to produce a high-performance webapp that runs securely and efficiently server-side.
2. Authenticated Svelte server-side code in practice
If you've already got used to the Client call signatures, the requirement to switch to the Firestore Admin API is a nuisance. But you'll soon get used to this so it shouldn't hold you up significantly.
Getting user data, however, is a different matter. For many applications, access to user properties such as uId
is critical to their design. For example, a webapp may need to ensure that users can only see their own data. Unfortunately, arranging this is quite a struggle. Here we go:
- First, on the client, you need to find a way to create an "idToken" package containing everything your server-side code might need to know about a user. Google provides a
getIdToken()
mechanism to construct this from the user'sauth
session data. - Then you need to find a way of passing this package to the server. The mechanism used here registers this in a "header" that gets added to client calls to the server.
- Then you need to obtain a Google "Service Account" that enables you to authenticate your use of the Firestore Admin API on a Google server. The keys that define this need to be embedded securely in your project files (recall the
firebaseConfig.env
discussion in Post 3.3. - And finally, your server-side code must present these Service Account keys wherever you need to use a Firestore database.
2.1 Getting an idToken
Have a look at the following code from the <script>
section of a products-maintenance-sv/+page.svelte
"server" version of the "rules-friendly" products-maintenance-rf
code. This uses getIdToken()
to access a user's Firebase authentication session and build an idToken
// src/routes/products-maintenance-sv/+page.svelte
<script>
import { auth } from "$lib/utilities/firebase-client";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
onMount(async () => {
if (!auth.currentUser) {
// Redirect to login if not authenticated, with a redirect parameter
goto("/login?redirect=/products-maintenance-sv");
return;
}
try {
// Fetch the ID token directly
const idToken = await auth.currentUser.getIdToken();
window.alert("idToken:" + JSON.stringify(idToken));
} catch (error) {
window.alert("Error retrieving ID token:", error);
}
});
</script>
You saw the onMount()
arrangement earlier in products-maintenance-rf/+page.svelte
where it was used to ensure that the user is logged in. It is now also used to get an idToken
variable by calling the asynchronous auth.currentUser.getIdToken()
.
Create a new src/routes/products-maintenance-sv
folder and paste the code listed above into a new +page.svelte
file within it. Now try running this in the dev
server at http://localhost:5173/products-maintenance-sv
. Once you're logged in (using the version of /login/+page.svelte
last seen in Post 3.4 you should see the idToken
displayed in an alert
message.
The Firebase ID token is a JSON Web Token (JWT). The JSON bit means that it is an object encoded as a character string using "Javascript Object Notation" (if this is your first sight of a "JSON" you might find it useful to ask chatGPT for background). JSONs are widely used where you need to pass Javascript objects around as character strings. The JWT JSON includes everything you might need to know about a user. I'll show you how you extract this information later in this post - it's not complicated.
2.2 Passing the idToken
to the server
The mechanism described in this post sends the "IdToken" as a "cookie" in the "request header" that accompanies server requests. An "http header" is a packet of information that passes through the web when a client-based +page.svelte
file sends a request to a server-based +page.server.js
file. Such a request will be sent every time you read or write a Firestore document. A "cookie" is a string that gets added to every request header.
This arrangement is complicated but is regarded as secure. From your point of view, as an IT student, it's also interesting and educational because it gives insight into web design internals.
A client-side Javascript program could easily set a "regular" cookie containing the JWT but, for security reasons, you very much do not want to do this. If you can do this then anybody can. A server-side +page.server.js
file, on the other hand, can set an "http-only" cookie in the client browser using a set-cookie
call. Here's an example:
// Set a secure, HTTP-only cookie with the `idToken` token
const headers = {
'Set-Cookie': cookie.serialize('idToken', idToken, {
httpOnly: true
})
};
let response = new Response('Set cookie from server', {
status: 200,
headers,
body: { message: 'Cookie set successfully' } // Optional message
});
return response;
The httpOnly: true
setting above means that, although the cookie is held client-side, it cannot be accessed from Javascript. In this way you can ensure that the value you set here is secure from tampering.
The question you should be asking now is "How can a server-side +page.server.js
file launch a Set-Cookie
command to set an idToken
when it doesn't know the idToken
?".
Welcome to the Svelte +server.js
file. This is server-side code that can be called from client-side code with a Javascript fetch
command. Such server-side code is called an "end-point". A fetch
command is Javascript's native method for submitting a request
to a web-based "end-point". The command enables you to include data in the request and so this is how you get an idToken
value onto the server. Here's an example:
// client-side +page.svelte code
const idToken = await user.getIdToken();
// Send token to the server to set the cookie
fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ idToken }),
});
and here's how the recipient +server.js
file would retrieve this and extract its idToken
.
// server-side +server.js code
export async function POST({ request }) {
const { idToken } = await request.json();
}
You are probably thinking "Why does this code use a "fetch" command to "send" something?" but there you are. "Fetch" was designed as a versatile API for making many different types of HTTP request. Ask chatGPT for a tutorial if you'd like to get some background. See for some examples.
The proposal now is to make your login page responsible for making the +server.js
call that sets the browser http-only cookie. Once the cookie is set, it will be added automatically to every HTTP call the browser makes until it expires.
To set this in motion, create new folders and files for the following login-and-set-cookie/+page.svelte
version of the login page, and its accompanying api/set-cookie/+server.js
end point:
// src/routes/login-and-set-cookie/+page.svelte
<script>
import { onMount } from "svelte";
import { auth, app } from "$lib/utilities/firebase-client";
import { goto } from "$app/navigation"; // SvelteKit's navigation for redirection
import { signInWithEmailAndPassword } from "firebase/auth";
let redirect;
let email = "";
let password = "";
onMount(() => {
// Parse the redirectTo parameter from the current URL
const urlParams = new URLSearchParams(window.location.search);
redirect = urlParams.get("redirect") || "/";
});
// this code will run after a successful login.
auth.onAuthStateChanged(async (user) => {
if (user) {
const idToken = await user.getIdToken();
console.log("In login_awith-cookie : idToken: ", idToken);
// Send token to the server to set the cookie
fetch("/api/set-cookie", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ idToken }),
});
window.alert("In with-cookie : cookie set");
goto(redirect);
}
});
async function loginWithMail() {
try {
const result = await signInWithEmailAndPassword(
auth,
email,
password,
);
} catch (error) {
window.alert("login with Mail failed" + error);
}
}
</script>
<div class="login-form">
<h1>Login</h1>
<form on:submit={loginWithMail}>
<input bind:value={email} type="text" placeholder="Email" />
<input bind:value={password} type="password" placeholder="Password" />
<button type="submit">Login</button>
</form>
</div>
<style>
.login-form {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
height: 100vh;
}
form {
width: 300px;
margin: 0 auto;
padding: 20px;
border: 1px solid #ccc;
border-radius: 5px;
background-color: #f5f5f5;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
input[type="text"],
input[type="password"] {
width: 100%;
padding: 10px 0;
margin-bottom: 10px;
border: 1px solid #ccc;
border-radius: 3px;
}
button {
display: block;
width: 100%;
padding: 10px;
background-color: #007bff;
color: #fff;
border: none;
border-radius: 3px;
cursor: pointer;
}
div button {
display: block;
width: 300px;
padding: 10px;
background-color: #4285f4;
color: #fff;
border: none;
border-radius: 3px;
cursor: pointer;
}
</style>
// src/routes/api/set-cookie/+server.js
import admin from 'firebase-admin';
import cookie from 'cookie';
export async function POST({ request }) {
const { idToken } = await request.json();
try {
// Verify the token with Firebase Admin SDK
const decodedToken = await admin.auth().verifyIdToken(idToken);
// Use the cookie.serialize method to create a 'Set-Cookie' header for inclusion in the POST
// response. This will instruct the browser to create a cookie called 'idToken' with the value of idToken
// that will be incorporated in all subsequent browser communication requests to pages on this domain.
const headers = {
'Set-Cookie': cookie.serialize('idToken', idToken, {
httpOnly: true, // Ensures the cookie is only accessible by the web server
secure: true, // Ensures the cookie is only sent over HTTPS
sameSite: 'None', // Allows the cookie to be sent in cross-site requests
maxAge: 60 * 60, // 1 hour (same as Firebase ID token expiry)
path: '/' // Ensures the cookie is sent with every request, regardless of the path.
})
};
let response = new Response('Set cookie from login', {
status: 200,
headers,
body: { message: 'Cookie set successfully' } // Optional message
});
console.log("Cookie set")
return response;
} catch (err) {
console.error("Error in login server function: ", err);
let response = new Response('Set cookie from login', {
status: 401,
body: { message: 'Unauthorized' } // Optional message
});
return response;
}
};
Note that, in order to use api/set-cookie/+server.js
, you first need to install the npm "cookie" library. This library helps create properly formatted cookies for inclusion in HTTP response headers.
npm install cookie
There's no need for a "logout-and remove-cookie" logout page. Setting a new cookie will overwrite any old version with the same name.
2.3 Setting a Service Account in your project
A Service Account for a project is an object packed with secure keys and "owner" information (such as the project's projectId
). When a "+page.server.js" file runs, a copy of the Service Account embedded within it is presented to Google. If the two match up, the server file is authenticated.
Here's the procedure for:
- creating and downloading a Service Account for your project on the Cloud,
- embedding this in your project, and
- installing in your project the 'firebase-admin' library required to perform the comparison
2.3.1 Creating a Service Account
- Go to the Google Cloud Console.
- Navigate to IAM & Admin > Service Accounts and check that this points to your
svelte-dev
project (using the pulldown menu at the top left). The IAM (Identity and Access Management) screen lists all the Cloud permissions that control who can do what with the Google Cloud resources for your project. This deserves a 'post' in its own right, but this isn't the time - Switch out of the IAM page and into the Service Accounts page by mousing over the toolbar at the left of the screen and clicking the one labelled "Service Accounts". You should see that a default account has already been created.
- Click the "+ Create Service Account" button at the top of the page and Create a new Service Account with a unique "Service account name such as "svelte-dev" (or whatever takes your fancy - it must be between 6 and 30 characters long and can only see lower-case alphanumerics and dashes). A version of this with a suffix guaranteed to be unique Cloud-wide is propagated into the "Service account ID" field. I suggest you accept whatever it offers.
Now click the "Create And Continue" button and proceed to the "Grant this service account access to the project" section. Start by opening the pull-down menu on the field. This is a little complicated because it has two panels. The left-hand panel (which has a slider bar), allows you to select a product or service. The right-hand one lists the roles that are available for that service. Use the left-hand panel to select the "Firebase" Service and then select the "Admin SDK Administrator Service Agent" role from the right-hand panel. Click "Continue", then "Done" to return to the Service Accounts screen
Finally, click the "three-dot" menu at the RHS of the entry for the "Firebase Admin SDK Service Agent" key that you've just created and select "manage keys". Click "Add Key" > Create new key > JSON > Create and note that a new file has appeared in your "downloads" folder. This is your "Service Account Key". All you have to do now is embed this in your project.
2.3.2 Embedding the downloaded Service Account in your project
- Create a
/secrets
folder in the root of your project to provide a secure location for the Service Account Key. Move the download Service Account file into a/secrets/serviceAccount.json
file and add the "/secrets" folder and any editing history for it to your ".gitignore" file:
// .gitignore - fragment
# Secrets
/secrets/
/.history/secrets/
This is another instance of the safeguarding mechanism described previously in Post 3.3 to stop you from inadvertently revealing files in Git repositories. For Windows users, an even more secure approach would be to create Windows GOOGLE_APPLICATION_CREDENTIAL
environment variables to provide key references.
2.3.3 Installing the 'firebase-admin' library in your project
To run the "server logon" procedure, your +page.server.js
code needs access to the Firebase admin
API. You get this by installing "firebase-admin" in your project:
npm install firebase-admin
You can now create an admin
reference in your code with:
import admin from 'firebase-admin';
Note that the syntax of this import differs from those you've been using so far - there are no curly brackets around the "admin" bit. Whereas the other libraries you have used let you import named components, this version requires you to import the whole thing from a default admin
export. This supplies the components as properties such as admin.auth()
, admin.firestore()
, etc. of the parent admin
object. Designers of this library have taken the view that this is a more practical arrangement in this situation.
When using a default import you can call the imported parent object anything you like (eg, you might call it myFirebaseAdmin
instead of admin
). Compare this arrangement with the named export approach of the lib/utilities/firebase-config
file you created earlier
2.4 Using the Service Account
and the idToken
in your +page.server.js
file
This is where you finally get down to the nitty-gritty of using the Firestore Admin API to access a Firestore database server-side.
First, you use your Service Account keys to "initialise" your app and thus get permission to create the adminDb
object required to use the Admin API (just as you need db
for the Client API). Then you need to get your idToken
from the cookie and extract from this any user content you might need in your Firestore calls. At this point, you're finally free to code these calls using the Firestore Admin API.
Copy the code listed below to a new +page.server.js
file in your src/routes/products-maintenance-sv
folder. This is a "server version" of the products-maintenance
code first seen in Post 3.3. It was used there to show how server-side code attempting to use the Firestore Client API would fail where the collections they were addressing were subject to Firestore database rules. This new version benefits from:
- Service account keys that enable it to use Firestore Admin API commands and thus ignore the database rules
- An
idToken
cookie that enables it to obtain details of an authenticated user
// src/routes/products-maintenance-sv/+page.server.js
import admin from 'firebase-admin';
import serviceAccount from '/secrets/service-account-file.json';
import cookie from 'cookie'; // install with "npm install cookie"
import { productNumberIsNumeric } from "$lib/utilities/productNumberIsNumeric";
// Give initialiseApp your project's Service Account Credentials. But make sure you only do this once.
// Things can get messy if you don't do this when your application is deployed in an environment where
// multiple instances of the server or multiple processes are running,
try {
if (!admin.apps.length) {
admin.initializeApp({
credential: admin.credential.cert(serviceAccount)
});
}
} catch (error) {
console.error("Failed to initialize Firebase Admin SDK:", error);
}
const adminDb = admin.firestore(); // Create an Admin SDK Firestore instance
export const actions = {
default: async ({ request }) => {
// Get the idToken from the request header
const cookies = cookie.parse(request.headers.get('cookie'));
const idToken = cookies.idToken;
// example of use of idToken to get the userEmail
const decodedToken = await admin.auth().verifyIdToken(idToken);
const userEmail = decodedToken.email;
console.log("userEmail from cookie : " + userEmail);
// capture of form data
const input = await request.formData();
const productNumber = input.get("productNumber");
const productDetails = input.get("productDetails");
// server-side repeat of client-side validation to catch hackers
const validationResult = productNumberIsNumeric(productNumber);
// Add the new record to the database
if (validationResult) {
try {
const productsDocData = { productNumber: parseInt(productNumber, 10), productDetails: productDetails };
const productsCollRef = adminDb.collection("products");
const productsDocRef = productsCollRef.doc(); // Creates a new doc with an auto-generated ID
await productsDocRef.set(productsDocData);
return { validationSuccess: true, databaseUpdateSuccess: true };
} catch (error) {
return { validationSuccess: true, databaseUpdateSuccess: false, databaseError: error.message };
}
} else {
return { validationSuccess: false, databaseUpdateSuccess: null };
}
}
};
Notice the curious way that the code builds the userEmail
field
// example of use of idToken to get the userEmail
const decodedToken = await admin.auth().verifyIdToken(idToken);
const userEmail = decodedToken.email;
The verifyIdToken
method name might make you wonder whether this is trying to authenticate your user again. Don't worry - it's not. It's just doing a security check on the token's embedded "signatures" to assure itself that it hasn't been tampered with and hasn't expired.
The decodedToken
created by verifyIdToken
is a simple object containing email
and userName
properties etc for your authenticated user. The subsequent Firestore code doesn't use any of these, but I'm sure you can easily imagine how it might do so.
I suggest you use the "boiler-plate" approach again when coding Admin API calls - use chatGPT to convert the client code documented in Post 10.1 if necessary.
Now replace the content of the src/routes/products-maintenance-sv/+page.svelte
file you created earlier with the code shown below. This will provide a client front-end to the products-maintenance-sv/+page.server.js
file:
// src/routes/products-maintenance-sv/+page.svelte
<script>
import { onMount } from "svelte";
import { auth } from "$lib/utilities/firebase-client";
import { goto } from "$app/navigation";
import { productNumberIsNumeric } from "$lib/utilities/productNumberIsNumeric";
// Put the check on the auth state change inside an onAuthStateChanged callback inside onMount. This seems
// bizarre but seems the only way to get server-side activity completed and auth.currentUser into a stable
// state. This wasn't an issue on the client side "reules-friendly" version but became a problem as soon
// as a +page.server.js file with actions() was added.
onMount(async () => {
auth.onAuthStateChanged(async (user) => {
if (!auth.currentUser) {
// Redirect to login if not authenticated. The parameter tells login how to get back here
goto("/login-and-set-cookie?redirect=/products-maintenance-sv");
}
});
});
let productNumber;
let productDetails;
let productNumberClass = "productNumber";
let submitButtonClass = "submitButton";
export let form;
</script>
<form method="POST">
<label>
Product Number
<input
bind:value={productNumber}
name="productNumber"
class={productNumberClass}
on:input={() => {
if (productNumberIsNumeric(productNumber)) {
submitButtonClass = "submitButton validForm";
productNumberClass = "productNumber";
} else {
submitButtonClass = "submitButton error";
productNumberClass = "productNumber error";
}
}}
/>
</label>
<label>
Product Details
<input
bind:value={productDetails}
name="productDetails"
class={productNumberClass}
/>
</label>
{#if productNumberClass === "productNumber error"}
<span class="error">Invalid input. Please enter a number.</span>
{/if}
<button class={submitButtonClass} type="submit">Submit</button>
</form>
{#if form !== null} <!--ie do this if the form has been submitted, -->
{#if form.databaseUpdateSuccess}
<p>Form submitted successfully.</p>
{:else}
<p>Form submission failed! Error: {form.databaseError}</p>
{/if}
{/if}
<style>
.productNumber {
border: 1px solid black;
height: 1rem;
width: 5rem;
margin: auto;
}
.submitButton {
display: inline-block;
margin-top: 1rem;
}
.error {
color: red;
}
.validForm {
background: palegreen;
}
</style>
To run this in your dev server, start by logging out with http://localhost:5173/logout
. Then run http://localhost:5173/products-maintenance-sv
. This will invite you to log in on the login-and-set-cookie
page.
Once you've successfully logged in, you will see the familiar form inviting you to create a new product.
At this point, the login-and-set-cookie
page should have set the idToken
cookie securely in your browser. When you enter data and submit the form, control will pass to the server-side code in products-maintenance-sv/+page.server.js
. This authenticates itself by presenting the service codes built into your project and then grabs the idToken
from the headers and its input
data from the form object in the Sveltekit request
. The code won't do anything useful with the user data available in idToken
but a log message displaying the value of userEmail
will be displayed in the VSCode terminal. Finally, the Firestore Admin code will add the new product to the products
database collection.
You can confirm that the update has been successfully applied by running the old http://localhost:5173/products-display-rf
page.
Note that after the form has been submitted it displays a confirmation message and clears its input fields. "Form refresh" is Javascript's default action after form submission.
You might wonder how the http://localhost:5173/products-display-rf
page page works when it's still running Firestore Client API code server side with Firestore auth rules set on the products
collection. The difference is that these rules are only applied to writes. The products-display-rf
code is just reading documents.
In practice, I think that if you were concerned to avoid confusion and decided to create a products-display-sv
version of products-display-sv
you'd want to use Firestore Admin API calls throughout. Remember, though, that you'd then need to start by presenting your Service Account credentials to initializeApp
.
3. Summary
This has been a long post and will have stretched your Javascript to the limit. If you're still with me at this point - well done. Really, well done - you've displayed extraordinary persistence!
The "client-side" techniques introduced by the previous post are a joy to work with, but I hope you'll appreciate the security and speed advantages of the server-side arrangements. With experience, server-side code development will become just as easy and natural as client-side work.
But there's still one thing left to do. Although you've now developed a webapp with lots of pages that run just fine in your dev server, none of these are yet visible on the web.
The next post tells you how you "build" and deploy" your webapp onto a Google AppEngine and thus release it to the eager public. This will be a big moment!
I hope you'll still have the energy left to read on and find out what you need to do. It's not too difficult.
Postscript: When things go wrong - viewing Headers in the Inspector
Actually, the number of things that could go wrong for you in this section is probably approaching the infinite. Try not to panic too much and keep a close eye on the filing structure of your project. It's all too easy to get the right code into the wrong file, or the right file into the wrong folder. Additionally, you might find it helpful to regularly "clear the ground" by killing and restarting your terminal session. At the very least, this gives you a clean sheet when looking for the initial cause of an error sequence.
But, since you're now playing with headers and cookies you will also find it useful to know that the browser's Inspector tool can give you visual insight into this stuff. The Inspector can show you a cookie embedded in a page's request header.
To see this facility in action, first make sure you're logged out on the live system with https://myLiveUrl/logout
(where myLiveUrl is the address of your deployed webapp) . Then run the products-maintenance=sv
page at https://https://myLiveUrl/products-maintenance-sv
. Get yourself logged in, open the Inspector on the "enter new product" form and click on the "Network" tab. This now displays the list of network requests made by a page.
Now use the webapp to insert a new Product and note how the "requests" list is refreshed. These are the network requests required to perform that simple update - a surprisingly long list! Scrolling back up to the top of this list you should find an entry for your products-maintenance-sv
page. If you click on this, the panel to the right of the requests list should display full details of both the response and the request headers for the transaction. The screenshot below shows the cookie embedded within the request header.
Posted on November 25, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.