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
40 changes: 36 additions & 4 deletions src/cli/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | 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<string> {
const text = await response.text().catch(() => "");
if (!text) {
Expand Down
23 changes: 15 additions & 8 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -6222,18 +6225,22 @@ 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)}`,
);
if (authedCreds.personal_server) {
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 <url>");
}
renderer.detail("Credentials saved to ~/.vana/auth.json");
},
Expand Down
110 changes: 110 additions & 0 deletions test/cli/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
53 changes: 53 additions & 0 deletions test/cli/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading