agent-harness 0.26.0 → 0.27.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d5561c80c0a4ccab10925503a30bb20be2c24c929caa58b9d69a4aac2eb27aac
4
- data.tar.gz: aae45d0a30105fed84eb442bdbeaaea0c0bc2fc995997bec49bccbed61db4249
3
+ metadata.gz: c88e49fef781a9b839029456b728f0f0bc99f0678a507084e044d9583d6a1220
4
+ data.tar.gz: 55d80c399dca1371e95346ac51aa2b83523a6c18a075f325d1eedf6f75e2839f
5
5
  SHA512:
6
- metadata.gz: 00eb3ec6f37bbdda7bd430abf55eb09f4f6f79f86b565256c7b287cc2f0b9ef840f108b00a33ba84d457d689784772bb4d307191e08e0aae5941247739ceb651
7
- data.tar.gz: 51efc497bd800e2d2ef31886ddeb65c502242dee45cfb40947d35f904f76e9ad215bf57520a779bd42a69e1b04759242c37c6b30a6b98b5a75707c759cd66e2a
6
+ metadata.gz: 4f2b742d3b20a8499cff4480e89e6d0f519626de0c752a8a837af33f2a3bacd65d1a4dfaff499846964c128c240176c1f70824d00e89cfc653bc1d1d4eb79ff5
7
+ data.tar.gz: 73d8251bc434bc664486759fa84f07e6b27eaa693bc16d98e48522b09b732c3053f3f5e3682aa25980d5cd7b622c247861a01d8ae2a4a50d40d3da9e589be9c8
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.26.0"
2
+ ".": "0.27.0"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -6,6 +6,13 @@
6
6
  * **auth:** add provider-owned PKCE code-exchange API for Claude OAuth (`AgentHarness::Authentication.exchange_code`). Takes an authorization code plus PKCE verifier (and `redirect_uri`/`client_id`), posts an `authorization_code` grant to the Claude token endpoint, and persists the resulting access/refresh tokens in the native `claudeAiOauth` shape. Adds `exchange_code_supported?` and a `code_exchange` key to `auth_capabilities` ([#266](https://github.com/viamin/agent-harness/issues/266)).
7
7
 
8
8
 
9
+ ## [0.27.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.26.0...agent-harness/v0.27.0) (2026-06-27)
10
+
11
+
12
+ ### Features
13
+
14
+ * Authentication: Claude OAuth PKCE code-exchange API ([#267](https://github.com/viamin/agent-harness/issues/267)) ([7cabc73](https://github.com/viamin/agent-harness/commit/7cabc73f2a660e292533175e9db3a94d18326031))
15
+
9
16
  ## [0.26.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.25.0...agent-harness/v0.26.0) (2026-06-26)
10
17
 
11
18
 
@@ -51,9 +51,9 @@ module AgentHarness
51
51
  {
52
52
  auth_type: provider.auth_type,
53
53
  auth_url: flow_supported,
54
+ exchange_code: flow_supported,
54
55
  refresh: flow_supported,
55
- exchange: flow_supported,
56
- code_exchange: flow_supported
56
+ exchange: flow_supported
57
57
  }
58
58
  end
59
59
 
@@ -91,6 +91,45 @@ module AgentHarness
91
91
  end
92
92
  end
93
93
 
94
+ # Check whether PKCE code exchange is supported for a provider.
95
+ #
96
+ # @param provider_name [Symbol] the provider name
97
+ # @return [Boolean] true if exchange_code can be called for the provider
98
+ # @raise [ProviderNotFoundError] if provider is unknown
99
+ def exchange_code_supported?(provider_name)
100
+ auth_capabilities(provider_name)[:exchange_code]
101
+ end
102
+
103
+ # Exchange an OAuth authorization code for tokens using PKCE.
104
+ #
105
+ # Performs the code→token exchange with the provider's token endpoint
106
+ # and stores the resulting credentials in native shape.
107
+ #
108
+ # @param provider_name [Symbol] the provider name
109
+ # @param code [String] the authorization code from the OAuth redirect
110
+ # @param code_verifier [String] the PKCE code verifier used when generating the auth URL
111
+ # @return [Hash] result with :success and :credentials keys
112
+ # @raise [UnsupportedAuthFlowError] if provider doesn't support code exchange
113
+ # @raise [ArgumentError] if code or code_verifier are blank
114
+ # @raise [AuthenticationError] if the token exchange request fails
115
+ def exchange_code(provider_name, code:, code_verifier:)
116
+ provider_name = provider_name.to_sym
117
+ provider = resolve_provider(provider_name)
118
+
119
+ unless provider.auth_type == :oauth
120
+ raise UnsupportedAuthFlowError,
121
+ "Provider #{provider_name} uses #{provider.auth_type} auth and does not support PKCE code exchange"
122
+ end
123
+
124
+ case provider_name
125
+ when :claude, :anthropic
126
+ exchange_claude_code(code: code, code_verifier: code_verifier)
127
+ else
128
+ raise UnsupportedAuthFlowError,
129
+ "PKCE code exchange is not yet implemented for provider #{provider_name}"
130
+ end
131
+ end
132
+
94
133
  # Check whether credential refresh is supported for a provider.
95
134
  #
96
135
  # @param provider_name [Symbol] the provider name
@@ -173,74 +212,8 @@ module AgentHarness
173
212
  end
174
213
  end
175
214
 
176
- # Check whether PKCE authorization-code exchange is supported for a provider.
177
- #
178
- # @param provider_name [Symbol] the provider name
179
- # @return [Boolean] true if exchange_code can be called
180
- # @raise [ProviderNotFoundError] if provider is unknown
181
- def exchange_code_supported?(provider_name)
182
- auth_capabilities(provider_name)[:code_exchange]
183
- end
184
-
185
- # Exchange a PKCE authorization code for tokens and persist them in native shape.
186
- #
187
- # Posts the authorization code and PKCE verifier to the provider's OAuth
188
- # token endpoint, then writes the resulting access/refresh tokens to the
189
- # credentials store using the provider's native shape (e.g. +claudeAiOauth+
190
- # for Claude).
191
- #
192
- # Serializes through a file lock so that concurrent callers do not race on
193
- # credential writes.
194
- #
195
- # @param provider_name [Symbol] the provider name
196
- # @param code [String] authorization code returned from the OAuth redirect (required)
197
- # @param code_verifier [String] PKCE code verifier matching the code_challenge sent on the auth URL (required)
198
- # @param redirect_uri [String] redirect URI registered with the authorization request (required)
199
- # @param client_id [String] OAuth client identifier (required)
200
- # @param state [String, nil] optional state parameter echoed from the authorization request
201
- # @return [Hash] credential in claudeAiOauth shape for Claude:
202
- # +{ claudeAiOauth: { accessToken:, refreshToken:, expiresAt: } }+
203
- # @raise [UnsupportedAuthFlowError] if provider doesn't support PKCE code exchange
204
- # @raise [ArgumentError] if any required parameter is blank
205
- # @raise [AuthenticationError] if the exchange fails
206
- def exchange_code(provider_name, code:, code_verifier:, redirect_uri:, client_id:, state: nil)
207
- provider_name = provider_name.to_sym
208
- provider = resolve_provider(provider_name)
209
-
210
- unless provider.auth_type == :oauth
211
- raise UnsupportedAuthFlowError,
212
- "Provider #{provider_name} uses #{provider.auth_type} auth and does not support code exchange"
213
- end
214
-
215
- validate_code_exchange_params!(code: code, code_verifier: code_verifier,
216
- redirect_uri: redirect_uri, client_id: client_id)
217
-
218
- case provider_name
219
- when :claude, :anthropic
220
- exchange_claude_code(
221
- code: code.strip,
222
- code_verifier: code_verifier.strip,
223
- redirect_uri: redirect_uri.strip,
224
- client_id: client_id.strip,
225
- state: state
226
- )
227
- else
228
- raise UnsupportedAuthFlowError,
229
- "Code exchange is not yet implemented for provider #{provider_name}"
230
- end
231
- end
232
-
233
215
  private
234
216
 
235
- def validate_code_exchange_params!(code:, code_verifier:, redirect_uri:, client_id:)
236
- {code: code, code_verifier: code_verifier,
237
- redirect_uri: redirect_uri, client_id: client_id}.each do |name, value|
238
- unless non_blank?(value)
239
- raise ArgumentError, "#{name} must be a non-empty string"
240
- end
241
- end
242
- end
243
-
244
217
  def claude_oauth_flow_provider?(requested_name, canonical_name)
245
218
  [:claude, :anthropic].include?(requested_name) || canonical_name == :claude
246
219
  end
@@ -312,6 +285,126 @@ module AgentHarness
312
285
  "https://claude.ai/oauth/authorize"
313
286
  end
314
287
 
288
+ def claude_token_url
289
+ "https://claude.ai/oauth/token"
290
+ end
291
+
292
+ # Public OAuth client_id for the Claude Code CLI. This value is the
293
+ # well-known public client identifier used by the Claude Code CLI's
294
+ # PKCE login flow; callers building the auth_url must use the same
295
+ # client_id so the token exchange succeeds.
296
+ def claude_oauth_client_id
297
+ "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
298
+ end
299
+
300
+ # Redirect URI registered for the Claude Code CLI OAuth client. Must
301
+ # match the redirect_uri used in the authorization request — per RFC
302
+ # 6749 §4.1.3 the token endpoint validates that they are identical.
303
+ def claude_oauth_redirect_uri
304
+ "https://console.anthropic.com/oauth/code/callback"
305
+ end
306
+
307
+ def exchange_claude_code(code:, code_verifier:)
308
+ raise ArgumentError, "code must be a non-empty string" unless code.is_a?(String) && !code.strip.empty?
309
+ raise ArgumentError, "code_verifier must be a non-empty string" unless code_verifier.is_a?(String) && !code_verifier.strip.empty?
310
+
311
+ uri = URI.parse(claude_token_url)
312
+ request = Net::HTTP::Post.new(uri)
313
+ request.content_type = "application/json"
314
+ # Include client_id and redirect_uri per RFC 6749 §4.1.3: the token
315
+ # endpoint requires client_id for public clients and redirect_uri
316
+ # when one was sent in the authorization request. Callers using
317
+ # claude_auth_url are expected to build the authorization request
318
+ # with these same values.
319
+ request.body = JSON.generate({
320
+ grant_type: "authorization_code",
321
+ client_id: claude_oauth_client_id,
322
+ redirect_uri: claude_oauth_redirect_uri,
323
+ code: code.strip,
324
+ code_verifier: code_verifier.strip
325
+ })
326
+
327
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
328
+ http.open_timeout = 10
329
+ http.read_timeout = 30
330
+ http.request(request)
331
+ end
332
+
333
+ unless response.is_a?(Net::HTTPSuccess)
334
+ error_body = begin
335
+ JSON.parse(response.body)
336
+ rescue JSON::ParserError
337
+ {"error" => response.body}
338
+ end
339
+ raise AuthenticationError.new(
340
+ "PKCE code exchange failed (HTTP #{response.code}): #{error_body["error"] || error_body["error_description"] || response.body}",
341
+ provider: :claude
342
+ )
343
+ end
344
+
345
+ token_data = JSON.parse(response.body)
346
+
347
+ unless token_data["access_token"].is_a?(String) && !token_data["access_token"].strip.empty?
348
+ raise AuthenticationError.new(
349
+ "PKCE code exchange returned no access_token",
350
+ provider: :claude
351
+ )
352
+ end
353
+
354
+ store_claude_token_response(token_data)
355
+ end
356
+
357
+ def store_claude_token_response(token_data)
358
+ credentials_path = claude_credentials_path
359
+ dir = File.dirname(credentials_path)
360
+ FileUtils.mkdir_p(dir, mode: 0o700)
361
+
362
+ lock_path = "#{credentials_path}.lock"
363
+ File.open(lock_path, File::RDWR | File::CREAT, 0o600) do |lock|
364
+ lock.flock(File::LOCK_EX)
365
+
366
+ credentials = read_claude_credentials
367
+ credentials = {} unless credentials.is_a?(Hash)
368
+
369
+ if credentials.key?("claudeAiOauth")
370
+ # Preserve the native claudeAiOauth shape so extract_claude_token
371
+ # picks up the newly exchanged token instead of a stale nested value.
372
+ oauth = credentials["claudeAiOauth"]
373
+ oauth = {} unless oauth.is_a?(Hash)
374
+ oauth["accessToken"] = token_data["access_token"]
375
+ oauth["refreshToken"] = token_data["refresh_token"] if token_data["refresh_token"]
376
+ if token_data["expires_in"]
377
+ oauth["expiresAt"] = (Time.now + token_data["expires_in"].to_i).iso8601
378
+ else
379
+ oauth.delete("expiresAt")
380
+ end
381
+ credentials["claudeAiOauth"] = oauth
382
+ else
383
+ credentials["oauth_token"] = token_data["access_token"]
384
+ credentials["refreshToken"] = token_data["refresh_token"] if token_data["refresh_token"]
385
+ if token_data["expires_in"]
386
+ credentials["expiresAt"] = (Time.now + token_data["expires_in"].to_i).iso8601
387
+ else
388
+ credentials.delete("expiresAt")
389
+ credentials.delete("expires_at")
390
+ end
391
+ end
392
+
393
+ tmpfile = Tempfile.new(".credentials", dir)
394
+ begin
395
+ tmpfile.write(JSON.pretty_generate(credentials))
396
+ tmpfile.close
397
+ File.chmod(0o600, tmpfile.path)
398
+ File.rename(tmpfile.path, credentials_path)
399
+ rescue
400
+ tmpfile.close!
401
+ raise
402
+ end
403
+ end
404
+
405
+ {success: true, credentials: token_data}
406
+ end
407
+
315
408
  def provider_config_for(requested_name, canonical_name:)
316
409
  requested_key = requested_name.to_sym
317
410
  canonical_key = canonical_name.to_sym
@@ -423,103 +516,6 @@ module AgentHarness
423
516
  end
424
517
  end
425
518
 
426
- def exchange_claude_code(code:, code_verifier:, redirect_uri:, client_id:, state:)
427
- credentials_path = claude_credentials_path
428
- lock_path = "#{credentials_path}.lock"
429
-
430
- dir = File.dirname(credentials_path)
431
- FileUtils.mkdir_p(dir, mode: 0o700)
432
-
433
- File.open(lock_path, File::RDWR | File::CREAT, 0o600) do |lock|
434
- lock.flock(File::LOCK_EX)
435
-
436
- response_body = post_code_exchange(
437
- code: code,
438
- code_verifier: code_verifier,
439
- redirect_uri: redirect_uri,
440
- client_id: client_id,
441
- state: state
442
- )
443
-
444
- access_token = response_body["access_token"]
445
- new_refresh_token = response_body["refresh_token"]
446
- expires_in = response_body["expires_in"]
447
-
448
- unless non_blank?(access_token)
449
- raise AuthenticationError.new(
450
- "Code exchange response did not include an access_token",
451
- provider: :claude
452
- )
453
- end
454
-
455
- expires_at = expires_in ? (Time.now + expires_in).iso8601 : nil
456
-
457
- oauth_block = {
458
- "accessToken" => access_token,
459
- "refreshToken" => new_refresh_token,
460
- "expiresAt" => expires_at
461
- }
462
-
463
- credentials = read_claude_credentials
464
- credentials = {} unless credentials.is_a?(Hash)
465
- credentials["claudeAiOauth"] = oauth_block
466
- credentials["oauth_token"] = access_token
467
- credentials.delete("expires_at")
468
- credentials["expiresAt"] = expires_at
469
-
470
- tmpfile = Tempfile.new(".credentials", dir)
471
- begin
472
- tmpfile.write(JSON.pretty_generate(credentials))
473
- tmpfile.close
474
- File.chmod(0o600, tmpfile.path)
475
- File.rename(tmpfile.path, credentials_path)
476
- rescue
477
- tmpfile.close!
478
- raise
479
- end
480
-
481
- {claudeAiOauth: oauth_block.transform_keys(&:to_sym)}
482
- end
483
- end
484
-
485
- def post_code_exchange(code:, code_verifier:, redirect_uri:, client_id:, state:)
486
- payload = {
487
- grant_type: "authorization_code",
488
- code: code,
489
- code_verifier: code_verifier,
490
- redirect_uri: redirect_uri,
491
- client_id: client_id
492
- }
493
- payload[:state] = state if non_blank?(state)
494
-
495
- http = Net::HTTP.new(CLAUDE_TOKEN_ENDPOINT.host, CLAUDE_TOKEN_ENDPOINT.port)
496
- http.use_ssl = true
497
- http.open_timeout = 10
498
- http.read_timeout = 10
499
-
500
- request = Net::HTTP::Post.new(CLAUDE_TOKEN_ENDPOINT.path)
501
- request["Content-Type"] = "application/json"
502
- request.body = JSON.generate(payload)
503
-
504
- response = http.request(request)
505
-
506
- body = begin
507
- JSON.parse(response.body)
508
- rescue JSON::ParserError
509
- {}
510
- end
511
-
512
- unless response.is_a?(Net::HTTPSuccess)
513
- error_description = body["error_description"] || body["error"] || body["message"] || response.body
514
- raise AuthenticationError.new(
515
- "Code exchange failed (HTTP #{response.code}): #{error_description}",
516
- provider: :claude
517
- )
518
- end
519
-
520
- body
521
- end
522
-
523
519
  def post_token_exchange(refresh_token)
524
520
  http = Net::HTTP.new(CLAUDE_TOKEN_ENDPOINT.host, CLAUDE_TOKEN_ENDPOINT.port)
525
521
  http.use_ssl = true
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AgentHarness
4
- VERSION = "0.26.0"
4
+ VERSION = "0.27.0"
5
5
  end
data/lib/agent_harness.rb CHANGED
@@ -322,6 +322,24 @@ module AgentHarness
322
322
  Authentication.auth_url(provider_name)
323
323
  end
324
324
 
325
+ # Check whether PKCE code exchange is supported for a provider
326
+ # @param provider_name [Symbol] the provider name
327
+ # @return [Boolean] true if exchange_code can be called for the provider
328
+ # @raise [ProviderNotFoundError] if provider is unknown
329
+ def exchange_code_supported?(provider_name)
330
+ Authentication.exchange_code_supported?(provider_name)
331
+ end
332
+
333
+ # Exchange an OAuth authorization code for tokens using PKCE
334
+ # @param provider_name [Symbol] the provider name
335
+ # @param code [String] the authorization code
336
+ # @param code_verifier [String] the PKCE code verifier
337
+ # @return [Hash] result with :success and :credentials keys
338
+ # @raise [UnsupportedAuthFlowError] if provider doesn't support code exchange
339
+ def exchange_code(provider_name, code:, code_verifier:)
340
+ Authentication.exchange_code(provider_name, code: code, code_verifier: code_verifier)
341
+ end
342
+
325
343
  # Check whether credential refresh is supported for a provider
326
344
  # @param provider_name [Symbol] the provider name
327
345
  # @return [Boolean] true if refresh_auth can be called for the provider
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: agent-harness
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.26.0
4
+ version: 0.27.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bart Agapinan