sweatstack 0.61.0__py3-none-any.whl → 0.62.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.
- sweatstack/fastapi/__init__.py +29 -1
- sweatstack/fastapi/config.py +21 -1
- sweatstack/fastapi/dependencies.py +116 -3
- sweatstack/fastapi/models.py +67 -1
- sweatstack/fastapi/routes.py +87 -16
- sweatstack/fastapi/token_stores.py +238 -0
- sweatstack/fastapi/webhooks.py +201 -0
- {sweatstack-0.61.0.dist-info → sweatstack-0.62.0.dist-info}/METADATA +1 -1
- {sweatstack-0.61.0.dist-info → sweatstack-0.62.0.dist-info}/RECORD +11 -9
- {sweatstack-0.61.0.dist-info → sweatstack-0.62.0.dist-info}/WHEEL +0 -0
- {sweatstack-0.61.0.dist-info → sweatstack-0.62.0.dist-info}/entry_points.txt +0 -0
sweatstack/fastapi/__init__.py
CHANGED
|
@@ -65,7 +65,19 @@ from .dependencies import (
|
|
|
65
65
|
SelectedUser,
|
|
66
66
|
SweatStackUser,
|
|
67
67
|
)
|
|
68
|
+
from .models import StoredTokens, TokenStore
|
|
68
69
|
from .routes import instrument
|
|
70
|
+
from .token_stores import EncryptedSQLiteTokenStore, SQLiteTokenStore
|
|
71
|
+
from .webhooks import (
|
|
72
|
+
WebhookError,
|
|
73
|
+
WebhookPayload,
|
|
74
|
+
WebhookPayloadModel,
|
|
75
|
+
WebhookTokenRefreshError,
|
|
76
|
+
WebhookTokenStoreError,
|
|
77
|
+
WebhookUserNotFoundError,
|
|
78
|
+
WebhookVerificationError,
|
|
79
|
+
verify_signature,
|
|
80
|
+
)
|
|
69
81
|
|
|
70
82
|
__all__ = [
|
|
71
83
|
# Configuration
|
|
@@ -74,9 +86,25 @@ __all__ = [
|
|
|
74
86
|
"urls",
|
|
75
87
|
# User types
|
|
76
88
|
"SweatStackUser",
|
|
77
|
-
#
|
|
89
|
+
# User dependencies
|
|
78
90
|
"AuthenticatedUser",
|
|
79
91
|
"OptionalUser",
|
|
80
92
|
"SelectedUser",
|
|
81
93
|
"OptionalSelectedUser",
|
|
94
|
+
# Webhook dependencies
|
|
95
|
+
"WebhookPayload",
|
|
96
|
+
"WebhookPayloadModel",
|
|
97
|
+
# Token storage
|
|
98
|
+
"TokenStore",
|
|
99
|
+
"StoredTokens",
|
|
100
|
+
"SQLiteTokenStore",
|
|
101
|
+
"EncryptedSQLiteTokenStore",
|
|
102
|
+
# Webhook utilities
|
|
103
|
+
"verify_signature",
|
|
104
|
+
# Exceptions
|
|
105
|
+
"WebhookError",
|
|
106
|
+
"WebhookVerificationError",
|
|
107
|
+
"WebhookTokenStoreError",
|
|
108
|
+
"WebhookUserNotFoundError",
|
|
109
|
+
"WebhookTokenRefreshError",
|
|
82
110
|
]
|
sweatstack/fastapi/config.py
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
"""Module-level configuration for the FastAPI plugin."""
|
|
2
2
|
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
3
5
|
import logging
|
|
4
6
|
import os
|
|
5
|
-
from dataclasses import dataclass
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
6
9
|
from urllib.parse import quote
|
|
7
10
|
|
|
8
11
|
from cryptography.fernet import Fernet
|
|
9
12
|
from pydantic import SecretStr
|
|
10
13
|
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from .models import TokenStore
|
|
16
|
+
|
|
11
17
|
logger = logging.getLogger(__name__)
|
|
12
18
|
|
|
13
19
|
|
|
@@ -35,6 +41,8 @@ class FastAPIConfig:
|
|
|
35
41
|
cookie_max_age: int
|
|
36
42
|
auth_route_prefix: str
|
|
37
43
|
redirect_unauthenticated: bool
|
|
44
|
+
webhook_secret: SecretStr | None = None
|
|
45
|
+
token_store: TokenStore | None = None
|
|
38
46
|
|
|
39
47
|
@property
|
|
40
48
|
def redirect_uri(self) -> str:
|
|
@@ -63,6 +71,8 @@ def configure(
|
|
|
63
71
|
cookie_max_age: int = 86400,
|
|
64
72
|
auth_route_prefix: str = "/auth/sweatstack",
|
|
65
73
|
redirect_unauthenticated: bool = True,
|
|
74
|
+
webhook_secret: str | SecretStr | None = None,
|
|
75
|
+
token_store: TokenStore | None = None,
|
|
66
76
|
) -> None:
|
|
67
77
|
"""Configure the FastAPI plugin.
|
|
68
78
|
|
|
@@ -82,6 +92,10 @@ def configure(
|
|
|
82
92
|
auth_route_prefix: URL prefix for auth routes. Defaults to "/auth/sweatstack".
|
|
83
93
|
redirect_unauthenticated: If True, redirect unauthenticated requests to login
|
|
84
94
|
with ?next= set to the current path. If False, return 401. Defaults to True.
|
|
95
|
+
webhook_secret: Secret for verifying webhook signatures. Falls back to
|
|
96
|
+
SWEATSTACK_WEBHOOK_SECRET env var. Required if using WebhookPayload dependency.
|
|
97
|
+
token_store: TokenStore implementation for persisting tokens. Required if using
|
|
98
|
+
AuthenticatedUser in webhook handlers.
|
|
85
99
|
"""
|
|
86
100
|
global _config
|
|
87
101
|
|
|
@@ -90,6 +104,7 @@ def configure(
|
|
|
90
104
|
client_secret = client_secret or os.environ.get("SWEATSTACK_CLIENT_SECRET")
|
|
91
105
|
app_url = app_url or os.environ.get("APP_URL")
|
|
92
106
|
session_secret = session_secret or os.environ.get("SWEATSTACK_SESSION_SECRET")
|
|
107
|
+
webhook_secret = webhook_secret or os.environ.get("SWEATSTACK_WEBHOOK_SECRET")
|
|
93
108
|
|
|
94
109
|
# Validate required parameters
|
|
95
110
|
if not client_id:
|
|
@@ -129,6 +144,9 @@ def configure(
|
|
|
129
144
|
# Normalize prefix (strip trailing slash)
|
|
130
145
|
auth_route_prefix = auth_route_prefix.rstrip("/")
|
|
131
146
|
|
|
147
|
+
# Convert webhook_secret to SecretStr if provided
|
|
148
|
+
webhook_secret_obj = _to_secret(webhook_secret) if webhook_secret else None
|
|
149
|
+
|
|
132
150
|
_config = FastAPIConfig(
|
|
133
151
|
client_id=client_id,
|
|
134
152
|
client_secret=client_secret_obj,
|
|
@@ -139,6 +157,8 @@ def configure(
|
|
|
139
157
|
cookie_max_age=cookie_max_age,
|
|
140
158
|
auth_route_prefix=auth_route_prefix,
|
|
141
159
|
redirect_unauthenticated=redirect_unauthenticated,
|
|
160
|
+
webhook_secret=webhook_secret_obj,
|
|
161
|
+
token_store=token_store,
|
|
142
162
|
)
|
|
143
163
|
|
|
144
164
|
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import logging
|
|
6
6
|
import time
|
|
7
7
|
from dataclasses import dataclass
|
|
8
|
+
from datetime import datetime, timezone
|
|
8
9
|
from typing import Annotated, NoReturn
|
|
9
10
|
from urllib.parse import quote
|
|
10
11
|
|
|
@@ -15,7 +16,7 @@ from ..client import Client
|
|
|
15
16
|
from ..constants import DEFAULT_URL
|
|
16
17
|
from ..utils import decode_jwt_body
|
|
17
18
|
from .config import get_config
|
|
18
|
-
from .models import SessionData, TokenSet, extract_user_id
|
|
19
|
+
from .models import SessionData, StoredTokens, TokenSet, extract_user_id
|
|
19
20
|
from .session import (
|
|
20
21
|
SESSION_COOKIE_NAME,
|
|
21
22
|
clear_session_cookie,
|
|
@@ -58,6 +59,18 @@ def _is_token_expiring(token: str) -> bool:
|
|
|
58
59
|
return True
|
|
59
60
|
|
|
60
61
|
|
|
62
|
+
def _extract_expiry(access_token: str) -> datetime:
|
|
63
|
+
"""Extract expiry time from JWT access token."""
|
|
64
|
+
body = decode_jwt_body(access_token)
|
|
65
|
+
return datetime.fromtimestamp(body["exp"], tz=timezone.utc)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _extract_timezone(access_token: str) -> str:
|
|
69
|
+
"""Extract timezone from JWT access token."""
|
|
70
|
+
body = decode_jwt_body(access_token)
|
|
71
|
+
return body.get("tz", "UTC")
|
|
72
|
+
|
|
73
|
+
|
|
61
74
|
def _refresh_access_token(
|
|
62
75
|
refresh_token: str,
|
|
63
76
|
client_id: str,
|
|
@@ -146,6 +159,81 @@ def _get_session_or_none(request: Request) -> SessionData | None:
|
|
|
146
159
|
return None
|
|
147
160
|
|
|
148
161
|
|
|
162
|
+
# ---------------------------------------------------------------------------
|
|
163
|
+
# Webhook user loading
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _load_user_from_store(user_id: str) -> SweatStackUser:
|
|
168
|
+
"""Load user from TokenStore for webhook context.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
user_id: The user ID from the webhook payload.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
SweatStackUser with tokens loaded from the store.
|
|
175
|
+
|
|
176
|
+
Raises:
|
|
177
|
+
WebhookTokenStoreError: If token_store is not configured.
|
|
178
|
+
WebhookUserNotFoundError: If no tokens exist for the user.
|
|
179
|
+
WebhookTokenRefreshError: If token refresh fails.
|
|
180
|
+
"""
|
|
181
|
+
# Import here to avoid circular imports
|
|
182
|
+
from .webhooks import (
|
|
183
|
+
WebhookTokenRefreshError,
|
|
184
|
+
WebhookTokenStoreError,
|
|
185
|
+
WebhookUserNotFoundError,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
config = get_config()
|
|
189
|
+
|
|
190
|
+
if not config.token_store:
|
|
191
|
+
raise WebhookTokenStoreError(
|
|
192
|
+
"TokenStore required when using AuthenticatedUser in webhook handlers. "
|
|
193
|
+
"Configure with: configure(token_store=...)"
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
tokens = config.token_store.load(user_id)
|
|
197
|
+
if not tokens:
|
|
198
|
+
raise WebhookUserNotFoundError(
|
|
199
|
+
f"No stored tokens for user {user_id}. "
|
|
200
|
+
"User may not have authenticated with your app yet."
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# Check if tokens need refresh
|
|
204
|
+
if _is_token_expiring(tokens.access_token):
|
|
205
|
+
try:
|
|
206
|
+
new_access_token = _refresh_access_token(
|
|
207
|
+
refresh_token=tokens.refresh_token,
|
|
208
|
+
client_id=config.client_id,
|
|
209
|
+
client_secret=config.client_secret.get_secret_value(),
|
|
210
|
+
tz=_extract_timezone(tokens.access_token),
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
tokens = StoredTokens(
|
|
214
|
+
user_id=tokens.user_id,
|
|
215
|
+
access_token=new_access_token,
|
|
216
|
+
refresh_token=tokens.refresh_token,
|
|
217
|
+
expires_at=_extract_expiry(new_access_token),
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
config.token_store.save(tokens)
|
|
221
|
+
|
|
222
|
+
except Exception as e:
|
|
223
|
+
raise WebhookTokenRefreshError(
|
|
224
|
+
f"Failed to refresh tokens for user {user_id}: {e}"
|
|
225
|
+
) from e
|
|
226
|
+
|
|
227
|
+
return SweatStackUser(
|
|
228
|
+
client=Client(
|
|
229
|
+
api_key=tokens.access_token,
|
|
230
|
+
refresh_token=tokens.refresh_token,
|
|
231
|
+
client_id=config.client_id,
|
|
232
|
+
client_secret=config.client_secret,
|
|
233
|
+
)
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
149
237
|
# ---------------------------------------------------------------------------
|
|
150
238
|
# Core dependency logic
|
|
151
239
|
# ---------------------------------------------------------------------------
|
|
@@ -182,6 +270,16 @@ def _create_user(
|
|
|
182
270
|
session = SessionData(principal=session.principal, delegated=refreshed)
|
|
183
271
|
else:
|
|
184
272
|
session = SessionData(principal=refreshed, delegated=session.delegated)
|
|
273
|
+
# Persist refreshed principal tokens to store if configured
|
|
274
|
+
if config.token_store:
|
|
275
|
+
config.token_store.save(
|
|
276
|
+
StoredTokens(
|
|
277
|
+
user_id=refreshed.user_id,
|
|
278
|
+
access_token=refreshed.access_token,
|
|
279
|
+
refresh_token=refreshed.refresh_token,
|
|
280
|
+
expires_at=_extract_expiry(refreshed.access_token),
|
|
281
|
+
)
|
|
282
|
+
)
|
|
185
283
|
tokens = refreshed
|
|
186
284
|
set_session_cookie(response, session.to_dict())
|
|
187
285
|
|
|
@@ -200,11 +298,26 @@ def _create_user(
|
|
|
200
298
|
# ---------------------------------------------------------------------------
|
|
201
299
|
|
|
202
300
|
|
|
203
|
-
def _require_authenticated_user(
|
|
301
|
+
async def _require_authenticated_user(
|
|
204
302
|
request: Request,
|
|
205
303
|
response: Response,
|
|
206
304
|
) -> SweatStackUser:
|
|
207
|
-
"""Dependency: always returns principal user.
|
|
305
|
+
"""Dependency: always returns principal user.
|
|
306
|
+
|
|
307
|
+
In webhook context (detected by X-Sweatstack-Signature header),
|
|
308
|
+
loads the user from TokenStore instead of session cookie.
|
|
309
|
+
"""
|
|
310
|
+
# Import here to avoid circular imports
|
|
311
|
+
from .webhooks import WebhookPayloadModel, _detect_webhook_context
|
|
312
|
+
|
|
313
|
+
# Check if this is a webhook request
|
|
314
|
+
webhook_context: WebhookPayloadModel | None = await _detect_webhook_context(request)
|
|
315
|
+
|
|
316
|
+
if webhook_context:
|
|
317
|
+
# Webhook context: load from TokenStore
|
|
318
|
+
return _load_user_from_store(webhook_context.user_id)
|
|
319
|
+
|
|
320
|
+
# Browser context: load from cookie
|
|
208
321
|
session = _get_session_or_raise(request)
|
|
209
322
|
return _create_user(session, response, use_delegated=False)
|
|
210
323
|
|
sweatstack/fastapi/models.py
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from dataclasses import dataclass
|
|
6
|
-
from
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Any, Protocol, runtime_checkable
|
|
7
8
|
|
|
8
9
|
from pydantic import SecretStr
|
|
9
10
|
|
|
@@ -107,3 +108,68 @@ def extract_user_id(jwt_token: str | SecretStr) -> str:
|
|
|
107
108
|
return user_id
|
|
108
109
|
except (IndexError, KeyError) as e:
|
|
109
110
|
raise ValueError(f"Malformed JWT token: {e}") from e
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
# Token storage for webhook support
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True, slots=True)
|
|
119
|
+
class StoredTokens:
|
|
120
|
+
"""Token data for persistent storage.
|
|
121
|
+
|
|
122
|
+
Used by TokenStore implementations to persist tokens for webhook handling.
|
|
123
|
+
The library does NOT encrypt tokens - see Security section in documentation.
|
|
124
|
+
|
|
125
|
+
Attributes:
|
|
126
|
+
user_id: The SweatStack user ID.
|
|
127
|
+
access_token: The OAuth access token.
|
|
128
|
+
refresh_token: The OAuth refresh token.
|
|
129
|
+
expires_at: When the access token expires.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
user_id: str
|
|
133
|
+
access_token: str
|
|
134
|
+
refresh_token: str
|
|
135
|
+
expires_at: datetime
|
|
136
|
+
|
|
137
|
+
def __repr__(self) -> str:
|
|
138
|
+
"""Hide sensitive data in logs."""
|
|
139
|
+
return (
|
|
140
|
+
f"StoredTokens(user_id={self.user_id!r}, "
|
|
141
|
+
f"access_token='***', refresh_token='***', "
|
|
142
|
+
f"expires_at={self.expires_at!r})"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@runtime_checkable
|
|
147
|
+
class TokenStore(Protocol):
|
|
148
|
+
"""Protocol for persisting OAuth tokens.
|
|
149
|
+
|
|
150
|
+
Implement this interface to enable AuthenticatedUser in webhook handlers.
|
|
151
|
+
The library calls these methods automatically:
|
|
152
|
+
- save(): After OAuth callback and token refresh
|
|
153
|
+
- load(): When handling webhooks
|
|
154
|
+
- delete(): On logout
|
|
155
|
+
|
|
156
|
+
Thread Safety:
|
|
157
|
+
All methods may be called concurrently. Your implementation must be thread-safe.
|
|
158
|
+
|
|
159
|
+
Error Handling:
|
|
160
|
+
- save(): Use upsert semantics (don't raise on duplicate user_id)
|
|
161
|
+
- load(): Return None if user not found (don't raise)
|
|
162
|
+
- delete(): Be idempotent (don't raise if user doesn't exist)
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
def save(self, tokens: StoredTokens) -> None:
|
|
166
|
+
"""Save or update tokens for a user."""
|
|
167
|
+
...
|
|
168
|
+
|
|
169
|
+
def load(self, user_id: str) -> StoredTokens | None:
|
|
170
|
+
"""Load tokens for a user. Returns None if not found."""
|
|
171
|
+
...
|
|
172
|
+
|
|
173
|
+
def delete(self, user_id: str) -> None:
|
|
174
|
+
"""Delete tokens for a user. Idempotent."""
|
|
175
|
+
...
|
sweatstack/fastapi/routes.py
CHANGED
|
@@ -15,7 +15,8 @@ from fastapi.responses import RedirectResponse
|
|
|
15
15
|
from ..constants import DEFAULT_URL
|
|
16
16
|
from ..utils import decode_jwt_body
|
|
17
17
|
from .config import get_config
|
|
18
|
-
from .
|
|
18
|
+
from .dependencies import _extract_expiry
|
|
19
|
+
from .models import SessionData, StoredTokens, TokenSet
|
|
19
20
|
from .session import (
|
|
20
21
|
SESSION_COOKIE_NAME,
|
|
21
22
|
STATE_COOKIE_NAME,
|
|
@@ -166,28 +167,34 @@ def create_router() -> APIRouter:
|
|
|
166
167
|
code: str | None = None,
|
|
167
168
|
state: str | None = None,
|
|
168
169
|
error: str | None = None,
|
|
170
|
+
error_description: str | None = None,
|
|
169
171
|
) -> Response:
|
|
170
172
|
"""Handle OAuth callback from SweatStack."""
|
|
171
173
|
config = get_config()
|
|
172
174
|
|
|
175
|
+
def error_redirect(error_code: str) -> Response:
|
|
176
|
+
"""Redirect to / with error code in query params."""
|
|
177
|
+
response = RedirectResponse(url=f"/?auth_error={error_code}", status_code=302)
|
|
178
|
+
clear_state_cookie(response)
|
|
179
|
+
return response
|
|
180
|
+
|
|
173
181
|
# Get state cookie
|
|
174
182
|
state_cookie = request.cookies.get(STATE_COOKIE_NAME)
|
|
175
183
|
|
|
176
|
-
#
|
|
177
|
-
response = RedirectResponse(url="/", status_code=302)
|
|
178
|
-
clear_state_cookie(response)
|
|
179
|
-
|
|
180
|
-
# Handle OAuth errors
|
|
184
|
+
# Handle OAuth errors from provider
|
|
181
185
|
if error:
|
|
182
|
-
|
|
186
|
+
logger.warning("OAuth error from provider: %s - %s", error, error_description)
|
|
187
|
+
return error_redirect(error)
|
|
183
188
|
|
|
184
189
|
# Verify state (CSRF protection)
|
|
185
190
|
if not state or not state_cookie or state != state_cookie:
|
|
186
|
-
|
|
191
|
+
logger.warning("OAuth state mismatch (possible CSRF)")
|
|
192
|
+
return error_redirect("invalid_state")
|
|
187
193
|
|
|
188
194
|
# Exchange code for tokens
|
|
189
195
|
if not code:
|
|
190
|
-
|
|
196
|
+
logger.warning("OAuth callback missing authorization code")
|
|
197
|
+
return error_redirect("missing_code")
|
|
191
198
|
|
|
192
199
|
try:
|
|
193
200
|
token_response = httpx.post(
|
|
@@ -202,24 +209,31 @@ def create_router() -> APIRouter:
|
|
|
202
209
|
)
|
|
203
210
|
token_response.raise_for_status()
|
|
204
211
|
tokens = token_response.json()
|
|
205
|
-
except
|
|
206
|
-
|
|
212
|
+
except httpx.HTTPStatusError as e:
|
|
213
|
+
logger.error("Token exchange failed: %s - %s", e.response.status_code, e.response.text)
|
|
214
|
+
return error_redirect("token_exchange_failed")
|
|
215
|
+
except Exception as e:
|
|
216
|
+
logger.error("Token exchange error: %s", e)
|
|
217
|
+
return error_redirect("token_exchange_failed")
|
|
207
218
|
|
|
208
219
|
access_token = tokens.get("access_token")
|
|
209
220
|
refresh_token = tokens.get("refresh_token")
|
|
210
221
|
|
|
211
222
|
if not access_token:
|
|
212
|
-
|
|
223
|
+
logger.error("Token response missing access_token")
|
|
224
|
+
return error_redirect("invalid_token_response")
|
|
213
225
|
|
|
214
226
|
# Extract user_id from JWT
|
|
215
227
|
try:
|
|
216
228
|
token_body = decode_jwt_body(access_token)
|
|
217
229
|
user_id = token_body.get("sub")
|
|
218
|
-
except Exception:
|
|
219
|
-
|
|
230
|
+
except Exception as e:
|
|
231
|
+
logger.error("Failed to decode access token: %s", e)
|
|
232
|
+
return error_redirect("invalid_token")
|
|
220
233
|
|
|
221
234
|
if not user_id:
|
|
222
|
-
|
|
235
|
+
logger.error("Access token missing 'sub' claim")
|
|
236
|
+
return error_redirect("invalid_token")
|
|
223
237
|
|
|
224
238
|
# Create session
|
|
225
239
|
session_data = {
|
|
@@ -228,6 +242,17 @@ def create_router() -> APIRouter:
|
|
|
228
242
|
"user_id": user_id,
|
|
229
243
|
}
|
|
230
244
|
|
|
245
|
+
# Persist tokens to store if configured
|
|
246
|
+
if config.token_store:
|
|
247
|
+
config.token_store.save(
|
|
248
|
+
StoredTokens(
|
|
249
|
+
user_id=user_id,
|
|
250
|
+
access_token=access_token,
|
|
251
|
+
refresh_token=refresh_token,
|
|
252
|
+
expires_at=_extract_expiry(access_token),
|
|
253
|
+
)
|
|
254
|
+
)
|
|
255
|
+
|
|
231
256
|
# Determine redirect URL from state
|
|
232
257
|
state_data = parse_state(state)
|
|
233
258
|
redirect_url = state_data.get("next", "/")
|
|
@@ -238,8 +263,16 @@ def create_router() -> APIRouter:
|
|
|
238
263
|
return response
|
|
239
264
|
|
|
240
265
|
@router.post("/logout")
|
|
241
|
-
def logout() -> Response:
|
|
266
|
+
def logout(request: Request) -> Response:
|
|
242
267
|
"""Clear session and redirect to /."""
|
|
268
|
+
config = get_config()
|
|
269
|
+
|
|
270
|
+
# Delete tokens from store if configured
|
|
271
|
+
if config.token_store:
|
|
272
|
+
session = _get_session_data(request)
|
|
273
|
+
if session:
|
|
274
|
+
config.token_store.delete(session.principal.user_id)
|
|
275
|
+
|
|
243
276
|
response = RedirectResponse(url="/", status_code=302)
|
|
244
277
|
clear_session_cookie(response)
|
|
245
278
|
return response
|
|
@@ -298,6 +331,39 @@ def create_router() -> APIRouter:
|
|
|
298
331
|
return router
|
|
299
332
|
|
|
300
333
|
|
|
334
|
+
def _warn_if_webhook_misconfigured(app: FastAPI) -> None:
|
|
335
|
+
"""Log error if WebhookPayload is used but webhook_secret not configured."""
|
|
336
|
+
config = get_config()
|
|
337
|
+
|
|
338
|
+
if config.webhook_secret:
|
|
339
|
+
return # Properly configured
|
|
340
|
+
|
|
341
|
+
# Import here to avoid circular imports
|
|
342
|
+
from .webhooks import _require_webhook_payload
|
|
343
|
+
|
|
344
|
+
# Check if any route uses WebhookPayload dependency
|
|
345
|
+
for route in app.routes:
|
|
346
|
+
if not hasattr(route, "dependant"):
|
|
347
|
+
continue
|
|
348
|
+
|
|
349
|
+
if _uses_dependency(route.dependant, _require_webhook_payload):
|
|
350
|
+
raise RuntimeError(
|
|
351
|
+
f"Route '{route.path}' uses WebhookPayload but webhook_secret is not configured. "
|
|
352
|
+
"Webhook signature verification will fail at runtime. "
|
|
353
|
+
"Configure with the SWEATSTACK_WEBHOOK_SECRET env variable or configure(webhook_secret='whsec_...')"
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _uses_dependency(dependant, target_callable) -> bool:
|
|
358
|
+
"""Check if a dependency tree includes the target callable."""
|
|
359
|
+
for dep in dependant.dependencies:
|
|
360
|
+
if dep.call is target_callable:
|
|
361
|
+
return True
|
|
362
|
+
if hasattr(dep, "dependant") and _uses_dependency(dep.dependant, target_callable):
|
|
363
|
+
return True
|
|
364
|
+
return False
|
|
365
|
+
|
|
366
|
+
|
|
301
367
|
def instrument(app: FastAPI) -> None:
|
|
302
368
|
"""Add SweatStack auth routes to a FastAPI application.
|
|
303
369
|
|
|
@@ -310,3 +376,8 @@ def instrument(app: FastAPI) -> None:
|
|
|
310
376
|
config = get_config() # This will raise if not configured
|
|
311
377
|
router = create_router()
|
|
312
378
|
app.include_router(router, prefix=config.auth_route_prefix)
|
|
379
|
+
|
|
380
|
+
# Validate webhook configuration at startup (after all routes are registered)
|
|
381
|
+
@app.on_event("startup")
|
|
382
|
+
def _check_webhook_config():
|
|
383
|
+
_warn_if_webhook_misconfigured(app)
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Token store implementations for development use.
|
|
2
|
+
|
|
3
|
+
WARNING: These implementations are for LOCAL DEVELOPMENT ONLY.
|
|
4
|
+
Do not use in production. Implement your own TokenStore with proper
|
|
5
|
+
database infrastructure, encryption, and monitoring.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import sqlite3
|
|
12
|
+
import threading
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from .models import StoredTokens, TokenStore
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SQLiteTokenStore(TokenStore):
|
|
22
|
+
"""SQLite-based TokenStore for local development.
|
|
23
|
+
|
|
24
|
+
WARNING: This implementation is for LOCAL DEVELOPMENT ONLY.
|
|
25
|
+
Do not use in production. For production, implement your own
|
|
26
|
+
TokenStore with proper database infrastructure, encryption,
|
|
27
|
+
and monitoring.
|
|
28
|
+
|
|
29
|
+
Features:
|
|
30
|
+
- File-based storage (no external database required)
|
|
31
|
+
- Thread-safe with connection-per-thread pattern
|
|
32
|
+
- Auto-creates table on first use
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
db_path: Path to SQLite database file. Defaults to "sweatstack_tokens.db"
|
|
36
|
+
in current directory.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, db_path: str | Path = "sweatstack_tokens.db"):
|
|
40
|
+
self.db_path = str(db_path)
|
|
41
|
+
self._local = threading.local()
|
|
42
|
+
|
|
43
|
+
logger.warning(
|
|
44
|
+
"SQLiteTokenStore is for LOCAL DEVELOPMENT ONLY. "
|
|
45
|
+
"Do not use in production. Implement a proper TokenStore "
|
|
46
|
+
"with your production database."
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
self._init_db()
|
|
50
|
+
|
|
51
|
+
def _get_connection(self) -> sqlite3.Connection:
|
|
52
|
+
"""Get thread-local database connection."""
|
|
53
|
+
if not hasattr(self._local, "connection"):
|
|
54
|
+
self._local.connection = sqlite3.connect(
|
|
55
|
+
self.db_path,
|
|
56
|
+
check_same_thread=False,
|
|
57
|
+
)
|
|
58
|
+
self._local.connection.row_factory = sqlite3.Row
|
|
59
|
+
return self._local.connection
|
|
60
|
+
|
|
61
|
+
def _init_db(self) -> None:
|
|
62
|
+
"""Create tokens table if it doesn't exist."""
|
|
63
|
+
conn = self._get_connection()
|
|
64
|
+
conn.execute("""
|
|
65
|
+
CREATE TABLE IF NOT EXISTS sweatstack_tokens (
|
|
66
|
+
user_id TEXT PRIMARY KEY,
|
|
67
|
+
access_token TEXT NOT NULL,
|
|
68
|
+
refresh_token TEXT NOT NULL,
|
|
69
|
+
expires_at TEXT NOT NULL
|
|
70
|
+
)
|
|
71
|
+
""")
|
|
72
|
+
conn.commit()
|
|
73
|
+
|
|
74
|
+
def save(self, tokens: StoredTokens) -> None:
|
|
75
|
+
"""Save or update tokens for a user."""
|
|
76
|
+
conn = self._get_connection()
|
|
77
|
+
conn.execute(
|
|
78
|
+
"""
|
|
79
|
+
INSERT INTO sweatstack_tokens (user_id, access_token, refresh_token, expires_at)
|
|
80
|
+
VALUES (?, ?, ?, ?)
|
|
81
|
+
ON CONFLICT(user_id) DO UPDATE SET
|
|
82
|
+
access_token = excluded.access_token,
|
|
83
|
+
refresh_token = excluded.refresh_token,
|
|
84
|
+
expires_at = excluded.expires_at
|
|
85
|
+
""",
|
|
86
|
+
(
|
|
87
|
+
tokens.user_id,
|
|
88
|
+
tokens.access_token,
|
|
89
|
+
tokens.refresh_token,
|
|
90
|
+
tokens.expires_at.isoformat(),
|
|
91
|
+
),
|
|
92
|
+
)
|
|
93
|
+
conn.commit()
|
|
94
|
+
|
|
95
|
+
def load(self, user_id: str) -> StoredTokens | None:
|
|
96
|
+
"""Load tokens for a user. Returns None if not found."""
|
|
97
|
+
conn = self._get_connection()
|
|
98
|
+
row = conn.execute(
|
|
99
|
+
"SELECT * FROM sweatstack_tokens WHERE user_id = ?",
|
|
100
|
+
(user_id,),
|
|
101
|
+
).fetchone()
|
|
102
|
+
|
|
103
|
+
if row:
|
|
104
|
+
return StoredTokens(
|
|
105
|
+
user_id=row["user_id"],
|
|
106
|
+
access_token=row["access_token"],
|
|
107
|
+
refresh_token=row["refresh_token"],
|
|
108
|
+
expires_at=datetime.fromisoformat(row["expires_at"]),
|
|
109
|
+
)
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
def delete(self, user_id: str) -> None:
|
|
113
|
+
"""Delete tokens for a user. Idempotent."""
|
|
114
|
+
conn = self._get_connection()
|
|
115
|
+
conn.execute(
|
|
116
|
+
"DELETE FROM sweatstack_tokens WHERE user_id = ?",
|
|
117
|
+
(user_id,),
|
|
118
|
+
)
|
|
119
|
+
conn.commit()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class EncryptedSQLiteTokenStore(TokenStore):
|
|
123
|
+
"""Encrypted SQLite TokenStore for local development.
|
|
124
|
+
|
|
125
|
+
WARNING: This implementation is for LOCAL DEVELOPMENT ONLY.
|
|
126
|
+
Do not use in production. This demonstrates the encryption pattern -
|
|
127
|
+
adapt it for your production database.
|
|
128
|
+
|
|
129
|
+
Encrypts access_token and refresh_token using Fernet (AES-128-CBC + HMAC).
|
|
130
|
+
user_id and expires_at are stored unencrypted for queries.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
encryption_key: Fernet key for encryption. Generate with:
|
|
134
|
+
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
|
135
|
+
db_path: Path to SQLite database file.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
def __init__(
|
|
139
|
+
self,
|
|
140
|
+
encryption_key: str | bytes,
|
|
141
|
+
db_path: str | Path = "sweatstack_tokens_encrypted.db",
|
|
142
|
+
):
|
|
143
|
+
# Import here so cryptography is optional for basic usage
|
|
144
|
+
from cryptography.fernet import Fernet
|
|
145
|
+
|
|
146
|
+
self.db_path = str(db_path)
|
|
147
|
+
self._local = threading.local()
|
|
148
|
+
|
|
149
|
+
if isinstance(encryption_key, str):
|
|
150
|
+
encryption_key = encryption_key.encode()
|
|
151
|
+
self._fernet = Fernet(encryption_key)
|
|
152
|
+
|
|
153
|
+
logger.warning(
|
|
154
|
+
"EncryptedSQLiteTokenStore is for LOCAL DEVELOPMENT ONLY. "
|
|
155
|
+
"Do not use in production. This demonstrates the encryption "
|
|
156
|
+
"pattern - implement with your production database."
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
self._init_db()
|
|
160
|
+
|
|
161
|
+
def _get_connection(self) -> sqlite3.Connection:
|
|
162
|
+
"""Get thread-local database connection."""
|
|
163
|
+
if not hasattr(self._local, "connection"):
|
|
164
|
+
self._local.connection = sqlite3.connect(
|
|
165
|
+
self.db_path,
|
|
166
|
+
check_same_thread=False,
|
|
167
|
+
)
|
|
168
|
+
self._local.connection.row_factory = sqlite3.Row
|
|
169
|
+
return self._local.connection
|
|
170
|
+
|
|
171
|
+
def _init_db(self) -> None:
|
|
172
|
+
"""Create tokens table if it doesn't exist."""
|
|
173
|
+
conn = self._get_connection()
|
|
174
|
+
conn.execute("""
|
|
175
|
+
CREATE TABLE IF NOT EXISTS sweatstack_tokens (
|
|
176
|
+
user_id TEXT PRIMARY KEY,
|
|
177
|
+
access_token_encrypted TEXT NOT NULL,
|
|
178
|
+
refresh_token_encrypted TEXT NOT NULL,
|
|
179
|
+
expires_at TEXT NOT NULL
|
|
180
|
+
)
|
|
181
|
+
""")
|
|
182
|
+
conn.commit()
|
|
183
|
+
|
|
184
|
+
def _encrypt(self, value: str) -> str:
|
|
185
|
+
"""Encrypt a string value."""
|
|
186
|
+
return self._fernet.encrypt(value.encode()).decode()
|
|
187
|
+
|
|
188
|
+
def _decrypt(self, value: str) -> str:
|
|
189
|
+
"""Decrypt a string value."""
|
|
190
|
+
return self._fernet.decrypt(value.encode()).decode()
|
|
191
|
+
|
|
192
|
+
def save(self, tokens: StoredTokens) -> None:
|
|
193
|
+
"""Save or update tokens for a user."""
|
|
194
|
+
conn = self._get_connection()
|
|
195
|
+
conn.execute(
|
|
196
|
+
"""
|
|
197
|
+
INSERT INTO sweatstack_tokens
|
|
198
|
+
(user_id, access_token_encrypted, refresh_token_encrypted, expires_at)
|
|
199
|
+
VALUES (?, ?, ?, ?)
|
|
200
|
+
ON CONFLICT(user_id) DO UPDATE SET
|
|
201
|
+
access_token_encrypted = excluded.access_token_encrypted,
|
|
202
|
+
refresh_token_encrypted = excluded.refresh_token_encrypted,
|
|
203
|
+
expires_at = excluded.expires_at
|
|
204
|
+
""",
|
|
205
|
+
(
|
|
206
|
+
tokens.user_id,
|
|
207
|
+
self._encrypt(tokens.access_token),
|
|
208
|
+
self._encrypt(tokens.refresh_token),
|
|
209
|
+
tokens.expires_at.isoformat(),
|
|
210
|
+
),
|
|
211
|
+
)
|
|
212
|
+
conn.commit()
|
|
213
|
+
|
|
214
|
+
def load(self, user_id: str) -> StoredTokens | None:
|
|
215
|
+
"""Load tokens for a user. Returns None if not found."""
|
|
216
|
+
conn = self._get_connection()
|
|
217
|
+
row = conn.execute(
|
|
218
|
+
"SELECT * FROM sweatstack_tokens WHERE user_id = ?",
|
|
219
|
+
(user_id,),
|
|
220
|
+
).fetchone()
|
|
221
|
+
|
|
222
|
+
if row:
|
|
223
|
+
return StoredTokens(
|
|
224
|
+
user_id=row["user_id"],
|
|
225
|
+
access_token=self._decrypt(row["access_token_encrypted"]),
|
|
226
|
+
refresh_token=self._decrypt(row["refresh_token_encrypted"]),
|
|
227
|
+
expires_at=datetime.fromisoformat(row["expires_at"]),
|
|
228
|
+
)
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
def delete(self, user_id: str) -> None:
|
|
232
|
+
"""Delete tokens for a user. Idempotent."""
|
|
233
|
+
conn = self._get_connection()
|
|
234
|
+
conn.execute(
|
|
235
|
+
"DELETE FROM sweatstack_tokens WHERE user_id = ?",
|
|
236
|
+
(user_id,),
|
|
237
|
+
)
|
|
238
|
+
conn.commit()
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Webhook handling for the FastAPI plugin."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import json
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import Annotated
|
|
12
|
+
|
|
13
|
+
from fastapi import Depends, HTTPException, Request
|
|
14
|
+
|
|
15
|
+
from .config import get_config
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# Exceptions
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class WebhookError(Exception):
|
|
24
|
+
"""Base class for webhook-related errors."""
|
|
25
|
+
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class WebhookVerificationError(WebhookError):
|
|
30
|
+
"""Raised when webhook signature verification fails."""
|
|
31
|
+
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class WebhookTokenStoreError(WebhookError):
|
|
36
|
+
"""Raised when TokenStore is required but not configured."""
|
|
37
|
+
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class WebhookUserNotFoundError(WebhookError):
|
|
42
|
+
"""Raised when no stored tokens exist for the webhook's user_id."""
|
|
43
|
+
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class WebhookTokenRefreshError(WebhookError):
|
|
48
|
+
"""Raised when token refresh fails in webhook context."""
|
|
49
|
+
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# Webhook payload model
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True, slots=True)
|
|
59
|
+
class WebhookPayloadModel:
|
|
60
|
+
"""Verified webhook payload data.
|
|
61
|
+
|
|
62
|
+
Attributes:
|
|
63
|
+
user_id: The SweatStack user ID this webhook relates to.
|
|
64
|
+
event_type: The type of event (e.g., "activity_created").
|
|
65
|
+
resource_id: The ID of the affected resource.
|
|
66
|
+
timestamp: When the event occurred.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
user_id: str
|
|
70
|
+
event_type: str
|
|
71
|
+
resource_id: str
|
|
72
|
+
timestamp: datetime
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# Signature verification
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
TIMESTAMP_TOLERANCE = 300 # 5 minutes
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def verify_signature(
|
|
83
|
+
payload: bytes,
|
|
84
|
+
signature_header: str,
|
|
85
|
+
secret: str,
|
|
86
|
+
) -> None:
|
|
87
|
+
"""Verify webhook signature.
|
|
88
|
+
|
|
89
|
+
SweatStack uses HMAC-SHA256 signatures. The signature header format is:
|
|
90
|
+
t={timestamp},v1={signature}
|
|
91
|
+
|
|
92
|
+
The signed payload is: {timestamp}.{raw_json_body}
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
payload: The raw request body bytes.
|
|
96
|
+
signature_header: The X-Sweatstack-Signature header value.
|
|
97
|
+
secret: The webhook secret.
|
|
98
|
+
|
|
99
|
+
Raises:
|
|
100
|
+
WebhookVerificationError: If signature is invalid or timestamp too old.
|
|
101
|
+
"""
|
|
102
|
+
try:
|
|
103
|
+
parts = dict(p.split("=", 1) for p in signature_header.split(","))
|
|
104
|
+
timestamp = int(parts["t"])
|
|
105
|
+
signature = parts["v1"]
|
|
106
|
+
except (KeyError, ValueError) as e:
|
|
107
|
+
raise WebhookVerificationError("Invalid signature header format") from e
|
|
108
|
+
|
|
109
|
+
# Check timestamp to prevent replay attacks
|
|
110
|
+
if abs(time.time() - timestamp) > TIMESTAMP_TOLERANCE:
|
|
111
|
+
raise WebhookVerificationError("Timestamp outside tolerance window")
|
|
112
|
+
|
|
113
|
+
# Compute expected signature
|
|
114
|
+
signed_payload = f"{timestamp}.".encode() + payload
|
|
115
|
+
expected = hmac.new(
|
|
116
|
+
secret.encode(),
|
|
117
|
+
signed_payload,
|
|
118
|
+
hashlib.sha256,
|
|
119
|
+
).hexdigest()
|
|
120
|
+
|
|
121
|
+
# Constant-time comparison to prevent timing attacks
|
|
122
|
+
if not hmac.compare_digest(expected, signature):
|
|
123
|
+
raise WebhookVerificationError("Invalid signature")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
# FastAPI dependencies
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
async def _detect_webhook_context(request: Request) -> WebhookPayloadModel | None:
|
|
132
|
+
"""Detect if this is a webhook request and return verified payload.
|
|
133
|
+
|
|
134
|
+
This dependency is cached per-request by FastAPI. It's used by both
|
|
135
|
+
WebhookPayload (which requires it) and AuthenticatedUser (which uses it
|
|
136
|
+
to detect webhook context for token loading).
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
WebhookPayloadModel if this is a verified webhook request, None otherwise.
|
|
140
|
+
|
|
141
|
+
Raises:
|
|
142
|
+
WebhookVerificationError: If signature header is present but invalid.
|
|
143
|
+
"""
|
|
144
|
+
signature = request.headers.get("X-Sweatstack-Signature")
|
|
145
|
+
if not signature:
|
|
146
|
+
return None # Not a webhook request - fast path
|
|
147
|
+
|
|
148
|
+
config = get_config()
|
|
149
|
+
|
|
150
|
+
if not config.webhook_secret:
|
|
151
|
+
raise WebhookVerificationError(
|
|
152
|
+
"Webhook received but webhook_secret not configured. "
|
|
153
|
+
"Add webhook_secret to configure() to enable webhook handling."
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
body = await request.body()
|
|
157
|
+
secret = (
|
|
158
|
+
config.webhook_secret.get_secret_value()
|
|
159
|
+
if hasattr(config.webhook_secret, "get_secret_value")
|
|
160
|
+
else config.webhook_secret
|
|
161
|
+
)
|
|
162
|
+
verify_signature(body, signature, secret)
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
data = json.loads(body)
|
|
166
|
+
return WebhookPayloadModel(
|
|
167
|
+
user_id=data["user_id"],
|
|
168
|
+
event_type=data["event_type"],
|
|
169
|
+
resource_id=data["resource_id"],
|
|
170
|
+
timestamp=datetime.fromisoformat(data["timestamp"]),
|
|
171
|
+
)
|
|
172
|
+
except (json.JSONDecodeError, KeyError, ValueError) as e:
|
|
173
|
+
raise WebhookVerificationError(f"Invalid webhook payload: {e}") from e
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
async def _require_webhook_payload(
|
|
177
|
+
webhook_context: Annotated[WebhookPayloadModel | None, Depends(_detect_webhook_context)],
|
|
178
|
+
) -> WebhookPayloadModel:
|
|
179
|
+
"""Dependency that requires a verified webhook payload.
|
|
180
|
+
|
|
181
|
+
Raises:
|
|
182
|
+
HTTPException: If request is not a valid webhook.
|
|
183
|
+
"""
|
|
184
|
+
if webhook_context is None:
|
|
185
|
+
raise HTTPException(status_code=400, detail="Missing or invalid webhook signature")
|
|
186
|
+
return webhook_context
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# Public type alias for use in endpoint signatures
|
|
190
|
+
WebhookPayload = Annotated[WebhookPayloadModel, Depends(_require_webhook_payload)]
|
|
191
|
+
"""Dependency that returns a verified webhook payload.
|
|
192
|
+
|
|
193
|
+
The signature is verified before your handler runs. If verification fails,
|
|
194
|
+
a 400 response is returned automatically.
|
|
195
|
+
|
|
196
|
+
Example:
|
|
197
|
+
@app.post("/webhooks/sweatstack")
|
|
198
|
+
def handle_webhook(payload: WebhookPayload):
|
|
199
|
+
print(f"Event: {payload.event_type} for user {payload.user_id}")
|
|
200
|
+
return {"status": "received"}
|
|
201
|
+
"""
|
|
@@ -11,13 +11,15 @@ sweatstack/streamlit.py,sha256=wnabWhife9eMAdkECPjRKkzE82KZoi_H8YzucZl_m9s,19604
|
|
|
11
11
|
sweatstack/sweatshell.py,sha256=MYLNcWbOdceqKJ3S0Pe8dwHXEeYsGJNjQoYUXpMTftA,333
|
|
12
12
|
sweatstack/utils.py,sha256=AwHRdC1ziOZ5o9RBIB21Uxm-DoClVRAJSVvgsmSmvps,1801
|
|
13
13
|
sweatstack/Sweat Stack examples/Getting started.ipynb,sha256=k2hiSffWecoQ0VxjdpDcgFzBXDQiYEebhnAYlu8cgX8,6335204
|
|
14
|
-
sweatstack/fastapi/__init__.py,sha256=
|
|
15
|
-
sweatstack/fastapi/config.py,sha256=
|
|
16
|
-
sweatstack/fastapi/dependencies.py,sha256=
|
|
17
|
-
sweatstack/fastapi/models.py,sha256=
|
|
18
|
-
sweatstack/fastapi/routes.py,sha256=
|
|
14
|
+
sweatstack/fastapi/__init__.py,sha256=BteLHro6KCVknIgNAp6OS5ZJRWmIXHS4iXOLOSyN2Mc,3216
|
|
15
|
+
sweatstack/fastapi/config.py,sha256=nNiZOeAXbipK4l2mxaPfaLwz2Ml-Eu7n-G46yGlvjA4,8764
|
|
16
|
+
sweatstack/fastapi/dependencies.py,sha256=8kfKqe-js1G7LLe2AR8xpChtoCKrDG3-F0VkZyXMlIo,12620
|
|
17
|
+
sweatstack/fastapi/models.py,sha256=vBImNywlKiw-10ZODRAHZSrWG3Xfla8uuXA0Y-p0Opc,5499
|
|
18
|
+
sweatstack/fastapi/routes.py,sha256=qNRSod_ZWziFptInplmwmrOe5ZwpJbL7kE5LHWJIxC8,13106
|
|
19
19
|
sweatstack/fastapi/session.py,sha256=BtRPCmIEaToJPwFyZ0fqWGlmnDHuWKy8nri9dJrPXaA,2717
|
|
20
|
-
sweatstack
|
|
21
|
-
sweatstack
|
|
22
|
-
sweatstack-0.
|
|
23
|
-
sweatstack-0.
|
|
20
|
+
sweatstack/fastapi/token_stores.py,sha256=-waq0CQLszIp-2uLwWgkMz8IbsmUC7xopvM2F12sbMo,8207
|
|
21
|
+
sweatstack/fastapi/webhooks.py,sha256=ShRQRkarJ3vuEkT1lkl1-_oQbPQTLNkgQ2E6Mm4UepU,6066
|
|
22
|
+
sweatstack-0.62.0.dist-info/METADATA,sha256=SqQNBCJZibHU6qQaQi1ij-OLHAWt082zfxPrXSFiJY4,994
|
|
23
|
+
sweatstack-0.62.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
24
|
+
sweatstack-0.62.0.dist-info/entry_points.txt,sha256=kCzOUQI3dqbTpEYqtgYDeiKFaqaA7BMlV6D24BMzCFU,208
|
|
25
|
+
sweatstack-0.62.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|