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.
@@ -0,0 +1,10 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
@@ -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"]
@@ -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)