Skip to main content

cfgd_core/oci/auth/
mod.rs

1// Registry authentication: Docker config.json, env vars, credential helpers,
2// bearer token exchange, and base64 encoding for HTTP Basic auth.
3
4use std::collections::HashMap;
5
6use serde::Deserialize;
7
8use crate::errors::OciError;
9
10/// Credentials for authenticating to an OCI registry.
11#[derive(Debug, Clone)]
12pub struct RegistryAuth {
13    pub username: String,
14    pub password: String,
15}
16
17/// Docker config.json structure (subset).
18#[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    /// Resolve credentials for the given registry hostname.
34    ///
35    /// Tries, in order:
36    /// 1. `REGISTRY_USERNAME` / `REGISTRY_PASSWORD` environment variables
37    /// 2. Docker config.json (`~/.docker/config.json`) — base64 auth field
38    /// 3. Docker credential helpers (`docker-credential-<helper>`)
39    pub fn resolve(registry: &str) -> Option<Self> {
40        // 1. Environment variables
41        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        // 2. Docker config.json
54        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            // Try direct auth entry
59            if let Some(auth) = resolve_from_docker_auths(&config.auths, registry) {
60                return Some(auth);
61            }
62
63            // Try credential helper
64            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    /// Returns the HTTP Basic auth header value.
75    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
90/// Try to find credentials in the Docker config auths map.
91/// Keys can be full URLs like `https://ghcr.io` or just hostnames like `ghcr.io`.
92pub(super) fn resolve_from_docker_auths(
93    auths: &HashMap<String, DockerAuthEntry>,
94    registry: &str,
95) -> Option<RegistryAuth> {
96    // Try exact match and common variants
97    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    // For docker.io, also check index.docker.io
114    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
132/// Decode a base64 `user:password` auth string from Docker config.
133pub(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
146/// Run a Docker credential helper to get credentials.
147fn 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()); // Close stdin so helper can proceed
162            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
201/// Attempt to get a bearer token for the given registry+repository scope.
202/// Registries return a 401 with a Www-Authenticate header pointing to a token endpoint.
203pub(super) fn get_bearer_token(
204    agent: &ureq::Agent,
205    www_authenticate: &str,
206    auth: Option<&RegistryAuth>,
207) -> Result<String, OciError> {
208    // Parse: Bearer realm="...",service="...",scope="..."
209    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
272// ---------------------------------------------------------------------------
273// Base64 helpers (no external dependency needed for this)
274// ---------------------------------------------------------------------------
275
276pub(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;