Fix the shared Claude auth flow: whole sign-in URL, recoverable rejected code
Build App / compute-version (pull_request) Successful in 6s
Build App / build-macos (pull_request) Successful in 2m30s
Build App / build-windows (pull_request) Successful in 5m23s
Build App / build-linux (pull_request) Successful in 5m50s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Build App / compute-version (pull_request) Successful in 6s
Build App / build-macos (pull_request) Successful in 2m30s
Build App / build-windows (pull_request) Successful in 5m23s
Build App / build-linux (pull_request) Successful in 5m50s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Two compounding bugs made `claude setup-token` unusable, both measured against 2.1.226 under a pty rather than reasoned about. **The sign-in URL was truncated.** The CLI emits it as an OSC 8 hyperlink and slices the *visible* text of that hyperlink to the terminal width: a 346 character URL arrives at 80 columns as five separate hyperlink emissions, each carrying the whole URL in its parameter and 80 characters of it on screen. The transcript scraper picked up the first slice — a URL that parses, points at claude.com, and cannot authorise anything. The ANSI stripper now surfaces the OSC 8 target and `claude-token-link` carries it to the UI, which prefers it over the scraped text. It still goes through `sanitizeRelayUrl` with the ANTHROPIC_SIGN_IN_HOSTS allowlist before display and again before `openUrl` — an OSC 8 parameter is never rendered, which makes it the easier place to hide a hostile host, not a trusted one. The wrapped-display fallback is kept for CLI versions that print a bare URL. **A rejected code hung the flow.** On a bad paste the CLI prints `OAuth error: Invalid code…` / `Press Enter to retry.` and blocks on stdin instead of exiting; nothing recognised that, so the exec sat until the 15-minute timeout with the UI still saying "Finishing sign-in". Given the first bug handed the user a truncated URL, an invalid code was the likely first outcome. The streamed output is now scanned for that message, `claude-token-code-rejected` reopens the input with an explanation, and the Enter is sent so the next code has a prompt to land in — bounded by MAX_CODE_ATTEMPTS, after which the flow reports a failure. An undeterminable exec exit status is logged rather than silently read as success. **A wrapped token was rejected *and* leaked.** `stty cols` fails silently, and an 80-column fallback splits the ~103 character token across two lines: the parser saw a too-short fragment and failed, while the redactor masked the first line — which carries the `sk-ant-` marker — and printed the second, the tail of a live credential, to the UI in clear. `scan_credential_body` now reassembles a run across hard wraps and both the parser and the redactor use it, so they cannot disagree about where a credential ends. A join only happens across a break at a plausible terminal margin (>= 40 columns) and only for a run not already long enough to be a whole credential — without that second guard a repainting TUI welds one frame's token onto the next frame's first word. The length floor is applied to the reassembled body, so a fragment is still never accepted. Also: `stty cols` raised 200 -> 400 (the URL alone needs ~350), and `ESC ( B` is handled as the three-byte charset designation it is — it prefixes every repaint frame, and treating it as two bytes emitted a stray `B` that could glue itself onto a token and make the parser refuse it. `submit_claude_token_code`'s single-write behaviour is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KSP2KNPhuWKQ4DL5TZEn3k
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { authErrorMessage, extractSignInUrl } from "./useClaudeAuth";
|
||||
import {
|
||||
authErrorMessage,
|
||||
extractSignInUrl,
|
||||
pickSignInUrl,
|
||||
} from "./useClaudeAuth";
|
||||
|
||||
describe("extractSignInUrl", () => {
|
||||
it("finds the authorize URL in realistic setup-token output", () => {
|
||||
@@ -79,6 +83,64 @@ describe("extractSignInUrl", () => {
|
||||
const second = "https://platform.claude.com/oauth/authorize?code=true&more=1";
|
||||
expect(extractSignInUrl(`${first}\n${second}\n`)).toBe(first);
|
||||
});
|
||||
|
||||
// ── 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);
|
||||
});
|
||||
});
|
||||
|
||||
describe("authErrorMessage", () => {
|
||||
|
||||
Reference in New Issue
Block a user