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.
Files changed (116) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +14 -0
  3. data/.yardopts +6 -0
  4. data/README.md +293 -0
  5. data/Rakefile +26 -0
  6. data/basecamp-sdk.gemspec +46 -0
  7. data/lib/basecamp/auth_strategy.rb +38 -0
  8. data/lib/basecamp/chain_hooks.rb +45 -0
  9. data/lib/basecamp/client.rb +428 -0
  10. data/lib/basecamp/config.rb +143 -0
  11. data/lib/basecamp/errors.rb +289 -0
  12. data/lib/basecamp/generated/metadata.json +2281 -0
  13. data/lib/basecamp/generated/services/attachments_service.rb +24 -0
  14. data/lib/basecamp/generated/services/boosts_service.rb +70 -0
  15. data/lib/basecamp/generated/services/campfires_service.rb +122 -0
  16. data/lib/basecamp/generated/services/card_columns_service.rb +103 -0
  17. data/lib/basecamp/generated/services/card_steps_service.rb +57 -0
  18. data/lib/basecamp/generated/services/card_tables_service.rb +20 -0
  19. data/lib/basecamp/generated/services/cards_service.rb +66 -0
  20. data/lib/basecamp/generated/services/checkins_service.rb +157 -0
  21. data/lib/basecamp/generated/services/client_approvals_service.rb +28 -0
  22. data/lib/basecamp/generated/services/client_correspondences_service.rb +28 -0
  23. data/lib/basecamp/generated/services/client_replies_service.rb +30 -0
  24. data/lib/basecamp/generated/services/client_visibility_service.rb +21 -0
  25. data/lib/basecamp/generated/services/comments_service.rb +49 -0
  26. data/lib/basecamp/generated/services/documents_service.rb +52 -0
  27. data/lib/basecamp/generated/services/events_service.rb +20 -0
  28. data/lib/basecamp/generated/services/forwards_service.rb +67 -0
  29. data/lib/basecamp/generated/services/lineup_service.rb +44 -0
  30. data/lib/basecamp/generated/services/message_boards_service.rb +20 -0
  31. data/lib/basecamp/generated/services/message_types_service.rb +59 -0
  32. data/lib/basecamp/generated/services/messages_service.rb +75 -0
  33. data/lib/basecamp/generated/services/people_service.rb +73 -0
  34. data/lib/basecamp/generated/services/projects_service.rb +63 -0
  35. data/lib/basecamp/generated/services/recordings_service.rb +64 -0
  36. data/lib/basecamp/generated/services/reports_service.rb +56 -0
  37. data/lib/basecamp/generated/services/schedules_service.rb +92 -0
  38. data/lib/basecamp/generated/services/search_service.rb +31 -0
  39. data/lib/basecamp/generated/services/subscriptions_service.rb +50 -0
  40. data/lib/basecamp/generated/services/templates_service.rb +82 -0
  41. data/lib/basecamp/generated/services/timeline_service.rb +20 -0
  42. data/lib/basecamp/generated/services/timesheets_service.rb +81 -0
  43. data/lib/basecamp/generated/services/todolist_groups_service.rb +41 -0
  44. data/lib/basecamp/generated/services/todolists_service.rb +53 -0
  45. data/lib/basecamp/generated/services/todos_service.rb +106 -0
  46. data/lib/basecamp/generated/services/todosets_service.rb +20 -0
  47. data/lib/basecamp/generated/services/tools_service.rb +80 -0
  48. data/lib/basecamp/generated/services/uploads_service.rb +61 -0
  49. data/lib/basecamp/generated/services/vaults_service.rb +49 -0
  50. data/lib/basecamp/generated/services/webhooks_service.rb +63 -0
  51. data/lib/basecamp/generated/types.rb +3196 -0
  52. data/lib/basecamp/hooks.rb +70 -0
  53. data/lib/basecamp/http.rb +440 -0
  54. data/lib/basecamp/logger_hooks.rb +46 -0
  55. data/lib/basecamp/noop_hooks.rb +9 -0
  56. data/lib/basecamp/oauth/discovery.rb +123 -0
  57. data/lib/basecamp/oauth/errors.rb +35 -0
  58. data/lib/basecamp/oauth/exchange.rb +291 -0
  59. data/lib/basecamp/oauth/pkce.rb +68 -0
  60. data/lib/basecamp/oauth/types.rb +133 -0
  61. data/lib/basecamp/oauth.rb +56 -0
  62. data/lib/basecamp/oauth_token_provider.rb +108 -0
  63. data/lib/basecamp/operation_info.rb +17 -0
  64. data/lib/basecamp/request_info.rb +10 -0
  65. data/lib/basecamp/request_result.rb +14 -0
  66. data/lib/basecamp/security.rb +112 -0
  67. data/lib/basecamp/services/attachments_service.rb +33 -0
  68. data/lib/basecamp/services/authorization_service.rb +47 -0
  69. data/lib/basecamp/services/base_service.rb +146 -0
  70. data/lib/basecamp/services/campfires_service.rb +141 -0
  71. data/lib/basecamp/services/card_columns_service.rb +106 -0
  72. data/lib/basecamp/services/card_steps_service.rb +86 -0
  73. data/lib/basecamp/services/card_tables_service.rb +23 -0
  74. data/lib/basecamp/services/cards_service.rb +93 -0
  75. data/lib/basecamp/services/checkins_service.rb +127 -0
  76. data/lib/basecamp/services/client_approvals_service.rb +33 -0
  77. data/lib/basecamp/services/client_correspondences_service.rb +33 -0
  78. data/lib/basecamp/services/client_replies_service.rb +35 -0
  79. data/lib/basecamp/services/comments_service.rb +63 -0
  80. data/lib/basecamp/services/documents_service.rb +74 -0
  81. data/lib/basecamp/services/events_service.rb +27 -0
  82. data/lib/basecamp/services/forwards_service.rb +80 -0
  83. data/lib/basecamp/services/lineup_service.rb +67 -0
  84. data/lib/basecamp/services/message_boards_service.rb +24 -0
  85. data/lib/basecamp/services/message_types_service.rb +79 -0
  86. data/lib/basecamp/services/messages_service.rb +133 -0
  87. data/lib/basecamp/services/people_service.rb +73 -0
  88. data/lib/basecamp/services/projects_service.rb +67 -0
  89. data/lib/basecamp/services/recordings_service.rb +127 -0
  90. data/lib/basecamp/services/reports_service.rb +80 -0
  91. data/lib/basecamp/services/schedules_service.rb +156 -0
  92. data/lib/basecamp/services/search_service.rb +36 -0
  93. data/lib/basecamp/services/subscriptions_service.rb +67 -0
  94. data/lib/basecamp/services/templates_service.rb +96 -0
  95. data/lib/basecamp/services/timeline_service.rb +62 -0
  96. data/lib/basecamp/services/timesheet_service.rb +68 -0
  97. data/lib/basecamp/services/todolist_groups_service.rb +100 -0
  98. data/lib/basecamp/services/todolists_service.rb +104 -0
  99. data/lib/basecamp/services/todos_service.rb +156 -0
  100. data/lib/basecamp/services/todosets_service.rb +23 -0
  101. data/lib/basecamp/services/tools_service.rb +89 -0
  102. data/lib/basecamp/services/uploads_service.rb +84 -0
  103. data/lib/basecamp/services/vaults_service.rb +84 -0
  104. data/lib/basecamp/services/webhooks_service.rb +88 -0
  105. data/lib/basecamp/static_token_provider.rb +24 -0
  106. data/lib/basecamp/token_provider.rb +42 -0
  107. data/lib/basecamp/version.rb +6 -0
  108. data/lib/basecamp/webhooks/event.rb +52 -0
  109. data/lib/basecamp/webhooks/rack_middleware.rb +49 -0
  110. data/lib/basecamp/webhooks/receiver.rb +161 -0
  111. data/lib/basecamp/webhooks/verify.rb +36 -0
  112. data/lib/basecamp.rb +107 -0
  113. data/scripts/generate-metadata.rb +106 -0
  114. data/scripts/generate-services.rb +778 -0
  115. data/scripts/generate-types.rb +191 -0
  116. 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