sweatstack 0.59.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/client.py +67 -27
- sweatstack/fastapi/__init__.py +82 -0
- sweatstack/fastapi/config.py +223 -0
- sweatstack/fastapi/dependencies.py +293 -0
- sweatstack/fastapi/models.py +109 -0
- sweatstack/fastapi/routes.py +312 -0
- sweatstack/fastapi/session.py +102 -0
- {sweatstack-0.59.0.dist-info → sweatstack-0.61.0.dist-info}/METADATA +4 -1
- {sweatstack-0.59.0.dist-info → sweatstack-0.61.0.dist-info}/RECORD +11 -5
- {sweatstack-0.59.0.dist-info → sweatstack-0.61.0.dist-info}/WHEEL +0 -0
- {sweatstack-0.59.0.dist-info → sweatstack-0.61.0.dist-info}/entry_points.txt +0 -0
sweatstack/client.py
CHANGED
|
@@ -19,6 +19,8 @@ from importlib.metadata import version
|
|
|
19
19
|
from io import BytesIO
|
|
20
20
|
from pathlib import Path
|
|
21
21
|
from typing import Any, Dict, Generator, get_type_hints, List, Literal
|
|
22
|
+
|
|
23
|
+
from pydantic import SecretStr
|
|
22
24
|
from urllib.parse import parse_qs, urlparse
|
|
23
25
|
|
|
24
26
|
import httpx
|
|
@@ -86,7 +88,7 @@ class _LocalCacheMixin:
|
|
|
86
88
|
raise ValueError("Not authenticated. Please call authenticate() or login() first.")
|
|
87
89
|
|
|
88
90
|
try:
|
|
89
|
-
jwt_body = decode_jwt_body(self.api_key)
|
|
91
|
+
jwt_body = decode_jwt_body(self.api_key.get_secret_value())
|
|
90
92
|
user_id = jwt_body.get("sub")
|
|
91
93
|
if not user_id:
|
|
92
94
|
raise ValueError("Unable to extract user ID from token")
|
|
@@ -208,6 +210,15 @@ except ImportError:
|
|
|
208
210
|
__version__ = "unknown"
|
|
209
211
|
|
|
210
212
|
|
|
213
|
+
def _to_secret(value: str | SecretStr | None) -> SecretStr | None:
|
|
214
|
+
"""Convert a string to SecretStr, or return None if value is None."""
|
|
215
|
+
if value is None:
|
|
216
|
+
return None
|
|
217
|
+
if isinstance(value, SecretStr):
|
|
218
|
+
return value
|
|
219
|
+
return SecretStr(value)
|
|
220
|
+
|
|
221
|
+
|
|
211
222
|
class _OAuth2Mixin:
|
|
212
223
|
"""OAuth2 authentication methods for the Client class."""
|
|
213
224
|
|
|
@@ -317,7 +328,6 @@ class _OAuth2Mixin:
|
|
|
317
328
|
token_response = TokenResponse.model_validate(response.json())
|
|
318
329
|
|
|
319
330
|
self.api_key = token_response.access_token
|
|
320
|
-
self.jwt = token_response.access_token # For backward compatibility
|
|
321
331
|
self.refresh_token = token_response.refresh_token
|
|
322
332
|
|
|
323
333
|
if persist:
|
|
@@ -400,13 +410,12 @@ class _OAuth2Mixin:
|
|
|
400
410
|
|
|
401
411
|
if hasattr(server, "code"):
|
|
402
412
|
try:
|
|
403
|
-
|
|
413
|
+
self.exchange_code_for_token(
|
|
404
414
|
code=server.code,
|
|
405
415
|
client_id=OAUTH2_CLIENT_ID,
|
|
406
416
|
code_verifier=code_verifier,
|
|
407
417
|
persist=persist_api_key,
|
|
408
418
|
)
|
|
409
|
-
self.jwt = token_response.access_token
|
|
410
419
|
print("SweatStack Python login successful.")
|
|
411
420
|
except Exception as e:
|
|
412
421
|
raise Exception("SweatStack Python login failed. Please try again.") from e
|
|
@@ -665,12 +674,12 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
|
|
|
665
674
|
|
|
666
675
|
def __init__(
|
|
667
676
|
self,
|
|
668
|
-
api_key: str | None = None,
|
|
669
|
-
refresh_token: str | None = None,
|
|
677
|
+
api_key: str | SecretStr | None = None,
|
|
678
|
+
refresh_token: str | SecretStr | None = None,
|
|
670
679
|
url: str | None = None,
|
|
671
680
|
streamlit_compatible: bool = False,
|
|
672
681
|
client_id: str | None = None,
|
|
673
|
-
client_secret: str | None = None,
|
|
682
|
+
client_secret: str | SecretStr | None = None,
|
|
674
683
|
):
|
|
675
684
|
"""Initialize a SweatStack client.
|
|
676
685
|
|
|
@@ -679,16 +688,18 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
|
|
|
679
688
|
refresh_token: Optional refresh token for automatic token renewal.
|
|
680
689
|
url: Optional SweatStack instance URL. Defaults to production.
|
|
681
690
|
streamlit_compatible: Set to True when using in Streamlit apps.
|
|
691
|
+
client_id: Optional OAuth client ID. Defaults to the public client ID.
|
|
692
|
+
client_secret: Optional OAuth client secret for confidential clients.
|
|
682
693
|
"""
|
|
683
|
-
self.
|
|
684
|
-
self.
|
|
694
|
+
self._api_key: SecretStr | None = _to_secret(api_key)
|
|
695
|
+
self._refresh_token: SecretStr | None = _to_secret(refresh_token)
|
|
696
|
+
self._client_secret: SecretStr | None = _to_secret(client_secret)
|
|
685
697
|
self.url = url
|
|
686
698
|
self.streamlit_compatible = streamlit_compatible
|
|
687
699
|
self.client_id = client_id or OAUTH2_CLIENT_ID
|
|
688
|
-
self.client_secret = client_secret
|
|
689
700
|
|
|
690
701
|
def _do_token_refresh(self, tz: str) -> str:
|
|
691
|
-
refresh_token = self.
|
|
702
|
+
refresh_token = self._refresh_token
|
|
692
703
|
if refresh_token is None:
|
|
693
704
|
raise ValueError(
|
|
694
705
|
"Cannot refresh token: no refresh_token available. "
|
|
@@ -700,10 +711,10 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
|
|
|
700
711
|
"/api/v1/oauth/token",
|
|
701
712
|
data={
|
|
702
713
|
"grant_type": "refresh_token",
|
|
703
|
-
"refresh_token": refresh_token,
|
|
714
|
+
"refresh_token": refresh_token.get_secret_value(),
|
|
704
715
|
"tz": tz,
|
|
705
716
|
"client_id": self.client_id,
|
|
706
|
-
"client_secret": self.
|
|
717
|
+
"client_secret": self._client_secret.get_secret_value() if self._client_secret else None,
|
|
707
718
|
},
|
|
708
719
|
)
|
|
709
720
|
self._raise_for_status(response)
|
|
@@ -717,7 +728,7 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
|
|
|
717
728
|
if body["exp"] - TOKEN_EXPIRY_MARGIN < time.time():
|
|
718
729
|
# Token is (almost) expired, refresh it
|
|
719
730
|
token = self._do_token_refresh(body["tz"])
|
|
720
|
-
self._api_key = token
|
|
731
|
+
self._api_key = SecretStr(token)
|
|
721
732
|
except Exception as exception:
|
|
722
733
|
logging.warning("Exception checking token expiry: %s", exception)
|
|
723
734
|
# If token can't be decoded, just return as-is
|
|
@@ -727,14 +738,17 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
|
|
|
727
738
|
return token
|
|
728
739
|
|
|
729
740
|
@property
|
|
730
|
-
def api_key(self) ->
|
|
741
|
+
def api_key(self) -> SecretStr | None:
|
|
731
742
|
"""The current API access token.
|
|
732
743
|
|
|
733
744
|
Automatically loads from instance, environment (SWEATSTACK_API_KEY),
|
|
734
745
|
or persistent storage. Refreshes expired tokens automatically.
|
|
746
|
+
|
|
747
|
+
Returns a SecretStr to prevent accidental logging of the token.
|
|
748
|
+
Use .get_secret_value() to get the actual token string.
|
|
735
749
|
"""
|
|
736
750
|
if self._api_key is not None:
|
|
737
|
-
value = self._api_key
|
|
751
|
+
value = self._api_key.get_secret_value()
|
|
738
752
|
elif value := os.getenv("SWEATSTACK_API_KEY"):
|
|
739
753
|
pass
|
|
740
754
|
else:
|
|
@@ -744,30 +758,56 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
|
|
|
744
758
|
# A non-authenticated client is a potentially valid use-case.
|
|
745
759
|
return None
|
|
746
760
|
|
|
747
|
-
|
|
761
|
+
# Check expiry and potentially refresh (returns the string value)
|
|
762
|
+
checked_value = self._check_token_expiry(value)
|
|
763
|
+
return SecretStr(checked_value)
|
|
748
764
|
|
|
749
765
|
@api_key.setter
|
|
750
|
-
def api_key(self, value: str):
|
|
751
|
-
self._api_key = value
|
|
766
|
+
def api_key(self, value: str | SecretStr | None):
|
|
767
|
+
self._api_key = _to_secret(value)
|
|
752
768
|
|
|
753
769
|
@property
|
|
754
|
-
def refresh_token(self) ->
|
|
770
|
+
def refresh_token(self) -> SecretStr | None:
|
|
755
771
|
"""The refresh token used for automatic token renewal.
|
|
756
772
|
|
|
757
773
|
Loads from instance, environment (SWEATSTACK_REFRESH_TOKEN), or persistent storage.
|
|
774
|
+
|
|
775
|
+
Returns a SecretStr to prevent accidental logging of the token.
|
|
776
|
+
Use .get_secret_value() to get the actual token string.
|
|
758
777
|
"""
|
|
759
778
|
if self._refresh_token is not None:
|
|
760
779
|
return self._refresh_token
|
|
761
780
|
elif value := os.getenv("SWEATSTACK_REFRESH_TOKEN"):
|
|
762
|
-
|
|
781
|
+
return SecretStr(value)
|
|
763
782
|
else:
|
|
764
783
|
_, value = self._load_persistent_tokens()
|
|
765
|
-
|
|
766
|
-
return value
|
|
784
|
+
return _to_secret(value)
|
|
767
785
|
|
|
768
786
|
@refresh_token.setter
|
|
769
|
-
def refresh_token(self, value: str):
|
|
770
|
-
self._refresh_token = value
|
|
787
|
+
def refresh_token(self, value: str | SecretStr | None):
|
|
788
|
+
self._refresh_token = _to_secret(value)
|
|
789
|
+
|
|
790
|
+
@property
|
|
791
|
+
def client_secret(self) -> SecretStr | None:
|
|
792
|
+
"""The OAuth client secret for confidential clients.
|
|
793
|
+
|
|
794
|
+
Returns a SecretStr to prevent accidental logging of the secret.
|
|
795
|
+
Use .get_secret_value() to get the actual secret string.
|
|
796
|
+
"""
|
|
797
|
+
return self._client_secret
|
|
798
|
+
|
|
799
|
+
@client_secret.setter
|
|
800
|
+
def client_secret(self, value: str | SecretStr | None):
|
|
801
|
+
self._client_secret = _to_secret(value)
|
|
802
|
+
|
|
803
|
+
@property
|
|
804
|
+
def jwt(self) -> SecretStr | None:
|
|
805
|
+
"""Alias for api_key (backward compatibility)."""
|
|
806
|
+
return self.api_key
|
|
807
|
+
|
|
808
|
+
@jwt.setter
|
|
809
|
+
def jwt(self, value: str | SecretStr | None):
|
|
810
|
+
self.api_key = value
|
|
771
811
|
|
|
772
812
|
@property
|
|
773
813
|
def url(self) -> str:
|
|
@@ -808,7 +848,7 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
|
|
|
808
848
|
token = self.api_key
|
|
809
849
|
|
|
810
850
|
if token:
|
|
811
|
-
headers["Authorization"] = f"Bearer {token}"
|
|
851
|
+
headers["Authorization"] = f"Bearer {token.get_secret_value()}"
|
|
812
852
|
|
|
813
853
|
with httpx.Client(base_url=self.url, headers=headers, timeout=60) as client:
|
|
814
854
|
yield client
|
|
@@ -1644,7 +1684,7 @@ class Client(_OAuth2Mixin, _DelegationMixin, _TokenStorageMixin, _LocalCacheMixi
|
|
|
1644
1684
|
raise ValueError("Not authenticated. Please call authenticate() or login() first.")
|
|
1645
1685
|
|
|
1646
1686
|
try:
|
|
1647
|
-
jwt_body = decode_jwt_body(self.api_key)
|
|
1687
|
+
jwt_body = decode_jwt_body(self.api_key.get_secret_value())
|
|
1648
1688
|
user_id = jwt_body.get("sub")
|
|
1649
1689
|
if not user_id:
|
|
1650
1690
|
raise ValueError("Unable to extract user ID from token")
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""FastAPI integration for SweatStack authentication.
|
|
2
|
+
|
|
3
|
+
This module provides OAuth authentication for FastAPI applications using
|
|
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.
|
|
7
|
+
|
|
8
|
+
Example:
|
|
9
|
+
from fastapi import FastAPI
|
|
10
|
+
from sweatstack.fastapi import configure, instrument, AuthenticatedUser, SelectedUser, urls
|
|
11
|
+
|
|
12
|
+
configure() # uses environment variables
|
|
13
|
+
|
|
14
|
+
app = FastAPI()
|
|
15
|
+
instrument(app)
|
|
16
|
+
|
|
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
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
import fastapi # noqa: F401
|
|
54
|
+
except ImportError:
|
|
55
|
+
raise ImportError(
|
|
56
|
+
"FastAPI is required for sweatstack.fastapi. "
|
|
57
|
+
"Install it with: pip install 'sweatstack[fastapi]'"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
from .config import configure, urls
|
|
61
|
+
from .dependencies import (
|
|
62
|
+
AuthenticatedUser,
|
|
63
|
+
OptionalSelectedUser,
|
|
64
|
+
OptionalUser,
|
|
65
|
+
SelectedUser,
|
|
66
|
+
SweatStackUser,
|
|
67
|
+
)
|
|
68
|
+
from .routes import instrument
|
|
69
|
+
|
|
70
|
+
__all__ = [
|
|
71
|
+
# Configuration
|
|
72
|
+
"configure",
|
|
73
|
+
"instrument",
|
|
74
|
+
"urls",
|
|
75
|
+
# User types
|
|
76
|
+
"SweatStackUser",
|
|
77
|
+
# Dependencies
|
|
78
|
+
"AuthenticatedUser",
|
|
79
|
+
"OptionalUser",
|
|
80
|
+
"SelectedUser",
|
|
81
|
+
"OptionalSelectedUser",
|
|
82
|
+
]
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Module-level configuration for the FastAPI plugin."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
|
|
8
|
+
from cryptography.fernet import Fernet
|
|
9
|
+
from pydantic import SecretStr
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _validate_fernet_key(key: str) -> None:
|
|
15
|
+
"""Validate that a string is a valid Fernet key."""
|
|
16
|
+
try:
|
|
17
|
+
Fernet(key.encode() if isinstance(key, str) else key)
|
|
18
|
+
except Exception:
|
|
19
|
+
raise ValueError(
|
|
20
|
+
f"Invalid session_secret. Fernet keys must be 32 url-safe base64-encoded bytes. "
|
|
21
|
+
f"Generate one with: python -c \"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\""
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class FastAPIConfig:
|
|
27
|
+
"""Internal configuration for the FastAPI plugin."""
|
|
28
|
+
|
|
29
|
+
client_id: str
|
|
30
|
+
client_secret: SecretStr
|
|
31
|
+
app_url: str
|
|
32
|
+
session_secret: SecretStr | list[SecretStr]
|
|
33
|
+
scopes: list[str]
|
|
34
|
+
cookie_secure: bool
|
|
35
|
+
cookie_max_age: int
|
|
36
|
+
auth_route_prefix: str
|
|
37
|
+
redirect_unauthenticated: bool
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def redirect_uri(self) -> str:
|
|
41
|
+
"""Construct the OAuth redirect URI from app_url and auth_route_prefix."""
|
|
42
|
+
return f"{self.app_url.rstrip('/')}{self.auth_route_prefix}/callback"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
_config: FastAPIConfig | None = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _to_secret(value: str | SecretStr) -> SecretStr:
|
|
49
|
+
"""Convert a string to SecretStr if needed."""
|
|
50
|
+
if isinstance(value, SecretStr):
|
|
51
|
+
return value
|
|
52
|
+
return SecretStr(value)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def configure(
|
|
56
|
+
*,
|
|
57
|
+
client_id: str | None = None,
|
|
58
|
+
client_secret: str | SecretStr | None = None,
|
|
59
|
+
app_url: str | None = None,
|
|
60
|
+
session_secret: str | SecretStr | list[str | SecretStr] | None = None,
|
|
61
|
+
scopes: list[str] | None = None,
|
|
62
|
+
cookie_secure: bool | None = None,
|
|
63
|
+
cookie_max_age: int = 86400,
|
|
64
|
+
auth_route_prefix: str = "/auth/sweatstack",
|
|
65
|
+
redirect_unauthenticated: bool = True,
|
|
66
|
+
) -> None:
|
|
67
|
+
"""Configure the FastAPI plugin.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
client_id: OAuth client ID. Falls back to SWEATSTACK_CLIENT_ID env var.
|
|
71
|
+
client_secret: OAuth client secret. Falls back to SWEATSTACK_CLIENT_SECRET env var.
|
|
72
|
+
app_url: Base URL of the application (e.g., "http://localhost:8000").
|
|
73
|
+
Falls back to APP_URL env var.
|
|
74
|
+
The OAuth redirect URI is derived as: app_url + auth_route_prefix + "/callback"
|
|
75
|
+
session_secret: Fernet key(s) for cookie encryption. Can be a single key
|
|
76
|
+
or a list of keys for key rotation (first encrypts, all decrypt).
|
|
77
|
+
Falls back to SWEATSTACK_SESSION_SECRET env var.
|
|
78
|
+
scopes: OAuth scopes to request. Defaults to ["profile", "data:read"].
|
|
79
|
+
cookie_secure: Whether to set the Secure flag on cookies. If not specified,
|
|
80
|
+
auto-detected from app_url (True for https, False for http).
|
|
81
|
+
cookie_max_age: Session cookie lifetime in seconds. Defaults to 86400 (24h).
|
|
82
|
+
auth_route_prefix: URL prefix for auth routes. Defaults to "/auth/sweatstack".
|
|
83
|
+
redirect_unauthenticated: If True, redirect unauthenticated requests to login
|
|
84
|
+
with ?next= set to the current path. If False, return 401. Defaults to True.
|
|
85
|
+
"""
|
|
86
|
+
global _config
|
|
87
|
+
|
|
88
|
+
# Resolve from environment variables
|
|
89
|
+
client_id = client_id or os.environ.get("SWEATSTACK_CLIENT_ID")
|
|
90
|
+
client_secret = client_secret or os.environ.get("SWEATSTACK_CLIENT_SECRET")
|
|
91
|
+
app_url = app_url or os.environ.get("APP_URL")
|
|
92
|
+
session_secret = session_secret or os.environ.get("SWEATSTACK_SESSION_SECRET")
|
|
93
|
+
|
|
94
|
+
# Validate required parameters
|
|
95
|
+
if not client_id:
|
|
96
|
+
raise ValueError("client_id is required (or set SWEATSTACK_CLIENT_ID)")
|
|
97
|
+
if not client_secret:
|
|
98
|
+
raise ValueError("client_secret is required (or set SWEATSTACK_CLIENT_SECRET)")
|
|
99
|
+
if not app_url:
|
|
100
|
+
raise ValueError("app_url is required (or set APP_URL)")
|
|
101
|
+
if not session_secret:
|
|
102
|
+
raise ValueError("session_secret is required (or set SWEATSTACK_SESSION_SECRET)")
|
|
103
|
+
|
|
104
|
+
# Auto-detect cookie_secure from app_url scheme
|
|
105
|
+
if cookie_secure is None:
|
|
106
|
+
cookie_secure = app_url.startswith("https://")
|
|
107
|
+
if not cookie_secure and "localhost" not in app_url and "127.0.0.1" not in app_url:
|
|
108
|
+
logger.warning(
|
|
109
|
+
"Using HTTP with non-localhost URL (%s) - cookies will not be secure",
|
|
110
|
+
app_url,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Validate and convert session secret(s)
|
|
114
|
+
secret_list = [session_secret] if isinstance(session_secret, (str, SecretStr)) else session_secret
|
|
115
|
+
for secret in secret_list:
|
|
116
|
+
secret_value = secret.get_secret_value() if isinstance(secret, SecretStr) else secret
|
|
117
|
+
_validate_fernet_key(secret_value)
|
|
118
|
+
|
|
119
|
+
# Convert to SecretStr
|
|
120
|
+
client_secret_obj = _to_secret(client_secret)
|
|
121
|
+
if isinstance(session_secret, (str, SecretStr)):
|
|
122
|
+
session_secret_obj: SecretStr | list[SecretStr] = _to_secret(session_secret)
|
|
123
|
+
else:
|
|
124
|
+
session_secret_obj = [_to_secret(s) for s in session_secret]
|
|
125
|
+
|
|
126
|
+
if scopes is None:
|
|
127
|
+
scopes = ["profile", "data:read"]
|
|
128
|
+
|
|
129
|
+
# Normalize prefix (strip trailing slash)
|
|
130
|
+
auth_route_prefix = auth_route_prefix.rstrip("/")
|
|
131
|
+
|
|
132
|
+
_config = FastAPIConfig(
|
|
133
|
+
client_id=client_id,
|
|
134
|
+
client_secret=client_secret_obj,
|
|
135
|
+
app_url=app_url,
|
|
136
|
+
session_secret=session_secret_obj,
|
|
137
|
+
scopes=scopes,
|
|
138
|
+
cookie_secure=cookie_secure,
|
|
139
|
+
cookie_max_age=cookie_max_age,
|
|
140
|
+
auth_route_prefix=auth_route_prefix,
|
|
141
|
+
redirect_unauthenticated=redirect_unauthenticated,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def get_config() -> FastAPIConfig:
|
|
146
|
+
"""Get the current configuration.
|
|
147
|
+
|
|
148
|
+
Raises:
|
|
149
|
+
RuntimeError: If configure() has not been called.
|
|
150
|
+
"""
|
|
151
|
+
if _config is None:
|
|
152
|
+
raise RuntimeError(
|
|
153
|
+
"configure() must be called before instrument()"
|
|
154
|
+
)
|
|
155
|
+
return _config
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class _Urls:
|
|
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
|
+
"""
|
|
173
|
+
|
|
174
|
+
def login(self, next: str | None = None) -> str:
|
|
175
|
+
"""Get the login URL.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
next: Optional path to redirect to after login.
|
|
179
|
+
"""
|
|
180
|
+
base = f"{get_config().auth_route_prefix}/login"
|
|
181
|
+
if next:
|
|
182
|
+
return f"{base}?next={quote(next)}"
|
|
183
|
+
return base
|
|
184
|
+
|
|
185
|
+
def logout(self) -> str:
|
|
186
|
+
"""Get the logout URL."""
|
|
187
|
+
return f"{get_config().auth_route_prefix}/logout"
|
|
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
|
+
|
|
222
|
+
|
|
223
|
+
urls = _Urls()
|