basecamp-sdk 0.15.0 → 0.17.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/lib/basecamp/api_error.rb +3 -2
- data/lib/basecamp/client.rb +21 -3
- data/lib/basecamp/generated/metadata.json +156 -1
- data/lib/basecamp/generated/services/bubble_ups_service.rb +36 -0
- data/lib/basecamp/generated/services/people_service.rb +30 -0
- data/lib/basecamp/generated/services/projects_service.rb +18 -0
- data/lib/basecamp/generated/services/recordings_service.rb +19 -0
- data/lib/basecamp/generated/services/templates_service.rb +28 -0
- data/lib/basecamp/generated/types.rb +207 -2
- data/lib/basecamp/http.rb +55 -23
- data/lib/basecamp/oauth/exchange.rb +152 -25
- data/lib/basecamp/oauth/fetcher.rb +1 -1
- data/lib/basecamp/oauth_token_provider.rb +50 -13
- data/lib/basecamp/people_confirmation_required_error.rb +14 -0
- data/lib/basecamp/rate_limit_error.rb +4 -2
- data/lib/basecamp/security.rb +20 -0
- data/lib/basecamp/version.rb +2 -2
- data/lib/basecamp.rb +95 -12
- data/scripts/generate-services.rb +7 -1
- metadata +4 -2
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
# Auto-generated from OpenAPI spec. Do not edit manually.
|
|
4
|
-
# Generated: 2026-
|
|
4
|
+
# Generated: 2026-09-08T23:57:39Z
|
|
5
5
|
|
|
6
6
|
require "json"
|
|
7
7
|
require "time"
|
|
@@ -1135,6 +1135,58 @@ module Basecamp
|
|
|
1135
1135
|
end
|
|
1136
1136
|
end
|
|
1137
1137
|
|
|
1138
|
+
# ClientInvitationError
|
|
1139
|
+
class ClientInvitationError
|
|
1140
|
+
include TypeHelpers
|
|
1141
|
+
attr_accessor :email_address, :messages
|
|
1142
|
+
|
|
1143
|
+
# @return [Array<Symbol>]
|
|
1144
|
+
def self.required_fields
|
|
1145
|
+
%i[email_address messages].freeze
|
|
1146
|
+
end
|
|
1147
|
+
|
|
1148
|
+
def initialize(data = {})
|
|
1149
|
+
@email_address = data["email_address"]
|
|
1150
|
+
@messages = data["messages"]
|
|
1151
|
+
end
|
|
1152
|
+
|
|
1153
|
+
def to_h
|
|
1154
|
+
{
|
|
1155
|
+
"email_address" => @email_address,
|
|
1156
|
+
"messages" => @messages,
|
|
1157
|
+
}.reject { |k, v| v.nil? && !["email_address"].include?(k) }
|
|
1158
|
+
end
|
|
1159
|
+
|
|
1160
|
+
def to_json(*args)
|
|
1161
|
+
to_h.to_json(*args)
|
|
1162
|
+
end
|
|
1163
|
+
end
|
|
1164
|
+
|
|
1165
|
+
# ClientInvitationErrors
|
|
1166
|
+
class ClientInvitationErrors
|
|
1167
|
+
include TypeHelpers
|
|
1168
|
+
attr_accessor :errors
|
|
1169
|
+
|
|
1170
|
+
# @return [Array<Symbol>]
|
|
1171
|
+
def self.required_fields
|
|
1172
|
+
%i[errors].freeze
|
|
1173
|
+
end
|
|
1174
|
+
|
|
1175
|
+
def initialize(data = {})
|
|
1176
|
+
@errors = parse_array(data["errors"], "ClientInvitationError")
|
|
1177
|
+
end
|
|
1178
|
+
|
|
1179
|
+
def to_h
|
|
1180
|
+
{
|
|
1181
|
+
"errors" => @errors,
|
|
1182
|
+
}.compact
|
|
1183
|
+
end
|
|
1184
|
+
|
|
1185
|
+
def to_json(*args)
|
|
1186
|
+
to_h.to_json(*args)
|
|
1187
|
+
end
|
|
1188
|
+
end
|
|
1189
|
+
|
|
1138
1190
|
# ClientReply
|
|
1139
1191
|
class ClientReply
|
|
1140
1192
|
include TypeHelpers
|
|
@@ -1374,6 +1426,37 @@ module Basecamp
|
|
|
1374
1426
|
end
|
|
1375
1427
|
end
|
|
1376
1428
|
|
|
1429
|
+
# CreateClientRequest
|
|
1430
|
+
class CreateClientRequest
|
|
1431
|
+
include TypeHelpers
|
|
1432
|
+
attr_accessor :email_address, :company_name, :name, :title
|
|
1433
|
+
|
|
1434
|
+
# @return [Array<Symbol>]
|
|
1435
|
+
def self.required_fields
|
|
1436
|
+
%i[email_address].freeze
|
|
1437
|
+
end
|
|
1438
|
+
|
|
1439
|
+
def initialize(data = {})
|
|
1440
|
+
@email_address = data["email_address"]
|
|
1441
|
+
@company_name = data["company_name"]
|
|
1442
|
+
@name = data["name"]
|
|
1443
|
+
@title = data["title"]
|
|
1444
|
+
end
|
|
1445
|
+
|
|
1446
|
+
def to_h
|
|
1447
|
+
{
|
|
1448
|
+
"email_address" => @email_address,
|
|
1449
|
+
"company_name" => @company_name,
|
|
1450
|
+
"name" => @name,
|
|
1451
|
+
"title" => @title,
|
|
1452
|
+
}.compact
|
|
1453
|
+
end
|
|
1454
|
+
|
|
1455
|
+
def to_json(*args)
|
|
1456
|
+
to_h.to_json(*args)
|
|
1457
|
+
end
|
|
1458
|
+
end
|
|
1459
|
+
|
|
1377
1460
|
# CreatePersonRequest
|
|
1378
1461
|
class CreatePersonRequest
|
|
1379
1462
|
include TypeHelpers
|
|
@@ -3169,7 +3252,7 @@ module Basecamp
|
|
|
3169
3252
|
include TypeHelpers
|
|
3170
3253
|
# @!attribute [rw] clientside
|
|
3171
3254
|
# @deprecated This shape is deprecated since 2024-01: Use Client Visibility feature instead
|
|
3172
|
-
attr_accessor :app_url, :created_at, :id, :name, :status, :updated_at, :url, :bookmark_url, :bookmarked, :client_company, :clients_enabled, :clientside, :description, :dock, :end_date, :purpose, :start_date
|
|
3255
|
+
attr_accessor :app_url, :created_at, :id, :name, :status, :updated_at, :url, :bookmark_url, :bookmarked, :client_company, :clients_enabled, :clientside, :description, :dock, :end_date, :purpose, :star_url, :starred, :start_date
|
|
3173
3256
|
|
|
3174
3257
|
# @return [Array<Symbol>]
|
|
3175
3258
|
def self.required_fields
|
|
@@ -3193,6 +3276,8 @@ module Basecamp
|
|
|
3193
3276
|
@dock = parse_array(data["dock"], "DockItem")
|
|
3194
3277
|
@end_date = data["end_date"]
|
|
3195
3278
|
@purpose = data["purpose"]
|
|
3279
|
+
@star_url = data["star_url"]
|
|
3280
|
+
@starred = parse_boolean(data["starred"])
|
|
3196
3281
|
@start_date = data["start_date"]
|
|
3197
3282
|
end
|
|
3198
3283
|
|
|
@@ -3214,6 +3299,8 @@ module Basecamp
|
|
|
3214
3299
|
"dock" => @dock,
|
|
3215
3300
|
"end_date" => @end_date,
|
|
3216
3301
|
"purpose" => @purpose,
|
|
3302
|
+
"star_url" => @star_url,
|
|
3303
|
+
"starred" => @starred,
|
|
3217
3304
|
"start_date" => @start_date,
|
|
3218
3305
|
}.compact
|
|
3219
3306
|
end
|
|
@@ -3245,6 +3332,31 @@ module Basecamp
|
|
|
3245
3332
|
end
|
|
3246
3333
|
end
|
|
3247
3334
|
|
|
3335
|
+
# ProjectClientEnablement
|
|
3336
|
+
class ProjectClientEnablement
|
|
3337
|
+
include TypeHelpers
|
|
3338
|
+
attr_accessor :clients_enabled
|
|
3339
|
+
|
|
3340
|
+
# @return [Array<Symbol>]
|
|
3341
|
+
def self.required_fields
|
|
3342
|
+
%i[clients_enabled].freeze
|
|
3343
|
+
end
|
|
3344
|
+
|
|
3345
|
+
def initialize(data = {})
|
|
3346
|
+
@clients_enabled = parse_boolean(data["clients_enabled"])
|
|
3347
|
+
end
|
|
3348
|
+
|
|
3349
|
+
def to_h
|
|
3350
|
+
{
|
|
3351
|
+
"clients_enabled" => @clients_enabled,
|
|
3352
|
+
}.compact
|
|
3353
|
+
end
|
|
3354
|
+
|
|
3355
|
+
def to_json(*args)
|
|
3356
|
+
to_h.to_json(*args)
|
|
3357
|
+
end
|
|
3358
|
+
end
|
|
3359
|
+
|
|
3248
3360
|
# ProjectConstruction
|
|
3249
3361
|
class ProjectConstruction
|
|
3250
3362
|
include TypeHelpers
|
|
@@ -4289,6 +4401,99 @@ module Basecamp
|
|
|
4289
4401
|
end
|
|
4290
4402
|
end
|
|
4291
4403
|
|
|
4404
|
+
# TemplateLibrary
|
|
4405
|
+
class TemplateLibrary
|
|
4406
|
+
include TypeHelpers
|
|
4407
|
+
attr_accessor :bucket, :todolists, :todoset
|
|
4408
|
+
|
|
4409
|
+
# @return [Array<Symbol>]
|
|
4410
|
+
def self.required_fields
|
|
4411
|
+
%i[bucket todolists todoset].freeze
|
|
4412
|
+
end
|
|
4413
|
+
|
|
4414
|
+
def initialize(data = {})
|
|
4415
|
+
@bucket = parse_type(data["bucket"], "RecordingBucket")
|
|
4416
|
+
@todolists = parse_array(data["todolists"], "Todolist")
|
|
4417
|
+
@todoset = parse_type(data["todoset"], "RecordingParent")
|
|
4418
|
+
end
|
|
4419
|
+
|
|
4420
|
+
def to_h
|
|
4421
|
+
{
|
|
4422
|
+
"bucket" => @bucket,
|
|
4423
|
+
"todolists" => @todolists,
|
|
4424
|
+
"todoset" => @todoset,
|
|
4425
|
+
}.compact
|
|
4426
|
+
end
|
|
4427
|
+
|
|
4428
|
+
def to_json(*args)
|
|
4429
|
+
to_h.to_json(*args)
|
|
4430
|
+
end
|
|
4431
|
+
end
|
|
4432
|
+
|
|
4433
|
+
# TemplateLibraryConfirmationPerson
|
|
4434
|
+
class TemplateLibraryConfirmationPerson
|
|
4435
|
+
include TypeHelpers
|
|
4436
|
+
attr_accessor :avatar_url, :id, :name
|
|
4437
|
+
|
|
4438
|
+
# @return [Array<Symbol>]
|
|
4439
|
+
def self.required_fields
|
|
4440
|
+
%i[avatar_url id name].freeze
|
|
4441
|
+
end
|
|
4442
|
+
|
|
4443
|
+
def initialize(data = {})
|
|
4444
|
+
@avatar_url = data["avatar_url"]
|
|
4445
|
+
@id = parse_integer(data["id"])
|
|
4446
|
+
@name = data["name"]
|
|
4447
|
+
end
|
|
4448
|
+
|
|
4449
|
+
def to_h
|
|
4450
|
+
{
|
|
4451
|
+
"avatar_url" => @avatar_url,
|
|
4452
|
+
"id" => @id,
|
|
4453
|
+
"name" => @name,
|
|
4454
|
+
}.compact
|
|
4455
|
+
end
|
|
4456
|
+
|
|
4457
|
+
def to_json(*args)
|
|
4458
|
+
to_h.to_json(*args)
|
|
4459
|
+
end
|
|
4460
|
+
end
|
|
4461
|
+
|
|
4462
|
+
# TemplateLibraryCopy
|
|
4463
|
+
class TemplateLibraryCopy
|
|
4464
|
+
include TypeHelpers
|
|
4465
|
+
attr_accessor :destination_parent_id, :id, :source_recording_id, :status, :url, :destination_todolist
|
|
4466
|
+
|
|
4467
|
+
# @return [Array<Symbol>]
|
|
4468
|
+
def self.required_fields
|
|
4469
|
+
%i[destination_parent_id id source_recording_id status url].freeze
|
|
4470
|
+
end
|
|
4471
|
+
|
|
4472
|
+
def initialize(data = {})
|
|
4473
|
+
@destination_parent_id = parse_integer(data["destination_parent_id"])
|
|
4474
|
+
@id = parse_integer(data["id"])
|
|
4475
|
+
@source_recording_id = parse_integer(data["source_recording_id"])
|
|
4476
|
+
@status = data["status"]
|
|
4477
|
+
@url = data["url"]
|
|
4478
|
+
@destination_todolist = parse_type(data["destination_todolist"], "Todolist")
|
|
4479
|
+
end
|
|
4480
|
+
|
|
4481
|
+
def to_h
|
|
4482
|
+
{
|
|
4483
|
+
"destination_parent_id" => @destination_parent_id,
|
|
4484
|
+
"id" => @id,
|
|
4485
|
+
"source_recording_id" => @source_recording_id,
|
|
4486
|
+
"status" => @status,
|
|
4487
|
+
"url" => @url,
|
|
4488
|
+
"destination_todolist" => @destination_todolist,
|
|
4489
|
+
}.compact
|
|
4490
|
+
end
|
|
4491
|
+
|
|
4492
|
+
def to_json(*args)
|
|
4493
|
+
to_h.to_json(*args)
|
|
4494
|
+
end
|
|
4495
|
+
end
|
|
4496
|
+
|
|
4292
4497
|
# TimelineAttachment
|
|
4293
4498
|
class TimelineAttachment
|
|
4294
4499
|
include TypeHelpers
|
data/lib/basecamp/http.rb
CHANGED
|
@@ -176,7 +176,11 @@ module Basecamp
|
|
|
176
176
|
# @param url [String] absolute URL
|
|
177
177
|
# @return [Response]
|
|
178
178
|
def get_download(url)
|
|
179
|
-
|
|
179
|
+
# download: true projects this flow's hooks and transport errors (SPEC
|
|
180
|
+
# §9): the caller's URL can smuggle a signed query through the rewrite
|
|
181
|
+
# into hop 1, and a transport error renders the URL it failed on. The
|
|
182
|
+
# wire request keeps the query; only the renderings are projected.
|
|
183
|
+
request_with_retry(:get, url, retry_on: DOWNLOAD_RETRY_ON, accept: nil, download: true)
|
|
180
184
|
end
|
|
181
185
|
|
|
182
186
|
# Fetches all pages of a paginated resource.
|
|
@@ -396,7 +400,7 @@ module Basecamp
|
|
|
396
400
|
end
|
|
397
401
|
|
|
398
402
|
def request_with_retry(method, url, params: {}, allow_cross_origin: false, operation: nil, retry_on: nil,
|
|
399
|
-
accept: "application/json")
|
|
403
|
+
accept: "application/json", download: false)
|
|
400
404
|
op_retry = operation && Http.operation_retry(operation)
|
|
401
405
|
# The cap is floored at one attempt on every path: whether a request
|
|
402
406
|
# reaches the wire at all must not depend on whether the operation
|
|
@@ -422,7 +426,7 @@ module Basecamp
|
|
|
422
426
|
|
|
423
427
|
begin
|
|
424
428
|
return single_request(method, url, params: params, body: nil, attempt: attempt,
|
|
425
|
-
allow_cross_origin: allow_cross_origin, accept: accept, refresh_replay: false)
|
|
429
|
+
allow_cross_origin: allow_cross_origin, accept: accept, refresh_replay: false, download: download)
|
|
426
430
|
rescue Basecamp::AuthError => e
|
|
427
431
|
# SPEC §4: the refresh replay is a request on the wire, so it spends
|
|
428
432
|
# an attempt from THIS budget rather than an uncounted one inside
|
|
@@ -474,8 +478,9 @@ module Basecamp
|
|
|
474
478
|
|
|
475
479
|
delay = calculate_delay(attempt, error.retry_after)
|
|
476
480
|
|
|
477
|
-
|
|
478
|
-
|
|
481
|
+
hook_url = download ? Security.display_url(url) : url
|
|
482
|
+
@hooks.on_retry(RequestInfo.new(method: method.to_s.upcase, url: hook_url, attempt: attempt),
|
|
483
|
+
attempt + 1, error, delay)
|
|
479
484
|
sleep(delay)
|
|
480
485
|
end
|
|
481
486
|
end
|
|
@@ -511,14 +516,19 @@ module Basecamp
|
|
|
511
516
|
end
|
|
512
517
|
|
|
513
518
|
def single_request(method, url, params:, body:, attempt:, retry_count: 0, allow_cross_origin: false,
|
|
514
|
-
accept: "application/json", refresh_replay: true)
|
|
519
|
+
accept: "application/json", refresh_replay: true, download: false)
|
|
515
520
|
assert_credential_origin!(url, allow_cross_origin)
|
|
516
|
-
|
|
521
|
+
# download: the SPEC §9 projection for a URL whose query can carry a
|
|
522
|
+
# credential (download hop 1) — hooks see origin+path, and a transport
|
|
523
|
+
# error is severed below; the wire request keeps url.
|
|
524
|
+
info = RequestInfo.new(method: method.to_s.upcase, url: download ? Security.display_url(url) : url,
|
|
525
|
+
attempt: attempt)
|
|
517
526
|
@hooks.on_request_start(info)
|
|
518
527
|
|
|
519
528
|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
520
529
|
|
|
521
|
-
|
|
530
|
+
severed = nil
|
|
531
|
+
result = begin
|
|
522
532
|
response = @faraday.run_request(method, url, body, request_headers(accept: accept)) do |req|
|
|
523
533
|
req.params.merge!(params) if params.any?
|
|
524
534
|
end
|
|
@@ -551,17 +561,29 @@ module Basecamp
|
|
|
551
561
|
&& @token_refreshed
|
|
552
562
|
@token_refreshed = false
|
|
553
563
|
return single_request(method, url, params: params, body: body, attempt: attempt, retry_count: retry_count + 1,
|
|
554
|
-
allow_cross_origin: allow_cross_origin, accept: accept, refresh_replay: refresh_replay)
|
|
564
|
+
allow_cross_origin: allow_cross_origin, accept: accept, refresh_replay: refresh_replay, download: download)
|
|
555
565
|
end
|
|
556
566
|
|
|
557
|
-
|
|
567
|
+
# On a download, a status error is raised below the rescue too: the
|
|
568
|
+
# Faraday exception retains the request (SPEC §9), and raising here
|
|
569
|
+
# would make it MRI's implicit cause.
|
|
570
|
+
raise error unless download
|
|
571
|
+
|
|
572
|
+
severed = error
|
|
558
573
|
rescue Faraday::Error => e
|
|
559
574
|
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
560
|
-
error
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
575
|
+
# SPEC §9: on a download hop 1 the Faraday error can render the URL it
|
|
576
|
+
# failed on, so it is neither the cause nor the hint, and it is raised
|
|
577
|
+
# below — outside this rescue, with cause: nil — so MRI's implicit
|
|
578
|
+
# cause is severed too. Every other request keeps its diagnostic.
|
|
579
|
+
error = download ? Basecamp::NetworkError.new : Basecamp::NetworkError.new("Connection failed", cause: e)
|
|
580
|
+
@hooks.on_request_end(info, RequestResult.new(duration: duration, error: error))
|
|
581
|
+
raise error unless download
|
|
582
|
+
|
|
583
|
+
severed = error
|
|
564
584
|
end
|
|
585
|
+
|
|
586
|
+
severed ? raise(severed, cause: nil) : result
|
|
565
587
|
end
|
|
566
588
|
|
|
567
589
|
# accept: nil is the binary-download carve-out (SPEC §14): hop 1 sends
|
|
@@ -624,38 +646,48 @@ module Basecamp
|
|
|
624
646
|
|
|
625
647
|
retry_after = parse_retry_after(headers["Retry-After"] || headers["retry-after"])
|
|
626
648
|
request_id = headers["X-Request-Id"] || headers["x-request-id"]
|
|
649
|
+
# SPEC §6 step 3: a body's error_description becomes the hint. Class
|
|
650
|
+
# defaults (AuthError, ForbiddenError) still apply when the body has none.
|
|
651
|
+
hint = Basecamp.parse_error_hint(body)
|
|
627
652
|
|
|
628
653
|
err = case status
|
|
629
654
|
when 401
|
|
630
655
|
# Try token refresh; flag for caller to retry
|
|
631
656
|
@token_refreshed = refresh_on_401 && @token_provider&.refreshable? && @token_provider.refresh
|
|
632
|
-
Basecamp::AuthError.new("Authentication failed")
|
|
657
|
+
Basecamp::AuthError.new("Authentication failed", hint: hint)
|
|
633
658
|
when 403
|
|
634
|
-
Basecamp::ForbiddenError.new("Access denied")
|
|
659
|
+
Basecamp::ForbiddenError.new("Access denied", hint: hint)
|
|
635
660
|
when 404
|
|
636
661
|
message = Security.truncate(Basecamp.parse_error_message(body) || "Not found")
|
|
637
|
-
Basecamp::NotFoundError.new(message: message)
|
|
662
|
+
Basecamp::NotFoundError.new(message: message, hint: hint)
|
|
638
663
|
when 429
|
|
639
|
-
Basecamp::RateLimitError.new(retry_after: retry_after)
|
|
664
|
+
Basecamp::RateLimitError.new(retry_after: retry_after, hint: hint)
|
|
640
665
|
when 400, 422
|
|
641
666
|
field_errors = Basecamp.parse_field_errors(body)
|
|
642
667
|
message = Security.truncate(
|
|
643
668
|
Basecamp.compose_validation_message(Basecamp.parse_error_message(body), field_errors) || "Validation failed"
|
|
644
669
|
)
|
|
645
|
-
Basecamp
|
|
670
|
+
people = status == 422 ? Basecamp.parse_template_library_confirmation_people(body) : nil
|
|
671
|
+
if people
|
|
672
|
+
Basecamp::PeopleConfirmationRequiredError.new(
|
|
673
|
+
message, people: people, hint: hint, http_status: status, field_errors: field_errors
|
|
674
|
+
)
|
|
675
|
+
else
|
|
676
|
+
Basecamp::ValidationError.new(message, hint: hint, http_status: status, field_errors: field_errors)
|
|
677
|
+
end
|
|
646
678
|
when 507
|
|
647
679
|
# A 5xx status carrying a client fact: the account is out of storage, or
|
|
648
680
|
# at its webhook ceiling. Retrying cannot satisfy it, so this is decided
|
|
649
681
|
# before the 5xx arms below.
|
|
650
682
|
message = Security.truncate(Basecamp.parse_error_message(body) || "Account limit reached")
|
|
651
|
-
Basecamp::LimitExceededError.new(message)
|
|
683
|
+
Basecamp::LimitExceededError.new(message, hint: hint)
|
|
652
684
|
when 500
|
|
653
|
-
Basecamp::ApiError.new("Server error (500)", http_status: 500, retryable: true)
|
|
685
|
+
Basecamp::ApiError.new("Server error (500)", http_status: 500, retryable: true, hint: hint)
|
|
654
686
|
when 502, 503, 504
|
|
655
|
-
Basecamp::ApiError.new("Gateway error (#{status})", http_status: status, retryable: true)
|
|
687
|
+
Basecamp::ApiError.new("Gateway error (#{status})", http_status: status, retryable: true, hint: hint)
|
|
656
688
|
else
|
|
657
689
|
message = Security.truncate(Basecamp.parse_error_message(body) || "Request failed (HTTP #{status})")
|
|
658
|
-
Basecamp::ApiError.from_status(status || 0, message)
|
|
690
|
+
Basecamp::ApiError.from_status(status || 0, message, hint: hint)
|
|
659
691
|
end
|
|
660
692
|
|
|
661
693
|
err.instance_variable_set(:@request_id, request_id) if request_id
|
|
@@ -2,16 +2,53 @@
|
|
|
2
2
|
|
|
3
3
|
require "faraday"
|
|
4
4
|
require "json"
|
|
5
|
+
require "timeout"
|
|
5
6
|
require "uri"
|
|
6
7
|
|
|
7
8
|
module Basecamp
|
|
8
9
|
module Oauth
|
|
9
10
|
# Handles OAuth 2 token exchange and refresh operations.
|
|
11
|
+
#
|
|
12
|
+
# Both operations POST credentials — the authorization code and client
|
|
13
|
+
# secret, or the refresh token — to a token endpoint the caller names,
|
|
14
|
+
# which may be one that discovery's metadata chose. The POST therefore
|
|
15
|
+
# rides the same hardened transport discipline as the device flow
|
|
16
|
+
# (SPEC §16 "Token-Endpoint Transport Policy"): redirects are refused
|
|
17
|
+
# rather than followed, the whole request is wall-clock bounded, and the
|
|
18
|
+
# body reads under the shared streaming cap.
|
|
10
19
|
class Exchange
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
|
|
14
|
-
|
|
20
|
+
# The redirect statuses a token endpoint response is refused for
|
|
21
|
+
# (SPEC §16 "Token-Endpoint Transport Policy") — the same set the signed
|
|
22
|
+
# download hop refuses (SPEC §14). 304 is deliberately absent: it is a
|
|
23
|
+
# cache validator, not a redirect-with-Location, and stays on the
|
|
24
|
+
# generic non-success path.
|
|
25
|
+
REDIRECT_STATUSES = [ 301, 302, 303, 307, 308 ].freeze
|
|
26
|
+
|
|
27
|
+
# Default per-request timeout in seconds — the shared credential-POST
|
|
28
|
+
# default every SDK's token and device POSTs converge on (SPEC §16).
|
|
29
|
+
DEFAULT_TIMEOUT = 30
|
|
30
|
+
|
|
31
|
+
# Cap on a token response body (1 MiB), matching the device flow's and
|
|
32
|
+
# the other SDKs' token-response bound.
|
|
33
|
+
MAX_BODY_BYTES = 1 * 1024 * 1024
|
|
34
|
+
|
|
35
|
+
# @param http_client [Faraday::Connection, nil] HTTP client. Nil selects
|
|
36
|
+
# the headers-first default transport ({Fetcher.stream_http}); an
|
|
37
|
+
# injected connection is refused unless its stack is verifiably
|
|
38
|
+
# redirect-free (adapter-only), and keeps the injected-client fidelity
|
|
39
|
+
# tier: status classification only after the (bounded) read completes,
|
|
40
|
+
# deadline enforced wall-clock around the call.
|
|
41
|
+
# @param timeout [Numeric] Request timeout in seconds (default: 30).
|
|
42
|
+
# Invalid values and values beyond the shared 3600 s ceiling fall back
|
|
43
|
+
# to the default rather than disabling the bound.
|
|
44
|
+
def initialize(http_client: nil, timeout: DEFAULT_TIMEOUT)
|
|
45
|
+
# An injected connection is the caller's transport, but redirect
|
|
46
|
+
# suppression is not negotiable on a credential POST: refuse a stack
|
|
47
|
+
# that could follow (or rewrite) before any request is issued — the
|
|
48
|
+
# same guard discovery, resource, and the device flow apply.
|
|
49
|
+
Fetcher.ensure_redirects_suppressed!(http_client) if http_client
|
|
50
|
+
@http_client = http_client
|
|
51
|
+
@timeout = Fetcher.normalize_timeout(timeout, default: DEFAULT_TIMEOUT)
|
|
15
52
|
end
|
|
16
53
|
|
|
17
54
|
# Exchanges an authorization code for access and refresh tokens.
|
|
@@ -80,14 +117,6 @@ module Basecamp
|
|
|
80
117
|
|
|
81
118
|
private
|
|
82
119
|
|
|
83
|
-
def build_default_client(timeout)
|
|
84
|
-
Faraday.new do |conn|
|
|
85
|
-
conn.options.timeout = timeout
|
|
86
|
-
conn.options.open_timeout = timeout
|
|
87
|
-
conn.adapter Faraday.default_adapter
|
|
88
|
-
end
|
|
89
|
-
end
|
|
90
|
-
|
|
91
120
|
def validate_exchange_request!(request)
|
|
92
121
|
raise OauthError.new("validation", "Token endpoint is required") if request.token_endpoint.to_s.empty?
|
|
93
122
|
raise OauthError.new("validation", "Authorization code is required") if request.code.to_s.empty?
|
|
@@ -145,30 +174,128 @@ module Basecamp
|
|
|
145
174
|
def do_token_request(token_endpoint, params)
|
|
146
175
|
Basecamp::Security.require_https_unless_localhost!(token_endpoint, "token endpoint")
|
|
147
176
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
177
|
+
status, body = post_form(
|
|
178
|
+
token_endpoint, params,
|
|
179
|
+
skip_status: ->(s) { REDIRECT_STATUSES.include?(s) }
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# A refused redirect is a typed verdict classified by status alone —
|
|
183
|
+
# its body (skipped above) is never a token, and the credential POST
|
|
184
|
+
# is never re-issued toward Location (SPEC §16).
|
|
185
|
+
if REDIRECT_STATUSES.include?(status)
|
|
186
|
+
raise OauthError.new(
|
|
187
|
+
"api_error",
|
|
188
|
+
"redirect #{status} on the token endpoint is not followed",
|
|
189
|
+
http_status: status
|
|
190
|
+
)
|
|
152
191
|
end
|
|
153
192
|
|
|
154
|
-
parse_token_response(
|
|
193
|
+
parse_token_response(status, body)
|
|
155
194
|
rescue Faraday::TimeoutError
|
|
156
195
|
raise OauthError.new("network", "Token request timed out", retryable: true)
|
|
157
196
|
rescue Faraday::Error => e
|
|
158
197
|
raise OauthError.new("network", "Token request failed: #{e.message}", retryable: true)
|
|
159
198
|
end
|
|
160
199
|
|
|
161
|
-
|
|
162
|
-
|
|
200
|
+
# POSTs the token form and returns +[status, body]+, reading under the
|
|
201
|
+
# same bounded/streaming cap as discovery and the device flow.
|
|
202
|
+
#
|
|
203
|
+
# With no injected client the POST runs on the headers-first
|
|
204
|
+
# {Fetcher.stream_http} primitive: +skip_status+ classifies a redirect
|
|
205
|
+
# by status at HEADER time (its body is never read, even one that
|
|
206
|
+
# stalls forever), redirects are structurally never followed, and a
|
|
207
|
+
# watchdog bounds the whole request — a stalled or byte-dripped header
|
|
208
|
+
# phase included — at the timeout. An INJECTED Faraday connection keeps
|
|
209
|
+
# the Faraday path below.
|
|
210
|
+
def post_form(url, params, skip_status:)
|
|
211
|
+
if @http_client.nil?
|
|
212
|
+
Fetcher.stream_http(
|
|
213
|
+
:post, url,
|
|
214
|
+
headers: { "Content-Type" => "application/x-www-form-urlencoded", "Accept" => "application/json" },
|
|
215
|
+
form: params, timeout: @timeout, max_body_bytes: MAX_BODY_BYTES, skip_status: skip_status
|
|
216
|
+
)
|
|
217
|
+
else
|
|
218
|
+
post_form_injected(url, params, skip_status)
|
|
219
|
+
end
|
|
220
|
+
rescue Fetcher::SkipBody => e
|
|
221
|
+
# The body was intentionally not drained (a redirect's body is never a
|
|
222
|
+
# token) — classify by status upstream.
|
|
223
|
+
[ e.status, "" ]
|
|
224
|
+
rescue Fetcher::BodyTooLarge
|
|
225
|
+
raise OauthError.new("api_error", "Token response exceeds size cap")
|
|
226
|
+
rescue Fetcher::ReadDeadlineExceeded
|
|
227
|
+
# A slow-drip read is a transport timeout, not an api_error — surface
|
|
228
|
+
# as the Faraday timeout the caller's rescue classifies.
|
|
229
|
+
raise Faraday::TimeoutError, "Token request read exceeded the timeout deadline"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Injected-client (Faraday) lane — the injected-client fidelity tier
|
|
233
|
+
# (SPEC §16): the same invariants as the default transport (suppressed
|
|
234
|
+
# redirects, bounded body, whole-request wall clock), with buffered
|
|
235
|
+
# classification. +req.options.timeout+ below bounds only each socket
|
|
236
|
+
# read and resets on every +on_data+ chunk, so a slow-drip peer could
|
|
237
|
+
# otherwise hold the credential POST open past the timeout while
|
|
238
|
+
# staying under the cap — the monotonic deadline bounds the WHOLE
|
|
239
|
+
# request, and +Timeout.timeout+ enforces it through a stalled or
|
|
240
|
+
# dripped HEADER phase, where +on_data+ (a body callback) never runs.
|
|
241
|
+
def post_form_injected(url, params, skip_status)
|
|
242
|
+
deadline = Fetcher.monotonic_now + @timeout
|
|
243
|
+
chunks, on_data = Fetcher.bounded_reader(MAX_BODY_BYTES, deadline: deadline, skip_status: skip_status)
|
|
244
|
+
# The window is the REMAINING budget, not a fresh timeout: time spent
|
|
245
|
+
# before dispatch already counts against the deadline, so the request
|
|
246
|
+
# can never run past it.
|
|
247
|
+
remaining = deadline - Fetcher.monotonic_now
|
|
248
|
+
raise Faraday::TimeoutError, "request budget exhausted before dispatch" if remaining <= 0
|
|
249
|
+
|
|
250
|
+
response = Timeout.timeout(remaining, Faraday::TimeoutError) do
|
|
251
|
+
@http_client.post(url) do |req|
|
|
252
|
+
req.headers["Content-Type"] = "application/x-www-form-urlencoded"
|
|
253
|
+
req.headers["Accept"] = "application/json"
|
|
254
|
+
req.body = URI.encode_www_form(params)
|
|
255
|
+
req.options.timeout = @timeout
|
|
256
|
+
req.options.open_timeout = @timeout
|
|
257
|
+
req.options.on_data = on_data
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Status-first backstop on the completed response: the +on_data+
|
|
262
|
+
# SkipBody fast-path only fires when the adapter streams AND passes
|
|
263
|
+
# +env+ (Faraday >= 2.5). A buffered adapter that ignores +on_data+,
|
|
264
|
+
# an older Faraday (2.0–2.4) that omits +env+, or a header-only
|
|
265
|
+
# response reaches here with the redirect body un-skipped — re-apply
|
|
266
|
+
# +skip_status+ to the final status so a redirect is classified by
|
|
267
|
+
# status for every client shape, never buffered into a size-cap error.
|
|
268
|
+
# A definitive completed status outranks the deadline re-check below;
|
|
269
|
+
# everything else completing past the deadline is refused as the same
|
|
270
|
+
# transport-shaped timeout (Timeout.timeout's interrupt can land late).
|
|
271
|
+
if skip_status.call(response.status)
|
|
272
|
+
[ response.status, "" ]
|
|
273
|
+
elsif Fetcher.monotonic_now > deadline
|
|
274
|
+
raise Faraday::TimeoutError, "response completed after the deadline"
|
|
275
|
+
else
|
|
276
|
+
body =
|
|
277
|
+
if chunks.empty?
|
|
278
|
+
raw = response.body.to_s
|
|
279
|
+
raise Fetcher::BodyTooLarge if raw.bytesize > MAX_BODY_BYTES
|
|
280
|
+
|
|
281
|
+
raw
|
|
282
|
+
else
|
|
283
|
+
chunks.join
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
[ response.status, body.dup.force_encoding(Encoding::UTF_8) ]
|
|
287
|
+
end
|
|
288
|
+
end
|
|
163
289
|
|
|
164
|
-
|
|
290
|
+
def parse_token_response(status, body)
|
|
291
|
+
data = JSON.parse(body)
|
|
165
292
|
|
|
166
|
-
handle_error_response(
|
|
293
|
+
handle_error_response(status, data) unless (200..299).cover?(status)
|
|
167
294
|
|
|
168
295
|
unless data["access_token"].is_a?(String) && !data["access_token"].empty?
|
|
169
296
|
raise OauthError.new(
|
|
170
297
|
"api_error", "Token response missing or non-string access_token",
|
|
171
|
-
http_status:
|
|
298
|
+
http_status: status
|
|
172
299
|
)
|
|
173
300
|
end
|
|
174
301
|
|
|
@@ -179,7 +306,7 @@ module Basecamp
|
|
|
179
306
|
raise OauthError.new(
|
|
180
307
|
"api_error",
|
|
181
308
|
"Token response resource must be a non-empty string when present",
|
|
182
|
-
http_status:
|
|
309
|
+
http_status: status
|
|
183
310
|
)
|
|
184
311
|
end
|
|
185
312
|
|
|
@@ -191,7 +318,7 @@ module Basecamp
|
|
|
191
318
|
raise OauthError.new(
|
|
192
319
|
"api_error",
|
|
193
320
|
"Token response token_type must be a non-empty string when present",
|
|
194
|
-
http_status:
|
|
321
|
+
http_status: status
|
|
195
322
|
)
|
|
196
323
|
end
|
|
197
324
|
|
|
@@ -213,7 +340,7 @@ module Basecamp
|
|
|
213
340
|
raise OauthError.new(
|
|
214
341
|
"api_error",
|
|
215
342
|
"Failed to parse token response",
|
|
216
|
-
http_status:
|
|
343
|
+
http_status: status
|
|
217
344
|
), cause: nil
|
|
218
345
|
end
|
|
219
346
|
|
|
@@ -239,7 +239,7 @@ module Basecamp
|
|
|
239
239
|
|
|
240
240
|
raise OauthError.new(
|
|
241
241
|
"validation",
|
|
242
|
-
"Injected OAuth
|
|
242
|
+
"Injected OAuth client must carry only an adapter (no middleware); " \
|
|
243
243
|
"found #{offending.klass.name}. Redirects are suppressed for SSRF safety, so a " \
|
|
244
244
|
"connection whose middleware stack cannot be verified redirect-free is refused"
|
|
245
245
|
)
|