auth51 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,33 @@
1
+ name: Publish to PyPI
2
+
3
+ # Publishes on a GitHub Release. PyPI Trusted Publishing (OIDC) — no stored API
4
+ # token. Configure the trusted publisher on PyPI first: project "auth51" →
5
+ # publisher = this repo, workflow "publish.yml", environment "pypi".
6
+ # NOTE: auth51 depends on auth51-checksum — publish that project to PyPI FIRST,
7
+ # or `pip install auth51` won't resolve for anyone.
8
+
9
+ on:
10
+ release:
11
+ types: [published]
12
+ workflow_dispatch: {}
13
+
14
+ permissions:
15
+ contents: read
16
+
17
+ jobs:
18
+ pypi:
19
+ runs-on: ubuntu-latest
20
+ environment: pypi
21
+ permissions:
22
+ # Job-level permissions REPLACE the workflow-level block (not merged), so
23
+ # both must be listed here or checkout 404s on a private repo.
24
+ contents: read # let actions/checkout read the (private) repo
25
+ id-token: write # mint the OIDC token PyPI verifies (no stored secret)
26
+ steps:
27
+ - uses: actions/checkout@v4
28
+ - uses: actions/setup-python@v5
29
+ with:
30
+ python-version: "3.12"
31
+ - run: python -m pip install --upgrade build
32
+ - run: python -m build
33
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,6 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ *.egg-info/
5
+ .pytest_cache/
6
+ dist/
auth51-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,67 @@
1
+ Metadata-Version: 2.4
2
+ Name: auth51
3
+ Version: 0.1.0
4
+ Summary: auth51 client (embed mode) — in-process egress interception that gives every agent action a scoped, verifiable, DPoP-bound intent token, minted at the source and verified at the resource.
5
+ Project-URL: Homepage, https://auth51.com
6
+ Project-URL: Repository, https://github.com/unforge-io/auth51-client-python
7
+ Author-email: unforge <dev-admin@unforge.io>
8
+ License: Proprietary
9
+ Keywords: agent-identity,ai-agents,auth51,dpop,intent-token,mcp,oauth
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: Other/Proprietary License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Security
14
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
15
+ Requires-Python: >=3.10
16
+ Requires-Dist: auth51-checksum>=0.1.1
17
+ Requires-Dist: cryptography>=42
18
+ Requires-Dist: pyjwt>=2.9
19
+ Provides-Extra: dev
20
+ Requires-Dist: httpx>=0.27; extra == 'dev'
21
+ Requires-Dist: pytest>=8; extra == 'dev'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # auth51 — Python client (embed mode)
25
+
26
+ The in-process enforcement point: it intercepts your agent's egress and gives
27
+ every governed call a **scoped, verifiable intent token** — minted at the source,
28
+ verified at the resource. This is the generalized, MCP-era successor to the
29
+ original A-JWT client shim, with **contextvar-based agent attribution** (no call-
30
+ stack introspection) per DESIGN §7b.
31
+
32
+ It's the **durable, unbypassable** path: it sees *all* egress in-process (native
33
+ tool calls *and* MCP calls), and depends only on the HTTP library — not on any
34
+ agent host's conventions.
35
+
36
+ ## Use
37
+ ```python
38
+ import auth51
39
+
40
+ auth51.configure(
41
+ authority_url="https://authority.auth51.com",
42
+ client_id="...", client_secret="...",
43
+ audiences=["api.internal"], # hosts to govern; everything else passes through
44
+ )
45
+
46
+ with auth51.agent("ReviewBot", checksum=agent_checksum, scope="read:repo"):
47
+ httpx.get("https://api.internal/repos/...") # ← stamped with an intent token
48
+ ```
49
+ Config can also come from env: `AUTH51_AUTHORITY_URL`, `AUTH51_CLIENT_ID`,
50
+ `AUTH51_CLIENT_SECRET`, `AUTH51_AUDIENCES` (comma-sep), `AUTH51_FAIL_OPEN`.
51
+
52
+ ## How it works
53
+ - **One import installs it** (idempotent; a no-op until configured + inside an `agent()` context).
54
+ - A framework adapter (LangChain/CrewAI — next) or the app sets the current agent via a contextvar at the tool-call boundary; the interceptor reads it.
55
+ - On governed egress: mint a scoped intent token (Hop A) → attach as a Bearer header → forward. Minting uses **stdlib urllib** so it never re-enters the patched HTTP client.
56
+ - **fail-open** by default (mint failure → forward unstamped, don't wedge the agent); set `fail_open=False` to fail closed.
57
+
58
+ ## Retained from the original A-JWT client shim
59
+ - **Startup-sync (`load_agents`)** — preload registered agents from the authority, index by checksum, detect collisions, and fail-fast on drift when the app declares expected checksums. *Boot-time validation: a mis-deployed agent is caught before it runs.*
60
+ - **Workflow seams** — `with auth51.workflow(id): ...` + `record_step(...)` track an execution; the chain + completed-steps + `prev_jti` ride into each intent token's `delegation_context` (§4.3 tamper-evident trace).
61
+ - **contextvar attribution** + smart caching — kept; **call-stack introspection dropped.**
62
+
63
+ ## Status
64
+ - ✅ contextvar agent attribution · httpx **sync + async** + **requests** interception · mint + attach · fail-open/closed
65
+ - ✅ `load_agents()` startup-sync (cache + collision + drift validation) · workflow seams (delegation_context + `prev_jti` chain)
66
+ - ✅ LangChain adapter (`auth51.adapters.langchain.Auth51CallbackHandler`) — sets agent context + records steps at tool boundaries
67
+ - ⏭️ next: PoP signing (the `cnf` binding) · MCP `_meta` injection · `aiohttp` · local checksum recompute (shared lib) · live e2e against the deployed authority
auth51-0.1.0/README.md ADDED
@@ -0,0 +1,44 @@
1
+ # auth51 — Python client (embed mode)
2
+
3
+ The in-process enforcement point: it intercepts your agent's egress and gives
4
+ every governed call a **scoped, verifiable intent token** — minted at the source,
5
+ verified at the resource. This is the generalized, MCP-era successor to the
6
+ original A-JWT client shim, with **contextvar-based agent attribution** (no call-
7
+ stack introspection) per DESIGN §7b.
8
+
9
+ It's the **durable, unbypassable** path: it sees *all* egress in-process (native
10
+ tool calls *and* MCP calls), and depends only on the HTTP library — not on any
11
+ agent host's conventions.
12
+
13
+ ## Use
14
+ ```python
15
+ import auth51
16
+
17
+ auth51.configure(
18
+ authority_url="https://authority.auth51.com",
19
+ client_id="...", client_secret="...",
20
+ audiences=["api.internal"], # hosts to govern; everything else passes through
21
+ )
22
+
23
+ with auth51.agent("ReviewBot", checksum=agent_checksum, scope="read:repo"):
24
+ httpx.get("https://api.internal/repos/...") # ← stamped with an intent token
25
+ ```
26
+ Config can also come from env: `AUTH51_AUTHORITY_URL`, `AUTH51_CLIENT_ID`,
27
+ `AUTH51_CLIENT_SECRET`, `AUTH51_AUDIENCES` (comma-sep), `AUTH51_FAIL_OPEN`.
28
+
29
+ ## How it works
30
+ - **One import installs it** (idempotent; a no-op until configured + inside an `agent()` context).
31
+ - A framework adapter (LangChain/CrewAI — next) or the app sets the current agent via a contextvar at the tool-call boundary; the interceptor reads it.
32
+ - On governed egress: mint a scoped intent token (Hop A) → attach as a Bearer header → forward. Minting uses **stdlib urllib** so it never re-enters the patched HTTP client.
33
+ - **fail-open** by default (mint failure → forward unstamped, don't wedge the agent); set `fail_open=False` to fail closed.
34
+
35
+ ## Retained from the original A-JWT client shim
36
+ - **Startup-sync (`load_agents`)** — preload registered agents from the authority, index by checksum, detect collisions, and fail-fast on drift when the app declares expected checksums. *Boot-time validation: a mis-deployed agent is caught before it runs.*
37
+ - **Workflow seams** — `with auth51.workflow(id): ...` + `record_step(...)` track an execution; the chain + completed-steps + `prev_jti` ride into each intent token's `delegation_context` (§4.3 tamper-evident trace).
38
+ - **contextvar attribution** + smart caching — kept; **call-stack introspection dropped.**
39
+
40
+ ## Status
41
+ - ✅ contextvar agent attribution · httpx **sync + async** + **requests** interception · mint + attach · fail-open/closed
42
+ - ✅ `load_agents()` startup-sync (cache + collision + drift validation) · workflow seams (delegation_context + `prev_jti` chain)
43
+ - ✅ LangChain adapter (`auth51.adapters.langchain.Auth51CallbackHandler`) — sets agent context + records steps at tool boundaries
44
+ - ⏭️ next: PoP signing (the `cnf` binding) · MCP `_meta` injection · `aiohttp` · local checksum recompute (shared lib) · live e2e against the deployed authority
@@ -0,0 +1,51 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "auth51"
7
+ version = "0.1.0"
8
+ description = "auth51 client (embed mode) — in-process egress interception that gives every agent action a scoped, verifiable, DPoP-bound intent token, minted at the source and verified at the resource."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "Proprietary" }
12
+ authors = [{ name = "unforge", email = "dev-admin@unforge.io" }]
13
+ keywords = ["auth51", "ai-agents", "agent-identity", "oauth", "dpop", "mcp", "intent-token"]
14
+ classifiers = [
15
+ "License :: Other/Proprietary License",
16
+ "Programming Language :: Python :: 3",
17
+ "Intended Audience :: Developers",
18
+ "Topic :: Security",
19
+ "Topic :: Software Development :: Libraries :: Python Modules",
20
+ ]
21
+ # auth51-checksum is the shared registration-contract hash (stdlib-only),
22
+ # published separately to PyPI. The rest of the core uses only stdlib (urllib +
23
+ # hashlib/hmac) — httpx/requests are patched only if present.
24
+ dependencies = [
25
+ "auth51-checksum>=0.1.1",
26
+ # Proof-of-Possession (DPoP / RFC 9449) — ephemeral key gen + proof signing.
27
+ # PoP degrades to a no-op if these are somehow absent (see pop.py).
28
+ "cryptography>=42",
29
+ "pyjwt>=2.9",
30
+ ]
31
+
32
+ [project.optional-dependencies]
33
+ dev = ["pytest>=8", "httpx>=0.27"]
34
+
35
+ [project.urls]
36
+ Homepage = "https://auth51.com"
37
+ Repository = "https://github.com/unforge-io/auth51-client-python"
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/auth51"]
41
+ # _debug.py is demo-only (attack injection + blocking breakpoints). Keep it OUT
42
+ # of the published artifact — it stays in the source tree / vendored copies for
43
+ # the scenario+workforce live demo, and the interceptor loads it optionally.
44
+ exclude = ["src/auth51/_debug.py"]
45
+
46
+ [tool.hatch.build.targets.sdist]
47
+ exclude = ["src/auth51/_debug.py", "tests"]
48
+
49
+ [tool.pytest.ini_options]
50
+ pythonpath = ["src"]
51
+ testpaths = ["tests"]
@@ -0,0 +1,47 @@
1
+ """
2
+ auth51 — the in-process client (embed mode).
3
+
4
+ One import installs egress interception (DESIGN §7b: activation = one import).
5
+ It's a no-op until you `configure(...)` an authority + audiences AND wrap agent
6
+ work in `auth51.agent(...)`. Then every governed outbound call carries a scoped,
7
+ verifiable intent token — minted at the source, verified at the resource.
8
+
9
+ import auth51
10
+ auth51.configure(authority_url=..., client_id=..., client_secret=...,
11
+ audiences=["api.internal"])
12
+ with auth51.agent("ReviewBot", checksum=cs, scope="read:repo"):
13
+ httpx.get("https://api.internal/...") # ← stamped with an intent token
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from .config import Config, configure, get_config, load_env
18
+ from .context import (
19
+ AgentContext, agent, current, current_derived, effective,
20
+ workflow, start_workflow, end_workflow, current_workflow, record_step,
21
+ )
22
+ from .identity import Candidate, DerivedIdentity, derive_identity
23
+ from .interceptor import install, uninstall, on_identity_event
24
+ from .obs import observe, current_obs
25
+ from .mcp import (
26
+ INTENT_META_KEY, build_intent_meta, exchange_intent, extract_intent,
27
+ incoming_intent, inject_intent, mint_and_inject, mint_for_tool,
28
+ scope_for_tool, serving,
29
+ )
30
+ from .registry import load_agents, registered_agents, agent_for_checksum, candidates
31
+
32
+ __all__ = [
33
+ "Config", "configure", "get_config", "load_env",
34
+ "AgentContext", "agent", "current", "current_derived", "effective",
35
+ "workflow", "start_workflow", "end_workflow", "current_workflow", "record_step",
36
+ "Candidate", "DerivedIdentity", "derive_identity",
37
+ "install", "uninstall", "on_identity_event",
38
+ "observe", "current_obs",
39
+ "INTENT_META_KEY", "build_intent_meta", "extract_intent", "inject_intent",
40
+ "mint_and_inject", "mint_for_tool", "scope_for_tool",
41
+ "serving", "incoming_intent", "exchange_intent",
42
+ "load_agents", "registered_agents", "agent_for_checksum", "candidates",
43
+ ]
44
+
45
+ # Activation = one import. Idempotent; pure passthrough until configured.
46
+ load_env()
47
+ install()
File without changes
@@ -0,0 +1,61 @@
1
+ """
2
+ LangChain adapter — set the auth51 agent context + record workflow steps at tool
3
+ boundaries, so `import auth51` + this callback handler = governed egress with no
4
+ other app changes. This generalizes the original clientshim's tool wrapper (which
5
+ set the agent contextvar and recorded steps) — minus the framework lock-in and
6
+ the call-stack introspection.
7
+
8
+ from langchain.agents import AgentExecutor
9
+ from auth51.adapters.langchain import Auth51CallbackHandler
10
+
11
+ handler = Auth51CallbackHandler(agent_id="ReviewBot", checksum=cs)
12
+ executor.invoke({...}, config={"callbacks": [handler]})
13
+
14
+ Import is lazy: this module requires `langchain_core`. The rest of auth51 works
15
+ without it.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from typing import Any, Dict, Optional
20
+
21
+ try:
22
+ from langchain_core.callbacks import BaseCallbackHandler
23
+ except ImportError as exc: # pragma: no cover
24
+ raise ImportError(
25
+ "auth51.adapters.langchain requires langchain_core "
26
+ "(`pip install langchain-core`)."
27
+ ) from exc
28
+
29
+ from ..context import AgentContext, _reset_agent, _set_agent, record_step
30
+
31
+
32
+ class Auth51CallbackHandler(BaseCallbackHandler):
33
+ """Binds tool execution to an agent identity for auth51 egress stamping."""
34
+
35
+ def __init__(self, agent_id: str, checksum: Optional[str] = None, scope: Optional[str] = None):
36
+ self.agent_id = agent_id
37
+ self.checksum = checksum
38
+ self.scope = scope
39
+ self._runs: Dict[Any, tuple] = {} # run_id -> (reset_token, tool_name)
40
+
41
+ def on_tool_start(self, serialized: Dict[str, Any], input_str: str, *, run_id=None, **kwargs) -> None:
42
+ tool = (serialized or {}).get("name") or kwargs.get("name") or ""
43
+ ctx = AgentContext(
44
+ agent_id=self.agent_id, checksum=self.checksum,
45
+ scope=self.scope, tool=tool, provenance="llm-declared",
46
+ )
47
+ token = _set_agent(ctx)
48
+ self._runs[run_id] = (token, tool)
49
+ record_step(self.agent_id, tool, "started")
50
+
51
+ def on_tool_end(self, output: Any, *, run_id=None, **kwargs) -> None:
52
+ token, tool = self._runs.pop(run_id, (None, ""))
53
+ record_step(self.agent_id, tool, "completed")
54
+ if token is not None:
55
+ _reset_agent(token)
56
+
57
+ def on_tool_error(self, error: BaseException, *, run_id=None, **kwargs) -> None:
58
+ token, tool = self._runs.pop(run_id, (None, ""))
59
+ record_step(self.agent_id, tool, "failed", error=str(error))
60
+ if token is not None:
61
+ _reset_agent(token)
@@ -0,0 +1,241 @@
1
+ """
2
+ Authority client — mints intent tokens. Uses stdlib urllib (NOT httpx/requests)
3
+ on purpose: those are the libraries the interceptor patches, so minting via them
4
+ would re-enter the interceptor (infinite recursion). urllib sidesteps that
5
+ entirely and keeps the core dependency-free.
6
+
7
+ Contract mirrors auth51-contracts (the authority OpenAPI): client_credentials →
8
+ OAuth token carrying `generate:intent-token` + delegated scope, then
9
+ POST /v1/intent/token with grant_type=agent_checksum.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import time
15
+ import urllib.parse
16
+ import urllib.request
17
+ from typing import Dict, Optional, Tuple
18
+
19
+ from .config import get_config
20
+
21
+
22
+ class AuthorityError(Exception):
23
+ pass
24
+
25
+
26
+ # OAuth client_credentials tokens cached per scope-set (mirrors the proxy).
27
+ _oauth_cache: Dict[str, Tuple[str, float]] = {}
28
+
29
+
30
+ def _post_form(url: str, data: dict, headers: Optional[dict] = None) -> dict:
31
+ body = urllib.parse.urlencode(data).encode()
32
+ req = urllib.request.Request(
33
+ url, data=body, method="POST",
34
+ headers={"content-type": "application/x-www-form-urlencoded", **(headers or {})},
35
+ )
36
+ with urllib.request.urlopen(req, timeout=10) as r: # noqa: S310 (trusted authority URL)
37
+ return json.loads(r.read().decode())
38
+
39
+
40
+ def _post_json(url: str, obj: dict, headers: Optional[dict] = None) -> dict:
41
+ body = json.dumps(obj).encode()
42
+ req = urllib.request.Request(
43
+ url, data=body, method="POST",
44
+ headers={"content-type": "application/json", **(headers or {})},
45
+ )
46
+ with urllib.request.urlopen(req, timeout=10) as r: # noqa: S310
47
+ return json.loads(r.read().decode())
48
+
49
+
50
+ def get_json(url: str, headers: Optional[dict] = None) -> dict:
51
+ req = urllib.request.Request(url, method="GET", headers=headers or {})
52
+ with urllib.request.urlopen(req, timeout=10) as r: # noqa: S310
53
+ return json.loads(r.read().decode())
54
+
55
+
56
+ def get_oauth_token(full_scope: str) -> str:
57
+ """An auth51 OAuth token for an exact scope string, cached per scope-set.
58
+
59
+ Keyed path (on-prem / local / anywhere with a secret): client_credentials
60
+ using the configured client_id/client_secret. Keyless path (cloud): if no
61
+ secret is configured, prove the runtime's ambient workload identity (AWS
62
+ today) and let the authority mint — no stored secret. See
63
+ DESIGN-keyless-workload-identity.md."""
64
+ cfg = get_config()
65
+ now = time.time()
66
+ cached = _oauth_cache.get(full_scope)
67
+ if cached and now < cached[1] - 5:
68
+ return cached[0]
69
+ if cfg.client_id and cfg.client_secret:
70
+ data = _post_form(
71
+ cfg.authority_url.rstrip("/") + "/v1/oauth/token",
72
+ {
73
+ "grant_type": "client_credentials",
74
+ "client_id": cfg.client_id,
75
+ "client_secret": cfg.client_secret,
76
+ "scope": full_scope,
77
+ },
78
+ )
79
+ else:
80
+ data = _acquire_workload_token(full_scope)
81
+ tok = data.get("access_token")
82
+ if not tok:
83
+ raise AuthorityError("OAuth token endpoint returned no access_token")
84
+ _oauth_cache[full_scope] = (tok, now + float(data.get("expires_in", 300)))
85
+ return tok
86
+
87
+
88
+ def _acquire_workload_token(full_scope: str) -> dict:
89
+ """Keyless: sign an ambient AWS identity proof and exchange it for an auth51
90
+ token at /workload/aws-token. The issued token is DPoP-bound to our PoP key,
91
+ so a replayed GetCallerIdentity would yield a token the replayer can't use."""
92
+ from . import aws, pop # local imports: keep authority import-light
93
+
94
+ cfg = get_config()
95
+ cnf = pop.jkt() if cfg.pop_enabled else None
96
+ payload = aws.signed_identity_payload(cnf_jkt=cnf)
97
+ if payload is None:
98
+ raise AuthorityError(
99
+ "no auth51 credentials: set AUTH51_CLIENT_ID/SECRET, or run on a "
100
+ "registered cloud workload identity (AWS role)."
101
+ )
102
+ payload["scope"] = full_scope
103
+ return _post_json(cfg.authority_url.rstrip("/") + "/v1/workload/aws-token", payload)
104
+
105
+
106
+ def mint_intent(
107
+ *,
108
+ agent_id: str,
109
+ checksum: Optional[str],
110
+ scope: str,
111
+ audience: str,
112
+ workflow_id: Optional[str] = None,
113
+ workflow_step: Optional[dict] = None,
114
+ delegation_context: Optional[dict] = None,
115
+ cnf_jkt: Optional[str] = None,
116
+ ) -> str:
117
+ """
118
+ Mint a scoped, short-lived intent token for one agent action (Hop A).
119
+ Optional workflow_id/step/delegation_context ride in for provenance (§4.3);
120
+ workflow_enabled stays False (we don't use the authority's DAG authz path).
121
+ cnf_jkt binds the token to the caller's ephemeral PoP key (DPoP / RFC 9449).
122
+ """
123
+ cfg = get_config()
124
+ # The OAuth client_credentials token only needs the mint capability
125
+ # (generate:intent-token). The delegated scope goes in the mint body's
126
+ # requested_scopes and is governed by the agent GRANT — NOT the OAuth token
127
+ # (the authority does not require requested ⊆ OAuth scopes; the grant is the
128
+ # ceiling). Folding the delegated scope into the OAuth request would 400 for
129
+ # any scope this OAuth client isn't explicitly granted.
130
+ oauth = get_oauth_token("generate:intent-token")
131
+ payload = {
132
+ "grant_type": "agent_checksum",
133
+ "agent_id": agent_id,
134
+ "computed_checksum": checksum or "",
135
+ "requested_scopes": scope.split() if scope else [],
136
+ "audience": audience,
137
+ "workflow_enabled": False,
138
+ }
139
+ if workflow_id:
140
+ payload["workflow_id"] = workflow_id
141
+ if workflow_step:
142
+ payload["workflow_step"] = workflow_step
143
+ if delegation_context:
144
+ payload["delegation_context"] = delegation_context
145
+ if cnf_jkt:
146
+ payload["cnf_jkt"] = cnf_jkt
147
+ mint_url = cfg.authority_url.rstrip("/") + "/v1/intent/token"
148
+ data = _post_json(mint_url, payload, headers={"authorization": f"Bearer {oauth}"})
149
+ tok = data.get("access_token")
150
+ if not tok:
151
+ raise AuthorityError("intent mint returned no access_token")
152
+ # Record the exchange for the live viewer (observability). The Authorization
153
+ # bearer is intentionally omitted (it's a client-credentials secret-equivalent);
154
+ # the request body + response are the transparency-relevant parts.
155
+ global _last_mint_exchange
156
+ _last_mint_exchange = {
157
+ "request": {"method": "POST", "url": mint_url, "body": payload},
158
+ "response": data,
159
+ }
160
+ return tok
161
+
162
+
163
+ _last_mint_exchange: Optional[dict] = None
164
+
165
+
166
+ def last_mint_exchange() -> Optional[dict]:
167
+ """The most recent mint request/response (for the live viewer). Per-process,
168
+ overwritten each mint; never includes the OAuth bearer."""
169
+ return _last_mint_exchange
170
+
171
+
172
+ _JWT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:jwt"
173
+ _TOKEN_EXCHANGE_GRANT = "urn:ietf:params:oauth:grant-type:token-exchange"
174
+
175
+
176
+ def exchange_token(
177
+ subject_token: str, *, audience: str, scope: Optional[str] = None
178
+ ) -> str:
179
+ """Hop B (RFC 8693): exchange an intent token (subject) for a downstream
180
+ token scoped/audienced for the RS. Used at an MCP server's egress. The
181
+ authority verifies the subject_token against its trusted-issuer config —
182
+ so the authority must trust its own issuer for self-issued intent tokens.
183
+ """
184
+ cfg = get_config()
185
+ data = {
186
+ "grant_type": _TOKEN_EXCHANGE_GRANT,
187
+ "subject_token": subject_token,
188
+ "subject_token_type": _JWT_TOKEN_TYPE,
189
+ "audience": audience,
190
+ }
191
+ if scope:
192
+ data["scope"] = scope
193
+ out = _post_form(cfg.authority_url.rstrip("/") + "/v1/oauth/token", data)
194
+ tok = out.get("access_token")
195
+ if not tok:
196
+ raise AuthorityError("token exchange returned no access_token")
197
+ return tok
198
+
199
+
200
+ def exchange_session(
201
+ session_token: str,
202
+ *,
203
+ audience: str,
204
+ scope: Optional[str] = None,
205
+ bind_pop: bool = True,
206
+ ) -> str:
207
+ """Exchange an EXTERNAL session token — issued by the application's own IDP
208
+ (a trusted issuer registered at the authority: Okta/Auth0/Entra/the legacy
209
+ patchet IDP/…) — for a sender-constrained auth51 OAuth token (RFC 8693).
210
+
211
+ Two properties close the A2 gap (a stolen session token being enough):
212
+ * we authenticate with the embed's CLIENT credential, so the session token
213
+ alone cannot be exchanged (the issuer is marked require_client_auth);
214
+ * the issued token is DPoP-bound to the ephemeral PoP key (cnf.jkt), so it
215
+ cannot be replayed — the same key then proves possession when this token
216
+ mints intent tokens.
217
+ The returned token is the auth51 OAuth token used in place of a raw external
218
+ session for `generate:intent-token`.
219
+ """
220
+ from . import pop # local import: pop has no dep on authority, avoid cycle
221
+
222
+ cfg = get_config()
223
+ data = {
224
+ "grant_type": _TOKEN_EXCHANGE_GRANT,
225
+ "subject_token": session_token,
226
+ "subject_token_type": _JWT_TOKEN_TYPE,
227
+ "audience": audience,
228
+ "client_id": cfg.client_id or "",
229
+ "client_secret": cfg.client_secret or "",
230
+ }
231
+ if scope:
232
+ data["scope"] = scope
233
+ if bind_pop:
234
+ jkt = pop.jkt()
235
+ if jkt:
236
+ data["cnf_jkt"] = jkt
237
+ out = _post_form(cfg.authority_url.rstrip("/") + "/v1/oauth/token", data)
238
+ tok = out.get("access_token")
239
+ if not tok:
240
+ raise AuthorityError("session exchange returned no access_token")
241
+ return tok