diff --git a/.github/workflows/linejs-auto-update.yml b/.github/workflows/linejs-auto-update.yml index 651ed9b..7ead557 100644 --- a/.github/workflows/linejs-auto-update.yml +++ b/.github/workflows/linejs-auto-update.yml @@ -1,13 +1,9 @@ name: LINEJS auto update on: + # Run on every PR. Path filters can leave required checks permanently pending + # when a PR changes files outside the filter, blocking merge without a job. pull_request: - paths: - - 'package.json' - - 'bun.lock' - - 'engine/linejs/**' - - 'scripts/*linejs*' - - '.github/workflows/linejs-auto-update.yml' workflow_dispatch: schedule: - cron: '17 */6 * * *' diff --git a/engine/auth/auth-finalization.test.ts b/engine/auth/auth-finalization.test.ts new file mode 100644 index 0000000..fd10280 --- /dev/null +++ b/engine/auth/auth-finalization.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, test } from "bun:test"; +import { createAuthFinalizationBarrier } from "./auth-finalization"; + +describe("auth finalization barrier", () => { + test("waits for an active finalization to settle", async () => { + const barrier = createAuthFinalizationBarrier(); + let settle!: () => void; + const operation = barrier.track(() => new Promise((resolve) => { + settle = resolve; + })); + let waited = false; + const waiting = barrier.wait().then(() => { waited = true; }); + + await Promise.resolve(); + expect(waited).toBe(false); + settle(); + await Promise.all([operation, waiting]); + expect(waited).toBe(true); + }); + + test("does not let a rejected finalization block logout cleanup", async () => { + const barrier = createAuthFinalizationBarrier(); + const operation = barrier.track(async () => { + throw new Error("finalization failed"); + }); + await expect(operation).rejects.toThrow("finalization failed"); + await expect(barrier.wait()).resolves.toBeUndefined(); + }); + + test("does not wait after the finalization has settled", async () => { + const barrier = createAuthFinalizationBarrier(); + await barrier.track(async () => undefined); + await expect(barrier.wait()).resolves.toBeUndefined(); + }); + + test("waits for every concurrently tracked finalization", async () => { + const barrier = createAuthFinalizationBarrier(); + let settleFirst!: () => void; + let settleSecond!: () => void; + const first = barrier.track(() => new Promise((resolve) => { + settleFirst = resolve; + })); + const second = barrier.track(() => new Promise((resolve) => { + settleSecond = resolve; + })); + let waited = false; + const waiting = barrier.wait().then(() => { waited = true; }); + + await Promise.resolve(); + settleFirst(); + await first; + await Promise.resolve(); + expect(waited).toBe(false); + + settleSecond(); + await Promise.all([second, waiting]); + expect(waited).toBe(true); + }); + + test("waits for a finalization registered while waiting", async () => { + const barrier = createAuthFinalizationBarrier(); + let settleFirst!: () => void; + let settleSecond!: () => void; + const first = barrier.track(() => new Promise((resolve) => { + settleFirst = resolve; + })); + let waited = false; + const waiting = barrier.wait().then(() => { waited = true; }); + + await Promise.resolve(); + const second = barrier.track(() => new Promise((resolve) => { + settleSecond = resolve; + })); + await Promise.resolve(); + settleFirst(); + await first; + await Promise.resolve(); + expect(waited).toBe(false); + + settleSecond(); + await Promise.all([second, waiting]); + expect(waited).toBe(true); + }); + + test("logout clears auth-storage writes that finish after logout starts", async () => { + const barrier = createAuthFinalizationBarrier(); + const durable = new Map(); + let generation = 0; + let finishWrite!: () => void; + + const finalization = barrier.track(async () => { + if (generation !== 0) return; + await new Promise((resolve) => { finishWrite = resolve; }); + // The write was already inside the barrier when Logout invalidated it. + durable.set("refreshToken", "late-token"); + }); + + // Ensure the finalizer has entered its protected write before Logout. + while (!finishWrite) await Promise.resolve(); + generation += 1; + let logoutFinished = false; + const logout = barrier.wait().then(() => { + durable.clear(); + logoutFinished = true; + }); + + expect(logoutFinished).toBe(false); + finishWrite(); + await Promise.all([finalization, logout]); + + expect(logoutFinished).toBe(true); + expect(durable.size).toBe(0); + }); + + test("does not commit auth storage when logout invalidates before finalization", async () => { + const barrier = createAuthFinalizationBarrier(); + const durable = new Map(); + let generation = 1; + + const result = await barrier.track(async () => { + if (generation !== 1) return false; + durable.set("refreshToken", "stale-token"); + return true; + }); + + expect(result).toBe(true); + generation += 1; + await barrier.wait(); + durable.clear(); + expect(durable.size).toBe(0); + }); +}); diff --git a/engine/auth/auth-finalization.ts b/engine/auth/auth-finalization.ts new file mode 100644 index 0000000..73b2e19 --- /dev/null +++ b/engine/auth/auth-finalization.ts @@ -0,0 +1,28 @@ +/** + * Tracks asynchronous session-finalization phases so logout can invalidate + * attempts immediately, then wait for every in-flight finalizer before + * clearing shared account and credential state. + */ +export function createAuthFinalizationBarrier() { + const pending = new Set>(); + + return { + track(operation: () => Promise): Promise { + const task = Promise.resolve().then(operation); + // Track a non-rejecting promise so a failed finalizer cannot leave the + // barrier permanently blocked or create an unhandled rejection. + const settled = task.then(() => undefined, () => undefined); + pending.add(settled); + void settled.then(() => pending.delete(settled)); + return task; + }, + + async wait(): Promise { + // Loop because another finalizer may be registered while we are waiting + // for the current batch to settle. + while (pending.size > 0) { + await Promise.all([...pending]); + } + }, + }; +} diff --git a/engine/auth/login-guard.test.ts b/engine/auth/login-guard.test.ts new file mode 100644 index 0000000..bff945b --- /dev/null +++ b/engine/auth/login-guard.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test"; +import { createLoginAttemptGuard } from "./login-guard"; + +describe("login attempt guard", () => { + test("rejects overlapping authentication attempts", () => { + const guard = createLoginAttemptGuard(); + const release = guard.tryAcquire(); + + expect(release).toBeTypeOf("function"); + expect(guard.isLocked()).toBe(true); + expect(guard.tryAcquire()).toBeNull(); + + release?.(); + expect(guard.isLocked()).toBe(false); + expect(guard.tryAcquire()).toBeTypeOf("function"); + }); + + test("keeps a stale attempt locked until its owner settles", () => { + const guard = createLoginAttemptGuard(); + let settle!: () => void; + const pending = new Promise((resolve) => { + settle = resolve; + }); + const release = guard.tryAcquire(); + + expect(guard.tryAcquire()).toBeNull(); + settle(); + expect(guard.isLocked()).toBe(true); + + return pending.then(() => { + release?.(); + expect(guard.isLocked()).toBe(false); + }); + }); + + test("release is idempotent", () => { + const guard = createLoginAttemptGuard(); + const release = guard.tryAcquire(); + + release?.(); + release?.(); + + expect(guard.isLocked()).toBe(false); + expect(guard.tryAcquire()).toBeTypeOf("function"); + }); +}); diff --git a/engine/auth/login-guard.ts b/engine/auth/login-guard.ts new file mode 100644 index 0000000..ba19588 --- /dev/null +++ b/engine/auth/login-guard.ts @@ -0,0 +1,27 @@ +export type ReleaseLoginAttempt = () => void; + +/** + * Serializes authentication flows that share LINEJS credential storage. + * The owner releases the lock only after its underlying LINEJS operation settles. + */ +export function createLoginAttemptGuard() { + let locked = false; + + return { + isLocked(): boolean { + return locked; + }, + + tryAcquire(): ReleaseLoginAttempt | null { + if (locked) return null; + locked = true; + let released = false; + + return () => { + if (released) return; + released = true; + locked = false; + }; + }, + }; +} diff --git a/engine/main.ts b/engine/main.ts index e95f5f6..3cdf0bc 100644 --- a/engine/main.ts +++ b/engine/main.ts @@ -1,5 +1,7 @@ import { bindClientStorage, createTemporaryQrStorage, getTargetProfiles, LineJsAdapter, parseE2EEKeyData, type LineClient, type LineyPollingSession } from "./linejs/adapter"; import { resolveTalkOwnership, reconcileTalkOwnership } from "./talk-ownership"; +import { createLoginAttemptGuard } from "./auth/login-guard"; +import { createAuthFinalizationBarrier } from "./auth/auth-finalization"; import { presentOwnProfile } from "./linejs/profile-display"; import { resolveFavoriteTime } from "./linejs/favorite-state"; // Keep all LINEJS values behind the adapter boundary. This makes replacing the @@ -85,7 +87,8 @@ const LINEJS_VERSION = LineJsAdapter.version; let ACCOUNT_ID = "default"; if (!parentPort) process.umask(0o077); // Node Workers inherit the host's secure umask. let client: LineClient | null = null; -let loginInFlight = false; +const loginAttemptGuard = createLoginAttemptGuard(); +const authFinalizationBarrier = createAuthFinalizationBarrier(); let authGeneration = 0; let listenerAbort: AbortController | null = null; let currentProfile: any = null; @@ -487,6 +490,22 @@ function normalize(value: any): any { return normalizeJsonValue(value); } +async function createAuthAttemptStorage() { + const initialData: Record = { ...store.getAll() }; + const refreshToken = await store.get("refreshToken"); + if (typeof refreshToken === "string" && refreshToken) initialData.refreshToken = refreshToken; + return createTemporaryQrStorage(initialData); +} + +async function commitAuthAttemptStorage(authStorage: ReturnType): Promise { + for (const [key, value] of Object.entries(authStorage.snapshot())) { + await store.set(key, value as any); + } + const refreshToken = await authStorage.storage.get("refreshToken"); + if (typeof refreshToken === "string" && refreshToken) await store.set("refreshToken", refreshToken); + else await store.delete("refreshToken"); +} + async function persistCredential(next: LineClient): Promise { const refreshToken = await store.get("refreshToken"); const expireValue = await store.get("expire"); @@ -526,7 +545,12 @@ function readCredential(): AuthCredential | null { } async function clearLocalSession() { + // Invalidate in-flight login/finalization immediately, then wait for any + // finalizer already past its generation check before clearing shared state. authGeneration += 1; + await authFinalizationBarrier.wait(); + // Invalidate an outstanding QR login. Keep its in-flight guard until the + // LINEJS promise settles so a second login cannot race the shared storage. listenerAbort?.abort(); listenerAbort = null; // Full sync is cancellable and must not continue writing the previous @@ -4028,75 +4052,139 @@ function attachEvents(next: LineClient) { } } -async function finishAuthentication(next: LineClient, restored: boolean) { - // Validate the session against LINE before declaring it ready. Full sync is - // deliberately *not* part of this critical path: waiting for hundreds of - // rooms here held the entire desktop login screen in "starting" state. +async function finishAuthentication( + next: LineClient, + restored: boolean, + generation: number, + authStorage: ReturnType, +): Promise { + if (generation !== authGeneration) return false; + + // Profile validation is read-only and can remain outside the finalization + // barrier, so Logout never waits on a stalled LINE network request. const profile: any = await next.getMyProfile(); - const account = await activateAccount(profile); - database.consolidateTalkIdentityAliases(ACCOUNT_ID); - client = next; - currentProfile = presentOwnProfile(normalize(profile)); - database.upsertAccount(ACCOUNT_ID, { - mid: String(profile?.mid ?? ""), - displayName: String(profile?.displayName ?? ""), - }); - prunePersistentCache(true); - callManager.attach(next); - attachEvents(next); - const credentialStorage = await persistCredential(next); - emit({ - type: "auth.ready", - restored, - clientIdentity: clientIdentity(), - profile: currentProfile, - authTokenPresent: Boolean(LineJsAdapter.authToken(next)), - storage: credentialStorage, - accountChanged: account.changed && !account.migrated, + if (generation !== authGeneration) return false; + + return authFinalizationBarrier.track(async (): Promise => { + if (generation !== authGeneration) return false; + + const account = await activateAccount(profile); + // activateAccount can itself await account-switch cleanup. Logout may have + // invalidated this attempt while that cleanup was in progress. + if (generation !== authGeneration) return false; + + // LINEJS authenticates against an isolated in-memory store. Commit its + // mutations only inside the finalization barrier so Logout either + // invalidates before any write or waits and clears every committed write. + await commitAuthAttemptStorage(authStorage); + if (generation !== authGeneration) return false; + bindClientStorage(next, store); + + database.consolidateTalkIdentityAliases(ACCOUNT_ID); + database.upsertAccount(ACCOUNT_ID, { + mid: String(profile?.mid ?? ""), + displayName: String(profile?.displayName ?? ""), + }); + prunePersistentCache(true); + + // Persist before publishing an authenticated client. If logout starts + // during persistence, it waits for this task and clears the credential + // after this operation settles. + const credentialStorage = await persistCredential(next); + if (generation !== authGeneration) return false; + + client = next; + currentProfile = presentOwnProfile(normalize(profile)); + callManager.attach(next); + attachEvents(next); + emit({ + type: "auth.ready", + restored, + clientIdentity: clientIdentity(), + profile: currentProfile, + authTokenPresent: Boolean(LineJsAdapter.authToken(next)), + storage: credentialStorage, + accountChanged: account.changed && !account.migrated, + }); + const fullCompleted = database.getSyncState(ACCOUNT_ID, "full-complete")?.status === "ready"; + const mode = !fullCompleted || process.env.LINEY_FULL_SYNC_ON_LOGIN === "1" ? "full" : "delta"; + if (process.env.LINEY_LIGHT_START !== "1") { + void startSync(mode).catch(() => undefined); + } + void resumePendingOutbox(); + return true; }); - // The first successful account-wide pass is durable. Subsequent launches - // refresh changed/new rooms in the background without replaying all history. - const fullCompleted = database.getSyncState(ACCOUNT_ID, "full-complete")?.status === "ready"; - const mode = !fullCompleted || process.env.LINEY_FULL_SYNC_ON_LOGIN === "1" ? "full" : "delta"; - if (process.env.LINEY_LIGHT_START !== "1") { - void startSync(mode).catch(() => undefined); - } - void resumePendingOutbox(); } async function startQrLogin(id: string) { - if (loginInFlight) { + if (loginAttemptGuard.isLocked()) { + replyError(id, new Error("login already in progress")); + return; + } + const releaseLogin = loginAttemptGuard.tryAcquire(); + if (!releaseLogin) { replyError(id, new Error("login already in progress")); return; } - loginInFlight = true; const generation = ++authGeneration; + let acknowledged = false; + let authStorage: ReturnType | null = null; + const acknowledgeStarted = () => { + if (acknowledged || generation !== authGeneration) return; + acknowledged = true; + emit({ id, ok: true, result: { authenticated: false, started: true } }); + }; try { listenerAbort?.abort(); client = null; const identity = clientIdentity(); - const next = await loginWithBrandedQR( + // QR login resolves only after the user completes authentication. Keep + // that promise in the worker, but acknowledge the RPC as soon as LINEJS + // has produced the QR/PIN challenge. + authStorage = await createAuthAttemptStorage(); + if (generation !== authGeneration) { + await authStorage.clear(); + releaseLogin(); + return; + } + const login = loginWithBrandedQR( { onReceiveQRUrl(url) { + if (generation !== authGeneration) return; emit({ type: "auth.qr", url }); + acknowledgeStarted(); }, onPincodeRequest(pin) { + if (generation !== authGeneration) return; emit({ type: "auth.pin", pin }); + acknowledgeStarted(); }, }, { device: identity.device, - storage: store, + storage: authStorage.storage, }, identity, ); - if (generation !== authGeneration) return; - await finishAuthentication(next, false); - emit({ id, ok: true, result: { authenticated: true } }); + void login.then(async (next) => { + if (generation !== authGeneration) return; + await finishAuthentication(next, false, generation, authStorage!); + }).catch((error) => { + if (generation !== authGeneration) return; + if (!acknowledged) replyError(id, error); + else emit({ type: "auth.error", error: errorInfo(error) }); + }).finally(async () => { + // A stale login still owns the single LINEJS auth slot until it settles. + await authStorage?.clear().catch(() => undefined); + releaseLogin(); + }); } catch (error) { - replyError(id, error); - } finally { - loginInFlight = false; + if (generation === authGeneration) { + if (!acknowledged) replyError(id, error); + else emit({ type: "auth.error", error: errorInfo(error) }); + } + await authStorage?.clear().catch(() => undefined); + releaseLogin(); } } @@ -4135,12 +4223,16 @@ async function importE2EEKeychainViaQr(id: string) { replyError(id, new Error("ต้องเชื่อมต่อบัญชี LINE ก่อนนำเข้าคีย์")); return; } - if (loginInFlight) { + if (loginAttemptGuard.isLocked()) { replyError(id, new Error("มีการเข้าสู่ระบบอีกครั้งกำลังทำงานอยู่")); return; } - loginInFlight = true; + const releaseLogin = loginAttemptGuard.tryAcquire(); + if (!releaseLogin) { + replyError(id, new Error("login already in progress")); + return; + } const generation = authGeneration; let temporary: ReturnType | null = null; try { @@ -4216,9 +4308,9 @@ async function importE2EEKeychainViaQr(id: string) { return left.localeCompare(right); }); for (const [key, value] of changedEntries) { - await store.set(key, value as any); + await temporary.storage.set(key, value as any); } - const savedAlias = await store.get(aliasKey); + const savedAlias = await temporary.storage.get(aliasKey); const savedKey = parseE2EEKeyData(savedAlias); if ( String(savedKey?.keyId ?? "") !== selectedKeyId @@ -4228,8 +4320,7 @@ async function importE2EEKeychainViaQr(id: string) { throw new Error("บันทึก keychain ลงเครื่องไม่สำเร็จ"); } - bindClientStorage(next, store); - await finishAuthentication(next, false); + if (!(await finishAuthentication(next, false, generation, temporary!))) return; void recoverPendingE2EE(next).catch((error) => emit({ type: "event.e2ee.recovery_error", error: errorInfo(error).message })); emit({ id, @@ -4240,12 +4331,12 @@ async function importE2EEKeychainViaQr(id: string) { replyError(id, error); } finally { if (temporary) await temporary.clear().catch(() => undefined); - loginInFlight = false; + releaseLogin(); } } async function startPasswordLogin(id: string, rawEmail: unknown, rawPassword: unknown) { - if (loginInFlight) { + if (loginAttemptGuard.isLocked()) { replyError(id, new Error("login already in progress")); return; } @@ -4261,11 +4352,18 @@ async function startPasswordLogin(id: string, rawEmail: unknown, rawPassword: un return; } - loginInFlight = true; + const releaseLogin = loginAttemptGuard.tryAcquire(); + if (!releaseLogin) { + replyError(id, new Error("login already in progress")); + return; + } const generation = ++authGeneration; + let authStorage: ReturnType | null = null; try { listenerAbort?.abort(); client = null; + authStorage = await createAuthAttemptStorage(); + if (generation !== authGeneration) return; const next = await loginWithPassword( { email, @@ -4276,11 +4374,11 @@ async function startPasswordLogin(id: string, rawEmail: unknown, rawPassword: un }, { device: clientIdentity().device, - storage: store, + storage: authStorage.storage, }, ); if (generation !== authGeneration) return; - await finishAuthentication(next, false); + if (!(await finishAuthentication(next, false, generation, authStorage!))) return; emit({ id, ok: true, result: { authenticated: true } }); } catch (error) { replyError(id, error); @@ -4288,7 +4386,8 @@ async function startPasswordLogin(id: string, rawEmail: unknown, rawPassword: un // Do not retain the user's password in the engine after the login attempt. email = ""; password = ""; - loginInFlight = false; + await authStorage?.clear().catch(() => undefined); + releaseLogin(); } } @@ -4297,17 +4396,37 @@ async function restoreToken(id: string, token: unknown) { emit({ id, ok: true, result: { authenticated: true, restored: true } }); return; } + // Token restore shares LINEJS storage with QR, password, and E2EE login. + // It must own the same auth slot to prevent concurrent credential writes. + if (loginAttemptGuard.isLocked()) { + replyError(id, new Error("login already in progress")); + return; + } + + const releaseLogin = loginAttemptGuard.tryAcquire(); + if (!releaseLogin) { + replyError(id, new Error("login already in progress")); + return; + } const generation = ++authGeneration; + let authStorage: ReturnType | null = null; try { + listenerAbort?.abort(); + client = null; + authStorage = await createAuthAttemptStorage(); + if (generation !== authGeneration) return; const next = await loginWithAuthToken(token as any, { device: clientIdentity().device, - storage: store, + storage: authStorage.storage, }); if (generation !== authGeneration) return; - await finishAuthentication(next, true); + if (!(await finishAuthentication(next, true, generation, authStorage!))) return; emit({ id, ok: true, result: { authenticated: true } }); } catch (error) { replyError(id, error); + } finally { + await authStorage?.clear().catch(() => undefined); + releaseLogin(); } } @@ -4316,19 +4435,25 @@ async function autoRestore() { if (!credential) return; const generation = authGeneration; emit({ type: "auth.restoring" }); + let authStorage: ReturnType | null = null; try { + authStorage = await createAuthAttemptStorage(); + if (generation !== authGeneration) return; const next = await loginWithAuthToken(credential as any, { device: clientIdentity().device, - storage: store, + storage: authStorage.storage, }); if (generation !== authGeneration) return; - await finishAuthentication(next, true); + await finishAuthentication(next, true, generation, authStorage); } catch (error) { + if (generation !== authGeneration) return; client = null; emit({ type: "auth.restore_failed", error: errorInfo(error).message, }); + } finally { + await authStorage?.clear().catch(() => undefined); } } @@ -6279,4 +6404,4 @@ for (const signal of ["SIGINT", "SIGTERM"] as const) { process.once(signal, () => { void shutdown().finally(() => process.exit(0)); }); -} +} \ No newline at end of file diff --git a/engine/protocol/protocol.ts b/engine/protocol/protocol.ts index c3b83d3..0d2fd59 100644 --- a/engine/protocol/protocol.ts +++ b/engine/protocol/protocol.ts @@ -31,6 +31,7 @@ export type EngineEventType = | "auth.ready" | "auth.qr" | "auth.pin" + | "auth.error" | "auth.logged_out" | "sync.start" | "sync.progress" diff --git a/package.json b/package.json index 329a1fe..8c8d71d 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "web:preview": "vite preview", "lint": "oxlint", "test": "bun test", - "test:unit": "bun test engine/*.test.ts", + "test:unit": "bun test engine", "verify:release": "npm run doctor && npm test && npm run lint && npm run electron:engine && npm run electron:web && git diff --check", "build:rpm": "npm run electron:engine && npm run electron:web && npm run electron:helpers && electron-builder --linux rpm", "release:local": "sh scripts/package-release.sh", diff --git a/src/App.tsx b/src/App.tsx index ca6e4e0..b8640e0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1220,6 +1220,13 @@ function MainApp() { setAuthError(""); } if (event.type === "auth.pin" && typeof event.pin === "string") setPin(event.pin); + if (event.type === "auth.error") { + const error = event.error as { message?: unknown } | undefined; + setQrUrl(""); + setPin(""); + setAuthBusy(false); + setAuthError(typeof error?.message === "string" ? error.message : "เข้าสู่ระบบ LINE ไม่สำเร็จ กรุณาลองใหม่"); + } if (event.type === "auth.ready") { authenticatedRef.current = true; setAuthBusy(false);