Svelte and GraphQL with Authentication

Alish Giri - Jun 21 - - Dev Community

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.

urql Documentation

A highly customisable and versatile GraphQL client.

favicon commerce.nearform.com

urql/exchange-auth

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

urql Documentation

A highly customisable and versatile GraphQL client.

favicon commerce.nearform.com

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

This is used for codegen for a typescript project.

urql Documentation

A highly customisable and versatile GraphQL client.

favicon commerce.nearform.com

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

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
npm create svelte@latest your_app_name
Enter fullscreen mode Exit fullscreen mode

Installing dependencies

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

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.

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

Configuring Tailwindcss

Create tailwind.config.js in the root of your project and add the following content.

// tailwind.config.js

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

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

* src/app.css */

@tailwind base;
@tailwind components;
@tailwind utilities;
Enter fullscreen mode Exit fullscreen mode

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.

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

Dowloading our API schema from the backend

Recheck package.json file above if you encounter any issues here.

yarn codegen
Enter fullscreen mode Exit fullscreen mode

Adding our GraphQL endpoints

Get the Auth Queries defined in the backend.

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

Configuring Urql client

Update vite.config.ts as shown below.

// vite.config.ts

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

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

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

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

Now initialize the gqlClient we created above in our app.

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

Create the auth service file

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

Creating our login page

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

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

. . . . . . . . . . . .