Skip to content
ioBrokerPublic

About

Webserver for ioBroker adapters, with integrated certificate managment

Resources

Security policy

Stars

1 star

Watchers

3 watching

Forks

Repository files navigation

Webserver for ioBroker adapters

Description

This module provides a webserver, which automatically takes care of certificate handling using the ioBroker certificates.

How-To

Install via npm i @iobroker/webserver.

Use the webserver in your ioBroker adapter as the following:

  • TypeScript:
import { WebServer } from '@iobroker/webserver';

const webServer = new WebServer({ app, adapter, secure: true });

// initialize and you can use your server as known
const server = await webServer.init();
  • JavaScript:
const { WebServer } = require('@iobroker/webserver');

const webServer = new WebServer({ app, adapter, secure: true });

// initialize and you can use your server as known
const server = await webServer.init();

And so you can use CertificateManager that is used in the WebServer already:

  • TypeScript:
import { CertificateManager } from '@iobroker/webserver';

// Not required for server
const certManager = new CertificateManager({ adapter });

// get all collections
const collections = await certManager.getAllCollections();
  • JavaScript:
const { CertificateManager } = require('@iobroker/webserver');

// Not required for server
const certManager = new CertificateManager({ adapter });

// get all collections
const collections = await certManager.getAllCollections();

ACME HTTP-01 challenges

A certificate authority validates an HTTP-01 challenge by fetching http://<domain>/.well-known/acme-challenge/<token> on port 80. Path and port are fixed by RFC 8555, so on a host with a single public IP that request lands on whichever adapter holds port 80 - usually web or admin. The acme adapter used to take that port over for the duration of an order, stopping the adapter that had it.

WebServer answers those requests itself instead. The acme adapter publishes its tokens in acme.<instance>.info.httpChallenges and the lookup happens in front of the app, before any authentication, because the CA is anonymous:

// Nothing to do - this is on by default
const webServer = new WebServer({ app, adapter, secure: true });

// Opt out and keep /.well-known/acme-challenge/ entirely to the app
const webServer = new WebServer({ app, adapter, secure: true, acmeChallenge: false });

Only a request whose token is actually published is answered here; everything else is passed on to the app untouched, so nothing the app serves on that path is shadowed.

An adapter that builds its server without WebServer can mount the same lookup itself, before any authentication middleware:

import { acmeChallengeMiddleware } from '@iobroker/webserver';

app.use(acmeChallengeMiddleware(adapter));

serveAcmeChallenge(adapter, req, res) is the same thing for a plain http.RequestListener; it resolves to true when it answered the request.

HTTP/2

A secure server speaks HTTP/2, and HTTP/1.1 to every client that does not offer HTTP/2. This is on by default:

// HTTP/2 with HTTP/1.1 fallback
const webServer = new WebServer({ app, adapter, secure: true });

// HTTP/1.1 only
const webServer = new WebServer({ app, adapter, secure: true, http2: false });

Without secure the option has no effect - browsers use HTTP/2 over TLS only.

  • Express works unchanged. Express replaces the prototype of every request and response with its own HTTP/1 one, which breaks the HTTP/2 compat objects (the first body read even crashes the process). WebServer pins the HTTP/2 members to each request and response beforehand, fills in the host header from :authority, gives each response the _implicitHeader() of HTTP/1 that express-session relies on, and drops HTTP/1 connection headers (Connection, Keep-Alive, Transfer-Encoding, ...) an app sets on the response, which HTTP/2 forbids.
  • WebSockets keep working. Browsers open them on a separate HTTP/1.1 connection, which the server hands to its upgrade listeners as before (ws, socket.io, @iobroker/ws-server).
  • close() does not hang on HTTP/2 sessions a browser keeps open: they are closed gracefully along with the server.
  • Types: init() resolves to http.Server | https.Server | http2.Http2SecureServer, and to http.Server | https.Server only with http2: false. Http2SecureServer lacks some http.Server members, e.g. closeAllConnections() and maxHeadersCount.

Used resources

js-controller 8 keeps a per-host registry of the exclusive resources its instances occupy - the ones only a single instance can hold at a time, such as a TCP port - under system.host.<hostname>.usedResources.<type>. It answers "which port is already spoken for on this host", so a user configuring a new instance can pick a free one instead of running into EADDRINUSE.

WebServer reports its port there by itself: it registers what the server bound to once it listens, and frees the entry when the server closes again.

// io-package.json - without this js-controller derives the entry from native.port instead
{
    "common": {
        "declareUsedResources": true
    }
}
// Nothing else to do - this is on by default
const webServer = new WebServer({ app, adapter, secure: true });
const server = await webServer.init();
server.listen(port, bind); // registered from here on, freed on close()

// Opt out
const webServer = new WebServer({ app, adapter, secure: true, usedResources: false });
  • The port is taken from the listening server, not from the configuration, so what is registered is what was really bound - including the address (family only for a concrete one: a wildcard listener occupies the port in both families).
  • The feature is asked for before anything is sent. adapter.supportsFeature('CONTROLLER_USED_RESOURCES') has to say yes and the adapter's @iobroker/adapter-core has to have the methods - a host that does not know registerUsedResource never answers, so every call would first sit out the five second timeout of the adapter API. Without both, nothing is sent and a debug line says which half is missing.
  • Without common.declareUsedResources nothing is reported either (js-controller derives the entry from native.port and would refuse the registration). The server is unaffected in every case; a registration the host refuses is logged, never thrown.
  • Freeing happens on close(). An instance that is stopped or crashes without closing keeps its entry, which the host marks as no longer held.

An adapter that builds its server without WebServer, or listens on more than one port, can report the same way:

import { trackUsedPort, registerUsedPort, freeUsedPort, supportsUsedResources } from '@iobroker/webserver';

// Bound to the lifetime of a server: registered on `listening`, freed on `close`
const stopTracking = trackUsedPort(adapter, server);

// Or one-shot, for a port that is not owned by a `net.Server`
await registerUsedPort(adapter, { port: 1883 });
await freeUsedPort(adapter, { port: 1883 });

// All of them check this themselves; it is exported for an adapter that wants to know beforehand
if (supportsUsedResources(adapter)) {
    // this js-controller keeps the registry and this adapter-core can talk to it
}

CORS / access control

accessControl puts the CORS headers in front of the app, so every answer carries them:

const webServer = new WebServer({
    app,
    adapter,
    secure: true,
    accessControl: {
        // A literal origin, `*`, or a function picking one per request
        accessControlAllowOrigin: origin => origin,
        accessControlAllowMethods: 'GET,PUT,POST,DELETE,OPTIONS',
        accessControlAllowHeaders: 'Content-Type, Authorization',
        accessControlAllowCredentials: true,
        accessControlMaxAge: 600,
    },
});

Doing it here rather than as an Express middleware matters when the app registers routes that answer without calling next(): an app.use() added after createOAuth2Server() never sees POST /oauth/token, so that answer would go out without any CORS header. A concrete origin is sent together with Vary: Origin; * is not.

accessControlRequestHeaders and accessControlRequestMethod are deprecated - those are request headers a browser sends in a preflight and never did anything on a response. They now only fill in for accessControlAllowHeaders / accessControlAllowMethods.

OAuth2 support

You can activate the OAuth2 support for the webserver. To do this, add the following code after the server is initialized:

// ... initialization of the webserver        
this.webServer.app.use(cookieParser());
this.webServer.app.use(bodyParser.urlencoded({ extended: true }));
this.webServer.app.use(bodyParser.json());
this.webServer.app.use(bodyParser.text());

// Install oauth2 server (Only this line is required)
createOAuth2Server(this, { app: this.webServer.app, secure: this.config.secure, withSession: true });

// Old authentication method
this.webServer.app.use(
    session({
        secret: this.secret,
        saveUninitialized: true,
        resave: true,
        cookie: { maxAge: (parseInt(this.config.ttl as string, 10) || 3600) * 1000, httpOnly: false }, // default TTL
        // @ts-expect-error missing typing
        store: this.store!,
    }),
);

If you want to completely disable old authentication method, the code should looks like:

// ... initialization of the webserver        
this.webServer.app.use(cookieParser());
this.webServer.app.use(bodyParser.urlencoded({ extended: true }));
this.webServer.app.use(bodyParser.json());
this.webServer.app.use(bodyParser.text());

// Install oauth2 server (Only this line is required)
createOAuth2Server(this, { app: this.webServer.app, secure: this.config.secure });

Login with OAuth2 is available under /oauth/token URL:

POST /oauth/token HTTP/1.1
Host: IP:PORT
Content-Type: application/x-www-form-urlencoded
Data: grant_type=password&username=<user>&password=<password>&client_id=ioBroker&stayloggedin=<false/true>

stayloggedin=true means that the token will be stored in the browser and will be used for the next requests and is optional.

The answer is like:

{
    "access_token": "21f89e3eee32d3af08a71c1cc44ec72e0e3014a9",
    "expires_in": 3600,
    "refresh_token": "66d35faa5d53ca8242cfe57367210e76b7ffded7",
    "refresh_token_expires_in": "600000",
    "token_type": "Bearer"
}

Refresh token is available under /oauth/token URL:

POST /oauth/token HTTP/1.1
Host: IP:PORT
Content-Type: application/x-www-form-urlencoded
Data: grant_type=refresh_token&refresh_token=<REFRESH_TOKEN>&client_id=ioBroker&stayloggedin=<false/true>

The answer is the same as for the login but with new tokens.

Authorization code flow with PKCE

The password grant above requires the client to handle the user's credentials. Clients that run outside your control — MCP clients such as Claude Desktop, or any "connect your account" integration — must not do that. For them the webserver can additionally offer the browser-based authorization code flow with PKCE (RFC 7636), including dynamic client registration (RFC 7591), authorization server metadata (RFC 8414), resource indicators (RFC 8707) and token revocation (RFC 7009).

It is opt-in, because it exposes a browser-facing login page and — unless disabled — an open registration endpoint:

createOAuth2Server(this, {
    app: this.webServer.app,
    secure: this.config.secure,
    // Enable the browser-based flow
    authorizationCode: true,
    // Externally reachable base URL. REQUIRED behind a reverse proxy: the URLs published in the
    // discovery document must be the ones the client can actually reach.
    baseUrl: 'https://iobroker.example.com',
    // Optional: pin the name shown on the login and consent pages
    productName: 'ioBroker',
    // Optional: require clients to be registered by hand instead of via RFC 7591
    dynamicClientRegistration: false,
    // Optional: how many clients to keep before the oldest dynamic ones are pruned (default 100)
    maxClients: 100,
});

This adds the following endpoints:

Endpoint Purpose
GET /.well-known/oauth-authorization-server Discovery document (RFC 8414)
POST /oauth/register Dynamic client registration (RFC 7591), can be disabled
GET /oauth/authorize Login and consent page
POST /oauth/authorize Login and consent submission
POST /oauth/token Also accepts grant_type=authorization_code
POST /oauth/revoke Token revocation (RFC 7009)

Notes:

  • PKCE is mandatory. Only code_challenge_method=S256 is accepted; there are no client secrets, every client is a public client.
  • Only registered redirect URIs are accepted, compared byte-for-byte. Plain http: is allowed only for loopback addresses (RFC 8252 §7.3), so native apps can use an ephemeral local port.
  • Users who are already signed in (an access_token cookie from the web UI) only see the consent step, not another login form.
  • resource (RFC 8707) is bound to the token. OAuth2Model.getTokenInfo(accessToken) returns the stored record including aud and clientId, so a resource server can reject tokens that were issued for something else. The binding survives a refresh.
  • HTTPS is required for anything but localhost — the flow runs through the user's browser, and clients refuse plain http: for remote hosts.
  • Registered clients are stored as ioBroker objects under <adapter.namespace>.oauth.clients.*.

Client registration:

POST /oauth/register HTTP/1.1
Content-Type: application/json

{ "client_name": "Claude", "redirect_uris": ["https://claude.ai/api/mcp/auth_callback"] }

Token exchange:

POST /oauth/token HTTP/1.1
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&code=<CODE>&code_verifier=<VERIFIER>&client_id=<CLIENT_ID>&redirect_uri=<REDIRECT_URI>

Single sign-on with OpenID Connect

With the oidc option, createOAuth2Server adds /sso and /sso-callback for a login at an external identity provider (Keycloak, Authentik, Auth0, Entra ID, ...):

createOAuth2Server(this, {
    app: this.webServer.app,
    secure: this.config.secure,
    oidc: { issuer: 'https://auth.example.com/application/o/iobroker/', clientId: 'iobroker' },
    // REQUIRED behind a TLS-terminating reverse proxy: the redirect URI sent to the identity provider
    // is `<baseUrl>/sso-callback` and must match the one registered there
    baseUrl: 'https://iobroker.example.com',
    // Recommended: put only a one-time code into the URL instead of the tokens
    ssoHandoff: 'code',
});

How the result gets to the page:

  • ssoHandoff: 'url' (default, for compatibility): after a login the page gets ?ssoLoginResponse=<tokens as JSON>, after connecting an identity ?id_token=<ID token>. Both end up in the browser history and in proxy logs.

  • ssoHandoff: 'code': the page gets ?ssoCode=<code> and exchanges it within 60 seconds, once:

    POST /sso-token HTTP/1.1
    Content-Type: application/json
    
    { "code": "<CODE>" }

    The answer is the same as for /oauth/token after a login, or { "id_token": "..." } after connecting an identity.

In both cases a login also sets the access_token cookie (HttpOnly, SameSite=Lax, Secure on https).

Changelog

3.5.0 (2026-11-13)

  • (@GermanBluefox) Two-factor authentication with an authenticator app (TOTP): routes /2fa/* to set it up, the code is asked at the password login (otp at /oauth/token, second step on the login page of the authorization code flow). Users with it cannot log in with basic authentication or a password in the URL. Backup codes; an administrator can switch it off for others.

3.4.1 (2026-11-13)

  • (@GermanBluefox) Fixed: a disabled ioBroker user could still sign in with single sign-on. Like a password login, it is now refused, apart from admin

3.4.0 (2026-10-26)

  • (@GermanBluefox) Fixed: the access_token cookie of a single sign-on was set without any flags, so page scripts could read it and it was sent along cross-site requests. It is now HttpOnly, SameSite=Lax and Secure on https
  • (@GermanBluefox) Fixed: behind a TLS-terminating reverse proxy /sso sent http://.../sso-callback as redirect URI, which did not match the one registered at the identity provider, and refused a redirectUrl with https:. The baseUrl option is now used for the single sign-on too
  • (@GermanBluefox) Added: ssoHandoff: 'code' puts only a one-time code into the URL after a single sign-on, the page exchanges it via POST /sso-token. Without it the tokens (?ssoLoginResponse=) and the ID token (?id_token=) are in the URL, as before

3.3.2 (2026-10-25)

  • (@GermanBluefox) /sso?method=register Fixed problem with authentication

3.3.1 (2026-10-11)

  • (@GermanBluefox) Added tests for the single sign-on. They run the whole flow against an identity provider that is started inside the test: it publishes a discovery document and its keys, hands out a code and signs the ID token itself. Covered are the login of a connected user, the connecting of an ioBroker user, PKCE for a public client, the secret of a confidential client, the configured scopes, and that the routes do not exist without a configuration, that a foreign redirect target and an unknown state are refused, that a state works only once, and that an identity provider that cannot be reached gives a 502 instead of a dead end. npm test builds and runs them
  • (@GermanBluefox) Fixed the crash server.shouldUpgradeCallback is not a function on WebSocket connections over HTTP/1.1 with HTTP/2 enabled (Node.js 22.21 bug)

3.3.0 (2026-10-01)

  • (@GermanBluefox) Added: the identity provider for the single sign-on is configured instead of being part of the code. createOAuth2Server takes an oidc option with the issuer, the client ID, an optional client secret and the scopes, and reads the endpoints from the discovery document of the issuer, so every provider that follows the standard works: Keycloak, Authentik, Auth0, Entra ID. Until now a Keycloak realm on a private address was compiled in, which nobody but its owner could use
  • (@GermanBluefox) Breaking: without the oidc option /sso and /sso-callback do not exist any more. They used to be registered always and sent the user to the compiled-in Keycloak, so an installation that did not configure anything is not losing a login that worked
  • (@GermanBluefox) Fixed: /sso redirected the browser to whatever redirectUrl said once the login was through. Anybody who could get a user to open that address could send them anywhere; the target now has to be on the same server
  • (@GermanBluefox) Fixed: everything about a started login - the redirect target, whether it signs in or connects a user, and which user - travelled through the browser inside the state parameter, where it could be changed. The state is now an opaque random value, the login itself stays on the server, is accepted exactly once and is forgotten after ten minutes
  • (@GermanBluefox) Added: a public client proves with PKCE (S256) that the authorization code belongs to its own login, a confidential client authenticates with its client_secret, and the ID token has to carry the nonce of that login, so a token obtained elsewhere cannot be replayed
  • (@GermanBluefox) Changed: an identity provider that cannot be reached answers with 502 instead of sending the browser into nothing, and a failed discovery is not cached, so the next login tries again

3.2.2 (2026-09-25)

  • (@GermanBluefox) WebServer now reports the port it listens on to the per-host registry of used resources of js-controller 8 and frees the entry when the server is closed, so the occupied port of an adapter is the one it really bound to instead of the native.port js-controller derives it from. Nothing is sent unless supportsFeature('CONTROLLER_USED_RESOURCES') says the controller keeps the registry, so an older js-controller is unaffected. Requires "declareUsedResources": true in common of the io-package.json; opt out with usedResources: false. trackUsedPort(), registerUsedPort(), freeUsedPort() and supportsUsedResources() are the same for adapters building their own server

3.1.2 (2026-09-19)

  • (@GermanBluefox) HTTP/2: responses now have _implicitHeader(), which HTTP/1 responses have and express-session calls whenever it saves the session before the response ends. With resave, every response of an app using express-session failed with res._implicitHeader is not a function

3.1.1 (2026-09-18)

  • (@GermanBluefox) The result type of init() now includes http2.Http2SecureServer unless http2: false is set, matching the default of the http2 option since 3.1.0. It claimed http.Server | https.Server before, so members that an HTTP/2 server does not have - like closeAllConnections() - compiled and failed at runtime
  • (@GermanBluefox) Added the http2 option: a secure server speaks HTTP/2 with HTTP/1.1 fallback. Express apps, body parsers, WebSocket upgrades and close() keep working unchanged

3.0.2 (2026-09-03)

  • (@GermanBluefox) The access_token cookie of a "stay logged in" login gets its lifetime as Max-Age (relative to the browser clock) instead of Expires (an absolute date from the server clock): a server with a wrong time handed out cookies the browser dropped at once

3.0.1 (2026-08-26)

  • (@GermanBluefox) HTTPS servers built from a certificate collection now also send the issuing chain from collection.chain. Only the leaf was sent before, so clients that do not already know the intermediate - curl and most non-browser HTTP clients - failed with unable to get local issuer certificate
  • (@GermanBluefox) accessControl: accessControlAllowOrigin now also accepts a function, so the allowed origin can be picked per request (origin => origin reflects it back)
  • (@GermanBluefox) accessControl: added accessControlMaxAge, and a concrete allowed origin is now sent together with Vary: Origin
  • (@GermanBluefox) accessControl: accessControlRequestHeaders and accessControlRequestMethod are deprecated. They are request headers a browser sends in a preflight and had no effect as response headers; they now serve as a fallback for accessControlAllowHeaders / accessControlAllowMethods

3.0.0 (2026-08-26)

  • (@GermanBluefox) Fixed the OAuth2 consent page dropping the authorization response in Chromium and WebKit: form-action also applies to the redirect that follows the form POST, so the client's callback origin is now part of the policy. Clicking "Allow" appeared to do nothing and a second click reported "Request expired"
  • (@GermanBluefox) The login and consent forms now post to a relative URL, so the flow also works when the server is reverse-proxied under a path prefix
  • (@GermanBluefox) An unexpected error in an OAuth2 endpoint no longer escapes as an unhandled rejection (which terminates the host adapter); it is logged and answered with an error page
  • (@GermanBluefox) WebServer now answers ACME HTTP-01 challenges published by the acme adapter, so it no longer has to be stopped to free port 80 (opt out via acmeChallenge: false)
  • (@GermanBluefox) Exported acmeChallengeMiddleware and serveAcmeChallenge for adapters that build their server themselves
  • (@GermanBluefox) BREAKING: Updated jwks-rsa to 4.x. It depends on the ESM-only jose 6, so the minimal Node.js version is now 20.19 (or 22.12 / 23+)

2.0.1 (2026-08-04)

  • (@GermanBluefox) Added the OAuth2 authorization code flow with PKCE, dynamic client registration, authorization server metadata and token revocation (opt-in via authorizationCode: true)
  • (@GermanBluefox) Tokens can now be bound to a client and a resource (RFC 8707); the binding survives a refresh
  • (@GermanBluefox) /oauth/token no longer requires the host adapter to install a body parser

1.4.0 (2026-04-13)

  • (@GermanBluefox) Fixed possible errors
  • (@GermanBluefox) Updated packages

1.3.3 (2026-02-12)

  • (@GermanBluefox) Added missing types for the acme adapter

1.3.1 (2025-06-17)

  • (@foxriver76) Implemented (for now - inofficial) Keycloack SSO support

1.2.8 (2025-04-29)

  • (@GermanBluefox) Corrected time to live for the access token

1.2.7 (2025-04-21)

  • (@GermanBluefox) Corrected a problem with authentication, as type-is was too old.

1.2.6 (2025-04-01)

  • (@GermanBluefox) Changed the order of authentications. Basic authentication will be checked as the last one.
  • (@GermanBluefox) Added the setting to disable basic authentication

1.2.4 (2025-03-25)

  • (@GermanBluefox) Added the possibility to give tokens for internal use (like node-red)

1.2.0 (2025-03-05)

  • (@GermanBluefox) Added the log output for invalid password in OAuth2
  • (@GermanBluefox) A minimal Node.js version is 16 (Not breaking, as no one uses node 14)
  • (@GermanBluefox) Updated TypeScript to 5.8

1.1.7 (2025-02-27)

  • (@GermanBluefox) Added support for OAuth2 authentication with brute force

1.0.8 (2025-02-07)

  • (@GermanBluefox) Updated packages and typing

1.0.6 (2024-09-14)

  • (@GermanBluefox) Added access control options for server
  • (@GermanBluefox) Used @iobroker/eslint-config for linting

1.0.3 (2023-10-16)

  • (@GermanBluefox) Extend the security checker with the pattern detection and custom URL

1.0.1 (2023-10-11)

  • (@GermanBluefox) Changed the error text of the security checker

1.0.0 (2023-10-11)

  • (@GermanBluefox) added the security checker

0.3.7 (2023-09-24)

  • (raintonr) Fix contexts for SNICallback (#3).

0.3.6 (2023-07-07)

  • (@GermanBluefox) Update packages

0.3.4 (2023-03-27)

  • (@GermanBluefox) Corrected small error with CA certificate

0.3.3 (2023-03-24)

  • (@GermanBluefox) Added check of the cert files

0.3.1 (2023-03-20)

  • (@GermanBluefox) Corrected error with getCertificatesAsync

0.3.0 (2023-03-20)

  • (@GermanBluefox) Added support for user-configured certificates for fallback

0.2.1 (2023-03-20)

  • (@GermanBluefox) Rename Webserver to WebServer

0.1.0 (2023-03-13)

About

Webserver for ioBroker adapters, with integrated certificate managment

Resources

Security policy

Stars

1 star

Watchers

3 watching

Forks

Releases

Packages

Used by

Contributors

Languages