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:
@@ -3,6 +3,8 @@ import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
import * as commands from "../lib/tauri-commands";
|
||||
import { ANTHROPIC_SIGN_IN_HOSTS, sanitizeRelayUrl } from "../lib/urlRelay";
|
||||
import type {
|
||||
ClaudeTokenCodeRejectedEvent,
|
||||
ClaudeTokenLinkEvent,
|
||||
ClaudeTokenOutputEvent,
|
||||
ClaudeTokenProgressEvent,
|
||||
} from "../lib/types";
|
||||
@@ -19,10 +21,17 @@ import type {
|
||||
/** Emitted by `auth_token_commands.rs`; payload shapes live in `lib/types.ts`. */
|
||||
const PROGRESS_EVENT = "claude-token-progress";
|
||||
const OUTPUT_EVENT = "claude-token-output";
|
||||
const LINK_EVENT = "claude-token-link";
|
||||
const CODE_REJECTED_EVENT = "claude-token-code-rejected";
|
||||
|
||||
/** Bound on the retained transcript. The tail is the interesting part. */
|
||||
const MAX_OUTPUT = 64 * 1024;
|
||||
|
||||
/** Bound on retained sign-in candidates. The backend already deduplicates
|
||||
* consecutive repeats; this stops a container that prints a fresh hyperlink
|
||||
* every frame from growing state without limit. */
|
||||
const MAX_LINKS = 16;
|
||||
|
||||
/**
|
||||
* Tauri rejects an `invoke` with the Rust `Err(String)` itself, and this
|
||||
* backend writes its errors as complete, actionable sentences ("The container
|
||||
@@ -38,13 +47,13 @@ export function authErrorMessage(e: unknown, fallback: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the sign-in URL out of `claude setup-token`'s transcript.
|
||||
* Choose one sign-in URL from a list of candidates.
|
||||
*
|
||||
* **The transcript is container output, so every candidate here is
|
||||
* attacker-controlled if the sandboxed agent misbehaves.** It is then rendered
|
||||
* under a heading that says "Sign in with Anthropic" and handed to the host
|
||||
* browser, which makes this the highest-value URL in the app to spoof: a user
|
||||
* who follows it types their real Anthropic credentials into whatever it
|
||||
* **Every candidate is container output, so all of them are
|
||||
* attacker-controlled if the sandboxed agent misbehaves.** The winner is
|
||||
* rendered under a heading that says "Sign in with Anthropic" and handed to the
|
||||
* host browser, which makes this the highest-value URL in the app to spoof: a
|
||||
* user who follows it types their real Anthropic credentials into whatever it
|
||||
* resolves to. Three rules follow, and none of them are optional:
|
||||
*
|
||||
* - Every candidate goes through the shared {@link sanitizeRelayUrl}, with a
|
||||
@@ -60,14 +69,8 @@ export function authErrorMessage(e: unknown, fallback: string): string {
|
||||
* the complete one — and it cannot swap the origin, because a longer string
|
||||
* with the same prefix has the same host.
|
||||
*/
|
||||
export function extractSignInUrl(text: string): string | null {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const matches = text.match(/https?:\/\/[^\s"'`<>\x00-\x20\x7f]+/g);
|
||||
if (!matches) return null;
|
||||
|
||||
const cleaned = matches
|
||||
// Trailing punctuation belongs to the prose, not the URL.
|
||||
.map((url) => url.replace(/[.,;:!?)\]}>'"]+$/, ""))
|
||||
export function pickSignInUrl(candidates: readonly string[]): string | null {
|
||||
const cleaned = candidates
|
||||
.map((url) => sanitizeRelayUrl(url, { allowHosts: ANTHROPIC_SIGN_IN_HOSTS }))
|
||||
.filter((url): url is string => url !== null);
|
||||
|
||||
@@ -81,6 +84,33 @@ export function extractSignInUrl(text: string): string | null {
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrape a sign-in URL out of `claude setup-token`'s visible transcript.
|
||||
*
|
||||
* **This is the fallback, not the primary route.** The CLI emits the URL as an
|
||||
* OSC 8 hyperlink and slices the *visible* text of that hyperlink to the
|
||||
* terminal width — measured at 80 columns, a 346-character URL arrives as five
|
||||
* 80-character pieces on five lines. Nothing scraping the visible text can put
|
||||
* those back together: the pieces share no prefix, so the "extends the current
|
||||
* pick" rule cannot join them, and joining adjacent lines by guesswork on an
|
||||
* untrusted stream is exactly the sort of thing the rules above exist to
|
||||
* forbid. What comes out is the first 80 characters — a URL that parses, that
|
||||
* points at claude.com, and that cannot authorise anything.
|
||||
*
|
||||
* So the backend lifts the whole URL out of the hyperlink parameter and sends
|
||||
* it on `claude-token-link`, and {@link useClaudeTokenAcquisition} prefers that.
|
||||
* This remains for CLI versions that print a bare URL with no hyperlink at all,
|
||||
* where a URL narrow enough not to wrap is recovered correctly.
|
||||
*/
|
||||
export function extractSignInUrl(text: string): string | null {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const matches = text.match(/https?:\/\/[^\s"'`<>\x00-\x20\x7f]+/g);
|
||||
if (!matches) return null;
|
||||
|
||||
// Trailing punctuation belongs to the prose, not the URL.
|
||||
return pickSignInUrl(matches.map((url) => url.replace(/[.,;:!?)\]}>'"]+$/, "")));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Token presence
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -133,6 +163,12 @@ export interface ClaudeTokenAcquisition {
|
||||
submitting: boolean;
|
||||
codeSubmitted: boolean;
|
||||
submitError: string | null;
|
||||
/**
|
||||
* How many codes `claude setup-token` has refused. Non-zero means the CLI is
|
||||
* still alive and waiting for another one — a recoverable state, not the end
|
||||
* of the flow.
|
||||
*/
|
||||
codeRejections: number;
|
||||
submitCode: (code: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
@@ -154,6 +190,13 @@ export function useClaudeTokenAcquisition(
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [codeSubmitted, setCodeSubmitted] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [codeRejections, setCodeRejections] = useState(0);
|
||||
// Candidates from `claude-token-link`, in arrival order. Kept as a list
|
||||
// rather than a single value so `pickSignInUrl` applies the same first-wins
|
||||
// rule here as it does to the scraped transcript — the CLI reprints the same
|
||||
// hyperlink after every retry, and a *different* one arriving later must not
|
||||
// be able to displace the one the user was already shown.
|
||||
const [links, setLinks] = useState<string[]>([]);
|
||||
|
||||
// Held in a ref so a fresh callback identity cannot restart the flow.
|
||||
const succeededRef = useRef(onSucceeded);
|
||||
@@ -193,6 +236,26 @@ export function useClaudeTokenAcquisition(
|
||||
: next;
|
||||
});
|
||||
});
|
||||
await register<ClaudeTokenLinkEvent>(LINK_EVENT, (payload) => {
|
||||
if (payload.project_id !== projectId) return;
|
||||
setLinks((prev) =>
|
||||
prev.includes(payload.url) || prev.length >= MAX_LINKS
|
||||
? prev
|
||||
: [...prev, payload.url],
|
||||
);
|
||||
});
|
||||
await register<ClaudeTokenCodeRejectedEvent>(
|
||||
CODE_REJECTED_EVENT,
|
||||
(payload) => {
|
||||
if (payload.project_id !== projectId) return;
|
||||
// The CLI is alive and back at its prompt, so this is a correction
|
||||
// the user can act on — not a failure. Re-open the input and say
|
||||
// why, rather than leaving "Finishing sign-in" on screen forever.
|
||||
setCodeRejections((n) => n + 1);
|
||||
setCodeSubmitted(false);
|
||||
setSubmitError(payload.message);
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
if (cancelled) return;
|
||||
setPhase("failed");
|
||||
@@ -261,7 +324,13 @@ export function useClaudeTokenAcquisition(
|
||||
}
|
||||
}, []);
|
||||
|
||||
const signInUrl = useMemo(() => extractSignInUrl(output), [output]);
|
||||
// The hyperlink parameter wins whenever there is one: it is the only place
|
||||
// the CLI emits the URL contiguously. Scraping the visible text is the
|
||||
// fallback for versions that print a bare URL — see `extractSignInUrl`.
|
||||
const signInUrl = useMemo(
|
||||
() => pickSignInUrl(links) ?? extractSignInUrl(output),
|
||||
[links, output],
|
||||
);
|
||||
|
||||
return {
|
||||
phase,
|
||||
@@ -272,6 +341,7 @@ export function useClaudeTokenAcquisition(
|
||||
submitting,
|
||||
codeSubmitted,
|
||||
submitError,
|
||||
codeRejections,
|
||||
submitCode,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user