Skip to content

Commit f7fb64f

Browse files
CopilotMossaka
andcommitted
Add OCI authentication infrastructure with Docker config support
- Add docker_credential dependency to wassette crate - Create new oci_auth module with get_registry_auth() function - Update loader.rs to use authentication for OCI pulls - Update oci_multi_layer.rs to accept and use auth parameter - Add comprehensive unit tests for Docker config parsing - Handle missing config files gracefully (fallback to Anonymous) - Support both single-layer and multi-layer OCI artifact authentication Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com>
1 parent a7f6232 commit f7fb64f

File tree

7 files changed

+241
-11
lines changed

7 files changed

+241
-11
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/wassette/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ license.workspace = true
77
[dependencies]
88
anyhow = { workspace = true }
99
component2json = { path = "../component2json" }
10+
docker_credential = "1.3"
1011
etcetera = { workspace = true }
1112
futures = { workspace = true }
1213
hex = "0.4"

crates/wassette/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ mod component_storage;
3030
mod config;
3131
mod http;
3232
mod loader;
33+
pub mod oci_auth;
3334
pub mod oci_multi_layer;
3435
mod policy_internal;
3536
mod runtime_context;

crates/wassette/src/loader.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -200,11 +200,13 @@ impl Loadable for ComponentResource {
200200
eprintln!("Downloading component from {}...", reference);
201201
}
202202

203+
// Get authentication credentials for this registry
204+
let auth = crate::oci_auth::get_registry_auth(&reference)
205+
.context("Failed to get registry authentication")?;
206+
203207
// First try oci-wasm for backwards compatibility with single-layer artifacts
204208
let wasm_client = oci_wasm::WasmClient::from(oci_client.clone());
205-
let result = wasm_client
206-
.pull(&reference, &oci_client::secrets::RegistryAuth::Anonymous)
207-
.await;
209+
let result = wasm_client.pull(&reference, &auth).await;
208210

209211
match result {
210212
Ok(data) => {
@@ -237,6 +239,7 @@ impl Loadable for ComponentResource {
237239
let artifact = crate::oci_multi_layer::pull_multi_layer_artifact_with_progress(
238240
&reference,
239241
oci_client,
242+
&auth,
240243
show_progress,
241244
)
242245
.await

crates/wassette/src/oci_auth.rs

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
//! OCI registry authentication support
5+
//!
6+
//! This module provides authentication for OCI registries by reading Docker config files
7+
//! and extracting credentials. It supports both username/password and identity token
8+
//! authentication methods.
9+
10+
use anyhow::{Context, Result};
11+
use docker_credential::{CredentialRetrievalError, DockerCredential};
12+
use oci_client::secrets::RegistryAuth;
13+
use oci_client::Reference;
14+
use tracing::{debug, warn};
15+
16+
/// Get authentication credentials for an OCI registry reference
17+
///
18+
/// This function attempts to read credentials from the Docker config file
19+
/// (typically `~/.docker/config.json`). It follows the standard Docker credential
20+
/// resolution process:
21+
///
22+
/// 1. Check `$DOCKER_CONFIG/config.json` if the env var is set
23+
/// 2. Check `~/.docker/config.json` as the default location
24+
/// 3. Fall back to `Anonymous` if no config or credentials are found
25+
///
26+
/// # Arguments
27+
///
28+
/// * `reference` - The OCI reference to get credentials for
29+
///
30+
/// # Returns
31+
///
32+
/// Returns a `RegistryAuth` enum that can be one of:
33+
/// - `Anonymous` - No credentials found or config doesn't exist
34+
/// - `Basic(username, password)` - Username/password credentials
35+
/// - `Bearer(token)` - Not currently supported, falls back to Anonymous
36+
///
37+
/// # Errors
38+
///
39+
/// Returns an error if the Docker config file exists but cannot be parsed
40+
/// or if credential retrieval fails for reasons other than missing config.
41+
pub fn get_registry_auth(reference: &Reference) -> Result<RegistryAuth> {
42+
// Get the registry server address from the reference
43+
// Strip trailing slash if present for consistent matching
44+
let server = reference
45+
.resolve_registry()
46+
.strip_suffix('/')
47+
.unwrap_or_else(|| reference.resolve_registry());
48+
49+
debug!("Looking up credentials for registry: {}", server);
50+
51+
// Attempt to retrieve credentials using docker_credential crate
52+
match docker_credential::get_credential(server) {
53+
Ok(DockerCredential::UsernamePassword(username, password)) => {
54+
debug!("Found Docker credentials for registry: {}", server);
55+
Ok(RegistryAuth::Basic(username, password))
56+
}
57+
Ok(DockerCredential::IdentityToken(_)) => {
58+
// Identity tokens are not supported by oci-client yet
59+
warn!(
60+
"Identity token authentication found for {} but is not supported. Using anonymous access.",
61+
server
62+
);
63+
Ok(RegistryAuth::Anonymous)
64+
}
65+
Err(CredentialRetrievalError::ConfigNotFound) => {
66+
debug!("Docker config file not found, using anonymous authentication");
67+
Ok(RegistryAuth::Anonymous)
68+
}
69+
Err(CredentialRetrievalError::ConfigReadError) => {
70+
debug!("Unable to read Docker config file, using anonymous authentication");
71+
Ok(RegistryAuth::Anonymous)
72+
}
73+
Err(CredentialRetrievalError::NoCredentialConfigured) => {
74+
debug!(
75+
"No credentials configured for registry {}, using anonymous authentication",
76+
server
77+
);
78+
Ok(RegistryAuth::Anonymous)
79+
}
80+
Err(e) => {
81+
// For other errors (helper failures, decoding errors, etc.), return the error
82+
Err(e).context(format!(
83+
"Failed to retrieve credentials for registry {}",
84+
server
85+
))
86+
}
87+
}
88+
}
89+
90+
#[cfg(test)]
91+
mod tests {
92+
use std::fs;
93+
use std::path::PathBuf;
94+
95+
use tempfile::TempDir;
96+
97+
use super::*;
98+
99+
fn create_test_docker_config(dir: &TempDir, config_content: &str) -> PathBuf {
100+
let config_dir = dir.path().join(".docker");
101+
fs::create_dir_all(&config_dir).unwrap();
102+
let config_path = config_dir.join("config.json");
103+
fs::write(&config_path, config_content).unwrap();
104+
config_path
105+
}
106+
107+
#[test]
108+
fn test_get_registry_auth_with_basic_credentials() {
109+
use temp_env;
110+
111+
let temp_dir = TempDir::new().unwrap();
112+
113+
// Create a test Docker config with basic auth
114+
let config_content = r#"{
115+
"auths": {
116+
"ghcr.io": {
117+
"auth": "dGVzdHVzZXI6dGVzdHBhc3M="
118+
}
119+
}
120+
}"#;
121+
122+
let config_path = create_test_docker_config(&temp_dir, config_content);
123+
124+
// Set DOCKER_CONFIG to point to our test directory
125+
let docker_config_dir = config_path.parent().unwrap();
126+
127+
temp_env::with_var("DOCKER_CONFIG", Some(docker_config_dir), || {
128+
let reference: Reference = "ghcr.io/test/image:latest".parse().unwrap();
129+
let auth = get_registry_auth(&reference).unwrap();
130+
131+
match auth {
132+
RegistryAuth::Basic(username, password) => {
133+
assert_eq!(username, "testuser");
134+
assert_eq!(password, "testpass");
135+
}
136+
_ => panic!("Expected Basic auth, got: {:?}", auth),
137+
}
138+
});
139+
}
140+
141+
#[test]
142+
fn test_get_registry_auth_no_config() {
143+
use temp_env;
144+
145+
let temp_dir = TempDir::new().unwrap();
146+
147+
// Set DOCKER_CONFIG to empty temp dir (no config.json)
148+
temp_env::with_var("DOCKER_CONFIG", Some(temp_dir.path()), || {
149+
let reference: Reference = "docker.io/library/nginx:latest".parse().unwrap();
150+
let auth = get_registry_auth(&reference).unwrap();
151+
152+
assert!(
153+
matches!(auth, RegistryAuth::Anonymous),
154+
"Expected Anonymous auth when config not found"
155+
);
156+
});
157+
}
158+
159+
#[test]
160+
fn test_get_registry_auth_no_credentials_for_registry() {
161+
use temp_env;
162+
163+
let temp_dir = TempDir::new().unwrap();
164+
165+
// Create config with credentials for a different registry
166+
let config_content = r#"{
167+
"auths": {
168+
"other-registry.io": {
169+
"auth": "dGVzdHVzZXI6dGVzdHBhc3M="
170+
}
171+
}
172+
}"#;
173+
174+
let config_path = create_test_docker_config(&temp_dir, config_content);
175+
let docker_config_dir = config_path.parent().unwrap();
176+
177+
temp_env::with_var("DOCKER_CONFIG", Some(docker_config_dir), || {
178+
// Try to get auth for a registry not in the config
179+
let reference: Reference = "docker.io/library/nginx:latest".parse().unwrap();
180+
let auth = get_registry_auth(&reference).unwrap();
181+
182+
assert!(
183+
matches!(auth, RegistryAuth::Anonymous),
184+
"Expected Anonymous auth when no credentials for registry"
185+
);
186+
});
187+
}
188+
189+
#[test]
190+
fn test_reference_parsing() {
191+
// Test that various OCI references parse correctly
192+
let test_cases = vec![
193+
"ghcr.io/microsoft/wassette:latest",
194+
"docker.io/library/nginx:1.0",
195+
"localhost:5000/myimage:v1",
196+
];
197+
198+
for reference_str in test_cases {
199+
let reference: Reference = reference_str.parse().unwrap();
200+
let registry = reference.resolve_registry();
201+
assert!(!registry.is_empty(), "Registry should not be empty");
202+
}
203+
}
204+
205+
#[test]
206+
fn test_registry_server_stripping() {
207+
// Test that trailing slashes are handled correctly
208+
let reference: Reference = "ghcr.io/test/image:latest".parse().unwrap();
209+
let server = reference
210+
.resolve_registry()
211+
.strip_suffix('/')
212+
.unwrap_or_else(|| reference.resolve_registry());
213+
214+
// Should not have trailing slash
215+
assert!(!server.ends_with('/'), "Server should not end with slash");
216+
}
217+
}

crates/wassette/src/oci_multi_layer.rs

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -97,25 +97,25 @@ fn verify_digest(data: &[u8], expected_digest: &str) -> Result<()> {
9797
pub async fn pull_multi_layer_artifact(
9898
reference: &Reference,
9999
client: &Client,
100+
auth: &oci_client::secrets::RegistryAuth,
100101
) -> Result<MultiLayerArtifact> {
101-
pull_multi_layer_artifact_with_progress(reference, client, false).await
102+
pull_multi_layer_artifact_with_progress(reference, client, auth, false).await
102103
}
103104

104105
/// Pull a multi-layer OCI artifact and extract all relevant layers with optional progress reporting
105106
pub async fn pull_multi_layer_artifact_with_progress(
106107
reference: &Reference,
107108
client: &Client,
109+
auth: &oci_client::secrets::RegistryAuth,
108110
show_progress: bool,
109111
) -> Result<MultiLayerArtifact> {
110-
let auth = oci_client::secrets::RegistryAuth::Anonymous;
111-
112112
// Pull just the manifest first
113113
if show_progress {
114114
eprintln!("Pulling manifest for {}...", reference);
115115
}
116116
info!("Pulling OCI manifest: {}", reference);
117117
let (manifest, manifest_digest) = client
118-
.pull_manifest(reference, &auth)
118+
.pull_manifest(reference, auth)
119119
.await
120120
.context("Failed to pull OCI manifest")?;
121121

@@ -276,8 +276,12 @@ pub async fn pull_multi_layer_artifact_with_progress(
276276

277277
/// Pull just the WASM component from a multi-layer OCI artifact
278278
/// This is a compatibility function that ignores non-WASM layers
279-
pub async fn pull_wasm_only(reference: &Reference, client: &Client) -> Result<Vec<u8>> {
280-
let artifact = pull_multi_layer_artifact(reference, client).await?;
279+
pub async fn pull_wasm_only(
280+
reference: &Reference,
281+
client: &Client,
282+
auth: &oci_client::secrets::RegistryAuth,
283+
) -> Result<Vec<u8>> {
284+
let artifact = pull_multi_layer_artifact(reference, client, auth).await?;
281285

282286
if artifact.policy_data.is_some() {
283287
info!("Note: Policy layer found but will not be processed in this context");
@@ -298,10 +302,11 @@ pub async fn pull_wasm_only(reference: &Reference, client: &Client) -> Result<Ve
298302
pub async fn pull_multi_layer_artifact_secure(
299303
reference: &Reference,
300304
client: &Client,
305+
auth: &oci_client::secrets::RegistryAuth,
301306
) -> Result<MultiLayerArtifact> {
302307
// This uses the same implementation as pull_multi_layer_artifact
303308
// since we've already added digest verification there
304-
pull_multi_layer_artifact_with_progress(reference, client, false).await
309+
pull_multi_layer_artifact_with_progress(reference, client, auth, false).await
305310
}
306311

307312
#[cfg(test)]

tests/oci_integration_test.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,8 +252,10 @@ mod multi_layer_oci_tests {
252252
..Default::default()
253253
});
254254

255+
let auth = oci_client::secrets::RegistryAuth::Anonymous;
255256
let artifact =
256-
wassette::oci_multi_layer::pull_multi_layer_artifact(&reference, &client).await?;
257+
wassette::oci_multi_layer::pull_multi_layer_artifact(&reference, &client, &auth)
258+
.await?;
257259

258260
// Verify WASM component was downloaded
259261
assert!(!artifact.wasm_data.is_empty());

0 commit comments

Comments
 (0)