easyssf-core 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-core
3
+ Version: 0.7.0
4
+ Summary: The vocabulary of the OpenID Shared Signals Framework: SETs, subjects, event types, the typed CAEP, RISC and SCIM events, stream configuration and transmitter metadata
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,set,ssf
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: Typing :: Typed
17
+ Requires-Python: >=3.11
18
+ Provides-Extra: signing
19
+ Requires-Dist: joserfc<2,>=1.7; extra == 'signing'
20
+ Description-Content-Type: text/markdown
21
+
22
+ # easyssf-core
23
+
24
+ The vocabulary of the [OpenID Shared Signals Framework](https://openid.net/specs/openid-sharedsignals-framework-1_0.html)
25
+ for Python: Security Event Tokens (`EventToken`, the `new_set` builder), subjects (RFC 9493, complex
26
+ subjects, the `scim` format), delivery methods, stream configuration, transmitter metadata, the
27
+ event types of SSF, CAEP, RISC and SCIM Events (RFC 9967) with their aliases, and the typed CAEP,
28
+ RISC and SCIM events. No dependencies; `easyssf-core[signing]` adds `easyssf.core.signing`, which
29
+ signs SETs with joserfc.
30
+
31
+ Part of [easyssf-python](https://github.com/easyssf/easyssf-python); the receiver is
32
+ `easyssf-receiver`.
@@ -0,0 +1,11 @@
1
+ # easyssf-core
2
+
3
+ The vocabulary of the [OpenID Shared Signals Framework](https://openid.net/specs/openid-sharedsignals-framework-1_0.html)
4
+ for Python: Security Event Tokens (`EventToken`, the `new_set` builder), subjects (RFC 9493, complex
5
+ subjects, the `scim` format), delivery methods, stream configuration, transmitter metadata, the
6
+ event types of SSF, CAEP, RISC and SCIM Events (RFC 9967) with their aliases, and the typed CAEP,
7
+ RISC and SCIM events. No dependencies; `easyssf-core[signing]` adds `easyssf.core.signing`, which
8
+ signs SETs with joserfc.
9
+
10
+ Part of [easyssf-python](https://github.com/easyssf/easyssf-python); the receiver is
11
+ `easyssf-receiver`.
@@ -0,0 +1,36 @@
1
+ [project]
2
+ name = "easyssf-core"
3
+ version = "0.7.0"
4
+ description = "The vocabulary of the OpenID Shared Signals Framework: SETs, subjects, event types, the typed CAEP, RISC and SCIM events, stream configuration and transmitter metadata"
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", "set"]
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
+ "Typing :: Typed",
17
+ ]
18
+ dependencies = []
19
+
20
+ [project.optional-dependencies]
21
+ signing = ["joserfc>=1.7,<2"]
22
+
23
+ [project.urls]
24
+ Homepage = "https://easyssf.org"
25
+ Repository = "https://github.com/easyssf/easyssf-python"
26
+ Changelog = "https://github.com/easyssf/easyssf-python/blob/main/CHANGELOG.md"
27
+
28
+ [build-system]
29
+ requires = ["hatchling>=1.27"]
30
+ build-backend = "hatchling.build"
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ packages = ["src/easyssf"]
34
+
35
+ [tool.hatch.build.targets.sdist]
36
+ include = ["src", "README.md", "tests"]
@@ -0,0 +1,57 @@
1
+ """The vocabulary of the OpenID Shared Signals Framework: SETs, subjects, delivery methods, stream
2
+ configuration, transmitter metadata, event types with aliases, and the typed CAEP, RISC and SCIM
3
+ events. The port of ``easyssf-core`` of the Java library."""
4
+
5
+ from . import caep, event_types, risc, scim, subjects
6
+ from .claims import (
7
+ Claims,
8
+ as_int,
9
+ audiences,
10
+ event_timestamp,
11
+ object_member,
12
+ parse_claims,
13
+ string_member,
14
+ strings,
15
+ )
16
+ from .delivery import POLL_URI, PUSH_URI, DeliveryMethod
17
+ from .event_types import EventTypes
18
+ from .metadata import SPEC_VERSION_1_0, TransmitterMetadata
19
+ from .stream import StreamConfiguration, StreamStatus, StreamStatusValue
20
+ from .subject import ALL_SESSIONS, MEMBERS, Format, Member, Subject, SubjectIdentifier
21
+ from .token import EventToken, SetBuilder, SubjectCompatibilityMode, new_jti, new_set
22
+
23
+ __all__ = [
24
+ "ALL_SESSIONS",
25
+ "MEMBERS",
26
+ "POLL_URI",
27
+ "PUSH_URI",
28
+ "SPEC_VERSION_1_0",
29
+ "Claims",
30
+ "DeliveryMethod",
31
+ "EventToken",
32
+ "EventTypes",
33
+ "Format",
34
+ "Member",
35
+ "SetBuilder",
36
+ "StreamConfiguration",
37
+ "StreamStatus",
38
+ "StreamStatusValue",
39
+ "Subject",
40
+ "SubjectCompatibilityMode",
41
+ "SubjectIdentifier",
42
+ "TransmitterMetadata",
43
+ "as_int",
44
+ "audiences",
45
+ "caep",
46
+ "event_timestamp",
47
+ "event_types",
48
+ "new_jti",
49
+ "new_set",
50
+ "object_member",
51
+ "parse_claims",
52
+ "risc",
53
+ "scim",
54
+ "string_member",
55
+ "strings",
56
+ "subjects",
57
+ ]
@@ -0,0 +1,369 @@
1
+ """The CAEP 1.0 events: the kinds of section 3 and typed access to their claims, the common ones
2
+ of section 2 (``event_timestamp``, ``initiating_entity``, ``reason_admin``, ``reason_user``) and
3
+ the event-specific ones (``credential_type``, ``change_type``, ``current_level``, ...). The port
4
+ of ``ssf/caep`` of the Go port and of ``org.easyssf.core.caep``."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import copy
9
+ import json
10
+ from datetime import datetime
11
+ from enum import Enum
12
+ from typing import Any, Final
13
+
14
+ from . import event_types
15
+ from .claims import Claims, event_timestamp, object_member, string_member, strings
16
+ from .event_types import EventTypes
17
+ from .token import EventToken
18
+
19
+ __all__ = ["KNOWN", "Event", "Kind", "PayloadBuilder", "localized", "new_payload"]
20
+
21
+
22
+ class Kind(Enum):
23
+ """One of the events of CAEP 1.0, section 3, or :attr:`OTHER` for an event type in the CAEP
24
+ namespace this version does not know. New kinds are added as CAEP defines events, so do not
25
+ match over them exhaustively; implement the receiver's CAEP handler instead."""
26
+
27
+ OTHER = "Other"
28
+ """An event type in the CAEP namespace this version does not know, for example one of a later
29
+ CAEP version. The common claims of CAEP section 2 are still available."""
30
+ SESSION_REVOKED = "SessionRevoked"
31
+ """The session was revoked (section 3.1)."""
32
+ TOKEN_CLAIMS_CHANGE = "TokenClaimsChange" # noqa: S105 # an event type name, not a credential
33
+ """Claims of the token the subject identifies changed (section 3.2)."""
34
+ CREDENTIAL_CHANGE = "CredentialChange"
35
+ """A credential was created, revoked, updated or deleted (section 3.3)."""
36
+ ASSURANCE_LEVEL_CHANGE = "AssuranceLevelChange"
37
+ """The assurance level of the subject changed (section 3.4)."""
38
+ DEVICE_COMPLIANCE_CHANGE = "DeviceComplianceChange"
39
+ """The compliance status of the device changed (section 3.5)."""
40
+ SESSION_ESTABLISHED = "SessionEstablished"
41
+ """A session was established (section 3.6)."""
42
+ SESSION_PRESENTED = "SessionPresented"
43
+ """A session was presented to the transmitter (section 3.7)."""
44
+ RISK_LEVEL_CHANGE = "RiskLevelChange"
45
+ """The risk level of the subject changed (section 3.8)."""
46
+
47
+ @property
48
+ def event_type(self) -> str:
49
+ """The event type URI of the kind, ``""`` for :attr:`OTHER`."""
50
+ for kind, uri in KNOWN:
51
+ if kind is self:
52
+ return uri
53
+ return ""
54
+
55
+ @classmethod
56
+ def of(cls, event_type: str) -> Kind | None:
57
+ """The kind of CAEP event of an event type URI or alias: :attr:`OTHER` for an unknown
58
+ event type in the CAEP namespace, ``None`` if the event type is not a CAEP event."""
59
+ if not event_types.is_caep_event(event_type):
60
+ return None
61
+ uri = event_types.resolve(event_type)
62
+ for kind, known in KNOWN:
63
+ if known == uri:
64
+ return kind
65
+ return cls.OTHER
66
+
67
+ def __str__(self) -> str:
68
+ """The name of the kind: ``SessionRevoked``, ..., ``Other``."""
69
+ return self.value
70
+
71
+
72
+ KNOWN: Final[tuple[tuple[Kind, str], ...]] = (
73
+ (Kind.SESSION_REVOKED, EventTypes.CAEP_SESSION_REVOKED),
74
+ (Kind.TOKEN_CLAIMS_CHANGE, EventTypes.CAEP_TOKEN_CLAIMS_CHANGE),
75
+ (Kind.CREDENTIAL_CHANGE, EventTypes.CAEP_CREDENTIAL_CHANGE),
76
+ (Kind.ASSURANCE_LEVEL_CHANGE, EventTypes.CAEP_ASSURANCE_LEVEL_CHANGE),
77
+ (Kind.DEVICE_COMPLIANCE_CHANGE, EventTypes.CAEP_DEVICE_COMPLIANCE_CHANGE),
78
+ (Kind.SESSION_ESTABLISHED, EventTypes.CAEP_SESSION_ESTABLISHED),
79
+ (Kind.SESSION_PRESENTED, EventTypes.CAEP_SESSION_PRESENTED),
80
+ (Kind.RISK_LEVEL_CHANGE, EventTypes.CAEP_RISK_LEVEL_CHANGE),
81
+ )
82
+ """The known kinds and their event type URIs, in the order of the specification."""
83
+
84
+
85
+ class Event:
86
+ """A CAEP 1.0 event of a SET, with typed access to the claims every CAEP event may carry
87
+ (section 2) and to the event-specific claims of section 3. An accessor returns ``""`` (or an
88
+ empty collection, or ``None``) when the claim is absent, whether because the transmitter left
89
+ it out or because the :attr:`kind` does not define it. :attr:`payload` is the event as
90
+ received, for anything else.
91
+
92
+ The subject of the event is the ``sub_id`` of the SET, the subject of the event context in a
93
+ handler: CAEP does not prescribe a subject format.
94
+ """
95
+
96
+ __slots__ = ("_event_type", "_kind", "_payload")
97
+
98
+ def __init__(self, event_type: str, kind: Kind, payload: Claims) -> None:
99
+ self._event_type = event_type
100
+ self._kind = kind
101
+ self._payload = payload
102
+
103
+ @classmethod
104
+ def of(cls, event_type: str, payload: Claims | None = None) -> Event | None:
105
+ """The CAEP event with the given type (URI or alias) and payload; ``None`` if the type is
106
+ not a CAEP event. Lenient: no payload is an empty one."""
107
+ kind = Kind.of(event_type)
108
+ if kind is None:
109
+ return None
110
+ return cls(event_types.resolve(event_type), kind, {} if payload is None else payload)
111
+
112
+ @classmethod
113
+ def in_token(cls, token: EventToken) -> list[Event]:
114
+ """The CAEP events of a SET, in the order of their event type URIs (sorted, as the Go port
115
+ does, so that the order is the same whatever the transmitter sent). CAEP allows one event
116
+ per SET; a SET with several is read like one of each. A payload that is not a JSON object
117
+ is read as an empty one."""
118
+ events = []
119
+ for event_type in sorted(token.events):
120
+ event = cls.of(event_type, object_member(token.events, event_type))
121
+ if event is not None:
122
+ events.append(event)
123
+ return events
124
+
125
+ @property
126
+ def event_type(self) -> str:
127
+ """The event type URI."""
128
+ return self._event_type
129
+
130
+ @property
131
+ def kind(self) -> Kind:
132
+ """The kind of event."""
133
+ return self._kind
134
+
135
+ @property
136
+ def payload(self) -> Claims:
137
+ """The event payload as received."""
138
+ return self._payload
139
+
140
+ # CAEP section 2: claims every event may carry
141
+
142
+ @property
143
+ def event_timestamp(self) -> datetime | None:
144
+ """The ``event_timestamp``, the time the event occurred. CAEP defines seconds since the
145
+ epoch; a value that is clearly milliseconds is accepted too. ``None`` if absent."""
146
+ return event_timestamp(self._payload.get("event_timestamp"))
147
+
148
+ @property
149
+ def initiating_entity(self) -> str:
150
+ """The ``initiating_entity``, who invoked the event: ``admin``, ``user``, ``policy`` or
151
+ ``system``."""
152
+ return self._string("initiating_entity")
153
+
154
+ @property
155
+ def reason_admin(self) -> dict[str, str]:
156
+ """The ``reason_admin``, the reason for administrators, by BCP 47 language tag (``en``,
157
+ ``de-CH``, ...); empty if absent. A transmitter that sends a plain string instead of the
158
+ localizable object of CAEP gets the key ``""``."""
159
+ return localized(self._payload.get("reason_admin"))
160
+
161
+ @property
162
+ def reason_user(self) -> dict[str, str]:
163
+ """The ``reason_user``, the reason for display to the end user, by BCP 47 language tag;
164
+ empty if absent, a plain string under the key ``""``."""
165
+ return localized(self._payload.get("reason_user"))
166
+
167
+ # CAEP section 3: event-specific claims
168
+
169
+ @property
170
+ def claims(self) -> Claims | None:
171
+ """The ``claims`` of a :attr:`Kind.TOKEN_CLAIMS_CHANGE`, each with its new value; ``None``
172
+ if absent."""
173
+ return object_member(self._payload, "claims")
174
+
175
+ @property
176
+ def credential_type(self) -> str:
177
+ """The ``credential_type`` of a :attr:`Kind.CREDENTIAL_CHANGE`: ``password``, ``pin``,
178
+ ``x509``, ``fido2-platform``, ``fido2-roaming``, ``fido-u2f``, ``verifiable-credential``,
179
+ ``phone-voice``, ``phone-sms``, ``app`` or a value agreed with the transmitter."""
180
+ return self._string("credential_type")
181
+
182
+ @property
183
+ def change_type(self) -> str:
184
+ """The ``change_type`` of a :attr:`Kind.CREDENTIAL_CHANGE`: ``create``, ``revoke``,
185
+ ``update`` or ``delete``."""
186
+ return self._string("change_type")
187
+
188
+ @property
189
+ def friendly_name(self) -> str:
190
+ """The ``friendly_name`` of the credential of a :attr:`Kind.CREDENTIAL_CHANGE`."""
191
+ return self._string("friendly_name")
192
+
193
+ @property
194
+ def x509_issuer(self) -> str:
195
+ """The ``x509_issuer`` of an X.509 credential."""
196
+ return self._string("x509_issuer")
197
+
198
+ @property
199
+ def x509_serial(self) -> str:
200
+ """The ``x509_serial`` of an X.509 credential."""
201
+ return self._string("x509_serial")
202
+
203
+ @property
204
+ def fido2_aaguid(self) -> str:
205
+ """The ``fido2_aaguid``, the FIDO2 Authenticator Attestation GUID of the credential."""
206
+ return self._string("fido2_aaguid")
207
+
208
+ @property
209
+ def namespace(self) -> str:
210
+ """The ``namespace`` of the levels of an :attr:`Kind.ASSURANCE_LEVEL_CHANGE`: ``RFC8176``,
211
+ ``RFC6711``, ``ISO-IEC-29115``, ``NIST-IAL``, ``NIST-AAL``, ``NIST-FAL`` or a value agreed
212
+ with the transmitter."""
213
+ return self._string("namespace")
214
+
215
+ @property
216
+ def current_level(self) -> str:
217
+ """The ``current_level``: the assurance level of an :attr:`Kind.ASSURANCE_LEVEL_CHANGE` in
218
+ its :attr:`namespace`, or the risk level of a :attr:`Kind.RISK_LEVEL_CHANGE` (``LOW``,
219
+ ``MEDIUM``, ``HIGH``)."""
220
+ return self._string("current_level")
221
+
222
+ @property
223
+ def previous_level(self) -> str:
224
+ """The ``previous_level`` of an assurance or risk level change, ``""`` if the transmitter
225
+ does not know the previous level."""
226
+ return self._string("previous_level")
227
+
228
+ @property
229
+ def change_direction(self) -> str:
230
+ """The ``change_direction`` of an :attr:`Kind.ASSURANCE_LEVEL_CHANGE`: ``increase`` or
231
+ ``decrease``."""
232
+ return self._string("change_direction")
233
+
234
+ @property
235
+ def current_status(self) -> str:
236
+ """The ``current_status`` of a :attr:`Kind.DEVICE_COMPLIANCE_CHANGE`: ``compliant`` or
237
+ ``not-compliant``."""
238
+ return self._string("current_status")
239
+
240
+ @property
241
+ def previous_status(self) -> str:
242
+ """The ``previous_status`` of a :attr:`Kind.DEVICE_COMPLIANCE_CHANGE`."""
243
+ return self._string("previous_status")
244
+
245
+ @property
246
+ def fp_ua(self) -> str:
247
+ """The ``fp_ua``, the fingerprint of the user agent of a :attr:`Kind.SESSION_ESTABLISHED`
248
+ or :attr:`Kind.SESSION_PRESENTED`."""
249
+ return self._string("fp_ua")
250
+
251
+ @property
252
+ def acr(self) -> str:
253
+ """The ``acr``, the authentication context class reference of a
254
+ :attr:`Kind.SESSION_ESTABLISHED`, as in an ID Token."""
255
+ return self._string("acr")
256
+
257
+ @property
258
+ def amr(self) -> list[str]:
259
+ """The ``amr``, the authentication methods references of a
260
+ :attr:`Kind.SESSION_ESTABLISHED`, as in an ID Token; empty if absent."""
261
+ return strings(self._payload.get("amr"))
262
+
263
+ @property
264
+ def ext_id(self) -> str:
265
+ """The ``ext_id``, the external session identifier of a :attr:`Kind.SESSION_ESTABLISHED`
266
+ or :attr:`Kind.SESSION_PRESENTED` that correlates it with a broader session, for example a
267
+ SAML one."""
268
+ return self._string("ext_id")
269
+
270
+ @property
271
+ def principal(self) -> str:
272
+ """The ``principal`` of a :attr:`Kind.RISK_LEVEL_CHANGE`, the entity whose risk changed:
273
+ ``USER``, ``DEVICE``, ``SESSION``, ``TENANT``, ``ORG_UNIT``, ``GROUP`` or another entity
274
+ of SSF."""
275
+ return self._string("principal")
276
+
277
+ @property
278
+ def risk_reason(self) -> str:
279
+ """The ``risk_reason`` of a :attr:`Kind.RISK_LEVEL_CHANGE`."""
280
+ return self._string("risk_reason")
281
+
282
+ def _string(self, name: str) -> str:
283
+ return string_member(self._payload, name)
284
+
285
+ def __repr__(self) -> str:
286
+ return f"Event({self._kind.value}, {self._event_type!r})"
287
+
288
+
289
+ def _json_text(value: object) -> str:
290
+ """A JSON value as its compact text, with the member names sorted as Go's ``json.Marshal`` does,
291
+ so that the five ports render the same text."""
292
+ return json.dumps(value, separators=(",", ":"), sort_keys=True, ensure_ascii=False)
293
+
294
+
295
+ def localized(value: object) -> dict[str, str]:
296
+ """Reads a localizable claim as CAEP defines it (section 2): an object of texts by BCP 47
297
+ language tag. A plain string is kept under the key ``""``, other values as their JSON; ``None``
298
+ gives an empty dict."""
299
+ if value is None:
300
+ return {}
301
+ if isinstance(value, str):
302
+ return {"": value}
303
+ if isinstance(value, dict):
304
+ reasons: dict[str, str] = {}
305
+ for tag, reason in value.items():
306
+ if reason is None:
307
+ continue
308
+ reasons[tag] = reason if isinstance(reason, str) else _json_text(reason)
309
+ return reasons
310
+ return {"": _json_text(value)}
311
+
312
+
313
+ class PayloadBuilder:
314
+ """Assembles the payload of a CAEP event for a transmitter or a test: the common claims of
315
+ CAEP 1.0, section 2, and any event-specific ones.
316
+
317
+ Example:
318
+ >>> payload = (new_payload().event_timestamp(datetime.now(tz=UTC)).initiating_entity("admin")
319
+ ... .reason_admin("en", "Policy violation").build())
320
+ """
321
+
322
+ def __init__(self) -> None:
323
+ self._claims: Claims = {}
324
+
325
+ def event_timestamp(self, at: datetime) -> PayloadBuilder:
326
+ """Sets ``event_timestamp``, in seconds since the epoch."""
327
+ self._claims["event_timestamp"] = int(at.timestamp())
328
+ return self
329
+
330
+ def initiating_entity(self, entity: str) -> PayloadBuilder:
331
+ """Sets ``initiating_entity``: ``admin``, ``user``, ``policy`` or ``system``."""
332
+ self._claims["initiating_entity"] = entity
333
+ return self
334
+
335
+ def reason_admin(self, language: str, reason: str) -> PayloadBuilder:
336
+ """Adds a ``reason_admin`` in the language, a BCP 47 tag such as ``en``."""
337
+ return self._localized("reason_admin", language, reason)
338
+
339
+ def reason_user(self, language: str, reason: str) -> PayloadBuilder:
340
+ """Adds a ``reason_user`` in the language."""
341
+ return self._localized("reason_user", language, reason)
342
+
343
+ def _localized(self, name: str, language: str, text: str) -> PayloadBuilder:
344
+ # The reasons are kept as the dict of a decoded payload, so that localized() reads them
345
+ # back whether the payload was built or received.
346
+ reasons = self._claims.get(name)
347
+ if not isinstance(reasons, dict):
348
+ reasons = {}
349
+ self._claims[name] = reasons
350
+ reasons[language] = text
351
+ return self
352
+
353
+ def claim(self, name: str, value: Any) -> PayloadBuilder:
354
+ """Sets an event-specific claim such as ``credential_type`` or ``current_level``; ``None``
355
+ removes it."""
356
+ if value is None:
357
+ self._claims.pop(name, None)
358
+ else:
359
+ self._claims[name] = value
360
+ return self
361
+
362
+ def build(self) -> Claims:
363
+ """The payload as claims, a copy, to pass to :meth:`easyssf.core.SetBuilder.event`."""
364
+ return copy.deepcopy(self._claims)
365
+
366
+
367
+ def new_payload() -> PayloadBuilder:
368
+ """Starts an empty payload."""
369
+ return PayloadBuilder()
@@ -0,0 +1,109 @@
1
+ """JSON claims as decoded by ``json.loads`` and the lenient accessors that read them.
2
+
3
+ The claims of a SET, an event payload, a subject identifier and a stream configuration are all
4
+ plain JSON objects. Nothing here raises for input that does not fit: an accessor returns ``""``,
5
+ ``None`` or an empty list, and the verifier applies the rules that produce error codes.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import math
12
+ from datetime import UTC, datetime
13
+ from typing import Any, TypeAlias
14
+
15
+ __all__ = [
16
+ "Claims",
17
+ "as_int",
18
+ "audiences",
19
+ "event_timestamp",
20
+ "is_object",
21
+ "object_member",
22
+ "parse_claims",
23
+ "string_member",
24
+ "strings",
25
+ ]
26
+
27
+ Claims: TypeAlias = dict[str, Any]
28
+ """A JSON object as decoded by ``json.loads``: the claims of a SET, an event payload, a subject
29
+ identifier. Numbers are ``int`` or ``float``, nested objects are ``Claims``-shaped dicts."""
30
+
31
+ _MILLISECONDS_THRESHOLD = 100_000_000_000
32
+
33
+
34
+ def string_member(obj: Claims | None, name: str) -> str:
35
+ """The member with the given name if it is a non-blank string, ``""`` otherwise."""
36
+ if not obj:
37
+ return ""
38
+ value = obj.get(name)
39
+ if isinstance(value, str) and value.strip():
40
+ return value
41
+ return ""
42
+
43
+
44
+ def is_object(value: object) -> bool:
45
+ """Whether the value is a JSON object (a dict)."""
46
+ return isinstance(value, dict)
47
+
48
+
49
+ def object_member(obj: Claims | None, name: str) -> Claims | None:
50
+ """The member with the given name if it is a JSON object, ``None`` otherwise."""
51
+ if not obj:
52
+ return None
53
+ value = obj.get(name)
54
+ return value if isinstance(value, dict) else None
55
+
56
+
57
+ def as_int(value: object) -> int | None:
58
+ """A JSON number as an integer, ``None`` if the value is not a finite number.
59
+
60
+ ``bool`` is a number to Python but not to JSON, so ``True`` is not ``1`` here.
61
+ """
62
+ if isinstance(value, bool):
63
+ return None
64
+ if isinstance(value, int):
65
+ return value
66
+ if isinstance(value, float):
67
+ if math.isnan(value) or math.isinf(value):
68
+ return None
69
+ return int(value)
70
+ return None
71
+
72
+
73
+ def strings(value: object) -> list[str]:
74
+ """A JSON array of strings; other values give an empty list, non-string elements are skipped."""
75
+ if not isinstance(value, list):
76
+ return []
77
+ return [element for element in value if isinstance(element, str)]
78
+
79
+
80
+ def audiences(aud: object) -> list[str]:
81
+ """An ``aud`` claim as a list: a string as a list of one, an array of its strings, empty otherwise."""
82
+ if isinstance(aud, str):
83
+ return [aud]
84
+ if isinstance(aud, list):
85
+ return strings(aud)
86
+ return []
87
+
88
+
89
+ def event_timestamp(value: object) -> datetime | None:
90
+ """An ``event_timestamp`` claim of an event payload as a point in time.
91
+
92
+ CAEP 1.0 (section 2) and RISC 1.0 (section 2.7) define it as a JSON number of seconds since
93
+ the epoch; a value that is clearly milliseconds is read as such, as some transmitters send
94
+ milliseconds. ``None`` if the value is not a number.
95
+ """
96
+ number = as_int(value)
97
+ if number is None:
98
+ return None
99
+ if number > _MILLISECONDS_THRESHOLD:
100
+ number //= 1000
101
+ return datetime.fromtimestamp(number, tz=UTC)
102
+
103
+
104
+ def parse_claims(data: bytes | str) -> Claims:
105
+ """Decodes a JSON object; raises ``ValueError`` if the data is not a JSON object."""
106
+ decoded = json.loads(data)
107
+ if not isinstance(decoded, dict):
108
+ raise ValueError("expected a JSON object")
109
+ return decoded