We have so far built a typical semi-SPA (Single-page application) 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) 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.
Source code
The source code for this series is hosted on GitHub via:
After that, change directory into each subdirectory: backend and frontend in different terminals. Then following the instructions in each subdirectory to run them.
You can get the overview of the code for this article on github. 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:
~/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:
// frontend/src/routes/auth/login/+page.server.tsimport{fail,typeActions,redirect}from'@sveltejs/kit';importtype{PageServerLoad}from'./$types';importtype{CustomError,LoginRequestBody}from'$lib/utils/types';import{BASE_API_URI}from'$lib/utils/constant';exportconstload:PageServerLoad=async ({locals})=>{// redirect user if logged inif (locals.user){throwredirect(302,'/');}};exportconstactions: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})=>{constformData=awaitrequest.formData();constemail=String(formData.get('email'));constpassword=String(formData.get('password'));constnext=String(formData.get('next'));constlogin:LoginRequestBody={email,password};constapiURL=`${BASE_API_URI}/users/login/`;constrequestInitOptions:RequestInit={method:'POST',credentials:'include',headers:{'Content-Type':'application/json'},body:JSON.stringify(login)};constres=awaitfetch(apiURL,requestInitOptions);if (!res.ok){constresponse=awaitres.json();consterrors:Array<CustomError>=[];errors.push({error:response.error,id:0});returnfail(400,{errors:errors});}for (constheaderofres.headers){if (header[0]==='set-cookie'){cookies.set('id',header[1].split('=')[1].split(';')[0],{httpOnly:true,sameSite:'lax',path:'/',secure:true});break;}}throwredirect(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 — 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. 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:
<!-- frontend/src/routes/auth/login/+page.svelte --><script lang="ts">import{applyAction,enhance,typeSubmitFunction}from'$app/forms';import{loading}from'$lib/stores/loading.store';import{notification}from'$lib/stores/notification.store';import{happyEmoji}from'$lib/utils/constant';importtype{ActionData}from'./$types';import{receive,send}from'$lib/utils/helpers/animate.crossfade';import{page}from'$app/stores';exportletform:ActionData;consthandleLogin:SubmitFunction=async ()=>{loading.setLoading(true,'Please wait while we log you in...');returnasync ({result})=>{loading.setLoading(false);if (result.type==='success'||result.type==='redirect'){$notification={message:`Login successful ${happyEmoji}...`,colorName:`emerald`};}awaitapplyAction(result);};};</script><svelte:head><title>Auth - Login | Actix Web & SvelteKit</title></svelte:head><formclass="form"method="POST"action="?/login"use:enhance={handleLogin}><h1style="text-align:center">Login</h1>
{#if form?.errors}
{#each form?.errors as error (error.id)}
<pclass="text-center text-rose-600"in:receive={{key:error.id}}out:send={{key:error.id}}>
{error.error}
</p>
{/each}
{/if}
<inputtype="hidden"name="next"value={$page.url.searchParams.get('next')}/><inputtype="email"name="email"id="email"placeholder="Email address"required/><inputtype="password"name="password"id="password"placeholder="Password"required/><spanstyle="display:block; text-align: right; margin-bottom: 0.5rem"><ahref={null}class="text-sm text-slate-400"> Forgot password? </a></span><buttontype="submit"class="btn"> Login </button><spanclass="text-sm text-sky-400"style="display:block; text-align: center; margin-top: 0.5rem">
No account?
<ahref="/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 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 to progressively enhance 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 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:
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. 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.
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. 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 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 — the abode of custom request data. This entire modus operandi equates to the following snippet:
// frontend/src/hooks.server.tsimport{BASE_API_URI}from'$lib/utils/constant';importtype{User}from'$lib/utils/types';importtype{Handle}from'@sveltejs/kit';exportconsthandle:Handle=async ({event,resolve})=>{// get cookies from browserconstsession=event.cookies.get('id');if (!session){// if there is no session load page as normalreturnawaitresolve(event);}// find the user based on the sessionconstres=awaitevent.fetch(`${BASE_API_URI}/users/current-user/`,{credentials:'include',headers:{Cookie:`sessionid=${session}`}});if (!res.ok){// if there is no session load page as normalreturnawaitresolve(event);}// if `user` exists set `events.local`constresponse:User=(awaitres.json())asUser;event.locals.user=response;// load page as normalreturnawaitresolve(event);};
To make this user data available to all other pages, we will propagate it with +layout.server.ts:
// frontend/src/routes/+layout.server.tsimporttype{LayoutServerLoad}from'./$types';// get `locals.user` and pass it to the `page` storeexportconstload: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:
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 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:
The form has an action pointed to /auth/logout — a new route we'll create soon. It also has use:enhance attribute whose value is the handleLogout:
<!-- frontend/src/lib/component/Header/Header.svelte --><script lang="ts">...consthandleLogout:SubmitFunction=()=>{loading.setLoading(true,'Please wait while we log you out...');returnasync ({result})=>{loading.setLoading(false);if (result.type==='success'||result.type==='redirect'){$notification={message:`Logout successfull ${happyEmoji}...`,colorName:`emerald`};}awaitapplyAction(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:
// frontend/src/routes/auth/logout/+page.server.tsimport{fail,redirect}from'@sveltejs/kit';importtype{Actions,PageServerLoad}from'./$types';import{BASE_API_URI}from'$lib/utils/constant';importtype{CustomError}from'$lib/utils/types';exportconstload:PageServerLoad=async ({locals})=>{// redirect user if not logged inif (!locals.user){throwredirect(302,`/auth/login?next=/auth/logout`);}};exportconstactions:Actions={default:async ({fetch,cookies})=>{constrequestInitOptions:RequestInit={method:'POST',headers:{'Content-Type':'application/json',Cookie:`sessionid=${cookies.get('id')}`}};constres=awaitfetch(`${BASE_API_URI}/users/logout/`,requestInitOptions);if (!res.ok){constresponse=awaitres.json();consterrors:Array<CustomError>=[];errors.push({error:response.error,id:0});returnfail(400,{errors:errors});}// eat the cookiecookies.delete('id',{path:'/'});// redirect the userthrowredirect(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 — in a literal terms, eat — 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:
// frontend/src/routes/auth/register/+page.server.tsimport{redirect,typeActions,fail}from'@sveltejs/kit';importtype{PageServerLoad}from'./$types';import{BASE_API_URI}from'$lib/utils/constant';importtype{CustomError,RegisterRequestBody}from'$lib/utils/types';import{isValidEmail,isValidPasswordMedium}from'$lib/utils/helpers/input.validation';import{isEmpty}from'$lib/utils/helpers/test.object.empty';exportconstload:PageServerLoad=async ({locals})=>{// redirect user if logged inif (locals.user){throwredirect(302,'/');}};exportconstactions: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})=>{constformData=awaitrequest.formData();constemail=String(formData.get('email'));constfirstName=String(formData.get('first_name'));constlastName=String(formData.get('last_name'));constpassword=String(formData.get('password'));constconfirmPassword=String(formData.get('confirm_password'));// Some validationsconstfieldsError: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)){returnfail(400,{fieldsError:fieldsError});}constregistrationBody:RegisterRequestBody={email,first_name:firstName,last_name:lastName,password};constapiURL=`${BASE_API_URI}/users/register/`;constrequestInitOptions:RequestInit={method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(registrationBody)};constres=awaitfetch(apiURL,requestInitOptions);if (!res.ok){constresponse=awaitres.json();consterrors:Array<CustomError>=[];errors.push({error:response.error,id:0});returnfail(400,{errors:errors});}constresponse=awaitres.json();throwredirect(303,`/auth/confirming?message=${response.message}`);}};
And:
// frontend/src/routes/auth/about/[id]/+page.server.tsimport{redirect,typeActions,fail}from'@sveltejs/kit';importtype{PageServerLoad}from'./$types';import{BASE_API_URI,IMAGE_UPLOAD_SIZE}from'$lib/utils/constant';importtype{CustomError,User}from'$lib/utils/types';import{returnFileSize}from'$lib/utils/helpers/image.file.size';exportconstload:PageServerLoad=async ({locals,params})=>{// redirect user if not logged inif (!locals.user){throwredirect(302,`/auth/login?next=/auth/about/${params.id}`);}};exportconstactions: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})=>{constformData=awaitrequest.formData();// Some validationsconsterrors:Array<CustomError>=[];// Ensure that the file is not too bigif (formData.get('thumbnail')){constfile=formData.get('thumbnail')asFile;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 oneif (formData.get('first_name')){constfirstName=formData.get('first_name');if (firstName===locals.user.first_name||firstName===''){formData.delete('first_name');}}// Ensure that last_name is different from the current oneif (formData.get('last_name')){constlastName=formData.get('last_name');if (lastName===locals.user.last_name||lastName===''){formData.delete('last_name');}}if (errors.length>0){returnfail(400,{errors:errors});}constapiURL=`${BASE_API_URI}/users/update-user/`;constres=awaitfetch(apiURL,{method:'PATCH',headers:{Cookie:`sessionid=${cookies.get('id')}`},body:formData});if (!res.ok){constresponse=awaitres.json();consterrors:Array<CustomError>=[];errors.push({error:response.error,id:Math.floor(Math.random()*100)});returnfail(400,{errors:errors});}constresponse=(awaitres.json())asUser;locals.user=response;throwredirect(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.