qx-auth 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- qx_auth-0.1.0/.gitignore +51 -0
- qx_auth-0.1.0/PKG-INFO +78 -0
- qx_auth-0.1.0/README.md +62 -0
- qx_auth-0.1.0/pyproject.toml +24 -0
- qx_auth-0.1.0/src/qx/auth/__init__.py +45 -0
- qx_auth-0.1.0/src/qx/auth/jwt/__init__.py +249 -0
- qx_auth-0.1.0/src/qx/auth/oidc/__init__.py +103 -0
- qx_auth-0.1.0/src/qx/auth/policy/__init__.py +129 -0
- qx_auth-0.1.0/src/qx/auth/py.typed +0 -0
- qx_auth-0.1.0/src/qx/auth/rate_limit/__init__.py +129 -0
- qx_auth-0.1.0/src/qx/auth/rbac/__init__.py +74 -0
- qx_auth-0.1.0/tests/test_auth_unit.py +228 -0
qx_auth-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.pyo
|
|
5
|
+
*.pyd
|
|
6
|
+
.Python
|
|
7
|
+
*.so
|
|
8
|
+
*.egg
|
|
9
|
+
*.egg-info/
|
|
10
|
+
dist/
|
|
11
|
+
build/
|
|
12
|
+
eggs/
|
|
13
|
+
.eggs/
|
|
14
|
+
sdist/
|
|
15
|
+
wheels/
|
|
16
|
+
*.egg-link
|
|
17
|
+
|
|
18
|
+
# Virtual environments
|
|
19
|
+
.venv/
|
|
20
|
+
venv/
|
|
21
|
+
env/
|
|
22
|
+
ENV/
|
|
23
|
+
|
|
24
|
+
# uv
|
|
25
|
+
.uv/
|
|
26
|
+
|
|
27
|
+
# Testing
|
|
28
|
+
.pytest_cache/
|
|
29
|
+
.coverage
|
|
30
|
+
htmlcov/
|
|
31
|
+
.tox/
|
|
32
|
+
|
|
33
|
+
# Type checking
|
|
34
|
+
.mypy_cache/
|
|
35
|
+
.ruff_cache/
|
|
36
|
+
|
|
37
|
+
# IDE
|
|
38
|
+
.idea/
|
|
39
|
+
.vscode/
|
|
40
|
+
*.swp
|
|
41
|
+
*.swo
|
|
42
|
+
|
|
43
|
+
# OS
|
|
44
|
+
.DS_Store
|
|
45
|
+
Thumbs.db
|
|
46
|
+
|
|
47
|
+
# Docker
|
|
48
|
+
*.env.local
|
|
49
|
+
|
|
50
|
+
# Dist artifacts
|
|
51
|
+
dist/
|
qx_auth-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: qx-auth
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Qx auth: JWT validation, OIDC client, RBAC primitives, policy engine, rate limiting
|
|
5
|
+
Author: Qx Engineering
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.14
|
|
8
|
+
Requires-Dist: cryptography>=43.0.0
|
|
9
|
+
Requires-Dist: httpx>=0.27.0
|
|
10
|
+
Requires-Dist: pyjwt[crypto]>=2.9.0
|
|
11
|
+
Requires-Dist: qx-cache
|
|
12
|
+
Requires-Dist: qx-core
|
|
13
|
+
Requires-Dist: qx-di
|
|
14
|
+
Requires-Dist: qx-http
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# qx-auth
|
|
18
|
+
|
|
19
|
+
JWT validation, OIDC discovery, RBAC primitives, policy engine, and token-bucket rate limiting for the Qx framework.
|
|
20
|
+
|
|
21
|
+
## What lives here
|
|
22
|
+
|
|
23
|
+
- **`qx.auth.JwtValidator`** — validates and decodes JWT access tokens. Supports RS256/ES256 via JWKS endpoint, audience and issuer validation, and a pluggable `RevocationCheck`.
|
|
24
|
+
- **`qx.auth.JwtSettings`** — Pydantic settings: JWKS URI, issuer, audience, algorithm, leeway.
|
|
25
|
+
- **`qx.auth.Principal`** — decoded token claims: `subject`, `email`, `roles`, `permissions`, `tenant_id`, raw claims dict.
|
|
26
|
+
- **`qx.auth.OidcDiscovery`** — fetches and caches the OpenID Connect discovery document (`/.well-known/openid-configuration`). Populates `JwtSettings` from the discovery endpoint automatically.
|
|
27
|
+
- **`qx.auth.OidcConfiguration`** — parsed OIDC discovery document.
|
|
28
|
+
- **`qx.auth.Role` / `Permission`** — value objects for RBAC. `Role` contains a set of `Permission` strings with wildcard matching (`orders.*` matches `orders.read`).
|
|
29
|
+
- **`qx.auth.PolicyEvaluator`** — evaluates a list of `Policy` objects against a `Principal`. Policies are composable with `require_permission`, `require_any_permission`, and `require_all_permissions`.
|
|
30
|
+
- **`qx.auth.TokenBucket`** — in-memory token-bucket rate limiter. Returns a `TokenBucketResult` with `allowed`, `remaining`, and `retry_after` — no exceptions.
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
### JWT validation in a FastAPI route
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from qx.auth import JwtValidator, Principal
|
|
38
|
+
from fastapi import Depends, HTTPException
|
|
39
|
+
from fastapi.security import HTTPBearer
|
|
40
|
+
|
|
41
|
+
security = HTTPBearer()
|
|
42
|
+
validator = JwtValidator(settings.jwt)
|
|
43
|
+
|
|
44
|
+
async def current_principal(token=Depends(security)) -> Principal:
|
|
45
|
+
result = await validator.validate(token.credentials)
|
|
46
|
+
if not result.is_success:
|
|
47
|
+
raise HTTPException(status_code=401)
|
|
48
|
+
return result.value
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Policy evaluation
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from qx.auth import PolicyEvaluator, require_permission
|
|
55
|
+
|
|
56
|
+
evaluator = PolicyEvaluator([require_permission("users.write")])
|
|
57
|
+
decision = evaluator.evaluate(principal)
|
|
58
|
+
if not decision.allowed:
|
|
59
|
+
return Result.failure(ForbiddenError(...))
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Token-bucket rate limiting
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from qx.auth import TokenBucket
|
|
66
|
+
|
|
67
|
+
bucket = TokenBucket(capacity=100, refill_rate=10) # 10 tokens/sec
|
|
68
|
+
|
|
69
|
+
result = bucket.consume(principal.subject)
|
|
70
|
+
if not result.allowed:
|
|
71
|
+
return Result.failure(RateLimitedError(retry_after=result.retry_after))
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Design rules
|
|
75
|
+
|
|
76
|
+
- `JwtValidator.validate()` returns `Result[Principal]` — it never raises. Callers decide how to translate validation failures to HTTP responses.
|
|
77
|
+
- JWKS are cached and refreshed lazily on key-ID miss so a key rotation doesn't require a restart.
|
|
78
|
+
- Permission wildcards follow a simple dot-separated scheme: `"orders.*"` grants all permissions starting with `"orders."`. Policies compose with AND (`require_all_permissions`) or OR (`require_any_permission`) semantics.
|
qx_auth-0.1.0/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# qx-auth
|
|
2
|
+
|
|
3
|
+
JWT validation, OIDC discovery, RBAC primitives, policy engine, and token-bucket rate limiting for the Qx framework.
|
|
4
|
+
|
|
5
|
+
## What lives here
|
|
6
|
+
|
|
7
|
+
- **`qx.auth.JwtValidator`** — validates and decodes JWT access tokens. Supports RS256/ES256 via JWKS endpoint, audience and issuer validation, and a pluggable `RevocationCheck`.
|
|
8
|
+
- **`qx.auth.JwtSettings`** — Pydantic settings: JWKS URI, issuer, audience, algorithm, leeway.
|
|
9
|
+
- **`qx.auth.Principal`** — decoded token claims: `subject`, `email`, `roles`, `permissions`, `tenant_id`, raw claims dict.
|
|
10
|
+
- **`qx.auth.OidcDiscovery`** — fetches and caches the OpenID Connect discovery document (`/.well-known/openid-configuration`). Populates `JwtSettings` from the discovery endpoint automatically.
|
|
11
|
+
- **`qx.auth.OidcConfiguration`** — parsed OIDC discovery document.
|
|
12
|
+
- **`qx.auth.Role` / `Permission`** — value objects for RBAC. `Role` contains a set of `Permission` strings with wildcard matching (`orders.*` matches `orders.read`).
|
|
13
|
+
- **`qx.auth.PolicyEvaluator`** — evaluates a list of `Policy` objects against a `Principal`. Policies are composable with `require_permission`, `require_any_permission`, and `require_all_permissions`.
|
|
14
|
+
- **`qx.auth.TokenBucket`** — in-memory token-bucket rate limiter. Returns a `TokenBucketResult` with `allowed`, `remaining`, and `retry_after` — no exceptions.
|
|
15
|
+
|
|
16
|
+
## Usage
|
|
17
|
+
|
|
18
|
+
### JWT validation in a FastAPI route
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from qx.auth import JwtValidator, Principal
|
|
22
|
+
from fastapi import Depends, HTTPException
|
|
23
|
+
from fastapi.security import HTTPBearer
|
|
24
|
+
|
|
25
|
+
security = HTTPBearer()
|
|
26
|
+
validator = JwtValidator(settings.jwt)
|
|
27
|
+
|
|
28
|
+
async def current_principal(token=Depends(security)) -> Principal:
|
|
29
|
+
result = await validator.validate(token.credentials)
|
|
30
|
+
if not result.is_success:
|
|
31
|
+
raise HTTPException(status_code=401)
|
|
32
|
+
return result.value
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Policy evaluation
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from qx.auth import PolicyEvaluator, require_permission
|
|
39
|
+
|
|
40
|
+
evaluator = PolicyEvaluator([require_permission("users.write")])
|
|
41
|
+
decision = evaluator.evaluate(principal)
|
|
42
|
+
if not decision.allowed:
|
|
43
|
+
return Result.failure(ForbiddenError(...))
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Token-bucket rate limiting
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from qx.auth import TokenBucket
|
|
50
|
+
|
|
51
|
+
bucket = TokenBucket(capacity=100, refill_rate=10) # 10 tokens/sec
|
|
52
|
+
|
|
53
|
+
result = bucket.consume(principal.subject)
|
|
54
|
+
if not result.allowed:
|
|
55
|
+
return Result.failure(RateLimitedError(retry_after=result.retry_after))
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Design rules
|
|
59
|
+
|
|
60
|
+
- `JwtValidator.validate()` returns `Result[Principal]` — it never raises. Callers decide how to translate validation failures to HTTP responses.
|
|
61
|
+
- JWKS are cached and refreshed lazily on key-ID miss so a key rotation doesn't require a restart.
|
|
62
|
+
- Permission wildcards follow a simple dot-separated scheme: `"orders.*"` grants all permissions starting with `"orders."`. Policies compose with AND (`require_all_permissions`) or OR (`require_any_permission`) semantics.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "qx-auth"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Qx auth: JWT validation, OIDC client, RBAC primitives, policy engine, rate limiting"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.14"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
authors = [{ name = "Qx Engineering" }]
|
|
9
|
+
dependencies = [
|
|
10
|
+
"qx-core",
|
|
11
|
+
"qx-di",
|
|
12
|
+
"qx-cache",
|
|
13
|
+
"qx-http",
|
|
14
|
+
"pyjwt[crypto]>=2.9.0",
|
|
15
|
+
"httpx>=0.27.0",
|
|
16
|
+
"cryptography>=43.0.0",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["hatchling"]
|
|
21
|
+
build-backend = "hatchling.build"
|
|
22
|
+
|
|
23
|
+
[tool.hatch.build.targets.wheel]
|
|
24
|
+
packages = ["src/qx"]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Qx auth: JWT validation, OIDC, RBAC, policies, rate limiting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from qx.auth.jwt import JwtSettings, JwtValidator, Principal, RevocationCheck
|
|
6
|
+
from qx.auth.oidc import OidcConfiguration, OidcDiscovery
|
|
7
|
+
from qx.auth.policy import (
|
|
8
|
+
Decision,
|
|
9
|
+
Policy,
|
|
10
|
+
PolicyEvaluator,
|
|
11
|
+
PolicyResult,
|
|
12
|
+
require_all_permissions,
|
|
13
|
+
require_any_permission,
|
|
14
|
+
require_permission,
|
|
15
|
+
)
|
|
16
|
+
from qx.auth.rate_limit import TokenBucket, TokenBucketResult
|
|
17
|
+
from qx.auth.rbac import Permission, Role
|
|
18
|
+
|
|
19
|
+
__version__ = "0.1.0"
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
# Policy
|
|
23
|
+
"Decision",
|
|
24
|
+
# JWT
|
|
25
|
+
"JwtSettings",
|
|
26
|
+
"JwtValidator",
|
|
27
|
+
# OIDC
|
|
28
|
+
"OidcConfiguration",
|
|
29
|
+
"OidcDiscovery",
|
|
30
|
+
# RBAC
|
|
31
|
+
"Permission",
|
|
32
|
+
"Policy",
|
|
33
|
+
"PolicyEvaluator",
|
|
34
|
+
"PolicyResult",
|
|
35
|
+
"Principal",
|
|
36
|
+
"RevocationCheck",
|
|
37
|
+
"Role",
|
|
38
|
+
# Rate limit
|
|
39
|
+
"TokenBucket",
|
|
40
|
+
"TokenBucketResult",
|
|
41
|
+
"__version__",
|
|
42
|
+
"require_all_permissions",
|
|
43
|
+
"require_any_permission",
|
|
44
|
+
"require_permission",
|
|
45
|
+
]
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""JWT validation.
|
|
2
|
+
|
|
3
|
+
Validates inbound JWTs against a configured issuer (typically Qx's own
|
|
4
|
+
IdP, or any OIDC-compliant authority). Public keys come from the issuer's
|
|
5
|
+
JWKS endpoint with TTL caching to avoid hammering the IdP on every request.
|
|
6
|
+
|
|
7
|
+
What we validate:
|
|
8
|
+
- Signature (RS256/ES256 by default; configurable list).
|
|
9
|
+
- ``iss`` matches the configured issuer.
|
|
10
|
+
- ``aud`` includes the configured audience.
|
|
11
|
+
- ``exp`` is in the future (with a small clock-skew tolerance).
|
|
12
|
+
- ``nbf`` is in the past (likewise).
|
|
13
|
+
- Optional: ``azp`` (authorized party) for OIDC.
|
|
14
|
+
|
|
15
|
+
What we don't do here:
|
|
16
|
+
- Token revocation: that needs a separate check against a revocation store
|
|
17
|
+
(Redis-backed JTI deny-list, or token introspection). Plug it in via the
|
|
18
|
+
``revocation_check`` hook.
|
|
19
|
+
|
|
20
|
+
Output: a ``Principal`` value object that downstream code consumes — never
|
|
21
|
+
the raw claims dict. Keeps the security boundary clean.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from collections.abc import Awaitable, Callable
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from typing import Any, ClassVar
|
|
29
|
+
from uuid import UUID
|
|
30
|
+
|
|
31
|
+
import httpx
|
|
32
|
+
import jwt
|
|
33
|
+
from jwt import PyJWKClient
|
|
34
|
+
from pydantic import Field
|
|
35
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
36
|
+
from qx.core import (
|
|
37
|
+
InfrastructureError,
|
|
38
|
+
Result,
|
|
39
|
+
UnauthorizedError,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"JwtSettings",
|
|
44
|
+
"JwtValidator",
|
|
45
|
+
"Principal",
|
|
46
|
+
"RevocationCheck",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True, kw_only=True)
|
|
51
|
+
class Principal:
|
|
52
|
+
"""Authenticated identity.
|
|
53
|
+
|
|
54
|
+
The result of validating a JWT — pure value object, no JWT internals leak
|
|
55
|
+
past this point. Downstream policy / RBAC code consumes the ``permissions``
|
|
56
|
+
and ``roles`` collections.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
subject: UUID
|
|
60
|
+
issuer: str
|
|
61
|
+
tenant_id: UUID | None = None
|
|
62
|
+
email: str | None = None
|
|
63
|
+
name: str | None = None
|
|
64
|
+
roles: frozenset[str] = field(default_factory=frozenset)
|
|
65
|
+
permissions: frozenset[str] = field(default_factory=frozenset)
|
|
66
|
+
scopes: frozenset[str] = field(default_factory=frozenset)
|
|
67
|
+
raw_claims: dict[str, Any] = field(default_factory=dict)
|
|
68
|
+
|
|
69
|
+
def has_role(self, role: str) -> bool:
|
|
70
|
+
return role in self.roles
|
|
71
|
+
|
|
72
|
+
def has_permission(self, permission: str) -> bool:
|
|
73
|
+
return permission in self.permissions
|
|
74
|
+
|
|
75
|
+
def has_scope(self, scope: str) -> bool:
|
|
76
|
+
return scope in self.scopes
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
RevocationCheck = Callable[[str], Awaitable[bool]]
|
|
80
|
+
"""Async function taking a JTI; returns True if revoked."""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class JwtSettings(BaseSettings):
|
|
84
|
+
model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict(
|
|
85
|
+
env_prefix="QX_AUTH__JWT__",
|
|
86
|
+
extra="ignore",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
issuer: str = Field(default="https://qx.local")
|
|
90
|
+
jwks_uri: str | None = None # if None, fetch from {issuer}/.well-known/jwks.json
|
|
91
|
+
audience: str = "qx"
|
|
92
|
+
allowed_algorithms: tuple[str, ...] = ("RS256", "ES256")
|
|
93
|
+
leeway_seconds: int = 30
|
|
94
|
+
cache_keys_ttl_seconds: int = 3600
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class JwtValidator:
|
|
98
|
+
"""Validate JWTs against a configured OIDC-compatible issuer."""
|
|
99
|
+
|
|
100
|
+
def __init__(
|
|
101
|
+
self,
|
|
102
|
+
settings: JwtSettings,
|
|
103
|
+
*,
|
|
104
|
+
revocation_check: RevocationCheck | None = None,
|
|
105
|
+
http_client: httpx.AsyncClient | None = None,
|
|
106
|
+
) -> None:
|
|
107
|
+
self._settings = settings
|
|
108
|
+
self._revocation_check = revocation_check
|
|
109
|
+
self._http = http_client or httpx.AsyncClient(timeout=5.0)
|
|
110
|
+
self._jwks_uri = settings.jwks_uri or f"{settings.issuer.rstrip('/')}/.well-known/jwks.json"
|
|
111
|
+
# PyJWKClient caches keys internally with built-in TTL semantics.
|
|
112
|
+
self._jwk_client = PyJWKClient(
|
|
113
|
+
self._jwks_uri,
|
|
114
|
+
cache_keys=True,
|
|
115
|
+
lifespan=settings.cache_keys_ttl_seconds,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
async def validate(self, token: str) -> Result[Principal]: # noqa: PLR0911
|
|
119
|
+
# Pull the right key for this token's `kid` header.
|
|
120
|
+
try:
|
|
121
|
+
signing_key = self._jwk_client.get_signing_key_from_jwt(token).key
|
|
122
|
+
except jwt.exceptions.PyJWKClientError as exc:
|
|
123
|
+
return Result.failure(
|
|
124
|
+
InfrastructureError(
|
|
125
|
+
code="auth.jwks_unavailable",
|
|
126
|
+
message=f"could not fetch signing key: {exc}",
|
|
127
|
+
cause=exc,
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
except Exception as exc:
|
|
131
|
+
return Result.failure(
|
|
132
|
+
UnauthorizedError(
|
|
133
|
+
code="auth.invalid_token",
|
|
134
|
+
message="token signature could not be verified",
|
|
135
|
+
cause=exc,
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
claims = jwt.decode(
|
|
141
|
+
token,
|
|
142
|
+
key=signing_key,
|
|
143
|
+
algorithms=list(self._settings.allowed_algorithms),
|
|
144
|
+
audience=self._settings.audience,
|
|
145
|
+
issuer=self._settings.issuer,
|
|
146
|
+
leeway=self._settings.leeway_seconds,
|
|
147
|
+
options={"require": ["exp", "iat", "iss", "aud", "sub"]},
|
|
148
|
+
)
|
|
149
|
+
except jwt.ExpiredSignatureError as exc:
|
|
150
|
+
return Result.failure(
|
|
151
|
+
UnauthorizedError(
|
|
152
|
+
code="auth.token_expired",
|
|
153
|
+
message="token expired",
|
|
154
|
+
cause=exc,
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
except jwt.InvalidAudienceError as exc:
|
|
158
|
+
return Result.failure(
|
|
159
|
+
UnauthorizedError(
|
|
160
|
+
code="auth.invalid_audience",
|
|
161
|
+
message="token audience does not include this service",
|
|
162
|
+
cause=exc,
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
except jwt.InvalidIssuerError as exc:
|
|
166
|
+
return Result.failure(
|
|
167
|
+
UnauthorizedError(
|
|
168
|
+
code="auth.invalid_issuer",
|
|
169
|
+
message="token issuer is not trusted",
|
|
170
|
+
cause=exc,
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
except jwt.InvalidTokenError as exc:
|
|
174
|
+
return Result.failure(
|
|
175
|
+
UnauthorizedError(
|
|
176
|
+
code="auth.invalid_token",
|
|
177
|
+
message=f"token rejected: {exc}",
|
|
178
|
+
cause=exc,
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# Revocation hook.
|
|
183
|
+
jti = claims.get("jti")
|
|
184
|
+
if jti and self._revocation_check is not None:
|
|
185
|
+
try:
|
|
186
|
+
if await self._revocation_check(jti):
|
|
187
|
+
return Result.failure(
|
|
188
|
+
UnauthorizedError(
|
|
189
|
+
code="auth.token_revoked",
|
|
190
|
+
message="token has been revoked",
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
except Exception as exc:
|
|
194
|
+
# Fail closed on revocation-check infrastructure errors.
|
|
195
|
+
return Result.failure(
|
|
196
|
+
InfrastructureError(
|
|
197
|
+
code="auth.revocation_check_failed",
|
|
198
|
+
message=f"revocation check failed: {exc}",
|
|
199
|
+
cause=exc,
|
|
200
|
+
)
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# Map claims to Principal. Convention follows OIDC + a few Qx
|
|
204
|
+
# extensions (qx:permissions, qx:tenant).
|
|
205
|
+
try:
|
|
206
|
+
sub = UUID(claims["sub"])
|
|
207
|
+
except (KeyError, ValueError) as exc:
|
|
208
|
+
return Result.failure(
|
|
209
|
+
UnauthorizedError(
|
|
210
|
+
code="auth.invalid_subject",
|
|
211
|
+
message="sub claim is missing or not a UUID",
|
|
212
|
+
cause=exc,
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
tenant_raw = claims.get("qx:tenant") or claims.get("tenant_id")
|
|
217
|
+
tenant_id: UUID | None = None
|
|
218
|
+
if tenant_raw:
|
|
219
|
+
try:
|
|
220
|
+
tenant_id = UUID(tenant_raw)
|
|
221
|
+
except ValueError:
|
|
222
|
+
# Tenant present but malformed — treat as unauth rather than ignore.
|
|
223
|
+
return Result.failure(
|
|
224
|
+
UnauthorizedError(
|
|
225
|
+
code="auth.invalid_tenant",
|
|
226
|
+
message="tenant_id claim is malformed",
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
roles = claims.get("qx:roles") or claims.get("roles") or []
|
|
231
|
+
permissions = claims.get("qx:permissions") or claims.get("permissions") or []
|
|
232
|
+
scopes_raw = claims.get("scope") or claims.get("scp") or ""
|
|
233
|
+
scopes = scopes_raw.split() if isinstance(scopes_raw, str) else list(scopes_raw)
|
|
234
|
+
|
|
235
|
+
principal = Principal(
|
|
236
|
+
subject=sub,
|
|
237
|
+
issuer=claims["iss"],
|
|
238
|
+
tenant_id=tenant_id,
|
|
239
|
+
email=claims.get("email"),
|
|
240
|
+
name=claims.get("name") or claims.get("preferred_username"),
|
|
241
|
+
roles=frozenset(roles),
|
|
242
|
+
permissions=frozenset(permissions),
|
|
243
|
+
scopes=frozenset(scopes),
|
|
244
|
+
raw_claims=claims,
|
|
245
|
+
)
|
|
246
|
+
return Result.success(principal)
|
|
247
|
+
|
|
248
|
+
async def aclose(self) -> None:
|
|
249
|
+
await self._http.aclose()
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""OIDC discovery client.
|
|
2
|
+
|
|
3
|
+
For services that need full OIDC discovery (not just JWT validation against a
|
|
4
|
+
known JWKS URI), this fetches the issuer's well-known config and exposes
|
|
5
|
+
endpoints with sensible TTL caching.
|
|
6
|
+
|
|
7
|
+
Used by:
|
|
8
|
+
|
|
9
|
+
- ``JwtValidator`` when no ``jwks_uri`` is configured — fall back to discovery.
|
|
10
|
+
- Services that initiate authorization code flows (rare server-to-server, but
|
|
11
|
+
used by admin tooling and console BFFs).
|
|
12
|
+
|
|
13
|
+
Out of scope: the actual auth-code flow, PKCE state management, token storage.
|
|
14
|
+
Qx's hosted UI handles browser-flow auth; this module is just the
|
|
15
|
+
plumbing for backend services to validate the resulting tokens.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import time
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import TYPE_CHECKING, Any
|
|
24
|
+
|
|
25
|
+
from qx.core import InfrastructureError, Result
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
import httpx
|
|
29
|
+
|
|
30
|
+
__all__ = ["OidcConfiguration", "OidcDiscovery"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class OidcConfiguration:
|
|
35
|
+
issuer: str
|
|
36
|
+
authorization_endpoint: str | None
|
|
37
|
+
token_endpoint: str | None
|
|
38
|
+
userinfo_endpoint: str | None
|
|
39
|
+
jwks_uri: str
|
|
40
|
+
end_session_endpoint: str | None
|
|
41
|
+
introspection_endpoint: str | None
|
|
42
|
+
raw: dict[str, Any]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class OidcDiscovery:
|
|
46
|
+
"""Fetches and caches OIDC ``.well-known`` configuration."""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
http_client: httpx.AsyncClient,
|
|
51
|
+
*,
|
|
52
|
+
cache_ttl_seconds: int = 3600,
|
|
53
|
+
) -> None:
|
|
54
|
+
self._http = http_client
|
|
55
|
+
self._ttl = cache_ttl_seconds
|
|
56
|
+
self._cache: dict[str, tuple[float, OidcConfiguration]] = {}
|
|
57
|
+
self._lock = asyncio.Lock()
|
|
58
|
+
|
|
59
|
+
async def fetch(self, issuer: str) -> Result[OidcConfiguration]:
|
|
60
|
+
now = time.monotonic()
|
|
61
|
+
cached = self._cache.get(issuer)
|
|
62
|
+
if cached is not None and (now - cached[0]) < self._ttl:
|
|
63
|
+
return Result.success(cached[1])
|
|
64
|
+
|
|
65
|
+
async with self._lock:
|
|
66
|
+
# Double-check inside the lock.
|
|
67
|
+
cached = self._cache.get(issuer)
|
|
68
|
+
if cached is not None and (now - cached[0]) < self._ttl:
|
|
69
|
+
return Result.success(cached[1])
|
|
70
|
+
url = f"{issuer.rstrip('/')}/.well-known/openid-configuration"
|
|
71
|
+
try:
|
|
72
|
+
resp = await self._http.get(url, timeout=5.0)
|
|
73
|
+
resp.raise_for_status()
|
|
74
|
+
except Exception as exc:
|
|
75
|
+
return Result.failure(
|
|
76
|
+
InfrastructureError(
|
|
77
|
+
code="oidc.discovery_failed",
|
|
78
|
+
message=f"could not fetch {url}: {exc}",
|
|
79
|
+
cause=exc,
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
data = resp.json()
|
|
83
|
+
try:
|
|
84
|
+
config = OidcConfiguration(
|
|
85
|
+
issuer=data["issuer"],
|
|
86
|
+
authorization_endpoint=data.get("authorization_endpoint"),
|
|
87
|
+
token_endpoint=data.get("token_endpoint"),
|
|
88
|
+
userinfo_endpoint=data.get("userinfo_endpoint"),
|
|
89
|
+
jwks_uri=data["jwks_uri"],
|
|
90
|
+
end_session_endpoint=data.get("end_session_endpoint"),
|
|
91
|
+
introspection_endpoint=data.get("introspection_endpoint"),
|
|
92
|
+
raw=data,
|
|
93
|
+
)
|
|
94
|
+
except KeyError as exc:
|
|
95
|
+
return Result.failure(
|
|
96
|
+
InfrastructureError(
|
|
97
|
+
code="oidc.malformed_discovery",
|
|
98
|
+
message=f"required discovery field missing: {exc}",
|
|
99
|
+
cause=exc,
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
self._cache[issuer] = (now, config)
|
|
103
|
+
return Result.success(config)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Policy evaluator.
|
|
2
|
+
|
|
3
|
+
The policy layer answers: "is this principal allowed to perform this action
|
|
4
|
+
on this resource?" Two flavors:
|
|
5
|
+
|
|
6
|
+
- ``require_permission(perm)`` — the bread-and-butter RBAC check.
|
|
7
|
+
- ``Policy`` — a composable predicate over (Principal, Resource) for cases
|
|
8
|
+
where pure RBAC isn't enough (e.g., "user can edit a comment if they own
|
|
9
|
+
it or have the ``moderate`` permission").
|
|
10
|
+
|
|
11
|
+
Decisions are ``Allow`` / ``Deny``. Deny wins on conflict — this is the
|
|
12
|
+
standard ABAC / Zanzibar convention; it's also less surprising for
|
|
13
|
+
developers reading auth code.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from enum import Enum
|
|
20
|
+
from typing import TYPE_CHECKING, Any
|
|
21
|
+
|
|
22
|
+
from qx.auth.rbac import Permission
|
|
23
|
+
from qx.core import ForbiddenError, Result
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from collections.abc import Awaitable, Callable
|
|
27
|
+
|
|
28
|
+
from qx.auth.jwt import Principal
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"Decision",
|
|
32
|
+
"Policy",
|
|
33
|
+
"PolicyEvaluator",
|
|
34
|
+
"PolicyResult",
|
|
35
|
+
"require_all_permissions",
|
|
36
|
+
"require_any_permission",
|
|
37
|
+
"require_permission",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Decision(Enum):
|
|
42
|
+
ALLOW = "allow"
|
|
43
|
+
DENY = "deny"
|
|
44
|
+
NOT_APPLICABLE = "not_applicable"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class PolicyResult:
|
|
49
|
+
decision: Decision
|
|
50
|
+
reason: str | None = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class Policy:
|
|
55
|
+
"""A named policy with an async predicate."""
|
|
56
|
+
|
|
57
|
+
name: str
|
|
58
|
+
rule: Callable[[Principal, Any], Awaitable[PolicyResult]]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def require_permission(permission: str) -> Policy:
|
|
62
|
+
"""Policy that allows iff the principal has the named permission."""
|
|
63
|
+
perm = Permission(name=permission)
|
|
64
|
+
|
|
65
|
+
async def _rule(p: Principal, _resource: Any) -> PolicyResult:
|
|
66
|
+
if perm.matches(p.permissions):
|
|
67
|
+
return PolicyResult(Decision.ALLOW)
|
|
68
|
+
return PolicyResult(Decision.DENY, reason=f"missing required permission: {permission}")
|
|
69
|
+
|
|
70
|
+
return Policy(name=f"require_permission({permission})", rule=_rule)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def require_any_permission(*permissions: str) -> Policy:
|
|
74
|
+
perms = [Permission(name=p) for p in permissions]
|
|
75
|
+
|
|
76
|
+
async def _rule(p: Principal, _resource: Any) -> PolicyResult:
|
|
77
|
+
if any(perm.matches(p.permissions) for perm in perms):
|
|
78
|
+
return PolicyResult(Decision.ALLOW)
|
|
79
|
+
return PolicyResult(
|
|
80
|
+
Decision.DENY,
|
|
81
|
+
reason=f"missing any of: {', '.join(permissions)}",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
return Policy(name=f"require_any({permissions})", rule=_rule)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def require_all_permissions(*permissions: str) -> Policy:
|
|
88
|
+
perms = [Permission(name=p) for p in permissions]
|
|
89
|
+
|
|
90
|
+
async def _rule(p: Principal, _resource: Any) -> PolicyResult:
|
|
91
|
+
missing = [perm.name for perm in perms if not perm.matches(p.permissions)]
|
|
92
|
+
if missing:
|
|
93
|
+
return PolicyResult(
|
|
94
|
+
Decision.DENY,
|
|
95
|
+
reason=f"missing required permissions: {', '.join(missing)}",
|
|
96
|
+
)
|
|
97
|
+
return PolicyResult(Decision.ALLOW)
|
|
98
|
+
|
|
99
|
+
return Policy(name=f"require_all({permissions})", rule=_rule)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class PolicyEvaluator:
|
|
103
|
+
"""Compose policies and evaluate them against a principal."""
|
|
104
|
+
|
|
105
|
+
def __init__(self, *policies: Policy) -> None:
|
|
106
|
+
self._policies = policies
|
|
107
|
+
|
|
108
|
+
async def evaluate(
|
|
109
|
+
self,
|
|
110
|
+
principal: Principal,
|
|
111
|
+
resource: Any = None,
|
|
112
|
+
) -> Result[None]:
|
|
113
|
+
"""Return success if all policies allow; failure otherwise.
|
|
114
|
+
|
|
115
|
+
Deny semantics: as soon as any policy returns DENY, we short-circuit
|
|
116
|
+
with that reason. NOT_APPLICABLE policies are skipped (a policy that
|
|
117
|
+
doesn't have an opinion on this principal is treated as silent).
|
|
118
|
+
"""
|
|
119
|
+
for policy in self._policies:
|
|
120
|
+
outcome = await policy.rule(principal, resource)
|
|
121
|
+
if outcome.decision is Decision.DENY:
|
|
122
|
+
return Result.failure(
|
|
123
|
+
ForbiddenError(
|
|
124
|
+
code="authz.denied",
|
|
125
|
+
message=outcome.reason or f"denied by {policy.name}",
|
|
126
|
+
details={"policy": policy.name},
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
return Result.success(None)
|
|
File without changes
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Token-bucket rate limiter backed by Redis.
|
|
2
|
+
|
|
3
|
+
A token bucket holds N tokens; consumers take a token to perform an action.
|
|
4
|
+
Tokens refill at rate R per second up to capacity N. If the bucket is empty,
|
|
5
|
+
the action is rejected with ``Result.failure(RateLimitedError(...))``.
|
|
6
|
+
|
|
7
|
+
Implementation: Lua script that atomically computes the current bucket
|
|
8
|
+
contents from a stored ``(last_refill_at, last_count)`` pair, decides whether
|
|
9
|
+
to take a token, and writes back. The Lua script avoids the read-then-write
|
|
10
|
+
race that plagues naive implementations.
|
|
11
|
+
|
|
12
|
+
Buckets are namespaced by ``(scope, key)`` — e.g., ``("login", user_id)`` or
|
|
13
|
+
``("api", api_key)``. Same scope, same refill rate; different rates need
|
|
14
|
+
different bucket names.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import time
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import TYPE_CHECKING
|
|
22
|
+
|
|
23
|
+
from qx.core import RateLimitedError, Result
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
import redis.asyncio as aioredis
|
|
27
|
+
|
|
28
|
+
__all__ = ["TokenBucket", "TokenBucketResult"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_LUA_TAKE = """
|
|
32
|
+
-- KEYS[1] = bucket key
|
|
33
|
+
-- ARGV[1] = capacity, ARGV[2] = refill_per_second, ARGV[3] = now (float seconds)
|
|
34
|
+
-- ARGV[4] = take (int), ARGV[5] = ttl (int)
|
|
35
|
+
local capacity = tonumber(ARGV[1])
|
|
36
|
+
local refill = tonumber(ARGV[2])
|
|
37
|
+
local now = tonumber(ARGV[3])
|
|
38
|
+
local take = tonumber(ARGV[4])
|
|
39
|
+
local ttl = tonumber(ARGV[5])
|
|
40
|
+
|
|
41
|
+
local data = redis.call('HMGET', KEYS[1], 'count', 'last')
|
|
42
|
+
local count = tonumber(data[1])
|
|
43
|
+
local last = tonumber(data[2])
|
|
44
|
+
|
|
45
|
+
if count == nil then
|
|
46
|
+
count = capacity
|
|
47
|
+
last = now
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
local elapsed = now - last
|
|
51
|
+
if elapsed > 0 then
|
|
52
|
+
count = math.min(capacity, count + (elapsed * refill))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
local allowed = 0
|
|
56
|
+
if count >= take then
|
|
57
|
+
count = count - take
|
|
58
|
+
allowed = 1
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
redis.call('HMSET', KEYS[1], 'count', count, 'last', now)
|
|
62
|
+
redis.call('EXPIRE', KEYS[1], ttl)
|
|
63
|
+
return {allowed, count}
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class TokenBucketResult:
|
|
69
|
+
allowed: bool
|
|
70
|
+
remaining: float
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class TokenBucket:
|
|
74
|
+
"""Token-bucket rate limiter for one (scope, key) pair."""
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
client: aioredis.Redis,
|
|
79
|
+
*,
|
|
80
|
+
scope: str,
|
|
81
|
+
capacity: int,
|
|
82
|
+
refill_per_second: float,
|
|
83
|
+
namespace: str = "qx:rl",
|
|
84
|
+
) -> None:
|
|
85
|
+
self._r = client
|
|
86
|
+
self._scope = scope
|
|
87
|
+
self._capacity = capacity
|
|
88
|
+
self._refill = refill_per_second
|
|
89
|
+
self._ns = namespace
|
|
90
|
+
|
|
91
|
+
def _key(self, key: str) -> str:
|
|
92
|
+
return f"{self._ns}:{self._scope}:{key}"
|
|
93
|
+
|
|
94
|
+
async def take(self, key: str, *, tokens: int = 1) -> TokenBucketResult:
|
|
95
|
+
# TTL should be long enough that an idle key stays around until it
|
|
96
|
+
# would fully refill — otherwise we'd lose state and grant new
|
|
97
|
+
# capacity. ``capacity / refill_per_second`` * 2 is a safe default.
|
|
98
|
+
ttl = max(int((self._capacity / max(self._refill, 0.001)) * 2), 60)
|
|
99
|
+
result = await self._r.eval( # type: ignore[misc]
|
|
100
|
+
_LUA_TAKE,
|
|
101
|
+
1,
|
|
102
|
+
self._key(key),
|
|
103
|
+
str(self._capacity),
|
|
104
|
+
str(self._refill),
|
|
105
|
+
str(time.time()),
|
|
106
|
+
str(tokens),
|
|
107
|
+
str(ttl),
|
|
108
|
+
)
|
|
109
|
+
allowed = bool(result[0])
|
|
110
|
+
remaining = float(result[1])
|
|
111
|
+
return TokenBucketResult(allowed=allowed, remaining=remaining)
|
|
112
|
+
|
|
113
|
+
async def check(self, key: str, *, tokens: int = 1) -> Result[None]:
|
|
114
|
+
"""Take a token and convert the outcome to a ``Result``."""
|
|
115
|
+
outcome = await self.take(key, tokens=tokens)
|
|
116
|
+
if outcome.allowed:
|
|
117
|
+
return Result.success(None)
|
|
118
|
+
return Result.failure(
|
|
119
|
+
RateLimitedError(
|
|
120
|
+
code="rate_limit.exceeded",
|
|
121
|
+
message=f"rate limit exceeded for {self._scope}:{key}",
|
|
122
|
+
details={
|
|
123
|
+
"scope": self._scope,
|
|
124
|
+
"remaining": outcome.remaining,
|
|
125
|
+
"capacity": self._capacity,
|
|
126
|
+
"refill_per_second": self._refill,
|
|
127
|
+
},
|
|
128
|
+
)
|
|
129
|
+
)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""RBAC primitives.
|
|
2
|
+
|
|
3
|
+
Permissions are dotted strings::
|
|
4
|
+
|
|
5
|
+
organization.read
|
|
6
|
+
organization.users.invite
|
|
7
|
+
billing.invoice.refund
|
|
8
|
+
|
|
9
|
+
Convention:
|
|
10
|
+
- First segment = resource scope (organization, billing, …)
|
|
11
|
+
- Last segment = action (read, write, delete, invite, refund, …)
|
|
12
|
+
- Wildcards allowed at any segment: ``billing.*`` (anything on billing),
|
|
13
|
+
``organization.users.*`` (anything on users in an organization).
|
|
14
|
+
|
|
15
|
+
Roles are named bundles of permissions. The policy layer is permission-
|
|
16
|
+
centric, not role-centric — roles are user-facing labels; permissions are
|
|
17
|
+
what we actually check.
|
|
18
|
+
|
|
19
|
+
Matching:
|
|
20
|
+
- ``user.permissions`` resolves any wildcards at issue-time (JWT contains
|
|
21
|
+
expanded permissions, not roles → permissions; the IdP does that). The
|
|
22
|
+
matcher here just checks whether ``required`` is in the set.
|
|
23
|
+
- Policy code calls ``Permission.matches(required, granted_set)`` which
|
|
24
|
+
handles literal + wildcard matches.
|
|
25
|
+
|
|
26
|
+
Why not a generic ABAC engine here? ABAC is great but overkill for V1; you
|
|
27
|
+
implement it via a custom ``Behavior`` that pulls request attributes and runs
|
|
28
|
+
your own rules. The RBAC primitive is the 80% case.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from dataclasses import dataclass
|
|
34
|
+
|
|
35
|
+
__all__ = ["Permission", "Role"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class Permission:
|
|
40
|
+
"""A required permission name."""
|
|
41
|
+
|
|
42
|
+
name: str
|
|
43
|
+
|
|
44
|
+
def matches(self, granted: frozenset[str]) -> bool:
|
|
45
|
+
"""Check if any granted permission satisfies this requirement.
|
|
46
|
+
|
|
47
|
+
``granted`` is the principal's expanded permission set. Wildcards in
|
|
48
|
+
granted permissions (``billing.*``) match any required permission
|
|
49
|
+
starting with the same prefix.
|
|
50
|
+
"""
|
|
51
|
+
if self.name in granted:
|
|
52
|
+
return True
|
|
53
|
+
# Wildcard matching — walk granted set looking for ``prefix.*`` patterns.
|
|
54
|
+
for g in granted:
|
|
55
|
+
if g.endswith(".*"):
|
|
56
|
+
prefix = g[:-2]
|
|
57
|
+
if self.name == prefix or self.name.startswith(prefix + "."):
|
|
58
|
+
return True
|
|
59
|
+
if g == "*":
|
|
60
|
+
return True
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class Role:
|
|
66
|
+
"""A named bundle of permissions.
|
|
67
|
+
|
|
68
|
+
The role-to-permissions mapping lives in the IdP (Qx itself). This
|
|
69
|
+
type is a convenience for downstream code that wants to reason about role
|
|
70
|
+
names — but actual authorization decisions check permissions, not roles.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
name: str
|
|
74
|
+
permissions: frozenset[Permission] = frozenset()
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Auth unit tests.
|
|
2
|
+
|
|
3
|
+
Generates RSA key pair in-memory, mints test JWTs, exercises ``JwtValidator``
|
|
4
|
+
against them. No live IdP, no live Redis — pure logic.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from typing import Any
|
|
11
|
+
from uuid import uuid4
|
|
12
|
+
|
|
13
|
+
import jwt
|
|
14
|
+
import pytest
|
|
15
|
+
from cryptography.hazmat.primitives import serialization
|
|
16
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
17
|
+
from qx.auth import (
|
|
18
|
+
Decision,
|
|
19
|
+
JwtSettings,
|
|
20
|
+
JwtValidator,
|
|
21
|
+
Permission,
|
|
22
|
+
PolicyEvaluator,
|
|
23
|
+
Principal,
|
|
24
|
+
require_all_permissions,
|
|
25
|
+
require_any_permission,
|
|
26
|
+
require_permission,
|
|
27
|
+
)
|
|
28
|
+
from qx.auth.policy import Policy, PolicyResult
|
|
29
|
+
|
|
30
|
+
# ---- RSA key pair + JWKS fixture ----
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.fixture(scope="module")
|
|
34
|
+
def rsa_key() -> Any:
|
|
35
|
+
return rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.fixture(scope="module")
|
|
39
|
+
def private_pem(rsa_key: Any) -> bytes:
|
|
40
|
+
return rsa_key.private_bytes(
|
|
41
|
+
encoding=serialization.Encoding.PEM,
|
|
42
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
43
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@pytest.fixture(scope="module")
|
|
48
|
+
def public_pem(rsa_key: Any) -> bytes:
|
|
49
|
+
return rsa_key.public_key().public_bytes(
|
|
50
|
+
encoding=serialization.Encoding.PEM,
|
|
51
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _make_token(private_pem: bytes, **overrides: Any) -> str:
|
|
56
|
+
now = int(time.time())
|
|
57
|
+
claims = {
|
|
58
|
+
"iss": "https://test.qx",
|
|
59
|
+
"aud": "qx",
|
|
60
|
+
"sub": str(uuid4()),
|
|
61
|
+
"iat": now,
|
|
62
|
+
"exp": now + 60,
|
|
63
|
+
"email": "ada@example.com",
|
|
64
|
+
"name": "Ada Lovelace",
|
|
65
|
+
"qx:permissions": ["organization.read", "billing.invoice.read"],
|
|
66
|
+
"qx:roles": ["admin"],
|
|
67
|
+
"scope": "openid profile",
|
|
68
|
+
**overrides,
|
|
69
|
+
}
|
|
70
|
+
return jwt.encode(claims, private_pem, algorithm="RS256", headers={"kid": "test"})
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class _StubJwkClient:
|
|
74
|
+
"""Stand-in for PyJWKClient that just returns our static key."""
|
|
75
|
+
|
|
76
|
+
def __init__(self, signing_key: Any) -> None:
|
|
77
|
+
self._key = signing_key
|
|
78
|
+
|
|
79
|
+
def get_signing_key_from_jwt(self, _token: str) -> Any:
|
|
80
|
+
class _K:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
k = _K()
|
|
84
|
+
k.key = self._key
|
|
85
|
+
return k
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ---- JWT validation ----
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def test_valid_token_yields_principal(private_pem: bytes, public_pem: bytes) -> None:
|
|
92
|
+
settings = JwtSettings(issuer="https://test.qx", audience="qx")
|
|
93
|
+
validator = JwtValidator(settings)
|
|
94
|
+
validator._jwk_client = _StubJwkClient(public_pem)
|
|
95
|
+
token = _make_token(private_pem)
|
|
96
|
+
result = await validator.validate(token)
|
|
97
|
+
assert result.is_success
|
|
98
|
+
p = result.value
|
|
99
|
+
assert p.email == "ada@example.com"
|
|
100
|
+
assert "admin" in p.roles
|
|
101
|
+
assert Permission(name="organization.read").matches(p.permissions)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def test_expired_token_rejected(private_pem: bytes, public_pem: bytes) -> None:
|
|
105
|
+
settings = JwtSettings(issuer="https://test.qx", audience="qx", leeway_seconds=0)
|
|
106
|
+
validator = JwtValidator(settings)
|
|
107
|
+
validator._jwk_client = _StubJwkClient(public_pem)
|
|
108
|
+
token = _make_token(private_pem, exp=int(time.time()) - 60, iat=int(time.time()) - 120)
|
|
109
|
+
result = await validator.validate(token)
|
|
110
|
+
assert result.is_failure
|
|
111
|
+
assert result.error.code == "auth.token_expired"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def test_wrong_audience_rejected(private_pem: bytes, public_pem: bytes) -> None:
|
|
115
|
+
settings = JwtSettings(issuer="https://test.qx", audience="qx")
|
|
116
|
+
validator = JwtValidator(settings)
|
|
117
|
+
validator._jwk_client = _StubJwkClient(public_pem)
|
|
118
|
+
token = _make_token(private_pem, aud="someone-else")
|
|
119
|
+
result = await validator.validate(token)
|
|
120
|
+
assert result.is_failure
|
|
121
|
+
assert result.error.code == "auth.invalid_audience"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
async def test_wrong_issuer_rejected(private_pem: bytes, public_pem: bytes) -> None:
|
|
125
|
+
settings = JwtSettings(issuer="https://test.qx", audience="qx")
|
|
126
|
+
validator = JwtValidator(settings)
|
|
127
|
+
validator._jwk_client = _StubJwkClient(public_pem)
|
|
128
|
+
token = _make_token(private_pem, iss="https://imposter")
|
|
129
|
+
result = await validator.validate(token)
|
|
130
|
+
assert result.is_failure
|
|
131
|
+
assert result.error.code == "auth.invalid_issuer"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
async def test_invalid_signature_rejected(private_pem: bytes, public_pem: bytes) -> None:
|
|
135
|
+
# Generate a *different* keypair and sign with it; validator has the original public key.
|
|
136
|
+
rogue = rsa.generate_private_key(public_exponent=65537, key_size=2048).private_bytes(
|
|
137
|
+
encoding=serialization.Encoding.PEM,
|
|
138
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
139
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
140
|
+
)
|
|
141
|
+
settings = JwtSettings(issuer="https://test.qx", audience="qx")
|
|
142
|
+
validator = JwtValidator(settings)
|
|
143
|
+
validator._jwk_client = _StubJwkClient(public_pem)
|
|
144
|
+
token = _make_token(rogue)
|
|
145
|
+
result = await validator.validate(token)
|
|
146
|
+
assert result.is_failure
|
|
147
|
+
assert result.error.code == "auth.invalid_token"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
async def test_revoked_token_rejected(private_pem: bytes, public_pem: bytes) -> None:
|
|
151
|
+
async def revoked(_jti: str) -> bool:
|
|
152
|
+
return True
|
|
153
|
+
|
|
154
|
+
settings = JwtSettings(issuer="https://test.qx", audience="qx")
|
|
155
|
+
validator = JwtValidator(settings, revocation_check=revoked)
|
|
156
|
+
validator._jwk_client = _StubJwkClient(public_pem)
|
|
157
|
+
token = _make_token(private_pem, jti=str(uuid4()))
|
|
158
|
+
result = await validator.validate(token)
|
|
159
|
+
assert result.is_failure
|
|
160
|
+
assert result.error.code == "auth.token_revoked"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ---- RBAC ----
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_permission_literal_match() -> None:
|
|
167
|
+
assert Permission(name="billing.read").matches(frozenset({"billing.read"})) is True
|
|
168
|
+
assert Permission(name="billing.read").matches(frozenset({"billing.write"})) is False
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_permission_wildcard_match() -> None:
|
|
172
|
+
granted = frozenset({"billing.*"})
|
|
173
|
+
assert Permission(name="billing.read").matches(granted) is True
|
|
174
|
+
assert Permission(name="billing.invoice.refund").matches(granted) is True
|
|
175
|
+
assert Permission(name="organization.read").matches(granted) is False
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_permission_global_wildcard() -> None:
|
|
179
|
+
assert Permission(name="anything").matches(frozenset({"*"})) is True
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ---- Policy evaluator ----
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _principal(*permissions: str) -> Principal:
|
|
186
|
+
return Principal(
|
|
187
|
+
subject=uuid4(),
|
|
188
|
+
issuer="test",
|
|
189
|
+
permissions=frozenset(permissions),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
async def test_require_permission_allows_when_present() -> None:
|
|
194
|
+
e = PolicyEvaluator(require_permission("billing.read"))
|
|
195
|
+
r = await e.evaluate(_principal("billing.read"))
|
|
196
|
+
assert r.is_success
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
async def test_require_permission_denies_when_absent() -> None:
|
|
200
|
+
e = PolicyEvaluator(require_permission("billing.read"))
|
|
201
|
+
r = await e.evaluate(_principal("organization.read"))
|
|
202
|
+
assert r.is_failure
|
|
203
|
+
assert r.error.code == "authz.denied"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
async def test_require_any_permission() -> None:
|
|
207
|
+
e = PolicyEvaluator(require_any_permission("billing.read", "billing.read.limited"))
|
|
208
|
+
r = await e.evaluate(_principal("billing.read.limited"))
|
|
209
|
+
assert r.is_success
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
async def test_require_all_permissions() -> None:
|
|
213
|
+
e = PolicyEvaluator(require_all_permissions("billing.read", "billing.export"))
|
|
214
|
+
r = await e.evaluate(_principal("billing.read"))
|
|
215
|
+
assert r.is_failure
|
|
216
|
+
assert "billing.export" in (r.error.message or "")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
async def test_custom_policy() -> None:
|
|
220
|
+
async def owner_only(p: Principal, resource: Any) -> PolicyResult:
|
|
221
|
+
if resource.get("owner_id") == str(p.subject):
|
|
222
|
+
return PolicyResult(Decision.ALLOW)
|
|
223
|
+
return PolicyResult(Decision.DENY, reason="not owner")
|
|
224
|
+
|
|
225
|
+
e = PolicyEvaluator(Policy(name="owner_only", rule=owner_only))
|
|
226
|
+
user = _principal()
|
|
227
|
+
assert (await e.evaluate(user, {"owner_id": str(user.subject)})).is_success
|
|
228
|
+
assert (await e.evaluate(user, {"owner_id": str(uuid4())})).is_failure
|