44import base64
55import json
66import os
7+ import re
78from urllib .parse import urlparse
89
910from 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
236303class FakeRegistry (DockerRegistry ):
0 commit comments