agentonboard-sdk 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentonboard/__init__.py +60 -0
- agentonboard/account.py +320 -0
- agentonboard/codes.py +76 -0
- agentonboard/frameworks/__init__.py +3 -0
- agentonboard/frameworks/fastapi.py +119 -0
- agentonboard/frameworks/flask.py +124 -0
- agentonboard/keys.py +298 -0
- agentonboard/py.typed +0 -0
- agentonboard/verify.py +571 -0
- agentonboard_sdk-0.1.0.dist-info/METADATA +111 -0
- agentonboard_sdk-0.1.0.dist-info/RECORD +12 -0
- agentonboard_sdk-0.1.0.dist-info/WHEEL +4 -0
agentonboard/__init__.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AgentOnboard Python SDK — Universal identity layer for AI agents.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from agentonboard.account import (
|
|
6
|
+
AccountCreator,
|
|
7
|
+
AccountOutcome,
|
|
8
|
+
AccountResolver,
|
|
9
|
+
AgentIdentity,
|
|
10
|
+
AsyncAccountCreator,
|
|
11
|
+
AsyncAccountResolver,
|
|
12
|
+
create_agent_account,
|
|
13
|
+
create_agent_account_async,
|
|
14
|
+
normalize_email,
|
|
15
|
+
verify_agent_account,
|
|
16
|
+
verify_agent_account_async,
|
|
17
|
+
)
|
|
18
|
+
from agentonboard.codes import (
|
|
19
|
+
ACCOUNT_ERROR_DESCRIPTIONS,
|
|
20
|
+
VERIFY_ERROR_DESCRIPTIONS,
|
|
21
|
+
AccountErrorCode,
|
|
22
|
+
VerifyErrorCode,
|
|
23
|
+
status_from_code,
|
|
24
|
+
)
|
|
25
|
+
from agentonboard.verify import (
|
|
26
|
+
DEFAULT_CLOCK_TOLERANCE_SECONDS,
|
|
27
|
+
Verifier,
|
|
28
|
+
VerifyAgentTokenResult,
|
|
29
|
+
normalize_audience,
|
|
30
|
+
verify_agent_token,
|
|
31
|
+
verify_agent_token_async,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
__version__ = "0.1.0"
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"ACCOUNT_ERROR_DESCRIPTIONS",
|
|
38
|
+
"AccountCreator",
|
|
39
|
+
"AccountErrorCode",
|
|
40
|
+
"AccountOutcome",
|
|
41
|
+
"AccountResolver",
|
|
42
|
+
"AgentIdentity",
|
|
43
|
+
"AsyncAccountCreator",
|
|
44
|
+
"AsyncAccountResolver",
|
|
45
|
+
"DEFAULT_CLOCK_TOLERANCE_SECONDS",
|
|
46
|
+
"VERIFY_ERROR_DESCRIPTIONS",
|
|
47
|
+
"Verifier",
|
|
48
|
+
"VerifyAgentTokenResult",
|
|
49
|
+
"VerifyErrorCode",
|
|
50
|
+
"__version__",
|
|
51
|
+
"create_agent_account",
|
|
52
|
+
"create_agent_account_async",
|
|
53
|
+
"normalize_audience",
|
|
54
|
+
"normalize_email",
|
|
55
|
+
"status_from_code",
|
|
56
|
+
"verify_agent_account",
|
|
57
|
+
"verify_agent_account_async",
|
|
58
|
+
"verify_agent_token",
|
|
59
|
+
"verify_agent_token_async",
|
|
60
|
+
]
|
agentonboard/account.py
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Account mapping and automated provisioning boundaries for AgentOnboard.
|
|
3
|
+
|
|
4
|
+
Implements verify_agent_account and create_agent_account per ADR 0002 and
|
|
5
|
+
docs/decisions/0007-agents-can-create-partner-accounts.md.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import inspect
|
|
11
|
+
from collections.abc import Awaitable, Callable
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any, Generic, TypeVar
|
|
14
|
+
|
|
15
|
+
from agentonboard.codes import AccountErrorCode
|
|
16
|
+
from agentonboard.verify import (
|
|
17
|
+
DEFAULT_CLOCK_TOLERANCE_SECONDS,
|
|
18
|
+
verify_agent_token,
|
|
19
|
+
verify_agent_token_async,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
TUser = TypeVar("TUser")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(slots=True, frozen=True)
|
|
26
|
+
class AgentIdentity:
|
|
27
|
+
"""The verified identity, and the only thing that crosses into your resolver/creator."""
|
|
28
|
+
|
|
29
|
+
email: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def normalize_email(email: str) -> str:
|
|
33
|
+
"""Normalizes an email address for database lookups by trimming whitespace and converting to lowercase."""
|
|
34
|
+
if not email or not isinstance(email, str):
|
|
35
|
+
return ""
|
|
36
|
+
return email.strip().lower()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(slots=True, frozen=True)
|
|
40
|
+
class AccountOutcome(Generic[TUser]):
|
|
41
|
+
ok: bool
|
|
42
|
+
email: str | None = None
|
|
43
|
+
user: TUser | None = None
|
|
44
|
+
created: bool = False
|
|
45
|
+
code: AccountErrorCode | None = None
|
|
46
|
+
error: str | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
AccountResolver = Callable[[AgentIdentity], TUser | None]
|
|
50
|
+
AccountCreator = Callable[[AgentIdentity], TUser]
|
|
51
|
+
AsyncAccountResolver = Callable[[AgentIdentity], Awaitable[TUser | None] | TUser | None]
|
|
52
|
+
AsyncAccountCreator = Callable[[AgentIdentity], Awaitable[TUser] | TUser]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def verify_agent_account(
|
|
56
|
+
token: str,
|
|
57
|
+
*,
|
|
58
|
+
audience: str,
|
|
59
|
+
resolver: AccountResolver[TUser],
|
|
60
|
+
issuer: str | None = None,
|
|
61
|
+
jwks_url: str | None = None,
|
|
62
|
+
jwks: dict[str, Any] | None = None,
|
|
63
|
+
fetch_jwks: Callable[[str], dict[str, Any]] | None = None,
|
|
64
|
+
clock_tolerance_seconds: int = DEFAULT_CLOCK_TOLERANCE_SECONDS,
|
|
65
|
+
) -> AccountOutcome[TUser]:
|
|
66
|
+
"""
|
|
67
|
+
Verifies an agent session token, then maps the verified email to your own user.
|
|
68
|
+
Never creates an account. If no account exists, returns ACCOUNT_REQUIRED.
|
|
69
|
+
"""
|
|
70
|
+
verify_result = verify_agent_token(
|
|
71
|
+
token=token,
|
|
72
|
+
audience=audience,
|
|
73
|
+
issuer=issuer,
|
|
74
|
+
jwks_url=jwks_url,
|
|
75
|
+
jwks=jwks,
|
|
76
|
+
fetch_jwks=fetch_jwks,
|
|
77
|
+
clock_tolerance_seconds=clock_tolerance_seconds,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
if not verify_result.ok:
|
|
81
|
+
return AccountOutcome(
|
|
82
|
+
ok=False,
|
|
83
|
+
code=verify_result.code,
|
|
84
|
+
error=verify_result.error,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
verified_email = verify_result.email or ""
|
|
88
|
+
identity = AgentIdentity(email=verified_email)
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
user = resolver(identity)
|
|
92
|
+
except Exception:
|
|
93
|
+
return AccountOutcome(
|
|
94
|
+
ok=False,
|
|
95
|
+
code="RESOLVER_ERROR",
|
|
96
|
+
error="The account resolver threw. This is a fault in the resolver, not the token.",
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
if user is None:
|
|
100
|
+
return AccountOutcome(
|
|
101
|
+
ok=False,
|
|
102
|
+
code="ACCOUNT_REQUIRED",
|
|
103
|
+
error="This identity has no account at your service; send the human to connect their account, then retry.",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
return AccountOutcome(
|
|
107
|
+
ok=True,
|
|
108
|
+
email=verified_email,
|
|
109
|
+
user=user,
|
|
110
|
+
created=False,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def verify_agent_account_async(
|
|
115
|
+
token: str,
|
|
116
|
+
*,
|
|
117
|
+
audience: str,
|
|
118
|
+
resolver: AsyncAccountResolver[TUser],
|
|
119
|
+
issuer: str | None = None,
|
|
120
|
+
jwks_url: str | None = None,
|
|
121
|
+
jwks: dict[str, Any] | None = None,
|
|
122
|
+
fetch_jwks: Callable[[str], dict[str, Any]] | None = None,
|
|
123
|
+
fetch_jwks_async: Callable[[str], Awaitable[dict[str, Any]]] | None = None,
|
|
124
|
+
clock_tolerance_seconds: int = DEFAULT_CLOCK_TOLERANCE_SECONDS,
|
|
125
|
+
) -> AccountOutcome[TUser]:
|
|
126
|
+
"""Asynchronous variant of verify_agent_account."""
|
|
127
|
+
verify_result = await verify_agent_token_async(
|
|
128
|
+
token=token,
|
|
129
|
+
audience=audience,
|
|
130
|
+
issuer=issuer,
|
|
131
|
+
jwks_url=jwks_url,
|
|
132
|
+
jwks=jwks,
|
|
133
|
+
fetch_jwks=fetch_jwks,
|
|
134
|
+
fetch_jwks_async=fetch_jwks_async,
|
|
135
|
+
clock_tolerance_seconds=clock_tolerance_seconds,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
if not verify_result.ok:
|
|
139
|
+
return AccountOutcome(
|
|
140
|
+
ok=False,
|
|
141
|
+
code=verify_result.code,
|
|
142
|
+
error=verify_result.error,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
verified_email = verify_result.email or ""
|
|
146
|
+
identity = AgentIdentity(email=verified_email)
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
user_val = resolver(identity)
|
|
150
|
+
if inspect.isawaitable(user_val):
|
|
151
|
+
user = await user_val
|
|
152
|
+
else:
|
|
153
|
+
user = user_val
|
|
154
|
+
except Exception:
|
|
155
|
+
return AccountOutcome(
|
|
156
|
+
ok=False,
|
|
157
|
+
code="RESOLVER_ERROR",
|
|
158
|
+
error="The account resolver threw. This is a fault in the resolver, not the token.",
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
if user is None:
|
|
162
|
+
return AccountOutcome(
|
|
163
|
+
ok=False,
|
|
164
|
+
code="ACCOUNT_REQUIRED",
|
|
165
|
+
error="This identity has no account at your service; send the human to connect their account, then retry.",
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
return AccountOutcome(
|
|
169
|
+
ok=True,
|
|
170
|
+
email=verified_email,
|
|
171
|
+
user=user,
|
|
172
|
+
created=False,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def create_agent_account(
|
|
177
|
+
token: str,
|
|
178
|
+
*,
|
|
179
|
+
audience: str,
|
|
180
|
+
resolver: AccountResolver[TUser],
|
|
181
|
+
creator: AccountCreator[TUser],
|
|
182
|
+
issuer: str | None = None,
|
|
183
|
+
jwks_url: str | None = None,
|
|
184
|
+
jwks: dict[str, Any] | None = None,
|
|
185
|
+
fetch_jwks: Callable[[str], dict[str, Any]] | None = None,
|
|
186
|
+
clock_tolerance_seconds: int = DEFAULT_CLOCK_TOLERANCE_SECONDS,
|
|
187
|
+
) -> AccountOutcome[TUser]:
|
|
188
|
+
"""
|
|
189
|
+
Verifies an agent session token, maps the email to a user, and if no user exists,
|
|
190
|
+
calls creator once to provision the account.
|
|
191
|
+
"""
|
|
192
|
+
verify_result = verify_agent_token(
|
|
193
|
+
token=token,
|
|
194
|
+
audience=audience,
|
|
195
|
+
issuer=issuer,
|
|
196
|
+
jwks_url=jwks_url,
|
|
197
|
+
jwks=jwks,
|
|
198
|
+
fetch_jwks=fetch_jwks,
|
|
199
|
+
clock_tolerance_seconds=clock_tolerance_seconds,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
if not verify_result.ok:
|
|
203
|
+
return AccountOutcome(
|
|
204
|
+
ok=False,
|
|
205
|
+
code=verify_result.code,
|
|
206
|
+
error=verify_result.error,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
verified_email = verify_result.email or ""
|
|
210
|
+
identity = AgentIdentity(email=verified_email)
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
user = resolver(identity)
|
|
214
|
+
except Exception:
|
|
215
|
+
return AccountOutcome(
|
|
216
|
+
ok=False,
|
|
217
|
+
code="RESOLVER_ERROR",
|
|
218
|
+
error="The account resolver threw. This is a fault in the resolver, not the token.",
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
if user is not None:
|
|
222
|
+
return AccountOutcome(
|
|
223
|
+
ok=True,
|
|
224
|
+
email=verified_email,
|
|
225
|
+
user=user,
|
|
226
|
+
created=False,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
try:
|
|
230
|
+
new_user = creator(identity)
|
|
231
|
+
except Exception:
|
|
232
|
+
return AccountOutcome(
|
|
233
|
+
ok=False,
|
|
234
|
+
code="RESOLVER_ERROR",
|
|
235
|
+
error="The account creator threw. This is a fault in the creator, not the token.",
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
return AccountOutcome(
|
|
239
|
+
ok=True,
|
|
240
|
+
email=verified_email,
|
|
241
|
+
user=new_user,
|
|
242
|
+
created=True,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
async def create_agent_account_async(
|
|
247
|
+
token: str,
|
|
248
|
+
*,
|
|
249
|
+
audience: str,
|
|
250
|
+
resolver: AsyncAccountResolver[TUser],
|
|
251
|
+
creator: AsyncAccountCreator[TUser],
|
|
252
|
+
issuer: str | None = None,
|
|
253
|
+
jwks_url: str | None = None,
|
|
254
|
+
jwks: dict[str, Any] | None = None,
|
|
255
|
+
fetch_jwks: Callable[[str], dict[str, Any]] | None = None,
|
|
256
|
+
fetch_jwks_async: Callable[[str], Awaitable[dict[str, Any]]] | None = None,
|
|
257
|
+
clock_tolerance_seconds: int = DEFAULT_CLOCK_TOLERANCE_SECONDS,
|
|
258
|
+
) -> AccountOutcome[TUser]:
|
|
259
|
+
"""Asynchronous variant of create_agent_account."""
|
|
260
|
+
verify_result = await verify_agent_token_async(
|
|
261
|
+
token=token,
|
|
262
|
+
audience=audience,
|
|
263
|
+
issuer=issuer,
|
|
264
|
+
jwks_url=jwks_url,
|
|
265
|
+
jwks=jwks,
|
|
266
|
+
fetch_jwks=fetch_jwks,
|
|
267
|
+
fetch_jwks_async=fetch_jwks_async,
|
|
268
|
+
clock_tolerance_seconds=clock_tolerance_seconds,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
if not verify_result.ok:
|
|
272
|
+
return AccountOutcome(
|
|
273
|
+
ok=False,
|
|
274
|
+
code=verify_result.code,
|
|
275
|
+
error=verify_result.error,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
verified_email = verify_result.email or ""
|
|
279
|
+
identity = AgentIdentity(email=verified_email)
|
|
280
|
+
|
|
281
|
+
try:
|
|
282
|
+
user_val = resolver(identity)
|
|
283
|
+
if inspect.isawaitable(user_val):
|
|
284
|
+
user = await user_val
|
|
285
|
+
else:
|
|
286
|
+
user = user_val
|
|
287
|
+
except Exception:
|
|
288
|
+
return AccountOutcome(
|
|
289
|
+
ok=False,
|
|
290
|
+
code="RESOLVER_ERROR",
|
|
291
|
+
error="The account resolver threw. This is a fault in the resolver, not the token.",
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
if user is not None:
|
|
295
|
+
return AccountOutcome(
|
|
296
|
+
ok=True,
|
|
297
|
+
email=verified_email,
|
|
298
|
+
user=user,
|
|
299
|
+
created=False,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
try:
|
|
303
|
+
new_user_val = creator(identity)
|
|
304
|
+
if inspect.isawaitable(new_user_val):
|
|
305
|
+
new_user = await new_user_val
|
|
306
|
+
else:
|
|
307
|
+
new_user = new_user_val
|
|
308
|
+
except Exception:
|
|
309
|
+
return AccountOutcome(
|
|
310
|
+
ok=False,
|
|
311
|
+
code="RESOLVER_ERROR",
|
|
312
|
+
error="The account creator threw. This is a fault in the creator, not the token.",
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
return AccountOutcome(
|
|
316
|
+
ok=True,
|
|
317
|
+
email=verified_email,
|
|
318
|
+
user=new_user,
|
|
319
|
+
created=True,
|
|
320
|
+
)
|
agentonboard/codes.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Every failure verify_agent_token and verify_agent_account can report.
|
|
3
|
+
|
|
4
|
+
Published vocabulary: codes are only ever added, never renamed or removed, because partners
|
|
5
|
+
branch on them. Keep this list short — every code is a decision each language SDK has
|
|
6
|
+
to make identically (plans/0008-sdk-design.md §6).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
VerifyErrorCode = Literal[
|
|
12
|
+
"EXPIRED",
|
|
13
|
+
"INVALID_SIGNATURE",
|
|
14
|
+
"AUDIENCE_MISMATCH",
|
|
15
|
+
"ISSUER_MISMATCH",
|
|
16
|
+
"MALFORMED_TOKEN",
|
|
17
|
+
"CONFIG_ERROR",
|
|
18
|
+
"UNKNOWN_KEY",
|
|
19
|
+
"KEY_SOURCE_UNAVAILABLE",
|
|
20
|
+
"MISSING_EMAIL",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
AccountErrorCode = Literal[
|
|
24
|
+
VerifyErrorCode,
|
|
25
|
+
"ACCOUNT_REQUIRED",
|
|
26
|
+
"RESOLVER_ERROR",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
VERIFY_ERROR_DESCRIPTIONS: dict[VerifyErrorCode, str] = {
|
|
30
|
+
"EXPIRED": "The token's exp claim is in the past, beyond the clock-skew tolerance.",
|
|
31
|
+
"INVALID_SIGNATURE": (
|
|
32
|
+
"The signature did not verify against a published key, or the token used an "
|
|
33
|
+
"algorithm this SDK does not accept."
|
|
34
|
+
),
|
|
35
|
+
"AUDIENCE_MISMATCH": "The token was minted for a different audience, or carries no aud claim.",
|
|
36
|
+
"ISSUER_MISMATCH": "The token was minted by a different issuer, or carries no iss claim.",
|
|
37
|
+
"MALFORMED_TOKEN": (
|
|
38
|
+
"The token is not a readable JWT, or a claim this SDK requires is absent or unusable."
|
|
39
|
+
),
|
|
40
|
+
"CONFIG_ERROR": "The call itself is wrong: audience, issuer, JWKS URL, or clock-skew tolerance.",
|
|
41
|
+
"UNKNOWN_KEY": (
|
|
42
|
+
"The token's kid matched no key in the key set this SDK could obtain. The code does "
|
|
43
|
+
"not say why: the key may be revoked or rotated away, the token may be forged, or "
|
|
44
|
+
"the key endpoint may be unreachable and leave this SDK holding an out-of-date set."
|
|
45
|
+
),
|
|
46
|
+
"KEY_SOURCE_UNAVAILABLE": (
|
|
47
|
+
"This SDK could not obtain a signing key: the key set could not be fetched, the "
|
|
48
|
+
"published set is not one this SDK can verify against, or it publishes more than one "
|
|
49
|
+
"key for the token's kid."
|
|
50
|
+
),
|
|
51
|
+
"MISSING_EMAIL": "The token verified but carries no usable email claim to identify the human.",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
ACCOUNT_ERROR_DESCRIPTIONS: dict[AccountErrorCode, str] = {
|
|
55
|
+
**VERIFY_ERROR_DESCRIPTIONS, # type: ignore[dict-item]
|
|
56
|
+
"ACCOUNT_REQUIRED": (
|
|
57
|
+
"The token verified, but the partner resolver returned no account for this identity."
|
|
58
|
+
),
|
|
59
|
+
"RESOLVER_ERROR": "The partner's account lookup or creation callback threw an error.",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def status_from_code(code: str | None) -> int:
|
|
64
|
+
"""
|
|
65
|
+
Turns a verification or account failure code into an HTTP status code.
|
|
66
|
+
Rules per agent-token-v1 statusMapping:
|
|
67
|
+
- 403 for ACCOUNT_REQUIRED
|
|
68
|
+
- 500 for RESOLVER_ERROR and CONFIG_ERROR
|
|
69
|
+
- 401 for all other error codes
|
|
70
|
+
"""
|
|
71
|
+
if code == "ACCOUNT_REQUIRED":
|
|
72
|
+
return 403
|
|
73
|
+
if code in ("RESOLVER_ERROR", "CONFIG_ERROR"):
|
|
74
|
+
return 500
|
|
75
|
+
return 401
|
|
76
|
+
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FastAPI security dependency for AgentOnboard.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Any, Generic, TypeVar
|
|
8
|
+
|
|
9
|
+
from fastapi import HTTPException, Request, status
|
|
10
|
+
from fastapi.openapi.models import HTTPBearer as HTTPBearerModel
|
|
11
|
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
12
|
+
from fastapi.security.base import SecurityBase
|
|
13
|
+
|
|
14
|
+
from agentonboard.account import (
|
|
15
|
+
AgentIdentity,
|
|
16
|
+
AsyncAccountCreator,
|
|
17
|
+
AsyncAccountResolver,
|
|
18
|
+
create_agent_account_async,
|
|
19
|
+
verify_agent_account_async,
|
|
20
|
+
)
|
|
21
|
+
from agentonboard.codes import status_from_code
|
|
22
|
+
from agentonboard.verify import verify_agent_token_async
|
|
23
|
+
|
|
24
|
+
TUser = TypeVar("TUser")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AgentBearerAuth(SecurityBase, Generic[TUser]):
|
|
28
|
+
"""
|
|
29
|
+
FastAPI dependency for verifying AgentOnboard session tokens and resolving accounts.
|
|
30
|
+
|
|
31
|
+
Usage:
|
|
32
|
+
auth = AgentBearerAuth(audience="notes.com")
|
|
33
|
+
|
|
34
|
+
@app.get("/items")
|
|
35
|
+
async def read_items(identity: AgentIdentity = Depends(auth)):
|
|
36
|
+
return {"user": identity.email}
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
*,
|
|
42
|
+
audience: str,
|
|
43
|
+
resolver: AsyncAccountResolver[TUser] | None = None,
|
|
44
|
+
creator: AsyncAccountCreator[TUser] | None = None,
|
|
45
|
+
auto_create: bool = False,
|
|
46
|
+
issuer: str | None = None,
|
|
47
|
+
jwks_url: str | None = None,
|
|
48
|
+
jwks: dict[str, Any] | None = None,
|
|
49
|
+
auto_error: bool = True,
|
|
50
|
+
) -> None:
|
|
51
|
+
self.model = HTTPBearerModel()
|
|
52
|
+
self.scheme_name = self.__class__.__name__
|
|
53
|
+
self.bearer = HTTPBearer(auto_error=auto_error)
|
|
54
|
+
self.audience = audience
|
|
55
|
+
self.resolver = resolver
|
|
56
|
+
self.creator = creator
|
|
57
|
+
self.auto_create = auto_create
|
|
58
|
+
self.issuer = issuer
|
|
59
|
+
self.jwks_url = jwks_url
|
|
60
|
+
self.jwks = jwks
|
|
61
|
+
|
|
62
|
+
async def __call__(
|
|
63
|
+
self, request: Request
|
|
64
|
+
) -> AgentIdentity | TUser:
|
|
65
|
+
credentials: HTTPAuthorizationCredentials | None = await self.bearer(request)
|
|
66
|
+
if not credentials or not credentials.credentials:
|
|
67
|
+
raise HTTPException(
|
|
68
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
69
|
+
detail={"code": "MALFORMED_TOKEN", "error": "Missing authorization bearer token"},
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
token = credentials.credentials
|
|
73
|
+
|
|
74
|
+
if self.resolver is None:
|
|
75
|
+
# Token verification only
|
|
76
|
+
result = await verify_agent_token_async(
|
|
77
|
+
token=token,
|
|
78
|
+
audience=self.audience,
|
|
79
|
+
issuer=self.issuer,
|
|
80
|
+
jwks_url=self.jwks_url,
|
|
81
|
+
jwks=self.jwks,
|
|
82
|
+
)
|
|
83
|
+
if not result.ok:
|
|
84
|
+
http_status = status_from_code(result.code)
|
|
85
|
+
raise HTTPException(
|
|
86
|
+
status_code=http_status,
|
|
87
|
+
detail={"code": result.code, "error": result.error},
|
|
88
|
+
)
|
|
89
|
+
return AgentIdentity(email=result.email or "")
|
|
90
|
+
|
|
91
|
+
# Account resolution / provisioning
|
|
92
|
+
if self.auto_create and self.creator is not None:
|
|
93
|
+
outcome = await create_agent_account_async(
|
|
94
|
+
token=token,
|
|
95
|
+
audience=self.audience,
|
|
96
|
+
resolver=self.resolver,
|
|
97
|
+
creator=self.creator,
|
|
98
|
+
issuer=self.issuer,
|
|
99
|
+
jwks_url=self.jwks_url,
|
|
100
|
+
jwks=self.jwks,
|
|
101
|
+
)
|
|
102
|
+
else:
|
|
103
|
+
outcome = await verify_agent_account_async(
|
|
104
|
+
token=token,
|
|
105
|
+
audience=self.audience,
|
|
106
|
+
resolver=self.resolver,
|
|
107
|
+
issuer=self.issuer,
|
|
108
|
+
jwks_url=self.jwks_url,
|
|
109
|
+
jwks=self.jwks,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
if not outcome.ok:
|
|
113
|
+
http_status = status_from_code(outcome.code)
|
|
114
|
+
raise HTTPException(
|
|
115
|
+
status_code=http_status,
|
|
116
|
+
detail={"code": outcome.code, "error": outcome.error},
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
return outcome.user # type: ignore[return-value]
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Flask decorator and helper for AgentOnboard.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import functools
|
|
8
|
+
import inspect
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from typing import Any, TypeVar
|
|
11
|
+
|
|
12
|
+
from flask import g, jsonify, request
|
|
13
|
+
|
|
14
|
+
from agentonboard.account import (
|
|
15
|
+
AccountCreator,
|
|
16
|
+
AccountResolver,
|
|
17
|
+
AgentIdentity,
|
|
18
|
+
create_agent_account,
|
|
19
|
+
verify_agent_account,
|
|
20
|
+
)
|
|
21
|
+
from agentonboard.codes import status_from_code
|
|
22
|
+
from agentonboard.verify import verify_agent_token
|
|
23
|
+
|
|
24
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
25
|
+
TUser = TypeVar("TUser")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def require_agent_token(
|
|
29
|
+
*,
|
|
30
|
+
audience: str,
|
|
31
|
+
resolver: AccountResolver[TUser] | None = None,
|
|
32
|
+
creator: AccountCreator[TUser] | None = None,
|
|
33
|
+
auto_create: bool = False,
|
|
34
|
+
issuer: str | None = None,
|
|
35
|
+
jwks_url: str | None = None,
|
|
36
|
+
jwks: dict[str, Any] | None = None,
|
|
37
|
+
) -> Callable[[F], F]:
|
|
38
|
+
"""
|
|
39
|
+
Flask route decorator for verifying AgentOnboard session tokens.
|
|
40
|
+
|
|
41
|
+
Usage:
|
|
42
|
+
@app.route("/protected")
|
|
43
|
+
@require_agent_token(audience="notes.com")
|
|
44
|
+
def protected():
|
|
45
|
+
return jsonify({"user": g.agent_identity.email})
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def decorator(fn: F) -> F:
|
|
49
|
+
@functools.wraps(fn)
|
|
50
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
51
|
+
auth_header = request.headers.get("Authorization", "")
|
|
52
|
+
if not auth_header or not auth_header.startswith("Bearer "):
|
|
53
|
+
return (
|
|
54
|
+
jsonify(
|
|
55
|
+
{
|
|
56
|
+
"code": "MALFORMED_TOKEN",
|
|
57
|
+
"error": "Missing or malformed Authorization Bearer header",
|
|
58
|
+
}
|
|
59
|
+
),
|
|
60
|
+
401,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
token = auth_header[7:].strip()
|
|
64
|
+
|
|
65
|
+
if resolver is None:
|
|
66
|
+
res = verify_agent_token(
|
|
67
|
+
token=token,
|
|
68
|
+
audience=audience,
|
|
69
|
+
issuer=issuer,
|
|
70
|
+
jwks_url=jwks_url,
|
|
71
|
+
jwks=jwks,
|
|
72
|
+
)
|
|
73
|
+
if not res.ok:
|
|
74
|
+
status_code = status_from_code(res.code)
|
|
75
|
+
return jsonify({"code": res.code, "error": res.error}), status_code
|
|
76
|
+
|
|
77
|
+
identity = AgentIdentity(email=res.email or "")
|
|
78
|
+
g.agent_identity = identity
|
|
79
|
+
|
|
80
|
+
sig = inspect.signature(fn)
|
|
81
|
+
if "agent_identity" in sig.parameters:
|
|
82
|
+
kwargs["agent_identity"] = identity
|
|
83
|
+
return fn(*args, **kwargs)
|
|
84
|
+
|
|
85
|
+
# Account resolution
|
|
86
|
+
if auto_create and creator is not None:
|
|
87
|
+
outcome = create_agent_account(
|
|
88
|
+
token=token,
|
|
89
|
+
audience=audience,
|
|
90
|
+
resolver=resolver,
|
|
91
|
+
creator=creator,
|
|
92
|
+
issuer=issuer,
|
|
93
|
+
jwks_url=jwks_url,
|
|
94
|
+
jwks=jwks,
|
|
95
|
+
)
|
|
96
|
+
else:
|
|
97
|
+
outcome = verify_agent_account(
|
|
98
|
+
token=token,
|
|
99
|
+
audience=audience,
|
|
100
|
+
resolver=resolver,
|
|
101
|
+
issuer=issuer,
|
|
102
|
+
jwks_url=jwks_url,
|
|
103
|
+
jwks=jwks,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
if not outcome.ok:
|
|
107
|
+
status_code = status_from_code(outcome.code)
|
|
108
|
+
return jsonify({"code": outcome.code, "error": outcome.error}), status_code
|
|
109
|
+
|
|
110
|
+
identity = AgentIdentity(email=outcome.email or "")
|
|
111
|
+
g.agent_identity = identity
|
|
112
|
+
g.agent_user = outcome.user
|
|
113
|
+
|
|
114
|
+
sig = inspect.signature(fn)
|
|
115
|
+
if "agent_user" in sig.parameters:
|
|
116
|
+
kwargs["agent_user"] = outcome.user
|
|
117
|
+
if "agent_identity" in sig.parameters:
|
|
118
|
+
kwargs["agent_identity"] = identity
|
|
119
|
+
|
|
120
|
+
return fn(*args, **kwargs)
|
|
121
|
+
|
|
122
|
+
return wrapper # type: ignore[return-value]
|
|
123
|
+
|
|
124
|
+
return decorator
|