auth.rs 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  1. //! Authentication providers for NpubCash API
  2. //!
  3. //! Implements NIP-98 and JWT authentication
  4. use std::sync::Arc;
  5. use std::time::{Duration, SystemTime};
  6. use base64::Engine;
  7. use nostr_sdk::{EventBuilder, Keys, Kind, Tag};
  8. use tokio::sync::RwLock;
  9. use crate::types::Nip98Response;
  10. use crate::{Error, Result};
  11. #[derive(Debug)]
  12. struct CachedToken {
  13. token: String,
  14. expires_at: SystemTime,
  15. }
  16. /// JWT authentication provider using NIP-98
  17. #[derive(Debug)]
  18. pub struct JwtAuthProvider {
  19. base_url: String,
  20. keys: Keys,
  21. http_client: reqwest::Client,
  22. cached_token: Arc<RwLock<Option<CachedToken>>>,
  23. }
  24. impl JwtAuthProvider {
  25. /// Create a new JWT authentication provider
  26. ///
  27. /// # Arguments
  28. ///
  29. /// * `base_url` - Base URL of the NpubCash service
  30. /// * `keys` - Nostr keys for signing NIP-98 tokens
  31. pub fn new(base_url: String, keys: Keys) -> Self {
  32. Self {
  33. base_url,
  34. keys,
  35. http_client: reqwest::Client::new(),
  36. cached_token: Arc::new(RwLock::new(None)),
  37. }
  38. }
  39. /// Ensure we have a valid cached JWT token, fetching a new one if needed
  40. ///
  41. /// This method checks the cache first and returns the cached token if it's still valid.
  42. /// If the cache is empty or expired, it fetches a new JWT token from the API.
  43. ///
  44. /// # Errors
  45. ///
  46. /// Returns an error if token generation or API request fails
  47. async fn ensure_cached_token(&self) -> Result<String> {
  48. // Check if we have a valid cached token
  49. if let Some(token) = self.get_valid_cached_token().await {
  50. return Ok(token);
  51. }
  52. // Fetch a new JWT token from the API
  53. let token = self.fetch_fresh_jwt_token().await?;
  54. // Cache the new token
  55. self.cache_token(&token).await;
  56. Ok(token)
  57. }
  58. /// Get a valid token from cache, if one exists and hasn't expired
  59. async fn get_valid_cached_token(&self) -> Option<String> {
  60. let cache = self.cached_token.read().await;
  61. cache.as_ref().and_then(|cached| {
  62. if cached.expires_at > SystemTime::now() {
  63. Some(cached.token.clone())
  64. } else {
  65. None
  66. }
  67. })
  68. }
  69. /// Fetch a fresh JWT token from the NpubCash API using NIP-98 authentication
  70. async fn fetch_fresh_jwt_token(&self) -> Result<String> {
  71. let auth_url = format!("{}/api/v2/auth/nip98", self.base_url);
  72. // Create NIP-98 token for authentication
  73. let nostr_token = self.create_nip98_token_with_logging(&auth_url)?;
  74. // Send authentication request
  75. let response = self.send_auth_request(&auth_url, &nostr_token).await?;
  76. // Parse and validate response
  77. self.parse_jwt_response(response).await
  78. }
  79. /// Create a NIP-98 token with debug logging
  80. fn create_nip98_token_with_logging(&self, auth_url: &str) -> Result<String> {
  81. tracing::debug!("Creating NIP-98 token for URL: {}", auth_url);
  82. let nostr_token = self.create_nip98_token(auth_url, "GET")?;
  83. tracing::debug!(
  84. "NIP-98 token created (first 50 chars): {}",
  85. &nostr_token[..50.min(nostr_token.len())]
  86. );
  87. Ok(nostr_token)
  88. }
  89. /// Send the authentication request to the API
  90. async fn send_auth_request(
  91. &self,
  92. auth_url: &str,
  93. nostr_token: &str,
  94. ) -> Result<reqwest::Response> {
  95. tracing::debug!("Sending request to: {}", auth_url);
  96. tracing::debug!(
  97. "Authorization header: Nostr {}",
  98. &nostr_token[..50.min(nostr_token.len())]
  99. );
  100. let response = self
  101. .http_client
  102. .get(auth_url)
  103. .header("Authorization", format!("Nostr {nostr_token}"))
  104. .header("Content-Type", "application/json")
  105. .header("Accept", "application/json")
  106. .header("User-Agent", "cdk-npubcash/0.13.0")
  107. .send()
  108. .await?;
  109. tracing::debug!("Response status: {}", response.status());
  110. Ok(response)
  111. }
  112. /// Parse the JWT response from the API
  113. async fn parse_jwt_response(&self, response: reqwest::Response) -> Result<String> {
  114. let status = response.status();
  115. if !status.is_success() {
  116. let error_text = response.text().await.unwrap_or_default();
  117. tracing::error!("Auth failed - Status: {}, Body: {}", status, error_text);
  118. return Err(Error::Auth(format!(
  119. "Failed to get JWT: {status} - {error_text}"
  120. )));
  121. }
  122. let nip98_response: Nip98Response = response.json().await?;
  123. Ok(nip98_response.data.token)
  124. }
  125. /// Cache the JWT token with a 5-minute expiration
  126. async fn cache_token(&self, token: &str) {
  127. let expires_at = SystemTime::now() + Duration::from_secs(5 * 60);
  128. let mut cache = self.cached_token.write().await;
  129. *cache = Some(CachedToken {
  130. token: token.to_string(),
  131. expires_at,
  132. });
  133. }
  134. fn create_nip98_token(&self, url: &str, method: &str) -> Result<String> {
  135. let u_tag = Tag::custom(
  136. nostr_sdk::TagKind::Custom(std::borrow::Cow::Borrowed("u")),
  137. vec![url],
  138. );
  139. let method_tag = Tag::custom(
  140. nostr_sdk::TagKind::Custom(std::borrow::Cow::Borrowed("method")),
  141. vec![method],
  142. );
  143. let event = EventBuilder::new(Kind::Custom(27235), "")
  144. .tags(vec![u_tag, method_tag])
  145. .sign_with_keys(&self.keys)
  146. .map_err(|e| Error::Nostr(e.to_string()))?;
  147. let json = serde_json::to_string(&event)?;
  148. tracing::debug!("NIP-98 event JSON: {}", json);
  149. let encoded = base64::engine::general_purpose::STANDARD.encode(json);
  150. tracing::debug!("Base64 encoded token length: {}", encoded.len());
  151. Ok(encoded)
  152. }
  153. /// Get a Bearer token for authenticated requests
  154. ///
  155. /// # Arguments
  156. ///
  157. /// * `_url` - The URL being accessed (unused, kept for future extensibility)
  158. /// * `_method` - The HTTP method being used (unused, kept for future extensibility)
  159. ///
  160. /// # Errors
  161. ///
  162. /// Returns an error if token generation or fetching fails
  163. pub async fn get_auth_token(&self, _url: &str, _method: &str) -> Result<String> {
  164. let token = self.ensure_cached_token().await?;
  165. Ok(format!("Bearer {token}"))
  166. }
  167. /// Get a NIP-98 auth header for direct authentication
  168. ///
  169. /// This creates a fresh NIP-98 signed event for the specific URL and method,
  170. /// returning the full Authorization header value (e.g., "Nostr <base64_event>").
  171. ///
  172. /// # Arguments
  173. ///
  174. /// * `url` - The URL being accessed
  175. /// * `method` - The HTTP method being used (GET, POST, PATCH, etc.)
  176. ///
  177. /// # Errors
  178. ///
  179. /// Returns an error if token generation fails
  180. pub fn get_nip98_auth_header(&self, url: &str, method: &str) -> Result<String> {
  181. let token = self.create_nip98_token(url, method)?;
  182. Ok(format!("Nostr {token}"))
  183. }
  184. }