Integrating with IOServer OIDC
ioserver-oidc is a set of IOServer middlewares that verify auth-service JWT access tokens on HTTP controllers and Socket.IO services. The backend only needs the public JWKS endpoint: no secret is shared between the two services.
ioserver-oidc 0.2.3 (the version its API was checked against). The package is maintained in its own repository; refer to its documentation for later versions.Prerequisites
- A running auth-service instance with a public
BETTER_AUTH_URL. - An IOServer backend with
ioserver-oidcinstalled (ioserver2 or later, Node.js 22 or later). - An application registered in auth-service, as described below.
Application setup in auth-service
The middleware accepts a token only when all of these hold:
| Check | Value it expects |
|---|---|
| Signature | A key of <AUTH_SERVICE_URL>/api/auth/jwks and an asymmetric algorithm (RS*, PS*, ES* or EdDSA) |
iss | AUTH_SERVICE_URL without a trailing slash (or AUTH_SERVICE_ISSUER) |
aud | AUTH_SERVICE_AUDIENCE. When it is not set, the value of AUTH_SERVICE_APP_SLUG is used instead, which does not match the aud auth-service issues (a resource URL) |
azp | AUTH_SERVICE_APP_SLUG. The client_id claim is used when azp is absent. When AUTH_SERVICE_AUDIENCE is set and differs from the slug, one of them must be present |
Two consequences for the registration:
AUTH_SERVICE_APP_SLUGmust be theclient_idof the application that signs the users in, because that is what auth-service puts inazp. Theroles,permissionsandfeaturesclaims are also computed for that application.AUTH_SERVICE_AUDIENCEmust be the resource the token is issued for: theurlof an application that the signing-in application is allowed to request. The authorization request must carryresource=<that url>; without it the access token is opaque and the middleware refuses it.
The simplest setup is one application. Create it in the admin interface (Applications → Create Application), or with the admin API:
POST /api/admin/applications
Content-Type: application/json
{
"name": "My App",
"slug": "my-app",
"url": "https://api.example.com",
"isPublic": true,
"redirectUris": ["https://app.example.com/callback"],
"allowedScopes": ["openid", "profile", "email", "roles", "permissions", "features"]
}
Here AUTH_SERVICE_APP_SLUG=my-app and AUTH_SERVICE_AUDIENCE=https://api.example.com. A public application (isPublic: true) suits a browser frontend: it has no secret and uses PKCE, which every application requires. It is also open to every account (see Integrating a third-party application); use isPublic: false and grant users explicitly if only some accounts may sign in. A confidential application returns its clientSecret once, at creation.
url, or an entry of CORS_ORIGINS. In the setup above the frontend runs on https://app.example.com, which is not the application url, so add it to CORS_ORIGINS — or give the frontend its own application and let it request the API as a resource, as follows.Separate frontend and API applications
You can also register the API as an application of its own (slug: my-api, url: https://api.example.com) and the frontend as another one (slug: my-frontend, url: https://app.example.com) whose allowedResources contains https://api.example.com. The API's url must be registered before it can be listed. The frontend then requests resource=https://api.example.com, the tokens carry azp: my-frontend, and the backend runs with AUTH_SERVICE_APP_SLUG=my-frontend and AUTH_SERVICE_AUDIENCE=https://api.example.com. The roles of my-frontend are the ones in the tokens.
Frontend — authorize and obtain tokens
Sign users in with any OIDC library, as described in Integrating a third-party application. The values specific to this setup:
| Parameter | Value |
|---|---|
client_id | The slug of the signing-in application (AUTH_SERVICE_APP_SLUG) |
scope | openid profile email roles permissions features (add org for org_id) |
resource | The audience the backend expects (AUTH_SERVICE_AUDIENCE) |
code_challenge_method | S256, with a code_challenge: PKCE is required |
Then send the access token to the backend:
const response = await fetch("https://api.example.com/documents", {
headers: { Authorization: `Bearer ${accessToken}` },
});
Backend — verify tokens
Configuration comes from environment variables, read once at startup by OidcConfigManager:
| Variable | Required | Default | Description |
|---|---|---|---|
AUTH_SERVICE_URL | Yes | — | Public base URL of auth-service (BETTER_AUTH_URL) |
AUTH_SERVICE_APP_SLUG | Yes | — | client_id of the application that signs users in |
AUTH_SERVICE_AUDIENCE | No | AUTH_SERVICE_APP_SLUG | Expected aud. Set it to the resource URL |
AUTH_SERVICE_JWKS_URI | No | <AUTH_SERVICE_URL>/api/auth/jwks | Override the JWKS location |
AUTH_SERVICE_ISSUER | No | AUTH_SERVICE_URL | Override the expected iss |
AUTH_SERVICE_URL=https://auth.example.com
AUTH_SERVICE_APP_SLUG=my-app
AUTH_SERVICE_AUDIENCE=https://api.example.com
import { IOServer } from "ioserver";
import { OidcConfigManager, OidcHttpMiddleware } from "ioserver-oidc";
import { DocumentsController } from "./DocumentsController.js";
const server = new IOServer({ /* your IOServer options */ });
// The manager name must be "oidcConfig": the middlewares look it up by that name.
server.addManager({ name: "oidcConfig", manager: OidcConfigManager });
server.addController({
name: "documents",
controller: DocumentsController,
middlewares: [OidcHttpMiddleware],
prefix: "/documents",
});
await server.start();
The middleware reads the Authorization: Bearer header and verifies the token against the JWKS. The key set is fetched from <AUTH_SERVICE_URL>/api/auth/jwks and cached in the process. It answers:
| Status | code | Meaning |
|---|---|---|
401 | ERR_AUTH_TOKEN_REQUIRED | No Bearer token |
401 | ERR_AUTH_TOKEN_INVALID | Bad signature, wrong iss, aud or azp, or expired |
403 | ERR_ACCOUNT_DISABLED | The local user record returned by your users manager is disabled (see below) |
500 | ERR_USER_PROVISION_FAILED | Your users manager threw |
If your application registers a manager named users with a findOrCreate(sub, { email, name }) method, the middleware calls it on each request and uses the returned record's id and role as request.userId and request.userRole. Without it, request.userId is the token's sub.
Reading claims in a controller
After a successful check the middleware sets these properties on the Fastify request:
| Property | Source |
|---|---|
sub | sub claim |
userId | id of your users.findOrCreate record, else sub |
userRole | role of your users.findOrCreate record, else the first entry of roles, else "user" |
roles | roles claim (an empty array without the roles scope) |
permissions | permissions claim (an empty array without the permissions scope) |
features | features claim (an empty object without the features scope or an active plan) |
org_id | org_id claim, only when present (needs the org scope) |
import { BaseController } from "ioserver";
import type { FastifyReply, FastifyRequest } from "fastify";
interface OidcRequest extends FastifyRequest {
sub: string;
roles: string[];
permissions: string[];
features: Record<string, unknown>;
org_id?: string;
}
export class DocumentsController extends BaseController {
async list(request: FastifyRequest, reply: FastifyReply) {
const { roles, features, org_id: orgId } = request as OidcRequest;
if (!roles.includes("editor")) {
return reply.code(403).send({ statusCode: 403, error: "Forbidden", message: "Requires the editor role" });
}
// gate on a feature flag from the user's subscription plan
if (features.apiAccess !== true) {
return reply.code(403).send({ statusCode: 403, error: "Forbidden", message: "API access is not part of your plan" });
}
reply.send({ orgId });
}
}
How controller methods are mapped to routes is described in the IOServer documentation.
Socket.IO services
OidcSocketMiddleware applies the same checks to a Socket.IO connection. It reads the token from socket.handshake.auth.token, or else from the Authorization: Bearer handshake header, and sets the same properties on the socket. A failed check rejects the connection with the error message ERR_AUTH_TOKEN_REQUIRED or ERR_AUTH_TOKEN_INVALID. OidcSocketAdminMiddleware, placed after it, rejects with ERR_FORBIDDEN unless roles contains admin.
import { OidcSocketAdminMiddleware, OidcSocketMiddleware } from "ioserver-oidc";
server.addService({
name: "users",
service: UserService,
middlewares: [OidcSocketMiddleware, OidcSocketAdminMiddleware],
});
OIDC discovery
Auth-service publishes its configuration at the issuer root:
GET https://auth.example.com/.well-known/openid-configuration
ioserver-oidc does not use discovery: it derives the JWKS location and the issuer from AUTH_SERVICE_URL. Other OIDC clients, such as the frontend library, can configure themselves from that document. For a framework-agnostic walkthrough, see Integrating a third-party application.