jengolabs-auth 0.1.0__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.
- jengolabs_auth-0.1.0/.gitignore +11 -0
- jengolabs_auth-0.1.0/PKG-INFO +146 -0
- jengolabs_auth-0.1.0/README.md +112 -0
- jengolabs_auth-0.1.0/jengolabs_auth/__init__.py +24 -0
- jengolabs_auth-0.1.0/jengolabs_auth/client.py +105 -0
- jengolabs_auth-0.1.0/jengolabs_auth/errors.py +30 -0
- jengolabs_auth-0.1.0/jengolabs_auth/fastapi/__init__.py +13 -0
- jengolabs_auth-0.1.0/jengolabs_auth/fastapi/dependencies.py +77 -0
- jengolabs_auth-0.1.0/jengolabs_auth/fastapi/middleware.py +58 -0
- jengolabs_auth-0.1.0/jengolabs_auth/models.py +68 -0
- jengolabs_auth-0.1.0/jengolabs_auth/py.typed +0 -0
- jengolabs_auth-0.1.0/pyproject.toml +63 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: jengolabs-auth
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for Jengo Auth — session verification, FastAPI middleware, and role-based access control
|
|
5
|
+
Project-URL: Repository, https://github.com/jengolabs/jen-auth
|
|
6
|
+
Project-URL: Documentation, https://github.com/jengolabs/jen-auth/blob/main/docs/dx/DX.md
|
|
7
|
+
Author: Jengo Labs
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Framework :: FastAPI
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Requires-Dist: httpx>=0.27.0
|
|
21
|
+
Requires-Dist: pydantic>=2.0.0
|
|
22
|
+
Provides-Extra: all
|
|
23
|
+
Requires-Dist: fastapi>=0.100.0; extra == 'all'
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: fastapi>=0.100.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: mypy>=1.13.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: ruff>=0.8.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: uvicorn>=0.30.0; extra == 'dev'
|
|
31
|
+
Provides-Extra: fastapi
|
|
32
|
+
Requires-Dist: fastapi>=0.100.0; extra == 'fastapi'
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
# jengolabs-auth
|
|
36
|
+
|
|
37
|
+
Python SDK for [Jengo Auth](https://github.com/jengolabs/jen-auth) — session verification, FastAPI middleware, and role-based access control.
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install jengolabs-auth[fastapi]
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Quick Start (FastAPI)
|
|
46
|
+
|
|
47
|
+
### Option 1: Dependency Injection
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from fastapi import FastAPI, Depends
|
|
51
|
+
from jengolabs_auth import AuthClient, AuthClientConfig
|
|
52
|
+
from jengolabs_auth.fastapi import require_auth, require_app_role
|
|
53
|
+
|
|
54
|
+
auth_client = AuthClient(AuthClientConfig(
|
|
55
|
+
auth_server_url="http://localhost:4000",
|
|
56
|
+
tenant_api_key="your-tenant-api-key",
|
|
57
|
+
app_slug="my-service",
|
|
58
|
+
cache_ttl_seconds=30,
|
|
59
|
+
))
|
|
60
|
+
|
|
61
|
+
app = FastAPI()
|
|
62
|
+
|
|
63
|
+
@app.get("/api/me")
|
|
64
|
+
async def get_me(session=require_auth(auth_client)):
|
|
65
|
+
return {"id": session.user.id, "email": session.user.email}
|
|
66
|
+
|
|
67
|
+
@app.delete("/api/resource/{resource_id}")
|
|
68
|
+
async def delete_resource(
|
|
69
|
+
resource_id: str,
|
|
70
|
+
session=require_app_role("admin", auth_client=auth_client),
|
|
71
|
+
):
|
|
72
|
+
return {"deleted": resource_id}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Option 2: Middleware
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from fastapi import FastAPI, Request
|
|
79
|
+
from jengolabs_auth import AuthClient, AuthClientConfig
|
|
80
|
+
from jengolabs_auth.fastapi import JengoAuthMiddleware
|
|
81
|
+
|
|
82
|
+
auth_client = AuthClient(AuthClientConfig(
|
|
83
|
+
auth_server_url="http://localhost:4000",
|
|
84
|
+
tenant_api_key="your-tenant-api-key",
|
|
85
|
+
app_slug="my-service",
|
|
86
|
+
))
|
|
87
|
+
|
|
88
|
+
app = FastAPI()
|
|
89
|
+
app.add_middleware(
|
|
90
|
+
JengoAuthMiddleware,
|
|
91
|
+
auth_client=auth_client,
|
|
92
|
+
public_paths=["/health", "/docs", "/openapi.json"],
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
@app.get("/api/me")
|
|
96
|
+
async def get_me(request: Request):
|
|
97
|
+
user = request.state.auth_user
|
|
98
|
+
return {"id": user.id, "email": user.email}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Option 3: Manual Verification
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from jengolabs_auth import AuthClient, AuthClientConfig, AuthenticationError
|
|
105
|
+
|
|
106
|
+
auth_client = AuthClient(AuthClientConfig(
|
|
107
|
+
auth_server_url="http://localhost:4000",
|
|
108
|
+
tenant_api_key="your-tenant-api-key",
|
|
109
|
+
app_slug="my-service",
|
|
110
|
+
))
|
|
111
|
+
|
|
112
|
+
async def verify_token(token: str):
|
|
113
|
+
try:
|
|
114
|
+
session = await auth_client.verify_session(f"Bearer {token}")
|
|
115
|
+
return session.user
|
|
116
|
+
except AuthenticationError:
|
|
117
|
+
return None
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Models
|
|
121
|
+
|
|
122
|
+
All models are Pydantic v2 and support both snake_case and camelCase fields:
|
|
123
|
+
|
|
124
|
+
| Model | Fields |
|
|
125
|
+
|---|---|
|
|
126
|
+
| `AuthUser` | id, email, name, email_verified, image, role, created_at, updated_at |
|
|
127
|
+
| `AuthSessionData` | id, token, expires_at |
|
|
128
|
+
| `AuthAppGrant` | app_slug, role, granted_at |
|
|
129
|
+
| `AuthSessionWithGrant` | user, session, app_grant, organization |
|
|
130
|
+
|
|
131
|
+
## Session Caching
|
|
132
|
+
|
|
133
|
+
Enable in-memory caching to reduce auth server calls:
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
auth_client = AuthClient(AuthClientConfig(
|
|
137
|
+
auth_server_url="http://localhost:4000",
|
|
138
|
+
tenant_api_key="your-tenant-api-key",
|
|
139
|
+
app_slug="my-service",
|
|
140
|
+
cache_ttl_seconds=30, # cache sessions for 30 seconds
|
|
141
|
+
))
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## License
|
|
145
|
+
|
|
146
|
+
MIT
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# jengolabs-auth
|
|
2
|
+
|
|
3
|
+
Python SDK for [Jengo Auth](https://github.com/jengolabs/jen-auth) — session verification, FastAPI middleware, and role-based access control.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install jengolabs-auth[fastapi]
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start (FastAPI)
|
|
12
|
+
|
|
13
|
+
### Option 1: Dependency Injection
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from fastapi import FastAPI, Depends
|
|
17
|
+
from jengolabs_auth import AuthClient, AuthClientConfig
|
|
18
|
+
from jengolabs_auth.fastapi import require_auth, require_app_role
|
|
19
|
+
|
|
20
|
+
auth_client = AuthClient(AuthClientConfig(
|
|
21
|
+
auth_server_url="http://localhost:4000",
|
|
22
|
+
tenant_api_key="your-tenant-api-key",
|
|
23
|
+
app_slug="my-service",
|
|
24
|
+
cache_ttl_seconds=30,
|
|
25
|
+
))
|
|
26
|
+
|
|
27
|
+
app = FastAPI()
|
|
28
|
+
|
|
29
|
+
@app.get("/api/me")
|
|
30
|
+
async def get_me(session=require_auth(auth_client)):
|
|
31
|
+
return {"id": session.user.id, "email": session.user.email}
|
|
32
|
+
|
|
33
|
+
@app.delete("/api/resource/{resource_id}")
|
|
34
|
+
async def delete_resource(
|
|
35
|
+
resource_id: str,
|
|
36
|
+
session=require_app_role("admin", auth_client=auth_client),
|
|
37
|
+
):
|
|
38
|
+
return {"deleted": resource_id}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Option 2: Middleware
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from fastapi import FastAPI, Request
|
|
45
|
+
from jengolabs_auth import AuthClient, AuthClientConfig
|
|
46
|
+
from jengolabs_auth.fastapi import JengoAuthMiddleware
|
|
47
|
+
|
|
48
|
+
auth_client = AuthClient(AuthClientConfig(
|
|
49
|
+
auth_server_url="http://localhost:4000",
|
|
50
|
+
tenant_api_key="your-tenant-api-key",
|
|
51
|
+
app_slug="my-service",
|
|
52
|
+
))
|
|
53
|
+
|
|
54
|
+
app = FastAPI()
|
|
55
|
+
app.add_middleware(
|
|
56
|
+
JengoAuthMiddleware,
|
|
57
|
+
auth_client=auth_client,
|
|
58
|
+
public_paths=["/health", "/docs", "/openapi.json"],
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
@app.get("/api/me")
|
|
62
|
+
async def get_me(request: Request):
|
|
63
|
+
user = request.state.auth_user
|
|
64
|
+
return {"id": user.id, "email": user.email}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Option 3: Manual Verification
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from jengolabs_auth import AuthClient, AuthClientConfig, AuthenticationError
|
|
71
|
+
|
|
72
|
+
auth_client = AuthClient(AuthClientConfig(
|
|
73
|
+
auth_server_url="http://localhost:4000",
|
|
74
|
+
tenant_api_key="your-tenant-api-key",
|
|
75
|
+
app_slug="my-service",
|
|
76
|
+
))
|
|
77
|
+
|
|
78
|
+
async def verify_token(token: str):
|
|
79
|
+
try:
|
|
80
|
+
session = await auth_client.verify_session(f"Bearer {token}")
|
|
81
|
+
return session.user
|
|
82
|
+
except AuthenticationError:
|
|
83
|
+
return None
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Models
|
|
87
|
+
|
|
88
|
+
All models are Pydantic v2 and support both snake_case and camelCase fields:
|
|
89
|
+
|
|
90
|
+
| Model | Fields |
|
|
91
|
+
|---|---|
|
|
92
|
+
| `AuthUser` | id, email, name, email_verified, image, role, created_at, updated_at |
|
|
93
|
+
| `AuthSessionData` | id, token, expires_at |
|
|
94
|
+
| `AuthAppGrant` | app_slug, role, granted_at |
|
|
95
|
+
| `AuthSessionWithGrant` | user, session, app_grant, organization |
|
|
96
|
+
|
|
97
|
+
## Session Caching
|
|
98
|
+
|
|
99
|
+
Enable in-memory caching to reduce auth server calls:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
auth_client = AuthClient(AuthClientConfig(
|
|
103
|
+
auth_server_url="http://localhost:4000",
|
|
104
|
+
tenant_api_key="your-tenant-api-key",
|
|
105
|
+
app_slug="my-service",
|
|
106
|
+
cache_ttl_seconds=30, # cache sessions for 30 seconds
|
|
107
|
+
))
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
MIT
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from jengolabs_auth.client import AuthClient, AuthClientConfig
|
|
2
|
+
from jengolabs_auth.errors import AuthenticationError, AuthorizationError, AuthTransportError
|
|
3
|
+
from jengolabs_auth.models import (
|
|
4
|
+
AuthAppGrant,
|
|
5
|
+
AuthOrganization,
|
|
6
|
+
AuthSession,
|
|
7
|
+
AuthSessionData,
|
|
8
|
+
AuthSessionWithGrant,
|
|
9
|
+
AuthUser,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"AuthClient",
|
|
14
|
+
"AuthClientConfig",
|
|
15
|
+
"AuthenticationError",
|
|
16
|
+
"AuthTransportError",
|
|
17
|
+
"AuthorizationError",
|
|
18
|
+
"AuthAppGrant",
|
|
19
|
+
"AuthOrganization",
|
|
20
|
+
"AuthSession",
|
|
21
|
+
"AuthSessionData",
|
|
22
|
+
"AuthSessionWithGrant",
|
|
23
|
+
"AuthUser",
|
|
24
|
+
]
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Callable
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from jengolabs_auth.errors import AuthenticationError, AuthTransportError
|
|
10
|
+
from jengolabs_auth.models import AuthSessionWithGrant
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class AuthClientConfig:
|
|
15
|
+
auth_server_url: str
|
|
16
|
+
tenant_api_key: str
|
|
17
|
+
app_slug: str
|
|
18
|
+
cache_ttl_seconds: float = 0
|
|
19
|
+
timeout_seconds: float = 5.0
|
|
20
|
+
on_error: Callable[[Exception], None] | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class _CacheEntry:
|
|
25
|
+
session: AuthSessionWithGrant
|
|
26
|
+
expires_at: float
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AuthClient:
|
|
30
|
+
def __init__(self, config: AuthClientConfig) -> None:
|
|
31
|
+
self._config = config
|
|
32
|
+
self._cache: dict[str, _CacheEntry] = {}
|
|
33
|
+
self._http = httpx.AsyncClient(
|
|
34
|
+
base_url=config.auth_server_url,
|
|
35
|
+
timeout=config.timeout_seconds,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
async def verify_session(self, bearer_token_or_cookie: str) -> AuthSessionWithGrant:
|
|
39
|
+
cached = self._get_from_cache(bearer_token_or_cookie)
|
|
40
|
+
if cached is not None:
|
|
41
|
+
return cached
|
|
42
|
+
|
|
43
|
+
is_bearer = bearer_token_or_cookie.startswith("Bearer ")
|
|
44
|
+
headers: dict[str, str] = {
|
|
45
|
+
"X-Tenant-Key": self._config.tenant_api_key,
|
|
46
|
+
"X-App-Slug": self._config.app_slug,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if is_bearer:
|
|
50
|
+
headers["Authorization"] = bearer_token_or_cookie
|
|
51
|
+
else:
|
|
52
|
+
headers["Cookie"] = bearer_token_or_cookie
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
response = await self._http.get("/api/auth/session", headers=headers)
|
|
56
|
+
except httpx.HTTPError as exc:
|
|
57
|
+
error = AuthTransportError()
|
|
58
|
+
if self._config.on_error:
|
|
59
|
+
self._config.on_error(error)
|
|
60
|
+
raise error from exc
|
|
61
|
+
|
|
62
|
+
if response.status_code != 200:
|
|
63
|
+
error = AuthenticationError("Invalid or expired session")
|
|
64
|
+
if self._config.on_error:
|
|
65
|
+
self._config.on_error(error)
|
|
66
|
+
raise error
|
|
67
|
+
|
|
68
|
+
session = AuthSessionWithGrant.model_validate(response.json())
|
|
69
|
+
self._set_cache(bearer_token_or_cookie, session)
|
|
70
|
+
return session
|
|
71
|
+
|
|
72
|
+
def clear_cache(self) -> None:
|
|
73
|
+
self._cache.clear()
|
|
74
|
+
|
|
75
|
+
async def close(self) -> None:
|
|
76
|
+
await self._http.aclose()
|
|
77
|
+
|
|
78
|
+
async def __aenter__(self) -> AuthClient:
|
|
79
|
+
return self
|
|
80
|
+
|
|
81
|
+
async def __aexit__(self, *_: object) -> None:
|
|
82
|
+
await self.close()
|
|
83
|
+
|
|
84
|
+
def _get_from_cache(self, key: str) -> AuthSessionWithGrant | None:
|
|
85
|
+
if self._config.cache_ttl_seconds <= 0:
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
entry = self._cache.get(key)
|
|
89
|
+
if entry is None:
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
if time.monotonic() > entry.expires_at:
|
|
93
|
+
del self._cache[key]
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
return entry.session
|
|
97
|
+
|
|
98
|
+
def _set_cache(self, key: str, session: AuthSessionWithGrant) -> None:
|
|
99
|
+
if self._config.cache_ttl_seconds <= 0:
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
self._cache[key] = _CacheEntry(
|
|
103
|
+
session=session,
|
|
104
|
+
expires_at=time.monotonic() + self._config.cache_ttl_seconds,
|
|
105
|
+
)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AuthenticationError(Exception):
|
|
5
|
+
status_code = 401
|
|
6
|
+
|
|
7
|
+
def __init__(self, message: str = "Authentication required") -> None:
|
|
8
|
+
self.message = message
|
|
9
|
+
super().__init__(self.message)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AuthTransportError(AuthenticationError):
|
|
13
|
+
"""Raised when the auth server cannot be reached (network / timeout).
|
|
14
|
+
|
|
15
|
+
Distinct from AuthenticationError so callers can return 503 instead
|
|
16
|
+
of 401 and avoid leaking connectivity details to clients.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
status_code = 503
|
|
20
|
+
|
|
21
|
+
def __init__(self, message: str = "Authentication service unavailable") -> None:
|
|
22
|
+
super().__init__(message)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AuthorizationError(Exception):
|
|
26
|
+
status_code = 403
|
|
27
|
+
|
|
28
|
+
def __init__(self, message: str = "Insufficient permissions") -> None:
|
|
29
|
+
self.message = message
|
|
30
|
+
super().__init__(self.message)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from jengolabs_auth.fastapi.dependencies import (
|
|
2
|
+
AuthDependencies,
|
|
3
|
+
require_app_role,
|
|
4
|
+
require_auth,
|
|
5
|
+
)
|
|
6
|
+
from jengolabs_auth.fastapi.middleware import JengoAuthMiddleware
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"AuthDependencies",
|
|
10
|
+
"JengoAuthMiddleware",
|
|
11
|
+
"require_app_role",
|
|
12
|
+
"require_auth",
|
|
13
|
+
]
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Callable
|
|
4
|
+
|
|
5
|
+
from fastapi import Depends, Request
|
|
6
|
+
from fastapi.exceptions import HTTPException
|
|
7
|
+
|
|
8
|
+
from jengolabs_auth.client import AuthClient
|
|
9
|
+
from jengolabs_auth.errors import AuthenticationError, AuthorizationError, AuthTransportError
|
|
10
|
+
from jengolabs_auth.models import AuthSessionWithGrant
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AuthDependencies:
|
|
14
|
+
def __init__(self, auth_client: AuthClient) -> None:
|
|
15
|
+
self._auth_client = auth_client
|
|
16
|
+
|
|
17
|
+
async def get_session(self, request: Request) -> AuthSessionWithGrant:
|
|
18
|
+
credential = _extract_credential(request)
|
|
19
|
+
if credential is None:
|
|
20
|
+
raise HTTPException(status_code=401, detail="Authentication required")
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
session = await self._auth_client.verify_session(credential)
|
|
24
|
+
except AuthTransportError as exc:
|
|
25
|
+
raise HTTPException(
|
|
26
|
+
status_code=503, detail="Authentication service unavailable"
|
|
27
|
+
) from exc
|
|
28
|
+
except AuthenticationError as exc:
|
|
29
|
+
raise HTTPException(status_code=401, detail="Invalid or expired session") from exc
|
|
30
|
+
|
|
31
|
+
if session.app_grant is None:
|
|
32
|
+
raise HTTPException(status_code=403, detail="Access denied for this application")
|
|
33
|
+
|
|
34
|
+
return session
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def dependency(self) -> Callable[..., AuthSessionWithGrant]:
|
|
38
|
+
return self.get_session
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def require_auth(auth_client: AuthClient) -> Callable[..., AuthSessionWithGrant]:
|
|
42
|
+
deps = AuthDependencies(auth_client)
|
|
43
|
+
return Depends(deps.get_session)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def require_app_role(
|
|
47
|
+
*allowed_roles: str,
|
|
48
|
+
auth_client: AuthClient,
|
|
49
|
+
) -> Callable[..., AuthSessionWithGrant]:
|
|
50
|
+
deps = AuthDependencies(auth_client)
|
|
51
|
+
|
|
52
|
+
async def _check_role(
|
|
53
|
+
request: Request,
|
|
54
|
+
) -> AuthSessionWithGrant:
|
|
55
|
+
session = await deps.get_session(request)
|
|
56
|
+
|
|
57
|
+
if session.app_grant is None or session.app_grant.role not in allowed_roles:
|
|
58
|
+
raise HTTPException(
|
|
59
|
+
status_code=403,
|
|
60
|
+
detail=f"Required role: {' or '.join(allowed_roles)}",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
return session
|
|
64
|
+
|
|
65
|
+
return Depends(_check_role)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _extract_credential(request: Request) -> str | None:
|
|
69
|
+
auth_header = request.headers.get("Authorization")
|
|
70
|
+
if auth_header and auth_header.startswith("Bearer "):
|
|
71
|
+
return auth_header
|
|
72
|
+
|
|
73
|
+
cookie_header = request.headers.get("Cookie")
|
|
74
|
+
if cookie_header:
|
|
75
|
+
return cookie_header
|
|
76
|
+
|
|
77
|
+
return None
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
|
4
|
+
from starlette.requests import Request
|
|
5
|
+
from starlette.responses import JSONResponse, Response
|
|
6
|
+
|
|
7
|
+
from jengolabs_auth.client import AuthClient
|
|
8
|
+
from jengolabs_auth.errors import AuthenticationError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class JengoAuthMiddleware(BaseHTTPMiddleware):
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
app: object,
|
|
15
|
+
auth_client: AuthClient,
|
|
16
|
+
public_paths: list[str] | None = None,
|
|
17
|
+
) -> None:
|
|
18
|
+
super().__init__(app) # type: ignore[arg-type]
|
|
19
|
+
self._auth_client = auth_client
|
|
20
|
+
self._public_paths = public_paths or []
|
|
21
|
+
|
|
22
|
+
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
|
23
|
+
if self._is_public(request.url.path):
|
|
24
|
+
return await call_next(request)
|
|
25
|
+
|
|
26
|
+
credential = self._extract_credential(request)
|
|
27
|
+
if credential is None:
|
|
28
|
+
return JSONResponse({"error": "Authentication required"}, status_code=401)
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
session = await self._auth_client.verify_session(credential)
|
|
32
|
+
except AuthenticationError:
|
|
33
|
+
return JSONResponse({"error": "Invalid or expired session"}, status_code=401)
|
|
34
|
+
|
|
35
|
+
if session.app_grant is None:
|
|
36
|
+
return JSONResponse({"error": "Access denied for this application"}, status_code=403)
|
|
37
|
+
|
|
38
|
+
request.state.auth_user = session.user
|
|
39
|
+
request.state.auth_session = session.session
|
|
40
|
+
request.state.auth_app_grant = session.app_grant
|
|
41
|
+
request.state.auth_organization = session.organization
|
|
42
|
+
|
|
43
|
+
return await call_next(request)
|
|
44
|
+
|
|
45
|
+
def _is_public(self, path: str) -> bool:
|
|
46
|
+
return any(path.startswith(p) for p in self._public_paths)
|
|
47
|
+
|
|
48
|
+
@staticmethod
|
|
49
|
+
def _extract_credential(request: Request) -> str | None:
|
|
50
|
+
auth_header = request.headers.get("Authorization")
|
|
51
|
+
if auth_header and auth_header.startswith("Bearer "):
|
|
52
|
+
return auth_header
|
|
53
|
+
|
|
54
|
+
cookie_header = request.headers.get("Cookie")
|
|
55
|
+
if cookie_header:
|
|
56
|
+
return cookie_header
|
|
57
|
+
|
|
58
|
+
return None
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AuthUser(BaseModel):
|
|
7
|
+
id: str
|
|
8
|
+
email: str
|
|
9
|
+
name: str
|
|
10
|
+
email_verified: bool = Field(alias="emailVerified")
|
|
11
|
+
image: str | None = None
|
|
12
|
+
role: str | None = None
|
|
13
|
+
created_at: str = Field(alias="createdAt")
|
|
14
|
+
updated_at: str = Field(alias="updatedAt")
|
|
15
|
+
|
|
16
|
+
model_config = {"populate_by_name": True}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AuthSessionData(BaseModel):
|
|
20
|
+
id: str
|
|
21
|
+
token: str
|
|
22
|
+
expires_at: str = Field(alias="expiresAt")
|
|
23
|
+
|
|
24
|
+
model_config = {"populate_by_name": True}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AuthAppGrant(BaseModel):
|
|
28
|
+
app_slug: str = Field(alias="appSlug")
|
|
29
|
+
role: str
|
|
30
|
+
granted_at: str = Field(alias="grantedAt")
|
|
31
|
+
|
|
32
|
+
model_config = {"populate_by_name": True}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AuthOrganizationInfo(BaseModel):
|
|
36
|
+
id: str
|
|
37
|
+
name: str
|
|
38
|
+
role: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AuthSession(BaseModel):
|
|
42
|
+
user: AuthUser
|
|
43
|
+
session: AuthSessionData
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AuthSessionWithGrant(AuthSession):
|
|
47
|
+
app_grant: AuthAppGrant | None = Field(default=None, alias="appGrant")
|
|
48
|
+
organization: AuthOrganizationInfo | None = None
|
|
49
|
+
|
|
50
|
+
model_config = {"populate_by_name": True}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class AuthOrganization(BaseModel):
|
|
54
|
+
id: str
|
|
55
|
+
name: str
|
|
56
|
+
slug: str
|
|
57
|
+
role: str
|
|
58
|
+
members: list[OrganizationMember]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class OrganizationMember(BaseModel):
|
|
62
|
+
user_id: str = Field(alias="userId")
|
|
63
|
+
role: str
|
|
64
|
+
|
|
65
|
+
model_config = {"populate_by_name": True}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
AuthOrganization.model_rebuild()
|
|
File without changes
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "jengolabs-auth"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python SDK for Jengo Auth — session verification, FastAPI middleware, and role-based access control"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "Jengo Labs" }]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Framework :: FastAPI",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Typing :: Typed",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"httpx>=0.27.0",
|
|
27
|
+
"pydantic>=2.0.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
fastapi = ["fastapi>=0.100.0"]
|
|
32
|
+
all = ["fastapi>=0.100.0"]
|
|
33
|
+
dev = [
|
|
34
|
+
"fastapi>=0.100.0",
|
|
35
|
+
"uvicorn>=0.30.0",
|
|
36
|
+
"pytest>=8.0.0",
|
|
37
|
+
"pytest-asyncio>=0.24.0",
|
|
38
|
+
"ruff>=0.8.0",
|
|
39
|
+
"mypy>=1.13.0",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[project.urls]
|
|
43
|
+
Repository = "https://github.com/jengolabs/jen-auth"
|
|
44
|
+
Documentation = "https://github.com/jengolabs/jen-auth/blob/main/docs/dx/DX.md"
|
|
45
|
+
|
|
46
|
+
[tool.hatch.build.targets.wheel]
|
|
47
|
+
packages = ["jengolabs_auth"]
|
|
48
|
+
|
|
49
|
+
[tool.ruff]
|
|
50
|
+
target-version = "py310"
|
|
51
|
+
line-length = 100
|
|
52
|
+
|
|
53
|
+
[tool.ruff.lint]
|
|
54
|
+
select = ["E", "F", "I", "UP", "B", "SIM", "TCH"]
|
|
55
|
+
|
|
56
|
+
[tool.mypy]
|
|
57
|
+
python_version = "3.10"
|
|
58
|
+
strict = true
|
|
59
|
+
warn_return_any = true
|
|
60
|
+
warn_unused_configs = true
|
|
61
|
+
|
|
62
|
+
[tool.pytest.ini_options]
|
|
63
|
+
asyncio_mode = "auto"
|