sonnet-auth 0.1.0__py3-none-any.whl

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,92 @@
1
+ """sonnet-auth -- JWT/JWKS authentication and Cedar authorization for sonnet-server.
2
+
3
+ Public API:
4
+
5
+ AuthN (JWT/JWKS):
6
+ AuthSettings -- settings model with resolve(env_prefix, seed)
7
+ AuthnConfig -- JWT/JWKS config (Pydantic, frozen)
8
+ AuthMode -- jwt_verifier | oauth_proxy
9
+ JwtCredentialValidator -- implements sonnet-server CredentialValidator protocol
10
+ build_auth_context -- JWT claims -> AuthContext
11
+ resolve_attributes -- JWT claims -> Cedar principal attributes
12
+ resolve_dot_path -- nested claim extraction
13
+
14
+ AuthZ (Cedar, requires sonnet-auth[cedar]):
15
+ AuthzDeniedError -- raised by check_authz on deny; transports map to 403
16
+ check_authz -- raises AuthzDeniedError on deny, no-op if disabled
17
+ filter_authz -- returns permitted resource IDs
18
+ resolve_principal_attrs -- resolves JWT claims + enrichment -> Cedar attrs
19
+ set_resource_attribute_resolver -- register domain-specific resolver
20
+ set_principal_enricher -- register principal enricher (org hierarchy, etc.)
21
+ ResourceAttributeResolver -- protocol for resource attr lookup
22
+ PrincipalEnricher -- protocol for principal attr enrichment
23
+ PolicyEngine -- Cedar policy evaluator (check, filter, reload)
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from typing import TYPE_CHECKING
29
+
30
+ # AuthN -- always available
31
+ from sonnet_auth.context import build_auth_context, resolve_attributes, resolve_dot_path
32
+ from sonnet_auth.jwt_validator import JwtCredentialValidator
33
+ from sonnet_auth.settings import AuthMode, AuthnConfig, AuthSettings
34
+
35
+ if TYPE_CHECKING:
36
+ # Expose Cedar types for static analysis tools (mypy, pyright, IDEs).
37
+ # These are lazily imported at runtime to avoid cedarpy dependency
38
+ # when only authn is used.
39
+ from sonnet_auth.authz import (
40
+ AuthzDeniedError,
41
+ PrincipalEnricher,
42
+ ResourceAttributeResolver,
43
+ check_authz,
44
+ filter_authz,
45
+ resolve_principal_attrs,
46
+ set_principal_enricher,
47
+ set_resource_attribute_resolver,
48
+ )
49
+ from sonnet_auth.policy_engine import PolicyEngine
50
+
51
+ __all__ = [
52
+ # AuthN
53
+ "AuthMode",
54
+ "AuthnConfig",
55
+ "AuthSettings",
56
+ "JwtCredentialValidator",
57
+ "build_auth_context",
58
+ "resolve_attributes",
59
+ "resolve_dot_path",
60
+ # AuthZ (lazy -- imported on use to avoid cedarpy import at module level)
61
+ "AuthzDeniedError",
62
+ "PolicyEngine",
63
+ "PrincipalEnricher",
64
+ "ResourceAttributeResolver",
65
+ "check_authz",
66
+ "filter_authz",
67
+ "resolve_principal_attrs",
68
+ "set_principal_enricher",
69
+ "set_resource_attribute_resolver",
70
+ ]
71
+
72
+
73
+ def __getattr__(name: str):
74
+ """Lazy imports for Cedar authz -- avoids cedarpy import when only using authn."""
75
+ if name == "PolicyEngine":
76
+ from sonnet_auth.policy_engine import PolicyEngine
77
+
78
+ return PolicyEngine
79
+ if name in (
80
+ "AuthzDeniedError",
81
+ "PrincipalEnricher",
82
+ "ResourceAttributeResolver",
83
+ "check_authz",
84
+ "filter_authz",
85
+ "resolve_principal_attrs",
86
+ "set_principal_enricher",
87
+ "set_resource_attribute_resolver",
88
+ ):
89
+ from sonnet_auth import authz
90
+
91
+ return getattr(authz, name)
92
+ raise AttributeError(f"module 'sonnet_auth' has no attribute {name!r}")
sonnet_auth/authz.py ADDED
@@ -0,0 +1,221 @@
1
+ """AuthZ helpers — one-liner authorization for REST handlers and MCP tools.
2
+
3
+ This module is the single entry point for authorization checks. It hides
4
+ all internal plumbing (claim resolution, principal enrichment, Cedar
5
+ evaluation) behind two stable functions:
6
+
7
+ check_authz() — raises 403 on deny, no-op if authz disabled
8
+ filter_authz() — returns permitted resource IDs, returns all if disabled
9
+
10
+ Resource attribute resolution is pluggable via ``ResourceAttributeResolver``.
11
+ Register the consumer's implementation once at startup:
12
+
13
+ from sonnet_auth.authz import set_resource_attribute_resolver
14
+ set_resource_attribute_resolver(PropertiesCacheResolver())
15
+
16
+ The caller API (check_authz / filter_authz) never changes regardless of the
17
+ resolver, enrichment, or Cedar evaluation happening behind the scenes.
18
+
19
+ Internal flow:
20
+ 1. get_policy_engine() → engine or None (no-op if disabled)
21
+ 2. get_current_auth() → AuthContext (identity from JWT)
22
+ 3. resolve_attributes(claims, claim_map) → base attrs from token
23
+ 4. PrincipalEnricher.enrich() → enriched attrs (if registered)
24
+ 5. resolver.resolve(type, id) → resource attrs
25
+ 6. engine.check() / engine.filter() → Cedar evaluation
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from typing import Any, Protocol
31
+
32
+ from loguru import logger
33
+
34
+ from sonnet_auth.context import resolve_attributes
35
+ from sonnet_auth.policy_engine import PolicyEngine
36
+ from sonnet_server.guards import get_current_auth
37
+ from sonnet_server.services.registry import get_service_registry
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # ResourceAttributeResolver — pluggable resource attribute lookup
41
+ # ---------------------------------------------------------------------------
42
+
43
+
44
+ class ResourceAttributeResolver(Protocol):
45
+ """Maps a resource type + ID to Cedar entity attributes.
46
+
47
+ Implement this protocol in each consumer and register it at startup
48
+ via ``set_resource_attribute_resolver()``. coco-rag's implementation
49
+ uses ``PropertiesCache`` backed by the PostgreSQL settings table.
50
+
51
+ Returns an empty dict when no attributes are found — Cedar policies
52
+ that require ABAC attributes will deny by default.
53
+ """
54
+
55
+ def resolve(self, resource_type: str, resource_id: str) -> dict[str, Any]: ...
56
+
57
+
58
+ _resource_attribute_resolver: ResourceAttributeResolver | None = None
59
+
60
+
61
+ def set_resource_attribute_resolver(resolver: ResourceAttributeResolver) -> None:
62
+ """Register the resource attribute resolver. Call once at startup."""
63
+ global _resource_attribute_resolver # noqa: PLW0603
64
+ _resource_attribute_resolver = resolver
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # PrincipalEnricher — pluggable principal attribute enrichment
69
+ # ---------------------------------------------------------------------------
70
+
71
+
72
+ class PrincipalEnricher(Protocol):
73
+ """Enriches resolved JWT claim attributes before Cedar evaluation.
74
+
75
+ Implement this protocol to add org hierarchy, group expansion, or remote
76
+ attribute lookups. Register it via ``set_principal_enricher()``.
77
+
78
+ The default (no enricher registered) is a no-op — attrs pass through
79
+ unchanged.
80
+ """
81
+
82
+ def enrich(self, user_id: str, attrs: dict[str, Any]) -> dict[str, Any]: ...
83
+
84
+
85
+ _principal_enricher: PrincipalEnricher | None = None
86
+
87
+
88
+ def set_principal_enricher(enricher: PrincipalEnricher) -> None:
89
+ """Register the principal enricher. Call once at startup."""
90
+ global _principal_enricher # noqa: PLW0603
91
+ _principal_enricher = enricher
92
+
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # Engine resolution
96
+ # ---------------------------------------------------------------------------
97
+
98
+
99
+ def get_policy_engine() -> PolicyEngine | None:
100
+ """Return PolicyEngine if authz enabled, None if not configured."""
101
+ try:
102
+ return get_service_registry().get(PolicyEngine)
103
+ except KeyError:
104
+ return None
105
+
106
+
107
+ def resolve_principal_attrs(engine: PolicyEngine, user_id: str, claims: dict) -> dict[str, Any]:
108
+ """Resolve claim_map attrs and run principal enrichment.
109
+
110
+ Both ``claim_map`` and ``auto_map_claims`` come from the engine (set at
111
+ construction from AuthnConfig). No DB access — safe in tests and CI.
112
+
113
+ Public — used by the /authz/check dry-run endpoint.
114
+ """
115
+ attrs = resolve_attributes(claims, engine.claim_map, auto_map_claims=engine.auto_map_claims)
116
+ if _principal_enricher is not None:
117
+ attrs = _principal_enricher.enrich(user_id, attrs)
118
+ logger.trace("Principal attrs resolved: user={} attrs={}", user_id, attrs)
119
+ return attrs
120
+
121
+
122
+ # ---------------------------------------------------------------------------
123
+ # Resource attribute resolution
124
+ # ---------------------------------------------------------------------------
125
+
126
+
127
+ def _resolve_resource_attrs(resource_type: str, resource_id: str, resource_attrs: dict[str, Any] | None) -> dict[str, Any]:
128
+ """Resolve resource attributes — explicit attrs take priority, then resolver."""
129
+ if resource_attrs is not None:
130
+ return resource_attrs
131
+ if _resource_attribute_resolver is not None:
132
+ return _resource_attribute_resolver.resolve(resource_type, resource_id)
133
+ return {}
134
+
135
+
136
+ # ---------------------------------------------------------------------------
137
+ # Public API — stable forever
138
+ # ---------------------------------------------------------------------------
139
+
140
+
141
+ class AuthzDeniedError(Exception):
142
+ """Raised when Cedar policy evaluation denies an action.
143
+
144
+ Transport layers (REST, MCP) catch this and convert it to the appropriate
145
+ response -- ``HTTPException(403)`` for REST, a tool error for MCP.
146
+
147
+ In practice, sonnet-server's REST handlers register an exception handler
148
+ that converts ``AuthzDeniedError`` to 403, so callers that use
149
+ ``check_authz`` in REST handlers need not catch it explicitly.
150
+ """
151
+
152
+ def __init__(self, action: str, resource_type: str, resource_id: str) -> None:
153
+ super().__init__(f"Forbidden: {action} on {resource_type}/{resource_id}")
154
+ self.action = action
155
+ self.resource_type = resource_type
156
+ self.resource_id = resource_id
157
+
158
+
159
+ def check_authz(
160
+ action: str,
161
+ resource_type: str,
162
+ resource_id: str,
163
+ resource_attrs: dict[str, Any] | None = None,
164
+ ) -> None:
165
+ """Check Cedar policy — raises AuthzDeniedError on deny, no-op if authz disabled.
166
+
167
+ Call at the top of any REST handler or MCP tool that accesses a resource.
168
+ Resource properties are resolved automatically via the registered
169
+ ``ResourceAttributeResolver`` unless ``resource_attrs`` is explicitly provided.
170
+
171
+ Callers in REST handlers do not need to catch ``AuthzDeniedError`` —
172
+ the transport layer converts it to HTTP 403 via the registered exception
173
+ handler. MCP tool handlers should catch it and return a tool error.
174
+ """
175
+ engine = get_policy_engine()
176
+ if not engine:
177
+ return
178
+ auth = get_current_auth()
179
+ principal_attrs = resolve_principal_attrs(engine, auth.user_id, auth.claims)
180
+ resolved_attrs = _resolve_resource_attrs(resource_type, resource_id, resource_attrs)
181
+ if not engine.check(auth.user_id, principal_attrs, action, resource_type, resource_id, resolved_attrs):
182
+ logger.info(
183
+ "AuthZ denied: user={} action={} resource={}/{}{}",
184
+ auth.user_id,
185
+ action,
186
+ resource_type,
187
+ resource_id,
188
+ f" resource_attrs={resolved_attrs}" if resolved_attrs else "",
189
+ )
190
+ raise AuthzDeniedError(action, resource_type, resource_id)
191
+
192
+
193
+ def filter_authz(
194
+ action: str,
195
+ resource_type: str,
196
+ resources: list[tuple[str, dict[str, Any]]] | list[str],
197
+ ) -> set[str]:
198
+ """Filter to permitted resource IDs — returns all IDs if authz disabled.
199
+
200
+ Accepts either ``[(id, attrs), ...]`` or ``[id, ...]``. When attrs are
201
+ not provided, they are resolved automatically via the registered
202
+ ``ResourceAttributeResolver``. Enrichment is applied once per call.
203
+ """
204
+ if not resources:
205
+ return set()
206
+
207
+ engine = get_policy_engine()
208
+ if not engine:
209
+ if isinstance(resources[0], str):
210
+ return set(resources)
211
+ return {rid for rid, _ in resources}
212
+
213
+ # Normalise input to [(id, attrs)] pairs
214
+ if isinstance(resources[0], str):
215
+ pairs = [(rid, _resolve_resource_attrs(resource_type, rid, None)) for rid in resources]
216
+ else:
217
+ pairs = [(rid, _resolve_resource_attrs(resource_type, rid, attrs)) for rid, attrs in resources]
218
+
219
+ auth = get_current_auth()
220
+ principal_attrs = resolve_principal_attrs(engine, auth.user_id, auth.claims)
221
+ return set(engine.filter(auth.user_id, principal_attrs, action, resource_type, pairs))
sonnet_auth/context.py ADDED
@@ -0,0 +1,133 @@
1
+ """Shared claim-to-AuthContext mapping used by both REST and MCP transports.
2
+
3
+ Both ``JwtCredentialValidator`` (REST) and ``AuthBridgeMiddleware`` (MCP)
4
+ produce an ``AuthContext`` from a validated JWT's claim dict. This module
5
+ centralises that mapping so any future field change requires a single edit.
6
+
7
+ The OIDC-standard ``sub`` claim is always mapped to ``user_id``/``username``.
8
+ Additional claims are extracted via ``claim_map`` (dot-path → attribute name)
9
+ from ``AuthConfig``. Mapped attributes named ``email`` or ``tenant_id``
10
+ populate the corresponding ``AuthContext`` fields; all others are available
11
+ to the Cedar policy engine via ``resolve_attributes()``.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Mapping
17
+ from types import MappingProxyType
18
+ from typing import Any
19
+
20
+ from sonnet_server.guards import AuthContext
21
+
22
+ # JWT standard/registered claims excluded from auto-mapping — these are
23
+ # protocol plumbing, not domain attributes meaningful to Cedar policies.
24
+ _JWT_PLUMBING: frozenset[str] = frozenset({"sub", "iat", "exp", "nbf", "iss", "aud", "jti"})
25
+
26
+
27
+ def resolve_dot_path(data: Mapping, path: str) -> Any | None:
28
+ """Walk a dot-delimited path into a nested mapping, returning None on miss.
29
+
30
+ Accepts any ``Mapping`` (dict, MappingProxyType, etc.) so callers can
31
+ pass raw JWT dicts or the frozen ``AuthContext.claims`` proxy directly.
32
+
33
+ **Limitation:** returns ``None`` both when a path is absent *and* when a
34
+ leaf value is explicitly ``None``. Callers cannot distinguish the two cases.
35
+ A claim value that is legitimately ``None`` in the JWT will be treated as
36
+ absent. This is an accepted constraint — JWT claims used as Cedar attributes
37
+ should carry a meaningful value; ``None`` is not a valid Cedar attribute value.
38
+
39
+ >>> resolve_dot_path({"a": {"b": [1, 2]}}, "a.b")
40
+ [1, 2]
41
+ >>> resolve_dot_path({"a": 1}, "a.b.c") is None
42
+ True
43
+ """
44
+ current: Any = data
45
+ for segment in path.split("."):
46
+ if not isinstance(current, Mapping):
47
+ return None
48
+ current = current.get(segment)
49
+ if current is None:
50
+ return None
51
+ return current
52
+
53
+
54
+ _SENTINEL = object()
55
+
56
+
57
+ def resolve_attributes(
58
+ claims: Mapping,
59
+ claim_map: dict[str, str],
60
+ auto_map_claims: bool = False,
61
+ ) -> dict[str, Any]:
62
+ """Resolve JWT claims to Cedar principal attributes.
63
+
64
+ When ``auto_map_claims`` is True, all top-level claims are included
65
+ as attributes using their original name (except ``sub``, ``iat``,
66
+ ``exp``, ``nbf``, ``iss``, ``aud``, ``jti`` which are JWT plumbing).
67
+ Falsy but present values (``False``, ``0``, ``[]``, ``""``) are included.
68
+ Explicit ``claim_map`` entries take priority over auto-mapped names,
69
+ allowing renames and dot-path extraction of nested claims.
70
+
71
+ When ``auto_map_claims`` is False (default), only claims listed in
72
+ ``claim_map`` are visible.
73
+ """
74
+ attrs: dict[str, Any] = {}
75
+
76
+ # Auto-map: pass through all top-level claims (except JWT plumbing).
77
+ # Include falsy-but-present values (e.g. active=False, count=0).
78
+ # Skip only None and absent keys.
79
+ if auto_map_claims:
80
+ for key, value in claims.items():
81
+ if key not in _JWT_PLUMBING and value is not None:
82
+ attrs[key] = value
83
+
84
+ # Explicit claim_map: overrides auto-mapped names, supports dot-paths.
85
+ # resolve_dot_path returns None for both absent keys and None values;
86
+ # we use a sentinel to distinguish "key present but falsy" from "key absent".
87
+ for attr_name, dot_path in claim_map.items():
88
+ # Walk the path manually to detect absent vs None leaf
89
+ current: Any = claims
90
+ found = True
91
+ for segment in dot_path.split("."):
92
+ if not isinstance(current, Mapping):
93
+ found = False
94
+ break
95
+ current = current.get(segment, _SENTINEL)
96
+ if current is _SENTINEL:
97
+ found = False
98
+ break
99
+ if found and current is not None:
100
+ attrs[attr_name] = current
101
+
102
+ return attrs
103
+
104
+
105
+ def build_auth_context(
106
+ claims: dict,
107
+ claim_map: dict[str, str] | None = None,
108
+ auto_map_claims: bool = False,
109
+ ) -> AuthContext:
110
+ """Build an ``AuthContext`` from a validated JWT claim dict.
111
+
112
+ The OIDC-standard ``sub`` claim is always mapped to ``user_id`` and
113
+ ``username``. Additional claims are extracted via ``claim_map`` and
114
+ ``auto_map_claims``:
115
+
116
+ - ``email`` → ``AuthContext.email`` (when mapped)
117
+ - ``tenant_id`` → ``AuthContext.tenant_id`` (when mapped)
118
+ - All others remain available to Cedar via ``resolve_attributes()``
119
+ at evaluation time (re-derived from ``claims`` + ``claim_map``).
120
+
121
+ Precondition: ``claims`` must come from a token that has already been
122
+ validated (signature, expiry, issuer, audience).
123
+ """
124
+ sub = claims["sub"]
125
+ attrs = resolve_attributes(claims, claim_map or {}, auto_map_claims=auto_map_claims)
126
+
127
+ return AuthContext(
128
+ user_id=sub,
129
+ username=sub,
130
+ email=attrs.get("email"),
131
+ tenant_id=attrs.get("tenant_id"),
132
+ claims=MappingProxyType(claims),
133
+ )
@@ -0,0 +1,136 @@
1
+ """JwtCredentialValidator — validates JWT bearer tokens against a JWKS endpoint.
2
+
3
+ Uses joserfc (the successor to authlib.jose) — the same crypto library as
4
+ FastMCP's JWTVerifier, ensuring consistent token validation across both
5
+ REST and MCP transports.
6
+
7
+ JWKS keys are fetched synchronously at construction time. If the JWKS
8
+ endpoint is unreachable at startup, the validator starts with no keys
9
+ and returns None (→ 401) for every request until the TTL-based refresh
10
+ succeeds. This avoids a hard startup failure when the IdP is temporarily
11
+ unavailable during rolling deploys.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import time
17
+ from typing import TYPE_CHECKING
18
+
19
+ import httpx
20
+ from joserfc import jwt as joserfc_jwt
21
+ from joserfc.errors import JoseError
22
+ from joserfc.jwk import KeySet
23
+ from loguru import logger
24
+
25
+ from sonnet_auth.context import build_auth_context
26
+ from sonnet_server.guards import AuthContext, CredentialValidator
27
+
28
+ if TYPE_CHECKING:
29
+ from sonnet_auth.settings import AuthConfig
30
+
31
+ # JWKS cache TTL in seconds — keys are re-fetched after this interval
32
+ _JWKS_CACHE_TTL = 3600
33
+
34
+
35
+ class JwtCredentialValidator(CredentialValidator):
36
+ """Validates JWT bearer tokens (RS256/ES256) against a JWKS endpoint.
37
+
38
+ Uses joserfc for consistent crypto with FastMCP's JWTVerifier.
39
+ The ``jwks_client`` parameter is injectable for testing.
40
+
41
+ Precondition: ``auth_config.jwks_uri`` must be non-None and HTTPS.
42
+ This is enforced by ``AuthSettings`` validation before construction.
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ auth_config: AuthConfig,
48
+ *,
49
+ key_set: KeySet | None = None,
50
+ ) -> None:
51
+ if not auth_config.jwks_uri:
52
+ raise ValueError("JwtCredentialValidator requires auth_config.jwks_uri to be set")
53
+ self._jwks_uri = auth_config.jwks_uri
54
+ self._issuer = auth_config.issuer
55
+ self._audience = auth_config.audience
56
+ self._claim_map: dict[str, str] = auth_config.claim_map
57
+ self._auto_map_claims: bool = auth_config.auto_map_claims
58
+
59
+ if key_set is not None:
60
+ # Injected key set (tests) — skip network fetch
61
+ self._key_set: KeySet | None = key_set
62
+ else:
63
+ try:
64
+ self._key_set = self._fetch_key_set()
65
+ except RuntimeError as exc:
66
+ # JWKS endpoint unreachable at startup — start with no keys.
67
+ # All requests return 401 until the TTL refresh succeeds.
68
+ logger.warning("JWKS fetch failed at startup — all requests will return 401 until resolved: {}", exc)
69
+ self._key_set = None
70
+
71
+ self._key_set_fetched_at = time.monotonic()
72
+
73
+ def _fetch_key_set(self) -> KeySet:
74
+ """Fetch JWKS from the configured endpoint synchronously."""
75
+ try:
76
+ response = httpx.get(self._jwks_uri, timeout=10.0)
77
+ response.raise_for_status()
78
+ key_set = KeySet.import_key_set(response.json())
79
+ logger.trace("JWKS fetched successfully — {} key(s)", len(key_set.keys))
80
+ return key_set
81
+ except httpx.HTTPError as exc:
82
+ raise RuntimeError(f"Failed to fetch JWKS from {self._jwks_uri}: {exc}") from exc
83
+
84
+ def _get_key_set(self) -> KeySet | None:
85
+ """Return cached key set, refreshing if TTL has expired or no keys yet."""
86
+ if self._key_set is None or time.monotonic() - self._key_set_fetched_at > _JWKS_CACHE_TTL:
87
+ logger.debug("Fetching JWKS from {} ({})", self._jwks_uri, "retry" if self._key_set is None else "TTL expired")
88
+ try:
89
+ self._key_set = self._fetch_key_set()
90
+ self._key_set_fetched_at = time.monotonic()
91
+ except RuntimeError as exc:
92
+ if self._key_set is not None:
93
+ logger.warning("JWKS refresh failed, using cached keys: {}", exc)
94
+ else:
95
+ logger.warning("JWKS fetch failed — all requests will return 401: {}", exc)
96
+ return self._key_set
97
+
98
+ def validate(self, bearer_token: str) -> AuthContext | None:
99
+ """Validate a bearer token. Returns AuthContext on success, None on failure.
100
+
101
+ Returns None (→ 401) if no JWKS keys are available yet.
102
+ """
103
+ try:
104
+ key_set = self._get_key_set()
105
+ if key_set is None:
106
+ logger.debug("JWT validation skipped — no JWKS keys available")
107
+ return None
108
+ token = joserfc_jwt.decode(bearer_token, key_set, algorithms=["RS256", "ES256"])
109
+
110
+ claims_registry = joserfc_jwt.JWTClaimsRegistry(
111
+ sub={"essential": True},
112
+ exp={"essential": True},
113
+ **({} if self._issuer is None else {"iss": {"essential": True, "value": self._issuer}}),
114
+ **({} if self._audience is None else {"aud": {"essential": True, "value": self._audience}}),
115
+ )
116
+ claims_registry.validate(token.claims)
117
+
118
+ ctx = build_auth_context(token.claims, self._claim_map, auto_map_claims=self._auto_map_claims)
119
+ logger.trace(
120
+ "JWT validated: user={} iss={} aud={}",
121
+ ctx.user_id,
122
+ token.claims.get("iss"),
123
+ token.claims.get("aud"),
124
+ )
125
+ return ctx
126
+ except JoseError as exc:
127
+ logger.debug("JWT validation failed: {}", exc)
128
+ return None
129
+ except RuntimeError as exc:
130
+ logger.warning("JWKS error during token validation: {}", exc)
131
+ return None
132
+ except (KeyError, AttributeError) as exc:
133
+ # Configuration error (e.g. malformed claim_map, unexpected claims shape).
134
+ # Re-raise so misconfigurations surface clearly rather than silently
135
+ # producing 401 responses that look like invalid tokens.
136
+ raise RuntimeError(f"Auth configuration error during token validation: {exc}") from exc
@@ -0,0 +1,230 @@
1
+ """PolicyEngine — Cedar-based authorization evaluation.
2
+
3
+ Pure evaluator: receives validated policies + schema at construction,
4
+ performs no I/O. Loading from the settings table is the caller's
5
+ responsibility (see services/di.py).
6
+
7
+ The engine exposes two operations:
8
+
9
+ check() — permit/deny a single (principal, action, resource) triple
10
+ filter() — from a list of (resource_id, resource_attrs) pairs, return
11
+ only the IDs the principal is permitted to act on.
12
+ Uses is_authorized_batch() internally — one Cedar call for
13
+ the entire list, not N individual calls.
14
+
15
+ Principal attributes and resource attributes are always provided by the
16
+ caller (authz helpers). The engine never resolves claims, never runs
17
+ enrichers, never fetches anything — it is a pure function of its inputs.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from typing import Any
23
+
24
+ from cedarpy import is_authorized, is_authorized_batch, validate_policies
25
+ from loguru import logger
26
+
27
+ # Cedar entity type name for principals — must match the schema
28
+ _TYPE_USER = "User"
29
+
30
+
31
+ class PolicyEngine:
32
+ """Cedar policy evaluator.
33
+
34
+ Construction validates policies against the schema and raises
35
+ ``ValueError`` immediately if validation fails — fail fast at startup,
36
+ not at first request.
37
+
38
+ Stores ``claim_map`` as a read-only property for the authz helpers to
39
+ read — the engine itself never uses it.
40
+ """
41
+
42
+ def __init__(
43
+ self,
44
+ policies: str,
45
+ schema: str,
46
+ claim_map: dict[str, str] | None = None,
47
+ auto_map_claims: bool = False,
48
+ ) -> None:
49
+ result = validate_policies(policies, schema)
50
+ if not result.validation_passed:
51
+ raise ValueError(f"Cedar policy validation failed: {result.errors}")
52
+
53
+ self._policies = policies
54
+ self._claim_map: dict[str, str] = claim_map or {}
55
+ self._auto_map_claims: bool = auto_map_claims
56
+ logger.info("PolicyEngine initialised — Cedar policies validated successfully")
57
+
58
+ @property
59
+ def claim_map(self) -> dict[str, str]:
60
+ """JWT claim dot-paths → Cedar attribute names. Read by authz helpers."""
61
+ return self._claim_map
62
+
63
+ @property
64
+ def auto_map_claims(self) -> bool:
65
+ """Whether to pass all top-level JWT claims as Cedar attributes. Read by authz helpers."""
66
+ return self._auto_map_claims
67
+
68
+ # ------------------------------------------------------------------
69
+ # Lifecycle
70
+ # ------------------------------------------------------------------
71
+
72
+ def reload(self, policies: str, schema: str) -> None:
73
+ """Replace policies and schema. Validates before swapping — old policies
74
+ stay active if validation fails.
75
+
76
+ Raises:
77
+ ValueError: If the new policies fail validation against the schema.
78
+ """
79
+ result = validate_policies(policies, schema)
80
+ if not result.validation_passed:
81
+ raise ValueError(f"Cedar policy validation failed: {result.errors}")
82
+ self._policies = policies
83
+ logger.info("PolicyEngine reloaded — new Cedar policies validated and active")
84
+
85
+ # ------------------------------------------------------------------
86
+ # Public API
87
+ # ------------------------------------------------------------------
88
+
89
+ def check(
90
+ self,
91
+ user_id: str,
92
+ principal_attrs: dict[str, Any],
93
+ action: str,
94
+ resource_type: str,
95
+ resource_id: str,
96
+ resource_attrs: dict[str, Any] | None = None,
97
+ ) -> bool:
98
+ """Return True if the principal is permitted to perform action on resource.
99
+
100
+ Args:
101
+ user_id: Principal entity ID (from AuthContext.user_id).
102
+ principal_attrs: Pre-built principal attributes (from authz helpers).
103
+ action: Cedar action name (e.g. "search", "configure").
104
+ resource_type: Cedar entity type (e.g. "Topic", "Source").
105
+ resource_id: Resource identifier (e.g. "backend", "my-repo").
106
+ resource_attrs: Resource attributes for ABAC. Empty dict or None for RBAC.
107
+ """
108
+ entities = [
109
+ _build_principal_entity(user_id, principal_attrs),
110
+ _build_resource_entity(resource_type, resource_id, resource_attrs or {}),
111
+ ]
112
+ request = _build_request(user_id, action, resource_type, resource_id)
113
+ logger.trace(
114
+ "Cedar check: principal={} principal_attrs={} action={} resource={}/{} resource_attrs={}",
115
+ user_id,
116
+ principal_attrs,
117
+ action,
118
+ resource_type,
119
+ resource_id,
120
+ resource_attrs or {},
121
+ )
122
+ result = is_authorized(request, self._policies, entities)
123
+
124
+ decision = "ALLOW" if result.allowed else "DENY"
125
+ logger.debug(
126
+ "PolicyEngine.check: user={} action={} resource={}/{} → {}",
127
+ user_id,
128
+ action,
129
+ resource_type,
130
+ resource_id,
131
+ decision,
132
+ )
133
+ if not result.allowed:
134
+ logger.trace("PolicyEngine deny diagnostics: {}", result.diagnostics)
135
+ return result.allowed
136
+
137
+ def filter(
138
+ self,
139
+ user_id: str,
140
+ principal_attrs: dict[str, Any],
141
+ action: str,
142
+ resource_type: str,
143
+ resources: list[tuple[str, dict[str, Any]]],
144
+ ) -> list[str]:
145
+ """Return the subset of resource IDs the principal is permitted to act on.
146
+
147
+ Uses is_authorized_batch() for efficiency — one Cedar evaluation
148
+ call regardless of list length.
149
+
150
+ Args:
151
+ user_id: Principal entity ID.
152
+ principal_attrs: Pre-built principal attributes.
153
+ action: Cedar action name.
154
+ resource_type: Cedar entity type ("Topic" or "Source").
155
+ resources: List of (resource_id, resource_attrs) pairs.
156
+
157
+ Returns:
158
+ List of permitted resource IDs (preserves input order).
159
+ """
160
+ if not resources:
161
+ return []
162
+
163
+ entities = [_build_principal_entity(user_id, principal_attrs)] + [
164
+ _build_resource_entity(resource_type, rid, attrs) for rid, attrs in resources
165
+ ]
166
+ requests = [_build_request(user_id, action, resource_type, rid) for rid, _ in resources]
167
+ logger.trace(
168
+ "Cedar filter: principal={} principal_attrs={} action={} type={} resources={}",
169
+ user_id,
170
+ principal_attrs,
171
+ action,
172
+ resource_type,
173
+ [(rid, attrs) for rid, attrs in resources],
174
+ )
175
+ results = is_authorized_batch(requests=requests, policies=self._policies, entities=entities)
176
+ permitted = [rid for (rid, _), result in zip(resources, results, strict=True) if result.allowed]
177
+
178
+ logger.debug(
179
+ "PolicyEngine.filter: user={} action={} type={} total={} permitted={}",
180
+ user_id,
181
+ action,
182
+ resource_type,
183
+ len(resources),
184
+ len(permitted),
185
+ )
186
+ if len(permitted) < len(resources):
187
+ denied = [rid for rid in (r[0] for r in resources) if rid not in set(permitted)]
188
+ logger.trace("PolicyEngine filter denied resources: {}", denied)
189
+ return permitted
190
+
191
+
192
+ # ------------------------------------------------------------------
193
+ # Entity / request builders (module-level — no state, easy to test)
194
+ # ------------------------------------------------------------------
195
+
196
+
197
+ def _build_principal_entity(user_id: str, attrs: dict[str, Any]) -> dict:
198
+ """Build a Cedar User entity from pre-built attributes."""
199
+ return {
200
+ "uid": {"__entity": {"type": _TYPE_USER, "id": user_id}},
201
+ "attrs": attrs,
202
+ "parents": [],
203
+ }
204
+
205
+
206
+ def _build_resource_entity(resource_type: str, resource_id: str, attrs: dict[str, Any]) -> dict:
207
+ """Build a Cedar resource entity (Topic, Source, etc.)."""
208
+ return {
209
+ "uid": {"__entity": {"type": resource_type, "id": resource_id}},
210
+ "attrs": attrs,
211
+ "parents": [],
212
+ }
213
+
214
+
215
+ def _build_request(user_id: str, action: str, resource_type: str, resource_id: str) -> dict:
216
+ """Build a Cedar authorization request dict.
217
+
218
+ Cedar entity references use ``Type::"id"`` syntax — embedded double
219
+ quotes would break parsing. User-controlled values (user_id from JWT
220
+ ``sub``, resource_id from tool args) are checked defensively here.
221
+ """
222
+ for name, value in (("user_id", user_id), ("resource_id", resource_id)):
223
+ if '"' in value:
224
+ raise ValueError(f"Cedar entity ID must not contain double quotes: {name}={value!r}")
225
+ return {
226
+ "principal": f'{_TYPE_USER}::"{user_id}"',
227
+ "action": f'Action::"{action}"',
228
+ "resource": f'{resource_type}::"{resource_id}"',
229
+ "context": {},
230
+ }
@@ -0,0 +1,254 @@
1
+ """AuthSettings — typed auth configuration with env var + optional seed resolution.
2
+
3
+ Resolution per field (example with prefix ``COCO_RAG_``):
4
+ env var COCO_RAG_AUTHN_ENABLED > seed value (e.g. from DB) > default.
5
+
6
+ The env prefix is passed explicitly to ``AuthSettings.resolve()`` so the
7
+ same model works for any consumer (COCO_RAG_, COCO_GRAPH_, ATLAS_, …).
8
+ No module-level mutable state — safe to call from any context.
9
+
10
+ Seed key contract (what callers pass to resolve()):
11
+ ``authn_enabled`` (bool) — AuthN toggle
12
+ ``authn_config`` (dict) — AuthN config blob (validated by AuthnConfig)
13
+ ``authz_enabled`` (bool) — AuthZ toggle
14
+
15
+ DB storage keys (coco-rag settings table):
16
+ authn_enabled — flat boolean (AuthN toggle / emergency kill switch)
17
+ authn_config — JSON object: AuthN config (jwks_uri, issuer, audience,
18
+ mode, claim_map, auto_map_claims, mcp_base_url,
19
+ upstream_* fields)
20
+ authz_enabled — flat boolean (AuthZ / Cedar toggle)
21
+ authz_cedar_policies — text: Cedar policy source
22
+ authz_cedar_schema — text: Cedar schema source
23
+
24
+ CLI usage:
25
+ coco-rag settings set authn_enabled true
26
+ coco-rag settings set authn_config '{"jwks_uri": "...", "issuer": "...", "audience": "..."}'
27
+ coco-rag settings set authz_enabled true
28
+ coco-rag settings set-text authz_cedar_policies --file policies.cedar
29
+ coco-rag settings set-text authz_cedar_schema --file schema.cedarschema
30
+
31
+ Future (Phase 4 — atlas bundle pull):
32
+ ``authz_config`` (dict) — AuthZ config blob (AuthzConfig, when added)
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import json
38
+ import os
39
+ from enum import StrEnum
40
+ from typing import Any, ClassVar
41
+
42
+ from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator, model_validator
43
+
44
+
45
+ def _validate_https_url(v: str | None, *, env_prefix: str = "") -> str | None:
46
+ """Validate URL uses HTTPS with a non-empty host. Preserves value as-is (no normalisation).
47
+
48
+ ``{env_prefix}AUTHN_ALLOW_HTTP=true`` relaxes to HTTP for test environments
49
+ (localhost only). For example: ``COCO_RAG_AUTHN_ALLOW_HTTP=true``.
50
+ """
51
+ if v is None:
52
+ return None
53
+ from urllib.parse import urlparse
54
+
55
+ parsed = urlparse(v)
56
+ allow_http = os.environ.get(f"{env_prefix}AUTHN_ALLOW_HTTP", "").lower() in ("true", "1")
57
+
58
+ if allow_http:
59
+ if parsed.scheme not in ("http", "https") or not parsed.hostname:
60
+ raise ValueError(f"URL must use HTTP(S) with a valid host: {v!r}")
61
+ if parsed.scheme == "http" and parsed.hostname not in ("localhost", "127.0.0.1"):
62
+ raise ValueError(f"HTTP is only allowed for localhost in test mode: {v!r}")
63
+ return v
64
+
65
+ if parsed.scheme != "https" or not parsed.hostname:
66
+ raise ValueError(f"URL must use HTTPS with a valid host: {v!r}")
67
+ return v
68
+
69
+
70
+ class AuthMode(StrEnum):
71
+ JWT_VERIFIER = "jwt_verifier"
72
+ OAUTH_PROXY = "oauth_proxy"
73
+
74
+
75
+ class AuthnConfig(BaseModel):
76
+ """JWT/JWKS authentication configuration.
77
+
78
+ Stored as the ``auth_config`` settings key. All fields that must be
79
+ consistent live here. The enable/disable toggle is a separate flat key
80
+ (``auth_enabled``) so it can be flipped independently.
81
+
82
+ MCP-specific fields (``mcp_base_url``, ``upstream_*``, ``mode``) are
83
+ included here because the MCP auth provider is constructed from this
84
+ config. Services without MCP leave these fields at their defaults.
85
+
86
+ Instances are frozen — post-construction mutation raises ValidationError.
87
+ """
88
+
89
+ model_config = ConfigDict(frozen=True, extra="forbid")
90
+
91
+ jwks_uri: str | None = None
92
+ issuer: str | None = None
93
+ audience: str | None = None
94
+ mode: AuthMode = AuthMode.JWT_VERIFIER
95
+
96
+ # Claim mapping — controls how JWT claims become AuthContext fields and
97
+ # Cedar principal attributes. Shared by both authn and authz layers.
98
+ auto_map_claims: bool = False
99
+ claim_map: dict[str, str] = Field(default_factory=dict)
100
+
101
+ # MCP auth provider — public base URL for RFC 9728 metadata endpoint.
102
+ # Required when mode=jwt_verifier and an MCP server is active.
103
+ # Required when mode=oauth_proxy.
104
+ mcp_base_url: str | None = None
105
+
106
+ # OAuthProxy-only (when mode="oauth_proxy")
107
+ upstream_authorization_endpoint: str | None = None
108
+ upstream_token_endpoint: str | None = None
109
+ upstream_client_id: str | None = None
110
+ upstream_client_secret: SecretStr | None = None
111
+
112
+ # Env var suffixes (without prefix) mapped to field names.
113
+ # Used by AuthSettings.resolve() to apply per-field env var overrides.
114
+ # Full env var: {prefix}AUTHN_{SUFFIX} e.g. COCO_RAG_AUTHN_JWKS_URI
115
+ _ENV_SUFFIXES: ClassVar[dict[str, str]] = {
116
+ "AUTHN_JWKS_URI": "jwks_uri",
117
+ "AUTHN_ISSUER": "issuer",
118
+ "AUTHN_AUDIENCE": "audience",
119
+ "AUTHN_MODE": "mode",
120
+ "AUTHN_AUTO_MAP_CLAIMS": "auto_map_claims",
121
+ "AUTHN_CLAIM_MAP": "claim_map",
122
+ "AUTHN_MCP_BASE_URL": "mcp_base_url",
123
+ "AUTHN_UPSTREAM_AUTHORIZATION_ENDPOINT": "upstream_authorization_endpoint",
124
+ "AUTHN_UPSTREAM_TOKEN_ENDPOINT": "upstream_token_endpoint",
125
+ "AUTHN_UPSTREAM_CLIENT_ID": "upstream_client_id",
126
+ "AUTHN_UPSTREAM_CLIENT_SECRET": "upstream_client_secret",
127
+ }
128
+
129
+ @field_validator(
130
+ "jwks_uri", "issuer", "mcp_base_url", "upstream_authorization_endpoint", "upstream_token_endpoint", mode="before"
131
+ )
132
+ @classmethod
133
+ def _require_https(cls, v: str | None) -> str | None:
134
+ # The field validator has no access to the env prefix. It enforces
135
+ # HTTPS strictly and respects the bare AUTHN_ALLOW_HTTP env var (no
136
+ # prefix). Callers that need prefix-aware allow-HTTP (e.g. test
137
+ # environments using COCO_RAG_AUTHN_ALLOW_HTTP) must use
138
+ # AuthSettings.resolve(), which applies the prefix before building
139
+ # the model. Direct construction (e.g. in tests) should either use
140
+ # HTTPS URLs or set the unprefixed AUTHN_ALLOW_HTTP env var.
141
+ return _validate_https_url(v)
142
+
143
+ @model_validator(mode="after")
144
+ def _validate_oauth_proxy_fields(self) -> AuthnConfig:
145
+ if self.mode == AuthMode.OAUTH_PROXY:
146
+ missing = [
147
+ f
148
+ for f in ("mcp_base_url", "upstream_authorization_endpoint", "upstream_token_endpoint", "upstream_client_id")
149
+ if not getattr(self, f)
150
+ ]
151
+ if missing:
152
+ raise ValueError(f"oauth_proxy mode requires: {', '.join(missing)}")
153
+ return self
154
+
155
+
156
+ class AuthSettings(BaseModel):
157
+ """Complete auth settings: authn toggle + config, authz toggle.
158
+
159
+ Instances are frozen — post-construction mutation raises ValidationError.
160
+
161
+ Note: AuthzConfig will be added in Phase 4 when atlas bundle pull
162
+ fields (atlas_url, bundle_ttl) exist. Adding an empty model now would
163
+ be structural noise with no current value.
164
+ """
165
+
166
+ model_config = ConfigDict(frozen=True)
167
+
168
+ enabled: bool = False
169
+ config: AuthnConfig = Field(default_factory=AuthnConfig)
170
+ authz_enabled: bool = False
171
+
172
+ @model_validator(mode="after")
173
+ def _validate_enabled_requires_config(self) -> AuthSettings:
174
+ """When auth is enabled, jwks_uri, issuer, and audience are all required."""
175
+ if self.enabled:
176
+ missing = [f for f in ("jwks_uri", "issuer", "audience") if not getattr(self.config, f)]
177
+ if missing:
178
+ raise ValueError(f"auth_enabled=true requires auth_config fields: {', '.join(missing)}")
179
+ return self
180
+
181
+ @classmethod
182
+ def resolve(cls, *, env_prefix: str = "", seed: dict[str, Any] | None = None) -> AuthSettings:
183
+ """Build AuthSettings from env vars with optional seed values.
184
+
185
+ Args:
186
+ env_prefix: Env var prefix for this consumer, e.g. ``"COCO_RAG_"``.
187
+ seed: Optional pre-loaded values (e.g. from a DB settings table).
188
+ Expected keys:
189
+ ``authn_enabled`` (bool) — AuthN toggle
190
+ ``authn_config`` (dict) — AuthN config blob (AuthnConfig)
191
+ ``authz_enabled`` (bool) — AuthZ toggle
192
+ Per-field resolution: env var > seed value > default.
193
+ Unexpected keys are ignored with a warning logged.
194
+ """
195
+ from loguru import logger
196
+
197
+ seed = seed or {}
198
+
199
+ unexpected = set(seed) - {"authn_enabled", "authn_config", "authz_enabled"}
200
+ if unexpected:
201
+ logger.warning("AuthSettings.resolve: unexpected seed keys ignored: {}", sorted(unexpected))
202
+
203
+ enabled = _resolve(env_prefix, "AUTHN_ENABLED", seed.get("authn_enabled"), default=False)
204
+ authz_enabled = _resolve(env_prefix, "AUTHZ_ENABLED", seed.get("authz_enabled"), default=False)
205
+
206
+ seed_config = seed.get("authn_config")
207
+ if seed_config is None:
208
+ seed_config = {}
209
+ elif not isinstance(seed_config, dict):
210
+ logger.warning("AuthSettings.resolve: authn_config is not a dict ({}), ignoring", type(seed_config).__name__)
211
+ seed_config = {}
212
+
213
+ config_data = dict(seed_config)
214
+ for suffix, field in AuthnConfig._ENV_SUFFIXES.items():
215
+ _override_from_env(config_data, field, env_prefix, suffix)
216
+
217
+ return cls(
218
+ enabled=enabled,
219
+ config=AuthnConfig.model_validate(config_data),
220
+ authz_enabled=authz_enabled,
221
+ )
222
+
223
+
224
+ def _resolve(env_prefix: str, env_suffix: str, seed_value: Any, *, default: Any = None) -> Any:
225
+ """Return env var value if set, else seed value, else default."""
226
+ env_val = os.environ.get(f"{env_prefix}{env_suffix}")
227
+ if env_val is not None:
228
+ return _parse_env_value(env_val)
229
+ if seed_value is not None:
230
+ return seed_value
231
+ return default
232
+
233
+
234
+ def _override_from_env(config_data: dict, field: str, env_prefix: str, env_suffix: str) -> None:
235
+ """Override a single field in config_data if the env var is set."""
236
+ env_val = os.environ.get(f"{env_prefix}{env_suffix}")
237
+ if env_val is not None:
238
+ config_data[field] = _parse_env_value(env_val)
239
+
240
+
241
+ def _parse_env_value(val: str) -> Any:
242
+ """Parse an env var string into a Python value."""
243
+ lower = val.lower()
244
+ if lower in ("true", "1", "yes"):
245
+ return True
246
+ if lower in ("false", "0", "no"):
247
+ return False
248
+ try:
249
+ result = json.loads(val)
250
+ if result is None:
251
+ raise ValueError("Env var value 'null' is not supported — unset the variable to use the default")
252
+ return result
253
+ except json.JSONDecodeError:
254
+ return val
@@ -0,0 +1,124 @@
1
+ Metadata-Version: 2.4
2
+ Name: sonnet-auth
3
+ Version: 0.1.0
4
+ Summary: JWT/JWKS authentication and Cedar authorization for sonnet-server applications
5
+ Author-email: Wolfgang Miller <wolfgang.miller@petrarca-labs.com>
6
+ License-Expression: Apache-2.0
7
+ Classifier: Intended Audience :: Developers
8
+ Classifier: Programming Language :: Python
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.14
11
+ Requires-Python: <4.0,>=3.14
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: sonnet-server>=0.1.9
14
+ Requires-Dist: joserfc>=1.0.0
15
+ Requires-Dist: httpx>=0.28.0
16
+ Provides-Extra: cedar
17
+ Requires-Dist: cedarpy>=4.0.0; extra == "cedar"
18
+ Provides-Extra: dev
19
+ Requires-Dist: sonnet-auth[cedar]; extra == "dev"
20
+ Requires-Dist: ruff>=0.3.0; extra == "dev"
21
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
22
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
23
+ Requires-Dist: testcontainers[keycloak]>=4.0; extra == "dev"
24
+
25
+ # Sonnet Auth
26
+
27
+ JWT/JWKS authentication and Cedar authorization for sonnet-server
28
+ applications. No FastMCP dependency -- MCP auth wiring lives in each
29
+ domain service.
30
+
31
+ ## What it provides
32
+
33
+ - **JWT/JWKS validation** -- `JwtCredentialValidator` implements
34
+ sonnet-server's `CredentialValidator` protocol. RS256/ES256 with
35
+ TTL-based JWKS key refresh and graceful degradation on IdP outages.
36
+ - **Claim mapping** -- configurable dot-path extraction from JWT claims
37
+ to `AuthContext` fields and Cedar principal attributes.
38
+ `auto_map_claims` mode passes all non-plumbing claims automatically.
39
+ - **Cedar policy evaluation** (optional `[cedar]` extra) -- `PolicyEngine`
40
+ wraps cedarpy for in-process RBAC/ABAC. `check_authz()` and
41
+ `filter_authz()` are one-liner authorization for REST and MCP handlers.
42
+ - **Pluggable resource resolution** -- `ResourceAttributeResolver` protocol
43
+ for domain-specific Cedar resource attributes.
44
+ - **Settings model** -- `AuthSettings.resolve(env_prefix, seed)` with
45
+ per-field env var overrides and optional DB seeding. No global state.
46
+
47
+ ## Install
48
+
49
+ ```bash
50
+ # JWT authentication only
51
+ pip install sonnet-auth
52
+
53
+ # JWT + Cedar authorization
54
+ pip install sonnet-auth[cedar]
55
+ ```
56
+
57
+ ## Prerequisites
58
+
59
+ - Python 3.14+
60
+ - sonnet-server >= 0.1.9
61
+
62
+ ## Usage
63
+
64
+ ### AuthN only (JWT/JWKS)
65
+
66
+ ```python
67
+ from sonnet_auth import AuthSettings, JwtCredentialValidator
68
+ from sonnet_server.guards import set_credential_validator
69
+
70
+ auth = AuthSettings.resolve(env_prefix="MY_APP_")
71
+ if auth.enabled:
72
+ set_credential_validator(JwtCredentialValidator(auth.config))
73
+ ```
74
+
75
+ Configuration via env vars:
76
+
77
+ ```
78
+ MY_APP_AUTHN_ENABLED=true
79
+ MY_APP_AUTHN_JWKS_URI=https://idp.example.com/.well-known/jwks.json
80
+ MY_APP_AUTHN_ISSUER=https://idp.example.com
81
+ MY_APP_AUTHN_AUDIENCE=my-app
82
+ ```
83
+
84
+ Or with DB seeding (coco-rag pattern):
85
+
86
+ ```python
87
+ seed = {
88
+ "authn_enabled": svc.get("authn_enabled"),
89
+ "authn_config": svc.get("authn_config"),
90
+ "authz_enabled": svc.get("authz_enabled"),
91
+ }
92
+ auth = AuthSettings.resolve(env_prefix="COCO_RAG_", seed=seed)
93
+ ```
94
+
95
+ ### AuthZ (Cedar policies)
96
+
97
+ ```python
98
+ from sonnet_auth import check_authz, filter_authz, set_resource_attribute_resolver
99
+
100
+ # Register domain-specific resource resolver
101
+ set_resource_attribute_resolver(MyResolver())
102
+
103
+ # In any REST handler or MCP tool
104
+ check_authz("search", "Source", source_name) # raises AuthzDeniedError on deny
105
+
106
+ # For list operations
107
+ permitted = filter_authz("list", "Source", source_names)
108
+ ```
109
+
110
+ ## Package structure
111
+
112
+ ```
113
+ src/sonnet_auth/
114
+ __init__.py # public API with lazy Cedar imports
115
+ settings.py # AuthSettings, AuthnConfig
116
+ context.py # JWT claim -> AuthContext mapping
117
+ jwt_validator.py # JwtCredentialValidator (JWKS)
118
+ policy_engine.py # Cedar PolicyEngine [cedar extra]
119
+ authz.py # check_authz, filter_authz [cedar extra]
120
+ ```
121
+
122
+ ## License
123
+
124
+ Apache License 2.0
@@ -0,0 +1,10 @@
1
+ sonnet_auth/__init__.py,sha256=q-E3Zf6i7PAxUIQ_6-56jqwcm66qe5qpOYWEVqIqsHs,3335
2
+ sonnet_auth/authz.py,sha256=TVoNBGZjQ4NtDgT2CapL_8BRaWTN4QtUkxCxvGwMuXg,8797
3
+ sonnet_auth/context.py,sha256=RTtOfMDsVIForXipFKnCCvfVZXLy81eZzMqhPoqNO-4,5156
4
+ sonnet_auth/jwt_validator.py,sha256=zU0Xfr9dAHVHe7fQEljQjxnJv5Pqkxm94mtgpAXV_dg,5920
5
+ sonnet_auth/policy_engine.py,sha256=EuLFbQnWuiLkpQRM7SAUod_npJjLlL5cJNc4JsH0Qcs,8829
6
+ sonnet_auth/settings.py,sha256=vVHHEUPmcEOXZEBPkZo4iftO8RgslcQjkVLQaThQqEI,10595
7
+ sonnet_auth-0.1.0.dist-info/METADATA,sha256=oSf9SjnukRBuH0QuId8hg1CsWrLQoEhgdBGDpunO3Z0,3827
8
+ sonnet_auth-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
9
+ sonnet_auth-0.1.0.dist-info/top_level.txt,sha256=7FcXKvjjxrmHdmUjoKgHEN6l8Of_LVsT1jieOYMtgWM,12
10
+ sonnet_auth-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ sonnet_auth