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.
@@ -0,0 +1,3 @@
1
+ """Command-line client for the Coelevate Context Repository public API."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from coelevate_cli.cli import main
4
+
5
+ sys.exit(main())
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