diff --git a/electron/src/auth/service.ts b/electron/src/auth/service.ts index 0519e6089..105c94f57 100644 --- a/electron/src/auth/service.ts +++ b/electron/src/auth/service.ts @@ -34,6 +34,15 @@ function parseJsonResponse(text: string) { } } +function parseOAuthError(text: string) { + try { + const payload = JSON.parse(text); + return typeof payload?.error === "string" ? payload.error : ""; + } catch { + return ""; + } +} + function requestSite(session, request) { if (request.service !== "kael") return session.origin; @@ -94,6 +103,7 @@ export class DesktopAuthService { this.sessions = new Map(); this.currentSessionKey = ""; this.tokens = {}; + this.refreshingTokens = new Map(); this.pendingAuth = null; this.callbackServer = null; this.redirectUri = DEEP_LINK_CALLBACK; @@ -335,13 +345,17 @@ export class DesktopAuthService { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" }, body: new URLSearchParams(parameters).toString(), - redirect: "manual" + redirect: "manual", + signal: AbortSignal.timeout(30_000) }); const text = await response.text(); - if (!response.ok) - throw new Error( + if (!response.ok) { + const error = new Error( `Token exchange failed: status=${response.status}, body=${compactApiErrorBody(text, response.headers.get("content-type") || "")}` - ); + ) as Error & { oauthError?: string }; + error.oauthError = parseOAuthError(text); + throw error; + } const payload = parseJsonResponse(text); if (!payload.access_token) throw new Error("Token exchange response did not include access_token"); return { @@ -359,18 +373,48 @@ export class DesktopAuthService { } if (!stored.expires_at || stored.expires_at > Math.floor(Date.now() / 1000) + 60) return stored.access_token; if (!stored.refresh_token) throw new Error(`refresh_token missing for site ${site}`); - const refreshed = await this.exchangeToken(site, { - grant_type: "refresh_token", - refresh_token: stored.refresh_token, - client_id: stored.client_id || "" - }); - this.tokens[sessionId] = { - ...refreshed, - refresh_token: refreshed.refresh_token || stored.refresh_token, - client_id: stored.client_id || "" - }; - await this.persistTokens(); - return refreshed.access_token; + + const activeRefresh = this.refreshingTokens.get(sessionId); + if (activeRefresh) return activeRefresh; + + const refresh = (async () => { + try { + const refreshed = await this.exchangeToken(site, { + grant_type: "refresh_token", + refresh_token: stored.refresh_token, + client_id: stored.client_id || "" + }); + const current = this.tokens[sessionId]; + if (current !== stored) { + if (current?.access_token) return current.access_token; + throw new Error(`auth session changed for site ${site}`); + } + this.tokens[sessionId] = { + ...refreshed, + refresh_token: refreshed.refresh_token || stored.refresh_token, + client_id: stored.client_id || "" + }; + await this.persistTokens(); + return refreshed.access_token; + } catch (error) { + if (error?.oauthError === "invalid_grant" && this.tokens[sessionId] === stored) { + delete this.tokens[sessionId]; + try { + await this.persistTokens(); + } catch (persistError) { + electronLog.warn("failed to remove expired auth token", persistError); + } + this.emitEvent("auth-session-expired", { sessionId }, "main"); + } + throw error; + } + })(); + this.refreshingTokens.set(sessionId, refresh); + try { + return await refresh; + } finally { + if (this.refreshingTokens.get(sessionId) === refresh) this.refreshingTokens.delete(sessionId); + } } async bootstrapAuthSession({ site, sessionId }) { diff --git a/electron/tests/auth-request-site.test.ts b/electron/tests/auth-request-site.test.ts index fc8d7e739..472d8c17a 100644 --- a/electron/tests/auth-request-site.test.ts +++ b/electron/tests/auth-request-site.test.ts @@ -9,7 +9,17 @@ import { parseUrl } from "../src/shared/url.ts"; const source = readFileSync(new URL("../src/auth/service.ts", import.meta.url), "utf8"); const helpers = source.slice(source.indexOf("function endpoint("), source.indexOf("function base64Url(")); const methods = source.slice(source.indexOf(" async apiRequest("), source.indexOf(" async logout(")); +const exchangeTokenMethod = source.slice( + source.indexOf(" async exchangeToken("), + source.indexOf(" async freshToken(") +); +const freshTokenMethod = source.slice( + source.indexOf(" async freshToken("), + source.indexOf(" async bootstrapAuthSession(") +); const script = stripTypeScriptTypes(`${helpers}\nnew (class { ${methods} })()`); +const exchangeTokenScript = stripTypeScriptTypes(`${helpers}\nnew (class { ${exchangeTokenMethod} })()`); +const freshTokenScript = stripTypeScriptTypes(`new (class { ${freshTokenMethod} })()`); function setup(env: Record = {}, origin = "https://jumpserver.test", responseStatus = 200) { const requests: { url: string; init: RequestInit }[] = []; @@ -33,6 +43,146 @@ function setup(env: Record = {}, origin = "https://jumpserver.te return { auth, requests, session }; } +function setupFreshToken(exchangeToken: () => Promise>) { + const auth = runInNewContext(freshTokenScript, { electronLog: { warn() {} } }); + const events: unknown[][] = []; + let persistCount = 0; + Object.assign(auth, { + tokens: { + account: { + access_token: "expired-access", + refresh_token: "refresh-1", + client_id: "client-1", + expires_at: 1 + } + }, + refreshingTokens: new Map(), + exchangeToken, + emitEvent: (...args: unknown[]) => events.push(args), + persistTokens: async () => { + persistCount += 1; + } + }); + return { auth, events, persisted: () => persistCount }; +} + +function oauthError(code: string) { + return Object.assign(new Error(`oauth ${code}`), { oauthError: code }); +} + +test("token exchange times out and classifies only the structured OAuth error code", async () => { + const timeoutCalls: number[] = []; + const timeoutSignal = {}; + const auth = runInNewContext(exchangeTokenScript, { + AbortSignal: { + timeout: (milliseconds: number) => { + timeoutCalls.push(milliseconds); + return timeoutSignal; + } + }, + compactApiErrorBody: (text: string) => text, + OAUTH_TOKEN: "/token/", + URLSearchParams + }); + for (const [body, expected] of [ + [{ error: "invalid_grant", error_description: "expired" }, "invalid_grant"], + [{ error: "server_error", error_description: "mentions invalid_grant" }, "server_error"], + [{ error_description: "invalid_grant" }, ""] + ] as const) { + auth.fetchSite = async (_url: string, init: RequestInit) => { + assert.equal(init.signal, timeoutSignal); + return new Response(JSON.stringify(body), { status: 400 }); + }; + await assert.rejects( + auth.exchangeToken("https://jumpserver.test", { grant_type: "refresh_token" }), + (error: any) => { + assert.equal(error.oauthError, expected); + return true; + } + ); + } + assert.deepEqual(timeoutCalls, [30_000, 30_000, 30_000]); +}); + +test("expired token requests share one refresh exchange", async () => { + let exchangeCount = 0; + let resolveExchange!: (token: Record) => void; + const exchange = new Promise>((resolve) => { + resolveExchange = resolve; + }); + const { auth, persisted } = setupFreshToken(async () => { + exchangeCount += 1; + return exchange; + }); + + const first = auth.freshToken("https://jumpserver.test", "account"); + const second = auth.freshToken("https://jumpserver.test", "account"); + resolveExchange({ access_token: "access-2", refresh_token: "refresh-2", expires_at: 2_000_000_000 }); + + assert.deepEqual(await Promise.all([first, second]), ["access-2", "access-2"]); + assert.equal(exchangeCount, 1); + assert.equal(persisted(), 1); + assert.equal(auth.tokens.account.refresh_token, "refresh-2"); + assert.equal(auth.refreshingTokens.size, 0); +}); + +test("shared invalid refresh emits one account-scoped expiration event", async () => { + let rejectExchange!: (error: Error) => void; + const exchange = new Promise>((_resolve, reject) => { + rejectExchange = reject; + }); + const { auth, events, persisted } = setupFreshToken(async () => exchange); + + const first = auth.freshToken("https://jumpserver.test", "account"); + const second = auth.freshToken("https://jumpserver.test", "account"); + rejectExchange(oauthError("invalid_grant")); + + const results = await Promise.allSettled([first, second]); + assert.ok(results.every((result) => result.status === "rejected")); + assert.equal(auth.tokens.account, undefined); + assert.equal(persisted(), 1); + assert.equal(events.length, 1); + assert.equal(events[0][0], "auth-session-expired"); + assert.equal((events[0][1] as { sessionId?: string }).sessionId, "account"); + assert.equal(events[0][2], "main"); + assert.equal(auth.refreshingTokens.size, 0); +}); + +test("stale invalid refresh cannot clear a newer token generation", async () => { + let rejectExchange!: (error: Error) => void; + const exchange = new Promise>((_resolve, reject) => { + rejectExchange = reject; + }); + const { auth, events, persisted } = setupFreshToken(async () => exchange); + const request = auth.freshToken("https://jumpserver.test", "account"); + const replacement = { + access_token: "new-login-access", + refresh_token: "new-login-refresh", + client_id: "client-1", + expires_at: 2_000_000_000 + }; + auth.tokens.account = replacement; + rejectExchange(oauthError("invalid_grant")); + + await assert.rejects(request, /invalid_grant/); + assert.equal(auth.tokens.account, replacement); + assert.equal(persisted(), 0); + assert.deepEqual(events, []); +}); + +test("failed token refreshes do not block a later refresh attempt", async () => { + let exchangeCount = 0; + const { auth } = setupFreshToken(async () => { + exchangeCount += 1; + if (exchangeCount === 1) throw new Error("temporary failure"); + return { access_token: "access-2", refresh_token: "refresh-2", expires_at: 2_000_000_000 }; + }); + + await assert.rejects(auth.freshToken("https://jumpserver.test", "account"), /temporary failure/); + await assert.doesNotReject(auth.freshToken("https://jumpserver.test", "account")); + assert.equal(exchangeCount, 2); +}); + test("Koko tickets bind to the token organization without changing the selected organization", async () => { for (const orgId of ["asset-org", undefined]) { const { auth, requests, session } = setup({}, "https://jumpserver.test", 201); diff --git a/ui/app.vue b/ui/app.vue index 18352b5ef..e839daa2f 100644 --- a/ui/app.vue +++ b/ui/app.vue @@ -73,6 +73,7 @@ const unlistenTheme = ref(null); const unlistenFont = ref(null); const unlistenSettingsNavigate = ref(null); const unlistenWebProtocolUrl = ref(null); +const unlistenAuthSessionExpired = ref(null); const { openSettings } = useSettingsWindow(); const { confirmOpen: siteLeaveConfirmOpen, @@ -343,6 +344,20 @@ async function applyAfterHydration() { onMounted(async () => { unregisterAiTaskTabCloseConfirm = registerAiTaskTabCloseConfirm((tabIds) => confirmAiTaskLeave("tab", tabIds)); + + if (isDesktopRuntime()) { + try { + unlistenAuthSessionExpired.value = await desktopListen<{ sessionId?: string }>( + "auth-session-expired", + ({ payload }) => { + void authSession.handleDesktopAuthExpired(String(payload?.sessionId || "")); + } + ); + } catch (err) { + console.error("listen auth-session-expired failed", err); + } + } + if (!route.path.startsWith("/facelive/")) void authSession.bootstrapPersistedSession(); if (!isDesktopRuntime()) return; @@ -424,6 +439,7 @@ onBeforeUnmount(() => { unlistenFont.value?.(); unlistenSettingsNavigate.value?.(); unlistenWebProtocolUrl.value?.(); + unlistenAuthSessionExpired.value?.(); }); diff --git a/ui/components/SideBar/assetTree.vue b/ui/components/SideBar/assetTree.vue index ac1ca273f..46c05e1af 100644 --- a/ui/components/SideBar/assetTree.vue +++ b/ui/components/SideBar/assetTree.vue @@ -219,6 +219,8 @@ const removeFavoriteNodes = (nodes: AssetTreeNode[]): AssetTreeNode[] => })); const reportError = (error: unknown) => { + if (!loggedIn.value) return; + const title = t("Asset.GetAssetFailed"); const description = error instanceof Error ? error.message : String(error); const signature = `${title}::${description}`; diff --git a/ui/composables/useApiRequest.test.ts b/ui/composables/useApiRequest.test.ts index 312b41c9a..d7b9de985 100644 --- a/ui/composables/useApiRequest.test.ts +++ b/ui/composables/useApiRequest.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { apiRequest, ApiRequestError, @@ -11,12 +11,26 @@ import { updateLunaPreferences } from "./useApiRequest"; -const { desktopInvoke } = vi.hoisted(() => ({ desktopInvoke: vi.fn() })); +const { desktopInvoke, userInfoStore } = vi.hoisted(() => ({ + desktopInvoke: vi.fn(), + userInfoStore: { + loggedIn: false, + orgId: "org-current", + setUserLoggedIn: vi.fn() + } +})); vi.mock("~/shared/desktop/bridge", () => ({ desktopInvoke })); -vi.mock("~/store/modules/userInfo", () => ({ - useUserInfoStore: () => ({ loggedIn: false, orgId: "org-current" }) -})); +vi.mock("~/store/modules/userInfo", () => ({ useUserInfoStore: () => userInfoStore })); + +beforeEach(() => { + desktopInvoke.mockReset(); + userInfoStore.loggedIn = false; + userInfoStore.setUserLoggedIn.mockReset(); + userInfoStore.setUserLoggedIn.mockImplementation((loggedIn: boolean) => { + userInfoStore.loggedIn = loggedIn; + }); +}); describe("API request headers", () => { afterEach(() => vi.unstubAllGlobals()); @@ -294,6 +308,37 @@ describe("API error summaries", () => { }); }); + it("does not infer auth expiry from an OAuth error description", async () => { + userInfoStore.loggedIn = true; + vi.stubGlobal("isDesktopRuntime", () => true); + desktopInvoke.mockRejectedValueOnce( + new Error( + 'Error invoking remote method \'jms:invoke\': Error: Token exchange failed: status=400, body={"error":"server_error","error_description":"mentions invalid_grant"}' + ) + ); + + await expect(apiRequest({ method: "GET", path: "/api/test/" })).rejects.toThrow("invalid_grant"); + expect(userInfoStore.setUserLoggedIn).not.toHaveBeenCalled(); + }); + + it("does not throttle the next auth failure while already logged out", async () => { + const emit = vi.fn(); + vi.stubGlobal("isDesktopRuntime", () => true); + vi.stubGlobal("useEventBus", () => ({ emit })); + desktopInvoke.mockRejectedValue( + new Error('api GET /api/test/: api request failed: status=401, body={"detail":"Unauthorized"}') + ); + + await expect(apiRequest({ method: "GET", path: "/api/test/" })).rejects.toMatchObject({ status: 401 }); + userInfoStore.loggedIn = true; + await expect(apiRequest({ method: "GET", path: "/api/test/" })).rejects.toMatchObject({ status: 401 }); + + expect(userInfoStore.setUserLoggedIn).toHaveBeenCalledOnce(); + expect(userInfoStore.setUserLoggedIn).toHaveBeenCalledWith(false); + expect(emit).toHaveBeenCalledWith("clearAssets", undefined); + expect(emit).toHaveBeenCalledWith("login", undefined); + }); + it("preserves structured validation data", () => { const data = { code: "invalid", fields: { name: ["Required"] } }; expect(new ApiRequestError(400, data).data).toBe(data); diff --git a/ui/composables/useApiRequest.ts b/ui/composables/useApiRequest.ts index 89bb48b9e..a621ddcf7 100644 --- a/ui/composables/useApiRequest.ts +++ b/ui/composables/useApiRequest.ts @@ -239,15 +239,15 @@ const isAuthFailure = (error: unknown) => { }; const handleApiAuthFailure = () => { - if (!import.meta.client) return; + if (import.meta.server) return; + + const userInfoStore = useUserInfoStore(); + if (!userInfoStore.loggedIn) return; const now = Date.now(); if (now - lastAuthFailureAt < 1500) return; lastAuthFailureAt = now; - const userInfoStore = useUserInfoStore(); - if (!userInfoStore.loggedIn) return; - userInfoStore.setUserLoggedIn(false); useEventBus().emit("clearAssets", undefined); if (isDesktopRuntime()) { diff --git a/ui/composables/useAuthSession.test.ts b/ui/composables/useAuthSession.test.ts index c45581446..23a6f62f0 100644 --- a/ui/composables/useAuthSession.test.ts +++ b/ui/composables/useAuthSession.test.ts @@ -1,3 +1,4 @@ +import type { Ref } from "vue"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ref } from "vue"; @@ -11,22 +12,25 @@ const organization = (id: string, name: string, isDefault = false) => ({ const mocks = vi.hoisted(() => ({ store: {} as any, - fetchResponse: null as ((url: string) => Promise) | null + fetchResponse: null as ((url: string) => Promise) | null, + pathname: "/" })); vi.mock("~/shared/desktop/bridge", () => ({ desktopInvoke: vi.fn() })); vi.mock("~/store/modules/userInfo", () => ({ useUserInfoStore: () => mocks.store })); vi.mock("~/utils/runtime", async (importOriginal) => ({ ...(await importOriginal()), - pageLocation: () => ({ search: "", origin: "https://luna.test", pathname: "/" }) + pageLocation: () => ({ search: "", origin: "https://luna.test", pathname: mocks.pathname }) })); let useAuthSession: typeof import("./useAuthSession").useAuthSession; +let currentAccountIdRef: Ref; beforeEach(async () => { vi.resetModules(); globalThis.localStorage?.clear(); - const currentAccountId = ref(""); + mocks.pathname = "/"; + currentAccountIdRef = ref(""); const userMap = ref({}); mocks.store = { currentAccountId: "", @@ -46,7 +50,7 @@ beforeEach(async () => { }) }; - vi.stubGlobal("storeToRefs", () => ({ currentAccountId, userMap })); + vi.stubGlobal("storeToRefs", () => ({ currentAccountId: currentAccountIdRef, userMap })); vi.stubGlobal("useNuxtApp", () => ({ $i18n: { t: (key: string) => key } })); vi.stubGlobal("useToast", () => ({ add: vi.fn() })); vi.stubGlobal("useLocalePath", () => (path: unknown) => path); @@ -78,6 +82,38 @@ afterEach(() => { vi.unstubAllGlobals(); }); +describe("desktop session expiry", () => { + it("reauthenticates only the expired current account from every layout", async () => { + const order: string[] = []; + const emit = vi.fn((event: string) => order.push(event)); + const navigateTo = vi.fn(async () => { + order.push("navigate"); + }); + vi.stubGlobal("useEventBus", () => ({ emit })); + vi.stubGlobal("navigateTo", navigateTo); + vi.stubGlobal("isDesktopRuntime", () => true); + currentAccountIdRef.value = "account-1"; + mocks.store.loggedIn = true; + const auth = useAuthSession(); + + await expect(auth.handleDesktopAuthExpired("account-2")).resolves.toBe(false); + expect(mocks.store.setUserLoggedIn).not.toHaveBeenCalled(); + + await expect(auth.handleDesktopAuthExpired("account-1")).resolves.toBe(true); + expect(mocks.store.setUserLoggedIn).toHaveBeenCalledWith(false); + expect(navigateTo).toHaveBeenCalledWith({ path: "/" }); + expect(order).toEqual(["clearAssets", "navigate", "login"]); + + mocks.pathname = "/auth/browser"; + mocks.store.loggedIn = true; + order.length = 0; + navigateTo.mockClear(); + await expect(auth.handleDesktopAuthExpired("account-1")).resolves.toBe(true); + expect(navigateTo).not.toHaveBeenCalled(); + expect(order).toEqual(["clearAssets"]); + }); +}); + describe("web session bootstrap", () => { it("initializes the organization before exposing the authenticated session", async () => { await expect(useAuthSession().bootstrapPersistedSession()).resolves.toBe(true); diff --git a/ui/composables/useAuthSession.ts b/ui/composables/useAuthSession.ts index de939704c..d1efa73e7 100644 --- a/ui/composables/useAuthSession.ts +++ b/ui/composables/useAuthSession.ts @@ -351,6 +351,14 @@ export const useAuthSession = () => { return true; }; + const promptDesktopLogin = async () => { + if (import.meta.server || !isDesktopRuntime()) return; + if (pageLocation().pathname.includes("/auth")) return; + await navigateTo(localePath({ path: "/" })); + await nextTick(); + useEventBus().emit("login", undefined); + }; + const bootstrapSession = async () => { if (bootstrapRetryTimer) { clearTimeout(bootstrapRetryTimer); @@ -359,12 +367,6 @@ export const useAuthSession = () => { const restored = restorePersistedSnapshot(); - const promptLogin = () => { - if (!import.meta.client || !isDesktopRuntime()) return; - if (pageLocation().pathname.includes("/auth")) return; - useEventBus().emit("login", undefined); - }; - const notifyBootstrapFailure = (failure: "network" | "server") => { if (lastBootstrapFailure === failure) return; lastBootstrapFailure = failure; @@ -402,7 +404,7 @@ export const useAuthSession = () => { if (failure === "auth") { lastBootstrapFailure = null; userInfoStore.setUserLoggedIn(false); - if (restored) promptLogin(); + if (restored) await promptDesktopLogin(); return false; } if (failure) { @@ -424,7 +426,7 @@ export const useAuthSession = () => { } userInfoStore.setUserLoggedIn(false); - if (restored) promptLogin(); + if (restored) await promptDesktopLogin(); return false; } @@ -440,7 +442,7 @@ export const useAuthSession = () => { } catch (error) { console.error("bootstrap auth session failed", { site, restored, error }); userInfoStore.setUserLoggedIn(false); - if (restored) promptLogin(); + if (restored) await promptDesktopLogin(); return false; } }; @@ -458,6 +460,15 @@ export const useAuthSession = () => { return bootstrapPromise; }; + const handleDesktopAuthExpired = async (sessionId: string) => { + if (!sessionId || sessionId !== currentAccountId.value) return false; + + userInfoStore.setUserLoggedIn(false); + useEventBus().emit("clearAssets", undefined); + await promptDesktopLogin(); + return true; + }; + const refreshOrganizations = () => { if (!userInfoStore.loggedIn) return Promise.resolve(); if (refreshOrganizationsPromise) return refreshOrganizationsPromise; @@ -492,6 +503,7 @@ export const useAuthSession = () => { applyLoginPayload, authReady, bootstrapPersistedSession, + handleDesktopAuthExpired, refreshOrganizations }; };