fix: extra run auth callback in feeds (like tiktok)

This commit is contained in:
Toil 2026-10-15 22:42:04 +03:00
parent 868de1e152
commit 0fb23f98b0
No known key found for this signature in database
GPG key ID: 57248E2819D32070
2 changed files with 147 additions and 13 deletions

View file

@ -167,19 +167,7 @@ export async function updateAccountInfo() {
}
}
export async function updateAccountByCallbackData(
data: AuthMessageData,
): Promise<void> {
const { state, code } = data;
const storedState = sessionStorage.getItem("votYandexState");
if (!state || state !== storedState) {
throw new Error("[VOT] Invalid state value");
}
if (!code) {
throw new Error("[VOT] Invalid auth code response");
}
async function exchangeCodeForAccount(code: string): Promise<void> {
let token: TokenInfo;
try {
token = await getOAuthTokenByCode(code);
@ -206,6 +194,41 @@ export async function updateAccountByCallbackData(
await updateAccountInfo();
}
// Every VideoHandler listens for the same callback message, and Yandex auth
// codes are single-use, so all of them must share one exchange. The record is
// kept after settling because a repeated code can never succeed again.
let pendingCodeExchange: {
code: string;
state: string;
promise: Promise<void>;
} | null = null;
export async function updateAccountByCallbackData(
data: AuthMessageData,
): Promise<void> {
const { state, code } = data;
if (
code &&
pendingCodeExchange?.code === code &&
pendingCodeExchange.state === state
) {
return pendingCodeExchange.promise;
}
const storedState = sessionStorage.getItem("votYandexState");
if (!state || state !== storedState) {
throw new Error("[VOT] Invalid state value");
}
if (!code) {
throw new Error("[VOT] Invalid auth code response");
}
const promise = exchangeCodeForAccount(code);
pendingCodeExchange = { code, state, promise };
return promise;
}
export const createAuthLink = async (): Promise<string> => {
const state = getUUID();
const codeVerifier = createCodeVerifier();

View file

@ -0,0 +1,111 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
(globalThis as unknown as { DEBUG_MODE: boolean }).DEBUG_MODE = false;
const memoryStorage = () => {
const items = new Map<string, string>();
return {
getItem: (key: string) => items.get(key) ?? null,
setItem: (key: string, value: string) => void items.set(key, value),
removeItem: (key: string) => void items.delete(key),
};
};
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: memoryStorage(),
});
Object.defineProperty(globalThis, "sessionStorage", {
configurable: true,
value: memoryStorage(),
});
const { updateAccountByCallbackData } = await import(
"../src/modules/auth/yandex"
);
const originalFetch = globalThis.fetch;
let tokenRequests: string[] = [];
const jsonResponse = (body: unknown) =>
new Response(JSON.stringify(body), {
headers: { "Content-Type": "application/json" },
});
function stubFetch(handleToken: (code: string) => Response) {
globalThis.fetch = (async (_url: unknown, init?: RequestInit) => {
if (init?.method === "POST") {
const code = new URLSearchParams(String(init.body)).get("code") ?? "";
tokenRequests.push(code);
return handleToken(code);
}
return jsonResponse({ login: "user", default_avatar_id: "avatar" });
}) as typeof fetch;
}
const grantToken = () =>
jsonResponse({ access_token: "token", expires_in: 3600 });
const rejectGrant = () =>
new Response(
JSON.stringify({
error: "invalid_grant",
error_description: "Code has expired",
}),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
describe("updateAccountByCallbackData", () => {
beforeEach(() => {
tokenRequests = [];
sessionStorage.setItem("votYandexState", "state");
sessionStorage.setItem("votYandexCodeVerifier", "verifier");
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
test("shares one token exchange between concurrent and later calls with the same code", async () => {
stubFetch(grantToken);
const data = { state: "state", code: "code-1" };
await Promise.all([
updateAccountByCallbackData(data),
updateAccountByCallbackData(data),
updateAccountByCallbackData(data),
]);
await updateAccountByCallbackData(data);
expect(tokenRequests).toEqual(["code-1"]);
});
test("shares a rejection but still exchanges a different code afterwards", async () => {
stubFetch((code) => (code === "code-2" ? rejectGrant() : grantToken()));
sessionStorage.setItem("votYandexState", "state-2");
const failing = { state: "state-2", code: "code-2" };
const results = await Promise.allSettled([
updateAccountByCallbackData(failing),
updateAccountByCallbackData(failing),
]);
expect(results.map((result) => result.status)).toEqual([
"rejected",
"rejected",
]);
expect(tokenRequests).toEqual(["code-2"]);
sessionStorage.setItem("votYandexState", "state-3");
await updateAccountByCallbackData({ state: "state-3", code: "code-3" });
expect(tokenRequests).toEqual(["code-2", "code-3"]);
});
test("still rejects an invalid state without sending a request", async () => {
stubFetch(grantToken);
await expect(
updateAccountByCallbackData({ state: "wrong", code: "code-4" }),
).rejects.toThrow("[VOT] Invalid state value");
expect(tokenRequests).toEqual([]);
});
});