Skip to content

Commit 4b0bc84

Browse files
committed
Make DockerRegistry token handling more generic
Obtain token_url from `WWW-Authenticate` header if available
1 parent c64d689 commit 4b0bc84

File tree

1 file changed

+89
-22
lines changed

1 file changed

+89
-22
lines changed

binderhub/registry.py

Lines changed: 89 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import base64
55
import json
66
import os
7+
import re
78
from urllib.parse import urlparse
89

910
from tornado import httpclient
@@ -131,8 +132,7 @@ def _default_token_url(self):
131132
elif self.url.endswith(".docker.io"):
132133
return "https://auth.docker.io/token?service=registry.docker.io"
133134
else:
134-
# is gcr.io's token url common? If so, it might be worth defaulting
135-
# to https://registry.host/v2/token?service=registry.host
135+
# If necessary we'll look for the WWW-Authenticate header
136136
return ""
137137

138138
username = Unicode(
@@ -185,30 +185,87 @@ def _default_password(self):
185185
base64.b64decode(b64_auth.encode("utf-8")).decode("utf-8").split(":", 1)[1]
186186
)
187187

188+
def _parse_www_authenticate_header(self, header):
189+
# Header takes the form
190+
# WWW-Authenticate: Bearer realm="https://uk-london-1.ocir.io/12345678/docker/token",service="uk-london-1.ocir.io",scope=""
191+
self.log.debug(f"Parsing WWW-Authenticate {header}")
192+
193+
if not header.lower().startswith("bearer "):
194+
raise ValueError(f"Only WWW-Authenticate Bearer type supported: {header}")
195+
try:
196+
realm = re.search(r'realm="([^"]+)"', header).group(1)
197+
# Should service and scope parameters be optional instead of just empty?
198+
service = re.search(r'service="([^"]*)"', header).group(1)
199+
scope = re.search(r'scope="([^"]*)"', header).group(1)
200+
return realm, service, scope
201+
except AttributeError:
202+
raise ValueError(
203+
f"Expected WWW-Authenticate to include realm service scope: {header}"
204+
) from None
205+
206+
async def _get_token(self, client, token_url, service, scope):
207+
auth_req = httpclient.HTTPRequest(
208+
url_concat(
209+
token_url,
210+
{
211+
"scope": scope,
212+
"service": service,
213+
},
214+
),
215+
auth_username=self.username,
216+
auth_password=self.password,
217+
)
218+
self.log.debug(
219+
f"Getting registry token from {token_url} service={service} scope={scope}"
220+
)
221+
auth_resp = await client.fetch(auth_req)
222+
response_body = json.loads(auth_resp.body.decode("utf-8", "replace"))
223+
224+
if "token" in response_body.keys():
225+
token = response_body["token"]
226+
elif "access_token" in response_body.keys():
227+
token = response_body["access_token"]
228+
else:
229+
raise ValueError(f"No token in response from registry: {response_body}")
230+
return token
231+
232+
async def _get_image_manifest_from_www_authenticate(
233+
self, client, www_auth_header, url
234+
):
235+
realm, service, scope = self._parse_www_authenticate_header(www_auth_header)
236+
token = await self._get_token(client, realm, service, scope)
237+
req = httpclient.HTTPRequest(
238+
url,
239+
headers={"Authorization": f"Bearer {token}"},
240+
)
241+
self.log.debug(f"Getting image manifest from {url}")
242+
try:
243+
resp = await client.fetch(req)
244+
except httpclient.HTTPError as e:
245+
if e.code == 404:
246+
return None
247+
else:
248+
raise
249+
return json.loads(resp.body.decode("utf-8"))
250+
188251
async def get_image_manifest(self, image, tag):
252+
"""
253+
Get the manifest for an image.
254+
255+
image: The image name without the registry and tag
256+
tag: The image tag
257+
"""
189258
client = httpclient.AsyncHTTPClient()
190259
url = f"{self.url}/v2/{image}/manifests/{tag}"
260+
token = None
191261
# first, get a token to perform the manifest request
192262
if self.token_url:
193-
auth_req = httpclient.HTTPRequest(
194-
url_concat(
195-
self.token_url,
196-
{
197-
"scope": f"repository:{image}:pull",
198-
"service": "container_registry",
199-
},
200-
),
201-
auth_username=self.username,
202-
auth_password=self.password,
263+
token = await self._get_token(
264+
client,
265+
self.token_url,
266+
scope=f"repository:{image}:pull",
267+
service="container_registry",
203268
)
204-
auth_resp = await client.fetch(auth_req)
205-
response_body = json.loads(auth_resp.body.decode("utf-8", "replace"))
206-
207-
if "token" in response_body.keys():
208-
token = response_body["token"]
209-
elif "access_token" in response_body.keys():
210-
token = response_body["access_token"]
211-
212269
req = httpclient.HTTPRequest(
213270
url,
214271
headers={"Authorization": f"Bearer {token}"},
@@ -221,16 +278,26 @@ async def get_image_manifest(self, image, tag):
221278
auth_password=self.password,
222279
)
223280

281+
self.log.debug(f"Getting image manifest from {url}")
224282
try:
225283
resp = await client.fetch(req)
226284
except httpclient.HTTPError as e:
227285
if e.code == 404:
228286
# 404 means it doesn't exist
229287
return None
288+
elif (
289+
e.code == 401 and not token and "www-authenticate" in e.response.headers
290+
):
291+
# Unauthorised. If we don't have a token, try and get one using
292+
# information from the WWW-Authenticate header
293+
# https://stackoverflow.com/questions/56193110/how-can-i-use-docker-registry-http-api-v2-to-obtain-a-list-of-all-repositories-i/68654659#68654659
294+
www_auth_header = e.response.headers["www-authenticate"]
295+
return await self._get_image_manifest_from_www_authenticate(
296+
client, www_auth_header, url
297+
)
230298
else:
231299
raise
232-
else:
233-
return json.loads(resp.body.decode("utf-8"))
300+
return json.loads(resp.body.decode("utf-8"))
234301

235302

236303
class FakeRegistry(DockerRegistry):

0 commit comments

Comments
 (0)