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.
- kx_auth_core/__init__.py +71 -0
- kx_auth_core/assertion.py +110 -0
- kx_auth_core/authz.py +148 -0
- kx_auth_core/outbound/__init__.py +21 -0
- kx_auth_core/outbound/claims.py +26 -0
- kx_auth_core/outbound/config.py +139 -0
- kx_auth_core/outbound/strategies.py +225 -0
- kx_auth_core/py.typed +0 -0
- kx_auth_core/settings.py +105 -0
- kx_auth_core/verify.py +240 -0
- kx_auth_core-0.5.0.dist-info/METADATA +49 -0
- kx_auth_core-0.5.0.dist-info/RECORD +13 -0
- kx_auth_core-0.5.0.dist-info/WHEEL +4 -0
kx_auth_core/__init__.py
ADDED
|
@@ -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
|
kx_auth_core/settings.py
ADDED
|
@@ -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,,
|