Skip to main content

nea_esi/
auth.rs

1// OAuth 2.0 / EVE SSO support with PKCE.
2
3use chrono::{DateTime, Duration, Utc};
4use secrecy::{ExposeSecret, SecretString};
5use serde::Deserialize;
6use sha2::{Digest, Sha256};
7use tracing::debug;
8
9use crate::{EsiClient, EsiError, Result};
10
11// ---------------------------------------------------------------------------
12// SSO constants
13// ---------------------------------------------------------------------------
14
15const SSO_AUTH_URL: &str = "https://login.eveonline.com/v2/oauth/authorize";
16pub(crate) const SSO_TOKEN_URL: &str = "https://login.eveonline.com/v2/oauth/token";
17
18// ---------------------------------------------------------------------------
19// Types
20// ---------------------------------------------------------------------------
21
22/// Credentials for EVE SSO. Debug-safe: `client_secret` is redacted.
23#[derive(Debug, Clone)]
24pub enum EsiAppCredentials {
25    Web {
26        client_id: String,
27        client_secret: SecretString,
28    },
29    Native {
30        client_id: String,
31    },
32}
33
34impl EsiAppCredentials {
35    #[must_use]
36    pub fn client_id(&self) -> &str {
37        match self {
38            Self::Web { client_id, .. } | Self::Native { client_id } => client_id,
39        }
40    }
41}
42
43/// Returned by `authorize_url()`, consumed by `exchange_code()`.
44pub struct PkceChallenge {
45    pub authorize_url: String,
46    pub code_verifier: SecretString,
47    pub state: String,
48}
49
50/// Raw token response from ESI's token endpoint (private).
51#[derive(Deserialize)]
52struct TokenResponse {
53    access_token: SecretString,
54    expires_in: u64,
55    #[allow(dead_code)]
56    token_type: String,
57    refresh_token: SecretString,
58}
59
60/// A live token pair with expiration tracking. Debug-safe.
61#[derive(Debug, Clone)]
62pub struct EsiTokens {
63    pub access_token: SecretString,
64    pub refresh_token: SecretString,
65    pub expires_at: DateTime<Utc>,
66}
67
68impl EsiTokens {
69    /// True if the access token has expired.
70    #[must_use]
71    pub fn is_expired(&self) -> bool {
72        Utc::now() >= self.expires_at
73    }
74
75    /// True if the access token expires within 60 seconds.
76    #[must_use]
77    pub fn needs_refresh(&self) -> bool {
78        Utc::now() >= self.expires_at - Duration::seconds(60)
79    }
80}
81
82// ---------------------------------------------------------------------------
83// PKCE helpers
84// ---------------------------------------------------------------------------
85
86/// Generate a random 128-byte code verifier, base64url-encoded (no padding).
87fn generate_code_verifier() -> SecretString {
88    use rand::Rng;
89    let mut buf = [0u8; 96];
90    rand::rng().fill_bytes(&mut buf);
91    base64_url_encode(&buf).into()
92}
93
94/// Compute the S256 code challenge from a verifier.
95fn compute_code_challenge(verifier: &str) -> String {
96    let digest = Sha256::digest(verifier.as_bytes());
97    base64_url_encode(&digest)
98}
99
100/// Generate a random state parameter.
101fn generate_state() -> String {
102    use rand::Rng;
103    let mut buf = [0u8; 32];
104    rand::rng().fill_bytes(&mut buf);
105    base64_url_encode(&buf)
106}
107
108/// Base64url encode without padding (RFC 7636).
109fn base64_url_encode(input: &[u8]) -> String {
110    use base64::Engine;
111    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
112    URL_SAFE_NO_PAD.encode(input)
113}
114
115// ---------------------------------------------------------------------------
116// OAuth methods on EsiClient
117// ---------------------------------------------------------------------------
118
119impl EsiClient {
120    /// Build the EVE SSO authorization URL with PKCE.
121    ///
122    /// Returns a `PkceChallenge` containing the URL to redirect the user to,
123    /// the code verifier (needed for `exchange_code`), and the state parameter
124    /// (should be verified on callback).
125    pub fn authorize_url(&self, redirect_uri: &str, scopes: &[&str]) -> Result<PkceChallenge> {
126        let creds = self
127            .app_credentials
128            .as_ref()
129            .ok_or_else(|| EsiError::Auth("no app credentials configured".into()))?;
130
131        let code_verifier = generate_code_verifier();
132        let code_challenge = compute_code_challenge(code_verifier.expose_secret());
133        let state = generate_state();
134
135        let mut url = url::Url::parse(SSO_AUTH_URL)
136            .map_err(|e| EsiError::Auth(format!("failed to parse SSO URL: {e}")))?;
137
138        url.query_pairs_mut()
139            .append_pair("response_type", "code")
140            .append_pair("redirect_uri", redirect_uri)
141            .append_pair("client_id", creds.client_id())
142            .append_pair("scope", &scopes.join(" "))
143            .append_pair("state", &state)
144            .append_pair("code_challenge", &code_challenge)
145            .append_pair("code_challenge_method", "S256");
146
147        Ok(PkceChallenge {
148            authorize_url: url.to_string(),
149            code_verifier,
150            state,
151        })
152    }
153
154    /// Shared helper: send a token request form and parse the response.
155    async fn token_request(
156        &self,
157        form_params: &[(&str, String)],
158        error_context: &str,
159    ) -> Result<EsiTokens> {
160        let creds = self
161            .app_credentials
162            .as_ref()
163            .ok_or_else(|| EsiError::Auth("no app credentials configured".into()))?;
164
165        let request = if let EsiAppCredentials::Web { client_secret, .. } = creds {
166            self.client
167                .post(&self.sso_token_url)
168                .form(form_params)
169                .basic_auth(creds.client_id(), Some(client_secret.expose_secret()))
170        } else {
171            self.client.post(&self.sso_token_url).form(form_params)
172        };
173
174        let resp = request
175            .send()
176            .await
177            .map_err(|e| EsiError::TokenRefresh(format!("{error_context} request failed: {e}")))?;
178
179        if !resp.status().is_success() {
180            let status = resp.status().as_u16();
181            let body = resp.text().await.unwrap_or_default();
182            return Err(EsiError::TokenRefresh(format!(
183                "{error_context} failed (HTTP {status}): {body}"
184            )));
185        }
186
187        let token_resp: TokenResponse = resp
188            .json()
189            .await
190            .map_err(|e| EsiError::TokenRefresh(format!("failed to parse token response: {e}")))?;
191
192        Ok(EsiTokens {
193            access_token: token_resp.access_token,
194            refresh_token: token_resp.refresh_token,
195            expires_at: Utc::now() + Duration::seconds(token_resp.expires_in.cast_signed()),
196        })
197    }
198
199    /// Exchange an authorization code for tokens.
200    pub async fn exchange_code(
201        &self,
202        code: &str,
203        code_verifier: &SecretString,
204        redirect_uri: &str,
205    ) -> Result<EsiTokens> {
206        let creds = self
207            .app_credentials
208            .as_ref()
209            .ok_or_else(|| EsiError::Auth("no app credentials configured".into()))?;
210
211        let mut form = vec![
212            ("grant_type", "authorization_code".to_string()),
213            ("code", code.to_string()),
214            ("redirect_uri", redirect_uri.to_string()),
215            ("code_verifier", code_verifier.expose_secret().to_string()),
216        ];
217
218        // Only include client_id in the body for Native apps (Web apps send it
219        // via Basic auth in the Authorization header).
220        if matches!(creds, EsiAppCredentials::Native { .. }) {
221            form.push(("client_id", creds.client_id().to_string()));
222        }
223
224        let tokens = self.token_request(&form, "token exchange").await?;
225        *self.tokens.write().await = Some(tokens.clone());
226        debug!("token exchange complete");
227        Ok(tokens)
228    }
229
230    /// Refresh the access token using the stored refresh token.
231    ///
232    /// Uses a dedicated mutex to serialize refresh operations, preventing
233    /// concurrent callers from racing on the same refresh token.
234    pub async fn refresh_token(&self) -> Result<EsiTokens> {
235        let creds = self
236            .app_credentials
237            .as_ref()
238            .ok_or_else(|| EsiError::Auth("no app credentials configured".into()))?;
239
240        // Serialize all refresh attempts — only one network call at a time.
241        let _refresh_guard = self.refresh_mutex.lock().await;
242
243        // Double-check: another task may have already refreshed while we waited.
244        {
245            let guard = self.tokens.read().await;
246            if let Some(ref existing) = *guard
247                && !existing.needs_refresh()
248            {
249                return Ok(existing.clone());
250            }
251        }
252
253        // Read the current refresh token (read lock, released quickly).
254        let current_refresh = {
255            let guard = self.tokens.read().await;
256            guard
257                .as_ref()
258                .ok_or_else(|| EsiError::TokenRefresh("no tokens to refresh".into()))?
259                .refresh_token
260                .clone()
261        };
262
263        let mut form = vec![
264            ("grant_type", "refresh_token".to_string()),
265            ("refresh_token", current_refresh.expose_secret().to_string()),
266        ];
267
268        // Only include client_id in the body for Native apps.
269        if matches!(creds, EsiAppCredentials::Native { .. }) {
270            form.push(("client_id", creds.client_id().to_string()));
271        }
272
273        let tokens = self.token_request(&form, "token refresh").await?;
274        *self.tokens.write().await = Some(tokens.clone());
275        debug!("token refresh complete");
276        Ok(tokens)
277    }
278
279    /// Store tokens (e.g. restored from a database).
280    pub async fn set_tokens(&self, tokens: EsiTokens) {
281        *self.tokens.write().await = Some(tokens);
282    }
283
284    /// Read the current tokens.
285    pub async fn get_tokens(&self) -> Option<EsiTokens> {
286        self.tokens.read().await.clone()
287    }
288
289    /// Clear stored tokens (logout).
290    pub async fn clear_tokens(&self) {
291        *self.tokens.write().await = None;
292    }
293
294    /// Ensure we have a valid (non-expired) access token. Refreshes if needed.
295    /// Returns the access token string if available.
296    pub(crate) async fn ensure_valid_token(&self) -> Result<Option<SecretString>> {
297        let guard = self.tokens.read().await;
298        match &*guard {
299            None => Ok(None),
300            Some(tokens) => {
301                if tokens.needs_refresh() {
302                    // Drop read lock before taking write lock for refresh.
303                    drop(guard);
304                    let refreshed = self.refresh_token().await?;
305                    Ok(Some(refreshed.access_token))
306                } else {
307                    Ok(Some(tokens.access_token.clone()))
308                }
309            }
310        }
311    }
312}
313
314// ---------------------------------------------------------------------------
315// Tests
316// ---------------------------------------------------------------------------
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    #[test]
323    fn test_pkce_verifier_length() {
324        let verifier = generate_code_verifier();
325        // 96 random bytes -> 128 base64url chars
326        assert_eq!(verifier.expose_secret().len(), 128);
327    }
328
329    #[test]
330    fn test_pkce_challenge_is_sha256() {
331        let verifier = generate_code_verifier();
332        let challenge = compute_code_challenge(verifier.expose_secret());
333
334        // Manually compute expected challenge.
335        let digest = Sha256::digest(verifier.expose_secret().as_bytes());
336        let expected = base64_url_encode(&digest);
337        assert_eq!(challenge, expected);
338    }
339
340    #[test]
341    fn test_state_is_nonempty() {
342        let state = generate_state();
343        assert!(!state.is_empty());
344    }
345
346    #[test]
347    fn test_authorize_url_params() {
348        let client = EsiClient::with_native_app("test-agent", "my-client-id").unwrap();
349        let challenge = client
350            .authorize_url(
351                "http://localhost:8080/callback",
352                &["esi-wallet.read_character_wallet.v1"],
353            )
354            .unwrap();
355
356        let parsed = url::Url::parse(&challenge.authorize_url).unwrap();
357        let params: std::collections::HashMap<_, _> = parsed.query_pairs().collect();
358
359        assert_eq!(params.get("response_type").unwrap(), "code");
360        assert_eq!(params.get("client_id").unwrap(), "my-client-id");
361        assert_eq!(
362            params.get("redirect_uri").unwrap(),
363            "http://localhost:8080/callback"
364        );
365        assert_eq!(
366            params.get("scope").unwrap(),
367            "esi-wallet.read_character_wallet.v1"
368        );
369        assert_eq!(params.get("code_challenge_method").unwrap(), "S256");
370        assert!(params.contains_key("code_challenge"));
371        assert!(params.contains_key("state"));
372        assert_eq!(params.get("state").unwrap(), &challenge.state);
373    }
374
375    #[test]
376    fn test_authorize_url_without_credentials() {
377        let client = EsiClient::new();
378        let result = client.authorize_url("http://localhost", &[]);
379        assert!(result.is_err());
380    }
381
382    #[test]
383    fn test_tokens_is_expired() {
384        let expired = EsiTokens {
385            access_token: SecretString::from("test".to_string()),
386            refresh_token: SecretString::from("test".to_string()),
387            expires_at: Utc::now() - Duration::seconds(10),
388        };
389        assert!(expired.is_expired());
390        assert!(expired.needs_refresh());
391
392        let valid = EsiTokens {
393            access_token: SecretString::from("test".to_string()),
394            refresh_token: SecretString::from("test".to_string()),
395            expires_at: Utc::now() + Duration::seconds(300),
396        };
397        assert!(!valid.is_expired());
398        assert!(!valid.needs_refresh());
399    }
400
401    #[test]
402    fn test_tokens_needs_refresh_within_60s() {
403        let soon = EsiTokens {
404            access_token: SecretString::from("test".to_string()),
405            refresh_token: SecretString::from("test".to_string()),
406            expires_at: Utc::now() + Duration::seconds(30),
407        };
408        assert!(!soon.is_expired());
409        assert!(soon.needs_refresh());
410    }
411
412    #[test]
413    fn test_token_response_deserialization() {
414        let json = r#"{
415            "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.test",
416            "expires_in": 1199,
417            "token_type": "Bearer",
418            "refresh_token": "abc123refresh"
419        }"#;
420        let resp: TokenResponse = serde_json::from_str(json).unwrap();
421        assert_eq!(
422            resp.access_token.expose_secret(),
423            "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.test"
424        );
425        assert_eq!(resp.expires_in, 1199);
426        assert_eq!(resp.token_type, "Bearer");
427        assert_eq!(resp.refresh_token.expose_secret(), "abc123refresh");
428    }
429
430    #[test]
431    fn test_secret_redaction() {
432        let tokens = EsiTokens {
433            access_token: SecretString::from("super_secret_token".to_string()),
434            refresh_token: SecretString::from("super_secret_refresh".to_string()),
435            expires_at: Utc::now() + Duration::seconds(300),
436        };
437        let debug_output = format!("{:?}", tokens);
438        assert!(
439            !debug_output.contains("super_secret_token"),
440            "access_token leaked in debug output"
441        );
442        assert!(
443            !debug_output.contains("super_secret_refresh"),
444            "refresh_token leaked in debug output"
445        );
446    }
447
448    #[tokio::test]
449    async fn test_new_client_has_no_tokens() {
450        let client = EsiClient::new();
451        assert!(client.get_tokens().await.is_none());
452    }
453
454    #[tokio::test]
455    async fn test_set_and_get_tokens() {
456        let client = EsiClient::new();
457        let tokens = EsiTokens {
458            access_token: SecretString::from("access".to_string()),
459            refresh_token: SecretString::from("refresh".to_string()),
460            expires_at: Utc::now() + Duration::seconds(300),
461        };
462        client.set_tokens(tokens).await;
463        let retrieved = client.get_tokens().await.unwrap();
464        assert_eq!(retrieved.access_token.expose_secret(), "access");
465    }
466
467    #[tokio::test]
468    async fn test_clear_tokens() {
469        let client = EsiClient::new();
470        let tokens = EsiTokens {
471            access_token: SecretString::from("access".to_string()),
472            refresh_token: SecretString::from("refresh".to_string()),
473            expires_at: Utc::now() + Duration::seconds(300),
474        };
475        client.set_tokens(tokens).await;
476        client.clear_tokens().await;
477        assert!(client.get_tokens().await.is_none());
478    }
479}