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
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);
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,
};
// ...
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
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,
});
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 };
}
};
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;
};
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);
});
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);
}
});
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 };
};
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] || "";
};
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 })),
// ...
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
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;
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 || "");
},
}),
);
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")(),
});
},
});
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));
});
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 };
};
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 optionalredirect
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>
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>
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>
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}`);
}
});
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>
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;
}
}
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>
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>
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>
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)