authfort-service 0.0.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.
- authfort_service-0.0.2/.gitignore +10 -0
- authfort_service-0.0.2/.python-version +1 -0
- authfort_service-0.0.2/PKG-INFO +73 -0
- authfort_service-0.0.2/README.md +57 -0
- authfort_service-0.0.2/pyproject.toml +34 -0
- authfort_service-0.0.2/src/authfort_service/__init__.py +9 -0
- authfort_service-0.0.2/src/authfort_service/integrations/__init__.py +0 -0
- authfort_service-0.0.2/src/authfort_service/integrations/fastapi.py +49 -0
- authfort_service-0.0.2/src/authfort_service/introspect.py +120 -0
- authfort_service-0.0.2/src/authfort_service/jwks.py +119 -0
- authfort_service-0.0.2/src/authfort_service/py.typed +0 -0
- authfort_service-0.0.2/src/authfort_service/service_auth.py +102 -0
- authfort_service-0.0.2/src/authfort_service/verifier.py +104 -0
- authfort_service-0.0.2/tests/conftest.py +95 -0
- authfort_service-0.0.2/tests/test_introspect_client.py +138 -0
- authfort_service-0.0.2/tests/test_jwks_fetcher.py +115 -0
- authfort_service-0.0.2/tests/test_service_auth.py +102 -0
- authfort_service-0.0.2/tests/test_verifier.py +129 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.13
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: authfort-service
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: Lightweight JWT verification for AuthFort-powered microservices
|
|
5
|
+
Project-URL: Homepage, https://github.com/bhagyajitjagdev/authfort
|
|
6
|
+
Project-URL: Repository, https://github.com/bhagyajitjagdev/authfort
|
|
7
|
+
Author: Bhagyajit Jagdev
|
|
8
|
+
License: MIT
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Requires-Dist: cryptography>=46.0.5
|
|
11
|
+
Requires-Dist: httpx>=0.28.1
|
|
12
|
+
Requires-Dist: pyjwt[crypto]>=2.11.0
|
|
13
|
+
Provides-Extra: fastapi
|
|
14
|
+
Requires-Dist: fastapi>=0.104; extra == 'fastapi'
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# authfort-service
|
|
18
|
+
|
|
19
|
+
Lightweight JWT verification for microservices powered by AuthFort.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install authfort-service[fastapi]
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Quick Start
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from authfort_service import ServiceAuth
|
|
31
|
+
from fastapi import FastAPI, Depends
|
|
32
|
+
|
|
33
|
+
service = ServiceAuth(
|
|
34
|
+
jwks_url="https://auth.example.com/.well-known/jwks.json",
|
|
35
|
+
issuer="authfort",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
app = FastAPI()
|
|
39
|
+
|
|
40
|
+
@app.get("/api/data")
|
|
41
|
+
async def protected(user=Depends(service.current_user)):
|
|
42
|
+
return {"user_id": user.sub, "roles": user.roles}
|
|
43
|
+
|
|
44
|
+
@app.get("/api/admin")
|
|
45
|
+
async def admin_only(user=Depends(service.require_role("admin"))):
|
|
46
|
+
return {"message": "admin access"}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Features
|
|
50
|
+
|
|
51
|
+
- JWKS fetching with automatic caching and refresh
|
|
52
|
+
- JWT signature verification (RS256)
|
|
53
|
+
- Token introspection client (optional real-time validation)
|
|
54
|
+
- FastAPI integration (current_user, require_role dependencies)
|
|
55
|
+
- No database required
|
|
56
|
+
|
|
57
|
+
## With Introspection
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
service = ServiceAuth(
|
|
61
|
+
jwks_url="https://auth.example.com/.well-known/jwks.json",
|
|
62
|
+
issuer="authfort",
|
|
63
|
+
introspect_url="https://auth.example.com/auth/introspect",
|
|
64
|
+
introspect_secret="shared-secret",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# Real-time validation (checks ban status, token version, fresh roles)
|
|
68
|
+
result = await service.introspect(token)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## License
|
|
72
|
+
|
|
73
|
+
[MIT](../LICENSE)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# authfort-service
|
|
2
|
+
|
|
3
|
+
Lightweight JWT verification for microservices powered by AuthFort.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install authfort-service[fastapi]
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from authfort_service import ServiceAuth
|
|
15
|
+
from fastapi import FastAPI, Depends
|
|
16
|
+
|
|
17
|
+
service = ServiceAuth(
|
|
18
|
+
jwks_url="https://auth.example.com/.well-known/jwks.json",
|
|
19
|
+
issuer="authfort",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
app = FastAPI()
|
|
23
|
+
|
|
24
|
+
@app.get("/api/data")
|
|
25
|
+
async def protected(user=Depends(service.current_user)):
|
|
26
|
+
return {"user_id": user.sub, "roles": user.roles}
|
|
27
|
+
|
|
28
|
+
@app.get("/api/admin")
|
|
29
|
+
async def admin_only(user=Depends(service.require_role("admin"))):
|
|
30
|
+
return {"message": "admin access"}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Features
|
|
34
|
+
|
|
35
|
+
- JWKS fetching with automatic caching and refresh
|
|
36
|
+
- JWT signature verification (RS256)
|
|
37
|
+
- Token introspection client (optional real-time validation)
|
|
38
|
+
- FastAPI integration (current_user, require_role dependencies)
|
|
39
|
+
- No database required
|
|
40
|
+
|
|
41
|
+
## With Introspection
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
service = ServiceAuth(
|
|
45
|
+
jwks_url="https://auth.example.com/.well-known/jwks.json",
|
|
46
|
+
issuer="authfort",
|
|
47
|
+
introspect_url="https://auth.example.com/auth/introspect",
|
|
48
|
+
introspect_secret="shared-secret",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Real-time validation (checks ban status, token version, fresh roles)
|
|
52
|
+
result = await service.introspect(token)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## License
|
|
56
|
+
|
|
57
|
+
[MIT](../LICENSE)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "authfort-service"
|
|
3
|
+
version = "0.0.2"
|
|
4
|
+
description = "Lightweight JWT verification for AuthFort-powered microservices"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = {text = "MIT"}
|
|
7
|
+
requires-python = ">=3.11"
|
|
8
|
+
authors = [{name = "Bhagyajit Jagdev"}]
|
|
9
|
+
dependencies = [
|
|
10
|
+
"cryptography>=46.0.5",
|
|
11
|
+
"httpx>=0.28.1",
|
|
12
|
+
"pyjwt[crypto]>=2.11.0",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Homepage = "https://github.com/bhagyajitjagdev/authfort"
|
|
17
|
+
Repository = "https://github.com/bhagyajitjagdev/authfort"
|
|
18
|
+
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
fastapi = ["fastapi>=0.104"]
|
|
21
|
+
|
|
22
|
+
[dependency-groups]
|
|
23
|
+
dev = [
|
|
24
|
+
"fastapi>=0.129.0",
|
|
25
|
+
"pytest>=9.0.2",
|
|
26
|
+
"pytest-asyncio>=1.3.0",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[build-system]
|
|
30
|
+
requires = ["hatchling"]
|
|
31
|
+
build-backend = "hatchling.build"
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.wheel]
|
|
34
|
+
packages = ["src/authfort_service"]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""AuthFort Service — Lightweight JWT verification for microservices."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.0.2"
|
|
4
|
+
|
|
5
|
+
from authfort_service.introspect import IntrospectionResult
|
|
6
|
+
from authfort_service.service_auth import ServiceAuth
|
|
7
|
+
from authfort_service.verifier import TokenPayload, TokenVerificationError
|
|
8
|
+
|
|
9
|
+
__all__ = ["IntrospectionResult", "ServiceAuth", "TokenPayload", "TokenVerificationError"]
|
|
File without changes
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""FastAPI dependencies for authfort-service."""
|
|
2
|
+
|
|
3
|
+
from fastapi import Depends, HTTPException, Request
|
|
4
|
+
|
|
5
|
+
from authfort_service.verifier import JWTVerifier, TokenPayload, TokenVerificationError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def create_current_user_dep(verifier: JWTVerifier):
|
|
9
|
+
"""Create a FastAPI dependency that extracts and verifies the JWT."""
|
|
10
|
+
|
|
11
|
+
async def current_user(request: Request) -> TokenPayload:
|
|
12
|
+
auth_header = request.headers.get("Authorization")
|
|
13
|
+
if not auth_header or not auth_header.startswith("Bearer "):
|
|
14
|
+
raise HTTPException(
|
|
15
|
+
status_code=401,
|
|
16
|
+
detail={"error": "token_missing", "message": "No access token provided"},
|
|
17
|
+
)
|
|
18
|
+
token = auth_header[7:]
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
return await verifier.verify(token)
|
|
22
|
+
except TokenVerificationError as e:
|
|
23
|
+
raise HTTPException(
|
|
24
|
+
status_code=401,
|
|
25
|
+
detail={"error": e.code, "message": e.message},
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
return current_user
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def create_require_role_dep(verifier: JWTVerifier, role: str | list[str]):
|
|
32
|
+
"""Create a FastAPI dependency that requires a specific role."""
|
|
33
|
+
required_roles = [role] if isinstance(role, str) else role
|
|
34
|
+
current_user_dep = create_current_user_dep(verifier)
|
|
35
|
+
|
|
36
|
+
async def check_role(
|
|
37
|
+
user: TokenPayload = Depends(current_user_dep),
|
|
38
|
+
) -> TokenPayload:
|
|
39
|
+
if not any(r in user.roles for r in required_roles):
|
|
40
|
+
raise HTTPException(
|
|
41
|
+
status_code=403,
|
|
42
|
+
detail={
|
|
43
|
+
"error": "insufficient_role",
|
|
44
|
+
"message": f"Requires one of: {', '.join(required_roles)}",
|
|
45
|
+
},
|
|
46
|
+
)
|
|
47
|
+
return user
|
|
48
|
+
|
|
49
|
+
return check_role
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Token introspection client — calls the main AuthFort server for real-time validation.
|
|
2
|
+
|
|
3
|
+
Use this when you need to check banned status, token version, or fresh roles
|
|
4
|
+
that can't be determined from the JWT alone.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("authfort_service.introspect")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class IntrospectionResult:
|
|
18
|
+
"""Result from the introspection endpoint."""
|
|
19
|
+
|
|
20
|
+
active: bool
|
|
21
|
+
sub: str | None = None
|
|
22
|
+
email: str | None = None
|
|
23
|
+
name: str | None = None
|
|
24
|
+
roles: list[str] | None = None
|
|
25
|
+
token_version: int | None = None
|
|
26
|
+
exp: int | None = None
|
|
27
|
+
iat: int | None = None
|
|
28
|
+
iss: str | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class IntrospectionClient:
|
|
32
|
+
"""Async HTTP client for the AuthFort introspection endpoint.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
introspect_url: URL of the introspection endpoint.
|
|
36
|
+
secret: Shared secret for Authorization header (optional).
|
|
37
|
+
cache_ttl: Cache TTL in seconds (0 = no cache, default).
|
|
38
|
+
http_timeout: HTTP request timeout in seconds (default 5).
|
|
39
|
+
fail_open: If True, return inactive on network errors instead of raising.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
introspect_url: str,
|
|
45
|
+
*,
|
|
46
|
+
secret: str | None = None,
|
|
47
|
+
cache_ttl: float = 0.0,
|
|
48
|
+
http_timeout: float = 5.0,
|
|
49
|
+
fail_open: bool = False,
|
|
50
|
+
) -> None:
|
|
51
|
+
self._introspect_url = introspect_url
|
|
52
|
+
self._secret = secret
|
|
53
|
+
self._cache_ttl = cache_ttl
|
|
54
|
+
self._http_timeout = http_timeout
|
|
55
|
+
self._fail_open = fail_open
|
|
56
|
+
self._cache: dict[str, tuple[IntrospectionResult, float]] = {}
|
|
57
|
+
|
|
58
|
+
async def introspect(self, token: str) -> IntrospectionResult:
|
|
59
|
+
"""Introspect a token via the auth server.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
IntrospectionResult with active=True/False.
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
httpx.HTTPError: If auth server unreachable and fail_open is False.
|
|
66
|
+
"""
|
|
67
|
+
if self._cache_ttl > 0:
|
|
68
|
+
cached = self._cache.get(token)
|
|
69
|
+
if cached is not None:
|
|
70
|
+
result, cached_at = cached
|
|
71
|
+
if (time.monotonic() - cached_at) < self._cache_ttl:
|
|
72
|
+
return result
|
|
73
|
+
|
|
74
|
+
headers: dict[str, str] = {"Content-Type": "application/json"}
|
|
75
|
+
if self._secret:
|
|
76
|
+
headers["Authorization"] = f"Bearer {self._secret}"
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
async with httpx.AsyncClient(timeout=self._http_timeout) as client:
|
|
80
|
+
response = await client.post(
|
|
81
|
+
self._introspect_url,
|
|
82
|
+
json={"token": token},
|
|
83
|
+
headers=headers,
|
|
84
|
+
)
|
|
85
|
+
response.raise_for_status()
|
|
86
|
+
data = response.json()
|
|
87
|
+
except Exception:
|
|
88
|
+
if self._fail_open:
|
|
89
|
+
logger.warning(
|
|
90
|
+
"Introspection failed for %s, returning inactive",
|
|
91
|
+
self._introspect_url,
|
|
92
|
+
)
|
|
93
|
+
return IntrospectionResult(active=False)
|
|
94
|
+
raise
|
|
95
|
+
|
|
96
|
+
result = IntrospectionResult(
|
|
97
|
+
active=data.get("active", False),
|
|
98
|
+
sub=data.get("sub"),
|
|
99
|
+
email=data.get("email"),
|
|
100
|
+
name=data.get("name"),
|
|
101
|
+
roles=data.get("roles"),
|
|
102
|
+
token_version=data.get("token_version"),
|
|
103
|
+
exp=data.get("exp"),
|
|
104
|
+
iat=data.get("iat"),
|
|
105
|
+
iss=data.get("iss"),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
if self._cache_ttl > 0:
|
|
109
|
+
self._cache[token] = (result, time.monotonic())
|
|
110
|
+
if len(self._cache) > 1000:
|
|
111
|
+
self._evict_stale()
|
|
112
|
+
|
|
113
|
+
return result
|
|
114
|
+
|
|
115
|
+
def _evict_stale(self) -> None:
|
|
116
|
+
"""Remove expired entries from the cache."""
|
|
117
|
+
now = time.monotonic()
|
|
118
|
+
stale = [k for k, (_, t) in self._cache.items() if (now - t) > self._cache_ttl]
|
|
119
|
+
for k in stale:
|
|
120
|
+
del self._cache[k]
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""JWKS fetcher and cache — fetches public keys from the auth server.
|
|
2
|
+
|
|
3
|
+
Features:
|
|
4
|
+
- TTL-based cache (configurable, default 1 hour)
|
|
5
|
+
- Auto-refresh on unknown kid (key rotation)
|
|
6
|
+
- Rate-limited refetch (max once per min_refetch_interval)
|
|
7
|
+
- Async-safe via asyncio.Lock
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import logging
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
from jwt import PyJWK
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger("authfort_service.jwks")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class CachedJWKS:
|
|
23
|
+
"""In-memory cache of JWKS keys."""
|
|
24
|
+
|
|
25
|
+
keys: dict[str, PyJWK] = field(default_factory=dict)
|
|
26
|
+
fetched_at: float = 0.0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class JWKSFetcher:
|
|
30
|
+
"""Fetches and caches JWKS public keys from the auth server.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
jwks_url: URL of the JWKS endpoint.
|
|
34
|
+
cache_ttl: How long to cache keys in seconds (default 3600 = 1 hour).
|
|
35
|
+
min_refetch_interval: Minimum seconds between fetch attempts (default 30).
|
|
36
|
+
http_timeout: HTTP request timeout in seconds (default 10).
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
jwks_url: str,
|
|
42
|
+
*,
|
|
43
|
+
cache_ttl: float = 3600.0,
|
|
44
|
+
min_refetch_interval: float = 30.0,
|
|
45
|
+
http_timeout: float = 10.0,
|
|
46
|
+
_transport: httpx.BaseTransport | None = None,
|
|
47
|
+
) -> None:
|
|
48
|
+
self._jwks_url = jwks_url
|
|
49
|
+
self._cache_ttl = cache_ttl
|
|
50
|
+
self._min_refetch_interval = min_refetch_interval
|
|
51
|
+
self._http_timeout = http_timeout
|
|
52
|
+
self._transport = _transport
|
|
53
|
+
self._cache = CachedJWKS()
|
|
54
|
+
self._lock = asyncio.Lock()
|
|
55
|
+
self._last_fetch_attempt: float = float("-inf")
|
|
56
|
+
|
|
57
|
+
async def get_key(self, kid: str) -> PyJWK | None:
|
|
58
|
+
"""Get a public key by kid. Refreshes cache if stale."""
|
|
59
|
+
key = self._cache.keys.get(kid)
|
|
60
|
+
if key is not None and not self._is_cache_stale():
|
|
61
|
+
return key
|
|
62
|
+
await self._maybe_refresh()
|
|
63
|
+
return self._cache.keys.get(kid)
|
|
64
|
+
|
|
65
|
+
async def get_key_or_refresh(self, kid: str) -> PyJWK | None:
|
|
66
|
+
"""Get a key, forcing a refresh if kid is unknown (key rotation scenario)."""
|
|
67
|
+
key = self._cache.keys.get(kid)
|
|
68
|
+
if key is not None:
|
|
69
|
+
return key
|
|
70
|
+
await self._maybe_refresh(force=True)
|
|
71
|
+
return self._cache.keys.get(kid)
|
|
72
|
+
|
|
73
|
+
def _is_cache_stale(self) -> bool:
|
|
74
|
+
if self._cache.fetched_at == 0.0:
|
|
75
|
+
return True
|
|
76
|
+
return (time.monotonic() - self._cache.fetched_at) > self._cache_ttl
|
|
77
|
+
|
|
78
|
+
async def _maybe_refresh(self, *, force: bool = False) -> None:
|
|
79
|
+
"""Refresh the JWKS cache, respecting rate limits."""
|
|
80
|
+
now = time.monotonic()
|
|
81
|
+
if not force and not self._is_cache_stale():
|
|
82
|
+
return
|
|
83
|
+
if (now - self._last_fetch_attempt) < self._min_refetch_interval:
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
async with self._lock:
|
|
87
|
+
now = time.monotonic()
|
|
88
|
+
if (now - self._last_fetch_attempt) < self._min_refetch_interval:
|
|
89
|
+
return
|
|
90
|
+
self._last_fetch_attempt = now
|
|
91
|
+
try:
|
|
92
|
+
await self._fetch()
|
|
93
|
+
except Exception:
|
|
94
|
+
logger.exception("Failed to fetch JWKS from %s", self._jwks_url)
|
|
95
|
+
|
|
96
|
+
async def _fetch(self) -> None:
|
|
97
|
+
"""Fetch JWKS from the server and update cache."""
|
|
98
|
+
kwargs: dict = {"timeout": self._http_timeout}
|
|
99
|
+
if self._transport is not None:
|
|
100
|
+
kwargs["transport"] = self._transport
|
|
101
|
+
async with httpx.AsyncClient(**kwargs) as client:
|
|
102
|
+
response = await client.get(self._jwks_url)
|
|
103
|
+
response.raise_for_status()
|
|
104
|
+
jwks_data = response.json()
|
|
105
|
+
|
|
106
|
+
new_keys: dict[str, PyJWK] = {}
|
|
107
|
+
for key_data in jwks_data.get("keys", []):
|
|
108
|
+
try:
|
|
109
|
+
kid = key_data.get("kid")
|
|
110
|
+
if kid:
|
|
111
|
+
new_keys[kid] = PyJWK(key_data)
|
|
112
|
+
except Exception:
|
|
113
|
+
logger.warning("Failed to parse JWK with kid=%s", key_data.get("kid"))
|
|
114
|
+
|
|
115
|
+
self._cache = CachedJWKS(
|
|
116
|
+
keys=new_keys,
|
|
117
|
+
fetched_at=time.monotonic(),
|
|
118
|
+
)
|
|
119
|
+
logger.debug("JWKS refreshed: %d keys loaded", len(new_keys))
|
|
File without changes
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""ServiceAuth — main entry point for authfort-service.
|
|
2
|
+
|
|
3
|
+
Mirrors AuthFort's API style with .current_user and .require_role() dependencies.
|
|
4
|
+
No database needed — verifies JWTs using cached JWKS public keys.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from authfort_service.introspect import IntrospectionClient, IntrospectionResult
|
|
8
|
+
from authfort_service.jwks import JWKSFetcher
|
|
9
|
+
from authfort_service.verifier import JWTVerifier, TokenPayload, TokenVerificationError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ServiceAuth:
|
|
13
|
+
"""Lightweight JWT verifier for microservices.
|
|
14
|
+
|
|
15
|
+
Verifies AuthFort-issued JWTs using JWKS public keys.
|
|
16
|
+
Optionally introspects tokens for real-time checks (banned, version mismatch).
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
jwks_url: URL of the AuthFort JWKS endpoint.
|
|
20
|
+
issuer: Expected JWT issuer claim (default "authfort").
|
|
21
|
+
algorithms: Allowed JWT algorithms (default ["RS256"]).
|
|
22
|
+
jwks_cache_ttl: How long to cache JWKS keys in seconds (default 3600).
|
|
23
|
+
introspect_url: URL of the introspection endpoint (optional).
|
|
24
|
+
introspect_secret: Shared secret for introspection auth (optional).
|
|
25
|
+
introspect_cache_ttl: Cache TTL for introspection results (default 0 = no cache).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
jwks_url: str,
|
|
31
|
+
*,
|
|
32
|
+
issuer: str = "authfort",
|
|
33
|
+
algorithms: list[str] | None = None,
|
|
34
|
+
jwks_cache_ttl: float = 3600.0,
|
|
35
|
+
introspect_url: str | None = None,
|
|
36
|
+
introspect_secret: str | None = None,
|
|
37
|
+
introspect_cache_ttl: float = 0.0,
|
|
38
|
+
) -> None:
|
|
39
|
+
self._jwks_fetcher = JWKSFetcher(jwks_url, cache_ttl=jwks_cache_ttl)
|
|
40
|
+
self._verifier = JWTVerifier(
|
|
41
|
+
self._jwks_fetcher, issuer=issuer, algorithms=algorithms,
|
|
42
|
+
)
|
|
43
|
+
self._introspect_client: IntrospectionClient | None = None
|
|
44
|
+
if introspect_url is not None:
|
|
45
|
+
self._introspect_client = IntrospectionClient(
|
|
46
|
+
introspect_url,
|
|
47
|
+
secret=introspect_secret,
|
|
48
|
+
cache_ttl=introspect_cache_ttl,
|
|
49
|
+
fail_open=True,
|
|
50
|
+
)
|
|
51
|
+
self._current_user_dep = None
|
|
52
|
+
|
|
53
|
+
async def verify_token(self, token: str) -> TokenPayload:
|
|
54
|
+
"""Verify a JWT and return the decoded payload (JWKS-only, no introspection).
|
|
55
|
+
|
|
56
|
+
Raises:
|
|
57
|
+
TokenVerificationError: If verification fails.
|
|
58
|
+
"""
|
|
59
|
+
return await self._verifier.verify(token)
|
|
60
|
+
|
|
61
|
+
async def introspect(self, token: str) -> IntrospectionResult:
|
|
62
|
+
"""Introspect a token via the auth server (real-time check).
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
RuntimeError: If introspection is not configured.
|
|
66
|
+
"""
|
|
67
|
+
if self._introspect_client is None:
|
|
68
|
+
raise RuntimeError(
|
|
69
|
+
"Introspection not configured. Pass introspect_url to ServiceAuth()."
|
|
70
|
+
)
|
|
71
|
+
return await self._introspect_client.introspect(token)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def current_user(self):
|
|
75
|
+
"""FastAPI dependency: get the current authenticated user from JWT.
|
|
76
|
+
|
|
77
|
+
Uses JWKS-only verification (fast, no network call to auth server).
|
|
78
|
+
|
|
79
|
+
Usage:
|
|
80
|
+
service_auth = ServiceAuth(jwks_url="...")
|
|
81
|
+
|
|
82
|
+
@app.get("/profile")
|
|
83
|
+
async def profile(user=Depends(service_auth.current_user)):
|
|
84
|
+
print(user.email)
|
|
85
|
+
"""
|
|
86
|
+
if self._current_user_dep is None:
|
|
87
|
+
from authfort_service.integrations.fastapi import create_current_user_dep
|
|
88
|
+
|
|
89
|
+
self._current_user_dep = create_current_user_dep(self._verifier)
|
|
90
|
+
return self._current_user_dep
|
|
91
|
+
|
|
92
|
+
def require_role(self, role: str | list[str]):
|
|
93
|
+
"""FastAPI dependency factory: require a specific role.
|
|
94
|
+
|
|
95
|
+
Usage:
|
|
96
|
+
@app.get("/admin")
|
|
97
|
+
async def admin(user=Depends(service_auth.require_role("admin"))):
|
|
98
|
+
...
|
|
99
|
+
"""
|
|
100
|
+
from authfort_service.integrations.fastapi import create_require_role_dep
|
|
101
|
+
|
|
102
|
+
return create_require_role_dep(self._verifier, role)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""JWT verification using JWKS public keys — local verification, no DB needed."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
import jwt
|
|
7
|
+
|
|
8
|
+
from authfort_service.jwks import JWKSFetcher
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger("authfort_service.verifier")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TokenVerificationError(Exception):
|
|
14
|
+
"""Raised when JWT verification fails."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, message: str, code: str):
|
|
17
|
+
self.message = message
|
|
18
|
+
self.code = code
|
|
19
|
+
super().__init__(message)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class TokenPayload:
|
|
24
|
+
"""Decoded JWT payload — the user identity from a verified token."""
|
|
25
|
+
|
|
26
|
+
sub: str
|
|
27
|
+
email: str
|
|
28
|
+
name: str | None
|
|
29
|
+
roles: list[str]
|
|
30
|
+
token_version: int
|
|
31
|
+
exp: int
|
|
32
|
+
iat: int
|
|
33
|
+
iss: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class JWTVerifier:
|
|
37
|
+
"""Verifies JWT access tokens using JWKS public keys.
|
|
38
|
+
|
|
39
|
+
No database needed — pure cryptographic verification using cached public keys.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
jwks_fetcher: The JWKS fetcher for key lookup.
|
|
43
|
+
issuer: Expected JWT issuer claim (default "authfort").
|
|
44
|
+
algorithms: Allowed JWT algorithms (default ["RS256"]).
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
jwks_fetcher: JWKSFetcher,
|
|
50
|
+
*,
|
|
51
|
+
issuer: str = "authfort",
|
|
52
|
+
algorithms: list[str] | None = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
self._fetcher = jwks_fetcher
|
|
55
|
+
self._issuer = issuer
|
|
56
|
+
self._algorithms = algorithms or ["RS256"]
|
|
57
|
+
|
|
58
|
+
async def verify(self, token: str) -> TokenPayload:
|
|
59
|
+
"""Verify a JWT and return the decoded payload.
|
|
60
|
+
|
|
61
|
+
Checks: signature, expiration, issuer, required claims.
|
|
62
|
+
On unknown kid, triggers JWKS refresh (handles key rotation).
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
TokenVerificationError: If verification fails.
|
|
66
|
+
"""
|
|
67
|
+
try:
|
|
68
|
+
header = jwt.get_unverified_header(token)
|
|
69
|
+
except jwt.InvalidTokenError:
|
|
70
|
+
raise TokenVerificationError("Malformed token", "token_invalid")
|
|
71
|
+
|
|
72
|
+
kid = header.get("kid")
|
|
73
|
+
if not kid:
|
|
74
|
+
raise TokenVerificationError("Token missing kid header", "token_invalid")
|
|
75
|
+
|
|
76
|
+
jwk = await self._fetcher.get_key_or_refresh(kid)
|
|
77
|
+
if jwk is None:
|
|
78
|
+
raise TokenVerificationError("Unknown signing key", "token_invalid")
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
payload = jwt.decode(
|
|
82
|
+
token,
|
|
83
|
+
jwk.key,
|
|
84
|
+
algorithms=self._algorithms,
|
|
85
|
+
issuer=self._issuer,
|
|
86
|
+
options={"require": ["sub", "email", "roles", "ver", "exp", "iat", "iss"]},
|
|
87
|
+
)
|
|
88
|
+
except jwt.ExpiredSignatureError:
|
|
89
|
+
raise TokenVerificationError("Token has expired", "token_expired")
|
|
90
|
+
except jwt.InvalidIssuerError:
|
|
91
|
+
raise TokenVerificationError("Invalid issuer", "token_invalid")
|
|
92
|
+
except jwt.InvalidTokenError as e:
|
|
93
|
+
raise TokenVerificationError(f"Invalid token: {e}", "token_invalid")
|
|
94
|
+
|
|
95
|
+
return TokenPayload(
|
|
96
|
+
sub=payload["sub"],
|
|
97
|
+
email=payload["email"],
|
|
98
|
+
name=payload.get("name"),
|
|
99
|
+
roles=payload["roles"],
|
|
100
|
+
token_version=payload["ver"],
|
|
101
|
+
exp=payload["exp"],
|
|
102
|
+
iat=payload["iat"],
|
|
103
|
+
iss=payload["iss"],
|
|
104
|
+
)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Test fixtures for authfort-service tests.
|
|
2
|
+
|
|
3
|
+
All tests are DB-free — they generate RSA keys, create JWTs manually,
|
|
4
|
+
and mock JWKS responses using httpx MockTransport.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import uuid
|
|
9
|
+
from datetime import UTC, datetime, timedelta
|
|
10
|
+
|
|
11
|
+
import jwt
|
|
12
|
+
import pytest
|
|
13
|
+
from cryptography.hazmat.primitives import serialization
|
|
14
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
15
|
+
|
|
16
|
+
pytestmark = pytest.mark.asyncio
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture
|
|
20
|
+
def rsa_key_pair():
|
|
21
|
+
"""Generate a test RSA key pair."""
|
|
22
|
+
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
23
|
+
private_pem = private_key.private_bytes(
|
|
24
|
+
encoding=serialization.Encoding.PEM,
|
|
25
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
26
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
27
|
+
).decode("utf-8")
|
|
28
|
+
public_pem = private_key.public_key().public_bytes(
|
|
29
|
+
encoding=serialization.Encoding.PEM,
|
|
30
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
31
|
+
).decode("utf-8")
|
|
32
|
+
return private_pem, public_pem
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@pytest.fixture
|
|
36
|
+
def test_kid():
|
|
37
|
+
return f"test-key-{uuid.uuid4().hex[:8]}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@pytest.fixture
|
|
41
|
+
def jwk_from_public_key(rsa_key_pair, test_kid):
|
|
42
|
+
"""Convert the test public key to JWK format."""
|
|
43
|
+
import base64
|
|
44
|
+
|
|
45
|
+
from cryptography.hazmat.primitives.serialization import load_pem_public_key
|
|
46
|
+
|
|
47
|
+
_, public_pem = rsa_key_pair
|
|
48
|
+
public_key = load_pem_public_key(public_pem.encode("utf-8"))
|
|
49
|
+
public_numbers = public_key.public_numbers()
|
|
50
|
+
|
|
51
|
+
def _int_to_b64url(value: int) -> str:
|
|
52
|
+
byte_length = (value.bit_length() + 7) // 8
|
|
53
|
+
value_bytes = value.to_bytes(byte_length, byteorder="big")
|
|
54
|
+
return base64.urlsafe_b64encode(value_bytes).rstrip(b"=").decode("ascii")
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
"kty": "RSA",
|
|
58
|
+
"kid": test_kid,
|
|
59
|
+
"use": "sig",
|
|
60
|
+
"alg": "RS256",
|
|
61
|
+
"n": _int_to_b64url(public_numbers.n),
|
|
62
|
+
"e": _int_to_b64url(public_numbers.e),
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@pytest.fixture
|
|
67
|
+
def jwks_response(jwk_from_public_key):
|
|
68
|
+
"""A JWKS response body with one key."""
|
|
69
|
+
return {"keys": [jwk_from_public_key]}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def create_test_token(
|
|
73
|
+
private_key_pem: str,
|
|
74
|
+
kid: str,
|
|
75
|
+
*,
|
|
76
|
+
user_id: str | None = None,
|
|
77
|
+
email: str = "test@example.com",
|
|
78
|
+
roles: list[str] | None = None,
|
|
79
|
+
token_version: int = 1,
|
|
80
|
+
issuer: str = "authfort",
|
|
81
|
+
expires_in: int = 900,
|
|
82
|
+
) -> str:
|
|
83
|
+
"""Create a test JWT signed with the given private key."""
|
|
84
|
+
now = datetime.now(UTC)
|
|
85
|
+
payload = {
|
|
86
|
+
"sub": user_id or str(uuid.uuid4()),
|
|
87
|
+
"email": email,
|
|
88
|
+
"name": "Test User",
|
|
89
|
+
"roles": roles or [],
|
|
90
|
+
"ver": token_version,
|
|
91
|
+
"iat": now,
|
|
92
|
+
"exp": now + timedelta(seconds=expires_in),
|
|
93
|
+
"iss": issuer,
|
|
94
|
+
}
|
|
95
|
+
return jwt.encode(payload, private_key_pem, algorithm="RS256", headers={"kid": kid})
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Tests for the introspection client — HTTP calls, caching, and error handling."""
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from authfort_service.introspect import IntrospectionClient, IntrospectionResult
|
|
7
|
+
|
|
8
|
+
pytestmark = pytest.mark.asyncio
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _mock_introspect(response_data: dict, *, status_code: int = 200):
|
|
12
|
+
"""Create mock transport for introspection endpoint."""
|
|
13
|
+
captured_requests = []
|
|
14
|
+
|
|
15
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
16
|
+
captured_requests.append(request)
|
|
17
|
+
return httpx.Response(status_code, json=response_data)
|
|
18
|
+
|
|
19
|
+
return handler, captured_requests
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TestIntrospectionClient:
|
|
23
|
+
async def test_introspect_active_token(self, monkeypatch):
|
|
24
|
+
response = {
|
|
25
|
+
"active": True, "sub": "user-123", "email": "test@example.com",
|
|
26
|
+
"roles": ["admin"], "token_version": 1, "exp": 99999, "iat": 11111,
|
|
27
|
+
"iss": "authfort",
|
|
28
|
+
}
|
|
29
|
+
handler, _ = _mock_introspect(response)
|
|
30
|
+
|
|
31
|
+
original_init = httpx.AsyncClient.__init__
|
|
32
|
+
|
|
33
|
+
def patched_init(self_client, **kwargs):
|
|
34
|
+
kwargs["transport"] = httpx.MockTransport(handler)
|
|
35
|
+
original_init(self_client, **kwargs)
|
|
36
|
+
|
|
37
|
+
monkeypatch.setattr(httpx.AsyncClient, "__init__", patched_init)
|
|
38
|
+
|
|
39
|
+
client = IntrospectionClient("http://test/auth/introspect")
|
|
40
|
+
result = await client.introspect("some-token")
|
|
41
|
+
|
|
42
|
+
assert isinstance(result, IntrospectionResult)
|
|
43
|
+
assert result.active is True
|
|
44
|
+
assert result.email == "test@example.com"
|
|
45
|
+
assert result.roles == ["admin"]
|
|
46
|
+
|
|
47
|
+
async def test_introspect_inactive_token(self, monkeypatch):
|
|
48
|
+
handler, _ = _mock_introspect({"active": False})
|
|
49
|
+
|
|
50
|
+
original_init = httpx.AsyncClient.__init__
|
|
51
|
+
|
|
52
|
+
def patched_init(self_client, **kwargs):
|
|
53
|
+
kwargs["transport"] = httpx.MockTransport(handler)
|
|
54
|
+
original_init(self_client, **kwargs)
|
|
55
|
+
|
|
56
|
+
monkeypatch.setattr(httpx.AsyncClient, "__init__", patched_init)
|
|
57
|
+
|
|
58
|
+
client = IntrospectionClient("http://test/auth/introspect")
|
|
59
|
+
result = await client.introspect("bad-token")
|
|
60
|
+
assert result.active is False
|
|
61
|
+
|
|
62
|
+
async def test_introspect_sends_secret(self, monkeypatch):
|
|
63
|
+
handler, captured = _mock_introspect({"active": True})
|
|
64
|
+
|
|
65
|
+
original_init = httpx.AsyncClient.__init__
|
|
66
|
+
|
|
67
|
+
def patched_init(self_client, **kwargs):
|
|
68
|
+
kwargs["transport"] = httpx.MockTransport(handler)
|
|
69
|
+
original_init(self_client, **kwargs)
|
|
70
|
+
|
|
71
|
+
monkeypatch.setattr(httpx.AsyncClient, "__init__", patched_init)
|
|
72
|
+
|
|
73
|
+
client = IntrospectionClient(
|
|
74
|
+
"http://test/auth/introspect", secret="my-secret",
|
|
75
|
+
)
|
|
76
|
+
await client.introspect("token")
|
|
77
|
+
|
|
78
|
+
assert len(captured) == 1
|
|
79
|
+
assert captured[0].headers["Authorization"] == "Bearer my-secret"
|
|
80
|
+
|
|
81
|
+
async def test_introspect_cache(self, monkeypatch):
|
|
82
|
+
call_count = {"n": 0}
|
|
83
|
+
|
|
84
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
85
|
+
call_count["n"] += 1
|
|
86
|
+
return httpx.Response(200, json={"active": True, "email": "test@example.com"})
|
|
87
|
+
|
|
88
|
+
original_init = httpx.AsyncClient.__init__
|
|
89
|
+
|
|
90
|
+
def patched_init(self_client, **kwargs):
|
|
91
|
+
kwargs["transport"] = httpx.MockTransport(handler)
|
|
92
|
+
original_init(self_client, **kwargs)
|
|
93
|
+
|
|
94
|
+
monkeypatch.setattr(httpx.AsyncClient, "__init__", patched_init)
|
|
95
|
+
|
|
96
|
+
client = IntrospectionClient(
|
|
97
|
+
"http://test/auth/introspect", cache_ttl=60,
|
|
98
|
+
)
|
|
99
|
+
await client.introspect("token-1")
|
|
100
|
+
await client.introspect("token-1") # Cached
|
|
101
|
+
await client.introspect("token-1") # Cached
|
|
102
|
+
assert call_count["n"] == 1
|
|
103
|
+
|
|
104
|
+
async def test_network_error_fail_open(self, monkeypatch):
|
|
105
|
+
def error_handler(request: httpx.Request) -> httpx.Response:
|
|
106
|
+
raise httpx.ConnectError("connection refused")
|
|
107
|
+
|
|
108
|
+
original_init = httpx.AsyncClient.__init__
|
|
109
|
+
|
|
110
|
+
def patched_init(self_client, **kwargs):
|
|
111
|
+
kwargs["transport"] = httpx.MockTransport(error_handler)
|
|
112
|
+
original_init(self_client, **kwargs)
|
|
113
|
+
|
|
114
|
+
monkeypatch.setattr(httpx.AsyncClient, "__init__", patched_init)
|
|
115
|
+
|
|
116
|
+
client = IntrospectionClient(
|
|
117
|
+
"http://test/auth/introspect", fail_open=True,
|
|
118
|
+
)
|
|
119
|
+
result = await client.introspect("token")
|
|
120
|
+
assert result.active is False
|
|
121
|
+
|
|
122
|
+
async def test_network_error_fail_closed(self, monkeypatch):
|
|
123
|
+
def error_handler(request: httpx.Request) -> httpx.Response:
|
|
124
|
+
raise httpx.ConnectError("connection refused")
|
|
125
|
+
|
|
126
|
+
original_init = httpx.AsyncClient.__init__
|
|
127
|
+
|
|
128
|
+
def patched_init(self_client, **kwargs):
|
|
129
|
+
kwargs["transport"] = httpx.MockTransport(error_handler)
|
|
130
|
+
original_init(self_client, **kwargs)
|
|
131
|
+
|
|
132
|
+
monkeypatch.setattr(httpx.AsyncClient, "__init__", patched_init)
|
|
133
|
+
|
|
134
|
+
client = IntrospectionClient(
|
|
135
|
+
"http://test/auth/introspect", fail_open=False,
|
|
136
|
+
)
|
|
137
|
+
with pytest.raises(httpx.ConnectError):
|
|
138
|
+
await client.introspect("token")
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Tests for the JWKS fetcher — caching, refresh, and error handling."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from authfort_service.jwks import JWKSFetcher
|
|
9
|
+
|
|
10
|
+
pytestmark = pytest.mark.asyncio
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _make_mock_transport(jwks_response: dict, *, status_code: int = 200):
|
|
14
|
+
"""Create an httpx MockTransport that returns the given JWKS response."""
|
|
15
|
+
call_count = {"n": 0}
|
|
16
|
+
|
|
17
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
18
|
+
call_count["n"] += 1
|
|
19
|
+
return httpx.Response(status_code, json=jwks_response)
|
|
20
|
+
|
|
21
|
+
return httpx.MockTransport(handler), call_count
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TestJWKSFetcher:
|
|
25
|
+
async def test_fetch_and_cache_keys(self, jwks_response, test_kid):
|
|
26
|
+
transport, call_count = _make_mock_transport(jwks_response)
|
|
27
|
+
fetcher = JWKSFetcher(
|
|
28
|
+
"http://test/.well-known/jwks.json",
|
|
29
|
+
cache_ttl=3600,
|
|
30
|
+
_transport=transport,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
key = await fetcher.get_key(test_kid)
|
|
34
|
+
assert key is not None
|
|
35
|
+
assert call_count["n"] == 1
|
|
36
|
+
|
|
37
|
+
async def test_cache_prevents_refetch(self, jwks_response, test_kid):
|
|
38
|
+
transport, call_count = _make_mock_transport(jwks_response)
|
|
39
|
+
fetcher = JWKSFetcher(
|
|
40
|
+
"http://test/.well-known/jwks.json",
|
|
41
|
+
cache_ttl=3600,
|
|
42
|
+
_transport=transport,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
await fetcher.get_key(test_kid)
|
|
46
|
+
await fetcher.get_key(test_kid)
|
|
47
|
+
await fetcher.get_key(test_kid)
|
|
48
|
+
assert call_count["n"] == 1 # Only one fetch
|
|
49
|
+
|
|
50
|
+
async def test_unknown_kid_triggers_refresh(self, jwks_response, test_kid):
|
|
51
|
+
transport, call_count = _make_mock_transport(jwks_response)
|
|
52
|
+
fetcher = JWKSFetcher(
|
|
53
|
+
"http://test/.well-known/jwks.json",
|
|
54
|
+
cache_ttl=3600,
|
|
55
|
+
min_refetch_interval=0,
|
|
56
|
+
_transport=transport,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
await fetcher.get_key(test_kid)
|
|
60
|
+
result = await fetcher.get_key_or_refresh("unknown-kid")
|
|
61
|
+
assert result is None
|
|
62
|
+
assert call_count["n"] == 2 # Second fetch attempted
|
|
63
|
+
|
|
64
|
+
async def test_rate_limiting(self, jwks_response, test_kid):
|
|
65
|
+
transport, call_count = _make_mock_transport(jwks_response)
|
|
66
|
+
fetcher = JWKSFetcher(
|
|
67
|
+
"http://test/.well-known/jwks.json",
|
|
68
|
+
cache_ttl=0, # Always stale
|
|
69
|
+
min_refetch_interval=60, # Rate limit 60s
|
|
70
|
+
_transport=transport,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
await fetcher.get_key(test_kid) # First fetch
|
|
74
|
+
await fetcher.get_key(test_kid) # Rate limited, no fetch
|
|
75
|
+
assert call_count["n"] == 1
|
|
76
|
+
|
|
77
|
+
async def test_fetch_failure_logged(self, test_kid, caplog):
|
|
78
|
+
def error_handler(request: httpx.Request) -> httpx.Response:
|
|
79
|
+
raise httpx.ConnectError("connection refused")
|
|
80
|
+
|
|
81
|
+
transport = httpx.MockTransport(error_handler)
|
|
82
|
+
fetcher = JWKSFetcher(
|
|
83
|
+
"http://test/.well-known/jwks.json",
|
|
84
|
+
_transport=transport,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
with caplog.at_level(logging.ERROR, logger="authfort_service.jwks"):
|
|
88
|
+
result = await fetcher.get_key(test_kid)
|
|
89
|
+
assert result is None
|
|
90
|
+
assert "Failed to fetch JWKS" in caplog.text
|
|
91
|
+
|
|
92
|
+
async def test_malformed_jwk_skipped(self, test_kid):
|
|
93
|
+
jwks_with_bad_key = {
|
|
94
|
+
"keys": [
|
|
95
|
+
{"kty": "invalid", "kid": "bad-key"},
|
|
96
|
+
]
|
|
97
|
+
}
|
|
98
|
+
transport, _ = _make_mock_transport(jwks_with_bad_key)
|
|
99
|
+
fetcher = JWKSFetcher(
|
|
100
|
+
"http://test/.well-known/jwks.json",
|
|
101
|
+
_transport=transport,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
result = await fetcher.get_key("bad-key")
|
|
105
|
+
assert result is None # Bad key should be skipped
|
|
106
|
+
|
|
107
|
+
async def test_empty_jwks_response(self):
|
|
108
|
+
transport, _ = _make_mock_transport({"keys": []})
|
|
109
|
+
fetcher = JWKSFetcher(
|
|
110
|
+
"http://test/.well-known/jwks.json",
|
|
111
|
+
_transport=transport,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
result = await fetcher.get_key("any-kid")
|
|
115
|
+
assert result is None
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Tests for ServiceAuth — main entry point, FastAPI integration."""
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
import pytest
|
|
5
|
+
from fastapi import Depends, FastAPI
|
|
6
|
+
from httpx import ASGITransport, AsyncClient
|
|
7
|
+
|
|
8
|
+
from authfort_service import ServiceAuth, TokenPayload, TokenVerificationError
|
|
9
|
+
from conftest import create_test_token
|
|
10
|
+
|
|
11
|
+
pytestmark = pytest.mark.asyncio
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _patch_fetcher(monkeypatch, jwks_response):
|
|
15
|
+
"""Patch httpx.AsyncClient to return mock JWKS response.
|
|
16
|
+
|
|
17
|
+
Only patches clients that don't already have an ASGITransport
|
|
18
|
+
(so the test's own ASGI test client isn't affected).
|
|
19
|
+
"""
|
|
20
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
21
|
+
return httpx.Response(200, json=jwks_response)
|
|
22
|
+
|
|
23
|
+
original_init = httpx.AsyncClient.__init__
|
|
24
|
+
|
|
25
|
+
def patched_init(self_client, **kwargs):
|
|
26
|
+
if not isinstance(kwargs.get("transport"), ASGITransport):
|
|
27
|
+
kwargs["transport"] = httpx.MockTransport(handler)
|
|
28
|
+
original_init(self_client, **kwargs)
|
|
29
|
+
|
|
30
|
+
monkeypatch.setattr(httpx.AsyncClient, "__init__", patched_init)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TestServiceAuth:
|
|
34
|
+
async def test_verify_token(self, rsa_key_pair, test_kid, jwks_response, monkeypatch):
|
|
35
|
+
_patch_fetcher(monkeypatch, jwks_response)
|
|
36
|
+
private_pem, _ = rsa_key_pair
|
|
37
|
+
|
|
38
|
+
sa = ServiceAuth(jwks_url="http://test/.well-known/jwks.json")
|
|
39
|
+
token = create_test_token(private_pem, test_kid, email="test@example.com")
|
|
40
|
+
payload = await sa.verify_token(token)
|
|
41
|
+
|
|
42
|
+
assert isinstance(payload, TokenPayload)
|
|
43
|
+
assert payload.email == "test@example.com"
|
|
44
|
+
|
|
45
|
+
async def test_introspect_not_configured(self):
|
|
46
|
+
sa = ServiceAuth(jwks_url="http://test/.well-known/jwks.json")
|
|
47
|
+
with pytest.raises(RuntimeError, match="Introspection not configured"):
|
|
48
|
+
await sa.introspect("token")
|
|
49
|
+
|
|
50
|
+
async def test_current_user_dep(self, rsa_key_pair, test_kid, jwks_response, monkeypatch):
|
|
51
|
+
_patch_fetcher(monkeypatch, jwks_response)
|
|
52
|
+
private_pem, _ = rsa_key_pair
|
|
53
|
+
|
|
54
|
+
sa = ServiceAuth(jwks_url="http://test/.well-known/jwks.json")
|
|
55
|
+
token = create_test_token(private_pem, test_kid, email="user@example.com", roles=["user"])
|
|
56
|
+
|
|
57
|
+
app = FastAPI()
|
|
58
|
+
|
|
59
|
+
@app.get("/profile")
|
|
60
|
+
async def profile(user=Depends(sa.current_user)):
|
|
61
|
+
return {"email": user.email, "roles": user.roles}
|
|
62
|
+
|
|
63
|
+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
64
|
+
resp = await client.get("/profile", headers={"Authorization": f"Bearer {token}"})
|
|
65
|
+
assert resp.status_code == 200
|
|
66
|
+
assert resp.json()["email"] == "user@example.com"
|
|
67
|
+
|
|
68
|
+
async def test_require_role(self, rsa_key_pair, test_kid, jwks_response, monkeypatch):
|
|
69
|
+
_patch_fetcher(monkeypatch, jwks_response)
|
|
70
|
+
private_pem, _ = rsa_key_pair
|
|
71
|
+
|
|
72
|
+
sa = ServiceAuth(jwks_url="http://test/.well-known/jwks.json")
|
|
73
|
+
|
|
74
|
+
app = FastAPI()
|
|
75
|
+
|
|
76
|
+
@app.get("/admin")
|
|
77
|
+
async def admin(user=Depends(sa.require_role("admin"))):
|
|
78
|
+
return {"message": "admin access"}
|
|
79
|
+
|
|
80
|
+
admin_token = create_test_token(private_pem, test_kid, roles=["admin"])
|
|
81
|
+
user_token = create_test_token(private_pem, test_kid, roles=["user"])
|
|
82
|
+
|
|
83
|
+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
84
|
+
resp = await client.get("/admin", headers={"Authorization": f"Bearer {admin_token}"})
|
|
85
|
+
assert resp.status_code == 200
|
|
86
|
+
|
|
87
|
+
resp = await client.get("/admin", headers={"Authorization": f"Bearer {user_token}"})
|
|
88
|
+
assert resp.status_code == 403
|
|
89
|
+
|
|
90
|
+
async def test_current_user_no_token(self, rsa_key_pair, test_kid, jwks_response, monkeypatch):
|
|
91
|
+
_patch_fetcher(monkeypatch, jwks_response)
|
|
92
|
+
sa = ServiceAuth(jwks_url="http://test/.well-known/jwks.json")
|
|
93
|
+
|
|
94
|
+
app = FastAPI()
|
|
95
|
+
|
|
96
|
+
@app.get("/profile")
|
|
97
|
+
async def profile(user=Depends(sa.current_user)):
|
|
98
|
+
return {"email": user.email}
|
|
99
|
+
|
|
100
|
+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
101
|
+
resp = await client.get("/profile")
|
|
102
|
+
assert resp.status_code == 401
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Tests for the JWT verifier — token verification using JWKS keys."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from authfort_service.jwks import JWKSFetcher
|
|
9
|
+
from authfort_service.verifier import JWTVerifier, TokenPayload, TokenVerificationError
|
|
10
|
+
from conftest import create_test_token
|
|
11
|
+
|
|
12
|
+
pytestmark = pytest.mark.asyncio
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _patch_fetcher(monkeypatch, jwks_response):
|
|
16
|
+
"""Patch httpx.AsyncClient to return mock JWKS response."""
|
|
17
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
18
|
+
return httpx.Response(200, json=jwks_response)
|
|
19
|
+
|
|
20
|
+
original_init = httpx.AsyncClient.__init__
|
|
21
|
+
|
|
22
|
+
def patched_init(self_client, **kwargs):
|
|
23
|
+
kwargs["transport"] = httpx.MockTransport(handler)
|
|
24
|
+
original_init(self_client, **kwargs)
|
|
25
|
+
|
|
26
|
+
monkeypatch.setattr(httpx.AsyncClient, "__init__", patched_init)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TestJWTVerifier:
|
|
30
|
+
async def test_verify_valid_token(self, rsa_key_pair, test_kid, jwks_response, monkeypatch):
|
|
31
|
+
_patch_fetcher(monkeypatch, jwks_response)
|
|
32
|
+
private_pem, _ = rsa_key_pair
|
|
33
|
+
fetcher = JWKSFetcher("http://test/.well-known/jwks.json")
|
|
34
|
+
verifier = JWTVerifier(fetcher)
|
|
35
|
+
|
|
36
|
+
token = create_test_token(private_pem, test_kid, email="user@example.com", roles=["admin"])
|
|
37
|
+
payload = await verifier.verify(token)
|
|
38
|
+
|
|
39
|
+
assert isinstance(payload, TokenPayload)
|
|
40
|
+
assert payload.email == "user@example.com"
|
|
41
|
+
assert payload.roles == ["admin"]
|
|
42
|
+
assert payload.name == "Test User"
|
|
43
|
+
assert payload.iss == "authfort"
|
|
44
|
+
|
|
45
|
+
async def test_verify_expired_token(self, rsa_key_pair, test_kid, jwks_response, monkeypatch):
|
|
46
|
+
_patch_fetcher(monkeypatch, jwks_response)
|
|
47
|
+
private_pem, _ = rsa_key_pair
|
|
48
|
+
fetcher = JWKSFetcher("http://test/.well-known/jwks.json")
|
|
49
|
+
verifier = JWTVerifier(fetcher)
|
|
50
|
+
|
|
51
|
+
token = create_test_token(private_pem, test_kid, expires_in=-10)
|
|
52
|
+
|
|
53
|
+
with pytest.raises(TokenVerificationError, match="expired"):
|
|
54
|
+
await verifier.verify(token)
|
|
55
|
+
|
|
56
|
+
async def test_verify_wrong_issuer(self, rsa_key_pair, test_kid, jwks_response, monkeypatch):
|
|
57
|
+
_patch_fetcher(monkeypatch, jwks_response)
|
|
58
|
+
private_pem, _ = rsa_key_pair
|
|
59
|
+
fetcher = JWKSFetcher("http://test/.well-known/jwks.json")
|
|
60
|
+
verifier = JWTVerifier(fetcher, issuer="expected-issuer")
|
|
61
|
+
|
|
62
|
+
token = create_test_token(private_pem, test_kid, issuer="wrong-issuer")
|
|
63
|
+
|
|
64
|
+
with pytest.raises(TokenVerificationError, match="Invalid"):
|
|
65
|
+
await verifier.verify(token)
|
|
66
|
+
|
|
67
|
+
async def test_verify_missing_kid(self, monkeypatch, jwks_response):
|
|
68
|
+
_patch_fetcher(monkeypatch, jwks_response)
|
|
69
|
+
fetcher = JWKSFetcher("http://test/.well-known/jwks.json")
|
|
70
|
+
verifier = JWTVerifier(fetcher)
|
|
71
|
+
|
|
72
|
+
# Create a JWT without kid in header
|
|
73
|
+
import jwt
|
|
74
|
+
from datetime import UTC, datetime, timedelta
|
|
75
|
+
|
|
76
|
+
now = datetime.now(UTC)
|
|
77
|
+
token = jwt.encode(
|
|
78
|
+
{"sub": "user", "email": "test@example.com", "roles": [], "ver": 1,
|
|
79
|
+
"iat": now, "exp": now + timedelta(seconds=900), "iss": "authfort"},
|
|
80
|
+
"secret", algorithm="HS256",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
with pytest.raises(TokenVerificationError):
|
|
84
|
+
await verifier.verify(token)
|
|
85
|
+
|
|
86
|
+
async def test_verify_unknown_kid(self, rsa_key_pair, jwks_response, monkeypatch):
|
|
87
|
+
_patch_fetcher(monkeypatch, jwks_response)
|
|
88
|
+
private_pem, _ = rsa_key_pair
|
|
89
|
+
fetcher = JWKSFetcher(
|
|
90
|
+
"http://test/.well-known/jwks.json", min_refetch_interval=0,
|
|
91
|
+
)
|
|
92
|
+
verifier = JWTVerifier(fetcher)
|
|
93
|
+
|
|
94
|
+
token = create_test_token(private_pem, "unknown-kid-12345")
|
|
95
|
+
|
|
96
|
+
with pytest.raises(TokenVerificationError, match="Unknown signing key"):
|
|
97
|
+
await verifier.verify(token)
|
|
98
|
+
|
|
99
|
+
async def test_verify_malformed_token(self, monkeypatch, jwks_response):
|
|
100
|
+
_patch_fetcher(monkeypatch, jwks_response)
|
|
101
|
+
fetcher = JWKSFetcher("http://test/.well-known/jwks.json")
|
|
102
|
+
verifier = JWTVerifier(fetcher)
|
|
103
|
+
|
|
104
|
+
with pytest.raises(TokenVerificationError, match="Malformed"):
|
|
105
|
+
await verifier.verify("not.a.valid.token")
|
|
106
|
+
|
|
107
|
+
async def test_verify_returns_all_fields(self, rsa_key_pair, test_kid, jwks_response, monkeypatch):
|
|
108
|
+
_patch_fetcher(monkeypatch, jwks_response)
|
|
109
|
+
private_pem, _ = rsa_key_pair
|
|
110
|
+
fetcher = JWKSFetcher("http://test/.well-known/jwks.json")
|
|
111
|
+
verifier = JWTVerifier(fetcher)
|
|
112
|
+
|
|
113
|
+
user_id = "550e8400-e29b-41d4-a716-446655440000"
|
|
114
|
+
token = create_test_token(
|
|
115
|
+
private_pem, test_kid,
|
|
116
|
+
user_id=user_id,
|
|
117
|
+
email="hello@example.com",
|
|
118
|
+
roles=["admin", "editor"],
|
|
119
|
+
token_version=3,
|
|
120
|
+
)
|
|
121
|
+
payload = await verifier.verify(token)
|
|
122
|
+
|
|
123
|
+
assert payload.sub == user_id
|
|
124
|
+
assert payload.email == "hello@example.com"
|
|
125
|
+
assert payload.roles == ["admin", "editor"]
|
|
126
|
+
assert payload.token_version == 3
|
|
127
|
+
assert payload.iss == "authfort"
|
|
128
|
+
assert isinstance(payload.exp, int)
|
|
129
|
+
assert isinstance(payload.iat, int)
|