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.
@@ -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)