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 ADDED
@@ -0,0 +1,46 @@
1
+ # authplane-sdk
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/authplane-sdk?style=flat-square&label=authplane-sdk)](https://pypi.org/project/authplane-sdk/)
4
+ [![Python versions](https://img.shields.io/pypi/pyversions/authplane-sdk?style=flat-square)](https://pypi.org/project/authplane-sdk/)
5
+ [![License](https://img.shields.io/badge/License-Apache_2.0-blue?style=flat-square)](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