Part I: Developing Simple OpenID Authorization Server with Node.js & Typescript
Tags: node, typescript, openid, koa
Published: 2022-02-18 15:58:35

## Introduction

In this tutorial, we will explore how to build an authorization server using the [panava/node-oidc-provider](https://github.com/panva/node-oidc-provider) library, which is built on top of the [koajs/koa](https://github.com/koajs/koa) framework. This library simplifies the implementation of an authorization server while providing customization options based on our requirements.

## Source Code

Completed source code on [ebrahimmfadae/openid-connect-app](https://github.com/ebrahimmfadae/openid-connect-app)

## Let's start

The project has the following directory structure.

```
openid-connect-app/
  public/
  app/
    src/
      controllers/
      routes/
      views/
  oidc/
    src/
      adapters/
      configs/
      controllers/
      db/
      middlewares/
      routes/
      services/
      views/
```

## Config npm

To begin, navigate to your project directory and run the following command in the terminal.

```
:~/openid-connect-app$ npm init -y
```

Make sure that a `package.json` file is created in the project folder.

For simplicity we will use `$` instead of `:~/openid-connect-app$` through the rest of the tutorial.

## Config Typescript

Next, install the required TypeScript dependencies by running the following commands.

```
$ yarn add typescript@4.3.5 ts-node@10.2.0 -T
$ yarn add @types/node@16.4.14 -DT
```

Create a `tsconfig.json` file with this content.

```json
{
  "compilerOptions": {
    "target": "es2020",
    "module": "commonjs",
    "allowJs": true,
    "strict": true,
    "noImplicitAny": false,
    "esModuleInterop": true,
    "resolveJsonModule": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  }
}
```

## Add npm scripts

We can run scripts with `npm run` or `yarn run` command.

```json
{
  "start:oidc": "ts-node oidc/src",
  "start:app": "ts-node app/src"
}
```

## Environment Variables

Various environment variables are required for the server. Please refer to the [docker-compose.yml](https://github.com/ebrahimmfadae/openid-connect-app/blob/main/docker-compose.yml) file in the GitHub repository for a complete list.

## Add authorization server dependencies

Install the necessary dependencies for the authorization server by running the following commands.

```
$ yarn add oidc-provider@7.12.0 koa@2.13.1 -T
$ yarn add @types/oidc-provider@7.6.0 @types/koa@2.13.4 -DT
```

## Create OIDC Provider

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

```ts
import { Provider, Configuration } from "oidc-provider";

export const oidc = (issuer: string, configuration: Configuration) => {
  return new Provider(issuer, configuration);
};
```

The `Provider` class encapsulates every functionality that we need for implementing an authorization server. It takes two arguments. First is an issuer and the second is configuration object.

The issuer is the the base URL of the authorization server which in our case is `http://localhost:3000`. Once we deployed our source code to the production server we must change it to the server's public address. The issuer will be used at different places, So it's important that we provide a correct value.

## OIDC configuration file

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

```ts
import { Configuration } from "oidc-provider";

export const configuration: Configuration = {
  async findAccount(_, id) {
    return {
      accountId: id,
      async claims(_, scope) {
        return { sub: id };
      },
    };
  },
  clients: [
    {
      client_id: "app",
      client_secret: "scorpion",
      redirect_uris: ["http://localhost:3005/cb"],
      grant_types: ["authorization_code"],
      scope: "openid",
    },
  ],
  pkce: { required: () => false, methods: ["S256"] },
};
```

The simplest way we could config our oidc server is to add one single client and a way to tell `Provider` how it can find an account. We simplify it more by mocking the account fetching operation and returning an account with a passed id regardless of its value.

The `redirect_uris` holds the addresses that the client can redirect to. It must be a public address. `http://localhost:3005` is the public address of our app.

The `pkce` is a mechanism for improving token exchange security but requires more effort to implement. I will say how to use it in a separate tutorial.

## OIDC server index

_`./oidc/src/index.ts`_

```ts
import Koa from "koa";
import mount from "koa-mount";
import render from "koa-ejs";
import koaStatic from "koa-static";
import { oidc } from "./configs/provider";
import { configuration } from "./configs/configuration";

const provider = oidc(process.env.OIDC_ISSUER as string, configuration);

const start = async () => {
  const app = new Koa();
  render(app, {
    cache: false,
    viewExt: "ejs",
    layout: false,
    root: path.resolve("oidc/src/views"),
  });
  const provider = oidc(process.env.OIDC_ISSUER as string, configuration);
  app.use(koaStatic(path.resolve("public")));
  app.use(mount(provider.app));
  app.listen(process.env.PORT, () =>
    console.log(`oidc-provider listening on port ${process.env.PORT}`);
  );
};

void start();
```

First we will create a `Koa` instance and a `Provider`. Actually `provider.app` is a complete koa application on its own, But things are more manageable if we plug it into our own koa app. In order to do this we going to use `koa-mount`.

By running this command you can start authorization server.

```
$ yarn run start:oidc
```

## OIDC client

We need a frontend client to interact with authorization server. For this purpose we are going to use koa and EJS in combination. [mde/ejs](https://github.com/mde/ejs) is a template engine. It gives us the ability to write HTML files with more flexibility.

Our HTML pages are all using _`public/main.css`_ as main style. You can find it in GitHub repository ([Here](https://github.com/ebrahimmfadae/openid-connect-app/blob/main/public/main.css)).

There is a HTML part that is repeated in almost every file and we are going to use a feature of EJS to reduce the boilerplate. We will separate that piece and then include it wherever needed.

_`./app/views/components/head.ejs`_

```html
<head>
  <meta http-equiv="X-UA-Compatible" content="IE=edge" />
  <meta charset="utf-8" />
  <meta
    name="viewport"
    content="width=device-width, initial-scale=1, shrink-to-fit=no"
  />
  <title><%= title %></title>
  <link href="/main.css" rel="stylesheet" type="text/css" />
</head>
```

Every time you see:

```html
<%- include('components/head'); -%>
```

That's the _MAGIC_!

## Add dependencies

```
$ yarn add koa-ejs@4.3.0 koa-static@5.0.0 -T
$ yarn add @types/koa-ejs@4.2.4 @types/koa-static@4.0.2 -DT
```

## Login Page

If you haven't logged in already, you will first be redirected to `login` screen and it asks you to enter your credentials and then you will be redirected to `consent` page. For now every username and password is valid; Because we mocked user fetching. Later in the series we will replace this part with real user authentication.

## Add login page

_`./oidc/src/views/login.ejs`_

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

        <button type="submit" class="login login-submit">Sign-in</button>
      </form>
      <div class="login-help">
        <a href="/interaction/<%= uid %>/abort">[ Cancel ]</a>
      </div>
    </div>
    <%- include('components/footer'); -%>
  </body>
</html>
```

What data we are sending?

- `username`
- `password`

## Add consent page

`consent` is the final step of authorization when you will hit the authorize button in order to give grant to a client for issuing refresh token for your user.

_`./oidc/src/views/consent.ejs`_

```html
<!DOCTYPE html>
<html>
  <%- include('components/head'); -%>
  <body>
    <div class="login-card">
      <h1><%= title %></h1>
      <form
        autocomplete="off"
        action="/interaction/<%= uid %>/confirm"
        method="post"
      >
        <p>
          Do you allow <strong><%= clientId %></strong> to access your account
          information? (<strong><%= scope %></strong>)
        </p>
        <button type="submit" class="login login-submit">Authorize</button>
      </form>
    </div>
    <%- include('components/footer'); -%>
  </body>
</html>
```

## Add auth controllers

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

```ts
import { Middleware } from "koa";
import { Provider } from "oidc-provider";
import * as accountService from "../services/account.service";

function debug(obj: any) {
  return Object.entries(obj)
    .map(
      (ent: [string, any]) =>
        `<strong>${ent[0]}</strong>: ${JSON.stringify(ent[1])}`
    )
    .join("<br>");
}

export default (oidc: Provider): { [key: string]: Middleware } => ({
  interaction: async (ctx) => {}, // 1 (See below)
  login: async (ctx) => {}, // 2 (See below)
  abortInteraction: async (ctx) => {}, // 3 (See below)
  confirmInteraction: async (ctx) => {}, // 4 (See below)
});
```

When a user requests for authorization it will be redirected to `/interaction` route with some specific information. `interaction` controller captures these information to login user or ask it for consent.

```ts
// 1
async function interaction(ctx) {
  const { uid, prompt, params, session } = (await oidc.interactionDetails(
    ctx.req,
    ctx.res
  )) as any;

  if (prompt.name === "login") {
    return ctx.render("login", {
      uid,
      details: prompt.details,
      params,
      session: session ? debug(session) : undefined,
      title: "Sign-In",
      dbg: {
        params: debug(params),
        prompt: debug(prompt),
      },
    });
  } else if (prompt.name === "consent") {
    return ctx.render("consent", {
      uid,
      title: "Authorize",
      clientId: params.client_id,
      scope: params.scope.replace(/ /g, ", "),
      session: session ? debug(session) : undefined,
      dbg: {
        params: debug(params),
        prompt: debug(prompt),
      },
    });
  } else {
    ctx.throw(501, "Not implemented.");
  }
}
```

On the login page user will send its login credentials to `/login` route. `login` controller will handle the request. If credentials are valid user will be redirected to consent page.

```ts
// 2
async function login(ctx) {
  const {
    prompt: { name },
  } = await oidc.interactionDetails(ctx.req, ctx.res);
  if (name === "login") {
    const account = await accountService.get(ctx.request.body.username);
    let result: any;
    if (account?.password === ctx.request.body.password) {
      result = {
        login: {
          accountId: ctx.request.body.username,
        },
      };
    } else {
      result = {
        error: "access_denied",
        error_description: "Username or password is incorrect.",
      };
    }
    return oidc.interactionFinished(ctx.req, ctx.res, result, {
      mergeWithLastSubmission: false,
    });
  }
}
```

If user hits the cancel button on grant page, This endpoint will be called.

```ts
// 3
async function abortInteraction(ctx) {
  const result = {
    error: "access_denied",
    error_description: "End-User aborted interaction",
  };
  await oidc.interactionFinished(ctx.req, ctx.res, result, {
    mergeWithLastSubmission: false,
  });
}
```

If the user hit authorize button on grant page, This controller will be called.

```ts
// 4
async function confirmInteraction (ctx) {
    const interactionDetails = await oidc.interactionDetails(ctx.req, ctx.res);
    const {
      prompt: { name, details },
      params,
      session: { accountId },
    } = interactionDetails as any;

    if (name === "consent") {
      const grant = interactionDetails.grantId
        ? await oidc.Grant.find(interactionDetails.grantId)
        : new oidc.Grant({
            accountId,
            clientId: params.client_id as string,
          });

      if (grant) {
        if (details.missingOIDCScope) {
          grant.addOIDCScope(details.missingOIDCScope.join(" "));
        }
        if (details.missingOIDCClaims) {
          grant.addOIDCClaims(details.missingOIDCClaims);
        }
        if (details.missingResourceScopes) {
          for (const [indicator, scopes] of Object.entries(
            details.missingResourceScopes
          )) {
            grant.addResourceScope(indicator, (scopes as any).join(" "));
          }
        }

        const grantId = await grant.save();

        const result = { consent: { grantId } };
        await oidc.interactionFinished(ctx.req, ctx.res, result, {
          mergeWithLastSubmission: true,
        });
      }
    } else {
      ctx.throw(400, "Interaction prompt type must be `consent`.");
    }
  },
```

## Add auth router

_`./oidc/src/routes/auth.router.ts`_

```ts
import koaBody from "koa-body";
import Router from "koa-router";
import { Provider } from "oidc-provider";
import authController from "../controllers/auth.controller";
import { authenticate } from "../middlewares/auth.middleware";
import { noCache } from "../middlewares/no-cache.middleware";

const bodyParser = koaBody();

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

  const { abortInteraction, confirmInteraction, interaction, login } =
    authController(oidc);

  router.post("/interaction/:uid/login", noCache, bodyParser, login);
  router.post("/interaction/:uid/confirm", noCache, confirmInteraction);
  router.get("/interaction/:uid/abort", noCache, abortInteraction);
  router.get("/interaction/:uid", noCache, interaction);

  return router;
};
```

## Update configs

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

```ts
export const configuration: Configuration = {
  // ...
  features: {
    devInteractions: { enabled: false },
  },
};
```

## Add user claims

Here we defined scope and claims in configuration object.

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

```ts
import * as accountService from "../services/account.service";

export const configuration: Configuration = {
  async findAccount(ctx, id) {
    const account = { emailVerified: true, email: "ebrahimmfadae@gmail.com" };
    return (
      account && {
        accountId: id,
        async claims(_, scope) {
          if (!scope) return undefined;
          const openid = { sub: id };
          const email = {
            email: account.email,
            email_verified: account.emailVerified,
          };
          const accountInfo = {};
          if (scope.includes("openid")) Object.assign(accountInfo, openid);
          if (scope.includes("email")) Object.assign(accountInfo, email);
          return accountInfo;
        },
      }
    );
  },
  clients: [
    {
      client_id: "app",
      client_secret: "scorpion",
      redirect_uris: ["http://localhost:3005/cb"],
      grant_types: ["authorization_code"],
      scope: "openid email profile phone address offline_access",
    },
  ],
  claims: {
    address: ["address"],
    email: ["email", "email_verified"],
    phone: ["phone_number", "phone_number_verified"],
    profile: [
      "birthdate",
      "family_name",
      "gender",
      "given_name",
      "locale",
      "middle_name",
      "name",
      "nickname",
      "picture",
      "preferred_username",
      "profile",
      "updated_at",
      "website",
      "zoneinfo",
    ],
  },
};
```

## Frontend startup script

In order for server to be able to read static resources like _`public/main.css`_ we need to use `koa-static`.

_`./app/index.ts`_

```ts
import Koa from "koa";
import render from "koa-ejs";
import koaStatic from "koa-static";
import path from "path";
import routes from "./routes";

const app = new Koa();
render(app, {
  cache: false,
  viewExt: "ejs",
  layout: false,
  root: path.resolve("app/src/views"),
});

app.use(koaStatic(path.resolve("public")));
app.use(routes().routes());

app.listen(process.env.PORT, () =>
  console.log(`sample-app listening on port ${process.env.PORT}`)
);
```

You run front-end server with this command.

```
$ yarn run start:app
```

## Design main page

All pages will have a simple html structure; So main page is just a form with some essential inputs. This page is our `sample-app`.

_`./app/src/views/sample-app.ejs`_

```html
<!DOCTYPE html>
<html>
  <%- include('components/head'); -%>
  <body class="app">
    <div class="login-card">
      <h1><%= title %></h1>
      <form action="<%= authServerUrl %>/auth" method="post">
        <label>Client Id</label>
        <input required name="client_id" value="<%= clientId %>" />
        <label>Response Type</label>
        <input required name="response_type" value="code" />
        <label>Redirect URI</label>
        <input required name="redirect_uri" value="<%= appUrl %>/cb" />
        <label>Scope</label>
        <input required name="scope" value="openid" />

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

What data we are sending?

- `client_id` of our app in authorization server.
- `response_type`, Which here is `code` because we want a code for completing authorization_code flow.
- `redirect_uri` is the address that auth server will navigate us to after completing the grant request (whether on success or fail). And because we want to navigate back to `app` we set this to `appUrl`.
- `scope`, The scopes that the user will be granted to access.

## App router

Here we config our server routes. Currently there is no route except index.

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

```ts
import Router from "koa-router";
import appController from "../controllers/app.controller";

export default () => {
  const router = new Router();
  const { sampleApp } = appController();
  router.get("/", sampleApp);
  return router;
};
```

_`./app/routes/index.ts`_

```ts
import Router from "koa-router";
import appRouter from "../routes/app.router";

export default () => {
  const router = new Router();
  router.use(appRouter().routes());
  return router;
};
```

## Load sample-app page

EJS is just a template engine. It can't serve itself. We must add a controller to koa to make it do this for us.

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

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

export default (): { [key: string]: Middleware } => ({
  sampleApp: async (ctx) => {
    return ctx.render("sample-app", {
      title: "Sample App",
      authServerUrl: process.env.PUBLIC_OIDC_ISSUER,
      appUrl: process.env.PUBLIC_APP_URL,
      clientId: "app",
    });
  },
});
```

## Issue Token

Now that we have implemented our authorization server and our app we are going to add ability to issue a token for granted user. All we have done is tended to reach this step.

## Add issue token page

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

```html
<!DOCTYPE html>
<html>
  <%- include('components/head'); -%>
  <body class="app">
    <div class="login-card">
      <h1><%= title %></h1>
      <form
        autocomplete="off"
        action="<%= authServerUrl %>/token"
        method="post"
      >
        <label>Client Id</label>
        <input required name="client_id" value="<%= clientId %>" />
        <label>Client Secret</label>
        <input required name="client_secret" value="<%= clientSecret %>" />
        <label>Grant Type</label>
        <input required name="grant_type" value="authorization_code" />
        <label>Code</label>
        <input required name="code" value="<%= code %>" />
        <label>Redirect URI</label>
        <input required name="redirect_uri" value="<%= appUrl %>/cb" />
        <label>Scope</label>
        <input required name="scope" value="openid" />

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

What new data we are sending?

- `client_secret` of our app in authorization server.
- `grant_type`, Which here is `authorization_code`.
- `code` that we received on authorization step.

## Add app controller

Authorization server redirects user to `callback` address with error or success statuses. If user approve access a code is passed to callback route as query param. Otherwise if user rejects grant request, an error parameter is passed to query. According to these parameters we complete authorization flow.

Here for sake of learning we designed a form to manually get a token. In a real life scenario you probably want to automatically request issue token and then redirect to the desired app page.

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

```ts
export default (): { [key: string]: Middleware } => ({
  callback: async (ctx) => {
    if ("error" in ctx.query) {
      ctx.throw(401, `${ctx.query.error}: ${ctx.query.error_description}`);
    } else {
      return ctx.render("token", {
        code: ctx.query.code,
        title: "App Callback",
        authServerUrl: process.env.PUBLIC_OIDC_ISSUER,
        appUrl: process.env.PUBLIC_APP_URL,
        clientId: process.env.CLIENT_ID,
        clientSecret: process.env.CLIENT_SECRET,
      });
    }
  },
});
```

## Add app router

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

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

  const { callback } = appController();

  router.get("/cb", callback);

  return router;
};
```

## Run all

Use this command to run all services. Then visit `http://localhost:3000` and follow the authentication flow.

```
$ yarn compose:up oidc app api
```

## Summary

We have implemented an authorization server and an app to communicate with it. We have no user registration, but don't worry we will add it in when we implemented MongoDB as persistent database.