basecamp-sdk 0.5.0 → 0.6.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 +2 -2
- data/lib/basecamp/ambiguous_error.rb +23 -0
- data/lib/basecamp/api_error.rb +27 -0
- data/lib/basecamp/auth_error.rb +16 -0
- data/lib/basecamp/auth_strategy.rb +0 -18
- data/lib/basecamp/bearer_auth.rb +21 -0
- data/lib/basecamp/client.rb +126 -0
- data/lib/basecamp/download_result.rb +10 -0
- data/lib/basecamp/error.rb +86 -0
- data/lib/basecamp/error_code.rb +16 -0
- data/lib/basecamp/exit_code.rb +17 -0
- data/lib/basecamp/forbidden_error.rb +20 -0
- data/lib/basecamp/generated/metadata.json +12 -1
- data/lib/basecamp/{services → generated/services}/authorization_service.rb +1 -1
- data/lib/basecamp/generated/services/automation_service.rb +19 -0
- data/lib/basecamp/{services → generated/services}/base_service.rb +0 -1
- data/lib/basecamp/generated/services/card_steps_service.rb +9 -0
- data/lib/basecamp/generated/services/search_service.rb +1 -1
- data/lib/basecamp/generated/services/tools_service.rb +3 -2
- data/lib/basecamp/generated/types.rb +1 -1
- data/lib/basecamp/http.rb +22 -13
- data/lib/basecamp/network_error.rb +16 -0
- data/lib/basecamp/not_found_error.rb +20 -0
- data/lib/basecamp/oauth/config.rb +30 -0
- data/lib/basecamp/oauth/discovery.rb +9 -41
- data/lib/basecamp/oauth/exchange.rb +20 -114
- data/lib/basecamp/oauth/exchange_request.rb +36 -0
- data/lib/basecamp/oauth/{errors.rb → oauth_error.rb} +1 -1
- data/lib/basecamp/oauth/refresh_request.rb +30 -0
- data/lib/basecamp/oauth/token.rb +52 -0
- data/lib/basecamp/oauth.rb +40 -8
- data/lib/basecamp/operation_info.rb +0 -7
- data/lib/basecamp/operation_result.rb +10 -0
- data/lib/basecamp/rate_limit_error.rb +19 -0
- data/lib/basecamp/security.rb +1 -1
- data/lib/basecamp/usage_error.rb +10 -0
- data/lib/basecamp/validation_error.rb +15 -0
- data/lib/basecamp/version.rb +1 -1
- data/lib/basecamp/webhooks/rack_middleware.rb +0 -2
- data/lib/basecamp/webhooks/receiver.rb +0 -4
- data/lib/basecamp/webhooks/verification_error.rb +7 -0
- data/lib/basecamp.rb +62 -22
- data/scripts/generate-services.rb +3 -3
- metadata +26 -43
- data/lib/basecamp/errors.rb +0 -294
- data/lib/basecamp/oauth/types.rb +0 -133
- data/lib/basecamp/services/attachments_service.rb +0 -33
- data/lib/basecamp/services/campfires_service.rb +0 -141
- data/lib/basecamp/services/card_columns_service.rb +0 -106
- data/lib/basecamp/services/card_steps_service.rb +0 -86
- data/lib/basecamp/services/card_tables_service.rb +0 -23
- data/lib/basecamp/services/cards_service.rb +0 -93
- data/lib/basecamp/services/checkins_service.rb +0 -127
- data/lib/basecamp/services/client_approvals_service.rb +0 -33
- data/lib/basecamp/services/client_correspondences_service.rb +0 -33
- data/lib/basecamp/services/client_replies_service.rb +0 -35
- data/lib/basecamp/services/comments_service.rb +0 -63
- data/lib/basecamp/services/documents_service.rb +0 -74
- data/lib/basecamp/services/events_service.rb +0 -27
- data/lib/basecamp/services/forwards_service.rb +0 -80
- data/lib/basecamp/services/lineup_service.rb +0 -67
- data/lib/basecamp/services/message_boards_service.rb +0 -24
- data/lib/basecamp/services/message_types_service.rb +0 -79
- data/lib/basecamp/services/messages_service.rb +0 -133
- data/lib/basecamp/services/people_service.rb +0 -73
- data/lib/basecamp/services/projects_service.rb +0 -67
- data/lib/basecamp/services/recordings_service.rb +0 -127
- data/lib/basecamp/services/reports_service.rb +0 -80
- data/lib/basecamp/services/schedules_service.rb +0 -156
- data/lib/basecamp/services/search_service.rb +0 -36
- data/lib/basecamp/services/subscriptions_service.rb +0 -67
- data/lib/basecamp/services/templates_service.rb +0 -96
- data/lib/basecamp/services/timeline_service.rb +0 -62
- data/lib/basecamp/services/timesheet_service.rb +0 -68
- data/lib/basecamp/services/todolist_groups_service.rb +0 -100
- data/lib/basecamp/services/todolists_service.rb +0 -104
- data/lib/basecamp/services/todos_service.rb +0 -156
- data/lib/basecamp/services/todosets_service.rb +0 -23
- data/lib/basecamp/services/tools_service.rb +0 -89
- data/lib/basecamp/services/uploads_service.rb +0 -84
- data/lib/basecamp/services/vaults_service.rb +0 -84
- data/lib/basecamp/services/webhooks_service.rb +0 -88
data/lib/basecamp/http.rb
CHANGED
|
@@ -80,6 +80,14 @@ module Basecamp
|
|
|
80
80
|
single_request_raw(:post, url, body: body, content_type: content_type, attempt: 1)
|
|
81
81
|
end
|
|
82
82
|
|
|
83
|
+
# Performs a GET request without retry logic.
|
|
84
|
+
# Used for the download flow where retry is not appropriate.
|
|
85
|
+
# @param url [String] absolute URL
|
|
86
|
+
# @return [Response]
|
|
87
|
+
def get_no_retry(url)
|
|
88
|
+
single_request(:get, url, params: {}, body: nil, attempt: 1)
|
|
89
|
+
end
|
|
90
|
+
|
|
83
91
|
# Fetches all pages of a paginated resource.
|
|
84
92
|
# @param path [String] initial URL path
|
|
85
93
|
# @param params [Hash] query parameters
|
|
@@ -104,7 +112,7 @@ module Basecamp
|
|
|
104
112
|
begin
|
|
105
113
|
items = JSON.parse(response.body)
|
|
106
114
|
rescue JSON::ParserError => e
|
|
107
|
-
raise Basecamp::
|
|
115
|
+
raise Basecamp::ApiError.new("Failed to parse paginated response (page #{page}): #{Security.truncate(e.message)}")
|
|
108
116
|
end
|
|
109
117
|
items.each(&block)
|
|
110
118
|
|
|
@@ -114,7 +122,7 @@ module Basecamp
|
|
|
114
122
|
next_url = Security.resolve_url(url, next_url)
|
|
115
123
|
|
|
116
124
|
unless Security.same_origin?(next_url, base_url)
|
|
117
|
-
raise Basecamp::
|
|
125
|
+
raise Basecamp::ApiError.new(
|
|
118
126
|
"Pagination Link header points to different origin: #{Security.truncate(next_url)}"
|
|
119
127
|
)
|
|
120
128
|
end
|
|
@@ -149,7 +157,7 @@ module Basecamp
|
|
|
149
157
|
begin
|
|
150
158
|
data = JSON.parse(response.body)
|
|
151
159
|
rescue JSON::ParserError => e
|
|
152
|
-
raise Basecamp::
|
|
160
|
+
raise Basecamp::ApiError.new("Failed to parse paginated response (page #{page}): #{Security.truncate(e.message)}")
|
|
153
161
|
end
|
|
154
162
|
unless data.key?(key)
|
|
155
163
|
warn "[Basecamp SDK] paginate_key: expected key '#{key}' not found in response (page #{page})"
|
|
@@ -163,7 +171,7 @@ module Basecamp
|
|
|
163
171
|
next_url = Security.resolve_url(url, next_url)
|
|
164
172
|
|
|
165
173
|
unless Security.same_origin?(next_url, base_url)
|
|
166
|
-
raise Basecamp::
|
|
174
|
+
raise Basecamp::ApiError.new(
|
|
167
175
|
"Pagination Link header points to different origin: #{Security.truncate(next_url)}"
|
|
168
176
|
)
|
|
169
177
|
end
|
|
@@ -188,7 +196,7 @@ module Basecamp
|
|
|
188
196
|
begin
|
|
189
197
|
first_data = JSON.parse(first_response.body)
|
|
190
198
|
rescue JSON::ParserError => e
|
|
191
|
-
raise Basecamp::
|
|
199
|
+
raise Basecamp::ApiError.new(
|
|
192
200
|
"Failed to parse paginated response (page 1): #{Security.truncate(e.message)}"
|
|
193
201
|
)
|
|
194
202
|
end
|
|
@@ -208,7 +216,7 @@ module Basecamp
|
|
|
208
216
|
next_url = Security.resolve_url(url, next_link)
|
|
209
217
|
|
|
210
218
|
unless Security.same_origin?(next_url, base_url)
|
|
211
|
-
raise Basecamp::
|
|
219
|
+
raise Basecamp::ApiError.new(
|
|
212
220
|
"Pagination Link header points to different origin: " \
|
|
213
221
|
"#{Security.truncate(next_url)}"
|
|
214
222
|
)
|
|
@@ -221,7 +229,7 @@ module Basecamp
|
|
|
221
229
|
begin
|
|
222
230
|
data = JSON.parse(response.body)
|
|
223
231
|
rescue JSON::ParserError => e
|
|
224
|
-
raise Basecamp::
|
|
232
|
+
raise Basecamp::ApiError.new(
|
|
225
233
|
"Failed to parse paginated response (page #{page}): " \
|
|
226
234
|
"#{Security.truncate(e.message)}"
|
|
227
235
|
)
|
|
@@ -271,7 +279,7 @@ module Basecamp
|
|
|
271
279
|
|
|
272
280
|
begin
|
|
273
281
|
return single_request(method, url, params: params, body: nil, attempt: attempt)
|
|
274
|
-
rescue Basecamp::RateLimitError, Basecamp::NetworkError, Basecamp::
|
|
282
|
+
rescue Basecamp::RateLimitError, Basecamp::NetworkError, Basecamp::ApiError => e
|
|
275
283
|
raise e unless e.retryable?
|
|
276
284
|
|
|
277
285
|
last_error = e
|
|
@@ -287,7 +295,7 @@ module Basecamp
|
|
|
287
295
|
end
|
|
288
296
|
end
|
|
289
297
|
|
|
290
|
-
raise last_error || Basecamp::
|
|
298
|
+
raise last_error || Basecamp::ApiError.new("Request failed after #{@config.max_retries} retries")
|
|
291
299
|
end
|
|
292
300
|
|
|
293
301
|
def single_request(method, url, params:, body:, attempt:, retry_count: 0)
|
|
@@ -401,19 +409,20 @@ module Basecamp
|
|
|
401
409
|
when 403
|
|
402
410
|
Basecamp::ForbiddenError.new("Access denied")
|
|
403
411
|
when 404
|
|
404
|
-
Basecamp
|
|
412
|
+
message = Security.truncate(Basecamp.parse_error_message(body) || "Not found")
|
|
413
|
+
Basecamp::NotFoundError.new(message: message)
|
|
405
414
|
when 429
|
|
406
415
|
Basecamp::RateLimitError.new(retry_after: retry_after)
|
|
407
416
|
when 400, 422
|
|
408
417
|
message = Security.truncate(Basecamp.parse_error_message(body) || "Validation failed")
|
|
409
418
|
Basecamp::ValidationError.new(message, http_status: status)
|
|
410
419
|
when 500
|
|
411
|
-
Basecamp::
|
|
420
|
+
Basecamp::ApiError.new("Server error (500)", http_status: 500, retryable: true)
|
|
412
421
|
when 502, 503, 504
|
|
413
|
-
Basecamp::
|
|
422
|
+
Basecamp::ApiError.new("Gateway error (#{status})", http_status: status, retryable: true)
|
|
414
423
|
else
|
|
415
424
|
message = Security.truncate(Basecamp.parse_error_message(body) || "Request failed (HTTP #{status})")
|
|
416
|
-
Basecamp::
|
|
425
|
+
Basecamp::ApiError.from_status(status || 0, message)
|
|
417
426
|
end
|
|
418
427
|
|
|
419
428
|
err.instance_variable_set(:@request_id, request_id) if request_id
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Basecamp
|
|
4
|
+
# Raised when there's a network error (connection, timeout, DNS).
|
|
5
|
+
class NetworkError < Error
|
|
6
|
+
def initialize(message = "Network error", cause: nil)
|
|
7
|
+
super(
|
|
8
|
+
code: ErrorCode::NETWORK,
|
|
9
|
+
message: message,
|
|
10
|
+
hint: cause&.message || "Check your network connection",
|
|
11
|
+
retryable: true,
|
|
12
|
+
cause: cause
|
|
13
|
+
)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Basecamp
|
|
4
|
+
# Raised when a resource is not found (404).
|
|
5
|
+
class NotFoundError < Error
|
|
6
|
+
def initialize(resource = nil, identifier = nil, message: nil, hint: nil)
|
|
7
|
+
message ||= if resource
|
|
8
|
+
"#{resource} not found: #{identifier}"
|
|
9
|
+
else
|
|
10
|
+
"Not found"
|
|
11
|
+
end
|
|
12
|
+
super(
|
|
13
|
+
code: ErrorCode::NOT_FOUND,
|
|
14
|
+
message: message,
|
|
15
|
+
hint: hint,
|
|
16
|
+
http_status: 404
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Basecamp
|
|
4
|
+
module Oauth
|
|
5
|
+
# OAuth 2 server configuration from discovery endpoint.
|
|
6
|
+
#
|
|
7
|
+
# @attr issuer [String] The authorization server's issuer identifier
|
|
8
|
+
# @attr authorization_endpoint [String] URL of the authorization endpoint
|
|
9
|
+
# @attr token_endpoint [String] URL of the token endpoint
|
|
10
|
+
# @attr registration_endpoint [String, nil] URL of the dynamic client registration endpoint
|
|
11
|
+
# @attr scopes_supported [Array<String>, nil] List of OAuth 2 scopes supported
|
|
12
|
+
Config = Data.define(
|
|
13
|
+
:issuer,
|
|
14
|
+
:authorization_endpoint,
|
|
15
|
+
:token_endpoint,
|
|
16
|
+
:registration_endpoint,
|
|
17
|
+
:scopes_supported
|
|
18
|
+
) do
|
|
19
|
+
def initialize(
|
|
20
|
+
issuer:,
|
|
21
|
+
authorization_endpoint:,
|
|
22
|
+
token_endpoint:,
|
|
23
|
+
registration_endpoint: nil,
|
|
24
|
+
scopes_supported: nil
|
|
25
|
+
)
|
|
26
|
+
super
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -4,13 +4,9 @@ require "faraday"
|
|
|
4
4
|
require "json"
|
|
5
5
|
|
|
6
6
|
module Basecamp
|
|
7
|
-
# OAuth 2 helpers for Basecamp authentication.
|
|
8
7
|
module Oauth
|
|
9
|
-
#
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
# Discoverer fetches OAuth 2 server configuration from discovery endpoints.
|
|
13
|
-
class Discoverer
|
|
8
|
+
# Fetches OAuth 2 server configuration from discovery endpoints.
|
|
9
|
+
class Discovery
|
|
14
10
|
# @param http_client [Faraday::Connection, nil] HTTP client (uses default if nil)
|
|
15
11
|
# @param timeout [Integer] Request timeout in seconds (default: 10)
|
|
16
12
|
def initialize(http_client: nil, timeout: 10)
|
|
@@ -24,11 +20,11 @@ module Basecamp
|
|
|
24
20
|
#
|
|
25
21
|
# @param base_url [String] The OAuth server's base URL (e.g., "https://launchpad.37signals.com")
|
|
26
22
|
# @return [Config] The OAuth server configuration
|
|
27
|
-
# @raise [
|
|
23
|
+
# @raise [OauthError] on network or parsing errors
|
|
28
24
|
#
|
|
29
25
|
# @example
|
|
30
|
-
#
|
|
31
|
-
# config =
|
|
26
|
+
# discovery = Basecamp::Oauth::Discovery.new
|
|
27
|
+
# config = discovery.discover("https://launchpad.37signals.com")
|
|
32
28
|
# puts config.token_endpoint
|
|
33
29
|
# # => "https://launchpad.37signals.com/authorization/token"
|
|
34
30
|
def discover(base_url)
|
|
@@ -42,7 +38,7 @@ module Basecamp
|
|
|
42
38
|
end
|
|
43
39
|
|
|
44
40
|
unless response.success?
|
|
45
|
-
raise
|
|
41
|
+
raise OauthError.new(
|
|
46
42
|
"network",
|
|
47
43
|
"OAuth discovery failed with status #{response.status}: #{Basecamp::Security.truncate(response.body)}",
|
|
48
44
|
http_status: response.status
|
|
@@ -62,9 +58,9 @@ module Basecamp
|
|
|
62
58
|
scopes_supported: data["scopes_supported"]
|
|
63
59
|
)
|
|
64
60
|
rescue Faraday::Error => e
|
|
65
|
-
raise
|
|
61
|
+
raise OauthError.new("network", "OAuth discovery failed: #{e.message}", retryable: true)
|
|
66
62
|
rescue JSON::ParserError => e
|
|
67
|
-
raise
|
|
63
|
+
raise OauthError.new("api_error", "Failed to parse discovery response: #{e.message}")
|
|
68
64
|
end
|
|
69
65
|
|
|
70
66
|
private
|
|
@@ -85,39 +81,11 @@ module Basecamp
|
|
|
85
81
|
|
|
86
82
|
return if missing.empty?
|
|
87
83
|
|
|
88
|
-
raise
|
|
84
|
+
raise OauthError.new(
|
|
89
85
|
"api_error",
|
|
90
86
|
"Invalid OAuth discovery response: missing required fields: #{missing.join(", ")}"
|
|
91
87
|
)
|
|
92
88
|
end
|
|
93
89
|
end
|
|
94
|
-
|
|
95
|
-
module_function
|
|
96
|
-
|
|
97
|
-
# Discovers OAuth configuration from the well-known endpoint.
|
|
98
|
-
#
|
|
99
|
-
# @param base_url [String] The OAuth server's base URL
|
|
100
|
-
# @param timeout [Integer] Request timeout in seconds (default: 10)
|
|
101
|
-
# @return [Config] The OAuth server configuration
|
|
102
|
-
#
|
|
103
|
-
# @example
|
|
104
|
-
# config = Basecamp::Oauth.discover("https://launchpad.37signals.com")
|
|
105
|
-
def discover(base_url, timeout: 10)
|
|
106
|
-
Discoverer.new(timeout: timeout).discover(base_url)
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
# Discovers OAuth configuration from Basecamp's Launchpad server.
|
|
110
|
-
#
|
|
111
|
-
# Convenience function that calls discover() with the Launchpad base URL.
|
|
112
|
-
#
|
|
113
|
-
# @param timeout [Integer] Request timeout in seconds (default: 10)
|
|
114
|
-
# @return [Config] The OAuth server configuration
|
|
115
|
-
#
|
|
116
|
-
# @example
|
|
117
|
-
# config = Basecamp::Oauth.discover_launchpad
|
|
118
|
-
# # Use config.authorization_endpoint to start OAuth flow
|
|
119
|
-
def discover_launchpad(timeout: 10)
|
|
120
|
-
discover(LAUNCHPAD_BASE_URL, timeout: timeout)
|
|
121
|
-
end
|
|
122
90
|
end
|
|
123
91
|
end
|
|
@@ -5,10 +5,9 @@ require "json"
|
|
|
5
5
|
require "uri"
|
|
6
6
|
|
|
7
7
|
module Basecamp
|
|
8
|
-
# OAuth 2 helpers for Basecamp authentication.
|
|
9
8
|
module Oauth
|
|
10
|
-
#
|
|
11
|
-
class
|
|
9
|
+
# Handles OAuth 2 token exchange and refresh operations.
|
|
10
|
+
class Exchange
|
|
12
11
|
# @param http_client [Faraday::Connection, nil] HTTP client (uses default if nil)
|
|
13
12
|
# @param timeout [Integer] Request timeout in seconds (default: 30)
|
|
14
13
|
def initialize(http_client: nil, timeout: 30)
|
|
@@ -22,10 +21,10 @@ module Basecamp
|
|
|
22
21
|
#
|
|
23
22
|
# @param request [ExchangeRequest] Exchange request parameters
|
|
24
23
|
# @return [Token] The token response
|
|
25
|
-
# @raise [
|
|
24
|
+
# @raise [OauthError] on validation, network, or authentication errors
|
|
26
25
|
#
|
|
27
26
|
# @example Standard OAuth 2
|
|
28
|
-
# token =
|
|
27
|
+
# token = exchange.exchange(ExchangeRequest.new(
|
|
29
28
|
# token_endpoint: config.token_endpoint,
|
|
30
29
|
# code: "auth_code_from_callback",
|
|
31
30
|
# redirect_uri: "https://myapp.com/callback",
|
|
@@ -34,7 +33,7 @@ module Basecamp
|
|
|
34
33
|
# ))
|
|
35
34
|
#
|
|
36
35
|
# @example Launchpad legacy format
|
|
37
|
-
# token =
|
|
36
|
+
# token = exchange.exchange(ExchangeRequest.new(
|
|
38
37
|
# token_endpoint: config.token_endpoint,
|
|
39
38
|
# code: "auth_code",
|
|
40
39
|
# redirect_uri: "https://myapp.com/callback",
|
|
@@ -56,10 +55,10 @@ module Basecamp
|
|
|
56
55
|
#
|
|
57
56
|
# @param request [RefreshRequest] Refresh request parameters
|
|
58
57
|
# @return [Token] The new token response
|
|
59
|
-
# @raise [
|
|
58
|
+
# @raise [OauthError] on validation, network, or authentication errors
|
|
60
59
|
#
|
|
61
60
|
# @example Standard OAuth 2
|
|
62
|
-
# new_token =
|
|
61
|
+
# new_token = exchange.refresh(RefreshRequest.new(
|
|
63
62
|
# token_endpoint: config.token_endpoint,
|
|
64
63
|
# refresh_token: old_token.refresh_token,
|
|
65
64
|
# client_id: "my_client_id",
|
|
@@ -67,7 +66,7 @@ module Basecamp
|
|
|
67
66
|
# ))
|
|
68
67
|
#
|
|
69
68
|
# @example Launchpad legacy format
|
|
70
|
-
# new_token =
|
|
69
|
+
# new_token = exchange.refresh(RefreshRequest.new(
|
|
71
70
|
# token_endpoint: config.token_endpoint,
|
|
72
71
|
# refresh_token: old_token.refresh_token,
|
|
73
72
|
# use_legacy_format: true
|
|
@@ -90,15 +89,15 @@ module Basecamp
|
|
|
90
89
|
end
|
|
91
90
|
|
|
92
91
|
def validate_exchange_request!(request)
|
|
93
|
-
raise
|
|
94
|
-
raise
|
|
95
|
-
raise
|
|
96
|
-
raise
|
|
92
|
+
raise OauthError.new("validation", "Token endpoint is required") if request.token_endpoint.to_s.empty?
|
|
93
|
+
raise OauthError.new("validation", "Authorization code is required") if request.code.to_s.empty?
|
|
94
|
+
raise OauthError.new("validation", "Redirect URI is required") if request.redirect_uri.to_s.empty?
|
|
95
|
+
raise OauthError.new("validation", "Client ID is required") if request.client_id.to_s.empty?
|
|
97
96
|
end
|
|
98
97
|
|
|
99
98
|
def validate_refresh_request!(request)
|
|
100
|
-
raise
|
|
101
|
-
raise
|
|
99
|
+
raise OauthError.new("validation", "Token endpoint is required") if request.token_endpoint.to_s.empty?
|
|
100
|
+
raise OauthError.new("validation", "Refresh token is required") if request.refresh_token.to_s.empty?
|
|
102
101
|
end
|
|
103
102
|
|
|
104
103
|
def build_exchange_params(request)
|
|
@@ -150,9 +149,9 @@ module Basecamp
|
|
|
150
149
|
|
|
151
150
|
parse_token_response(response)
|
|
152
151
|
rescue Faraday::TimeoutError
|
|
153
|
-
raise
|
|
152
|
+
raise OauthError.new("network", "Token request timed out", retryable: true)
|
|
154
153
|
rescue Faraday::Error => e
|
|
155
|
-
raise
|
|
154
|
+
raise OauthError.new("network", "Token request failed: #{e.message}", retryable: true)
|
|
156
155
|
end
|
|
157
156
|
|
|
158
157
|
def parse_token_response(response)
|
|
@@ -162,7 +161,7 @@ module Basecamp
|
|
|
162
161
|
|
|
163
162
|
handle_error_response(response.status, data) unless response.success?
|
|
164
163
|
|
|
165
|
-
raise
|
|
164
|
+
raise OauthError.new("api_error", "Token response missing access_token") unless data["access_token"]
|
|
166
165
|
|
|
167
166
|
Token.new(
|
|
168
167
|
access_token: data["access_token"],
|
|
@@ -172,7 +171,7 @@ module Basecamp
|
|
|
172
171
|
scope: data["scope"]
|
|
173
172
|
)
|
|
174
173
|
rescue JSON::ParserError
|
|
175
|
-
raise
|
|
174
|
+
raise OauthError.new(
|
|
176
175
|
"api_error",
|
|
177
176
|
"Failed to parse token response: #{Basecamp::Security.truncate(response.body)}",
|
|
178
177
|
http_status: response.status
|
|
@@ -183,7 +182,7 @@ module Basecamp
|
|
|
183
182
|
error_msg = Basecamp::Security.truncate(data["error_description"] || data["error"] || "Token request failed")
|
|
184
183
|
|
|
185
184
|
if status == 401 || data["error"] == "invalid_grant"
|
|
186
|
-
raise
|
|
185
|
+
raise OauthError.new(
|
|
187
186
|
"auth",
|
|
188
187
|
error_msg,
|
|
189
188
|
http_status: status,
|
|
@@ -191,101 +190,8 @@ module Basecamp
|
|
|
191
190
|
)
|
|
192
191
|
end
|
|
193
192
|
|
|
194
|
-
raise
|
|
193
|
+
raise OauthError.new("api_error", error_msg, http_status: status)
|
|
195
194
|
end
|
|
196
195
|
end
|
|
197
|
-
|
|
198
|
-
module_function
|
|
199
|
-
|
|
200
|
-
# Exchanges an authorization code for access and refresh tokens.
|
|
201
|
-
#
|
|
202
|
-
# @param token_endpoint [String] URL of the token endpoint
|
|
203
|
-
# @param code [String] The authorization code
|
|
204
|
-
# @param redirect_uri [String] The redirect URI used in the authorization request
|
|
205
|
-
# @param client_id [String] The client identifier
|
|
206
|
-
# @param client_secret [String, nil] The client secret
|
|
207
|
-
# @param code_verifier [String, nil] PKCE code verifier
|
|
208
|
-
# @param use_legacy_format [Boolean] Use Launchpad's non-standard format
|
|
209
|
-
# @param timeout [Integer] Request timeout in seconds (default: 30)
|
|
210
|
-
# @return [Token] The token response
|
|
211
|
-
#
|
|
212
|
-
# @example
|
|
213
|
-
# token = Basecamp::Oauth.exchange_code(
|
|
214
|
-
# token_endpoint: config.token_endpoint,
|
|
215
|
-
# code: "auth_code",
|
|
216
|
-
# redirect_uri: "https://myapp.com/callback",
|
|
217
|
-
# client_id: ENV["BASECAMP_CLIENT_ID"],
|
|
218
|
-
# client_secret: ENV["BASECAMP_CLIENT_SECRET"],
|
|
219
|
-
# use_legacy_format: true
|
|
220
|
-
# )
|
|
221
|
-
def exchange_code(
|
|
222
|
-
token_endpoint:,
|
|
223
|
-
code:,
|
|
224
|
-
redirect_uri:,
|
|
225
|
-
client_id:,
|
|
226
|
-
client_secret: nil,
|
|
227
|
-
code_verifier: nil,
|
|
228
|
-
use_legacy_format: false,
|
|
229
|
-
timeout: 30
|
|
230
|
-
)
|
|
231
|
-
request = ExchangeRequest.new(
|
|
232
|
-
token_endpoint: token_endpoint,
|
|
233
|
-
code: code,
|
|
234
|
-
redirect_uri: redirect_uri,
|
|
235
|
-
client_id: client_id,
|
|
236
|
-
client_secret: client_secret,
|
|
237
|
-
code_verifier: code_verifier,
|
|
238
|
-
use_legacy_format: use_legacy_format
|
|
239
|
-
)
|
|
240
|
-
Exchanger.new(timeout: timeout).exchange(request)
|
|
241
|
-
end
|
|
242
|
-
|
|
243
|
-
# Refreshes an access token using a refresh token.
|
|
244
|
-
#
|
|
245
|
-
# @param token_endpoint [String] URL of the token endpoint
|
|
246
|
-
# @param refresh_token [String] The refresh token
|
|
247
|
-
# @param client_id [String, nil] The client identifier
|
|
248
|
-
# @param client_secret [String, nil] The client secret
|
|
249
|
-
# @param use_legacy_format [Boolean] Use Launchpad's non-standard format
|
|
250
|
-
# @param timeout [Integer] Request timeout in seconds (default: 30)
|
|
251
|
-
# @return [Token] The new token response
|
|
252
|
-
#
|
|
253
|
-
# @example
|
|
254
|
-
# new_token = Basecamp::Oauth.refresh_token(
|
|
255
|
-
# token_endpoint: config.token_endpoint,
|
|
256
|
-
# refresh_token: old_token.refresh_token,
|
|
257
|
-
# use_legacy_format: true
|
|
258
|
-
# )
|
|
259
|
-
def refresh_token(
|
|
260
|
-
token_endpoint:,
|
|
261
|
-
refresh_token:,
|
|
262
|
-
client_id: nil,
|
|
263
|
-
client_secret: nil,
|
|
264
|
-
use_legacy_format: false,
|
|
265
|
-
timeout: 30
|
|
266
|
-
)
|
|
267
|
-
request = RefreshRequest.new(
|
|
268
|
-
token_endpoint: token_endpoint,
|
|
269
|
-
refresh_token: refresh_token,
|
|
270
|
-
client_id: client_id,
|
|
271
|
-
client_secret: client_secret,
|
|
272
|
-
use_legacy_format: use_legacy_format
|
|
273
|
-
)
|
|
274
|
-
Exchanger.new(timeout: timeout).refresh(request)
|
|
275
|
-
end
|
|
276
|
-
|
|
277
|
-
# Checks if a token is expired or about to expire.
|
|
278
|
-
#
|
|
279
|
-
# @param token [Token] The token to check
|
|
280
|
-
# @param buffer_seconds [Integer] Buffer time before actual expiration (default: 60)
|
|
281
|
-
# @return [Boolean] true if expired or will expire within buffer time
|
|
282
|
-
#
|
|
283
|
-
# @example
|
|
284
|
-
# if Basecamp::Oauth.token_expired?(token)
|
|
285
|
-
# token = Basecamp::Oauth.refresh_token(...)
|
|
286
|
-
# end
|
|
287
|
-
def token_expired?(token, buffer_seconds = 60)
|
|
288
|
-
token.expired?(buffer_seconds)
|
|
289
|
-
end
|
|
290
196
|
end
|
|
291
197
|
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Basecamp
|
|
4
|
+
module Oauth
|
|
5
|
+
# Parameters for exchanging an authorization code for tokens.
|
|
6
|
+
#
|
|
7
|
+
# @attr token_endpoint [String] URL of the token endpoint
|
|
8
|
+
# @attr code [String] The authorization code received from the authorization server
|
|
9
|
+
# @attr redirect_uri [String] The redirect URI used in the authorization request
|
|
10
|
+
# @attr client_id [String] The client identifier
|
|
11
|
+
# @attr client_secret [String, nil] The client secret (optional for public clients)
|
|
12
|
+
# @attr code_verifier [String, nil] PKCE code verifier (optional)
|
|
13
|
+
# @attr use_legacy_format [Boolean] Use Launchpad's non-standard token format
|
|
14
|
+
ExchangeRequest = Data.define(
|
|
15
|
+
:token_endpoint,
|
|
16
|
+
:code,
|
|
17
|
+
:redirect_uri,
|
|
18
|
+
:client_id,
|
|
19
|
+
:client_secret,
|
|
20
|
+
:code_verifier,
|
|
21
|
+
:use_legacy_format
|
|
22
|
+
) do
|
|
23
|
+
def initialize(
|
|
24
|
+
token_endpoint:,
|
|
25
|
+
code:,
|
|
26
|
+
redirect_uri:,
|
|
27
|
+
client_id:,
|
|
28
|
+
client_secret: nil,
|
|
29
|
+
code_verifier: nil,
|
|
30
|
+
use_legacy_format: false
|
|
31
|
+
)
|
|
32
|
+
super
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -8,7 +8,7 @@ module Basecamp
|
|
|
8
8
|
# @attr http_status [Integer, nil] HTTP status code if applicable
|
|
9
9
|
# @attr hint [String, nil] Helpful hint for resolving the error
|
|
10
10
|
# @attr retryable [Boolean] Whether the request can be retried
|
|
11
|
-
class
|
|
11
|
+
class OauthError < StandardError
|
|
12
12
|
attr_reader :type, :http_status, :hint, :retryable
|
|
13
13
|
|
|
14
14
|
# @param type [String] Error type
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Basecamp
|
|
4
|
+
module Oauth
|
|
5
|
+
# Parameters for refreshing an access token.
|
|
6
|
+
#
|
|
7
|
+
# @attr token_endpoint [String] URL of the token endpoint
|
|
8
|
+
# @attr refresh_token [String] The refresh token
|
|
9
|
+
# @attr client_id [String, nil] The client identifier (optional)
|
|
10
|
+
# @attr client_secret [String, nil] The client secret (optional)
|
|
11
|
+
# @attr use_legacy_format [Boolean] Use Launchpad's non-standard token format
|
|
12
|
+
RefreshRequest = Data.define(
|
|
13
|
+
:token_endpoint,
|
|
14
|
+
:refresh_token,
|
|
15
|
+
:client_id,
|
|
16
|
+
:client_secret,
|
|
17
|
+
:use_legacy_format
|
|
18
|
+
) do
|
|
19
|
+
def initialize(
|
|
20
|
+
token_endpoint:,
|
|
21
|
+
refresh_token:,
|
|
22
|
+
client_id: nil,
|
|
23
|
+
client_secret: nil,
|
|
24
|
+
use_legacy_format: false
|
|
25
|
+
)
|
|
26
|
+
super
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Basecamp
|
|
4
|
+
module Oauth
|
|
5
|
+
# OAuth 2 access token response.
|
|
6
|
+
#
|
|
7
|
+
# @attr access_token [String] The access token string
|
|
8
|
+
# @attr token_type [String] Token type (usually "Bearer")
|
|
9
|
+
# @attr refresh_token [String, nil] The refresh token string
|
|
10
|
+
# @attr expires_in [Integer, nil] Lifetime of the access token in seconds
|
|
11
|
+
# @attr expires_at [Time, nil] Calculated expiration time
|
|
12
|
+
# @attr scope [String, nil] OAuth scope granted
|
|
13
|
+
Token = Data.define(
|
|
14
|
+
:access_token,
|
|
15
|
+
:token_type,
|
|
16
|
+
:refresh_token,
|
|
17
|
+
:expires_in,
|
|
18
|
+
:expires_at,
|
|
19
|
+
:scope
|
|
20
|
+
) do
|
|
21
|
+
def initialize(
|
|
22
|
+
access_token:,
|
|
23
|
+
token_type: "Bearer",
|
|
24
|
+
refresh_token: nil,
|
|
25
|
+
expires_in: nil,
|
|
26
|
+
expires_at: nil,
|
|
27
|
+
scope: nil
|
|
28
|
+
)
|
|
29
|
+
# Calculate expires_at from expires_in if not provided
|
|
30
|
+
calculated_expires_at = expires_at || (expires_in ? Time.now + expires_in : nil)
|
|
31
|
+
super(
|
|
32
|
+
access_token: access_token,
|
|
33
|
+
token_type: token_type,
|
|
34
|
+
refresh_token: refresh_token,
|
|
35
|
+
expires_in: expires_in,
|
|
36
|
+
expires_at: calculated_expires_at,
|
|
37
|
+
scope: scope
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Checks if the token is expired or about to expire.
|
|
42
|
+
#
|
|
43
|
+
# @param buffer_seconds [Integer] Buffer time before actual expiration (default: 60)
|
|
44
|
+
# @return [Boolean] true if expired or will expire within buffer time
|
|
45
|
+
def expired?(buffer_seconds = 60)
|
|
46
|
+
return false unless expires_at
|
|
47
|
+
|
|
48
|
+
Time.now + buffer_seconds >= expires_at
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|