feat(oauth): enable browser-paste PKCE flow for OpenAI-compatible providers

Two coordinated changes that make openai-compatible OAuth providers usable
with a non-localhost redirect_uri (browser shows the callback URL, user
copies it back into the terminal — the same UX Claude uses).

Fix: OpenAICompatibleOAuthProvider::fixed_redirect_uri() previously returned
Some(uri) for any redirect_uri including public HTTPS URLs, which trapped
run_pkce_flow into trying to bind a TCP listener on a public URL. It now
returns Some only for loopback URIs (127.0.0.1, localhost, ::1). Non-loopback
URIs return None, routing run_pkce_flow to the paste branch.

New tri-format paste parser (parse_paste_input):
- Full callback URL (starts with http:// or https://): parse code + state from
  the query string. This is what most modern OAuth providers redirect to and
  what a naive user copies from the browser bar.
- Anthropic-style code#state fragment: preserved for Claude compatibility.
- Bare code: accepted with a warning that CSRF state validation is skipped.
  For providers whose callback page shows only the code with no state.

State validation moved from mandatory to conditional — if a paste didn't
carry state (bare-code path), we warn and skip the check instead of hard-
failing. The listener path (localhost + LAN redirects) still requires state
because the server sends it in the query.

Adds 9 unit tests covering both changes.
This commit is contained in:
2026-07-21 11:14:55 -06:00
parent cab1e72b97
commit 3aede58a11
2 changed files with 154 additions and 15 deletions
+12 -1
View File
@@ -5,6 +5,13 @@ pub struct OpenAICompatibleOAuthProvider {
pub client_name: String,
}
fn is_loopback_uri(uri: &str) -> bool {
url::Url::parse(uri)
.ok()
.and_then(|u| u.host_str().map(str::to_string))
.is_some_and(|host| matches!(host.as_str(), "127.0.0.1" | "localhost" | "[::1]" | "::1"))
}
impl OAuthProvider for OpenAICompatibleOAuthProvider {
fn provider_name(&self) -> &str {
&self.client_name
@@ -70,7 +77,11 @@ impl OAuthProvider for OpenAICompatibleOAuthProvider {
fn fixed_redirect_uri(&self) -> Option<String> {
if let Some(uri) = &self.config.redirect_uri {
return Some(uri.clone());
return if is_loopback_uri(uri) {
Some(uri.clone())
} else {
None
};
}
if let Some(port) = self.config.redirect_port {
return Some(format!("http://127.0.0.1:{port}/callback"));