easyssf-test 0.7.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.
- easyssf/testing/__init__.py +43 -0
- easyssf/testing/clock.py +38 -0
- easyssf/testing/keys.py +131 -0
- easyssf/testing/py.typed +0 -0
- easyssf/testing/transmitter.py +726 -0
- easyssf/testing/waiting.py +58 -0
- easyssf_test-0.7.0.dist-info/METADATA +32 -0
- easyssf_test-0.7.0.dist-info/RECORD +9 -0
- easyssf_test-0.7.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Test support for SSF receivers: the in-process test transmitter, signing helpers, a manual
|
|
2
|
+
clock and waiting helpers. The port of ``easyssf-test`` of the Java library."""
|
|
3
|
+
|
|
4
|
+
from easyssf.core.signing import JWT_TYPE, SECEVENT_JWT_TYPE
|
|
5
|
+
|
|
6
|
+
from .clock import ManualClock
|
|
7
|
+
from .keys import SigningKey, claims_of, generate_key, shared_key, sign, sign_with, unsigned
|
|
8
|
+
from .transmitter import (
|
|
9
|
+
ACCESS_TOKEN,
|
|
10
|
+
AUDIENCE,
|
|
11
|
+
CLIENT_ID,
|
|
12
|
+
CLIENT_SECRET,
|
|
13
|
+
InProcessTransport,
|
|
14
|
+
MetadataLocation,
|
|
15
|
+
ReportedError,
|
|
16
|
+
TestTransmitter,
|
|
17
|
+
client_for,
|
|
18
|
+
)
|
|
19
|
+
from .waiting import during, eventually
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"ACCESS_TOKEN",
|
|
23
|
+
"AUDIENCE",
|
|
24
|
+
"CLIENT_ID",
|
|
25
|
+
"CLIENT_SECRET",
|
|
26
|
+
"JWT_TYPE",
|
|
27
|
+
"SECEVENT_JWT_TYPE",
|
|
28
|
+
"InProcessTransport",
|
|
29
|
+
"ManualClock",
|
|
30
|
+
"MetadataLocation",
|
|
31
|
+
"ReportedError",
|
|
32
|
+
"SigningKey",
|
|
33
|
+
"TestTransmitter",
|
|
34
|
+
"claims_of",
|
|
35
|
+
"client_for",
|
|
36
|
+
"during",
|
|
37
|
+
"eventually",
|
|
38
|
+
"generate_key",
|
|
39
|
+
"shared_key",
|
|
40
|
+
"sign",
|
|
41
|
+
"sign_with",
|
|
42
|
+
"unsigned",
|
|
43
|
+
]
|
easyssf/testing/clock.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""A clock moved by hand, for tests of time-dependent behaviour."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import UTC, datetime, timedelta
|
|
6
|
+
|
|
7
|
+
__all__ = ["ManualClock"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ManualClock:
|
|
11
|
+
"""A clock that only moves when a test moves it; a ``Clock`` for stores and pollers.
|
|
12
|
+
|
|
13
|
+
Example:
|
|
14
|
+
>>> clock = ManualClock()
|
|
15
|
+
>>> store = InMemoryJtiDedupStore(100, timedelta(minutes=5), clock)
|
|
16
|
+
>>> clock.advance(timedelta(minutes=6))
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, now: datetime | None = None) -> None:
|
|
20
|
+
self._now = now if now is not None else datetime(2026, 1, 1, tzinfo=UTC)
|
|
21
|
+
|
|
22
|
+
def __call__(self) -> datetime:
|
|
23
|
+
"""The current time of the clock."""
|
|
24
|
+
return self._now
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def now(self) -> datetime:
|
|
28
|
+
"""The current time of the clock."""
|
|
29
|
+
return self._now
|
|
30
|
+
|
|
31
|
+
def advance(self, delta: timedelta) -> datetime:
|
|
32
|
+
"""Moves the clock forward by ``delta`` and returns the new time."""
|
|
33
|
+
self._now += delta
|
|
34
|
+
return self._now
|
|
35
|
+
|
|
36
|
+
def set(self, now: datetime) -> None:
|
|
37
|
+
"""Sets the clock to the given time."""
|
|
38
|
+
self._now = now
|
easyssf/testing/keys.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Signing keys and signing helpers for tests: keys the receiver accepts, keys it must reject,
|
|
2
|
+
and JWSs signed with whatever a test needs, with no minimum key size and no algorithm check."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import base64
|
|
7
|
+
import json
|
|
8
|
+
import threading
|
|
9
|
+
from dataclasses import dataclass, replace
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from joserfc import jws
|
|
13
|
+
from joserfc.jwk import RSAKey
|
|
14
|
+
|
|
15
|
+
from easyssf.core import Claims, parse_claims
|
|
16
|
+
from easyssf.core.signing import SECEVENT_JWT_TYPE, Signer
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from joserfc.jwk import ECKey, OKPKey
|
|
20
|
+
|
|
21
|
+
__all__ = ["SigningKey", "claims_of", "generate_key", "shared_key", "sign", "sign_with", "unsigned"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class SigningKey:
|
|
26
|
+
"""An RSA key a transmitter signs with."""
|
|
27
|
+
|
|
28
|
+
key_id: str
|
|
29
|
+
"""The ``kid`` the signed tokens carry."""
|
|
30
|
+
key: RSAKey
|
|
31
|
+
"""The private key."""
|
|
32
|
+
|
|
33
|
+
def with_key_id(self, key_id: str) -> SigningKey:
|
|
34
|
+
"""The same key under another ``kid``."""
|
|
35
|
+
return replace(self, key_id=key_id)
|
|
36
|
+
|
|
37
|
+
def public_jwk(self) -> Claims:
|
|
38
|
+
"""The public key as a JWK with the ``kid`` and ``use``."""
|
|
39
|
+
jwk = self.key.as_dict(private=False)
|
|
40
|
+
jwk["kid"] = self.key_id
|
|
41
|
+
jwk["use"] = "sig"
|
|
42
|
+
return jwk
|
|
43
|
+
|
|
44
|
+
def signer(self, algorithm: str = "RS256") -> Signer:
|
|
45
|
+
"""The key as a :class:`~easyssf.core.signing.Signer`, for SETs built with
|
|
46
|
+
:func:`easyssf.core.new_set`. Refuses a key below 2048 bits, as the signer does."""
|
|
47
|
+
return Signer(self.key, algorithm, self.key_id)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def generate_key(bits: int, key_id: str) -> SigningKey:
|
|
51
|
+
"""Makes a fresh key of the given size, for SETs the receiver must reject
|
|
52
|
+
(``generate_key(1024, "weak")``) or ones of another transmitter. :func:`shared_key` is faster."""
|
|
53
|
+
import warnings
|
|
54
|
+
|
|
55
|
+
with warnings.catch_warnings():
|
|
56
|
+
warnings.simplefilter("ignore")
|
|
57
|
+
return SigningKey(key_id, RSAKey.generate_key(bits))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
_shared_lock = threading.Lock()
|
|
61
|
+
_shared: SigningKey | None = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def shared_key() -> SigningKey:
|
|
65
|
+
"""The 2048 bit key every default transmitter of the process signs with: generating one takes
|
|
66
|
+
a while, and the tests do not depend on the keys of two transmitters differing."""
|
|
67
|
+
global _shared # noqa: PLW0603
|
|
68
|
+
with _shared_lock:
|
|
69
|
+
if _shared is None:
|
|
70
|
+
_shared = generate_key(2048, "test-key")
|
|
71
|
+
return _shared
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def sign(key: SigningKey, typ: str | None, claims: Claims) -> str:
|
|
75
|
+
"""Signs the claims with ``RS256``; ``typ`` is the ``typ`` header, left out if ``None`` or
|
|
76
|
+
``""``. ``SECEVENT_JWT_TYPE`` makes a SET."""
|
|
77
|
+
return sign_with(key, "RS256", typ, claims)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def sign_with(key: SigningKey | ECKey | OKPKey, algorithm: str, typ: str | None, claims: Claims) -> str:
|
|
81
|
+
"""Signs the claims as a JWS in compact serialization with any algorithm of RFC 7518 that fits
|
|
82
|
+
the key: ``RS256``, ``PS256``, ``ES256``, ``EdDSA``, ... No minimum key size, unlike
|
|
83
|
+
:class:`~easyssf.core.signing.Signer`: tests need to sign with keys the receiver has to reject."""
|
|
84
|
+
import warnings
|
|
85
|
+
|
|
86
|
+
if isinstance(key, SigningKey):
|
|
87
|
+
protected: dict[str, str] = {"alg": algorithm, "kid": key.key_id}
|
|
88
|
+
private = key.key
|
|
89
|
+
else:
|
|
90
|
+
protected = {"alg": algorithm}
|
|
91
|
+
if key.kid:
|
|
92
|
+
protected["kid"] = key.kid
|
|
93
|
+
private = key # type: ignore[assignment]
|
|
94
|
+
if typ:
|
|
95
|
+
protected["typ"] = typ
|
|
96
|
+
payload = json.dumps(claims, separators=(",", ":")).encode()
|
|
97
|
+
with warnings.catch_warnings():
|
|
98
|
+
warnings.simplefilter("ignore")
|
|
99
|
+
return jws.serialize_compact(protected, payload, private, algorithms=[algorithm])
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def unsigned(claims: Claims, typ: str | None = SECEVENT_JWT_TYPE) -> str:
|
|
103
|
+
"""The claims as an unsecured JWT (RFC 7519, section 6): ``alg`` of ``none``, no signature."""
|
|
104
|
+
header: dict[str, str] = {"alg": "none"}
|
|
105
|
+
if typ:
|
|
106
|
+
header["typ"] = typ
|
|
107
|
+
return _b64(json.dumps(header, separators=(",", ":")).encode()) + "." + _b64(_dump(claims)) + "."
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def claims_of(compact: str) -> Claims:
|
|
111
|
+
"""The claims of a JWS in compact serialization, without any verification.
|
|
112
|
+
|
|
113
|
+
Raises:
|
|
114
|
+
ValueError: if it is not a JWS whose payload is a JSON object.
|
|
115
|
+
"""
|
|
116
|
+
parts = compact.split(".")
|
|
117
|
+
if len(parts) != 3:
|
|
118
|
+
raise ValueError("not a JWS in compact serialization")
|
|
119
|
+
return parse_claims(_unb64(parts[1]))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _dump(claims: Claims) -> bytes:
|
|
123
|
+
return json.dumps(claims, separators=(",", ":")).encode()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _b64(data: bytes) -> str:
|
|
127
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _unb64(segment: str) -> bytes:
|
|
131
|
+
return base64.urlsafe_b64decode(segment + "=" * (-len(segment) % 4))
|
easyssf/testing/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
"""A minimal SSF transmitter / identity provider for the tests of a receiver: it publishes
|
|
2
|
+
transmitter metadata and a JWK Set, signs SETs and access tokens, issues access tokens for the
|
|
3
|
+
client credentials grant and offers stream management and a poll endpoint that behave like the
|
|
4
|
+
ones of Keycloak. It answers in-process: its ``client`` is an ``httpx.AsyncClient`` whose
|
|
5
|
+
transport routes requests for the transmitter's host to :meth:`TestTransmitter.handle` without a
|
|
6
|
+
socket, and :meth:`TestTransmitter.asgi` mounts the same handler on an ASGI server when a real
|
|
7
|
+
port is wanted. The port of ``ssftest`` of the Go port.
|
|
8
|
+
|
|
9
|
+
Example:
|
|
10
|
+
>>> transmitter = TestTransmitter()
|
|
11
|
+
>>> subject = complex_subject(iss_sub(transmitter.issuer, "alice"), opaque("session-1"))
|
|
12
|
+
>>> claims = transmitter.set_claims("CaepSessionRevoked", subject)
|
|
13
|
+
>>> claims["aud"] = "https://my-app.example"
|
|
14
|
+
>>> encoded_set = transmitter.sign_set(claims)
|
|
15
|
+
>>> # POST encoded_set to /ssf/push, or transmitter.queue_set(encoded_set) for a polling receiver
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import base64
|
|
22
|
+
import binascii
|
|
23
|
+
import json
|
|
24
|
+
import time
|
|
25
|
+
from collections.abc import Awaitable, Callable, Iterable
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from datetime import datetime, timedelta
|
|
28
|
+
from enum import Enum
|
|
29
|
+
from typing import Any, Final
|
|
30
|
+
from urllib.parse import parse_qs, urlsplit
|
|
31
|
+
|
|
32
|
+
import httpx
|
|
33
|
+
|
|
34
|
+
from easyssf.core import (
|
|
35
|
+
POLL_URI,
|
|
36
|
+
PUSH_URI,
|
|
37
|
+
SPEC_VERSION_1_0,
|
|
38
|
+
Claims,
|
|
39
|
+
StreamStatusValue,
|
|
40
|
+
as_int,
|
|
41
|
+
event_types,
|
|
42
|
+
new_jti,
|
|
43
|
+
object_member,
|
|
44
|
+
parse_claims,
|
|
45
|
+
string_member,
|
|
46
|
+
strings,
|
|
47
|
+
)
|
|
48
|
+
from easyssf.core.signing import JWT_TYPE, SECEVENT_JWT_TYPE, Signer
|
|
49
|
+
|
|
50
|
+
from .keys import SigningKey, claims_of, shared_key, sign
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"ACCESS_TOKEN",
|
|
54
|
+
"AUDIENCE",
|
|
55
|
+
"CLIENT_ID",
|
|
56
|
+
"CLIENT_SECRET",
|
|
57
|
+
"InProcessTransport",
|
|
58
|
+
"MetadataLocation",
|
|
59
|
+
"ReportedError",
|
|
60
|
+
"TestTransmitter",
|
|
61
|
+
"client_for",
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
AUDIENCE: Final = "https://receiver.example"
|
|
65
|
+
"""The audience of the SETs the transmitter signs by default."""
|
|
66
|
+
CLIENT_ID: Final = "receiver"
|
|
67
|
+
"""The client id the token endpoint accepts."""
|
|
68
|
+
CLIENT_SECRET: Final = "receiver-secret" # noqa: S105
|
|
69
|
+
"""The client secret the token endpoint accepts."""
|
|
70
|
+
ACCESS_TOKEN: Final = "transmitter-access-token" # noqa: S105
|
|
71
|
+
"""The access token the token endpoint issues until :meth:`TestTransmitter.expire_access_tokens`."""
|
|
72
|
+
|
|
73
|
+
_REALM: Final = "/realms/test"
|
|
74
|
+
_WELL_KNOWN_PATH: Final = "/.well-known/ssf-configuration"
|
|
75
|
+
_JWKS_PATH: Final = "/protocol/openid-connect/certs"
|
|
76
|
+
_TOKEN_PATH: Final = "/protocol/openid-connect/token" # noqa: S105
|
|
77
|
+
_POLL_PATH: Final = "/ssf/poll"
|
|
78
|
+
_DEFAULT_MAX_EVENTS: Final = 10
|
|
79
|
+
_BLOCKING_POLL_INTERVAL: Final = 0.01
|
|
80
|
+
_JSON_HEADERS: Final = {"Content-Type": "application/json"}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class MetadataLocation(Enum):
|
|
84
|
+
"""Where the transmitter publishes its metadata."""
|
|
85
|
+
|
|
86
|
+
SSF = "ssf"
|
|
87
|
+
"""SSF 1.0, section 7.2: ``/.well-known/ssf-configuration`` followed by the path of the issuer."""
|
|
88
|
+
OIDC_STYLE = "oidc-style"
|
|
89
|
+
"""``/.well-known/ssf-configuration`` under the path of the issuer, as OpenID Connect Discovery
|
|
90
|
+
places its document and some transmitters do."""
|
|
91
|
+
NONE = "none"
|
|
92
|
+
"""No metadata is published."""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True, slots=True)
|
|
96
|
+
class ReportedError:
|
|
97
|
+
"""An error a polling receiver reported for a SET (the ``setErrs`` member of a poll request,
|
|
98
|
+
RFC 8936, section 2.2)."""
|
|
99
|
+
|
|
100
|
+
jti: str
|
|
101
|
+
code: str
|
|
102
|
+
description: str
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(slots=True)
|
|
106
|
+
class _QueuedSet:
|
|
107
|
+
jti: str
|
|
108
|
+
set: str
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(frozen=True, slots=True)
|
|
112
|
+
class _PendingPoll:
|
|
113
|
+
"""A poll request whose response waits for a SET: the hold and the ``maxEvents`` to answer with."""
|
|
114
|
+
|
|
115
|
+
hold: timedelta
|
|
116
|
+
max_events: int
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
_Scope = dict[str, Any]
|
|
120
|
+
_Receive = Callable[[], Awaitable[dict[str, Any]]]
|
|
121
|
+
_Send = Callable[[dict[str, Any]], Awaitable[None]]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class TestTransmitter:
|
|
125
|
+
"""A minimal SSF transmitter for tests, see the module documentation. Used from one event
|
|
126
|
+
loop; the blocking ``sync_client`` may be driven from another thread.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
location: where the metadata is published, the SSF location by default.
|
|
130
|
+
host: the host of the issuer, ``transmitter.example`` by default; give every transmitter of
|
|
131
|
+
a test its own, as a receiver tells transmitters apart by issuer.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
__test__ = False # not a test class, although pytest would take the name for one
|
|
135
|
+
|
|
136
|
+
def __init__(self, location: MetadataLocation = MetadataLocation.SSF, host: str = "") -> None:
|
|
137
|
+
self._issuer = "https://" + (host or "transmitter.example") + _REALM
|
|
138
|
+
self._key = shared_key()
|
|
139
|
+
self._available = True
|
|
140
|
+
self._requests = 0
|
|
141
|
+
self._tokens = 0
|
|
142
|
+
self._polls = 0
|
|
143
|
+
self._documents: dict[str, str] = {}
|
|
144
|
+
self._streams: list[Claims] = []
|
|
145
|
+
self._queued: list[_QueuedSet] = []
|
|
146
|
+
self._acknowledged: list[str] = []
|
|
147
|
+
self._reported_errors: list[ReportedError] = []
|
|
148
|
+
self._verification_requests: list[str] = []
|
|
149
|
+
self._added_subjects: list[Claims | None] = []
|
|
150
|
+
self._removed_subjects: list[Claims | None] = []
|
|
151
|
+
self._last_poll_request: Claims = {}
|
|
152
|
+
self._long_poll_hold = timedelta(0)
|
|
153
|
+
self._valid_access_token = ACCESS_TOKEN
|
|
154
|
+
self._stream_issuer = ""
|
|
155
|
+
self._user_agent = ""
|
|
156
|
+
self._waiters: list[asyncio.Future[None]] = [] # the held poll requests, woken when a SET is queued
|
|
157
|
+
self._transport: InProcessTransport | None = None
|
|
158
|
+
self._client: httpx.AsyncClient | None = None
|
|
159
|
+
self._sync_client: httpx.Client | None = None
|
|
160
|
+
self.publish_jwks(self._key)
|
|
161
|
+
self.publish_metadata(location)
|
|
162
|
+
|
|
163
|
+
@classmethod
|
|
164
|
+
def with_metadata(cls, location: MetadataLocation, host: str = "") -> TestTransmitter:
|
|
165
|
+
"""A transmitter that publishes its metadata at the given location, or not at all; the
|
|
166
|
+
``WithMetadata`` of the Go port."""
|
|
167
|
+
return cls(location, host)
|
|
168
|
+
|
|
169
|
+
# -- the in-process clients -------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def transport(self) -> InProcessTransport:
|
|
173
|
+
"""The transport that answers requests for the transmitter's host in-process, for an
|
|
174
|
+
``httpx.AsyncClient`` or ``httpx.Client`` of the receiver under test."""
|
|
175
|
+
if self._transport is None:
|
|
176
|
+
self._transport = InProcessTransport((self,))
|
|
177
|
+
return self._transport
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def client(self) -> httpx.AsyncClient:
|
|
181
|
+
"""The client that calls the transmitter in-process, :func:`client_for` this transmitter.
|
|
182
|
+
Give it to the receiver under test."""
|
|
183
|
+
if self._client is None:
|
|
184
|
+
self._client = httpx.AsyncClient(transport=self.transport)
|
|
185
|
+
return self._client
|
|
186
|
+
|
|
187
|
+
@property
|
|
188
|
+
def sync_client(self) -> httpx.Client:
|
|
189
|
+
"""The blocking client on the same transport, for sync code under test. A held long poll
|
|
190
|
+
blocks the calling thread, so do not use it on the event loop that queues the SETs."""
|
|
191
|
+
if self._sync_client is None:
|
|
192
|
+
self._sync_client = httpx.Client(transport=self.transport)
|
|
193
|
+
return self._sync_client
|
|
194
|
+
|
|
195
|
+
# -- what the transmitter is --------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def issuer(self) -> str:
|
|
199
|
+
"""The issuer of the transmitter: the ``iss`` of every SET and the base of its endpoints."""
|
|
200
|
+
return self._issuer
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def key(self) -> SigningKey:
|
|
204
|
+
"""The key the transmitter signs with."""
|
|
205
|
+
return self._key
|
|
206
|
+
|
|
207
|
+
@property
|
|
208
|
+
def jwks_url(self) -> str:
|
|
209
|
+
"""Where the JWK Set is published."""
|
|
210
|
+
return self._issuer + _JWKS_PATH
|
|
211
|
+
|
|
212
|
+
@property
|
|
213
|
+
def token_url(self) -> str:
|
|
214
|
+
"""The token endpoint."""
|
|
215
|
+
return self._issuer + _TOKEN_PATH
|
|
216
|
+
|
|
217
|
+
@property
|
|
218
|
+
def poll_url(self) -> str:
|
|
219
|
+
"""The poll endpoint."""
|
|
220
|
+
return self._issuer + _POLL_PATH
|
|
221
|
+
|
|
222
|
+
@property
|
|
223
|
+
def host(self) -> str:
|
|
224
|
+
"""The host of the issuer."""
|
|
225
|
+
return urlsplit(self._issuer).netloc
|
|
226
|
+
|
|
227
|
+
@property
|
|
228
|
+
def signer(self) -> Signer:
|
|
229
|
+
"""The transmitter's key as a :class:`~easyssf.core.signing.Signer`, RS256, for SETs built
|
|
230
|
+
with :func:`easyssf.core.new_set`."""
|
|
231
|
+
return self._key.signer("RS256")
|
|
232
|
+
|
|
233
|
+
# -- what the transmitter saw -------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
@property
|
|
236
|
+
def requests(self) -> int:
|
|
237
|
+
"""How many HTTP requests the transmitter received so far."""
|
|
238
|
+
return self._requests
|
|
239
|
+
|
|
240
|
+
@property
|
|
241
|
+
def token_requests(self) -> int:
|
|
242
|
+
"""How many token requests were answered with a token."""
|
|
243
|
+
return self._tokens
|
|
244
|
+
|
|
245
|
+
@property
|
|
246
|
+
def poll_requests(self) -> int:
|
|
247
|
+
"""How many poll requests were received."""
|
|
248
|
+
return self._polls
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def last_poll_request(self) -> Claims:
|
|
252
|
+
"""The body of the last poll request, empty if none was received."""
|
|
253
|
+
return dict(self._last_poll_request)
|
|
254
|
+
|
|
255
|
+
@property
|
|
256
|
+
def last_user_agent(self) -> str:
|
|
257
|
+
"""The ``User-Agent`` of the most recent request, ``""`` if none."""
|
|
258
|
+
return self._user_agent
|
|
259
|
+
|
|
260
|
+
@property
|
|
261
|
+
def streams(self) -> list[Claims]:
|
|
262
|
+
"""The streams registered at the transmitter, a snapshot."""
|
|
263
|
+
return [dict(stream) for stream in self._streams]
|
|
264
|
+
|
|
265
|
+
@property
|
|
266
|
+
def acknowledged_sets(self) -> list[str]:
|
|
267
|
+
"""The ``jti`` of every SET a polling receiver acknowledged, a snapshot."""
|
|
268
|
+
return list(self._acknowledged)
|
|
269
|
+
|
|
270
|
+
@property
|
|
271
|
+
def reported_errors(self) -> list[ReportedError]:
|
|
272
|
+
"""The errors a polling receiver reported, a snapshot."""
|
|
273
|
+
return list(self._reported_errors)
|
|
274
|
+
|
|
275
|
+
@property
|
|
276
|
+
def verification_requests(self) -> list[str]:
|
|
277
|
+
"""The states of the verification requests received, a snapshot; ``""`` for a request
|
|
278
|
+
without state."""
|
|
279
|
+
return list(self._verification_requests)
|
|
280
|
+
|
|
281
|
+
@property
|
|
282
|
+
def added_subjects(self) -> list[Claims | None]:
|
|
283
|
+
"""The subjects added to streams, a snapshot; ``None`` where a request had no subject."""
|
|
284
|
+
return list(self._added_subjects)
|
|
285
|
+
|
|
286
|
+
@property
|
|
287
|
+
def removed_subjects(self) -> list[Claims | None]:
|
|
288
|
+
"""The subjects removed from streams, a snapshot; ``None`` where a request had no subject."""
|
|
289
|
+
return list(self._removed_subjects)
|
|
290
|
+
|
|
291
|
+
@property
|
|
292
|
+
def queued_set_count(self) -> int:
|
|
293
|
+
"""The number of SETs queued and not yet acknowledged."""
|
|
294
|
+
return len(self._queued)
|
|
295
|
+
|
|
296
|
+
# -- knobs -----------------------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
def set_available(self, available: bool) -> None:
|
|
299
|
+
"""Makes the token, stream management and poll endpoints answer with 503 while ``False``.
|
|
300
|
+
Published documents (the JWK Set, the metadata) are served regardless."""
|
|
301
|
+
self._available = available
|
|
302
|
+
|
|
303
|
+
def set_stream_issuer(self, issuer: str) -> None:
|
|
304
|
+
"""Makes the transmitter create streams with the given issuer instead of its own; ``""``
|
|
305
|
+
restores its own."""
|
|
306
|
+
self._stream_issuer = issuer
|
|
307
|
+
|
|
308
|
+
def set_long_poll_hold(self, hold: timedelta) -> None:
|
|
309
|
+
"""How long a poll request without ``returnImmediately: true`` is held while no SET is
|
|
310
|
+
queued. Zero by default: the transmitter then answers at once, as if it did not support
|
|
311
|
+
long polling."""
|
|
312
|
+
self._long_poll_hold = hold
|
|
313
|
+
|
|
314
|
+
def expire_access_tokens(self) -> None:
|
|
315
|
+
"""Makes the access tokens issued so far invalid: requests with them are answered with
|
|
316
|
+
401 and the token endpoint issues another token."""
|
|
317
|
+
self._valid_access_token = ACCESS_TOKEN + "-" + new_jti()
|
|
318
|
+
|
|
319
|
+
def queue_set(self, encoded_set: str) -> None:
|
|
320
|
+
"""Makes the SET available at the poll endpoint, under its ``jti``."""
|
|
321
|
+
self.queue_set_with_jti(string_member(claims_of(encoded_set), "jti"), encoded_set)
|
|
322
|
+
|
|
323
|
+
def queue_set_with_jti(self, jti: str, encoded_set: str) -> None:
|
|
324
|
+
"""Makes the SET available at the poll endpoint under the given ``jti``."""
|
|
325
|
+
for queued in self._queued:
|
|
326
|
+
if queued.jti == jti:
|
|
327
|
+
queued.set = encoded_set
|
|
328
|
+
self._wake_polls()
|
|
329
|
+
return
|
|
330
|
+
self._queued.append(_QueuedSet(jti, encoded_set))
|
|
331
|
+
self._wake_polls()
|
|
332
|
+
|
|
333
|
+
def reset(self) -> None:
|
|
334
|
+
"""Forgets streams, queued SETs and what the receiver reported."""
|
|
335
|
+
self._streams, self._queued, self._acknowledged, self._reported_errors = [], [], [], []
|
|
336
|
+
self._verification_requests, self._added_subjects, self._removed_subjects = [], [], []
|
|
337
|
+
self._polls = 0
|
|
338
|
+
self._last_poll_request = {}
|
|
339
|
+
self._long_poll_hold = timedelta(0)
|
|
340
|
+
self._available = True
|
|
341
|
+
self._valid_access_token = ACCESS_TOKEN
|
|
342
|
+
self._stream_issuer = ""
|
|
343
|
+
self._wake_polls()
|
|
344
|
+
|
|
345
|
+
# -- documents and streams -------------------------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
def publish_document(self, path: str, body: str) -> None:
|
|
348
|
+
"""Serves ``body`` (JSON) at the given path of the issuer's host."""
|
|
349
|
+
self._documents[path] = body
|
|
350
|
+
|
|
351
|
+
def remove_document(self, path: str) -> None:
|
|
352
|
+
"""Stops serving the document at the path, so that requests for it are answered with 404."""
|
|
353
|
+
self._documents.pop(path, None)
|
|
354
|
+
|
|
355
|
+
def publish_jwks(self, *keys: SigningKey) -> None:
|
|
356
|
+
"""Serves the public keys as the JWK Set, replacing the one before."""
|
|
357
|
+
self.publish_document(_REALM + _JWKS_PATH, _dumps({"keys": [key.public_jwk() for key in keys]}))
|
|
358
|
+
|
|
359
|
+
def publish_metadata(self, location: MetadataLocation) -> None:
|
|
360
|
+
"""Serves the transmitter metadata at the given location, removing it from the other."""
|
|
361
|
+
self.remove_document(_WELL_KNOWN_PATH + _REALM)
|
|
362
|
+
self.remove_document(_REALM + _WELL_KNOWN_PATH)
|
|
363
|
+
metadata = _dumps(
|
|
364
|
+
{
|
|
365
|
+
"issuer": self._issuer,
|
|
366
|
+
"jwks_uri": self.jwks_url,
|
|
367
|
+
"spec_version": SPEC_VERSION_1_0,
|
|
368
|
+
"delivery_methods_supported": [PUSH_URI, POLL_URI],
|
|
369
|
+
"configuration_endpoint": self._issuer + "/ssf/streams",
|
|
370
|
+
"status_endpoint": self._issuer + "/ssf/status",
|
|
371
|
+
"add_subject_endpoint": self._issuer + "/ssf/subjects:add",
|
|
372
|
+
"remove_subject_endpoint": self._issuer + "/ssf/subjects:remove",
|
|
373
|
+
"verification_endpoint": self._issuer + "/ssf/verify",
|
|
374
|
+
}
|
|
375
|
+
)
|
|
376
|
+
if location is MetadataLocation.SSF:
|
|
377
|
+
self.publish_document(_WELL_KNOWN_PATH + _REALM, metadata)
|
|
378
|
+
elif location is MetadataLocation.OIDC_STYLE:
|
|
379
|
+
self.publish_document(_REALM + _WELL_KNOWN_PATH, metadata)
|
|
380
|
+
|
|
381
|
+
def add_stream(self, request: Claims) -> Claims:
|
|
382
|
+
"""Registers a stream the way the transmitter does for a create request: assigns the
|
|
383
|
+
identifier, the audience and, for POLL delivery, the poll endpoint. Returns the stream as
|
|
384
|
+
registered."""
|
|
385
|
+
stream = dict(request)
|
|
386
|
+
if not string_member(stream, "stream_id"):
|
|
387
|
+
stream["stream_id"] = new_jti()
|
|
388
|
+
stream_id = stream["stream_id"]
|
|
389
|
+
stream["iss"] = self._stream_issuer or self._issuer
|
|
390
|
+
stream["aud"] = [CLIENT_ID + "/" + stream_id]
|
|
391
|
+
requested = stream.get("events_requested")
|
|
392
|
+
stream["events_delivered"] = list(requested) if isinstance(requested, list) else []
|
|
393
|
+
delivery = dict(object_member(stream, "delivery") or {})
|
|
394
|
+
delivery.pop("authorization_header", None)
|
|
395
|
+
if string_member(delivery, "method") == POLL_URI:
|
|
396
|
+
delivery["endpoint_url"] = self.poll_url
|
|
397
|
+
stream["delivery"] = delivery
|
|
398
|
+
self._streams.append(stream)
|
|
399
|
+
return dict(stream)
|
|
400
|
+
|
|
401
|
+
# -- SETs and tokens --------------------------------------------------------------------------------
|
|
402
|
+
|
|
403
|
+
def set_claims(self, event_type: str, subject_id: Claims | None) -> Claims:
|
|
404
|
+
"""The claims of a valid SET with a single event whose payload has an ``event_timestamp``.
|
|
405
|
+
``event_type`` is an alias such as ``CaepSessionRevoked`` or an event type URI,
|
|
406
|
+
``subject_id`` the ``sub_id`` claim, left out if ``None``."""
|
|
407
|
+
return self.set_claims_with_payload(event_type, subject_id, {"event_timestamp": int(time.time())})
|
|
408
|
+
|
|
409
|
+
def set_claims_with_payload(self, event_type: str, subject_id: Claims | None, payload: Claims | None) -> Claims:
|
|
410
|
+
"""The claims of a valid SET with a single event and the given payload."""
|
|
411
|
+
claims: Claims = {
|
|
412
|
+
"iss": self._issuer,
|
|
413
|
+
"jti": new_jti(),
|
|
414
|
+
"iat": int(time.time()),
|
|
415
|
+
"aud": AUDIENCE,
|
|
416
|
+
"events": {event_types.resolve(event_type): {} if payload is None else payload},
|
|
417
|
+
}
|
|
418
|
+
if subject_id is not None:
|
|
419
|
+
claims["sub_id"] = subject_id
|
|
420
|
+
return claims
|
|
421
|
+
|
|
422
|
+
def set(self, event_type: str, subject_id: Claims | None) -> str:
|
|
423
|
+
"""A valid, signed SET with a single event."""
|
|
424
|
+
return self.sign_set(self.set_claims(event_type, subject_id))
|
|
425
|
+
|
|
426
|
+
def sign_set(self, claims: Claims) -> str:
|
|
427
|
+
"""Signs the claims as a SET: typed ``secevent+jwt``, with the transmitter's key."""
|
|
428
|
+
return sign(self._key, SECEVENT_JWT_TYPE, claims)
|
|
429
|
+
|
|
430
|
+
def access_token_for(self, subject: str, session_id: str, issued_at: datetime) -> str:
|
|
431
|
+
"""An access token of the identity provider, as an OAuth client would present it: ``iss``,
|
|
432
|
+
``sub``, ``sid``, ``iat`` and a five minute ``exp``."""
|
|
433
|
+
claims: Claims = {
|
|
434
|
+
"iss": self._issuer,
|
|
435
|
+
"sub": subject,
|
|
436
|
+
"sid": session_id,
|
|
437
|
+
"iat": int(issued_at.timestamp()),
|
|
438
|
+
"exp": int(time.time()) + 300,
|
|
439
|
+
}
|
|
440
|
+
return sign(self._key, JWT_TYPE, claims)
|
|
441
|
+
|
|
442
|
+
# -- the HTTP handler --------------------------------------------------------------------------------
|
|
443
|
+
|
|
444
|
+
async def handle(self, request: httpx.Request) -> httpx.Response:
|
|
445
|
+
"""Answers a request: the documents, the token endpoint, the stream management endpoints
|
|
446
|
+
and the poll endpoint; the ``ServeHTTP`` of the Go port. A held long poll returns when a
|
|
447
|
+
SET is queued, the hold elapses or the awaiting task is cancelled."""
|
|
448
|
+
outcome = self._respond(request, await request.aread())
|
|
449
|
+
if isinstance(outcome, httpx.Response):
|
|
450
|
+
return outcome
|
|
451
|
+
await self._wait_for_set(outcome.hold)
|
|
452
|
+
return self._poll_response(outcome.max_events)
|
|
453
|
+
|
|
454
|
+
def handle_blocking(self, request: httpx.Request) -> httpx.Response:
|
|
455
|
+
""":meth:`handle` for a blocking client: a held long poll sleeps on the calling thread."""
|
|
456
|
+
outcome = self._respond(request, request.read())
|
|
457
|
+
if isinstance(outcome, httpx.Response):
|
|
458
|
+
return outcome
|
|
459
|
+
deadline = time.monotonic() + outcome.hold.total_seconds()
|
|
460
|
+
while not self._queued and time.monotonic() < deadline:
|
|
461
|
+
time.sleep(_BLOCKING_POLL_INTERVAL)
|
|
462
|
+
return self._poll_response(outcome.max_events)
|
|
463
|
+
|
|
464
|
+
async def asgi(self, scope: _Scope, receive: _Receive, send: _Send) -> None:
|
|
465
|
+
"""An ASGI 3 application serving :meth:`handle`, to mount on uvicorn when a real port is
|
|
466
|
+
wanted: ``uvicorn.Server(uvicorn.Config(transmitter.asgi, port=0))``."""
|
|
467
|
+
if scope["type"] == "lifespan":
|
|
468
|
+
while True:
|
|
469
|
+
message = await receive()
|
|
470
|
+
await send({"type": message["type"] + ".complete"})
|
|
471
|
+
if message["type"] == "lifespan.shutdown":
|
|
472
|
+
return
|
|
473
|
+
if scope["type"] != "http":
|
|
474
|
+
raise RuntimeError(f"unsupported ASGI scope {scope['type']}")
|
|
475
|
+
body = b""
|
|
476
|
+
while True:
|
|
477
|
+
message = await receive()
|
|
478
|
+
body += message.get("body", b"")
|
|
479
|
+
if not message.get("more_body", False):
|
|
480
|
+
break
|
|
481
|
+
headers = [(name.decode("latin-1"), value.decode("latin-1")) for name, value in scope.get("headers", [])]
|
|
482
|
+
host = next((value for name, value in headers if name.lower() == "host"), None)
|
|
483
|
+
if host is None:
|
|
484
|
+
server = scope.get("server") or ("localhost", None)
|
|
485
|
+
host = server[0] if server[1] is None else f"{server[0]}:{server[1]}"
|
|
486
|
+
url = f"{scope.get('scheme', 'http')}://{host}{scope['path']}"
|
|
487
|
+
if scope.get("query_string"):
|
|
488
|
+
url += "?" + scope["query_string"].decode("latin-1")
|
|
489
|
+
response = await self.handle(httpx.Request(scope["method"], url, headers=headers, content=body))
|
|
490
|
+
await send(
|
|
491
|
+
{
|
|
492
|
+
"type": "http.response.start",
|
|
493
|
+
"status": response.status_code,
|
|
494
|
+
"headers": [(k.encode("latin-1"), v.encode("latin-1")) for k, v in response.headers.multi_items()],
|
|
495
|
+
}
|
|
496
|
+
)
|
|
497
|
+
await send({"type": "http.response.body", "body": response.content})
|
|
498
|
+
|
|
499
|
+
def _respond(self, request: httpx.Request, body: bytes) -> httpx.Response | _PendingPoll:
|
|
500
|
+
self._requests += 1
|
|
501
|
+
self._user_agent = request.headers.get("User-Agent", "")
|
|
502
|
+
path = request.url.path
|
|
503
|
+
document = self._documents.get(path)
|
|
504
|
+
if document is not None:
|
|
505
|
+
return _respond(200, document)
|
|
506
|
+
if not path.startswith(_REALM + "/"):
|
|
507
|
+
return _respond(404)
|
|
508
|
+
endpoint = path[len(_REALM) + 1 :]
|
|
509
|
+
if not self._available:
|
|
510
|
+
return _respond(503)
|
|
511
|
+
# the token endpoint of Keycloak, and /token as the emulated authorization server of the
|
|
512
|
+
# conformance suite has it next to the transmitter
|
|
513
|
+
if endpoint in (_TOKEN_PATH[1:], "token"):
|
|
514
|
+
return self._handle_token(request, body)
|
|
515
|
+
if request.headers.get("Authorization") != "Bearer " + self._valid_access_token:
|
|
516
|
+
return _respond(401)
|
|
517
|
+
claims: Claims = {}
|
|
518
|
+
if body.strip():
|
|
519
|
+
try:
|
|
520
|
+
claims = parse_claims(body)
|
|
521
|
+
except ValueError:
|
|
522
|
+
claims = {}
|
|
523
|
+
stream_id = request.url.params.get("stream_id", "") or string_member(claims, "stream_id")
|
|
524
|
+
if endpoint == "ssf/streams":
|
|
525
|
+
return self._handle_streams(request.method, stream_id, claims)
|
|
526
|
+
if endpoint == "ssf/status":
|
|
527
|
+
status = string_member(claims, "status") or StreamStatusValue.ENABLED
|
|
528
|
+
code = 200 if stream_id and self._find_stream(stream_id) is not None else 404
|
|
529
|
+
return _respond_json(code, {"stream_id": stream_id, "status": status})
|
|
530
|
+
if endpoint == "ssf/verify":
|
|
531
|
+
self._verification_requests.append(_string_of(claims.get("state")))
|
|
532
|
+
return _respond(204)
|
|
533
|
+
if endpoint == _POLL_PATH[1:]:
|
|
534
|
+
return self._poll(claims)
|
|
535
|
+
if endpoint == "ssf/subjects:add":
|
|
536
|
+
self._added_subjects.append(object_member(claims, "subject"))
|
|
537
|
+
return _respond(200)
|
|
538
|
+
if endpoint == "ssf/subjects:remove":
|
|
539
|
+
self._removed_subjects.append(object_member(claims, "subject"))
|
|
540
|
+
return _respond(204)
|
|
541
|
+
return _respond(404)
|
|
542
|
+
|
|
543
|
+
def _handle_token(self, request: httpx.Request, body: bytes) -> httpx.Response:
|
|
544
|
+
form = {
|
|
545
|
+
name: values[0] for name, values in parse_qs(body.decode(errors="replace"), keep_blank_values=True).items()
|
|
546
|
+
}
|
|
547
|
+
basic = _basic_auth(request.headers.get("Authorization", ""))
|
|
548
|
+
authenticated = basic == (CLIENT_ID, CLIENT_SECRET) or (
|
|
549
|
+
form.get("client_id") == CLIENT_ID and form.get("client_secret") == CLIENT_SECRET
|
|
550
|
+
)
|
|
551
|
+
if not authenticated or form.get("grant_type") != "client_credentials":
|
|
552
|
+
return _respond_json(401, {"error": "invalid_client"})
|
|
553
|
+
self._tokens += 1
|
|
554
|
+
return _respond_json(200, {"access_token": self._valid_access_token, "token_type": "Bearer", "expires_in": 300})
|
|
555
|
+
|
|
556
|
+
def _handle_streams(self, method: str, stream_id: str, request: Claims) -> httpx.Response:
|
|
557
|
+
if method == "GET":
|
|
558
|
+
if not stream_id:
|
|
559
|
+
return _respond(200, _dumps(self.streams))
|
|
560
|
+
stream = self._find_stream(stream_id)
|
|
561
|
+
return _respond(404) if stream is None else _respond_json(200, stream)
|
|
562
|
+
if method == "POST":
|
|
563
|
+
if self._streams:
|
|
564
|
+
return _respond_json(
|
|
565
|
+
409, {"error": "stream_error", "error_description": "Only one stream per receiver is allowed"}
|
|
566
|
+
)
|
|
567
|
+
return _respond_json(201, self.add_stream(request))
|
|
568
|
+
if method in ("PATCH", "PUT"):
|
|
569
|
+
updated = self._find_stream(stream_id)
|
|
570
|
+
if updated is None:
|
|
571
|
+
return _respond(404)
|
|
572
|
+
updated.update(request)
|
|
573
|
+
self._remove_stream(stream_id)
|
|
574
|
+
return _respond_json(200, self.add_stream(updated))
|
|
575
|
+
if method == "DELETE":
|
|
576
|
+
if self._find_stream(stream_id) is None:
|
|
577
|
+
return _respond(404)
|
|
578
|
+
self._remove_stream(stream_id)
|
|
579
|
+
return _respond(204)
|
|
580
|
+
return _respond(405)
|
|
581
|
+
|
|
582
|
+
def _poll(self, request: Claims) -> httpx.Response | _PendingPoll:
|
|
583
|
+
self._polls += 1
|
|
584
|
+
max_events = as_int(request.get("maxEvents"))
|
|
585
|
+
if max_events is None:
|
|
586
|
+
max_events = _DEFAULT_MAX_EVENTS
|
|
587
|
+
return_immediately = request.get("returnImmediately") is True
|
|
588
|
+
self._last_poll_request = dict(request)
|
|
589
|
+
for jti in strings(request.get("ack")):
|
|
590
|
+
if self._remove_queued(jti):
|
|
591
|
+
self._acknowledged.append(jti)
|
|
592
|
+
for jti, reported in (object_member(request, "setErrs") or {}).items():
|
|
593
|
+
self._remove_queued(jti)
|
|
594
|
+
report = reported if isinstance(reported, dict) else None
|
|
595
|
+
self._reported_errors.append(
|
|
596
|
+
ReportedError(jti, string_member(report, "err"), string_member(report, "description"))
|
|
597
|
+
)
|
|
598
|
+
# a long poll (returnImmediately false or absent, RFC 8936 section 2.5) is held until a SET
|
|
599
|
+
# is queued, the hold time elapses or the receiver gives up
|
|
600
|
+
if not return_immediately and max_events > 0 and self._long_poll_hold > timedelta(0) and not self._queued:
|
|
601
|
+
return _PendingPoll(self._long_poll_hold, max_events)
|
|
602
|
+
return self._poll_response(max_events)
|
|
603
|
+
|
|
604
|
+
def _poll_response(self, max_events: int) -> httpx.Response:
|
|
605
|
+
sets = {queued.jti: queued.set for queued in self._queued[: max(max_events, 0)]}
|
|
606
|
+
return _respond_json(200, {"sets": sets, "moreAvailable": len(self._queued) > len(sets)})
|
|
607
|
+
|
|
608
|
+
async def _wait_for_set(self, hold: timedelta) -> None:
|
|
609
|
+
"""Waits until a SET is queued or the hold elapses; a cancellation of the awaiting task
|
|
610
|
+
stops the wait, the ``ctx.Done()`` of the Go port."""
|
|
611
|
+
loop = asyncio.get_running_loop()
|
|
612
|
+
deadline = loop.time() + hold.total_seconds()
|
|
613
|
+
while not self._queued:
|
|
614
|
+
remaining = deadline - loop.time()
|
|
615
|
+
if remaining <= 0:
|
|
616
|
+
return
|
|
617
|
+
waiter: asyncio.Future[None] = loop.create_future()
|
|
618
|
+
self._waiters.append(waiter)
|
|
619
|
+
try:
|
|
620
|
+
async with asyncio.timeout(remaining):
|
|
621
|
+
await waiter
|
|
622
|
+
except TimeoutError:
|
|
623
|
+
return
|
|
624
|
+
finally:
|
|
625
|
+
if waiter in self._waiters:
|
|
626
|
+
self._waiters.remove(waiter)
|
|
627
|
+
|
|
628
|
+
def _wake_polls(self) -> None:
|
|
629
|
+
"""Wakes up held poll requests, from any thread."""
|
|
630
|
+
waiters, self._waiters = self._waiters, []
|
|
631
|
+
for waiter in waiters:
|
|
632
|
+
waiter.get_loop().call_soon_threadsafe(_resolve, waiter)
|
|
633
|
+
|
|
634
|
+
def _remove_queued(self, jti: str) -> bool:
|
|
635
|
+
for index, queued in enumerate(self._queued):
|
|
636
|
+
if queued.jti == jti:
|
|
637
|
+
del self._queued[index]
|
|
638
|
+
return True
|
|
639
|
+
return False
|
|
640
|
+
|
|
641
|
+
def _find_stream(self, stream_id: str) -> Claims | None:
|
|
642
|
+
for stream in self._streams:
|
|
643
|
+
if string_member(stream, "stream_id") == stream_id:
|
|
644
|
+
return dict(stream)
|
|
645
|
+
return None
|
|
646
|
+
|
|
647
|
+
def _remove_stream(self, stream_id: str) -> None:
|
|
648
|
+
self._streams = [stream for stream in self._streams if string_member(stream, "stream_id") != stream_id]
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
class InProcessTransport(httpx.MockTransport):
|
|
652
|
+
"""An ``httpx`` transport that answers requests for the hosts of the given transmitters
|
|
653
|
+
in-process, each from its handler, and refuses every other host with an
|
|
654
|
+
``httpx.ConnectError``, so a test never reaches the network. Serves an ``httpx.AsyncClient``
|
|
655
|
+
through :meth:`TestTransmitter.handle` and an ``httpx.Client`` through
|
|
656
|
+
:meth:`TestTransmitter.handle_blocking`."""
|
|
657
|
+
|
|
658
|
+
def __init__(self, transmitters: Iterable[TestTransmitter]) -> None:
|
|
659
|
+
self._transmitters = tuple(transmitters)
|
|
660
|
+
super().__init__(self._handle_blocking)
|
|
661
|
+
|
|
662
|
+
@property
|
|
663
|
+
def transmitters(self) -> tuple[TestTransmitter, ...]:
|
|
664
|
+
"""The transmitters the transport routes to."""
|
|
665
|
+
return self._transmitters
|
|
666
|
+
|
|
667
|
+
def _route(self, request: httpx.Request) -> TestTransmitter:
|
|
668
|
+
origin = f"{request.url.scheme}://{request.url.netloc.decode('ascii')}"
|
|
669
|
+
for transmitter in self._transmitters:
|
|
670
|
+
if transmitter.issuer.startswith(origin + "/"):
|
|
671
|
+
return transmitter
|
|
672
|
+
issuers = [transmitter.issuer for transmitter in self._transmitters]
|
|
673
|
+
raise httpx.ConnectError(f"no route to {origin}, the test transmitters are {issuers}", request=request)
|
|
674
|
+
|
|
675
|
+
def _handle_blocking(self, request: httpx.Request) -> httpx.Response:
|
|
676
|
+
return self._route(request).handle_blocking(request)
|
|
677
|
+
|
|
678
|
+
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
|
679
|
+
"""Answers the request from the transmitter of its host, without a socket."""
|
|
680
|
+
await request.aread()
|
|
681
|
+
return await self._route(request).handle(request)
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def client_for(*transmitters: TestTransmitter) -> httpx.AsyncClient:
|
|
685
|
+
"""An ``httpx.AsyncClient`` that answers requests for the hosts of the given transmitters
|
|
686
|
+
in-process, each from its handler, and refuses every other host, so a test never reaches the
|
|
687
|
+
network. :attr:`TestTransmitter.client` is one for a single transmitter; a receiver with
|
|
688
|
+
several transmitters gets one for all of them."""
|
|
689
|
+
return httpx.AsyncClient(transport=InProcessTransport(transmitters))
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _resolve(waiter: asyncio.Future[None]) -> None:
|
|
693
|
+
if not waiter.done():
|
|
694
|
+
waiter.set_result(None)
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def _basic_auth(header: str) -> tuple[str, str] | None:
|
|
698
|
+
scheme, _, credentials = header.partition(" ")
|
|
699
|
+
if scheme.lower() != "basic":
|
|
700
|
+
return None
|
|
701
|
+
try:
|
|
702
|
+
decoded = base64.b64decode(credentials.strip(), validate=True).decode()
|
|
703
|
+
except (binascii.Error, ValueError):
|
|
704
|
+
return None
|
|
705
|
+
user, separator, password = decoded.partition(":")
|
|
706
|
+
return (user, password) if separator else None
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def _respond(status: int, body: str = "") -> httpx.Response:
|
|
710
|
+
return httpx.Response(status, headers=_JSON_HEADERS, content=body.encode())
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def _respond_json(status: int, body: Any) -> httpx.Response:
|
|
714
|
+
return _respond(status, _dumps(body))
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def _dumps(value: Any) -> str:
|
|
718
|
+
return json.dumps(value, separators=(",", ":"))
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def _string_of(value: Any) -> str:
|
|
722
|
+
if value is None:
|
|
723
|
+
return ""
|
|
724
|
+
if isinstance(value, str):
|
|
725
|
+
return value
|
|
726
|
+
return _dumps(value)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Waiting for a condition in asyncio tests, the ``Eventually`` of the Java and .NET tests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Awaitable, Callable
|
|
9
|
+
from datetime import timedelta
|
|
10
|
+
|
|
11
|
+
__all__ = ["during", "eventually"]
|
|
12
|
+
|
|
13
|
+
_POLL_INTERVAL = 0.01
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def _holds(condition: Callable[[], bool | Awaitable[bool]]) -> bool:
|
|
17
|
+
result = condition()
|
|
18
|
+
if inspect.isawaitable(result):
|
|
19
|
+
return bool(await result)
|
|
20
|
+
return bool(result)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def eventually(
|
|
24
|
+
condition: Callable[[], bool | Awaitable[bool]],
|
|
25
|
+
timeout: float | timedelta = 5.0,
|
|
26
|
+
what: str = "the condition",
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Waits until ``condition`` holds, polling it every 10 milliseconds for at most ``timeout``
|
|
29
|
+
seconds. Fails with ``what`` if it does not hold in time.
|
|
30
|
+
|
|
31
|
+
Raises:
|
|
32
|
+
AssertionError: when the timeout passes.
|
|
33
|
+
"""
|
|
34
|
+
seconds = timeout.total_seconds() if isinstance(timeout, timedelta) else timeout
|
|
35
|
+
deadline = time.monotonic() + seconds
|
|
36
|
+
while not await _holds(condition):
|
|
37
|
+
if time.monotonic() > deadline:
|
|
38
|
+
raise AssertionError(f"Timed out waiting for: {what}")
|
|
39
|
+
await asyncio.sleep(_POLL_INTERVAL)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def during(
|
|
43
|
+
condition: Callable[[], bool | Awaitable[bool]],
|
|
44
|
+
duration: float | timedelta = 0.2,
|
|
45
|
+
what: str = "the condition",
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Checks that ``condition`` keeps holding for ``duration`` seconds, polling it every 10
|
|
48
|
+
milliseconds. Fails with ``what`` otherwise.
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
AssertionError: when the condition stops holding.
|
|
52
|
+
"""
|
|
53
|
+
seconds = duration.total_seconds() if isinstance(duration, timedelta) else duration
|
|
54
|
+
deadline = time.monotonic() + seconds
|
|
55
|
+
while time.monotonic() < deadline:
|
|
56
|
+
if not await _holds(condition):
|
|
57
|
+
raise AssertionError(f"Stopped holding: {what}")
|
|
58
|
+
await asyncio.sleep(_POLL_INTERVAL)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: easyssf-test
|
|
3
|
+
Version: 0.7.0
|
|
4
|
+
Summary: A test transmitter for SSF receivers: signs SETs, serves metadata and a JWK Set, issues tokens, emulates stream management and the poll endpoint, in-process through an httpx transport
|
|
5
|
+
Project-URL: Homepage, https://easyssf.org
|
|
6
|
+
Project-URL: Repository, https://github.com/easyssf/easyssf-python
|
|
7
|
+
Project-URL: Changelog, https://github.com/easyssf/easyssf-python/blob/main/CHANGELOG.md
|
|
8
|
+
Author: the easyssf authors
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
Keywords: caep,openid,risc,security-events,ssf,testing
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Topic :: Security
|
|
16
|
+
Classifier: Topic :: Software Development :: Testing
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Requires-Dist: easyssf-core[signing]<0.8,>=0.7
|
|
20
|
+
Requires-Dist: httpx<1,>=0.28
|
|
21
|
+
Requires-Dist: joserfc<2,>=1.7
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# easyssf-test
|
|
25
|
+
|
|
26
|
+
Test support for SSF receivers built with easyssf: `TestTransmitter` signs SETs, serves its metadata
|
|
27
|
+
and JWK Set, issues access tokens and emulates stream management and the poll endpoint, in-process
|
|
28
|
+
through the `httpx.AsyncClient` it hands out, so a test never opens a socket. Signing helpers make
|
|
29
|
+
SETs a receiver must reject, `eventually` and `during` wait for asynchronous outcomes, `ManualClock`
|
|
30
|
+
moves time by hand.
|
|
31
|
+
|
|
32
|
+
See the [README of easyssf-python](https://github.com/easyssf/easyssf-python#readme).
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
easyssf/testing/__init__.py,sha256=YZpioCpuhgbOIauzOLBGfSHz2dgKbOgkt8U48HfxpkI,1009
|
|
2
|
+
easyssf/testing/clock.py,sha256=SHJuqbaiHSNlh0dPIp7UPSjzsusSmwAaEn70ZyjbGMY,1132
|
|
3
|
+
easyssf/testing/keys.py,sha256=lBtnPSSpU3hds7GgkxKsCsXT3mnzGD7BsRonQ2hTkI4,4686
|
|
4
|
+
easyssf/testing/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
easyssf/testing/transmitter.py,sha256=_vcDpkDFIEdgbRCVzohxkHstiekL0kEA4tXwUqYJDRc,31208
|
|
6
|
+
easyssf/testing/waiting.py,sha256=25OSGfIhCJuw--uXAFOT725tVhjaTO6-oIp_BuN-nhc,1904
|
|
7
|
+
easyssf_test-0.7.0.dist-info/METADATA,sha256=vpkuEWKB_hpbZ41I3z-PEwuh3L3DoSwZUqGeqSJ-3mU,1560
|
|
8
|
+
easyssf_test-0.7.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
9
|
+
easyssf_test-0.7.0.dist-info/RECORD,,
|