sweatstack 0.60.0__py3-none-any.whl → 0.62.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,51 @@ 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
  )
68
+ from .models import StoredTokens, TokenStore
34
69
  from .routes import instrument
70
+ from .token_stores import EncryptedSQLiteTokenStore, SQLiteTokenStore
71
+ from .webhooks import (
72
+ WebhookError,
73
+ WebhookPayload,
74
+ WebhookPayloadModel,
75
+ WebhookTokenRefreshError,
76
+ WebhookTokenStoreError,
77
+ WebhookUserNotFoundError,
78
+ WebhookVerificationError,
79
+ verify_signature,
80
+ )
35
81
 
36
82
  __all__ = [
83
+ # Configuration
37
84
  "configure",
38
85
  "instrument",
86
+ "urls",
87
+ # User types
88
+ "SweatStackUser",
89
+ # User dependencies
39
90
  "AuthenticatedUser",
40
91
  "OptionalUser",
41
- "SweatStackUser",
42
- "urls",
92
+ "SelectedUser",
93
+ "OptionalSelectedUser",
94
+ # Webhook dependencies
95
+ "WebhookPayload",
96
+ "WebhookPayloadModel",
97
+ # Token storage
98
+ "TokenStore",
99
+ "StoredTokens",
100
+ "SQLiteTokenStore",
101
+ "EncryptedSQLiteTokenStore",
102
+ # Webhook utilities
103
+ "verify_signature",
104
+ # Exceptions
105
+ "WebhookError",
106
+ "WebhookVerificationError",
107
+ "WebhookTokenStoreError",
108
+ "WebhookUserNotFoundError",
109
+ "WebhookTokenRefreshError",
43
110
  ]
@@ -1,13 +1,19 @@
1
1
  """Module-level configuration for the FastAPI plugin."""
2
2
 
3
+ from __future__ import annotations
4
+
3
5
  import logging
4
6
  import os
5
- from dataclasses import dataclass
7
+ from dataclasses import dataclass, field
8
+ from typing import TYPE_CHECKING
6
9
  from urllib.parse import quote
7
10
 
8
11
  from cryptography.fernet import Fernet
9
12
  from pydantic import SecretStr
10
13
 
14
+ if TYPE_CHECKING:
15
+ from .models import TokenStore
16
+
11
17
  logger = logging.getLogger(__name__)
12
18
 
13
19
 
@@ -35,6 +41,8 @@ class FastAPIConfig:
35
41
  cookie_max_age: int
36
42
  auth_route_prefix: str
37
43
  redirect_unauthenticated: bool
44
+ webhook_secret: SecretStr | None = None
45
+ token_store: TokenStore | None = None
38
46
 
39
47
  @property
40
48
  def redirect_uri(self) -> str:
@@ -63,6 +71,8 @@ def configure(
63
71
  cookie_max_age: int = 86400,
64
72
  auth_route_prefix: str = "/auth/sweatstack",
65
73
  redirect_unauthenticated: bool = True,
74
+ webhook_secret: str | SecretStr | None = None,
75
+ token_store: TokenStore | None = None,
66
76
  ) -> None:
67
77
  """Configure the FastAPI plugin.
68
78
 
@@ -82,6 +92,10 @@ def configure(
82
92
  auth_route_prefix: URL prefix for auth routes. Defaults to "/auth/sweatstack".
83
93
  redirect_unauthenticated: If True, redirect unauthenticated requests to login
84
94
  with ?next= set to the current path. If False, return 401. Defaults to True.
95
+ webhook_secret: Secret for verifying webhook signatures. Falls back to
96
+ SWEATSTACK_WEBHOOK_SECRET env var. Required if using WebhookPayload dependency.
97
+ token_store: TokenStore implementation for persisting tokens. Required if using
98
+ AuthenticatedUser in webhook handlers.
85
99
  """
86
100
  global _config
87
101
 
@@ -90,6 +104,7 @@ def configure(
90
104
  client_secret = client_secret or os.environ.get("SWEATSTACK_CLIENT_SECRET")
91
105
  app_url = app_url or os.environ.get("APP_URL")
92
106
  session_secret = session_secret or os.environ.get("SWEATSTACK_SESSION_SECRET")
107
+ webhook_secret = webhook_secret or os.environ.get("SWEATSTACK_WEBHOOK_SECRET")
93
108
 
94
109
  # Validate required parameters
95
110
  if not client_id:
@@ -129,6 +144,9 @@ def configure(
129
144
  # Normalize prefix (strip trailing slash)
130
145
  auth_route_prefix = auth_route_prefix.rstrip("/")
131
146
 
147
+ # Convert webhook_secret to SecretStr if provided
148
+ webhook_secret_obj = _to_secret(webhook_secret) if webhook_secret else None
149
+
132
150
  _config = FastAPIConfig(
133
151
  client_id=client_id,
134
152
  client_secret=client_secret_obj,
@@ -139,6 +157,8 @@ def configure(
139
157
  cookie_max_age=cookie_max_age,
140
158
  auth_route_prefix=auth_route_prefix,
141
159
  redirect_unauthenticated=redirect_unauthenticated,
160
+ webhook_secret=webhook_secret_obj,
161
+ token_store=token_store,
142
162
  )
143
163
 
144
164
 
@@ -156,7 +176,20 @@ def get_config() -> FastAPIConfig:
156
176
 
157
177
 
158
178
  class _Urls:
159
- """URL helpers for the FastAPI plugin."""
179
+ """URL helpers for the FastAPI plugin.
180
+
181
+ Provides methods to generate URLs for authentication and user selection routes.
182
+ These URLs can be used in templates or redirects.
183
+
184
+ Example:
185
+ from sweatstack.fastapi import urls
186
+
187
+ # In a template:
188
+ <a href="{{ urls.login() }}">Login</a>
189
+ <form method="post" action="{{ urls.select_user(athlete.id) }}">
190
+ <button>View as {{ athlete.name }}</button>
191
+ </form>
192
+ """
160
193
 
161
194
  def login(self, next: str | None = None) -> str:
162
195
  """Get the login URL.
@@ -173,5 +206,38 @@ class _Urls:
173
206
  """Get the logout URL."""
174
207
  return f"{get_config().auth_route_prefix}/logout"
175
208
 
209
+ def select_user(self, user_id: str, next: str | None = None) -> str:
210
+ """Get the URL to switch to viewing as another user.
211
+
212
+ Args:
213
+ user_id: The ID of the user to view as.
214
+ next: Optional path to redirect to after switching.
215
+
216
+ Example:
217
+ <form method="post" action="{{ urls.select_user(athlete.id) }}">
218
+ <button>View as {{ athlete.name }}</button>
219
+ </form>
220
+ """
221
+ base = f"{get_config().auth_route_prefix}/select-user/{user_id}"
222
+ if next:
223
+ return f"{base}?next={quote(next)}"
224
+ return base
225
+
226
+ def select_self(self, next: str | None = None) -> str:
227
+ """Get the URL to switch back to viewing as yourself.
228
+
229
+ Args:
230
+ next: Optional path to redirect to after switching.
231
+
232
+ Example:
233
+ <form method="post" action="{{ urls.select_self() }}">
234
+ <button>Back to my view</button>
235
+ </form>
236
+ """
237
+ base = f"{get_config().auth_route_prefix}/select-self"
238
+ if next:
239
+ return f"{base}?next={quote(next)}"
240
+ return base
241
+
176
242
 
177
243
  urls = _Urls()
@@ -1,9 +1,12 @@
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 datetime import datetime, timezone
9
+ from typing import Annotated, NoReturn
7
10
  from urllib.parse import quote
8
11
 
9
12
  import httpx
@@ -13,6 +16,7 @@ from ..client import Client
13
16
  from ..constants import DEFAULT_URL
14
17
  from ..utils import decode_jwt_body
15
18
  from .config import get_config
19
+ from .models import SessionData, StoredTokens, TokenSet, extract_user_id
16
20
  from .session import (
17
21
  SESSION_COOKIE_NAME,
18
22
  clear_session_cookie,
@@ -20,33 +24,54 @@ from .session import (
20
24
  set_session_cookie,
21
25
  )
22
26
 
27
+ logger = logging.getLogger(__name__)
28
+
23
29
  TOKEN_EXPIRY_MARGIN = 5 # seconds
24
30
 
25
31
 
26
- @dataclass
32
+ @dataclass(slots=True)
27
33
  class SweatStackUser:
28
34
  """Authenticated SweatStack user.
29
35
 
30
36
  Attributes:
31
- user_id: The user's SweatStack ID.
32
37
  client: An authenticated Client instance for API calls.
33
38
  """
34
39
 
35
- user_id: str
36
40
  client: Client
37
41
 
42
+ @property
43
+ def user_id(self) -> str:
44
+ """The user ID this client acts as."""
45
+ return extract_user_id(self.client.api_key)
46
+
38
47
 
39
- def is_token_expiring(token: str) -> bool:
48
+ # ---------------------------------------------------------------------------
49
+ # Token refresh
50
+ # ---------------------------------------------------------------------------
51
+
52
+
53
+ def _is_token_expiring(token: str) -> bool:
40
54
  """Check if a token is within TOKEN_EXPIRY_MARGIN seconds of expiring."""
41
55
  try:
42
56
  body = decode_jwt_body(token)
43
57
  return body["exp"] - TOKEN_EXPIRY_MARGIN < time.time()
44
58
  except Exception:
45
- # If we can't decode, assume it's expired
46
59
  return True
47
60
 
48
61
 
49
- def refresh_access_token(
62
+ def _extract_expiry(access_token: str) -> datetime:
63
+ """Extract expiry time from JWT access token."""
64
+ body = decode_jwt_body(access_token)
65
+ return datetime.fromtimestamp(body["exp"], tz=timezone.utc)
66
+
67
+
68
+ def _extract_timezone(access_token: str) -> str:
69
+ """Extract timezone from JWT access token."""
70
+ body = decode_jwt_body(access_token)
71
+ return body.get("tz", "UTC")
72
+
73
+
74
+ def _refresh_access_token(
50
75
  refresh_token: str,
51
76
  client_id: str,
52
77
  client_secret: str,
@@ -67,12 +92,39 @@ def refresh_access_token(
67
92
  return response.json()["access_token"]
68
93
 
69
94
 
70
- def _raise_unauthenticated(request: Request) -> NoReturn:
71
- """Raise appropriate exception for unauthenticated requests.
95
+ def _refresh_tokens_if_needed(tokens: TokenSet) -> TokenSet | None:
96
+ """Refresh tokens if the access token is expiring.
72
97
 
73
- If redirect_unauthenticated is True, redirects to login with ?next= set.
74
- Otherwise, raises 401 Unauthorized.
98
+ Returns new TokenSet if refreshed, None if no refresh needed.
75
99
  """
100
+ if not _is_token_expiring(tokens.access_token):
101
+ return None
102
+
103
+ token_body = decode_jwt_body(tokens.access_token)
104
+ tz = token_body.get("tz", "UTC")
105
+
106
+ config = get_config()
107
+ new_access_token = _refresh_access_token(
108
+ refresh_token=tokens.refresh_token,
109
+ client_id=config.client_id,
110
+ client_secret=config.client_secret.get_secret_value(),
111
+ tz=tz,
112
+ )
113
+
114
+ return TokenSet(
115
+ access_token=new_access_token,
116
+ refresh_token=tokens.refresh_token,
117
+ user_id=tokens.user_id,
118
+ )
119
+
120
+
121
+ # ---------------------------------------------------------------------------
122
+ # Session helpers
123
+ # ---------------------------------------------------------------------------
124
+
125
+
126
+ def _raise_unauthenticated(request: Request) -> NoReturn:
127
+ """Raise appropriate exception for unauthenticated requests."""
76
128
  config = get_config()
77
129
  if config.redirect_unauthenticated:
78
130
  next_url = request.url.path
@@ -83,79 +135,272 @@ def _raise_unauthenticated(request: Request) -> NoReturn:
83
135
  raise HTTPException(status_code=401, detail="Not authenticated")
84
136
 
85
137
 
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
138
+ def _get_session_or_raise(request: Request) -> SessionData:
139
+ """Get and validate session data, raising if invalid."""
140
+ raw_session = decrypt_session(request.cookies.get(SESSION_COOKIE_NAME))
141
+ if not raw_session:
142
+ _raise_unauthenticated(request)
93
143
 
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:
144
+ try:
145
+ return SessionData.from_dict(raw_session)
146
+ except (KeyError, TypeError):
98
147
  _raise_unauthenticated(request)
99
148
 
100
- access_token = session.get("access_token")
101
- refresh_token = session.get("refresh_token")
102
- user_id = session.get("user_id")
103
149
 
104
- if not access_token or not user_id:
105
- clear_session_cookie(response)
106
- _raise_unauthenticated(request)
150
+ def _get_session_or_none(request: Request) -> SessionData | None:
151
+ """Get session data if present and valid, None otherwise."""
152
+ raw_session = decrypt_session(request.cookies.get(SESSION_COOKIE_NAME))
153
+ if not raw_session:
154
+ return None
107
155
 
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)
156
+ try:
157
+ return SessionData.from_dict(raw_session)
158
+ except (KeyError, TypeError):
159
+ return None
113
160
 
114
- try:
115
- # Extract timezone from current token
116
- token_body = decode_jwt_body(access_token)
117
- tz = token_body.get("tz", "UTC")
118
161
 
119
- config = get_config()
120
- new_access_token = refresh_access_token(
121
- refresh_token=refresh_token,
162
+ # ---------------------------------------------------------------------------
163
+ # Webhook user loading
164
+ # ---------------------------------------------------------------------------
165
+
166
+
167
+ def _load_user_from_store(user_id: str) -> SweatStackUser:
168
+ """Load user from TokenStore for webhook context.
169
+
170
+ Args:
171
+ user_id: The user ID from the webhook payload.
172
+
173
+ Returns:
174
+ SweatStackUser with tokens loaded from the store.
175
+
176
+ Raises:
177
+ WebhookTokenStoreError: If token_store is not configured.
178
+ WebhookUserNotFoundError: If no tokens exist for the user.
179
+ WebhookTokenRefreshError: If token refresh fails.
180
+ """
181
+ # Import here to avoid circular imports
182
+ from .webhooks import (
183
+ WebhookTokenRefreshError,
184
+ WebhookTokenStoreError,
185
+ WebhookUserNotFoundError,
186
+ )
187
+
188
+ config = get_config()
189
+
190
+ if not config.token_store:
191
+ raise WebhookTokenStoreError(
192
+ "TokenStore required when using AuthenticatedUser in webhook handlers. "
193
+ "Configure with: configure(token_store=...)"
194
+ )
195
+
196
+ tokens = config.token_store.load(user_id)
197
+ if not tokens:
198
+ raise WebhookUserNotFoundError(
199
+ f"No stored tokens for user {user_id}. "
200
+ "User may not have authenticated with your app yet."
201
+ )
202
+
203
+ # Check if tokens need refresh
204
+ if _is_token_expiring(tokens.access_token):
205
+ try:
206
+ new_access_token = _refresh_access_token(
207
+ refresh_token=tokens.refresh_token,
122
208
  client_id=config.client_id,
123
- client_secret=config.client_secret,
124
- tz=tz,
209
+ client_secret=config.client_secret.get_secret_value(),
210
+ tz=_extract_timezone(tokens.access_token),
211
+ )
212
+
213
+ tokens = StoredTokens(
214
+ user_id=tokens.user_id,
215
+ access_token=new_access_token,
216
+ refresh_token=tokens.refresh_token,
217
+ expires_at=_extract_expiry(new_access_token),
125
218
  )
126
219
 
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
220
+ config.token_store.save(tokens)
221
+
222
+ except Exception as e:
223
+ raise WebhookTokenRefreshError(
224
+ f"Failed to refresh tokens for user {user_id}: {e}"
225
+ ) from e
131
226
 
132
- except Exception:
133
- logging.exception("Token refresh failed for user %s", user_id)
134
- clear_session_cookie(response)
135
- _raise_unauthenticated(request)
227
+ return SweatStackUser(
228
+ client=Client(
229
+ api_key=tokens.access_token,
230
+ refresh_token=tokens.refresh_token,
231
+ client_id=config.client_id,
232
+ client_secret=config.client_secret,
233
+ )
234
+ )
136
235
 
236
+
237
+ # ---------------------------------------------------------------------------
238
+ # Core dependency logic
239
+ # ---------------------------------------------------------------------------
240
+
241
+
242
+ def _create_user(
243
+ session: SessionData,
244
+ response: Response,
245
+ *,
246
+ use_delegated: bool,
247
+ ) -> SweatStackUser:
248
+ """Create user from session, refreshing tokens if needed."""
137
249
  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
250
+
251
+ # Select which tokens to use
252
+ if use_delegated and session.delegated:
253
+ tokens = session.delegated
254
+ is_delegated = True
255
+ else:
256
+ tokens = session.principal
257
+ is_delegated = False
258
+
259
+ # Refresh tokens if needed and persist immediately
260
+ try:
261
+ refreshed = _refresh_tokens_if_needed(tokens)
262
+ except Exception:
263
+ logger.exception("Token refresh failed for user %s", tokens.user_id)
264
+ clear_session_cookie(response)
265
+ raise HTTPException(status_code=401, detail="Session expired")
266
+
267
+ if refreshed:
268
+ # Update session with refreshed tokens
269
+ if is_delegated:
270
+ session = SessionData(principal=session.principal, delegated=refreshed)
271
+ else:
272
+ session = SessionData(principal=refreshed, delegated=session.delegated)
273
+ # Persist refreshed principal tokens to store if configured
274
+ if config.token_store:
275
+ config.token_store.save(
276
+ StoredTokens(
277
+ user_id=refreshed.user_id,
278
+ access_token=refreshed.access_token,
279
+ refresh_token=refreshed.refresh_token,
280
+ expires_at=_extract_expiry(refreshed.access_token),
281
+ )
282
+ )
283
+ tokens = refreshed
284
+ set_session_cookie(response, session.to_dict())
285
+
286
+ return SweatStackUser(
287
+ client=Client(
288
+ api_key=tokens.access_token,
289
+ refresh_token=tokens.refresh_token,
290
+ client_id=config.client_id,
291
+ client_secret=config.client_secret,
292
+ )
143
293
  )
144
- return SweatStackUser(user_id=user_id, client=client)
145
294
 
146
295
 
147
- def optional_user(request: Request, response: Response) -> SweatStackUser | None:
148
- """Dependency that optionally returns an authenticated user.
296
+ # ---------------------------------------------------------------------------
297
+ # Dependency functions
298
+ # ---------------------------------------------------------------------------
299
+
149
300
 
150
- Returns a SweatStackUser if the session is valid, None otherwise.
151
- Does not raise exceptions for missing or invalid sessions.
301
+ async def _require_authenticated_user(
302
+ request: Request,
303
+ response: Response,
304
+ ) -> SweatStackUser:
305
+ """Dependency: always returns principal user.
306
+
307
+ In webhook context (detected by X-Sweatstack-Signature header),
308
+ loads the user from TokenStore instead of session cookie.
152
309
  """
310
+ # Import here to avoid circular imports
311
+ from .webhooks import WebhookPayloadModel, _detect_webhook_context
312
+
313
+ # Check if this is a webhook request
314
+ webhook_context: WebhookPayloadModel | None = await _detect_webhook_context(request)
315
+
316
+ if webhook_context:
317
+ # Webhook context: load from TokenStore
318
+ return _load_user_from_store(webhook_context.user_id)
319
+
320
+ # Browser context: load from cookie
321
+ session = _get_session_or_raise(request)
322
+ return _create_user(session, response, use_delegated=False)
323
+
324
+
325
+ def _require_selected_user(
326
+ request: Request,
327
+ response: Response,
328
+ ) -> SweatStackUser:
329
+ """Dependency: returns delegated user if selected, otherwise principal."""
330
+ session = _get_session_or_raise(request)
331
+ return _create_user(session, response, use_delegated=True)
332
+
333
+
334
+ def _optional_authenticated_user(
335
+ request: Request,
336
+ response: Response,
337
+ ) -> SweatStackUser | None:
338
+ """Dependency: returns principal user or None."""
339
+ session = _get_session_or_none(request)
340
+ if not session:
341
+ return None
153
342
  try:
154
- return require_user(request, response)
343
+ return _create_user(session, response, use_delegated=False)
155
344
  except HTTPException:
156
345
  return None
157
346
 
158
347
 
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)]
348
+ def _optional_selected_user(
349
+ request: Request,
350
+ response: Response,
351
+ ) -> SweatStackUser | None:
352
+ """Dependency: returns selected user or None."""
353
+ session = _get_session_or_none(request)
354
+ if not session:
355
+ return None
356
+ try:
357
+ return _create_user(session, response, use_delegated=True)
358
+ except HTTPException:
359
+ return None
360
+
361
+
362
+ # ---------------------------------------------------------------------------
363
+ # Public type aliases
364
+ # ---------------------------------------------------------------------------
365
+
366
+ AuthenticatedUser = Annotated[SweatStackUser, Depends(_require_authenticated_user)]
367
+ """Dependency that always returns the principal (logged-in) user.
368
+
369
+ Example:
370
+ @app.get("/my-athletes")
371
+ def get_athletes(user: AuthenticatedUser):
372
+ return user.client.get_users()
373
+ """
374
+
375
+ SelectedUser = Annotated[SweatStackUser, Depends(_require_selected_user)]
376
+ """Dependency that returns the currently selected user.
377
+
378
+ Returns the delegated user if one is selected, otherwise the principal user.
379
+
380
+ Example:
381
+ @app.get("/activities")
382
+ def get_activities(user: SelectedUser):
383
+ return user.client.get_activities()
384
+ """
385
+
386
+ OptionalUser = Annotated[SweatStackUser | None, Depends(_optional_authenticated_user)]
387
+ """Dependency that returns the principal user or None if not authenticated.
388
+
389
+ Example:
390
+ @app.get("/")
391
+ def home(user: OptionalUser):
392
+ if user:
393
+ return {"logged_in": True, "user_id": user.user_id}
394
+ return {"logged_in": False}
395
+ """
396
+
397
+ OptionalSelectedUser = Annotated[SweatStackUser | None, Depends(_optional_selected_user)]
398
+ """Dependency that returns the selected user or None if not authenticated.
399
+
400
+ Example:
401
+ @app.get("/public-profile")
402
+ def profile(user: OptionalSelectedUser):
403
+ if user:
404
+ return user.client.get_user()
405
+ return {"message": "Not logged in"}
406
+ """