DEV Community

Ebrahim Hoseiny Fadae
Ebrahim Hoseiny Fadae

Posted on • Updated on

Part II: Persisting OpenID Server Data on MongoDB with Node.js

Introduction

In this article, we'll explore how to integrate MongoDB with an OIDC (OpenID Connect 1.0) authentication server. We'll be using the panava/node-oidc-provider library for implementing the OIDC server and the Mongoose 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
Enter fullscreen mode Exit fullscreen mode

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

import mongoose from "mongoose";

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

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

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);
Enter fullscreen mode Exit fullscreen mode

Write an adapter

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

Since 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 as a reference class and implement our adapter logic.

./oidc/src/adapters/mongodb.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,
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

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

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

export const configuration: Configuration = {
  adapter: MongoDbAdapter,
};
Enter fullscreen mode Exit fullscreen mode

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

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);
Enter fullscreen mode Exit fullscreen mode

Update findAccount() in configuration

./oidc/src/configs/configuration.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),
          };
        },
      }
    );
  },
Enter fullscreen mode Exit fullscreen mode

Add user repository service

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

./oidc/src/services/account-persistent.service.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 });
Enter fullscreen mode Exit fullscreen mode

Add register controller

Append this to auth controller.

./oidc/src/controllers/auth.controller.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.";
  },
Enter fullscreen mode Exit fullscreen mode

And this to auth router.

router.post("/users", bodyParser, register);
Enter fullscreen mode Exit fullscreen mode

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

import { Middleware } from "koa";

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

Add router

./app/src/routes/app.router.ts

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

  const { registerForm } = appController();

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

  return router;
};
Enter fullscreen mode Exit fullscreen mode

Add register page

./app/src/views/register.ejs

<!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>
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (13)

Collapse
 
kaboume profile image
Eric

Thanks a lot for this article !

Collapse
 
ebrahimmfadae profile image
Ebrahim Hoseiny Fadae

You're welcome 😄

Collapse
 
webdiy profile image
web-diy

Does panva/node-oidc-provider support the use of PostgreSQL. I want to use it instead of MongoDB.

I would be grateful for any help.

Because when I try to rewrite the Mongo code, I get a lot of errors.

Type '(_: KoaContextWithOIDC, id: string) => Promise<{ accountId: string; claims(_: string, scope: string): Promise<{} | undefined>; } | null>' is not assignable to type 'FindAccount'.
Type 'Promise<{ accountId: string; claims(_: string, scope: string): Promise<{} | undefined>; } | null>' is not assignable to type 'CanBePromise<Account | undefined>'.
Type 'Promise<{ accountId: string; claims(_: string, scope: string): Promise<{} | undefined>; } | null>' is not assignable to type 'Promise<Account | undefined>'.
Type '{ accountId: string; claims(_: string, scope: string): Promise<{} | undefined>; } | null' is not assignable to type 'Account | undefined'.
Type 'null' is not assignable to type 'Account | undefined'.

Collapse
 
ebrahimmfadae profile image
Ebrahim Hoseiny Fadae • Edited

It supports any database you want. You only need to implement the adapter for postgres instead of mongodb. And I think you faced this error because you have enabled strict mode in tsconfig.

Collapse
 
webdiy profile image
web-diy • Edited

I was able to make an Account model. And the table in PG is created.
But now I am unable to create a BaseModel in PG similar to MongoDB.

I get an error: "original: error: column "payload->>'kind'" does not exist"

`sql: `CREATE UNIQUE INDEX "base_models_key_payload" ON "BaseModels" ("key", "payload->>'kind'")`,`
Enter fullscreen mode Exit fullscreen mode

Perhaps you can tell me how to build the correct model for PG?



const { Sequelize, DataTypes, Op} = require('sequelize');


const dbHost = process.env.DB_HOST || ' ';
const dbPort = parseInt(process.env.DB_PORT || ' ', 10);
const dbName = process.env.DB_NAME || ' ';
const dbUser = process.env.DB_USER || ' ';
const dbPassword = process.env.DB_PASSWORD || ' ';
const sequelize = new Sequelize(dbName, dbUser, dbPassword, {
  host: dbHost,
  port: dbPort,
  dialect: 'postgres',
});

//"BaseModel"
const BaseModel = sequelize.define('BaseModel', {
    key: {
      type: DataTypes.STRING,
      allowNull: false,
    },
    payload: {
      type: DataTypes.JSONB,
      allowNull: false,
    },
    expiresAt: {
      type: DataTypes.DATE,
      allowNull: false,
    },
  }, {

    indexes: [
      { fields: ['key', 'payload->>\'kind\''], unique: true, name: 'base_models_key_payload' },
      { fields: ['payload->>\'uid\''], unique: true, where: { payload: { kind: 'Session' } } },
      { fields: ['payload->>\'grantId\''], unique: true, where: { payload: { kind: ['AccessToken', 'AuthorizationCode', 'RefreshToken', 'DeviceCode', 'BackchannelAuthenticationRequest'] } } },
      { fields: ['payload->>\'userCode\''], unique: true, where: { payload: { kind: 'DeviceCode' } } },
      { fields: ['expiresAt'], where: { expiresAt: { [Op.lt]: new Date() } } },
    ],
  });

// Synchronizing the model with the database
(async () => {
  try {
    await sequelize.sync();
    console.log('OK "BaseModel" ');
  } catch (error) {
    console.error('Error "BaseModel":', error);
  }
})();

export  {BaseModel};


Enter fullscreen mode Exit fullscreen mode
Thread Thread
 
ebrahimmfadae profile image
Ebrahim Hoseiny Fadae

I'm not familiar with creating indexes on an unstructured JSONB object in relational databases. I looked for an answer and found stackoverflow.com/a/69575659. I hope this helps.

Collapse
 
kaboume profile image
Eric

Have you used this oidc server in production? Are you satisfied because I hesitate between panva node-oidc-provider and Ory Hydra. I wonder if panva node-oidc-provider will be well maintained over time...

Collapse
 
ebrahimmfadae profile image
Ebrahim Hoseiny Fadae

I have not used panava/node-oidc-provider in production. My primary goal was to show the OpenID protocol implementation. In my opinion ory/hydra is way better and more performant. Also, it is written in go, Which i love. Don't know about the future, But currently, panava/node-oidc-provider is under active development. In the end, what matters is the protocol itself, Implementation could vary based on different factors.

Collapse
 
kaboume profile image
Eric

Thanks a lot for your answer. !

Collapse
 
kaboume profile image
Eric

And did you wrote an Account Class for MongoDB ?

Collapse
 
ebrahimmfadae profile image
Ebrahim Hoseiny Fadae

Do you mean Account.ts?

Collapse
 
kaboume profile image
Eric

In the file "account.service.ts", you import { accounts } from "../db/memory".
If I want to use MongoDB as database for accounts, I have to import in the "account.service.ts" file a db/mongodb.js file which uses the db/mongodb/Account.ts file ?

Thread Thread
 
ebrahimmfadae profile image
Ebrahim Hoseiny Fadae

Yes, It's correct. I will update the article to mention this.