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.
- checksums.yaml +4 -4
- data/README.md +65 -1
- data/basecamp-sdk.gemspec +2 -3
- data/lib/basecamp/client.rb +5 -0
- data/lib/basecamp/generated/metadata.json +100 -33
- data/lib/basecamp/generated/services/base_service.rb +13 -0
- data/lib/basecamp/generated/services/campfires_service.rb +15 -3
- data/lib/basecamp/generated/services/card_columns_service.rb +26 -23
- data/lib/basecamp/generated/services/checkins_service.rb +5 -4
- data/lib/basecamp/generated/services/client_approvals_service.rb +1 -1
- data/lib/basecamp/generated/services/client_correspondences_service.rb +1 -1
- data/lib/basecamp/generated/services/documents_service.rb +3 -2
- data/lib/basecamp/generated/services/forwards_service.rb +1 -1
- data/lib/basecamp/generated/services/gauges_service.rb +4 -4
- data/lib/basecamp/generated/services/messages_service.rb +4 -3
- data/lib/basecamp/generated/services/my_assignments_service.rb +1 -1
- data/lib/basecamp/generated/services/my_notifications_service.rb +1 -1
- data/lib/basecamp/generated/services/people_service.rb +2 -2
- data/lib/basecamp/generated/services/projects_service.rb +2 -2
- data/lib/basecamp/generated/services/recordings_service.rb +1 -1
- data/lib/basecamp/generated/services/reports_service.rb +3 -3
- data/lib/basecamp/generated/services/schedules_service.rb +5 -4
- data/lib/basecamp/generated/services/search_service.rb +14 -3
- data/lib/basecamp/generated/services/templates_service.rb +4 -5
- data/lib/basecamp/generated/services/timesheets_service.rb +3 -3
- data/lib/basecamp/generated/services/todolists_service.rb +15 -3
- data/lib/basecamp/generated/services/todos_service.rb +5 -5
- data/lib/basecamp/generated/services/tools_service.rb +7 -6
- data/lib/basecamp/generated/services/uploads_service.rb +20 -2
- data/lib/basecamp/generated/services/webhooks_service.rb +2 -2
- data/lib/basecamp/generated/services/wormholes_service.rb +44 -0
- data/lib/basecamp/generated/types.rb +284 -64
- 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 +61 -13
- data/scripts/generate-types.rb +33 -1
- metadata +14 -21
- 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
|
|
64
|
+
# Used for endpoints not on the base API.
|
|
65
|
+
#
|
|
66
|
+
# This is the PUBLIC, general path and it credentials cross-origin for ONE
|
|
67
|
+
# destination only: the exact Launchpad authorization URL
|
|
68
|
+
# ({Basecamp::Security::LAUNCHPAD_AUTHORIZATION_URL}). Every other foreign
|
|
69
|
+
# origin — including an endpoint-shaped URL such as
|
|
70
|
+
# +https://evil.example/authorization.json+ — trips the same-origin guard, so
|
|
71
|
+
# the bearer token only ever reaches Launchpad, the configured base URL, or
|
|
72
|
+
# localhost. There is deliberately NO raw-string trusted-origin parameter: a
|
|
73
|
+
# syntactically valid origin does not prove discovery provenance, so the ONE
|
|
74
|
+
# legitimate cross-origin discovery destination goes through the narrow
|
|
75
|
+
# {#get_authorization_document}, which derives its issuer from internal
|
|
76
|
+
# discovery of the configured base URL rather than any caller argument.
|
|
77
|
+
#
|
|
65
78
|
# @param url [String] absolute URL
|
|
66
79
|
# @param params [Hash] query parameters
|
|
67
80
|
# @return [Response]
|
|
68
81
|
def get_absolute(url, params: {})
|
|
69
|
-
|
|
82
|
+
Security.require_https_unless_localhost!(url, "absolute URL")
|
|
83
|
+
|
|
84
|
+
allow_cross_origin = url == Security::LAUNCHPAD_AUTHORIZATION_URL
|
|
85
|
+
request(:get, url, params: params, allow_cross_origin: allow_cross_origin)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Fetches the credentialed authorization document (the fixed +authorization.json+
|
|
89
|
+
# path). This is the ONE sanctioned cross-origin credential path besides
|
|
90
|
+
# Launchpad, and the origin that receives the bearer token is NOT
|
|
91
|
+
# caller-supplied.
|
|
92
|
+
#
|
|
93
|
+
# The issuer is derived HERE by running resource-first discovery (SPEC.md §16)
|
|
94
|
+
# against this client's OWN configured base URL, then binding to whatever
|
|
95
|
+
# issuer discovery selects and validates (RFC 8414 issuer binding). A soft
|
|
96
|
+
# fallback fetches Launchpad's fixed URL; a hard discovery failure raises. The
|
|
97
|
+
# request URL is CONSTRUCTED from the discovered issuer origin + the fixed path
|
|
98
|
+
# (string concatenation, never URL re-parsing). Because no caller-supplied
|
|
99
|
+
# config, origin, or path reaches this method, there is no public API through
|
|
100
|
+
# which a forged issuer could redirect the credential to a foreign host —
|
|
101
|
+
# discovery provenance is structural, not a claim about a passed-in object.
|
|
102
|
+
#
|
|
103
|
+
# @return [Response]
|
|
104
|
+
# @raise [Oauth::DiscoverySelectionError] on a hard discovery failure
|
|
105
|
+
# @raise [Basecamp::UsageError] when the discovered issuer is not an origin root
|
|
106
|
+
def get_authorization_document
|
|
107
|
+
result = Oauth.discover_from_resource(@config.base_url)
|
|
108
|
+
if result.selected?
|
|
109
|
+
issuer_origin = Security.require_origin_root!(result.issuer, "selected issuer origin")
|
|
110
|
+
request(:get, "#{issuer_origin}/authorization.json", allow_cross_origin: true)
|
|
111
|
+
else
|
|
112
|
+
get_absolute(Security::LAUNCHPAD_AUTHORIZATION_URL)
|
|
113
|
+
end
|
|
70
114
|
end
|
|
71
115
|
|
|
72
116
|
# Performs a POST request.
|
|
@@ -296,18 +340,18 @@ module Basecamp
|
|
|
296
340
|
end
|
|
297
341
|
end
|
|
298
342
|
|
|
299
|
-
def request(method, path, params: {}, body: nil)
|
|
300
|
-
url = build_url(path)
|
|
343
|
+
def request(method, path, params: {}, body: nil, allow_cross_origin: false)
|
|
344
|
+
url = build_url(path, allow_cross_origin: allow_cross_origin)
|
|
301
345
|
|
|
302
346
|
# Mutations don't retry on 429/5xx to avoid duplicating data
|
|
303
347
|
if method == :get
|
|
304
|
-
request_with_retry(method, url, params: params)
|
|
348
|
+
request_with_retry(method, url, params: params, allow_cross_origin: allow_cross_origin)
|
|
305
349
|
else
|
|
306
|
-
single_request(method, url, params: params, body: body, attempt: 1)
|
|
350
|
+
single_request(method, url, params: params, body: body, attempt: 1, allow_cross_origin: allow_cross_origin)
|
|
307
351
|
end
|
|
308
352
|
end
|
|
309
353
|
|
|
310
|
-
def request_with_retry(method, url, params: {})
|
|
354
|
+
def request_with_retry(method, url, params: {}, allow_cross_origin: false)
|
|
311
355
|
attempt = 0
|
|
312
356
|
last_error = nil
|
|
313
357
|
|
|
@@ -316,7 +360,7 @@ module Basecamp
|
|
|
316
360
|
break if attempt > @config.max_retries
|
|
317
361
|
|
|
318
362
|
begin
|
|
319
|
-
return single_request(method, url, params: params, body: nil, attempt: attempt)
|
|
363
|
+
return single_request(method, url, params: params, body: nil, attempt: attempt, allow_cross_origin: allow_cross_origin)
|
|
320
364
|
rescue Basecamp::RateLimitError, Basecamp::NetworkError, Basecamp::ApiError => e
|
|
321
365
|
raise e unless e.retryable?
|
|
322
366
|
|
|
@@ -336,7 +380,8 @@ module Basecamp
|
|
|
336
380
|
raise last_error || Basecamp::ApiError.new("Request failed after #{@config.max_retries} retries")
|
|
337
381
|
end
|
|
338
382
|
|
|
339
|
-
def single_request(method, url, params:, body:, attempt:, retry_count: 0)
|
|
383
|
+
def single_request(method, url, params:, body:, attempt:, retry_count: 0, allow_cross_origin: false)
|
|
384
|
+
assert_credential_origin!(url, allow_cross_origin)
|
|
340
385
|
info = RequestInfo.new(method: method.to_s.upcase, url: url, attempt: attempt)
|
|
341
386
|
@hooks.on_request_start(info)
|
|
342
387
|
|
|
@@ -370,7 +415,7 @@ module Basecamp
|
|
|
370
415
|
# After a successful token refresh on 401, retry the request once
|
|
371
416
|
if error.is_a?(Basecamp::AuthError) && error.http_status == 401 && retry_count < 1 && @token_refreshed
|
|
372
417
|
@token_refreshed = false
|
|
373
|
-
return single_request(method, url, params: params, body: body, attempt: attempt, retry_count: retry_count + 1)
|
|
418
|
+
return single_request(method, url, params: params, body: body, attempt: attempt, retry_count: retry_count + 1, allow_cross_origin: allow_cross_origin)
|
|
374
419
|
end
|
|
375
420
|
|
|
376
421
|
raise error
|
|
@@ -393,6 +438,7 @@ module Basecamp
|
|
|
393
438
|
end
|
|
394
439
|
|
|
395
440
|
def single_request_raw(method, url, body:, content_type:, attempt:)
|
|
441
|
+
assert_credential_origin!(url, false)
|
|
396
442
|
info = RequestInfo.new(method: method.to_s.upcase, url: url, attempt: attempt)
|
|
397
443
|
@hooks.on_request_start(info)
|
|
398
444
|
|
|
@@ -467,17 +513,42 @@ module Basecamp
|
|
|
467
513
|
err
|
|
468
514
|
end
|
|
469
515
|
|
|
470
|
-
def build_url(path)
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
516
|
+
def build_url(path, allow_cross_origin: false)
|
|
517
|
+
# Schemes are case-insensitive (RFC 3986): detect absolute URLs on a
|
|
518
|
+
# lowercased copy so HTTPS://... is not mis-joined onto the base URL.
|
|
519
|
+
lower_path = path.downcase
|
|
520
|
+
if lower_path.start_with?("https://")
|
|
521
|
+
return path if allow_cross_origin
|
|
522
|
+
return path if Security.localhost?(path) || Security.same_origin?(path, @config.base_url)
|
|
523
|
+
|
|
524
|
+
raise Basecamp::UsageError.new("URL origin does not match configured base URL: #{Security.truncate(path)}")
|
|
525
|
+
elsif lower_path.start_with?("http://")
|
|
526
|
+
# Localhost may use plain HTTP for local development; every other host
|
|
527
|
+
# must use HTTPS.
|
|
528
|
+
return path if Security.localhost?(path)
|
|
529
|
+
|
|
530
|
+
raise Basecamp::UsageError.new("URL must use HTTPS: #{Security.truncate(path)}")
|
|
475
531
|
end
|
|
476
532
|
|
|
477
533
|
path = "/#{path}" unless path.start_with?("/")
|
|
478
534
|
"#{@config.base_url}#{path}"
|
|
479
535
|
end
|
|
480
536
|
|
|
537
|
+
# Attach-point backstop: refuse to attach credentials to a foreign origin
|
|
538
|
+
# before the auth strategy adds the bearer token. Localhost is carved out
|
|
539
|
+
# for dev/test. allow_cross_origin is granted only by get_absolute (for the
|
|
540
|
+
# trusted Launchpad authorization endpoint) or get_authorization_document (for
|
|
541
|
+
# a URL constructed against an issuer that internal discovery selected and
|
|
542
|
+
# validated); every other absolute URL must be same-origin with the base URL.
|
|
543
|
+
def assert_credential_origin!(url, allow_cross_origin)
|
|
544
|
+
return if allow_cross_origin
|
|
545
|
+
return if Security.localhost?(url) || Security.same_origin?(url, @config.base_url)
|
|
546
|
+
|
|
547
|
+
raise Basecamp::UsageError.new(
|
|
548
|
+
"Refusing to send credentials to a different origin than base URL: #{Security.truncate(url)}"
|
|
549
|
+
)
|
|
550
|
+
end
|
|
551
|
+
|
|
481
552
|
def calculate_delay(attempt, server_retry_after)
|
|
482
553
|
return server_retry_after if server_retry_after&.positive?
|
|
483
554
|
|
|
@@ -2,26 +2,41 @@
|
|
|
2
2
|
|
|
3
3
|
module Basecamp
|
|
4
4
|
module Oauth
|
|
5
|
-
# OAuth 2 server configuration from
|
|
5
|
+
# OAuth 2 server configuration from an Authorization Server Metadata document
|
|
6
|
+
# (RFC 8414).
|
|
7
|
+
#
|
|
8
|
+
# As of BC5 resource-first discovery, +authorization_endpoint+ is OPTIONAL:
|
|
9
|
+
# device-only authorization servers omit it, so authorization-code consumers
|
|
10
|
+
# MUST assert its presence before use. +token_endpoint+ stays required.
|
|
6
11
|
#
|
|
7
12
|
# @attr issuer [String] The authorization server's issuer identifier
|
|
8
|
-
# @attr authorization_endpoint [String] URL of the authorization endpoint
|
|
13
|
+
# @attr authorization_endpoint [String, nil] URL of the authorization endpoint (optional)
|
|
9
14
|
# @attr token_endpoint [String] URL of the token endpoint
|
|
15
|
+
# @attr device_authorization_endpoint [String, nil] URL of the RFC 8628 device authorization endpoint
|
|
10
16
|
# @attr registration_endpoint [String, nil] URL of the dynamic client registration endpoint
|
|
11
17
|
# @attr scopes_supported [Array<String>, nil] List of OAuth 2 scopes supported
|
|
18
|
+
# @attr grant_types_supported [Array<String>, nil] OAuth 2 grant types the server supports
|
|
19
|
+
# NOTE: +device_authorization_endpoint+ is APPENDED after the pre-existing
|
|
20
|
+
# members. Data.define's member order is the positional/deconstruct order, so
|
|
21
|
+
# inserting a new field mid-list would shift positional callers (and pattern
|
|
22
|
+
# matches). Keep new fields last for positional compatibility.
|
|
12
23
|
Config = Data.define(
|
|
13
24
|
:issuer,
|
|
14
25
|
:authorization_endpoint,
|
|
15
26
|
:token_endpoint,
|
|
16
27
|
:registration_endpoint,
|
|
17
|
-
:scopes_supported
|
|
28
|
+
:scopes_supported,
|
|
29
|
+
:grant_types_supported,
|
|
30
|
+
:device_authorization_endpoint
|
|
18
31
|
) do
|
|
19
32
|
def initialize(
|
|
20
33
|
issuer:,
|
|
21
|
-
authorization_endpoint:,
|
|
22
34
|
token_endpoint:,
|
|
35
|
+
authorization_endpoint: nil,
|
|
23
36
|
registration_endpoint: nil,
|
|
24
|
-
scopes_supported: nil
|
|
37
|
+
scopes_supported: nil,
|
|
38
|
+
grant_types_supported: nil,
|
|
39
|
+
device_authorization_endpoint: nil
|
|
25
40
|
)
|
|
26
41
|
super
|
|
27
42
|
end
|
|
@@ -1,91 +1,161 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "faraday"
|
|
4
|
-
require "json"
|
|
5
|
-
|
|
6
3
|
module Basecamp
|
|
7
4
|
module Oauth
|
|
8
|
-
# Fetches OAuth 2
|
|
5
|
+
# Fetches RFC 8414 OAuth 2 Authorization Server Metadata and binds the
|
|
6
|
+
# returned +issuer+ to the requested issuer origin (hop 2 of resource-first
|
|
7
|
+
# discovery).
|
|
9
8
|
class Discovery
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
# Structured marker: AS metadata failed the RFC 8414 issuer code-point
|
|
10
|
+
# bind. Raised (never message-matched) so {Oauth.discover_from_resource}
|
|
11
|
+
# classifies an issuer mismatch by CLASS via {Oauth.as_failure_error} —
|
|
12
|
+
# brittle, locale-sensitive substring matching is gone. Kept in the
|
|
13
|
+
# discovery layer, deliberately NOT in the device error files that
|
|
14
|
+
# {DeviceFlowError} shares.
|
|
15
|
+
class IssuerBindingError < OauthError
|
|
16
|
+
def initialize(message, http_status: nil)
|
|
17
|
+
super("api_error", message, http_status: http_status)
|
|
18
|
+
end
|
|
14
19
|
end
|
|
15
20
|
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
|
|
21
|
+
# @param http_client [Faraday::Connection, nil] HTTP client (SSRF-hardened default if nil)
|
|
22
|
+
# @param timeout [Integer] request timeout in seconds (default: 10)
|
|
23
|
+
# @param max_body_bytes [Integer] bounded read cap in bytes
|
|
24
|
+
def initialize(http_client: nil, timeout: 10, max_body_bytes: Fetcher::DEFAULT_MAX_BODY_BYTES)
|
|
25
|
+
Fetcher.ensure_redirects_suppressed!(http_client) if http_client
|
|
26
|
+
# Normalize before building the client and before the fetch computes its
|
|
27
|
+
# wall-clock deadline: a non-finite/non-positive timeout must not disable
|
|
28
|
+
# either bound (see Fetcher.normalize_timeout).
|
|
29
|
+
@timeout = Fetcher.normalize_timeout(timeout)
|
|
30
|
+
@http_client = http_client || Fetcher.build_client(@timeout)
|
|
31
|
+
# Normalize the public cap to a finite non-negative Integer: a nil, float,
|
|
32
|
+
# or Float::INFINITY would otherwise disable the streaming memory bound
|
|
33
|
+
# (an infinite/undefined cap never trips +total > max_body_bytes+),
|
|
34
|
+
# reintroducing an SSRF/OOM risk. Mirrors Resource#initialize.
|
|
35
|
+
@max_body_bytes =
|
|
36
|
+
if max_body_bytes.is_a?(Integer) && max_body_bytes >= 0
|
|
37
|
+
max_body_bytes
|
|
38
|
+
else
|
|
39
|
+
Fetcher::DEFAULT_MAX_BODY_BYTES
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Discovers OAuth configuration from
|
|
44
|
+
# <tt>{base_url}/.well-known/oauth-authorization-server</tt>, binding the
|
|
45
|
+
# returned +issuer+ to +base_url+ by code-point (RFC 8414 §3.3/§4, no
|
|
46
|
+
# normalization beyond origin-root parsing). +token_endpoint+ is required;
|
|
47
|
+
# +authorization_endpoint+ is optional (device-only servers omit it).
|
|
20
48
|
#
|
|
21
|
-
# @param base_url [String]
|
|
22
|
-
# @return [Config]
|
|
23
|
-
# @raise [
|
|
49
|
+
# @param base_url [String] the OAuth server's issuer origin
|
|
50
|
+
# @return [Config] the OAuth server configuration
|
|
51
|
+
# @raise [Basecamp::UsageError] on a malformed origin
|
|
52
|
+
# @raise [OauthError] +api_error+ on invalid metadata / issuer mismatch
|
|
24
53
|
#
|
|
25
54
|
# @example
|
|
26
|
-
#
|
|
27
|
-
# config
|
|
28
|
-
# puts config.token_endpoint
|
|
29
|
-
# # => "https://launchpad.37signals.com/authorization/token"
|
|
55
|
+
# config = Basecamp::Oauth::Discovery.new.discover("https://launchpad.37signals.com")
|
|
56
|
+
# config.token_endpoint # => "https://launchpad.37signals.com/authorization/token"
|
|
30
57
|
def discover(base_url)
|
|
31
|
-
Basecamp::Security.
|
|
58
|
+
issuer_origin = Basecamp::Security.require_origin_root!(base_url, "OAuth discovery base URL")
|
|
59
|
+
# Bind against the caller's raw +base_url+ (RFC 8414 §3.3, SPEC.md §16 "NO
|
|
60
|
+
# normalization"); the normalized origin is only for the fetch URL.
|
|
61
|
+
discover_and_bind(issuer_origin, base_url)
|
|
62
|
+
end
|
|
32
63
|
|
|
33
|
-
|
|
34
|
-
|
|
64
|
+
# Resource-first entry: validate +advertised_issuer+ as an origin root, then
|
|
65
|
+
# fetch from the normalized origin and bind the AS metadata +issuer+ against
|
|
66
|
+
# the RAW advertised string by code-point. Routing and binding are distinct —
|
|
67
|
+
# an AS whose issuer matches what the resource advertised (e.g. a trailing
|
|
68
|
+
# slash or explicit default port) binds instead of being normalized away into
|
|
69
|
+
# a false +issuer_mismatch+. Validation happens HERE so there is NO public
|
|
70
|
+
# unvalidated-fetch entry point: the SSRF origin policy is always enforced.
|
|
71
|
+
#
|
|
72
|
+
# @param advertised_issuer [String] the raw issuer string a resource advertised
|
|
73
|
+
# @raise [Basecamp::UsageError] on a malformed advertised origin
|
|
74
|
+
def discover_advertised(advertised_issuer)
|
|
75
|
+
issuer_origin = Basecamp::Security.require_origin_root!(advertised_issuer, "advertised issuer")
|
|
76
|
+
discover_and_bind(issuer_origin, advertised_issuer)
|
|
77
|
+
end
|
|
35
78
|
|
|
36
|
-
|
|
37
|
-
req.headers["Accept"] = "application/json"
|
|
38
|
-
end
|
|
79
|
+
private
|
|
39
80
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
81
|
+
# Fetch AS metadata from +issuer_origin+ (an ALREADY-VALIDATED origin root)
|
|
82
|
+
# but bind the returned +issuer+ against +bind_issuer+ by code-point. Kept
|
|
83
|
+
# private: it does no origin validation of its own, so exposing it would be
|
|
84
|
+
# an unvalidated-fetch entry point that defeats the SSRF origin policy.
|
|
85
|
+
def discover_and_bind(issuer_origin, bind_issuer)
|
|
86
|
+
discovery_url = "#{issuer_origin}/.well-known/oauth-authorization-server"
|
|
87
|
+
data = Fetcher.fetch_json(@http_client, discovery_url, timeout: @timeout, max_body_bytes: @max_body_bytes)
|
|
88
|
+
parse_and_bind(data, bind_issuer)
|
|
46
89
|
end
|
|
47
90
|
|
|
48
|
-
|
|
91
|
+
# Universal validation only: +issuer+ + +token_endpoint+ present and
|
|
92
|
+
# non-empty, issuer identical by code-point, and any present +*_endpoint+
|
|
93
|
+
# field non-empty. Per-grant endpoint checks are the consumer's job.
|
|
94
|
+
def parse_and_bind(data, expected_issuer_origin)
|
|
95
|
+
issuer = data["issuer"]
|
|
96
|
+
if !issuer.is_a?(String) || issuer.empty?
|
|
97
|
+
raise OauthError.new("api_error", "Invalid OAuth discovery response: missing required fields: issuer")
|
|
98
|
+
end
|
|
49
99
|
|
|
50
|
-
|
|
51
|
-
|
|
100
|
+
# RFC 8414 §3.3/§4: issuer identical by code-point. No normalization.
|
|
101
|
+
unless issuer == expected_issuer_origin
|
|
102
|
+
raise IssuerBindingError.new(
|
|
103
|
+
"OAuth issuer mismatch: metadata issuer #{issuer.inspect} does not equal #{expected_issuer_origin.inspect}"
|
|
104
|
+
)
|
|
105
|
+
end
|
|
52
106
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
registration_endpoint: data["registration_endpoint"],
|
|
58
|
-
scopes_supported: data["scopes_supported"]
|
|
59
|
-
)
|
|
60
|
-
rescue Faraday::Error => e
|
|
61
|
-
raise OauthError.new("network", "OAuth discovery failed: #{e.message}", retryable: true)
|
|
62
|
-
rescue JSON::ParserError => e
|
|
63
|
-
raise OauthError.new("api_error", "Failed to parse discovery response: #{e.message}")
|
|
64
|
-
end
|
|
107
|
+
token_endpoint = data["token_endpoint"]
|
|
108
|
+
if !token_endpoint.is_a?(String) || token_endpoint.empty?
|
|
109
|
+
raise OauthError.new("api_error", "Invalid OAuth discovery response: missing required fields: token_endpoint")
|
|
110
|
+
end
|
|
65
111
|
|
|
66
|
-
|
|
112
|
+
reject_empty_endpoints!(data)
|
|
113
|
+
validate_string_array!(data, "grant_types_supported")
|
|
114
|
+
validate_string_array!(data, "scopes_supported")
|
|
67
115
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
116
|
+
Config.new(
|
|
117
|
+
issuer: issuer,
|
|
118
|
+
authorization_endpoint: data["authorization_endpoint"],
|
|
119
|
+
token_endpoint: token_endpoint,
|
|
120
|
+
device_authorization_endpoint: data["device_authorization_endpoint"],
|
|
121
|
+
registration_endpoint: data["registration_endpoint"],
|
|
122
|
+
scopes_supported: data["scopes_supported"],
|
|
123
|
+
grant_types_supported: data["grant_types_supported"]
|
|
124
|
+
)
|
|
73
125
|
end
|
|
74
|
-
end
|
|
75
126
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
127
|
+
# Any endpoint field that IS present must be a non-empty String: "" is a
|
|
128
|
+
# truthy value in Ruby and must be rejected, and a non-string endpoint
|
|
129
|
+
# (array/number/object, or a present JSON null) is malformed metadata —
|
|
130
|
+
# a present null is NOT the same as an absent key.
|
|
131
|
+
def reject_empty_endpoints!(data)
|
|
132
|
+
data.each do |key, value|
|
|
133
|
+
next unless key.end_with?("_endpoint")
|
|
81
134
|
|
|
82
|
-
|
|
135
|
+
unless value.is_a?(String) && !value.empty?
|
|
136
|
+
raise OauthError.new("api_error", "Invalid OAuth discovery response: invalid #{key}")
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
83
140
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
141
|
+
# A metadata list field (e.g. +grant_types_supported+, +scopes_supported+),
|
|
142
|
+
# when present, must be an array of strings. A bare string must never be
|
|
143
|
+
# accepted: substring-matching +grant_types_supported+ could falsely enable
|
|
144
|
+
# a grant such as device_code, and a non-array is malformed metadata.
|
|
145
|
+
def validate_string_array!(data, key)
|
|
146
|
+
# Distinguish an ABSENT key from a present JSON null: a present null is
|
|
147
|
+
# malformed (the field must be an array of strings when present), while
|
|
148
|
+
# an absent key is legitimately unset.
|
|
149
|
+
return unless data.key?(key)
|
|
150
|
+
|
|
151
|
+
value = data[key]
|
|
152
|
+
unless value.is_a?(Array) && value.all?(String)
|
|
153
|
+
raise OauthError.new(
|
|
154
|
+
"api_error",
|
|
155
|
+
"Invalid OAuth discovery response: #{key} must be an array of strings"
|
|
156
|
+
)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
89
159
|
end
|
|
90
160
|
end
|
|
91
161
|
end
|
|
@@ -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
|