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.

SettingMeaning
AUTH_BACKEND=httpSelects this backend
AUTH_HTTP_URLBase URL. kraken appends /validate, /revalidate and /check-room-access, keeping any path in the base, so http://core:3000/v1/internal/actors works.
BACKEND_SECRETSent 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 serviceThe client's connect() fails with
200 with "result": "deny"access_denied
Any status other than 200authentication_failed
No answer within 5 seconds, or unreachableconnection_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:

KeyMeaning
actor_token_idRequired. The actor's id: reported to the client, used for presence, revalidation and default load-balance groups.
project_idReported to the client in the auth reply; part of load-balance group names.
organization_idThe group kraken counts connections in for max_connections.
actor_typeDefault user. Reported to the client.
appsThe actor's grants, grouped by app (below).
max_connectionsConnection limit for the organization_id, counted across the cluster. null or absent means unlimited.
scope_slugAn 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_atUnix 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_secondsAsk the MQTT broker backend for a non-clean session with this expiry. Ignored by the default syn broker.
max_message_size_bytesAccepted, but not enforced in v0.9.0: every publish is held to the fixed 921,600 byte ceiling.

Each entry in apps:

KeyMeaning
app_idThe 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_nameDescriptive
allowed_topicsThe 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_subscriptionsSubscriptions 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_webhooksPer-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 answerWhat kraken does
200 { "valid": true, ...client_attrs... } with the attributes at the top levelReplaces 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 answerKeeps 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-bob connected as actor bob; an unknown token was refused with access_denied.
  • bob's subscribe to chat/general/messages was accepted from his grants, and to chat/late/messages through /check-room-access. A subscribe to chat/secret/messages was refused with unknown_topic.
  • alice's publish to chat/general/messages reached bob. Her publish to chat/late/messages was refused with unknown_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.