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.
@@ -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
- # Dependencies
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
  ]
@@ -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
 
@@ -3,7 +3,8 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from dataclasses import dataclass
6
- from typing import Any
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
+ ...
@@ -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 .models import SessionData, TokenSet
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
- # Clear state cookie regardless of outcome
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
- return response
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
- return Response(content="Invalid state", status_code=400)
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
- return Response(content="Missing authorization code", status_code=400)
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 Exception:
206
- return response # Redirect to / on token exchange failure
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
- return response
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
- return response
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
- return response
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
+ """
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sweatstack
3
- Version: 0.61.0
3
+ Version: 0.62.0
4
4
  Summary: The official Python client for SweatStack
5
5
  Author-email: Aart Goossens <aart@gssns.io>
6
6
  Requires-Python: >=3.9
@@ -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=J20u-R3ABLP0vGLl3m_H76nTvpoMHtpKpyH8vufb9kM,2465
15
- sweatstack/fastapi/config.py,sha256=S9Y5G5YprSugICtkCdVEUBwdbGsg2MuzdPx8QJaP8XA,7850
16
- sweatstack/fastapi/dependencies.py,sha256=6QrWCcYJJXI0-Tn2A4hKimVMCa45rRUAu-gijtgAq4k,8739
17
- sweatstack/fastapi/models.py,sha256=2VNKITN7LKacQxxVgYJjDaZ6Xq2eYBtvkQbq7H6bLlY,3386
18
- sweatstack/fastapi/routes.py,sha256=Y-g8DMM2gG_8ETnLN7ZfUqBT8AkwIG9WFbEqJtyyKcM,10058
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-0.61.0.dist-info/METADATA,sha256=RGfVVMy3zO08wiJw98gF-9IW_8gjuYsJ3Kg58BAJyi4,994
21
- sweatstack-0.61.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
22
- sweatstack-0.61.0.dist-info/entry_points.txt,sha256=kCzOUQI3dqbTpEYqtgYDeiKFaqaA7BMlV6D24BMzCFU,208
23
- sweatstack-0.61.0.dist-info/RECORD,,
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,,