agno 2.1.2__py3-none-any.whl → 2.3.13__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.
Files changed (314) hide show
  1. agno/agent/agent.py +5540 -2273
  2. agno/api/api.py +2 -0
  3. agno/api/os.py +1 -1
  4. agno/compression/__init__.py +3 -0
  5. agno/compression/manager.py +247 -0
  6. agno/culture/__init__.py +3 -0
  7. agno/culture/manager.py +956 -0
  8. agno/db/async_postgres/__init__.py +3 -0
  9. agno/db/base.py +689 -6
  10. agno/db/dynamo/dynamo.py +933 -37
  11. agno/db/dynamo/schemas.py +174 -10
  12. agno/db/dynamo/utils.py +63 -4
  13. agno/db/firestore/firestore.py +831 -9
  14. agno/db/firestore/schemas.py +51 -0
  15. agno/db/firestore/utils.py +102 -4
  16. agno/db/gcs_json/gcs_json_db.py +660 -12
  17. agno/db/gcs_json/utils.py +60 -26
  18. agno/db/in_memory/in_memory_db.py +287 -14
  19. agno/db/in_memory/utils.py +60 -2
  20. agno/db/json/json_db.py +590 -14
  21. agno/db/json/utils.py +60 -26
  22. agno/db/migrations/manager.py +199 -0
  23. agno/db/migrations/v1_to_v2.py +43 -13
  24. agno/db/migrations/versions/__init__.py +0 -0
  25. agno/db/migrations/versions/v2_3_0.py +938 -0
  26. agno/db/mongo/__init__.py +15 -1
  27. agno/db/mongo/async_mongo.py +2760 -0
  28. agno/db/mongo/mongo.py +879 -11
  29. agno/db/mongo/schemas.py +42 -0
  30. agno/db/mongo/utils.py +80 -8
  31. agno/db/mysql/__init__.py +2 -1
  32. agno/db/mysql/async_mysql.py +2912 -0
  33. agno/db/mysql/mysql.py +946 -68
  34. agno/db/mysql/schemas.py +72 -10
  35. agno/db/mysql/utils.py +198 -7
  36. agno/db/postgres/__init__.py +2 -1
  37. agno/db/postgres/async_postgres.py +2579 -0
  38. agno/db/postgres/postgres.py +942 -57
  39. agno/db/postgres/schemas.py +81 -18
  40. agno/db/postgres/utils.py +164 -2
  41. agno/db/redis/redis.py +671 -7
  42. agno/db/redis/schemas.py +50 -0
  43. agno/db/redis/utils.py +65 -7
  44. agno/db/schemas/__init__.py +2 -1
  45. agno/db/schemas/culture.py +120 -0
  46. agno/db/schemas/evals.py +1 -0
  47. agno/db/schemas/memory.py +17 -2
  48. agno/db/singlestore/schemas.py +63 -0
  49. agno/db/singlestore/singlestore.py +949 -83
  50. agno/db/singlestore/utils.py +60 -2
  51. agno/db/sqlite/__init__.py +2 -1
  52. agno/db/sqlite/async_sqlite.py +2911 -0
  53. agno/db/sqlite/schemas.py +62 -0
  54. agno/db/sqlite/sqlite.py +965 -46
  55. agno/db/sqlite/utils.py +169 -8
  56. agno/db/surrealdb/__init__.py +3 -0
  57. agno/db/surrealdb/metrics.py +292 -0
  58. agno/db/surrealdb/models.py +334 -0
  59. agno/db/surrealdb/queries.py +71 -0
  60. agno/db/surrealdb/surrealdb.py +1908 -0
  61. agno/db/surrealdb/utils.py +147 -0
  62. agno/db/utils.py +2 -0
  63. agno/eval/__init__.py +10 -0
  64. agno/eval/accuracy.py +75 -55
  65. agno/eval/agent_as_judge.py +861 -0
  66. agno/eval/base.py +29 -0
  67. agno/eval/performance.py +16 -7
  68. agno/eval/reliability.py +28 -16
  69. agno/eval/utils.py +35 -17
  70. agno/exceptions.py +27 -2
  71. agno/filters.py +354 -0
  72. agno/guardrails/prompt_injection.py +1 -0
  73. agno/hooks/__init__.py +3 -0
  74. agno/hooks/decorator.py +164 -0
  75. agno/integrations/discord/client.py +1 -1
  76. agno/knowledge/chunking/agentic.py +13 -10
  77. agno/knowledge/chunking/fixed.py +4 -1
  78. agno/knowledge/chunking/semantic.py +9 -4
  79. agno/knowledge/chunking/strategy.py +59 -15
  80. agno/knowledge/embedder/fastembed.py +1 -1
  81. agno/knowledge/embedder/nebius.py +1 -1
  82. agno/knowledge/embedder/ollama.py +8 -0
  83. agno/knowledge/embedder/openai.py +8 -8
  84. agno/knowledge/embedder/sentence_transformer.py +6 -2
  85. agno/knowledge/embedder/vllm.py +262 -0
  86. agno/knowledge/knowledge.py +1618 -318
  87. agno/knowledge/reader/base.py +6 -2
  88. agno/knowledge/reader/csv_reader.py +8 -10
  89. agno/knowledge/reader/docx_reader.py +5 -6
  90. agno/knowledge/reader/field_labeled_csv_reader.py +16 -20
  91. agno/knowledge/reader/json_reader.py +5 -4
  92. agno/knowledge/reader/markdown_reader.py +8 -8
  93. agno/knowledge/reader/pdf_reader.py +17 -19
  94. agno/knowledge/reader/pptx_reader.py +101 -0
  95. agno/knowledge/reader/reader_factory.py +32 -3
  96. agno/knowledge/reader/s3_reader.py +3 -3
  97. agno/knowledge/reader/tavily_reader.py +193 -0
  98. agno/knowledge/reader/text_reader.py +22 -10
  99. agno/knowledge/reader/web_search_reader.py +1 -48
  100. agno/knowledge/reader/website_reader.py +10 -10
  101. agno/knowledge/reader/wikipedia_reader.py +33 -1
  102. agno/knowledge/types.py +1 -0
  103. agno/knowledge/utils.py +72 -7
  104. agno/media.py +22 -6
  105. agno/memory/__init__.py +14 -1
  106. agno/memory/manager.py +544 -83
  107. agno/memory/strategies/__init__.py +15 -0
  108. agno/memory/strategies/base.py +66 -0
  109. agno/memory/strategies/summarize.py +196 -0
  110. agno/memory/strategies/types.py +37 -0
  111. agno/models/aimlapi/aimlapi.py +17 -0
  112. agno/models/anthropic/claude.py +515 -40
  113. agno/models/aws/bedrock.py +102 -21
  114. agno/models/aws/claude.py +131 -274
  115. agno/models/azure/ai_foundry.py +41 -19
  116. agno/models/azure/openai_chat.py +39 -8
  117. agno/models/base.py +1249 -525
  118. agno/models/cerebras/cerebras.py +91 -21
  119. agno/models/cerebras/cerebras_openai.py +21 -2
  120. agno/models/cohere/chat.py +40 -6
  121. agno/models/cometapi/cometapi.py +18 -1
  122. agno/models/dashscope/dashscope.py +2 -3
  123. agno/models/deepinfra/deepinfra.py +18 -1
  124. agno/models/deepseek/deepseek.py +69 -3
  125. agno/models/fireworks/fireworks.py +18 -1
  126. agno/models/google/gemini.py +877 -80
  127. agno/models/google/utils.py +22 -0
  128. agno/models/groq/groq.py +51 -18
  129. agno/models/huggingface/huggingface.py +17 -6
  130. agno/models/ibm/watsonx.py +16 -6
  131. agno/models/internlm/internlm.py +18 -1
  132. agno/models/langdb/langdb.py +13 -1
  133. agno/models/litellm/chat.py +44 -9
  134. agno/models/litellm/litellm_openai.py +18 -1
  135. agno/models/message.py +28 -5
  136. agno/models/meta/llama.py +47 -14
  137. agno/models/meta/llama_openai.py +22 -17
  138. agno/models/mistral/mistral.py +8 -4
  139. agno/models/nebius/nebius.py +6 -7
  140. agno/models/nvidia/nvidia.py +20 -3
  141. agno/models/ollama/chat.py +24 -8
  142. agno/models/openai/chat.py +104 -29
  143. agno/models/openai/responses.py +101 -81
  144. agno/models/openrouter/openrouter.py +60 -3
  145. agno/models/perplexity/perplexity.py +17 -1
  146. agno/models/portkey/portkey.py +7 -6
  147. agno/models/requesty/requesty.py +24 -4
  148. agno/models/response.py +73 -2
  149. agno/models/sambanova/sambanova.py +20 -3
  150. agno/models/siliconflow/siliconflow.py +19 -2
  151. agno/models/together/together.py +20 -3
  152. agno/models/utils.py +254 -8
  153. agno/models/vercel/v0.py +20 -3
  154. agno/models/vertexai/__init__.py +0 -0
  155. agno/models/vertexai/claude.py +190 -0
  156. agno/models/vllm/vllm.py +19 -14
  157. agno/models/xai/xai.py +19 -2
  158. agno/os/app.py +549 -152
  159. agno/os/auth.py +190 -3
  160. agno/os/config.py +23 -0
  161. agno/os/interfaces/a2a/router.py +8 -11
  162. agno/os/interfaces/a2a/utils.py +1 -1
  163. agno/os/interfaces/agui/router.py +18 -3
  164. agno/os/interfaces/agui/utils.py +152 -39
  165. agno/os/interfaces/slack/router.py +55 -37
  166. agno/os/interfaces/slack/slack.py +9 -1
  167. agno/os/interfaces/whatsapp/router.py +0 -1
  168. agno/os/interfaces/whatsapp/security.py +3 -1
  169. agno/os/mcp.py +110 -52
  170. agno/os/middleware/__init__.py +2 -0
  171. agno/os/middleware/jwt.py +676 -112
  172. agno/os/router.py +40 -1478
  173. agno/os/routers/agents/__init__.py +3 -0
  174. agno/os/routers/agents/router.py +599 -0
  175. agno/os/routers/agents/schema.py +261 -0
  176. agno/os/routers/evals/evals.py +96 -39
  177. agno/os/routers/evals/schemas.py +65 -33
  178. agno/os/routers/evals/utils.py +80 -10
  179. agno/os/routers/health.py +10 -4
  180. agno/os/routers/knowledge/knowledge.py +196 -38
  181. agno/os/routers/knowledge/schemas.py +82 -22
  182. agno/os/routers/memory/memory.py +279 -52
  183. agno/os/routers/memory/schemas.py +46 -17
  184. agno/os/routers/metrics/metrics.py +20 -8
  185. agno/os/routers/metrics/schemas.py +16 -16
  186. agno/os/routers/session/session.py +462 -34
  187. agno/os/routers/teams/__init__.py +3 -0
  188. agno/os/routers/teams/router.py +512 -0
  189. agno/os/routers/teams/schema.py +257 -0
  190. agno/os/routers/traces/__init__.py +3 -0
  191. agno/os/routers/traces/schemas.py +414 -0
  192. agno/os/routers/traces/traces.py +499 -0
  193. agno/os/routers/workflows/__init__.py +3 -0
  194. agno/os/routers/workflows/router.py +624 -0
  195. agno/os/routers/workflows/schema.py +75 -0
  196. agno/os/schema.py +256 -693
  197. agno/os/scopes.py +469 -0
  198. agno/os/utils.py +514 -36
  199. agno/reasoning/anthropic.py +80 -0
  200. agno/reasoning/gemini.py +73 -0
  201. agno/reasoning/openai.py +5 -0
  202. agno/reasoning/vertexai.py +76 -0
  203. agno/run/__init__.py +6 -0
  204. agno/run/agent.py +155 -32
  205. agno/run/base.py +55 -3
  206. agno/run/requirement.py +181 -0
  207. agno/run/team.py +125 -38
  208. agno/run/workflow.py +72 -18
  209. agno/session/agent.py +102 -89
  210. agno/session/summary.py +56 -15
  211. agno/session/team.py +164 -90
  212. agno/session/workflow.py +405 -40
  213. agno/table.py +10 -0
  214. agno/team/team.py +3974 -1903
  215. agno/tools/dalle.py +2 -4
  216. agno/tools/eleven_labs.py +23 -25
  217. agno/tools/exa.py +21 -16
  218. agno/tools/file.py +153 -23
  219. agno/tools/file_generation.py +16 -10
  220. agno/tools/firecrawl.py +15 -7
  221. agno/tools/function.py +193 -38
  222. agno/tools/gmail.py +238 -14
  223. agno/tools/google_drive.py +271 -0
  224. agno/tools/googlecalendar.py +36 -8
  225. agno/tools/googlesheets.py +20 -5
  226. agno/tools/jira.py +20 -0
  227. agno/tools/mcp/__init__.py +10 -0
  228. agno/tools/mcp/mcp.py +331 -0
  229. agno/tools/mcp/multi_mcp.py +347 -0
  230. agno/tools/mcp/params.py +24 -0
  231. agno/tools/mcp_toolbox.py +3 -3
  232. agno/tools/models/nebius.py +5 -5
  233. agno/tools/models_labs.py +20 -10
  234. agno/tools/nano_banana.py +151 -0
  235. agno/tools/notion.py +204 -0
  236. agno/tools/parallel.py +314 -0
  237. agno/tools/postgres.py +76 -36
  238. agno/tools/redshift.py +406 -0
  239. agno/tools/scrapegraph.py +1 -1
  240. agno/tools/shopify.py +1519 -0
  241. agno/tools/slack.py +18 -3
  242. agno/tools/spotify.py +919 -0
  243. agno/tools/tavily.py +146 -0
  244. agno/tools/toolkit.py +25 -0
  245. agno/tools/workflow.py +8 -1
  246. agno/tools/yfinance.py +12 -11
  247. agno/tracing/__init__.py +12 -0
  248. agno/tracing/exporter.py +157 -0
  249. agno/tracing/schemas.py +276 -0
  250. agno/tracing/setup.py +111 -0
  251. agno/utils/agent.py +938 -0
  252. agno/utils/cryptography.py +22 -0
  253. agno/utils/dttm.py +33 -0
  254. agno/utils/events.py +151 -3
  255. agno/utils/gemini.py +15 -5
  256. agno/utils/hooks.py +118 -4
  257. agno/utils/http.py +113 -2
  258. agno/utils/knowledge.py +12 -5
  259. agno/utils/log.py +1 -0
  260. agno/utils/mcp.py +92 -2
  261. agno/utils/media.py +187 -1
  262. agno/utils/merge_dict.py +3 -3
  263. agno/utils/message.py +60 -0
  264. agno/utils/models/ai_foundry.py +9 -2
  265. agno/utils/models/claude.py +49 -14
  266. agno/utils/models/cohere.py +9 -2
  267. agno/utils/models/llama.py +9 -2
  268. agno/utils/models/mistral.py +4 -2
  269. agno/utils/print_response/agent.py +109 -16
  270. agno/utils/print_response/team.py +223 -30
  271. agno/utils/print_response/workflow.py +251 -34
  272. agno/utils/streamlit.py +1 -1
  273. agno/utils/team.py +98 -9
  274. agno/utils/tokens.py +657 -0
  275. agno/vectordb/base.py +39 -7
  276. agno/vectordb/cassandra/cassandra.py +21 -5
  277. agno/vectordb/chroma/chromadb.py +43 -12
  278. agno/vectordb/clickhouse/clickhousedb.py +21 -5
  279. agno/vectordb/couchbase/couchbase.py +29 -5
  280. agno/vectordb/lancedb/lance_db.py +92 -181
  281. agno/vectordb/langchaindb/langchaindb.py +24 -4
  282. agno/vectordb/lightrag/lightrag.py +17 -3
  283. agno/vectordb/llamaindex/llamaindexdb.py +25 -5
  284. agno/vectordb/milvus/milvus.py +50 -37
  285. agno/vectordb/mongodb/__init__.py +7 -1
  286. agno/vectordb/mongodb/mongodb.py +36 -30
  287. agno/vectordb/pgvector/pgvector.py +201 -77
  288. agno/vectordb/pineconedb/pineconedb.py +41 -23
  289. agno/vectordb/qdrant/qdrant.py +67 -54
  290. agno/vectordb/redis/__init__.py +9 -0
  291. agno/vectordb/redis/redisdb.py +682 -0
  292. agno/vectordb/singlestore/singlestore.py +50 -29
  293. agno/vectordb/surrealdb/surrealdb.py +31 -41
  294. agno/vectordb/upstashdb/upstashdb.py +34 -6
  295. agno/vectordb/weaviate/weaviate.py +53 -14
  296. agno/workflow/__init__.py +2 -0
  297. agno/workflow/agent.py +299 -0
  298. agno/workflow/condition.py +120 -18
  299. agno/workflow/loop.py +77 -10
  300. agno/workflow/parallel.py +231 -143
  301. agno/workflow/router.py +118 -17
  302. agno/workflow/step.py +609 -170
  303. agno/workflow/steps.py +73 -6
  304. agno/workflow/types.py +96 -21
  305. agno/workflow/workflow.py +2039 -262
  306. {agno-2.1.2.dist-info → agno-2.3.13.dist-info}/METADATA +201 -66
  307. agno-2.3.13.dist-info/RECORD +613 -0
  308. agno/tools/googlesearch.py +0 -98
  309. agno/tools/mcp.py +0 -679
  310. agno/tools/memori.py +0 -339
  311. agno-2.1.2.dist-info/RECORD +0 -543
  312. {agno-2.1.2.dist-info → agno-2.3.13.dist-info}/WHEEL +0 -0
  313. {agno-2.1.2.dist-info → agno-2.3.13.dist-info}/licenses/LICENSE +0 -0
  314. {agno-2.1.2.dist-info → agno-2.3.13.dist-info}/top_level.txt +0 -0
agno/os/middleware/jwt.py CHANGED
@@ -1,14 +1,25 @@
1
+ """JWT Middleware for AgentOS - JWT Authentication with optional RBAC."""
2
+
1
3
  import fnmatch
4
+ import json
5
+ import re
2
6
  from enum import Enum
3
7
  from os import getenv
4
- from typing import List, Optional
8
+ from typing import Any, Dict, List, Optional
5
9
 
6
10
  import jwt
7
11
  from fastapi import Request, Response
8
12
  from fastapi.responses import JSONResponse
13
+ from jwt import PyJWK
9
14
  from starlette.middleware.base import BaseHTTPMiddleware
10
15
 
11
- from agno.utils.log import log_debug
16
+ from agno.os.scopes import (
17
+ AgentOSScope,
18
+ get_accessible_resource_ids,
19
+ get_default_scope_mappings,
20
+ has_required_scopes,
21
+ )
22
+ from agno.utils.log import log_debug, log_warning
12
23
 
13
24
 
14
25
  class TokenSource(str, Enum):
@@ -19,78 +30,536 @@ class TokenSource(str, Enum):
19
30
  BOTH = "both" # Try header first, then cookie
20
31
 
21
32
 
33
+ class JWTValidator:
34
+ """
35
+ JWT token validator that can be used standalone or within JWTMiddleware.
36
+
37
+ This class handles:
38
+ - Loading verification keys (static keys or JWKS files)
39
+ - Validating JWT signatures
40
+ - Extracting claims from tokens
41
+
42
+ It can be stored on app.state for use by WebSocket handlers or other
43
+ components that need JWT validation outside of the HTTP middleware chain.
44
+
45
+ Example:
46
+ # Create validator
47
+ validator = JWTValidator(
48
+ verification_keys=["your-public-key"],
49
+ algorithm="RS256",
50
+ )
51
+
52
+ # Validate a token
53
+ try:
54
+ payload = validator.validate(token)
55
+ user_id = payload.get("sub")
56
+ scopes = payload.get("scopes", [])
57
+ except jwt.InvalidTokenError as e:
58
+ print(f"Invalid token: {e}")
59
+
60
+ # Store on app.state for WebSocket access
61
+ app.state.jwt_validator = validator
62
+ """
63
+
64
+ def __init__(
65
+ self,
66
+ verification_keys: Optional[List[str]] = None,
67
+ jwks_file: Optional[str] = None,
68
+ algorithm: str = "RS256",
69
+ validate: bool = True,
70
+ scopes_claim: str = "scopes",
71
+ user_id_claim: str = "sub",
72
+ session_id_claim: str = "session_id",
73
+ audience_claim: str = "aud",
74
+ leeway: int = 10,
75
+ ):
76
+ """
77
+ Initialize the JWT validator.
78
+
79
+ Args:
80
+ verification_keys: List of keys for verifying JWT signatures.
81
+ For asymmetric algorithms (RS256, ES256), these should be public keys.
82
+ For symmetric algorithms (HS256), these are shared secrets.
83
+ jwks_file: Path to a static JWKS (JSON Web Key Set) file containing public keys.
84
+ algorithm: JWT algorithm (default: RS256).
85
+ validate: Whether to validate the JWT token (default: True).
86
+ scopes_claim: JWT claim name for scopes (default: "scopes").
87
+ user_id_claim: JWT claim name for user ID (default: "sub").
88
+ session_id_claim: JWT claim name for session ID (default: "session_id").
89
+ audience_claim: JWT claim name for audience (default: "aud").
90
+ leeway: Seconds of leeway for clock skew tolerance (default: 10).
91
+ """
92
+ self.algorithm = algorithm
93
+ self.validate = validate
94
+ self.scopes_claim = scopes_claim
95
+ self.user_id_claim = user_id_claim
96
+ self.session_id_claim = session_id_claim
97
+ self.audience_claim = audience_claim
98
+ self.leeway = leeway
99
+
100
+ # Build list of verification keys
101
+ self.verification_keys: List[str] = []
102
+ if verification_keys:
103
+ self.verification_keys.extend(verification_keys)
104
+
105
+ # Add key from environment variable if not already provided
106
+ env_key = getenv("JWT_VERIFICATION_KEY", "")
107
+ if env_key and env_key not in self.verification_keys:
108
+ self.verification_keys.append(env_key)
109
+
110
+ # JWKS configuration - load keys from JWKS file or environment variable
111
+ self.jwks_keys: Dict[str, PyJWK] = {} # kid -> PyJWK mapping
112
+
113
+ # Try jwks_file parameter first
114
+ if jwks_file:
115
+ self._load_jwks_file(jwks_file)
116
+ else:
117
+ # Try JWT_JWKS_FILE env var (path to file)
118
+ jwks_file_env = getenv("JWT_JWKS_FILE", "")
119
+ if jwks_file_env:
120
+ self._load_jwks_file(jwks_file_env)
121
+
122
+ # Validate that at least one key source is provided if validate=True
123
+ if self.validate and not self.verification_keys and not self.jwks_keys:
124
+ raise ValueError(
125
+ "At least one JWT verification key or JWKS file is required when validate=True. "
126
+ "Set via verification_keys parameter, JWT_VERIFICATION_KEY environment variable, "
127
+ "jwks_file parameter or JWT_JWKS_FILE environment variable."
128
+ )
129
+
130
+ def _load_jwks_file(self, file_path: str) -> None:
131
+ """
132
+ Load keys from a static JWKS file.
133
+
134
+ Args:
135
+ file_path: Path to the JWKS JSON file
136
+ """
137
+ try:
138
+ with open(file_path) as f:
139
+ jwks_data = json.load(f)
140
+ self._parse_jwks_data(jwks_data)
141
+ log_debug(f"Loaded {len(self.jwks_keys)} key(s) from JWKS file: {file_path}")
142
+ except FileNotFoundError:
143
+ raise ValueError(f"JWKS file not found: {file_path}")
144
+ except json.JSONDecodeError as e:
145
+ raise ValueError(f"Invalid JSON in JWKS file {file_path}: {e}")
146
+
147
+ def _parse_jwks_data(self, jwks_data: Dict[str, Any]) -> None:
148
+ """
149
+ Parse JWKS data and populate self.jwks_keys.
150
+
151
+ Args:
152
+ jwks_data: Parsed JWKS dictionary with "keys" array
153
+ """
154
+ keys = jwks_data.get("keys", [])
155
+ if not keys:
156
+ log_warning("JWKS contains no keys")
157
+ return
158
+
159
+ for key_data in keys:
160
+ try:
161
+ kid = key_data.get("kid")
162
+ jwk = PyJWK.from_dict(key_data)
163
+ if kid:
164
+ self.jwks_keys[kid] = jwk
165
+ else:
166
+ # If no kid, use a default key (for single-key JWKS)
167
+ self.jwks_keys["_default"] = jwk
168
+ except Exception as e:
169
+ log_warning(f"Failed to parse JWKS key: {e}")
170
+
171
+ def validate_token(self, token: str, expected_audience: Optional[str] = None) -> Dict[str, Any]:
172
+ """
173
+ Validate JWT token and extract claims.
174
+
175
+ Args:
176
+ token: The JWT token to validate
177
+ expected_audience: The expected audience to verify (optional)
178
+
179
+ Returns:
180
+ Dictionary of claims if valid
181
+
182
+ Raises:
183
+ jwt.InvalidAudienceError: If audience claim doesn't match expected
184
+ jwt.ExpiredSignatureError: If token has expired
185
+ jwt.InvalidTokenError: If token is invalid
186
+ """
187
+ decode_options: Dict[str, Any] = {}
188
+ decode_kwargs: Dict[str, Any] = {
189
+ "algorithms": [self.algorithm],
190
+ "leeway": self.leeway,
191
+ }
192
+
193
+ # Configure audience verification
194
+ if expected_audience:
195
+ decode_kwargs["audience"] = expected_audience
196
+ else:
197
+ decode_options["verify_aud"] = False
198
+
199
+ # If validation is disabled, decode without signature verification
200
+ if not self.validate:
201
+ decode_options["verify_signature"] = False
202
+ decode_kwargs["options"] = decode_options
203
+ return jwt.decode(token, **decode_kwargs)
204
+
205
+ if decode_options:
206
+ decode_kwargs["options"] = decode_options
207
+
208
+ last_exception: Optional[Exception] = None
209
+
210
+ # Try JWKS keys first if configured
211
+ if self.jwks_keys:
212
+ try:
213
+ # Get the kid from the token header to find the right key
214
+ unverified_header = jwt.get_unverified_header(token)
215
+ kid = unverified_header.get("kid")
216
+
217
+ jwk = None
218
+ if kid and kid in self.jwks_keys:
219
+ jwk = self.jwks_keys[kid]
220
+ elif "_default" in self.jwks_keys:
221
+ # Fall back to default key if no kid match
222
+ jwk = self.jwks_keys["_default"]
223
+
224
+ if jwk:
225
+ return jwt.decode(token, jwk.key, **decode_kwargs)
226
+ except jwt.InvalidAudienceError:
227
+ raise
228
+ except jwt.ExpiredSignatureError:
229
+ raise
230
+ except jwt.InvalidTokenError as e:
231
+ if not self.verification_keys:
232
+ raise
233
+ last_exception = e
234
+
235
+ # Try each static verification key until one succeeds
236
+ for key in self.verification_keys:
237
+ try:
238
+ return jwt.decode(token, key, **decode_kwargs)
239
+ except jwt.InvalidAudienceError:
240
+ raise
241
+ except jwt.ExpiredSignatureError:
242
+ raise
243
+ except jwt.InvalidTokenError as e:
244
+ last_exception = e
245
+ continue
246
+
247
+ if last_exception:
248
+ raise last_exception
249
+ raise jwt.InvalidTokenError("No verification keys configured")
250
+
251
+ def extract_claims(self, payload: Dict[str, Any]) -> Dict[str, Any]:
252
+ """
253
+ Extract standard claims from a JWT payload.
254
+
255
+ Args:
256
+ payload: The decoded JWT payload
257
+
258
+ Returns:
259
+ Dictionary with user_id, session_id, scopes, and audience
260
+ """
261
+ scopes = payload.get(self.scopes_claim, [])
262
+ if isinstance(scopes, str):
263
+ scopes = [scopes]
264
+ elif not isinstance(scopes, list):
265
+ scopes = []
266
+
267
+ return {
268
+ "user_id": payload.get(self.user_id_claim),
269
+ "session_id": payload.get(self.session_id_claim),
270
+ "scopes": scopes,
271
+ "audience": payload.get(self.audience_claim),
272
+ }
273
+
274
+
22
275
  class JWTMiddleware(BaseHTTPMiddleware):
23
276
  """
24
- JWT Middleware for validating tokens and storing JWT claims in request state.
277
+ JWT Authentication Middleware with optional RBAC (Role-Based Access Control).
25
278
 
26
279
  This middleware:
27
- 1. Extracts JWT token from Authorization header, cookies, or both
280
+ 1. Extracts JWT token from Authorization header or cookies
28
281
  2. Decodes and validates the token
29
- 3. Stores JWT claims in request.state for easy access in endpoints
282
+ 3. Validates the `aud` (audience) claim matches the AgentOS ID (if configured)
283
+ 4. Stores JWT claims (user_id, session_id, scopes) in request.state
284
+ 5. Optionally checks if the request path requires specific scopes (if scope_mappings provided)
285
+ 6. Validates that the authenticated user has the required scopes
286
+ 7. Returns 401 for invalid tokens, 403 for insufficient scopes
287
+
288
+ RBAC is opt-in: Only enabled when authorization=True or scope_mappings are provided.
289
+ Without authorization enabled, the middleware only extracts and validates JWT tokens.
290
+
291
+ Audience Verification:
292
+ - The `aud` claim in JWT tokens should contain the AgentOS ID
293
+ - This is verified against the AgentOS instance ID from app.state.agent_os_id
294
+ - Tokens with mismatched audience will be rejected with 401
295
+
296
+ Scope Format (simplified):
297
+ - Global resource scopes: `resource:action` (e.g., "agents:read")
298
+ - Per-resource scopes: `resource:<resource-id>:action` (e.g., "agents:web-agent:run")
299
+ - Wildcards: `resource:*:action` (e.g., "agents:*:run")
300
+ - Admin scope: `admin` (grants all permissions)
30
301
 
31
302
  Token Sources:
32
303
  - "header": Extract from Authorization header (default)
33
304
  - "cookie": Extract from HTTP cookie
34
305
  - "both": Try header first, then cookie as fallback
35
306
 
36
- Claims are stored as:
37
- - request.state.user_id: User ID from configured claim
38
- - request.state.session_id: Session ID from configured claim
39
- - request.state.dependencies: Dictionary of dependency claims
40
- - request.state.session_state: Dictionary of session state claims
41
- - request.state.authenticated: Boolean authentication status
42
-
307
+ Example:
308
+ from agno.os.middleware import JWTMiddleware
309
+ from agno.os.scopes import AgentOSScope
310
+
311
+ # Single verification key
312
+ app.add_middleware(
313
+ JWTMiddleware,
314
+ verification_keys=["your-public-key"],
315
+ authorization=True,
316
+ verify_audience=True, # Verify aud claim matches AgentOS ID
317
+ scope_mappings={
318
+ # Override default scope for this endpoint
319
+ "GET /agents": ["agents:read"],
320
+ # Add new endpoint mapping
321
+ "POST /custom/endpoint": ["agents:run"],
322
+ # Allow access without scopes
323
+ "GET /public/stats": [],
324
+ }
325
+ )
326
+
327
+ # Multiple verification keys (accept tokens from multiple issuers)
328
+ app.add_middleware(
329
+ JWTMiddleware,
330
+ verification_keys=[
331
+ "public-key-from-issuer-1",
332
+ "public-key-from-issuer-2",
333
+ ],
334
+ authorization=True,
335
+ )
336
+
337
+ # Using a static JWKS file
338
+ app.add_middleware(
339
+ JWTMiddleware,
340
+ jwks_file="/path/to/jwks.json",
341
+ authorization=True,
342
+ )
343
+
344
+ # No validation (extract claims only, useful for development)
345
+ app.add_middleware(
346
+ JWTMiddleware,
347
+ validate=False, # No verification key needed
348
+ )
43
349
  """
44
350
 
45
351
  def __init__(
46
352
  self,
47
353
  app,
48
- secret_key: Optional[str] = None,
49
- algorithm: str = "HS256",
354
+ verification_keys: Optional[List[str]] = None,
355
+ jwks_file: Optional[str] = None,
356
+ secret_key: Optional[str] = None, # Deprecated: Use verification_keys instead
357
+ algorithm: str = "RS256",
358
+ validate: bool = True,
359
+ authorization: Optional[bool] = None,
50
360
  token_source: TokenSource = TokenSource.HEADER,
51
361
  token_header_key: str = "Authorization",
52
362
  cookie_name: str = "access_token",
53
- validate: bool = True,
54
- excluded_route_paths: Optional[List[str]] = None,
55
- scopes_claim: Optional[str] = None,
363
+ scopes_claim: str = "scopes",
56
364
  user_id_claim: str = "sub",
57
365
  session_id_claim: str = "session_id",
366
+ audience_claim: str = "aud",
367
+ verify_audience: bool = False,
58
368
  dependencies_claims: Optional[List[str]] = None,
59
369
  session_state_claims: Optional[List[str]] = None,
370
+ scope_mappings: Optional[Dict[str, List[str]]] = None,
371
+ excluded_route_paths: Optional[List[str]] = None,
372
+ admin_scope: Optional[str] = None,
60
373
  ):
61
374
  """
62
375
  Initialize the JWT middleware.
63
376
 
64
377
  Args:
65
378
  app: The FastAPI app instance
66
- secret_key: The secret key to use for JWT validation (optional, will use JWT_SECRET_KEY environment variable if not provided)
67
- algorithm: The algorithm to use for JWT validation
68
- token_header_key: The key to use for the Authorization header (only used when token_source is header)
69
- token_source: Where to extract the JWT token from (header, cookie, or both)
70
- cookie_name: The name of the cookie containing the JWT token (only used when token_source is cookie/both)
71
- validate: Whether to validate the JWT token
72
- excluded_route_paths: A list of route paths to exclude from JWT validation
73
- scopes_claim: The claim to use for scopes extraction
74
- user_id_claim: The claim to use for user ID extraction
75
- session_id_claim: The claim to use for session ID extraction
379
+ verification_keys: List of keys for verifying JWT signatures.
380
+ For asymmetric algorithms (RS256, ES256), these should be public keys.
381
+ For symmetric algorithms (HS256), these are shared secrets.
382
+ Each key will be tried in order until one successfully validates the token.
383
+ Useful when accepting tokens signed by different private keys.
384
+ If not provided, will use JWT_VERIFICATION_KEY env var (as a single-item list).
385
+ jwks_file: Path to a static JWKS (JSON Web Key Set) file containing public keys.
386
+ The file should contain a JSON object with a "keys" array.
387
+ Keys are looked up by the "kid" (key ID) claim in the JWT header.
388
+ If not provided, will check JWT_JWKS_FILE env var for a file path,
389
+ or JWT_JWKS env var for inline JWKS JSON content.
390
+ secret_key: (deprecated) Use verification_keys instead. If provided, will be added to verification_keys.
391
+ algorithm: JWT algorithm (default: RS256). Common options: RS256 (asymmetric), HS256 (symmetric).
392
+ validate: Whether to validate the JWT token (default: True). If False, tokens are decoded
393
+ without signature verification and no verification key is required.
394
+ authorization: Whether to add authorization checks to the request (i.e. validation of scopes)
395
+ token_source: Where to extract JWT token from (header, cookie, or both)
396
+ token_header_key: Header key for Authorization (default: "Authorization")
397
+ cookie_name: Cookie name for JWT token (default: "access_token")
398
+ scopes_claim: JWT claim name for scopes (default: "scopes")
399
+ user_id_claim: JWT claim name for user ID (default: "sub")
400
+ session_id_claim: JWT claim name for session ID (default: "session_id")
401
+ audience_claim: JWT claim name for audience/OS ID (default: "aud")
402
+ verify_audience: Whether to verify the audience claim matches AgentOS ID (default: False)
76
403
  dependencies_claims: A list of claims to extract from the JWT token for dependencies
77
404
  session_state_claims: A list of claims to extract from the JWT token for session state
405
+ scope_mappings: Optional dictionary mapping route patterns to required scopes.
406
+ If None, RBAC is disabled and only JWT extraction/validation happens.
407
+ If provided, mappings are ADDITIVE to default scope mappings (overrides on conflict).
408
+ Use empty list [] to explicitly allow access without scopes for a route.
409
+ Format: {"POST /agents/*/runs": ["agents:run"], "GET /public": []}
410
+ excluded_route_paths: List of route paths to exclude from JWT/RBAC checks
411
+ admin_scope: The scope that grants admin access (default: "agent_os:admin")
412
+
413
+ Note:
414
+ - At least one verification key or JWKS file must be provided if validate=True
415
+ - If validate=False, no verification key is needed (claims are extracted without verification)
416
+ - JWKS keys are tried first (by kid), then static verification_keys as fallback
417
+ - CORS allowed origins are read from app.state.cors_allowed_origins (set by AgentOS).
418
+ This allows error responses to include proper CORS headers.
78
419
  """
79
420
  super().__init__(app)
80
- self.secret_key = secret_key or getenv("JWT_SECRET_KEY")
81
- if not self.secret_key:
82
- raise ValueError("Secret key is required")
421
+
422
+ # Handle deprecated secret_key parameter
423
+ all_verification_keys = list(verification_keys) if verification_keys else []
424
+ if secret_key:
425
+ log_warning("secret_key is deprecated. Use verification_keys instead.")
426
+ if secret_key not in all_verification_keys:
427
+ all_verification_keys.append(secret_key)
428
+
429
+ # Create the JWT validator (handles key loading and token validation)
430
+ self.validator = JWTValidator(
431
+ verification_keys=all_verification_keys if all_verification_keys else None,
432
+ jwks_file=jwks_file,
433
+ algorithm=algorithm,
434
+ validate=validate,
435
+ scopes_claim=scopes_claim,
436
+ user_id_claim=user_id_claim,
437
+ session_id_claim=session_id_claim,
438
+ audience_claim=audience_claim,
439
+ )
440
+
441
+ # Store config for easy access
442
+ self.validate = validate
83
443
  self.algorithm = algorithm
84
- self.token_header_key = token_header_key
85
444
  self.token_source = token_source
445
+ self.token_header_key = token_header_key
86
446
  self.cookie_name = cookie_name
87
- self.validate = validate
88
- self.excluded_route_paths = excluded_route_paths
89
447
  self.scopes_claim = scopes_claim
90
448
  self.user_id_claim = user_id_claim
91
449
  self.session_id_claim = session_id_claim
92
- self.dependencies_claims = dependencies_claims or []
93
- self.session_state_claims = session_state_claims or []
450
+ self.audience_claim = audience_claim
451
+ self.verify_audience = verify_audience
452
+ self.dependencies_claims: List[str] = dependencies_claims or []
453
+ self.session_state_claims: List[str] = session_state_claims or []
454
+
455
+ # RBAC configuration (opt-in via scope_mappings)
456
+ self.authorization = authorization
457
+
458
+ # If scope_mappings are provided, enable authorization
459
+ if scope_mappings is not None and self.authorization is None:
460
+ self.authorization = True
461
+
462
+ # Build final scope mappings (additive approach)
463
+ if self.authorization:
464
+ # Start with default scope mappings
465
+ self.scope_mappings = get_default_scope_mappings()
466
+
467
+ # Merge user-provided scope mappings (overrides defaults)
468
+ if scope_mappings is not None:
469
+ self.scope_mappings.update(scope_mappings)
470
+ else:
471
+ self.scope_mappings = scope_mappings or {}
472
+
473
+ self.excluded_route_paths = (
474
+ excluded_route_paths if excluded_route_paths is not None else self._get_default_excluded_routes()
475
+ )
476
+ self.admin_scope = admin_scope or AgentOSScope.ADMIN.value
477
+
478
+ def _get_default_excluded_routes(self) -> List[str]:
479
+ """Get default routes that should be excluded from RBAC checks."""
480
+ return [
481
+ "/",
482
+ "/health",
483
+ "/docs",
484
+ "/redoc",
485
+ "/openapi.json",
486
+ "/docs/oauth2-redirect",
487
+ ]
488
+
489
+ def _extract_resource_id_from_path(self, path: str, resource_type: str) -> Optional[str]:
490
+ """
491
+ Extract resource ID from a path.
492
+
493
+ Args:
494
+ path: The request path
495
+ resource_type: Type of resource ("agents", "teams", "workflows")
496
+
497
+ Returns:
498
+ The resource ID if found, None otherwise
499
+
500
+ Examples:
501
+ >>> _extract_resource_id_from_path("/agents/my-agent/runs", "agents")
502
+ "my-agent"
503
+ """
504
+ # Pattern: /{resource_type}/{resource_id}/...
505
+ pattern = f"^/{resource_type}/([^/]+)"
506
+ match = re.search(pattern, path)
507
+ if match:
508
+ return match.group(1)
509
+ return None
510
+
511
+ def _is_route_excluded(self, path: str) -> bool:
512
+ """Check if a route path matches any of the excluded patterns."""
513
+ if not self.excluded_route_paths:
514
+ return False
515
+
516
+ for excluded_path in self.excluded_route_paths:
517
+ # Support both exact matches and wildcard patterns
518
+ if fnmatch.fnmatch(path, excluded_path):
519
+ return True
520
+ # Also check without trailing slash
521
+ if fnmatch.fnmatch(path.rstrip("/"), excluded_path):
522
+ return True
523
+
524
+ return False
525
+
526
+ def _get_required_scopes(self, method: str, path: str) -> List[str]:
527
+ """
528
+ Get required scopes for a given method and path.
529
+
530
+ Args:
531
+ method: HTTP method (GET, POST, etc.)
532
+ path: Request path
533
+
534
+ Returns:
535
+ List of required scopes. Empty list [] means no scopes required (allow access).
536
+ Routes not in scope_mappings also return [], allowing access.
537
+ """
538
+ route_key = f"{method} {path}"
539
+
540
+ # First, try exact match
541
+ if route_key in self.scope_mappings:
542
+ return self.scope_mappings[route_key]
543
+
544
+ # Then try pattern matching
545
+ for pattern, scopes in self.scope_mappings.items():
546
+ pattern_method, pattern_path = pattern.split(" ", 1)
547
+
548
+ # Check if method matches
549
+ if pattern_method != method:
550
+ continue
551
+
552
+ # Convert pattern to fnmatch pattern (replace {param} with *)
553
+ # This handles both /agents/* and /agents/{agent_id} style patterns
554
+ normalized_pattern = pattern_path
555
+ if "{" in normalized_pattern:
556
+ # Replace {param} with * for pattern matching
557
+ normalized_pattern = re.sub(r"\{[^}]+\}", "*", normalized_pattern)
558
+
559
+ if fnmatch.fnmatch(path, normalized_pattern):
560
+ return scopes
561
+
562
+ return []
94
563
 
95
564
  def _extract_token_from_header(self, request: Request) -> Optional[str]:
96
565
  """Extract JWT token from Authorization header."""
@@ -98,32 +567,17 @@ class JWTMiddleware(BaseHTTPMiddleware):
98
567
  if not authorization:
99
568
  return None
100
569
 
101
- try:
102
- # Remove the "Bearer " prefix (if present)
103
- _, token = authorization.split(" ", 1)
104
- return token
105
- except ValueError:
106
- return None
570
+ # Support both "Bearer <token>" and just "<token>"
571
+ if authorization.lower().startswith("bearer "):
572
+ return authorization[7:].strip()
573
+ return authorization.strip()
107
574
 
108
575
  def _extract_token_from_cookie(self, request: Request) -> Optional[str]:
109
576
  """Extract JWT token from cookie."""
110
- return request.cookies.get(self.cookie_name)
111
-
112
- def _extract_token(self, request: Request) -> Optional[str]:
113
- """Extract JWT token based on configured token source."""
114
- if self.token_source == TokenSource.HEADER:
115
- return self._extract_token_from_header(request)
116
- elif self.token_source == TokenSource.COOKIE:
117
- return self._extract_token_from_cookie(request)
118
- elif self.token_source == TokenSource.BOTH:
119
- # Try header first, then cookie
120
- token = self._extract_token_from_header(request)
121
- if token is None:
122
- token = self._extract_token_from_cookie(request)
123
- return token
124
- else:
125
- log_debug(f"Unknown token source: {self.token_source}")
126
- return None
577
+ cookie_value = request.cookies.get(self.cookie_name)
578
+ if cookie_value:
579
+ return cookie_value.strip()
580
+ return None
127
581
 
128
582
  def _get_missing_token_error_message(self) -> str:
129
583
  """Get appropriate error message for missing token based on token source."""
@@ -136,98 +590,208 @@ class JWTMiddleware(BaseHTTPMiddleware):
136
590
  else:
137
591
  return "JWT token missing"
138
592
 
139
- def _is_route_excluded(self, path: str) -> bool:
140
- """Check if a route path matches any of the excluded patterns."""
141
- if not self.excluded_route_paths:
142
- return False
593
+ def _create_error_response(
594
+ self,
595
+ status_code: int,
596
+ detail: str,
597
+ origin: Optional[str] = None,
598
+ cors_allowed_origins: Optional[List[str]] = None,
599
+ ) -> JSONResponse:
600
+ """Create an error response with CORS headers."""
601
+ response = JSONResponse(status_code=status_code, content={"detail": detail})
602
+
603
+ # Add CORS headers to the error response
604
+ if origin and self._is_origin_allowed(origin, cors_allowed_origins):
605
+ response.headers["Access-Control-Allow-Origin"] = origin
606
+ response.headers["Access-Control-Allow-Credentials"] = "true"
607
+ response.headers["Access-Control-Allow-Methods"] = "*"
608
+ response.headers["Access-Control-Allow-Headers"] = "*"
609
+ response.headers["Access-Control-Expose-Headers"] = "*"
610
+
611
+ return response
612
+
613
+ def _is_origin_allowed(self, origin: str, cors_allowed_origins: Optional[List[str]] = None) -> bool:
614
+ """Check if the origin is in the allowed origins list."""
615
+ if not cors_allowed_origins:
616
+ # If no allowed origins configured, allow all (fallback to default behavior)
617
+ return True
618
+
619
+ # Check if origin is in the allowed list
620
+ return origin in cors_allowed_origins
143
621
 
144
- for excluded_path in self.excluded_route_paths:
145
- # Support both exact matches and wildcard patterns
146
- if fnmatch.fnmatch(path, excluded_path):
147
- return True
622
+ async def dispatch(self, request: Request, call_next) -> Response:
623
+ """Process the request: extract JWT, validate, and check RBAC scopes."""
624
+ path = request.url.path
625
+ method = request.method
148
626
 
149
- return False
627
+ # Skip OPTIONS requests (CORS preflight)
628
+ if method == "OPTIONS":
629
+ return await call_next(request)
150
630
 
151
- async def dispatch(self, request: Request, call_next) -> Response:
152
- if self._is_route_excluded(request.url.path):
631
+ # Skip excluded routes
632
+ if self._is_route_excluded(path):
153
633
  return await call_next(request)
154
634
 
155
- # Extract JWT token from configured source (header, cookie, or both)
156
- token = self._extract_token(request)
635
+ # Get origin and CORS allowed origins for error responses
636
+ origin = request.headers.get("origin")
637
+ cors_allowed_origins = getattr(request.app.state, "cors_allowed_origins", None)
638
+
639
+ # Get agent_os_id from app state for audience verification
640
+ agent_os_id = getattr(request.app.state, "agent_os_id", None)
157
641
 
642
+ # Extract JWT token
643
+ token = self._extract_token(request)
158
644
  if not token:
159
645
  if self.validate:
160
646
  error_msg = self._get_missing_token_error_message()
161
- return JSONResponse(status_code=401, content={"detail": error_msg})
162
- return await call_next(request)
647
+ return self._create_error_response(401, error_msg, origin, cors_allowed_origins)
163
648
 
164
- # Decode JWT token
165
649
  try:
166
- payload = jwt.decode(token, self.secret_key, algorithms=[self.algorithm]) # type: ignore
650
+ # Validate token and extract claims (with audience verification if configured)
651
+ expected_audience = agent_os_id if self.verify_audience else None
652
+ payload: Dict[str, Any] = self.validator.validate_token(token, expected_audience) # type: ignore
653
+
654
+ # Extract standard claims and store in request.state
655
+ user_id = payload.get(self.user_id_claim)
656
+ session_id = payload.get(self.session_id_claim)
657
+ scopes = payload.get(self.scopes_claim, [])
658
+ audience = payload.get(self.audience_claim)
659
+
660
+ # Ensure scopes is a list
661
+ if isinstance(scopes, str):
662
+ scopes = [scopes]
663
+ elif not isinstance(scopes, list):
664
+ scopes = []
665
+
666
+ # Store claims in request.state
667
+ request.state.authenticated = True
668
+ request.state.user_id = user_id
669
+ request.state.session_id = session_id
670
+ request.state.scopes = scopes
671
+ request.state.audience = audience
672
+ request.state.authorization_enabled = self.authorization or False
167
673
 
168
- # Extract scopes claims
169
- scopes = []
170
- if self.scopes_claim in payload:
171
- extracted_scopes = payload[self.scopes_claim]
172
- if isinstance(extracted_scopes, str):
173
- scopes = extracted_scopes.split(" ")
174
- else:
175
- scopes = extracted_scopes
176
- if scopes:
177
- request.state.scopes = scopes
178
-
179
- # Extract user information
180
- if self.user_id_claim in payload:
181
- user_id = payload[self.user_id_claim]
182
- request.state.user_id = user_id
183
- if self.session_id_claim in payload:
184
- session_id = payload[self.session_id_claim]
185
- request.state.session_id = session_id
186
- else:
187
- session_id = None
188
-
189
- # Extract dependency claims
674
+ # Extract dependencies claims
190
675
  dependencies = {}
191
- for claim in self.dependencies_claims:
192
- if claim in payload:
193
- dependencies[claim] = payload[claim]
676
+ if self.dependencies_claims:
677
+ for claim in self.dependencies_claims:
678
+ if claim in payload:
679
+ dependencies[claim] = payload[claim]
194
680
 
195
681
  if dependencies:
682
+ log_debug(f"Extracted dependencies: {dependencies}")
196
683
  request.state.dependencies = dependencies
197
684
 
198
685
  # Extract session state claims
199
686
  session_state = {}
200
- for claim in self.session_state_claims:
201
- if claim in payload:
202
- session_state[claim] = payload[claim]
687
+ if self.session_state_claims:
688
+ for claim in self.session_state_claims:
689
+ if claim in payload:
690
+ session_state[claim] = payload[claim]
203
691
 
204
692
  if session_state:
693
+ log_debug(f"Extracted session state: {session_state}")
205
694
  request.state.session_state = session_state
206
695
 
696
+ # RBAC scope checking (only if enabled)
697
+ if self.authorization:
698
+ # Extract resource type and ID from path
699
+ resource_type = None
700
+ resource_id = None
701
+
702
+ if "/agents" in path:
703
+ resource_type = "agents"
704
+ elif "/teams" in path:
705
+ resource_type = "teams"
706
+ elif "/workflows" in path:
707
+ resource_type = "workflows"
708
+
709
+ if resource_type:
710
+ resource_id = self._extract_resource_id_from_path(path, resource_type)
711
+
712
+ required_scopes = self._get_required_scopes(method, path)
713
+
714
+ # Empty list [] means no scopes required (allow access)
715
+ if required_scopes:
716
+ # Use the scope validation system
717
+ has_access = has_required_scopes(
718
+ scopes,
719
+ required_scopes,
720
+ resource_type=resource_type,
721
+ resource_id=resource_id,
722
+ admin_scope=self.admin_scope,
723
+ )
724
+
725
+ # Special handling for listing endpoints (no resource_id)
726
+ if not has_access and not resource_id and resource_type:
727
+ # For listing endpoints, always allow access but store accessible IDs for filtering
728
+ # This allows endpoints to return filtered results (including empty list) instead of 403
729
+ accessible_ids = get_accessible_resource_ids(
730
+ scopes, resource_type, admin_scope=self.admin_scope
731
+ )
732
+ has_access = True # Always allow listing endpoints
733
+ request.state.accessible_resource_ids = accessible_ids
734
+
735
+ if accessible_ids:
736
+ log_debug(f"User has specific {resource_type} scopes. Accessible IDs: {accessible_ids}")
737
+ else:
738
+ log_debug(f"User has no {resource_type} scopes. Will return empty list.")
739
+
740
+ if not has_access:
741
+ log_warning(
742
+ f"Insufficient scopes for {method} {path}. Required: {required_scopes}, User has: {scopes}"
743
+ )
744
+ return self._create_error_response(
745
+ 403, "Insufficient permissions", origin, cors_allowed_origins
746
+ )
747
+
748
+ log_debug(f"Scope check passed for {method} {path}. User scopes: {scopes}")
749
+ else:
750
+ log_debug(f"No scopes required for {method} {path}")
751
+
752
+ log_debug(f"JWT decoded successfully for user: {user_id}")
753
+
207
754
  request.state.token = token
208
755
  request.state.authenticated = True
209
756
 
210
- log_debug(f"JWT decoded successfully for user: {user_id}")
211
- if dependencies:
212
- log_debug(f"Extracted dependencies: {dependencies}")
213
- if session_state:
214
- log_debug(f"Extracted session state: {session_state}")
757
+ except jwt.InvalidAudienceError:
758
+ log_warning(f"Invalid audience - expected: {agent_os_id}")
759
+ return self._create_error_response(
760
+ 401, "Invalid audience - token not valid for this AgentOS instance", origin, cors_allowed_origins
761
+ )
215
762
 
216
- except jwt.ExpiredSignatureError:
763
+ except jwt.ExpiredSignatureError as e:
217
764
  if self.validate:
218
- return JSONResponse(status_code=401, content={"detail": "Token has expired"})
765
+ log_warning(f"Token has expired: {str(e)}")
766
+ return self._create_error_response(401, "Token has expired", origin, cors_allowed_origins)
219
767
  request.state.authenticated = False
220
768
  request.state.token = token
221
769
 
222
770
  except jwt.InvalidTokenError as e:
223
771
  if self.validate:
224
- return JSONResponse(status_code=401, content={"detail": f"Invalid token: {str(e)}"})
772
+ log_warning(f"Invalid token: {str(e)}")
773
+ return self._create_error_response(401, f"Invalid token: {str(e)}", origin, cors_allowed_origins)
225
774
  request.state.authenticated = False
226
775
  request.state.token = token
227
776
  except Exception as e:
228
777
  if self.validate:
229
- return JSONResponse(status_code=401, content={"detail": f"Error decoding token: {str(e)}"})
778
+ log_warning(f"Error decoding token: {str(e)}")
779
+ return self._create_error_response(401, f"Error decoding token: {str(e)}", origin, cors_allowed_origins)
230
780
  request.state.authenticated = False
231
781
  request.state.token = token
232
782
 
233
783
  return await call_next(request)
784
+
785
+ def _extract_token(self, request: Request) -> Optional[str]:
786
+ """Extract JWT token based on configured source."""
787
+ if self.token_source == TokenSource.HEADER:
788
+ return self._extract_token_from_header(request)
789
+ elif self.token_source == TokenSource.COOKIE:
790
+ return self._extract_token_from_cookie(request)
791
+ elif self.token_source == TokenSource.BOTH:
792
+ # Try header first, then cookie
793
+ token = self._extract_token_from_header(request)
794
+ if token:
795
+ return token
796
+ return self._extract_token_from_cookie(request)
797
+ return None