fix(telegram): bind Mini App auth to dashboard launches

Require a short-lived, owner-bound launch ticket before minting a Control UI bootstrap token.

Co-authored-by: Val Alexander <68980965+BunsDev@users.noreply.github.com>
This commit is contained in:
Val Alexander
2026-08-05 09:54:34 -05:00
committed by GitHub
parent d7a444b08a
commit 4663244170
10 changed files with 296 additions and 34 deletions
+4 -3
View File
@@ -1,9 +1,10 @@
// Telegram Mini App registerFull entrypoint.
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import { registerTelegramMiniAppCommand } from "./src/miniapp/command.js";
import { createTelegramMiniAppLaunchTickets } from "./src/miniapp/launch-ticket.js";
import { registerTelegramMiniAppRoutes } from "./src/miniapp/routes.js";
export function registerTelegramMiniApp(api: OpenClawPluginApi): void {
registerTelegramMiniAppRoutes(api);
registerTelegramMiniAppCommand(api);
const launchTickets = createTelegramMiniAppLaunchTickets();
registerTelegramMiniAppRoutes(api, launchTickets);
registerTelegramMiniAppCommand(api, launchTickets);
}
@@ -4,9 +4,13 @@ import type {
PluginCommandContext,
} from "openclaw/plugin-sdk/plugin-entry";
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const resolveTelegramMiniAppUrls = vi.hoisted(() => vi.fn());
const launchTickets = {
issue: vi.fn(() => "launch-ticket"),
consume: vi.fn(() => false),
};
vi.mock("./url.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./url.js")>()),
@@ -19,10 +23,13 @@ function registerDashboardCommand(
api: Parameters<typeof registerTelegramMiniAppCommand>[0],
): OpenClawPluginCommandDefinition {
const commands: OpenClawPluginCommandDefinition[] = [];
registerTelegramMiniAppCommand({
...api,
registerCommand: (command) => commands.push(command),
});
registerTelegramMiniAppCommand(
{
...api,
registerCommand: (command) => commands.push(command),
},
launchTickets,
);
return expectDefined(commands[0], "registered Telegram dashboard command");
}
@@ -40,6 +47,10 @@ function commandContext(overrides: Partial<PluginCommandContext>): PluginCommand
}
describe("registerTelegramMiniAppCommand", () => {
beforeEach(() => {
launchTickets.issue.mockClear();
});
it("returns a DM-only message for group invocations", async () => {
const command = registerDashboardCommand(
createTestPluginApi({
@@ -65,6 +76,7 @@ describe("registerTelegramMiniAppCommand", () => {
),
).resolves.toEqual({ text: "open this in a DM with the bot" });
expect(resolveTelegramMiniAppUrls).not.toHaveBeenCalled();
expect(launchTickets.issue).not.toHaveBeenCalled();
});
it("returns a web app button for owner DM invocations", async () => {
@@ -104,11 +116,12 @@ describe("registerTelegramMiniAppCommand", () => {
{
label: "Open dashboard",
webApp: {
url: "https://host.tailnet.ts.net/__openclaw_tg_miniapp/?accountId=ops",
url: "https://host.tailnet.ts.net/__openclaw_tg_miniapp/?accountId=ops#launchTicket=launch-ticket",
},
},
],
},
]);
expect(launchTickets.issue).toHaveBeenCalledWith({ accountId: "ops", userId: "123" });
});
});
+10 -4
View File
@@ -1,4 +1,3 @@
// Telegram Mini App /dashboard command.
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type {
@@ -6,15 +5,20 @@ import type {
OpenClawPluginCommandDefinition,
PluginCommandContext,
} from "openclaw/plugin-sdk/plugin-entry";
import type { TelegramMiniAppLaunchTickets } from "./launch-ticket.js";
import { isTelegramMiniAppOwner } from "./owner.js";
import { resolveTelegramMiniAppUrls, TELEGRAM_MINIAPP_URL_ERROR } from "./url.js";
export function registerTelegramMiniAppCommand(api: OpenClawPluginApi): void {
api.registerCommand(createTelegramMiniAppDashboardCommand(api));
export function registerTelegramMiniAppCommand(
api: OpenClawPluginApi,
launchTickets: TelegramMiniAppLaunchTickets,
): void {
api.registerCommand(createTelegramMiniAppDashboardCommand(api, launchTickets));
}
function createTelegramMiniAppDashboardCommand(
api: OpenClawPluginApi,
launchTickets: TelegramMiniAppLaunchTickets,
): OpenClawPluginCommandDefinition {
return {
name: "dashboard",
@@ -39,6 +43,9 @@ function createTelegramMiniAppDashboardCommand(
return { text: TELEGRAM_MINIAPP_URL_ERROR };
}
pageUrl.searchParams.set("accountId", accountId);
pageUrl.hash = new URLSearchParams({
launchTicket: launchTickets.issue({ accountId, userId }),
}).toString();
return {
text: "Open OpenClaw dashboard.",
presentation: {
@@ -59,7 +66,6 @@ function currentConfig(api: OpenClawPluginApi): OpenClawConfig {
}
function isTelegramDirectCommand(ctx: PluginCommandContext): boolean {
// Parses OpenClaw's canonical telegram:<id> / telegram:group:<id> from/sessionKey encoding.
// DM-only because Telegram permits web_app inline buttons only in private chats.
const from = ctx.from?.trim() ?? "";
const sessionKey = ctx.sessionKey?.trim() ?? "";
@@ -0,0 +1,47 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createTelegramMiniAppLaunchTickets } from "./launch-ticket.js";
describe("Telegram Mini App launch tickets", () => {
afterEach(() => {
vi.useRealTimers();
});
it("binds one use to the issuing account and owner", () => {
const tickets = createTelegramMiniAppLaunchTickets();
const ticket = tickets.issue({ accountId: "ops", userId: "123" });
expect(tickets.consume({ ticket, accountId: "default", userId: "123" })).toBe(false);
expect(tickets.consume({ ticket, accountId: "ops", userId: "999" })).toBe(false);
expect(tickets.consume({ ticket, accountId: "ops", userId: "123" })).toBe(true);
expect(tickets.consume({ ticket, accountId: "ops", userId: "123" })).toBe(false);
});
it("expires after five minutes", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-19T12:00:00Z"));
const tickets = createTelegramMiniAppLaunchTickets();
const ticket = tickets.issue({ accountId: "default", userId: "123" });
vi.advanceTimersByTime(5 * 60_000);
expect(tickets.consume({ ticket, accountId: "default", userId: "123" })).toBe(false);
});
it("evicts the oldest ticket at the capacity limit", () => {
const tickets = createTelegramMiniAppLaunchTickets();
const oldest = tickets.issue({ accountId: "default", userId: "123" });
for (let index = 0; index < 1000; index += 1) {
tickets.issue({ accountId: "default", userId: "123" });
}
expect(tickets.consume({ ticket: oldest, accountId: "default", userId: "123" })).toBe(false);
});
it("invalidates tickets when the plugin lifecycle restarts", () => {
const firstLifecycle = createTelegramMiniAppLaunchTickets();
const ticket = firstLifecycle.issue({ accountId: "default", userId: "123" });
const nextLifecycle = createTelegramMiniAppLaunchTickets();
expect(nextLifecycle.consume({ ticket, accountId: "default", userId: "123" })).toBe(false);
});
});
@@ -0,0 +1,57 @@
import crypto from "node:crypto";
const LAUNCH_TICKET_TTL_MS = 5 * 60_000;
const LAUNCH_TICKET_LIMIT = 1000;
type LaunchTicket = {
accountId: string;
userId: string;
expiresAtMs: number;
};
export type TelegramMiniAppLaunchTickets = {
issue: (params: { accountId: string; userId: string }) => string;
consume: (params: { ticket: string; accountId: string; userId: string }) => boolean;
};
export function createTelegramMiniAppLaunchTickets(): TelegramMiniAppLaunchTickets {
const tickets = new Map<string, LaunchTicket>();
function prune(): void {
const now = Date.now();
for (const [ticket, launch] of tickets) {
if (launch.expiresAtMs <= now) {
tickets.delete(ticket);
}
}
}
return {
issue({ accountId, userId }) {
prune();
const ticket = crypto.randomBytes(32).toString("base64url");
tickets.set(ticket, {
accountId,
userId,
expiresAtMs: Date.now() + LAUNCH_TICKET_TTL_MS,
});
while (tickets.size > LAUNCH_TICKET_LIMIT) {
const oldest = tickets.keys().next().value;
if (!oldest) {
break;
}
tickets.delete(oldest);
}
return ticket;
},
consume({ ticket, accountId, userId }) {
prune();
const launch = tickets.get(ticket);
if (!launch || launch.accountId !== accountId || launch.userId !== userId) {
return false;
}
tickets.delete(ticket);
return true;
},
};
}
@@ -3,12 +3,13 @@
import { describe, expect, it, vi } from "vitest";
import { renderTelegramMiniAppPage, TELEGRAM_MINIAPP_EXPIRED_MESSAGE } from "./page.js";
describe("telegram miniapp auth timeout", () => {
describe("telegram miniapp page bootstrap", () => {
it("executes the generated page and expires a hung auth request", async () => {
let scheduledTimeout: { callback: () => void; delayMs: number; id: number } | undefined;
const clearTimeoutSpy = vi.fn();
const ready = vi.fn();
const fetchMock = vi.fn();
const location = { hash: "#launchTicket=launch-ticket", replace: vi.fn() };
const rendered = new DOMParser().parseFromString(
renderTelegramMiniAppPage({ accountId: "ops", scriptNonce: "test-nonce" }),
@@ -47,14 +48,20 @@ describe("telegram miniapp auth timeout", () => {
"setTimeout",
"clearTimeout",
"fetch",
"location",
bootstrap,
)(window, document, AbortController, scheduleTimeout, clearTimeoutSpy, fetchMock);
)(window, document, AbortController, scheduleTimeout, clearTimeoutSpy, fetchMock, location);
expect(ready).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
"auth",
expect.objectContaining({
method: "POST",
body: JSON.stringify({
initData: "signed-init-data",
accountId: "ops",
launchTicket: "launch-ticket",
}),
credentials: "same-origin",
signal: expect.any(AbortSignal),
}),
@@ -74,4 +81,54 @@ describe("telegram miniapp auth timeout", () => {
document.body.replaceChildren();
}
});
it("redirects with the authenticated handoff", async () => {
const ready = vi.fn();
const location = { hash: "#launchTicket=launch-ticket", replace: vi.fn() };
const rendered = new DOMParser().parseFromString(
renderTelegramMiniAppPage({ accountId: "ops", scriptNonce: "test-nonce" }),
"text/html",
);
const bootstrap = rendered.querySelector("script:not([src])")?.textContent;
if (!bootstrap) {
throw new Error("generated Mini App page is missing its bootstrap script");
}
document.body.innerHTML = rendered.body.innerHTML;
Object.defineProperty(window, "Telegram", {
configurable: true,
value: { WebApp: { initData: "signed-init-data", ready } },
});
const fetchMock = vi.fn(async () => ({
ok: true,
json: async () => ({
bootstrapToken: "bootstrap-token",
controlUiUrl: "https://host.tailnet.ts.net/openclaw",
gatewayUrl: "wss://host.tailnet.ts.net",
}),
}));
try {
// oxlint-disable-next-line typescript/no-implied-eval -- Execute the generated bootstrap itself so the test cannot drift into a reimplementation.
new Function(
"window",
"document",
"AbortController",
"setTimeout",
"clearTimeout",
"fetch",
"location",
bootstrap,
)(window, document, AbortController, setTimeout, clearTimeout, fetchMock, location);
await vi.waitFor(() => {
expect(location.replace).toHaveBeenCalledWith(
"https://host.tailnet.ts.net/openclaw#gatewayUrl=wss%3A%2F%2Fhost.tailnet.ts.net&bootstrapToken=bootstrap-token",
);
});
expect(ready).toHaveBeenCalledTimes(1);
} finally {
Reflect.deleteProperty(window, "Telegram");
document.body.replaceChildren();
}
});
});
@@ -6,6 +6,7 @@ describe("renderTelegramMiniAppPage", () => {
const html = renderTelegramMiniAppPage({ accountId: "ops", scriptNonce: "nonce" });
expect(html).toContain('const accountId = "ops";');
expect(html).toContain('new URLSearchParams(location.hash.slice(1)).get("launchTicket")');
expect(html).toContain("new URL(payload.controlUiUrl)");
expect(html).not.toContain("const controlUiUrl =");
});
+3 -3
View File
@@ -1,4 +1,3 @@
// Telegram Mini App bootstrap page.
import { escapeHtml } from "openclaw/plugin-sdk/text-utility-runtime";
export const TELEGRAM_MINIAPP_EXPIRED_MESSAGE =
@@ -35,13 +34,14 @@ export function renderTelegramMiniAppPage(params: {
</main>
<script nonce="${nonce}">
const accountId = ${accountId};
const launchTicket = new URLSearchParams(location.hash.slice(1)).get("launchTicket") || "";
const status = document.getElementById("status");
const showExpired = () => {
status.textContent = ${JSON.stringify(TELEGRAM_MINIAPP_EXPIRED_MESSAGE)};
};
const webApp = window.Telegram && window.Telegram.WebApp;
const initData = webApp && typeof webApp.initData === "string" ? webApp.initData : "";
if (!initData) {
if (!initData || !launchTicket) {
showExpired();
} else {
webApp.ready();
@@ -54,7 +54,7 @@ export function renderTelegramMiniAppPage(params: {
fetch("auth", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ initData, accountId }),
body: JSON.stringify({ initData, accountId, launchTicket }),
credentials: "same-origin",
signal: authController.signal
}).then(async (response) => {
+75 -11
View File
@@ -5,6 +5,10 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
createTelegramMiniAppLaunchTickets,
type TelegramMiniAppLaunchTickets,
} from "./launch-ticket.js";
type OpenClawPluginHttpRouteParams = Parameters<OpenClawPluginApi["registerHttpRoute"]>[0];
@@ -33,6 +37,7 @@ const { registerTelegramMiniAppRoutes } = await import("./routes.js");
const BOT_TOKEN = "fixture";
let signedNonceSequence = 0;
let launchTickets: TelegramMiniAppLaunchTickets;
class MockResponse {
statusCode = 200;
@@ -59,7 +64,7 @@ function createRoute(cfg: OpenClawConfig): OpenClawPluginHttpRouteParams {
route = params;
},
});
registerTelegramMiniAppRoutes(api);
registerTelegramMiniAppRoutes(api, launchTickets);
if (!route) {
throw new Error("expected miniapp route registration");
}
@@ -111,8 +116,19 @@ function signedInitData(userId: string, nonce: string): string {
return params.toString();
}
function authBody(params: { userId?: string; nonce: string; accountId?: string }): string {
const userId = params.userId ?? "123456";
const accountId = params.accountId ?? "default";
return JSON.stringify({
initData: signedInitData(userId, params.nonce),
accountId,
launchTicket: launchTickets.issue({ accountId, userId }),
});
}
describe("registerTelegramMiniAppRoutes", () => {
beforeEach(() => {
launchTickets = createTelegramMiniAppLaunchTickets();
issueDeviceBootstrapToken.mockClear();
resolveTelegramMiniAppUrls.mockClear();
});
@@ -138,10 +154,7 @@ describe("registerTelegramMiniAppRoutes", () => {
method: "POST",
url: "/__openclaw_tg_miniapp/auth",
contentType: "application/json; charset=utf-8",
body: JSON.stringify({
initData: signedInitData("123456", "success"),
accountId: "default",
}),
body: authBody({ nonce: "success" }),
});
expect(res.statusCode).toBe(200);
@@ -168,12 +181,13 @@ describe("registerTelegramMiniAppRoutes", () => {
it("rejects replayed init-data without minting again", async () => {
const route = createRoute(config());
const initData = signedInitData("123456", "replay");
const launchTicket = launchTickets.issue({ accountId: "default", userId: "123456" });
await callRoute({
route,
method: "POST",
url: "/__openclaw_tg_miniapp/auth",
contentType: "application/json",
body: JSON.stringify({ initData }),
body: JSON.stringify({ initData, launchTicket }),
ip: "203.0.113.20",
});
const replay = await callRoute({
@@ -181,7 +195,7 @@ describe("registerTelegramMiniAppRoutes", () => {
method: "POST",
url: "/__openclaw_tg_miniapp/auth",
contentType: "application/json",
body: JSON.stringify({ initData }),
body: JSON.stringify({ initData, launchTicket }),
ip: "203.0.113.20",
});
@@ -193,6 +207,7 @@ describe("registerTelegramMiniAppRoutes", () => {
it("reserves validated init-data before minting", async () => {
const route = createRoute(config());
const initData = signedInitData("123456", "concurrent");
const launchTicket = launchTickets.issue({ accountId: "default", userId: "123456" });
const responses = await Promise.all([
callRoute({
@@ -200,7 +215,7 @@ describe("registerTelegramMiniAppRoutes", () => {
method: "POST",
url: "/__openclaw_tg_miniapp/auth",
contentType: "application/json",
body: JSON.stringify({ initData }),
body: JSON.stringify({ initData, launchTicket }),
ip: "203.0.113.21",
}),
callRoute({
@@ -208,7 +223,7 @@ describe("registerTelegramMiniAppRoutes", () => {
method: "POST",
url: "/__openclaw_tg_miniapp/auth",
contentType: "application/json",
body: JSON.stringify({ initData }),
body: JSON.stringify({ initData, launchTicket }),
ip: "203.0.113.22",
}),
]);
@@ -219,17 +234,66 @@ describe("registerTelegramMiniAppRoutes", () => {
it("rejects non-owner Mini App auth requests", async () => {
const route = createRoute(config(["999999"]));
const launchTicket = launchTickets.issue({ accountId: "default", userId: "123456" });
const res = await callRoute({
route,
method: "POST",
url: "/__openclaw_tg_miniapp/auth",
contentType: "application/json",
body: JSON.stringify({ initData: signedInitData("123456", "non-owner") }),
body: JSON.stringify({
initData: signedInitData("123456", "non-owner"),
launchTicket,
}),
ip: "203.0.113.30",
});
expect(res.statusCode).toBe(403);
expect(res.body).toBe("Restricted to the bot owner.");
expect(issueDeviceBootstrapToken).not.toHaveBeenCalled();
expect(
launchTickets.consume({ ticket: launchTicket, accountId: "default", userId: "123456" }),
).toBe(true);
});
it("rejects owner init-data without an issued launch ticket", async () => {
const route = createRoute(config());
const res = await callRoute({
route,
method: "POST",
url: "/__openclaw_tg_miniapp/auth",
contentType: "application/json",
body: JSON.stringify({
initData: signedInitData("123456", "missing-ticket"),
launchTicket: "not-issued",
}),
ip: "203.0.113.31",
});
expect(res.statusCode).toBe(401);
expect(res.body).toBe("This link expired. Reopen the dashboard from your bot chat.");
expect(issueDeviceBootstrapToken).not.toHaveBeenCalled();
});
it("does not consume a launch ticket when URL resolution fails", async () => {
resolveTelegramMiniAppUrls.mockRejectedValueOnce(new Error("not published"));
const route = createRoute(config());
const initData = signedInitData("123456", "url-retry");
const launchTicket = launchTickets.issue({ accountId: "default", userId: "123456" });
const request = {
route,
method: "POST",
url: "/__openclaw_tg_miniapp/auth",
contentType: "application/json",
body: JSON.stringify({ initData, launchTicket }),
ip: "203.0.113.32",
};
const unavailable = await callRoute(request);
const retry = await callRoute(request);
expect(unavailable.statusCode).toBe(503);
expect(retry.statusCode).toBe(200);
expect(issueDeviceBootstrapToken).toHaveBeenCalledTimes(1);
});
it("rate-limits repeated auth requests by IP", async () => {
@@ -241,7 +305,7 @@ describe("registerTelegramMiniAppRoutes", () => {
method: "POST",
url: "/__openclaw_tg_miniapp/auth",
contentType: "application/json",
body: JSON.stringify({ initData: signedInitData("123456", `rate-${i}`) }),
body: authBody({ nonce: `rate-${i}` }),
ip: "203.0.113.40",
});
}
+21 -5
View File
@@ -1,4 +1,3 @@
// Telegram Mini App HTTP routes.
import crypto from "node:crypto";
import type { IncomingMessage, ServerResponse } from "node:http";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
@@ -10,6 +9,7 @@ import {
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import { resolveTelegramAccount } from "../accounts.js";
import { validateTelegramMiniAppInitData } from "./init-data.js";
import type { TelegramMiniAppLaunchTickets } from "./launch-ticket.js";
import { isTelegramMiniAppOwner } from "./owner.js";
import { renderTelegramMiniAppPage, TELEGRAM_MINIAPP_EXPIRED_MESSAGE } from "./page.js";
import {
@@ -26,7 +26,10 @@ const RATE_LIMIT_MAX = 10;
const replayCache = new Map<string, number>();
const rateLimit = new Map<string, { count: number; resetAtMs: number }>();
export function registerTelegramMiniAppRoutes(api: OpenClawPluginApi): void {
export function registerTelegramMiniAppRoutes(
api: OpenClawPluginApi,
launchTickets: TelegramMiniAppLaunchTickets,
): void {
api.registerHttpRoute({
path: TELEGRAM_MINIAPP_PATH_PREFIX,
match: "prefix",
@@ -38,7 +41,7 @@ export function registerTelegramMiniAppRoutes(api: OpenClawPluginApi): void {
return true;
}
if (url.pathname === AUTH_PATH) {
await handleAuth(api, req, res);
await handleAuth(api, launchTickets, req, res);
return true;
}
sendText(res, 404, "Not found");
@@ -67,6 +70,7 @@ async function handlePage(req: IncomingMessage, res: ServerResponse, url: URL):
async function handleAuth(
api: OpenClawPluginApi,
launchTickets: TelegramMiniAppLaunchTickets,
req: IncomingMessage,
res: ServerResponse,
): Promise<void> {
@@ -117,6 +121,16 @@ async function handleAuth(
sendText(res, 503, TELEGRAM_MINIAPP_URL_ERROR);
return;
}
if (
!launchTickets.consume({
ticket: body.launchTicket,
accountId,
userId: validated.userId,
})
) {
sendText(res, 401, TELEGRAM_MINIAPP_EXPIRED_MESSAGE);
return;
}
if (!rememberReplay(validated.hash, validated.authDateMs + 300_000)) {
sendText(res, 401, TELEGRAM_MINIAPP_EXPIRED_MESSAGE);
return;
@@ -141,7 +155,7 @@ function currentConfig(api: OpenClawPluginApi): OpenClawConfig {
async function readJsonBody(
req: IncomingMessage,
): Promise<{ initData: string; accountId?: string } | "too-large" | null> {
): Promise<{ initData: string; launchTicket: string; accountId?: string } | "too-large" | null> {
const chunks: Buffer[] = [];
let total = 0;
for await (const chunk of req) {
@@ -155,13 +169,15 @@ async function readJsonBody(
try {
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8")) as {
initData?: unknown;
launchTicket?: unknown;
accountId?: unknown;
};
if (typeof parsed.initData !== "string") {
if (typeof parsed.initData !== "string" || typeof parsed.launchTicket !== "string") {
return null;
}
return {
initData: parsed.initData,
launchTicket: parsed.launchTicket,
...(typeof parsed.accountId === "string" ? { accountId: parsed.accountId } : {}),
};
} catch {