1use 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
11const 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#[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
43pub struct PkceChallenge {
45 pub authorize_url: String,
46 pub code_verifier: SecretString,
47 pub state: String,
48}
49
50#[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#[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 #[must_use]
71 pub fn is_expired(&self) -> bool {
72 Utc::now() >= self.expires_at
73 }
74
75 #[must_use]
77 pub fn needs_refresh(&self) -> bool {
78 Utc::now() >= self.expires_at - Duration::seconds(60)
79 }
80}
81
82fn 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
94fn compute_code_challenge(verifier: &str) -> String {
96 let digest = Sha256::digest(verifier.as_bytes());
97 base64_url_encode(&digest)
98}
99
100fn 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
108fn 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
115impl EsiClient {
120 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 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 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 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 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 let _refresh_guard = self.refresh_mutex.lock().await;
242
243 {
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 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 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 pub async fn set_tokens(&self, tokens: EsiTokens) {
281 *self.tokens.write().await = Some(tokens);
282 }
283
284 pub async fn get_tokens(&self) -> Option<EsiTokens> {
286 self.tokens.read().await.clone()
287 }
288
289 pub async fn clear_tokens(&self) {
291 *self.tokens.write().await = None;
292 }
293
294 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(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#[cfg(test)]
319mod tests {
320 use super::*;
321
322 #[test]
323 fn test_pkce_verifier_length() {
324 let verifier = generate_code_verifier();
325 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 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}