tl;dr - nuxt-auth is a good plugin, but does not support a bread-and-butter feature like HttpOnly
cookie based authentication (which is an OWASP Top 10 issue), so here’s how to do basic cookie authentication without nuxt-auth
, with routing and backend considered. No clone-able repo, because I’d rather you thought hard about whether you want to use random auth code from a random blog on the internet.
Nuxt has since resolved the issue by introducting the cookie scheme in newer versions of Nuxt! Beware taking on the maintenance burden of the code and approach below (though it's what I'm still using) -- it should be perfectly fine to use nuxt-auth now.
So it turns out that nuxt-auth
does not support HttpOnly cookies which is a very basic security practice that browsers and browser developers have been relying on for decades now. While the reasons people use nuxt
and SSR rendering in general vary, I suspect many might rely on the ecosystem too heavily because of uncertainty/lack of skill/context when dealing with backends.
I personally am excited by and use nuxt
because it represents the holy grail of front-end engineering for me – you can have your cake (complex but composable component-based frontends) and eat it too (server-side rendering a la Django, Rails). I do not user nuxt
with some perverse desire to make everything happen on the frontend or gain more control of what is normally backend-relegated functionality in my frontend code. The decision for me was pretty simple – nuxt-auth
doesn’t support HttpOnly
cookies, so I can’t use nuxt-auth
and I have to write something myself. This post is the breakdown of what my solution looks like if anyone wants to take the same path.
Unfortunately, unlike previous how-I-did-this posts, this post will not come with a repository you can clone, because trying to copy/incorporate this code should be painful in my opinion – you should think carefully about authentication decisions. My code could have bugs, nuxt-auth
also has different tradeoffs (like not having HttpOnly
support, and exposing your auth to your users), you need to choose explicitly and carefully.
Before I get started I will note that the actual idea of maintaining login state across browser requests is remarkably simple to understand in 2020, because the path is well worn:
example.com
example.com
, resulting in the user being shown a login formPOST
request is performed by the JS loaded by example.com
, to an endpoint like example.com/login
POST
request from #3 contains a Set-Cookie
header which instructs the user agent to set zero or more cookies in the user agent’s persistent cookie storage mechanism
Set-Cookie
header have various attributesfetch
, axios
, jQuery’s $.ajax
)There are lots of variations to this flow (for example #3 does not have to be AJAX driven, it could easily be a form-driven POST
), but the basic structure is the same.
With that basic explanation out of the way, let’s get down to the code you’d need to write for this to work
Here is the code you’ll need somewhere in your “backend” code. The implementation language happens to be Typsecript but you should be able to imagine writing this code in most other programming languages you know.
I happen to be using tsoa
as my Controller
layer, but note that you do not have to use an MVC framework – it shouldn’t be hard to imagine a simple express
route that performs the same functionality. I use tsoa
because of it’s support for generating OpenAPI spec, and some of the meta programming and validation enables. If you hear “Controller” or “MVC” and shudder, rest assured I know how it feels – I’m not a huge fan of overly complex architectures ((req, res, next) => void
was and is a revelation) a but the benefits are worth it for me.
/login
endpoint)src/controllers/v1/login/Controller.ts
import {
Controller,
Route,
Post,
Body,
Request,
} from "tsoa";
import { Request as ExpressRequest } from "express";
import { serialize as serializeCookie } from "cookie";
// You probably should define what your cookie is called somewhere so other things can use it
import { AUTH_COOKIE_NAME } from "../../../authentication";
// I have some special code for wrapping every response in a response envelope, and pruning objects before showing them to the outside world
import { ResponseEnvelope, Pruned } from "../../../types";
// Name your errors, give them error codes and good descriptions
// Zalando's problem (https://github.com/zalando/problem) is pretty good on the Java side of things, I roll my own since it's simple
import { LoginFailedError, UnexpectedServerError } from "../../../errors";
import { getServiceOrFail } from "../../util";
import { UserAndAuthToken } from "../../../components/db";
import UserService from "../../../services/User";
import User from "../../../models/User";
export interface LoginCredentials {
email: string;
password: string;
}
@Route("v1/login")
export class LoginController extends Controller {
/**
* Signup a user by email
*
* @returns {ResponseEnvelope<Pruned<User>>}
*/
@Post()
public async login(
@Body() creds: LoginCredentials,
@Request() req: ExpressRequest,
): Promise<ResponseEnvelope<Pruned<User>>> {
const userSvc = await getServiceOrFail(req.app, UserService);
let uaat: UserAndAuthToken;
try {
uaat = await userSvc.loginUserWithEmailAndPassword(creds.email, creds.password);
} catch (e) {
throw new LoginFailedError();
}
// Save the JWT to response cookies
if (!uaat.authToken.token) { throw new UnexpectedServerError("Failed to create login token"); }
// Here's what will cause the user-agent (browser) side magic to happen
this.setHeader('Set-Cookie', serializeCookie(AUTH_COOKIE_NAME, uaat.authToken.token, req.app.cookieOptions));
return new ResponseEnvelope({data: uaat.user.prune()});
}
}
export default LoginController;
Here are some of the settings (req.app.cookieOptions
) I use for this particular application in staging
and production
environments:
// In another constants file
export const DEFAULT_COOKIE_MAX_AGE_MS = 1000 * 60 * 60 * 24 * 7 * 2; // 2 weeks
// Closer to where the server starts up
app.cookieOptions = {
...defaultCookieOptions,
httpOnly: true,
secure: true,
maxAge: DEFAULT_COOKIE_MAX_AGE_MS,
signed: true,
sameSite: true,
}
Note that your cookie does not have to be a JWT, mine just happens to be – but there are lots of ways you could go about this.
You could use “opaque tokens” – randomized hard-to-guess strings (UvZbddhbJ5VeqaRKtEJnydAyyj62DMuj
), UUID v4s (0fc75454-f771-49df-ab66-b5c140d86bd4
) or other things, but you need to make sure you prevent cookie leakage (for example by making sure to always use HTTPS, and making sure the cookies are marked with the secure
attribute) so that cookies won’t be leaked. So what happens if/when cookies are leaked? It’s simple – someone who has access to the cookie can craft a request and impersonate any user they want.
If you pick some sort of important compound value (let’s say an object like {"username": "user"}
serialized), you’re going to want to sign (and optionally encrypt) the value you’re sending around so you can be sure it wasn’t tampered with on the client. Here’s a simple signing example from node-cookie-signature
, though you should probably use some other well known library like passport
or even a lower level library like cookie-signature
.
The point of this post is to keep things simple (JWTs aren’t technically simple but we’ll ignore them for now), but show the pieces you need on either side, so I’m going to go with the JWT approach (which offers a signed value that I can put in cookies), and simple endpoint implementations (as opposed to connecting the bits for a lib like passport
).
/logout
endpoint)As users eventually log in they’re probably going to want to log out sometimes as well. I’ll skip explaining some of the
src/controllers/v1/logout/Controller.ts
import {
Controller,
Route,
Post,
Body,
Request,
Security,
} from "tsoa";
import { Request as ExpressRequest } from "express";
import { serialize as serializeCookie } from "cookie";
import { AUTH_COOKIE_NAME, SecuritySchemeName } from "../../../authentication";
import { InvalidRequestError } from "../../../errors";
import { ResponseEnvelope, AuthTokenType } from "../../../types";
import { getServiceOrFail, getUAATOrFail, ensureAdminOrUUIDMatches } from "../../util";
import AuthTokenService from "../../../services/AuthToken";
export interface LogoutParams {
uuid?: string;
}
@Route("v1/logout")
export class LogoutController extends Controller {
/**
* Logout the current user (or a certain user by UUID if the calling user is an administrator)
*
* @returns {ResponseEnvelope<boolean>}
*/
@Post()
@Security(SecuritySchemeName.Cookie)
public async logout(
@Body() params: LogoutParams,
@Request() req: ExpressRequest,
): Promise<ResponseEnvelope<boolean>> {
const authTokenSvc = await getServiceOrFail(req.app, AuthTokenService);
const uaat = await getUAATOrFail(req);
if (!uaat.user || !uaat.user.uuid || !uaat.authToken || !uaat.authToken.token) { throw new InvalidRequestError(); }
// Ensure the current user is an admin or has the uuid in question
const uuid = params.uuid || uaat.user.uuid;
ensureAdminOrUUIDMatches(uaat, uuid);
// Delete all login tokens for a user
await authTokenSvc.deleteAllTokensOfTypeForUser(AuthTokenType.Login, uuid);
// Encourage cookie deletion on the frontend
const alteredOptions = {
...req.app.cookieOptions,
maxAge: -1
}
this.setHeader('Set-Cookie', serializeCookie(AUTH_COOKIE_NAME, uaat.authToken.token, alteredOptions));
return new ResponseEnvelope({data: true});
}
}
export default LogoutController;
NOTE: The comment that says “encourage” says that because all you can do is “encourage” a user agent (browser) to do something. Remember that you do not have control over a user agent, no matter how you try. Do not implicitly trust user’s browsers to do the right thing.
/auth-check/cookie
endpointsrc/controllers/v1/auth-check/cookie/Controller.ts
import {
Controller,
Route,
Get,
Security,
} from "tsoa";
import { ResponseEnvelope } from "../../../../types";
import { SecuritySchemeName } from "../../../../authentication";
@Route("v1/auth-check/cookie")
export class AuthCheckCookieController extends Controller {
/**
* Check whether an cookie-based AuthToken is valid/active
*
* @returns {ResponseEnvelope<boolean>}
*/
@Get()
@Security(SecuritySchemeName.Cookie)
public async checkCookieAuth(): Promise<ResponseEnvelope<boolean>> {
return new ResponseEnvelope({data: true});
}
}
export default AuthCheckCookieController;
So this is wonderfully short! where is the actual interesting stuff happening? SecuritySchemeName
is just an enum (SecuritySchemeName.Cookie
is "cookie"
), but the real work is done by tsoa
’s @Security
annotation, in conjunction with code you’ve added. Here’s a quick glimpse of what my file looks like:
src/authentication.ts
import { Request as ExpressRequest } from "express";
import { InvalidRequestError, UnauthorizedError } from "./errors";
import { AuthTokenType } from "./types";
import { getServiceOrFail } from "./controllers/util";
export enum SecuritySchemeName {
Cookie = "cookie",
APIBearer = "api-bearer",
}
export function expressAuthentication(
request: ExpressRequest,
securityName: string,
scopes?: string[],
): Promise<any> {
switch (securityName) {
case SecuritySchemeName.APIBearer: return validateAPIBearerAuth(request);
case SecuritySchemeName.Cookie: return validateCookieAuth(request);
default:
return Promise.reject(new InvalidRequestError("Invalid authentication"));
}
}
/**
* Validate cookie authentication
*
* @param {ExpressRequest} req
* @returns {Promise<AuthInfo>}
*/
export async function validateCookieAuth(req: ExpressRequest): Promise<AuthInfo> {
const authTokenSvc = await getServiceOrFail(req.app, AuthTokenService);
// Pull the cookie with the name from the request
if (!(AUTH_COOKIE_NAME in req.cookies)) {
const err = new InvalidRequestError("Invalid/missing authentication cookie");
// This is a really common "error", every new user will be not logged in
err.ignored = true;
throw err;
}
const cookie = req.cookies[AUTH_COOKIE_NAME].trim();
// Attempt to find the key by bearer token
// NOTE: you're going to want to replace this with whatever logic is relevant for your app,
// you may not have an AuthTokenService as I do.
let authToken: AuthToken;
try {
authToken = await authTokenSvc.getByTokenAndType(AuthTokenType.Login, cookie, {includeDeleted: false});
} catch (err) {
if (req.app && req.app.logger) { req.app.logger.error(`Failed credential retrieval: ${err}`); }
throw new UnauthorizedError("Invalid credentials");
}
// Ensure the uath token is valid/present/non-null
if (!authToken) { throw new UnauthorizedError("Invalid cookie"); }
return {user: authToken.user, authToken};
}
I’ll leave the header-based API Bearer authentication stuff as an exercise for the reader, but it should be relatively obvious based on the working of the cookie implementation.
At this point you should have three things on the backend:
OK, so now that we’ve got the /login
and /logout
endpoints that do the bog-standard Set-Cookie
things, let’s make it all work with nuxt
.
What makes nuxt
different here is that it’s essentially a second server that’s running next to the real API, so requests go something like this:
+------------------+ +------------------+ +------------------+
| Browser +---->+ Nuxt server +----> API |
+------------------+ +------------------+ +------------------+
The method we’re about to dive into isn’t strictly the most efficient it could be (running nuxt
as a middleware on the API server would be, if you can), but let’s assume that efficiency isn’t the biggest problem right now.
@nuxt/proxy
and proxy as necessaryYou’re going to want to do some proxying to a given backend (assuming you’re not using nuxt
as a middleware), and to get to the API you’ll need to proxy the requests you get to the nuxt
server. Alternatively, you can also host the API at a completely different subdomain (like api.example.com
), but this example assumes that you want your nuxt app to attempt to contact the API through /api/
(aka http[s]?://example.com/api
assuming your frontend is at example.com/
). The plugin to do that with is @nuxt/proxy
:
nuxt.config.js
:
// Nuxt modules
modules: [
// ... other modules ...
"@nuxtjs/proxy",
// ... other modules ...
],
// ... other config ...
// Proxy configuration
proxy: {
"/api": {
target: "http://localhost:3000", // of course, you can use process.env here!
pathRewrite: {"^/api": "/"},
},
},
cookie-universal-nuxt
for easy HTTP cookie manipulationAnother thing we’re going to want to do is manipulate cookies, and a nice convenient library for making this possible is cookie-universal-nuxt
.
nuxt.config.js
// Nuxt modules
modules: [
// ... other modules ...
"cookie-universal-nuxt",
// ... other modules ...
],
@nuxt/axios
I personally love using and prefer axios
, so installing @nuxt/axios
was one of the first things I did, but I needed to configure it a bit more carefully to allow it to work with the @nuxtjs/proxy
and set credentials
:
nuxt.config.js
// Nuxt modules
modules: [
// ... other modules ...
["@nuxtjs/axios", { proxy: true, credentials: true }],
// ... other modules ...
],
@nuxtjs/router-extras
One of the things I noticed pretty quickly was the difficulty in sharing metadata information about a certain page with nuxt
’s Router
and particularly that the route
propertly in the nuxt per-request context
didn’t wasn’t as easy to influence as I wanted. Turns out this is not an intended feature of nuxt
, which sort of makes sense (there are lots of ways to solve this).
The easiest and most direct way I’ve found to solve this issue, and enable access to properties like meta
on routes exposed as ctx.route
in middleware was by adding the @nuxtjs/router-extras
build module.
middleware/cookie-auth.js
) We’re going to need a middleware for cookie auth in the middleware
folder as nuxt
expects (I’ve named mine src/middleware/cookie-auth.js
), and mine is a bit big so let’s review it in pieces:
cookieAuth(...)
Nuxt middleware needs entrypoints, so I start off with a fairly simple one:
/**
* Perform cookie authentication
*
* @param {object} ctx - route context
*/
export default async function cookieAuth(ctx) {
// If all routes in the path have explicitly disabled auth, no need to check
const allRoutesNoAuth = ctx.route.meta.every(m => m && m.auth === false);
if (allRoutesNoAuth) { return; }
if (process.server) { return handleServer(ctx); }
if (process.client) { return handleClient(ctx); }
throw new Error("[middleware/cookie-auth] Unrecognized context");
}
The only real interesting code in here is a bit of an efficiency hack – ctx.route.meta
happens to contain all the metadata for every route that is hit on the way to the page being rendered on the server side. I know that if every one of those routes has meta.auth
set to false
then I don’t have to do any authentication in either case. By default, authentication will be required, but if every route explicitly disables it, no auth work is necessary.
NOTE: I personally like strict separation (I would love if nuxt
supported something like <middleware>.server.js
and <middleware>.client.js
), so I choose to delegate the calls for server/client code to separate functions;
handleServer(...)
The server-side logic for handling authentication for a request is the most complicated bit (and it took me some time to get it at least this much correct!).
// Server-side cookie handling logic
async function handleServer(ctx) {
// if we somehow don't have a route, let the request through
if (!ctx.route) { return; }
// Get the auth cookie (if it's present)
const authCookie = ctx.app.$cookies.get(AUTH_COOKIE_NAME);
const cacheKey = `auth.${authCookie}`;
// If there's no auth cookie we know we can go right to login
// we know the page requires auth of some kind at this point
if (!authCookie) {
ctx.redirect(FRONTEND_URLS.login);
return;
}
// Attempt to verify authentication cookie
try {
// Perform an actual auth check
const result = await ctx.app.$axios.get(URLS.v1.authCheckCookie, {headers: ctx.req.headers});
// Ensure that the auth check was actually successful
if (result.data.status !== "success" || result.data.data !== true) {
throw new Error("Unexpected error during auth check");
}
// Redirect to app if our cookie is still valid and we're trying to hit login
if (authCookie && ctx.route.path === FRONTEND_URLS.login) {
ctx.redirect(FRONTEND_URLS.app);
return;
}
} catch (err) {
// If a 401 was returned the cookie is likely expired/invalid
if (err.response.status === 401) {
ctx.app.$cookies.remove(AUTH_COOKIE_NAME);
}
// If login failed because the backend server had an error then we need to make sure
console.log("[middleware/cookie-auth/server] Authentication error:", err);
// If the auth check fails, then user is not logged in, so redirect
ctx.redirect(FRONTEND_URLS.login);
}
}
There’s a lot to jump into here, but the code is well commented and should be somewhat readable. The logic regarding when to exit early and check for /login
(or some other URL that means the user is attempting to login) is somewhat pernicious and can lead to endless redirects, so be very careful!
handleClient(...)
In my case I actually don’t do much with authentication on the client-side (for places where users must be authenticated this is handled on the server-side first, on in requests/internal services). This means everything is pretty quiet on the client-side, but I have some TODO
s:
// Client-side cookie handling logic
async function handleClient(ctx) {
// TODO: check if there's a current user as far as we can tell (some CurrentUserService ?)
// (this isn't stricly necessary since API requests will be checked there, and initial load should cover initial checks)
return;
}
nuxt
One way to make the server-side code above a bit more efficient is by using a constrained in-memory cache like node-cache
. The code get a little more complicated but here’s what the modified try
from handleServer(...)
looks like:
// Attempt to verify authentication cookie
try {
// FUTURE: If the cookie is an expired JWT we know we know it's invalid
// It's highly likely that a cookie is still valid if it's still in the cache
// so let's let the user through to the page they were going to
// (assuming it's not login, which should be redirected to app)
if (ctx.req.cache
&& ctx.req.cache.authCookies
&& ctx.req.cache.authCookies.has(cacheKey)
&& ctx.route.path !== FRONTEND_URLS.login) {
ctx.req.cache.authCookies.set(cacheKey, true, AUTH_COOKIE_CACHE_TTL_SECONDS);
return;
}
// Perform an actual auth check
const result = await ctx.app.$axios.get(URLS.v1.authCheckCookie, {headers: ctx.req.headers});
// Ensure that the auth check was actually successful
if (result.data.status !== "success" || result.data.data !== true) {
throw new Error("Unexpected error during auth check");
}
// Redirect to app if our cookie is still valid and we're trying ot hit login
if (authCookie && ctx.route.path === FRONTEND_URLS.login) {
ctx.redirect(FRONTEND_URLS.app);
return;
}
// Update the auth cookie cache
ctx.req.cache.authCookies.set(cacheKey, true, AUTH_COOKIE_CACHE_TTL_SECONDS);
} catch (err) {
// If a 401 was returned the cookie is likely expired/invalid
if (err.response.status === 401) {
ctx.req.cache.authCookies.del(cacheKey);
ctx.app.$cookies.remove(AUTH_COOKIE_NAME);
}
// ... the rest is the same
}
But how do we get that nice ctx.req.cache
thing to show up? I’m not sure if there’s a simpler way to do it, but that involved me writing a middleware for making a simple in-memory cache available to Nuxt. In production, you’re going to want to delegate this caching functionality something like Redis but the imnplementation I’ve included here (at the end) isn’t a terrible place to start as an abstraction (i.e. it doesn’t matter if you use node-cache
or redis
behind the scenes if you constrain the implementations to use ctx.req.cache
).
First you’ll need to modify your nuxt.config.js
and add a serverMiddleware
entry:
nuxt.config.js
serverMiddleware: [
"~/api/cache", // in-memory cache for requests to use
],
Then you’ll need to actually add the code:
api/cache.js
import NodeCache from "node-cache";
/**
* Request-level in-memory cache that will hold various information for the application
*/
const CACHE = {
authCookies: new NodeCache(),
};
export default function (req, res, next) {
req.cache = CACHE;
next();
};
NOTE This cache cannot persist after restarts – you need to extend this middleware to connect to an external dependency like Redis or your backend database for this to work well for a horizontally scaled application.
path
attribute preventing cookie from being sent from the clientOne issue I ran into when working on this was that my own cookie wasn’t being set because the path
attribute on the cookie wasn’t set properly to work locally and in the eventual staging/production environments. Make sure to either not set the path
attribute or set it to localhost
when running locally!
SameSite
behavior in browsersYou can read more about this on the Mozilla Hacks site – it’s probably a good idea to give this some thought and set it.
Congratulations on making it all the way down here! This post took a bit to write up, but hopefully is useful for those looking to understand login from first principles, and implement it in nuxt
without nuxt-auth
(or something like passport
). Hopefully you enjoyed this post as much as I enjoyed writing the code that went into it.