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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/lib/generators/ruby_llm/mcp/{install_generator.rb → install/install_generator.rb} +4 -2
  3. data/lib/generators/ruby_llm/mcp/oauth/install_generator.rb +354 -0
  4. data/lib/generators/ruby_llm/mcp/oauth/templates/concerns/mcp_token_storage.rb.tt +114 -0
  5. data/lib/generators/ruby_llm/mcp/oauth/templates/concerns/user_mcp_oauth_concern.rb.tt +90 -0
  6. data/lib/generators/ruby_llm/mcp/oauth/templates/controllers/mcp_connections_controller.rb.tt +239 -0
  7. data/lib/generators/ruby_llm/mcp/oauth/templates/jobs/cleanup_expired_oauth_states_job.rb.tt +27 -0
  8. data/lib/generators/ruby_llm/mcp/oauth/templates/jobs/example_job.rb.tt +78 -0
  9. data/lib/generators/ruby_llm/mcp/oauth/templates/lib/mcp_client.rb.tt +68 -0
  10. data/lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_credentials.rb.tt +19 -0
  11. data/lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_states.rb.tt +21 -0
  12. data/lib/generators/ruby_llm/mcp/oauth/templates/models/mcp_oauth_credential.rb.tt +54 -0
  13. data/lib/generators/ruby_llm/mcp/oauth/templates/models/mcp_oauth_state.rb.tt +30 -0
  14. data/lib/generators/ruby_llm/mcp/oauth/templates/views/index.html.erb +646 -0
  15. data/lib/generators/ruby_llm/mcp/oauth/templates/views/show.html.erb +560 -0
  16. data/lib/ruby_llm/mcp/auth/browser/callback_handler.rb +71 -0
  17. data/lib/ruby_llm/mcp/auth/browser/callback_server.rb +30 -0
  18. data/lib/ruby_llm/mcp/auth/browser/http_server.rb +115 -0
  19. data/lib/ruby_llm/mcp/auth/browser/opener.rb +41 -0
  20. data/lib/ruby_llm/mcp/auth/browser/pages.rb +539 -0
  21. data/lib/ruby_llm/mcp/auth/browser_oauth_provider.rb +254 -0
  22. data/lib/ruby_llm/mcp/auth/client_registrar.rb +170 -0
  23. data/lib/ruby_llm/mcp/auth/discoverer.rb +124 -0
  24. data/lib/ruby_llm/mcp/auth/flows/authorization_code_flow.rb +105 -0
  25. data/lib/ruby_llm/mcp/auth/flows/client_credentials_flow.rb +66 -0
  26. data/lib/ruby_llm/mcp/auth/grant_strategies/authorization_code.rb +31 -0
  27. data/lib/ruby_llm/mcp/auth/grant_strategies/base.rb +31 -0
  28. data/lib/ruby_llm/mcp/auth/grant_strategies/client_credentials.rb +31 -0
  29. data/lib/ruby_llm/mcp/auth/http_response_handler.rb +65 -0
  30. data/lib/ruby_llm/mcp/auth/memory_storage.rb +72 -0
  31. data/lib/ruby_llm/mcp/auth/oauth_provider.rb +226 -0
  32. data/lib/ruby_llm/mcp/auth/security.rb +44 -0
  33. data/lib/ruby_llm/mcp/auth/session_manager.rb +56 -0
  34. data/lib/ruby_llm/mcp/auth/token_manager.rb +236 -0
  35. data/lib/ruby_llm/mcp/auth/url_builder.rb +78 -0
  36. data/lib/ruby_llm/mcp/auth.rb +359 -0
  37. data/lib/ruby_llm/mcp/client.rb +49 -0
  38. data/lib/ruby_llm/mcp/configuration.rb +39 -13
  39. data/lib/ruby_llm/mcp/coordinator.rb +11 -0
  40. data/lib/ruby_llm/mcp/errors.rb +11 -0
  41. data/lib/ruby_llm/mcp/railtie.rb +2 -10
  42. data/lib/ruby_llm/mcp/tool.rb +1 -1
  43. data/lib/ruby_llm/mcp/transport.rb +94 -1
  44. data/lib/ruby_llm/mcp/transports/sse.rb +116 -22
  45. data/lib/ruby_llm/mcp/transports/stdio.rb +4 -3
  46. data/lib/ruby_llm/mcp/transports/streamable_http.rb +81 -79
  47. data/lib/ruby_llm/mcp/version.rb +1 -1
  48. data/lib/ruby_llm/mcp.rb +10 -4
  49. metadata +40 -5
  50. /data/lib/generators/ruby_llm/mcp/{templates → install/templates}/initializer.rb +0 -0
  51. /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