Ebrahim Hoseiny Fadae
Posted on March 10, 2022
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
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);
}
};
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);
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,
});
}
}
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,
};
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);
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),
};
},
}
);
},
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 });
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.";
},
And this to auth router.
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
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
export default () => {
const router = new Router();
const { registerForm } = appController();
router.get("/register", registerForm);
return router;
};
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>
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.
Posted on March 10, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.