DEV Community

Ebrahim Hoseiny Fadae
Ebrahim Hoseiny Fadae

Posted on • Edited on

Part III: Adding Resource Server Authorization to OpenID with Node.js

Introduction

In this article, we will continue building upon our OIDC (OpenID Connect 1.0) implementation with Node.js to add resource server authorization. Resource server authorization ensures that only authorized clients can access protected resources. We will create a separate client specifically for the resource server, which will have restricted access and cannot issue new tokens.

Let's start

We call every entity or action that is accessible through a URI a resource. Authorization server only grants access for resource owner with valid scopes.

Update configuration

We will create a separate client for resource server. This client is a restricted client which only can access resources. We can't issue new token with this client. Also we can remove it to revoke every user access to resources. We have enabled introspection feature which able us to validate a token. resourceIndicators is were we define oure resource server.

./oidc/src/configs/configuration.ts

export const configuration: Configuration = {
  clients: [
    {
      client_id: "api",
      client_secret: "night-wolf",
      redirect_uris: [],
      response_types: [],
      grant_types: ["client_credentials"],
      scope: "openid email profile phone address",
    },
  ],
  features: {
    introspection: {
      enabled: true,
      allowedPolicy(ctx, client, token) {
        if (
          client.introspectionEndpointAuthMethod === "none" &&
          token.clientId !== ctx.oidc.client?.clientId
        ) {
          return false;
        }
        return true;
      },
    },
    resourceIndicators: {
      defaultResource(ctx) {
        return Array.isArray(ctx.oidc.params?.resource)
          ? ctx.oidc.params?.resource[0]
          : ctx.oidc.params?.resource;
      },
      getResourceServerInfo(ctx, resourceIndicator, client) {
        return {
          scope: "api:read offline_access",
        };
      },
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

Add API service

To run the API server follow these steps. We will set up everything under ./api directory.

1. Add dependencies

$ yarn add koa
$ yarn add @types/koa -D
Enter fullscreen mode Exit fullscreen mode

2. Add controller

We will create a mocked service. It will return PI number and we treat it as top secret information!

./api/src/controllers/api.controller.ts

import { Middleware } from "koa";

export default (): { [key: string]: Middleware } => ({
  pi: async (ctx) => {
    ctx.status = 200;
    ctx.message = Math.PI.toString();
  },
});
Enter fullscreen mode Exit fullscreen mode

3. Add middlewares

We reached the magical part! Here we check if the user has access to the resource. Then we will pass session information to the next controller in the chain.

./api/src/middlewares/auth.middleware.ts

import { Middleware } from "koa";
import fetch from "node-fetch";

export const authenticate: Middleware = async (ctx, next) => {
  const body = new URLSearchParams();
  if (!ctx.request.headers.authorization) return ctx.throw(401);
  body.append(
    "token",
    ctx.request.headers.authorization.replace(/^Bearer /, "")
  );
  body.append("client_id", process.env.CLIENT_ID as string);
  body.append("client_secret", process.env.CLIENT_SECRET as string);
  const url = `${process.env.AUTH_ISSUER}/token/introspection`;
  const response = await fetch(url, {
    method: "POST",
    headers: {
      ["Content-Type"]: "application/x-www-form-urlencoded",
    },
    body: body,
  });
  if (response.status !== 200) ctx.throw(401);
  const json = await response.json();
  const { active, aud } = json;
  // Resource URI and audience (aud) must be equal
  if (active && aud.trim() === ctx.request.href.split("?")[0]) {
    ctx.state.session = json;
    await next();
  } else {
    ctx.throw(401);
  }
};

// Check if scope is valid
export const authorize =
  (...scopes: string[]): Middleware =>
  async (ctx, next) => {
    if (
      ctx.state.session &&
      scopes.every((scope) => ctx.state.session.scope.includes(scope))
    ) {
      await next();
    } else {
      ctx.throw(401);
    }
  };
Enter fullscreen mode Exit fullscreen mode

4. Add router

Here we bind the router to the controller. Also, we give the required scopes for ./pi controller.

./api/src/routes/api.router.ts

import Router from "koa-router";
import apiController from "../controllers/api.controller";
import { authenticate, authorize } from "../middlewares/auth.middleware";

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

  const { pi } = apiController();

  router.get("/pi", authenticate, authorize("api:read"), pi);

  return router;
};
Enter fullscreen mode Exit fullscreen mode

./api/src/routes/index.ts

import Router from "koa-router";
import appRouter from "./api.router";

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

  router.use(appRouter().routes());

  return router;
};
Enter fullscreen mode Exit fullscreen mode

5. Start the server

Server startup script

./api/src/index.ts

import cors from "@koa/cors";
import dotenv from "dotenv";
import Koa from "koa";
import path from "path";
import router from "./routes";

dotenv.config({ path: path.resolve("api/.env") });

const app = new Koa();

app.use(cors());
app.use(router().routes());

app.listen(process.env.PORT, () => {
  console.log(
    `api listening on port ${process.env.PORT}, check http://localhost:${process.env.PORT}`
  );
});
Enter fullscreen mode Exit fullscreen mode

Add service page in app

As the final piece, we create a consumer app under ./app directory which accesses API server to access PI resource.

Add html file

./app/src/views/pi.ejs

<!DOCTYPE html>
<html>
  <%- include('components/head'); -%>
  <body class="app">
    <div class="login-card">
      <h1><%= title %></h1>
      <form autocomplete="off">
        <label>Token</label>
        <input id="token" required name="token" placeholder="Token" />
        <p id="pi" style="margin-top: 0">Value: -</p>

        <button type="button" class="login login-submit" onclick="onClick()">
          Fetch
        </button>
      </form>
    </div>
  </body>
  <script>
    async function onClick() {
      try {
        const response = await fetch("<%= apiUrl %>/pi", {
          headers: {
            ["Authorization"]: `Bearer ${
              document.getElementById("token").value
            }`,
          },
        });
        if (response.status === 401) {
          return alert("You are not authorized to access PI.");
        } else if (response.status !== 200) {
          return alert(" Failed to fetch PI.");
        }
        const pi = await response.text();
        document.getElementById("pi").innerText = `Value: ${pi}`;
      } catch (error) {
        alert("Error encountered.");
      }
    }
  </script>
</html>
Enter fullscreen mode Exit fullscreen mode

Add controllers

./app/src/controllers/app.controller.ts

export default (): { [key: string]: Middleware } => ({
  pi: async (ctx) => {
    return ctx.render("pi", { title: "PI", apiUrl: process.env.API_URL });
  },
});
Enter fullscreen mode Exit fullscreen mode

Add router

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

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

  const { pi } = appController();

  router.get("/pi", pi);

  return router;
};
Enter fullscreen mode Exit fullscreen mode

Summary

In this section, we created a resource server with PI number as a restricted resource. Then we integrated this with our authorization server to grant user access. To see the result we created a minimal web app to view everything in action.

Top comments (2)

Collapse
 
zhamdi profile image
Zied Hamdi

There's something I don't understand:

In auth.middleware.ts, if the user has no headers.authorization, you throw him out. But how does he gain that?

export const authenticate: Middleware = async (ctx, next) => {
  if (!ctx.request.headers.authorization) return ctx.throw(401);
Enter fullscreen mode Exit fullscreen mode
Collapse
 
ebrahimmfadae profile image
Ebrahim Hoseiny Fadae

If the user provides an incorrect authorization token, we will respond with a 401 status code. For our purposes, "not providing a token" is considered equivalent to providing an invalid token.