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/__init__.py +80 -0
- dockerhub_api/__main__.py +4 -0
- dockerhub_api/agent_server.py +92 -0
- dockerhub_api/api/__init__.py +1 -0
- dockerhub_api/api/api_client_access_tokens.py +77 -0
- dockerhub_api/api/api_client_audit_logs.py +56 -0
- dockerhub_api/api/api_client_auth.py +80 -0
- dockerhub_api/api/api_client_base.py +338 -0
- dockerhub_api/api/api_client_groups.py +158 -0
- dockerhub_api/api/api_client_org_access_tokens.py +106 -0
- dockerhub_api/api/api_client_orgs.py +149 -0
- dockerhub_api/api/api_client_repositories.py +217 -0
- dockerhub_api/api/api_client_scim.py +153 -0
- dockerhub_api/api_client.py +35 -0
- dockerhub_api/auth.py +252 -0
- dockerhub_api/dockerhub_input_models.py +756 -0
- dockerhub_api/dockerhub_response_models.py +344 -0
- dockerhub_api/main_agent.json +14 -0
- dockerhub_api/mcp/__init__.py +120 -0
- dockerhub_api/mcp/mcp_admin.py +45 -0
- dockerhub_api/mcp/mcp_audit.py +44 -0
- dockerhub_api/mcp/mcp_auth.py +66 -0
- dockerhub_api/mcp/mcp_org.py +58 -0
- dockerhub_api/mcp/mcp_repos.py +58 -0
- dockerhub_api/mcp/mcp_scim.py +56 -0
- dockerhub_api/mcp/mcp_teams.py +55 -0
- dockerhub_api/mcp_config.json +3 -0
- dockerhub_api/mcp_server.py +109 -0
- dockerhub_api-0.1.0.dist-info/METADATA +230 -0
- dockerhub_api-0.1.0.dist-info/RECORD +34 -0
- dockerhub_api-0.1.0.dist-info/WHEEL +5 -0
- dockerhub_api-0.1.0.dist-info/entry_points.txt +3 -0
- dockerhub_api-0.1.0.dist-info/licenses/LICENSE +21 -0
- dockerhub_api-0.1.0.dist-info/top_level.txt +1 -0
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)
|