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 +36 -0
- apron_auth/client.py +282 -0
- apron_auth/errors.py +35 -0
- apron_auth/models.py +152 -0
- apron_auth/pkce.py +25 -0
- apron_auth/protocols.py +85 -0
- apron_auth/providers/__init__.py +3 -0
- apron_auth/providers/atlassian.py +71 -0
- apron_auth/providers/github.py +141 -0
- apron_auth/providers/google.py +90 -0
- apron_auth/providers/hubspot.py +126 -0
- apron_auth/providers/linear.py +41 -0
- apron_auth/providers/microsoft.py +78 -0
- apron_auth/providers/notion.py +109 -0
- apron_auth/providers/salesforce.py +82 -0
- apron_auth/providers/slack.py +98 -0
- apron_auth/providers/typeform.py +42 -0
- apron_auth/py.typed +0 -0
- apron_auth/scopes.py +20 -0
- apron_auth/stores.py +71 -0
- apron_auth-0.1.0.dist-info/METADATA +274 -0
- apron_auth-0.1.0.dist-info/RECORD +25 -0
- apron_auth-0.1.0.dist-info/WHEEL +5 -0
- apron_auth-0.1.0.dist-info/licenses/LICENSE +201 -0
- apron_auth-0.1.0.dist-info/top_level.txt +1 -0
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("=")
|
apron_auth/protocols.py
ADDED
|
@@ -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
|