HTTP Auth Contract
With AUTH_BACKEND=http, kraken asks an HTTP service of yours whether a token is valid and what it may reach. The contract is three JSON endpoints. @nolag/core's example host implements them, and so can anything else.
| Setting | Meaning |
|---|---|
AUTH_BACKEND=http | Selects this backend |
AUTH_HTTP_URL | Base URL. kraken appends /validate, /revalidate and /check-room-access, keeping any path in the base, so http://core:3000/v1/internal/actors works. |
BACKEND_SECRET | Sent on every call as Authorization: Bearer <secret>. Check it, and keep the service off public networks. |
Every call is a POST with a JSON body. kraken waits up to 5 seconds for /validate and /revalidate, and 2 seconds for /check-room-access.
POST /validate
Called when a client connects or sends a reauth, unless kraken validated the same token in the last 30 seconds.
{ "accessToken": "the token the client sent" }
Answer 200 with one of:
{ "result": "allow", "client_attrs": { "actor_token_id": "bob", "apps": [] } }
{ "result": "deny" }
| Your service | The client's connect() fails with |
|---|---|
200 with "result": "deny" | access_denied |
Any status other than 200 | authentication_failed |
| No answer within 5 seconds, or unreachable | connection_failed |
A denial is a valid answer, not an error, so return it with 200. @nolag/core does the same.
Client attributes
What kraken v0.9.0 reads from an allow answer:
| Key | Meaning |
|---|---|
actor_token_id | Required. The actor's id: reported to the client, used for presence, revalidation and default load-balance groups. |
project_id | Reported to the client in the auth reply; part of load-balance group names. |
organization_id | The group kraken counts connections in for max_connections. |
actor_type | Default user. Reported to the client. |
apps | The actor's grants, grouped by app (below). |
max_connections | Connection limit for the organization_id, counted across the cluster. null or absent means unlimited. |
scope_slug | An access scope. kraken rewrites a client's app/room/topic to app/<scope>/room/topic when only the scoped address matches the grants. See Access Scopes. |
auth_expires_at | Unix seconds. kraken refuses the connect with token_expired once it has passed, and closes a live connection with code 4003 at its first heartbeat after it. Set it for short-lived tokens; omit it for long-lived ones. |
persistent_session, session_expiry_seconds | Ask the MQTT broker backend for a non-clean session with this expiry. Ignored by the default syn broker. |
max_message_size_bytes | Accepted, but not enforced in v0.9.0: every publish is held to the fixed 921,600 byte ceiling. |
Each entry in apps:
| Key | Meaning |
|---|---|
app_id | The app's id. Grants in this app resolve to internal topics under it, so every actor of the app must get the same value. |
app_name | Descriptive |
allowed_topics | The grants: { "pattern", "permission", "topic", "room_id", "room_slug" }. pattern is app/room/topic and may use + and #; permission is subscribe, publish or pubSub; room_id and room_slug are needed for presence and lobbies; topic optionally names the internal topic for an exact pattern. |
allowed_lobbies | [{ "lobby_slug", "lobby_id" }] |
active_subscriptions | Subscriptions kraken restores when the client reconnects: addresses, or { "pattern", "topic", "load_balance", "load_balance_group", "filters" } |
hydration_webhook, trigger_webhook | { "url", "headers" } or null. See Webhooks. |
topic_webhooks | Per-topic overrides: { "<topic>": { "on_subscribe": { ... }, "on_publish": { ... } } } |
How grants resolve to internal topics, and why every actor of an app must agree on app_id and topic, is the same as for the static file: see How grants turn into topics. Unlike the static file, an HTTP answer can carry several apps, and each grant keeps its own app's id.
POST /revalidate
Called for each live connection on a heartbeat when at least 10 minutes have passed since it was last validated.
{ "actorTokenId": "bob" }
| Your answer | What kraken does |
|---|---|
200 { "valid": true, ...client_attrs... } with the attributes at the top level | Replaces the connection's grants with the new ones |
200 { "valid": false, "disconnect_reason": "token_revoked" } | Closes the connection with WebSocket close code 4001 and the reason as the close reason (falling back to error, then unknown) |
| Any other status, a timeout, or no answer | Keeps the connection, and tries again at the next heartbeat |
We checked the second row against kraken v0.9.0 with the service below: a connection whose actor the service had revoked was closed 600.1 seconds after it connected, with code 4001 and reason token_revoked, and no frame before the close.
Check room access
POST /check-room-access is optional, and called in one situation only: a client subscribes to an address that none of its cached grants covers, typically a room created after it connected.
{ "actorTokenId": "bob", "pattern": "chat/late/messages" }
{
"allow": true,
"allowed_topics": [
{ "pattern": "chat/late/#", "permission": "pubSub", "app_id": "chat-app", "room_id": "room-late", "room_slug": "late" }
]
}
On allow: true, kraken adds the returned grants to the connection and accepts the subscribe, provided one of them covers the address; each grant needs its app_id. Anything else (allow: false, an error status, no answer within 2 seconds, or a service without the endpoint) refuses the subscribe with unknown_topic. kraken remembers a refusal for 5 seconds per actor and address, and while your service is unreachable a circuit breaker skips the call altogether.
This applies to subscribes only. A publish outside the cached grants fails with unknown_topic without calling your service, until the next revalidation brings the new grants or the client reconnects. To switch the check off, set cache_miss_fallback_enabled to false in kraken's sys.config.
A minimal service
This Node.js service implements all three endpoints for two hard-coded tokens. It uses only the standard library:
import http from "node:http";
const SECRET = process.env.BACKEND_SECRET;
// Your user store. Here: two hard-coded tokens.
const actors = {
"token-for-alice": { id: "alice", active: true },
"token-for-bob": { id: "bob", active: true },
};
// Everything kraken needs to know about one actor.
function clientAttrs(actorTokenId) {
return {
actor_token_id: actorTokenId,
project_id: "my-project",
organization_id: "my-org",
actor_type: "user",
apps: [
{
app_id: "chat-app",
app_name: "chat",
allowed_topics: [
{ pattern: "chat/general/#", permission: "pubSub", room_id: "room-general", room_slug: "general" },
],
},
],
};
}
const byId = (id) => Object.values(actors).find((a) => a.id === id);
const routes = {
"/validate": ({ accessToken }) => {
const actor = actors[accessToken];
return actor?.active
? { result: "allow", client_attrs: clientAttrs(actor.id) }
: { result: "deny" };
},
"/revalidate": ({ actorTokenId }) => {
const actor = byId(actorTokenId);
return actor?.active
? { valid: true, ...clientAttrs(actorTokenId) }
: { valid: false, disconnect_reason: "token_revoked" };
},
"/check-room-access": ({ actorTokenId, pattern }) => {
// Called when a subscribe misses the cached grants. Allow chat/late/<topic>.
const [app, room] = pattern.split("/");
if (byId(actorTokenId)?.active && app === "chat" && room === "late") {
return {
allow: true,
allowed_topics: [
{ pattern: "chat/late/#", permission: "pubSub", app_id: "chat-app", room_id: "room-late", room_slug: "late" },
],
};
}
return { allow: false, allowed_topics: [] };
},
};
http
.createServer((req, res) => {
let body = "";
req.on("data", (chunk) => (body += chunk));
req.on("end", () => {
const route = routes[req.url];
const authorized = req.headers.authorization === `Bearer ${SECRET}`;
const status = !authorized ? 401 : route ? 200 : 404;
const reply = status === 200 ? route(JSON.parse(body || "{}")) : {};
res.writeHead(status, { "content-type": "application/json" });
res.end(JSON.stringify(reply));
});
})
.listen(4000, () => console.log("auth service on :4000"));
Run it, then build kraken (in your kraken checkout) and start it pointed at the service:
BACKEND_SECRET=change-me node auth-service.mjs
docker build -t kraken:0.9.0 .
docker run --rm -p 8080:8080 \
-e AUTH_BACKEND=http \
-e AUTH_HTTP_URL=http://host.docker.internal:4000 \
-e BACKEND_SECRET=change-me \
--add-host host.docker.internal:host-gateway \
kraken:0.9.0
What we saw with this pair, connecting with @nolag/js-sdk:
token-for-bobconnected as actorbob; an unknown token was refused withaccess_denied.- bob's subscribe to
chat/general/messageswas accepted from his grants, and tochat/late/messagesthrough/check-room-access. A subscribe tochat/secret/messageswas refused withunknown_topic. - alice's publish to
chat/general/messagesreached bob. Her publish tochat/late/messageswas refused withunknown_topic, because publishes do not trigger the room-access check.
A real service would look tokens up in a database, compare secrets in constant time, and return the actor's real grants. It should also answer /validate quickly: every connect waits for it.
Using @nolag/core
@nolag/core implements this contract through AuthzFacade and its toBroker* response adapters, and its example host serves it under /v1/internal/actors. Point kraken at it with:
AUTH_BACKEND=http
AUTH_HTTP_URL=http://core:3000/v1/internal/actors
The example host does not check BACKEND_SECRET, or any other credential. A host you write for production should require the secret, and kraken will send it. See Full Stack.
Restoring subscriptions
kraken restores what your service returns in active_subscriptions when a client reconnects; it keeps no record of its own. To learn what clients subscribed to, run kraken with CONTROL_BACKEND=http and CONTROL_HTTP_URL: it then posts batches of subscribe and unsubscribe events to {CONTROL_HTTP_URL}/subscriptions, which your service can store and hand back. The shape of those calls is in Plugins. @nolag/core's example host does not implement them, which is why the full-stack quickstart does not restore subscriptions.