OIDC/OAuth2 JWT middleware set for IOServer.
Protects Fastify HTTP routes and Socket.IO namespaces by verifying access tokens issued by auth-service (BetterAuth + OAuth2 provider) via remote JWKS — zero secret storage on the application side.
- Features
- Requirements
- Installation
- Quick start
- Environment variables
- API reference
- Request / socket context
- Error codes
- Security notes
- Contributing
- License
- ✅ Verifies RS256 / ES256 JWT access tokens via remote JWKS (no secret distribution)
- ✅ Validates
iss,aud, and expiry claims - ✅ In-process JWKS key cache — one HTTP round-trip per key rotation
- ✅ Auto-provisions local user records via
appHandle.users.findOrCreate(...) - ✅ Rejects disabled accounts (403)
- ✅ Injects
sub,userId,userRole,roles,permissions,featureson every authenticated request/socket - ✅ Admin role guard for Socket.IO namespaces
- ✅ Full TypeScript declarations; ESM-only
| Dependency | Version |
|---|---|
| Node.js | ≥ 20 |
| ioserver | ≥ 2.0.0 |
| jose | ≥ 6.0.0 |
# npm
npm install ioserver-oidc
# pnpm
pnpm add ioserver-oidc
# yarn
yarn add ioserver-oidcjose is bundled as a direct dependency — no extra installation required.
import {
OidcConfigManager,
OidcHttpMiddleware,
OidcSocketMiddleware,
OidcSocketAdminMiddleware,
} from "ioserver-oidc";
import { IOServer } from "ioserver";
const server = new IOServer({
/* your IOServer options */
});
// Reads AUTH_SERVICE_URL + AUTH_SERVICE_APP_SLUG from process.env
server.addManager({ name: "oidcConfig", manager: OidcConfigManager });server.addController({
name: "profile",
controller: ProfileController,
middlewares: [OidcHttpMiddleware], // ← JWT-required
prefix: "/profile",
});// Any authenticated user
server.addService({
name: "chat",
service: ChatService,
middlewares: [OidcSocketMiddleware],
});
// Admin-only namespace
server.addService({
name: "users",
service: UserService,
middlewares: [OidcSocketMiddleware, OidcSocketAdminMiddleware],
});// HTTP (Fastify)
fastify.get("/me", async (request) => {
const req = request as any;
return { userId: req.userId, role: req.userRole };
});
// Socket.IO
socket.on("ping", () => {
console.log(socket.userId, socket.userRole);
});| Variable | Required | Default | Description |
|---|---|---|---|
AUTH_SERVICE_URL |
✅ | — | Public base URL of your auth-service. E.g. https://auth.example.com |
AUTH_SERVICE_APP_SLUG |
✅ | — | OAuth2 client_id / app slug registered in auth-service |
AUTH_SERVICE_JWKS_URI |
❌ | <AUTH_SERVICE_URL>/api/auth/jwks |
Override the JWKS endpoint |
AUTH_SERVICE_ISSUER |
❌ | <AUTH_SERVICE_URL> |
Override the expected iss claim |
All variables are read once at server startup by OidcConfigManager.start().
If OidcConfigManager is not registered, each middleware reads the same
variables lazily on first request (without caching between restarts).
Extends BaseManager. Reads environment variables and exposes the resolved
OidcConfig to sibling middlewares via appHandle.oidcConfig.getConfig().
server.addManager({ name: "oidcConfig", manager: OidcConfigManager });The name must be
"oidcConfig"— the middlewares look forappHandle.oidcConfigby that exact key.
Extends BaseMiddleware. Verifies the Authorization: Bearer <token> header on
every inbound Fastify request.
Flow:
- Extracts the Bearer token from
Authorizationheader - Verifies JWT signature via JWKS (
iss+aud+ expiry) - Calls
appHandle.users.findOrCreate(sub, { email, name })if available - Rejects disabled accounts with
403 - Injects auth context onto the request object
Returns 401 on missing/invalid tokens, 403 on disabled accounts,
500 if user provisioning fails.
Same as OidcHttpMiddleware but for Socket.IO connections.
Token is read from (in order):
socket.handshake.auth.token— preferred, set by the Vue/web clientsocket.handshake.headers.authorization(Bearerprefix) — fallback
Calls appHandle.session.registerSocket(userId, socketId, sub) when the
session manager is available.
Rejects with new Error("ERR_AUTH_TOKEN_REQUIRED") or
"ERR_AUTH_TOKEN_INVALID" on failure.
Role guard. Must be placed after OidcSocketMiddleware in the middlewares
array (relies on socket.roles/socket.userRole being already set).
Rejects with new Error("ERR_FORBIDDEN") when the user does not hold
the "admin" role.
Low-level function — use this if you need to verify a token outside of the IOServer middleware system.
import { verifyOidcToken } from "ioserver-oidc";
const ctx = await verifyOidcToken(rawJwt, {
authServiceUrl: "https://auth.example.com",
appSlug: "my-app",
});
// ctx → OidcUserContextThrows a jose JWTVerifyError (or subclass) on any verification failure.
import type { OidcConfig, OidcUserContext, OidcFeatures } from "ioserver-oidc";interface OidcConfig {
authServiceUrl: string; // e.g. "https://auth.example.com"
appSlug: string; // OAuth2 client_id (= app slug)
jwksUri?: string; // Override JWKS endpoint
issuer?: string; // Override expected `iss` claim
}interface OidcUserContext {
userId: string; // Local DB user ID (after findOrCreate)
sub: string; // OIDC sub claim
email: string | null;
name: string | null;
userRole: string; // First element of roles[], fallback "user"
roles: string[];
permissions: string[];
features: OidcFeatures; // Record<string, unknown>
}After successful authentication the following properties are available:
| Property | Type | Source |
|---|---|---|
sub |
string |
JWT sub claim |
userId |
string |
Local DB users.id |
userRole |
string |
roles[0] or "user" |
roles |
string[] |
JWT roles claim |
permissions |
string[] |
JWT permissions claim |
features |
Record<string,unknown> |
JWT features claim |
In TypeScript, cast the Fastify request or Socket.IO socket to any (or
augment the types in your app) to access these properties.
| Code | HTTP / Socket | Meaning |
|---|---|---|
ERR_AUTH_TOKEN_REQUIRED |
401 / reject | No Authorization header or auth token |
ERR_AUTH_TOKEN_INVALID |
401 / reject | JWT signature / claims verification failed |
ERR_USER_DISABLED |
403 | User account is disabled in the local DB |
ERR_USER_PROVISION_FAILED |
500 | findOrCreate threw an error |
ERR_FORBIDDEN |
— / reject | User lacks the required role |
- Access tokens are never stored — they are verified in-memory on every request/connection using the cached JWKS.
- JWKS keys are fetched lazily and cached per URI. The
joselibrary automatically re-fetches keys on signature verification failure (key rotation) with a minimum 5-minute cooldown. - The
aud(audience) claim is always validated againstOidcConfig.appSlugto prevent token substitution attacks between different applications sharing the same auth-service instance. - The
iss(issuer) claim is validated againstOidcConfig.authServiceUrl(or the explicit override).
- Fork the repo and create a branch:
git checkout -b feat/my-feature - Make your changes (TypeScript in
src/) - Build:
pnpm run build - Open a Pull Request against
main