Authentication system using rust (actix-web) and sveltekit - Enhancing the frontend application with form actions
Tags: webdev, javascript, svelte, tutorial
Published: 2023-05-02 17:45:30

## Introduction

We have so far built a typical semi-[SPA (Single-page application)][1] whose post requests are done via JavaScript and without it, such requests will not pass the test of time. This has made us heavily rely on the [Cross-Origin Resource Sharing (CORS)][2] configuration previously written. Without it, our carefully built and performant authentication system will just be another waste of effort. If you have noticed, whenever you refresh the page, your session cookie is gone and you must re-login before private routes can be accessed. Why build an application that is not resilient? What happens to an app that is resilient and feels natural while not losing interactivity? Rich Harris called such apps Transitional applications. Applications that are resilient (work w/o JavaScript), conform with web accessibility rules and standards, less buggy, SEO friendly, very interactive (if JavaScript is available) and without bloated JavaScript files. That's what we'll turn our frontend application to in this article using the powers of [SvelteKit Form Actions][3].

## Source code

The source code for this series is hosted on GitHub via:

{% github Sirneij/rust-auth %}

## Implementation

> You can get the overview of the code for this article on [github][4]. Some changes were also made directly to the repo's main branch. We will cover them.

### Step 1: Remove CORS configuration and `actix-cors` from the backend

The first step we need to take in this challenging journey is to ensure that no front-end "JavaScript-based" application can access our backend service. To do this, we'll disable or more appropriately remove their enabler which, in this case, is `actix-cors`:

```shell
~/rust-auth/backend$ cargo remove actix-cors
```

Then, remove the entire `.wrap` that houses its configuration from the `run` function in `backend/src/startup.rs`. We can now compile and run our application safely.

### Step 2: Use form actions for the login route

If you notice, our frontend application has lost communication channels with our backend service. Hence, the front-end is broken. Let's start fixing it.

We'll start with our app's login route. Let's create a `+page.server.ts` in `frontend/src/routes/auth/login/`. In SvelteKit, only `+page.server.ts` not `+page.ts` files can export form actions and form actions, w/o JavaScript, can send POST data to the server. Also, since `+page.server.ts` only runs on the server, there's no need to configure CORS at the backend. This is because CORS mainly affect the browser. Our `frontend/src/routes/auth/login/+page.server.ts` should look like this:

```ts
// frontend/src/routes/auth/login/+page.server.ts

import { fail, type Actions, redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import type { CustomError, LoginRequestBody } from '$lib/utils/types';
import { BASE_API_URI } from '$lib/utils/constant';

export const load: PageServerLoad = async ({ locals }) => {
    // redirect user if logged in
    if (locals.user) {
        throw redirect(302, '/');
    }
};

export const actions: Actions = {
    /**
     *
     * @param request - The request object
     * @param fetch - Fetch object from sveltekit
     * @param cookies - SvelteKit's cookie object
     * @returns Error data or redirects user to the home page or the previous page
     */
    login: async ({ request, fetch, cookies }) => {
        const formData = await request.formData();
        const email = String(formData.get('email'));
        const password = String(formData.get('password'));
        const next = String(formData.get('next'));

        const login: LoginRequestBody = {
            email,
            password
        };

        const apiURL = `${BASE_API_URI}/users/login/`;

        const requestInitOptions: RequestInit = {
            method: 'POST',
            credentials: 'include',
            headers: {
                'Content-Type': 'application/json'
            },
            body: JSON.stringify(login)
        };

        const res = await fetch(apiURL, requestInitOptions);

        if (!res.ok) {
            const response = await res.json();
            const errors: Array<CustomError> = [];
            errors.push({ error: response.error, id: 0 });
            return fail(400, { errors: errors });
        }

        for (const header of res.headers) {
            if (header[0] === 'set-cookie') {
                cookies.set('id', header[1].split('=')[1].split(';')[0], {
                    httpOnly: true,
                    sameSite: 'lax',
                    path: '/',
                    secure: true
                });

                break;
            }
        }

        throw redirect(303, next || '/');
    }
};
```

The file starts with some imports, followed by a `load` function whose only job, for now, is to prevent authenticated users from accessing the login page. Then, we exported a NAMED form action. We could have used the default but opted to be more explicit. This NAMED action, `login`, takes `request`, `fetch`, and `cookies` which were destructed from `event` &mdash; the single argument a form action takes.

In the login method, we retrieved the data from the form in `frontend/src/routes/auth/login/+page.svelte`. These data were deconstructed to build a JSON-compatible `LoginRequestBody` type since form actions only provide [FormData][5]. Notice a third field, `next`, from our form. This field stores the next page to be redirected to in case there was a page a user previously wanted to access but was redirected to the login page due to not being authenticated. Then, we formed and made a request to our backend service. If the response from the server was not as we expected, we used SvelteKit's `fail` to return a failure with a formed error response from the server. If otherwise, we searched through the response headers, retrieved its `set-cookie` and set the cookie. After that, we redirected the user to the "next" page or to the home page.

Next is `frontend/src/routes/auth/login/+page.svelte`:

```html
<!-- frontend/src/routes/auth/login/+page.svelte -->
<script lang="ts">
    import { applyAction, enhance, type SubmitFunction } from '$app/forms';
    import { loading } from '$lib/stores/loading.store';
    import { notification } from '$lib/stores/notification.store';
    import { happyEmoji } from '$lib/utils/constant';
    import type { ActionData } from './$types';
    import { receive, send } from '$lib/utils/helpers/animate.crossfade';
    import { page } from '$app/stores';
    export let form: ActionData;
    const handleLogin: SubmitFunction = async () => {
        loading.setLoading(true, 'Please wait while we log you in...');
        return async ({ result }) => {
            loading.setLoading(false);
            if (result.type === 'success' || result.type === 'redirect') {
                $notification = {
                    message: `Login successful ${happyEmoji}...`,
                    colorName: `emerald`
                };
            }
            await applyAction(result);
        };
    };
</script>

<svelte:head>
    <title>Auth - Login | Actix Web & SvelteKit</title>
</svelte:head>

<form class="form" method="POST" action="?/login" use:enhance={handleLogin}>
    <h1 style="text-align:center">Login</h1>

    {#if form?.errors}
        {#each form?.errors as error (error.id)}
            <p
                class="text-center text-rose-600"
                in:receive={{ key: error.id }}
                out:send={{ key: error.id }}
            >
                {error.error}
            </p>
        {/each}
    {/if}

    <input type="hidden" name="next" value={$page.url.searchParams.get('next')} />

    <input type="email" name="email" id="email" placeholder="Email address" required />

    <input type="password" name="password" id="password" placeholder="Password" required />

    <span style="display:block; text-align: right; margin-bottom: 0.5rem">
        <a href={null} class="text-sm text-slate-400"> Forgot password? </a>
    </span>

    <button type="submit" class="btn"> Login </button>

    <span class="text-sm text-sky-400" style="display:block; text-align: center; margin-top: 0.5rem">
        No account?
        <a href="/auth/register" class="ml-2 text-slate-400"> Create an account. </a>
    </span>
</form>
```

This page has been reduced in length having shortened `handleLogin` and turned it into a [`SubmitFunction`][6] and removed the long tailwind CSS classes. The forms now use the styles in `frontend/src/lib/css/styles.css`. `handleLogin` is a custom function utilized by SvelteKit's [`use:enhance`][7] to [progressively enhance][8] our form so that if JavaScript is enabled or available, it will be as interactive as we want it. In the method, we enabled our loading animation while waiting for a response from our form action. If the form's return type, `result` type to be precise, is either a `success` or `redirect`, we notify the user using our notification store. Regardless, we used [applyAction][9] to update the entire application in case some of its data have changed. You can achieve this with `update` but customization will be lost.

Just before `handleLogin`, we exported `form`, an `ActionData`, that exposes whatever happens in our form actions. Using it, we displayed any errors from the form actions and any other data that we might have sent to the page. The form action and the `use:enhance` we talked about were used here:

```html
...
<form class="form" method="POST" action="?/login" use:enhance={handleLogin}>
...
```

A simple form element with a "POST" method (very important regardless of whether or not you are doing a "PUT", "PATCH" or "DELETE" request). You can use a "GET" but it's for a [different scenario][10]. Then, we set its `action` attribute to `?/login`. `login` is the name of our form action. If we hadn't used a NAMED form action, we wouldn't have set the `action` attribute at all. Notice the difference. Next, we fed our `handleLogin` to `use:enhance`. This brings back our form interactivity. You don't need to always feed `use:enhance` with a function if you have no custom things to do to make your form interactive.

This hidden input element:

```html
...
<input type="hidden" name="next" value={$page.url.searchParams.get('next')} />
...
```

helps store the possible next page we'll redirect to after a successful login process.

This is it. We now have a resilient login form and the concepts learned here are all we need to make other features resilient!

### Step 3: Persist user sessions regardless of any page refresh

As pointed out in the introduction, as soon as a user refreshes the page in our current application, such a user's session is destroyed and a new one is required. We need to change this to as long as the current user has a valid cookie and hasn't logged out, thereby destroying such a session, such a user should remain logged in regardless of (hard) page refresh. To achieve this, we will be using yet another feature of SvelteKit, called [Hooks][11]. Declaimer: it's different from React hooks. In SvelteKit, Hooks are app-wide and are used for a couple of things. However, we will use SvelteKit's [handle][12] hook to intercept every request the app receives and before fulfilling such requests, checks the browser whether or not there's a particular cookie. If there isn't, fulfil the request swiftly. If there is, however, retrieves the cookie and sends it to the backend which will in turn, if everything goes as planned and the cookie is valid, respond with a user who has such a cookie and such a user will be saved in the app's [locals][13] &mdash; the abode of custom request data. This entire modus operandi equates to the following snippet:

```ts
// frontend/src/hooks.server.ts
import { BASE_API_URI } from '$lib/utils/constant';
import type { User } from '$lib/utils/types';
import type { Handle } from '@sveltejs/kit';

export const handle: Handle = async ({ event, resolve }) => {
    // get cookies from browser
    const session = event.cookies.get('id');

    if (!session) {
        // if there is no session load page as normal
        return await resolve(event);
    }

    // find the user based on the session
    const res = await event.fetch(`${BASE_API_URI}/users/current-user/`, {
        credentials: 'include',
        headers: {
            Cookie: `sessionid=${session}`
        }
    });

    if (!res.ok) {
        // if there is no session load page as normal
        return await resolve(event);
    }

    // if `user` exists set `events.local`
    const response: User = (await res.json()) as User;

    event.locals.user = response;

    // load page as normal
    return await resolve(event);
};

```

To make this user data available to all other pages, we will propagate it with `+layout.server.ts`:

```ts
// frontend/src/routes/+layout.server.ts
import type { LayoutServerLoad } from './$types';

// get `locals.user` and pass it to the `page` store
export const load: LayoutServerLoad = async ({ locals }) => {
    return {
        user: locals.user
    };
};
```

Since we have a `+layout.ts` file already, the `user` data returned from its "server" counterpart, or more precisely, "parent" will NOT be made available unless we ask it to:

```ts
// frontend/src/routes/+layout.ts
import type { LayoutLoad } from './$types';

export const load: LayoutLoad = async ({ parent, data, fetch, url }) => {
    await parent();
    const { user } = data;
    return { fetch, url: url.pathname, user };
};
```

We first awaited its parent, `+layout.server.ts`, and destructure `user` out of the `data` attribute which was then made available! Voila! Every page now has the `user` data via `$page.data.user`. Remember the [page store][14] right?

That's all we need to do to persist user data. Now, we can safely delete `loggedInUser` and `isAuthenticated` stores!!!

> CAVEAT: Ensure the stored cookies have `httpOnly` and `secure` set to `true` for security reasons. You can go a step further by setting `sameSite` to either `Lax` or `Strict`. The latter was not tested by me yet.

### Step 4: Update `logout` logic

Currently, in `frontend/src/lib/component/Header/Header.svelte`, we have a button with an `on:click` event attached to a `logout` function defined in `frontend/src/lib/utils/requests/logout.requests.ts`. In the spirit of web standards and to continue our resolve to enhance our application, we will replace that button with a form:

```html
<!-- frontend/src/lib/component/Header/Header.svelte -->
...
<form action="/auth/logout" method="POST" use:enhance={handleLogout}>
    <button type="submit" class="text-white hover:text-sky-400 logout">Logout</button>
</form>
...
```

The form has an action pointed to `/auth/logout` &mdash; a new route we'll create soon. It also has `use:enhance` attribute whose value is the `handleLogout`:

```html
<!-- frontend/src/lib/component/Header/Header.svelte -->
<script lang="ts">
    ...
    const handleLogout: SubmitFunction = () => {
        loading.setLoading(true, 'Please wait while we log you out...');
        return async ({ result }) => {
            loading.setLoading(false);
            if (result.type === 'success' || result.type === 'redirect') {
                $notification = {
                    message: `Logout successfull ${happyEmoji}...`,
                    colorName: `emerald`
                };
            }
            await applyAction(result);
        };
    };
    ...
<script>
```

Quite identical to `handleLogin`. Now, we need to create the logout route which that form action pointed to. In the route, create a `+page.server.ts` file:

```ts
// frontend/src/routes/auth/logout/+page.server.ts
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { BASE_API_URI } from '$lib/utils/constant';
import type { CustomError } from '$lib/utils/types';

export const load: PageServerLoad = async ({ locals }) => {
    // redirect user if not logged in
    if (!locals.user) {
        throw redirect(302, `/auth/login?next=/auth/logout`);
    }
};

export const actions: Actions = {
    default: async ({ fetch, cookies }) => {
        const requestInitOptions: RequestInit = {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                Cookie: `sessionid=${cookies.get('id')}`
            }
        };

        const res = await fetch(`${BASE_API_URI}/users/logout/`, requestInitOptions);

        if (!res.ok) {
            const response = await res.json();
            const errors: Array<CustomError> = [];
            errors.push({ error: response.error, id: 0 });
            return fail(400, { errors: errors });
        }

        // eat the cookie
        cookies.delete('id', { path: '/' });

        // redirect the user
        throw redirect(302, '/auth/login');
    }
};
```

First, we ensured that only authenticated users could access the route using the `load` function. Then, we defined the DEFAULT form action which simply sends our cookie to the backend. If that's successful, we destroy &mdash; in a literal terms, eat &mdash; such a cookie. Any request made afterwards with such a cookie will be denied! Default form action was used here because the action attribute's value on the form was the logout route. If we were to use a NAMED form action, the form's action attribute would be `action="/auth/logout?/<namw_of_form_action>`.

Now we can log out!!!

### Step 4: Extend form actions to the `register` and `about` routes

There is nothing new with these two routes. The concepts required to build them have been learned. For completeness, I will put the contents of their respective `+page.server.ts` files here:

```ts
// frontend/src/routes/auth/register/+page.server.ts

import { redirect, type Actions, fail } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { BASE_API_URI } from '$lib/utils/constant';
import type { CustomError, RegisterRequestBody } from '$lib/utils/types';
import { isValidEmail, isValidPasswordMedium } from '$lib/utils/helpers/input.validation';
import { isEmpty } from '$lib/utils/helpers/test.object.empty';

export const load: PageServerLoad = async ({ locals }) => {
    // redirect user if logged in
    if (locals.user) {
        throw redirect(302, '/');
    }
};

export const actions: Actions = {
    /**
     *
     * @param request - The request object
     * @param fetch - Fetch object from sveltekit
     * @returns Error data or redirects user to the home page or the previous page
     */
    register: async ({ request, fetch }) => {
        const formData = await request.formData();
        const email = String(formData.get('email'));
        const firstName = String(formData.get('first_name'));
        const lastName = String(formData.get('last_name'));
        const password = String(formData.get('password'));
        const confirmPassword = String(formData.get('confirm_password'));

        // Some validations
        const fieldsError: Record<string, string> = {};
        if (!isValidEmail(email)) {
            fieldsError.email = 'That email address is invalid.';
        }
        if (!isValidPasswordMedium(password)) {
            fieldsError.password =
                'Password is not valid. Password must contain six characters or more and has at least one lowercase and one uppercase alphabetical character or has at least one lowercase and one numeric character or has at least one uppercase and one numeric character.';
        }
        if (confirmPassword.trim() !== password.trim()) {
            fieldsError.confirmPassword = 'Password and confirm password do not match.';
        }

        if (!isEmpty(fieldsError)) {
            return fail(400, { fieldsError: fieldsError });
        }

        const registrationBody: RegisterRequestBody = {
            email,
            first_name: firstName,
            last_name: lastName,
            password
        };

        const apiURL = `${BASE_API_URI}/users/register/`;

        const requestInitOptions: RequestInit = {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json'
            },
            body: JSON.stringify(registrationBody)
        };

        const res = await fetch(apiURL, requestInitOptions);

        if (!res.ok) {
            const response = await res.json();
            const errors: Array<CustomError> = [];
            errors.push({ error: response.error, id: 0 });
            return fail(400, { errors: errors });
        }

        const response = await res.json();

        throw redirect(303, `/auth/confirming?message=${response.message}`);
    }
};
```

And:

```ts
// frontend/src/routes/auth/about/[id]/+page.server.ts

import { redirect, type Actions, fail } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { BASE_API_URI, IMAGE_UPLOAD_SIZE } from '$lib/utils/constant';
import type { CustomError, User } from '$lib/utils/types';
import { returnFileSize } from '$lib/utils/helpers/image.file.size';

export const load: PageServerLoad = async ({ locals, params }) => {
    // redirect user if not logged in
    if (!locals.user) {
        throw redirect(302, `/auth/login?next=/auth/about/${params.id}`);
    }
};

export const actions: Actions = {
    /**
     *
     * @param request - The request object
     * @param fetch - Fetch object from sveltekit
     * @param cookies - SvelteKit's cookie object
     * @param locals - The local object, housing current user
     * @returns Error data or redirects user to the home page or the previous page
     */
    updateUser: async ({ request, fetch, cookies, locals }) => {
        const formData = await request.formData();

        // Some validations
        const errors: Array<CustomError> = [];

        // Ensure that the file is not too big
        if (formData.get('thumbnail')) {
            const file = formData.get('thumbnail') as File;
            if (file.size <= 0) {
                formData.delete('thumbnail');
            } else {
                const [size, isValid] = returnFileSize(file.size);

                if (!isValid) {
                    errors.push({
                        id: Math.floor(Math.random() * 100),
                        error: `Image size ${size} is too large. Please keep it below ${IMAGE_UPLOAD_SIZE}kB.`
                    });
                    formData.delete('thumbnail');
                }
            }
        }
        // Ensure that first_name is different from the current one
        if (formData.get('first_name')) {
            const firstName = formData.get('first_name');
            if (firstName === locals.user.first_name || firstName === '') {
                formData.delete('first_name');
            }
        }
        // Ensure that last_name is different from the current one
        if (formData.get('last_name')) {
            const lastName = formData.get('last_name');
            if (lastName === locals.user.last_name || lastName === '') {
                formData.delete('last_name');
            }
        }

        if (errors.length > 0) {
            return fail(400, { errors: errors });
        }

        const apiURL = `${BASE_API_URI}/users/update-user/`;

        const res = await fetch(apiURL, {
            method: 'PATCH',
            headers: {
                Cookie: `sessionid=${cookies.get('id')}`
            },
            body: formData
        });

        if (!res.ok) {
            const response = await res.json();
            const errors: Array<CustomError> = [];
            errors.push({ error: response.error, id: Math.floor(Math.random() * 100) });
            return fail(400, { errors: errors });
        }

        const response = (await res.json()) as User;

        locals.user = response;

        throw redirect(303, `/auth/about/${response.id}`);
    }
};
```

Just the regular form validations and, upon successful requests to the server, accompanying redirections.

> NOTE: In the main repository, we made changes to `update_user.rs` and some utils in the `backend` app. We also updated some of the routes `frontend` to reflect the overall changes we made. Kindly compare your version of the code with that in the main branch.

With these, we have made a truly transitional application that is resilient, performant, SEO friendly, obeys web accessibility rules, and conforms to web standards, among others.

I welcome gigs, comments, criticisms (constructive), collaborations and all other nifty stuff... See you in the next article where we'll implement activation token regeneration and some changing users' passwords.

## Outro

Enjoyed this article? Consider [contacting me for a job, something worthwhile or buying a coffee ☕](mailto:sirneij@gmail.com). You can also connect with/follow me on [LinkedIn](https://www.linkedin.com/in/idogun-john-nelson/) and [Twitter](https://twitter.com/Sirneij). It isn't bad if you help share this article for wider coverage. I will appreciate it...

[1]: https://developer.mozilla.org/en-US/docs/Glossary/SPA "SPA (Single-page application)"
[2]: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS "Cross-Origin Resource Sharing (CORS)"
[3]: https://kit.svelte.dev/docs/form-actions "Form actions"
[4]: https://github.com/Sirneij/rust-auth/pull/11/commits/e9834512c083d39c8bece5ca157ec6e299182b9e "Enhance frontend application"
[5]: https://developer.mozilla.org/en-US/docs/Web/API/FormData/FormData "FormData: FormData() constructor"
[6]: https://kit.svelte.dev/docs/types#public-types-submitfunction "SubmitFunction"
[7]: https://kit.svelte.dev/docs/form-actions#progressive-enhancement-use-enhance "use:enhance"
[8]: https://kit.svelte.dev/docs/form-actions#progressive-enhancement "Progressive enhancement"
[9]: https://kit.svelte.dev/docs/form-actions#progressive-enhancement-applyaction "applyAction"
[10]: https://kit.svelte.dev/docs/form-actions#get-vs-post "GET vs POST"
[11]: https://kit.svelte.dev/docs/hooks "Hooks"
[12]: https://kit.svelte.dev/docs/hooks#server-hooks-handle "Server hooks"
[13]: https://kit.svelte.dev/docs/types#app-locals "Locals"
[14]: https://kit.svelte.dev/docs/modules#$app-stores-page "Page store"