DEV Community

Mukesh
Mukesh

Posted on

Part 2: Authentication Flows

If you haven't already, I would recommend having a quick look at the Introduction & Sequence Diagram

Welcome to the 3-part series that helps you create a scalable production-ready authentication system using pure JWT & a middleware for your SvelteKit project


You are reading Part 2

Goal: Implement user authentication flows using JWT, covering sign-up, sign-in, and logout

Topics we'll cover

  • Sign-Up Flow: Server-side endpoint to register users and issue JWT, with a Svelte form.
  • Sign-In Flow: Server-side endpoint to authenticate users and issue JWT, with a Svelte form.
  • Logout Flow: Server-side endpoint to clear cookies, with a simple UI.

Note:

  • All form validations are happening server-side, as it should be.
  • The forms are pretty basic. Focus on the logic, understand & then enhance the design of the forms using AI.

Sign-Up Flow

Let's implement the sign-up endpoint:

// src/routes/auth/sign-up/+page.server.ts

import { fail, redirect } from "@sveltejs/kit";
import {
  generateToken,
  setAuthCookie,
  logToken,
} from "$lib/auth/jwt";
import { createUser, getUserByEmail } from "$lib/database/db";
import bcrypt from "bcrypt";
import type { Actions } from "./$types";

export const actions = {
  signup: async ({ cookies, request }) => {
    const data = await request.formData();
    const email = data.get("email");
    const password = data.get("password");

    // Wrap all registration logic in a separate async function
    const registerUser = async () => {
      try {
        // Email validation
        if (typeof email !== "string" || !email) {
          return {
            success: false,
            error: "invalid-input",
            message: "Email is required",
          };
        }

        // Email format validation
        const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
        if (!emailRegex.test(email)) {
          return {
            success: false,
            error: "invalid-input",
            message: "Please enter a valid email address",
          };
        }

        // Password validation
        if (typeof password !== "string" || password.length < 6) {
          return {
            success: false,
            error: "invalid-input",
            message: "Password must be at least 6 characters",
          };
        }

        // Check if user already exists
        const existingUser = await getUserByEmail(email);
        if (existingUser) {
          return {
            success: false,
            error: "user-exists",
            message: "An account with this email already exists",
          };
        }

        // Hash the password before storing it
        const saltRounds = 10;
        const hashedPassword = await bcrypt.hash(
          password,
          saltRounds
        );

        // Create the user in the database
        const user = await createUser(
          email,
          hashedPassword,
          "user" // Default role
        );

        console.log("User Created");

        if (!user) {
          return {
            success: false,
            error: "database-error",
            message: "Failed to create account - database error",
          };
        }

        // Create token for the new user
        const tokenPayload = {
          userId: user.USER_ID,
          email: user.EMAIL,
          role: user.ROLE,
        };

        const accessToken = generateToken(tokenPayload);

        // Set JWT cookie
        setAuthCookie(cookies, accessToken);

        // Log token to database
        if (user.USER_ID) {
          // We use a non-awaited promise to avoid blocking
          logToken(accessToken, user.USER_ID).catch((err) => {
            console.error("Failed to log token:", err);
          });
        } else {
          console.error(
            "Cannot log token: user.USER_ID is null or undefined"
          );
        }

        return { success: true };
      } catch (error) {
        console.error("Registration error:", error);
        return {
          success: false,
          error: "registration-failed",
          message: "Failed to create account",
        };
      }
    };

    // Execute the registration process
    const result = await registerUser();

    if (!result.success) {
      // Map error types to appropriate HTTP status codes and response formats
      switch (result.error) {
        case "user-exists":
          return fail(400, {
            invalid: true,
            message: result.message,
          });

        case "invalid-input":
          return fail(400, {
            invalid: true,
            message: result.message,
          });

        case "connection-error":
          return fail(503, { error: true, message: result.message });

        case "database-error":
        case "registration-failed":
        default:
          return fail(500, { error: true, message: result.message });
      }
    }
    // Registration succeeded, perform redirect
    throw redirect(302, "/dashboards/analytics");
  },
} satisfies Actions;
Enter fullscreen mode Exit fullscreen mode

And the sign-up form:

// src/routes/auth/sign-up/+page.svelte

<script lang="ts">
    import AuthLayout from "$lib/layouts/AuthLayout.svelte";
    import LogoBox from "$lib/components/LogoBox.svelte";
    import SignWithOptions from "../components/SignWithOptions.svelte";
    import {Button, Card, CardBody, Col, Input, Row} from "@sveltestrap/sveltestrap";
    import type { ActionData } from './$types';
    import { enhance } from '$app/forms';
    import type { SubmitFunction } from '@sveltejs/kit';
    import { goto } from '$app/navigation';

    const signInImg = '/images/sign-in.svg'

    // Get form data for error display
    let { form } = $props<{ form?: ActionData }>();
    let loading = $state(false);
    let showErrors = $state(true); // Controls visibility of error messages

    // Custom enhance function to track loading state
    const handleSubmit: SubmitFunction = () => {
        loading = true;
        showErrors = false; // Hide any previous errors on new submission

        return async ({ result, update }) => {
            if (result.type === 'redirect') {
                // Handle redirect by navigating to the specified location
                loading = false; // Make sure to reset loading before redirect
                goto(result.location);
                return;
            }

            // For other result types, update form with the result
            await update();
            loading = false;
            showErrors = true; // Only show errors if we're not redirecting
        };
    }
</script>

<h2>Sign Up</h2>

<form method="POST" action="?/signup" use:enhance={handleSubmit}>

        <!-- Show loading spinner and form status -->
        {#if loading}
              <div>Loading...</div>
              <p>Creating your account...</p>

        {:else if showErrors}

                <!-- Display validation errors -->
                {#if form?.invalid}
                    <div>{form.message || 'Please check your input.'}</div>
                {/if}

                {#if form?.error}
                    <div>{form.message || 'An error occurred.'}</div>
                {/if}

        {/if}

    <label class="form-label" for="email">Email</label>
    <Input type="email" 
                   id="email" 
                   name="email" 
                   class={showErrors && form?.invalid && form?.message?.includes('email') ? 'is-invalid' : ''} 
           placeholder="Enter your email"
           disabled={loading}
     >

     <label class="form-label" for="password">Password</label>
       <Input 
        type="password" 
        id="password" 
        name="password"
        class={showErrors && form?.invalid && form?.message?.includes('assword') ? 'is-invalid' : ''}
        placeholder="Enter your password"
        disabled={loading}
      />


      <Button color="primary" type="submit" disabled={loading}>
          {loading ? 'Signing Up...' : 'Sign Up'}
      </Button>

</form>

<p > Already have an account?
    <a href="/auth/sign-in">Sign In</a>
</p>

Enter fullscreen mode Exit fullscreen mode

Sign-In Flow

Now for the sign-in endpoint:

// src/routes/auth/sign-in/+page.server.ts

import { fail, redirect } from "@sveltejs/kit";
import { generateToken, logToken } from "$lib/auth/jwt";
import { setAuthCookie } from "$lib/auth/cookies";
import { validateUserCredentials } from "$lib/database/db";
import type { Actions } from "./$types";

// Error response types
type AuthError = {
  success: false;
  error:
    | "invalid-input"
    | "invalid-credentials"
    | "connection-error"
    | "database-error"
    | "login-failed";
  message: string;
};

// Success response type
type AuthSuccess = {
  success: true;
};

// Combined result type
type AuthResult = AuthError | AuthSuccess;

export const actions = {
  login: async ({ cookies, request }) => {
    const data = await request.formData();
    const email = data.get("email")?.toString() || "";
    const password = data.get("password")?.toString() || "";

    // Wrap all login logic in a separate async function
    const authenticateUser = async (): Promise<AuthResult> => {
      try {
        // Validate input fields
        if (!email || !password) {
          return {
            success: false,
            error: "invalid-input",
            message: "Email and password are required",
          };
        }

        // Validate user credentials against database
        const user = await validateUserCredentials(email, password);

        // If authentication failed
        if (!user) {
          return {
            success: false,
            error: "invalid-credentials",
            message: "Invalid email or password",
          };
        }

        // User authenticated - create JWT token
        const tokenPayload = {
          userId: user.USER_ID,
          email: user.EMAIL,
          role: user.ROLE,
        };

        const accessToken = generateToken(tokenPayload);

        // Set JWT cookie
        setAuthCookie(cookies, accessToken);

        // Log token to database (non-blocking)
        if (user.USER_ID) {
          logToken(accessToken, user.USER_ID).catch((err) => {
            console.error("Failed to log token:", err);
          });
        }

        return { success: true };
      } catch (error) {
        console.error("Login error:", error);

        // Get error message from any type of error
        const errorMessage =
          error instanceof Error ? error.message : String(error);

        // Simple error classification based on key terms
        let errorType: AuthError["error"] = "login-failed";
        let errorMsg = "An unexpected error occurred";

        // Simple keyword-based error detection
        if (
          errorMessage.includes("network") ||
          errorMessage.includes("connect")
        ) {
          errorType = "connection-error";
          errorMsg =
            "Unable to connect to the service. Please try again later.";
        } else if (
          errorMessage.includes("database") ||
          errorMessage.includes("query")
        ) {
          errorType = "database-error";
          errorMsg = "Database error. Please try again later.";
        }

        return {
          success: false,
          error: errorType,
          message: errorMsg,
        };
      }
    };

    // Execute the authentication process
    const result = await authenticateUser();

    if (!result.success) {
      return handleError(result);
    }

    // Login succeeded, perform redirect
    console.log("Login successful, redirecting to dashboard");
    throw redirect(302, "/dashboard");
  },
} satisfies Actions;

// Helper function to handle errors - returns consistent error format
function handleError(result: AuthError): ReturnType<typeof fail> {
  // Simple mapping of error types to status codes
  let statusCode = 500;

  // Define possible response shapes
  type ErrorResponse = { error: boolean; message: string };
  type CredentialsResponse = {
    credentials: boolean;
    message: string;
  };
  type InvalidResponse = { invalid: boolean; message: string };

  // Start with default error response
  let responseData:
    | ErrorResponse
    | CredentialsResponse
    | InvalidResponse = { error: true, message: result.message };

  if (result.error === "invalid-credentials") {
    statusCode = 400;
    responseData = { credentials: true, message: result.message };
  } else if (result.error === "invalid-input") {
    statusCode = 400;
    responseData = { invalid: true, message: result.message };
  } else if (result.error === "connection-error") {
    statusCode = 503;
  }

  return fail(statusCode, responseData);
}

Enter fullscreen mode Exit fullscreen mode

And the sign-in form:

// src/routes/auth/sign-in/+page.svelte

<script lang="ts">
    import AuthLayout from "$lib/layouts/AuthLayout.svelte";
    import LogoBox from "$lib/components/LogoBox.svelte";
    import {Button, Card, CardBody, Col, Input, Row} from "@sveltestrap/sveltestrap";
    import SignWithOptions from "../components/SignWithOptions.svelte";
    import type { ActionData } from './$types';
    import { enhance } from '$app/forms';
    import type { SubmitFunction } from '@sveltejs/kit';
    import { goto } from '$app/navigation';

    const signInImg = '/images/sign-in.svg'

    let { form } = $props<{ form?: ActionData }>();
    let loading = $state(false);
    let showErrors = $state(true); // Controls visibility of error messages

    // Custom enhance function to track loading state
    const handleSubmit: SubmitFunction = () => {
        loading = true;
        showErrors = false; // Hide any previous errors on new submission

        return async ({ result, update }) => {
            if (result.type === 'redirect') {
                // Handle redirect by navigating to the specified location
                loading = false; // Make sure to reset loading before redirect
                goto(result.location);
                return;
            }

            // For other result types, update form with the result
            await update();
            loading = false;
            showErrors = true; // Only show errors if we're not redirecting
        };
    }
</script>

<h2>Sign In</h2>

<!-- Using a native form with the enhance action -->
<form method="POST" action="?/login" class="authentication-form" use:enhance={handleSubmit}>
    {#if loading}

        <span class="visually-hidden">Loading...</span>
        <p class="mt-2 text-muted">Signing in...</p>

    {:else if showErrors}

        {#if form?.invalid}
            <div>{form.message || 'Email and password are required.'}</div>
        {/if}

        {#if form?.credentials}
            <div>{form.message || 'You have entered wrong credentials.'}</div>
        {/if}

        {#if form?.error}
            <div>{form.message || 'An unexpected error occurred.'}</div>
        {/if}

    {/if}


        <label class="form-label" for="email">Email</label>
    <Input type="email" 
           id="email" 
           name="email"
           class={showErrors && form?.invalid ? 'is-invalid' : ''}
           placeholder="Enter your email" 
           value="user@demo.com"
           disabled={loading}
     />


    <a href="/auth/reset-password"> Reset password</a>
    <label for="password">Password</label>
    <Input 
        type="password" 
        id="password" 
        name="password"
        class={showErrors && (form?.invalid || form?.credentials) ? 'is-invalid' : ''}
        placeholder="Enter your password" 
        value="123456"
        disabled={loading}
     />


      <Button color="primary" type="submit" disabled={loading}>
          {loading ? 'Signing In...' : 'Sign In'}
      </Button>

</form>                    

<p>
    Don't have an account?
    <a href="/auth/sign-up" >Sign Up</a>
</p>
Enter fullscreen mode Exit fullscreen mode

Logout Flow

Finally, the logout endpoint:

// src/routes/auth/logout/+page.server.ts
import { json, redirect } from '@sveltejs/kit';

export async function POST({ cookies }) {
  // [INSERT YOUR LOGOUT ENDPOINT CODE HERE]
}
Enter fullscreen mode Exit fullscreen mode

And the logout UI:

// src/routes/auth/logout/+page.svelte

<svelte:head>
    <title>Logging out...</title>
</svelte:head>

<span >Loading...</span>
<p>Logging you out...</p>

Enter fullscreen mode Exit fullscreen mode

Next → Part 3: Protecting Routes & Security
Previous → Part 1: Setup & JWT Basics

Top comments (0)