Skip to main content

tuitbot_core/x_api/auth/
mod.rs

1//! OAuth 2.0 PKCE authentication and token management for X API.
2//!
3//! Supports two authentication modes:
4//! - **Manual**: User copies an authorization URL, pastes the code back.
5//! - **Local callback**: CLI starts a temporary HTTP server to capture the code.
6//!
7//! Token management handles persistent storage, loading, and automatic
8//! refresh before expiry.
9
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12
13use crate::error::XApiError;
14
15mod oauth;
16mod refresh;
17mod token;
18
19pub use oauth::{authenticate_callback, authenticate_manual};
20pub use refresh::TokenRefreshResponse;
21pub use token::TokenManager;
22
23// ───────────────────────────────────────────────────────────────
24
25/// X API OAuth 2.0 authorization endpoint.
26pub const AUTH_URL: &str = "https://x.com/i/oauth2/authorize";
27
28/// X API OAuth 2.0 token endpoint.
29pub const TOKEN_URL: &str = "https://api.x.com/2/oauth2/token";
30
31/// Pre-expiry refresh window in seconds (5 minutes).
32pub const REFRESH_WINDOW_SECS: i64 = 300;
33
34// ───────────────────────────────────────────────────────────────
35
36/// Stored OAuth tokens with expiration tracking.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct Tokens {
39    /// The Bearer access token.
40    pub access_token: String,
41    /// The refresh token for obtaining new access tokens.
42    pub refresh_token: String,
43    /// When the access token expires (UTC).
44    pub expires_at: DateTime<Utc>,
45    /// Granted OAuth scopes.
46    #[serde(default)]
47    pub scopes: Vec<String>,
48}
49
50// ───────────────────────────────────────────────────────────────
51
52/// Save tokens to disk as JSON with restricted permissions.
53pub fn save_tokens(tokens: &Tokens, path: &std::path::Path) -> Result<(), String> {
54    if let Some(parent) = path.parent() {
55        std::fs::create_dir_all(parent).map_err(|e| format!("Failed to create directory: {e}"))?;
56    }
57
58    let json = serde_json::to_string_pretty(tokens)
59        .map_err(|e| format!("Failed to serialize tokens: {e}"))?;
60
61    // Write token file with restricted permissions from the start (no TOCTOU window)
62    #[cfg(unix)]
63    {
64        use std::io::Write;
65        use std::os::unix::fs::OpenOptionsExt;
66        let mut file = std::fs::OpenOptions::new()
67            .write(true)
68            .create(true)
69            .truncate(true)
70            .mode(0o600)
71            .open(path)
72            .map_err(|e| format!("Failed to create token file: {e}"))?;
73        file.write_all(json.as_bytes())
74            .map_err(|e| format!("Failed to write tokens: {e}"))?;
75    }
76
77    #[cfg(not(unix))]
78    {
79        std::fs::write(path, &json).map_err(|e| format!("Failed to write tokens: {e}"))?;
80        tracing::warn!("Cannot set restrictive file permissions on non-Unix platform");
81    }
82
83    Ok(())
84}
85
86/// Load tokens from disk. Returns `None` if the file does not exist.
87pub fn load_tokens(path: &std::path::Path) -> Result<Option<Tokens>, XApiError> {
88    match std::fs::read_to_string(path) {
89        Ok(contents) => {
90            let tokens: Tokens =
91                serde_json::from_str(&contents).map_err(|e| XApiError::ApiError {
92                    status: 0,
93                    message: format!("Failed to parse tokens file: {e}"),
94                })?;
95            Ok(Some(tokens))
96        }
97        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
98        Err(e) => Err(XApiError::ApiError {
99            status: 0,
100            message: format!("Failed to read tokens file: {e}"),
101        }),
102    }
103}
104
105#[cfg(test)]
106mod tests;