Skip to main content

tuitbot_core/x_api/auth/
oauth.rs

1//! OAuth 2.0 PKCE client setup and authentication flows.
2
3use std::io::Write;
4
5use oauth2::basic::BasicClient;
6use oauth2::{
7    AuthUrl, AuthorizationCode, ClientId, CsrfToken, PkceCodeChallenge, RedirectUrl, Scope,
8    TokenResponse, TokenUrl,
9};
10
11use crate::error::XApiError;
12use crate::x_api::scopes::REQUIRED_SCOPES;
13
14use super::{Tokens, AUTH_URL, TOKEN_URL};
15
16/// Build the OAuth 2.0 PKCE client with the given configuration.
17pub(crate) fn build_oauth_client(
18    client_id: &str,
19    redirect_uri: &str,
20) -> Result<BasicClient, XApiError> {
21    let auth_url = AuthUrl::new(AUTH_URL.to_string()).map_err(|e| XApiError::ApiError {
22        status: 0,
23        message: format!("Invalid auth URL: {e}"),
24    })?;
25
26    let token_url = TokenUrl::new(TOKEN_URL.to_string()).map_err(|e| XApiError::ApiError {
27        status: 0,
28        message: format!("Invalid token URL: {e}"),
29    })?;
30
31    let redirect = RedirectUrl::new(redirect_uri.to_string()).map_err(|e| XApiError::ApiError {
32        status: 0,
33        message: format!("Invalid redirect URI: {e}"),
34    })?;
35
36    let client = BasicClient::new(
37        ClientId::new(client_id.to_string()),
38        None,
39        auth_url,
40        Some(token_url),
41    )
42    .set_redirect_uri(redirect);
43
44    Ok(client)
45}
46
47/// Perform OAuth 2.0 PKCE authentication in manual mode.
48///
49/// Prints the authorization URL and prompts the user to paste the
50/// authorization code from the callback URL. Exchanges the code for tokens.
51pub async fn authenticate_manual(client_id: &str) -> Result<Tokens, XApiError> {
52    let redirect_uri = "http://localhost/callback";
53    let client = build_oauth_client(client_id, redirect_uri)?;
54
55    let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
56
57    let mut auth_builder = client
58        .authorize_url(CsrfToken::new_random)
59        .set_pkce_challenge(pkce_challenge);
60    for scope in REQUIRED_SCOPES {
61        auth_builder = auth_builder.add_scope(Scope::new(scope.to_string()));
62    }
63    let (auth_url, csrf_state) = auth_builder.url();
64
65    println!("\n=== X API Authentication (Manual Mode) ===\n");
66    println!("1. Open this URL in your browser:\n");
67    println!("   {auth_url}\n");
68    println!("2. Authorize the application");
69    println!("3. Copy the authorization code from the callback URL");
70    println!("   (Look for ?code=XXXXX in the URL)\n");
71
72    let _ = csrf_state; // State validation not applicable in manual mode
73
74    print!("Paste the authorization code: ");
75    std::io::stdout().flush().map_err(|e| XApiError::ApiError {
76        status: 0,
77        message: format!("IO error: {e}"),
78    })?;
79
80    let mut code = String::new();
81    std::io::stdin()
82        .read_line(&mut code)
83        .map_err(|e| XApiError::ApiError {
84            status: 0,
85            message: format!("Failed to read input: {e}"),
86        })?;
87
88    let code = code.trim().to_string();
89    if code.is_empty() {
90        return Err(XApiError::ApiError {
91            status: 0,
92            message: "Authorization code cannot be empty".to_string(),
93        });
94    }
95
96    exchange_code(&client, &code, pkce_verifier).await
97}
98
99/// Perform OAuth 2.0 PKCE authentication with a local callback server.
100///
101/// Starts a temporary HTTP server, opens the browser to the authorization URL,
102/// and captures the callback with the authorization code automatically.
103pub async fn authenticate_callback(
104    client_id: &str,
105    host: &str,
106    port: u16,
107) -> Result<Tokens, XApiError> {
108    let redirect_uri = format!("http://{host}:{port}/callback");
109    let client = build_oauth_client(client_id, &redirect_uri)?;
110
111    let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
112
113    let mut auth_builder = client
114        .authorize_url(CsrfToken::new_random)
115        .set_pkce_challenge(pkce_challenge);
116    for scope in REQUIRED_SCOPES {
117        auth_builder = auth_builder.add_scope(Scope::new(scope.to_string()));
118    }
119    let (auth_url, csrf_state) = auth_builder.url();
120
121    // Start the temporary callback server
122    let addr = format!("{host}:{port}");
123    let listener = tokio::net::TcpListener::bind(&addr)
124        .await
125        .map_err(|e| XApiError::ApiError {
126            status: 0,
127            message: format!(
128                "Failed to bind callback server on {addr}: {e}. Try changing auth.callback_port."
129            ),
130        })?;
131
132    tracing::info!("Callback server listening on {addr}");
133
134    // Open the browser
135    let url_str = auth_url.to_string();
136    if let Err(e) = open::that(&url_str) {
137        tracing::warn!(error = %e, "Failed to open browser automatically");
138        println!("\nCould not open browser automatically.");
139        println!("Please open this URL manually:\n");
140        println!("   {url_str}\n");
141    } else {
142        println!("\nOpened authorization URL in your browser.");
143        println!("Waiting for callback...\n");
144    }
145
146    // Wait for the callback with a timeout
147    let callback_result = tokio::time::timeout(
148        std::time::Duration::from_secs(120),
149        accept_callback(&listener, csrf_state.secret()),
150    )
151    .await
152    .map_err(|_| XApiError::ApiError {
153        status: 0,
154        message: "Authentication timed out after 120 seconds".to_string(),
155    })??;
156
157    exchange_code(&client, &callback_result, pkce_verifier).await
158}
159
160/// Accept a single HTTP callback and extract the authorization code.
161async fn accept_callback(
162    listener: &tokio::net::TcpListener,
163    expected_state: &str,
164) -> Result<String, XApiError> {
165    let (mut stream, _addr) = listener.accept().await.map_err(|e| XApiError::ApiError {
166        status: 0,
167        message: format!("Failed to accept connection: {e}"),
168    })?;
169
170    use tokio::io::{AsyncReadExt, AsyncWriteExt};
171
172    let mut buf = vec![0u8; 4096];
173    let n = stream
174        .read(&mut buf)
175        .await
176        .map_err(|e| XApiError::ApiError {
177            status: 0,
178            message: format!("Failed to read request: {e}"),
179        })?;
180
181    let request = String::from_utf8_lossy(&buf[..n]);
182
183    // Parse the first line: GET /callback?code=XXX&state=YYY HTTP/1.1
184    let first_line = request.lines().next().unwrap_or("");
185    let path = first_line.split_whitespace().nth(1).unwrap_or("");
186
187    let query_start = path.find('?').map(|i| i + 1);
188    let query_string = query_start.map(|i| &path[i..]).unwrap_or("");
189
190    let mut code = None;
191    let mut state = None;
192
193    for param in query_string.split('&') {
194        if let Some((key, value)) = param.split_once('=') {
195            match key {
196                "code" => code = Some(value.to_string()),
197                "state" => state = Some(value.to_string()),
198                _ => {}
199            }
200        }
201    }
202
203    // Validate state (required for CSRF protection)
204    let received_state = state.ok_or_else(|| XApiError::ApiError {
205        status: 0,
206        message: "Missing OAuth state parameter in callback".to_string(),
207    })?;
208    if received_state != expected_state {
209        let error_html = "HTTP/1.1 400 Bad Request\r\nContent-Type: text/html\r\n\r\n\
210            <html><body><h1>Authentication Failed</h1>\
211            <p>State parameter mismatch. This may indicate a CSRF attack.</p>\
212            <p>Please try again.</p></body></html>";
213        let _ = stream.write_all(error_html.as_bytes()).await;
214        return Err(XApiError::ApiError {
215            status: 0,
216            message: "OAuth state parameter mismatch".to_string(),
217        });
218    }
219
220    let auth_code = code.ok_or_else(|| XApiError::ApiError {
221        status: 0,
222        message: "No authorization code in callback URL".to_string(),
223    })?;
224
225    // Send success response
226    let success_html = "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n\
227        <html><body><h1>Authentication Successful!</h1>\
228        <p>You can close this tab and return to the terminal.</p></body></html>";
229    let _ = stream.write_all(success_html.as_bytes()).await;
230
231    Ok(auth_code)
232}
233
234/// Exchange an authorization code for tokens using the PKCE verifier.
235async fn exchange_code(
236    client: &BasicClient,
237    code: &str,
238    pkce_verifier: oauth2::PkceCodeVerifier,
239) -> Result<Tokens, XApiError> {
240    let http_client = oauth2::reqwest::async_http_client;
241
242    let token_result = client
243        .exchange_code(AuthorizationCode::new(code.to_string()))
244        .set_pkce_verifier(pkce_verifier)
245        .request_async(http_client)
246        .await
247        .map_err(|e| XApiError::ApiError {
248            status: 0,
249            message: format!("Token exchange failed: {e}"),
250        })?;
251
252    let access_token = token_result.access_token().secret().to_string();
253    let refresh_token = token_result
254        .refresh_token()
255        .map(|rt| rt.secret().to_string())
256        .unwrap_or_default();
257
258    let expires_in = token_result
259        .expires_in()
260        .map(|d| d.as_secs() as i64)
261        .unwrap_or(7200);
262
263    let scopes: Vec<String> = token_result
264        .scopes()
265        .map(|s| s.iter().map(|scope| scope.to_string()).collect())
266        .unwrap_or_else(|| REQUIRED_SCOPES.iter().map(|s| s.to_string()).collect());
267
268    let tokens = Tokens {
269        access_token,
270        refresh_token,
271        expires_at: chrono::Utc::now() + chrono::Duration::seconds(expires_in),
272        scopes,
273    };
274
275    tracing::info!(
276        expires_at = %tokens.expires_at,
277        scopes = ?tokens.scopes,
278        "Authentication successful"
279    );
280
281    Ok(tokens)
282}