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",
};
},
},
},
};
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
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();
},
});
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);
}
};
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;
};
./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;
};
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}`
);
});
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>
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 });
},
});
Add router
./app/src/routes/app.router.ts
export default () => {
const router = new Router();
const { pi } = appController();
router.get("/pi", pi);
return router;
};
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)
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?
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.