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/
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.
{
"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.
{
"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 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
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
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
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 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>
Every time you see:
<%- 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
<!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
<!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
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.
// 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.
// 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.
// 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.
// 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
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
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
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
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
<!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 iscode
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 toapp
we set this toappUrl
. -
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;
};
./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;
};
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",
});
},
});
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>
What new data we are sending?
-
client_secret
of our app in authorization server. -
grant_type
, Which here isauthorization_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,
});
}
},
});
Add app router
./app/src/routes/app.router.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.
Top comments (8)
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.
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.
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
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!
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.
needs
conformIdTokenClaims : false
when needs to include more claims. ( when there's no id_token response type)
What is the uid here in the routes?
uid is the unique identifier (not universally, but in the database),
after calling auth endpoint. e.g:
A unique identifier will be generate and redirects to /interaction/:uid