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
|
@@ -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
|
data/lib/basecamp/oauth.rb
CHANGED
|
@@ -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
|