Integration

Integrating with IOServer OIDC

Protect an IOServer application with auth-service access tokens using the ioserver-oidc middleware package.

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.

This page describes 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-oidc installed (ioserver 2 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:

CheckValue it expects
SignatureA key of <AUTH_SERVICE_URL>/api/auth/jwks and an asymmetric algorithm (RS*, PS*, ES* or EdDSA)
issAUTH_SERVICE_URL without a trailing slash (or AUTH_SERVICE_ISSUER)
audAUTH_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)
azpAUTH_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:

  1. AUTH_SERVICE_APP_SLUG must be the client_id of the application that signs the users in, because that is what auth-service puts in azp. The roles, permissions and features claims are also computed for that application.
  2. AUTH_SERVICE_AUDIENCE must be the resource the token is issued for: the url of an application that the signing-in application is allowed to request. The authorization request must carry resource=<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.

The browser origin of the frontend must be allowed to call auth-service: the origin of an application 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:

ParameterValue
client_idThe slug of the signing-in application (AUTH_SERVICE_APP_SLUG)
scopeopenid profile email roles permissions features (add org for org_id)
resourceThe audience the backend expects (AUTH_SERVICE_AUDIENCE)
code_challenge_methodS256, 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:

VariableRequiredDefaultDescription
AUTH_SERVICE_URLYes—Public base URL of auth-service (BETTER_AUTH_URL)
AUTH_SERVICE_APP_SLUGYes—client_id of the application that signs users in
AUTH_SERVICE_AUDIENCENoAUTH_SERVICE_APP_SLUGExpected aud. Set it to the resource URL
AUTH_SERVICE_JWKS_URINo<AUTH_SERVICE_URL>/api/auth/jwksOverride the JWKS location
AUTH_SERVICE_ISSUERNoAUTH_SERVICE_URLOverride 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:

StatuscodeMeaning
401ERR_AUTH_TOKEN_REQUIREDNo Bearer token
401ERR_AUTH_TOKEN_INVALIDBad signature, wrong iss, aud or azp, or expired
403ERR_ACCOUNT_DISABLEDThe local user record returned by your users manager is disabled (see below)
500ERR_USER_PROVISION_FAILEDYour 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:

PropertySource
subsub claim
userIdid of your users.findOrCreate record, else sub
userRolerole of your users.findOrCreate record, else the first entry of roles, else "user"
rolesroles claim (an empty array without the roles scope)
permissionspermissions claim (an empty array without the permissions scope)
featuresfeatures claim (an empty object without the features scope or an active plan)
org_idorg_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.

Copyright © 2026