2026-08-09 11:49:03 -07:00
|
|
|
import { describe, it, expect } from "vitest";
|
2026-08-10 09:57:16 -07:00
|
|
|
import {
|
|
|
|
|
authErrorMessage,
|
|
|
|
|
extractSignInUrl,
|
|
|
|
|
pickSignInUrl,
|
|
|
|
|
} from "./useClaudeAuth";
|
2026-08-09 11:49:03 -07:00
|
|
|
|
|
|
|
|
describe("extractSignInUrl", () => {
|
|
|
|
|
it("finds the authorize URL in realistic setup-token output", () => {
|
|
|
|
|
const url =
|
|
|
|
|
"https://claude.ai/oauth/authorize?code=true&client_id=abc&redirect_uri=https%3A%2F%2Fplatform.claude.com%2Foauth%2Fcode%2Fcallback";
|
|
|
|
|
expect(
|
|
|
|
|
extractSignInUrl(
|
|
|
|
|
`Claude Code long-lived token setup\nBrowser didn't open? Use this url to sign in:\n${url}\n\nPaste code here if prompted > `,
|
|
|
|
|
),
|
|
|
|
|
).toBe(url);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("returns null before the CLI has printed anything useful", () => {
|
|
|
|
|
expect(extractSignInUrl("")).toBeNull();
|
|
|
|
|
expect(extractSignInUrl("Starting `claude setup-token`…\n")).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("drops trailing prose punctuation", () => {
|
|
|
|
|
expect(extractSignInUrl("Visit https://claude.ai/oauth/authorize?x=1.")).toBe(
|
|
|
|
|
"https://claude.ai/oauth/authorize?x=1",
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("prefers the OAuth URL over unrelated links in the transcript", () => {
|
|
|
|
|
const text =
|
|
|
|
|
"Docs: https://docs.claude.com/en/docs/claude-code/setup-token-and-more-words\n" +
|
|
|
|
|
"Sign in: https://claude.ai/oauth/authorize?code=true\n";
|
|
|
|
|
expect(extractSignInUrl(text)).toBe("https://claude.ai/oauth/authorize?code=true");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("keeps the full link when a TUI repaint also emitted a truncated one", () => {
|
|
|
|
|
const full = "https://claude.ai/oauth/authorize?code=true&client_id=abcdefgh";
|
|
|
|
|
const text = `https://claude.ai/oauth/authorize?code=tr\n${full}\n`;
|
|
|
|
|
expect(extractSignInUrl(text)).toBe(full);
|
|
|
|
|
});
|
2026-08-09 19:35:39 -07:00
|
|
|
|
|
|
|
|
// ── The spoof this function exists to refuse ──────────────────────────────
|
|
|
|
|
// The transcript is container output. Everything below is a URL a misbehaving
|
|
|
|
|
// sandboxed agent can print at will, and the modal renders whatever comes
|
|
|
|
|
// back under a heading that says "Sign in with Anthropic".
|
|
|
|
|
|
|
|
|
|
it("rejects userinfo that makes an attacker's host read as Anthropic's", () => {
|
|
|
|
|
// Displays as `https://claude.ai...` in anything that truncates; navigates
|
|
|
|
|
// to evil.tld and harvests the real credential.
|
|
|
|
|
const spoof =
|
|
|
|
|
"https://claude.ai@evil.tld/oauth/authorize?" + "padding=".repeat(40);
|
|
|
|
|
expect(extractSignInUrl(`Use this url to sign in:\n${spoof}\n`)).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not let a longer hostile URL displace the real one", () => {
|
|
|
|
|
const real = "https://claude.ai/oauth/authorize?code=true&client_id=abc";
|
|
|
|
|
const longer =
|
|
|
|
|
"https://evil.tld/oauth/authorize?" + "x".repeat(real.length * 2);
|
|
|
|
|
expect(extractSignInUrl(`${real}\n${longer}\n`)).toBe(real);
|
|
|
|
|
// ...and the same when the hostile one is printed first.
|
|
|
|
|
expect(extractSignInUrl(`${longer}\n${real}\n`)).toBe(real);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("rejects a host that merely contains an Anthropic domain", () => {
|
|
|
|
|
expect(
|
|
|
|
|
extractSignInUrl("Sign in: https://claude.ai.evil.tld/oauth/authorize\n"),
|
|
|
|
|
).toBeNull();
|
|
|
|
|
expect(
|
|
|
|
|
extractSignInUrl("Sign in: https://evil.tld/claude.ai/oauth/authorize\n"),
|
|
|
|
|
).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("rejects non-http schemes and control characters smuggled into the link", () => {
|
|
|
|
|
expect(extractSignInUrl("Open javascript:alert(1) to continue\n")).toBeNull();
|
|
|
|
|
expect(
|
|
|
|
|
extractSignInUrl("https://claude.ai/oauth\u0000/authorize\n"),
|
|
|
|
|
).toBe("https://claude.ai/oauth");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("takes the first legitimate link, not the longest", () => {
|
|
|
|
|
const first = "https://claude.ai/oauth/authorize?code=true";
|
|
|
|
|
const second = "https://platform.claude.com/oauth/authorize?code=true&more=1";
|
|
|
|
|
expect(extractSignInUrl(`${first}\n${second}\n`)).toBe(first);
|
|
|
|
|
});
|
2026-08-10 09:57:16 -07:00
|
|
|
|
|
|
|
|
// ── Why the scraper is only the fallback ─────────────────────────────────
|
|
|
|
|
// `claude setup-token` emits the URL as an OSC 8 hyperlink and slices the
|
|
|
|
|
// *visible* text of it to the terminal width, so the transcript holds five
|
|
|
|
|
// 80-character pieces of a 346-character URL. Each piece is a valid,
|
|
|
|
|
// Anthropic-hosted, oauth-looking URL — and none of them authorises
|
|
|
|
|
// anything.
|
|
|
|
|
|
|
|
|
|
it("cannot recover a URL the CLI sliced across lines, which is why the hyperlink wins", () => {
|
|
|
|
|
const slices = [
|
|
|
|
|
FULL_URL.slice(0, 80),
|
|
|
|
|
FULL_URL.slice(80, 160),
|
|
|
|
|
FULL_URL.slice(160, 240),
|
|
|
|
|
FULL_URL.slice(240, 320),
|
|
|
|
|
FULL_URL.slice(320),
|
|
|
|
|
];
|
|
|
|
|
const scraped = extractSignInUrl(slices.join("\n"));
|
|
|
|
|
|
|
|
|
|
// Documenting the limit, not endorsing it: the pieces share no prefix, so
|
|
|
|
|
// the "extends the current pick" rule cannot join them, and guessing at
|
|
|
|
|
// line joins on an untrusted stream is not on the table.
|
|
|
|
|
expect(scraped).toBe(slices[0]);
|
|
|
|
|
expect(scraped).not.toBe(FULL_URL);
|
|
|
|
|
|
|
|
|
|
// The hyperlink parameter carries the whole thing, and that is what the
|
|
|
|
|
// hook prefers.
|
|
|
|
|
expect(pickSignInUrl([FULL_URL])).toBe(FULL_URL);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/** The real sign-in URL, at its measured length (346 characters, Claude Code
|
|
|
|
|
* 2.1.226). */
|
|
|
|
|
const FULL_URL =
|
|
|
|
|
"https://claude.com/cai/oauth/authorize?code=true&client_id=9d1c250a-e61b-44d9-88ed-5944d1962f5e&response_type=code&redirect_uri=https%3A%2F%2Fplatform.claude.com%2Foauth%2Fcode%2Fcallback&scope=user%3Ainference&code_challenge=RUX5MlWvwld1dmpvF_aPIJQWMBmffuJt4dOdL13zWAg&code_challenge_method=S256&state=su-x9PgZzvkBd3-um6G1llLNDgxptyO6HERvvCSrTbg";
|
|
|
|
|
|
|
|
|
|
describe("pickSignInUrl", () => {
|
|
|
|
|
it("keeps a 346-character authorize URL intact", () => {
|
|
|
|
|
expect(FULL_URL).toHaveLength(346);
|
|
|
|
|
expect(pickSignInUrl([FULL_URL])).toBe(FULL_URL);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("applies the same host allowlist to a hyperlink target", () => {
|
|
|
|
|
// An OSC 8 parameter is container output like anything else, and it is
|
|
|
|
|
// never displayed — so it is the *easier* place to hide a hostile host.
|
|
|
|
|
expect(pickSignInUrl(["https://evil.tld/cai/oauth/authorize"])).toBeNull();
|
|
|
|
|
expect(
|
|
|
|
|
pickSignInUrl(["https://claude.ai@evil.tld/oauth/authorize"]),
|
|
|
|
|
).toBeNull();
|
|
|
|
|
expect(pickSignInUrl(["javascript:alert(1)"])).toBeNull();
|
|
|
|
|
expect(pickSignInUrl([])).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not let a later hyperlink displace the one already shown", () => {
|
|
|
|
|
const real = `${FULL_URL}`;
|
|
|
|
|
const spoof = "https://claude.com.evil.tld/cai/oauth/authorize?code=true";
|
|
|
|
|
expect(pickSignInUrl([real, spoof])).toBe(real);
|
|
|
|
|
expect(pickSignInUrl([spoof, real])).toBe(real);
|
|
|
|
|
});
|
2026-08-09 11:49:03 -07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("authErrorMessage", () => {
|
|
|
|
|
it("passes a Tauri string rejection through verbatim", () => {
|
|
|
|
|
const backend =
|
|
|
|
|
"The container for 'api' is not running. Start it, then run authentication again.";
|
|
|
|
|
expect(authErrorMessage(backend, "fallback")).toBe(backend);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("uses an Error's message", () => {
|
|
|
|
|
expect(authErrorMessage(new Error("channel closed"), "fallback")).toBe(
|
|
|
|
|
"channel closed",
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("falls back rather than stringifying an opaque value", () => {
|
|
|
|
|
expect(authErrorMessage({ weird: true }, "Something went wrong.")).toBe(
|
|
|
|
|
"Something went wrong.",
|
|
|
|
|
);
|
|
|
|
|
expect(authErrorMessage(" ", "Something went wrong.")).toBe(
|
|
|
|
|
"Something went wrong.",
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|