Skip to main content

brainwires_network/auth/
client.rs

1//! Authentication Client
2//!
3//! HTTP client for authenticating with the Brainwires Studio backend.
4//! Uses injected endpoint configuration instead of CLI-specific constants.
5
6use anyhow::{Context, Result, anyhow};
7use regex::Regex;
8use reqwest::Client;
9
10use super::types::{AuthRequest, AuthResponse};
11
12/// Authentication client for interacting with Brainwires Studio backend
13///
14/// Unlike the CLI version, this client does NOT auto-save sessions or manage
15/// keyring storage. The caller is responsible for persisting the auth response.
16pub struct AuthClient {
17    http_client: Client,
18    /// Full backend URL (e.g., `https://brainwires.studio`)
19    backend_url: String,
20    /// Auth endpoint path (e.g., "/api/cli/auth")
21    auth_endpoint: String,
22    /// Compiled API key validation pattern
23    api_key_pattern: Regex,
24}
25
26impl AuthClient {
27    /// Create a new authentication client
28    ///
29    /// # Arguments
30    /// * `backend_url` - Base URL (e.g., `https://brainwires.studio`)
31    /// * `auth_endpoint` - Auth endpoint path (e.g., "/api/cli/auth")
32    /// * `api_key_pattern` - Regex pattern for API key validation (e.g., r"^bw_(prod|dev|test)_[a-z0-9]{32}$")
33    pub fn new(backend_url: String, auth_endpoint: String, api_key_pattern: &str) -> Self {
34        Self {
35            http_client: Client::new(),
36            backend_url,
37            auth_endpoint,
38            api_key_pattern: Regex::new(api_key_pattern).expect("Invalid API key regex pattern"),
39        }
40    }
41
42    /// Create from an `AuthEndpoints` trait implementation
43    pub fn from_endpoints(endpoints: &dyn crate::traits::AuthEndpoints) -> Self {
44        Self::new(
45            endpoints.backend_url().to_string(),
46            endpoints.auth_endpoint(),
47            endpoints.api_key_pattern(),
48        )
49    }
50
51    /// Validate API key format against the configured pattern
52    pub fn validate_api_key_format(&self, api_key: &str) -> Result<()> {
53        if !self.api_key_pattern.is_match(api_key) {
54            return Err(anyhow!(
55                "Invalid API key format. Expected: bw_[env]_[32chars]"
56            ));
57        }
58        Ok(())
59    }
60
61    /// Authenticate with API key
62    ///
63    /// Returns the raw `AuthResponse` from the backend. The caller is responsible
64    /// for creating a session and storing the API key.
65    pub async fn authenticate(&self, api_key: &str) -> Result<AuthResponse> {
66        // Validate format
67        self.validate_api_key_format(api_key)?;
68
69        // Make API request
70        let url = format!("{}{}", self.backend_url, self.auth_endpoint);
71        let request = AuthRequest {
72            api_key: api_key.to_string(),
73        };
74
75        let response = self
76            .http_client
77            .post(&url)
78            .json(&request)
79            .send()
80            .await
81            .context("Failed to send authentication request")?;
82
83        if !response.status().is_success() {
84            let status = response.status();
85            let error_text = response
86                .text()
87                .await
88                .unwrap_or_else(|_| "Unknown error".to_string());
89
90            return Err(anyhow!(
91                "Authentication failed (status {}): {}",
92                status,
93                error_text
94            ));
95        }
96
97        let auth_response: AuthResponse = response
98            .json()
99            .await
100            .context("Failed to parse authentication response")?;
101
102        Ok(auth_response)
103    }
104
105    /// Get the configured backend URL
106    pub fn backend_url(&self) -> &str {
107        &self.backend_url
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    fn make_client() -> AuthClient {
116        AuthClient::new(
117            "https://test.example.com".to_string(),
118            "/api/cli/auth".to_string(),
119            r"^bw_(prod|dev|test)_[a-z0-9]{32}$",
120        )
121    }
122
123    #[test]
124    fn test_validate_api_key_format() {
125        let client = make_client();
126
127        // Valid keys
128        assert!(
129            client
130                .validate_api_key_format("bw_dev_12345678901234567890123456789012")
131                .is_ok()
132        );
133        assert!(
134            client
135                .validate_api_key_format("bw_prod_abcdefghijklmnopqrstuvwxyz123456")
136                .is_ok()
137        );
138        assert!(
139            client
140                .validate_api_key_format("bw_test_00000000000000000000000000000000")
141                .is_ok()
142        );
143
144        // Invalid keys
145        assert!(client.validate_api_key_format("invalid").is_err());
146        assert!(
147            client
148                .validate_api_key_format("bw_invalid_12345678901234567890123456789012")
149                .is_err()
150        );
151        assert!(client.validate_api_key_format("bw_dev_short").is_err());
152        assert!(
153            client
154                .validate_api_key_format("bw_dev_UPPERCASE0000000000000000000000")
155                .is_err()
156        );
157    }
158
159    #[test]
160    fn test_auth_client_new() {
161        let client = make_client();
162        assert_eq!(client.backend_url(), "https://test.example.com");
163    }
164
165    #[test]
166    fn test_validate_api_key_error_message() {
167        let client = make_client();
168        let result = client.validate_api_key_format("invalid_key");
169        assert!(result.is_err());
170        let error = result.unwrap_err();
171        assert!(error.to_string().contains("Invalid API key format"));
172    }
173
174    #[test]
175    fn test_validate_api_key_edge_cases() {
176        let client = make_client();
177
178        assert!(client.validate_api_key_format("").is_err());
179        assert!(client.validate_api_key_format("   ").is_err());
180        assert!(client.validate_api_key_format("bw_dev_123").is_err()); // too short
181        assert!(
182            client
183                .validate_api_key_format("bw_dev_123456789012345678901234567890123")
184                .is_err()
185        ); // too long
186        assert!(
187            client
188                .validate_api_key_format("dev_12345678901234567890123456789012")
189                .is_err()
190        ); // missing prefix
191        assert!(
192            client
193                .validate_api_key_format("bw__12345678901234567890123456789012")
194                .is_err()
195        ); // missing env
196        assert!(
197            client
198                .validate_api_key_format("bw_Dev_12345678901234567890123456789012")
199                .is_err()
200        ); // mixed case env
201    }
202}