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.
@@ -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-03-26T04:17:28Z
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 (e.g., Launchpad).
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
- request(:get, url, params: params)
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
- if path.start_with?("https://")
472
- return path
473
- elsif path.start_with?("http://")
474
- raise Basecamp::UsageError.new("URL must use HTTPS: #{path}")
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 discovery endpoint.
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 server configuration from discovery endpoints.
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
- # @param http_client [Faraday::Connection, nil] HTTP client (uses default if nil)
11
- # @param timeout [Integer] Request timeout in seconds (default: 10)
12
- def initialize(http_client: nil, timeout: 10)
13
- @http_client = http_client || build_default_client(timeout)
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
- # Discovers OAuth configuration from the well-known endpoint.
17
- #
18
- # Fetches the OAuth 2 Authorization Server Metadata from:
19
- # `{base_url}/.well-known/oauth-authorization-server`
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] The OAuth server's base URL (e.g., "https://launchpad.37signals.com")
22
- # @return [Config] The OAuth server configuration
23
- # @raise [OauthError] on network or parsing errors
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
- # discovery = Basecamp::Oauth::Discovery.new
27
- # config = discovery.discover("https://launchpad.37signals.com")
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.require_https_unless_localhost!(base_url, "discovery base URL")
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
- normalized_base = base_url.chomp("/")
34
- discovery_url = "#{normalized_base}/.well-known/oauth-authorization-server"
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
- response = @http_client.get(discovery_url) do |req|
37
- req.headers["Accept"] = "application/json"
38
- end
79
+ private
39
80
 
40
- unless response.success?
41
- raise OauthError.new(
42
- "network",
43
- "OAuth discovery failed with status #{response.status}: #{Basecamp::Security.truncate(response.body)}",
44
- http_status: response.status
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
- Basecamp::Security.check_body_size!(response.body, Basecamp::Security::MAX_ERROR_BODY_BYTES, "Discovery")
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
- data = JSON.parse(response.body)
51
- validate_discovery_response!(data)
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
- Config.new(
54
- issuer: data["issuer"],
55
- authorization_endpoint: data["authorization_endpoint"],
56
- token_endpoint: data["token_endpoint"],
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
- private
112
+ reject_empty_endpoints!(data)
113
+ validate_string_array!(data, "grant_types_supported")
114
+ validate_string_array!(data, "scopes_supported")
67
115
 
68
- def build_default_client(timeout)
69
- Faraday.new do |conn|
70
- conn.options.timeout = timeout
71
- conn.options.open_timeout = timeout
72
- conn.adapter Faraday.default_adapter
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
- def validate_discovery_response!(data)
77
- missing = []
78
- missing << "issuer" unless data["issuer"]
79
- missing << "authorization_endpoint" unless data["authorization_endpoint"]
80
- missing << "token_endpoint" unless data["token_endpoint"]
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
- return if missing.empty?
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
- raise OauthError.new(
85
- "api_error",
86
- "Invalid OAuth discovery response: missing required fields: #{missing.join(", ")}"
87
- )
88
- end
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