Skip to content

Authentication and authorization ​

Both are off by default, so an unconfigured server accepts any peer and allows any exposed call. The management surface is not off by default in the same sense — it is simply never published unless asked for. See below.

Authenticating peers ​

authenticate receives whatever the client sent as credentials and returns an identity to accept the peer, or undefined to reject it. Rejected peers never reach the RPC layer — the check runs as socket.io middleware, before the connection is established at all.

typescript
const server = new RpcServer({
    transports: [{ port: 7843 }],
    authenticate: async (credentials) => {
        const user = await lookUpToken((credentials as { token?: string }).token)
        return user && { name: user.id, roles: user.roles }
    }
})

const client = new RpcClient('http://localhost:7843', {
    name: 'operator-17',              // must equal the identity's name, see below
    credentials: { token: 'a-token' }
})

RpcClientOptions.name must match RpcIdentity.name. The source field of a message is written by the sender, so it is a claim, not evidence. An authenticating transport pins each connection to the name it authenticated as and drops frames claiming any other source. Without that, an authenticated peer could address its calls as another peer and inherit its rights.

It pins the peer registry to the same rule. A frame's source is normally learned as it is parsed, which is how discovery works over MQTT — the broker is the authority there and there is no connection to check. Where there is one, a name is registered only once the connection has been checked, so a rejected frame cannot leave a peer that does not exist in the routing table.

Tokens, without writing the authenticator ​

createTokenAuthenticator is the common case packaged: a map from bearer token to the peer it admits.

typescript
import { createTokenAuthenticator, defaultWebSocketPort, RpcServer } from '@source-repo/rpc'

const server = new RpcServer({
    transports: [{ port: defaultWebSocketPort }],
    authenticate: createTokenAuthenticator({
        [process.env.PLANT_TOKEN!]: 'plantServer',
        [process.env.HMI_TOKEN!]: { name: 'hmi', roles: ['operator'] }
    })
})

One token per peer, not one token for the bus. A token that maps to a name is evidence of who is calling, and the rule above then does the rest: a holder that connects under any other name gets a socket and nothing else — its announcement is refused, so it is never listed, and its frames are dropped. A single token shared by everyone proves only that the caller is inside the fence, and any holder could then claim to be the peer whose commands matter. There is deliberately no single-secret form.

Blank tokens, grants with no name and an empty map all throw rather than construct, because each one would quietly admit more than it looks like it does.

Authorizing calls ​

authorize runs for every call and every event subscription. Return false to reject with a Forbidden error.

typescript
const server = new RpcServer({
    transports: [{ port: 7843 }],
    authenticate,
    authorize: ({ identity, instanceName, method, subscription }) => {
        if (subscription) return identity?.roles?.includes('observer') ?? false
        if (instanceName === 'plant' && method.startsWith('write')) return identity?.roles?.includes('engineer') ?? false
        return true
    }
})

An authorizer that throws denies the call. Failing open would turn a bug in the authorizer into an access-control bypass.

requireAuthenticatedPeers defaults to true when authenticate is set, rejecting calls from peers no transport can vouch for with an Unauthorized error.

Describing transport posture without describing secrets ​

When introspection is enabled, every built-in transport adds a security section to its RpcTransportDescription. It reports four separate facts: authentication, encryption, frame signing and relaying. Each carries its evidence as well as its value:

typescript
{
    authentication: { value: 'credentials-presented', evidence: 'peer' },
    encryption: { value: 'tls-verified', evidence: 'peer' },
    signing: { value: 'bidirectional', evidence: 'peer' },
    relay: { value: 'not-applicable', evidence: 'peer' }
}

The wording is intentionally narrow. A dialling peer can say it presented credentials; only the listener knows whether it required and accepted them. A listener can say it serves TLS; only a dialler can say it verified the certificate. MQTT broker credentials and Source RPC frame signing are separate because the first establishes access to a broker while the second establishes a frame's Source RPC identity end to end.

These are configuration facts reported by the described peer, so their initial evidence is peer. A console may add what its own link verified as link evidence, but must never silently promote the peer's report. { evidence: 'unknown' } and { value: 'none', evidence: 'peer' } are different answers.

Only closed posture values travel. Tokens, usernames, passwords, keys, certificates and arbitrary transport options do not. Public endpoints have user information, query and fragment stripped before they enter describe().

The web console can ask its own console.identity() query what the console server established about the browser connection carrying that call. The answer contains only authentication state, the routed source, and—when the transport authenticated it—the pinned identity name and roles. It never contains authenticator claims, tokens, credentials, or policy details. Each source fact says whether it is link evidence or an unverified routed claim.

This does not answer whether the browser may call a plant method. Browser calls are relayed: the page asks the console process, then that process asks the target peer. The target authorizes the console's outbound link identity or source claim, not the browser-link identity displayed by the Security workspace. Consequently the method panel can explain the target's declared effect, semantics, component-authority requirement, and current elevations, but its access decision remains unknown until call time. A custom authorizer may also use parameters and private policy that introspection must not reveal.

Operational security visibility ​

The web console projects security posture through an ordinary tree-shaped DataProvider. Its peer branches include transport posture, active elevations, and live component-authority leases; its network branches include conservative findings and the console's bounded security-event history. Every row retains the evidence vocabulary above, so self-reporting, link verification, local inference, and unavailable information never collapse into one trust claim.

msgrpc.authorities() returns only components that declare authority-gated methods, together with those method names and their current { holder?, expiresAt?, generation }. It is an authorized query and intentionally separate from describe(): callers denied this query learn no holder, and caches cannot turn an expired lease into current posture.

Each known authority row has ordinary DataProvider actions for acquiring or renewing a bounded lease and releasing the console's own lease. Their first argument is the opaque row id, exactly as for every DataProvider action; the console validates it against the currently disclosed authority records, then invokes the component's existing $acquire or $release. The target still authorizes those calls. The open console offers one-, five-, and fifteen-minute leases and no takeover. Unknown authority placeholders are deliberately not actionable.

An offline transition clears live authority immediately. Existing component values are retained by their own channel as explicitly stale values, not presented as current posture. When the same peer name returns, the console invalidates cached descriptions, refusals, and authority before re-describing peers it had already used; peers nobody opened remain discovery-only and cost no introspection call. The first description is a transport-posture baseline. Later descriptions are compared using only the public, secret-free transport contract and produce bounded transport-change events for added or removed transports and changes to endpoint, authentication, encryption, signing, or relay posture. Disconnect alone stays a Presence event.

The console normalizes transport rejections plus relayed Unauthorized, Forbidden, and NotInControl outcomes into safe reason codes. It retains 200 entries in memory and streams new ones. A peer exposing introspection also publishes a safe msgrpc.security event for AI access decisions, authority changes and elevation changes. The console subscribes after the peer describes that event and rebuilds every received object from an allow-list. Codes are closed; raw authorizer errors, AI-grant reasons, claims, credentials and call parameters are absent. These rows retain peer evidence because the target reported the decision rather than pretending the console verified its policy.

The target keeps no event history. describe() and authorities() answer what is true now; the event bridge explains transitions observed after subscription. The console's bounded history is useful for diagnosing a running network, but it is neither complete nor durable audit evidence. In the Security DataProvider, derived posture rows are explicitly marked current and retained event rows history, so an old transition is not confused with the peer's current state.

Run the cross-layer acceptance fixture with:

sh
npm run test:security --workspace @source-repo/rpc-cli

It verifies authenticated Socket.IO roles, the anonymous browser-to-console versus authenticated console-to-target boundary, expiring AI grants and elevations, authority contention and expiry, identity-name mismatch, reconnect, signed and unsigned MQTT, and absence of token/signing secrets from console-facing answers. The web tests additionally pass credential-bearing hostile input through the Security DataProvider, generic value renderer, traffic row boundary and JSONL serializer. Metadata traffic excludes arguments, results and detailed errors; payload capture is a deliberate opt-in and those application values are not claimed to be redacted.

AI observers and personas ​

The same Security DataProvider is intended for MCP use, but the observer changes: its reads and actions must leave the MCP peer under that peer's own authenticated ai-tool identity. An MCP aggregate must not relay through the web console or publish itself as a more privileged network provider, either of which would make the target authorize the intermediary instead of the AI.

One MCP process currently has one Source RPC identity. Distinct PLC-development and security-administration personas therefore run as distinct HTTP-only MCP tasks with separate peer names, bus credentials, ports and MCP door tokens. A persona is selected by which authenticated door the client connects to, never by a prompt or tool argument. ai.tool.write, ai.tool.program, and ai.sponsor remain independent target-side grants, with ordinary authorize() narrowing the exact nodes and methods underneath them.

The generic MCP DataProvider adapter, current Security resource hydration, and bounded operational event collection are implemented. Its declared authority actions call the target directly under the selected persona. Beginning with the first Security-provider read, each described peer declaring msgrpc.security gets one authorized subscription; only the closed event vocabulary crosses the boundary, the newest 200 events are kept in process memory, and a returned peer is subscribed afresh. Coverage counts and an explicit unavailable-source finding distinguish a quiet network from a refused observation. The result is intentionally persona-local diagnostic history rather than a complete or durable audit trail.

The management surface ​

manageRpc is not exposed by default. Enabling it publishes exactly one method, createRpcInstance, which constructs an instance of a class already passed to exposeClass():

typescript
const server = new RpcServer({ transports: [{ port: 7843 }], exposeManagement: true })

It is still subject to authorize, so you can restrict who may create instances. The expose* methods are never remotely reachable.

Versions before 2.0.0 published all of ManageRpc under manageRpc with no authentication, so any peer that could reach the transport could construct any exposeClass'd class with chosen arguments, or overwrite an exposed name and deny service to every other client. If you are upgrading, treat both as having been reachable.

A peer says what it can currently do that is dangerous ​

Before there is any mechanism for granting elevated access, there should be one for seeing it — because a gate whose state nobody can observe is a gate nobody can audit. Today the question "is anything on this network unlocked right now" should have an answer without calling anything.

describe() carries elevated, and a console shows it above everything else on the peer:

elevated   docker.create — may create containers from postgres, emqx/emqx · until someone closes it

It announces and nothing more. authorize(), the AI grants document and the capability's own allow-list decide what may happen, and would decide exactly the same with this field removed. What it buys is that the posture travels.

It is asked of the instance, not remembered by the host. A component that is an elevation implements elevation(), so composing it into a host is what makes the host announce it — the way dataResources() works. That matters because the failure this exists to catch is somebody forgetting, and an announcement you have to remember to make is one that will be missed exactly when it counts.

For a capability that is not an object — a mounted socket, a debug endpoint, a flag somebody passed — the host declares it directly:

typescript
const held = server.elevate({ capability: 'debug.endpoint', reason: 'diagnosing', until: Date.now() + 2 * 3600_000, grantedBy: 'anders' })
// …later, or by itself when `until` passes
held.lower()

The most important field is until, and the most important case is its absence. An elevation nothing will close is one somebody has to remember to close — the taped-over key, opened for a reason that passed while nobody came back. A viewer draws that as worse than a bounded one rather than the same, and where an until is given it is enforced as well as announced, so the announcement cannot outlive the thing.

A lapsed elevation is not announced at all. Posture is what is true now; history belongs in the audit trail.

MIT licensed