sweatstack 0.59.0__py3-none-any.whl → 0.60.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 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
- token_response = self.exchange_code_for_token(
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.api_key = api_key
684
- self.refresh_token = refresh_token
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.refresh_token
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.client_secret,
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) -> str:
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
- return self._check_token_expiry(value)
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) -> str:
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
- pass
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,43 @@
1
+ """FastAPI integration for SweatStack authentication.
2
+
3
+ This module provides OAuth authentication for FastAPI applications using
4
+ SweatStack as the identity provider.
5
+
6
+ Example:
7
+ from fastapi import FastAPI
8
+ from sweatstack.fastapi import configure, instrument, AuthenticatedUser
9
+
10
+ configure() # uses environment variables
11
+
12
+ app = FastAPI()
13
+ instrument(app)
14
+
15
+ @app.get("/me")
16
+ def get_me(user: AuthenticatedUser):
17
+ return user.client.get_userinfo()
18
+ """
19
+
20
+ try:
21
+ import fastapi # noqa: F401
22
+ except ImportError:
23
+ raise ImportError(
24
+ "FastAPI is required for sweatstack.fastapi. "
25
+ "Install it with: pip install 'sweatstack[fastapi]'"
26
+ )
27
+
28
+ from .config import configure, urls
29
+ from .dependencies import (
30
+ AuthenticatedUser,
31
+ OptionalUser,
32
+ SweatStackUser,
33
+ )
34
+ from .routes import instrument
35
+
36
+ __all__ = [
37
+ "configure",
38
+ "instrument",
39
+ "AuthenticatedUser",
40
+ "OptionalUser",
41
+ "SweatStackUser",
42
+ "urls",
43
+ ]
@@ -0,0 +1,177 @@
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
+ def login(self, next: str | None = None) -> str:
162
+ """Get the login URL.
163
+
164
+ Args:
165
+ next: Optional path to redirect to after login.
166
+ """
167
+ base = f"{get_config().auth_route_prefix}/login"
168
+ if next:
169
+ return f"{base}?next={quote(next)}"
170
+ return base
171
+
172
+ def logout(self) -> str:
173
+ """Get the logout URL."""
174
+ return f"{get_config().auth_route_prefix}/logout"
175
+
176
+
177
+ urls = _Urls()
@@ -0,0 +1,161 @@
1
+ """FastAPI dependencies for authentication."""
2
+
3
+ import logging
4
+ import time
5
+ from dataclasses import dataclass
6
+ from typing import Annotated, NoReturn, TypeAlias
7
+ from urllib.parse import quote
8
+
9
+ import httpx
10
+ from fastapi import Depends, HTTPException, Request, Response
11
+
12
+ from ..client import Client
13
+ from ..constants import DEFAULT_URL
14
+ from ..utils import decode_jwt_body
15
+ from .config import get_config
16
+ from .session import (
17
+ SESSION_COOKIE_NAME,
18
+ clear_session_cookie,
19
+ decrypt_session,
20
+ set_session_cookie,
21
+ )
22
+
23
+ TOKEN_EXPIRY_MARGIN = 5 # seconds
24
+
25
+
26
+ @dataclass
27
+ class SweatStackUser:
28
+ """Authenticated SweatStack user.
29
+
30
+ Attributes:
31
+ user_id: The user's SweatStack ID.
32
+ client: An authenticated Client instance for API calls.
33
+ """
34
+
35
+ user_id: str
36
+ client: Client
37
+
38
+
39
+ def is_token_expiring(token: str) -> bool:
40
+ """Check if a token is within TOKEN_EXPIRY_MARGIN seconds of expiring."""
41
+ try:
42
+ body = decode_jwt_body(token)
43
+ return body["exp"] - TOKEN_EXPIRY_MARGIN < time.time()
44
+ except Exception:
45
+ # If we can't decode, assume it's expired
46
+ return True
47
+
48
+
49
+ def refresh_access_token(
50
+ refresh_token: str,
51
+ client_id: str,
52
+ client_secret: str,
53
+ tz: str,
54
+ ) -> str:
55
+ """Exchange a refresh token for a new access token."""
56
+ response = httpx.post(
57
+ f"{DEFAULT_URL}/api/v1/oauth/token",
58
+ data={
59
+ "grant_type": "refresh_token",
60
+ "refresh_token": refresh_token,
61
+ "client_id": client_id,
62
+ "client_secret": client_secret,
63
+ "tz": tz,
64
+ },
65
+ )
66
+ response.raise_for_status()
67
+ return response.json()["access_token"]
68
+
69
+
70
+ def _raise_unauthenticated(request: Request) -> NoReturn:
71
+ """Raise appropriate exception for unauthenticated requests.
72
+
73
+ If redirect_unauthenticated is True, redirects to login with ?next= set.
74
+ Otherwise, raises 401 Unauthorized.
75
+ """
76
+ config = get_config()
77
+ if config.redirect_unauthenticated:
78
+ next_url = request.url.path
79
+ if request.url.query:
80
+ next_url += f"?{request.url.query}"
81
+ login_url = f"{config.auth_route_prefix}/login?next={quote(next_url)}"
82
+ raise HTTPException(status_code=303, headers={"Location": login_url})
83
+ raise HTTPException(status_code=401, detail="Not authenticated")
84
+
85
+
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
93
+
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:
98
+ _raise_unauthenticated(request)
99
+
100
+ access_token = session.get("access_token")
101
+ refresh_token = session.get("refresh_token")
102
+ user_id = session.get("user_id")
103
+
104
+ if not access_token or not user_id:
105
+ clear_session_cookie(response)
106
+ _raise_unauthenticated(request)
107
+
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)
136
+
137
+ 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
143
+ )
144
+ return SweatStackUser(user_id=user_id, client=client)
145
+
146
+
147
+ def optional_user(request: Request, response: Response) -> SweatStackUser | None:
148
+ """Dependency that optionally returns an authenticated user.
149
+
150
+ Returns a SweatStackUser if the session is valid, None otherwise.
151
+ Does not raise exceptions for missing or invalid sessions.
152
+ """
153
+ try:
154
+ return require_user(request, response)
155
+ except HTTPException:
156
+ return None
157
+
158
+
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)]
@@ -0,0 +1,177 @@
1
+ """OAuth routes for the FastAPI plugin."""
2
+
3
+ import base64
4
+ import json
5
+ import secrets
6
+ from urllib.parse import urlencode
7
+
8
+ import httpx
9
+ from fastapi import APIRouter, FastAPI, Request, Response
10
+ from fastapi.responses import RedirectResponse
11
+
12
+ from ..constants import DEFAULT_URL
13
+ from ..utils import decode_jwt_body
14
+ from .config import get_config
15
+ from .session import (
16
+ STATE_COOKIE_NAME,
17
+ clear_session_cookie,
18
+ clear_state_cookie,
19
+ set_session_cookie,
20
+ set_state_cookie,
21
+ )
22
+
23
+
24
+ def validate_redirect(url: str | None) -> str | None:
25
+ """Validate that a redirect URL is a safe relative path.
26
+
27
+ Returns the URL if valid, None otherwise.
28
+ """
29
+ if url and url.startswith("/") and not url.startswith("//"):
30
+ return url
31
+ return None
32
+
33
+
34
+ def create_state(next_url: str | None) -> str:
35
+ """Create an OAuth state value with nonce and optional redirect."""
36
+ nonce = secrets.token_urlsafe(32)
37
+ state_data = {"nonce": nonce}
38
+ if next_url:
39
+ state_data["next"] = next_url
40
+ return base64.urlsafe_b64encode(json.dumps(state_data).encode()).decode()
41
+
42
+
43
+ def parse_state(state: str) -> dict:
44
+ """Parse an OAuth state value."""
45
+ try:
46
+ return json.loads(base64.urlsafe_b64decode(state.encode()))
47
+ except Exception:
48
+ return {}
49
+
50
+
51
+ def create_router() -> APIRouter:
52
+ """Create the auth router with login, callback, and logout routes."""
53
+ router = APIRouter()
54
+
55
+ @router.get("/login")
56
+ def login(request: Request, next: str | None = None) -> Response:
57
+ """Redirect to SweatStack OAuth authorization."""
58
+ config = get_config()
59
+
60
+ # Validate and create state
61
+ validated_next = validate_redirect(next)
62
+ state = create_state(validated_next)
63
+
64
+ # Build authorization URL
65
+ params = {
66
+ "client_id": config.client_id,
67
+ "redirect_uri": config.redirect_uri,
68
+ "scope": " ".join(config.scopes),
69
+ "state": state,
70
+ "prompt": "none",
71
+ }
72
+ auth_url = f"{DEFAULT_URL}/oauth/authorize?{urlencode(params)}"
73
+
74
+ # Set state cookie and redirect
75
+ response = RedirectResponse(url=auth_url, status_code=302)
76
+ set_state_cookie(response, state)
77
+ return response
78
+
79
+ @router.get("/callback")
80
+ def callback(
81
+ request: Request,
82
+ code: str | None = None,
83
+ state: str | None = None,
84
+ error: str | None = None,
85
+ ) -> Response:
86
+ """Handle OAuth callback from SweatStack."""
87
+ config = get_config()
88
+
89
+ # Get state cookie
90
+ state_cookie = request.cookies.get(STATE_COOKIE_NAME)
91
+
92
+ # Clear state cookie regardless of outcome
93
+ response = RedirectResponse(url="/", status_code=302)
94
+ clear_state_cookie(response)
95
+
96
+ # Handle OAuth errors
97
+ if error:
98
+ return response
99
+
100
+ # Verify state (CSRF protection)
101
+ if not state or not state_cookie or state != state_cookie:
102
+ return Response(content="Invalid state", status_code=400)
103
+
104
+ # Exchange code for tokens
105
+ if not code:
106
+ return Response(content="Missing authorization code", status_code=400)
107
+
108
+ try:
109
+ token_response = httpx.post(
110
+ f"{DEFAULT_URL}/api/v1/oauth/token",
111
+ data={
112
+ "grant_type": "authorization_code",
113
+ "client_id": config.client_id,
114
+ "client_secret": config.client_secret.get_secret_value(),
115
+ "code": code,
116
+ "redirect_uri": config.redirect_uri,
117
+ },
118
+ )
119
+ token_response.raise_for_status()
120
+ tokens = token_response.json()
121
+ except Exception:
122
+ return response # Redirect to / on token exchange failure
123
+
124
+ access_token = tokens.get("access_token")
125
+ refresh_token = tokens.get("refresh_token")
126
+
127
+ if not access_token:
128
+ return response
129
+
130
+ # Extract user_id from JWT
131
+ try:
132
+ token_body = decode_jwt_body(access_token)
133
+ user_id = token_body.get("sub")
134
+ except Exception:
135
+ return response
136
+
137
+ if not user_id:
138
+ return response
139
+
140
+ # Create session
141
+ session_data = {
142
+ "access_token": access_token,
143
+ "refresh_token": refresh_token,
144
+ "user_id": user_id,
145
+ }
146
+
147
+ # Determine redirect URL from state
148
+ state_data = parse_state(state)
149
+ redirect_url = state_data.get("next", "/")
150
+
151
+ response = RedirectResponse(url=redirect_url, status_code=302)
152
+ clear_state_cookie(response)
153
+ set_session_cookie(response, session_data)
154
+ return response
155
+
156
+ @router.post("/logout")
157
+ def logout() -> Response:
158
+ """Clear session and redirect to /."""
159
+ response = RedirectResponse(url="/", status_code=302)
160
+ clear_session_cookie(response)
161
+ return response
162
+
163
+ return router
164
+
165
+
166
+ def instrument(app: FastAPI) -> None:
167
+ """Add SweatStack auth routes to a FastAPI application.
168
+
169
+ Args:
170
+ app: The FastAPI application to instrument.
171
+
172
+ Raises:
173
+ RuntimeError: If configure() has not been called.
174
+ """
175
+ config = get_config() # This will raise if not configured
176
+ router = create_router()
177
+ app.include_router(router, prefix=config.auth_route_prefix)
@@ -0,0 +1,102 @@
1
+ """Session encryption and cookie helpers."""
2
+
3
+ import json
4
+ import logging
5
+ from typing import Any
6
+
7
+ from cryptography.fernet import Fernet, InvalidToken
8
+ from fastapi import Response
9
+
10
+ from .config import get_config
11
+
12
+ SESSION_COOKIE_NAME = "sweatstack_session"
13
+ STATE_COOKIE_NAME = "sweatstack_oauth_state"
14
+
15
+
16
+ def _get_fernet_instances() -> list[Fernet]:
17
+ """Get Fernet instances for encryption/decryption."""
18
+ config = get_config()
19
+ secrets = config.session_secret
20
+ if not isinstance(secrets, list):
21
+ secrets = [secrets]
22
+ return [Fernet(secret.get_secret_value().encode()) for secret in secrets]
23
+
24
+
25
+ def encrypt_session(data: dict[str, Any]) -> str:
26
+ """Encrypt session data.
27
+
28
+ Uses the first configured key for encryption.
29
+ """
30
+ fernets = _get_fernet_instances()
31
+ json_data = json.dumps(data)
32
+ return fernets[0].encrypt(json_data.encode()).decode()
33
+
34
+
35
+ def decrypt_session(encrypted: str | None) -> dict[str, Any] | None:
36
+ """Decrypt session data.
37
+
38
+ Tries all configured keys for decryption (supports key rotation).
39
+ Returns None if decryption fails or data is missing.
40
+ """
41
+ if not encrypted:
42
+ return None
43
+
44
+ fernets = _get_fernet_instances()
45
+
46
+ for fernet in fernets:
47
+ try:
48
+ decrypted = fernet.decrypt(encrypted.encode())
49
+ return json.loads(decrypted)
50
+ except InvalidToken:
51
+ continue
52
+ except Exception as e:
53
+ logging.warning(f"Session decryption error: {e}")
54
+ return None
55
+
56
+ # All keys failed
57
+ return None
58
+
59
+
60
+ def set_session_cookie(response: Response, session_data: dict[str, Any]) -> None:
61
+ """Set the encrypted session cookie on the response."""
62
+ config = get_config()
63
+ encrypted = encrypt_session(session_data)
64
+ response.set_cookie(
65
+ key=SESSION_COOKIE_NAME,
66
+ value=encrypted,
67
+ httponly=True,
68
+ secure=config.cookie_secure,
69
+ samesite="lax",
70
+ max_age=config.cookie_max_age,
71
+ path="/",
72
+ )
73
+
74
+
75
+ def clear_session_cookie(response: Response) -> None:
76
+ """Clear the session cookie."""
77
+ response.delete_cookie(
78
+ key=SESSION_COOKIE_NAME,
79
+ path="/",
80
+ )
81
+
82
+
83
+ def set_state_cookie(response: Response, state: str) -> None:
84
+ """Set the OAuth state cookie (short-lived, 5 minutes)."""
85
+ config = get_config()
86
+ response.set_cookie(
87
+ key=STATE_COOKIE_NAME,
88
+ value=state,
89
+ httponly=True,
90
+ secure=config.cookie_secure,
91
+ samesite="lax",
92
+ max_age=300, # 5 minutes
93
+ path="/",
94
+ )
95
+
96
+
97
+ def clear_state_cookie(response: Response) -> None:
98
+ """Clear the OAuth state cookie."""
99
+ response.delete_cookie(
100
+ key=STATE_COOKIE_NAME,
101
+ path="/",
102
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sweatstack
3
- Version: 0.59.0
3
+ Version: 0.60.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
@@ -10,6 +10,9 @@ Requires-Dist: pandas>=2.2.3
10
10
  Requires-Dist: platformdirs>=4.0.0
11
11
  Requires-Dist: pyarrow>=18.0.0
12
12
  Requires-Dist: pydantic>=2.10.5
13
+ Provides-Extra: fastapi
14
+ Requires-Dist: cryptography>=41.0.0; extra == 'fastapi'
15
+ Requires-Dist: fastapi[standard]>=0.100.0; extra == 'fastapi'
13
16
  Provides-Extra: jupyter
14
17
  Requires-Dist: ipython>=8.31.0; extra == 'jupyter'
15
18
  Requires-Dist: jupyterlab>=4.3.4; extra == 'jupyter'
@@ -1,6 +1,6 @@
1
1
  sweatstack/__init__.py,sha256=tiVfgKlswRPaDMEy0gA7u8rveqEYZTA_kyB9lJ3J6Sc,21
2
2
  sweatstack/cli.py,sha256=N1NWOgEZR2yaJvIXxo9qvp_jFlypZYb0nujpbVNYQ6A,720
3
- sweatstack/client.py,sha256=dTx7Cqpd56NH32-Ndc6vo1WnzsN1C9BfpFrchV9NTdQ,66366
3
+ sweatstack/client.py,sha256=0SipHY_USSMnxEb3PdmQ4ogUZTB81nC-eAJjthwqy6g,68175
4
4
  sweatstack/constants.py,sha256=fGO6ksOv5HeISv9lHRoYm4besW1GTveXS8YD3K0ljg0,41
5
5
  sweatstack/ipython_init.py,sha256=OtBB9dQvyLXklD4kA2x1swaVtU9u73fG4V4-zz4YRAg,139
6
6
  sweatstack/jupyterlab_oauth2_startup.py,sha256=YcjXvzeZ459vL_dCkFi1IxX_RNAu80ZX9rwa0OXJfTM,1023
@@ -11,7 +11,12 @@ 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-0.59.0.dist-info/METADATA,sha256=811kowZZQ5K40LdVRA5JeLk95TBj5MHY6UGWn91g4Kg,852
15
- sweatstack-0.59.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
16
- sweatstack-0.59.0.dist-info/entry_points.txt,sha256=kCzOUQI3dqbTpEYqtgYDeiKFaqaA7BMlV6D24BMzCFU,208
17
- sweatstack-0.59.0.dist-info/RECORD,,
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
18
+ 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,,