docker_image_pusher/registry/
auth.rs

1use crate::error::{RegistryError, Result};
2use crate::logging::Logger;
3use reqwest::Client;
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6
7#[derive(Debug, Serialize, Deserialize)]
8pub struct AuthResponse {
9    pub token: String,
10    pub expires_in: Option<u64>,
11    pub issued_at: Option<String>,
12}
13
14#[derive(Debug, Clone)]
15pub struct AuthChallenge {
16    pub realm: String,
17    pub service: Option<String>,
18    pub scope: Option<String>,
19}
20
21#[derive(Clone)]
22pub struct Auth {
23    client: Client,
24}
25
26impl Auth {
27    pub fn new() -> Self {
28        Auth {
29            client: Client::new(),
30        }
31    }
32
33    /// Parse WWW-Authenticate header according to Docker Registry API v2 spec
34    fn parse_www_authenticate(header_value: &str) -> Result<AuthChallenge> {
35        if !header_value.starts_with("Bearer ") {
36            return Err(RegistryError::Registry(
37                "Only Bearer authentication is supported".to_string(),
38            ));
39        }
40
41        let params_str = &header_value[7..]; // Remove "Bearer " prefix
42        let mut params = HashMap::new();
43
44        // Parse key=value pairs
45        for param in params_str.split(',') {
46            let param = param.trim();
47            if let Some(eq_pos) = param.find('=') {
48                let key = param[..eq_pos].trim();
49                let value = param[eq_pos + 1..].trim();
50                // Remove quotes if present
51                let value = if value.starts_with('"') && value.ends_with('"') {
52                    &value[1..value.len() - 1]
53                } else {
54                    value
55                };
56                params.insert(key, value);
57            }
58        }
59
60        let realm = params
61            .get("realm")
62            .ok_or_else(|| {
63                RegistryError::Registry("Missing realm in WWW-Authenticate header".to_string())
64            })?
65            .to_string();
66
67        Ok(AuthChallenge {
68            realm,
69            service: params.get("service").map(|s| s.to_string()),
70            scope: params.get("scope").map(|s| s.to_string()),
71        })
72    }
73
74    /// Perform Docker Registry API v2 authentication
75    pub async fn authenticate_with_registry(
76        &self,
77        registry_url: &str,
78        repository: &str,
79        username: Option<&str>,
80        password: Option<&str>,
81        output: &Logger,
82    ) -> Result<Option<String>> {
83        output.verbose("Starting Docker Registry API v2 authentication...");
84
85        // Step 1: Try to access the registry to get auth challenge
86        let ping_url = format!("{}/v2/", registry_url);
87        let response = self
88            .client
89            .get(&ping_url)
90            .send()
91            .await
92            .map_err(|e| RegistryError::Network(format!("Failed to ping registry: {}", e)))?;
93
94        match response.status().as_u16() {
95            200 => {
96                output.verbose("Registry does not require authentication");
97                return Ok(None);
98            }
99            401 => {
100                output.verbose("Registry requires authentication, processing challenge...");
101
102                // Step 2: Parse WWW-Authenticate header
103                let www_auth = response
104                    .headers()
105                    .get("www-authenticate")
106                    .and_then(|h| h.to_str().ok())
107                    .ok_or_else(|| {
108                        RegistryError::Registry(
109                            "Missing WWW-Authenticate header in 401 response".to_string(),
110                        )
111                    })?;
112
113                let challenge = Self::parse_www_authenticate(www_auth)?;
114                output.verbose(&format!(
115                    "Auth challenge: realm={}, service={:?}, scope={:?}",
116                    challenge.realm, challenge.service, challenge.scope
117                ));
118
119                // Step 3: Request token from auth service
120                return self
121                    .request_token(challenge, repository, username, password, output)
122                    .await;
123            }
124            _ => {
125                return Err(RegistryError::Registry(format!(
126                    "Unexpected status {} when checking registry authentication",
127                    response.status()
128                )));
129            }
130        }
131    }
132
133    async fn request_token(
134        &self,
135        challenge: AuthChallenge,
136        repository: &str,
137        username: Option<&str>,
138        password: Option<&str>,
139        output: &Logger,
140    ) -> Result<Option<String>> {
141        let mut url = reqwest::Url::parse(&challenge.realm)
142            .map_err(|e| RegistryError::Registry(format!("Invalid auth realm URL: {}", e)))?;
143
144        // Add query parameters
145        if let Some(service) = &challenge.service {
146            url.query_pairs_mut().append_pair("service", service);
147        }
148
149        // Build scope for repository access
150        let scope = format!("repository:{}:pull,push", repository);
151        url.query_pairs_mut().append_pair("scope", &scope);
152
153        output.verbose(&format!("Requesting token from: {}", url));
154
155        // Build request with optional basic auth
156        let mut request = self.client.get(url);
157
158        if let (Some(user), Some(pass)) = (username, password) {
159            output.verbose(&format!("Using basic auth for user: {}", user));
160            request = request.basic_auth(user, Some(pass));
161        }
162
163        let response = request
164            .send()
165            .await
166            .map_err(|e| RegistryError::Network(format!("Failed to request auth token: {}", e)))?;
167
168        if response.status().is_success() {
169            let auth_response: AuthResponse = response.json().await.map_err(|e| {
170                RegistryError::Registry(format!("Failed to parse auth response: {}", e))
171            })?;
172
173            output.success("Successfully obtained authentication token");
174            output.verbose(&format!(
175                "Token expires in: {:?} seconds",
176                auth_response.expires_in
177            ));
178
179            Ok(Some(auth_response.token))
180        } else {
181            let status = response.status();
182            let error_text = response
183                .text()
184                .await
185                .unwrap_or_else(|_| "Unknown error".to_string());
186            Err(RegistryError::Registry(format!(
187                "Authentication failed (status {}): {}",
188                status, error_text
189            )))
190        }
191    }
192
193    pub async fn login(
194        &self,
195        username: &str,
196        _password: &str,
197        output: &Logger,
198    ) -> Result<Option<String>> {
199        output.info(&format!("Authenticating user: {}", username));
200
201        // This is a placeholder - in real usage, registry URL should be provided
202        output.warning("login() method is deprecated, use authenticate_with_registry() instead");
203        Ok(Some("dummy_token".to_string()))
204    }
205
206    pub async fn get_repository_token(
207        &self,
208        _username: &str,
209        _password: &str,
210        repository: &str,
211        output: &Logger,
212    ) -> Result<Option<String>> {
213        output.info(&format!("Getting repository token for: {}", repository));
214
215        // This is a placeholder - in real usage, registry URL should be provided
216        output.warning(
217            "get_repository_token() method is deprecated, use authenticate_with_registry() instead",
218        );
219        Ok(Some("dummy_repo_token".to_string()))
220    }
221
222    pub async fn get_token(
223        &self,
224        _registry: &str,
225        _repo: &str,
226        _username: Option<&str>,
227        _password: Option<&str>,
228    ) -> Result<String> {
229        // Placeholder for backward compatibility
230        Ok("token".to_string())
231    }
232}