coda-node 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.
- coda_node/__init__.py +18 -0
- coda_node/errors.py +44 -0
- coda_node/py.typed +0 -0
- coda_node/server/__init__.py +9 -0
- coda_node/server/app.py +191 -0
- coda_node/server/auth.py +192 -0
- coda_node/server/cli.py +360 -0
- coda_node/server/config.py +300 -0
- coda_node/server/consumer.py +625 -0
- coda_node/server/daemon.py +261 -0
- coda_node/server/executor.py +200 -0
- coda_node/server/heartbeat.py +113 -0
- coda_node/server/ir.py +182 -0
- coda_node/server/qasm.py +377 -0
- coda_node/server/webhook.py +161 -0
- coda_node/vpn/__init__.py +49 -0
- coda_node/vpn/guard.py +424 -0
- coda_node/vpn/service.py +593 -0
- coda_node-0.1.0.dist-info/METADATA +348 -0
- coda_node-0.1.0.dist-info/RECORD +23 -0
- coda_node-0.1.0.dist-info/WHEEL +4 -0
- coda_node-0.1.0.dist-info/entry_points.txt +3 -0
- coda_node-0.1.0.dist-info/licenses/LICENSE +21 -0
coda_node/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Coda node: a standalone server runtime for quantum processing units.
|
|
2
|
+
|
|
3
|
+
This package provides everything needed to connect an execution backend
|
|
4
|
+
(QPU or simulator) to the Coda cloud platform. It handles JWT-based
|
|
5
|
+
authentication, VPN tunnel management, Redis job consumption, and signed
|
|
6
|
+
webhook delivery so that backend authors only need to implement the
|
|
7
|
+
:class:`~coda_node.server.executor.JobExecutor` protocol.
|
|
8
|
+
|
|
9
|
+
Typical usage::
|
|
10
|
+
|
|
11
|
+
uv run coda-node start --token <node-token>
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from coda_node.errors import CodaError
|
|
15
|
+
from coda_node.server import app, create_app
|
|
16
|
+
|
|
17
|
+
__version__ = "0.1.0"
|
|
18
|
+
__all__ = ["CodaError", "__version__", "app", "create_app"]
|
coda_node/errors.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Shared exception hierarchy for coda-node.
|
|
2
|
+
|
|
3
|
+
All domain-specific exceptions inherit from :class:`CodaError` so that
|
|
4
|
+
callers can distinguish expected operational errors from unexpected bugs
|
|
5
|
+
with a single ``except CodaError`` clause.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"AuthError",
|
|
10
|
+
"CodaError",
|
|
11
|
+
"ConfigError",
|
|
12
|
+
"ExecutorError",
|
|
13
|
+
"NodeError",
|
|
14
|
+
"VPNError",
|
|
15
|
+
"WebhookError",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CodaError(Exception):
|
|
20
|
+
"""Base exception for all coda-node errors."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ConfigError(CodaError):
|
|
24
|
+
"""Invalid or missing configuration."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AuthError(CodaError):
|
|
28
|
+
"""JWT authentication failure."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class VPNError(CodaError):
|
|
32
|
+
"""VPN tunnel or health check failure."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class NodeError(CodaError):
|
|
36
|
+
"""Node provisioning or reconnect failure."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ExecutorError(CodaError):
|
|
40
|
+
"""Executor loading or job execution failure."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class WebhookError(CodaError):
|
|
44
|
+
"""Webhook delivery failure."""
|
coda_node/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Server runtime: FastAPI app, configuration, job consumption, and webhooks.
|
|
2
|
+
|
|
3
|
+
Re-exports the application factory :func:`create_app` and the default
|
|
4
|
+
``app`` instance used by the ``coda start`` CLI command.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from coda_node.server.app import app, create_app
|
|
8
|
+
|
|
9
|
+
__all__ = ["app", "create_app"]
|
coda_node/server/app.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""FastAPI application for the standalone Coda-connected node server.
|
|
2
|
+
|
|
3
|
+
The application is constructed via :func:`create_app`, which wires up a
|
|
4
|
+
lifespan that boots the VPN guard, Redis consumer, heartbeat reporter,
|
|
5
|
+
and webhook client. A module-level ``app`` instance is provided for
|
|
6
|
+
``uvicorn`` import-string references (``coda_node.server.app:app``).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import logging
|
|
13
|
+
from collections.abc import AsyncGenerator
|
|
14
|
+
from contextlib import asynccontextmanager, suppress
|
|
15
|
+
|
|
16
|
+
import redis.asyncio as aioredis
|
|
17
|
+
from fastapi import FastAPI
|
|
18
|
+
from fastapi.responses import JSONResponse
|
|
19
|
+
|
|
20
|
+
from coda_node.server.config import Settings
|
|
21
|
+
from coda_node.server.consumer import RedisConsumer
|
|
22
|
+
from coda_node.server.executor import JobExecutor, load_executor
|
|
23
|
+
from coda_node.server.heartbeat import HeartbeatClient
|
|
24
|
+
from coda_node.server.webhook import WebhookClient
|
|
25
|
+
from coda_node.vpn import (
|
|
26
|
+
ServiceState,
|
|
27
|
+
VPNGuard,
|
|
28
|
+
connect_settings,
|
|
29
|
+
kill_openvpn_daemon,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _resolve_connectivity(device_spec: object | None) -> list[list[int]] | None:
|
|
36
|
+
"""Extract qubit connectivity from a device spec, preferring directed edges."""
|
|
37
|
+
if device_spec is None:
|
|
38
|
+
return None
|
|
39
|
+
directed = getattr(device_spec, "directed_edges", None)
|
|
40
|
+
if directed:
|
|
41
|
+
return [list(e) for e in directed]
|
|
42
|
+
return [list(e) for e in device_spec.logical_edges] # type: ignore[attr-defined]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def _on_vpn_state_change(state: ServiceState) -> None:
|
|
46
|
+
"""Log VPN state transitions at WARNING level."""
|
|
47
|
+
logger.warning("VPN state changed: %s", state.value)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def create_app(executor: JobExecutor | None = None) -> FastAPI:
|
|
51
|
+
"""Build a fully-wired FastAPI application.
|
|
52
|
+
|
|
53
|
+
The returned app uses an async lifespan that:
|
|
54
|
+
|
|
55
|
+
1. Loads ``Settings`` from environment / persisted config.
|
|
56
|
+
2. Connects to the Coda cloud (node or JWT reconnect).
|
|
57
|
+
3. Runs a VPN preflight check and starts background monitoring.
|
|
58
|
+
4. Opens a Redis consumer loop that dispatches jobs to *executor*.
|
|
59
|
+
5. On shutdown, drains in-flight work and tears down resources.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
executor: Custom execution backend. When ``None``, the executor
|
|
63
|
+
is resolved from ``CODA_EXECUTOR_FACTORY`` or falls back to
|
|
64
|
+
:class:`~coda_node.server.executor.NoopExecutor`.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
A configured :class:`~fastapi.FastAPI` instance with ``/health``
|
|
68
|
+
and ``/ready`` endpoints.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
@asynccontextmanager
|
|
72
|
+
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|
73
|
+
"""Boot services on startup and tear them down on shutdown."""
|
|
74
|
+
settings = Settings()
|
|
75
|
+
await connect_settings(settings)
|
|
76
|
+
|
|
77
|
+
guard = VPNGuard(
|
|
78
|
+
probe_targets=settings.vpn_probe_urls,
|
|
79
|
+
interface_hint=settings.vpn_interface_hint,
|
|
80
|
+
check_interval_sec=settings.vpn_check_interval_sec,
|
|
81
|
+
vpn_required=settings.vpn_required,
|
|
82
|
+
extra_headers=settings.node_connect_headers,
|
|
83
|
+
)
|
|
84
|
+
vpn_status = await guard.preflight()
|
|
85
|
+
if (
|
|
86
|
+
not vpn_status.ok
|
|
87
|
+
and settings.vpn_required
|
|
88
|
+
and not settings.allow_degraded_startup
|
|
89
|
+
):
|
|
90
|
+
raise RuntimeError(f"VPN preflight failed: {vpn_status.reason}")
|
|
91
|
+
|
|
92
|
+
redis_client = aioredis.from_url(settings.redis_url, decode_responses=True)
|
|
93
|
+
runner = executor or load_executor(settings)
|
|
94
|
+
|
|
95
|
+
device_spec = getattr(runner, "device", None)
|
|
96
|
+
connectivity = _resolve_connectivity(device_spec)
|
|
97
|
+
|
|
98
|
+
webhook = WebhookClient(
|
|
99
|
+
qpu_id=settings.qpu_id,
|
|
100
|
+
jwt_private_key=settings.jwt_private_key,
|
|
101
|
+
jwt_key_id=settings.jwt_key_id,
|
|
102
|
+
extra_headers=settings.node_connect_headers,
|
|
103
|
+
)
|
|
104
|
+
consumer = RedisConsumer(
|
|
105
|
+
redis=redis_client,
|
|
106
|
+
runner=runner,
|
|
107
|
+
webhook=webhook,
|
|
108
|
+
qpu_id=settings.qpu_id,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
heartbeat = HeartbeatClient(
|
|
112
|
+
heartbeat_url=settings.heartbeat_url,
|
|
113
|
+
qpu_id=settings.qpu_id,
|
|
114
|
+
jwt_private_key=settings.jwt_private_key,
|
|
115
|
+
jwt_key_id=settings.jwt_key_id,
|
|
116
|
+
consumer=consumer,
|
|
117
|
+
interval=settings.heartbeat_interval_sec,
|
|
118
|
+
connectivity=connectivity,
|
|
119
|
+
extra_headers=settings.node_connect_headers,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
watch_task = asyncio.create_task(guard.watch(_on_vpn_state_change))
|
|
123
|
+
consumer_task = asyncio.create_task(consumer.consume_loop())
|
|
124
|
+
heartbeat_task = asyncio.create_task(heartbeat.run())
|
|
125
|
+
|
|
126
|
+
app.state.settings = settings
|
|
127
|
+
app.state.guard = guard
|
|
128
|
+
app.state.consumer = consumer
|
|
129
|
+
app.state.webhook = webhook
|
|
130
|
+
app.state.heartbeat = heartbeat
|
|
131
|
+
|
|
132
|
+
yield
|
|
133
|
+
|
|
134
|
+
guard.stop()
|
|
135
|
+
watch_task.cancel()
|
|
136
|
+
with suppress(asyncio.CancelledError):
|
|
137
|
+
await watch_task
|
|
138
|
+
|
|
139
|
+
await heartbeat.close()
|
|
140
|
+
heartbeat_task.cancel()
|
|
141
|
+
with suppress(asyncio.CancelledError):
|
|
142
|
+
await heartbeat_task
|
|
143
|
+
|
|
144
|
+
drained = await consumer.drain(timeout=settings.shutdown_drain_timeout_sec)
|
|
145
|
+
if not drained:
|
|
146
|
+
logger.warning("Drain timeout expired, cancelling in-flight job")
|
|
147
|
+
consumer_task.cancel()
|
|
148
|
+
with suppress(asyncio.CancelledError):
|
|
149
|
+
await consumer_task
|
|
150
|
+
|
|
151
|
+
await webhook.close()
|
|
152
|
+
await redis_client.aclose()
|
|
153
|
+
kill_openvpn_daemon()
|
|
154
|
+
|
|
155
|
+
app = FastAPI(title="Coda Node", lifespan=lifespan)
|
|
156
|
+
|
|
157
|
+
@app.get("/health")
|
|
158
|
+
async def health() -> dict[str, str]:
|
|
159
|
+
"""Liveness probe — returns 200 if the process is running."""
|
|
160
|
+
return {"status": "ok"}
|
|
161
|
+
|
|
162
|
+
async def _check_readiness() -> dict[str, object]:
|
|
163
|
+
"""Gather composite readiness state from VPN guard and Redis consumer."""
|
|
164
|
+
guard: VPNGuard = app.state.guard
|
|
165
|
+
consumer: RedisConsumer = app.state.consumer
|
|
166
|
+
vpn_ok = guard.is_ready
|
|
167
|
+
redis_ok = consumer.redis_healthy
|
|
168
|
+
return {
|
|
169
|
+
"ready": vpn_ok and redis_ok,
|
|
170
|
+
"vpn_state": guard.state.value,
|
|
171
|
+
"redis_healthy": redis_ok,
|
|
172
|
+
"current_job": consumer.current_job_id,
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
@app.get("/ready")
|
|
176
|
+
async def ready() -> JSONResponse:
|
|
177
|
+
"""Readiness probe — returns 200 when healthy, 503 otherwise."""
|
|
178
|
+
try:
|
|
179
|
+
result = await asyncio.wait_for(_check_readiness(), timeout=5.0)
|
|
180
|
+
status_code = 200 if result["ready"] else 503
|
|
181
|
+
return JSONResponse(content=result, status_code=status_code)
|
|
182
|
+
except TimeoutError:
|
|
183
|
+
return JSONResponse(
|
|
184
|
+
content={"ready": False, "reason": "health check timeout"},
|
|
185
|
+
status_code=503,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
return app
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
app = create_app()
|
coda_node/server/auth.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""RS256 JWT authentication between the node runtime and the Coda cloud.
|
|
2
|
+
|
|
3
|
+
Every request from the node to the Coda API (webhooks, heartbeats,
|
|
4
|
+
reconnect) is authenticated with a short-lived JWT signed with an RSA
|
|
5
|
+
private key. The cloud verifies the signature using the corresponding
|
|
6
|
+
public key registered during node provisioning.
|
|
7
|
+
|
|
8
|
+
Key concepts:
|
|
9
|
+
|
|
10
|
+
* **KeyPair** -- an RSA keypair generated locally or received from the
|
|
11
|
+
node endpoint.
|
|
12
|
+
* **sign_token** -- creates a signed JWT for outbound requests.
|
|
13
|
+
* **verify_token** / **verify_token_with_key** -- validate inbound JWTs
|
|
14
|
+
(used primarily in tests and potential future inbound auth).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from datetime import UTC, datetime, timedelta
|
|
22
|
+
|
|
23
|
+
import jwt
|
|
24
|
+
from cryptography.hazmat.primitives import serialization
|
|
25
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"DEFAULT_ISSUER",
|
|
29
|
+
"DEFAULT_TTL",
|
|
30
|
+
"KeyPair",
|
|
31
|
+
"generate_keypair",
|
|
32
|
+
"sign_token",
|
|
33
|
+
"verify_token",
|
|
34
|
+
"verify_token_with_key",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
DEFAULT_ISSUER = "coda"
|
|
38
|
+
DEFAULT_TTL = timedelta(hours=1)
|
|
39
|
+
KEY_SIZE = 2048
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class KeyPair:
|
|
44
|
+
"""RSA keypair for JWT authentication."""
|
|
45
|
+
|
|
46
|
+
private_key_pem: str
|
|
47
|
+
public_key_pem: str
|
|
48
|
+
key_id: str
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def generate_keypair(key_id: str) -> KeyPair:
|
|
52
|
+
"""Generate a fresh PEM-encoded RSA-2048 keypair.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
key_id: Logical identifier embedded in JWTs as the ``kid`` header.
|
|
56
|
+
Typically the QPU node identifier.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
A :class:`KeyPair` containing PEM-encoded private and public keys.
|
|
60
|
+
"""
|
|
61
|
+
private_key = rsa.generate_private_key(public_exponent=65537, key_size=KEY_SIZE)
|
|
62
|
+
|
|
63
|
+
private_pem = private_key.private_bytes(
|
|
64
|
+
encoding=serialization.Encoding.PEM,
|
|
65
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
66
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
67
|
+
).decode()
|
|
68
|
+
|
|
69
|
+
public_pem = (
|
|
70
|
+
private_key.public_key()
|
|
71
|
+
.public_bytes(
|
|
72
|
+
encoding=serialization.Encoding.PEM,
|
|
73
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
74
|
+
)
|
|
75
|
+
.decode()
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
return KeyPair(
|
|
79
|
+
private_key_pem=private_pem,
|
|
80
|
+
public_key_pem=public_pem,
|
|
81
|
+
key_id=key_id,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def sign_token(
|
|
86
|
+
subject: str,
|
|
87
|
+
private_key_pem: str,
|
|
88
|
+
*,
|
|
89
|
+
issuer: str = DEFAULT_ISSUER,
|
|
90
|
+
ttl: timedelta = DEFAULT_TTL,
|
|
91
|
+
key_id: str | None = None,
|
|
92
|
+
) -> str:
|
|
93
|
+
"""Create a short-lived RS256-signed JWT.
|
|
94
|
+
|
|
95
|
+
The token carries ``sub``, ``iss``, ``iat``, and ``exp`` claims and a
|
|
96
|
+
``kid`` header so the verifier can look up the correct public key.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
subject: The ``sub`` claim -- usually the QPU node identifier.
|
|
100
|
+
private_key_pem: PEM-encoded RSA private key used to sign.
|
|
101
|
+
issuer: The ``iss`` claim.
|
|
102
|
+
ttl: Token lifetime from now.
|
|
103
|
+
key_id: Explicit ``kid`` header value. Defaults to *subject*.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
A compact-serialised JWT string.
|
|
107
|
+
"""
|
|
108
|
+
now = datetime.now(UTC)
|
|
109
|
+
payload = {
|
|
110
|
+
"sub": subject,
|
|
111
|
+
"iss": issuer,
|
|
112
|
+
"iat": now,
|
|
113
|
+
"exp": now + ttl,
|
|
114
|
+
}
|
|
115
|
+
headers = {"kid": key_id or subject}
|
|
116
|
+
return jwt.encode(payload, private_key_pem, algorithm="RS256", headers=headers)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def verify_token(
|
|
120
|
+
token: str,
|
|
121
|
+
get_public_key: Callable[[str], str | None],
|
|
122
|
+
*,
|
|
123
|
+
issuer: str = DEFAULT_ISSUER,
|
|
124
|
+
) -> dict[str, object]:
|
|
125
|
+
"""Verify and decode a JWT by resolving its ``kid`` to a public key.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
token: The compact-serialised JWT to verify.
|
|
129
|
+
get_public_key: Callable that maps a ``kid`` string to a
|
|
130
|
+
PEM-encoded public key, or ``None`` if the key is unknown.
|
|
131
|
+
issuer: Expected ``iss`` claim value.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
The decoded payload as a dictionary.
|
|
135
|
+
|
|
136
|
+
Raises:
|
|
137
|
+
jwt.InvalidTokenError: If the token is malformed, the ``kid`` is
|
|
138
|
+
missing or unknown, or signature / claim validation fails.
|
|
139
|
+
"""
|
|
140
|
+
try:
|
|
141
|
+
header = jwt.get_unverified_header(token)
|
|
142
|
+
except jwt.DecodeError as exc:
|
|
143
|
+
raise jwt.InvalidTokenError(f"Malformed token header: {exc}") from exc
|
|
144
|
+
|
|
145
|
+
kid = header.get("kid")
|
|
146
|
+
if not kid:
|
|
147
|
+
raise jwt.InvalidTokenError("Token missing 'kid' header")
|
|
148
|
+
|
|
149
|
+
public_key_pem = get_public_key(kid)
|
|
150
|
+
if not public_key_pem:
|
|
151
|
+
raise jwt.InvalidTokenError(f"Unknown key_id: {kid}")
|
|
152
|
+
|
|
153
|
+
result: dict[str, object] = jwt.decode(
|
|
154
|
+
token,
|
|
155
|
+
public_key_pem,
|
|
156
|
+
algorithms=["RS256"],
|
|
157
|
+
issuer=issuer,
|
|
158
|
+
options={"require": ["sub", "iss", "exp", "iat"]},
|
|
159
|
+
)
|
|
160
|
+
return result
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def verify_token_with_key(
|
|
164
|
+
token: str,
|
|
165
|
+
public_key_pem: str,
|
|
166
|
+
*,
|
|
167
|
+
issuer: str = DEFAULT_ISSUER,
|
|
168
|
+
) -> dict[str, object]:
|
|
169
|
+
"""Verify and decode a JWT when the public key is already known.
|
|
170
|
+
|
|
171
|
+
Unlike :func:`verify_token`, this skips the ``kid`` lookup and uses
|
|
172
|
+
the provided key directly.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
token: The compact-serialised JWT to verify.
|
|
176
|
+
public_key_pem: PEM-encoded RSA public key.
|
|
177
|
+
issuer: Expected ``iss`` claim value.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
The decoded payload as a dictionary.
|
|
181
|
+
|
|
182
|
+
Raises:
|
|
183
|
+
jwt.InvalidTokenError: If signature or claim validation fails.
|
|
184
|
+
"""
|
|
185
|
+
result: dict[str, object] = jwt.decode(
|
|
186
|
+
token,
|
|
187
|
+
public_key_pem,
|
|
188
|
+
algorithms=["RS256"],
|
|
189
|
+
issuer=issuer,
|
|
190
|
+
options={"require": ["sub", "iss", "exp", "iat"]},
|
|
191
|
+
)
|
|
192
|
+
return result
|