DEV Community

Ebrahim Hoseiny Fadae
Ebrahim Hoseiny Fadae

Posted on • Updated on

Part I: Developing Simple OpenID Authorization Server with Node.js & Typescript

Introduction

In this tutorial, we will explore how to build an authorization server using the panava/node-oidc-provider library, which is built on top of the 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

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

Config npm

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

:~/openid-connect-app$ npm init -y
Enter fullscreen mode Exit fullscreen mode

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

Create a tsconfig.json file with this content.

{
  "compilerOptions": {
    "target": "es2020",
    "module": "commonjs",
    "allowJs": true,
    "strict": true,
    "noImplicitAny": false,
    "esModuleInterop": true,
    "resolveJsonModule": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  }
}
Enter fullscreen mode Exit fullscreen mode

Add npm scripts

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

{
  "start:oidc": "ts-node oidc/src",
  "start:app": "ts-node app/src"
}
Enter fullscreen mode Exit fullscreen mode

Environment Variables

Various environment variables are required for the server. Please refer to the 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
Enter fullscreen mode Exit fullscreen mode

Create OIDC Provider

./oidc/src/configs/provider.ts

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

export const oidc = (issuer: string, configuration: Configuration) => {
  return new Provider(issuer, configuration);
};
Enter fullscreen mode Exit fullscreen mode

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

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"] },
};
Enter fullscreen mode Exit fullscreen mode

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

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

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

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 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).

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

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

Every time you see:

<%- include('components/head'); -%>
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

Add auth controllers

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

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.

// 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.");
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

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

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

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

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

// 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`.");
    }
  },
Enter fullscreen mode Exit fullscreen mode

Add auth router

./oidc/src/routes/auth.router.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;
};
Enter fullscreen mode Exit fullscreen mode

Update configs

./oidc/src/configs/configuration.ts

export const configuration: Configuration = {
  // ...
  features: {
    devInteractions: { enabled: false },
  },
};
Enter fullscreen mode Exit fullscreen mode

Add user claims

Here we defined scope and claims in configuration object.

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

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

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

You run front-end server with this command.

$ yarn run start:app
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

./app/routes/index.ts

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

export default () => {
  const router = new Router();
  router.use(appRouter().routes());
  return router;
};
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

Add app router

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

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

  const { callback } = appController();

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

  return router;
};
Enter fullscreen mode Exit fullscreen mode

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

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.

Top comments (8)

Collapse
 
webdiy profile image
web-diy

Hi! Thank you for always answering questions quickly.
I am currently trying to deploy an application on a remote server. And I'm having trouble with that.

The problem arises at the last stage - getting tokens when I try to send a CallBack form. And after it there is an error 500.
I think that the problem appears due to the fact that the OIDC on the server uses HTTPS. The APP uses HTTP.

Image description
Image description
Image description

Please tell me, maybe there are some ideas how to make this application work correctly on a remote server.

Here are the changed environment variables:
Public REMOTE addresses
PUBLIC_OIDC_ISSUER=testsso.site.com:443
PUBLIC_APP_URL=testsso.site.com:3005
PUBLIC_API_URL=testsso.site.com:3006

And also, an error occurs when registering a user: Internal Server Error

Another such warning appears in the logs:
oidc-provider WARNING: x-forwarded-proto header not detected for an https issuer, you must configure your ssl offloading proxy and the provider, see documentation for more details: github.com/panva/node-oidc-provide...

P.S. I configured the NGINX server and now the transition from HTTP < - > HTTPS is working correctly. But the 500 error still appears when issuing tokens (send form on /token). And the 500 error appears when re-authorizing, when we should go to the page with CallBack

On the 'localhost', the application works without errors.

Collapse
 
zhamdi profile image
Zied Hamdi

Thank you for sharing this tutorial, I just joined a company who provides with OIDC and IAM services, and didn't find docs to get started fast. Your article came at rescue

Collapse
 
varsha profile image
varsha kumari
Collapse
 
webdiy profile image
web-diy

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.

Hello! Please give me advice on how to make sure that after “Authorization” you can automatically receive an Access Token without manually sending the “callback” form to localhost:3000/token.

I'm now trying to launch OpenId Connect on my resources, but I'm stuck at the moment of receiving the Authorization Code and then further receiving the Access Token and passing it to my application.

Thanks in advance for your answer!

Collapse
 
ebrahimmfadae profile image
Ebrahim Hoseiny Fadae • Edited

Hi, You should re-implement the issue token logic from app/src/views/token.ejs. I have implemented it as a form that requires user interaction. You can execute it automatically through an API call.

Let met know if you need more help. If you want more specific help, Reach me on LinkedIn.

Collapse
 
cybercoder profile image
Vahid Alimohamadi • Edited

needs

conformIdTokenClaims : false

when needs to include more claims. ( when there's no id_token response type)

Collapse
 
antrikshawevideo profile image
antrikshawevideo

What is the uid here in the routes?

Collapse
 
cybercoder profile image
Vahid Alimohamadi • Edited

uid is the unique identifier (not universally, but in the database),
after calling auth endpoint. e.g:

http://localhost:3001/oidc/auth?client_id=test&response_type=code&redirect_uri=http://localhost:3001/callback
Enter fullscreen mode Exit fullscreen mode

A unique identifier will be generate and redirects to /interaction/:uid