sweatstack 0.60.0__py3-none-any.whl → 0.61.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sweatstack/fastapi/__init__.py +46 -7
- sweatstack/fastapi/config.py +47 -1
- sweatstack/fastapi/dependencies.py +203 -71
- sweatstack/fastapi/models.py +109 -0
- sweatstack/fastapi/routes.py +137 -2
- {sweatstack-0.60.0.dist-info → sweatstack-0.61.0.dist-info}/METADATA +1 -1
- {sweatstack-0.60.0.dist-info → sweatstack-0.61.0.dist-info}/RECORD +9 -8
- {sweatstack-0.60.0.dist-info → sweatstack-0.61.0.dist-info}/WHEEL +0 -0
- {sweatstack-0.60.0.dist-info → sweatstack-0.61.0.dist-info}/entry_points.txt +0 -0
sweatstack/fastapi/__init__.py
CHANGED
|
@@ -1,20 +1,52 @@
|
|
|
1
1
|
"""FastAPI integration for SweatStack authentication.
|
|
2
2
|
|
|
3
3
|
This module provides OAuth authentication for FastAPI applications using
|
|
4
|
-
SweatStack as the identity provider.
|
|
4
|
+
SweatStack as the identity provider. It includes support for user switching,
|
|
5
|
+
allowing applications like coaching platforms to view data on behalf of
|
|
6
|
+
other users.
|
|
5
7
|
|
|
6
8
|
Example:
|
|
7
9
|
from fastapi import FastAPI
|
|
8
|
-
from sweatstack.fastapi import configure, instrument, AuthenticatedUser
|
|
10
|
+
from sweatstack.fastapi import configure, instrument, AuthenticatedUser, SelectedUser, urls
|
|
9
11
|
|
|
10
12
|
configure() # uses environment variables
|
|
11
13
|
|
|
12
14
|
app = FastAPI()
|
|
13
15
|
instrument(app)
|
|
14
16
|
|
|
15
|
-
@app.get("/
|
|
16
|
-
def
|
|
17
|
-
|
|
17
|
+
@app.get("/activities")
|
|
18
|
+
def get_activities(user: SelectedUser):
|
|
19
|
+
# Returns activities for the currently selected user
|
|
20
|
+
return user.client.get_activities()
|
|
21
|
+
|
|
22
|
+
@app.get("/my-athletes")
|
|
23
|
+
def get_athletes(user: AuthenticatedUser):
|
|
24
|
+
# Always returns the principal user's accessible users
|
|
25
|
+
return user.client.get_users()
|
|
26
|
+
|
|
27
|
+
User Switching:
|
|
28
|
+
The module supports two methods of user switching:
|
|
29
|
+
|
|
30
|
+
1. URL-based switching (recommended for web apps):
|
|
31
|
+
Use urls.select_user(user_id) and urls.select_self() in templates:
|
|
32
|
+
|
|
33
|
+
<form method="post" action="{{ urls.select_user(athlete.id) }}">
|
|
34
|
+
<button>View as {{ athlete.name }}</button>
|
|
35
|
+
</form>
|
|
36
|
+
|
|
37
|
+
2. Programmatic switching:
|
|
38
|
+
Call client.switch_user() in your endpoint code:
|
|
39
|
+
|
|
40
|
+
@app.post("/select/{athlete_id}")
|
|
41
|
+
def select(athlete_id: str, user: AuthenticatedUser):
|
|
42
|
+
user.client.switch_user(athlete_id)
|
|
43
|
+
return RedirectResponse("/dashboard")
|
|
44
|
+
|
|
45
|
+
Dependency Types:
|
|
46
|
+
- AuthenticatedUser: Always returns the principal (logged-in) user
|
|
47
|
+
- OptionalUser: Returns principal or None if not authenticated
|
|
48
|
+
- SelectedUser: Returns the selected user (delegated or principal)
|
|
49
|
+
- OptionalSelectedUser: Returns selected user or None if not authenticated
|
|
18
50
|
"""
|
|
19
51
|
|
|
20
52
|
try:
|
|
@@ -28,16 +60,23 @@ except ImportError:
|
|
|
28
60
|
from .config import configure, urls
|
|
29
61
|
from .dependencies import (
|
|
30
62
|
AuthenticatedUser,
|
|
63
|
+
OptionalSelectedUser,
|
|
31
64
|
OptionalUser,
|
|
65
|
+
SelectedUser,
|
|
32
66
|
SweatStackUser,
|
|
33
67
|
)
|
|
34
68
|
from .routes import instrument
|
|
35
69
|
|
|
36
70
|
__all__ = [
|
|
71
|
+
# Configuration
|
|
37
72
|
"configure",
|
|
38
73
|
"instrument",
|
|
74
|
+
"urls",
|
|
75
|
+
# User types
|
|
76
|
+
"SweatStackUser",
|
|
77
|
+
# Dependencies
|
|
39
78
|
"AuthenticatedUser",
|
|
40
79
|
"OptionalUser",
|
|
41
|
-
"
|
|
42
|
-
"
|
|
80
|
+
"SelectedUser",
|
|
81
|
+
"OptionalSelectedUser",
|
|
43
82
|
]
|
sweatstack/fastapi/config.py
CHANGED
|
@@ -156,7 +156,20 @@ def get_config() -> FastAPIConfig:
|
|
|
156
156
|
|
|
157
157
|
|
|
158
158
|
class _Urls:
|
|
159
|
-
"""URL helpers for the FastAPI plugin.
|
|
159
|
+
"""URL helpers for the FastAPI plugin.
|
|
160
|
+
|
|
161
|
+
Provides methods to generate URLs for authentication and user selection routes.
|
|
162
|
+
These URLs can be used in templates or redirects.
|
|
163
|
+
|
|
164
|
+
Example:
|
|
165
|
+
from sweatstack.fastapi import urls
|
|
166
|
+
|
|
167
|
+
# In a template:
|
|
168
|
+
<a href="{{ urls.login() }}">Login</a>
|
|
169
|
+
<form method="post" action="{{ urls.select_user(athlete.id) }}">
|
|
170
|
+
<button>View as {{ athlete.name }}</button>
|
|
171
|
+
</form>
|
|
172
|
+
"""
|
|
160
173
|
|
|
161
174
|
def login(self, next: str | None = None) -> str:
|
|
162
175
|
"""Get the login URL.
|
|
@@ -173,5 +186,38 @@ class _Urls:
|
|
|
173
186
|
"""Get the logout URL."""
|
|
174
187
|
return f"{get_config().auth_route_prefix}/logout"
|
|
175
188
|
|
|
189
|
+
def select_user(self, user_id: str, next: str | None = None) -> str:
|
|
190
|
+
"""Get the URL to switch to viewing as another user.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
user_id: The ID of the user to view as.
|
|
194
|
+
next: Optional path to redirect to after switching.
|
|
195
|
+
|
|
196
|
+
Example:
|
|
197
|
+
<form method="post" action="{{ urls.select_user(athlete.id) }}">
|
|
198
|
+
<button>View as {{ athlete.name }}</button>
|
|
199
|
+
</form>
|
|
200
|
+
"""
|
|
201
|
+
base = f"{get_config().auth_route_prefix}/select-user/{user_id}"
|
|
202
|
+
if next:
|
|
203
|
+
return f"{base}?next={quote(next)}"
|
|
204
|
+
return base
|
|
205
|
+
|
|
206
|
+
def select_self(self, next: str | None = None) -> str:
|
|
207
|
+
"""Get the URL to switch back to viewing as yourself.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
next: Optional path to redirect to after switching.
|
|
211
|
+
|
|
212
|
+
Example:
|
|
213
|
+
<form method="post" action="{{ urls.select_self() }}">
|
|
214
|
+
<button>Back to my view</button>
|
|
215
|
+
</form>
|
|
216
|
+
"""
|
|
217
|
+
base = f"{get_config().auth_route_prefix}/select-self"
|
|
218
|
+
if next:
|
|
219
|
+
return f"{base}?next={quote(next)}"
|
|
220
|
+
return base
|
|
221
|
+
|
|
176
222
|
|
|
177
223
|
urls = _Urls()
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
"""FastAPI dependencies for authentication."""
|
|
2
2
|
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
3
5
|
import logging
|
|
4
6
|
import time
|
|
5
7
|
from dataclasses import dataclass
|
|
6
|
-
from typing import Annotated, NoReturn
|
|
8
|
+
from typing import Annotated, NoReturn
|
|
7
9
|
from urllib.parse import quote
|
|
8
10
|
|
|
9
11
|
import httpx
|
|
@@ -13,6 +15,7 @@ from ..client import Client
|
|
|
13
15
|
from ..constants import DEFAULT_URL
|
|
14
16
|
from ..utils import decode_jwt_body
|
|
15
17
|
from .config import get_config
|
|
18
|
+
from .models import SessionData, TokenSet, extract_user_id
|
|
16
19
|
from .session import (
|
|
17
20
|
SESSION_COOKIE_NAME,
|
|
18
21
|
clear_session_cookie,
|
|
@@ -20,33 +23,42 @@ from .session import (
|
|
|
20
23
|
set_session_cookie,
|
|
21
24
|
)
|
|
22
25
|
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
23
28
|
TOKEN_EXPIRY_MARGIN = 5 # seconds
|
|
24
29
|
|
|
25
30
|
|
|
26
|
-
@dataclass
|
|
31
|
+
@dataclass(slots=True)
|
|
27
32
|
class SweatStackUser:
|
|
28
33
|
"""Authenticated SweatStack user.
|
|
29
34
|
|
|
30
35
|
Attributes:
|
|
31
|
-
user_id: The user's SweatStack ID.
|
|
32
36
|
client: An authenticated Client instance for API calls.
|
|
33
37
|
"""
|
|
34
38
|
|
|
35
|
-
user_id: str
|
|
36
39
|
client: Client
|
|
37
40
|
|
|
41
|
+
@property
|
|
42
|
+
def user_id(self) -> str:
|
|
43
|
+
"""The user ID this client acts as."""
|
|
44
|
+
return extract_user_id(self.client.api_key)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
# Token refresh
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
38
50
|
|
|
39
|
-
|
|
51
|
+
|
|
52
|
+
def _is_token_expiring(token: str) -> bool:
|
|
40
53
|
"""Check if a token is within TOKEN_EXPIRY_MARGIN seconds of expiring."""
|
|
41
54
|
try:
|
|
42
55
|
body = decode_jwt_body(token)
|
|
43
56
|
return body["exp"] - TOKEN_EXPIRY_MARGIN < time.time()
|
|
44
57
|
except Exception:
|
|
45
|
-
# If we can't decode, assume it's expired
|
|
46
58
|
return True
|
|
47
59
|
|
|
48
60
|
|
|
49
|
-
def
|
|
61
|
+
def _refresh_access_token(
|
|
50
62
|
refresh_token: str,
|
|
51
63
|
client_id: str,
|
|
52
64
|
client_secret: str,
|
|
@@ -67,12 +79,39 @@ def refresh_access_token(
|
|
|
67
79
|
return response.json()["access_token"]
|
|
68
80
|
|
|
69
81
|
|
|
70
|
-
def
|
|
71
|
-
"""
|
|
82
|
+
def _refresh_tokens_if_needed(tokens: TokenSet) -> TokenSet | None:
|
|
83
|
+
"""Refresh tokens if the access token is expiring.
|
|
72
84
|
|
|
73
|
-
|
|
74
|
-
Otherwise, raises 401 Unauthorized.
|
|
85
|
+
Returns new TokenSet if refreshed, None if no refresh needed.
|
|
75
86
|
"""
|
|
87
|
+
if not _is_token_expiring(tokens.access_token):
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
token_body = decode_jwt_body(tokens.access_token)
|
|
91
|
+
tz = token_body.get("tz", "UTC")
|
|
92
|
+
|
|
93
|
+
config = get_config()
|
|
94
|
+
new_access_token = _refresh_access_token(
|
|
95
|
+
refresh_token=tokens.refresh_token,
|
|
96
|
+
client_id=config.client_id,
|
|
97
|
+
client_secret=config.client_secret.get_secret_value(),
|
|
98
|
+
tz=tz,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
return TokenSet(
|
|
102
|
+
access_token=new_access_token,
|
|
103
|
+
refresh_token=tokens.refresh_token,
|
|
104
|
+
user_id=tokens.user_id,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
# Session helpers
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _raise_unauthenticated(request: Request) -> NoReturn:
|
|
114
|
+
"""Raise appropriate exception for unauthenticated requests."""
|
|
76
115
|
config = get_config()
|
|
77
116
|
if config.redirect_unauthenticated:
|
|
78
117
|
next_url = request.url.path
|
|
@@ -83,79 +122,172 @@ def _raise_unauthenticated(request: Request) -> NoReturn:
|
|
|
83
122
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
|
84
123
|
|
|
85
124
|
|
|
86
|
-
def
|
|
87
|
-
"""
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
- If True: redirects to login with ?next= set to current path
|
|
92
|
-
- If False: raises 401 Unauthorized
|
|
125
|
+
def _get_session_or_raise(request: Request) -> SessionData:
|
|
126
|
+
"""Get and validate session data, raising if invalid."""
|
|
127
|
+
raw_session = decrypt_session(request.cookies.get(SESSION_COOKIE_NAME))
|
|
128
|
+
if not raw_session:
|
|
129
|
+
_raise_unauthenticated(request)
|
|
93
130
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
if not session:
|
|
131
|
+
try:
|
|
132
|
+
return SessionData.from_dict(raw_session)
|
|
133
|
+
except (KeyError, TypeError):
|
|
98
134
|
_raise_unauthenticated(request)
|
|
99
135
|
|
|
100
|
-
access_token = session.get("access_token")
|
|
101
|
-
refresh_token = session.get("refresh_token")
|
|
102
|
-
user_id = session.get("user_id")
|
|
103
136
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
137
|
+
def _get_session_or_none(request: Request) -> SessionData | None:
|
|
138
|
+
"""Get session data if present and valid, None otherwise."""
|
|
139
|
+
raw_session = decrypt_session(request.cookies.get(SESSION_COOKIE_NAME))
|
|
140
|
+
if not raw_session:
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
return SessionData.from_dict(raw_session)
|
|
145
|
+
except (KeyError, TypeError):
|
|
146
|
+
return None
|
|
147
|
+
|
|
107
148
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
clear_session_cookie(response)
|
|
112
|
-
_raise_unauthenticated(request)
|
|
113
|
-
|
|
114
|
-
try:
|
|
115
|
-
# Extract timezone from current token
|
|
116
|
-
token_body = decode_jwt_body(access_token)
|
|
117
|
-
tz = token_body.get("tz", "UTC")
|
|
118
|
-
|
|
119
|
-
config = get_config()
|
|
120
|
-
new_access_token = refresh_access_token(
|
|
121
|
-
refresh_token=refresh_token,
|
|
122
|
-
client_id=config.client_id,
|
|
123
|
-
client_secret=config.client_secret,
|
|
124
|
-
tz=tz,
|
|
125
|
-
)
|
|
126
|
-
|
|
127
|
-
# Update session with new token
|
|
128
|
-
session["access_token"] = new_access_token
|
|
129
|
-
set_session_cookie(response, session)
|
|
130
|
-
access_token = new_access_token
|
|
131
|
-
|
|
132
|
-
except Exception:
|
|
133
|
-
logging.exception("Token refresh failed for user %s", user_id)
|
|
134
|
-
clear_session_cookie(response)
|
|
135
|
-
_raise_unauthenticated(request)
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
# Core dependency logic
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
136
152
|
|
|
153
|
+
|
|
154
|
+
def _create_user(
|
|
155
|
+
session: SessionData,
|
|
156
|
+
response: Response,
|
|
157
|
+
*,
|
|
158
|
+
use_delegated: bool,
|
|
159
|
+
) -> SweatStackUser:
|
|
160
|
+
"""Create user from session, refreshing tokens if needed."""
|
|
137
161
|
config = get_config()
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
162
|
+
|
|
163
|
+
# Select which tokens to use
|
|
164
|
+
if use_delegated and session.delegated:
|
|
165
|
+
tokens = session.delegated
|
|
166
|
+
is_delegated = True
|
|
167
|
+
else:
|
|
168
|
+
tokens = session.principal
|
|
169
|
+
is_delegated = False
|
|
170
|
+
|
|
171
|
+
# Refresh tokens if needed and persist immediately
|
|
172
|
+
try:
|
|
173
|
+
refreshed = _refresh_tokens_if_needed(tokens)
|
|
174
|
+
except Exception:
|
|
175
|
+
logger.exception("Token refresh failed for user %s", tokens.user_id)
|
|
176
|
+
clear_session_cookie(response)
|
|
177
|
+
raise HTTPException(status_code=401, detail="Session expired")
|
|
178
|
+
|
|
179
|
+
if refreshed:
|
|
180
|
+
# Update session with refreshed tokens
|
|
181
|
+
if is_delegated:
|
|
182
|
+
session = SessionData(principal=session.principal, delegated=refreshed)
|
|
183
|
+
else:
|
|
184
|
+
session = SessionData(principal=refreshed, delegated=session.delegated)
|
|
185
|
+
tokens = refreshed
|
|
186
|
+
set_session_cookie(response, session.to_dict())
|
|
187
|
+
|
|
188
|
+
return SweatStackUser(
|
|
189
|
+
client=Client(
|
|
190
|
+
api_key=tokens.access_token,
|
|
191
|
+
refresh_token=tokens.refresh_token,
|
|
192
|
+
client_id=config.client_id,
|
|
193
|
+
client_secret=config.client_secret,
|
|
194
|
+
)
|
|
143
195
|
)
|
|
144
|
-
return SweatStackUser(user_id=user_id, client=client)
|
|
145
196
|
|
|
146
197
|
|
|
147
|
-
|
|
148
|
-
|
|
198
|
+
# ---------------------------------------------------------------------------
|
|
199
|
+
# Dependency functions
|
|
200
|
+
# ---------------------------------------------------------------------------
|
|
149
201
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
202
|
+
|
|
203
|
+
def _require_authenticated_user(
|
|
204
|
+
request: Request,
|
|
205
|
+
response: Response,
|
|
206
|
+
) -> SweatStackUser:
|
|
207
|
+
"""Dependency: always returns principal user."""
|
|
208
|
+
session = _get_session_or_raise(request)
|
|
209
|
+
return _create_user(session, response, use_delegated=False)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _require_selected_user(
|
|
213
|
+
request: Request,
|
|
214
|
+
response: Response,
|
|
215
|
+
) -> SweatStackUser:
|
|
216
|
+
"""Dependency: returns delegated user if selected, otherwise principal."""
|
|
217
|
+
session = _get_session_or_raise(request)
|
|
218
|
+
return _create_user(session, response, use_delegated=True)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _optional_authenticated_user(
|
|
222
|
+
request: Request,
|
|
223
|
+
response: Response,
|
|
224
|
+
) -> SweatStackUser | None:
|
|
225
|
+
"""Dependency: returns principal user or None."""
|
|
226
|
+
session = _get_session_or_none(request)
|
|
227
|
+
if not session:
|
|
228
|
+
return None
|
|
153
229
|
try:
|
|
154
|
-
return
|
|
230
|
+
return _create_user(session, response, use_delegated=False)
|
|
155
231
|
except HTTPException:
|
|
156
232
|
return None
|
|
157
233
|
|
|
158
234
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
235
|
+
def _optional_selected_user(
|
|
236
|
+
request: Request,
|
|
237
|
+
response: Response,
|
|
238
|
+
) -> SweatStackUser | None:
|
|
239
|
+
"""Dependency: returns selected user or None."""
|
|
240
|
+
session = _get_session_or_none(request)
|
|
241
|
+
if not session:
|
|
242
|
+
return None
|
|
243
|
+
try:
|
|
244
|
+
return _create_user(session, response, use_delegated=True)
|
|
245
|
+
except HTTPException:
|
|
246
|
+
return None
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# ---------------------------------------------------------------------------
|
|
250
|
+
# Public type aliases
|
|
251
|
+
# ---------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
AuthenticatedUser = Annotated[SweatStackUser, Depends(_require_authenticated_user)]
|
|
254
|
+
"""Dependency that always returns the principal (logged-in) user.
|
|
255
|
+
|
|
256
|
+
Example:
|
|
257
|
+
@app.get("/my-athletes")
|
|
258
|
+
def get_athletes(user: AuthenticatedUser):
|
|
259
|
+
return user.client.get_users()
|
|
260
|
+
"""
|
|
261
|
+
|
|
262
|
+
SelectedUser = Annotated[SweatStackUser, Depends(_require_selected_user)]
|
|
263
|
+
"""Dependency that returns the currently selected user.
|
|
264
|
+
|
|
265
|
+
Returns the delegated user if one is selected, otherwise the principal user.
|
|
266
|
+
|
|
267
|
+
Example:
|
|
268
|
+
@app.get("/activities")
|
|
269
|
+
def get_activities(user: SelectedUser):
|
|
270
|
+
return user.client.get_activities()
|
|
271
|
+
"""
|
|
272
|
+
|
|
273
|
+
OptionalUser = Annotated[SweatStackUser | None, Depends(_optional_authenticated_user)]
|
|
274
|
+
"""Dependency that returns the principal user or None if not authenticated.
|
|
275
|
+
|
|
276
|
+
Example:
|
|
277
|
+
@app.get("/")
|
|
278
|
+
def home(user: OptionalUser):
|
|
279
|
+
if user:
|
|
280
|
+
return {"logged_in": True, "user_id": user.user_id}
|
|
281
|
+
return {"logged_in": False}
|
|
282
|
+
"""
|
|
283
|
+
|
|
284
|
+
OptionalSelectedUser = Annotated[SweatStackUser | None, Depends(_optional_selected_user)]
|
|
285
|
+
"""Dependency that returns the selected user or None if not authenticated.
|
|
286
|
+
|
|
287
|
+
Example:
|
|
288
|
+
@app.get("/public-profile")
|
|
289
|
+
def profile(user: OptionalSelectedUser):
|
|
290
|
+
if user:
|
|
291
|
+
return user.client.get_user()
|
|
292
|
+
return {"message": "Not logged in"}
|
|
293
|
+
"""
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Data models for FastAPI session management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import SecretStr
|
|
9
|
+
|
|
10
|
+
from ..utils import decode_jwt_body
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class TokenSet:
|
|
15
|
+
"""Immutable token pair with user ID.
|
|
16
|
+
|
|
17
|
+
This represents either principal or delegated tokens stored in the session.
|
|
18
|
+
The frozen=True ensures tokens can't be accidentally modified.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
access_token: str
|
|
22
|
+
refresh_token: str
|
|
23
|
+
user_id: str
|
|
24
|
+
|
|
25
|
+
def to_dict(self) -> dict[str, str]:
|
|
26
|
+
"""Serialize to dictionary for session storage."""
|
|
27
|
+
return {
|
|
28
|
+
"access_token": self.access_token,
|
|
29
|
+
"refresh_token": self.refresh_token,
|
|
30
|
+
"user_id": self.user_id,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def from_dict(cls, data: dict[str, Any]) -> TokenSet:
|
|
35
|
+
"""Deserialize from dictionary."""
|
|
36
|
+
return cls(
|
|
37
|
+
access_token=data["access_token"],
|
|
38
|
+
refresh_token=data["refresh_token"],
|
|
39
|
+
user_id=data["user_id"],
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(slots=True)
|
|
44
|
+
class SessionData:
|
|
45
|
+
"""Type-safe wrapper for session data.
|
|
46
|
+
|
|
47
|
+
Handles both the new format (with principal/delegated) and legacy format
|
|
48
|
+
(flat access_token/refresh_token/user_id) for backwards compatibility.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
principal: TokenSet
|
|
52
|
+
delegated: TokenSet | None = None
|
|
53
|
+
|
|
54
|
+
def to_dict(self) -> dict[str, Any]:
|
|
55
|
+
"""Serialize to dictionary for cookie storage."""
|
|
56
|
+
data: dict[str, Any] = {"principal": self.principal.to_dict()}
|
|
57
|
+
if self.delegated:
|
|
58
|
+
data["delegated"] = self.delegated.to_dict()
|
|
59
|
+
return data
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def from_dict(cls, data: dict[str, Any]) -> SessionData:
|
|
63
|
+
"""Deserialize from dictionary.
|
|
64
|
+
|
|
65
|
+
Handles both new format and legacy format for backwards compatibility.
|
|
66
|
+
"""
|
|
67
|
+
# New format: has "principal" key
|
|
68
|
+
if "principal" in data:
|
|
69
|
+
return cls(
|
|
70
|
+
principal=TokenSet.from_dict(data["principal"]),
|
|
71
|
+
delegated=TokenSet.from_dict(data["delegated"]) if data.get("delegated") else None,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Legacy format: flat structure with access_token, refresh_token, user_id
|
|
75
|
+
# Migrate to new format by treating as principal
|
|
76
|
+
return cls(
|
|
77
|
+
principal=TokenSet(
|
|
78
|
+
access_token=data["access_token"],
|
|
79
|
+
refresh_token=data["refresh_token"],
|
|
80
|
+
user_id=data["user_id"],
|
|
81
|
+
),
|
|
82
|
+
delegated=None,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def extract_user_id(jwt_token: str | SecretStr) -> str:
|
|
87
|
+
"""Extract user ID ('sub' claim) from a JWT token.
|
|
88
|
+
|
|
89
|
+
This does not validate the signature - the token was already validated
|
|
90
|
+
by the API when it was issued.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
jwt_token: The JWT access token (str or SecretStr).
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
The user ID from the token's 'sub' claim.
|
|
97
|
+
|
|
98
|
+
Raises:
|
|
99
|
+
ValueError: If the token is malformed or missing the 'sub' claim.
|
|
100
|
+
"""
|
|
101
|
+
try:
|
|
102
|
+
token_str = jwt_token.get_secret_value() if isinstance(jwt_token, SecretStr) else jwt_token
|
|
103
|
+
payload = decode_jwt_body(token_str)
|
|
104
|
+
user_id = payload.get("sub")
|
|
105
|
+
if not user_id:
|
|
106
|
+
raise ValueError("Token missing 'sub' claim")
|
|
107
|
+
return user_id
|
|
108
|
+
except (IndexError, KeyError) as e:
|
|
109
|
+
raise ValueError(f"Malformed JWT token: {e}") from e
|
sweatstack/fastapi/routes.py
CHANGED
|
@@ -1,25 +1,33 @@
|
|
|
1
1
|
"""OAuth routes for the FastAPI plugin."""
|
|
2
2
|
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
3
5
|
import base64
|
|
4
6
|
import json
|
|
7
|
+
import logging
|
|
5
8
|
import secrets
|
|
6
|
-
from urllib.parse import urlencode
|
|
9
|
+
from urllib.parse import urlencode, urlparse
|
|
7
10
|
|
|
8
11
|
import httpx
|
|
9
|
-
from fastapi import APIRouter, FastAPI, Request, Response
|
|
12
|
+
from fastapi import APIRouter, FastAPI, HTTPException, Request, Response
|
|
10
13
|
from fastapi.responses import RedirectResponse
|
|
11
14
|
|
|
12
15
|
from ..constants import DEFAULT_URL
|
|
13
16
|
from ..utils import decode_jwt_body
|
|
14
17
|
from .config import get_config
|
|
18
|
+
from .models import SessionData, TokenSet
|
|
15
19
|
from .session import (
|
|
20
|
+
SESSION_COOKIE_NAME,
|
|
16
21
|
STATE_COOKIE_NAME,
|
|
17
22
|
clear_session_cookie,
|
|
18
23
|
clear_state_cookie,
|
|
24
|
+
decrypt_session,
|
|
19
25
|
set_session_cookie,
|
|
20
26
|
set_state_cookie,
|
|
21
27
|
)
|
|
22
28
|
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
23
31
|
|
|
24
32
|
def validate_redirect(url: str | None) -> str | None:
|
|
25
33
|
"""Validate that a redirect URL is a safe relative path.
|
|
@@ -31,6 +39,82 @@ def validate_redirect(url: str | None) -> str | None:
|
|
|
31
39
|
return None
|
|
32
40
|
|
|
33
41
|
|
|
42
|
+
def _is_same_origin(referer: str | None, app_url: str) -> bool:
|
|
43
|
+
"""Check if a referer URL is from the same origin as the app."""
|
|
44
|
+
if not referer:
|
|
45
|
+
return False
|
|
46
|
+
try:
|
|
47
|
+
ref_parsed = urlparse(referer)
|
|
48
|
+
app_parsed = urlparse(app_url)
|
|
49
|
+
return (
|
|
50
|
+
ref_parsed.scheme == app_parsed.scheme
|
|
51
|
+
and ref_parsed.netloc == app_parsed.netloc
|
|
52
|
+
)
|
|
53
|
+
except Exception:
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _get_redirect_url(request: Request, next_param: str | None) -> str:
|
|
58
|
+
"""Determine the redirect URL after a user selection change.
|
|
59
|
+
|
|
60
|
+
Priority: ?next= parameter > Referer header (if same-origin) > /
|
|
61
|
+
"""
|
|
62
|
+
# First try the explicit next parameter
|
|
63
|
+
if validated := validate_redirect(next_param):
|
|
64
|
+
return validated
|
|
65
|
+
|
|
66
|
+
# Then try the Referer header if same-origin
|
|
67
|
+
config = get_config()
|
|
68
|
+
referer = request.headers.get("referer")
|
|
69
|
+
if _is_same_origin(referer, config.app_url):
|
|
70
|
+
# Extract just the path from referer
|
|
71
|
+
parsed = urlparse(referer)
|
|
72
|
+
path = parsed.path
|
|
73
|
+
if parsed.query:
|
|
74
|
+
path += f"?{parsed.query}"
|
|
75
|
+
if validated := validate_redirect(path):
|
|
76
|
+
return validated
|
|
77
|
+
|
|
78
|
+
# Default to root
|
|
79
|
+
return "/"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _get_session_data(request: Request) -> SessionData | None:
|
|
83
|
+
"""Get session data from request cookie."""
|
|
84
|
+
raw_session = decrypt_session(request.cookies.get(SESSION_COOKIE_NAME))
|
|
85
|
+
if not raw_session:
|
|
86
|
+
return None
|
|
87
|
+
try:
|
|
88
|
+
return SessionData.from_dict(raw_session)
|
|
89
|
+
except (KeyError, TypeError):
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _fetch_delegated_token(principal_tokens: TokenSet, target_user_id: str) -> TokenSet:
|
|
94
|
+
"""Fetch a delegated token for the target user using principal credentials."""
|
|
95
|
+
config = get_config()
|
|
96
|
+
|
|
97
|
+
response = httpx.post(
|
|
98
|
+
f"{DEFAULT_URL}/api/v1/oauth/delegated-token",
|
|
99
|
+
headers={"Authorization": f"Bearer {principal_tokens.access_token}"},
|
|
100
|
+
json={"sub": target_user_id},
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
if response.status_code == 403:
|
|
104
|
+
raise HTTPException(status_code=403, detail="You don't have access to this user")
|
|
105
|
+
if response.status_code == 404:
|
|
106
|
+
raise HTTPException(status_code=404, detail="User not found")
|
|
107
|
+
|
|
108
|
+
response.raise_for_status()
|
|
109
|
+
tokens = response.json()
|
|
110
|
+
|
|
111
|
+
return TokenSet(
|
|
112
|
+
access_token=tokens["access_token"],
|
|
113
|
+
refresh_token=tokens["refresh_token"],
|
|
114
|
+
user_id=target_user_id,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
34
118
|
def create_state(next_url: str | None) -> str:
|
|
35
119
|
"""Create an OAuth state value with nonce and optional redirect."""
|
|
36
120
|
nonce = secrets.token_urlsafe(32)
|
|
@@ -160,6 +244,57 @@ def create_router() -> APIRouter:
|
|
|
160
244
|
clear_session_cookie(response)
|
|
161
245
|
return response
|
|
162
246
|
|
|
247
|
+
@router.post("/select-user/{user_id}")
|
|
248
|
+
def select_user(request: Request, user_id: str, next: str | None = None) -> Response:
|
|
249
|
+
"""Switch to viewing as another user.
|
|
250
|
+
|
|
251
|
+
Fetches a delegated token for the target user and stores it in the session.
|
|
252
|
+
Redirects to Referer (if same-origin), ?next= parameter, or /.
|
|
253
|
+
"""
|
|
254
|
+
session = _get_session_data(request)
|
|
255
|
+
if not session:
|
|
256
|
+
raise HTTPException(status_code=401, detail="Not authenticated")
|
|
257
|
+
|
|
258
|
+
# Fetch delegated token for the target user
|
|
259
|
+
try:
|
|
260
|
+
delegated_tokens = _fetch_delegated_token(session.principal, user_id)
|
|
261
|
+
except httpx.HTTPStatusError as e:
|
|
262
|
+
logger.warning("Failed to fetch delegated token for user %s: %s", user_id, e)
|
|
263
|
+
raise HTTPException(status_code=403, detail="You don't have access to this user")
|
|
264
|
+
|
|
265
|
+
# Update session with delegated tokens
|
|
266
|
+
updated_session = SessionData(
|
|
267
|
+
principal=session.principal,
|
|
268
|
+
delegated=delegated_tokens,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
redirect_url = _get_redirect_url(request, next)
|
|
272
|
+
response = RedirectResponse(url=redirect_url, status_code=303)
|
|
273
|
+
set_session_cookie(response, updated_session.to_dict())
|
|
274
|
+
return response
|
|
275
|
+
|
|
276
|
+
@router.post("/select-self")
|
|
277
|
+
def select_self(request: Request, next: str | None = None) -> Response:
|
|
278
|
+
"""Switch back to viewing as yourself (clear delegation).
|
|
279
|
+
|
|
280
|
+
Removes the delegated tokens from the session.
|
|
281
|
+
Redirects to Referer (if same-origin), ?next= parameter, or /.
|
|
282
|
+
"""
|
|
283
|
+
session = _get_session_data(request)
|
|
284
|
+
if not session:
|
|
285
|
+
raise HTTPException(status_code=401, detail="Not authenticated")
|
|
286
|
+
|
|
287
|
+
# Clear delegation
|
|
288
|
+
updated_session = SessionData(
|
|
289
|
+
principal=session.principal,
|
|
290
|
+
delegated=None,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
redirect_url = _get_redirect_url(request, next)
|
|
294
|
+
response = RedirectResponse(url=redirect_url, status_code=303)
|
|
295
|
+
set_session_cookie(response, updated_session.to_dict())
|
|
296
|
+
return response
|
|
297
|
+
|
|
163
298
|
return router
|
|
164
299
|
|
|
165
300
|
|
|
@@ -11,12 +11,13 @@ sweatstack/streamlit.py,sha256=wnabWhife9eMAdkECPjRKkzE82KZoi_H8YzucZl_m9s,19604
|
|
|
11
11
|
sweatstack/sweatshell.py,sha256=MYLNcWbOdceqKJ3S0Pe8dwHXEeYsGJNjQoYUXpMTftA,333
|
|
12
12
|
sweatstack/utils.py,sha256=AwHRdC1ziOZ5o9RBIB21Uxm-DoClVRAJSVvgsmSmvps,1801
|
|
13
13
|
sweatstack/Sweat Stack examples/Getting started.ipynb,sha256=k2hiSffWecoQ0VxjdpDcgFzBXDQiYEebhnAYlu8cgX8,6335204
|
|
14
|
-
sweatstack/fastapi/__init__.py,sha256=
|
|
15
|
-
sweatstack/fastapi/config.py,sha256=
|
|
16
|
-
sweatstack/fastapi/dependencies.py,sha256=
|
|
17
|
-
sweatstack/fastapi/
|
|
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
|
|
18
19
|
sweatstack/fastapi/session.py,sha256=BtRPCmIEaToJPwFyZ0fqWGlmnDHuWKy8nri9dJrPXaA,2717
|
|
19
|
-
sweatstack-0.
|
|
20
|
-
sweatstack-0.
|
|
21
|
-
sweatstack-0.
|
|
22
|
-
sweatstack-0.
|
|
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,,
|
|
File without changes
|
|
File without changes
|