coelevate-cli 0.2.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.
- coelevate_cli/__init__.py +3 -0
- coelevate_cli/__main__.py +5 -0
- coelevate_cli/auth.py +169 -0
- coelevate_cli/cli.py +988 -0
- coelevate_cli-0.2.0.dist-info/METADATA +291 -0
- coelevate_cli-0.2.0.dist-info/RECORD +9 -0
- coelevate_cli-0.2.0.dist-info/WHEEL +4 -0
- coelevate_cli-0.2.0.dist-info/entry_points.txt +2 -0
- coelevate_cli-0.2.0.dist-info/licenses/LICENSE +202 -0
coelevate_cli/auth.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""OAuth client-credentials token source for the Context Repository public API.
|
|
2
|
+
|
|
3
|
+
A customer API key (client id + secret) is exchanged at the token endpoint for a
|
|
4
|
+
short-lived bearer token: ``POST <token url>`` with ``grant_type=client_credentials``,
|
|
5
|
+
the requested ``scope`` as a form field, and the key as HTTP Basic (RFC 6749 section
|
|
6
|
+
2.3.1). The token is cached until a minute before **its own** ``exp`` claim rather than
|
|
7
|
+
``now + expires_in``: a declared lifetime is a claim about one response, while ``exp``
|
|
8
|
+
is a fact about the token, and a server may legitimately hand back an already
|
|
9
|
+
partly-consumed token while reporting its full lifetime.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
import json
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
#: The path the platform serves the token exchange on; the full URL (scheme + host) is
|
|
22
|
+
#: deployment-specific and always supplied by the caller.
|
|
23
|
+
TOKEN_PATH = "/oauth/token"
|
|
24
|
+
|
|
25
|
+
#: The two scopes a key can hold for this API. There is deliberately no publish scope.
|
|
26
|
+
READ_SCOPE = "context:read"
|
|
27
|
+
ASSETS_SCOPE = "context:assets"
|
|
28
|
+
KITS_SCOPE = "context:kits"
|
|
29
|
+
GAPS_SCOPE = "context:gaps"
|
|
30
|
+
|
|
31
|
+
#: Token-endpoint error codes that mean "this key may not have that scope", as opposed to a
|
|
32
|
+
#: failure of the exchange itself.
|
|
33
|
+
_REFUSAL_CODES = frozenset({"invalid_scope", "access_denied", "unauthorized_client"})
|
|
34
|
+
|
|
35
|
+
#: Refresh a token this long before its own ``exp``.
|
|
36
|
+
_REFRESH_MARGIN_SECONDS = 60
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class OAuthTokenError(RuntimeError):
|
|
40
|
+
"""The token endpoint refused the exchange. ``error`` is RFC 6749 section 5.2's code
|
|
41
|
+
(``invalid_client``, ``invalid_scope``, ...) when the server sent one."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, message: str, *, error: str | None, status_code: int) -> None:
|
|
44
|
+
super().__init__(message)
|
|
45
|
+
self.error = error
|
|
46
|
+
self.status_code = status_code
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _jwt_claim(token: str, name: str) -> object | None:
|
|
50
|
+
"""One unverified claim of a JWT, or ``None`` if the token is not a parseable JWT or
|
|
51
|
+
lacks it. A client-side hint only: the server verifies every call regardless of what
|
|
52
|
+
this returns."""
|
|
53
|
+
try:
|
|
54
|
+
_, payload_b64, _ = token.split(".")
|
|
55
|
+
padded = payload_b64 + "=" * (-len(payload_b64) % 4)
|
|
56
|
+
return json.loads(base64.urlsafe_b64decode(padded)).get(name)
|
|
57
|
+
except Exception: # noqa: BLE001 - any parse failure just means "use the fallback"
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _jwt_exp(token: str) -> float | None:
|
|
62
|
+
"""The unverified ``exp`` claim (Unix seconds) of a JWT, or ``None``: a caching hint."""
|
|
63
|
+
exp = _jwt_claim(token, "exp")
|
|
64
|
+
try:
|
|
65
|
+
return float(exp) if exp is not None else None
|
|
66
|
+
except (TypeError, ValueError):
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _jwt_project_id(token: str) -> str | None:
|
|
71
|
+
"""The unverified ``project_id`` claim: the fallback for a token response that does
|
|
72
|
+
not carry the project in its body."""
|
|
73
|
+
claim = _jwt_claim(token, "project_id")
|
|
74
|
+
return claim if isinstance(claim, str) and claim else None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ClientCredentialsTokenSource(httpx.Auth):
|
|
78
|
+
"""Exchanges a key for bearer tokens and attaches one to every request of the
|
|
79
|
+
client it is installed on. Thread-safe: one exchange at a time."""
|
|
80
|
+
|
|
81
|
+
def __init__(
|
|
82
|
+
self,
|
|
83
|
+
*,
|
|
84
|
+
token_url: str,
|
|
85
|
+
client_id: str,
|
|
86
|
+
client_secret: str,
|
|
87
|
+
scope: str | None = None,
|
|
88
|
+
http_client: httpx.Client | None = None,
|
|
89
|
+
) -> None:
|
|
90
|
+
if not (token_url and client_id and client_secret):
|
|
91
|
+
raise ValueError("token_url, client_id and client_secret are all required")
|
|
92
|
+
self.token_url = token_url
|
|
93
|
+
self.client_id = client_id
|
|
94
|
+
self._client_secret = client_secret
|
|
95
|
+
self._scope = scope
|
|
96
|
+
self._client = http_client or httpx.Client(timeout=httpx.Timeout(15.0))
|
|
97
|
+
self._lock = threading.Lock()
|
|
98
|
+
self._cached: tuple[str, float] | None = None
|
|
99
|
+
#: What the last exchange said about the key (set by ``token()``): the project the
|
|
100
|
+
#: key resolves to and the scope the server actually granted. ``None`` until the
|
|
101
|
+
#: first exchange, or when the server named none.
|
|
102
|
+
self.project_id: str | None = None
|
|
103
|
+
self.granted_scope: str | None = None
|
|
104
|
+
|
|
105
|
+
def token(self) -> str:
|
|
106
|
+
"""A token with more than the refresh margin left, exchanging only when needed."""
|
|
107
|
+
with self._lock:
|
|
108
|
+
now = time.time()
|
|
109
|
+
if self._cached is not None and self._cached[1] - now > _REFRESH_MARGIN_SECONDS:
|
|
110
|
+
return self._cached[0]
|
|
111
|
+
access_token, expiry = self._exchange(now)
|
|
112
|
+
self._cached = (access_token, expiry)
|
|
113
|
+
return access_token
|
|
114
|
+
|
|
115
|
+
def grants_scope(self, scope: str) -> bool | None:
|
|
116
|
+
"""Whether the server grants this key ``scope``, asked by requesting a token for that
|
|
117
|
+
scope alone: ``True`` when granted, ``False`` when refused, ``None`` (unknown) when the
|
|
118
|
+
exchange failed for another reason (network, 5xx), which says nothing about the key.
|
|
119
|
+
Leaves this source's own cached token and granted scope untouched."""
|
|
120
|
+
probe = ClientCredentialsTokenSource(
|
|
121
|
+
token_url=self.token_url,
|
|
122
|
+
client_id=self.client_id,
|
|
123
|
+
client_secret=self._client_secret,
|
|
124
|
+
scope=scope,
|
|
125
|
+
http_client=self._client,
|
|
126
|
+
)
|
|
127
|
+
try:
|
|
128
|
+
probe.token()
|
|
129
|
+
except OAuthTokenError as exc:
|
|
130
|
+
return False if exc.error in _REFUSAL_CODES else None
|
|
131
|
+
except httpx.HTTPError:
|
|
132
|
+
return None
|
|
133
|
+
return scope in (probe.granted_scope or "").split()
|
|
134
|
+
|
|
135
|
+
def auth_flow(self, request: httpx.Request):
|
|
136
|
+
request.headers["Authorization"] = f"Bearer {self.token()}"
|
|
137
|
+
yield request
|
|
138
|
+
|
|
139
|
+
def _exchange(self, now: float) -> tuple[str, float]:
|
|
140
|
+
form = {"grant_type": "client_credentials"}
|
|
141
|
+
if self._scope:
|
|
142
|
+
form["scope"] = self._scope
|
|
143
|
+
response = self._client.post(self.token_url, data=form, auth=(self.client_id, self._client_secret))
|
|
144
|
+
if response.status_code != 200:
|
|
145
|
+
error, description = None, None
|
|
146
|
+
try:
|
|
147
|
+
body = response.json()
|
|
148
|
+
error, description = body.get("error"), body.get("error_description")
|
|
149
|
+
except ValueError:
|
|
150
|
+
pass
|
|
151
|
+
detail = f"{error}: {description}" if description else (error or response.text[:200])
|
|
152
|
+
raise OAuthTokenError(
|
|
153
|
+
f"{self.token_url} refused client {self.client_id!r} (HTTP {response.status_code}): {detail}",
|
|
154
|
+
error=error,
|
|
155
|
+
status_code=response.status_code,
|
|
156
|
+
)
|
|
157
|
+
body = response.json()
|
|
158
|
+
access_token = body["access_token"]
|
|
159
|
+
project_id = body.get("project_id")
|
|
160
|
+
self.project_id = project_id if isinstance(project_id, str) and project_id else _jwt_project_id(access_token)
|
|
161
|
+
scope = body.get("scope")
|
|
162
|
+
self.granted_scope = scope if isinstance(scope, str) and scope else self._scope
|
|
163
|
+
real_exp = _jwt_exp(access_token)
|
|
164
|
+
if real_exp is not None:
|
|
165
|
+
return access_token, real_exp
|
|
166
|
+
# An opaque token: the declared lifetime is all there is. With neither, never
|
|
167
|
+
# cache - the next call exchanges again rather than trusting a guess.
|
|
168
|
+
expires_in = body.get("expires_in")
|
|
169
|
+
return access_token, (now + float(expires_in)) if expires_in is not None else now
|