NgSysV2-3.3: A Serious Svelte InfoSys: Firebase D/b rules and Login
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
When you created your first Firestore database with the help of post 2.3 you may remember that you were asked whether you wanted to apply "production" or "test" rules. The suggestion at the time was that "test" rules should be selected. If you'd used the "rules" tab on the Firestore page in the Firebase console at this point, you'd have found your rules were set to something like:
match /{document=**} {
allow read, write: if request.time < timestamp.date(2024, 10, 31);
}
Here, Google has created a default rule that uses a timestamp to permit read and write access to your database for 30 days from the day you created it. That's unlikely to be what you want now (and Google will nag you to change it, anyway). So, it's now time to learn more about Firestore rules and how you might use them to make your database secure.
Database Rules
Firestore "rules" let you restrict read and write access to database collections, by referring to a Firebase request
object that is passed to a Firestore "rule-handler" whenever a database call is made. This object contains, among other things, details of the user making the request, the type of operation being performed, and the current time. If you'd like to see the full list of properties, chatGPT will happily supply this.
To get a full account of Firestore rules syntax, it's probably best if I refer you to Google's own docs at Getting Started with Firestore Rules. For present purposes, I'm going to concentrate on the immediate requirements for the default database that this post series has created.
To see a database rule in action, try using the "rules" tab on the Firestore page in your Firebase console to change your rules to:
match /{document=**} {
allow read: if true;
allow write: if request.time < timestamp.date(2000, 01, 01);
}
This rule only allows write access to the documents in your database if the time is earlier than the 1st Jan 2000. So, unless you're currently running in a time machine, you won't now be able to create a new document.
Click the "Publish" button to make the new rule live (you can safely ignore the message that says that publishing may take some time to take effect - in practice the delay seems to be minimal), and see how your webapp reacts
Start your dev server and launch the webapp at http://localhost:5173
. When you try to add a new product, you shouldn't be too surprised when you receive a "500: Internal Error" page. When you go to your terminal session to investigate the cause, you'll see the following message:
[FirebaseError: Missing or insufficient permissions.] {
code: 'permission-denied',
customData: undefined,
toString: [Function (anonymous)]
}
Now that you've got a feel for how Firestore rules work, you can start to think about how you might use this on the products-display
and products-maintenance
pages you created in Post 3.1.
As you'll recall, these offered two routes as follows:
- a "products-display" route at
localhost:5173/products-display
that allows users to read all documents from theproducts
collection and - a "products-maintenance" route at
localhost:5173/products-maintenance
that allows users to write new documents to the collection.
The assumption is that while you'll be happy to permit anybody to read product
documents using the "products-display" route, you'll want only authorised individuals to be able to add new products using the "products-maintenance" route.
Individuals will be authorised by issuing them with a "user-id/password" combination that they can use to "login" to the webapp. This procedure will create a persistent "auth" state on the user's client device that becomes part of the Firebase request
object that is passed to Firestore rules processing when the user tries to access a database.
Then, if you set Firestore rules as follows:
match /{document=**} {
allow read: if true;
allow write: if request.auth != null
}
only "logged-in" users will be able to write to the "products" page.
Now all you need to know is how to write a "login" page to create an auth
state. Please read on!
2. Firebase Login
In a login screen, prospective system users are asked to provide a personal identifier (usually an email address) and an associated password.
The system then checks the user's identifier and password against a secure list of known credentials. In Firebase you'll find this list in your project's Firebase console under the "Build -> Authentication -> Users" tab. Have a look at this. Take the opportunity to register a test email address and password ("programmatic" registration is also possible but isn't covered here). Note the "User UID field" that Firebase allocates to the registration. This is a unique, encrypted version of the email address. As you'll see shortly, this forms an important element of the Firebase security mechanism. Note also that the screen provides facilities for deleting accounts and changing passwords.
While you're here, check out the "Sign-in method" tab on the "Authentication" screen. Email/password combinations or Google accounts are offered. I recommend that you enable only the email/password option at this stage.
Now create a login screen. The sample code shown below is surprisingly brief (and most of it is styling!):
routes/login/+page.svelte
<script>
import { auth } from "$lib/utilities/firebase-client";
import {
signInWithEmailAndPassword,
} from "firebase/auth";
let email = "";
let password = "";
async function loginWithMail() {
try {
const result = await signInWithEmailAndPassword(
auth,
email,
password,
);
window.alert("You are logged in with Mail");
} 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>
The code collects user-id/password input and then calls a Firebase signInWithEmailAndPassword
function to authenticate it.
I've put try
blocks around the Firebase call so that you can see the results (for good or ill) in popup window.alert
messages.
Let's have a logout
page too. Here it is. Note its use of a new Svelte tool - the onMount
function. The Firebase signOut
API call is asynchronous and, since I want to await
it before displaying a "You are logged out" message, I can't do this directly in the <script>
section of the logout page. Svelte's onMount
tool is a handy facility in which to bundle up all sorts of things that you'd like to happen automatically when the browser has finished loading the DOM for your webapp. You'll see another use for it in Post 3.4
routes/logout/+page.svelte
<script>https://dev.to/mjoycemilburn/ngsysv2-34-a-serious-svelte-infosys-a-rules-friendly-version-5g2c
import { auth } from "$lib/utilities/firebase-client";
import { onMount } from "svelte";
import { signOut } from "firebase/auth";
async function handleLogout() {
try {
// Sign out of Firebase Authentication
await signOut(auth);
window.alert("You are logged out");
} catch (error) {
window.alert("Error during logout:", error);
}
}
// Call handleLogout when component mounts
onMount(() => {
handleLogout();
});
</script>
<p>Logging out...</p>
Create new route
folders and +page.svelte
files for the login
and logout
scripts. But don't try running them yet because I need to tell you about a few more bits and pieces!
Notice that these files now import their auth
variable from a central src/lib/utilities/firebase-client.js
file. Inside this, the webapp presents its firebase-config
keys to assure Firebase that it is authorised to create an auth
object. Here's the updated version of src/lib/utilities/firebase-client.js
that does this.
lib/utilities/firebase-client.js
import { initializeApp, getApps, getApp } from 'firebase/app';
import { getAuth } from 'firebase/auth';
import { getFirestore } from "firebase/firestore";
const firebaseConfig = {
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
appId: import.meta.env.VITE_FIREBASE_APP_ID,
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
};
const app = !getApps().length ? initializeApp(firebaseConfig) : getApp();
const auth = getAuth(app);
const db = getFirestore(app);
export { app, auth, db }
Since the app
, auth
and db
variables exported here are widely required in a webapp, much code is saved by generating them in a central location.
But a couple of pieces of "bling" here need some explanation.
First, you'll note that I'm no longer coding firebaseConfig
properties such as apiKey
directly in the code but am referencing Vite parameters that I've defined in the project's .env
file (a file or folder with a "." signifies that it is "system" data). Here it is:
// svelte-dev/.env - don't include this line
VITE_FIREBASE_API_KEY="AIzaSyDOVyss6etYIswpmVLsT6n-tWh0GZoPQhM"
VITE_FIREBASE_APP_ID="1:585552006025:web:e41b855f018fcc161e6f58"
VITE_FIREBASE_PROJECT_ID="svelte-dev-80286"
VITE_FIREBASE_AUTH_DOMAIN="svelte-dev-80286.firebaseapp.com"
VITE_FIREBASE_STORAGE_BUCKET="svelte-dev-80286.appspot.com",
VITE_FIREBASE_MESSAGING_SENDER_ID="585552006025",
VITE_USE_AUTH_EMULATOR=false
VITE_USE_FIRESTORE_EMULATOR=false
The whole point of the .env
file is to put your firebaseConfig
keys in a file that doesn't contain application code. This makes it much easier for you to manage their security. But let's put this to one side for now so you can concentrate on more important things. I've added a note to the end of this post that I hope will explain everything.
A second feature that may puzzle you is the const app = !getApps().length ? initializeApp(firebaseConfig) : getApp();
line. This is an example of a Javascript "ternary" statement (get chatGPT to explain how it works, if you've not met this before). Its effect is to :
- Initialise a Firebase app only if no app is currently present in the Firebase environment. The webapp will be referencing
firebase-client.js
frequently during a user session andinitializeApp()
fails if anapp
already exists - Otherwise retrieve the existing app.
Back in the mainstream now, the effect of all this is that, when a login succeeds, Firebase creates an "auth" object for the authenticated user and squirrels this away securely in the browser's environment. Firebase can thus, automatically, add an authentication token generated from this to every request
object passed to a FireStore database service request. Information in the token includes properties such as the "user uID" identifier you saw earlier in the Firebase authentication tab. Consequently, Firestore can decide how to apply a rule such as allow write: if request.auth != null
.
This all means that client-side Firestore activity works like clockwork - once you're logged in, Firestore rules take care of all your database security concerns.
But there's a snag - a huge snag, in fact. The "auth" object that Firebase tucked into the browser environment isn't available to server-side pages like inventory-maintenance/+page.server.js
.
You can easily demonstrate this.
-
Publish new Firestore rules that let anybody read everything in the
products
collection but ensure only authenticated people can write documents
match /products/{document=**} { allow read: if true; allow write: if request.auth != null; }
Login using the
/login
route and receive a "you are logged in with Google" alert.Launch the
/products-display
page. Because the Firestore rules permit anyone to read everything. The page will thus display the current list of registered products, exactly as before.Try to add a new product with the
products-maintenance
page. Agh! Error!
Form submission failed! : Error is : 7 PERMISSION_DENIED: Missing or insufficient permissions
What's happened here is that the browser's "squirrelled" auth
variable and the rest of its parent Firebase session information is unavailable to Firestore on the server. Consequently request.auth
is undefined there and so the Firestore rule fails.
The position is that Google has declined to provide Firebase with a server-side version of the excellent session management facility that makes life so enjoyable client-side. Currently, your code has been using the Firestore Client API. On the server, where a Firestore database has set "rules" on a collection that references Firebase auth
session variables, you must use the Firestore Admin API rather than the Client API. Calls in the Admin API don't check Firestore rules, so don't fail when auth
is undefined. But using the Admin API has several consequences:
- the Admin API uses different "call signatures". The sequence of admin API functions you use to perform read/write database operations server-side is broadly similar to the client version but uses different syntax.
- the Admin API requires you to obtain any user data you might need by using code that you write yourself. The client-side Firebase session that conveniently provided the invaluable
auth.currentUser
object to deliveruID
anduserName
etc is no longer available server-side. You must make your own arrangements to provide a replacement.
Groan. Is there an alternative? The next two posts show:
- How you might duck the issue by developing a "Rules-friendly" version of your webapp. The consequence of this will be poorer performance (because server-side code runs faster), reduced security (because client-side form validation is insecure) and diminished SEO prospects (because web spiders have reduced enthusiasm for indexing client-side code)
- Alternatively, how you might battle on and develop a full "Client-server" version of your webapp. This will give you top-line performance, security and SEO but will require you to negotiate yet another wave of technology to plug the functionality gaps left by the loss of the Firebase user session arrangement
3. Conclusion
This has been a rough ride with an unhappy ending. However, I hope you will have the energy to read on.
From your point of view as a developer, Rules-friendly code will be a perfect delight because you can debug it entirely using the browser's Inspector tool. While it has limitations, as noted earlier, it could be perfectly satisfactory for many simple applications.
Alternatively, while the client-server version presents some new challenges, it will give you valuable experience in mainstream development practices and deliver truly remarkable performance.
Postscript - what's this ".env" business all about?
Bizarre as it may seem, this all starts with Microsoft's "Github" version-control software. This is a free webapp that lets you copy source copies into a personal web-based repository. It has become an industry standard and you can learn about it at A gentle introduction to git and Github.
Its main purpose is to integrate the activities of teams of developers working in parallel on the same project. But you might well be interested in using it because, during the development and ongoing enhancement of your personal projects, there will be times when you want to place a "checkpoint" snapshot of your code somewhere safe. .
The problem is that it is all too easy for sensitive keys embedded in source code to be inadvertently saved in a Git repository. While repositories can be marked as "private", security is not guaranteed.
One part of the answer is to arrange things so that project keys are held separately from code files. This way they don't need to be copied to Git. Placing your Firebase keys in your lib\utilities\firebase-client.js
file helped here because it meant that keys were no longer coded into multiple +page.server.js
files. But there was still code in the central firebase-client.js
that you would want to save in your repo. Using the env
file lets you finally disentangle code from keys. Although your .env
keys remain in your project, this file no longer needs to be copied to the code repository. It can, therefore, be added to the .gitignore
file that tells Git which files to exclude.
You'll find that Svelte created a .gitignore
file when it initialised your project and that it already contains details of Vite system folders that have no place in a source-code checkpoint. To make sure that a ".env" file (and any editing history for the file) is excluded from all Git commits you would add the following entries:
// .gitignore - fragment
# Firebase config keys
.env
.history/.env*
Note that, once you've done this, the /.env
line in your VSCode Workspace directory will become "greyed out". This provides visual assurance that this file will not be revealed on the web by a Git commit.
Posted on November 25, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.