docker_image_pusher/registry/
auth.rs

1//! Authentication module for Docker registry access
2
3use crate::error::{Result, PusherError};
4use crate::error::handlers::HttpErrorHandler;
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(&self, username: &str, password: &str, output: &OutputManager) -> Result<Option<String>> {
49        output.verbose("Attempting to authenticate with registry...");
50        
51        // First, try to access the v2 API to get auth challenge
52        let v2_url = format!("{}/v2/", self.registry_address);
53        let response = self.client.get(&v2_url).send().await
54            .map_err(|e| PusherError::Network(format!("Failed to access registry API: {}", e)))?;
55        
56        if response.status() == 401 {
57            // Parse WWW-Authenticate header
58            if let Some(auth_header) = response.headers().get("www-authenticate") {
59                let auth_str = auth_header.to_str()
60                    .map_err(|e| PusherError::Authentication(format!("Invalid auth header: {}", e)))?;
61                
62                if auth_str.starts_with("Bearer ") {
63                    return self.handle_bearer_auth(auth_str, username, password, output).await;
64                }
65            }
66        }
67        
68        // If no authentication required or unsupported auth method
69        output.info("No authentication required or unsupported authentication method");
70        Ok(None)
71    }
72
73    // Enhanced method to get repository-specific token
74    pub async fn get_repository_token(
75        &self,
76        username: &str,
77        password: &str,
78        repository: &str,
79        output: &OutputManager,
80    ) -> Result<Option<String>> {
81        output.verbose(&format!("Getting repository-specific token for: {}", repository));
82        
83        // Try to access a repository-specific endpoint to get proper scope
84        let repo_url = format!("{}/v2/{}/blobs/uploads/", self.registry_address, repository);
85        let response = self.client.post(&repo_url).send().await
86            .map_err(|e| PusherError::Network(format!("Failed to access repository endpoint: {}", e)))?;
87        
88        if response.status() == 401 {
89            if let Some(auth_header) = response.headers().get("www-authenticate") {
90                let auth_str = auth_header.to_str()
91                    .map_err(|e| PusherError::Authentication(format!("Invalid auth header: {}", e)))?;
92                
93                if auth_str.starts_with("Bearer ") {
94                    return self.handle_bearer_auth_with_scope(auth_str, username, password, repository, output).await;
95                }
96            }
97        }
98        
99        // Fallback to general token
100        self.login(username, password, output).await
101    }
102
103    async fn handle_bearer_auth(
104        &self,
105        auth_header: &str,
106        username: &str,
107        password: &str,
108        output: &OutputManager,
109    ) -> Result<Option<String>> {
110        let challenge = self.parse_auth_challenge(auth_header)?;
111        
112        output.verbose(&format!("Bearer auth challenge: realm={}, service={}", 
113            challenge.realm, challenge.service));
114        
115        // Request token from auth service
116        let mut token_url = format!("{}?service={}", challenge.realm, challenge.service);
117        if let Some(scope) = &challenge.scope {
118            token_url.push_str(&format!("&scope={}", scope));
119        }
120        
121        self.request_token(&token_url, username, password, output).await
122    }
123
124    async fn handle_bearer_auth_with_scope(
125        &self,
126        auth_header: &str,
127        username: &str,
128        password: &str,
129        repository: &str,
130        output: &OutputManager,
131    ) -> Result<Option<String>> {
132        let challenge = self.parse_auth_challenge(auth_header)?;
133        
134        output.verbose(&format!("Bearer auth challenge for {}: realm={}, service={}", 
135            repository, challenge.realm, challenge.service));
136        
137        // Build scope for push access to the specific repository
138        let scope = format!("repository:{}:push,pull", repository);
139        let token_url = format!("{}?service={}&scope={}", challenge.realm, challenge.service, scope);
140        
141        output.verbose(&format!("Requesting token with scope: {}", scope));
142        
143        self.request_token(&token_url, username, password, output).await
144    }
145
146    async fn request_token(
147        &self,
148        token_url: &str,
149        username: &str,
150        password: &str,
151        output: &OutputManager,
152    ) -> Result<Option<String>> {
153        output.verbose(&format!("Token request URL: {}", token_url));
154        
155        let response = self.client
156            .get(token_url)
157            .basic_auth(username, Some(password))
158            .send()
159            .await
160            .map_err(|e| PusherError::Network(format!("Failed to request auth token: {}", e)))?;
161        
162        output.verbose(&format!("Token response status: {}", response.status()));
163        
164        if response.status().is_success() {
165            let token_response: TokenResponse = response.json().await
166                .map_err(|e| PusherError::Authentication(format!("Failed to parse token response: {}", e)))?;
167            
168            let token = token_response.token.or(token_response.access_token)
169                .ok_or_else(|| PusherError::Authentication("No token in auth response".to_string()))?;
170            
171            output.success("Authentication token obtained");
172            output.verbose(&format!("Token prefix: {}...", &token[..10]));
173            Ok(Some(token))
174        } else {
175            let status = response.status();
176            let error_text = response.text().await
177                .unwrap_or_else(|_| "Failed to read error response".to_string());
178            
179            output.error(&format!("Token request failed (status {}): {}", status, error_text));
180            
181            Err(HttpErrorHandler::handle_auth_error(status, &error_text))
182        }
183    }
184
185    fn parse_auth_challenge(&self, auth_header: &str) -> Result<AuthChallenge> {
186        // Parse Bearer realm="...",service="...",scope="..."
187        let mut realm = String::new();
188        let mut service = String::new();
189        let mut scope = None;
190        
191        // Remove "Bearer " prefix
192        let params = auth_header.strip_prefix("Bearer ")
193            .ok_or_else(|| PusherError::Authentication("Invalid Bearer auth header".to_string()))?;
194        
195        // Simple parsing of key=value pairs
196        for param in params.split(',') {
197            let param = param.trim();
198            if let Some(eq_pos) = param.find('=') {
199                let key = param[..eq_pos].trim();
200                let value = param[eq_pos + 1..].trim().trim_matches('"');
201                
202                match key {
203                    "realm" => realm = value.to_string(),
204                    "service" => service = value.to_string(),
205                    "scope" => scope = Some(value.to_string()),
206                    _ => {} // Ignore unknown parameters
207                }
208            }
209        }
210        
211        if realm.is_empty() || service.is_empty() {
212            return Err(PusherError::Authentication("Invalid auth challenge format".to_string()));
213        }
214        
215        Ok(AuthChallenge {
216            realm,
217            service,
218            scope,
219        })
220    }
221}