apron-auth 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.
apron_auth/__init__.py ADDED
@@ -0,0 +1,36 @@
1
+ """Stateless OAuth 2.0 protocol library."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from apron_auth.client import OAuthClient
6
+ from apron_auth.errors import (
7
+ ConfigurationError,
8
+ OAuthError,
9
+ PermanentOAuthError,
10
+ RevocationError,
11
+ StateError,
12
+ TokenExchangeError,
13
+ TokenRefreshError,
14
+ )
15
+ from apron_auth.models import OAuthPendingState, ProviderConfig, ScopeMetadata, TokenSet
16
+ from apron_auth.protocols import RevocationHandler, StandardRevocationHandler, StateStore
17
+ from apron_auth.stores import MemoryStateStore
18
+
19
+ __all__ = [
20
+ "ConfigurationError",
21
+ "MemoryStateStore",
22
+ "OAuthClient",
23
+ "OAuthError",
24
+ "OAuthPendingState",
25
+ "PermanentOAuthError",
26
+ "ProviderConfig",
27
+ "RevocationError",
28
+ "RevocationHandler",
29
+ "ScopeMetadata",
30
+ "StandardRevocationHandler",
31
+ "StateError",
32
+ "StateStore",
33
+ "TokenExchangeError",
34
+ "TokenRefreshError",
35
+ "TokenSet",
36
+ ]
apron_auth/client.py ADDED
@@ -0,0 +1,282 @@
1
+ """OAuth 2.0 client for authorization code flows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import secrets
6
+ import time
7
+ from typing import TYPE_CHECKING, Any
8
+ from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
9
+
10
+ import httpx
11
+
12
+ from apron_auth.errors import (
13
+ ConfigurationError,
14
+ PermanentOAuthError,
15
+ RevocationError,
16
+ StateError,
17
+ TokenExchangeError,
18
+ TokenRefreshError,
19
+ )
20
+ from apron_auth.models import OAuthPendingState, TokenSet
21
+ from apron_auth.pkce import generate_code_challenge, generate_code_verifier
22
+ from apron_auth.protocols import StandardRevocationHandler
23
+ from apron_auth.scopes import join_scopes
24
+
25
+ if TYPE_CHECKING:
26
+ from apron_auth.models import ProviderConfig
27
+ from apron_auth.protocols import RevocationHandler, StateStore
28
+
29
+
30
+ class _TokenEndpointError(Exception):
31
+ """Internal exception carrying the OAuth error code from the token endpoint."""
32
+
33
+ def __init__(self, message: str, error_code: str = "") -> None:
34
+ super().__init__(message)
35
+ self.error_code = error_code
36
+
37
+
38
+ class OAuthClient:
39
+ """Stateless OAuth 2.0 client for authorization code flows."""
40
+
41
+ DEFAULT_PERMANENT_ERROR_CODES = frozenset({"invalid_grant", "unauthorized_client", "invalid_client"})
42
+
43
+ def __init__(
44
+ self,
45
+ config: ProviderConfig,
46
+ state_store: StateStore | None = None,
47
+ revocation_handler: RevocationHandler | None = None,
48
+ permanent_error_codes: set[str] | None = None,
49
+ ) -> None:
50
+ """Create an OAuth client.
51
+
52
+ Args:
53
+ config: Provider endpoints, credentials, and behavior.
54
+ state_store: Optional persistence for OAuth state across requests.
55
+ revocation_handler: Optional provider-specific token revocation.
56
+ permanent_error_codes: Additional OAuth error codes that should be
57
+ treated as irrecoverable during token refresh. These merge
58
+ with, rather than replace, DEFAULT_PERMANENT_ERROR_CODES.
59
+ """
60
+ self._config = config
61
+ self._state_store = state_store
62
+ self._revocation_handler = revocation_handler
63
+ self._permanent_error_codes = self.DEFAULT_PERMANENT_ERROR_CODES | (permanent_error_codes or set())
64
+
65
+ async def get_authorization_url(
66
+ self,
67
+ redirect_uri: str | None = None,
68
+ metadata: dict[str, Any] | None = None,
69
+ ) -> tuple[str, OAuthPendingState]:
70
+ """Build an authorization URL with state and optional PKCE.
71
+
72
+ If a ``StateStore`` is configured, the pending state is saved
73
+ automatically before returning.
74
+
75
+ Args:
76
+ redirect_uri: Override the redirect URI from ``ProviderConfig``.
77
+ metadata: Opaque caller context attached to the pending state.
78
+ Carried through ``StateStore`` save/consume and surfaced
79
+ on ``TokenSet.context`` when ``exchange_code`` auto-consumes.
80
+
81
+ Returns:
82
+ A tuple of the authorization URL and the pending state.
83
+
84
+ Raises:
85
+ ConfigurationError: If no redirect URI is available.
86
+ """
87
+ effective_redirect_uri = redirect_uri or self._config.redirect_uri
88
+ if not effective_redirect_uri:
89
+ msg = "redirect_uri must be provided either in the method call or in ProviderConfig"
90
+ raise ConfigurationError(msg)
91
+
92
+ state = secrets.token_urlsafe(32)
93
+ code_verifier = None
94
+
95
+ params: dict[str, str] = {
96
+ "client_id": self._config.client_id,
97
+ "response_type": "code",
98
+ "redirect_uri": effective_redirect_uri,
99
+ "state": state,
100
+ }
101
+
102
+ if self._config.scopes:
103
+ params["scope"] = join_scopes(self._config.scopes, self._config.scope_separator)
104
+
105
+ if self._config.use_pkce:
106
+ code_verifier = generate_code_verifier()
107
+ params["code_challenge"] = generate_code_challenge(code_verifier)
108
+ params["code_challenge_method"] = "S256"
109
+
110
+ params.update(self._config.extra_params)
111
+
112
+ parsed = urlparse(self._config.authorize_url)
113
+ existing_params = parse_qs(parsed.query)
114
+ merged = {k: v[0] if len(v) == 1 else v for k, v in existing_params.items()}
115
+ merged.update(params)
116
+ url = urlunparse(parsed._replace(query=urlencode(merged, doseq=True)))
117
+
118
+ pending_state = OAuthPendingState(
119
+ state=state,
120
+ redirect_uri=effective_redirect_uri,
121
+ code_verifier=code_verifier,
122
+ created_at=time.time(),
123
+ metadata=metadata or {},
124
+ )
125
+
126
+ if self._state_store is not None:
127
+ await self._state_store.save(pending_state)
128
+
129
+ return url, pending_state
130
+
131
+ async def exchange_code(
132
+ self,
133
+ code: str,
134
+ state: str | None = None,
135
+ redirect_uri: str | None = None,
136
+ code_verifier: str | None = None,
137
+ ) -> TokenSet:
138
+ """Exchange an authorization code for tokens.
139
+
140
+ Two modes:
141
+ - Pass state to consume from StateStore and retrieve stored
142
+ redirect_uri and code_verifier.
143
+ - Pass redirect_uri and code_verifier directly.
144
+ """
145
+ context: dict[str, Any] = {}
146
+ if state is not None and self._state_store is not None:
147
+ pending = await self._state_store.consume(state)
148
+ if pending is None:
149
+ msg = "OAuth state is invalid, expired, or already consumed"
150
+ raise StateError(msg)
151
+ redirect_uri = pending.redirect_uri
152
+ code_verifier = pending.code_verifier
153
+ context = pending.metadata
154
+
155
+ data: dict[str, str] = {
156
+ "grant_type": "authorization_code",
157
+ "code": code,
158
+ }
159
+ if redirect_uri:
160
+ data["redirect_uri"] = redirect_uri
161
+ if code_verifier:
162
+ data["code_verifier"] = code_verifier
163
+
164
+ try:
165
+ response = await self._token_request(data)
166
+ except _TokenEndpointError as exc:
167
+ raise TokenExchangeError(str(exc)) from exc
168
+ return self._parse_token_response(response, context=context)
169
+
170
+ async def refresh_token(self, refresh_token: str) -> TokenSet:
171
+ """Refresh an access token using a refresh token.
172
+
173
+ Raises PermanentOAuthError for irrecoverable failures
174
+ (invalid_grant, unauthorized_client, invalid_client).
175
+ Raises TokenRefreshError for transient failures.
176
+ """
177
+ data = {
178
+ "grant_type": "refresh_token",
179
+ "refresh_token": refresh_token,
180
+ }
181
+ try:
182
+ response = await self._token_request(data)
183
+ except _TokenEndpointError as exc:
184
+ if exc.error_code in self._permanent_error_codes:
185
+ raise PermanentOAuthError(str(exc)) from exc
186
+ raise TokenRefreshError(str(exc)) from exc
187
+ except Exception as exc:
188
+ raise TokenRefreshError(str(exc)) from exc
189
+ return self._parse_token_response(response)
190
+
191
+ async def revoke_token(self, token: str) -> bool:
192
+ """Revoke a token via the provider's revocation endpoint.
193
+
194
+ Uses the configured RevocationHandler, or falls back to
195
+ StandardRevocationHandler (RFC 7009 POST).
196
+ """
197
+ if not self._config.revocation_url:
198
+ msg = "revocation_url is not configured for this provider"
199
+ raise ConfigurationError(msg)
200
+
201
+ handler = self._revocation_handler
202
+ if handler is None:
203
+ handler = StandardRevocationHandler()
204
+
205
+ try:
206
+ result = await handler.revoke(token, self._config)
207
+ except RevocationError:
208
+ raise
209
+ except Exception as exc:
210
+ raise RevocationError(str(exc)) from exc
211
+ if not result:
212
+ msg = "Token revocation failed"
213
+ raise RevocationError(msg)
214
+ return True
215
+
216
+ async def _token_request(self, data: dict[str, str]) -> dict:
217
+ """Send a token request via authlib's AsyncOAuth2Client.
218
+
219
+ Authlib handles token_endpoint_auth_method (client_secret_post vs
220
+ client_secret_basic), request encoding, and response parsing.
221
+ Three error paths are possible — see inline comments.
222
+ """
223
+ from authlib.integrations.base_client.errors import OAuthError
224
+ from authlib.integrations.httpx_client import AsyncOAuth2Client
225
+
226
+ try:
227
+ async with AsyncOAuth2Client(
228
+ client_id=self._config.client_id,
229
+ client_secret=self._config.client_secret.get_secret_value(),
230
+ token_endpoint_auth_method=self._config.token_endpoint_auth_method,
231
+ ) as client:
232
+ token = await client.fetch_token(self._config.token_url, **data)
233
+ return dict(token)
234
+ except OAuthError as exc:
235
+ # Authlib raises OAuthError for 4xx responses that contain an
236
+ # OAuth error body ({"error": "...", "error_description": "..."}).
237
+ # The .error attribute carries the OAuth error code (e.g.
238
+ # "invalid_grant") which refresh_token uses to distinguish
239
+ # permanent from transient failures.
240
+ raise _TokenEndpointError(
241
+ f"{exc.error}: {exc.description}" if exc.description else str(exc.error),
242
+ error_code=str(exc.error),
243
+ ) from exc
244
+ except httpx.HTTPStatusError as exc:
245
+ # Authlib calls raise_for_status() for 5xx responses, producing
246
+ # an httpx.HTTPStatusError. We attempt to extract the OAuth
247
+ # error code from the response body if present.
248
+ error_code = ""
249
+ msg = f"HTTP {exc.response.status_code}"
250
+ try:
251
+ body = exc.response.json()
252
+ error_code = body.get("error", "")
253
+ description = body.get("error_description", "")
254
+ msg = f"{error_code}: {description}" if description else error_code or msg
255
+ except Exception:
256
+ pass
257
+ raise _TokenEndpointError(msg, error_code=error_code) from exc
258
+ except Exception as exc:
259
+ # Network errors (ConnectError, TimeoutException), JSON decode
260
+ # errors, or anything else. No error_code available.
261
+ raise _TokenEndpointError(str(exc), error_code="") from exc
262
+
263
+ def _parse_token_response(self, data: dict, context: dict[str, Any] | None = None) -> TokenSet:
264
+ """Parse a token endpoint response into a TokenSet."""
265
+ known_fields = {"access_token", "token_type", "refresh_token", "expires_in", "expires_at", "scope"}
266
+ metadata = {k: v for k, v in data.items() if k not in known_fields}
267
+
268
+ expires_at = data.get("expires_at")
269
+ expires_in = data.get("expires_in")
270
+ if expires_at is None and expires_in is not None:
271
+ expires_at = time.time() + int(expires_in)
272
+
273
+ return TokenSet(
274
+ access_token=data["access_token"],
275
+ token_type=data.get("token_type", "Bearer"),
276
+ refresh_token=data.get("refresh_token"),
277
+ expires_in=int(expires_in) if expires_in is not None else None,
278
+ expires_at=expires_at,
279
+ scope=data.get("scope"),
280
+ metadata=metadata,
281
+ context=context or {},
282
+ )
apron_auth/errors.py ADDED
@@ -0,0 +1,35 @@
1
+ """Exception hierarchy for apron-auth OAuth operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class OAuthError(Exception):
7
+ """Base exception for all apron-auth errors."""
8
+
9
+
10
+ class TokenExchangeError(OAuthError):
11
+ """Authorization code exchange failed."""
12
+
13
+
14
+ class TokenRefreshError(OAuthError):
15
+ """Token refresh failed (transient — retry may succeed)."""
16
+
17
+
18
+ class PermanentOAuthError(OAuthError):
19
+ """Irrecoverable OAuth failure.
20
+
21
+ Raised for errors like invalid_grant, unauthorized_client, or
22
+ invalid_client. The caller should delete the stored token.
23
+ """
24
+
25
+
26
+ class RevocationError(OAuthError):
27
+ """Token revocation failed at the provider."""
28
+
29
+
30
+ class StateError(OAuthError):
31
+ """OAuth state invalid, expired, or already consumed."""
32
+
33
+
34
+ class ConfigurationError(OAuthError):
35
+ """Provider configuration is invalid or incomplete."""
apron_auth/models.py ADDED
@@ -0,0 +1,152 @@
1
+ """Data models for OAuth configuration, tokens, and state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Literal
6
+
7
+ from pydantic import BaseModel, SecretStr
8
+
9
+ AccessType = Literal["read", "write", "admin"]
10
+
11
+
12
+ class ScopeMetadata(BaseModel, frozen=True):
13
+ """Consent-UI metadata for a single OAuth scope.
14
+
15
+ Field shape mirrors apron-tools' ``ScopeMetadata`` so a consumer can
16
+ concatenate ``CapabilityGroup.metadata()`` from apron-tools with
17
+ :attr:`ProviderConfig.scope_metadata` to produce a complete
18
+ ``list[ScopeMetadata]`` for a consent picker. apron-auth declares
19
+ metadata only for the cross-cutting scopes its presets inject;
20
+ apron-tools owns metadata for tool-level scopes.
21
+
22
+ Attributes:
23
+ scope: The OAuth scope string sent to the provider, exactly as it
24
+ appears in :attr:`ProviderConfig.scopes`.
25
+ label: Short human-readable name for the consent picker.
26
+ description: Longer explanation of what granting this scope does;
27
+ should match the provider's authorize-page wording where
28
+ possible so the consent picker stays in sync with what the
29
+ user sees at the provider.
30
+ access_type: Coarse classification used for visual grouping in
31
+ the consent picker.
32
+ required: When ``True``, the consumer's UI should not allow the
33
+ user to deselect the scope — typically because the OAuth
34
+ flow itself depends on it (e.g. ``openid`` for identity,
35
+ ``offline_access`` for refresh tokens).
36
+ """
37
+
38
+ scope: str
39
+ label: str
40
+ description: str
41
+ access_type: AccessType
42
+ required: bool = False
43
+
44
+
45
+ class ProviderConfig(BaseModel, frozen=True):
46
+ """OAuth provider configuration — endpoints, credentials, behaviour.
47
+
48
+ Attributes:
49
+ disconnect_fully_revokes: Whether ``revoke_token`` removes the
50
+ user's portal-level OAuth grant.
51
+
52
+ When ``True``, calling
53
+ :meth:`~apron_auth.client.OAuthClient.revoke_token` is
54
+ sufficient to force a fresh consent screen on the next
55
+ authorization flow — enabling automatic scope-reduction
56
+ (tier 1) end-to-end inside the OAuth flow.
57
+
58
+ When ``False``, revocation only invalidates the current
59
+ token. A subsequent authorization flow reuses the existing
60
+ portal-level grant and the user keeps their previously-
61
+ granted scopes regardless of what's requested. Consumers
62
+ must surface a deep link to the provider's app-management
63
+ settings (tier 3) for the user to remove the grant
64
+ manually.
65
+
66
+ Defaults to ``False``. Over-claiming silently breaks scope
67
+ reduction; under-claiming harmlessly falls back to the
68
+ manual deep-link path.
69
+ scope_metadata: Consent-UI metadata for the cross-cutting scopes
70
+ the preset injects into :attr:`scopes` (e.g. ``openid``,
71
+ ``offline_access``). Empty when the preset does not inject
72
+ any scopes of its own. Consumers building a consent picker
73
+ should concatenate apron-tools' ``CapabilityGroup.metadata()``
74
+ with this list to cover both tool-level and cross-cutting
75
+ scopes without a parallel hand-maintained table.
76
+ required_scope_families: Set-level scope constraints expressing
77
+ "at least one of these scope sets must be requested". Each
78
+ inner list is an "at-least-one-of" group; the constraint is
79
+ satisfied when the final scope selection contains at least
80
+ one scope drawn from at least one family. Used by providers
81
+ (e.g. Slack) whose token endpoint applies a set-level OR
82
+ rule that cannot be expressed as per-scope
83
+ :attr:`ScopeMetadata.required`. A consent picker can
84
+ enforce the constraint generically without provider-specific
85
+ knowledge. Defaults to empty (no set-level constraint).
86
+ """
87
+
88
+ client_id: str
89
+ client_secret: SecretStr
90
+ authorize_url: str
91
+ token_url: str
92
+ revocation_url: str | None = None
93
+ redirect_uri: str | None = None
94
+ scopes: list[str] = []
95
+ scope_separator: str = " "
96
+ use_pkce: bool = True
97
+ token_endpoint_auth_method: str = "client_secret_post"
98
+ extra_params: dict[str, str] = {}
99
+ disconnect_fully_revokes: bool = False
100
+ scope_metadata: list[ScopeMetadata] = []
101
+ required_scope_families: list[list[str]] = []
102
+
103
+
104
+ class TokenSet(BaseModel, frozen=True):
105
+ """Token data returned from code exchange or refresh.
106
+
107
+ Attributes:
108
+ access_token: The access token issued by the provider.
109
+ token_type: Token type, typically ``"Bearer"``.
110
+ refresh_token: Optional refresh token for obtaining new access tokens.
111
+ expires_in: Token lifetime in seconds as reported by the provider.
112
+ expires_at: Absolute expiry time as a Unix timestamp.
113
+ scope: Space-separated scopes granted by the provider.
114
+ metadata: Additional fields from the provider's token endpoint
115
+ response that are not captured by the named attributes above
116
+ (e.g. Slack's ``team_id``). Populated automatically.
117
+ context: Caller-supplied context carried opaquely from
118
+ ``OAuthPendingState.metadata`` through the authorization flow.
119
+ Populated when ``exchange_code`` auto-consumes from a
120
+ ``StateStore``; empty otherwise.
121
+ """
122
+
123
+ access_token: str
124
+ token_type: str = "Bearer"
125
+ refresh_token: str | None = None
126
+ expires_in: int | None = None
127
+ expires_at: float | None = None
128
+ scope: str | None = None
129
+ metadata: dict[str, Any] = {}
130
+ context: dict[str, Any] = {}
131
+
132
+
133
+ class OAuthPendingState(BaseModel, frozen=True):
134
+ """State stored during the OAuth authorization flow.
135
+
136
+ Attributes:
137
+ state: Unique token identifying this authorization request.
138
+ redirect_uri: Redirect URI for this flow.
139
+ code_verifier: PKCE code verifier, if PKCE is enabled.
140
+ created_at: Unix timestamp when this state was created.
141
+ metadata: Opaque caller-supplied context that apron-auth carries
142
+ but never interprets. Attach application-specific data
143
+ (e.g. ``user_id``, ``tenant_id``) here; it will be preserved
144
+ through ``StateStore`` save/consume and surfaced on
145
+ ``TokenSet.context`` when ``exchange_code`` auto-consumes.
146
+ """
147
+
148
+ state: str
149
+ redirect_uri: str
150
+ code_verifier: str | None = None
151
+ created_at: float
152
+ metadata: dict[str, Any] = {}
apron_auth/pkce.py ADDED
@@ -0,0 +1,25 @@
1
+ """PKCE (Proof Key for Code Exchange) support per RFC 7636."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import secrets
8
+
9
+
10
+ def generate_code_verifier() -> str:
11
+ """Generate a cryptographically random code verifier.
12
+
13
+ Returns a 43-character URL-safe string with 256 bits of entropy.
14
+ """
15
+ return secrets.token_urlsafe(32)
16
+
17
+
18
+ def generate_code_challenge(code_verifier: str) -> str:
19
+ """Generate an S256 code challenge from a code verifier.
20
+
21
+ Computes the SHA-256 hash of the verifier and returns the
22
+ base64url encoding without padding.
23
+ """
24
+ digest = hashlib.sha256(code_verifier.encode("utf-8")).digest()
25
+ return base64.urlsafe_b64encode(digest).decode("utf-8").rstrip("=")
@@ -0,0 +1,85 @@
1
+ """Protocols for caller-provided storage and provider-specific revocation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
6
+
7
+ import httpx
8
+
9
+ from apron_auth.errors import RevocationError
10
+
11
+ if TYPE_CHECKING:
12
+ from apron_auth.models import OAuthPendingState, ProviderConfig
13
+
14
+
15
+ @runtime_checkable
16
+ class StateStore(Protocol):
17
+ """Caller provides persistence for OAuth state.
18
+
19
+ Implementations are responsible for expiring stale entries.
20
+ Each OAuthPendingState carries a created_at timestamp that
21
+ implementations should use to enforce a maximum age (typically
22
+ under 10 minutes for OAuth authorization flows).
23
+
24
+ See MemoryStateStore for a reference implementation with
25
+ automatic expiry.
26
+ """
27
+
28
+ async def save(self, state: OAuthPendingState) -> None:
29
+ """Store pending state during authorization URL generation."""
30
+ ...
31
+
32
+ async def consume(self, state_key: str) -> OAuthPendingState | None:
33
+ """Atomically retrieve and invalidate state.
34
+
35
+ Returns None if the state is invalid, expired, or already consumed.
36
+ """
37
+ ...
38
+
39
+
40
+ @runtime_checkable
41
+ class RevocationHandler(Protocol):
42
+ """Provider-specific token revocation."""
43
+
44
+ async def revoke(self, token: str, config: ProviderConfig) -> bool:
45
+ """Revoke a token at the provider.
46
+
47
+ Returns True if revocation succeeded.
48
+ """
49
+ ...
50
+
51
+
52
+ class StandardRevocationHandler:
53
+ """RFC 7009 token revocation via POST with token in form body."""
54
+
55
+ def __init__(self, client: httpx.AsyncClient | None = None) -> None:
56
+ self._client = client
57
+
58
+ async def revoke(self, token: str, config: ProviderConfig) -> bool:
59
+ """Revoke a token using standard RFC 7009 POST."""
60
+ if config.revocation_url is None:
61
+ msg = "revocation_url is required but not set in ProviderConfig"
62
+ raise ValueError(msg)
63
+ revocation_url = config.revocation_url
64
+ if self._client is not None:
65
+ return await self._send(self._client, token, revocation_url, config)
66
+ async with httpx.AsyncClient() as client:
67
+ return await self._send(client, token, revocation_url, config)
68
+
69
+ async def _send(
70
+ self,
71
+ client: httpx.AsyncClient,
72
+ token: str,
73
+ revocation_url: str,
74
+ config: ProviderConfig,
75
+ ) -> bool:
76
+ """Send the revocation request and return success status."""
77
+ try:
78
+ response = await client.post(
79
+ revocation_url,
80
+ data={"token": token},
81
+ auth=(config.client_id, config.client_secret.get_secret_value()),
82
+ )
83
+ except httpx.RequestError as exc:
84
+ raise RevocationError(str(exc)) from exc
85
+ return response.is_success
@@ -0,0 +1,3 @@
1
+ """Provider presets for common OAuth providers."""
2
+
3
+ from __future__ import annotations