pargo-auth 0.1.2__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.
pargo_auth/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """PARGO Auth - Shared authentication middleware for PARGO backend services."""
2
+
3
+ __version__ = "0.1.2"
4
+
5
+ from .middleware import SupabaseAuth, get_current_user, require_auth
6
+ from .models import AuthenticatedUser
7
+ from .exceptions import AuthError, InvalidTokenError, ExpiredTokenError
8
+
9
+ __all__ = [
10
+ "SupabaseAuth",
11
+ "get_current_user",
12
+ "require_auth",
13
+ "AuthenticatedUser",
14
+ "AuthError",
15
+ "InvalidTokenError",
16
+ "ExpiredTokenError",
17
+ ]
@@ -0,0 +1,31 @@
1
+ """Authentication exceptions."""
2
+
3
+
4
+ class AuthError(Exception):
5
+ """Base authentication error."""
6
+
7
+ def __init__(self, message: str = "Authentication failed", status_code: int = 401):
8
+ self.message = message
9
+ self.status_code = status_code
10
+ super().__init__(self.message)
11
+
12
+
13
+ class InvalidTokenError(AuthError):
14
+ """Token is malformed or signature is invalid."""
15
+
16
+ def __init__(self, message: str = "Invalid token"):
17
+ super().__init__(message, status_code=401)
18
+
19
+
20
+ class ExpiredTokenError(AuthError):
21
+ """Token has expired."""
22
+
23
+ def __init__(self, message: str = "Token expired"):
24
+ super().__init__(message, status_code=401)
25
+
26
+
27
+ class MissingTokenError(AuthError):
28
+ """No token provided."""
29
+
30
+ def __init__(self, message: str = "Missing authorization header"):
31
+ super().__init__(message, status_code=401)
@@ -0,0 +1,206 @@
1
+ """FastAPI authentication middleware for Supabase JWT verification."""
2
+
3
+ import os
4
+ import logging
5
+ from typing import Optional
6
+ from functools import lru_cache
7
+
8
+ import httpx
9
+ import jwt
10
+ from fastapi import Request, HTTPException, Depends
11
+ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
12
+
13
+ from .models import AuthenticatedUser
14
+ from .exceptions import AuthError, InvalidTokenError, ExpiredTokenError, MissingTokenError
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ # Security scheme for OpenAPI docs
19
+ security = HTTPBearer(auto_error=False)
20
+
21
+
22
+ class SupabaseAuth:
23
+ """
24
+ Supabase JWT verification using JWKS.
25
+
26
+ Usage:
27
+ auth = SupabaseAuth(
28
+ supabase_url="https://xxx.supabase.co",
29
+ supabase_anon_key="your-anon-key", # Optional, for audience validation
30
+ )
31
+
32
+ @app.get("/protected")
33
+ async def protected(user: AuthenticatedUser = Depends(auth.get_user)):
34
+ return {"user_id": user.sub}
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ supabase_url: Optional[str] = None,
40
+ supabase_anon_key: Optional[str] = None,
41
+ skip_verification_in_dev: bool = True,
42
+ ):
43
+ self.supabase_url = supabase_url or os.getenv("SUPABASE_URL")
44
+ self.supabase_anon_key = supabase_anon_key or os.getenv("SUPABASE_ANON_KEY")
45
+ self.skip_verification_in_dev = skip_verification_in_dev
46
+ self._jwks_client: Optional[jwt.PyJWKClient] = None
47
+
48
+ if not self.supabase_url:
49
+ raise ValueError("SUPABASE_URL must be set")
50
+
51
+ @property
52
+ def jwks_url(self) -> str:
53
+ """JWKS endpoint for Supabase project."""
54
+ return f"{self.supabase_url}/auth/v1/.well-known/jwks.json"
55
+
56
+ @property
57
+ def issuer(self) -> str:
58
+ """Expected JWT issuer."""
59
+ return f"{self.supabase_url}/auth/v1"
60
+
61
+ def _get_jwks_client(self) -> jwt.PyJWKClient:
62
+ """Get or create JWKS client (cached)."""
63
+ if self._jwks_client is None:
64
+ self._jwks_client = jwt.PyJWKClient(
65
+ self.jwks_url,
66
+ cache_keys=True,
67
+ lifespan=3600, # Cache keys for 1 hour
68
+ )
69
+ return self._jwks_client
70
+
71
+ def verify_token(self, token: str) -> AuthenticatedUser:
72
+ """
73
+ Verify a Supabase JWT and return user info.
74
+
75
+ Raises:
76
+ InvalidTokenError: Token is malformed or signature invalid
77
+ ExpiredTokenError: Token has expired
78
+ """
79
+ try:
80
+ # Get signing key from JWKS
81
+ jwks_client = self._get_jwks_client()
82
+ signing_key = jwks_client.get_signing_key_from_jwt(token)
83
+
84
+ # Decode and verify
85
+ payload = jwt.decode(
86
+ token,
87
+ signing_key.key,
88
+ algorithms=["RS256"],
89
+ issuer=self.issuer,
90
+ options={
91
+ "verify_aud": False, # Supabase doesn't always set aud
92
+ "verify_iss": True,
93
+ "verify_exp": True,
94
+ },
95
+ )
96
+
97
+ return AuthenticatedUser(
98
+ sub=payload["sub"],
99
+ email=payload.get("email"),
100
+ email_verified=payload.get("email_verified", False),
101
+ raw_claims=payload,
102
+ )
103
+
104
+ except jwt.ExpiredSignatureError:
105
+ raise ExpiredTokenError()
106
+ except jwt.InvalidTokenError as e:
107
+ logger.warning(f"Invalid token: {e}")
108
+ raise InvalidTokenError(str(e))
109
+ except Exception as e:
110
+ logger.error(f"Token verification failed: {e}")
111
+ raise InvalidTokenError("Token verification failed")
112
+
113
+ async def get_user(
114
+ self,
115
+ request: Request,
116
+ credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
117
+ ) -> AuthenticatedUser:
118
+ """
119
+ FastAPI dependency to get authenticated user.
120
+
121
+ Usage:
122
+ @app.get("/me")
123
+ async def get_me(user: AuthenticatedUser = Depends(auth.get_user)):
124
+ return {"id": user.sub}
125
+ """
126
+ # Skip auth in local development
127
+ if self.skip_verification_in_dev and os.getenv("ENV") == "local":
128
+ return AuthenticatedUser(
129
+ sub="local-dev-user",
130
+ email="dev@local",
131
+ email_verified=True,
132
+ )
133
+
134
+ # Extract token
135
+ token = None
136
+ if credentials:
137
+ token = credentials.credentials
138
+
139
+ # Fallback: check x-user-id header (legacy support)
140
+ if not token:
141
+ legacy_uid = request.headers.get("x-user-id")
142
+ if legacy_uid:
143
+ logger.warning("Using legacy x-user-id header - migrate to Bearer token")
144
+ return AuthenticatedUser(sub=legacy_uid)
145
+
146
+ if not token:
147
+ raise HTTPException(status_code=401, detail="Missing authorization")
148
+
149
+ try:
150
+ return self.verify_token(token)
151
+ except AuthError as e:
152
+ raise HTTPException(status_code=e.status_code, detail=e.message)
153
+
154
+ async def get_user_optional(
155
+ self,
156
+ request: Request,
157
+ credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
158
+ ) -> Optional[AuthenticatedUser]:
159
+ """
160
+ FastAPI dependency that returns None instead of raising if not authenticated.
161
+ Useful for endpoints that work differently for logged-in vs anonymous users.
162
+ """
163
+ try:
164
+ return await self.get_user(request, credentials)
165
+ except HTTPException:
166
+ return None
167
+
168
+
169
+ # Convenience: module-level instance using environment variables
170
+ _default_auth: Optional[SupabaseAuth] = None
171
+
172
+
173
+ def _get_default_auth() -> SupabaseAuth:
174
+ """Get or create default auth instance from environment."""
175
+ global _default_auth
176
+ if _default_auth is None:
177
+ _default_auth = SupabaseAuth()
178
+ return _default_auth
179
+
180
+
181
+ async def get_current_user(
182
+ request: Request,
183
+ credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
184
+ ) -> AuthenticatedUser:
185
+ """
186
+ Convenience dependency using environment-configured auth.
187
+
188
+ Usage:
189
+ from pargo_auth import get_current_user, AuthenticatedUser
190
+
191
+ @app.get("/me")
192
+ async def get_me(user: AuthenticatedUser = Depends(get_current_user)):
193
+ return {"id": user.sub}
194
+ """
195
+ auth = _get_default_auth()
196
+ return await auth.get_user(request, credentials)
197
+
198
+
199
+ def require_auth():
200
+ """
201
+ Dependency that can be used in router dependencies list.
202
+
203
+ Usage:
204
+ router = APIRouter(dependencies=[Depends(require_auth())])
205
+ """
206
+ return Depends(get_current_user)
pargo_auth/models.py ADDED
@@ -0,0 +1,21 @@
1
+ """Data models for authentication."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Optional
5
+
6
+
7
+ @dataclass
8
+ class AuthenticatedUser:
9
+ """Represents an authenticated user from Supabase JWT."""
10
+
11
+ sub: str # Supabase user ID (stable, use as canonical identity)
12
+ email: Optional[str] = None
13
+ email_verified: bool = False
14
+
15
+ # Raw claims for extensibility
16
+ raw_claims: Optional[dict] = None
17
+
18
+ @property
19
+ def supabase_uid(self) -> str:
20
+ """Alias for sub - the Supabase user ID."""
21
+ return self.sub
@@ -0,0 +1,144 @@
1
+ Metadata-Version: 2.4
2
+ Name: pargo-auth
3
+ Version: 0.1.2
4
+ Summary: Shared authentication middleware for PARGO backend services
5
+ Project-URL: Homepage, https://github.com/pargoorg/pargo-auth
6
+ Project-URL: Repository, https://github.com/pargoorg/pargo-auth
7
+ Author-email: PARGO <dev@pargo.dk>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Framework :: FastAPI
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Requires-Python: >=3.10
19
+ Requires-Dist: cryptography>=41.0.0
20
+ Requires-Dist: fastapi>=0.100.0
21
+ Requires-Dist: httpx>=0.24.0
22
+ Requires-Dist: pyjwt>=2.8.0
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest-asyncio>=0.21.0; extra == 'dev'
25
+ Requires-Dist: pytest>=7.0.0; extra == 'dev'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # pargo-auth
29
+
30
+ Shared authentication middleware for PARGO backend services. Verifies Supabase JWTs and provides FastAPI dependencies.
31
+
32
+ ## Installation
33
+
34
+ ```bash
35
+ pip install pargo-auth
36
+ ```
37
+
38
+ ## Quick Start
39
+
40
+ ```python
41
+ from fastapi import FastAPI, Depends
42
+ from pargo_auth import get_current_user, AuthenticatedUser
43
+
44
+ app = FastAPI()
45
+
46
+ @app.get("/me")
47
+ async def get_me(user: AuthenticatedUser = Depends(get_current_user)):
48
+ return {
49
+ "id": user.sub,
50
+ "email": user.email,
51
+ }
52
+ ```
53
+
54
+ ## Configuration
55
+
56
+ Set these environment variables:
57
+
58
+ ```bash
59
+ SUPABASE_URL=https://your-project.supabase.co
60
+ ENV=local # Skip auth verification in local dev
61
+ ```
62
+
63
+ ## Usage Patterns
64
+
65
+ ### Basic: Protect individual endpoints
66
+
67
+ ```python
68
+ from pargo_auth import get_current_user, AuthenticatedUser
69
+
70
+ @app.get("/protected")
71
+ async def protected(user: AuthenticatedUser = Depends(get_current_user)):
72
+ return {"user_id": user.sub}
73
+ ```
74
+
75
+ ### Protect entire router
76
+
77
+ ```python
78
+ from fastapi import APIRouter, Depends
79
+ from pargo_auth import require_auth
80
+
81
+ router = APIRouter(dependencies=[require_auth()])
82
+
83
+ @router.get("/data")
84
+ async def get_data(): # Auth already required by router
85
+ return {"data": "secret"}
86
+ ```
87
+
88
+ ### Optional auth (different behavior for logged-in vs anonymous)
89
+
90
+ ```python
91
+ from pargo_auth import SupabaseAuth, AuthenticatedUser
92
+
93
+ auth = SupabaseAuth()
94
+
95
+ @app.get("/content")
96
+ async def get_content(user: AuthenticatedUser | None = Depends(auth.get_user_optional)):
97
+ if user:
98
+ return {"content": "personalized", "user": user.sub}
99
+ return {"content": "generic"}
100
+ ```
101
+
102
+ ### Custom instance (non-default config)
103
+
104
+ ```python
105
+ from pargo_auth import SupabaseAuth
106
+
107
+ auth = SupabaseAuth(
108
+ supabase_url="https://custom.supabase.co",
109
+ skip_verification_in_dev=False, # Always verify, even locally
110
+ )
111
+
112
+ @app.get("/strict")
113
+ async def strict_endpoint(user = Depends(auth.get_user)):
114
+ return {"user": user.sub}
115
+ ```
116
+
117
+ ## AuthenticatedUser
118
+
119
+ The `AuthenticatedUser` object contains:
120
+
121
+ | Field | Type | Description |
122
+ |-------|------|-------------|
123
+ | `sub` | `str` | Supabase user ID (stable, use as canonical identity) |
124
+ | `email` | `str \| None` | User's email (if available) |
125
+ | `email_verified` | `bool` | Whether email is verified |
126
+ | `raw_claims` | `dict \| None` | Full JWT payload for custom claims |
127
+
128
+ ## Legacy Support
129
+
130
+ For backwards compatibility, the middleware also checks for `x-user-id` header if no Bearer token is present. This allows gradual migration from the old auth system.
131
+
132
+ ## Development
133
+
134
+ ```bash
135
+ # Install dev dependencies
136
+ pip install -e ".[dev]"
137
+
138
+ # Run tests
139
+ pytest
140
+ ```
141
+
142
+ ## License
143
+
144
+ MIT
@@ -0,0 +1,8 @@
1
+ pargo_auth/__init__.py,sha256=lWjCNYxegtjkGnnQu7mvjGs5iyLAuaSLFwnAmWqIPcM,454
2
+ pargo_auth/exceptions.py,sha256=Qckee29SlPf8VS21s-1gT8j_gCoYKaJhYKet9DE28WE,879
3
+ pargo_auth/middleware.py,sha256=fCh41HYMfNQbEieVFnUG6uCl-wr0SQ2KahGX297HCYE,6779
4
+ pargo_auth/models.py,sha256=I_mb0PGPy7FHZbMHxs1tR4NekD-cv1Ea4ZSzjStDZo4,548
5
+ pargo_auth-0.1.2.dist-info/METADATA,sha256=xXFakWjCY3MzTammApy2JUSnO8Myss86EA0x_LgIvg4,3612
6
+ pargo_auth-0.1.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
7
+ pargo_auth-0.1.2.dist-info/licenses/LICENSE,sha256=A-E9k545xbWsw-1DRhKLXcePHJ15tKx_bQl12OabqEY,1062
8
+ pargo_auth-0.1.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 PARGO
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.