easyssf-test 0.7.0__tar.gz

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,22 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .venv/
6
+ dist/
7
+ build/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ .coverage
12
+ coverage.xml
13
+ htmlcov/
14
+
15
+ # IDE
16
+ .idea/
17
+ .vscode/
18
+
19
+ # Local
20
+ scratch/
21
+ .DS_Store
22
+ conformance/certs/
@@ -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-test
2
+
3
+ Test support for SSF receivers built with easyssf: `TestTransmitter` signs SETs, serves its metadata
4
+ and JWK Set, issues access tokens and emulates stream management and the poll endpoint, in-process
5
+ through the `httpx.AsyncClient` it hands out, so a test never opens a socket. Signing helpers make
6
+ SETs a receiver must reject, `eventually` and `during` wait for asynchronous outcomes, `ManualClock`
7
+ moves time by hand.
8
+
9
+ See the [README of easyssf-python](https://github.com/easyssf/easyssf-python#readme).
@@ -0,0 +1,41 @@
1
+ [project]
2
+ name = "easyssf-test"
3
+ version = "0.7.0"
4
+ description = "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
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = "Apache-2.0"
8
+ authors = [{ name = "the easyssf authors" }]
9
+ keywords = ["ssf", "caep", "risc", "openid", "security-events", "testing"]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Intended Audience :: Developers",
13
+ "Programming Language :: Python :: 3",
14
+ "Programming Language :: Python :: 3 :: Only",
15
+ "Topic :: Security",
16
+ "Topic :: Software Development :: Testing",
17
+ "Typing :: Typed",
18
+ ]
19
+ dependencies = [
20
+ "easyssf-core[signing]>=0.7,<0.8",
21
+ "joserfc>=1.7,<2",
22
+ "httpx>=0.28,<1",
23
+ ]
24
+
25
+ [project.urls]
26
+ Homepage = "https://easyssf.org"
27
+ Repository = "https://github.com/easyssf/easyssf-python"
28
+ Changelog = "https://github.com/easyssf/easyssf-python/blob/main/CHANGELOG.md"
29
+
30
+ [build-system]
31
+ requires = ["hatchling>=1.27"]
32
+ build-backend = "hatchling.build"
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ packages = ["src/easyssf"]
36
+
37
+ [tool.hatch.build.targets.sdist]
38
+ include = ["src", "README.md", "tests"]
39
+
40
+ [tool.uv.sources]
41
+ easyssf-core = { workspace = true }
@@ -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