Passport.js with VHosts

So, I started moving all of my apps into a single VHost monorepo setup because:

  • It's easier to share code across projects (I don't have to setup shared dependencies explicitly)
  • It allows me to deploy all of my apps to a single service/instance which means that I don't have to pay as much for hosting (there are free options with serverless stacks, but I like the simplicity and control of a more "traditional" deployment).
  • Keeping related histories together is nice and helps a bit when I need to dig for things.

Unfortunately, Passport.js uses a singleton instance, which is configured like so:

app.use(passport.initialize());

passport.serializeUser(serializeUser);
passport.deserializeUser(deserializeUser);
passport.use(localLoginStrategy);

meaning that all of the configuration lives on the passport module itself. So, we can't configure different serializers, deserializers, or login strategies for each vhost, even if the apps representations of users are completely different.

Instead, I now have my top level serializer, deserializer, and login strategy delegating to each app:

// passport uses singleton state, so with a vhost setup we can't just use one instance of passport per vhost because the instances will conflict.
// What I've done instead is have one instance of passport that delegates to each vhost as needed for within serializeUser, deserializeUser, and the LocalStrategy.

export const initPassport = (app: Express) => {
  app.use(passport.initialize());

  passport.serializeUser(serializeUser);
  passport.deserializeUser(deserializeUser);
  passport.use(localLoginStrategy);
};

const UnrecognizedVHost = new Error(
  "Attempted to access an unrecognized vhost",
);

export const serializeUser = (user, done) => {
  const done_ = (err, serUser) => done(err, `${user.__vhost}:${serUser}`);
  const vhostName = user.__vhost;
  const vhost = currentVhost(vhostName);

  switch (vhost) {
    case Hosts.yourFirstHost:
      return YourFirstHostPassport.serializeUser(user, done_);
    case Hosts.yourSecondHost:
      return YourSecondHostPassportPassport.serializeUser(user, done_);
    case Hosts.unknown:
      throw UnrecognizedVHost;
  }
};

export const deserializeUser = async (vHostAndId, done) => {
  const [vhostName, id] = vHostAndId.split(":");
  const vhost = currentVhost(vhostName);

  switch (vhost) {
    case Hosts.yourFirstHost:
      return YourFirstHostPassport.deserializeUser(id, done);
    case Hosts.yourSecondHost:
      return YourSecondHostPassport.deserializeUser(id, done);
    case Hosts.unknown:
      throw UnrecognizedVHost;
  }
};

export const hostName = (req: any) => req.hostname;

export const localLoginStrategy = new LocalStrategy(
  { passReqToCallback: true },
  async (req: Request, emailAddress: string, password: string, done) => {
    const vhost = currentVhost(hostName(req));
    switch (vhost) {
      case Hosts.yourFirstHost:
        return YourFirstHostPassport.localLoginVerify(
          req,
          emailAddress,
          password,
          done,
        );
      case Hosts.yourSecondHost:
        return YourSecondHost.localLoginVerify(
          req,
          emailAddress,
          password,
          done,
        );
      case Hosts.unknown:
        throw UnrecognizedVHost;
      default:
        assertNever(vhost);
    }
  },
);

I'm serializing and deserializing from the user's id here, but obviously you could change this up to use something else. You just need to make sure that your vhost is passed along in your serialization somehow.

I defined the vhost selection like this:

export const Hosts = class {
  static readonly yourFirstHost = Symbol();
  static readonly yourSecondHost = Symbol();
  static readonly unknown = Symbol();
};

export type Host =
  | typeof Hosts.yourFirstHost
  | typeof Hosts.yourSecondHost
  | typeof Hosts.unknown;

export const currentVhost = (hostName: string): Host => {
  const yourFirstHost = process.env.YOUR_FIRST_HOST!;
  const yourSecondHost = process.env.YOUR_SECOND_HOST!;

  switch (hostName) {
    case yourFirstHost:
      return Hosts.yourFirstHost;
    case yourSecondHost:
      return Hosts.yourSecondHost;
    default:
      return Hosts.unknown;
  }
};

Using Symbols might be overkill for your use case, but I'd probably at least do something like

export type Host = "myfirsthost.example.com" | "mysecondhost.example.com"

and then case over the incoming hostName to ensure that we handle configuration for all vhosts wherever one might be used. Don't use open ended logic on the hostname (verify that it matches one of our defined vhosts exactly or that we're really okay with the consequences of using a generic fallback for any hostname), since the value comes from the client.

Then, on successful login in the login callback (passport.authenticate("local", (req, res, next) => { ... <somewhere in here> ... });, I set user.__vhost for the current vhost. Everything proceeds normally in each app's serializer, deserializer, and login verifier otherwise.

For the specifics of handling the login flow for each app: I'm planning on open sourcing Sapling's local authentication as soon as I have some free time to package it all up, so stay tuned.

Stay up to date

Get notified when I publish something new, and unsubscribe at any time.