mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 =");
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user