bitbucket_cli/auth/
oauth.rs

1use anyhow::{Context, Result};
2use oauth2::basic::BasicClient;
3use oauth2::{
4    AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, PkceCodeChallenge, 
5    RedirectUrl, RefreshToken, Scope, TokenResponse, TokenUrl,
6};
7use std::io::{BufRead, BufReader, Write};
8use std::net::TcpListener;
9
10use super::{AuthManager, Credential};
11
12/// Async HTTP client for OAuth2 token exchange
13async fn async_http_client(
14    request: oauth2::HttpRequest,
15) -> Result<oauth2::HttpResponse, reqwest::Error> {
16    let client = reqwest::Client::builder()
17        .redirect(reqwest::redirect::Policy::none())
18        .build()?;
19
20    let mut request_builder = client
21        .request(request.method().clone(), request.uri().to_string())
22        .body(request.body().clone());
23
24    for (name, value) in request.headers() {
25        request_builder = request_builder.header(name.as_str(), value.as_bytes());
26    }
27
28    let response = request_builder.send().await?;
29
30    let status_code = response.status();
31    let headers = response.headers().to_owned();
32    let body = response.bytes().await?.to_vec();
33
34    let mut builder = oauth2::http::Response::builder()
35        .status(status_code);
36    
37    for (name, value) in headers.iter() {
38        builder = builder.header(name, value);
39    }
40    
41    // Build the response - this should never fail with valid HTTP data
42    Ok(builder.body(body).expect("Failed to build HTTP response"))
43}
44
45const BITBUCKET_AUTH_URL: &str = "https://bitbucket.org/site/oauth2/authorize";
46const BITBUCKET_TOKEN_URL: &str = "https://bitbucket.org/site/oauth2/access_token";
47
48/// OAuth 2.0 authentication flow
49pub struct OAuthFlow {
50    client_id: String,
51    client_secret: String,
52}
53
54impl OAuthFlow {
55    pub fn new(client_id: String, client_secret: String) -> Self {
56        Self {
57            client_id,
58            client_secret,
59        }
60    }
61
62    /// Run the OAuth 2.0 authentication flow
63    pub async fn authenticate(&self, auth_manager: &AuthManager) -> Result<Credential> {
64        println!("\nšŸ” Bitbucket OAuth Authentication");
65        println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
66        println!();
67
68        // Find an available port for the callback server
69        let listener =
70            TcpListener::bind("127.0.0.1:0").context("Failed to bind callback server")?;
71        let port = listener.local_addr()?.port();
72        let redirect_url = format!("http://127.0.0.1:{}/callback", port);
73
74        // Create OAuth client
75        let client = BasicClient::new(ClientId::new(self.client_id.clone()))
76            .set_client_secret(ClientSecret::new(self.client_secret.clone()))
77            .set_auth_uri(AuthUrl::new(BITBUCKET_AUTH_URL.to_string())?)
78            .set_token_uri(TokenUrl::new(BITBUCKET_TOKEN_URL.to_string())?)
79            .set_redirect_uri(RedirectUrl::new(redirect_url.clone())?);
80
81        // Generate PKCE challenge
82        let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
83
84        // Generate authorization URL
85        let (auth_url, csrf_token) = client
86            .authorize_url(|| CsrfToken::new_random())
87            .add_scope(Scope::new("repository".to_string()))
88            .add_scope(Scope::new("pullrequest".to_string()))
89            .add_scope(Scope::new("issue".to_string()))
90            .add_scope(Scope::new("pipeline".to_string()))
91            .add_scope(Scope::new("account".to_string()))
92            .set_pkce_challenge(pkce_challenge)
93            .url();
94
95        println!("Opening browser for authentication...");
96        println!();
97
98        // Try to open browser
99        if open::that(auth_url.as_str()).is_err() {
100            println!("Could not open browser automatically.");
101            println!("Please open this URL in your browser:");
102            println!();
103            println!("  {}", auth_url);
104            println!();
105        }
106
107        println!("Waiting for authorization...");
108
109        // Wait for callback
110        let code = Self::wait_for_callback(listener, csrf_token)?;
111
112        println!("Authorization received, exchanging for token...");
113
114        // Exchange code for token
115        let token_response = client
116            .exchange_code(code)
117            .set_pkce_verifier(pkce_verifier)
118            .request_async(&async_http_client)
119            .await
120            .context("Failed to exchange authorization code for token")?;
121
122        let access_token = token_response.access_token().secret().to_string();
123        let refresh_token = token_response
124            .refresh_token()
125            .map(|t| t.secret().to_string());
126        let expires_at = token_response
127            .expires_in()
128            .map(|d| chrono::Utc::now().timestamp() + d.as_secs() as i64);
129
130        let credential = Credential::OAuth {
131            access_token,
132            refresh_token,
133            expires_at,
134        };
135
136        // Store credentials
137        auth_manager.store_credentials(&credential)?;
138
139        println!("\nāœ… Successfully authenticated via OAuth");
140
141        Ok(credential)
142    }
143
144    /// Wait for the OAuth callback and extract the authorization code
145    fn wait_for_callback(
146        listener: TcpListener,
147        expected_csrf: CsrfToken,
148    ) -> Result<AuthorizationCode> {
149        for stream in listener.incoming() {
150            let mut stream = match stream {
151                Ok(s) => s,
152                Err(_) => continue,
153            };
154
155            let mut reader = BufReader::new(&stream);
156            let mut request_line = String::new();
157            if reader.read_line(&mut request_line).is_err() {
158                continue;
159            }
160
161            // Parse the request URL
162            let Some(redirect_url) = request_line.split_whitespace().nth(1) else {
163                continue;
164            };
165
166            let Ok(url) = url::Url::parse(&format!("http://localhost{}", redirect_url)) else {
167                continue;
168            };
169
170            let mut code = None;
171            let mut state = None;
172
173            for (key, value) in url.query_pairs() {
174                match key.as_ref() {
175                    "code" => code = Some(AuthorizationCode::new(value.to_string())),
176                    "state" => state = Some(CsrfToken::new(value.to_string())),
177                    _ => {}
178                }
179            }
180
181            // Verify CSRF token
182            if let Some(ref state) = state {
183                if state.secret() != expected_csrf.secret() {
184                    let response = "HTTP/1.1 400 Bad Request\r\n\r\nCSRF token mismatch";
185                    let _ = stream.write_all(response.as_bytes());
186                    continue;
187                }
188            }
189
190            // Send success response
191            let response = r#"HTTP/1.1 200 OK
192Content-Type: text/html
193
194<!DOCTYPE html>
195<html>
196<head><title>Bitbucket CLI</title></head>
197<body style="font-family: system-ui; text-align: center; padding: 50px;">
198<h1>āœ… Authentication Successful</h1>
199<p>You can close this window and return to the terminal.</p>
200</body>
201</html>"#;
202            let _ = stream.write_all(response.as_bytes());
203
204            if let Some(code) = code {
205                return Ok(code);
206            }
207        }
208
209        anyhow::bail!("Callback server closed unexpectedly")
210    }
211
212    /// Refresh an expired OAuth token
213    pub async fn refresh_token(
214        &self,
215        auth_manager: &AuthManager,
216        refresh_token: &str,
217    ) -> Result<Credential> {
218        let client = BasicClient::new(ClientId::new(self.client_id.clone()))
219            .set_client_secret(ClientSecret::new(self.client_secret.clone()))
220            .set_auth_uri(AuthUrl::new(BITBUCKET_AUTH_URL.to_string())?)
221            .set_token_uri(TokenUrl::new(BITBUCKET_TOKEN_URL.to_string())?);
222
223
224        let token_response = client
225            .exchange_refresh_token(&RefreshToken::new(refresh_token.to_string()))
226            .request_async(&async_http_client)
227            .await
228            .context("Failed to refresh token")?;
229
230        let access_token = token_response.access_token().secret().to_string();
231        let new_refresh_token = token_response
232            .refresh_token()
233            .map(|t| t.secret().to_string())
234            .unwrap_or_else(|| refresh_token.to_string());
235        let expires_at = token_response
236            .expires_in()
237            .map(|d| chrono::Utc::now().timestamp() + d.as_secs() as i64);
238
239        let credential = Credential::OAuth {
240            access_token,
241            refresh_token: Some(new_refresh_token),
242            expires_at,
243        };
244
245        auth_manager.store_credentials(&credential)?;
246
247        Ok(credential)
248    }
249}