kx-auth-core 0.5.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,71 @@
1
+ """Lean, fastmcp-free shared core for kx auth.
2
+
3
+ Holds the shared, fastmcp-free auth mechanisms imported by the container (``kx-mcp-core``), the
4
+ backend bundles, and the client-side ``kx auth`` CLI alike:
5
+ - **inbound** — the ``KX_MCP_AUTH`` config contract (:class:`AuthSettings`) + a joserfc-based
6
+ :func:`verify_token` (the one implementation of bearer validation; the container also keeps
7
+ FastMCP's ``JWTVerifier`` for serving);
8
+ - **outbound** — the token-exchange seam (:func:`exchange` + :func:`register_outbound_strategy`),
9
+ so identity propagation is one implementation reused by every backend and the CLI.
10
+
11
+ Deliberately depends on no fastmcp, so bundles and the CLI can use it without pulling in the server.
12
+ """
13
+
14
+ from .assertion import project_from_claims, project_principal
15
+ from .authz import (
16
+ ROUTE_ONLY_MODES,
17
+ AuthzAdapter,
18
+ AuthzDecision,
19
+ AuthzRequest,
20
+ authz_adapters,
21
+ decide,
22
+ register_authz_adapter,
23
+ )
24
+ from .outbound import (
25
+ OutboundConfig,
26
+ OutboundCredential,
27
+ decode_claims_unverified,
28
+ exchange,
29
+ outbound_strategies,
30
+ register_outbound_strategy,
31
+ )
32
+ from .settings import AuthSettings
33
+ from .verify import (
34
+ AUTH_REQUIRED,
35
+ DENIED,
36
+ ERROR,
37
+ OK,
38
+ VerifyResult,
39
+ resolve_public_key,
40
+ verify_token,
41
+ )
42
+
43
+ __all__ = [
44
+ # inbound
45
+ "AuthSettings",
46
+ "verify_token",
47
+ "VerifyResult",
48
+ "resolve_public_key",
49
+ "OK",
50
+ "ERROR",
51
+ "AUTH_REQUIRED",
52
+ "DENIED",
53
+ # outbound
54
+ "exchange",
55
+ "register_outbound_strategy",
56
+ "outbound_strategies",
57
+ "OutboundConfig",
58
+ "OutboundCredential",
59
+ "decode_claims_unverified",
60
+ # identity assertion (plain kdb+)
61
+ "project_principal",
62
+ "project_from_claims",
63
+ # authorization decision seam
64
+ "AuthzRequest",
65
+ "AuthzDecision",
66
+ "AuthzAdapter",
67
+ "decide",
68
+ "register_authz_adapter",
69
+ "authz_adapters",
70
+ "ROUTE_ONLY_MODES",
71
+ ]
@@ -0,0 +1,110 @@
1
+ """Shape a validated principal into the dict ferried to kdb+ for identity assertion.
2
+
3
+ Plain kdb+ has no bearer concept over qIPC, so the container does not mint a token for it (that's
4
+ the ``outbound`` token-exchange seam used by OAuth backends such as KDB.AI). Instead it **asserts** the caller's
5
+ identity: it ferries the validated structured fields + the raw claims to q, which calls
6
+ ``.kx.auth.bind[principal]``.
7
+
8
+ **Promotion lives in q, not here.** ``.kx.auth.promote`` is the single authority that extracts
9
+ ``groups``/``tenant`` from the configured claim path, symbolises the promoted fields, and
10
+ canonicalises ``exp`` — so the qIPC and HTTP transports converge on one promotion implementation.
11
+ This helper only assembles the ferry shape: the structured fields q can't re-derive (``sub`` /
12
+ ``client`` / ``scopes`` / ``exp`` / ``aud`` / ``iss`` / ``act``) plus the raw ``claims`` blob q
13
+ promotes from. It does not extract groups.
14
+
15
+ It is deliberately:
16
+
17
+ - **fastmcp-free** — operates on plain claim fields / a claims ``dict``, not fastmcp's
18
+ ``AccessToken``, so the container, the bundles, and the fastmcp-free ``kx auth`` CLI reuse one
19
+ implementation.
20
+ - **pykx-free** — returns a plain Python ``dict``. The kdbx connection layer (which has PyKX) wraps
21
+ the raw ``claims`` string values as ``CharVector`` before sending, so high-cardinality values
22
+ (``jti`` …) do not intern as q symbols; q symbolises only the promoted fields.
23
+
24
+ Two entry points, one implementation: :func:`project_principal` builds the ferry dict from
25
+ already-extracted fields (what the kdbx connection layer has from ``current_principal()``);
26
+ :func:`project_from_claims` builds it from a raw JWT claims dict (an indicative preview used by
27
+ ``kx auth assert --principal`` — the authoritative shape is whatever ``.kx.auth.promote`` produces).
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from typing import Any, Mapping, Sequence
33
+
34
+
35
+ def _as_scope_list(scopes: Sequence[str] | str | None) -> list[str]:
36
+ """Normalise scopes to a list. Accepts a list or an OAuth space-delimited ``scope`` string."""
37
+ if scopes is None:
38
+ return []
39
+ if isinstance(scopes, str):
40
+ return scopes.split()
41
+ return [str(s) for s in scopes]
42
+
43
+
44
+ def project_principal(
45
+ *,
46
+ subject: str | None,
47
+ client_id: str | None = None,
48
+ scopes: Sequence[str] | str | None = None,
49
+ expires_at: int | None = None,
50
+ audience: Any = None,
51
+ issuer: str | None = None,
52
+ act: Mapping[str, Any] | None = None,
53
+ claims: Mapping[str, Any] | None = None,
54
+ ) -> dict[str, Any]:
55
+ """Assemble the ferry dict from extracted fields.
56
+
57
+ The structured fields q cannot re-derive are passed through; ``aud`` / ``iss`` / ``act`` fall back
58
+ to the raw ``claims`` when not given explicitly. ``sub`` / ``client`` / ``scopes`` / ``claims`` are
59
+ always present so the q side can rely on their shape. **No groups/tenant extraction here** — q's
60
+ ``promote`` reads those from ``claims`` so qIPC and HTTP share one promotion path.
61
+ """
62
+ claims = dict(claims or {})
63
+ sub = subject or client_id or ""
64
+
65
+ if audience is None:
66
+ audience = claims.get("aud")
67
+ if issuer is None:
68
+ issuer = claims.get("iss")
69
+ if act is None:
70
+ act = claims.get("act")
71
+
72
+ out: dict[str, Any] = {
73
+ "sub": sub,
74
+ "client": client_id or "",
75
+ "scopes": _as_scope_list(scopes),
76
+ "claims": claims,
77
+ }
78
+ if audience is not None:
79
+ out["aud"] = audience
80
+ if issuer:
81
+ out["iss"] = issuer
82
+ if expires_at is not None:
83
+ out["exp"] = int(expires_at)
84
+ if act:
85
+ out["act"] = dict(act)
86
+ return out
87
+
88
+
89
+ def project_from_claims(claims: Mapping[str, Any]) -> dict[str, Any]:
90
+ """Assemble the ferry dict from a raw JWT claims dict (the ``kx auth assert`` preview path).
91
+
92
+ Derives the structured fields (``sub`` / ``client`` / scopes / ``exp``) from the standard claim
93
+ names, then delegates to :func:`project_principal`. Groups are *not* derived here — they are q's
94
+ job (``.kx.auth.promote``), so this is an indicative preview of what gets ferried, not the final
95
+ promoted principal.
96
+ """
97
+ claims = dict(claims)
98
+ scope = claims.get("scope")
99
+ if scope is None:
100
+ scope = claims.get("scopes")
101
+ return project_principal(
102
+ subject=claims.get("sub"),
103
+ client_id=claims.get("client_id") or claims.get("azp"),
104
+ scopes=scope,
105
+ expires_at=claims.get("exp"),
106
+ audience=claims.get("aud"),
107
+ issuer=claims.get("iss"),
108
+ act=claims.get("act"),
109
+ claims=claims,
110
+ )
kx_auth_core/authz.py ADDED
@@ -0,0 +1,148 @@
1
+ """The pluggable authorization-decision seam.
2
+
3
+ ``decide(request, *, strategy)`` resolves ``strategy`` from a registry and runs the registered
4
+ adapter, always returning an :class:`AuthzDecision` so the tool path reads ``allowed`` / ``reason`` /
5
+ ``obligations`` uniformly. A registry (not a hard-coded conditional) so a backend's capability
6
+ adapter slots in via :func:`register_authz_adapter` without touching dispatch.
7
+ Shaped like the outbound seam in :mod:`kx_auth_core.outbound.strategies`
8
+ (``register_outbound_strategy`` / ``exchange`` / ``outbound_strategies``), so the codebase has one
9
+ extension pattern, not two.
10
+
11
+ This registry serves two kinds of check. A **capability check** sits at the tool boundary —
12
+ data-agnostic, driven by the ``@authorize`` decorator (wired in :mod:`kx_mcp_core`), which builds an
13
+ :class:`AuthzRequest` and calls :func:`decide`. An adapter can also do explicit-consult: call a
14
+ backend's own data gate as a policy point and act on its verdict — the kdb-x entitlements adapter
15
+ (``kdbx_entitlements``, consulting q ``.kx.auth.entitled`` in-tool on the bound handle) is the first
16
+ of these. A backend whose data gate enforces directly on the data call itself (for example KDB.AI ACL)
17
+ needs no adapter here. The bridge between the two check styles is :attr:`AuthzDecision.obligations`:
18
+ a free-form scope-down payload (an entitled-symbol list, a where-clause fragment) an adapter uses to
19
+ answer *allow-with-obligations* through the same boolean-first shape.
20
+
21
+ Deliberately fastmcp-free. The ``action`` / ``resource`` vocabulary is a documented convention, not
22
+ validated here (mirrors how the outbound seam never validates its audience/resource strings) — an
23
+ adapter and the backend's own data gate must agree on the same strings by convention, not by code.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from copy import deepcopy
29
+ from dataclasses import dataclass, field, replace
30
+ from typing import Any, Mapping, Optional, Protocol, Union, runtime_checkable
31
+
32
+ # The strategy values that mean "no adapter — route-only allow" (the inbound `unset` precedent):
33
+ # authorization is opt-in, so an unconfigured seam must not regress the zero-config posture.
34
+ # Public: the container's `configure_authz` validates a configured mode against this, so the set of
35
+ # modes that mean "no adapter, allow through" must have exactly one definition. Duplicating it in
36
+ # kx-mcp-core would let the two drift silently, and a mode wrongly treated as route-only is an
37
+ # allow-everything.
38
+ ROUTE_ONLY_MODES = frozenset({"", "unset", "none"})
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class AuthzRequest:
43
+ """The question a capability check answers: may ``subject`` perform ``action`` on ``resource``?
44
+
45
+ ``action`` (e.g. ``query`` / ``write`` / ``search`` / ``admin``) and ``resource`` (e.g.
46
+ ``kdbx.sql`` / ``kdbai:table`` / ``acme:package``) are a documented convention, not validated —
47
+ both this adapter and the q ``authorize[action;resource]`` gate key on the same strings.
48
+ ``namespace`` is the mount namespace the primitive lives under (``kdbx`` / ``kdbai`` / ``acme``).
49
+ ``claims`` is the validated inbound principal's raw claims (an audit/escape-hatch payload;
50
+ adapters should prefer the promoted fields a backend exposes over re-deriving from raw claims).
51
+ """
52
+
53
+ subject: str
54
+ action: str
55
+ resource: str
56
+ namespace: str
57
+ claims: Mapping[str, Any] = field(default_factory=dict)
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class AuthzDecision:
62
+ """The answer slip :func:`decide` always returns. Boolean-first; ``obligations`` is the optional
63
+ scope-down extension.
64
+
65
+ ``allowed`` is the decision. ``adapter`` records which registered adapter produced it (stamped by
66
+ :func:`decide`; ``None`` for the route-only allow). ``reason`` is a human/audit string. The
67
+ default-empty ``obligations`` is where an adapter expresses *allow-with-obligations* — a free-form
68
+ payload the tool/backend applies to scope a result down (e.g. an entitled-symbol list, a
69
+ where-clause fragment). Most adapters return a bare ``bool`` and never touch it.
70
+ """
71
+
72
+ allowed: bool
73
+ adapter: Optional[str] = None
74
+ reason: Optional[str] = None
75
+ obligations: Mapping[str, Any] = field(default_factory=dict)
76
+
77
+
78
+ @runtime_checkable
79
+ class AuthzAdapter(Protocol):
80
+ """A capability adapter. Registered via :func:`register_authz_adapter` and dispatched by
81
+ :func:`decide`. Receives the :class:`AuthzRequest` and returns ``True`` / ``False`` for the common
82
+ case, or a full :class:`AuthzDecision` to carry ``reason`` / ``obligations``. **Raise to fail
83
+ closed** — :func:`decide` turns any exception into a deny, never an allow.
84
+ """
85
+
86
+ def __call__(self, request: AuthzRequest) -> Union[bool, AuthzDecision]: ...
87
+
88
+
89
+ _ADAPTERS: dict[str, AuthzAdapter] = {}
90
+
91
+
92
+ def register_authz_adapter(name: str, adapter: AuthzAdapter) -> None:
93
+ """Register an authorization adapter under ``name``. The extension point for a backend's
94
+ capability check. Mirrors ``register_outbound_strategy``."""
95
+ _ADAPTERS[name] = adapter
96
+
97
+
98
+ def authz_adapters() -> list[str]:
99
+ """The names of all registered adapters, sorted. Mirrors ``outbound_strategies()`` / ``auth_modes()``."""
100
+ return sorted(_ADAPTERS)
101
+
102
+
103
+ def decide(request: AuthzRequest, *, strategy: Optional[str]) -> AuthzDecision:
104
+ """Resolve ``strategy`` to an adapter, run it, and return an :class:`AuthzDecision`.
105
+
106
+ Three postures — the easy thing to get wrong, so they are explicit here:
107
+
108
+ * **route-only** — ``strategy`` is unset / ``"unset"`` / ``"none"`` (or ``None``): **allow**, with
109
+ ``adapter=None``. Authorization is opt-in; an unconfigured seam must not regress the zero-config
110
+ single-principal posture (the inbound ``KX_MCP_AUTH=unset`` precedent).
111
+ * **unknown strategy** — a *named* strategy not in the registry: ``ValueError`` (a clear operator
112
+ config error, surfaced loudly — mirrors ``exchange``'s unknown-strategy).
113
+ * **adapter raised** — a registered adapter throws at runtime: **deny / fail closed**
114
+ (``allowed=False``), the exception captured in ``reason``. An adapter exception must *never*
115
+ fall through to allow (matches the q-side ``policy:{[…] 0b}`` default).
116
+
117
+ A registered adapter returning a bare ``bool`` is normalised into an :class:`AuthzDecision`;
118
+ returning an :class:`AuthzDecision` is passed through with ``obligations`` intact. Either way the
119
+ producing ``adapter`` is stamped, so the caller reads one uniform shape.
120
+ """
121
+ if not strategy or strategy in ROUTE_ONLY_MODES:
122
+ return AuthzDecision(allowed=True, adapter=None, reason="no adapter configured (route-only)")
123
+
124
+ try:
125
+ adapter = _ADAPTERS[strategy]
126
+ except KeyError:
127
+ raise ValueError(
128
+ f"unknown authz strategy {strategy!r}; known: {authz_adapters()}"
129
+ ) from None
130
+
131
+ try:
132
+ result = adapter(request)
133
+ except Exception as exc: # fail closed — an adapter error is a deny, never an allow
134
+ return AuthzDecision(
135
+ allowed=False, adapter=strategy, reason=f"adapter raised: {type(exc).__name__}: {exc}"
136
+ )
137
+
138
+ if isinstance(result, AuthzDecision):
139
+ # Pass the decision through (obligations intact), stamping the adapter when it didn't.
140
+ # Snapshot obligations on BOTH paths: `replace()` is a shallow copy, so the returned
141
+ # decision would otherwise share the adapter's own mapping object — an adapter that caches
142
+ # or extends its payload could mutate a decision a caller already holds (and already acted
143
+ # on). Deep because obligations nest (`{"entitled": [...], "denied": [...]}`), so a
144
+ # top-level copy would still share the lists. The self-stamped path needs it too: that one
145
+ # skips `replace` entirely and was the aliasing case a `replace`-only fix left behind.
146
+ stamped = result if result.adapter else replace(result, adapter=strategy)
147
+ return replace(stamped, obligations=deepcopy(stamped.obligations))
148
+ return AuthzDecision(allowed=bool(result), adapter=strategy)
@@ -0,0 +1,21 @@
1
+ """Outbound identity propagation: the pluggable token-exchange seam.
2
+
3
+ A backend tool reads the validated inbound principal (`current_principal()`) and calls
4
+ `exchange(config, subject_token)` to mint the backend-shaped credential — `passthrough` / `rfc_8693`
5
+ / `service_account`, or a `custom` driver registered via `register_outbound_strategy`. The exchange
6
+ runs in the tool, not in parent middleware (the principal crosses the mount boundary; middleware
7
+ state does not).
8
+ """
9
+
10
+ from .claims import decode_claims_unverified
11
+ from .config import OutboundConfig, OutboundCredential
12
+ from .strategies import exchange, outbound_strategies, register_outbound_strategy
13
+
14
+ __all__ = [
15
+ "OutboundConfig",
16
+ "OutboundCredential",
17
+ "exchange",
18
+ "register_outbound_strategy",
19
+ "outbound_strategies",
20
+ "decode_claims_unverified",
21
+ ]
@@ -0,0 +1,26 @@
1
+ """Best-effort JWT claim decode for **audit / traceability only** — never for authorization.
2
+
3
+ The container does not re-verify tokens it mints/forwards outbound: a `passthrough` token was already
4
+ validated inbound, and an `rfc_8693` / `service_account` token came straight from a just-called
5
+ trusted STS. We decode the payload only to record `sub` / `aud` / `jti` / `scope` in the audit chain.
6
+ Opaque (non-JWT) tokens decode to ``{}`` — the credential still works, it just carries no claims.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import json
13
+ from typing import Any
14
+
15
+
16
+ def decode_claims_unverified(token: str | None) -> dict[str, Any]:
17
+ """Decode a JWT's payload without signature verification. ``{}`` for opaque/empty tokens."""
18
+ if not token:
19
+ return {}
20
+ try:
21
+ payload_b64 = token.split(".")[1]
22
+ padded = payload_b64 + "=" * (-len(payload_b64) % 4)
23
+ decoded = json.loads(base64.urlsafe_b64decode(padded))
24
+ except Exception:
25
+ return {}
26
+ return decoded if isinstance(decoded, dict) else {}
@@ -0,0 +1,139 @@
1
+ """Outbound identity-propagation config + the credential it produces.
2
+
3
+ `OutboundConfig` is a backend's outbound fragment — constructed by the extension from *its own* env
4
+ prefix (`KDBAI_OUTBOUND_*`, `KXI_OUTBOUND_*`, …); the container owns the schema + the strategies, the
5
+ extension owns *which* strategy and *which* endpoint. Frozen, so it can key a per-principal connection
6
+ cache.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Literal, Optional
12
+
13
+ from pydantic import BaseModel, ConfigDict, Field, SecretStr
14
+
15
+ # RFC 8693 / OAuth constants (the wire shape `rfc_8693` sends).
16
+ GRANT_TOKEN_EXCHANGE = "urn:ietf:params:oauth:grant-type:token-exchange"
17
+ GRANT_CLIENT_CREDENTIALS = "client_credentials"
18
+ TOKEN_TYPE_ACCESS = "urn:ietf:params:oauth:token-type:access_token"
19
+
20
+
21
+ class OutboundConfig(BaseModel):
22
+ """How a backend propagates the inbound principal outbound (frozen).
23
+
24
+ The public contract for outbound identity propagation: a backend builds this from its own
25
+ env-prefixed fragment and hands it to :func:`exchange`. The per-field descriptions are the
26
+ canonical reference for what each knob does and which strategy consults it.
27
+ """
28
+
29
+ model_config = ConfigDict(frozen=True)
30
+
31
+ strategy: str = Field(
32
+ default="passthrough",
33
+ description=(
34
+ "Which outbound strategy to run. Resolved by name from the register_outbound_strategy "
35
+ "registry at exchange() time (a bare str, not an enum, so custom drivers register "
36
+ "arbitrary names — mirrors the inbound KX_MCP_AUTH mode). Built-ins: passthrough / "
37
+ "rfc_8693 / service_account. An unregistered name raises ValueError at exchange()."
38
+ ),
39
+ )
40
+ token_url: Optional[str] = Field(
41
+ default=None,
42
+ description=(
43
+ "OIDC/STS token endpoint. Required by rfc_8693 and service_account; unused by passthrough."
44
+ ),
45
+ )
46
+ audience: Optional[str] = Field(
47
+ default=None,
48
+ description=(
49
+ "Target backend audience. rfc_8693 requests a token for it; passthrough's guard refuses "
50
+ "unless the inbound token's aud already includes it; service_account may request it."
51
+ ),
52
+ )
53
+ resource: Optional[str] = Field(
54
+ default=None,
55
+ description="Optional RFC 8707 resource URI — an alternative to audience for rfc_8693.",
56
+ )
57
+ client_id: Optional[str] = Field(
58
+ default=None,
59
+ description=(
60
+ "Client identity for authenticating at the token endpoint (rfc_8693 / service_account)."
61
+ ),
62
+ )
63
+ client_secret: Optional[SecretStr] = Field(
64
+ default=None,
65
+ description="Client secret paired with client_id. SecretStr — kept out of logs and reprs.",
66
+ )
67
+ client_auth: Literal["basic", "post"] = Field(
68
+ default="post",
69
+ description=(
70
+ "How credentials reach the token endpoint (RFC 6749 §2.3): 'post' = in the request body "
71
+ "(client_secret_post), 'basic' = Authorization: Basic header (client_secret_basic). "
72
+ "Default 'post' matches the supported backend IdPs. Only "
73
+ "consulted when client_id is set."
74
+ ),
75
+ )
76
+ scopes: Optional[list[str]] = Field(
77
+ default=None,
78
+ description=(
79
+ "Optional requested scopes, space-joined onto the token request "
80
+ "(rfc_8693 / service_account)."
81
+ ),
82
+ )
83
+ subject_token_type: str = Field(
84
+ default=TOKEN_TYPE_ACCESS,
85
+ description=(
86
+ "RFC 8693 subject_token_type for the inbound bearer. Defaults to an OAuth access token."
87
+ ),
88
+ )
89
+ verify: bool = Field(
90
+ default=True,
91
+ description=(
92
+ "TLS verification for the token call exchange() makes when it builds its own client "
93
+ "(ignored if the caller passes its own client). Set False for e.g. a self-signed dev "
94
+ "Keycloak — without the backend importing httpx."
95
+ ),
96
+ )
97
+ timeout: float = Field(
98
+ default=30.0,
99
+ description=(
100
+ "Request timeout (seconds) for that same token call; ignored when the caller passes a "
101
+ "client."
102
+ ),
103
+ )
104
+
105
+
106
+ class OutboundCredential(BaseModel):
107
+ """The product of an exchange: the credential to present to the backend, plus audit metadata."""
108
+
109
+ access_token: str = Field(
110
+ description=(
111
+ "The credential to present to the backend (forwarded as-is by passthrough; minted by "
112
+ "rfc_8693 / service_account)."
113
+ ),
114
+ )
115
+ token_type: str = Field(
116
+ default="Bearer",
117
+ description=(
118
+ "OAuth token type from the token endpoint; 'Bearer' for passthrough and the usual "
119
+ "default otherwise."
120
+ ),
121
+ )
122
+ expires_in: Optional[int] = Field(
123
+ default=None,
124
+ description=(
125
+ "Lifetime in seconds as reported by the token endpoint; None for passthrough (the "
126
+ "forwarded token carries its own expiry)."
127
+ ),
128
+ )
129
+ strategy: str = Field(
130
+ default="",
131
+ description="The strategy that produced this credential — recorded in the audit chain.",
132
+ )
133
+ claims: dict[str, Any] = Field(
134
+ default_factory=dict,
135
+ description=(
136
+ "Best-effort, *unverified* decode of access_token (sub / aud / jti / scope), for the "
137
+ "audit chain ONLY — never an authorization input. {} for opaque (non-JWT) tokens."
138
+ ),
139
+ )
@@ -0,0 +1,225 @@
1
+ """The pluggable outbound token-exchange seam.
2
+
3
+ `exchange(config, subject_token)` resolves `config.strategy` from a registry and runs it, returning
4
+ an `OutboundCredential` for the backend. A registry (not a hard-coded conditional) so a `custom`
5
+ driver — e.g. Microsoft Entra OBO — slots in via `register_outbound_strategy` without touching
6
+ dispatch. Mirrors the inbound verifier seam in `auth/providers.py`. Built-in strategies: `passthrough`
7
+ (forward the inbound bearer when its audience matches), `rfc_8693` (RFC 8693 token exchange), and
8
+ `service_account` (`client_credentials` — the container's own workload identity).
9
+
10
+ Each exchange emits one `kx_mcp.audit` line so the inbound→exchange→outbound chain is traceable.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ from typing import Optional, Protocol
17
+
18
+ import httpx
19
+
20
+ from .claims import decode_claims_unverified
21
+ from .config import (
22
+ GRANT_CLIENT_CREDENTIALS,
23
+ GRANT_TOKEN_EXCHANGE,
24
+ OutboundConfig,
25
+ OutboundCredential,
26
+ )
27
+
28
+ logger = logging.getLogger("kx_mcp.audit")
29
+
30
+
31
+ class OutboundStrategy(Protocol):
32
+ """A custom outbound driver. Registered via ``register_outbound_strategy`` and dispatched by
33
+ ``exchange``. Receives the backend's ``OutboundConfig`` and the inbound bearer (``None`` for
34
+ workload strategies like ``service_account``), plus a keyword-only pooled ``httpx.AsyncClient``
35
+ to reuse. Returns the credential to present to the backend. Raise ``ValueError`` on missing
36
+ config, ``PermissionError`` to refuse (e.g. an audience guard).
37
+ """
38
+
39
+ async def __call__(
40
+ self, config: OutboundConfig, subject_token: Optional[str], *, client: httpx.AsyncClient
41
+ ) -> OutboundCredential: ...
42
+
43
+
44
+ _STRATEGIES: dict[str, OutboundStrategy] = {}
45
+
46
+
47
+ def register_outbound_strategy(name: str, strategy: OutboundStrategy) -> None:
48
+ """Register an outbound strategy. The extension point for `custom` drivers."""
49
+ _STRATEGIES[name] = strategy
50
+
51
+
52
+ def outbound_strategies() -> list[str]:
53
+ """The names of all registered strategies (built-ins + custom), sorted. Mirrors ``auth_modes()``."""
54
+ return sorted(_STRATEGIES)
55
+
56
+
57
+ async def exchange(
58
+ config: OutboundConfig,
59
+ subject_token: Optional[str] = None,
60
+ *,
61
+ client: Optional[httpx.AsyncClient] = None,
62
+ ) -> OutboundCredential:
63
+ """Run the configured outbound strategy and return the backend credential.
64
+
65
+ Pass an `httpx.AsyncClient` to reuse a pooled client; otherwise one is created and closed per
66
+ call. Raises `ValueError` on an unknown strategy or missing required config; `PermissionError`
67
+ when `passthrough`'s audience guard refuses.
68
+
69
+ Emits one `kx_mcp.audit` record per call (the inbound→exchange→outbound chain), on success and
70
+ failure alike.
71
+ """
72
+ try:
73
+ strategy = _STRATEGIES[config.strategy]
74
+ except KeyError:
75
+ raise ValueError(
76
+ f"unknown outbound strategy {config.strategy!r}; known: {outbound_strategies()}"
77
+ ) from None
78
+
79
+ owns_client = client is None
80
+ client = client or httpx.AsyncClient(verify=config.verify, timeout=config.timeout)
81
+ try:
82
+ credential = await strategy(config, subject_token, client=client)
83
+ except Exception as exc:
84
+ _audit(config, subject_token, credential=None, error=exc)
85
+ raise
86
+ finally:
87
+ if owns_client:
88
+ await client.aclose()
89
+
90
+ _audit(config, subject_token, credential=credential, error=None)
91
+ return credential
92
+
93
+
94
+ # --- built-in strategies -----------------------------------------------------------------------
95
+
96
+
97
+ async def _passthrough(
98
+ config: OutboundConfig, subject_token: Optional[str], *, client: httpx.AsyncClient
99
+ ) -> OutboundCredential:
100
+ """Forward the inbound bearer unchanged — only when its audience matches the configured backend.
101
+
102
+ ``audience`` is **required**: forwarding a bearer without checking it was minted for this backend
103
+ is a confused-deputy risk, so an unset audience is *refused* rather than blindly forwarded.
104
+ """
105
+ if not subject_token:
106
+ raise ValueError("passthrough requires an inbound subject token (none present)")
107
+ if not config.audience:
108
+ raise ValueError(
109
+ "passthrough requires `audience` — refusing to forward a bearer without verifying it "
110
+ "was minted for this backend (confused-deputy guard)"
111
+ )
112
+ claims = decode_claims_unverified(subject_token)
113
+ if not _audience_includes(claims.get("aud"), config.audience):
114
+ raise PermissionError(
115
+ f"passthrough refused: inbound audience {claims.get('aud')!r} "
116
+ f"does not include {config.audience!r}"
117
+ )
118
+ return OutboundCredential(access_token=subject_token, strategy="passthrough", claims=claims)
119
+
120
+
121
+ async def _rfc_8693(
122
+ config: OutboundConfig, subject_token: Optional[str], *, client: httpx.AsyncClient
123
+ ) -> OutboundCredential:
124
+ """RFC 8693 token exchange: swap the inbound bearer for a backend-audience token."""
125
+ if not subject_token:
126
+ raise ValueError("rfc_8693 requires an inbound subject token (none present)")
127
+ if not config.token_url:
128
+ raise ValueError("rfc_8693 requires token_url")
129
+ data = {
130
+ "grant_type": GRANT_TOKEN_EXCHANGE,
131
+ "subject_token": subject_token,
132
+ "subject_token_type": config.subject_token_type,
133
+ }
134
+ if config.audience:
135
+ data["audience"] = config.audience
136
+ if config.resource:
137
+ data["resource"] = config.resource
138
+ if config.scopes:
139
+ data["scope"] = " ".join(config.scopes)
140
+ return await _post_for_token(config, data, client, strategy="rfc_8693")
141
+
142
+
143
+ async def _service_account(
144
+ config: OutboundConfig, subject_token: Optional[str], *, client: httpx.AsyncClient
145
+ ) -> OutboundCredential:
146
+ """OAuth client_credentials — the container's own workload identity, not the caller's."""
147
+ if not config.token_url:
148
+ raise ValueError("service_account requires token_url")
149
+ data = {"grant_type": GRANT_CLIENT_CREDENTIALS}
150
+ if config.audience:
151
+ data["audience"] = config.audience
152
+ if config.scopes:
153
+ data["scope"] = " ".join(config.scopes)
154
+ return await _post_for_token(config, data, client, strategy="service_account")
155
+
156
+
157
+ async def _post_for_token(
158
+ config: OutboundConfig, data: dict[str, str], client: httpx.AsyncClient, *, strategy: str
159
+ ) -> OutboundCredential:
160
+ url = config.token_url
161
+ assert url is not None # callers guard this; narrows Optional[str] for the type checker
162
+ headers = {"Accept": "application/json"}
163
+ body = dict(data) # copy — don't mutate the caller's dict when adding post-body creds
164
+ auth: Optional[tuple[str, str]] = None
165
+ if config.client_id:
166
+ secret = config.client_secret.get_secret_value() if config.client_secret else ""
167
+ if config.client_auth == "basic":
168
+ auth = (config.client_id, secret) # client_secret_basic — Authorization header
169
+ else:
170
+ body["client_id"] = config.client_id # client_secret_post — credentials in the body
171
+ body["client_secret"] = secret
172
+ if auth is not None:
173
+ response = await client.post(url, data=body, headers=headers, auth=auth)
174
+ else:
175
+ response = await client.post(url, data=body, headers=headers)
176
+ response.raise_for_status()
177
+ body = response.json()
178
+ token = body.get("access_token")
179
+ if not token:
180
+ raise ValueError(f"{strategy}: token endpoint returned no access_token")
181
+ return OutboundCredential(
182
+ access_token=token,
183
+ token_type=body.get("token_type", "Bearer"),
184
+ expires_in=body.get("expires_in"),
185
+ strategy=strategy,
186
+ claims=decode_claims_unverified(token),
187
+ )
188
+
189
+
190
+ def _audience_includes(aud: object, wanted: str) -> bool:
191
+ if aud is None:
192
+ return False
193
+ audiences = aud if isinstance(aud, list) else [aud]
194
+ return wanted in audiences
195
+
196
+
197
+ def _audit(
198
+ config: OutboundConfig,
199
+ subject_token: Optional[str],
200
+ *,
201
+ credential: Optional[OutboundCredential],
202
+ error: Optional[Exception],
203
+ ) -> None:
204
+ subject = decode_claims_unverified(subject_token).get("sub") or "anonymous"
205
+ if error is not None:
206
+ logger.info(
207
+ "audit exchange strategy=%s subject=%s -> audience=%s outcome=error error=%s",
208
+ config.strategy, subject, config.audience, type(error).__name__,
209
+ )
210
+ return
211
+ assert credential is not None
212
+ claims = credential.claims
213
+ logger.info(
214
+ "audit exchange strategy=%s subject=%s -> audience=%s jti=%s scopes=%s outcome=ok",
215
+ credential.strategy or config.strategy,
216
+ subject,
217
+ config.audience or claims.get("aud"),
218
+ claims.get("jti"),
219
+ claims.get("scope"),
220
+ )
221
+
222
+
223
+ register_outbound_strategy("passthrough", _passthrough)
224
+ register_outbound_strategy("rfc_8693", _rfc_8693)
225
+ register_outbound_strategy("service_account", _service_account)
kx_auth_core/py.typed ADDED
File without changes
@@ -0,0 +1,105 @@
1
+ """Container inbound-auth configuration (the ``KX_MCP_AUTH`` family) — the shared config contract.
2
+
3
+ The *mode* lives in the bare ``KX_MCP_AUTH`` env var (``unset`` / ``static`` / ``jwks`` /
4
+ ``oidc_proxy`` / ``entra``; later ``proxy_headers`` — see the verifier seam in
5
+ :mod:`kx_mcp_core.auth.providers`); the mode-specific detail lives under the ``KX_MCP_AUTH_*``
6
+ prefix. This is the container's config fragment — distinct from each extension's own prefix
7
+ (``KDBX_DB_*``, ``KDBAI_DB_*``).
8
+
9
+ This model lives in the lean ``kx-auth-core`` package (no fastmcp) so **both** the container and the
10
+ client-side ``kx auth`` CLI bind to the *same* config: the CLI validates a bearer against the same
11
+ keys/issuer/audience the container enforces. ``kx_mcp_core.auth.AuthSettings`` re-exports it.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import List, Optional
17
+
18
+ from pydantic import AliasChoices, Field, field_validator
19
+ from pydantic_settings import BaseSettings, SettingsConfigDict
20
+
21
+
22
+ class AuthSettings(BaseSettings):
23
+ """Resolved inbound-auth config. Read from the environment by default; constructible by name
24
+ in tests/glue (``AuthSettings(mode="static", public_key=...)``)."""
25
+
26
+ model_config = SettingsConfigDict(
27
+ env_prefix="KX_MCP_AUTH_",
28
+ populate_by_name=True,
29
+ extra="ignore",
30
+ )
31
+
32
+ # The mode selector is the bare KX_MCP_AUTH var (not KX_MCP_AUTH_MODE) — see the module docstring.
33
+ mode: str = Field(default="unset", validation_alias="KX_MCP_AUTH")
34
+
35
+ # static (RS256 against a local public key) — supply the PEM inline or by path.
36
+ public_key: Optional[str] = None
37
+ public_key_path: Optional[str] = None
38
+
39
+ # jwks (RS256 against a remote JWKS endpoint — Keycloak / Auth0 / any OIDC issuer).
40
+ jwks_uri: Optional[str] = None
41
+
42
+ # The container's own public base URL, as clients reach it (e.g. https://mcp.example or
43
+ # http://127.0.0.1:8000). When set, the jwks provider advertises RFC 9728 Protected Resource
44
+ # Metadata (the issuer as authorization_server) so an MCP client / `kx auth login` can *discover*
45
+ # the IdP from the server. Unset → the verifier validates only (no discovery) — correct for the
46
+ # stdio / no-public-URL posture. Must equal the URL the client actually connects to (proxy-aware).
47
+ resource_url: Optional[str] = None
48
+
49
+ # Shared claim-validation knobs for static + jwks.
50
+ issuer: Optional[str] = None
51
+ audience: Optional[str] = None
52
+ algorithm: str = "RS256"
53
+ required_scopes: Optional[List[str]] = None
54
+
55
+ # entra (FastMCP's AzureProvider — the OAuth-Proxy front door for clients that must *log in*
56
+ # against Microsoft Entra, which has no open DCR). These name the **one app you pre-register**;
57
+ # the proxy brokers the flow on its behalf. `base_url` is the container's public URL (reuse
58
+ # `resource_url`). We accept both the canonical `KX_MCP_AUTH_*` names and the `AZURE_*` names
59
+ # the Azure tooling / FastMCP docs use, so an existing Azure-app `.env` works unchanged.
60
+ client_id: Optional[str] = Field(
61
+ default=None,
62
+ validation_alias=AliasChoices("KX_MCP_AUTH_CLIENT_ID", "AZURE_CLIENT_ID"),
63
+ )
64
+ client_secret: Optional[str] = Field(
65
+ default=None,
66
+ validation_alias=AliasChoices("KX_MCP_AUTH_CLIENT_SECRET", "AZURE_CLIENT_SECRET"),
67
+ )
68
+ tenant_id: Optional[str] = Field(
69
+ default=None,
70
+ validation_alias=AliasChoices("KX_MCP_AUTH_TENANT_ID", "AZURE_TENANT_ID"),
71
+ )
72
+ # Application ID URI exposed by the app registration; defaults to api://{client_id} in Entra,
73
+ # so leave unset unless you configured a custom one.
74
+ identifier_uri: Optional[str] = None
75
+
76
+ # oidc_proxy (FastMCP's OIDCProxy — `entra` generalised to any OIDC issuer whose DCR is
77
+ # closed). Reuses `client_id`/`client_secret` (the one app you pre-register), `resource_url`
78
+ # (base_url), `audience`, `algorithm` and `required_scopes`.
79
+
80
+ # The discovery document. Derived as `{issuer}/.well-known/openid-configuration` when unset;
81
+ # set it for issuers that serve it elsewhere (Entra v2.0, some Auth0/Okta setups).
82
+ config_url: Optional[str] = None
83
+ # Signs container-issued tokens and keys the encrypted OAuth-state store. Unset → both are
84
+ # derived from `client_secret`. Required for a public/PKCE client with no secret.
85
+ jwt_signing_key: Optional[str] = None
86
+ # False accepts a discovery document missing an OIDC-required field (usually
87
+ # `subject_types_supported`).
88
+ oidc_strict: bool = True
89
+
90
+ @field_validator("mode", mode="before")
91
+ @classmethod
92
+ def _normalise_mode(cls, value: object) -> str:
93
+ """Empty / whitespace / unset all collapse to ``unset`` (the no-auth bundling posture)."""
94
+ if isinstance(value, str) and value.strip():
95
+ return value.strip().lower()
96
+ return "unset"
97
+
98
+ @field_validator("required_scopes", mode="before")
99
+ @classmethod
100
+ def _split_scopes(cls, value: object) -> object:
101
+ """Accept a comma- or space-separated string from the environment (not just JSON)."""
102
+ if isinstance(value, str):
103
+ parts = value.replace(",", " ").split()
104
+ return parts or None
105
+ return value
kx_auth_core/verify.py ADDED
@@ -0,0 +1,240 @@
1
+ """The shared, fastmcp-free JWT verifier — one implementation, two front doors.
2
+
3
+ ``verify_token(token, settings)`` decodes and validates a bearer against the same ``KX_MCP_AUTH``
4
+ config the container enforces, using **joserfc** (the exact JOSE library FastMCP's ``JWTVerifier``
5
+ uses under the hood) so the CLI's verdict matches what the container would do. The serving path
6
+ (``kx_mcp_core.auth.providers``) keeps FastMCP's ``JWTVerifier`` — this is the *same* validation
7
+ logic, mirrored; the repo's ``test_auth_cli_contract`` pins the two in agreement.
8
+
9
+ Unlike the FastMCP verifier (which collapses every failure to ``None``), this returns a
10
+ **categorised** :class:`VerifyResult` so the ``kx auth introspect`` CLI can map to its stable
11
+ exit-code contract:
12
+
13
+ * ``OK`` — valid, accepted (exit 0 / HTTP 200)
14
+ * ``ERROR`` — malformed token, bad signature, unreachable/bad JWKS (exit 1 / HTTP 400/500)
15
+ * ``AUTH_REQUIRED`` — well-signed but expired; the agent should re-login (exit 3 / HTTP 401)
16
+ * ``DENIED`` — well-signed but not acceptable here: issuer / audience / required-scope
17
+ mismatch (exit 4 / HTTP 403)
18
+
19
+ The static-vs-denied split mirrors HTTP 401/403: a structurally broken token is an ``ERROR``; a
20
+ genuine, properly-signed token that simply isn't authorised for this resource is ``DENIED``.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import base64
26
+ import json
27
+ import time
28
+ from dataclasses import dataclass
29
+ from pathlib import Path
30
+ from typing import Any, Optional
31
+
32
+ import httpx
33
+ from joserfc import jwk, jwt
34
+ from joserfc.errors import JoseError
35
+
36
+ from .settings import AuthSettings
37
+
38
+ # Verdict categories (stable; the CLI maps these to exit codes).
39
+ OK = "ok"
40
+ ERROR = "error"
41
+ AUTH_REQUIRED = "auth_required"
42
+ DENIED = "denied"
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class VerifyResult:
47
+ """The categorised outcome of verifying a bearer. ``valid`` is true only for :data:`OK`."""
48
+
49
+ category: str
50
+ valid: bool
51
+ claims: Optional[dict] = None
52
+ client_id: Optional[str] = None
53
+ scopes: Optional[list] = None
54
+ reason: Optional[str] = None
55
+
56
+ @classmethod
57
+ def ok(cls, claims: dict, client_id: str, scopes: list) -> "VerifyResult":
58
+ return cls(OK, True, claims=claims, client_id=client_id, scopes=scopes)
59
+
60
+ @classmethod
61
+ def fail(cls, category: str, reason: str) -> "VerifyResult":
62
+ return cls(category, False, reason=reason)
63
+
64
+
65
+ def resolve_public_key(settings: AuthSettings) -> str:
66
+ """Resolve the static-mode public-key PEM (inline or by path). Shared with the container's
67
+ ``_build_static`` so both read the key identically."""
68
+ if settings.public_key:
69
+ return settings.public_key
70
+ if settings.public_key_path:
71
+ return Path(settings.public_key_path).read_text()
72
+ raise ValueError(
73
+ "KX_MCP_AUTH=static requires KX_MCP_AUTH_PUBLIC_KEY or KX_MCP_AUTH_PUBLIC_KEY_PATH "
74
+ "(an RS256 public-key PEM)"
75
+ )
76
+
77
+
78
+ def _import_key(key: str | bytes | dict, algorithm: str):
79
+ """Import key material for the algorithm family (mirrors FastMCP's ``_import_key_for_algorithm``)."""
80
+ if algorithm.startswith("HS"):
81
+ return jwk.import_key(key, "oct")
82
+ if algorithm.startswith(("RS", "PS")):
83
+ return jwk.import_key(key, "RSA")
84
+ if algorithm.startswith("ES"):
85
+ return jwk.import_key(key, "EC")
86
+ raise ValueError(f"Unsupported algorithm: {algorithm}.")
87
+
88
+
89
+ def _jwk_to_pem(key_data: dict) -> str:
90
+ kty = key_data.get("kty")
91
+ if kty == "RSA":
92
+ return jwk.import_key(key_data, "RSA").as_pem().decode("utf-8")
93
+ if kty == "EC":
94
+ return jwk.import_key(key_data, "EC").as_pem().decode("utf-8")
95
+ raise ValueError(f"Unsupported JWK key type: {kty!r}")
96
+
97
+
98
+ def _decode_header(token: str) -> dict:
99
+ """Decode a JWT's header segment without verifying — used only to read ``kid`` for JWKS lookup."""
100
+ try:
101
+ header_b64 = token.split(".")[0]
102
+ padded = header_b64 + "=" * (-len(header_b64) % 4)
103
+ return json.loads(base64.urlsafe_b64decode(padded))
104
+ except Exception as exc: # malformed token — caller surfaces as ERROR
105
+ raise ValueError(f"could not decode token header: {exc}") from exc
106
+
107
+
108
+ def _resolve_jwks_key(settings: AuthSettings, token: str) -> str:
109
+ """Fetch the JWKS and return the PEM for the token's ``kid`` (or the sole key)."""
110
+ if not settings.jwks_uri:
111
+ raise ValueError("KX_MCP_AUTH=jwks requires KX_MCP_AUTH_JWKS_URI (the issuer's JWKS endpoint)")
112
+ kid = _decode_header(token).get("kid")
113
+ resp = httpx.get(settings.jwks_uri, timeout=10.0)
114
+ resp.raise_for_status()
115
+ # A LIST of (kid, pem) pairs, not a dict keyed on kid: two entries sharing a kid — or, the case
116
+ # that actually bit, two entries with NO kid at all, which both used to collapse onto one
117
+ # "_default" slot — silently reduced the key count, turning a genuine "multiple keys, no kid"
118
+ # ambiguity into a single-key selection whose outcome depended on the order the issuer happened
119
+ # to serve its JWKS in. Ambiguity must be an error, and the same error either way.
120
+ entries = [(key_data.get("kid"), _jwk_to_pem(key_data)) for key_data in resp.json().get("keys", [])]
121
+ if not entries:
122
+ raise ValueError("no keys found in JWKS")
123
+ if kid:
124
+ matches = [pem for entry_kid, pem in entries if entry_kid == kid]
125
+ if not matches:
126
+ raise ValueError(f"key ID {kid!r} not found in JWKS")
127
+ if len(matches) > 1:
128
+ raise ValueError(f"multiple keys in JWKS share key ID (kid) {kid!r}")
129
+ return matches[0]
130
+ if len(entries) == 1:
131
+ return entries[0][1]
132
+ raise ValueError("multiple keys in JWKS but no key ID (kid) in token")
133
+
134
+
135
+ def _extract_scopes(claims: dict) -> tuple[list, str]:
136
+ """Scopes from the standard ``scope`` claim, falling back to ``scp``. Mirrors FastMCP.
137
+
138
+ Returns ``(scopes, status)`` where status is one of:
139
+
140
+ * ``"ok"`` — a well-formed claim, or none at all.
141
+ * ``"invalid_members"`` — a **list**-valued claim carrying a non-string member. FastMCP's
142
+ ``JWTVerifier`` **rejects** this token outright (``AccessToken.scopes`` is typed
143
+ ``list[str]``), verified live against the pinned fastmcp, so the caller must too — anything
144
+ else and ``kx auth introspect`` would report a verdict the container does not enforce.
145
+ Rejecting also means an *unhashable* member never reaches the caller's ``set(scopes)``, which
146
+ is evaluated outside the try/except and used to escape as a bare ``TypeError``.
147
+ * ``"malformed_type"`` — the claim is present but is neither a string nor a list. ``JWTVerifier``
148
+ **accepts** this, treating it as no scope, so the token stays valid here as well; the status
149
+ exists only so the caller's reason can distinguish "carried a broken claim" from "carried no
150
+ claim", which used to read identically.
151
+
152
+ The ``scope`` → ``scp`` fall-through is preserved: a wrong-typed ``scope`` still lets a
153
+ well-formed ``scp`` win, it just remembers the first claim was broken.
154
+
155
+ Both verdicts are pinned by ``tests/deterministic/unit/test_auth_cli_contract.py``, which now
156
+ parametrizes the hostile shapes — it previously only fed well-formed claims, so this function
157
+ was free to drift from the verifier it is meant to mirror.
158
+ """
159
+ status = "ok"
160
+ for claim in ("scope", "scp"):
161
+ if claim not in claims:
162
+ continue
163
+ value = claims[claim]
164
+ if isinstance(value, str):
165
+ return value.split(), status
166
+ if isinstance(value, list):
167
+ if not all(isinstance(member, str) for member in value):
168
+ return [], "invalid_members"
169
+ return value, status
170
+ status = "malformed_type"
171
+ return [], status
172
+
173
+
174
+ def _audience_ok(expected: Any, actual: Any) -> bool:
175
+ expected_list = expected if isinstance(expected, list) else [expected]
176
+ actual_list = actual if isinstance(actual, list) else [actual]
177
+ return any(e in actual_list for e in expected_list)
178
+
179
+
180
+ def verify_token(token: str, settings: AuthSettings) -> VerifyResult:
181
+ """Verify ``token`` against ``settings`` and return a categorised :class:`VerifyResult`.
182
+
183
+ Signature failures / malformed tokens / JWKS problems are :data:`ERROR`; a well-signed but
184
+ expired token is :data:`AUTH_REQUIRED`; a well-signed token failing issuer / audience /
185
+ required-scope checks is :data:`DENIED`. The validation order mirrors FastMCP's ``JWTVerifier``.
186
+ """
187
+ if settings.mode not in ("static", "jwks"):
188
+ return VerifyResult.fail(
189
+ ERROR, f"introspect requires KX_MCP_AUTH=static or jwks (got {settings.mode!r})"
190
+ )
191
+
192
+ # 1. Resolve the verification key, then verify the signature (joserfc raises on bad sig/format).
193
+ try:
194
+ if settings.mode == "static":
195
+ verification_key = resolve_public_key(settings)
196
+ else:
197
+ verification_key = _resolve_jwks_key(settings, token)
198
+ key = _import_key(verification_key, settings.algorithm)
199
+ claims = jwt.decode(token, key, algorithms=[settings.algorithm]).claims
200
+ except JoseError as exc:
201
+ return VerifyResult.fail(ERROR, f"invalid signature or token format: {exc}")
202
+ except httpx.HTTPError as exc:
203
+ return VerifyResult.fail(ERROR, f"could not fetch JWKS: {exc}")
204
+ except (ValueError, KeyError, TypeError) as exc:
205
+ return VerifyResult.fail(ERROR, str(exc))
206
+
207
+ client_id = str(
208
+ claims.get("client_id") or claims.get("azp") or claims.get("sub") or "unknown"
209
+ )
210
+
211
+ # 2. Expiry — a real credential, just stale: the agent should re-login.
212
+ exp = claims.get("exp")
213
+ if exp is not None and exp < time.time():
214
+ return VerifyResult.fail(AUTH_REQUIRED, "token expired")
215
+
216
+ # 3. Issuer / audience / required scopes — well-signed but not acceptable here → DENIED.
217
+ if settings.issuer and claims.get("iss") != settings.issuer:
218
+ return VerifyResult.fail(
219
+ DENIED, f"issuer mismatch (got {claims.get('iss')!r}, expected {settings.issuer!r})"
220
+ )
221
+ if settings.audience and not _audience_ok(settings.audience, claims.get("aud")):
222
+ return VerifyResult.fail(
223
+ DENIED, f"audience mismatch (got {claims.get('aud')!r}, expected {settings.audience!r})"
224
+ )
225
+
226
+ scopes, scope_status = _extract_scopes(claims)
227
+ if scope_status == "invalid_members":
228
+ # Rejected, matching JWTVerifier. Categorised ERROR (a broken token, not a policy refusal)
229
+ # and named explicitly, so this is never confused with a legitimate scope shortfall.
230
+ return VerifyResult.fail(ERROR, "malformed scope claim: non-string member in a list-valued scope")
231
+ if settings.required_scopes:
232
+ missing = set(settings.required_scopes) - set(scopes)
233
+ if missing:
234
+ # Name the malformed case: otherwise "missing required scopes" reads identically whether
235
+ # the token carried no scope at all or carried a broken one, and only the second is a
236
+ # bug to chase at the issuer.
237
+ detail = " (malformed scope claim)" if scope_status == "malformed_type" else ""
238
+ return VerifyResult.fail(DENIED, f"missing required scopes: {sorted(missing)}{detail}")
239
+
240
+ return VerifyResult.ok(claims=claims, client_id=client_id, scopes=scopes)
@@ -0,0 +1,49 @@
1
+ Metadata-Version: 2.5
2
+ Name: kx-auth-core
3
+ Version: 0.5.0
4
+ Summary: Lean, fastmcp-free shared core for kx auth: the KX_MCP_AUTH config contract + a joserfc verifier, depended on by both the container (kx-mcp-core) and the client-side kx auth CLI.
5
+ Project-URL: Homepage, https://github.com/KxSystems/kx-mcp-server-container
6
+ Project-URL: Repository, https://github.com/KxSystems/kx-mcp-server-container
7
+ Project-URL: Changelog, https://github.com/KxSystems/kx-mcp-server-container/blob/main/CHANGELOG.md
8
+ License-Expression: Apache-2.0
9
+ Requires-Python: <3.14,>=3.10
10
+ Requires-Dist: cryptography>=50.0.0
11
+ Requires-Dist: httpx<1.0,>=0.28.1
12
+ Requires-Dist: joserfc>=1.1.0
13
+ Requires-Dist: pydantic-settings>=2.14.2
14
+ Description-Content-Type: text/markdown
15
+
16
+ # kx-auth-core
17
+
18
+ The lean, deliberately **fastmcp-free** auth core shared by the KX MCP composition container
19
+ (`kx-mcp-core`), the backend bundles, and the client-side `kx auth` CLI (`kx-auth-cli`). It holds
20
+ the auth *mechanisms* so every consumer binds to one implementation:
21
+
22
+ - **Inbound config + verification** — `AuthSettings` (the `KX_MCP_AUTH*` contract) and a
23
+ [joserfc](https://jose.authlib.org/) `verify_token` returning a categorised verdict
24
+ (`ok` / `error` / `auth_required` / `denied`). joserfc is the same JOSE library FastMCP's
25
+ `JWTVerifier` uses, so server and CLI verification cannot drift.
26
+ - **Outbound identity** — `exchange(config, subject_token)` over the
27
+ `register_outbound_strategy` registry (`passthrough` / `rfc_8693` / `service_account` / custom),
28
+ with the `OutboundConfig` / `OutboundCredential` shapes. Pure token→credential; no fastmcp.
29
+ - **Authorization contract** — `AuthzRequest` / `AuthzDecision` (boolean-first, with an
30
+ `obligations` slot for scope-down), the `AuthzAdapter` protocol, and the
31
+ `register_authz_adapter` / `decide` registry behind the container's `@authorize` seam.
32
+ - **Identity assertion projection** — `project_principal` / `project_from_claims`, the
33
+ fastmcp-free (and pykx-free) claims→dict projection the kdb-x identity-assertion path ferries
34
+ to q.
35
+
36
+ ## Who should depend on it
37
+
38
+ Backend bundles (for `exchange` and the authz contract) and client-side tooling (the `kx auth`
39
+ CLI). If you are assembling a *server*, depend on `kx-mcp-core` instead — it re-exports these
40
+ seams. Two invariants to respect when contributing: this package never imports `fastmcp` (it ships
41
+ where the agent/client runs), and `verify_token` must stay in agreement with the container's
42
+ `JWTVerifier` (pinned by contract tests in the parent repo).
43
+
44
+ ## Documentation
45
+
46
+ Full reference in the [`kx-mcp-server-container`](https://github.com/KxSystems/kx-mcp-server-container)
47
+ repository: the [auth guide](https://github.com/KxSystems/kx-mcp-server-container/blob/main/docs/auth.md)
48
+ (inbound modes, outbound strategies, authorization) and the design specs (token-exchange,
49
+ identity-assertion, authorization). All workspace packages version in lockstep from release tags.
@@ -0,0 +1,13 @@
1
+ kx_auth_core/__init__.py,sha256=ZyxT0Nkb78wPxVCJx1oI5xUtim4gM3SS9NlqQRTTLfw,1910
2
+ kx_auth_core/assertion.py,sha256=I3mSXtwm5489tacTylEcajeCDBTeGe9HRfldKekniPY,4585
3
+ kx_auth_core/authz.py,sha256=6xaojs7ZA_CMeXK7aifhHEp5SHHucc2HI7ba9ryqe2k,8059
4
+ kx_auth_core/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ kx_auth_core/settings.py,sha256=gXNw5IKGV1s1_fXF6ptD0JqBSMJso3qUOx3Cx-mkKbY,5217
6
+ kx_auth_core/verify.py,sha256=KYbXYACAdV2kMchAmc5wmY7olW0aVXkoFlR8Bd7_YPQ,11379
7
+ kx_auth_core/outbound/__init__.py,sha256=gBrx6wgpYMvPCNPVzC9YSUrVOlnxtLqdgzM1YAFiFtM,833
8
+ kx_auth_core/outbound/claims.py,sha256=770eEFj4RAuV_0ybzRQBLl8GGRowWmnNC4H6qTBDFWg,1059
9
+ kx_auth_core/outbound/config.py,sha256=rbNibjHVOJMaB5jJFGWyh8Z8LiSfHw-lfywzAbajHDI,5456
10
+ kx_auth_core/outbound/strategies.py,sha256=NMUOWDyoNLd84ywvYk4DCxMjjg-oHNx995Fu04TfI3g,8868
11
+ kx_auth_core-0.5.0.dist-info/METADATA,sha256=sUBvo0smaegxKRRo0-mMF4JuGsDngO0Bx-wq1Hy9VDM,2959
12
+ kx_auth_core-0.5.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
13
+ kx_auth_core-0.5.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any