From 59b539eb84b04990a1e03e0b061541e74ea03bd7 Mon Sep 17 00:00:00 2001 From: Makar Dzhehur Date: Mon, 29 Jun 2026 08:41:45 +0300 Subject: [PATCH] fix(bot): re-exchange tokens on 401 instead of rotating refresh --- apps/bot/src/services/apiClient.ts | 89 ++++++++++++++++------------- apps/bot/src/services/tokenStore.ts | 13 +++-- 2 files changed, 57 insertions(+), 45 deletions(-) diff --git a/apps/bot/src/services/apiClient.ts b/apps/bot/src/services/apiClient.ts index 67b1788..084c3bf 100644 --- a/apps/bot/src/services/apiClient.ts +++ b/apps/bot/src/services/apiClient.ts @@ -9,15 +9,54 @@ import { const { API_URL } = config; +const FETCH_TIMEOUT_MS = 20_000; + +async function timedFetch(url: string, init: RequestInit): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + try { + return await fetch(url, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timer); + } +} + +// Bot is a trusted service client: instead of rotating refresh tokens (which +// trips the API's reuse-detection when a response is lost to a cold start), it +// re-exchanges telegramId + name for a fresh pair on demand. +async function exchangeTokens( + telegramId: number, + name: string, +): Promise { + const res = await timedFetch(`${API_URL}/auth/telegram/exchange`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + source: "bot", + telegramId: String(telegramId), + name, + }), + }); + + if (!res.ok) { + throw new Error(`Telegram auth failed: ${res.status}`); + } + + const data = (await res.json()) as TokenPair; + await setTokens(telegramId, data, name); + return data; +} + async function apiFetch( method: string, path: string, telegramId: number, body?: unknown, + retried = false, ): Promise { const tokens = await getTokens(telegramId); - const res = await fetch(`${API_URL}${path}`, { + const res = await timedFetch(`${API_URL}${path}`, { method, headers: { "Content-Type": "application/json", @@ -26,56 +65,24 @@ async function apiFetch( ...(body !== undefined ? { body: JSON.stringify(body) } : {}), }); - if (res.status === 401 && tokens) { - const refreshed = await tryRefresh(telegramId, tokens.refreshToken); - if (refreshed) { - return apiFetch(method, path, telegramId, body); + if (res.status === 401 && tokens && !retried) { + try { + await exchangeTokens(telegramId, tokens.name); + } catch { + await deleteTokens(telegramId); + return res; } + return apiFetch(method, path, telegramId, body, true); } return res; } -async function tryRefresh( - telegramId: number, - refreshToken: string, -): Promise { - const res = await fetch(`${API_URL}/auth/telegram/refresh`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ refreshToken }), - }); - - if (!res.ok) { - await deleteTokens(telegramId); - return false; - } - - const data = (await res.json()) as TokenPair; - await setTokens(telegramId, data); - return true; -} - export async function authenticateUser( telegramId: number, name: string, ): Promise { - const res = await fetch(`${API_URL}/auth/telegram/exchange`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - source: "bot", - telegramId: String(telegramId), - name, - }), - }); - - if (!res.ok) { - throw new Error(`Telegram auth failed: ${res.status}`); - } - - const data = (await res.json()) as TokenPair; - await setTokens(telegramId, data); + await exchangeTokens(telegramId, name); } export async function isAuthenticated(telegramId: number): Promise { diff --git a/apps/bot/src/services/tokenStore.ts b/apps/bot/src/services/tokenStore.ts index c2e7fa8..7d8acda 100644 --- a/apps/bot/src/services/tokenStore.ts +++ b/apps/bot/src/services/tokenStore.ts @@ -1,23 +1,28 @@ import { redis } from "../lib/redis.js"; export type TokenPair = { accessToken: string; refreshToken: string }; +export type StoredSession = TokenPair & { name: string }; const key = (telegramId: number) => `bot:session:${telegramId}`; -// 30 days TTL – token pair is refreshed on each use +// 30 days TTL – tokens are re-exchanged on expiry, name lets us mint a fresh pair const TTL_SECONDS = 60 * 60 * 24 * 30; -export async function getTokens(telegramId: number): Promise { +export async function getTokens( + telegramId: number, +): Promise { const raw = await redis.get(key(telegramId)); if (!raw) return null; - return JSON.parse(raw) as TokenPair; + return JSON.parse(raw) as StoredSession; } export async function setTokens( telegramId: number, tokens: TokenPair, + name: string, ): Promise { - await redis.set(key(telegramId), JSON.stringify(tokens), "EX", TTL_SECONDS); + const session: StoredSession = { ...tokens, name }; + await redis.set(key(telegramId), JSON.stringify(session), "EX", TTL_SECONDS); } export async function deleteTokens(telegramId: number): Promise {