1use crate::errors::{AuthError, Result};
11use base64::Engine;
12use serde::{Deserialize, Serialize};
13use sha2::{Digest, Sha256};
14use std::collections::HashMap;
15use std::sync::Arc;
16use std::time::{Duration, SystemTime, UNIX_EPOCH};
17use tokio::sync::RwLock;
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct IndieAuthConfig {
22 pub client_id: String,
24 pub redirect_uri: String,
26 pub authorization_endpoint: Option<String>,
28 pub token_endpoint: Option<String>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct IndieAuthRequest {
35 pub response_type: String,
37 pub client_id: String,
39 pub redirect_uri: String,
41 pub state: String,
43 pub code_challenge: String,
45 pub code_challenge_method: String,
47 pub me: Option<String>,
49 pub scope: Option<String>,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct IndieAuthCallback {
56 pub code: String,
58 pub state: String,
60 pub me: Option<String>,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct IndieAuthTokenResponse {
67 pub me: String,
69 pub access_token: Option<String>,
71 pub token_type: Option<String>,
73 pub scope: Option<String>,
75 pub profile: Option<IndieAuthProfile>,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct IndieAuthProfile {
82 pub name: Option<String>,
83 pub url: Option<String>,
84 pub photo: Option<String>,
85 pub email: Option<String>,
86}
87
88pub struct IndieAuthClient {
90 config: IndieAuthConfig,
91}
92
93impl IndieAuthClient {
94 pub fn new(config: IndieAuthConfig) -> Result<Self> {
96 if config.client_id.is_empty() {
97 return Err(AuthError::validation("client_id cannot be empty"));
98 }
99 if config.redirect_uri.is_empty() {
100 return Err(AuthError::validation("redirect_uri cannot be empty"));
101 }
102 Ok(Self { config })
103 }
104
105 pub fn generate_code_verifier() -> Result<String> {
107 use ring::rand::{SecureRandom, SystemRandom};
108 let rng = SystemRandom::new();
109 let mut buf = [0u8; 32];
110 rng.fill(&mut buf)
111 .map_err(|_| AuthError::crypto("Failed to generate code verifier".to_string()))?;
112 Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(buf))
113 }
114
115 pub fn compute_code_challenge(verifier: &str) -> String {
117 let hash = Sha256::digest(verifier.as_bytes());
118 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hash)
119 }
120
121 pub fn build_authorization_url(
123 &self,
124 code_verifier: &str,
125 scope: Option<&str>,
126 me: Option<&str>,
127 ) -> Result<(IndieAuthRequest, String)> {
128 let auth_endpoint = self
129 .config
130 .authorization_endpoint
131 .as_deref()
132 .ok_or_else(|| {
133 AuthError::config("Authorization endpoint not discovered yet".to_string())
134 })?;
135
136 let state = generate_state()?;
137 let code_challenge = Self::compute_code_challenge(code_verifier);
138
139 let request = IndieAuthRequest {
140 response_type: "code".to_string(),
141 client_id: self.config.client_id.clone(),
142 redirect_uri: self.config.redirect_uri.clone(),
143 state: state.clone(),
144 code_challenge: code_challenge.clone(),
145 code_challenge_method: "S256".to_string(),
146 me: me.map(|s| s.to_string()),
147 scope: scope.map(|s| s.to_string()),
148 };
149
150 let mut url = format!(
151 "{endpoint}?response_type=code&client_id={cid}&redirect_uri={ruri}&state={state}&code_challenge={cc}&code_challenge_method=S256",
152 endpoint = auth_endpoint,
153 cid = urlencoding::encode(&self.config.client_id),
154 ruri = urlencoding::encode(&self.config.redirect_uri),
155 state = urlencoding::encode(&state),
156 cc = urlencoding::encode(&code_challenge),
157 );
158
159 if let Some(s) = scope {
160 url.push_str(&format!("&scope={}", urlencoding::encode(s)));
161 }
162 if let Some(m) = me {
163 url.push_str(&format!("&me={}", urlencoding::encode(m)));
164 }
165
166 Ok((request, url))
167 }
168
169 pub fn verify_callback(
171 &self,
172 callback: &IndieAuthCallback,
173 expected_state: &str,
174 ) -> Result<()> {
175 if callback.state != expected_state {
176 return Err(AuthError::validation("State parameter mismatch"));
177 }
178 if callback.code.is_empty() {
179 return Err(AuthError::validation("Authorization code is empty"));
180 }
181 Ok(())
182 }
183
184 pub fn verify_pkce(code_verifier: &str, code_challenge: &str) -> Result<()> {
186 let expected = Self::compute_code_challenge(code_verifier);
187 if expected != code_challenge {
188 return Err(AuthError::validation("PKCE code challenge mismatch"));
189 }
190 Ok(())
191 }
192
193 pub fn validate_profile_url(url: &str) -> Result<()> {
200 if !(url.starts_with("https://") || url.starts_with("http://")) {
201 return Err(AuthError::validation(
202 "Profile URL must use http or https scheme",
203 ));
204 }
205 if url.contains('#') {
206 return Err(AuthError::validation(
207 "Profile URL must not contain a fragment",
208 ));
209 }
210 if url.contains('@') {
211 return Err(AuthError::validation(
212 "Profile URL must not contain userinfo",
213 ));
214 }
215 let after_scheme = url.split("://").nth(1).unwrap_or("");
217 let host = after_scheme.split('/').next().unwrap_or("");
218 if host.is_empty() {
219 return Err(AuthError::validation("Profile URL has no host"));
220 }
221 if host.parse::<std::net::Ipv4Addr>().is_ok() {
223 return Err(AuthError::validation(
224 "Profile URL must not be an IP address",
225 ));
226 }
227 Ok(())
228 }
229}
230
231#[derive(Debug, Clone, Serialize, Deserialize)]
236pub struct IndieAuthMetadata {
237 pub issuer: String,
238 pub authorization_endpoint: String,
239 pub token_endpoint: String,
240 #[serde(skip_serializing_if = "Option::is_none")]
241 pub introspection_endpoint: Option<String>,
242 #[serde(skip_serializing_if = "Option::is_none")]
243 pub revocation_endpoint: Option<String>,
244 pub code_challenge_methods_supported: Vec<String>,
245 #[serde(skip_serializing_if = "Vec::is_empty", default)]
246 pub scopes_supported: Vec<String>,
247 #[serde(skip_serializing_if = "Vec::is_empty", default)]
248 pub response_types_supported: Vec<String>,
249}
250
251impl IndieAuthMetadata {
252 pub fn new(issuer: &str) -> Self {
254 Self {
255 issuer: issuer.to_string(),
256 authorization_endpoint: format!("{issuer}/auth"),
257 token_endpoint: format!("{issuer}/token"),
258 introspection_endpoint: Some(format!("{issuer}/introspect")),
259 revocation_endpoint: Some(format!("{issuer}/revoke")),
260 code_challenge_methods_supported: vec!["S256".to_string()],
261 scopes_supported: vec![
262 "profile".to_string(),
263 "email".to_string(),
264 "create".to_string(),
265 "update".to_string(),
266 "delete".to_string(),
267 ],
268 response_types_supported: vec!["code".to_string()],
269 }
270 }
271}
272
273#[allow(dead_code)]
277#[derive(Debug, Clone)]
278struct StoredAuthCode {
279 code: String,
280 client_id: String,
281 redirect_uri: String,
282 me: String,
283 scope: Option<String>,
284 code_challenge: String,
285 code_challenge_method: String,
286 created_at: u64,
287}
288
289pub struct IndieAuthServer {
291 issuer: String,
292 codes: Arc<RwLock<HashMap<String, StoredAuthCode>>>,
294 tokens: Arc<RwLock<HashMap<String, (String, Option<String>, u64)>>>,
296 code_lifetime: u64,
298 token_lifetime: u64,
300}
301
302impl IndieAuthServer {
303 pub fn new(issuer: &str, code_lifetime: u64, token_lifetime: u64) -> Self {
305 Self {
306 issuer: issuer.to_string(),
307 codes: Arc::new(RwLock::new(HashMap::new())),
308 tokens: Arc::new(RwLock::new(HashMap::new())),
309 code_lifetime,
310 token_lifetime,
311 }
312 }
313
314 pub fn metadata(&self) -> IndieAuthMetadata {
316 IndieAuthMetadata::new(&self.issuer)
317 }
318
319 fn now_secs() -> u64 {
320 SystemTime::now()
321 .duration_since(UNIX_EPOCH)
322 .unwrap_or(Duration::ZERO)
323 .as_secs()
324 }
325
326 fn generate_token() -> Result<String> {
327 use ring::rand::{SecureRandom, SystemRandom};
328 let rng = SystemRandom::new();
329 let mut buf = [0u8; 32];
330 rng.fill(&mut buf)
331 .map_err(|_| AuthError::crypto("Failed to generate token"))?;
332 Ok(hex::encode(buf))
333 }
334
335 pub async fn issue_code(
337 &self,
338 client_id: &str,
339 redirect_uri: &str,
340 me: &str,
341 scope: Option<&str>,
342 code_challenge: &str,
343 code_challenge_method: &str,
344 ) -> Result<String> {
345 if code_challenge_method != "S256" {
346 return Err(AuthError::validation(
347 "Only S256 code_challenge_method is supported",
348 ));
349 }
350 let code = Self::generate_token()?;
351 let stored = StoredAuthCode {
352 code: code.clone(),
353 client_id: client_id.to_string(),
354 redirect_uri: redirect_uri.to_string(),
355 me: me.to_string(),
356 scope: scope.map(|s| s.to_string()),
357 code_challenge: code_challenge.to_string(),
358 code_challenge_method: code_challenge_method.to_string(),
359 created_at: Self::now_secs(),
360 };
361 self.codes.write().await.insert(code.clone(), stored);
362 Ok(code)
363 }
364
365 pub async fn exchange_code(
369 &self,
370 code: &str,
371 client_id: &str,
372 redirect_uri: &str,
373 code_verifier: &str,
374 ) -> Result<IndieAuthTokenResponse> {
375 let stored = {
376 let mut codes = self.codes.write().await;
377 codes
378 .remove(code)
379 .ok_or_else(|| AuthError::validation("Invalid or expired authorization code"))?
380 };
381
382 if stored.client_id != client_id {
384 return Err(AuthError::validation("client_id mismatch"));
385 }
386 if stored.redirect_uri != redirect_uri {
387 return Err(AuthError::validation("redirect_uri mismatch"));
388 }
389
390 let now = Self::now_secs();
392 if now - stored.created_at > self.code_lifetime {
393 return Err(AuthError::validation("Authorization code has expired"));
394 }
395
396 IndieAuthClient::verify_pkce(code_verifier, &stored.code_challenge)?;
398
399 let (access_token, token_type) = if stored.scope.is_some() {
401 let token = Self::generate_token()?;
402 self.tokens.write().await.insert(
403 token.clone(),
404 (stored.me.clone(), stored.scope.clone(), now),
405 );
406 (Some(token), Some("Bearer".to_string()))
407 } else {
408 (None, None)
409 };
410
411 Ok(IndieAuthTokenResponse {
412 me: stored.me,
413 access_token,
414 token_type,
415 scope: stored.scope,
416 profile: None,
417 })
418 }
419
420 pub async fn introspect_token(&self, token: &str) -> Option<(String, Option<String>, bool)> {
422 let tokens = self.tokens.read().await;
423 tokens.get(token).map(|(me, scope, created_at)| {
424 let now = Self::now_secs();
425 let active = now - created_at <= self.token_lifetime;
426 (me.clone(), scope.clone(), active)
427 })
428 }
429
430 pub async fn revoke_token(&self, token: &str) -> bool {
432 self.tokens.write().await.remove(token).is_some()
433 }
434
435 pub async fn cleanup(&self) {
437 let now = Self::now_secs();
438 self.codes
439 .write()
440 .await
441 .retain(|_, v| now - v.created_at <= self.code_lifetime);
442 self.tokens
443 .write()
444 .await
445 .retain(|_, (_, _, created)| now - *created <= self.token_lifetime);
446 }
447}
448
449fn generate_state() -> Result<String> {
451 use ring::rand::{SecureRandom, SystemRandom};
452 let rng = SystemRandom::new();
453 let mut buf = [0u8; 16];
454 rng.fill(&mut buf)
455 .map_err(|_| AuthError::crypto("Failed to generate state".to_string()))?;
456 Ok(hex::encode(buf))
457}
458
459#[cfg(test)]
460mod tests {
461 use super::*;
462 use base64::Engine;
463
464 fn test_config() -> IndieAuthConfig {
465 IndieAuthConfig {
466 client_id: "https://app.example.com/".to_string(),
467 redirect_uri: "https://app.example.com/callback".to_string(),
468 authorization_endpoint: Some("https://indieauth.example.com/auth".to_string()),
469 token_endpoint: Some("https://indieauth.example.com/token".to_string()),
470 }
471 }
472
473 #[test]
474 fn test_create_client() {
475 let client = IndieAuthClient::new(test_config()).unwrap();
476 assert_eq!(client.config.client_id, "https://app.example.com/");
477 }
478
479 #[test]
480 fn test_empty_client_id_rejected() {
481 let mut cfg = test_config();
482 cfg.client_id = String::new();
483 assert!(IndieAuthClient::new(cfg).is_err());
484 }
485
486 #[test]
487 fn test_generate_code_verifier() {
488 let v1 = IndieAuthClient::generate_code_verifier().unwrap();
489 let v2 = IndieAuthClient::generate_code_verifier().unwrap();
490 assert!(v1.len() >= 43);
491 assert_ne!(v1, v2);
492 }
493
494 #[test]
495 fn test_pkce_challenge_s256() {
496 let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
497 let challenge = IndieAuthClient::compute_code_challenge(verifier);
498 assert!(!challenge.is_empty());
500 assert!(
502 base64::engine::general_purpose::URL_SAFE_NO_PAD
503 .decode(&challenge)
504 .is_ok()
505 );
506 }
507
508 #[test]
509 fn test_pkce_verify_success() {
510 let verifier = IndieAuthClient::generate_code_verifier().unwrap();
511 let challenge = IndieAuthClient::compute_code_challenge(&verifier);
512 IndieAuthClient::verify_pkce(&verifier, &challenge).unwrap();
513 }
514
515 #[test]
516 fn test_pkce_verify_mismatch() {
517 let verifier = IndieAuthClient::generate_code_verifier().unwrap();
518 assert!(IndieAuthClient::verify_pkce(&verifier, "wrong-challenge").is_err());
519 }
520
521 #[test]
522 fn test_build_authorization_url() {
523 let client = IndieAuthClient::new(test_config()).unwrap();
524 let verifier = IndieAuthClient::generate_code_verifier().unwrap();
525 let (req, url) = client
526 .build_authorization_url(
527 &verifier,
528 Some("profile"),
529 Some("https://user.example.com/"),
530 )
531 .unwrap();
532
533 assert_eq!(req.response_type, "code");
534 assert_eq!(req.code_challenge_method, "S256");
535 assert!(url.starts_with("https://indieauth.example.com/auth?"));
536 assert!(url.contains("response_type=code"));
537 assert!(url.contains("code_challenge="));
538 assert!(url.contains("scope=profile"));
539 }
540
541 #[test]
542 fn test_build_url_no_endpoint() {
543 let mut cfg = test_config();
544 cfg.authorization_endpoint = None;
545 let client = IndieAuthClient::new(cfg).unwrap();
546 let verifier = IndieAuthClient::generate_code_verifier().unwrap();
547 assert!(
548 client
549 .build_authorization_url(&verifier, None, None)
550 .is_err()
551 );
552 }
553
554 #[test]
555 fn test_verify_callback_valid() {
556 let client = IndieAuthClient::new(test_config()).unwrap();
557 let cb = IndieAuthCallback {
558 code: "auth-code-123".to_string(),
559 state: "expected-state".to_string(),
560 me: None,
561 };
562 client.verify_callback(&cb, "expected-state").unwrap();
563 }
564
565 #[test]
566 fn test_verify_callback_state_mismatch() {
567 let client = IndieAuthClient::new(test_config()).unwrap();
568 let cb = IndieAuthCallback {
569 code: "auth-code-123".to_string(),
570 state: "wrong-state".to_string(),
571 me: None,
572 };
573 assert!(client.verify_callback(&cb, "expected-state").is_err());
574 }
575
576 #[test]
577 fn test_verify_callback_empty_code() {
578 let client = IndieAuthClient::new(test_config()).unwrap();
579 let cb = IndieAuthCallback {
580 code: String::new(),
581 state: "ok".to_string(),
582 me: None,
583 };
584 assert!(client.verify_callback(&cb, "ok").is_err());
585 }
586
587 #[test]
588 fn test_validate_profile_url_valid() {
589 IndieAuthClient::validate_profile_url("https://user.example.com/").unwrap();
590 IndieAuthClient::validate_profile_url("http://user.example.com/path").unwrap();
591 }
592
593 #[test]
594 fn test_validate_profile_url_no_scheme() {
595 assert!(IndieAuthClient::validate_profile_url("ftp://example.com").is_err());
596 }
597
598 #[test]
599 fn test_validate_profile_url_fragment() {
600 assert!(IndieAuthClient::validate_profile_url("https://example.com/#frag").is_err());
601 }
602
603 #[test]
604 fn test_validate_profile_url_userinfo() {
605 assert!(IndieAuthClient::validate_profile_url("https://user@example.com/").is_err());
606 }
607
608 #[test]
609 fn test_validate_profile_url_ip_address() {
610 assert!(IndieAuthClient::validate_profile_url("https://127.0.0.1/").is_err());
611 }
612
613 #[test]
616 fn test_server_metadata() {
617 let meta = IndieAuthMetadata::new("https://auth.example.com");
618 assert_eq!(meta.issuer, "https://auth.example.com");
619 assert_eq!(meta.authorization_endpoint, "https://auth.example.com/auth");
620 assert!(
621 meta.code_challenge_methods_supported
622 .contains(&"S256".to_string())
623 );
624 let json = serde_json::to_string(&meta).unwrap();
625 assert!(json.contains("issuer"));
626 }
627
628 #[tokio::test]
631 async fn test_server_issue_and_exchange_code() {
632 let server = IndieAuthServer::new("https://auth.example.com", 600, 3600);
633
634 let verifier = IndieAuthClient::generate_code_verifier().unwrap();
635 let challenge = IndieAuthClient::compute_code_challenge(&verifier);
636
637 let code = server
638 .issue_code(
639 "https://app.example.com/",
640 "https://app.example.com/callback",
641 "https://user.example.com/",
642 Some("profile create"),
643 &challenge,
644 "S256",
645 )
646 .await
647 .unwrap();
648
649 let resp = server
650 .exchange_code(
651 &code,
652 "https://app.example.com/",
653 "https://app.example.com/callback",
654 &verifier,
655 )
656 .await
657 .unwrap();
658
659 assert_eq!(resp.me, "https://user.example.com/");
660 assert!(resp.access_token.is_some());
661 assert_eq!(resp.token_type.as_deref(), Some("Bearer"));
662 assert_eq!(resp.scope.as_deref(), Some("profile create"));
663 }
664
665 #[tokio::test]
666 async fn test_server_code_single_use() {
667 let server = IndieAuthServer::new("https://auth.example.com", 600, 3600);
668 let verifier = IndieAuthClient::generate_code_verifier().unwrap();
669 let challenge = IndieAuthClient::compute_code_challenge(&verifier);
670
671 let code = server
672 .issue_code(
673 "https://app/",
674 "https://app/cb",
675 "https://me/",
676 None,
677 &challenge,
678 "S256",
679 )
680 .await
681 .unwrap();
682
683 server
685 .exchange_code(&code, "https://app/", "https://app/cb", &verifier)
686 .await
687 .unwrap();
688
689 assert!(
691 server
692 .exchange_code(&code, "https://app/", "https://app/cb", &verifier)
693 .await
694 .is_err()
695 );
696 }
697
698 #[tokio::test]
699 async fn test_server_pkce_mismatch() {
700 let server = IndieAuthServer::new("https://auth.example.com", 600, 3600);
701 let verifier = IndieAuthClient::generate_code_verifier().unwrap();
702 let challenge = IndieAuthClient::compute_code_challenge(&verifier);
703
704 let code = server
705 .issue_code(
706 "https://app/",
707 "https://app/cb",
708 "https://me/",
709 None,
710 &challenge,
711 "S256",
712 )
713 .await
714 .unwrap();
715
716 assert!(
717 server
718 .exchange_code(&code, "https://app/", "https://app/cb", "wrong-verifier")
719 .await
720 .is_err()
721 );
722 }
723
724 #[tokio::test]
725 async fn test_server_introspect_and_revoke() {
726 let server = IndieAuthServer::new("https://auth.example.com", 600, 3600);
727 let verifier = IndieAuthClient::generate_code_verifier().unwrap();
728 let challenge = IndieAuthClient::compute_code_challenge(&verifier);
729
730 let code = server
731 .issue_code(
732 "https://app/",
733 "https://app/cb",
734 "https://me/",
735 Some("profile"),
736 &challenge,
737 "S256",
738 )
739 .await
740 .unwrap();
741
742 let resp = server
743 .exchange_code(&code, "https://app/", "https://app/cb", &verifier)
744 .await
745 .unwrap();
746
747 let token = resp.access_token.unwrap();
748 let (me, scope, active) = server.introspect_token(&token).await.unwrap();
749 assert_eq!(me, "https://me/");
750 assert_eq!(scope.as_deref(), Some("profile"));
751 assert!(active);
752
753 assert!(server.revoke_token(&token).await);
754 assert!(server.introspect_token(&token).await.is_none());
755 }
756
757 #[tokio::test]
758 async fn test_server_no_token_without_scope() {
759 let server = IndieAuthServer::new("https://auth.example.com", 600, 3600);
760 let verifier = IndieAuthClient::generate_code_verifier().unwrap();
761 let challenge = IndieAuthClient::compute_code_challenge(&verifier);
762
763 let code = server
764 .issue_code(
765 "https://app/",
766 "https://app/cb",
767 "https://me/",
768 None,
769 &challenge,
770 "S256",
771 )
772 .await
773 .unwrap();
774
775 let resp = server
776 .exchange_code(&code, "https://app/", "https://app/cb", &verifier)
777 .await
778 .unwrap();
779
780 assert!(resp.access_token.is_none());
781 assert_eq!(resp.me, "https://me/");
782 }
783}