docker_image_pusher/registry/
auth.rs

1//! Authentication module for Docker registry access
2
3use crate::error::handlers::HttpErrorHandler;
4use crate::error::{PusherError, Result};
5use crate::output::OutputManager;
6use reqwest::Client;
7use serde::Deserialize;
8
9#[derive(Debug, Deserialize)]
10struct AuthChallenge {
11    realm: String,
12    service: String,
13    scope: Option<String>,
14}
15
16#[derive(Debug, Deserialize)]
17struct TokenResponse {
18    token: Option<String>,
19    access_token: Option<String>,
20}
21
22#[derive(Debug, Clone)]
23pub struct Auth {
24    client: Client,
25    registry_address: String,
26}
27
28impl Auth {
29    pub fn new(registry_address: &str, skip_tls: bool) -> Result<Self> {
30        let client_builder = if skip_tls {
31            Client::builder()
32                .danger_accept_invalid_certs(true)
33                .danger_accept_invalid_hostnames(true)
34        } else {
35            Client::builder()
36        };
37
38        let client = client_builder
39            .build()
40            .map_err(|e| PusherError::Network(format!("Failed to build auth client: {}", e)))?;
41
42        Ok(Self {
43            client,
44            registry_address: registry_address.to_string(),
45        })
46    }
47
48    pub async fn login(
49        &self,
50        username: &str,
51        password: &str,
52        output: &OutputManager,
53    ) -> Result<Option<String>> {
54        output.verbose("Attempting to authenticate with registry...");
55
56        // First, try to access the v2 API to get auth challenge
57        let v2_url = format!("{}/v2/", self.registry_address);
58        let response =
59            self.client.get(&v2_url).send().await.map_err(|e| {
60                PusherError::Network(format!("Failed to access registry API: {}", e))
61            })?;
62
63        if response.status() == 401 {
64            // Parse WWW-Authenticate header
65            if let Some(auth_header) = response.headers().get("www-authenticate") {
66                let auth_str = auth_header.to_str().map_err(|e| {
67                    PusherError::Authentication(format!("Invalid auth header: {}", e))
68                })?;
69
70                if auth_str.starts_with("Bearer ") {
71                    return self
72                        .handle_bearer_auth(auth_str, username, password, output)
73                        .await;
74                }
75            }
76        }
77
78        // If no authentication required or unsupported auth method
79        output.info("No authentication required or unsupported authentication method");
80        Ok(None)
81    }
82
83    // Enhanced method to get repository-specific token
84    pub async fn get_repository_token(
85        &self,
86        username: &str,
87        password: &str,
88        repository: &str,
89        output: &OutputManager,
90    ) -> Result<Option<String>> {
91        output.verbose(&format!(
92            "Getting repository-specific token for: {}",
93            repository
94        ));
95
96        // Try to access a repository-specific endpoint to get proper scope
97        let repo_url = format!("{}/v2/{}/blobs/uploads/", self.registry_address, repository);
98        let response = self.client.post(&repo_url).send().await.map_err(|e| {
99            PusherError::Network(format!("Failed to access repository endpoint: {}", e))
100        })?;
101
102        if response.status() == 401 {
103            if let Some(auth_header) = response.headers().get("www-authenticate") {
104                let auth_str = auth_header.to_str().map_err(|e| {
105                    PusherError::Authentication(format!("Invalid auth header: {}", e))
106                })?;
107
108                if auth_str.starts_with("Bearer ") {
109                    return self
110                        .handle_bearer_auth_with_scope(
111                            auth_str, username, password, repository, output,
112                        )
113                        .await;
114                }
115            }
116        }
117
118        // Fallback to general token
119        self.login(username, password, output).await
120    }
121
122    async fn handle_bearer_auth(
123        &self,
124        auth_header: &str,
125        username: &str,
126        password: &str,
127        output: &OutputManager,
128    ) -> Result<Option<String>> {
129        let challenge = self.parse_auth_challenge(auth_header)?;
130
131        output.verbose(&format!(
132            "Bearer auth challenge: realm={}, service={}",
133            challenge.realm, challenge.service
134        ));
135
136        // Request token from auth service
137        let mut token_url = format!("{}?service={}", challenge.realm, challenge.service);
138        if let Some(scope) = &challenge.scope {
139            token_url.push_str(&format!("&scope={}", scope));
140        }
141
142        self.request_token(&token_url, username, password, output)
143            .await
144    }
145
146    async fn handle_bearer_auth_with_scope(
147        &self,
148        auth_header: &str,
149        username: &str,
150        password: &str,
151        repository: &str,
152        output: &OutputManager,
153    ) -> Result<Option<String>> {
154        let challenge = self.parse_auth_challenge(auth_header)?;
155
156        output.verbose(&format!(
157            "Bearer auth challenge for {}: realm={}, service={}",
158            repository, challenge.realm, challenge.service
159        ));
160
161        // Build scope for push access to the specific repository
162        let scope = format!("repository:{}:push,pull", repository);
163        let token_url = format!(
164            "{}?service={}&scope={}",
165            challenge.realm, challenge.service, scope
166        );
167
168        output.verbose(&format!("Requesting token with scope: {}", scope));
169
170        self.request_token(&token_url, username, password, output)
171            .await
172    }
173
174    async fn request_token(
175        &self,
176        token_url: &str,
177        username: &str,
178        password: &str,
179        output: &OutputManager,
180    ) -> Result<Option<String>> {
181        output.verbose(&format!("Token request URL: {}", token_url));
182
183        let response = self
184            .client
185            .get(token_url)
186            .basic_auth(username, Some(password))
187            .send()
188            .await
189            .map_err(|e| PusherError::Network(format!("Failed to request auth token: {}", e)))?;
190
191        output.verbose(&format!("Token response status: {}", response.status()));
192
193        if response.status().is_success() {
194            let token_response: TokenResponse = response.json().await.map_err(|e| {
195                PusherError::Authentication(format!("Failed to parse token response: {}", e))
196            })?;
197
198            let token = token_response
199                .token
200                .or(token_response.access_token)
201                .ok_or_else(|| {
202                    PusherError::Authentication("No token in auth response".to_string())
203                })?;
204
205            output.success("Authentication token obtained");
206            output.verbose(&format!("Token prefix: {}...", &token[..10]));
207            Ok(Some(token))
208        } else {
209            let status = response.status();
210            let error_text = response
211                .text()
212                .await
213                .unwrap_or_else(|_| "Failed to read error response".to_string());
214
215            output.error(&format!(
216                "Token request failed (status {}): {}",
217                status, error_text
218            ));
219
220            Err(HttpErrorHandler::handle_auth_error(status, &error_text))
221        }
222    }
223
224    fn parse_auth_challenge(&self, auth_header: &str) -> Result<AuthChallenge> {
225        // Parse Bearer realm="...",service="...",scope="..."
226        let mut realm = String::new();
227        let mut service = String::new();
228        let mut scope = None;
229
230        // Remove "Bearer " prefix
231        let params = auth_header
232            .strip_prefix("Bearer ")
233            .ok_or_else(|| PusherError::Authentication("Invalid Bearer auth header".to_string()))?;
234
235        // Simple parsing of key=value pairs
236        for param in params.split(',') {
237            let param = param.trim();
238            if let Some(eq_pos) = param.find('=') {
239                let key = param[..eq_pos].trim();
240                let value = param[eq_pos + 1..].trim().trim_matches('"');
241
242                match key {
243                    "realm" => realm = value.to_string(),
244                    "service" => service = value.to_string(),
245                    "scope" => scope = Some(value.to_string()),
246                    _ => {} // Ignore unknown parameters
247                }
248            }
249        }
250
251        if realm.is_empty() || service.is_empty() {
252            return Err(PusherError::Authentication(
253                "Invalid auth challenge format".to_string(),
254            ));
255        }
256
257        Ok(AuthChallenge {
258            realm,
259            service,
260            scope,
261        })
262    }
263}