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.
Files changed (128) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +137 -0
  4. data/.simplecov +46 -0
  5. data/.yardopts +10 -0
  6. data/CHANGELOG.md +33 -0
  7. data/CODE_OF_CONDUCT.md +128 -0
  8. data/CONTRIBUTING.md +165 -0
  9. data/Gemfile +43 -0
  10. data/Guardfile +34 -0
  11. data/LICENSE.txt +21 -0
  12. data/PUBLISHING_CHECKLIST.md +214 -0
  13. data/README.md +171 -0
  14. data/Rakefile +165 -0
  15. data/docs/agent_execution.md +309 -0
  16. data/docs/api_reference.md +792 -0
  17. data/docs/configuration.md +780 -0
  18. data/docs/events.md +475 -0
  19. data/docs/getting_started.md +668 -0
  20. data/docs/integration.md +262 -0
  21. data/docs/server_apps.md +621 -0
  22. data/docs/troubleshooting.md +765 -0
  23. data/lib/a2a/client/api_methods.rb +263 -0
  24. data/lib/a2a/client/auth/api_key.rb +161 -0
  25. data/lib/a2a/client/auth/interceptor.rb +288 -0
  26. data/lib/a2a/client/auth/jwt.rb +189 -0
  27. data/lib/a2a/client/auth/oauth2.rb +146 -0
  28. data/lib/a2a/client/auth.rb +137 -0
  29. data/lib/a2a/client/base.rb +316 -0
  30. data/lib/a2a/client/config.rb +210 -0
  31. data/lib/a2a/client/connection_pool.rb +233 -0
  32. data/lib/a2a/client/http_client.rb +524 -0
  33. data/lib/a2a/client/json_rpc_handler.rb +136 -0
  34. data/lib/a2a/client/middleware/circuit_breaker_interceptor.rb +245 -0
  35. data/lib/a2a/client/middleware/logging_interceptor.rb +371 -0
  36. data/lib/a2a/client/middleware/rate_limit_interceptor.rb +142 -0
  37. data/lib/a2a/client/middleware/retry_interceptor.rb +161 -0
  38. data/lib/a2a/client/middleware.rb +116 -0
  39. data/lib/a2a/client/performance_tracker.rb +60 -0
  40. data/lib/a2a/configuration/defaults.rb +34 -0
  41. data/lib/a2a/configuration/environment_loader.rb +76 -0
  42. data/lib/a2a/configuration/file_loader.rb +115 -0
  43. data/lib/a2a/configuration/inheritance.rb +101 -0
  44. data/lib/a2a/configuration/validator.rb +180 -0
  45. data/lib/a2a/configuration.rb +201 -0
  46. data/lib/a2a/errors.rb +291 -0
  47. data/lib/a2a/modules.rb +50 -0
  48. data/lib/a2a/monitoring/alerting.rb +490 -0
  49. data/lib/a2a/monitoring/distributed_tracing.rb +398 -0
  50. data/lib/a2a/monitoring/health_endpoints.rb +204 -0
  51. data/lib/a2a/monitoring/metrics_collector.rb +438 -0
  52. data/lib/a2a/monitoring.rb +463 -0
  53. data/lib/a2a/plugin.rb +358 -0
  54. data/lib/a2a/plugin_manager.rb +159 -0
  55. data/lib/a2a/plugins/example_auth.rb +81 -0
  56. data/lib/a2a/plugins/example_middleware.rb +118 -0
  57. data/lib/a2a/plugins/example_transport.rb +76 -0
  58. data/lib/a2a/protocol/agent_card.rb +8 -0
  59. data/lib/a2a/protocol/agent_card_server.rb +584 -0
  60. data/lib/a2a/protocol/capability.rb +496 -0
  61. data/lib/a2a/protocol/json_rpc.rb +254 -0
  62. data/lib/a2a/protocol/message.rb +8 -0
  63. data/lib/a2a/protocol/task.rb +8 -0
  64. data/lib/a2a/rails/a2a_controller.rb +258 -0
  65. data/lib/a2a/rails/controller_helpers.rb +499 -0
  66. data/lib/a2a/rails/engine.rb +167 -0
  67. data/lib/a2a/rails/generators/agent_generator.rb +311 -0
  68. data/lib/a2a/rails/generators/install_generator.rb +209 -0
  69. data/lib/a2a/rails/generators/migration_generator.rb +232 -0
  70. data/lib/a2a/rails/generators/templates/add_a2a_indexes.rb +57 -0
  71. data/lib/a2a/rails/generators/templates/agent_controller.rb +122 -0
  72. data/lib/a2a/rails/generators/templates/agent_controller_spec.rb +160 -0
  73. data/lib/a2a/rails/generators/templates/agent_readme.md +200 -0
  74. data/lib/a2a/rails/generators/templates/create_a2a_push_notification_configs.rb +68 -0
  75. data/lib/a2a/rails/generators/templates/create_a2a_tasks.rb +83 -0
  76. data/lib/a2a/rails/generators/templates/example_agent_controller.rb +228 -0
  77. data/lib/a2a/rails/generators/templates/initializer.rb +108 -0
  78. data/lib/a2a/rails/generators/templates/push_notification_config_model.rb +228 -0
  79. data/lib/a2a/rails/generators/templates/task_model.rb +200 -0
  80. data/lib/a2a/rails/tasks/a2a.rake +228 -0
  81. data/lib/a2a/server/a2a_methods.rb +520 -0
  82. data/lib/a2a/server/agent.rb +537 -0
  83. data/lib/a2a/server/agent_execution/agent_executor.rb +279 -0
  84. data/lib/a2a/server/agent_execution/request_context.rb +219 -0
  85. data/lib/a2a/server/apps/rack_app.rb +311 -0
  86. data/lib/a2a/server/apps/sinatra_app.rb +261 -0
  87. data/lib/a2a/server/default_request_handler.rb +350 -0
  88. data/lib/a2a/server/events/event_consumer.rb +116 -0
  89. data/lib/a2a/server/events/event_queue.rb +226 -0
  90. data/lib/a2a/server/example_agent.rb +248 -0
  91. data/lib/a2a/server/handler.rb +281 -0
  92. data/lib/a2a/server/middleware/authentication_middleware.rb +212 -0
  93. data/lib/a2a/server/middleware/cors_middleware.rb +171 -0
  94. data/lib/a2a/server/middleware/logging_middleware.rb +362 -0
  95. data/lib/a2a/server/middleware/rate_limit_middleware.rb +382 -0
  96. data/lib/a2a/server/middleware.rb +213 -0
  97. data/lib/a2a/server/push_notification_manager.rb +327 -0
  98. data/lib/a2a/server/request_handler.rb +136 -0
  99. data/lib/a2a/server/storage/base.rb +141 -0
  100. data/lib/a2a/server/storage/database.rb +266 -0
  101. data/lib/a2a/server/storage/memory.rb +274 -0
  102. data/lib/a2a/server/storage/redis.rb +320 -0
  103. data/lib/a2a/server/storage.rb +38 -0
  104. data/lib/a2a/server/task_manager.rb +534 -0
  105. data/lib/a2a/transport/grpc.rb +481 -0
  106. data/lib/a2a/transport/http.rb +415 -0
  107. data/lib/a2a/transport/sse.rb +499 -0
  108. data/lib/a2a/types/agent_card.rb +540 -0
  109. data/lib/a2a/types/artifact.rb +99 -0
  110. data/lib/a2a/types/base_model.rb +223 -0
  111. data/lib/a2a/types/events.rb +117 -0
  112. data/lib/a2a/types/message.rb +106 -0
  113. data/lib/a2a/types/part.rb +288 -0
  114. data/lib/a2a/types/push_notification.rb +139 -0
  115. data/lib/a2a/types/security.rb +167 -0
  116. data/lib/a2a/types/task.rb +154 -0
  117. data/lib/a2a/types.rb +88 -0
  118. data/lib/a2a/utils/helpers.rb +245 -0
  119. data/lib/a2a/utils/message_buffer.rb +278 -0
  120. data/lib/a2a/utils/performance.rb +247 -0
  121. data/lib/a2a/utils/rails_detection.rb +97 -0
  122. data/lib/a2a/utils/structured_logger.rb +306 -0
  123. data/lib/a2a/utils/time_helpers.rb +167 -0
  124. data/lib/a2a/utils/validation.rb +8 -0
  125. data/lib/a2a/version.rb +6 -0
  126. data/lib/a2a-rails.rb +58 -0
  127. data/lib/a2a.rb +198 -0
  128. 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