fix: switch telegram connect to short token and single-window redirect

This commit is contained in:
Ruslan Bakiev
2026-02-22 09:20:22 +07:00
parent 25f7f8dfb4
commit 5679f22f7f
4 changed files with 44 additions and 88 deletions

View File

@@ -1,38 +1,13 @@
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import { randomBytes } from "node:crypto";
export type LinkTokenPayloadV1 = {
v: 1;
teamId: string;
userId: string;
nonce: string;
exp: number;
};
const TOKEN_TTL_SEC = Number(process.env.TELEGRAM_LINK_TOKEN_TTL_SEC || 10 * 60);
function base64UrlEncode(input: Buffer | string) {
return Buffer.from(input)
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
}
function base64UrlDecode(input: string) {
const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
const pad = normalized.length % 4;
const padded = pad === 0 ? normalized : normalized + "=".repeat(4 - pad);
return Buffer.from(padded, "base64");
}
export function requireLinkSecret() {
const secret = String(process.env.TELEGRAM_LINK_SECRET || process.env.TELEGRAM_BOT_TOKEN || "").trim();
if (!secret) {
throw createError({ statusCode: 500, statusMessage: "TELEGRAM_LINK_SECRET or TELEGRAM_BOT_TOKEN is required" });
}
return secret;
}
export function requireBotUsername() {
const botUsername = String(process.env.TELEGRAM_BOT_USERNAME || "").trim().replace(/^@/, "");
if (!botUsername) {
@@ -41,52 +16,26 @@ export function requireBotUsername() {
return botUsername;
}
function sign(input: string, secret: string) {
return createHmac("sha256", secret).update(input).digest();
}
export function issueLinkToken(input: { teamId: string; userId: string }) {
const secret = requireLinkSecret();
void input;
// Telegram deep-link `start` parameter is limited, keep token very short.
const token = randomBytes(12).toString("hex");
const payload: LinkTokenPayloadV1 = {
v: 1,
teamId: input.teamId,
userId: input.userId,
nonce: randomBytes(12).toString("hex"),
nonce: token,
exp: Math.floor(Date.now() / 1000) + Math.max(60, TOKEN_TTL_SEC),
};
const payloadRaw = JSON.stringify(payload);
const payloadEncoded = base64UrlEncode(payloadRaw);
const sig = base64UrlEncode(sign(payloadEncoded, secret));
const token = `${payloadEncoded}.${sig}`;
return { token, payload };
}
export function verifyLinkToken(token: string): LinkTokenPayloadV1 | null {
const raw = String(token || "").trim();
if (!raw) return null;
const dotIdx = raw.indexOf(".");
if (dotIdx <= 0 || dotIdx >= raw.length - 1) return null;
const payloadEncoded = raw.slice(0, dotIdx);
const sigEncoded = raw.slice(dotIdx + 1);
try {
const secret = requireLinkSecret();
const expected = sign(payloadEncoded, secret);
const actual = base64UrlDecode(sigEncoded);
if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) {
return null;
}
const payload = JSON.parse(base64UrlDecode(payloadEncoded).toString("utf8")) as LinkTokenPayloadV1;
if (!payload || payload.v !== 1) return null;
if (!payload.teamId || !payload.userId || !payload.nonce || !payload.exp) return null;
if (Math.floor(Date.now() / 1000) > payload.exp) return null;
return payload;
} catch {
return null;
}
if (!/^[a-f0-9]{24}$/.test(raw)) return null;
return {
v: 1,
nonce: raw,
exp: 0,
};
}
export function extractLinkTokenFromStartText(text: string) {