import type { AccountsState, AuthStatus, CurrentUserSummary, LoginCredentials, TwoFactorMethod, TwoFactorPayload, } from "../../shared/types/auth"; import { clearLoginClient, createLoginClient, dropClient, getActiveClient } from "./client"; import { clearPending, listAccounts, promotePending, removeAccount, setActive, } from "../accounts/store"; import { toCurrentUserSummary } from "./mappers"; import { userCache } from "./userService"; import { clearSessionCookies, syncSessionCookies } from "./cookies"; import { logger } from "../debug/logger"; import { seedActiveAccount } from "../store/social"; import { entityStore } from "../store/entityStore"; import { repos } from "../store/repository/manager"; interface VRChatLike { login: (opts: { username: string; password: string; twoFactorCode?: () => Promise | string; throwOnError?: boolean; }) => Promise; getCurrentUser: (opts?: { throwOnError?: boolean }) => Promise<{ data?: unknown }>; logout?: () => Promise; } type RawUser = Parameters[0]; function isRealUser(data: unknown): data is RawUser { return ( !!data && typeof data === "object" && "id" in data && "displayName" in data && !("requiresTwoFactorAuth" in data) ); } async function summaryFrom(vrc: VRChatLike): Promise { const { data } = await vrc.getCurrentUser({ throwOnError: true }); if (!isRealUser(data)) throw new Error("not authenticated"); return toCurrentUserSummary(data); } let pendingTwoFactor: { resolveCode: (code: string) => void; methods: TwoFactorMethod[]; loginDone: Promise; } | null = null; async function finalizeLogin(vrc: VRChatLike): Promise { const user = await summaryFrom(vrc); promotePending({ id: user.id, displayName: user.displayName, userIcon: user.userIcon || user.currentAvatarThumbnailImageUrl, }); clearLoginClient(); dropClient(user.id); userCache.clear(); await syncSessionCookies(vrc); logger.info("auth", `signed in as ${user.displayName}`); void seedActiveAccount(true); return { state: "authenticated", user }; } export async function checkStatus(): Promise { const vrc = getActiveClient() as unknown as VRChatLike | null; if (!vrc) return { state: "unauthenticated" }; try { const user = await summaryFrom(vrc); await syncSessionCookies(vrc); void seedActiveAccount(); return { state: "authenticated", user }; } catch { return { state: "unauthenticated" }; } } export async function login(creds: LoginCredentials): Promise { clearPending(); const vrc = createLoginClient() as unknown as VRChatLike; let resolveCode!: (code: string) => void; let rejectCode!: (err: unknown) => void; const codePromise = new Promise((res, rej) => { resolveCode = res; rejectCode = rej; }); let signalAwaiting!: (methods: TwoFactorMethod[]) => void; const awaiting = new Promise((res) => { signalAwaiting = res; }); const loginDone: Promise = vrc .login({ username: creds.username, password: creds.password, throwOnError: true, twoFactorCode: async () => { signalAwaiting(["totp", "emailOtp"]); return codePromise; }, }) .then(() => finalizeLogin(vrc)) .catch((err) => { rejectCode(err); throw err; }); const winner = await Promise.race([ loginDone.then((status) => ({ kind: "done" as const, status })), awaiting.then((methods) => ({ kind: "await" as const, methods })), ]); if (winner.kind === "done") { pendingTwoFactor = null; return winner.status; } pendingTwoFactor = { resolveCode, methods: winner.methods, loginDone }; return { state: "awaiting2fa", methods: winner.methods }; } export async function verify2fa(payload: TwoFactorPayload): Promise { if (!pendingTwoFactor) return { state: "unauthenticated" }; const { resolveCode, loginDone } = pendingTwoFactor; resolveCode(payload.code); try { const status = await loginDone; pendingTwoFactor = null; return status; } catch { pendingTwoFactor = null; return { state: "unauthenticated" }; } } export function listAccountsState(): AccountsState { return listAccounts(); } export async function switchAccount(id: string): Promise { setActive(id); userCache.clear(); logger.info("auth", `switched account → ${id}`); return checkStatus(); } export function removeAccountAction(id: string): AccountsState { dropClient(id); userCache.clear(); repos.destroy(id); return removeAccount(id); } export async function logout(): Promise { const id = listAccounts().activeId; const vrc = getActiveClient() as unknown as VRChatLike | null; try { await vrc?.logout?.(); } catch {} pendingTwoFactor = null; entityStore.clear(); await clearSessionCookies(); if (id) removeAccountAction(id); const next = getActiveClient(); if (next) { await syncSessionCookies(next); void seedActiveAccount(true); } }