DEV Community

Pascal Martineau
Pascal Martineau

Posted on • Edited on

Isomorphic authentication / authorization

This one is a very long read, so hang on tight!

What does isomorphic mean for our project?

The goal here is to share as much authentication and authorization logic as possible between the backend and frontend. In practice, the authentication state and authorization rules' signatures should be the same but the logic itself will sometimes have to be implemented separately.

Setting up login / logout API routes

Our users will be authenticated by storing a JSON Web Token (JWT) in a HTTP-only cookie. We could return this token from a login mutation in our GraphQL API, but we'd still have to manage the cookie logic in Nuxt.

To make things simpler, we can implement login/logout as API routes and manage the cookie from the server response directly instead of splitting the logic between Nuxt and our GraphQL schema. This will also come in handy for third-party authentication providers, which require a callback route for handling the response anyways.

Unless I'm mistaken, there isn't an easy way to set cookies from within GraphQL mutation resolvers. This would require hacking into GraphQL Helix or creating a custom Envelop plugin.

Encrypting & verifying passwords

Before going any further, we'll need to add basic encryption for storing and verifying passwords securely. This will require the following packages:

yarn add -D bcrypt @types/bcrypt
Enter fullscreen mode Exit fullscreen mode

We can encapsulate our encryption and verification logic as helper functions inside utils/password.ts:

import { compareSync, hashSync } from "bcrypt";

export const encryptPassword = (password: string): string => hashSync(password, 10);

export const verifyPassword = (password: string, encrypted: string): boolean => compareSync(password, encrypted);
Enter fullscreen mode Exit fullscreen mode

Let's adjust our seeding script to store the encrypted password (remember to run yarn prisma db seed after):

// ...
import { encryptPassword } from "../utils/password";

// ...
  // Default admin user
  const admin = {
    email: process.env.SEED_ADMIN_EMAIL || "admin@example.com",
    password: encryptPassword(process.env.SEED_ADMIN_PASSWORD || "changeme"),
    role: UserRole.ADMIN,
  };
// ...
Enter fullscreen mode Exit fullscreen mode

Handling authentication state with JWT

In addition to encrypting passwords, we need a secure way of storing and retrieving the authentication state using the JSON Web Token open standard. It's very important to get this right, as it is central to our application security.

Let's install the packages required to do this:

yarn add -D jsonwebtoken @types/jsonwebtoken
Enter fullscreen mode Exit fullscreen mode

Since we want our JWT payload to be strongly typed, let's define a AuthState interface and a runtime validation helper in utils/jwt.ts:

import type { UserRole } from "@prisma/client";

// Type safe authentication state validation
export interface AuthState {
  user: null | {
    id: number;
    role: UserRole;
  };
}

export const validateAuthState = (authState: AuthState): AuthState => ({
  user: authState?.user ? (({ id, role }) => ({ id, role }))(authState.user) : null,
});
Enter fullscreen mode Exit fullscreen mode

validateAuthState ensures the authentication state will always have the exact shape defined in the interface. In short, the user property will either be null when the user is not authenticated, or contain both id and role otherwise (these are the bare minimum for our authorization needs).

Next, we'll implement a helper for decoding the token into a validated authentication state. This will later be used in the contextFactory to set the current user in the GraphQL execution context:

import jwt from "jsonwebtoken";

// ...

// Decode and validate authentication state payload from (string) token
const jwtSecretKey = process.env.JWT_SECRET_KEY || "jwtsecretkey";

export const decodeJwt = (token: string): AuthState => {
  try {
    const authState = jwt.verify(token, jwtSecretKey) as AuthState;
    return validateAuthState(authState);
  } catch (error) {
    return { user: null };
  }
};
Enter fullscreen mode Exit fullscreen mode

Then, we implement setAuthState, our helper for updating the authentication state as a JWT cookie in the server response:

import type { ServerResponse } from "http";
import type { User, UserRole } from "@prisma/client";
import type { CookieSerializeOptions } from "cookie-es";
import { setCookie } from "h3";
import type { SignOptions } from "jsonwebtoken";
import jwt from "jsonwebtoken";

// ...

// Encode authentication state as JWT cookie in server response
export const jwtCookieName = process.env.JWT_COOKIE_NAME || "jwt";
const jwtSignOptions: SignOptions = { expiresIn: "2h" };
const jwtCookieOptions: CookieSerializeOptions = { path: "/", httpOnly: true };

export const setAuthState = (user: User | null, res: ServerResponse): AuthState => {
  const authState = validateAuthState({ user });
  setCookie(res, jwtCookieName, jwt.sign(authState, jwtSecretKey, jwtSignOptions), jwtCookieOptions);
  return authState;
};
Enter fullscreen mode Exit fullscreen mode

This helper will be used by our authentication API endpoints to set (or reset) the current user. Under the hood, it takes a User object (typed from our data layer) and a ServerResponse, encodes a validated authState in the Set-Cookie HTTP response header and finally returns the authentication state itself.

The various options (jwtSignOptions and jwtCookieOptions) can be tweaked to fit your security needs, but the provided settings should be a good start (all tokens expire after 2h, HTTP-only cookie with / path).

Can we setup the authentication API routes already?

At this point, we have everything we need to implement our login / logout API routes. Let's create the easiest one first in server/api/logout.ts:

import { defineHandle } from "h3";
import { setAuthState } from "~/utils/jwt";

export default defineHandle((_req, res) => {
  return setAuthState(null, res);
});
Enter fullscreen mode Exit fullscreen mode

As you can see, logging out is quite simple!

Now let's add server/api/login.ts:

import { defineHandle, useBody } from "h3";
import { prisma } from "~/prisma/client";
import { setAuthState } from "~/utils/jwt";
import { verifyPassword } from "~/utils/password";

export default defineHandle(async (req, res) => {
  try {
    const { email, password } = await useBody(req);
    const user = await prisma.user.findFirst({ where: { email } });
    if (!user) throw new Error("User does not exist.");
    if (!verifyPassword(password, user.password)) throw new Error("Invalid password");
    return setAuthState(user, res);
  } catch (error) {
    res.statusCode = 401;
    res.statusMessage = (error as Error).message;
    return setAuthState(null, res);
  }
});
Enter fullscreen mode Exit fullscreen mode

In both cases, returning the authentication state in the response body will allow us to keep the client-side authentication state up to date without having to query the GraphQL backend (more on this later).

Authentication / authorization in the backend

This section covers the backend logic required to properly authenticate users and restrict some operations at the field-level based on custom authorization rules.

Getting the authentication state in the context

In order to provide the authentication state to our resolvers, we'll add the auth property on our Context type and set its value from the token passed to contextFactory:

import { prisma } from "../prisma/client";
import type { AuthState } from "../utils/jwt";

export type Context = {
  auth: AuthState;
  prisma: typeof prisma;
};

export const contextFactory = (token: string): Context => {
  return { auth: decodeJwt(token), prisma };
};
Enter fullscreen mode Exit fullscreen mode

To obtain the token from the incoming request, we have to parse its headers and extract the cookie, so let's add a helper in utils/jwt.ts:

import { parse } from "cookie-es";

// Extract JWT token from request headers
export const getTokenFromHeaders = (headers: { cookies: string }): string => {
  const cookies: Record<string, string> = parse(headers.cookies || "");
  return cookies[jwtCookieName] || "";
};
Enter fullscreen mode Exit fullscreen mode

We could also pass the token in the request's authorization header, we'd just have to adjust the code above.

Finally, we need to adjust the options to processRequest in server/api/graphql.ts:

import { getTokenFromHeaders } from "../../utils/jwt";

    // ...
    contextFactory: ({ request }) => contextFactory(getTokenFromHeaders(request.headers as { cookies: string })),
    // ...
Enter fullscreen mode Exit fullscreen mode

Adding field-level authorization rules

Since we don't want to repeat the same authorization patterns over and over again in our resolvers, we'll implement re-usable authorization rules using nexus-shield:

yarn add -D nexus-shield
Enter fullscreen mode Exit fullscreen mode

This package is a Nexus plugin, so we need to add it inside server/schema.ts as an option to makeSchema:

import { nexusShield, allow } from "nexus-shield";

// ...

export default makeSchema({
  plugins: [
    nexusShield({
      defaultError: new Error("Unauthorized"),
      defaultRule: allow,
    }),
  ],
  // ...
}) as unknown as GraphQLSchema;
Enter fullscreen mode Exit fullscreen mode

We'll only define two authorization rules for now (isAuthenticated and hasUserRole) and re-export the provided operators (and, or, etc.), which should cover most cases. I chose to put this inside server/nexus/_rules.ts:

import { generic, ruleType, ShieldCache } from "nexus-shield";
import type { UserRole } from "@prisma/client";

export { and, or, chain, not, race } from "nexus-shield";

export const isAuthenticated = generic(
  ruleType({
    cache: ShieldCache.CONTEXTUAL,
    resolve: (_root, _args, { auth }) => {
      return !!auth.user;
    },
  }),
);

export const hasUserRole = (role: UserRole) =>
  generic(
    ruleType({
      cache: ShieldCache.CONTEXTUAL,
      resolve: (_root, _args, { auth }) => {
        return [role, "ADMIN"].includes(auth.user?.role || "");
      },
    }),
  );
Enter fullscreen mode Exit fullscreen mode

As you can see from the code above, isAuthenticated simply checks if the user is authenticated while hasUserRole checks for a specific role (with "ADMIN" always being authorized).

Using these rules in our Nexus types is quite straightforward, for example our hello query can be restricted to the "EDITOR" role like so:

import { extendType } from "nexus";
import { hasUserRole } from "./_rules";

export const HelloQuery = extendType({
  type: "Query",
  definition(t) {
    t.nonNull.field("hello", {
      type: "String",
      resolve: () => `Hello Nexus`,
      shield: hasUserRole("EDITOR")(),
    });
  },
});
Enter fullscreen mode Exit fullscreen mode

To learn about different rule types, caching and more, please refer to the nexus-shield documentation.

Authentication / authorization in the frontend

This section covers the frontend logic required to access the currently authenticated user from our pages, components and navigation.

Getting the authentication state in a composable

To make things as simple as possible for our components, we'll create a useAuth composable that will return various helpers for managing authentication & authorization logic (login, logout, authorization rules, etc.)

Let's begin by defining a reactive and SSR-friendly shared state in a server plugin. Essentially, this plugin initializes the authentication state (auth) with the decoded JWT cookie on each request. The code for this goes in plugins/auth.server.ts:

import type { AuthState } from "~/utils/jwt";
import { jwtCookieName, decodeJwt } from "~/utils/jwt";

export default defineNuxtPlugin(() => {
  const token = useCookie(jwtCookieName).value;
  useState<AuthState>("auth", () => decodeJwt(token));
});
Enter fullscreen mode Exit fullscreen mode

With the auth state initialized by our Nuxt server plugin on each request, we can implement useAuth in composables/auth.ts:

import { UserRole } from "@prisma/client";
import type { AuthState } from "~/utils/jwt";

export const useAuth = () => {
  // Current authentication state (initialized in plugins/auth.server.ts)
  const auth = useState<AuthState>("auth", () => ({ user: null }));

  // Authorization rules
  const isAuthenticated = computed<boolean>(() => !!auth.value.user?.id);
  const hasUserRole = (role: UserRole) => ["ADMIN", role].includes(auth.value.user?.role || "");

  // Authentication helpers
  const login = async (credentials: { email: string; password: string }) => {
    const result = await $fetch("/api/login", { method: "POST", body: credentials });
    auth.value = result;
  };
  const logout = async () => {
    const result = await $fetch("/api/logout", { method: "POST" });
    auth.value = result;
  };

  return { auth, isAuthenticated, hasUserRole, login, logout };
};
Enter fullscreen mode Exit fullscreen mode

Defining the authentication process

Creating useful navigation guards requires having a minimal set of routes and navigation logic for the authentication process (i.e. login form, redirect on success / failure, etc.)

To keep things simple, we'll define the following routes:

  • / : Front page
  • /login : Login page (with optional redirect parameter)
  • /secret : Secret page (requires authentication)

When trying to navigate to /secret, the user should be taken to /login?redirect=/secret, then redirected to /secret upon a successful login.

Login / logout forms

Again let's start with the most obvious of the two, components/form/logout.vue:

<script setup lang="ts">
const { logout } = useAuth();
async function onLogout() {
  await logout();
  location.reload();
}
</script>

<template>
  <button @click="onLogout">Logout</button>
</template>
Enter fullscreen mode Exit fullscreen mode

In the code above, we force a location.reload() to reset the GraphQL client SSR cache.

Let's create a basic login form in components/form/login.vue (see the next article of this series for the final component with full validation):

<script setup lang="ts">
const { login } = useAuth();
const credentials = reactive({
  email: "",
  password: "",
});
const error = ref<string>("");

async function onLogin() {
  try {
    await login(credentials);
    const { query } = useRoute();
    useRouter().push((query as { redirect: string }).redirect || "/");
  } catch (e) {
    error.value = (e as Error).message;
  }
}
</script>

<template>
  <form class="space-y-1.5" @submit.prevent="onLogin">
    <p v-if="error">{{ error }}</p>
    <div><input v-model="credentials.email" type="email" placeholder="Email" /></div>
    <div><input v-model="credentials.password" type="password" placeholder="Password" /></div>
    <div>
      <button type="submit" class="btn">Login</button>
    </div>
  </form>
</template>
Enter fullscreen mode Exit fullscreen mode

We leave out the CSS for the .btn component as an exercise for the reader :)

Navigation menu

To provide some kind of basic page navigation, let's add a menu component in components/nav/menu.vue:

<script setup lang="ts">
const { isAuthenticated } = useAuth();
</script>

<template>
  <div class="py-3 bg-slate-800 text-white">
    <nav class="container">
      <ul class="flex items-center gap-6">
        <li>
          <NuxtLink to="/">Home</NuxtLink>
        </li>
        <li v-if="isAuthenticated">
          <NuxtLink to="/secret">Secret</NuxtLink>
        </li>
        <li class="ml-auto">
          <NuxtLink v-if="!isAuthenticated" to="/login">Login</NuxtLink>
          <FormLogout v-else />
        </li>
      </ul>
    </nav>
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

This component can then be added in layouts/default.vue.

Implementing the authorization middleware

Before creating our pages, we need a way to prevent users from navigating to protected routes under certain circumstances. These helpers are referred to as navigation guards and they are implemented using Nuxt middlewares.

For the sake of isomorphism, we'll try to reproduce the authorization rules from our backend, i.e. isAuthenticated and hasUserRole.

The first one goes in middleware/is-authenticated.ts:

export default defineNuxtRouteMiddleware((to, from) => {
  const { isAuthenticated } = useAuth();
  if (!isAuthenticated.value) {
    return navigateTo(`/login?redirect=${to.fullPath}`);
  }
});
Enter fullscreen mode Exit fullscreen mode

You see how the useAuth composable is already coming in handy? Also note we are sending the current full path as a redirect query parameter to our login page so the user can be redirected back after a successful login.

Applying this middleware to a page is done via the page metadata like so:

<script setup lang="ts">
definePageMeta({
  middleware: ["is-authenticated"],
});
</script>
Enter fullscreen mode Exit fullscreen mode

You probably have already guessed that the second navigation guard goes in middleware/has-user-role.ts:

import type { UserRole } from "@prisma/client";

export default defineNuxtRouteMiddleware((to, from) => {
  const { isAuthenticated, hasUserRole } = useAuth();
  if (!isAuthenticated.value) {
    return navigateTo(`/login?redirect=${to.fullPath}`);
  } else if (!hasUserRole(to.meta.hasUserRole || "ADMIN")) {
    return abortNavigation("You do not have permission to visit this page.");
  }
});

declare module "nuxt3/dist/pages/runtime/composables" {
  interface PageMeta {
    hasUserRole?: UserRole;
  }
}
Enter fullscreen mode Exit fullscreen mode

There is a lot to point out in the code above. First, if the user is not authenticated at all, we redirect just like is-authenticated to allow a chance of logging in. Then if the user doesn't have the proper role, it's a dead-end so we abort the navigation (I'm still looking for a nicer way of letting the user know something was wrong).

As for the PageMeta interface extension right below, this is used to add typings to definePageMeta so it accepts a UserRole that will be used as a parameter to the middleware, like so:

<script setup lang="ts">
definePageMeta({
  middleware: ["has-user-role"],
  hasUserRole: "EDITOR",
});
</script>
Enter fullscreen mode Exit fullscreen mode

Creating the secret and login pages

At last, we can create the missing pages to achieve the authentication process described earlier.

First, let's take a look at pages/secret.vue:

<script setup lang="ts">
definePageMeta({
  middleware: ["is-authenticated"],
});
</script>

<template>
  <div id="secret-page" class="prose">
    <h1>Secret page</h1>
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

And as for pages/login.vue:

<script setup lang="ts">
definePageMeta({
  middleware: ["is-not-authenticated"],
});
</script>

<template>
  <div id="login-page" class="prose">
    <h1>Login page</h1>
    <div class="not-prose">
      <FormLogin />
    </div>
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

We left out middleware/is-not-authenticated.ts as another exercise for the reader! Also, know that definePageMeta can be used for SEO as well.

So that's it, our application now features isomorphic authentication and authorization with a great DX!

Top comments (0)