ruby_llm-mcp 0.7.1 → 0.8.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/lib/generators/ruby_llm/mcp/{install_generator.rb → install/install_generator.rb} +4 -2
- data/lib/generators/ruby_llm/mcp/oauth/install_generator.rb +354 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/concerns/mcp_token_storage.rb.tt +114 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/concerns/user_mcp_oauth_concern.rb.tt +90 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/controllers/mcp_connections_controller.rb.tt +239 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/jobs/cleanup_expired_oauth_states_job.rb.tt +27 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/jobs/example_job.rb.tt +78 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/lib/mcp_client.rb.tt +68 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_credentials.rb.tt +19 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_states.rb.tt +21 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/models/mcp_oauth_credential.rb.tt +54 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/models/mcp_oauth_state.rb.tt +30 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/views/index.html.erb +646 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/views/show.html.erb +560 -0
- data/lib/ruby_llm/mcp/auth/browser/callback_handler.rb +71 -0
- data/lib/ruby_llm/mcp/auth/browser/callback_server.rb +30 -0
- data/lib/ruby_llm/mcp/auth/browser/http_server.rb +115 -0
- data/lib/ruby_llm/mcp/auth/browser/opener.rb +41 -0
- data/lib/ruby_llm/mcp/auth/browser/pages.rb +539 -0
- data/lib/ruby_llm/mcp/auth/browser_oauth_provider.rb +254 -0
- data/lib/ruby_llm/mcp/auth/client_registrar.rb +170 -0
- data/lib/ruby_llm/mcp/auth/discoverer.rb +124 -0
- data/lib/ruby_llm/mcp/auth/flows/authorization_code_flow.rb +105 -0
- data/lib/ruby_llm/mcp/auth/flows/client_credentials_flow.rb +66 -0
- data/lib/ruby_llm/mcp/auth/grant_strategies/authorization_code.rb +31 -0
- data/lib/ruby_llm/mcp/auth/grant_strategies/base.rb +31 -0
- data/lib/ruby_llm/mcp/auth/grant_strategies/client_credentials.rb +31 -0
- data/lib/ruby_llm/mcp/auth/http_response_handler.rb +65 -0
- data/lib/ruby_llm/mcp/auth/memory_storage.rb +72 -0
- data/lib/ruby_llm/mcp/auth/oauth_provider.rb +226 -0
- data/lib/ruby_llm/mcp/auth/security.rb +44 -0
- data/lib/ruby_llm/mcp/auth/session_manager.rb +56 -0
- data/lib/ruby_llm/mcp/auth/token_manager.rb +236 -0
- data/lib/ruby_llm/mcp/auth/url_builder.rb +78 -0
- data/lib/ruby_llm/mcp/auth.rb +359 -0
- data/lib/ruby_llm/mcp/client.rb +49 -0
- data/lib/ruby_llm/mcp/configuration.rb +39 -13
- data/lib/ruby_llm/mcp/coordinator.rb +11 -0
- data/lib/ruby_llm/mcp/errors.rb +11 -0
- data/lib/ruby_llm/mcp/railtie.rb +2 -10
- data/lib/ruby_llm/mcp/tool.rb +1 -1
- data/lib/ruby_llm/mcp/transport.rb +94 -1
- data/lib/ruby_llm/mcp/transports/sse.rb +116 -22
- data/lib/ruby_llm/mcp/transports/stdio.rb +4 -3
- data/lib/ruby_llm/mcp/transports/streamable_http.rb +81 -79
- data/lib/ruby_llm/mcp/version.rb +1 -1
- data/lib/ruby_llm/mcp.rb +10 -4
- metadata +40 -5
- /data/lib/generators/ruby_llm/mcp/{templates → install/templates}/initializer.rb +0 -0
- /data/lib/generators/ruby_llm/mcp/{templates → install/templates}/mcps.yml +0 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module MCP
|
|
5
|
+
module Auth
|
|
6
|
+
# Service for managing OAuth token operations
|
|
7
|
+
# Handles token exchange, refresh, and client credentials flows
|
|
8
|
+
class TokenManager
|
|
9
|
+
attr_reader :http_client, :logger
|
|
10
|
+
|
|
11
|
+
def initialize(http_client, logger)
|
|
12
|
+
@http_client = http_client
|
|
13
|
+
@logger = logger
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Exchange authorization code for access token
|
|
17
|
+
# @param server_metadata [ServerMetadata] server metadata
|
|
18
|
+
# @param client_info [ClientInfo] client info
|
|
19
|
+
# @param code [String] authorization code
|
|
20
|
+
# @param pkce [PKCE] PKCE parameters
|
|
21
|
+
# @param server_url [String] MCP server URL
|
|
22
|
+
# @return [Token] access token
|
|
23
|
+
def exchange_authorization_code(server_metadata, client_info, code, pkce, server_url)
|
|
24
|
+
logger.debug("Exchanging authorization code for access token")
|
|
25
|
+
|
|
26
|
+
registered_redirect_uri = client_info.metadata.redirect_uris.first
|
|
27
|
+
params = build_auth_code_params(client_info, code, pkce, registered_redirect_uri, server_url)
|
|
28
|
+
|
|
29
|
+
response = post_token_exchange(server_metadata, params)
|
|
30
|
+
response = retry_if_redirect_mismatch(response, server_metadata, params, registered_redirect_uri)
|
|
31
|
+
|
|
32
|
+
validate_token_response!(response, "Token exchange")
|
|
33
|
+
parse_token_response(response)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Exchange client credentials for access token
|
|
37
|
+
# @param server_metadata [ServerMetadata] server metadata
|
|
38
|
+
# @param client_info [ClientInfo] client info with secret
|
|
39
|
+
# @param scope [String, nil] requested scope
|
|
40
|
+
# @param server_url [String] MCP server URL
|
|
41
|
+
# @return [Token] access token
|
|
42
|
+
def exchange_client_credentials(server_metadata, client_info, scope, server_url)
|
|
43
|
+
logger.debug("Exchanging client credentials for access token")
|
|
44
|
+
|
|
45
|
+
params = {
|
|
46
|
+
grant_type: "client_credentials",
|
|
47
|
+
client_id: client_info.client_id,
|
|
48
|
+
client_secret: client_info.client_secret,
|
|
49
|
+
scope: scope,
|
|
50
|
+
resource: server_url
|
|
51
|
+
}.compact
|
|
52
|
+
|
|
53
|
+
response = post_token_exchange(server_metadata, params)
|
|
54
|
+
validate_token_response!(response, "Token exchange")
|
|
55
|
+
parse_token_response(response)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Refresh access token using refresh token
|
|
59
|
+
# @param server_metadata [ServerMetadata] server metadata
|
|
60
|
+
# @param client_info [ClientInfo] client info
|
|
61
|
+
# @param token [Token] current token with refresh_token
|
|
62
|
+
# @param server_url [String] MCP server URL
|
|
63
|
+
# @return [Token, nil] new token or nil if refresh failed
|
|
64
|
+
def refresh_token(server_metadata, client_info, token, server_url)
|
|
65
|
+
return nil unless token.refresh_token
|
|
66
|
+
|
|
67
|
+
logger.debug("Refreshing access token")
|
|
68
|
+
|
|
69
|
+
params = build_refresh_params(client_info, token, server_url)
|
|
70
|
+
response = post_token_refresh(server_metadata, params)
|
|
71
|
+
|
|
72
|
+
# Return nil on error responses
|
|
73
|
+
return nil if response.is_a?(HTTPX::ErrorResponse)
|
|
74
|
+
return nil unless response.status == 200
|
|
75
|
+
|
|
76
|
+
parse_refresh_response(response, token)
|
|
77
|
+
rescue JSON::ParserError => e
|
|
78
|
+
logger.warn("Invalid token refresh response: #{e.message}")
|
|
79
|
+
nil
|
|
80
|
+
rescue HTTPX::Error => e
|
|
81
|
+
logger.warn("Network error during token refresh: #{e.message}")
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
# Build parameters for authorization code exchange
|
|
88
|
+
# @param client_info [ClientInfo] client info
|
|
89
|
+
# @param code [String] authorization code
|
|
90
|
+
# @param pkce [PKCE] PKCE parameters
|
|
91
|
+
# @param redirect_uri [String] redirect URI
|
|
92
|
+
# @param server_url [String] MCP server URL
|
|
93
|
+
# @return [Hash] token exchange parameters
|
|
94
|
+
def build_auth_code_params(client_info, code, pkce, redirect_uri, server_url)
|
|
95
|
+
params = {
|
|
96
|
+
grant_type: "authorization_code",
|
|
97
|
+
code: code,
|
|
98
|
+
redirect_uri: redirect_uri,
|
|
99
|
+
client_id: client_info.client_id,
|
|
100
|
+
code_verifier: pkce.code_verifier,
|
|
101
|
+
resource: server_url
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
add_client_secret_if_needed(params, client_info)
|
|
105
|
+
params
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Build parameters for token refresh
|
|
109
|
+
# @param client_info [ClientInfo] client info
|
|
110
|
+
# @param token [Token] current token
|
|
111
|
+
# @param server_url [String] MCP server URL
|
|
112
|
+
# @return [Hash] refresh parameters
|
|
113
|
+
def build_refresh_params(client_info, token, server_url)
|
|
114
|
+
params = {
|
|
115
|
+
grant_type: "refresh_token",
|
|
116
|
+
refresh_token: token.refresh_token,
|
|
117
|
+
client_id: client_info.client_id,
|
|
118
|
+
resource: server_url
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
add_client_secret_if_needed(params, client_info)
|
|
122
|
+
params
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Add client secret to params if needed
|
|
126
|
+
# @param params [Hash] token request parameters
|
|
127
|
+
# @param client_info [ClientInfo] client info
|
|
128
|
+
def add_client_secret_if_needed(params, client_info)
|
|
129
|
+
return unless client_info.client_secret
|
|
130
|
+
return unless client_info.metadata.token_endpoint_auth_method == "client_secret_post"
|
|
131
|
+
|
|
132
|
+
params[:client_secret] = client_info.client_secret
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Post token exchange request
|
|
136
|
+
# @param server_metadata [ServerMetadata] server metadata
|
|
137
|
+
# @param params [Hash] form parameters
|
|
138
|
+
# @return [HTTPX::Response] HTTP response
|
|
139
|
+
def post_token_exchange(server_metadata, params)
|
|
140
|
+
http_client.post(
|
|
141
|
+
server_metadata.token_endpoint,
|
|
142
|
+
headers: { "Content-Type" => "application/x-www-form-urlencoded" },
|
|
143
|
+
form: params
|
|
144
|
+
)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Post token refresh request
|
|
148
|
+
# @param server_metadata [ServerMetadata] server metadata
|
|
149
|
+
# @param params [Hash] form parameters
|
|
150
|
+
# @return [HTTPX::Response] HTTP response
|
|
151
|
+
def post_token_refresh(server_metadata, params)
|
|
152
|
+
response = http_client.post(
|
|
153
|
+
server_metadata.token_endpoint,
|
|
154
|
+
headers: { "Content-Type" => "application/x-www-form-urlencoded" },
|
|
155
|
+
form: params
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
if response.is_a?(HTTPX::ErrorResponse)
|
|
159
|
+
logger.warn("Token refresh failed: #{response.error&.message || 'Request failed'}")
|
|
160
|
+
elsif response.status != 200
|
|
161
|
+
logger.warn("Token refresh failed: HTTP #{response.status}")
|
|
162
|
+
end
|
|
163
|
+
response
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Retry token exchange if redirect URI mismatch detected
|
|
167
|
+
# @param response [HTTPX::Response] initial response
|
|
168
|
+
# @param server_metadata [ServerMetadata] server metadata
|
|
169
|
+
# @param params [Hash] exchange parameters
|
|
170
|
+
# @param registered_redirect_uri [String] registered redirect URI
|
|
171
|
+
# @return [HTTPX::Response] response (possibly retried)
|
|
172
|
+
def retry_if_redirect_mismatch(response, server_metadata, params, registered_redirect_uri)
|
|
173
|
+
# Don't retry on error responses
|
|
174
|
+
return response if response.is_a?(HTTPX::ErrorResponse)
|
|
175
|
+
return response if response.status == 200
|
|
176
|
+
|
|
177
|
+
redirect_hint = HttpResponseHandler.extract_redirect_mismatch(response.body.to_s)
|
|
178
|
+
return response unless redirect_hint
|
|
179
|
+
return response if redirect_hint[:expected] == registered_redirect_uri
|
|
180
|
+
|
|
181
|
+
logger.warn("Redirect URI mismatch, retrying with: #{redirect_hint[:expected]}")
|
|
182
|
+
params[:redirect_uri] = redirect_hint[:expected]
|
|
183
|
+
post_token_exchange(server_metadata, params)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Validate token response
|
|
187
|
+
# @param response [HTTPX::Response, HTTPX::ErrorResponse] HTTP response
|
|
188
|
+
# @param context [String] context for error messages
|
|
189
|
+
# @raise [Errors::TransportError] if response is invalid
|
|
190
|
+
def validate_token_response!(response, context)
|
|
191
|
+
# Handle HTTPX ErrorResponse
|
|
192
|
+
if response.is_a?(HTTPX::ErrorResponse)
|
|
193
|
+
error_message = response.error&.message || "Request failed"
|
|
194
|
+
raise Errors::TransportError.new(message: "#{context} failed: #{error_message}")
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
return if response.status == 200
|
|
198
|
+
|
|
199
|
+
raise Errors::TransportError.new(
|
|
200
|
+
message: "#{context} failed: HTTP #{response.status}",
|
|
201
|
+
code: response.status
|
|
202
|
+
)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Parse token response
|
|
206
|
+
# @param response [HTTPX::Response] HTTP response
|
|
207
|
+
# @return [Token] parsed token
|
|
208
|
+
def parse_token_response(response)
|
|
209
|
+
data = JSON.parse(response.body.to_s)
|
|
210
|
+
Token.new(
|
|
211
|
+
access_token: data["access_token"],
|
|
212
|
+
token_type: data["token_type"] || "Bearer",
|
|
213
|
+
expires_in: data["expires_in"],
|
|
214
|
+
scope: data["scope"],
|
|
215
|
+
refresh_token: data["refresh_token"]
|
|
216
|
+
)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Parse refresh response, preserving old refresh token if not provided
|
|
220
|
+
# @param response [HTTPX::Response] HTTP response
|
|
221
|
+
# @param old_token [Token] previous token
|
|
222
|
+
# @return [Token] new token
|
|
223
|
+
def parse_refresh_response(response, old_token)
|
|
224
|
+
data = JSON.parse(response.body.to_s)
|
|
225
|
+
Token.new(
|
|
226
|
+
access_token: data["access_token"],
|
|
227
|
+
token_type: data["token_type"] || "Bearer",
|
|
228
|
+
expires_in: data["expires_in"],
|
|
229
|
+
scope: data["scope"],
|
|
230
|
+
refresh_token: data["refresh_token"] || old_token.refresh_token
|
|
231
|
+
)
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module RubyLLM
|
|
6
|
+
module MCP
|
|
7
|
+
module Auth
|
|
8
|
+
# Utility class for building OAuth URLs
|
|
9
|
+
# Handles discovery URLs, authorization URLs, and URL normalization
|
|
10
|
+
class UrlBuilder
|
|
11
|
+
# Build discovery URL for OAuth server metadata
|
|
12
|
+
# @param server_url [String] MCP server URL
|
|
13
|
+
# @param discovery_type [Symbol] :authorization_server or :protected_resource
|
|
14
|
+
# @return [String] discovery URL
|
|
15
|
+
def self.build_discovery_url(server_url, discovery_type = :authorization_server)
|
|
16
|
+
uri = URI.parse(server_url)
|
|
17
|
+
|
|
18
|
+
# Extract ONLY origin (scheme + host + port)
|
|
19
|
+
origin = "#{uri.scheme}://#{uri.host}"
|
|
20
|
+
origin += ":#{uri.port}" if uri.port && !default_port?(uri)
|
|
21
|
+
|
|
22
|
+
# Two discovery endpoints supported
|
|
23
|
+
endpoint = if discovery_type == :authorization_server
|
|
24
|
+
"oauth-authorization-server"
|
|
25
|
+
else
|
|
26
|
+
"oauth-protected-resource"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
"#{origin}/.well-known/#{endpoint}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Build OAuth authorization URL
|
|
33
|
+
# @param authorization_endpoint [String] auth server endpoint
|
|
34
|
+
# @param client_id [String] client ID
|
|
35
|
+
# @param redirect_uri [String] redirect URI
|
|
36
|
+
# @param scope [String, nil] requested scope
|
|
37
|
+
# @param state [String] CSRF state
|
|
38
|
+
# @param pkce [PKCE] PKCE parameters
|
|
39
|
+
# @param resource [String] resource indicator (RFC 8707)
|
|
40
|
+
# @return [String] authorization URL
|
|
41
|
+
def self.build_authorization_url(authorization_endpoint, client_id, redirect_uri, scope, state, pkce, resource) # rubocop:disable Metrics/ParameterLists
|
|
42
|
+
params = {
|
|
43
|
+
response_type: "code",
|
|
44
|
+
client_id: client_id,
|
|
45
|
+
redirect_uri: redirect_uri,
|
|
46
|
+
scope: scope,
|
|
47
|
+
state: state, # CSRF protection
|
|
48
|
+
code_challenge: pkce.code_challenge,
|
|
49
|
+
code_challenge_method: pkce.code_challenge_method, # S256
|
|
50
|
+
resource: resource # RFC 8707 - Resource Indicators
|
|
51
|
+
}.compact
|
|
52
|
+
|
|
53
|
+
uri = URI.parse(authorization_endpoint)
|
|
54
|
+
uri.query = URI.encode_www_form(params)
|
|
55
|
+
uri.to_s
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Get authorization base URL from server URL
|
|
59
|
+
# @param server_url [String] MCP server URL
|
|
60
|
+
# @return [String] authorization base URL (scheme + host + port)
|
|
61
|
+
def self.get_authorization_base_url(server_url)
|
|
62
|
+
uri = URI.parse(server_url)
|
|
63
|
+
origin = "#{uri.scheme}://#{uri.host}"
|
|
64
|
+
origin += ":#{uri.port}" if uri.port && !default_port?(uri)
|
|
65
|
+
origin
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Check if port is default for scheme
|
|
69
|
+
# @param uri [URI] parsed URI
|
|
70
|
+
# @return [Boolean] true if default port
|
|
71
|
+
def self.default_port?(uri)
|
|
72
|
+
(uri.scheme == "http" && uri.port == 80) ||
|
|
73
|
+
(uri.scheme == "https" && uri.port == 443)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "digest/sha2"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "time"
|
|
7
|
+
|
|
8
|
+
module RubyLLM
|
|
9
|
+
module MCP
|
|
10
|
+
module Auth
|
|
11
|
+
# OAuth configuration constants
|
|
12
|
+
# Token refresh buffer time in seconds (5 minutes)
|
|
13
|
+
TOKEN_REFRESH_BUFFER = 300
|
|
14
|
+
|
|
15
|
+
# Default OAuth timeout in seconds (30 seconds)
|
|
16
|
+
DEFAULT_OAUTH_TIMEOUT = 30
|
|
17
|
+
|
|
18
|
+
# CSRF state parameter size in bytes (32 bytes)
|
|
19
|
+
CSRF_STATE_SIZE = 32
|
|
20
|
+
|
|
21
|
+
# PKCE code verifier size in bytes (32 bytes)
|
|
22
|
+
PKCE_VERIFIER_SIZE = 32
|
|
23
|
+
|
|
24
|
+
# Represents an OAuth 2.1 access token with expiration tracking
|
|
25
|
+
class Token
|
|
26
|
+
attr_reader :access_token, :token_type, :expires_in, :scope, :refresh_token, :expires_at
|
|
27
|
+
|
|
28
|
+
def initialize(access_token:, token_type: "Bearer", expires_in: nil, scope: nil, refresh_token: nil)
|
|
29
|
+
@access_token = access_token
|
|
30
|
+
@token_type = token_type
|
|
31
|
+
@expires_in = expires_in
|
|
32
|
+
@scope = scope
|
|
33
|
+
@refresh_token = refresh_token
|
|
34
|
+
@expires_at = expires_in ? Time.now + expires_in : nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Check if token has expired
|
|
38
|
+
# @return [Boolean] true if token is expired
|
|
39
|
+
def expired?
|
|
40
|
+
return false unless @expires_at
|
|
41
|
+
|
|
42
|
+
Time.now >= @expires_at
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Check if token expires soon (within configured buffer)
|
|
46
|
+
# This enables proactive token refresh
|
|
47
|
+
# @return [Boolean] true if token expires within the buffer period
|
|
48
|
+
def expires_soon?
|
|
49
|
+
return false unless @expires_at
|
|
50
|
+
|
|
51
|
+
Time.now >= (@expires_at - TOKEN_REFRESH_BUFFER)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Format token for Authorization header
|
|
55
|
+
# @return [String] formatted as "Bearer {access_token}"
|
|
56
|
+
def to_header
|
|
57
|
+
"#{@token_type} #{@access_token}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Serialize token to hash
|
|
61
|
+
# @return [Hash] token data
|
|
62
|
+
def to_h
|
|
63
|
+
{
|
|
64
|
+
access_token: @access_token,
|
|
65
|
+
token_type: @token_type,
|
|
66
|
+
expires_in: @expires_in,
|
|
67
|
+
scope: @scope,
|
|
68
|
+
refresh_token: @refresh_token,
|
|
69
|
+
expires_at: @expires_at&.iso8601
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Deserialize token from hash
|
|
74
|
+
# @param data [Hash] token data
|
|
75
|
+
# @return [Token] new token instance
|
|
76
|
+
def self.from_h(data)
|
|
77
|
+
token = new(
|
|
78
|
+
access_token: data[:access_token] || data["access_token"],
|
|
79
|
+
token_type: data[:token_type] || data["token_type"] || "Bearer",
|
|
80
|
+
expires_in: data[:expires_in] || data["expires_in"],
|
|
81
|
+
scope: data[:scope] || data["scope"],
|
|
82
|
+
refresh_token: data[:refresh_token] || data["refresh_token"]
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Restore expires_at if present
|
|
86
|
+
expires_at_str = data[:expires_at] || data["expires_at"]
|
|
87
|
+
if expires_at_str
|
|
88
|
+
token.instance_variable_set(:@expires_at, Time.parse(expires_at_str))
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
token
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Client metadata for dynamic client registration (RFC 7591)
|
|
96
|
+
# Supports all optional parameters from the specification
|
|
97
|
+
class ClientMetadata
|
|
98
|
+
attr_reader :redirect_uris, :token_endpoint_auth_method, :grant_types, :response_types, :scope,
|
|
99
|
+
:client_name, :client_uri, :logo_uri, :contacts, :tos_uri, :policy_uri,
|
|
100
|
+
:jwks_uri, :jwks, :software_id, :software_version
|
|
101
|
+
|
|
102
|
+
def initialize( # rubocop:disable Metrics/ParameterLists
|
|
103
|
+
redirect_uris:,
|
|
104
|
+
token_endpoint_auth_method: "none",
|
|
105
|
+
grant_types: %w[authorization_code refresh_token],
|
|
106
|
+
response_types: ["code"],
|
|
107
|
+
scope: nil,
|
|
108
|
+
client_name: nil,
|
|
109
|
+
client_uri: nil,
|
|
110
|
+
logo_uri: nil,
|
|
111
|
+
contacts: nil,
|
|
112
|
+
tos_uri: nil,
|
|
113
|
+
policy_uri: nil,
|
|
114
|
+
jwks_uri: nil,
|
|
115
|
+
jwks: nil,
|
|
116
|
+
software_id: nil,
|
|
117
|
+
software_version: nil
|
|
118
|
+
)
|
|
119
|
+
@redirect_uris = redirect_uris
|
|
120
|
+
@token_endpoint_auth_method = token_endpoint_auth_method
|
|
121
|
+
@grant_types = grant_types
|
|
122
|
+
@response_types = response_types
|
|
123
|
+
@scope = scope
|
|
124
|
+
@client_name = client_name
|
|
125
|
+
@client_uri = client_uri
|
|
126
|
+
@logo_uri = logo_uri
|
|
127
|
+
@contacts = contacts
|
|
128
|
+
@tos_uri = tos_uri
|
|
129
|
+
@policy_uri = policy_uri
|
|
130
|
+
@jwks_uri = jwks_uri
|
|
131
|
+
@jwks = jwks
|
|
132
|
+
@software_id = software_id
|
|
133
|
+
@software_version = software_version
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Convert to hash for registration request
|
|
137
|
+
# @return [Hash] client metadata
|
|
138
|
+
def to_h
|
|
139
|
+
{
|
|
140
|
+
redirect_uris: @redirect_uris,
|
|
141
|
+
token_endpoint_auth_method: @token_endpoint_auth_method,
|
|
142
|
+
grant_types: @grant_types,
|
|
143
|
+
response_types: @response_types,
|
|
144
|
+
scope: @scope,
|
|
145
|
+
client_name: @client_name,
|
|
146
|
+
client_uri: @client_uri,
|
|
147
|
+
logo_uri: @logo_uri,
|
|
148
|
+
contacts: @contacts,
|
|
149
|
+
tos_uri: @tos_uri,
|
|
150
|
+
policy_uri: @policy_uri,
|
|
151
|
+
jwks_uri: @jwks_uri,
|
|
152
|
+
jwks: @jwks,
|
|
153
|
+
software_id: @software_id,
|
|
154
|
+
software_version: @software_version
|
|
155
|
+
}.compact
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Registered client information from authorization server
|
|
160
|
+
class ClientInfo
|
|
161
|
+
attr_reader :client_id, :client_secret, :client_id_issued_at, :client_secret_expires_at, :metadata
|
|
162
|
+
|
|
163
|
+
def initialize(client_id:, client_secret: nil, client_id_issued_at: nil, client_secret_expires_at: nil,
|
|
164
|
+
metadata: nil)
|
|
165
|
+
@client_id = client_id
|
|
166
|
+
@client_secret = client_secret
|
|
167
|
+
@client_id_issued_at = client_id_issued_at
|
|
168
|
+
@client_secret_expires_at = client_secret_expires_at
|
|
169
|
+
@metadata = metadata
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Check if client secret has expired
|
|
173
|
+
# @return [Boolean] true if client secret is expired
|
|
174
|
+
def client_secret_expired?
|
|
175
|
+
return false unless @client_secret_expires_at
|
|
176
|
+
|
|
177
|
+
Time.now.to_i >= @client_secret_expires_at
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Serialize to hash
|
|
181
|
+
# @return [Hash] client info
|
|
182
|
+
def to_h
|
|
183
|
+
{
|
|
184
|
+
client_id: @client_id,
|
|
185
|
+
client_secret: @client_secret,
|
|
186
|
+
client_id_issued_at: @client_id_issued_at,
|
|
187
|
+
client_secret_expires_at: @client_secret_expires_at,
|
|
188
|
+
metadata: @metadata&.to_h
|
|
189
|
+
}
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Deserialize from hash
|
|
193
|
+
# @param data [Hash] client info data
|
|
194
|
+
# @return [ClientInfo] new instance
|
|
195
|
+
def self.from_h(data)
|
|
196
|
+
metadata_data = data[:metadata] || data["metadata"]
|
|
197
|
+
metadata = if metadata_data
|
|
198
|
+
ClientMetadata.new(**metadata_data.transform_keys(&:to_sym))
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
new(
|
|
202
|
+
client_id: data[:client_id] || data["client_id"],
|
|
203
|
+
client_secret: data[:client_secret] || data["client_secret"],
|
|
204
|
+
client_id_issued_at: data[:client_id_issued_at] || data["client_id_issued_at"],
|
|
205
|
+
client_secret_expires_at: data[:client_secret_expires_at] || data["client_secret_expires_at"],
|
|
206
|
+
metadata: metadata
|
|
207
|
+
)
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# OAuth Authorization Server Metadata (RFC 8414)
|
|
212
|
+
class ServerMetadata
|
|
213
|
+
attr_reader :issuer, :authorization_endpoint, :token_endpoint, :registration_endpoint,
|
|
214
|
+
:scopes_supported, :response_types_supported, :grant_types_supported
|
|
215
|
+
|
|
216
|
+
def initialize(issuer:, authorization_endpoint:, token_endpoint:, options: {})
|
|
217
|
+
@issuer = issuer
|
|
218
|
+
@authorization_endpoint = authorization_endpoint
|
|
219
|
+
@token_endpoint = token_endpoint
|
|
220
|
+
@registration_endpoint = options[:registration_endpoint] || options["registration_endpoint"]
|
|
221
|
+
@scopes_supported = options[:scopes_supported] || options["scopes_supported"]
|
|
222
|
+
@response_types_supported = options[:response_types_supported] || options["response_types_supported"]
|
|
223
|
+
@grant_types_supported = options[:grant_types_supported] || options["grant_types_supported"]
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Check if dynamic client registration is supported
|
|
227
|
+
# @return [Boolean] true if registration endpoint exists
|
|
228
|
+
def supports_registration?
|
|
229
|
+
!@registration_endpoint.nil?
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Serialize to hash
|
|
233
|
+
# @return [Hash] server metadata
|
|
234
|
+
def to_h
|
|
235
|
+
{
|
|
236
|
+
issuer: @issuer,
|
|
237
|
+
authorization_endpoint: @authorization_endpoint,
|
|
238
|
+
token_endpoint: @token_endpoint,
|
|
239
|
+
registration_endpoint: @registration_endpoint,
|
|
240
|
+
scopes_supported: @scopes_supported,
|
|
241
|
+
response_types_supported: @response_types_supported,
|
|
242
|
+
grant_types_supported: @grant_types_supported
|
|
243
|
+
}.compact
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Deserialize from hash
|
|
247
|
+
# @param data [Hash] server metadata
|
|
248
|
+
# @return [ServerMetadata] new instance
|
|
249
|
+
def self.from_h(data)
|
|
250
|
+
new(
|
|
251
|
+
issuer: data[:issuer] || data["issuer"],
|
|
252
|
+
authorization_endpoint: data[:authorization_endpoint] || data["authorization_endpoint"],
|
|
253
|
+
token_endpoint: data[:token_endpoint] || data["token_endpoint"],
|
|
254
|
+
registration_endpoint: data[:registration_endpoint] || data["registration_endpoint"],
|
|
255
|
+
scopes_supported: data[:scopes_supported] || data["scopes_supported"],
|
|
256
|
+
response_types_supported: data[:response_types_supported] || data["response_types_supported"],
|
|
257
|
+
grant_types_supported: data[:grant_types_supported] || data["grant_types_supported"]
|
|
258
|
+
)
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# OAuth Protected Resource Metadata (RFC 9728)
|
|
263
|
+
# Used for authorization server delegation
|
|
264
|
+
class ResourceMetadata
|
|
265
|
+
attr_reader :resource, :authorization_servers
|
|
266
|
+
|
|
267
|
+
def initialize(resource:, authorization_servers:)
|
|
268
|
+
@resource = resource
|
|
269
|
+
@authorization_servers = authorization_servers
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Serialize to hash
|
|
273
|
+
# @return [Hash] resource metadata
|
|
274
|
+
def to_h
|
|
275
|
+
{
|
|
276
|
+
resource: @resource,
|
|
277
|
+
authorization_servers: @authorization_servers
|
|
278
|
+
}
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Deserialize from hash
|
|
282
|
+
# @param data [Hash] resource metadata
|
|
283
|
+
# @return [ResourceMetadata] new instance
|
|
284
|
+
def self.from_h(data)
|
|
285
|
+
new(
|
|
286
|
+
resource: data[:resource] || data["resource"],
|
|
287
|
+
authorization_servers: data[:authorization_servers] || data["authorization_servers"]
|
|
288
|
+
)
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Proof Key for Code Exchange (PKCE) implementation (RFC 7636)
|
|
293
|
+
# Required for OAuth 2.1 security
|
|
294
|
+
class PKCE
|
|
295
|
+
attr_reader :code_verifier, :code_challenge, :code_challenge_method
|
|
296
|
+
|
|
297
|
+
def initialize
|
|
298
|
+
@code_verifier = generate_code_verifier
|
|
299
|
+
@code_challenge = generate_code_challenge(@code_verifier)
|
|
300
|
+
@code_challenge_method = "S256" # SHA256 - only secure method for OAuth 2.1
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Serialize to hash
|
|
304
|
+
# @return [Hash] PKCE parameters
|
|
305
|
+
def to_h
|
|
306
|
+
{
|
|
307
|
+
code_verifier: @code_verifier,
|
|
308
|
+
code_challenge: @code_challenge,
|
|
309
|
+
code_challenge_method: @code_challenge_method
|
|
310
|
+
}
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Deserialize from hash
|
|
314
|
+
# @param data [Hash] PKCE data
|
|
315
|
+
# @return [PKCE] new instance
|
|
316
|
+
def self.from_h(data)
|
|
317
|
+
pkce = allocate
|
|
318
|
+
pkce.instance_variable_set(:@code_verifier, data[:code_verifier] || data["code_verifier"])
|
|
319
|
+
pkce.instance_variable_set(:@code_challenge, data[:code_challenge] || data["code_challenge"])
|
|
320
|
+
pkce.instance_variable_set(:@code_challenge_method,
|
|
321
|
+
data[:code_challenge_method] || data["code_challenge_method"] || "S256")
|
|
322
|
+
pkce
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
private
|
|
326
|
+
|
|
327
|
+
# Generate cryptographically secure code verifier
|
|
328
|
+
# @return [String] base64url-encoded random bytes
|
|
329
|
+
def generate_code_verifier
|
|
330
|
+
Base64.urlsafe_encode64(SecureRandom.random_bytes(PKCE_VERIFIER_SIZE), padding: false)
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# Generate code challenge from verifier using SHA256
|
|
334
|
+
# @param verifier [String] code verifier
|
|
335
|
+
# @return [String] base64url-encoded SHA256 hash
|
|
336
|
+
def generate_code_challenge(verifier)
|
|
337
|
+
digest = Digest::SHA256.digest(verifier)
|
|
338
|
+
Base64.urlsafe_encode64(digest, padding: false)
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# Factory method to create OAuth providers
|
|
343
|
+
# @param server_url [String] OAuth server URL
|
|
344
|
+
# @param type [Symbol] OAuth provider type (:standard or :browser)
|
|
345
|
+
# @param options [Hash] additional options passed to provider
|
|
346
|
+
# @return [OAuthProvider, BrowserOAuthProvider] OAuth provider instance
|
|
347
|
+
def self.create_oauth(server_url, type: :standard, **options)
|
|
348
|
+
case type
|
|
349
|
+
when :browser
|
|
350
|
+
BrowserOAuthProvider.new(server_url: server_url, **options)
|
|
351
|
+
when :standard
|
|
352
|
+
OAuthProvider.new(server_url: server_url, **options)
|
|
353
|
+
else
|
|
354
|
+
raise ArgumentError, "Unknown OAuth type: #{type}. Must be :standard or :browser"
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
end
|