openbadgeslib 3.8.0__tar.gz → 3.9.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.
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/Changelog.txt +27 -0
- {openbadgeslib-3.8.0/openbadgeslib.egg-info → openbadgeslib-3.9.0}/PKG-INFO +1 -1
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/ob3/__init__.py +7 -2
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/ob3/credential.py +161 -6
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/ob3/verifier.py +99 -1
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/openbadges_verifier.py +8 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/util.py +1 -1
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0/openbadgeslib.egg-info}/PKG-INFO +1 -1
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib.egg-info/SOURCES.txt +1 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_ob3_credential.py +118 -0
- openbadgeslib-3.9.0/tests/test_ob3_endorsement.py +188 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/LICENSE.txt +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/MANIFEST.in +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/README.md +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/SECURITY.md +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/docs/README.md +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/__init__.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/_jws/__init__.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/_jws/exceptions.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/_jws/utils.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/badge.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/baking.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/config.ini.example +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/confparser.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/errors.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/keys.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/logs.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/mail.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/ob1/__init__.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/ob1/badge.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/ob1/signer.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/ob1/verifier.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/ob2/__init__.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/ob2/models.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/ob2/signer.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/ob2/verifier.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/ob3/contexts/__init__.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/ob3/contexts/credentials-v2.json +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.1.json +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.2.json +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/ob3/contexts/ob-v3p0-context-3.0.3.json +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/ob3/contexts/security-data-integrity-v2.json +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/ob3/contexts/security-multikey-v1.json +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/ob3/did.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/ob3/eudi.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/ob3/ldp.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/ob3/signer.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/ob3/status.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/ob3/status_list.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/ob3/status_registry.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/openbadges_init.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/openbadges_keygenerator.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/openbadges_publish.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/openbadges_signer.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/py.typed +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/signer.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib/verifier.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib.egg-info/dependency_links.txt +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib.egg-info/entry_points.txt +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib.egg-info/requires.txt +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/openbadgeslib.egg-info/top_level.txt +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/pyproject.toml +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/setup.cfg +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/config1.ini +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/conformance/Dockerfile.ob3 +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/conformance/README.md +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/conformance/conftest.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/conformance/docker-compose.yml +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/conformance/test_official_validators.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/conftest.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/fixtures/ob_schemas/README.md +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/fixtures/ob_schemas/ob_v3p0_achievementcredential_schema.json +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/fixtures/ob_schemas/ob_v3p0_profile_schema.json +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/fixtures/ob_schemas/refresh.sh +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/fixtures/vc_di_eddsa/README.md +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/fixtures/vc_di_eddsa/credentials-examples-v2.json +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/fixtures/vc_di_eddsa/doc-hash.txt +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/fixtures/vc_di_eddsa/key-pair.json +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/fixtures/vc_di_eddsa/proof-config-hash.txt +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/fixtures/vc_di_eddsa/signed-credential.json +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/images/sample1.png +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/images/sample1.svg +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/images/userimage01.svg +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/ldp_helpers.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/logo Python Espan/314/203a.svg" +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/logo Python Espa/303/261a.svg" +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_cli_json.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_cli_smoke.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_confparser.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_docs.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_eddsa.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_jws.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_key_operation.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_ob1_badge_io.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_ob1_deprecation.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_ob1_signer.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_ob1_verifier.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_ob2_cli.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_ob2_models.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_ob2_signer.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_ob2_verifier.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_ob3_conformance_schema.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_ob3_contexts.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_ob3_did.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_ob3_did_doc.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_ob3_eudi_sd_jwt.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_ob3_ldp.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_ob3_ldp_cli.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_ob3_ldp_sign.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_ob3_publish_cli.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_ob3_signer.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_ob3_signer_cli_ldp.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_ob3_status.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_ob3_status_list.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_ob3_status_registry.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_ob3_verifier.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_sign_ecc.pem +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_sign_rsa.pem +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_util.py +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_verify_ecc.pem +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/test_verify_rsa.pem +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/withoutxmlheader.svg +0 -0
- {openbadgeslib-3.8.0 → openbadgeslib-3.9.0}/tests/withxmlheader.svg +0 -0
|
@@ -4,6 +4,33 @@ OpenBadgesLib - Changelog
|
|
|
4
4
|
Newest first. Dates are ISO 8601 (YYYY-MM-DD).
|
|
5
5
|
|
|
6
6
|
|
|
7
|
+
* v3.9.0 - 2026-07-08
|
|
8
|
+
|
|
9
|
+
- feat(ob3): broaden the credential data model toward the OB 3.0 spec — the
|
|
10
|
+
gap versus the spec was the model, not the crypto. New `Evidence` (the full
|
|
11
|
+
`evidence` array: narrative, name, description, genre, audience — beyond the
|
|
12
|
+
single `evidence_url`) and `Alignment` (competency-framework mappings, what
|
|
13
|
+
LMSes read to map a badge to a competency) dataclasses, plus
|
|
14
|
+
`achievementType`, `creditsAvailable` and an `alignments` list on
|
|
15
|
+
`Achievement`. All are validated in the house style (dataclasses + mypy, no
|
|
16
|
+
Pydantic), parse from a verified credential, round-trip through `to_vc`, and
|
|
17
|
+
still satisfy the offline conformance schema. (Refs #162 —
|
|
18
|
+
Result/ResultDescription, IdentityObject and creditsEarned follow.)
|
|
19
|
+
|
|
20
|
+
- feat(ob3): support OpenBadges 3.0 `endorsementJwt` (spec errata v1.6, added
|
|
21
|
+
to context 3.0.3). Third-party endorsement JWTs were passing through as
|
|
22
|
+
opaque, unmapped fields; the credential model now exposes them at the three
|
|
23
|
+
levels the spec allows — `credential.endorsement_jwts`,
|
|
24
|
+
`issuer.endorsement_jwts`, `achievement.endorsement_jwts`, gathered by
|
|
25
|
+
`credential.all_endorsement_jwts()` — and round-trips them through `to_vc`.
|
|
26
|
+
A new `ob3.verify_endorsement_jwt(token, download=None,
|
|
27
|
+
endorser_pubkey_pem=None)` verifies an EndorsementCredential JWT under the
|
|
28
|
+
*endorser's* key (resolved from its own issuer did:web/did:key, or an
|
|
29
|
+
explicit key when the endorser is not a DID), checking the
|
|
30
|
+
EndorsementCredential type and its validFrom/validUntil window.
|
|
31
|
+
`openbadges-verifier --json` now reports the `endorsements` count. (#161)
|
|
32
|
+
|
|
33
|
+
|
|
7
34
|
* v3.8.0 - 2026-07-08
|
|
8
35
|
|
|
9
36
|
- security(ob3): status lists gain replay protection and opt-in proof
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openbadgeslib
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.9.0
|
|
4
4
|
Summary: Production-ready Python library & CLI for Open Badges 3.0 (W3C Verifiable Credentials): issue, verify, and revoke JWT-VC credentials with Bitstring Status Lists and did:web — plus strict OB 2.0 and legacy OB 1.0.
|
|
5
5
|
Author-email: Luis González Fernández <luisgf@luisgf.es>, Jesús Cea Avión <jcea@jcea.es>
|
|
6
6
|
License: LGPLv3
|
|
@@ -20,9 +20,11 @@
|
|
|
20
20
|
License along with this library.
|
|
21
21
|
"""
|
|
22
22
|
|
|
23
|
-
from .credential import Achievement, Issuer,
|
|
23
|
+
from .credential import (Achievement, Alignment, Evidence, Issuer,
|
|
24
|
+
OpenBadgeCredential)
|
|
24
25
|
from .signer import OB3Signer
|
|
25
|
-
from .verifier import OB3VerificationError, OB3Verifier
|
|
26
|
+
from .verifier import (OB3VerificationError, OB3Verifier,
|
|
27
|
+
verify_endorsement_jwt)
|
|
26
28
|
from .ldp import (OB3LdpSigner, OB3LdpVerifier, add_data_integrity_proof,
|
|
27
29
|
verify_data_integrity_proof)
|
|
28
30
|
from .status import check_credential_status
|
|
@@ -34,6 +36,8 @@ from .did import (build_did_document, did_key_from_pem, did_web_from_url,
|
|
|
34
36
|
|
|
35
37
|
__all__ = [
|
|
36
38
|
'Achievement',
|
|
39
|
+
'Alignment',
|
|
40
|
+
'Evidence',
|
|
37
41
|
'Issuer',
|
|
38
42
|
'OpenBadgeCredential',
|
|
39
43
|
'OB3LdpSigner',
|
|
@@ -55,4 +59,5 @@ __all__ = [
|
|
|
55
59
|
'resolve_verification_method',
|
|
56
60
|
'sign_status_list_credential',
|
|
57
61
|
'status_entry',
|
|
62
|
+
'verify_endorsement_jwt',
|
|
58
63
|
]
|
|
@@ -86,6 +86,10 @@ class Issuer:
|
|
|
86
86
|
url: Optional[str] = None
|
|
87
87
|
email: Optional[str] = None
|
|
88
88
|
image_url: Optional[str] = None
|
|
89
|
+
# Compact EndorsementCredential JWTs vouching for this profile (OB 3.0
|
|
90
|
+
# `endorsementJwt`, added to context 3.0.3 by errata v1.6). Verifiable with
|
|
91
|
+
# verify_endorsement_jwt; the endorser is a third party, not this issuer.
|
|
92
|
+
endorsement_jwts: List[str] = field(default_factory=list)
|
|
89
93
|
|
|
90
94
|
def to_dict(self) -> dict[str, Any]:
|
|
91
95
|
d: dict[str, Any] = {"id": self.id, "type": ["Profile"], "name": self.name}
|
|
@@ -95,9 +99,82 @@ class Issuer:
|
|
|
95
99
|
d["email"] = self.email
|
|
96
100
|
if self.image_url:
|
|
97
101
|
d["image"] = {"id": self.image_url, "type": "Image"}
|
|
102
|
+
if self.endorsement_jwts:
|
|
103
|
+
d["endorsementJwt"] = list(self.endorsement_jwts)
|
|
98
104
|
return d
|
|
99
105
|
|
|
100
106
|
|
|
107
|
+
@dataclass
|
|
108
|
+
class Alignment:
|
|
109
|
+
"""Alignment of an achievement to a node in an educational framework
|
|
110
|
+
(OB 3.0 `alignment`) — what an LMS reads to map a badge to a competency."""
|
|
111
|
+
|
|
112
|
+
target_name: str
|
|
113
|
+
target_url: str
|
|
114
|
+
target_framework: Optional[str] = None
|
|
115
|
+
target_code: Optional[str] = None
|
|
116
|
+
target_description: Optional[str] = None
|
|
117
|
+
|
|
118
|
+
def to_dict(self) -> dict[str, Any]:
|
|
119
|
+
d: dict[str, Any] = {
|
|
120
|
+
"type": ["Alignment"],
|
|
121
|
+
"targetName": self.target_name,
|
|
122
|
+
"targetUrl": self.target_url,
|
|
123
|
+
}
|
|
124
|
+
if self.target_framework:
|
|
125
|
+
d["targetFramework"] = self.target_framework
|
|
126
|
+
if self.target_code:
|
|
127
|
+
d["targetCode"] = self.target_code
|
|
128
|
+
if self.target_description:
|
|
129
|
+
d["targetDescription"] = self.target_description
|
|
130
|
+
return d
|
|
131
|
+
|
|
132
|
+
@classmethod
|
|
133
|
+
def from_dict(cls, data: dict[str, Any]) -> "Alignment":
|
|
134
|
+
return cls(
|
|
135
|
+
target_name=_require(data, "targetName", "alignment"),
|
|
136
|
+
target_url=_require(data, "targetUrl", "alignment"),
|
|
137
|
+
target_framework=data.get("targetFramework"),
|
|
138
|
+
target_code=data.get("targetCode"),
|
|
139
|
+
target_description=data.get("targetDescription"),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass
|
|
144
|
+
class Evidence:
|
|
145
|
+
"""A piece of evidence supporting a credential (OB 3.0 `evidence`), richer
|
|
146
|
+
than a bare URL: an optional narrative and descriptive metadata."""
|
|
147
|
+
|
|
148
|
+
id: Optional[str] = None # dereferenceable URL of the evidence
|
|
149
|
+
narrative: Optional[str] = None
|
|
150
|
+
name: Optional[str] = None
|
|
151
|
+
description: Optional[str] = None
|
|
152
|
+
genre: Optional[str] = None
|
|
153
|
+
audience: Optional[str] = None
|
|
154
|
+
|
|
155
|
+
def to_dict(self) -> dict[str, Any]:
|
|
156
|
+
d: dict[str, Any] = {"type": ["Evidence"]}
|
|
157
|
+
if self.id:
|
|
158
|
+
d["id"] = self.id
|
|
159
|
+
for key, value in (("narrative", self.narrative), ("name", self.name),
|
|
160
|
+
("description", self.description),
|
|
161
|
+
("genre", self.genre), ("audience", self.audience)):
|
|
162
|
+
if value:
|
|
163
|
+
d[key] = value
|
|
164
|
+
return d
|
|
165
|
+
|
|
166
|
+
@classmethod
|
|
167
|
+
def from_dict(cls, data: dict[str, Any]) -> "Evidence":
|
|
168
|
+
return cls(
|
|
169
|
+
id=data.get("id"),
|
|
170
|
+
narrative=data.get("narrative"),
|
|
171
|
+
name=data.get("name"),
|
|
172
|
+
description=data.get("description"),
|
|
173
|
+
genre=data.get("genre"),
|
|
174
|
+
audience=data.get("audience"),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
101
178
|
@dataclass
|
|
102
179
|
class Achievement:
|
|
103
180
|
"""A badge class / achievement definition."""
|
|
@@ -108,6 +185,16 @@ class Achievement:
|
|
|
108
185
|
criteria_narrative: str
|
|
109
186
|
image_url: Optional[str] = None
|
|
110
187
|
tags: List[str] = field(default_factory=list)
|
|
188
|
+
# Compact EndorsementCredential JWTs vouching for this achievement
|
|
189
|
+
# (OB 3.0 `endorsementJwt`). See Issuer.endorsement_jwts.
|
|
190
|
+
endorsement_jwts: List[str] = field(default_factory=list)
|
|
191
|
+
# Kind of achievement (OB 3.0 `achievementType`, e.g. 'Badge',
|
|
192
|
+
# 'Certificate', 'Competency', …) and any academic credit it carries.
|
|
193
|
+
achievement_type: Optional[str] = None
|
|
194
|
+
credits_available: Optional[float] = None
|
|
195
|
+
# Framework alignments (OB 3.0 `alignment`): the competency mappings LMSes
|
|
196
|
+
# consume. Empty when the achievement declares none.
|
|
197
|
+
alignments: List[Alignment] = field(default_factory=list)
|
|
111
198
|
|
|
112
199
|
def to_dict(self) -> dict[str, Any]:
|
|
113
200
|
d: dict[str, Any] = {
|
|
@@ -117,10 +204,18 @@ class Achievement:
|
|
|
117
204
|
"description": self.description,
|
|
118
205
|
"criteria": {"narrative": self.criteria_narrative},
|
|
119
206
|
}
|
|
207
|
+
if self.achievement_type:
|
|
208
|
+
d["achievementType"] = self.achievement_type
|
|
209
|
+
if self.credits_available is not None:
|
|
210
|
+
d["creditsAvailable"] = self.credits_available
|
|
120
211
|
if self.image_url:
|
|
121
212
|
d["image"] = {"id": self.image_url, "type": "Image"}
|
|
122
213
|
if self.tags:
|
|
123
214
|
d["tag"] = self.tags
|
|
215
|
+
if self.alignments:
|
|
216
|
+
d["alignment"] = [a.to_dict() for a in self.alignments]
|
|
217
|
+
if self.endorsement_jwts:
|
|
218
|
+
d["endorsementJwt"] = list(self.endorsement_jwts)
|
|
124
219
|
return d
|
|
125
220
|
|
|
126
221
|
|
|
@@ -137,11 +232,20 @@ class OpenBadgeCredential:
|
|
|
137
232
|
name: Optional[str] = None # defaults to achievement.name
|
|
138
233
|
issuance_date: Optional[datetime] = None # defaults to now (UTC)
|
|
139
234
|
expiration_date: Optional[datetime] = None
|
|
235
|
+
# Backward-compatible single-URL evidence convenience. For the full OB 3.0
|
|
236
|
+
# evidence list (narrative, name, …) use `evidence`; when both are set the
|
|
237
|
+
# list wins. Parsing populates both (evidence_url = evidence[0].id).
|
|
140
238
|
evidence_url: Optional[str] = None
|
|
239
|
+
evidence: List["Evidence"] = field(default_factory=list)
|
|
141
240
|
# Raw credentialStatus entries (Bitstring Status List / StatusList2021),
|
|
142
241
|
# normalised to a list of objects. Consumed by ob3.status to check
|
|
143
242
|
# revocation; empty when the credential carries no status.
|
|
144
243
|
credential_status: List[dict[str, Any]] = field(default_factory=list)
|
|
244
|
+
# Compact EndorsementCredential JWTs vouching for the credential itself
|
|
245
|
+
# (top-level OB 3.0 `endorsementJwt`). Endorsements attached to the issuer
|
|
246
|
+
# or the achievement live on those objects; all_endorsement_jwts() gathers
|
|
247
|
+
# the three. Empty when the credential carries none.
|
|
248
|
+
endorsement_jwts: List[str] = field(default_factory=list)
|
|
145
249
|
# A read-only view of the already-validated document this credential was
|
|
146
250
|
# parsed from (set by the verifiers via _from_vc); None when built in-memory
|
|
147
251
|
# to issue. Lets a caller read spec fields the model does not map —
|
|
@@ -195,14 +299,27 @@ class OpenBadgeCredential:
|
|
|
195
299
|
vc["credentialSubject"]["id"] = self.recipient_id
|
|
196
300
|
if self.expiration_date:
|
|
197
301
|
vc["validUntil"] = _iso(self.expiration_date)
|
|
198
|
-
|
|
199
|
-
|
|
302
|
+
evidence_items = self.evidence or (
|
|
303
|
+
[Evidence(id=self.evidence_url)] if self.evidence_url else [])
|
|
304
|
+
if evidence_items:
|
|
305
|
+
vc["evidence"] = [e.to_dict() for e in evidence_items]
|
|
200
306
|
if self.credential_status:
|
|
201
307
|
vc["credentialStatus"] = (
|
|
202
308
|
self.credential_status[0] if len(self.credential_status) == 1
|
|
203
309
|
else self.credential_status)
|
|
310
|
+
if self.endorsement_jwts:
|
|
311
|
+
vc["endorsementJwt"] = list(self.endorsement_jwts)
|
|
204
312
|
return vc
|
|
205
313
|
|
|
314
|
+
def all_endorsement_jwts(self) -> List[str]:
|
|
315
|
+
"""Every compact EndorsementCredential JWT attached to this credential,
|
|
316
|
+
gathered from the credential itself and from its issuer and achievement
|
|
317
|
+
(OB 3.0 allows `endorsementJwt` at all three levels). Verify each with
|
|
318
|
+
:func:`openbadgeslib.ob3.verify_endorsement_jwt`."""
|
|
319
|
+
return [*self.endorsement_jwts,
|
|
320
|
+
*self.issuer.endorsement_jwts,
|
|
321
|
+
*self.achievement.endorsement_jwts]
|
|
322
|
+
|
|
206
323
|
def to_jwt_payload(self) -> dict[str, Any]:
|
|
207
324
|
"""Return the JWT payload for a JWT-VC signed credential.
|
|
208
325
|
|
|
@@ -276,6 +393,7 @@ class OpenBadgeCredential:
|
|
|
276
393
|
url=issuer_data.get("url"),
|
|
277
394
|
email=issuer_data.get("email"),
|
|
278
395
|
image_url=_as_dict_or_empty(issuer_data.get("image")).get("id"),
|
|
396
|
+
endorsement_jwts=_string_list(issuer_data.get("endorsementJwt")),
|
|
279
397
|
)
|
|
280
398
|
|
|
281
399
|
# credentialSubject may be a single object or a (non-empty) array.
|
|
@@ -290,6 +408,10 @@ class OpenBadgeCredential:
|
|
|
290
408
|
"vc.credentialSubject.achievement")
|
|
291
409
|
criteria = _as_dict_or_empty(ach_data.get("criteria"))
|
|
292
410
|
image = _as_dict_or_empty(ach_data.get("image"))
|
|
411
|
+
alignment_raw = ach_data.get("alignment")
|
|
412
|
+
alignments = [Alignment.from_dict(a) for a in alignment_raw
|
|
413
|
+
if isinstance(a, dict)] \
|
|
414
|
+
if isinstance(alignment_raw, list) else []
|
|
293
415
|
achievement = Achievement(
|
|
294
416
|
id=_require(ach_data, "id", "vc.credentialSubject.achievement"),
|
|
295
417
|
name=_require(ach_data, "name", "vc.credentialSubject.achievement"),
|
|
@@ -297,6 +419,10 @@ class OpenBadgeCredential:
|
|
|
297
419
|
criteria_narrative=criteria.get("narrative", ""),
|
|
298
420
|
image_url=image.get("id"),
|
|
299
421
|
tags=ach_data.get("tag", []),
|
|
422
|
+
endorsement_jwts=_string_list(ach_data.get("endorsementJwt")),
|
|
423
|
+
achievement_type=ach_data.get("achievementType"),
|
|
424
|
+
credits_available=_float_or_none(ach_data.get("creditsAvailable")),
|
|
425
|
+
alignments=alignments,
|
|
300
426
|
)
|
|
301
427
|
|
|
302
428
|
# Accept both VC 2.0 (validFrom/validUntil) and VC 1.1
|
|
@@ -306,10 +432,16 @@ class OpenBadgeCredential:
|
|
|
306
432
|
expires = vc.get("validUntil") or vc.get("expirationDate")
|
|
307
433
|
expiration_date = _parse_date(expires, "vc.validUntil") if expires else None
|
|
308
434
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
435
|
+
evidence_raw = vc.get("evidence")
|
|
436
|
+
if isinstance(evidence_raw, list):
|
|
437
|
+
evidence_list = [Evidence.from_dict(e) for e in evidence_raw
|
|
438
|
+
if isinstance(e, dict)]
|
|
439
|
+
elif isinstance(evidence_raw, dict):
|
|
440
|
+
evidence_list = [Evidence.from_dict(evidence_raw)]
|
|
441
|
+
else:
|
|
442
|
+
evidence_list = []
|
|
443
|
+
# Backward-compatible convenience: the first evidence's URL.
|
|
444
|
+
evidence_url = evidence_list[0].id if evidence_list else None
|
|
313
445
|
|
|
314
446
|
# credentialStatus may be a single object or an array; keep only object
|
|
315
447
|
# entries so the status checker can rely on .get() without crashing.
|
|
@@ -343,7 +475,9 @@ class OpenBadgeCredential:
|
|
|
343
475
|
issuance_date=issuance_date,
|
|
344
476
|
expiration_date=expiration_date,
|
|
345
477
|
evidence_url=evidence_url,
|
|
478
|
+
evidence=evidence_list,
|
|
346
479
|
credential_status=credential_status,
|
|
480
|
+
endorsement_jwts=_string_list(vc.get("endorsementJwt")),
|
|
347
481
|
)
|
|
348
482
|
# Keep the validated document so the caller can read fields the model
|
|
349
483
|
# does not map (see the ``raw`` field). This is the JWT-VC payload or
|
|
@@ -364,6 +498,27 @@ def _as_dict_or_empty(value: Any) -> dict[str, Any]:
|
|
|
364
498
|
return value if isinstance(value, dict) else {}
|
|
365
499
|
|
|
366
500
|
|
|
501
|
+
def _float_or_none(value: Any) -> Optional[float]:
|
|
502
|
+
"""Coerce a JSON number to float, else None (for optional numeric fields
|
|
503
|
+
like creditsAvailable). A bool is not a number here."""
|
|
504
|
+
if isinstance(value, bool):
|
|
505
|
+
return None
|
|
506
|
+
if isinstance(value, (int, float)):
|
|
507
|
+
return float(value)
|
|
508
|
+
return None
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _string_list(value: Any) -> List[str]:
|
|
512
|
+
"""Return the string members of *value* when it is a list, else an empty
|
|
513
|
+
list. `endorsementJwt` is an array of compact JWT strings; a single string
|
|
514
|
+
is also accepted (tolerant read of a producer that omitted the array)."""
|
|
515
|
+
if isinstance(value, str):
|
|
516
|
+
return [value]
|
|
517
|
+
if isinstance(value, list):
|
|
518
|
+
return [item for item in value if isinstance(item, str)]
|
|
519
|
+
return []
|
|
520
|
+
|
|
521
|
+
|
|
367
522
|
def _require(data: dict[str, Any], key: str, where: str) -> str:
|
|
368
523
|
"""Return data[key] as a string, raising a clear ValueError if missing,
|
|
369
524
|
empty, or not a string. All fields validated here (id/name) are identifiers
|
|
@@ -26,7 +26,7 @@ from typing import Any, Optional, Union
|
|
|
26
26
|
import jwt
|
|
27
27
|
import jwt.exceptions
|
|
28
28
|
|
|
29
|
-
from .credential import OpenBadgeCredential
|
|
29
|
+
from .credential import OpenBadgeCredential, _parse_iso
|
|
30
30
|
from ..errors import ErrorParsingFile, UnknownKeyType, LibOpenBadgesException
|
|
31
31
|
from ..keys import KeyType, detect_key_type, key_to_pem
|
|
32
32
|
from ..util import normalize_recipient_id, recipient_ids_match
|
|
@@ -382,6 +382,19 @@ class OB3Verifier:
|
|
|
382
382
|
|
|
383
383
|
# ── token extraction ───────────────────────────────────────────────────────
|
|
384
384
|
|
|
385
|
+
@staticmethod
|
|
386
|
+
def _decode_unverified(token: str) -> dict[str, Any]:
|
|
387
|
+
"""Decode a JWT payload without checking the signature (to read the
|
|
388
|
+
issuer before the key is resolved). Raises OB3VerificationError on a
|
|
389
|
+
malformed token."""
|
|
390
|
+
try:
|
|
391
|
+
payload = jwt.decode(token, options={"verify_signature": False})
|
|
392
|
+
except jwt.exceptions.DecodeError as exc:
|
|
393
|
+
raise OB3VerificationError("malformed JWT: %s" % exc) from exc
|
|
394
|
+
if not isinstance(payload, dict):
|
|
395
|
+
raise OB3VerificationError("JWT payload is not an object")
|
|
396
|
+
return payload
|
|
397
|
+
|
|
385
398
|
@staticmethod
|
|
386
399
|
def extract_token_from_svg(svg_bytes: bytes) -> str:
|
|
387
400
|
"""Extract the embedded credential from a baked SVG badge.
|
|
@@ -418,3 +431,88 @@ class OB3Verifier:
|
|
|
418
431
|
if token is None:
|
|
419
432
|
raise OB3VerificationError("No openbadgecredential iTXt chunk found in PNG")
|
|
420
433
|
return token
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
# ── endorsements (OB 3.0 endorsementJwt, errata v1.6) ────────────────────────
|
|
437
|
+
|
|
438
|
+
def verify_endorsement_jwt(token: str, download: Any = None,
|
|
439
|
+
endorser_pubkey_pem: Any = None) -> dict[str, Any]:
|
|
440
|
+
"""Verify a compact EndorsementCredential JWT (OB 3.0 ``endorsementJwt``).
|
|
441
|
+
|
|
442
|
+
An endorsement is a Verifiable Credential a **third party** (the endorser,
|
|
443
|
+
not the badge issuer) signs to vouch for an achievement, issuer or
|
|
444
|
+
credential. This verifies its signature under the endorser's key — resolved
|
|
445
|
+
from the endorsement's own issuer DID (``did:web``/``did:key``), or
|
|
446
|
+
``endorser_pubkey_pem`` when the endorser is not a DID — checks it is an
|
|
447
|
+
``EndorsementCredential`` whose validFrom/validUntil window is current, and
|
|
448
|
+
returns ``{id, issuer, endorses, comment}``.
|
|
449
|
+
|
|
450
|
+
Raises :class:`OB3VerificationError` on any failure (malformed token, bad
|
|
451
|
+
signature, wrong type, expired, or an endorser that is neither a DID nor
|
|
452
|
+
covered by ``endorser_pubkey_pem``). The verifier machinery is reused, but
|
|
453
|
+
OB3Verifier.verify() cannot: an EndorsementCredential is not an
|
|
454
|
+
OpenBadgeCredential, so its type check would reject it.
|
|
455
|
+
"""
|
|
456
|
+
if not isinstance(token, str) or token.lstrip().startswith('{'):
|
|
457
|
+
raise OB3VerificationError(
|
|
458
|
+
"endorsementJwt must be a compact JWT string")
|
|
459
|
+
|
|
460
|
+
unverified = OB3Verifier._decode_unverified(token)
|
|
461
|
+
endorser = _claim_object_id(unverified.get("issuer")) or unverified.get("iss")
|
|
462
|
+
|
|
463
|
+
if endorser_pubkey_pem is not None:
|
|
464
|
+
verifier = OB3Verifier(pubkey_pem=endorser_pubkey_pem)
|
|
465
|
+
elif isinstance(endorser, str) and endorser.startswith("did:"):
|
|
466
|
+
verifier = OB3Verifier.for_issuer_did(endorser, download=download)
|
|
467
|
+
else:
|
|
468
|
+
raise OB3VerificationError(
|
|
469
|
+
"cannot verify endorsement: its issuer %r is not a DID — pass "
|
|
470
|
+
"endorser_pubkey_pem" % (endorser,))
|
|
471
|
+
|
|
472
|
+
payload = verifier._decode_payload(token) # signature + algorithm pin
|
|
473
|
+
|
|
474
|
+
types = payload.get("type", [])
|
|
475
|
+
if isinstance(types, str):
|
|
476
|
+
types = [types]
|
|
477
|
+
if "EndorsementCredential" not in (types if isinstance(types, list) else []):
|
|
478
|
+
raise OB3VerificationError(
|
|
479
|
+
"not an EndorsementCredential (type=%r)" % (payload.get("type"),))
|
|
480
|
+
|
|
481
|
+
_check_endorsement_window(payload)
|
|
482
|
+
|
|
483
|
+
subject = payload.get("credentialSubject")
|
|
484
|
+
if isinstance(subject, list):
|
|
485
|
+
subject = subject[0] if subject else {}
|
|
486
|
+
if not isinstance(subject, dict):
|
|
487
|
+
subject = {}
|
|
488
|
+
return {
|
|
489
|
+
"id": payload.get("id"),
|
|
490
|
+
"issuer": endorser,
|
|
491
|
+
"endorses": subject.get("id"),
|
|
492
|
+
"comment": subject.get("endorsementComment"),
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _check_endorsement_window(vc: dict[str, Any]) -> None:
|
|
497
|
+
"""Reject an endorsement whose validFrom/validUntil window is not current
|
|
498
|
+
(the JWT exp is also checked in _decode_payload, but a producer may set a
|
|
499
|
+
vc-level bound without the registered claim)."""
|
|
500
|
+
now = datetime.now(timezone.utc)
|
|
501
|
+
valid_until = vc.get("validUntil")
|
|
502
|
+
if isinstance(valid_until, str):
|
|
503
|
+
try:
|
|
504
|
+
expires = _parse_iso(valid_until)
|
|
505
|
+
except Exception:
|
|
506
|
+
expires = None
|
|
507
|
+
if expires is not None and expires < now:
|
|
508
|
+
raise OB3VerificationError(
|
|
509
|
+
"endorsement expired (validUntil %s)" % valid_until)
|
|
510
|
+
valid_from = vc.get("validFrom")
|
|
511
|
+
if isinstance(valid_from, str):
|
|
512
|
+
try:
|
|
513
|
+
starts = _parse_iso(valid_from)
|
|
514
|
+
except Exception:
|
|
515
|
+
starts = None
|
|
516
|
+
if starts is not None and starts > now:
|
|
517
|
+
raise OB3VerificationError(
|
|
518
|
+
"endorsement is not yet valid (validFrom %s)" % valid_from)
|
|
@@ -435,6 +435,11 @@ def _verify_ob3(args: argparse.Namespace) -> None:
|
|
|
435
435
|
result['expires'] = (credential.expiration_date.isoformat()
|
|
436
436
|
if credential.expiration_date else None)
|
|
437
437
|
result['evidence'] = credential.evidence_url
|
|
438
|
+
# Surface how many EndorsementCredential JWTs the credential carries (on
|
|
439
|
+
# itself, its issuer or its achievement) so they are no longer invisible.
|
|
440
|
+
# They are third-party VCs; verify each with ob3.verify_endorsement_jwt.
|
|
441
|
+
endorsement_jwts = credential.all_endorsement_jwts()
|
|
442
|
+
result['endorsements'] = len(endorsement_jwts)
|
|
438
443
|
|
|
439
444
|
if args.show and not args.json:
|
|
440
445
|
print('[+] Credential issuer : %s' % credential.issuer.name)
|
|
@@ -445,6 +450,9 @@ def _verify_ob3(args: argparse.Namespace) -> None:
|
|
|
445
450
|
print('[+] Expires : %s' % credential.expiration_date.isoformat())
|
|
446
451
|
if credential.evidence_url:
|
|
447
452
|
print('[+] Evidence : %s' % credential.evidence_url)
|
|
453
|
+
if endorsement_jwts:
|
|
454
|
+
print('[+] Endorsements : %d (verify with '
|
|
455
|
+
'ob3.verify_endorsement_jwt)' % len(endorsement_jwts))
|
|
448
456
|
|
|
449
457
|
if result['trusted']:
|
|
450
458
|
result['reason'] = None
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openbadgeslib
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.9.0
|
|
4
4
|
Summary: Production-ready Python library & CLI for Open Badges 3.0 (W3C Verifiable Credentials): issue, verify, and revoke JWT-VC credentials with Bitstring Status Lists and did:web — plus strict OB 2.0 and legacy OB 1.0.
|
|
5
5
|
Author-email: Luis González Fernández <luisgf@luisgf.es>, Jesús Cea Avión <jcea@jcea.es>
|
|
6
6
|
License: LGPLv3
|
|
@@ -330,3 +330,121 @@ class TestNaiveDatetimeAssumedUtc:
|
|
|
330
330
|
cred = _make_credential(expiration_date=datetime(2030, 6, 1, 0, 0, 0))
|
|
331
331
|
assert cred.expiration_date.tzinfo is not None
|
|
332
332
|
assert cred.to_vc()['validUntil'] == '2030-06-01T00:00:00Z'
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
# ── #162: broadened model — Evidence, Alignment, achievementType, credits ────
|
|
336
|
+
|
|
337
|
+
from openbadgeslib.ob3.credential import Alignment, Evidence # noqa: E402
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
class TestBroadenedModel:
|
|
341
|
+
def _credential(self, **achievement_kw):
|
|
342
|
+
return OpenBadgeCredential(
|
|
343
|
+
issuer=Issuer(id='https://issuer.example', name='I'),
|
|
344
|
+
recipient_id='mailto:r@example.com',
|
|
345
|
+
achievement=Achievement(id='https://a.example/1', name='A',
|
|
346
|
+
description='d', criteria_narrative='c',
|
|
347
|
+
**achievement_kw))
|
|
348
|
+
|
|
349
|
+
def test_alignment_round_trip(self):
|
|
350
|
+
cred = self._credential(alignments=[
|
|
351
|
+
Alignment(target_name='Competency X',
|
|
352
|
+
target_url='https://framework.example/x',
|
|
353
|
+
target_framework='EU Framework', target_code='X.1'),
|
|
354
|
+
])
|
|
355
|
+
vc = cred.to_vc()
|
|
356
|
+
assert vc['credentialSubject']['achievement']['alignment'] == [{
|
|
357
|
+
'type': ['Alignment'],
|
|
358
|
+
'targetName': 'Competency X',
|
|
359
|
+
'targetUrl': 'https://framework.example/x',
|
|
360
|
+
'targetFramework': 'EU Framework',
|
|
361
|
+
'targetCode': 'X.1',
|
|
362
|
+
}]
|
|
363
|
+
again = OpenBadgeCredential.from_vc_document(vc)
|
|
364
|
+
assert again.achievement.alignments[0].target_name == 'Competency X'
|
|
365
|
+
assert again.achievement.alignments[0].target_code == 'X.1'
|
|
366
|
+
|
|
367
|
+
def test_achievement_type_and_credits_round_trip(self):
|
|
368
|
+
cred = self._credential(achievement_type='Competency',
|
|
369
|
+
credits_available=3.5)
|
|
370
|
+
ach = cred.to_vc()['credentialSubject']['achievement']
|
|
371
|
+
assert ach['achievementType'] == 'Competency'
|
|
372
|
+
assert ach['creditsAvailable'] == 3.5
|
|
373
|
+
again = OpenBadgeCredential.from_vc_document(cred.to_vc())
|
|
374
|
+
assert again.achievement.achievement_type == 'Competency'
|
|
375
|
+
assert again.achievement.credits_available == 3.5
|
|
376
|
+
|
|
377
|
+
def test_evidence_list_round_trip(self):
|
|
378
|
+
cred = OpenBadgeCredential(
|
|
379
|
+
issuer=Issuer(id='https://issuer.example', name='I'),
|
|
380
|
+
recipient_id='mailto:r@example.com',
|
|
381
|
+
achievement=Achievement(id='https://a.example/1', name='A',
|
|
382
|
+
description='d', criteria_narrative='c'),
|
|
383
|
+
evidence=[
|
|
384
|
+
Evidence(id='https://ev.example/1', narrative='Did the thing',
|
|
385
|
+
name='Project'),
|
|
386
|
+
Evidence(narrative='No URL, just a narrative'),
|
|
387
|
+
])
|
|
388
|
+
vc = cred.to_vc()
|
|
389
|
+
assert len(vc['evidence']) == 2
|
|
390
|
+
assert vc['evidence'][0] == {
|
|
391
|
+
'type': ['Evidence'], 'id': 'https://ev.example/1',
|
|
392
|
+
'narrative': 'Did the thing', 'name': 'Project'}
|
|
393
|
+
assert vc['evidence'][1] == {
|
|
394
|
+
'type': ['Evidence'], 'narrative': 'No URL, just a narrative'}
|
|
395
|
+
again = OpenBadgeCredential.from_vc_document(vc)
|
|
396
|
+
assert [e.narrative for e in again.evidence] == \
|
|
397
|
+
['Did the thing', 'No URL, just a narrative']
|
|
398
|
+
# backward-compatible convenience: first evidence URL
|
|
399
|
+
assert again.evidence_url == 'https://ev.example/1'
|
|
400
|
+
|
|
401
|
+
def test_evidence_url_still_works(self):
|
|
402
|
+
# The single-URL convenience keeps issuing one Evidence object.
|
|
403
|
+
cred = self._credential()
|
|
404
|
+
cred.evidence_url = 'https://ev.example/only'
|
|
405
|
+
vc = cred.to_vc()
|
|
406
|
+
assert vc['evidence'] == [
|
|
407
|
+
{'type': ['Evidence'], 'id': 'https://ev.example/only'}]
|
|
408
|
+
|
|
409
|
+
def test_parse_from_raw_vc(self):
|
|
410
|
+
vc = {
|
|
411
|
+
'@context': OB3_CONTEXT,
|
|
412
|
+
'id': 'urn:uuid:00000000-0000-0000-0000-0000000000f1',
|
|
413
|
+
'type': ['VerifiableCredential', 'OpenBadgeCredential'],
|
|
414
|
+
'issuer': 'https://issuer.example',
|
|
415
|
+
'validFrom': '2024-01-01T00:00:00Z',
|
|
416
|
+
'credentialSubject': {
|
|
417
|
+
'id': 'mailto:r@example.com',
|
|
418
|
+
'type': ['AchievementSubject'],
|
|
419
|
+
'achievement': {
|
|
420
|
+
'id': 'https://a.example/1', 'type': ['Achievement'],
|
|
421
|
+
'name': 'A', 'description': 'd',
|
|
422
|
+
'criteria': {'narrative': 'c'},
|
|
423
|
+
'achievementType': 'Certificate',
|
|
424
|
+
'creditsAvailable': 2,
|
|
425
|
+
'alignment': [{'type': ['Alignment'],
|
|
426
|
+
'targetName': 'C', 'targetUrl': 'https://f/x'}],
|
|
427
|
+
},
|
|
428
|
+
},
|
|
429
|
+
'evidence': [{'type': ['Evidence'], 'id': 'https://ev/1',
|
|
430
|
+
'narrative': 'n'}],
|
|
431
|
+
}
|
|
432
|
+
cred = OpenBadgeCredential.from_vc_document(vc)
|
|
433
|
+
assert cred.achievement.achievement_type == 'Certificate'
|
|
434
|
+
assert cred.achievement.credits_available == 2.0
|
|
435
|
+
assert cred.achievement.alignments[0].target_url == 'https://f/x'
|
|
436
|
+
assert cred.evidence[0].narrative == 'n'
|
|
437
|
+
|
|
438
|
+
def test_signed_credential_preserves_new_fields(self, rsa_priv_pem,
|
|
439
|
+
rsa_pub_pem):
|
|
440
|
+
# End-to-end: the fields survive signing and verification.
|
|
441
|
+
from openbadgeslib.ob3 import OB3Signer, OB3Verifier
|
|
442
|
+
cred = self._credential(achievement_type='Competency',
|
|
443
|
+
credits_available=1.0,
|
|
444
|
+
alignments=[Alignment(
|
|
445
|
+
target_name='Skill',
|
|
446
|
+
target_url='https://f/skill')])
|
|
447
|
+
token = OB3Signer(privkey_pem=rsa_priv_pem, algorithm='RS256').sign(cred)
|
|
448
|
+
verified = OB3Verifier(pubkey_pem=rsa_pub_pem).verify(token)
|
|
449
|
+
assert verified.achievement.achievement_type == 'Competency'
|
|
450
|
+
assert verified.achievement.alignments[0].target_name == 'Skill'
|