agent-enrollment-protocol 0.1.0__py3-none-any.whl
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.
- agent_enrollment_protocol/__init__.py +7 -0
- agent_enrollment_protocol/adapters/__init__.py +29 -0
- agent_enrollment_protocol/adapters/asgi.py +445 -0
- agent_enrollment_protocol/agent/__init__.py +96 -0
- agent_enrollment_protocol/agent/client.py +934 -0
- agent_enrollment_protocol/agent/platform_provider.py +659 -0
- agent_enrollment_protocol/agent/stores.py +101 -0
- agent_enrollment_protocol/agent/transport.py +51 -0
- agent_enrollment_protocol/agent/types.py +218 -0
- agent_enrollment_protocol/core/__init__.py +250 -0
- agent_enrollment_protocol/core/assertions.py +141 -0
- agent_enrollment_protocol/core/claims.py +38 -0
- agent_enrollment_protocol/core/constants.py +46 -0
- agent_enrollment_protocol/core/did_web.py +77 -0
- agent_enrollment_protocol/core/errors.py +48 -0
- agent_enrollment_protocol/core/http.py +102 -0
- agent_enrollment_protocol/core/inspect.py +70 -0
- agent_enrollment_protocol/core/models.py +929 -0
- agent_enrollment_protocol/core/openapi.py +108 -0
- agent_enrollment_protocol/core/validation.py +77 -0
- agent_enrollment_protocol/platform/__init__.py +79 -0
- agent_enrollment_protocol/platform/document.py +158 -0
- agent_enrollment_protocol/platform/platform.py +674 -0
- agent_enrollment_protocol/platform/stores.py +219 -0
- agent_enrollment_protocol/platform/types.py +282 -0
- agent_enrollment_protocol/py.typed +1 -0
- agent_enrollment_protocol/service/__init__.py +95 -0
- agent_enrollment_protocol/service/credentials.py +355 -0
- agent_enrollment_protocol/service/service.py +871 -0
- agent_enrollment_protocol/service/stores.py +135 -0
- agent_enrollment_protocol/service/types.py +337 -0
- agent_enrollment_protocol-0.1.0.dist-info/METADATA +257 -0
- agent_enrollment_protocol-0.1.0.dist-info/RECORD +35 -0
- agent_enrollment_protocol-0.1.0.dist-info/WHEEL +4 -0
- agent_enrollment_protocol-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Optional framework and server integration surfaces."""
|
|
2
|
+
|
|
3
|
+
from .asgi import (
|
|
4
|
+
AEP_PRINCIPAL_SCOPE_KEY,
|
|
5
|
+
DEFAULT_INSPECT_CACHE_CONTROL,
|
|
6
|
+
DEFAULT_MAXIMUM_REQUEST_BODY_BYTES,
|
|
7
|
+
AepAsgiApplication,
|
|
8
|
+
AepAuthenticationMiddleware,
|
|
9
|
+
AsgiApplication,
|
|
10
|
+
AsgiMessage,
|
|
11
|
+
AsgiReceive,
|
|
12
|
+
AsgiScope,
|
|
13
|
+
AsgiSend,
|
|
14
|
+
principal_from_scope,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"AEP_PRINCIPAL_SCOPE_KEY",
|
|
19
|
+
"DEFAULT_INSPECT_CACHE_CONTROL",
|
|
20
|
+
"DEFAULT_MAXIMUM_REQUEST_BODY_BYTES",
|
|
21
|
+
"AepAsgiApplication",
|
|
22
|
+
"AepAuthenticationMiddleware",
|
|
23
|
+
"AsgiApplication",
|
|
24
|
+
"AsgiMessage",
|
|
25
|
+
"AsgiReceive",
|
|
26
|
+
"AsgiScope",
|
|
27
|
+
"AsgiSend",
|
|
28
|
+
"principal_from_scope",
|
|
29
|
+
]
|
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import ipaddress
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Awaitable, Callable, Iterable, Mapping, MutableMapping, Sequence
|
|
7
|
+
from typing import Any, Protocol
|
|
8
|
+
from urllib.parse import quote, quote_from_bytes, urlsplit, urlunsplit
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel
|
|
11
|
+
|
|
12
|
+
from agent_enrollment_protocol.core import (
|
|
13
|
+
AEP_MEDIA_TYPE,
|
|
14
|
+
AEP_PROBLEM_MEDIA_TYPE,
|
|
15
|
+
AEP_WELL_KNOWN_PATH,
|
|
16
|
+
AuthorizationCarrier,
|
|
17
|
+
AuthorizationScheme,
|
|
18
|
+
Command,
|
|
19
|
+
command_path_from_inspect,
|
|
20
|
+
media_type_essence,
|
|
21
|
+
parse_authorization,
|
|
22
|
+
)
|
|
23
|
+
from agent_enrollment_protocol.core.errors import AepAuthorizationError
|
|
24
|
+
from agent_enrollment_protocol.service import (
|
|
25
|
+
AuthenticatedPrincipal,
|
|
26
|
+
CommandOptions,
|
|
27
|
+
ProtectedResourceRequest,
|
|
28
|
+
Service,
|
|
29
|
+
ServiceResult,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
AsgiScope = MutableMapping[str, Any]
|
|
33
|
+
AsgiMessage = MutableMapping[str, Any]
|
|
34
|
+
AsgiReceive = Callable[[], Awaitable[AsgiMessage]]
|
|
35
|
+
AsgiSend = Callable[[AsgiMessage], Awaitable[None]]
|
|
36
|
+
|
|
37
|
+
DEFAULT_INSPECT_CACHE_CONTROL = "public, max-age=300"
|
|
38
|
+
DEFAULT_MAXIMUM_REQUEST_BODY_BYTES = 1 << 20
|
|
39
|
+
AEP_PRINCIPAL_SCOPE_KEY = "aep.principal"
|
|
40
|
+
_HTTP_TOKEN_CHARACTERS = frozenset(
|
|
41
|
+
"!#$%&'*+-.^_`|~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _RequestDisconnected(Exception):
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AsgiApplication(Protocol):
|
|
50
|
+
async def __call__(self, scope: AsgiScope, receive: AsgiReceive, send: AsgiSend) -> None: ...
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class AepAsgiApplication:
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
service: Service,
|
|
57
|
+
application: AsgiApplication | None = None,
|
|
58
|
+
*,
|
|
59
|
+
inspect_cache_control: str = DEFAULT_INSPECT_CACHE_CONTROL,
|
|
60
|
+
maximum_request_body_bytes: int = DEFAULT_MAXIMUM_REQUEST_BODY_BYTES,
|
|
61
|
+
) -> None:
|
|
62
|
+
if not isinstance(service, Service):
|
|
63
|
+
raise TypeError("AEP ASGI application requires a Service")
|
|
64
|
+
if (
|
|
65
|
+
not isinstance(maximum_request_body_bytes, int)
|
|
66
|
+
or isinstance(maximum_request_body_bytes, bool)
|
|
67
|
+
or maximum_request_body_bytes < 1
|
|
68
|
+
):
|
|
69
|
+
raise ValueError("AEP ASGI request body limit must be positive")
|
|
70
|
+
try:
|
|
71
|
+
encoded_cache_control = inspect_cache_control.encode("latin-1")
|
|
72
|
+
except (AttributeError, UnicodeEncodeError) as error:
|
|
73
|
+
raise ValueError(
|
|
74
|
+
"AEP Inspect Cache-Control must be a valid HTTP field value"
|
|
75
|
+
) from error
|
|
76
|
+
if (
|
|
77
|
+
not inspect_cache_control.strip()
|
|
78
|
+
or b"\r" in encoded_cache_control
|
|
79
|
+
or b"\n" in encoded_cache_control
|
|
80
|
+
):
|
|
81
|
+
raise ValueError("AEP Inspect Cache-Control must be a valid HTTP field value")
|
|
82
|
+
|
|
83
|
+
document = service.inspect_document
|
|
84
|
+
inspect_body = _json_bytes(document.to_wire())
|
|
85
|
+
self._application = application if application is not None else _default_application
|
|
86
|
+
self._command_paths = {
|
|
87
|
+
command_path_from_inspect(document, command): command
|
|
88
|
+
for command in Command
|
|
89
|
+
if command is not Command.INSPECT and command.value in document.commands.supported
|
|
90
|
+
}
|
|
91
|
+
self._inspect_body = inspect_body
|
|
92
|
+
self._inspect_cache_control = inspect_cache_control
|
|
93
|
+
self._inspect_etag = f'"sha256:{hashlib.sha256(inspect_body).hexdigest()}"'
|
|
94
|
+
self._maximum_request_body_bytes = maximum_request_body_bytes
|
|
95
|
+
self._service = service
|
|
96
|
+
|
|
97
|
+
async def __call__(self, scope: AsgiScope, receive: AsgiReceive, send: AsgiSend) -> None:
|
|
98
|
+
if scope.get("type") != "http":
|
|
99
|
+
await self._application(scope, receive, send)
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
path = scope.get("path")
|
|
103
|
+
if path == AEP_WELL_KNOWN_PATH:
|
|
104
|
+
await self._serve_inspect(scope, send)
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
command = self._command_paths.get(path) if isinstance(path, str) else None
|
|
108
|
+
if command is None:
|
|
109
|
+
await self._application(scope, receive, send)
|
|
110
|
+
return
|
|
111
|
+
await self._serve_command(scope, receive, send, command)
|
|
112
|
+
|
|
113
|
+
async def _serve_inspect(self, scope: AsgiScope, send: AsgiSend) -> None:
|
|
114
|
+
if scope.get("method") != "GET":
|
|
115
|
+
await _send_problem(
|
|
116
|
+
send,
|
|
117
|
+
400,
|
|
118
|
+
"Invalid request",
|
|
119
|
+
code="invalid_request",
|
|
120
|
+
headers={"Allow": "GET"},
|
|
121
|
+
)
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
headers = _request_headers(scope)
|
|
125
|
+
if _etag_matches(headers.get("if-none-match"), self._inspect_etag):
|
|
126
|
+
await _send_response(
|
|
127
|
+
send,
|
|
128
|
+
304,
|
|
129
|
+
b"",
|
|
130
|
+
{
|
|
131
|
+
"Cache-Control": self._inspect_cache_control,
|
|
132
|
+
"ETag": self._inspect_etag,
|
|
133
|
+
},
|
|
134
|
+
)
|
|
135
|
+
return
|
|
136
|
+
await _send_response(
|
|
137
|
+
send,
|
|
138
|
+
200,
|
|
139
|
+
self._inspect_body,
|
|
140
|
+
{
|
|
141
|
+
"Cache-Control": self._inspect_cache_control,
|
|
142
|
+
"Content-Type": AEP_MEDIA_TYPE,
|
|
143
|
+
"ETag": self._inspect_etag,
|
|
144
|
+
},
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
async def _serve_command(
|
|
148
|
+
self,
|
|
149
|
+
scope: AsgiScope,
|
|
150
|
+
receive: AsgiReceive,
|
|
151
|
+
send: AsgiSend,
|
|
152
|
+
command: Command,
|
|
153
|
+
) -> None:
|
|
154
|
+
expected_method = "GET" if command is Command.STATUS else "POST"
|
|
155
|
+
if scope.get("method") != expected_method:
|
|
156
|
+
await _send_problem(
|
|
157
|
+
send,
|
|
158
|
+
400,
|
|
159
|
+
"Invalid request",
|
|
160
|
+
code="invalid_request",
|
|
161
|
+
headers={"Allow": expected_method},
|
|
162
|
+
)
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
headers = _request_headers(scope)
|
|
166
|
+
assertion = _command_assertion(headers.get("authorization"))
|
|
167
|
+
if command is Command.STATUS:
|
|
168
|
+
status_result = await self._service.status(CommandOptions(client_assertion=assertion))
|
|
169
|
+
await _send_service_result(send, status_result)
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
if not _single_media_type(headers.get("content-type"), AEP_MEDIA_TYPE):
|
|
173
|
+
await _send_problem(send, 400, "Invalid request", code="invalid_request")
|
|
174
|
+
return
|
|
175
|
+
try:
|
|
176
|
+
body = await _read_body(receive, self._maximum_request_body_bytes)
|
|
177
|
+
except _RequestDisconnected:
|
|
178
|
+
return
|
|
179
|
+
if body is None:
|
|
180
|
+
await _send_problem(send, 400, "Invalid request", code="invalid_request")
|
|
181
|
+
return
|
|
182
|
+
options = CommandOptions(
|
|
183
|
+
client_assertion=assertion,
|
|
184
|
+
idempotency_key=_single_header(headers.get("idempotency-key")) or "",
|
|
185
|
+
)
|
|
186
|
+
result: ServiceResult[Any]
|
|
187
|
+
if command is Command.ENROLL:
|
|
188
|
+
result = await self._service.enroll(body, options)
|
|
189
|
+
elif command is Command.GRANT:
|
|
190
|
+
result = await self._service.grant(body, options)
|
|
191
|
+
else:
|
|
192
|
+
result = await self._service.revoke(body, options)
|
|
193
|
+
await _send_service_result(send, result)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class AepAuthenticationMiddleware:
|
|
197
|
+
def __init__(
|
|
198
|
+
self,
|
|
199
|
+
application: AsgiApplication,
|
|
200
|
+
service: Service,
|
|
201
|
+
*,
|
|
202
|
+
resource_origin: str,
|
|
203
|
+
allow_insecure_loopback: bool = False,
|
|
204
|
+
) -> None:
|
|
205
|
+
if not callable(application):
|
|
206
|
+
raise TypeError("AEP authentication middleware requires an ASGI application")
|
|
207
|
+
if not isinstance(service, Service):
|
|
208
|
+
raise TypeError("AEP authentication middleware requires a Service")
|
|
209
|
+
self._application = application
|
|
210
|
+
self._resource_origin = _resource_origin(resource_origin, allow_insecure_loopback)
|
|
211
|
+
self._service = service
|
|
212
|
+
|
|
213
|
+
async def __call__(self, scope: AsgiScope, receive: AsgiReceive, send: AsgiSend) -> None:
|
|
214
|
+
if scope.get("type") != "http":
|
|
215
|
+
await self._application(scope, receive, send)
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
result = await self._service.authenticate_protected_resource(
|
|
219
|
+
ProtectedResourceRequest(
|
|
220
|
+
headers=_request_headers(scope),
|
|
221
|
+
method=_scope_text(scope, "method"),
|
|
222
|
+
url=_resource_url(self._resource_origin, scope),
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
if not result.authenticated:
|
|
226
|
+
if result.response is None:
|
|
227
|
+
raise RuntimeError("AEP Service returned an incomplete authentication result")
|
|
228
|
+
await _send_service_result(send, result.response)
|
|
229
|
+
return
|
|
230
|
+
if result.principal is None:
|
|
231
|
+
raise RuntimeError("AEP Service returned an authenticated result without a principal")
|
|
232
|
+
authenticated_scope = dict(scope)
|
|
233
|
+
authenticated_scope[AEP_PRINCIPAL_SCOPE_KEY] = result.principal
|
|
234
|
+
await self._application(authenticated_scope, receive, send)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def principal_from_scope(scope: Mapping[str, Any]) -> AuthenticatedPrincipal | None:
|
|
238
|
+
principal = scope.get(AEP_PRINCIPAL_SCOPE_KEY)
|
|
239
|
+
return principal if isinstance(principal, AuthenticatedPrincipal) else None
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
async def _default_application(scope: AsgiScope, receive: AsgiReceive, send: AsgiSend) -> None:
|
|
243
|
+
scope_type = scope.get("type")
|
|
244
|
+
if scope_type == "http":
|
|
245
|
+
await _send_problem(send, 404, "Not Found")
|
|
246
|
+
return
|
|
247
|
+
if scope_type != "lifespan":
|
|
248
|
+
raise RuntimeError("AEP ASGI application received an unsupported scope")
|
|
249
|
+
while True:
|
|
250
|
+
message_type = (await receive()).get("type")
|
|
251
|
+
if message_type == "lifespan.startup":
|
|
252
|
+
await send({"type": "lifespan.startup.complete"})
|
|
253
|
+
elif message_type == "lifespan.shutdown":
|
|
254
|
+
await send({"type": "lifespan.shutdown.complete"})
|
|
255
|
+
return
|
|
256
|
+
else:
|
|
257
|
+
raise RuntimeError("AEP ASGI application received an invalid lifespan message")
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
async def _read_body(receive: AsgiReceive, maximum: int) -> bytes | None:
|
|
261
|
+
body = bytearray()
|
|
262
|
+
while True:
|
|
263
|
+
message = await receive()
|
|
264
|
+
message_type = message.get("type")
|
|
265
|
+
if message_type == "http.disconnect":
|
|
266
|
+
raise _RequestDisconnected
|
|
267
|
+
if message_type != "http.request":
|
|
268
|
+
raise RuntimeError("AEP ASGI application received an invalid request message")
|
|
269
|
+
chunk = message.get("body", b"")
|
|
270
|
+
if not isinstance(chunk, bytes):
|
|
271
|
+
raise RuntimeError("AEP ASGI request body chunks must be bytes")
|
|
272
|
+
if len(body) + len(chunk) > maximum:
|
|
273
|
+
return None
|
|
274
|
+
body.extend(chunk)
|
|
275
|
+
if not message.get("more_body", False):
|
|
276
|
+
return bytes(body)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
async def _send_service_result(send: AsgiSend, result: ServiceResult[Any]) -> None:
|
|
280
|
+
value: object = result.problem if result.problem is not None else result.body
|
|
281
|
+
if isinstance(value, BaseModel):
|
|
282
|
+
value = value.model_dump(by_alias=True, exclude_none=True, mode="json")
|
|
283
|
+
body = _json_bytes(value)
|
|
284
|
+
headers = dict(result.headers)
|
|
285
|
+
headers["Cache-Control"] = "no-store"
|
|
286
|
+
headers["Content-Type"] = result.content_type
|
|
287
|
+
await _send_response(send, result.status, body, headers)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
async def _send_problem(
|
|
291
|
+
send: AsgiSend,
|
|
292
|
+
status: int,
|
|
293
|
+
title: str,
|
|
294
|
+
*,
|
|
295
|
+
code: str | None = None,
|
|
296
|
+
headers: Mapping[str, str] | None = None,
|
|
297
|
+
) -> None:
|
|
298
|
+
response_headers = dict(headers or {})
|
|
299
|
+
response_headers["Cache-Control"] = "no-store"
|
|
300
|
+
response_headers["Content-Type"] = AEP_PROBLEM_MEDIA_TYPE
|
|
301
|
+
problem = {
|
|
302
|
+
"status": status,
|
|
303
|
+
"title": title,
|
|
304
|
+
"type": f"urn:aep:error:{code}" if code is not None else "about:blank",
|
|
305
|
+
}
|
|
306
|
+
if code is not None:
|
|
307
|
+
problem["code"] = code
|
|
308
|
+
await _send_response(send, status, _json_bytes(problem), response_headers)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
async def _send_response(
|
|
312
|
+
send: AsgiSend,
|
|
313
|
+
status: int,
|
|
314
|
+
body: bytes,
|
|
315
|
+
headers: Mapping[str, str],
|
|
316
|
+
) -> None:
|
|
317
|
+
values = {
|
|
318
|
+
name.lower(): value for name, value in headers.items() if name.lower() != "content-length"
|
|
319
|
+
}
|
|
320
|
+
if status != 304:
|
|
321
|
+
values["content-length"] = str(len(body))
|
|
322
|
+
encoded: list[tuple[bytes, bytes]] = []
|
|
323
|
+
for name, value in values.items():
|
|
324
|
+
if (
|
|
325
|
+
not name
|
|
326
|
+
or any(character not in _HTTP_TOKEN_CHARACTERS for character in name)
|
|
327
|
+
or "\r" in value
|
|
328
|
+
or "\n" in value
|
|
329
|
+
or any(ord(character) > 255 for character in value)
|
|
330
|
+
):
|
|
331
|
+
raise ValueError("AEP ASGI response contains an invalid HTTP field")
|
|
332
|
+
encoded.append((name.encode("ascii"), value.encode("latin-1")))
|
|
333
|
+
await send({"type": "http.response.start", "status": status, "headers": encoded})
|
|
334
|
+
await send({"type": "http.response.body", "body": body})
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _request_headers(scope: Mapping[str, Any]) -> Mapping[str, str | Sequence[str]]:
|
|
338
|
+
grouped: dict[str, list[str]] = {}
|
|
339
|
+
raw_headers = scope.get("headers", ())
|
|
340
|
+
if not isinstance(raw_headers, Iterable):
|
|
341
|
+
raise RuntimeError("AEP ASGI request headers must be iterable")
|
|
342
|
+
for item in raw_headers:
|
|
343
|
+
if not isinstance(item, Iterable):
|
|
344
|
+
raise RuntimeError("AEP ASGI request header entries must be pairs")
|
|
345
|
+
try:
|
|
346
|
+
raw_name, raw_value = item
|
|
347
|
+
except (TypeError, ValueError) as error:
|
|
348
|
+
raise RuntimeError("AEP ASGI request header entries must be pairs") from error
|
|
349
|
+
if not isinstance(raw_name, bytes) or not isinstance(raw_value, bytes):
|
|
350
|
+
raise RuntimeError("AEP ASGI request headers must contain bytes")
|
|
351
|
+
name = raw_name.decode("latin-1").lower()
|
|
352
|
+
grouped.setdefault(name, []).append(raw_value.decode("latin-1"))
|
|
353
|
+
return {
|
|
354
|
+
name: values[0] if len(values) == 1 else tuple(values) for name, values in grouped.items()
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _single_header(value: str | Sequence[str] | None) -> str | None:
|
|
359
|
+
return value if isinstance(value, str) else None
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _single_media_type(value: str | Sequence[str] | None, expected: str) -> bool:
|
|
363
|
+
return isinstance(value, str) and media_type_essence(value) == expected
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _command_assertion(value: str | Sequence[str] | None) -> str:
|
|
367
|
+
if not isinstance(value, str):
|
|
368
|
+
return ""
|
|
369
|
+
try:
|
|
370
|
+
presentation = parse_authorization(value, AuthorizationCarrier.STANDARD)
|
|
371
|
+
except AepAuthorizationError:
|
|
372
|
+
return ""
|
|
373
|
+
return presentation.credentials if presentation.scheme is AuthorizationScheme.AEP else ""
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _etag_matches(value: str | Sequence[str] | None, expected: str) -> bool:
|
|
377
|
+
values = (value,) if isinstance(value, str) else value or ()
|
|
378
|
+
for field_value in values:
|
|
379
|
+
for candidate in field_value.split(","):
|
|
380
|
+
candidate = candidate.strip()
|
|
381
|
+
if (
|
|
382
|
+
candidate == "*"
|
|
383
|
+
or candidate == expected
|
|
384
|
+
or candidate.removeprefix("W/") == expected
|
|
385
|
+
):
|
|
386
|
+
return True
|
|
387
|
+
return False
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _resource_origin(value: str, allow_insecure_loopback: bool) -> tuple[str, str]:
|
|
391
|
+
parsed = urlsplit(value)
|
|
392
|
+
try:
|
|
393
|
+
_ = parsed.port
|
|
394
|
+
except ValueError as error:
|
|
395
|
+
raise ValueError("AEP protected-resource origin is invalid") from error
|
|
396
|
+
if (
|
|
397
|
+
not parsed.hostname
|
|
398
|
+
or parsed.username is not None
|
|
399
|
+
or parsed.password is not None
|
|
400
|
+
or parsed.path not in ("", "/")
|
|
401
|
+
or parsed.query
|
|
402
|
+
or parsed.fragment
|
|
403
|
+
or "?" in value
|
|
404
|
+
or "#" in value
|
|
405
|
+
):
|
|
406
|
+
raise ValueError(
|
|
407
|
+
"AEP protected-resource origin must not contain credentials, path, query, or fragment"
|
|
408
|
+
)
|
|
409
|
+
secure = parsed.scheme == "https"
|
|
410
|
+
loopback = parsed.scheme == "http" and allow_insecure_loopback and _is_loopback(parsed.hostname)
|
|
411
|
+
if not secure and not loopback:
|
|
412
|
+
raise ValueError("AEP protected-resource origin must use HTTPS")
|
|
413
|
+
return parsed.scheme, parsed.netloc
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _is_loopback(hostname: str) -> bool:
|
|
417
|
+
if hostname == "localhost":
|
|
418
|
+
return True
|
|
419
|
+
try:
|
|
420
|
+
return ipaddress.ip_address(hostname).is_loopback
|
|
421
|
+
except ValueError:
|
|
422
|
+
return False
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _resource_url(origin: tuple[str, str], scope: Mapping[str, Any]) -> str:
|
|
426
|
+
raw_path = scope.get("raw_path")
|
|
427
|
+
if isinstance(raw_path, bytes):
|
|
428
|
+
path = quote_from_bytes(raw_path, safe="/%:@!$&'()*+,;=-._~")
|
|
429
|
+
else:
|
|
430
|
+
path = quote(_scope_text(scope, "path"), safe="/:@!$&'()*+,;=-._~")
|
|
431
|
+
raw_query = scope.get("query_string", b"")
|
|
432
|
+
if not isinstance(raw_query, bytes):
|
|
433
|
+
raise RuntimeError("AEP ASGI query string must be bytes")
|
|
434
|
+
return urlunsplit((origin[0], origin[1], path, raw_query.decode("ascii"), ""))
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _scope_text(scope: Mapping[str, Any], name: str) -> str:
|
|
438
|
+
value = scope.get(name)
|
|
439
|
+
if not isinstance(value, str):
|
|
440
|
+
raise RuntimeError(f'AEP ASGI scope field "{name}" must be text')
|
|
441
|
+
return value
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _json_bytes(value: object) -> bytes:
|
|
445
|
+
return json.dumps(value, separators=(",", ":"), sort_keys=True).encode()
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Agent-side enrollment, credential, and authentication workflows."""
|
|
2
|
+
|
|
3
|
+
from .client import Agent, AgentOptions, ServiceSession
|
|
4
|
+
from .platform_provider import (
|
|
5
|
+
MemoryPlatformDiscoveryCache,
|
|
6
|
+
PlatformAuthenticationHeaders,
|
|
7
|
+
PlatformCommandError,
|
|
8
|
+
PlatformContextProvider,
|
|
9
|
+
PlatformDiscoveryCache,
|
|
10
|
+
PlatformDiscoveryCacheEntry,
|
|
11
|
+
PlatformIdempotencyKeyFactory,
|
|
12
|
+
PlatformIdentityProvider,
|
|
13
|
+
PlatformIdentityProviderOptions,
|
|
14
|
+
PlatformPendingSign,
|
|
15
|
+
PlatformPendingSignResolver,
|
|
16
|
+
PlatformSignPendingError,
|
|
17
|
+
)
|
|
18
|
+
from .stores import (
|
|
19
|
+
MemoryCredentialStore,
|
|
20
|
+
MemoryIdentityStore,
|
|
21
|
+
MemoryInspectCache,
|
|
22
|
+
RandomIdempotencyKeyProvider,
|
|
23
|
+
)
|
|
24
|
+
from .transport import AsyncHttpTransport, HttpxTransport
|
|
25
|
+
from .types import (
|
|
26
|
+
AgentCommandError,
|
|
27
|
+
AssertionSigner,
|
|
28
|
+
AuthenticationOptions,
|
|
29
|
+
BuiltInCredential,
|
|
30
|
+
ClaimRequirementsError,
|
|
31
|
+
CommandResult,
|
|
32
|
+
CredentialRecord,
|
|
33
|
+
CredentialStore,
|
|
34
|
+
EnrollmentStateError,
|
|
35
|
+
EnrollOptions,
|
|
36
|
+
GrantOptions,
|
|
37
|
+
GrantResult,
|
|
38
|
+
IdempotencyKeyProvider,
|
|
39
|
+
IdentityProvider,
|
|
40
|
+
IdentityRequest,
|
|
41
|
+
IdentityStore,
|
|
42
|
+
InspectCache,
|
|
43
|
+
InspectCacheEntry,
|
|
44
|
+
Inspection,
|
|
45
|
+
OperationKey,
|
|
46
|
+
RevokeOptions,
|
|
47
|
+
ServiceIdentity,
|
|
48
|
+
WaitOptions,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
"Agent",
|
|
53
|
+
"AgentCommandError",
|
|
54
|
+
"AgentOptions",
|
|
55
|
+
"AssertionSigner",
|
|
56
|
+
"AsyncHttpTransport",
|
|
57
|
+
"AuthenticationOptions",
|
|
58
|
+
"BuiltInCredential",
|
|
59
|
+
"ClaimRequirementsError",
|
|
60
|
+
"CommandResult",
|
|
61
|
+
"CredentialRecord",
|
|
62
|
+
"CredentialStore",
|
|
63
|
+
"EnrollOptions",
|
|
64
|
+
"EnrollmentStateError",
|
|
65
|
+
"GrantOptions",
|
|
66
|
+
"GrantResult",
|
|
67
|
+
"HttpxTransport",
|
|
68
|
+
"IdempotencyKeyProvider",
|
|
69
|
+
"IdentityProvider",
|
|
70
|
+
"IdentityRequest",
|
|
71
|
+
"IdentityStore",
|
|
72
|
+
"InspectCache",
|
|
73
|
+
"InspectCacheEntry",
|
|
74
|
+
"Inspection",
|
|
75
|
+
"MemoryCredentialStore",
|
|
76
|
+
"MemoryIdentityStore",
|
|
77
|
+
"MemoryInspectCache",
|
|
78
|
+
"MemoryPlatformDiscoveryCache",
|
|
79
|
+
"OperationKey",
|
|
80
|
+
"PlatformAuthenticationHeaders",
|
|
81
|
+
"PlatformCommandError",
|
|
82
|
+
"PlatformContextProvider",
|
|
83
|
+
"PlatformDiscoveryCache",
|
|
84
|
+
"PlatformDiscoveryCacheEntry",
|
|
85
|
+
"PlatformIdempotencyKeyFactory",
|
|
86
|
+
"PlatformIdentityProvider",
|
|
87
|
+
"PlatformIdentityProviderOptions",
|
|
88
|
+
"PlatformPendingSign",
|
|
89
|
+
"PlatformPendingSignResolver",
|
|
90
|
+
"PlatformSignPendingError",
|
|
91
|
+
"RandomIdempotencyKeyProvider",
|
|
92
|
+
"RevokeOptions",
|
|
93
|
+
"ServiceIdentity",
|
|
94
|
+
"ServiceSession",
|
|
95
|
+
"WaitOptions",
|
|
96
|
+
]
|