dockerhub-api 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dockerhub_api/auth.py ADDED
@@ -0,0 +1,252 @@
1
+ """Docker Hub authentication.
2
+
3
+ CONCEPT:HUB-1.1 — JWT auth lifecycle.
4
+
5
+ Docker Hub's v2 API authenticates with a short-lived JWT minted from
6
+ ``POST /v2/auth/token`` using an *identifier* (username or organization name)
7
+ and a *secret* (account password, personal access token ``dckr_pat_*``, or an
8
+ organization access token). This module owns that lifecycle:
9
+
10
+ - :class:`TokenManager` — mints the JWT, caches it, and refreshes it before
11
+ expiry (``exp`` claim parsed straight from the JWT payload, no signature
12
+ verification needed client-side).
13
+ - :func:`get_client` — factory that builds the :class:`~dockerhub_api.api_client.Api`
14
+ client from explicit arguments or ``DOCKERHUB_*`` environment variables.
15
+
16
+ Secrets are never logged; only derived metadata (expiry, identifier) is.
17
+ """
18
+
19
+ import base64
20
+ import binascii
21
+ import json
22
+ import os
23
+ import threading
24
+ import time
25
+ from typing import Any
26
+
27
+ import httpx
28
+ from agent_utilities.base_utilities import get_logger, to_boolean
29
+ from agent_utilities.core.exceptions import AuthError
30
+
31
+ logger = get_logger(__name__)
32
+
33
+ DEFAULT_DOCKERHUB_URL = "https://hub.docker.com"
34
+ AUTH_ENDPOINT = "/v2/auth/token"
35
+ #: Fallback token lifetime (seconds) when the JWT carries no readable ``exp``.
36
+ DEFAULT_TOKEN_TTL = 300.0
37
+ #: Refresh this many seconds *before* the JWT actually expires.
38
+ DEFAULT_REFRESH_SKEW = 60.0
39
+
40
+ _token_manager_lock = threading.Lock()
41
+ _token_managers: dict[tuple, "TokenManager"] = {}
42
+
43
+
44
+ def decode_jwt_claims(token: str) -> dict[str, Any]:
45
+ """Best-effort decode of a JWT payload segment (no signature verification).
46
+
47
+ Returns an empty dict when the token is not a parseable JWT.
48
+ """
49
+ try:
50
+ segments = token.split(".")
51
+ if len(segments) < 2:
52
+ return {}
53
+ payload = segments[1]
54
+ payload += "=" * (-len(payload) % 4)
55
+ decoded = base64.urlsafe_b64decode(payload.encode("utf-8"))
56
+ claims = json.loads(decoded)
57
+ return claims if isinstance(claims, dict) else {}
58
+ except (ValueError, binascii.Error, UnicodeDecodeError):
59
+ return {}
60
+
61
+
62
+ class TokenManager:
63
+ """Caches the Docker Hub JWT and refreshes it before expiry.
64
+
65
+ Thread-safe: concurrent callers share a single cached token and only one
66
+ mint request is in flight at a time.
67
+ """
68
+
69
+ def __init__(
70
+ self,
71
+ identifier: str,
72
+ secret: str,
73
+ url: str = DEFAULT_DOCKERHUB_URL,
74
+ verify: bool = True,
75
+ timeout: float = 30.0,
76
+ refresh_skew: float = DEFAULT_REFRESH_SKEW,
77
+ transport: httpx.BaseTransport | None = None,
78
+ ):
79
+ if not identifier or not secret:
80
+ raise AuthError("Docker Hub identifier and secret are both required.")
81
+ self.identifier = identifier
82
+ self._secret = secret
83
+ self.url = url.rstrip("/")
84
+ self.verify = verify
85
+ self.timeout = timeout
86
+ self.refresh_skew = refresh_skew
87
+ self._transport = transport
88
+ self._lock = threading.Lock()
89
+ self._token: str | None = None
90
+ self._claims: dict[str, Any] = {}
91
+ self._expires_at: float = 0.0
92
+
93
+ def get_token(self) -> str:
94
+ """Return a valid JWT, minting a fresh one when (nearly) expired."""
95
+ with self._lock:
96
+ if self._token and time.time() < (self._expires_at - self.refresh_skew):
97
+ return self._token
98
+ return self._fetch()
99
+
100
+ def claims(self) -> dict[str, Any]:
101
+ """Return the decoded claims of the current JWT (minting if needed)."""
102
+ self.get_token()
103
+ return dict(self._claims)
104
+
105
+ def invalidate(self) -> None:
106
+ """Drop the cached token so the next call mints a fresh one."""
107
+ with self._lock:
108
+ self._token = None
109
+ self._claims = {}
110
+ self._expires_at = 0.0
111
+
112
+ @property
113
+ def expires_at(self) -> float:
114
+ return self._expires_at
115
+
116
+ def _fetch(self) -> str:
117
+ response = httpx.Client(
118
+ base_url=self.url,
119
+ verify=self.verify,
120
+ timeout=self.timeout,
121
+ transport=self._transport,
122
+ ).post(
123
+ AUTH_ENDPOINT,
124
+ json={"identifier": self.identifier, "secret": self._secret},
125
+ )
126
+ if response.status_code in (401, 403):
127
+ raise AuthError(
128
+ f"Docker Hub rejected the credentials for '{self.identifier}' "
129
+ f"(HTTP {response.status_code})."
130
+ )
131
+ if response.status_code >= 400:
132
+ raise AuthError(
133
+ f"Docker Hub token mint failed with HTTP {response.status_code}."
134
+ )
135
+ body = response.json()
136
+ token = body.get("access_token") or body.get("token")
137
+ if not token:
138
+ raise AuthError("Docker Hub token response did not include a token.")
139
+ self._token = token
140
+ self._claims = decode_jwt_claims(token)
141
+ exp = self._claims.get("exp")
142
+ if isinstance(exp, (int, float)) and exp > 0:
143
+ self._expires_at = float(exp)
144
+ else:
145
+ self._expires_at = time.time() + DEFAULT_TOKEN_TTL
146
+ logger.info(
147
+ "Minted Docker Hub JWT",
148
+ extra={
149
+ "identifier": self.identifier,
150
+ "expires_at": self._expires_at,
151
+ },
152
+ )
153
+ return self._token
154
+
155
+
156
+ def get_token_manager(
157
+ identifier: str,
158
+ secret: str,
159
+ url: str = DEFAULT_DOCKERHUB_URL,
160
+ verify: bool = True,
161
+ ) -> TokenManager:
162
+ """Return a shared, process-wide :class:`TokenManager` for the credentials.
163
+
164
+ Sharing the manager lets every short-lived client (one per MCP tool call)
165
+ reuse the same cached JWT instead of re-minting on every request.
166
+ """
167
+ key = (url.rstrip("/"), identifier, verify)
168
+ with _token_manager_lock:
169
+ manager = _token_managers.get(key)
170
+ if manager is None or manager._secret != secret:
171
+ manager = TokenManager(
172
+ identifier=identifier, secret=secret, url=url, verify=verify
173
+ )
174
+ _token_managers[key] = manager
175
+ return manager
176
+
177
+
178
+ def get_client(
179
+ url: str | None = None,
180
+ username: str | None = None,
181
+ token: str | None = None,
182
+ jwt: str | None = None,
183
+ verify: bool | None = None,
184
+ allow_destructive: bool | None = None,
185
+ config: dict | None = None,
186
+ ) -> Any:
187
+ """Factory for the Docker Hub :class:`~dockerhub_api.api_client.Api` client.
188
+
189
+ Credential resolution order:
190
+
191
+ 1. ``jwt`` / ``DOCKERHUB_JWT`` — a pre-minted bearer used as-is.
192
+ 2. ``username`` + ``token`` — from the OFFICIAL hub-tool environment names
193
+ ``DOCKER_HUB_USER`` + ``DOCKER_HUB_TOKEN`` (primary), falling back to
194
+ ``DOCKERHUB_USERNAME`` + ``DOCKERHUB_TOKEN`` — exchanged for a
195
+ short-lived JWT via a shared :class:`TokenManager` (the secret may be a
196
+ password, a PAT ``dckr_pat_*``, or an org token).
197
+ 3. Anonymous — public, unauthenticated endpoints only.
198
+
199
+ ``DOCKERHUB_ALLOW_DESTRUCTIVE`` (default ``False``) gates deletes and
200
+ org-settings writes. CONCEPT:HUB-1.3 — destructive-action gating.
201
+ """
202
+ from dockerhub_api.api_client import Api
203
+
204
+ config = config or {}
205
+ url = str(
206
+ url or config.get("url") or os.getenv("DOCKERHUB_URL") or DEFAULT_DOCKERHUB_URL
207
+ )
208
+ username = (
209
+ username
210
+ or config.get("username")
211
+ or os.getenv("DOCKER_HUB_USER")
212
+ or os.getenv("DOCKERHUB_USERNAME")
213
+ )
214
+ token = (
215
+ token
216
+ or config.get("token")
217
+ or os.getenv("DOCKER_HUB_TOKEN")
218
+ or os.getenv("DOCKERHUB_TOKEN")
219
+ )
220
+ jwt = jwt or config.get("jwt") or os.getenv("DOCKERHUB_JWT")
221
+ if verify is None:
222
+ verify = to_boolean(string=os.getenv("DOCKERHUB_SSL_VERIFY", "True"))
223
+ if allow_destructive is None:
224
+ allow_destructive = to_boolean(
225
+ string=os.getenv("DOCKERHUB_ALLOW_DESTRUCTIVE", "False")
226
+ )
227
+
228
+ if jwt:
229
+ logger.info("Using pre-minted Docker Hub JWT")
230
+ return Api(
231
+ url=url, token=jwt, verify=verify, allow_destructive=allow_destructive
232
+ )
233
+
234
+ if username and token:
235
+ logger.info(
236
+ "Using Docker Hub credential exchange", extra={"identifier": username}
237
+ )
238
+ manager = get_token_manager(
239
+ identifier=username, secret=token, url=url, verify=verify
240
+ )
241
+ return Api(
242
+ url=url,
243
+ token_manager=manager,
244
+ verify=verify,
245
+ allow_destructive=allow_destructive,
246
+ )
247
+
248
+ logger.warning(
249
+ "No Docker Hub credentials configured — anonymous client "
250
+ "(public endpoints only). Set DOCKERHUB_USERNAME and DOCKERHUB_TOKEN."
251
+ )
252
+ return Api(url=url, verify=verify, allow_destructive=allow_destructive)