Skip to content

Commit a9f7d9b

Browse files
committed
Add OAuth 2.0 authentication with token refresh support
Implement OAuth2Authenticator class that supports: - Token storage (access_token, refresh_token, expires_at) - Automatic token expiration checking via token_expired? - Token refresh via refresh_token! method
1 parent 42b9f4d commit a9f7d9b

File tree

7 files changed

+586
-53
lines changed

7 files changed

+586
-53
lines changed

lib/x/client.rb

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
require_relative "client_credentials"
55
require_relative "connection"
66
require_relative "oauth_authenticator"
7+
require_relative "oauth2_authenticator"
78
require_relative "redirect_handler"
89
require_relative "request_builder"
910
require_relative "response_parser"
@@ -54,6 +55,9 @@ class Client
5455
# @param access_token [String, nil] the access token for OAuth authentication
5556
# @param access_token_secret [String, nil] the access token secret for OAuth 1.0a authentication
5657
# @param bearer_token [String, nil] the bearer token for authentication
58+
# @param client_id [String, nil] the OAuth 2.0 client ID
59+
# @param client_secret [String, nil] the OAuth 2.0 client secret
60+
# @param refresh_token [String, nil] the OAuth 2.0 refresh token
5761
# @param base_url [String] the base URL for API requests
5862
# @param open_timeout [Integer] the timeout for opening connections in seconds
5963
# @param read_timeout [Integer] the timeout for reading responses in seconds
@@ -69,7 +73,7 @@ class Client
6973
# @example Create a client with OAuth 1.0a authentication
7074
# client = X::Client.new(api_key: "key", api_key_secret: "secret", access_token: "token", access_token_secret: "token_secret")
7175
def initialize(api_key: nil, api_key_secret: nil, access_token: nil, access_token_secret: nil,
72-
bearer_token: nil,
76+
bearer_token: nil, client_id: nil, client_secret: nil, refresh_token: nil,
7377
base_url: DEFAULT_BASE_URL,
7478
open_timeout: Connection::DEFAULT_OPEN_TIMEOUT,
7579
read_timeout: Connection::DEFAULT_READ_TIMEOUT,
@@ -79,7 +83,8 @@ def initialize(api_key: nil, api_key_secret: nil, access_token: nil, access_toke
7983
default_array_class: DEFAULT_ARRAY_CLASS,
8084
default_object_class: DEFAULT_OBJECT_CLASS,
8185
max_redirects: RedirectHandler::DEFAULT_MAX_REDIRECTS)
82-
initialize_credentials(api_key:, api_key_secret:, access_token:, access_token_secret:, bearer_token:)
86+
initialize_credentials(api_key:, api_key_secret:, access_token:, access_token_secret:, bearer_token:,
87+
client_id:, client_secret:, refresh_token:)
8388
initialize_authenticator
8489
@base_url = base_url
8590
@default_array_class = default_array_class

lib/x/client_credentials.rb

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,24 @@ module ClientCredentials
3232
# @example Get the bearer token
3333
# client.bearer_token
3434
attr_reader :bearer_token
35+
# The OAuth 2.0 client ID
36+
# @api public
37+
# @return [String, nil] the OAuth 2.0 client ID
38+
# @example Get the client ID
39+
# client.client_id
40+
attr_reader :client_id
41+
# The OAuth 2.0 client secret
42+
# @api public
43+
# @return [String, nil] the OAuth 2.0 client secret
44+
# @example Get the client secret
45+
# client.client_secret
46+
attr_reader :client_secret
47+
# The OAuth 2.0 refresh token
48+
# @api public
49+
# @return [String, nil] the OAuth 2.0 refresh token
50+
# @example Get the refresh token
51+
# client.refresh_token
52+
attr_reader :refresh_token
3553

3654
# Set the API key for OAuth 1.0a authentication
3755
#
@@ -93,24 +111,64 @@ def bearer_token=(bearer_token)
93111
initialize_authenticator
94112
end
95113

114+
# Set the OAuth 2.0 client ID
115+
#
116+
# @api public
117+
# @param client_id [String] the OAuth 2.0 client ID
118+
# @return [void]
119+
# @example Set the client ID
120+
# client.client_id = "new_id"
121+
def client_id=(client_id)
122+
@client_id = client_id
123+
initialize_authenticator
124+
end
125+
126+
# Set the OAuth 2.0 client secret
127+
#
128+
# @api public
129+
# @param client_secret [String] the OAuth 2.0 client secret
130+
# @return [void]
131+
# @example Set the client secret
132+
# client.client_secret = "new_secret"
133+
def client_secret=(client_secret)
134+
@client_secret = client_secret
135+
initialize_authenticator
136+
end
137+
138+
# Set the OAuth 2.0 refresh token
139+
#
140+
# @api public
141+
# @param refresh_token [String] the OAuth 2.0 refresh token
142+
# @return [void]
143+
# @example Set the refresh token
144+
# client.refresh_token = "new_token"
145+
def refresh_token=(refresh_token)
146+
@refresh_token = refresh_token
147+
initialize_authenticator
148+
end
149+
96150
private
97151

98152
# Initialize credential instance variables
99153
# @api private
100154
# @return [void]
101-
def initialize_credentials(api_key:, api_key_secret:, access_token:, access_token_secret:, bearer_token:)
155+
def initialize_credentials(api_key:, api_key_secret:, access_token:, access_token_secret:, bearer_token:,
156+
client_id:, client_secret:, refresh_token:)
102157
@api_key = api_key
103158
@api_key_secret = api_key_secret
104159
@access_token = access_token
105160
@access_token_secret = access_token_secret
106161
@bearer_token = bearer_token
162+
@client_id = client_id
163+
@client_secret = client_secret
164+
@refresh_token = refresh_token
107165
end
108166

109167
# Initialize the appropriate authenticator based on available credentials
110168
# @api private
111169
# @return [Authenticator] the initialized authenticator
112170
def initialize_authenticator
113-
@authenticator = oauth_authenticator || bearer_authenticator || @authenticator || Authenticator.new
171+
@authenticator = oauth_authenticator || oauth2_authenticator || bearer_authenticator || @authenticator || Authenticator.new
114172
end
115173

116174
# Build an OAuth 1.0a authenticator if credentials are available
@@ -122,6 +180,15 @@ def oauth_authenticator
122180
OAuthAuthenticator.new(api_key:, api_key_secret:, access_token:, access_token_secret:)
123181
end
124182

183+
# Build an OAuth 2.0 authenticator if credentials are available
184+
# @api private
185+
# @return [OAuth2Authenticator, nil] the OAuth 2.0 authenticator or nil
186+
def oauth2_authenticator
187+
return unless client_id && client_secret && access_token && refresh_token
188+
189+
OAuth2Authenticator.new(client_id:, client_secret:, access_token:, refresh_token:)
190+
end
191+
125192
# Build a bearer token authenticator if credentials are available
126193
# @api private
127194
# @return [BearerTokenAuthenticator, nil] the bearer token authenticator or nil

lib/x/oauth2_authenticator.rb

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
require "base64"
2+
require "json"
3+
require "net/http"
4+
require "uri"
5+
require_relative "authenticator"
6+
7+
module X
8+
# Handles OAuth 2.0 authentication with token refresh capability
9+
# @api public
10+
class OAuth2Authenticator < Authenticator
11+
# URL for the OAuth 2.0 token endpoint
12+
TOKEN_URL = "https://api.x.com/2/oauth2/token".freeze
13+
# Host for token refresh requests
14+
TOKEN_HOST = "api.x.com".freeze
15+
# Port for token refresh requests
16+
TOKEN_PORT = 443
17+
# Grant type for token refresh
18+
REFRESH_GRANT_TYPE = "refresh_token".freeze
19+
20+
# The OAuth 2.0 client ID
21+
# @api public
22+
# @return [String] the client ID
23+
# @example Get the client ID
24+
# authenticator.client_id
25+
attr_accessor :client_id
26+
# The OAuth 2.0 client secret
27+
# @api public
28+
# @return [String] the client secret
29+
# @example Get the client secret
30+
# authenticator.client_secret
31+
attr_accessor :client_secret
32+
# The OAuth 2.0 access token
33+
# @api public
34+
# @return [String] the access token
35+
# @example Get the access token
36+
# authenticator.access_token
37+
attr_accessor :access_token
38+
# The OAuth 2.0 refresh token
39+
# @api public
40+
# @return [String] the refresh token
41+
# @example Get the refresh token
42+
# authenticator.refresh_token
43+
attr_accessor :refresh_token
44+
# The expiration time of the access token
45+
# @api public
46+
# @return [Time, nil] the expiration time
47+
# @example Get the expiration time
48+
# authenticator.expires_at
49+
attr_accessor :expires_at
50+
51+
# Initialize a new OAuth 2.0 authenticator
52+
#
53+
# @api public
54+
# @param client_id [String] the OAuth 2.0 client ID
55+
# @param client_secret [String] the OAuth 2.0 client secret
56+
# @param access_token [String] the OAuth 2.0 access token
57+
# @param refresh_token [String] the OAuth 2.0 refresh token
58+
# @param expires_at [Time, nil] the expiration time of the access token
59+
# @return [OAuth2Authenticator] a new authenticator instance
60+
# @example Create an authenticator
61+
# authenticator = X::OAuth2Authenticator.new(
62+
# client_id: "id",
63+
# client_secret: "secret",
64+
# access_token: "token",
65+
# refresh_token: "refresh"
66+
# )
67+
def initialize(client_id:, client_secret:, access_token:, refresh_token:, expires_at: nil) # rubocop:disable Lint/MissingSuper
68+
@client_id = client_id
69+
@client_secret = client_secret
70+
@access_token = access_token
71+
@refresh_token = refresh_token
72+
@expires_at = expires_at
73+
end
74+
75+
# Generate the authentication header
76+
#
77+
# @api public
78+
# @param _request [Net::HTTPRequest, nil] the HTTP request (unused)
79+
# @return [Hash{String => String}] the authentication header
80+
# @example Get the header
81+
# authenticator.header(request)
82+
def header(_request)
83+
{AUTHENTICATION_HEADER => "Bearer #{access_token}"}
84+
end
85+
86+
# Check if the access token has expired
87+
#
88+
# @api public
89+
# @return [Boolean] true if the token has expired
90+
# @example Check expiration
91+
# authenticator.token_expired?
92+
def token_expired?
93+
return false if expires_at.nil?
94+
95+
Time.now >= expires_at
96+
end
97+
98+
# Refresh the access token using the refresh token
99+
#
100+
# @api public
101+
# @return [Hash{String => Object}] the token response
102+
# @raise [Error] if token refresh fails
103+
# @example Refresh the token
104+
# authenticator.refresh_token!
105+
def refresh_token!
106+
response = send_token_request
107+
handle_token_response(response)
108+
end
109+
110+
private
111+
112+
# Send the token refresh request
113+
# @api private
114+
# @return [Net::HTTPResponse] the HTTP response
115+
def send_token_request
116+
http = build_http_client
117+
request = build_token_request
118+
http.request(request)
119+
end
120+
121+
# Build the HTTP client for token refresh
122+
# @api private
123+
# @return [Net::HTTP] the HTTP client
124+
def build_http_client
125+
http = Net::HTTP.new(TOKEN_HOST, TOKEN_PORT)
126+
http.use_ssl = true
127+
http
128+
end
129+
130+
# Build the token refresh request
131+
# @api private
132+
# @return [Net::HTTP::Post] the POST request
133+
def build_token_request
134+
request = Net::HTTP::Post.new(TOKEN_URL)
135+
request["Content-Type"] = "application/x-www-form-urlencoded"
136+
request["Authorization"] = "Basic #{Base64.strict_encode64("#{client_id}:#{client_secret}")}"
137+
request.body = URI.encode_www_form(grant_type: REFRESH_GRANT_TYPE, refresh_token: refresh_token)
138+
request
139+
end
140+
141+
# Handle the token response
142+
# @api private
143+
# @param response [Net::HTTPResponse] the HTTP response
144+
# @return [Hash{String => Object}] the parsed response body
145+
# @raise [Error] if the response indicates an error
146+
def handle_token_response(response)
147+
body = JSON.parse(response.body)
148+
unless response.is_a?(Net::HTTPSuccess)
149+
error_message = body["error_description"] || body["error"] || "Token refresh failed"
150+
raise Error, error_message
151+
end
152+
update_tokens(body)
153+
body
154+
end
155+
156+
# Update tokens from the response
157+
# @api private
158+
# @param token_response [Hash{String => Object}] the token response
159+
# @return [void]
160+
def update_tokens(token_response)
161+
@access_token = token_response.fetch("access_token")
162+
@refresh_token = token_response.fetch("refresh_token") if token_response.key?("refresh_token")
163+
@expires_at = Time.now + token_response.fetch("expires_in") if token_response.key?("expires_in")
164+
end
165+
end
166+
end

sig/x.rbs

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -208,22 +208,53 @@ module X
208208
def json?: (Net::HTTPResponse response) -> bool
209209
end
210210

211+
class OAuth2Authenticator < Authenticator
212+
TOKEN_URL: String
213+
TOKEN_HOST: String
214+
TOKEN_PORT: Integer
215+
REFRESH_GRANT_TYPE: String
216+
217+
attr_accessor client_id: String
218+
attr_accessor client_secret: String
219+
attr_accessor access_token: String
220+
attr_accessor refresh_token: String
221+
attr_accessor expires_at: Time?
222+
def initialize: (client_id: String, client_secret: String, access_token: String, refresh_token: String, ?expires_at: Time?) -> void
223+
def header: (Net::HTTPRequest? request) -> Hash[String, String]
224+
def token_expired?: -> bool
225+
def refresh_token!: -> Hash[String, untyped]
226+
227+
private
228+
def send_token_request: -> Net::HTTPResponse
229+
def build_http_client: -> Net::HTTP
230+
def build_token_request: -> Net::HTTP::Post
231+
def handle_token_response: (Net::HTTPResponse response) -> Hash[String, untyped]
232+
def update_tokens: (Hash[String, untyped] token_response) -> void
233+
end
234+
211235
module ClientCredentials
212236
attr_reader api_key: String?
213237
attr_reader api_key_secret: String?
214238
attr_reader access_token: String?
215239
attr_reader access_token_secret: String?
216240
attr_reader bearer_token: String?
241+
attr_reader client_id: String?
242+
attr_reader client_secret: String?
243+
attr_reader refresh_token: String?
217244
def api_key=: (String api_key) -> void
218245
def api_key_secret=: (String api_key_secret) -> void
219246
def access_token=: (String access_token) -> void
220247
def access_token_secret=: (String access_token_secret) -> void
221248
def bearer_token=: (String bearer_token) -> void
249+
def client_id=: (String client_id) -> void
250+
def client_secret=: (String client_secret) -> void
251+
def refresh_token=: (String refresh_token) -> void
222252

223253
private
224-
def initialize_credentials: (api_key: String?, api_key_secret: String?, access_token: String?, access_token_secret: String?, bearer_token: String?) -> void
225-
def initialize_authenticator: -> (Authenticator | BearerTokenAuthenticator | OAuthAuthenticator)
254+
def initialize_credentials: (api_key: String?, api_key_secret: String?, access_token: String?, access_token_secret: String?, bearer_token: String?, client_id: String?, client_secret: String?, refresh_token: String?) -> void
255+
def initialize_authenticator: -> (Authenticator | BearerTokenAuthenticator | OAuthAuthenticator | OAuth2Authenticator)
226256
def oauth_authenticator: -> OAuthAuthenticator?
257+
def oauth2_authenticator: -> OAuth2Authenticator?
227258
def bearer_authenticator: -> BearerTokenAuthenticator?
228259
end
229260

@@ -234,7 +265,7 @@ module X
234265
DEFAULT_ARRAY_CLASS: singleton(Array)
235266
DEFAULT_OBJECT_CLASS: singleton(Hash)
236267
extend Forwardable
237-
@authenticator: Authenticator | BearerTokenAuthenticator | OAuthAuthenticator
268+
@authenticator: Authenticator | BearerTokenAuthenticator | OAuthAuthenticator | OAuth2Authenticator
238269
@connection: Connection
239270
@request_builder: RequestBuilder
240271
@redirect_handler: RedirectHandler
@@ -243,7 +274,7 @@ module X
243274
attr_accessor base_url: String
244275
attr_accessor default_array_class: singleton(Array)
245276
attr_accessor default_object_class: singleton(Hash)
246-
def initialize: (?api_key: String?, ?api_key_secret: String?, ?access_token: String?, ?access_token_secret: String?, ?bearer_token: String?, ?base_url: String, ?open_timeout: Integer, ?read_timeout: Integer, ?write_timeout: Integer, ?debug_output: untyped, ?proxy_url: String?, ?default_array_class: singleton(Array), ?default_object_class: singleton(Hash), ?max_redirects: Integer) -> void
277+
def initialize: (?api_key: String?, ?api_key_secret: String?, ?access_token: String?, ?access_token_secret: String?, ?bearer_token: String?, ?client_id: String?, ?client_secret: String?, ?refresh_token: String?, ?base_url: String, ?open_timeout: Integer, ?read_timeout: Integer, ?write_timeout: Integer, ?debug_output: untyped, ?proxy_url: String?, ?default_array_class: singleton(Array), ?default_object_class: singleton(Hash), ?max_redirects: Integer) -> void
247278
def get: (String endpoint, ?headers: Hash[String, String], ?array_class: Class, ?object_class: Class) -> untyped
248279
def post: (String endpoint, ?String? body, ?headers: Hash[String, String], ?array_class: Class, ?object_class: Class) -> untyped
249280
def put: (String endpoint, ?String? body, ?headers: Hash[String, String], ?array_class: Class, ?object_class: Class) -> untyped

0 commit comments

Comments
 (0)