Cookie Authentication Without Nuxt Auth

Categories
Nuxt logo

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.

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:

  1. A “user agent” (ex. Firefox Browser) visits your website @ example.com
    • HTML, CSS, and JS are loaded/executed for example.com, resulting in the user being shown a login form
  2. A POST request is performed by the JS loaded by example.com, to an endpoint like example.com/login
  3. The HTTP response to the 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
  4. Subsequent requests from the user agent will send cookies as necessary, depending on the API used (XML/HTTRPC, fetch, 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

API

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.

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).

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.

src/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:

  • The ability to log a user in
  • The ability to log a user out
  • The ability to check whether a request is properly authenticated (authorization was not covered)

Nuxt

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.

Add @nuxt/proxy and proxy as necessary

You’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": "/"},
    },
  },

Another 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 ...
  ],

Add / properly configure @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 ...
  ],

Expose routing metadata from pages with @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.

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:

The entrypoint, 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;

Server-side logic in 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!

Client-side logic in 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 TODOs:

// 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;
}

Bonus: Caching with a simple in-memory cache for 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.

Gotcha: Incorrect path attribute preventing cookie from being sent from the client

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

Gotcha: Watch out for changes to SameSite behavior in browsers

You can read more about this on the Mozilla Hacks site – it’s probably a good idea to give this some thought and set it.

Wrapup

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.