tba-protocol-gnap 0.4.0.dev2__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,17 @@
1
+ Metadata-Version: 2.4
2
+ Name: tba-protocol-gnap
3
+ Version: 0.4.0.dev2
4
+ Summary: Versioned RFC 9635 Grant Negotiation and Authorization Protocol mapping.
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.10,<3.15
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3.10
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Programming Language :: Python :: 3.14
13
+ Requires-Dist: tba-capability-delegated-access-negotiation (==0.4.0.dev2)
14
+ Requires-Dist: tba-concrete-claim-assertions (==0.4.0.dev2)
15
+ Requires-Dist: tba-concrete-claim-subject-identifiers (==0.4.0.dev2)
16
+ Requires-Dist: tba-concrete-claim-updated-at-rfc3339 (==0.4.0.dev2)
17
+ Requires-Dist: tba-contracts-identity (==0.4.0.dev2)
@@ -0,0 +1,18 @@
1
+ [project]
2
+ name = "tba-protocol-gnap"
3
+ version = "0.4.0.dev2"
4
+ description = "Versioned RFC 9635 Grant Negotiation and Authorization Protocol mapping."
5
+ requires-python = ">=3.10,<3.15"
6
+ license = "Apache-2.0"
7
+ dependencies = [
8
+ "tba-concrete-claim-assertions==0.4.0.dev2",
9
+ "tba-concrete-claim-subject-identifiers==0.4.0.dev2",
10
+ "tba-concrete-claim-updated-at-rfc3339==0.4.0.dev2",
11
+ "tba-capability-delegated-access-negotiation==0.4.0.dev2",
12
+ "tba-contracts-identity==0.4.0.dev2",
13
+ ]
14
+ [build-system]
15
+ requires = ["poetry-core>=1.0.0"]
16
+ build-backend = "poetry.core.masonry.api"
17
+ [tool.poetry]
18
+ packages = [{ include = "tba_protocol_gnap", from = "src" }]
@@ -0,0 +1,55 @@
1
+ """Versioned RFC 9635 GNAP protocol ownership."""
2
+
3
+ from tba_contracts_identity.protocol_processing import (
4
+ build_protocol_capability_report,
5
+ )
6
+
7
+ from .bindings import CAPABILITY_REQUIREMENTS
8
+ from .claims import GNAP_SUBJECT_CLAIM_CLASSES, compose_gnap_subject_claim_set
9
+ from .compatibility import COMPATIBILITY_PATHS, GnapCompatibility, compatibility
10
+ from .errors import (
11
+ GnapOperationUnavailableError,
12
+ GnapProtocolError,
13
+ GnapSchemaError,
14
+ )
15
+ from .features import FEATURES_BY_VERSION, supports
16
+ from .migrations import migrate_request
17
+ from .protocol import GnapProtocol
18
+ from .schemas import GnapRequest, parse_gnap_request, serialize_gnap_result
19
+ from .versions import CURRENT_VERSION, VERSION_HISTORY, GnapVersion, select_version
20
+
21
+
22
+ def capability_report() -> dict[str, object]:
23
+ return build_protocol_capability_report(
24
+ protocol="gnap",
25
+ revision=CURRENT_VERSION.identifier,
26
+ features=tuple(FEATURES_BY_VERSION[CURRENT_VERSION.identifier]),
27
+ evidence_links=("tests/unit/test_versioned_gnap_protocol.py",),
28
+ extra_requirements=CAPABILITY_REQUIREMENTS,
29
+ include_default_artifact_requirements=False,
30
+ )
31
+
32
+
33
+ __all__ = [
34
+ "CAPABILITY_REQUIREMENTS",
35
+ "COMPATIBILITY_PATHS",
36
+ "CURRENT_VERSION",
37
+ "FEATURES_BY_VERSION",
38
+ "GNAP_SUBJECT_CLAIM_CLASSES",
39
+ "VERSION_HISTORY",
40
+ "GnapCompatibility",
41
+ "GnapOperationUnavailableError",
42
+ "GnapProtocol",
43
+ "GnapProtocolError",
44
+ "GnapRequest",
45
+ "GnapSchemaError",
46
+ "GnapVersion",
47
+ "capability_report",
48
+ "compatibility",
49
+ "compose_gnap_subject_claim_set",
50
+ "migrate_request",
51
+ "parse_gnap_request",
52
+ "select_version",
53
+ "serialize_gnap_result",
54
+ "supports",
55
+ ]
@@ -0,0 +1,57 @@
1
+ """RFC 9635 wire operations mapped to reportable capabilities."""
2
+
3
+ from tba_contracts_identity.capabilities import ProtocolCapabilityRequirement
4
+
5
+ from .versions import CURRENT_VERSION
6
+
7
+ CAPABILITY_REQUIREMENTS = (
8
+ ProtocolCapabilityRequirement(
9
+ "gnap",
10
+ CURRENT_VERSION.identifier,
11
+ "grant-request",
12
+ "grant endpoint POST",
13
+ "grant.negotiation",
14
+ "request_grant",
15
+ "grant-negotiation-result",
16
+ ),
17
+ ProtocolCapabilityRequirement(
18
+ "gnap",
19
+ CURRENT_VERSION.identifier,
20
+ "grant-continuation",
21
+ "continuation endpoint POST/PATCH/DELETE",
22
+ "grant.negotiation",
23
+ "continue_grant",
24
+ "grant-negotiation-result",
25
+ False,
26
+ ),
27
+ ProtocolCapabilityRequirement(
28
+ "gnap",
29
+ CURRENT_VERSION.identifier,
30
+ "access-token-rotation",
31
+ "token management rotate",
32
+ "grant.negotiation",
33
+ "rotate_access_token",
34
+ "rotated-access-token",
35
+ False,
36
+ ),
37
+ ProtocolCapabilityRequirement(
38
+ "gnap",
39
+ CURRENT_VERSION.identifier,
40
+ "client-key-proof",
41
+ "GNAP request signature",
42
+ "artifact.processing",
43
+ "validate",
44
+ "verified-gnap-key-proof",
45
+ ),
46
+ ProtocolCapabilityRequirement(
47
+ "gnap",
48
+ CURRENT_VERSION.identifier,
49
+ "request-replay",
50
+ "GNAP request digest and proof",
51
+ "security.replay-protection",
52
+ "check_and_reserve",
53
+ "reserved-gnap-request",
54
+ ),
55
+ )
56
+
57
+ __all__ = ["CAPABILITY_REQUIREMENTS"]
@@ -0,0 +1,28 @@
1
+ """RFC 9635 subject-information claim composition."""
2
+
3
+ from tba_concrete_claim_assertions import AssertionsClaim
4
+ from tba_concrete_claim_subject_identifiers import SubjectIdentifiersClaim
5
+ from tba_concrete_claim_updated_at_rfc3339 import UpdatedAtRfc3339Claim
6
+ from tba_contracts_identity.claims import ClaimSet
7
+
8
+ from .versions import CURRENT_VERSION
9
+
10
+ GNAP_SUBJECT_CLAIM_CLASSES = (
11
+ SubjectIdentifiersClaim,
12
+ AssertionsClaim,
13
+ UpdatedAtRfc3339Claim,
14
+ )
15
+
16
+
17
+ def compose_gnap_subject_claim_set(*claims: object) -> ClaimSet:
18
+ unexpected = [
19
+ claim.name
20
+ for claim in claims
21
+ if not isinstance(claim, GNAP_SUBJECT_CLAIM_CLASSES)
22
+ ]
23
+ if unexpected:
24
+ raise ValueError(f"claims are not RFC 9635 subject fields: {unexpected}")
25
+ return ClaimSet(tuple(claims), "gnap-subject", CURRENT_VERSION.identifier)
26
+
27
+
28
+ __all__ = ["GNAP_SUBJECT_CLAIM_CLASSES", "compose_gnap_subject_claim_set"]
@@ -0,0 +1,38 @@
1
+ """GNAP draft-to-RFC compatibility paths."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from .versions import CURRENT_VERSION, VERSION_HISTORY
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class GnapCompatibility:
10
+ source: str
11
+ target: str
12
+ compatible: bool
13
+ lossless: bool
14
+ migration_required: bool
15
+
16
+
17
+ COMPATIBILITY_PATHS = tuple(
18
+ GnapCompatibility(
19
+ version.identifier,
20
+ CURRENT_VERSION.identifier,
21
+ True,
22
+ True,
23
+ version.identifier != CURRENT_VERSION.identifier,
24
+ )
25
+ for version in VERSION_HISTORY
26
+ )
27
+
28
+
29
+ def compatibility(
30
+ source: str, target: str = CURRENT_VERSION.identifier
31
+ ) -> GnapCompatibility:
32
+ for path in COMPATIBILITY_PATHS:
33
+ if path.source == source and path.target == target:
34
+ return path
35
+ return GnapCompatibility(source, target, False, False, False)
36
+
37
+
38
+ __all__ = ["COMPATIBILITY_PATHS", "GnapCompatibility", "compatibility"]
@@ -0,0 +1,20 @@
1
+ """RFC 9635 protocol mapping errors."""
2
+
3
+
4
+ class GnapProtocolError(ValueError):
5
+ """Base error for invalid GNAP protocol material."""
6
+
7
+
8
+ class GnapSchemaError(GnapProtocolError):
9
+ """Raised when a GNAP request or response has an invalid shape."""
10
+
11
+
12
+ class GnapOperationUnavailableError(GnapProtocolError):
13
+ """Raised when an optional grant lifecycle operation is unavailable."""
14
+
15
+
16
+ __all__ = [
17
+ "GnapOperationUnavailableError",
18
+ "GnapProtocolError",
19
+ "GnapSchemaError",
20
+ ]
@@ -0,0 +1,41 @@
1
+ FEATURES_BY_VERSION = {
2
+ "draft-13": frozenset(
3
+ {"access-token-request", "client-instance", "interaction", "continuation"}
4
+ ),
5
+ "draft-ietf-gnap-core-protocol-20": frozenset(
6
+ {
7
+ "access-token-request",
8
+ "client-instance",
9
+ "interaction",
10
+ "continuation",
11
+ "subject-information",
12
+ "token-rotation",
13
+ }
14
+ ),
15
+ "RFC9635": frozenset(
16
+ {
17
+ "access-token-request",
18
+ "client-instance",
19
+ "interaction",
20
+ "continuation",
21
+ "subject-information",
22
+ "token-rotation",
23
+ "key-proofing",
24
+ "multiple-access-tokens",
25
+ "grant-modification",
26
+ "token-management",
27
+ "authorization-server-discovery",
28
+ "subject-identifiers-rfc9493",
29
+ }
30
+ ),
31
+ }
32
+
33
+
34
+ def supports(feature: str, version: str = "RFC9635") -> bool:
35
+ try:
36
+ return feature in FEATURES_BY_VERSION[version]
37
+ except KeyError as exc:
38
+ raise ValueError(f"unsupported GNAP version: {version}") from exc
39
+
40
+
41
+ __all__ = ["FEATURES_BY_VERSION", "supports"]
@@ -0,0 +1,19 @@
1
+ from typing import Any, Mapping
2
+
3
+
4
+ def migrate_request(
5
+ value: Mapping[str, Any], *, source: str, target: str = "RFC9635"
6
+ ) -> dict[str, Any]:
7
+ if source == target:
8
+ return dict(value)
9
+ if (
10
+ source not in {"draft-13", "draft-ietf-gnap-core-protocol-20"}
11
+ or target != "RFC9635"
12
+ ):
13
+ raise ValueError(f"unsupported GNAP migration: {source} -> {target}")
14
+ # The supported draft request members retained their RFC 9635 wire names.
15
+ # Migration metadata is deliberately not injected into protocol payloads.
16
+ return dict(value)
17
+
18
+
19
+ __all__ = ["migrate_request"]
@@ -0,0 +1,43 @@
1
+ """RFC 9635 mapping to the neutral grant-negotiation capability."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+
8
+ from tba_capability_delegated_access_negotiation import GrantNegotiationCapability
9
+ from tba_contracts_identity.gnap import GrantContinuationRequest
10
+
11
+ from .schemas import GnapRequest, parse_gnap_request, serialize_gnap_result
12
+
13
+
14
+ class GnapProtocol:
15
+ version = "RFC9635"
16
+
17
+ def __init__(self, capability: GrantNegotiationCapability):
18
+ self._capability = capability
19
+
20
+ async def grant(self, payload: Mapping[str, Any]) -> dict[str, object]:
21
+ request = parse_gnap_request(payload).to_contract()
22
+ return serialize_gnap_result(await self._capability.request(request))
23
+
24
+ async def continue_grant(
25
+ self,
26
+ continuation_token: str,
27
+ *,
28
+ proof: str | bytes | Mapping[str, object] | None = None,
29
+ ) -> dict[str, object]:
30
+ request = GrantContinuationRequest(continuation_token, proof)
31
+ return serialize_gnap_result(await self._capability.continue_request(request))
32
+
33
+ async def rotate_access_token(
34
+ self,
35
+ management_token: str,
36
+ *,
37
+ proof: str | bytes | Mapping[str, object] | None = None,
38
+ ) -> dict[str, object]:
39
+ request = GrantContinuationRequest(management_token, proof)
40
+ return serialize_gnap_result(await self._capability.rotate(request))
41
+
42
+
43
+ __all__ = ["GnapProtocol", "GnapRequest", "parse_gnap_request"]
@@ -0,0 +1,119 @@
1
+ """RFC 9635 request and response schema normalization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ from tba_contracts_identity.gnap import (
10
+ GrantAccessRequest,
11
+ GrantNegotiationRequest,
12
+ GrantNegotiationResult,
13
+ )
14
+
15
+ from .errors import GnapSchemaError
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class GnapRequest:
20
+ access_token: tuple[Mapping[str, Any], ...]
21
+ client: Mapping[str, Any] | str
22
+ interact: Mapping[str, Any] | None = None
23
+ subject: Mapping[str, Any] | None = None
24
+
25
+ def to_contract(self) -> GrantNegotiationRequest:
26
+ requests = tuple(
27
+ GrantAccessRequest(
28
+ tuple(item["access"]),
29
+ label=item.get("label"),
30
+ flags=tuple(item.get("flags", ())),
31
+ )
32
+ for item in self.access_token
33
+ )
34
+ return GrantNegotiationRequest(
35
+ requests,
36
+ self.client,
37
+ dict(self.interact) if self.interact is not None else None,
38
+ dict(self.subject) if self.subject is not None else None,
39
+ )
40
+
41
+
42
+ def _access_requests(value: object) -> tuple[Mapping[str, Any], ...]:
43
+ if value is None:
44
+ return ()
45
+ items = (value,) if isinstance(value, Mapping) else value
46
+ if not isinstance(items, Sequence) or isinstance(items, (str, bytes)):
47
+ raise GnapSchemaError("GNAP access_token must be an object or array")
48
+ requests = []
49
+ for item in items:
50
+ if not isinstance(item, Mapping):
51
+ raise GnapSchemaError("GNAP access_token entries must be objects")
52
+ access = item.get("access")
53
+ if (
54
+ not isinstance(access, Sequence)
55
+ or isinstance(access, (str, bytes))
56
+ or not access
57
+ ):
58
+ raise GnapSchemaError("GNAP access token request requires access")
59
+ label = item.get("label")
60
+ if label is not None and not isinstance(label, str):
61
+ raise GnapSchemaError("GNAP access token label must be a string")
62
+ flags = item.get("flags", ())
63
+ if (
64
+ not isinstance(flags, Sequence)
65
+ or isinstance(flags, (str, bytes))
66
+ or any(not isinstance(flag, str) for flag in flags)
67
+ or len(set(flags)) != len(flags)
68
+ ):
69
+ raise GnapSchemaError("GNAP access token flags must be unique strings")
70
+ requests.append(dict(item))
71
+ labels = [item.get("label") for item in requests if item.get("label") is not None]
72
+ if len(requests) > 1 and len(labels) != len(requests):
73
+ raise GnapSchemaError("multiple GNAP access token requests require labels")
74
+ if len(set(labels)) != len(labels):
75
+ raise GnapSchemaError("GNAP access token labels must be unique")
76
+ return tuple(requests)
77
+
78
+
79
+ def parse_gnap_request(value: Mapping[str, Any]) -> GnapRequest:
80
+ access = _access_requests(value.get("access_token"))
81
+ subject = value.get("subject")
82
+ if subject is not None and not isinstance(subject, Mapping):
83
+ raise GnapSchemaError("GNAP subject must be an object")
84
+ if not access and subject is None:
85
+ raise GnapSchemaError("GNAP request requires access_token or subject")
86
+ client = value.get("client")
87
+ if not isinstance(client, (Mapping, str)):
88
+ raise GnapSchemaError("GNAP request requires a client instance")
89
+ interact = value.get("interact")
90
+ if interact is not None and not isinstance(interact, Mapping):
91
+ raise GnapSchemaError("GNAP interact must be an object")
92
+ return GnapRequest(
93
+ access,
94
+ dict(client) if isinstance(client, Mapping) else client,
95
+ dict(interact) if isinstance(interact, Mapping) else None,
96
+ dict(subject) if isinstance(subject, Mapping) else None,
97
+ )
98
+
99
+
100
+ def serialize_gnap_result(result: GrantNegotiationResult) -> dict[str, object]:
101
+ payload: dict[str, object] = {}
102
+ if result.continuation is not None:
103
+ payload["continue"] = dict(result.continuation)
104
+ if result.access_tokens:
105
+ payload["access_token"] = (
106
+ dict(result.access_tokens[0])
107
+ if len(result.access_tokens) == 1
108
+ else [dict(token) for token in result.access_tokens]
109
+ )
110
+ if result.interaction is not None:
111
+ payload["interact"] = dict(result.interaction)
112
+ if result.subject is not None:
113
+ payload["subject"] = dict(result.subject)
114
+ if result.metadata:
115
+ payload.update(result.metadata)
116
+ return payload
117
+
118
+
119
+ __all__ = ["GnapRequest", "parse_gnap_request", "serialize_gnap_result"]
@@ -0,0 +1,26 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass(frozen=True, slots=True)
5
+ class GnapVersion:
6
+ identifier: str
7
+ status: str
8
+ published: str
9
+
10
+
11
+ VERSION_HISTORY = (
12
+ GnapVersion("draft-13", "superseded-draft", "2023-03-13"),
13
+ GnapVersion("draft-ietf-gnap-core-protocol-20", "superseded-draft", "2024-04-24"),
14
+ GnapVersion("RFC9635", "proposed-standard", "2024-10"),
15
+ )
16
+ CURRENT_VERSION = VERSION_HISTORY[-1]
17
+
18
+
19
+ def select_version(identifier: str = "RFC9635") -> GnapVersion:
20
+ for version in VERSION_HISTORY:
21
+ if version.identifier == identifier:
22
+ return version
23
+ raise ValueError(f"unsupported GNAP version: {identifier}")
24
+
25
+
26
+ __all__ = ["CURRENT_VERSION", "VERSION_HISTORY", "GnapVersion", "select_version"]