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.
@@ -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("/me")
16
- def get_me(user: AuthenticatedUser):
17
- return user.client.get_userinfo()
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
- "SweatStackUser",
42
- "urls",
80
+ "SelectedUser",
81
+ "OptionalSelectedUser",
43
82
  ]
@@ -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, TypeAlias
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
- def is_token_expiring(token: str) -> bool:
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 refresh_access_token(
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 _raise_unauthenticated(request: Request) -> NoReturn:
71
- """Raise appropriate exception for unauthenticated requests.
82
+ def _refresh_tokens_if_needed(tokens: TokenSet) -> TokenSet | None:
83
+ """Refresh tokens if the access token is expiring.
72
84
 
73
- If redirect_unauthenticated is True, redirects to login with ?next= set.
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 require_user(request: Request, response: Response) -> SweatStackUser:
87
- """Dependency that requires an authenticated user.
88
-
89
- Returns a SweatStackUser if the session is valid. If not authenticated,
90
- behavior depends on the redirect_unauthenticated config:
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
- Automatically refreshes tokens if they are about to expire.
95
- """
96
- session = decrypt_session(request.cookies.get(SESSION_COOKIE_NAME))
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
- if not access_token or not user_id:
105
- clear_session_cookie(response)
106
- _raise_unauthenticated(request)
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
- # Check if token needs refresh
109
- if is_token_expiring(access_token):
110
- if not refresh_token:
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
- client = Client(
139
- api_key=access_token,
140
- refresh_token=refresh_token,
141
- client_id=config.client_id,
142
- client_secret=config.client_secret, # Client accepts SecretStr directly
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
- def optional_user(request: Request, response: Response) -> SweatStackUser | None:
148
- """Dependency that optionally returns an authenticated user.
198
+ # ---------------------------------------------------------------------------
199
+ # Dependency functions
200
+ # ---------------------------------------------------------------------------
149
201
 
150
- Returns a SweatStackUser if the session is valid, None otherwise.
151
- Does not raise exceptions for missing or invalid sessions.
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 require_user(request, response)
230
+ return _create_user(session, response, use_delegated=False)
155
231
  except HTTPException:
156
232
  return None
157
233
 
158
234
 
159
- # Type aliases for use in route handlers
160
- AuthenticatedUser: TypeAlias = Annotated[SweatStackUser, Depends(require_user)]
161
- OptionalUser: TypeAlias = Annotated[SweatStackUser | None, Depends(optional_user)]
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
@@ -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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sweatstack
3
- Version: 0.60.0
3
+ Version: 0.61.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,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=4_a6oqapT8Pv0sdt6OmuTA_vo4qFSiyXjN5WMLnl0rs,971
15
- sweatstack/fastapi/config.py,sha256=9u_XXGpX2XdOJO8G2sL_Cx4L_hYUzktozS2MkIs7Fwk,6304
16
- sweatstack/fastapi/dependencies.py,sha256=K9nuSV5Vduu4hoN94B8rj6UKtUBu9uKtSwEmECDG9vM,5060
17
- sweatstack/fastapi/routes.py,sha256=ZyaowLYXlOM6a74Cog5uSzPKHNTBvOpIBruaJVzhKjE,5339
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.60.0.dist-info/METADATA,sha256=XJq9khIsRFJDUVGLB9Vv_qwRutrIHlU4QwHRsLQKdeU,994
20
- sweatstack-0.60.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
21
- sweatstack-0.60.0.dist-info/entry_points.txt,sha256=kCzOUQI3dqbTpEYqtgYDeiKFaqaA7BMlV6D24BMzCFU,208
22
- sweatstack-0.60.0.dist-info/RECORD,,
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,,