Svelte and GraphQL with Authentication
Tags: svelte, graphql
Published: 2024-06-21 15:00:44

You will learn,

- How to get the schema from the backend?
- How to use a code generation tool with Typescript?
- How to make a GraphQL API request?
- How to handle expired JWT token?

Lets dive in!

## Tools we will be using:

**urql**

This is the GraphQL client we will be using to make the API request possible.
There are other alternatives like `houdini` and `apollo-client` but after struggling with them I found urql package to be much better.

{% embed https://formidable.com/open-source/urql/docs/basics/svelte/?source=post_page-----bf3a32eee5bb-------------------------------- %}

**urql/exchange-auth**

This package or an addon acts like the request interceptor which will help us in renewing the expired token.

{% embed https://formidable.com/open-source/urql/docs/api/auth-exchange/?source=post_page-----bf3a32eee5bb-------------------------------- %}

**graphql-codegen/cli & graphql-codegen/client-preset**

This is used for codegen for a typescript project.

{% embed https://formidable.com/open-source/urql/docs/basics/typescript-integration/?source=post_page-----bf3a32eee5bb-------------------------------- %}


Additionally, we will be using:

- **Tailwindcss** — For CSS styling.
- **jwt-decode** — To extract information stored in the JWT token.

## Files and Folder structure

```
src
  - lib
      - components
      - graphql
      - models
      - services
      - utils
      - index.ts
  - routes
      - login
  - stores
  app.css
  app.d.ts
  app.html
codegen.ts
vite.config.ts
tailwind.config.js
```

## Create a Svelte project with Typescript.

Choose options below after running the command:

- A Skeleton project
- Yes, using TypeScript syntax
- Add Prettier for code formatting

```bash
npm create svelte@latest your_app_name
```

## Installing dependencies

```json
{
    "name": "svelte-app",
    "version": "0.0.1",
    "private": true,
    "scripts": {
        "dev": "vite dev",
        "build": "vite build",
        "preview": "vite preview",
        "codegen": "graphql-codegen",
        "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
        "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
        "lint": "prettier --plugin-search-dir . --check . && eslint .",
        "format": "prettier --plugin-search-dir . --write ."
    },
    "devDependencies": {
        "@graphql-codegen/cli": "^5.0.0",
        "@graphql-codegen/client-preset": "^4.1.0",
        "@sveltejs/adapter-auto": "^2.1.1",
        "@sveltejs/kit": "^1.27.6",
        "@typescript-eslint/eslint-plugin": "^6.12.0",
        "@typescript-eslint/parser": "^6.12.0",
        "autoprefixer": "^10.4.16",
        "eslint": "^8.54.0",
        "eslint-config-prettier": "^9.0.0",
        "eslint-plugin-svelte": "^2.35.1",
        "graphql": "^16.8.1",
        "postcss": "^8.4.31",
        "prettier": "^3.1.0",
        "prettier-plugin-svelte": "^3.1.2",
        "svelte": "^4.2.7",
        "svelte-check": "^3.6.2",
        "tailwindcss": "^3.3.5",
        "tslib": "^2.6.2",
        "typescript": "^5.3.3",
        "vite": "^5.0.2"
    },
    "type": "module",
    "dependencies": {
        "@urql/exchange-auth": "^2.1.6",
        "@urql/svelte": "^4.0.4",
        "jwt-decode": "^4.0.0",
        "svelte-use-form": "^2.10.0"
    }
}
```

## Configuring code generation (for Typescript)

Create a _codegen.ts_ file in the root of the project and add the following content.

Replace base url on the `schema` property below. Depending on the env introspection can be enabled or disabled in the backend. By default introspection should be enabled. 

```typescript
// codegen.ts

import { CodegenConfig } from '@graphql-codegen/cli';

const config: CodegenConfig = {
    schema: 'http://localhost:8000/graphql',
    documents: ['src/**/*.svelte', 'src/**/*.ts'],
    ignoreNoDocuments: true,
    generates: {
        './src/lib/graphql/': { // Folder to store downloaded GrapqhQL Schema.
            preset: 'client',
            plugins: [],
        },
    },
};

export default config;
```

## Configuring Tailwindcss
Create _tailwind.config.js_ in the root of your project and add the following content.

```javascript
// tailwind.config.js

/** @type {import('tailwindcss').Config} */
export default {
 content: ['./src/**/*.{html,js,svelte,ts}'],
 theme: {},
 plugins: []
};
```

Now import tailwindcss in the _**src/app.css**_ file.

```css
* src/app.css */

@tailwind base;
@tailwind components;
@tailwind utilities;
```

## Configuring Storage for Auth Tokens
Create _local-storage.service.ts_ to store the auth tokens.

> We are using try-catch block to prevent errors when Svelte tries to access localStorage in the server. As localStorage is only available in the browser.

```typescript
// src/lib/services/local-storage.service.ts

export const accessTokenKey = "access_token";
export const refreshTokenKey = "refresh_token";

export function saveAuthTokens(accessToken: string, refreshToken: string) {
    localStorage.setItem(accessTokenKey, accessToken);
    localStorage.setItem(refreshTokenKey, refreshToken);
}

export function getAccessToken(): string | null {
    try {
        return localStorage.getItem(accessTokenKey);
    } catch {
        return null;
    }
}

export function getRefreshToken(): string | null {
    try {
        return localStorage.getItem(refreshTokenKey);
    } catch {
        return null;
    }
}

export function saveNewAccessToken(newToken: string) {
    localStorage.setItem(accessTokenKey, newToken);
}

export function clearAuthTokens(): void {
    localStorage.clear()
}
```

## Dowloading our API schema from the backend

Recheck _package.json_ file above if you encounter any issues here.

```bash
yarn codegen
```

## Adding our GraphQL endpoints

Get the Auth Queries defined in the backend.

```typescript
// src/lib/graphql/queries/auth.ts

import { gql } from "@urql/svelte"

import type { AuthMutationsRenewTokenArgs, AuthQueriesLoginArgs, LoginSuccess, LogoutSuccess, RenewTokenSuccess } from "../graphql"

export const LOGIN_QUERY = gql<{ auth: { login: LoginSuccess } }, AuthQueriesLoginArgs>`
    query Login($input: LoginInput!) {
        auth {
            login(input: $input) {
                ... on LoginSuccess {
                    __typename
                    accessToken
                    refreshToken
                }
            }
        }
    }
`

export const RENEW_TOKEN_MUTATION = gql<{ auth: { renewToken: RenewTokenSuccess } }, AuthMutationsRenewTokenArgs>`
    mutation RenewAccessToken($input: RenewTokenInput!) {
        auth {
            renewToken(input: $input) {
                ... on RenewTokenSuccess {
                    __typename
                    newAccessToken
                }
            }
        }
    }
`

export const LOGOUT_MUTATION = gql<{ auth: { logout: LogoutSuccess } }, { accessToken: string }>`
    mutation Logout($accessToken: String!) {
        auth {
            logout(accessToken: $accessToken) {
                ... on LogoutSuccess {
                    __typename
                    success
                }
            }
        }
    }
`
```

## Configuring Urql client
Update _vite.config.ts_ as shown below.

```javascript
// vite.config.ts

import { sveltekit } from '@sveltejs/kit/vite'
import { defineConfig } from 'vite'

export default defineConfig({
 plugins: [sveltekit()],
 optimizeDeps: {
  exclude: ['@urql/svelte'],
 }
});
```

Now add the _**base.service.ts**_ file to configure the Urql client.
This is also where we will renew the access-token when required.

```typescript
import { goto } from "$app/navigation";
import { jwtDecode } from "jwt-decode";
import { dev } from "$app/environment";
import { Client, cacheExchange, fetchExchange } from "@urql/svelte";
import { authExchange, type AuthUtilities } from "@urql/exchange-auth";

import { RENEW_TOKEN_MUTATION } from "$lib/graphql/queries/auth";
import { clearAuthTokens, getAccessToken, getRefreshToken, saveNewAccessToken } from "./local-storage.service";

const auth = authExchange(async (utilities: AuthUtilities) => {
  let token = getAccessToken();
  let refreshToken = getRefreshToken();
  return {
    addAuthToOperation(operation) {
      return token
        ? utilities.appendHeaders(operation, {
            Authorization: `Bearer ${token}`,
          })
        : operation;
    },
    didAuthError(error) {
      // Check for "Forbidden" error response, caused when access token has expired.
      return error.response?.status === 403;
    },
    willAuthError() {
      // Sync tokens on every operation
      token = getAccessToken();
      refreshToken = getRefreshToken();

      if (token) {
        // If JWT has expired then run the refreshAuth func.
        const { exp } = jwtDecode(token);
        if (Date.now() >= exp! * 1000) return true;
      }

      return false;
    },
    async refreshAuth() {
      // Clear the token for refresh token API to be called without any issues.
      // addAuthToOperation will fail if token is not set to null.
      token = null;

      if (refreshToken) {
        try {
          const result = await utilities.mutate(RENEW_TOKEN_MUTATION, {
            input: { refreshToken },
          });
          if (result.error) {
            clearAuthTokens();
            goto("/login");
          } else if (result.data?.auth.renewToken.newAccessToken) {
            const renewedToken = result.data.auth.renewToken.newAccessToken;
            token = renewedToken;
            saveNewAccessToken(renewedToken);
          }
        } catch (e) {
          console.log("Refresh Token Error", e);
        }
      }
    },
  };
});

const gqlClient = new Client({
  requestPolicy: "network-only",
  exchanges: [cacheExchange, auth, fetchExchange],
  url: dev ? "http://localhost:8000/graphql" : "<your_production_url>",
});

export default gqlClient;
```

Now initialize the gqlClient we created above in our app.

```svelte
<!-- src/routes/+page.svelte -->

<script lang="ts">
 import { onMount } from 'svelte';
 import { goto } from '$app/navigation';
 import { setContextClient } from '@urql/svelte';

 import gqlClient from '../lib/services/base.service';
 import { getAccessToken } from '$lib/services/local-storage.service';

 onMount(() => {
  const accessToken = getAccessToken();
  const visitingRoute = location.pathname;

  if (!accessToken) return goto(`/login`);

  if (visitingRoute === '/') return goto(`/dashboard`);
 });

 setContextClient(gqlClient);
</script>

<p>loading...</p>
```

## Create the auth service file

```typescript
// src/lib/services/auth.service.ts

import gqlClient from "./base.service";
import type { LoginSuccess } from "$lib/graphql/graphql";
import { LOGIN_QUERY, LOGOUT_MUTATION } from "$lib/graphql/queries/auth";

const login = async (identifier: string, password: string): Promise<LoginSuccess | undefined> => {
    const result = await gqlClient.query(LOGIN_QUERY, { input: { identifier, password } }).toPromise();
    if (result.error) throw result.error;
    return result.data!.auth.login;
}

const logout = async (accessToken: string) => {
    const result = await gqlClient.mutation(LOGOUT_MUTATION, { accessToken }).toPromise();
    if (result.error) throw result.error;
    return result.data;
}

const authService = {
    login,
    logout,
}

export default authService;
```

## Creating our login page

```svelte
<!-- src/routes/login/+layout.svelte -->

<div class="flex flex-col flex-1 bg-white justify-center">
 <div class="m-10 flex flex-grow flex-col rounded-2xl items-center p-5">
  <slot />
 </div>
</div>
```


```svelte
<!-- src/routes/login/+page.svelte -->

<script lang="ts">
 import { goto } from '$app/navigation';
 import { useForm, Hint, validators, email, required, HintGroup } from 'svelte-use-form';

 import authService from '$lib/services/auth.service';
 import { notifierStore } from '../../stores/notifier.store';
 import { saveAuthTokens } from '$lib/services/local-storage.service';
 import LoadingButtonXL from '$lib/components/shared/loading-button-xl.svelte';

 const form = useForm();

 let password: string;
 let userEmail: string;
 let isLoading = false;

 async function onLogin() {
  if (isLoading) return;

  try {
   isLoading = true;
   const data = await authService.login(userEmail, password);
   saveAuthTokens(data!.accessToken, data!.refreshToken);
   goto('/dashboard');
  } catch (e) {
   handleError(e);
  } finally {
   isLoading = false;
  }
 }

 function onForgotPassword() {}

 function handleError(error: any) {
  if (typeof error === 'string') {
   console.log(error);
  } else if (error instanceof Error) {
   console.log(error.message);
  }
 }

</script>

<div class="lg:w-9/12 w-full flex flex-col lg:mt-52 text-gray-500">
 <h1 class="text-4xl text-black mb-10 text-center">Login to Continue</h1>
 <form
  use:form
  class="w-full flex flex-col"
  on:submit|preventDefault={$form.valid ? onLogin : null}
 >
  <input
   type="email"
   name="email"
   placeholder="Email"
   bind:value={userEmail}
   use:validators={[required, email]}
   class="border border-gray-200 rounded-2xl py-5 px-6 text-xl tracking-wide text-gray-600"
  />
  <HintGroup for="email">
   <Hint class="text-red-800 m-2" on="required">This is a mandatory field.</Hint>
   <Hint class="text-red-800 m-2" on="email" hideWhenRequired>Email is not valid.</Hint>
  </HintGroup>
  <input
   type="password"
   name="password"
   bind:value={password}
   placeholder="Password"
   use:validators={[required]}
   class="border border-gray-200 rounded-2xl py-5 px-6 mt-4 text-xl tracking-wide text-gray-600"
  />
  <HintGroup for="password">
   <Hint class="text-red-800 m-2" on="required">This is a mandatory field.</Hint>
  </HintGroup>

  <button class="text-MD mt-2 self-end" on:click={onForgotPassword}>Forgot Password?</button>

  <button
   type="submit"
   class="bg-app-primary flex flex-row items-center justify-center text-white my-5 rounded-2xl p-4"
  >
   {#if isLoading}
    your_loading_spinner
   {/if}
   <span class="ml-2">LOGIN</span>
  </button>
 </form>
</div>
```

That should be it! If any questions do not hesitate to comment. Thanks.