NgSysV2-3.5: A Serious Svelte InfoSys: A Client-Server Version

mjoycemilburn

MartinJ

Posted on November 25, 2024

NgSysV2-3.5: A Serious Svelte InfoSys: A Client-Server Version

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 reference auth. 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 and userEmail derived from auth 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:

  1. 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's auth session data.
  2. 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.
  3. 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.
  4. 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>
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

Enter fullscreen mode Exit fullscreen mode

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

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:

  1. creating and downloading a Service Account for your project on the Cloud,
  2. embedding this in your project, and
  3. installing in your project the 'firebase-admin' library required to perform the comparison

2.3.1 Creating a Service Account

  1. Go to the Google Cloud Console.
  2. 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
  3. 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.
  4. 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.
  5. 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

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

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

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

You can now create an admin reference in your code with:

import admin from 'firebase-admin';
Enter fullscreen mode Exit fullscreen mode

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

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

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>
    &nbsp;&nbsp;

    <label>
        Product Details
        <input
            bind:value={productDetails}
            name="productDetails"
            class={productNumberClass}
        />
    </label>
    &nbsp;&nbsp;

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

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

Screenshot showing a cookie embedded in a request header

💖 💪 🙅 🚩
mjoycemilburn
MartinJ

Posted on November 25, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related