csa-skilljar 0.8.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.
- csa_skilljar/__init__.py +11 -0
- csa_skilljar/auth.py +169 -0
- csa_skilljar/backend.py +2011 -0
- csa_skilljar/client.py +319 -0
- csa_skilljar/exceptions.py +54 -0
- csa_skilljar/mcp/__init__.py +1 -0
- csa_skilljar/mcp/_config.py +142 -0
- csa_skilljar/mcp/_schemas.py +455 -0
- csa_skilljar/mcp/_tools/__init__.py +20 -0
- csa_skilljar/mcp/_tools/_base.py +63 -0
- csa_skilljar/mcp/_tools/access.py +92 -0
- csa_skilljar/mcp/_tools/courses.py +172 -0
- csa_skilljar/mcp/_tools/enrolment.py +372 -0
- csa_skilljar/mcp/_tools/feedback.py +47 -0
- csa_skilljar/mcp/_tools/groups.py +425 -0
- csa_skilljar/mcp/_tools/lessons.py +266 -0
- csa_skilljar/mcp/_tools/publishing.py +362 -0
- csa_skilljar/mcp/_tools/question_banks.py +287 -0
- csa_skilljar/mcp/_tools/questions.py +284 -0
- csa_skilljar/mcp/_tools/quizzes.py +196 -0
- csa_skilljar/mcp/_tools/signup_fields.py +187 -0
- csa_skilljar/mcp/_tools/students.py +299 -0
- csa_skilljar/mcp/_tools/web_packages.py +282 -0
- csa_skilljar/mcp/cli.py +90 -0
- csa_skilljar/mcp/server.py +80 -0
- csa_skilljar/policy.py +201 -0
- csa_skilljar/py.typed +0 -0
- csa_skilljar/scopes.py +105 -0
- csa_skilljar-0.8.0.dist-info/METADATA +315 -0
- csa_skilljar-0.8.0.dist-info/RECORD +34 -0
- csa_skilljar-0.8.0.dist-info/WHEEL +5 -0
- csa_skilljar-0.8.0.dist-info/entry_points.txt +2 -0
- csa_skilljar-0.8.0.dist-info/licenses/LICENSE +202 -0
- csa_skilljar-0.8.0.dist-info/top_level.txt +1 -0
csa_skilljar/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Python client and MCP server for the Skilljar customer education platform."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from .backend import Backend, FakeBackend, V2Backend
|
|
5
|
+
from .client import SkilljarClient
|
|
6
|
+
from .policy import ALL_CAPABILITIES, PROFILES, Policy, PolicyBackend
|
|
7
|
+
|
|
8
|
+
__version__ = "0.8.0"
|
|
9
|
+
|
|
10
|
+
__all__ = ["ALL_CAPABILITIES", "PROFILES", "Backend", "FakeBackend", "Policy",
|
|
11
|
+
"PolicyBackend", "SkilljarClient", "V2Backend", "__version__"]
|
csa_skilljar/auth.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""v2 authentication: the `client_credentials` grant.
|
|
2
|
+
|
|
3
|
+
We use `client_credentials`, which Skilljar's own MCP server cannot: it is remote and
|
|
4
|
+
acts for a browser user, so its authorization server offers only `authorization_code`.
|
|
5
|
+
Running locally removes the browser, the redirect URI, the consent flow and the token
|
|
6
|
+
file entirely (ADR-003). The only cached state is an access token held in memory.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import time
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from . import exceptions as exc
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
TOKEN_PATH = "/v2/auth/token" # nosec B105 # a URL path, not a secret
|
|
23
|
+
_REFRESH_MARGIN_SECONDS = 60.0
|
|
24
|
+
_FALLBACK_LIFETIME_SECONDS = 300.0 # used only when neither the JWT nor the server says
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def decode_claims(token: str) -> dict[str, Any]:
|
|
28
|
+
"""Read a JWT's claims WITHOUT verifying the signature.
|
|
29
|
+
|
|
30
|
+
Deliberate: this is our own token and the server verifies it. We read `exp` and
|
|
31
|
+
`scope` locally so `check_access` can report expiry and the scope pre-check can
|
|
32
|
+
refuse an impossible call with zero network traffic. Never treat the result as
|
|
33
|
+
an authorization decision about someone else's token.
|
|
34
|
+
"""
|
|
35
|
+
try:
|
|
36
|
+
payload = token.split(".")[1]
|
|
37
|
+
payload += "=" * (-len(payload) % 4)
|
|
38
|
+
claims = json.loads(base64.urlsafe_b64decode(payload))
|
|
39
|
+
except Exception as e: # noqa: BLE001 - a malformed token must not crash startup
|
|
40
|
+
# ZD-1: not swallowed. Returning {} silently made "no claims" and "not a JWT"
|
|
41
|
+
# indistinguishable, and the caller loses both expiry and scope pre-checks.
|
|
42
|
+
log.warning("v2 access token could not be decoded (%s); scope pre-checks and "
|
|
43
|
+
"expiry reporting are unavailable for this token", type(e).__name__)
|
|
44
|
+
return {}
|
|
45
|
+
if not isinstance(claims, dict):
|
|
46
|
+
log.warning("v2 access token could not be decoded (payload is %s, not an object); "
|
|
47
|
+
"scope pre-checks and expiry reporting are unavailable",
|
|
48
|
+
type(claims).__name__)
|
|
49
|
+
return {}
|
|
50
|
+
return claims
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class V2Credentials:
|
|
54
|
+
"""Holds the client id/secret and mints short-lived access tokens on demand."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, client_id: str, client_secret: str, *,
|
|
57
|
+
base_url: str = "https://api.skilljar.com",
|
|
58
|
+
http: httpx.Client | None = None) -> None:
|
|
59
|
+
self._id = client_id; self._secret = client_secret
|
|
60
|
+
self._base = base_url.rstrip("/")
|
|
61
|
+
self._http = http or httpx.Client(timeout=30.0)
|
|
62
|
+
self._token: str | None = None; self._claims: dict[str, Any] = {}
|
|
63
|
+
self._expiry: float | None = None
|
|
64
|
+
|
|
65
|
+
def __repr__(self) -> str: # never let a credential reach a log line
|
|
66
|
+
return f"<V2Credentials client_id={self._id[:4]}... credential=***>"
|
|
67
|
+
|
|
68
|
+
def _expired(self) -> bool:
|
|
69
|
+
# ZD-17: an absorbing state lived here. When the JWT carried no usable `exp`
|
|
70
|
+
# this returned True forever, so every call re-granted a token - correct code,
|
|
71
|
+
# false premise, and nothing would ever report it. `_expiry` is now always set
|
|
72
|
+
# from the best available source, so the cache always works.
|
|
73
|
+
if self._expiry is None:
|
|
74
|
+
return True
|
|
75
|
+
return time.time() >= self._expiry - _REFRESH_MARGIN_SECONDS
|
|
76
|
+
|
|
77
|
+
def token(self) -> str:
|
|
78
|
+
if self._token is not None and not self._expired():
|
|
79
|
+
return self._token
|
|
80
|
+
try:
|
|
81
|
+
r = self._http.post(
|
|
82
|
+
f"{self._base}{TOKEN_PATH}",
|
|
83
|
+
data={"grant_type": "client_credentials",
|
|
84
|
+
"client_id": self._id, "client_secret": self._secret},
|
|
85
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"})
|
|
86
|
+
except httpx.HTTPError as e:
|
|
87
|
+
raise exc.ApiError(f"could not reach Skilljar to authenticate: {e}") from e
|
|
88
|
+
if r.status_code in (400, 401, 403):
|
|
89
|
+
raise exc.CredentialsRejected(
|
|
90
|
+
"Skilljar rejected the v2 client credentials. They may have been rotated, or "
|
|
91
|
+
"the client deleted. Re-issue the client in the Skilljar Dashboard and restart "
|
|
92
|
+
"the server.")
|
|
93
|
+
if r.status_code >= 400:
|
|
94
|
+
raise exc.ApiError(f"token grant failed with HTTP {r.status_code}", status=r.status_code)
|
|
95
|
+
try:
|
|
96
|
+
body = r.json()
|
|
97
|
+
except ValueError as e:
|
|
98
|
+
raise exc.ApiError("token grant returned a response that is not JSON") from e
|
|
99
|
+
if not isinstance(body, dict):
|
|
100
|
+
raise exc.ApiError("token grant returned JSON that is not an object")
|
|
101
|
+
tok = body.get("access_token")
|
|
102
|
+
if not tok:
|
|
103
|
+
raise exc.ApiError("token grant returned no access_token")
|
|
104
|
+
self._token = str(tok); self._claims = decode_claims(self._token)
|
|
105
|
+
self._expiry = self._resolve_expiry(body.get("expires_in"))
|
|
106
|
+
return self._token
|
|
107
|
+
|
|
108
|
+
def _resolve_expiry(self, expires_in: Any) -> float:
|
|
109
|
+
"""Best available expiry, in this order: the JWT's `exp`, the grant response's
|
|
110
|
+
`expires_in`, then a short conservative window. Never None - see `_expired`."""
|
|
111
|
+
exp = self._claims.get("exp")
|
|
112
|
+
if isinstance(exp, (int, float)):
|
|
113
|
+
return float(exp)
|
|
114
|
+
if isinstance(expires_in, (int, float)) and expires_in > 0:
|
|
115
|
+
return time.time() + float(expires_in)
|
|
116
|
+
log.warning("v2 token carries no expiry and the grant response gave none; "
|
|
117
|
+
"assuming %.0fs", _FALLBACK_LIFETIME_SECONDS)
|
|
118
|
+
return time.time() + _FALLBACK_LIFETIME_SECONDS
|
|
119
|
+
|
|
120
|
+
def granted_scopes(self) -> tuple[str, ...] | None:
|
|
121
|
+
"""The scopes this token carries, or None when the token does not say.
|
|
122
|
+
|
|
123
|
+
None and () are DIFFERENT and the caller must treat them differently. () means
|
|
124
|
+
"issued with no scopes", which is a real answer and a reason to refuse locally.
|
|
125
|
+
None means "this token has no scope claim we recognise", which says nothing
|
|
126
|
+
about what the client may do - refusing on it would send the operator off to
|
|
127
|
+
re-issue a credential that is fine.
|
|
128
|
+
|
|
129
|
+
Skilljar issues `scopes` as a JSON LIST. RFC 6749 and RFC 9068 specify `scope`
|
|
130
|
+
as a space-delimited STRING. Both are read, because this code originally read
|
|
131
|
+
only the standard one and every single call was refused against a real token
|
|
132
|
+
that was correctly scoped - the claim was there, under the other name, in the
|
|
133
|
+
other shape.
|
|
134
|
+
"""
|
|
135
|
+
self.token()
|
|
136
|
+
for key in ("scopes", "scope"):
|
|
137
|
+
if key not in self._claims:
|
|
138
|
+
continue
|
|
139
|
+
raw = self._claims[key]
|
|
140
|
+
if isinstance(raw, (list, tuple)):
|
|
141
|
+
return tuple(str(s) for s in raw if str(s))
|
|
142
|
+
return tuple(s for s in str(raw).replace(",", " ").split() if s)
|
|
143
|
+
# ZD-1/ZD-17: an absorbing state that quietly refuses everything is exactly the
|
|
144
|
+
# shape of the `_expired` bug. Say so, once, and let the caller decide.
|
|
145
|
+
log.warning(
|
|
146
|
+
"v2 token carries no recognised scope claim (looked for `scopes` and "
|
|
147
|
+
"`scope`; token has: %s). The local scope pre-check is disabled for this "
|
|
148
|
+
"token - Skilljar remains authoritative and will reject anything the "
|
|
149
|
+
"client may not do.", ", ".join(sorted(self._claims)) or "(no claims)")
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
def expires_in(self) -> float | None:
|
|
153
|
+
"""Seconds until expiry, from cached state. No network call."""
|
|
154
|
+
return None if self._expiry is None else self._expiry - time.time()
|
|
155
|
+
|
|
156
|
+
def require_scope(self, scope: str) -> None:
|
|
157
|
+
"""Refuse locally, before any request, naming the exact missing scope."""
|
|
158
|
+
granted = self.granted_scopes()
|
|
159
|
+
if granted is None:
|
|
160
|
+
# The token does not say what it may do. granted_scopes() has already
|
|
161
|
+
# warned. Refusing here would be a guess presented as a fact, and would
|
|
162
|
+
# send the operator to re-issue a credential that may be perfectly good.
|
|
163
|
+
return
|
|
164
|
+
if scope in granted:
|
|
165
|
+
return
|
|
166
|
+
raise exc.ScopeError(
|
|
167
|
+
f"Your v2 client was issued: {', '.join(granted) or '(none)'}. Re-issue it "
|
|
168
|
+
f"including `{scope}`, then restart the server. No call was made to Skilljar.",
|
|
169
|
+
required=scope, granted=granted)
|