bitbucket_cli/auth/
oauth.rs1use anyhow::{Context, Result};
2use oauth2::basic::BasicClient;
3use oauth2::{
4 AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, PkceCodeChallenge, RedirectUrl,
5 RefreshToken, Scope, TokenResponse, TokenUrl,
6};
7use std::io::{BufRead, BufReader, Write};
8use std::net::TcpListener;
9
10use super::{AuthManager, Credential};
11
12async 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().status(status_code);
35
36 for (name, value) in headers.iter() {
37 builder = builder.header(name, value);
38 }
39
40 Ok(builder.body(body).expect("Failed to build HTTP response"))
42}
43
44const BITBUCKET_AUTH_URL: &str = "https://bitbucket.org/site/oauth2/authorize";
45const BITBUCKET_TOKEN_URL: &str = "https://bitbucket.org/site/oauth2/access_token";
46
47pub struct OAuthFlow {
49 client_id: String,
50 client_secret: String,
51}
52
53impl OAuthFlow {
54 pub fn new(client_id: String, client_secret: String) -> Self {
55 Self {
56 client_id,
57 client_secret,
58 }
59 }
60
61 fn bind_to_available_port(ports: &[u16]) -> Result<(TcpListener, u16)> {
63 for &port in ports {
64 match TcpListener::bind(format!("127.0.0.1:{}", port)) {
65 Ok(listener) => {
66 return Ok((listener, port));
67 }
68 Err(_) => continue,
69 }
70 }
71
72 anyhow::bail!(
73 "Could not bind to any preferred port. Tried: {:?}\n\n\
74 Please ensure at least one of these ports is available:\n\
75 - Close any applications using these ports\n\
76 - Or use API key authentication: bitbucket auth login --api-key",
77 ports
78 )
79 }
80
81 pub async fn authenticate(&self, auth_manager: &AuthManager) -> Result<Credential> {
83 println!("\nš Bitbucket OAuth Authentication");
84 println!("āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā");
85 println!();
86
87 const PREFERRED_PORTS: &[u16] = &[8080, 3000, 8888, 9000];
90
91 let (listener, port) = Self::bind_to_available_port(PREFERRED_PORTS)
92 .context("Failed to bind callback server. Please ensure one of these ports is available: 8080, 3000, 8888, or 9000")?;
93
94 let redirect_url = format!("http://127.0.0.1:{}/callback", port);
95
96 println!("š” Callback server listening on port {}", port);
97 println!(" Make sure your OAuth consumer callback URL is set to:");
98 println!(" {}", redirect_url);
99 println!();
100
101 let client = BasicClient::new(ClientId::new(self.client_id.clone()))
103 .set_client_secret(ClientSecret::new(self.client_secret.clone()))
104 .set_auth_uri(AuthUrl::new(BITBUCKET_AUTH_URL.to_string())?)
105 .set_token_uri(TokenUrl::new(BITBUCKET_TOKEN_URL.to_string())?)
106 .set_redirect_uri(RedirectUrl::new(redirect_url.clone())?);
107
108 let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
110
111 let (auth_url, csrf_token) = client
113 .authorize_url(CsrfToken::new_random)
114 .add_scope(Scope::new("repository".to_string()))
115 .add_scope(Scope::new("pullrequest".to_string()))
116 .add_scope(Scope::new("issue".to_string()))
117 .add_scope(Scope::new("pipeline".to_string()))
118 .add_scope(Scope::new("account".to_string()))
119 .set_pkce_challenge(pkce_challenge)
120 .url();
121
122 println!("Opening browser for authentication...");
123 println!();
124
125 if open::that(auth_url.as_str()).is_err() {
127 println!("Could not open browser automatically.");
128 println!("Please open this URL in your browser:");
129 println!();
130 println!(" {}", auth_url);
131 println!();
132 }
133
134 println!("Waiting for authorization...");
135
136 let code = Self::wait_for_callback(listener, csrf_token)?;
138
139 println!("Authorization received, exchanging for token...");
140
141 let token_response = client
143 .exchange_code(code)
144 .set_pkce_verifier(pkce_verifier)
145 .request_async(&async_http_client)
146 .await
147 .context("Failed to exchange authorization code for token")?;
148
149 let access_token = token_response.access_token().secret().to_string();
150 let refresh_token = token_response
151 .refresh_token()
152 .map(|t| t.secret().to_string());
153 let expires_at = token_response
154 .expires_in()
155 .map(|d| chrono::Utc::now().timestamp() + d.as_secs() as i64);
156
157 let credential = Credential::OAuth {
158 access_token,
159 refresh_token,
160 expires_at,
161 };
162
163 auth_manager.store_credentials(&credential)?;
165
166 println!("\nā
Successfully authenticated via OAuth");
167
168 Ok(credential)
169 }
170
171 fn wait_for_callback(
173 listener: TcpListener,
174 expected_csrf: CsrfToken,
175 ) -> Result<AuthorizationCode> {
176 for stream in listener.incoming() {
177 let mut stream = match stream {
178 Ok(s) => s,
179 Err(_) => continue,
180 };
181
182 let mut reader = BufReader::new(&stream);
183 let mut request_line = String::new();
184 if reader.read_line(&mut request_line).is_err() {
185 continue;
186 }
187
188 let Some(redirect_url) = request_line.split_whitespace().nth(1) else {
190 continue;
191 };
192
193 let Ok(url) = url::Url::parse(&format!("http://localhost{}", redirect_url)) else {
194 continue;
195 };
196
197 let mut code = None;
198 let mut state = None;
199
200 for (key, value) in url.query_pairs() {
201 match key.as_ref() {
202 "code" => code = Some(AuthorizationCode::new(value.to_string())),
203 "state" => state = Some(CsrfToken::new(value.to_string())),
204 _ => {}
205 }
206 }
207
208 if let Some(ref state) = state {
210 if state.secret() != expected_csrf.secret() {
211 let response = "HTTP/1.1 400 Bad Request\r\n\r\nCSRF token mismatch";
212 let _ = stream.write_all(response.as_bytes());
213 continue;
214 }
215 }
216
217 let response = r#"HTTP/1.1 200 OK
219Content-Type: text/html
220
221<!DOCTYPE html>
222<html>
223<head><title>Bitbucket CLI</title></head>
224<body style="font-family: system-ui; text-align: center; padding: 50px;">
225<h1>ā
Authentication Successful</h1>
226<p>You can close this window and return to the terminal.</p>
227</body>
228</html>"#;
229 let _ = stream.write_all(response.as_bytes());
230
231 if let Some(code) = code {
232 return Ok(code);
233 }
234 }
235
236 anyhow::bail!("Callback server closed unexpectedly")
237 }
238
239 pub async fn refresh_token(
241 &self,
242 auth_manager: &AuthManager,
243 refresh_token: &str,
244 ) -> Result<Credential> {
245 let client = BasicClient::new(ClientId::new(self.client_id.clone()))
246 .set_client_secret(ClientSecret::new(self.client_secret.clone()))
247 .set_auth_uri(AuthUrl::new(BITBUCKET_AUTH_URL.to_string())?)
248 .set_token_uri(TokenUrl::new(BITBUCKET_TOKEN_URL.to_string())?);
249
250 let token_response = client
251 .exchange_refresh_token(&RefreshToken::new(refresh_token.to_string()))
252 .request_async(&async_http_client)
253 .await
254 .context("Failed to refresh token")?;
255
256 let access_token = token_response.access_token().secret().to_string();
257 let new_refresh_token = token_response
258 .refresh_token()
259 .map(|t| t.secret().to_string())
260 .unwrap_or_else(|| refresh_token.to_string());
261 let expires_at = token_response
262 .expires_in()
263 .map(|d| chrono::Utc::now().timestamp() + d.as_secs() as i64);
264
265 let credential = Credential::OAuth {
266 access_token,
267 refresh_token: Some(new_refresh_token),
268 expires_at,
269 };
270
271 auth_manager.store_credentials(&credential)?;
272
273 Ok(credential)
274 }
275}