authplane-sdk 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.
- authplane/README.md +46 -0
- authplane/__init__.py +116 -0
- authplane/auth_provider.py +28 -0
- authplane/cache.py +78 -0
- authplane/circuit_breaker.py +90 -0
- authplane/client.py +483 -0
- authplane/credentials.py +34 -0
- authplane/docs/user-guide.md +629 -0
- authplane/dpop.py +363 -0
- authplane/dpop_verification.py +191 -0
- authplane/errors.py +363 -0
- authplane/internal/__init__.py +26 -0
- authplane/internal/cache_headers.py +55 -0
- authplane/internal/document_cache.py +205 -0
- authplane/internal/document_fetcher.py +115 -0
- authplane/internal/fetch_result.py +19 -0
- authplane/internal/jwt.py +17 -0
- authplane/internal/metadata.py +110 -0
- authplane/internal/urls.py +95 -0
- authplane/net/__init__.py +16 -0
- authplane/net/fetch_settings.py +56 -0
- authplane/net/http.py +113 -0
- authplane/net/ip_validation.py +149 -0
- authplane/net/ssrf.py +367 -0
- authplane/oauth/__init__.py +29 -0
- authplane/oauth/client_credentials.py +72 -0
- authplane/oauth/introspection.py +84 -0
- authplane/oauth/parsing.py +82 -0
- authplane/oauth/prm.py +41 -0
- authplane/oauth/revocation.py +61 -0
- authplane/oauth/token_exchange.py +92 -0
- authplane/oauth/types.py +88 -0
- authplane/py.typed +0 -0
- authplane/verifier/__init__.py +6 -0
- authplane/verifier/claims.py +84 -0
- authplane/verifier/verifier.py +413 -0
- authplane_sdk-0.1.0.dist-info/METADATA +78 -0
- authplane_sdk-0.1.0.dist-info/RECORD +40 -0
- authplane_sdk-0.1.0.dist-info/WHEEL +4 -0
- authplane_sdk-0.1.0.dist-info/licenses/LICENSE +191 -0
authplane/README.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# authplane-sdk
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/authplane-sdk/)
|
|
4
|
+
[](https://pypi.org/project/authplane-sdk/)
|
|
5
|
+
[](https://opensource.org/licenses/Apache-2.0)
|
|
6
|
+
|
|
7
|
+
Framework-agnostic OAuth 2.1 JWT validation and token operations for Python resource servers.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install authplane-sdk
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Quickstart
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
import asyncio
|
|
19
|
+
from authplane import ASCredentials, AuthplaneClient
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def main():
|
|
23
|
+
client = await AuthplaneClient.create(
|
|
24
|
+
issuer="https://auth.example.com",
|
|
25
|
+
auth=ASCredentials(client_id="my-resource", client_secret="s3cret"),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
res = client.resource(
|
|
29
|
+
resource="https://api.example.com",
|
|
30
|
+
scopes=["read", "write"],
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
claims = await res.verify(incoming_jwt)
|
|
34
|
+
print(claims.sub, claims.scopes)
|
|
35
|
+
|
|
36
|
+
await client.aclose()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
asyncio.run(main())
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Call `await client.aclose()` on shutdown to stop background JWKS and metadata refresh tasks.
|
|
43
|
+
|
|
44
|
+
## Documentation
|
|
45
|
+
|
|
46
|
+
Full API reference, configuration options, error hierarchy, DPoP, token operations, introspection, token exchange, and advanced usage: **[User Guide](docs/user-guide.md)**.
|
authplane/__init__.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Authplane Python SDK — OAuth 2.1 JWT validation and token operations for protected resources."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
# Client
|
|
6
|
+
# Authentication
|
|
7
|
+
from .auth_provider import AuthProvider, ClientCredentialsProvider
|
|
8
|
+
from .client import AuthplaneClient
|
|
9
|
+
from .credentials import ASCredentials
|
|
10
|
+
from .dpop import (
|
|
11
|
+
SUPPORTED_DPOP_ALGORITHMS,
|
|
12
|
+
DPoPKeyMaterial,
|
|
13
|
+
DPoPNonceStore,
|
|
14
|
+
DPoPProvider,
|
|
15
|
+
DPoPReplayStore,
|
|
16
|
+
DPoPRequestContext,
|
|
17
|
+
InboundDPoPOptions,
|
|
18
|
+
InMemoryDPoPNonceStore,
|
|
19
|
+
InMemoryDPoPReplayStore,
|
|
20
|
+
)
|
|
21
|
+
from .dpop_verification import VerifiedDPoPProof
|
|
22
|
+
|
|
23
|
+
# Errors (base + the one that changes HTTP status)
|
|
24
|
+
from .errors import (
|
|
25
|
+
AuthError,
|
|
26
|
+
AuthplaneError,
|
|
27
|
+
CircuitOpenError,
|
|
28
|
+
ConsentRequiredError,
|
|
29
|
+
DPoPBindingMismatchError,
|
|
30
|
+
DPoPError,
|
|
31
|
+
DPoPNotSupportedError,
|
|
32
|
+
DPoPProofMissingError,
|
|
33
|
+
DPoPReplayDetectedError,
|
|
34
|
+
InsufficientScopeError,
|
|
35
|
+
InvalidClaimsError,
|
|
36
|
+
InvalidClientError,
|
|
37
|
+
InvalidDPoPProofError,
|
|
38
|
+
InvalidGrantError,
|
|
39
|
+
InvalidRequestError,
|
|
40
|
+
InvalidScopeError,
|
|
41
|
+
InvalidSignatureError,
|
|
42
|
+
JWKSFetchError,
|
|
43
|
+
MetadataFetchError,
|
|
44
|
+
MissingMetadataEndpointError,
|
|
45
|
+
ProtocolError,
|
|
46
|
+
ServerError,
|
|
47
|
+
TokenExpiredError,
|
|
48
|
+
TokenMissingError,
|
|
49
|
+
TokenRevokedError,
|
|
50
|
+
UnauthorizedClientError,
|
|
51
|
+
UnsupportedGrantTypeError,
|
|
52
|
+
VerifierRuntimeError,
|
|
53
|
+
http_status,
|
|
54
|
+
www_authenticate,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Configuration
|
|
58
|
+
from .net import FetchSettings
|
|
59
|
+
from .oauth.types import IntrospectionRevocation
|
|
60
|
+
|
|
61
|
+
# Resource (verifier)
|
|
62
|
+
from .verifier import AuthplaneResource, VerifiedClaims
|
|
63
|
+
from .verifier.verifier import RevocationChecker
|
|
64
|
+
|
|
65
|
+
__all__ = [
|
|
66
|
+
"SUPPORTED_DPOP_ALGORITHMS",
|
|
67
|
+
"ASCredentials",
|
|
68
|
+
"AuthError",
|
|
69
|
+
"AuthProvider",
|
|
70
|
+
"AuthplaneClient",
|
|
71
|
+
"AuthplaneError",
|
|
72
|
+
"AuthplaneResource",
|
|
73
|
+
"CircuitOpenError",
|
|
74
|
+
"ClientCredentialsProvider",
|
|
75
|
+
"ConsentRequiredError",
|
|
76
|
+
"DPoPBindingMismatchError",
|
|
77
|
+
"DPoPError",
|
|
78
|
+
"DPoPKeyMaterial",
|
|
79
|
+
"DPoPNonceStore",
|
|
80
|
+
"DPoPNotSupportedError",
|
|
81
|
+
"DPoPProofMissingError",
|
|
82
|
+
"DPoPProvider",
|
|
83
|
+
"DPoPReplayDetectedError",
|
|
84
|
+
"DPoPReplayStore",
|
|
85
|
+
"DPoPRequestContext",
|
|
86
|
+
"FetchSettings",
|
|
87
|
+
"InMemoryDPoPNonceStore",
|
|
88
|
+
"InMemoryDPoPReplayStore",
|
|
89
|
+
"InboundDPoPOptions",
|
|
90
|
+
"InsufficientScopeError",
|
|
91
|
+
"IntrospectionRevocation",
|
|
92
|
+
"InvalidClaimsError",
|
|
93
|
+
"InvalidClientError",
|
|
94
|
+
"InvalidDPoPProofError",
|
|
95
|
+
"InvalidGrantError",
|
|
96
|
+
"InvalidRequestError",
|
|
97
|
+
"InvalidScopeError",
|
|
98
|
+
"InvalidSignatureError",
|
|
99
|
+
"JWKSFetchError",
|
|
100
|
+
"MetadataFetchError",
|
|
101
|
+
"MissingMetadataEndpointError",
|
|
102
|
+
"ProtocolError",
|
|
103
|
+
"RevocationChecker",
|
|
104
|
+
"ServerError",
|
|
105
|
+
"TokenExpiredError",
|
|
106
|
+
"TokenMissingError",
|
|
107
|
+
"TokenRevokedError",
|
|
108
|
+
"UnauthorizedClientError",
|
|
109
|
+
"UnsupportedGrantTypeError",
|
|
110
|
+
"VerifiedClaims",
|
|
111
|
+
"VerifiedDPoPProof",
|
|
112
|
+
"VerifierRuntimeError",
|
|
113
|
+
"__version__",
|
|
114
|
+
"http_status",
|
|
115
|
+
"www_authenticate",
|
|
116
|
+
]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""AuthProvider protocol — pluggable authentication for OAuth operations."""
|
|
2
|
+
|
|
3
|
+
from typing import Protocol, runtime_checkable
|
|
4
|
+
|
|
5
|
+
from .net.http import build_basic_auth_header
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@runtime_checkable
|
|
9
|
+
class AuthProvider(Protocol):
|
|
10
|
+
"""Protocol for providing authentication headers to OAuth endpoints.
|
|
11
|
+
|
|
12
|
+
Implementations return the headers dict (e.g. ``{"Authorization": "Basic ..."}``).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def auth_headers(self) -> dict[str, str]:
|
|
16
|
+
"""Return HTTP headers for authenticating to the AS."""
|
|
17
|
+
...
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ClientCredentialsProvider:
|
|
21
|
+
"""HTTP Basic Auth from client_id + client_secret (RFC 6749 §2.3.1)."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, client_id: str, client_secret: str) -> None:
|
|
24
|
+
self._headers = build_basic_auth_header(client_id, client_secret)
|
|
25
|
+
|
|
26
|
+
def auth_headers(self) -> dict[str, str]:
|
|
27
|
+
"""Return Basic auth headers built from client_id and client_secret."""
|
|
28
|
+
return self._headers
|
authplane/cache.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Token cache with TTL buffer for client_credentials tokens."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class CacheEntry:
|
|
9
|
+
"""A cached token."""
|
|
10
|
+
|
|
11
|
+
access_token: str
|
|
12
|
+
token_type: str
|
|
13
|
+
expires_in: int
|
|
14
|
+
scope: str
|
|
15
|
+
expires_at: float # monotonic time
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TokenCache:
|
|
19
|
+
"""In-memory token cache with configurable TTL buffer.
|
|
20
|
+
|
|
21
|
+
Tokens are evicted `ttl_buffer_seconds` before their actual expiry
|
|
22
|
+
to avoid using tokens that are about to expire.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
ttl_buffer_seconds: float = 30.0,
|
|
28
|
+
default_ttl_seconds: float = 3600.0,
|
|
29
|
+
) -> None:
|
|
30
|
+
self._ttl_buffer = ttl_buffer_seconds
|
|
31
|
+
self._default_ttl = default_ttl_seconds
|
|
32
|
+
self._entries: dict[str, CacheEntry] = {}
|
|
33
|
+
|
|
34
|
+
def get(self, key: str) -> CacheEntry | None:
|
|
35
|
+
"""Get a cached entry if it exists and hasn't expired."""
|
|
36
|
+
entry = self._entries.get(key)
|
|
37
|
+
if entry is None:
|
|
38
|
+
return None
|
|
39
|
+
if time.monotonic() >= entry.expires_at:
|
|
40
|
+
del self._entries[key]
|
|
41
|
+
return None
|
|
42
|
+
return entry
|
|
43
|
+
|
|
44
|
+
def set(
|
|
45
|
+
self,
|
|
46
|
+
key: str,
|
|
47
|
+
access_token: str,
|
|
48
|
+
token_type: str,
|
|
49
|
+
expires_in: int = 0,
|
|
50
|
+
scope: str = "",
|
|
51
|
+
) -> None:
|
|
52
|
+
"""Cache a token. Skips caching if effective TTL <= 0."""
|
|
53
|
+
ttl = (expires_in if expires_in > 0 else self._default_ttl) - self._ttl_buffer
|
|
54
|
+
if ttl <= 0:
|
|
55
|
+
return
|
|
56
|
+
self._entries[key] = CacheEntry(
|
|
57
|
+
access_token=access_token,
|
|
58
|
+
token_type=token_type,
|
|
59
|
+
expires_in=expires_in,
|
|
60
|
+
scope=scope,
|
|
61
|
+
expires_at=time.monotonic() + ttl,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def delete(self, key: str) -> None:
|
|
65
|
+
"""Remove a cached entry."""
|
|
66
|
+
self._entries.pop(key, None)
|
|
67
|
+
|
|
68
|
+
@staticmethod
|
|
69
|
+
def cache_key(scope: str = "", resource: str = "") -> str:
|
|
70
|
+
"""Generate a deterministic cache key from scope and resource.
|
|
71
|
+
|
|
72
|
+
Scopes are sorted for consistency.
|
|
73
|
+
"""
|
|
74
|
+
parts = sorted(scope.split()) if scope else []
|
|
75
|
+
scope_part = " ".join(parts)
|
|
76
|
+
if resource:
|
|
77
|
+
return f"{scope_part}|{resource}" if scope_part else f"|{resource}"
|
|
78
|
+
return scope_part or "_default"
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Circuit breaker for AS resilience."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from enum import Enum
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class _State(Enum):
|
|
8
|
+
CLOSED = "closed"
|
|
9
|
+
OPEN = "open"
|
|
10
|
+
HALF_OPEN = "half_open"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CircuitBreaker:
|
|
14
|
+
"""Protects against cascading failures when the AS is unavailable."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
threshold: int = 5,
|
|
19
|
+
cooldown_seconds: float = 30.0,
|
|
20
|
+
) -> None:
|
|
21
|
+
self._threshold = threshold
|
|
22
|
+
self._cooldown_seconds = cooldown_seconds
|
|
23
|
+
self._state = _State.CLOSED
|
|
24
|
+
self._failure_count = 0
|
|
25
|
+
self._opened_at = 0.0
|
|
26
|
+
self._probe_in_flight = False
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def state(self) -> str:
|
|
30
|
+
"""Return the current circuit state ('closed', 'open', or 'half_open')."""
|
|
31
|
+
return self._effective_state().value
|
|
32
|
+
|
|
33
|
+
def _effective_state(self) -> _State:
|
|
34
|
+
# OPEN transitions to a logical HALF_OPEN view after the cooldown
|
|
35
|
+
# window expires. We delay mutating the stored state until a caller
|
|
36
|
+
# actually attempts the probe so repeated state checks remain cheap.
|
|
37
|
+
if (
|
|
38
|
+
self._state == _State.OPEN
|
|
39
|
+
and time.monotonic() - self._opened_at >= self._cooldown_seconds
|
|
40
|
+
):
|
|
41
|
+
return _State.HALF_OPEN
|
|
42
|
+
return self._state
|
|
43
|
+
|
|
44
|
+
def allow(self) -> bool:
|
|
45
|
+
"""Return True if a request should be attempted, False if shed."""
|
|
46
|
+
state = self._effective_state()
|
|
47
|
+
if state == _State.CLOSED:
|
|
48
|
+
return True
|
|
49
|
+
if state == _State.OPEN:
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
# HALF_OPEN allows exactly one in-flight probe. Everybody else keeps
|
|
53
|
+
# seeing the circuit as unavailable until that probe succeeds or fails.
|
|
54
|
+
if self._state != _State.HALF_OPEN:
|
|
55
|
+
self._state = _State.HALF_OPEN
|
|
56
|
+
if self._probe_in_flight:
|
|
57
|
+
return False
|
|
58
|
+
self._probe_in_flight = True
|
|
59
|
+
return True
|
|
60
|
+
|
|
61
|
+
def record_success(self) -> None:
|
|
62
|
+
"""Record a successful request, closing the circuit if it was half-open."""
|
|
63
|
+
# Any success, including the half-open probe, fully restores traffic.
|
|
64
|
+
self._failure_count = 0
|
|
65
|
+
self._state = _State.CLOSED
|
|
66
|
+
self._probe_in_flight = False
|
|
67
|
+
|
|
68
|
+
def record_failure(self) -> None:
|
|
69
|
+
"""Record a failed request, opening the circuit when the threshold is reached."""
|
|
70
|
+
effective_state = self._effective_state()
|
|
71
|
+
# A failed half-open probe immediately re-opens the circuit instead of
|
|
72
|
+
# walking failure_count up again.
|
|
73
|
+
if self._probe_in_flight and (
|
|
74
|
+
effective_state == _State.HALF_OPEN or self._state == _State.HALF_OPEN
|
|
75
|
+
):
|
|
76
|
+
self._state = _State.OPEN
|
|
77
|
+
self._opened_at = time.monotonic()
|
|
78
|
+
self._probe_in_flight = False
|
|
79
|
+
self._failure_count = self._threshold
|
|
80
|
+
return
|
|
81
|
+
if effective_state == _State.HALF_OPEN and self._state == _State.OPEN:
|
|
82
|
+
# Ignore failures that arrive after cooldown but before a probe was
|
|
83
|
+
# actually admitted; they should not reset the cooldown timer.
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
self._failure_count += 1
|
|
87
|
+
if self._failure_count >= self._threshold:
|
|
88
|
+
self._state = _State.OPEN
|
|
89
|
+
self._opened_at = time.monotonic()
|
|
90
|
+
self._probe_in_flight = False
|