basecamp-sdk 0.7.3 → 0.8.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/README.md +63 -0
- data/basecamp-sdk.gemspec +2 -3
- data/lib/basecamp/generated/metadata.json +57 -43
- data/lib/basecamp/generated/services/campfires_service.rb +13 -1
- data/lib/basecamp/generated/services/card_columns_service.rb +26 -23
- data/lib/basecamp/generated/services/reports_service.rb +1 -1
- data/lib/basecamp/generated/services/todos_service.rb +4 -4
- data/lib/basecamp/generated/services/tools_service.rb +7 -6
- data/lib/basecamp/generated/services/uploads_service.rb +17 -0
- data/lib/basecamp/generated/types.rb +32 -8
- data/lib/basecamp/http.rb +86 -15
- data/lib/basecamp/oauth/config.rb +20 -5
- data/lib/basecamp/oauth/discovery.rb +134 -64
- data/lib/basecamp/oauth/discovery_result.rb +33 -0
- data/lib/basecamp/oauth/discovery_selection_error.rb +30 -0
- data/lib/basecamp/oauth/fetcher.rb +204 -0
- data/lib/basecamp/oauth/protected_resource_metadata.rb +22 -0
- data/lib/basecamp/oauth/resource.rb +95 -0
- data/lib/basecamp/oauth.rb +173 -0
- data/lib/basecamp/security.rb +83 -0
- data/lib/basecamp/services/authorization_service.rb +45 -0
- data/lib/basecamp/services/todos_extensions.rb +121 -0
- data/lib/basecamp/version.rb +2 -2
- data/lib/basecamp.rb +6 -0
- data/scripts/generate-services.rb +37 -3
- metadata +13 -21
- data/lib/basecamp/generated/services/authorization_service.rb +0 -47
|
@@ -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-07-22T08:33:01Z
|
|
5
5
|
|
|
6
6
|
require "json"
|
|
7
7
|
require "time"
|
|
@@ -2093,7 +2093,7 @@ module Basecamp
|
|
|
2093
2093
|
# Notification
|
|
2094
2094
|
class Notification
|
|
2095
2095
|
include TypeHelpers
|
|
2096
|
-
attr_accessor :created_at, :id, :updated_at, :app_url, :bookmark_url, :bucket_name, :content_excerpt, :creator, :image_url, :memory_url, :named, :participants, :previewable_attachments, :read_at, :readable_identifier, :readable_sgid, :section, :subscribed, :subscription_url, :title, :type, :unread_at, :unread_count, :unread_url
|
|
2096
|
+
attr_accessor :created_at, :id, :updated_at, :app_url, :bookmark_url, :bubble_up_at, :bubble_up_url, :bucket_name, :content_excerpt, :creator, :image_url, :memory_url, :named, :participants, :previewable_attachments, :read_at, :readable_identifier, :readable_sgid, :section, :subscribed, :subscription_url, :title, :type, :unread_at, :unread_count, :unread_url
|
|
2097
2097
|
|
|
2098
2098
|
# @return [Array<Symbol>]
|
|
2099
2099
|
def self.required_fields
|
|
@@ -2106,6 +2106,8 @@ module Basecamp
|
|
|
2106
2106
|
@updated_at = parse_datetime(data["updated_at"])
|
|
2107
2107
|
@app_url = data["app_url"]
|
|
2108
2108
|
@bookmark_url = data["bookmark_url"]
|
|
2109
|
+
@bubble_up_at = parse_datetime(data["bubble_up_at"])
|
|
2110
|
+
@bubble_up_url = data["bubble_up_url"]
|
|
2109
2111
|
@bucket_name = data["bucket_name"]
|
|
2110
2112
|
@content_excerpt = data["content_excerpt"]
|
|
2111
2113
|
@creator = parse_type(data["creator"], "Person")
|
|
@@ -2134,6 +2136,8 @@ module Basecamp
|
|
|
2134
2136
|
"updated_at" => @updated_at,
|
|
2135
2137
|
"app_url" => @app_url,
|
|
2136
2138
|
"bookmark_url" => @bookmark_url,
|
|
2139
|
+
"bubble_up_at" => @bubble_up_at,
|
|
2140
|
+
"bubble_up_url" => @bubble_up_url,
|
|
2137
2141
|
"bucket_name" => @bucket_name,
|
|
2138
2142
|
"content_excerpt" => @content_excerpt,
|
|
2139
2143
|
"creator" => @creator,
|
|
@@ -2164,9 +2168,10 @@ module Basecamp
|
|
|
2164
2168
|
# OutOfOffice
|
|
2165
2169
|
class OutOfOffice
|
|
2166
2170
|
include TypeHelpers
|
|
2167
|
-
attr_accessor :enabled, :end_date, :ongoing, :person, :start_date
|
|
2171
|
+
attr_accessor :back_on_date, :enabled, :end_date, :ongoing, :person, :start_date
|
|
2168
2172
|
|
|
2169
2173
|
def initialize(data = {})
|
|
2174
|
+
@back_on_date = data["back_on_date"]
|
|
2170
2175
|
@enabled = parse_boolean(data["enabled"])
|
|
2171
2176
|
@end_date = data["end_date"]
|
|
2172
2177
|
@ongoing = parse_boolean(data["ongoing"])
|
|
@@ -2176,6 +2181,7 @@ module Basecamp
|
|
|
2176
2181
|
|
|
2177
2182
|
def to_h
|
|
2178
2183
|
{
|
|
2184
|
+
"back_on_date" => @back_on_date,
|
|
2179
2185
|
"enabled" => @enabled,
|
|
2180
2186
|
"end_date" => @end_date,
|
|
2181
2187
|
"ongoing" => @ongoing,
|
|
@@ -2219,7 +2225,7 @@ module Basecamp
|
|
|
2219
2225
|
# OutOfOfficePerson
|
|
2220
2226
|
class OutOfOfficePerson
|
|
2221
2227
|
include TypeHelpers
|
|
2222
|
-
attr_accessor :id, :name
|
|
2228
|
+
attr_accessor :id, :avatar_url, :name
|
|
2223
2229
|
|
|
2224
2230
|
# @return [Array<Symbol>]
|
|
2225
2231
|
def self.required_fields
|
|
@@ -2228,12 +2234,14 @@ module Basecamp
|
|
|
2228
2234
|
|
|
2229
2235
|
def initialize(data = {})
|
|
2230
2236
|
@id = parse_integer(data["id"])
|
|
2237
|
+
@avatar_url = data["avatar_url"]
|
|
2231
2238
|
@name = data["name"]
|
|
2232
2239
|
end
|
|
2233
2240
|
|
|
2234
2241
|
def to_h
|
|
2235
2242
|
{
|
|
2236
2243
|
"id" => @id,
|
|
2244
|
+
"avatar_url" => @avatar_url,
|
|
2237
2245
|
"name" => @name,
|
|
2238
2246
|
}.compact
|
|
2239
2247
|
end
|
|
@@ -2246,7 +2254,7 @@ module Basecamp
|
|
|
2246
2254
|
# Person
|
|
2247
2255
|
class Person
|
|
2248
2256
|
include TypeHelpers
|
|
2249
|
-
attr_accessor :id, :name, :admin, :attachable_sgid, :avatar_url, :bio, :can_access_hill_charts, :can_access_timesheet, :can_manage_people, :can_manage_projects, :can_ping, :client, :company, :created_at, :email_address, :employee, :location, :owner, :personable_type, :time_zone, :title, :updated_at, :system_label
|
|
2257
|
+
attr_accessor :id, :name, :admin, :attachable_sgid, :avatar_url, :bio, :can_access_hill_charts, :can_access_timesheet, :can_manage_people, :can_manage_projects, :can_ping, :client, :company, :created_at, :email_address, :employee, :location, :owner, :personable_type, :tagline, :time_zone, :title, :updated_at, :system_label
|
|
2250
2258
|
|
|
2251
2259
|
# @return [Array<Symbol>]
|
|
2252
2260
|
def self.required_fields
|
|
@@ -2274,6 +2282,7 @@ module Basecamp
|
|
|
2274
2282
|
@location = data["location"]
|
|
2275
2283
|
@owner = parse_boolean(data["owner"])
|
|
2276
2284
|
@personable_type = data["personable_type"]
|
|
2285
|
+
@tagline = data["tagline"]
|
|
2277
2286
|
@time_zone = data["time_zone"]
|
|
2278
2287
|
@title = data["title"]
|
|
2279
2288
|
@updated_at = parse_datetime(data["updated_at"])
|
|
@@ -2300,6 +2309,7 @@ module Basecamp
|
|
|
2300
2309
|
"location" => @location,
|
|
2301
2310
|
"owner" => @owner,
|
|
2302
2311
|
"personable_type" => @personable_type,
|
|
2312
|
+
"tagline" => @tagline,
|
|
2303
2313
|
"time_zone" => @time_zone,
|
|
2304
2314
|
"title" => @title,
|
|
2305
2315
|
"updated_at" => @updated_at,
|
|
@@ -2427,7 +2437,7 @@ module Basecamp
|
|
|
2427
2437
|
# Project
|
|
2428
2438
|
class Project
|
|
2429
2439
|
include TypeHelpers
|
|
2430
|
-
attr_accessor :app_url, :created_at, :id, :name, :status, :updated_at, :url, :bookmark_url, :bookmarked, :client_company, :clients_enabled, :clientside, :description, :dock, :purpose
|
|
2440
|
+
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
|
|
2431
2441
|
|
|
2432
2442
|
# @return [Array<Symbol>]
|
|
2433
2443
|
def self.required_fields
|
|
@@ -2449,7 +2459,9 @@ module Basecamp
|
|
|
2449
2459
|
@clientside = parse_type(data["clientside"], "ClientSide")
|
|
2450
2460
|
@description = data["description"]
|
|
2451
2461
|
@dock = parse_array(data["dock"], "DockItem")
|
|
2462
|
+
@end_date = data["end_date"]
|
|
2452
2463
|
@purpose = data["purpose"]
|
|
2464
|
+
@start_date = data["start_date"]
|
|
2453
2465
|
end
|
|
2454
2466
|
|
|
2455
2467
|
def to_h
|
|
@@ -2468,7 +2480,9 @@ module Basecamp
|
|
|
2468
2480
|
"clientside" => @clientside,
|
|
2469
2481
|
"description" => @description,
|
|
2470
2482
|
"dock" => @dock,
|
|
2483
|
+
"end_date" => @end_date,
|
|
2471
2484
|
"purpose" => @purpose,
|
|
2485
|
+
"start_date" => @start_date,
|
|
2472
2486
|
}.compact
|
|
2473
2487
|
end
|
|
2474
2488
|
|
|
@@ -3375,7 +3389,7 @@ module Basecamp
|
|
|
3375
3389
|
# Todo
|
|
3376
3390
|
class Todo
|
|
3377
3391
|
include TypeHelpers
|
|
3378
|
-
attr_accessor :app_url, :bucket, :content, :created_at, :creator, :id, :inherits_status, :parent, :status, :title, :type, :updated_at, :url, :visible_to_clients, :assignees, :bookmark_url, :boosts_count, :boosts_url, :comments_count, :comments_url, :completed, :completion_subscribers, :completion_url, :description, :due_on, :position, :starts_on, :subscription_url
|
|
3392
|
+
attr_accessor :app_url, :bucket, :content, :created_at, :creator, :id, :inherits_status, :parent, :status, :title, :type, :updated_at, :url, :visible_to_clients, :assignees, :bookmark_url, :boosts_count, :boosts_url, :comments_count, :comments_url, :completed, :completion_subscribers, :completion_url, :description, :due_on, :position, :starts_on, :steps, :subscription_url
|
|
3379
3393
|
|
|
3380
3394
|
# @return [Array<Symbol>]
|
|
3381
3395
|
def self.required_fields
|
|
@@ -3410,6 +3424,7 @@ module Basecamp
|
|
|
3410
3424
|
@due_on = data["due_on"]
|
|
3411
3425
|
@position = parse_integer(data["position"])
|
|
3412
3426
|
@starts_on = data["starts_on"]
|
|
3427
|
+
@steps = parse_array(data["steps"], "CardStep")
|
|
3413
3428
|
@subscription_url = data["subscription_url"]
|
|
3414
3429
|
end
|
|
3415
3430
|
|
|
@@ -3442,6 +3457,7 @@ module Basecamp
|
|
|
3442
3457
|
"due_on" => @due_on,
|
|
3443
3458
|
"position" => @position,
|
|
3444
3459
|
"starts_on" => @starts_on,
|
|
3460
|
+
"steps" => @steps,
|
|
3445
3461
|
"subscription_url" => @subscription_url,
|
|
3446
3462
|
}.compact
|
|
3447
3463
|
end
|
|
@@ -3662,7 +3678,7 @@ module Basecamp
|
|
|
3662
3678
|
# Todoset
|
|
3663
3679
|
class Todoset
|
|
3664
3680
|
include TypeHelpers
|
|
3665
|
-
attr_accessor :app_url, :bucket, :created_at, :creator, :id, :inherits_status, :name, :status, :title, :type, :updated_at, :url, :visible_to_clients, :app_todolists_url, :bookmark_url, :completed, :completed_ratio, :position, :todolists_count, :todolists_url
|
|
3681
|
+
attr_accessor :app_url, :bucket, :created_at, :creator, :id, :inherits_status, :name, :status, :title, :type, :updated_at, :url, :visible_to_clients, :app_todolists_url, :app_todos_url, :bookmark_url, :completed, :completed_loose_todos_count, :completed_ratio, :position, :todolists_count, :todolists_url, :todos_count, :todos_url
|
|
3666
3682
|
|
|
3667
3683
|
# @return [Array<Symbol>]
|
|
3668
3684
|
def self.required_fields
|
|
@@ -3684,12 +3700,16 @@ module Basecamp
|
|
|
3684
3700
|
@url = data["url"]
|
|
3685
3701
|
@visible_to_clients = parse_boolean(data["visible_to_clients"])
|
|
3686
3702
|
@app_todolists_url = data["app_todolists_url"]
|
|
3703
|
+
@app_todos_url = data["app_todos_url"]
|
|
3687
3704
|
@bookmark_url = data["bookmark_url"]
|
|
3688
3705
|
@completed = parse_boolean(data["completed"])
|
|
3706
|
+
@completed_loose_todos_count = parse_integer(data["completed_loose_todos_count"])
|
|
3689
3707
|
@completed_ratio = data["completed_ratio"]
|
|
3690
3708
|
@position = parse_integer(data["position"])
|
|
3691
3709
|
@todolists_count = parse_integer(data["todolists_count"])
|
|
3692
3710
|
@todolists_url = data["todolists_url"]
|
|
3711
|
+
@todos_count = parse_integer(data["todos_count"])
|
|
3712
|
+
@todos_url = data["todos_url"]
|
|
3693
3713
|
end
|
|
3694
3714
|
|
|
3695
3715
|
def to_h
|
|
@@ -3708,12 +3728,16 @@ module Basecamp
|
|
|
3708
3728
|
"url" => @url,
|
|
3709
3729
|
"visible_to_clients" => @visible_to_clients,
|
|
3710
3730
|
"app_todolists_url" => @app_todolists_url,
|
|
3731
|
+
"app_todos_url" => @app_todos_url,
|
|
3711
3732
|
"bookmark_url" => @bookmark_url,
|
|
3712
3733
|
"completed" => @completed,
|
|
3734
|
+
"completed_loose_todos_count" => @completed_loose_todos_count,
|
|
3713
3735
|
"completed_ratio" => @completed_ratio,
|
|
3714
3736
|
"position" => @position,
|
|
3715
3737
|
"todolists_count" => @todolists_count,
|
|
3716
3738
|
"todolists_url" => @todolists_url,
|
|
3739
|
+
"todos_count" => @todos_count,
|
|
3740
|
+
"todos_url" => @todos_url,
|
|
3717
3741
|
}.compact
|
|
3718
3742
|
end
|
|
3719
3743
|
|
data/lib/basecamp/http.rb
CHANGED
|
@@ -61,12 +61,56 @@ module Basecamp
|
|
|
61
61
|
end
|
|
62
62
|
|
|
63
63
|
# Performs a GET request to an absolute URL.
|
|
64
|
-
# Used for endpoints not on the base API
|
|
64
|
+
# Used for endpoints not on the base API.
|
|
65
|
+
#
|
|
66
|
+
# This is the PUBLIC, general path and it credentials cross-origin for ONE
|
|
67
|
+
# destination only: the exact Launchpad authorization URL
|
|
68
|
+
# ({Basecamp::Security::LAUNCHPAD_AUTHORIZATION_URL}). Every other foreign
|
|
69
|
+
# origin — including an endpoint-shaped URL such as
|
|
70
|
+
# +https://evil.example/authorization.json+ — trips the same-origin guard, so
|
|
71
|
+
# the bearer token only ever reaches Launchpad, the configured base URL, or
|
|
72
|
+
# localhost. There is deliberately NO raw-string trusted-origin parameter: a
|
|
73
|
+
# syntactically valid origin does not prove discovery provenance, so the ONE
|
|
74
|
+
# legitimate cross-origin discovery destination goes through the narrow
|
|
75
|
+
# {#get_authorization_document}, which derives its issuer from internal
|
|
76
|
+
# discovery of the configured base URL rather than any caller argument.
|
|
77
|
+
#
|
|
65
78
|
# @param url [String] absolute URL
|
|
66
79
|
# @param params [Hash] query parameters
|
|
67
80
|
# @return [Response]
|
|
68
81
|
def get_absolute(url, params: {})
|
|
69
|
-
|
|
82
|
+
Security.require_https_unless_localhost!(url, "absolute URL")
|
|
83
|
+
|
|
84
|
+
allow_cross_origin = url == Security::LAUNCHPAD_AUTHORIZATION_URL
|
|
85
|
+
request(:get, url, params: params, allow_cross_origin: allow_cross_origin)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Fetches the credentialed authorization document (the fixed +authorization.json+
|
|
89
|
+
# path). This is the ONE sanctioned cross-origin credential path besides
|
|
90
|
+
# Launchpad, and the origin that receives the bearer token is NOT
|
|
91
|
+
# caller-supplied.
|
|
92
|
+
#
|
|
93
|
+
# The issuer is derived HERE by running resource-first discovery (SPEC.md §16)
|
|
94
|
+
# against this client's OWN configured base URL, then binding to whatever
|
|
95
|
+
# issuer discovery selects and validates (RFC 8414 issuer binding). A soft
|
|
96
|
+
# fallback fetches Launchpad's fixed URL; a hard discovery failure raises. The
|
|
97
|
+
# request URL is CONSTRUCTED from the discovered issuer origin + the fixed path
|
|
98
|
+
# (string concatenation, never URL re-parsing). Because no caller-supplied
|
|
99
|
+
# config, origin, or path reaches this method, there is no public API through
|
|
100
|
+
# which a forged issuer could redirect the credential to a foreign host —
|
|
101
|
+
# discovery provenance is structural, not a claim about a passed-in object.
|
|
102
|
+
#
|
|
103
|
+
# @return [Response]
|
|
104
|
+
# @raise [Oauth::DiscoverySelectionError] on a hard discovery failure
|
|
105
|
+
# @raise [Basecamp::UsageError] when the discovered issuer is not an origin root
|
|
106
|
+
def get_authorization_document
|
|
107
|
+
result = Oauth.discover_from_resource(@config.base_url)
|
|
108
|
+
if result.selected?
|
|
109
|
+
issuer_origin = Security.require_origin_root!(result.issuer, "selected issuer origin")
|
|
110
|
+
request(:get, "#{issuer_origin}/authorization.json", allow_cross_origin: true)
|
|
111
|
+
else
|
|
112
|
+
get_absolute(Security::LAUNCHPAD_AUTHORIZATION_URL)
|
|
113
|
+
end
|
|
70
114
|
end
|
|
71
115
|
|
|
72
116
|
# Performs a POST request.
|
|
@@ -296,18 +340,18 @@ module Basecamp
|
|
|
296
340
|
end
|
|
297
341
|
end
|
|
298
342
|
|
|
299
|
-
def request(method, path, params: {}, body: nil)
|
|
300
|
-
url = build_url(path)
|
|
343
|
+
def request(method, path, params: {}, body: nil, allow_cross_origin: false)
|
|
344
|
+
url = build_url(path, allow_cross_origin: allow_cross_origin)
|
|
301
345
|
|
|
302
346
|
# Mutations don't retry on 429/5xx to avoid duplicating data
|
|
303
347
|
if method == :get
|
|
304
|
-
request_with_retry(method, url, params: params)
|
|
348
|
+
request_with_retry(method, url, params: params, allow_cross_origin: allow_cross_origin)
|
|
305
349
|
else
|
|
306
|
-
single_request(method, url, params: params, body: body, attempt: 1)
|
|
350
|
+
single_request(method, url, params: params, body: body, attempt: 1, allow_cross_origin: allow_cross_origin)
|
|
307
351
|
end
|
|
308
352
|
end
|
|
309
353
|
|
|
310
|
-
def request_with_retry(method, url, params: {})
|
|
354
|
+
def request_with_retry(method, url, params: {}, allow_cross_origin: false)
|
|
311
355
|
attempt = 0
|
|
312
356
|
last_error = nil
|
|
313
357
|
|
|
@@ -316,7 +360,7 @@ module Basecamp
|
|
|
316
360
|
break if attempt > @config.max_retries
|
|
317
361
|
|
|
318
362
|
begin
|
|
319
|
-
return single_request(method, url, params: params, body: nil, attempt: attempt)
|
|
363
|
+
return single_request(method, url, params: params, body: nil, attempt: attempt, allow_cross_origin: allow_cross_origin)
|
|
320
364
|
rescue Basecamp::RateLimitError, Basecamp::NetworkError, Basecamp::ApiError => e
|
|
321
365
|
raise e unless e.retryable?
|
|
322
366
|
|
|
@@ -336,7 +380,8 @@ module Basecamp
|
|
|
336
380
|
raise last_error || Basecamp::ApiError.new("Request failed after #{@config.max_retries} retries")
|
|
337
381
|
end
|
|
338
382
|
|
|
339
|
-
def single_request(method, url, params:, body:, attempt:, retry_count: 0)
|
|
383
|
+
def single_request(method, url, params:, body:, attempt:, retry_count: 0, allow_cross_origin: false)
|
|
384
|
+
assert_credential_origin!(url, allow_cross_origin)
|
|
340
385
|
info = RequestInfo.new(method: method.to_s.upcase, url: url, attempt: attempt)
|
|
341
386
|
@hooks.on_request_start(info)
|
|
342
387
|
|
|
@@ -370,7 +415,7 @@ module Basecamp
|
|
|
370
415
|
# After a successful token refresh on 401, retry the request once
|
|
371
416
|
if error.is_a?(Basecamp::AuthError) && error.http_status == 401 && retry_count < 1 && @token_refreshed
|
|
372
417
|
@token_refreshed = false
|
|
373
|
-
return single_request(method, url, params: params, body: body, attempt: attempt, retry_count: retry_count + 1)
|
|
418
|
+
return single_request(method, url, params: params, body: body, attempt: attempt, retry_count: retry_count + 1, allow_cross_origin: allow_cross_origin)
|
|
374
419
|
end
|
|
375
420
|
|
|
376
421
|
raise error
|
|
@@ -393,6 +438,7 @@ module Basecamp
|
|
|
393
438
|
end
|
|
394
439
|
|
|
395
440
|
def single_request_raw(method, url, body:, content_type:, attempt:)
|
|
441
|
+
assert_credential_origin!(url, false)
|
|
396
442
|
info = RequestInfo.new(method: method.to_s.upcase, url: url, attempt: attempt)
|
|
397
443
|
@hooks.on_request_start(info)
|
|
398
444
|
|
|
@@ -467,17 +513,42 @@ module Basecamp
|
|
|
467
513
|
err
|
|
468
514
|
end
|
|
469
515
|
|
|
470
|
-
def build_url(path)
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
516
|
+
def build_url(path, allow_cross_origin: false)
|
|
517
|
+
# Schemes are case-insensitive (RFC 3986): detect absolute URLs on a
|
|
518
|
+
# lowercased copy so HTTPS://... is not mis-joined onto the base URL.
|
|
519
|
+
lower_path = path.downcase
|
|
520
|
+
if lower_path.start_with?("https://")
|
|
521
|
+
return path if allow_cross_origin
|
|
522
|
+
return path if Security.localhost?(path) || Security.same_origin?(path, @config.base_url)
|
|
523
|
+
|
|
524
|
+
raise Basecamp::UsageError.new("URL origin does not match configured base URL: #{Security.truncate(path)}")
|
|
525
|
+
elsif lower_path.start_with?("http://")
|
|
526
|
+
# Localhost may use plain HTTP for local development; every other host
|
|
527
|
+
# must use HTTPS.
|
|
528
|
+
return path if Security.localhost?(path)
|
|
529
|
+
|
|
530
|
+
raise Basecamp::UsageError.new("URL must use HTTPS: #{Security.truncate(path)}")
|
|
475
531
|
end
|
|
476
532
|
|
|
477
533
|
path = "/#{path}" unless path.start_with?("/")
|
|
478
534
|
"#{@config.base_url}#{path}"
|
|
479
535
|
end
|
|
480
536
|
|
|
537
|
+
# Attach-point backstop: refuse to attach credentials to a foreign origin
|
|
538
|
+
# before the auth strategy adds the bearer token. Localhost is carved out
|
|
539
|
+
# for dev/test. allow_cross_origin is granted only by get_absolute (for the
|
|
540
|
+
# trusted Launchpad authorization endpoint) or get_authorization_document (for
|
|
541
|
+
# a URL constructed against an issuer that internal discovery selected and
|
|
542
|
+
# validated); every other absolute URL must be same-origin with the base URL.
|
|
543
|
+
def assert_credential_origin!(url, allow_cross_origin)
|
|
544
|
+
return if allow_cross_origin
|
|
545
|
+
return if Security.localhost?(url) || Security.same_origin?(url, @config.base_url)
|
|
546
|
+
|
|
547
|
+
raise Basecamp::UsageError.new(
|
|
548
|
+
"Refusing to send credentials to a different origin than base URL: #{Security.truncate(url)}"
|
|
549
|
+
)
|
|
550
|
+
end
|
|
551
|
+
|
|
481
552
|
def calculate_delay(attempt, server_retry_after)
|
|
482
553
|
return server_retry_after if server_retry_after&.positive?
|
|
483
554
|
|
|
@@ -2,26 +2,41 @@
|
|
|
2
2
|
|
|
3
3
|
module Basecamp
|
|
4
4
|
module Oauth
|
|
5
|
-
# OAuth 2 server configuration from
|
|
5
|
+
# OAuth 2 server configuration from an Authorization Server Metadata document
|
|
6
|
+
# (RFC 8414).
|
|
7
|
+
#
|
|
8
|
+
# As of BC5 resource-first discovery, +authorization_endpoint+ is OPTIONAL:
|
|
9
|
+
# device-only authorization servers omit it, so authorization-code consumers
|
|
10
|
+
# MUST assert its presence before use. +token_endpoint+ stays required.
|
|
6
11
|
#
|
|
7
12
|
# @attr issuer [String] The authorization server's issuer identifier
|
|
8
|
-
# @attr authorization_endpoint [String] URL of the authorization endpoint
|
|
13
|
+
# @attr authorization_endpoint [String, nil] URL of the authorization endpoint (optional)
|
|
9
14
|
# @attr token_endpoint [String] URL of the token endpoint
|
|
15
|
+
# @attr device_authorization_endpoint [String, nil] URL of the RFC 8628 device authorization endpoint
|
|
10
16
|
# @attr registration_endpoint [String, nil] URL of the dynamic client registration endpoint
|
|
11
17
|
# @attr scopes_supported [Array<String>, nil] List of OAuth 2 scopes supported
|
|
18
|
+
# @attr grant_types_supported [Array<String>, nil] OAuth 2 grant types the server supports
|
|
19
|
+
# NOTE: +device_authorization_endpoint+ is APPENDED after the pre-existing
|
|
20
|
+
# members. Data.define's member order is the positional/deconstruct order, so
|
|
21
|
+
# inserting a new field mid-list would shift positional callers (and pattern
|
|
22
|
+
# matches). Keep new fields last for positional compatibility.
|
|
12
23
|
Config = Data.define(
|
|
13
24
|
:issuer,
|
|
14
25
|
:authorization_endpoint,
|
|
15
26
|
:token_endpoint,
|
|
16
27
|
:registration_endpoint,
|
|
17
|
-
:scopes_supported
|
|
28
|
+
:scopes_supported,
|
|
29
|
+
:grant_types_supported,
|
|
30
|
+
:device_authorization_endpoint
|
|
18
31
|
) do
|
|
19
32
|
def initialize(
|
|
20
33
|
issuer:,
|
|
21
|
-
authorization_endpoint:,
|
|
22
34
|
token_endpoint:,
|
|
35
|
+
authorization_endpoint: nil,
|
|
23
36
|
registration_endpoint: nil,
|
|
24
|
-
scopes_supported: nil
|
|
37
|
+
scopes_supported: nil,
|
|
38
|
+
grant_types_supported: nil,
|
|
39
|
+
device_authorization_endpoint: nil
|
|
25
40
|
)
|
|
26
41
|
super
|
|
27
42
|
end
|
|
@@ -1,91 +1,161 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "faraday"
|
|
4
|
-
require "json"
|
|
5
|
-
|
|
6
3
|
module Basecamp
|
|
7
4
|
module Oauth
|
|
8
|
-
# Fetches OAuth 2
|
|
5
|
+
# Fetches RFC 8414 OAuth 2 Authorization Server Metadata and binds the
|
|
6
|
+
# returned +issuer+ to the requested issuer origin (hop 2 of resource-first
|
|
7
|
+
# discovery).
|
|
9
8
|
class Discovery
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
# Structured marker: AS metadata failed the RFC 8414 issuer code-point
|
|
10
|
+
# bind. Raised (never message-matched) so {Oauth.discover_from_resource}
|
|
11
|
+
# classifies an issuer mismatch by CLASS via {Oauth.as_failure_error} —
|
|
12
|
+
# brittle, locale-sensitive substring matching is gone. Kept in the
|
|
13
|
+
# discovery layer, deliberately NOT in the device error files that
|
|
14
|
+
# {DeviceFlowError} shares.
|
|
15
|
+
class IssuerBindingError < OauthError
|
|
16
|
+
def initialize(message, http_status: nil)
|
|
17
|
+
super("api_error", message, http_status: http_status)
|
|
18
|
+
end
|
|
14
19
|
end
|
|
15
20
|
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
|
|
21
|
+
# @param http_client [Faraday::Connection, nil] HTTP client (SSRF-hardened default if nil)
|
|
22
|
+
# @param timeout [Integer] request timeout in seconds (default: 10)
|
|
23
|
+
# @param max_body_bytes [Integer] bounded read cap in bytes
|
|
24
|
+
def initialize(http_client: nil, timeout: 10, max_body_bytes: Fetcher::DEFAULT_MAX_BODY_BYTES)
|
|
25
|
+
Fetcher.ensure_redirects_suppressed!(http_client) if http_client
|
|
26
|
+
# Normalize before building the client and before the fetch computes its
|
|
27
|
+
# wall-clock deadline: a non-finite/non-positive timeout must not disable
|
|
28
|
+
# either bound (see Fetcher.normalize_timeout).
|
|
29
|
+
@timeout = Fetcher.normalize_timeout(timeout)
|
|
30
|
+
@http_client = http_client || Fetcher.build_client(@timeout)
|
|
31
|
+
# Normalize the public cap to a finite non-negative Integer: a nil, float,
|
|
32
|
+
# or Float::INFINITY would otherwise disable the streaming memory bound
|
|
33
|
+
# (an infinite/undefined cap never trips +total > max_body_bytes+),
|
|
34
|
+
# reintroducing an SSRF/OOM risk. Mirrors Resource#initialize.
|
|
35
|
+
@max_body_bytes =
|
|
36
|
+
if max_body_bytes.is_a?(Integer) && max_body_bytes >= 0
|
|
37
|
+
max_body_bytes
|
|
38
|
+
else
|
|
39
|
+
Fetcher::DEFAULT_MAX_BODY_BYTES
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Discovers OAuth configuration from
|
|
44
|
+
# <tt>{base_url}/.well-known/oauth-authorization-server</tt>, binding the
|
|
45
|
+
# returned +issuer+ to +base_url+ by code-point (RFC 8414 §3.3/§4, no
|
|
46
|
+
# normalization beyond origin-root parsing). +token_endpoint+ is required;
|
|
47
|
+
# +authorization_endpoint+ is optional (device-only servers omit it).
|
|
20
48
|
#
|
|
21
|
-
# @param base_url [String]
|
|
22
|
-
# @return [Config]
|
|
23
|
-
# @raise [
|
|
49
|
+
# @param base_url [String] the OAuth server's issuer origin
|
|
50
|
+
# @return [Config] the OAuth server configuration
|
|
51
|
+
# @raise [Basecamp::UsageError] on a malformed origin
|
|
52
|
+
# @raise [OauthError] +api_error+ on invalid metadata / issuer mismatch
|
|
24
53
|
#
|
|
25
54
|
# @example
|
|
26
|
-
#
|
|
27
|
-
# config
|
|
28
|
-
# puts config.token_endpoint
|
|
29
|
-
# # => "https://launchpad.37signals.com/authorization/token"
|
|
55
|
+
# config = Basecamp::Oauth::Discovery.new.discover("https://launchpad.37signals.com")
|
|
56
|
+
# config.token_endpoint # => "https://launchpad.37signals.com/authorization/token"
|
|
30
57
|
def discover(base_url)
|
|
31
|
-
Basecamp::Security.
|
|
58
|
+
issuer_origin = Basecamp::Security.require_origin_root!(base_url, "OAuth discovery base URL")
|
|
59
|
+
# Bind against the caller's raw +base_url+ (RFC 8414 §3.3, SPEC.md §16 "NO
|
|
60
|
+
# normalization"); the normalized origin is only for the fetch URL.
|
|
61
|
+
discover_and_bind(issuer_origin, base_url)
|
|
62
|
+
end
|
|
32
63
|
|
|
33
|
-
|
|
34
|
-
|
|
64
|
+
# Resource-first entry: validate +advertised_issuer+ as an origin root, then
|
|
65
|
+
# fetch from the normalized origin and bind the AS metadata +issuer+ against
|
|
66
|
+
# the RAW advertised string by code-point. Routing and binding are distinct —
|
|
67
|
+
# an AS whose issuer matches what the resource advertised (e.g. a trailing
|
|
68
|
+
# slash or explicit default port) binds instead of being normalized away into
|
|
69
|
+
# a false +issuer_mismatch+. Validation happens HERE so there is NO public
|
|
70
|
+
# unvalidated-fetch entry point: the SSRF origin policy is always enforced.
|
|
71
|
+
#
|
|
72
|
+
# @param advertised_issuer [String] the raw issuer string a resource advertised
|
|
73
|
+
# @raise [Basecamp::UsageError] on a malformed advertised origin
|
|
74
|
+
def discover_advertised(advertised_issuer)
|
|
75
|
+
issuer_origin = Basecamp::Security.require_origin_root!(advertised_issuer, "advertised issuer")
|
|
76
|
+
discover_and_bind(issuer_origin, advertised_issuer)
|
|
77
|
+
end
|
|
35
78
|
|
|
36
|
-
|
|
37
|
-
req.headers["Accept"] = "application/json"
|
|
38
|
-
end
|
|
79
|
+
private
|
|
39
80
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
81
|
+
# Fetch AS metadata from +issuer_origin+ (an ALREADY-VALIDATED origin root)
|
|
82
|
+
# but bind the returned +issuer+ against +bind_issuer+ by code-point. Kept
|
|
83
|
+
# private: it does no origin validation of its own, so exposing it would be
|
|
84
|
+
# an unvalidated-fetch entry point that defeats the SSRF origin policy.
|
|
85
|
+
def discover_and_bind(issuer_origin, bind_issuer)
|
|
86
|
+
discovery_url = "#{issuer_origin}/.well-known/oauth-authorization-server"
|
|
87
|
+
data = Fetcher.fetch_json(@http_client, discovery_url, timeout: @timeout, max_body_bytes: @max_body_bytes)
|
|
88
|
+
parse_and_bind(data, bind_issuer)
|
|
46
89
|
end
|
|
47
90
|
|
|
48
|
-
|
|
91
|
+
# Universal validation only: +issuer+ + +token_endpoint+ present and
|
|
92
|
+
# non-empty, issuer identical by code-point, and any present +*_endpoint+
|
|
93
|
+
# field non-empty. Per-grant endpoint checks are the consumer's job.
|
|
94
|
+
def parse_and_bind(data, expected_issuer_origin)
|
|
95
|
+
issuer = data["issuer"]
|
|
96
|
+
if !issuer.is_a?(String) || issuer.empty?
|
|
97
|
+
raise OauthError.new("api_error", "Invalid OAuth discovery response: missing required fields: issuer")
|
|
98
|
+
end
|
|
49
99
|
|
|
50
|
-
|
|
51
|
-
|
|
100
|
+
# RFC 8414 §3.3/§4: issuer identical by code-point. No normalization.
|
|
101
|
+
unless issuer == expected_issuer_origin
|
|
102
|
+
raise IssuerBindingError.new(
|
|
103
|
+
"OAuth issuer mismatch: metadata issuer #{issuer.inspect} does not equal #{expected_issuer_origin.inspect}"
|
|
104
|
+
)
|
|
105
|
+
end
|
|
52
106
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
registration_endpoint: data["registration_endpoint"],
|
|
58
|
-
scopes_supported: data["scopes_supported"]
|
|
59
|
-
)
|
|
60
|
-
rescue Faraday::Error => e
|
|
61
|
-
raise OauthError.new("network", "OAuth discovery failed: #{e.message}", retryable: true)
|
|
62
|
-
rescue JSON::ParserError => e
|
|
63
|
-
raise OauthError.new("api_error", "Failed to parse discovery response: #{e.message}")
|
|
64
|
-
end
|
|
107
|
+
token_endpoint = data["token_endpoint"]
|
|
108
|
+
if !token_endpoint.is_a?(String) || token_endpoint.empty?
|
|
109
|
+
raise OauthError.new("api_error", "Invalid OAuth discovery response: missing required fields: token_endpoint")
|
|
110
|
+
end
|
|
65
111
|
|
|
66
|
-
|
|
112
|
+
reject_empty_endpoints!(data)
|
|
113
|
+
validate_string_array!(data, "grant_types_supported")
|
|
114
|
+
validate_string_array!(data, "scopes_supported")
|
|
67
115
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
116
|
+
Config.new(
|
|
117
|
+
issuer: issuer,
|
|
118
|
+
authorization_endpoint: data["authorization_endpoint"],
|
|
119
|
+
token_endpoint: token_endpoint,
|
|
120
|
+
device_authorization_endpoint: data["device_authorization_endpoint"],
|
|
121
|
+
registration_endpoint: data["registration_endpoint"],
|
|
122
|
+
scopes_supported: data["scopes_supported"],
|
|
123
|
+
grant_types_supported: data["grant_types_supported"]
|
|
124
|
+
)
|
|
73
125
|
end
|
|
74
|
-
end
|
|
75
126
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
127
|
+
# Any endpoint field that IS present must be a non-empty String: "" is a
|
|
128
|
+
# truthy value in Ruby and must be rejected, and a non-string endpoint
|
|
129
|
+
# (array/number/object, or a present JSON null) is malformed metadata —
|
|
130
|
+
# a present null is NOT the same as an absent key.
|
|
131
|
+
def reject_empty_endpoints!(data)
|
|
132
|
+
data.each do |key, value|
|
|
133
|
+
next unless key.end_with?("_endpoint")
|
|
81
134
|
|
|
82
|
-
|
|
135
|
+
unless value.is_a?(String) && !value.empty?
|
|
136
|
+
raise OauthError.new("api_error", "Invalid OAuth discovery response: invalid #{key}")
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
83
140
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
141
|
+
# A metadata list field (e.g. +grant_types_supported+, +scopes_supported+),
|
|
142
|
+
# when present, must be an array of strings. A bare string must never be
|
|
143
|
+
# accepted: substring-matching +grant_types_supported+ could falsely enable
|
|
144
|
+
# a grant such as device_code, and a non-array is malformed metadata.
|
|
145
|
+
def validate_string_array!(data, key)
|
|
146
|
+
# Distinguish an ABSENT key from a present JSON null: a present null is
|
|
147
|
+
# malformed (the field must be an array of strings when present), while
|
|
148
|
+
# an absent key is legitimately unset.
|
|
149
|
+
return unless data.key?(key)
|
|
150
|
+
|
|
151
|
+
value = data[key]
|
|
152
|
+
unless value.is_a?(Array) && value.all?(String)
|
|
153
|
+
raise OauthError.new(
|
|
154
|
+
"api_error",
|
|
155
|
+
"Invalid OAuth discovery response: #{key} must be an array of strings"
|
|
156
|
+
)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
89
159
|
end
|
|
90
160
|
end
|
|
91
161
|
end
|