Isomorphic authentication / authorization
Tags: nuxt3, authentication, authorization, graphql
Published: 2022-02-23 14:27:10

_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:
```shell
yarn add -D bcrypt @types/bcrypt
```

We can encapsulate our encryption and verification logic as helper functions inside `utils/password.ts`:
```typescript
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);
```

Let's adjust our seeding script to store the encrypted password (remember to run `yarn prisma db seed` after):
```typescript
// ...
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,
  };
// ...
```

### 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](https://datatracker.ietf.org/doc/html/rfc7519). 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:
```shell
yarn add -D jsonwebtoken @types/jsonwebtoken
```

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`:
```typescript
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,
});
```

`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:

```typescript
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 };
  }
};
```

Then, we implement `setAuthState`, our helper for updating the authentication state as a JWT cookie in the server response:
```typescript
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;
};
```
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`:
```typescript
import { defineHandle } from "h3";
import { setAuthState } from "~/utils/jwt";

export default defineHandle((_req, res) => {
  return setAuthState(null, res);
});
```

As you can see, logging out is quite simple!

Now let's add `server/api/login.ts`:
```typescript
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);
  }
});
```

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`:
```typescript
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 };
};
```

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`:
```typescript
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] || "";
};
```
_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`:
```typescript
import { getTokenFromHeaders } from "../../utils/jwt";

    // ...
    contextFactory: ({ request }) => contextFactory(getTokenFromHeaders(request.headers as { cookies: string })),
    // ...
```

### 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`](https://github.com/Sytten/nexus-shield):

```shell
yarn add -D nexus-shield
```

This package is a Nexus plugin, so we need to add it inside `server/schema.ts` as an option to `makeSchema`:
```typescript
import { nexusShield, allow } from "nexus-shield";

// ...

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

We'll only define two authorization rules for now (`isAuthenticated` and `hasUserRole`) and re-export the provided [operators](https://github.com/Sytten/nexus-shield#operators) (`and`, `or`, etc.), which should cover most cases. I chose to put this inside `server/nexus/_rules.ts`:
```typescript
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 || "");
      },
    }),
  );
```

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:
```typescript
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")(),
    });
  },
});
```

To learn about different rule types, caching and more, please refer to the `nexus-shield` [documentation](https://github.com/Sytten/nexus-shield#usage).

## 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](https://v3.nuxtjs.org/docs/usage/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`:

```typescript
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));
});
```

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

```typescript
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 };
};
```

### 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`:
```vuejs
<script setup lang="ts">
const { logout } = useAuth();
async function onLogout() {
  await logout();
  location.reload();
}
</script>

<template>
  <button @click="onLogout">Logout</button>
</template>
```
_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):
```vuejs
<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>
```

_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`:
```vuejs
<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>
```

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](https://v3.nuxtjs.org/docs/directory-structure/middleware).

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`:
```typescript
export default defineNuxtRouteMiddleware((to, from) => {
  const { isAuthenticated } = useAuth();
  if (!isAuthenticated.value) {
    return navigateTo(`/login?redirect=${to.fullPath}`);
  }
});
```

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](https://v3.nuxtjs.org/docs/directory-structure/pages#page-metadata) like so:
```vuejs
<script setup lang="ts">
definePageMeta({
  middleware: ["is-authenticated"],
});
</script>
```

You probably have already guessed that the second navigation guard goes in `middleware/has-user-role.ts`:
```typescript
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;
  }
}
```

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:
```vuejs
<script setup lang="ts">
definePageMeta({
  middleware: ["has-user-role"],
  hasUserRole: "EDITOR",
});
</script>
```

### 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`:
```vuejs
<script setup lang="ts">
definePageMeta({
  middleware: ["is-authenticated"],
});
</script>

<template>
  <div id="secret-page" class="prose">
    <h1>Secret page</h1>
  </div>
</template>
```

And as for `pages/login.vue`:
```vuejs
<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>
```

_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!