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.
- easyssf_test-0.7.0/.gitignore +22 -0
- easyssf_test-0.7.0/PKG-INFO +32 -0
- easyssf_test-0.7.0/README.md +9 -0
- easyssf_test-0.7.0/pyproject.toml +41 -0
- easyssf_test-0.7.0/src/easyssf/testing/__init__.py +43 -0
- easyssf_test-0.7.0/src/easyssf/testing/clock.py +38 -0
- easyssf_test-0.7.0/src/easyssf/testing/keys.py +131 -0
- easyssf_test-0.7.0/src/easyssf/testing/py.typed +0 -0
- easyssf_test-0.7.0/src/easyssf/testing/transmitter.py +726 -0
- easyssf_test-0.7.0/src/easyssf/testing/waiting.py +58 -0
- easyssf_test-0.7.0/tests/test_keys.py +86 -0
- easyssf_test-0.7.0/tests/test_transmitter.py +552 -0
|
@@ -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
|