basecamp-sdk 0.2.1
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 +7 -0
- data/.rubocop.yml +14 -0
- data/.yardopts +6 -0
- data/README.md +293 -0
- data/Rakefile +26 -0
- data/basecamp-sdk.gemspec +46 -0
- data/lib/basecamp/auth_strategy.rb +38 -0
- data/lib/basecamp/chain_hooks.rb +45 -0
- data/lib/basecamp/client.rb +428 -0
- data/lib/basecamp/config.rb +143 -0
- data/lib/basecamp/errors.rb +289 -0
- data/lib/basecamp/generated/metadata.json +2281 -0
- data/lib/basecamp/generated/services/attachments_service.rb +24 -0
- data/lib/basecamp/generated/services/boosts_service.rb +70 -0
- data/lib/basecamp/generated/services/campfires_service.rb +122 -0
- data/lib/basecamp/generated/services/card_columns_service.rb +103 -0
- data/lib/basecamp/generated/services/card_steps_service.rb +57 -0
- data/lib/basecamp/generated/services/card_tables_service.rb +20 -0
- data/lib/basecamp/generated/services/cards_service.rb +66 -0
- data/lib/basecamp/generated/services/checkins_service.rb +157 -0
- data/lib/basecamp/generated/services/client_approvals_service.rb +28 -0
- data/lib/basecamp/generated/services/client_correspondences_service.rb +28 -0
- data/lib/basecamp/generated/services/client_replies_service.rb +30 -0
- data/lib/basecamp/generated/services/client_visibility_service.rb +21 -0
- data/lib/basecamp/generated/services/comments_service.rb +49 -0
- data/lib/basecamp/generated/services/documents_service.rb +52 -0
- data/lib/basecamp/generated/services/events_service.rb +20 -0
- data/lib/basecamp/generated/services/forwards_service.rb +67 -0
- data/lib/basecamp/generated/services/lineup_service.rb +44 -0
- data/lib/basecamp/generated/services/message_boards_service.rb +20 -0
- data/lib/basecamp/generated/services/message_types_service.rb +59 -0
- data/lib/basecamp/generated/services/messages_service.rb +75 -0
- data/lib/basecamp/generated/services/people_service.rb +73 -0
- data/lib/basecamp/generated/services/projects_service.rb +63 -0
- data/lib/basecamp/generated/services/recordings_service.rb +64 -0
- data/lib/basecamp/generated/services/reports_service.rb +56 -0
- data/lib/basecamp/generated/services/schedules_service.rb +92 -0
- data/lib/basecamp/generated/services/search_service.rb +31 -0
- data/lib/basecamp/generated/services/subscriptions_service.rb +50 -0
- data/lib/basecamp/generated/services/templates_service.rb +82 -0
- data/lib/basecamp/generated/services/timeline_service.rb +20 -0
- data/lib/basecamp/generated/services/timesheets_service.rb +81 -0
- data/lib/basecamp/generated/services/todolist_groups_service.rb +41 -0
- data/lib/basecamp/generated/services/todolists_service.rb +53 -0
- data/lib/basecamp/generated/services/todos_service.rb +106 -0
- data/lib/basecamp/generated/services/todosets_service.rb +20 -0
- data/lib/basecamp/generated/services/tools_service.rb +80 -0
- data/lib/basecamp/generated/services/uploads_service.rb +61 -0
- data/lib/basecamp/generated/services/vaults_service.rb +49 -0
- data/lib/basecamp/generated/services/webhooks_service.rb +63 -0
- data/lib/basecamp/generated/types.rb +3196 -0
- data/lib/basecamp/hooks.rb +70 -0
- data/lib/basecamp/http.rb +440 -0
- data/lib/basecamp/logger_hooks.rb +46 -0
- data/lib/basecamp/noop_hooks.rb +9 -0
- data/lib/basecamp/oauth/discovery.rb +123 -0
- data/lib/basecamp/oauth/errors.rb +35 -0
- data/lib/basecamp/oauth/exchange.rb +291 -0
- data/lib/basecamp/oauth/pkce.rb +68 -0
- data/lib/basecamp/oauth/types.rb +133 -0
- data/lib/basecamp/oauth.rb +56 -0
- data/lib/basecamp/oauth_token_provider.rb +108 -0
- data/lib/basecamp/operation_info.rb +17 -0
- data/lib/basecamp/request_info.rb +10 -0
- data/lib/basecamp/request_result.rb +14 -0
- data/lib/basecamp/security.rb +112 -0
- data/lib/basecamp/services/attachments_service.rb +33 -0
- data/lib/basecamp/services/authorization_service.rb +47 -0
- data/lib/basecamp/services/base_service.rb +146 -0
- data/lib/basecamp/services/campfires_service.rb +141 -0
- data/lib/basecamp/services/card_columns_service.rb +106 -0
- data/lib/basecamp/services/card_steps_service.rb +86 -0
- data/lib/basecamp/services/card_tables_service.rb +23 -0
- data/lib/basecamp/services/cards_service.rb +93 -0
- data/lib/basecamp/services/checkins_service.rb +127 -0
- data/lib/basecamp/services/client_approvals_service.rb +33 -0
- data/lib/basecamp/services/client_correspondences_service.rb +33 -0
- data/lib/basecamp/services/client_replies_service.rb +35 -0
- data/lib/basecamp/services/comments_service.rb +63 -0
- data/lib/basecamp/services/documents_service.rb +74 -0
- data/lib/basecamp/services/events_service.rb +27 -0
- data/lib/basecamp/services/forwards_service.rb +80 -0
- data/lib/basecamp/services/lineup_service.rb +67 -0
- data/lib/basecamp/services/message_boards_service.rb +24 -0
- data/lib/basecamp/services/message_types_service.rb +79 -0
- data/lib/basecamp/services/messages_service.rb +133 -0
- data/lib/basecamp/services/people_service.rb +73 -0
- data/lib/basecamp/services/projects_service.rb +67 -0
- data/lib/basecamp/services/recordings_service.rb +127 -0
- data/lib/basecamp/services/reports_service.rb +80 -0
- data/lib/basecamp/services/schedules_service.rb +156 -0
- data/lib/basecamp/services/search_service.rb +36 -0
- data/lib/basecamp/services/subscriptions_service.rb +67 -0
- data/lib/basecamp/services/templates_service.rb +96 -0
- data/lib/basecamp/services/timeline_service.rb +62 -0
- data/lib/basecamp/services/timesheet_service.rb +68 -0
- data/lib/basecamp/services/todolist_groups_service.rb +100 -0
- data/lib/basecamp/services/todolists_service.rb +104 -0
- data/lib/basecamp/services/todos_service.rb +156 -0
- data/lib/basecamp/services/todosets_service.rb +23 -0
- data/lib/basecamp/services/tools_service.rb +89 -0
- data/lib/basecamp/services/uploads_service.rb +84 -0
- data/lib/basecamp/services/vaults_service.rb +84 -0
- data/lib/basecamp/services/webhooks_service.rb +88 -0
- data/lib/basecamp/static_token_provider.rb +24 -0
- data/lib/basecamp/token_provider.rb +42 -0
- data/lib/basecamp/version.rb +6 -0
- data/lib/basecamp/webhooks/event.rb +52 -0
- data/lib/basecamp/webhooks/rack_middleware.rb +49 -0
- data/lib/basecamp/webhooks/receiver.rb +161 -0
- data/lib/basecamp/webhooks/verify.rb +36 -0
- data/lib/basecamp.rb +107 -0
- data/scripts/generate-metadata.rb +106 -0
- data/scripts/generate-services.rb +778 -0
- data/scripts/generate-types.rb +191 -0
- metadata +316 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Basecamp
|
|
4
|
+
module Oauth
|
|
5
|
+
# OAuth-specific error class.
|
|
6
|
+
#
|
|
7
|
+
# @attr type [String] Error type ("validation", "auth", "network", "api_error")
|
|
8
|
+
# @attr http_status [Integer, nil] HTTP status code if applicable
|
|
9
|
+
# @attr hint [String, nil] Helpful hint for resolving the error
|
|
10
|
+
# @attr retryable [Boolean] Whether the request can be retried
|
|
11
|
+
class OAuthError < StandardError
|
|
12
|
+
attr_reader :type, :http_status, :hint, :retryable
|
|
13
|
+
|
|
14
|
+
# @param type [String] Error type
|
|
15
|
+
# @param message [String] Error message
|
|
16
|
+
# @param http_status [Integer, nil] HTTP status code
|
|
17
|
+
# @param hint [String, nil] Helpful hint
|
|
18
|
+
# @param retryable [Boolean] Whether retryable
|
|
19
|
+
def initialize(type, message, http_status: nil, hint: nil, retryable: false)
|
|
20
|
+
super(message)
|
|
21
|
+
@type = type
|
|
22
|
+
@http_status = http_status
|
|
23
|
+
@hint = hint
|
|
24
|
+
@retryable = retryable
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def to_s
|
|
28
|
+
parts = [ "[#{type}] #{super}" ]
|
|
29
|
+
parts << "(HTTP #{http_status})" if http_status
|
|
30
|
+
parts << "Hint: #{hint}" if hint
|
|
31
|
+
parts.join(" ")
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Basecamp
|
|
8
|
+
# OAuth 2 helpers for Basecamp authentication.
|
|
9
|
+
module Oauth
|
|
10
|
+
# Exchanger handles OAuth 2 token exchange and refresh operations.
|
|
11
|
+
class Exchanger
|
|
12
|
+
# @param http_client [Faraday::Connection, nil] HTTP client (uses default if nil)
|
|
13
|
+
# @param timeout [Integer] Request timeout in seconds (default: 30)
|
|
14
|
+
def initialize(http_client: nil, timeout: 30)
|
|
15
|
+
@http_client = http_client || build_default_client(timeout)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Exchanges an authorization code for access and refresh tokens.
|
|
19
|
+
#
|
|
20
|
+
# Supports both standard OAuth 2 and Basecamp's Launchpad legacy format.
|
|
21
|
+
# Use `use_legacy_format: true` for Launchpad compatibility.
|
|
22
|
+
#
|
|
23
|
+
# @param request [ExchangeRequest] Exchange request parameters
|
|
24
|
+
# @return [Token] The token response
|
|
25
|
+
# @raise [OAuthError] on validation, network, or authentication errors
|
|
26
|
+
#
|
|
27
|
+
# @example Standard OAuth 2
|
|
28
|
+
# token = exchanger.exchange(ExchangeRequest.new(
|
|
29
|
+
# token_endpoint: config.token_endpoint,
|
|
30
|
+
# code: "auth_code_from_callback",
|
|
31
|
+
# redirect_uri: "https://myapp.com/callback",
|
|
32
|
+
# client_id: "my_client_id",
|
|
33
|
+
# client_secret: "my_client_secret"
|
|
34
|
+
# ))
|
|
35
|
+
#
|
|
36
|
+
# @example Launchpad legacy format
|
|
37
|
+
# token = exchanger.exchange(ExchangeRequest.new(
|
|
38
|
+
# token_endpoint: config.token_endpoint,
|
|
39
|
+
# code: "auth_code",
|
|
40
|
+
# redirect_uri: "https://myapp.com/callback",
|
|
41
|
+
# client_id: "my_client_id",
|
|
42
|
+
# client_secret: "my_client_secret",
|
|
43
|
+
# use_legacy_format: true
|
|
44
|
+
# ))
|
|
45
|
+
def exchange(request)
|
|
46
|
+
validate_exchange_request!(request)
|
|
47
|
+
|
|
48
|
+
params = build_exchange_params(request)
|
|
49
|
+
do_token_request(request.token_endpoint, params)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Refreshes an access token using a refresh token.
|
|
53
|
+
#
|
|
54
|
+
# Supports both standard OAuth 2 and Basecamp's Launchpad legacy format.
|
|
55
|
+
# Use `use_legacy_format: true` for Launchpad compatibility.
|
|
56
|
+
#
|
|
57
|
+
# @param request [RefreshRequest] Refresh request parameters
|
|
58
|
+
# @return [Token] The new token response
|
|
59
|
+
# @raise [OAuthError] on validation, network, or authentication errors
|
|
60
|
+
#
|
|
61
|
+
# @example Standard OAuth 2
|
|
62
|
+
# new_token = exchanger.refresh(RefreshRequest.new(
|
|
63
|
+
# token_endpoint: config.token_endpoint,
|
|
64
|
+
# refresh_token: old_token.refresh_token,
|
|
65
|
+
# client_id: "my_client_id",
|
|
66
|
+
# client_secret: "my_client_secret"
|
|
67
|
+
# ))
|
|
68
|
+
#
|
|
69
|
+
# @example Launchpad legacy format
|
|
70
|
+
# new_token = exchanger.refresh(RefreshRequest.new(
|
|
71
|
+
# token_endpoint: config.token_endpoint,
|
|
72
|
+
# refresh_token: old_token.refresh_token,
|
|
73
|
+
# use_legacy_format: true
|
|
74
|
+
# ))
|
|
75
|
+
def refresh(request)
|
|
76
|
+
validate_refresh_request!(request)
|
|
77
|
+
|
|
78
|
+
params = build_refresh_params(request)
|
|
79
|
+
do_token_request(request.token_endpoint, params)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def build_default_client(timeout)
|
|
85
|
+
Faraday.new do |conn|
|
|
86
|
+
conn.options.timeout = timeout
|
|
87
|
+
conn.options.open_timeout = timeout
|
|
88
|
+
conn.adapter Faraday.default_adapter
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def validate_exchange_request!(request)
|
|
93
|
+
raise OAuthError.new("validation", "Token endpoint is required") if request.token_endpoint.to_s.empty?
|
|
94
|
+
raise OAuthError.new("validation", "Authorization code is required") if request.code.to_s.empty?
|
|
95
|
+
raise OAuthError.new("validation", "Redirect URI is required") if request.redirect_uri.to_s.empty?
|
|
96
|
+
raise OAuthError.new("validation", "Client ID is required") if request.client_id.to_s.empty?
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def validate_refresh_request!(request)
|
|
100
|
+
raise OAuthError.new("validation", "Token endpoint is required") if request.token_endpoint.to_s.empty?
|
|
101
|
+
raise OAuthError.new("validation", "Refresh token is required") if request.refresh_token.to_s.empty?
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def build_exchange_params(request)
|
|
105
|
+
params = {}
|
|
106
|
+
|
|
107
|
+
if request.use_legacy_format
|
|
108
|
+
# Launchpad uses non-standard "type" parameter
|
|
109
|
+
params["type"] = "web_server"
|
|
110
|
+
else
|
|
111
|
+
# Standard OAuth 2
|
|
112
|
+
params["grant_type"] = "authorization_code"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
params["code"] = request.code
|
|
116
|
+
params["redirect_uri"] = request.redirect_uri
|
|
117
|
+
params["client_id"] = request.client_id
|
|
118
|
+
params["client_secret"] = request.client_secret if request.client_secret
|
|
119
|
+
params["code_verifier"] = request.code_verifier if request.code_verifier
|
|
120
|
+
|
|
121
|
+
params
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def build_refresh_params(request)
|
|
125
|
+
params = {}
|
|
126
|
+
|
|
127
|
+
if request.use_legacy_format
|
|
128
|
+
# Launchpad uses non-standard "type" parameter
|
|
129
|
+
params["type"] = "refresh"
|
|
130
|
+
else
|
|
131
|
+
# Standard OAuth 2
|
|
132
|
+
params["grant_type"] = "refresh_token"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
params["refresh_token"] = request.refresh_token
|
|
136
|
+
params["client_id"] = request.client_id if request.client_id
|
|
137
|
+
params["client_secret"] = request.client_secret if request.client_secret
|
|
138
|
+
|
|
139
|
+
params
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def do_token_request(token_endpoint, params)
|
|
143
|
+
Basecamp::Security.require_https_unless_localhost!(token_endpoint, "token endpoint")
|
|
144
|
+
|
|
145
|
+
response = @http_client.post(token_endpoint) do |req|
|
|
146
|
+
req.headers["Content-Type"] = "application/x-www-form-urlencoded"
|
|
147
|
+
req.headers["Accept"] = "application/json"
|
|
148
|
+
req.body = URI.encode_www_form(params)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
parse_token_response(response)
|
|
152
|
+
rescue Faraday::TimeoutError
|
|
153
|
+
raise OAuthError.new("network", "Token request timed out", retryable: true)
|
|
154
|
+
rescue Faraday::Error => e
|
|
155
|
+
raise OAuthError.new("network", "Token request failed: #{e.message}", retryable: true)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def parse_token_response(response)
|
|
159
|
+
Basecamp::Security.check_body_size!(response.body, Basecamp::Security::MAX_ERROR_BODY_BYTES, "Token")
|
|
160
|
+
|
|
161
|
+
data = JSON.parse(response.body)
|
|
162
|
+
|
|
163
|
+
handle_error_response(response.status, data) unless response.success?
|
|
164
|
+
|
|
165
|
+
raise OAuthError.new("api_error", "Token response missing access_token") unless data["access_token"]
|
|
166
|
+
|
|
167
|
+
Token.new(
|
|
168
|
+
access_token: data["access_token"],
|
|
169
|
+
refresh_token: data["refresh_token"],
|
|
170
|
+
token_type: data["token_type"] || "Bearer",
|
|
171
|
+
expires_in: data["expires_in"],
|
|
172
|
+
scope: data["scope"]
|
|
173
|
+
)
|
|
174
|
+
rescue JSON::ParserError
|
|
175
|
+
raise OAuthError.new(
|
|
176
|
+
"api_error",
|
|
177
|
+
"Failed to parse token response: #{Basecamp::Security.truncate(response.body)}",
|
|
178
|
+
http_status: response.status
|
|
179
|
+
)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def handle_error_response(status, data)
|
|
183
|
+
error_msg = Basecamp::Security.truncate(data["error_description"] || data["error"] || "Token request failed")
|
|
184
|
+
|
|
185
|
+
if status == 401 || data["error"] == "invalid_grant"
|
|
186
|
+
raise OAuthError.new(
|
|
187
|
+
"auth",
|
|
188
|
+
error_msg,
|
|
189
|
+
http_status: status,
|
|
190
|
+
hint: "The authorization code or refresh token may be invalid or expired"
|
|
191
|
+
)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
raise OAuthError.new("api_error", error_msg, http_status: status)
|
|
195
|
+
end
|
|
196
|
+
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
|
+
end
|
|
291
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "base64"
|
|
6
|
+
|
|
7
|
+
module Basecamp
|
|
8
|
+
module Oauth
|
|
9
|
+
# PKCE (Proof Key for Code Exchange) utilities for OAuth 2.0.
|
|
10
|
+
#
|
|
11
|
+
# Provides cryptographically secure code verifier and challenge generation
|
|
12
|
+
# to protect against authorization code interception attacks.
|
|
13
|
+
module Pkce
|
|
14
|
+
# Generates a cryptographically secure PKCE code verifier and challenge.
|
|
15
|
+
#
|
|
16
|
+
# The verifier is 43 characters (32 random bytes, base64url-encoded).
|
|
17
|
+
# The challenge is the base64url-encoded SHA256 hash of the verifier.
|
|
18
|
+
#
|
|
19
|
+
# Use code_challenge_method=S256 with the challenge in the authorization request.
|
|
20
|
+
#
|
|
21
|
+
# @return [Hash] containing :verifier and :challenge keys
|
|
22
|
+
#
|
|
23
|
+
# @example
|
|
24
|
+
# pkce = Basecamp::Oauth::Pkce.generate
|
|
25
|
+
#
|
|
26
|
+
# # In authorization request:
|
|
27
|
+
# auth_url = "#{auth_endpoint}?code_challenge=#{pkce[:challenge]}&code_challenge_method=S256"
|
|
28
|
+
#
|
|
29
|
+
# # Later, in token exchange:
|
|
30
|
+
# token = exchange_code(code: code, code_verifier: pkce[:verifier])
|
|
31
|
+
#
|
|
32
|
+
def self.generate
|
|
33
|
+
# Generate 32 random bytes, base64url-encoded without padding
|
|
34
|
+
# Note: Use Base64.urlsafe_encode64 directly for consistent behavior across Ruby versions
|
|
35
|
+
verifier = Base64.urlsafe_encode64(SecureRandom.random_bytes(32), padding: false)
|
|
36
|
+
|
|
37
|
+
# Compute SHA256 hash and base64url-encode without padding
|
|
38
|
+
hash = Digest::SHA256.digest(verifier)
|
|
39
|
+
challenge = Base64.urlsafe_encode64(hash, padding: false)
|
|
40
|
+
|
|
41
|
+
{ verifier: verifier, challenge: challenge }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Generates a cryptographically secure OAuth state parameter.
|
|
45
|
+
#
|
|
46
|
+
# The state is 22 characters (16 random bytes, base64url-encoded).
|
|
47
|
+
# Use this to prevent CSRF attacks on the OAuth flow.
|
|
48
|
+
#
|
|
49
|
+
# @return [String] the state parameter
|
|
50
|
+
#
|
|
51
|
+
# @example
|
|
52
|
+
# state = Basecamp::Oauth::Pkce.generate_state
|
|
53
|
+
#
|
|
54
|
+
# # Store state in session before redirect:
|
|
55
|
+
# session[:oauth_state] = state
|
|
56
|
+
#
|
|
57
|
+
# # In callback handler:
|
|
58
|
+
# if params[:state] != session[:oauth_state]
|
|
59
|
+
# raise "State mismatch - possible CSRF attack"
|
|
60
|
+
# end
|
|
61
|
+
#
|
|
62
|
+
def self.generate_state
|
|
63
|
+
# Note: Use Base64.urlsafe_encode64 directly for consistent behavior across Ruby versions
|
|
64
|
+
Base64.urlsafe_encode64(SecureRandom.random_bytes(16), padding: false)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
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
|
+
|
|
30
|
+
# OAuth 2 access token response.
|
|
31
|
+
#
|
|
32
|
+
# @attr access_token [String] The access token string
|
|
33
|
+
# @attr token_type [String] Token type (usually "Bearer")
|
|
34
|
+
# @attr refresh_token [String, nil] The refresh token string
|
|
35
|
+
# @attr expires_in [Integer, nil] Lifetime of the access token in seconds
|
|
36
|
+
# @attr expires_at [Time, nil] Calculated expiration time
|
|
37
|
+
# @attr scope [String, nil] OAuth scope granted
|
|
38
|
+
Token = Data.define(
|
|
39
|
+
:access_token,
|
|
40
|
+
:token_type,
|
|
41
|
+
:refresh_token,
|
|
42
|
+
:expires_in,
|
|
43
|
+
:expires_at,
|
|
44
|
+
:scope
|
|
45
|
+
) do
|
|
46
|
+
def initialize(
|
|
47
|
+
access_token:,
|
|
48
|
+
token_type: "Bearer",
|
|
49
|
+
refresh_token: nil,
|
|
50
|
+
expires_in: nil,
|
|
51
|
+
expires_at: nil,
|
|
52
|
+
scope: nil
|
|
53
|
+
)
|
|
54
|
+
# Calculate expires_at from expires_in if not provided
|
|
55
|
+
calculated_expires_at = expires_at || (expires_in ? Time.now + expires_in : nil)
|
|
56
|
+
super(
|
|
57
|
+
access_token: access_token,
|
|
58
|
+
token_type: token_type,
|
|
59
|
+
refresh_token: refresh_token,
|
|
60
|
+
expires_in: expires_in,
|
|
61
|
+
expires_at: calculated_expires_at,
|
|
62
|
+
scope: scope
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Checks if the token is expired or about to expire.
|
|
67
|
+
#
|
|
68
|
+
# @param buffer_seconds [Integer] Buffer time before actual expiration (default: 60)
|
|
69
|
+
# @return [Boolean] true if expired or will expire within buffer time
|
|
70
|
+
def expired?(buffer_seconds = 60)
|
|
71
|
+
return false unless expires_at
|
|
72
|
+
|
|
73
|
+
Time.now + buffer_seconds >= expires_at
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Parameters for exchanging an authorization code for tokens.
|
|
78
|
+
#
|
|
79
|
+
# @attr token_endpoint [String] URL of the token endpoint
|
|
80
|
+
# @attr code [String] The authorization code received from the authorization server
|
|
81
|
+
# @attr redirect_uri [String] The redirect URI used in the authorization request
|
|
82
|
+
# @attr client_id [String] The client identifier
|
|
83
|
+
# @attr client_secret [String, nil] The client secret (optional for public clients)
|
|
84
|
+
# @attr code_verifier [String, nil] PKCE code verifier (optional)
|
|
85
|
+
# @attr use_legacy_format [Boolean] Use Launchpad's non-standard token format
|
|
86
|
+
ExchangeRequest = Data.define(
|
|
87
|
+
:token_endpoint,
|
|
88
|
+
:code,
|
|
89
|
+
:redirect_uri,
|
|
90
|
+
:client_id,
|
|
91
|
+
:client_secret,
|
|
92
|
+
:code_verifier,
|
|
93
|
+
:use_legacy_format
|
|
94
|
+
) do
|
|
95
|
+
def initialize(
|
|
96
|
+
token_endpoint:,
|
|
97
|
+
code:,
|
|
98
|
+
redirect_uri:,
|
|
99
|
+
client_id:,
|
|
100
|
+
client_secret: nil,
|
|
101
|
+
code_verifier: nil,
|
|
102
|
+
use_legacy_format: false
|
|
103
|
+
)
|
|
104
|
+
super
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Parameters for refreshing an access token.
|
|
109
|
+
#
|
|
110
|
+
# @attr token_endpoint [String] URL of the token endpoint
|
|
111
|
+
# @attr refresh_token [String] The refresh token
|
|
112
|
+
# @attr client_id [String, nil] The client identifier (optional)
|
|
113
|
+
# @attr client_secret [String, nil] The client secret (optional)
|
|
114
|
+
# @attr use_legacy_format [Boolean] Use Launchpad's non-standard token format
|
|
115
|
+
RefreshRequest = Data.define(
|
|
116
|
+
:token_endpoint,
|
|
117
|
+
:refresh_token,
|
|
118
|
+
:client_id,
|
|
119
|
+
:client_secret,
|
|
120
|
+
:use_legacy_format
|
|
121
|
+
) do
|
|
122
|
+
def initialize(
|
|
123
|
+
token_endpoint:,
|
|
124
|
+
refresh_token:,
|
|
125
|
+
client_id: nil,
|
|
126
|
+
client_secret: nil,
|
|
127
|
+
use_legacy_format: false
|
|
128
|
+
)
|
|
129
|
+
super
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "oauth/errors"
|
|
4
|
+
require_relative "oauth/types"
|
|
5
|
+
require_relative "oauth/discovery"
|
|
6
|
+
require_relative "oauth/exchange"
|
|
7
|
+
|
|
8
|
+
module Basecamp
|
|
9
|
+
# OAuth 2 module for Basecamp SDK.
|
|
10
|
+
#
|
|
11
|
+
# Provides OAuth discovery, token exchange, and token refresh functionality.
|
|
12
|
+
# Supports both standard OAuth 2 and Basecamp's Launchpad legacy format.
|
|
13
|
+
#
|
|
14
|
+
# @example Complete OAuth flow
|
|
15
|
+
# # 1. Discover OAuth configuration
|
|
16
|
+
# config = Basecamp::Oauth.discover_launchpad
|
|
17
|
+
#
|
|
18
|
+
# # 2. Build authorization URL (redirect user here)
|
|
19
|
+
# auth_url = "#{config.authorization_endpoint}?" + URI.encode_www_form(
|
|
20
|
+
# type: "web_server",
|
|
21
|
+
# client_id: ENV["BASECAMP_CLIENT_ID"],
|
|
22
|
+
# redirect_uri: "https://myapp.com/callback"
|
|
23
|
+
# )
|
|
24
|
+
#
|
|
25
|
+
# # 3. Exchange authorization code for tokens (in callback handler)
|
|
26
|
+
# token = Basecamp::Oauth.exchange_code(
|
|
27
|
+
# token_endpoint: config.token_endpoint,
|
|
28
|
+
# code: params[:code],
|
|
29
|
+
# redirect_uri: "https://myapp.com/callback",
|
|
30
|
+
# client_id: ENV["BASECAMP_CLIENT_ID"],
|
|
31
|
+
# client_secret: ENV["BASECAMP_CLIENT_SECRET"],
|
|
32
|
+
# use_legacy_format: true # Required for Launchpad
|
|
33
|
+
# )
|
|
34
|
+
#
|
|
35
|
+
# # 4. Use the token
|
|
36
|
+
# client = Basecamp.client(
|
|
37
|
+
# access_token: token.access_token,
|
|
38
|
+
# account_id: "12345"
|
|
39
|
+
# )
|
|
40
|
+
#
|
|
41
|
+
# # 5. Refresh when needed
|
|
42
|
+
# if token.expired?
|
|
43
|
+
# token = Basecamp::Oauth.refresh_token(
|
|
44
|
+
# token_endpoint: config.token_endpoint,
|
|
45
|
+
# refresh_token: token.refresh_token,
|
|
46
|
+
# use_legacy_format: true
|
|
47
|
+
# )
|
|
48
|
+
# end
|
|
49
|
+
#
|
|
50
|
+
# @see https://github.com/basecamp/api/blob/master/sections/authentication.md
|
|
51
|
+
module Oauth
|
|
52
|
+
# Re-export constants
|
|
53
|
+
# @return [String] Default Launchpad base URL
|
|
54
|
+
# LAUNCHPAD_BASE_URL is defined in discovery.rb
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Basecamp
|
|
4
|
+
# A token provider that supports OAuth token refresh.
|
|
5
|
+
#
|
|
6
|
+
# @example
|
|
7
|
+
# provider = Basecamp::OauthTokenProvider.new(
|
|
8
|
+
# access_token: "current-token",
|
|
9
|
+
# refresh_token: "refresh-token",
|
|
10
|
+
# client_id: "your-client-id",
|
|
11
|
+
# client_secret: "your-client-secret"
|
|
12
|
+
# )
|
|
13
|
+
class OauthTokenProvider
|
|
14
|
+
include TokenProvider
|
|
15
|
+
|
|
16
|
+
# Token endpoint for Basecamp OAuth
|
|
17
|
+
TOKEN_URL = "https://launchpad.37signals.com/authorization/token"
|
|
18
|
+
|
|
19
|
+
# @return [String, nil] the current refresh token
|
|
20
|
+
attr_reader :refresh_token
|
|
21
|
+
|
|
22
|
+
# @return [Time, nil] when the access token expires
|
|
23
|
+
attr_reader :expires_at
|
|
24
|
+
|
|
25
|
+
# Callback invoked when tokens are refreshed.
|
|
26
|
+
# @return [Proc, nil]
|
|
27
|
+
attr_accessor :on_refresh
|
|
28
|
+
|
|
29
|
+
# @param access_token [String] current access token
|
|
30
|
+
# @param refresh_token [String, nil] refresh token for renewal
|
|
31
|
+
# @param client_id [String] OAuth client ID
|
|
32
|
+
# @param client_secret [String] OAuth client secret
|
|
33
|
+
# @param expires_at [Time, nil] token expiration time
|
|
34
|
+
# @param on_refresh [Proc, nil] callback when tokens refresh
|
|
35
|
+
def initialize(access_token:, client_id:, client_secret:, refresh_token: nil, expires_at: nil, on_refresh: nil)
|
|
36
|
+
@access_token = access_token
|
|
37
|
+
@refresh_token = refresh_token
|
|
38
|
+
@client_id = client_id
|
|
39
|
+
@client_secret = client_secret
|
|
40
|
+
@expires_at = expires_at
|
|
41
|
+
@on_refresh = on_refresh
|
|
42
|
+
@mutex = Mutex.new
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Returns the current access token, refreshing if expired.
|
|
46
|
+
# @return [String]
|
|
47
|
+
def access_token
|
|
48
|
+
@mutex.synchronize do
|
|
49
|
+
refresh_if_needed
|
|
50
|
+
@access_token
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Refreshes the access token using the refresh token.
|
|
55
|
+
# @return [Boolean] true if refresh succeeded
|
|
56
|
+
def refresh
|
|
57
|
+
@mutex.synchronize do
|
|
58
|
+
return false unless refreshable?
|
|
59
|
+
|
|
60
|
+
perform_refresh
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @return [Boolean] true if refresh token is available
|
|
65
|
+
def refreshable?
|
|
66
|
+
@refresh_token && !@refresh_token.empty?
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @return [Boolean] true if the access token is expired
|
|
70
|
+
def expired?
|
|
71
|
+
@expires_at && Time.now >= @expires_at
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def refresh_if_needed
|
|
77
|
+
perform_refresh if expired? && refreshable?
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def perform_refresh
|
|
81
|
+
require "faraday"
|
|
82
|
+
require "json"
|
|
83
|
+
require "uri"
|
|
84
|
+
|
|
85
|
+
response = Faraday.post(TOKEN_URL) do |req|
|
|
86
|
+
req.headers["Content-Type"] = "application/x-www-form-urlencoded"
|
|
87
|
+
req.body = URI.encode_www_form(
|
|
88
|
+
type: "refresh",
|
|
89
|
+
refresh_token: @refresh_token,
|
|
90
|
+
client_id: @client_id,
|
|
91
|
+
client_secret: @client_secret
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
raise AuthError.new("Token refresh failed: #{response.status}") unless response.success?
|
|
96
|
+
|
|
97
|
+
data = JSON.parse(response.body)
|
|
98
|
+
@access_token = data["access_token"]
|
|
99
|
+
@expires_at = Time.now + data["expires_in"].to_i if data["expires_in"]
|
|
100
|
+
|
|
101
|
+
@on_refresh&.call(@access_token, @refresh_token, @expires_at)
|
|
102
|
+
|
|
103
|
+
true
|
|
104
|
+
rescue Faraday::Error => e
|
|
105
|
+
raise NetworkError.new("Token refresh network error", cause: e)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|