Error Handling & Security
Error Handling & Security
Error codes
HTTP errors (OidcHttpMiddleware)
| Code | HTTP status | Meaning |
|---|---|---|
ERR_AUTH_TOKEN_REQUIRED | 401 | Missing Authorization header or value does not start with Bearer |
ERR_AUTH_TOKEN_INVALID | 401 | JWT signature or claims verification failed (expired, wrong issuer, wrong audience, bad signature) |
ERR_USER_DISABLED | 403 | Token is valid but the local user account is disabled |
ERR_USER_PROVISION_FAILED | 500 | users.findOrCreate threw an unexpected error |
Response shape:
{
"statusCode": 401,
"error": "Unauthorized",
"code": "ERR_AUTH_TOKEN_REQUIRED",
"message": "Bearer token required."
}
Socket.IO errors (OidcSocketMiddleware, OidcSocketAdminMiddleware)
Socket.IO errors call next(new Error("ERR_*")), which triggers a connect_error event on the client:
| Error message | Meaning |
|---|---|
ERR_AUTH_TOKEN_REQUIRED | No token in auth.token or Authorization header |
ERR_AUTH_TOKEN_INVALID | JWT verification failure |
ERR_USER_DISABLED | User account is disabled |
ERR_FORBIDDEN | Admin role required (OidcSocketAdminMiddleware) |
Client-side handling:
socket.on("connect_error", (err) => {
switch (err.message) {
case "ERR_AUTH_TOKEN_REQUIRED":
// Redirect to login
break;
case "ERR_AUTH_TOKEN_INVALID":
// Token expired — refresh and reconnect
break;
case "ERR_FORBIDDEN":
// Not an admin — show access-denied UI
break;
default:
console.error("Unexpected connection error:", err.message);
}
});
Handling token expiry on long-lived connections
JWTs are verified at connection time. A Socket.IO connection that was established with a valid token will remain open even after the token expires — the middleware only runs during the handshake.
To enforce token lifetime on long-lived connections:
- Send the token in
socket.handshake.auth.token - Before token expiry, have the client reconnect with the refreshed token
- Optionally implement a server-side ping that checks
socket.featuresor a timestamp injected at connection time
// Server: record connection time
class AuthService extends BaseService {
async "connect:init"(socket: any, _data: unknown) {
socket.connectedAt = Date.now();
}
}
// Server: periodic check via a Watcher
class TokenWatcher extends BaseWatcher {
private timer: ReturnType<typeof setInterval> | null = null;
async watch() {
this.timer = setInterval(() => {
// Disconnect sockets connected more than 1 hour ago
const io = (this.app as any).io;
io.sockets.sockets.forEach((socket: any) => {
if (Date.now() - socket.connectedAt > 3_600_000) {
socket.disconnect(true);
}
});
}, 60_000);
}
stop() {
if (this.timer) { clearInterval(this.timer); this.timer = null; }
}
}
Security guarantees
Signature verification — Every token is verified against the remote JWKS using jose.jwtVerify. The library accepts RS256 and ES256 by default. Tokens with a symmetric algorithm (HS256) are rejected.
Claim validation — iss (issuer) and aud (audience) are always validated. aud must match OidcConfig.appSlug to prevent token substitution between different applications sharing the same auth-service.
Key rotation — JWKS keys are cached per URI. jose automatically re-fetches the key set when signature verification fails with the cached keys, with a minimum 5-minute cooldown to prevent hammering the JWKS endpoint.
No secret storage — Access tokens are never written to disk, a database, or a cache. Verification happens in-memory on every request.
Account disablement — Disabled accounts are checked after token verification, not before. A valid token does not guarantee access — the local users table is the authoritative source for account status.
HTTPS requirement
Tokens transmitted over plain HTTP can be intercepted. Always deploy behind TLS in production. Using Traefik as a reverse proxy with automatic certificate management is a common pattern in this project ecosystem — see the IOServer Docker deployment guide.