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 +4 -4
- data/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +7 -0
- data/lib/agent_harness/authentication.rb +161 -165
- data/lib/agent_harness/version.rb +1 -1
- data/lib/agent_harness.rb +18 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c88e49fef781a9b839029456b728f0f0bc99f0678a507084e044d9583d6a1220
|
|
4
|
+
data.tar.gz: 55d80c399dca1371e95346ac51aa2b83523a6c18a075f325d1eedf6f75e2839f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4f2b742d3b20a8499cff4480e89e6d0f519626de0c752a8a837af33f2a3bacd65d1a4dfaff499846964c128c240176c1f70824d00e89cfc653bc1d1d4eb79ff5
|
|
7
|
+
data.tar.gz: 73d8251bc434bc664486759fa84f07e6b27eaa693bc16d98e48522b09b732c3053f3f5e3682aa25980d5cd7b622c247861a01d8ae2a4a50d40d3da9e589be9c8
|
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
|
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
|