basecamp-sdk 0.7.3 → 0.9.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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +65 -1
  3. data/basecamp-sdk.gemspec +2 -3
  4. data/lib/basecamp/client.rb +5 -0
  5. data/lib/basecamp/generated/metadata.json +100 -33
  6. data/lib/basecamp/generated/services/base_service.rb +13 -0
  7. data/lib/basecamp/generated/services/campfires_service.rb +15 -3
  8. data/lib/basecamp/generated/services/card_columns_service.rb +26 -23
  9. data/lib/basecamp/generated/services/checkins_service.rb +5 -4
  10. data/lib/basecamp/generated/services/client_approvals_service.rb +1 -1
  11. data/lib/basecamp/generated/services/client_correspondences_service.rb +1 -1
  12. data/lib/basecamp/generated/services/documents_service.rb +3 -2
  13. data/lib/basecamp/generated/services/forwards_service.rb +1 -1
  14. data/lib/basecamp/generated/services/gauges_service.rb +4 -4
  15. data/lib/basecamp/generated/services/messages_service.rb +4 -3
  16. data/lib/basecamp/generated/services/my_assignments_service.rb +1 -1
  17. data/lib/basecamp/generated/services/my_notifications_service.rb +1 -1
  18. data/lib/basecamp/generated/services/people_service.rb +2 -2
  19. data/lib/basecamp/generated/services/projects_service.rb +2 -2
  20. data/lib/basecamp/generated/services/recordings_service.rb +1 -1
  21. data/lib/basecamp/generated/services/reports_service.rb +3 -3
  22. data/lib/basecamp/generated/services/schedules_service.rb +5 -4
  23. data/lib/basecamp/generated/services/search_service.rb +14 -3
  24. data/lib/basecamp/generated/services/templates_service.rb +4 -5
  25. data/lib/basecamp/generated/services/timesheets_service.rb +3 -3
  26. data/lib/basecamp/generated/services/todolists_service.rb +15 -3
  27. data/lib/basecamp/generated/services/todos_service.rb +5 -5
  28. data/lib/basecamp/generated/services/tools_service.rb +7 -6
  29. data/lib/basecamp/generated/services/uploads_service.rb +20 -2
  30. data/lib/basecamp/generated/services/webhooks_service.rb +2 -2
  31. data/lib/basecamp/generated/services/wormholes_service.rb +44 -0
  32. data/lib/basecamp/generated/types.rb +284 -64
  33. data/lib/basecamp/http.rb +86 -15
  34. data/lib/basecamp/oauth/config.rb +20 -5
  35. data/lib/basecamp/oauth/discovery.rb +134 -64
  36. data/lib/basecamp/oauth/discovery_result.rb +33 -0
  37. data/lib/basecamp/oauth/discovery_selection_error.rb +30 -0
  38. data/lib/basecamp/oauth/fetcher.rb +204 -0
  39. data/lib/basecamp/oauth/protected_resource_metadata.rb +22 -0
  40. data/lib/basecamp/oauth/resource.rb +95 -0
  41. data/lib/basecamp/oauth.rb +173 -0
  42. data/lib/basecamp/security.rb +83 -0
  43. data/lib/basecamp/services/authorization_service.rb +45 -0
  44. data/lib/basecamp/services/todos_extensions.rb +121 -0
  45. data/lib/basecamp/version.rb +2 -2
  46. data/lib/basecamp.rb +6 -0
  47. data/scripts/generate-services.rb +61 -13
  48. data/scripts/generate-types.rb +33 -1
  49. metadata +14 -21
  50. data/lib/basecamp/generated/services/authorization_service.rb +0 -47
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
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ module Oauth
5
+ # Result of {Oauth.discover_from_resource}: either a *selected* AS config, or
6
+ # a *soft* fallback to Launchpad. Hard failures are raised as
7
+ # {DiscoverySelectionError}, never represented here — so no consumer can
8
+ # convert a hard failure into a Launchpad request.
9
+ #
10
+ # @attr kind [Symbol] +:selected+ or +:fallback+
11
+ # @attr config [Config, nil] the selected AS config (when +:selected+)
12
+ # @attr issuer [String, nil] the selected issuer (when +:selected+)
13
+ # @attr reason [String, nil] the soft fallback reason (when +:fallback+):
14
+ # +"resource_discovery_failed"+ or +"no_as_advertised"+
15
+ DiscoveryResult = Data.define(:kind, :config, :issuer, :reason) do
16
+ def self.selected(config)
17
+ new(kind: :selected, config: config, issuer: config.issuer, reason: nil)
18
+ end
19
+
20
+ def self.fallback(reason)
21
+ new(kind: :fallback, config: nil, issuer: nil, reason: reason)
22
+ end
23
+
24
+ def selected?
25
+ kind == :selected
26
+ end
27
+
28
+ def fallback?
29
+ kind == :fallback
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ module Oauth
5
+ # A hard resource-first selection/validation failure. Raised — never returned
6
+ # as a fallback — so no consumer can convert it into a Launchpad request.
7
+ #
8
+ # The +reason+ is one of:
9
+ # +ambiguous_issuers+, +expected_issuer_unavailable+, +invalid_issuer_origin+,
10
+ # +as_fetch_failed+, +issuer_mismatch+, +capability_unavailable+.
11
+ #
12
+ # @attr reason [String] the hard-failure classification
13
+ class DiscoverySelectionError < OauthError
14
+ attr_reader :reason
15
+
16
+ # @param reason [String] the hard-failure classification
17
+ # @param message [String] human-readable description
18
+ # @param http_status [Integer, nil] HTTP status code, if applicable
19
+ def initialize(reason, message, http_status: nil)
20
+ # Only capability_unavailable is consumer/usage-shaped (validation). Every
21
+ # other reason — including expected_issuer_unavailable — is an AS metadata
22
+ # fault surfaced as api_error, matching the other four SDKs (an issuer the
23
+ # resource does not advertise is a metadata fault, not a caller-usage one).
24
+ type = reason == "capability_unavailable" ? "validation" : "api_error"
25
+ super(type, message, http_status: http_status)
26
+ @reason = reason
27
+ end
28
+ end
29
+ end
30
+ end