docker_image_pusher/registry/
auth.rs

1//! Authentication module for Docker registry access
2
3use crate::error::{Result, PusherError};
4use reqwest::{Client, header::{AUTHORIZATION, WWW_AUTHENTICATE}};
5use serde::Deserialize;
6use base64::{Engine as _, engine::general_purpose};
7
8#[derive(Deserialize, Debug)]
9struct AuthResponse {
10    token: Option<String>,
11    access_token: Option<String>,
12}
13
14#[derive(Debug)]
15pub struct AuthChallenge {
16    pub realm: String,
17    pub service: String,
18    pub scope: Option<String>,
19}
20
21impl AuthChallenge {
22    pub fn parse(auth_header: &str) -> Result<Self> {
23        let mut realm = String::new();
24        let mut service = String::new();
25        let mut scope = None;
26
27        if auth_header.starts_with("Bearer ") {
28            let params = &auth_header[7..]; // Remove "Bearer "
29            for param in params.split(',') {
30                let param = param.trim();
31                if let Some((key, value)) = param.split_once('=') {
32                    let value = value.trim_matches('"');
33                    match key {
34                        "realm" => realm = value.to_string(),
35                        "service" => service = value.to_string(),
36                        "scope" => scope = Some(value.to_string()),
37                        _ => {}
38                    }
39                }
40            }
41        }
42
43        if realm.is_empty() || service.is_empty() {
44            return Err(PusherError::Authentication("Invalid auth challenge format".to_string()));
45        }
46
47        Ok(AuthChallenge { realm, service, scope })
48    }
49}
50
51pub struct Auth {
52    client: Client,
53    registry_url: String,
54}
55
56impl Auth {
57    pub fn new(registry_url: &str, skip_tls: bool) -> Result<Self> {
58        let client = if skip_tls {
59            Client::builder()
60                .danger_accept_invalid_certs(true)
61                .danger_accept_invalid_hostnames(true)
62                .build()
63                .map_err(PusherError::Network)?
64        } else {
65            Client::new()
66        };
67
68        Ok(Auth {
69            client,
70            registry_url: registry_url.to_string(),
71        })
72    }
73
74    pub async fn get_auth_challenge(&self) -> Result<Option<AuthChallenge>> {
75        let url = format!("{}/v2/", self.registry_url);
76        println!("  Getting auth challenge from: {}", url);
77        
78        let response = self.client.get(&url).send().await?;
79        println!("  Auth challenge response status: {}", response.status());
80        
81        if response.status() == 401 {
82            if let Some(auth_header) = response.headers().get(WWW_AUTHENTICATE) {
83                let auth_str = auth_header.to_str()
84                    .map_err(|e| PusherError::Authentication(format!("Invalid auth header: {}", e)))?;
85                println!("  WWW-Authenticate header: {}", auth_str);
86                return Ok(Some(AuthChallenge::parse(auth_str)?));
87            }
88        }
89        
90        Ok(None)
91    }
92
93    pub async fn get_token_for_repository(
94        &self,
95        challenge: &AuthChallenge,
96        username: &str,
97        password: &str,
98        repository: &str,
99    ) -> Result<String> {
100        let credentials = general_purpose::STANDARD.encode(format!("{}:{}", username, password));
101        
102        // For Harbor, we need the exact repository scope with push permission
103        let scope = format!("repository:{}:pull,push", repository);
104        let query_params = vec![
105            ("service", challenge.service.as_str()),
106            ("scope", scope.as_str()),
107        ];
108        
109        println!("  Token request to: {}", challenge.realm);
110        println!("  Service: {}", challenge.service);
111        println!("  Scope: {}", scope);
112        println!("  Username: {}", username);
113        
114        self.request_token(&challenge.realm, &credentials, &query_params).await
115    }
116
117    pub async fn get_token(
118        &self,
119        challenge: &AuthChallenge,
120        username: &str,
121        password: &str,
122        repository: Option<&str>,
123    ) -> Result<String> {
124        let credentials = general_purpose::STANDARD.encode(format!("{}:{}", username, password));
125        
126        let mut query_params = vec![("service", challenge.service.as_str())];
127        
128        // Build scope for Harbor
129        let scope_value = if let Some(repo) = repository {
130            format!("repository:{}:pull,push", repo)
131        } else {
132            String::new()
133        };
134        
135        if !scope_value.is_empty() {
136            query_params.push(("scope", scope_value.as_str()));
137            println!("  Token request to: {}", challenge.realm);
138            println!("  Service: {}", challenge.service);
139            println!("  Scope: {}", scope_value);
140            println!("  Username: {}", username);
141        }
142        
143        match self.request_token(&challenge.realm, &credentials, &query_params).await {
144            Ok(token) => Ok(token),
145            Err(e) => {
146                println!("  Token request failed: {}", e);
147                // Retry without scope if failed
148                if !scope_value.is_empty() {
149                    println!("  Retrying without scope...");
150                    let basic_params = vec![("service", challenge.service.as_str())];
151                    self.request_token(&challenge.realm, &credentials, &basic_params).await
152                } else {
153                    Err(PusherError::Authentication("Token request failed".to_string()))
154                }
155            }
156        }
157    }
158
159    async fn request_token(
160        &self,
161        realm: &str,
162        credentials: &str,
163        query_params: &[(&str, &str)],
164    ) -> Result<String> {
165        let response = self.client
166            .get(realm)
167            .header(AUTHORIZATION, format!("Basic {}", credentials))
168            .query(query_params)
169            .send()
170            .await?;
171
172        println!("  Token response status: {}", response.status());
173
174        if response.status().is_success() {
175            let response_text = response.text().await?;
176            println!("  Token response body: {}", response_text);
177            
178            let auth_response: AuthResponse = serde_json::from_str(&response_text)
179                .map_err(|e| PusherError::Authentication(format!("Failed to parse token response: {}", e)))?;
180            
181            let token = auth_response.token
182                .or(auth_response.access_token)
183                .ok_or_else(|| PusherError::Authentication("No token in response".to_string()))?;
184            
185            println!("  Token received successfully");
186            Ok(token)
187        } else {
188            let error_text = response.text().await?;
189            println!("  Token request failed with body: {}", error_text);
190            Err(PusherError::Authentication(format!("Token request failed: {}", error_text)))
191        }
192    }
193
194    pub async fn login(&self, username: &str, password: &str) -> Result<Option<String>> {
195        if let Some(challenge) = self.get_auth_challenge().await? {
196            println!("Auth challenge received from registry:");
197            println!("  Realm: {}", challenge.realm);
198            println!("  Service: {}", challenge.service);
199            if let Some(scope) = &challenge.scope {
200                println!("  Scope: {}", scope);
201            }
202            let token = self.get_token(&challenge, username, password, None).await?;
203            Ok(Some(token))
204        } else {
205            println!("No auth challenge - registry may not require authentication");
206            Ok(None)
207        }
208    }
209
210    pub async fn login_with_repository(&self, username: &str, password: &str, repository: &str) -> Result<Option<String>> {
211        if let Some(challenge) = self.get_auth_challenge().await? {
212            println!("Getting repository-specific token for: {}", repository);
213            let token = self.get_token_for_repository(&challenge, username, password, repository).await?;
214            Ok(Some(token))
215        } else {
216            Ok(None)
217        }
218    }
219}