Skip to content

Commit 3de27f2

Browse files
authored
Merge pull request jupyterhub#1586 from manics/docker-registry
Add compatibility for fetching tokens from other Docker registries
2 parents 070641b + c6cb24b commit 3de27f2

File tree

2 files changed

+191
-28
lines changed

2 files changed

+191
-28
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):

binderhub/tests/test_registry.py

Lines changed: 102 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22
import base64
33
import json
44
import os
5+
from random import randint
56

7+
import pytest
8+
from tornado import httpclient
69
from tornado.web import Application, HTTPError, RequestHandler
710

811
from binderhub.registry import DockerRegistry
@@ -59,17 +62,62 @@ def test_registry_gcr_defaults(tmpdir):
5962
assert registry.password == "{...}"
6063

6164

65+
@pytest.mark.parametrize(
66+
"header,expected",
67+
[
68+
(
69+
'Bearer realm="https://example.org/abc/token",service="example.org",scope=""',
70+
("https://example.org/abc/token", "example.org", ""),
71+
),
72+
(
73+
'BEARER scope="abc",service="example.org",realm="https://example.org/abc/token"',
74+
("https://example.org/abc/token", "example.org", "abc"),
75+
),
76+
],
77+
)
78+
def test_parse_www_authenticate_header(header, expected):
79+
registry = DockerRegistry()
80+
assert expected == registry._parse_www_authenticate_header(header)
81+
82+
83+
@pytest.mark.parametrize(
84+
"header,expected",
85+
[
86+
(
87+
'basic realm="https://example.org/abc/token"',
88+
"Only WWW-Authenticate Bearer type supported",
89+
),
90+
(
91+
'bearer realm="https://example.org/abc/token"',
92+
"Expected WWW-Authenticate to include realm service scope",
93+
),
94+
],
95+
)
96+
def test_parse_www_authenticate_header_invalid(header, expected):
97+
registry = DockerRegistry()
98+
with pytest.raises(ValueError) as excinfo:
99+
registry._parse_www_authenticate_header(header)
100+
assert excinfo.value.args[0].startswith(expected)
101+
102+
62103
# Mock the registry API calls made by get_image_manifest
63104

64105

65106
class MockTokenHandler(RequestHandler):
66107
"""Mock handler for the registry token handler"""
67108

68-
def initialize(self, test_handle):
109+
def initialize(self, test_handle, service=None, scope=None):
69110
self.test_handle = test_handle
111+
self.service = service
112+
self.scope = scope
70113

71114
def get(self):
72-
self.get_argument("scope")
115+
scope = self.get_argument("scope")
116+
if self.scope:
117+
assert scope == self.scope
118+
service = self.get_argument("service")
119+
if self.service:
120+
assert service == self.service
73121
auth_header = self.request.headers.get("Authorization", "")
74122
if not auth_header.startswith("Basic "):
75123
raise HTTPError(401, "No basic auth")
@@ -104,8 +152,52 @@ def get(self, image, tag):
104152
# get_image_manifest never looks at the contents here
105153
self.write(json.dumps({"image": image, "tag": tag}))
106154

155+
def write_error(self, status_code, **kwargs):
156+
err_cls, err, traceback = kwargs["exc_info"]
157+
if status_code == 401:
158+
r = self.request
159+
self.set_header(
160+
"WWW-Authenticate",
161+
f'Bearer realm="{r.protocol}://{r.host}/token",service="service=1",scope="scope-2"',
162+
)
163+
super().write_error(status_code, **kwargs)
164+
165+
166+
async def test_get_token():
167+
username = "user"
168+
password = "pass"
169+
test_handle = {"username": username, "password": password}
170+
app = Application(
171+
[
172+
(r"/token", MockTokenHandler, {"test_handle": test_handle}),
173+
]
174+
)
175+
ip = "127.0.0.1"
176+
port = randint(10000, 65535)
177+
app.listen(port, ip)
178+
179+
registry = DockerRegistry(
180+
url="https://example.org", username=username, password=password
181+
)
182+
183+
assert registry.url == "https://example.org"
184+
assert registry.auth_config_url == "https://example.org"
185+
# token_url should be unset, since it should be determined by the caller from a
186+
# WWW-Authenticate header
187+
assert registry.token_url == ""
188+
assert registry.username == username
189+
assert registry.password == password
190+
token = await registry._get_token(
191+
httpclient.AsyncHTTPClient(),
192+
f"http://{ip}:{port}/token",
193+
"service.1",
194+
"scope.2",
195+
)
196+
assert token == test_handle["token"]
197+
107198

108-
async def test_get_image_manifest(tmpdir, request):
199+
@pytest.mark.parametrize("token_url_known", [True, False])
200+
async def test_get_image_manifest(tmpdir, token_url_known):
109201
username = "asdf"
110202
password = "asdf;ouyag"
111203
test_handle = {"username": username, "password": password}
@@ -120,7 +212,7 @@ async def test_get_image_manifest(tmpdir, request):
120212
]
121213
)
122214
ip = "127.0.0.1"
123-
port = 10504
215+
port = randint(10000, 65535)
124216
url = f"http://{ip}:{port}"
125217
app.listen(port, ip)
126218
config_json = tmpdir.join("dockerconfig.json")
@@ -137,12 +229,16 @@ async def test_get_image_manifest(tmpdir, request):
137229
},
138230
f,
139231
)
232+
if token_url_known:
233+
token_url = url + "/token"
234+
else:
235+
token_url = ""
140236
registry = DockerRegistry(
141-
docker_config_path=str(config_json), token_url=url + "/token", url=url
237+
docker_config_path=str(config_json), token_url=token_url, url=url
142238
)
143239
assert registry.url == url
144240
assert registry.auth_config_url == url
145-
assert registry.token_url == url + "/token"
241+
assert registry.token_url == token_url
146242
assert registry.username == username
147243
assert registry.password == password
148244
manifest = await registry.get_image_manifest("myimage", "abc123")

0 commit comments

Comments
 (0)