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.
- sweatstack-0.61.0/.claude/settings.local.json +18 -0
- {sweatstack-0.59.0 → sweatstack-0.61.0}/CHANGELOG.md +16 -0
- sweatstack-0.61.0/FASTAPI_DOCS.md +275 -0
- sweatstack-0.61.0/FASTAPI_PLUGIN.md +396 -0
- sweatstack-0.61.0/FASTAPI_USER_SWITCHING.md +858 -0
- {sweatstack-0.59.0 → sweatstack-0.61.0}/PKG-INFO +4 -1
- sweatstack-0.61.0/fastapi_coaching_example.py +97 -0
- sweatstack-0.61.0/fastapi_example.py +95 -0
- sweatstack-0.61.0/fastapi_sweatstack.py +66 -0
- {sweatstack-0.59.0 → sweatstack-0.61.0}/pyproject.toml +5 -1
- {sweatstack-0.59.0 → sweatstack-0.61.0}/src/sweatstack/client.py +67 -27
- sweatstack-0.61.0/src/sweatstack/fastapi/__init__.py +82 -0
- sweatstack-0.61.0/src/sweatstack/fastapi/config.py +223 -0
- sweatstack-0.61.0/src/sweatstack/fastapi/dependencies.py +293 -0
- sweatstack-0.61.0/src/sweatstack/fastapi/models.py +109 -0
- sweatstack-0.61.0/src/sweatstack/fastapi/routes.py +312 -0
- sweatstack-0.61.0/src/sweatstack/fastapi/session.py +102 -0
- {sweatstack-0.59.0 → sweatstack-0.61.0}/uv.lock +647 -32
- sweatstack-0.59.0/.claude/settings.local.json +0 -14
- {sweatstack-0.59.0 → sweatstack-0.61.0}/.gitignore +0 -0
- {sweatstack-0.59.0 → sweatstack-0.61.0}/.python-version +0 -0
- {sweatstack-0.59.0 → sweatstack-0.61.0}/DEVELOPMENT.md +0 -0
- {sweatstack-0.59.0 → sweatstack-0.61.0}/Makefile +0 -0
- {sweatstack-0.59.0 → sweatstack-0.61.0}/README.md +0 -0
- {sweatstack-0.59.0 → sweatstack-0.61.0}/docs/conf.py +0 -0
- {sweatstack-0.59.0 → sweatstack-0.61.0}/docs/everything.rst +0 -0
- {sweatstack-0.59.0 → sweatstack-0.61.0}/docs/index.rst +0 -0
- {sweatstack-0.59.0 → sweatstack-0.61.0}/playground/.ipynb_checkpoints/Untitled-checkpoint.ipynb +0 -0
- {sweatstack-0.59.0 → sweatstack-0.61.0}/playground/README.md +0 -0
- {sweatstack-0.59.0 → sweatstack-0.61.0}/playground/Sweat Stack examples/Getting started.ipynb +0 -0
- {sweatstack-0.59.0 → sweatstack-0.61.0}/playground/Untitled.ipynb +0 -0
- {sweatstack-0.59.0 → sweatstack-0.61.0}/playground/hello.py +0 -0
- {sweatstack-0.59.0 → sweatstack-0.61.0}/playground/pyproject.toml +0 -0
- {sweatstack-0.59.0 → sweatstack-0.61.0}/src/sweatstack/Sweat Stack examples/Getting started.ipynb +0 -0
- {sweatstack-0.59.0 → sweatstack-0.61.0}/src/sweatstack/__init__.py +0 -0
- {sweatstack-0.59.0 → sweatstack-0.61.0}/src/sweatstack/cli.py +0 -0
- {sweatstack-0.59.0 → sweatstack-0.61.0}/src/sweatstack/constants.py +0 -0
- {sweatstack-0.59.0 → sweatstack-0.61.0}/src/sweatstack/ipython_init.py +0 -0
- {sweatstack-0.59.0 → sweatstack-0.61.0}/src/sweatstack/jupyterlab_oauth2_startup.py +0 -0
- {sweatstack-0.59.0 → sweatstack-0.61.0}/src/sweatstack/openapi_schemas.py +0 -0
- {sweatstack-0.59.0 → sweatstack-0.61.0}/src/sweatstack/py.typed +0 -0
- {sweatstack-0.59.0 → sweatstack-0.61.0}/src/sweatstack/schemas.py +0 -0
- {sweatstack-0.59.0 → sweatstack-0.61.0}/src/sweatstack/streamlit.py +0 -0
- {sweatstack-0.59.0 → sweatstack-0.61.0}/src/sweatstack/sweatshell.py +0 -0
- {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.
|