30 min

Wer darf was

Ein Token je Vorgang ist die interessantere Bauform: Es gehört keinem Nutzer, sondern einem Durchgang.

Meilenstein Derselbe Container bedient zwei Endpunkte mit zwei Auth-Modellen, und keine Sitzung wandert zwischen ihnen.

Bei stdio beantwortet sich die Frage, bevor sie gestellt wird: Berechtigt ist, wer den Prozess starten durfte. Sobald der Server eine Adresse hat, steht sie offen. Und sie hat mehr als eine richtige Antwort.

Modell 1: ein Token für den Dienst

Die einfachste Bauform, und für interne Dienste meistens die richtige. Ein Geheimnis in der Umgebung, ein Bearer-Kopf, fertig. Vier Zeilen.

// helpdesk/auth.ts
import { timingSafeEqual } from "node:crypto";

/** Zeitkonstanter Vergleich. `timingSafeEqual` verlangt gleich lange Puffer und
 *  wirft sonst; dass die Länge durchsickert, ist bei einem Hex-Token aus
 *  `openssl rand` egal - seine Länge ist ohnehin bekannt. */
export function gleich(a: string, b: string): boolean {
  const x = Buffer.from(a, "utf8");
  const y = Buffer.from(b, "utf8");
  return x.length === y.length && timingSafeEqual(x, y);
}

export function pruefeBetreiber(req: IncomingMessage): boolean {
  const erwartet = process.env.HELPDESK_TOKEN;
  if (!erwartet) return false;
  const kopf = req.headers["authorization"];
  return typeof kopf === "string" && gleich(kopf, `Bearer ${erwartet}`);
}

Warum timingSafeEqual und nicht ===? Ein === auf Strings bricht beim ersten unterschiedlichen Zeichen ab, und dieser winzige Unterschied wird über viele Versuche messbar, sodass sich ein Token Zeichen für Zeichen erraten lässt. Die sichere Fassung kostet dich vier Zeilen.

Zwei Kleinigkeiten stecken noch darin. Ein fehlendes Token gilt als nicht berechtigt und nicht als offene Tür; ein Dienst, der ohne gesetzte Umgebungsvariable jeden hereinlässt, fällt erfahrungsgemäß erst im Betrieb auf. Und die Antwort lautet 401 ohne Erklärung. Wer kein Token hat, muss nicht erfahren, ob es das richtige gegeben hätte.

Modell 2: ein Token je Vorgang

Das interessantere Muster, und im Feld deutlich seltener. Das Token gehört keinem Nutzer. Es gehört einem Durchgang, entsteht mit ihm, stirbt mit ihm und öffnet sonst nichts.

export type Vorgang = { id: string; token: string; bestand: Map<string, Ticket> };

const VORGAENGE = new Map<string, Vorgang>();

export function oeffneVorgang(id: string, token: string): Vorgang {
  const vorgang: Vorgang = { id, token, bestand: neuerBestand() };
  VORGAENGE.set(token, vorgang);
  return vorgang;
}

/** Token → Vorgang. Erst die Form prüfen, dann nachschlagen: Was nicht wie ein
 *  Token aussieht, kostet keine Suche. `null` heißt 401, ohne zu verraten, ob
 *  es das Token gab. */
export function loeseVorgang(token: string): Vorgang | null {
  if (!/^[a-f0-9]{32}$/.test(token)) return null;
  return VORGAENGE.get(token) ?? null;
}

Wozu der Umweg? Weil damit drei Dinge auf einmal gelöst sind, für die du sonst drei Mechanismen bräuchtest.

Der Datenschnitt. Der Vorgang trägt seinen Bestand. Wer sein Token vorlegt, sieht seine acht Tickets und keine anderen, und das liegt nicht an einer WHERE-Klausel: Der Server, der für ihn gebaut wird, kennt nur diese.

Die Lebensdauer. Ein Nutzer-Token wird irgendwann widerrufen, und dafür braucht es eine Widerrufsliste. Ein Vorgangs-Token endet, wenn der Vorgang endet.

Die Weitergabe. Es steht auf einer Seite, wird in eine Client-Konfiguration kopiert und liegt danach in einer Datei auf einem fremden Rechner, wo es vermutlich noch Wochen liegen bleibt, nachdem der Vorgang längst zu ist. Ein Nutzer-Token wäre dort ein Problem. Dieses hier öffnet einen Übungslauf.

So arbeiten die Simulationswelten dieses Repos. Die Sim-Endpunkte (/sim-calendar, /sim-crm) prüfen ein Run-Token aus parcours_runs und niemals das MCP_AGENT_TOKEN des Betreibers; das gehört der Redaktionsschnittstelle allein. Der Token-Auflöser pinnt den Lauf zusätzlich auf seine eigene Aufgabengattung, damit ein gültiges Kalender-Token am CRM nichts öffnet.

Beide im selben Prozess

Ein Server, zwei Türen. Die Verzweigung steht im HTTP-Handler:

// helpdesk/http.ts
const server = http.createServer(async (req, res) => {
  const pfad = (req.url ?? "/").split("?")[0]!;

  if (pfad === "/health" && req.method === "GET") {
    res.writeHead(200, { "content-type": "application/json" });
    res.end(JSON.stringify({ ok: true, sitzungen: sitzungen.anzahl }));
    return;
  }

  if (pfad === "/vorgang" || pfad.startsWith("/vorgang/")) {
    const token = lies(req, pfad);
    const vorgang = token ? loeseVorgang(token) : null;
    if (!vorgang) {
      res.statusCode = 401;
      res.end("Unauthorized");
      return;
    }
    await bediene(req, res, `vorgang:${vorgang.id}`, () => baue(vorgang.bestand));
    return;
  }

  if (pfad !== "/mcp") {
    res.statusCode = 404;
    res.end();
    return;
  }
  if (!pruefeBetreiber(req)) {
    res.statusCode = 401;
    res.end("Unauthorized");
    return;
  }
  await bediene(req, res, "betrieb", () => baue(betriebsBestand));
});

Das Token darf im Pfad oder im Kopf stehen:

/** Das Vorgangs-Token steht im Pfad oder im Bearer-Kopf. Beides, weil
 *  MCP-Clients sich uneinig sind: manche nehmen nur eine URL entgegen. */
function lies(req: http.IncomingMessage, pfad: string): string | null {
  const imPfad = /^\/vorgang\/([a-f0-9]{32})\/?$/.exec(pfad);
  if (imPfad) return imPfad[1]!;
  const kopf = req.headers["authorization"];
  const bearer = typeof kopf === "string" ? /^Bearer\s+(\S+)$/.exec(kopf) : null;
  return bearer ? bearer[1]! : null;
}

Ein Token im Pfad ist keine schöne Sache. Es landet in Logs und in der Browser-Historie. Nötig ist es trotzdem, weil manche Clients nichts als eine URL entgegennehmen. Für ein wegwerfbares Vorgangs-Token darfst du diesen Handel eingehen; für ein Nutzer-Token nicht.

Die Lücke, die man übersieht

Angesehen wird das Token beim Aufbau der Sitzung. Danach reist nur noch eine Sitzungs-ID mit. Was hindert jemanden daran, eine am Vorgangs-Endpunkt eröffnete Sitzung anschließend am Betriebs-Endpunkt weiterzuverwenden?

Ohne die drei Zeilen hier: nichts.

  // Eine Sitzung merkt sich, wofür sie geöffnet wurde. Ohne das könnte eine
  // Sitzungs-ID vom Vorgangs-Endpunkt am Betriebs-Endpunkt weiterlaufen - das
  // Token wird ja nur beim Aufbau angesehen.
  if (bekannt && bekannt.bereich !== bereich) {
    res.statusCode = 401;
    res.end("Unauthorized");
    return;
  }

Deshalb trägt nimmAuf einen bereich, und deshalb schlägt sieh nach, ohne die Uhr zu stellen. Eine fremde Sitzung soll durch den bloßen Versuch weder erreichbar noch länger am Leben sein. Der Dienst dieses Repos hat denselben Vergleich an derselben Stelle stehen, mit derselben Begründung im Kommentar.

Der Beweis

Vier Verbindungsversuche gegen den laufenden Server:

A ohne Token: Error: Streamable HTTP error: Error POSTing to endpoint: Unauthorized
B Werkzeuge: 9
C Stats im Vorgang: { offen: 8 }
D falsches Token: Error: Streamable HTTP error: Error POSTing to endpoint: Unauthorized

A wird abgewiesen, B kommt mit dem Dienst-Token durch, C sieht seinen eigenen Bestand, D scheitert an einem Token aus zweiunddreißig f.

Was hier nicht steht

Rechte je Werkzeug. Der Helpdesk hat zwei Türen und dahinter jeweils alle neun Werkzeuge. Wer hereinkommt, darf auch löschen.

Für einen Übungsserver stimmt das so. Für einen Dienst an einem echten Bestand wäre es der nächste Schritt: lesende Werkzeuge für alle, schreibende für wenige. Umbauen musst du dafür wenig, weil die Stelle schon darauf wartet. registriereHelpdesk bekommt einen zweiten Parameter, und was nicht registriert wird, steht auch nicht in der Werkzeugliste. Ein Werkzeug, das ein Modell nicht sieht, kann es nicht aufrufen. Verlässlicher prüft keine Rechteprüfung.