inaturalist_oauth/
lib.rs

1//! This crate provides a simple way to obtain an iNaturalist API token using the OAuth2
2//! authorization flow. It handles the process of opening a web browser for user
3//! authorization, running a temporary local server to catch the redirect, and
4//! exchanging the authorization code for an API token.
5//!
6//! ## Usage
7//!
8//! ```no_run
9//! use inaturalist_oauth::Authenticator;
10//!
11//! #[tokio::main]
12//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
13//!     let client_id = "YOUR_CLIENT_ID".to_string();
14//!     let client_secret = "YOUR_CLIENT_SECRET".to_string();
15//!
16//!     let token_details = Authenticator::new(client_id, client_secret)
17//!         .with_redirect_server_port(8081)
18//!         .get_api_token()
19//!         .await?;
20//!     println!("Got iNaturalist API token: {}", token_details.api_token);
21//!     Ok(())
22//! }
23//! ```
24use oauth2::basic::BasicClient;
25use oauth2::http::{HeaderMap, HeaderValue, Method};
26use oauth2::{
27    AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, PkceCodeChallenge,
28    PkceCodeVerifier, RedirectUrl, TokenResponse, TokenUrl,
29};
30use serde::{Deserialize, Serialize};
31use std::io::{Read, Write};
32use std::net::{TcpListener, TcpStream};
33use std::time::{Duration, SystemTime};
34use url::Url;
35
36/// Contains the iNaturalist API token and its expiration details.
37///
38/// While the iNaturalist API token itself doesn't expire, the OAuth2 access token
39/// used to obtain it does (typically after 24 hours). This struct includes an
40/// `expires_at` field to indicate when the original access token expires.
41/// This can be useful for prompting the user to re-authenticate if you want to
42/// ensure the entire authentication flow is periodically re-validated.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct TokenDetails {
45    /// The long-lived iNaturalist API token.
46    pub api_token: String,
47    /// The time when the underlying OAuth2 access token expires.
48    pub expires_at: SystemTime,
49}
50
51/// Information needed to complete the authorization process after the user is redirected.
52#[derive(Debug, Clone)]
53pub struct AuthorizationInfo {
54    /// The URL the user should be redirected to for authorization.
55    pub url: Url,
56    /// The PKCE verifier that must be used when exchanging the authorization code.
57    pub pkce_verifier: PkceVerifier,
58}
59
60/// A wrapper for the PKCE verifier to ensure type safety.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct PkceVerifier(String);
63
64impl PkceVerifier {
65    /// Creates a new `PkceVerifier`.
66    pub fn new(secret: String) -> Self {
67        Self(secret)
68    }
69}
70
71/// Handles the iNaturalist OAuth2 flow to obtain an API token.
72///
73/// This struct is used to configure the authenticator and initiate the OAuth2 flow.
74pub struct Authenticator {
75    client_id: ClientId,
76    client_secret: ClientSecret,
77    port: u16,
78}
79
80impl Authenticator {
81    /// Creates a new `Authenticator`.
82    ///
83    /// # Arguments
84    ///
85    /// * `client_id` - The iNaturalist application's client ID.
86    /// * `client_secret` - The iNaturalist application's client secret.
87    pub fn new(client_id: String, client_secret: String) -> Self {
88        Self {
89            client_id: ClientId::new(client_id),
90            client_secret: ClientSecret::new(client_secret),
91            port: 8080,
92        }
93    }
94
95    /// Sets the port for the local redirect server.
96    ///
97    /// The default port is 8080.
98    pub fn with_redirect_server_port(mut self, port: u16) -> Self {
99        self.port = port;
100        self
101    }
102
103    /// Generates the authorization URL and PKCE verifier.
104    ///
105    /// This is the first step in the OAuth2 flow. The user should be redirected
106    /// to the returned URL. The `pkce_verifier` must be stored and used when
107    /// calling `exchange_code`.
108    pub fn authorization_url(&self) -> Result<AuthorizationInfo, Box<dyn std::error::Error>> {
109        let redirect_url = format!("http://localhost:{}", self.port);
110        let client = self.client(&redirect_url)?;
111
112        // Generate a PKCE (Proof Key for Code Exchange) challenge to secure the flow.
113        let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
114
115        // Construct the authorization URL that the user will visit.
116        let (auth_url, _csrf_token) = client
117            .authorize_url(CsrfToken::new_random)
118            .set_pkce_challenge(pkce_challenge)
119            .url();
120
121        Ok(AuthorizationInfo {
122            url: auth_url,
123            pkce_verifier: PkceVerifier::new(pkce_verifier.secret().to_string()),
124        })
125    }
126
127    /// Exchanges an authorization code for an iNaturalist API token.
128    ///
129    /// This is the final step in the OAuth2 flow. The `code` is obtained from
130    /// the iNaturalist redirect, and `pkce_verifier` is the one generated by
131    /// `authorization_url`.
132    pub async fn exchange_code(
133        &self,
134        code: AuthorizationCode,
135        pkce_verifier: PkceVerifier,
136    ) -> Result<TokenDetails, Box<dyn std::error::Error>> {
137        let redirect_url = format!("http://localhost:{}", self.port);
138        let client = self.client(&redirect_url)?;
139        let pkce_verifier = PkceCodeVerifier::new(pkce_verifier.0);
140
141        // Exchange the authorization code for an OAuth2 access token.
142        let token_response = client
143            .exchange_code(code)
144            .set_pkce_verifier(pkce_verifier)
145            .request_async(oauth2::reqwest::async_http_client)
146            .await?;
147
148        let token_string = token_response.access_token().secret();
149        log::info!("OAuth access token: {token_string}");
150
151        // Use the access token to request a long-lived iNaturalist API token.
152        let response = self.fetch_api_token(token_string).await?;
153        log::info!("OAuth API token: {}", response.api_token);
154
155        // Return the API token and calculated expiration time.
156        let expires_at = SystemTime::now() + Duration::from_secs(24 * 60 * 60);
157        Ok(TokenDetails {
158            api_token: response.api_token,
159            expires_at,
160        })
161    }
162
163    /// Listens for the redirect from iNaturalist and extracts the authorization code.
164    ///
165    /// This method starts a temporary local server to catch the redirect from
166    /// iNaturalist after the user has authorized the application. It's a
167    /// blocking call that waits for the redirect and returns the authorization
168    /// code.
169    pub fn listen_for_redirect(&self) -> Result<AuthorizationCode, Box<dyn std::error::Error>> {
170        // Set up a local TCP listener to act as the redirect URI.
171        let listener = TcpListener::bind(("127.0.0.1", self.port))?;
172
173        // Listen for the redirect from iNaturalist, which will contain the authorization code.
174        self.listen_for_code(listener)
175    }
176
177    /// Initiates the OAuth2 flow to get an iNaturalist API token.
178    ///
179    /// This method opens the user's web browser to the iNaturalist authorization page.
180    /// After the user authorizes the application, it completes the OAuth2 flow,
181    /// obtains an access token, and then exchanges it for a long-lived API token.
182    pub async fn get_api_token(self) -> Result<TokenDetails, Box<dyn std::error::Error>> {
183        // 1. Generate authorization URL and PKCE verifier.
184        let auth_info = self.authorization_url()?;
185
186        // 2. Open the URL in the user's default web browser.
187        log::info!("Opening browser to: {}", auth_info.url);
188        opener::open(auth_info.url.to_string())?;
189
190        // 3. Listen for the redirect from iNaturalist, which will contain the authorization code.
191        let code = self.listen_for_redirect()?;
192
193        // 4. Exchange the authorization code for an API token.
194        self.exchange_code(code, auth_info.pkce_verifier).await
195    }
196
197    /// Listens for and handles incoming HTTP requests to extract the authorization code.
198    fn listen_for_code(
199        &self,
200        listener: TcpListener,
201    ) -> Result<AuthorizationCode, Box<dyn std::error::Error>> {
202        for stream in listener.incoming() {
203            match stream {
204                Ok(stream) => {
205                    // Attempt to handle the connection. If we get a code, we're done.
206                    if let Ok(code) = self.handle_connection(stream) {
207                        return Ok(code);
208                    }
209                    // If handle_connection failed, an error response was already sent
210                    // to the browser. We can continue listening for another request
211                    // (e.g., from browser retries or favicon requests).
212                }
213                Err(e) => {
214                    log::error!("Failed to accept connection: {e}");
215                }
216            }
217        }
218        Err("Server closed before receiving authorization code".into())
219    }
220
221    /// Handles a single incoming TCP connection, parsing the request and sending a response.
222    fn handle_connection(
223        &self,
224        mut stream: TcpStream,
225    ) -> Result<AuthorizationCode, Box<dyn std::error::Error>> {
226        let mut buffer = [0; 1024];
227        stream.read(&mut buffer)?;
228
229        match self.parse_code_from_request(&buffer) {
230            Ok(code) => {
231                let message = "<h1>Success!</h1><p>You can close this window now.</p>";
232                self.send_response(&mut stream, "200 OK", message)?;
233                Ok(code)
234            }
235            Err(e) => {
236                log::error!("Failed to parse code from request: {e}");
237                let message = "<h1>Error!</h1><p>Could not get authorization code from iNaturalist. Please try again.</p>";
238                self.send_response(&mut stream, "400 Bad Request", message)?;
239                Err(e)
240            }
241        }
242    }
243
244    /// Parses an HTTP request from the raw byte buffer to find the `code` query parameter.
245    fn parse_code_from_request(
246        &self,
247        buffer: &[u8],
248    ) -> Result<AuthorizationCode, Box<dyn std::error::Error>> {
249        let mut headers = [httparse::EMPTY_HEADER; 64];
250        let mut req = httparse::Request::new(&mut headers);
251        req.parse(buffer)?;
252
253        let path = req.path.ok_or("Malformed request: no path")?;
254        let url = Url::parse(&format!("http://localhost{path}"))?;
255
256        let code_pair = url
257            .query_pairs()
258            .find(|pair| pair.0 == "code")
259            .ok_or_else(|| format!("URL did not contain 'code' parameter: {url}"))?;
260
261        Ok(AuthorizationCode::new(code_pair.1.into_owned()))
262    }
263
264    /// Writes a simple HTTP response to the given stream.
265    fn send_response(
266        &self,
267        stream: &mut TcpStream,
268        status: &str,
269        body: &str,
270    ) -> std::io::Result<()> {
271        let response = format!(
272            "HTTP/1.1 {}\r\ncontent-length: {}\r\n\r\n{}",
273            status,
274            body.len(),
275            body
276        );
277        stream.write_all(response.as_bytes())
278    }
279
280    /// Fetches the long-lived iNaturalist API token using the OAuth2 access token.
281    async fn fetch_api_token(
282        &self,
283        token_string: &str,
284    ) -> Result<ApiTokenResponse, Box<dyn std::error::Error>> {
285        let mut headers = HeaderMap::new();
286        headers.append(
287            "Authorization",
288            HeaderValue::from_str(&format!("Bearer {token_string}"))?,
289        );
290        let request = oauth2::HttpRequest {
291            body: vec![],
292            headers,
293            url: "https://www.inaturalist.org/users/api_token".try_into()?,
294            method: Method::GET,
295        };
296
297        let response = oauth2::reqwest::async_http_client(request).await?;
298        Ok(serde_json::from_slice(&response.body)?)
299    }
300
301    /// Configures and returns the `oauth2::BasicClient`.
302    fn client(&self, redirect_url: &str) -> Result<BasicClient, Box<dyn std::error::Error>> {
303        let auth_url = AuthUrl::new("https://www.inaturalist.org/oauth/authorize".to_string())?;
304        let token_url = TokenUrl::new("https://www.inaturalist.org/oauth/token".to_string())?;
305
306        Ok(BasicClient::new(
307            self.client_id.clone(),
308            Some(self.client_secret.clone()),
309            auth_url,
310            Some(token_url),
311        )
312        .set_redirect_uri(RedirectUrl::new(redirect_url.to_string())?))
313    }
314}
315
316#[derive(Deserialize)]
317struct ApiTokenResponse {
318    api_token: String,
319}