solvapay 0.1.0.dev4__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.
- solvapay/__init__.py +26 -0
- solvapay/_currencies.py +170 -0
- solvapay/_generated/__init__.py +1 -0
- solvapay/_generated/models.py +3643 -0
- solvapay/_generated/operations.py +1894 -0
- solvapay/_http.py +243 -0
- solvapay/_js.py +114 -0
- solvapay/_tax_ids.py +442 -0
- solvapay/agents/__init__.py +67 -0
- solvapay/agents/calls.py +302 -0
- solvapay/agents/identity.py +293 -0
- solvapay/agents/mcp.py +614 -0
- solvapay/agents/oauth.py +105 -0
- solvapay/business_details.py +124 -0
- solvapay/client.py +107 -0
- solvapay/errors.py +33 -0
- solvapay/mcp/__init__.py +90 -0
- solvapay/mcp/app.py +243 -0
- solvapay/mcp/auth.py +604 -0
- solvapay/mcp/bootstrap.py +494 -0
- solvapay/mcp/intent.py +474 -0
- solvapay/mcp/mcp-app.html +119 -0
- solvapay/mcp/narrate.py +1019 -0
- solvapay/mcp/response.py +220 -0
- solvapay/mcp/server.py +343 -0
- solvapay/mcp/tools.py +273 -0
- solvapay/mcp/transport.py +550 -0
- solvapay/money.py +61 -0
- solvapay/paywall/__init__.py +25 -0
- solvapay/paywall/decide.py +450 -0
- solvapay/paywall/gate.py +176 -0
- solvapay/paywall/state.py +371 -0
- solvapay/pricing.py +281 -0
- solvapay/py.typed +0 -0
- solvapay/solvapay.py +173 -0
- solvapay/webhooks.py +93 -0
- solvapay-0.1.0.dev4.dist-info/METADATA +183 -0
- solvapay-0.1.0.dev4.dist-info/RECORD +39 -0
- solvapay-0.1.0.dev4.dist-info/WHEEL +4 -0
solvapay/agents/calls.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""One agent's paid call, start to finish.
|
|
2
|
+
|
|
3
|
+
SolvaPay decides everything: ``authorize`` before the upstream call finds the
|
|
4
|
+
customer, checks the spend policy, holds the estimate against the budget and
|
|
5
|
+
the balance, and waits for the owner when they must approve; ``complete``
|
|
6
|
+
after it settles at the cost the upstream reported. This module only drives
|
|
7
|
+
those two requests and turns their answers into ``Allowed`` or ``Refused``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import hashlib
|
|
14
|
+
import logging
|
|
15
|
+
import math
|
|
16
|
+
import time
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from decimal import ROUND_HALF_UP, Decimal, localcontext
|
|
20
|
+
from typing import Any, Literal
|
|
21
|
+
|
|
22
|
+
from ..client import AsyncApiClient
|
|
23
|
+
from ..errors import SolvaPayError
|
|
24
|
+
from .identity import VerifiedAgent, agent_refusal
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger("solvapay.agents")
|
|
27
|
+
|
|
28
|
+
AUTHORIZE_WAIT_MAX_SECONDS = 55
|
|
29
|
+
PROMPT_EXCERPT_MAX = 1500
|
|
30
|
+
|
|
31
|
+
RefusalReason = Literal[
|
|
32
|
+
"customer_not_linked",
|
|
33
|
+
"ask",
|
|
34
|
+
"deny",
|
|
35
|
+
"topup_required",
|
|
36
|
+
"approval_declined",
|
|
37
|
+
"approval_expired",
|
|
38
|
+
"agent_not_accepted",
|
|
39
|
+
"approval_timed_out",
|
|
40
|
+
"approval_client_closed",
|
|
41
|
+
"unavailable",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
# Refusals money or the owner can settle: HTTP 402.
|
|
45
|
+
_OWNER_OR_MONEY_REASONS = frozenset(
|
|
46
|
+
{
|
|
47
|
+
"customer_not_linked",
|
|
48
|
+
"topup_required",
|
|
49
|
+
"ask",
|
|
50
|
+
"approval_declined",
|
|
51
|
+
"approval_expired",
|
|
52
|
+
"approval_timed_out",
|
|
53
|
+
"approval_client_closed",
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
NOT_ACCEPTED = "SolvaPay did not accept this agent (revoked or not for this merchant)."
|
|
58
|
+
UNAVAILABLE = "SolvaPay could not be reached to authorize this call. Try again shortly."
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class Money:
|
|
63
|
+
amount: str
|
|
64
|
+
"""A decimal amount with up to 8 places."""
|
|
65
|
+
currency: str
|
|
66
|
+
"""ISO 4217, such as USD."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class PromptFacts:
|
|
71
|
+
prompt_hash: str
|
|
72
|
+
tool_error: bool
|
|
73
|
+
excerpt: str
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def prompt_facts(text: str, tool_error: bool = False) -> PromptFacts:
|
|
77
|
+
"""A hash of the prompt (repeats show loops), and its end, sent only if the owner opted in.
|
|
78
|
+
|
|
79
|
+
The end is the last ``PROMPT_EXCERPT_MAX`` UTF-16 code units, as TypeScript's
|
|
80
|
+
``slice`` counts and SolvaPay measures them: an emoji counts twice."""
|
|
81
|
+
units = text.encode("utf-16-le")
|
|
82
|
+
if len(units) <= 2 * PROMPT_EXCERPT_MAX:
|
|
83
|
+
excerpt = text
|
|
84
|
+
else:
|
|
85
|
+
end = units[-2 * PROMPT_EXCERPT_MAX :]
|
|
86
|
+
# The cut went through a character outside the BMP: drop its lone low half.
|
|
87
|
+
if 0xDC00 <= int.from_bytes(end[:2], "little") <= 0xDFFF:
|
|
88
|
+
end = end[2:]
|
|
89
|
+
excerpt = end.decode("utf-16-le")
|
|
90
|
+
return PromptFacts(
|
|
91
|
+
prompt_hash=f"sha256:{hashlib.sha256(text.encode('utf-8')).hexdigest()}",
|
|
92
|
+
tool_error=tool_error,
|
|
93
|
+
excerpt=excerpt,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def amount_string(amount: float | Decimal | str) -> str:
|
|
98
|
+
"""An amount as a decimal string with up to 8 places, trailing zeros trimmed.
|
|
99
|
+
|
|
100
|
+
A float is rounded from its exact binary value, as TypeScript's ``toFixed`` does:
|
|
101
|
+
``1.5e-8`` is just under the half, so it is ``0.00000001``."""
|
|
102
|
+
exact = Decimal(amount) if isinstance(amount, float) else Decimal(str(amount))
|
|
103
|
+
# Enough precision for every integer digit plus the 8 places (the default 28
|
|
104
|
+
# fails from 1e20 up).
|
|
105
|
+
with localcontext() as context:
|
|
106
|
+
context.prec = max(28, exact.adjusted() + 10)
|
|
107
|
+
fixed = exact.quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP)
|
|
108
|
+
text = f"{fixed:f}".rstrip("0").rstrip(".")
|
|
109
|
+
return text or "0"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class AgentCall:
|
|
114
|
+
request_id: str
|
|
115
|
+
"""The merchant's id for the call; SolvaPay keys the call on it."""
|
|
116
|
+
agent_token: str
|
|
117
|
+
"""The token as presented: SolvaPay verifies it again."""
|
|
118
|
+
agent: VerifiedAgent
|
|
119
|
+
item: str
|
|
120
|
+
"""What is used: a model id, a tool name."""
|
|
121
|
+
estimate: Money
|
|
122
|
+
prompt: PromptFacts | None = None
|
|
123
|
+
cancelled: Callable[[], bool] = lambda: False
|
|
124
|
+
"""True once the caller has gone away, to stop waiting for the owner."""
|
|
125
|
+
log_fields: dict[str, Any] = field(default_factory=dict)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True)
|
|
129
|
+
class Refused:
|
|
130
|
+
reason: RefusalReason
|
|
131
|
+
message: str
|
|
132
|
+
"""The sentence for the human, in every case."""
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(frozen=True)
|
|
136
|
+
class CallResult:
|
|
137
|
+
status: int
|
|
138
|
+
"""The status the upstream answered with."""
|
|
139
|
+
cost: tuple[float | Decimal | str, str] | None
|
|
140
|
+
"""The cost the upstream reported, (amount, currency), or None when none arrived."""
|
|
141
|
+
interrupted: str | None = None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class Allowed:
|
|
145
|
+
"""The call may go ahead. Finish it once, when the upstream has answered."""
|
|
146
|
+
|
|
147
|
+
def __init__(self, api: AsyncApiClient, call: AgentCall, answer: Any) -> None:
|
|
148
|
+
self._api = api
|
|
149
|
+
self._call = call
|
|
150
|
+
self.call_ref: str = answer.call_ref
|
|
151
|
+
self.reservation = answer.reservation
|
|
152
|
+
self.item = call.item
|
|
153
|
+
self.estimate = call.estimate
|
|
154
|
+
self._completed = False
|
|
155
|
+
|
|
156
|
+
async def finish(self, result: CallResult) -> Any:
|
|
157
|
+
"""Settles the call at how the upstream answered. ``None`` when SolvaPay could not
|
|
158
|
+
be told: logged, and the reservation lapses at the estimate."""
|
|
159
|
+
body: dict[str, Any] = {
|
|
160
|
+
"status": result.status,
|
|
161
|
+
"cost": None
|
|
162
|
+
if result.cost is None
|
|
163
|
+
else {"amount": amount_string(result.cost[0]), "currency": result.cost[1]},
|
|
164
|
+
}
|
|
165
|
+
if result.interrupted:
|
|
166
|
+
body["interrupted"] = result.interrupted
|
|
167
|
+
return await self._complete(body)
|
|
168
|
+
|
|
169
|
+
async def release(self, reason: str) -> None:
|
|
170
|
+
"""The call never reached the upstream: completed as a failure, so nothing is charged."""
|
|
171
|
+
await self._complete({"status": 502, "cost": None, "interrupted": reason})
|
|
172
|
+
|
|
173
|
+
async def _complete(self, body: dict[str, Any]) -> Any:
|
|
174
|
+
if self._completed:
|
|
175
|
+
raise SolvaPayError(
|
|
176
|
+
f"Call {self.call_ref} was already completed", code="call_completed"
|
|
177
|
+
)
|
|
178
|
+
self._completed = True
|
|
179
|
+
try:
|
|
180
|
+
answer = await self._api.complete_agent_call(call_ref=self.call_ref, body=body)
|
|
181
|
+
# The call's books are settled on it: every field must be there.
|
|
182
|
+
_require_text(answer.call_ref, "callRef")
|
|
183
|
+
_require_text(answer.amount.amount, "amount.amount")
|
|
184
|
+
_require_text(answer.amount.currency, "amount.currency")
|
|
185
|
+
return answer
|
|
186
|
+
except SolvaPayError as error:
|
|
187
|
+
logger.error(
|
|
188
|
+
"call.complete_failed %s",
|
|
189
|
+
{**_fields(self._call), "callRef": self.call_ref, "status": error.status,
|
|
190
|
+
"error": error.message},
|
|
191
|
+
) # fmt: skip
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _require_text(value: str | None, label: str) -> None:
|
|
196
|
+
"""A field TypeScript requires as a non-empty string; the generated model allows less."""
|
|
197
|
+
if not value:
|
|
198
|
+
raise SolvaPayError(f"{label} must be a non-empty string", code="invalid_response")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _check_authorize(answer: Any) -> None:
|
|
202
|
+
"""Checked field by field, as TypeScript does: a paid call goes ahead on it."""
|
|
203
|
+
_require_text(answer.call_ref, "callRef")
|
|
204
|
+
if answer.status == "allowed":
|
|
205
|
+
_require_text(answer.reservation.expires_at, "reservation.expiresAt")
|
|
206
|
+
return
|
|
207
|
+
_require_text(answer.message, "message")
|
|
208
|
+
if answer.status == "waiting":
|
|
209
|
+
_require_text(answer.approval.reference, "approval.reference")
|
|
210
|
+
_require_text(answer.approval.expires_at, "approval.expiresAt")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _fields(call: AgentCall) -> dict[str, Any]:
|
|
214
|
+
return {
|
|
215
|
+
"requestId": call.request_id,
|
|
216
|
+
**call.log_fields,
|
|
217
|
+
"agentRef": call.agent.agent_ref,
|
|
218
|
+
"userRef": call.agent.principal_ref,
|
|
219
|
+
"item": call.item,
|
|
220
|
+
"estimate": f"{call.estimate.amount} {call.estimate.currency}",
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class AgentCalls:
|
|
225
|
+
"""Authorizes agent calls with SolvaPay, waiting up to ``max_wait_seconds`` for the owner."""
|
|
226
|
+
|
|
227
|
+
def __init__(
|
|
228
|
+
self,
|
|
229
|
+
api: AsyncApiClient,
|
|
230
|
+
*,
|
|
231
|
+
max_wait_seconds: int,
|
|
232
|
+
clock: Callable[[], float] = time.monotonic,
|
|
233
|
+
) -> None:
|
|
234
|
+
self.api = api
|
|
235
|
+
self.max_wait_seconds = max_wait_seconds
|
|
236
|
+
self._clock = clock
|
|
237
|
+
|
|
238
|
+
def _refuse(
|
|
239
|
+
self, call: AgentCall, reason: RefusalReason, message: str, **fields: Any
|
|
240
|
+
) -> Refused:
|
|
241
|
+
logger.info("call.refused %s", {**_fields(call), "reason": reason, **fields})
|
|
242
|
+
return Refused(reason, message)
|
|
243
|
+
|
|
244
|
+
async def begin(self, call: AgentCall) -> Allowed | Refused:
|
|
245
|
+
deadline = self._clock() + self.max_wait_seconds
|
|
246
|
+
while True:
|
|
247
|
+
wait = max(0, min(AUTHORIZE_WAIT_MAX_SECONDS, math.floor(deadline - self._clock())))
|
|
248
|
+
body: dict[str, Any] = {
|
|
249
|
+
"agentToken": call.agent_token,
|
|
250
|
+
"requestId": call.request_id,
|
|
251
|
+
"item": call.item,
|
|
252
|
+
"estimate": {"amount": call.estimate.amount, "currency": call.estimate.currency},
|
|
253
|
+
"wait": wait,
|
|
254
|
+
}
|
|
255
|
+
if call.prompt:
|
|
256
|
+
body["promptHash"] = call.prompt.prompt_hash
|
|
257
|
+
body["toolError"] = call.prompt.tool_error
|
|
258
|
+
# Only when the token SolvaPay minted says the owner opted in.
|
|
259
|
+
if call.agent.prompt_excerpt:
|
|
260
|
+
body["promptExcerpt"] = call.prompt.excerpt
|
|
261
|
+
try:
|
|
262
|
+
answer = (await self.api.authorize_agent_call(body=body)).root
|
|
263
|
+
_check_authorize(answer)
|
|
264
|
+
except SolvaPayError as error:
|
|
265
|
+
logger.error(
|
|
266
|
+
"call.authorize_failed %s",
|
|
267
|
+
{**_fields(call), "status": error.status, "error": error.message},
|
|
268
|
+
)
|
|
269
|
+
# A refusal of the merchant (agent payments off, a secret key for the
|
|
270
|
+
# other environment) is not the agent's: unavailable, logged above.
|
|
271
|
+
if agent_refusal(error):
|
|
272
|
+
return self._refuse(call, "agent_not_accepted", NOT_ACCEPTED)
|
|
273
|
+
return self._refuse(call, "unavailable", UNAVAILABLE)
|
|
274
|
+
|
|
275
|
+
if answer.status == "allowed":
|
|
276
|
+
logger.info("call.allowed %s", {**_fields(call), "callRef": answer.call_ref})
|
|
277
|
+
return Allowed(self.api, call, answer)
|
|
278
|
+
if answer.status == "refused":
|
|
279
|
+
return self._refuse(call, answer.reason, answer.message, callRef=answer.call_ref)
|
|
280
|
+
held = {"callRef": answer.call_ref, "approvalRef": answer.approval.reference}
|
|
281
|
+
if self.max_wait_seconds == 0:
|
|
282
|
+
return self._refuse(call, "ask", answer.message, **held)
|
|
283
|
+
if call.cancelled():
|
|
284
|
+
return self._refuse(call, "approval_client_closed", answer.message, **held)
|
|
285
|
+
if self._clock() >= deadline:
|
|
286
|
+
return self._refuse(call, "approval_timed_out", answer.message, **held)
|
|
287
|
+
logger.info("call.waiting %s", {**_fields(call), **held})
|
|
288
|
+
await asyncio.sleep(answer.retry_after_seconds)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def refusal_status(reason: RefusalReason) -> int:
|
|
292
|
+
"""The HTTP status for a refusal: 402 for what money or the owner can settle, 422 for a
|
|
293
|
+
policy deny, 401 for an agent SolvaPay did not accept, 502 when it could not be reached."""
|
|
294
|
+
if reason == "agent_not_accepted":
|
|
295
|
+
return 401
|
|
296
|
+
if reason == "unavailable":
|
|
297
|
+
return 502
|
|
298
|
+
if reason == "deny":
|
|
299
|
+
return 422
|
|
300
|
+
if reason in _OWNER_OR_MONEY_REASONS:
|
|
301
|
+
return 402
|
|
302
|
+
raise SolvaPayError(f"Refusal reason {reason!r} has no status", code="unknown_refusal_reason")
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""Who is calling: SolvaPay agent tokens and agent keys.
|
|
2
|
+
|
|
3
|
+
Agent tokens are ES256 JWTs signed by SolvaPay's agent service, with keys at
|
|
4
|
+
``<issuer>/jwks.json``, issued for one merchant (``aud`` is its provider
|
|
5
|
+
reference, or this server's resource URL for OAuth tokens). Agent keys
|
|
6
|
+
(``agk_…``) are opaque: SolvaPay says whose they are.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import Awaitable, Callable
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from typing import Any, Literal
|
|
17
|
+
|
|
18
|
+
import jwt
|
|
19
|
+
|
|
20
|
+
from ..client import AsyncApiClient
|
|
21
|
+
from ..errors import SolvaPayError
|
|
22
|
+
from .oauth import canonical_resource
|
|
23
|
+
|
|
24
|
+
AgentEnvironment = Literal["sandbox", "live"]
|
|
25
|
+
AGENT_ENVIRONMENTS: tuple[AgentEnvironment, ...] = ("sandbox", "live")
|
|
26
|
+
AGENT_KEY_PREFIX = "agk_"
|
|
27
|
+
AGENT_TOKEN_EXCERPT_CLAIM = "px"
|
|
28
|
+
AGENT_TOKEN_CLIENT_CLAIM = "cid"
|
|
29
|
+
AGENT_TOKEN_ENV_CLAIM = "env"
|
|
30
|
+
CLOCK_TOLERANCE_SECONDS = 30
|
|
31
|
+
AGENT_KEY_CACHE_SECONDS = 30.0
|
|
32
|
+
|
|
33
|
+
RejectionReason = Literal["expired", "invalid", "environment_mismatch"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class VerifiedAgent:
|
|
38
|
+
agent_ref: str
|
|
39
|
+
"""The agent, ``agt_…`` (the token's ``sub``)."""
|
|
40
|
+
principal_ref: str
|
|
41
|
+
"""Pairwise reference of the account at this merchant, ``ppl_…``."""
|
|
42
|
+
prompt_excerpt: bool
|
|
43
|
+
"""The prompt's end may go to SolvaPay with this agent's calls (``px`` claim)."""
|
|
44
|
+
environment: AgentEnvironment
|
|
45
|
+
token_id: str | None = None
|
|
46
|
+
expires_at: datetime | None = None
|
|
47
|
+
client_id: str | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class AgentRejected:
|
|
52
|
+
reason: RejectionReason
|
|
53
|
+
detail: str
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
AgentResult = VerifiedAgent | AgentRejected
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _check_environment(agent: VerifiedAgent, merchant: AgentEnvironment | None) -> AgentResult:
|
|
60
|
+
if merchant is not None and agent.environment != merchant:
|
|
61
|
+
return AgentRejected(
|
|
62
|
+
"environment_mismatch",
|
|
63
|
+
f"This agent spends in {agent.environment}, and this secret key is for {merchant}",
|
|
64
|
+
)
|
|
65
|
+
return agent
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _is_number(value: Any) -> bool:
|
|
69
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _key_matches(jwk: dict[str, Any], kid: str | None) -> bool:
|
|
73
|
+
"""Whether a JWKS member may verify an ES256 token with this ``kid``, by the rules
|
|
74
|
+
TypeScript's ``jose`` applies: a token without a ``kid`` may use any matching key."""
|
|
75
|
+
if jwk.get("kty") != "EC" or jwk.get("crv") != "P-256":
|
|
76
|
+
return False
|
|
77
|
+
if kid is not None and jwk.get("kid") != kid:
|
|
78
|
+
return False
|
|
79
|
+
if isinstance(jwk.get("alg"), str) and jwk["alg"] != "ES256":
|
|
80
|
+
return False
|
|
81
|
+
if isinstance(jwk.get("use"), str) and jwk["use"] != "sig":
|
|
82
|
+
return False
|
|
83
|
+
key_ops = jwk.get("key_ops")
|
|
84
|
+
return not isinstance(key_ops, list) or "verify" in key_ops
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class AgentTokenVerifier:
|
|
88
|
+
"""Verifies agent tokens against SolvaPay's published keys."""
|
|
89
|
+
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
*,
|
|
93
|
+
issuer: str,
|
|
94
|
+
provider_ref: str,
|
|
95
|
+
resource: str | None = None,
|
|
96
|
+
environment: AgentEnvironment | None = None,
|
|
97
|
+
jwks: jwt.PyJWKClient | None = None,
|
|
98
|
+
clock: Callable[[], float] = time.time,
|
|
99
|
+
) -> None:
|
|
100
|
+
self.issuer = issuer
|
|
101
|
+
self.audiences = [provider_ref] + ([canonical_resource(resource)] if resource else [])
|
|
102
|
+
self.environment = environment
|
|
103
|
+
self._jwks = jwks or jwt.PyJWKClient(f"{issuer.rstrip('/')}/jwks.json", cache_keys=True)
|
|
104
|
+
self._clock = clock
|
|
105
|
+
|
|
106
|
+
def _key(self, kid: str | None) -> Any:
|
|
107
|
+
"""The one key that may have signed the token. The set is fetched again once when
|
|
108
|
+
none matches, as after SolvaPay rotates its key; more than one is ambiguous."""
|
|
109
|
+
for refresh in (False, True):
|
|
110
|
+
keys = [
|
|
111
|
+
key.key
|
|
112
|
+
for key in self._jwks.get_jwk_set(refresh=refresh).keys
|
|
113
|
+
if _key_matches(getattr(key, "_jwk_data", {}), kid)
|
|
114
|
+
]
|
|
115
|
+
if len(keys) > 1:
|
|
116
|
+
raise jwt.PyJWKClientError("More than one key in the JWKS matches the token")
|
|
117
|
+
if keys:
|
|
118
|
+
return keys[0]
|
|
119
|
+
raise jwt.PyJWKClientError("No key in the JWKS matches the token")
|
|
120
|
+
|
|
121
|
+
def _claims(self, token: str) -> dict[str, Any]:
|
|
122
|
+
header = jwt.get_unverified_header(token)
|
|
123
|
+
if header.get("alg") != "ES256":
|
|
124
|
+
raise jwt.InvalidAlgorithmError("The token is not signed with ES256")
|
|
125
|
+
kid = header.get("kid")
|
|
126
|
+
claims: dict[str, Any] = jwt.decode(
|
|
127
|
+
token,
|
|
128
|
+
self._key(kid if isinstance(kid, str) else None),
|
|
129
|
+
algorithms=["ES256"],
|
|
130
|
+
audience=self.audiences,
|
|
131
|
+
issuer=self.issuer,
|
|
132
|
+
# The times are checked below, as TypeScript's jose checks them: claims must
|
|
133
|
+
# be numbers, and an iat in the future is not refused.
|
|
134
|
+
options={
|
|
135
|
+
"require": ["sub", "exp", "iat", "jti"],
|
|
136
|
+
"verify_exp": False,
|
|
137
|
+
"verify_nbf": False,
|
|
138
|
+
"verify_iat": False,
|
|
139
|
+
"verify_sub": False,
|
|
140
|
+
"verify_jti": False,
|
|
141
|
+
},
|
|
142
|
+
)
|
|
143
|
+
now = int(self._clock())
|
|
144
|
+
if not _is_number(claims["iat"]):
|
|
145
|
+
raise jwt.InvalidIssuedAtError("iat must be a number")
|
|
146
|
+
nbf = claims.get("nbf")
|
|
147
|
+
if nbf is not None:
|
|
148
|
+
if not _is_number(nbf):
|
|
149
|
+
raise jwt.ImmatureSignatureError("nbf must be a number")
|
|
150
|
+
if nbf > now + CLOCK_TOLERANCE_SECONDS:
|
|
151
|
+
raise jwt.ImmatureSignatureError("The token is not yet valid (nbf)")
|
|
152
|
+
if not _is_number(claims["exp"]):
|
|
153
|
+
raise jwt.DecodeError("exp must be a number")
|
|
154
|
+
if claims["exp"] <= now - CLOCK_TOLERANCE_SECONDS:
|
|
155
|
+
raise jwt.ExpiredSignatureError("Signature has expired")
|
|
156
|
+
return claims
|
|
157
|
+
|
|
158
|
+
def verify(self, token: str) -> AgentResult:
|
|
159
|
+
try:
|
|
160
|
+
claims = self._claims(token)
|
|
161
|
+
except jwt.ExpiredSignatureError as error:
|
|
162
|
+
return AgentRejected("expired", str(error))
|
|
163
|
+
except jwt.PyJWTError as error:
|
|
164
|
+
return AgentRejected("invalid", str(error))
|
|
165
|
+
|
|
166
|
+
client_id = claims.get(AGENT_TOKEN_CLIENT_CLAIM)
|
|
167
|
+
if client_id is not None and not isinstance(client_id, str):
|
|
168
|
+
return AgentRejected("invalid", "cid is not a string")
|
|
169
|
+
sub, principal = claims.get("sub"), claims.get("principal")
|
|
170
|
+
if not isinstance(sub, str) or not sub.startswith("agt_"):
|
|
171
|
+
return AgentRejected("invalid", "sub is not an agent reference")
|
|
172
|
+
if not isinstance(principal, str) or not principal.startswith("ppl_"):
|
|
173
|
+
return AgentRejected("invalid", "principal is missing")
|
|
174
|
+
if claims.get("scope") != "usage":
|
|
175
|
+
return AgentRejected("invalid", "scope is not usage")
|
|
176
|
+
env = claims.get(AGENT_TOKEN_ENV_CLAIM)
|
|
177
|
+
if env not in AGENT_ENVIRONMENTS:
|
|
178
|
+
return AgentRejected("invalid", "env is not sandbox or live")
|
|
179
|
+
return _check_environment(
|
|
180
|
+
VerifiedAgent(
|
|
181
|
+
agent_ref=sub,
|
|
182
|
+
principal_ref=principal,
|
|
183
|
+
prompt_excerpt=claims.get(AGENT_TOKEN_EXCERPT_CLAIM) is True,
|
|
184
|
+
environment=env,
|
|
185
|
+
token_id=str(claims["jti"]),
|
|
186
|
+
expires_at=datetime.fromtimestamp(claims["exp"], tz=timezone.utc),
|
|
187
|
+
client_id=client_id or None,
|
|
188
|
+
),
|
|
189
|
+
self.environment,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class AgentKeyVerifier:
|
|
194
|
+
"""Asks SolvaPay whose an agent key is, reusing each answer for 30 seconds.
|
|
195
|
+
|
|
196
|
+
SolvaPay out of reach raises: every paid call would fail the same way.
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
def __init__(
|
|
200
|
+
self,
|
|
201
|
+
api: AsyncApiClient,
|
|
202
|
+
*,
|
|
203
|
+
environment: AgentEnvironment | None = None,
|
|
204
|
+
clock: Callable[[], float] = time.monotonic,
|
|
205
|
+
) -> None:
|
|
206
|
+
self.api = api
|
|
207
|
+
self.environment = environment
|
|
208
|
+
self._clock = clock
|
|
209
|
+
self._answers: dict[str, tuple[float, AgentResult]] = {}
|
|
210
|
+
|
|
211
|
+
async def verify(self, agent_key: str) -> AgentResult:
|
|
212
|
+
cached = self._answers.get(agent_key)
|
|
213
|
+
if cached and cached[0] > self._clock():
|
|
214
|
+
return cached[1]
|
|
215
|
+
try:
|
|
216
|
+
verified = await self.api.verify_agent_key(body={"agentKey": agent_key})
|
|
217
|
+
result: AgentResult = _check_environment(
|
|
218
|
+
VerifiedAgent(
|
|
219
|
+
agent_ref=verified.agent_ref,
|
|
220
|
+
principal_ref=verified.principal_ref,
|
|
221
|
+
prompt_excerpt=verified.prompt_excerpt,
|
|
222
|
+
environment=verified.environment,
|
|
223
|
+
),
|
|
224
|
+
self.environment,
|
|
225
|
+
)
|
|
226
|
+
except SolvaPayError as error:
|
|
227
|
+
# Only a refusal of the key itself is the agent's. SolvaPay failing, or
|
|
228
|
+
# refusing the merchant (agent payments off, a secret key for the other
|
|
229
|
+
# environment), is raised for the merchant to fix.
|
|
230
|
+
reason = agent_refusal(error)
|
|
231
|
+
if reason is None:
|
|
232
|
+
raise
|
|
233
|
+
result = AgentRejected(reason, error.message)
|
|
234
|
+
self._answers[agent_key] = (self._clock() + AGENT_KEY_CACHE_SECONDS, result)
|
|
235
|
+
return result
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def agent_refusal(error: SolvaPayError) -> Literal["invalid", "environment_mismatch"] | None:
|
|
239
|
+
"""Whose a 401 or 403 from SolvaPay's agent endpoints is, as in TypeScript.
|
|
240
|
+
|
|
241
|
+
The agent's: the token or key is unknown, revoked or another merchant's (401,
|
|
242
|
+
or 403 with no code), or from the other environment (403
|
|
243
|
+
``environment_mismatch``). Any other code, such as ``feature_not_enabled``
|
|
244
|
+
(agent payments are off for this merchant) or ``key_env_mismatch`` (the
|
|
245
|
+
secret key is for the other environment), is the merchant's: ``None``."""
|
|
246
|
+
if error.status == 401:
|
|
247
|
+
return "invalid"
|
|
248
|
+
if error.status != 403:
|
|
249
|
+
return None
|
|
250
|
+
if error.code == "http_403": # SolvaPay sent no code
|
|
251
|
+
return "invalid"
|
|
252
|
+
if error.code == "environment_mismatch":
|
|
253
|
+
return "environment_mismatch"
|
|
254
|
+
return None
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
_JWT = re.compile(r"^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$")
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def looks_like_jwt(value: str) -> bool:
|
|
261
|
+
return bool(_JWT.fullmatch(value))
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def is_agent_key(value: str) -> bool:
|
|
265
|
+
return value.startswith(AGENT_KEY_PREFIX) and len(value) > len(AGENT_KEY_PREFIX)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@dataclass(frozen=True)
|
|
269
|
+
class NotAgent:
|
|
270
|
+
"""The credential is not an agent's: the merchant's own auth decides."""
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
Identification = VerifiedAgent | AgentRejected | NotAgent
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class AgentLayer:
|
|
277
|
+
"""Tells an agent's credential from the merchant's own, and verifies it."""
|
|
278
|
+
|
|
279
|
+
def __init__(
|
|
280
|
+
self,
|
|
281
|
+
*,
|
|
282
|
+
verify_token: Callable[[str], AgentResult],
|
|
283
|
+
verify_key: Callable[[str], Awaitable[AgentResult]] | None = None,
|
|
284
|
+
) -> None:
|
|
285
|
+
self._verify_token = verify_token
|
|
286
|
+
self._verify_key = verify_key
|
|
287
|
+
|
|
288
|
+
async def identify(self, presented: str) -> Identification:
|
|
289
|
+
if is_agent_key(presented) and self._verify_key is not None:
|
|
290
|
+
return await self._verify_key(presented)
|
|
291
|
+
if not looks_like_jwt(presented):
|
|
292
|
+
return NotAgent()
|
|
293
|
+
return self._verify_token(presented)
|