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.
@@ -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
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+
6
+ module Basecamp
7
+ module Oauth
8
+ # SSRF-hardened fetch of a small OAuth discovery JSON document, shared by both
9
+ # discovery hops (RFC 9728 resource metadata and RFC 8414 AS metadata).
10
+ #
11
+ # RFC 9728 §7.7 flags SSRF via attacker-influenced metadata: advertised AS
12
+ # URLs are untrusted input. Every fetch therefore:
13
+ #
14
+ # 1. requires HTTPS (localhost exempt) — enforced by the origin-root profile
15
+ # ({Basecamp::Security.require_origin_root!}) before this is called;
16
+ # 2. bounds the timeout — both a per-read socket timeout AND a monotonic
17
+ # wall-clock deadline over the whole streaming read (a per-read timeout
18
+ # alone resets on every chunk, so a slow-drip peer could hang the fetch);
19
+ # 3. suppresses redirects — the default Faraday connection carries no redirect
20
+ # middleware, so an attacker-controlled 3xx +Location+ is surfaced as a
21
+ # non-2xx +api_error+ rather than chased;
22
+ # 4. reads the body under a genuine bounded/streaming cap that aborts the read
23
+ # the moment the accumulated size exceeds the limit (via Faraday's +on_data+
24
+ # streaming callback) — NOT a post-hoc size check on an already-buffered
25
+ # body.
26
+ #
27
+ # Non-2xx on either hop maps to +api_error+ (not +network+).
28
+ module Fetcher
29
+ # Discovery documents are tiny; cap the read at 1 MiB by default.
30
+ DEFAULT_MAX_BODY_BYTES = 1 * 1024 * 1024
31
+
32
+ # Default request timeout in seconds when a caller supplies none or an
33
+ # invalid one.
34
+ DEFAULT_TIMEOUT = 10
35
+
36
+ # Coerce the public timeout to a finite, positive numeric. A nil, non-numeric,
37
+ # non-positive, or +Float::INFINITY+/+NaN+ value would otherwise disable BOTH
38
+ # the socket timeout and the wall-clock deadline in {fetch_json} (+now + inf+
39
+ # never trips), letting a slow-drip peer hold the fetch open indefinitely.
40
+ # Mirrors the +max_body_bytes+ normalization in the discovery initializers.
41
+ #
42
+ # @param timeout [Object] caller-supplied timeout
43
+ # @return [Numeric] a finite, positive timeout in seconds
44
+ def self.normalize_timeout(timeout)
45
+ # +real?+ gates out Complex before +finite?+/+positive?+ (which Complex does
46
+ # not define — calling them would raise NoMethodError). Integer, Float, and
47
+ # Rational are all real and answer both.
48
+ return timeout if timeout.is_a?(Numeric) && timeout.real? && timeout.finite? && timeout.positive?
49
+
50
+ DEFAULT_TIMEOUT
51
+ end
52
+
53
+ # Raised internally to abort a streaming read once the cap is exceeded.
54
+ # Never escapes this module — it is mapped to an OauthError.
55
+ class BodyTooLarge < StandardError; end
56
+
57
+ # Raised internally when a streaming read exceeds its wall-clock deadline.
58
+ # Never escapes this module — it is mapped to a retryable +network+ OauthError.
59
+ class ReadDeadlineExceeded < StandardError; end
60
+
61
+ # Builds a +[chunks, on_data]+ pair for a genuine bounded/streaming read.
62
+ # Assign +on_data+ to a request's +req.options.on_data+; after the request
63
+ # returns, +chunks.join+ is the accumulated body. The proc raises
64
+ # {BodyTooLarge} the moment the accumulated size exceeds +max_body_bytes+,
65
+ # so an oversized body is never fully buffered. Callers rescue
66
+ # {BodyTooLarge} and map it to their own error. Shared by both discovery
67
+ # hops and the device flow so every OAuth response reads under the same cap.
68
+ #
69
+ # +req.options.timeout+ only bounds each individual socket read, and every
70
+ # +on_data+ chunk resets it — so a peer dripping one byte before each read
71
+ # timeout can hold the connection open arbitrarily long without ever tripping
72
+ # the cap. When a monotonic +deadline+ is supplied, the proc raises
73
+ # {ReadDeadlineExceeded} the moment the WHOLE read outlives it, matching the
74
+ # wall-clock bound the other SDKs enforce (Python's monotonic deadline, Go's
75
+ # context, TS's abort timer, Kotlin's requestTimeoutMillis).
76
+ #
77
+ # @param max_body_bytes [Integer] bounded read cap in bytes
78
+ # @param deadline [Float, nil] monotonic clock deadline (CLOCK_MONOTONIC seconds)
79
+ # @return [Array(Array<String>, Proc)] the chunk buffer and the +on_data+ proc
80
+ def self.bounded_reader(max_body_bytes, deadline: nil)
81
+ chunks = []
82
+ total = 0
83
+ reader = proc do |chunk, _received|
84
+ if deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
85
+ raise ReadDeadlineExceeded
86
+ end
87
+
88
+ total += chunk.bytesize
89
+ raise BodyTooLarge if total > max_body_bytes
90
+
91
+ chunks << chunk
92
+ end
93
+ [ chunks, reader ]
94
+ end
95
+
96
+ # Builds the default SSRF-hardened Faraday connection. No redirect
97
+ # middleware is registered, so redirects are not followed.
98
+ #
99
+ # @param timeout [Integer] request + connect timeout in seconds
100
+ # @return [Faraday::Connection]
101
+ def self.build_client(timeout)
102
+ Faraday.new do |conn|
103
+ conn.options.timeout = timeout
104
+ conn.options.open_timeout = timeout
105
+ conn.adapter Faraday.default_adapter
106
+ end
107
+ end
108
+
109
+ # Rejects an INJECTED connection whose middleware stack we cannot verify to
110
+ # be redirect-free. Redirect suppression is a load-bearing SSRF control (RFC
111
+ # 9728 §7.7): a caller-supplied client that follows redirects would silently
112
+ # chase an attacker-controlled +Location+. A class-NAME heuristic (matching
113
+ # +/redirect/+) is bypassable by a follower whose class name does not contain
114
+ # "redirect", so we enforce a POLICY instead of guessing by name: an injected
115
+ # connection may carry ONLY adapter handlers. The default {build_client}
116
+ # connection (adapter only) and a test's mock adapter qualify; ANY request/
117
+ # response middleware — which could follow redirects under any name, or
118
+ # otherwise rewrite the request — is refused rather than trusted.
119
+ #
120
+ # @param client [Faraday::Connection]
121
+ # @raise [OauthError] +validation+ when non-adapter middleware is present
122
+ def self.ensure_redirects_suppressed!(client)
123
+ return unless client.respond_to?(:builder)
124
+
125
+ builder = client.builder
126
+ handlers = Array(builder.handlers)
127
+ # Faraday keeps the TERMINAL adapter handler OUTSIDE builder.handlers, so
128
+ # a redirect-follower smuggled into the adapter slot (+conn.adapter Follower+,
129
+ # not validated to be a Faraday::Adapter subclass) would evade a
130
+ # handlers-only scan and run as the terminal app. Fold the adapter into
131
+ # the same policy check: a genuine adapter (<= Faraday::Adapter) passes;
132
+ # any non-adapter class in that slot is refused.
133
+ handlers += [ builder.adapter ] if builder.respond_to?(:adapter)
134
+ offending = handlers.compact.find do |h|
135
+ h.respond_to?(:klass) && h.klass.is_a?(Class) && !(h.klass <= Faraday::Adapter)
136
+ end
137
+ return unless offending
138
+
139
+ raise OauthError.new(
140
+ "validation",
141
+ "Injected OAuth discovery client must carry only an adapter (no middleware); " \
142
+ "found #{offending.klass.name}. Redirects are suppressed for SSRF safety, so a " \
143
+ "connection whose middleware stack cannot be verified redirect-free is refused"
144
+ )
145
+ end
146
+
147
+ # Fetches +url+ and returns the parsed JSON object (a Hash).
148
+ #
149
+ # The request timeout is applied per-request (not only on the connection)
150
+ # so a bounded read is enforced even when the caller INJECTS its own
151
+ # connection: an injected client's adapter default would otherwise leave the
152
+ # requested +timeout+ unenforced. This mirrors the device flow's +post_form+.
153
+ #
154
+ # @param http_client [Faraday::Connection] the SSRF-hardened connection
155
+ # @param url [String] fully-qualified well-known URL to fetch
156
+ # @param timeout [Integer] per-request timeout in seconds
157
+ # @param max_body_bytes [Integer] bounded read cap in bytes
158
+ # @return [Hash] the parsed JSON document
159
+ # @raise [OauthError] +api_error+ on non-2xx, oversized body, non-object
160
+ # JSON, or parse failure; +network+ on transport failure
161
+ def self.fetch_json(http_client, url, timeout:, max_body_bytes: DEFAULT_MAX_BODY_BYTES)
162
+ # Wall-clock deadline over the WHOLE read: req.options.timeout below bounds
163
+ # only each socket read and resets on every chunk, so a slow-drip peer could
164
+ # otherwise hang the fetch indefinitely while staying under max_body_bytes.
165
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
166
+ chunks, on_data = bounded_reader(max_body_bytes, deadline: deadline)
167
+
168
+ response = http_client.get(url) do |req|
169
+ req.headers["Accept"] = "application/json"
170
+ # Bounded streaming read: abort the moment the cap is exceeded so an
171
+ # oversized body is never fully buffered.
172
+ req.options.on_data = on_data
173
+ # Apply the request timeout on every request — even an injected client —
174
+ # so a stalled socket can't hang discovery under the adapter default.
175
+ req.options.timeout = timeout
176
+ req.options.open_timeout = timeout
177
+ end
178
+
179
+ body = chunks.join.force_encoding(Encoding::UTF_8)
180
+
181
+ unless (200..299).cover?(response.status)
182
+ raise OauthError.new(
183
+ "api_error",
184
+ "OAuth discovery failed with status #{response.status}: #{Basecamp::Security.truncate(body)}",
185
+ http_status: response.status
186
+ )
187
+ end
188
+
189
+ data = JSON.parse(body)
190
+ raise OauthError.new("api_error", "OAuth discovery response is not a JSON object") unless data.is_a?(Hash)
191
+
192
+ data
193
+ rescue BodyTooLarge
194
+ raise OauthError.new("api_error", "OAuth discovery response exceeds size cap")
195
+ rescue ReadDeadlineExceeded
196
+ raise OauthError.new("network", "OAuth discovery timed out", retryable: true)
197
+ rescue Faraday::Error => e
198
+ raise OauthError.new("network", "OAuth discovery failed: #{e.message}", retryable: true)
199
+ rescue JSON::ParserError => e
200
+ raise OauthError.new("api_error", "Failed to parse OAuth discovery response: #{e.message}")
201
+ end
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ module Oauth
5
+ # RFC 9728 protected-resource metadata (hop 1 of resource-first discovery).
6
+ #
7
+ # +authorization_servers+ preserves "key absent" and "present but empty
8
+ # <tt>[]</tt>" distinctly: BC5 omits the key while dark, per RFC 9728 §3.2.
9
+ # Both select Launchpad, but the distinction is meaningful to callers
10
+ # inspecting metadata directly (absent => +nil+, empty => +[]+).
11
+ #
12
+ # @attr resource [String] The resource identifier; equals the requested
13
+ # resource origin by code-point.
14
+ # @attr authorization_servers [Array<String>, nil] Advertised authorization
15
+ # servers; +nil+ when the key is absent, +[]+ when present but empty.
16
+ ProtectedResourceMetadata = Data.define(:resource, :authorization_servers) do
17
+ def initialize(resource:, authorization_servers: nil)
18
+ super
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ module Oauth
5
+ # Fetches RFC 9728 protected-resource metadata (hop 1 of resource-first
6
+ # discovery) and binds the returned +resource+ to the requested origin.
7
+ class Resource
8
+ # @param http_client [Faraday::Connection, nil] HTTP client (SSRF-hardened default if nil)
9
+ # @param timeout [Integer] request timeout in seconds (default: 10)
10
+ # @param max_body_bytes [Integer] bounded read cap in bytes
11
+ def initialize(http_client: nil, timeout: 10, max_body_bytes: Fetcher::DEFAULT_MAX_BODY_BYTES)
12
+ Fetcher.ensure_redirects_suppressed!(http_client) if http_client
13
+ # Normalize before building the client and before the fetch computes its
14
+ # wall-clock deadline: a non-finite/non-positive timeout must not disable
15
+ # either bound (see Fetcher.normalize_timeout).
16
+ @timeout = Fetcher.normalize_timeout(timeout)
17
+ @http_client = http_client || Fetcher.build_client(@timeout)
18
+ # Normalize the public cap to a finite non-negative Integer: a nil, float,
19
+ # or Float::INFINITY would otherwise disable the streaming memory bound
20
+ # (an infinite/undefined cap never trips), reintroducing an SSRF/OOM risk.
21
+ @max_body_bytes =
22
+ if max_body_bytes.is_a?(Integer) && max_body_bytes >= 0
23
+ max_body_bytes
24
+ else
25
+ Fetcher::DEFAULT_MAX_BODY_BYTES
26
+ end
27
+ end
28
+
29
+ # Discovers protected-resource metadata from
30
+ # <tt>{resource_origin}/.well-known/oauth-protected-resource</tt>.
31
+ # +resource+ is required and must equal the requested origin by code-point;
32
+ # +authorization_servers+ is preserved distinctly as absent (+nil+) vs
33
+ # present-empty (+[]+).
34
+ #
35
+ # @param resource_origin [String] the API/resource host origin
36
+ # @return [ProtectedResourceMetadata]
37
+ # @raise [Basecamp::UsageError] on a malformed caller origin
38
+ # @raise [OauthError] +api_error+ on invalid metadata / resource mismatch
39
+ def discover(resource_origin)
40
+ origin = Basecamp::Security.require_origin_root!(resource_origin, "resource origin")
41
+ url = "#{origin}/.well-known/oauth-protected-resource"
42
+ data = Fetcher.fetch_json(@http_client, url, timeout: @timeout, max_body_bytes: @max_body_bytes)
43
+
44
+ resource = data["resource"]
45
+ # Type-check, don't just probe truthiness: a wrong-typed resource (number,
46
+ # object, array) is malformed metadata, and calling +.empty?+ on it would
47
+ # raise a NoMethodError rather than a clean api_error.
48
+ if !resource.is_a?(String) || resource.empty?
49
+ raise OauthError.new("api_error", "Invalid resource metadata: missing required field: resource")
50
+ end
51
+
52
+ # Bind the resource identifier to the requested identifier (the raw caller
53
+ # origin), code-point exact, NO normalization (RFC 9728 §3.3, SPEC.md §16):
54
+ # the well-known URL is built from the normalized +origin+, but the metadata
55
+ # +resource+ must be identical to what the caller supplied.
56
+ unless resource == resource_origin
57
+ raise OauthError.new(
58
+ "api_error",
59
+ "Resource identifier mismatch: metadata resource #{resource.inspect} does not equal #{resource_origin.inspect}"
60
+ )
61
+ end
62
+
63
+ ProtectedResourceMetadata.new(
64
+ resource: resource,
65
+ authorization_servers: extract_authorization_servers(data)
66
+ )
67
+ end
68
+
69
+ private
70
+
71
+ # Preserve absent (+nil+) vs present-empty (+[]+). A present value that is
72
+ # not an array of strings — including a JSON +null+ — is malformed metadata
73
+ # and is rejected (→ soft resource_discovery_failed in the orchestrator),
74
+ # never iterated (a bare string would otherwise be treated as a sequence of
75
+ # single-character issuers during selection) and never normalized to +[]+.
76
+ def extract_authorization_servers(data)
77
+ return nil unless data.key?("authorization_servers")
78
+
79
+ servers = data["authorization_servers"]
80
+ # A present JSON null (or any non-array-of-strings) is MALFORMED metadata,
81
+ # not "present but empty": it must fail hop-1 (→ soft resource_discovery_failed
82
+ # in the orchestrator), never be normalized to [] and read as no_as_advertised.
83
+ # An empty array is a valid "present but empty" value and is preserved.
84
+ unless servers.is_a?(Array) && servers.all?(String)
85
+ raise OauthError.new(
86
+ "api_error",
87
+ "Invalid resource metadata: authorization_servers must be an array of strings"
88
+ )
89
+ end
90
+
91
+ servers
92
+ end
93
+ end
94
+ end
95
+ end
@@ -46,6 +46,18 @@ module Basecamp
46
46
  module Oauth
47
47
  LAUNCHPAD_BASE_URL = "https://launchpad.37signals.com"
48
48
 
49
+ # Soft fallback reasons — the ONLY two outcomes under which
50
+ # {discover_from_resource} yields a fallback (Launchpad) rather than a
51
+ # selected config. Every other failure raises {DiscoverySelectionError}.
52
+ FALLBACK_RESOURCE_DISCOVERY_FAILED = "resource_discovery_failed"
53
+ FALLBACK_NO_AS_ADVERTISED = "no_as_advertised"
54
+
55
+ # Discovers RFC 8414 Authorization Server Metadata and binds +issuer+ to
56
+ # +base_url+ by code-point.
57
+ #
58
+ # @param base_url [String] the OAuth server's issuer origin
59
+ # @param timeout [Integer] request timeout in seconds
60
+ # @return [Config]
49
61
  def self.discover(base_url, timeout: 10)
50
62
  Discovery.new(timeout: timeout).discover(base_url)
51
63
  end
@@ -54,6 +66,56 @@ module Basecamp
54
66
  discover(LAUNCHPAD_BASE_URL, timeout: timeout)
55
67
  end
56
68
 
69
+ # Discovers RFC 9728 protected-resource metadata for a resource origin.
70
+ #
71
+ # @param resource_origin [String] the API/resource host origin
72
+ # @param timeout [Integer] request timeout in seconds
73
+ # @return [ProtectedResourceMetadata]
74
+ def self.discover_protected_resource(resource_origin, timeout: 10)
75
+ Resource.new(timeout: timeout).discover(resource_origin)
76
+ end
77
+
78
+ # Resource-first discovery orchestrator (SPEC.md §16). Composes RFC 9728
79
+ # (resource metadata) and RFC 8414 (AS metadata) and applies the
80
+ # stage-sensitive fallback state machine.
81
+ #
82
+ # Returns a {DiscoveryResult} that is either +selected+ (a bound AS config)
83
+ # or a soft +fallback+ whose reason is +resource_discovery_failed+ or
84
+ # +no_as_advertised+ ONLY. Every hard failure raises
85
+ # {DiscoverySelectionError} — callers MUST NOT convert a raise into a
86
+ # Launchpad request.
87
+ #
88
+ # @param resource_origin [String] the API/resource host origin
89
+ # @param expected_issuer [String, nil] explicit, authoritative issuer
90
+ # selection. When provided, the advertised member equal by code-point is
91
+ # selected; if none matches, +expected_issuer_unavailable+ is raised (never
92
+ # a fallback). Omit to use the Basecamp-profile exclusion heuristic.
93
+ # @param timeout [Integer] request timeout in seconds
94
+ # @return [DiscoveryResult]
95
+ # @raise [Basecamp::UsageError] on a malformed caller +resource_origin+
96
+ # @raise [DiscoverySelectionError] on any hard selection/validation failure
97
+ def self.discover_from_resource(resource_origin, expected_issuer: nil, timeout: 10)
98
+ # Origin-root validation of the *caller's* input is a usage error — let it
99
+ # propagate as-is (not a soft fallback).
100
+ # Hop 1: resource metadata. Any failure here is soft (before selection).
101
+ # Pass the RAW resource_origin so binding is code-point-exact against the
102
+ # caller's identifier (SPEC.md §16); discover_protected_resource normalizes
103
+ # only its fetch URL. A malformed caller origin raises UsageError → re-raised.
104
+ resource = begin
105
+ discover_protected_resource(resource_origin, timeout: timeout)
106
+ rescue Basecamp::UsageError
107
+ raise
108
+ rescue OauthError
109
+ nil
110
+ end
111
+
112
+ if resource.nil?
113
+ DiscoveryResult.fallback(FALLBACK_RESOURCE_DISCOVERY_FAILED)
114
+ else
115
+ select_and_bind(resource.authorization_servers || [], expected_issuer, timeout)
116
+ end
117
+ end
118
+
57
119
  def self.exchange_code(
58
120
  token_endpoint:, code:, redirect_uri:, client_id:,
59
121
  client_secret: nil, code_verifier: nil,
@@ -84,5 +146,116 @@ module Basecamp
84
146
  def self.token_expired?(token, buffer_seconds = 60)
85
147
  token.expired?(buffer_seconds)
86
148
  end
149
+
150
+ # Selects an issuer from the advertised set and — once a BC5 issuer is
151
+ # committed — binds its AS metadata. From this point on, every failure is
152
+ # fatal: no Launchpad request may be issued.
153
+ #
154
+ # @return [DiscoveryResult] +selected+, or +fallback(no_as_advertised)+ when
155
+ # the advertised set carries no non-Launchpad issuer.
156
+ def self.select_and_bind(advertised, expected_issuer, timeout)
157
+ selected_issuer =
158
+ if expected_issuer
159
+ select_expected(advertised, expected_issuer)
160
+ else
161
+ select_by_exclusion(advertised)
162
+ end
163
+
164
+ if selected_issuer.nil?
165
+ # Valid resource metadata omits BC5 — soft fallback (before selection).
166
+ DiscoveryResult.fallback(FALLBACK_NO_AS_ADVERTISED)
167
+ else
168
+ bind_issuer(selected_issuer, timeout)
169
+ end
170
+ end
171
+
172
+ # Explicit, authoritative selection: the advertised member equal by
173
+ # code-point, else a hard +expected_issuer_unavailable+.
174
+ def self.select_expected(advertised, expected_issuer)
175
+ match = advertised.find { |server| server == expected_issuer }
176
+ if match.nil?
177
+ raise DiscoverySelectionError.new(
178
+ "expected_issuer_unavailable",
179
+ "Expected issuer #{expected_issuer.inspect} is not advertised by the resource"
180
+ )
181
+ end
182
+
183
+ match
184
+ end
185
+
186
+ # Basecamp-profile heuristic: identification by exclusion. Exactly one
187
+ # non-Launchpad issuer selects it; two or more is a hard +ambiguous_issuers+
188
+ # (never guess); zero returns +nil+ (caller yields the soft fallback).
189
+ def self.select_by_exclusion(advertised)
190
+ # Dedupe by code-point: the same non-Launchpad issuer advertised twice
191
+ # (e.g. [BC5, BC5, Launchpad]) is ONE candidate, not an ambiguity.
192
+ non_launchpad = advertised.reject { |server| launchpad_issuer?(server) }.uniq
193
+ if non_launchpad.length >= 2
194
+ raise DiscoverySelectionError.new(
195
+ "ambiguous_issuers",
196
+ "Multiple non-Launchpad issuers advertised; pass expected_issuer to disambiguate: #{non_launchpad.join(", ")}"
197
+ )
198
+ end
199
+
200
+ non_launchpad.first
201
+ end
202
+
203
+ # BC5 is committed: validate the advertised issuer origin then fetch + bind
204
+ # its AS metadata. Every failure here is a hard {DiscoverySelectionError}.
205
+ def self.bind_issuer(selected_issuer, timeout)
206
+ config = begin
207
+ # discover_advertised validates the advertised issuer as an origin root,
208
+ # then fetches from the normalized origin and binds the AS metadata issuer
209
+ # against the RAW advertised string by code-point — so an AS whose issuer
210
+ # equals what the resource advertised is not rejected merely because
211
+ # normalization dropped a trailing slash / default port before the bind.
212
+ # It exposes no unvalidated-fetch entry point (the SSRF origin policy holds).
213
+ Discovery.new(timeout: timeout).discover_advertised(selected_issuer)
214
+ rescue Basecamp::UsageError => e
215
+ raise DiscoverySelectionError.new(
216
+ "invalid_issuer_origin",
217
+ "Advertised issuer #{selected_issuer.inspect} is not a valid origin root: #{e.message}"
218
+ )
219
+ rescue OauthError => e
220
+ raise as_failure_error(selected_issuer, e)
221
+ end
222
+
223
+ DiscoveryResult.selected(config)
224
+ end
225
+
226
+ # Distinguishes an issuer-binding mismatch from a generic AS fetch failure by
227
+ # the error's CLASS — a structured marker ({Discovery::IssuerBindingError})
228
+ # raised by the binding check — not by matching its message text.
229
+ def self.as_failure_error(issuer_origin, error)
230
+ case error
231
+ when Discovery::IssuerBindingError
232
+ DiscoverySelectionError.new("issuer_mismatch", error.message)
233
+ else
234
+ DiscoverySelectionError.new(
235
+ "as_fetch_failed",
236
+ "AS metadata fetch failed for committed issuer #{issuer_origin.inspect}: #{error.message}"
237
+ )
238
+ end
239
+ end
240
+
241
+ # True when an advertised issuer string denotes the Launchpad origin. A
242
+ # non-origin-root advertised value is (correctly) treated as non-Launchpad;
243
+ # its later origin-root validation raises +invalid_issuer_origin+. Comparison
244
+ # is origin-aware (scheme + normalized host + port) via {Security.same_origin?}
245
+ # so a case-variant host (e.g. +Launchpad.37signals.COM+) still classifies as
246
+ # Launchpad rather than slipping through as a BC5 issuer.
247
+ def self.launchpad_issuer?(issuer)
248
+ origin = Basecamp::Security.require_origin_root!(issuer, "issuer")
249
+ Basecamp::Security.same_origin?(origin, launchpad_origin)
250
+ rescue Basecamp::UsageError
251
+ false
252
+ end
253
+
254
+ def self.launchpad_origin
255
+ @launchpad_origin ||= Basecamp::Security.require_origin_root!(LAUNCHPAD_BASE_URL, "Launchpad base URL")
256
+ end
257
+
258
+ private_class_method :select_and_bind, :select_expected, :select_by_exclusion,
259
+ :bind_issuer, :as_failure_error, :launchpad_issuer?, :launchpad_origin
87
260
  end
88
261
  end