Authentication
Authentication in mekik is opt-in and connect-time only. By default every connection is accepted and identity is client-asserted. Configure an Authenticator and connect requires a valid credential — and a verified identity overrides whatever the client claimed. This page is that model.
Scope, up front. v1 auth is connect-time only: no mid-session refresh, no token expiry handling on the server, no RBAC. It decides who may open a socket, and nothing more. Authorization beyond that is your graph's job.
The port
An Authenticator is a one-method port:
- TypeScript
- .NET
interface Authenticator {
authenticate(credential: Credential): Promise<AuthVerdict> | AuthVerdict;
}
interface Credential {
token?: string; // from hello.token, ?token=, or Bearer header
headers?: Record<string, string | undefined>; // raw connect headers (for a cookie/session check)
query?: Record<string, string | undefined>; // raw connect query params
}
interface AuthVerdict {
ok: boolean;
userId?: string; // the authoritative id — overrides the asserted one
claims?: Record<string, unknown>; // surfaced to nodes at ctx.meta.auth
reason?: string; // human-readable rejection reason
}
public interface IAuthenticator
{
ValueTask<AuthVerdict> AuthenticateAsync(Credential credential);
}
public sealed record Credential
{
public string? Token { get; init; } // from hello.token, ?token=, or Bearer header
public IReadOnlyDictionary<string, string?>? Headers { get; init; } // raw connect headers (cookie/session check)
public IReadOnlyDictionary<string, string?>? Query { get; init; } // raw connect query params
}
public sealed record AuthVerdict
{
public required bool Ok { get; init; }
public string? UserId { get; init; } // authoritative id — overrides the asserted one
public IReadOnlyDictionary<string, object?>? Claims { get; init; } // surfaced to nodes at ctx.Meta["auth"]
public string? Reason { get; init; } // human-readable rejection reason
}
Enable it on the app:
- TypeScript
- .NET
const app = mekik({ graph, authenticator: myAuthenticator });
The port is Authenticator with an authenticate(credential) method returning an AuthVerdict.
var app = new MekikApp(new MekikOptions { Graph = graph, Authenticator = myAuthenticator });
The port is IAuthenticator with an AuthenticateAsync(credential) method returning the same verdict shape.
Where the credential comes from
The transport assembles the Credential from whatever channel carried it:
| Channel | Fills | Typical use |
|---|---|---|
hello.token | credential.token | a client that sends a token in the handshake frame |
?token= query param | credential.token + credential.query | a proxy/gateway authenticating at the HTTP upgrade |
Authorization: Bearer header | credential.token | a non-browser client (browsers can't set WS headers) |
| a cookie | credential.headers | a same-site browser app (HttpOnly session cookie) |
Your Authenticator reads whichever it expects. A JWT authenticator verifies credential.token; a session authenticator reads the cookie out of credential.headers.
The verified-userId rule (anti-spoofing)
The single most important rule:
When an
Authenticatorreturnsok:truewith auserId, that id is authoritative and overrides anyuserIdthe client asserted inhello.
A client can claim to be userId: "admin" in its hello. If the authenticator verifies the token and says the user is actually u-7, the connection is u-7 — the asserted admin is discarded. A valid token cannot be used to impersonate another user. Read the verified id back from welcome:
await connector.connect();
connector.identity?.userId; // the id the server verified, not necessarily the one asserted
Rejection
When authenticate returns ok:false, the engine sends an error frame and closes the socket:
{ "type": "error", "data": { "code": "unauthorized", "message": "invalid token" } }
followed by WebSocket close code 4401 (AUTH_CLOSE_CODE). A client should treat 4401 as terminal for this credential — reconnecting with the same rejected token can't change the verdict. An auth rejection is not a chat message; it never lands in the transcript.
Claims reach the node
On success, the verdict's claims are placed at ctx.meta.auth, so a node reads verified, server-side facts without the graph knowing anything about auth:
- TypeScript
- .NET
.node("desk", async (state, ctx) => {
const claims = ctx.meta.auth as { role?: string; tenant?: string } | undefined;
if (claims?.role !== "admin") return { reply: "Not authorized for that." };
return { reply: await adminAction() };
})
.Node("desk", async (State state, IContext ctx) =>
{
var claims = ctx.Meta.GetValueOrDefault("auth") as IReadOnlyDictionary<string, object?>;
if (claims?.GetValueOrDefault("role") as string != "admin")
return Update.Of("reply", "Not authorized for that.");
return Update.Of("reply", await AdminAction());
})
claims stay server-side — they are not sent to the client. They're the trusted context your graph branches on. See Concepts → Graph context.
A minimal authenticator
For tests and simple deployments, StaticTokenAuthenticator is a fixed token → {userId, claims} table:
- TypeScript
- .NET
import { mekik, StaticTokenAuthenticator } from "@mekik/core";
const app = mekik({
graph,
authenticator: new StaticTokenAuthenticator({
"tok-alice": { userId: "u-alice", claims: { role: "admin" } },
"tok-bob": { userId: "u-bob", claims: { role: "user" } },
}),
});
using Mekik;
var app = new MekikApp(new MekikOptions
{
Graph = graph,
Authenticator = new StaticTokenAuthenticator(new Dictionary<string, (string, IReadOnlyDictionary<string, object?>?)>
{
["tok-alice"] = ("u-alice", new Dictionary<string, object?> { ["role"] = "admin" }),
["tok-bob"] = ("u-bob", new Dictionary<string, object?> { ["role"] = "user" }),
}),
});
A real one verifies a JWT signature or a session cookie:
- TypeScript
- .NET
const jwtAuth: Authenticator = {
async authenticate(cred) {
if (!cred.token) return { ok: false, reason: "no token presented" };
try {
const payload = await verifyJwt(cred.token, secret);
return { ok: true, userId: payload.sub, claims: { role: payload.role } };
} catch {
return { ok: false, reason: "invalid token" };
}
},
};
public sealed class JwtAuthenticator : IAuthenticator
{
public async ValueTask<AuthVerdict> AuthenticateAsync(Credential cred)
{
if (cred.Token is null) return new AuthVerdict { Ok = false, Reason = "no token presented" };
try
{
var payload = await VerifyJwt(cred.Token, secret);
return new AuthVerdict
{
Ok = true,
UserId = payload.Sub,
Claims = new Dictionary<string, object?> { ["role"] = payload.Role },
};
}
catch
{
return new AuthVerdict { Ok = false, Reason = "invalid token" };
}
}
}
Pairing with the client
The chativa connector presents credentials through its own MekikAuthProvider adapters — CookieAuth, TokenAuth, or a custom one — which decide what the client sends, while your Authenticator decides whether to accept it. The two are mirror ports:
| chativa adapter (client) | mekik authenticator (server) | credential |
|---|---|---|
CookieAuth | a cookie/session Authenticator | the browser's cookie — nothing to send |
TokenAuth (string) | a static-token Authenticator | a long-lived API key |
TokenAuth (function) | a JWT Authenticator | a short-lived JWT, re-minted per attempt |
your own MekikAuthProvider | your own Authenticator | anything |
See chativa's MekikConnector → Authentication for the client side.
Where to go next
- Transport — how the credential is assembled from the socket.
- Protocol → Identity & resume — how a verified
userIdinteracts with the four-id model. - Concepts → Graph context —
ctx.meta.authalongsidemeta.mekikandmeta.client.