bitbucket_cli/auth/
oauth.rs

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