From 20ec9e62004d7030ea3f920019c88a93e8069800 Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Wed, 1 Jul 2026 22:34:43 -0500 Subject: [PATCH] fix: prevent stale Personal Server URL after Account OAuth login Account OAuth login (the OIDC device-grant path added in #3) never returns Personal Server fields on the real token response, so the CLI silently kept a previous account's pinned personalServerUrl in ~/.vana/vana-connect-state.json after switching accounts. Clear it whenever a fresh login reports no PS, and read PS fields from id_token claims as a secondary source alongside the top-level token fields. Surface a clear "no Personal Server found" hint instead of silently omitting it. This is a preparatory correctness/UX fix, not full Account OAuth -> PS sync enablement: Unity Account has no authenticated-session endpoint that mints or returns a caller's Personal Server URL/session token (the legacy connect app's device-poll route does this via personal_servers/sessions tables, but Unity Account doesn't expose an equivalent yet). See the accompanying report for what's still needed server-side. --- src/cli/auth.ts | 40 +++++++++++++-- src/cli/index.ts | 23 ++++++--- test/cli/auth.test.ts | 110 +++++++++++++++++++++++++++++++++++++++++ test/cli/index.test.ts | 53 ++++++++++++++++++++ 4 files changed, 214 insertions(+), 12 deletions(-) diff --git a/src/cli/auth.ts b/src/cli/auth.ts index 68d9d5c..b92a9d0 100644 --- a/src/cli/auth.ts +++ b/src/cli/auth.ts @@ -474,18 +474,50 @@ async function pollOAuthDeviceCode(params: { function oauthTokenToAuthorized( token: OAuthTokenSuccess, ): DeviceCodePollAuthorized { + const address = resolveOAuthAccountAddress(token); + const claims = decodeJwtClaims(token.id_token); + return { status: "authorized", - address: resolveOAuthAccountAddress(token), + address, session_token: token.access_token, expires_at: token.expires_at, expires_in: token.expires_in, - personal_server_url: token.personal_server_url, - personal_server_session_token: token.personal_server_session_token, - ps_access_token: token.ps_access_token, + personal_server_url: + token.personal_server_url ?? + readStringClaim(claims, [ + "personal_server_url", + "ps_url", + "vana_personal_server_url", + ]), + personal_server_session_token: + token.personal_server_session_token ?? + readStringClaim(claims, [ + "personal_server_session_token", + "ps_session_token", + ]), + ps_access_token: + token.ps_access_token ?? + readStringClaim(claims, ["ps_access_token", "vana_ps_token"]), }; } +function readStringClaim( + claims: Record | null, + keys: string[], +): string | undefined { + if (!claims) { + return undefined; + } + for (const key of keys) { + const value = claims[key]; + if (typeof value === "string" && value.trim()) { + return value; + } + } + return undefined; +} + async function readOAuthErrorDetail(response: Response): Promise { const text = await response.text().catch(() => ""); if (!text) { diff --git a/src/cli/index.ts b/src/cli/index.ts index ffc886b..1365e8c 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -6179,9 +6179,12 @@ async function runLogin( if (creds) { await saveCredentials(creds); - if (creds.personal_server?.url) { - await updateCliConfig({ personalServerUrl: creds.personal_server.url }); - } + // Always sync the pinned PS config to this login's result — including + // clearing it when this account has no PS, so a stale PS URL from a + // previously logged-in account can't linger and be used by mistake. + await updateCliConfig({ + personalServerUrl: creds.personal_server?.url, + }); process.stdout.write( `${JSON.stringify({ status: "authenticated", @@ -6222,11 +6225,12 @@ async function runLogin( }, onAuthorized: async (authedCreds) => { await saveCredentials(authedCreds); - if (authedCreds.personal_server?.url) { - await updateCliConfig({ - personalServerUrl: authedCreds.personal_server.url, - }); - } + // Always sync the pinned PS config to this login's result — including + // clearing it when this account has no PS, so a stale PS URL from a + // previously logged-in account can't linger and be used by mistake. + await updateCliConfig({ + personalServerUrl: authedCreds.personal_server?.url, + }); renderer.success( `Logged in as ${formatAddress(authedCreds.account.address)}`, ); @@ -6234,6 +6238,9 @@ async function runLogin( renderer.detail( `Personal Server: ${authedCreds.personal_server.url}`, ); + } else { + renderer.detail("No Personal Server found for this account yet."); + renderer.next("vana server set-url "); } renderer.detail("Credentials saved to ~/.vana/auth.json"); }, diff --git a/test/cli/auth.test.ts b/test/cli/auth.test.ts index b33f7f1..ac32bfd 100644 --- a/test/cli/auth.test.ts +++ b/test/cli/auth.test.ts @@ -559,6 +559,116 @@ describe("runDeviceCodeFlow", () => { "vana-cli-dev", ); }); + + it("resolves Personal Server info from OAuth id_token claims when the token response omits it", async () => { + process.env.VANA_ACCOUNT_URL = "https://account-dev.vana.org"; + const idToken = [ + Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url"), + Buffer.from( + JSON.stringify({ + sub: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + wallet_address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + personal_server_url: "https://ps.example", + ps_session_token: "vana_ps_token", + }), + ).toString("base64url"), + "", + ].join("."); + + fetchMock + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + device_authorization_endpoint: + "https://account-dev.vana.org/oauth/device/code", + token_endpoint: "https://account-dev.vana.org/oauth/token", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + device_code: "device-123", + user_code: "ABCD-EFGH", + verification_uri: "https://account-dev.vana.org/device", + expires_in: 300, + interval: 5, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + access_token: "vana_sess_123", + id_token: idToken, + expires_at: "2026-04-22T00:00:00.000Z", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + const promise = runDeviceCodeFlow({ + onCode: vi.fn(), + onWaiting: vi.fn(), + onAuthorized: vi.fn(), + onExpired: vi.fn(), + onError: vi.fn(), + }); + + await vi.advanceTimersByTimeAsync(5_000); + + await expect(promise).resolves.toMatchObject({ + account: { address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" }, + personal_server: { + url: "https://ps.example", + session_token: "vana_ps_token", + }, + }); + }); + + it("leaves personal_server null when OAuth returns no PS info at all", async () => { + mockDiscoveryUnavailable(); + fetchMock + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + device_code: "device-123", + user_code: "ABCD-EFGH", + verification_uri: "https://account.vana.org/auth/device", + expires_in: 300, + interval: 5, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + status: "authorized", + session_token: "vana_sess_123", + address: "0xabc123", + expires_at: "2026-04-22T00:00:00.000Z", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + const promise = runDeviceCodeFlow({ + onCode: vi.fn(), + onWaiting: vi.fn(), + onAuthorized: vi.fn(), + onExpired: vi.fn(), + onError: vi.fn(), + }); + + await vi.advanceTimersByTimeAsync(5_000); + + await expect(promise).resolves.toMatchObject({ + personal_server: null, + }); + }); }); describe("resolveOAuthClientId", () => { diff --git a/test/cli/index.test.ts b/test/cli/index.test.ts index 1d903c9..3015c8d 100644 --- a/test/cli/index.test.ts +++ b/test/cli/index.test.ts @@ -591,6 +591,59 @@ describe("runCli", () => { }); }); + it("clears a stale pinned Personal Server URL when cloud login resolves none", async () => { + mockRunDeviceCodeFlow.mockImplementation(async (callbacks) => { + const creds = { + account: { + address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + session_token: "vana_account_session", + expires_at: "2026-04-22T19:25:14.420Z", + }, + personal_server: null, + }; + await callbacks.onAuthorized(creds); + return creds; + }); + + const { runCli } = await import("../../src/cli/index.js"); + const exitCode = await runCli(["node", "vana", "login"]); + + expect(exitCode).toBe(0); + expect(mockUpdateCliConfig).toHaveBeenCalledWith({ + personalServerUrl: undefined, + }); + expect(stderr).toContain("No Personal Server found for this account yet."); + expect(stderr).toContain("vana server set-url"); + }); + + it("pins the Personal Server URL returned by cloud login", async () => { + mockRunDeviceCodeFlow.mockImplementation(async (callbacks) => { + const creds = { + account: { + address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + session_token: "vana_account_session", + expires_at: "2026-04-22T19:25:14.420Z", + }, + personal_server: { + url: "https://0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266.myvana.app", + session_token: "vana_ps_session", + expires_at: "2026-04-22T19:25:14.420Z", + }, + }; + await callbacks.onAuthorized(creds); + return creds; + }); + + const { runCli } = await import("../../src/cli/index.js"); + const exitCode = await runCli(["node", "vana", "login"]); + + expect(exitCode).toBe(0); + expect(mockUpdateCliConfig).toHaveBeenCalledWith({ + personalServerUrl: + "https://0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266.myvana.app", + }); + }); + it("prints telemetry status in json mode", async () => { mockGetTelemetryStatus.mockResolvedValue({ enabled: false,