Skip to content

Commit 7ab1c90

Browse files
committed
fix: implement proper registry auth flow
1 parent f6344da commit 7ab1c90

1 file changed

Lines changed: 98 additions & 33 deletions

File tree

create-a-container/bin/create-container.js

Lines changed: 98 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -35,32 +35,102 @@ const { Container, Node, Site } = db;
3535
const { parseArgs } = require(path.join(__dirname, '..', 'utils', 'cli'));
3636

3737
/**
38-
* Fetch JSON from a URL with optional headers
38+
* Low-level HTTP GET that returns status, headers, and body without throwing on 4xx
3939
* @param {string} url - The URL to fetch
40-
* @param {object} headers - Optional headers
41-
* @returns {Promise<object>} Parsed JSON response
40+
* @param {object} headers - Optional request headers
41+
* @returns {Promise<{statusCode: number, headers: object, body: string}>}
4242
*/
43-
function fetchJson(url, headers = {}) {
43+
function httpGet(url, headers = {}) {
4444
return new Promise((resolve, reject) => {
4545
const req = https.get(url, { headers }, (res) => {
4646
let data = '';
4747
res.on('data', chunk => data += chunk);
4848
res.on('end', () => {
49-
if (res.statusCode >= 400) {
50-
reject(new Error(`HTTP ${res.statusCode}: ${data}`));
51-
} else {
52-
try {
53-
resolve(JSON.parse(data));
54-
} catch (e) {
55-
reject(new Error(`Failed to parse JSON: ${e.message}`));
56-
}
57-
}
49+
resolve({ statusCode: res.statusCode, headers: res.headers, body: data });
5850
});
5951
});
6052
req.on('error', reject);
6153
});
6254
}
6355

56+
/**
57+
* Fetch JSON from a URL with optional headers (throws on non-2xx)
58+
* @param {string} url - The URL to fetch
59+
* @param {object} headers - Optional headers
60+
* @returns {Promise<object>} Parsed JSON response
61+
*/
62+
async function fetchJson(url, headers = {}) {
63+
const res = await httpGet(url, headers);
64+
if (res.statusCode >= 400) {
65+
throw new Error(`HTTP ${res.statusCode}: ${res.body}`);
66+
}
67+
return JSON.parse(res.body);
68+
}
69+
70+
/**
71+
* Parse a WWW-Authenticate Bearer challenge header
72+
* Example: Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/nginx:pull"
73+
* @param {string} header - The WWW-Authenticate header value
74+
* @returns {object|null} Parsed fields { realm, service, scope } or null if not Bearer
75+
*/
76+
function parseWwwAuthenticate(header) {
77+
if (!header || !header.startsWith('Bearer ')) return null;
78+
const params = {};
79+
const regex = /(\w+)="([^"]*)"/g;
80+
let match;
81+
while ((match = regex.exec(header)) !== null) {
82+
params[match[1]] = match[2];
83+
}
84+
return params;
85+
}
86+
87+
/**
88+
* Fetch JSON from a registry URL with automatic token authentication
89+
* Implements the Docker Registry Token Authentication spec:
90+
* 1. Attempt the request
91+
* 2. If 401, parse WWW-Authenticate for Bearer challenge
92+
* 3. Request a token from the auth service
93+
* 4. Retry with the Bearer token
94+
* @param {string} url - The registry URL to fetch
95+
* @param {object} headers - Optional request headers
96+
* @returns {Promise<object>} Parsed JSON response
97+
*/
98+
async function authenticatedFetchJson(url, headers = {}) {
99+
const res = await httpGet(url, headers);
100+
101+
if (res.statusCode === 200) {
102+
return JSON.parse(res.body);
103+
}
104+
105+
if (res.statusCode !== 401) {
106+
throw new Error(`HTTP ${res.statusCode}: ${res.body}`);
107+
}
108+
109+
// Parse the Bearer challenge from WWW-Authenticate header
110+
const challenge = parseWwwAuthenticate(res.headers['www-authenticate']);
111+
if (!challenge || !challenge.realm) {
112+
throw new Error(`Registry returned 401 but no Bearer challenge in WWW-Authenticate header`);
113+
}
114+
115+
// Build token request URL with query parameters from the challenge
116+
const tokenUrl = new URL(challenge.realm);
117+
if (challenge.service) tokenUrl.searchParams.set('service', challenge.service);
118+
if (challenge.scope) tokenUrl.searchParams.set('scope', challenge.scope);
119+
120+
const tokenData = await fetchJson(tokenUrl.toString());
121+
if (!tokenData.token) {
122+
throw new Error('Auth service did not return a token');
123+
}
124+
125+
// Retry the original request with the Bearer token
126+
headers['Authorization'] = `Bearer ${tokenData.token}`;
127+
const retryRes = await httpGet(url, headers);
128+
if (retryRes.statusCode >= 400) {
129+
throw new Error(`HTTP ${retryRes.statusCode} after auth: ${retryRes.body}`);
130+
}
131+
return JSON.parse(retryRes.body);
132+
}
133+
64134
/**
65135
* Get the digest (sha256 hash) of a Docker/OCI image from the registry
66136
* Handles both single-arch and multi-arch (manifest list) images
@@ -70,27 +140,20 @@ function fetchJson(url, headers = {}) {
70140
* @returns {Promise<string>} Short digest (first 12 chars of sha256)
71141
*/
72142
async function getImageDigest(registry, repo, tag) {
73-
let headers = {};
74-
75-
// Docker Hub requires auth token
76-
if (registry === 'docker.io' || registry === 'registry-1.docker.io') {
77-
const tokenUrl = `https://auth.docker.io/token?service=registry.docker.io&scope=repository:${repo}:pull`;
78-
const tokenData = await fetchJson(tokenUrl);
79-
headers['Authorization'] = `Bearer ${tokenData.token}`;
80-
}
81-
82143
const registryHost = registry === 'docker.io' ? 'registry-1.docker.io' : registry;
83144

84-
// Fetch manifest - accept both single manifest and manifest list
85-
headers['Accept'] = [
86-
'application/vnd.docker.distribution.manifest.v2+json',
87-
'application/vnd.oci.image.manifest.v1+json',
88-
'application/vnd.docker.distribution.manifest.list.v2+json',
89-
'application/vnd.oci.image.index.v1+json'
90-
].join(', ');
145+
// Fetch manifest with automatic registry auth challenge-response
146+
const acceptHeaders = {
147+
'Accept': [
148+
'application/vnd.docker.distribution.manifest.v2+json',
149+
'application/vnd.oci.image.manifest.v1+json',
150+
'application/vnd.docker.distribution.manifest.list.v2+json',
151+
'application/vnd.oci.image.index.v1+json'
152+
].join(', ')
153+
};
91154

92155
const manifestUrl = `https://${registryHost}/v2/${repo}/manifests/${tag}`;
93-
let manifest = await fetchJson(manifestUrl, headers);
156+
let manifest = await authenticatedFetchJson(manifestUrl, { ...acceptHeaders });
94157

95158
// Handle manifest list (multi-arch) - select amd64/linux
96159
if (manifest.manifests && Array.isArray(manifest.manifests)) {
@@ -101,10 +164,12 @@ async function getImageDigest(registry, repo, tag) {
101164
throw new Error('No amd64/linux manifest found in manifest list');
102165
}
103166

104-
// Fetch the actual manifest for amd64
105-
headers['Accept'] = 'application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json';
167+
// Fetch the actual manifest for amd64 (reuse same auth flow)
168+
const archHeaders = {
169+
'Accept': 'application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json'
170+
};
106171
const archManifestUrl = `https://${registryHost}/v2/${repo}/manifests/${amd64Manifest.digest}`;
107-
manifest = await fetchJson(archManifestUrl, headers);
172+
manifest = await authenticatedFetchJson(archManifestUrl, { ...archHeaders });
108173
}
109174

110175
// Get config digest from manifest

0 commit comments

Comments
 (0)