sweatstack 0.59.0__tar.gz → 0.61.0__tar.gz

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.
Files changed (45) hide show
  1. sweatstack-0.61.0/.claude/settings.local.json +18 -0
  2. {sweatstack-0.59.0 → sweatstack-0.61.0}/CHANGELOG.md +16 -0
  3. sweatstack-0.61.0/FASTAPI_DOCS.md +275 -0
  4. sweatstack-0.61.0/FASTAPI_PLUGIN.md +396 -0
  5. sweatstack-0.61.0/FASTAPI_USER_SWITCHING.md +858 -0
  6. {sweatstack-0.59.0 → sweatstack-0.61.0}/PKG-INFO +4 -1
  7. sweatstack-0.61.0/fastapi_coaching_example.py +97 -0
  8. sweatstack-0.61.0/fastapi_example.py +95 -0
  9. sweatstack-0.61.0/fastapi_sweatstack.py +66 -0
  10. {sweatstack-0.59.0 → sweatstack-0.61.0}/pyproject.toml +5 -1
  11. {sweatstack-0.59.0 → sweatstack-0.61.0}/src/sweatstack/client.py +67 -27
  12. sweatstack-0.61.0/src/sweatstack/fastapi/__init__.py +82 -0
  13. sweatstack-0.61.0/src/sweatstack/fastapi/config.py +223 -0
  14. sweatstack-0.61.0/src/sweatstack/fastapi/dependencies.py +293 -0
  15. sweatstack-0.61.0/src/sweatstack/fastapi/models.py +109 -0
  16. sweatstack-0.61.0/src/sweatstack/fastapi/routes.py +312 -0
  17. sweatstack-0.61.0/src/sweatstack/fastapi/session.py +102 -0
  18. {sweatstack-0.59.0 → sweatstack-0.61.0}/uv.lock +647 -32
  19. sweatstack-0.59.0/.claude/settings.local.json +0 -14
  20. {sweatstack-0.59.0 → sweatstack-0.61.0}/.gitignore +0 -0
  21. {sweatstack-0.59.0 → sweatstack-0.61.0}/.python-version +0 -0
  22. {sweatstack-0.59.0 → sweatstack-0.61.0}/DEVELOPMENT.md +0 -0
  23. {sweatstack-0.59.0 → sweatstack-0.61.0}/Makefile +0 -0
  24. {sweatstack-0.59.0 → sweatstack-0.61.0}/README.md +0 -0
  25. {sweatstack-0.59.0 → sweatstack-0.61.0}/docs/conf.py +0 -0
  26. {sweatstack-0.59.0 → sweatstack-0.61.0}/docs/everything.rst +0 -0
  27. {sweatstack-0.59.0 → sweatstack-0.61.0}/docs/index.rst +0 -0
  28. {sweatstack-0.59.0 → sweatstack-0.61.0}/playground/.ipynb_checkpoints/Untitled-checkpoint.ipynb +0 -0
  29. {sweatstack-0.59.0 → sweatstack-0.61.0}/playground/README.md +0 -0
  30. {sweatstack-0.59.0 → sweatstack-0.61.0}/playground/Sweat Stack examples/Getting started.ipynb +0 -0
  31. {sweatstack-0.59.0 → sweatstack-0.61.0}/playground/Untitled.ipynb +0 -0
  32. {sweatstack-0.59.0 → sweatstack-0.61.0}/playground/hello.py +0 -0
  33. {sweatstack-0.59.0 → sweatstack-0.61.0}/playground/pyproject.toml +0 -0
  34. {sweatstack-0.59.0 → sweatstack-0.61.0}/src/sweatstack/Sweat Stack examples/Getting started.ipynb +0 -0
  35. {sweatstack-0.59.0 → sweatstack-0.61.0}/src/sweatstack/__init__.py +0 -0
  36. {sweatstack-0.59.0 → sweatstack-0.61.0}/src/sweatstack/cli.py +0 -0
  37. {sweatstack-0.59.0 → sweatstack-0.61.0}/src/sweatstack/constants.py +0 -0
  38. {sweatstack-0.59.0 → sweatstack-0.61.0}/src/sweatstack/ipython_init.py +0 -0
  39. {sweatstack-0.59.0 → sweatstack-0.61.0}/src/sweatstack/jupyterlab_oauth2_startup.py +0 -0
  40. {sweatstack-0.59.0 → sweatstack-0.61.0}/src/sweatstack/openapi_schemas.py +0 -0
  41. {sweatstack-0.59.0 → sweatstack-0.61.0}/src/sweatstack/py.typed +0 -0
  42. {sweatstack-0.59.0 → sweatstack-0.61.0}/src/sweatstack/schemas.py +0 -0
  43. {sweatstack-0.59.0 → sweatstack-0.61.0}/src/sweatstack/streamlit.py +0 -0
  44. {sweatstack-0.59.0 → sweatstack-0.61.0}/src/sweatstack/sweatshell.py +0 -0
  45. {sweatstack-0.59.0 → sweatstack-0.61.0}/src/sweatstack/utils.py +0 -0
@@ -0,0 +1,18 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(python:*)",
5
+ "WebFetch(domain:app.sweatstack.no)",
6
+ "Bash(cat:*)",
7
+ "Bash(find:*)",
8
+ "Bash(grep:*)",
9
+ "Bash(uv run python:*)",
10
+ "Bash(uv run pytest:*)",
11
+ "Bash(uv pip install:*)",
12
+ "Bash(SWEATSTACK_CLIENT_ID=test-client SWEATSTACK_CLIENT_SECRET=test-secret APP_URL=http://localhost:8000 SWEATSTACK_SESSION_SECRET=dGVzdC1zZWNyZXQta2V5LXRoYXQtaXMtMzItYnl0ZXM= python -c \"\nfrom sweatstack.fastapi import configure_fastapi\nfrom sweatstack.fastapi.config import get_config\n\n# No arguments - all from env vars\nconfigure_fastapi\\(\\)\n\nconfig = get_config\\(\\)\nprint\\(f''client_id: {config.client_id}''\\)\nprint\\(f''app_url: {config.app_url}''\\)\nprint\\(f''redirect_uri: {config.redirect_uri}''\\)\n\")",
13
+ "WebFetch(domain:developer.sweatstack.no)",
14
+ "Bash(uv run ruff:*)"
15
+ ],
16
+ "deny": []
17
+ }
18
+ }
@@ -6,6 +6,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
8
 
9
+ ## [0.61.0] - 2026-01-29
10
+
11
+ ### Added
12
+ - Added user switching support to FastAPI integration.
13
+
14
+
15
+ ## [0.60.0] - 2026-01-28
16
+
17
+ ### Added
18
+ - Added a FastAPI integration.
19
+
20
+
21
+ ### Changed
22
+ - Converted sensitive variables to SecretStr to prevent accidental logging.
23
+
24
+
9
25
  ## [0.59.0] - 2026-01-27
10
26
 
11
27
  ### Changed
@@ -0,0 +1,275 @@
1
+ # FastAPI Integration
2
+
3
+ Add SweatStack authentication to your FastAPI app in minutes. Your users authenticate with SweatStack, and you get a ready-to-use API client for each request.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install 'sweatstack[fastapi]'
9
+ ```
10
+
11
+ ## Prerequisites
12
+
13
+ Before you start, you'll need OAuth credentials from SweatStack:
14
+
15
+ 1. Go to [SweatStack Developer Settings](https://app.sweatstack.no/settings/developer)
16
+ 2. Register a new application
17
+ 3. Set the redirect URI to `http://localhost:8000/auth/sweatstack/callback`
18
+ 4. Note your **Client ID** and **Client Secret**
19
+
20
+ !!! tip "Redirect URI"
21
+ The redirect URI must match exactly. For local development, use `http://localhost:8000/auth/sweatstack/callback`. Update this when deploying to production.
22
+
23
+ ## Quickstart
24
+
25
+ Create a file called `app.py`:
26
+
27
+ ```python
28
+ from fastapi import FastAPI
29
+ from sweatstack.fastapi import configure, instrument, AuthenticatedUser
30
+
31
+ configure()
32
+
33
+ app = FastAPI()
34
+ instrument(app)
35
+
36
+
37
+ @app.get("/")
38
+ def home(user: AuthenticatedUser):
39
+ return {"message": f"Welcome, {user.user_id}!"}
40
+
41
+
42
+ @app.get("/activities")
43
+ def activities(user: AuthenticatedUser):
44
+ return user.client.get_activities(limit=10)
45
+ ```
46
+
47
+ That's the entire app. Let's run it.
48
+
49
+ ## Set Environment Variables
50
+
51
+ The plugin reads configuration from environment variables:
52
+
53
+ ```bash
54
+ export SWEATSTACK_CLIENT_ID="your-client-id"
55
+ export SWEATSTACK_CLIENT_SECRET="your-client-secret"
56
+ export SWEATSTACK_SESSION_SECRET="$(python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())')"
57
+ export APP_URL="http://localhost:8000"
58
+ ```
59
+
60
+ !!! note "Session Secret"
61
+ The session secret encrypts tokens stored in cookies. Generate a new one for each environment. Never commit it to version control.
62
+
63
+ ## Run Your App
64
+
65
+ ```bash
66
+ fastapi dev app.py
67
+ ```
68
+
69
+ Open [http://localhost:8000](http://localhost:8000) in your browser. You'll be redirected to SweatStack to log in, then back to your app with full API access.
70
+
71
+ ---
72
+
73
+ ## How It Works
74
+
75
+ The plugin adds three routes to your app:
76
+
77
+ | Route | Description |
78
+ |-------|-------------|
79
+ | `GET /auth/sweatstack/login` | Redirects to SweatStack OAuth |
80
+ | `GET /auth/sweatstack/callback` | Handles the OAuth callback |
81
+ | `POST /auth/sweatstack/logout` | Clears the session |
82
+
83
+ When a user visits a protected route:
84
+
85
+ 1. If not logged in → redirected to `/auth/sweatstack/login`
86
+ 2. If logged in → your route handler receives a `SweatStackUser` with:
87
+ - `user.user_id` — the authenticated user's ID
88
+ - `user.client` — a configured API client for that user
89
+
90
+ Tokens are stored in encrypted, httponly cookies. No database required.
91
+
92
+ ---
93
+
94
+ ## Protecting Routes
95
+
96
+ ### Require Authentication
97
+
98
+ Use `AuthenticatedUser` for routes that require a logged-in user:
99
+
100
+ ```python
101
+ from sweatstack.fastapi import AuthenticatedUser
102
+
103
+ @app.get("/dashboard")
104
+ def dashboard(user: AuthenticatedUser):
105
+ return user.client.get_activities()
106
+ ```
107
+
108
+ If the user isn't logged in, they're automatically redirected to the login page.
109
+
110
+ ### Optional Authentication
111
+
112
+ Use `OptionalUser` for routes that work with or without authentication:
113
+
114
+ ```python
115
+ from sweatstack.fastapi import OptionalUser
116
+
117
+ @app.get("/")
118
+ def home(user: OptionalUser):
119
+ if user:
120
+ return {"message": f"Hello, {user.user_id}!"}
121
+ return {"message": "Hello, guest! Please log in."}
122
+ ```
123
+
124
+ ---
125
+
126
+ ## Building Login/Logout UI
127
+
128
+ Use the `urls` helper to build login and logout links:
129
+
130
+ ```python
131
+ from fastapi.responses import HTMLResponse
132
+ from sweatstack.fastapi import AuthenticatedUser, OptionalUser, urls
133
+
134
+ @app.get("/")
135
+ def home(user: OptionalUser):
136
+ if user:
137
+ return HTMLResponse(f"""
138
+ <p>Welcome, {user.user_id}!</p>
139
+ <form method="POST" action="{urls.logout()}">
140
+ <button type="submit">Logout</button>
141
+ </form>
142
+ """)
143
+ return HTMLResponse(f"""
144
+ <p>Please log in to continue.</p>
145
+ <a href="{urls.login()}">Login with SweatStack</a>
146
+ """)
147
+ ```
148
+
149
+ The `urls` helper provides:
150
+
151
+ - `urls.login()` — returns `/auth/sweatstack/login`
152
+ - `urls.login(next="/dashboard")` — login with redirect after auth
153
+ - `urls.logout()` — returns `/auth/sweatstack/logout`
154
+
155
+ ---
156
+
157
+ ## Configuration Reference
158
+
159
+ All parameters can be passed to `configure()` or set via environment variables:
160
+
161
+ ```python
162
+ from sweatstack.fastapi import configure
163
+
164
+ configure(
165
+ # Required (or use environment variables)
166
+ client_id="...", # env: SWEATSTACK_CLIENT_ID
167
+ client_secret="...", # env: SWEATSTACK_CLIENT_SECRET
168
+ app_url="http://localhost:8000", # env: APP_URL
169
+ session_secret="...", # env: SWEATSTACK_SESSION_SECRET
170
+
171
+ # Optional
172
+ scopes=["profile", "data:read"], # OAuth scopes to request
173
+ cookie_max_age=86400, # Session lifetime in seconds (default: 24h)
174
+ auth_route_prefix="/auth/sweatstack", # Prefix for auth routes
175
+ redirect_unauthenticated=True, # Redirect to login vs return 401
176
+ )
177
+ ```
178
+
179
+ ### Environment Variables
180
+
181
+ | Variable | Description |
182
+ |----------|-------------|
183
+ | `SWEATSTACK_CLIENT_ID` | Your OAuth client ID |
184
+ | `SWEATSTACK_CLIENT_SECRET` | Your OAuth client secret |
185
+ | `SWEATSTACK_SESSION_SECRET` | Fernet key for cookie encryption |
186
+ | `APP_URL` | Base URL of your app (e.g., `http://localhost:8000`) |
187
+
188
+ ### Generating a Session Secret
189
+
190
+ ```bash
191
+ python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
192
+ ```
193
+
194
+ ### Key Rotation
195
+
196
+ For zero-downtime secret rotation, pass a list of keys. The first key encrypts new sessions; all keys are tried for decryption:
197
+
198
+ ```python
199
+ configure(
200
+ session_secret=[
201
+ os.environ["SESSION_SECRET_NEW"],
202
+ os.environ["SESSION_SECRET_OLD"],
203
+ ],
204
+ )
205
+ ```
206
+
207
+ ---
208
+
209
+ ## Security
210
+
211
+ The plugin follows security best practices:
212
+
213
+ - **Encrypted cookies** — Tokens are encrypted with Fernet (AES-128-CBC + HMAC)
214
+ - **HttpOnly** — Cookies are not accessible via JavaScript
215
+ - **Secure flag** — Automatically enabled for HTTPS URLs
216
+ - **SameSite=Lax** — Protection against CSRF attacks
217
+ - **State parameter** — OAuth state validated to prevent CSRF
218
+ - **Redirect validation** — Only relative paths allowed in `?next=` parameter
219
+
220
+ ---
221
+
222
+ ## Example: Complete App
223
+
224
+ Here's a complete example with multiple routes:
225
+
226
+ ```python
227
+ from fastapi import FastAPI
228
+ from fastapi.responses import HTMLResponse
229
+ from sweatstack.fastapi import (
230
+ configure, instrument, AuthenticatedUser, OptionalUser, urls
231
+ )
232
+
233
+ configure()
234
+
235
+ app = FastAPI(title="My SweatStack App")
236
+ instrument(app)
237
+
238
+
239
+ @app.get("/")
240
+ def home(user: OptionalUser):
241
+ if user:
242
+ return HTMLResponse(f"""
243
+ <h1>Welcome back!</h1>
244
+ <p>User ID: {user.user_id}</p>
245
+ <ul>
246
+ <li><a href="/activities">My Activities</a></li>
247
+ <li><a href="/profile">My Profile</a></li>
248
+ </ul>
249
+ <form method="POST" action="{urls.logout()}">
250
+ <button type="submit">Logout</button>
251
+ </form>
252
+ """)
253
+ return HTMLResponse(f"""
254
+ <h1>Welcome!</h1>
255
+ <p><a href="{urls.login()}">Login with SweatStack</a></p>
256
+ """)
257
+
258
+
259
+ @app.get("/activities")
260
+ def activities(user: AuthenticatedUser):
261
+ return user.client.get_activities(limit=10)
262
+
263
+
264
+ @app.get("/profile")
265
+ def profile(user: AuthenticatedUser):
266
+ return user.client.get_userinfo()
267
+ ```
268
+
269
+ ---
270
+
271
+ ## Next Steps
272
+
273
+ - Explore the [SweatStack API Reference](/reference/) to see what data you can access
274
+ - Learn about [OAuth2 concepts](/learn/oauth2-openid/) for deeper understanding
275
+ - Check out the [Python client documentation](/learn/python-client/) for all available methods
@@ -0,0 +1,396 @@
1
+ # SweatStack FastAPI Plugin
2
+
3
+ A lightweight plugin for integrating SweatStack OAuth into FastAPI applications.
4
+
5
+ ## Executive Summary
6
+
7
+ This plugin provides stateless OAuth authentication for FastAPI applications using SweatStack as the identity provider.
8
+
9
+ **Architecture:**
10
+ - OAuth 2.0 authorization code flow with CSRF protection via `state` parameter
11
+ - Tokens stored in Fernet-encrypted cookies (AES-128-CBC + HMAC) — no server-side session storage required
12
+ - Automatic token refresh when access tokens near expiry
13
+ - FastAPI dependency injection for clean route handler integration
14
+
15
+ **Security model:**
16
+ - Cookies: `httponly`, `secure`, `samesite=lax`
17
+ - OAuth state validated via short-lived cookie to prevent CSRF
18
+ - Redirect URLs validated to prevent open redirect attacks
19
+ - Tokens never exposed to browser JavaScript
20
+
21
+ **Interface:**
22
+ ```python
23
+ from sweatstack.fastapi import configure, instrument, AuthenticatedUser
24
+
25
+ configure(client_id=..., client_secret=..., session_secret=..., app_url=...)
26
+ instrument(app) # Adds {prefix}/login, callback, logout (default: /auth/sweatstack)
27
+
28
+ def my_route(user: AuthenticatedUser):
29
+ return user.client.get_activities()
30
+ ```
31
+
32
+ **Scope of v1:** Sync-only, no PKCE (client secret flow), module-level configuration. Async, PKCE, and class-based config can be added later without breaking changes.
33
+
34
+ ## Usage Example
35
+
36
+ ```
37
+ app/
38
+ ├── main.py
39
+ ├── routers/
40
+ │ ├── activities.py
41
+ │ └── athletes.py
42
+ ```
43
+
44
+ ```python
45
+ # main.py
46
+ import os
47
+ from fastapi import FastAPI
48
+ from sweatstack.fastapi import configure, instrument
49
+
50
+ from .routers import activities, athletes
51
+
52
+ app = FastAPI()
53
+
54
+ configure(
55
+ client_id="...",
56
+ client_secret="...",
57
+ app_url="http://localhost:8000",
58
+ session_secret=os.environ["SESSION_SECRET"],
59
+ )
60
+
61
+ instrument(app)
62
+
63
+ app.include_router(activities.router, prefix="/activities")
64
+ app.include_router(athletes.router, prefix="/athletes")
65
+ ```
66
+
67
+ ```python
68
+ # routers/activities.py
69
+ from fastapi import APIRouter
70
+ from sweatstack.fastapi import AuthenticatedUser, OptionalUser
71
+
72
+ router = APIRouter()
73
+
74
+
75
+ @router.get("/")
76
+ def list_activities(user: AuthenticatedUser):
77
+ return user.client.get_activities(limit=10)
78
+
79
+
80
+ @router.get("/public")
81
+ def public_feed(user: OptionalUser):
82
+ if user:
83
+ return user.client.get_activities(limit=5)
84
+ return {"message": "Login to see your activities"}
85
+ ```
86
+
87
+ ```python
88
+ # routers/athletes.py
89
+ from fastapi import APIRouter
90
+ from sweatstack.fastapi import AuthenticatedUser
91
+
92
+ router = APIRouter()
93
+
94
+
95
+ @router.get("/me")
96
+ def get_me(user: AuthenticatedUser):
97
+ return user.client.get_userinfo()
98
+ ```
99
+
100
+ ## Routes Added by `instrument(app)`
101
+
102
+ | Method | Path | Description |
103
+ |--------|------|-------------|
104
+ | GET | `{prefix}/login` | Redirects to SweatStack OAuth |
105
+ | GET | `{prefix}/callback` | Handles OAuth callback |
106
+ | POST | `{prefix}/logout` | Clears session, redirects to `/` |
107
+
108
+ Default prefix is `/auth/sweatstack`, configurable via `auth_route_prefix` parameter.
109
+
110
+ The login endpoint accepts an optional `?next=/path` parameter to redirect after successful authentication.
111
+
112
+
113
+ ## Technical Summary
114
+
115
+ **All exports from `sweatstack.fastapi`:**
116
+
117
+ ```python
118
+ from sweatstack.fastapi import (
119
+ configure, # Configure OAuth and session settings
120
+ instrument, # Add auth routes to FastAPI app
121
+ AuthenticatedUser, # Dependency: requires authenticated user
122
+ OptionalUser, # Dependency: returns user or None
123
+ SweatStackUser, # The user dataclass (for type hints)
124
+ )
125
+ ```
126
+
127
+ | Export | Type | Description |
128
+ |--------|------|-------------|
129
+ | `AuthenticatedUser` | `Annotated[SweatStackUser, Depends(...)]` | Requires valid session, returns user or 401/redirect |
130
+ | `OptionalUser` | `Annotated[SweatStackUser \| None, Depends(...)]` | Returns user if logged in, `None` otherwise |
131
+ | `SweatStackUser` | `dataclass` | For type hints when needed |
132
+
133
+ Use `AuthenticatedUser` for routes that require authentication. Behavior when unauthenticated depends on `redirect_unauthenticated` config (default: 401, or redirect to login with `?next=` if True).
134
+
135
+ **SweatStackUser:**
136
+
137
+ ```python
138
+ @dataclass
139
+ class SweatStackUser:
140
+ user_id: str
141
+ client: Client # Authenticated SweatStack client instance
142
+ ```
143
+
144
+
145
+ ## Configuration Parameters
146
+
147
+ ```python
148
+ configure(
149
+ # Required (or use environment variables)
150
+ client_id: str, # OAuth client ID (env: SWEATSTACK_CLIENT_ID)
151
+ client_secret: str, # OAuth client secret (env: SWEATSTACK_CLIENT_SECRET)
152
+ app_url: str, # Base URL of the app (env: APP_URL)
153
+ session_secret: str | list[str], # Fernet key(s) (env: SWEATSTACK_SESSION_SECRET)
154
+
155
+ # Optional
156
+ scopes: list[str] = ["profile", "data:read"], # OAuth scopes to request
157
+ cookie_secure: bool | None = None, # Auto-detected from app_url scheme
158
+ cookie_max_age: int = 86400, # Session cookie lifetime in seconds (default: 24h)
159
+ auth_route_prefix: str = "/auth/sweatstack", # Prefix for auth routes
160
+ redirect_unauthenticated: bool = False, # If True, redirect to login instead of 401
161
+ )
162
+ ```
163
+
164
+ All required parameters can be set via environment variables, allowing zero-argument configuration:
165
+
166
+ ```python
167
+ # With environment variables set, this is all you need:
168
+ configure()
169
+ ```
170
+
171
+ The OAuth redirect URI is automatically derived as `app_url + auth_route_prefix + "/callback"`.
172
+
173
+ **Base URL:** All API calls use `https://app.sweatstack.no`.
174
+
175
+
176
+ ## Session Storage
177
+
178
+ OAuth tokens must be stored securely and not exposed to the browser. We use Fernet-encrypted cookies for stateless session management.
179
+
180
+ **Why Fernet?**
181
+ - Symmetric encryption (AES-128-CBC + HMAC)
182
+ - Simple API from the `cryptography` library
183
+ - Provides both confidentiality and integrity
184
+ - No server-side storage required
185
+
186
+ **Configuration:**
187
+
188
+ ```python
189
+ configure(
190
+ client_id="...",
191
+ client_secret="...",
192
+ app_url="http://localhost:8000",
193
+ session_secret=os.environ["SESSION_SECRET"], # Fernet key
194
+ )
195
+ ```
196
+
197
+ **Generate a session secret:**
198
+
199
+ ```bash
200
+ python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
201
+ ```
202
+
203
+ **Cookie names:**
204
+ - `sweatstack_session` — encrypted session data
205
+ - `sweatstack_oauth_state` — temporary state during OAuth flow (5 min TTL)
206
+
207
+ **Cookie settings (both cookies):**
208
+ - `httponly=True` — no JavaScript access
209
+ - `secure` — auto-detected from `app_url` scheme (https=True, http=False)
210
+ - `samesite="lax"` — CSRF protection
211
+ - `path="/"` — available on all routes
212
+
213
+ A warning is logged when using HTTP with a non-localhost URL.
214
+
215
+ **Session cookie additional settings:**
216
+ - `max_age=86400` — 24-hour expiry (configurable via `cookie_max_age`)
217
+
218
+ **Session data structure (encrypted):**
219
+
220
+ ```json
221
+ {
222
+ "access_token": "...",
223
+ "refresh_token": "...",
224
+ "user_id": "abc123"
225
+ }
226
+ ```
227
+
228
+ **Key rotation:**
229
+
230
+ For zero-downtime key rotation, `session_secret` accepts a list. First key encrypts; all keys are tried for decryption:
231
+
232
+ ```python
233
+ configure(
234
+ ...
235
+ session_secret=[
236
+ os.environ["SESSION_SECRET_NEW"],
237
+ os.environ["SESSION_SECRET_OLD"],
238
+ ],
239
+ )
240
+ ```
241
+
242
+
243
+ ## OAuth Flow
244
+
245
+ ### Login (`GET {prefix}/login`)
246
+
247
+ 1. Generate random `state` value (32 bytes, URL-safe base64)
248
+ 2. If `?next=` parameter provided, validate and encode it in state: `state = base64(json({"nonce": "...", "next": "/dashboard"}))`
249
+ 3. Set `sweatstack_oauth_state` cookie with the full encoded state (5 min TTL)
250
+ 4. Redirect to `https://app.sweatstack.no/oauth/authorize` with parameters:
251
+ - `client_id`, `redirect_uri`, `scope`, `state`, `prompt=none`
252
+
253
+ **Redirect validation:** The `next` parameter must be a relative path (starts with `/`, not `//`) to prevent open redirect attacks:
254
+
255
+ ```python
256
+ def validate_redirect(url: str | None) -> str | None:
257
+ if url and url.startswith('/') and not url.startswith('//'):
258
+ return url
259
+ return None
260
+ ```
261
+
262
+ ### Callback (`GET {prefix}/callback`)
263
+
264
+ 1. Compare `state` query parameter with `sweatstack_oauth_state` cookie (byte-for-byte)
265
+ 2. If mismatch or missing: return 400 Bad Request
266
+ 3. Clear state cookie
267
+ 4. If `?error=` parameter present: redirect to `/`
268
+ 5. Exchange `code` for tokens via `POST https://app.sweatstack.no/api/v1/oauth/token`
269
+ 6. Extract `user_id` from access token JWT
270
+ 7. Create encrypted `sweatstack_session` cookie with tokens
271
+ 8. Redirect to `next` URL from state (default: `/`)
272
+
273
+ ### Logout (`POST {prefix}/logout`)
274
+
275
+ 1. Clear `sweatstack_session` cookie
276
+ 2. Redirect to `/`
277
+
278
+
279
+ ## Token Refresh
280
+
281
+ Access tokens are short-lived. The plugin automatically refreshes them when needed.
282
+
283
+ **Refresh logic (in dependency):**
284
+
285
+ ```python
286
+ def require_user(request: Request, response: Response) -> SweatStackUser:
287
+ session = decrypt_session(request.cookies.get("sweatstack_session"))
288
+ if not session:
289
+ raise HTTPException(401, "Not authenticated")
290
+
291
+ access_token = session["access_token"]
292
+
293
+ if is_token_expiring(access_token):
294
+ try:
295
+ # Extract timezone from current token for refresh request
296
+ token_body = decode_jwt_body(access_token)
297
+ tz = token_body.get("tz", "UTC")
298
+
299
+ new_access_token = refresh_access_token(
300
+ refresh_token=session["refresh_token"],
301
+ client_id=config.client_id,
302
+ client_secret=config.client_secret,
303
+ tz=tz,
304
+ )
305
+ session["access_token"] = new_access_token
306
+ # Update cookie in response
307
+ set_session_cookie(response, session)
308
+ access_token = new_access_token
309
+ except Exception:
310
+ logging.exception("Token refresh failed")
311
+ clear_session_cookie(response)
312
+ raise HTTPException(401, "Session expired")
313
+
314
+ client = Client(api_key=access_token)
315
+ return SweatStackUser(user_id=session["user_id"], client=client)
316
+ ```
317
+
318
+ **Expiry check:**
319
+
320
+ ```python
321
+ TOKEN_EXPIRY_MARGIN = 5 # seconds
322
+
323
+ def is_token_expiring(token: str) -> bool:
324
+ body = decode_jwt_body(token)
325
+ return body["exp"] - TOKEN_EXPIRY_MARGIN < time.time()
326
+ ```
327
+
328
+ **Timezone handling:** The refresh endpoint requires a `tz` parameter. We extract this from the current access token's JWT body (falls back to `"UTC"` if not present).
329
+
330
+ The cookie is updated directly via FastAPI's `Response` parameter in the dependency, avoiding middleware complexity.
331
+
332
+
333
+ ## Error Handling
334
+
335
+ | Scenario | Behavior |
336
+ |----------|----------|
337
+ | Missing/invalid session cookie | 401 Unauthorized |
338
+ | Token refresh fails | Log error, clear cookie, 401 Unauthorized |
339
+ | OAuth callback error | Redirect to `/` (future: configurable error page) |
340
+ | State mismatch in callback | 400 Bad Request |
341
+ | Decryption fails (tampered cookie) | Treat as missing session, 401 |
342
+
343
+
344
+ ## File Structure
345
+
346
+ ```
347
+ src/sweatstack/
348
+ ├── __init__.py # Main package (no FastAPI re-exports)
349
+ ├── client.py # Existing CLI client
350
+ ├── utils.py # Existing utils (decode_jwt_body, etc.)
351
+ └── fastapi/
352
+ ├── __init__.py # All FastAPI exports (with dependency check)
353
+ ├── config.py # Module-level configuration state
354
+ ├── routes.py # Login, callback, logout routes
355
+ ├── dependencies.py # require_user, optional_user, SweatStackUser
356
+ └── session.py # Fernet encrypt/decrypt, cookie helpers
357
+ ```
358
+
359
+ **FastAPI submodule exports:**
360
+
361
+ ```python
362
+ # sweatstack/fastapi/__init__.py
363
+ from .config import configure
364
+ from .routes import instrument
365
+ from .dependencies import AuthenticatedUser, OptionalUser, SweatStackUser
366
+ ```
367
+
368
+
369
+ ## Implementation Notes
370
+
371
+ **Sync-only:** This first version uses synchronous code throughout. The existing `Client` class is sync, and we reuse it directly. Async support can be added later.
372
+
373
+ **No PKCE yet:** The initial implementation uses the standard authorization code flow with client secret. PKCE support will be added in a future iteration.
374
+
375
+ **Module-level state:** Configuration is stored in an internal `FastAPIConfig` dataclass (not exposed to users — they just pass kwargs to `configure()`). This is simple and works well for typical single-app deployments.
376
+
377
+ **Configuration validation:** Calling `instrument()` before `configure()` raises a clear error:
378
+ ```
379
+ RuntimeError: configure() must be called before instrument()
380
+ ```
381
+
382
+ **Optional FastAPI dependency:** FastAPI is an optional dependency. Attempting to import from `sweatstack.fastapi` without FastAPI installed raises a clear error:
383
+ ```python
384
+ # sweatstack/fastapi/__init__.py
385
+ try:
386
+ import fastapi
387
+ except ImportError:
388
+ raise ImportError(
389
+ "FastAPI is required for sweatstack.fastapi. "
390
+ "Install it with: pip install sweatstack[fastapi]"
391
+ )
392
+ ```
393
+
394
+ This follows the same pattern as the existing Streamlit integration.
395
+
396
+ **Dependencies:** Requires `cryptography` (for Fernet) and `fastapi` as optional extras. The existing `httpx` dependency handles OAuth token exchange.