bitbucket_cli/auth/
oauth.rs1use 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
16pub 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 pub async fn authenticate(&self, auth_manager: &AuthManager) -> Result<Credential> {
32 println!("\nš Bitbucket OAuth Authentication");
33 println!("āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā");
34 println!();
35
36 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 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 let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
53
54 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 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 let code = Self::wait_for_callback(listener, csrf_token)?;
81
82 println!("Authorization received, exchanging for token...");
83
84 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 auth_manager.store_credentials(&credential)?;
108
109 println!("\nā
Successfully authenticated via OAuth");
110
111 Ok(credential)
112 }
113
114 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 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 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 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 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}