Part II: Persisting OpenID Server Data on MongoDB with Node.js
Tags: node, openid, mongodb, koa
Published: 2022-03-10 12:41:08

## Introduction

In this article, we'll explore how to integrate [MongoDB](https://www.mongodb.com/) with an OIDC ([OpenID Connect 1.0](https://openid.net/specs/openid-connect-core-1_0.html)) authentication server. We'll be using the [panava/node-oidc-provider](https://github.com/panva/node-oidc-provider) library for implementing the OIDC server and the [Mongoose](https://mongoosejs.com/) for connecting to MongoDB.

## Let's start

Assuming you have followed the previous articles, you only need to add the necessary database dependencies.

```
$ yarn add mongoose@6.0.5 -T
```

## Connect to MongoDB

For connecting to the MongoDB instance we only need to call `connect` from the mongoose library.

_`./oidc/src/db/mongodb/connection.ts`_

```ts
import mongoose from "mongoose";

export default async () => {
  const URI = process.env.MONGODB_URI ?? "";
  try {
    return await mongoose.connect(URI, {});
  } catch (error) {
    console.error(error);
  }
};
```

## Add Base model

Now that we have our database connected, we need to design our models to store the data. The first option that comes to mind is that we create a model for every entity that we have (.e.g AccessToken, Session, AuthorizationCode, .etc).
But we are not doing this because of the reasons, I will mention:

- You don't want to search every collection for an entity. (Most of the time you have an id and don't know where it belongs)
- Global constraints are harder to implement (.e.g grantId must be unique across every authentication flow).
- Repetitive logic (Handle expiration for every collection)
- Engaging with the logic that you will barely use directly. (Actually, oidc uses the detail not you)
- The oidc library eases the process and it gives us a payload object and wants a payload object back. The oidc mention these entities as `model`.

We will create an object which holds our payload and we call it BaseModel.

_`./oidc/src/db/mongodb/models/BaseModel.ts`_

```ts
import mongoose, { Schema } from "mongoose";

const BaseModelSchema = new Schema({
  key: { type: String, required: true },
  payload: { type: Object, required: true },
  expiresAt: { type: Date, required: true },
});

/**
 * key must be unique for every model
 */
BaseModelSchema.index(
  { key: 1, "payload.kind": 1 },
  {
    unique: true,
  }
);

/**
 * uid must be unique for every model == Session
 */
BaseModelSchema.index(
  { "payload.uid": 1 },
  {
    unique: true,
    partialFilterExpression: { "payload.kind": "Session" },
  }
);

/**
 * grantId must be unique for every authentication request model
 */
BaseModelSchema.index(
  { "payload.grantId": 1 },
  {
    unique: true,
    partialFilterExpression: {
      "payload.kind": {
        $in: [
          "AccessToken",
          "AuthorizationCode",
          "RefreshToken",
          "DeviceCode",
          "BackchannelAuthenticationRequest",
        ],
      },
    },
  }
);

/**
 * userCode must be unique for every model == DeviceCode
 */
BaseModelSchema.index(
  { "payload.userCode": 1 },
  {
    unique: true,
    partialFilterExpression: { "payload.kind": "DeviceCode" },
  }
);

/**
 * says that document must be removed on expiresAt with 0 delay (expireAfterSeconds: 0)
 */
BaseModelSchema.index(
  { expiresAt: 1 },
  {
    expireAfterSeconds: 0,
  }
);

export const BaseModel = mongoose.model("BaseModel", BaseModelSchema);
```

## Write an adapter

Now we must tell oidc to use our BaseModel, but how?

Since [panva/node-oidc-provider](https://github.com/panva/node-oidc-provider) doesn't have an interface to derive it and implement our adapter (It's written in pure JS); we must provide required methods through an object or a class. To do this we use [adapters/memory_adapter.js](https://github.com/panva/node-oidc-provider/blob/main/lib/adapters/memory_adapter.js) as a reference class and implement our adapter logic.

_`./oidc/src/adapters/mongodb.ts`_

```ts
import { BaseModel } from "../db/mongodb/models/BaseModel";

export class MongoDbAdapter {
  model: string;

  /**
   *
   * Creates an instance of MongoDbAdapter for an oidc-provider model.
   *
   * @constructor
   * @param {string} name Name of the oidc-provider model. One of "Grant, "Session", "AccessToken",
   * "AuthorizationCode", "RefreshToken", "ClientCredentials", "Client", "InitialAccessToken",
   * "RegistrationAccessToken", "DeviceCode", "Interaction", "ReplayDetection",
   * "BackchannelAuthenticationRequest", or "PushedAuthorizationRequest"
   *
   */
  constructor(name: string) {
    this.model = name;
  }

  /**
   *
   * Update or Create an instance of an oidc-provider model.
   *
   * @return {Promise} Promise fulfilled when the operation succeeded. Rejected with error when
   * encountered.
   * @param {string} id Identifier that oidc-provider will use to reference this model instance for
   * future operations.
   * @param {object} payload Object with all properties intended for storage.
   * @param {number} expiresIn Number of seconds intended for this model to be stored.
   *
   */
  async upsert(id: string, payload: any, expiresIn: number): Promise<any> {
    return await BaseModel.updateOne(
      {
        key: id,
      },
      { payload, expiresAt: new Date(Date.now() + expiresIn * 1000) },
      { upsert: true }
    );
  }

  /**
   *
   * Return previously stored instance of an oidc-provider model.
   *
   * @return {Promise} Promise fulfilled with what was previously stored for the id (when found and
   * not dropped yet due to expiration) or falsy value when not found anymore. Rejected with error
   * when encountered.
   * @param {string} id Identifier of oidc-provider model
   *
   */
  async find(id: string): Promise<any> {
    const doc: any = await BaseModel.findOne({
      key: id,
      "payload.kind": this.model,
    });
    return doc?.payload;
  }

  /**
   *
   * Return previously stored instance of DeviceCode by the end-user entered user code. You only
   * need this method for the deviceFlow feature
   *
   * @return {Promise} Promise fulfilled with the stored device code object (when found and not
   * dropped yet due to expiration) or falsy value when not found anymore. Rejected with error
   * when encountered.
   * @param {string} userCode the user_code value associated with a DeviceCode instance
   *
   */
  async findByUserCode(userCode: string): Promise<any> {
    const doc: any = await BaseModel.findOne({
      "payload.kind": "DeviceCode",
      "payload.userCode": userCode,
    });
    return doc?.payload;
  }

  /**
   *
   * Return previously stored instance of Session by its uid reference property.
   *
   * @return {Promise} Promise fulfilled with the stored session object (when found and not
   * dropped yet due to expiration) or falsy value when not found anymore. Rejected with error
   * when encountered.
   * @param {string} uid the uid value associated with a Session instance
   *
   */
  async findByUid(uid: string): Promise<any> {
    const doc: any = await BaseModel.findOne({
      "payload.kind": "Session",
      "payload.uid": uid,
    });
    return doc?.payload;
  }

  /**
   *
   * Mark a stored oidc-provider model as consumed (not yet expired though!). Future finds for this
   * id should be fulfilled with an object containing additional property named "consumed" with a
   * truthy value (timestamp, date, boolean, etc).
   *
   * @return {Promise} Promise fulfilled when the operation succeeded. Rejected with error when
   * encountered.
   * @param {string} id Identifier of oidc-provider model
   *
   */
  async consume(id: string): Promise<any> {
    return BaseModel.updateOne(
      {
        key: id,
        "payload.kind": this.model,
      },
      { consumed: Date.now() / 1000 }
    );
  }

  /**
   *
   * Destroy/Drop/Remove a stored oidc-provider model. Future finds for this id should be fulfilled
   * with falsy values.
   *
   * @return {Promise} Promise fulfilled when the operation succeeded. Rejected with error when
   * encountered.
   * @param {string} id Identifier of oidc-provider model
   *
   */
  async destroy(id: string): Promise<any> {
    return BaseModel.deleteOne({
      key: id,
      "payload.kind": this.model,
    });
  }

  /**
   *
   * Destroy/Drop/Remove a stored oidc-provider model by its grantId property reference. Future
   * finds for all tokens having this grantId value should be fulfilled with falsy values.
   *
   * @return {Promise} Promise fulfilled when the operation succeeded. Rejected with error when
   * encountered.
   * @param {string} grantId the grantId value associated with a this model's instance
   *
   */
  async revokeByGrantId(grantId: string): Promise<any> {
    return BaseModel.deleteMany({
      "payload.grantId": grantId,
    });
  }
}
```

### Add adapter to configs

This is a simplified config object. You have to add the adapter to your full configuration object.

_`./oidc/src/configs/configuration.ts`_

```ts
import { MongoDbAdapter } from "../adapters/mongodb";

export const configuration: Configuration = {
  adapter: MongoDbAdapter,
};
```

## Get into details

Up to this point we implemented our persistent OpenID server, but we never mentioned our most important entity.

> Where is user account?

OpenID Connect protocol doesn't say anything about how you should store your user account or how must registration flow be; These are all up to you. And here is were we are diving into details. Here is where model properties are directly used in our system.

## Add user account

First we will create a very simple user account model. For simplification i didn't used any security approach for storing password. It's just a plain text.

_`./oidc/src/db/mongodb/models/Account.ts`_

```ts
import mongoose, { Schema } from "mongoose";

const AccountSchema = new Schema({
  username: {
    type: String,
    unique: true,
  },
  password: String,
  email: {
    type: String,
    unique: true,
  },
  emailVerified: {
    type: Boolean,
    default: false,
  },
});

export const Account = mongoose.model("Account", AccountSchema);
```

### Update findAccount() in configuration

_`./oidc/src/configs/configuration.ts`_

```ts
  async findAccount(ctx, id) {
    const account = await accountService.get(id);
    return (
      account && {
        accountId: id,
        async claims(use /* id_token, userinfo */, scope, claims) {
          if (!scope) return undefined;
          const openid = { sub: id };
          const email = {
            email: account.email,
            email_verified: account.emailVerified,
          };
          return {
            ...(scope.includes("openid") && openid),
            ...(scope.includes("email") && email),
          };
        },
      }
    );
  },
```

### Add user repository service

Replace this file everywhere that we used `account.service.ts`.

_`./oidc/src/services/account-persistent.service.ts`_

```ts
import { Account } from "../db/models/Account";

export const get = async (key: string) => Account.findOne({ username: key });
export const set = async (key: string, value: any) => Account.insertOne({ username: key }, { ...value });
```

### Add register controller

Append this to auth controller.

_`./oidc/src/controllers/auth.controller.ts`_

```ts
  async function register(ctx) {
    const body = ctx.request.body;
    if(await accountService.get(body.username)) ctx.throw(400); 
    await accountService.set(body.username, {
      username: body.username,
      password: body.password,
    });
    ctx.message = "User successfully created.";
  },
```

And this to auth router.

```ts
router.post("/users", bodyParser, register);
```

Probably we don't want to let everybody register a user in our system. For protecting against that we must use client credential authentication. We will do this in later tutorials.

## Add register page to the app

After implementing back-end logic we must update our app server to handle registration.

_`./app/src/controllers/app.controller.ts`_

```ts
import { Middleware } from "koa";

export default (): { [key: string]: Middleware } => ({
  registerForm: async (ctx) => {
    return ctx.render("register", {
      title: "Register User",
      authServerUrl: process.env.AUTH_ISSUER,
    });
  },
});
```

### Add router

_`./app/src/routes/app.router.ts`_

```ts
export default () => {
  const router = new Router();

  const { registerForm } = appController();

  router.get("/register", registerForm);

  return router;
};
```

### Add register page

_`./app/src/views/register.ejs`_

```html
<!DOCTYPE html>
<html>
  <%- include('components/head'); -%>
  <body class="app">
    <div class="login-card">
      <h1><%= title %></h1>
      <form
        autocomplete="off"
        action="<%= authServerUrl %>/users"
        method="post"
      >
        <label>Username</label>
        <input required type="text" name="username" placeholder="username" />
        <label>Password</label>
        <input
          required
          type="password"
          name="password"
          placeholder="and password"
        />

        <button type="submit" class="login login-submit">Register</button>
      </form>
    </div>
  </body>
</html>
```

What data we are sending?

- `username`
- `password`

## Summary

In this part, we learned how to persist our data in an OpenID server. We saw that we have to implement user registration by ourselves which is both a good and a bad thing. Good because of the flexibility and bad because of the design mistakes we probably make. In the next part, we will create a resource server to try out our authorization server.