cfgd_core/oci/auth/
mod.rs1use std::collections::HashMap;
5
6use serde::Deserialize;
7
8use crate::errors::OciError;
9
10#[derive(Debug, Clone)]
12pub struct RegistryAuth {
13 pub username: String,
14 pub password: String,
15}
16
17#[derive(Debug, Default, Deserialize)]
19#[serde(rename_all = "camelCase")]
20pub(super) struct DockerConfig {
21 #[serde(default)]
22 pub(super) auths: HashMap<String, DockerAuthEntry>,
23 #[serde(default)]
24 pub(super) cred_helpers: HashMap<String, String>,
25}
26
27#[derive(Debug, Default, Deserialize)]
28pub(super) struct DockerAuthEntry {
29 pub(super) auth: Option<String>,
30}
31
32impl RegistryAuth {
33 pub fn resolve(registry: &str) -> Option<Self> {
40 if let (Ok(user), Ok(pass)) = (
42 std::env::var("REGISTRY_USERNAME"),
43 std::env::var("REGISTRY_PASSWORD"),
44 ) && !user.is_empty()
45 && !pass.is_empty()
46 {
47 return Some(RegistryAuth {
48 username: user,
49 password: pass,
50 });
51 }
52
53 let config_path = docker_config_path();
55 if let Ok(contents) = std::fs::read_to_string(&config_path)
56 && let Ok(config) = serde_json::from_str::<DockerConfig>(&contents)
57 {
58 if let Some(auth) = resolve_from_docker_auths(&config.auths, registry) {
60 return Some(auth);
61 }
62
63 if let Some(helper) = config.cred_helpers.get(registry)
65 && let Some(auth) = resolve_from_credential_helper(helper, registry)
66 {
67 return Some(auth);
68 }
69 }
70
71 None
72 }
73
74 pub(super) fn basic_auth_header(&self) -> String {
76 format!(
77 "Basic {}",
78 base64_encode(format!("{}:{}", self.username, self.password).as_bytes())
79 )
80 }
81}
82
83pub(super) fn docker_config_path() -> std::path::PathBuf {
84 if let Ok(dir) = std::env::var("DOCKER_CONFIG") {
85 return std::path::PathBuf::from(dir).join("config.json");
86 }
87 crate::expand_tilde(std::path::Path::new("~/.docker/config.json"))
88}
89
90pub(super) fn resolve_from_docker_auths(
93 auths: &HashMap<String, DockerAuthEntry>,
94 registry: &str,
95) -> Option<RegistryAuth> {
96 let candidates = [
98 registry.to_string(),
99 format!("https://{}", registry),
100 format!("https://{}/v2/", registry),
101 format!("https://{}/v1/", registry),
102 ];
103
104 for candidate in &candidates {
105 if let Some(entry) = auths.get(candidate)
106 && let Some(ref auth_b64) = entry.auth
107 && let Some(cred) = decode_docker_auth(auth_b64)
108 {
109 return Some(cred);
110 }
111 }
112
113 if registry == "docker.io" || registry == "index.docker.io" {
115 let alt_candidates = [
116 "https://index.docker.io/v1/".to_string(),
117 "index.docker.io".to_string(),
118 ];
119 for candidate in &alt_candidates {
120 if let Some(entry) = auths.get(candidate)
121 && let Some(ref auth_b64) = entry.auth
122 && let Some(cred) = decode_docker_auth(auth_b64)
123 {
124 return Some(cred);
125 }
126 }
127 }
128
129 None
130}
131
132pub(super) fn decode_docker_auth(auth_b64: &str) -> Option<RegistryAuth> {
134 let decoded = base64_decode(auth_b64)?;
135 let decoded_str = String::from_utf8(decoded).ok()?;
136 let (user, pass) = decoded_str.split_once(':')?;
137 if user.is_empty() {
138 return None;
139 }
140 Some(RegistryAuth {
141 username: user.to_string(),
142 password: pass.to_string(),
143 })
144}
145
146fn resolve_from_credential_helper(helper_name: &str, registry: &str) -> Option<RegistryAuth> {
148 let helper_bin = format!("docker-credential-{}", helper_name);
149 let output = std::process::Command::new(&helper_bin)
150 .arg("get")
151 .stdin(std::process::Stdio::piped())
152 .stdout(std::process::Stdio::piped())
153 .stderr(std::process::Stdio::null())
154 .spawn()
155 .ok()
156 .and_then(|mut child| {
157 use std::io::Write;
158 if let Some(ref mut stdin) = child.stdin {
159 stdin.write_all(registry.as_bytes()).ok();
160 }
161 drop(child.stdin.take()); let start = std::time::Instant::now();
163 let timeout = std::time::Duration::from_secs(10);
164 loop {
165 match child.try_wait() {
166 Ok(Some(_)) => return child.wait_with_output().ok(),
167 Ok(None) if start.elapsed() >= timeout => {
168 let _ = child.kill();
169 let _ = child.wait();
170 tracing::warn!(helper = %helper_bin, "credential helper timed out after {}s", timeout.as_secs());
171 return None;
172 }
173 Ok(None) => std::thread::sleep(std::time::Duration::from_millis(50)),
174 Err(_) => return None,
175 }
176 }
177 })?;
178
179 if !output.status.success() {
180 return None;
181 }
182
183 #[derive(Deserialize)]
184 struct CredHelperOutput {
185 #[serde(alias = "Username")]
186 username: String,
187 #[serde(alias = "Secret")]
188 secret: String,
189 }
190
191 let parsed: CredHelperOutput = serde_json::from_slice(&output.stdout).ok()?;
192 if parsed.username.is_empty() {
193 return None;
194 }
195 Some(RegistryAuth {
196 username: parsed.username,
197 password: parsed.secret,
198 })
199}
200
201pub(super) fn get_bearer_token(
204 agent: &ureq::Agent,
205 www_authenticate: &str,
206 auth: Option<&RegistryAuth>,
207) -> Result<String, OciError> {
208 let realm = extract_auth_param(www_authenticate, "realm");
210 let service = extract_auth_param(www_authenticate, "service");
211 let scope = extract_auth_param(www_authenticate, "scope");
212
213 let realm = realm.ok_or_else(|| OciError::AuthFailed {
214 registry: String::new(),
215 message: format!("missing realm in Www-Authenticate header: {www_authenticate}"),
216 })?;
217
218 let mut url = realm.to_string();
219 let mut params = Vec::new();
220 if let Some(svc) = service {
221 params.push(format!("service={}", svc));
222 }
223 if let Some(sc) = scope {
224 params.push(format!("scope={}", sc));
225 }
226 if !params.is_empty() {
227 url = format!("{}?{}", url, params.join("&"));
228 }
229
230 let mut req = agent.get(&url);
231 if let Some(cred) = auth {
232 req = req.set("Authorization", &cred.basic_auth_header());
233 }
234
235 let resp = req.call().map_err(|e| OciError::AuthFailed {
236 registry: String::new(),
237 message: format!("token request failed: {e}"),
238 })?;
239
240 #[derive(Deserialize)]
241 struct TokenResponse {
242 token: Option<String>,
243 access_token: Option<String>,
244 }
245
246 let body_str = resp.into_string().map_err(|e| OciError::AuthFailed {
247 registry: String::new(),
248 message: format!("cannot read token response body: {e}"),
249 })?;
250 let body: TokenResponse =
251 serde_json::from_str(&body_str).map_err(|e| OciError::AuthFailed {
252 registry: String::new(),
253 message: format!("invalid token response JSON: {e}"),
254 })?;
255
256 body.token
257 .or(body.access_token)
258 .ok_or_else(|| OciError::AuthFailed {
259 registry: String::new(),
260 message: "no token in response".to_string(),
261 })
262}
263
264pub(super) fn extract_auth_param<'a>(header: &'a str, param: &str) -> Option<&'a str> {
265 let search = format!("{param}=\"");
266 let start = header.find(&search)?;
267 let value_start = start + search.len();
268 let end = header[value_start..].find('"')?;
269 Some(&header[value_start..value_start + end])
270}
271
272pub(super) fn base64_encode(data: &[u8]) -> String {
277 const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
278 let mut result = String::new();
279 for chunk in data.chunks(3) {
280 let b0 = chunk[0] as u32;
281 let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
282 let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
283 let triple = (b0 << 16) | (b1 << 8) | b2;
284
285 result.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
286 result.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
287
288 if chunk.len() > 1 {
289 result.push(CHARS[((triple >> 6) & 0x3F) as usize] as char);
290 } else {
291 result.push('=');
292 }
293
294 if chunk.len() > 2 {
295 result.push(CHARS[(triple & 0x3F) as usize] as char);
296 } else {
297 result.push('=');
298 }
299 }
300 result
301}
302
303pub(super) fn base64_decode(input: &str) -> Option<Vec<u8>> {
304 fn char_val(c: u8) -> Option<u8> {
305 match c {
306 b'A'..=b'Z' => Some(c - b'A'),
307 b'a'..=b'z' => Some(c - b'a' + 26),
308 b'0'..=b'9' => Some(c - b'0' + 52),
309 b'+' => Some(62),
310 b'/' => Some(63),
311 b'=' => Some(0),
312 _ => None,
313 }
314 }
315
316 let input = input.trim();
317 if input.is_empty() {
318 return Some(Vec::new());
319 }
320
321 let bytes = input.as_bytes();
322 if !bytes.len().is_multiple_of(4) {
323 return None;
324 }
325
326 let mut result = Vec::with_capacity(bytes.len() * 3 / 4);
327 for chunk in bytes.chunks(4) {
328 let a = char_val(chunk[0])?;
329 let b = char_val(chunk[1])?;
330 let c = char_val(chunk[2])?;
331 let d = char_val(chunk[3])?;
332
333 let triple = ((a as u32) << 18) | ((b as u32) << 12) | ((c as u32) << 6) | (d as u32);
334
335 result.push(((triple >> 16) & 0xFF) as u8);
336 if chunk[2] != b'=' {
337 result.push(((triple >> 8) & 0xFF) as u8);
338 }
339 if chunk[3] != b'=' {
340 result.push((triple & 0xFF) as u8);
341 }
342 }
343
344 Some(result)
345}
346
347#[cfg(test)]
348mod tests;