ff-ltitoolkit 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.
- ff_ltitoolkit-0.1.0.dist-info/METADATA +98 -0
- ff_ltitoolkit-0.1.0.dist-info/RECORD +94 -0
- ff_ltitoolkit-0.1.0.dist-info/WHEEL +4 -0
- ff_ltitoolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- ltitoolkit/__init__.py +20 -0
- ltitoolkit/adapters/__init__.py +11 -0
- ltitoolkit/adapters/brightspace/__init__.py +35 -0
- ltitoolkit/adapters/brightspace/client.py +176 -0
- ltitoolkit/adapters/canvas/__init__.py +27 -0
- ltitoolkit/adapters/canvas/client.py +142 -0
- ltitoolkit/advantage/__init__.py +9 -0
- ltitoolkit/advantage/service.py +96 -0
- ltitoolkit/core/__init__.py +19 -0
- ltitoolkit/core/actions.py +6 -0
- ltitoolkit/core/assignments_grades.py +300 -0
- ltitoolkit/core/contrib/__init__.py +0 -0
- ltitoolkit/core/contrib/django/__init__.py +5 -0
- ltitoolkit/core/contrib/django/cookie.py +56 -0
- ltitoolkit/core/contrib/django/launch_data_storage/__init__.py +0 -0
- ltitoolkit/core/contrib/django/launch_data_storage/cache.py +10 -0
- ltitoolkit/core/contrib/django/lti1p3_tool_config/__init__.py +139 -0
- ltitoolkit/core/contrib/django/lti1p3_tool_config/admin.py +48 -0
- ltitoolkit/core/contrib/django/lti1p3_tool_config/apps.py +6 -0
- ltitoolkit/core/contrib/django/lti1p3_tool_config/migrations/0001_initial.py +168 -0
- ltitoolkit/core/contrib/django/lti1p3_tool_config/migrations/__init__.py +0 -0
- ltitoolkit/core/contrib/django/lti1p3_tool_config/models.py +185 -0
- ltitoolkit/core/contrib/django/message_launch.py +39 -0
- ltitoolkit/core/contrib/django/oidc_login.py +41 -0
- ltitoolkit/core/contrib/django/redirect.py +34 -0
- ltitoolkit/core/contrib/django/request.py +32 -0
- ltitoolkit/core/contrib/django/session.py +5 -0
- ltitoolkit/core/contrib/flask/__init__.py +7 -0
- ltitoolkit/core/contrib/flask/cookie.py +34 -0
- ltitoolkit/core/contrib/flask/launch_data_storage/__init__.py +0 -0
- ltitoolkit/core/contrib/flask/launch_data_storage/cache.py +9 -0
- ltitoolkit/core/contrib/flask/message_launch.py +32 -0
- ltitoolkit/core/contrib/flask/oidc_login.py +31 -0
- ltitoolkit/core/contrib/flask/redirect.py +34 -0
- ltitoolkit/core/contrib/flask/request.py +40 -0
- ltitoolkit/core/contrib/flask/session.py +5 -0
- ltitoolkit/core/contrib/py.typed +0 -0
- ltitoolkit/core/cookie.py +17 -0
- ltitoolkit/core/cookies_allowed_check.py +151 -0
- ltitoolkit/core/course_groups.py +115 -0
- ltitoolkit/core/deep_link.py +100 -0
- ltitoolkit/core/deep_link_resource.py +96 -0
- ltitoolkit/core/deployment.py +13 -0
- ltitoolkit/core/exception.py +16 -0
- ltitoolkit/core/grade.py +143 -0
- ltitoolkit/core/launch_data_storage/__init__.py +0 -0
- ltitoolkit/core/launch_data_storage/base.py +75 -0
- ltitoolkit/core/launch_data_storage/cache.py +43 -0
- ltitoolkit/core/launch_data_storage/session.py +29 -0
- ltitoolkit/core/lineitem.py +205 -0
- ltitoolkit/core/message_launch.py +828 -0
- ltitoolkit/core/message_validators/__init__.py +13 -0
- ltitoolkit/core/message_validators/abstract.py +25 -0
- ltitoolkit/core/message_validators/deep_link.py +34 -0
- ltitoolkit/core/message_validators/privacy_launch.py +40 -0
- ltitoolkit/core/message_validators/resource_message.py +21 -0
- ltitoolkit/core/message_validators/submission_review.py +45 -0
- ltitoolkit/core/names_roles.py +97 -0
- ltitoolkit/core/oidc_login.py +275 -0
- ltitoolkit/core/py.typed +0 -0
- ltitoolkit/core/redirect.py +24 -0
- ltitoolkit/core/registration.py +119 -0
- ltitoolkit/core/request.py +17 -0
- ltitoolkit/core/roles.py +109 -0
- ltitoolkit/core/service_connector.py +144 -0
- ltitoolkit/core/session.py +70 -0
- ltitoolkit/core/tool_config/__init__.py +4 -0
- ltitoolkit/core/tool_config/abstract.py +117 -0
- ltitoolkit/core/tool_config/dict.py +253 -0
- ltitoolkit/core/tool_config/json_file.py +100 -0
- ltitoolkit/core/tool_config/py.typed +0 -0
- ltitoolkit/core/utils.py +10 -0
- ltitoolkit/dynamic_registration/__init__.py +39 -0
- ltitoolkit/dynamic_registration/models.py +192 -0
- ltitoolkit/dynamic_registration/service.py +156 -0
- ltitoolkit/dynamic_registration/store.py +40 -0
- ltitoolkit/dynamic_registration/tool_conf.py +102 -0
- ltitoolkit/exceptions.py +42 -0
- ltitoolkit/fastapi/__init__.py +30 -0
- ltitoolkit/fastapi/cookie.py +53 -0
- ltitoolkit/fastapi/dynamic_registration.py +40 -0
- ltitoolkit/fastapi/message_launch.py +60 -0
- ltitoolkit/fastapi/oidc_login.py +47 -0
- ltitoolkit/fastapi/redirect.py +54 -0
- ltitoolkit/fastapi/request.py +77 -0
- ltitoolkit/fastapi/session.py +13 -0
- ltitoolkit/http.py +80 -0
- ltitoolkit/token/__init__.py +20 -0
- ltitoolkit/token/cache.py +47 -0
- ltitoolkit/token/service.py +165 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Starlette/FastAPI request adapter for the vendored LTI core.
|
|
2
|
+
|
|
3
|
+
The core's :class:`ltitoolkit.core.request.Request` exposes a *synchronous*
|
|
4
|
+
``get_param()``. Starlette, however, reads the form body asynchronously
|
|
5
|
+
(``await request.form()``). We bridge this by extracting all request data
|
|
6
|
+
*eagerly* in :meth:`FastApiRequest.from_request` (inside the async route) and
|
|
7
|
+
then serving it synchronously from an in-memory mapping — so the core never has
|
|
8
|
+
to ``await`` anything.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import typing as t
|
|
14
|
+
|
|
15
|
+
from starlette.requests import Request as StarletteRequest
|
|
16
|
+
|
|
17
|
+
from ltitoolkit.core.request import Request
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FastApiRequest(Request):
|
|
21
|
+
"""A core ``Request`` backed by data pulled from a Starlette request.
|
|
22
|
+
|
|
23
|
+
Construct it with :meth:`from_request` from inside an async handler; that is
|
|
24
|
+
the only place the (async) form body can be read.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
*,
|
|
30
|
+
cookies: t.Mapping[str, str],
|
|
31
|
+
session: t.MutableMapping[str, t.Any],
|
|
32
|
+
request_data: t.Mapping[str, t.Any],
|
|
33
|
+
request_is_secure: bool,
|
|
34
|
+
) -> None:
|
|
35
|
+
super().__init__()
|
|
36
|
+
self._cookies = cookies
|
|
37
|
+
self._session = session
|
|
38
|
+
self._request_data = request_data
|
|
39
|
+
self._request_is_secure = request_is_secure
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
async def from_request(cls, request: StarletteRequest) -> FastApiRequest:
|
|
43
|
+
"""Eagerly read query/form params, cookies and session from Starlette.
|
|
44
|
+
|
|
45
|
+
The default launch-data storage writes to ``request.session``, which
|
|
46
|
+
requires Starlette's ``SessionMiddleware`` to be installed. If it is not,
|
|
47
|
+
a throwaway dict is used (fine for cache-backed storage, but session
|
|
48
|
+
storage will not persist across requests).
|
|
49
|
+
"""
|
|
50
|
+
request_data: dict[str, t.Any] = dict(request.query_params)
|
|
51
|
+
if request.method != "GET":
|
|
52
|
+
form = await request.form()
|
|
53
|
+
request_data.update({key: form[key] for key in form})
|
|
54
|
+
|
|
55
|
+
# request.session only exists when SessionMiddleware is active.
|
|
56
|
+
session: t.MutableMapping[str, t.Any]
|
|
57
|
+
session = request.session if "session" in request.scope else {}
|
|
58
|
+
|
|
59
|
+
return cls(
|
|
60
|
+
cookies=dict(request.cookies),
|
|
61
|
+
session=session,
|
|
62
|
+
request_data=request_data,
|
|
63
|
+
request_is_secure=request.url.scheme == "https",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def session(self) -> t.MutableMapping[str, t.Any]:
|
|
68
|
+
return self._session
|
|
69
|
+
|
|
70
|
+
def get_param(self, key: str) -> t.Any:
|
|
71
|
+
return self._request_data.get(key)
|
|
72
|
+
|
|
73
|
+
def get_cookie(self, key: str) -> str | None:
|
|
74
|
+
return self._cookies.get(key)
|
|
75
|
+
|
|
76
|
+
def is_secure(self) -> bool:
|
|
77
|
+
return self._request_is_secure
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Session service adapter.
|
|
2
|
+
|
|
3
|
+
The core's :class:`ltitoolkit.core.session.SessionService` is framework-neutral:
|
|
4
|
+
its default backend (``SessionDataStorage``) simply reads and writes
|
|
5
|
+
``request.session``. For FastAPI that is the dict provided by Starlette's
|
|
6
|
+
``SessionMiddleware``, so no behaviour needs to change here.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from ltitoolkit.core.session import SessionService
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FastApiSessionService(SessionService):
|
|
13
|
+
pass
|
ltitoolkit/http.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""HTTP session construction with sane, safe defaults.
|
|
2
|
+
|
|
3
|
+
The vendored core issues requests with **no timeout**, which can hang a worker
|
|
4
|
+
indefinitely if the LMS stalls. Rather than patch the vendored code, we provide
|
|
5
|
+
a session whose adapter injects a default timeout on every request, and we pass
|
|
6
|
+
that session into the core services and our own token service.
|
|
7
|
+
|
|
8
|
+
Retries are **disabled by default** and, when enabled, are restricted to
|
|
9
|
+
idempotent methods (GET/HEAD/OPTIONS). LTI score submission and token requests
|
|
10
|
+
are POSTs and must never be retried blindly (risk of duplicate side effects).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import typing as t
|
|
16
|
+
|
|
17
|
+
import requests
|
|
18
|
+
from requests.adapters import HTTPAdapter
|
|
19
|
+
from requests.models import PreparedRequest
|
|
20
|
+
from urllib3.util.retry import Retry
|
|
21
|
+
|
|
22
|
+
# (connect timeout, read timeout) in seconds.
|
|
23
|
+
DEFAULT_TIMEOUT: tuple[float, float] = (10.0, 30.0)
|
|
24
|
+
|
|
25
|
+
_IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TimeoutHTTPAdapter(HTTPAdapter):
|
|
29
|
+
"""An adapter that applies a default timeout when a call omits one."""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self, *args: t.Any, timeout: float | tuple[float, float] = DEFAULT_TIMEOUT, **kwargs: t.Any
|
|
33
|
+
) -> None:
|
|
34
|
+
self._timeout = timeout
|
|
35
|
+
super().__init__(*args, **kwargs)
|
|
36
|
+
|
|
37
|
+
def send( # noqa: PLR0913 - signature mirrors requests' HTTPAdapter.send
|
|
38
|
+
self,
|
|
39
|
+
request: PreparedRequest,
|
|
40
|
+
stream: bool = False,
|
|
41
|
+
timeout: t.Any = None,
|
|
42
|
+
verify: t.Any = True,
|
|
43
|
+
cert: t.Any = None,
|
|
44
|
+
proxies: t.Any = None,
|
|
45
|
+
) -> t.Any:
|
|
46
|
+
if timeout is None:
|
|
47
|
+
timeout = self._timeout
|
|
48
|
+
return super().send(
|
|
49
|
+
request, stream=stream, timeout=timeout, verify=verify, cert=cert, proxies=proxies
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def build_session(
|
|
54
|
+
*,
|
|
55
|
+
timeout: float | tuple[float, float] = DEFAULT_TIMEOUT,
|
|
56
|
+
retries: int = 0,
|
|
57
|
+
backoff_factor: float = 0.3,
|
|
58
|
+
user_agent: str = "ltitoolkit",
|
|
59
|
+
) -> requests.Session:
|
|
60
|
+
"""Create a ``requests.Session`` with a default timeout and optional retries.
|
|
61
|
+
|
|
62
|
+
:param timeout: default ``(connect, read)`` timeout for every request.
|
|
63
|
+
:param retries: max retries for *idempotent* methods only (0 disables them).
|
|
64
|
+
:param backoff_factor: exponential backoff between retries.
|
|
65
|
+
:param user_agent: ``User-Agent`` header sent with every request.
|
|
66
|
+
"""
|
|
67
|
+
session = requests.Session()
|
|
68
|
+
session.headers["User-Agent"] = user_agent
|
|
69
|
+
|
|
70
|
+
retry = Retry(
|
|
71
|
+
total=retries,
|
|
72
|
+
backoff_factor=backoff_factor,
|
|
73
|
+
status_forcelist=(429, 500, 502, 503, 504),
|
|
74
|
+
allowed_methods=_IDEMPOTENT_METHODS,
|
|
75
|
+
raise_on_status=False,
|
|
76
|
+
)
|
|
77
|
+
adapter = TimeoutHTTPAdapter(timeout=timeout, max_retries=retry)
|
|
78
|
+
session.mount("https://", adapter)
|
|
79
|
+
session.mount("http://", adapter)
|
|
80
|
+
return session
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Generic OAuth2 client-credentials token minting.
|
|
2
|
+
|
|
3
|
+
The tool authenticates to an LMS by signing a JWT with its *own* private key
|
|
4
|
+
(the key behind its JWKS) and exchanging it for an access token via the
|
|
5
|
+
client-credentials grant — no user login, no copied credentials.
|
|
6
|
+
|
|
7
|
+
This is reused for both LTI Advantage service calls (AGS/NRPS — portable) and
|
|
8
|
+
LMS-proprietary API calls (Canvas etc. — used by per-LMS adapters). Only the
|
|
9
|
+
*scopes* and *endpoints* differ per LMS; the auth mechanism here is shared.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .cache import AccessToken, InMemoryTokenCache, TokenCache
|
|
13
|
+
from .service import AccessTokenService
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"AccessToken",
|
|
17
|
+
"TokenCache",
|
|
18
|
+
"InMemoryTokenCache",
|
|
19
|
+
"AccessTokenService",
|
|
20
|
+
]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Access-token value object and pluggable cache.
|
|
2
|
+
|
|
3
|
+
Tokens are cached per scope-set and reused until they are near expiry. The cache
|
|
4
|
+
is an interface (:class:`TokenCache`) so applications can swap the default
|
|
5
|
+
in-process dict for Redis, a database, etc. — important when running multiple
|
|
6
|
+
workers that should share tokens (the default cache is per-process only).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import typing as t
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class AccessToken:
|
|
17
|
+
"""An OAuth2 access token with the scopes it was issued for and its expiry."""
|
|
18
|
+
|
|
19
|
+
value: str
|
|
20
|
+
scopes: tuple[str, ...]
|
|
21
|
+
expires_at: float # epoch seconds
|
|
22
|
+
|
|
23
|
+
def is_expired(self, *, leeway: float = 60.0, now: float) -> bool:
|
|
24
|
+
"""True if the token is at/after its expiry (minus a safety ``leeway``)."""
|
|
25
|
+
return now >= (self.expires_at - leeway)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@t.runtime_checkable
|
|
29
|
+
class TokenCache(t.Protocol):
|
|
30
|
+
"""Minimal cache interface for storing access tokens by key."""
|
|
31
|
+
|
|
32
|
+
def get(self, key: str) -> AccessToken | None: ...
|
|
33
|
+
|
|
34
|
+
def set(self, key: str, token: AccessToken) -> None: ...
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class InMemoryTokenCache:
|
|
38
|
+
"""Default per-process cache. Not shared across workers/processes."""
|
|
39
|
+
|
|
40
|
+
def __init__(self) -> None:
|
|
41
|
+
self._store: dict[str, AccessToken] = {}
|
|
42
|
+
|
|
43
|
+
def get(self, key: str) -> AccessToken | None:
|
|
44
|
+
return self._store.get(key)
|
|
45
|
+
|
|
46
|
+
def set(self, key: str, token: AccessToken) -> None:
|
|
47
|
+
self._store[key] = token
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Client-credentials access-token service.
|
|
2
|
+
|
|
3
|
+
The tool authenticates to an LMS by signing a short-lived *client assertion* JWT
|
|
4
|
+
with its own private key and exchanging it (``grant_type=client_credentials``)
|
|
5
|
+
for an access token — no user login, no copied secrets. This is the auth half of
|
|
6
|
+
both LTI Advantage service calls and LMS-proprietary API calls (e.g. listing
|
|
7
|
+
Canvas files); only the requested *scopes* differ.
|
|
8
|
+
|
|
9
|
+
This service adds, on top of the vendored core's signing:
|
|
10
|
+
|
|
11
|
+
- **expiry-aware caching** (reuse a token until it nears expiry), and
|
|
12
|
+
- **explicit timeouts** and **typed errors**
|
|
13
|
+
|
|
14
|
+
which the core's in-memory token cache lacks. The security-critical RSA signing
|
|
15
|
+
is delegated to PyJWT exactly as the core does it.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import hashlib
|
|
21
|
+
import time
|
|
22
|
+
import typing as t
|
|
23
|
+
import uuid
|
|
24
|
+
|
|
25
|
+
import jwt
|
|
26
|
+
import requests
|
|
27
|
+
|
|
28
|
+
from ..exceptions import AccessTokenError
|
|
29
|
+
from ..http import build_session
|
|
30
|
+
from .cache import AccessToken, InMemoryTokenCache, TokenCache
|
|
31
|
+
|
|
32
|
+
if t.TYPE_CHECKING:
|
|
33
|
+
from ..core.registration import Registration
|
|
34
|
+
|
|
35
|
+
_JWT_BEARER = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
|
|
36
|
+
# Client-assertion lifetime. Kept short per the LTI security spec.
|
|
37
|
+
_ASSERTION_LIFETIME = 60
|
|
38
|
+
# Fallback token lifetime if the platform omits ``expires_in``.
|
|
39
|
+
_DEFAULT_TOKEN_LIFETIME = 3600
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AccessTokenService:
|
|
43
|
+
"""Mint and cache client-credentials access tokens for a registration."""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
registration: Registration,
|
|
48
|
+
*,
|
|
49
|
+
session: requests.Session | None = None,
|
|
50
|
+
cache: TokenCache | None = None,
|
|
51
|
+
timeout: float | tuple[float, float] = (10.0, 30.0),
|
|
52
|
+
expiry_leeway: float = 60.0,
|
|
53
|
+
clock: t.Callable[[], float] = time.time,
|
|
54
|
+
) -> None:
|
|
55
|
+
self._registration = registration
|
|
56
|
+
self._session = session if session is not None else build_session(timeout=timeout)
|
|
57
|
+
self._cache: TokenCache = cache if cache is not None else InMemoryTokenCache()
|
|
58
|
+
self._timeout = timeout
|
|
59
|
+
self._leeway = expiry_leeway
|
|
60
|
+
self._clock = clock
|
|
61
|
+
|
|
62
|
+
def get_token(self, scopes: t.Sequence[str]) -> str:
|
|
63
|
+
"""Return a valid access token for ``scopes``, minting one if needed."""
|
|
64
|
+
if not scopes:
|
|
65
|
+
raise ValueError("At least one scope is required")
|
|
66
|
+
|
|
67
|
+
key = self._cache_key(scopes)
|
|
68
|
+
cached = self._cache.get(key)
|
|
69
|
+
if cached is not None and not cached.is_expired(
|
|
70
|
+
leeway=self._leeway, now=self._clock()
|
|
71
|
+
):
|
|
72
|
+
return cached.value
|
|
73
|
+
|
|
74
|
+
token = self._request_token(scopes)
|
|
75
|
+
self._cache.set(key, token)
|
|
76
|
+
return token.value
|
|
77
|
+
|
|
78
|
+
# -- internals ---------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
@staticmethod
|
|
81
|
+
def _cache_key(scopes: t.Sequence[str]) -> str:
|
|
82
|
+
canonical = "|".join(sorted(scopes)).encode("utf-8")
|
|
83
|
+
return hashlib.sha256(canonical).hexdigest()
|
|
84
|
+
|
|
85
|
+
def _build_client_assertion(self) -> str:
|
|
86
|
+
client_id = self._registration.get_client_id()
|
|
87
|
+
token_url = self._registration.get_auth_token_url()
|
|
88
|
+
assert client_id is not None, "Registration is missing client_id"
|
|
89
|
+
assert token_url is not None, "Registration is missing auth_token_url"
|
|
90
|
+
audience = self._registration.get_auth_audience() or token_url
|
|
91
|
+
private_key = self._registration.get_tool_private_key()
|
|
92
|
+
assert private_key is not None, "Registration is missing the tool private key"
|
|
93
|
+
|
|
94
|
+
now = int(self._clock())
|
|
95
|
+
claims: dict[str, t.Any] = {
|
|
96
|
+
"iss": str(client_id),
|
|
97
|
+
"sub": str(client_id),
|
|
98
|
+
"aud": str(audience),
|
|
99
|
+
"iat": now - 5,
|
|
100
|
+
"exp": now + _ASSERTION_LIFETIME,
|
|
101
|
+
"jti": "ltitoolkit-" + uuid.uuid4().hex,
|
|
102
|
+
}
|
|
103
|
+
headers: dict[str, str] = {}
|
|
104
|
+
kid = self._registration.get_kid()
|
|
105
|
+
if kid:
|
|
106
|
+
headers["kid"] = kid
|
|
107
|
+
|
|
108
|
+
encoded = jwt.encode(claims, private_key, algorithm="RS256", headers=headers)
|
|
109
|
+
# PyJWT < 2 returned bytes; normalise to str.
|
|
110
|
+
return encoded.decode("utf-8") if isinstance(encoded, bytes) else encoded
|
|
111
|
+
|
|
112
|
+
def _request_token(self, scopes: t.Sequence[str]) -> AccessToken:
|
|
113
|
+
token_url = self._registration.get_auth_token_url()
|
|
114
|
+
assert token_url is not None, "Registration is missing auth_token_url"
|
|
115
|
+
|
|
116
|
+
payload = {
|
|
117
|
+
"grant_type": "client_credentials",
|
|
118
|
+
"client_assertion_type": _JWT_BEARER,
|
|
119
|
+
"client_assertion": self._build_client_assertion(),
|
|
120
|
+
"scope": " ".join(scopes),
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
response = self._session.post(token_url, data=payload, timeout=self._timeout)
|
|
125
|
+
except requests.Timeout as exc:
|
|
126
|
+
raise AccessTokenError(
|
|
127
|
+
f"Timed out requesting access token: {exc}",
|
|
128
|
+
url=token_url,
|
|
129
|
+
is_timeout=True,
|
|
130
|
+
) from exc
|
|
131
|
+
except requests.RequestException as exc:
|
|
132
|
+
raise AccessTokenError(
|
|
133
|
+
f"Error requesting access token: {exc}", url=token_url
|
|
134
|
+
) from exc
|
|
135
|
+
|
|
136
|
+
if not response.ok:
|
|
137
|
+
raise AccessTokenError(
|
|
138
|
+
"Access token request rejected by the platform",
|
|
139
|
+
status_code=response.status_code,
|
|
140
|
+
url=token_url,
|
|
141
|
+
response_text=response.text,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
body = response.json()
|
|
146
|
+
access_token = body["access_token"]
|
|
147
|
+
except (ValueError, KeyError, TypeError) as exc:
|
|
148
|
+
raise AccessTokenError(
|
|
149
|
+
"Malformed access token response from the platform",
|
|
150
|
+
status_code=response.status_code,
|
|
151
|
+
url=token_url,
|
|
152
|
+
response_text=response.text,
|
|
153
|
+
) from exc
|
|
154
|
+
|
|
155
|
+
expires_in = body.get("expires_in", _DEFAULT_TOKEN_LIFETIME)
|
|
156
|
+
try:
|
|
157
|
+
expires_at = self._clock() + float(expires_in)
|
|
158
|
+
except (TypeError, ValueError):
|
|
159
|
+
expires_at = self._clock() + _DEFAULT_TOKEN_LIFETIME
|
|
160
|
+
|
|
161
|
+
return AccessToken(
|
|
162
|
+
value=access_token,
|
|
163
|
+
scopes=tuple(sorted(scopes)),
|
|
164
|
+
expires_at=expires_at,
|
|
165
|
+
)
|