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.
@@ -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
+ ]
@@ -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
@@ -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))
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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any