Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 60 additions & 16 deletions electron/src/auth/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand All @@ -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 }) {
Expand Down
150 changes: 150 additions & 0 deletions electron/tests/auth-request-site.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {}, origin = "https://jumpserver.test", responseStatus = 200) {
const requests: { url: string; init: RequestInit }[] = [];
Expand All @@ -33,6 +43,146 @@ function setup(env: Record<string, string> = {}, origin = "https://jumpserver.te
return { auth, requests, session };
}

function setupFreshToken(exchangeToken: () => Promise<Record<string, unknown>>) {
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<string, unknown>) => void;
const exchange = new Promise<Record<string, unknown>>((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<Record<string, unknown>>((_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<Record<string, unknown>>((_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);
Expand Down
16 changes: 16 additions & 0 deletions ui/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ const unlistenTheme = ref<DesktopUnlistenFn | null>(null);
const unlistenFont = ref<DesktopUnlistenFn | null>(null);
const unlistenSettingsNavigate = ref<DesktopUnlistenFn | null>(null);
const unlistenWebProtocolUrl = ref<DesktopUnlistenFn | null>(null);
const unlistenAuthSessionExpired = ref<DesktopUnlistenFn | null>(null);
const { openSettings } = useSettingsWindow();
const {
confirmOpen: siteLeaveConfirmOpen,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -424,6 +439,7 @@ onBeforeUnmount(() => {
unlistenFont.value?.();
unlistenSettingsNavigate.value?.();
unlistenWebProtocolUrl.value?.();
unlistenAuthSessionExpired.value?.();
});
</script>

Expand Down
2 changes: 2 additions & 0 deletions ui/components/SideBar/assetTree.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand Down
55 changes: 50 additions & 5 deletions ui/composables/useApiRequest.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
apiRequest,
ApiRequestError,
Expand All @@ -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());
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading