authweave-webhooks 7.1.2__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.
- authweave_webhooks-7.1.2/.gitignore +36 -0
- authweave_webhooks-7.1.2/LICENSE +21 -0
- authweave_webhooks-7.1.2/PKG-INFO +122 -0
- authweave_webhooks-7.1.2/README.md +90 -0
- authweave_webhooks-7.1.2/authweave_webhooks/__init__.py +52 -0
- authweave_webhooks-7.1.2/authweave_webhooks/errors.py +34 -0
- authweave_webhooks-7.1.2/authweave_webhooks/headers.py +135 -0
- authweave_webhooks-7.1.2/authweave_webhooks/keys.py +70 -0
- authweave_webhooks-7.1.2/authweave_webhooks/litestar_adapter.py +31 -0
- authweave_webhooks-7.1.2/authweave_webhooks/models.py +115 -0
- authweave_webhooks-7.1.2/authweave_webhooks/py.typed +0 -0
- authweave_webhooks-7.1.2/authweave_webhooks/redis_store.py +35 -0
- authweave_webhooks-7.1.2/authweave_webhooks/sender.py +155 -0
- authweave_webhooks-7.1.2/authweave_webhooks/signer.py +101 -0
- authweave_webhooks-7.1.2/authweave_webhooks/verify.py +240 -0
- authweave_webhooks-7.1.2/pyproject.toml +70 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# macOS
|
|
2
|
+
.DS_Store
|
|
3
|
+
|
|
4
|
+
# Python bytecode and native build products
|
|
5
|
+
__pycache__/
|
|
6
|
+
*.py[cod]
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Local environments and secrets
|
|
10
|
+
/.venv/
|
|
11
|
+
/.uv-cache/
|
|
12
|
+
.env
|
|
13
|
+
.env.*
|
|
14
|
+
!.env.example
|
|
15
|
+
|
|
16
|
+
# Packaging output
|
|
17
|
+
/build/
|
|
18
|
+
/dist/
|
|
19
|
+
*.egg-info/
|
|
20
|
+
|
|
21
|
+
# Test, coverage, and tool caches
|
|
22
|
+
.coverage
|
|
23
|
+
.coverage.*
|
|
24
|
+
/coverage.xml
|
|
25
|
+
/htmlcov/
|
|
26
|
+
/.pytest_cache/
|
|
27
|
+
/.ruff_cache/
|
|
28
|
+
/.cache/
|
|
29
|
+
|
|
30
|
+
# Generated documentation and code intelligence
|
|
31
|
+
/site/
|
|
32
|
+
/docs/_include/
|
|
33
|
+
/.codegraph/
|
|
34
|
+
|
|
35
|
+
# Docker reference ephemeral key material
|
|
36
|
+
/docker/reference/http-signatures/_runtime/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vladislav Shepilov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: authweave-webhooks
|
|
3
|
+
Version: 7.1.2
|
|
4
|
+
Summary: Asymmetric Standard Webhooks toolkit for AuthWeave integrations
|
|
5
|
+
Project-URL: homepage, https://github.com/ZYLVEXT/litestar-auth
|
|
6
|
+
Project-URL: documentation, https://zylvext.github.io/litestar-auth/
|
|
7
|
+
Project-URL: source, https://github.com/ZYLVEXT/litestar-auth
|
|
8
|
+
Project-URL: tracker, https://github.com/ZYLVEXT/litestar-auth/issues
|
|
9
|
+
Author-email: Vladislav Shepilov <shepilov.v@protonmail.com>
|
|
10
|
+
Maintainer-email: Vladislav Shepilov <shepilov.v@protonmail.com>
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: authentication,ed25519,security,standard-webhooks,webhooks
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Security
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
22
|
+
Requires-Python: <3.15.0,>=3.12.0
|
|
23
|
+
Requires-Dist: authweave-core==7.1.2
|
|
24
|
+
Requires-Dist: cryptography<51.0,>=50.0.0
|
|
25
|
+
Provides-Extra: httpx
|
|
26
|
+
Requires-Dist: httpx<1.0,>=0.28.1; extra == 'httpx'
|
|
27
|
+
Provides-Extra: litestar
|
|
28
|
+
Requires-Dist: litestar<3.0,>=2.24.0; extra == 'litestar'
|
|
29
|
+
Provides-Extra: redis
|
|
30
|
+
Requires-Dist: redis<9.0,>=8.1.0; extra == 'redis'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# authweave-webhooks
|
|
34
|
+
|
|
35
|
+
Asymmetric Standard Webhooks toolkit for AuthWeave integrations
|
|
36
|
+
(`authweave-standard-webhooks-v1a` Ed25519 profile).
|
|
37
|
+
|
|
38
|
+
This package does **not** depend on `litestar-auth` and is not authentication
|
|
39
|
+
middleware. It verifies or produces webhook deliveries before JSON parsing.
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
uv add 'authweave-webhooks[redis]'
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from authweave_webhooks import (
|
|
47
|
+
Ed25519PublicKey,
|
|
48
|
+
PublicKeyDocument,
|
|
49
|
+
StandardWebhooksVerifier,
|
|
50
|
+
StaticPublicKeyResolver,
|
|
51
|
+
)
|
|
52
|
+
from authweave_webhooks.redis_store import RedisReplayStore
|
|
53
|
+
|
|
54
|
+
resolver = StaticPublicKeyResolver(
|
|
55
|
+
PublicKeyDocument(
|
|
56
|
+
version="1",
|
|
57
|
+
environment="sandbox",
|
|
58
|
+
owner="merchant-1",
|
|
59
|
+
endpoint="https://merchant.example/hooks/payments",
|
|
60
|
+
not_before=0,
|
|
61
|
+
retire_after=None,
|
|
62
|
+
keys=(Ed25519PublicKey(public_key),),
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
verifier = StandardWebhooksVerifier(
|
|
66
|
+
resolver,
|
|
67
|
+
replay_store=RedisReplayStore(redis),
|
|
68
|
+
expected_environment="sandbox",
|
|
69
|
+
expected_owner="merchant-1",
|
|
70
|
+
expected_endpoint="https://merchant.example/hooks/payments",
|
|
71
|
+
time_source=lambda: 1_700_000_000,
|
|
72
|
+
)
|
|
73
|
+
verified = await verifier.verify(headers=headers, body=raw_body)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The replay store is mandatory. After a signature succeeds, `verify()` atomically
|
|
77
|
+
claims the `webhook-id` in a namespace derived from environment, owner, endpoint,
|
|
78
|
+
and id. The library derives a TTL that covers the complete inclusive timestamp
|
|
79
|
+
acceptance window; replay-store outage or capacity pressure fails verification
|
|
80
|
+
closed. A repeated valid delivery is returned with `verified.replay_detected=True`;
|
|
81
|
+
the flag is telemetry, not business idempotency.
|
|
82
|
+
|
|
83
|
+
After **every** successful verification, atomically insert the complete raw body
|
|
84
|
+
and verified metadata into a durable inbox with a unique key over environment,
|
|
85
|
+
owner, endpoint, and `webhook_id`. Never overwrite an existing row, and acknowledge
|
|
86
|
+
the HTTP delivery only after that transaction commits. A retry can then restore an
|
|
87
|
+
inbox row missing after a crash, while a committed row absorbs concurrent or later
|
|
88
|
+
retries. Use a shared replay store such as Redis in multi-worker deployments.
|
|
89
|
+
|
|
90
|
+
Pass an optional core `SecurityObserver` to the verifier or HTTP sender to emit
|
|
91
|
+
bounded verification/replay/delivery telemetry. Retry and queue consumers may
|
|
92
|
+
pass `TraceCorrelation` values through `links=`; trace context is correlation
|
|
93
|
+
only and is never accepted as identity.
|
|
94
|
+
|
|
95
|
+
`HttpxWebhookSender` requires a non-empty exact endpoint allowlist, disables
|
|
96
|
+
redirects, and streams at most 65,536 response bytes. The application must also
|
|
97
|
+
place its HTTP client behind the controlled egress proxy/subnet described in the
|
|
98
|
+
merchant sender threat model; DNS safety is not inferred from HTTPS syntax.
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
from authweave_webhooks.sender import HttpxWebhookSender
|
|
102
|
+
|
|
103
|
+
sender = HttpxWebhookSender(
|
|
104
|
+
httpx_client,
|
|
105
|
+
allowed_endpoints={"https://merchant.example/hooks/payments"},
|
|
106
|
+
)
|
|
107
|
+
result = await sender.send(endpoint=merchant_endpoint, delivery=delivery)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Extras
|
|
111
|
+
|
|
112
|
+
- `[redis]` — shared `RedisReplayStore` for fail-closed verification
|
|
113
|
+
- `[httpx]` — one-shot HTTPS sender without auto-retry
|
|
114
|
+
- `[litestar]` — raw-body verification helper
|
|
115
|
+
|
|
116
|
+
Private keys stay inside `AsyncMessageSigner` implementations. The library never
|
|
117
|
+
accepts private key bytes on verifier APIs and keeps secrets out of `repr` /
|
|
118
|
+
error messages.
|
|
119
|
+
|
|
120
|
+
See `docs/roadmap.md`, `docs/merchant/webhooks.md`, the sender threat model, and
|
|
121
|
+
ADR 0002 for key-tenancy and egress rules. Language-neutral vectors plus Python
|
|
122
|
+
and dependency-free Node.js verifiers live in `docs/vectors/webhooks/v1a/`.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# authweave-webhooks
|
|
2
|
+
|
|
3
|
+
Asymmetric Standard Webhooks toolkit for AuthWeave integrations
|
|
4
|
+
(`authweave-standard-webhooks-v1a` Ed25519 profile).
|
|
5
|
+
|
|
6
|
+
This package does **not** depend on `litestar-auth` and is not authentication
|
|
7
|
+
middleware. It verifies or produces webhook deliveries before JSON parsing.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
uv add 'authweave-webhooks[redis]'
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from authweave_webhooks import (
|
|
15
|
+
Ed25519PublicKey,
|
|
16
|
+
PublicKeyDocument,
|
|
17
|
+
StandardWebhooksVerifier,
|
|
18
|
+
StaticPublicKeyResolver,
|
|
19
|
+
)
|
|
20
|
+
from authweave_webhooks.redis_store import RedisReplayStore
|
|
21
|
+
|
|
22
|
+
resolver = StaticPublicKeyResolver(
|
|
23
|
+
PublicKeyDocument(
|
|
24
|
+
version="1",
|
|
25
|
+
environment="sandbox",
|
|
26
|
+
owner="merchant-1",
|
|
27
|
+
endpoint="https://merchant.example/hooks/payments",
|
|
28
|
+
not_before=0,
|
|
29
|
+
retire_after=None,
|
|
30
|
+
keys=(Ed25519PublicKey(public_key),),
|
|
31
|
+
)
|
|
32
|
+
)
|
|
33
|
+
verifier = StandardWebhooksVerifier(
|
|
34
|
+
resolver,
|
|
35
|
+
replay_store=RedisReplayStore(redis),
|
|
36
|
+
expected_environment="sandbox",
|
|
37
|
+
expected_owner="merchant-1",
|
|
38
|
+
expected_endpoint="https://merchant.example/hooks/payments",
|
|
39
|
+
time_source=lambda: 1_700_000_000,
|
|
40
|
+
)
|
|
41
|
+
verified = await verifier.verify(headers=headers, body=raw_body)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The replay store is mandatory. After a signature succeeds, `verify()` atomically
|
|
45
|
+
claims the `webhook-id` in a namespace derived from environment, owner, endpoint,
|
|
46
|
+
and id. The library derives a TTL that covers the complete inclusive timestamp
|
|
47
|
+
acceptance window; replay-store outage or capacity pressure fails verification
|
|
48
|
+
closed. A repeated valid delivery is returned with `verified.replay_detected=True`;
|
|
49
|
+
the flag is telemetry, not business idempotency.
|
|
50
|
+
|
|
51
|
+
After **every** successful verification, atomically insert the complete raw body
|
|
52
|
+
and verified metadata into a durable inbox with a unique key over environment,
|
|
53
|
+
owner, endpoint, and `webhook_id`. Never overwrite an existing row, and acknowledge
|
|
54
|
+
the HTTP delivery only after that transaction commits. A retry can then restore an
|
|
55
|
+
inbox row missing after a crash, while a committed row absorbs concurrent or later
|
|
56
|
+
retries. Use a shared replay store such as Redis in multi-worker deployments.
|
|
57
|
+
|
|
58
|
+
Pass an optional core `SecurityObserver` to the verifier or HTTP sender to emit
|
|
59
|
+
bounded verification/replay/delivery telemetry. Retry and queue consumers may
|
|
60
|
+
pass `TraceCorrelation` values through `links=`; trace context is correlation
|
|
61
|
+
only and is never accepted as identity.
|
|
62
|
+
|
|
63
|
+
`HttpxWebhookSender` requires a non-empty exact endpoint allowlist, disables
|
|
64
|
+
redirects, and streams at most 65,536 response bytes. The application must also
|
|
65
|
+
place its HTTP client behind the controlled egress proxy/subnet described in the
|
|
66
|
+
merchant sender threat model; DNS safety is not inferred from HTTPS syntax.
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from authweave_webhooks.sender import HttpxWebhookSender
|
|
70
|
+
|
|
71
|
+
sender = HttpxWebhookSender(
|
|
72
|
+
httpx_client,
|
|
73
|
+
allowed_endpoints={"https://merchant.example/hooks/payments"},
|
|
74
|
+
)
|
|
75
|
+
result = await sender.send(endpoint=merchant_endpoint, delivery=delivery)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Extras
|
|
79
|
+
|
|
80
|
+
- `[redis]` — shared `RedisReplayStore` for fail-closed verification
|
|
81
|
+
- `[httpx]` — one-shot HTTPS sender without auto-retry
|
|
82
|
+
- `[litestar]` — raw-body verification helper
|
|
83
|
+
|
|
84
|
+
Private keys stay inside `AsyncMessageSigner` implementations. The library never
|
|
85
|
+
accepts private key bytes on verifier APIs and keeps secrets out of `repr` /
|
|
86
|
+
error messages.
|
|
87
|
+
|
|
88
|
+
See `docs/roadmap.md`, `docs/merchant/webhooks.md`, the sender threat model, and
|
|
89
|
+
ADR 0002 for key-tenancy and egress rules. Language-neutral vectors plus Python
|
|
90
|
+
and dependency-free Node.js verifiers live in `docs/vectors/webhooks/v1a/`.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Asymmetric Standard Webhooks toolkit for AuthWeave integrations."""
|
|
2
|
+
|
|
3
|
+
from authweave_webhooks.errors import WebhookFailureCode, WebhookVerificationError
|
|
4
|
+
from authweave_webhooks.headers import build_signing_input, format_signature_header, parse_headers
|
|
5
|
+
from authweave_webhooks.keys import PublicKeyResolver, StaticPublicKeyResolver
|
|
6
|
+
from authweave_webhooks.models import (
|
|
7
|
+
DEFAULT_TIMESTAMP_TOLERANCE_SECONDS,
|
|
8
|
+
HEADER_ID,
|
|
9
|
+
HEADER_SIGNATURE,
|
|
10
|
+
HEADER_TIMESTAMP,
|
|
11
|
+
MAX_BODY_BYTES,
|
|
12
|
+
MAX_PUBLIC_KEYS,
|
|
13
|
+
MAX_SIGNATURES,
|
|
14
|
+
MAX_WEBHOOK_ID_LENGTH,
|
|
15
|
+
SIGNATURE_VERSION,
|
|
16
|
+
Ed25519PublicKey,
|
|
17
|
+
PublicKeyDocument,
|
|
18
|
+
VerifiedWebhook,
|
|
19
|
+
WebhookDelivery,
|
|
20
|
+
)
|
|
21
|
+
from authweave_webhooks.signer import AsyncMessageSigner, LocalEd25519KeyringSigner, create_delivery
|
|
22
|
+
from authweave_webhooks.verify import StandardWebhooksVerifier
|
|
23
|
+
|
|
24
|
+
__version__ = "7.1.2"
|
|
25
|
+
|
|
26
|
+
__all__ = (
|
|
27
|
+
"DEFAULT_TIMESTAMP_TOLERANCE_SECONDS",
|
|
28
|
+
"HEADER_ID",
|
|
29
|
+
"HEADER_SIGNATURE",
|
|
30
|
+
"HEADER_TIMESTAMP",
|
|
31
|
+
"MAX_BODY_BYTES",
|
|
32
|
+
"MAX_PUBLIC_KEYS",
|
|
33
|
+
"MAX_SIGNATURES",
|
|
34
|
+
"MAX_WEBHOOK_ID_LENGTH",
|
|
35
|
+
"SIGNATURE_VERSION",
|
|
36
|
+
"AsyncMessageSigner",
|
|
37
|
+
"Ed25519PublicKey",
|
|
38
|
+
"LocalEd25519KeyringSigner",
|
|
39
|
+
"PublicKeyDocument",
|
|
40
|
+
"PublicKeyResolver",
|
|
41
|
+
"StandardWebhooksVerifier",
|
|
42
|
+
"StaticPublicKeyResolver",
|
|
43
|
+
"VerifiedWebhook",
|
|
44
|
+
"WebhookDelivery",
|
|
45
|
+
"WebhookFailureCode",
|
|
46
|
+
"WebhookVerificationError",
|
|
47
|
+
"__version__",
|
|
48
|
+
"build_signing_input",
|
|
49
|
+
"create_delivery",
|
|
50
|
+
"format_signature_header",
|
|
51
|
+
"parse_headers",
|
|
52
|
+
)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Typed errors and outcomes for Standard Webhooks verification."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class WebhookFailureCode(StrEnum):
|
|
9
|
+
"""Bounded failure codes returned to callers without key-discrimination detail."""
|
|
10
|
+
|
|
11
|
+
MALFORMED_HEADERS = "malformed_headers"
|
|
12
|
+
DUPLICATE_HEADER = "duplicate_header"
|
|
13
|
+
BODY_TOO_LARGE = "body_too_large"
|
|
14
|
+
TIMESTAMP_INVALID = "timestamp_invalid"
|
|
15
|
+
TIMESTAMP_OUT_OF_TOLERANCE = "timestamp_out_of_tolerance"
|
|
16
|
+
SIGNATURE_INVALID = "signature_invalid"
|
|
17
|
+
KEY_UNAVAILABLE = "key_unavailable"
|
|
18
|
+
ENVIRONMENT_MISMATCH = "environment_mismatch"
|
|
19
|
+
OWNER_MISMATCH = "owner_mismatch"
|
|
20
|
+
ENDPOINT_MISMATCH = "endpoint_mismatch"
|
|
21
|
+
STORE_UNAVAILABLE = "store_unavailable"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class WebhookVerificationError(Exception):
|
|
25
|
+
"""Fail-closed verification error with a secret-free bounded code."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, code: WebhookFailureCode) -> None:
|
|
28
|
+
"""Bind the public failure code without embedding raw material."""
|
|
29
|
+
self.code = code
|
|
30
|
+
super().__init__(code.value)
|
|
31
|
+
|
|
32
|
+
def __repr__(self) -> str:
|
|
33
|
+
"""Return a secret-free representation."""
|
|
34
|
+
return f"WebhookVerificationError(code={self.code!r})"
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Bounded Standard Webhooks header and signing-input helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
|
|
10
|
+
from authweave_webhooks.errors import WebhookFailureCode, WebhookVerificationError
|
|
11
|
+
from authweave_webhooks.models import (
|
|
12
|
+
ED25519_SIGNATURE_BYTES,
|
|
13
|
+
HEADER_ID,
|
|
14
|
+
HEADER_SIGNATURE,
|
|
15
|
+
HEADER_TIMESTAMP,
|
|
16
|
+
MAX_BODY_BYTES,
|
|
17
|
+
MAX_SIGNATURES,
|
|
18
|
+
MAX_WEBHOOK_ID_LENGTH,
|
|
19
|
+
SIGNATURE_VERSION,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
23
|
+
_TIMESTAMP_PATTERN = re.compile(r"^[0-9]+$")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class ParsedWebhookHeaders:
|
|
28
|
+
"""Exactly one id, timestamp, and signature list after duplicate rejection."""
|
|
29
|
+
|
|
30
|
+
webhook_id: str
|
|
31
|
+
timestamp: int
|
|
32
|
+
signatures: tuple[bytes, ...] = field(repr=False)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def build_signing_input(*, webhook_id: str, timestamp: int, body: bytes) -> bytes:
|
|
36
|
+
"""Build the Standard Webhooks signed byte sequence.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
ASCII ``webhook-id.timestamp.`` concatenated with the exact raw body.
|
|
40
|
+
"""
|
|
41
|
+
prefix = f"{webhook_id}.{timestamp}.".encode("ascii")
|
|
42
|
+
return prefix + body
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def parse_headers(
|
|
46
|
+
headers: Mapping[str, str] | list[tuple[str, str]],
|
|
47
|
+
*,
|
|
48
|
+
body: bytes,
|
|
49
|
+
max_body_bytes: int = MAX_BODY_BYTES,
|
|
50
|
+
) -> ParsedWebhookHeaders:
|
|
51
|
+
"""Parse and bound Standard Webhooks headers before cryptographic verify.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
The parsed header triple.
|
|
55
|
+
|
|
56
|
+
Raises:
|
|
57
|
+
WebhookVerificationError: On duplicate, malformed, or oversized input.
|
|
58
|
+
"""
|
|
59
|
+
if len(body) > max_body_bytes:
|
|
60
|
+
raise WebhookVerificationError(WebhookFailureCode.BODY_TOO_LARGE)
|
|
61
|
+
|
|
62
|
+
values = _collect_headers(headers)
|
|
63
|
+
webhook_id = _require_one(values, HEADER_ID)
|
|
64
|
+
timestamp_raw = _require_one(values, HEADER_TIMESTAMP)
|
|
65
|
+
signature_raw = _require_one(values, HEADER_SIGNATURE)
|
|
66
|
+
|
|
67
|
+
if (
|
|
68
|
+
not webhook_id
|
|
69
|
+
or len(webhook_id) > MAX_WEBHOOK_ID_LENGTH
|
|
70
|
+
or "." in webhook_id
|
|
71
|
+
or _ID_PATTERN.fullmatch(webhook_id) is None
|
|
72
|
+
):
|
|
73
|
+
raise WebhookVerificationError(WebhookFailureCode.MALFORMED_HEADERS)
|
|
74
|
+
if not timestamp_raw or "." in timestamp_raw or _TIMESTAMP_PATTERN.fullmatch(timestamp_raw) is None:
|
|
75
|
+
raise WebhookVerificationError(WebhookFailureCode.TIMESTAMP_INVALID)
|
|
76
|
+
|
|
77
|
+
signatures = _parse_signatures(signature_raw)
|
|
78
|
+
return ParsedWebhookHeaders(webhook_id=webhook_id, timestamp=int(timestamp_raw), signatures=signatures)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def format_signature_header(signatures: list[bytes]) -> str:
|
|
82
|
+
"""Format one or more Ed25519 signatures as a Standard Webhooks header.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
A space-delimited ``v1a,<base64>`` list.
|
|
86
|
+
"""
|
|
87
|
+
if not signatures or len(signatures) > MAX_SIGNATURES:
|
|
88
|
+
msg = f"signature count must be 1..{MAX_SIGNATURES}"
|
|
89
|
+
raise ValueError(msg)
|
|
90
|
+
parts: list[str] = []
|
|
91
|
+
for signature in signatures:
|
|
92
|
+
if len(signature) != ED25519_SIGNATURE_BYTES:
|
|
93
|
+
msg = "Ed25519 signature must be 64 bytes"
|
|
94
|
+
raise ValueError(msg)
|
|
95
|
+
parts.append(f"{SIGNATURE_VERSION},{base64.b64encode(signature).decode('ascii')}")
|
|
96
|
+
return " ".join(parts)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _collect_headers(headers: Mapping[str, str] | list[tuple[str, str]]) -> dict[str, list[str]]:
|
|
100
|
+
items = headers.items() if not isinstance(headers, list) else headers
|
|
101
|
+
collected: dict[str, list[str]] = {}
|
|
102
|
+
for raw_name, raw_value in items:
|
|
103
|
+
name = raw_name.lower()
|
|
104
|
+
if name not in {HEADER_ID, HEADER_TIMESTAMP, HEADER_SIGNATURE}:
|
|
105
|
+
continue
|
|
106
|
+
collected.setdefault(name, []).append(raw_value)
|
|
107
|
+
return collected
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _require_one(values: dict[str, list[str]], name: str) -> str:
|
|
111
|
+
present = values.get(name, [])
|
|
112
|
+
if len(present) > 1:
|
|
113
|
+
raise WebhookVerificationError(WebhookFailureCode.DUPLICATE_HEADER)
|
|
114
|
+
if len(present) != 1:
|
|
115
|
+
raise WebhookVerificationError(WebhookFailureCode.MALFORMED_HEADERS)
|
|
116
|
+
return present[0]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _parse_signatures(raw: str) -> tuple[bytes, ...]:
|
|
120
|
+
parts = raw.split()
|
|
121
|
+
if not parts or len(parts) > MAX_SIGNATURES:
|
|
122
|
+
raise WebhookVerificationError(WebhookFailureCode.SIGNATURE_INVALID)
|
|
123
|
+
signatures: list[bytes] = []
|
|
124
|
+
for part in parts:
|
|
125
|
+
version, sep, payload = part.partition(",")
|
|
126
|
+
if sep != "," or version != SIGNATURE_VERSION or not payload:
|
|
127
|
+
raise WebhookVerificationError(WebhookFailureCode.SIGNATURE_INVALID)
|
|
128
|
+
try:
|
|
129
|
+
decoded = base64.b64decode(payload, validate=True)
|
|
130
|
+
except ValueError as exc:
|
|
131
|
+
raise WebhookVerificationError(WebhookFailureCode.SIGNATURE_INVALID) from exc
|
|
132
|
+
if len(decoded) != ED25519_SIGNATURE_BYTES:
|
|
133
|
+
raise WebhookVerificationError(WebhookFailureCode.SIGNATURE_INVALID)
|
|
134
|
+
signatures.append(decoded)
|
|
135
|
+
return tuple(signatures)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Public-key resolver protocol and static onboarding documents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
|
|
7
|
+
from authweave_webhooks.models import PublicKeyDocument
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PublicKeyResolver(Protocol):
|
|
11
|
+
"""Resolve and optionally refresh the trusted public-key document."""
|
|
12
|
+
|
|
13
|
+
async def resolve(self) -> PublicKeyDocument:
|
|
14
|
+
"""Return the currently trusted key document.
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
The onboarding-trusted public-key document.
|
|
18
|
+
"""
|
|
19
|
+
...
|
|
20
|
+
|
|
21
|
+
async def refresh(self) -> PublicKeyDocument | None:
|
|
22
|
+
"""Perform at most one controlled refresh when verification misses.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
The latest trusted document, or ``None`` when refresh is
|
|
26
|
+
unavailable. Concurrent callers may receive a document refreshed by
|
|
27
|
+
another caller without performing a second remote refresh.
|
|
28
|
+
"""
|
|
29
|
+
...
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class StaticPublicKeyResolver:
|
|
33
|
+
"""In-memory resolver for tests and single-document onboarding fixtures."""
|
|
34
|
+
|
|
35
|
+
__slots__ = ("_document", "_refresh_document", "_refreshed")
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
document: PublicKeyDocument,
|
|
40
|
+
*,
|
|
41
|
+
refresh_document: PublicKeyDocument | None = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Bind the active document and an optional one-shot refresh document."""
|
|
44
|
+
self._document = document
|
|
45
|
+
self._refresh_document = refresh_document
|
|
46
|
+
self._refreshed = False
|
|
47
|
+
|
|
48
|
+
async def resolve(self) -> PublicKeyDocument:
|
|
49
|
+
"""Return the current document.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
The trusted public-key document.
|
|
53
|
+
"""
|
|
54
|
+
return self._document
|
|
55
|
+
|
|
56
|
+
async def refresh(self) -> PublicKeyDocument | None:
|
|
57
|
+
"""Replace the document once from the configured refresh snapshot.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
The refreshed/current document, or ``None`` when no refresh snapshot
|
|
61
|
+
is configured. Only the first caller changes the snapshot; later
|
|
62
|
+
concurrent callers receive the same refreshed document.
|
|
63
|
+
"""
|
|
64
|
+
if self._refreshed:
|
|
65
|
+
return self._document
|
|
66
|
+
if self._refresh_document is None:
|
|
67
|
+
return None
|
|
68
|
+
self._refreshed = True
|
|
69
|
+
self._document = self._refresh_document
|
|
70
|
+
return self._document
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Optional Litestar raw-body adapter for webhook verification."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Protocol
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
|
|
10
|
+
from authweave_webhooks.models import VerifiedWebhook
|
|
11
|
+
from authweave_webhooks.verify import StandardWebhooksVerifier
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _LitestarRequest(Protocol):
|
|
15
|
+
headers: Mapping[str, str]
|
|
16
|
+
|
|
17
|
+
async def body(self) -> bytes: ...
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def verify_litestar_request(
|
|
21
|
+
verifier: StandardWebhooksVerifier,
|
|
22
|
+
request: _LitestarRequest,
|
|
23
|
+
) -> VerifiedWebhook:
|
|
24
|
+
"""Verify a Litestar request using exact raw body bytes before deserialization.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
The verified webhook envelope.
|
|
28
|
+
"""
|
|
29
|
+
body = await request.body()
|
|
30
|
+
headers = list(request.headers.items())
|
|
31
|
+
return await verifier.verify(headers=headers, body=bytes(body))
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Immutable webhook models and key-document contracts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Final
|
|
7
|
+
|
|
8
|
+
ED25519_PUBLIC_KEY_BYTES = 32
|
|
9
|
+
ED25519_SIGNATURE_BYTES = 64
|
|
10
|
+
MAX_BODY_BYTES: Final = 262_144
|
|
11
|
+
MAX_WEBHOOK_ID_LENGTH: Final = 128
|
|
12
|
+
MAX_SIGNATURES: Final = 3
|
|
13
|
+
MAX_PUBLIC_KEYS: Final = 3
|
|
14
|
+
DEFAULT_TIMESTAMP_TOLERANCE_SECONDS: Final = 300
|
|
15
|
+
SIGNATURE_VERSION: Final = "v1a"
|
|
16
|
+
HEADER_ID: Final = "webhook-id"
|
|
17
|
+
HEADER_TIMESTAMP: Final = "webhook-timestamp"
|
|
18
|
+
HEADER_SIGNATURE: Final = "webhook-signature"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class Ed25519PublicKey:
|
|
23
|
+
"""One onboarding-trusted Ed25519 public key (32 raw bytes)."""
|
|
24
|
+
|
|
25
|
+
public_key: bytes
|
|
26
|
+
|
|
27
|
+
def __post_init__(self) -> None:
|
|
28
|
+
"""Reject non-Ed25519 key material."""
|
|
29
|
+
if len(self.public_key) != ED25519_PUBLIC_KEY_BYTES:
|
|
30
|
+
msg = "Ed25519 public key must be 32 bytes"
|
|
31
|
+
raise ValueError(msg)
|
|
32
|
+
|
|
33
|
+
def __repr__(self) -> str:
|
|
34
|
+
"""Omit raw key bytes from representations."""
|
|
35
|
+
return "Ed25519PublicKey(public_key=...)"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class PublicKeyDocument:
|
|
40
|
+
"""Bounded public-key set for one merchant/endpoint environment.
|
|
41
|
+
|
|
42
|
+
At most three keys cover active/next/retiring rotation overlap. Private keys
|
|
43
|
+
never appear in this document.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
version: str
|
|
47
|
+
environment: str
|
|
48
|
+
owner: str
|
|
49
|
+
endpoint: str
|
|
50
|
+
not_before: int
|
|
51
|
+
retire_after: int | None
|
|
52
|
+
keys: tuple[Ed25519PublicKey, ...]
|
|
53
|
+
|
|
54
|
+
def __post_init__(self) -> None:
|
|
55
|
+
"""Enforce rotation cardinality and identity bounds."""
|
|
56
|
+
if not self.version or not self.environment or not self.owner or not self.endpoint:
|
|
57
|
+
msg = "version, environment, owner, and endpoint are required"
|
|
58
|
+
raise ValueError(msg)
|
|
59
|
+
if not self.keys or len(self.keys) > MAX_PUBLIC_KEYS:
|
|
60
|
+
msg = f"public key document must contain 1..{MAX_PUBLIC_KEYS} keys"
|
|
61
|
+
raise ValueError(msg)
|
|
62
|
+
if self.retire_after is not None and self.retire_after < self.not_before:
|
|
63
|
+
msg = "retire_after must be >= not_before"
|
|
64
|
+
raise ValueError(msg)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True, slots=True)
|
|
68
|
+
class VerifiedWebhook:
|
|
69
|
+
"""Immutable verification result produced before JSON parsing."""
|
|
70
|
+
|
|
71
|
+
webhook_id: str
|
|
72
|
+
timestamp: int
|
|
73
|
+
body: bytes
|
|
74
|
+
environment: str
|
|
75
|
+
owner: str
|
|
76
|
+
endpoint: str
|
|
77
|
+
key_document_version: str
|
|
78
|
+
replay_detected: bool = False
|
|
79
|
+
|
|
80
|
+
def __repr__(self) -> str:
|
|
81
|
+
"""Omit raw body bytes from representations."""
|
|
82
|
+
return (
|
|
83
|
+
"VerifiedWebhook("
|
|
84
|
+
f"webhook_id={self.webhook_id!r}, timestamp={self.timestamp}, "
|
|
85
|
+
f"body_len={len(self.body)}, environment={self.environment!r}, "
|
|
86
|
+
f"owner={self.owner!r}, endpoint={self.endpoint!r}, "
|
|
87
|
+
f"key_document_version={self.key_document_version!r}, "
|
|
88
|
+
f"replay_detected={self.replay_detected!r})"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True, slots=True)
|
|
93
|
+
class WebhookDelivery:
|
|
94
|
+
"""Producer output: exact headers and raw body for one delivery attempt."""
|
|
95
|
+
|
|
96
|
+
webhook_id: str
|
|
97
|
+
timestamp: int
|
|
98
|
+
body: bytes
|
|
99
|
+
signature_header: str
|
|
100
|
+
|
|
101
|
+
def headers(self) -> dict[str, str]:
|
|
102
|
+
"""Return the three Standard Webhooks headers for this attempt."""
|
|
103
|
+
return {
|
|
104
|
+
HEADER_ID: self.webhook_id,
|
|
105
|
+
HEADER_TIMESTAMP: str(self.timestamp),
|
|
106
|
+
HEADER_SIGNATURE: self.signature_header,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
def __repr__(self) -> str:
|
|
110
|
+
"""Omit raw body and signature material from representations."""
|
|
111
|
+
return (
|
|
112
|
+
"WebhookDelivery("
|
|
113
|
+
f"webhook_id={self.webhook_id!r}, timestamp={self.timestamp}, "
|
|
114
|
+
f"body_len={len(self.body)}, signature_header=...)"
|
|
115
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Optional Redis-backed webhook replay store."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from authweave_core import ReplayOutcome, validate_replay_key
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RedisReplayStore:
|
|
9
|
+
"""Thin Redis SET NX EX adapter satisfying authweave-core ``ReplayStore``.
|
|
10
|
+
|
|
11
|
+
Requires the ``authweave-webhooks[redis]`` extra. Connection failures map to
|
|
12
|
+
``Unavailable`` so webhook verification fails closed.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
__slots__ = ("_redis",)
|
|
16
|
+
|
|
17
|
+
def __init__(self, redis: object) -> None:
|
|
18
|
+
"""Bind an async Redis client exposing ``set(name, value, nx=True, ex=...)``."""
|
|
19
|
+
self._redis = redis
|
|
20
|
+
|
|
21
|
+
async def check_and_store(self, key: str, *, ttl_seconds: float) -> ReplayOutcome:
|
|
22
|
+
"""Atomically claim ``key`` with a TTL.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
The typed put-if-absent outcome.
|
|
26
|
+
"""
|
|
27
|
+
validate_replay_key(key)
|
|
28
|
+
if ttl_seconds <= 0:
|
|
29
|
+
msg = "ttl_seconds must be positive"
|
|
30
|
+
raise ValueError(msg)
|
|
31
|
+
try:
|
|
32
|
+
created = await self._redis.set(key, "1", nx=True, ex=int(ttl_seconds)) # ty: ignore[unresolved-attribute]
|
|
33
|
+
except Exception: # ruff: ignore[blind-except] - any client/transport failure is Unavailable
|
|
34
|
+
return ReplayOutcome.UNAVAILABLE
|
|
35
|
+
return ReplayOutcome.STORED if created else ReplayOutcome.REPLAY
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Optional bounded HTTPS webhook sender (one attempt, no auto-retry)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
|
|
9
|
+
from authweave_core import SecurityOperation, SecurityOutcome, observe_security
|
|
10
|
+
|
|
11
|
+
from authweave_webhooks.models import HEADER_ID, HEADER_SIGNATURE, HEADER_TIMESTAMP, WebhookDelivery
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import Collection, Sequence
|
|
15
|
+
|
|
16
|
+
from authweave_core import SecurityObserver, TraceCorrelation
|
|
17
|
+
|
|
18
|
+
_MAX_RESPONSE_BYTES = 65_536
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class SenderResult:
|
|
23
|
+
"""Outcome of a single webhook POST attempt."""
|
|
24
|
+
|
|
25
|
+
status_code: int
|
|
26
|
+
retry_after_seconds: int | None
|
|
27
|
+
body: bytes
|
|
28
|
+
|
|
29
|
+
def __repr__(self) -> str:
|
|
30
|
+
"""Omit response body contents from representations.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
A secret-free summary.
|
|
34
|
+
"""
|
|
35
|
+
return (
|
|
36
|
+
f"SenderResult(status_code={self.status_code}, "
|
|
37
|
+
f"retry_after_seconds={self.retry_after_seconds}, body_len={len(self.body)})"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def validate_https_endpoint(url: str) -> str:
|
|
42
|
+
"""Accept only explicit HTTPS endpoints without userinfo or fragment.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
The validated URL.
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
ValueError: If the URL is not an allowed HTTPS endpoint.
|
|
49
|
+
"""
|
|
50
|
+
parsed = urlparse(url)
|
|
51
|
+
if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password or parsed.fragment:
|
|
52
|
+
msg = "webhook endpoint must be HTTPS without userinfo or fragment"
|
|
53
|
+
raise ValueError(msg)
|
|
54
|
+
return url
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class HttpxWebhookSender:
|
|
58
|
+
"""Send one bounded HTTPS delivery attempt.
|
|
59
|
+
|
|
60
|
+
Does not retry automatically. ``Retry-After`` is returned as a typed
|
|
61
|
+
recommendation only. Every destination must match the constructor's exact
|
|
62
|
+
onboarding allowlist. A controlled egress proxy/subnet is still required to
|
|
63
|
+
contain DNS rebinding and compromised allowlisted destinations.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
__slots__ = ("_allowed_endpoints", "_client", "_observer", "_timeout_seconds")
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
client: object,
|
|
71
|
+
*,
|
|
72
|
+
allowed_endpoints: Collection[str],
|
|
73
|
+
timeout_seconds: float = 5.0,
|
|
74
|
+
observer: SecurityObserver | None = None,
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Bind an httpx-like async client and exact onboarding endpoints."""
|
|
77
|
+
if timeout_seconds <= 0:
|
|
78
|
+
msg = "timeout_seconds must be positive"
|
|
79
|
+
raise ValueError(msg)
|
|
80
|
+
if not allowed_endpoints:
|
|
81
|
+
msg = "allowed_endpoints must contain at least one endpoint"
|
|
82
|
+
raise ValueError(msg)
|
|
83
|
+
self._client = client
|
|
84
|
+
self._allowed_endpoints = frozenset(validate_https_endpoint(endpoint) for endpoint in allowed_endpoints)
|
|
85
|
+
self._timeout_seconds = timeout_seconds
|
|
86
|
+
self._observer = observer
|
|
87
|
+
|
|
88
|
+
async def send(
|
|
89
|
+
self,
|
|
90
|
+
*,
|
|
91
|
+
endpoint: str,
|
|
92
|
+
delivery: WebhookDelivery,
|
|
93
|
+
links: Sequence[TraceCorrelation] = (),
|
|
94
|
+
) -> SenderResult:
|
|
95
|
+
"""POST one delivery attempt to ``endpoint``.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
Status, optional Retry-After recommendation, and a bounded body.
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
ValueError: If the endpoint fails the HTTPS policy or exact
|
|
102
|
+
onboarding allowlist.
|
|
103
|
+
"""
|
|
104
|
+
with observe_security(
|
|
105
|
+
self._observer,
|
|
106
|
+
SecurityOperation.WEBHOOK_DELIVER,
|
|
107
|
+
profile="standard_webhooks",
|
|
108
|
+
links=links,
|
|
109
|
+
) as observation:
|
|
110
|
+
validated_endpoint = validate_https_endpoint(endpoint)
|
|
111
|
+
if validated_endpoint not in self._allowed_endpoints:
|
|
112
|
+
msg = "webhook endpoint is not in the configured exact allowlist"
|
|
113
|
+
raise ValueError(msg)
|
|
114
|
+
async with self._client.stream( # ty: ignore[unresolved-attribute]
|
|
115
|
+
"POST",
|
|
116
|
+
validated_endpoint,
|
|
117
|
+
content=delivery.body,
|
|
118
|
+
headers={
|
|
119
|
+
HEADER_ID: delivery.webhook_id,
|
|
120
|
+
HEADER_TIMESTAMP: str(delivery.timestamp),
|
|
121
|
+
HEADER_SIGNATURE: delivery.signature_header,
|
|
122
|
+
"content-type": "application/json",
|
|
123
|
+
},
|
|
124
|
+
timeout=self._timeout_seconds,
|
|
125
|
+
follow_redirects=False,
|
|
126
|
+
) as response:
|
|
127
|
+
body = await _read_bounded_body(response)
|
|
128
|
+
retry_after = _parse_retry_after(response.headers.get("retry-after"))
|
|
129
|
+
status_code = int(response.status_code)
|
|
130
|
+
result = SenderResult(status_code=status_code, retry_after_seconds=retry_after, body=body)
|
|
131
|
+
observation.set_outcome(
|
|
132
|
+
SecurityOutcome.SUCCESS if 200 <= result.status_code < 300 else SecurityOutcome.ERROR,
|
|
133
|
+
)
|
|
134
|
+
return result
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def _read_bounded_body(response: object) -> bytes:
|
|
138
|
+
body = bytearray()
|
|
139
|
+
async for raw_chunk in response.aiter_bytes(): # ty: ignore[unresolved-attribute]
|
|
140
|
+
chunk = bytes(raw_chunk)
|
|
141
|
+
remaining = _MAX_RESPONSE_BYTES - len(body)
|
|
142
|
+
body.extend(chunk[:remaining])
|
|
143
|
+
if len(body) == _MAX_RESPONSE_BYTES:
|
|
144
|
+
break
|
|
145
|
+
return bytes(body)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _parse_retry_after(raw: str | None) -> int | None:
|
|
149
|
+
if raw is None:
|
|
150
|
+
return None
|
|
151
|
+
try:
|
|
152
|
+
value = int(raw)
|
|
153
|
+
except ValueError:
|
|
154
|
+
return None
|
|
155
|
+
return value if value >= 0 else None
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Ed25519 signing protocol and local keyring signer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
|
|
7
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
8
|
+
|
|
9
|
+
from authweave_webhooks.headers import build_signing_input, format_signature_header
|
|
10
|
+
from authweave_webhooks.models import WebhookDelivery
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AsyncMessageSigner(Protocol):
|
|
14
|
+
"""Sign bounded webhook bytes using an approved key reference.
|
|
15
|
+
|
|
16
|
+
Implementations must accept a key reference, never raw private key bytes, in
|
|
17
|
+
the public call signature. Private material stays inside the signer/KMS.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
async def sign(self, *, key_ref: str, message: bytes) -> bytes:
|
|
21
|
+
"""Return a 64-byte Ed25519 signature over ``message``.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
The raw Ed25519 signature bytes.
|
|
25
|
+
"""
|
|
26
|
+
...
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class LocalEd25519KeyringSigner:
|
|
30
|
+
"""Reference signer that looks up private keys by opaque key reference.
|
|
31
|
+
|
|
32
|
+
Intended for tests and single-process demos. Production deployments should
|
|
33
|
+
use a KMS/HSM-backed ``AsyncMessageSigner``. Private keys never appear in
|
|
34
|
+
``repr`` or raised error messages.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
__slots__ = ("_keys",)
|
|
38
|
+
|
|
39
|
+
def __init__(self, keys: dict[str, Ed25519PrivateKey]) -> None:
|
|
40
|
+
"""Bind opaque key references to private keys held only in memory."""
|
|
41
|
+
if not keys:
|
|
42
|
+
msg = "keyring must contain at least one key"
|
|
43
|
+
raise ValueError(msg)
|
|
44
|
+
self._keys = dict(keys)
|
|
45
|
+
|
|
46
|
+
def __repr__(self) -> str:
|
|
47
|
+
"""List key references without private material."""
|
|
48
|
+
refs = sorted(self._keys)
|
|
49
|
+
return f"LocalEd25519KeyringSigner(key_refs={refs!r})"
|
|
50
|
+
|
|
51
|
+
async def sign(self, *, key_ref: str, message: bytes) -> bytes:
|
|
52
|
+
"""Sign ``message`` with the referenced key.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
The raw Ed25519 signature.
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
KeyError: If the key reference is unknown.
|
|
59
|
+
"""
|
|
60
|
+
try:
|
|
61
|
+
private_key = self._keys[key_ref]
|
|
62
|
+
except KeyError as exc:
|
|
63
|
+
msg = "unknown key reference"
|
|
64
|
+
raise KeyError(msg) from exc
|
|
65
|
+
return private_key.sign(message)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def create_delivery(
|
|
69
|
+
*,
|
|
70
|
+
signer: AsyncMessageSigner,
|
|
71
|
+
key_refs: list[str],
|
|
72
|
+
webhook_id: str,
|
|
73
|
+
timestamp: int,
|
|
74
|
+
body: bytes,
|
|
75
|
+
) -> WebhookDelivery:
|
|
76
|
+
"""Construct one Standard Webhooks delivery attempt.
|
|
77
|
+
|
|
78
|
+
``webhook_id`` must be stable across retries; ``timestamp`` is the attempt
|
|
79
|
+
time and should be refreshed on retry. ``key_refs`` may include active and
|
|
80
|
+
next keys during rotation overlap.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Headers-ready delivery material.
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
ValueError: If identifiers are malformed.
|
|
87
|
+
"""
|
|
88
|
+
if not webhook_id or "." in webhook_id:
|
|
89
|
+
msg = "webhook_id must be non-empty and must not contain '.'"
|
|
90
|
+
raise ValueError(msg)
|
|
91
|
+
if timestamp < 0:
|
|
92
|
+
msg = "timestamp must be non-negative"
|
|
93
|
+
raise ValueError(msg)
|
|
94
|
+
message = build_signing_input(webhook_id=webhook_id, timestamp=timestamp, body=body)
|
|
95
|
+
signatures = [await signer.sign(key_ref=key_ref, message=message) for key_ref in key_refs]
|
|
96
|
+
return WebhookDelivery(
|
|
97
|
+
webhook_id=webhook_id,
|
|
98
|
+
timestamp=timestamp,
|
|
99
|
+
body=body,
|
|
100
|
+
signature_header=format_signature_header(signatures),
|
|
101
|
+
)
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Standard Webhooks Ed25519 verifier."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
from hashlib import sha256
|
|
7
|
+
from json import dumps
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from authweave_core import ReplayOutcome, SecurityOperation, SecurityOutcome, observe_security
|
|
11
|
+
from cryptography.exceptions import InvalidSignature
|
|
12
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
13
|
+
|
|
14
|
+
from authweave_webhooks.errors import WebhookFailureCode, WebhookVerificationError
|
|
15
|
+
from authweave_webhooks.headers import build_signing_input, parse_headers
|
|
16
|
+
from authweave_webhooks.models import (
|
|
17
|
+
DEFAULT_TIMESTAMP_TOLERANCE_SECONDS,
|
|
18
|
+
MAX_BODY_BYTES,
|
|
19
|
+
VerifiedWebhook,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
24
|
+
|
|
25
|
+
from authweave_core import ReplayStore, SecurityObserver, TraceCorrelation
|
|
26
|
+
|
|
27
|
+
from authweave_webhooks.keys import PublicKeyResolver
|
|
28
|
+
from authweave_webhooks.models import PublicKeyDocument
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class StandardWebhooksVerifier:
|
|
32
|
+
"""Verify asymmetric Standard Webhooks deliveries before JSON parsing."""
|
|
33
|
+
|
|
34
|
+
__slots__ = (
|
|
35
|
+
"_expected_endpoint",
|
|
36
|
+
"_expected_environment",
|
|
37
|
+
"_expected_owner",
|
|
38
|
+
"_max_body_bytes",
|
|
39
|
+
"_observer",
|
|
40
|
+
"_replay_store",
|
|
41
|
+
"_replay_ttl_seconds",
|
|
42
|
+
"_resolver",
|
|
43
|
+
"_time_source",
|
|
44
|
+
"_tolerance",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
resolver: PublicKeyResolver,
|
|
50
|
+
*,
|
|
51
|
+
replay_store: ReplayStore,
|
|
52
|
+
expected_environment: str,
|
|
53
|
+
expected_owner: str,
|
|
54
|
+
expected_endpoint: str,
|
|
55
|
+
timestamp_tolerance_seconds: int = DEFAULT_TIMESTAMP_TOLERANCE_SECONDS,
|
|
56
|
+
max_body_bytes: int = MAX_BODY_BYTES,
|
|
57
|
+
time_source: Callable[[], int],
|
|
58
|
+
observer: SecurityObserver | None = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Bind onboarding identity, clock, key resolver, and replay store."""
|
|
61
|
+
if timestamp_tolerance_seconds <= 0:
|
|
62
|
+
msg = "timestamp_tolerance_seconds must be positive"
|
|
63
|
+
raise ValueError(msg)
|
|
64
|
+
self._resolver = resolver
|
|
65
|
+
self._replay_store = replay_store
|
|
66
|
+
self._expected_environment = expected_environment
|
|
67
|
+
self._expected_owner = expected_owner
|
|
68
|
+
self._expected_endpoint = expected_endpoint
|
|
69
|
+
self._tolerance = timestamp_tolerance_seconds
|
|
70
|
+
# Both timestamp boundaries are inclusive; cover a proof first seen at the future boundary.
|
|
71
|
+
self._replay_ttl_seconds = timestamp_tolerance_seconds * 2 + 1
|
|
72
|
+
self._max_body_bytes = max_body_bytes
|
|
73
|
+
self._time_source = time_source
|
|
74
|
+
self._observer = observer
|
|
75
|
+
|
|
76
|
+
async def verify(
|
|
77
|
+
self,
|
|
78
|
+
*,
|
|
79
|
+
headers: Mapping[str, str] | list[tuple[str, str]],
|
|
80
|
+
body: bytes,
|
|
81
|
+
links: Sequence[TraceCorrelation] = (),
|
|
82
|
+
) -> VerifiedWebhook:
|
|
83
|
+
"""Verify headers and body, refreshing keys at most once on miss.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
An immutable verified webhook envelope.
|
|
87
|
+
|
|
88
|
+
Raises:
|
|
89
|
+
WebhookVerificationError: On any fail-closed verification failure.
|
|
90
|
+
"""
|
|
91
|
+
with observe_security(
|
|
92
|
+
self._observer,
|
|
93
|
+
SecurityOperation.VERIFY_WEBHOOK,
|
|
94
|
+
profile="standard_webhooks",
|
|
95
|
+
credential_kind="ed25519",
|
|
96
|
+
links=links,
|
|
97
|
+
) as observation:
|
|
98
|
+
try:
|
|
99
|
+
result = await self._verify(headers=headers, body=body)
|
|
100
|
+
result = replace(
|
|
101
|
+
result,
|
|
102
|
+
replay_detected=await self._claim_replay(result, links=links),
|
|
103
|
+
)
|
|
104
|
+
except WebhookVerificationError as exc:
|
|
105
|
+
outcome = (
|
|
106
|
+
SecurityOutcome.UNAVAILABLE
|
|
107
|
+
if exc.code in {WebhookFailureCode.KEY_UNAVAILABLE, WebhookFailureCode.STORE_UNAVAILABLE}
|
|
108
|
+
else SecurityOutcome.INVALID
|
|
109
|
+
)
|
|
110
|
+
observation.set_outcome(outcome, reason_code=exc.code.value)
|
|
111
|
+
raise
|
|
112
|
+
observation.set_outcome(SecurityOutcome.VERIFIED)
|
|
113
|
+
return result
|
|
114
|
+
|
|
115
|
+
async def _verify(
|
|
116
|
+
self,
|
|
117
|
+
*,
|
|
118
|
+
headers: Mapping[str, str] | list[tuple[str, str]],
|
|
119
|
+
body: bytes,
|
|
120
|
+
) -> VerifiedWebhook:
|
|
121
|
+
parsed = parse_headers(
|
|
122
|
+
headers if isinstance(headers, list) else dict(headers),
|
|
123
|
+
body=body,
|
|
124
|
+
max_body_bytes=self._max_body_bytes,
|
|
125
|
+
)
|
|
126
|
+
self._check_timestamp(parsed.timestamp)
|
|
127
|
+
message = build_signing_input(
|
|
128
|
+
webhook_id=parsed.webhook_id,
|
|
129
|
+
timestamp=parsed.timestamp,
|
|
130
|
+
body=body,
|
|
131
|
+
)
|
|
132
|
+
document = await self._resolver.resolve()
|
|
133
|
+
self._check_ownership(document)
|
|
134
|
+
if self._signatures_match(document, message, parsed.signatures):
|
|
135
|
+
return self._result(parsed.webhook_id, parsed.timestamp, body, document)
|
|
136
|
+
|
|
137
|
+
with observe_security(
|
|
138
|
+
self._observer,
|
|
139
|
+
SecurityOperation.KEY_REFRESH,
|
|
140
|
+
profile="standard_webhooks",
|
|
141
|
+
) as observation:
|
|
142
|
+
refreshed = await self._resolver.refresh()
|
|
143
|
+
candidate = await self._resolver.resolve() if refreshed is None else refreshed
|
|
144
|
+
self._check_ownership(candidate)
|
|
145
|
+
matched = self._signatures_match(candidate, message, parsed.signatures)
|
|
146
|
+
if matched:
|
|
147
|
+
observation.set_outcome(SecurityOutcome.HIT if refreshed is None else SecurityOutcome.SUCCESS)
|
|
148
|
+
else:
|
|
149
|
+
observation.set_outcome(SecurityOutcome.MISS)
|
|
150
|
+
if matched:
|
|
151
|
+
return self._result(parsed.webhook_id, parsed.timestamp, body, candidate)
|
|
152
|
+
raise WebhookVerificationError(WebhookFailureCode.SIGNATURE_INVALID)
|
|
153
|
+
|
|
154
|
+
async def _claim_replay(
|
|
155
|
+
self,
|
|
156
|
+
result: VerifiedWebhook,
|
|
157
|
+
*,
|
|
158
|
+
links: Sequence[TraceCorrelation],
|
|
159
|
+
) -> bool:
|
|
160
|
+
namespace = dumps(
|
|
161
|
+
(result.environment, result.owner, result.endpoint, result.webhook_id),
|
|
162
|
+
ensure_ascii=False,
|
|
163
|
+
separators=(",", ":"),
|
|
164
|
+
).encode()
|
|
165
|
+
key = f"webhook:v1:{sha256(namespace).hexdigest()}"
|
|
166
|
+
with observe_security(
|
|
167
|
+
self._observer,
|
|
168
|
+
SecurityOperation.REPLAY_CHECK,
|
|
169
|
+
profile="standard_webhooks",
|
|
170
|
+
links=links,
|
|
171
|
+
) as observation:
|
|
172
|
+
outcome = await self._replay_store.check_and_store(
|
|
173
|
+
key,
|
|
174
|
+
ttl_seconds=self._replay_ttl_seconds,
|
|
175
|
+
)
|
|
176
|
+
if outcome is ReplayOutcome.STORED:
|
|
177
|
+
observation.set_outcome(SecurityOutcome.STORED)
|
|
178
|
+
elif outcome is ReplayOutcome.REPLAY:
|
|
179
|
+
observation.set_outcome(SecurityOutcome.REPLAY)
|
|
180
|
+
elif outcome is ReplayOutcome.CAPACITY_EXCEEDED:
|
|
181
|
+
observation.set_outcome(SecurityOutcome.CAPACITY_EXCEEDED)
|
|
182
|
+
else:
|
|
183
|
+
observation.set_outcome(SecurityOutcome.UNAVAILABLE)
|
|
184
|
+
if outcome is ReplayOutcome.STORED:
|
|
185
|
+
return False
|
|
186
|
+
if outcome is ReplayOutcome.REPLAY:
|
|
187
|
+
return True
|
|
188
|
+
raise WebhookVerificationError(WebhookFailureCode.STORE_UNAVAILABLE)
|
|
189
|
+
|
|
190
|
+
def _check_timestamp(self, timestamp: int) -> None:
|
|
191
|
+
now = self._time_source()
|
|
192
|
+
if abs(now - timestamp) > self._tolerance:
|
|
193
|
+
raise WebhookVerificationError(WebhookFailureCode.TIMESTAMP_OUT_OF_TOLERANCE)
|
|
194
|
+
|
|
195
|
+
def _check_ownership(self, document: PublicKeyDocument) -> None:
|
|
196
|
+
if document.environment != self._expected_environment:
|
|
197
|
+
raise WebhookVerificationError(WebhookFailureCode.ENVIRONMENT_MISMATCH)
|
|
198
|
+
if document.owner != self._expected_owner:
|
|
199
|
+
raise WebhookVerificationError(WebhookFailureCode.OWNER_MISMATCH)
|
|
200
|
+
if document.endpoint != self._expected_endpoint:
|
|
201
|
+
raise WebhookVerificationError(WebhookFailureCode.ENDPOINT_MISMATCH)
|
|
202
|
+
now = self._time_source()
|
|
203
|
+
if now < document.not_before:
|
|
204
|
+
raise WebhookVerificationError(WebhookFailureCode.KEY_UNAVAILABLE)
|
|
205
|
+
if document.retire_after is not None and now > document.retire_after:
|
|
206
|
+
raise WebhookVerificationError(WebhookFailureCode.KEY_UNAVAILABLE)
|
|
207
|
+
|
|
208
|
+
def _signatures_match(
|
|
209
|
+
self,
|
|
210
|
+
document: PublicKeyDocument,
|
|
211
|
+
message: bytes,
|
|
212
|
+
signatures: tuple[bytes, ...],
|
|
213
|
+
) -> bool:
|
|
214
|
+
for key in document.keys:
|
|
215
|
+
public_key = Ed25519PublicKey.from_public_bytes(key.public_key)
|
|
216
|
+
for signature in signatures:
|
|
217
|
+
try:
|
|
218
|
+
public_key.verify(signature, message)
|
|
219
|
+
except InvalidSignature:
|
|
220
|
+
continue
|
|
221
|
+
else:
|
|
222
|
+
return True
|
|
223
|
+
return False
|
|
224
|
+
|
|
225
|
+
def _result(
|
|
226
|
+
self,
|
|
227
|
+
webhook_id: str,
|
|
228
|
+
timestamp: int,
|
|
229
|
+
body: bytes,
|
|
230
|
+
document: PublicKeyDocument,
|
|
231
|
+
) -> VerifiedWebhook:
|
|
232
|
+
return VerifiedWebhook(
|
|
233
|
+
webhook_id=webhook_id,
|
|
234
|
+
timestamp=timestamp,
|
|
235
|
+
body=body,
|
|
236
|
+
environment=document.environment,
|
|
237
|
+
owner=document.owner,
|
|
238
|
+
endpoint=document.endpoint,
|
|
239
|
+
key_document_version=document.version,
|
|
240
|
+
)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "authweave-webhooks"
|
|
3
|
+
version = "7.1.2"
|
|
4
|
+
description = "Asymmetric Standard Webhooks toolkit for AuthWeave integrations"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [
|
|
9
|
+
{name = "Vladislav Shepilov", email = "shepilov.v@protonmail.com"},
|
|
10
|
+
]
|
|
11
|
+
maintainers = [
|
|
12
|
+
{name = "Vladislav Shepilov", email = "shepilov.v@protonmail.com"},
|
|
13
|
+
]
|
|
14
|
+
keywords = ["webhooks", "ed25519", "standard-webhooks", "authentication", "security"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Topic :: Security",
|
|
19
|
+
"Topic :: Software Development :: Libraries",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Programming Language :: Python :: 3.14",
|
|
24
|
+
]
|
|
25
|
+
requires-python = "<3.15.0,>=3.12.0"
|
|
26
|
+
dependencies = [
|
|
27
|
+
"authweave-core==7.1.2",
|
|
28
|
+
"cryptography>=50.0.0,<51.0",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
redis = ["redis>=8.1.0,<9.0"]
|
|
33
|
+
httpx = ["httpx>=0.28.1,<1.0"]
|
|
34
|
+
litestar = ["litestar>=2.24.0,<3.0"]
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
homepage = "https://github.com/ZYLVEXT/litestar-auth"
|
|
38
|
+
documentation = "https://zylvext.github.io/litestar-auth/"
|
|
39
|
+
source = "https://github.com/ZYLVEXT/litestar-auth"
|
|
40
|
+
tracker = "https://github.com/ZYLVEXT/litestar-auth/issues"
|
|
41
|
+
|
|
42
|
+
[build-system]
|
|
43
|
+
requires = ["hatchling==1.31.0"]
|
|
44
|
+
build-backend = "hatchling.build"
|
|
45
|
+
|
|
46
|
+
[tool.hatch.build.targets.wheel]
|
|
47
|
+
packages = ["authweave_webhooks"]
|
|
48
|
+
|
|
49
|
+
[tool.hatch.build.targets.sdist]
|
|
50
|
+
only-include = ["authweave_webhooks"]
|
|
51
|
+
|
|
52
|
+
[tool.coverage.run]
|
|
53
|
+
source = ["authweave_webhooks"]
|
|
54
|
+
branch = true
|
|
55
|
+
|
|
56
|
+
[tool.coverage.report]
|
|
57
|
+
fail_under = 100
|
|
58
|
+
show_missing = true
|
|
59
|
+
|
|
60
|
+
[tool.ty.environment]
|
|
61
|
+
python-version = "3.12"
|
|
62
|
+
|
|
63
|
+
[tool.ty.src]
|
|
64
|
+
include = ["authweave_webhooks", "tests"]
|
|
65
|
+
|
|
66
|
+
[tool.deptry]
|
|
67
|
+
experimental_namespace_package = true
|
|
68
|
+
|
|
69
|
+
[tool.deptry.per_rule_ignores]
|
|
70
|
+
DEP002 = ["httpx", "litestar", "redis"]
|