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.
- sweatstack/fastapi/__init__.py +74 -7
- sweatstack/fastapi/config.py +68 -2
- sweatstack/fastapi/dependencies.py +309 -64
- sweatstack/fastapi/models.py +175 -0
- sweatstack/fastapi/routes.py +223 -17
- sweatstack/fastapi/token_stores.py +238 -0
- sweatstack/fastapi/webhooks.py +201 -0
- {sweatstack-0.60.0.dist-info → sweatstack-0.62.0.dist-info}/METADATA +1 -1
- {sweatstack-0.60.0.dist-info → sweatstack-0.62.0.dist-info}/RECORD +11 -8
- {sweatstack-0.60.0.dist-info → sweatstack-0.62.0.dist-info}/WHEEL +0 -0
- {sweatstack-0.60.0.dist-info → sweatstack-0.62.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,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
|
-
"
|
|
42
|
-
"
|
|
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
|
]
|
sweatstack/fastapi/config.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
71
|
-
"""
|
|
95
|
+
def _refresh_tokens_if_needed(tokens: TokenSet) -> TokenSet | None:
|
|
96
|
+
"""Refresh tokens if the access token is expiring.
|
|
72
97
|
|
|
73
|
-
|
|
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
|
|
87
|
-
"""
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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=
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
148
|
-
|
|
296
|
+
# ---------------------------------------------------------------------------
|
|
297
|
+
# Dependency functions
|
|
298
|
+
# ---------------------------------------------------------------------------
|
|
299
|
+
|
|
149
300
|
|
|
150
|
-
|
|
151
|
-
|
|
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
|
|
343
|
+
return _create_user(session, response, use_delegated=False)
|
|
155
344
|
except HTTPException:
|
|
156
345
|
return None
|
|
157
346
|
|
|
158
347
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
+
"""
|