iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🐡

Building an OpenID Connect Provider with Hono and Next.js - Part 1: Application Setup

に公開

Introduction

I periodically build OpenID Connect implementations for study purposes.
While there are still many areas lacking, I've managed to implement what looks like a series of flows, so I'm writing this article.
As mentioned above, the implementation is quite incomplete.
Therefore, please note that this article is strongly intended as a personal memo and may contain inaccuracies.
Let's get started.

Architecture

This time, I've implemented it as follows.

Application

image.png
The frontend uses Next.js, and the API part is built with Hono.
Some might think Next.js or Hono alone would suffice, and you're right.
However, this time I chose a configuration using both because I wanted to completely separate the API so it could be called from various environments.

Execution Environment

Next.js runs on Vercel, and Hono runs on Cloudflare Workers.
I prioritized the easiest deployment methods, selecting platforms with high affinity for each framework.
For Cloudflare Workers, I also wanted to experience development in an edge computing environment. (Vercel also runs on the edge, but I didn't think too deeply about that.)

DB/Storage

I used Cloudflare D1 for the database and Cloudflare Workers KV for key-value storage.
Since I currently don't have requirements that demand MySQL or PostgreSQL and the table structure isn't complex, I prioritized ease of use with Cloudflare Workers.
Note that I'm using Prisma as the ORM for DB operations in this implementation, but as stated in the documentation, transactions are not available when using Cloudflare D1.
Therefore, please be aware that when using it for business purposes, you need to verify if the absence of transactions is acceptable.
Regarding the choice of key-value storage, I also focused on affinity with Cloudflare Workers.
However, the documentation mentions that network propagation can take up to 60 seconds, so you should consider whether this truly meets your requirements.
Still, using Cloudflare-related services made construction so easy that I couldn't resist the temptation.
That's a rough overview of the architecture.
Next, we'll move on to the actual preparation.

Application Preparation

Setting Up the Applications

First, we will build the application locally.
Note that the following discussion assumes the use of npm.
If you are more familiar with other tools, please replace them accordingly.

First, let's prepare Hono.
Run npm create hono@latest based on the documentation.
You will be presented with various questions; fill them in appropriately.
However, for the question about the execution environment, I selected Cloudflare Workers.

2025-02-24_10h08_47.png

Once the project is installed, start the application with npm run dev and verify that "Hello Hono!" is displayed.
This completes the initial preparation for Hono.

Next is Next.js, which also follows the documentation.
I answered the Next.js questions as follows:

2025-02-24_10h22_01.png

That covers the basic setup for Next.js, but there is one additional task: add the following to the scripts in package.json.

"dev:https": "next dev --turbopack --experimental-https"

This command is configured to start the application using HTTPS (see documentation).
The reason for running it over HTTPS is that the Relying Party interacts with the OpenID Provider using @auth0/auth0-react.
@auth0/auth0-react is a React wrapper for @auth0/auth0-spa-js. As stated in this code, @auth0/auth0-spa-js expects to send requests only to OpenID Providers running on HTTPS.
Since this Next.js app is what receives the initial requests from the Relying Party, we enable those requests by serving it over HTTPS.
To achieve this, we add a script with the --experimental-https option.

With this, the application setup is complete.

Creating API Specifications in Hono

Now that we've set up the applications, we'll enable Next.js to call the Hono app.
First, we will provide API specifications using OpenAPI on the Hono side.
This is very simple; just implement it in src/index.ts as follows:

import { OpenAPIHono } from '@hono/zod-openapi'
const app = new OpenAPIHono()
app.doc('/doc', {
  openapi: '3.0.0',
  info: {
    title: 'OpenID Connect API',
    version: '1.0.0'
  }
})
export default app

By doing this, you can provide the defined API in JSON format.
Now, let's actually create an API.
The flow is as follows:

  1. Define the request and response schemas using the z object provided by @hono/zod-openapi.
  2. Define the routing while using the schemas from step 1.
  3. Write the internal logic based on the routing.

Following this, we'll create a POST endpoint for /oauth/token.
First, define a schema like the following:

import { z } from '@hono/zod-openapi'
export const CreateTokenParamSchema = z.object({
    client_id: z.string().optional().openapi({ title: 'Client ID', description: 'Required for requests from public clients' }),
    redirect_uri: z.string().optional().openapi({ title: 'Redirect URI', description: 'Required for requests from public clients' }),
    grant_type: z.enum(['authorization_code', 'refresh_token']).openapi({ title: 'Grant Type', description: 'authorization_code: get authorization code, refresh_token: get refresh token' }),
    code: z.string().openapi({ title: 'Authorization Code' }),
    code_verifier: z.string().openapi({ title: 'Code Verifier', description: 'Recommended in the spec, but required for security' }),
})
export type CreateTokenParamType = z.infer<typeof CreateTokenParamSchema>
export const CreateTokenResponseSchema = z.object({
    access_token: z.string().openapi({ title: 'Access Token' }),
    token_type: z.enum(['Bearer']).openapi({ title: 'Token Type' }),
    expires_in: z.number().openapi({ title: 'Expires In' }),
    refresh_token: z.string().optional().openapi({ title: 'Refresh Token', description: 'Returned only when a refresh token is also requested' }),
    id_token: z.string().openapi({ title: 'ID Token' }),
})

The z object from @hono/zod-openapi basically has the same syntax as Zod.
By providing a description in the openapi method, you can add comments to the API specification.
Also, defining routing using this schema allows it to handle request validation.
This eliminates the need to write request validation logic internally, reducing omissions.
Since the design requires schemas at the type level, you are forced to define them.
Personally, I like this restriction.
It's easy to procrastinate on request validation, but this forces you to address it immediately.

Once the schema is defined, create a route like this:

import { createRoute } from "@hono/zod-openapi";
import { CreateTokenParamSchema, CreateTokenResponseSchema } from "./schema";
export const createPostRouter = createRoute({
    method: 'post',
    path: 'oauth/token',
    request: {
        body: { content: { "application/json": { schema: CreateTokenParamSchema } } }
    },
    responses: {
        201: {
            content: {
                "application/json": {
                    schema: CreateTokenResponseSchema
                }
            },
            description: 'Token response'
        }
    }
})

The content is intuitive, so I'll skip the details.
Note that for responses, autocompletion isn't available, so you'll need to manually specify the status codes you want to return.
Now that the routing is ready, let's register it with the Hono application.

import { createPostRouter } from "./router";
import type app from "../..";
export const registerTokenRoutes = (baseApp: typeof app) => {
    baseApp.openapi(createPostRouter, async (c) => {
       /** Internal logic */
        return c.json({ /** Values defined in the response schema */ }, 201,)
    })
}

To provide it as an OpenAPI endpoint, use the openapi method of OpenAPIHono.
The first argument is the routing definition, and the second argument is the implementation of the callback function for the actual process.
The reason I'm using the app type provided by src/index.ts instead of OpenAPIHono for the argument of the registerTokenRoutes function is as follows:
This is to avoid type errors even when using Bindings.
When running Hono on Cloudflare Workers, you can use a feature called Bindings.
Roughly speaking, Bindings link environment values to the application instance at startup.
Bindings can link not just literal environment variables but also Cloudflare features like Cloudflare D1 or Cloudflare Workers KV to the application.
For example, Bindings are used as follows:

type Bindings = {
  DB: D1Database
  "kv-store": KVNamespace,
  ISSUER_URL: string,
}
const app = new OpenAPIHono<{ Bindings: Bindings }>()

By doing this, the application can use the env property to retrieve the linked content.
However, the type of OpenAPIHono with Bindings linked is internally different from the original OpenAPIHono type.
Therefore, using the raw OpenAPIHono type causes an error, so I avoid this by using typeof on the app defined in src/index.ts.

Now that we're ready to register it, modify src/index.ts.

import { OpenAPIHono } from '@hono/zod-openapi'
const app = new OpenAPIHono()
registerTokenRoutes(app)
app.doc('/doc', {
  openapi: '3.0.0',
  info: {
    title: 'OpenID Connect API',
    version: '1.0.0'
  }
})
export default app

Now, if you start Hono and access http://localhost:8787/doc, the following value will be output:

{
	"openapi": "3.0.0",
	"info": {
		"title": "OpenID Connect API",
		"version": "1.0.0"
	},
	"components": {
		"schemas": {},
		"parameters": {}
	},
	"paths": {
		"/oauth/token": {
			"post": {
				"requestBody": {
					"content": {
						"application/json": {
							"schema": {
								"type": "object",
								"properties": {
									"client_id": {
										"type": "string",
										"description": "Required for requests from public clients",
										"title": "Client ID"
									},
									"redirect_uri": {
										"type": "string",
										"description": "Required for requests from public clients",
										"title": "Redirect URI"
									},
									"grant_type": {
										"type": "string",
										"enum": [
											"authorization_code",
											"refresh_token"
										],
										"description": "authorization_code: get authorization code, refresh_token: get refresh token",
										"title": "Grant Type"
									},
									"code": {
										"type": "string",
										"title": "Authorization Code"
									},
									"code_verifier": {
										"type": "string",
										"description": "Recommended in the spec, but required for security",
										"title": "Code Verifier"
									}
								},
								"required": [
									"grant_type",
									"code",
									"code_verifier"
								]
							}
						}
					}
				},
				"responses": {
					"201": {
						"description": "Token response",
						"content": {
							"application/json": {
								"schema": {
									"type": "object",
									"properties": {
										"access_token": {
											"type": "string",
											"title": "Access Token"
										},
										"token_type": {
											"type": "string",
											"enum": [
												"Bearer"
											],
											"title": "Token Type"
										},
										"expires_in": {
											"type": "number",
											"title": "Expires In"
										},
										"refresh_token": {
											"type": "string",
											"description": "Returned only when a refresh token is also requested",
											"title": "Refresh Token"
										},
										"id_token": {
											"type": "string",
											"title": "ID Token"
										}
									},
									"required": [
										"access_token",
										"token_type",
										"expires_in",
										"id_token"
									]
								}
							}
					}
				}
			}
		}
	}
}

It generated a nice specification.
Now, Hono can provide the API to external consumers.

Calling Hono's API from Next.js

Next, we will set up Next.js to call the Hono API.
If you just want to call it, you could simply write the endpoints using fetch or axios.
However, the lack of autocompletion is quite challenging during development, so we will automatically generate code based on the API specification.
For this project, we'll use Orval as the code generation tool, though you can use any tool you are familiar with.

First, install Orval using npm i -D orval.
By default, Orval uses Axios for code generation. This can be changed to fetch or other alternatives, but since there's no specific reason to change it, we'll stick with the default. Thus, we also install Axios with npm i axios.

Once installed, create orval.config.ts in the project root and implement it as follows:

import { defineConfig } from 'orval'
export default defineConfig({
  "openid-connect-api-file": {
    input: 'http://localhost:8787/doc',
    output: {
      target: "./src/apis.ts",
      baseUrl: 'http://localhost:8787',
    },
  },
})

The first property can be any name you like. Since you can set multiple properties, the following configuration would generate two separate code files:

export default defineConfig({
  "openid-connect-api-file": {
    input: new URL("/doc", process.env.OPENID_CONNECT_API_URL).href,
    output: {
      target: "./src/apis.ts",
      baseUrl: process.env.OPENID_CONNECT_API_URL,
    },
  },
  "openid-connect-api-file2": {
    input: 'http://localhost:8787/doc',
    output: {
      target: "./src/apis.ts",
      baseUrl: 'http://localhost:8787',
    },
  },
})

This allows you to use multiple OpenAPI specifications within a single project.

Let's briefly look at the input and output properties.
input specifies the OpenAPI definition to reference. While the documentation shows various options, here we simply provide the URL where the API specification can be checked.
output defines how the code is generated. This section has even more configuration options than input, but we are only using target to define the output file path and baseUrl for the destination URL.

After defining these, run npx orval --config ./orval.config.ts.
The API code will be automatically generated in src/apis.ts.
Finally, you can import the generated functions into your application and call them to send requests to Hono.
Note that although you can import these functions in Client Components, you will encounter CORS errors. Therefore, it is generally recommended to perform requests from the server side.

Conclusion

In this article, we prepared the project for a custom OpenID Provider implementation.
Now that the basic project setup is complete, in the next part, I'd like to discuss the database and GitHub Actions.
Thank you for reading!

Discussion