Skip to content

Commit 1296b80

Browse files
authored
Fix the problem where a wrong realm is found in www-authenticate (#88)
* Fix the problem where a wrong realm is found in www-authenticate The token cache returned an invalid token for the container registry and when using that token on the first request we got an incorrect realm in the www-authenticate header. If that happens we will now invalidate the cache and do a HEAD request without a token to get a proper www-authenticate.
1 parent 3fc860d commit 1296b80

File tree

1 file changed

+70
-30
lines changed

1 file changed

+70
-30
lines changed

python/src/etos_api/library/docker.py

+70-30
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"""Docker operations for the ETOS API."""
1717
import time
1818
import logging
19+
from typing import Optional, Mapping
1920
from threading import Lock
2021
import aiohttp
2122

@@ -33,12 +34,14 @@ class Docker:
3334
"""
3435

3536
logger = logging.getLogger(__name__)
37+
# The amount of time in seconds before the token expires where we should recreate it.
38+
token_expire_modifier = 30
3639
# In-memory database for stored authorization tokens.
3740
# This dictionary shares memory with all instances of `Docker`, by design.
3841
tokens = {}
3942
lock = Lock()
4043

41-
def token(self, manifest_url: str) -> str:
44+
def token(self, manifest_url: str) -> Optional[str]:
4245
"""Get a stored token, removing it if expired.
4346
4447
:param manifest_url: URL the token has been stored for.
@@ -54,7 +57,7 @@ def token(self, manifest_url: str) -> str:
5457
return token.get("token")
5558

5659
async def head(
57-
self, session: aiohttp.ClientSession, url: str, token: str = None
60+
self, session: aiohttp.ClientSession, url: str, token: Optional[str] = None
5861
) -> aiohttp.ClientResponse:
5962
"""Make a HEAD request to a URL, adding token to headers if supplied.
6063
@@ -70,36 +73,78 @@ async def head(
7073
async with session.head(url, headers=headers) as response:
7174
return response
7275

73-
async def authorize(
74-
self, session: aiohttp.ClientSession, response: aiohttp.ClientResponse
75-
) -> str:
76-
"""Get a token from an unauthorized request to image repository.
76+
async def get_token_from_container_registry(
77+
self, session: aiohttp.ClientSession, realm: str, parameters: dict
78+
) -> dict:
79+
"""Get a token from an unauthorized request to container repository.
7780
7881
:param session: Client HTTP session to use for HTTP request.
79-
:param response: HTTP response to get headers from.
82+
:param realm: The realm to authorize against.
83+
:param parameters: Parameters to use for the authorization request.
8084
:return: Response JSON from authorization request.
8185
"""
82-
www_auth_header = response.headers.get("www-authenticate")
83-
challenge = www_auth_header.replace("Bearer ", "")
84-
parts = challenge.split(",")
86+
async with session.get(realm, params=parameters) as response:
87+
response.raise_for_status()
88+
return await response.json()
8589

86-
url = None
87-
query = {}
88-
for part in parts:
89-
key, value = part.split("=")
90-
if key == "realm":
91-
url = value.strip('"')
92-
else:
93-
query[key] = value.strip('"')
90+
async def authorize(
91+
self, session: aiohttp.ClientSession, response: aiohttp.ClientResponse, manifest_url: str
92+
) -> str:
93+
"""Authorize against container registry.
9494
95+
:param session: Client HTTP session to use for HTTP request.
96+
:param response: Response from a previous request to parse headers from.
97+
:param manifest_url: Manifest URL to query should a new auth request be needed.
98+
:return: A token retrieved from container registry.
99+
"""
100+
parameters = await self.parse_headers(response.headers)
101+
url = parameters.get("realm")
95102
if not isinstance(url, str) or not (
96103
url.startswith("http://") or url.startswith("https://")
97104
):
98-
raise ValueError(f"No realm URL found in www-authenticate header: {www_auth_header}")
105+
self.logger.warning("No realm in original request, retrying without a token")
106+
with self.lock:
107+
try:
108+
del self.tokens[manifest_url]
109+
except KeyError:
110+
pass
111+
response = await self.head(session, manifest_url)
112+
parameters = await self.parse_headers(response.headers)
113+
url = parameters.get("realm")
114+
if not isinstance(url, str) or not (
115+
url.startswith("http://") or url.startswith("https://")
116+
):
117+
raise ValueError(
118+
f"No realm URL found in www-authenticate header: {response.headers}"
119+
)
120+
url = parameters.pop("realm")
121+
response_json = await self.get_token_from_container_registry(session, url, parameters)
122+
with self.lock:
123+
self.tokens[manifest_url] = {
124+
"token": response_json.get("token"),
125+
"expire": time.time()
126+
+ response_json.get("expires_in", 0.0)
127+
- self.token_expire_modifier,
128+
}
129+
return ""
130+
131+
async def parse_headers(self, headers: Mapping) -> dict:
132+
"""Parse the www-authenticate header and convert it to a dict.
133+
134+
:param headers: Headers to parse.
135+
:return: Dictionary of the keys in the www-authenticate header.
136+
"""
137+
www_auth_header = headers.get("www-authenticate")
138+
if www_auth_header is None:
139+
return {}
140+
challenge = www_auth_header.replace("Bearer ", "")
141+
parts = challenge.split(",")
99142

100-
async with session.get(url, params=query) as response:
101-
response.raise_for_status()
102-
return await response.json()
143+
parameters = {}
144+
for part in parts:
145+
key, value = part.split("=")
146+
parameters[key] = value.strip('"')
147+
return parameters
103148

104149
def tag(self, base: str) -> tuple[str, str]:
105150
"""Figure out tag from a container image name.
@@ -149,7 +194,7 @@ def repository(self, repo: str) -> tuple[str, str]:
149194
registry = DEFAULT_REGISTRY
150195
return registry, repo
151196

152-
async def digest(self, name: str) -> str:
197+
async def digest(self, name: str) -> Optional[str]:
153198
"""Get a sha256 digest from an image in an image repository.
154199
155200
:param name: The name of the container image.
@@ -167,13 +212,8 @@ async def digest(self, name: str) -> str:
167212
try:
168213
if response.status == 401 and "www-authenticate" in response.headers:
169214
self.logger.info("Generate a new authorization token for %r", manifest_url)
170-
response_json = await self.authorize(session, response)
171-
with self.lock:
172-
self.tokens[manifest_url] = {
173-
"token": response_json.get("token"),
174-
"expire": time.time() + response_json.get("expires_in"),
175-
}
176-
response = await self.head(session, manifest_url, self.token(manifest_url))
215+
token = await self.authorize(session, response, manifest_url)
216+
response = await self.head(session, manifest_url, token)
177217
digest = response.headers.get("Docker-Content-Digest")
178218
except aiohttp.ClientResponseError as exception:
179219
self.logger.error("Error getting container image %r", exception)

0 commit comments

Comments
 (0)