traylinx_auth_client 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d479f011cde37bff0d312edef21f32c3cab5e7947971f57c5d165aa1e6b57df3
4
+ data.tar.gz: c5016a5a0bef8c57563210630a488d8196d8ce835b44c2d466710434440b8210
5
+ SHA512:
6
+ metadata.gz: 263b96fef7fe57aa4b69ea3f2c5399bf78cd492f84ab7aa0ef7e8fd442a3e9b7126a4709b6bb17cdd2b2cf46ddd2429a4a10bd067b53579a5b9c84b8007f3709
7
+ data.tar.gz: 5d1d2659cfb940f4713fa4321e45a22444dfe65f455bf693896739bbd85c042f06d7cb7e8c6bccfab99711bbc67dc2624d454d4f3449431b5b2707eee4366643
data/PUBLISHING.md ADDED
@@ -0,0 +1,85 @@
1
+ # Publishing `traylinx_auth_client`
2
+
3
+ This guide details how to build and publish the `traylinx_auth_client` Ruby gem.
4
+
5
+ ## Prerequisites
6
+
7
+ - Ruby installed (2.7+)
8
+ - `bundler` installed
9
+ - Access to the target Gem repository (RubyGems.org or private)
10
+
11
+ ## 1. Verify Metadata
12
+
13
+ Ensure `traylinx_auth_client.gemspec` has the correct version and metadata.
14
+
15
+ ```bash
16
+ # Check version
17
+ grep "spec.version" traylinx_auth_client.gemspec
18
+ ```
19
+
20
+ ## 2. Build the Gem
21
+
22
+ Navigate to the SDK directory and build the gem package.
23
+
24
+ ```bash
25
+ cd traylinx_core/sdks/ruby_sentinel
26
+ gem build traylinx_auth_client.gemspec
27
+ ```
28
+
29
+ This will output a file named like `traylinx_auth_client-0.1.0.gem`.
30
+
31
+ ## 3. Local Verification (Optional)
32
+
33
+ You can install the built gem locally to verify it.
34
+
35
+ ```bash
36
+ gem install ./traylinx_auth_client-0.1.0.gem
37
+ ```
38
+
39
+ ## 4. Publish to RubyGems
40
+
41
+ If publishing to the public RubyGems.org:
42
+
43
+ ```bash
44
+ gem push traylinx_auth_client-0.1.0.gem
45
+ ```
46
+
47
+ ## 5. Publish to Private Registry (e.g., GitHub Packages)
48
+
49
+ If using GitHub Packages, you need to configure your `~/.gem/credentials` or use the command line.
50
+
51
+ ```bash
52
+ # Example for GitHub Packages
53
+ gem push --key github --host https://rubygems.pkg.github.com/StartTraylinx traylinx_auth_client-0.1.0.gem
54
+ ```
55
+
56
+ ## 6. Automating with CI/CD
57
+
58
+ For automated publishing via GitHub Actions, ensure you have the `RUBYGEMS_API_KEY` secret set up in your repository.
59
+
60
+ ```yaml
61
+ # .github/workflows/publish_gem.yml
62
+ name: Publish Ruby Gem
63
+
64
+ on:
65
+ push:
66
+ tags:
67
+ - 'v*'
68
+
69
+ jobs:
70
+ build:
71
+ runs-on: ubuntu-latest
72
+ steps:
73
+ - uses: actions/checkout@v2
74
+ - name: Set up Ruby
75
+ uses: ruby/setup-ruby@v1
76
+ with:
77
+ ruby-version: '3.0'
78
+
79
+ - name: Build and Publish
80
+ env:
81
+ GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }}
82
+ run: |
83
+ gem build *.gemspec
84
+ gem push *.gem
85
+ ```
data/README.md ADDED
@@ -0,0 +1,180 @@
1
+ # Traylinx Auth Client (Ruby)
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/traylinx-auth-client.svg)](https://badge.fury.io/rb/traylinx-auth-client)
4
+ [![Ruby](https://img.shields.io/badge/ruby-2.7+-ruby.svg)](https://www.ruby-lang.org/)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
+
7
+ A robust, enterprise-grade Ruby library for Traylinx Sentinel Agent-to-Agent (A2A) authentication. This client provides secure token management, automatic retry logic, comprehensive error handling, and Rack middleware integration.
8
+
9
+ ## 🚀 Features
10
+
11
+ - **🔐 Dual Token Authentication**: Handles both `access_token` and `agent_secret_token` with automatic refresh
12
+ - **🛡️ Enterprise Security**: Input validation, secure credential handling, and comprehensive error management
13
+ - **⚡ High Performance**: Thread-safe implementation, token caching, and connection pooling
14
+ - **🔄 Async Support**: Built on Faraday for flexible adapters (Async/EM possible)
15
+ - **🎯 Rack Integration**: Simple middleware for protecting endpoints with A2A authentication
16
+ - **📡 JSON-RPC Support**: Support for A2A RPC method calls
17
+ - **🔧 Zero Configuration**: Works with environment variables out of the box
18
+
19
+ ## 📦 Installation
20
+
21
+ Add this line to your application's Gemfile:
22
+
23
+ ```ruby
24
+ gem 'traylinx_auth_client'
25
+ ```
26
+
27
+ And then execute:
28
+
29
+ ```bash
30
+ bundle install
31
+ ```
32
+
33
+ Or install it yourself as:
34
+
35
+ ```bash
36
+ gem install traylinx_auth_client
37
+ ```
38
+
39
+ ## ⚡ Quick Start (5 lines)
40
+
41
+ ```ruby
42
+ require 'traylinx_auth_client'
43
+
44
+ # Set ENV: TRAYLINX_CLIENT_ID, TRAYLINX_CLIENT_SECRET,
45
+ # TRAYLINX_API_BASE_URL, TRAYLINX_AGENT_USER_ID
46
+
47
+ # Make authenticated request to another agent
48
+ client = TraylinxAuthClient.client
49
+ response = client.make_a2a_request(:get, "https://other-agent.com/api/data")
50
+ puts response # JSON response body
51
+ ```
52
+
53
+ ## 🔧 Configuration
54
+
55
+ ### Environment Variables
56
+
57
+ Set these environment variables for your agent:
58
+
59
+ ```bash
60
+ export TRAYLINX_CLIENT_ID="your-client-id"
61
+ export TRAYLINX_CLIENT_SECRET="your-client-secret"
62
+ export TRAYLINX_API_BASE_URL="https://auth.traylinx.com"
63
+ export TRAYLINX_AGENT_USER_ID="12345678-..."
64
+ ```
65
+
66
+ ### Programmatic Configuration
67
+
68
+ ```ruby
69
+ require 'traylinx_auth_client'
70
+
71
+ TraylinxAuthClient.configure do |config|
72
+ config.client_id = "your-client-id"
73
+ config.client_secret = "your-client-secret"
74
+ config.api_base_url = "https://auth.traylinx.com"
75
+ config.agent_user_id = "1234..."
76
+
77
+ config.timeout = 30 # Seconds
78
+ config.max_retries = 3
79
+ config.cache_tokens = true
80
+ config.log_level = :info
81
+ end
82
+ ```
83
+
84
+ ## 📖 Usage Examples
85
+
86
+ ### Making Authenticated Requests
87
+
88
+ ```ruby
89
+ client = TraylinxAuthClient.client
90
+
91
+ # GET request
92
+ data = client.make_a2a_request(:get, "https://other-agent.com/api/users")
93
+
94
+ # POST request with JSON
95
+ result = client.make_a2a_request(:post, "https://other-agent.com/api/process",
96
+ json: { items: ['item1', 'item2'] },
97
+ timeout: 60
98
+ )
99
+ ```
100
+
101
+ ### Manual Header Management
102
+
103
+ ```ruby
104
+ # Get all headers (for Auth Service)
105
+ headers = client.get_request_headers
106
+ # => { "Authorization" => "Bearer ...", "X-Agent-Secret-Token" => "...", ... }
107
+
108
+ # Get Agent headers (for A2A)
109
+ agent_headers = client.get_agent_request_headers
110
+ # => { "X-Agent-Secret-Token" => "...", "X-Agent-User-Id" => "..." }
111
+ ```
112
+
113
+ ### Rack Middleware (Rails/Sinatra)
114
+
115
+ Protect your application endpoints with A2A authentication.
116
+
117
+ ```ruby
118
+ # config.ru or application.rb
119
+ require 'traylinx_auth_client'
120
+
121
+ use TraylinxAuthClient::Middleware
122
+ ```
123
+
124
+ Or with specific validation path:
125
+
126
+ ```ruby
127
+ use TraylinxAuthClient::Middleware, validation_path: /^\/api\/protected/
128
+ ```
129
+
130
+ ### JSON-RPC Calls
131
+
132
+ ```ruby
133
+ # Introspect a token
134
+ active = client.validate_token("some-agent-token", "agent-user-id")
135
+
136
+ # Generic RPC call
137
+ result = client.rpc_call("custom_method", { param: "value" })
138
+ ```
139
+
140
+ ### 🌌 Stargate P2P Identity (New)
141
+
142
+ The client supports Stargate P2P identity certification for peer-to-peer communication.
143
+
144
+ ```ruby
145
+ # 1. Get a challenge from Sentinel
146
+ challenge = client.get_p2p_challenge(peer_id)
147
+
148
+ # 2. Sign the challenge with your private key (implementation depends on your crypto lib)
149
+ signature = sign_challenge(challenge, private_key)
150
+
151
+ # 3. Certify identity
152
+ cert = client.certify_p2p_identity(peer_id, public_key_base64, signature, challenge)
153
+
154
+ puts cert[:certificate] # JWT Certificate
155
+ puts cert[:expires_at] # Expiration
156
+ ```
157
+
158
+ ## 🔐 Authentication Flow
159
+
160
+ 1. **Token Acquisition**: Automatically exchanges `client_id` + `client_secret` for OAuth tokens.
161
+ 2. **Token Caching**: Caches `access_token` and `agent_secret_token` in a thread-safe Map.
162
+ 3. **Auto Refresh**: Refreshes tokens 60 seconds before expiration.
163
+
164
+ ## 🛡️ Error Handling
165
+
166
+ The client raises specific exceptions rooted in `TraylinxAuthClient::TraylinxAuthError`.
167
+
168
+ ```ruby
169
+ begin
170
+ client.make_a2a_request(:get, url)
171
+ rescue TraylinxAuthClient::AuthenticationError => e
172
+ puts "Auth failed: #{e.message}"
173
+ rescue TraylinxAuthClient::NetworkError => e
174
+ puts "Network error: #{e.message}"
175
+ end
176
+ ```
177
+
178
+ ## License
179
+
180
+ MIT License.
@@ -0,0 +1,377 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "faraday/retry"
5
+ require "concurrent"
6
+ require "json"
7
+ require "time"
8
+
9
+ require_relative "configuration"
10
+ require_relative "errors"
11
+
12
+ module TraylinxAuthClient
13
+ # Main client for interacting with Traylinx Sentinel Authentication.
14
+ # Handles token acquisition, management, and validation.
15
+ class Client
16
+ attr_reader :config
17
+
18
+ # Initializes the Client with configuration options.
19
+ #
20
+ # @param options [Hash, Configuration] Hash of options or Configuration object
21
+ # @option options [String] :client_id
22
+ # @option options [String] :client_secret
23
+ # @option options [String] :api_base_url
24
+ # @option options [String] :agent_user_id
25
+ def initialize(options = {})
26
+ @config = options.is_a?(Configuration) ? options : Configuration.new(options)
27
+
28
+ # Token storage
29
+ @tokens = Concurrent::Map.new
30
+ @tokens[:access_token] = nil
31
+ @tokens[:agent_secret_token] = nil
32
+ @tokens[:expires_at] = nil
33
+
34
+ # Mutex for token refresh synchronization
35
+ @refresh_mutex = Mutex.new
36
+
37
+ setup_connection
38
+ end
39
+
40
+ # Retrieves a valid Access Token (for Auth Service interaction).
41
+ # Refreshes the token if expired.
42
+ #
43
+ # @return [String] The JWT access token
44
+ # @raise [TokenExpiredError] if token cannot be obtained
45
+ def get_access_token
46
+ ensure_valid_tokens!
47
+ token = @tokens[:access_token]
48
+ raise TokenExpiredError, "Failed to obtain access token" unless token
49
+ token
50
+ end
51
+
52
+ # Retrieves a valid Agent Secret Token (for A2A communication).
53
+ # Refreshes the token if expired.
54
+ #
55
+ # @return [String] The agent secret token
56
+ # @raise [TokenExpiredError] if token cannot be obtained
57
+ def get_agent_secret_token
58
+ ensure_valid_tokens!
59
+ token = @tokens[:agent_secret_token]
60
+ raise TokenExpiredError, "Failed to obtain agent secret token" unless token
61
+ token
62
+ end
63
+
64
+ def get_request_headers
65
+ {
66
+ "Authorization" => "Bearer #{get_access_token}",
67
+ "X-Agent-Secret-Token" => get_agent_secret_token,
68
+ "X-Agent-User-Id" => @config.agent_user_id
69
+ }
70
+ end
71
+
72
+ def get_agent_request_headers
73
+ {
74
+ "X-Agent-Secret-Token" => get_agent_secret_token,
75
+ "X-Agent-User-Id" => @config.agent_user_id
76
+ }
77
+ end
78
+
79
+ def get_a2a_headers
80
+ {
81
+ "Authorization" => "Bearer #{get_agent_secret_token}",
82
+ "X-Agent-User-Id" => @config.agent_user_id
83
+ }
84
+ end
85
+
86
+ # Helper to perform a validated HTTP request to another agent.
87
+ # Automatically injects A2A headers (X-Agent-Secret-Token).
88
+ #
89
+ # @param method [Symbol] :get, :post, :put, :delete
90
+ # @param url [String] The full URL to call
91
+ # @param options [Hash] Request options
92
+ # @option options [Hash] :headers Additional headers
93
+ # @option options [Hash] :json JSON payload (for POST/PUT)
94
+ # @option options [Hash] :params Query parameters
95
+ # @option options [Integer] :timeout Request timeout
96
+ # @return [Hash, String] The response body (parsed JSON if applicable)
97
+ def make_a2a_request(method, url, options = {})
98
+ headers = get_agent_request_headers.merge(options[:headers] || {})
99
+
100
+ response = Faraday.new(url: url) do |f|
101
+ f.request :json
102
+ f.response :json
103
+ f.adapter Faraday.default_adapter
104
+ end.send(method.to_s.downcase) do |req|
105
+ req.headers = headers
106
+ req.body = options[:json] if options[:json]
107
+ req.params = options[:params] if options[:params]
108
+ req.options.timeout = options[:timeout] || @config.timeout
109
+ end
110
+
111
+ handle_response(response)
112
+ rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
113
+ raise NetworkError.new("Connection failed: #{e.message}", error_code: "CONNECTION_ERROR")
114
+ end
115
+
116
+ # Validates an Agent Secret Token against the Sentinel Introspection endpoint.
117
+ # Usage: When receiving a request from another agent.
118
+ #
119
+ # @param agent_secret_token [String] The token to validate
120
+ # @param agent_user_id [String] The agent ID claiming the token
121
+ # @return [Boolean] true if valid, false otherwise
122
+ def validate_token(agent_secret_token, agent_user_id)
123
+ # Validates against /oauth/agent/introspect using Access Token
124
+
125
+ headers = {
126
+ "Authorization" => "Bearer #{get_access_token}",
127
+ "Content-Type" => "application/x-www-form-urlencoded"
128
+ }
129
+
130
+ data = {
131
+ agent_secret_token: agent_secret_token,
132
+ agent_user_id: agent_user_id
133
+ }
134
+
135
+ response = @connection.post("oauth/agent/introspect") do |req|
136
+ req.headers = headers
137
+ req.body = data # Let middleware handle encoding
138
+ req.options.timeout = @config.timeout
139
+ end
140
+
141
+ if response.status == 200
142
+ body = response.body.is_a?(String) ? (JSON.parse(response.body) rescue {}) : response.body
143
+ return body["active"] == true
144
+ elsif response.status == 401
145
+ raise AuthenticationError.new("Access token invalid for token validation", status_code: 401)
146
+ else
147
+ handle_response(response) # Will raise appropriate error
148
+ end
149
+ rescue Faraday::Error => e
150
+ raise NetworkError.new("Token validation failed: #{e.message}")
151
+ end
152
+
153
+ def detect_auth_mode(headers)
154
+ # Normalize headers keys to downcase
155
+ headers = headers.transform_keys { |k| k.to_s.downcase }
156
+
157
+ if headers["authorization"]&.start_with?("Bearer ")
158
+ "bearer"
159
+ elsif headers["x-agent-secret-token"]
160
+ "custom"
161
+ else
162
+ "none"
163
+ end
164
+ end
165
+
166
+ def validate_a2a_request(headers)
167
+ headers = headers.transform_keys { |k| k.to_s.downcase }
168
+
169
+ # 1. Try Bearer token
170
+ if headers["authorization"]&.start_with?("Bearer ")
171
+ token = headers["authorization"].split(" ").last
172
+ agent_id = headers["x-agent-user-id"]
173
+ return validate_token(token, agent_id) if token && agent_id
174
+ end
175
+
176
+ # 2. Try Custom Header
177
+ token = headers["x-agent-secret-token"]
178
+ agent_id = headers["x-agent-user-id"]
179
+
180
+ if token && agent_id
181
+ return validate_token(token, agent_id)
182
+ end
183
+
184
+ false
185
+ end
186
+
187
+ # =========================================================================
188
+ # Stargate P2P Identity Methods
189
+ # =========================================================================
190
+
191
+ def get_p2p_challenge(peer_id)
192
+ headers = {
193
+ "Authorization" => "Bearer #{get_access_token}",
194
+ "Content-Type" => "application/json"
195
+ }
196
+
197
+ response = @connection.get("a2a/p2p/challenge") do |req|
198
+ req.params = { peer_id: peer_id }
199
+ req.headers = headers
200
+ req.options.timeout = @config.timeout
201
+ end
202
+
203
+ body = handle_response(response)
204
+ body["challenge"]
205
+ rescue Faraday::Error => e
206
+ raise NetworkError.new("Failed to fetch P2P challenge: #{e.message}")
207
+ end
208
+
209
+ def certify_p2p_identity(peer_id, public_key, signature, challenge)
210
+ headers = {
211
+ "Authorization" => "Bearer #{get_access_token}",
212
+ "Content-Type" => "application/json"
213
+ }
214
+
215
+ payload = {
216
+ peer_id: peer_id,
217
+ public_key: public_key,
218
+ signature: signature,
219
+ challenge: challenge
220
+ }
221
+
222
+ response = @connection.post("a2a/p2p/certify") do |req|
223
+ req.headers = headers
224
+ req.body = payload.to_json
225
+ req.options.timeout = @config.timeout
226
+ end
227
+
228
+ body = handle_response(response)
229
+ {
230
+ certificate: body["certificate"],
231
+ expires_at: body["expires_at"]
232
+ }
233
+ rescue Faraday::Error => e
234
+ raise NetworkError.new("Failed to certify P2P identity: #{e.message}")
235
+ end
236
+
237
+ # Generic RPC call
238
+ def rpc_call(method, params, rpc_url: nil, include_agent_credentials: nil)
239
+ default_rpc_url = "#{@config.api_base_url.chomp('/')}/a2a/rpc"
240
+ url = rpc_url || default_rpc_url
241
+
242
+ # Auto-detect defaults matching Python SDK logic
243
+ if include_agent_credentials.nil?
244
+ # If URL is NOT the default Auth Service RPC URL, assume it's another agent => use agent creds
245
+ include_agent_credentials = (url != default_rpc_url)
246
+ end
247
+
248
+ headers = {
249
+ "Content-Type" => "application/json"
250
+ }
251
+
252
+ if include_agent_credentials
253
+ headers["X-Agent-Secret-Token"] = get_agent_secret_token
254
+ headers["X-Agent-User-Id"] = @config.agent_user_id
255
+ else
256
+ headers["Authorization"] = "Bearer #{get_access_token}"
257
+ end
258
+
259
+ payload = {
260
+ jsonrpc: "2.0",
261
+ method: method,
262
+ params: params,
263
+ id: SecureRandom.uuid
264
+ }
265
+
266
+ response = Faraday.new(url: url) do |f|
267
+ f.request :json
268
+ f.response :json
269
+ f.adapter Faraday.default_adapter
270
+ end.post do |req|
271
+ req.headers = headers
272
+ req.body = payload
273
+ req.options.timeout = @config.timeout
274
+ end
275
+
276
+ body = handle_response(response)
277
+
278
+ if body.is_a?(Hash) && body["error"]
279
+ raise TraylinxAuthError.new("RPC Error: #{body['error']['message']}", error_code: body['error']['code'])
280
+ end
281
+
282
+ body.is_a?(Hash) ? body["result"] : body
283
+ end
284
+
285
+ private
286
+
287
+ def setup_connection
288
+ base_url = @config.api_base_url
289
+ base_url = "#{base_url}/" unless base_url.end_with?("/")
290
+
291
+ @connection = Faraday.new(url: base_url) do |f|
292
+ f.request :json
293
+ f.request :url_encoded
294
+ f.response :json
295
+ f.request :retry, {
296
+ max: @config.max_retries,
297
+ interval: @config.retry_delay,
298
+ backoff_factor: 2,
299
+ retry_statuses: [429, 500, 502, 503, 504],
300
+ exceptions: [
301
+ Faraday::ConnectionFailed,
302
+ Faraday::TimeoutError,
303
+ Faraday::SSLError
304
+ ]
305
+ }
306
+ f.adapter Faraday.default_adapter
307
+ end
308
+ end
309
+
310
+ def ensure_valid_tokens!
311
+ # return if tokens are valid (and we cache tokens)
312
+ return if @config.cache_tokens && valid_tokens?
313
+
314
+ @refresh_mutex.synchronize do
315
+ # double check inside lock
316
+ return if @config.cache_tokens && valid_tokens?
317
+ refresh_tokens!
318
+ end
319
+ end
320
+
321
+ def valid_tokens?
322
+ return false unless @tokens[:access_token]
323
+ return false unless @tokens[:expires_at]
324
+
325
+ # Buffer of 60 seconds
326
+ Time.now.utc < (@tokens[:expires_at] - 60)
327
+ end
328
+
329
+ def refresh_tokens!
330
+ # Use relative path to ensure correct appending to base URL
331
+ response = @connection.post("oauth/token") do |req|
332
+ req.headers["Content-Type"] = "application/json"
333
+ req.body = {
334
+ grant_type: "client_credentials",
335
+ client_id: @config.client_id,
336
+ client_secret: @config.client_secret,
337
+ scope: "a2a"
338
+ }
339
+ end
340
+
341
+ if response.status == 200
342
+ data = response.body
343
+ # data keys might be strings or symbols depending on middleware
344
+ # Faraday middleware response :json parses with string keys by default usually?
345
+ # Actually standard practice is string keys unless configged
346
+
347
+ # Safe access
348
+ data = data.transform_keys(&:to_s)
349
+
350
+ @tokens[:access_token] = data["access_token"]
351
+ @tokens[:agent_secret_token] = data["agent_secret_token"]
352
+
353
+ expires_in = data["expires_in"].to_i
354
+ @tokens[:expires_at] = Time.now.utc + expires_in
355
+ else
356
+ raise AuthenticationError.new(
357
+ "Valid credentials failed: #{response.body}",
358
+ status_code: response.status
359
+ )
360
+ end
361
+ rescue Faraday::Error => e
362
+ raise NetworkError.new("Network error during token refresh: #{e.message}")
363
+ end
364
+
365
+ def handle_response(response)
366
+ if response.success?
367
+ response.body
368
+ elsif response.status == 401
369
+ raise AuthenticationError.new("Authentication failed", status_code: 401)
370
+ elsif response.status == 403
371
+ raise AuthenticationError.new("Permission denied", status_code: 403)
372
+ else
373
+ raise NetworkError.new("Request failed: #{response.status}", status_code: response.status)
374
+ end
375
+ end
376
+ end
377
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+
5
+ module TraylinxAuthClient
6
+ # Configuration class for the client
7
+ class Configuration
8
+ attr_accessor :client_id, :client_secret, :api_base_url, :agent_user_id,
9
+ :timeout, :max_retries, :retry_delay, :cache_tokens, :log_level
10
+
11
+ # Defaults matching Python/JS SDKs
12
+ DEFAULT_TIMEOUT = 30
13
+ DEFAULT_MAX_RETRIES = 3
14
+ DEFAULT_RETRY_DELAY = 1.0
15
+ DEFAULT_CACHE_TOKENS = true
16
+ DEFAULT_LOG_LEVEL = :info
17
+
18
+ def initialize(options = {})
19
+ @client_id = options[:client_id] || ENV["TRAYLINX_CLIENT_ID"]
20
+ @client_secret = options[:client_secret] || ENV["TRAYLINX_CLIENT_SECRET"]
21
+ @api_base_url = options[:api_base_url] || ENV["TRAYLINX_API_BASE_URL"]
22
+ @agent_user_id = options[:agent_user_id] || ENV["TRAYLINX_AGENT_USER_ID"]
23
+
24
+ @timeout = options[:timeout] || DEFAULT_TIMEOUT
25
+ @max_retries = options[:max_retries] || DEFAULT_MAX_RETRIES
26
+ @retry_delay = options[:retry_delay] || DEFAULT_RETRY_DELAY
27
+ @cache_tokens = options.key?(:cache_tokens) ? options[:cache_tokens] : DEFAULT_CACHE_TOKENS
28
+ @log_level = options[:log_level] || DEFAULT_LOG_LEVEL
29
+
30
+ validate!
31
+ end
32
+
33
+ def validate!
34
+ missing = []
35
+ missing << "client_id" unless @client_id
36
+ missing << "client_secret" unless @client_secret
37
+ missing << "api_base_url" unless @api_base_url
38
+
39
+ # agent_user_id is needed for A2A but maybe valid to init without it for Auth
40
+ # service checks? Following strict parity mostly requires it.
41
+ # For now, we'll mark it optional at init but required for A2A calls.
42
+
43
+ unless missing.empty?
44
+ raise ValidationError.new("Missing required configuration: #{missing.join(', ')}")
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TraylinxAuthClient
4
+ # Base error class for all TraylinxAuthClient errors
5
+ class TraylinxAuthError < StandardError
6
+ attr_reader :error_code, :status_code
7
+
8
+ def initialize(message, error_code: nil, status_code: nil)
9
+ super(message)
10
+ @error_code = error_code
11
+ @status_code = status_code
12
+ end
13
+ end
14
+
15
+ # Thrown for input validation failures
16
+ class ValidationError < TraylinxAuthError; end
17
+
18
+ # Thrown for authentication-related failures
19
+ class AuthenticationError < TraylinxAuthError; end
20
+
21
+ # Thrown when tokens are expired or unavailable
22
+ class TokenExpiredError < TraylinxAuthError; end
23
+
24
+ # Thrown for network-related issues
25
+ class NetworkError < TraylinxAuthError; end
26
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "client"
4
+
5
+ module TraylinxAuthClient
6
+ # Rack Middleware for protecting endpoints with A2A auth.
7
+ # Intercepts requests and validates Authentication headers.
8
+ class Middleware
9
+ # @param app [Object] The Rack application
10
+ # @param client [TraylinxAuthClient::Client, nil] Optional pre-configured client
11
+ # @param validation_path [Regexp, String, nil] Optional regex/string to match paths requiring auth
12
+ def initialize(app, client: nil, validation_path: nil)
13
+ @app = app
14
+ @client = client || Client.new
15
+ @validation_path = validation_path
16
+ end
17
+
18
+ # Handles the incoming Rack request.
19
+ # @param env [Hash] The Rack environment
20
+ def call(env)
21
+ # Only validate if no specific path filtering is set, or if path matches
22
+ # This is simplified. Real middleware often takes an optional block or regex.
23
+ # For now, we assume if mounted, it protects everything unless configured.
24
+
25
+ request = Rack::Request.new(env)
26
+
27
+ # If validation_path is provided, only apply to that path
28
+ if @validation_path && !request.path.match?(@validation_path)
29
+ return @app.call(env)
30
+ end
31
+
32
+ # 1. Construct headers map from Rack environment
33
+ headers = extract_headers(request)
34
+
35
+ # 2. Validate using the unified client logic
36
+ # This handles Bearer tokens and custom headers with correct priority
37
+ if @client.validate_a2a_request(headers)
38
+ @app.call(env)
39
+ else
40
+ unauthorized_response("Invalid authentication credentials")
41
+ end
42
+ rescue => e
43
+ # Log error if possible
44
+ unauthorized_response("Authentication error: #{e.message}")
45
+ end
46
+
47
+ private
48
+
49
+ def extract_headers(request)
50
+ # Rack stores headers as HTTP_UPPERCASE_NAME
51
+ # We just need to reconstruct the relevant ones or pass a predictable hash
52
+ # validate_a2a_request handles case-insensitivity, so we just pass keys.
53
+
54
+ {
55
+ "Authorization" => request.get_header("HTTP_AUTHORIZATION"),
56
+ "X-Agent-Secret-Token" => request.get_header("HTTP_X_AGENT_SECRET_TOKEN"),
57
+ "X-Agent-User-Id" => request.get_header("HTTP_X_AGENT_USER_ID")
58
+ }
59
+ end
60
+
61
+ def unauthorized_response(message)
62
+ [401, { "Content-Type" => "application/json" }, [{ error: message }.to_json]]
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TraylinxAuthClient
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "traylinx_auth_client/version"
4
+ require_relative "traylinx_auth_client/configuration"
5
+ require_relative "traylinx_auth_client/client"
6
+ require_relative "traylinx_auth_client/errors"
7
+ require_relative "traylinx_auth_client/middleware"
8
+
9
+ module TraylinxAuthClient
10
+ class << self
11
+ def configure
12
+ yield configuration
13
+ end
14
+
15
+ def configuration
16
+ @configuration ||= Configuration.new
17
+ end
18
+
19
+ def client
20
+ @client ||= Client.new(configuration)
21
+ end
22
+
23
+ # Reset client and configuration (useful for testing)
24
+ def reset!
25
+ @configuration = nil
26
+ @client = nil
27
+ end
28
+
29
+ # Proxy helper methods to the default client
30
+ def make_a2a_request(method, url, options = {})
31
+ client.make_a2a_request(method, url, options)
32
+ end
33
+ end
34
+ end
metadata ADDED
@@ -0,0 +1,194 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: traylinx_auth_client
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Traylinx Team
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-01-11 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: faraday
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.7'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.7'
27
+ - !ruby/object:Gem::Dependency
28
+ name: faraday-retry
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: concurrent-ruby
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.2'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.2'
55
+ - !ruby/object:Gem::Dependency
56
+ name: jwt
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '2.7'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '2.7'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rack
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '2.0'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '2.0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: bundler
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '2.0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '2.0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: rake
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '13.0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '13.0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: rspec
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '3.0'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '3.0'
125
+ - !ruby/object:Gem::Dependency
126
+ name: webmock
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - "~>"
130
+ - !ruby/object:Gem::Version
131
+ version: '3.18'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - "~>"
137
+ - !ruby/object:Gem::Version
138
+ version: '3.18'
139
+ - !ruby/object:Gem::Dependency
140
+ name: rack-test
141
+ requirement: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - "~>"
144
+ - !ruby/object:Gem::Version
145
+ version: '2.1'
146
+ type: :development
147
+ prerelease: false
148
+ version_requirements: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - "~>"
151
+ - !ruby/object:Gem::Version
152
+ version: '2.1'
153
+ description: A robust Ruby client for Traylinx Sentinel A2A authentication. Provides
154
+ secure token management, OAuth2 exchange, and Rack middleware.
155
+ email:
156
+ - dev@traylinx.com
157
+ executables: []
158
+ extensions: []
159
+ extra_rdoc_files: []
160
+ files:
161
+ - PUBLISHING.md
162
+ - README.md
163
+ - lib/traylinx_auth_client.rb
164
+ - lib/traylinx_auth_client/client.rb
165
+ - lib/traylinx_auth_client/configuration.rb
166
+ - lib/traylinx_auth_client/errors.rb
167
+ - lib/traylinx_auth_client/middleware.rb
168
+ - lib/traylinx_auth_client/version.rb
169
+ homepage: https://github.com/traylinx/traylinx-auth-client-ruby
170
+ licenses:
171
+ - MIT
172
+ metadata:
173
+ homepage_uri: https://github.com/traylinx/traylinx-auth-client-ruby
174
+ source_code_uri: https://github.com/traylinx/traylinx-auth-client-ruby
175
+ post_install_message:
176
+ rdoc_options: []
177
+ require_paths:
178
+ - lib
179
+ required_ruby_version: !ruby/object:Gem::Requirement
180
+ requirements:
181
+ - - ">="
182
+ - !ruby/object:Gem::Version
183
+ version: 2.7.0
184
+ required_rubygems_version: !ruby/object:Gem::Requirement
185
+ requirements:
186
+ - - ">="
187
+ - !ruby/object:Gem::Version
188
+ version: '0'
189
+ requirements: []
190
+ rubygems_version: 3.4.10
191
+ signing_key:
192
+ specification_version: 4
193
+ summary: Traylinx Sentinel Agent-to-Agent Authentication Client
194
+ test_files: []