a2a-ruby 1.0.0
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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +137 -0
- data/.simplecov +46 -0
- data/.yardopts +10 -0
- data/CHANGELOG.md +33 -0
- data/CODE_OF_CONDUCT.md +128 -0
- data/CONTRIBUTING.md +165 -0
- data/Gemfile +43 -0
- data/Guardfile +34 -0
- data/LICENSE.txt +21 -0
- data/PUBLISHING_CHECKLIST.md +214 -0
- data/README.md +171 -0
- data/Rakefile +165 -0
- data/docs/agent_execution.md +309 -0
- data/docs/api_reference.md +792 -0
- data/docs/configuration.md +780 -0
- data/docs/events.md +475 -0
- data/docs/getting_started.md +668 -0
- data/docs/integration.md +262 -0
- data/docs/server_apps.md +621 -0
- data/docs/troubleshooting.md +765 -0
- data/lib/a2a/client/api_methods.rb +263 -0
- data/lib/a2a/client/auth/api_key.rb +161 -0
- data/lib/a2a/client/auth/interceptor.rb +288 -0
- data/lib/a2a/client/auth/jwt.rb +189 -0
- data/lib/a2a/client/auth/oauth2.rb +146 -0
- data/lib/a2a/client/auth.rb +137 -0
- data/lib/a2a/client/base.rb +316 -0
- data/lib/a2a/client/config.rb +210 -0
- data/lib/a2a/client/connection_pool.rb +233 -0
- data/lib/a2a/client/http_client.rb +524 -0
- data/lib/a2a/client/json_rpc_handler.rb +136 -0
- data/lib/a2a/client/middleware/circuit_breaker_interceptor.rb +245 -0
- data/lib/a2a/client/middleware/logging_interceptor.rb +371 -0
- data/lib/a2a/client/middleware/rate_limit_interceptor.rb +142 -0
- data/lib/a2a/client/middleware/retry_interceptor.rb +161 -0
- data/lib/a2a/client/middleware.rb +116 -0
- data/lib/a2a/client/performance_tracker.rb +60 -0
- data/lib/a2a/configuration/defaults.rb +34 -0
- data/lib/a2a/configuration/environment_loader.rb +76 -0
- data/lib/a2a/configuration/file_loader.rb +115 -0
- data/lib/a2a/configuration/inheritance.rb +101 -0
- data/lib/a2a/configuration/validator.rb +180 -0
- data/lib/a2a/configuration.rb +201 -0
- data/lib/a2a/errors.rb +291 -0
- data/lib/a2a/modules.rb +50 -0
- data/lib/a2a/monitoring/alerting.rb +490 -0
- data/lib/a2a/monitoring/distributed_tracing.rb +398 -0
- data/lib/a2a/monitoring/health_endpoints.rb +204 -0
- data/lib/a2a/monitoring/metrics_collector.rb +438 -0
- data/lib/a2a/monitoring.rb +463 -0
- data/lib/a2a/plugin.rb +358 -0
- data/lib/a2a/plugin_manager.rb +159 -0
- data/lib/a2a/plugins/example_auth.rb +81 -0
- data/lib/a2a/plugins/example_middleware.rb +118 -0
- data/lib/a2a/plugins/example_transport.rb +76 -0
- data/lib/a2a/protocol/agent_card.rb +8 -0
- data/lib/a2a/protocol/agent_card_server.rb +584 -0
- data/lib/a2a/protocol/capability.rb +496 -0
- data/lib/a2a/protocol/json_rpc.rb +254 -0
- data/lib/a2a/protocol/message.rb +8 -0
- data/lib/a2a/protocol/task.rb +8 -0
- data/lib/a2a/rails/a2a_controller.rb +258 -0
- data/lib/a2a/rails/controller_helpers.rb +499 -0
- data/lib/a2a/rails/engine.rb +167 -0
- data/lib/a2a/rails/generators/agent_generator.rb +311 -0
- data/lib/a2a/rails/generators/install_generator.rb +209 -0
- data/lib/a2a/rails/generators/migration_generator.rb +232 -0
- data/lib/a2a/rails/generators/templates/add_a2a_indexes.rb +57 -0
- data/lib/a2a/rails/generators/templates/agent_controller.rb +122 -0
- data/lib/a2a/rails/generators/templates/agent_controller_spec.rb +160 -0
- data/lib/a2a/rails/generators/templates/agent_readme.md +200 -0
- data/lib/a2a/rails/generators/templates/create_a2a_push_notification_configs.rb +68 -0
- data/lib/a2a/rails/generators/templates/create_a2a_tasks.rb +83 -0
- data/lib/a2a/rails/generators/templates/example_agent_controller.rb +228 -0
- data/lib/a2a/rails/generators/templates/initializer.rb +108 -0
- data/lib/a2a/rails/generators/templates/push_notification_config_model.rb +228 -0
- data/lib/a2a/rails/generators/templates/task_model.rb +200 -0
- data/lib/a2a/rails/tasks/a2a.rake +228 -0
- data/lib/a2a/server/a2a_methods.rb +520 -0
- data/lib/a2a/server/agent.rb +537 -0
- data/lib/a2a/server/agent_execution/agent_executor.rb +279 -0
- data/lib/a2a/server/agent_execution/request_context.rb +219 -0
- data/lib/a2a/server/apps/rack_app.rb +311 -0
- data/lib/a2a/server/apps/sinatra_app.rb +261 -0
- data/lib/a2a/server/default_request_handler.rb +350 -0
- data/lib/a2a/server/events/event_consumer.rb +116 -0
- data/lib/a2a/server/events/event_queue.rb +226 -0
- data/lib/a2a/server/example_agent.rb +248 -0
- data/lib/a2a/server/handler.rb +281 -0
- data/lib/a2a/server/middleware/authentication_middleware.rb +212 -0
- data/lib/a2a/server/middleware/cors_middleware.rb +171 -0
- data/lib/a2a/server/middleware/logging_middleware.rb +362 -0
- data/lib/a2a/server/middleware/rate_limit_middleware.rb +382 -0
- data/lib/a2a/server/middleware.rb +213 -0
- data/lib/a2a/server/push_notification_manager.rb +327 -0
- data/lib/a2a/server/request_handler.rb +136 -0
- data/lib/a2a/server/storage/base.rb +141 -0
- data/lib/a2a/server/storage/database.rb +266 -0
- data/lib/a2a/server/storage/memory.rb +274 -0
- data/lib/a2a/server/storage/redis.rb +320 -0
- data/lib/a2a/server/storage.rb +38 -0
- data/lib/a2a/server/task_manager.rb +534 -0
- data/lib/a2a/transport/grpc.rb +481 -0
- data/lib/a2a/transport/http.rb +415 -0
- data/lib/a2a/transport/sse.rb +499 -0
- data/lib/a2a/types/agent_card.rb +540 -0
- data/lib/a2a/types/artifact.rb +99 -0
- data/lib/a2a/types/base_model.rb +223 -0
- data/lib/a2a/types/events.rb +117 -0
- data/lib/a2a/types/message.rb +106 -0
- data/lib/a2a/types/part.rb +288 -0
- data/lib/a2a/types/push_notification.rb +139 -0
- data/lib/a2a/types/security.rb +167 -0
- data/lib/a2a/types/task.rb +154 -0
- data/lib/a2a/types.rb +88 -0
- data/lib/a2a/utils/helpers.rb +245 -0
- data/lib/a2a/utils/message_buffer.rb +278 -0
- data/lib/a2a/utils/performance.rb +247 -0
- data/lib/a2a/utils/rails_detection.rb +97 -0
- data/lib/a2a/utils/structured_logger.rb +306 -0
- data/lib/a2a/utils/time_helpers.rb +167 -0
- data/lib/a2a/utils/validation.rb +8 -0
- data/lib/a2a/version.rb +6 -0
- data/lib/a2a-rails.rb +58 -0
- data/lib/a2a.rb +198 -0
- metadata +437 -0
@@ -0,0 +1,288 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "oauth2"
|
4
|
+
require_relative "jwt"
|
5
|
+
require_relative "api_key"
|
6
|
+
|
7
|
+
##
|
8
|
+
# Authentication interceptor for automatic token handling
|
9
|
+
#
|
10
|
+
# Provides a unified interface for applying different authentication
|
11
|
+
# strategies to HTTP requests, with automatic token refresh and
|
12
|
+
# error handling.
|
13
|
+
#
|
14
|
+
module A2A
|
15
|
+
module Client
|
16
|
+
module Auth
|
17
|
+
class Interceptor
|
18
|
+
attr_reader :strategy, :auto_retry
|
19
|
+
|
20
|
+
##
|
21
|
+
# Initialize authentication interceptor
|
22
|
+
#
|
23
|
+
# @param strategy [Object] Authentication strategy (OAuth2, JWT, ApiKey, etc.)
|
24
|
+
# @param auto_retry [Boolean] Whether to automatically retry on auth failures
|
25
|
+
def initialize(strategy, auto_retry: true)
|
26
|
+
@strategy = strategy
|
27
|
+
@auto_retry = auto_retry
|
28
|
+
@retry_mutex = Mutex.new
|
29
|
+
end
|
30
|
+
|
31
|
+
##
|
32
|
+
# Middleware call method for Faraday
|
33
|
+
#
|
34
|
+
# @param request [Object] The request object
|
35
|
+
# @param context [Hash] Request context
|
36
|
+
# @param next_middleware [Proc] Next middleware in chain
|
37
|
+
# @return [Object] Response from next middleware
|
38
|
+
def call(request, context, next_middleware)
|
39
|
+
# Apply authentication to the request
|
40
|
+
apply_authentication(request)
|
41
|
+
|
42
|
+
# Execute the request
|
43
|
+
begin
|
44
|
+
response = next_middleware.call(request, context)
|
45
|
+
|
46
|
+
# Check for authentication errors and retry if configured
|
47
|
+
if authentication_error?(response) && @auto_retry
|
48
|
+
handle_auth_error_retry(request, context, next_middleware)
|
49
|
+
else
|
50
|
+
response
|
51
|
+
end
|
52
|
+
rescue A2A::Errors::AuthenticationError, A2A::Errors::AuthorizationFailed => e
|
53
|
+
raise e unless @auto_retry
|
54
|
+
|
55
|
+
handle_auth_error_retry(request, context, next_middleware)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
##
|
60
|
+
# Apply authentication to a request
|
61
|
+
#
|
62
|
+
# @param request [Object] The request to authenticate
|
63
|
+
def apply_authentication(request)
|
64
|
+
case @strategy
|
65
|
+
when OAuth2, JWT, ApiKey
|
66
|
+
@strategy.apply_to_request(request)
|
67
|
+
when Hash
|
68
|
+
# Handle configuration-based authentication
|
69
|
+
apply_config_authentication(request, @strategy)
|
70
|
+
else
|
71
|
+
raise ArgumentError, "Unsupported authentication strategy: #{@strategy.class}"
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
##
|
76
|
+
# Create interceptor from configuration
|
77
|
+
#
|
78
|
+
# @param config [Hash] Authentication configuration
|
79
|
+
# @return [Interceptor] Configured interceptor
|
80
|
+
def self.from_config(config)
|
81
|
+
strategy = case config["type"] || config[:type]
|
82
|
+
when "oauth2"
|
83
|
+
OAuth2.new(
|
84
|
+
client_id: config["client_id"] || config[:client_id],
|
85
|
+
client_secret: config["client_secret"] || config[:client_secret],
|
86
|
+
token_url: config["token_url"] || config[:token_url],
|
87
|
+
scope: config["scope"] || config[:scope]
|
88
|
+
)
|
89
|
+
when "jwt"
|
90
|
+
JWT.new(
|
91
|
+
token: config["token"] || config[:token],
|
92
|
+
secret: config["secret"] || config[:secret],
|
93
|
+
algorithm: config["algorithm"] || config[:algorithm] || "HS256",
|
94
|
+
payload: config["payload"] || config[:payload],
|
95
|
+
headers: config["headers"] || config[:headers],
|
96
|
+
expires_in: config["expires_in"] || config[:expires_in]
|
97
|
+
)
|
98
|
+
when "api_key"
|
99
|
+
ApiKey.new(
|
100
|
+
key: config["key"] || config[:key],
|
101
|
+
name: config["name"] || config[:name] || "X-API-Key",
|
102
|
+
location: config["location"] || config[:location] || "header"
|
103
|
+
)
|
104
|
+
else
|
105
|
+
raise ArgumentError, "Unknown authentication type: #{config['type'] || config[:type]}"
|
106
|
+
end
|
107
|
+
|
108
|
+
new(strategy, auto_retry: config["auto_retry"] || config[:auto_retry] || true)
|
109
|
+
end
|
110
|
+
|
111
|
+
##
|
112
|
+
# Create interceptor from security scheme
|
113
|
+
#
|
114
|
+
# @param scheme [Hash] Security scheme definition
|
115
|
+
# @param credentials [Hash] Authentication credentials
|
116
|
+
# @return [Interceptor] Configured interceptor
|
117
|
+
def self.from_security_scheme(scheme, credentials)
|
118
|
+
strategy = case scheme["type"]
|
119
|
+
when "oauth2"
|
120
|
+
OAuth2.new(
|
121
|
+
client_id: credentials["client_id"],
|
122
|
+
client_secret: credentials["client_secret"],
|
123
|
+
token_url: scheme["tokenUrl"],
|
124
|
+
scope: credentials["scope"]
|
125
|
+
)
|
126
|
+
when "http"
|
127
|
+
case scheme["scheme"]
|
128
|
+
when "bearer"
|
129
|
+
JWT.new(token: credentials["token"])
|
130
|
+
when "basic"
|
131
|
+
# Basic auth would be handled differently
|
132
|
+
raise ArgumentError, "Basic auth not yet implemented"
|
133
|
+
else
|
134
|
+
raise ArgumentError, "Unsupported HTTP scheme: #{scheme['scheme']}"
|
135
|
+
end
|
136
|
+
when "apiKey"
|
137
|
+
ApiKey.from_security_scheme(scheme, credentials["key"])
|
138
|
+
else
|
139
|
+
raise ArgumentError, "Unsupported security scheme type: #{scheme['type']}"
|
140
|
+
end
|
141
|
+
|
142
|
+
new(strategy)
|
143
|
+
end
|
144
|
+
|
145
|
+
##
|
146
|
+
# Check if the strategy supports token refresh
|
147
|
+
#
|
148
|
+
# @return [Boolean] True if strategy supports refresh
|
149
|
+
def supports_refresh?
|
150
|
+
@strategy.respond_to?(:refresh_token!) || @strategy.respond_to?(:regenerate_token!)
|
151
|
+
end
|
152
|
+
|
153
|
+
##
|
154
|
+
# Refresh authentication credentials
|
155
|
+
#
|
156
|
+
# @return [Boolean] True if refresh was successful
|
157
|
+
def refresh!
|
158
|
+
return false unless supports_refresh?
|
159
|
+
|
160
|
+
begin
|
161
|
+
if @strategy.respond_to?(:refresh_token!)
|
162
|
+
@strategy.refresh_token!
|
163
|
+
elsif @strategy.respond_to?(:regenerate_token!)
|
164
|
+
@strategy.regenerate_token!
|
165
|
+
end
|
166
|
+
true
|
167
|
+
rescue StandardError
|
168
|
+
false
|
169
|
+
end
|
170
|
+
end
|
171
|
+
|
172
|
+
##
|
173
|
+
# Get current authentication status
|
174
|
+
#
|
175
|
+
# @return [Hash] Status information
|
176
|
+
def status
|
177
|
+
{
|
178
|
+
strategy: @strategy.class.name,
|
179
|
+
valid: strategy_valid?,
|
180
|
+
supports_refresh: supports_refresh?,
|
181
|
+
expires_at: strategy_expires_at
|
182
|
+
}
|
183
|
+
end
|
184
|
+
|
185
|
+
private
|
186
|
+
|
187
|
+
##
|
188
|
+
# Apply configuration-based authentication
|
189
|
+
#
|
190
|
+
# @param request [Object] The request to authenticate
|
191
|
+
# @param config [Hash] Authentication configuration
|
192
|
+
def apply_config_authentication(request, config)
|
193
|
+
case config["type"] || config[:type]
|
194
|
+
when "bearer"
|
195
|
+
request.headers["Authorization"] = "Bearer #{config['token'] || config[:token]}"
|
196
|
+
when "basic"
|
197
|
+
require "base64"
|
198
|
+
username = config["username"] || config[:username]
|
199
|
+
password = config["password"] || config[:password]
|
200
|
+
credentials = Base64.strict_encode64("#{username}:#{password}")
|
201
|
+
request.headers["Authorization"] = "Basic #{credentials}"
|
202
|
+
when "api_key"
|
203
|
+
name = config["name"] || config[:name] || "X-API-Key"
|
204
|
+
key = config["key"] || config[:key]
|
205
|
+
location = config["location"] || config[:location] || "header"
|
206
|
+
|
207
|
+
case location
|
208
|
+
when "header"
|
209
|
+
request.headers[name] = key
|
210
|
+
when "query"
|
211
|
+
request.params[name] = key
|
212
|
+
end
|
213
|
+
end
|
214
|
+
end
|
215
|
+
|
216
|
+
##
|
217
|
+
# Check if response indicates authentication error
|
218
|
+
#
|
219
|
+
# @param response [Object] The response to check
|
220
|
+
# @return [Boolean] True if authentication error
|
221
|
+
def authentication_error?(response)
|
222
|
+
# This would depend on the response format
|
223
|
+
# For HTTP responses, check status codes
|
224
|
+
return [401, 403].include?(response.status) if response.respond_to?(:status)
|
225
|
+
|
226
|
+
# For JSON-RPC responses, check error codes
|
227
|
+
if response.respond_to?(:[]) && response["error"]
|
228
|
+
error_code = response["error"]["code"]
|
229
|
+
return [A2A::Protocol::JsonRpc::AUTHENTICATION_REQUIRED, A2A::Protocol::JsonRpc::AUTHORIZATION_FAILED].include?(error_code)
|
230
|
+
end
|
231
|
+
|
232
|
+
false
|
233
|
+
end
|
234
|
+
|
235
|
+
##
|
236
|
+
# Handle authentication error with retry
|
237
|
+
#
|
238
|
+
# @param request [Object] The original request
|
239
|
+
# @param context [Hash] Request context
|
240
|
+
# @param next_middleware [Proc] Next middleware in chain
|
241
|
+
# @return [Object] Response from retry attempt
|
242
|
+
def handle_auth_error_retry(request, context, next_middleware)
|
243
|
+
@retry_mutex.synchronize do
|
244
|
+
# Try to refresh credentials
|
245
|
+
if refresh!
|
246
|
+
# Reapply authentication and retry
|
247
|
+
apply_authentication(request)
|
248
|
+
return next_middleware.call(request, context)
|
249
|
+
end
|
250
|
+
end
|
251
|
+
|
252
|
+
# If refresh failed, raise the original error
|
253
|
+
raise A2A::Errors::AuthenticationError, "Authentication failed and refresh unsuccessful"
|
254
|
+
end
|
255
|
+
|
256
|
+
##
|
257
|
+
# Check if the current strategy is valid
|
258
|
+
#
|
259
|
+
# @return [Boolean] True if strategy is valid
|
260
|
+
def strategy_valid?
|
261
|
+
case @strategy
|
262
|
+
when OAuth2
|
263
|
+
@strategy.token_valid?
|
264
|
+
when JWT
|
265
|
+
!@strategy.token_expired?
|
266
|
+
when ApiKey
|
267
|
+
@strategy.valid?
|
268
|
+
else
|
269
|
+
true # Assume valid for unknown strategies
|
270
|
+
end
|
271
|
+
end
|
272
|
+
|
273
|
+
##
|
274
|
+
# Get strategy expiration time
|
275
|
+
#
|
276
|
+
# @return [Time, nil] Expiration time if available
|
277
|
+
def strategy_expires_at
|
278
|
+
case @strategy
|
279
|
+
when OAuth2
|
280
|
+
@strategy.expires_at
|
281
|
+
when JWT
|
282
|
+
@strategy.expires_at
|
283
|
+
end
|
284
|
+
end
|
285
|
+
end
|
286
|
+
end
|
287
|
+
end
|
288
|
+
end
|
@@ -0,0 +1,189 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "jwt"
|
4
|
+
require "json"
|
5
|
+
|
6
|
+
##
|
7
|
+
# JWT Bearer Token authentication strategy
|
8
|
+
#
|
9
|
+
# Supports both static JWT tokens and dynamic JWT generation
|
10
|
+
# for authentication with A2A agents.
|
11
|
+
#
|
12
|
+
module A2A
|
13
|
+
module Client
|
14
|
+
module Auth
|
15
|
+
class JWT
|
16
|
+
attr_reader :token, :algorithm, :secret, :payload, :headers
|
17
|
+
|
18
|
+
##
|
19
|
+
# Initialize JWT authentication
|
20
|
+
#
|
21
|
+
# @param token [String, nil] Pre-generated JWT token
|
22
|
+
# @param secret [String, nil] Secret key for JWT signing (required for dynamic tokens)
|
23
|
+
# @param algorithm [String] JWT signing algorithm (default: 'HS256')
|
24
|
+
# @param payload [Hash, nil] JWT payload for dynamic token generation
|
25
|
+
# @param headers [Hash, nil] Additional JWT headers
|
26
|
+
# @param expires_in [Integer, nil] Token expiration time in seconds
|
27
|
+
def initialize(token: nil, secret: nil, algorithm: "HS256", payload: nil,
|
28
|
+
headers: nil, expires_in: nil)
|
29
|
+
@token = token
|
30
|
+
@secret = secret
|
31
|
+
@algorithm = algorithm
|
32
|
+
@payload = payload || {}
|
33
|
+
@headers = headers || {}
|
34
|
+
@expires_in = expires_in
|
35
|
+
@generated_token = nil
|
36
|
+
@token_expires_at = nil
|
37
|
+
@token_mutex = Mutex.new
|
38
|
+
|
39
|
+
validate_configuration!
|
40
|
+
end
|
41
|
+
|
42
|
+
##
|
43
|
+
# Get a valid JWT token
|
44
|
+
#
|
45
|
+
# @return [String] The JWT token
|
46
|
+
def jwt_token
|
47
|
+
if @token
|
48
|
+
# Static token
|
49
|
+
@token
|
50
|
+
else
|
51
|
+
# Dynamic token generation
|
52
|
+
@token_mutex.synchronize do
|
53
|
+
generate_token if token_expired?
|
54
|
+
@generated_token
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
##
|
60
|
+
# Get authorization header value
|
61
|
+
#
|
62
|
+
# @return [String] The authorization header value
|
63
|
+
def authorization_header
|
64
|
+
"Bearer #{jwt_token}"
|
65
|
+
end
|
66
|
+
|
67
|
+
##
|
68
|
+
# Apply authentication to a Faraday request
|
69
|
+
#
|
70
|
+
# @param request [Faraday::Request] The request to authenticate
|
71
|
+
def apply_to_request(request)
|
72
|
+
request.headers["Authorization"] = authorization_header
|
73
|
+
end
|
74
|
+
|
75
|
+
##
|
76
|
+
# Validate the JWT token
|
77
|
+
#
|
78
|
+
# @param token [String, nil] Token to validate (uses current token if nil)
|
79
|
+
# @param verify_signature [Boolean] Whether to verify the signature
|
80
|
+
# @return [Hash] Decoded JWT payload
|
81
|
+
def validate_token(token = nil, verify_signature: true)
|
82
|
+
token_to_validate = token || jwt_token
|
83
|
+
|
84
|
+
if verify_signature && @secret
|
85
|
+
::JWT.decode(token_to_validate, @secret, true, { algorithm: @algorithm })
|
86
|
+
else
|
87
|
+
::JWT.decode(token_to_validate, nil, false)
|
88
|
+
end
|
89
|
+
rescue ::JWT::DecodeError => e
|
90
|
+
raise A2A::Errors::AuthenticationError, "JWT validation failed: #{e.message}"
|
91
|
+
end
|
92
|
+
|
93
|
+
##
|
94
|
+
# Check if the token is expired
|
95
|
+
#
|
96
|
+
# @param token [String, nil] Token to check (uses current token if nil)
|
97
|
+
# @return [Boolean] True if token is expired
|
98
|
+
def token_expired?(token = nil)
|
99
|
+
return false unless @expires_in || token
|
100
|
+
|
101
|
+
if @generated_token && @token_expires_at
|
102
|
+
# Check generated token expiration
|
103
|
+
Time.now >= (@token_expires_at - 30) # 30 second buffer
|
104
|
+
elsif token || @token
|
105
|
+
# Check token payload expiration
|
106
|
+
begin
|
107
|
+
payload = validate_token(token, verify_signature: false).first
|
108
|
+
exp = payload["exp"]
|
109
|
+
return false unless exp
|
110
|
+
|
111
|
+
Time.now.to_i >= (exp - 30) # 30 second buffer
|
112
|
+
rescue A2A::Errors::AuthenticationError
|
113
|
+
true # Consider invalid tokens as expired
|
114
|
+
end
|
115
|
+
else
|
116
|
+
false
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
##
|
121
|
+
# Force regenerate the token (for dynamic tokens)
|
122
|
+
#
|
123
|
+
# @return [String] The new JWT token
|
124
|
+
def regenerate_token!
|
125
|
+
return @token if @token # Can't regenerate static tokens
|
126
|
+
|
127
|
+
@token_mutex.synchronize do
|
128
|
+
generate_token
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
##
|
133
|
+
# Get token expiration time
|
134
|
+
#
|
135
|
+
# @return [Time, nil] Token expiration time
|
136
|
+
def expires_at
|
137
|
+
if @token_expires_at
|
138
|
+
@token_expires_at
|
139
|
+
elsif @token
|
140
|
+
begin
|
141
|
+
payload = validate_token(@token, verify_signature: false).first
|
142
|
+
exp = payload["exp"]
|
143
|
+
Time.zone.at(exp) if exp
|
144
|
+
rescue A2A::Errors::AuthenticationError
|
145
|
+
nil
|
146
|
+
end
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
private
|
151
|
+
|
152
|
+
##
|
153
|
+
# Validate the authentication configuration
|
154
|
+
def validate_configuration!
|
155
|
+
raise ArgumentError, "Either token or secret must be provided" if @token.nil? && @secret.nil?
|
156
|
+
|
157
|
+
raise ArgumentError, "Payload is required for dynamic token generation" if @token.nil? && @payload.empty?
|
158
|
+
|
159
|
+
return if %w[HS256 HS384 HS512 RS256 RS384 RS512 ES256 ES384 ES512].include?(@algorithm)
|
160
|
+
|
161
|
+
raise ArgumentError, "Unsupported JWT algorithm: #{@algorithm}"
|
162
|
+
end
|
163
|
+
|
164
|
+
##
|
165
|
+
# Generate a new JWT token
|
166
|
+
#
|
167
|
+
# @return [String] The generated JWT token
|
168
|
+
def generate_token
|
169
|
+
raise ArgumentError, "Cannot generate token without secret" unless @secret
|
170
|
+
|
171
|
+
# Build payload with expiration
|
172
|
+
token_payload = @payload.dup
|
173
|
+
|
174
|
+
if @expires_in
|
175
|
+
now = Time.now.to_i
|
176
|
+
token_payload["iat"] = now
|
177
|
+
token_payload["exp"] = now + @expires_in
|
178
|
+
@token_expires_at = Time.now + @expires_in
|
179
|
+
end
|
180
|
+
|
181
|
+
# Generate token
|
182
|
+
@generated_token = ::JWT.encode(token_payload, @secret, @algorithm, @headers)
|
183
|
+
rescue ::JWT::EncodeError => e
|
184
|
+
raise A2A::Errors::AuthenticationError, "JWT generation failed: #{e.message}"
|
185
|
+
end
|
186
|
+
end
|
187
|
+
end
|
188
|
+
end
|
189
|
+
end
|
@@ -0,0 +1,146 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "faraday"
|
4
|
+
require "json"
|
5
|
+
require "base64"
|
6
|
+
|
7
|
+
##
|
8
|
+
# OAuth 2.0 Client Credentials Flow authentication strategy
|
9
|
+
#
|
10
|
+
# Implements OAuth 2.0 client credentials flow for machine-to-machine
|
11
|
+
# authentication with A2A agents.
|
12
|
+
#
|
13
|
+
module A2A
|
14
|
+
module Client
|
15
|
+
module Auth
|
16
|
+
class OAuth2
|
17
|
+
attr_reader :client_id, :client_secret, :token_url, :scope, :access_token, :expires_at
|
18
|
+
|
19
|
+
##
|
20
|
+
# Initialize OAuth2 authentication
|
21
|
+
#
|
22
|
+
# @param client_id [String] OAuth2 client ID
|
23
|
+
# @param client_secret [String] OAuth2 client secret
|
24
|
+
# @param token_url [String] OAuth2 token endpoint URL
|
25
|
+
# @param scope [String, nil] Optional scope for the token
|
26
|
+
def initialize(client_id:, client_secret:, token_url:, scope: nil)
|
27
|
+
@client_id = client_id
|
28
|
+
@client_secret = client_secret
|
29
|
+
@token_url = token_url
|
30
|
+
@scope = scope
|
31
|
+
@access_token = nil
|
32
|
+
@expires_at = nil
|
33
|
+
@token_mutex = Mutex.new
|
34
|
+
end
|
35
|
+
|
36
|
+
##
|
37
|
+
# Get a valid access token, refreshing if necessary
|
38
|
+
#
|
39
|
+
# @return [String] The access token
|
40
|
+
def token
|
41
|
+
@token_mutex.synchronize do
|
42
|
+
refresh_token if token_expired?
|
43
|
+
@access_token
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
##
|
48
|
+
# Get authorization header value
|
49
|
+
#
|
50
|
+
# @return [String] The authorization header value
|
51
|
+
def authorization_header
|
52
|
+
"Bearer #{token}"
|
53
|
+
end
|
54
|
+
|
55
|
+
##
|
56
|
+
# Apply authentication to a Faraday request
|
57
|
+
#
|
58
|
+
# @param request [Faraday::Request] The request to authenticate
|
59
|
+
def apply_to_request(request)
|
60
|
+
request.headers["Authorization"] = authorization_header
|
61
|
+
end
|
62
|
+
|
63
|
+
##
|
64
|
+
# Check if the current token is valid
|
65
|
+
#
|
66
|
+
# @return [Boolean] True if token is valid and not expired
|
67
|
+
def token_valid?
|
68
|
+
@access_token && !token_expired?
|
69
|
+
end
|
70
|
+
|
71
|
+
##
|
72
|
+
# Force refresh the access token
|
73
|
+
#
|
74
|
+
# @return [String] The new access token
|
75
|
+
def refresh_token!
|
76
|
+
@token_mutex.synchronize do
|
77
|
+
refresh_token
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
##
|
82
|
+
# Clear the current token (force re-authentication)
|
83
|
+
def clear_token!
|
84
|
+
@token_mutex.synchronize do
|
85
|
+
@access_token = nil
|
86
|
+
@expires_at = nil
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
private
|
91
|
+
|
92
|
+
##
|
93
|
+
# Check if the token is expired
|
94
|
+
#
|
95
|
+
# @return [Boolean] True if token is expired or will expire soon
|
96
|
+
def token_expired?
|
97
|
+
return true unless @expires_at
|
98
|
+
|
99
|
+
# Consider token expired if it expires within 30 seconds
|
100
|
+
Time.now >= (@expires_at - 30)
|
101
|
+
end
|
102
|
+
|
103
|
+
##
|
104
|
+
# Refresh the access token using client credentials flow
|
105
|
+
def refresh_token
|
106
|
+
connection = Faraday.new do |conn|
|
107
|
+
conn.request :url_encoded
|
108
|
+
conn.response :json
|
109
|
+
conn.adapter Faraday.default_adapter
|
110
|
+
end
|
111
|
+
|
112
|
+
# Prepare request parameters
|
113
|
+
params = {
|
114
|
+
grant_type: "client_credentials",
|
115
|
+
client_id: @client_id,
|
116
|
+
client_secret: @client_secret
|
117
|
+
}
|
118
|
+
params[:scope] = @scope if @scope
|
119
|
+
|
120
|
+
# Make token request
|
121
|
+
response = connection.post(@token_url, params)
|
122
|
+
|
123
|
+
unless response.success?
|
124
|
+
raise A2A::Errors::AuthenticationError, "OAuth2 token request failed: #{response.status} - #{response.body}"
|
125
|
+
end
|
126
|
+
|
127
|
+
token_data = response.body
|
128
|
+
|
129
|
+
unless token_data["access_token"]
|
130
|
+
raise A2A::Errors::AuthenticationError, "OAuth2 response missing access_token: #{token_data}"
|
131
|
+
end
|
132
|
+
|
133
|
+
@access_token = token_data["access_token"]
|
134
|
+
|
135
|
+
# Calculate expiration time
|
136
|
+
expires_in = token_data["expires_in"]&.to_i || 3600 # Default to 1 hour
|
137
|
+
@expires_at = Time.now + expires_in
|
138
|
+
|
139
|
+
@access_token
|
140
|
+
rescue Faraday::Error => e
|
141
|
+
raise A2A::Errors::AuthenticationError, "OAuth2 token request failed: #{e.message}"
|
142
|
+
end
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
146
|
+
end
|