pargo-auth 0.1.2__tar.gz

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.
@@ -0,0 +1,63 @@
1
+ name: Deploy to PyPI
2
+
3
+ on:
4
+ push:
5
+ branches: [master, main]
6
+ workflow_dispatch:
7
+
8
+ jobs:
9
+ deploy:
10
+ runs-on: ubuntu-latest
11
+ permissions:
12
+ contents: write
13
+ id-token: write
14
+
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ with:
18
+ fetch-depth: 0
19
+ token: ${{ secrets.GITHUB_TOKEN }}
20
+
21
+ - name: Set up Python
22
+ uses: actions/setup-python@v5
23
+ with:
24
+ python-version: "3.11"
25
+
26
+ - name: Install build tools
27
+ run: pip install hatch
28
+
29
+ - name: Bump version
30
+ id: bump
31
+ run: |
32
+ CURRENT=$(grep -Po '(?<=__version__ = ")[^"]+' src/pargo_auth/__init__.py)
33
+ echo "Current version: $CURRENT"
34
+
35
+ IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT"
36
+ NEW_PATCH=$((PATCH + 1))
37
+ NEW_VERSION="$MAJOR.$MINOR.$NEW_PATCH"
38
+ echo "New version: $NEW_VERSION"
39
+
40
+ sed -i "s/__version__ = \"$CURRENT\"/__version__ = \"$NEW_VERSION\"/" src/pargo_auth/__init__.py
41
+ echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT
42
+
43
+ - name: Commit version bump
44
+ run: |
45
+ git config user.name "github-actions[bot]"
46
+ git config user.email "github-actions[bot]@users.noreply.github.com"
47
+ git add src/pargo_auth/__init__.py
48
+ git commit -m "Bump version to ${{ steps.bump.outputs.version }}" || echo "No changes to commit"
49
+ git push
50
+
51
+ - name: Build package
52
+ run: hatch build
53
+
54
+ - name: Publish to PyPI
55
+ uses: pypa/gh-action-pypi-publish@release/v1
56
+
57
+ - name: Verify deployment
58
+ run: |
59
+ echo "=== Package built ==="
60
+ ls -la dist/
61
+ echo ""
62
+ echo "=== Version deployed ==="
63
+ echo "pargo-auth ${{ steps.bump.outputs.version }}"
@@ -0,0 +1,62 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # Distribution / packaging
7
+ .Python
8
+ build/
9
+ develop-eggs/
10
+ dist/
11
+ downloads/
12
+ eggs/
13
+ .eggs/
14
+ lib/
15
+ lib64/
16
+ parts/
17
+ sdist/
18
+ var/
19
+ wheels/
20
+ *.egg-info/
21
+ .installed.cfg
22
+ *.egg
23
+
24
+ # PyInstaller
25
+ *.manifest
26
+ *.spec
27
+
28
+ # Installer logs
29
+ pip-log.txt
30
+ pip-delete-this-directory.txt
31
+
32
+ # Unit test / coverage reports
33
+ htmlcov/
34
+ .tox/
35
+ .nox/
36
+ .coverage
37
+ .coverage.*
38
+ .cache
39
+ nosetests.xml
40
+ coverage.xml
41
+ *.cover
42
+ *.py,cover
43
+ .hypothesis/
44
+ .pytest_cache/
45
+
46
+ # Environments
47
+ .env
48
+ .venv
49
+ env/
50
+ venv/
51
+ ENV/
52
+ env.bak/
53
+ venv.bak/
54
+
55
+ # IDE
56
+ .idea/
57
+ .vscode/
58
+ *.swp
59
+ *.swo
60
+
61
+ # macOS
62
+ .DS_Store
@@ -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.
@@ -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,117 @@
1
+ # pargo-auth
2
+
3
+ Shared authentication middleware for PARGO backend services. Verifies Supabase JWTs and provides FastAPI dependencies.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install pargo-auth
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```python
14
+ from fastapi import FastAPI, Depends
15
+ from pargo_auth import get_current_user, AuthenticatedUser
16
+
17
+ app = FastAPI()
18
+
19
+ @app.get("/me")
20
+ async def get_me(user: AuthenticatedUser = Depends(get_current_user)):
21
+ return {
22
+ "id": user.sub,
23
+ "email": user.email,
24
+ }
25
+ ```
26
+
27
+ ## Configuration
28
+
29
+ Set these environment variables:
30
+
31
+ ```bash
32
+ SUPABASE_URL=https://your-project.supabase.co
33
+ ENV=local # Skip auth verification in local dev
34
+ ```
35
+
36
+ ## Usage Patterns
37
+
38
+ ### Basic: Protect individual endpoints
39
+
40
+ ```python
41
+ from pargo_auth import get_current_user, AuthenticatedUser
42
+
43
+ @app.get("/protected")
44
+ async def protected(user: AuthenticatedUser = Depends(get_current_user)):
45
+ return {"user_id": user.sub}
46
+ ```
47
+
48
+ ### Protect entire router
49
+
50
+ ```python
51
+ from fastapi import APIRouter, Depends
52
+ from pargo_auth import require_auth
53
+
54
+ router = APIRouter(dependencies=[require_auth()])
55
+
56
+ @router.get("/data")
57
+ async def get_data(): # Auth already required by router
58
+ return {"data": "secret"}
59
+ ```
60
+
61
+ ### Optional auth (different behavior for logged-in vs anonymous)
62
+
63
+ ```python
64
+ from pargo_auth import SupabaseAuth, AuthenticatedUser
65
+
66
+ auth = SupabaseAuth()
67
+
68
+ @app.get("/content")
69
+ async def get_content(user: AuthenticatedUser | None = Depends(auth.get_user_optional)):
70
+ if user:
71
+ return {"content": "personalized", "user": user.sub}
72
+ return {"content": "generic"}
73
+ ```
74
+
75
+ ### Custom instance (non-default config)
76
+
77
+ ```python
78
+ from pargo_auth import SupabaseAuth
79
+
80
+ auth = SupabaseAuth(
81
+ supabase_url="https://custom.supabase.co",
82
+ skip_verification_in_dev=False, # Always verify, even locally
83
+ )
84
+
85
+ @app.get("/strict")
86
+ async def strict_endpoint(user = Depends(auth.get_user)):
87
+ return {"user": user.sub}
88
+ ```
89
+
90
+ ## AuthenticatedUser
91
+
92
+ The `AuthenticatedUser` object contains:
93
+
94
+ | Field | Type | Description |
95
+ |-------|------|-------------|
96
+ | `sub` | `str` | Supabase user ID (stable, use as canonical identity) |
97
+ | `email` | `str \| None` | User's email (if available) |
98
+ | `email_verified` | `bool` | Whether email is verified |
99
+ | `raw_claims` | `dict \| None` | Full JWT payload for custom claims |
100
+
101
+ ## Legacy Support
102
+
103
+ 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.
104
+
105
+ ## Development
106
+
107
+ ```bash
108
+ # Install dev dependencies
109
+ pip install -e ".[dev]"
110
+
111
+ # Run tests
112
+ pytest
113
+ ```
114
+
115
+ ## License
116
+
117
+ MIT
@@ -0,0 +1,46 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pargo-auth"
7
+ dynamic = ["version"]
8
+ description = "Shared authentication middleware for PARGO backend services"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [
13
+ { name = "PARGO", email = "dev@pargo.dk" }
14
+ ]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Framework :: FastAPI",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ ]
25
+ dependencies = [
26
+ "fastapi>=0.100.0",
27
+ "httpx>=0.24.0",
28
+ "PyJWT>=2.8.0",
29
+ "cryptography>=41.0.0",
30
+ ]
31
+
32
+ [project.optional-dependencies]
33
+ dev = [
34
+ "pytest>=7.0.0",
35
+ "pytest-asyncio>=0.21.0",
36
+ ]
37
+
38
+ [project.urls]
39
+ Homepage = "https://github.com/pargoorg/pargo-auth"
40
+ Repository = "https://github.com/pargoorg/pargo-auth"
41
+
42
+ [tool.hatch.version]
43
+ path = "src/pargo_auth/__init__.py"
44
+
45
+ [tool.hatch.build.targets.wheel]
46
+ packages = ["src/pargo_auth"]
@@ -0,0 +1,3 @@
1
+ [pytest]
2
+ testpaths = tests
3
+ asyncio_mode = auto
@@ -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)
@@ -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
File without changes
@@ -0,0 +1,42 @@
1
+ """Tests for auth middleware."""
2
+
3
+ import pytest
4
+ from pargo_auth import AuthenticatedUser
5
+ from pargo_auth.exceptions import InvalidTokenError, ExpiredTokenError
6
+
7
+
8
+ def test_authenticated_user_creation():
9
+ """Test AuthenticatedUser dataclass."""
10
+ user = AuthenticatedUser(
11
+ sub="user-123",
12
+ email="test@example.com",
13
+ email_verified=True,
14
+ )
15
+
16
+ assert user.sub == "user-123"
17
+ assert user.supabase_uid == "user-123" # Alias
18
+ assert user.email == "test@example.com"
19
+ assert user.email_verified is True
20
+
21
+
22
+ def test_authenticated_user_minimal():
23
+ """Test AuthenticatedUser with minimal fields."""
24
+ user = AuthenticatedUser(sub="user-456")
25
+
26
+ assert user.sub == "user-456"
27
+ assert user.email is None
28
+ assert user.email_verified is False
29
+
30
+
31
+ def test_invalid_token_error():
32
+ """Test InvalidTokenError."""
33
+ error = InvalidTokenError("bad token")
34
+ assert error.status_code == 401
35
+ assert error.message == "bad token"
36
+
37
+
38
+ def test_expired_token_error():
39
+ """Test ExpiredTokenError."""
40
+ error = ExpiredTokenError()
41
+ assert error.status_code == 401
42
+ assert "expired" in error.message.lower()